diff --git a/benchmarks/ab.py b/benchmarks/ab.py
index 20e679dc5..4b9fb50e7 100644
--- a/benchmarks/ab.py
+++ b/benchmarks/ab.py
@@ -106,8 +106,8 @@ def arm_environment(settings: list[str]) -> dict[str, str]:
def arm_configuration(configuration: Record, backend: str) -> Record:
- """Return one arm's configuration: its backend, in double precision."""
- return {**configuration, "backend": backend, "precision": "double"}
+ """Return one arm's configuration: its backend."""
+ return {**configuration, "backend": backend}
def arm_order(round_index: int) -> tuple[str, ...]:
diff --git a/benchmarks/builders.py b/benchmarks/builders.py
index 0067aa1f9..2a838b813 100644
--- a/benchmarks/builders.py
+++ b/benchmarks/builders.py
@@ -80,21 +80,18 @@ def integrator(stepper: str) -> Multistep | RungeKutta | None:
def variant(configuration: Record) -> Record:
- """Return the keywords that turn a case into its backend, precision, noise and grid.
+ """Return the keywords that turn a case into its backend, noise and grid.
- ``precision`` is ``"double"`` unless the configuration names it. A single-precision
- run, or one asked for ``noise="none"``,
- is noiseless: no seed and no noise model, so that a cross-backend A/B compares
- one workload. ``fft_size="pow2"`` rebuilds the case's time grid at the next power
- of two; the transverse grids are kept, as their sizes set the physics.
+ A run asked for ``noise="none"`` is noiseless: no seed and no noise model, so that
+ a cross-backend A/B compares one workload. ``fft_size="pow2"`` rebuilds the case's
+ time grid at the next power of two; the transverse grids are kept, as their sizes
+ set the physics.
"""
- precision = configuration.get("precision") or "double"
- noiseless = precision == "single" or configuration.get("noise") == "none"
+ noiseless = configuration.get("noise") == "none"
fft_size = configuration.get("fft_size") or "case"
if fft_size not in ("case", "pow2"):
raise SuiteError(f"unknown fft_size {fft_size}")
return {
- "precision": precision,
"noiseless": noiseless,
"fft_size": fft_size,
"backend": configuration["backend"],
@@ -151,7 +148,6 @@ def capillary(name: str, options: Record | None = None) -> Simulation:
energy=275e-6,
modes=4 if name == "rdw" else 1,
mode_average=averaged,
- precision=options["precision"],
backend=options["backend"],
**_limits(options, 1.5, 800e-9, (90e-9, 4e-6), 400e-15),
**_launch(options),
@@ -167,7 +163,6 @@ def capillary(name: str, options: Record | None = None) -> Simulation:
energy=10e-6,
modes=3,
loss=0.0,
- precision=options["precision"],
backend=options["backend"],
**_limits(options, 0.13, 515e-9, (220e-9, 4e-6), 2e-12),
**_launch(options),
@@ -187,7 +182,6 @@ def capillary(name: str, options: Record | None = None) -> Simulation:
modes=1 if name == "ctc_reduced" else 2,
raman=True,
temperature=294.0,
- precision=options["precision"],
backend=options["backend"],
**_limits(options, 0.1, 515e-9, (250e-9, 2e-6), 2e-12),
**_launch(options),
@@ -205,7 +199,6 @@ def capillary(name: str, options: Record | None = None) -> Simulation:
modes=1,
envelope=name == "argon_blueshift_envelope",
mode_average=averaged,
- precision=options["precision"],
backend=options["backend"],
**_limits(
options,
@@ -230,7 +223,6 @@ def capillary(name: str, options: Record | None = None) -> Simulation:
plasma=False,
modes=1,
mode_average=True,
- precision=options["precision"],
backend=options["backend"],
**_limits(options, 0.4, 1030e-9, (200e-9, 4e-6), 800e-15),
**_launch(options),
@@ -249,7 +241,6 @@ def capillary(name: str, options: Record | None = None) -> Simulation:
raman=True,
modes=1,
mode_average=averaged,
- precision=options["precision"],
backend=options["backend"],
**_limits(options, 2.0, 1030e-9, (400e-9, 4e-6), 6e-12),
**_launch(options),
@@ -286,7 +277,6 @@ def soliton(length: float, options: Record, **keywords: Any) -> Simulation:
# and a seed would make it a different workload (SEP 37 measured 65 times
# the steps with one).
noise=None,
- precision=options["precision"],
backend=options["backend"],
**_limits(options, length, 835e-9, (500e-9, 2e-6), 4e-12, envelope=True),
**keywords,
@@ -332,7 +322,6 @@ def gnlse(name: str, options: Record | None = None) -> Simulation:
peak_power=10e3,
raman=True,
shock=True,
- precision=options["precision"],
backend=options["backend"],
**_limits(options, 0.15, 835e-9, (400e-9, 2e-6), 12.5e-12, envelope=True),
**_launch(options),
@@ -591,7 +580,6 @@ def build(configuration: Record) -> Simulation:
options = variant(configuration)
if case.builder in ("kerr", "spatial"):
settings = SimulationSettings(
- precision=options["precision"],
save_planes=itinerary,
dz_max=case.length,
dz_init=case.length * 1e-4,
diff --git a/benchmarks/registry.py b/benchmarks/registry.py
index fc1497d5c..197e22985 100644
--- a/benchmarks/registry.py
+++ b/benchmarks/registry.py
@@ -185,9 +185,8 @@ def configuration(
"""Resolve one measurement without constructing a scientific object.
On a device build, `width` is the host's thread count and `device` says the
- native backend must run on the accelerator. Every backend runs in double
- precision; ``noise="none"`` drops the case's noise and
- ``fft_size="pow2"`` rebuilds its time grid at a power of two
+ native backend must run on the accelerator. ``noise="none"`` drops the case's
+ noise and ``fft_size="pow2"`` rebuilds its time grid at a power of two
(``benchmarks.builders.variant``).
"""
if stepper not in self.steppers:
@@ -201,7 +200,6 @@ def configuration(
"stepper": stepper,
"threads": width,
"backend": backend,
- "precision": "double",
"noise": noise,
"fft_size": fft_size,
"device": device,
diff --git a/benchmarks/sessions.py b/benchmarks/sessions.py
index 4bbbc8dba..c632e5452 100644
--- a/benchmarks/sessions.py
+++ b/benchmarks/sessions.py
@@ -83,7 +83,7 @@ def fibre(
itinerary : list of float
The save planes, m, the last at `stop`.
options : dict
- The backend, precision and grid variant (``benchmarks.builders.variant``).
+ The backend, noise and grid variant (``benchmarks.builders.variant``).
Returns
-------
diff --git a/benchmarks/track.py b/benchmarks/track.py
index f227328a5..0431de223 100644
--- a/benchmarks/track.py
+++ b/benchmarks/track.py
@@ -82,7 +82,7 @@ def selection_options(parser: argparse.ArgumentParser) -> None:
"--backend",
choices=BACKENDS,
default="native",
- help="every backend runs in double precision",
+ help="the backend every case runs on",
)
parser.add_argument(
"--noise",
diff --git a/benchmarks/worker.py b/benchmarks/worker.py
index 4e6919362..8e5d694bf 100644
--- a/benchmarks/worker.py
+++ b/benchmarks/worker.py
@@ -717,7 +717,6 @@ def measure(
"counted": counters,
"repeat_difference": _repeat_difference(finals),
"integrator": integrator_label(stepper.integrator),
- "precision": configuration.get("precision", "double"),
"rtol": controller.rtol,
"atol": controller.atol,
"noise": simulation.noise_resolution.resolved
diff --git a/changelog.d/binary64-only.removed.md b/changelog.d/binary64-only.removed.md
new file mode 100644
index 000000000..f5885cb81
--- /dev/null
+++ b/changelog.d/binary64-only.removed.md
@@ -0,0 +1 @@
+Single and mixed precision are retired: solprop computes in IEEE 754 binary64 only ([ADR-077](docs/dev/decisions.md)). The `precision` keyword of `SimulationSettings`, the `Simulation.from_*` constructors and the `prop_*` functions, and a recipe's `precision` key, are gone (a recipe's `precision = "double"` is dropped, so an old recipe still loads, and any other value is refused naming ADR-077); so are `solprop.translate.precision`, the `precision` field of `PropagationProblem` and the `precision` argument of `assemble`, the reference's binary32 path (`NumpySingleBackend`), `SINGLE_PRECISION_NOISE`, `solprop.driver.defaults.rtol_in_precision`, the `precision` argument of `default_rtol`, `default_controller`, `default_integrator`, `resolve_noise` and `solprop.backends.registry.available`, the run summary's precision row, the `precision` field of `solprop_native.BuildInfo`, and the guide's single-precision page and gallery example. The kernel's `run_options` reply lists the installed backends under `backends`, in place of `double` and `single`. Output written in single precision still opens.
diff --git a/changelog.d/cf4-coupled-flow.removed.md b/changelog.d/cf4-coupled-flow.removed.md
new file mode 100644
index 000000000..009b65851
--- /dev/null
+++ b/changelog.d/cf4-coupled-flow.removed.md
@@ -0,0 +1 @@
+Removed the CF4 coupled-flow sections (ADR-077): `CoherentWall`'s `method`, `growth_limit` and `fallback` keywords, `solprop.translate.coupled.cf4_flow`, `cf4_defect`, `cf4_growth_bound`, `CF4_NODES`, `CF4_WEIGHTS` and `CF4_GROWTH_LIMIT`, the `"cf4"` section method, and the `flow.cf4` entry of a coherent wall's run record. They ran on `numpy_ref` alone; a recipe that gives one is refused with that reason. A varying coherent wall is integrated by the frozen-residual split, as by default; to shorten its advisory step, divide the sections (`CoherentWall(sections=...)`) or cap the step.
diff --git a/changelog.d/comparison-multistep.removed.md b/changelog.d/comparison-multistep.removed.md
new file mode 100644
index 000000000..78a586d56
--- /dev/null
+++ b/changelog.d/comparison-multistep.removed.md
@@ -0,0 +1 @@
+Removed `solprop.stepper.multistep.ComparisonMultistep`, the benchmark-only Adams variants SEP 13 compared and did not select (PECE, explicit Adams–Bashforth, the matched predictor–corrector pairs and their Hermite dense interpolant), with the reference's general Adams path that stepped them, `milne_factor`, `defect_integral`, the `Corrector` and `Interpolant` types, `Multistep.corrector`, `Multistep.corrector_nodes` and `IntegrationSpec.reference_only` (ADR-077). Use `Multistep()`; `Multistep(order=q, variable_order=False, formulation="interaction")` is the one-order-higher PEC variant the comparison family also named.
diff --git a/changelog.d/final-pi-controller.breaking.md b/changelog.d/final-pi-controller.breaking.md
new file mode 100644
index 000000000..c54b23092
--- /dev/null
+++ b/changelog.d/final-pi-controller.breaking.md
@@ -0,0 +1 @@
+`PIController` is final: defining a subclass raises `StepperError` (ADR-077). Both backends implement its decisions and its norm (`scale`, `accepts`, `propose`, `rejection_factor`, `start_factor`, `equivalent_norm`) themselves, so a subclass that overrode one would have stepped differently on each; it ran on `numpy_ref` alone and native refused it. Set the controller's fields instead.
diff --git a/changelog.d/native-comparison-variants.breaking.md b/changelog.d/native-comparison-variants.breaking.md
deleted file mode 100644
index f8733f1cf..000000000
--- a/changelog.d/native-comparison-variants.breaking.md
+++ /dev/null
@@ -1 +0,0 @@
-The native backend refuses the benchmark-only Adams comparison family, `ComparisonMultistep`, by name and before it allocates anything, on CPU and device builds: PECE, explicit Adams–Bashforth (`corrector="none"`), the matched pairs (`corrector_order = order`) and the one-order-higher PEC variant (`corrector="pec", corrector_order=order + 1`) alike. They ran on solprop's Python march, or for the PEC variant on the transitional native bridge; they are now the NumPy reference's alone (SEP 47 section 6.3), so run them with `backend="numpy_ref"`, or use `Multistep()`, the selected method, on native. The native bridge's copy of the general Adams path and its Hermite dense interpolant are removed with them.
diff --git a/changelog.d/native-numpy-ref-decoupled.breaking.md b/changelog.d/native-numpy-ref-decoupled.breaking.md
new file mode 100644
index 000000000..fa03faf0e
--- /dev/null
+++ b/changelog.d/native-numpy-ref-decoupled.breaking.md
@@ -0,0 +1,13 @@
+The general modal route's and the coupled flow's admission is part of the problem's own
+validation (`solprop.translate.admission.check_general_route`), raised once as a
+`TranslationError` naming solprop rather than as a `BackendError` from each backend;
+`solprop.backends.numpy_ref.admission` and `refuse_general_leaves` are removed. The
+general route's derivations moved to `solprop.translate.general` (`node_kerr_tensor`,
+`node_kerr_form`, `node_delayed_maps`) and its node chunk to `solprop.backends.node_chunk`
+(`general_node_chunk`, `general_planned_chunk`); the quadrature alarm is
+`solprop.translate.modal.QUADRATURE_ALARM`, logged on the `solprop.translate.modal`
+logger; the Adams call ledger is `solprop.backends.ledger.CallLedger`, and the
+reference's Adams module is `solprop.backends.numpy_ref.multistep`.
+`CompiledResponses.response_order` is derived from the compiled leaves and is no longer a
+constructor argument. `solprop_native` no longer exports the `*_FIELDS` seam tuples, and
+`check_build` no longer looks seam members up.
diff --git a/changelog.d/native-reference-only-controllers.breaking.md b/changelog.d/native-reference-only-controllers.breaking.md
deleted file mode 100644
index b76330c6d..000000000
--- a/changelog.d/native-reference-only-controllers.breaking.md
+++ /dev/null
@@ -1 +0,0 @@
-The native backend refuses a `PIController` subclass that overrides one of its decisions or its norm (`scale`, `accepts`, `propose`, `rejection_factor`, `start_factor` or `equivalent_norm`), by name and before it allocates anything, on CPU and device builds, for every method. Such a controller ran on the transitional native bridge, its Python loop with a `HostMarchWarning` where it overrode the norm's scale; it is now a reference-only experiment (SEP 47 section 6.3), so run it with `backend="numpy_ref"`, or use `PIController` itself on native.
diff --git a/changelog.d/precision-resume-key.breaking.md b/changelog.d/precision-resume-key.breaking.md
new file mode 100644
index 000000000..823e796a2
--- /dev/null
+++ b/changelog.d/precision-resume-key.breaking.md
@@ -0,0 +1 @@
+A run's configuration no longer records a `precision`, which every run recorded since SEP 33, so the resume key of every run changes once ([ADR-077](docs/dev/decisions.md)): a run started on an earlier release is refused a resume on this one, naming the change. Finish in-flight runs, and resume stopped ones, on the release that started them before upgrading; their output still opens.
diff --git a/changelog.d/saved-plane-copies.changed.md b/changelog.d/saved-plane-copies.changed.md
index cd504e287..402229e28 100644
--- a/changelog.d/saved-plane-copies.changed.md
+++ b/changelog.d/saved-plane-copies.changed.md
@@ -1 +1 @@
-A saved plane makes fewer field-sized copies, with the same stored bits. A field the backend made for the observation alone (the native backend's host copy, the reference's widened single-precision state) is converted to true-power amplitudes where it is, so a modal plane on `native` makes two field-sized arrays where it made three, and a unit-weight one no longer copies the field at all before storing it. The reference's single-precision observation scales its widened state in place, one array fewer, and a plane observer and a failure checkpoint at a plane that kept no field now share one fetch of it. This partly addresses [#346](https://github.com/LupoLab/solprop/issues/346), as SEP 47's early copy elisions; a large spatial plane on `native` still makes its host copy and its stored copy.
+A saved plane makes fewer field-sized copies, with the same stored bits. A field the backend made for the observation alone (the native backend's host copy) is converted to true-power amplitudes where it is, so a modal plane on `native` makes two field-sized arrays where it made three, and a unit-weight one no longer copies the field at all before storing it. A plane observer and a failure checkpoint at a plane that kept no field now share one fetch of it. This partly addresses [#346](https://github.com/LupoLab/solprop/issues/346), as SEP 47's early copy elisions; a large spatial plane on `native` still makes its host copy and its stored copy.
diff --git a/changelog.d/sep-0047-retire-mlx.removed.md b/changelog.d/sep-0047-retire-mlx.removed.md
index ccdb8bce5..73bd4dbc7 100644
--- a/changelog.d/sep-0047-retire-mlx.removed.md
+++ b/changelog.d/sep-0047-retire-mlx.removed.md
@@ -1 +1 @@
-The experimental `mlx` backend for Apple GPUs is retired ([SEP 47](docs/dev/seps/archive/sep-0047-run-ownership.md) §6.6): `backend="mlx"`, in a call or a recipe, raises `BackendError` and never runs on another backend, and output that records it still opens. The `solprop[mlx]` extra, the front end's MLX choice and the `metal` entry of the kernel's run options are gone; the front end shows a recipe's unavailable backend as it is named, and picking a binary64 backend for a single-precision recipe returns it to double precision. Single precision runs on `numpy_ref`, named explicitly; `precision="single"` with `backend="auto"` is refused. With MLX's transform costs gone, the `transforms` keyword of `FieldGrid.from_limits` and `EnvelopeGrid.from_limits` (a recipe grid that gives it is refused, naming SEP 47), the `library` argument of `solprop.grid.fft_cost`, `solprop.grid.TransformLibrary` and `TRANSFORM_LIBRARIES`, `solprop.backends.registry.metal_available` and `SINGLE_ONLY`, and the `mlx_*` and `single_precision_rows` sizes of `solprop.backends.memory` are removed.
+The experimental `mlx` backend for Apple GPUs is retired ([SEP 47](docs/dev/seps/archive/sep-0047-run-ownership.md) §6.6): `backend="mlx"`, in a call or a recipe, raises `BackendError` and never runs on another backend, and output that records it still opens. The `solprop[mlx]` extra, the front end's MLX choice and the `metal` entry of the kernel's run options are gone; the front end shows a recipe's unavailable backend as it is named. With MLX's transform costs gone, the `transforms` keyword of `FieldGrid.from_limits` and `EnvelopeGrid.from_limits` (a recipe grid that gives it is refused, naming SEP 47 and the fix, as are the keys ADR-077 retired), the `library` argument of `solprop.grid.fft_cost`, `solprop.grid.TransformLibrary` and `TRANSFORM_LIBRARIES`, `solprop.backends.registry.metal_available` and `SINGLE_ONLY`, and the `mlx_*` and `single_precision_rows` sizes of `solprop.backends.memory` are removed.
diff --git a/changelog.d/sep-0047-step-driver-removed.removed.md b/changelog.d/sep-0047-step-driver-removed.removed.md
index 6bd7837c1..6fa2d9f65 100644
--- a/changelog.d/sep-0047-step-driver-removed.removed.md
+++ b/changelog.d/sep-0047-step-driver-removed.removed.md
@@ -3,8 +3,7 @@ The legacy step driver `solprop.backends.driver` (`StepDriver`), the Adams march
are removed ([SEP 47](docs/dev/seps/archive/sep-0047-run-ownership.md) section 6.8): the native
backend runs every run in its native session and inherits no Python step loop, and the
NumPy reference marches with its own `solprop.backends.host` classes. `reached` and
-`replay_tolerance` are now in `solprop.stepper.schedule`, and `refuse_general_leaves` in
-`solprop.backends.numpy_ref.admission`. A native backend's direct stepping members
+`replay_tolerance` are now in `solprop.stepper.schedule`. A native backend's direct stepping members
(`set_initial`, `propagate_to`, `observe`, `stats`, `attach_monitor` and the rest) are
gone with them: start a run with `backend.prepare(problem, request).start(start)` or
`solprop.backends.sessions.start_session`.
diff --git a/changelog.d/spectral-responses-leaf.removed.md b/changelog.d/spectral-responses-leaf.removed.md
new file mode 100644
index 000000000..c2a6895e5
--- /dev/null
+++ b/changelog.d/spectral-responses-leaf.removed.md
@@ -0,0 +1 @@
+Removed `CompiledResponses.spectral_responses` and `Flags.has_spectral_responses` from the kernel contract. The tuple could only repeat `envelope_plasma`, which carries the envelope plasma's post-transform assembly alone, and the refusals that read the flag could not be reached.
diff --git a/changelog.d/two-backends.removed.md b/changelog.d/two-backends.removed.md
new file mode 100644
index 000000000..ad056e290
--- /dev/null
+++ b/changelog.d/two-backends.removed.md
@@ -0,0 +1 @@
+solprop has exactly two backends, `native` and `numpy_ref`, with no tiers and no plugin backends ([ADR-077](docs/dev/decisions.md)). `solprop.backends.registry.BACKENDS`, `BackendEntry`, `REMOVED_BACKENDS` and `FAR_FIELD_BACKENDS` are removed; `BACKEND_NAMES` lists the two. A backend name that is neither, `numba_cpu` and `mlx` included, now raises `BackendError` naming both wherever it is requested, where `select` raised `ValueError` for an unknown one; output and snapshots that record a retired backend still load.
diff --git a/docs/auto_examples/index.rst b/docs/auto_examples/index.rst
index 1c60cf630..d96f0cccc 100644
--- a/docs/auto_examples/index.rst
+++ b/docs/auto_examples/index.rst
@@ -452,47 +452,6 @@ planar compressor.
-.. thumbnail-parent-div-close
-
-.. raw:: html
-
-
-
-Performance
------------
-
-Running the same physics in less memory: single precision on the reference backend,
-compared with binary64.
-
-
-.. raw:: html
-
-
-
-.. thumbnail-parent-div-open
-
-.. raw:: html
-
-
-
-.. only:: html
-
- .. image:: /auto_examples/performance/images/thumb/sphx_glr_plot_01_single_precision_thumb.png
- :alt:
-
- :doc:`/auto_examples/performance/plot_01_single_precision`
-
-.. raw:: html
-
-
Single precision
-
-
-
.. thumbnail-parent-div-close
.. raw:: html
@@ -508,5 +467,4 @@ compared with binary64.
/auto_examples/processes/index.rst
/auto_examples/supercontinuum/index.rst
/auto_examples/waveguides/index.rst
- /auto_examples/performance/index.rst
diff --git a/docs/auto_examples/performance/images/sphx_glr_plot_01_single_precision_001.png b/docs/auto_examples/performance/images/sphx_glr_plot_01_single_precision_001.png
deleted file mode 100644
index b430b1feb..000000000
Binary files a/docs/auto_examples/performance/images/sphx_glr_plot_01_single_precision_001.png and /dev/null differ
diff --git a/docs/auto_examples/performance/images/sphx_glr_plot_01_single_precision_002.png b/docs/auto_examples/performance/images/sphx_glr_plot_01_single_precision_002.png
deleted file mode 100644
index 29ad6b12b..000000000
Binary files a/docs/auto_examples/performance/images/sphx_glr_plot_01_single_precision_002.png and /dev/null differ
diff --git a/docs/auto_examples/performance/images/thumb/sphx_glr_plot_01_single_precision_thumb.png b/docs/auto_examples/performance/images/thumb/sphx_glr_plot_01_single_precision_thumb.png
deleted file mode 100644
index e53744793..000000000
Binary files a/docs/auto_examples/performance/images/thumb/sphx_glr_plot_01_single_precision_thumb.png and /dev/null differ
diff --git a/docs/auto_examples/performance/index.rst b/docs/auto_examples/performance/index.rst
deleted file mode 100644
index 7170579f9..000000000
--- a/docs/auto_examples/performance/index.rst
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-.. _sphx_glr_auto_examples_performance:
-
-Performance
------------
-
-Running the same physics in less memory: single precision on the reference backend,
-compared with binary64.
-
-
-.. raw:: html
-
-
-
-
-.. raw:: html
-
-
-
-.. thumbnail-parent-div-open
-
-.. raw:: html
-
-
-
-.. only:: html
-
- .. image:: /auto_examples/performance/images/thumb/sphx_glr_plot_01_single_precision_thumb.png
- :alt:
-
- :doc:`/auto_examples/performance/plot_01_single_precision`
-
-.. raw:: html
-
-
Single precision
-
-
-
-.. thumbnail-parent-div-close
-
-.. raw:: html
-
-
-
-
-.. toctree::
- :hidden:
-
- /auto_examples/performance/plot_01_single_precision
-
diff --git a/docs/auto_examples/performance/plot_01_single_precision.codeobj.json b/docs/auto_examples/performance/plot_01_single_precision.codeobj.json
deleted file mode 100644
index 7b2df363f..000000000
--- a/docs/auto_examples/performance/plot_01_single_precision.codeobj.json
+++ /dev/null
@@ -1,1997 +0,0 @@
-{
- "BACKENDS": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "dict"
- }
- ],
- "C": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.constants",
- "module_short": "solprop.constants",
- "name": "C"
- }
- ],
- "FieldGrid.from_limits": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.grid",
- "module_short": "solprop.grid",
- "name": "FieldGrid.from_limits"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "FieldGrid.from_limits"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.grid",
- "module_short": "solprop.grid",
- "name": "TimeGrid.from_limits"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "TimeGrid.from_limits"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed.from_limits"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed.from_limits"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.grid.FieldGrid",
- "module_short": "solprop.grid.FieldGrid",
- "name": "from_limits"
- }
- ],
- "GaussianPulse": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.pulses",
- "module_short": "solprop.pulses",
- "name": "GaussianPulse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "GaussianPulse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.pulses",
- "module_short": "solprop.pulses",
- "name": "ShapedPulse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "ShapedPulse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.pulses",
- "module_short": "solprop.pulses",
- "name": "GaussianPulse"
- }
- ],
- "KerrResponse": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.nonlinear.responses",
- "module_short": "solprop.nonlinear",
- "name": "KerrResponse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.nonlinear",
- "module_short": "solprop.nonlinear",
- "name": "KerrResponse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "KerrResponse"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.nonlinear.responses",
- "module_short": "solprop.nonlinear",
- "name": "KerrResponse"
- }
- ],
- "OptmatRate": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.nonlinear.optmat_rates",
- "module_short": "solprop.nonlinear.optmat_rates",
- "name": "OptmatRate"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.nonlinear",
- "module_short": "solprop.nonlinear",
- "name": "OptmatRate"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "OptmatRate"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.nonlinear.optmat_rates",
- "module_short": "solprop.nonlinear.optmat_rates",
- "name": "OptmatRate"
- }
- ],
- "PIController": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.stepper.controller",
- "module_short": "solprop.stepper",
- "name": "PIController"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.stepper",
- "module_short": "solprop.stepper",
- "name": "PIController"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "PIController"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.stepper",
- "module_short": "solprop.stepper",
- "name": "PIController"
- }
- ],
- "PlasmaResponse": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.nonlinear.responses",
- "module_short": "solprop.nonlinear",
- "name": "PlasmaResponse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.nonlinear",
- "module_short": "solprop.nonlinear",
- "name": "PlasmaResponse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "PlasmaResponse"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.nonlinear.responses",
- "module_short": "solprop.nonlinear",
- "name": "PlasmaResponse"
- }
- ],
- "RadialGrid": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.grid",
- "module_short": "solprop.grid",
- "name": "RadialGrid"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "RadialGrid"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.grid",
- "module_short": "solprop.grid",
- "name": "RadialGrid"
- }
- ],
- "Simulation.from_radial": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.driver.simulation",
- "module_short": "solprop.driver",
- "name": "Simulation.from_radial"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.driver",
- "module_short": "solprop.driver",
- "name": "Simulation.from_radial"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Simulation.from_radial"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed.from_radial"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed.from_radial"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.driver.Simulation",
- "module_short": "solprop.driver.Simulation",
- "name": "from_radial"
- }
- ],
- "SimulationSettings": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.driver.simulation",
- "module_short": "solprop.driver",
- "name": "SimulationSettings"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.driver",
- "module_short": "solprop.driver",
- "name": "SimulationSettings"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "SimulationSettings"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.driver",
- "module_short": "solprop.driver",
- "name": "SimulationSettings"
- }
- ],
- "SpatialMedium": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.spatial",
- "module_short": "solprop.spatial",
- "name": "SpatialMedium"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "SpatialMedium"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.spatial",
- "module_short": "solprop.spatial",
- "name": "SpatialMedium"
- }
- ],
- "SpatialPulse": [
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop.pulses",
- "module_short": "solprop.pulses",
- "name": "SpatialPulse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "SpatialPulse"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed"
- },
- {
- "is_class": true,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.pulses",
- "module_short": "solprop.pulses",
- "name": "SpatialPulse"
- }
- ],
- "capillary": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "function"
- }
- ],
- "carrier": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "centroid": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "float64"
- }
- ],
- "chi3": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "critical_power": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "function"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.spatial",
- "module_short": "solprop.spatial",
- "name": "critical_power"
- }
- ],
- "density": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "density.max": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "double": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.memory",
- "module_short": "solprop.output",
- "name": "MemoryOutput"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "MemoryOutput"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "MemoryOutput"
- }
- ],
- "double.fields": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.memory",
- "module_short": "solprop.output",
- "name": "MemoryOutput.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "MemoryOutput.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "MemoryOutput.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.schema",
- "module_short": "solprop.output",
- "name": "PlaneStore.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "PlaneStore.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "PlaneStore.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "abc",
- "module_short": "abc",
- "name": "ABC.fields"
- }
- ],
- "ends": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "list"
- }
- ],
- "energy": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "float64"
- }
- ],
- "fig": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.figure",
- "module_short": "matplotlib.figure",
- "name": "Figure"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Figure"
- }
- ],
- "fig.tight_layout": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.figure",
- "module_short": "matplotlib.figure",
- "name": "Figure.tight_layout"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Figure.tight_layout"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.figure",
- "module_short": "matplotlib.figure",
- "name": "FigureBase.tight_layout"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "FigureBase.tight_layout"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.tight_layout"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.tight_layout"
- }
- ],
- "filaments": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "dict"
- }
- ],
- "gamma3": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "function"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.physdata",
- "module_short": "solprop.physdata",
- "name": "gamma3"
- }
- ],
- "get_spectral_density": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "function"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.processing",
- "module_short": "solprop.processing",
- "name": "get_spectral_density"
- }
- ],
- "grid": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.grid",
- "module_short": "solprop.grid",
- "name": "FieldGrid"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "FieldGrid"
- }
- ],
- "index": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "keep": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "left": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes"
- }
- ],
- "left.legend": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.legend"
- }
- ],
- "left.plot": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.plot"
- }
- ],
- "left.set_xlabel": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.set_xlabel"
- }
- ],
- "left.set_xlim": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_xlim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_xlim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.set_xlim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.set_xlim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.set_xlim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.set_xlim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.set_xlim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.set_xlim"
- }
- ],
- "left.set_ylabel": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.set_ylabel"
- }
- ],
- "left.set_ylim": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_ylim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_ylim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.set_ylim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.set_ylim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.set_ylim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.set_ylim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.set_ylim"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.set_ylim"
- }
- ],
- "level": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "medium": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.spatial",
- "module_short": "solprop.spatial",
- "name": "SpatialMedium"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "SpatialMedium"
- }
- ],
- "medium.density": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.profiles",
- "module_short": "solprop.profiles",
- "name": "DensityPath"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "DensityPath"
- }
- ],
- "medium.index": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.spatial",
- "module_short": "solprop.spatial",
- "name": "SpatialMedium.index"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "SpatialMedium.index"
- }
- ],
- "np.array": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "builtin_function_or_method"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "array"
- }
- ],
- "np.asarray": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "builtin_function_or_method"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "asarray"
- }
- ],
- "np.linalg.norm": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "_ArrayFunctionDispatcher"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy.linalg",
- "module_short": "numpy.linalg",
- "name": "norm"
- }
- ],
- "np.linspace": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "_ArrayFunctionDispatcher"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "linspace"
- }
- ],
- "np.log": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ufunc"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "log"
- }
- ],
- "np.log10": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ufunc"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "log10"
- }
- ],
- "np.maximum": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ufunc"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "maximum"
- }
- ],
- "np.pi": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "pi"
- }
- ],
- "np.real": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "_ArrayFunctionDispatcher"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "real"
- }
- ],
- "np.sqrt": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ufunc"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "sqrt"
- }
- ],
- "np.sum": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "_ArrayFunctionDispatcher"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "sum"
- }
- ],
- "omega": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "peak": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "peak.max": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "builtin_function_or_method"
- }
- ],
- "plt.subplots": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "function"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.pyplot",
- "module_short": "matplotlib.pyplot",
- "name": "subplots"
- }
- ],
- "power": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "precision": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "str"
- }
- ],
- "prop_capillary": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "function"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.driver",
- "module_short": "solprop.driver",
- "name": "prop_capillary"
- }
- ],
- "pulse": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.pulses",
- "module_short": "solprop.pulses",
- "name": "SpatialPulse"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "SpatialPulse"
- }
- ],
- "radius": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "relative": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "function"
- }
- ],
- "responses": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "list"
- }
- ],
- "right": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes"
- }
- ],
- "right.legend": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.legend"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.legend"
- }
- ],
- "right.plot": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.plot"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.plot"
- }
- ],
- "right.set_xlabel": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.set_xlabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.set_xlabel"
- }
- ],
- "right.set_ylabel": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "Axes.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Axes.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes._base",
- "module_short": "matplotlib.axes._base",
- "name": "_AxesBase.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.axes",
- "module_short": "matplotlib.axes",
- "name": "_AxesBase.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "_AxesBase.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib.artist",
- "module_short": "matplotlib.artist",
- "name": "Artist.set_ylabel"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "matplotlib",
- "module_short": "matplotlib",
- "name": "Artist.set_ylabel"
- }
- ],
- "run": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.memory",
- "module_short": "solprop.output",
- "name": "MemoryOutput"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "MemoryOutput"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "MemoryOutput"
- }
- ],
- "run.metadata.backend.name": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.memory",
- "module_short": "solprop.output",
- "name": "MemoryOutput.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "MemoryOutput.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "MemoryOutput.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.schema",
- "module_short": "solprop.output",
- "name": "PlaneStore.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "PlaneStore.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "PlaneStore.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed.metadata"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "abc",
- "module_short": "abc",
- "name": "ABC.metadata"
- }
- ],
- "runs": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "dict"
- }
- ],
- "runs.items": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "builtin_function_or_method"
- }
- ],
- "seconds": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "single": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.memory",
- "module_short": "solprop.output",
- "name": "MemoryOutput"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "MemoryOutput"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "MemoryOutput"
- }
- ],
- "single.fields": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.memory",
- "module_short": "solprop.output",
- "name": "MemoryOutput.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "MemoryOutput.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "MemoryOutput.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output.schema",
- "module_short": "solprop.output",
- "name": "PlaneStore.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop.output",
- "module_short": "solprop.output",
- "name": "PlaneStore.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "PlaneStore.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop._display",
- "module_short": "solprop._display",
- "name": "Displayed.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "solprop",
- "module_short": "solprop",
- "name": "Displayed.fields"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "abc",
- "module_short": "abc",
- "name": "ABC.fields"
- }
- ],
- "spectral": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "started": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "float"
- }
- ],
- "style": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "str"
- }
- ],
- "time.perf_counter": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "builtin_function_or_method"
- },
- {
- "is_class": false,
- "is_explicit": false,
- "module": "time",
- "module_short": "time",
- "name": "perf_counter"
- }
- ],
- "wavelength": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "z": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "numpy",
- "module_short": "numpy",
- "name": "ndarray"
- }
- ],
- "z.size": [
- {
- "is_class": false,
- "is_explicit": false,
- "module": "builtins",
- "module_short": "builtins",
- "name": "int"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/auto_examples/performance/plot_01_single_precision.ipynb b/docs/auto_examples/performance/plot_01_single_precision.ipynb
deleted file mode 100644
index b2bfefe1d..000000000
--- a/docs/auto_examples/performance/plot_01_single_precision.ipynb
+++ /dev/null
@@ -1,86 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "\n# Single precision\n\nA run can hold its field and evaluate its nonlinear response in binary32 with\n``precision=\"single\"``, which halves the memory of every field-sized buffer. Whether a\nproblem keeps its accuracy in binary32 is a property of the problem, so this example\nruns two of them both ways and compares: the photoionisation blue shift of\n:doc:`/auto_examples/processes/plot_06_ionisation_blue_shift`, here over its first\n0.2 m, and a short radial argon filament, both on field grids with the field-grid\nplasma. Each\nruns in binary64 on the default backend, ``native``, and in binary32 on the reference\nbackend, ``numpy_ref``, which is named explicitly: it is the backend that runs single\nprecision.\n\n**What to look for.** The two precisions' output spectra lie on top of each other on a\n40 dB scale; their difference, normwise over the whole saved field, is set by the\ncontroller's tolerance and the binary32 propagator's rounding, which accumulates over\nthe run's steps, and the energy, the spectral centroid, the electron density, the peak\nintensity and the beam radius agree to the digits printed. The printed times show the\nunthreaded reference's binary32 run slower than ``native``'s binary64 one: here single\nprecision is a check of accuracy, not a speed-up\n(:doc:`/guide/performance/single-precision`).\n\n**What is approximate.** Single precision's tolerance floor is ``rtol = 1e-6`` (the\ndefault here); a problem that amplifies small perturbations strongly, such as a\nnoise-free comb-to-continuum, reaches its accuracy over a shorter length, as the\n:doc:`survival record ` states. Timings depend on\nthe machine and its load.\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## The capillary, in both precisions\n\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "collapsed": false
- },
- "outputs": [],
- "source": [
- "import time\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.constants import C\nfrom solprop.driver import Simulation, SimulationSettings, prop_capillary\nfrom solprop.grid import FieldGrid, RadialGrid\nfrom solprop.nonlinear.optmat_rates import OptmatRate\nfrom solprop.nonlinear.responses import KerrResponse, PlasmaResponse\nfrom solprop.physdata import gamma3\nfrom solprop.processing import get_spectral_density\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.spatial import SpatialMedium, critical_power\nfrom solprop.stepper import PIController\n\n# Binary64 on the default backend, binary32 on the reference, the backend that runs it.\nBACKENDS = {\"double\": \"auto\", \"single\": \"numpy_ref\"}\n\n\ndef capillary(precision):\n \"\"\"Run the blue-shift capillary's first 0.2 m; return it and its time.\"\"\"\n started = time.perf_counter()\n run = prop_capillary(\n 13e-6,\n 0.2,\n \"Ar\",\n 1.7,\n wavelength=800e-9,\n fwhm=30e-15,\n energy=3e-6,\n loss=False,\n trange=600e-15,\n wavelength_limits=(300e-9, 4e-6),\n plasma=True,\n n_save=51,\n precision=precision,\n backend=BACKENDS[precision],\n progress=\"none\",\n noise=None,\n )\n return run, time.perf_counter() - started\n\n\nruns = {precision: capillary(precision) for precision in (\"double\", \"single\")}\nfor precision, (run, seconds) in runs.items():\n print(f\"{precision}: {seconds:.1f} s on {run.metadata.backend.name}\")\n\n\ndef relative(a, b):\n \"\"\"Return the normwise relative difference of two arrays.\"\"\"\n return float(np.linalg.norm(a - b) / np.linalg.norm(b))\n\n\ndouble, single = runs[\"double\"][0], runs[\"single\"][0]\nprint(\n \"field difference at the end \"\n f\"{relative(single.fields()[-1], double.fields()[-1]):.1e}\"\n)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## The spectra and the plasma\n\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "collapsed": false
- },
- "outputs": [],
- "source": [
- "fig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.6))\nfor precision, style in ((\"double\", \"-\"), (\"single\", \"--\")):\n run = runs[precision][0]\n omega, spectral = get_spectral_density(run, \"omega\")\n keep = omega > 0\n wavelength = 2 * np.pi * C / omega[keep]\n level = spectral[-1, keep] / spectral[-1, keep].max()\n left.plot(\n wavelength * 1e9,\n 10 * np.log10(np.maximum(level, 1e-6)),\n style,\n label=precision,\n )\n z = np.asarray(run[\"z\"])\n density = np.asarray(run[\"stats/electron_density\"]).reshape(z.size, -1).max(axis=1)\n right.plot(z, density * 1e-6, style, label=precision)\n centroid = 2 * np.pi * C * np.sum(spectral[-1]) / np.sum(spectral[-1] * omega)\n energy = float(np.asarray(run[\"stats/energy\"]).ravel()[-1])\n print(\n f\"{precision}: centroid {centroid * 1e9:.2f} nm, energy {energy * 1e6:.5f} uJ, \"\n f\"peak electron density {density.max() * 1e-6:.4e} cm^-3\"\n )\nleft.set_xlim(300, 1600)\nleft.set_ylim(-40, 2)\nleft.set_xlabel(\"wavelength (nm)\")\nleft.set_ylabel(\"spectral density at 0.2 m (dB)\")\nleft.legend()\nright.set_xlabel(\"z (m)\")\nright.set_ylabel(\"peak electron density (cm$^{-3}$)\")\nright.legend()\nfig.tight_layout()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## A radial filament, in both precisions\n\nA 30 fs pulse at three times the critical power for self-focusing, 3 cm in argon at\none bar, on 256 radial nodes in 2 mm and a field grid: Kerr self-focusing and the\nfield-grid plasma on every node.\n\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "collapsed": false
- },
- "outputs": [],
- "source": [
- "medium = SpatialMedium(\"Ar\", 1.0)\nchi3 = gamma3(\"Ar\", wavelength=800e-9)\ngrid = FieldGrid.from_limits(0.03, 800e-9, (350e-9, 2e-6), 250e-15)\ncarrier = 2 * np.pi * C / 800e-9\ndensity = float(medium.density(np.array([0.0]))[0])\nindex = float(np.real(medium.index(np.array([carrier]))[0]))\npower = 3.0 * critical_power(800e-9, chi3 * density, index=index)\nenergy = power * 30e-15 * np.sqrt(np.pi / (4 * np.log(2)))\npulse = SpatialPulse(\n GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=energy), 100e-6\n)\nresponses = [\n KerrResponse(chi3, thg=False),\n PlasmaResponse(\"Ar\", OptmatRate(\"Ar\", 800e-9)),\n]\nfilaments = {}\nfor precision in (\"double\", \"single\"):\n started = time.perf_counter()\n filaments[precision] = Simulation.from_radial(\n grid,\n RadialGrid(2e-3, 256),\n medium,\n responses,\n pulse,\n settings=SimulationSettings(\n controller=PIController(rtol=1e-6, atol=0.0),\n dz_max=1e-3,\n dz_init=3e-5,\n save_planes=list(np.linspace(0.0, 0.03, 31)[1:]),\n ionisation_field_range=(1e7, 1e11),\n precision=precision,\n backend=BACKENDS[precision],\n ),\n noise=None,\n ).run()\n run = filaments[precision]\n print(\n f\"{precision}: {time.perf_counter() - started:.1f} s on \"\n f\"{run.metadata.backend.name}\"\n )\nends = [filaments[precision].fields()[-1] for precision in (\"single\", \"double\")]\nprint(f\"field difference at 3 cm {relative(*ends):.1e}\")\n\nfig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.4))\nfor precision, style in ((\"double\", \"-\"), (\"single\", \"--\")):\n run = filaments[precision]\n z = np.asarray(run[\"z\"])\n peak = np.asarray(run[\"stats/peak_intensity\"]).ravel()\n radius = np.asarray(run[\"stats/fwhm_r\"]).ravel()\n left.plot(z * 1e2, peak * 1e-4, style, label=precision)\n right.plot(z * 1e2, radius * 1e6, style, label=precision)\n print(\n f\"{precision}: peak intensity {peak.max() * 1e-4:.4e} W/cm^2, \"\n f\"final FWHM radius {radius[-1] * 1e6:.3f} um\"\n )\nleft.set_xlabel(\"z (cm)\")\nleft.set_ylabel(\"peak intensity (W/cm$^2$)\")\nleft.legend()\nright.set_xlabel(\"z (cm)\")\nright.set_ylabel(\"FWHM radius (um)\")\nright.legend()\nfig.tight_layout()"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python 3",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.14.7"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 0
-}
\ No newline at end of file
diff --git a/docs/auto_examples/performance/plot_01_single_precision.py b/docs/auto_examples/performance/plot_01_single_precision.py
deleted file mode 100644
index 1eae3b68b..000000000
--- a/docs/auto_examples/performance/plot_01_single_precision.py
+++ /dev/null
@@ -1,205 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-r"""
-Single precision
-================
-
-A run can hold its field and evaluate its nonlinear response in binary32 with
-``precision="single"``, which halves the memory of every field-sized buffer. Whether a
-problem keeps its accuracy in binary32 is a property of the problem, so this example
-runs two of them both ways and compares: the photoionisation blue shift of
-:doc:`/auto_examples/processes/plot_06_ionisation_blue_shift`, here over its first
-0.2 m, and a short radial argon filament, both on field grids with the field-grid
-plasma. Each
-runs in binary64 on the default backend, ``native``, and in binary32 on the reference
-backend, ``numpy_ref``, which is named explicitly: it is the backend that runs single
-precision.
-
-**What to look for.** The two precisions' output spectra lie on top of each other on a
-40 dB scale; their difference, normwise over the whole saved field, is set by the
-controller's tolerance and the binary32 propagator's rounding, which accumulates over
-the run's steps, and the energy, the spectral centroid, the electron density, the peak
-intensity and the beam radius agree to the digits printed. The printed times show the
-unthreaded reference's binary32 run slower than ``native``'s binary64 one: here single
-precision is a check of accuracy, not a speed-up
-(:doc:`/guide/performance/single-precision`).
-
-**What is approximate.** Single precision's tolerance floor is ``rtol = 1e-6`` (the
-default here); a problem that amplifies small perturbations strongly, such as a
-noise-free comb-to-continuum, reaches its accuracy over a shorter length, as the
-:doc:`survival record ` states. Timings depend on
-the machine and its load.
-"""
-
-# %%
-# The capillary, in both precisions
-# ---------------------------------
-
-import time
-
-import matplotlib.pyplot as plt
-import numpy as np
-
-from solprop.constants import C
-from solprop.driver import Simulation, SimulationSettings, prop_capillary
-from solprop.grid import FieldGrid, RadialGrid
-from solprop.nonlinear.optmat_rates import OptmatRate
-from solprop.nonlinear.responses import KerrResponse, PlasmaResponse
-from solprop.physdata import gamma3
-from solprop.processing import get_spectral_density
-from solprop.pulses import GaussianPulse, SpatialPulse
-from solprop.spatial import SpatialMedium, critical_power
-from solprop.stepper import PIController
-
-# Binary64 on the default backend, binary32 on the reference, the backend that runs it.
-BACKENDS = {"double": "auto", "single": "numpy_ref"}
-
-
-def capillary(precision):
- """Run the blue-shift capillary's first 0.2 m; return it and its time."""
- started = time.perf_counter()
- run = prop_capillary(
- 13e-6,
- 0.2,
- "Ar",
- 1.7,
- wavelength=800e-9,
- fwhm=30e-15,
- energy=3e-6,
- loss=False,
- trange=600e-15,
- wavelength_limits=(300e-9, 4e-6),
- plasma=True,
- n_save=51,
- precision=precision,
- backend=BACKENDS[precision],
- progress="none",
- noise=None,
- )
- return run, time.perf_counter() - started
-
-
-runs = {precision: capillary(precision) for precision in ("double", "single")}
-for precision, (run, seconds) in runs.items():
- print(f"{precision}: {seconds:.1f} s on {run.metadata.backend.name}")
-
-
-def relative(a, b):
- """Return the normwise relative difference of two arrays."""
- return float(np.linalg.norm(a - b) / np.linalg.norm(b))
-
-
-double, single = runs["double"][0], runs["single"][0]
-print(
- "field difference at the end "
- f"{relative(single.fields()[-1], double.fields()[-1]):.1e}"
-)
-
-# %%
-# The spectra and the plasma
-# --------------------------
-
-fig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.6))
-for precision, style in (("double", "-"), ("single", "--")):
- run = runs[precision][0]
- omega, spectral = get_spectral_density(run, "omega")
- keep = omega > 0
- wavelength = 2 * np.pi * C / omega[keep]
- level = spectral[-1, keep] / spectral[-1, keep].max()
- left.plot(
- wavelength * 1e9,
- 10 * np.log10(np.maximum(level, 1e-6)),
- style,
- label=precision,
- )
- z = np.asarray(run["z"])
- density = np.asarray(run["stats/electron_density"]).reshape(z.size, -1).max(axis=1)
- right.plot(z, density * 1e-6, style, label=precision)
- centroid = 2 * np.pi * C * np.sum(spectral[-1]) / np.sum(spectral[-1] * omega)
- energy = float(np.asarray(run["stats/energy"]).ravel()[-1])
- print(
- f"{precision}: centroid {centroid * 1e9:.2f} nm, energy {energy * 1e6:.5f} uJ, "
- f"peak electron density {density.max() * 1e-6:.4e} cm^-3"
- )
-left.set_xlim(300, 1600)
-left.set_ylim(-40, 2)
-left.set_xlabel("wavelength (nm)")
-left.set_ylabel("spectral density at 0.2 m (dB)")
-left.legend()
-right.set_xlabel("z (m)")
-right.set_ylabel("peak electron density (cm$^{-3}$)")
-right.legend()
-fig.tight_layout()
-
-# %%
-# A radial filament, in both precisions
-# -------------------------------------
-#
-# A 30 fs pulse at three times the critical power for self-focusing, 3 cm in argon at
-# one bar, on 256 radial nodes in 2 mm and a field grid: Kerr self-focusing and the
-# field-grid plasma on every node.
-
-medium = SpatialMedium("Ar", 1.0)
-chi3 = gamma3("Ar", wavelength=800e-9)
-grid = FieldGrid.from_limits(0.03, 800e-9, (350e-9, 2e-6), 250e-15)
-carrier = 2 * np.pi * C / 800e-9
-density = float(medium.density(np.array([0.0]))[0])
-index = float(np.real(medium.index(np.array([carrier]))[0]))
-power = 3.0 * critical_power(800e-9, chi3 * density, index=index)
-energy = power * 30e-15 * np.sqrt(np.pi / (4 * np.log(2)))
-pulse = SpatialPulse(
- GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=energy), 100e-6
-)
-responses = [
- KerrResponse(chi3, thg=False),
- PlasmaResponse("Ar", OptmatRate("Ar", 800e-9)),
-]
-filaments = {}
-for precision in ("double", "single"):
- started = time.perf_counter()
- filaments[precision] = Simulation.from_radial(
- grid,
- RadialGrid(2e-3, 256),
- medium,
- responses,
- pulse,
- settings=SimulationSettings(
- controller=PIController(rtol=1e-6, atol=0.0),
- dz_max=1e-3,
- dz_init=3e-5,
- save_planes=list(np.linspace(0.0, 0.03, 31)[1:]),
- ionisation_field_range=(1e7, 1e11),
- precision=precision,
- backend=BACKENDS[precision],
- ),
- noise=None,
- ).run()
- run = filaments[precision]
- print(
- f"{precision}: {time.perf_counter() - started:.1f} s on "
- f"{run.metadata.backend.name}"
- )
-ends = [filaments[precision].fields()[-1] for precision in ("single", "double")]
-print(f"field difference at 3 cm {relative(*ends):.1e}")
-
-fig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.4))
-for precision, style in (("double", "-"), ("single", "--")):
- run = filaments[precision]
- z = np.asarray(run["z"])
- peak = np.asarray(run["stats/peak_intensity"]).ravel()
- radius = np.asarray(run["stats/fwhm_r"]).ravel()
- left.plot(z * 1e2, peak * 1e-4, style, label=precision)
- right.plot(z * 1e2, radius * 1e6, style, label=precision)
- print(
- f"{precision}: peak intensity {peak.max() * 1e-4:.4e} W/cm^2, "
- f"final FWHM radius {radius[-1] * 1e6:.3f} um"
- )
-left.set_xlabel("z (cm)")
-left.set_ylabel("peak intensity (W/cm$^2$)")
-left.legend()
-right.set_xlabel("z (cm)")
-right.set_ylabel("FWHM radius (um)")
-right.legend()
-fig.tight_layout()
diff --git a/docs/auto_examples/performance/plot_01_single_precision.py.md5 b/docs/auto_examples/performance/plot_01_single_precision.py.md5
deleted file mode 100644
index 6616080e4..000000000
--- a/docs/auto_examples/performance/plot_01_single_precision.py.md5
+++ /dev/null
@@ -1 +0,0 @@
-097b152edff4b12a8ec1d4ccfa0e89b2
\ No newline at end of file
diff --git a/docs/auto_examples/performance/plot_01_single_precision.rst b/docs/auto_examples/performance/plot_01_single_precision.rst
deleted file mode 100644
index b4bf8ea82..000000000
--- a/docs/auto_examples/performance/plot_01_single_precision.rst
+++ /dev/null
@@ -1,318 +0,0 @@
-
-.. DO NOT EDIT.
-.. THIS FILE WAS AUTOMATICALLY GENERATED BY SPHINX-GALLERY.
-.. TO MAKE CHANGES, EDIT THE SOURCE PYTHON FILE:
-.. "auto_examples/performance/plot_01_single_precision.py"
-.. LINE NUMBERS ARE GIVEN BELOW.
-
-.. only:: html
-
- .. note::
- :class: sphx-glr-download-link-note
-
- :ref:`Go to the end
`
- to download the full example code.
-
-.. rst-class:: sphx-glr-example-title
-
-.. _sphx_glr_auto_examples_performance_plot_01_single_precision.py:
-
-
-Single precision
-================
-
-A run can hold its field and evaluate its nonlinear response in binary32 with
-``precision="single"``, which halves the memory of every field-sized buffer. Whether a
-problem keeps its accuracy in binary32 is a property of the problem, so this example
-runs two of them both ways and compares: the photoionisation blue shift of
-:doc:`/auto_examples/processes/plot_06_ionisation_blue_shift`, here over its first
-0.2 m, and a short radial argon filament, both on field grids with the field-grid
-plasma. Each
-runs in binary64 on the default backend, ``native``, and in binary32 on the reference
-backend, ``numpy_ref``, which is named explicitly: it is the backend that runs single
-precision.
-
-**What to look for.** The two precisions' output spectra lie on top of each other on a
-40 dB scale; their difference, normwise over the whole saved field, is set by the
-controller's tolerance and the binary32 propagator's rounding, which accumulates over
-the run's steps, and the energy, the spectral centroid, the electron density, the peak
-intensity and the beam radius agree to the digits printed. The printed times show the
-unthreaded reference's binary32 run slower than ``native``'s binary64 one: here single
-precision is a check of accuracy, not a speed-up
-(:doc:`/guide/performance/single-precision`).
-
-**What is approximate.** Single precision's tolerance floor is ``rtol = 1e-6`` (the
-default here); a problem that amplifies small perturbations strongly, such as a
-noise-free comb-to-continuum, reaches its accuracy over a shorter length, as the
-:doc:`survival record ` states. Timings depend on
-the machine and its load.
-
-.. GENERATED FROM PYTHON SOURCE LINES 37-39
-
-The capillary, in both precisions
----------------------------------
-
-.. GENERATED FROM PYTHON SOURCE LINES 39-100
-
-.. code-block:: Python
-
-
- import time
-
- import matplotlib.pyplot as plt
- import numpy as np
-
- from solprop.constants import C
- from solprop.driver import Simulation, SimulationSettings, prop_capillary
- from solprop.grid import FieldGrid, RadialGrid
- from solprop.nonlinear.optmat_rates import OptmatRate
- from solprop.nonlinear.responses import KerrResponse, PlasmaResponse
- from solprop.physdata import gamma3
- from solprop.processing import get_spectral_density
- from solprop.pulses import GaussianPulse, SpatialPulse
- from solprop.spatial import SpatialMedium, critical_power
- from solprop.stepper import PIController
-
- # Binary64 on the default backend, binary32 on the reference, the backend that runs it.
- BACKENDS = {"double": "auto", "single": "numpy_ref"}
-
-
- def capillary(precision):
- """Run the blue-shift capillary's first 0.2 m; return it and its time."""
- started = time.perf_counter()
- run = prop_capillary(
- 13e-6,
- 0.2,
- "Ar",
- 1.7,
- wavelength=800e-9,
- fwhm=30e-15,
- energy=3e-6,
- loss=False,
- trange=600e-15,
- wavelength_limits=(300e-9, 4e-6),
- plasma=True,
- n_save=51,
- precision=precision,
- backend=BACKENDS[precision],
- progress="none",
- noise=None,
- )
- return run, time.perf_counter() - started
-
-
- runs = {precision: capillary(precision) for precision in ("double", "single")}
- for precision, (run, seconds) in runs.items():
- print(f"{precision}: {seconds:.1f} s on {run.metadata.backend.name}")
-
-
- def relative(a, b):
- """Return the normwise relative difference of two arrays."""
- return float(np.linalg.norm(a - b) / np.linalg.norm(b))
-
-
- double, single = runs["double"][0], runs["single"][0]
- print(
- "field difference at the end "
- f"{relative(single.fields()[-1], double.fields()[-1]):.1e}"
- )
-
-
-
-
-
-.. rst-class:: sphx-glr-script-out
-
- .. code-block:: none
-
- double: 12.7 s on native
- single: 99.6 s on numpy_ref
- field difference at the end 8.9e-06
-
-
-
-
-.. GENERATED FROM PYTHON SOURCE LINES 101-103
-
-The spectra and the plasma
---------------------------
-
-.. GENERATED FROM PYTHON SOURCE LINES 103-136
-
-.. code-block:: Python
-
-
- fig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.6))
- for precision, style in (("double", "-"), ("single", "--")):
- run = runs[precision][0]
- omega, spectral = get_spectral_density(run, "omega")
- keep = omega > 0
- wavelength = 2 * np.pi * C / omega[keep]
- level = spectral[-1, keep] / spectral[-1, keep].max()
- left.plot(
- wavelength * 1e9,
- 10 * np.log10(np.maximum(level, 1e-6)),
- style,
- label=precision,
- )
- z = np.asarray(run["z"])
- density = np.asarray(run["stats/electron_density"]).reshape(z.size, -1).max(axis=1)
- right.plot(z, density * 1e-6, style, label=precision)
- centroid = 2 * np.pi * C * np.sum(spectral[-1]) / np.sum(spectral[-1] * omega)
- energy = float(np.asarray(run["stats/energy"]).ravel()[-1])
- print(
- f"{precision}: centroid {centroid * 1e9:.2f} nm, energy {energy * 1e6:.5f} uJ, "
- f"peak electron density {density.max() * 1e-6:.4e} cm^-3"
- )
- left.set_xlim(300, 1600)
- left.set_ylim(-40, 2)
- left.set_xlabel("wavelength (nm)")
- left.set_ylabel("spectral density at 0.2 m (dB)")
- left.legend()
- right.set_xlabel("z (m)")
- right.set_ylabel("peak electron density (cm$^{-3}$)")
- right.legend()
- fig.tight_layout()
-
-
-
-
-.. image-sg:: /auto_examples/performance/images/sphx_glr_plot_01_single_precision_001.png
- :alt: plot 01 single precision
- :srcset: /auto_examples/performance/images/sphx_glr_plot_01_single_precision_001.png
- :class: sphx-glr-single-img
-
-
-.. rst-class:: sphx-glr-script-out
-
- .. code-block:: none
-
- double: centroid 669.01 nm, energy 1.33141 uJ, peak electron density 4.3531e+17 cm^-3
- single: centroid 669.01 nm, energy 1.33141 uJ, peak electron density 4.3531e+17 cm^-3
-
-
-
-
-.. GENERATED FROM PYTHON SOURCE LINES 137-143
-
-A radial filament, in both precisions
--------------------------------------
-
-A 30 fs pulse at three times the critical power for self-focusing, 3 cm in argon at
-one bar, on 256 radial nodes in 2 mm and a field grid: Kerr self-focusing and the
-field-grid plasma on every node.
-
-.. GENERATED FROM PYTHON SOURCE LINES 143-206
-
-.. code-block:: Python
-
-
- medium = SpatialMedium("Ar", 1.0)
- chi3 = gamma3("Ar", wavelength=800e-9)
- grid = FieldGrid.from_limits(0.03, 800e-9, (350e-9, 2e-6), 250e-15)
- carrier = 2 * np.pi * C / 800e-9
- density = float(medium.density(np.array([0.0]))[0])
- index = float(np.real(medium.index(np.array([carrier]))[0]))
- power = 3.0 * critical_power(800e-9, chi3 * density, index=index)
- energy = power * 30e-15 * np.sqrt(np.pi / (4 * np.log(2)))
- pulse = SpatialPulse(
- GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=energy), 100e-6
- )
- responses = [
- KerrResponse(chi3, thg=False),
- PlasmaResponse("Ar", OptmatRate("Ar", 800e-9)),
- ]
- filaments = {}
- for precision in ("double", "single"):
- started = time.perf_counter()
- filaments[precision] = Simulation.from_radial(
- grid,
- RadialGrid(2e-3, 256),
- medium,
- responses,
- pulse,
- settings=SimulationSettings(
- controller=PIController(rtol=1e-6, atol=0.0),
- dz_max=1e-3,
- dz_init=3e-5,
- save_planes=list(np.linspace(0.0, 0.03, 31)[1:]),
- ionisation_field_range=(1e7, 1e11),
- precision=precision,
- backend=BACKENDS[precision],
- ),
- noise=None,
- ).run()
- run = filaments[precision]
- print(
- f"{precision}: {time.perf_counter() - started:.1f} s on "
- f"{run.metadata.backend.name}"
- )
- ends = [filaments[precision].fields()[-1] for precision in ("single", "double")]
- print(f"field difference at 3 cm {relative(*ends):.1e}")
-
- fig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.4))
- for precision, style in (("double", "-"), ("single", "--")):
- run = filaments[precision]
- z = np.asarray(run["z"])
- peak = np.asarray(run["stats/peak_intensity"]).ravel()
- radius = np.asarray(run["stats/fwhm_r"]).ravel()
- left.plot(z * 1e2, peak * 1e-4, style, label=precision)
- right.plot(z * 1e2, radius * 1e6, style, label=precision)
- print(
- f"{precision}: peak intensity {peak.max() * 1e-4:.4e} W/cm^2, "
- f"final FWHM radius {radius[-1] * 1e6:.3f} um"
- )
- left.set_xlabel("z (cm)")
- left.set_ylabel("peak intensity (W/cm$^2$)")
- left.legend()
- right.set_xlabel("z (cm)")
- right.set_ylabel("FWHM radius (um)")
- right.legend()
- fig.tight_layout()
-
-
-
-.. image-sg:: /auto_examples/performance/images/sphx_glr_plot_01_single_precision_002.png
- :alt: plot 01 single precision
- :srcset: /auto_examples/performance/images/sphx_glr_plot_01_single_precision_002.png
- :class: sphx-glr-single-img
-
-
-.. rst-class:: sphx-glr-script-out
-
- .. code-block:: none
-
- BoundaryWarning: the safety ramp has removed 1.47e-06 of the launch energy above the input noise's share: content reached the grid's outer wavenumbers; refine the transverse grid (SEP 40 section 5.3)
- double: 1.9 s on native
- BoundaryWarning: the safety ramp has removed 1.47e-06 of the launch energy above the input noise's share: content reached the grid's outer wavenumbers; refine the transverse grid (SEP 40 section 5.3)
- single: 61.6 s on numpy_ref
- field difference at 3 cm 7.5e-07
- double: peak intensity 1.7445e+14 W/cm^2, final FWHM radius 167.043 um
- single: peak intensity 1.7445e+14 W/cm^2, final FWHM radius 167.043 um
-
-
-
-
-
-.. rst-class:: sphx-glr-timing
-
- **Total running time of the script:** (2 minutes 56.462 seconds)
-
-
-.. _sphx_glr_download_auto_examples_performance_plot_01_single_precision.py:
-
-.. only:: html
-
- .. container:: sphx-glr-footer sphx-glr-footer-example
-
- .. container:: sphx-glr-download sphx-glr-download-jupyter
-
- :download:`Download Jupyter notebook: plot_01_single_precision.ipynb `
-
- .. container:: sphx-glr-download sphx-glr-download-python
-
- :download:`Download Python source code: plot_01_single_precision.py `
-
- .. container:: sphx-glr-download sphx-glr-download-zip
-
- :download:`Download zipped: plot_01_single_precision.zip `
diff --git a/docs/auto_examples/performance/plot_01_single_precision.zip b/docs/auto_examples/performance/plot_01_single_precision.zip
deleted file mode 100644
index 35eb48cfc..000000000
Binary files a/docs/auto_examples/performance/plot_01_single_precision.zip and /dev/null differ
diff --git a/docs/conf.py b/docs/conf.py
index 56c28a2c1..f7fa5a55a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -262,7 +262,6 @@ def _format_warning(
"examples/processes",
"examples/supercontinuum",
"examples/waveguides",
- "examples/performance",
]
),
"within_subsection_order": "FileNameSortKey",
diff --git a/docs/dev/accuracy.md b/docs/dev/accuracy.md
index cfcac5e66..89bb57301 100644
--- a/docs/dev/accuracy.md
+++ b/docs/dev/accuracy.md
@@ -260,7 +260,8 @@ case at 250, 500 and 1000 fixed steps, all four rows run back to back, changing
thing at a time (coarser rungs exceed the in-stage absorber's stability limit, 5.5 mm on
this metre, ADR-025). The plasma rows use the held-current source; the Luna comparisons
below run the time-domain gated plasma source through a test-only hook
-(`tests/gated_source.py`):
+(`tests/gated_source.py`), which `numpy_ref` and `native` both run, held to each other
+by `tests/backends/test_native_held_plasma.py`:
| fill | plasma | order |
|---|---|---|
diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md
index 632da903c..bc4929c0b 100644
--- a/docs/dev/architecture.md
+++ b/docs/dev/architecture.md
@@ -29,13 +29,9 @@ Luna's scope and its most valuable design choices, and is designed around the fo
- a clean, fast-loading Python package that plays well with NumPy, SciPy, matplotlib and h5py.
**Priorities, in order:** accuracy and reliability; then as much CPU performance as can be
-had; then GPU performance (ADR-020). Double precision by default: realistic modelling always
-carries noise, so the stepper's job is efficiency at sufficient accuracy, not extended
-precision. Single precision is an explicit per-run choice, `precision="single"`, under
-SEP 33's contract (ADR-054): the state, stages, transforms and responses in binary32,
-the controller, host scalars and propagator exponents in binary64, every narrowed table
-with its own power-of-two exponent and the state at an exact scale. `numpy_ref`'s
-single-precision path is its oracle; `native` computes in binary64 only.
+had; then GPU performance (ADR-020). Double precision throughout, on every backend
+(ADR-077): realistic modelling always carries noise, so the stepper's job is efficiency at
+sufficient accuracy, not extended precision.
## 2. The model
@@ -217,10 +213,10 @@ against `src/solprop` by `tests/test_structure_figures.py`.
| `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 |
| `linear.py`, `boundaries.py`, `nonlinear/` | Linear assembly, absorbing rates, response definitions, ionisation, Raman, normalisation and the GNLSE mapping | P |
-| `translate/` | Validated `PropagationProblem` and its route union (`translate/routes.py`); parameter expansions and z tables; modal/radial/Cartesian transforms and the hybrid planar one (slab modes in x × FFT in y, `translate/planar.py`); dense tracked modal tables, the general modal route and the coupled-flow and metric records (`translate/general.py`, `translate/coupled.py`); the spatial plane measure (`translate/flux.py`, the `FluxPlan` of schema 35); the persisted frame integral and the filled input port of a problem's entrance (`translate/coupling.py`); response tables, masks and stage bundles | T |
-| `backends/` | Registry and protocols, shared step and batch drivers, scalar bodies, spatial and population statistics | B |
-| `backends/numpy_ref/` | Reference execution | 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 inherited driver and marches in `backends/` (`driver.py`, `multistep.py`, `low_storage.py`) remain the native backend's base class until SEP 47 phase 5 removes them, though no run is served through them, and the parts the Python sessions compose are in `backends/session_core.py` | B |
+| `translate/` | Validated `PropagationProblem` and its route union (`translate/routes.py`); parameter expansions and z tables; modal/radial/Cartesian transforms and the hybrid planar one (slab modes in x × FFT in y, `translate/planar.py`); dense tracked modal tables, the general modal route and the coupled-flow and metric records (`translate/general.py`, `translate/coupled.py`); the spatial plane measure (`translate/flux.py`, the `FluxPlan` of schema 35); the persisted frame integral and the filled input port of a problem's entrance (`translate/coupling.py`); what the general route and the coupled flow execute, checked once (`translate/admission.py`); response tables, masks and stage bundles | T |
+| `backends/` | Registry and protocols, sessions, the Adams call ledger both backends keep (`ledger.py`), node chunks, scalar bodies, spatial and population statistics | B |
+| `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 |
| `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 |
@@ -700,13 +696,12 @@ chooses its own group: `start_group` asks the first member to prepare it,
refuses step planes and groups only points of one execution layout
(`solprop.driver.batch.compatible_groups`).
- **The reference's group** (`solprop.backends.host.group_session.HostGroupSession`,
- prepared by `HostPreparedGroup`) serves every `numpy_ref` group, in both precisions.
+ prepared by `HostPreparedGroup`) serves every `numpy_ref` group.
It runs over the reference's group runner (`HostGroup`, or `NumpyBatchBackend`'s
- member-axis stage arithmetic in binary64, which `select_batch` builds from the
- registry row), which adopts the loaded member backends, each with its own tables,
+ member-axis stage arithmetic, which `select_batch` builds), which adopts the loaded member backends, each with its own tables,
state and noise, and advances them through each common step in turn. It keeps that
runner rather than composing one `HostSession` per member, because the runner's
- arithmetic, failure positions and precision rule are what a reference group means:
+ arithmetic and failure positions are what a reference group means:
the module docstring gives the reasons.
Admission uses memory ledgers and a free-device-memory query with headroom.
@@ -864,17 +859,13 @@ linearly — a capillary's coherent wall, the taper coupling of a moving multimo
block on the general modal route — the problem carries the optional
`linear.coupled_flow` leaf (`translate/coupled.py`) beside the diagonal Φ table. Its
generator does not commute with itself at different z, so the flow is built per
-section of the operator table's breakpoints, in one of three ways:
+section of the operator table's breakpoints, in one of two ways:
- `"constant_schur"`: a constant block per section, whose exponential is formed from its
Schur decomposition and is exact over any step inside the section;
- `"frozen_residual"`: the block frozen per section inside the exponential, and the rest of
the generator — its z variation and the connection — as an explicit residual that the
- Runge–Kutta source carries, so the embedded pair sees its error;
-- `"cf4"`: a fourth-order commutator-free exponential of the whole generator at Gauss
- nodes of each stage interval, whose flow error the embedded pair does not see
- ([stepper.md](stepper.md) §2); admitted on `numpy_ref` only and never selected by
- default.
+ Runge–Kutta source carries, so the embedded pair sees its error.
The coupled flow runs on `numpy_ref` and the native CPU build, which forms
the per-bin flow actions in its own kernels and agrees with the reference to a derived
diff --git a/docs/dev/backends.md b/docs/dev/backends.md
index 05871125a..da62d8d9f 100644
--- a/docs/dev/backends.md
+++ b/docs/dev/backends.md
@@ -17,9 +17,9 @@ processor is at or above a build's baseline, and otherwise raises `BackendError`
to do; it stores that name before execution or worker transfer. `auto` has no fallback.
Native being installed is a packaging policy ([SEP 43](seps/archive/sep-0043-retire-numba-cpu.md) §5),
so a run does not have to prove that native could execute here, and nothing loads a runtime to
-enforce it. Two requests resolve before the registry and never consult native: a TDSE
-response's `auto` is `numpy_ref`, and single precision's is refused: name `numpy_ref`. Explicit names are preserved, so `backend="numpy_ref"` runs where native is
-absent and `backend="native"` on a processor below the baseline raises the package's refusal.
+enforce it. A TDSE response's `auto` resolves before the registry, to `numpy_ref`, and
+never consults native. Explicit names are preserved, so `backend="numpy_ref"` runs where
+native is absent and `backend="native"` on a processor below the baseline raises the package's refusal.
`select` also accepts `"auto"`, checks `SOLPROP_REQUIRE_DEVICE` against the actual build before
anything is allocated, and logs the actual backend at INFO after
successful construction. Import, build, device and unsupported-problem errors stay visible and
@@ -94,29 +94,26 @@ buffers, layout and plans are private; the state is never a parameter of `propag
`observe`; the numbers are the contract — a backend is correct when it reproduces the
reference to the stated bound. Selection is by name (`backends.select(name, problem, request)`);
availability is detected with `importlib.util.find_spec` (importing an accelerator package
-can initialise a device runtime), never cached; an unknown name raises `ValueError` listing
-what is usable, a known name whose dependencies are missing raises `ImportError` saying
-what to install, and a removed name keeps a tombstone (`REMOVED_BACKENDS`):
-`backend="numba_cpu"` raises `BackendError` naming `native` and `numpy_ref`, wherever a
-requested name is resolved. A recorded name is not a request, so old output and snapshots
-that record it still load. `metadata()` says what the execution *was* — the name, the
+can initialise a device runtime), never cached; a name that is neither backend, a retired
+one's such as `numba_cpu` or `mlx` included, raises `BackendError` naming `native` and
+`numpy_ref` wherever a requested name is resolved, and `native` without its package raises
+`ImportError` saying what to install. A recorded name is not a request, so old output and
+snapshots that record a retired backend still load. `metadata()` says what the execution *was* — the name, the
resolved propagator mode, and whatever pools, plans and digests the backend has — and the
driver writes it into the run's `RunMetadata.backend`, outside the resume key.
-**Registration.** A backend is one row of `registry.BACKENDS`, a `BackendEntry` that
-names by import path (`"module:attribute"`) everything selection reads of it, so
-`select`, `select_batch` and the policy checks read the row and never branch on the
-name: the modules it `requires` and the `extras` or `install` text that supply them;
-its `factory`, called as `factory(problem, request, *, grid, modes, profiles, options)`;
-the reference group's lockstep runner `batch(owners)` (`select_batch`; the native row
-names none, its group running one native session per member); and the native policies, each `None` on a row without it: `first_use`, called
-before each construction (SEP 46's first-use calibration), `device`, the build
-description that `SOLPROP_REQUIRE_DEVICE` reads (a row without one is refused a required
-device by name), and `far_field`, the far-field statistics the installed build declares
-(`statistics_refusal`). `auto` is `registry.AUTO`, `native`, with no fallback. Nothing
-is imported until selection needs it, and only after `requires` is found. There are
-exactly two backends, and no other will be added, in this repository or as a plugin from
-another distribution (ADR-077); there is no entry-point discovery.
+**The two backends.** There are exactly two (ADR-077), `registry.BACKEND_NAMES`:
+`numpy_ref` and `native`, with no plugin rows and no other backend. `select` constructs
+the reference through `solprop.backends.numpy_ref.construct` and the native backend
+through `solprop_native.NativeBackend`, each called as
+`factory(problem, request, *, grid, modes, profiles, options)`, importing each only when
+it is selected. Before a native construction it runs SEP 46's first-use calibration;
+`SOLPROP_REQUIRE_DEVICE` reads the installed native build's `build_info()` (the
+reference is refused a required device by name); and `statistics_refusal` reads the
+far-field statistics `NativeBackend.far_field_statistics` declares. `select_batch` is the
+reference group's lockstep runner; the native group runs one native session per member
+through its own `prepare_group` (**Selection** below). `auto` is `registry.AUTO`,
+`native`, with no fallback.
**Periodic-statistics storage hooks.** The seam carries three storage hooks for the
periodic statistics — `_reset_step_stats`, `_record_step_stats`, `_drain_step_stats` — so
@@ -153,7 +150,7 @@ assembled outside that grouping is refused by name, at `driver.batch`'s
`refuse_mixed_guides` and again by each backend's group: the reference's `_validate_members`
(`backends/host/group.py`) and the native `solprop_native.group.refusal`. A group hands each member its own problem, so a batched general run is what its members
are alone, to the same rounding bound; a problem is one trajectory and carries no batch
-axis. A member carrying a coupled flow is refused by name by the reference's binary64
+axis. A member carrying a coupled flow is refused by name by the reference's
lockstep batch (`NumpyBatchBackend`), whose stage arithmetic over the member axis applies
no flow action; the native group admits one, each member's own session carrying its flow.
@@ -216,7 +213,7 @@ checks; `native` takes its own
strict arithmetic, so the rows a table keeps for its last positions are shared whichever
filled them. A record may add a third operation, an exponential Adams trial's
coefficient tables in one call (`Helpers.tables`, the composition of
-`backends.multistep.trial_tables` over its own basis): `NATIVE` has it, in C++ beside
+`backends.numpy_ref.multistep.trial_tables` over its own basis): `NATIVE` has it, in C++ beside
the basis (`trial_tables` in `lagrange_basis.hpp`), so its resident engine forms every
table of a trial in one call rather than a dozen; `PLAIN` composes them. Translation runs
before any backend exists and needs nothing compiled:
@@ -370,8 +367,8 @@ threading beyond what `scipy.fft` does with `workers=1`. It exists to be read an
every step of the anatomy in [architecture.md §8](architecture.md) is a named function with a
docstring stating the formula. It is slow (tens of separate array operations per stage) and
that is acceptable; it is the reference against which every other backend is validated, and
-the supported executor of the short list of features `native` does not run yet: the TDSE
-response, CF4 coupled-flow sections and single precision away from Apple silicon
+the supported executor of the one feature `native` does not run yet: the TDSE
+response
([the list](../guide/performance/backends.md#features-that-run-on-the-reference-backend)).
A new feature joins the list only as an accepted exception (§10).
@@ -408,7 +405,11 @@ unitary change of basis, the tables' own execution maps (at one interval of degr
for a constant guide; any sections and degree for a moving one, below), `n_pol = 1`, the
constant unit `power_weight` and each region's declared responses — the instantaneous
cubic in either form, and a delayed silica tensor Raman term beside it — over the
-reference's three components — and refuses every other by name, before it allocates. The
+reference's three components. Every other combination is refused by name once, in the
+problem's own validation (`translate/admission.py`, `check_general_route`), which both
+backends run before they allocate; the per-node tensor, the form and the delayed maps are
+`translate/general.py`'s (`node_kerr_tensor`, `node_kerr_form`, `node_delayed_maps`) and
+the node chunk `backends/node_chunk.py`'s (`general_planned_chunk`), shared with `native`. The
real cubic is evaluated by its **symmetric contraction**, `symmetric_cubic`: the tensor
summed once per node and evaluation over the orderings of each monomial
`(E_j E_k) E_l`, `j <= k <= l`, and ten monomials a sample in place of the dense 81
@@ -423,10 +424,11 @@ the in-stage absorber and the return to the interaction picture.
the coherent wall block of a capillary (scalar, one `HE_1m` family, or vector, the two
radial families of one azimuthal order), the declared unitary basis of a *split* block on
the general modal route, and the connection of a moving solid guide. All take the same path
-through this backend. Each section's method is one of three:
+through this backend. Each section's method is one of two:
-- **`"constant_schur"`.** The backend decomposes the leaf's frozen generator once per
- section, in its own constructor, and applies a Schur matrix-function action over the
+- **`"constant_schur"`.** The leaf's frozen generator is decomposed once per section,
+ in the backend's constructor, by the factors both backends read
+ (`translate/coupled.py`, `coupled_flow_factors`), and the backend applies a Schur matrix-function action over the
channel axis inside `_trial_step`, taking the inverse the interaction picture needs as
the matrix function of the negated exponent rather than as a solve and measuring
`norm(U) norm(U^-1)` of the whole step against the leaf's declared bound. The Schur
@@ -441,21 +443,10 @@ through this backend. Each section's method is one of three:
picture's own factors carry it back. The residual is evaluated from the stage bundle's
`flow_section` and `flow_weights` rows, and applied only on the bins the error norm
scores (`grids.inband_mask`); the frozen flow alone carries the spectral collar.
-- **`"cf4"`**. A step anchored in a CF4 section applies, for each stage fraction `c`, the
- commutator-free product `exp(B₂) exp(B₁)` of the whole generator `G − C` at the two
- Gauss nodes of `[z, z + c h]` — the node weights built from the leaf by
- `translate.coupled.flow_weights`, not from the bundle, because a node is not a stage —
- and its exact inverse `exp(−B₁) exp(−B₂)`, both by the same scaled Taylor series the
- Schur path uses; the source carries no residual there. The four factors' spectral norms
- are measured at every stage and refused above the section's declared pair bound. A flow
- whose declared fallback left some sections `"frozen_residual"` is executed step by step
- by the method of the anchor's section. The held actions are keyed on the step's start as
- well as its section and length, so the resident peak `memory_ledger` reports does not
- grow.
The route needs the cumulative `Phi` beside the flow to be a multiple of the identity,
because the two factors are applied as a product, and it checks that; a frozen-residual
-or CF4 flow needs `Phi` only to be a **weighted sum of fixed terms**, and a problem whose
+flow needs `Phi` only to be a **weighted sum of fixed terms**, and a problem whose
`Phi` took the z table is refused by name. FSAL is absent there as it is everywhere in this
backend.
@@ -464,8 +455,7 @@ expansion per section of the guide's path, and `numpy_ref` evaluates `S(z1)` and
at every stage as the weighted sum of their coefficients over the stage bundle's
`transform_weights`, in the seam's own evaluation form, before the source pass runs on
them (`GeneralMaps.at`). It admits them only beside a `"frozen_residual"` flow carrying the
-connection on every section; a CF4 flow or none at all beside moving maps is refused by
-name.
+connection on every section; moving maps with no flow beside them are refused by name.
The anatomy of one stage — the node chunks, mode-averaged propagation as the one-node
case (ADR-010), the transverse components, the dual synthesis–projection pair, the fused
@@ -516,37 +506,6 @@ that kernel matches the line's Lorentzian to 1e-5; the soliton self-frequency sh
Gordon's rate to 2.4 % at T₀ = 800 fs; and the photon number of a lossless GNLSE run with
the shock term and Raman on is conserved exactly while the energy falls by 7.2e-6.
-**Single precision** ([SEP 33](seps/archive/sep-0033-single-precision-mlx.md), ADR-054). A
-problem whose `precision` is single is executed by `NumpySingleBackend`
-(`backends/numpy_ref/single.py`), which the registry constructs for `numpy_ref`: the
-reference's passes and step loop in binary32, through hooks of the reference that leave
-its binary64 arithmetic unchanged (`SourceTables`, `_propagator`, `_rounded`,
-`_error_norm`, `_envelope_sources`). It narrows the source tables once at construction,
-each with its block exponent (applied at upload where that is exact, refused otherwise
-unless the pass carries it, as the Raman kernel's does); holds the state at the launch's
-power-of-two scale `s`; forms every propagator, prefactor and z-table value in binary64
-and rounds it once; evaluates Kerr, Raman (scalar, tensor, mixtures) and the field-grid
-plasma in binary32 with each term's homogeneity factor in one host coefficient, the
-stable population forms and blocked running integrals
-(`translate.precision.prefix_sum`); reduces the controller's norms as scaled sums of
-squares against `atol / s`; applies the split-step collar as the correction
-`a + F(F^-1(a)(f - 1))` in binary32, `f - 1` formed in binary64 and rounded once (a
-binary64 state keeps `F(F^-1(a) f)`); and checks after every trial that the
-state and stages stayed complex64, and after every step that the finished state is
-complex64 and finite. Its fixed-schedule batches are the plain `HostGroup`, each
-member through its own binary32 trial: `NumpyBatchBackend`'s stage arithmetic over the
-member axis is binary64, so it refuses a single-precision member by name, and every
-lockstep batch refuses members of mixed precisions (`_validate_members`). The
-ionisation rate's table
-lookup, the envelope-grid plasma's cycle moments and the statistics are evaluated in
-binary64 from the binary32 field, which is what the oracle defines. Its constructor applies the settings' own admission (`translate.precision.admit_precision`):
-a problem that reaches `registry.select` without passing `SimulationSettings` is still
-refused an adaptive `rtol` below the floor. The Adams integrator is admitted (#342):
-binary32 Adams agrees with binary64 Adams to the method's own truncation on the survival
-cases, and a single-precision history resumes in binary32 (`HostHistory.load_rows`,
-`NumpySingleBackend._adams_restore`). `native` refuses a single-precision
-problem by name.
-
**Selection.** `backends/registry.py`: `available()` detects with
`importlib.util.find_spec` and never caches; `select(name, problem, request, *, grid,
modes, profiles)` builds one, for the problem and the request it is run with: the
@@ -600,7 +559,7 @@ either. The native backend's (`solprop_native.group.NativePreparedGroup`) opens
`NativeGroupSession`, which refuses by name what `solprop_native.group.refusal` names
(an adaptive member, step planes, members that do not share the schedule) before any
member session starts. The reference's (`backends.host.group_session.HostPreparedGroup`)
-opens its own `HostGroupSession` over the group runner its registry row names. A backend
+opens its own `HostGroupSession` over the group runner `select_batch` builds. A backend
that runs no group raises `BackendError` from `prepare_group`. The
three keyword arguments are what `stats()` needs and the kernel contract does not carry.
@@ -1014,10 +973,9 @@ the low-storage march holds flows (§8).
### 3.8 The general modal route
`native` executes the general modal route — `transform.modal_tables`, `linear.modal_metric`
-and `responses.regional_responses` — in the combinations `numpy_ref` admits, and refuses the
-rest with the reference's own words: its admission runs the shared list
-`solprop.backends.numpy_ref.admission.refuse_general_route` under its own name, so a new
-refusal there is a refusal here. It adds one limit of its own: a delayed response must be the
+and `responses.regional_responses` — in the combinations solprop's problem validation
+admits (`solprop.translate.admission.check_general_route`), which it runs where it is
+built, so a refusal there is a refusal on both backends, raised once. It adds one limit of its own: a delayed response must be the
six channels of the silica tensor Raman model on three components (§4,
`_refuse_native_route`). `linear.coupled_flow` is §3.9. The route is a **second execution
path** (`src/general_modal.cpp`, `GeneralModalSource`) beside the fused pipeline and not an
@@ -1070,12 +1028,12 @@ frequency-dependent map of three Cartesian components.
### 3.9 The coupled flow
-`native` executes `linear.coupled_flow` in the combinations `numpy_ref` admits but CF4: the
+`native` executes `linear.coupled_flow` in every combination `numpy_ref` admits: the
single constant-Schur section (the scalar wall, the TE/TM wall, the split block on the general
route) and the varying `"frozen_residual"` flow with its explicit residual and the taper
-connection. A `"cf4"` section is refused by name at the backend and at the capillary entry
-point, and so is a flow beside a Cartesian free-space problem, which no translation builds
-(`require_coupled_flow`). Its admission is the shared list under its own name.
+connection. A flow beside a Cartesian free-space problem, which no translation builds, is
+refused by name (`require_coupled_flow`). Its admission is the shared list under its own
+name.
- **Host-side, shared with the reference.** The Schur factors of every section's frozen block,
once at construction, as the reference forms them, and the eigenbasis of each triangular
@@ -1250,7 +1208,7 @@ Everywhere the multipliers move off the nodes, to wherever the route already hol
a symmetric response beside others, none alone, two for the polynomial; a Cartesian or planar
column adds its products and their spectra (`backends/node_chunk.py`, `_dispersive_chunk`). The
general modal route's chunk counts two buffers per node and component for each filtered field and
- each product of its workspace (`numpy_ref.general_modal.general_dispersive_buffers`,
+ each product of its workspace (`backends/node_chunk.py`, `general_dispersive_buffers`,
`general_node_chunk`).
- **Parity** (`tests/backends/test_native_dispersive_kerr.py`,
`tests/backends/test_native_dispersive_routes.py`): one evaluation, one step and ten replayed
@@ -1463,14 +1421,11 @@ version it was built at without comparing it, so an editable build stays valid a
commits that leave `native/` alone. Solprop knows only the registry
row and imports the package only when it is selected.
-**Every backend refuses what it cannot execute.** The general modal route's optional
-leaves (the coupled flow, the moving guide and the others) are part of the contract whether
-or not a backend executes them, and a backend that
-accepted a problem carrying one it ignores would silently run a different model. A backend
-therefore calls
-{py:func}`~solprop.backends.numpy_ref.admission.refuse_general_leaves` before it runs, naming in
-`admits` the leaves it executes, as `numpy_ref` does, or makes the same
-refusals itself, as `native` does in its own adapter. A third-party backend owes the same. A change that
+**What is executed is checked once.** The general modal route's optional leaves (the
+coupled flow, the moving guide and the others) are executed by both backends in the same
+combinations, and the problem's own validation refuses every other by name
+({py:func}`~solprop.translate.admission.check_general_route`), so neither backend repeats
+the list. A change that
touches `native/` is complete only when the gates (`poe check-all`) and solprop's lanes
are green with the current build installed.
@@ -1711,13 +1666,14 @@ the engine's existing code; the coarse channel spectra, times the pad scale and
analytic doubling, are written by the existing stage-combination kernel. Views, host-map
release and plans happen at construction; the `general_*` rows join
`memory_ledger()`. The adapter derives the per-node tensor, the form, the chunk and the
-delayed maps with `solprop.backends.numpy_ref.general_modal`'s public functions, so the
+delayed maps with solprop's shared derivations (`solprop.translate.general`,
+`solprop.backends.node_chunk`), so the
arrays are the reference's own, and refuses every combination the reference refuses by
name before allocating. A general modal run's statistics are solprop's `collect` on the
observed channel field with the basis metric.
-**Moving maps.** The adapter admits moving maps through the shared list
-(`moving_maps=True`) and packs each stage's `transform_weights`; the engine uploads every
+**Moving maps.** The problem's validation admits moving maps beside their connection's
+flow, and the adapter packs each stage's `transform_weights`; the engine uploads every
Chebyshev coefficient of both maps once, finds each stage's section on the host when a
trial's weights are loaded, and before each stage's source combines that section's `n_deg`
terms in a named team kernel (one team per map row, the row's bins its vector lanes, the
@@ -1726,8 +1682,8 @@ synthesis and projection read; nothing is allocated in a trial, and the ledger a
weights (`general_map_weights`).
**The coupled flow** (`src/coupled_flow.cpp`, `CoupledFlowAction`) executes the
-constant-Schur and frozen-residual sections; CF4 stays `numpy_ref`'s and is refused by
-name. The adapter uploads each section's eigenbasis
+constant-Schur and frozen-residual sections, every section the translation builds. The
+adapter uploads each section's eigenbasis
(`solprop.translate.coupled.schur_eigenbasis`: `W`, `Y`, the eigenvalues and the route per
bin, with the Schur factors of the bins routed to scaling and squaring) once at
construction, and `CoupledFlowAction::form_actions` forms every stage's and the whole
@@ -1861,18 +1817,17 @@ and a CPU target may contract multiply-adds, so the builds' answers agree to rou
bitwise, and every build is held to the same parity bounds against `numpy_ref` and the same
rounding bounds between repeats and thread counts. No build carries numerical pins
([SEP 25](seps/archive/sep-0025-rounding-level-reproducibility.md)): `REFERENCE` keeps its name for
-its tidy and sanitizer role only. The native adapter's contract tuples `MODAL_TABLES_FIELDS`, `REGIONAL_RESPONSE_FIELDS`
-and `COUPLED_FLOW_FIELDS` name the general leaves' members its adapter reads, so a rename
-is refused by `check_build`.
+its tidy and sanitizer role only. A rename of a seam member the adapter reads is caught by
+solprop's frozen tables (`tests/backends/test_driver.py`), which move with the seam.
## 4a. `mlx`: retired
[SEP 33](seps/archive/sep-0033-single-precision-mlx.md)'s experimental single-precision
backend for Apple GPUs was retired, unused, by
[SEP 47](seps/archive/sep-0047-run-ownership.md) §6.6, before the driver hierarchy it inherited
-was restructured. `backend="mlx"` raises `BackendError` (`registry.REMOVED_BACKENDS`),
-and output that records it still opens. Single precision runs on `numpy_ref`, its
-oracle. SEP 33 and the benchmark ledger keep what it measured.
+was restructured. `backend="mlx"` raises `BackendError`, as any name that is neither
+backend does, and output that records it still opens. Single precision went with it (ADR-077): every
+backend computes in binary64. SEP 33 and the benchmark ledger keep what it measured.
## 5. JAX: evaluated, not adopted
@@ -1996,22 +1951,18 @@ that execution space.
| the hybrid planar kind (`"slab"`) | yes | yes | held field-plasma source qualified on HIP; other combinations not qualified |
| general modal route (modal tables, metric, regional responses) | yes | yes; a delayed response only as the six silica tensor channels on three components | qualified |
| coupled flow: constant-Schur and frozen-residual sections | yes | yes | qualified |
-| coupled flow: `"cf4"` sections | yes | refused | refused |
| the dispersive electronic response (`responses.dispersive_kerr`) | every route | every route | qualified on one AMD Instinct device (HIP) and one NVIDIA Hopper device (CUDA) |
| the low-call Adams integrator (`StepperSettings.integrator`), both formulations | yes | every route, the history resident; beside a coupled flow the exponential formulation alone, on constant-Schur and frozen-residual sections, the history resident at any width and on either route of each bin (the eigenbasis or the augmented exponential), in the native session | beside a coupled flow the history resident at any width and on either route of each bin, in the native session (past 16 channels or off the eigenbasis route: run on an NVIDIA A40, not yet qualified); qualified |
-| the Adams comparison methods (`ComparisonMultistep`, benchmark-only: PEC, PECE, explicit Adams–Bashforth, the matched pairs) | yes | refused by name (SEP 47 §6.3) | refused by name (SEP 47 §6.3) |
-| a `PIController` subclass overriding a decision or the norm (`scale`, `accepts`, `propose`, `rejection_factor`, `start_factor`, `equivalent_norm`) | yes | refused by name (SEP 47 §6.3) | refused by name (SEP 47 §6.3) |
| the Adams carrier frame (`Multistep(carrier_frame="scalar")`, SEP 35) | every guided route; free space refused | CPU builds, resident, on every representation of the operator (a z table's chord carried as its own scalar table, #255); device builds refused | refused by name |
| the low-storage RK4IP march (`LowStorageRungeKutta`), replay-only | every diagonal-flow route | every diagonal-flow route, a resident engine whose step is one native call | flows recomputed per element; qualified under HIP, not under CUDA |
| moving solid guide (moving maps beside a frozen-residual flow) | yes | yes | qualified |
-| fixed-schedule groups | fibre and general guides; a coupled flow member by member (binary64 lockstep batch for the rest) | fibre and general guides, a coupled flow included (the native group session) | fibre and general guides, and a coupled flow where the session admits each member (the native group session) |
+| fixed-schedule groups | fibre and general guides; a coupled flow member by member (the lockstep batch for the rest) | fibre and general guides, a coupled flow included (the native group session) | fibre and general guides, and a coupled flow where the session admits each member (the native group session) |
| the native execution session and group session (SEP 47) | its own session (`HostSession`) | every native run and group (phase 5: no other native executor) | admitted on CUDA and HIP as on the CPU build (SEP 47 phase 4): beyond the modal route with planes, rows, the temporal monitor and the Adams history (phase 3), the spatial routes (radial, Cartesian, line, planar) with their fluence widths, a transverse edge and a collar's current, the noise models, dense output, the low-storage replay, a coupled flow wider than 16 channels or off its eigenbasis route, and the native group session; run on an NVIDIA A40 against the transitional bridge on the same device before phase 5 deleted it (bitwise, but for the bounded transverse sums and dense Runge-Kutta planes), not yet qualified on either vendor |
| periodic statistics between planes | yes | yes | yes: the native session takes each row natively and keeps it in a resident buffer until the next observation, with no return to Python (SEP 47 §6.4) |
| Adams snapshots and a resume that continues the history (`MarchSnapshot`) | yes | yes: the resident engine copies its rows (`store`, and `states`, `rates`, `applied` where kept) to the host at a kept save and back on a resume | compiles; not qualified |
| field-free observation (`observe(field=False)`), the runner, a transverse collar's rate and a set evanescent rate on the separable element | yes | yes (field-free observation needs no native change; the rate is one view in `SpatialCoefficients`) | compiles; not qualified |
| materials from optmat: gas Raman leaves of many rows on gradients, rate tables, optmat walls, the silica bundles | yes | yes | not qualified |
| coupling between runs (`field_plane`, `regrid`, `couple`, `Outcouple`, `transport`, `with_input`; SEP 31) | host-side: any source and receiving backend | host-side | receiving runs not qualified |
-| single precision (`precision="single"`, SEP 33): fibre, radial and Cartesian routes, Kerr, Raman, field and envelope plasma, both absorber schemes, Runge–Kutta and the low-storage march, fixed-schedule batches | the oracle | refused | refused |
**Materials from optmat** ([SEP 3](seps/archive/sep-0003-optmat-materials.md), a run under
`MaterialSelection`) reach the backends as the arrays any other run carries: index and
@@ -2057,12 +2008,11 @@ plasma do not cover it. The far-field statistics' device pass is not qualified (
## 10. What each backend is for
-solprop has exactly two backends (ADR-077, which replaced ADR-061's tiers), and no other,
-in this repository or as a plugin, will be implemented. ADR-068 makes `native` the primary
-backend, and ADR-064 retired `numba_cpu`. ADR-077 also settles binary64 as the only
-arithmetic: the single-precision path that still runs on `numpy_ref` is being removed. A
-feature lands on `native` and `numpy_ref` in either order. Every backend keeps what it runs, correct and in parity with the
-reference, and refuses the rest by name.
+There are exactly two backends, with fixed roles and no tiers, and no other backend will be
+implemented, in this repository or as a plugin (ADR-077; ADR-064 retired `numba_cpu`,
+ADR-068 made `native` the primary backend). A feature lands on `native` and `numpy_ref` in
+either order. Each backend keeps what it runs, correct and in parity with the other, and
+refuses the rest by name.
| backend | role | new features |
|---|---|---|
@@ -2082,11 +2032,6 @@ Which backend and build to choose for a run is the user guide's
## 11. Current limits
-- `"cf4"` coupled-flow sections run only on `numpy_ref`.
-- Single precision runs on `numpy_ref`, its oracle, alone; it
- refuses the general modal route, the hybrid planar kind, coupled flows, TDSE responses,
- the dispersive electronic response, the far-field statistics, noise models, the Adams
- integrator and an adaptive `rtol` below `1e-6`.
- The dispersive electronic response runs on `numpy_ref` and `native` on
every route; on a device it is qualified on one AMD Instinct generation (HIP) and one
NVIDIA Hopper device (CUDA); a polynomial mixture costs one group per distinct leg
diff --git a/docs/dev/conventions.md b/docs/dev/conventions.md
index 64379679c..1ab98b111 100644
--- a/docs/dev/conventions.md
+++ b/docs/dev/conventions.md
@@ -336,10 +336,8 @@ Kernel state and canonical storage: `(n_modes, n_pol, n_omega)` complex128, ω
contiguous (C order): one trajectory. A group of trajectories is a sequence of member
problems, not a leading axis ([SEP 47](seps/archive/sep-0047-run-ownership.md) §6.7); a backend
may hold its state privately with a leading axis of one (the native build's four-axis
-layout), and a march snapshot's file keeps that axis. Under `precision="single"` (SEP 33, ADR-054) a backend holds the
-state as complex64 at the run's exact power-of-two scale `s`, `a / s`; what crosses the
-seam to the host, and every saved field, is the physical state widened to complex128.
-Saved fields: `(n_z, n_modes, n_pol, n_omega)`; a batched scan stores each member
+layout), and a march snapshot's file keeps that axis. Every backend computes in binary64
+(ADR-077). Saved fields: `(n_z, n_modes, n_pol, n_omega)`; a batched scan stores each member
separately. Space-domain buffers: `(n_nodes_chunk, n_pol, n_to)`. Convenience accessors
may squeeze trivial axes; the seam and the files never do.
diff --git a/docs/dev/decisions.md b/docs/dev/decisions.md
index 1c40bf3f8..30843ac53 100644
--- a/docs/dev/decisions.md
+++ b/docs/dev/decisions.md
@@ -844,7 +844,7 @@ Withdrawn (development-process record, not a design decision).
operators. Both are the same predict–evaluate–correct recurrence with one call a
step, the same history lifecycle, controller, budget and observations.
- **Decision:** `Multistep.formulation` selects `"interaction"` (SEP 13's method) or
- `"exponential"`. The march is shared (`solprop.backends.multistep.AdamsMarch`); the
+ `"exponential"`. The march is shared (`solprop.backends.numpy_ref.multistep.AdamsMarch`); the
exponential formulation stores untransported samples in the same history store,
evaluates its source through the protected hook `StepDriver._adams_lab_source`, forms
the moments $k!\varphi_{k+1}(h D)$ once per trial (`solprop.backends.exponential`) and
diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py
index b48846bf5..385dd4c92 100644
--- a/docs/dev/figures/build.py
+++ b/docs/dev/figures/build.py
@@ -680,6 +680,7 @@ class Layer(NamedTuple):
"modal_path",
"coupled",
"general",
+ "admission",
"radial",
"free",
"planar",
@@ -695,7 +696,6 @@ class Layer(NamedTuple):
"envelope_plasma",
"raman",
"masks",
- "precision",
"tdse",
"tdse_source",
"run_request",
@@ -712,7 +712,7 @@ class Layer(NamedTuple):
"populations",
"memory",
"node_chunk",
- "multistep",
+ "ledger",
"adams_core",
"rk_core",
"exponential",
@@ -729,7 +729,6 @@ class Layer(NamedTuple):
(
"driver",
"adams",
- "general",
"low_storage",
"group",
"session",
@@ -742,9 +741,8 @@ class Layer(NamedTuple):
"backend",
"batch",
"passes",
- "transforms",
- "single",
- "admission",
+ "multistep",
+ "populations",
"envelope_plasma",
"general_modal",
"tdse",
@@ -1010,7 +1008,7 @@ def label(self) -> str:
Section(
1,
"identity and validation",
- ("grid_kind", "shape", "flags", "precision"),
+ ("grid_kind", "shape", "flags"),
"translate.problem.Flags",
("derived from the content of §2 to §8", "by translate.assemble"),
"T",
diff --git a/docs/dev/figures/kernel_contract.svg b/docs/dev/figures/kernel_contract.svg
index a40bf0f79..57f1b8bd2 100644
--- a/docs/dev/figures/kernel_contract.svg
+++ b/docs/dev/figures/kernel_contract.svg
@@ -80,7 +80,7 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill:
derived from the content of §2 to §8
by translate.assemble
-§1 grid_kind · shape · flags · precision
+§1 grid_kind · shape · flags
identity and validation
translate.problem.Flags
diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg
index a60d1417e..647233d39 100644
--- a/docs/dev/figures/layers.svg
+++ b/docs/dev/figures/layers.svg
@@ -164,13 +164,13 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill:
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 · precision · tdse
-tdse_source · run_request
+general · admission · 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
@@ -181,21 +181,20 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill:
backends/
protocol · registry · spatial
populations · memory · node_chunk
-multistep · adams_core · rk_core
+ledger · 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
-transforms · single · admission
-envelope_plasma · general_modal
-tdse
+driver · adams · low_storage
+group · session · group_session
+
+backends/numpy_ref/
+backend · batch · passes
+multistep · populations
+envelope_plasma · general_modal
+tdse
D
diff --git a/docs/dev/gui/design.md b/docs/dev/gui/design.md
index 3a71f3078..12d823eba 100644
--- a/docs/dev/gui/design.md
+++ b/docs/dev/gui/design.md
@@ -697,9 +697,8 @@ and the output size, and Run starts from it.
- **Cost**: the size comes from solprop, and the time from similar past runs in the workspace, or
it says it is unknown. Timing warm-up steps with `Simulation.estimate()` takes seconds, so it runs
on request (21.1).
-- **Numerics** (Advanced on the sheet): backend (from `backends.registry.available()`), precision,
- tolerance (`rtol`, with the route's default shown), integrator, save planes (`n_save` or a
- spacing), statistics (`StatsSpec`, with the route's default set and the class's required
+- **Numerics** (Advanced on the sheet): backend (from `backends.registry.available()`), tolerance
+ (`rtol`, with the route's default shown), integrator, save planes (`n_save` or a spacing), statistics (`StatsSpec`, with the route's default set and the class's required
statistics ticked), threads (`SOLPROP_THREADS`), boundary check and absorber, snapshots.
- **Output**: run title and workspace folder (defaults filled in).
- **Resume** replaces Run for an existing run of the same recipe that solprop reports resumable
@@ -1291,7 +1290,7 @@ All are decided: Q1 by D8, Q2 by SEP 41 as recommended, and Q3–Q12 as recommen
| Q8 | Minimum screen size. | 1280 × 800, usable at 1024 × 700. |
| Q9 | Should the GUI ever accept Python snippets (a custom pulse shape, a custom response)? | No; "Export as Python" is the way out. |
| Q10 | Compare-runs in the first release? | Revised by D16: no. Comparison is rare, so it comes later, as overlays of lines. |
-| Q11 | Expose GPU backends (mlx on Apple silicon, device builds)? | Yes, in Advanced, with the precision rule enforced. The `mlx` backend was retired in SEP 47 and is no longer offered. |
+| Q11 | Expose GPU backends (mlx on Apple silicon, device builds)? | Yes, in Advanced, with the precision rule enforced. The `mlx` backend was retired in SEP 47 and is no longer offered, and single precision with it (ADR-077). |
| Q12 | Accessibility targets (keyboard-only use, screen readers)? | Keyboard-complete from the start; screen readers best effort through Qt. |
## 15. Changes proposed to solprop
diff --git a/docs/dev/kernel-contract.md b/docs/dev/kernel-contract.md
index 2eaa50707..9f6248453 100644
--- a/docs/dev/kernel-contract.md
+++ b/docs/dev/kernel-contract.md
@@ -46,13 +46,12 @@ hard-mask zeros, and explicitly exact zeros and no-ops.
|---|---|---|
| `grid_kind` | `"field" \| "envelope"` | selects r2c/c2r vs c2c transforms and the response formulas |
| `shape` | `(n_modes, n_pol, n_omega)` | canonical state shape of the one trajectory the problem describes; a group of trajectories is a sequence of problems ([SEP 47](seps/archive/sep-0047-run-ownership.md) §6.7) |
-| `flags` | frozen dataclass of static flags and counts | `single_mode`, `scalar_pol`, `has_plasma`, `has_raman`, `has_spectral_responses`, `has_noise`, `has_temporal_absorber`, `constant_linop`, `mode_averaged`, `n_channels`, `n_raman` — kernels specialise on these at compile time. Derived from the content, never passed in |
+| `flags` | frozen dataclass of static flags and counts | `single_mode`, `scalar_pol`, `has_plasma`, `has_raman`, `has_noise`, `has_temporal_absorber`, `constant_linop`, `mode_averaged`, `n_channels`, `n_raman` — kernels specialise on these at compile time. Derived from the content, never passed in |
| `grids` | frozen dataclass | section 2 (`translate.masks.Grids`) |
| `linear` | frozen dataclass | section 3 (`translate.linop.LinearOperator`) |
| `responses` | frozen dataclass | section 5 (`translate.responses.CompiledResponses`) |
| `initial` | frozen dataclass | section 6 (`translate.problem.Initial`) |
| `route` | `ModalRoute \| GeneralRoute \| SpatialRoute` | the closed union of route descriptions (`translate.routes`), each variant carrying exactly the fields its executor needs: the transform (§4) and the route's own leaves. Below, under *The route union* |
-| `precision` | `translate.precision.Precision` | the arithmetic the run is held in ([SEP 33](seps/archive/sep-0033-single-precision-mlx.md), ADR-054): `state` and `source`, each `"double"` or `"single"` and equal (a mixed record is refused). Every table here is binary64 either way; a backend that admits single precision narrows its tables on upload, each with its own power-of-two exponent, and holds the state at the launch's exact power-of-two scale. Under `"single"`, `validate` refuses the general modal route, the hybrid planar kind, coupled flows, TDSE responses, the dispersive electronic response, the far-field statistics and noise models; `native` refuses a single-precision problem by name. Default `DOUBLE`. Added in version 36 |
| `split_crossing` | `SplitCrossing` or `None` | the split temporal collar's crossing limit (`boundaries.SplitCrossing`): `buckets`, each `(mode, frequency)` bin's walk-off bucket as `(n_modes,n_omega) uint8` (the fastest of its polarisations and positions, a coupled flow's channels with the generator's diagonal added to the shared carrier; `boundaries.split_rate_buckets`), the fastest rate a bin reaches `fastest` (the open first bucket's rate), the collar's `width` and the launch's limit `launch_limit`, which holds an adaptive `dz_max` and a fixed schedule at translation. A backend that reads the boundary monitor under the `"split_step"` scheme returns the state's energy per bucket with every reading (`BoundaryEnergies.occupancy`; native: `bind_occupancy`, then `monitor_reading`'s ten numbers followed by the 128 buckets); the monitor turns it into a cap on the steps until the next check (`HostDriver._split_capped`, the native Adams march's required `step_cap` bound), and a fixed schedule whose steps before the next check exceed it, at a check or when a resumed state is watched, stops with a `BoundaryError`. `None` under the in-stage scheme or without a temporal collar. Added in version 43 |
`PropagationProblem` also carries two **properties**, which are not fields and so not rows
above: `transform`, the route's transform, and `device_ready`, true when Φ (§3) and the prefactor (§5) are each a weighted sum
@@ -68,7 +67,7 @@ what the problem promises alone; `translate.problem.validate_run(problem, reques
the same checks in the same order with the request-dependent ones among them — the tables
and leaves covering the run, a schedule landing on every plane and kink, the collar's
stability, the split collar's crossing, the exponent budget, the method's admission on the
-route and the statistics the route and precision admit — and every backend calls it before
+route and the statistics the route admits — and every backend calls it before
it allocates.
### The route union
@@ -181,14 +180,11 @@ singleton run.
`transform.modal_tables` (§4) and `responses.regional_responses` (§5) are optional. Each
defaults to `None`, and a problem with all four `None` carries no field-sized or dense
buffer for them. The leaves carry `int32` region and support indices beside float64,
-complex128, bool and intp. **A backend refuses by name each leaf it does not execute**,
-through `solprop.backends.numpy_ref.admission.refuse_general_leaves(..., admits=...)`, or its own
-equivalent check in a compiled backend's adapter (as `native`'s does), because the version check alone cannot fire
-for a backend compiled against the same constant. The combinations a backend executes
-are one shared list, `solprop.backends.numpy_ref.admission.refuse_general_route`, run
-under the caller's name with the section methods it executes and whether it evaluates
-moving maps (`moving_maps=False` by default), so every backend refuses the same problems
-in the same words. On the general route `response_order` is `(accumulate,)` alone — the
+complex128, bool and intp. Both backends execute the same combinations of them, and
+**the problem's own validation refuses every other by name**, once
+(`solprop.translate.admission.check_general_route`, run by
+`PropagationProblem.validate` and so by `validate_run` where a backend is built). On the
+general route `response_order` is `(accumulate,)` alone — the
response is the regional leaf and not a fused-pass stage — and the prefactor row is the
real crop scale. What a backend reads across the seam on that route is the leaves' arrays,
`grids.crop_index`, `grids.pad_scale`, `n_to` and the prefactor row.
@@ -211,18 +207,6 @@ read the side it came from; reading the opening side instead drops a whole run f
order to **first** (measured 2.1e-3 at ten steps of a qualification taper, against
3.45e-9 once the tie is broken this way).
-A `"cf4"` section stores exactly what a `"frozen_residual"` one does — the same Chebyshev
-coefficients of `G` and `C` along z — and differs in how a backend integrates it and in
-what its rows mean: no explicit residual (`residual_norm_bound` is zero) and a pair bound
-for `inverse_condition_bound`. A CF4 step of length `l` from `z` exponentiates the whole
-generator `L = G − C` at the two Gauss nodes `z + c_j l`, which are **not** stage
-positions, so the stage bundle's `flow_weights` cannot supply them; `numpy_ref` builds them
-from the leaf itself — `solprop.translate.coupled.flow_weights` of the node against the
-step's `flow_section`, the same Chebyshev evaluation the bundle row is, from `z_edges` and
-the `n_deg` axis the leaf already carries. A compiled backend would need up to twelve such
-positions per step (two nodes at each of six distinct stage fractions); no bundle row
-carries them, which is why CF4 runs on `numpy_ref` alone.
-
The Schur factors of a section's frozen block and the actions formed from them are
backend-local and never cross the seam. The compiled backends form the actions in their
own kernels, from the eigenbasis of each section's triangular factor
@@ -254,28 +238,14 @@ which is natural and not required. **No one-sided rule** is needed for it, unlik
geometry, where only their slope jumps, so a position on an edge takes the section
starting there (within 1e-10 of the span) and the value differs from the other side's by
the representation error alone. A problem whose maps do not move — no modal tables, or
-maps of one coefficient — reports `None`. A backend that does not evaluate moving maps
-refuses such a problem by name through the shared admission list.
+maps of one coefficient — reports `None`. Moving maps are admitted only beside the
+`"frozen_residual"` flow that carries their connection.
-**The frozen literals and the contract tuples.** `tests/backends/test_driver.py` freezes
+**The frozen literals.** `tests/backends/test_driver.py` freezes
`LINEAR_OPERATOR_FIELDS` (`dataclasses.fields(LinearOperator)`), the descriptor's and the
-request's fields,
-`STAGE_TERMS_FIELDS`, `STAGE_SCALARS_FIELDS` and `SEPARABLE_OPERATOR_FIELDS`; a change to
-the seam rewrites the literal it changes. `solprop-native` exports
-its own tuples of the names it relies on — `LINEAR_OPERATOR_FIELDS`, `TRANSFORM_FIELDS`,
-`RESPONSE_FIELDS`, `STAGE_SCALARS_FIELDS`, and `MODAL_TABLES_FIELDS` (`electric`,
-`omega_fine`, `projection`, `region_index`, `source_frequency_factor`, `synthesis`),
-`REGIONAL_RESPONSE_FIELDS` (`n_terms`) and `COUPLED_FLOW_FIELDS` (`connection`,
-`generator`, `inverse_condition_bound`, `n_deg`, `n_modes`, `section_methods`) for the
-members of `ModalTransformTables`, `RegionalResponseTables` and `CoupledFlowTables` its
-adapter reads itself — and its `check_build()` refuses a solprop without one (a dataclass
-field, or a property for the derived counts), so a renamed leaf or member fails there with
-a message naming it rather than as a missing attribute inside the adapter. The two sets are
-separate objects that this document keeps in step. Members read only inside solprop's own
-derivations the adapter calls (`node_kerr_tensor`, `node_delayed_maps`, `metric_at`,
-`frozen_generator`, `schur_blocks`) are solprop's to keep consistent and are not listed;
-for that reason `ModalMetricTables` has no tuple, the adapter handing the leaf to
-`metric_at` whole.
+request's fields, `STAGE_TERMS_FIELDS`, `STAGE_SCALARS_FIELDS`,
+`SEPARABLE_OPERATOR_FIELDS` and `MARCH_POLICY_FIELDS`; a change to the seam rewrites the
+literal it changes, and `native` and solprop are changed together in the same commit.
The dataclass tree is exactly `PropagationProblem` and the five array groups `grids`,
`linear`, `transform`, `responses` and `initial`, and beside it the run request's
@@ -326,7 +296,7 @@ rather than stepping it with another method. `Multistep.formulation` is
`HostAdamsMarch` keeps the controller -- orders, slots, rejection,
restarts, the call budget and the ledger -- and hands every state-sized operation of
its fused and exponential paths to an engine
-(`solprop.backends.multistep.AdamsEngine`): the startup sample, a trial's predictor,
+(`solprop.backends.numpy_ref.multistep.AdamsEngine`): the startup sample, a trial's predictor,
source, corrector and scored defect norms, the adoption and transport of an accepted
step, holding and restoring the accepted state around a dense observation, and the
observation itself. Only scalars, the coefficient tables and the norms cross the
@@ -334,8 +304,7 @@ interface. The default returns `None`, and the march uses `HostHistory`, the hos
engine, over the other Adams hooks; `native` always returns one resident in its native
state, and refuses the untransported compensation witness by name (SEP 47 phase 5
deleted its host-engine fallback, and with it its `_adams_source`, `_adams_lab_source`,
-`_adams_propagator`, `_adams_block` and `_adams_adopt` overrides). The general path
-(PECE and the comparison variants) keeps host arrays.
+`_adams_propagator`, `_adams_block` and `_adams_adopt` overrides).
`HostDriver._adams_source(lab, anchor, position, *, purpose)` takes a
`TDSEPurpose` label so startup, predictor, corrected-field and diagnostic
@@ -596,16 +565,9 @@ The three certificate rows are per section: `generator_error` is the frozen bloc
diagnostic **plus** the relative Chebyshev tail of the z expansion, `residual_norm_bound`
is the enclosure `Σ_k ‖r_k‖₂` of the explicit residual over the **whole** section (`|T_k|
≤ 1`, so it bounds the extrema no sample sees), and `inverse_condition_bound` is
-`exp(dz_max (μ_max − μ_min))` of the frozen block. On a **`"cf4"`** section the same three
-rows mean: the Chebyshev tail alone, **zero** (the whole generator is in the exponential),
-and the **pair bound** `exp((2/√3) dz_max (m_hi − m_lo))` on the product of the four
-factor norms of any forward-and-inverse CF4 pair a step anchored there can form, `[m_lo,
-m_hi]` enclosing the Hermitian-part spectrum of `G − C` over the step's reach by Weyl's
-inequality on the stored coefficients; its growth bound `exp((2√3 − 3)/6 · dz_max (m_hi −
-m_lo))` is that row to the power `(2 − √3)/4` and needs no row of its own
-(`solprop.translate.coupled.cf4_growth_bound`). `0.9/(K · cond)` from the last two is the
-advisory step `solprop.translate.problem.validate_coupled_flow` warns `dz_init`, `dz_max`
-and a fixed schedule against; no row carries it.
+`exp(dz_max (μ_max − μ_min))` of the frozen block. `0.9/(K · cond)` from the last two is
+the advisory step `solprop.translate.problem.validate_coupled_flow` warns `dz_init`,
+`dz_max` and a fixed schedule against; no row carries it.
**What executes.** Two families of combinations, either of which may carry the vector
block (which differs only in the generator it stores and in carrying no factored pair; on
@@ -614,18 +576,9 @@ each family's own and exactly zero between them, stored like the scalar one in t
true-power amplitude): `section_methods == ("constant_schur",)`, the dense generator at
one interval of degree zero, no connection and an identically zero `residual_norm_bound`,
beside a `Phi` that is the exact constant representation; and `section_methods` that is
-`"frozen_residual"` or `"cf4"` on **every** section, the dense generator at any degree
-along z, an optional connection and, on a frozen-residual section, a residual the
-interaction-picture source carries, beside a `Phi` of any device-ready representation. A
-`"cf4"` section is integrated by the commutator-free fourth-order exponential `exp(l(a A₁
-+ b A₂)) exp(l(b A₁ + a A₂))` of the whole generator at the Gauss nodes (`a, b = (3 ∓
-2√3)/12`, the early-weighted factor acting first; `solprop.translate.coupled.CF4_NODES`,
-`CF4_WEIGHTS`) with no residual, its inverse the product of the negated factors in reverse
-order. A step is integrated by the method of the section its anchor lies in, so a flow
-whose declared fallback mixed the two is the same equation throughout. For a CF4 section
-the backend measures the four factor norms of every stage's pair and refuses a product
-above the declared pair bound; the flow's own `O(l⁵)` defect is invisible to the embedded
-pair, so a run through CF4 sections is at most **fourth** order.
+`"frozen_residual"` on **every** section, the dense generator at any degree along z, an
+optional connection and a residual the interaction-picture source carries, beside a `Phi`
+of any device-ready representation.
Both need no noise draw and a scalar state (`n_pol = 1`, the action contracting the
channel axis alone; the synthesis may resolve two components, which the flow never reads),
@@ -636,7 +589,7 @@ ln[w(z)/w(0)]` is that channel's for all of them, and the capillary wall's varyi
generator carries the rest of each channel's own on its diagonal, `−½ d ln(w_n/w_1)/dz`
(real, zero on the reference channel and on a constant guide), so `exp(ΔΦ)` times the flow
renormalises every channel by its own power weight; the term is part of the stored
-generator and so of the frozen block, the residual and the CF4 exponent alike. The backend
+generator and so of the frozen block and the residual alike. The backend
measures `norm(U) norm(U^-1)` of the whole step itself and refuses above the declared
`inverse_condition_bound` rather than trusting it.
@@ -658,9 +611,8 @@ package has certified, and admitted on the **general modal** route, where
which a declared unitary rotation leaves where it was. A flow that mixes a
`"constant_schur"` section with any other, a factored generator, a `"diagonal"` section, a
varying degree or a connection beside a constant section, and a `Phi` that took the z
-table beside a varying one, are refused by name. `numpy_ref` executes every admitted
-combination; the native backend executes the constant-Schur and frozen-residual
-sections and refuse a `"cf4"` section by name
+table beside a varying one, are refused by name. `numpy_ref` and the native backend
+execute every admitted combination.
## 4. Multimode transform
@@ -674,7 +626,7 @@ sections and refuse a `"cf4"` section by name
| `geometry` | `SpatialGeometry` or `None` | the spatial leaf below; absent for modal transforms |
| `node_weights` | `float64 (n_nodes,)` or `None` | physical transverse weights in m² at z=0 for resolved modal populations; divide by taper scale squared at later planes; mode-averaged runs use `aeff` and spatial runs use geometry weights |
| `kind` | `"mode_averaged"`, `"modal"`, `"radial"`, `"free"` or `"slab"` | what the transform represents, checked on construction against the geometry, the tables, the node count and the node weights; `mode_averaged` is `kind == "mode_averaged"`, so a one-node quadrature is `"modal"` and gets no area conversion |
-| `modal_tables` | `ModalTransformTables` or `None` | the full E/H reference of a general modal guide — separate synthesis S and projection Q, both on `omega`, the dense complex E and H per √W, the reciprocal partners and their Gram, the coefficient dual, the node rule with its Cartesian coordinates, area weights and per-node `int32` region index, the `−iω` source factor, the active-support mask and the declared profile error, with optional `factors`. A leaf in `translate.modal_tables`. S and Q are Chebyshev coefficients along z, `(n_intervals, n_deg, ...)`. A snapshot guide's are one coefficient over one interval, exact for a map that does not move; a **moving** solid guide's are one expansion per section of its path (`solprop.translate.modal_path.path_transform_tables`), sampled at the section's Chebyshev-Lobatto points on the provider's own rule at each position — a rule whose panel edges move with the material interfaces, so node `q` is one point `x_q(z)` moving smoothly with the geometry and keeps one material region, which is why `region_index` stays one row per node — and combined per stage over `StageScalars.transform_weights` (§1). The dense reference, its partners, the coordinate dual, `node_xy`, `weights` and `valid` are then stored at the section edges, which are `z_edges`. `omega` is `grids.omega` itself, `omega_fine` the problem's fine spectrum — which sizes the support mask `valid` and nothing else, the state being a coarse spectrum and both maps therefore coarse, `(..., n_omega)` — and `z_edges` covers the run. When it is present the general route reads these tables and the real static `synthesis`, `projection` and `projection_coarse` above are **declared placeholders**: the single zero column `(1, n_modes)` and its two `(n_modes, 1)` transposes, with no taper, node weights or geometry, all checked by `Transform.__post_init__`, so that a route that does not use them cannot carry a second, silently wrong transform. The route's own chunking is its backend's ledger. It is never present beside spatial `geometry`; a general guide has `n_pol = 1` and three physical components per channel (`MAX_N_COMPONENTS`), which is not the same thing as three state polarisations (`MAX_N_POL` stays 2). Every backend executes it in one combination — a field grid, a certified identity `linear.modal_metric` or the dense metric of a declared unitary change of basis, the tables' own execution maps at one interval of degree zero (or a moving guide's maps of any sections and degree beside the `"frozen_residual"` coupled flow that carries their connection; CF4 and no flow at all are refused by name beside them), `n_pol = 1`, the constant unit `power_weight` and each region's declared responses, instantaneous and delayed — and refuses every other by name, before it allocates. A declared change of basis also composes its identity into `gauge_id`, `+circular(i,j)`, which the metric must match |
+| `modal_tables` | `ModalTransformTables` or `None` | the full E/H reference of a general modal guide — separate synthesis S and projection Q, both on `omega`, the dense complex E and H per √W, the reciprocal partners and their Gram, the coefficient dual, the node rule with its Cartesian coordinates, area weights and per-node `int32` region index, the `−iω` source factor, the active-support mask and the declared profile error, with optional `factors`. A leaf in `translate.modal_tables`. S and Q are Chebyshev coefficients along z, `(n_intervals, n_deg, ...)`. A snapshot guide's are one coefficient over one interval, exact for a map that does not move; a **moving** solid guide's are one expansion per section of its path (`solprop.translate.modal_path.path_transform_tables`), sampled at the section's Chebyshev-Lobatto points on the provider's own rule at each position — a rule whose panel edges move with the material interfaces, so node `q` is one point `x_q(z)` moving smoothly with the geometry and keeps one material region, which is why `region_index` stays one row per node — and combined per stage over `StageScalars.transform_weights` (§1). The dense reference, its partners, the coordinate dual, `node_xy`, `weights` and `valid` are then stored at the section edges, which are `z_edges`. `omega` is `grids.omega` itself, `omega_fine` the problem's fine spectrum — which sizes the support mask `valid` and nothing else, the state being a coarse spectrum and both maps therefore coarse, `(..., n_omega)` — and `z_edges` covers the run. When it is present the general route reads these tables and the real static `synthesis`, `projection` and `projection_coarse` above are **declared placeholders**: the single zero column `(1, n_modes)` and its two `(n_modes, 1)` transposes, with no taper, node weights or geometry, all checked by `Transform.__post_init__`, so that a route that does not use them cannot carry a second, silently wrong transform. The route's own chunking is its backend's ledger. It is never present beside spatial `geometry`; a general guide has `n_pol = 1` and three physical components per channel (`MAX_N_COMPONENTS`), which is not the same thing as three state polarisations (`MAX_N_POL` stays 2). Every backend executes it in one combination — a field grid, a certified identity `linear.modal_metric` or the dense metric of a declared unitary change of basis, the tables' own execution maps at one interval of degree zero (or a moving guide's maps of any sections and degree beside the `"frozen_residual"` coupled flow that carries their connection; no flow at all is refused by name beside them), `n_pol = 1`, the constant unit `power_weight` and each region's declared responses, instantaneous and delayed — and refuses every other by name, before it allocates. A declared change of basis also composes its identity into `gauge_id`, `+circular(i,j)`, which the metric must match |
The transform carries **no node chunk**. How many nodes a response pass holds at once is
an execution choice, not part of the scientific problem
@@ -751,9 +703,13 @@ sqrt(A_eff(0)/A_eff(z))`, which for a scale-invariant mode is the `a(0)/a(z)` of
## 5. Responses (the compiled nonlinear term)
+The fused-pass sequence, `response_order` (e.g. `intensity, ionisation_rate,
+plasma_scan, plasma_current, kerr, raman_drive, accumulate`), is not a field: it is
+derived from the leaves below, so a stage is never named without its leaf nor a leaf
+held without its stage.
+
| field | shape / dtype | meaning |
|---|---|---|
-| `response_order` | tuple of enum | the fused-pass sequence (e.g. `intensity, ionisation_rate, plasma_scan, plasma_current, kerr, raman_drive, accumulate`) |
| `kerr_coefficients` | frozen dataclass | `chi3`-derived coefficients with the transform normalisation folded in; THG flag; vector cross-term factors |
| `dispersive_kerr` | `DispersiveKerrTerms` or `None` | the frequency-dependent electronic response, a leaf described below: one channel per population with its own fine-grid multipliers and density table, `None` without one |
| `ionisation_table` | tuple of `RateTable` | rate tables in plasma-channel order, identical to each channel's `rate`; empty without a plasma response |
@@ -764,7 +720,6 @@ sqrt(A_eff(0)/A_eff(z))`, which for a scale-invariant mode is the `a(0)/a(z)` of
| `labels` | tuple of str | unique stable component labels, in species order |
| `species_names` | tuple of str | chemical species names in the same order; names may repeat |
| `species_densities` | tuple of scalar Chebyshev tables | one number density per label, including zero and non-ionising populations |
-| `spectral_responses` | tuple of `EnvelopePlasmaAssembly` | explicit post-transform source assembly, empty or the same object as `envelope_plasma` |
| `envelope_plasma` | `EnvelopePlasmaAssembly` or None | labelled envelope channels with validated instantaneous rate/cycle rules and coarse-axis Drude/loss multipliers; ordinary polarisation remains a separate stream until spectral assembly |
| `prefactor_representation` | `"constant" \| "expansion" \| "table" \| "separable"` | which representation carries the prefactor, following §3 |
| `prefactor` | `(n_intervals, n_deg, n_modes, n_pol, n_omega) complex128` Chebyshev table or `None` | the UPPE/envelope prefactor `−iω/(2ε₀c Re n_eff)` (or its envelope form) times the crop scale — on the general modal route the **real crop scale alone**, the `−iω` being `transform.modal_tables.source_frequency_factor` and the reciprocal normalisation being inside Q — as a table along z over the same span and the same declared kinks as Φ (a `"constant"` prefactor stays one interval of degree 0 and does not carry a response path's kinks); the real part of the index only, the loss stays in `D`. Exactly zero outside the active mask at every position. Present for `"table"` and for `"constant"`, whose one interval of degree 0 is exact, and absent for `"expansion"` |
@@ -1218,12 +1173,9 @@ embedded estimate is computed either way), `n_steps`, `n_rejected`, and `step_st
keyed by statistic name, without a batch axis. Nothing writes to an observation's
arrays again, the backend included: `run_problem` hands them to the output without a copy,
and a handler may keep them. The array-backed state of a host backend is observed as it is
-and frozen, so its next step replaces it rather than accepting in place. `field_owned` says
-whether the backend made `field` for this observation alone and keeps no reference to it (the
-native backend's host copy, the reference's widened single-precision state; a backend's
-`observes_fresh_field`): the run's conversion to true-power amplitudes then works on it in
-place, where for any other field that conversion's new array is what detaches the plane from
-the backend's state (SEP 47 §5.4). It is `False` by default and without a field.
+and frozen, so its next step replaces it rather than accepting in place; the run's conversion
+to true-power amplitudes makes a new array, which is what detaches the plane from the
+backend's state (SEP 47 §5.4).
`step_stats` is a `StepStats`: `z` `(n_rows,)` and `step` `(n_rows,)` — the index of the
accepted step each row was taken after, into *this* observation's `step_history`, which
@@ -1248,29 +1200,26 @@ both distributions, never through an untyped optional field
Every addition updates the rows of this document (which
`tests/translate/test_problem.py` checks against the dataclasses in both directions),
-the frozen descriptor tables in `tests/backends/test_driver.py`, `native`'s
-`_check_seam` manifest (`PROBLEM_FIELDS`, `REQUEST_FIELDS`, `ROUTE_FIELDS` and the
-leaves' tuples) when the native adapter reads it, the capability and refusal
+the frozen descriptor tables in `tests/backends/test_driver.py`, the capability and refusal
tests, the memory ledger ([architecture.md §8](architecture.md)), the manual and the
source and error-budget evidence, and adds a release-note fragment. It lands on
`numpy_ref` and native CPU, in either order (ADR-068), unless its accepted SEP records
an exception. There is no version counter to bump (ADR-068).
-A new **backend** extends no descriptor: it consumes this contract as it stands. It
-registers with one `BackendEntry` row in `solprop.backends.registry.BACKENDS`
-([backends.md §1](backends.md)), visits every route variant and component to prepare
-or refuse it by name, and runs through its own `prepare` (one run) and
-`prepare_group` (a group of compatible members, or a `BackendError` saying it runs
-none), so the run layer names no backend. It qualifies on the conformance suite in
-`tests/sessions` and against the reference.
+There are exactly two **backends**, `native` and `numpy_ref`, and no other will be
+added (ADR-077; [backends.md §1](backends.md)). Each consumes this contract as it
+stands, visits every route variant and component to prepare or refuse it by name, and
+runs through its own `prepare` (one run) and `prepare_group` (a group of compatible
+members, or a `BackendError` saying it runs none), so the run layer names no backend.
+Both qualify on the conformance suite in `tests/sessions`, native against the
+reference.
## 11. The Adams march
*Normative since kernel schema 39 ([SEP 39](seps/archive/sep-0039-native-adams-march.md),
ADR-060). The native backend executes it on its CPU build for `Multistep()` in either
formulation, adaptive or replayed; every other configuration and backend runs the
-reference. The comparison methods (`ComparisonMultistep`) are the reference's alone:
-native refuses them by name ([SEP 47](seps/archive/sep-0047-run-ownership.md) §6.3).*
+reference.*
The step loop of `Multistep()` is one algorithm with one reference implementation,
solprop's `HostAdamsMarch` and the controller core it calls (`solprop.backends.adams_core`).
@@ -1323,7 +1272,7 @@ position and a kink restart together. Only then is the next proposal tested agai
floor. A floor so found is raised after the attempt is recorded.
**What a native call takes**: the policy, once per run and again when the monitor's
-interval changes (`MARCH_POLICY_FIELDS`, exactly the record's fields), with a replay's
+interval changes (`MarchPolicy`, every field of the record), with a replay's
schedule (its steps and edges); the state above, with a replay's cursor (the driver's
schedule index); and its bounds: the target; for a replay, the edges' slack
(`replay_tolerance`); whether the collar rests; the accepted steps after which the
@@ -1351,11 +1300,11 @@ one attempt, of seven and of none to one set of bits.
**The boundary checks inside the march** ([SEP 45](seps/archive/sep-0045-native-rk-march.md)
§4.8, normative since its phase 1). Unless the run's monitor reads a transverse edge, a
call also takes the monitor's checks: the bounds carry the monitor's watch policy
-(`BoundaryMonitor.watch_policy`, exactly `WATCH_POLICY_FIELDS`: the scaled floor
+(`BoundaryMonitor.watch_policy`: the scaled floor
regions, the launch floor, the activity, warning and stop thresholds, the quiet
collar's share, count and opt-in, the cadence, the interval, the crossing share and
each bucket's step limit) and the most readings one call buffers; the state carries the
-monitor's watch state (`WATCH_STATE_FIELDS`). After each accepted step that reaches its
+monitor's watch state (`BoundaryMonitor.watch_state`). After each accepted step that reaches its
statistics the march applies `due`, and on a due step takes the reading, buffers it
against the attempt's row and predicts `BoundaryMonitor.step`: the split cap, which
then caps the next step, the cadence, the quiet verdict, a first warning or a stop. It
@@ -1387,9 +1336,8 @@ and the wheel stage's scan of the strict files' disassembly for a fused instruct
*Normative from [SEP 45](seps/archive/sep-0045-native-rk-march.md) phase 3. `native` executes it
(`native/src/rk_march.cpp`), adaptive or replayed, with `PIController`'s norm, weighted or
-not, on CPU and device builds; a controller that overrides one of `PIController`'s
-decisions is the reference's alone, and native refuses it by name
-([SEP 47](seps/archive/sep-0047-run-ownership.md) §6.3).*
+not, on CPU and device builds. `PIController` is final (ADR-077), so its decisions are
+the only ones either backend takes.*
The adaptive loop of `RungeKutta()` (`integrator is None`, §7) is one algorithm with one
reference implementation, solprop's `HostDriver._march_to` and the controller core it
diff --git a/docs/dev/kernel-problem.md b/docs/dev/kernel-problem.md
index 9a8ea7e2b..6768a85c1 100644
--- a/docs/dev/kernel-problem.md
+++ b/docs/dev/kernel-problem.md
@@ -180,8 +180,8 @@ a replayed fixed schedule lands on every save plane and every hard kink. A failu
## What is compiled
The instantaneous Kerr response, the delayed Raman response and the plasma response
-compile, each into its own leaf of `responses`, and `responses.response_order` lists the
-passes a stage makes, in order.
+compile, each into its own leaf of `responses`, and `responses.response_order`, derived
+from those leaves, lists the passes a stage makes, in order.
**Plasma.** A plasma carries its own ionisation rate, so it supplies the run's
`ionisation_table` tuple and, beside it, one labelled
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 3161bb0cf..ae5c85666 100644
--- a/docs/dev/seps/archive/sep-0033-single-precision-mlx.md
+++ b/docs/dev/seps/archive/sep-0033-single-precision-mlx.md
@@ -16,7 +16,7 @@
| **Supersedes** | — |
| **Superseded-by** | — |
| **Implementation** | John Travers with Opus 5.5 (AI); phases 1–4 on `feature/sep-0033-single-precision`, one pull request |
-| **Manual pages** | [Single precision](../../../guide/performance/single-precision.md); [Choosing a backend](../../../guide/performance/backends.md); the worked example [Single precision](../../../auto_examples/performance/plot_01_single_precision.rst) |
+| **Manual pages** | Single precision (the page, retired by ADR-077); [Choosing a backend](../../../guide/performance/backends.md); the worked example Single precision (retired by ADR-077) |
## Part I — Proposal
diff --git a/docs/dev/seps/archive/sep-0036-native-reduced-precision.md b/docs/dev/seps/archive/sep-0036-native-reduced-precision.md
index 88046b3af..0dcb19f32 100644
--- a/docs/dev/seps/archive/sep-0036-native-reduced-precision.md
+++ b/docs/dev/seps/archive/sep-0036-native-reduced-precision.md
@@ -244,8 +244,7 @@ can therefore qualify noise where single cannot (§4.4).
through hooks of the reference (`_upload`, `_propagator`, `_rounded`, `_error_norm`,
`_finish`, `_envelope_sources` and others). ADR-054 records single precision as an
opt-in arithmetic validated against its oracle. The manual page
- [Single precision](../../../guide/performance/single-precision.md) carries the survival
- record.
+ Single precision (retired by ADR-077) carried the survival record.
- **What SEP 33 measured** (its Part II):
- Every survival case survives in binary32 at the floor except the reduced
comb-to-continuum case. That case survives only over 0.05 m, because noise-free
diff --git a/docs/dev/stepper.md b/docs/dev/stepper.md
index d51717b21..34afba93b 100644
--- a/docs/dev/stepper.md
+++ b/docs/dev/stepper.md
@@ -177,17 +177,14 @@ completed step (irrelevant without dense output, but tested).
**Who marches** ([SEP 47](seps/archive/sep-0047-run-ownership.md)). The NumPy reference runs
its own step driver and marches in `backends/host/`: `HostDriver`, `HostAdamsMarch`,
-`HostGeneralPath`, `HostLowStorageMarch` and the fixed-schedule group `HostGroup`. Every
+`HostLowStorageMarch` and the fixed-schedule group `HostGroup`. Every
native run is its native session's, whose compiled Runge-Kutta, Adams and low-storage
marches decide natively from one policy record each: the native backend loads a start
itself (`solprop_native._load`) and keeps its Adams march's policy, ledger and history
in a record of its own (`_NativeAdamsMarch`), and inherits no Python step loop. The
decision corpora hold the reference's marches and the compiled ones to the same
decisions on the same readings; where this page names a member of the reference's
-march, the compiled march's decision is the same. The comparison variants
-(`ComparisonMultistep`, whose general Adams path is the reference's) and a controller that
-overrides a decision are the reference's alone: the native backend refuses them by name
-(SEP 47 section 6.3).
+march, the compiled march's decision is the same.
**FSAL, the evidence.** The rebasing identity `k₁ⁿ⁺¹ = exp(ΔΦⁿ)·k₇ⁿ` and the condition it
needs (nothing changes the state after the last stage) are algorithms.md §2. **The hard
@@ -219,10 +216,8 @@ may be reused through it.
**A coupled flow recycles no stage, on any backend.** A frozen-residual flow adds
`R_s(z)·u` to the same lab-frame source the material response is evaluated in, so its
stages are the same objects a diagonal flow's are. The flow actions a step holds are keyed
-on `(section, dz)`, so a step that begins in a different section rebuilds them; a `"cf4"`
-section's actions are keyed on `(section, dz, z)` as well, because they exponentiate the
-generator at Gauss nodes inside each stage's own interval, and nothing is reused between
-steps. An adaptive run, whose `dz` changes every step, re-forms the actions every trial;
+on `(section, dz)`, so a step that begins in a different section rebuilds them. An
+adaptive run, whose `dz` changes every step, re-forms the actions every trial;
the compiled backends form them in their own kernels, at 3 to 11 % of a step.
The identity itself is known. On a frozen-residual or constant section the last stage's
@@ -233,9 +228,8 @@ fraction where the action is the identity, is `N(y) + R_{s'}(z + h) y`. So
section**, `s' = s`: the certified forward action it needs is the whole step's `U_h`,
which the step already holds and whose conditioning it has measured. It fails at a
section edge, where the next anchor's frozen block and one-sided weights differ — the
-same invalidation as the actions' own key. A CF4 section has no such identity (its
-actions depend on `z`). In rounding the rebased stage differs from a fresh one by the
-round trip `U_h U_h⁻¹`, a few `C u` with `C` the declared conditioning.
+same invalidation as the actions' own key. In rounding the rebased stage differs from a
+fresh one by the round trip `U_h U_h⁻¹`, a few `C u` with `C` the declared conditioning.
It is not taken. One stage evaluation on the coherent-wall cases (scalar wall, TE/TM
wall, taper; one thread) costs 0.18 to 1.39 ms on
@@ -257,20 +251,6 @@ normative: the step's section wins wherever the position lies inside it, matched
arriving a few ulp past name the same section, and a stage that ran past its step's
section takes the section it **closes** in.
-**A CF4 section's flow error is outside the embedded pair.** On a `"cf4"` section the
-interaction picture is built on the commutator-free fourth-order exponential of the whole
-generator rather than on a frozen block, and no explicit linear term enters the source.
-The embedded difference compares two combinations of the same stages, each carried by the
-same approximate flow, so the flow's own local defect — `O(h⁵)`, which makes the run at
-most **fourth** order — never appears in the error norm, and the controller can neither
-see nor bound it; the flow's error enters the source only through the stage actions'
-perturbation, one order of `h` higher. What governs it is the declared step:
-`validate_coupled_flow` compares the step-doubling estimate `(16/15) ‖V(h) − V(h/2)²‖` at
-the largest declared step with the controller's `rtol` and warns, advisory like the frozen
-split's advisory step. That estimate is asymptotic, not a bound; it has been checked
-against independent solves and the run's measured order. CF4 is admitted on `numpy_ref`
-only and is never selected by default.
-
### The low-storage march
The design record is [SEP 24](seps/archive/sep-0024-low-storage-interaction-picture-runge-kutta.md).
@@ -323,7 +303,7 @@ a Cartesian Kerr beam, `tests/backends/test_low_storage.py`).
### Low-call Adams methods
-`stepper/multistep.py` and `backends/multistep.py` hold the Adams integrator, the default
+`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;
`stepper/runge_kutta.py`'s `RungeKutta` names the tableau's march, the default of
`prop_gnlse`, `prop_waveguide` and `prop_free`).
@@ -336,10 +316,7 @@ picture, transporting the history, on request
- a corrector one order higher than its predictor;
- variable order up to five by default.
-`ComparisonMultistep` keeps the variants SEP 13 compared and did not select (PECE,
-explicit Adams–Bashforth, matched pairs) for the benchmarks.
-
-The march (`backends/multistep.py`) is shared: a backend supplies the step driver's two
+The march (`backends/numpy_ref/multistep.py`) is shared: a backend supplies the step driver's two
hooks `_adams_source`, one source evaluation at a lab-frame field returned in the step's
anchor frame, and `_adams_propagator`, and takes the march as its `_march_to` when the
problem carries an integrator. `numpy_ref` evaluates through its reference passes. `native` keeps
@@ -375,7 +352,7 @@ take the matrix functions from the flow actions' eigenbasis (`CoupledBlock.basis
compensation below,
formed from each sample's flow weights against the step's own section. The lab-frame
source runs without the flow's pipeline on every backend. The interaction formulation
-and CF4 sections are refused by name (`backends.exponential.refuse_formulation`).
+is refused by name (`backends.exponential.refuse_formulation`).
**The carrier frame** ([SEP 35](seps/archive/sep-0035-carrier-frame-exponential-adams.md),
`Multistep.carrier_frame`, opt-in) changes what an exponential trial interpolates and
@@ -425,8 +402,7 @@ whose passes are the kernels above; a backend may return its own from the hook
`_adams_engine`, as `native` does to keep the history resident in its
native state, always -- beside a coupled flow too, whose block functions, coupled predictor,
corrector, carry and defect norms are native passes over its resident eigenbasis, a
-frozen-residual flow's generator applied once per sample. The general
-path (PECE and the comparison variants) keeps host arrays.
+frozen-residual flow's generator applied once per sample.
**The native Runge–Kutta march** (ADR-069, [SEP 45](seps/archive/sep-0045-native-rk-march.md)):
`HostDriver._march_to`'s decisions are pure functions of a `RungeKuttaPolicy`
@@ -439,11 +415,9 @@ holds its extension; in SEP 47's native session the march holds it through its e
`rk::Engine::extend`, finishes the step itself and the session observes the plane where the
state lives, `native/src/dense.cpp`), a replayed schedule, a coupled flow (its guards transcribed), a z table
the transverse edge (read on the host, as the Adams march reads it) and a weighted norm
-(reduced natively in both loops), on CPU and device builds alike. A `PIController` subclass
-that overrides one of its decisions or its norm (`scale`, `accepts`, `propose`,
-`rejection_factor`, `start_factor`, `equivalent_norm`) is a reference-only experiment:
-`native` refuses it by name where the backend is built, before it allocates
-([SEP 47](seps/archive/sep-0047-run-ownership.md) §6.3), and `numpy_ref` runs it.
+(reduced natively in both loops), on CPU and device builds alike. `PIController` is final
+(ADR-077): defining a subclass raises `StepperError`, so its decisions and its norm are the
+only ones either backend takes.
**The policy and the native march** (ADR-060, [SEP 39](seps/archive/sep-0039-native-adams-march.md)):
every number the controller reads -- the order and its variability, the safety and
@@ -467,9 +441,7 @@ reading through the monitor at its own step. The Python march is the reference -
`HostAdamsMarch` for `numpy_ref`; native steps every configuration it admits with the
native march and has no Python march to fall back on (SEP 47 phase 5): the interaction
formulation beside a coupled flow, a representation its stage tables do not read and
-the untransported compensation witness are refused by name. The comparison methods
-(`ComparisonMultistep`, the one-order-higher PEC variant among them) and a controller that
-overrides a decision are refused by name at admission (SEP 47 §6.3). A z table's carrier frame steps natively: the stage tables carry the imaginary
+the untransported compensation witness are refused by name. A z table's carrier frame steps natively: the stage tables carry the imaginary
part of the table's reference element as a scalar table, read by the Clenshaw helper
solprop's tables use, so its shift and step cap are the host's (issue #255). A native
group replays each member in its own native session (`solprop_native.group`).
@@ -488,10 +460,9 @@ flow's `G(z_j) u_j` per sample as `applied` where the host engine keeps the samp
weights (`weights_`). Each engine refuses rows it does not keep, so those two
histories do not cross; every other history does. The engines' trial and dense-step
bookkeeping is not exported: a load forgets it (`reset`), as the next step is a new
-trial. Both methods are part of the `AdamsEngine` protocol. A march that cannot carry
-its history (the reference's general path), or one whose saved rows the engine
-refuses (another engine's rows, another build's shapes), resumes afresh, and the driver
-warns.
+trial. Both methods are part of the `AdamsEngine` protocol. A march whose saved rows
+the engine refuses (another engine's rows, another build's shapes) resumes afresh, and
+the driver warns.
Nothing in the trial lifecycle reads a held trial across the snapshot: it is taken
only at an accepted position with nothing ahead of it.
@@ -616,8 +587,7 @@ the measurements behind them.
record the norm each step actually scored; recording the equivalent instead would make
a run whose last steps were forced short close as a failure.
- **A diverged trial is a rejection.** A trial whose norm is infinite or NaN -- a
- propagator past `exp(88.72)` under single precision, past `exp(709.78)` in binary64, on
- a step too long for a strong collar or loss -- is rejected whatever the mode
+ propagator past `exp(709.78)`, on a step too long for a strong collar or loss -- is rejected whatever the mode
(`PIController.accepts`), and the proposal shrinks by `min_factor`; the history records
its norm as infinity, which `BackendHistory` admits on a rejected entry only. A replayed
schedule cannot shrink, and refuses such a step by name.
diff --git a/docs/dev/testing.md b/docs/dev/testing.md
index c5151f746..1087ee173 100644
--- a/docs/dev/testing.md
+++ b/docs/dev/testing.md
@@ -214,15 +214,9 @@ margin, never fitted to the discrepancy they then accept. The ladder of
[accuracy.md](accuracy.md) section 7 is the pattern (`16 ε` for one step, `128 ε` for
eight), and `tests/bounds.py` provides `gamma`, `higham_gamma`, `fft_bound`, `scan_bound`,
`prefix_bound`, `stencil_bound`, `step_budget` and `propagation_growth`. `u = 2⁻⁵³` and
-`ε = 2u` are not interchangeable. **Binary32** ([SEP 33](seps/archive/sep-0033-single-precision-mlx.md)):
-every formula takes its roundoff as a parameter (`UNIT_ROUNDOFF_32 = 2⁻²⁴`,
-`EPS_32 = 2⁻²³`) and is re-evaluated there, not scaled from its binary64 value, since
-`γ_k`'s denominator matters and each refuses `k u ≥ 1`. A binary32 bound includes one
-rounding per narrowed table entry and the conditioning of signed sums; where it exceeds a
-tenth of the case's accuracy envelope it is uninformative, and the comparison runs over a
-shorter window instead. Backend agreement within a binary32 rounding bound is never a
-survival verdict: that is SEP 33's class T study against binary64 references. A case whose dynamics amplify strongly is compared
-over a short window. Backend equivalence uses peak-normalised or normwise deviation
+`ε = 2u` are not interchangeable. Every formula takes its roundoff as a parameter,
+binary64's by default, and refuses `k u ≥ 1`. A case whose dynamics amplify strongly is
+compared over a short window. Backend equivalence uses peak-normalised or normwise deviation
against nontrivial reference fields, so a zeros-returning backend cannot pass:
`assert_within` checks finiteness, refuses an identically zero reference and reports
the observed margin. A result that is mathematically zero but reached through inexact
diff --git a/docs/examples/index.md b/docs/examples/index.md
index a0854497b..4c031b2f4 100644
--- a/docs/examples/index.md
+++ b/docs/examples/index.md
@@ -26,7 +26,7 @@ sequence if you are new to solprop.
| Supercontinuum and noise | [Input pulses and their shot-noise floor](../auto_tutorials/basics/plot_02_pulses.rst) | [Supercontinuum generation](../auto_examples/supercontinuum/index.rst): mechanisms, ensembles, noise, and coherence |
| Waveguide geometry | [Waveguide tutorials](../auto_tutorials/waveguides/index.rst) | [Waveguide physics](../auto_examples/waveguides/index.rst): gradients, tapers, vector modes, and planar compressors |
| Beams and free space | [Free-space tutorials](../auto_tutorials/free_space/index.rst): radial, Cartesian, line, and planar calculations | Follow their links to the [free-space guide](../guide/free-space/index.md) for model and resolution choices |
-| Performance | [Choosing a backend](../guide/performance/backends.md) | [Performance](../auto_examples/performance/index.rst): single precision on an Apple GPU against binary64 |
+| Performance | [Choosing a backend](../guide/performance/backends.md) | Follow its links to [performance tuning](../guide/performance/tuning.md) and [calibration](../guide/performance/calibration.md) |
| Inputs, analysis, and workflows | [Basics](../auto_tutorials/basics/index.rst) and [optical tools](../auto_tutorials/tools/index.rst) | Use the analysis in a worked simulation as a starting point for your own output |
## Two kinds of calculation
diff --git a/docs/examples/performance/README.rst b/docs/examples/performance/README.rst
deleted file mode 100644
index 5b3e19517..000000000
--- a/docs/examples/performance/README.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-Performance
------------
-
-Running the same physics in less memory: single precision on the reference backend,
-compared with binary64.
diff --git a/docs/examples/performance/plot_01_single_precision.py b/docs/examples/performance/plot_01_single_precision.py
deleted file mode 100644
index 1eae3b68b..000000000
--- a/docs/examples/performance/plot_01_single_precision.py
+++ /dev/null
@@ -1,205 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-r"""
-Single precision
-================
-
-A run can hold its field and evaluate its nonlinear response in binary32 with
-``precision="single"``, which halves the memory of every field-sized buffer. Whether a
-problem keeps its accuracy in binary32 is a property of the problem, so this example
-runs two of them both ways and compares: the photoionisation blue shift of
-:doc:`/auto_examples/processes/plot_06_ionisation_blue_shift`, here over its first
-0.2 m, and a short radial argon filament, both on field grids with the field-grid
-plasma. Each
-runs in binary64 on the default backend, ``native``, and in binary32 on the reference
-backend, ``numpy_ref``, which is named explicitly: it is the backend that runs single
-precision.
-
-**What to look for.** The two precisions' output spectra lie on top of each other on a
-40 dB scale; their difference, normwise over the whole saved field, is set by the
-controller's tolerance and the binary32 propagator's rounding, which accumulates over
-the run's steps, and the energy, the spectral centroid, the electron density, the peak
-intensity and the beam radius agree to the digits printed. The printed times show the
-unthreaded reference's binary32 run slower than ``native``'s binary64 one: here single
-precision is a check of accuracy, not a speed-up
-(:doc:`/guide/performance/single-precision`).
-
-**What is approximate.** Single precision's tolerance floor is ``rtol = 1e-6`` (the
-default here); a problem that amplifies small perturbations strongly, such as a
-noise-free comb-to-continuum, reaches its accuracy over a shorter length, as the
-:doc:`survival record ` states. Timings depend on
-the machine and its load.
-"""
-
-# %%
-# The capillary, in both precisions
-# ---------------------------------
-
-import time
-
-import matplotlib.pyplot as plt
-import numpy as np
-
-from solprop.constants import C
-from solprop.driver import Simulation, SimulationSettings, prop_capillary
-from solprop.grid import FieldGrid, RadialGrid
-from solprop.nonlinear.optmat_rates import OptmatRate
-from solprop.nonlinear.responses import KerrResponse, PlasmaResponse
-from solprop.physdata import gamma3
-from solprop.processing import get_spectral_density
-from solprop.pulses import GaussianPulse, SpatialPulse
-from solprop.spatial import SpatialMedium, critical_power
-from solprop.stepper import PIController
-
-# Binary64 on the default backend, binary32 on the reference, the backend that runs it.
-BACKENDS = {"double": "auto", "single": "numpy_ref"}
-
-
-def capillary(precision):
- """Run the blue-shift capillary's first 0.2 m; return it and its time."""
- started = time.perf_counter()
- run = prop_capillary(
- 13e-6,
- 0.2,
- "Ar",
- 1.7,
- wavelength=800e-9,
- fwhm=30e-15,
- energy=3e-6,
- loss=False,
- trange=600e-15,
- wavelength_limits=(300e-9, 4e-6),
- plasma=True,
- n_save=51,
- precision=precision,
- backend=BACKENDS[precision],
- progress="none",
- noise=None,
- )
- return run, time.perf_counter() - started
-
-
-runs = {precision: capillary(precision) for precision in ("double", "single")}
-for precision, (run, seconds) in runs.items():
- print(f"{precision}: {seconds:.1f} s on {run.metadata.backend.name}")
-
-
-def relative(a, b):
- """Return the normwise relative difference of two arrays."""
- return float(np.linalg.norm(a - b) / np.linalg.norm(b))
-
-
-double, single = runs["double"][0], runs["single"][0]
-print(
- "field difference at the end "
- f"{relative(single.fields()[-1], double.fields()[-1]):.1e}"
-)
-
-# %%
-# The spectra and the plasma
-# --------------------------
-
-fig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.6))
-for precision, style in (("double", "-"), ("single", "--")):
- run = runs[precision][0]
- omega, spectral = get_spectral_density(run, "omega")
- keep = omega > 0
- wavelength = 2 * np.pi * C / omega[keep]
- level = spectral[-1, keep] / spectral[-1, keep].max()
- left.plot(
- wavelength * 1e9,
- 10 * np.log10(np.maximum(level, 1e-6)),
- style,
- label=precision,
- )
- z = np.asarray(run["z"])
- density = np.asarray(run["stats/electron_density"]).reshape(z.size, -1).max(axis=1)
- right.plot(z, density * 1e-6, style, label=precision)
- centroid = 2 * np.pi * C * np.sum(spectral[-1]) / np.sum(spectral[-1] * omega)
- energy = float(np.asarray(run["stats/energy"]).ravel()[-1])
- print(
- f"{precision}: centroid {centroid * 1e9:.2f} nm, energy {energy * 1e6:.5f} uJ, "
- f"peak electron density {density.max() * 1e-6:.4e} cm^-3"
- )
-left.set_xlim(300, 1600)
-left.set_ylim(-40, 2)
-left.set_xlabel("wavelength (nm)")
-left.set_ylabel("spectral density at 0.2 m (dB)")
-left.legend()
-right.set_xlabel("z (m)")
-right.set_ylabel("peak electron density (cm$^{-3}$)")
-right.legend()
-fig.tight_layout()
-
-# %%
-# A radial filament, in both precisions
-# -------------------------------------
-#
-# A 30 fs pulse at three times the critical power for self-focusing, 3 cm in argon at
-# one bar, on 256 radial nodes in 2 mm and a field grid: Kerr self-focusing and the
-# field-grid plasma on every node.
-
-medium = SpatialMedium("Ar", 1.0)
-chi3 = gamma3("Ar", wavelength=800e-9)
-grid = FieldGrid.from_limits(0.03, 800e-9, (350e-9, 2e-6), 250e-15)
-carrier = 2 * np.pi * C / 800e-9
-density = float(medium.density(np.array([0.0]))[0])
-index = float(np.real(medium.index(np.array([carrier]))[0]))
-power = 3.0 * critical_power(800e-9, chi3 * density, index=index)
-energy = power * 30e-15 * np.sqrt(np.pi / (4 * np.log(2)))
-pulse = SpatialPulse(
- GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=energy), 100e-6
-)
-responses = [
- KerrResponse(chi3, thg=False),
- PlasmaResponse("Ar", OptmatRate("Ar", 800e-9)),
-]
-filaments = {}
-for precision in ("double", "single"):
- started = time.perf_counter()
- filaments[precision] = Simulation.from_radial(
- grid,
- RadialGrid(2e-3, 256),
- medium,
- responses,
- pulse,
- settings=SimulationSettings(
- controller=PIController(rtol=1e-6, atol=0.0),
- dz_max=1e-3,
- dz_init=3e-5,
- save_planes=list(np.linspace(0.0, 0.03, 31)[1:]),
- ionisation_field_range=(1e7, 1e11),
- precision=precision,
- backend=BACKENDS[precision],
- ),
- noise=None,
- ).run()
- run = filaments[precision]
- print(
- f"{precision}: {time.perf_counter() - started:.1f} s on "
- f"{run.metadata.backend.name}"
- )
-ends = [filaments[precision].fields()[-1] for precision in ("single", "double")]
-print(f"field difference at 3 cm {relative(*ends):.1e}")
-
-fig, (left, right) = plt.subplots(1, 2, figsize=(10.0, 3.4))
-for precision, style in (("double", "-"), ("single", "--")):
- run = filaments[precision]
- z = np.asarray(run["z"])
- peak = np.asarray(run["stats/peak_intensity"]).ravel()
- radius = np.asarray(run["stats/fwhm_r"]).ravel()
- left.plot(z * 1e2, peak * 1e-4, style, label=precision)
- right.plot(z * 1e2, radius * 1e6, style, label=precision)
- print(
- f"{precision}: peak intensity {peak.max() * 1e-4:.4e} W/cm^2, "
- f"final FWHM radius {radius[-1] * 1e6:.3f} um"
- )
-left.set_xlabel("z (cm)")
-left.set_ylabel("peak intensity (W/cm$^2$)")
-left.legend()
-right.set_xlabel("z (cm)")
-right.set_ylabel("FWHM radius (um)")
-right.legend()
-fig.tight_layout()
diff --git a/docs/guide/front-end/analysis.md b/docs/guide/front-end/analysis.md
index f4cf8492b..a426daf6a 100644
--- a/docs/guide/front-end/analysis.md
+++ b/docs/guide/front-end/analysis.md
@@ -52,8 +52,8 @@ element set to *Auto* is searched by solprop for the shortest pulse; **Take the
found** fixes them. The plane and band compressed are the ones chosen on *At this plane*.
**Run health** says how the run went: its step size along z with the rejected steps,
-the light removed at the time window's edges, the run's record (its backend, precision
-and threads, its wall time and peak memory) and its log. Check it before trusting a result: a run whose light
+the light removed at the time window's edges, the run's record (its backend and
+threads, its wall time and peak memory) and its log. Check it before trusting a result: a run whose light
reaches the window's edges, or whose steps collapse, needs a wider window or a smaller
tolerance ([numerics](../numerics/index.md)).
diff --git a/docs/guide/media/noise.md b/docs/guide/media/noise.md
index e779a10e9..a020c12e1 100644
--- a/docs/guide/media/noise.md
+++ b/docs/guide/media/noise.md
@@ -90,8 +90,8 @@ a seed keeps meaning one realisation on a given grid size.
- the whole field, seed included
- the field without the seed; processing does not restore it
* - refused beside
- - single precision and the planar grid
- - those, plus interpolated planes, field planes for coupling and coupled flows
+ - the planar grid
+ - it, plus interpolated planes, field planes for coupling and coupled flows
```
Both come from the same draw. {py:func}`~solprop.noise.input_noise` and
@@ -192,10 +192,10 @@ modified run, whose saved field holds no seed. See
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`
all take both. The keyword's default, `"default"`, resolves to `"input"` wherever the
-run can carry noise. Where it cannot, it resolves to none: under single precision, whose
-binary32 rounding swamps the seed, on the hybrid planar grid and on a `prop_waveguide`
-run of slab modes, neither of which declares a photon measure for its amplitudes per
-unit width (an explicit model is refused there), beside the TDSE response, whose runs are expensive, rarely repeated as
+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
+its amplitudes per unit width (an explicit model is refused there), beside the TDSE
+response, whose runs are expensive, rarely repeated as
ensembles and checked coherently (an explicit noise model there is warned on), and on
the Cartesian and line grids of `prop_free`, whose runs are slow and rarely about noise
sensitivity. The [table of defaults](../numerics/choosing-a-stepper.md#the-defaults)
diff --git a/docs/guide/numerics/adams.md b/docs/guide/numerics/adams.md
index d4c0f6567..bb16a5a9e 100644
--- a/docs/guide/numerics/adams.md
+++ b/docs/guide/numerics/adams.md
@@ -52,7 +52,7 @@ the general modal route and the radial, Cartesian, line and planar free-space ro
with constant and varying operators and every noise model the route admits. Beside a
coupled flow (a coherent capillary wall, a split block, a moving guide) only the
exponential formulation runs, on its constant-Schur and frozen-residual sections; the
-interaction formulation and CF4 sections are refused by name. Another backend refuses
+interaction formulation is refused by name. Another backend refuses
`Multistep()` by name and runs `RungeKutta()`. `Multistep(carrier_frame="scalar")`
removes the carrier's rotation from the exponential history on field grids
([the carrier frame](#the-carrier-frame)); it is opt-in and runs on `numpy_ref`
@@ -143,9 +143,7 @@ alone does not describe, and the cap cost it calls.
### What was compared
-SEP 13 also ran these variants. They remain executable, as
-`solprop.stepper.multistep.ComparisonMultistep`, for the benchmarks only, on
-`backend="numpy_ref"`; the native backend refuses them by name:
+SEP 13 also ran these variants. They were removed once the selection was settled:
- **PECE**, which spends a second call at the corrected field;
- **plain Adams–Bashforth**, with an a-posteriori recheck;
@@ -294,7 +292,7 @@ a multiple of the identity in the channel axis, so a step's reference is
$B = (\Delta\Phi/h)\,I + G_s$, with $G_s$ the frozen block of the section the step is
anchored in, and the two commute. On the bins that carry a block the step's flow and
moments are matrix functions of $hB$ in the channel axis
-(`backends.multistep.coupled_moments`, from the exponential of an augmented matrix
+(`backends.numpy_ref.multistep.coupled_moments`, from the exponential of an augmented matrix
carried back by the block's Schur vectors), applied to each sample's channel vector; off
them they are the diagonal ones. A frozen-residual section's residual $R_s(z)u$ is
linear in the state and joins the transported compensation above: each sample keeps its
@@ -303,9 +301,8 @@ own section's block -- rebuilding the history's residual when a step enters a ne
section -- and carries it by the reference's forward flow, never an inverse one. The
residual acts on the in-band bins, as the Runge-Kutta stage applies it. The lab-frame
sample is the material source alone. The interaction formulation stays refused, since it
-would carry its history through inverse actions whose conditioning grows with loss; a
-CF4 section, which exponentiates the generator inside each stage, is excluded. On the
-scalar wall and the taper (0.2 mm windows) it takes
+would carry its history through inverse actions whose conditioning grows with loss. On
+the scalar wall and the taper (0.2 mm windows) it takes
two to three times fewer calls than IP-DOPRI5 at matched error. On
the native backend, the bins on the flow actions' eigenbasis route take their matrix functions as
`W f(x + h λ) Y` from that basis, and the bins off it read them off the augmented exponential as
@@ -555,12 +552,10 @@ operator adds `q + 2` compensation volumes. These buffers are omitted when
complex field volumes. The [memory investigation](../../dev/benchmarks.md#resident-adams-storage)
gives measured peaks and an allocation breakdown for a large Cartesian beam.
-For the comparison benchmarks, `ComparisonMultistep(interpolant="hermite")` uses the
-generic cubic Hermite interpolant of the step's end states and derivatives instead. It
-is a reference, not a production option: its error is one order
-below what the step's estimate controls, so it can miss the requested accuracy even in
-the strong field. The witness is $u' = x^3$ on $[0, h]$, where the step is exact and
-the Hermite midpoint value is 0 against $h^4/64$.
+SEP 13 also compared the generic cubic Hermite interpolant of the step's end states and
+derivatives. Its error is one order below what the step's estimate controls, so it can
+miss the requested accuracy even in the strong field. The witness is $u' = x^3$ on
+$[0, h]$, where the step is exact and the Hermite midpoint value is 0 against $h^4/64$.
Interpolation is refused with the per-step collar (`absorber_scheme="split_step"`),
whose collared field is not the interpolant's, a coupled flow and the `modified` noise
@@ -578,8 +573,8 @@ What dense observation saves depends on the plane spacing:
spacing, so it saves only a few calls.
- **With 501 planes,** landing caps every step at the spacing. Dense observation then
takes 272 calls against 506 at `rtol=1e-3`. Its window error, 8.9e-4, is what that
- tolerance buys, the same as a landed run with 50 planes. The native and Hermite
- interpolants agree to every printed digit.
+ tolerance buys, the same as a landed run with 50 planes. The Adams and Hermite
+ interpolants agreed to every printed digit.
**A resume continues the history.** At every save plane and terminal checkpoint the
output keeps the march's state beside the field: the accepted state exactly as the
@@ -598,8 +593,7 @@ snapshot. Without a snapshot -- `resume_history=False`, an unreadable one, or on
the resuming backend cannot continue (a frozen-residual coupled flow's history does not
cross between `numpy_ref` and `native`) -- the history restarts at order one and the
startup call is charged again, and the resumed run agrees with the uninterrupted one to
-its tolerance. A method whose history cannot be kept at all (the comparison methods'
-general path) warns when its resume restarts it.
+its tolerance.
**Repeated rejections.** After three consecutive rejections the history is restarted
from the accepted state, which removes a parasitic mode a smaller step cannot.
@@ -659,7 +653,7 @@ gives the measured ranking by regime, on the native backend's CPU build and on d
- `numpy_ref` and `native` on every route, for both formulations, but
beside a coupled flow, where the exponential formulation alone steps its
- constant-Schur and frozen-residual sections and CF4 sections are refused. On `native`
+ constant-Schur and frozen-residual sections. On `native`
the history and the step's arithmetic are resident in the engine, on the CPU and on
the AMD CDNA 3 and NVIDIA Hopper devices SEP 16 qualified, beside a coupled flow too:
at any width and whichever route each bin takes. On the CPU build the
diff --git a/docs/guide/numerics/boundaries.md b/docs/guide/numerics/boundaries.md
index 571737c85..0aa0d813d 100644
--- a/docs/guide/numerics/boundaries.md
+++ b/docs/guide/numerics/boundaries.md
@@ -731,11 +731,10 @@ noise the floor alone fills the collar far above the threshold, so the option on
on noise-free runs; on a 2 cm noise-free GNLSE Raman/shock run whose pulse stays clear it
saved 6 to 9 % per step across `native` and the since-retired `numba_cpu` (Apple M5 Max). It is off by default
because a run cannot know in advance whether its light stays clear, and an omission
-judged too early is an error nothing reports (ADR-057). It applies to binary64 modal and
-radial stages with the in-stage scheme; the split scheme, the low-storage march, the
-general modal route, coupled flows, a real-space collar, single precision, and the
-Cartesian, line and planar routes, whose stages keep the term regardless, always keep
-the collar. The record's `collar_left_out_checks` counts the checks that found it out.
+judged too early is an error nothing reports (ADR-057). It applies to modal and radial
+stages with the in-stage scheme; the split scheme, the low-storage march, the general
+modal route, coupled flows, a real-space collar, and the Cartesian, line and planar
+routes, whose stages keep the term regardless, always keep the collar. The record's `collar_left_out_checks` counts the checks that found it out.
**Above the shot-noise floor.** Input noise, on by default, puts one photon per mode across
the whole window, guards and collar included. Light below that floor cannot be told from
diff --git a/docs/guide/numerics/choosing-a-stepper.md b/docs/guide/numerics/choosing-a-stepper.md
index 543feba35..d708f4231 100644
--- a/docs/guide/numerics/choosing-a-stepper.md
+++ b/docs/guide/numerics/choosing-a-stepper.md
@@ -37,11 +37,7 @@ on; pass `integrator=`, `rtol=` or `atol=` to choose another.
| `prop_free` (Cartesian, line) | `RungeKutta()` | 1e-4 | 0 | none |
| `prop_planar` | `Multistep()` | 1e-5 | 0 | none (not carried) |
-Under `precision="single"` every interface takes the table's integrator and no noise,
-and its `rtol` is the larger of the table's and `1e-6`, at most
-`1e-4`: `1e-6` on `prop_gnlse` and `prop_capillary`, the table's on the others
-([single precision](../performance/single-precision.md)). The
-spatial interfaces' tolerances apply where their `SimulationSettings` give no
+The spatial interfaces' tolerances apply where their `SimulationSettings` give no
controller; a free-space run that names no integrator takes `RungeKutta()`. A default
noise that resolves to none says why in the run record and the start-of-run summary,
and asking for noise beside the TDSE response is warned on: TDSE and Cartesian runs
@@ -51,9 +47,8 @@ The table lives in one place, `solprop.driver.defaults.ROUTE_DEFAULTS`, with the
collar's scheme beside it (`"split_step"` on the radial, free-space and planar routes
unless planes are interpolated, `"nonlinear"` elsewhere). The run record's `defaults`
entry and the start-of-run summary say which of the integrator, `rtol` and the scheme
-took the route's default, and a copy of the settings made with `dataclasses.replace` in
-the other precision takes that precision's default for each of them, the backend
-`"auto"` included.
+took the route's default, and a copy of the settings made with `dataclasses.replace`
+keeps taking the route's default for each of them.
**Naming an integrator keeps the interface's tolerance.** The tolerance in the table
belongs to the interface, not to the method: `prop_capillary(integrator=RungeKutta())`
diff --git a/docs/guide/numerics/fixed-schedules.md b/docs/guide/numerics/fixed-schedules.md
index 36241b3ee..3d9cc90e5 100644
--- a/docs/guide/numerics/fixed-schedules.md
+++ b/docs/guide/numerics/fixed-schedules.md
@@ -57,8 +57,8 @@ Two functions turn an adaptive run into a schedule.
reads the `step_history` a run writes to its output, which records every attempt with its
start, its length and whether it was accepted, and drops the rejected attempts. Replaying
the schedule with the same save planes repeats the run's step positions without its
-controller. With a method whose orders are fixed (below), that is how two precisions or
-two backends are compared on one discretisation; `prop_gnlse` steps with `RungeKutta()`
+controller. With a method whose orders are fixed (below), that is how two backends
+are compared on one discretisation; `prop_gnlse` steps with `RungeKutta()`
(a few seconds):
```python
@@ -100,7 +100,7 @@ def steps_of(run):
schedule = steps_of(soliton())
one = soliton(schedule=schedule)
-other = soliton(schedule=schedule) # change backend= or precision= here to compare
+other = soliton(schedule=schedule) # change backend= here to compare
print(np.array_equal(steps_of(one).steps, schedule.steps)) # True
```
@@ -113,18 +113,16 @@ makes that choice afresh, so its orders can differ in two ways:
- from the adaptive run's, where that run rejected a step: it then waits a step before
raising the order, which a replay never has to;
-- from another replay's in another precision or on another backend, where an estimate
+- from another replay's on another backend, where an estimate
lies near the margin: rounding decides the choice there, and a different order
sequence makes a different truncation error.
-Two replays in one precision on one backend are identical. To compare the arithmetic
+Two replays on one backend are identical. To compare the arithmetic
alone, replay with fixed orders: `RungeKutta()`, as above, or
`Multistep(variable_order=False)`, whose order rises from one as its history accrues to
`order` and stays there, by a rule that reads no estimate. Two such replays of one
schedule make the same truncation errors, so their difference is rounding alone.
-Single precision steps with each interface's own default, so a comparison across
-precisions replays the same method; name `Multistep(variable_order=False)` or
-`RungeKutta()` on both to fix its orders. Recording the order sequence in the schedule
+Recording the order sequence in the schedule
would also fix a variable-order replay's orders, and make it match its adaptive run;
that would extend the saved schedule and is not done, since a fixed order gives the
comparison without it.
diff --git a/docs/guide/performance/backends.md b/docs/guide/performance/backends.md
index 7b4566780..2239e4e4b 100644
--- a/docs/guide/performance/backends.md
+++ b/docs/guide/performance/backends.md
@@ -22,10 +22,9 @@ repeat the first:
| request | native usable | processor below the floor, or its features unreadable | native fails to load | no native package |
|---|---|---|---|---|
-| double precision, `auto` | `native` | refuses, naming the missing features or the failed check | the load's own error, unchanged | refuses, saying how to install it |
+| `auto` | `native` | refuses, naming the missing features or the failed check | the load's own error, unchanged | refuses, saying how to install it |
| `native` by name | `native` | as above | as above | as above |
| TDSE response, `auto` | `numpy_ref`; native isn't consulted | the same | the same | the same |
-| single precision, `auto` | refuses, naming `numpy_ref`; native isn't consulted | the same | the same | the same |
| `numpy_ref` by name | runs; native isn't consulted | the same | the same | the same |
`"auto"` has no fallback. These cases stay distinct, so an import failure is never reported
@@ -47,10 +46,9 @@ what runs it: a CPU build of the native backend, in a second environment, or
`Multistep()` ([the Adams integrator](../numerics/adams.md)) and `RungeKutta()`, each
the default of some interfaces
-([the defaults](../numerics/choosing-a-stepper.md#the-defaults)), are available across
-the binary64 backends for their supported configurations; single precision admits
-`Multistep()` in its exponential formulation on `numpy_ref`. Beside a coupled flow, Adams requires its
-exponential formulation; CF4 requires Runge–Kutta on the reference backend. Check the
+([the defaults](../numerics/choosing-a-stepper.md#the-defaults)), are available on
+both backends for their supported configurations. Beside a coupled flow, Adams requires its
+exponential formulation. Check the
response and geometry chapters for further restrictions. The backend used is
logged at INFO and recorded in the output.
@@ -88,8 +86,6 @@ supported on the supported platforms:
| feature | reached by | tracked by |
|---|---|---|
| the [TDSE response](../media/tdse.md) | `auto` or by name | |
-| `CoherentWall(method="cf4")`, and a coupled flow with a CF4 section, with `integrator=RungeKutta()` | by name | [#304](https://github.com/LupoLab/solprop/issues/304) |
-| [single precision](single-precision.md) | by name | [#342](https://github.com/LupoLab/solprop/issues/342) |
The TDSE row's `auto` is that of the entry points; `SimulationSettings` built by hand with
`tdse_execution` [names the backend](../results/interior-launch.md). Running `numpy_ref` by
@@ -98,23 +94,19 @@ a general waiver: a new feature still lands on both `numpy_ref` and `native`
([SEP 0](../../dev/seps/sep-0000-process.md)), and joins the list only as an accepted
exception.
-## The removed `numba_cpu` backend
+## Retired backends
-solprop once had a third double-precision backend, `numba_cpu`, and 0.1.0 is the last
-release with it. Naming it now raises {py:class}`~solprop.exceptions.BackendError`, naming
-`native` and `numpy_ref`, so a script or recipe that selects it needs `backend="native"`, or
-no `backend=` at all. Output, snapshots and a scan's execution metadata that record
-`numba_cpu` still load, and keep saying so: the record is not rewritten.
+solprop has exactly two backends, `native` and `numpy_ref`, and will have no other. It once
+had two more: `numba_cpu`, a third double-precision backend whose last release is 0.1.0,
+and `mlx`, an experimental single-precision backend for Apple GPUs. Naming either now
+raises {py:class}`~solprop.exceptions.BackendError`, naming `native` and `numpy_ref`, as
+any name that is neither does, so a script or recipe that selects one needs
+`backend="native"`, or no `backend=` at all. Output, snapshots and a scan's execution
+metadata that record a retired backend still load, and keep saying so: a recorded name is
+not a request, and the record is not rewritten. A run started on a release that had the
+backend finishes on that release.
-The backend is an execution resource, so changing it does not change a run's identity. A
-run can continue on `native` or `numpy_ref`, with its script's or recipe's backend changed
-deliberately, through the normal [resume](../results/resuming.md) checks; the original
-execution record stays and the new one is added. Adams history crosses to the new backend,
-except a frozen-residual coupled flow's: a host engine's history continues on `numpy_ref`,
-and on native starts afresh and says why. Anything refused is recovered with
-`solprop==0.1.0`.
-
-Its settings are gone too: `SOLPROP_NUMBA_THREADS`, `SOLPROP_NUMBA_REGIME`,
+The settings of `numba_cpu` are gone too: `SOLPROP_NUMBA_THREADS`, `SOLPROP_NUMBA_REGIME`,
`SOLPROP_FFTW_THREADS`, `SOLPROP_TRANSVERSE_FFTW_THREADS`, `SOLPROP_PASS_TIMERS`,
`NUMBA_DISABLE_JIT` and `NUMBA_NUM_THREADS` no longer do anything.
@@ -143,9 +135,8 @@ ten steps before committing a long campaign to one backend.
([execution resources](resources.md#memory)) before raising the channel count, the node
count or the grid.
- **The coupled flow** (a coherent wall, a varying wall, a split block): both CPU
- backends run the constant-Schur and frozen-residual sections;
- `CoherentWall(method="cf4")` runs on `numpy_ref` alone. A single small wall run gains
- nothing past a few threads. On a varying wall the flow's actions are re-formed every
+ backends run the constant-Schur and frozen-residual sections. A single small wall run
+ gains nothing past a few threads. On a varying wall the flow's actions are re-formed every
adaptive step, in a few per cent of a step on the native backend.
- **On a GPU** (a CUDA or HIP build of the native backend): pick it for large grids. Every general-route,
coupled-flow and moving-guide case measured ran faster on either a data-centre NVIDIA
diff --git a/docs/guide/performance/index.md b/docs/guide/performance/index.md
index 814c70f53..f3c002a37 100644
--- a/docs/guide/performance/index.md
+++ b/docs/guide/performance/index.md
@@ -24,5 +24,4 @@ building-native
resources
progress
tuning
-single-precision
```
diff --git a/docs/guide/performance/native.md b/docs/guide/performance/native.md
index 4a38df37b..c8b0a1e31 100644
--- a/docs/guide/performance/native.md
+++ b/docs/guide/performance/native.md
@@ -56,8 +56,6 @@ generator applied to its state), so a snapshot of it continues only on the nativ
backend, and a host engine's snapshot of it only on `numpy_ref`; a
mismatch starts the history afresh and says why. Other histories cross between backends.
-Separately, the `cf4` section method of a coupled flow (`CoherentWall(method="cf4")`)
-runs on `numpy_ref` only, with the Runge–Kutta march; the native backend does not run it.
The planar guide resolved in y is built into the GPU builds but has not been qualified
on a device. solprop has no fallback: an import failure, an incompatible build, a processor
below the build's baseline or a refused problem raises, naming what to do instead
diff --git a/docs/guide/performance/progress.md b/docs/guide/performance/progress.md
index cc348102a..8314d32dd 100644
--- a/docs/guide/performance/progress.md
+++ b/docs/guide/performance/progress.md
@@ -89,7 +89,6 @@ Starting propagation from z = 0 m to 10 mm
machine: arm64, Apple M5 Max, 18 CPUs (18 usable), 128 GiB, Darwin 27.0.0
backend: numpy_ref
threads: 1 (NumPy reference); budget 18 (affinity)
- precision: double
grid: field, 4096 points, window 683.1 fs, band 150 nm to 4 um
representation: mode-averaged, one mode, scalar
medium: Ar, 293.15 K, core radius 125 um
diff --git a/docs/guide/performance/single-precision.md b/docs/guide/performance/single-precision.md
deleted file mode 100644
index 9945c0f23..000000000
--- a/docs/guide/performance/single-precision.md
+++ /dev/null
@@ -1,225 +0,0 @@
-# Single precision
-
-solprop computes in IEEE binary64 (double precision) by default. A run can instead hold
-its propagated field and its nonlinear evaluation in binary32 (single precision) with
-`precision="single"` ([SEP 33](../../dev/seps/archive/sep-0033-single-precision-mlx.md)).
-Single precision halves the memory of every field-sized buffer and is what graphics
-processors without fast binary64 arithmetic compute in; whether a given problem keeps
-its accuracy in binary32 is a property of the problem, so this page records which do.
-
-```{note}
-Single precision runs on the reference backend, `numpy_ref`, named explicitly: it runs
-every route below as the reference implementation, which defines the arithmetic and
-measures it but is not fast. `native` computes in binary64 only
-([#342](https://github.com/LupoLab/solprop/issues/342)).
-```
-
-## Asking for it
-
-```python
-from solprop.driver import prop_capillary
-
-output = prop_capillary(
- 125e-6,
- 0.1,
- "He",
- 0.8,
- wavelength=800e-9,
- fwhm=10e-15,
- energy=100e-6,
- wavelength_limits=(200e-9, 3e-6),
- trange=200e-15,
- precision="single",
- backend="numpy_ref",
-)
-```
-
-`precision` is a keyword of `prop_gnlse`, `prop_capillary` and `prop_waveguide`, and a
-field of {class}`~solprop.driver.simulation.SimulationSettings` for the free-space
-interfaces. Under single precision:
-
-- **The tolerance floor.** An explicit adaptive `rtol` below `1e-6`
- ({data}`~solprop.translate.precision.SINGLE_RTOL_FLOOR`) is refused: a local error
- target below binary32's rounding per step buys steps without accuracy. A fixed
- schedule reads no tolerance.
-- **The default tolerance.** Each interface takes the larger of its binary64 default
- and the floor, and no more than `1e-4`
- ({data}`~solprop.translate.precision.SINGLE_RTOL_CAP`), the loosest value the
- survival record below measured: `1e-6` on `prop_gnlse` (whose binary64 default is
- `1e-7`) and `prop_capillary`, `1e-5` on `prop_waveguide`, `prop_radial` and
- `prop_planar`, and `1e-4` in free space
- ([its record](#free-space-at-its-binary64-tolerance)). A spatial interface applies it where its `SimulationSettings` give no
- controller; an explicit `rtol` or controller is the caller's.
-- **The step's exponent.** Binary32 represents ``exp(x)`` only up to $x \approx 88.7$
- (binary64 to 709.8). The Runge–Kutta interaction picture multiplies by the linear
- propagator and its inverse, so under `RungeKutta()` a replayed step (or an adaptive
- run's floor `dz_min`) over which a strong collar or loss reaches that exponent is
- refused at translation, with the shortest step that fits, and one within a tenth of
- it is warned of; a 1 mm capillary's default collar on 0.25 mm steps is one such case.
- The low-storage march and the exponential Adams method apply forward flows only: a
- damped bin's flow underflows harmlessly towards zero, so they are limited by growth
- alone, which no passive medium has. The Kerr beams' 0.5 µm steps, whose spectral
- collar reaches 150 to 180, run in binary32 with `LowStorageRungeKutta()` for this
- reason.
-- **The integrator.** Each interface takes its binary64 default
- ([the defaults](../numerics/choosing-a-stepper.md#the-defaults)): `Multistep()` in
- its exponential formulation on `prop_capillary`, `prop_radial` and `prop_planar`
- ([the Adams record](#the-adams-integrator-in-binary32)), `RungeKutta()` (DOPRI5)
- elsewhere; the low-storage march is admitted too. A run changes arithmetic, not
- method, when it changes precision.
-- **Noise.** A default `noise` resolves to none, and the run records why; an explicit
- noise model is refused (below).
-- **The backend.** `backend="auto"` is refused: it never resolves to a binary64 backend,
- nor to the reference implementation, which is named explicitly, `backend="numpy_ref"`.
-- **Outputs** are unchanged: saved fields, statistics and processing are binary64, and
- files are the same size. The precision is part of the run's configuration, so a run is
- never resumed in the other precision.
-
-## What it covers
-
-| | `numpy_ref` |
-|---|---|
-| capillaries, solid-core and GNLSE fibres, mode-averaged and multimode, field and envelope grids | yes |
-| Kerr, the envelope grid's third-harmonic term, scalar and tensor Raman, gas Raman mixtures, the silica bundle | yes |
-| field-grid plasma | yes |
-| envelope-grid plasma | yes |
-| radial and Cartesian free space, including a line, with their transverse collars | yes |
-| gradients, tapers, loss, both temporal absorber schemes, the polarisation taper | yes |
-| Runge–Kutta, the low-storage march | yes |
-| fixed-schedule batches through `Batch` | yes |
-| the Adams integrator | the exponential formulation |
-| noise models | refused (a known limitation, below) |
-| the general modal route, the hybrid planar kind, coupled flows, TDSE responses, the dispersive electronic response, the far-field statistics | refused |
-
-## How the arithmetic is kept accurate
-
-Binary32 carries about seven significant digits and represents magnitudes between about
-$10^{-38}$ and $3\times10^{38}$. solprop keeps both from limiting a run:
-
-- **Range.** Every table a run multiplies by is narrowed with its own power-of-two
- exponent, and the field is held at an exact power-of-two scale chosen from the launch.
- A nitrogen Raman kernel whose spectrum is near $10^{-50}$ — zero in binary32 as it
- stands — keeps every bin; a Cartesian energy sum that would reach $10^{44}$ is scaled
- before squaring.
-- **The linear propagator** $e^{\Delta\Phi}$ is formed to binary64 accuracy and rounded
- once per step. Its rounding still adds coherently from step to step, about
- $c\,M\,u$ after $M$ steps with $u = 2^{-24}$; the record below states $c$.
-- **Populations.** The ionised fraction and the neutral survival are formed in forms that
- keep relative accuracy at tiny and saturated exposures, and running integrals over
- time are summed in a balanced order rather than one long sequential sum.
-- **The controller, landing and every scalar** stay in binary64.
-
-## The survival record
-
-Each case below was run with `RungeKutta()` in both precisions: in single precision at
-`rtol = 1e-6` and `1e-5`, compared at the end of its window with a binary64 run on the
-same grid at `rtol = 1e-10`, against accuracy envelopes fixed before any comparison: the
-field to $10^{-3}$, the spectral energy in each of 32 bands above −40 dB to 1 %, the
-total energy to $10^{-4}$, the peak power or intensity, the final ionised fraction and
-the beam width to $10^{-3}$, all relative. The table gives the largest of those relative
-errors at `rtol = 1e-6`, the field's error, and binary64's own field error at the same
-tolerance for comparison.
-
-| case | single: field error | largest other metric | binary64 at `1e-6`: field | verdict |
-|---|---|---|---|---|
-| self-phase modulation, 10π, no dispersion (GNLSE) | 7.9e-6 | 9.6e-5 (bands) | 1.9e-6 | survives |
-| fundamental soliton, ten periods (GNLSE) | 2.7e-5 | 6.5e-6 (peak) | 1.1e-6 | survives |
-| dispersive-wave emission, He capillary gradient, two modes, 1.5 m | 2.6e-6 | 4.9e-5 (bands) | 1.3e-6 | survives |
-| comb-to-continuum, H₂ capillary at 40 bar, 0.1 m, noise-free | 2.6e-3 | 3.4e-4 (peak) | 3.0e-7 | survives over 0.05 m |
-| plasma blue shift, Ar capillary, field grid, 0.4 m | 1.8e-4 | 2.1e-4 (bands) | 1.6e-4 | survives |
-| plasma blue shift, Ar capillary, envelope grid, 0.4 m | 1.3e-5 | 2.1e-5 (ionised) | 1.3e-5 | survives |
-| molecular Raman, N₂ capillary at 2 bar, 0.3 m | 1.1e-6 | 2.7e-5 (bands) | 1.6e-7 | survives |
-| radial Kerr beam, 0.3 m | 8.5e-6 | 1.3e-4 (bands) | 8.6e-6 | survives |
-| radial filament in argon with plasma, 3 cm | 2.3e-6 | 1.2e-5 (peak) | 2.2e-6 | survives |
-| Cartesian Gaussian diffraction, 64 × 64 | 2.4e-7 | 2.4e-7 (bands) | 0 | survives |
-| Cartesian Kerr beam, 64 × 64 | 2.7e-6 | 2.0e-5 (bands) | 4.2e-6 | survives |
-
-At `rtol = 1e-5` every case but the comb-to-continuum stays inside its envelopes too.
-The table is the reference implementation's. Its rows were measured
-on 24 and 25 October 2026, before the single-precision fixes of 27 October 2026 made the
-reference's transforms sum in binary32 (the comb-to-continuum case, re-measured on them,
-is below) and before the default grids and boundaries changed
-([SEP 21](../../dev/seps/archive/sep-0021-absorbing-boundaries.md)); they have not been
-repeated since.
-
-- **The comb-to-continuum limit.** Noise-free stimulated Raman scattering grows from
- whatever seed the Stokes band holds; rounding seeds it at the level of the unit
- roundoff times the field. In binary64, adding a seed of $2^{-24}$ of the launch's peak
- spectral amplitude moves the result at 0.1 m by 5 %: the sensitivity is the
- physics, and binary32's own rounding is such a seed. Over 0.05 m the run is inside
- every envelope. The reference's transforms now sum in binary32, as the backends' do
- (NumPy's float32 transforms are binary64 rounded once, which this record first used);
- on them the field error is 2.5×10⁻⁴ at 0.05 m and 1.6×10⁻³ at 0.1 m, so the limit
- stands ([SEP 36](../../dev/seps/archive/sep-0036-native-reduced-precision.md)).
-- **The tolerance floor** stays at `1e-6`: tightening from `1e-5` to `1e-6` still buys
- accuracy (by 92 % in the self-phase-modulation case's field).
-- **The propagator's step limit.** With no nonlinear source the rounding of
- $e^{\Delta\Phi}$ accumulates as $c\,M\,u$ after $M$ steps, with $c \approx 0.5$
- measured on a capillary operator over $10^2$–$10^4$ steps (0.1–0.3 on a GNLSE one).
- The field envelope of $10^{-3}$ is reached after about $3\times10^4$ steps; a run
- that needs more takes binary64.
-
-### Free space at its binary64 tolerance
-
-Free space's binary64 default, `rtol = 1e-4`, was qualified in binary32 on 5 November
-2026 ([#85](https://github.com/LupoLab/solprop/issues/85)); until then a single-precision
-free-space run took `1e-5`. Each case ran with `RungeKutta()`, noise-free, on a
-power-of-two grid, in binary32 on the `mlx` backend (since retired) and binary64 on
-`native`, against a binary64 run
-at `rtol = 1e-6` (the field's relative difference):
-
-| case | binary64 at `1e-4` | binary32 at `1e-4` | binary64 at `1e-5` | binary32 at `1e-5` |
-|---|---|---|---|---|
-| radial filament in argon with plasma, 3 cm | 9.8e-5 | 2.0e-5 | 1.2e-5 | 1.0e-5 |
-| radial Kerr beam with its transverse collar, 8 cm | 2.5e-5 | 2.1e-5 | 1.7e-6 | 2.0e-6 |
-| Cartesian argon filament, 128 × 128, 1.5 cm | 6.9e-5 | 2.4e-5 | 6.2e-6 | 5.2e-6 |
-
-Binary32 at `1e-4` is as close to the reference as binary64 at `1e-4`, or closer: the
-tolerance, not the precision, sets the error. The Cartesian filament takes 48 steps at
-`1e-4` against 61 at `1e-5`.
-
-### The Adams integrator in binary32
-
-`Multistep()` was admitted on 5 November 2026 by the rule
-[#342](https://github.com/LupoLab/solprop/issues/342) proposed: binary32 Adams judged
-against binary64 Adams at the same tolerance, rather than against Runge–Kutta, whose
-difference from Adams is the methods' truncation. Each case ran its whole length,
-noise-free, on a power-of-two grid, at each interface's default tolerance, on the `mlx`
-backend (binary32, since retired) and `native` (binary64). The table gives the relative difference of the
-final fields, and binary64 Runge–Kutta's from binary64 Adams as the scale of the
-methods' own truncation:
-
-| case | binary32 Adams against binary64 Adams | binary64 RK against binary64 Adams | binary32 RK against binary64 Adams |
-|---|---|---|---|
-| He capillary gradient, mode-averaged, 1.5 m (RDW) | 2.3e-6 | 3.6e-6 | 4.3e-5 |
-| the same, four modes | 4.3e-6 | 1.7e-6 | 1.3e-4 |
-| Ar capillary, field-grid plasma, 0.2 m | 1.3e-5 | 2.3e-5 | 4.9e-5 |
-| Ar capillary, envelope-grid plasma, 0.2 m | 8.2e-5 | 1.1e-4 | 1.2e-4 |
-| H₂ capillary, Raman, mode-averaged, 0.4 m | 8.0e-5 | 2.9e-5 | 1.0e-4 |
-| GNLSE, Raman and shock, 0.15 m | 2.2e-4 | 3.1e-5 | 9.4e-5 |
-| radial filament in argon with plasma, 3 cm | 6.8e-6 | 1.4e-5 | 1.1e-5 |
-| radial Kerr beam with its transverse collar, 8 cm | 5.0e-6 | 6.7e-6 | 7.7e-6 |
-
-Every case is far inside the field envelope of $10^{-3}$, and all but the GNLSE within a
-small multiple of the methods' own difference; the GNLSE runs at `1e-6` in binary32
-against binary64's `1e-7` (the floor), which accounts for its row. On the capillaries
-binary32 Adams is closer to the binary64 reference than binary32 Runge–Kutta is.
-
-## Known limitations
-
-- **Noise.** A one-photon-per-mode seed is of order $10^{-5}$ of a microjoule pulse's peak
- spectral amplitude and smaller at higher energies, while binary32's accumulated
- rounding can reach $10^{-5}$ after $10^4$ steps. Noise-seeded runs are refused under
- single precision until that boundary is qualified.
-- **Amplifying dynamics.** Rounding at the level of $10^{-7}$ per step is amplified like
- any perturbation by soliton fission, modulation instability or noise-free stimulated
- Raman scattering; such runs reach their accuracy over a shorter length, as the record
- states.
-- **Performance.** On the reference implementation a single-precision run costs about as
- much as a double-precision one; no backend runs it faster.
-
-The [worked example](../../auto_examples/performance/plot_01_single_precision.rst) runs a
-capillary and a radial filament, each with the field-grid plasma, in both precisions and
-compares them. The precision setting, its binary32 contract and the survival study were
-proposed and carried out by
-[SEP 33](../../dev/seps/archive/sep-0033-single-precision-mlx.md).
diff --git a/docs/guide/results/output.md b/docs/guide/results/output.md
index 0d8394b40..13de3a9df 100644
--- a/docs/guide/results/output.md
+++ b/docs/guide/results/output.md
@@ -396,7 +396,7 @@ adaptive or replayed a fixed schedule, because the embedded estimate costs nothi
and a replayed run still needs it: a largest scaled norm above one means a step was too
coarse for the tolerance the norm is scaled by ([stepper.md](../../dev/stepper.md) §8).
A rejected attempt that diverged, whose propagator or error overflowed (a long step
-under a strong collar, more easily in single precision), records an infinite norm; the
+under a strong collar), records an infinite norm; the
stepper retried it at a much shorter step, so take the largest norm over the accepted
attempts. {py:func}`~solprop.output.hdf5.resume` returns the history recorded so far with the last
plane, and an appending handler numbers the next attempt where the file left off, so a
@@ -436,12 +436,11 @@ the scan container, not the records in it, and is still 1. A resume needs the fi
schema to be this one, and checks that before it compares the records
([what a resume compares](resuming.md#what-a-resume-compares)).
-Every run records its `precision` (`"double"` or `"single"`,
-[single precision](../performance/single-precision.md)) in its configuration, so a run
-is never resumed in the other precision, and its plane-reference record
-(`RunMetadata.reference`, [provenance](provenance.md#the-metadata-schema)), from which
-a saved plane is read back as a coherent field plane
-([coupling simulations](../workflows/coupling.md)).
+Every run records its plane-reference record (`RunMetadata.reference`,
+[provenance](provenance.md#the-metadata-schema)), from which a saved plane is read back
+as a coherent field plane ([coupling simulations](../workflows/coupling.md)). The
+configuration no longer records a precision: solprop computes in binary64 only. A run
+started on an earlier release, whose configuration did, finishes on that release.
How a run resumes from its last plane depends on what that plane is:
diff --git a/docs/guide/setup/model-selection.md b/docs/guide/setup/model-selection.md
index d9a719cfd..2cb61a727 100644
--- a/docs/guide/setup/model-selection.md
+++ b/docs/guide/setup/model-selection.md
@@ -44,7 +44,7 @@ pulse, noise and output keywords; these differ, by interface:
| keyword | `prop_gnlse` | `prop_capillary` | `prop_waveguide` | `prop_radial`, `prop_free`, `prop_planar` |
|---|---|---|---|---|
| `noise`, `seed` | yes | yes | yes | yes, except `prop_planar`, whose grid carries no noise model |
-| `rtol`, `atol`, `backend`, `precision` | yes; `atol` 1e-14 √W | yes; `atol` 1e-10 √W | yes; `atol` 1e-10 √W | in the `settings` they require |
+| `rtol`, `atol`, `backend` | yes; `atol` 1e-14 √W | yes; `atol` 1e-10 √W | yes; `atol` 1e-10 √W | in the `settings` they require |
| `raman` | `False`: fused silica's response only when asked | `None`: each species' curated response | `None`, as the capillary | the medium's own |
| loss | `loss_db_per_m`, a fibre's attenuation | `loss=True`, the capillary's own wall loss | the modes' own | the medium's own |
| `step_planes`, `interpolate_planes` | yes (`step_planes=None`, the default, and `0` are off) | yes (`None` is off, or every step with the TDSE response) | yes (`None` is off) | in the settings |
@@ -185,7 +185,7 @@ Quoted departures are results of particular comparisons, not general error bars.
| declared basis ([chapter](../waveguides/solid-fibres.md#declared-bases-circular-polarisation-and-split-pairs)) | a unitary change of an exactly degenerate block (e.g. circular polarisation); a split block propagated as a coupled flow | a rotated channel of a split block reports the block's *mean* $n_{\rm eff}$, which is not a propagation constant (the block beats); `zdw` is withheld there |
| coherent wall ([chapter](../waveguides/capillaries.md), [model](../waveguides/coherent-wall.md#the-coherent-wall)) | the condensed Robin boundary of the retained `HE_1m` set; each channel's loss rate departs from Marcatili's by the relative amount $-(4N^2t^2 + u_m^2/2(ka)^2)$, with $N$ retained channels and $t = v_n/k_0a$ as on the model page — e.g. $1.2\times10^{-5}$ for one channel of a 125 µm bore at 800 nm | quasi-static and paraxial: the three validity ratios are recorded and the run warns within a factor ten of the limit and refuses above it |
| vector (TE/TM) wall | each family's own impedance, the $\mathrm{HE}\leftrightarrow\mathrm{EH}$ coupling by the wall's anisotropy | grazing-impedance boundary; checked against the exact vector Maxwell roots family by family (to between $5\times10^{-6}$ and $5\times10^{-5}$ at 800 nm), but not against a resolved-cladding solve of a mixed state, which is a known limitation |
-| taper and gradient ([chapter](../waveguides/capillaries.md#tapers-and-pressure-gradients)) | the frozen-section split is a rearrangement, not an approximation (the residual is evaluated exactly); the taper connection of the capillary's $J_0$ basis; each channel's own normalisation | the step error, controlled by the stepper; CF4's flow error is invisible to the controller |
+| taper and gradient ([chapter](../waveguides/capillaries.md#tapers-and-pressure-gradients)) | the frozen-section split is a rearrangement, not an approximation (the residual is evaluated exactly); the taper connection of the capillary's $J_0$ basis; each channel's own normalisation | the step error, controlled by the stepper |
| moving solid guide ([chapter](../waveguides/solid-fibres.md#a-solid-guide-that-moves-along-z), [model](../waveguides/moving-modal.md#a-guide-whose-geometry-moves-along-z)) | the mode fields re-solved along z at the degree a held-out check demands, the moving synthesis and projection maps, the solid-mode connection in the frozen-residual flow | the frozen-section step error, controlled by the stepper; a strong taper needs short steps or `sections=` (the advisory step warns) |
| planar guide resolved in y ([model](../waveguides/planar.md#resolving-y)) | slab modes in x times an FFT in y, exact (non-paraxial) in y, each channel's complex wall term in its longitudinal wave number; a field uniform in y is the slab run | the slab's first-order wall model; constant gap and fill; one polarisation family (TE or TM) |
| hollow planar (slab) guide ([model](../waveguides/planar.md#hollow-planar-waveguides)) | Laakmann–Steier's one wall pair with the filled-core contrast; $N$ and $A_{\rm eff}=4a/3$ per unit width; the Gaussian launch's projection onto the slab modes | first order in $p\lambda/4a$; **uniform in y**: powers and energies are per metre of y, and self-focusing, filamentation and modulational instability in y are absent (they set a real compressor's energy limit); a taper follows each mode adiabatically |
@@ -202,8 +202,7 @@ repeatability tests pass on a data-centre NVIDIA GPU (CUDA) and an AMD Instinct
|---|---|---|---|
| general modal route (all its responses, declared bases, a split block as a coupled flow) | yes | yes | qualified |
| a moving solid guide (moving maps beside the frozen-residual flow) | yes | yes | qualified |
-| coherent wall, scalar and TE/TM, straight, tapered or in a gradient (`method="frozen_residual"`), and the taper coupling of a multimode capillary without a wall | yes | yes | qualified |
-| `CoherentWall(method="cf4")` | yes | refused by name | refused by name |
+| coherent wall, scalar and TE/TM, straight, tapered or in a gradient, and the taper coupling of a multimode capillary without a wall | yes | yes | qualified |
| a varying guide whose linear operator or nonlinear prefactor takes the general z table rather than an expansion in the medium's parameters (e.g. a moving dispersion, the adiabatic opt-out `taper_coupling=False` of a multimode taper whose expansion does not resolve, or a mixture of three or more species on independent density paths) | yes | yes | refused by name |
| a hollow planar (slab) set, constant gap or tapered, mode-averaged or multimode | yes | yes | runs as any mode set; not separately qualified |
| a line free space uniform in y (`LineGrid`, per metre of y) | yes | yes | qualified (CUDA) |
@@ -215,9 +214,9 @@ repeatability tests pass on a data-centre NVIDIA GPU (CUDA) and an AMD Instinct
`backend="auto"`, the default, is `native`, and it **never falls back**: it refuses if the
native package is missing or the processor is below its minimum, and a problem the native
-backend refuses is refused, by name, and you choose `numpy_ref` for it explicitly. Its two
-exceptions are the TDSE response, which it runs on `numpy_ref`, and single precision, which it
-refuses, for you to name `numpy_ref` ([which backend a run uses](../performance/backends.md#which-backend-a-run-uses)). Whether the native backend runs on the CPU or on a GPU
+backend refuses is refused, by name, and you choose `numpy_ref` for it explicitly. Its one
+exception is the TDSE response, which it runs on `numpy_ref`
+([which backend a run uses](../performance/backends.md#which-backend-a-run-uses)). Whether the native backend runs on the CPU or on a GPU
is decided by how it was built. The [backends chapter](../performance/backends.md) compares the
backends and budgets memory; [the native backend](../performance/native.md) says how to build it,
locally or for a GPU; [performance](../performance/tuning.md) covers threads and tuning. Every
diff --git a/docs/guide/setup/recipes.md b/docs/guide/setup/recipes.md
index 7aa13ac6e..145a5b258 100644
--- a/docs/guide/setup/recipes.md
+++ b/docs/guide/setup/recipes.md
@@ -184,8 +184,8 @@ A recipe holds the `from_*` form, so the temporal absorber and its scheme are ke
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
keywords too. The settings table takes the stepping, the planes, the statistics, the
-absorber, the backend and the precision; `fields`, `field_planes` and `resume_history` stay
-in `[execution]`, and the fields a route's builder sets (the density, 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
transverse quadrature) are not the recipe's. On the `planar` route a slab mode names its
fill, `{ type = "SlabMode", half_gap = "63.5 um", gas = "Ar", pressure = "1 bar" }`,
because the fill of its channels is the run's; on the `waveguide` route the same
@@ -346,7 +346,7 @@ default of `None` is resolved by a rule, `resolver(values)` says what it will be
from solprop.recipe import parameters
rtol = parameters("capillary")["rtol"]
-print(rtol.resolver({"precision": "single"})) # the capillary's tolerance in binary32
+print(rtol.resolver({})) # the capillary's default tolerance, and why
```
The same call takes a registered type's name for the keywords of its table, so a second
diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md
index 01130b237..21d33b154 100644
--- a/docs/guide/setup/running.md
+++ b/docs/guide/setup/running.md
@@ -291,9 +291,7 @@ straight bore or a taper alike. The block is the run's loss model, so `loss=` mu
`te_tm` block) and a scalar state, and it is refused beside `lattice=` and
`model="reduced"`, each by name. It carries the default input noise; the modified noise
model is refused beside it. It runs on `numpy_ref` and the native
-backend, CPU and GPU builds alike, with the default integration method;
-`CoherentWall(method="cf4")`, which integrates each section by commutator-free
-exponentials of its whole generator, runs on `numpy_ref` alone. See [the coherent wall](../waveguides/coherent-wall.md#the-coherent-wall),
+backend, CPU and GPU builds alike. See [the coherent wall](../waveguides/coherent-wall.md#the-coherent-wall),
the [capillary guide](../waveguides/capillaries.md) and the gallery examples they link.
**The taper coupling.** A multimode capillary whose radius varies along z carries the
@@ -367,7 +365,7 @@ z-profiles, a capillary run records the electron density, the peak ionisation ra
peak on-axis intensity and the **quadrature error** -- the difference between the fixed
rule's projection and its embedded coarse one. That last is an *alarm*, never a
controller input: above
-{py:data}`~solprop.backends.numpy_ref.backend.QUADRATURE_ALARM` ($10^{-6}$) the backend
+{py:data}`~solprop.translate.modal.QUADRATURE_ALARM` ($10^{-6}$) the backend
logs once per plane and the run continues, because a rule that cannot resolve the
integrand is not something a smaller step can fix; raise `n_r` instead. On four HE₁ₘ
modes at the default order its largest value in the RDW case is 1.0×10⁻⁶. See [statistics](../results/statistics-catalogue.md#backend-statistics).
diff --git a/docs/guide/setup/spatial-decompositions.md b/docs/guide/setup/spatial-decompositions.md
index 5111561f3..9e246721f 100644
--- a/docs/guide/setup/spatial-decompositions.md
+++ b/docs/guide/setup/spatial-decompositions.md
@@ -49,7 +49,7 @@ How each is reached, and where it runs:
| [mode-averaged](#mode-averaged-one-field-and-one-area) | √W, J | `prop_gnlse`, `Simulation.from_gnlse`; `prop_capillary(mode_average=True)` | both CPU |
| [capillary, radial](#capillary-modes-on-a-radial-rule) | √W, J | `prop_capillary(modes=N)`, `Simulation.from_capillary` | both CPU |
| [capillary, full 2-D](#capillary-modes-on-the-full-cross-section) | √W, J | `prop_waveguide(modes, ..., gas=...)`, `Simulation.from_waveguide` | both CPU |
-| [coherent wall](#the-capillary-wall-two-ways) | √W, J | `prop_capillary(..., wall=CoherentWall())` | both CPU (CF4 on `numpy_ref` only) |
+| [coherent wall](#the-capillary-wall-two-ways) | √W, J | `prop_capillary(..., wall=CoherentWall())` | both CPU |
| [antiresonant](#antiresonant-tube-lattice) | √W, J | `prop_capillary(..., lattice=TubeLattice(...))` | both CPU |
| [step-index, exact](#step-index-fibres-exact) | √W, J | `prop_gnlse(fibre=StepIndexMode(...))`; the general route | both CPU |
| [graded-index](#graded-index-providers) | √W true power, J | `prop_waveguide((), ..., provider=guide)` | both CPU |
@@ -65,8 +65,8 @@ How each is reached, and where it runs:
"Both CPU" backends means `numpy_ref` and the native backend's CPU build.
The native backend's GPU builds (CUDA and HIP) also run the mode-averaged, multimode, general modal,
coherent-wall and moving-guide routes, and the CUDA build runs radial, Cartesian and line
-free space; the planar guide resolved in y is not qualified on a GPU, and a CF4
-section runs on `numpy_ref` alone ([choosing a backend](../performance/backends.md),
+free space; the planar guide resolved in y is not qualified on a GPU
+([choosing a backend](../performance/backends.md),
[the native backend](../performance/native.md)).
Every modal route writes energy in joules and amplitudes in the true-power √W of
[power and intensity](../results/power-and-intensity.md): $b = \sqrt{\operatorname{Re}n_{\rm eff}}\,a$
@@ -276,8 +276,8 @@ exact vector Maxwell roots family by family, but it has not been checked against
resolved-cladding solve of a mixed state.
**Entry point.** `prop_capillary(..., wall=CoherentWall())`. The scalar and TE/TM blocks
-run on both CPU backends, straight, tapered or in a gradient; `method="cf4"` runs on
-`numpy_ref` alone ([the capillary chapter](../waveguides/capillaries.md)). The block is
+run on both CPU backends, straight, tapered or in a gradient
+([the capillary chapter](../waveguides/capillaries.md)). The block is
scalar in the **state**, one component per channel, but not in the synthesis: a
$\mathrm{TE}_{0m}$, $\mathrm{TM}_{0m}$ or EH set beside the `te_tm` wall resolves
$E_x$ and $E_y$, so its Kerr and plasma terms see the whole field.
diff --git a/docs/guide/waveguides/capillaries.md b/docs/guide/waveguides/capillaries.md
index 7756a099f..40d9b3494 100644
--- a/docs/guide/waveguides/capillaries.md
+++ b/docs/guide/waveguides/capillaries.md
@@ -73,7 +73,7 @@ from solprop.driver import prop_capillary
from solprop.grid import FieldGrid
from solprop.modes import CoherentWall
from solprop.processing import energy
-from solprop.stepper import FixedSchedule, RungeKutta
+from solprop.stepper import FixedSchedule
length, steps = 2e-4, 10
grid = FieldGrid.from_limits(length, 800e-9, (400e-9, 1.6e-6), 300e-15)
@@ -350,35 +350,6 @@ hard_kinks=corners)` builds one that does for any corners; adaptive stepping lan
corners by itself; a schedule that comes near a corner without landing
on it is refused.
-**The frozen split or CF4.** `CoherentWall(method="cf4")` integrates each section's
-whole generator by the fourth-order commutator-free exponential instead of freezing it:
-no explicit residual and no advisory step, but a flow error the step controller cannot
-see, a run at most fourth order, and a step 3.3 times dearer. It is the more accurate
-only for steps beyond the frozen split's advisory limit, so **prefer the default**; use
-CF4 when you must take steps longer than `advisory_step` and have checked the answer by
-halving them. It runs on `numpy_ref` only and with the Runge–Kutta march only
-(`integrator=RungeKutta()`; the default Adams method refuses it), is refused on a guide
-that does not vary, and is admitted only where its growth guard (`growth_limit`,
-default 2) holds — with `fallback="frozen_residual"` a section the guard rejects is
-integrated by the frozen split instead of refused:
-
-```python
-cf4 = prop_capillary(
- taper,
- length,
- "He",
- 1.0,
- backend="numpy_ref",
- integrator=RungeKutta(),
- wall=CoherentWall(method="cf4", fallback="frozen_residual"),
- **common,
-)
-difference = np.max(np.abs(cf4.planes()[-1].field - tapered.planes()[-1].field))
-print(
- f"CF4 against the frozen split: {difference / np.max(np.abs(tapered.planes()[-1].field)):.1e}"
-)
-```
-
## The vector wall: radial and azimuthal polarisation
`CoherentWall(impedance="te_tm")` gives the wall's tangential and normal fields their own
@@ -414,8 +385,7 @@ a disagreement is refused by name rather than silently ignored. A `TE_0m` or `TM
ionisation table's default range is read from the peak over the axis and the run's own
transverse nodes, where the plasma is evaluated; `ionisation_field_range=` still
overrides it (worked example 03 declares one). The vector wall carries a
-taper and a gradient exactly as the scalar one does, with each family's own connection;
-CF4 is admitted beside it.
+taper and a gradient exactly as the scalar one does, with each family's own connection.
A set with azimuthal structure is evaluated on a full polar rule rather than the radial
one, with both transverse components at every node:
@@ -441,7 +411,7 @@ launch, each with three radial orders and the vector wall.
## Backends
Both CPU backends run the wall — scalar and TE/TM, straight, tapered or in a gradient —
-and the taper coupling without one, with the frozen split; CF4 is `numpy_ref`'s alone.
+and the taper coupling without one, with the frozen split.
The **adiabatic** opt-out of a multimode taper (`taper_coupling=False`) puts every mode
on its own row of the linear operator, and where that expansion does not resolve (a
metre of kinked taper, for instance) it takes the general z table, which every CPU
diff --git a/docs/guide/waveguides/coherent-wall.md b/docs/guide/waveguides/coherent-wall.md
index 032be611c..7fbb6bf50 100644
--- a/docs/guide/waveguides/coherent-wall.md
+++ b/docs/guide/waveguides/coherent-wall.md
@@ -78,8 +78,8 @@ owns the refusal. The `input` noise model is admitted
draw is part of the launch, one photon per channel and bin in the $\mathrm{HE}_{1m}$
set, which is orthonormal in true power (real Bessel profiles, orthogonal on the core,
each normalised to its own power), and the block propagates it like any field. Both
-backends execute the block — `numpy_ref` and `native`, on the CPU and on the qualified GPU builds ([the native backend](../performance/native.md)) —
-except that `method="cf4"` runs on `numpy_ref` alone. The **vector** boundary response
+backends execute the block — `numpy_ref` and `native`, on the CPU and on the qualified GPU builds ([the native backend](../performance/native.md)).
+The **vector** boundary response
[chapter](vector-wall.md) covers the additional families and is declared instead of it rather than
beside it.
@@ -354,8 +354,8 @@ carried in the flow's diagonal, beside the block:
real, zero for $\mathrm{HE}_{11}$ and zero wherever the guide does not vary, with the
slope $w_n' = \operatorname{Re}\,dn_\text{eff}/dz$ taken in closed form through the
radius and the fill ({py:meth}`~solprop.modes.capillary.MarcatiliMode.dneff_dz`), never
-by a difference. It is part of the generator, so the frozen block, the explicit residual
-and CF4 all carry it. On the 50 to 40 µm taper below, leaving it out — giving every
+by a difference. It is part of the generator, so the frozen block and the explicit
+residual both carry it. On the 50 to 40 µm taper below, leaving it out — giving every
channel the lowest channel's normalisation — moves $\mathrm{HE}_{12}$ and
$\mathrm{HE}_{13}$ relative to $\mathrm{HE}_{11}$ by $9\times10^{-6}$ and
$3\times10^{-5}$ at 800 nm and by $1.5\times10^{-4}$ and $5.5\times10^{-4}$ at the
@@ -455,22 +455,3 @@ and a walled *straight* bore $10^{-6}$.
{doc}`../../auto_tutorials/capillaries/plot_08_coherent_wall_taper` runs this taper beside the two
straight bores it joins, reads the sectioning off the record, and shows that four frozen
sections per interval and one give the same answer to $2\times10^{-10}$.
-
-**Integrating the varying section by CF4.** `CoherentWall(method="cf4")` integrates each
-section's whole generator by the fourth-order commutator-free exponential instead of
-freezing it: no explicit residual, so no advisory step, but a flow error the step
-controller cannot see and a run that is at most fourth order. It is admitted only where
-its internal factors provably cannot exceed $\exp(h\,m_{\rm hi})$ — the largest norm
-the exact flow could have over a step $h$, with $m_{\rm hi}$ the largest eigenvalue of
-the generator's Hermitian part — by more than `growth_limit` (default 2); a rejected
-section is refused
-by name, or integrated by the frozen split where `fallback="frozen_residual"` says so.
-That guard bounds growth, not accuracy: a lossless section passes it at any step, and
-only the step-doubling defect estimate, on which `validate` warns against `rtol`, says
-whether a step is small enough. On every taper measured a CF4 step costs 3.3 times a
-frozen one and is the more accurate only for steps beyond the frozen split's advisory
-limit, so the default frozen split is the better choice at equal cost. CF4 is refused
-on a guide that does not vary, whose one section the constant-Schur action already
-integrates exactly; beside `impedance="te_tm"` it is admitted, because it integrates the
-stored expansion and reads nothing of the block that built it — measured fourth order,
-3.996 and 3.999, on the vector taper below.
diff --git a/docs/guide/waveguides/general-modal.md b/docs/guide/waveguides/general-modal.md
index e18032031..5be4ef104 100644
--- a/docs/guide/waveguides/general-modal.md
+++ b/docs/guide/waveguides/general-modal.md
@@ -26,9 +26,8 @@ fill. What the route supports, and what it refuses rather than approximates, is
in the last two sections of this page; read them before planning a run. In short: the route
runs on **a field grid, for a guide of constant geometry or a solid guide whose geometry
moves along z**, on `numpy_ref` and the native backend (its CPU build and
-its CUDA and HIP device builds alike); a coupled flow runs on all of them too, except
-that a flow with a CF4 section runs on `numpy_ref` alone. A general modal run writes,
-reads back and resumes an HDF5 file, a scan point writes one, and a
+its CUDA and HIP device builds alike); a coupled flow runs on all of them too. A general
+modal run writes, reads back and resumes an HDF5 file, a scan point writes one, and a
batch of launches on one guide runs in lockstep. A declared basis over a block that is
only *nearly* degenerate propagates as a coupled matrix flow, and a guide whose geometry
moves carries the connection of its moving basis, which couples its channels.
@@ -376,9 +375,6 @@ at the translation layer and in the backend.
* Every gas keyword beside a provider: the guide owns its materials, its responses and
its transverse rule, and a keyword silently ignored would read as applied.
-* A CF4 section on any backend but `numpy_ref`: it has no execution path there (its
- Gauss-node weights are evaluated inside the reference backend), and nothing falls
- back.
* A guide that does not span the active band, or whose sampled or interpolated Gram is
further from orthonormal than `gram_atol`: the run would propagate against zeros, or
against a basis it declares orthonormal and is not.
@@ -391,7 +387,7 @@ at the translation layer and in the backend.
`MAX_GENERAL_FLOW_EXPONENT`.
* Beside a guide whose geometry moves: a snapshot position other than the entrance, a
run longer than the declared path, the `modified` noise model, a lockstep batch, a
- fixed schedule that straddles a kink, and a CF4 flow or no flow at all beside the moving maps
+ fixed schedule that straddles a kink, and no flow at all beside the moving maps
([a guide whose geometry moves along z](moving-modal.md#a-guide-whose-geometry-moves-along-z)).
* The scalar-route power names, `quadrature_error` and — beside a split block — `zdw`: a
statistic the basis does not define is refused with its replacement where it has one,
@@ -424,8 +420,7 @@ two radial families of one azimuthal order. Each has its gallery example:
## What is implemented, and what is not
Implemented, and executed by both backends — `numpy_ref`, and `native` in its
-CPU build and its CUDA and HIP device builds — with the one exception of a CF4 section,
-which runs on `numpy_ref` alone:
+CPU build and its CUDA and HIP device builds:
* `Simulation.from_waveguide(..., provider=guide)` and
`prop_waveguide(..., provider=guide)`, with the launch, the noise draw, the statistics
@@ -508,9 +503,7 @@ Not implemented, and refused rather than approximated:
finite-element tables, but it is not built: the linear operator asks every channel for
a propagation constant across the whole active band, through the one site every route
in the package shares. A guide that does not span the active band is therefore
- refused, with both bands in the message;
-* a **CF4 section** on the native backend. `numpy_ref` evaluates a CF4 step's
- Gauss-node weights itself, and `native` has no path for them.
+ refused, with both bands in the message.
Two approximations are worth naming because a reader would otherwise take them as exact.
Outside the guide's sampled band the stored reference is an **explicit zero** rather than
diff --git a/docs/guide/waveguides/modal-bases.md b/docs/guide/waveguides/modal-bases.md
index fde504cfe..254f1bb32 100644
--- a/docs/guide/waveguides/modal-bases.md
+++ b/docs/guide/waveguides/modal-bases.md
@@ -118,9 +118,7 @@ carries the absorbing collar, which two guides with different index tables carry
differently, and the same comparison reads 1e-3.
**Where it runs, and what it refuses by name.** Every backend executes the leaf —
-`numpy_ref` and `native`, on the CPU and on a GPU — except a CF4 section,
-which runs on `numpy_ref` alone and which the entry point refuses elsewhere before
-anything is translated. A **noise** model beside it is refused, because the shot-noise
+`numpy_ref` and `native`, on the CPU and on a GPU. A **noise** model beside it is refused, because the shot-noise
draw ({doc}`../media/noise`) is transported by the unitary phase of a *diagonal* flow and its
transport through a coupled one has not been measured. In a `Batch` group the reference
takes a coupled member's trial member by member, because its member-axis batch evaluates
diff --git a/docs/guide/waveguides/moving-modal.md b/docs/guide/waveguides/moving-modal.md
index 9c8efe468..e2bb4a242 100644
--- a/docs/guide/waveguides/moving-modal.md
+++ b/docs/guide/waveguides/moving-modal.md
@@ -240,7 +240,7 @@ evaluates one of each.
would not build again, a run longer than the declared path, the `modified` noise model
(the `input` draw is part of the launch and is admitted), the reference's lockstep
batch (a native `Batch` group runs each member in its own session), a fixed
-schedule that straddles a kink, a CF4 flow or no flow at all beside moving maps. Every backend executes the moving maps — `numpy_ref` and `native` on
+schedule that straddles a kink, or no flow at all beside moving maps. Every backend executes the moving maps — `numpy_ref` and `native` on
the CPU and on a GPU. A declared circular basis is honoured: the metric then stays
the identity, the basis being unitary and the measured Gram departure unitarily
invariant.
diff --git a/docs/guide/waveguides/solid-fibres.md b/docs/guide/waveguides/solid-fibres.md
index 6dacf9714..81230d35b 100644
--- a/docs/guide/waveguides/solid-fibres.md
+++ b/docs/guide/waveguides/solid-fibres.md
@@ -351,8 +351,8 @@ frozen-section flow. The model, its validation and its limits are in
It runs on both backends, `numpy_ref` and `native` (CPU and GPU builds).
A strong taper couples strongly: the run's advisory step warns when the steps are too
long, and `sections=` shortens the frozen sections. Beside a moving guide the
-`modified` noise model, a lockstep batch, a CF4 flow and a fixed schedule that straddles
-a kink of the path are refused. The
+`modified` noise model, a lockstep batch and a fixed schedule that straddles a kink of
+the path are refused. The
[tapered graded-index worked example](../../auto_examples/waveguides/plot_05_moving_solid_guide.rst)
runs eight tapers from abrupt to adiabatic against a first-order coupled-mode estimate
and Love's criterion, rebuilds the fields at the entrance and the waist, and puts a
@@ -384,8 +384,8 @@ The ones a user meets first:
for its harmonic;
* the per-channel power statistics (`energy`, `peak_power`, ...), replaced by
`physical_energy` and `analytic_flux` ([reading the output](../results/reading-output.md));
-* on a moving guide, the `modified` noise model, a lockstep batch, a CF4 flow or a fixed
- schedule that straddles a kink (above).
+* on a moving guide, the `modified` noise model, a lockstep batch or a fixed schedule that
+ straddles a kink (above).
The incident-beam launch into a fibre and its coupling budget were proposed and carried
out by [SEP 31](../../dev/seps/archive/sep-0031-simulation-coupling.md).
diff --git a/docs/guide/waveguides/supplied-guides.md b/docs/guide/waveguides/supplied-guides.md
index 6af821694..ac4b016f8 100644
--- a/docs/guide/waveguides/supplied-guides.md
+++ b/docs/guide/waveguides/supplied-guides.md
@@ -119,8 +119,8 @@ either into a named channel with `channel=` or as an incident beam with
`incident=` coupled under `coupling=`, of which the channels launch the share they
capture, the rest lost rather than restored. It runs on a **field** grid on `numpy_ref` and the native
backend, CPU and GPU builds alike, for a constant guide and for a moving one or a
-declared coupled flow (a split block or a coherent wall); a CF4 wall runs on `numpy_ref`
-alone. `backend="auto"` selects `native`.
+declared coupled flow (a split block or a coherent wall). `backend="auto"` selects
+`native`.
It **writes and resumes an HDF5 file**: the file carries the channel labels, the
provider, sampling, table and metric identities and the accessors the basis admits, so
diff --git a/docs/guide/waveguides/vector-wall.md b/docs/guide/waveguides/vector-wall.md
index e291ea8f6..6704ea06d 100644
--- a/docs/guide/waveguides/vector-wall.md
+++ b/docs/guide/waveguides/vector-wall.md
@@ -249,7 +249,6 @@ $10^{-52}$ while the connection moves 11 % of it into $\mathrm{TM}_{02}$. Agains
independent solve in the true-power amplitude whose connection is the **measured**
matrix rather than the formula, ten steps of the 50 to 40 µm taper agree to
$1.2\times10^{-8}$ on every channel ratio and converge at fifth order. A single
-$\mathrm{HE}_{1m}$ family under the TE/TM wall is the scalar taper to rounding, and
-`method="cf4"` is admitted here too. The native CPU backend runs the
-vector wall on a taper as well, within `1e-11` of the reference's final plane after ten
-steps (the frozen-residual method; CF4 stays `numpy_ref`'s).
+$\mathrm{HE}_{1m}$ family under the TE/TM wall is the scalar taper to rounding. The
+native CPU backend runs the vector wall on a taper as well, within `1e-11` of the
+reference's final plane after ten steps.
diff --git a/docs/guide/workflows/luna.md b/docs/guide/workflows/luna.md
index dc4f8fe5c..759e34fd5 100644
--- a/docs/guide/workflows/luna.md
+++ b/docs/guide/workflows/luna.md
@@ -131,7 +131,7 @@ solprop's field-grid plasma holds the post-pulse current through
([SEP 27](../../dev/seps/archive/sep-0027-held-plasma-current.md)); Luna's gated polarisation
switches off both the residual current and polarisation at the temporal collar. The
propagation comparisons with Luna therefore run Luna's gated source through a test-only
-hook, under a separate identity that cannot be resumed.
+hook, on either backend, under a separate identity that cannot be resumed.
**The modal nonlinear prefactor.** solprop's modal prefactor is
diff --git a/gui/src/solprop_gui/ui/analysis_pages.py b/gui/src/solprop_gui/ui/analysis_pages.py
index 7e7466755..bca1c34b4 100644
--- a/gui/src/solprop_gui/ui/analysis_pages.py
+++ b/gui/src/solprop_gui/ui/analysis_pages.py
@@ -1931,8 +1931,6 @@ def _facts_text(facts: Mapping[str, Any]) -> str:
lines = []
if facts.get("backend"):
lines.append(f"Backend: {facts['backend']}")
- if facts.get("precision"):
- lines.append(f"Precision: {facts['precision']}")
if facts.get("threads"):
lines.append(f"Threads: {facts['threads']}")
if facts.get("wall_time") is not None:
diff --git a/gui/src/solprop_gui/ui/run_options.py b/gui/src/solprop_gui/ui/run_options.py
index 9acf5e051..f6b931a03 100644
--- a/gui/src/solprop_gui/ui/run_options.py
+++ b/gui/src/solprop_gui/ui/run_options.py
@@ -138,8 +138,6 @@ def __init__(self, parent: QWidget | None = None) -> None:
row.addWidget(self.threads)
row.addStretch(1)
self._budget = 0
- self.single: tuple[str, ...] = ()
- """The backends here that compute in single precision."""
self._shown: tuple[object, bool] = ("auto", True)
def set_options(self, options: Mapping[str, object], threads: int | None) -> None:
@@ -152,8 +150,7 @@ def set_options(self, options: Mapping[str, object], threads: int | None) -> Non
threads : int or None
The count chosen before, or ``None`` for the default.
"""
- installed: Sequence[str] = options["double"] # pyright: ignore[reportAssignmentType]
- self.single = tuple(options["single"]) # pyright: ignore[reportArgumentType]
+ installed: Sequence[str] = options["backends"] # pyright: ignore[reportAssignmentType]
self.backend.clear()
for name in ("auto", *installed):
self.backend.addItem(_NAMES.get(name, name), name)
diff --git a/gui/src/solprop_gui/ui/workflow_window.py b/gui/src/solprop_gui/ui/workflow_window.py
index ad856f51b..afe935fcc 100644
--- a/gui/src/solprop_gui/ui/workflow_window.py
+++ b/gui/src/solprop_gui/ui/workflow_window.py
@@ -766,30 +766,16 @@ def _may_leave_run(self) -> bool:
return True
def _backend_chosen(self, name: str) -> None:
- """Write the backend picked into the recipe, with a precision it computes in.
-
- A single-precision recipe, such as one written for the retired ``mlx``
- backend, returns to double precision on a backend that computes in binary64
- only, so that choosing a backend makes it runnable.
+ """Write the backend picked into the recipe.
Parameters
----------
name : str
The backend.
"""
- model = self.document.model
- if model is None:
+ if self.document.model is None:
return
- values: list[tuple[str, object]] = [("backend", name)]
- single = model.get("precision") == "single"
- if single and name not in self.sheet.run_options.single:
- values.append(("precision", "double"))
- self.document.edit_values(values)
- if len(values) > 1:
- self.notify(
- f"{name} computes in double precision, so the run does too. "
- "Undo puts single precision back."
- )
+ self.document.edit_values([("backend", name)])
def _threads_chosen(self, threads: object) -> None:
"""Keep the thread count picked, for this run and the next session's.
diff --git a/gui/tests/test_ui_workflow.py b/gui/tests/test_ui_workflow.py
index 00dfae17d..cc93ffd06 100644
--- a/gui/tests/test_ui_workflow.py
+++ b/gui/tests/test_ui_workflow.py
@@ -1318,19 +1318,18 @@ def test_a_run_s_backend_and_threads_are_chosen_on_the_sheet(
qtbot.waitUntil(lambda: not options.backend.isEnabled(), timeout=WAIT_MS)
-def test_a_recipe_s_retired_backend_is_shown_and_leaving_it_runs_in_double(
+def test_a_recipe_s_retired_backend_is_shown_until_another_is_picked(
window: WorkflowWindow, qtbot: QtBot
) -> None:
"""An mlx-era recipe (SEP 47 retired mlx) shows its backend, not "Automatic"."""
options = window.sheet.run_options
qtbot.waitUntil(lambda: options.threads.count() > 0, timeout=WAIT_MS)
- assert options.single == ("numpy_ref",)
window.begin_class("capillary")
qtbot.waitUntil(lambda: window.sheet.problem is not None, timeout=WAIT_MS)
qtbot.waitUntil(options.backend.isEnabled, timeout=WAIT_MS)
model = window.document.model
assert model is not None
- window.document.edit_values([("backend", "mlx"), ("precision", "single")])
+ window.document.edit_values([("backend", "mlx")])
qtbot.waitUntil(lambda: options.backend.currentData() == "mlx", timeout=WAIT_MS)
assert options.backend.currentText() == "mlx (not available)"
shown = options.backend.model()
@@ -1338,18 +1337,16 @@ def test_a_recipe_s_retired_backend_is_shown_and_leaving_it_runs_in_double(
item = shown.item(options.backend.currentIndex())
assert not item.isEnabled()
assert "mlx" in item.toolTip()
- # A binary64 backend picked: the recipe returns to double precision.
+ # An installed backend picked: the retired one leaves the list.
native = options.backend.findData("native")
options.backend.setCurrentIndex(native)
options.backend.activated.emit(native)
- assert (model.get("backend"), model.get("precision")) == ("native", "double")
- assert "double precision" in window.statusBar().currentMessage()
+ assert model.get("backend") == "native"
qtbot.waitUntil(lambda: options.backend.findData("mlx") == -1, timeout=WAIT_MS)
- # The reference computes single precision too, and keeps it.
window.document.undo.undo()
- assert (model.get("backend"), model.get("precision")) == ("mlx", "single")
+ assert model.get("backend") == "mlx"
window._backend_chosen("numpy_ref")
- assert (model.get("backend"), model.get("precision")) == ("numpy_ref", "single")
+ assert model.get("backend") == "numpy_ref"
def test_every_tile_has_its_drawing(qtbot: QtBot) -> None:
diff --git a/native/AGENTS.md b/native/AGENTS.md
index 4cad24cba..faa5a0f56 100644
--- a/native/AGENTS.md
+++ b/native/AGENTS.md
@@ -18,14 +18,12 @@ contract and its conventions; where both apply, follow the stricter rule.
requires this package at that same version: it is a required dependency, with no
extra.
- The public names are those in `solprop_native.__all__` (the backend, the build record
- and its checks, and the contract tuples `*_FIELDS`). The compiled `_core` module and
- the C++ API are private.
-- solprop has two backends, this one and its NumPy reference, and settles on IEEE
- binary64 (solprop's ADR-077). This backend computes in double precision only and
- refuses single precision, which runs on the reference alone until it is removed. It
- runs every feature except the TDSE response and the reference-only methods ADR-077
- retires (CF4 sections, `ComparisonMultistep`, `PIController` overrides), on CPUs and
- GPUs.
+ and its checks, and `available_device_memory`). The compiled `_core` module and the
+ C++ API are private.
+- solprop has two backends, this one and its NumPy reference, and computes in IEEE
+ binary64 only (solprop's ADR-077): every field, stage and statistic here is double
+ precision, with no single or mixed mode. This backend runs every feature except the
+ TDSE response, on CPUs and GPUs.
- The layers are solprop -> Python backend adapter -> nanobind seam -> a Python-free
C++20 core. Native code never imports Python or solprop concepts and never owns
user-facing policy: it may execute an algorithm the kernel contract specifies in
@@ -34,10 +32,14 @@ contract and its conventions; where both apply, follow the stricter rule.
every parameter handed over by solprop and solprop's implementation as the reference,
and it never chooses or defaults a parameter itself.
- solprop imports this package only lazily, through its backend registry. The wheel
- does not depend on solprop at run time; the contract is solprop's kernel contract and
- the members named by the tuples above, which `check_build()` looks up. In an editable
- checkout it also compares the extension with the sources by digest, so mismatched
- halves (a stale extension beside newer sources) are refused, naming `uv sync`.
+ imports without solprop: its thread-budget reader at import is a copy of solprop's
+ (`_budget.py`, held to it by solprop's `tests/test_process.py`), and only the adapter
+ (`backend.py` and what it loads) imports solprop. The contract is solprop's kernel
+ contract, whose fields
+ the frozen tables of solprop's `tests/backends/test_driver.py` hold. In an editable
+ checkout `check_build()` compares the extension with the sources by digest, so
+ mismatched halves (a stale extension beside newer sources) are refused, naming
+ `uv sync`.
- Kokkos runtime ownership is process-wide and locked. Initialise at most once, destroy
plans and views before an owned finalisation, and never call Python during native
teardown.
@@ -65,9 +67,13 @@ by name.
The backend executes the problems solprop hands it through the seam and refuses every
other problem by name, before allocating anything. When solprop adds a problem class,
the backend either executes it with tests against solprop's reference backend or
-refuses it explicitly. Admission of the general modal route and the coupled flow is
-solprop's shared list (`solprop.backends.numpy_ref.admission`), run under the name
-`native`. Never claim accelerator support from a successful configuration or host
+refuses it explicitly. What the general modal route and the coupled flow execute is
+checked once, in solprop's problem validation (`solprop.translate.admission`), which
+the adapter runs where it is built; the route's derivations (`solprop.translate.general`),
+its node chunk (`solprop.backends.node_chunk`), the coupled flow's Schur factors
+(`solprop.translate.coupled.coupled_flow_factors`) and the Adams call ledger
+(`solprop.backends.ledger`) are solprop's, shared with the reference. The adapter imports
+nothing from `solprop.backends.numpy_ref`. Never claim accelerator support from a successful configuration or host
compilation alone; a device path is supported once its tests pass on that device.
## The gate
@@ -163,15 +169,14 @@ the gate.
## Python and nanobind seam
-- The contract with solprop is its kernel contract plus the descriptor and bundle
- fields listed in the contract tuples. The backend inherits no solprop driver: a run is
- its native session (`session.py`), and the load, the Adams record and the timing probe
- are its own (`_load.py`, `backend.py`, `_probe.py`). `check_build()` refuses a missing member
- by name and, in an editable checkout, an extension built from other sources than
- `native/` (`build_info().source_digest`; `cmake/SourceDigest.cmake` and
- `_source_digest` compute it, and stay in step). It records solprop's version without
- comparing it. Add a name to the tuples whenever the adapter starts reading another
- member; never pin solprop's version.
+- The contract with solprop is its kernel contract, whose fields solprop's frozen
+ tables hold. The backend inherits no solprop driver: a run is its native session
+ (`session.py`), and the load, the Adams record and the timing probe are its own
+ (`_load.py`, `backend.py`, `_probe.py`). `check_build()` refuses, in an editable
+ checkout, an extension built from other sources than `native/`
+ (`build_info().source_digest`; `cmake/SourceDigest.cmake` and `_source_digest`
+ compute it, and stay in step). It records solprop's version without comparing it;
+ never pin solprop's version.
- Accept the documented NumPy dtype, rank, shape and C-contiguity exactly and refuse
hidden O(n) conversions. Read-only input is valid. Returned host arrays own their
memory.
diff --git a/native/README.md b/native/README.md
index fe9fb3c88..ab16d0dde 100644
--- a/native/README.md
+++ b/native/README.md
@@ -107,14 +107,12 @@ with `SOLPROP_TEST_BACKEND=native`).
### The kernel contract
-This source implements solprop's kernel contract. A build pins no solprop version: it
-requires the solprop members listed in the exported tuples `DRIVER_SURFACE`,
-`STAGE_TERMS_FIELDS`, `STAGE_SCALARS_FIELDS` and the other `*_FIELDS` tuples, which
-`check_build()` looks up, naming any that are missing. In an editable checkout it also
-compares `build_info().source_digest`, the digest of what the extension was compiled from,
-with this directory's sources. Rebuild with `uv sync --reinstall-package solprop-native`
-when it refuses for either reason; a wheel carries no sources, and solprop's metadata pins it
-to solprop's own version. The version recorded at configure time
+This source implements solprop's kernel contract, whose fields solprop's frozen tables
+hold (`tests/backends/test_driver.py`). A build pins no solprop version. In an editable
+checkout `check_build()` compares `build_info().source_digest`, the digest of what the
+extension was compiled from, with this directory's sources. Rebuild with
+`uv sync --reinstall-package solprop-native` when it refuses; a wheel carries no sources,
+and solprop's metadata pins it to solprop's own version. The version recorded at configure time
(`solprop_version_built_against`: the repository's version, or `unknown` for a direct
CMake configure) is provenance only. The TDSE electronic response is refused by name
before anything is allocated; TDSE propagation uses `backend="numpy_ref"`.
diff --git a/native/include/solprop_native/core.hpp b/native/include/solprop_native/core.hpp
index ecb44edec..00f693695 100644
--- a/native/include/solprop_native/core.hpp
+++ b/native/include/solprop_native/core.hpp
@@ -587,6 +587,10 @@ struct Problem {
bool take_real{};
bool plasma{};
bool envelope_plasma{};
+ // solprop's private, test-only gated plasma benchmark: the field plasma's current
+ // enters as w(t) times its running integral, added to the local polarisation,
+ // instead of the held current's two collared spectra. Never set by a user run.
+ bool plasma_gated{};
// Channel-major 4 x 512 split angular rules and coarse-axis current factors.
std::vector envelope_rule_nodes, envelope_rule_weights, envelope_drude_factor;
std::vector envelope_loss_factor;
diff --git a/native/include/solprop_native/dense_output.hpp b/native/include/solprop_native/dense_output.hpp
index 19341d898..1143a7f16 100644
--- a/native/include/solprop_native/dense_output.hpp
+++ b/native/include/solprop_native/dense_output.hpp
@@ -54,8 +54,7 @@ namespace solprop_native::engines {
/** The Adams march's dense output (solprop's `_ResidentAdams.march_stepped`, `observe`,
`hold` and `restore`): the interaction formulation's corrector interpolant, or the
exponential formulation's corrector with its compensation and carrier frame, over
- the last accepted step the engine kept. The cubic Hermite interpolant is a
- comparison method's, which a session refuses. */
+ the last accepted step the engine kept. */
class AdamsDense final : public session::Dense {
public:
AdamsDense(GeneralState& state, const stage::Tables& tables, stage::Anchor& anchor,
diff --git a/native/src/bindings.cpp b/native/src/bindings.cpp
index 88c91bdff..b6a13c8fe 100644
--- a/native/src/bindings.cpp
+++ b/native/src/bindings.cpp
@@ -565,6 +565,8 @@ native::Problem problem_from(const nb::dict& values) {
// solprop SEP 18: the running-integral rule. Translation designed and
// certified the taps; the core checks their extent, finiteness and the
// trapezoid's one identity tap.
+ if (values.contains("plasma_gated"))
+ result.plasma_gated = nb::cast(values["plasma_gated"]);
if (values.contains("plasma_band")) {
result.plasma_band = nb::cast(values["plasma_band"]);
const auto taps = exact_array(values["plasma_taps"]);
@@ -3068,7 +3070,6 @@ nb::dict build_record() {
result["coverage_instrumented"] =
static_cast(SOLPROP_NATIVE_COVERAGE_INSTRUMENTED);
result["coverage_source_hash"] = SOLPROP_NATIVE_COVERAGE_SOURCE_HASH;
- result["precision"] = "complex128";
result["fft_normalization"] = "none (explicit inverse scaling)";
#if defined(KOKKOS_ENABLE_CUDA)
result["fft_planner"] = "Kokkos-FFT persistent Plan / cuFFT";
diff --git a/native/src/general.cpp b/native/src/general.cpp
index ad47f5cfb..c8b3954d4 100644
--- a/native/src/general.cpp
+++ b/native/src/general.cpp
@@ -1411,6 +1411,10 @@ void require_general_problem(const Problem& problem) {
if (!problem.plasma_band &&
(problem.plasma_taps.size() != 1 || problem.plasma_taps[0] != 1.0))
throw std::invalid_argument("the trapezoid rule is the one identity tap");
+ if (problem.plasma_gated &&
+ (!problem.plasma || problem.envelope_plasma || problem.general_modal))
+ throw std::invalid_argument(
+ "the gated plasma benchmark needs a field plasma column off the general route");
if (problem.plasma_band) {
if (!problem.plasma || problem.envelope_plasma ||
problem.family == FieldFamily::envelope)
@@ -2626,9 +2630,13 @@ class GeneralWorkspace {
void project(std::size_t first_node, std::size_t node_count, bool first_chunk,
const EnvelopeStream* stream = nullptr,
const FieldCurrentStream* field_stream = nullptr);
- bool held_plasma() const noexcept {
+ bool field_plasma() const noexcept {
return problem_.plasma && !problem_.envelope_plasma;
}
+ bool held_plasma() const noexcept { return field_plasma() && !problem_.plasma_gated; }
+ // solprop's private gated benchmark: the current's running integral, gated by the
+ // time window, joins the local polarisation before it is projected.
+ bool gated_plasma() const noexcept { return field_plasma() && problem_.plasma_gated; }
bool held_radial() const noexcept {
return held_plasma() && !problem_.separable_geometry() &&
!problem_.spatial_spectral_weights.empty();
@@ -2674,6 +2682,7 @@ class GeneralWorkspace {
void transform_forward_pack();
void held_response(std::size_t stage, std::size_t first_node, std::size_t node_count,
const TransverseChunk* chunk = nullptr);
+ void gated_response(std::size_t node_count);
void held_transform(bool coarse_only = false, bool transformed = false);
void finish_quadrature();
bool deferred_quadrature_eligible() const noexcept {
@@ -3018,6 +3027,9 @@ class GeneralWorkspace {
std::unique_ptr forward_pack_fine_plan_,
forward_pack_fine_tail_plan_, forward_pack_coarse_plan_;
RealView2 held_current_, held_projection_;
+ // The gated benchmark's running integral of each current row, and the band rule's
+ // filtered rows (`gated_response`).
+ RealView2 gated_prefix_, gated_filtered_;
ComplexView2 held_nodal_spectrum_, held_projected_spectrum_;
RealView held_derivative_, held_inverse_;
RealView3 envelope_rule_nodes_, envelope_rule_weights_, envelope_moments_;
@@ -3487,7 +3499,7 @@ bool GeneralWorkspace::folds_single_node() const noexcept {
#if defined(KOKKOS_ENABLE_CUDA) || defined(KOKKOS_ENABLE_HIP)
return false;
#else
- return !general_ && !dispersive_ && !held_radial() &&
+ return !general_ && !dispersive_ && !held_radial() && !gated_plasma() &&
!problem_.separable_geometry() && problem_.n_modes == 1 &&
problem_.n_nodes == 1 && problem_.node_chunk == 1 &&
problem_.n_pol_state == 1 && problem_.n_pol_transform == 1 &&
@@ -4067,8 +4079,15 @@ void GeneralWorkspace::allocate() {
: RealView2("solprop_native.absorber.fine_real", temporal_columns,
problem_.n_to);
}
- if (held_plasma()) {
+ if (field_plasma())
held_current_ = RealView2("solprop_native.held.current", chunk_rows, problem_.n_to);
+ if (gated_plasma()) {
+ gated_prefix_ = RealView2("solprop_native.gated.prefix", chunk_rows, problem_.n_to);
+ if (problem_.plasma_band)
+ gated_filtered_ =
+ RealView2("solprop_native.gated.filtered", chunk_rows, problem_.n_to);
+ }
+ if (held_plasma()) {
held_derivative_ = RealView("solprop_native.held.derivative", problem_.n_to);
held_inverse_ = RealView("solprop_native.held.inverse", problem_.n_omega);
const bool radial = held_radial();
@@ -10198,6 +10217,8 @@ void GeneralWorkspace::separable_core(const std::size_t stage, const bool materi
dispersive->respond(chunk.nodes);
if (held_plasma())
held_response(stage, chunk.first_node, chunk.nodes, &chunk);
+ if (gated_plasma())
+ gated_response(chunk.nodes);
Kokkos::Profiling::popRegion();
// Alone with products of its own, nothing of the stage's is projected.
const bool stage_projects = !alone || dispersive->borrowed();
@@ -10607,6 +10628,8 @@ bool GeneralWorkspace::modal_core(const std::size_t stage) {
const Kokkos::Profiling::ScopedRegion region("solprop_native.held.response");
held_response(stage, first_node, node_count);
}
+ if (gated_plasma())
+ gated_response(node_count);
if (stage_projects && !folded && !held_radial()) {
const Kokkos::Profiling::ScopedRegion region("solprop_native.projection");
project(first_node, node_count, first_chunk);
@@ -13213,6 +13236,34 @@ void GeneralWorkspace::held_response(const std::size_t stage,
}
}
+// solprop's private gated benchmark (`NumpyRefBackend._column_sources`): the chunk's
+// live current rows are integrated by the problem's running-integral rule, the band
+// rule's filter then the trapezoid (solprop SEP 18), and w(t) times each prefix joins
+// the local polarisation, which is then projected as the held current's streams would
+// have been. The scans are the device column's portable primitives; no hot column
+// changes, and a run without the benchmark never reaches this.
+void GeneralWorkspace::gated_response(const std::size_t node_count) {
+ const Kokkos::Profiling::ScopedRegion region("solprop_native.gated.response");
+ const auto live = std::pair(
+ 0, node_count * problem_.effective_node_pol());
+ const auto current = Kokkos::subview(held_current_, live, Kokkos::ALL);
+ const auto prefix = Kokkos::subview(gated_prefix_, live, Kokkos::ALL);
+ const auto& space = response_space_for(node_count);
+ if (problem_.plasma_band)
+ portable_team_band_scan(space, current,
+ Kokkos::subview(gated_filtered_, live, Kokkos::ALL), prefix,
+ plasma_taps_, problem_.dt_fine);
+ else
+ portable_team_trapezoid_scan(space, current, prefix, problem_.dt_fine);
+ const auto window = time_window_;
+ const auto polarisation = polarisation_real_;
+ cartesian_for(
+ "solprop_native.gated.polarisation", space, live.second, problem_.n_to,
+ KOKKOS_LAMBDA(const std::size_t row, const std::size_t time) {
+ polarisation(row, time) += window(time) * prefix(row, time);
+ });
+}
+
// A stage's forward rows in one transform: all of them when the absorber's are due,
// else those after the absorber's (or the projection's alone). A host deferring its
// quadrature transforms the fine block only; `finish_quadrature` takes the coarse.
@@ -17124,6 +17175,8 @@ std::map GeneralWorkspace::base_memory_ledger() const
{"held_current_host_derivative",
problem_.time_window_derivative.capacity() * real_bytes},
{"held_current", held_current_.size() * real_bytes},
+ {"gated_current_prefix",
+ (gated_prefix_.size() + gated_filtered_.size()) * real_bytes},
{"held_current_gj", held_streams_[0].bytes()},
{"held_current_edge", held_streams_[1].bytes()},
{"held_current_nonplasma", held_streams_[2].bytes()},
@@ -17431,6 +17484,8 @@ void GeneralWorkspace::close() noexcept {
forward_pack_spectrum_ = {};
forward_pack_tail_spectrum_ = {};
held_current_ = {};
+ gated_prefix_ = {};
+ gated_filtered_ = {};
held_projection_ = {};
held_nodal_spectrum_ = {};
held_projected_spectrum_ = {};
diff --git a/native/src/solprop_native/__init__.py b/native/src/solprop_native/__init__.py
index a5da943a5..4fcdf28bc 100644
--- a/native/src/solprop_native/__init__.py
+++ b/native/src/solprop_native/__init__.py
@@ -129,23 +129,6 @@
from .backend import NativeBackend
__all__ = [
- "COUPLED_FLOW_FIELDS",
- "DISPERSIVE_KERR_FIELDS",
- "LINEAR_OPERATOR_FIELDS",
- "MARCH_POLICY_FIELDS",
- "MODAL_TABLES_FIELDS",
- "PROBLEM_FIELDS",
- "REGIONAL_RESPONSE_FIELDS",
- "REQUEST_FIELDS",
- "REQUEST_SECTION_FIELDS",
- "RESPONSE_FIELDS",
- "ROUTE_FIELDS",
- "SEPARABLE_OPERATOR_FIELDS",
- "STAGE_SCALARS_FIELDS",
- "STAGE_TERMS_FIELDS",
- "TRANSFORM_FIELDS",
- "WATCH_POLICY_FIELDS",
- "WATCH_STATE_FIELDS",
"BuildInfo",
"NativeBackend",
"__version__",
@@ -271,288 +254,6 @@ def refresh_profile() -> str:
_core.register_host_blas()
-MARCH_POLICY_FIELDS: tuple[str, ...] = (
- "order",
- "variable_order",
- "safety",
- "max_growth",
- "min_factor",
- "rejection_growth",
- "hysteresis",
- "restart_after",
- "collar_stability",
- "real_intervals",
- "collar_rate",
- "carrier",
- "carrier_cap",
- "max_calls",
- "equal_approach",
- "interval_cap",
- "dz_min",
- "dz_max",
- "stretch",
- "z_end",
- "save_planes",
- "hard_kinks",
- "breakpoints",
- "landing_ulps",
- "interpolate",
- "quiet_interval",
-)
-"""``solprop.stepper.multistep.MarchPolicy``'s fields, exactly, since schema 39.
-
-The native Adams march reads every number of its algorithm from this record (solprop
-SEP 39 section 4.2), so a field solprop adds is one the native march would not honour:
-:func:`check_build` refuses a solprop whose record differs in either direction."""
-
-WATCH_POLICY_FIELDS: tuple[str, ...] = (
- "split",
- "crossing",
- "floor_regions",
- "launch_floor",
- "activity",
- "warn",
- "stop",
- "quiet_share",
- "quiet_checks",
- "quiet_collar",
- "cadence",
- "interval",
- "share",
- "bucket_limits",
-)
-"""``BoundaryMonitor.watch_policy``'s fields, exactly (solprop SEP 45 section 4.8).
-
-The native march predicts the monitor's checks from these numbers alone, so a field
-solprop adds is one it would not honour: the adapter refuses a policy that differs in
-either direction."""
-
-WATCH_STATE_FIELDS: tuple[str, ...] = (
- "has_last",
- "last_z",
- "since",
- "busy",
- "quiet_run",
- "quiet",
- "warned",
- "cap",
-)
-"""``BoundaryMonitor.watch_state``'s fields, exactly: what the predictions read and
-change, compared with solprop's monitor after every drain (SEP 45 section 4.8)."""
-
-
-LINEAR_OPERATOR_FIELDS: tuple[str, ...] = (
- "power_weight_representation",
- "power_weight",
- "power_weight_expansion",
- "power_weight_constant",
- "coupled_flow",
- "modal_metric",
- "phi_representation",
- "polynomial",
- "rate",
-)
-"""``LinearOperator`` rows this backend relies on.
-
-The four modal power-weight rows the statistics read (schema 11) and the two optional
-general-modal leaves added at schema 12: ``modal_metric`` (identity, or the dense
-metric of a declared unitary basis) and ``coupled_flow`` (the constant-Schur and
-frozen-residual sections; CF4 is refused by name), both executed by this backend.
-The general z table (solprop SEP 29 G1): ``phi_representation``, and the table
-``polynomial`` and its derivative ``rate`` (cached properties), whose coefficients
-this backend holds resident and evaluates natively.
-Naming them here makes a solprop that renamed one fail at :func:`check_build` with a
-message naming the member, rather than as an ``AttributeError`` raised from inside the
-adapter on the first problem it is handed."""
-
-PROBLEM_FIELDS: tuple[str, ...] = (
- "grid_kind",
- "shape",
- "flags",
- "grids",
- "linear",
- "responses",
- "initial",
- "route",
- "precision",
- "split_crossing",
-)
-"""``PropagationProblem`` fields this backend reads (solprop SEP 47 section 6.2).
-
-The descriptor's shared components and its route: a solprop that renamed or removed
-one fails at :func:`check_build` naming it, rather than inside the adapter. The run's
-policy is the request's (:data:`REQUEST_FIELDS`)."""
-
-REQUEST_FIELDS: tuple[str, ...] = (
- "integration",
- "error_control",
- "replay",
- "observation",
- "boundary",
- "history",
- "snapshots",
- "stepper",
-)
-"""``RunRequest`` members this backend reads (solprop SEP 47 section 5.2).
-
-The method, error control, itinerary, observation, boundary and history policies a
-backend is built and a session started with, and ``stepper``, the stepping policy
-gathered into one record, which the march reads."""
-
-REQUEST_SECTION_FIELDS: dict[str, tuple[str, ...]] = {
- "IntegrationSpec": ("method",),
- "ErrorControlSpec": ("controller",),
- "ReplaySpec": ("schedule",),
- "ObservationRequest": ("save_planes", "dense", "stats"),
- "BoundaryRequest": ("monitor",),
-}
-"""Members of the ``RunRequest`` sections this backend reads, by section class.
-
-Checked at :func:`check_build` as :data:`ROUTE_FIELDS` is, so a solprop that renamed
-one is named there rather than failing inside the adapter."""
-
-ROUTE_FIELDS: dict[str, tuple[str, ...]] = {
- "ModalRoute": ("transform", "tdse"),
- "GeneralRoute": ("transform",),
- "SpatialRoute": ("kind", "transform", "flux", "transverse"),
-}
-"""The route variants this backend visits, each with the fields it reads.
-
-``solprop.translate.routes`` holds the closed union; the adapter visits
-``problem.route`` with a ``match`` statement, prepares these variants and refuses the
-modal route's TDSE extension, and any variant it does not know, by name before it
-allocates (solprop SEP 47 section 6.2)."""
-
-TRANSFORM_FIELDS: tuple[str, ...] = ("modal_tables",)
-"""``Transform`` rows this backend relies on.
-
-The general-modal reference of schema 12, whose synthesis and projection maps this
-backend executes; the same frozen-name guard as :data:`LINEAR_OPERATOR_FIELDS`, for the
-same reason."""
-
-RESPONSE_FIELDS: tuple[str, ...] = (
- "regional_responses",
- "dispersive_kerr",
- "plasma_integral",
- "prefactor_representation",
- "prefactor",
-)
-"""``CompiledResponses`` rows this backend relies on.
-
-The regional response tables of schema 12, executed by this backend (the instantaneous
-cubic in both forms and the silica tensor Raman model), and schema 24's
-``dispersive_kerr``, solprop SEP 6's frequency-dependent electronic response, executed
-on every route; the prefactor's representation and its z table (solprop SEP 29 G1);
-the same frozen-name guard as :data:`LINEAR_OPERATOR_FIELDS`, for the same reason."""
-
-MODAL_TABLES_FIELDS: tuple[str, ...] = (
- "electric",
- "omega_fine",
- "projection",
- "region_index",
- "source_frequency_factor",
- "synthesis",
-)
-"""``ModalTransformTables`` members the adapter reads itself (``modal_tables``).
-
-The leaves' tuples above guard the leaf names; these guard the members inside them that
-this package reads directly when it packs the general modal route. Members read only
-inside solprop's own derivations, which the adapter calls (``node_kerr_tensor``,
-``node_delayed_maps``, ``metric_at``, ...), are solprop's to keep consistent and are
-not listed. For the same reason ``ModalMetricTables`` has no tuple:
-the adapter hands ``linear.modal_metric`` to solprop's ``metric_at`` and reads none of
-its members. Add a name here whenever the adapter starts reading another member."""
-
-DISPERSIVE_KERR_FIELDS: tuple[str, ...] = (
- "carrier",
- "coefficients",
- "evaluator",
- "filters",
- "n_channels",
- "outputs",
- "positive",
- "products",
- "regions",
- "sector",
-)
-"""``DispersiveKerrTerms`` members the adapter reads itself.
-
-``responses.dispersive_kerr``, read when the response is laid out for the native
-engine (``regions`` on the general modal route); the leaf's grouping (``groups``) is
-solprop's own derivation, which the adapter calls. The same guard as
-:data:`MODAL_TABLES_FIELDS`; ``n_channels`` is a property, looked up on the class."""
-
-REGIONAL_RESPONSE_FIELDS: tuple[str, ...] = ("n_terms",)
-"""``RegionalResponseTables`` members the adapter reads itself.
-
-``responses.regional_responses``; the same guard as :data:`MODAL_TABLES_FIELDS`."""
-
-COUPLED_FLOW_FIELDS: tuple[str, ...] = (
- "connection",
- "generator",
- "inverse_condition_bound",
- "n_deg",
- "n_modes",
- "section_methods",
-)
-"""``CoupledFlowTables`` members the adapter reads itself (``linear.coupled_flow``).
-
-Read when the host-side Schur factors and residual terms are formed; the same guard as
-:data:`MODAL_TABLES_FIELDS`. ``n_deg`` and ``n_modes`` are properties, looked up on the
-class."""
-
-SEPARABLE_OPERATOR_FIELDS: tuple[str, ...] = (
- "k_squared",
- "kperp_sq",
- "k_squared_low",
- "kperp_sq_low",
- "frame",
- "spectral_rate",
- "prefactor_numerator",
- "prefactor_clamped",
- "active",
- "evanescent_rate",
- "n_pol",
- "z_span",
- "kperp_rate",
- "user_rate",
-)
-"""Required shared spatial coefficients, frozen since schema 6.
-
-Schema 31 (solprop SEP 22 phase 2) appends ``kperp_rate``, a transverse collar's power
-rate per column."""
-
-STAGE_TERMS_FIELDS: tuple[str, ...] = ("phi", "prefactor", "separable")
-"""The ``StageTerms`` fields the native problem is packed from."""
-
-STAGE_SCALARS_FIELDS: tuple[str, ...] = (
- "z0",
- "z1",
- "phi_delta",
- "phi_at",
- "prefactor",
- "density",
- "kerr_density",
- "plasma_densities",
- "plasma_preionisation",
- "raman_densities",
- "dispersive_kerr_densities",
- "taper_scale",
- "raman_rates",
- "flow_section",
- "flow_weights",
- "transform_weights",
-)
-"""The ``StageScalars`` fields a trial's bundles are packed from.
-
-``flow_section`` and ``flow_weights`` are schema 13's rows for a coupled flow: the
-step's frozen section and each stage's weights of the flow's residual terms, packed
-when a problem carries ``linear.coupled_flow``. ``transform_weights`` is schema 14's
-row: each stage's weights of a moving guide's synthesis and projection maps, which this
-backend combines per stage. ``dispersive_kerr_densities`` is schema 24's row: each
-dispersive channel's number density at the stage (solprop SEP 6)."""
-
-
@dataclass(frozen=True)
class BuildInfo:
"""Immutable build and execution provenance for this extension.
@@ -673,8 +374,8 @@ class BuildInfo:
native_arch_enabled, aggressive_vectorization_enabled, ipo_enabled,
fast_math_enabled, optimization_policy
Optimization switches and the human-readable compiled policy.
- precision, layout
- Numerical scalar and field layout.
+ layout
+ Field layout.
python_version, numpy_version, platform_machine
Host Python environment.
"""
@@ -751,7 +452,6 @@ class BuildInfo:
ipo_enabled: bool
fast_math_enabled: bool
optimization_policy: str
- precision: str
layout: str
python_version: str
numpy_version: str
@@ -778,8 +478,8 @@ def build_info() -> BuildInfo:
Returns
-------
BuildInfo
- Compiler, dependencies, execution spaces, concurrency, layout, precision,
- optimization policy, and source-version provenance.
+ Compiler, dependencies, execution spaces, concurrency, layout, optimization
+ policy, and source-version provenance.
"""
native_record = _core.build_record()
blas_threads = native_record["blas_threads"]
@@ -859,7 +559,6 @@ def build_info() -> BuildInfo:
ipo_enabled=bool(record["ipo_enabled"]),
fast_math_enabled=bool(record["fast_math_enabled"]),
optimization_policy=str(record["optimization_policy"]),
- precision=str(record["precision"]),
layout=str(record["layout"]),
python_version=platform.python_version(),
numpy_version=np.__version__,
@@ -903,28 +602,17 @@ def _checkout_digest() -> str | None:
def check_build() -> None:
- """Validate this build against this checkout's sources and the installed solprop.
+ """Validate this build against this checkout's sources and its own metadata.
Raises
------
solprop.exceptions.BackendError
If an editable checkout's ``native`` sources are not the ones the extension
- was compiled from, if the installed solprop lacks a seam member this backend
- relies on
- (:data:`MARCH_POLICY_FIELDS`, :data:`STAGE_TERMS_FIELDS`,
- :data:`STAGE_SCALARS_FIELDS`, :data:`SEPARABLE_OPERATOR_FIELDS`,
- :data:`PROBLEM_FIELDS`, :data:`REQUEST_FIELDS`, :data:`REQUEST_SECTION_FIELDS`,
- :data:`ROUTE_FIELDS`,
- :data:`LINEAR_OPERATOR_FIELDS`, :data:`TRANSFORM_FIELDS`,
- :data:`RESPONSE_FIELDS`, or the leaves' members in :data:`MODAL_TABLES_FIELDS`,
- :data:`REGIONAL_RESPONSE_FIELDS`, :data:`COUPLED_FLOW_FIELDS` and
- :data:`DISPERSIVE_KERR_FIELDS`),
- execution-space metadata is inconsistent, a reference build carries vendor
- BLAS or machine-specific code, a distribution build carries native code or no
- BLAS, fast-math is enabled, or an accelerator build sees no device. The
- solprop *version* is recorded, never compared here: the two packages are
- released together and pinned to one version, and the members are looked up by
- name.
+ was compiled from, execution-space metadata is inconsistent, a reference build
+ carries vendor BLAS or machine-specific code, a distribution build carries
+ native code or no BLAS, fast-math is enabled, or an accelerator build sees no
+ device. The solprop *version* is recorded, never compared here: the two
+ packages are released together and pinned to one version.
"""
from solprop.exceptions import BackendError
@@ -936,7 +624,6 @@ def check_build() -> None:
f"checkout's native/ ({Path(__file__).parents[2]}): rebuild it with "
"`uv sync --reinstall-package solprop-native`"
)
- _check_seam(info, BackendError)
if info.fast_math_enabled:
raise BackendError("solprop-native was built with forbidden fast-math")
if info.openmp_enabled and info.execution_space != "OpenMP":
@@ -991,103 +678,6 @@ def check_build() -> None:
)
-def _check_seam(info: BuildInfo, error: type[Exception]) -> None:
- """Refuse a solprop lacking a descriptor or bundle member this backend uses."""
- import dataclasses as _dataclasses
-
- from solprop.stepper.multistep import MarchPolicy
- from solprop.translate import routes
- from solprop.translate import run_request as run_requests
- from solprop.translate.coupled import CoupledFlowTables
- from solprop.translate.linop import LinearOperator
- from solprop.translate.masks import Grids
- from solprop.translate.modal import Transform
- from solprop.translate.modal_tables import ModalTransformTables
- from solprop.translate.problem import PropagationProblem
- from solprop.translate.responses import (
- CompiledResponses,
- DispersiveKerrTerms,
- RegionalResponseTables,
- )
- from solprop.translate.run_request import RunRequest
- from solprop.translate.spatial import SeparableOperator
- from solprop.translate.stage import StageScalars, StageTerms
-
- missing: list[str] = []
- # The policy is exact: a field the native march does not read is not honoured.
- policy = tuple(field.name for field in _dataclasses.fields(MarchPolicy))
- missing.extend(
- f"MarchPolicy.{name}" for name in MARCH_POLICY_FIELDS if name not in policy
- )
- extra = [name for name in policy if name not in MARCH_POLICY_FIELDS]
- descriptor = {field.name for field in _dataclasses.fields(PropagationProblem)}
- missing.extend(
- f"PropagationProblem.{name}"
- for name in PROBLEM_FIELDS
- if name not in descriptor
- )
- request = {field.name for field in _dataclasses.fields(RunRequest)}
- missing.extend(
- f"RunRequest.{name}"
- for name in REQUEST_FIELDS
- if name not in request and not hasattr(RunRequest, name)
- )
- for section, wanted in REQUEST_SECTION_FIELDS.items():
- spec = getattr(run_requests, section, None)
- if spec is None:
- missing.append(f"solprop.translate.run_request.{section}")
- continue
- present = {field.name for field in _dataclasses.fields(spec)}
- missing.extend(f"{section}.{name}" for name in wanted if name not in present)
- for variant, wanted in ROUTE_FIELDS.items():
- route = getattr(routes, variant, None)
- if route is None:
- missing.append(f"solprop.translate.routes.{variant}")
- continue
- present = {field.name for field in _dataclasses.fields(route)}
- missing.extend(f"{variant}.{name}" for name in wanted if name not in present)
- for bundle, wanted in (
- (Grids, ("t_window_fine_derivative",)),
- (LinearOperator, LINEAR_OPERATOR_FIELDS),
- (Transform, TRANSFORM_FIELDS),
- (CompiledResponses, RESPONSE_FIELDS),
- (SeparableOperator, SEPARABLE_OPERATOR_FIELDS),
- (StageTerms, STAGE_TERMS_FIELDS),
- (StageScalars, STAGE_SCALARS_FIELDS),
- (ModalTransformTables, MODAL_TABLES_FIELDS),
- (RegionalResponseTables, REGIONAL_RESPONSE_FIELDS),
- (CoupledFlowTables, COUPLED_FLOW_FIELDS),
- (DispersiveKerrTerms, DISPERSIVE_KERR_FIELDS),
- ):
- # A member is a dataclass field or, for the leaves' derived counts and the
- # z table's views, a property or a cached property.
- present = {field.name for field in _dataclasses.fields(bundle)}
- present.update(
- name
- for name in wanted
- if isinstance(
- getattr(bundle, name, None), (property, functools.cached_property)
- )
- )
- missing.extend(
- f"{bundle.__name__}.{name}" for name in wanted if name not in present
- )
- if missing or extra:
- lacks = f"it has no {', '.join(missing)}" if missing else ""
- reads = (
- f"its MarchPolicy has {', '.join(extra)}, which this build does not read"
- if extra
- else ""
- )
- raise error(
- f"solprop-native {__version__} (built beside solprop "
- f"{info.solprop_version_built_against}) does not fit the installed "
- f"solprop {version('solprop')}: {'; '.join(filter(None, (lacks, reads)))};"
- " rebuild the backend against this solprop or install the one it was "
- "built beside"
- )
-
-
def __getattr__(name: str) -> object:
"""Load the solprop-dependent backend class only when it is requested."""
if name == "NativeBackend":
diff --git a/native/src/solprop_native/_load.py b/native/src/solprop_native/_load.py
index 5339b228c..5034f03bb 100644
--- a/native/src/solprop_native/_load.py
+++ b/native/src/solprop_native/_load.py
@@ -276,10 +276,10 @@ def scalars(self, z0: float, z1: float) -> StageScalars:
def collar_rests(problem: PropagationProblem, stepper: StepperSettings) -> bool:
"""Return whether a run's stages may leave a quiet temporal collar out (SEP 21).
- A binary64 modal or radial stage with the in-stage (``"nonlinear"``) scheme and a
- temporal collar alone. The low-storage march, coupled flows, the general modal
- route, a real-space collar sharing the term, single precision, and the Cartesian,
- line and planar routes keep the collar in every stage (SEP 21 D15).
+ A modal or radial stage with the in-stage (``"nonlinear"``) scheme and a temporal
+ collar alone. The low-storage march, coupled flows, the general modal route, a
+ real-space collar sharing the term, and the Cartesian, line and planar routes keep
+ the collar in every stage (SEP 21 D15).
- Returns: True when the term may be left out while the collar is quiet.
"""
@@ -294,7 +294,6 @@ def collar_rests(problem: PropagationProblem, stepper: StepperSettings) -> bool:
and responses.absorber_scheme == "nonlinear"
and problem.linear.coupled_flow is None
and problem.transform.modal_tables is None
- and not problem.precision.single
and resting_geometry
)
diff --git a/native/src/solprop_native/_wait.py b/native/src/solprop_native/_wait.py
index 7b6a71cc0..2aea7b5ad 100644
--- a/native/src/solprop_native/_wait.py
+++ b/native/src/solprop_native/_wait.py
@@ -15,8 +15,8 @@
ADR-056 had made idle threads sleep at once, after spinning cost an EPYC 7763 1.4 to
3.3 times at 10,000 spins while the backend ran several thread teams in turn (Kokkos's,
-the threaded FFTs', the BLAS's, NumPy's and numba's). Those now share one runtime and
-NumPy's BLAS holds one thread, and the same test no longer loses.
+the threaded FFTs', the BLAS's and NumPy's). Those now share one runtime and NumPy's
+BLAS holds one thread, and the same test no longer loses.
So, unless the user chose, the import sets before the core loads the runtime:
diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py
index 573396518..577748ad6 100644
--- a/native/src/solprop_native/backend.py
+++ b/native/src/solprop_native/backend.py
@@ -29,6 +29,7 @@
refuse_carrier_frame,
refuse_formulation,
)
+from solprop.backends.ledger import CallLedger, Derivative, source_absent
from solprop.backends.low_storage import schedule_key, table_steps
from solprop.backends.memory import (
LOCAL_FLUX_STATISTICS,
@@ -36,19 +37,10 @@
low_storage_stage_register,
z_table_slots,
)
-from solprop.backends.multistep import (
- CallLedger,
- _Derivative, # pyright: ignore[reportPrivateUsage] - the march's history entries
- source_absent,
-)
-from solprop.backends.node_chunk import NodeChunkPlan, plan_node_chunk
-from solprop.backends.numpy_ref.admission import refuse_general_route
-from solprop.backends.numpy_ref.general_modal import (
- DELAYED_TERMS,
+from solprop.backends.node_chunk import (
+ NodeChunkPlan,
general_planned_chunk,
- node_delayed_maps,
- node_kerr_form,
- node_kerr_tensor,
+ plan_node_chunk,
)
from solprop.backends.protocol import Backend, TimingProbe
from solprop.backends.session import (
@@ -85,7 +77,6 @@
from solprop.stepper.controller import PIController
from solprop.stepper.low_storage import LowStorageRungeKutta
from solprop.stepper.multistep import (
- ComparisonMultistep,
MarchPolicy,
Multistep,
integrated_weights,
@@ -97,13 +88,17 @@
PiecewiseChebyshev,
)
from solprop.translate.coupled import (
+ CoupledFlowFactors,
CoupledFlowTables,
- SectionMethod,
+ coupled_flow_factors,
flow_edge_slack,
- frozen_generator,
- schur_blocks,
schur_eigenbasis,
)
+from solprop.translate.general import (
+ node_delayed_maps,
+ node_kerr_form,
+ node_kerr_tensor,
+)
from solprop.translate.linop import (
delta_phi,
power_weight_at,
@@ -111,7 +106,7 @@
unitary_phase,
)
from solprop.translate.plasma_source import (
- _BENCHMARK_GATED, # pyright: ignore[reportPrivateUsage] - refuse the private NumPy benchmark hook
+ _BENCHMARK_GATED, # pyright: ignore[reportPrivateUsage] - the private gated benchmark
plasma_source_record,
)
from solprop.translate.problem import (
@@ -120,7 +115,6 @@
StepperSettings,
validate_run,
)
-from solprop.translate.responses import CompiledResponses, ResponseStage
from solprop.translate.routes import (
GeneralRoute,
ModalRoute,
@@ -304,12 +298,6 @@ class NativeBackend:
solprop's registry admits them on this backend by this attribute and refuses
them, by name, on a build without it.
"""
- observes_fresh_field: ClassVar[bool] = True
- """Each observation is a new host array (``field_to_host``) that nothing here keeps.
-
- So solprop converts an observed field to output units where it is (solprop SEP 47
- section 5.4).
- """
reads_transverse_edge = True
"""The transverse edge is read from the native state where it lives (solprop SEP 40
section 5.3, SEP 44), by the native session at each read."""
@@ -327,11 +315,14 @@ def __init__(
far_field: FarFieldTables | None = None,
options: ExecutionOptions | None = None,
) -> None:
- if _BENCHMARK_GATED.get():
- raise BackendError("the gated plasma benchmark requires numpy_ref")
check_build()
+ # The private gated benchmark is captured where the backend is built, as the
+ # reference captures it; the run's identity carries it from the driver.
+ self._gated_plasma = _BENCHMARK_GATED.get()
stepper = request.stepper
_refuse_unsupported(problem, stepper)
+ if self._gated_plasma:
+ _refuse_gated_route(problem)
validate_run(problem, request)
stats = request.observation.stats
refused = metric_refusal(problem, (*stats.names, *stats.periodic))
@@ -1394,7 +1385,10 @@ def metadata(self) -> dict[str, object]:
**self._chunk_plan.record(),
"node_chunk": self.node_chunk,
}
- if self._problem.responses.plasma_channels:
+ if self._problem.responses.plasma_channels and self._gated_plasma:
+ work["_benchmark_plasma_source"] = "gated_nonresumable"
+ work["plasma_source"] = {"form": "gated_benchmark", "resumable": False}
+ elif self._problem.responses.plasma_channels:
work["plasma_source"] = plasma_source_record(self._problem.grids)
# The evaluations this backend made: the Adams ledger's logical calls, the
# low-storage march's physical ones (SEP 24 section 5), or the native
@@ -1651,12 +1645,13 @@ def _require_initial_array(field: object, shape: tuple[int, ...]) -> None:
class _ResidentAdams:
"""solprop's Adams history kept in the native state (solprop SEP 16 phase 3b).
- The native Adams march's engine (``solprop.backends.multistep.AdamsEngine``):
- the history's rows, a trial's predictor, its source, the corrected field and the
- defect norms, the adoption and transport of an accepted step and dense observation
- run in the engine, with only the trial's scalars, coefficient tables and norms
- crossing. solprop's host engine (``HostHistory``) is its reference. The backend
- hands it the native state and three of its own operations.
+ The native Adams march's engine (the reference's ``AdamsEngine``, in
+ ``solprop.backends.numpy_ref.multistep``): the history's rows, a trial's predictor,
+ its source, the corrected field and the defect norms, the adoption and transport of
+ an accepted step and dense observation run in the engine, with only the trial's
+ scalars, coefficient tables and norms crossing. solprop's host engine
+ (``HostHistory``) is its reference. The backend hands it the native state and three
+ of its own operations.
"""
native: _core.State
@@ -1694,8 +1689,8 @@ def __post_init__(self) -> None:
self.native.adams_allocate(self.n_slots, self.compensated, self.interpolate)
self._exponential = self.method.formulation == "exponential"
# The controller's scale is formed on the device each trial, so that the
- # state's magnitude never crosses: it is solprop's own, since the backend
- # refuses a controller that overrides it (`_refuse_reference_only`).
+ # state's magnitude never crosses: it is solprop's own, since PIController
+ # is final (ADR-077).
controller = self.controller
weights = controller.weights
self.native.adams_scale(
@@ -1779,9 +1774,8 @@ def restore(self, z: float, held: object) -> None:
self.native.adams_restore()
self.adopted(z)
- def start(self, z: float, *, stored: bool) -> None:
+ def start(self, z: float) -> None:
"""Evaluate the history's first sample at the accepted state into row zero."""
- del stored
self.hoist(z)
arrays, material = self.arrays(z, z)
with _envelope_errors():
@@ -1995,8 +1989,7 @@ def observe(self, position: float) -> object:
np.ascontiguousarray(self._dense_carrier),
)
return self.native
- # The step's own derivative polynomial: the cubic Hermite interpolant is a
- # comparison method's (`ComparisonMultistep`), which native refuses.
+ # The step's own derivative polynomial.
theta = (position - z) / dz
weights = dz * integrated_weights(
nodes, theta, check=False, helpers=self.helpers
@@ -2342,7 +2335,7 @@ class _NativeAdamsMarch:
natively, from one policy record (:func:`~solprop.stepper.multistep.march_policy`).
This record is what the run starts from: the policy, the history's entries
(position, identity and engine row), the order, the rejection flags and the
- :class:`~solprop.backends.multistep.CallLedger`, each fresh at a load
+ :class:`~solprop.backends.numpy_ref.multistep.CallLedger`, each fresh at a load
(:meth:`reset`, :meth:`begin_run`) or restored from a resume's snapshot and ledger
(:meth:`resume`, :meth:`restore_ledger`). A native session hands it to the native
state once (:meth:`start_state`, :meth:`_configure`) and holds the march's state
@@ -2361,23 +2354,13 @@ def __init__(
# Whether the run interpolates its planes (ADR-036, ADR-039).
self._interpolate = bool(stepper.interpolate_planes)
exponential_form = method.formulation == "exponential"
- # The selected method's family, one-order-higher PEC, takes the fused path;
- # the comparison variants' general path is the reference's alone (SEP 47
- # section 6.3), refused at admission.
- self._fused = (
- not exponential_form
- and method.corrector == "pec"
- and all(
- method.corrector_nodes(order) == order + 1
- for order in range(1, method.order + 1)
- )
- )
- assert self._fused or exponential_form
+ # The interaction formulation takes the fused path.
+ self._fused = not exponential_form
resident = engine is None
self._engine: Any = (
backend._adams_engine(method, "transported") if engine is None else engine
)
- self._history: list[_Derivative] = []
+ self._history: list[Derivative] = []
self._rejections = 0
self._last_rejected = False
self._order = 1 if method.variable_order else method.order
@@ -2475,7 +2458,7 @@ def resume(self, snapshot: MarchSnapshot, z: float) -> str | None:
except BackendError as error:
return str(error)
self._history = [
- _Derivative(float(z), self._engine.value(int(slot)), identity, int(slot))
+ Derivative(float(z), self._engine.value(int(slot)), identity, int(slot))
for z, identity, slot in zip(
snapshot.positions, snapshot.identities, snapshot.slots, strict=True
)
@@ -2902,36 +2885,9 @@ def values(self, kind: Literal["phi", "prefactor"], z: float) -> ComplexArray:
return self._native.table_terms_to_host(kind, slot)
-_CONTROLLER_DECISIONS = (
- "scale",
- "accepts",
- "propose",
- "rejection_factor",
- "start_factor",
- "equivalent_norm",
-)
-"""The ``PIController`` methods the native controller implements itself.
-
-A subclass that overrides one of them is a reference-only experiment (solprop SEP 47
-section 6.3), which native refuses by name.
-"""
-
-
-def _refuse_reference_only(stepper: StepperSettings) -> None:
- """Refuse the reference-only methods and controllers by name (SEP 47 6.3).
-
- The benchmark-only comparison family and a controller that decides in Python run
- on ``numpy_ref`` alone; native refuses them, on CPU and device builds alike, before
- anything is allocated.
- """
+def _refuse_unknown_method(stepper: StepperSettings) -> None:
+ """Refuse a method object the native marches do not step, by name."""
integrator = stepper.integrator
- if isinstance(integrator, ComparisonMultistep):
- raise BackendError(
- f"solprop-native does not step the comparison variant {integrator.name}: "
- "ComparisonMultistep (PEC, PECE, explicit Adams-Bashforth and the matched "
- "pairs) is benchmark-only and reference-only (solprop SEP 47); use "
- "backend='numpy_ref' for it, or Multistep()"
- )
# A request built by hand may hold a method its annotation does not name.
unnamed = not isinstance(integrator, Multistep | LowStorageRungeKutta) # pyright: ignore[reportUnnecessaryIsInstance]
if integrator is not None and unnamed:
@@ -2939,32 +2895,17 @@ def _refuse_reference_only(stepper: StepperSettings) -> None:
f"solprop-native does not step the method {type(integrator).__name__}; "
"use Multistep(), LowStorageRungeKutta() or a Runge-Kutta tableau"
)
- controller = type(stepper.controller)
- overridden = [
- name
- for name in _CONTROLLER_DECISIONS
- if getattr(controller, name) is not getattr(PIController, name)
- ]
- if overridden:
- raise BackendError(
- f"solprop-native does not run the controller {controller.__name__}, "
- f"which overrides PIController's {', '.join(overridden)}: a controller "
- "that decides in Python is reference-only (solprop SEP 47); use "
- "backend='numpy_ref' for it, or PIController"
- )
def _refuse_adams(problem: PropagationProblem, stepper: StepperSettings) -> None:
integrator = stepper.integrator
# The low-storage march (solprop SEP 24 phase 3) runs on every route the
- # translation admits it on: no coupled flow and no TDSE response. The comparison
- # variants were refused before (`_refuse_reference_only`).
+ # translation admits it on: no coupled flow and no TDSE response.
if integrator is None or isinstance(integrator, LowStorageRungeKutta):
return
# solprop SEP 16 phase 3: every route's stage source serves both formulations,
# with the modified noise. A coupled flow is stepped by the exponential
- # formulation only (phase 5), as solprop's reference does; its CF4 sections are
- # refused by the shared check.
+ # formulation only (phase 5), as solprop's reference does.
if (
problem.linear.coupled_flow is not None
and integrator.formulation != "exponential"
@@ -2986,46 +2927,32 @@ def _refuse_adams(problem: PropagationProblem, stepper: StepperSettings) -> None
)
-def _refuse_omitted_stages(responses: CompiledResponses) -> None:
- """Refuse a ``response_order`` that leaves out a response the problem holds.
+def _refuse_gated_route(problem: PropagationProblem) -> None:
+ """Refuse the private gated plasma benchmark where native has no form for it.
- Native takes each response from its own leaves and doesn't read the order, so an
- omitted stage would still run. The translation never produces this; a hand-built
- problem can, and ``numpy_ref``, which reads the order, would run it without.
+ It is a field plasma column's, off the general route; the engine refuses anything
+ else too, as a safety net, but by then the problem is being built.
"""
- envelope = responses.envelope_plasma is not None
- held = {
- ResponseStage.KERR: responses.kerr_coefficients is not None,
- ResponseStage.DISPERSIVE_KERR: responses.dispersive_kerr is not None,
- ResponseStage.RAMAN_DRIVE: bool(responses.raman_terms),
- ResponseStage.IONISATION_RATE: bool(responses.ionisation_table)
- and not envelope,
- ResponseStage.PLASMA_SCAN: bool(responses.plasma_channels) and not envelope,
- ResponseStage.PLASMA_CURRENT: bool(responses.plasma_channels) and not envelope,
- ResponseStage.ENVELOPE_POPULATION: envelope,
- }
- omitted = [
- stage.value
- for stage, present in held.items()
- if present and stage not in responses.response_order
- ]
- if omitted:
- raise BackendError(
- f"response_order omits {', '.join(omitted)}, whose responses the problem "
- "holds; solprop-native takes each response from its own leaves and would "
- "run it anyway. Remove the response too, or use backend='numpy_ref', which "
- "follows the order"
+ responses = problem.responses
+ if responses.population_channels and (
+ responses.envelope_plasma is not None
+ or problem.transform.modal_tables is not None
+ ):
+ route = (
+ "the envelope plasma"
+ if responses.envelope_plasma is not None
+ else "a general modal guide's"
+ )
+ msg = (
+ "native runs the private gated plasma benchmark on a field plasma column "
+ f"off the general route, and this problem's plasma is {route}, which has "
+ "no gated form"
)
+ raise BackendError(msg)
def _refuse_unsupported(problem: PropagationProblem, stepper: StepperSettings) -> None:
- _refuse_reference_only(stepper)
- _refuse_omitted_stages(problem.responses)
- if problem.precision.single:
- raise BackendError(
- "solprop-native computes in binary64 only and refuses precision='single' "
- "(solprop SEP 33); use backend='numpy_ref', the single-precision oracle"
- )
+ _refuse_unknown_method(stepper)
# The route by its variant (solprop SEP 47 section 6.2), refused by name before
# anything is allocated: every route of the union but the modal route's TDSE
# extension, which is the reference's alone.
@@ -3051,24 +2978,8 @@ def _refuse_unsupported(problem: PropagationProblem, stepper: StepperSettings) -
# Schema 16's low-call Adams integrator (solprop SEP 13) runs here on the modal
# fibre route (SEP 14 phase 2), its source evaluated by the general engine's stage
# pipeline; elsewhere it is refused by name rather than stepped by this backend's
- # Runge-Kutta march, which would silently be a different method. A 'cf4' section
- # is refused first, by the shared list: no integrator runs one here, so the
- # integrator's advice to take the Runge-Kutta march would mislead.
- flow = problem.linear.coupled_flow
- if flow is not None and "cf4" in flow.section_methods:
- refuse_general_route(
- problem, backend="native", flow_methods=_FLOW_METHODS, moving_maps=True
- )
+ # Runge-Kutta march, which would silently be a different method.
_refuse_adams(problem, stepper)
- # One list for every backend: the general modal route and the coupled flow, each
- # refusal naming this backend.
- # A moving guide's synthesis and projection, combined per stage over schema 14's
- # `StageScalars.transform_weights`, run here beside the frozen-residual flow that
- # carries their connection.
- refuse_general_route(
- problem, backend="native", flow_methods=_FLOW_METHODS, moving_maps=True
- )
- _refuse_native_route(problem)
flags = problem.flags
if problem.transform.n_pol * problem.shape[1] > 2:
raise BackendError(
@@ -3088,57 +2999,16 @@ def _refuse_unsupported(problem: PropagationProblem, stepper: StepperSettings) -
raise BackendError(
"vector Raman requires tensor-capable terms; found scalar_only"
)
- if (
- flags.has_spectral_responses and problem.responses.envelope_plasma is None
- ) or any(
- response is not problem.responses.envelope_plasma
- for response in problem.responses.spectral_responses
- ):
- raise BackendError(
- "spectral responses other than envelope plasma are unsupported by Kokkos"
- )
-
-
-_FLOW_METHODS: tuple[SectionMethod, ...] = ("constant_schur", "frozen_residual")
-"""The section methods of ``linear.coupled_flow`` this backend executes.
-
-The constant Schur section and the frozen-residual varying flow. The commutator-free
-CF4 flow is solprop's reference backend's alone, and solprop's shared admission list
-refuses it here by name.
-"""
-
-
-def _refuse_native_route(problem: PropagationProblem) -> None:
- """Refuse what solprop's shared list admits and this backend has not built.
-
- The general modal route and the coupled flow are admitted by solprop's one list,
- :func:`solprop.backends.numpy_ref.admission.refuse_general_route`, run under this
- backend's name before anything is allocated. What remains
- here is this backend's own: the native delayed response contracts the six pair
- histories of three Cartesian components, which the reference's arithmetic does
- not require of a record.
- """
- tables = problem.transform.modal_tables
- regional = problem.responses.regional_responses
- if tables is None or regional is None or not regional.n_terms:
- return
- components = int(tables.electric.shape[2])
- if regional.n_terms != DELAYED_TERMS or components != 3:
- raise BackendError(
- f"native executes the {DELAYED_TERMS} delayed channels of the silica "
- "tensor Raman model on three components, and "
- f"responses.regional_responses carries {regional.n_terms} term(s) on "
- f"{components}"
- )
@dataclasses.dataclass(frozen=True)
-class _HostFlow:
+class _HostFlow(CoupledFlowFactors):
"""A coupled flow's Schur factors per section on its active bins, host-side.
- Formed as solprop's reference backend forms its own (``_coupled_flow_maps``): the
- frozen block of each section read out of the leaf, decomposed per active bin, the
- declared conditioning row, and a frozen-residual flow's terms; with each
+ solprop's shared factors (``solprop.translate.coupled.CoupledFlowFactors``), which
+ the reference executes too: the frozen block of each section read out of the leaf,
+ decomposed per active bin, the declared conditioning row, and a frozen-residual
+ flow's terms; with each
section's eigenbasis of its triangular factor (solprop's
``schur_eigenbasis``), whose route marks the bins formed from it, and the largest
row-sum norm of the factor, which the squaring refusal reads. The eigenbasis
@@ -3149,13 +3019,7 @@ class _HostFlow:
(``residual_bins``).
"""
- active: npt.NDArray[np.bool_]
scored: npt.NDArray[np.bool_]
- vectors: ComplexArray
- triangular: ComplexArray
- frozen: ComplexArray
- condition_bound: RealArray
- residual_terms: ComplexArray | None
left: ComplexArray
right: ComplexArray
eigenvalues: ComplexArray
@@ -3188,47 +3052,23 @@ def _decomposed_flow(problem: PropagationProblem) -> _HostFlow | None:
flow: CoupledFlowTables | None = problem.linear.coupled_flow
if flow is None:
return None
- generator = flow.generator
- assert generator is not None
- sections = len(flow.section_methods)
- active = np.asarray(np.any(generator != 0.0, axis=(0, 1, 2, 3)), dtype=np.bool_)
- frozen = np.stack(
- [
- np.moveaxis(frozen_generator(flow, index)[:, :, active], -1, 0)
- for index in range(sections)
- ]
- )
- factors = [schur_blocks(np.moveaxis(block, 0, -1)) for block in frozen]
- bases = [schur_eigenbasis(*pair) for pair in factors]
- residual: ComplexArray | None = None
- if flow.section_methods != ("constant_schur",):
- terms = generator[:, :, :, :, active]
- if flow.connection is not None:
- terms = terms - flow.connection[:, :, :, :, active]
- residual = np.ascontiguousarray(
- np.moveaxis(terms, (2, 3), (-2, -1)).reshape(
- sections * flow.n_deg, -1, flow.n_modes, flow.n_modes
- ),
- dtype=np.complex128,
- )
+ factors = coupled_flow_factors(flow)
+ active = factors.active
+ bases = [
+ schur_eigenbasis(vectors, triangular)
+ for vectors, triangular in zip(factors.vectors, factors.triangular, strict=True)
+ ]
return _HostFlow(
- active=active,
+ **vars(factors),
scored=np.ascontiguousarray(problem.grids.inband_mask[active], dtype=np.bool_),
- vectors=np.stack([pair[0] for pair in factors]),
- triangular=np.stack([pair[1] for pair in factors]),
- frozen=np.ascontiguousarray(frozen, dtype=np.complex128),
- condition_bound=np.ascontiguousarray(
- flow.inverse_condition_bound[:, active], dtype=np.float64
- ),
- residual_terms=residual,
left=np.ascontiguousarray(np.stack([basis[0] for basis in bases])),
right=np.ascontiguousarray(np.stack([basis[1] for basis in bases])),
eigenvalues=np.ascontiguousarray(np.stack([basis[2] for basis in bases])),
route=np.stack([basis[3] for basis in bases]),
row_sum=np.asarray(
[
- float(np.max(np.sum(abs(pair[1]), axis=-1), initial=0.0))
- for pair in factors
+ float(np.max(np.sum(abs(triangular), axis=-1), initial=0.0))
+ for triangular in factors.triangular
],
dtype=np.float64,
),
@@ -3677,6 +3517,9 @@ def _native_problem(
"harmonic_factor": 0.0 if kerr is None else kerr.harmonic_factor,
"take_real": kerr is not None and kerr.take_real,
"plasma": plasma is not None,
+ # The private gated benchmark (solprop's tests/gated_source.py), read where
+ # the backend is built, as the reference reads it.
+ "plasma_gated": plasma is not None and _BENCHMARK_GATED.get(),
"raman": raman is not None,
"raman_thg": raman is not None
and bool(raman.thg)
@@ -3919,14 +3762,13 @@ def _slab_payload(problem: PropagationProblem) -> dict[str, object]:
def _general_payload(
problem: PropagationProblem, chunk: int | None
) -> dict[str, object]:
- """Pack the general modal route's maps, derived as ``numpy_ref`` derives them.
+ """Pack the general modal route's maps, derived as the reference derives them.
The per-node tensor, the form and the delayed maps come from solprop's own
- public derivations (``solprop.backends.numpy_ref.general_modal``), so this
- backend executes exactly the arrays the reference executes; the chunk is the
- reference's own memory strategy at the same budget
- (:func:`solprop.backends.numpy_ref.general_modal.general_planned_chunk`), given as
- `chunk`. Nothing is packed for a problem without ``transform.modal_tables``.
+ derivations (``solprop.translate.general``), so both backends execute the same
+ arrays; the chunk is the route's own at the same budget
+ (:func:`solprop.backends.node_chunk.general_planned_chunk`), given as `chunk`.
+ Nothing is packed for a problem without ``transform.modal_tables``.
"""
tables = problem.transform.modal_tables
if tables is None:
diff --git a/native/src/solprop_native/session.py b/native/src/solprop_native/session.py
index 0d686237d..35cfa29a9 100644
--- a/native/src/solprop_native/session.py
+++ b/native/src/solprop_native/session.py
@@ -63,9 +63,8 @@
phase at each plane, formed on the host or, for a z table's propagator phase, read
from its slot in the leased state;
- no group (:mod:`solprop_native.group` runs one native session per member); and no
- comparison method, controller that overrides a decision or TDSE response, which the
- backend refuses by name where it is built, before it allocates (SEP 47 section
- 6.3).
+ TDSE response, which the backend refuses by name where it is built, before it
+ allocates (SEP 47 section 6.3).
"""
from __future__ import annotations
@@ -95,8 +94,7 @@
Terminal,
)
from solprop.backends.export import destination_refusal
-from solprop.backends.multistep import CallLedger, budget_error
-from solprop.backends.numpy_ref import QUADRATURE_ALARM
+from solprop.backends.ledger import CallLedger, budget_error
from solprop.backends.populations import axis_refusal
from solprop.backends.protocol import BackendHistory, StepStats, StepSummary
from solprop.backends.session import (
@@ -133,6 +131,7 @@
from solprop.stepper.schedule import LANDING_ULPS
from solprop.stepper.tableau import continuous_extension
from solprop.translate.linop import power_weight_at
+from solprop.translate.modal import QUADRATURE_ALARM, warn_quadrature
from . import _core, _counters, _transverse, _watch
from ._load import Loaded
@@ -160,10 +159,6 @@
_LOG = logging.getLogger(__name__)
-_QUADRATURE_LOG = logging.getLogger("solprop.backends.numpy_ref.backend")
-"""The reference's logger, on which the transverse quadrature alarm is given, so that a
-filter on the alarm reads both backends alike (``_check_quadrature``)."""
-
if TYPE_CHECKING:
from types import TracebackType
@@ -241,10 +236,9 @@ def refusal(
Decided from the problem, the request and the start alone, before anything is
allocated: the statistics the session does not evaluate. Everything else native
- does not run -- a comparison method, a controller that overrides a decision, a
- TDSE response, a coupled flow beside the interaction formulation, stage rows the
- native tables do not read -- the backend refuses by name where it is built
- (``NativeBackend``), still before any native state (SEP 47 section 6.3).
+ does not run -- a TDSE response, a coupled flow beside the interaction formulation,
+ stage rows the native tables do not read -- the backend refuses by name where it is
+ built (``NativeBackend``), still before any native state (SEP 47 section 6.3).
Parameters
----------
@@ -918,8 +912,8 @@ def advance(
def _check_quadrature(self, event: Event) -> None:
"""Warn once a plane if a step's transverse quadrature score passed the alarm.
- The reference's warning (``NumpyRefBackend._update_alarm``), on its logger and
- with its threshold: the native state keeps the largest accepted step's score
+ The shared warning (:func:`solprop.translate.modal.warn_quadrature`): the native
+ state keeps the largest accepted step's score
since the last read, which the session reads under its lock once an advance
(``Session::quadrature_peak``); the warning
is given once between two observations, as the reference gives it once a
@@ -933,15 +927,7 @@ def _check_quadrature(self, event: Event) -> None:
self._quadrature_peak = peak
if peak > QUADRATURE_ALARM and not self._alarm_reported:
self._alarm_reported = True
- _QUADRATURE_LOG.warning(
- "the transverse quadrature reproduces the nonlinear polarisation to "
- "only %.2e at z = %.6g m, above the %.0e alarm; the integrand is not "
- "resolved by this rule (a kinked ionisation rate, or too few nodes for "
- "the mode set) and no step size can fix it",
- peak,
- float(self._native.status().z),
- QUADRATURE_ALARM,
- )
+ warn_quadrature(peak, float(self._native.status().z))
if isinstance(event, ObservationReady):
self._alarm_reported = False
@@ -1763,8 +1749,8 @@ def _budget_error(self, event: _core.SessionEvent) -> BaseException:
"""Return the error of a trial the call budget could not pay for.
Formed from the session's own ledger, position and committed field, as the
- backend's march forms it from its own (:func:`~solprop.backends.multistep.
- budget_error`).
+ backend's march forms it from its own
+ (:func:`~solprop.backends.ledger.budget_error`).
"""
calls = self._sync_calls()
assert calls is not None
diff --git a/native/tests/test_api_contract.py b/native/tests/test_api_contract.py
index 9f81821a8..bb973914d 100644
--- a/native/tests/test_api_contract.py
+++ b/native/tests/test_api_contract.py
@@ -65,23 +65,6 @@ class PhaseBThreadRecord(ThreadRecord):
def test_public_surface_is_exact() -> None:
assert native.__all__ == [
- "COUPLED_FLOW_FIELDS",
- "DISPERSIVE_KERR_FIELDS",
- "LINEAR_OPERATOR_FIELDS",
- "MARCH_POLICY_FIELDS",
- "MODAL_TABLES_FIELDS",
- "PROBLEM_FIELDS",
- "REGIONAL_RESPONSE_FIELDS",
- "REQUEST_FIELDS",
- "REQUEST_SECTION_FIELDS",
- "RESPONSE_FIELDS",
- "ROUTE_FIELDS",
- "SEPARABLE_OPERATOR_FIELDS",
- "STAGE_SCALARS_FIELDS",
- "STAGE_TERMS_FIELDS",
- "TRANSFORM_FIELDS",
- "WATCH_POLICY_FIELDS",
- "WATCH_STATE_FIELDS",
"BuildInfo",
"NativeBackend",
"__version__",
@@ -224,7 +207,6 @@ def test_build_info_is_frozen_and_complete() -> None:
else:
pytest.fail(f"unknown device backend {info.device_backend!r}")
assert info.fft_planner.startswith("Kokkos-FFT persistent Plan")
- assert info.precision == "complex128"
assert info.layout == "LayoutRight"
assert info.fast_math_enabled is False
diff --git a/native/tests/test_backend.py b/native/tests/test_backend.py
index 1c611a166..3352168ff 100644
--- a/native/tests/test_backend.py
+++ b/native/tests/test_backend.py
@@ -10,6 +10,7 @@
import dataclasses
import functools
import math
+import re
from types import SimpleNamespace
from typing import NoReturn, cast
@@ -26,7 +27,7 @@
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.backends.session import Start
from solprop.driver import Simulation
-from solprop.exceptions import BackendError
+from solprop.exceptions import BackendError, TranslationError
from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid
from solprop.noise import NoiseModel
from solprop.nonlinear.responses import DispersiveKerrResponse, KerrResponse
@@ -36,11 +37,9 @@
from solprop.stats import StatsSpec
from solprop.stepper import FixedSchedule, LowStorageRungeKutta, PIController
from solprop.stepper.multistep import Multistep
-from solprop.translate import masks
from solprop.translate.coupled import CoupledFlowTables
from solprop.translate.linop import delta_phi
from solprop.translate.problem import PropagationProblem, assemble
-from solprop.translate.run_request import RunRequest
from solprop.translate.stage import terms
import solprop_native
@@ -165,15 +164,6 @@ def test_spatial_statistics_refuse_a_malformed_native_result(
session.close()
-def test_a_solprop_without_a_descriptor_member_is_refused_by_name(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """The seam is the descriptors and the bundles, not solprop's version string."""
- monkeypatch.delattr(RunRequest, "stepper")
- with pytest.raises(BackendError, match=r"has no RunRequest\.stepper"):
- check_build()
-
-
def test_stage_observation_owns_its_host_copy_and_checks_lifetime(
phase_a_case: PhaseACase,
) -> None:
@@ -203,32 +193,6 @@ def test_stage_observation_owns_its_host_copy_and_checks_lifetime(
state.stage_to_host(0)
-def test_a_stage_bundle_without_a_packed_field_is_refused_by_name(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(
- solprop_native,
- "STAGE_SCALARS_FIELDS",
- (*solprop_native.STAGE_SCALARS_FIELDS, "gone"),
- )
- with pytest.raises(BackendError, match=r"has no StageScalars\.gone"):
- check_build()
-
-
-def test_a_march_policy_field_either_way_is_refused_by_name(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- # The native march reads exactly the policy it was built against (SEP 39): a field
- # it lacks, or one solprop added that it would not honour, refuses the build.
- fields = solprop_native.MARCH_POLICY_FIELDS
- monkeypatch.setattr(solprop_native, "MARCH_POLICY_FIELDS", (*fields, "gone"))
- with pytest.raises(BackendError, match=r"has no MarchPolicy\.gone"):
- check_build()
- monkeypatch.setattr(solprop_native, "MARCH_POLICY_FIELDS", fields[1:])
- with pytest.raises(BackendError, match=r"MarchPolicy has order, which this build"):
- check_build()
-
-
def test_the_solprop_version_is_recorded_and_never_compared(
phase_a_case: PhaseACase, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -331,26 +295,17 @@ def test_the_record_names_the_low_storage_march_s_flows(
backend.close()
-def test_an_order_that_omits_a_held_response_is_refused(
+def test_a_problem_without_its_responses_is_linear_and_admitted(
phase_a_case: PhaseACase,
) -> None:
- # Native reads each response's own leaf, not the order, so the omitted Kerr term
- # would still run. Removing the response as well is a consistent, linear problem.
+ # The response order is derived from the leaves, so removing the Kerr leaf
+ # removes its stage too: a consistent, linear problem.
problem, run_request, grid = phase_a_case
- responses = problem.responses
- accumulate_only = responses.response_order[-1:]
- omitted = dataclasses.replace(
- problem,
- responses=dataclasses.replace(responses, response_order=accumulate_only),
- )
- with pytest.raises(BackendError, match="response_order omits kerr"):
- NativeBackend(omitted, run_request, grid=grid)
linear = dataclasses.replace(
problem,
- responses=dataclasses.replace(
- responses, response_order=accumulate_only, kerr_coefficients=None
- ),
+ responses=dataclasses.replace(problem.responses, kerr_coefficients=None),
)
+ assert len(linear.responses.response_order) == 1
NativeBackend(linear, run_request, grid=grid).close()
@@ -516,17 +471,15 @@ def test_every_schema_14_general_modal_leaf_is_refused_by_name(
The version check cannot catch these: this wheel and the problem both declare 14.
All four leaves are executed (the general modal route and the coupled flow), in
- the combinations solprop's one
- admission list admits, which this adapter runs under its own name. A lone table,
- metric or response is refused by that list before anything is allocated; the
- coupled flow is a real leaf here, a constant-Schur section carrying two
- Chebyshev degrees, which the list refuses as the reference does.
+ the combinations solprop's problem validation admits, which this adapter runs
+ where it is built. A lone table, metric or response is refused there before
+ anything is allocated: a sentinel by its type, the coupled flow, a real leaf of
+ another grid, by its axis.
The sentinel is set with ``object.__setattr__`` on a copy rather than through
``dataclasses.replace``, which re-runs the group's own validation: solprop's
``Transform`` refuses a ``modal_tables`` record beside the static matrices that
- record replaces, so a ``replace`` never reaches this adapter at all. What is
- under test here is the adapter's refusal, not solprop's.
+ record replaces, so a ``replace`` never reaches this adapter at all.
"""
problem, run_request, grid = phase_a_case
assert getattr(getattr(problem, group), leaf) is None
@@ -541,18 +494,20 @@ def test_every_schema_14_general_modal_leaf_is_refused_by_name(
object.__setattr__(spoiled, "route", route)
else:
spoiled = dataclasses.replace(problem, **{group: spoiled_group})
- expected = {
- "coupled_flow": r"native executes a constant-Schur coupled flow",
- "modal_metric": r"native executes linear\.modal_metric",
- "modal_tables": r"native executes a general modal guide",
- "regional_responses": r"native executes responses\.regional_responses",
- }[leaf]
- with pytest.raises(BackendError, match=expected):
+ # Refused by name: its type or its consistency with the state first, then the
+ # route's policy. Tables on the modal route are its transform's, which the route
+ # names.
+ expected = (
+ "with general modal tables"
+ if leaf == "modal_tables"
+ else re.escape(f"{group}.{leaf}")
+ )
+ with pytest.raises(TranslationError, match=expected):
NativeBackend(spoiled, run_request, grid=grid)
def _leaf_value(problem: PropagationProblem, leaf: str) -> object:
- """Return a leaf the shared admission list refuses on this problem."""
+ """Return a leaf the problem's validation refuses on this problem."""
if leaf != "coupled_flow":
return object()
n_omega = problem.shape[-1]
@@ -575,22 +530,6 @@ def _leaf_value(problem: PropagationProblem, leaf: str) -> object:
)
-@pytest.mark.parametrize(
- ("flags", "message"),
- [
- ({"has_spectral_responses": True}, "spectral responses other than envelope"),
- ],
-)
-def test_unsupported_contracts_are_refused_by_name(
- phase_a_case: PhaseACase, flags: dict[str, bool], message: str
-) -> None:
- problem, run_request, grid = phase_a_case
- changed = dataclasses.replace(problem.flags, **flags)
- spoiled = dataclasses.replace(problem, flags=changed)
- with pytest.raises(BackendError, match=message):
- NativeBackend(spoiled, run_request, grid=grid)
-
-
@pytest.mark.parametrize(
("evaluator", "path"),
[
@@ -819,99 +758,6 @@ def test_metadata_and_memory_ledger_are_self_consistent(
backend.close()
-def test_build_refuses_a_missing_separable_residual(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(
- solprop_native,
- "SEPARABLE_OPERATOR_FIELDS",
- (*solprop_native.SEPARABLE_OPERATOR_FIELDS, "missing_residual"),
- )
- with pytest.raises(
- BackendError, match=r"has no SeparableOperator\.missing_residual"
- ):
- solprop_native.check_build()
-
-
-@pytest.mark.parametrize(
- ("tuple_name", "bundle"),
- [
- ("LINEAR_OPERATOR_FIELDS", "LinearOperator"),
- ("TRANSFORM_FIELDS", "Transform"),
- ("RESPONSE_FIELDS", "CompiledResponses"),
- ("MODAL_TABLES_FIELDS", "ModalTransformTables"),
- ("REGIONAL_RESPONSE_FIELDS", "RegionalResponseTables"),
- ("COUPLED_FLOW_FIELDS", "CoupledFlowTables"),
- ("DISPERSIVE_KERR_FIELDS", "DispersiveKerrTerms"),
- ("PROBLEM_FIELDS", "PropagationProblem"),
- ("REQUEST_FIELDS", "RunRequest"),
- ],
-)
-def test_build_refuses_a_renamed_general_modal_leaf(
- monkeypatch: pytest.MonkeyPatch, tuple_name: str, bundle: str
-) -> None:
- """A renamed leaf is named by `check_build`, not an `AttributeError` later.
-
- Reading `problem.linear.coupled_flow` on a solprop that renamed it raises an
- `AttributeError` from inside the adapter's refusal, with no useful context; the
- frozen name is looked up once, at build check, and reported with the member.
- """
- monkeypatch.setattr(
- solprop_native,
- tuple_name,
- (*getattr(solprop_native, tuple_name), "renamed_leaf"),
- )
- with pytest.raises(BackendError, match=rf"has no {bundle}\.renamed_leaf"):
- solprop_native.check_build()
-
-
-def test_build_refuses_a_renamed_request_section_member(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """A renamed member of a request section is named by `check_build`."""
- sections = dict(solprop_native.REQUEST_SECTION_FIELDS)
- sections["ObservationRequest"] = (*sections["ObservationRequest"], "renamed_leaf")
- monkeypatch.setattr(solprop_native, "REQUEST_SECTION_FIELDS", sections)
- with pytest.raises(BackendError, match=r"has no ObservationRequest\.renamed_leaf"):
- solprop_native.check_build()
-
-
-def test_build_refuses_a_missing_request_section(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """A request section solprop no longer defines is named by `check_build`."""
- sections = {**solprop_native.REQUEST_SECTION_FIELDS, "RenamedSpec": ("method",)}
- monkeypatch.setattr(solprop_native, "REQUEST_SECTION_FIELDS", sections)
- with pytest.raises(
- BackendError, match=r"solprop\.translate\.run_request\.RenamedSpec"
- ):
- solprop_native.check_build()
-
-
-def test_the_leaf_member_tuples_name_what_the_installed_solprop_has() -> None:
- """Each nested member the adapter reads exists as a field or property today.
-
- `check_build` passing already says so; this names the three tuples and the classes
- they guard, so a tuple entry that no class has fails here
- by name before any backend is built.
- """
- from solprop.translate.coupled import CoupledFlowTables
- from solprop.translate.modal_tables import ModalTransformTables
- from solprop.translate.responses import DispersiveKerrTerms, RegionalResponseTables
-
- for bundle, wanted in (
- (ModalTransformTables, solprop_native.MODAL_TABLES_FIELDS),
- (RegionalResponseTables, solprop_native.REGIONAL_RESPONSE_FIELDS),
- (CoupledFlowTables, solprop_native.COUPLED_FLOW_FIELDS),
- (DispersiveKerrTerms, solprop_native.DISPERSIVE_KERR_FIELDS),
- ):
- fields = {field.name for field in dataclasses.fields(bundle)}
- for name in wanted:
- assert name in fields or isinstance(getattr(bundle, name), property), (
- f"{bundle.__name__}.{name}"
- )
-
-
@pytest.mark.parametrize("kind", ["radial", "free"])
def test_temporal_statistics_are_refused_on_a_spatial_problem(
phase_a_case: PhaseACase, kind: str
@@ -1144,11 +990,3 @@ def test_the_plasma_stash_is_off_under_the_split_step_absorber() -> None:
assert payloads["nonlinear"]["stash_plasma"] is True
assert payloads["split_step"]["split_step_absorber"] is True
assert payloads["split_step"]["stash_plasma"] is False
-
-
-def test_the_held_current_collar_member_is_checked_by_name(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(masks, "Grids", dataclasses.make_dataclass("Grids", []))
- with pytest.raises(BackendError, match=r"has no Grids\.t_window_fine_derivative"):
- check_build()
diff --git a/native/tests/test_coupled_flow.py b/native/tests/test_coupled_flow.py
index 5655572c9..a917518e0 100644
--- a/native/tests/test_coupled_flow.py
+++ b/native/tests/test_coupled_flow.py
@@ -273,12 +273,6 @@ def test_ten_steps_of_the_flow_are_the_reference_within_the_bound(name: str) ->
assert float(np.max(np.abs(actual - expected))) / scale <= bound
-def test_cf4_is_refused_by_name() -> None:
- case = simulation(radius=taper(), wall=CoherentWall(method="cf4"))
- with pytest.raises(BackendError, match=r"native does not execute a 'cf4' section"):
- NativeBackend(case.translate(), case._run_request(), grid=case.grid) # pyright: ignore[reportPrivateUsage]
-
-
def _owner(
case: Simulation, problem: PropagationProblem, run_request: RunRequest
) -> NativeBackend:
diff --git a/native/tests/test_general_modal.py b/native/tests/test_general_modal.py
index 009891420..c9be00038 100644
--- a/native/tests/test_general_modal.py
+++ b/native/tests/test_general_modal.py
@@ -30,15 +30,15 @@
import numpy.typing as npt
import pytest
from session_runs import alone, assert_same, grouped, launch, one_thread, run
-from solprop.backends.node_chunk import DEFAULT_NODE_BUDGET
+from solprop.backends.node_chunk import DEFAULT_NODE_BUDGET, general_node_chunk
from solprop.backends.numpy_ref import NumpyRefBackend
-from solprop.backends.numpy_ref.general_modal import (
- general_node_chunk,
- node_kerr_tensor,
-)
from solprop.backends.session import Start
from solprop.driver import Simulation
-from solprop.exceptions import BackendError, GeneralModalBasisWarning
+from solprop.exceptions import (
+ BackendError,
+ GeneralModalBasisWarning,
+ TranslationError,
+)
from solprop.grid import FieldGrid
from solprop.modes import (
GradedIndexProvider,
@@ -58,6 +58,7 @@
from solprop.stats import StatsSpec
from solprop.stepper import Multistep
from solprop.stepper.schedule import FixedSchedule
+from solprop.translate.general import node_kerr_tensor
from solprop.translate.problem import PropagationProblem
from solprop.translate.run_request import RunRequest
from thread_sizing import Sizing, assert_threaded
@@ -429,7 +430,7 @@ def _replaced(
)
-def test_every_combination_the_reference_refuses_is_refused_by_name() -> None:
+def test_every_combination_solprop_refuses_is_refused_by_name() -> None:
case = CASES["delayed_tensor"]()
problem = case.problem
tables = problem.transform.modal_tables
@@ -474,7 +475,7 @@ def test_every_combination_the_reference_refuses_is_refused_by_name() -> None:
),
}
for match, one in spoiled.items():
- with pytest.raises(BackendError, match=match):
+ with pytest.raises(TranslationError, match=match):
NativeBackend(one, case.run_request, grid=case.simulation.grid)
@@ -774,13 +775,12 @@ def test_a_moving_guide_runs_ten_steps_as_the_reference_does() -> None:
def test_moving_maps_without_their_connection_are_refused_by_name() -> None:
- """The shared admission list admits moving maps only beside their connection.
+ """Moving maps are admitted only beside their connection.
- Solprop's one list (``refuse_general_route``, run under this backend's name)
- refuses moving maps carried with no coupled flow -- modes that change shape and
- exchange no power -- as ``numpy_ref`` does. The maps are spoiled to two
- coefficients on a copy, set with ``object.__setattr__`` so that solprop's own
- validation is not what refuses them.
+ Solprop's problem validation (``check_general_route``), which this backend runs
+ where it is built, refuses moving maps carried with no coupled flow -- modes that
+ change shape and exchange no power. The maps are spoiled to two coefficients on a
+ copy, set with ``object.__setattr__`` past the transform's own check.
"""
case = CASES["strand_real"]()
problem = case.problem
@@ -788,14 +788,15 @@ def test_moving_maps_without_their_connection_are_refused_by_name() -> None:
assert tables is not None
assert tables.synthesis is not None
moving = dataclasses.replace(tables)
- object.__setattr__(moving, "synthesis", np.repeat(tables.synthesis, 2, axis=1))
+ repeated = np.repeat(tables.synthesis, 2, axis=1)
+ repeated.setflags(write=False)
+ object.__setattr__(moving, "synthesis", repeated)
transform = dataclasses.replace(problem.transform)
object.__setattr__(transform, "modal_tables", moving)
spoiled = dataclasses.replace(problem)
route = copy.copy(spoiled.route)
object.__setattr__(route, "transform", transform)
object.__setattr__(spoiled, "route", route)
- with pytest.raises(
- BackendError, match=r"native executes a moving guide.s maps.*no coupled flow"
- ):
+ expected = r"solprop executes a moving guide.s maps.*no coupled flow"
+ with pytest.raises(TranslationError, match=expected):
NativeBackend(spoiled, case.run_request, grid=case.simulation.grid)
diff --git a/src/solprop/_helpers.py b/src/solprop/_helpers.py
index b946e6cae..d6fdb2d49 100644
--- a/src/solprop/_helpers.py
+++ b/src/solprop/_helpers.py
@@ -23,7 +23,7 @@
arithmetic is IEEE.
A record may also form a whole exponential Adams trial's coefficient tables in one
call (:attr:`Helpers.tables`), the composition of
-:func:`solprop.backends.multistep.trial_tables` over its own basis, which its
+:func:`solprop.backends.numpy_ref.multistep.trial_tables` over its own basis, which its
resident engine reads every trial; a record without it composes them from the basis.
Translation is not a backend's: it runs before any backend exists and builds the
@@ -86,10 +86,10 @@
]
"""``tables(nodes, order, orders, compensated) -> tables``: a trial's tables at once.
-The contract of :func:`solprop.backends.multistep.trial_tables`, whose composition over
-the record's own basis it performs in one call, with the same operations in the same
-order: ``nodes`` float64 ``(n,)`` and C-contiguous, newest first; the result is that
-function's seven arrays.
+The contract of :func:`solprop.backends.numpy_ref.multistep.trial_tables`, whose
+composition over the record's own basis it performs in one call, with the same
+operations in the same order: ``nodes`` float64 ``(n,)`` and C-contiguous, newest first;
+the result is that function's seven arrays.
"""
diff --git a/src/solprop/backends/exponential.py b/src/solprop/backends/exponential.py
index d219652b7..65f4d0254 100644
--- a/src/solprop/backends/exponential.py
+++ b/src/solprop/backends/exponential.py
@@ -277,8 +277,7 @@ def refuse_formulation(
The exponential formulation steps constant, separable and varying diagonal
operators (phases 2--4) and the constant-Schur and frozen-residual sections of a
- coupled flow (phase 5); a CF4 section is excluded (Q3) and refused by name before
- any source is evaluated.
+ coupled flow (phase 5).
Parameters
----------
@@ -302,15 +301,6 @@ def refuse_formulation(
if flow is None:
return
methods = set(flow.section_methods)
- if "cf4" in methods:
- # SEP 16 Q3: a CF4 section has no explicit residual and exponentiates the
- # generator inside each stage; it is excluded from delivery.
- msg = (
- f"{backend}: the {method.name} integrator does not step a 'cf4' section "
- "of a coupled flow, which only the Runge-Kutta march does; pass "
- "integrator=RungeKutta()"
- )
- raise BackendError(msg)
if not methods <= COUPLED_SECTIONS:
unsupported = ", ".join(sorted(repr(one) for one in methods - COUPLED_SECTIONS))
msg = (
@@ -593,14 +583,10 @@ def _carrier_refusal(
backend: str,
delivered: frozenset[str],
geometry: str | None,
- *,
- single: bool = False,
) -> str | None:
"""Return why `backend` cannot step a carrier frame, or ``None`` when it can.
- ``geometry``: the spatial geometry's kind; ``None`` on a guided route.
- - ``single``: whether the run is held in single precision (SEP 33), where the shift
- was not qualified.
"""
if policy == "off":
return None
@@ -614,11 +600,6 @@ def _carrier_refusal(
f"{backend}: Multistep(carrier_frame={policy!r}) is not yet qualified on "
f"a free-space route ({geometry!r} geometry); pass carrier_frame='off'"
)
- if single:
- return (
- f"{backend}: Multistep(carrier_frame={policy!r}) is not qualified in "
- "single precision; pass carrier_frame='off' or precision='double'"
- )
return None
@@ -656,7 +637,6 @@ def refuse_carrier_frame(
backend,
delivered,
None if geometry is None else geometry.kind,
- single=problem.precision.single,
)
if refusal is not None:
raise BackendError(refusal)
diff --git a/src/solprop/backends/export.py b/src/solprop/backends/export.py
index edc4c0f54..4ce1a562f 100644
--- a/src/solprop/backends/export.py
+++ b/src/solprop/backends/export.py
@@ -99,9 +99,9 @@ def apply_recipe(
of `state`; required when ``recipe.power`` and ignored otherwise.
out : numpy.ndarray or None, optional
The destination, complex128, C-contiguous, the shape of `state`, not
- overlapping it; a new array by default. `state` itself converts in place, for a
- session that owns the only reference to it; each step is elementwise, so the
- result is the same bits.
+ overlapping it; a new array by default. `state` itself converts in place,
+ where the caller holds the only reference to it; each step is elementwise, so
+ the result is the same bits.
Returns
-------
diff --git a/src/solprop/backends/host/__init__.py b/src/solprop/backends/host/__init__.py
index f5572e76f..8be5240a8 100644
--- a/src/solprop/backends/host/__init__.py
+++ b/src/solprop/backends/host/__init__.py
@@ -5,15 +5,15 @@
"""The NumPy reference's own host execution machinery (SEP 47 phase 2).
The stateful control the reference session runs on: the step driver
-(:mod:`solprop.backends.host.driver`), the Adams march and its general path
-(:mod:`~solprop.backends.host.adams`, :mod:`~solprop.backends.host.general`), the
+(:mod:`solprop.backends.host.driver`), the Adams march
+(:mod:`~solprop.backends.host.adams`), the
low-storage march (:mod:`~solprop.backends.host.low_storage`), the fixed-schedule group
(:mod:`~solprop.backends.host.group`), the reference's run session
(:mod:`~solprop.backends.host.session`) and its group session
(:mod:`~solprop.backends.host.group_session`). The reference shares only stateless
numerical modules with the native backend -- the controller cores, tableaux, stage
tables, the exponential coefficients, the Adams and low-storage host engines
-(:class:`~solprop.backends.multistep.HostHistory`,
+(:class:`~solprop.backends.numpy_ref.multistep.HostHistory`,
:class:`~solprop.backends.low_storage.HostLowStorage`) and the call ledger -- and the
parts every Python session composes (:mod:`solprop.backends.session_core`), as the
maintainer decided at SEP 47's acceptance; the native backend runs in its own session
diff --git a/src/solprop/backends/host/adams.py b/src/solprop/backends/host/adams.py
index 45232555a..e162054dd 100644
--- a/src/solprop/backends/host/adams.py
+++ b/src/solprop/backends/host/adams.py
@@ -9,16 +9,15 @@
with the native backend's step driver, phase 5). The state-sized arithmetic is the
shared stateless code (:mod:`solprop.backends.host`): the march hands every operation
of its fused and exponential paths to the host engine
-(:class:`solprop.backends.multistep.HostHistory`), and the comparison variants' trials
-to :class:`solprop.backends.host.general.HostGeneralPath`. The decisions are
+(:class:`solprop.backends.numpy_ref.multistep.HostHistory`). The decisions are
:mod:`solprop.backends.adams_core`'s functions of one policy record.
The method, its lifecycle and its numbers are those of
-:mod:`solprop.backends.multistep`'s module documentation.
+:mod:`solprop.backends.numpy_ref.multistep`'s module documentation.
"""
import math
-from typing import TYPE_CHECKING, Any, Literal, cast
+from typing import Any, Literal, cast
import numpy as np
@@ -26,24 +25,18 @@
from solprop.backends import adams_core
from solprop.backends.exponential import CarrierReference
from solprop.backends.host.driver import HostDriver, reached, replay_tolerance
-from solprop.backends.multistep import (
+from solprop.backends.ledger import CallLedger, Derivative
+from solprop.backends.numpy_ref.multistep import (
AdamsEngine,
AdamsKernels,
- CallLedger,
ExponentialKernels,
HostHistory,
- _Derivative,
- _State,
)
from solprop.exceptions import BackendError, ConvergenceError, ResponseBudgetError
from solprop.output.schema 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
-from solprop.translate.tdse import TDSEPurpose
-
-if TYPE_CHECKING:
- from solprop.backends.host.general import HostGeneralPath
__all__ = ["HostAdamsMarch"]
@@ -64,10 +57,9 @@ class HostAdamsMarch:
``"transported"``, the delivered treatment, or ``"untransported"``, kept as a
test witness only.
engine : object, optional
- An :class:`~solprop.backends.multistep.AdamsEngine` for the fused and
- exponential paths, which the general path never takes, as the bridge's march
- never takes a resident engine there; ``None``, the default, takes the host
- engine (:class:`~solprop.backends.multistep.HostHistory`) over the backend's
+ An :class:`~solprop.backends.numpy_ref.multistep.AdamsEngine`; ``None``, the
+ default, takes the host engine
+ (:class:`~solprop.backends.numpy_ref.multistep.HostHistory`) over the backend's
hooks. A scripted engine drives the controller alone, for the decision traces
of SEP 39 section 7.1. Typed ``object`` because the engine protocol is
structural and not checkable at run time; the members the path calls are
@@ -92,42 +84,22 @@ def __init__(
self._method = method
# Whether the run interpolates its planes, and with what (ADR-036, ADR-039).
self._interpolate = backend._stepper.interpolate_planes
- self._interpolant = getattr(method, "interpolant", "adams")
self._kernels = AdamsKernels(helpers=backend.helpers)
# SEP 16's exponential formulation keeps untransported samples in the same
# store and shares everything else: the lifecycle, the controller, the ledger.
self._exponential: ExponentialKernels | None = None
if method.formulation == "exponential":
self._exponential = ExponentialKernels(helpers=backend.helpers)
- # The selected method's family, one-order-higher PEC, takes the fused path:
- # three passes a step over a store of history rows instead of a list of arrays
- # combined one product at a time.
- self._fused = (
- self._exponential is None
- and method.corrector == "pec"
- and all(
- method.corrector_nodes(order) == order + 1
- for order in range(1, method.order + 1)
- )
- )
# Every state-sized operation goes to an engine (SEP 16 phase 3b), the host
- # one unless a scripted engine is given. The general path's arithmetic stays
- # in its own class.
- if engine is not None and (self._fused or self._exponential is not None):
+ # one unless a scripted engine is given.
+ if engine is not None:
_check_engine(engine, exponential=self._exponential is not None)
self._engine: AdamsEngine = cast("AdamsEngine", engine)
else:
self._engine = HostHistory(
backend, method, self._kernels, self._exponential, compensation
)
- # The comparison variants' general path keeps its arithmetic in a class of its
- # own (SEP 39 Q7); the selected method's paths never reach it.
- self._general: HostGeneralPath | None = None
- if not (self._fused or self._exponential is not None):
- from solprop.backends.host import general # noqa: PLC0415 - cycle
-
- self._general = general.HostGeneralPath(self)
- self._history: list[_Derivative] = []
+ self._history: list[Derivative] = []
self._rejections = 0
self._last_rejected = False
self._order = 1 if method.variable_order else method.order
@@ -199,14 +171,9 @@ def snapshot(self) -> MarchSnapshot | None:
-------
solprop.output.schema.MarchSnapshot or None
The snapshot, or ``None`` where there is nothing a resume could continue:
- no history yet, the march ahead of an interpolated observation, or a march
- that cannot carry its history (:meth:`export_refusal`).
+ no history yet, or the march ahead of an interpolated observation.
"""
- if (
- self.export_refusal() is not None
- or not self._history
- or self._ahead is not None
- ):
+ if not self._history or self._ahead is not None:
return None
export = self._engine.snapshot_rows
backend = self._backend
@@ -237,38 +204,13 @@ def snapshot_nbytes(self) -> int | None:
The state and every engine row, or ``None`` where :meth:`snapshot` returns
``None``.
"""
- if (
- self.export_refusal() is not None
- or not self._history
- or self._ahead is not None
- ):
+ if not self._history or self._ahead is not None:
return None
state = (
math.prod(self._backend._problem.shape) * np.dtype(np.complex128).itemsize
)
return state + self._engine.snapshot_nbytes()
- def export_refusal(self) -> str | None:
- """Return why this march cannot carry its history across a resume, or ``None``.
-
- The fused and exponential paths keep their history in an engine, and every
- engine exports and installs its rows
- (:class:`~solprop.backends.multistep.AdamsEngine`, SEP 29 section 6.6). The
- general path combines a list of host arrays one product at a time and keeps no
- store a snapshot could hold.
-
- Returns
- -------
- str or None
- The reason, or ``None`` when the march exports and installs its history.
- """
- if not (self._fused or self._exponential is not None):
- return (
- "the general Adams path keeps its history as host arrays a resume "
- "does not carry"
- )
- return None
-
def load(self, snapshot: MarchSnapshot) -> str | None:
"""Continue from a snapshot taken where the backend's state now stands.
@@ -301,7 +243,7 @@ def load(self, snapshot: MarchSnapshot) -> str | None:
return str(error)
backend._adams_restore(snapshot.z, np.array(snapshot.state, copy=True)[None])
self._history = [
- _Derivative(float(z), self._engine.value(int(slot)), identity, int(slot))
+ Derivative(float(z), self._engine.value(int(slot)), identity, int(slot))
for z, identity, slot in zip(
snapshot.positions, snapshot.identities, snapshot.slots, strict=True
)
@@ -320,9 +262,6 @@ def _refusal(self, snapshot: MarchSnapshot) -> str | None:
- Returns: the reason, or ``None`` when the snapshot fits.
"""
backend = self._backend
- refusal = self.export_refusal()
- if refusal is not None:
- return refusal
checks = (
(
# Starting afresh is always safe.
@@ -361,23 +300,6 @@ def _refusal(self, snapshot: MarchSnapshot) -> str | None:
)
return next((reason for failed, reason in checks if failed), None)
- # --- evaluation -------------------------------------------------------------------
-
- def _source(
- self,
- lab: _State,
- anchor: float,
- position: float,
- *,
- purpose: TDSEPurpose = "predictor",
- ) -> tuple[_State, _State]:
- """Return the source at a lab-frame field, in the anchor's frame, and U.
-
- - ``anchor``, ``position``: the frame's anchor and the evaluation position, m.
- - Returns: ``U(anchor, position) N(lab, position)`` and ``U(position, anchor)``.
- """
- return self._backend._adams_source(lab, anchor, position, purpose=purpose)
-
# --- the march --------------------------------------------------------------------
def march_to(self, target: float) -> None:
@@ -543,9 +465,7 @@ def _afford(self) -> None:
"""Refuse a trial the call budget cannot pay for, before any call is made."""
self._raise_floor()
budget = self._policy.max_calls
- cost = (0 if self._history else 1) + (
- 2 if self._method.corrector == "pece" else 1
- )
+ cost = (0 if self._history else 1) + 1
spent = self.ledger.total
backend = self._backend
if budget is not None and spent + cost > budget:
@@ -570,9 +490,8 @@ def _start(self) -> None:
backend = self._backend
z = backend._z
self.ledger.startup += 1
- stored = self._fused or self._exponential is not None
- value = self._engine.start(z, stored=stored)
- self._history = [_Derivative(z, value, "accepted", 0 if stored else -1)]
+ value = self._engine.start(z)
+ self._history = [Derivative(z, value, "accepted", 0)]
self._order = 1 if self._policy.variable_order else self._policy.order
def _capped(self, proposal: float) -> float:
@@ -633,10 +552,7 @@ def _attempt(
"""
if self._exponential is not None:
return self._attempt_exponential(dz, fixed=fixed, landing=landing)
- if self._fused:
- return self._attempt_fused(dz, fixed=fixed, landing=landing)
- assert self._general is not None
- return self._general.attempt(dz, fixed=fixed, landing=landing)
+ return self._attempt_fused(dz, fixed=fixed, landing=landing)
def _attempt_fused(
self,
@@ -723,7 +639,7 @@ def _decide(
engine.exponential_accept(rows, free, interpolate=self._interpolate)
self._history = [
*keep,
- _Derivative(z + dz, engine.value(free), "predicted", free),
+ Derivative(z + dz, engine.value(free), "predicted", free),
]
self.ledger.accepted_steps += 1
self._rejections = 0
diff --git a/src/solprop/backends/host/driver.py b/src/solprop/backends/host/driver.py
index de368aa83..a4547ec32 100644
--- a/src/solprop/backends/host/driver.py
+++ b/src/solprop/backends/host/driver.py
@@ -28,7 +28,7 @@
from abc import ABC, abstractmethod
from collections import deque
from collections.abc import Callable, Mapping, Sequence
-from typing import TYPE_CHECKING, ClassVar, TypeVar, cast
+from typing import TYPE_CHECKING, TypeVar, cast
import numpy as np
from jaxtyping import Complex, Float
@@ -63,7 +63,7 @@
from solprop.output.schema import MarchSnapshot
from solprop.profiles import ZProfile
from solprop.stats import StatsSpec
-from solprop.stepper.controller import ControlRecord, PIController
+from solprop.stepper.controller import ControlRecord
from solprop.stepper.low_storage import LowStorageRungeKutta
from solprop.stepper.schedule import (
FixedSchedule,
@@ -329,19 +329,12 @@ class HostDriver[_StateT](ABC): # noqa: UP049 - the protected state type is pri
stage tables' Clenshaw rows and the Adams basis -- which the driver, march and
engines pass down rather than choosing one (SEP 29 section 6.6): ``PLAIN``, the
uncompiled forms, for the NumPy reference.
- observes_fresh_field : bool
- Whether ``_observed_field`` makes a new array at every call and keeps no
- reference to it, so that :meth:`observe` can say its field is owned
- (:attr:`solprop.backends.protocol.Observation.field_owned`). ``False`` by
- default: the array-backed state is observed as it is, frozen, and left
- behind by the next step.
"""
name: str = "driver"
max_batch: int = 1
propagator_default: PropagatorMode = "tabulate"
helpers: Helpers = PLAIN
- observes_fresh_field: ClassVar[bool] = False
def __init__(
self,
@@ -562,7 +555,7 @@ def _adams_block(self, z: float, dz: float) -> object:
(SEP 16 section 4.5), with ``G_s`` the frozen block of the section the step is
anchored in. ``None``, the default, is a problem with no coupled flow; a
backend that holds one returns its
- :class:`~solprop.backends.multistep.CoupledBlock`.
+ :class:`~solprop.backends.numpy_ref.multistep.CoupledBlock`.
- ``z``: where the step starts, m.
- ``dz``: the step, m.
@@ -616,15 +609,6 @@ def _adams_restore(
self._z = z
self._state = cast(_StateT, state)
- @property
- def _norm_controller(self) -> PIController:
- """Return the controller whose tolerances scale this backend's error norms.
-
- The problem's own; a backend whose state is held at a scale ``s`` divides its
- absolute tolerance by ``s`` (SEP 33 section 4.3).
- """
- return self._stepper.controller
-
def _observed_field(
self, state: _StateT
) -> Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]:
@@ -632,8 +616,7 @@ def _observed_field(
Array-backed CPU implementations use the identity. A device backend overrides
this hook to synchronize and copy without teaching the shared march its state
- representation, and sets :attr:`observes_fresh_field` when every call makes a
- new array.
+ representation.
- Returns: an owning host ``complex128`` field in canonical layout.
"""
@@ -897,17 +880,15 @@ def march_snapshot_nbytes(self, z: float) -> int | None:
return self._adams.snapshot_nbytes()
def march_export_refusal(self) -> str | None:
- """Return why this backend's Adams march cannot carry its history, or ``None``.
+ """Return ``None``: every march the reference admits carries its history.
Returns
-------
- str or None
- The reason
- (:meth:`solprop.backends.host.adams.HostAdamsMarch.export_refusal`), or
- ``None`` for a march that exports and installs its history and for the
- Runge-Kutta and low-storage marches, which keep none.
+ None
+ The Adams engines export and install their rows; the Runge-Kutta and
+ low-storage marches keep none.
"""
- return None if self._adams is None else self._adams.export_refusal()
+ return None
def restore_march(self, snapshot: MarchSnapshot) -> str | None:
"""Continue the Adams march from a snapshot at the loaded position.
@@ -961,7 +942,7 @@ def restore_call_ledgers(
if logical and self._adams is not None:
# The march imports its driver, so the ledger type is loaded only when
# a constructed backend restores a saved run.
- from solprop.backends.multistep import CallLedger # noqa: PLC0415
+ from solprop.backends.ledger import CallLedger # noqa: PLC0415
self._adams.ledger = CallLedger.from_record(logical)
@@ -1381,13 +1362,12 @@ def observe(self, *, field: bool = True) -> Observation:
state = self._require_state()
history = _history_record(self._history)
whole = self._observed_field(state) if field else None
- if whole is not None and not self.observes_fresh_field:
+ if whole is not None:
# The state itself, frozen as an observed array always was so that the
# next step replaces it rather than accepting in place, and held, so the
# observation takes a view of it without a copy. A state that only views
# memory the march owns is frozen but not held, and the observation copies
- # it. A fresh export stays writeable beneath the observation, which copies
- # it once: the session converts that copy in place (SEP 47 section 5.4).
+ # it.
hold(whole)
plane = Observation(
z=self._z,
@@ -1397,7 +1377,6 @@ def observe(self, *, field: bool = True) -> Observation:
n_steps=history.n_steps,
n_rejected=history.n_rejected,
step_stats=self._drain_step_stats(),
- field_owned=field and self.observes_fresh_field,
)
self._history = []
self._clock = time.perf_counter()
@@ -2213,12 +2192,11 @@ def _quiet_capped(self, proposal: float) -> float:
def _collar_can_rest(self) -> bool:
"""Return whether this run's stages may leave a quiet temporal collar out.
- One rule for every backend: a binary64 modal or radial stage with the
- in-stage (``"nonlinear"``) scheme and a temporal collar alone. The
- low-storage march, coupled flows, the general modal route, a real-space
- collar sharing the term, single precision, and the Cartesian, line and
- planar routes, whose stages keep the term whatever the monitor says, keep
- the collar in every stage (SEP 21 D15).
+ One rule for every backend: a modal or radial stage with the in-stage
+ (``"nonlinear"``) scheme and a temporal collar alone. The low-storage march,
+ coupled flows, the general modal route, a real-space collar sharing the term,
+ and the Cartesian, line and planar routes, whose stages keep the term whatever
+ the monitor says, keep the collar in every stage (SEP 21 D15).
- Returns: True when the term may be left out while the collar is quiet.
"""
@@ -2234,7 +2212,6 @@ def _collar_can_rest(self) -> bool:
and responses.absorber_scheme == "nonlinear"
and problem.linear.coupled_flow is None
and problem.transform.modal_tables is None
- and not problem.precision.single
and resting_geometry
)
@@ -2379,7 +2356,7 @@ def _axis_statistics(
A rule with a node on the axis hands over that node's row from this
observation's node scan; otherwise the axis row is scanned once
- (:func:`solprop.backends.populations.axis_populations`).
+ (:func:`solprop.backends.numpy_ref.populations.axis_populations`).
- ``field``: one batch member at this plane, shape (n_modes, n_pol, n_omega), or
``None`` from a backend whose :meth:`_axis_row_populations` reads its own
@@ -2439,7 +2416,7 @@ def _axis_row_populations(
"""
if field is None:
raise BackendError("the on-axis statistics scan needs the field")
- from solprop.backends.populations import ( # noqa: PLC0415 - a cycle
+ from solprop.backends.numpy_ref.populations import ( # noqa: PLC0415 - a cycle
axis_populations,
)
diff --git a/src/solprop/backends/host/general.py b/src/solprop/backends/host/general.py
deleted file mode 100644
index 5e1013338..000000000
--- a/src/solprop/backends/host/general.py
+++ /dev/null
@@ -1,393 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""The reference's general Adams path: the comparison variants SEP 13 did not select.
-
-PECE, explicit Adams-Bashforth and the matched predictor-corrector pairs controlled
-by Milne's estimate (:class:`~solprop.stepper.multistep.ComparisonMultistep`) combine
-a list of host arrays one product at a time rather than handing a store of history
-rows to an engine. :class:`~solprop.backends.host.adams.HostAdamsMarch` hands their
-trials to :class:`HostGeneralPath`, which keeps their arithmetic and shares the march's
-controller, history, ledger and lifecycle (SEP 39 phase 0, Q7). The selected method and
-the one-order-higher PEC variants never reach it.
-
-Owned by the NumPy reference since SEP 47 phase 2, and the reference's alone since the
-native backend refuses the comparison variants' general path (SEP 47 section 6.3).
-"""
-
-import math
-from collections.abc import Callable
-from dataclasses import dataclass
-from typing import cast
-
-import numpy as np
-
-from solprop._helpers import Helpers
-from solprop._interrupts import committing
-from solprop.backends import adams_core
-from solprop.backends.host.adams import HostAdamsMarch
-from solprop.backends.host.driver import HostDriver
-from solprop.backends.multistep import (
- _combine,
- _DenseStep,
- _Derivative,
- _State,
-)
-from solprop.stepper.multistep import integrated_weights, milne_factor
-
-__all__ = ["HostGeneralPath"]
-
-
-@dataclass
-class _Trial:
- """One trial step: its stencil, its propagator and its predictor."""
-
- z: float
- dz: float
- order: int
- nodes: np.ndarray
- values: list[_State]
- state: _State
- propagator: _State
- predictor: _State
- scale: Callable[[_State], float]
-
- @classmethod
- def start( # noqa: PLR0917 - called positionally
- cls,
- history: list[_Derivative],
- order: int,
- z: float,
- dz: float,
- state: _State,
- backend: HostDriver,
- ) -> "_Trial":
- """Form the predictor of a step from the history, newest derivative first.
-
- - ``history``: the accepted derivatives, oldest first, in the anchor's frame.
- - ``order``: the order asked for; fewer derivatives during startup lower it.
- - ``z``, ``dz``: the anchor and the step, m.
- """
- recent = history[::-1]
- nodes = np.array([(entry.z - z) / dz for entry in recent])
- values = [cast("_State", entry.value) for entry in recent]
- used = min(order, len(history))
- return cls(
- z=z,
- dz=dz,
- order=used,
- nodes=nodes,
- values=values,
- state=state,
- propagator=backend._adams_propagator(z, z + dz),
- predictor=_combine(
- integrated_weights(nodes[:used], check=False, helpers=backend.helpers),
- values[:used],
- dz,
- ),
- scale=_norm_function(backend, state),
- )
-
- def lab(self, increment: _State) -> _State:
- """Return the lab-frame field at the end of the step for an increment.
-
- ``U(z + dz, z) (a_n + increment)``, the increment being ``v - a_n`` in the
- anchor's frame.
- """
- return self.propagator * (self.state + increment)
-
- def norm(self, defect: _State) -> float:
- """Return the weak norm of a defect, carried to the end of the step.
-
- The defect is a local estimate in the anchor's frame; a norm of one is the
- tolerance.
- """
- return self.scale(self.propagator * defect)
-
-
-class HostGeneralPath:
- """The trials of the general Adams path, on the march they belong to.
-
- Parameters
- ----------
- march : solprop.backends.host.adams.HostAdamsMarch
- The march whose history, order, ledger and controller the trials use.
- """
-
- def __init__(self, march: HostAdamsMarch) -> None:
- """Hold the march."""
- self._march = march
-
- def attempt(
- self,
- dz: float,
- *,
- fixed: bool = False,
- landing: tuple[float, bool] | None = None,
- ) -> tuple[bool, float]:
- """Try one step of length `dz` on the general path; commit it if accepted.
-
- Parameters
- ----------
- dz : float
- The step, m.
- fixed : bool, optional
- Whether the step is a prescribed schedule's, accepted whatever its norm.
- landing : tuple or None, optional
- Where an accepted step ends, m, and whether that is a hard kink, which
- restarts the history; ``None`` ends it at ``z + dz`` and restarts nothing.
-
- Returns
- -------
- tuple
- Whether the step was accepted, and its scaled error norm.
- """
- march = self._march
- backend = march._backend
- state = backend._adams_state()
- z = backend._z
- backend._hoist(z)
- trial = _Trial.start(march._history, march._order, z, dz, state, backend)
- if march._method.corrector == "none" and len(trial.values) > trial.order:
- # Free rejection on the history alone: the next Adams-Bashforth formula's
- # difference from this one is this one's defect (SEP 13 section 4.3).
- wider = integrated_weights(
- trial.nodes[: trial.order + 1], check=False, helpers=backend.helpers
- )
- early = _combine(wider, trial.values[: trial.order + 1], dz)
- norm = trial.norm(early - trial.predictor)
- if not norm <= 1.0 and not (fixed and math.isfinite(norm)):
- march._reject(dz, norm, trial.order)
- return False, norm
-
- march.ledger.predictor += 1
- source, _ = march._source(
- trial.lab(trial.predictor), z, z + dz, purpose="predictor"
- )
- increment, defect, formula = self.correct(trial, source)
- norm = trial.norm(defect)
- if not norm <= 1.0 and not (fixed and math.isfinite(norm)):
- march.ledger.rejected += 1
- march._reject(dz, norm, trial.order)
- return False, norm
- self.commit(trial, trial.lab(increment), source, norm, formula, landing)
- return True, norm
-
- def correct(
- self, trial: _Trial, source: _State
- ) -> tuple[_State, _State, tuple[np.ndarray, list[_State]]]:
- """Return the accepted increment and its local-defect estimate.
-
- Parameters
- ----------
- trial : _Trial
- The step being tried.
- source : numpy.ndarray
- The source at the predicted field, in the anchor's frame.
-
- Returns
- -------
- tuple
- The increment ``v - a_n`` the step would accept and the estimate of its
- local defect, both in the anchor's frame, and the nodes and derivatives of
- the formula that produced the increment, for dense observation.
- """
- march = self._march
- order, nodes, values, dz = trial.order, trial.nodes, trial.values, trial.dz
- if march._method.corrector == "none":
- # Explicit Adams-Bashforth keeps its predictor; the stencil that ends at the
- # new derivative rechecks it after the call (SEP 13 section 4.3).
- check = np.concatenate(([1.0], nodes[:order]))
- corrected = _combine(
- integrated_weights(check, check=False, helpers=march._backend.helpers),
- [source, *values[:order]],
- dz,
- )
- predictor = (nodes[:order], values[:order])
- return trial.predictor, corrected - trial.predictor, predictor
- count = march._method.corrector_nodes(order)
- corrector_nodes = np.concatenate(([1.0], nodes[: count - 1]))
- corrected = _combine(
- integrated_weights(
- corrector_nodes, check=False, helpers=march._backend.helpers
- ),
- [source, *values[: count - 1]],
- dz,
- )
- difference = corrected - trial.predictor
- formula = (corrector_nodes, [source, *values[: count - 1]])
- if count == order:
- factor = milne_factor(nodes[:order], corrector_nodes)
- return corrected, factor * difference, formula
- # A corrector one order higher: the difference is the predictor's defect, a
- # conservative estimate of the accepted corrector's.
- return corrected, difference, formula
-
- def commit( # noqa: PLR0917 - called positionally
- self,
- trial: _Trial,
- advanced: _State,
- source: _State,
- norm: float,
- formula: tuple[np.ndarray, list[_State]],
- landing: tuple[float, bool] | None = None,
- ) -> None:
- """Adopt an accepted step: the state, the re-anchored history and the proposal.
-
- Parameters
- ----------
- trial : _Trial
- The accepted step.
- advanced : numpy.ndarray
- The state at its end, before the boundary treatments.
- source : numpy.ndarray
- The source at the predicted field, in the step's anchor frame.
- norm : float
- The step's scaled error norm.
- formula : tuple
- The nodes and anchor-frame derivatives of the accepted formula.
- landing : tuple or None, optional
- Where the step ends and whether it is a hard kink (:meth:`attempt`).
- """
- march = self._march
- backend = march._backend
- method = march._method
- z, dz, propagator = trial.z, trial.dz, trial.propagator
- # The comparison variants' corrected evaluation is inside the commit too.
- with committing():
- finished = backend._adams_adopt(advanced, dz)
- if method.corrector == "pece":
- march.ledger.corrected += 1
- try:
- stored, _ = march._source(finished, z, z + dz, purpose="corrected")
- except BaseException:
- # Adoption installs the candidate before its corrected response. A
- # failed response must leave the checkpoint at the previous state.
- backend._adams_restore(z, trial.state)
- raise
- identity = "accepted"
- else:
- stored = source
- identity = "predicted" if method.corrector == "pec" else "accepted"
- for entry in march._history:
- entry.value = propagator * cast("_State", entry.value)
- march._history.append(_Derivative(z + dz, propagator * stored, identity))
- march._history = march._history[-(method.order + 1) :]
- if march._interpolate:
- march._engine.dense = _DenseStep(
- z=z,
- dz=dz,
- start=trial.state,
- end=finished / propagator,
- start_slope=trial.values[0],
- end_slope=stored,
- nodes=formula[0],
- slopes=formula[1],
- )
- march.ledger.accepted_steps += 1
- march._rejections = 0
- march.ledger.orders.append(trial.order)
- order, norm = self.next_order(trial, source, norm)
- march._last_rejected = False
- march._land(z, dz, landing)
- policy = march._policy
- march._propose(
- dz, adams_core.factor(policy, norm, order, growth=policy.max_growth)
- )
-
- def next_order(
- self, trial: _Trial, source: _State, norm: float
- ) -> tuple[int, float]:
- """Choose the next step's order from neighbouring estimates; no extra call.
-
- For the one-order-higher PEC/PECE family the defect of order ``k`` is the
- difference between the ``k + 1``-derivative corrector through the new source
- and the ``k``-derivative predictor, so orders ``q - 1`` and ``q + 1`` are
- estimated from the same history and source as the step itself (SEP 13 section
- 4.4). The order maximising the predicted step wins if it beats the current one
- by :data:`~solprop.stepper.multistep.ORDER_HYSTERESIS`; the order never rises
- straight after a rejection.
-
- Parameters
- ----------
- trial : _Trial
- The accepted step.
- source : numpy.ndarray
- Its predicted-state source, in the step's anchor frame.
- norm : float
- Its scaled defect at its own order.
-
- Returns
- -------
- tuple
- The next order and the norm it is controlled with.
- """
- march = self._march
- method = march._method
- current = trial.order
- if not method.variable_order:
- march._order = method.order
- return current, norm
- # A neighbour is scored when the history can form it; the order never rises
- # straight after a rejection, nor beyond the derivatives the history holds.
- rising = not march._last_rejected and len(trial.values) > current
- scores = {current: norm} | {
- order: _defect_at(trial, source, order, march._backend.helpers)
- for order in (current - 1, current + 1)
- if 1 <= order <= method.order
- and order <= len(trial.values)
- and (order < current or rising)
- }
- return march._choose_order(current, scores, trial.dz)
-
-
-def _defect_at(trial: _Trial, source: _State, order: int, helpers: Helpers) -> float:
- """Return the scaled defect an accepted step would have had at another order.
-
- The order's predictor and its one-order-higher corrector are formed from the same
- history and the same predicted-state source, so scoring a neighbour costs no call.
- """
- nodes, values = trial.nodes[:order], trial.values[:order]
- predicted = _combine(
- integrated_weights(nodes, check=False, helpers=helpers), values, trial.dz
- )
- corrected = _combine(
- integrated_weights(
- np.concatenate(([1.0], nodes)), check=False, helpers=helpers
- ),
- [source, *values],
- trial.dz,
- )
- return trial.norm(corrected - predicted)
-
-
-def _norm_function(
- backend: HostDriver,
- state: _State,
-) -> Callable[[_State], float]:
- """Return the weak norm of a defect, scaled by the accepted state before the step.
-
- The Runge-Kutta march scales by the larger of the states before and after the step.
- An Adams step outside its stability region can multiply the state many times over
- while its predictor and corrector still agree to a fraction of the *new* state, so
- that scale lets a divergence pass as accurate: SEP 13 phase 2 measured PEC4 growing
- from 1e7 to 1e236 in five accepted steps with every scaled norm below 0.6. The state
- the step starts from is the scale that cannot grow with the step it judges.
-
- - Returns: ``defect -> norm`` for a defect carried to the end of the step.
- """
- problem = backend._problem
- mask, controller = problem.grids.norm_mask, backend._stepper.controller
- # The scale is the state's, the same for every defect of the step, so it is
- # reduced once: :func:`passes.error_norm`'s arithmetic, with the two state norms
- # it would take per call hoisted out.
- magnitude = float(np.linalg.norm(state[..., mask]))
- scale = controller.scale(magnitude, magnitude)
- divisor = scale[mask] if isinstance(scale, np.ndarray) else scale
-
- def scaled(defect: _State) -> float:
- """Return the scaled norm of a defect at the end of the step."""
- return float(np.linalg.norm(defect[..., mask] / divisor))
-
- return scaled
diff --git a/src/solprop/backends/host/group.py b/src/solprop/backends/host/group.py
index e2939f996..6bda576b4 100644
--- a/src/solprop/backends/host/group.py
+++ b/src/solprop/backends/host/group.py
@@ -446,7 +446,6 @@ def _validate_members(members: tuple[HostDriver[object], ...]) -> None:
schedule = settings.schedule
if schedule is None:
raise BackendError("lockstep execution requires an explicit fixed schedule")
- _refuse_mixed_precisions(members)
for member in members:
if not isinstance(member, Backend):
raise BackendError("lockstep members must implement the backend protocol")
@@ -495,19 +494,6 @@ def _validate_members(members: tuple[HostDriver[object], ...]) -> None:
_refuse_mixed_guides(problem, first._problem)
-def _refuse_mixed_precisions(members: tuple[HostDriver[object], ...]) -> None:
- """Refuse members whose problems do not share one precision.
-
- Each member's trial keeps its own format, and the runner's common step and norms
- would mix them.
-
- - ``members``: loaded workspaces, at least one.
- """
- first = members[0]._problem.precision
- if any(member._problem.precision != first for member in members):
- raise BackendError("lockstep execution refuses mixed precisions")
-
-
def _general_leaves(problem: PropagationProblem) -> object:
"""Return what general modal guide a problem carries, comparably.
diff --git a/src/solprop/backends/host/group_session.py b/src/solprop/backends/host/group_session.py
index 1086739f9..7095bf2d8 100644
--- a/src/solprop/backends/host/group_session.py
+++ b/src/solprop/backends/host/group_session.py
@@ -5,12 +5,12 @@
"""The NumPy reference's group session (SEP 47 section 6.7).
:class:`HostGroupSession` is the :class:`~solprop.backends.session.GroupSession` that
-serves every ``numpy_ref`` group, in both precisions, and :class:`HostPreparedGroup` is
+serves every ``numpy_ref`` group, and :class:`HostPreparedGroup` is
the prepared group a reference backend returns from ``prepare_group``. The group loads
each member from its start and hands the loaded members to the reference's group runner
(:class:`~solprop.backends.host.group.HostGroup`, or its member-axis arithmetic
-:class:`~solprop.backends.numpy_ref.batch.NumpyBatchBackend` in binary64, the choice the
-reference's registry row makes through :func:`solprop.backends.registry.select_batch`),
+:class:`~solprop.backends.numpy_ref.batch.NumpyBatchBackend`, the choice
+:func:`solprop.backends.registry.select_batch` makes),
which advances every member through each step of the common fixed schedule in turn.
The session presents that march through the group protocol: one window per save plane,
each member's own events, tickets, counters and outcome, and the failure distinction of
@@ -20,21 +20,18 @@
**per member.** The native group composes one native session per member
(``solprop_native.group.NativeGroupSession``), each advancing a whole window before the
next. The reference group keeps its runner because what it must preserve is the
-runner's, and composed member sessions would change all three:
+runner's, and composed member sessions would change both:
-- *Its arithmetic.* A binary64 group evaluates its stages on arrays with a leading
+- *Its arithmetic.* A group evaluates its stages on arrays with a leading
member axis (``NumpyBatchBackend``), whose reductions are ordered differently from a
lone run's: a member and its lone run agree to rounding, not bitwise
(``tests/backends/test_batch_lockstep.py``). Member sessions would run the lone path
- and change every binary64 group's result.
+ and change every group's result.
- *Its failure positions.* Members take each common step in turn, so a failure in one
member ends the group with the members ahead of it in flight at that step's end
(poisoned) and those behind it at the step's start (SEP 47 section 6.7). Member
sessions advancing whole windows in turn would leave the members ahead committed at
the window's end instead.
-- *Its precision rule.* The runner refuses a group of mixed precisions and runs
- single-precision members each on its own binary32 trial (``HostGroup``,
- ``lockstep_runner``).
Each member's boundary monitor is still its own: a stop ends that member alone while
the others go on (:attr:`~solprop.backends.host.group.HostGroup.member_stops`).
@@ -125,7 +122,7 @@ class HostGroupSession:
entrance.
solprop.exceptions.BackendError
If the runner refuses the members (``select_batch``): another backend, or
- another schedule, planes, precision or periodic statistics.
+ another schedule, planes or periodic statistics.
"""
def __init__(
diff --git a/src/solprop/backends/host/session.py b/src/solprop/backends/host/session.py
index 6df9cdc0a..40c26b4be 100644
--- a/src/solprop/backends/host/session.py
+++ b/src/solprop/backends/host/session.py
@@ -5,7 +5,7 @@
"""The NumPy reference's run session (SEP 47 sections 5 and 6.6).
:class:`HostSession` is the :class:`~solprop.backends.session.RunSession` that serves
-every ``numpy_ref`` run, in both precisions: the launch-before-monitor order, the plane
+every ``numpy_ref`` run: the launch-before-monitor order, the plane
schedule, the snapshot latch, bounded yields, chunked attempts, exports and the terminal
outcome with its checkpoint eligibility, over the reference's own driver
(:class:`solprop.backends.host.driver.HostDriver`), through its halt and yield hooks
diff --git a/src/solprop/backends/ledger.py b/src/solprop/backends/ledger.py
new file mode 100644
index 000000000..1b0710985
--- /dev/null
+++ b/src/solprop/backends/ledger.py
@@ -0,0 +1,306 @@
+# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""The Adams march's bookkeeping, shared by both backends (SEP 13 section 5, SEP 39).
+
+The call ledger and its record, the budget's error, a history entry and whether a
+problem's source is structurally zero: what the native session and the reference's
+host march both keep, with no arithmetic of either.
+"""
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field, fields
+from typing import TYPE_CHECKING
+
+import numpy as np
+from jaxtyping import Complex
+
+from solprop.exceptions import BackendError, ResponseBudgetError
+from solprop.translate.routes import tdse_descriptor
+
+if TYPE_CHECKING:
+ from solprop.translate.problem import PropagationProblem
+
+__all__ = ["CallLedger", "Derivative", "budget_error", "source_absent"]
+
+_State = Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]
+
+
+@dataclass
+class CallLedger:
+ """Expensive source evaluations of one run, by purpose (SEP 13 section 5).
+
+ Attributes
+ ----------
+ startup : int
+ Evaluations at an accepted field to start or restart the history.
+ predictor : int
+ Evaluations at a predicted field, accepted or not.
+ corrected : int
+ Evaluations at a corrected, accepted field; PEC makes none.
+ rejected : int
+ Evaluations spent on steps that were then rejected (included in the above).
+ accepted_steps, rejected_steps : int
+ Step counts.
+ restarts : int
+ History restarts at hard kinks.
+ failure_restarts : int
+ History restarts after :data:`~solprop.stepper.multistep.RESTART_AFTER`
+ consecutive rejections.
+ dense_observations : int
+ Save planes observed by interpolation instead of a landing; none costs a call.
+ orders : list of int
+ The predictor order of every accepted step. Its record
+ (:meth:`to_record`) run-length encodes it, as ``[order, count]`` pairs: a
+ run's order settles, and the record is rewritten at every saved plane.
+ """
+
+ startup: int = 0
+ predictor: int = 0
+ corrected: int = 0
+ rejected: int = 0
+ accepted_steps: int = 0
+ rejected_steps: int = 0
+ restarts: int = 0
+ failure_restarts: int = 0
+ dense_observations: int = 0
+ orders: list[int] = field(default_factory=list)
+
+ def __post_init__(self) -> None:
+ """Start the record's encoding of the orders empty."""
+ # The encoded runs of `orders[:_encoded]`, of the list `_source`: the record
+ # is rewritten at every saved plane, and re-encoding every step's order each
+ # time made a run saved step by step quadratic in its steps.
+ self._source: list[int] | None = None
+ self._runs: list[list[int]] = []
+ self._encoded = 0
+
+ def to_record(self) -> dict[str, object]:
+ """Return the ledger as its run record holds it, the orders run-length encoded.
+
+ The encoding is extended from the orders appended since the last record. A
+ list of orders that was replaced, or shortened, is encoded afresh.
+
+ Returns
+ -------
+ dict
+ Every counter by name, and ``orders`` as ``[order, count]`` pairs, in
+ step order, no two neighbours of the same order.
+
+ Examples
+ --------
+ >>> from solprop.backends.ledger import CallLedger
+ >>> CallLedger(accepted_steps=5, orders=[1, 2, 2, 3, 3]).to_record()["orders"]
+ [[1, 1], [2, 2], [3, 2]]
+ """
+ orders, runs = self.orders, self._runs
+ if (
+ orders is not self._source
+ or len(orders) < self._encoded
+ or (self._encoded and orders[self._encoded - 1] != runs[-1][0])
+ ):
+ self._source, runs, self._encoded = orders, [], 0
+ self._runs = runs
+ for order in orders[self._encoded :]:
+ if runs and runs[-1][0] == order:
+ runs[-1][1] += 1
+ else:
+ runs.append([order, 1])
+ self._encoded = len(orders)
+ record: dict[str, object] = {
+ name: getattr(self, name) for name in _LEDGER_FIELDS
+ }
+ record["orders"] = [list(run) for run in runs]
+ return record
+
+ @classmethod
+ def from_record(cls, record: Mapping[str, object]) -> "CallLedger":
+ """Restore validated cumulative counts without restoring numerical history.
+
+ Parameters
+ ----------
+ record : mapping
+ Persisted counter fields and accepted predictor orders, as
+ :meth:`to_record` writes them.
+
+ Returns
+ -------
+ CallLedger
+ Independently owned counters, the orders decoded step by step.
+
+ Raises
+ ------
+ BackendError
+ If a counter is absent, unknown, negative or not an integer, an order run
+ is not a pair of positive integers, or the runs do not count one order
+ per accepted step.
+
+ Examples
+ --------
+ >>> from solprop.backends.ledger import CallLedger
+ >>> ledger = CallLedger(accepted_steps=3, orders=[1, 2, 2])
+ >>> CallLedger.from_record(ledger.to_record()) == ledger
+ True
+ """
+ if set(record) != set(_LEDGER_FIELDS):
+ raise BackendError("logical call ledger fields do not match this schema")
+ ledger = cls()
+ for name, value in record.items():
+ if name == "orders":
+ continue
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
+ raise BackendError(f"invalid logical call counter {name}")
+ setattr(ledger, name, value)
+ ledger.orders = _decoded_orders(record["orders"], ledger.accepted_steps)
+ return ledger
+
+ @property
+ def total(self) -> int: # numpydoc ignore=RT01
+ """Every charged source evaluation.
+
+ Startup, predictor and corrected evaluations together.
+ """
+ return self.startup + self.predictor + self.corrected
+
+
+_LEDGER_FIELDS = tuple(one.name for one in fields(CallLedger))
+"""The counters and the orders a ledger's record holds, in field order."""
+
+
+def budget_error(
+ max_calls: int | None,
+ spent: int,
+ z: float,
+ cost: int,
+ field: Complex[np.ndarray, "n_modes n_pol n_omega"],
+) -> ResponseBudgetError:
+ """Return the error of an Adams trial the source-call budget cannot pay for.
+
+ One message for every march that keeps the budget: solprop's and the native
+ session's, which charge the same calls (SEP 39 section 4.9).
+
+ Parameters
+ ----------
+ max_calls : int or None
+ The method's budget.
+ spent : int
+ The calls charged so far (:attr:`CallLedger.total`).
+ z : float
+ The last accepted position, m.
+ cost : int
+ The calls the refused trial needs.
+ field : numpy.ndarray
+ The accepted state there, ``(n_modes, n_pol, n_omega)``, owned by the error.
+
+ Returns
+ -------
+ solprop.exceptions.ResponseBudgetError
+ Carrying the last accepted position and state and the calls spent.
+ """
+ msg = (
+ f"the source-call budget of {max_calls} is spent ({spent} "
+ f"charged) at z = {z:.6g} m: the next trial needs {cost}; "
+ "the run stops at its last accepted step, unfinished"
+ )
+ return ResponseBudgetError(msg, z=z, field=field, calls=spent)
+
+
+def _decoded_orders(runs: object, steps: int) -> list[int]:
+ """Return the order of every accepted step from its run-length record.
+
+ - ``runs``: the record's ``orders``: ``[order, count]`` pairs of positive integers.
+ """
+ if (
+ isinstance(runs, list)
+ and runs
+ and all(isinstance(one, int) and not isinstance(one, bool) for one in runs)
+ ):
+ # Development builds between two releases share the output schema, and
+ # those before the encoding wrote one order per step (#80). Read as pairs,
+ # such a list would be refused for its shape alone; it is named instead.
+ raise BackendError(
+ "the record's predictor orders are in the flat form an earlier "
+ "development build wrote, one order per step, not the [order, count] "
+ "pairs this build reads: rerun the case, or read it with that build"
+ )
+ if not isinstance(runs, list) or not all(
+ isinstance(run, list)
+ and len(run) == 2
+ and all(
+ isinstance(one, int) and not isinstance(one, bool) and one > 0
+ for one in run
+ )
+ for run in runs
+ ):
+ raise BackendError(
+ "logical call ledger orders must be [order, count] pairs of positive "
+ "integers"
+ )
+ # Checked before the expansion, which the counts bound.
+ counted = sum(count for _, count in runs)
+ if counted != steps:
+ raise BackendError(
+ f"logical call ledger orders count {counted} steps, and the ledger "
+ f"{steps} accepted steps"
+ )
+ return [order for order, count in runs for _ in range(count)]
+
+
+@dataclass(frozen=True)
+class Derivative:
+ """One entry of an Adams march's derivative history, in the current anchor's frame.
+
+ Attributes
+ ----------
+ z : float
+ Where the derivative was evaluated, m.
+ value : numpy.ndarray or None
+ The history store's row, complex128 of shape
+ ``(n_batch, n_modes, n_pol, n_omega)``, or ``None`` for an engine that keeps
+ its rows resident (the native state).
+ identity : str
+ Which field it was evaluated at: ``"accepted"``, or ``"predicted"`` for the
+ predicted field PEC stores in its place.
+ slot : int
+ Its row of the history store; ``-1`` before it has one.
+ """
+
+ z: float
+ value: _State | None
+ identity: str
+ slot: int = -1
+
+
+def source_absent(problem: "PropagationProblem") -> bool:
+ """Return whether a problem's complete source is structurally zero.
+
+ No response is compiled beyond the accumulation, and no in-stage absorber, no
+ atomic response and no general or spatial pipeline adds a term: SEP 16 section 4.2
+ then requires the exact chord rather than an interpolated compensation, which
+ need not cancel even at the endpoint. Decided from the problem's structure,
+ never from a sample that happens to vanish.
+
+ Parameters
+ ----------
+ problem : solprop.translate.problem.PropagationProblem
+ The problem.
+
+ Returns
+ -------
+ bool
+ Whether every source evaluation is identically zero.
+ """
+ responses = problem.responses
+ geometry = problem.transform.geometry
+ in_stage = (
+ responses.temporal_absorber_fine is not None
+ and responses.absorber_scheme == "nonlinear"
+ ) or (geometry is not None and bool(np.any(geometry.absorber_rate)))
+ return (
+ len(responses.response_order) == 1
+ and not in_stage
+ and tdse_descriptor(problem.route) is None
+ and problem.transform.modal_tables is None
+ and problem.linear.separable is None
+ )
diff --git a/src/solprop/backends/memory.py b/src/solprop/backends/memory.py
index db0957b24..0af0ef976 100644
--- a/src/solprop/backends/memory.py
+++ b/src/solprop/backends/memory.py
@@ -354,8 +354,7 @@ def adams_memory(
) -> dict[str, int]:
"""Return the Adams march's history rows ``numpy_ref`` holds for a problem.
- Empty without an Adams integrator, and on the general path, whose history is a list
- of arrays.
+ Empty without an Adams integrator.
Parameters
----------
@@ -391,22 +390,13 @@ def adams_memory(
>>> rows == {"adams_store": 6 * 16 * grid.n_omega}
True
"""
- from solprop.backends.multistep import source_absent # noqa: PLC0415 - import cycle
+ from solprop.backends.ledger import source_absent # noqa: PLC0415 - import cycle
from solprop.stepper.multistep import Multistep # noqa: PLC0415
method = stepper.integrator
if not isinstance(method, Multistep):
return {}
exponential = method.formulation == "exponential"
- fused = exponential or (
- method.corrector == "pec"
- and all(
- method.corrector_nodes(order) == order + 1
- for order in range(1, method.order + 1)
- )
- )
- if not fused:
- return {}
state = 16 * math.prod(problem.shape)
slots = method.order + 2
flow = problem.linear.coupled_flow
diff --git a/src/solprop/backends/node_chunk.py b/src/solprop/backends/node_chunk.py
index 10bd881ea..f9638cfb8 100644
--- a/src/solprop/backends/node_chunk.py
+++ b/src/solprop/backends/node_chunk.py
@@ -23,7 +23,7 @@
temporal columns per component; a Cartesian or planar column holds
:data:`FREE_COLUMN_BUFFERS`. A mode-averaged run has one node, and a general modal
guide's static matrices are a placeholder of one (its route sizes its own chunk from
- :func:`solprop.backends.numpy_ref.general_modal.general_node_chunk` at the same
+ :func:`general_node_chunk` at the same
budget).
- **The response admissions** then shrink the chunk until each node's envelope plasma,
Raman, dispersive and held-current buffers fit the same budget, refusing a budget that
@@ -56,17 +56,22 @@
if TYPE_CHECKING:
from solprop.translate.modal import Transform
from solprop.translate.problem import PropagationProblem
- from solprop.translate.responses import CompiledResponses
+ from solprop.translate.responses import CompiledResponses, DispersiveKerrTerms
__all__ = [
+ "BUFFERS_PER_GENERAL_NODE",
"BUFFERS_PER_NODE",
"DEFAULT_NODE_BUDGET",
"DEFAULT_SPATIAL_NODE_BUDGET",
"FREE_COLUMN_BUFFERS",
+ "PAIR_BUFFERS_PER_GENERAL_NODE",
"ChunkSource",
"NodeChunkPlan",
"default_node_budget",
"dispersive_node_buffers",
+ "general_dispersive_buffers",
+ "general_node_chunk",
+ "general_planned_chunk",
"plan_node_chunk",
]
@@ -97,6 +102,30 @@
BUFFERS_PER_NODE = 3
"""Node-chunk buffers the fused pass holds: ``E``, ``P`` and the Raman drive."""
+BUFFERS_PER_GENERAL_NODE = 4
+"""Chunk buffers the general source holds per node, each ``n_components`` wide.
+
+The synthesised fine spectrum, the analytic time field, the polarisation in time and
+its one-sided spectrum.
+"""
+
+PAIR_BUFFERS_PER_GENERAL_NODE = 2
+"""Chunk buffers the general source holds per node, each ``n_components`` **squared**.
+
+The tensor contraction is written in two steps rather than as one four-operand
+``einsum``, so that every intermediate it allocates is a buffer this module names:
+the pair history ``E_k conj(E_l)`` and the tensor contracted against it. A single
+``einsum`` with ``optimize=True`` allocates the same intermediates and names none of
+them, which a memory ledger cannot report.
+
+The static maps ``S`` and ``Q``, the per-node tensor, the dense E/H reference, its
+reciprocal partners and the coefficient dual are whole-problem allocations, counted
+row by row by :func:`solprop.backends.numpy_ref.general_modal.general_memory`; they do
+not shrink with the chunk, which is why the counts here and :data:`BUFFERS_PER_NODE` are
+different and neither reuses the other.
+"""
+
+
FREE_COLUMN_BUFFERS = 20
"""Complex-equivalent buffers per component a Cartesian or planar column holds.
@@ -528,3 +557,131 @@ def _dispersive_chunk( # noqa: PLR0917 - called positionally
"node_budget cannot hold one node of the dispersive electronic response"
)
return min(chunk, budget // per_node)
+
+
+def general_dispersive_buffers(leaf: DispersiveKerrTerms | None) -> int:
+ """Return the buffers per node and component SEP 6's response adds to a chunk.
+
+ The compiled general workspace holds, for each node of a chunk and each of its
+ components, every filtered field's spectrum and time series and every product and
+ its spectrum: two complex-equivalent buffers per field and two per product. The
+ symmetric evaluator has one field and one product per region with a gas fill (a
+ mixture's under its mixture filter); the polynomial two fields and two products per
+ group of equal leg bound, and one cube per region
+ (:meth:`~solprop.translate.responses.DispersiveKerrTerms.groups`).
+
+ Parameters
+ ----------
+ leaf : solprop.translate.responses.DispersiveKerrTerms or None
+ The route's dispersive channels, or ``None``.
+
+ Returns
+ -------
+ int
+ Complex-equivalent buffers per node and component; zero without the leaf.
+ """
+ if leaf is None:
+ return 0
+ groups = len(leaf.groups())
+ if leaf.evaluator == "symmetric":
+ fields = products = groups
+ else:
+ regions = 1 if leaf.regions is None else len(set(leaf.regions.tolist()))
+ fields, products = 2 * groups, regions + 2 * groups
+ return 2 * (fields + products)
+
+
+def general_node_chunk(
+ n_components: int,
+ n_to: int,
+ n_omega_fine: int,
+ budget: int,
+ *,
+ dispersive: int = 0,
+) -> int:
+ """Return the nodes the general source may hold at once within a byte budget.
+
+ The chunk bounds the space-domain work alone:
+ :data:`BUFFERS_PER_GENERAL_NODE` complex buffers per node and component,
+ :data:`PAIR_BUFFERS_PER_GENERAL_NODE` per node and component squared, and SEP 6's
+ dispersive response's own (:func:`general_dispersive_buffers`), over the longer of
+ the fine time axis and the fine spectrum. The maps, the reference and the dual are
+ whole-problem allocations that no chunk shrinks
+ (:func:`solprop.backends.numpy_ref.general_modal.general_memory`).
+
+ Parameters
+ ----------
+ n_components : int
+ Cartesian components of the local field, positive.
+ n_to : int
+ Fine time samples, positive.
+ n_omega_fine : int
+ Fine spectral bins, positive.
+ budget : int
+ Bytes the chunk buffers may occupy in total, positive.
+ dispersive : int, optional
+ The dispersive response's complex buffers per node and component; zero, the
+ default, without one.
+
+ Returns
+ -------
+ int
+ The chunk size, at least one.
+
+ Raises
+ ------
+ BackendError
+ If the budget is not positive.
+
+ Examples
+ --------
+ >>> from solprop.backends.node_chunk import general_node_chunk
+ >>> general_node_chunk(3, 1024, 513, 64 * 2**20)
+ 136
+ """
+ if budget <= 0:
+ msg = f"the general node budget must be positive, got {budget!r}"
+ raise BackendError(msg)
+ samples = max(n_to, n_omega_fine)
+ columns = (BUFFERS_PER_GENERAL_NODE + dispersive) * n_components + (
+ PAIR_BUFFERS_PER_GENERAL_NODE * n_components**2
+ )
+ per_node = columns * samples * np.dtype(np.complex128).itemsize
+ return int(max(1, budget // per_node))
+
+
+def general_planned_chunk(problem: PropagationProblem, plan: NodeChunkPlan) -> int:
+ """Return the general route's node chunk under a backend's node-chunk plan.
+
+ The route's own rule (:func:`general_node_chunk`) within the plan's budget -- the
+ explicit one, or :data:`~solprop.backends.node_chunk.DEFAULT_NODE_BUDGET` -- or
+ the plan's exact chunk when one was forced, bounded by the node count. Both
+ compiled backends take it, so that they execute the same chunks.
+
+ Parameters
+ ----------
+ problem : solprop.translate.problem.PropagationProblem
+ A problem that carries ``transform.modal_tables``.
+ plan : solprop.backends.node_chunk.NodeChunkPlan
+ The backend's plan (:func:`solprop.backends.node_chunk.plan_node_chunk`).
+
+ Returns
+ -------
+ int
+ Nodes the general source holds at once, between one and the node count.
+ """
+ tables = problem.transform.modal_tables
+ assert tables is not None
+ nodes = int(tables.region_index.size)
+ chunk = (
+ plan.requested
+ if plan.requested is not None
+ else general_node_chunk(
+ int(tables.electric.shape[2]),
+ problem.grids.n_to,
+ int(tables.omega_fine.size),
+ plan.budget,
+ dispersive=general_dispersive_buffers(problem.responses.dispersive_kerr),
+ )
+ )
+ return min(chunk, nodes)
diff --git a/src/solprop/backends/numpy_ref/__init__.py b/src/solprop/backends/numpy_ref/__init__.py
index f84750474..200ad3dd9 100644
--- a/src/solprop/backends/numpy_ref/__init__.py
+++ b/src/solprop/backends/numpy_ref/__init__.py
@@ -19,10 +19,7 @@
from collections.abc import Mapping, Sequence
-from solprop.backends.numpy_ref.backend import (
- QUADRATURE_ALARM,
- NumpyRefBackend,
-)
+from solprop.backends.numpy_ref.backend import NumpyRefBackend
from solprop.backends.numpy_ref.passes import (
accept_step,
accumulate,
@@ -52,7 +49,6 @@
from solprop.translate.tdse import TDSEBinding
__all__ = [
- "QUADRATURE_ALARM",
"NumpyRefBackend",
"accept_step",
"accumulate",
@@ -86,7 +82,7 @@ def construct(
tdse_binding: TDSEBinding | None = None,
options: ExecutionOptions | None = None,
) -> NumpyRefBackend:
- """Construct the reference for a problem in its precision (the registry's row).
+ """Construct the reference for a problem, as the registry selects it.
Parameters
----------
@@ -108,19 +104,9 @@ def construct(
Returns
-------
NumpyRefBackend
- The binary64 reference, or its binary32 subclass
- (:class:`~solprop.backends.numpy_ref.single.NumpySingleBackend`) for a
- single-precision problem (SEP 33).
+ The reference.
"""
- kind = NumpyRefBackend
- if problem.precision.single:
- # Imported here: the binary32 path is loaded only when it is asked for.
- from solprop.backends.numpy_ref.single import ( # noqa: PLC0415
- NumpySingleBackend,
- )
-
- kind = NumpySingleBackend
- return kind(
+ return NumpyRefBackend(
problem,
request,
grid=grid,
diff --git a/src/solprop/backends/numpy_ref/admission.py b/src/solprop/backends/numpy_ref/admission.py
deleted file mode 100644
index 15743bb2c..000000000
--- a/src/solprop/backends/numpy_ref/admission.py
+++ /dev/null
@@ -1,661 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""The general modal route's and the coupled flow's admission, for every backend.
-
-One list, run by each backend under its own name: ``numpy_ref`` calls
-:func:`refuse_general_route` from its own admission and the native backend calls
-it from its adapter. A combination admitted or
-refused here is admitted or refused on every backend that calls it, and each refusal
-names the calling backend and the leaf, so a new refusal is written once and cannot
-drift between copies.
-
-What a backend has not built is said with `flow_methods`: the section methods of
-``linear.coupled_flow`` it executes. A section method outside them is refused by name
-before anything is allocated. Everything else -- a backend's own kernels' limits --
-stays with the backend that has the limit.
-
-The list runs **before** :meth:`solprop.translate.problem.PropagationProblem.validate`
-and before any buffer is allocated, and repeats checks the translation layer also makes,
-because a problem can be assembled by something other than
-:func:`solprop.translate.problem.assemble` and a backend does not rely on a caller
-having been careful.
-"""
-
-from collections.abc import Collection, Sequence
-from typing import Final
-
-import numpy as np
-
-from solprop.backends.numpy_ref.general_modal import refuse_regional_responses
-from solprop.exceptions import BackendError
-from solprop.translate.coupled import (
- CoupledFlowTables,
- ModalMetricTables,
- SectionMethod,
-)
-from solprop.translate.general import MAX_DENSE_METRIC_DEPARTURE, metric_departure
-from solprop.translate.linop import LinearOperator
-from solprop.translate.modal_tables import DEFAULT_GRAM_ATOL, ModalTransformTables
-from solprop.translate.problem import MAX_N_COMPONENTS, PropagationProblem
-
-__all__ = [
- "FLOW_METHODS",
- "refuse_coupled_flow",
- "refuse_general_leaves",
- "refuse_general_route",
-]
-
-FLOW_METHODS: Final[tuple[SectionMethod, ...]] = (
- "constant_schur",
- "frozen_residual",
- "cf4",
-)
-"""Every section method of ``linear.coupled_flow`` the reference executes.
-
-``"diagonal"`` is not among them: it is the existing cumulative flow and needs no
-leaf. A backend that executes fewer passes its own subset to
-:func:`refuse_coupled_flow`; ``"cf4"`` is executed by the reference alone: it is not yet
-implemented on the native backend.
-"""
-
-
-def refuse_general_leaves(
- problem: PropagationProblem, backend: str, *, admits: Sequence[str] = ()
-) -> None:
- """Refuse, by name, each schema-12 general-modal leaf a backend cannot execute.
-
- The four optional leaves of the general modal route arrived at kernel schema 12
- with no backend behind them. A
- schema check alone cannot stand in for this: a separately built backend compares
- against solprop's imported constant, so a freshly built wheel would accept a
- problem carrying a representation it does not implement and silently run the old
- physical model. Each leaf is therefore named here, one refusal each, and each
- check is deleted only when that backend executes that exact representation.
-
- Every backend in this repository names all four leaves and admits through the
- shared list in this module, so this refusal guards
- a separately built backend. A backend that executes a leaf names it in `admits` and
- checks the exact representation it supports itself: admitting a leaf is a statement
- about one combination of contents, not about the field being present.
-
- Parameters
- ----------
- problem : solprop.translate.problem.PropagationProblem
- The problem to check.
- backend : str
- The backend's registry name, used in the refusal.
- admits : sequence of str, optional
- Dotted leaf names this backend executes, for example
- ``("transform.modal_tables",)``. Empty by default: a backend refuses every
- one until it has a kernel for it.
-
- Raises
- ------
- BackendError
- Naming the backend and the leaf it cannot execute.
- """
- for name, leaf in (
- ("linear.coupled_flow", problem.linear.coupled_flow),
- ("linear.modal_metric", problem.linear.modal_metric),
- ("transform.modal_tables", problem.transform.modal_tables),
- ("responses.regional_responses", problem.responses.regional_responses),
- ):
- if leaf is not None and name not in admits:
- msg = (
- f"{backend} does not execute {name}: the general modal route is not "
- "implemented on this backend, and a leaf silently ignored would run, "
- "converge and be the wrong physics"
- )
- raise BackendError(msg)
-
-
-def refuse_general_route(
- problem: PropagationProblem,
- *,
- backend: str = "numpy_ref",
- flow_methods: Collection[SectionMethod] = FLOW_METHODS,
- moving_maps: bool = False,
-) -> None:
- """Refuse, by name, every general modal or coupled-flow problem not executed.
-
- The coupled flow first: a metric or a table beside it is this route's refusal to
- make, not the general route's, and its message names the flow
- (:func:`refuse_coupled_flow`). Then the general modal route: a field grid, one
- channel axis and no state polarisation axis, the tables' own execution maps of a
- constant guide, an identity metric or the dense metric of a declared unitary change
- of basis of one, the constant unit power weight, no legacy response beside the
- route's own, and per-region responses the route executes
- (``refuse_regional_responses``, in :mod:`solprop.backends.numpy_ref.general_modal`).
- A problem carrying none of the four general-modal leaves passes untouched.
-
- Parameters
- ----------
- problem : solprop.translate.problem.PropagationProblem
- The problem to check.
- backend : str, optional
- The registry name each refusal names.
- flow_methods : collection of str, optional
- The section methods of ``linear.coupled_flow`` the backend executes; every
- one of :data:`FLOW_METHODS` by default, which is the reference's own list.
- moving_maps : bool, optional
- Whether the backend executes a **moving** guide's maps -- synthesis and
- projection of more than one coefficient along z, combined per stage over
- the bundle's ``transform_weights`` (kernel schema 14). False by default, so
- a backend that has not built them refuses them by name whatever else it
- admits; ``numpy_ref`` and the native backend pass True.
-
- Raises
- ------
- BackendError
- Naming the calling backend and what it does not execute.
-
- Examples
- --------
- >>> from solprop.backends.numpy_ref.admission import refuse_general_route
- >>> 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(1.0, 835e-9, (500e-9, 2e-6), 1e-12)
- >>> gnlse = problem_from([0.0, 0.0, -1.183e-26], omega_ref=grid.omega0,
- ... gamma=0.11, shock=False)
- >>> start = SechPulse(wavelength=835e-9, width=100e-15, peak_power=9.8).build(grid)
- >>> problem, request = assemble(grid, gnlse.mode, [gnlse.kerr], start,
- ... controller=PIController(rtol=1e-8, atol=1e-16),
- ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3,
- ... density=1.0, shock=False, prefactor_index=1.0)
- >>> refuse_general_route(problem, backend="native") is None
- True
- """
- refuse_coupled_flow(problem, backend=backend, flow_methods=flow_methods)
- _refuse_modal_route(problem, backend=backend, moving_maps=moving_maps)
-
-
-def refuse_coupled_flow(
- problem: PropagationProblem,
- *,
- backend: str = "numpy_ref",
- flow_methods: Collection[SectionMethod] = FLOW_METHODS,
-) -> None:
- """Refuse, by name, every coupled flow the calling backend does not execute.
-
- The reference executes exactly two combinations: the dense generator of **one**
- constant section with no connection and no residual, by a Schur matrix-function
- action, and a **varying** flow whose every section is ``"frozen_residual"`` or
- ``"cf4"`` -- the dense generator at any degree along z and an optional connection,
- a frozen-residual section by the Schur action of its frozen block with a residual
- the interaction-picture source carries explicitly, and a CF4 section by the
- commutator-free exponentials of its whole generator with none. A backend's
- `flow_methods` narrows that; naming
- ``linear.coupled_flow`` among a backend's admitted leaves says only that the field
- may be present, and this says which contents are executed: every other method,
- representation, mixture of the two and certificate row is named here rather than
- silently ignored.
-
- Parameters
- ----------
- problem : solprop.translate.problem.PropagationProblem
- The problem to check.
- backend : str, optional
- The registry name each refusal names.
- flow_methods : collection of str, optional
- The section methods the backend executes (:data:`FLOW_METHODS` by default).
-
- Raises
- ------
- BackendError
- Naming the part of the leaf, or of the problem around it, it cannot execute.
- """
- flow = problem.linear.coupled_flow
- if flow is None:
- return
- _refuse_flow_representation(flow, backend=backend, flow_methods=flow_methods)
- _refuse_flow_problem(problem, backend=backend)
-
-
-def _refuse_flow_representation(
- flow: CoupledFlowTables,
- *,
- backend: str,
- flow_methods: Collection[SectionMethod],
-) -> None:
- """Refuse a coupled flow whose representation the backend does not execute."""
- methods = set(flow.section_methods)
- frozen = methods <= {"frozen_residual", "cf4"}
- if flow.section_methods != ("constant_schur",) and not frozen:
- varying = " or ".join(
- repr(method) for method in flow_methods if method != "constant_schur"
- )
- msg = (
- f"{backend} executes one constant section by a Schur matrix-function "
- f"action, or every section of a varying flow as {varying}, and this "
- f"flow declares {sorted(methods)} over "
- f"{len(flow.section_methods)} section(s): 'diagonal' is the existing "
- "cumulative flow and needs no leaf, and a constant-Schur section beside "
- "any other is not one this backend builds a single held decomposition for"
- )
- raise BackendError(msg)
- unexecuted = sorted(methods - set(flow_methods))
- if unexecuted:
- msg = (
- f"{backend} does not execute a {' or '.join(map(repr, unexecuted))} "
- "section of linear.coupled_flow: it executes "
- f"{', '.join(map(repr, sorted(flow_methods)))} sections, and a section "
- "method silently integrated by another would run, converge and be a "
- "different approximation. The commutator-free CF4 flow is not yet "
- "implemented on the native backend; pass backend='numpy_ref', or "
- "declare the default frozen split"
- )
- raise BackendError(msg)
- if flow.representation != "dense":
- msg = (
- f"{backend} executes the dense generator of a coupled flow and this one "
- f"is {flow.representation!r}: a factored generator is reconstructed by no "
- "kernel here"
- )
- raise BackendError(msg)
- assert flow.generator is not None
- if not frozen and flow.generator.shape[1] != 1:
- msg = (
- f"{backend} executes a constant-Schur coupled flow whose generator is "
- f"constant across its section, and this one carries "
- f"{flow.generator.shape[1]} Chebyshev degrees along z: a generator that "
- "varies inside its section is a 'frozen_residual' flow, which declares "
- "the residual its variation becomes"
- )
- raise BackendError(msg)
- if not frozen and flow.connection is not None:
- msg = (
- f"{backend} executes no taper connection beside a constant section: "
- "linear.coupled_flow.connection is the moving-basis term of a taper and "
- "belongs to a 'frozen_residual' flow, whose explicit residual carries it"
- )
- raise BackendError(msg)
- if not frozen and float(np.max(flow.residual_norm_bound)) > 0.0:
- msg = (
- f"{backend} executes a constant-Schur coupled flow with no explicit "
- f"residual, and this one declares up to "
- f"{float(np.max(flow.residual_norm_bound)):.4g} 1/m: a section whose "
- "generator varies inside it is a 'frozen_residual' flow"
- )
- raise BackendError(msg)
-
-
-def _refuse_flow_problem(problem: PropagationProblem, *, backend: str) -> None:
- """Refuse a problem whose surroundings the coupled flow cannot be executed in.
-
- The four that are this route's to make. The cumulative diagonal ``Phi``
- multiplies the flow action, which is only the same equation when the two
- **commute**, and a mode-dependent diagonal does not, so ``phi_constant`` is
- required to be a multiple of the identity; a shot-noise draw is transported by the
- unitary phase of a diagonal flow, which a nonunitary coupled flow is
- not; the flux of a coupled block is the square-root model's, whose positivity
- is not established, so no explicit metric may sit beside one; and the block is
- **scalar**, so the state carries one polarisation.
-
- That last one is refused in the translation layer too
- (:func:`solprop.translate.problem._check_wall_route`, which owns the mode set the
- block is built from). It is repeated here because a problem can be assembled by
- something other than :func:`solprop.translate.problem.assemble`, and because the
- action contracts the channel axis alone: a scalar block beside two state
- components would be applied to each of them as though it described both. The TE/TM
- wall block (``CoherentWall(impedance="te_tm")``) names
- the wall's two impedances and is scalar in this sense too: its families are
- channels. The **transform** may resolve two transverse components (a ``TE_0m``,
- ``TM_0m`` or ``EH`` set synthesises E_x and E_y): the synthesis
- is read by the responses alone, never by the flow, so it is not refused.
-
- - ``problem``: the problem, whose coupled flow is not ``None``.
- """
- _, n_pol, _ = problem.shape
- if n_pol != 1:
- msg = (
- f"{backend} executes a coupled flow only for a scalar state, and this "
- f"problem's state carries n_pol = {n_pol} polarisation components (the "
- "product route): the flow's action contracts the channel axis alone, so "
- "a scalar block beside a state with two polarisation components would be "
- "applied to each as though it described both. "
- "CoherentWall(impedance='te_tm') names the wall's TE and TM impedances, "
- "not the state's polarisation axis: its families are channels, one "
- "component each"
- )
- raise BackendError(msg)
- linear = problem.linear
- flow = linear.coupled_flow
- assert flow is not None
- constant = flow.section_methods == ("constant_schur",)
- if constant and (
- linear.phi_representation != "constant" or linear.phi_constant is None
- ):
- msg = (
- f"{backend} executes a constant-Schur coupled flow only beside the exact "
- f"constant representation of Phi, and this problem's is "
- f"{linear.phi_representation!r}: a varying carrier belongs to a "
- "'frozen_residual' flow"
- )
- raise BackendError(msg)
- if not _mode_independent(linear):
- msg = (
- f"{backend} executes a coupled flow only beside a mode-independent Phi: "
- "the flow action and the cumulative diagonal are applied as a product, "
- "which is the same equation only when they commute, and a channel-"
- "dependent diagonal does not commute with a coupled block"
- )
- raise BackendError(msg)
- if not constant and not problem.device_ready:
- msg = (
- f"{backend} executes a frozen-residual coupled flow only beside a Phi "
- "that is a weighted sum of fixed terms, and this problem's took the z "
- "table: the flow's own stage weights and the carrier's must come from "
- "one stage bundle, and the table has no such form "
- "(docs/dev/kernel-contract.md section 1)"
- )
- raise BackendError(msg)
- # The input model's draw was added to the state when it was loaded, so the flow
- # carries it as part of the field and nothing here sees it (SEP 37 section 6);
- # only the modified model keeps a draw apart that a flow would have to transport.
- if problem.initial.noise_model == "modified":
- msg = (
- f"{backend} executes no 'modified' noise model beside a coupled flow: "
- "its one-photon-per-bin draw is advanced by the unitary phase of a "
- "diagonal flow, and its transport through a nonunitary coupled flow is "
- "not measured in this package; the 'input' model is admitted"
- )
- raise BackendError(msg)
- if linear.modal_metric is not None and problem.transform.modal_tables is None:
- # The refusal is about the **coherent wall's** own flux model: a condensed
- # boundary block has no bound reciprocal basis, so its flux is the square-root
- # model's and its positivity is not established. A general modal guide's metric
- # is a different object -- the Gram of its own sampled, power-orthonormal
- # transverse basis, which the route measures and gates before it declares
- # anything -- and the coupled block there is a declared unitary rotation of that
- # basis, which leaves the Gram's spectrum exactly where it was.
- # `transform.modal_tables` is what tells the two apart, because a mode-set route
- # has none and never will.
- msg = (
- f"{backend} executes no explicit metric beside a coupled flow on the "
- "mode-set route: the flux of a condensed boundary block is the "
- "square-root model's, whose positivity is not established. A general "
- "modal guide carries "
- "transform.modal_tables and its metric is its own sampled basis Gram, "
- "which this problem does not"
- )
- raise BackendError(msg)
-
-
-def _mode_independent(linear: LinearOperator) -> bool:
- """Return whether the cumulative ``Phi`` is a multiple of the identity in channels.
-
- That, and not constancy, is what the product ``exp(dPhi) exp(dz G_s)`` needs: a
- scalar multiple of the identity commutes with any block, a channel-dependent
- diagonal does not. Every representation is checked in its own terms -- the exact
- constant array, the expansion's fixed terms, or the table's coefficients -- because
- a varying guide takes whichever of them its declared parameters allow.
-
- - Returns: True when every channel of every term carries the same values.
- """
- held: list[np.ndarray] = []
- if linear.phi_constant is not None:
- held.append(linear.phi_constant)
- if linear.phi_expansion is not None:
- held.append(linear.phi_expansion.terms)
- if linear.phi_coefficients is not None:
- held.append(linear.phi_coefficients)
- return all(
- np.array_equal(
- block,
- np.broadcast_to(np.take(block, [0], axis=-3), block.shape),
- )
- for block in held
- )
-
-
-def _refuse_general_metric(
- tables: ModalTransformTables,
- metric: ModalMetricTables,
- *,
- backend: str,
-) -> None:
- """Refuse a dense metric this backend cannot execute, measuring it itself.
-
- The **nonunitary refusal**, at the backend. ``numpy_ref`` executes a general
- modal guide through the **diagonal** cumulative flow and measures the step
- controller's norm on the coordinate amplitudes, both of which are the basis's
- own only where that basis is orthonormal in true power. A declared unitary
- change of basis of a certified orthonormal reference stays there --
- ``W' = I + U.H delta U`` has the same spectral departure as ``delta`` -- and a
- nonunitary one does not: it is a skewed or leaky basis, whose flow is
- ``linear.coupled_flow`` and whose transport nothing here has measured.
-
- The block is measured here rather than read off ``error``: the translation layer
- declares that row, and a backend does not rely on a caller having been careful
- (the same rule the rest of this admission path follows).
-
- - ``tables``: the reference the metric must describe.
- """
- block = metric.dense
- if block is None:
- return
- if metric.gauge_id != tables.gauge_id:
- msg = (
- f"linear.modal_metric describes the basis {metric.gauge_id!r} and "
- f"transform.modal_tables is in {tables.gauge_id!r}: a metric of another "
- "basis than the reference it is executed beside would weight the wrong "
- "channels"
- )
- raise BackendError(msg)
- departure = max(
- metric_departure(block[interval, degree])
- for interval in range(block.shape[0])
- for degree in range(block.shape[1])
- )
- if departure > MAX_DENSE_METRIC_DEPARTURE:
- msg = (
- f"linear.modal_metric is dense and {departure:.4g} from the identity, "
- f"above the {MAX_DENSE_METRIC_DEPARTURE:.4g} {backend} will execute. "
- "This backend propagates through the diagonal cumulative flow and "
- "measures the controller's norm on the coordinate amplitudes, so it "
- "admits a dense metric only as a unitary change of basis of a "
- "certified orthonormal reference, whose departure is the reference's "
- "own; a nonunitary transform leaves a genuinely nonorthogonal basis, "
- "whose flow is linear.coupled_flow and whose transport is unmeasured"
- )
- raise BackendError(msg)
-
-
-def _refuse_modal_route(
- problem: PropagationProblem, *, backend: str, moving_maps: bool = False
-) -> None:
- """Refuse every general modal problem the calling backend does not execute.
-
- The general modal route is one combination: a field grid, one channel axis and no
- state polarisation axis, the tables' own execution maps, an **identity** metric --
- the Poynting-normalised orthogonal basis the Gram gate certifies -- the constant
- unit power weight of a true-power amplitude, three Cartesian components in the
- responses as in the reference, and constant geometry.
- Everything else is refused by name, because a representation silently ignored
- would run, converge and be the wrong physics.
-
- Two of these are also checked in the translation layer, where the record is
- built: the state's ``n_pol`` and the metric's declared defect. They are checked
- again here because this list runs *before* ``PropagationProblem.validate`` and
- because a problem can be assembled by something other than
- :func:`solprop.translate.problem.assemble`; a backend does not rely on a caller
- having been careful. Every refusal is raised before the route's maps, tensors or
- driver buffers are allocated.
-
- A legacy response beside the route's own is refused too: the route's source
- replaces the node pipeline that reads ``kerr_coefficients``, the population
- channels, the Raman terms and the spectral responses, so any of them would be
- carried in the record and silently not propagated. Every backend refuses it through
- this one list.
- """
- tables = problem.transform.modal_tables
- metric = problem.linear.modal_metric
- if tables is None:
- for name, leaf in (
- ("linear.modal_metric", metric),
- ("responses.regional_responses", problem.responses.regional_responses),
- ):
- if leaf is not None:
- msg = (
- f"{backend} executes {name} as part of the general modal route, "
- "and this problem carries no transform.modal_tables for it to "
- "belong to"
- )
- raise BackendError(msg)
- return
- if metric is None or metric.kind not in ("identity", "dense"):
- kind = "absent" if metric is None else f"of kind {metric.kind!r}"
- msg = (
- f"{backend} executes a general modal guide in a basis certified "
- f"orthonormal in true power -- an identity linear.modal_metric, or the "
- f"dense metric of a declared unitary change of basis of one -- and this "
- f"problem's is {kind}; a diagonal metric is a power-orthogonal basis "
- "with unequal channel norms, which nothing in this package builds and "
- "no backend has measured"
- )
- raise BackendError(msg)
- defect = float(np.max(metric.error))
- if defect > DEFAULT_GRAM_ATOL:
- # The translation layer gates this at the route's own declared budget; a
- # certificate that reaches a backend ungated is an unbounded silent error,
- # because an identity metric is executed as the identity and nothing
- # downstream reads the error row again.
- msg = (
- f"linear.modal_metric.error is {defect:.4g}, above the "
- f"{DEFAULT_GRAM_ATOL:.4g} {backend} will call the identity: this basis "
- "is that far from orthonormal where it was sampled, and a continuous "
- "wave's da/dz would be wrong by about that much"
- )
- raise BackendError(msg)
- _refuse_general_metric(tables, metric, backend=backend)
- if problem.grid_kind != "field":
- msg = (
- f"{backend} executes a general modal guide on a field grid, whose "
- f"canonical axis is increasing, and this problem is {problem.grid_kind}"
- )
- raise BackendError(msg)
- if tables.synthesis is None or tables.projection is None:
- msg = (
- f"{backend} executes a general modal guide through the synthesis and "
- "projection maps of transform.modal_tables, and this problem carries "
- "neither: a reference sampled over a path of positions has no execution "
- "map"
- )
- raise BackendError(msg)
- if tables.synthesis.shape[0] != 1 or tables.synthesis.shape[1] != 1:
- _refuse_moving_maps(problem, tables, backend=backend, moving_maps=moving_maps)
- weight = problem.linear.power_weight_constant
- if weight is None or not np.all(weight == 1.0):
- msg = (
- f"{backend} executes a general modal guide whose Poynting amplitude is "
- "already the true-power amplitude, so linear.power_weight is the "
- "constant one; this problem's is "
- f"{problem.linear.power_weight_representation}"
- )
- raise BackendError(msg)
- n_pol = problem.shape[1]
- if n_pol != 1:
- # Without this the pass would return a one-wide source and `stage_output`
- # would broadcast it onto a two-wide prefactor: two polarisations driven by
- # one channel's polarisation, which is not this route's physics.
- msg = (
- f"{backend} executes a general modal guide whose {MAX_N_COMPONENTS} "
- "physical components are the reference's own, so the state keeps "
- f"n_pol = 1, and this problem carries {n_pol}; a general guide puts "
- "every physical channel, including a selected parity partner, in "
- "n_modes"
- )
- raise BackendError(msg)
- _refuse_legacy_responses(problem, backend=backend)
- refuse_regional_responses(
- tables, problem.responses.regional_responses, backend=backend
- )
-
-
-def _refuse_moving_maps(
- problem: PropagationProblem,
- tables: ModalTransformTables,
- *,
- backend: str,
- moving_maps: bool,
-) -> None:
- """Refuse a moving guide's maps where they are not executed, or not composed.
-
- A backend that has not built the per-stage combination refuses them by name.
- ``numpy_ref`` and the native backend execute them, and only beside what a moving
- guide is propagated with: a coupled flow carrying the connection, integrated as
- ``"frozen_residual"`` on every section. Moving maps with no connection are a
- guide whose modes change shape and exchange no power, which is the adiabatic
- approximation the route exists to replace; and CF4 beside moving maps composes
- on paper -- its Gauss-node exponentials read ``flow_weights`` and the source
- reads ``transform_weights`` at stage positions -- but is not built or measured
- on this route, so it is refused rather than run.
- """
- assert tables.synthesis is not None
- intervals, degrees = (int(one) for one in tables.synthesis.shape[:2])
- if not moving_maps:
- msg = (
- f"{backend} executes a general modal guide of constant geometry, and "
- f"transform.modal_tables carries moving maps -- {intervals} section(s) "
- f"of degree {degrees - 1} along z, combined per stage over the bundle's "
- "transform_weights (kernel schema 14), which it does not build: a moving "
- "solid guide runs on numpy_ref and native"
- )
- raise BackendError(msg)
- flow = problem.linear.coupled_flow
- if (
- flow is None
- or flow.connection is None
- or set(flow.section_methods) != {"frozen_residual"}
- ):
- described = (
- "no coupled flow"
- if flow is None
- else f"a flow of {sorted(set(flow.section_methods))} sections"
- + ("" if flow.connection is not None else " and no connection")
- )
- msg = (
- f"{backend} executes a moving guide's maps beside the flow that carries "
- f"its connection, 'frozen_residual' on every section, and this problem "
- f"carries {described}: maps that move with no connection are the "
- "adiabatic approximation the moving route replaces, and CF4 beside "
- "moving maps is not built or measured on this route"
- )
- raise BackendError(msg)
-
-
-def _refuse_legacy_responses(problem: PropagationProblem, *, backend: str) -> None:
- """Refuse a legacy response a general modal guide would carry and not propagate."""
- responses = problem.responses
- legacy = [
- name
- for name, present in (
- ("kerr_coefficients", responses.kerr_coefficients is not None),
- # The general route's own dispersive channels name their node regions;
- # a population's leaf has none and would not be read.
- (
- "dispersive_kerr",
- responses.dispersive_kerr is not None
- and responses.dispersive_kerr.regions is None,
- ),
- ("population_channels", bool(responses.population_channels)),
- ("raman_terms", bool(responses.raman_terms)),
- ("envelope_plasma", responses.envelope_plasma is not None),
- ("spectral_responses", bool(responses.spectral_responses)),
- )
- if present
- ]
- if legacy:
- msg = (
- f"{backend} executes transform.modal_tables with its own "
- "responses.regional_responses and no legacy response, and this problem "
- f"also carries responses.{', responses.'.join(legacy)}: the general "
- "route's source replaces the node pipeline that would read them, so they "
- "would be recorded and not propagated"
- )
- raise BackendError(msg)
diff --git a/src/solprop/backends/numpy_ref/backend.py b/src/solprop/backends/numpy_ref/backend.py
index 8f51dc1aa..6b6d1818a 100644
--- a/src/solprop/backends/numpy_ref/backend.py
+++ b/src/solprop/backends/numpy_ref/backend.py
@@ -35,11 +35,12 @@
from solprop.backends.host.driver import HostDriver
from solprop.backends.host.low_storage import HostLowStorageMarch
from solprop.backends.memory import adams_memory, reference_base_memory
-from solprop.backends.node_chunk import NodeChunkPlan, plan_node_chunk
-from solprop.backends.numpy_ref import passes, transforms
-from solprop.backends.numpy_ref.admission import (
- refuse_general_route,
+from solprop.backends.node_chunk import (
+ NodeChunkPlan,
+ general_planned_chunk,
+ plan_node_chunk,
)
+from solprop.backends.numpy_ref import passes
from solprop.backends.numpy_ref.envelope_plasma import (
envelope_populations,
envelope_sources,
@@ -47,11 +48,7 @@
from solprop.backends.numpy_ref.general_modal import (
GeneralMaps,
general_memory,
- general_planned_chunk,
general_source,
- node_delayed_maps,
- node_kerr_form,
- node_kerr_tensor,
)
from solprop.backends.numpy_ref.tdse import (
atomic_edge_source,
@@ -97,15 +94,17 @@
from solprop.stepper.low_storage import LowStorageRungeKutta
from solprop.stepper.multistep import Multistep
from solprop.translate.coupled import (
- CF4_NODES,
- CF4_WEIGHTS,
- CoupledFlowTables,
- flow_weights,
- frozen_generator,
+ CoupledFlowFactors,
+ coupled_flow_factors,
metric_at,
- schur_blocks,
+)
+from solprop.translate.general import (
+ node_delayed_maps,
+ node_kerr_form,
+ node_kerr_tensor,
)
from solprop.translate.linop import power_weight_at
+from solprop.translate.modal import QUADRATURE_ALARM, warn_quadrature
from solprop.translate.plasma_source import _BENCHMARK_GATED
from solprop.translate.problem import (
MAX_N_POL,
@@ -132,22 +131,10 @@
steady_current_certificate,
)
-__all__ = ["QUADRATURE_ALARM", "BlockResidual", "NumpyRefBackend", "SourceTables"]
+__all__ = ["BlockResidual", "NumpyRefBackend", "SourceTables"]
logger = logging.getLogger(__name__)
-QUADRATURE_ALARM = 1e-6
-"""Relative quadrature error above which the alarm is logged, once per plane.
-
-The fixed rule integrates the Kerr quartic of a mode set to the reference's own
-rounding -- 2e-14 at ``n_r = 32`` for four HE_1m modes
-(``docs/guide/waveguides/mode-sets.md``) -- so
-anything a millionth of the polarisation is not the rule converging slowly but the
-integrand being unresolved, which no step size can fix. It also sits at the loose end
-of the tolerances a run asks the controller for, so an alarm means the quadrature has
-become the largest error in the step. The number is a judgement, not a measurement.
-"""
-
class NumpyRefBackend(HostDriver[Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]]):
"""Propagate one Kerr and plasma problem with interaction-picture DOPRI5.
@@ -276,8 +263,6 @@ def __init__(
tuple[np.ndarray, np.ndarray, np.ndarray | None, np.ndarray | None] | None
) = None
self._kind: GridKind = problem.grid_kind
- # The tables a source evaluation reads, as the problem holds them; the
- # single-precision path narrows them on upload (SEP 33).
self._tables = SourceTables.of(problem)
self._carrier = _harmonic_carrier(problem)
self._embedded = _embedded_projection(problem)
@@ -289,7 +274,7 @@ def __init__(
self._block_residual: BlockResidual | None = None
self._flow_cache: (
tuple[
- tuple[int, float, float | None],
+ tuple[int, float],
dict[float, tuple[np.ndarray | None, np.ndarray | None]],
]
| None
@@ -349,16 +334,6 @@ def __init__(
"replayed" if self._stepper.schedule is not None else "adaptive",
)
- def _propagator(
- self, exponent: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]
- ) -> Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]:
- """Return the stage propagator ``exp(dPhi)`` of a binary64 exponent.
-
- The single-precision path rounds the binary64 exponential once (SEP 33
- section 4.2); here it is the exponential itself.
- """
- return np.exp(exponent)
-
def _envelope_sources(
self, field_t: Complex[np.ndarray, "n_nodes n_pol n_to"], bundle: StageScalars
) -> tuple[
@@ -409,15 +384,6 @@ def _error_norm(
self._stepper.controller,
)
- def _rounded(
- self, values: Inexact[np.ndarray, "*shape"]
- ) -> Inexact[np.ndarray, "*shape"]:
- """Return a binary64 array a pass multiplies by, in the run's precision.
-
- The identity here; the single-precision path rounds it once (SEP 33).
- """
- return values
-
def stats(self, spec: StatsSpec) -> dict[str, np.ndarray]:
"""Collect the requested statistics at the current position.
@@ -703,7 +669,7 @@ def _species_statistics(
if channels:
if transform.geometry is not None and transform.geometry.kind == "free":
nx, ny = transform.geometry.shape
- physical = transforms.ifft2(
+ physical = np.fft.ifft2(
field.reshape(ny, nx, *field.shape[1:]), axes=(0, 1)
).reshape(field.shape)
nodes = passes.to_time(
@@ -804,7 +770,7 @@ def _plasma_nodes(
physical = (
slab_synthesis(self._problem.transform, field) / grids.pad_scale
if geometry.kind == "slab"
- else transforms.ifft2(
+ else np.fft.ifft2(
field.reshape(ny, nx, *field.shape[1:]), axes=(0, 1)
).reshape(field.shape)
)
@@ -852,7 +818,7 @@ def _ionisation(
slab_synthesis(self._problem.transform, field)
/ self._problem.grids.pad_scale
if geometry.kind == "slab"
- else transforms.ifft2(
+ else np.fft.ifft2(
field.reshape(ny, nx, *field.shape[1:]), axes=(0, 1)
).reshape(field.shape)
)
@@ -1180,7 +1146,7 @@ def _adams_source(
bundle, propagator = held[2], held[3]
else:
bundle = self._stage_scalars(anchor, position)
- propagator = self._propagator(self._delta_phi(bundle))
+ propagator = np.exp(self._delta_phi(bundle))
self._adams_held = None
noise = self._noise_at(bundle)
field_in = lab if noise is None else lab + noise
@@ -1203,10 +1169,13 @@ def _adams_block(self, z: float, dz: float) -> object:
- ``z``: where the step starts, m.
- ``dz``: the step, m.
- - Returns: the step's :class:`~solprop.backends.multistep.CoupledBlock`, or
- ``None`` without a coupled flow.
+ - Returns: the step's
+ :class:`~solprop.backends.numpy_ref.multistep.CoupledBlock`, or ``None``
+ without a coupled flow.
"""
- from solprop.backends.multistep import CoupledBlock # noqa: PLC0415 - cycle
+ from solprop.backends.numpy_ref.multistep import ( # noqa: PLC0415 - cycle
+ CoupledBlock,
+ )
flow = self._flow
if flow is None:
@@ -1215,7 +1184,7 @@ def _adams_block(self, z: float, dz: float) -> object:
section = bundle.flow_section
assert section is not None
residual = None
- if flow.residual_terms is not None and not flow.cf4[section]:
+ if flow.residual_terms is not None:
if self._block_residual is None:
self._block_residual = BlockResidual(flow)
residual = functools.partial(self._block_residual, section)
@@ -1265,7 +1234,7 @@ def _adams_propagator(
- ``anchor``, ``position``: the step's anchor and its end, m.
"""
bundle = self._stage_scalars(anchor, position)
- propagator = self._propagator(self._delta_phi(bundle))
+ propagator = np.exp(self._delta_phi(bundle))
if self._adams is not None:
self._adams_held = (anchor, position, bundle, propagator)
return propagator
@@ -1299,12 +1268,6 @@ def _trial_step(
second-order approximation: whatever block the exponential carries, the
residual is the rest of the generator.
- A step anchored in a ``"cf4"`` section carries **no** residual: its flow
- actions are the commutator-free exponentials of the whole generator over each
- stage's own length (:func:`_cf4_action`), so the source is the material
- response alone and the flow's own error is outside what the embedded pair
- measures: a known limitation of the controller on a CF4 section.
-
- ``z``: position at the start of the step, m.
- ``dz``: length of the trial step, m, positive.
- Returns: the scaled error norm and the state at the end of the step.
@@ -1318,7 +1281,7 @@ def _trial_step(
)
exponents = self._exponents(z, dz)
step = self._stage_scalars(z, z + dz)
- actions = self._flow_actions(z, step.flow_section, dz)
+ actions = self._flow_actions(step.flow_section, dz)
stages: list[Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]] = []
solution = np.zeros_like(state)
error = np.zeros_like(state)
@@ -1338,10 +1301,10 @@ def _trial_step(
self._channels(state, forward),
self._channels(combination, forward),
dz,
- self._propagator(exponent),
+ np.exp(exponent),
self._noise_at(bundle),
)
- back = self._propagator(-exponent)
+ back = np.exp(-exponent)
self._rk_source_evaluations += 1
drive = self._nonlinear(lab, bundle, back)
residual = self._residual(lab, step.flow_section, bundle)
@@ -1355,7 +1318,7 @@ def _trial_step(
error = passes.accumulate(error, stage, float(tableau.error_weights[index]))
# Kept for the continuous extension of an accepted step (SEP 14 phase 3).
self._last_stages = stages
- propagator = self._propagator(exponents(1.0, step))
+ propagator = np.exp(exponents(1.0, step))
whole = actions(1.0)[0]
advanced = passes.accept_step(
self._channels(state, whole),
@@ -1396,14 +1359,12 @@ def _residual(
``None`` for every problem but a ``"frozen_residual"`` coupled flow: the
constant section has an identically zero residual and is not
- multiplied by one, so its arithmetic is bit for bit what it was; a step
- anchored in a ``"cf4"`` section has none either, its whole generator being in
- the exponential.
+ multiplied by one, so its arithmetic is bit for bit what it was.
- Returns: ``R_s(z_1) u`` in the state's layout, or ``None``.
"""
flow = self._flow
- if flow is None or flow.residual_terms is None or flow.cf4[section]:
+ if flow is None or flow.residual_terms is None:
return None
assert bundle.flow_weights is not None
return _residual_drive(
@@ -1414,7 +1375,7 @@ def _residual(
)
def _flow_actions(
- self, z: float, section: int, dz: float
+ self, section: int, dz: float
) -> Callable[
[float],
tuple[
@@ -1442,15 +1403,6 @@ def _flow_actions(
the flow (``docs/dev/stepper.md`` section 2). The conditioning of a held
length was measured when it was built.
- A step anchored in a ``"cf4"`` section is not frozen: its actions are the
- commutator-free exponentials of the generator at Gauss nodes **inside** each
- stage's own interval, so they depend on where the step starts as well as on
- its length, and the key carries `z`. Within one step the six fractions are
- still computed once each; between steps nothing is reused. Every one of them
- is checked against the section's declared pair bound, factor by factor
- (:func:`_check_cf4_growth`).
-
- - ``z``: where the step starts, m.
- ``dz``: length of the trial step, m.
- Returns: ``fraction -> (U, U^-1)``, each ``None`` when no coupled flow is
carried.
@@ -1459,8 +1411,7 @@ def _flow_actions(
if flow is None:
return lambda fraction: (None, None) # noqa: ARG005
held = self._flow_cache
- cf4 = bool(flow.cf4[section])
- key = (section, dz, float(z) if cf4 else None)
+ key = (section, dz)
cache: dict[float, tuple[np.ndarray | None, np.ndarray | None]] = (
held[1] if held is not None and held[0] == key else {}
)
@@ -1478,17 +1429,10 @@ def action(
if fraction == 0.0:
return None, None
if fraction not in cache:
- if cf4:
- forward, inverse, growth = _cf4_action(
- flow, float(z), fraction * dz, section
- )
- _check_cf4_growth(flow, section, growth, fraction * dz)
- cache[fraction] = (forward, inverse)
- else:
- pair = _flow_action(flow, section, fraction * dz)
- if fraction == 1.0:
- _check_flow_conditioning(flow, section, pair, dz)
- cache[fraction] = pair
+ pair = _flow_action(flow, section, fraction * dz)
+ if fraction == 1.0:
+ _check_flow_conditioning(flow, section, pair, dz)
+ cache[fraction] = pair
return cache[fraction]
return action
@@ -1548,9 +1492,8 @@ def _finish(
return masked
if (monitor := self._split_monitor) is not None:
# The time factor acts alike on every column of any transverse basis, so
- # the stored state's power is what the split takes of, wherever it acts;
- # read as the physical state, which a single-precision state is scaled
- # from. What it finds is what the occupancies are judged on.
+ # the stored state's power is what the split takes of, wherever it acts.
+ # What it finds is what the occupancies are judged on.
sums = monitor.split_sums(self._observed_field(masked), dz)
self._split_removed += float(sums[0])
self._split_found = sums[1:]
@@ -1570,7 +1513,7 @@ def _finish(
if geometry is not None and geometry.kind == "free":
nx, ny = geometry.shape
shape = (ny, nx, *masked.shape[2:])
- physical = transforms.ifft2(masked[0].reshape(shape), axes=(0, 1)).reshape(
+ physical = np.fft.ifft2(masked[0].reshape(shape), axes=(0, 1)).reshape(
masked.shape[1:]
)
chunk = self.node_chunk
@@ -1579,7 +1522,7 @@ def _finish(
physical[rows] = passes.apply_temporal_rate(
physical[rows][None], rate, dz, kind=self._kind
)[0]
- result = transforms.fft2(physical.reshape(shape), axes=(0, 1)).reshape(
+ result = np.fft.fft2(physical.reshape(shape), axes=(0, 1)).reshape(
masked.shape
)
return self._apply_mask(result)
@@ -1651,15 +1594,7 @@ def _update_alarm(self) -> None:
)
if self._alarm > QUADRATURE_ALARM and not self._alarm_reported:
self._alarm_reported = True
- logger.warning(
- "the transverse quadrature reproduces the nonlinear polarisation to "
- "only %.2e at z = %.6g m, above the %.0e alarm; the integrand is not "
- "resolved by this rule (a kinked ionisation rate, or too few nodes for "
- "the mode set) and no step size can fix it",
- self._alarm,
- self._last_bundle.z1,
- QUADRATURE_ALARM,
- )
+ warn_quadrature(self._alarm, self._last_bundle.z1)
def _nonlinear(
self,
@@ -1732,10 +1667,10 @@ def _nonlinear(
assert self._last_envelope is not None
drive, loss, _, _ = self._last_envelope
factors = responses.envelope_plasma.factors
- spectrum += self._rounded(factors.drude) * passes.to_frequency(
+ spectrum += factors.drude * passes.to_frequency(
drive, grids.crop_index, kind=self._kind
)
- spectrum += self._rounded(factors.loss) * passes.to_frequency(
+ spectrum += factors.loss * passes.to_frequency(
loss, grids.crop_index, kind=self._kind
)
return passes.stage_output(
@@ -1911,8 +1846,7 @@ def _radial_plasma_spectrum(
"""Transform three nodal streams, combine, then project once in frequency."""
problem = self._problem
transform, grids = problem.transform, problem.grids
- # The field's own precision: complex128, or complex64 under SEP 33.
- spectrum = np.zeros(problem.shape, dtype=np.result_type(modal_t, np.complex64))
+ spectrum = np.zeros(problem.shape, dtype=np.complex128)
raman = self._raman_spectrum(bundle.raman_rates, bundle.raman_densities)
for first in range(0, transform.n_nodes, self.node_chunk):
rows = slice(
@@ -1965,8 +1899,8 @@ def _nonlinear_free(
return result
scratch = np.empty_like(lab[0])
if len(responses.response_order) > 1:
- transforms.ifft2(
- lab[0].reshape(shape), axes=(0, 1), out=scratch.reshape(shape)
+ scratch.reshape(shape)[...] = np.fft.ifft2(
+ lab[0].reshape(shape), axes=(0, 1)
)
raman = self._raman_spectrum(bundle.raman_rates, bundle.raman_densities)
for first in range(0, transform.n_nodes, self.node_chunk):
@@ -1984,33 +1918,31 @@ def _nonlinear_free(
if responses.envelope_plasma is not None:
drive, loss = self._envelope_sources(field_t, bundle)
factors = responses.envelope_plasma.factors
- scratch[rows] += self._rounded(factors.drude) * passes.to_frequency(
+ scratch[rows] += factors.drude * passes.to_frequency(
drive, grids.crop_index, kind=self._kind
)
- scratch[rows] += self._rounded(factors.loss) * passes.to_frequency(
+ scratch[rows] += factors.loss * passes.to_frequency(
loss, grids.crop_index, kind=self._kind
)
view = scratch.reshape(shape)
- transforms.fft2(view, axes=(0, 1), out=view)
+ view[...] = np.fft.fft2(view, axes=(0, 1))
data = problem.linear.separable
assert data is not None
for first in range(0, transform.n_nodes, self.node_chunk):
rows = slice(first, first + self.node_chunk)
_, prefactor = separable_values(data, rows)
- result[0, rows] = (
- self._rounded(prefactor) * self._tables.omega_window * scratch[rows]
- )
+ result[0, rows] = prefactor * self._tables.omega_window * scratch[rows]
if self._in_stage_absorber:
# The rate is additive to the normalised material term. Reusing the
# transverse scratch avoids a second retained physical spectral volume.
- transforms.ifft2(
- lab[0].reshape(shape), axes=(0, 1), out=scratch.reshape(shape)
+ scratch.reshape(shape)[...] = np.fft.ifft2(
+ lab[0].reshape(shape), axes=(0, 1)
)
temporal = self._tables.temporal_rate
timed = temporal is not None and responses.absorber_scheme == "nonlinear"
for first in range(0, transform.n_nodes, self.node_chunk):
rows = slice(first, first + self.node_chunk)
- rate = self._rounded(geometry.absorber_rate[rows, None, None])
+ rate = geometry.absorber_rate[rows, None, None]
if not timed:
# A rate of the node alone commutes with the temporal transforms:
# a spatial-only pass needs no temporal pair (SEP 40 section 4.7).
@@ -2025,7 +1957,7 @@ def _nonlinear_free(
kind=self._kind,
)
view = scratch.reshape(shape)
- transforms.fft2(view, axes=(0, 1), out=view)
+ view[...] = np.fft.fft2(view, axes=(0, 1))
result[0] += grids.active_mask * scratch
return result * inverse_propagator
@@ -2413,8 +2345,7 @@ def exponent(
class SourceTables:
"""The problem's tables a source evaluation multiplies by.
- Read through this record rather than off the problem so that the single-precision
- path can hold them narrowed (SEP 33 section 4.2) while the passes stay the same.
+ The arrays the passes read, gathered once from the problem.
Attributes
----------
@@ -2515,19 +2446,6 @@ def _refuse_unsupported(problem: PropagationProblem, stepper: StepperSettings) -
msg = f"numpy_ref does not execute the {route_name(route)} route"
raise BackendError(msg)
_refuse_adams(problem, stepper)
- # One list for every backend: the coupled flow first, then the
- # general modal route, each refusal naming this backend.
- refuse_general_route(problem, backend="numpy_ref", moving_maps=True)
- if (
- problem.flags.has_spectral_responses
- and problem.responses.envelope_plasma is None
- ):
- msg = (
- "numpy_ref executes the Kerr, Raman and plasma terms and this problem "
- "declares spectral responses; a term silently dropped would run, converge "
- "and be wrong"
- )
- raise BackendError(msg)
if (
problem.flags.has_raman
and problem.transform.n_pol * problem.shape[1] > 1
@@ -2664,15 +2582,13 @@ def _general_maps(
@dataclass(frozen=True)
-class _CoupledFlow:
+class _CoupledFlow(CoupledFlowFactors):
"""What ``numpy_ref`` holds to execute one coherent block, frozen per section.
- The Schur factors are the backend's own: they are computed here from the leaf's
- generator rather than carried across the seam, because a decomposition is not a
- contract row and no schema has one for it. The
- **section** axis leads them and the frequency axis follows, and only the bins the
- generator is non-zero at are held: the rest of the band has an identity action and
- the state is masked to zero there anyway.
+ The shared factors (:class:`~solprop.translate.coupled.CoupledFlowFactors`, which
+ native reads too), with the section axis leading and only the bins the generator
+ is non-zero at held: the rest of the band has an identity action and the state is
+ masked to zero there anyway.
A ``"constant_schur"`` leaf is the one-section case with no residual at all, and
goes through exactly the constant-section arithmetic. A ``"frozen_residual"`` leaf
@@ -2687,49 +2603,17 @@ class _CoupledFlow:
Attributes
----------
- active : numpy.ndarray
- Which bins carry a block, bool, shape ``(n_omega,)``.
residual_bins : numpy.ndarray
Which bins the explicit residual is applied at, bool, shape ``(n_omega,)``:
`active` and the problem's ``grids.inband_mask``, the requested band: in the
collar beyond it the wall model is outside its validity, and the residual is
left out whether or not the error norm scores there.
- vectors, triangular : numpy.ndarray
- ``Z`` and ``T`` of ``G_s = Z T Z^H`` per section, shape
- ``(n_sections, n_active, n_modes, n_modes)``.
- frozen : numpy.ndarray
- The frozen generator of each section in 1/m, same layout.
- condition_bound : numpy.ndarray
- The leaf's declared ``norm(U) norm(U^-1)`` per section and active bin,
- dimensionless, shape ``(n_sections, n_active)``.
- residual_terms : numpy.ndarray or None
- The coefficients of ``G(z) - C(z)`` in 1/m, shape
- ``(n_intervals * n_deg, n_active, n_modes, n_modes)``; ``None`` for a leaf with
- no explicit residual. A ``"cf4"`` section exponentiates the same combination
- at its Gauss nodes.
n_modes : int
Channels the block couples.
- cf4 : numpy.ndarray
- Which sections are ``"cf4"``, bool, shape ``(n_sections,)``.
- leaf : solprop.translate.coupled.CoupledFlowTables
- The leaf itself, whose edges and degree axis a CF4 node's weights are built
- on (:func:`solprop.translate.coupled.flow_weights`).
"""
- active: Bool[np.ndarray, " n_omega_flow"]
residual_bins: Bool[np.ndarray, " n_omega_scored"]
- vectors: Complex[np.ndarray, "n_sections_v n_active_v n_modes_v_out n_modes_v_in"]
- triangular: Complex[
- np.ndarray, "n_sections_t n_active_t n_modes_t_out n_modes_t_in"
- ]
- frozen: Complex[np.ndarray, "n_sections_f n_active_f n_modes_f_out n_modes_f_in"]
- condition_bound: Float[np.ndarray, "n_sections_c n_active_c"]
- residual_terms: (
- Complex[np.ndarray, "n_terms_r n_active_r n_modes_r_out n_modes_r_in"] | None
- )
n_modes: int
- cf4: Bool[np.ndarray, " n_sections_cf4"]
- leaf: CoupledFlowTables
_TAYLOR_ORDER = 16
@@ -2822,95 +2706,6 @@ def _flow_action(
return forward, inverse
-def _cf4_action(
- flow: _CoupledFlow, z: float, length: float, section: int
-) -> tuple[
- Complex[np.ndarray, "n_active_c4f n_modes_c4f_out n_modes_c4f_in"],
- Complex[np.ndarray, "n_active_c4i n_modes_c4i_out n_modes_c4i_in"],
- Float[np.ndarray, " n_active_c4g"],
-]:
- """Return the CF4 forward and inverse actions over ``[z, z + length]``.
-
- ``V = exp(B_2) exp(B_1)`` with ``B_1 = l (b A_1 + a A_2)`` acting first and
- ``B_2 = l (a A_1 + b A_2)`` second, ``A_j`` the whole generator ``G - C`` at the
- Gauss nodes ``z + c_j l`` (:data:`solprop.translate.coupled.CF4_NODES`, ``a, b``
- :data:`solprop.translate.coupled.CF4_WEIGHTS`). The inverse is
- ``exp(-B_1) exp(-B_2)``: the exact inverse of the approximation, formed from the
- negated exponents and never by a solve, so the interaction picture's forward and
- backward actions are inverse to rounding and a zero source reproduces ``V``
- exactly.
-
- The generator at a node is the stage bundle's own evaluation form, applied at a
- position that is not a stage: ``flow_weights`` of the node, resolved against the
- step's section, combined over the held terms. Every node lies strictly inside the
- step, so no edge rule is ever exercised by it.
-
- - ``z``: where the step starts, m.
- - ``length``: the stage's own length ``c_i h``, m, positive.
- - Returns: the two actions, shape ``(n_active, n_modes, n_modes)``, and the product
- of the four factors' spectral norms per active bin -- the quantity the CF4
- factor-norm guard is declared on.
- """
- assert flow.residual_terms is not None
- early, late = (
- combine(
- flow_weights(flow.leaf, float(z) + node * float(length), section),
- flow.residual_terms,
- )
- for node in CF4_NODES
- )
- small, large = CF4_WEIGHTS
- first = float(length) * (large * early + small * late)
- second = float(length) * (small * early + large * late)
- factors = (
- _triangular_exponential(first),
- _triangular_exponential(second),
- _triangular_exponential(-first),
- _triangular_exponential(-second),
- )
- growth = np.prod(
- [np.linalg.norm(factor, ord=2, axis=(-2, -1)) for factor in factors], axis=0
- )
- return factors[1] @ factors[0], factors[2] @ factors[3], growth
-
-
-def _check_cf4_growth(
- flow: _CoupledFlow,
- section: int,
- growth: Float[np.ndarray, " n_active_g"],
- length: float,
-) -> None:
- """Refuse a CF4 action whose internal factors exceed the declared pair bound.
-
- The CF4 factor-norm guard at the executor. The leaf declares, for a ``"cf4"``
- section, ``exp((2/sqrt(3)) dz_max (m_hi - m_lo))``: a bound on the product of the
- four factor norms of any forward and inverse pair a step anchored there can form
- (:func:`solprop.translate.coupled.cf4_flow`). Here the four norms are **measured**,
- from the factors actually formed, and compared with it, so a certificate declared
- over too short a step or a generator it was not built from is caught per step rather
- than trusted. It bounds the forward product's excess over the exact action and the
- inverse action the source is carried back by, together.
-
- - ``length``: the stage length it was formed over, m, used in the refusal.
- """
- declared = flow.condition_bound[section]
- # The same allowance as the frozen check: the bound is on the exact factors, and
- # what exceeds it by less than the rounding of four spectral norms is arithmetic.
- allowance = 1.0 + 64.0 * flow.n_modes * float(np.finfo(float).eps)
- excess = growth / (declared * allowance)
- if np.any(excess > 1.0):
- worst = int(np.argmax(excess))
- msg = (
- f"numpy_ref measured a CF4 factor-norm product of "
- f"{float(growth[worst]):.12g} over a stage of {length:.4g} m, above the "
- f"{float(declared[worst]):.12g} the certificate declares at active bin "
- f"{worst} of section {section}: the negative CF4 weight lets a factor of a "
- "dissipative flow grow beyond the exact action. Lower "
- "dz_max, which is what the bound was declared over"
- )
- raise BackendError(msg)
-
-
def _residual_action(
flow: _CoupledFlow, section: int, weights: Float[np.ndarray, " n_flow_terms"]
) -> Complex[np.ndarray, "n_active_r n_modes_r_out n_modes_r_in"]:
@@ -3130,36 +2925,13 @@ def _coupled_flow_maps(problem: PropagationProblem) -> _CoupledFlow | None:
if flow is None:
return None
assert flow.generator is not None
+ factors = coupled_flow_factors(flow)
+ active, residual = factors.active, factors.residual_terms
sections = len(flow.section_methods)
- active = np.any(flow.generator != 0.0, axis=(0, 1, 2, 3))
- frozen = np.stack(
- [
- np.moveaxis(frozen_generator(flow, index)[:, :, active], -1, 0)
- for index in range(sections)
- ]
- )
- factors = [schur_blocks(np.moveaxis(block, 0, -1)) for block in frozen]
- residual = None
- if flow.section_methods != ("constant_schur",):
- terms = flow.generator[:, :, :, :, active]
- if flow.connection is not None:
- terms = terms - flow.connection[:, :, :, :, active]
- residual = np.ascontiguousarray(
- np.moveaxis(terms, (2, 3), (-2, -1)).reshape(
- sections * flow.n_deg, -1, flow.n_modes, flow.n_modes
- )
- )
held = _CoupledFlow(
- active=active,
+ **vars(factors),
residual_bins=active & problem.grids.inband_mask,
- vectors=np.stack([pair[0] for pair in factors]),
- triangular=np.stack([pair[1] for pair in factors]),
- frozen=frozen,
- condition_bound=np.ascontiguousarray(flow.inverse_condition_bound[:, active]),
- residual_terms=residual,
n_modes=flow.n_modes,
- cf4=np.array([method == "cf4" for method in flow.section_methods]),
- leaf=flow,
)
logger.debug(
"coupled flow: %d channel(s) on %d of %d bins over %d section(s), %s, largest "
diff --git a/src/solprop/backends/numpy_ref/batch.py b/src/solprop/backends/numpy_ref/batch.py
index 2ff88ba61..1835a7bfd 100644
--- a/src/solprop/backends/numpy_ref/batch.py
+++ b/src/solprop/backends/numpy_ref/batch.py
@@ -21,10 +21,9 @@
def lockstep_runner(owners: Sequence[Backend]) -> BatchBackend:
- """Build the reference's lockstep runner for loaded members (the registry's row).
+ """Build the reference's lockstep runner for loaded members.
- :func:`solprop.backends.registry.select_batch` reads it from the reference's
- registry row.
+ What :func:`solprop.backends.registry.select_batch` returns for the reference.
Parameters
----------
@@ -35,17 +34,8 @@ def lockstep_runner(owners: Sequence[Backend]) -> BatchBackend:
-------
solprop.backends.protocol.BatchBackend
:class:`NumpyBatchBackend`, or :class:`~solprop.backends.host.group.HostGroup`
- when a member is single precision or carries a coupled flow.
+ when a member carries a coupled flow.
"""
- from solprop.backends.numpy_ref.single import ( # noqa: PLC0415 - lazy precision
- NumpySingleBackend,
- )
-
- if any(isinstance(owner, NumpySingleBackend) for owner in owners):
- # NumpyBatchBackend's stage arithmetic over the member axis is binary64; the
- # single-precision path's members each take their own binary32 trial. The
- # group's member validation refuses mixed precisions.
- return HostGroup(cast("Sequence[HostDriver[object]]", owners))
hosts = cast("Sequence[HostDriver[object]]", owners)
if any(host._problem.linear.coupled_flow is not None for host in hosts):
# NumpyBatchBackend's stage arithmetic over the member axis applies neither a
@@ -72,8 +62,7 @@ class NumpyBatchBackend(HostGroup):
Raises
------
BackendError
- If the fixed-schedule contract, backend or tableau differs between members,
- or a member is single precision.
+ If the fixed-schedule contract, backend or tableau differs between members.
"""
helpers = PLAIN
@@ -100,18 +89,6 @@ def __init__(self, members: Sequence[NumpyRefBackend]) -> None:
"on its own"
)
raise BackendError(msg)
- # Its stage arithmetic over the member axis is binary64: binary64
- # propagators and the plain error norm. Single-precision members each
- # take their own binary32 trial through `HostGroup`, which
- # `lockstep_runner` builds for them.
- if member._problem.precision.single:
- msg = (
- "the numpy_ref lockstep batch evaluates its stage arithmetic in "
- "binary64 and refuses a single-precision member; batch "
- "single-precision members with HostGroup, as "
- "registry.select_batch does"
- )
- raise BackendError(msg)
super().__init__([cast(HostDriver[object], member) for member in members])
def _trial_step(self, z: float, dz: float) -> tuple[float, tuple[object, ...]]:
diff --git a/src/solprop/backends/numpy_ref/general_modal.py b/src/solprop/backends/numpy_ref/general_modal.py
index 3f3219712..372a4ff4d 100644
--- a/src/solprop/backends/numpy_ref/general_modal.py
+++ b/src/solprop/backends/numpy_ref/general_modal.py
@@ -80,45 +80,28 @@
import logging
from collections.abc import Sequence
from dataclasses import dataclass, replace
-from typing import cast
import numpy as np
from jaxtyping import Complex, Float, Int32, Num
from solprop import fourier
-from solprop.backends.node_chunk import NodeChunkPlan
from solprop.constants import EPS0
from solprop.exceptions import BackendError
from solprop.nonlinear.responses import dispersive_cubic
from solprop.translate.modal_tables import ModalTransformTables
-from solprop.translate.problem import PropagationProblem
from solprop.translate.responses import (
- DELAYED_MODEL_ID,
- KERR_MODEL_FORMS,
- MODEL_ID_SEPARATOR,
DispersiveKerrTerms,
KerrForm,
- RegionalResponseTables,
)
__all__ = [
- "BUFFERS_PER_GENERAL_NODE",
- "DELAYED_TERMS",
- "PAIR_BUFFERS_PER_GENERAL_NODE",
"PAIR_HISTORIES",
"GeneralMaps",
"delayed_polarisation",
"dispersive_polarisation",
"expand_pair_history",
- "general_dispersive_buffers",
"general_memory",
- "general_node_chunk",
- "general_planned_chunk",
"general_source",
- "node_delayed_maps",
- "node_kerr_form",
- "node_kerr_tensor",
- "refuse_regional_responses",
"symmetric_cubic",
"symmetric_kerr_coefficients",
"symmetric_monomials",
@@ -126,9 +109,6 @@
logger = logging.getLogger(__name__)
-DELAYED_TERMS = 2
-"""Delayed channels the silica tensor Raman model carries: trace and rank two."""
-
PAIR_HISTORIES = 6
"""Independent entries of the real symmetric pair history of three components.
@@ -140,29 +120,6 @@
_UPPER = ((0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2))
"""The six stored pair entries, in order."""
-BUFFERS_PER_GENERAL_NODE = 4
-"""Chunk buffers the general source holds per node, each ``n_components`` wide.
-
-The synthesised fine spectrum, the analytic time field, the polarisation in time and
-its one-sided spectrum.
-"""
-
-PAIR_BUFFERS_PER_GENERAL_NODE = 2
-"""Chunk buffers the general source holds per node, each ``n_components`` **squared**.
-
-The tensor contraction is written in two steps rather than as one four-operand
-``einsum``, so that every intermediate it allocates is a buffer this module names:
-the pair history ``E_k conj(E_l)`` and the tensor contracted against it. A single
-``einsum`` with ``optimize=True`` allocates the same intermediates and names none of
-them, which a memory ledger cannot report.
-
-The static maps ``S`` and ``Q``, the per-node tensor, the dense E/H reference, its
-reciprocal partners and the coefficient dual are whole-problem allocations, counted
-row by row by :func:`general_memory`; they do not shrink with the chunk, which is why
-the counts here and :data:`solprop.backends.node_chunk.BUFFERS_PER_NODE` are different
-and neither reuses the other.
-"""
-
@dataclass(frozen=True, eq=False)
class GeneralMaps:
@@ -205,8 +162,8 @@ class GeneralMaps:
Which field the tensor is contracted against: the analytic signal, whose
polarisation carries the fundamental band alone, or the real
three-component field, whose cubic carries the third harmonic. Read from
- the record's ``model_id`` by :func:`node_kerr_form`; `tensor` already
- carries the normalisation that goes with it.
+ the record's ``model_id`` by :func:`solprop.translate.general.node_kerr_form`;
+ `tensor` already carries the normalisation that goes with it.
dispersive : solprop.translate.responses.DispersiveKerrTerms or None
The gas regions' dispersive channels (SEP 6), each acting at the nodes of
its own region, or ``None``.
@@ -334,179 +291,6 @@ def n_modes(self) -> int: # numpydoc ignore=RT01
return int(self.synthesis.shape[2])
-def refuse_regional_responses(
- tables: ModalTransformTables,
- regional: RegionalResponseTables | None,
- *,
- backend: str = "numpy_ref",
-) -> None:
- """Refuse a per-region response this route does not execute, before it allocates.
-
- Called from the backend's admission list, where it runs before the driver's
- buffers exist, and again from :func:`node_kerr_tensor`, which is public and must
- not build a tensor from a record it cannot read. Neither call allocates.
-
- A component count that disagrees with the reference is the dangerous one: a
- one-component tensor **broadcasts** into the three-component contraction and
- propagates a meaningless polarisation, and a two-component one dies inside
- ``einsum`` with a shape message that names no leaf.
-
- Parameters
- ----------
- tables : solprop.translate.modal_tables.ModalTransformTables
- The guide's reference, which fixes the component count.
- regional : solprop.translate.responses.RegionalResponseTables or None
- The compiled per-region responses, or ``None`` for a linear guide.
- backend : str, optional
- The registry name each refusal names.
-
- Raises
- ------
- BackendError
- Naming the leaf and what about it is not executed.
- """
- if regional is None:
- return
- components = int(tables.electric.shape[2])
- if regional.n_components != components:
- msg = (
- f"responses.regional_responses contracts over {regional.n_components} "
- f"Cartesian component(s) and transform.modal_tables resolves "
- f"{components}; {backend} executes the general modal route over the "
- "reference's own components, and a narrower tensor would broadcast into "
- "a polarisation that means nothing"
- )
- raise BackendError(msg)
- if regional.chi3.shape[0] != 1 or regional.chi3.shape[1] != 1:
- msg = (
- f"{backend} executes a general modal guide of constant geometry, and "
- f"responses.regional_responses.chi3 carries {regional.chi3.shape[0]} "
- f"interval(s) of degree {regional.chi3.shape[1] - 1} along z; a varying "
- "susceptibility is not supported on this route"
- )
- raise BackendError(msg)
- tags = regional.model_id.split(MODEL_ID_SEPARATOR)
- unknown = [one for one in tags[1:] if one != DELAYED_MODEL_ID]
- if tags[0] not in KERR_MODEL_FORMS or unknown:
- msg = (
- f"responses.regional_responses declares model_id "
- f"{regional.model_id!r} and {backend} executes the instantaneous "
- f"isotropic cubic in its two declared forms, "
- f"{sorted(KERR_MODEL_FORMS)}, optionally beside "
- f"{DELAYED_MODEL_ID!r}; a model this package does not know must "
- "not be evaluated as one it does"
- )
- raise BackendError(msg)
- if (DELAYED_MODEL_ID in tags) != bool(regional.n_terms):
- msg = (
- f"responses.regional_responses declares model_id "
- f"{regional.model_id!r} and {regional.n_terms} delayed term(s): a "
- "record's model identity and its term axis must agree, or the run "
- "would execute a model its own file does not name"
- )
- raise BackendError(msg)
- if regional.n_terms and regional.n_terms != DELAYED_TERMS:
- msg = (
- f"{backend} executes the {DELAYED_TERMS} delayed channels of the "
- f"silica tensor Raman model -- the trace and the traceless rank-two "
- f"one -- and this record carries {regional.n_terms}"
- )
- raise BackendError(msg)
- weight = regional.temporal_weight
- if regional.n_terms and not np.all(weight == weight[0]):
- msg = (
- "responses.regional_responses.temporal_weight is not uniform, and a "
- "delayed response is evaluated as a transform product on the doubled "
- "fine axis, which is a convolution only against a uniform quadrature "
- "weight; a non-uniform temporal grid needs a different evaluation"
- )
- raise BackendError(msg)
-
-
-def node_kerr_tensor(
- tables: ModalTransformTables, regional: RegionalResponseTables | None
-) -> Float[np.ndarray, "n_nodes 3 3 3 3"]:
- """Sum each node's material responses into one susceptibility tensor.
-
- A response acts in one material region and a node belongs to exactly one, so the
- tensor a node sees is ``sum_r chi3_r T_r`` over the responses of its region. Two
- responses in one region add, which is what superposed instantaneous
- susceptibilities do; a region no response names contributes nothing, which is how
- a linear cladding is declared.
-
- ``chi3`` is stored as Chebyshev coefficients along z. This route propagates
- constant geometry, so one interval of degree zero is required and the single
- coefficient is the value.
-
- Parameters
- ----------
- tables : solprop.translate.modal_tables.ModalTransformTables
- The guide's reference, whose ``region_index`` owns each node's region.
- regional : solprop.translate.responses.RegionalResponseTables or None
- The compiled per-region responses, or ``None`` for a linear guide.
-
- Returns
- -------
- numpy.ndarray
- The per-node tensor, float64, shape ``(n_nodes, 3, 3, 3, 3)``, in m²/V².
-
- Raises
- ------
- BackendError
- If the responses do not resolve the reference's components, the
- susceptibility varies along z, or a delayed term is declared.
- """
- refuse_regional_responses(tables, regional)
- nodes = int(tables.region_index.size)
- components = int(tables.electric.shape[2])
- summed = np.zeros((nodes, *(components,) * 4))
- if regional is None:
- return summed
- for index, region in enumerate(regional.response_region):
- rows = tables.region_index == region
- chi3 = float(regional.chi3[0, 0, index])
- summed[rows] += chi3 * regional.instantaneous_tensor[index]
- return summed
-
-
-def node_kerr_form(regional: RegionalResponseTables | None) -> KerrForm:
- """Return which cubic a compiled per-region record declares.
-
- The form is a physical-model choice and travels in ``model_id``, which is a
- representation tag inside the resume identity already; it is **not** a leaf
- field, because adding one would move a contract row for a string the record can
- carry.
-
- Parameters
- ----------
- regional : solprop.translate.responses.RegionalResponseTables or None
- The compiled per-region responses, or ``None`` for a linear guide, whose
- zero tensor makes the form immaterial.
-
- Returns
- -------
- {'analytic', 'real'}
- The declared form; ``"analytic"`` for a linear guide.
-
- Raises
- ------
- BackendError
- If the record declares a model identity this backend does not execute.
- """
- if regional is None:
- return "analytic"
- form = KERR_MODEL_FORMS.get(regional.model_id.split(MODEL_ID_SEPARATOR)[0])
- if form is None:
- msg = (
- f"responses.regional_responses declares model_id "
- f"{regional.model_id!r} and numpy_ref executes the instantaneous "
- f"isotropic cubic in its two declared forms, "
- f"{sorted(KERR_MODEL_FORMS)}"
- )
- raise BackendError(msg)
- return cast("KerrForm", form)
-
-
def symmetric_monomials(n_components: int) -> tuple[tuple[int, int, int], ...]:
"""Return the real cubic's monomials ``(j, k, l)``, ``j <= k <= l``, in order.
@@ -631,60 +415,6 @@ def symmetric_cubic(
return total
-def node_delayed_maps(
- tables: ModalTransformTables, regional: RegionalResponseTables | None
-) -> tuple[
- Int32[np.ndarray, " n_nodes"],
- Float[np.ndarray, "n_responses n_terms 3 3 3 3"] | None,
- Complex[np.ndarray, "n_responses n_terms n_rfft"] | None,
-]:
- """Derive the delayed response's per-node ownership, tensors and kernel spectra.
-
- A delayed response has **memory**, so unlike the instantaneous tensor it cannot
- be summed into one per-node array: two regions may declare different kernels and
- a convolution is not linear in the kernel's owner. What is derived instead is
- the response each node belongs to, each response's ``chi3_r D[term]``, and each
- kernel's transform on the doubled fine axis, already scaled by the temporal
- quadrature weight so that the transform product is the causal integral.
-
- Parameters
- ----------
- tables : solprop.translate.modal_tables.ModalTransformTables
- The guide's reference, whose ``region_index`` owns each node's region.
- regional : solprop.translate.responses.RegionalResponseTables or None
- The compiled per-region responses, or ``None`` for a linear guide.
-
- Returns
- -------
- tuple
- The per-node response index (``-1`` where a node's region declares none),
- the scaled delayed tensors and the kernel spectra, the last two ``None``
- when no region declares a delayed response.
-
- Raises
- ------
- BackendError
- Naming the leaf and what about it is not executed.
- """
- refuse_regional_responses(tables, regional)
- nodes = int(tables.region_index.size)
- owner = np.full(nodes, -1, dtype=np.int32)
- if regional is None:
- return owner, None, None
- for index, region in enumerate(regional.response_region):
- owner[tables.region_index == region] = index
- if not regional.n_terms:
- return owner, None, None
- n_to = int(regional.temporal_weight.size)
- scaled = (
- regional.delayed_tensor * regional.chi3[0, 0][:, None, None, None, None, None]
- )
- spectra = np.fft.rfft(regional.delayed_kernel[0, 0], n=2 * n_to, axis=-1) * float(
- regional.temporal_weight[0]
- )
- return owner, scaled, spectra
-
-
def expand_pair_history(
stored: Float[np.ndarray, "n_nodes 6 n_to"],
) -> Float[np.ndarray, "n_nodes 3 3 n_to"]:
@@ -817,133 +547,6 @@ def dispersive_polarisation(
return total
-def general_dispersive_buffers(leaf: DispersiveKerrTerms | None) -> int:
- """Return the buffers per node and component SEP 6's response adds to a chunk.
-
- The compiled general workspace holds, for each node of a chunk and each of its
- components, every filtered field's spectrum and time series and every product and
- its spectrum: two complex-equivalent buffers per field and two per product. The
- symmetric evaluator has one field and one product per region with a gas fill (a
- mixture's under its mixture filter); the polynomial two fields and two products per
- group of equal leg bound, and one cube per region
- (:meth:`~solprop.translate.responses.DispersiveKerrTerms.groups`).
-
- Parameters
- ----------
- leaf : solprop.translate.responses.DispersiveKerrTerms or None
- The route's dispersive channels, or ``None``.
-
- Returns
- -------
- int
- Complex-equivalent buffers per node and component; zero without the leaf.
- """
- if leaf is None:
- return 0
- groups = len(leaf.groups())
- if leaf.evaluator == "symmetric":
- fields = products = groups
- else:
- regions = 1 if leaf.regions is None else len(set(leaf.regions.tolist()))
- fields, products = 2 * groups, regions + 2 * groups
- return 2 * (fields + products)
-
-
-def general_node_chunk(
- n_components: int,
- n_to: int,
- n_omega_fine: int,
- budget: int,
- *,
- dispersive: int = 0,
-) -> int:
- """Return the nodes the general source may hold at once within a byte budget.
-
- The chunk bounds the space-domain work alone:
- :data:`BUFFERS_PER_GENERAL_NODE` complex buffers per node and component,
- :data:`PAIR_BUFFERS_PER_GENERAL_NODE` per node and component squared, and SEP 6's
- dispersive response's own (:func:`general_dispersive_buffers`), over the longer of
- the fine time axis and the fine spectrum. The maps, the reference and the dual are
- whole-problem allocations that no chunk shrinks (:func:`general_memory`).
-
- Parameters
- ----------
- n_components : int
- Cartesian components of the local field, positive.
- n_to : int
- Fine time samples, positive.
- n_omega_fine : int
- Fine spectral bins, positive.
- budget : int
- Bytes the chunk buffers may occupy in total, positive.
- dispersive : int, optional
- The dispersive response's complex buffers per node and component; zero, the
- default, without one.
-
- Returns
- -------
- int
- The chunk size, at least one.
-
- Raises
- ------
- BackendError
- If the budget is not positive.
-
- Examples
- --------
- >>> from solprop.backends.numpy_ref.general_modal import general_node_chunk
- >>> general_node_chunk(3, 1024, 513, 64 * 2**20)
- 136
- """
- if budget <= 0:
- msg = f"the general node budget must be positive, got {budget!r}"
- raise BackendError(msg)
- samples = max(n_to, n_omega_fine)
- columns = (BUFFERS_PER_GENERAL_NODE + dispersive) * n_components + (
- PAIR_BUFFERS_PER_GENERAL_NODE * n_components**2
- )
- per_node = columns * samples * np.dtype(np.complex128).itemsize
- return int(max(1, budget // per_node))
-
-
-def general_planned_chunk(problem: PropagationProblem, plan: NodeChunkPlan) -> int:
- """Return the general route's node chunk under a backend's node-chunk plan.
-
- The route's own rule (:func:`general_node_chunk`) within the plan's budget -- the
- explicit one, or :data:`~solprop.backends.node_chunk.DEFAULT_NODE_BUDGET` -- or
- the plan's exact chunk when one was forced, bounded by the node count. Both
- compiled backends take it, so that they execute the same chunks.
-
- Parameters
- ----------
- problem : solprop.translate.problem.PropagationProblem
- A problem that carries ``transform.modal_tables``.
- plan : solprop.backends.node_chunk.NodeChunkPlan
- The backend's plan (:func:`solprop.backends.node_chunk.plan_node_chunk`).
-
- Returns
- -------
- int
- Nodes the general source holds at once, between one and the node count.
- """
- tables = problem.transform.modal_tables
- assert tables is not None
- nodes = int(tables.region_index.size)
- chunk = (
- plan.requested
- if plan.requested is not None
- else general_node_chunk(
- int(tables.electric.shape[2]),
- problem.grids.n_to,
- int(tables.omega_fine.size),
- plan.budget,
- dispersive=general_dispersive_buffers(problem.responses.dispersive_kerr),
- )
- )
- return min(chunk, nodes)
-
-
def general_memory(
maps: GeneralMaps, tables: ModalTransformTables, n_to: int
) -> dict[str, int]:
diff --git a/src/solprop/backends/multistep.py b/src/solprop/backends/numpy_ref/multistep.py
similarity index 87%
rename from src/solprop/backends/multistep.py
rename to src/solprop/backends/numpy_ref/multistep.py
index 33ed23ab8..3c79baaeb 100644
--- a/src/solprop/backends/multistep.py
+++ b/src/solprop/backends/numpy_ref/multistep.py
@@ -2,13 +2,13 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
-"""The interaction-picture Adams march every CPU backend steps with (SEP 13, SEP 14).
+"""The reference's interaction-picture Adams march (SEP 13, SEP 14).
-One accepted step of a mature method costs one expensive source evaluation (explicit
-Adams-Bashforth and PEC) or two (PECE), against seven for the reference backend's
-IP-DOPRI5. The march keeps a short history of **transported** derivatives, re-anchored
-at every accepted step, and decides each step on a local-defect estimate formed from
-that history without another evaluation (SEP 13 sections 4.1--4.3).
+One accepted step of the PEC method costs one expensive source evaluation, against
+seven for the reference backend's IP-DOPRI5. The march keeps a short history of
+**transported** derivatives, re-anchored at every accepted step, and decides each step
+on a local-defect estimate formed from that history without another evaluation (SEP 13
+sections 4.1--4.3).
The interaction picture is anchored at the start of each step, ``z_n``. With
``U(z_2, z_1) = exp(Phi(z_2) - Phi(z_1))``, the variable ``v(z) = U(z_n, z) a(z)``
@@ -31,25 +31,21 @@
- a trial computes its predictor, its single source call and its estimate without
touching the history; only an accepted step commits, so a rejection leaves the
accepted history exactly as it was;
-- PECE decides acceptance before its second call, so a rejection costs one call;
-- explicit Adams-Bashforth rejects for free on a history estimate before the call,
- and checks the step again afterwards with the new derivative (section 4.3), at the
- cost of the call it has made;
- a hard kink is landed on and the history restarted on its far side; a save plane is
a pure observation and keeps the history;
- a load or a resume starts afresh, at order one, and its startup calls are counted.
**Who marches.** The NumPy reference marches with
:class:`solprop.backends.host.adams.HostAdamsMarch`; the native backend's session
-marches natively from one policy record (SEP 39, SEP 47). This module holds what is
-stateless and shared: the kernels, the host engine :class:`HostHistory`, the tables and
-the call ledger (SEP 47 Part III decision 5).
+marches natively from one policy record (SEP 39, SEP 47). This module holds the
+reference's kernels, its host engine :class:`HostHistory` and the tables; the call
+ledger both backends keep is :mod:`solprop.backends.ledger`.
"""
import logging
import math
from collections.abc import Callable, Mapping
-from dataclasses import dataclass, field, fields
+from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, cast
import numpy as np
@@ -63,31 +59,28 @@
phi_functions,
)
from solprop.backends.host.driver import HostDriver
+from solprop.backends.ledger import source_absent
from solprop.backends.numpy_ref import passes
-from solprop.exceptions import BackendError, ResponseBudgetError
+from solprop.exceptions import BackendError
from solprop.stepper.multistep import (
Multistep,
integrated_weights,
lagrange_coefficients,
)
from solprop.translate.linop import phi_rate
-from solprop.translate.routes import tdse_descriptor
__all__ = [
"AdamsEngine",
"AdamsKernels",
- "CallLedger",
"CoupledBlock",
"CoupledExponentialKernels",
"ExponentialKernels",
"HostHistory",
- "budget_error",
"compensation_tables",
"coupled_moments",
"exponential_tables",
"factored_defect_rows",
"fused_tables",
- "source_absent",
"trial_tables",
]
@@ -101,239 +94,6 @@
"""A carrier frame's rotation of each history row a trial reads, in its slots' order."""
-@dataclass
-class CallLedger:
- """Expensive source evaluations of one run, by purpose (SEP 13 section 5).
-
- Attributes
- ----------
- startup : int
- Evaluations at an accepted field to start or restart the history.
- predictor : int
- Evaluations at a predicted field, accepted or not.
- corrected : int
- PECE's evaluations at the corrected, accepted field.
- rejected : int
- Evaluations spent on steps that were then rejected (included in the above).
- accepted_steps, rejected_steps : int
- Step counts.
- restarts : int
- History restarts at hard kinks.
- failure_restarts : int
- History restarts after :data:`~solprop.stepper.multistep.RESTART_AFTER`
- consecutive rejections.
- dense_observations : int
- Save planes observed by interpolation instead of a landing; none costs a call.
- orders : list of int
- The predictor order of every accepted step. Its record
- (:meth:`to_record`) run-length encodes it, as ``[order, count]`` pairs: a
- run's order settles, and the record is rewritten at every saved plane.
- """
-
- startup: int = 0
- predictor: int = 0
- corrected: int = 0
- rejected: int = 0
- accepted_steps: int = 0
- rejected_steps: int = 0
- restarts: int = 0
- failure_restarts: int = 0
- dense_observations: int = 0
- orders: list[int] = field(default_factory=list)
-
- def __post_init__(self) -> None:
- """Start the record's encoding of the orders empty."""
- # The encoded runs of `orders[:_encoded]`, of the list `_source`: the record
- # is rewritten at every saved plane, and re-encoding every step's order each
- # time made a run saved step by step quadratic in its steps.
- self._source: list[int] | None = None
- self._runs: list[list[int]] = []
- self._encoded = 0
-
- def to_record(self) -> dict[str, object]:
- """Return the ledger as its run record holds it, the orders run-length encoded.
-
- The encoding is extended from the orders appended since the last record. A
- list of orders that was replaced, or shortened, is encoded afresh.
-
- Returns
- -------
- dict
- Every counter by name, and ``orders`` as ``[order, count]`` pairs, in
- step order, no two neighbours of the same order.
-
- Examples
- --------
- >>> from solprop.backends.multistep import CallLedger
- >>> CallLedger(accepted_steps=5, orders=[1, 2, 2, 3, 3]).to_record()["orders"]
- [[1, 1], [2, 2], [3, 2]]
- """
- orders, runs = self.orders, self._runs
- if (
- orders is not self._source
- or len(orders) < self._encoded
- or (self._encoded and orders[self._encoded - 1] != runs[-1][0])
- ):
- self._source, runs, self._encoded = orders, [], 0
- self._runs = runs
- for order in orders[self._encoded :]:
- if runs and runs[-1][0] == order:
- runs[-1][1] += 1
- else:
- runs.append([order, 1])
- self._encoded = len(orders)
- record: dict[str, object] = {
- name: getattr(self, name) for name in _LEDGER_FIELDS
- }
- record["orders"] = [list(run) for run in runs]
- return record
-
- @classmethod
- def from_record(cls, record: Mapping[str, object]) -> "CallLedger":
- """Restore validated cumulative counts without restoring numerical history.
-
- Parameters
- ----------
- record : mapping
- Persisted counter fields and accepted predictor orders, as
- :meth:`to_record` writes them.
-
- Returns
- -------
- CallLedger
- Independently owned counters, the orders decoded step by step.
-
- Raises
- ------
- BackendError
- If a counter is absent, unknown, negative or not an integer, an order run
- is not a pair of positive integers, or the runs do not count one order
- per accepted step.
-
- Examples
- --------
- >>> from solprop.backends.multistep import CallLedger
- >>> ledger = CallLedger(accepted_steps=3, orders=[1, 2, 2])
- >>> CallLedger.from_record(ledger.to_record()) == ledger
- True
- """
- if set(record) != set(_LEDGER_FIELDS):
- raise BackendError("logical call ledger fields do not match this schema")
- ledger = cls()
- for name, value in record.items():
- if name == "orders":
- continue
- if not isinstance(value, int) or isinstance(value, bool) or value < 0:
- raise BackendError(f"invalid logical call counter {name}")
- setattr(ledger, name, value)
- ledger.orders = _decoded_orders(record["orders"], ledger.accepted_steps)
- return ledger
-
- @property
- def total(self) -> int: # numpydoc ignore=RT01
- """Every charged source evaluation.
-
- Startup, predictor and corrected evaluations together.
- """
- return self.startup + self.predictor + self.corrected
-
-
-_LEDGER_FIELDS = tuple(one.name for one in fields(CallLedger))
-"""The counters and the orders a ledger's record holds, in field order."""
-
-
-def budget_error(
- max_calls: int | None,
- spent: int,
- z: float,
- cost: int,
- field: Complex[np.ndarray, "n_modes n_pol n_omega"],
-) -> ResponseBudgetError:
- """Return the error of an Adams trial the source-call budget cannot pay for.
-
- One message for every march that keeps the budget: solprop's and the native
- session's, which charge the same calls (SEP 39 section 4.9).
-
- Parameters
- ----------
- max_calls : int or None
- The method's budget.
- spent : int
- The calls charged so far (:attr:`CallLedger.total`).
- z : float
- The last accepted position, m.
- cost : int
- The calls the refused trial needs.
- field : numpy.ndarray
- The accepted state there, ``(n_modes, n_pol, n_omega)``, owned by the error.
-
- Returns
- -------
- solprop.exceptions.ResponseBudgetError
- Carrying the last accepted position and state and the calls spent.
- """
- msg = (
- f"the source-call budget of {max_calls} is spent ({spent} "
- f"charged) at z = {z:.6g} m: the next trial needs {cost}; "
- "the run stops at its last accepted step, unfinished"
- )
- return ResponseBudgetError(msg, z=z, field=field, calls=spent)
-
-
-def _decoded_orders(runs: object, steps: int) -> list[int]:
- """Return the order of every accepted step from its run-length record.
-
- - ``runs``: the record's ``orders``: ``[order, count]`` pairs of positive integers.
- """
- if (
- isinstance(runs, list)
- and runs
- and all(isinstance(one, int) and not isinstance(one, bool) for one in runs)
- ):
- # Development builds between two releases share the output schema, and
- # those before the encoding wrote one order per step (#80). Read as pairs,
- # such a list would be refused for its shape alone; it is named instead.
- raise BackendError(
- "the record's predictor orders are in the flat form an earlier "
- "development build wrote, one order per step, not the [order, count] "
- "pairs this build reads: rerun the case, or read it with that build"
- )
- if not isinstance(runs, list) or not all(
- isinstance(run, list)
- and len(run) == 2
- and all(
- isinstance(one, int) and not isinstance(one, bool) and one > 0
- for one in run
- )
- for run in runs
- ):
- raise BackendError(
- "logical call ledger orders must be [order, count] pairs of positive "
- "integers"
- )
- # Checked before the expansion, which the counts bound.
- counted = sum(count for _, count in runs)
- if counted != steps:
- raise BackendError(
- f"logical call ledger orders count {counted} steps, and the ledger "
- f"{steps} accepted steps"
- )
- return [order for order, count in runs for _ in range(count)]
-
-
-@dataclass
-class _Derivative:
- """One history entry, in the current anchor's frame."""
-
- z: float
- value: _State | None
- """The derivative on the general path; the store's row, or ``None`` for an engine
- that keeps its rows resident, on the fused and exponential paths."""
- identity: str
- slot: int = -1
- """Its row of the fused path's history store; -1 off that path."""
-
-
@dataclass
class _DenseStep:
"""The last accepted step, in its anchor's frame, for dense observation."""
@@ -341,35 +101,10 @@ class _DenseStep:
z: float
dz: float
start: _State
- end: _State
- start_slope: _State
- end_slope: _State
nodes: np.ndarray
slopes: list[_State]
-def _hermite(step: _DenseStep, theta: float) -> _State:
- """Return the cubic Hermite interpolant of an accepted step at `theta`.
-
- The generic option (a2) of SEP 13 section 6.4: the two end states and the two end
- derivatives, all in the step's anchor frame, with local error ``O(h^4)``.
-
- - ``theta``: the observation as a fraction of the step, in ``[0, 1]``.
- """
- theta2 = theta * theta
- theta3 = theta2 * theta
- h00 = 2 * theta3 - 3 * theta2 + 1
- h10 = theta3 - 2 * theta2 + theta
- h01 = -2 * theta3 + 3 * theta2
- h11 = theta3 - theta2
- return (
- h00 * step.start
- + h10 * step.dz * step.start_slope
- + h01 * step.end
- + h11 * step.dz * step.end_slope
- )
-
-
class AdamsKernels:
"""The field arithmetic of a fused Adams step, in NumPy (SEP 14 phase 2).
@@ -1532,40 +1267,6 @@ def observation(
return [1.0, *self.nodes[:order]], [self.endpoint.copy(), *history]
-def source_absent(problem: Any) -> bool:
- """Return whether a problem's complete source is structurally zero.
-
- No response is compiled beyond the accumulation, and no in-stage absorber, no
- atomic response and no general or spatial pipeline adds a term: SEP 16 section 4.2
- then requires the exact chord rather than an interpolated compensation, which
- need not cancel even at the endpoint. Decided from the problem's structure,
- never from a sample that happens to vanish.
-
- Parameters
- ----------
- problem : solprop.translate.problem.PropagationProblem
- The problem.
-
- Returns
- -------
- bool
- Whether every source evaluation is identically zero.
- """
- responses = problem.responses
- geometry = problem.transform.geometry
- in_stage = (
- responses.temporal_absorber_fine is not None
- and responses.absorber_scheme == "nonlinear"
- ) or (geometry is not None and bool(np.any(geometry.absorber_rate)))
- return (
- len(responses.response_order) == 1
- and not in_stage
- and tdse_descriptor(problem.route) is None
- and problem.transform.modal_tables is None
- and problem.linear.separable is None
- )
-
-
class AdamsEngine(Protocol):
"""What an Adams march asks of the engine that holds its history (SEP 16).
@@ -1575,8 +1276,8 @@ class AdamsEngine(Protocol):
"""
dense: Any
- """The general path's last accepted step (a ``_DenseStep`` or ``None``), which the
- march sets for observation."""
+ """The interaction formulation's last accepted step (a ``_DenseStep`` or ``None``),
+ for observation."""
compensated: bool
"""Whether trials carry a varying operator's linear compensation (SEP 16 4.2)."""
@@ -1616,15 +1317,13 @@ def restore(self, z: float, held: object) -> None:
"""
...
- def start(self, z: float, *, stored: bool) -> _State | None:
- """Evaluate the history's first sample at the accepted state.
+ def start(self, z: float) -> _State | None:
+ """Evaluate the history's first sample at the accepted state into row zero.
Parameters
----------
z : float
The accepted position, m.
- stored : bool
- Whether the sample goes to the store's row zero.
Returns
-------
@@ -1837,7 +1536,6 @@ def __init__(
self._kernels = kernels
self._exponential = exponential
self._compensation = compensation
- self._interpolant = getattr(method, "interpolant", "adams")
linear = backend._problem.linear
# SEP 16 phase 4: a varying operator's linear compensation, integrated in the
# reference flow's frame (Q8); the untransported treatment is a test witness.
@@ -1914,16 +1612,15 @@ def restore(self, z: float, held: object) -> None:
# --- startup ----------------------------------------------------------------------
- def start(self, z: float, *, stored: bool) -> _State:
+ def start(self, z: float) -> _State:
"""Evaluate the source at the accepted state, the history's first sample.
+ The sample goes to the store's row zero.
+
Parameters
----------
z : float
The accepted position, m.
- stored : bool
- Whether the sample goes to the store's row zero (the fused and exponential
- paths) or is returned alone (the general path).
Returns
-------
@@ -1938,20 +1635,12 @@ def start(self, z: float, *, stored: bool) -> _State:
value = backend._adams_lab_source(state, z, purpose="startup")
else:
value, _ = backend._adams_source(state, z, z, purpose="startup")
- if not stored:
- return value
if self._store is None or self._store.shape[1:] != value.shape:
# Zeroed, not empty: the rows a short history has not reached go into a
# resume snapshot as they are, and a file has to be reproducible.
- # In the state's precision: complex64 on SEP 33's single-precision path.
- self._store = np.zeros(
- (self.n_slots, *value.shape),
- dtype=np.result_type(value, np.complex64),
- )
+ self._store = np.zeros((self.n_slots, *value.shape), dtype=np.complex128)
if self.compensated:
self._states = np.zeros_like(self._store)
- # The operator's rates are binary64 host data, differenced against
- # the step's exponent, so they stay binary64 under SEP 33 too.
self._rates = np.zeros(self._store.shape, dtype=np.complex128)
self._store[0] = value
if self.compensated:
@@ -2057,7 +1746,7 @@ def fused_accept(self, keep: np.ndarray, free: int, *, interpolate: bool) -> Non
store = self._store
assert trial is not None and store is not None
self._fused_trial = None
- finished = self._backend._adams_adopt(trial.advanced, trial.dz)
+ self._backend._adams_adopt(trial.advanced, trial.dz)
order = trial.order
if interpolate:
# Copied before the transport re-anchors the rows in place: the step's own
@@ -2070,9 +1759,6 @@ def fused_accept(self, keep: np.ndarray, free: int, *, interpolate: bool) -> Non
z=trial.z,
dz=trial.dz,
start=trial.state.copy(),
- end=finished / trial.propagator,
- start_slope=slopes[1],
- end_slope=trial.source,
nodes=np.array([1.0, *trial.nodes[:order]]),
slopes=slopes,
)
@@ -2182,14 +1868,9 @@ def load_rows(self, rows: Mapping[str, np.ndarray], shape: tuple[int, ...]) -> N
)
raise BackendError(msg)
self.reset()
- # In the state's precision: binary32 on SEP 33's single-precision path, as the
- # store was kept; the operator's rates stay binary64 there too.
- dtype = (
- np.complex64 if self._backend._problem.precision.single else np.complex128
- )
- self._store = np.array(rows["store"], dtype=dtype, copy=True)
+ self._store = np.array(rows["store"], dtype=np.complex128, copy=True)
if self.compensated:
- self._states = np.array(rows["states"], dtype=dtype, copy=True)
+ self._states = np.array(rows["states"], dtype=np.complex128, copy=True)
self._rates = np.array(rows["rates"], dtype=np.complex128, copy=True)
self._weights = [
None
@@ -2263,11 +1944,6 @@ def exponential_trial( # noqa: PLR0917 - called positionally
if block is not None:
kernels = kernels.coupled()
moments, flow = _trial_propagators(kernels, exponent, width, block, dz, shift)
- if state.dtype == np.complex64:
- # SEP 33: the flow and its moments are evaluated in binary64, like every
- # propagator, and rounded once to the state's binary32.
- moments = np.asarray(moments).astype(np.complex64)
- flow = np.asarray(flow).astype(np.complex64)
rows_read = slots[: width - 1]
factors = (
None if shift is None else _carrier_factors(nodes, len(rows_read), shift)
@@ -2291,10 +1967,6 @@ def exponential_trial( # noqa: PLR0917 - called positionally
if shift is not None:
compensation.rotate(shift)
lab = lab + compensation.predictor(order)
- if state.dtype == np.complex64:
- # SEP 33: the compensation is formed in binary64 from the binary32
- # history and the prediction rounded once.
- lab = lab.astype(np.complex64)
source = backend._adams_lab_source(lab, z + dz, purpose="predictor")
corrector = _exponential_corrector(nodes, order, width, helpers=backend.helpers)
rows = kernels.defect_rows(nodes, orders, width)
@@ -2326,8 +1998,6 @@ def exponential_trial( # noqa: PLR0917 - called positionally
extra,
**({} if factors is None else {"factors": factors}),
)
- if state.dtype == np.complex64:
- advanced = np.asarray(advanced).astype(np.complex64)
self._exponential_trial = _ExponentialTrial(
z=z,
dz=dz,
@@ -2482,13 +2152,10 @@ def observe(self, position: float) -> object:
assert step is not None
backend = self._backend
theta = (position - step.z) / step.dz
- if self._interpolant == "adams":
- weights = integrated_weights(
- step.nodes, theta, check=False, helpers=backend.helpers
- )
- value = step.start + _combine(weights, step.slopes, step.dz)
- else:
- value = _hermite(step, theta)
+ weights = integrated_weights(
+ step.nodes, theta, check=False, helpers=backend.helpers
+ )
+ value = step.start + _combine(weights, step.slopes, step.dz)
# A bin the linear flow changes by more than an e-fold over the step (the
# spectral collar's) takes the anchor's linear flow: no polynomial follows its
# exponentially growing derivative (as for the Runge-Kutta extension).
@@ -2623,13 +2290,17 @@ def _divisor(
) -> np.ndarray:
"""Return the defect norm's scale per bin, the accepted state's before the step.
- See :func:`solprop.backends.host.general._norm_function` for why the state
- before the step is the scale.
+ The Runge-Kutta march scales by the larger of the states before and after the step.
+ An Adams step outside its stability region can multiply the state many times over
+ while its predictor and corrector still agree to a fraction of the *new* state, so
+ that scale lets a divergence pass as accurate: SEP 13 phase 2 measured PEC4 growing
+ from 1e7 to 1e236 in five accepted steps with every scaled norm below 0.6. The state
+ the step starts from is the scale that cannot grow with the step it judges.
- Returns: the scale of every bin, shape ``(n_omega,)``.
"""
mask = backend._problem.grids.norm_mask
- controller = backend._norm_controller
+ controller = backend._stepper.controller
magnitude = float(np.linalg.norm(state[..., mask]))
scale = controller.scale(magnitude, magnitude)
return (
diff --git a/src/solprop/backends/numpy_ref/passes.py b/src/solprop/backends/numpy_ref/passes.py
index 94483d00d..329383d03 100644
--- a/src/solprop/backends/numpy_ref/passes.py
+++ b/src/solprop/backends/numpy_ref/passes.py
@@ -45,7 +45,6 @@
from jaxtyping import Bool, Complex, Float, Inexact, Int, Num
from solprop import boundaries, fourier
-from solprop.backends.numpy_ref import transforms
from solprop.constants import EPS0
from solprop.exceptions import BackendError
from solprop.fourier import GridKind
@@ -201,11 +200,7 @@ def to_time(
"""
n_modes, n_pol = spectrum.shape[:2]
n_omega_fine = fourier.n_omega_for(n_to, kind)
- # The spectrum's own precision: complex128, or complex64 on SEP 33's
- # single-precision path.
- fine = np.zeros(
- (n_modes * n_pol, n_omega_fine), dtype=np.result_type(spectrum, np.complex64)
- )
+ fine = np.zeros((n_modes * n_pol, n_omega_fine), dtype=np.complex128)
fine[..., crop_index] = spectrum.reshape(n_modes * n_pol, -1)
return fourier.inverse(fine, kind=kind).reshape(n_modes, n_pol, n_to)
@@ -593,8 +588,8 @@ def raman_polarisation(
# pass reproduce the oracle rather than merely agree with it.
for node in range(field_t.shape[0]):
drive[node, :n_to] = raman_drive(field_t[node], kind=kind, thg=thg)
- convolved = transforms.irfft(
- spectrum * transforms.rfft(drive, axis=-1), n=2 * n_to, axis=-1
+ convolved = np.fft.irfft(
+ spectrum * np.fft.rfft(drive, axis=-1), n=2 * n_to, axis=-1
)[..., :n_to]
return (EPS0 * density) * field_t * convolved[:, np.newaxis, :]
@@ -639,8 +634,8 @@ def _vector_raman_polarisation(
2 * half * (np.real(x) * np.real(y) + np.imag(x) * np.imag(y))
)
kernels = spectrum if n_drives == 1 else spectrum[[0, 1, 1]]
- convolved = transforms.irfft(
- kernels * transforms.rfft(drive, axis=-1), n=2 * n_to, axis=-1
+ convolved = np.fft.irfft(
+ kernels * np.fft.rfft(drive, axis=-1), n=2 * n_to, axis=-1
)[..., :n_to]
if n_drives == 1:
return (EPS0 * density) * field_t * convolved
@@ -1234,7 +1229,7 @@ def tensor_raman_spectrum(
amplitude[channel] += weights[channel] * damped
offset += 1
total += density * component.fraction * amplitude
- return transforms.rfft(total, axis=-1) * dt
+ return np.fft.rfft(total, axis=-1) * dt
def nonlinear_polarisation(
@@ -1538,8 +1533,8 @@ def held_plasma_spectrum(
numpy.ndarray
Polarisation-equivalent spectrum in raw DFT units, with inactive bins zero.
"""
- current = transforms.rfft(gated, axis=-1)[..., crop_index]
- derivative = transforms.rfft(edge, axis=-1)[..., crop_index]
+ current = np.fft.rfft(gated, axis=-1)[..., crop_index]
+ derivative = np.fft.rfft(edge, axis=-1)[..., crop_index]
result = np.zeros_like(current)
q = 1j * omega[active]
result[..., active] = current[..., active] / q - derivative[..., active] / q**2
@@ -1653,19 +1648,16 @@ def _magnitude(
def _kerr(responses: CompiledResponses) -> KerrCoefficients:
- """Return the compiled Kerr coefficients a declared Kerr stage promised."""
- if responses.kerr_coefficients is None:
- msg = "response_order names the Kerr stage but no coefficients were compiled"
- raise BackendError(msg)
+ """Return the compiled Kerr coefficients, which the Kerr stage implies."""
+ assert responses.kerr_coefficients is not None
return responses.kerr_coefficients
def _plasma(responses: CompiledResponses) -> PlasmaCoefficients:
- """Return the compiled plasma group a declared plasma stage promised."""
- if responses.plasma_coefficients is None:
- msg = "response_order names a plasma stage but no coefficients were compiled"
- raise BackendError(msg)
- return responses.plasma_coefficients
+ """Return the compiled plasma group, which a plasma stage implies."""
+ plasma = responses.plasma_coefficients
+ assert plasma is not None
+ return plasma
def _raman_thg(responses: CompiledResponses) -> bool:
@@ -1676,20 +1668,13 @@ def _raman_thg(responses: CompiledResponses) -> bool:
Kerr and Raman responses agree about it on a field grid, refused a field-grid Raman
with no Kerr beside it, and a backend read the answer off the Kerr term's own flag.
Both refusals stand -- they are what makes the agreement true -- but the answer is
- now carried, so it is read where it belongs. An envelope grid's drive is ``|A|^2/2``
+ now carried, so it is read where it belongs: the stage runs exactly when the leaf is
+ present. An envelope grid's drive is ``|A|^2/2``
whatever it says, which is why the value is not checked there.
- Returns: True when the drive is ``E^2`` rather than ``|E_a|^2/2``.
"""
- compiled = responses.raman_terms
- if not compiled:
- msg = (
- "the fused pass reached the raman_drive stage with no "
- "responses.raman_coefficients; the stage is in response_order exactly when "
- "the leaf is present"
- )
- raise BackendError(msg)
- return compiled[0].thg
+ return responses.raman_terms[0].thg
def _raman(
@@ -1707,20 +1692,12 @@ def _raman(
def _plasma_rate(responses: CompiledResponses) -> RateTable:
"""Return the rate table the ionisation stage reads."""
- if len(responses.ionisation_table) != 1:
- msg = "response_order names the ionisation stage but no rate table was compiled"
- raise BackendError(msg)
return responses.ionisation_table[0]
def _checked[T](value: T | None) -> T:
- """Return a stage result the compiled order guarantees is present."""
- if value is None:
- msg = (
- "a plasma stage ran before the one that feeds it; response_order is out of "
- "sequence"
- )
- raise BackendError(msg)
+ """Return a stage result the derived order guarantees is present."""
+ assert value is not None
return value
@@ -2047,24 +2024,15 @@ def apply_temporal_rate(
absorber cannot be folded into ``Phi`` because it is not diagonal in omega, and a
per-step *window* would give total absorption ``W^n`` and no converged answer; as a
rate the factors telescope, so the total is ``exp(-alpha(t) L / 2)`` however the
- steps fall. ``alpha`` is a power coefficient, hence the half on the field.
-
- A complex128 state takes that product as written, in binary64. A complex64 state,
- the single-precision path's (SEP 33), takes it in binary32 as the correction
- ``y + F[F^-1[y] (f - 1)]``, with ``f - 1 = expm1(-alpha(t) h / 2)`` formed in
- binary64 and rounded once: the factor is exactly one outside the collar, so the
- round trip's binary32 rounding falls on the collar's correction alone, not on the
- whole state. The two forms are the same step in exact
- arithmetic only when ``F[F^-1[y]] = y``. On a field grid that needs the DC bin,
- and the Nyquist bin of an even ``n_t``, to be real: the real inverse transform
- drops their imaginary parts, which the product form loses and the correction
- form keeps. The backends' finishes mask both bins before the collar, so the
- forms agree there.
+ steps fall. ``alpha`` is a power coefficient, hence the half on the field. On a
+ field grid the real inverse transform drops the imaginary parts of the DC bin, and
+ of the Nyquist bin of an even ``n_t``; the backends' finishes mask both bins before
+ the collar.
Parameters
----------
state : numpy.ndarray
- The state, complex128 or complex64, shape
+ The state, complex128, shape
``(n_batch, n_modes, n_pol, n_omega)``, in the square root of watts. On a
field grid its DC and Nyquist bins are real (above).
rate : numpy.ndarray
@@ -2078,7 +2046,7 @@ def apply_temporal_rate(
Returns
-------
numpy.ndarray
- The absorbed state, same shape, units and precision.
+ The absorbed state, same shape and units.
Examples
--------
@@ -2088,13 +2056,6 @@ def apply_temporal_rate(
>>> absorbed = apply_temporal_rate(state, np.zeros(4), 1.0, kind="field")
>>> bool(np.allclose(absorbed, state))
True
- >>> narrow = apply_temporal_rate(state.astype(np.complex64), np.ones(4), 1.0,
- ... kind="field")
- >>> narrow.dtype
- dtype('complex64')
"""
time = fourier.inverse(state, kind=kind)
- if state.dtype == np.complex64:
- change = np.expm1(-0.5 * rate * dz).astype(np.float32)
- return state + fourier.forward(time * change, kind=kind)
return fourier.forward(time * np.exp(-0.5 * rate * dz), kind=kind)
diff --git a/src/solprop/backends/numpy_ref/populations.py b/src/solprop/backends/numpy_ref/populations.py
new file mode 100644
index 000000000..46c493a60
--- /dev/null
+++ b/src/solprop/backends/numpy_ref/populations.py
@@ -0,0 +1,90 @@
+# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""The reference's scan of the field synthesised on the axis (SEP 19)."""
+
+import numpy as np
+from jaxtyping import Complex, Float, Inexact
+
+from solprop.backends.numpy_ref import passes
+from solprop.backends.numpy_ref.envelope_plasma import envelope_populations
+from solprop.backends.populations import axis_row_matrix
+from solprop.translate.problem import PropagationProblem
+
+__all__ = ["axis_populations"]
+
+
+def axis_populations(
+ problem: PropagationProblem,
+ field: Complex[np.ndarray, "n_modes n_pol n_omega"],
+ z: float,
+ axis: Float[np.ndarray, "2 n_modes"],
+) -> tuple[Float[np.ndarray, " n_channels"], Float[np.ndarray, " n_channels"]]:
+ """Scan the field synthesised on the axis: one row, every channel (SEP 19).
+
+ The reference's, on the host: the axis is one row per saved plane, against every
+ node of every stage; the native session scans the same row natively. The field
+ is the same as the node fields' -- the same pad scale, taper and fine grid -- with
+ both Cartesian components of the mode profiles, as for ``peak_intensity``; when the
+ state carries the components itself, the profile's magnitude on the axis is the
+ scalar synthesis. The tables, preionisation and running-integral rule are the node
+ scan's.
+
+ Parameters
+ ----------
+ problem : PropagationProblem
+ The translated problem.
+ field : numpy.ndarray
+ One batch member's state, shape (n_modes, n_pol, n_omega).
+ z : float
+ Plane position in m.
+ axis : numpy.ndarray
+ The mode set's synthesis on the axis at `z`, taper included, rows x and y
+ (``HostDriver._on_axis_synthesis``).
+
+ Returns
+ -------
+ tuple of numpy.ndarray
+ The final ionised fraction and the largest rate (cycle-averaged on an
+ envelope grid) on the axis, one per channel.
+ """
+ grids = problem.grids
+ modal_t = passes.to_time(
+ field, grids.crop_index, grids.n_to, kind=problem.grid_kind
+ )
+ row = passes.synthesise(
+ modal_t, axis_row_matrix(problem, axis), 1.0, 2 if field.shape[1] == 1 else 1
+ )
+ return _scan_axis_row(problem, row, z)
+
+
+def _scan_axis_row(
+ problem: PropagationProblem,
+ row: Inexact[np.ndarray, "1 n_components n_to"],
+ z: float,
+) -> tuple[Float[np.ndarray, " n_channels"], Float[np.ndarray, " n_channels"]]:
+ """Scan one row of the field in time: every channel, with the node scan's tables.
+
+ - ``row``: the field on the axis on the fine time grid in V/m.
+ - ``z``: plane position in m.
+ - Returns: the final ionised fraction and the largest rate on the axis, one per
+ channel.
+ """
+ responses, grids = problem.responses, problem.grids
+ preionisation = tuple(
+ float(channel.preionisation(z)) for channel in responses.population_channels
+ )
+ if responses.envelope_plasma is not None:
+ fractions, peaks = envelope_populations(
+ row, responses.envelope_plasma, preionisation, grids.dt_fine
+ )
+ return fractions[:, 0], peaks[:, 0]
+ rates, population = passes.plasma_channel_fractions(
+ row,
+ responses.plasma_channels,
+ preionisation,
+ grids.dt_fine,
+ responses.plasma_integral,
+ )
+ return population[:, 0, -1], rates[:, 0].max(axis=-1)
diff --git a/src/solprop/backends/numpy_ref/single.py b/src/solprop/backends/numpy_ref/single.py
deleted file mode 100644
index e9bd5d327..000000000
--- a/src/solprop/backends/numpy_ref/single.py
+++ /dev/null
@@ -1,1331 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""The single-precision path of the reference backend: SEP 33's oracle.
-
-:class:`NumpySingleBackend` is
-:class:`~solprop.backends.numpy_ref.backend.NumpyRefBackend` with the propagated state,
-the stage values, every transform and the responses held in IEEE binary32, under the
-contract of ``docs/dev/seps/archive/sep-0033-single-precision-mlx.md`` section
-4.2. It is that contract's oracle, so it keeps the reference's one-function-per-pass
-shape and states every order it sums in.
-
-What is binary32 and what is not:
-
-* **The state** is held as ``a / s``, ``s`` the launch's exact power-of-two scale
- (:func:`solprop.translate.precision.state_scale`). ``observe`` and the statistics
- read ``s`` times the state, widened to binary64, which is exact.
-* **The tables** a source evaluation multiplies by are narrowed once at construction
- (``_upload``). Each has its block exponent
- (:func:`solprop.translate.precision.block_exponent`); where narrowing the table
- directly keeps every entry the block-exponent form keeps (the table's largest
- magnitude at least one, or no entry below binary32's smallest normal), the exponent is
- applied at upload, exactly, and otherwise the table is refused unless its pass
- carries the exponent itself, as the Raman kernel's does.
-* **The propagator** ``exp(dPhi)`` is formed from the binary64 exponent in binary64 and
- rounded once (section 4.1, item 3); every other z-dependent array a pass multiplies by
- -- the prefactor, a z-table's value, the spatial collar's rate -- is evaluated on the
- host in binary64 and rounded once.
-* **The responses** run in binary32 on the scaled field. Each takes its homogeneity
- factor (section 4.3, rule 4) in one binary64 coefficient combined on the host
- (:func:`solprop.translate.precision.combine`): ``s**2`` for the cubic terms; the
- plasma is evaluated at the physical field ``s E``, an exact multiplication, and its
- current divided by ``s``. The field-grid plasma uses the stable population forms of
- section 4.4 and blocked running integrals
- (:func:`solprop.translate.precision.prefix_sum`).
-* **Host evaluations of the oracle.** The ionisation rate's table lookup, the
- envelope-grid plasma's cycle moments and populations, and the statistics are
- evaluated in binary64 from the binary32 field and rounded (or, for the statistics,
- returned) once: the oracle defines the reference these comparisons are made against.
-* **The controller** reads a norm reduced as a scaled sum of squares
- (:func:`solprop.translate.precision.scaled_square_sum`), against an absolute
- tolerance divided by ``s``.
-"""
-
-import dataclasses
-import math
-from collections.abc import Mapping, Sequence
-from typing import ClassVar
-
-import numpy as np
-from jaxtyping import Complex, Float, Inexact, Num
-
-from solprop.backends.numpy_ref import passes, transforms
-from solprop.backends.numpy_ref.backend import NumpyRefBackend, SourceTables
-from solprop.backends.numpy_ref.envelope_plasma import envelope_sources
-from solprop.backends.session import ExecutionOptions
-from solprop.constants import EPS0
-from solprop.exceptions import BackendError, ConvergenceError, TranslationError
-from solprop.fourier import GridKind
-from solprop.grid import TimeGrid
-from solprop.modes.protocol import Mode
-from solprop.nonlinear.ionisation import RunningIntegral
-from solprop.profiles import ZProfile
-from solprop.stats import StatsSpec
-from solprop.stepper.controller import PIController
-from solprop.translate.precision import (
- admit_precision,
- block_exponent,
- combine,
- launch_peak,
- prefix_sum,
- scaled_square_sum,
- state_scale,
-)
-from solprop.translate.problem import PropagationProblem
-from solprop.translate.responses import (
- CompiledResponses,
- KerrCoefficients,
- PlasmaChannel,
- ResponseStage,
-)
-from solprop.translate.run_request import RunRequest
-from solprop.translate.stage import StageScalars
-from solprop.translate.tdse import TDSEBinding
-
-__all__ = [
- "NumpySingleBackend",
- "analytic_single",
- "kerr_single",
- "plasma_current_single",
- "raman_single",
- "running_integral",
- "single_sources",
- "stable_population",
-]
-
-_TINY = float(np.finfo(np.float32).tiny)
-
-# Stages the single-precision source evaluation reaches elsewhere: the rate and the
-# population inside the plasma current, the sum and window at the end, and the
-# envelope plasma through the backend's own hook.
-_ELSEWHERE = frozenset(
- {
- ResponseStage.INTENSITY,
- ResponseStage.IONISATION_RATE,
- ResponseStage.PLASMA_SCAN,
- ResponseStage.ACCUMULATE,
- ResponseStage.ENVELOPE_CYCLE,
- ResponseStage.ENVELOPE_POPULATION,
- ResponseStage.ENVELOPE_SOURCES,
- }
-)
-_HUGE = float(np.finfo(np.float32).max)
-
-
-class NumpySingleBackend(NumpyRefBackend):
- """Propagate a single-precision problem: the reference backend in binary32.
-
- Constructed by the registry for ``backend="numpy_ref"`` when the problem's
- precision is single. See the module docstring for what is held in which precision.
-
- Parameters
- ----------
- problem : solprop.translate.problem.PropagationProblem
- A validated problem with ``precision.single``.
- request : solprop.translate.run_request.RunRequest
- The run's policies.
- grid : solprop.grid.TimeGrid, optional
- The grid the state lives on, needed only by :meth:`stats`.
- modes : collections.abc.Sequence, optional
- The propagated modes, needed only by the ``zdw`` statistic.
- profiles : collections.abc.Mapping, optional
- The z-profiles keyed by statistic name.
- tdse_binding : solprop.translate.tdse.TDSEBinding or None, optional
- Must be ``None``: TDSE responses are refused under single precision.
- options : solprop.backends.session.ExecutionOptions or None, optional
- The run's execution options, from which the node chunk is planned; ``None``
- for the automatic choice.
-
- Raises
- ------
- BackendError
- If the problem is not single precision, the shared single-precision admission
- refuses it (:func:`solprop.translate.precision.admit_precision`: the Adams
- integrator outside the survival study, an adaptive `rtol` below the floor), or
- a table cannot be narrowed without losing range that its pass does not carry.
- """
-
- # The observed field is the state widened into a new array (`_observed_field`).
- observes_fresh_field: ClassVar[bool] = True
-
- def __init__(
- self,
- problem: PropagationProblem,
- request: RunRequest,
- *,
- grid: TimeGrid | None = None,
- modes: Sequence[Mode] | None = None,
- profiles: Mapping[str, ZProfile] | None = None,
- tdse_binding: TDSEBinding | None = None,
- options: ExecutionOptions | None = None,
- ) -> None:
- """Adopt a single-precision problem and narrow its tables."""
- if not problem.precision.single:
- raise BackendError("the single-precision path needs a single problem")
- # The settings admit a run through the same function, but a problem built or
- # replaced past them (``registry.select`` on a translated problem) reaches the
- # backend unadmitted; the refusals are the backend's own too.
- stepper = request.stepper
- try:
- admit_precision(
- problem.precision,
- backend=self.name,
- rtol=stepper.controller.rtol,
- schedule=stepper.schedule is not None,
- )
- except TranslationError as error:
- raise BackendError(str(error)) from error
- super().__init__(
- problem,
- request,
- grid=grid,
- modes=modes,
- profiles=profiles,
- tdse_binding=tdse_binding,
- options=options,
- )
- self._real = np.dtype(np.float32)
- self._complex = np.dtype(np.complex64)
- self._scale = state_scale(
- launch_peak(problem.initial.field0, problem.grid_kind)
- )
- self._scale_exponent = math.frexp(self._scale)[1] - 1
- # Block exponents of every narrowed table, by name (SEP 33 section 4.3).
- self.block_exponents: dict[str, int] = {}
- tables = self._tables
- self._tables = SourceTables(
- self._upload("synthesis", tables.synthesis),
- self._upload("projection", tables.projection),
- self._upload("t_window", tables.t_window),
- self._upload("t_window_derivative", tables.t_window_derivative),
- self._upload("omega_window", tables.omega_window),
- None
- if tables.temporal_rate is None
- else self._upload("temporal_rate", tables.temporal_rate),
- )
- if self._embedded is not None:
- self._embedded = self._upload("embedded", self._embedded)
- if self._spatial_absorber.size:
- self._spatial_absorber = self._upload(
- "spatial_absorber", self._spatial_absorber
- )
- if self._carrier is not None:
- self._carrier = self._upload("carrier", self._carrier)
- grids = problem.grids
- self._held_factors: tuple[np.ndarray, np.ndarray] | None = None
- if problem.responses.plasma_channels:
- q = 1j * grids.omega[grids.active_mask]
- self._held_factors = (
- self._upload("held_current", 1.0 / q),
- self._upload("held_edge", -1.0 / q**2),
- )
- self._raman_exponent = 0
- # The lab-frame source's unit propagator, in the state's precision.
- self._lab_frame = np.broadcast_to(
- np.ones((), dtype=self._complex), (1, *problem.shape)
- )
- controller = stepper.controller
- self._controller = dataclasses.replace(
- controller, atol=controller.atol / self._scale
- )
- # The largest and smallest nonzero magnitude of each pass's intermediates, kept
- # only when the study asks for them (SEP 33 section 7.2).
- self.intermediate_ranges: dict[str, tuple[float, float]] | None = None
- # The study's variant that evaluates the propagator from a binary32 exponent,
- # for the record of SEP 33 section 7.2 only.
- self.binary32_exponent = False
-
- @property
- def _norm_controller(self) -> PIController:
- """Return the controller with its absolute tolerance divided by ``s``.
-
- Returns
- -------
- solprop.stepper.controller.PIController
- The scaled controller.
- """
- return self._controller
-
- @property
- def scale(self) -> float:
- """Return the state's exact power-of-two scale ``s``.
-
- Returns
- -------
- float
- The state is held as ``a / s``.
- """
- return self._scale
-
- def _upload(self, name: str, values: Inexact[np.ndarray, "*shape"]) -> np.ndarray:
- """Narrow one table to binary32, keeping its range, and record its exponent.
-
- Parameters
- ----------
- name : str
- The table's name, for the record and a refusal.
- values : numpy.ndarray
- The binary64 table.
-
- Returns
- -------
- numpy.ndarray
- The read-only binary32 table, its block exponent applied exactly.
-
- Raises
- ------
- BackendError
- If the table's largest magnitude overflows binary32, or it is below one and
- holds an entry below binary32's smallest normal number, which narrowing
- directly would lose and a block exponent would keep.
- """
- source = np.asarray(values)
- self.block_exponents[name] = block_exponent(source)
- _check_range(source, f"the {name} table")
- dtype = self._complex if np.iscomplexobj(source) else self._real
- narrowed = np.ascontiguousarray(source, dtype=dtype)
- narrowed.setflags(write=False)
- return narrowed
-
- def _cast(self, values: Inexact[np.ndarray, "*shape"]) -> np.ndarray:
- """Round a host-evaluated binary64 array once, keeping its range.
-
- The rule of :meth:`_upload`: narrowing directly keeps every entry the
- block-exponent form keeps when the largest magnitude is at least one, and when
- no entry falls below binary32's smallest normal number; an entry flushed then
- lies below ``2**-126`` of the largest, inside SEP 33's underflow budget.
-
- Parameters
- ----------
- values : numpy.ndarray
- The binary64 array.
-
- Returns
- -------
- numpy.ndarray
- The binary32 array.
-
- Raises
- ------
- BackendError
- If the array overflows binary32, or its largest magnitude is below one and
- an entry would fall below binary32's smallest normal number.
- """
- source = np.asarray(values)
- _check_range(source, "a host-evaluated array")
- dtype = self._complex if np.iscomplexobj(source) else self._real
- return source.astype(dtype)
-
- # The state and what crosses to the host.
-
- def set_initial(
- self,
- field: Complex[np.ndarray, "n_modes n_pol n_omega"],
- noise: object,
- *,
- z: float = 0.0,
- resume: bool = False,
- ) -> None:
- """Load a state, holding it at the launch's scale in binary32.
-
- Parameters
- ----------
- field : numpy.ndarray
- The state, shape ``(n_modes, n_pol, n_omega)``, physical units.
- noise : object
- Must be ``None``; noise models are refused under single precision.
- z : float, optional
- The position the state belongs to, m.
- resume : bool, optional
- Whether `field` came back from a saved plane.
- """
- del noise # refused under single precision by validation
- super().set_initial(field, None, z=z, resume=resume)
- state = self._require_state()
- self._state = (state * (1.0 / self._scale)).astype(self._complex)
-
- def _observed_field(
- self, state: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]
- ) -> Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]:
- """Return the physical state, widened to binary64: ``s`` times it, exactly.
-
- Parameters
- ----------
- state : numpy.ndarray
- The scaled binary32 state.
-
- Returns
- -------
- numpy.ndarray
- An owned complex128 array.
- """
- widened = state.astype(np.complex128)
- # Scaled where it is: the same product as `widened * scale`, one array fewer.
- return np.multiply(widened, self._scale, out=widened)
-
- def stats(self, spec: StatsSpec) -> dict[str, np.ndarray]:
- """Collect statistics from the physical state widened to binary64.
-
- The oracle's statistics are binary64 functions of the exactly widened binary32
- state, so they carry the state's rounding and none of their own.
-
- Parameters
- ----------
- spec : solprop.stats.StatsSpec
- The statistics to collect.
-
- Returns
- -------
- dict
- As :meth:`solprop.backends.numpy_ref.backend.NumpyRefBackend.stats`.
- """
- held = self._require_state()
- self._state = self._observed_field(held)
- try:
- return super().stats(spec)
- finally:
- self._state = held
-
- # The propagator, the norm and the end of a step.
-
- def _propagator(
- self, exponent: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]
- ) -> Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]:
- """Return ``exp(dPhi)`` evaluated in binary64 and rounded once.
-
- Parameters
- ----------
- exponent : numpy.ndarray
- The binary64 exponent or its negative.
-
- Returns
- -------
- numpy.ndarray
- The complex64 propagator.
- """
- with np.errstate(over="ignore"):
- if self.binary32_exponent:
- return np.exp(exponent.astype(self._complex))
- return np.exp(exponent).astype(self._complex)
-
- def _rounded(self, values: Inexact[np.ndarray, "*shape"]) -> np.ndarray:
- """Round a host-evaluated coefficient array once (SEP 33 section 4.2).
-
- Parameters
- ----------
- values : numpy.ndarray
- A binary64 array.
-
- Returns
- -------
- numpy.ndarray
- The binary32 array.
- """
- return self._cast(values)
-
- def _prefactor(
- self, bundle: StageScalars
- ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]:
- """Return the stage's prefactor, evaluated in binary64 and rounded once.
-
- Parameters
- ----------
- bundle : solprop.translate.stage.StageScalars
- The stage's host scalars.
-
- Returns
- -------
- numpy.ndarray
- The complex64 prefactor.
- """
- return self._cast(super()._prefactor(bundle))
-
- def _error_norm(
- self,
- error: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"],
- state: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"],
- advanced: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"],
- ) -> float:
- """Return the scaled error norm from scaled sums of squares.
-
- The state norms and the residual norm are each a scaled sum of squares in
- binary32 (:func:`solprop.translate.precision.scaled_square_sum`), restored on
- the host; the absolute tolerance is the controller's divided by ``s``.
-
- Parameters
- ----------
- error : numpy.ndarray
- The embedded difference carried to the end of the step.
- state : numpy.ndarray
- The state at the anchor.
- advanced : numpy.ndarray
- The trial state at the end of the step.
-
- Returns
- -------
- float
- The norm the controller reads, binary64; infinite if any value is not
- finite, which the controller refuses.
- """
- mask = self._problem.grids.norm_mask
- residual = error[..., mask]
- if not bool(np.all(np.isfinite(residual))) or not bool(
- np.all(np.isfinite(advanced))
- ):
- return math.inf
- scale = self._controller.scale(
- math.sqrt(scaled_square_sum(state[..., mask])),
- math.sqrt(scaled_square_sum(advanced[..., mask])),
- )
- if isinstance(scale, np.ndarray):
- return math.sqrt(
- scaled_square_sum(residual, (1.0 / scale[mask]).astype(self._real) ** 2)
- )
- return math.sqrt(scaled_square_sum(residual)) / scale
-
- def _finish(
- self,
- state: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"],
- dz: float,
- ) -> Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]:
- """Mask the accepted state and apply a split-step collar, in binary32.
-
- The reference's finish runs on the complex64 state. A split-step collar takes
- the correction form of
- :func:`~solprop.backends.numpy_ref.passes.apply_temporal_rate`: its
- ``exp(-alpha(t) h / 2) - 1`` is formed in binary64 and rounded once, and its
- transforms and products are binary32.
-
- Parameters
- ----------
- state : numpy.ndarray
- The accepted state.
- dz : float
- The step's length, m.
-
- Returns
- -------
- numpy.ndarray
- The finished complex64 state.
-
- Raises
- ------
- BackendError
- If the finished state is not complex64: a binary64 array reached the
- finish, which would have computed it in binary64 (SEP 33 6.2).
- ConvergenceError
- If the finished state is not finite.
- """
- finished = np.asarray(super()._finish(state, dz))
- _require_dtype(finished, self._complex, "the finished state")
- if not bool(np.all(np.isfinite(finished))):
- msg = (
- f"numpy_ref: the single-precision state is not finite after a step of "
- f"{dz:.4g} m from z = {self._z:.6g} m: the field has diverged or left "
- "binary32's range"
- )
- raise ConvergenceError(msg)
- return finished
-
- def _adams_adopt(
- self, advanced: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"], dz: float
- ) -> Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]:
- """Adopt an Adams step's state, refusing one that left binary32.
-
- Parameters
- ----------
- advanced : numpy.ndarray
- The accepted state.
- dz : float
- The step's length, m.
-
- Returns
- -------
- numpy.ndarray
- The finished state.
- """
- _require_dtype(np.asarray(advanced), self._complex, "the Adams state")
- return super()._adams_adopt(advanced, dz)
-
- def _adams_restore(
- self, z: float, state: Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]
- ) -> None:
- """Restore an accepted Adams state, in binary32: a snapshot holds binary64.
-
- Parameters
- ----------
- z : float
- Its position, m.
- state : numpy.ndarray
- The scaled state, as the march held or a snapshot read it.
- """
- super()._adams_restore(z, np.asarray(state).astype(self._complex, copy=False))
-
- def _trial_step(
- self, z: float, dz: float
- ) -> tuple[float, Complex[np.ndarray, "n_batch n_modes n_pol n_omega"]]:
- """Take the reference's trial step, then check that it stayed in binary32.
-
- A step long enough that a propagator passes ``exp(88.72)`` overflows binary32;
- the infinity then meets the band's zeros as NaN. Neither is warned of: the
- trial's norm is not finite, and the controller rejects it and shrinks
- (``docs/dev/stepper.md`` section 3), as a replay refuses it by name.
-
- Parameters
- ----------
- z : float
- Position at the start of the step, m.
- dz : float
- Length of the trial step, m.
-
- Returns
- -------
- tuple
- The scaled error norm and the state at the end of the step.
-
- Raises
- ------
- BackendError
- If the state or a stage value is not complex64: a binary64 array slipped
- into a pass, which would compute silently in binary64 (SEP 33 6.2).
- """
- with np.errstate(over="ignore", invalid="ignore"):
- norm, advanced = super()._trial_step(z, dz)
- _require_dtype(advanced, self._complex, "the trial state")
- for index, stage in enumerate(self._last_stages):
- _require_dtype(stage, self._complex, f"stage {index + 1}")
- return norm, advanced
-
- # The source evaluation.
-
- def _column_sources(
- self,
- field_t: np.ndarray,
- bundle: StageScalars,
- raman: np.ndarray | None,
- ) -> passes.NonlinearSources:
- """Evaluate the responses at a chunk of nodes, in binary32.
-
- Parameters
- ----------
- field_t : numpy.ndarray
- The scaled field at the chunk's nodes, ``E / s``.
- bundle : solprop.translate.stage.StageScalars
- The stage's host scalars.
- raman : numpy.ndarray or None
- The narrowed Raman kernel spectrum, whose exponent is
- ``self._raman_exponent``.
-
- Returns
- -------
- solprop.backends.numpy_ref.passes.NonlinearSources
- The windowed polarisation and the ungated current, both divided by ``s``.
- """
- sources = single_sources(
- field_t,
- self._problem.responses,
- self._tables.t_window,
- kind=self._kind,
- scale_exponent=self._scale_exponent,
- dt=self._problem.grids.dt_fine,
- scalars=bundle,
- carrier=self._carrier,
- raman_spectrum=raman,
- raman_exponent=self._raman_exponent,
- )
- if self.intermediate_ranges is not None:
- self._note_range("field", field_t)
- self._note_range("polarisation", sources.polarisation)
- if sources.current is not None:
- self._note_range("current", sources.current)
- return sources
-
- def _envelope_sources(
- self, field_t: Complex[np.ndarray, "n_nodes n_pol n_to"], bundle: StageScalars
- ) -> tuple[
- Complex[np.ndarray, "n_nodes n_pol n_to"],
- Complex[np.ndarray, "n_nodes n_pol n_to"],
- ]:
- """Return the envelope plasma's sources, evaluated in binary64 and rounded.
-
- The cycle moments' per-sample decomposition and adaptive quadrature are
- evaluated on the host in binary64 from the physical field ``s E``, widened
- exactly, and the drive and loss divided by ``s`` and rounded once (SEP 33
- section 6.2).
-
- Parameters
- ----------
- field_t : numpy.ndarray
- The scaled field at the nodes, complex64.
- bundle : solprop.translate.stage.StageScalars
- The stage's host scalars.
-
- Returns
- -------
- tuple of numpy.ndarray
- The drive and the loss, complex64, divided by ``s``.
- """
- envelope = self._problem.responses.envelope_plasma
- assert envelope is not None
- grids = self._problem.grids
- drive, loss = envelope_sources(
- field_t.astype(np.complex128) * self._scale,
- envelope,
- bundle,
- grids.dt_fine,
- grids.t_window_fine,
- )
- inverse = 1.0 / self._scale
- return self._cast(drive * inverse), self._cast(loss * inverse)
-
- def _raman_spectrum(
- self, rates: tuple[float, ...], densities: tuple[float, ...] = ()
- ) -> Complex[np.ndarray, "... n_omega_doubled"] | None:
- """Return the narrowed Raman kernel spectrum, with its exponent kept.
-
- The spectrum is formed in binary64 as the reference forms it, then narrowed
- with its block exponent, which :func:`single_sources` folds into the Raman
- coefficient: a molecular kernel's spectrum of order ``1e-50`` keeps every bin.
-
- Parameters
- ----------
- rates : tuple of float
- The decay rate of each term at the stage, s^-1.
- densities : tuple of float, optional
- Component amplitude densities in m^-3.
-
- Returns
- -------
- numpy.ndarray or None
- The complex64 spectrum scaled by ``2**-e``, or ``None`` without Raman.
- """
- cached = self._raman_kernel
- spectrum = super()._raman_spectrum(rates, densities)
- if spectrum is None:
- return None
- if spectrum is not cached or spectrum.dtype != self._complex:
- exponent = block_exponent(spectrum)
- narrowed = (spectrum * math.ldexp(1.0, -exponent)).astype(self._complex)
- self._raman_exponent = exponent
- self.block_exponents["raman_kernel"] = exponent
- self._raman_kernel = narrowed
- spectrum = narrowed
- return spectrum
-
- def _held_streams(self, gated: np.ndarray, edge: np.ndarray) -> np.ndarray:
- """Return the held plasma current's spectrum, in binary32.
-
- Parameters
- ----------
- gated : numpy.ndarray
- The windowed current, divided by ``s``.
- edge : numpy.ndarray
- The current times the window's derivative, divided by ``s``.
-
- Returns
- -------
- numpy.ndarray
- The complex64 source on the coarse grid.
- """
- assert self._held_factors is not None
- grids = self._problem.grids
- inverse, inverse_squared = self._held_factors
- current = transforms.rfft(gated, axis=-1)[..., grids.crop_index]
- derivative = transforms.rfft(edge, axis=-1)[..., grids.crop_index]
- result = np.zeros_like(current)
- active = grids.active_mask
- result[..., active] = (
- current[..., active] * inverse + derivative[..., active] * inverse_squared
- )
- return result
-
- def _note_range(self, name: str, values: np.ndarray) -> None:
- """Widen the recorded magnitude range of one intermediate.
-
- Parameters
- ----------
- name : str
- The intermediate.
- values : numpy.ndarray
- Its values at this evaluation.
- """
- assert self.intermediate_ranges is not None
- magnitude = np.abs(values)
- nonzero = magnitude[magnitude > 0]
- if nonzero.size == 0:
- return
- low, high = float(np.min(nonzero)), float(np.max(nonzero))
- if name in self.intermediate_ranges:
- old_low, old_high = self.intermediate_ranges[name]
- low, high = min(low, old_low), max(high, old_high)
- self.intermediate_ranges[name] = (low, high)
-
-
-def _check_range(source: np.ndarray, what: str) -> None:
- """Refuse narrowing an array that would lose range a block exponent keeps.
-
- Parameters
- ----------
- source : numpy.ndarray
- The binary64 array.
- what : str
- What it is, for the message.
-
- Raises
- ------
- BackendError
- If its largest magnitude overflows binary32, or is below one beside an entry
- below binary32's smallest normal number.
- """
- magnitude = np.abs(source)
- largest = float(np.max(magnitude, initial=0.0))
- if not math.isfinite(largest) or largest >= _HUGE:
- msg = f"{what} reaches {largest:.3e}, beyond binary32's range"
- raise BackendError(msg)
- if largest < 1.0 and bool(np.any((magnitude > 0.0) & (magnitude < _TINY))):
- msg = (
- f"{what} holds entries below binary32's smallest normal number beside a "
- f"largest of {largest:.3e}: its pass must carry its block exponent, and "
- "none does (SEP 33 section 4.3)"
- )
- raise BackendError(msg)
-
-
-def _require_dtype(values: np.ndarray, dtype: np.dtype, what: str) -> None:
- """Refuse an array of the wrong precision, by name.
-
- Parameters
- ----------
- values : numpy.ndarray
- The array.
- dtype : numpy.dtype
- The precision it must have.
- what : str
- What it is, for the message.
-
- Raises
- ------
- BackendError
- If the dtype differs.
- """
- if values.dtype != dtype:
- msg = (
- f"the single-precision path computed {what} as {values.dtype}, not "
- f"{dtype}: a binary64 array reached a binary32 pass"
- )
- raise BackendError(msg)
-
-
-def single_sources(
- field_t: Num[np.ndarray, "n_nodes_chunk n_pol n_to"],
- responses: CompiledResponses,
- t_window: Float[np.ndarray, " n_to"],
- *,
- kind: GridKind,
- scale_exponent: int,
- dt: float,
- scalars: StageScalars,
- carrier: Complex[np.ndarray, " n_to"] | None = None,
- raman_spectrum: Complex[np.ndarray, "... n_omega_doubled"] | None = None,
- raman_exponent: int = 0,
-) -> passes.NonlinearSources:
- r"""Evaluate the responses of :func:`.passes.nonlinear_sources` in binary32.
-
- The field is the scaled one, ``E / s`` with ``s = 2**k``. The result is in the
- scaled state's units: the polarisation and the current divided by ``s``. Each
- response takes its homogeneity factor (SEP 33 section 4.3, rule 4) in one binary64
- coefficient: the cubic terms ``s**2`` with the density, the Raman term also the
- kernel's block exponent; the plasma is evaluated at the physical field ``s E``
- (exact) and its current divided by ``s`` (exact).
-
- Parameters
- ----------
- field_t : numpy.ndarray
- The scaled field at the chunk's nodes, binary32.
- responses : solprop.translate.responses.CompiledResponses
- The compiled responses.
- t_window : numpy.ndarray
- The narrowed temporal collar profile.
- kind : {'field', 'envelope'}
- The spectral layout.
- scale_exponent : int
- ``k`` with ``s = 2**k``.
- dt : float
- The fine time grid's spacing, s.
- scalars : solprop.translate.stage.StageScalars
- The stage's host scalars: densities and preionisation.
- carrier : numpy.ndarray or None, optional
- The narrowed harmonic carrier of an envelope grid.
- raman_spectrum : numpy.ndarray or None, optional
- The narrowed Raman kernel spectrum.
- raman_exponent : int, optional
- Its block exponent.
-
- Returns
- -------
- solprop.backends.numpy_ref.passes.NonlinearSources
- The windowed polarisation and the ungated current, in the scaled units.
-
- Raises
- ------
- BackendError
- For a response the single-precision path does not run.
- """
- total = np.zeros_like(field_t)
- current = None
- multiple = len(responses.plasma_channels) > 1
- k = scale_exponent
- for stage in responses.response_order:
- if stage is ResponseStage.KERR:
- kerr = responses.kerr_coefficients
- assert kerr is not None
- factor = combine(
- kerr.coefficient, scalars.kerr_density, exponent=2 * k
- ).binary32()
- total = total + kerr_single(field_t, kerr, factor, carrier)
- elif stage is ResponseStage.RAMAN_DRIVE:
- assert raman_spectrum is not None
- density = (
- 1.0
- if len(responses.raman_terms) > 1
- else scalars.raman_densities[0]
- if scalars.raman_densities
- else scalars.density
- )
- factor = combine(EPS0, density, exponent=2 * k + raman_exponent)
- total = total + raman_single(
- field_t,
- raman_spectrum,
- factor.binary32(),
- kind=kind,
- thg=passes._raman_thg(responses),
- )
- elif stage is ResponseStage.PLASMA_CURRENT:
- physical = np.ldexp(field_t, k) if field_t.dtype.kind == "f" else None
- if physical is None: # pragma: no cover - validation refuses it first
- raise BackendError("the field-grid plasma needs a real field")
- channels = responses.plasma_channels
- if multiple:
- densities = scalars.plasma_densities
- initials = scalars.plasma_preionisation
- else:
- plasma_density = (
- scalars.plasma_densities[0]
- if scalars.plasma_densities
- else scalars.density
- )
- densities = (plasma_density,)
- initials = (scalars.preionisation,)
- current = np.ldexp(
- plasma_current_single(
- physical,
- channels,
- densities,
- initials,
- dt,
- responses.plasma_integral,
- ),
- -k,
- )
- elif stage in _ELSEWHERE:
- continue
- else: # pragma: no cover - validation refuses these routes first
- msg = f"the single-precision path does not run the {stage.name} stage"
- raise BackendError(msg)
- windowed = (total * t_window).astype(field_t.dtype, copy=False)
- return passes.NonlinearSources(windowed, current)
-
-
-def kerr_single(
- field_t: Num[np.ndarray, "n_nodes_chunk n_pol n_to"],
- kerr: KerrCoefficients,
- factor: float,
- carrier: Complex[np.ndarray, " n_to"] | None,
-) -> Num[np.ndarray, "n_nodes_chunk n_pol n_to"]:
- """Evaluate the Kerr polarisation of :func:`.passes.kerr_polarisation` in binary32.
-
- Parameters
- ----------
- field_t : numpy.ndarray
- The scaled field, binary32.
- kerr : solprop.translate.responses.KerrCoefficients
- The compiled coefficients.
- factor : float
- ``coefficient * density * s**2``, combined on the host and rounded once.
- carrier : numpy.ndarray or None
- The narrowed harmonic carrier, when the envelope grid radiates the harmonic.
-
- Returns
- -------
- numpy.ndarray
- The polarisation divided by ``s``, binary32.
- """
- values = analytic_single(field_t) if kerr.analytic_signal else field_t
- if kerr.cubic_form == "real":
- polarisation = factor * np.sum(values**2, axis=-2, keepdims=True) * values
- else:
- squares = np.sum(values**2, axis=-2, keepdims=True)
- moduli = np.sum(
- np.real(values) ** 2 + np.imag(values) ** 2, axis=-2, keepdims=True
- )
- polarisation = factor * (
- kerr.self_factor * moduli * values
- + kerr.conjugate_factor * squares * np.conj(values)
- )
- if carrier is not None:
- polarisation = polarisation + (
- (factor * kerr.harmonic_factor) * carrier * squares * values
- )
- return polarisation.real if kerr.take_real else polarisation
-
-
-def analytic_single(
- values: Num[np.ndarray, "*batch n_t"],
-) -> Complex[np.ndarray, "*batch n_t"]:
- """Return the analytic signal of a real binary32 array, in binary32.
-
- The same construction as :func:`solprop.fourier.analytic_signal` (doubling the
- positive frequencies, keeping zero and Nyquist), with the multiplier held in the
- values' precision.
-
- Parameters
- ----------
- values : numpy.ndarray
- Real values along the last axis.
-
- Returns
- -------
- numpy.ndarray
- The complex64 analytic signal.
- """
- real = np.real(values).astype(np.float32, copy=False)
- n = real.shape[-1]
- spectrum = transforms.fft(real, axis=-1)
- weights = np.zeros(n, dtype=np.float32)
- weights[0] = 1.0
- if n % 2 == 0:
- weights[n // 2] = 1.0
- weights[1 : n // 2] = 2.0
- else:
- weights[1 : (n + 1) // 2] = 2.0
- return transforms.ifft(spectrum * weights, axis=-1)
-
-
-def _drive_single(
- row: Num[np.ndarray, "n_pol n_to"], *, kind: GridKind, thg: bool
-) -> Float[np.ndarray, " n_to"]:
- """Return the Raman drive of one node in binary32 (:func:`raman_drive`).
-
- Parameters
- ----------
- row : numpy.ndarray
- One node's scaled field.
- kind : {'field', 'envelope'}
- The spectral layout.
- thg : bool
- Whether the drive keeps the harmonic.
-
- Returns
- -------
- numpy.ndarray
- The float32 drive.
- """
- column = row[0]
- if kind == "envelope":
- return 0.5 * (np.real(column) ** 2 + np.imag(column) ** 2)
- if thg:
- return np.real(column) ** 2
- signal = analytic_single(np.real(column))
- return 0.5 * (np.real(signal) ** 2 + np.imag(signal) ** 2)
-
-
-def raman_single(
- field_t: Num[np.ndarray, "n_nodes_chunk n_pol n_to"],
- spectrum: Complex[np.ndarray, "... n_omega_doubled"],
- factor: float,
- *,
- kind: GridKind,
- thg: bool,
-) -> Num[np.ndarray, "n_nodes_chunk n_pol n_to"]:
- """Evaluate the delayed Raman polarisation in binary32.
-
- The convolution of :func:`.passes.raman_polarisation` with the narrowed kernel;
- the coefficient carries ``eps0``, the density, ``s**2`` and the kernel's exponent,
- and is applied once to the product with the field.
-
- Parameters
- ----------
- field_t : numpy.ndarray
- The scaled field, binary32.
- spectrum : numpy.ndarray
- The narrowed kernel spectrum, ``(n_to + 1,)`` scalar or ``(1 | 2, n_to + 1)``
- tensor.
- factor : float
- The combined coefficient, rounded once.
- kind : {'field', 'envelope'}
- The spectral layout.
- thg : bool
- Whether the drive keeps the harmonic.
-
- Returns
- -------
- numpy.ndarray
- The polarisation divided by ``s``.
-
- Raises
- ------
- BackendError
- If the field has neither one nor two components.
- """
- n_nodes, n_pol, n_to = field_t.shape
- if n_pol == 2:
- return _vector_raman_single(field_t, spectrum, factor, kind=kind, thg=thg)
- if n_pol != 1: # pragma: no cover - translation shapes the field
- raise BackendError("Raman needs one or two physical components")
- drive = np.zeros((n_nodes, 2 * n_to), dtype=np.float32)
- for node in range(n_nodes):
- drive[node, :n_to] = _drive_single(field_t[node], kind=kind, thg=thg)
- convolved = transforms.irfft(
- spectrum * transforms.rfft(drive, axis=-1), n=2 * n_to, axis=-1
- )[..., :n_to]
- return factor * field_t * convolved[:, np.newaxis, :]
-
-
-def _vector_raman_single(
- field_t: Num[np.ndarray, "n_nodes_chunk 2 n_to"],
- spectrum: Complex[np.ndarray, "n_kernels n_omega_doubled"],
- factor: float,
- *,
- kind: GridKind,
- thg: bool,
-) -> Num[np.ndarray, "n_nodes_chunk 2 n_to"]:
- """Evaluate the two-component Raman polarisation in binary32.
-
- Parameters
- ----------
- field_t : numpy.ndarray
- The scaled two-component field.
- spectrum : numpy.ndarray
- One isotropic or two (isotropic, anisotropic) narrowed kernel spectra.
- factor : float
- The combined coefficient.
- kind : {'field', 'envelope'}
- The spectral layout.
- thg : bool
- Whether the drive keeps the harmonic.
-
- Returns
- -------
- numpy.ndarray
- The polarisation divided by ``s``.
-
- Raises
- ------
- BackendError
- If the kernel spectra have the wrong shape.
- """
- n_to = field_t.shape[-1]
- shapes = ((1, n_to + 1), (2, n_to + 1))
- if (
- spectrum.ndim != 2 or spectrum.shape not in shapes
- ): # pragma: no cover - translated
- raise BackendError("vector Raman needs one or two tensor kernel spectra")
- values = field_t
- if kind == "field" and not thg:
- values = analytic_single(np.real(field_t))
- x, y = values[:, 0], values[:, 1]
- half = 0.5 if kind == "envelope" or not thg else 1.0
- xx = half * (np.real(x) ** 2 + np.imag(x) ** 2)
- yy = half * (np.real(y) ** 2 + np.imag(y) ** 2)
- n_drives = 1 if spectrum.shape[0] == 1 else 3
- drive = np.zeros((field_t.shape[0], n_drives, 2 * n_to), dtype=np.float32)
- drive[:, 0, :n_to] = xx + yy
- if n_drives == 3:
- drive[:, 1, :n_to] = xx - yy
- drive[:, 2, :n_to] = (
- 2 * half * (np.real(x) * np.real(y) + np.imag(x) * np.imag(y))
- )
- kernels = spectrum if n_drives == 1 else spectrum[[0, 1, 1]]
- convolved = transforms.irfft(
- kernels * transforms.rfft(drive, axis=-1), n=2 * n_to, axis=-1
- )[..., :n_to]
- if n_drives == 1:
- return factor * field_t * convolved
- u, v, w = convolved[:, 0], convolved[:, 1], convolved[:, 2]
- result = np.empty_like(field_t)
- result[:, 0] = (u + v) * field_t[:, 0] + w * field_t[:, 1]
- result[:, 1] = w * field_t[:, 0] + (u - v) * field_t[:, 1]
- return factor * result
-
-
-def running_integral(
- values: Float[np.ndarray, "... n_to"], dt: float, rule: RunningIntegral
-) -> Float[np.ndarray, "... n_to"]:
- """Return a running integral in binary32, in the oracle's blocked order.
-
- The rule of :meth:`solprop.nonlinear.ionisation.RunningIntegral.integrate`: the
- band rule's filter (its taps rounded once, the pairs added in the same order), then
- the cumulative trapezoid, whose increments are summed by
- :func:`solprop.translate.precision.prefix_sum`. Each prefix is a sum of depth at
- most ``ceil(log2 n)``, where a sequential binary32 cumulative sum of ``n`` terms
- would carry an error growing with ``n``.
-
- Parameters
- ----------
- values : numpy.ndarray
- The integrand along the last axis, binary32.
- dt : float
- The sample spacing, s.
- rule : solprop.nonlinear.ionisation.RunningIntegral
- The problem's rule.
-
- Returns
- -------
- numpy.ndarray
- The running integral, zero at the first sample, binary32.
- """
- rule.check(dt)
- filtered = values if rule.half_width == 0 else _band_filter(values, rule)
- increments = (filtered[..., :-1] + filtered[..., 1:]) * np.float32(0.5 * dt)
- result = np.zeros_like(filtered)
- result[..., 1:] = prefix_sum(increments, axis=-1)
- return result
-
-
-def _band_filter(
- values: Float[np.ndarray, "... n_to"], rule: RunningIntegral
-) -> Float[np.ndarray, "... n_to"]:
- """Apply a band rule's filter in binary32, as :meth:`RunningIntegral.filter` does.
-
- Parameters
- ----------
- values : numpy.ndarray
- The integrand, binary32.
- rule : solprop.nonlinear.ionisation.RunningIntegral
- A band rule.
-
- Returns
- -------
- numpy.ndarray
- The filtered integrand, binary32.
- """
- half = rule.half_width
- n_to = values.shape[-1]
- reach = np.arange(half, 0, -1)
- head = 2 * values[..., :1] - values[..., reach]
- tail = 2 * values[..., -1:] - values[..., n_to - 1 - reach[::-1]]
- extended = np.concatenate([head, values, tail], axis=-1)
- taps = rule.taps.astype(np.float32)
- filtered = taps[0] * values
- for k in range(1, half + 1):
- filtered = filtered + taps[k] * (
- extended[..., half - k : half - k + n_to]
- + extended[..., half + k : half + k + n_to]
- )
- return filtered
-
-
-def stable_population(
- exposure: Float[np.ndarray, "*shape"], preionisation: float
-) -> tuple[Float[np.ndarray, "*shape"], Float[np.ndarray, "*shape"]]:
- """Return the ionised fraction and the neutral survival in their stable forms.
-
- SEP 33 section 4.4: ``f = p + (1 - p) (-expm1(-X))`` keeps relative accuracy for a
- small exposure ``X``, and ``sigma = (1 - p) exp(-X)`` for a large one, where ``f``
- rounds to one. The loss current and the ionisation work are formed from ``sigma``,
- never from ``1 - f``.
-
- Parameters
- ----------
- exposure : numpy.ndarray
- ``X``, the running integral of the rate, non-negative, binary32.
- preionisation : float
- ``p`` in ``[0, 1]``.
-
- Returns
- -------
- fraction : numpy.ndarray
- ``f``, binary32.
- survival : numpy.ndarray
- ``sigma``, binary32.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.backends.numpy_ref.single import stable_population
- >>> f, sigma = stable_population(np.array([1e-8, 20.0], np.float32), 0.0)
- >>> bool(f[0] > 0), bool(sigma[1] > 0)
- (True, True)
- """
- keep = np.float32(1.0 - preionisation)
- survival = keep * np.exp(-exposure)
- fraction = np.float32(preionisation) + keep * -np.expm1(-exposure)
- return fraction, survival
-
-
-def plasma_current_single( # noqa: PLR0917 - called positionally
- field: Float[np.ndarray, "n_nodes_chunk n_pol n_to"],
- channels: tuple[PlasmaChannel, ...],
- densities: Sequence[float],
- preionisation: Sequence[float],
- dt: float,
- rule: RunningIntegral,
-) -> Float[np.ndarray, "n_nodes_chunk n_pol n_to"]:
- r"""Return the field-grid plasma's ungated current density in binary32.
-
- The current of :func:`.passes.nonlinear_sources` (one channel) and of its channel
- sum (several), with the stable populations of :func:`stable_population`: the free
- current integrates ``sum_c rho_c f_c (e^2/m) n_c E`` and the loss current is
- ``sum_c rho_c I_c W_c sigma_c E / |E|^2``, its division masked where the rate or
- the field is zero. The rate's table lookup is evaluated in binary64 from the
- binary32 magnitude and rounded once.
-
- Parameters
- ----------
- field : numpy.ndarray
- The physical field ``s E``, V/m, binary32.
- channels : tuple of solprop.translate.responses.PlasmaChannel
- The compiled channels.
- densities : sequence of float
- Each channel's density at the stage, m^-3.
- preionisation : sequence of float
- Each channel's pre-ionised fraction at the stage.
- dt : float
- The sample spacing, s.
- rule : solprop.nonlinear.ionisation.RunningIntegral
- The running-integral rule.
-
- Returns
- -------
- numpy.ndarray
- The current density, A/m^2, binary32.
- """
- magnitude = (
- np.abs(field[..., 0, :])
- if field.shape[-2] == 1
- else np.hypot(field[..., 0, :], field[..., 1, :])
- )
- weight = np.zeros_like(magnitude)
- work = np.zeros_like(magnitude)
- for channel, density, initial in zip(
- channels, densities, preionisation, strict=True
- ):
- rate = channel.rate(magnitude.astype(np.float64)).astype(np.float32)
- exposure = running_integral(rate, dt, rule)
- if rule.policy != "trapezoid":
- exposure = np.maximum(exposure, np.float32(0.0))
- fraction, survival = stable_population(exposure, initial)
- weight += (
- np.float32(
- density * channel.charge_mass_ratio * channel.electrons_per_event
- )
- * fraction
- )
- work += np.float32(density * channel.ionisation_potential) * (rate * survival)
- free = running_integral(weight[..., np.newaxis, :] * field, dt, rule)
- squared = magnitude * magnitude
- guard = np.zeros_like(work)
- np.divide(work, squared, out=guard, where=(work > 0.0) & (squared > 0.0))
- return free + guard[..., np.newaxis, :] * field
diff --git a/src/solprop/backends/numpy_ref/transforms.py b/src/solprop/backends/numpy_ref/transforms.py
deleted file mode 100644
index c4108e5b0..000000000
--- a/src/solprop/backends/numpy_ref/transforms.py
+++ /dev/null
@@ -1,208 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-r"""The reference backend's discrete Fourier transforms, chosen by the operand's format.
-
-A binary64 operand goes to ``numpy.fft``, which keeps the scalar oracle's transform
-arithmetic bit for bit. A binary32 operand goes to ``scipy.fft``, which sums in
-binary32. NumPy's float32 transforms return complex64 but are computed in binary64 and
-rounded once, so every bin is accurate to binary64. That under-represents the binary32
-transform error a reduced-precision backend carries, of order :math:`u_{32}\log n` of
-the input's norm in every bin, which a resonant response such as stimulated Raman
-scattering amplifies from below the true spectrum. The single-precision oracle
-(SEP 33) must round as its backends do.
-
-Each function takes the arguments of its ``numpy.fft`` namesake that the backend uses.
-``out``, where given, receives the result, which is also returned.
-
-Examples
---------
->>> import numpy as np
->>> from solprop.backends.numpy_ref import transforms
->>> transforms.rfft(np.ones(4, dtype=np.float32)).dtype
-dtype('complex64')
->>> transforms.rfft(np.ones(4)).dtype
-dtype('complex128')
-"""
-
-from __future__ import annotations
-
-from types import ModuleType
-
-import numpy as np
-import scipy.fft
-from jaxtyping import Inexact, Shaped
-
-__all__ = ["fft", "fft2", "ifft", "ifft2", "irfft", "rfft"]
-
-_BINARY32 = (np.dtype(np.float32), np.dtype(np.complex64))
-
-
-def _library(values: np.ndarray) -> ModuleType:
- """Return the transform library for an operand's format.
-
- Parameters
- ----------
- values : numpy.ndarray
- The operand.
-
- Returns
- -------
- types.ModuleType
- ``scipy.fft`` for a binary32 operand, ``numpy.fft`` otherwise.
- """
- return scipy.fft if values.dtype in _BINARY32 else np.fft
-
-
-def _into(result: np.ndarray, out: np.ndarray | None) -> np.ndarray:
- """Return a transform's result, written into a given array first.
-
- Parameters
- ----------
- result : numpy.ndarray
- The transform's result.
- out : numpy.ndarray or None
- Where to write it, or ``None`` to return `result` itself.
-
- Returns
- -------
- numpy.ndarray
- `out` holding `result`, or `result`.
- """
- if out is None:
- return result
- out[...] = result
- return out
-
-
-def rfft(
- values: Inexact[np.ndarray, "*input"], n: int | None = None, axis: int = -1
-) -> Shaped[np.ndarray, "*output"]:
- """Return the forward real transform along an axis.
-
- Parameters
- ----------
- values : numpy.ndarray
- Real input.
- n : int or None, optional
- Length to zero-pad or crop the axis to; ``None`` keeps it.
- axis : int, optional
- The transformed axis, the last by default.
-
- Returns
- -------
- numpy.ndarray
- The half spectrum, complex in the operand's precision.
- """
- return _library(values).rfft(values, n=n, axis=axis)
-
-
-def irfft(
- values: Inexact[np.ndarray, "*input"], n: int | None = None, axis: int = -1
-) -> Shaped[np.ndarray, "*output"]:
- """Return the inverse real transform along an axis.
-
- Parameters
- ----------
- values : numpy.ndarray
- Half spectrum.
- n : int or None, optional
- Length of the real output; ``None`` takes ``2 * (m - 1)``.
- axis : int, optional
- The transformed axis, the last by default.
-
- Returns
- -------
- numpy.ndarray
- The real signal in the operand's precision.
- """
- return _library(values).irfft(values, n=n, axis=axis)
-
-
-def fft(
- values: Inexact[np.ndarray, "*input"], axis: int = -1
-) -> Shaped[np.ndarray, "*output"]:
- """Return the forward complex transform along an axis.
-
- Parameters
- ----------
- values : numpy.ndarray
- Input.
- axis : int, optional
- The transformed axis, the last by default.
-
- Returns
- -------
- numpy.ndarray
- The spectrum, complex in the operand's precision.
- """
- return _library(values).fft(values, axis=axis)
-
-
-def ifft(
- values: Inexact[np.ndarray, "*input"], axis: int = -1
-) -> Shaped[np.ndarray, "*output"]:
- """Return the inverse complex transform along an axis.
-
- Parameters
- ----------
- values : numpy.ndarray
- Spectrum.
- axis : int, optional
- The transformed axis, the last by default.
-
- Returns
- -------
- numpy.ndarray
- The signal, complex in the operand's precision.
- """
- return _library(values).ifft(values, axis=axis)
-
-
-def fft2(
- values: Inexact[np.ndarray, "*input"],
- axes: tuple[int, int] = (0, 1),
- out: np.ndarray | None = None,
-) -> Shaped[np.ndarray, "*output"]:
- """Return the forward two-dimensional transform over two axes.
-
- Parameters
- ----------
- values : numpy.ndarray
- Input.
- axes : tuple of int, optional
- The transformed axes, the first two by default.
- out : numpy.ndarray or None, optional
- Where to write the result.
-
- Returns
- -------
- numpy.ndarray
- The spectrum, in `out` when given.
- """
- return _into(_library(values).fft2(values, axes=axes), out)
-
-
-def ifft2(
- values: Inexact[np.ndarray, "*input"],
- axes: tuple[int, int] = (0, 1),
- out: np.ndarray | None = None,
-) -> Shaped[np.ndarray, "*output"]:
- """Return the inverse two-dimensional transform over two axes.
-
- Parameters
- ----------
- values : numpy.ndarray
- Spectrum.
- axes : tuple of int, optional
- The transformed axes, the first two by default.
- out : numpy.ndarray or None, optional
- Where to write the result.
-
- Returns
- -------
- numpy.ndarray
- The signal, in `out` when given.
- """
- return _into(_library(values).ifft2(values, axes=axes), out)
diff --git a/src/solprop/backends/populations.py b/src/solprop/backends/populations.py
index f894ea970..55ca5d73e 100644
--- a/src/solprop/backends/populations.py
+++ b/src/solprop/backends/populations.py
@@ -7,17 +7,14 @@
from collections.abc import Iterable
import numpy as np
-from jaxtyping import Complex, Float, Inexact
+from jaxtyping import Float
-from solprop.backends.numpy_ref import passes
-from solprop.backends.numpy_ref.envelope_plasma import envelope_populations
from solprop.exceptions import BackendError
from solprop.stats import ON_AXIS_PLASMA_STATISTICS, species_statistics
from solprop.translate.problem import PropagationProblem
__all__ = [
"axis_node",
- "axis_populations",
"axis_refusal",
"axis_row_matrix",
"axis_statistics",
@@ -201,50 +198,6 @@ def axis_refusal(problem: PropagationProblem, names: Iterable[str]) -> str | Non
return None
-def axis_populations(
- problem: PropagationProblem,
- field: Complex[np.ndarray, "n_modes n_pol n_omega"],
- z: float,
- axis: Float[np.ndarray, "2 n_modes"],
-) -> tuple[Float[np.ndarray, " n_channels"], Float[np.ndarray, " n_channels"]]:
- """Scan the field synthesised on the axis: one row, every channel (SEP 19).
-
- The one implementation every backend uses, on the host: the axis is one row per
- saved plane, against every node of every stage, so its scan costs nothing a
- backend would compile. The field is the same as the node fields' -- the
- same pad scale, taper and fine grid -- with both Cartesian components of the mode
- profiles, as for ``peak_intensity``; when the state carries the components itself,
- the profile's magnitude on the axis is the scalar synthesis. The tables,
- preionisation and running-integral rule are the node scan's.
-
- Parameters
- ----------
- problem : PropagationProblem
- The translated problem.
- field : numpy.ndarray
- One batch member's state, shape (n_modes, n_pol, n_omega).
- z : float
- Plane position in m.
- axis : numpy.ndarray
- The mode set's synthesis on the axis at `z`, taper included, rows x and y
- (``HostDriver._on_axis_synthesis``).
-
- Returns
- -------
- tuple of numpy.ndarray
- The final ionised fraction and the largest rate (cycle-averaged on an
- envelope grid) on the axis, one per channel.
- """
- grids = problem.grids
- modal_t = passes.to_time(
- field, grids.crop_index, grids.n_to, kind=problem.grid_kind
- )
- row = passes.synthesise(
- modal_t, axis_row_matrix(problem, axis), 1.0, 2 if field.shape[1] == 1 else 1
- )
- return _scan_axis_row(problem, row, z)
-
-
def axis_row_matrix(
problem: PropagationProblem, axis: Float[np.ndarray, "2 n_modes"]
) -> Float[np.ndarray, "n_rows n_modes"]:
@@ -273,37 +226,6 @@ def axis_row_matrix(
return np.hypot(scaled[0], scaled[1])[None]
-def _scan_axis_row(
- problem: PropagationProblem,
- row: Inexact[np.ndarray, "1 n_components n_to"],
- z: float,
-) -> tuple[Float[np.ndarray, " n_channels"], Float[np.ndarray, " n_channels"]]:
- """Scan one row of the field in time: every channel, with the node scan's tables.
-
- - ``row``: the field on the axis on the fine time grid in V/m.
- - ``z``: plane position in m.
- - Returns: the final ionised fraction and the largest rate on the axis, one per
- channel.
- """
- responses, grids = problem.responses, problem.grids
- preionisation = tuple(
- float(channel.preionisation(z)) for channel in responses.population_channels
- )
- if responses.envelope_plasma is not None:
- fractions, peaks = envelope_populations(
- row, responses.envelope_plasma, preionisation, grids.dt_fine
- )
- return fractions[:, 0], peaks[:, 0]
- rates, population = passes.plasma_channel_fractions(
- row,
- responses.plasma_channels,
- preionisation,
- grids.dt_fine,
- responses.plasma_integral,
- )
- return population[:, 0, -1], rates[:, 0].max(axis=-1)
-
-
def axis_node(
problem: PropagationProblem, axis: Float[np.ndarray, "2 n_modes"]
) -> int | None:
diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py
index 1a737693e..7221124ae 100644
--- a/src/solprop/backends/protocol.py
+++ b/src/solprop/backends/protocol.py
@@ -364,16 +364,6 @@ class Observation:
and this one, empty unless the run asked for one. Separate from
``step_history`` because the two are sampled differently: the history is every
attempt, these are every ``period``-th accepted step.
- field_owned : bool
- Whether the backend made `field` for this observation alone and keeps no
- reference to it: a copy from a device or a widened single-precision state,
- made fresh at each call. ``False`` where the field may be the backend's own
- state array, frozen and left behind by the next step, and always ``False``
- without a field. The session that observed it, its only holder, converts an
- owned field to output units where it is, rather than into a new array
- (:meth:`solprop.backends.host.session.HostSession.export`); for any other
- field that conversion is what detaches it from the backend (SEP 47
- section 5.4).
Raises
------
@@ -402,7 +392,6 @@ class Observation:
# `dataclasses.field` qualified, because this class has an attribute called
# `field` and a bare name would read as that one.
step_stats: StepStats = dataclasses.field(default_factory=StepStats.empty)
- field_owned: bool = False
def __post_init__(self) -> None:
"""Freeze the arrays, copying any solprop does not already hold, and check.
@@ -416,7 +405,6 @@ def __post_init__(self) -> None:
if self.noise_phase is not None:
msg = "a noise_phase is given without a field"
raise BackendError(msg)
- object.__setattr__(self, "field_owned", False)
self._check_counts()
return
# A backend holds what it hands over (solprop._ownership.hold), which is then
diff --git a/src/solprop/backends/registry.py b/src/solprop/backends/registry.py
index 374c5cc7d..a02f53db6 100644
--- a/src/solprop/backends/registry.py
+++ b/src/solprop/backends/registry.py
@@ -2,20 +2,13 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
-"""Selecting a backend by name, and saying why one is not usable.
+"""Selecting one of the two backends by name, and saying why one is not usable.
-``docs/dev/backends.md`` section 1: selection is by name, availability is detected with
-:func:`importlib.util.find_spec` and **never cached**. The
-errors are two-tier, because the two failures need different answers from the reader: a
-name this package has never heard of is a :class:`ValueError` listing what is usable,
-and a known name whose dependencies are missing is an :class:`ImportError` saying what
-to install.
-
-Each backend is one row of :data:`BACKENDS`, a :class:`BackendEntry` naming by import
-path everything selection reads of it -- its constructor, its lockstep runner and its
-policy hooks -- so nothing here branches on a backend's name to construct one, and an
-optional backend in another distribution registers with one row
-(``docs/dev/backends.md`` section 1).
+``docs/dev/backends.md`` section 1: there are exactly two backends (ADR-077),
+``native`` and ``numpy_ref``. Selection is by name; availability is detected with
+:func:`importlib.util.find_spec` and **never cached**. A name that is neither, a
+retired backend's included, is a :class:`~solprop.exceptions.BackendError` naming the
+two; ``native`` without its package is an :class:`ImportError` saying what to install.
The native backend is a second distribution, ``solprop-native``, built from ``native/``
in this repository and a required dependency; probing availability does not import it.
@@ -31,7 +24,6 @@
import logging
import os
from collections.abc import Callable, Collection, Mapping, Sequence
-from dataclasses import dataclass
from pathlib import Path
from types import ModuleType
from typing import Final, cast
@@ -43,8 +35,6 @@
from solprop.modes.protocol import Mode
from solprop.profiles import ZProfile
from solprop.spatial import FAR_FIELD_STATISTICS
-from solprop.translate.plasma_source import _BENCHMARK_GATED
-from solprop.translate.precision import SINGLE_PRECISION_BACKENDS
from solprop.translate.problem import PropagationProblem
from solprop.translate.routes import tdse_descriptor
from solprop.translate.run_request import RunRequest
@@ -52,11 +42,8 @@
__all__ = [
"AUTO",
- "BACKENDS",
- "FAR_FIELD_BACKENDS",
- "REMOVED_BACKENDS",
+ "BACKEND_NAMES",
"REQUIRE_DEVICE_SETTING",
- "BackendEntry",
"available",
"resolve_name",
"select",
@@ -66,129 +53,59 @@
logger = logging.getLogger(__name__)
+BACKEND_NAMES: Final = ("numpy_ref", "native")
+"""The two backends (ADR-077): the numerical reference and the native backend."""
-@dataclass(frozen=True)
-class BackendEntry:
- """What selection needs of a backend, each object named by its import path.
+AUTO: Final = "native"
+"""What ``"auto"`` selects, with no fallback (SEP 43)."""
- A path is ``"module:attribute"``, the attribute dotted for one inside a class. It is
- imported only when selection needs it, after ``requires`` was found, so listing a
- backend imports nothing.
+_NATIVE_PACKAGE: Final = "solprop_native"
+"""The native backend's import package, in the ``solprop-native`` distribution."""
- Attributes
- ----------
- requires : tuple of str
- Importable module names that must be present. Empty for a backend that needs
- nothing beyond the package's own dependencies.
- extras : tuple of str
- The optional-dependency groups that supply ``requires``, for the error message.
- factory : str
- The backend's constructor, called as ``factory(problem, request, *, grid,
- modes, profiles, options)``, and with ``tdse_binding`` too for a TDSE run, which
- only the reference is given (:func:`select`).
- batch : str or None, optional
- The reference group's lockstep runner over loaded members, ``batch(owners)``
- (:func:`select_batch`); ``None`` for a backend whose prepared group runs its
- members itself, as the native group session does (SEP 47 section 6.7).
- install : str or None, optional
- What to install when ``requires`` is missing; ``None`` names ``extras``.
- distribution : str or None, optional
- The distribution that ships the backend, named by the messages of the policies
- below; ``None`` names the package or the backend instead.
- first_use : str or None, optional
- Called with no arguments before each construction: the native backend's
- first-use calibration (SEP 46); ``None`` for nothing.
- device : str or None, optional
- The build's description, ``device()``, whose ``device_backend`` (``"none"``
- on a CPU build) and ``device_count`` say where it runs
- (:data:`REQUIRE_DEVICE_SETTING`); the module holding it carries the version.
- ``None`` for a backend that never runs on a device.
- far_field : str or None, optional
- The far-field statistics (SEP 23) the installed backend declares it forms;
- another is refused by name before construction (:func:`statistics_refusal`).
- ``None`` for a backend that forms every one, or declares nothing to check.
- """
+_UNKNOWN: Final = (
+ "the backends are backend='native', the solprop-native package, and "
+ "backend='numpy_ref', the reference, which is unthreaded and far slower. Output "
+ "and snapshots that record another backend still load"
+)
+"""What a name that is neither backend is told to use instead."""
- requires: tuple[str, ...]
- extras: tuple[str, ...]
- factory: str
- batch: str | None = None
- install: str | None = None
- distribution: str | None = None
- first_use: str | None = None
- device: str | None = None
- far_field: str | None = None
-
-
-BACKENDS: Mapping[str, BackendEntry] = {
- "numpy_ref": BackendEntry(
- requires=(),
- extras=(),
- factory="solprop.backends.numpy_ref:construct",
- batch="solprop.backends.numpy_ref.batch:lockstep_runner",
- ),
- "native": BackendEntry(
- requires=("solprop_native",),
- extras=(),
- # The optional package has its own strict type gate; it is deliberately absent
- # from this repository's static-analysis source tree.
- factory="solprop_native:NativeBackend",
- install=(
- "solprop, or build solprop-native from native/ in the solprop repository"
- ),
- distribution="solprop-native",
- first_use="solprop.calibration.first_use:ensure",
- device="solprop_native:build_info",
- far_field="solprop_native:NativeBackend.far_field_statistics",
- ),
-}
-"""The backends this package knows, by name: one row each."""
+_NATIVE_MISSING: Final = (
+ 'backend="auto" needs the native backend, and solprop-native is not installed. '
+ "Install it with pip install solprop, or with uv sync in a checkout "
+ "(https://solprop.readthedocs.io/en/stable/introduction/installation.html). "
+ "A contributor's lightweight setup has none, so name the backend there, "
+ 'backend="numpy_ref"'
+)
+"""Why ``"auto"`` refuses with no native package (SEP 43 section 5)."""
-AUTO: Final = "native"
-"""What ``"auto"`` selects, with no fallback (SEP 43)."""
+_NATIVE_INSTALL: Final = (
+ "the 'native' backend needs solprop_native, which is not installed; install "
+ "solprop, or build solprop-native from native/ in the solprop repository"
+)
+"""What ``native`` says when its package is missing."""
-REMOVED_BACKENDS: Mapping[str, str] = {
- "numba_cpu": (
- "the numba_cpu backend was removed in SEP 43: use backend='native', the "
- "solprop-native package, or backend='numpy_ref', the reference, which is "
- "unthreaded and far slower. Output and snapshots that record numba_cpu still "
- "load"
- ),
- "mlx": (
- "the experimental mlx backend was retired in SEP 47: use backend='native', "
- "the solprop-native package, or, for precision='single', backend='numpy_ref', "
- "the reference. Output that records mlx still opens"
- ),
-}
-"""What to say about a backend that no longer exists, by the name it had."""
-
-FAR_FIELD_BACKENDS: tuple[str, ...] = ("numpy_ref", "native")
-"""The backends that form the far-field statistics of SEP 23.
-
-``numpy_ref`` always does; the native backend declares what it forms by its class's
-``far_field_statistics`` attribute (its row's ``far_field``). A statistic it does not
-declare is refused by name on this side of the seam (:func:`statistics_refusal`), before
-it is built.
-"""
+_NATIVE_BUILD_GUIDE: Final = (
+ "https://solprop.readthedocs.io/en/stable/guide/performance/building-native.html"
+)
+"""The manual's page on building solprop-native from source."""
+REQUIRE_DEVICE_SETTING: Final = "SOLPROP_REQUIRE_DEVICE"
+"""Where a run must execute: ``cuda``, ``hip`` or ``any``; unset for no requirement."""
+
+_REQUIRED_DEVICES: Final = {"cuda": {"cuda"}, "hip": {"hip"}, "any": {"cuda", "hip"}}
+"""The device backends each value of the setting accepts."""
-def _load(path: str) -> object:
- """Import the object a registry path names.
- ``path`` is ``"module:attribute"``, the attribute dotted for one inside a class.
- """
- module, _, attribute = path.partition(":")
- found: object = importlib.import_module(module)
- for part in attribute.split("."):
- found = getattr(found, part)
- return found
+def _native_installed() -> bool:
+ """Return whether the native package can be found, without importing it."""
+ return importlib.util.find_spec(_NATIVE_PACKAGE) is not None
def statistics_refusal(name: str, names: Sequence[str]) -> str | None:
"""Return why a backend cannot form some of the statistics named, or ``None``.
- Asking about a backend whose row declares its far-field statistics (the native
- backend's) imports the installed plugin, as selecting it would, to read them.
+ Asking about the native backend imports the installed package, as selecting it
+ would, to read the far-field statistics its class declares it forms.
Parameters
----------
@@ -200,8 +117,8 @@ def statistics_refusal(name: str, names: Sequence[str]) -> str | None:
Returns
-------
str or None
- The refusal, naming the backends that do form them, or ``None`` when the
- backend forms every one (or is not installed, which selecting it reports).
+ The refusal, naming the backend that forms them, or ``None`` when the backend
+ forms every one (or is not installed, which selecting it reports).
Examples
--------
@@ -210,62 +127,36 @@ def statistics_refusal(name: str, names: Sequence[str]) -> str | None:
True
"""
asked = [one for one in names if one in FAR_FIELD_STATISTICS]
- entry = BACKENDS.get(name)
- # numpy_ref, whose row declares nothing, forms every far-field statistic, in
- # binary64; single precision refuses them in validation (SEP 33).
- if not asked or entry is None or entry.far_field is None:
- return None
- formed = _plugin_far_field(name)
- if formed is None:
+ # numpy_ref forms every far-field statistic.
+ if not asked or name != "native" or not _native_installed():
return None
+ from solprop_native import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports]
+ NativeBackend,
+ )
+
+ formed = cast(Collection[str], NativeBackend.far_field_statistics)
missing = [one for one in asked if one not in formed]
if not missing:
return None
return (
- f"the installed {name!r} backend does not form the far-field statistics "
- f"{missing}; install a {entry.distribution or name} build that does, or run "
- "them on numpy_ref"
+ f"the installed native backend does not form the far-field statistics "
+ f"{missing}; install a solprop-native build that does, or run them on "
+ "numpy_ref"
)
-def _plugin_far_field(name: str) -> frozenset[str] | None:
- """Return the far-field statistics an installed plugin backend declares.
-
- - Returns: what the plugin declares (its row's ``far_field``: the native backend's
- ``NativeBackend.far_field_statistics``), empty for a build that predates the
- attribute, or ``None`` when the name is not a known, installed plugin that
- declares them.
- """
- entry = BACKENDS.get(name)
- if entry is None or entry.far_field is None or not _satisfied(entry):
- return None
- try:
- declared = _load(entry.far_field)
- except AttributeError:
- # A build from before the far-field statistics declares none.
- return frozenset()
- return frozenset(cast(Collection[str], declared))
-
-
-def available(precision: str = "double") -> tuple[str, ...]:
- """Return the names of the backends that can run a precision here and now.
+def available() -> tuple[str, ...]:
+ """Return the names of the backends that can run here and now.
Detection is by :func:`importlib.util.find_spec`, so nothing is imported and nothing
- is cached: a backend whose optional dependency is installed while the process runs
- becomes available without a restart, and initializing a native runtime is never a
- side effect of asking.
-
- Parameters
- ----------
- precision : {'double', 'single'}, optional
- The arithmetic asked for (SEP 33). ``"double"``, the default, lists every
- backend; ``"single"`` lists those that admit single precision
- (:data:`solprop.translate.precision.SINGLE_PRECISION_BACKENDS`).
+ is cached: a native package installed while the process runs becomes available
+ without a restart, and initializing a native runtime is never a side effect of
+ asking.
Returns
-------
tuple of str
- The usable names, in the table's order.
+ The usable names, in the order of :data:`BACKEND_NAMES`.
Examples
--------
@@ -273,45 +164,44 @@ def available(precision: str = "double") -> tuple[str, ...]:
>>> "numpy_ref" in available()
True
"""
- names = SINGLE_PRECISION_BACKENDS if precision == "single" else tuple(BACKENDS)
- return tuple(name for name in names if _satisfied(BACKENDS[name]))
+ return tuple(
+ name for name in BACKEND_NAMES if name != "native" or _native_installed()
+ )
def resolve_name(name: str = "auto") -> str:
- """Resolve automatic selection to an installed backend's registry name.
+ """Resolve automatic selection to an installed backend's name.
Parameters
----------
name : str, optional
``"auto"`` is ``"native"``, and has no fallback: it refuses if
``solprop_native`` can't be found or this processor doesn't meet the installed
- build's CPU baseline. Other names pass through unchanged, but for a removed
- backend
- (:data:`REMOVED_BACKENDS`).
+ build's CPU baseline. ``"native"`` and ``"numpy_ref"`` pass through unchanged.
Returns
-------
str
Concrete selection to store in simulation settings. This uncached probe does
- not import a backend or initialize a native runtime. :func:`select` validates
- the name and constructs the backend; failures there never cause a fallback.
+ not import a backend or initialize a native runtime. :func:`select` constructs
+ the backend; failures there never cause a fallback.
Raises
------
BackendError
- If ``name`` is a removed backend, or ``"auto"`` has nothing to select; the
- message says what to do.
+ If ``name`` is neither backend (a retired backend's name included), or
+ ``"auto"`` has nothing to select; the message says what to do.
Examples
--------
>>> resolve_name("numpy_ref")
'numpy_ref'
"""
- if name in REMOVED_BACKENDS:
- raise BackendError(REMOVED_BACKENDS[name])
- if name != "auto":
+ if name in BACKEND_NAMES:
return name
- if not _satisfied(BACKENDS[AUTO]):
+ if name != "auto":
+ raise BackendError(f"{name!r} is not a backend: {_UNKNOWN}")
+ if not _native_installed():
raise BackendError(_NATIVE_MISSING)
refusal = _native_baseline_shortfall()
if refusal is None:
@@ -319,42 +209,23 @@ def resolve_name(name: str = "auto") -> str:
raise BackendError(str(refusal)) from refusal
-_NATIVE_BUILD_GUIDE: Final = (
- "https://solprop.readthedocs.io/en/stable/guide/performance/building-native.html"
-)
-"""The manual's page on building solprop-native from source."""
-
-REQUIRE_DEVICE_SETTING: Final = "SOLPROP_REQUIRE_DEVICE"
-"""Where a run must execute: ``cuda``, ``hip`` or ``any``; unset for no requirement."""
-
-_REQUIRED_DEVICES: Final = {"cuda": {"cuda"}, "hip": {"hip"}, "any": {"cuda", "hip"}}
-"""The device backends each value of the setting accepts."""
-
-_NATIVE_MISSING = (
- 'backend="auto" needs the native backend, and solprop-native is not installed. '
- "Install it with pip install solprop, or with uv sync in a checkout "
- "(https://solprop.readthedocs.io/en/stable/introduction/installation.html). "
- "A contributor's lightweight setup has none, so name the backend there, "
- 'backend="numpy_ref"'
-)
-"""Why ``"auto"`` refuses with no native package (SEP 43 section 5)."""
-
-
def _device_build(name: str) -> bool:
"""Return whether a backend steps on an accelerator in this installation."""
- entry = BACKENDS.get(resolve_name(name))
- if entry is None or entry.device is None:
+ if resolve_name(name) != "native" or not _native_installed():
return False
- return _devices(entry)[0] != "none"
+ return _devices()[0] != "none"
-def _devices(entry: BackendEntry) -> tuple[str, int]:
- """Return where a backend's installed build runs, from its row's ``device``.
+def _devices() -> tuple[str, int]:
+ """Return where the installed native build runs: its device backend and count.
- - Returns: the build's device backend, ``"none"`` on a CPU build, and the devices it
- sees.
+ The device backend is ``"none"`` on a CPU build.
"""
- info = cast("Callable[[], object]", _load(cast(str, entry.device)))()
+ from solprop_native import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports]
+ build_info,
+ )
+
+ info = cast(object, build_info())
backend = cast(str, getattr(info, "device_backend")) # noqa: B009 - any build's
return backend, cast(int, getattr(info, "device_count")) # noqa: B009
@@ -375,33 +246,30 @@ def _check_required_device(name: str) -> None:
msg = f"{REQUIRE_DEVICE_SETTING}={value!r}: use one of {names}, or unset it"
raise BackendError(msg)
unset = f"unset {REQUIRE_DEVICE_SETTING} to run it anyway"
- entry = BACKENDS.get(name)
- if entry is None or entry.device is None:
+ if name != "native":
msg = (
f"{REQUIRE_DEVICE_SETTING}={value} requires a native device build, and "
f"this run is on {name}; {unset}"
)
raise BackendError(msg)
- if not _satisfied(entry):
+ if not _native_installed():
return # select() goes on to say that the package is missing
- package = entry.device.partition(":")[0]
- shipped = entry.distribution or package
try:
- module = importlib.import_module(package)
+ module = importlib.import_module(_NATIVE_PACKAGE)
except Exception as error:
# A device build with no visible device stops in Kokkos as the package loads.
msg = (
- f"{REQUIRE_DEVICE_SETTING}={value}: {shipped} did not load, so "
+ f"{REQUIRE_DEVICE_SETTING}={value}: solprop-native did not load, so "
f"this run wouldn't execute on a device: {error}"
)
raise BackendError(msg) from error
- device_backend, device_count = _devices(entry)
+ device_backend, device_count = _devices()
if device_backend not in accepted:
version = getattr(module, "__version__", "unknown")
msg = (
f"{REQUIRE_DEVICE_SETTING}={value} requires a device build, and the "
- f"installed {shipped} has device backend "
+ f"installed solprop-native has device backend "
f"{device_backend!r}, version {version}. A new release replaces a "
f"source build with the CPU wheel, so rebuild it ({_NATIVE_BUILD_GUIDE}), "
f"or {unset}"
@@ -425,7 +293,7 @@ def _native_baseline_shortfall() -> ImportError | None:
- Returns: the package's own refusal, or ``None`` when the processor meets the
baseline or the build records none to check.
"""
- spec = importlib.util.find_spec("solprop_native")
+ spec = importlib.util.find_spec(_NATIVE_PACKAGE)
locations = [] if spec is None else (spec.submodule_search_locations or [])
for location in locations:
source = Path(location) / "_cpu.py"
@@ -472,8 +340,8 @@ def select(
Parameters
----------
name : str
- The backend's registry name, for example ``"numpy_ref"``, or ``"auto"``
- for the native backend, with no fallback (:func:`resolve_name`).
+ ``"native"``, ``"numpy_ref"``, or ``"auto"`` for the native backend, with no
+ fallback (:func:`resolve_name`).
problem : solprop.translate.problem.PropagationProblem
The problem to step.
request : solprop.translate.run_request.RunRequest
@@ -499,32 +367,28 @@ def select(
Raises
------
- ValueError
- If ``name`` is not a backend this package knows; the message lists what is
- usable.
BackendError
- If ``name`` is a backend that has been removed, or one that does not form a
- statistic the problem asks for; the message says what to use instead.
+ If ``name`` is neither backend, or the backend does not run what the problem
+ asks for; the message says what to use instead.
ImportError
- If ``name`` is known but its dependencies are missing; the message says what
+ If ``name`` is ``"native"`` and its package is missing; the message says what
to install.
Examples
--------
- >>> from solprop.backends.registry import BACKENDS
- >>> sorted(BACKENDS)
- ['native', 'numpy_ref']
+ >>> from solprop.backends.registry import BACKEND_NAMES
+ >>> BACKEND_NAMES
+ ('numpy_ref', 'native')
"""
name = resolve_name(name)
- first_use = BACKENDS[name].first_use if name in BACKENDS else None
- if first_use is not None:
+ if name == "native":
# SEP 46: a first native workspace in an allocation with no profile row runs
# the quick calibration first, before the extension's runtime starts if it
# has not yet.
- cast("Callable[[], None]", _load(first_use))()
+ from solprop.calibration.first_use import ensure # noqa: PLC0415 - layer above
+
+ ensure()
_check_required_device(name)
- if _BENCHMARK_GATED.get() and name != "numpy_ref":
- raise BackendError("the gated plasma benchmark requires numpy_ref")
if (
tdse_descriptor(problem.route) is not None or tdse_binding is not None
) and name != "numpy_ref":
@@ -532,27 +396,8 @@ def select(
refusal = statistics_refusal(name, request.observation.stats.names)
if refusal is not None:
raise BackendError(refusal)
- entry = BACKENDS.get(name)
- if entry is None:
- usable = ", ".join(available())
- msg = f"{name!r} is not a known backend; usable backends are: {usable}"
- raise ValueError(msg)
- if not _satisfied(entry):
- missing = ", ".join(
- module
- for module in entry.requires
- if importlib.util.find_spec(module) is None
- )
- install = (
- entry.install
- if entry.install is not None
- else " or ".join(f"solprop[{extra}]" for extra in entry.extras)
- )
- msg = (
- f"the {name!r} backend needs {missing}, which is not installed; install "
- f"{install}"
- )
- raise ImportError(msg)
+ if name == "native" and not _native_installed():
+ raise ImportError(_NATIVE_INSTALL)
backend = _construct(
name,
problem,
@@ -570,10 +415,10 @@ def select(
def select_batch(owners: Sequence[Backend]) -> BatchBackend:
"""Select batch arithmetic without substituting a different backend.
- This is the reference group's lockstep runner, read from the registry row's
- ``batch``. A backend whose row names none, as ``native``'s, runs its groups through
- its own prepared group instead (:func:`solprop.backends.sessions.start_group`;
- SEP 47 section 6.7), and is refused here by name.
+ This is the reference group's lockstep runner. The native backend runs its groups
+ through its own prepared group instead
+ (:func:`solprop.backends.sessions.start_group`; SEP 47 section 6.7), and is
+ refused here by name.
Parameters
----------
@@ -596,30 +441,17 @@ def select_batch(owners: Sequence[Backend]) -> BatchBackend:
if any(owner.name != owners[0].name for owner in owners):
raise BackendError("Batch refuses mixed backends")
name = owners[0].name
- entry = BACKENDS.get(name)
- if entry is None:
- raise BackendError(f"Batch has no runner for backend {name!r}")
- if entry.batch is None:
+ if name != "numpy_ref":
msg = (
f"Batch has no runner for backend {name!r}: select_batch is the "
- "reference's lockstep runner, and a backend that registers none runs its "
- "groups through its own prepared group, which "
- "solprop.backends.sessions.start_group starts, as native's group session "
- "does (SEP 47 section 6.7)"
+ "reference's lockstep runner, and native runs its groups through its own "
+ "prepared group, which solprop.backends.sessions.start_group starts "
+ "(SEP 47 section 6.7)"
)
raise BackendError(msg)
- runner = cast("Callable[[Sequence[Backend]], BatchBackend]", _load(entry.batch))
- return runner(owners)
-
-
-def _satisfied(entry: BackendEntry) -> bool:
- """Return whether every module an entry requires can be found.
+ from solprop.backends.numpy_ref.batch import lockstep_runner # noqa: PLC0415
- - Returns: True when nothing is missing.
- """
- return all(
- importlib.util.find_spec(module) is not None for module in entry.requires
- )
+ return lockstep_runner(owners)
def _construct(
@@ -633,18 +465,23 @@ def _construct(
tdse_binding: TDSEBinding | None = None,
options: ExecutionOptions | None = None,
) -> Backend:
- """Import and build the named backend, whose requirements are already met.
+ """Import and build the named backend, whose package is installed.
- - ``profiles``: the z-profiles keyed by statistic name.
- - ``tdse_binding``: Driver-owned atomic runtime, passed only to the NumPy backend.
+ Imported here, not at module scope: selecting a backend must not be a reason to
+ import the other or initialize a native runtime. Only the reference is given a TDSE
+ binding; :func:`select` refuses one elsewhere.
"""
- entry = BACKENDS.get(name)
- if entry is None:
- raise AssertionError(f"unhandled backend registry entry {name!r}")
- # Imported here, not at module scope: selecting a backend must not be a reason to
- # import every backend or initialize a native runtime.
- factory = cast("Callable[..., Backend]", _load(entry.factory))
- # Only the reference is given a TDSE binding; select refuses one elsewhere.
+ factory: Callable[..., Backend]
+ if name == "native":
+ from solprop_native import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports]
+ NativeBackend,
+ )
+
+ factory = cast("Callable[..., Backend]", NativeBackend)
+ else:
+ from solprop.backends.numpy_ref import construct # noqa: PLC0415
+
+ factory = construct
host = {} if tdse_binding is None else {"tdse_binding": tdse_binding}
return factory(
problem,
diff --git a/src/solprop/backends/session_core.py b/src/solprop/backends/session_core.py
index a32d6ba86..ea4b7ac14 100644
--- a/src/solprop/backends/session_core.py
+++ b/src/solprop/backends/session_core.py
@@ -16,7 +16,7 @@
- :class:`SnapshotLatch` coalesces snapshot requests until one is serviced (section
5.5);
- :class:`ObservedField` observes a committed field once per ticket and shares it
- among its exports, converting it in place where nothing else holds it (section 5.4);
+ among its exports (section 5.4);
- :func:`merge_summaries`, :func:`bind` and :data:`EMPTY_SUMMARY` assemble events;
- :func:`foreign_request` and :func:`check_preparation` are the refusals a
preparation makes before any session starts (section 5.2).
@@ -314,9 +314,9 @@ class ObservedField:
first asks for it; every export of that ticket reads the same observation. An
observation's arrays are read-only and nothing writes to them again
(:meth:`solprop.backends.protocol.HostStepping.observe`), so an export with nothing
- to convert is that array; one the driver made for the session alone is converted in
- place when it was not handed out unconverted. Attempts a field fetch drains are
- kept for the next event's chunk.
+ to convert is that array on a spatial grid, and every other export a new array (or
+ the caller's destination). Attempts a field fetch drains are kept for the next
+ event's chunk.
Parameters
----------
@@ -334,17 +334,12 @@ def __init__(
self._drain = drain
self._observed: Observation | None = None
self._generation = -1
- self._converted: ExportRecipe | None = None
- # Whether the observation's own array was handed out unconverted (export).
- self._lent = False
# Attempts a field fetch drained that no event has delivered yet.
self._undelivered: list[tuple[BackendHistory, StepStats]] = []
def release(self) -> None:
"""Forget the observation: the next export of any ticket observes again."""
self._observed = None
- self._converted = None
- self._lent = False
def undelivered(self) -> list[tuple[BackendHistory, StepStats]]:
"""Return, once, the attempts field fetches drained since the last call.
@@ -381,40 +376,15 @@ def export(
"""
spatial = self._problem.transform.geometry is not None
recipe, weight = export_weight(self._problem, ticket.z, recipe)
- observed = self.observation(ticket)
- converted = self._converted
- if converted is not None and (converted != recipe or out is not None):
- # Converted in place for another recipe, and handed out so: observe the raw
- # field again rather than write to an array a consumer holds.
- self._observed = None
- self._converted = None
- observed = self.observation(ticket)
# A view of its own, as every fetch has handed out: a destination a caller
# passes back is then never taken for the source and is checked as one.
- state = observed.require_field().view()
- if self._converted is not None:
- # Converted in place for this recipe already: the same detached result.
- return state
+ state = self.observation(ticket).require_field().view()
identity = not recipe.optics and (weight is None or bool(np.all(weight == 1.0)))
- if identity and out is None and (observed.field_owned or spatial):
+ if identity and out is None and spatial:
# An export with nothing to convert is the observation's array. A modal
# field the backend may still hold is detached by a copy, as the
- # true-power conversion always detached it. Once lent, the array is never
- # converted in place.
- self._lent = True
+ # true-power conversion always detached it.
return state
- if out is None and observed.field_owned and not self._lent:
- # The driver made this array for this session alone, which hands out only
- # the export: convert it where it is (SEP 47 section 5.4).
- self._converted = recipe
- # The flag is the observation's own array's, which `state` is.
- whole = observed.require_field()
- whole.setflags(write=True)
- try:
- apply_recipe(whole, recipe, weight=weight, out=whole)
- finally:
- whole.setflags(write=False)
- return whole
return apply_recipe(state, recipe, weight=weight, out=out)
def noise_phase(self, ticket: ExportTicket) -> np.ndarray | None:
@@ -453,8 +423,6 @@ def observation(self, ticket: ExportTicket) -> Observation:
self._undelivered.append((observed.step_history, observed.step_stats))
self._observed = observed
self._generation = ticket.generation
- self._converted = None
- self._lent = False
return self._observed
diff --git a/src/solprop/boundaries.py b/src/solprop/boundaries.py
index 653c610ba..d30f08430 100644
--- a/src/solprop/boundaries.py
+++ b/src/solprop/boundaries.py
@@ -1021,8 +1021,8 @@ def occupancy(
numpy.ndarray
Shape ``(SPLIT_RATE_BUCKETS,)``, in the state's units of energy.
"""
- # In the state's own precision, without a state-sized temporary: the shares
- # are what the limit reads, so the order of summing is free.
+ # Without a state-sized temporary: the shares are what the limit reads, so
+ # the order of summing is free.
spectrum = np.asarray(field_spectrum)
re, im = np.real(spectrum), np.imag(spectrum)
weights = None if columns is None else np.asarray(columns, dtype=np.float64)
diff --git a/src/solprop/capabilities.py b/src/solprop/capabilities.py
index 0b835f836..d515d7256 100644
--- a/src/solprop/capabilities.py
+++ b/src/solprop/capabilities.py
@@ -414,12 +414,6 @@ class Rule:
"The low-storage march lands on its schedule's edges and has no dense output.",
_BOTH,
),
- Rule(
- "integrator-backends",
- ("integrator", "backend"),
- "The Multistep and low-storage integrators run on numpy_ref and native only.",
- _BOTH,
- ),
# The gnlse route.
Rule(
"gnlse-envelope-grid",
@@ -541,12 +535,6 @@ class Rule:
"A coherent wall block always carries the taper connection.",
_CAPILLARY,
),
- Rule(
- "wall-cf4-numpy-ref",
- ("wall", "backend"),
- "A coherent wall with method='cf4' runs on numpy_ref alone.",
- _CAPILLARY,
- ),
Rule(
"wall-is-the-loss",
("wall", "loss"),
@@ -609,12 +597,6 @@ class Rule:
"tdse_execution is the resources of a TDSE response, which tdse declares.",
_CAPILLARY,
),
- Rule(
- "tdse-double-precision",
- ("tdse_execution", "precision"),
- "No backend runs the TDSE response in binary32.",
- _CAPILLARY,
- ),
Rule(
"tdse-numpy-ref",
("tdse_execution", "backend"),
@@ -701,7 +683,7 @@ def route(name: str) -> RouteCapabilities:
>>> capillary.choices["shape"], capillary.choices["noise"]
(('gauss', 'sech'), ('default', 'input', 'modified', 'none'))
>>> [rule.id for rule in capillary.rules_for("tdse_execution")][:2]
- ['tdse-execution-needs-tdse', 'tdse-double-precision']
+ ['tdse-execution-needs-tdse', 'tdse-numpy-ref']
"""
# The recipe layer sits above this module, which the driver imports.
from solprop.recipe.core import ROUTES # noqa: PLC0415 - layer above
diff --git a/src/solprop/driver/batch.py b/src/solprop/driver/batch.py
index a19b3bd9e..e7112a6fc 100644
--- a/src/solprop/driver/batch.py
+++ b/src/solprop/driver/batch.py
@@ -45,11 +45,6 @@ def _signature(member: BatchMember) -> str:
"""Hash the execution layout while keeping member parameter tables independent."""
simulation = member.simulation
backend = simulation.settings.backend
- if backend not in {"numpy_ref", "native"}:
- raise DriverError(
- f"Batch does not support backend {simulation.settings.backend!r}; "
- "use Local or Pool to preserve the selected backend"
- )
problem = simulation.translate()
request = simulation._run_request()
if problem.transform.geometry is not None:
diff --git a/src/solprop/driver/defaults.py b/src/solprop/driver/defaults.py
index 79798fa52..0fb0d5fad 100644
--- a/src/solprop/driver/defaults.py
+++ b/src/solprop/driver/defaults.py
@@ -33,7 +33,6 @@
from solprop.stepper.multistep import Multistep
from solprop.stepper.runge_kutta import RungeKutta
from solprop.translate.masks import AbsorberScheme
-from solprop.translate.precision import SINGLE_RTOL_CAP, SINGLE_RTOL_FLOOR
__all__ = [
"CAPILLARY_RTOL",
@@ -45,7 +44,6 @@
"INTERIOR_LAUNCH_NOISE",
"PLANAR_NOISE",
"ROUTE_DEFAULTS",
- "SINGLE_PRECISION_NOISE",
"SLAB_NOISE",
"SPATIAL_ATOL",
"SPATIAL_RTOL",
@@ -62,7 +60,6 @@
"default_scheme",
"resolve_noise",
"route_defaults",
- "rtol_in_precision",
]
DEFAULT_RTOL = 1e-7
@@ -129,10 +126,6 @@
FREE_ATOL = 0.0
"""Absolute tolerance of the same default controller."""
-SINGLE_PRECISION_NOISE = (
- "single precision carries no noise: binary32 rounding swamps a one-photon seed"
-)
-"""Why a default resolves to no noise under ``precision="single"`` (SEP 37)."""
PLANAR_NOISE = "the hybrid planar grid carries no noise: no photon measure is declared"
"""Why a planar run carries no noise (SEP 37)."""
@@ -204,11 +197,9 @@ class RouteDefaults:
Attributes
----------
integrator : {'Multistep', 'RungeKutta'}
- The method a binary64 run steps with when none is named. Single precision
- always takes ``RungeKutta()`` (SEP 33).
+ The method a run steps with when none is named.
rtol : float
- The binary64 relative tolerance; :func:`default_rtol` gives it in single
- precision.
+ The relative tolerance.
atol : float
The absolute tolerance, in the route's field unit.
noise : {'input', 'quiet', 'unmeasured'}
@@ -316,22 +307,13 @@ def route_defaults(route: str) -> RouteDefaults:
raise DriverError(msg) from None
-def default_rtol(route: str, precision: str) -> float:
- """Return a route's default relative tolerance in the run's precision.
-
- The one rule every route follows. Binary64 takes the route's own. Binary32 takes the
- larger of it and :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR`, so it is
- never tighter than its binary64 twin nor than binary32 rounding can serve, and at
- most :data:`~solprop.translate.precision.SINGLE_RTOL_CAP`, the loosest value the
- single-precision survival record measured (the maintainers' ruling of 2026-10-28;
- the record reached free space's 1e-4 on 2026-11-05).
+def default_rtol(route: str) -> float:
+ """Return a route's default relative tolerance.
Parameters
----------
route : str
A :data:`RouteName`.
- precision : {'double', 'single'}
- The run's precision.
Returns
-------
@@ -341,51 +323,19 @@ def default_rtol(route: str, precision: str) -> float:
Examples
--------
>>> from solprop.driver.defaults import default_rtol
- >>> [default_rtol(route, "single") for route in ("gnlse", "capillary", "free")]
- [1e-06, 1e-06, 0.0001]
- >>> default_rtol("gnlse", "double")
- 1e-07
- """
- return rtol_in_precision(route_defaults(route).rtol, precision)
-
-
-def rtol_in_precision(rtol: float, precision: str) -> float:
- """Return a binary64 default tolerance as the run's precision takes it.
-
- The rule :func:`default_rtol` applies to a route's row, for any binary64 value.
-
- Parameters
- ----------
- rtol : float
- The binary64 default.
- precision : {'double', 'single'}
- The run's precision.
-
- Returns
- -------
- float
- `rtol` in binary64; clamped to the single-precision floor and cap otherwise.
-
- Examples
- --------
- >>> from solprop.driver.defaults import rtol_in_precision
- >>> [rtol_in_precision(rtol, "single") for rtol in (1e-8, 1e-5, 1e-3)]
- [1e-06, 1e-05, 0.0001]
+ >>> [default_rtol(route) for route in ("gnlse", "capillary", "free")]
+ [1e-07, 1e-06, 0.0001]
"""
- if precision != "single":
- return rtol
- return min(max(rtol, SINGLE_RTOL_FLOOR), SINGLE_RTOL_CAP)
+ return route_defaults(route).rtol
-def default_controller(route: str, precision: str) -> PIController:
+def default_controller(route: str) -> PIController:
"""Return the step controller a route takes when given no tolerances.
Parameters
----------
route : str
A :data:`RouteName`.
- precision : {'double', 'single'}
- The run's precision.
Returns
-------
@@ -395,16 +345,14 @@ def default_controller(route: str, precision: str) -> PIController:
Examples
--------
>>> from solprop.driver.defaults import default_controller
- >>> controller = default_controller("radial", "double")
+ >>> controller = default_controller("radial")
>>> controller.rtol, controller.atol
(1e-05, 0.0)
"""
- return PIController(
- rtol=default_rtol(route, precision), atol=route_defaults(route).atol
- )
+ return PIController(rtol=default_rtol(route), atol=route_defaults(route).atol)
-def default_integrator(route: str | None, precision: str) -> Multistep | RungeKutta:
+def default_integrator(route: str | None) -> Multistep | RungeKutta:
"""Return the integrator a route steps with when none is named.
Parameters
@@ -412,9 +360,6 @@ def default_integrator(route: str | None, precision: str) -> Multistep | RungeKu
route : str or None
A :data:`RouteName`, or ``None`` for settings built by hand, which step with
``Multistep()``.
- precision : {'double', 'single'}
- The run's precision. Both take the route's own integrator: binary32 Adams
- agrees with binary64 Adams to the method's truncation (#342).
Returns
-------
@@ -424,14 +369,13 @@ def default_integrator(route: str | None, precision: str) -> Multistep | RungeKu
Examples
--------
>>> from solprop.driver.defaults import default_integrator
- >>> type(default_integrator("gnlse", "double")).__name__
+ >>> type(default_integrator("gnlse")).__name__
'RungeKutta'
- >>> type(default_integrator("capillary", "single")).__name__
+ >>> type(default_integrator("capillary")).__name__
'Multistep'
- >>> type(default_integrator(None, "double")).__name__
+ >>> type(default_integrator(None)).__name__
'Multistep'
"""
- del precision # one rule in both precisions since #342
name = "Multistep" if route is None else route_defaults(route).integrator
return RungeKutta() if name == "RungeKutta" else Multistep()
@@ -467,13 +411,12 @@ def default_scheme(route: str | None, *, interpolate_planes: bool) -> AbsorberSc
def resolve_noise(
- route: str, noise: str | None, seed: int | None, precision: str
+ route: str, noise: str | None, seed: int | None
) -> tuple[NoiseModel | None, NoiseResolution]:
"""Resolve a constructor's ``noise`` keyword into the model a run carries (SEP 37).
``"default"`` follows the route's :data:`NoisePolicy`: the input noise, or none for
- the row's reason. Single precision carries none whatever the route, because binary32
- rounding swamps a one-photon seed. A seed beside a default that resolves to none is
+ the row's reason. A seed beside a default that resolves to none is
refused rather than silently dropped, and so is a seed beside ``noise=None``. An
explicit model is refused where the route is ``"unmeasured"`` and warned on beside
the TDSE response; any other value is passed through, and an unknown one is refused
@@ -487,8 +430,6 @@ def resolve_noise(
The keyword as given.
seed : int or None
The keyword ``seed`` as given.
- precision : {'double', 'single'}
- The run's arithmetic.
Returns
-------
@@ -509,9 +450,9 @@ def resolve_noise(
Examples
--------
>>> from solprop.driver.defaults import resolve_noise
- >>> resolve_noise("capillary", "default", None, "double")
+ >>> resolve_noise("capillary", "default", None)
('input', NoiseResolution(requested='default', resolved='input', reason=None))
- >>> resolve_noise("free", "default", None, "double")[1].reason[:20]
+ >>> resolve_noise("free", "default", None)[1].reason[:20]
'a Cartesian or line '
"""
row = route_defaults(route)
@@ -519,7 +460,7 @@ def resolve_noise(
msg = f"noise={noise!r}: {row.noise_reason}; pass noise=None"
raise DriverError(msg, parameter="noise")
if noise == "default":
- fallback = SINGLE_PRECISION_NOISE if precision == "single" else row.noise_reason
+ fallback = row.noise_reason
if fallback is not None:
if seed is not None:
msg = (
diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py
index 2bb55da31..dad614a4e 100644
--- a/src/solprop/driver/interface.py
+++ b/src/solprop/driver/interface.py
@@ -72,7 +72,6 @@
from solprop.stepper.runge_kutta import RungeKutta
from solprop.stepper.schedule import FixedSchedule
from solprop.translate.masks import AbsorberScheme
-from solprop.translate.precision import PrecisionName
from solprop.translate.responses import KerrForm
__all__ = [
@@ -485,7 +484,6 @@ def prop_gnlse(
output: Output | Path | str | None = None,
snapshots: SnapshotController | None = None,
backend: str = "auto",
- precision: PrecisionName = "double",
resume: bool | Literal["interpolated"] = False,
progress: ProgressReporter | ProgressChoice | None = None,
progress_period: float = DEFAULT_PROGRESS_PERIOD,
@@ -609,9 +607,7 @@ def prop_gnlse(
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``, or
- under ``precision="single"`` the floor
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR`, ``1e-6``. The map
+ ``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).
@@ -679,16 +675,6 @@ def prop_gnlse(
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.
- precision : {'double', 'single'}, optional
- The arithmetic the run is held in (SEP 33,
- ``docs/guide/performance/single-precision.md``). ``"double"``, the default, is
- binary64 throughout. ``"single"`` holds the state and the source evaluation in
- binary32: its default `rtol` is the floor
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR`, below which an explicit
- value is refused; its default integrator is ``RungeKutta()``; the default noise
- resolves to none, and the Adams method and an explicit noise model are refused.
- ``numpy_ref`` runs it as the reference; ``native`` and ``backend="auto"``
- refuse it.
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
@@ -792,7 +778,6 @@ def prop_gnlse(
absorber_scheme=absorber_scheme,
boundary_check=boundary_check,
backend=backend,
- precision=precision,
materials=materials,
)
logger.debug(
@@ -871,7 +856,6 @@ def prop_capillary(
polarisation_taper: bool = False,
node_budget: int | None = None,
backend: str = "auto",
- precision: PrecisionName = "double",
polarisation: str | float | None = None,
output: Output | Path | str | None = None,
snapshots: SnapshotController | None = None,
@@ -1006,7 +990,7 @@ def prop_capillary(
``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 (``method="cf4"`` on ``numpy_ref`` alone).
+ ``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
@@ -1117,9 +1101,8 @@ def prop_capillary(
Entropy of the noise generator.
rtol : float or None, optional
Relative step tolerance. ``None`` takes
- :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, ``1e-6``, in either
- precision (it equals the single-precision floor). Calibrate it for a production
- run (``docs/dev/stepper.md`` section 3).
+ :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`).
@@ -1166,16 +1149,6 @@ def prop_capillary(
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.
- precision : {'double', 'single'}, optional
- The arithmetic the run is held in (SEP 33,
- ``docs/guide/performance/single-precision.md``). ``"double"``, the default, is
- binary64 throughout. ``"single"`` holds the state and the source evaluation in
- binary32: its default `rtol` is the floor
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR`, below which an explicit
- value is refused; its default integrator is ``RungeKutta()``; the default noise
- resolves to none, and the Adams method and an explicit noise model are refused.
- ``numpy_ref`` runs it as the reference; ``native`` and ``backend="auto"``
- refuse it.
polarisation : str, float or None, optional
Refused; this path is scalar.
output : solprop.output.schema.Output, pathlib.Path, str or None, optional
@@ -1317,7 +1290,6 @@ def prop_capillary(
polarisation_taper=polarisation_taper,
node_budget=node_budget,
backend=backend,
- precision=precision,
polarisation=polarisation,
materials=materials,
tdse=tdse,
@@ -1405,7 +1377,6 @@ def prop_waveguide(
polarisation_taper: bool = False,
node_budget: int | None = None,
backend: str = "auto",
- precision: PrecisionName = "double",
polarisation: str | float | None = None,
taper_coupling: bool = True,
output: Output | Path | str | None = None,
@@ -1638,9 +1609,8 @@ def prop_waveguide(
Entropy of the noise generator.
rtol : float or None, optional
Relative step tolerance. ``None`` takes
- :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL`, ``1e-5``, in either
- precision. Calibrate it for a production run (``docs/dev/stepper.md``
- section 3).
+ :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`).
@@ -1685,16 +1655,6 @@ def prop_waveguide(
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.
- precision : {'double', 'single'}, optional
- The arithmetic the run is held in (SEP 33,
- ``docs/guide/performance/single-precision.md``). ``"double"``, the default, is
- binary64 throughout. ``"single"`` holds the state and the source evaluation in
- binary32: its default `rtol` stays this interface's ``1e-5``, above the floor
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR` below which an explicit
- value is refused; its default integrator is ``RungeKutta()``; the default noise
- resolves to none, and the Adams method and an explicit noise model are refused.
- ``numpy_ref`` runs it as the reference; ``native`` and ``backend="auto"``
- refuse it.
polarisation : str, float or None, optional
Refused; this path is scalar.
taper_coupling : bool, optional
@@ -1791,7 +1751,6 @@ def prop_waveguide(
polarisation_taper=polarisation_taper,
node_budget=node_budget,
backend=backend,
- precision=precision,
polarisation=polarisation,
taper_coupling=taper_coupling,
materials=materials,
diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py
index 391ddfedc..184e6773e 100644
--- a/src/solprop/driver/simulation.py
+++ b/src/solprop/driver/simulation.py
@@ -48,7 +48,7 @@
from solprop._display import Displayed, engineering, line, summarise
from solprop._ownership import borrow, frozen_copy, hold, hold_view
from solprop.adiabatic import AdiabaticAccuracy
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
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
@@ -85,7 +85,6 @@
FREE_RTOL,
INTERIOR_LAUNCH_NOISE,
PLANAR_NOISE,
- SINGLE_PRECISION_NOISE,
SLAB_NOISE,
SPATIAL_ATOL,
SPATIAL_RTOL,
@@ -301,7 +300,6 @@
from solprop.stepper.schedule import FixedSchedule
from solprop.stepper.tableau import DOPRI5
from solprop.translate.coupled import (
- CF4_GROWTH_LIMIT,
metric_at,
taper_identity,
wall_identity,
@@ -329,12 +327,6 @@
integration_record,
)
from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record
-from solprop.translate.precision import (
- Precision,
- PrecisionName,
- admit_precision,
- precision_named,
-)
from solprop.translate.problem import (
DEFAULT_COMPONENTS,
PropagationProblem,
@@ -379,7 +371,6 @@
"ORTHONORMALITY_TOLERANCE",
"PLANAR_NOISE",
"PLASMA_STATISTICS",
- "SINGLE_PRECISION_NOISE",
"SLAB_NOISE",
"SPATIAL_ATOL",
"SPATIAL_RTOL",
@@ -522,30 +513,6 @@ class _Defaulted(str):
__slots__ = ()
-class _AutoBackend(str):
- """A backend name ``"auto"`` resolved to, with the precision it resolved under.
-
- A copy keeps the concrete name, so a scan worker runs on the backend its parent
- chose even where its own packages differ; only a copy in the other precision
- resolves ``"auto"`` again, since binary64 and binary32 admit different backends.
- """
-
- precision: str
-
- def __new__(cls, name: str, precision: str) -> "_AutoBackend":
- """Return the name, marked with the precision it was resolved under."""
- marked = super().__new__(cls, name)
- marked.precision = precision
- return marked
-
- def __reduce__(self) -> tuple[object, tuple[str, str]]:
- """Pickle the name with its precision, for a scan worker.
-
- - Returns: the class and its arguments.
- """
- return (_AutoBackend, (str(self), self.precision))
-
-
if TYPE_CHECKING:
from solprop.calibration.refine import RefinementReport
from solprop.driver.estimate import RunEstimate
@@ -592,12 +559,9 @@ class SimulationSettings(Displayed):
: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`;
- under single precision the tolerance is at least
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR` and at most
- :data:`~solprop.translate.precision.SINGLE_RTOL_CAP`. An explicit controller
- always wins, and a default copied into the other precision takes that
- precision's default. The other fields are keyword-only.
+ :data:`~solprop.driver.defaults.FREE_ATOL` of :meth:`Simulation.from_free`.
+ An explicit controller always wins, and settings that already belong to a
+ route keep its 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
@@ -634,13 +598,11 @@ class SimulationSettings(Displayed):
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()`` under double precision, 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``),
- and ``RungeKutta()`` under single precision, which refuses the Adams method
- until its single-precision survival is established (SEP 33, Q2); the stored
- value is the resolved method. ``Multistep()`` is the low-call exponential Adams
+ ``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;
@@ -714,22 +676,9 @@ class SimulationSettings(Displayed):
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; under single precision it
- is refused (`precision`).
+ 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.
- precision : {'double', 'single'}
- The arithmetic the run is held in (SEP 33,
- ``docs/guide/performance/single-precision.md``). ``"double"``, the default, is
- binary64 throughout and changes nothing.
- ``"single"`` holds the state, the stages, the transforms and the responses in
- binary32, with the controller and every host scalar in binary64. It admits
- ``backend="numpy_ref"`` (the single-precision oracle) only; refuses an
- adaptive ``rtol`` below
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR`, the Adams integrator
- and an explicit noise model (a default noise resolves to none); and is
- refused by ``native``. ``backend="auto"`` is refused: name ``"numpy_ref"``.
- Part of the hashed configuration; outputs are binary64 either way.
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
@@ -737,8 +686,7 @@ class SimulationSettings(Displayed):
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; a march
- that cannot (the general path of a comparison method) restarts it and warns.
+ 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
@@ -833,7 +781,6 @@ class SimulationSettings(Displayed):
polarisation_taper: bool = False
backend: str = "auto"
integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None
- precision: PrecisionName = "double"
resume_history: bool | None = None
tdse_execution: TDSEExecution | None = None
fields: FieldsChoice = "all"
@@ -851,8 +798,8 @@ class SimulationSettings(Displayed):
_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 in the other precision takes the
- route's defaults again, and the route's choice never counts as named."""
+ :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
)
@@ -965,18 +912,7 @@ def __post_init__(self) -> 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))
- precision = _precision(self.precision)
- self._resolve_defaults(precision)
- # A backend "auto" chose is marked, so a copy in the other precision chooses
- # again rather than keeping a binary64 name single precision refuses.
- name = "single" if precision.single else "double"
- auto = self.backend == "auto" or (
- isinstance(self.backend, _AutoBackend) and self.backend.precision != name
- )
- if auto:
- object.__setattr__(self, "backend", "auto")
- if precision.single:
- object.__setattr__(self, "backend", _single_backend(self.backend))
+ self._resolve_defaults()
if self.fields == "planes" and self.step_planes:
msg = (
"fields='planes' names saved planes by index, and step_planes saves "
@@ -989,7 +925,7 @@ def __post_init__(self) -> None:
rule="fields-planes-step-planes",
)
if self.tdse_execution is not None:
- _admit_tdse_execution(self, precision)
+ _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(
@@ -1007,24 +943,8 @@ def __post_init__(self) -> None:
# 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).
- backend = resolve_name(self.backend)
- if auto:
- backend = _AutoBackend(backend, name)
- object.__setattr__(self, "backend", backend)
- # The precision's refusals come before the integrator's backend check, so a
- # single-precision run is told what single precision refuses rather than
- # what its backend does not step.
- try:
- admit_precision(
- precision,
- backend=self.backend,
- rtol=getattr(self.controller, "rtol", None),
- schedule=self.schedule is not None,
- )
- except TranslationError as error:
- raise DriverError(str(error), parameter="precision") from error
+ 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 = (
@@ -1035,15 +955,14 @@ def __post_init__(self) -> None:
)
warnings.warn(msg, SolpropWarning, stacklevel=3)
- def _resolve_defaults(self, precision: Precision) -> None:
+ 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 precision,
- route and planes; a value the caller gave is kept.
+ carried by a copy is resolved again, so it follows the copy's route and
+ planes; a value the caller gave is kept.
"""
- name = "single" if precision.single else "double"
carried = (
self.integrator is not None and self.integrator is self._default_integrator
)
@@ -1051,7 +970,7 @@ def _resolve_defaults(self, precision: Precision) -> None:
self, "integrator_named", self.integrator is not None and not carried
)
if not self.integrator_named:
- default = default_integrator(self._route, name)
+ default = default_integrator(self._route)
if carried and type(self.integrator) is type(default):
default = self.integrator
object.__setattr__(self, "integrator", default)
@@ -1059,18 +978,8 @@ def _resolve_defaults(self, precision: Precision) -> None:
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, name)
+ controller = default_controller(self._route)
defaulted = True
- elif defaulted and controller is not None:
- # A default copied into another precision or route takes that one's
- # tolerance, keeping the absolute tolerance the route's builder chose.
- rtol = (
- default_rtol(self._route, name)
- 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:
@@ -1138,11 +1047,11 @@ def defaults_resolution(self) -> DefaultsResolution:
return DefaultsResolution(self._route, defaulted)
-def _admit_tdse_execution(settings: SimulationSettings, precision: Precision) -> None:
+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 precision, then the backend, then the integrator.
+ the backend, then the integrator.
"""
if not isinstance(settings.tdse_execution, TDSEExecution):
msg = (
@@ -1150,14 +1059,6 @@ def _admit_tdse_execution(settings: SimulationSettings, precision: Precision) ->
f"{type(settings.tdse_execution).__name__}"
)
raise DriverError(msg, parameter="tdse_execution")
- if precision.single:
- msg = (
- "precision='single' refuses a TDSE response: no backend runs it in "
- "binary32 (SEP 33 section 5.1); use precision='double'"
- )
- raise DriverError(
- msg, parameter=("tdse_execution", "precision"), rule="tdse-double-precision"
- )
if settings.backend != "numpy_ref":
msg = (
f"tdse_execution requires an explicit backend='numpy_ref', the one backend "
@@ -1186,7 +1087,7 @@ def _quiet_collar(settings: SimulationSettings) -> bool:
def _keyword_controller(
- route: str, rtol: float | None, atol: float, precision: PrecisionName
+ 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.
@@ -1195,41 +1096,14 @@ def _keyword_controller(
- ``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 and a copy in the other precision
- re-resolves it, else ``None``.
+ 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, precision), atol=atol)
+ controller = PIController(rtol=default_rtol(route), atol=atol)
return controller, controller
-def _precision(name: PrecisionName) -> Precision:
- """Return the record of a public precision value, refusing an unknown one."""
- if name not in ("double", "single"): # pragma: no cover - refused by its annotation
- msg = f"precision must be 'double' or 'single', got {name!r}"
- raise DriverError(msg, parameter="precision")
- return precision_named(name)
-
-
-def _single_backend(backend: str) -> str:
- """Refuse ``"auto"`` for a single-precision run (SEP 33, Q8; SEP 47).
-
- No installed backend runs single precision quickly, so ``"auto"`` is refused: it
- never resolves to a binary64 backend, and never to ``numpy_ref``, which runs single
- precision when it is named. An explicit name passes through to
- :func:`~solprop.translate.precision.admit_precision`.
- """
- if backend != "auto":
- return backend
- msg = (
- "precision='single' runs on the reference only, and backend='auto' does not "
- "choose it: pass backend='numpy_ref', which runs single precision unthreaded "
- "and far slower, or use precision='double'"
- )
- raise DriverError(msg, parameter=("precision", "backend"))
-
-
def _stepper_record(settings: StepperSettings) -> StepperRecord:
"""Return the run's stepper record, naming an Adams integrator where one steps it.
@@ -1293,25 +1167,6 @@ def _admit_low_storage(
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 metadata_for(
problem: PropagationProblem,
request: RunRequest,
@@ -1884,8 +1739,7 @@ class Simulation(Displayed):
``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 (a
- ``method="cf4"`` wall ``numpy_ref`` alone).
+ ``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
@@ -2300,7 +2154,6 @@ def translate(self) -> PropagationProblem:
settings.spatial_absorber, self.spatial_grid
),
adiabatic_accuracy=settings.adiabatic_accuracy,
- precision=precision_named(settings.precision),
)
return self._problem
@@ -2902,9 +2755,6 @@ def _record(self, initial: object) -> RunMetadata:
"absorber": self.settings.absorber,
"absorber_scheme": _scheme(self.settings),
"propagator_mode": self.settings.propagator_mode,
- # Every run records its precision, double included (SEP 33
- # section 5.3, ADR-051), so a resume in the other is refused.
- "precision": self.settings.precision,
}
)
if self.frame is not None:
@@ -3087,22 +2937,11 @@ def _modal_identity(self) -> Mapping[str, JsonValue] | None:
validity_limit=self.wall.validity_limit,
impedance=self.wall.impedance,
)
- cf4 = self.wall.method == "cf4"
return wall_identity(
flow,
limits,
channels=[mode.channel_label for mode in self.modes],
impedance=self.wall.impedance,
- growth_limit=(
- CF4_GROWTH_LIMIT
- if self.wall.growth_limit is None
- else self.wall.growth_limit
- )
- if cf4
- else None,
- fallback=self.wall.fallback,
- dz_max=float(self._run_request().stepper.dz_max) if cf4 else None,
- kinks=self.translate().linear.hard_kinks,
scored=self.translate().grids.inband_mask,
n_components=self.translate().transform.n_pol,
has_noise=self.noise is not None,
@@ -4318,7 +4157,6 @@ def _from_spatial(
"radial" if isinstance(space, RadialGrid) else "free",
noise,
seed,
- settings.precision,
)
simulation.noise_resolution = resolution
if noise_model is not None:
@@ -4374,7 +4212,6 @@ def from_gnlse(
absorber_scheme: AbsorberScheme | None = None,
boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK,
backend: str = "auto",
- precision: PrecisionName = "double",
materials: MaterialSelection | None = None,
) -> "Simulation":
"""Build a simulation of a GNLSE fibre from its coefficients.
@@ -4521,13 +4358,6 @@ 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.
- precision : {'double', 'single'}, optional
- The arithmetic the run is held in (SEP 33): ``"double"``, the default, or
- ``"single"``, which takes ``rtol`` at the floor
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR` by default and
- ``RungeKutta()``, carries no noise, and runs on ``numpy_ref``, the
- reference, with ``backend="auto"`` refused
- (:attr:`SimulationSettings.precision`).
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
@@ -4594,7 +4424,7 @@ def from_gnlse(
pulses=pulses,
)
boundary, scheme = _boundary(absorber, absorber_scheme, working)
- controller, default = _keyword_controller("gnlse", rtol, atol, precision)
+ controller, default = _keyword_controller("gnlse", rtol, atol)
settings = SimulationSettings(
controller=controller,
_default_controller=default,
@@ -4618,9 +4448,8 @@ def from_gnlse(
boundary_check=boundary_check,
polarisation_taper=polarisation_taper,
backend=backend,
- precision=precision,
)
- noise_model, resolution = resolve_noise("gnlse", noise, seed, precision)
+ noise_model, resolution = resolve_noise("gnlse", noise, seed)
draw = functools.partial(_shot_noise, noise_model, working)
simulation = cls(
working,
@@ -4707,7 +4536,6 @@ def from_capillary(
polarisation_taper: bool = False,
node_budget: int | None = None,
backend: str = "auto",
- precision: PrecisionName = "double",
polarisation: str | float | None = None,
materials: MaterialSelection | None = None,
tdse: TDSEGasResponse | None = None,
@@ -4836,11 +4664,10 @@ def from_capillary(
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 (a
- ``method="cf4"`` wall ``numpy_ref`` alone); 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).
+ 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
@@ -5004,13 +4831,6 @@ def from_capillary(
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.
- precision : {'double', 'single'}, optional
- The arithmetic the run is held in (SEP 33): ``"double"``, the default, or
- ``"single"``, which takes ``rtol`` at the floor
- :data:`~solprop.translate.precision.SINGLE_RTOL_FLOOR` by default and
- ``RungeKutta()``, carries no noise, and runs on ``numpy_ref``, the
- reference, with ``backend="auto"`` refused
- (:attr:`SimulationSettings.precision`).
polarisation : str, float or None, optional
Refused; this path is scalar.
materials : solprop.physdata.selection.MaterialSelection or None, optional
@@ -5121,7 +4941,6 @@ def from_capillary(
polarisation_taper=polarisation_taper,
node_budget=node_budget,
backend=backend,
- precision=precision,
polarisation=polarisation,
materials=MaterialSelection() if materials is None else materials,
tdse=tdse,
@@ -5197,7 +5016,6 @@ def from_waveguide(
polarisation_taper: bool = False,
node_budget: int | None = None,
backend: str = "auto",
- precision: PrecisionName = "double",
polarisation: str | float | None = None,
taper_coupling: bool = True,
materials: MaterialSelection | None = None,
@@ -5532,13 +5350,6 @@ def from_waveguide(
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.
- precision : {'double', 'single'}, optional
- The arithmetic the run is held in (SEP 33): ``"double"``, the default, or
- ``"single"``, which keeps ``rtol`` at
- :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL` by default
- (``docs/guide/performance/single-precision.md``), takes ``RungeKutta()``,
- carries no noise, and runs on ``numpy_ref``, the reference, with
- ``backend="auto"`` refused (:attr:`SimulationSettings.precision`).
polarisation : str, float or None, optional
Refused; this path is scalar.
taper_coupling : bool, optional
@@ -5628,7 +5439,6 @@ def from_waveguide(
polarisation_taper=polarisation_taper,
node_budget=node_budget,
backend=backend,
- precision=precision,
polarisation=polarisation,
taper_coupling=taper_coupling,
materials=MaterialSelection() if materials is None else materials,
@@ -5710,7 +5520,6 @@ def from_waveguide(
polarisation_taper=polarisation_taper,
node_budget=node_budget,
backend=backend,
- precision=precision,
polarisation=polarisation,
taper_coupling=taper_coupling,
materials=MaterialSelection() if materials is None else materials,
@@ -5754,7 +5563,6 @@ def _from_general(
n_save: int | None,
stats: StatsSpec | None,
backend: str,
- precision: PrecisionName,
materials: MaterialSelection | None = None,
integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None,
launch: ModalLaunch | None = None,
@@ -5905,7 +5713,7 @@ def _from_general(
metric=metric_at(route.metric_tables, 0.0),
)
boundary, scheme = _boundary(absorber, absorber_scheme, working)
- controller, default = _keyword_controller("waveguide", rtol, atol, precision)
+ controller, default = _keyword_controller("waveguide", rtol, atol)
settings = SimulationSettings(
controller=controller,
_default_controller=default,
@@ -5928,9 +5736,8 @@ def _from_general(
stats=_general_stats() if stats is None else stats,
polarisation_taper=polarisation_taper,
backend=backend,
- precision=precision,
)
- noise_model, resolution = resolve_noise("waveguide", noise, seed, precision)
+ noise_model, resolution = resolve_noise("waveguide", noise, seed)
draw = functools.partial(_general_noise, noise_model, working, guide=provider)
simulation = cls(
working,
@@ -6028,7 +5835,6 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface
polarisation_taper: bool = False,
node_budget: int | None = None,
backend: str = "auto",
- precision: PrecisionName = "double",
polarisation: str | float | None = None,
materials: MaterialSelection | None = None,
tdse: TDSEGasResponse | None = None,
@@ -6114,10 +5920,9 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface
``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 (a ``method="cf4"`` wall ``numpy_ref`` alone); 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).
+ 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
@@ -6273,7 +6078,6 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface
)
_check_wall_declaration(
wall,
- backend=backend,
model=model,
loss=loss,
lattice=lattice,
@@ -6459,7 +6263,7 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface
if atomic is not None
else route
)
- controller, default = _keyword_controller(row, rtol, atol, precision)
+ controller, default = _keyword_controller(row, rtol, atol)
settings = SimulationSettings(
controller=controller,
_default_controller=default,
@@ -6509,10 +6313,9 @@ def _from_hollow( # noqa: PLR0915 - the interface's keyword surface
),
polarisation_taper=polarisation_taper,
backend=backend,
- precision=precision,
tdse_execution=None if atomic is None else atomic.execution,
)
- noise_model, resolution = resolve_noise(row, noise, seed, precision)
+ noise_model, resolution = resolve_noise(row, noise, seed)
draw = functools.partial(
_shot_noise, noise_model, working, n_modes=len(mode_set)
)
@@ -7334,17 +7137,9 @@ def _undamped_model_key(response: RamanResponse) -> object:
return models
-_CF4_BACKEND: Final = "numpy_ref"
-"""The one backend that executes a ``"cf4"`` section of the flow.
-
-CF4 is not yet implemented on the native backend, which refuses such a section.
-"""
-
-
def _check_wall_declaration(
wall: CoherentWall | None,
*,
- backend: str,
model: MarcatiliModel,
loss: LossSpec,
lattice: TubeLattice | None,
@@ -7373,22 +7168,6 @@ def _check_wall_declaration(
raise DriverError(
msg, parameter=("wall", "taper_coupling"), rule="wall-keeps-taper-coupling"
)
- resolved = resolve_name(backend)
- selected = f"{backend!r}" if backend == resolved else f"{backend!r} ({resolved})"
- if wall.method == "cf4" and resolved != _CF4_BACKEND:
- msg = (
- f"a coherent wall with method='cf4' integrates each section by the "
- f"commutator-free exponentials of its whole generator, which the "
- f"{_CF4_BACKEND} backend alone executes, and this run selects "
- f"{selected}: the native backend executes the default "
- f"method='frozen_residual' and refuses a CF4 section by name: CF4 is not "
- "yet implemented there. Pass "
- f"backend={_CF4_BACKEND!r} with method='cf4', or leave the method at its "
- "default"
- )
- raise DriverError(
- msg, parameter=("wall.method", "backend"), rule="wall-cf4-numpy-ref"
- )
if loss is not True:
msg = (
f"a coherent wall block is the loss model, and this run declares "
@@ -8456,8 +8235,8 @@ def _spatial_settings(settings: SimulationSettings, route: str) -> SimulationSet
- 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, in
- the run's precision), the integrator (``RungeKutta()`` in free space, whose Adams
+ 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
diff --git a/src/solprop/driver/summary.py b/src/solprop/driver/summary.py
index f72f2bef1..637788767 100644
--- a/src/solprop/driver/summary.py
+++ b/src/solprop/driver/summary.py
@@ -184,7 +184,6 @@ def run_summary(
("machine", _machine(machine())),
("backend", backend),
("threads", _threads(backend, execution)),
- ("precision", _precision(problem)),
("grid", _grid(metadata)),
("representation", _representation(problem, metadata)),
("medium", _medium(configuration)),
@@ -349,15 +348,6 @@ def _threads(backend: str, execution: Mapping[str, object]) -> str:
return limit
-def _precision(problem: PropagationProblem) -> str:
- """Describe the floating-point precision of the run.
-
- - Returns: ``"double"`` or ``"single"``.
- """
- # Translation admits no mixed precision, so the state's name is the run's.
- return problem.precision.state
-
-
def _grid(metadata: RunMetadata) -> str:
"""Describe the time grid: kind, points, window and wavelength band.
@@ -556,7 +546,7 @@ def _noise(metadata: RunMetadata) -> str:
gives its reason (SEP 37), so that a noiseless run is never silently noiseless.
- Returns: for example ``"input model (default), seed 3"``, ``"off"``, or ``"off
- (default: single precision carries no noise: ...)"``.
+ (default: the hybrid planar grid carries no noise: ...)"``.
"""
noise = metadata.noise
resolution = metadata.noise_resolution
diff --git a/src/solprop/modes/capillary.py b/src/solprop/modes/capillary.py
index 93c1d13a1..768fff41e 100644
--- a/src/solprop/modes/capillary.py
+++ b/src/solprop/modes/capillary.py
@@ -1562,8 +1562,7 @@ class CoherentWall(Displayed):
run. It carries the default ``"input"`` noise, drawn in the channel basis at the
entrance and propagated like any field, and refuses the ``"modified"`` model, whose
transport through a nonunitary coupled flow is not measured. ``numpy_ref`` and
- ``native`` execute the ``linear.coupled_flow`` leaf it becomes (``method="cf4"`` on
- ``numpy_ref`` alone).
+ ``native`` execute the ``linear.coupled_flow`` leaf it becomes.
**The vector boundary response** is ``impedance="te_tm"``. The scalar
block above condenses one *impedance-averaged* Robin condition, which is what a
@@ -1582,7 +1581,7 @@ class CoherentWall(Displayed):
their own impedances. It names the **wall's** two impedances and not the state's
polarisation axis: a ``"te_tm"`` run still carries one transverse component per
channel. On a guide that varies along z -- a taper, a pressure gradient -- the
- vector block takes the same frozen or CF4 route as the scalar one, with a
+ vector block takes the same frozen-residual route as the scalar one, with a
connection that is each radial family's own and nothing between the families
(:func:`wall_connection`). Which families the run carries is the **mode set's**
business and not this field's: a set retaining one hybrid family alone is a
@@ -1615,36 +1614,11 @@ class CoherentWall(Displayed):
discard the residual. It has no effect on a constant guide, whose residual is
exactly zero and which takes the single-section constant-Schur flow whatever
this says.
- method : str
- How a guide that varies along z is integrated. ``"frozen_residual"``, the
- default, is the frozen-residual split: each section's
- generator frozen inside the exponential and its variation in the explicit
- residual the Runge-Kutta source carries, fifth order. ``"cf4"`` integrates the
- **whole** section generator by the fourth-order commutator-free exponential,
- with no explicit residual and so no advisory step, but at most **fourth**
- order and with a flow error the embedded pair does not see
- (:func:`solprop.translate.coupled.cf4_flow`). It is admitted only behind two
- guards: the internal-growth bound of the CF4 factors against `growth_limit`
- and the pair
- conditioning against `condition_limit`, for either impedance. It is refused
- by name on a guide that does not vary at all, whose flow the constant-Schur
- action already integrates exactly.
- growth_limit : float or None
- Largest admitted internal-growth bound of a CF4 section, dimensionless and at
- least one; ``None`` takes
- :data:`solprop.translate.coupled.CF4_GROWTH_LIMIT`. Declaring it without
- ``method="cf4"`` is refused, because nothing would read it.
- fallback : str or None
- What a section the CF4 guards reject becomes: ``None`` refuses it by name,
- ``"frozen_residual"`` integrates it with the frozen-residual split and warns
- naming it.
- Declaring it without ``method="cf4"`` is refused.
Raises
------
ModeError
- If a limit or the section count is out of range, or if a method, a growth
- limit or a fallback is declared where nothing would honour it.
+ If a limit or the section count is out of range.
Examples
--------
@@ -1659,9 +1633,6 @@ class CoherentWall(Displayed):
validity_limit: float = 0.25
sections: int = 1
impedance: WallImpedance = "mean"
- method: Literal["frozen_residual", "cf4"] = "frozen_residual"
- growth_limit: float | None = None
- fallback: Literal["frozen_residual"] | None = None
def __post_init__(self) -> None:
"""Check the wall impedance, both limits and the section count.
@@ -1707,42 +1678,6 @@ def __post_init__(self) -> None:
f"{self.validity_limit!r}"
)
raise ModeError(msg)
- self._check_method()
-
- def _check_method(self) -> None:
- """Check the varying-section method and the two declarations only CF4 reads."""
- if self.method not in ("frozen_residual", "cf4"):
- msg = (
- "CoherentWall.method is 'frozen_residual' or 'cf4' for a guide that "
- f"varies along z, got {self.method!r}"
- )
- raise ModeError(msg)
- if self.method != "cf4" and (
- self.growth_limit is not None or self.fallback is not None
- ):
- msg = (
- "CoherentWall.growth_limit and fallback are the CF4 guards' and are "
- f"read only with method='cf4'; this declares method={self.method!r} "
- f"with growth_limit={self.growth_limit!r} and "
- f"fallback={self.fallback!r}"
- )
- raise ModeError(msg)
- if self.fallback not in (None, "frozen_residual"):
- msg = (
- "CoherentWall.fallback is None, which refuses a section the CF4 "
- "guards reject, or 'frozen_residual', which integrates it with the "
- f"frozen split; got {self.fallback!r}"
- )
- raise ModeError(msg)
- if self.growth_limit is not None and not (
- math.isfinite(self.growth_limit) and self.growth_limit >= 1.0
- ):
- msg = (
- "CoherentWall.growth_limit bounds the product of CF4's internal "
- "factors against the exact action's and is at least one, got "
- f"{self.growth_limit!r}"
- )
- raise ModeError(msg)
def __repr__(self) -> str:
"""Return a one-line description.
@@ -1751,8 +1686,7 @@ def __repr__(self) -> str:
-------
str
For example ``CoherentWall(impedance=mean, condition_limit=10000,
- validity_limit=0.25, sections=1)``; a CF4 wall adds its method, growth
- limit and fallback, which the default frozen split does not carry.
+ validity_limit=0.25, sections=1)``.
"""
rows = [
("impedance", self.impedance),
@@ -1760,13 +1694,6 @@ def __repr__(self) -> str:
("validity_limit", f"{self.validity_limit:g}"),
("sections", f"{self.sections:d}"),
]
- if self.method == "cf4":
- limit = "default" if self.growth_limit is None else f"{self.growth_limit:g}"
- rows += [
- ("method", self.method),
- ("growth_limit", limit),
- ("fallback", str(self.fallback)),
- ]
return line("CoherentWall", rows)
def _describe(self) -> tuple[str, list[tuple[str, object]]]:
@@ -1779,24 +1706,14 @@ def _describe(self) -> tuple[str, list[tuple[str, object]]]:
("condition limit", f"{self.condition_limit:g}"),
("validity limit", f"{self.validity_limit:g}"),
("sections per interval", f"{self.sections:d}"),
- ("varying method", self._method_text()),
("model", self._model_text()),
]
def _model_text(self) -> str:
"""Return the printed one-line model the declaration builds."""
wall = "TE/TM impedances" if self.impedance == "te_tm" else "mean impedance"
- if self.method == "cf4":
- return f"{wall}, paraxial, commutator-free per section"
return f"{wall}, paraxial, frozen per section"
- def _method_text(self) -> str:
- """Return the printed varying-section method with the CF4 guards it declares."""
- if self.method != "cf4":
- return self.method
- limit = "default" if self.growth_limit is None else f"{self.growth_limit:g}"
- return f"cf4, growth limit {limit}, fallback {self.fallback}"
-
@dataclass(frozen=True, eq=False)
class WallLimits(Displayed):
diff --git a/src/solprop/monitor.py b/src/solprop/monitor.py
index b62d80dd2..8e04a7af1 100644
--- a/src/solprop/monitor.py
+++ b/src/solprop/monitor.py
@@ -509,7 +509,7 @@ def power(
) -> Float[np.ndarray, " n_t"]:
"""Return the time-domain energy density summed over everything but time.
- The host reduction, in binary64 whatever the state's precision.
+ The host reduction, in binary64.
Parameters
----------
@@ -1026,7 +1026,7 @@ def watch_policy(self) -> dict[str, object]:
Returns
-------
dict
- The fields of ``solprop_native.WATCH_POLICY_FIELDS``.
+ The numbers above by name, ``split`` to ``bucket_limits``.
"""
check = self.check
crossing = self.crossing
@@ -1064,9 +1064,9 @@ def watch_state(self) -> dict[str, object]:
Returns
-------
dict
- The fields of ``solprop_native.WATCH_STATE_FIELDS``: whether a check has
- been made and where, the steps since it, the cadence's busy flag, the
- quiet run and flag, whether the warning was given, and the crossing cap.
+ Whether a check has been made and where, the steps since it, the cadence's
+ busy flag, the quiet run and flag, whether the warning was given, and the
+ crossing cap.
"""
last = self._last
return {
diff --git a/src/solprop/output/resumable.py b/src/solprop/output/resumable.py
index d8dc4f4de..a0b4e97a8 100644
--- a/src/solprop/output/resumable.py
+++ b/src/solprop/output/resumable.py
@@ -125,7 +125,7 @@ def _unusable_state(state: ResumeState) -> str | None:
# A resume under Multistep installs the charged ledger and refuses one it
# cannot read, such as the flat orders of an earlier development build of
# this schema (#80): the answer says so before a resume is tried.
- from solprop.backends.multistep import CallLedger # noqa: PLC0415 - heavy
+ from solprop.backends.ledger import CallLedger # noqa: PLC0415 - heavy
try:
CallLedger.from_record(state.outcome.logical_calls)
diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py
index a2e3cb818..c04e5d619 100644
--- a/src/solprop/output/schema.py
+++ b/src/solprop/output/schema.py
@@ -802,8 +802,8 @@ class BackendRecord:
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 (:data:`solprop.backends.registry.REMOVED_BACKENDS`): an old
- file's record reads as it was written, in its backend's own vocabulary.
+ 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
----------
@@ -1316,8 +1316,8 @@ class NoiseResolution:
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 single precision carries
- none; ``None`` otherwise.
+ Why a default resolved to no noise, for example that the hybrid planar grid
+ declares no photon measure; ``None`` otherwise.
Examples
--------
diff --git a/src/solprop/processing/coherence.py b/src/solprop/processing/coherence.py
index b1a5185d2..44c47d97f 100644
--- a/src/solprop/processing/coherence.py
+++ b/src/solprop/processing/coherence.py
@@ -211,7 +211,7 @@ def band_coherence(
:func:`spectral_bands` across the reference mean spectrum's occupied span. A band
whose mean spectral energy is below `db_min` of the largest band's is not
scored. The reference selects the bands and weighs them, so two ensembles judged
- against one reference (for example two precisions or two solvers with the same
+ against one reference (for example two backends or two solvers with the same
seeds) are averaged over the same bins with the same weights. The reference's own
band coherence is ``band_coherence(reference, reference)``.
diff --git a/src/solprop/recipe/core.py b/src/solprop/recipe/core.py
index 6fd8ba200..54cedb595 100644
--- a/src/solprop/recipe/core.py
+++ b/src/solprop/recipe/core.py
@@ -47,7 +47,13 @@
)
from solprop.recipe.paths import format_path, get_path, parse_path, set_path
from solprop.recipe.quantity import Unit, format_quantity, parse_quantity
-from solprop.recipe.registry import REGISTRY, BuildContext, coerce, decode_keyword
+from solprop.recipe.registry import (
+ REGISTRY,
+ BuildContext,
+ coerce,
+ decode_keyword,
+ retired,
+)
if TYPE_CHECKING:
from numpy.typing import ArrayLike
@@ -173,6 +179,10 @@ def __init__(
) -> None:
"""Adopt a checked document."""
self._document: Document = validate_document(document)
+ build = self._document.get("build", {})
+ for key in [key for key in build if retired("build", key, build[key], key)]:
+ # A retired key whose value is what solprop does anyway is dropped.
+ del build[key]
self._text = text
self._base = base
self._allow_outside = allow_outside
diff --git a/src/solprop/recipe/examples/single_precision.toml b/src/solprop/recipe/examples/single_precision.toml
deleted file mode 100644
index 104097174..000000000
--- a/src/solprop/recipe/examples/single_precision.toml
+++ /dev/null
@@ -1,28 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-# The ionisation blue-shift capillary's first 0.2 m on a field grid in double
-# precision, the first of the script's two precisions.
-# mirrors: docs/examples/performance/plot_01_single_precision.py
-
-[recipe]
-format = 1
-route = "capillary"
-title = "Plasma capillary in double precision"
-
-[build]
-radius = "13 um"
-length = "0.2 m"
-gas = "Ar"
-pressure = "1.7 bar"
-wavelength = "800 nm"
-fwhm = "30 fs"
-energy = "3 uJ"
-loss = false
-trange = "600 fs"
-wavelength_limits = ["300 nm", "4000 nm"]
-plasma = true
-n_save = 51
-precision = "double"
-noise = "none"
diff --git a/src/solprop/recipe/metadata.py b/src/solprop/recipe/metadata.py
index dbcabbf0b..7223b3c0a 100644
--- a/src/solprop/recipe/metadata.py
+++ b/src/solprop/recipe/metadata.py
@@ -252,19 +252,6 @@ class Parameter:
# ------------------------------------------------------------------- the resolvers
-def _precision(values: Mapping[str, object]) -> str:
- """Return the precision a recipe asks for.
-
- - ``values``: the recipe's values; on an object route the precision is a keyword of
- its ``settings`` table.
- - Returns: ``"double"`` unless the recipe sets ``"single"``.
- """
- settings = values.get("settings")
- if isinstance(settings, Mapping):
- values = settings
- return "single" if values.get("precision") == "single" else "double"
-
-
_SLAB_TYPES: Final = ("SlabMode", "ModeCollection.slab")
"""The registered types that make a waveguide's mode set a slab set."""
@@ -343,13 +330,11 @@ 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 in the recipe's precision."""
+ """Return the route's default."""
row = _row(route, values)
- precision = _precision(values)
return Resolution(
- default_rtol(row, precision),
- f"the {row} route's default in {precision} precision "
- "(solprop.driver.defaults.ROUTE_DEFAULTS)",
+ default_rtol(row),
+ f"the {row} route's default (solprop.driver.defaults.ROUTE_DEFAULTS)",
)
return resolve
@@ -359,15 +344,9 @@ 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 in the recipe's precision."""
+ """Return the route's default method."""
row = _row(route, values)
- precision = _precision(values)
- rule = (
- "single precision steps with RungeKutta()"
- if precision == "single"
- else f"the {row} route's default"
- )
- return Resolution(default_integrator(row, precision), rule)
+ return Resolution(default_integrator(row), f"the {row} route's default")
return resolve
@@ -381,7 +360,7 @@ def resolve(values: Mapping[str, object]) -> Resolution:
- Returns: the model (``None`` for none) and the reason.
"""
row = _row(route, values)
- model, resolution = resolve_noise(row, "default", None, _precision(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"
@@ -511,7 +490,6 @@ def resolve(values: Mapping[str, object]) -> Resolution:
_SAVE_PLANES: Final = Form("array", unit="m", minimum=0.0, exclusive=True)
_PHASES: Final = Form("array", elements="phases")
_SHAPES: Final = Form("choice", choices=("gauss", "sech"))
-_PRECISIONS: Final = Form("choice", choices=("double", "single"))
_BACKENDS: Final = Form("string")
_NOISE: Final = Form("choice", choices=("default", "input", "modified"))
_SEED: Final = Form("integer", minimum=0)
@@ -523,7 +501,6 @@ def resolve(values: Mapping[str, object]) -> Resolution:
_NOISE_PAGE: Final = "guide/media/noise.md"
_OUTPUT_PAGE: Final = "guide/results/output.md"
_BACKEND_PAGE: Final = "guide/performance/backends.md"
-_PRECISION_PAGE: Final = "guide/performance/single-precision.md"
_MATERIALS_PAGE: Final = "guide/media/material-selection.md"
_PLANES_PAGE: Final = "guide/numerics/solver.md"
@@ -789,14 +766,6 @@ def _shared(route: str) -> tuple[Parameter, ...]:
"The backend that executes the run; auto picks the fastest installed.",
_BACKEND_PAGE,
),
- Parameter(
- "precision",
- (_PRECISIONS,),
- "numerics",
- "advanced",
- "The arithmetic the run is held in.",
- _PRECISION_PAGE,
- ),
Parameter(
"materials",
(_MATERIALS,),
@@ -2451,24 +2420,6 @@ def _plasma(*extra: Row) -> tuple[Parameter, ...]:
"advanced",
"The impedance-averaged block or the coupled azimuthal one.",
),
- _keyword(
- "method",
- _choice("frozen_residual", "cf4"),
- "advanced",
- "How a guide that varies along z is integrated.",
- ),
- _keyword(
- "growth_limit",
- _number(minimum=1.0),
- "advanced",
- "Largest admitted internal growth of a CF4 section.",
- ),
- _keyword(
- "fallback",
- _choice("frozen_residual"),
- "advanced",
- "What a section the CF4 guards reject becomes; the default refuses it.",
- ),
),
"MarcatiliMode": _type(
"geometry",
@@ -3048,12 +2999,6 @@ def _plasma(*extra: Row) -> tuple[Parameter, ...]:
"advanced",
"The method that steps the run.",
),
- _keyword(
- "precision",
- _choice("double", "single"),
- "advanced",
- "The arithmetic the run is held in.",
- ),
_keyword(
"evanescent_rate",
_number("m^-1", minimum=0.0, exclusive=True),
diff --git a/src/solprop/recipe/registry.py b/src/solprop/recipe/registry.py
index f0be7caf0..3a3451f72 100644
--- a/src/solprop/recipe/registry.py
+++ b/src/solprop/recipe/registry.py
@@ -88,6 +88,7 @@
__all__ = [
"REGISTRY",
+ "RETIRED_KEYWORDS",
"BuildContext",
"Context",
"Factory",
@@ -96,6 +97,7 @@
"coerce",
"decode",
"decode_keyword",
+ "retired",
"unit_of",
]
@@ -282,16 +284,76 @@ def _pressure_pair(index: int) -> Unit:
underscored fields are private.
"""
-_RETIRED_GRID_TRANSFORMS: Final = (
+_BINARY64: Final = "solprop computes in binary64 only (ADR-077); remove the key"
+_CF4: Final = (
+ "CF4 coupled-flow sections were retired (ADR-077): the default frozen-residual "
+ "flow is used; remove the key"
+)
+_GRID_TRANSFORMS: Final = (
"transforms was retired in SEP 47 with the mlx backend: fft_size='auto' takes "
"FFTW's transform cost; remove the key"
)
-_RETIRED_KEYWORDS: Final[Mapping[tuple[str, str], str]] = {
- ("FieldGrid.from_limits", "transforms"): _RETIRED_GRID_TRANSFORMS,
- ("EnvelopeGrid.from_limits", "transforms"): _RETIRED_GRID_TRANSFORMS,
+RETIRED_KEYWORDS: Final[Mapping[tuple[str, str], str]] = {
+ ("build", "precision"): _BINARY64,
+ ("SimulationSettings", "precision"): _BINARY64,
+ ("FieldGrid.from_limits", "transforms"): _GRID_TRANSFORMS,
+ ("EnvelopeGrid.from_limits", "transforms"): _GRID_TRANSFORMS,
+ ("CoherentWall", "method"): _CF4,
+ ("CoherentWall", "growth_limit"): _CF4,
+ ("CoherentWall", "fallback"): _CF4,
}
-"""Why a type no longer takes a keyword it once did, by type and keyword."""
+"""Why a recipe table no longer takes a keyword it once did, by owner and keyword.
+
+The owner is a registered type's name, or ``"build"`` for a route's ``[build]``
+table."""
+
+_STILL_SO: Final[Mapping[str, object]] = {"precision": "double"}
+"""A retired keyword's value that says what solprop does anyway, so an old recipe
+holding it still loads: the key is dropped."""
+
+
+def retired(owner: str, key: str, value: object, path: str) -> bool:
+ """Refuse a retired keyword by name, or say it is to be dropped.
+
+ Parameters
+ ----------
+ owner : str
+ The registered type's name, or ``"build"`` for a route's ``[build]`` table.
+ key : str
+ The keyword.
+ value : object
+ Its value in the recipe.
+ path : str
+ The keyword's path, for the refusal.
+
+ Returns
+ -------
+ bool
+ True when the keyword is retired and its value is what solprop does anyway,
+ such as ``precision = "double"``, so the caller drops it; False when the
+ keyword is not retired.
+
+ Raises
+ ------
+ RecipeError
+ If the keyword is retired, naming why and the fix.
+
+ Examples
+ --------
+ >>> from solprop.recipe.registry import retired
+ >>> retired("build", "precision", "double", "precision")
+ True
+ >>> retired("build", "length", "1 m", "length")
+ False
+ """
+ reason = RETIRED_KEYWORDS.get((owner, key))
+ if reason is None:
+ return False
+ if key in _STILL_SO and value == _STILL_SO[key]:
+ return True
+ raise RecipeError(reason, path=path)
+
_FILL_UNITS: Final[Mapping[str, UnitSpec]] = {
"pressure": "bar",
@@ -652,12 +714,11 @@ def build_object(
if key == factory.data_file:
arguments[key] = item
continue
+ if retired(factory.name, key, item, f"{path}.{key}"):
+ continue
if factory.target is SimulationSettings and key in _EXECUTION_FIELDS:
msg = f"{key} is set in the recipe's [execution] table, not in settings"
raise RecipeError(msg, path=f"{path}.{key}")
- retired = _RETIRED_KEYWORDS.get((factory.name, key))
- if retired is not None:
- raise RecipeError(retired, path=f"{path}.{key}")
if key not in accepted:
raise unknown_key(
key,
diff --git a/src/solprop/service/messages.py b/src/solprop/service/messages.py
index 5a6be6613..0e54a3700 100644
--- a/src/solprop/service/messages.py
+++ b/src/solprop/service/messages.py
@@ -33,8 +33,11 @@ class ServiceError(SolpropError, RuntimeError):
"""The front end's service refused a message, a run's record or a machine."""
-PROTOCOL: Final = 1
-"""The version of these messages; a peer of another version is refused."""
+PROTOCOL: Final = 2
+"""The version of these messages; a peer of another version is refused.
+
+2: the ``run_options`` reply lists the installed backends under ``backends``, in
+place of ``double`` and ``single`` (ADR-077)."""
TERMINAL: Final = frozenset({"finished", "stopped", "failed", "lost"})
"""The event kinds that end a run's stream: never coalesced, never dropped."""
diff --git a/src/solprop/service/results.py b/src/solprop/service/results.py
index 1099490a4..890e3eb79 100644
--- a/src/solprop/service/results.py
+++ b/src/solprop/service/results.py
@@ -435,7 +435,7 @@ def _health_facts(execution: Mapping[str, object]) -> dict[str, object]:
"""Return what a run's record says about how it ran.
- ``execution``: the run's ``execution`` record.
- - Returns: ``backend``, ``precision``, ``threads``, ``wall_time`` (s),
+ - Returns: ``backend``, ``threads``, ``wall_time`` (s),
``peak_memory`` (bytes) and ``plasma_source`` (its check and limit), each ``None``
where the record has none.
"""
@@ -445,7 +445,6 @@ def _health_facts(execution: Mapping[str, object]) -> dict[str, object]:
source = plasma if isinstance(plasma, Mapping) else {}
return {
"backend": execution.get("backend"),
- "precision": execution.get("precision"),
"threads": execution.get("thread_count"),
"wall_time": held.get("wall_time_s"),
"peak_memory": held.get("peak_rss_bytes"),
diff --git a/src/solprop/service/sheet.py b/src/solprop/service/sheet.py
index b84c538d2..518d7ac21 100644
--- a/src/solprop/service/sheet.py
+++ b/src/solprop/service/sheet.py
@@ -888,14 +888,13 @@ def run_options(payload: Payload) -> dict[str, object]: # noqa: ARG001 - none
-------
dict
``threads`` (the budget a run gets by default), ``limit`` (what sets it),
- and ``double`` and ``single`` (the backends installed for each precision).
+ and ``backends`` (the backends installed).
"""
budget = _run_budget()
return {
"threads": budget.threads,
"limit": budget.source,
- "double": list(available("double")),
- "single": list(available("single")),
+ "backends": list(available()),
}
diff --git a/src/solprop/stepper/controller.py b/src/solprop/stepper/controller.py
index 61c413c49..0bb18071d 100644
--- a/src/solprop/stepper/controller.py
+++ b/src/solprop/stepper/controller.py
@@ -30,7 +30,7 @@
import math
from collections.abc import Sequence
from dataclasses import dataclass
-from typing import Literal
+from typing import Literal, final
import numpy as np
from jaxtyping import Float
@@ -252,6 +252,7 @@ def default_dz_min(length: float) -> float:
# PR02 is suppressed because numpydoc's static hook cannot see the __init__ that
# @dataclass generates, so it reads every documented field as an unknown parameter.
+@final
@dataclass(frozen=True, eq=False)
class PIController: # numpydoc ignore=PR02
"""Proportional-integral step-size controller with lag and a reject-free mode.
@@ -265,7 +266,9 @@ class PIController: # numpydoc ignore=PR02
fluctuation of the estimate; the integral term damps that, which is what keeps the
step size smooth enough for the step history to be worth replaying.
- Equality is by identity (``eq=False``) because ``weights`` is an array.
+ Equality is by identity (``eq=False``) because ``weights`` is an array. The class
+ is final: both backends implement exactly these decisions, so a subclass is refused
+ when it is defined (ADR-077).
Parameters
----------
@@ -322,7 +325,8 @@ class PIController: # numpydoc ignore=PR02
------
StepperError
If a tolerance, gain or factor is outside its valid range, or the region is
- not ``"active"`` or ``"band"``.
+ not ``"active"`` or ``"band"``; or, when a class is defined, if it subclasses
+ this one.
References
----------
@@ -353,6 +357,14 @@ class PIController: # numpydoc ignore=PR02
estimator_order: int = 4
region: Literal["active", "band"] = "active"
+ def __init_subclass__(cls) -> None:
+ """Refuse a subclass: native implements these decisions itself."""
+ msg = (
+ f"PIController is final: {cls.__name__} cannot subclass it. Both backends "
+ "implement its decisions and norm themselves; set its fields instead"
+ )
+ raise StepperError(msg)
+
def __post_init__(self) -> None:
"""Validate the settings and adopt a read-only copy of the weights."""
if self.rtol <= 0.0:
diff --git a/src/solprop/stepper/multistep.py b/src/solprop/stepper/multistep.py
index 636c14011..57897d8bc 100644
--- a/src/solprop/stepper/multistep.py
+++ b/src/solprop/stepper/multistep.py
@@ -9,8 +9,7 @@
the cost. This module holds the backend-agnostic half: the frozen
:class:`Multistep` specification a run carries in place of a Runge-Kutta tableau, and
the scalar algebra every backend shares -- the integrated Lagrange weights of a
-variable-step Adams formula on the **actual** history positions, the leading defect
-constants and the Milne factor of a predictor-corrector pair (SEP 13 section 4).
+variable-step Adams formula on the **actual** history positions (SEP 13 section 4).
Positions enter in normalised form, ``theta = (z - z_n) / h`` with ``z_n`` the anchor
and ``h`` the step being taken, so a history point behind the anchor has a negative
@@ -34,7 +33,6 @@
import numpy as np
from jaxtyping import Float
-from numpy.polynomial import polynomial
from solprop._helpers import PLAIN, Helpers
from solprop.exceptions import StepperError
@@ -49,17 +47,12 @@
"REJECTION_GROWTH",
"RESTART_AFTER",
"CarrierFrame",
- "ComparisonMultistep",
- "Corrector",
"Formulation",
- "Interpolant",
"MarchPolicy",
"Multistep",
- "defect_integral",
"integrated_weights",
"lagrange_coefficients",
"march_policy",
- "milne_factor",
]
MAX_ADAMS_ORDER = 6
@@ -110,15 +103,6 @@
seeing the error. The proposal is shortened before a trial; a shortened step is not a
landing."""
-Interpolant = Literal["adams", "hermite"]
-"""How a comparison method interpolates save planes when they are interpolated.
-
-``"adams"`` integrates the accepted step's own derivative polynomial, the selected
-method's interpolant; ``"hermite"`` is the generic cubic Hermite reference (SEP 13
-section 6.4). Whether planes are interpolated at all is the run's
-``interpolate_planes``.
-"""
-
Formulation = Literal["interaction", "exponential"]
"""How an Adams method integrates its source history (SEP 16 section 4.2).
@@ -145,15 +129,6 @@
of the third harmonic was developed and removed by SEP 35's gate.
"""
-Corrector = Literal["none", "pec", "pece"]
-"""The execution contract of a step (SEP 13 section 4.2).
-
-``"pec"`` corrects once and stores the derivative of the predicted state, the selected
-method's contract. ``"pece"`` pays a second call for the derivative of the corrected,
-accepted state, and ``"none"`` is explicit Adams-Bashforth; both remain only as
-comparators (:class:`ComparisonMultistep`).
-"""
-
@dataclass(frozen=True)
class Multistep:
@@ -279,14 +254,6 @@ def __post_init__(self) -> None:
msg = f"safety must lie in (0, 1], got {self.safety!r}"
raise StepperError(msg)
- @property
- def corrector(self) -> Corrector: # numpydoc ignore=RT01
- """Execution contract: one call per step, the predicted source kept.
-
- ``"pec"``.
- """
- return "pec"
-
@property
def name(self) -> str: # numpydoc ignore=RT01
"""The method's record name.
@@ -333,151 +300,6 @@ def real_stability_interval(self) -> float: # numpydoc ignore=RT01
"""
return PEC_REAL_INTERVAL[self.order - 1]
- def corrector_nodes(self, order: int) -> int:
- """Return the corrector's derivative count at a current predictor order.
-
- Parameters
- ----------
- order : int
- The predictor order in force, which is below :attr:`order` during startup.
-
- Returns
- -------
- int
- ``order + 1``: the corrector is one order higher than the predictor.
- """
- return order + 1
-
-
-@dataclass(frozen=True)
-class ComparisonMultistep(Multistep):
- """An Adams variant SEP 13 compared and did not select, kept for benchmarks.
-
- PECE, explicit Adams-Bashforth and the matched predictor-corrector pairs controlled
- by Milne's estimate each lost to :class:`Multistep` on every case measured (SEP 13
- Part II). They stay executable so that the comparison can be rerun, and are not part
- of the documented interface.
-
- Attributes
- ----------
- corrector : {'none', 'pec', 'pece'}
- The execution contract (:data:`Corrector`).
- corrector_order : int or None
- Derivatives of the corrector, the predicted one included: ``order`` for a
- matched pair or ``order + 1``, the selected family. ``None`` is ``order``.
- Ignored for ``corrector='none'``.
- interpolant : {'adams', 'hermite'}
- How the planes are interpolated when the run interpolates them
- (``interpolate_planes``, ADR-036, ADR-039): the accepted step's own derivative
- polynomial, at the step's order and no call, or the generic cubic Hermite
- interpolant of the step's end states and derivatives, a reference whose error
- is one order below what the step controls (:data:`Interpolant`).
- formulation : {'interaction'}
- The variants are stepped in the interaction picture only.
-
- Raises
- ------
- StepperError
- If a field is outside its range, variable order is asked of a matched pair, or
- the exponential formulation is asked of a comparison variant (SEP 16 delivers
- it for the selected family only).
-
- Examples
- --------
- >>> from solprop.stepper.multistep import ComparisonMultistep
- >>> ComparisonMultistep(corrector="pece", order=4).name
- 'adams-pece4'
- """
-
- corrector: Corrector = "pece" # pyright: ignore[reportIncompatibleMethodOverride]
- corrector_order: int | None = None
- variable_order: bool = False
- interpolant: Interpolant = "adams"
- formulation: Formulation = "interaction"
-
- def __post_init__(self) -> None:
- """Check the ranges, the corrector's among them.
-
- Raises
- ------
- StepperError
- Naming the offending field.
- """
- super().__post_init__()
- if self.formulation != "interaction":
- msg = (
- "the comparison variants use the interaction formulation only; the "
- "exponential one is delivered for Multistep (SEP 16)"
- )
- raise StepperError(msg)
- # The Literal annotation refuses an unknown corrector first wherever the
- # annotations are checked at run time, as in the test suite.
- if self.corrector not in ("none", "pec", "pece"): # pragma: no cover
- msg = f"corrector must be 'none', 'pec' or 'pece', got {self.corrector!r}"
- raise StepperError(msg)
- if self.corrector_order is not None and self.corrector_order not in (
- self.order,
- self.order + 1,
- ):
- msg = (
- f"corrector_order must be order ({self.order}) or order + 1, got "
- f"{self.corrector_order!r}"
- )
- raise StepperError(msg)
- if self.variable_order and (
- self.corrector == "none" or self.corrector_order != self.order + 1
- ):
- msg = (
- "variable_order needs the one-order-higher corrector "
- "(corrector 'pec' or 'pece' with corrector_order = order + 1)"
- )
- raise StepperError(msg)
-
- @property
- def history_key(self) -> str: # numpydoc ignore=RT01
- """What defines the history a march of this variant keeps.
-
- Every field of a comparison variant may shape its history, so the whole
- representation: any change restarts it.
-
- The variant's representation.
- """
- return repr(self)
-
- @property
- def name(self) -> str: # numpydoc ignore=RT01
- """The variant's record name.
-
- ``adams-``, with ``/`` for the
- higher-order corrector, ``ab`` standing for ``none``.
- """
- stem = "ab" if self.corrector == "none" else self.corrector
- extended = (
- self.corrector != "none"
- and self.corrector_order is not None
- and self.corrector_order != self.order
- )
- suffix = f"/{self.corrector_order}" if extended else ""
- variable = "-variable" if self.variable_order else ""
- return f"adams-{stem}{self.order}{suffix}{variable}"
-
- def corrector_nodes(self, order: int) -> int:
- """Return the corrector's derivative count at a current predictor order.
-
- Parameters
- ----------
- order : int
- The predictor order in force.
-
- Returns
- -------
- int
- ``order`` for a matched pair, ``order + 1`` for the higher-order corrector.
- """
- if self.corrector_order is None or self.corrector_order == self.order:
- return order
- return order + 1
-
@dataclass(frozen=True)
class MarchPolicy:
@@ -838,97 +660,6 @@ def lagrange_coefficients(
return rows
-def defect_integral(
- nodes: Sequence[float] | Float[np.ndarray, " n_nodes"], upper: float = 1.0
-) -> float:
- """Return the integral from 0 to `upper` of the node polynomial.
-
- ``integral_0^upper prod_j (theta - theta_j) dtheta``. Divided by ``q!`` for ``q``
- nodes it is the leading defect constant ``C`` of the formula interpolating on those
- nodes, ``exact - formula = C h^{q+1} g^{(q)}`` (SEP 13 section 4.3): the
- interpolation remainder, integrated.
-
- Parameters
- ----------
- nodes : sequence of float
- Normalised positions, as for :func:`integrated_weights`.
- upper : float, optional
- Upper limit of the integral.
-
- Returns
- -------
- float
- The integral, dimensionless.
-
- Examples
- --------
- The constant-step AB4 and AM4 constants, 251/720 and -19/720, times 4!:
-
- >>> from solprop.stepper.multistep import defect_integral
- >>> round(defect_integral([0, -1, -2, -3]) * 30, 9)
- 251.0
- >>> round(defect_integral([1, 0, -1, -2]) * 30, 9)
- -19.0
- """
- theta = np.asarray(nodes, dtype=np.float64).reshape(-1)
- antiderivative = polynomial.polyint(polynomial.polyfromroots(theta))
- return float(
- polynomial.polyval(upper, antiderivative)
- - polynomial.polyval(0.0, antiderivative)
- )
-
-
-def milne_factor(
- predictor_nodes: Sequence[float] | Float[np.ndarray, " n_nodes"],
- corrector_nodes: Sequence[float] | Float[np.ndarray, " n_nodes"],
-) -> float:
- """Return the factor turning a predictor-corrector difference into the defect.
-
- With the predictor's defect ``C_B h^{q+1} g^{(q)}`` and the corrector's
- ``C_M h^{q+1} g^{(q)}`` on the same number of nodes, ``v_C - v_P`` is
- ``(C_B - C_M) h^{q+1} g^{(q)}``, so the corrector's local defect is
- ``C_M / (C_B - C_M)`` times the difference (Milne's device, SEP 13 section 4.3).
- The common ``1/q!`` cancels.
-
- Parameters
- ----------
- predictor_nodes, corrector_nodes : sequence of float
- The two formulas' normalised nodes, equal in number; the corrector's include 1.
-
- Returns
- -------
- float
- ``C_M / (C_B - C_M)``.
-
- Raises
- ------
- StepperError
- If the node counts differ, or the two constants coincide so the difference
- carries no information.
-
- Examples
- --------
- >>> from solprop.stepper.multistep import milne_factor
- >>> round(milne_factor([0, -1, -2, -3], [1, 0, -1, -2]) * 270, 9)
- -19.0
- """
- if len(predictor_nodes) != len(corrector_nodes):
- msg = (
- f"a Milne estimate needs equal node counts, got {len(predictor_nodes)} and "
- f"{len(corrector_nodes)}"
- )
- raise StepperError(msg)
- predictor = defect_integral(predictor_nodes)
- corrector = defect_integral(corrector_nodes)
- spread = predictor - corrector
- # On equal nodes the spread is a few tenths; an irregular stencil that brought the
- # two constants together would divide round-off into the estimate.
- if abs(spread) <= 1e-12 * max(abs(predictor), abs(corrector), 1.0):
- msg = "the predictor and corrector defect constants coincide"
- raise StepperError(msg)
- return corrector / spread
-
-
def _check_nodes(theta: Float[np.ndarray, " n_nodes"]) -> None:
"""Refuse empty, non-finite or repeated nodes."""
if theta.size == 0 or not np.all(np.isfinite(theta)):
diff --git a/src/solprop/stepper/schedule.py b/src/solprop/stepper/schedule.py
index 9d5e2f209..fe2c53f99 100644
--- a/src/solprop/stepper/schedule.py
+++ b/src/solprop/stepper/schedule.py
@@ -235,18 +235,16 @@ def from_history(
neighbouring orders' defect estimates, with a hysteresis margin, and a replay
makes that choice afresh. Where the adaptive run rejected a step it waits a
step before raising the order, which a replay never has to, so a replay can
- differ from the adaptive run there. In another precision or on another
- backend, rounding decides a choice whose estimates lie near the margin, so
- two replays can take different orders, and so make different truncation
- errors; two replays in one precision on one backend are identical. To
- compare the arithmetic alone, replay with fixed orders, ``RungeKutta()`` or
- ``Multistep(variable_order=False)``, whose order rises from one to ``order``
- as its history accrues, reading no estimate: two such replays of one
- schedule differ by rounding alone. Single precision refuses ``Multistep()``,
- so a comparison across precisions is a Runge-Kutta one. Recording the order
- sequence in the schedule would also fix a variable-order replay's orders;
- that would extend the saved schedule and is not done, since a fixed order
- gives the comparison without it.
+ differ from the adaptive run there. On another backend, rounding decides a
+ choice whose estimates lie near the margin, so two replays can take different
+ orders, and so make different truncation errors; two replays on one backend
+ are identical. To compare the arithmetic alone, replay with fixed orders,
+ ``RungeKutta()`` or ``Multistep(variable_order=False)``, whose order rises from
+ one to ``order`` as its history accrues, reading no estimate: two such replays
+ of one schedule differ by rounding alone. Recording the order sequence in the
+ schedule would also fix a variable-order replay's orders; that would extend the
+ saved schedule and is not done, since a fixed order gives the comparison
+ without it.
**Limits.**
diff --git a/src/solprop/translate/admission.py b/src/solprop/translate/admission.py
new file mode 100644
index 000000000..8d5838b93
--- /dev/null
+++ b/src/solprop/translate/admission.py
@@ -0,0 +1,439 @@
+# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""What the general modal route and the coupled flow execute, checked once.
+
+Both backends execute the same combinations of these leaves, so the list is part of the
+problem's validation (:meth:`solprop.translate.problem.PropagationProblem.validate`,
+which :func:`solprop.translate.problem.validate_run` runs where a backend is built):
+each refusal has one raise site, before any buffer is allocated. It holds the policy:
+the leaves' consistency with the state -- channel and component counts, axes, extents
+-- is :meth:`~solprop.translate.problem.PropagationProblem.validate`'s own and runs
+first. A problem can be assembled by something other than
+:func:`solprop.translate.problem.assemble`, so nothing here relies on the assembly
+having refused it already.
+"""
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from solprop.exceptions import TranslationError
+from solprop.translate.coupled import CoupledFlowTables, ModalMetricTables
+from solprop.translate.general import (
+ MAX_DENSE_METRIC_DEPARTURE,
+ check_regional_responses,
+ metric_departure,
+)
+from solprop.translate.linop import LinearOperator
+from solprop.translate.modal_tables import DEFAULT_GRAM_ATOL, ModalTransformTables
+from solprop.translate.responses import RegionalResponseTables
+
+if TYPE_CHECKING:
+ from solprop.translate.problem import PropagationProblem
+
+__all__ = ["check_general_route", "check_leaf_types"]
+
+
+def check_general_route(problem: "PropagationProblem") -> None:
+ """Refuse, by name, each general modal or coupled-flow problem solprop does not run.
+
+ The coupled flow first: a metric or a table beside it is this route's refusal to
+ make, not the general route's. Then the general modal route: the tables' own
+ execution maps, an identity metric or the dense metric of a declared unitary change
+ of basis of one, the constant unit power weight, no legacy response beside the
+ route's own, per-region responses of a known model, and moving maps only beside the
+ flow that carries their connection. Its field grid is the leaves' own structure's to
+ require: a leaf's increasing frequency axis is ``grids.omega``, which an envelope
+ grid's FFT-ordered axis is not. A problem carrying none of the general-modal leaves
+ passes untouched.
+
+ Parameters
+ ----------
+ problem : solprop.translate.problem.PropagationProblem
+ The problem to check, its leaves consistent with its state.
+
+ Raises
+ ------
+ TranslationError
+ Naming the leaf, or the part of the problem around it, that is not executed.
+
+ Examples
+ --------
+ >>> from solprop.driver import Simulation
+ >>> from solprop.translate.admission import check_general_route
+ >>> 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",
+ ... )
+ >>> check_general_route(simulation.translate()) is None
+ True
+ """
+ flow = problem.linear.coupled_flow
+ if flow is not None:
+ _check_flow_representation(flow)
+ _check_flow_problem(problem, flow)
+ _check_modal_route(problem)
+
+
+def check_leaf_types(problem: "PropagationProblem") -> None:
+ """Refuse a general-modal leaf that is not its record type, by name.
+
+ The first of the leaves' consistency checks
+ (:meth:`~solprop.translate.problem.PropagationProblem.validate`), so that a
+ hand-built problem holding something else there meets a named refusal rather than
+ an attribute error further in.
+
+ Parameters
+ ----------
+ problem : solprop.translate.problem.PropagationProblem
+ The problem to check.
+
+ Raises
+ ------
+ TranslationError
+ Naming the leaf, the type it holds and the type it must be.
+ """
+ for name, leaf, kind in (
+ (
+ "transform.modal_tables",
+ problem.transform.modal_tables,
+ ModalTransformTables,
+ ),
+ ("linear.modal_metric", problem.linear.modal_metric, ModalMetricTables),
+ ("linear.coupled_flow", problem.linear.coupled_flow, CoupledFlowTables),
+ (
+ "responses.regional_responses",
+ problem.responses.regional_responses,
+ RegionalResponseTables,
+ ),
+ ):
+ if leaf is not None and not isinstance(leaf, kind):
+ msg = f"{name} is a {type(leaf).__name__}, not a {kind.__name__}"
+ raise TranslationError(msg)
+
+
+def _check_flow_representation(flow: CoupledFlowTables) -> None:
+ """Refuse a coupled flow whose representation no backend executes."""
+ methods = set(flow.section_methods)
+ frozen = methods == {"frozen_residual"}
+ if flow.section_methods != ("constant_schur",) and not frozen:
+ msg = (
+ "solprop executes one constant section by a Schur matrix-function "
+ "action, or every section of a varying flow as 'frozen_residual', and this "
+ f"flow declares {sorted(methods)} over "
+ f"{len(flow.section_methods)} section(s): 'diagonal' is the existing "
+ "cumulative flow and needs no leaf, and a constant-Schur section beside "
+ "any other is not one a single held decomposition is built for"
+ )
+ raise TranslationError(msg)
+ if flow.representation != "dense":
+ msg = (
+ "solprop executes the dense generator of a coupled flow and this one "
+ f"is {flow.representation!r}: a factored generator is reconstructed by no "
+ "kernel"
+ )
+ raise TranslationError(msg)
+ assert flow.generator is not None
+ if not frozen and flow.generator.shape[1] != 1:
+ msg = (
+ "solprop executes a constant-Schur coupled flow whose generator is "
+ f"constant across its section, and this one carries "
+ f"{flow.generator.shape[1]} Chebyshev degrees along z: a generator that "
+ "varies inside its section is a 'frozen_residual' flow, which declares "
+ "the residual its variation becomes"
+ )
+ raise TranslationError(msg)
+ if not frozen and flow.connection is not None:
+ msg = (
+ "solprop executes no taper connection beside a constant section: "
+ "linear.coupled_flow.connection is the moving-basis term of a taper and "
+ "belongs to a 'frozen_residual' flow, whose explicit residual carries it"
+ )
+ raise TranslationError(msg)
+ if not frozen and float(np.max(flow.residual_norm_bound)) > 0.0:
+ msg = (
+ "solprop executes a constant-Schur coupled flow with no explicit "
+ f"residual, and this one declares up to "
+ f"{float(np.max(flow.residual_norm_bound)):.4g} 1/m: a section whose "
+ "generator varies inside it is a 'frozen_residual' flow"
+ )
+ raise TranslationError(msg)
+
+
+def _check_flow_problem(problem: "PropagationProblem", flow: CoupledFlowTables) -> None:
+ """Refuse a problem whose surroundings the coupled flow cannot be executed in.
+
+ The cumulative diagonal ``Phi`` multiplies the flow action, which is only the same
+ equation when the two **commute**, so ``Phi`` must be a multiple of the identity in
+ channels; a shot-noise draw kept apart is transported by the unitary phase of a
+ diagonal flow, which a nonunitary coupled flow is not; the flux of a condensed
+ boundary block is the square-root model's, whose positivity is not established, so
+ no explicit metric may sit beside one on the mode-set route; and the block is
+ **scalar**: the action contracts the channel axis alone, so the state carries one
+ polarisation. The TE/TM wall block (``CoherentWall(impedance="te_tm")``) is scalar
+ in this sense too: its families are channels. The **transform** may resolve two
+ transverse components, since the synthesis is read by the responses alone.
+ """
+ _, n_pol, _ = problem.shape
+ if n_pol != 1:
+ msg = (
+ "solprop executes a coupled flow only for a scalar state, and this "
+ f"problem's state carries n_pol = {n_pol} polarisation components (the "
+ "product route): the flow's action contracts the channel axis alone, so "
+ "a scalar block beside a state with two polarisation components would be "
+ "applied to each as though it described both. "
+ "CoherentWall(impedance='te_tm') names the wall's TE and TM impedances, "
+ "not the state's polarisation axis: its families are channels, one "
+ "component each"
+ )
+ raise TranslationError(msg)
+ linear = problem.linear
+ constant = flow.section_methods == ("constant_schur",)
+ if constant and (
+ linear.phi_representation != "constant" or linear.phi_constant is None
+ ):
+ msg = (
+ "solprop executes a constant-Schur coupled flow only beside the exact "
+ f"constant representation of Phi, and this problem's is "
+ f"{linear.phi_representation!r}: a varying carrier belongs to a "
+ "'frozen_residual' flow"
+ )
+ raise TranslationError(msg)
+ if not _mode_independent(linear):
+ msg = (
+ "solprop executes a coupled flow only beside a mode-independent Phi: "
+ "the flow action and the cumulative diagonal are applied as a product, "
+ "which is the same equation only when they commute, and a channel-"
+ "dependent diagonal does not commute with a coupled block"
+ )
+ raise TranslationError(msg)
+ if not constant and not problem.device_ready:
+ msg = (
+ "solprop executes a frozen-residual coupled flow only beside a Phi "
+ "that is a weighted sum of fixed terms, and this problem's took the z "
+ "table: the flow's own stage weights and the carrier's must come from "
+ "one stage bundle, and the table has no such form "
+ "(docs/dev/kernel-contract.md section 1)"
+ )
+ raise TranslationError(msg)
+ # The input model's draw was added to the state when it was loaded, so the flow
+ # carries it as part of the field (SEP 37 section 6); only the modified model keeps
+ # a draw apart that a flow would have to transport.
+ if problem.initial.noise_model == "modified":
+ msg = (
+ "solprop executes no 'modified' noise model beside a coupled flow: "
+ "its one-photon-per-bin draw is advanced by the unitary phase of a "
+ "diagonal flow, and its transport through a nonunitary coupled flow is "
+ "not measured in this package; the 'input' model is admitted"
+ )
+ raise TranslationError(msg)
+ if linear.modal_metric is not None and problem.transform.modal_tables is None:
+ # The coherent wall's own flux model: a condensed boundary block has no bound
+ # reciprocal basis. A general modal guide's metric is the Gram of its own
+ # sampled, power-orthonormal basis, and the coupled block there a declared
+ # unitary rotation of it, which leaves the Gram's spectrum where it was;
+ # `transform.modal_tables` tells the two apart.
+ msg = (
+ "solprop executes no explicit metric beside a coupled flow on the "
+ "mode-set route: the flux of a condensed boundary block is the "
+ "square-root model's, whose positivity is not established. A general "
+ "modal guide carries "
+ "transform.modal_tables and its metric is its own sampled basis Gram, "
+ "which this problem does not"
+ )
+ raise TranslationError(msg)
+
+
+def _mode_independent(linear: LinearOperator) -> bool:
+ """Return whether the cumulative ``Phi`` is a multiple of the identity in channels.
+
+ That, and not constancy, is what the product ``exp(dPhi) exp(dz G_s)`` needs. Every
+ representation is checked in its own terms -- the exact constant array, the
+ expansion's fixed terms, or the table's coefficients -- because a varying guide
+ takes whichever of them its declared parameters allow.
+ """
+ held: list[np.ndarray] = []
+ if linear.phi_constant is not None:
+ held.append(linear.phi_constant)
+ if linear.phi_expansion is not None:
+ held.append(linear.phi_expansion.terms)
+ if linear.phi_coefficients is not None:
+ held.append(linear.phi_coefficients)
+ return all(
+ np.array_equal(
+ block,
+ np.broadcast_to(np.take(block, [0], axis=-3), block.shape),
+ )
+ for block in held
+ )
+
+
+def _check_modal_route(problem: "PropagationProblem") -> None:
+ """Refuse every general modal problem solprop does not execute.
+
+ The route is one combination: a field grid (which the leaves' frequency axes
+ already require), the tables' own execution maps, an
+ **identity** metric -- the Poynting-normalised orthogonal basis the Gram gate
+ certifies -- or the dense metric of a declared unitary change of basis of one, the
+ constant unit power weight of a true-power amplitude, and the route's own
+ responses. A legacy response beside them is refused, because the route's source
+ replaces the node pipeline that would read it: it would be recorded and silently
+ not propagated.
+ """
+ tables = problem.transform.modal_tables
+ metric = problem.linear.modal_metric
+ if tables is None:
+ if metric is not None:
+ msg = (
+ "solprop executes linear.modal_metric as part of the general modal "
+ "route, and this problem carries no transform.modal_tables for it to "
+ "belong to"
+ )
+ raise TranslationError(msg)
+ return
+ if metric is None or metric.kind not in ("identity", "dense"):
+ kind = "absent" if metric is None else f"of kind {metric.kind!r}"
+ msg = (
+ "solprop executes a general modal guide in a basis certified "
+ "orthonormal in true power -- an identity linear.modal_metric, or the "
+ "dense metric of a declared unitary change of basis of one -- and this "
+ f"problem's is {kind}; a diagonal metric is a power-orthogonal basis "
+ "with unequal channel norms, which nothing in this package builds and "
+ "no backend has measured"
+ )
+ raise TranslationError(msg)
+ defect = float(np.max(metric.error))
+ if defect > DEFAULT_GRAM_ATOL:
+ # An identity metric is executed as the identity and nothing downstream reads
+ # the error row again, so an ungated certificate is an unbounded silent error.
+ msg = (
+ f"linear.modal_metric.error is {defect:.4g}, above the "
+ f"{DEFAULT_GRAM_ATOL:.4g} solprop will call the identity: this basis "
+ "is that far from orthonormal where it was sampled, and a continuous "
+ "wave's da/dz would be wrong by about that much"
+ )
+ raise TranslationError(msg)
+ _check_dense_metric(tables, metric)
+ if tables.synthesis is None or tables.projection is None:
+ msg = (
+ "solprop executes a general modal guide through the synthesis and "
+ "projection maps of transform.modal_tables, and this problem carries "
+ "neither: a reference sampled over a path of positions has no execution "
+ "map"
+ )
+ raise TranslationError(msg)
+ if tables.synthesis.shape[0] != 1 or tables.synthesis.shape[1] != 1:
+ _check_moving_maps(problem)
+ weight = problem.linear.power_weight_constant
+ if weight is None or not np.all(weight == 1.0):
+ msg = (
+ "solprop executes a general modal guide whose Poynting amplitude is "
+ "already the true-power amplitude, so linear.power_weight is the "
+ "constant one; this problem's is "
+ f"{problem.linear.power_weight_representation}"
+ )
+ raise TranslationError(msg)
+ _check_legacy_responses(problem)
+ check_regional_responses(problem.responses.regional_responses)
+
+
+def _check_dense_metric(
+ tables: ModalTransformTables, metric: ModalMetricTables
+) -> None:
+ """Refuse a dense metric that is not a unitary change of basis, measuring it.
+
+ The route propagates through the **diagonal** cumulative flow and measures the step
+ controller's norm on the coordinate amplitudes, both of which are the basis's own
+ only where that basis is orthonormal in true power. A declared unitary change of
+ basis of a certified orthonormal reference stays there -- ``W' = I + U.H delta U``
+ has the same spectral departure as ``delta`` -- and a nonunitary one does not.
+ """
+ block = metric.dense
+ if block is None:
+ return
+ if metric.gauge_id != tables.gauge_id:
+ msg = (
+ f"linear.modal_metric describes the basis {metric.gauge_id!r} and "
+ f"transform.modal_tables is in {tables.gauge_id!r}: a metric of another "
+ "basis than the reference it is executed beside would weight the wrong "
+ "channels"
+ )
+ raise TranslationError(msg)
+ departure = max(
+ metric_departure(block[interval, degree])
+ for interval in range(block.shape[0])
+ for degree in range(block.shape[1])
+ )
+ if departure > MAX_DENSE_METRIC_DEPARTURE:
+ msg = (
+ f"linear.modal_metric is dense and {departure:.4g} from the identity, "
+ f"above the {MAX_DENSE_METRIC_DEPARTURE:.4g} solprop will execute. "
+ "The route propagates through the diagonal cumulative flow and "
+ "measures the controller's norm on the coordinate amplitudes, so it "
+ "admits a dense metric only as a unitary change of basis of a "
+ "certified orthonormal reference, whose departure is the reference's "
+ "own; a nonunitary transform leaves a genuinely nonorthogonal basis, "
+ "whose flow is linear.coupled_flow and whose transport is unmeasured"
+ )
+ raise TranslationError(msg)
+
+
+def _check_moving_maps(problem: "PropagationProblem") -> None:
+ """Refuse a moving guide's maps beside anything but the flow of their connection.
+
+ Moving maps with no connection are a guide whose modes change shape and exchange no
+ power, which is the adiabatic approximation the route exists to replace.
+ """
+ flow = problem.linear.coupled_flow
+ if (
+ flow is None
+ or flow.connection is None
+ or set(flow.section_methods) != {"frozen_residual"}
+ ):
+ described = (
+ "no coupled flow"
+ if flow is None
+ else f"a flow of {sorted(set(flow.section_methods))} sections"
+ + ("" if flow.connection is not None else " and no connection")
+ )
+ msg = (
+ "solprop executes a moving guide's maps beside the flow that carries "
+ f"its connection, 'frozen_residual' on every section, and this problem "
+ f"carries {described}: maps that move with no connection are the "
+ "adiabatic approximation the moving route replaces"
+ )
+ raise TranslationError(msg)
+
+
+def _check_legacy_responses(problem: "PropagationProblem") -> None:
+ """Refuse a legacy response a general modal guide would carry and not propagate."""
+ responses = problem.responses
+ legacy = [
+ name
+ for name, present in (
+ ("kerr_coefficients", responses.kerr_coefficients is not None),
+ # The general route's own dispersive channels name their node regions;
+ # a population's leaf has none and would not be read.
+ (
+ "dispersive_kerr",
+ responses.dispersive_kerr is not None
+ and responses.dispersive_kerr.regions is None,
+ ),
+ ("population_channels", bool(responses.population_channels)),
+ ("raman_terms", bool(responses.raman_terms)),
+ ("envelope_plasma", responses.envelope_plasma is not None),
+ )
+ if present
+ ]
+ if legacy:
+ msg = (
+ "solprop executes transform.modal_tables with its own "
+ "responses.regional_responses and no legacy response, and this problem "
+ f"also carries responses.{', responses.'.join(legacy)}: the general "
+ "route's source replaces the node pipeline that would read them, so they "
+ "would be recorded and not propagated"
+ )
+ raise TranslationError(msg)
diff --git a/src/solprop/translate/coupled.py b/src/solprop/translate/coupled.py
index 8d0fa1446..b3ccaa127 100644
--- a/src/solprop/translate/coupled.py
+++ b/src/solprop/translate/coupled.py
@@ -10,7 +10,7 @@
or ordered representation of a generator whose channels couple.
:class:`CoupledFlowTables` is the record, its validation and its printing, built by
-:func:`constant_schur_flow`, :func:`frozen_residual_flow` and :func:`cf4_flow` for the
+:func:`constant_schur_flow` and :func:`frozen_residual_flow` for the
routes whose channels couple -- a capillary's coherent wall and taper coupling, and a
general modal guide whose declared basis or motion couples its channels. Everywhere
else it is ``None`` and a guide propagates through the diagonal cumulative flow.
@@ -28,7 +28,6 @@
import logging
import math
-import warnings
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Literal
@@ -36,11 +35,11 @@
import numpy as np
from jaxtyping import Bool, Complex, Float
from numpy.polynomial import chebyshev as chebyshev_basis
-from scipy.linalg import expm, schur
+from scipy.linalg import schur
from solprop._display import Displayed, engineering, line
from solprop._ownership import frozen_copy
-from solprop.exceptions import SolpropWarning, TranslationError
+from solprop.exceptions import TranslationError
from solprop.modes.capillary import WallImpedance, WallLimits
from solprop.modes.material import JsonValue, quantised_measurement
from solprop.modes.protocol import ChannelLabel
@@ -53,20 +52,16 @@
)
__all__ = [
- "CF4_GROWTH_LIMIT",
- "CF4_NODES",
- "CF4_WEIGHTS",
"MAX_SECTION_DEGREE",
"SECTION_TOL",
+ "CoupledFlowFactors",
"CoupledFlowTables",
"MetricKind",
"ModalMetricTables",
"SectionMethod",
"advisory_step",
- "cf4_defect",
- "cf4_flow",
- "cf4_growth_bound",
"constant_schur_flow",
+ "coupled_flow_factors",
"dense_metric",
"eigenbasis_limit",
"flow_edge_slack",
@@ -96,14 +91,12 @@
produces.
"""
-SectionMethod = Literal["diagonal", "constant_schur", "frozen_residual", "cf4"]
+SectionMethod = Literal["diagonal", "constant_schur", "frozen_residual"]
"""How one interval of a coupled flow is integrated.
``"diagonal"`` is the existing cumulative commuting flow; ``"constant_schur"`` is the
Schur matrix-function action of a constant coherent block; ``"frozen_residual"`` freezes
-the block inside the section and leaves its variation to the explicit source; ``"cf4"``
-is the commutator-free fourth-order flow of the whole section generator, admitted by
-:func:`cf4_flow` behind its internal-growth and source-feedback guards.
+the block inside the section and leaves its variation to the explicit source.
"""
@@ -302,6 +295,31 @@ def n_omega(self) -> int: # numpydoc ignore=RT01
"""How many coarse frequencies the metric is tabulated on."""
return len(self.omega)
+ def check_rows(self) -> None:
+ """Check the certificate rows against the record's own axes, by name.
+
+ Construction checks them; a leaf replaced past it is checked again by
+ :meth:`solprop.translate.problem.PropagationProblem.validate` before anything
+ reads a row.
+
+ Raises
+ ------
+ TranslationError
+ If ``error``, ``minimum_eigenvalue`` or ``maximum_eigenvalue`` is not one
+ row per interval of ``z_edges`` (:func:`interval_count`) by one column per
+ coarse frequency.
+ """
+ rows = (interval_count(len(self.z_edges)), self.n_omega)
+ for name in ("error", "minimum_eigenvalue", "maximum_eigenvalue"):
+ shape = np.shape(getattr(self, name))
+ if shape != rows:
+ msg = (
+ f"linear.modal_metric.{name} has shape {shape}, expected {rows}: "
+ "one row per interval of its z_edges, one column per coarse "
+ "frequency"
+ )
+ raise TranslationError(msg)
+
def __repr__(self) -> str:
"""Return a one-line description, never an array.
@@ -477,19 +495,17 @@ class CoupledFlowTables(Displayed):
"""A generator whose channels couple, frozen or ordered along z.
The leaf of contract section 3 that carries the coherent wall operator and the
- taper coupling. ``numpy_ref`` executes three combinations. The first is the dense
+ taper coupling. ``numpy_ref`` and ``native`` execute two combinations. The first is
+ the dense
generator of a single constant section, ``section_methods ==
("constant_schur",)``, no connection and an exactly zero residual, built by
:func:`constant_schur_flow` from a constant coherent block. The second is the
dense generator of any number of ``"frozen_residual"`` sections at any degree
along z, with the taper connection and an explicit residual the Runge-Kutta
source carries, built by :func:`frozen_residual_flow` from the same block
- evaluated along the declared profile. The third is ``"cf4"`` sections, built by
- :func:`cf4_flow` from the same expansion, whose whole generator the backend
- integrates by the commutator-free fourth-order exponential with no explicit
- residual, mixed with ``"frozen_residual"`` sections only where a declared
- fallback put them. Every other method, representation and mixture is refused by
- name at the backend, and the native backend refuses ``"cf4"`` sections.
+ evaluated along the declared profile. Every other method, representation and
+ mixture is refused by name by the problem's validation
+ (:func:`solprop.translate.admission.check_general_route`).
The dense ``generator`` is optional. The factored alternative combines fixed
matrices with scalar weights, rank-one wall terms and connection coefficients
@@ -528,15 +544,13 @@ class CoupledFlowTables(Displayed):
Non-negative dimensionless representation error, shape
``(n_intervals, n_omega)``.
residual_norm_bound : numpy.ndarray
- Non-negative bound in 1/m on the explicit residual, same shape; zero on a
- ``"cf4"`` section, which has none.
+ Non-negative bound in 1/m on the explicit residual, same shape.
inverse_condition_bound : numpy.ndarray
Dimensionless bound of at least one on the conditioning of the inverse
- action, same shape; on a ``"cf4"`` section, on the product of the four factor
- norms of a forward and inverse pair (:func:`cf4_flow`).
+ action, same shape.
section_methods : tuple of str
- One of ``"diagonal"``, ``"constant_schur"``, ``"frozen_residual"`` or
- ``"cf4"`` per interval.
+ One of ``"diagonal"``, ``"constant_schur"`` or ``"frozen_residual"`` per
+ interval.
gauge_id, certificate_id : str
Non-empty identities; scalars, never arrays, enter resume identity.
@@ -844,7 +858,7 @@ def _validate_bounds(self) -> None:
f"got {len(methods)} for {intervals}"
)
raise TranslationError(msg)
- allowed = ("diagonal", "constant_schur", "frozen_residual", "cf4")
+ allowed = ("diagonal", "constant_schur", "frozen_residual")
unknown = [method for method in methods if method not in allowed]
if unknown:
msg = (
@@ -1703,6 +1717,105 @@ def frozen_generator(
return np.tensordot(weights, block, axes=1)
+@dataclass(frozen=True, eq=False)
+class CoupledFlowFactors:
+ """A coupled flow's frozen blocks and Schur factors on its active bins.
+
+ What both backends execute a flow from, derived once from the leaf
+ (:func:`coupled_flow_factors`); a decomposition is not a contract row, so it is
+ formed from the stored generator rather than carried across the seam.
+
+ Attributes
+ ----------
+ active : numpy.ndarray
+ Bool, ``(n_omega,)``: the bins the generator is non-zero at; the rest of the
+ band has an identity action.
+ vectors, triangular : numpy.ndarray
+ Complex128, ``(n_intervals, n_active, n_modes, n_modes)``: each section's Schur
+ factors ``Z`` and ``T`` per active bin (:func:`schur_blocks`).
+ frozen : numpy.ndarray
+ Complex128, same shape: each section's frozen block (:func:`frozen_generator`),
+ in 1/m.
+ condition_bound : numpy.ndarray
+ Float64, ``(n_intervals, n_active)``: the declared conditioning row.
+ residual_terms : numpy.ndarray or None
+ Complex128, ``(n_intervals * n_deg, n_active, n_modes, n_modes)``: a
+ frozen-residual flow's generator less its connection, flattened over
+ ``(n_intervals, n_deg)``; ``None`` for a constant-Schur flow.
+ """
+
+ active: Bool[np.ndarray, " n_omega"]
+ vectors: Complex[np.ndarray, "n_intervals n_active n_modes n_modes"]
+ triangular: Complex[np.ndarray, "n_intervals n_active n_modes n_modes"]
+ frozen: Complex[np.ndarray, "n_intervals n_active n_modes n_modes"]
+ condition_bound: Float[np.ndarray, "n_intervals n_active"]
+ residual_terms: Complex[np.ndarray, "n_terms n_active n_modes n_modes"] | None
+
+
+def coupled_flow_factors(flow: CoupledFlowTables) -> CoupledFlowFactors:
+ """Freeze and decompose each section of a dense coupled flow on its active bins.
+
+ Parameters
+ ----------
+ flow : CoupledFlowTables
+ A leaf of the dense representation, as the problem's validation admits it.
+
+ Returns
+ -------
+ CoupledFlowFactors
+ The frozen blocks, their Schur factors, the conditioning row and the residual.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from solprop.translate.coupled import constant_schur_flow, coupled_flow_factors
+ >>> block = np.zeros((2, 2, 3), dtype=complex)
+ >>> block[0, 1] = block[1, 0] = 1j
+ >>> flow = constant_schur_flow(
+ ... np.linspace(1e15, 2e15, 3), np.array([0.0, 1e-3]), block,
+ ... dz_max=1e-4, condition_limit=10.0, gauge_id="lab",
+ ... certificate_id="schur",
+ ... )
+ >>> coupled_flow_factors(flow).frozen.shape
+ (1, 3, 2, 2)
+ """
+ generator = flow.generator
+ assert generator is not None
+ sections = len(flow.section_methods)
+ active = np.asarray(np.any(generator != 0.0, axis=(0, 1, 2, 3)), dtype=np.bool_)
+ frozen = np.ascontiguousarray(
+ np.stack(
+ [
+ np.moveaxis(frozen_generator(flow, index)[:, :, active], -1, 0)
+ for index in range(sections)
+ ]
+ ),
+ dtype=np.complex128,
+ )
+ factors = [schur_blocks(np.moveaxis(block, 0, -1)) for block in frozen]
+ residual = None
+ if flow.section_methods != ("constant_schur",):
+ terms = generator[:, :, :, :, active]
+ if flow.connection is not None:
+ terms = terms - flow.connection[:, :, :, :, active]
+ residual = np.ascontiguousarray(
+ np.moveaxis(terms, (2, 3), (-2, -1)).reshape(
+ sections * flow.n_deg, -1, flow.n_modes, flow.n_modes
+ ),
+ dtype=np.complex128,
+ )
+ return CoupledFlowFactors(
+ active=active,
+ vectors=np.stack([pair[0] for pair in factors]),
+ triangular=np.stack([pair[1] for pair in factors]),
+ frozen=frozen,
+ condition_bound=np.ascontiguousarray(
+ flow.inverse_condition_bound[:, active], dtype=np.float64
+ ),
+ residual_terms=residual,
+ )
+
+
def flow_weights(
flow: CoupledFlowTables, z: float, section: int | None = None
) -> Float[np.ndarray, " n_flow_terms"]:
@@ -2181,580 +2294,12 @@ def _residual_enclosure(
return np.sum(np.linalg.norm(moved, ord=2, axis=(-2, -1)), axis=0)
-CF4_NODES: tuple[float, float] = (
- 0.5 - math.sqrt(3.0) / 6.0,
- 0.5 + math.sqrt(3.0) / 6.0,
-)
-"""The two Gauss-Legendre nodes of a CF4 step, as fractions of its length."""
-
-CF4_WEIGHTS: tuple[float, float] = (
- (3.0 - 2.0 * math.sqrt(3.0)) / 12.0,
- (3.0 + 2.0 * math.sqrt(3.0)) / 12.0,
-)
-"""``(a, b)`` of the two-exponential CF4: ``a + b = 1/2`` and ``b - a = 1/sqrt(3)``.
-
-Matching the integral of the generator fixes the sum, and matching the cubic Magnus
-term ``-h^3 [A_0, A_1]/12`` against the product's BCH commutator
-``-h^3 (b - a)[A_0, A_1]/(4 sqrt(3))`` fixes the difference. ``a = -0.0386751`` is
-**negative**, and is forced by the two-exponential ansatz: it is why a factor of a
-dissipative flow need not be contractive, and why :func:`cf4_flow` guards the growth
-of its internal factors.
-"""
-
-_CF4_GROWTH_EXPONENT: float = (2.0 * math.sqrt(3.0) - 3.0) / 6.0
-"""``2|a| = 0.0773503``: the excess growth rate of the two forward factors' product.
-
-Each factor's exponent is ``h(a A_early + b A_late)`` or ``h(b A_early + a A_late)``
-with ``a < 0 < b``. The logarithmic norm is subadditive and ``mu(c A) = c mu_min(A)``
-for ``c < 0``, so with every Hermitian-part eigenvalue of the generator in
-``[m_lo, m_hi]`` each factor has ``mu <= b m_hi - |a| m_lo``, and the two together
-``m_hi + 2|a|(m_hi - m_lo)``, because ``2b = 1 + 2|a|``. The exact action is bounded by
-``exp(h m_hi)``; the factors' product can exceed that by at most
-``exp(2|a| h (m_hi - m_lo))``.
-"""
-
-_CF4_CONDITION_EXPONENT: float = 2.0 / math.sqrt(3.0)
-"""``1 + 4|a| = 2/sqrt(3)``: the rate of the factor-norm product of a CF4 pair.
-
-The inverse factors have ``mu <= -b m_lo + |a| m_hi`` each, so the four factor norms of
-one forward-and-inverse pair multiply to at most ``exp((1 + 4|a|) h (m_hi - m_lo))``,
-against ``exp(h (m_hi - m_lo))`` for the exact flow.
-"""
-
-CF4_GROWTH_LIMIT: float = 2.0
-"""Default limit on a CF4 section's internal growth bound, dimensionless.
-
-A section whose bound ``exp(2|a| dz_max (m_hi - m_lo))`` exceeds it is refused by name
-(or falls back to ``"frozen_residual"`` where that is declared). Two is at most one bit
-of the state's scale lost to the product of the internal factors, and corresponds to
-``dz_max (m_hi - m_lo) <= 8.96`` -- about the nine e-foldings of the Hermitian spread
-that the default ``CoherentWall.condition_limit`` of 1e4 admits. It is a **proposal**
-reasoned from that bound, not a threshold established by experiment.
-"""
-
-
-def _hermitian_enclosure(
- coefficients: Complex[
- np.ndarray, "n_intervals_h n_deg_h n_modes_h_out n_modes_h_in n_omega_h"
- ],
- connection: Complex[
- np.ndarray, "n_intervals_k n_deg_k n_modes_k_out n_modes_k_in n_omega_k"
- ]
- | None,
-) -> tuple[Float[np.ndarray, "n_intervals_l n_omega_l"], Float[np.ndarray, "n_i n_w"]]:
- """Return an enclosure of the Hermitian-part spectrum of each section's generator.
-
- The generator a CF4 step exponentiates is ``L(z) = G(z) - C(z)``, the whole of it.
- With ``L = sum_k l_k T_k(x)`` on a section, the Hermitian part is
- ``Herm(l_0) + sum_{k>=1} Herm(l_k) T_k(x)`` and ``|T_k| <= 1``, so by Weyl's
- inequality every eigenvalue of ``Herm L(z)``, at every position of the section, lies
- within ``sum_{k>=1} ||Herm l_k||_2`` of the spectrum of ``Herm l_0``. That is an
- enclosure of the stored interpolant, not a sampled range. The connection is real
- and antisymmetric in the true-power amplitude, so in the kernel's it has a small
- symmetric part, of the order of the channels' weight spread; it is subtracted with
- the rest, because the enclosure is of what is exponentiated.
-
- - ``coefficients``: the generator's Chebyshev coefficients in 1/m.
- - ``connection``: the connection's, in the same layout, or ``None``.
- - Returns: the lower and upper ends ``m_lo`` and ``m_hi`` in 1/m, each of shape
- ``(n_intervals, n_omega)``.
- """
- terms = coefficients if connection is None else coefficients - connection
- moved = np.moveaxis(terms, (2, 3), (-2, -1))
- hermitian = 0.5 * (moved + np.conj(np.swapaxes(moved, -1, -2)))
- centre = np.linalg.eigvalsh(hermitian[:, 0])
- radius = np.zeros(centre.shape[:2])
- if hermitian.shape[1] > 1:
- radius = np.sum(np.linalg.norm(hermitian[:, 1:], ord=2, axis=(-2, -1)), axis=1)
- return centre[..., 0] - radius, centre[..., -1] + radius
-
-
-def _reach_spread(
- edges: Float[np.ndarray, " n_edges_r"],
- lower: Float[np.ndarray, "n_intervals_r n_omega_r"],
- upper: Float[np.ndarray, "n_intervals_r2 n_omega_r2"],
- dz_max: float,
-) -> Float[np.ndarray, "n_intervals_s n_omega_s"]:
- """Return the Hermitian spread every step anchored in a section can meet.
-
- A step anchored in section ``s`` starts inside it and is at most ``dz_max`` long, so
- its Gauss nodes lie in ``[lo_s, hi_s + dz_max]``, which may reach into the sections
- after it: its enclosure is the union of theirs. The spread is taken over that
- union, so that a certificate declared on a section holds for every step the
- controller may anchor there.
-
- - ``edges``: the section edges in m.
- - ``lower``, ``upper``: each section's enclosure, 1/m, shape ``(n_intervals,
- n_omega)``.
- - ``dz_max``: largest step, m.
- - Returns: ``m_hi - m_lo`` over each section's reach, 1/m, non-negative.
- """
- intervals = lower.shape[0]
- spread = np.empty_like(lower)
- for index in range(intervals):
- reach = int(np.searchsorted(edges, edges[index + 1] + dz_max, side="left"))
- last = min(max(reach, index + 1), intervals)
- spread[index] = np.max(upper[index:last], axis=0) - np.min(
- lower[index:last], axis=0
- )
- return spread
-
-
-def cf4_flow(
- omega: Float[np.ndarray, " n_omega"],
- z_edges: Float[np.ndarray, " n_edges"],
- generator: Complex[
- np.ndarray,
- "n_intervals_c4 n_deg_c4 n_modes_c4_out n_modes_c4_in n_omega_c4",
- ],
- connection: Complex[
- np.ndarray,
- "n_intervals_cc n_deg_cc n_modes_cc_out n_modes_cc_in n_omega_cc",
- ]
- | None,
- *,
- dz_max: float,
- condition_limit: float,
- growth_limit: float = CF4_GROWTH_LIMIT,
- fallback: Literal["frozen_residual"] | None = None,
- representation_error: float,
- gauge_id: str,
- certificate_id: str,
-) -> CoupledFlowTables:
- """Build the CF4 coupled flow of a varying guide, with its two guards.
-
- The certified alternative to :func:`frozen_residual_flow`:
- nothing is frozen and nothing is left to an explicit residual. A step of length
- ``h`` from ``z`` applies the fourth-order commutator-free exponential of the whole
- section generator ``L = G - C``,
-
- ``U(h) = exp(h (a A_1 + b A_2)) exp(h (b A_1 + a A_2))``,
-
- with ``A_j = L(z + c_j h)`` at the Gauss nodes ``c = 1/2 -+ sqrt(3)/6`` and
- ``a, b = (3 -+ 2 sqrt(3))/12``; the early-weighted factor acts first. Its local
- error is ``O(h^5)`` on a smooth section, so a run with it is at most **fourth**
- order globally, and the embedded Runge-Kutta pair does not see that error at all
- (:func:`cf4_defect`). Every section takes the same Chebyshev expansion
- :func:`frozen_residual_flow` does; what differs is the certificate.
-
- **The internal-growth guard**, before admission. ``a`` is negative, so a factor of
- a dissipative flow can grow: a block with eigenvalues 0 and -200/m over 1 m
- has internal factors of norm 1898 against an exact action of 0.96. With every
- Hermitian-part eigenvalue of ``L`` over the reach of a step enclosed in
- ``[m_lo, m_hi]`` (Weyl's inequality on the stored expansion), the logarithmic norm
- bounds the product of the two forward factors by ``exp(h m_hi)`` -- the largest the
- exact action can be -- times the **growth bound**
- ``exp((2 sqrt(3) - 3)/6 dz_max (m_hi - m_lo))``, and the four factors of a forward
- and inverse pair by ``exp((2/sqrt(3)) dz_max (m_hi - m_lo))``, which is the
- ``inverse_condition_bound`` a CF4 section declares and the backend checks per step
- against the factors it actually forms. A section whose growth bound exceeds
- `growth_limit`, or whose conditioning exceeds `condition_limit`, is **refused by
- name** -- or, only when `fallback` says so, takes ``"frozen_residual"`` instead,
- with a warning naming it.
-
- The rows of a CF4 section: ``generator_error`` is the expansion's relative tail
- alone, there being no Schur action to diagnose; ``residual_norm_bound`` is zero,
- there being no explicit residual; ``inverse_condition_bound`` is the pair bound
- above. A fallback section carries exactly the rows :func:`frozen_residual_flow`
- gives it.
-
- Parameters
- ----------
- omega : numpy.ndarray
- The canonical coarse angular frequencies in rad/s, increasing.
- z_edges : numpy.ndarray
- The section edges in m, shape ``(n_intervals + 1,)``, covering the run.
- generator : numpy.ndarray
- Chebyshev coefficients along z of the complete internal-convention generator in
- 1/m, shape ``(n_intervals, n_deg, n_modes, n_modes, n_omega)``.
- connection : numpy.ndarray or None
- The taper connection's coefficients in the same layout, or ``None``.
- dz_max : float
- Largest step the run may take, m, positive.
- condition_limit : float
- Largest admitted conditioning of a step's flow action, at least one.
- growth_limit : float, optional
- Largest admitted growth bound of a CF4 section, at least one. Default
- :data:`CF4_GROWTH_LIMIT`.
- fallback : {None, 'frozen_residual'}, optional
- What a section the guards reject becomes. ``None``, the default, refuses it.
- representation_error : float
- The relative Chebyshev tail of the expansions, non-negative.
- gauge_id : str
- The basis's phase and coordinate identity.
- certificate_id : str
- Identity of the certificate the bounds came from.
-
- Returns
- -------
- CoupledFlowTables
- The validated leaf, ``"cf4"`` on every admitted section.
-
- Raises
- ------
- TranslationError
- If a limit or the fallback is out of range, on everything
- :func:`frozen_residual_flow` refuses, if a section fails a guard with no
- fallback declared, or if a fallback section fails the frozen conditioning.
-
- Warns
- -----
- solprop.exceptions.SolpropWarning
- Naming every section that fell back, the guard that rejected it and its bound.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.coupled import cf4_flow
- >>> block = np.zeros((2, 1, 2, 2, 3), dtype=complex)
- >>> block[:, 0, 0, 0] = block[:, 0, 1, 1] = -1.0
- >>> flow = cf4_flow(
- ... np.linspace(1e15, 2e15, 3), np.array([0.0, 5e-4, 1e-3]), block, None,
- ... dz_max=1e-4, condition_limit=10.0, representation_error=0.0,
- ... gauge_id="lab", certificate_id="cf4",
- ... )
- >>> flow.section_methods, float(flow.inverse_condition_bound.max())
- (('cf4', 'cf4'), 1.0)
- """
- if not (math.isfinite(float(growth_limit)) and float(growth_limit) >= 1.0):
- msg = (
- "a CF4 flow's growth_limit bounds the product of its internal factors "
- f"against the exact action's and is at least one, got {growth_limit!r}"
- )
- raise TranslationError(msg)
- if fallback not in (None, "frozen_residual"):
- msg = (
- "a CF4 flow falls back to 'frozen_residual' where that is declared and "
- f"refuses otherwise; got fallback={fallback!r}"
- )
- raise TranslationError(msg)
- # The frozen split supplies the validation of every array and the rows a fallback
- # section keeps. Its own conditioning limit is lifted here and applied below to
- # the sections that actually fall back, because a CF4 section's conditioning is
- # its own pair bound and not the frozen block's.
- base = frozen_residual_flow(
- omega,
- z_edges,
- generator,
- connection,
- dz_max=dz_max,
- condition_limit=math.inf,
- representation_error=representation_error,
- gauge_id=gauge_id,
- certificate_id=certificate_id,
- )
- assert base.generator is not None
- lower, upper = _hermitian_enclosure(base.generator, base.connection)
- spread = _reach_spread(base.z_edges, lower, upper, float(dz_max))
- with np.errstate(over="ignore"):
- conditioning = np.exp(_CF4_CONDITION_EXPONENT * float(dz_max) * spread)
- growth = np.exp(_CF4_GROWTH_EXPONENT * float(dz_max) * spread)
- rejected = [
- index
- for index in range(spread.shape[0])
- if float(np.max(growth[index])) > float(growth_limit)
- or float(np.max(conditioning[index])) > float(condition_limit)
- ]
- reasons = [
- _cf4_rejection(
- index,
- base.omega,
- growth[index],
- conditioning[index],
- growth_limit=float(growth_limit),
- condition_limit=float(condition_limit),
- dz_max=float(dz_max),
- )
- for index in rejected
- ]
- if rejected and fallback is None:
- msg = (
- f"a CF4 coupled flow's guards reject {len(rejected)} of its "
- f"{spread.shape[0]} section(s): {'; '.join(reasons)}. Cap dz_max, divide "
- "the sections, raise the limit deliberately, or declare "
- "fallback='frozen_residual' to integrate those sections with the frozen "
- "split instead"
- )
- raise TranslationError(msg)
- for index in rejected:
- frozen = float(np.max(base.inverse_condition_bound[index]))
- if frozen > float(condition_limit):
- msg = (
- f"a CF4 coupled flow's section {index} is rejected by its guards "
- f"({reasons[rejected.index(index)]}) and cannot fall back either: its "
- f"frozen block's conditioning is {frozen:.4g} over dz_max = "
- f"{float(dz_max):.4g} m, above the {float(condition_limit):.4g} "
- "admitted. Cap dz_max"
- )
- raise TranslationError(msg)
- if rejected:
- warnings.warn(
- f"a CF4 coupled flow's guards reject {len(rejected)} of its "
- f"{spread.shape[0]} section(s), which fall back to 'frozen_residual' as "
- f"declared: {'; '.join(reasons)}",
- SolpropWarning,
- stacklevel=2,
- )
- error = np.array(base.generator_error)
- residual = np.array(base.residual_norm_bound)
- bound = np.array(base.inverse_condition_bound)
- methods: list[SectionMethod] = []
- for index in range(spread.shape[0]):
- if index in rejected:
- methods.append("frozen_residual")
- continue
- methods.append("cf4")
- error[index] = float(representation_error)
- residual[index] = 0.0
- bound[index] = np.maximum(conditioning[index], 1.0)
- return CoupledFlowTables(
- base.omega,
- base.z_edges,
- base.generator,
- None,
- None,
- None,
- None,
- base.connection,
- error,
- residual,
- bound,
- tuple(methods),
- gauge_id,
- certificate_id,
- )
-
-
-def _cf4_rejection(
- index: int,
- omega: Float[np.ndarray, " n_omega"],
- growth: Float[np.ndarray, " n_omega_g"],
- conditioning: Float[np.ndarray, " n_omega_c"],
- *,
- growth_limit: float,
- condition_limit: float,
- dz_max: float,
-) -> str:
- """Return the clause naming why a CF4 section was rejected.
-
- - ``omega``: the frequency axis in rad/s.
- - ``dz_max``: the step they were declared over, m.
- - Returns: one clause naming both bounds at the frequency where they are largest --
- the same frequency for both, each being increasing in the same spread.
- """
- worst = int(np.argmax(growth))
- return (
- f"section {index} has internal growth bound {float(growth[worst]):.4g} "
- f"(limit {growth_limit:.4g}) and pair conditioning "
- f"{float(conditioning[worst]):.4g} (limit {condition_limit:.4g}) over dz_max "
- f"= {dz_max:.4g} m at {float(omega[worst]):.6g} rad/s"
- )
-
-
-def cf4_growth_bound(
- flow: CoupledFlowTables,
-) -> Float[np.ndarray, "n_intervals n_omega"]:
- """Return the internal growth bound of every section, dimensionless.
-
- ``exp((2 sqrt(3) - 3)/6 dz_max (m_hi - m_lo))`` for a ``"cf4"`` section, read back
- from the conditioning row it declares, ``exp((2/sqrt(3)) dz_max (m_hi - m_lo))``:
- the growth bound is that row to the power ``(2 - sqrt(3))/4``, so it needs no row
- of its own (:func:`cf4_flow`). Every other section returns one, having no internal
- factors to grow.
-
- Parameters
- ----------
- flow : CoupledFlowTables
- The leaf.
-
- Returns
- -------
- numpy.ndarray
- Float64, shape ``(n_intervals, n_omega)``, at least one.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.coupled import cf4_flow, cf4_growth_bound
- >>> block = np.zeros((1, 1, 2, 2, 1), dtype=complex)
- >>> block[0, 0, 1, 1] = -100.0
- >>> flow = cf4_flow(
- ... np.array([1e15]), np.array([0.0, 1e-3]), block, None, dz_max=1e-3,
- ... condition_limit=10.0, representation_error=0.0, gauge_id="lab",
- ... certificate_id="cf4",
- ... )
- >>> f"{float(cf4_growth_bound(flow)[0, 0]):.6f}"
- '1.007765'
- """
- rows = np.ones_like(flow.inverse_condition_bound)
- power = _CF4_GROWTH_EXPONENT / _CF4_CONDITION_EXPONENT
- for index, method in enumerate(flow.section_methods):
- if method == "cf4":
- rows[index] = flow.inverse_condition_bound[index] ** power
- return rows
-
-
-def _flow_generator_at(
- flow: CoupledFlowTables,
- terms: Complex[np.ndarray, "n_flow_terms n_modes_g_out n_modes_g_in n_omega_g"],
- z: float,
- section: int,
-) -> Complex[np.ndarray, "n_omega_o n_modes_o_out n_modes_o_in"]:
- """Return ``L(z) = G(z) - C(z)`` of a step anchored in `section`, frequency first.
-
- - ``terms``: the flattened coefficients of ``G - C`` in 1/m.
- - ``z``: position in m.
- - Returns: the generator in 1/m, shape ``(n_omega, n_modes, n_modes)``.
- """
- combined = np.tensordot(flow_weights(flow, z, section), terms, axes=1)
- return np.ascontiguousarray(np.moveaxis(combined, -1, 0))
-
-
-def _cf4_action(
- flow: CoupledFlowTables,
- terms: Complex[np.ndarray, "n_flow_terms n_modes_a_out n_modes_a_in n_omega_a"],
- z: float,
- length: float,
- section: int,
-) -> Complex[np.ndarray, "n_omega_x n_modes_x_out n_modes_x_in"]:
- """Return the CF4 forward action over ``[z, z + length]``, by SciPy's exponential.
-
- The translation layer's own evaluation, independent of the backend's scaled Taylor
- series: the estimator and the executor are two implementations of one formula.
-
- - ``terms``: the flattened coefficients of ``G - C`` in 1/m.
- - ``z``: where the step starts, m.
- - ``length``: its length, m, positive.
- """
- early = _flow_generator_at(flow, terms, z + CF4_NODES[0] * length, section)
- late = _flow_generator_at(flow, terms, z + CF4_NODES[1] * length, section)
- small, large = CF4_WEIGHTS
- first = expm(length * (large * early + small * late))
- second = expm(length * (small * early + large * late))
- return second @ first
-
-
-def cf4_defect(
- flow: CoupledFlowTables,
- dz: float,
- kinks: Sequence[float] | Float[np.ndarray, " n_kinks"] = (),
-) -> Float[np.ndarray, "n_intervals n_omega"]:
- """Return the step-doubling estimate of every CF4 section's local flow defect.
-
- **The source-feedback estimator, and what kind of statement it is.** In the
- interaction picture ``u = U b`` the stage source is ``g = U^-1 N(U b)``, and a CF4
- run uses an approximation ``V`` of every stage's ``U``. With ``||U - V|| <= delta``,
- the source is perturbed by at most ``||V^-1|| delta (||U^-1|| ||N|| + L ||b||)`` --
- ``L`` the source's Lipschitz constant on a ball containing both states -- and the
- step's end is moved by ``delta ||b||`` directly. The direct term is ``O(h^5)`` per
- step; the feedback term enters the state through one more factor ``h`` and is
- ``O(h^6)``. The
- run is therefore **at most fourth order** in the flow's own error, and the embedded
- Runge-Kutta pair, which sees only the difference of two stage combinations, does
- not see it at all. No Lipschitz constant of the material response is available on
- a containing state ball -- Kerr, Raman and plasma each have their own, and none is
- enclosed by this package -- so the rigorous envelope is **not** what is declared.
- What is declared is the **asymptotic estimator**: two half steps have one sixteenth
- of the leading error of a full one, so ``(16/15) ||V(h) - V(h/2)^2||`` estimates the
- full step's local defect, qualified by observable refinement (measured against
- an independent DOP853 solution of the flow alone, and by the run's own order). It
- is neither a bound nor valid near a kink, at the rounding floor, or outside the
- asymptotic regime -- which nothing but this estimate polices: the internal-growth
- guard bounds growth from the Hermitian spread alone, so a
- lossless section admits any step, and far outside the regime the estimate
- **underestimates** (0.19 against a true 0.38 at ``Δβ h = 100``). It is what
- :func:`solprop.translate.problem.validate_coupled_flow` compares with the
- controller's tolerance.
-
- Each CF4 section is sampled with a step of length `dz` aligned with its start, its
- middle and its end, and the largest relative defect is kept, because the steepest
- part of a taper is where the defect is. A sampled step ends at the first of
- `kinks` after its start, and at the end of the run, because a step the driver
- takes does: adaptive stepping lands on every hard kink and a fixed schedule that
- straddles one is refused, and a CF4 step across a corner of the
- generator is not fourth order at all. Every other section returns zero.
-
- Parameters
- ----------
- flow : CoupledFlowTables
- The leaf, dense.
- dz : float
- The step to estimate over, m, positive.
- kinks : sequence of float, optional
- The run's hard kinks in m (``linear.hard_kinks``), which no step crosses.
-
- Returns
- -------
- numpy.ndarray
- The relative defect ``||V(h) - U(h)||_2 / ||U(h)||_2``, estimated, shape
- ``(n_intervals, n_omega)``.
-
- Raises
- ------
- TranslationError
- If the flow is factored or `dz` is not positive.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.coupled import cf4_defect, cf4_flow
- >>> block = np.zeros((1, 1, 2, 2, 1), dtype=complex)
- >>> block[0, 0, 1, 1] = -100.0
- >>> flow = cf4_flow(
- ... np.array([1e15]), np.array([0.0, 1e-3]), block, None, dz_max=1e-3,
- ... condition_limit=10.0, representation_error=0.0, gauge_id="lab",
- ... certificate_id="cf4",
- ... )
- >>> bool(cf4_defect(flow, 1e-3).max() < 1e-15)
- True
- """
- if flow.generator is None:
- msg = "a factored coupled flow has no dense generator to take a CF4 step of"
- raise TranslationError(msg)
- if not (float(dz) > 0.0):
- msg = f"a CF4 defect is estimated over a positive step, got {dz!r}"
- raise TranslationError(msg)
- edges = flow.z_edges
- step = float(dz)
- stops = np.unique(np.append(np.asarray(kinks, dtype=float), float(edges[-1])))
- slack = flow_edge_slack(flow)
- terms = (
- flow.generator if flow.connection is None else flow.generator - flow.connection
- )
- terms = terms.reshape(-1, *terms.shape[2:])
- defect = np.zeros(flow.inverse_condition_bound.shape)
- for index, method in enumerate(flow.section_methods):
- if method != "cf4":
- continue
- lo, hi = float(edges[index]), float(edges[index + 1])
- spare = max(hi - lo - step, 0.0)
- for anchor in sorted({lo, lo + 0.5 * spare, lo + spare}):
- following = stops[stops > anchor + slack]
- length = min(step, float(following[0]) - anchor)
- coarse = _cf4_action(flow, terms, anchor, length, index)
- fine = _cf4_action(
- flow, terms, anchor + 0.5 * length, 0.5 * length, index
- ) @ _cf4_action(flow, terms, anchor, 0.5 * length, index)
- relative = (
- 16.0
- / 15.0
- * np.linalg.norm(coarse - fine, ord=2, axis=(-2, -1))
- / np.linalg.norm(fine, ord=2, axis=(-2, -1))
- )
- defect[index] = np.maximum(defect[index], relative)
- return defect
-
-
def wall_identity(
flow: CoupledFlowTables,
limits: WallLimits,
*,
channels: Sequence[ChannelLabel],
impedance: WallImpedance = "mean",
- growth_limit: float | None = None,
- fallback: str | None = None,
- dz_max: float | None = None,
- kinks: Sequence[float] | Float[np.ndarray, " n_kinks"] = (),
scored: Bool[np.ndarray, " n_omega"] | None = None,
n_components: int = 1,
has_noise: bool = False,
@@ -2799,16 +2344,6 @@ def wall_identity(
:func:`solprop.output.capability.capability_of` still reads it as the ordinary
modal basis and the ordinary accessors stay admitted. A resume across a
changed wall impedance is refused by this field.
- growth_limit : float or None, optional
- The CF4 growth limit the flow was admitted under; required, with `dz_max`,
- exactly when a section is ``"cf4"``.
- fallback : str or None, optional
- The declared fallback of a CF4 flow, recorded as declared.
- dz_max : float or None, optional
- The largest step, m, over which the CF4 defect is estimated.
- kinks : sequence of float, optional
- The run's hard kinks in m, at which a sampled CF4 step ends
- (:func:`cf4_defect`).
scored : numpy.ndarray of bool, optional
The error norm's bins, over which the recorded advisory step is taken, as the
run's warning takes it (:func:`advisory_step`); ``None`` takes every bin.
@@ -2823,18 +2358,7 @@ def wall_identity(
Returns
-------
collections.abc.Mapping
- The ``modal`` record of :class:`solprop.output.schema.RunMetadata`. A flow with
- a ``"cf4"`` section adds a ``flow.cf4`` entry beside ``advisory_step`` -- a
- new key entry, as ``flow`` itself is, and absent from every other flow,
- whose record is therefore unchanged: the largest internal growth bound
- (:func:`cf4_growth_bound`), the limit it was held to, the declared fallback,
- and the largest step-doubling defect estimate at `dz_max`
- (:func:`cf4_defect`), named as the asymptotic estimator it is.
-
- Raises
- ------
- TranslationError
- If a CF4 flow is recorded without its growth limit or its step.
+ The ``modal`` record of :class:`solprop.output.schema.RunMetadata`.
Examples
--------
@@ -2878,25 +2402,6 @@ def wall_identity(
float(np.min(advisory_step(flow, scored)))
),
}
- if "cf4" in flow.section_methods:
- if growth_limit is None or dz_max is None:
- msg = (
- "a CF4 coupled flow's record carries the growth limit it was admitted "
- "under and the step its defect is estimated over; pass growth_limit "
- "and dz_max"
- )
- raise TranslationError(msg)
- record["cf4"] = {
- "growth_bound": quantised_measurement(
- float(np.max(cf4_growth_bound(flow)))
- ),
- "growth_limit": float(growth_limit),
- "fallback": fallback,
- "defect": quantised_measurement(
- float(np.max(cf4_defect(flow, dz_max, kinks)))
- ),
- "defect_estimator": "step doubling, asymptotic",
- }
return {
"basis": (
"capillary HE_1m" if impedance == "mean" else "capillary azimuthal order"
diff --git a/src/solprop/translate/general.py b/src/solprop/translate/general.py
index e1f30a808..095e81059 100644
--- a/src/solprop/translate/general.py
+++ b/src/solprop/translate/general.py
@@ -32,7 +32,7 @@
from typing import Final, cast
import numpy as np
-from jaxtyping import Bool, Complex, Float
+from jaxtyping import Bool, Complex, Float, Int32
from solprop import fourier
from solprop._display import Displayed, line
@@ -76,6 +76,9 @@
sample_modal_table,
)
from solprop.translate.responses import (
+ DELAYED_MODEL_ID,
+ KERR_MODEL_FORMS,
+ MODEL_ID_SEPARATOR,
DispersiveKerrTerms,
KerrForm,
RegionalResponseTables,
@@ -88,12 +91,16 @@
"N2_CONVERSION_RTOL",
"GeneralModalRoute",
"check_noise_basis",
+ "check_regional_responses",
"coupled_block",
"general_modal_route",
"guide_identity",
"interpolated_gram",
"metric_departure",
"modal_identity",
+ "node_delayed_maps",
+ "node_kerr_form",
+ "node_kerr_tensor",
"occupancy_weight",
]
@@ -2109,3 +2116,195 @@ def check_noise_basis(metric: ModalMetricTables | None, *, has_noise: bool) -> N
"derived. Run without noise, or in a certified orthonormal basis"
)
raise TranslationError(msg)
+
+
+def check_regional_responses(regional: RegionalResponseTables | None) -> None:
+ """Refuse a per-region response the general route does not execute, by name.
+
+ The one rule, run by the problem's validation
+ (:func:`solprop.translate.admission.check_general_route`) and by the derivations
+ below, which build nothing from a record they cannot read: a susceptibility
+ constant along z, a model identity this package knows, delayed terms that agree
+ with it, and a uniform temporal weight beside them. The component count is
+ :meth:`~solprop.translate.problem.PropagationProblem.validate`'s.
+
+ Parameters
+ ----------
+ regional : solprop.translate.responses.RegionalResponseTables or None
+ The compiled per-region responses, or ``None`` for a linear guide.
+
+ Raises
+ ------
+ TranslationError
+ Naming the leaf and what about it is not executed.
+ """
+ if regional is None:
+ return
+ if regional.chi3.shape[0] != 1 or regional.chi3.shape[1] != 1:
+ msg = (
+ "solprop executes a general modal guide of constant geometry, and "
+ f"responses.regional_responses.chi3 carries {regional.chi3.shape[0]} "
+ f"interval(s) of degree {regional.chi3.shape[1] - 1} along z; a varying "
+ "susceptibility is not supported on this route"
+ )
+ raise TranslationError(msg)
+ tags = regional.model_id.split(MODEL_ID_SEPARATOR)
+ unknown = [one for one in tags[1:] if one != DELAYED_MODEL_ID]
+ if tags[0] not in KERR_MODEL_FORMS or unknown:
+ msg = (
+ f"responses.regional_responses declares model_id "
+ f"{regional.model_id!r} and solprop executes the instantaneous "
+ f"isotropic cubic in its two declared forms, "
+ f"{sorted(KERR_MODEL_FORMS)}, optionally beside "
+ f"{DELAYED_MODEL_ID!r}; a model this package does not know must "
+ "not be evaluated as one it does"
+ )
+ raise TranslationError(msg)
+ if (DELAYED_MODEL_ID in tags) != bool(regional.n_terms):
+ msg = (
+ f"responses.regional_responses declares model_id "
+ f"{regional.model_id!r} and {regional.n_terms} delayed term(s): a "
+ "record's model identity and its term axis must agree, or the run "
+ "would execute a model its own file does not name"
+ )
+ raise TranslationError(msg)
+ weight = regional.temporal_weight
+ if regional.n_terms and not np.all(weight == weight[0]):
+ msg = (
+ "responses.regional_responses.temporal_weight is not uniform, and a "
+ "delayed response is evaluated as a transform product on the doubled "
+ "fine axis, which is a convolution only against a uniform quadrature "
+ "weight; a non-uniform temporal grid needs a different evaluation"
+ )
+ raise TranslationError(msg)
+
+
+def node_kerr_tensor(
+ tables: ModalTransformTables, regional: RegionalResponseTables | None
+) -> Float[np.ndarray, "n_nodes 3 3 3 3"]:
+ """Sum each node's material responses into one susceptibility tensor.
+
+ A response acts in one material region and a node belongs to exactly one, so the
+ tensor a node sees is ``sum_r chi3_r T_r`` over the responses of its region. Two
+ responses in one region add, which is what superposed instantaneous
+ susceptibilities do; a region no response names contributes nothing, which is how
+ a linear cladding is declared.
+
+ ``chi3`` is stored as Chebyshev coefficients along z. This route propagates
+ constant geometry, so one interval of degree zero is required and the single
+ coefficient is the value.
+
+ Parameters
+ ----------
+ tables : solprop.translate.modal_tables.ModalTransformTables
+ The guide's reference, whose ``region_index`` owns each node's region.
+ regional : solprop.translate.responses.RegionalResponseTables or None
+ The compiled per-region responses, or ``None`` for a linear guide.
+
+ Returns
+ -------
+ numpy.ndarray
+ The per-node tensor, float64, shape ``(n_nodes, 3, 3, 3, 3)``, in m²/V².
+
+ Raises
+ ------
+ TranslationError
+ If the record is not one the route executes (:func:`check_regional_responses`).
+ """
+ check_regional_responses(regional)
+ nodes = int(tables.region_index.size)
+ components = int(tables.electric.shape[2])
+ summed = np.zeros((nodes, *(components,) * 4))
+ if regional is None:
+ return summed
+ for index, region in enumerate(regional.response_region):
+ rows = tables.region_index == region
+ chi3 = float(regional.chi3[0, 0, index])
+ summed[rows] += chi3 * regional.instantaneous_tensor[index]
+ return summed
+
+
+def node_kerr_form(regional: RegionalResponseTables | None) -> KerrForm:
+ """Return which cubic a compiled per-region record declares.
+
+ The form is a physical-model choice and travels in ``model_id``, which is a
+ representation tag inside the resume identity already; it is **not** a leaf
+ field, because adding one would move a contract row for a string the record can
+ carry.
+
+ Parameters
+ ----------
+ regional : solprop.translate.responses.RegionalResponseTables or None
+ The compiled per-region responses, or ``None`` for a linear guide, whose
+ zero tensor makes the form immaterial.
+
+ Returns
+ -------
+ {'analytic', 'real'}
+ The declared form; ``"analytic"`` for a linear guide.
+
+ Raises
+ ------
+ TranslationError
+ If the record is not one the route executes (:func:`check_regional_responses`).
+ """
+ if regional is None:
+ return "analytic"
+ check_regional_responses(regional)
+ return cast(
+ "KerrForm", KERR_MODEL_FORMS[regional.model_id.split(MODEL_ID_SEPARATOR)[0]]
+ )
+
+
+def node_delayed_maps(
+ tables: ModalTransformTables, regional: RegionalResponseTables | None
+) -> tuple[
+ Int32[np.ndarray, " n_nodes"],
+ Float[np.ndarray, "n_responses n_terms 3 3 3 3"] | None,
+ Complex[np.ndarray, "n_responses n_terms n_rfft"] | None,
+]:
+ """Derive the delayed response's per-node ownership, tensors and kernel spectra.
+
+ A delayed response has **memory**, so unlike the instantaneous tensor it cannot
+ be summed into one per-node array: two regions may declare different kernels and
+ a convolution is not linear in the kernel's owner. What is derived instead is
+ the response each node belongs to, each response's ``chi3_r D[term]``, and each
+ kernel's transform on the doubled fine axis, already scaled by the temporal
+ quadrature weight so that the transform product is the causal integral.
+
+ Parameters
+ ----------
+ tables : solprop.translate.modal_tables.ModalTransformTables
+ The guide's reference, whose ``region_index`` owns each node's region.
+ regional : solprop.translate.responses.RegionalResponseTables or None
+ The compiled per-region responses, or ``None`` for a linear guide.
+
+ Returns
+ -------
+ tuple
+ The per-node response index (``-1`` where a node's region declares none),
+ the scaled delayed tensors and the kernel spectra, the last two ``None``
+ when no region declares a delayed response.
+
+ Raises
+ ------
+ TranslationError
+ If the record is not one the route executes (:func:`check_regional_responses`).
+ """
+ check_regional_responses(regional)
+ nodes = int(tables.region_index.size)
+ owner = np.full(nodes, -1, dtype=np.int32)
+ if regional is None:
+ return owner, None, None
+ for index, region in enumerate(regional.response_region):
+ owner[tables.region_index == region] = index
+ if not regional.n_terms:
+ return owner, None, None
+ n_to = int(regional.temporal_weight.size)
+ scaled = (
+ regional.delayed_tensor * regional.chi3[0, 0][:, None, None, None, None, None]
+ )
+ spectra = np.fft.rfft(regional.delayed_kernel[0, 0], n=2 * n_to, axis=-1) * float(
+ regional.temporal_weight[0]
+ )
+ return owner, scaled, spectra
diff --git a/src/solprop/translate/linop.py b/src/solprop/translate/linop.py
index e76152f0e..1e27c085f 100644
--- a/src/solprop/translate/linop.py
+++ b/src/solprop/translate/linop.py
@@ -275,7 +275,7 @@ class LinearOperator:
coupled_flow : solprop.translate.coupled.CoupledFlowTables or None
Kernel schema 12: the generator of a guide whose channels couple -- a
coherent wall, a taper's connection, a split block, a moving guide;
- executed by ``numpy_ref`` and ``native``, a CF4 section by ``numpy_ref`` alone.
+ executed by ``numpy_ref`` and ``native``.
``None`` on every route that declares no coupling.
modal_metric : solprop.translate.coupled.ModalMetricTables or None
Kernel schema 12: the explicit physical flux metric, a documented leaf here
diff --git a/src/solprop/translate/modal.py b/src/solprop/translate/modal.py
index 4ed842cc1..61ab2b6a9 100644
--- a/src/solprop/translate/modal.py
+++ b/src/solprop/translate/modal.py
@@ -51,15 +51,55 @@
from solprop.translate.spatial import SpatialGeometry
__all__ = [
+ "QUADRATURE_ALARM",
"Transform",
"TransformKind",
"general_modal_transform",
"mode_averaged_transform",
"multimode_transform",
+ "warn_quadrature",
]
logger = logging.getLogger(__name__)
+QUADRATURE_ALARM = 1e-6
+"""Relative quadrature error above which the alarm is logged, once per plane.
+
+The fixed rule integrates the Kerr quartic of a mode set to the reference's own
+rounding -- 2e-14 at ``n_r = 32`` for four HE_1m modes
+(``docs/guide/waveguides/mode-sets.md``) -- so
+anything a millionth of the polarisation is not the rule converging slowly but the
+integrand being unresolved, which no step size can fix. It also sits at the loose end
+of the tolerances a run asks the controller for, so an alarm means the quadrature has
+become the largest error in the step. The number is a judgement, not a measurement.
+"""
+
+
+def warn_quadrature(score: float, z: float) -> None:
+ """Log that a step's transverse quadrature score passed :data:`QUADRATURE_ALARM`.
+
+ Every backend gives the alarm here, on this module's logger, once per plane: the
+ embedded coarse rule's projection departs from the fixed rule's by ``score`` of the
+ nonlinear polarisation at ``z``, in metres.
+
+ Parameters
+ ----------
+ score : float
+ The relative quadrature error, above :data:`QUADRATURE_ALARM`.
+ z : float
+ The position of the step, m.
+ """
+ logger.warning(
+ "the transverse quadrature reproduces the nonlinear polarisation to "
+ "only %.2e at z = %.6g m, above the %.0e alarm; the integrand is not "
+ "resolved by this rule (a kinked ionisation rate, or too few nodes for "
+ "the mode set) and no step size can fix it",
+ score,
+ z,
+ QUADRATURE_ALARM,
+ )
+
+
TransformKind = Literal["mode_averaged", "modal", "radial", "free", "slab"]
"""What a transform represents (kernel schema 26, SEP 19).
diff --git a/src/solprop/translate/precision.py b/src/solprop/translate/precision.py
deleted file mode 100644
index 95481ee23..000000000
--- a/src/solprop/translate/precision.py
+++ /dev/null
@@ -1,699 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""The arithmetic a run is held in, and the range rules of single precision (SEP 33).
-
-A run is ``"double"`` (binary64, the default and the only arithmetic of ``native``) or
-``"single"``: the propagated state, the stage values, every transform and the responses
-in IEEE binary32, with the controller, every host scalar and the
-linear propagator's exponent in binary64
-(``docs/dev/seps/archive/sep-0033-single-precision-mlx.md`` section 4.2, ADR-054).
-This module holds what every backend that admits single precision shares, so that
-they compute the same exponents and refuse the same things:
-
-* :class:`Precision`, the record a
- :class:`~solprop.translate.problem.PropagationProblem` carries, with named fields so
- that a later mixed mode (a binary32 source beside a binary64 state) is a third public
- value rather than a schema redesign;
-* :func:`admit_precision`, the shared admission of the defaults and refusals;
-* the range helpers of SEP 33 section 4.3: :func:`block_exponent` and :func:`narrow`
- for every table narrowed at upload, :func:`state_scale` for the state's exact
- power-of-two scale, :class:`Coefficient` and :func:`combine` for the scalar factors
- combined on the host, and :func:`pairwise_sum`, :func:`prefix_sum` and
- :func:`scaled_square_sum`, the reduction orders the single-precision oracle defines
- (SEP 33, Q5).
-
-Nothing here changes a binary64 run: the translation layer's tables stay binary64, and a
-backend narrows them on upload.
-"""
-
-import math
-from dataclasses import dataclass
-from typing import Literal, get_args
-
-import numpy as np
-
-from solprop import fourier
-from solprop._ownership import hold
-from solprop.exceptions import TranslationError
-from solprop.fourier import GridKind
-
-__all__ = [
- "DOUBLE",
- "SINGLE",
- "SINGLE_PRECISION_BACKENDS",
- "SINGLE_RTOL_CAP",
- "SINGLE_RTOL_FLOOR",
- "Coefficient",
- "Narrowed",
- "Precision",
- "PrecisionName",
- "admit_precision",
- "block_exponent",
- "combine",
- "launch_peak",
- "narrow",
- "pairwise_sum",
- "precision_named",
- "prefix_sum",
- "scaled_square_sum",
- "state_scale",
-]
-
-PrecisionName = Literal["double", "single"]
-"""The public values of ``precision``: binary64 or binary32 arithmetic."""
-
-SINGLE_RTOL_FLOOR = 1e-6
-"""The smallest relative tolerance a single-precision run admits (SEP 33, Q1).
-
-A local error target below the per-step rounding of binary32, about ten times its unit
-roundoff, buys steps without accuracy. An explicit tolerance below the floor is refused,
-and an interface whose binary64 default is tighter takes the floor under single
-precision.
-"""
-
-SINGLE_RTOL_CAP = 1e-4
-"""The loosest default relative tolerance a single-precision run takes.
-
-The loosest the single-precision survival record has measured. It measured 1e-6 and
-1e-5 first; free space's binary64 default of 1e-4 was qualified in binary32 on 5
-November 2026 (#85): on a radial filament, a radial beam with its transverse collar and
-a Cartesian filament, binary32 at 1e-4 lies as close to a binary64 run at 1e-6 as
-binary64 at 1e-4 does, or closer. An explicit tolerance above it is the caller's, and
-is admitted.
-"""
-
-SINGLE_PRECISION_BACKENDS: tuple[str, ...] = ("numpy_ref",)
-"""The backends that admit ``precision="single"``.
-
-``numpy_ref`` is the single-precision oracle (SEP 33 phase 1). ``native`` refuses it by
-name until a later proposal adds its own mode.
-"""
-
-
-@dataclass(frozen=True, slots=True)
-class Precision:
- """The arithmetic of a run's propagated state and of its source evaluation.
-
- The public strings map to equal fields: ``"double"`` is binary64 throughout and
- ``"single"`` binary32 throughout, with the host scalars, the controller and the
- propagator's exponent in binary64 either way. A mixed mode would set the two fields
- apart and is not admitted.
-
- Attributes
- ----------
- state : {'double', 'single'}
- The propagated state, the stage values and the error estimate's array.
- source : {'double', 'single'}
- The transforms and the responses of a source evaluation.
-
- Examples
- --------
- >>> from solprop.translate.precision import Precision, precision_named
- >>> precision_named("single")
- Precision(state='single', source='single')
- >>> Precision().name, precision_named("single").single
- ('double', True)
- """
-
- state: PrecisionName = "double"
- source: PrecisionName = "double"
-
- def __post_init__(self) -> None:
- """Refuse an unknown name or a mixed record.
-
- Raises
- ------
- TranslationError
- If either field is not a public value, or the two differ.
- """
- names = get_args(PrecisionName)
- bad = self.state not in names or self.source not in names
- if bad: # pragma: no cover - refused by the annotation where it is checked
- msg = (
- f"precision fields must each be one of {names}, got state="
- f"{self.state!r}, source={self.source!r}"
- )
- raise TranslationError(msg)
- if self.state != self.source:
- msg = (
- "a mixed precision (a binary32 source beside a binary64 state, or the "
- "reverse) is not admitted; use precision='double' or 'single'"
- )
- raise TranslationError(msg)
-
- @property
- def name(self) -> PrecisionName:
- """Return the public value this record stands for.
-
- Returns
- -------
- {'double', 'single'}
- The shared value of the two fields.
- """
- return self.state
-
- @property
- def single(self) -> bool:
- """Return whether the state is held in binary32.
-
- Returns
- -------
- bool
- True under ``precision="single"``.
- """
- return self.state == "single"
-
- @property
- def complex_dtype(self) -> np.dtype:
- """Return the dtype of the state and of every complex pass.
-
- Returns
- -------
- numpy.dtype
- ``complex64`` under single precision, ``complex128`` otherwise.
- """
- return np.dtype(np.complex64 if self.single else np.complex128)
-
- @property
- def real_dtype(self) -> np.dtype:
- """Return the dtype of every real pass.
-
- Returns
- -------
- numpy.dtype
- ``float32`` under single precision, ``float64`` otherwise.
- """
- return np.dtype(np.float32 if self.single else np.float64)
-
-
-DOUBLE = Precision()
-"""Binary64 throughout: the default."""
-
-SINGLE = Precision("single", "single")
-"""Binary32 for the state, the stages and the source evaluation."""
-
-
-def precision_named(name: PrecisionName | Precision) -> Precision:
- """Return the record for a public precision value.
-
- Parameters
- ----------
- name : {'double', 'single'} or Precision
- The public value, or a record, which is returned unchanged.
-
- Returns
- -------
- Precision
- :data:`DOUBLE` or :data:`SINGLE`.
-
- Raises
- ------
- TranslationError
- If `name` is not a public value.
-
- Examples
- --------
- >>> from solprop.translate.precision import precision_named
- >>> precision_named("double").name
- 'double'
- """
- if isinstance(name, Precision):
- return name
- if name == "double":
- return DOUBLE
- if name != "single": # pragma: no cover - refused by the annotation where checked
- msg = f"precision must be 'double' or 'single', got {name!r}"
- raise TranslationError(msg)
- return SINGLE
-
-
-def admit_precision(
- precision: Precision,
- *,
- backend: str,
- rtol: float | None,
- schedule: bool,
-) -> None:
- """Apply the single-precision refusals every interface and backend shares.
-
- SEP 33 section 5.2. A binary64 run passes unchanged. A single-precision run is
- refused on a backend that does not admit it (:data:`SINGLE_PRECISION_BACKENDS`),
- and with an adaptive tolerance below :data:`SINGLE_RTOL_FLOOR`. The Adams
- integrator is admitted: binary32 Adams agrees with binary64 Adams to the method's
- own truncation on the survival cases (#342). The routes and responses no backend
- runs in binary32 are refused by
- :meth:`~solprop.translate.problem.PropagationProblem.validate`, where they are
- known.
-
- Parameters
- ----------
- precision : Precision
- The run's precision.
- backend : str
- The concrete registry name the run executes on.
- rtol : float or None
- The controller's relative tolerance, or ``None`` where the controller has none.
- schedule : bool
- Whether a fixed schedule is replayed, which reads no tolerance.
-
- Raises
- ------
- TranslationError
- Naming the refusal and what to do instead.
-
- Examples
- --------
- >>> from solprop.translate.precision import SINGLE, admit_precision
- >>> admit_precision(SINGLE, backend="numpy_ref", rtol=1e-6, schedule=False) is None
- True
- """
- if not precision.single:
- return
- if backend not in SINGLE_PRECISION_BACKENDS:
- msg = (
- f"backend {backend!r} computes in binary64 only and refuses "
- f"precision='single'; it runs on "
- f"{', '.join(SINGLE_PRECISION_BACKENDS)} (SEP 33)"
- )
- raise TranslationError(msg)
- if not schedule and rtol is not None and rtol < SINGLE_RTOL_FLOOR:
- msg = (
- f"rtol={rtol:g} is below the single-precision floor {SINGLE_RTOL_FLOOR:g}: "
- "a tighter local error target buys steps without accuracy in binary32; "
- "take rtol at least the floor, or precision='double'"
- )
- raise TranslationError(msg)
-
-
-def block_exponent(values: np.ndarray | float) -> int:
- """Return the power of two that brings a buffer's largest magnitude into [1, 2).
-
- SEP 33 section 4.3, rule 1: a buffer narrowed to binary32 is stored as
- ``values * 2**-e``, which is `values` rounded once, with no range loss for entries
- within ``2**-126`` of its largest.
-
- Parameters
- ----------
- values : numpy.ndarray or float
- The binary64 buffer, real or complex, finite.
-
- Returns
- -------
- int
- ``e`` with ``max |values| * 2**-e`` in ``[1, 2)``, or 0 for an all-zero buffer.
-
- Raises
- ------
- TranslationError
- If the buffer holds a non-finite value.
-
- Examples
- --------
- >>> from solprop.translate.precision import block_exponent
- >>> [block_exponent(x) for x in (1.0, 3.0, 0.25, 0.0)]
- [0, 1, -2, 0]
- """
- largest = float(np.max(np.abs(values), initial=0.0))
- if not math.isfinite(largest):
- raise TranslationError("a buffer narrowed to binary32 must be finite")
- if largest == 0.0:
- return 0
- return math.frexp(largest)[1] - 1
-
-
-@dataclass(frozen=True, slots=True)
-class Narrowed:
- """A binary64 buffer narrowed to binary32 with its own power-of-two exponent.
-
- The buffer stands for ``values * 2**exponent``; the exponent is a host integer and
- is folded into the binary64 coefficient the buffer is multiplied by
- (:func:`combine`), never applied to the binary32 values.
-
- Attributes
- ----------
- values : numpy.ndarray
- The scaled buffer, ``float32`` or ``complex64``, read-only, with its largest
- magnitude in ``[1, 2)`` (or zero).
- exponent : int
- The block exponent, :func:`block_exponent` of the binary64 buffer.
- """
-
- values: np.ndarray
- exponent: int
-
- def widened(self) -> np.ndarray:
- """Return the buffer the narrowed one stands for, in binary64.
-
- Returns
- -------
- numpy.ndarray
- ``values * 2**exponent``, exactly, as ``float64`` or ``complex128``.
- """
- wide = self.values.astype(np.result_type(self.values.dtype, np.float64))
- return _ldexp(wide, self.exponent)
-
-
-def narrow(values: np.ndarray) -> Narrowed:
- """Narrow a binary64 buffer to binary32 with its block exponent.
-
- Multiplying by a power of two is exact in binary64 for these magnitudes, so the
- narrowed values are the buffer rounded once.
-
- Parameters
- ----------
- values : numpy.ndarray
- The binary64 buffer, real or complex, finite.
-
- Returns
- -------
- Narrowed
- The read-only ``float32`` or ``complex64`` buffer and its exponent.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.precision import narrow
- >>> kernel = narrow(np.array([5.2e-50, 1.0e-50]))
- >>> kernel.values.dtype, kernel.exponent, bool(np.all(kernel.values > 0))
- (dtype('float32'), -164, True)
- """
- exponent = block_exponent(values)
- source = np.asarray(values)
- dtype = np.complex64 if np.iscomplexobj(source) else np.float32
- scaled = source * math.ldexp(1.0, -exponent)
- result = hold(np.ascontiguousarray(scaled, dtype=dtype))
- return Narrowed(result, exponent)
-
-
-def launch_peak(field0: np.ndarray, kind: GridKind) -> float:
- """Return the peak temporal amplitude of a launch, in the state's own units.
-
- The quantity the state scale is chosen from (SEP 33 section 4.3, rule 2): every
- column of the canonical state transformed to time on its own grid, without
- padding, in binary64.
-
- Parameters
- ----------
- field0 : numpy.ndarray
- The launch, complex, in the canonical layout ``(..., n_omega)``.
- kind : {'field', 'envelope'}
- The spectral layout.
-
- Returns
- -------
- float
- The largest temporal magnitude, non-negative.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.precision import launch_peak
- >>> launch_peak(np.full((1, 1, 1, 4), 2.0 + 0.0j), "envelope")
- 2.0
- """
- values = np.asarray(field0, dtype=np.complex128)
- if values.size == 0:
- return 0.0
- return float(np.max(np.abs(fourier.inverse(values, kind=kind)), initial=0.0))
-
-
-def state_scale(peak: float) -> float:
- """Return the exact power-of-two scale of a single-precision run's state.
-
- SEP 33 section 4.3, rule 2: the state is held as ``a / s`` with ``s = 2**k`` chosen
- from the launch so that the launch's peak temporal amplitude over ``s`` lies in
- ``[1, 2)``; ``s = 1`` for an identically zero launch. Being a function of the
- problem, not of the state at ``set_initial``, it is the same for a run, its restarts
- and its resumes.
-
- Parameters
- ----------
- peak : float
- The launch's peak temporal amplitude, in the state's units, non-negative.
-
- Returns
- -------
- float
- ``2**k``.
-
- Examples
- --------
- >>> from solprop.translate.precision import state_scale
- >>> state_scale(3.0e4), state_scale(0.0)
- (16384.0, 1.0)
- """
- return math.ldexp(1.0, block_exponent(peak))
-
-
-@dataclass(frozen=True, slots=True)
-class Coefficient:
- """A scalar factor combined on the host as a binary64 mantissa and an exponent.
-
- SEP 33 section 4.3, rule 3: the density, an amplitude, a prefactor's constant, the
- state scale's powers and the buffers' exponents are combined in binary64 into one
- coefficient per product, kept as a mantissa in ``[0.5, 1)`` (or zero) and a
- separate integer exponent, so that the combination itself cannot leave binary64's
- range whatever the exponents.
-
- Attributes
- ----------
- mantissa : float
- The binary64 mantissa, ``0`` or of magnitude in ``[0.5, 1)``.
- exponent : int
- The power of two it multiplies.
- """
-
- mantissa: float
- exponent: int
-
- @property
- def value(self) -> float:
- """Return the coefficient as one binary64 number.
-
- Returns
- -------
- float
- ``mantissa * 2**exponent``.
-
- Raises
- ------
- TranslationError
- If the coefficient is outside binary64's range.
- """
- try:
- return math.ldexp(self.mantissa, self.exponent)
- except OverflowError as error:
- raise TranslationError(
- f"a combined coefficient 2**{self.exponent} overflows binary64"
- ) from error
-
- def binary32(self) -> float:
- """Return the coefficient rounded once to binary32, as a Python float.
-
- Returns
- -------
- float
- The value of ``numpy.float32(value)``.
-
- Raises
- ------
- TranslationError
- If the coefficient is not a finite normal binary32 number (or zero): it
- would overflow or lose its precision, so the pass that applies it has not
- kept its intermediates in range.
- """
- value = self.value
- narrowed = float(np.float32(value))
- tiny = float(np.finfo(np.float32).tiny)
- if not math.isfinite(narrowed) or (value != 0.0 and abs(narrowed) < tiny):
- msg = (
- f"a combined coefficient {value:.3e} leaves binary32's normal range; "
- "the pass applying it must apply it where its intermediate is largest"
- )
- raise TranslationError(msg)
- return narrowed
-
-
-def combine(*factors: float, exponent: int = 0) -> Coefficient:
- """Combine scalar factors and power-of-two exponents in binary64.
-
- Each factor is split into its mantissa and exponent first, so the product of the
- mantissas stays in ``[2**-n, 1)`` for ``n`` factors and the exponents add as
- integers: a density of ``2.5e25`` times a kernel exponent of ``-164`` never forms
- the intermediate ``2.5e25 * 2**-164`` out of range.
-
- Parameters
- ----------
- *factors : float
- Finite binary64 factors.
- exponent : int, optional
- A further power of two, such as a buffer's block exponent or a power of the
- state scale's exponent.
-
- Returns
- -------
- Coefficient
- The product, normalised.
-
- Examples
- --------
- >>> from solprop.translate.precision import combine
- >>> c = combine(2.5e25, 8.854e-12, exponent=-164)
- >>> abs(c.value - 2.5e25 * 8.854e-12 * 2.0**-164) < 1e-60
- True
- """
- mantissa = 1.0
- total = exponent
- for factor in factors:
- part, power = math.frexp(float(factor))
- mantissa *= part
- total += power
- if mantissa == 0.0:
- return Coefficient(0.0, 0)
- mantissa, power = math.frexp(mantissa)
- total += power
- return Coefficient(mantissa, total)
-
-
-def pairwise_sum(values: np.ndarray, axis: int = -1) -> np.ndarray:
- """Sum along an axis as a balanced binary tree, in the values' own precision.
-
- The reduction order of the single-precision oracle (SEP 33 section 4.2, Q5): the
- axis is padded with zeros to a power of two and halved repeatedly, each level one
- vectorised addition. The error of each sum is bounded by ``gamma_d`` times the sum
- of magnitudes, ``d = ceil(log2 n)`` the depth of the tree.
-
- Parameters
- ----------
- values : numpy.ndarray
- The terms; the result has the same dtype.
- axis : int, optional
- The axis summed over.
-
- Returns
- -------
- numpy.ndarray
- The sums, with `axis` removed.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.precision import pairwise_sum
- >>> pairwise_sum(np.arange(5, dtype=np.float32)).item()
- 10.0
- """
- moved = np.moveaxis(np.asarray(values), axis, -1)
- n = moved.shape[-1]
- if n == 0:
- return np.zeros(moved.shape[:-1], dtype=moved.dtype)
- width = 1 << (n - 1).bit_length()
- level = np.zeros((*moved.shape[:-1], width), dtype=moved.dtype)
- level[..., :n] = moved
- while level.shape[-1] > 1:
- level = level[..., 0::2] + level[..., 1::2]
- return level[..., 0]
-
-
-def prefix_sum(values: np.ndarray, axis: int = -1) -> np.ndarray:
- """Return the running sums along an axis by doubling, in the values' own precision.
-
- The running-integral order of the single-precision oracle (SEP 33 section 4.2,
- Q5): ``ceil(log2 n)`` vectorised levels, at level ``k`` each entry adding the entry
- ``2**k`` before it (a Hillis-Steele scan). Every prefix is then a sum of depth at
- most ``ceil(log2 n)``, bounded by ``gamma_d`` times its sum of magnitudes, where a
- sequential binary32 sum of ``10**4`` terms could lose ``10**4 u``.
-
- Parameters
- ----------
- values : numpy.ndarray
- The increments; the result has the same dtype and shape.
- axis : int, optional
- The axis scanned along.
-
- Returns
- -------
- numpy.ndarray
- ``out[..., i] = sum(values[..., :i + 1])`` along `axis`.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.precision import prefix_sum
- >>> prefix_sum(np.arange(5, dtype=np.float32)).tolist()
- [0.0, 1.0, 3.0, 6.0, 10.0]
- """
- scan = np.array(np.moveaxis(np.asarray(values), axis, -1), copy=True)
- n = scan.shape[-1]
- shift = 1
- while shift < n:
- shifted = scan[..., :-shift].copy()
- scan[..., shift:] += shifted
- shift *= 2
- return np.moveaxis(scan, -1, axis)
-
-
-def scaled_square_sum(values: np.ndarray, weights: np.ndarray | None = None) -> float:
- """Return ``sum(w |v|**2)`` without squaring a value out of range.
-
- SEP 33 section 4.3, rule 6: the values are divided by a power of two chosen from
- their largest magnitude before squaring, as LAPACK's ``nrm2`` does, the squares
- summed in the values' precision by :func:`pairwise_sum`, and the scale restored on
- the host in binary64.
-
- Parameters
- ----------
- values : numpy.ndarray
- The values, real or complex, of any precision.
- weights : numpy.ndarray or None, optional
- Non-negative weights broadcastable against `values`, in the values' real
- precision; None weighs every value by one.
-
- Returns
- -------
- float
- The weighted sum of squares, binary64.
-
- Examples
- --------
- >>> import numpy as np
- >>> from solprop.translate.precision import scaled_square_sum
- >>> big = np.full(4, 3.0e30, dtype=np.float32)
- >>> total = scaled_square_sum(big)
- >>> abs(total / (4 * float(np.float32(3.0e30)) ** 2) - 1.0) < 1e-6
- True
- """
- array = np.asarray(values)
- exponent = block_exponent(array)
- scaled = _ldexp(array, -exponent)
- squares = np.real(scaled) * np.real(scaled)
- if np.iscomplexobj(scaled):
- squares = squares + np.imag(scaled) * np.imag(scaled)
- if weights is not None:
- squares = squares * weights
- total = pairwise_sum(squares.reshape(-1))
- return math.ldexp(float(total), 2 * exponent)
-
-
-def _ldexp(values: np.ndarray, exponent: int) -> np.ndarray:
- """Multiply by ``2**exponent`` exactly, in the values' own precision.
-
- Parameters
- ----------
- values : numpy.ndarray
- Real or complex values.
- exponent : int
- The power of two.
-
- Returns
- -------
- numpy.ndarray
- The scaled values, same dtype; exact unless a value over- or underflows.
- """
- if np.iscomplexobj(values):
- result = np.empty_like(values)
- result.real = np.ldexp(np.real(values), exponent)
- result.imag = np.ldexp(np.imag(values), exponent)
- return result
- return np.ldexp(values, exponent)
diff --git a/src/solprop/translate/problem.py b/src/solprop/translate/problem.py
index bca70cf3d..a5f898cbd 100644
--- a/src/solprop/translate/problem.py
+++ b/src/solprop/translate/problem.py
@@ -87,7 +87,7 @@
from solprop.physdata import Gas
from solprop.physdata.mixtures import Mixture
from solprop.profiles import FromCallable, ZProfile
-from solprop.spatial import FAR_FIELD_STATISTICS, SpatialDispersion, SpatialMedium
+from solprop.spatial import SpatialDispersion, SpatialMedium
from solprop.spatial_absorber import (
CROSSING_ADAPTIVE,
CROSSING_FIXED,
@@ -101,16 +101,14 @@
from solprop.stepper.multistep import Multistep
from solprop.stepper.schedule import FixedSchedule
from solprop.stepper.tableau import DOPRI5, Tableau, continuous_extension
+from solprop.translate.admission import check_general_route, check_leaf_types
from solprop.translate.chebyshev import (
DEFAULT_TOL,
PiecewiseChebyshev,
)
from solprop.translate.coupled import (
- CF4_GROWTH_LIMIT,
CoupledFlowTables,
advisory_step,
- cf4_defect,
- cf4_flow,
constant_schur_flow,
flow_edge_slack,
frozen_residual_flow,
@@ -148,7 +146,6 @@
)
from solprop.translate.planar import planar_operators, planar_transform
from solprop.translate.plasma_source import plasma_source_record
-from solprop.translate.precision import DOUBLE, Precision
from solprop.translate.radial import (
radial_operators,
radial_rate_bounds,
@@ -198,7 +195,6 @@
"DEFAULT_STRETCH",
"EXPONENT_BUDGET",
"LOG_DBL_MAX",
- "LOG_FLT_MAX",
"MAX_GENERAL_FLOW_EXPONENT",
"MAX_N_COMPONENTS",
"MAX_N_POL",
@@ -348,8 +344,6 @@ class Flags:
A plasma response is compiled.
has_raman : bool
A Raman kernel is compiled.
- has_spectral_responses : bool
- Responses are evaluated after the forward transform.
has_noise : bool
A shot-noise realisation is carried and added at every stage.
has_temporal_absorber : bool
@@ -366,7 +360,7 @@ class Flags:
Examples
--------
>>> from solprop.translate.problem import Flags
- >>> flags = Flags(True, True, False, False, False, False, False, True, True)
+ >>> flags = Flags(True, True, False, False, False, False, True, True)
>>> flags.constant_linop, flags.has_raman
(True, False)
"""
@@ -375,7 +369,6 @@ class Flags:
scalar_pol: bool
has_plasma: bool
has_raman: bool
- has_spectral_responses: bool
has_noise: bool
has_temporal_absorber: bool
constant_linop: bool
@@ -439,7 +432,6 @@ def from_content(
scalar_pol=shape[1] == 1,
has_plasma=bool(responses.population_channels),
has_raman=bool(responses.raman_terms),
- has_spectral_responses=bool(responses.spectral_responses),
has_noise=initial.noise0 is not None,
has_temporal_absorber=responses.temporal_absorber is not None,
constant_linop=linear.phi_representation in ("constant", "separable"),
@@ -827,9 +819,6 @@ class PropagationProblem:
What differs between routes, in exactly one variant of a closed union, each
with the fields its executor needs (SEP 47 section 6.2): the transform
(section 4) and the route's own leaves (:mod:`solprop.translate.routes`).
- precision : solprop.translate.precision.Precision, optional
- The arithmetic the run is held in (version 36): binary64 by default, or
- binary32 under SEP 33's contract. Tables are binary64 here either way.
split_crossing : solprop.boundaries.SplitCrossing or None, optional
The split temporal collar's crossing limit (version 43): each bin's walk-off
bucket, the collar's width and the launch's limit, which the boundary
@@ -864,7 +853,6 @@ class PropagationProblem:
responses: CompiledResponses
initial: Initial
route: Route
- precision: Precision = DOUBLE
split_crossing: SplitCrossing | None = None
@property
@@ -924,7 +912,9 @@ def validate(self) -> None:
the same range and, for the prefactor, carrying the state's value shape; a
constant prefactor, when present, standing in for a table that is one interval
of degree zero and agreeing with it; the hard kinks interval edges; the Raman
- rows agreeing with the fine grid and with each other. What depends on the
+ rows agreeing with the fine grid and with each other; the general modal route
+ and the coupled flow ones solprop executes
+ (:func:`solprop.translate.admission.check_general_route`). What depends on the
method, the step limits, the schedule, the planes or the statistics -- the
tables covering the run, the schedule landing on its targets, the collar's
stability, the exponent budget, the split collar's crossing and the method's
@@ -972,7 +962,6 @@ def _validate(self, run: _RunPolicy | None) -> None:
"""
if run is not None:
self._check_integrator(run.stepper)
- self._check_precision(run)
self._check_route(run)
if run is not None and not self.initial.z < run.stepper.save_planes[0]:
raise TranslationError("save planes must follow the initial position")
@@ -987,6 +976,7 @@ def _validate(self, run: _RunPolicy | None) -> None:
self._check_raman()
self._check_dispersive()
self._check_modal_leaves(run)
+ check_general_route(self) # the route's policy, once its leaves are consistent
self._check_tables(run)
def validate_launch(self, request: "RunRequest") -> None:
@@ -1086,145 +1076,6 @@ def _check_integrator(self, stepper: StepperSettings) -> None:
)
raise TranslationError(msg)
- def _check_precision(self, run: _RunPolicy | None) -> None:
- """Refuse, by name, what no backend runs in single precision (SEP 33 5.1).
-
- The general modal route (a moving guide among it), the hybrid planar kind,
- coupled flows (``"cf4"`` sections among them), TDSE responses, the dispersive
- electronic response, the far-field statistics and noise models are outside
- SEP 33's scope, so every backend refuses them under ``precision="single"``.
-
- - ``run``: the run's stepper section and statistics; ``None`` leaves the
- statistics and the step's exponent to :func:`validate_run`.
- """
- if not isinstance(
- self.precision, Precision
- ): # pragma: no cover - refused by the annotation where it is checked
- raise TranslationError("precision must be a Precision record")
- if not self.precision.single:
- return
- geometry = self.transform.geometry
- refused = [
- (self.transform.modal_tables is not None, "the general modal route"),
- (
- geometry is not None and geometry.kind == "slab",
- "the hybrid planar kind",
- ),
- (self.linear.coupled_flow is not None, "a coupled flow"),
- (tdse_of(self.route) is not None, "a TDSE response"),
- (
- self.responses.dispersive_kerr is not None,
- "the dispersive electronic response",
- ),
- (
- run is not None
- and bool(
- set(FAR_FIELD_STATISTICS).intersection(
- (*run.stats.names, *run.stats.periodic)
- )
- ),
- "the far-field statistics",
- ),
- (
- self.flags.has_noise or self.initial.noise_model != "none",
- "a noise model (a known limitation of single precision)",
- ),
- ]
- for refuse, name in refused:
- if refuse:
- msg = (
- f"precision='single' refuses {name}: no backend runs it in "
- "binary32 (SEP 33 section 5.1); use precision='double'"
- )
- raise TranslationError(msg)
- if run is not None:
- self._check_single_exponent(run.stepper)
-
- def _check_single_exponent(self, stepper: StepperSettings) -> None:
- """Refuse a step whose propagator or its inverse overflows binary32.
-
- The Runge-Kutta interaction picture multiplies each stage by ``exp(-dPhi)`` and
- the state by ``exp(dPhi)``; held in binary32, either is infinite once
- ``|Re dPhi|`` passes ``log(FLT_MAX)``, about 88.72, where binary64 reaches
- 709.78. A strong spectral collar on a long step is the usual cause. The
- low-storage march and the exponential Adams flow apply forward flows only, so a
- damped bin's flow underflows harmlessly towards zero and only growth,
- ``Re dPhi > 0``, can overflow: they are held to ``max Re dPhi``, Adams's times
- the reach of its history (a sample is carried up to ``order + 1`` steps). As
- :meth:`_check_exponent_budget` does for binary64, a replayed schedule's
- longest step, or the floor ``dz_min``, is held to the limit -- an adaptive
- controller rejects a non-finite trial above its floor and shrinks -- and every
- kind is checked: the exponent is evaluated from the operator itself over that
- step at the start and the end of the run, which for a varying operator samples
- its range.
- """
- schedule = stepper.schedule
- step, source = (
- (float(np.max(schedule.steps)), "replayed step")
- if schedule is not None
- else (stepper.dz_min, "step floor dz_min")
- )
- start = float(self.initial.z)
- end = float(stepper.save_planes[-1])
- step = min(step, end - start)
- integrator = stepper.integrator
- forward = not stepper.interpolate_planes and (
- isinstance(integrator, LowStorageRungeKutta)
- or (
- isinstance(integrator, Multistep)
- and integrator.formulation == "exponential"
- )
- )
- reach = integrator.order + 1 if isinstance(integrator, Multistep) else 1
- operator = self.linear.separable
- geometry = self.transform.geometry
- bounds = (
- rate_bounds(operator)
- if operator is not None
- else radial_rate_bounds(self.linear)
- if geometry is not None and geometry.kind == "radial"
- else None
- )
- exponent = 0.0
- if bounds is not None:
- # A free-space operator's rates, bounded as the binary64 budget bounds
- # them, without forming the volume (seconds on a 384^2 x 750 grid).
- growth = reach * step * bounds.gain
- exponent = growth if forward else max(step * bounds.loss, growth)
- for first in () if bounds is not None else {start, end - step}:
- real = np.real(delta_phi(self.linear, first, first + step))
- found = reach * float(np.max(real)) if forward else np.max(np.abs(real))
- exponent = max(exponent, float(found))
- if exponent <= EXPONENT_BUDGET * LOG_FLT_MAX:
- return
- name = "growth Re dPhi" if forward else "|Re dPhi|"
- message = (
- f"precision='single': the {source} {step:.3e} m gives a propagator "
- f"exponent {name} of {exponent:.1f}"
- )
- remedy = (
- "take steps shorter than "
- f"{step * EXPONENT_BUDGET * LOG_FLT_MAX / exponent:.3e} m, weaken the "
- "collar with a larger boundary_length, or use precision='double'"
- )
- if exponent > LOG_FLT_MAX:
- msg = (
- f"{message}, past binary32's range exp({LOG_FLT_MAX:.2f}): the "
- + (
- "forward flow overflows"
- if forward
- else "interaction picture's inverse propagator overflows"
- )
- + f"; {remedy}"
- )
- raise TranslationError(msg)
- warnings.warn(
- f"{message}, within a tenth of binary32's range exp({LOG_FLT_MAX:.2f}): "
- f"the run completes only while the field stays small; {remedy}",
- ExponentBudgetWarning,
- stacklevel=5,
- )
-
def _check_tdse(self, source: TDSESource, stepper: StepperSettings | None) -> None:
"""Refuse a manually constructed atomic seam outside the admitted route.
@@ -1968,10 +1819,8 @@ def _check_modal_leaves(self, run: _RunPolicy | None) -> None:
The contract's tree walk *does* recurse into these records: every array they
hold is checked for dtype, finiteness, contiguity and ownership like any
other leaf. What the walk does not do is relate any of them to the canonical
- state, and that is this method's work -- the channel counts, the component
- count, the frequency axes and the positions along z.
- Every leaf is ``None`` on every route that exists today, and the whole
- method is then a single test of four references.
+ state, and that is this method's work -- each leaf's type, the channel and
+ component counts, the frequency axes and the positions along z.
- ``run``: the run's stepper section and statistics; ``None`` leaves the run's
extent along z to :func:`validate_run`.
@@ -1981,6 +1830,7 @@ def _check_modal_leaves(self, run: _RunPolicy | None) -> None:
metric = self.linear.modal_metric
flow = self.linear.coupled_flow
regional = self.responses.regional_responses
+ check_leaf_types(self)
if tables is not None:
if self.transform.geometry is not None:
msg = (
@@ -2010,6 +1860,8 @@ def _check_modal_leaves(self, run: _RunPolicy | None) -> None:
f"state carries {n_modes}"
)
raise TranslationError(msg)
+ if metric is not None: # its rows, before the route's policy reads them
+ metric.check_rows()
if flow is not None and flow.n_modes != n_modes:
msg = (
f"linear.coupled_flow couples {flow.n_modes} channel(s) and the state "
@@ -2058,6 +1910,15 @@ def _check_modal_grids(self, run: _RunPolicy | None) -> None:
- ``run``: the run's stepper section and statistics, whose last plane the leaves
must reach; ``None`` checks the axes alone.
"""
+ if self.transform.modal_tables is not None and self.grid_kind != "field":
+ # Named first: every leaf below is tabulated on the increasing coarse axis
+ # of a field grid, so on another grid its axis checks would misname this.
+ msg = (
+ "solprop executes a general modal guide (transform.modal_tables) on a "
+ "field grid, whose canonical axis is increasing, and this problem is "
+ f"{self.grid_kind}"
+ )
+ raise TranslationError(msg)
omega = self.grids.omega
z_end = None if run is None else run.stepper.z_end
for name, leaf in (
@@ -2218,13 +2079,13 @@ def validate_run(problem: PropagationProblem, request: "RunRequest") -> None:
Everything :meth:`PropagationProblem.validate` checks, in the same order, and
with it what depends on the run's policy: the method's admission on the route,
- the save planes following the launch, the statistics the route and the
- precision admit, the request's own arrays, the propagator mode on a separable
- route, the tables and a general guide's leaves covering the run, a replayed
- schedule landing on every save plane and hard kink, the in-stage collar's
- stability, the split collar's crossing and the exponent budget. A backend calls
- this where it is built (SEP 47 section 5.2), so an unsupported combination is
- refused by name before any workspace exists.
+ the save planes following the launch, the statistics the route admits, the
+ request's own arrays, the propagator mode on a separable route, the tables and a
+ general guide's leaves covering the run, a replayed schedule landing on every save
+ plane and hard kink, the in-stage collar's stability, the split collar's crossing
+ and the exponent budget. A backend calls this where it is built (SEP 47 section
+ 5.2), so an unsupported combination is refused by name before any workspace
+ exists.
Parameters
----------
@@ -2268,9 +2129,6 @@ def validate_run(problem: PropagationProblem, request: "RunRequest") -> None:
``exp(h lambda)`` past it is infinite on every backend (SEP 22 section 4.5).
"""
-LOG_FLT_MAX = math.log(float(np.finfo(np.float32).max))
-"""``log(FLT_MAX)``, about 88.72: binary32's largest finite ``exp`` (SEP 33)."""
-
EXPONENT_BUDGET = 0.9
"""Fraction of :data:`LOG_DBL_MAX` above which translation warns of a step it cannot
shrink: such a run completes only while its field stays below ``exp`` of the margin.
@@ -2600,27 +2458,23 @@ def _crossing_clamped(
design: SpatialAbsorberDesign | None,
dz_max: float,
dz_init: float,
- *,
- single: bool = False,
) -> tuple[float, float]:
"""Return the step bounds held to a collar's crossing limit (SEP 40 section 4.7).
No adaptive step carries the protected band across the collar unseen; a run
without a collar keeps its bounds. Every transverse design also holds the step to
the safety ramp's exponent budget (section 6): the propagator's inverse factor
- ``exp(alpha_s h / 2)`` stays within half the precision's range, so a strong ramp on
- a fine grid cannot overflow it.
+ ``exp(alpha_s h / 2)`` stays within half binary64's range, so a strong ramp on a
+ fine grid cannot overflow it.
- ``dz_max``: the requested largest step, m.
- ``dz_init``: the requested first step, m.
- - ``single``: whether the state is binary32, whose range is smaller.
- Returns: the largest step and the first step, m.
"""
if design is None:
return dz_max, dz_init
# A resolved design's ramp is never zero: every axis has a nonzero largest |k|.
- budget = LOG_FLT_MAX if single else LOG_DBL_MAX
- dz_max = min(dz_max, budget / design.ramp_strength)
+ dz_max = min(dz_max, LOG_DBL_MAX / design.ramp_strength)
if design.has_collar:
dz_max = min(dz_max, design.crossing_step)
return dz_max, min(dz_init, dz_max)
@@ -3232,7 +3086,6 @@ def _wall_assembly(
"""
block = tuple(modes)
_check_wall_route(build)
- _check_wall_method(wall, cast("MarcatiliMode", block[0]))
if wall.impedance == "te_tm":
return _vector_wall_assembly(wall, block, grid, frame, build)
if not cast("MarcatiliMode", block[0]).z_constant:
@@ -3294,30 +3147,6 @@ def _wall_assembly(
return replace(linear, coupled_flow=flow)
-def _check_wall_method(wall: CoherentWall, first: MarcatiliMode) -> None:
- """Refuse, by name, a CF4 declaration the block it would integrate cannot take.
-
- A guide that does not vary along z has one constant section, whose flow the
- constant-Schur action integrates **exactly**, so there is nothing for a
- commutator-free exponential to order and the declaration would otherwise be
- silently ignored; a declaration that would change nothing is refused rather
- than dropped. CF4 is admitted beside the vector block of ``impedance="te_tm"``
- too: it integrates the stored expansion of whichever block the route built and
- reads nothing else of it.
- """
- if wall.method != "cf4":
- return
- if first.z_constant:
- msg = (
- "CoherentWall(method='cf4') orders the exponentials of a generator that "
- "varies along z, and this guide's does not: its one constant section is "
- "integrated exactly by the constant-Schur action, which a CF4 "
- "step would only reproduce to rounding. Declare the default method, or a "
- "radius or pressure that varies"
- )
- raise TranslationError(msg)
-
-
def _vector_wall_assembly(
wall: CoherentWall,
block: tuple[Mode, ...],
@@ -3505,34 +3334,17 @@ def _varying_wall_assembly(
else f"capillary azimuthal order nu={operator.sector}"
)
_check_wall_total_varying(coefficients, linear, edges, block, grid, tail)
- if wall.method == "cf4":
- flow = cf4_flow(
- grid.omega,
- edges,
- coefficients,
- link,
- dz_max=build.dz_max,
- condition_limit=wall.condition_limit,
- growth_limit=CF4_GROWTH_LIMIT
- if wall.growth_limit is None
- else wall.growth_limit,
- fallback=wall.fallback,
- representation_error=tail,
- gauge_id=gauge_id,
- certificate_id="cf4",
- )
- else:
- flow = frozen_residual_flow(
- grid.omega,
- edges,
- coefficients,
- link,
- dz_max=build.dz_max,
- condition_limit=wall.condition_limit,
- representation_error=tail,
- gauge_id=gauge_id,
- certificate_id="frozen-residual",
- )
+ flow = frozen_residual_flow(
+ grid.omega,
+ edges,
+ coefficients,
+ link,
+ dz_max=build.dz_max,
+ condition_limit=wall.condition_limit,
+ representation_error=tail,
+ gauge_id=gauge_id,
+ certificate_id="frozen-residual",
+ )
logger.debug(
"coherent wall, varying (%s): %d channels over %d section(s), worst validity "
"ratio %.3e (%s) at z = %.6g m, residual bound %.3e 1/m, largest conditioning "
@@ -3832,8 +3644,8 @@ def _channel_normalisation_at(
``-0.5 d ln(w_n/w_1)/dz = -0.5 (w_n'/w_n - w_1'/w_1)``, real, so the
optics/internal conjugation leaves it alone, zero for channel 1 by construction
and zero where the guide does not vary. It is part of the generator, so the
- frozen block carries it at the section midpoint, the explicit residual carries
- its variation, and CF4 integrates it with the rest. The slope is the analytic
+ frozen block carries it at the section midpoint and the explicit residual carries
+ its variation. The slope is the analytic
:meth:`solprop.modes.capillary.MarcatiliMode.dneff_dz`, never a difference.
- ``block``: the contiguous ``HE_11`` to ``HE_1N`` set.
@@ -4078,12 +3890,6 @@ def validate_coupled_flow(
fixed schedule is replayed, its own largest step, which the controller can neither
shorten nor reject.
- A flow with a ``"cf4"`` section has no explicit residual there and no advisory step,
- but a flow error the embedded pair cannot see: the largest declared step's
- step-doubling defect estimate (:func:`solprop.translate.coupled.cf4_defect`) is
- warned on when it exceeds the controller's relative tolerance -- an asymptotic
- estimator, and advisory like the first.
-
Parameters
----------
problem : PropagationProblem
@@ -4097,8 +3903,7 @@ def validate_coupled_flow(
-----
solprop.exceptions.SolpropWarning
Naming the step that exceeds the limit, with the term, the section, the
- frequency, ``K``, the conditioning factor and the limit; or, for a CF4 flow,
- the step, the section, the frequency, the defect estimate and the tolerance.
+ frequency, ``K``, the conditioning factor and the limit.
Examples
--------
@@ -4111,8 +3916,6 @@ def validate_coupled_flow(
return
# Over the bins the explicit residual is applied on: the requested band.
_warn_advisory_step(flow, stepper, problem.grids.inband_mask)
- if "cf4" in flow.section_methods:
- _warn_cf4_defect(flow, stepper, problem.linear.hard_kinks)
def _declared_steps(settings: StepperSettings) -> dict[str, float]:
@@ -4184,50 +3987,6 @@ def _warn_advisory_step(
)
-def _warn_cf4_defect(
- flow: CoupledFlowTables,
- settings: StepperSettings,
- kinks: Float[np.ndarray, " n_kinks"],
-) -> None:
- """Warn when a CF4 flow's estimated local defect exceeds the controller's tolerance.
-
- The CF4 counterpart of the advisory step, and as advisory as it: a CF4
- section's flow error is invisible to the embedded Runge-Kutta pair, which compares
- two combinations of the same stages, so the controller can neither see nor bound
- it, and the only step that governs it is the one the run declares. The
- step-doubling estimate of :func:`solprop.translate.coupled.cf4_defect` at the
- largest declared step is compared with the controller's relative tolerance, which
- is what the pair holds the rest of each step to. It is an **asymptotic
- estimator**, not a bound, and the warning says so; nothing here changes the
- caller's tolerances or schedule.
-
- - ``flow``: the leaf, carrying at least one ``"cf4"`` section.
- - ``kinks``: the run's hard kinks in m, at which every step ends.
- """
- declared = _declared_steps(settings)
- name = max(declared, key=lambda key: declared[key])
- step = declared[name]
- defect = cf4_defect(flow, step, kinks)
- section, bin_index = np.unravel_index(int(np.argmax(defect)), defect.shape)
- largest = float(defect[section, bin_index])
- tolerance = float(settings.controller.rtol)
- if largest <= tolerance:
- return
- warnings.warn(
- f"the coherent wall's CF4 flow has an estimated local defect of {largest:.4g} "
- f"per step of {step:.4g} m ({name}) on section {int(section)} at bin "
- f"{int(bin_index)}, {float(flow.omega[bin_index]):.6g} rad/s, above the "
- f"controller's relative tolerance {tolerance:.4g}: the embedded pair does not "
- "see a CF4 flow's error, so only the declared step governs it, and at this "
- "one the run is at most fourth order with a linear share the controller "
- "cannot hold. The estimate is step doubling, asymptotic and not a bound. "
- "Nothing here changes your schedule: cap the step, or "
- "declare the default method, deliberately",
- SolpropWarning,
- stacklevel=3,
- )
-
-
def _check_wall_route(build: _Assembly) -> None:
"""Refuse, by name, every run a coherent wall block is not derived for.
@@ -4872,7 +4631,6 @@ def assemble( # noqa: PLR0915 - one keyword per physics object of a run
evanescent_rate: float | None = None,
spatial_absorber: SpatialAbsorber | None = None,
adiabatic_accuracy: AdiabaticAccuracy | None = None,
- precision: Precision = DOUBLE,
) -> Translation:
"""Turn the physics objects of a run into a validated kernel problem.
@@ -5016,7 +4774,7 @@ def assemble( # noqa: PLR0915 - one keyword per physics object of a run
or, with ``impedance="te_tm"``, of one azimuthal order, on a constant or a
varying guide. With it ``Phi`` carries the mode-independent carrier alone
and the modal part of the flow becomes ``linear.coupled_flow``, which every
- backend executes (a CF4 section ``numpy_ref`` alone). A general modal guide,
+ backend executes. A general modal guide,
a spatial geometry, a polarisation axis and a noise model are refused beside
it by name.
taper_coupling : bool, optional
@@ -5051,10 +4809,6 @@ def assemble( # noqa: PLR0915 - one keyword per physics object of a run
The budgets a radial medium varying along z must meet for its forward
amplitude transport (SEP 34 section 4.5); None, the default, is
``AdiabaticAccuracy()``. A path exceeding them is refused.
- precision : solprop.translate.precision.Precision, optional
- The arithmetic the run is held in (SEP 33): :data:`~.precision.DOUBLE`, the
- default, or :data:`~.precision.SINGLE`. The tables are built in binary64
- either way; what single precision refuses is refused here.
Returns
-------
@@ -5275,9 +5029,7 @@ def assemble( # noqa: PLR0915 - one keyword per physics object of a run
prefactor_index,
None if reuse is None else reuse.initial,
)
- dz_max, dz_init = _crossing_clamped(
- design, dz_max, dz_init, single=precision.single
- )
+ dz_max, dz_init = _crossing_clamped(design, dz_max, dz_init)
geometry = transform.geometry
crossing = _split_crossing(
compiled,
@@ -5329,7 +5081,6 @@ def assemble( # noqa: PLR0915 - one keyword per physics object of a run
flux=flux,
transverse=design,
),
- precision=precision,
split_crossing=crossing,
)
# The run module builds on this one, for the stepper section.
diff --git a/src/solprop/translate/responses.py b/src/solprop/translate/responses.py
index fd0b6925d..9528e9da8 100644
--- a/src/solprop/translate/responses.py
+++ b/src/solprop/translate/responses.py
@@ -52,6 +52,7 @@
fast path.
"""
+import functools
import logging
import math
from collections.abc import Callable, Mapping, Sequence
@@ -126,6 +127,7 @@
__all__ = [
"DELAYED_MODEL_ID",
+ "DELAYED_TERMS",
"KERR_FORM_MODEL_IDS",
"KERR_MODEL_FORMS",
"MODEL_ID_SEPARATOR",
@@ -1387,6 +1389,17 @@ def _validate_tensors(self) -> None:
f"{components}, got {delayed.shape}"
)
raise TranslationError(msg)
+ terms = int(delayed.shape[1])
+ if terms and (terms != DELAYED_TERMS or components != MAX_N_COMPONENTS):
+ # The one check of the delayed channels: both backends execute the silica
+ # tensor model's channels on three components and nothing else.
+ msg = (
+ f"regional_responses carries {terms} delayed term(s) on {components} "
+ f"component(s); the delayed response is the {DELAYED_TERMS} delayed "
+ "channels of the silica tensor Raman model -- the trace and the "
+ f"traceless rank-two one -- on {MAX_N_COMPONENTS} Cartesian components"
+ )
+ raise TranslationError(msg)
object.__setattr__(self, "delayed_tensor", delayed)
def _validate_tables(self) -> None:
@@ -1520,6 +1533,9 @@ def isotropic_kerr_tensor(form: KerrForm = "analytic") -> Float[np.ndarray, "3 3
DELAYED_MODEL_ID: Final[str] = "silica-tensor-raman"
"""The model tag a compiled record carries when a region declares a delayed term."""
+DELAYED_TERMS: Final[int] = 2
+"""Delayed channels the silica tensor Raman model carries: trace and rank two."""
+
MODEL_ID_SEPARATOR: Final[str] = "+"
"""What joins the model tags of one compiled record's ``model_id``.
@@ -1754,8 +1770,6 @@ class CompiledResponses:
Attributes
----------
- response_order : tuple of ResponseStage
- The fused-pass sequence, in execution order.
kerr_coefficients : KerrCoefficients or None
The compiled Kerr term, or ``None`` when no Kerr response was declared.
ionisation_table : tuple of RateTable
@@ -1774,8 +1788,6 @@ class CompiledResponses:
density : solprop.translate.chebyshev.PiecewiseChebyshev
Number density along z, in m^-3 for a gas or dimensionless 1 for a medium whose
``chi3`` is already absolute, as a table.
- spectral_responses : tuple of EnvelopePlasmaAssembly
- Typed post-transform assembly; empty or the ``envelope_plasma`` record.
envelope_plasma : EnvelopePlasmaAssembly or None
Independent envelope populations, cycle data and spectral multipliers.
prefactor_representation : {'constant', 'expansion', 'table'}
@@ -1845,13 +1857,11 @@ class CompiledResponses:
(, )
"""
- response_order: tuple[ResponseStage, ...]
kerr_coefficients: KerrCoefficients | None
ionisation_table: tuple[RateTable, ...]
plasma_channels: tuple[PlasmaChannel, ...]
raman_terms: tuple[RamanCoefficients, ...]
density: PiecewiseChebyshev
- spectral_responses: tuple[EnvelopePlasmaAssembly, ...]
prefactor_representation: PhiRepresentation
prefactor: PiecewiseChebyshev | None
prefactor_expansion: ParameterExpansion | None
@@ -1881,11 +1891,6 @@ def __post_init__(self) -> None:
Naming the offending field.
"""
self._check_prefactor_representation()
- expected = () if self.envelope_plasma is None else (self.envelope_plasma,)
- if self.spectral_responses != expected:
- raise TranslationError(
- "spectral_responses must name the envelope plasma assembly"
- )
if self.envelope_plasma is not None and self.plasma_channels:
raise TranslationError("field and envelope plasma channels cannot coexist")
if self.plasma_integral.policy != "trapezoid" and not self.plasma_channels:
@@ -1910,6 +1915,35 @@ def __post_init__(self) -> None:
)
raise TranslationError(msg)
+ @functools.cached_property
+ def response_order(self) -> tuple[ResponseStage, ...]: # numpydoc ignore=RT01
+ """The fused-pass sequence the compiled leaves imply, in execution order.
+
+ Derived rather than stored, so that a stage is never named without its leaf nor
+ a leaf held without its stage; it always ends in ``ACCUMULATE``. Formed once,
+ on first reading, since every stage reads it.
+ """
+ envelope = self.envelope_plasma is not None
+ stages: list[ResponseStage] = []
+ if envelope:
+ stages += [
+ ResponseStage.ENVELOPE_CYCLE,
+ ResponseStage.ENVELOPE_POPULATION,
+ ResponseStage.ENVELOPE_SOURCES,
+ ]
+ elif self.ionisation_table:
+ stages += [ResponseStage.INTENSITY, ResponseStage.IONISATION_RATE]
+ if self.plasma_channels:
+ stages += [ResponseStage.PLASMA_SCAN, ResponseStage.PLASMA_CURRENT]
+ if self.kerr_coefficients is not None:
+ stages.append(ResponseStage.KERR)
+ if self.dispersive_kerr is not None:
+ stages.append(ResponseStage.DISPERSIVE_KERR)
+ if self.raman_terms:
+ stages.append(ResponseStage.RAMAN_DRIVE)
+ stages.append(ResponseStage.ACCUMULATE)
+ return tuple(stages)
+
@property
def harmonic_carrier(self) -> bool: # numpydoc ignore=RT01
"""Whether the pass needs ``grids.carrier_2w0``.
@@ -3031,26 +3065,6 @@ def compile_responses(
)
)
compiled = CompiledResponses(
- response_order=_response_order(
- bool(kerr),
- bool(tables) and not envelope,
- bool(compiled_plasma),
- bool(raman),
- has_dispersive=compiled_dispersive is not None,
- )
- if not envelope
- else (
- ResponseStage.ENVELOPE_CYCLE,
- ResponseStage.ENVELOPE_POPULATION,
- ResponseStage.ENVELOPE_SOURCES,
- *_response_order(
- bool(kerr),
- False,
- False,
- bool(raman),
- has_dispersive=compiled_dispersive is not None,
- ),
- ),
kerr_coefficients=compiled_kerr,
ionisation_table=tables,
plasma_channels=compiled_plasma,
@@ -3060,7 +3074,6 @@ def compile_responses(
labels=material.labels,
species_names=material.names,
species_densities=density_tables,
- spectral_responses=() if compiled_envelope is None else (compiled_envelope,),
envelope_plasma=compiled_envelope,
prefactor_representation=_prefactor_kind(constant, expansion is not None)
if supplied is None
@@ -3241,33 +3254,6 @@ def _tabulate_rate(rate: IonisationRate, field_range: tuple[float, float]) -> Ra
return _frozen_table(build_rate_table(rate, field_range))
-def _response_order(
- has_kerr: bool,
- has_ionisation: bool,
- has_plasma: bool,
- has_raman: bool,
- *,
- has_dispersive: bool = False,
-) -> tuple[ResponseStage, ...]:
- """Return the fused-pass sequence the declared content implies.
-
- - Returns: the stages in execution order, always ending in ``ACCUMULATE``.
- """
- stages: list[ResponseStage] = []
- if has_ionisation:
- stages += [ResponseStage.INTENSITY, ResponseStage.IONISATION_RATE]
- if has_plasma:
- stages += [ResponseStage.PLASMA_SCAN, ResponseStage.PLASMA_CURRENT]
- if has_kerr:
- stages.append(ResponseStage.KERR)
- if has_dispersive:
- stages.append(ResponseStage.DISPERSIVE_KERR)
- if has_raman:
- stages.append(ResponseStage.RAMAN_DRIVE)
- stages.append(ResponseStage.ACCUMULATE)
- return tuple(stages)
-
-
def raman_coefficients(
raman: RamanResponse | None,
kerr: KerrResponse | DispersiveKerrResponse | None,
diff --git a/src/solprop/translate/run_request.py b/src/solprop/translate/run_request.py
index d5974bd5b..722d903d9 100644
--- a/src/solprop/translate/run_request.py
+++ b/src/solprop/translate/run_request.py
@@ -49,7 +49,7 @@
from solprop.stats import StatsSpec
from solprop.stepper.controller import PIController
from solprop.stepper.low_storage import LowStorageRungeKutta
-from solprop.stepper.multistep import ComparisonMultistep, Multistep
+from solprop.stepper.multistep import Multistep
from solprop.stepper.schedule import FixedSchedule
from solprop.stepper.tableau import Tableau
from solprop.translate.problem import PropagatorMode, StepperSettings
@@ -80,8 +80,7 @@ class MethodKind(enum.Enum):
RUNGE_KUTTA : str
An embedded Runge-Kutta tableau, adaptive or replayed.
ADAMS : str
- The low-call Adams family (``Multistep``), including its benchmark-only
- comparison variants, which only the reference runs.
+ The low-call Adams family (``Multistep``).
LOW_STORAGE : str
The fixed-schedule low-storage RK4IP replay (SEP 24).
"""
@@ -102,7 +101,7 @@ class IntegrationSpec:
tableau : solprop.stepper.tableau.Tableau
The embedded Runge-Kutta tableau. An Adams run still names the tableau that
starts it and that its error control is scaled against.
- method : Multistep, ComparisonMultistep, LowStorageRungeKutta or None
+ method : Multistep, LowStorageRungeKutta or None
The Adams or low-storage method object, immutable; ``None`` for Runge-Kutta.
propagator_mode : {'auto', 'tabulate', 'recompute'}
How the linear propagator is formed.
@@ -110,21 +109,9 @@ class IntegrationSpec:
kind: MethodKind
tableau: Tableau
- method: Multistep | ComparisonMultistep | LowStorageRungeKutta | None
+ method: Multistep | LowStorageRungeKutta | None
propagator_mode: PropagatorMode
- @property
- def reference_only(self) -> bool: # numpydoc ignore=RT01
- """Whether only the NumPy reference runs this method.
-
- True for the benchmark-only comparison family, ``ComparisonMultistep``
- (SEP 47 section 6.3): PEC, PECE, explicit Adams-Bashforth and the matched
- pairs, which native refuses;
- :class:`~solprop.stepper.multistep.Multistep` is the selected method every
- backend runs.
- """
- return isinstance(self.method, ComparisonMultistep)
-
@dataclass(frozen=True)
class ErrorControlSpec:
@@ -133,8 +120,7 @@ class ErrorControlSpec:
Attributes
----------
controller : solprop.stepper.controller.PIController
- The controller and its norm. A subclass that overrides a decision method is
- a reference-only experiment (SEP 47 section 6.3).
+ The controller and its norm.
dz_min : float
The step floor, m.
dz_max : float
@@ -521,7 +507,7 @@ def describe_run(
integrator = settings.integrator
if isinstance(integrator, LowStorageRungeKutta):
kind = MethodKind.LOW_STORAGE
- elif isinstance(integrator, Multistep | ComparisonMultistep):
+ elif isinstance(integrator, Multistep):
kind = MethodKind.ADAMS
else:
kind = MethodKind.RUNGE_KUTTA
diff --git a/src/solprop/transverse_monitor.py b/src/solprop/transverse_monitor.py
index 29863eb5d..95dcfe5bc 100644
--- a/src/solprop/transverse_monitor.py
+++ b/src/solprop/transverse_monitor.py
@@ -347,11 +347,9 @@ class _Face:
def _row_energy(rows: np.ndarray) -> Float[np.ndarray, " n_rows"]:
"""Return each row's ``sum |E|^2`` of a two-dimensional complex array.
- Double precision is summed as a real view by ``einsum``, without the temporaries
- of ``abs(E)**2``; a read makes these sums many times a run (section 7.7).
+ Summed as a real view by ``einsum``, without the temporaries of ``abs(E)**2``; a
+ read makes these sums many times a run (section 7.7).
"""
- if rows.dtype != np.complex128:
- return np.sum(np.abs(rows) ** 2, axis=1)
flat = np.ascontiguousarray(rows).view(np.float64)
return np.einsum("ij,ij->i", flat, flat)
diff --git a/tests/backends/adams_traces.py b/tests/backends/adams_traces.py
index 6a38307d0..930ca283d 100644
--- a/tests/backends/adams_traces.py
+++ b/tests/backends/adams_traces.py
@@ -155,9 +155,11 @@ def __init__(self, harness: _Scripted, method: Multistep) -> None:
def reset(self) -> None:
"""Forget nothing: the script holds no state."""
- def start(self, z: float, *, stored: bool) -> None:
+ def start(self, z: float) -> None:
"""Record a startup evaluation; a resident engine keeps the sample itself."""
- self.harness.events.append({"start": z, "stored": stored})
+ # Every startup sample is stored since the comparison variants went, as the
+ # native march logs it.
+ self.harness.events.append({"start": z, "stored": True})
def value(self, slot: int) -> None:
"""Return no host sample, as a resident engine does."""
diff --git a/tests/backends/conftest.py b/tests/backends/conftest.py
index 31a6851da..b4f1a4fbe 100644
--- a/tests/backends/conftest.py
+++ b/tests/backends/conftest.py
@@ -301,7 +301,6 @@ def replace_groups(base, *, flags=None, validate=True, run_request=None, **group
grid_kind=base.grid_kind,
shape=base.shape,
flags=derived,
- precision=base.precision,
split_crossing=base.split_crossing,
**parts,
)
diff --git a/tests/backends/forced_thread_cases.py b/tests/backends/forced_thread_cases.py
index 883fd0015..28b752daa 100644
--- a/tests/backends/forced_thread_cases.py
+++ b/tests/backends/forced_thread_cases.py
@@ -24,7 +24,7 @@
from capability_routes import ROUTES
from sessions.runs import bare, load_native, native_trial, run
from solprop.backends.events import ObservationReady
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.session import Start
from solprop.boundaries import absorber
from solprop.exceptions import MaterialRangeWarning
@@ -431,7 +431,6 @@ def linear_cartesian(mode: str):
problem,
responses=dataclasses.replace(
problem.responses,
- response_order=problem.responses.response_order[-1:],
kerr_coefficients=None,
),
),
diff --git a/tests/backends/native_trace_marches.py b/tests/backends/native_trace_marches.py
index 685900dd9..7384dfe5a 100644
--- a/tests/backends/native_trace_marches.py
+++ b/tests/backends/native_trace_marches.py
@@ -27,10 +27,7 @@
from solprop._interrupts import committing
from solprop.backends import rk_core
-from solprop.backends.multistep import (
- _Derivative, # pyright: ignore[reportPrivateUsage]
- budget_error,
-)
+from solprop.backends.ledger import Derivative, budget_error
from solprop.exceptions import BackendError, ResponseBudgetError
from solprop.monitor import BoundaryEnergies, BoundaryMonitor
from solprop.stepper.multistep import Multistep
@@ -40,8 +37,38 @@
session_module = pytest.importorskip("solprop_native.session")
native = pytest.importorskip("solprop_native")
-WATCH_POLICY_FIELDS = native.WATCH_POLICY_FIELDS
-WATCH_STATE_FIELDS = native.WATCH_STATE_FIELDS
+WATCH_POLICY_FIELDS = (
+ "split",
+ "crossing",
+ "floor_regions",
+ "launch_floor",
+ "activity",
+ "warn",
+ "stop",
+ "quiet_share",
+ "quiet_checks",
+ "quiet_collar",
+ "cadence",
+ "interval",
+ "share",
+ "bucket_limits",
+)
+"""``BoundaryMonitor.watch_policy``'s fields, exactly (SEP 45 section 4.8): the numbers
+the native march predicts the monitor's checks from."""
+
+WATCH_STATE_FIELDS = (
+ "has_last",
+ "last_z",
+ "since",
+ "busy",
+ "quiet_run",
+ "quiet",
+ "warned",
+ "cap",
+)
+"""``BoundaryMonitor.watch_state``'s fields, exactly: what the predictions read and
+change, compared with solprop's monitor after every drain (SEP 45 section 4.8)."""
+
SPLIT_RATE_BUCKETS = __import__("solprop.boundaries", fromlist=["x"]).SPLIT_RATE_BUCKETS
_REACHED = backend_module._REACHED
_HOST_STEP = backend_module._HOST_STEP
@@ -397,7 +424,7 @@ def _synchronise(self, result: dict[str, Any]) -> None:
self._last_rejected = bool(state["last_rejected"])
self._collar_was_quiet = bool(state["collar_was_quiet"])
self._history = [
- _Derivative(
+ Derivative(
float(z), None, "predicted" if predicted else "accepted", int(slot)
)
for z, predicted, slot in state["history"]
diff --git a/tests/backends/planar_cases.py b/tests/backends/planar_cases.py
index bde3f4918..96bdf8a69 100644
--- a/tests/backends/planar_cases.py
+++ b/tests/backends/planar_cases.py
@@ -26,6 +26,7 @@
from solprop.nonlinear.responses import KerrResponse
from solprop.physdata import gamma3
from solprop.stepper.schedule import FixedSchedule
+from solprop.translate.responses import RunningIntegral
RADIUS_Y = 1e-3
"""Half-width of the periodic y window, m."""
@@ -144,3 +145,20 @@ def planar_case(name, *, steps=None):
problem, initial=dataclasses.replace(problem.initial, field0=field0)
)
return grid, space, problem, planar._run_request()
+
+
+def linear_responses(responses):
+ """Return the compiled responses with every response removed: a linear problem.
+
+ The order is derived from the leaves, so removing them removes their stages too.
+ """
+ return dataclasses.replace(
+ responses,
+ kerr_coefficients=None,
+ ionisation_table=(),
+ plasma_channels=(),
+ raman_terms=(),
+ dispersive_kerr=None,
+ envelope_plasma=None,
+ plasma_integral=RunningIntegral.trapezoid(),
+ )
diff --git a/tests/backends/test_adams_resume_ledgers.py b/tests/backends/test_adams_resume_ledgers.py
index fe70413ad..a8c1ec262 100644
--- a/tests/backends/test_adams_resume_ledgers.py
+++ b/tests/backends/test_adams_resume_ledgers.py
@@ -12,7 +12,7 @@
from sessions.runs import Records
from sessions.runs import run as session_run
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.session import Start
from solprop.backends.sessions import start_session
from solprop.driver import Simulation
diff --git a/tests/backends/test_adams_table_oracle.py b/tests/backends/test_adams_table_oracle.py
index d9fa173ae..6ba02f660 100644
--- a/tests/backends/test_adams_table_oracle.py
+++ b/tests/backends/test_adams_table_oracle.py
@@ -32,7 +32,7 @@
import pytest
from solprop._helpers import PLAIN, Helpers
-from solprop.backends.multistep import fused_tables, trial_tables
+from solprop.backends.numpy_ref.multistep import fused_tables, trial_tables
try:
from solprop_native._helpers import NATIVE # pyright: ignore[reportMissingImports]
diff --git a/tests/backends/test_admission.py b/tests/backends/test_admission.py
index 44c0e552b..2d6e7a5f3 100644
--- a/tests/backends/test_admission.py
+++ b/tests/backends/test_admission.py
@@ -4,9 +4,8 @@
"""The one admission list of the general modal route and the coupled flow.
-Every backend runs
-:func:`solprop.backends.numpy_ref.admission.refuse_general_route` under its own name,
-so a combination is admitted or refused once, and each refusal names the caller.
+:func:`solprop.translate.admission.check_general_route` runs in the problem's own
+validation, so a combination is admitted or refused once, for both backends.
"""
import copy
@@ -14,19 +13,16 @@
import pytest
-from driver.test_coherent_capillary import CF4_WALL, varying
+from driver.test_coherent_capillary import varying
from driver.test_split_block import build
from modal_provider_cases import general_grid, split_guide
-from solprop.backends.numpy_ref.admission import (
- FLOW_METHODS,
- refuse_coupled_flow,
- refuse_general_route,
-)
from solprop.exceptions import (
- BackendError,
GrazingIncidenceWarning,
MaterialRangeWarning,
+ TranslationError,
)
+from solprop.modes import CoherentWall
+from solprop.translate.admission import check_general_route
pytestmark = [
pytest.mark.filterwarnings(
@@ -47,40 +43,18 @@ def split_problem():
@pytest.fixture(scope="module")
-def cf4_problem():
- """A tapered wall integrated by CF4."""
- return varying(wall=CF4_WALL).translate()
-
+def frozen_problem():
+ """A tapered wall integrated by the frozen-residual split."""
+ return varying(wall=CoherentWall()).translate()
-def test_the_reference_admits_every_flow_method_it_executes(split_problem, cf4_problem):
- assert set(FLOW_METHODS) == {"constant_schur", "frozen_residual", "cf4"}
- assert refuse_general_route(split_problem) is None
- assert refuse_general_route(cf4_problem, backend="numpy_ref") is None
+def test_every_flow_method_executed_is_admitted(split_problem, frozen_problem):
+ assert check_general_route(split_problem) is None
+ assert check_general_route(frozen_problem) is None
-def test_a_section_method_the_caller_has_not_built_is_refused_by_its_name(
- cf4_problem,
-):
- """A backend narrows the flow's methods and is refused in its own name."""
- with pytest.raises(BackendError, match=r"^compiled does not execute a 'cf4'"):
- refuse_coupled_flow(
- cf4_problem,
- backend="compiled",
- flow_methods=("constant_schur", "frozen_residual"),
- )
- # The full list refuses it the same way, flow first.
- with pytest.raises(BackendError, match="not yet implemented on the native backend"):
- refuse_general_route(
- cf4_problem, backend="compiled", flow_methods=("frozen_residual",)
- )
-
-@pytest.mark.parametrize(
- "name", ["kerr_coefficients", "raman_terms", "spectral_responses"]
-)
-def test_a_legacy_response_beside_a_general_guide_is_refused_by_every_caller(
- split_problem, name
-):
+@pytest.mark.parametrize("name", ["kerr_coefficients", "raman_terms"])
+def test_a_legacy_response_beside_a_general_guide_is_refused(split_problem, name):
"""The route's source replaces the node pipeline that would read it.
The field is set on a copy past the group's own validation: what is under test
@@ -91,8 +65,7 @@ def test_a_legacy_response_beside_a_general_guide_is_refused_by_every_caller(
responses, name, object() if name == "kerr_coefficients" else (object(),)
)
spoiled = dataclasses.replace(split_problem, responses=responses)
- for backend in ("numpy_ref", "native"):
- with pytest.raises(
- BackendError, match=rf"^{backend} executes .*responses\.{name}"
- ):
- refuse_general_route(spoiled, backend=backend)
+ with pytest.raises(
+ TranslationError, match=rf"^solprop executes .*responses\.{name}"
+ ):
+ check_general_route(spoiled)
diff --git a/tests/backends/test_batch_lockstep.py b/tests/backends/test_batch_lockstep.py
index 4c70477f3..76aeb2292 100644
--- a/tests/backends/test_batch_lockstep.py
+++ b/tests/backends/test_batch_lockstep.py
@@ -291,10 +291,11 @@ def test_lockstep_refuses_mixed_backends(monkeypatch):
pytest.importorskip("solprop_native")
# Each copy's own refusals, which the test session's annotation hook would
# otherwise preempt: a member of the other family is not its driver type.
- for name in ("_validate_members", "_refuse_mixed_precisions"):
- monkeypatch.setattr(
- group_module, name, inspect.unwrap(getattr(group_module, name))
- )
+ monkeypatch.setattr(
+ group_module,
+ "_validate_members",
+ inspect.unwrap(group_module._validate_members),
+ )
with ExitStack() as cleanup:
members = tuple(
loaded(simulation(0, name), cleanup) for name in ("numpy_ref", "native")
diff --git a/tests/backends/test_batch_validation.py b/tests/backends/test_batch_validation.py
index 5b1067b6e..c5705352f 100644
--- a/tests/backends/test_batch_validation.py
+++ b/tests/backends/test_batch_validation.py
@@ -52,9 +52,9 @@ def test_batch_registry_has_no_native_runner_and_imports_nothing(monkeypatch):
monkeypatch.setattr(owner, "name", "native")
with pytest.raises(
BackendError,
- match=r"no runner for backend 'native': .* its own prepared group, which "
- r"solprop\.backends\.sessions\.start_group starts, as native's group "
- r"session does",
+ match=r"no runner for backend 'native': .* native runs its groups through "
+ r"its own prepared group, which solprop\.backends\.sessions\.start_group "
+ r"starts",
):
select_batch(cast(Sequence[Backend], [owner]))
assert owner.observe().z == 0.0
diff --git a/tests/backends/test_carrier_frame.py b/tests/backends/test_carrier_frame.py
index 7ecb554b6..f65d7f731 100644
--- a/tests/backends/test_carrier_frame.py
+++ b/tests/backends/test_carrier_frame.py
@@ -46,14 +46,14 @@
refuse_carrier_frame,
)
from solprop.backends.host.adams import HostAdamsMarch
-from solprop.backends.multistep import (
- CallLedger,
+from solprop.backends.ledger import CallLedger
+from solprop.backends.numpy_ref import NumpyRefBackend
+from solprop.backends.numpy_ref.multistep import (
CoupledBlock,
ExponentialKernels,
HostHistory,
coupled_moments,
)
-from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.boundaries import absorber
from solprop.driver import Simulation, SimulationSettings
from solprop.exceptions import BackendError, SolpropWarning, StepperError
@@ -66,7 +66,6 @@
from solprop.stepper.multistep import (
CARRIER_PHASE_CAP,
PEC_REAL_INTERVAL,
- ComparisonMultistep,
Multistep,
lagrange_coefficients,
)
@@ -351,8 +350,6 @@ def test_the_policy_is_a_field_of_the_method():
def test_a_shift_needs_the_exponential_formulation():
with pytest.raises(StepperError, match="needs the exponential formulation"):
Multistep(formulation="interaction", carrier_frame="scalar")
- with pytest.raises(StepperError, match="needs the exponential formulation"):
- ComparisonMultistep(carrier_frame="scalar")
def test_the_refusals_name_the_policy_and_the_route():
@@ -365,8 +362,6 @@ def test_the_refusals_name_the_policy_and_the_route():
for kind in ("free", "radial", "slab"):
spatial = _carrier_refusal("scalar", "numpy_ref", everything, kind)
assert spatial is not None and f"free-space route ('{kind}'" in spatial
- single = _carrier_refusal("scalar", "numpy_ref", everything, None, single=True)
- assert single is not None and "not qualified in single precision" in single
# Off, or delivered on a guided route: nothing to refuse.
assert _carrier_refusal("off", "native", frozenset(), "free") is None
assert _carrier_refusal("scalar", "numpy_ref", everything, None) is None
diff --git a/tests/backends/test_compiled_multistep.py b/tests/backends/test_compiled_multistep.py
index 5f848101c..e5d4bb4dd 100644
--- a/tests/backends/test_compiled_multistep.py
+++ b/tests/backends/test_compiled_multistep.py
@@ -4,11 +4,11 @@
"""SEP 14 phase 2: the Adams march on the compiled backends, against the reference.
-The march is shared (``solprop.backends.multistep``); only the one source evaluation
-and the propagator differ: Kokkos evaluates through its general engine's stage pipeline.
-Parity is the reference's
-trajectory to round-off: the same steps, orders and calls, and fields that agree to
-the accumulated rounding of the evaluation orders.
+The march is shared (``solprop.backends.numpy_ref.multistep``); only the one source
+evaluation and the propagator differ: Kokkos evaluates through its general engine's
+stage pipeline. Parity is the reference's trajectory to round-off: the same steps,
+orders and calls, and fields that agree to the accumulated rounding of the evaluation
+orders.
"""
import dataclasses
@@ -27,7 +27,7 @@
from solprop.backends.session import Start
from solprop.boundaries import absorber
from solprop.driver.simulation import Simulation, SimulationSettings
-from solprop.exceptions import DriverError
+from solprop.exceptions import BackendError
from solprop.grid import EnvelopeGrid, FieldGrid
from solprop.noise import modified_noise
from solprop.stepper import PIController
@@ -138,7 +138,7 @@ def test_the_settings_admit_the_compiled_backends():
**common,
)
assert settings.backend == "native"
- with pytest.raises(DriverError, match="numpy_ref and native only"):
+ with pytest.raises(BackendError, match="'cuda' is not a backend"):
SimulationSettings(
control,
backend="cuda",
diff --git a/tests/backends/test_dispersive_kerr_routes.py b/tests/backends/test_dispersive_kerr_routes.py
index 41a28e9ce..0294ed0c9 100644
--- a/tests/backends/test_dispersive_kerr_routes.py
+++ b/tests/backends/test_dispersive_kerr_routes.py
@@ -39,6 +39,7 @@
from bounds import assert_within
from legacy_geometry import LEGACY_GRID, bare_absorber
from modal_provider_cases import GENERAL_Z_END, WAVELENGTH, general_grid
+from solprop.backends import node_chunk
from solprop.backends.node_chunk import DEFAULT_NODE_BUDGET
from solprop.backends.numpy_ref import NumpyRefBackend, general_modal, passes
from solprop.backends.numpy_ref.backend import _general_maps
@@ -784,19 +785,19 @@ def test_the_general_chunk_counts_the_dispersive_buffers(guide, policy, buffers)
"""
_, (problem, _) = general_problem(guide, policy, "real")
leaf = problem.responses.dispersive_kerr
- assert general_modal.general_dispersive_buffers(leaf) == buffers
- assert general_modal.general_dispersive_buffers(None) == 0
+ assert node_chunk.general_dispersive_buffers(leaf) == buffers
+ assert node_chunk.general_dispersive_buffers(None) == 0
grid = general_grid()
n_to, n_fine = grid.n_to, int(grid.omega_fine.size)
per_node = (
(
- (general_modal.BUFFERS_PER_GENERAL_NODE + buffers) * 3
- + general_modal.PAIR_BUFFERS_PER_GENERAL_NODE * 9
+ (node_chunk.BUFFERS_PER_GENERAL_NODE + buffers) * 3
+ + node_chunk.PAIR_BUFFERS_PER_GENERAL_NODE * 9
)
* max(n_to, n_fine)
* 16
)
- chunk = general_modal.general_node_chunk
+ chunk = node_chunk.general_node_chunk
assert chunk(3, n_to, n_fine, 2 * per_node, dispersive=buffers) == 2
assert chunk(3, n_to, n_fine, 2 * per_node - 1, dispersive=buffers) == 1
# Without the response the same bytes hold more nodes.
diff --git a/tests/backends/test_driver.py b/tests/backends/test_driver.py
index 44ad3e434..a4a65a67a 100644
--- a/tests/backends/test_driver.py
+++ b/tests/backends/test_driver.py
@@ -28,7 +28,6 @@
from solprop.backends.host.driver import INITIAL_STEP_STATS_CAPACITY, HostDriver
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.backends.numpy_ref.batch import NumpyBatchBackend
-from solprop.backends.numpy_ref.single import NumpySingleBackend
from solprop.backends.protocol import BatchBackend, StepStats
from solprop.exceptions import BackendError
from solprop.log import BarReporter, LogReporter, NullReporter
@@ -38,7 +37,6 @@
from solprop.stepper.multistep import MarchPolicy
from solprop.stepper.schedule import FixedSchedule, reached
from solprop.translate import routes
-from solprop.translate import run_request as run_requests
from solprop.translate.linop import LinearOperator, delta_phi
from solprop.translate.problem import PropagationProblem, Translation
from solprop.translate.run_request import RunRequest
@@ -147,7 +145,7 @@ def test_a_rejected_step_is_retried_at_the_rejection_factor_alone(
@pytest.mark.parametrize("diverged", [np.inf, np.nan], ids=["infinite", "nan"])
def test_a_diverged_trial_is_rejected_shrunk_and_recorded_as_infinity(driver, diverged):
- """A non-finite norm, as a binary32 propagator past ``exp(88.72)`` scores, rejects.
+ """A non-finite norm, as an overflowing propagator scores, rejects.
The controller retries at its smallest factor, and the history records the trial as
infinity, so the observation at the next plane reads it back rather than refusing
@@ -954,12 +952,6 @@ def test_the_bundles_a_compiled_backend_packs_are_frozen():
HOST_SURFACE: dict[str, str] = {
- "_adams_adopt": (
- "(self, advanced: jaxtyping.Complex[ndarray, "
- "'n_batch n_modes n_pol n_omega'], "
- "dz: float) -> jaxtyping.Complex[ndarray, "
- "'n_batch n_modes n_pol n_omega']"
- ),
"_adams_block": "(self, z: float, dz: float) -> object",
"_adams_lab_source": (
"(self, lab: jaxtyping.Complex[ndarray, "
@@ -972,10 +964,6 @@ def test_the_bundles_a_compiled_backend_packs_are_frozen():
"(self, anchor: float, position: float) -> jaxtyping.Complex[ndarray, "
"'n_batch n_modes n_pol n_omega']"
),
- "_adams_restore": (
- "(self, z: float, state: jaxtyping.Complex[ndarray, "
- "'n_batch n_modes n_pol n_omega']) -> None"
- ),
"_adams_source": (
"(self, lab: jaxtyping.Complex[ndarray, "
"'n_batch n_modes n_pol n_omega'], anchor: float, position: float, *, "
@@ -992,17 +980,7 @@ def test_the_bundles_a_compiled_backend_packs_are_frozen():
"_dense_stages": "(self) -> list[numpy.ndarray]",
"_finish": "(self, state: _StateT, dz: float) -> _StateT",
"_march_to": "(self, target: float) -> None",
- "_norm_controller": "(self) -> solprop.stepper.controller.PIController",
- "_observed_field": (
- "(self, state: _StateT) -> jaxtyping.Complex[ndarray, "
- "'n_batch n_modes n_pol n_omega']"
- ),
"_on_accepted_step": "(self) -> None",
- "_prefactor": (
- "(self, "
- "bundle: solprop.translate.stage.StageScalars) -> jaxtyping.Complex[ndarray, "
- "'n_modes n_pol n_omega']"
- ),
"_record": "(self, z: float, dz: float, norm: float, accepted: bool) -> None",
"_release_source_calls": "(self) -> None",
"_reserve_source_calls": "(self, count: int) -> None",
@@ -1023,14 +1001,13 @@ def test_the_bundles_a_compiled_backend_packs_are_frozen():
"""Every ``HostDriver`` hook a NumPy reference backend overrides (SEP 47 phase 2).
The reference's own driver is no compiled seam, but its backends are written against
-these signatures: a change here changes what ``numpy_ref``, its single-precision
-backend and its group override, in the same commit."""
+these signatures: a change here changes what ``numpy_ref`` and its group override,
+in the same commit."""
HOST_ATTRIBUTES = (
"helpers",
"max_batch",
"name",
- "observes_fresh_field",
"propagator_default",
"reads_transverse_edge",
)
@@ -1040,7 +1017,7 @@ def test_the_bundles_a_compiled_backend_packs_are_frozen():
def test_the_host_hooks_the_reference_overrides_are_frozen():
overridden = {
name
- for backend in (NumpyRefBackend, NumpySingleBackend, NumpyBatchBackend)
+ for backend in (NumpyRefBackend, NumpyBatchBackend)
for owner in backend.__mro__
if owner.__module__.startswith("solprop.backends.numpy_ref")
for name in vars(owner)
@@ -1111,7 +1088,6 @@ def test_the_adams_and_dense_hooks_default_to_refusal(driver):
"responses",
"initial",
"route",
- "precision",
"split_crossing",
)
"""The fields of the one descriptor that crosses into a backend, in order: the
@@ -1135,8 +1111,8 @@ def test_the_adams_and_dense_hooks_default_to_refusal(driver):
}
"""The closed route union: each variant and its fields, in order.
-A route is added or changed here, in `kernel-contract.md` and in native's
-`ROUTE_FIELDS` together (the contract's extension procedure, section 10)."""
+A route is added or changed here and in `kernel-contract.md` together (the contract's
+extension procedure, section 10)."""
TDSE_SOURCE_FIELDS = ("descriptor", "current_numerator")
SPATIAL_KINDS = ("radial", "cartesian", "planar", "line")
@@ -1162,15 +1138,3 @@ def test_the_descriptor_and_its_route_union_are_frozen():
== TDSE_SOURCE_FIELDS
)
assert routes.SPATIAL_KINDS == SPATIAL_KINDS
-
-
-def test_the_native_descriptor_manifest_is_inside_the_frozen_one():
- native = pytest.importorskip("solprop_native")
- assert set(native.PROBLEM_FIELDS) <= set(PROPAGATION_PROBLEM_FIELDS)
- # The request's fields, and the stepper section it gathers for the march.
- assert set(native.REQUEST_FIELDS) <= {*RUN_REQUEST_FIELDS, "stepper"}
- for name, fields in native.ROUTE_FIELDS.items():
- assert set(fields) <= set(ROUTE_VARIANT_FIELDS[name])
- for name, fields in native.REQUEST_SECTION_FIELDS.items():
- section = getattr(run_requests, name)
- assert set(fields) <= {field.name for field in dataclasses.fields(section)}
diff --git a/tests/backends/test_envelope_plasma_host.py b/tests/backends/test_envelope_plasma_host.py
index 25e33f72f..0e61d5c4d 100644
--- a/tests/backends/test_envelope_plasma_host.py
+++ b/tests/backends/test_envelope_plasma_host.py
@@ -244,9 +244,7 @@ def test_problem_rejects_source_factors_from_another_grid(host_case):
(replace(assembly.factors, drude=assembly.factors.drude * 1.1), "multipliers"),
):
changed = replace(assembly, factors=factors)
- responses = replace(
- problem.responses, envelope_plasma=changed, spectral_responses=(changed,)
- )
+ responses = replace(problem.responses, envelope_plasma=changed)
with pytest.raises(TranslationError, match=message):
replace(problem, responses=responses).validate()
diff --git a/tests/backends/test_exponential_coupled.py b/tests/backends/test_exponential_coupled.py
index 54d4a1733..4b15f16dc 100644
--- a/tests/backends/test_exponential_coupled.py
+++ b/tests/backends/test_exponential_coupled.py
@@ -7,11 +7,10 @@
On a coupled section the step's reference is ``(Delta Phi / h) I + G_s``, the frozen
block of the section beside the cumulative diagonal, which is a multiple of the identity
in the channel axis; the trial's flow and moments are matrix functions of it on the bins
-that carry a block (:func:`solprop.backends.multistep.coupled_moments`). These tests
-check the matrix moments against independent evaluations, the march against the
+that carry a block (:func:`solprop.backends.numpy_ref.multistep.coupled_moments`). These
+tests check the matrix moments against independent evaluations, the march against the
Runge-Kutta march on the constant-Schur cases of ``tests/flow_parity.py``, exactness
-without a source, and what stays refused: the interaction formulation and the CF4 and
-(until it is delivered) frozen-residual sections.
+without a source, and what stays refused: the interaction formulation.
"""
import dataclasses
@@ -29,12 +28,12 @@
from sessions.runs import run as session_run
from solprop.backends import available
from solprop.backends.host.adams import HostAdamsMarch
-from solprop.backends.multistep import (
+from solprop.backends.numpy_ref import NumpyRefBackend
+from solprop.backends.numpy_ref.multistep import (
CoupledBlock,
CoupledExponentialKernels,
coupled_moments,
)
-from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.exceptions import BackendError, SolpropWarning
from solprop.stepper.multistep import Multistep
from solprop.stepper.runge_kutta import RungeKutta
diff --git a/tests/backends/test_exponential_march.py b/tests/backends/test_exponential_march.py
index 7c516f9e1..d2fcb083b 100644
--- a/tests/backends/test_exponential_march.py
+++ b/tests/backends/test_exponential_march.py
@@ -4,11 +4,12 @@
"""SEP 16 phase 2: the exponential Adams formulation on the reference backend.
-The formulation shares the Adams march's lifecycle (``solprop.backends.multistep``):
-these tests check what is its own -- the record, exactness on the linear flow, order,
-agreement with the Runge-Kutta march and the transported formulation, dense
-observation, the call ledger and budget -- and the two capabilities SEP 16 phase 2
-adds to both formulations: the ``modified`` noise and prescribed schedules.
+The formulation shares the Adams march's lifecycle
+(``solprop.backends.numpy_ref.multistep``): these tests check what is its own -- the
+record, exactness on the linear flow, order, agreement with the Runge-Kutta march and
+the transported formulation, dense observation, the call ledger and budget -- and the
+two capabilities SEP 16 phase 2 adds to both formulations: the ``modified`` noise and
+prescribed schedules.
"""
import dataclasses
@@ -27,19 +28,18 @@
from solprop.backends.host.adams import HostAdamsMarch
from solprop.backends.host.driver import HostDriver
from solprop.backends.host.group import HostGroup
-from solprop.backends.multistep import (
+from solprop.backends.numpy_ref import NumpyRefBackend
+from solprop.backends.numpy_ref.multistep import (
compensation_tables,
exponential_tables,
fused_tables,
trial_tables,
)
-from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.exceptions import (
BackendError,
ConvergenceError,
ResponseBudgetError,
SolpropWarning,
- StepperError,
TranslationError,
)
from solprop.grid import EnvelopeGrid
@@ -47,7 +47,6 @@
from solprop.stepper import FixedSchedule, PIController, RungeKutta
from solprop.stepper.multistep import (
PEC_REAL_INTERVAL,
- ComparisonMultistep,
Multistep,
march_policy,
)
@@ -121,12 +120,6 @@ def test_the_record_names_the_formulation():
"equal_approach=True, max_growth=2.0, min_factor=0.2, safety=0.8, "
"formulation='exponential', carrier_frame='off')"
)
- assert repr(ComparisonMultistep(corrector="pece", order=4)) == (
- "ComparisonMultistep(order=4, variable_order=False, max_calls=None, "
- "interval_cap=False, equal_approach=True, max_growth=2.0, min_factor=0.2, "
- "safety=0.8, formulation='interaction', carrier_frame='off', corrector='pece', "
- "corrector_order=None, interpolant='adams')"
- )
assert repr(INTERACTION).endswith(
"safety=0.8, formulation='interaction', carrier_frame='off')"
)
@@ -135,11 +128,6 @@ def test_the_record_names_the_formulation():
assert INTERACTION != EXPONENTIAL
-def test_comparison_variants_keep_the_interaction_formulation():
- with pytest.raises(StepperError, match="interaction formulation only"):
- ComparisonMultistep(corrector="pece", order=4, formulation="exponential")
-
-
@pytest.mark.parametrize("responses", ["none", "zero kerr"])
def test_exponential_is_the_exact_linear_flow_without_a_source(
narrow_grid, linear_problem, zero_kerr, responses
@@ -690,9 +678,11 @@ def test_a_backend_without_a_coupled_flow_hands_the_march_no_block(driver):
assert driver._adams_block(stub, 0.0, 1e-3) is None
-def test_the_formulation_refuses_a_cf4_section(narrow_grid, soliton_problem):
+def test_the_formulation_refuses_a_section_it_does_not_step(
+ narrow_grid, soliton_problem
+):
# SEP 16 Q3: the exponential formulation steps constant-Schur and frozen-residual
- # sections (phase 5); a CF4 section is excluded, and the shared check names it.
+ # sections (phase 5); any other section method is refused by name.
problem, run_request = soliton_problem(
narrow_grid, 1, save_planes=[0.1], integrator=EXPONENTIAL
)
@@ -712,11 +702,11 @@ def test_the_formulation_refuses_a_cf4_section(narrow_grid, soliton_problem):
coupled, run_request.stepper, "probe"
) # a constant-Schur section is stepped
flow = object.__new__(type(flow))
- object.__setattr__(flow, "section_methods", ("cf4",))
+ object.__setattr__(flow, "section_methods", ("diagonal",))
coupled = dataclasses.replace(
problem, linear=dataclasses.replace(problem.linear, coupled_flow=flow)
)
- with pytest.raises(BackendError, match="'cf4' section"):
+ with pytest.raises(BackendError, match="'diagonal' sections"):
refuse_formulation(coupled, run_request.stepper, "probe")
diff --git a/tests/backends/test_exponential_varying.py b/tests/backends/test_exponential_varying.py
index f7c0ad60b..a6ae786c8 100644
--- a/tests/backends/test_exponential_varying.py
+++ b/tests/backends/test_exponential_varying.py
@@ -23,7 +23,7 @@
from backends.conftest import EQUIVALENCE_BACKENDS
from sessions.runs import run as session_run
from solprop.backends.host.adams import HostAdamsMarch
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.driver.simulation import Simulation
from solprop.gasflow import steady_fill
diff --git a/tests/backends/test_exponential_weights.py b/tests/backends/test_exponential_weights.py
index bccedd72a..1fce4c66b 100644
--- a/tests/backends/test_exponential_weights.py
+++ b/tests/backends/test_exponential_weights.py
@@ -24,7 +24,7 @@
exponential_weights,
phi_functions,
)
-from solprop.backends.multistep import ExponentialKernels
+from solprop.backends.numpy_ref.multistep import ExponentialKernels
from solprop.exceptions import StepperError
from solprop.stepper.multistep import integrated_weights, lagrange_coefficients
diff --git a/tests/backends/test_far_field_statistics.py b/tests/backends/test_far_field_statistics.py
index bdd4cabe1..a989beb42 100644
--- a/tests/backends/test_far_field_statistics.py
+++ b/tests/backends/test_far_field_statistics.py
@@ -699,26 +699,27 @@ def test_radial_and_planar_geometry_are_refused_before_any_step(grid):
def test_an_older_native_build_is_refused_the_far_field_statistics_by_name(
grid, monkeypatch
):
+ native = pytest.importorskip("solprop_native")
space = FreeGrid(3e-4, 8, 3e-4, 8, collar_fraction=0.1)
simulation = free_simulation(grid, space, gaussian(60e-6))
problem = simulation.translate()
run_request = simulation._run_request()
# A build that declares none of them, as every build before SEP 23's third phase:
- # the refusal precedes the native build and names the backends that form them.
- monkeypatch.setattr(registry, "_plugin_far_field", lambda name: frozenset())
+ # the refusal precedes the native build and names the backend that forms them.
+ monkeypatch.setattr(native.NativeBackend, "far_field_statistics", frozenset())
with pytest.raises(BackendError, match="or run them on numpy_ref"):
select("native", problem, run_request, grid=grid)
assert statistics_refusal("native", ["energy"]) is None
assert statistics_refusal("numpy_ref", list(ALL_SIX)) is None
# A build that declares some of them is refused the others, by name.
monkeypatch.setattr(
- registry, "_plugin_far_field", lambda name: frozenset({"beam_spectrum"})
+ native.NativeBackend, "far_field_statistics", frozenset({"beam_spectrum"})
)
refusal = statistics_refusal("native", ["beam_spectrum", "region_spectrum"])
assert refusal is not None
assert "['region_spectrum']" in refusal
assert statistics_refusal("native", ["beam_spectrum"]) is None
- monkeypatch.setattr(registry, "_plugin_far_field", lambda name: frozenset())
+ monkeypatch.setattr(native.NativeBackend, "far_field_statistics", frozenset())
plain = free_simulation(
grid, space, gaussian(60e-6), spec=StatsSpec(names=("energy",))
@@ -741,26 +742,24 @@ def test_an_older_native_build_is_refused_the_far_field_statistics_by_name(
owner.close()
-def test_the_plugin_probe_reads_what_an_installed_build_declares(monkeypatch):
+def test_the_native_probe_reads_what_the_installed_build_declares(monkeypatch):
# Not installed: no refusal here, and selecting it reports the missing package.
- monkeypatch.setattr(registry, "_satisfied", lambda entry: False)
- assert registry._plugin_far_field("native") is None
+ monkeypatch.setattr(registry, "_native_installed", lambda: False)
assert statistics_refusal("native", ["beam_spectrum"]) is None
- assert registry._plugin_far_field("numpy_ref") is None
monkeypatch.undo()
+ assert statistics_refusal("numpy_ref", list(ALL_SIX)) is None
if importlib.util.find_spec("solprop_native") is None:
pytest.skip("solprop-native is not installed")
- # The optional plugin is imported only after its availability check.
+ # The package is imported only after its availability check.
from solprop_native import ( # noqa: PLC0415 # pyright: ignore[reportMissingImports]
NativeBackend,
)
- declared = registry._plugin_far_field("native")
- assert declared == frozenset(getattr(NativeBackend, "far_field_statistics", ()))
- # A build that predates the attribute declares none.
- monkeypatch.delattr(NativeBackend, "far_field_statistics", raising=False)
- assert registry._plugin_far_field("native") == frozenset()
- assert statistics_refusal("native", list(ALL_SIX)) is not None
+ assert statistics_refusal("native", list(ALL_SIX)) is None
+ # A build that declares none is refused every one, naming numpy_ref.
+ monkeypatch.setattr(NativeBackend, "far_field_statistics", frozenset())
+ refusal = statistics_refusal("native", list(ALL_SIX))
+ assert refusal is not None and "numpy_ref" in refusal
def test_the_host_pass_refuses_what_it_was_not_given(grid):
diff --git a/tests/backends/test_general_modal.py b/tests/backends/test_general_modal.py
index f2f0d3050..946c0deb4 100644
--- a/tests/backends/test_general_modal.py
+++ b/tests/backends/test_general_modal.py
@@ -23,6 +23,8 @@
import copy
import dataclasses
+import functools
+import re
import numpy as np
import pytest
@@ -42,26 +44,22 @@
from solprop import fourier
from solprop import stats as stats_module
from solprop.backends import select
+from solprop.backends.node_chunk import general_node_chunk
from solprop.backends.numpy_ref import NumpyRefBackend
-from solprop.backends.numpy_ref.admission import refuse_general_route
from solprop.backends.numpy_ref.backend import _GENERAL_LEDGER_ROWS, _general_maps
from solprop.backends.numpy_ref.general_modal import (
GeneralMaps,
delayed_polarisation,
expand_pair_history,
general_memory,
- general_node_chunk,
general_source,
- node_delayed_maps,
- node_kerr_form,
- node_kerr_tensor,
symmetric_cubic,
symmetric_kerr_coefficients,
symmetric_monomials,
)
from solprop.boundaries import absorber
from solprop.constants import EPS0
-from solprop.exceptions import BackendError
+from solprop.exceptions import BackendError, TranslationError
from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid
from solprop.nonlinear.normalisation import modal_kerr_coefficient
from solprop.nonlinear.raman import SilicaTensorResponse
@@ -69,8 +67,14 @@
from solprop.spatial import SpatialMedium
from solprop.stats import StatsSpec, default_spec
from solprop.stepper import FixedSchedule, PIController
+from solprop.translate.admission import check_general_route
from solprop.translate.coupled import CoupledFlowTables, ModalMetricTables, metric_at
-from solprop.translate.general import metric_departure
+from solprop.translate.general import (
+ metric_departure,
+ node_delayed_maps,
+ node_kerr_form,
+ node_kerr_tensor,
+)
from solprop.translate.linop import delta_phi
from solprop.translate.problem import Translation, _walk_arrays, assemble
from solprop.translate.responses import (
@@ -92,10 +96,9 @@
def leaf_value(name, problem):
"""Return a value for one leaf that the backend must refuse.
- Three of the four are built for real. ``modal_tables`` is a dense E/H reference of
- a guide this problem has no provider for, so it stands in as a sentinel installed
- past the constructor: the refusal reads the field for ``None`` and nothing else,
- and it fires before ``validate`` ever looks inside.
+ All four are built for real, for another problem than this one: ``modal_tables``
+ is the strand's reference (:func:`strand_tables`), whose channels and grids are
+ not this problem's.
"""
n_omega = problem.shape[-1]
n_modes = problem.shape[0]
@@ -142,7 +145,20 @@ def leaf_value(name, problem):
"isotropic-kerr",
"dense-nodes",
)
- return object()
+ return strand_tables()
+
+
+def frozen(values):
+ """Return a read-only copy, as every array crossing the seam is."""
+ copied = np.array(values)
+ copied.setflags(write=False)
+ return copied
+
+
+@functools.cache
+def strand_tables():
+ """The 1.5 um strand's E/H reference, built once."""
+ return strand_case((("HE", 1, 1, "even"),)).route.tables
def with_leaf(problem, name):
@@ -210,23 +226,15 @@ def test_the_disabled_route_puts_no_new_array_across_the_seam(disabled):
def test_every_backend_refuses_every_general_modal_leaf_by_name(
backend_class, backend_name, disabled, disabled_run, soliton_grid, leaf
):
- """A leaf a backend has no kernel for is refused by its dotted name.
-
- `numpy_ref` executes three of the four as the general modal route and the
- fourth, `linear.coupled_flow`, as the coherent wall block, so it names all four
- in `admits` and checks the exact representation itself
- (`test_the_reference_refuses_...` below and `tests/modes/test_wall_operator.py`).
- ``native`` executes all four too, and runs the shared admission list under its own
- name, which refuses a spoiled leaf here and names it.
+ """A leaf beside a problem it does not belong to is refused by its dotted name.
+
+ The refusal is the problem's own validation, which both backends run where they
+ are built, so it is the same on each: the leaf's consistency with the state first,
+ then the route's policy.
"""
assert backend_name in BACKEND_NAMES
- if backend_name == "numpy_ref":
- pytest.skip("numpy_ref executes every schema-12 leaf in one combination")
spoiled = with_leaf(disabled, leaf)
- expected = f"does not execute {GROUP[leaf]}.{leaf}"
- if backend_name == "native":
- expected = "native executes (a general modal guide|a constant-Schur|.* as part)"
- with pytest.raises(BackendError, match=expected):
+ with pytest.raises(TranslationError, match=re.escape(f"{GROUP[leaf]}.{leaf}")):
backend_class(spoiled, disabled_run.request, grid=soliton_grid)
@@ -629,12 +637,12 @@ def test_the_reference_refuses_a_diagonal_metric_by_name(strand):
problem, run_request = spoiled_problem(
strand, metric=("linear", "modal_metric", metric)
)
- with pytest.raises(BackendError, match="kind 'diagonal'"):
+ with pytest.raises(TranslationError, match="kind 'diagonal'"):
NumpyRefBackend(problem, run_request, grid=strand.grid)
absent, absent_request = spoiled_problem(
strand, metric=("linear", "modal_metric", None)
)
- with pytest.raises(BackendError, match="is absent"):
+ with pytest.raises(TranslationError, match="is absent"):
NumpyRefBackend(absent, absent_request, grid=strand.grid)
@@ -695,7 +703,7 @@ def test_the_reference_refuses_a_dense_metric_of_another_basis(circular_strand):
amplitudes=(1.0, 0.0),
metric=("linear", "modal_metric", elsewhere),
)
- with pytest.raises(BackendError, match="describes the basis 'elsewhere'"):
+ with pytest.raises(TranslationError, match="describes the basis 'elsewhere'"):
NumpyRefBackend(spoiled, spoiled_request, grid=circular_strand.grid)
@@ -708,13 +716,13 @@ def test_the_reference_refuses_a_dense_metric_too_far_from_orthonormal(
metric = problem.linear.modal_metric
assert metric is not None and metric.dense is not None
skewed = dataclasses.replace(metric)
- object.__setattr__(skewed, "dense", metric.dense * 1.5)
+ object.__setattr__(skewed, "dense", frozen(metric.dense * 1.5))
spoiled, spoiled_request = spoiled_problem(
circular_strand,
amplitudes=(1.0, 0.0),
metric=("linear", "modal_metric", skewed),
)
- with pytest.raises(BackendError, match=r"dense and 0\.5"):
+ with pytest.raises(TranslationError, match=r"dense and 0\.5"):
NumpyRefBackend(spoiled, spoiled_request, grid=circular_strand.grid)
@@ -742,20 +750,82 @@ def test_the_reference_refuses_an_identity_metric_that_is_not_the_identity(stran
problem, run_request = spoiled_problem(
strand, metric=("linear", "modal_metric", metric)
)
- with pytest.raises(BackendError, match=r"linear\.modal_metric\.error is 0\.5"):
+ with pytest.raises(TranslationError, match=r"linear\.modal_metric\.error is 0\.5"):
NumpyRefBackend(problem, run_request, grid=strand.grid)
+def test_a_mis_shaped_metric_certificate_is_refused_by_name(strand):
+ """A certificate row set past the record's construction is checked by name.
+
+ The leaf's consistency with its own axes is checked before the route's policy
+ reads a row, so a hand-built leaf meets a named refusal, not an indexing error.
+ """
+ _, state = continuous_wave(strand, [1.0])
+ problem = general_problem(strand, state).problem
+ assert problem.linear.modal_metric is not None
+ metric = dataclasses.replace(problem.linear.modal_metric)
+ short = np.zeros(3)
+ short.setflags(write=False)
+ object.__setattr__(metric, "error", short)
+ spoiled, _ = spoiled_problem(strand, metric=("linear", "modal_metric", metric))
+ with pytest.raises(TranslationError, match=r"modal_metric\.error has shape \(3,\)"):
+ spoiled.validate()
+
+
def test_the_reference_refuses_a_metric_or_a_response_with_no_tables(
disabled, disabled_run, soliton_grid
):
"""A leaf of the general route without the reference it belongs to."""
for leaf in ("modal_metric", "regional_responses"):
spoiled = with_leaf(disabled, leaf)
- with pytest.raises(BackendError, match=r"no transform\.modal_tables"):
+ with pytest.raises(
+ TranslationError,
+ match=re.escape(f"{GROUP[leaf]}.{leaf}"),
+ ):
NumpyRefBackend(spoiled, disabled_run.request, grid=soliton_grid)
+def test_a_metric_on_the_problem_s_own_grid_with_no_tables_is_refused(strand):
+ """A metric consistent with the state, on a guide with no reference, is the route's.
+
+ The strand's own identity metric beside the plain modal problem on the same field
+ grid: every structural check passes, and the route's policy names the tables it
+ lacks.
+ """
+ _, state = continuous_wave(strand, [1.0])
+ metric = general_problem(strand, state).problem.linear.modal_metric
+ plain = general_problem(strand, state, general=None).problem
+ assert plain.transform.modal_tables is None
+ linear = dataclasses.replace(plain.linear)
+ object.__setattr__(linear, "modal_metric", metric)
+ spoiled = dataclasses.replace(plain, linear=linear)
+ with pytest.raises(
+ TranslationError,
+ match=r"^solprop executes linear\.modal_metric as part of the general modal "
+ r"route, and this problem carries no transform\.modal_tables",
+ ):
+ spoiled.validate()
+
+
+@pytest.mark.parametrize("leaf", ["coupled_flow", "modal_metric", "regional_responses"])
+def test_a_leaf_that_is_not_its_record_type_is_refused_by_type(strand, leaf):
+ """A hand-built problem holding something else in a leaf meets a named refusal."""
+ _, state = continuous_wave(strand, [1.0])
+ problem = general_problem(strand, state).problem
+ group = dataclasses.replace(getattr(problem, GROUP[leaf]))
+ object.__setattr__(group, leaf, object())
+ spoiled = dataclasses.replace(problem, **{GROUP[leaf]: group})
+ kind = {
+ "coupled_flow": "CoupledFlowTables",
+ "modal_metric": "ModalMetricTables",
+ "regional_responses": "RegionalResponseTables",
+ }[leaf]
+ with pytest.raises(
+ TranslationError, match=rf"^{GROUP[leaf]}\.{leaf} is a object, not a {kind}$"
+ ):
+ spoiled.validate()
+
+
def test_the_reference_refuses_a_reference_with_no_execution_map(strand):
"""A table sampled over a path carries no map, and is refused."""
tables = dataclasses.replace(strand.route.tables)
@@ -764,7 +834,7 @@ def test_the_reference_refuses_a_reference_with_no_execution_map(strand):
problem, run_request = spoiled_problem(
strand, maps=("transform", "modal_tables", tables)
)
- with pytest.raises(BackendError, match="carries neither"):
+ with pytest.raises(TranslationError, match="carries neither"):
NumpyRefBackend(problem, run_request, grid=strand.grid)
@@ -778,26 +848,31 @@ def test_the_reference_refuses_moving_maps_without_their_connection(strand):
tables = dataclasses.replace(strand.route.tables)
assert strand.route.tables.synthesis is not None
object.__setattr__(
- tables, "synthesis", np.repeat(strand.route.tables.synthesis, 2, axis=1)
+ tables, "synthesis", frozen(np.repeat(strand.route.tables.synthesis, 2, axis=1))
)
problem, run_request = spoiled_problem(
strand, maps=("transform", "modal_tables", tables)
)
- with pytest.raises(BackendError, match="carries no coupled flow"):
+ with pytest.raises(TranslationError, match="carries no coupled flow"):
NumpyRefBackend(problem, run_request, grid=strand.grid)
- # A backend that has not built moving maps refuses them by name, whatever else.
- with pytest.raises(BackendError, match=r"native executes .* of constant geometry"):
- refuse_general_route(problem, backend="native")
def test_the_reference_refuses_a_power_weight_that_is_not_one(strand):
- """The Poynting amplitude is the true-power amplitude: the weight is one."""
+ """The Poynting amplitude is the true-power amplitude: the weight is one.
+
+ Taken on the route's policy itself: the constant is spoiled alone, which the
+ problem's structure refuses first, as disagreeing with its own table.
+ """
_, state = continuous_wave(strand, [1.0])
problem, run_request = general_problem(strand, state)
linear = dataclasses.replace(problem.linear)
- object.__setattr__(linear, "power_weight_constant", np.full(problem.shape, 1.5))
+ object.__setattr__(
+ linear, "power_weight_constant", frozen(np.full(problem.shape, 1.5))
+ )
spoiled = dataclasses.replace(problem, linear=linear)
- with pytest.raises(BackendError, match="constant one"):
+ with pytest.raises(TranslationError, match="constant one"):
+ check_general_route(spoiled)
+ with pytest.raises(TranslationError, match="power_weight_constant must match"):
NumpyRefBackend(spoiled, run_request, grid=strand.grid)
@@ -905,6 +980,8 @@ def spoiled_response(strand, name, value):
response = regional_responses(
[CHI3], [0], n_to=strand.grid.n_to, dt_fine=strand.grid.dt_fine
)
+ if isinstance(value, np.ndarray):
+ value.setflags(write=False)
object.__setattr__(response, name, value)
return response
@@ -930,9 +1007,9 @@ def test_the_reference_refuses_a_regional_response_it_cannot_execute(
propagates a meaningless polarisation, and two died inside `einsum` with a shape
message naming no leaf.
- Checked through the backend's constructor rather than through
- `node_kerr_tensor`, because that is where it matters: the refusal is in the
- admission list, which runs before the backend allocates a stage.
+ Checked through the backend's constructor, because that is where it matters: the
+ refusal is in the problem's own validation, which runs before the backend
+ allocates a stage.
"""
problem, run_request = spoiled_problem(
strand,
@@ -942,42 +1019,32 @@ def test_the_reference_refuses_a_regional_response_it_cannot_execute(
spoiled_response(strand, name, value),
),
)
- with pytest.raises(BackendError, match=message):
+ with pytest.raises(TranslationError, match=message):
NumpyRefBackend(problem, run_request, grid=strand.grid)
- # And the public helper refuses the same record, before its own allocation.
- with pytest.raises(BackendError, match=message):
- node_kerr_tensor(strand.route.tables, spoiled_response(strand, name, value))
+ # And the problem refuses it alone, with no request.
+ with pytest.raises(TranslationError, match=message):
+ problem.validate()
+ if "Cartesian" not in message:
+ # The public derivation refuses the same record, by the same rule, before it
+ # builds anything; the component count is the problem's structure.
+ with pytest.raises(TranslationError, match=message):
+ node_kerr_tensor(strand.route.tables, spoiled_response(strand, name, value))
def test_an_unknown_model_identity_is_not_executed_as_one_the_backend_knows(strand):
"""A model this package does not know is refused, not read as its neighbour.
The instantaneous form travels in `model_id` rather than in a leaf field of its
- own, because a field would move a contract row; the price is that the backend
- must refuse an identity outside the declared vocabulary instead of defaulting.
+ own, because a field would move a contract row; the price is that an identity
+ outside the declared vocabulary must be refused instead of defaulting.
"""
spoiled = spoiled_response(strand, "model_id", "isotropic-quartic")
- with pytest.raises(BackendError, match="isotropic-quartic"):
+ with pytest.raises(TranslationError, match="isotropic-quartic"):
node_kerr_form(spoiled)
assert node_kerr_form(None) == "analytic"
assert node_kerr_form(strand.route.responses) == "real"
-def test_the_reference_refuses_a_state_with_a_polarisation_axis(strand):
- """A general guide's components are its own; the state keeps `n_pol = 1`.
-
- Two state polarisations beside a general reference would leave the pass
- returning a one-wide source for `stage_output` to broadcast onto a two-wide
- prefactor -- two polarisations driven by one channel's polarisation.
- """
- _, state = continuous_wave(strand, [1.0])
- problem, run_request = general_problem(strand, state)
- n_modes, _, n_omega = problem.shape
- spoiled = dataclasses.replace(problem, shape=(n_modes, 2, n_omega))
- with pytest.raises(BackendError, match="n_pol = 1, and this problem carries 2"):
- NumpyRefBackend(spoiled, run_request, grid=strand.grid)
-
-
def test_a_linear_guide_has_no_polarisation_at_any_node(strand):
"""A guide whose regions declare no response gives an identically zero tensor."""
summed = node_kerr_tensor(strand.route.tables, None)
@@ -1272,11 +1339,16 @@ def pre_c2b_review_source(state, synthesis, maps, crop_index, *, n_to, pad_scale
def test_the_reference_refuses_a_general_route_on_an_envelope_grid(strand):
- """A leaf is tabulated on the canonical coarse grid, which is increasing."""
+ """A leaf is tabulated on a field grid's increasing coarse axis: refused by name."""
_, state = continuous_wave(strand, [1.0])
problem, run_request = general_problem(strand, state)
spoiled = dataclasses.replace(problem, grid_kind="envelope")
- with pytest.raises(BackendError, match="on a field grid"):
+ with pytest.raises(
+ TranslationError,
+ match=r"^solprop executes a general modal guide \(transform\.modal_tables\) on "
+ r"a field grid, whose canonical axis is increasing, and this problem is "
+ r"envelope$",
+ ):
NumpyRefBackend(spoiled, run_request, grid=strand.grid)
@@ -1908,12 +1980,16 @@ def test_a_delayed_record_whose_identity_disagrees_with_it_is_refused(strand):
dt_fine=grid.dt_fine,
)
object.__setattr__(regional, "model_id", "isotropic-kerr")
- with pytest.raises(BackendError, match="must agree"):
- node_delayed_maps(strand.route.tables, regional)
plain = regional_responses([CHI3], [0], n_to=grid.n_to, dt_fine=grid.dt_fine)
object.__setattr__(plain, "model_id", "isotropic-kerr+silica-tensor-raman")
- with pytest.raises(BackendError, match="must agree"):
- node_delayed_maps(strand.route.tables, plain)
+ for spoiled in (regional, plain):
+ problem, _ = spoiled_problem(
+ strand, responses=("responses", "regional_responses", spoiled)
+ )
+ with pytest.raises(TranslationError, match="must agree"):
+ problem.validate()
+ with pytest.raises(TranslationError, match="must agree"):
+ node_delayed_maps(strand.route.tables, spoiled)
def test_a_delayed_response_refuses_a_non_uniform_temporal_weight(strand):
@@ -1928,13 +2004,22 @@ def test_a_delayed_response_refuses_a_non_uniform_temporal_weight(strand):
)
weight = np.array(regional.temporal_weight, dtype=float)
weight[3] *= 1.5
+ weight.setflags(write=False)
object.__setattr__(regional, "temporal_weight", weight)
- with pytest.raises(BackendError, match="not uniform"):
+ problem, _ = spoiled_problem(
+ strand, responses=("responses", "regional_responses", regional)
+ )
+ with pytest.raises(TranslationError, match="not uniform"):
+ problem.validate()
+ with pytest.raises(TranslationError, match="not uniform"):
node_delayed_maps(strand.route.tables, regional)
def test_a_record_with_more_delayed_terms_than_the_model_has_is_refused(strand):
- """Two channels, named; a third is not executed as one of them."""
+ """Two channels, named; a third is refused when the record is built.
+
+ The translation-time check is the one raise site for both backends.
+ """
grid = strand.grid
regional = regional_responses(
[CHI3],
@@ -1943,13 +2028,13 @@ def test_a_record_with_more_delayed_terms_than_the_model_has_is_refused(strand):
n_to=grid.n_to,
dt_fine=grid.dt_fine,
)
- object.__setattr__(regional, "delayed_tensor", np.zeros((1, 3, 3, 3, 3, 3)))
- object.__setattr__(
- regional, "delayed_kernel", np.zeros((1, 1, 1, 3, 2 * grid.n_to))
- )
- object.__setattr__(regional, "kernel_valid_samples", np.zeros((1, 3), np.int32))
- with pytest.raises(BackendError, match="delayed channels"):
- node_delayed_maps(strand.route.tables, regional)
+ with pytest.raises(TranslationError, match="2 delayed channels"):
+ dataclasses.replace(
+ regional,
+ delayed_tensor=np.zeros((1, 3, 3, 3, 3, 3)),
+ delayed_kernel=np.zeros((1, 1, 1, 3, 2 * grid.n_to)),
+ kernel_valid_samples=np.zeros((1, 3), np.int32),
+ )
def test_two_regions_meet_their_own_kernels_and_only_theirs(strand):
diff --git a/tests/backends/test_held_plasma_contract.py b/tests/backends/test_held_plasma_contract.py
index a8d6c9873..2bf9f4da8 100644
--- a/tests/backends/test_held_plasma_contract.py
+++ b/tests/backends/test_held_plasma_contract.py
@@ -14,7 +14,7 @@
from solprop.backends import registry
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.driver.simulation import _open_output, metadata_for
-from solprop.exceptions import BackendError, DriverError, OutputError, TranslationError
+from solprop.exceptions import DriverError, OutputError, TranslationError
from solprop.output import HDF5Output, check_cache, resume
from solprop.output.schema import SavedPlane
from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record
@@ -59,12 +59,18 @@ def test_benchmark_identity_and_both_resume_doors(
_open_output(path, historical, resume=True)
-def test_gated_hook_refuses_compiled_execution_and_resets_after_exception(
- ionising_capillary,
-):
+def test_gated_hook_runs_on_native_and_resets_after_exception(ionising_capillary):
+ pytest.importorskip("solprop_native")
problem, run_request, _, _ = ionising_capillary()
- with pytest.raises(BackendError, match="requires numpy_ref"), _gated_source():
- registry.select("native", problem, run_request)
+ with pytest.raises(RuntimeError, match="leaves the context"), _gated_source():
+ backend = registry.select("native", problem, run_request)
+ try:
+ source = backend.metadata()["plasma_source"]
+ assert isinstance(source, dict)
+ assert source["form"] == "gated_benchmark"
+ finally:
+ backend.close()
+ raise RuntimeError("an error leaves the context")
assert not _BENCHMARK_GATED.get()
@@ -119,7 +125,13 @@ def test_private_historical_collar_cannot_bypass_production_admission(
"form": "gated_benchmark",
"resumable": False,
}
- with pytest.raises(BackendError, match="requires numpy_ref"):
- registry.select("native", historical, run_request)
+ if "native" in registry.available():
+ backend = registry.select("native", historical, run_request)
+ try:
+ source = backend.metadata()["plasma_source"]
+ assert isinstance(source, dict)
+ assert source["form"] == "gated_benchmark"
+ finally:
+ backend.close()
with pytest.raises(TranslationError, match="collar certificate"):
historical.validate()
diff --git a/tests/backends/test_luna_mixture_propagation.py b/tests/backends/test_luna_mixture_propagation.py
index fa190b565..64fbc9d89 100644
--- a/tests/backends/test_luna_mixture_propagation.py
+++ b/tests/backends/test_luna_mixture_propagation.py
@@ -15,7 +15,6 @@
from gated_source import _gated_source
from mixture_cases import AnalyticRate
from sessions.runs import run as session_run
-from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.constants import EPS0, C
from solprop.grid import FieldGrid
from solprop.modes import MarcatiliMode
@@ -182,11 +181,12 @@ def test_luna_mixture_field_witness(tag, backend_class):
check_luna_mixture_field_witness(tag, backend_class)
-def test_luna_plasma_witness_retains_the_historical_gated_equation():
- # The archived array and its tolerance retain their original equation. The
- # production current source has independent all-backend mixture ladders.
+def test_luna_plasma_witness_retains_the_historical_gated_equation(backend_class):
+ # The archived array and its tolerance retain their original equation, which
+ # both backends run privately. The production current source has independent
+ # all-backend mixture ladders.
with _gated_source():
- check_luna_mixture_field_witness("PLASMA", NumpyRefBackend)
+ check_luna_mixture_field_witness("PLASMA", backend_class)
def check_luna_mixture_field_witness(tag, backend_class):
diff --git a/tests/backends/test_multistep_march.py b/tests/backends/test_multistep_march.py
index d440647f3..3800eb2d0 100644
--- a/tests/backends/test_multistep_march.py
+++ b/tests/backends/test_multistep_march.py
@@ -15,20 +15,18 @@
import pytest
from legacy_geometry import LEGACY_GRID
-from solprop.backends.memory import adams_memory
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.backends.registry import resolve_name
from solprop.driver.simulation import Simulation, SimulationSettings
from solprop.exceptions import (
BackendError,
- DriverError,
ResponseBudgetError,
SolpropWarning,
)
from solprop.gasflow import steady_fill
from solprop.grid import EnvelopeGrid
from solprop.stepper import TSIT5, FixedSchedule, PIController, RungeKutta
-from solprop.stepper.multistep import ComparisonMultistep, Multistep
+from solprop.stepper.multistep import Multistep
from solprop.translate.coupled import constant_schur_flow
from solprop.translate.linop import delta_phi
@@ -36,10 +34,9 @@
# its -11.83 fs^2/mm fibre (tests/backends/conftest.py).
SOLITON_PERIOD = math.pi / 2 * (100e-15) ** 2 / 11.83e-27
-PEC4 = ComparisonMultistep(corrector="pec", order=4)
-PECE4 = ComparisonMultistep(corrector="pece", order=4)
-AB4 = ComparisonMultistep(corrector="none", order=4)
-RICHTER = ComparisonMultistep(corrector="pec", order=1, corrector_order=2)
+RICHTER = Multistep(order=1, variable_order=False, formulation="interaction")
+PEC34 = Multistep(order=3, variable_order=False, formulation="interaction")
+PEC45 = Multistep(order=4, variable_order=False, formulation="interaction")
@pytest.fixture(scope="session")
@@ -51,14 +48,6 @@ def narrow_grid():
return EnvelopeGrid.from_limits(1.0, 835e-9, (500e-9, 2e-6), 1e-12, **LEGACY_GRID)
-def test_an_unfused_method_holds_no_history_rows(linear_problem, narrow_grid):
- # Its history is a list of arrays, outside the fused store's rows.
- problem, run_request = linear_problem(
- narrow_grid, save_planes=[0.01], integrator=PECE4
- )
- assert adams_memory(problem, run_request.stepper) == {}
-
-
def run(problem, run_request, grid):
"""Propagate a problem to its last plane; return the field and the call ledger."""
backend = NumpyRefBackend(problem, run_request, grid=grid)
@@ -71,9 +60,7 @@ def run(problem, run_request, grid):
@pytest.mark.medium
-@pytest.mark.parametrize(
- ("method", "order"), [(AB4, 4), (PEC4, 4), (PECE4, 4), (RICHTER, 2)]
-)
+@pytest.mark.parametrize(("method", "order"), [(RICHTER, 2)])
def test_adams_converges_at_its_order(narrow_grid, soliton_problem, method, order):
# A fundamental soliton over one period. dz_max sets the mature step; the tolerance
# is a loose safety net that never binds, and the startup ramp starts a thousandth
@@ -96,15 +83,15 @@ def solve(n, integrator, rtol=1e-2):
for n in (20, 80)
]
observed = math.log(errors[0] / errors[1]) / math.log(4)
- # Three levels of the measurement: 3.7-5.4 for the order-four methods, 2.0 for
- # Richter's; the margin admits the irregular startup and landing steps.
+ # Three levels of the measurement: 2.0 for Richter's; the margin admits the
+ # irregular startup and landing steps.
assert observed > order - 0.6
def test_adams_is_the_exact_linear_flow_without_a_source(narrow_grid, linear_problem):
length = 0.5
problem, run_request = linear_problem(
- narrow_grid, save_planes=[length], integrator=PECE4
+ narrow_grid, save_planes=[length], integrator=PEC45
)
field, ledger = run(problem, run_request, narrow_grid)
start = problem.initial.field0[0, 0] * problem.grids.active_mask
@@ -120,9 +107,7 @@ def test_adams_is_the_exact_linear_flow_without_a_source(narrow_grid, linear_pro
(Multistep(formulation="interaction"), 1),
(Multistep(order=1, formulation="interaction"), 1),
(Multistep(interval_cap=True, formulation="interaction"), 1),
- (AB4, 1),
- (PEC4, 1),
- (PECE4, 2),
+ (PEC45, 1),
],
)
def test_adams_call_ledger(narrow_grid, soliton_problem, method, per_step):
@@ -145,21 +130,17 @@ def test_adams_call_ledger(narrow_grid, soliton_problem, method, per_step):
assert max(ledger.orders) <= method.order
-def test_settings_refuse_an_integrator_elsewhere():
+def test_settings_refuse_a_backend_that_is_neither_whatever_the_integrator():
+ """Both backends step every integrator; the registry refuses any other name."""
planes = [1.0]
common = {"save_planes": planes, "dz_max": 0.1, "dz_init": 1e-3}
control = PIController(rtol=1e-6, atol=1e-12)
- with pytest.raises(DriverError, match="RungeKutta"):
- SimulationSettings(control, backend="cuda", integrator=PEC4, **common)
- with pytest.raises(DriverError, match="RungeKutta"):
- SimulationSettings(control, backend="cuda", **common)
- settings = SimulationSettings(control, integrator=PEC4, **common)
+ for integrator in (PEC45, None, RungeKutta(TSIT5)):
+ with pytest.raises(BackendError, match="'cuda' is not a backend"):
+ SimulationSettings(control, backend="cuda", integrator=integrator, **common)
+ settings = SimulationSettings(control, integrator=PEC45, **common)
# Resolved as for the Runge-Kutta march (SEP 16 phase 3).
assert settings.backend == resolve_name("auto")
- # Any backend steps the Runge-Kutta march.
- assert SimulationSettings(
- control, backend="cuda", integrator=RungeKutta(TSIT5), **common
- ).integrator == RungeKutta(TSIT5)
def test_the_default_integrator_is_exponential_adams():
@@ -189,19 +170,16 @@ def test_contract_admits_an_integrator_with_a_schedule(narrow_grid, soliton_prob
# SEP 16 phase 2: a schedule fixes an Adams run's accepted positions.
schedule = FixedSchedule.uniform(0.0, 0.1, 0.01, save_planes=[0.1])
_, run_request = soliton_problem(
- narrow_grid, 1, save_planes=[0.1], schedule=schedule, integrator=PEC4
+ narrow_grid, 1, save_planes=[0.1], schedule=schedule, integrator=PEC45
)
assert run_request.stepper.schedule is schedule
-@pytest.mark.parametrize("dense", ["adams", "hermite"])
def test_dense_observation_is_the_exact_linear_flow_without_a_source(
- narrow_grid, linear_problem, dense
+ narrow_grid, linear_problem
):
planes = [0.1, 0.2, 0.3, 0.5]
- method = ComparisonMultistep(
- corrector="pec", order=3, corrector_order=4, interpolant=dense
- )
+ method = PEC34
problem, run_request = linear_problem(
narrow_grid, save_planes=planes, integrator=method, interpolate_planes=True
)
@@ -213,7 +191,7 @@ def test_dense_observation_is_the_exact_linear_flow_without_a_source(
field = backend.observe().require_field()[0, 0]
exact = np.exp(delta_phi(problem.linear, 0.0, plane))[0, 0, 0] * start
# With no source the interaction variable is constant across the step, so
- # every interpolant reproduces it: round-off in the propagators only.
+ # the interpolant reproduces it: round-off in the propagators only.
np.testing.assert_allclose(
field, exact, rtol=0, atol=1e-12 * np.abs(start).max()
)
@@ -224,8 +202,7 @@ def test_dense_observation_is_the_exact_linear_flow_without_a_source(
@pytest.mark.medium
-@pytest.mark.parametrize("dense", ["adams", "hermite"])
-def test_dense_observation_matches_landing(narrow_grid, soliton_problem, dense):
+def test_dense_observation_matches_landing(narrow_grid, soliton_problem):
planes = list(np.linspace(0.05, 1.0, 20) * SOLITON_PERIOD)
def fields(method, *, interpolate=False):
@@ -247,17 +224,14 @@ def fields(method, *, interpolate=False):
backend.close()
return np.array(observed), ledger
- landed, _ = fields(PECE4)
- interpolated, ledger = fields(
- ComparisonMultistep(corrector="pece", order=4, interpolant=dense),
- interpolate=True,
- )
+ landed, _ = fields(PEC45)
+ interpolated, ledger = fields(PEC45, interpolate=True)
assert ledger is not None and ledger.dense_observations > 0
scale = np.linalg.norm(landed, axis=1)
error = np.linalg.norm(interpolated - landed, axis=1) / scale
# The two runs take different steps, so they differ by their own global errors at
- # rtol 1e-7: 2.0e-7 measured, identical for both interpolants, which therefore add
- # less than that. A factor of ten above the measurement.
+ # rtol 1e-7: 2.0e-7 measured for PECE4 when this bound was set, 6.1e-8 for PEC4/5;
+ # the interpolant adds less than that. A factor of ten above the first measurement.
assert error.max() < 2e-6
@@ -289,7 +263,7 @@ def solve(integrator, rtol):
def test_call_budget_stops_before_overspending(narrow_grid, soliton_problem):
budget = 25
- method = ComparisonMultistep(corrector="pece", order=3, max_calls=budget)
+ method = replace(PEC34, max_calls=budget)
problem, run_request = soliton_problem(
narrow_grid,
1,
@@ -304,8 +278,8 @@ def test_call_budget_stops_before_overspending(narrow_grid, soliton_problem):
assert backend._adams is not None
ledger = backend._adams.ledger
assert ledger.total == caught.value.calls <= budget
- # PECE needs two calls a trial: the stop leaves at most one unspent.
- assert budget - ledger.total < 2
+ # PEC needs one call a trial: the stop leaves none unspent.
+ assert ledger.total == budget
assert 0.0 < caught.value.z < SOLITON_PERIOD
assert backend._state is not None
np.testing.assert_array_equal(caught.value.field, backend._state[0])
@@ -315,7 +289,7 @@ def test_call_budget_stops_before_overspending(narrow_grid, soliton_problem):
def test_resume_restarts_the_history_and_charges_its_startup(
narrow_grid, soliton_problem
):
- method = ComparisonMultistep(corrector="pec", order=3, corrector_order=4)
+ method = PEC34
problem, run_request = soliton_problem(
narrow_grid,
1,
@@ -347,7 +321,7 @@ def test_resume_restarts_the_history_and_charges_its_startup(
def test_capillary_constructor_takes_the_collar_scheme_and_integrator():
- method = ComparisonMultistep(corrector="pec", order=3, corrector_order=4)
+ method = PEC34
simulation = Simulation.from_capillary(
150e-6,
0.1,
@@ -486,40 +460,3 @@ def test_repeated_rejections_restart_the_history(narrow_grid, soliton_problem):
assert march._history == []
assert march._rejections == 0
backend.close()
-
-
-def test_variable_order_pece_one_order_higher_takes_the_general_path(
- narrow_grid, soliton_problem
-):
- # The selected family (one-order-higher PEC) takes the fused path; PECE one order
- # higher with variable order takes the general one, scoring the neighbouring
- # orders' defects (SEP 13 section 4.4) and still converging on the soliton.
- planes = [SOLITON_PERIOD / 4]
- method = ComparisonMultistep(
- corrector="pece", order=4, corrector_order=5, variable_order=True
- )
-
- def final(integrator, rtol):
- problem, run_request = soliton_problem(
- narrow_grid,
- 2,
- save_planes=planes,
- controller=PIController(rtol=rtol, atol=1e-18),
- integrator=integrator,
- )
- backend = NumpyRefBackend(problem, run_request, grid=narrow_grid)
- backend.set_initial(problem.initial.field0, None)
- backend.propagate_to(planes[-1])
- field = np.array(backend.observe().require_field())
- march = backend._adams
- backend.close()
- return field, march
-
- reference, _ = final(None, 1e-11)
- field, march = final(method, 1e-6)
- assert march is not None
- assert march._fused is False
- assert len(set(march.ledger.orders)) > 2
- scale = np.max(np.abs(reference))
- # Measured 1.1e-7 of the peak over orders one to four.
- assert np.max(np.abs(field - reference)) < 1e-6 * scale
diff --git a/tests/backends/test_native_error_norm.py b/tests/backends/test_native_error_norm.py
index 887f70385..96f92ca9a 100644
--- a/tests/backends/test_native_error_norm.py
+++ b/tests/backends/test_native_error_norm.py
@@ -93,7 +93,7 @@ def _operands(backend, reference, problem, run_request, dz):
reference.set_initial(problem.initial.field0, None)
reference._trial_step(0.0, dz)
step = reference._stage_scalars(0.0, dz)
- propagator = reference._propagator(reference._exponents(0.0, dz)(1.0, step))
+ propagator = np.exp(reference._exponents(0.0, dz)(1.0, step))
return stages, propagator
diff --git a/tests/backends/test_native_exponent_and_estimate.py b/tests/backends/test_native_exponent_and_estimate.py
index e6a7f7722..d60bfb518 100644
--- a/tests/backends/test_native_exponent_and_estimate.py
+++ b/tests/backends/test_native_exponent_and_estimate.py
@@ -49,7 +49,6 @@ def test_a_separable_exponent_is_the_host_volume():
problem,
responses=dataclasses.replace(
problem.responses,
- response_order=problem.responses.response_order[-1:],
kerr_coefficients=None,
# No collar at all: a split one would act after the exponent's own step.
temporal_absorber=None,
diff --git a/tests/backends/test_native_held_plasma.py b/tests/backends/test_native_held_plasma.py
index 0e4a7a177..5c7b7a01c 100644
--- a/tests/backends/test_native_held_plasma.py
+++ b/tests/backends/test_native_held_plasma.py
@@ -9,12 +9,16 @@
import numpy as np
import pytest
-from backends.test_band_plasma_parity import CASES, bound, case, options_for
+from backends.host_cases_envelope_plasma import build_envelope_mixture, geometry_case
+from backends.test_band_plasma_parity import CASES, bound, case, march, options_for
from backends.test_radial_nonlinear import derivative
from gated_source import _gated_source
from sessions.runs import native_trial
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.exceptions import BackendError
+from solprop.profiles import Constant
+from solprop.stepper import FixedSchedule
+from solprop.translate.linop import profile_table
from solprop.translate.plasma_integration import design_running_integral
native = pytest.importorskip("solprop_native")
@@ -61,11 +65,125 @@ def test_current_assembly_and_ledger_on_each_route(name, rule):
backend.close()
-def test_gated_comparison_is_refused_before_native_allocation():
- grid, problem, run_request = case("mode_averaged")
+def _gated_case(name, rule, steps=None):
+ """Return a case with field plasma, its rule, ten per cent pre-ionisation and,
+ for a march, a fixed schedule of `steps` over the parity ladder's window."""
+ grid, problem, run_request = case(name)
+ # Pre-ionised, so the free current, and with it the gated integral, is live
+ # from the first sample rather than a rounding-level population.
+ initial = profile_table(Constant(0.1), *problem.linear.z_span)
+ problem = dataclasses.replace(
+ problem,
+ responses=dataclasses.replace(
+ problem.responses,
+ plasma_integral=design_running_integral(grid, rule),
+ plasma_channels=tuple(
+ dataclasses.replace(channel, preionisation=initial)
+ for channel in problem.responses.plasma_channels
+ ),
+ ),
+ )
+ if steps is not None:
+ original_end = float(run_request.stepper.save_planes[-1])
+ # The planar fixture's own window is 80 Adams steps long; the parity ladder's
+ # 1e-7 m window keeps its cold-plasma phase below 0.028 rad.
+ end = 1e-7 if name == "planar" else original_end
+ run_request = run_request.with_stepper(
+ dataclasses.replace(
+ run_request.stepper,
+ save_planes=np.asarray(run_request.stepper.save_planes)
+ * (end / original_end),
+ schedule=FixedSchedule.uniform(0.0, end, end / steps),
+ )
+ )
+ return grid, problem, run_request
+
+
+@pytest.mark.parametrize("name", CASES)
+@pytest.mark.parametrize("rule", ["band", "trapezoid"])
+def test_the_gated_benchmark_source_matches_the_reference(name, rule):
+ """One nonlinear evaluation of the private gated form, against ``numpy_ref``.
+
+ The gated form replaces the held current's two transforms and products by one
+ filter and one trapezoid scan of the same current rows and a product with the
+ window: at most ``gamma(2 width n_to + 2)`` of the integral's magnitudes, inside
+ the ladder's per-call ``gamma(12 width n_to)``, so the held form's budget, fixed
+ before the evaluation, holds it (``docs/dev/testing.md`` section 3).
+ """
+ grid, problem, run_request = _gated_case(name, rule)
+ allowance = bound(problem, grid, "native", 0)
+ assert allowance > 0
+ options = options_for(name)
+ with _gated_source():
+ expected = derivative(NumpyRefBackend, problem, run_request, grid, options)
+ actual = derivative(native.NativeBackend, problem, run_request, grid, options)
+ held = derivative(native.NativeBackend, problem, run_request, grid, options)
+ scale = float(np.max(abs(expected)))
+ assert scale > 0
+ np.testing.assert_allclose(actual, expected, rtol=0, atol=allowance * scale)
+ assert np.count_nonzero(actual[..., ~grid.active_mask]) == 0
+ # The comparison discriminates: the production held current differs from the
+ # gated form by 5.4e-7 of the source or more on these cases (measured), a
+ # thousand times the allowance and more, so a native run that ignored the flag
+ # would fail above.
+ assert float(np.max(abs(held - actual))) > 1e3 * allowance * scale
+
+
+@pytest.mark.parametrize("name", CASES)
+@pytest.mark.parametrize("rule", ["band", "trapezoid"])
+@pytest.mark.parametrize("steps", [1, 8])
+def test_the_gated_benchmark_march_matches_the_reference(name, rule, steps):
+ """One and eight fixed steps of the gated form, against ``numpy_ref``.
+
+ The bound is the band-rule ladder's at seven source evaluations a step, fixed
+ before the march, as for the one evaluation above.
+ """
+ grid, problem, run_request = _gated_case(name, rule, steps)
+ limit = bound(problem, grid, "native", steps)
+ assert limit > 0
+ options = options_for(name)
+ with _gated_source():
+ expected, _ = march(
+ NumpyRefBackend, problem, run_request, grid, steps=steps, options=options
+ )
+ actual, _ = march(
+ native.NativeBackend,
+ problem,
+ run_request,
+ grid,
+ steps=steps,
+ options=options,
+ )
+ scale = float(np.max(np.abs(expected)))
+ assert scale > 0
+ assert float(np.max(np.abs(actual - expected))) <= limit * scale
+
+
+def test_the_gated_benchmark_names_itself_and_holds_no_held_streams():
+ """The record names the private form, and no held-current stream is allocated."""
+ grid, problem, run_request = _gated_case("mode_averaged", "trapezoid")
+ with _gated_source():
+ backend = native.NativeBackend(problem, run_request, grid=grid)
+ try:
+ record = backend.metadata()
+ assert record["_benchmark_plasma_source"] == "gated_nonresumable"
+ assert record["plasma_source"] == {
+ "form": "gated_benchmark",
+ "resumable": False,
+ }
+ ledger = backend.memory_ledger()
+ assert ledger["held_current_gj"] == ledger["held_current_edge"] == 0
+ assert ledger["gated_current_prefix"] > 0
+ finally:
+ backend.close()
+
+
+def test_the_gated_benchmark_is_refused_by_name_on_an_envelope_plasma():
+ """The gated form is a field column's: native names the refusal before it builds."""
+ problem, run_request, grid = geometry_case(build_envelope_mixture(), "modal", 1)
with (
_gated_source(),
- pytest.raises(BackendError, match="gated plasma benchmark requires numpy_ref"),
+ pytest.raises(BackendError, match=r"gated plasma benchmark.*envelope plasma"),
):
native.NativeBackend(problem, run_request, grid=grid)
diff --git a/tests/backends/test_native_held_projection_budget.py b/tests/backends/test_native_held_projection_budget.py
index 38da97e3c..66e10db2e 100644
--- a/tests/backends/test_native_held_projection_budget.py
+++ b/tests/backends/test_native_held_projection_budget.py
@@ -35,7 +35,6 @@
from solprop.backends.session import ExecutionOptions
from solprop.translate.ionisation import build_rate_table
from solprop.translate.plasma_integration import design_running_integral
-from solprop.translate.responses import ResponseStage
native = pytest.importorskip("solprop_native")
@@ -71,11 +70,6 @@ def current_only(problem, grid, rule):
for channel in responses.plasma_channels
),
kerr_coefficients=None,
- response_order=tuple(
- stage
- for stage in responses.response_order
- if stage is not ResponseStage.KERR
- ),
),
)
diff --git a/tests/backends/test_native_low_storage.py b/tests/backends/test_native_low_storage.py
index b13d7ed52..fc08ae64e 100644
--- a/tests/backends/test_native_low_storage.py
+++ b/tests/backends/test_native_low_storage.py
@@ -492,7 +492,6 @@ def test_a_linear_cartesian_beam_agrees():
# Without the Kerr coefficients too, which a compiled backend may read.
responses=dataclasses.replace(
problem.responses,
- response_order=problem.responses.response_order[-1:],
kerr_coefficients=None,
),
)
@@ -535,7 +534,6 @@ def test_a_planar_beam_with_neither_term_is_the_flow():
problem,
responses=dataclasses.replace(
problem.responses,
- response_order=problem.responses.response_order[-1:],
kerr_coefficients=None,
temporal_absorber=None,
temporal_absorber_fine=None,
diff --git a/tests/backends/test_native_march_admission.py b/tests/backends/test_native_march_admission.py
index f53f367c2..ddd2f33bb 100644
--- a/tests/backends/test_native_march_admission.py
+++ b/tests/backends/test_native_march_admission.py
@@ -7,9 +7,7 @@
The native march steps ``Multistep()`` in either formulation, adaptive or replayed, on
every route the backend admits: since solprop SEP 47's phase 5 there is no Python march
to fall back on, and what the native march does not step is refused by name before
-anything is allocated. The comparison family,
-``ComparisonMultistep``, is benchmark-only and the reference's alone (solprop SEP 47
-section 6.3): the backend refuses every variant by name before it allocates anything.
+anything is allocated.
"""
import copy
@@ -23,7 +21,7 @@
from solprop.backends import available
from solprop.driver import Simulation
from solprop.exceptions import BackendError, HostMarchWarning
-from solprop.stepper.multistep import ComparisonMultistep, Multistep
+from solprop.stepper.multistep import Multistep
from solprop.stepper.schedule import FixedSchedule
pytestmark = pytest.mark.skipif(
@@ -101,56 +99,6 @@ def test_a_replayed_schedule_is_native():
backend.close()
-@pytest.mark.parametrize(
- "integrator",
- [
- ComparisonMultistep(corrector="pece", order=4),
- ComparisonMultistep(corrector="pece", order=4, corrector_order=5),
- ComparisonMultistep(corrector="none", order=3),
- ComparisonMultistep(corrector="pec", order=4),
- ComparisonMultistep(corrector="pec", order=4, corrector_order=4),
- # The one-order-higher PEC variants, the selected recurrence under the
- # comparison family's name, are benchmark-only too.
- ComparisonMultistep(corrector="pec", order=4, corrector_order=5),
- ComparisonMultistep(
- corrector="pec", order=4, corrector_order=5, interpolant="hermite"
- ),
- ComparisonMultistep(
- corrector="pec", order=4, corrector_order=5, variable_order=True
- ),
- ],
- ids=[
- "pece4",
- "pece4/5",
- "ab3",
- "pec4 matched",
- "pec4/4",
- "pec4/5",
- "pec4/5 hermite",
- "pec4/5 variable",
- ],
-)
-@pytest.mark.parametrize("device", [False, True], ids=["cpu", "device"])
-def test_every_comparison_variant_is_refused_before_allocation(
- integrator, device, monkeypatch
-):
- # solprop SEP 47 section 6.3: benchmark-only and reference-only, refused by name
- # at admission on CPU and device builds alike (the device patched in: the
- # decision is the method's alone). The native state, the first allocation, is
- # never built.
- monkeypatch.setattr(backend_module, "_on_device", lambda: device)
- built_states = []
- monkeypatch.setattr(
- backend_module._core, "State", lambda *args: built_states.append(args)
- )
- with pytest.raises(BackendError, match="reference-only") as refused:
- built(on_route("gnlse", integrator))
- message = str(refused.value)
- assert f"comparison variant {integrator.name}: " in message
- assert "backend='numpy_ref'" in message
- assert built_states == []
-
-
@pytest.mark.parametrize("leaf", ["operator", "prefactor"])
def test_a_representation_the_stage_tables_do_not_read_is_refused_at_admission(
leaf, monkeypatch
diff --git a/tests/backends/test_native_planar_adams.py b/tests/backends/test_native_planar_adams.py
index 0aa90a7ff..73d6ffbde 100644
--- a/tests/backends/test_native_planar_adams.py
+++ b/tests/backends/test_native_planar_adams.py
@@ -38,7 +38,7 @@
import numpy as np
import pytest
-from backends.planar_cases import CASES, planar_case
+from backends.planar_cases import CASES, linear_responses, planar_case
from bounds import gamma, step_evaluations
from sessions.executors import committed_steps
from sessions.runs import run as session_run
@@ -184,17 +184,15 @@ def test_the_responses_are_exercised(cases, name):
grid, space, problem, run_request = cases(name)
linear = dataclasses.replace(
problem,
- responses=dataclasses.replace(
- problem.responses, response_order=problem.responses.response_order[:1]
- ),
+ responses=linear_responses(problem.responses),
)
schedule = run_request.stepper.schedule
assert schedule is not None
count = min(schedule.steps.size, RUN_STEPS)
length = grid.length * count / schedule.steps.size
bound = ladder_bound(name, grid, space, problem, run_request, count, length)
- # On the oracle, which reads ``response_order``: a run within the bound of it is
- # then the nonlinear run, and not a linear one.
+ # On the oracle: a run within the bound of it is then the nonlinear run, and not a
+ # linear one.
stepped = with_steps(run_request, count, length)
nonlinear, _ = run(NumpyRefBackend, problem, stepped, grid, length)
plain, _ = run(NumpyRefBackend, linear, stepped, grid, length)
diff --git a/tests/backends/test_native_rk_admission.py b/tests/backends/test_native_rk_admission.py
index ad69601a3..26a7c8a9c 100644
--- a/tests/backends/test_native_rk_admission.py
+++ b/tests/backends/test_native_rk_admission.py
@@ -7,15 +7,12 @@
The native march steps an adaptive run on staged rows with ``PIController``'s norm, and
the run's metadata records which loop stepped it; no switch selects solprop's loop any
more (solprop SEP 47 section 6.3 retired the test interface with the transitional
-bridge). A controller that overrides one of ``PIController``'s decisions is a
-reference-only experiment (solprop SEP 47 section 6.3): the backend refuses it by name,
-for every method and on CPU and device builds alike, before it allocates anything.
+bridge).
"""
import dataclasses
import warnings
-import numpy as np
import pytest
from capability_routes import ROUTES
@@ -23,12 +20,8 @@
from sessions.runs import run
from solprop.backends import available
from solprop.backends.events import Outcome
-from solprop.exceptions import BackendError, HostMarchWarning
-from solprop.stepper.controller import PIController
-from solprop.stepper.low_storage import LowStorageRungeKutta
-from solprop.stepper.multistep import Multistep
+from solprop.exceptions import HostMarchWarning
from solprop.stepper.runge_kutta import RungeKutta
-from solprop.stepper.schedule import FixedSchedule
pytestmark = pytest.mark.skipif(
"native" not in available(), reason="native is not installed"
@@ -88,74 +81,3 @@ def test_the_default_loop_is_native_without_a_warning(route, dense):
assert backend.metadata()["rk_march"] == "native"
finally:
backend.close()
-
-
-DECISIONS = (
- "scale",
- "accepts",
- "propose",
- "rejection_factor",
- "start_factor",
- "equivalent_norm",
-)
-"""Every ``PIController`` method the native controller implements itself."""
-
-
-def _overriding(name: str) -> type[PIController]:
- """Return a ``PIController`` subclass that overrides `name` and nothing else."""
-
- def inherited(self, *args, **kwargs):
- return getattr(super(type(self), self), name)(*args, **kwargs)
-
- return type(f"Own_{name}", (PIController,), {name: inherited})
-
-
-@pytest.mark.parametrize("name", DECISIONS)
-@pytest.mark.parametrize("method", ["runge-kutta", "adams", "low-storage"])
-@pytest.mark.parametrize("device", [False, True], ids=["cpu", "device"])
-def test_a_controller_that_overrides_a_decision_is_refused_before_allocation(
- name, method, device, monkeypatch
-):
- # solprop SEP 47 section 6.3: a controller that decides in Python is reference-only
- # and refused by name at admission, whatever the method, on CPU and device builds
- # alike (the device patched in: the decision is the controller's alone). The
- # override defers to PIController's own method, so only its presence is refused.
- # The native state, the first allocation, is never built.
- simulation = ROUTES["gnlse"](None)
- settings = {
- "controller": _overriding(name)(rtol=1e-6, atol=0.0),
- "integrator": {
- "runge-kutta": RungeKutta(),
- "adams": Multistep(),
- "low-storage": LowStorageRungeKutta(),
- }[method],
- }
- if method == "low-storage":
- stepper = ROUTES["gnlse"](None)._run_request().stepper
- settings["schedule"] = FixedSchedule.uniform(
- 0.0,
- float(stepper.z_end),
- # Below the temporal collar's stable step, which a replay must respect.
- float(stepper.z_end) / 400,
- save_planes=np.asarray(stepper.save_planes).tolist(),
- )
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- simulation.settings = dataclasses.replace(simulation.settings, **settings)
- problem = simulation.translate()
- run_request = simulation._run_request()
- monkeypatch.setattr(backend_module, "_on_device", lambda: device)
- built_states = []
- monkeypatch.setattr(
- backend_module._core, "State", lambda *args: built_states.append(args)
- )
- with pytest.raises(BackendError, match="reference-only") as refused:
- native.NativeBackend(
- problem, run_request, grid=simulation.grid, modes=simulation.modes
- )
- message = str(refused.value)
- assert f"the controller Own_{name}, which overrides PIController's {name}:" in (
- message
- )
- assert "backend='numpy_ref'" in message
- assert built_states == []
diff --git a/tests/backends/test_node_chunk.py b/tests/backends/test_node_chunk.py
index 3504807c3..b5ac30c1f 100644
--- a/tests/backends/test_node_chunk.py
+++ b/tests/backends/test_node_chunk.py
@@ -35,6 +35,7 @@
NodeChunkPlan,
_even_chunk,
default_node_budget,
+ general_planned_chunk,
plan_node_chunk,
)
from solprop.backends.numpy_ref import NumpyRefBackend
@@ -539,9 +540,6 @@ def test_a_general_guide_takes_the_plan_s_budget(native_backend):
strand,
)
from solprop.backends.numpy_ref.backend import _general_maps # noqa: PLC0415
- from solprop.backends.numpy_ref.general_modal import ( # noqa: PLC0415
- general_planned_chunk,
- )
problem, run_request, _ = general_problem(strand(ARGON_FILL), "symmetric", "real")
automatic = plan_node_chunk(problem)
diff --git a/tests/backends/test_numpy_ref_backend.py b/tests/backends/test_numpy_ref_backend.py
index e775e6c72..f134c911a 100644
--- a/tests/backends/test_numpy_ref_backend.py
+++ b/tests/backends/test_numpy_ref_backend.py
@@ -208,33 +208,6 @@ def test_the_history_covers_only_what_happened_since_the_last_observation(
# --------------------------------------------------------------------- the refusals
-def declare(translation, substitute, **overrides):
- """Return the problem with one or more flags declared, as a compiler would."""
- problem, run_request = translation
- return Translation(
- substitute(
- problem,
- flags=dataclasses.replace(problem.flags, **overrides),
- validate=False,
- ),
- run_request,
- )
-
-
-def test_a_declared_term_this_backend_cannot_execute_is_refused_by_name(
- backend_class, soliton_problem, narrow_grid, substitute
-):
- """A term silently dropped would run, converge and be wrong. The Kerr, Raman and
- plasma terms execute; the spectral-domain responses do not."""
- problem, run_request = declare(
- soliton_problem(narrow_grid, 1, save_planes=[LENGTH]),
- substitute,
- has_spectral_responses=True,
- )
- with pytest.raises(BackendError, match="spectral responses"):
- backend_class(problem, run_request)
-
-
def test_a_declared_raman_kernel_is_executed_rather_than_refused(
backend_class, backend_name, linear_problem, narrow_grid
):
diff --git a/tests/backends/test_numpy_ref_multimode.py b/tests/backends/test_numpy_ref_multimode.py
index 3c73dfccf..15d6914b4 100644
--- a/tests/backends/test_numpy_ref_multimode.py
+++ b/tests/backends/test_numpy_ref_multimode.py
@@ -388,7 +388,7 @@ def test_the_alarm_fires_on_a_rule_too_coarse_for_the_field(
modes = modes_of(capillary, 3)
rule = transverse_quadrature(modes, n_r=8, radial_rule="gauss_kronrod")
problem, run_request = build(grid, modes, pulse(grid, 3), quadrature=rule)
- with caplog.at_level(logging.WARNING, logger="solprop.backends.numpy_ref.backend"):
+ with caplog.at_level(logging.WARNING, logger="solprop.translate.modal"):
plane, _ = propagate(backend_class, problem, run_request, grid, stats=ALARM)
# Measured 2.4e-1: nine radial nodes cannot integrate the quartic of three Bessel
# profiles, and no step size can fix that.
@@ -406,7 +406,7 @@ def test_the_alarm_is_silent_on_a_rule_that_resolves_the_field(
modes, n_r=CONVERGED_N_R_SET, radial_rule="gauss_kronrod"
)
problem, run_request = build(grid, modes, pulse(grid, 3), quadrature=rule)
- with caplog.at_level(logging.WARNING, logger="solprop.backends.numpy_ref.backend"):
+ with caplog.at_level(logging.WARNING, logger="solprop.translate.modal"):
plane, _ = propagate(backend_class, problem, run_request, grid, stats=ALARM)
# Measured 1.8e-8, two orders below the alarm and falling by three orders for every
# eight nodes added.
@@ -501,7 +501,7 @@ def test_a_rule_with_no_embedded_pair_reports_nothing_rather_than_zero(
modes = modes_of(capillary, 2)
rule = transverse_quadrature(modes, n_r=8, radial_rule="gauss")
problem, run_request = build(grid, modes, pulse(grid, 2), quadrature=rule)
- with caplog.at_level(logging.WARNING, logger="solprop.backends.numpy_ref.backend"):
+ with caplog.at_level(logging.WARNING, logger="solprop.translate.modal"):
plane, _ = propagate(backend_class, problem, run_request, grid, stats=ALARM)
assert math.isnan(quadrature_alarm(plane))
assert not caplog.records
diff --git a/tests/backends/test_numpy_ref_plasma.py b/tests/backends/test_numpy_ref_plasma.py
index a4a766bc0..bf26d94af 100644
--- a/tests/backends/test_numpy_ref_plasma.py
+++ b/tests/backends/test_numpy_ref_plasma.py
@@ -60,7 +60,6 @@
from solprop.translate.problem import assemble
from solprop.translate.responses import (
PlasmaCoefficients,
- ResponseStage,
prefactor_at,
)
@@ -583,54 +582,6 @@ def test_a_plasma_statistic_without_a_plasma_is_refused(backend_class, field_gri
# ------------------------------------------------- guards on an inconsistent order
-@pytest.mark.parametrize(
- ("field", "named"),
- [
- ("kerr_coefficients", "Kerr stage"),
- ("plasma_channels", "plasma stage"),
- ("ionisation_table", "ionisation stage"),
- ],
-)
-def test_a_stage_without_its_coefficients_is_refused(ionising_capillary, field, named):
- """`response_order` and the coefficients come from one compiler and can only
- disagree in a hand-built or stale problem; the pass says which is missing rather
- than reading a ``None``."""
- problem, _, _, _ = ionising_capillary()
- responses = dataclasses.replace(problem.responses)
- object.__setattr__(responses, field, None if field == "kerr_coefficients" else ())
- with pytest.raises(BackendError, match=named):
- passes.nonlinear_polarisation(
- np.zeros((1, 1, problem.grids.n_to)),
- responses,
- problem.grids.t_window_fine,
- kind="field",
- density=1.0,
- preionisation=0.0,
- dt=problem.grids.dt_fine,
- )
-
-
-def test_a_plasma_stage_out_of_sequence_is_refused(ionising_capillary):
- """The current cannot run before the scan that feeds it."""
- problem, _, _, _ = ionising_capillary()
- responses = dataclasses.replace(problem.responses)
- object.__setattr__(
- responses,
- "response_order",
- (ResponseStage.PLASMA_CURRENT, ResponseStage.ACCUMULATE),
- )
- with pytest.raises(BackendError, match="out of sequence"):
- passes.nonlinear_polarisation(
- np.zeros((1, 1, problem.grids.n_to)),
- responses,
- problem.grids.t_window_fine,
- kind="field",
- density=1.0,
- preionisation=0.0,
- dt=problem.grids.dt_fine,
- )
-
-
def test_the_plasma_statistics_are_the_same_between_planes_as_at_one(
field_grid, ionising_capillary, backend_class
):
diff --git a/tests/backends/test_numpy_ref_raman.py b/tests/backends/test_numpy_ref_raman.py
index 1decb34ac..b3c91458e 100644
--- a/tests/backends/test_numpy_ref_raman.py
+++ b/tests/backends/test_numpy_ref_raman.py
@@ -60,7 +60,7 @@
kernel_spectrum,
kernel_taper,
)
-from solprop.translate.responses import prefactor_at
+from solprop.translate.responses import ResponseStage, prefactor_at
# The pass is the oracle's own arithmetic -- the same padded real transform pair, the
# same product, the same final multiplication -- but need not keep its order (SEP 25):
@@ -938,8 +938,10 @@ def test_the_drive_form_comes_from_the_leaf_and_not_from_the_kerr_flag(
assert leaf.thg == (not kerr.analytic_signal)
-def test_the_raman_stage_refuses_a_problem_with_no_leaf(raman_capillary):
+def test_the_raman_stage_runs_exactly_when_the_leaf_is_present(raman_capillary):
+ # response_order is derived from the compiled leaves, so the stage that reads
+ # raman_terms[0] cannot be reached without one (the old refusal is structural now).
problem, _, _, _ = raman_capillary()
+ assert ResponseStage.RAMAN_DRIVE in problem.responses.response_order
stripped = dataclasses.replace(problem.responses, raman_terms=())
- with pytest.raises(BackendError, match=r"no responses\.raman_coefficients"):
- passes._raman_thg(stripped)
+ assert ResponseStage.RAMAN_DRIVE not in stripped.response_order
diff --git a/tests/backends/test_planar_native_invariants.py b/tests/backends/test_planar_native_invariants.py
index 1f81c1d0e..757800db2 100644
--- a/tests/backends/test_planar_native_invariants.py
+++ b/tests/backends/test_planar_native_invariants.py
@@ -15,7 +15,7 @@
import numpy as np
import pytest
-from backends.planar_cases import planar_case
+from backends.planar_cases import linear_responses, planar_case
from bounds import gamma, step_evaluations
from sessions.runs import run
from solprop.backends.numpy_ref import NumpyRefBackend
@@ -96,17 +96,12 @@ def relative(actual, expected):
def test_the_responses_are_exercised(name):
"""The parity runs are nonlinear: native departs from the reference's linear run.
- The retired test removed the responses from ``response_order`` and ran the same
- compiled backend. Native does not read that order for its Kerr term (its payload
- takes ``kerr_coefficients``, and its other responses their own leaves), so the
- linear run here is the reference's, which does.
+ The linear run is the reference's, with every response removed.
"""
grid, space, problem, run_request = planar_case(name)
linear = dataclasses.replace(
problem,
- responses=dataclasses.replace(
- problem.responses, response_order=problem.responses.response_order[:1]
- ),
+ responses=linear_responses(problem.responses),
)
steps = run_request.stepper.schedule.steps.size # pyright: ignore[reportOptionalMemberAccess]
bound = ladder_bound(name, grid, space, problem, run_request, steps, grid.length)
@@ -127,8 +122,6 @@ def test_a_linear_run_with_the_collar_split_off_is_the_reference_s():
problem,
responses=dataclasses.replace(
problem.responses,
- response_order=problem.responses.response_order[-1:],
- # Native's payload reads the coefficients, not the order.
kerr_coefficients=None,
absorber_scheme="split_step",
),
diff --git a/tests/backends/test_reference_independence.py b/tests/backends/test_reference_independence.py
index e3701506d..2d62ab78f 100644
--- a/tests/backends/test_reference_independence.py
+++ b/tests/backends/test_reference_independence.py
@@ -32,22 +32,19 @@
import solprop.backends.host
import solprop.backends.numpy_ref
-from solprop.backends import low_storage, multistep
+from solprop.backends import low_storage
from solprop.backends.host.adams import HostAdamsMarch
from solprop.backends.host.driver import HostDriver
from solprop.backends.host.group import HostGroup
from solprop.backends.host.group_session import HostGroupSession
from solprop.backends.host.low_storage import HostLowStorageMarch
from solprop.backends.host.session import HostSession
-from solprop.backends.multistep import HostHistory
-from solprop.backends.numpy_ref import NumpyRefBackend
+from solprop.backends.numpy_ref import NumpyRefBackend, multistep
from solprop.backends.numpy_ref.batch import NumpyBatchBackend
-from solprop.backends.numpy_ref.single import NumpySingleBackend
from solprop.backends.session import Start
from solprop.backends.sessions import start_group, start_session
from solprop.exceptions import BackendError
from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep
-from solprop.stepper.multistep import ComparisonMultistep
PACKAGES = (solprop.backends.numpy_ref, solprop.backends.host)
"""The reference backend and its host machinery."""
@@ -62,7 +59,7 @@
reference."""
LEGACY_NAMES = {
- "solprop.backends.multistep": {"AdamsMarch"},
+ "solprop.backends.numpy_ref.multistep": {"AdamsMarch"},
"solprop.backends.low_storage": {"LowStorageMarch"},
}
"""The bridge's classes in modules whose other contents are shared numerical code."""
@@ -199,7 +196,7 @@ def test_importing_the_reference_loads_no_legacy_driver():
code = (
"import sys\n"
"import solprop.backends.numpy_ref, solprop.backends.numpy_ref.batch\n"
- "import solprop.backends.numpy_ref.single, solprop.backends.host.session\n"
+ "import solprop.backends.host.session\n"
"import solprop.backends.host.adams, solprop.backends.host.group\n"
"import solprop.backends.host.group_session\n"
"import solprop.backends.sessions\n"
@@ -227,9 +224,7 @@ def test_a_reference_module_holds_no_legacy_control(name):
assert not legacy, (name, key)
-@pytest.mark.parametrize(
- "backend", [NumpyRefBackend, NumpySingleBackend, NumpyBatchBackend]
-)
+@pytest.mark.parametrize("backend", [NumpyRefBackend, NumpyBatchBackend])
def test_a_reference_backend_inherits_only_the_host_driver(backend):
assert issubclass(backend, HostDriver)
assert not {one.__name__ for one in backend.__mro__} & LEGACY_CLASS_NAMES
@@ -306,11 +301,12 @@ def test_the_run_layer_serves_a_reference_group_with_the_host_group_session(
def test_an_injected_adams_engine_is_gated_and_checked_as_the_bridge_s(
soliton_problem, narrow_grid
):
- # The bridge's march takes a resident engine on the fused and exponential paths
- # only; the reference's takes an injected one there alone, and names what it lacks.
+ # The reference's march takes an injected engine and names what it lacks.
problem, run_request = soliton_problem(narrow_grid, 1, save_planes=[1e-3])
with closing(NumpyRefBackend(problem, run_request, grid=narrow_grid)) as backend:
with pytest.raises(BackendError, match="exponential path lacks"):
HostAdamsMarch(backend, Multistep(), engine=object())
- general = HostAdamsMarch(backend, ComparisonMultistep(order=3), engine=object())
- assert isinstance(general._engine, HostHistory)
+ with pytest.raises(BackendError, match="fused path lacks"):
+ HostAdamsMarch(
+ backend, Multistep(formulation="interaction"), engine=object()
+ )
diff --git a/tests/backends/test_registry.py b/tests/backends/test_registry.py
index b13030b81..2fa6c3cc7 100644
--- a/tests/backends/test_registry.py
+++ b/tests/backends/test_registry.py
@@ -2,15 +2,13 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
-"""Selecting a backend by name, and the three errors that can come back.
-
-``docs/dev/backends.md`` section 1: an unknown name is a :class:`ValueError` listing
-what is usable, and a known name whose dependencies are missing is an
-:class:`ImportError` naming the extra to install. The two are different questions --
-"what did I mean?" and "what do I install?" -- and one error type for both would answer
-neither. A name that was real until its backend was deleted is the third question --
-"where did it go?" -- and answers it with a
-:class:`~solprop.exceptions.BackendError` saying it has been removed and what to use.
+"""Selecting one of the two backends by name, and the errors that can come back.
+
+``docs/dev/backends.md`` section 1: a name that is neither backend, a retired one's
+included, is a :class:`~solprop.exceptions.BackendError` naming the two, and ``native``
+without its package is an :class:`ImportError` saying what to install. The two are
+different questions -- "what did I mean?" and "what do I install?" -- and one error
+type for both would answer neither.
"""
import ctypes
@@ -29,7 +27,7 @@
from solprop.backends import Backend, available, registry, resolve_name, select
from solprop.backends.numpy_ref import NumpyRefBackend
-from solprop.backends.registry import BACKENDS, REMOVED_BACKENDS, _construct
+from solprop.backends.registry import BACKEND_NAMES, _construct
from solprop.driver import SimulationSettings
from solprop.exceptions import BackendError
from solprop.stepper import PIController, RungeKutta
@@ -42,7 +40,7 @@
def test_the_reference_backend_is_always_available():
"""It needs nothing beyond the package's own dependencies, so it is always there."""
assert "numpy_ref" in available()
- assert set(available()) <= set(BACKENDS)
+ assert set(available()) <= set(BACKEND_NAMES)
def test_automatic_selection_probes_without_importing_or_caching(monkeypatch):
@@ -65,7 +63,8 @@ def find_spec(name, *args):
present = True
assert resolve_name() == "native"
assert resolve_name("numpy_ref") == "numpy_ref"
- assert resolve_name("unregistered") == "unregistered"
+ with pytest.raises(BackendError, match="'unregistered' is not a backend"):
+ resolve_name("unregistered")
present = False
with pytest.raises(BackendError, match="not installed"):
resolve_name()
@@ -186,58 +185,45 @@ def constructor(actual, request, **options):
}
-def test_an_unknown_name_lists_what_is_usable(linear_problem, narrow_grid):
- """The first tier: a name this package has never heard of."""
- problem, run_request = linear_problem(narrow_grid, save_planes=[LENGTH])
- with pytest.raises(ValueError, match="not a known backend; usable backends are"):
- select("numpy-ref", problem, run_request)
-
+def test_there_are_exactly_two_backends():
+ """ADR-077: the reference and native; numba_cpu and mlx are gone."""
+ assert BACKEND_NAMES == ("numpy_ref", "native")
-def test_the_table_holds_the_backends_that_exist():
- """The in-tree CUDA backend is gone, and mlx was retired in SEP 47."""
- assert sorted(BACKENDS) == ["native", "numpy_ref"]
+# A name that is neither backend -- a misspelling, or a retired backend's -- is refused
+# wherever a request is validated, names the two, and never resolves to another.
-# The tombstones: a removed backend's name is refused wherever a request is validated,
-# and never resolves to another backend.
-
-REMOVED = {
- "numba_cpu": "numba_cpu backend was removed in SEP 43",
- "mlx": "mlx backend was retired in SEP 47",
-}
-"""Each removed backend, and what its refusal says happened to it."""
+REMOVED = ("mlx", "numba_cpu", "numpy-ref")
+"""Two retired backends' names and a misspelling."""
def assert_names_the_replacements(refused, name):
- """The refusal says the backend is gone and names both backends to use instead."""
+ """The refusal says the name is not a backend and names the two backends."""
message = str(refused.value)
- assert REMOVED[name] in message
+ assert f"{name!r} is not a backend" in message
assert "backend='native'" in message
assert "backend='numpy_ref'" in message
-@pytest.mark.parametrize("name", sorted(REMOVED))
-def test_the_removed_backend_is_only_a_tombstone(name):
- assert name in REMOVED_BACKENDS
- assert name not in BACKENDS
+@pytest.mark.parametrize("name", REMOVED)
+def test_a_name_that_is_neither_backend_is_never_available(name):
+ assert name not in BACKEND_NAMES
assert name not in available()
- assert name not in available("single")
- assert not set(REMOVED_BACKENDS) & set(BACKENDS)
-@pytest.mark.parametrize("name", sorted(REMOVED))
+@pytest.mark.parametrize("name", REMOVED)
def test_resolving_the_removed_backend_names_the_replacements(name):
with pytest.raises(BackendError) as refused:
resolve_name(name)
assert_names_the_replacements(refused, name)
-@pytest.mark.parametrize("name", sorted(REMOVED))
+@pytest.mark.parametrize("name", REMOVED)
def test_selecting_the_removed_backend_names_the_replacements(
linear_problem, narrow_grid, monkeypatch, name
):
def forbidden(*args, **kwargs):
- raise AssertionError("no backend is constructed for a removed name")
+ raise AssertionError("no backend is constructed for an unknown name")
monkeypatch.setattr(registry, "_construct", forbidden)
problem, run_request = linear_problem(narrow_grid, save_planes=[LENGTH])
@@ -246,9 +232,8 @@ def forbidden(*args, **kwargs):
assert_names_the_replacements(refused, name)
-@pytest.mark.parametrize("precision", ["double", "single"])
-@pytest.mark.parametrize("name", sorted(REMOVED))
-def test_settings_naming_the_removed_backend_name_the_replacements(name, precision):
+@pytest.mark.parametrize("name", REMOVED)
+def test_settings_naming_the_removed_backend_name_the_replacements(name):
with pytest.raises(BackendError) as refused:
SimulationSettings(
controller=PIController(rtol=1e-5, atol=1e-14),
@@ -256,7 +241,6 @@ def test_settings_naming_the_removed_backend_name_the_replacements(name, precisi
dz_max=1e-3,
dz_init=1e-3,
backend=name,
- precision=precision,
integrator=RungeKutta(),
)
assert_names_the_replacements(refused, name)
@@ -280,17 +264,6 @@ def test_a_known_name_without_its_extra_names_the_extra(
select("native", problem, run_request)
-def test_construct_refuses_a_registry_entry_without_an_implementation(
- linear_problem, narrow_grid
-):
- """The private dispatcher stays exhaustive when the registry grows."""
- problem, run_request = linear_problem(narrow_grid, save_planes=[LENGTH])
- with pytest.raises(AssertionError, match="unhandled backend registry entry"):
- _construct(
- "unregistered", problem, run_request, grid=None, modes=None, profiles=None
- )
-
-
def test_optional_backend_constructor_forwards_the_context(
monkeypatch, linear_problem, narrow_grid
):
@@ -562,7 +535,7 @@ def test_a_required_device_is_refused_before_anything_is_constructed(
def test_a_required_device_leaves_a_missing_package_to_the_selection(monkeypatch):
monkeypatch.setenv(registry.REQUIRE_DEVICE_SETTING, "cuda")
- monkeypatch.setattr(registry, "_satisfied", lambda backend: False)
+ monkeypatch.setattr(registry, "_native_installed", lambda: False)
assert registry._check_required_device("native") is None
diff --git a/tests/backends/test_resident_adams.py b/tests/backends/test_resident_adams.py
index db4c8cadc..20bb7299a 100644
--- a/tests/backends/test_resident_adams.py
+++ b/tests/backends/test_resident_adams.py
@@ -10,9 +10,7 @@
native state. These tests pin
that every case takes the resident engine, since SEP 47's phase 5 the only one native
has (the host-engine comparisons went with the inherited driver's fallbacks), and hold
-its device-formed divisor to the reference's. The generic Hermite interpolant is a
-comparison method's (``ComparisonMultistep``), which native refuses (solprop SEP 47
-section 6.3); the reference steps it (``test_multistep_march.py``). The parity of
+its device-formed divisor to the reference's. The parity of
either against the reference backend is the shared suites'
(``test_exponential_routes.py``,
``test_compiled_multistep.py``).
diff --git a/tests/backends/test_single_precision.py b/tests/backends/test_single_precision.py
deleted file mode 100644
index fdc20e502..000000000
--- a/tests/backends/test_single_precision.py
+++ /dev/null
@@ -1,1308 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""The single-precision path of numpy_ref, SEP 33's oracle (section 7.2).
-
-Class R comparisons hold the binary32 path against the binary64 reference on the same
-fixed schedule, inside bounds evaluated at binary32's unit roundoff; class P
-comparisons are exact where the operation sequences differ only by powers of two;
-the targeted tests reproduce the review's counterexamples (a vanished Raman kernel, a
-lost population, a coherent propagator error) and the refusals.
-"""
-
-import dataclasses
-import math
-import warnings
-from collections.abc import Sequence
-from contextlib import ExitStack
-from typing import cast
-
-import numpy as np
-import pytest
-from scipy import signal
-
-from bounds import EPS_32, UNIT_ROUNDOFF_32, assert_within, higham_gamma, prefix_bound
-from legacy_geometry import LEGACY_GRID, bare_absorber
-from solprop import fourier
-from solprop.backends.host.driver import HostDriver
-from solprop.backends.host.group import HostGroup
-from solprop.backends.numpy_ref import NumpyRefBackend, passes
-from solprop.backends.numpy_ref.batch import NumpyBatchBackend
-from solprop.backends.numpy_ref.single import (
- NumpySingleBackend,
- analytic_single,
- plasma_current_single,
- raman_single,
- running_integral,
- stable_population,
-)
-from solprop.backends.protocol import Backend, Observation, stepping
-from solprop.backends.registry import (
- available,
- select,
- select_batch,
-)
-from solprop.boundaries import absorber
-from solprop.constants import EPS0, C
-from solprop.driver import Simulation, prop_gnlse
-from solprop.driver import simulation as simulation_module
-from solprop.driver.simulation import SimulationSettings
-from solprop.exceptions import (
- BackendError,
- BoundaryWarning,
- ConvergenceError,
- DriverError,
- ExponentBudgetWarning,
- TranslationError,
-)
-from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid
-from solprop.monitor import BoundaryCheck
-from solprop.nonlinear.ionisation import RunningIntegral
-from solprop.nonlinear.optmat_raman import gas_raman
-from solprop.nonlinear.optmat_rates import OptmatRate
-from solprop.nonlinear.responses import KerrResponse, PlasmaResponse
-from solprop.output.schema import RUN_SUMMARY_ANNOTATION, PlaneStore
-from solprop.physdata import gamma3
-from solprop.physdata.selection import MaterialSelection
-from solprop.pulses import GaussianPulse, SpatialPulse
-from solprop.spatial import SpatialMedium
-from solprop.stats import StatsSpec
-from solprop.stepper import LowStorageRungeKutta, PIController, RungeKutta
-from solprop.stepper.multistep import Multistep
-from solprop.stepper.schedule import FixedSchedule
-from solprop.translate.precision import (
- PrecisionName,
- combine,
- precision_named,
-)
-from vector_raman_cases import CHUNK as VECTOR_CHUNK
-from vector_raman_cases import vector_translation
-
-U32 = UNIT_ROUNDOFF_32
-
-
-def gnlse(
- precision: str,
- *,
- length: float = 0.05,
- steps: int | None = 10,
- gamma: float = 0.11,
- power: float = 100.0,
- raman: bool = True,
- integrator: RungeKutta | LowStorageRungeKutta | Multistep | None = None,
- beta0: float = 0.0,
- taper: bool = False,
-) -> Simulation:
- """Return a 1024-bin soliton fibre, replayed on a uniform schedule by default."""
- schedule = (
- None
- if steps is None
- else FixedSchedule.uniform(0.0, length, length / steps, save_planes=[length])
- )
- return Simulation.from_gnlse(
- gamma,
- length,
- [beta0, 0.0, -1.183e-26, 8.1e-41],
- wavelength=835e-9,
- # The former 1024-bin window and no collar: the bounds below count its bins,
- # and a replayed schedule is refused beside the default collar.
- grid=EnvelopeGrid.from_limits(
- length, 835e-9, (500e-9, 2e-6), 1e-12, **LEGACY_GRID
- ),
- absorber=False,
- width=50e-15,
- peak_power=power,
- shape="sech",
- save_planes=[length],
- backend="numpy_ref",
- precision=precision, # pyright: ignore[reportArgumentType]
- schedule=schedule,
- integrator=RungeKutta() if integrator is None else integrator,
- raman=raman,
- polarisation_taper=taper,
- noise=None,
- )
-
-
-def capillary(
- precision: str, gas: str | dict[str, float] = "Ar", **overrides
-) -> Simulation:
- """Return a short field-grid capillary, ionising in argon, on 16 fixed steps.
-
- The split-step collar is unconditionally stable, so the steps need not stay inside
- the in-stage collar's limit, which a short capillary's strong collar lowers.
- """
- length = overrides.pop("length", 0.004)
- keywords = {
- "wavelength": 800e-9,
- "wavelength_limits": (150e-9, 3e-6),
- "trange": 300e-15,
- "fwhm": 10e-15,
- "energy": 60e-6,
- "n_save": 2,
- "backend": "numpy_ref",
- "precision": precision,
- "integrator": RungeKutta(),
- "schedule": FixedSchedule.uniform(0.0, length, length / 16),
- "absorber_scheme": "split_step",
- } | overrides
- return Simulation.from_capillary(
- 50e-6, length, gas, 1.0, **{"noise": None, **keywords}
- )
-
-
-def loaded(simulation: Simulation, cleanup: ExitStack | None = None) -> Backend:
- """Return the simulation's backend with its launch loaded."""
- problem = simulation.translate()
- run_request = simulation._run_request()
- backend = select("numpy_ref", problem, run_request, grid=simulation.grid)
- if cleanup is not None:
- cleanup.callback(backend.close)
- stepping(backend).set_initial(problem.initial.field0, None)
- return backend
-
-
-def finish(simulation: Simulation) -> np.ndarray:
- """Run to the last save plane and return the observed field."""
- backend = loaded(simulation)
- try:
- stepping(backend).propagate_to(float(simulation.settings.save_planes[-1]))
- field = stepping(backend).observe().field
- assert field is not None
- return field
- finally:
- backend.close()
-
-
-def rescaled(backend: Backend, exponent: int) -> None:
- """Hold a loaded single-precision state at the scale ``2**exponent`` instead."""
- single = cast(NumpySingleBackend, backend)
- physical = single._observed_field(single._require_state())
- single._scale = math.ldexp(1.0, exponent)
- single._scale_exponent = exponent
- controller = single._stepper.controller
- single._controller = dataclasses.replace(
- controller, atol=controller.atol / single._scale
- )
- single._state = (physical / single._scale).astype(np.complex64)
-
-
-# The interface: settings, admission and refusals.
-
-
-def test_single_precision_takes_its_defaults_and_refuses_what_it_does_not_run():
- simulation = gnlse("single", steps=None)
- settings = simulation.settings
- assert settings.precision == "single"
- assert isinstance(settings.integrator, RungeKutta)
- assert cast("PIController", settings.controller).rtol == 1e-6
- # A capillary takes its own default, at the floor, not the waveguide's 1e-5.
- controller = capillary("single").settings.controller
- assert cast("PIController", controller).rtol == 1e-6
- problem = simulation.translate()
- assert problem.precision.single
- # Nothing in the problem itself is refused; the statistics and the step's exponent
- # are the run's, checked with its request (`validate_run`).
- assert problem.validate() is None
-
- def automatic() -> Simulation:
- return Simulation.from_gnlse(
- 0.11, 0.05, [0.0, 0.0, -1e-26], wavelength=835e-9, trange=1e-12,
- wavelength_limits=(500e-9, 2e-6), width=50e-15, peak_power=1.0,
- shape="sech", precision="single", noise=None,
- ) # fmt: skip
-
- # "auto" is refused: never a binary64 backend, never the oracle (Q8, SEP 47).
- with pytest.raises(DriverError, match="backend='auto'"):
- automatic()
- with pytest.raises(DriverError, match="binary64 only"):
- dataclasses.replace(settings, backend="native")
- # The Adams integrator is admitted (#342); the default stays Runge-Kutta.
- assert isinstance(
- dataclasses.replace(settings, integrator=Multistep()).integrator, Multistep
- )
- with pytest.raises(DriverError, match="floor"):
- dataclasses.replace(
- settings,
- controller=dataclasses.replace(
- cast("PIController", settings.controller), rtol=1e-8
- ),
- )
-
-
-@pytest.mark.parametrize("name", ["native"])
-def test_the_compiled_backends_refuse_a_single_precision_problem(name):
- """The refusal is the backend's own too, not only the settings' (SEP 33 5.2)."""
- if name not in available():
- pytest.skip(f"{name} is not installed")
- problem = gnlse("single").translate()
- run_request = gnlse("single")._run_request()
- with pytest.raises(BackendError, match="binary64 only"):
- select(name, problem, run_request)
-
-
-def test_the_oracle_applies_the_settings_admission_to_a_problem_past_them():
- """``registry.select`` on a translated problem meets the settings' refusals too.
-
- The settings refuse a tolerance below the floor under single precision; a problem
- replaced after translation reached the backend without them. The Adams integrator
- is admitted (#342).
- """
- simulation = gnlse("single", steps=None)
- problem, run_request = simulation.translate(), simulation._run_request()
- stepper = run_request.stepper
- adams = run_request.with_stepper(
- dataclasses.replace(stepper, integrator=Multistep())
- )
- tight = run_request.with_stepper(
- dataclasses.replace(
- stepper, controller=dataclasses.replace(stepper.controller, rtol=1e-8)
- )
- )
- with pytest.raises(BackendError, match="below the single-precision floor"):
- select("numpy_ref", problem, tight)
- select("numpy_ref", problem, adams).close()
-
-
-def test_a_step_whose_inverse_propagator_overflows_binary32_is_refused():
- """A 1 mm capillary's collar on 0.25 mm steps: |Re dPhi| = 150, past 88.72.
-
- Binary64 runs it (its limit is 709.78); binary32 would hold ``exp(150)`` in the
- interaction picture's inverse propagator.
- """
- length = 0.001
- schedule = FixedSchedule.uniform(0.0, length, length / 4)
- capillary("double", length=length, schedule=schedule).translate()
- with pytest.raises(TranslationError, match=r"past binary32's range exp\(88\.72\)"):
- capillary("single", length=length, schedule=schedule).translate()
-
-
-@pytest.mark.parametrize(
- "integrator",
- [LowStorageRungeKutta(), Multistep()],
- ids=["low_storage", "adams"],
-)
-def test_a_forward_only_march_takes_the_damped_step_runge_kutta_cannot(integrator):
- """The low-storage march and the exponential Adams flow form no inverse.
-
- A damped bin's forward flow underflows towards zero in binary32, harmlessly, so the
- step the Runge-Kutta interaction picture is refused (|Re dPhi| = 150) is admitted;
- only growth would overflow them.
- """
- length = 0.001
- schedule = FixedSchedule.uniform(0.0, length, length / 4)
- capillary(
- "single", length=length, schedule=schedule, integrator=integrator
- ).translate()
-
-
-def overflowing_beam(precision: str) -> Simulation:
- """Return a radial Kerr beam whose first trial step is the whole millimetre.
-
- The default collar over that step reaches ``|Re dPhi| = 459``: inside binary64's
- range, past binary32's 88.72, in the interaction picture's inverse propagator --
- a Runge-Kutta trial's; the exponential Adams flow forms no inverse.
- """
- length = 1e-3
- grid = EnvelopeGrid.from_limits(
- length, 800e-9, (600e-9, 1.2e-6), 150e-15, thg=False
- )
- pulse = SpatialPulse(
- GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 60e-6
- )
- settings = SimulationSettings(
- save_planes=[length],
- dz_max=length,
- dz_init=length,
- precision=precision, # pyright: ignore[reportArgumentType]
- backend="numpy_ref",
- integrator=RungeKutta(),
- )
- return Simulation.from_radial(
- grid,
- RadialGrid(6e-4, 16),
- SpatialMedium(),
- [KerrResponse(1e-25, thg=False)],
- pulse,
- settings=settings,
- )
-
-
-def run_to_end(simulation: Simulation, backend: str) -> Observation:
- """Propagate on `backend` to the last save plane and return its observation."""
- problem = simulation.translate()
- run_request = simulation._run_request()
- with ExitStack() as cleanup:
- made = select(backend, problem, run_request, grid=simulation.grid)
- cleanup.callback(made.close)
- stepping(made).set_initial(problem.initial.field0, None)
- stepping(made).propagate_to(float(simulation.settings.save_planes[-1]))
- return stepping(made).observe()
-
-
-def test_an_adaptive_trial_that_overflows_binary32_is_rejected_and_shrunk():
- """A trial past ``exp(88.72)`` is a rejection, not the end of the run.
-
- Binary64 takes the whole millimetre in one step. In binary32 the first trials'
- propagators overflow and their norms are infinite: the controller rejects them and
- retries at its smallest factor, and the history records them as infinity rather
- than refusing the record at the plane.
- """
- single = run_to_end(overflowing_beam("single"), "numpy_ref")
- double = run_to_end(overflowing_beam("double"), "numpy_ref")
- history = single.step_history
- assert double.step_history.n_rejected == 0
- assert history.n_rejected >= 1
- assert not bool(history.accepted[0])
- assert history.error_norm[0] == np.inf
- accepted = history.accepted
- assert np.all(np.isfinite(history.error_norm[accepted]))
- assert np.all(history.error_norm[accepted] <= 1.0)
- # The survival record's field envelope (single-precision guide): both runs are
- # adaptive at the default tolerance, so they are held to it, not to a rounding
- # bound.
- assert_within(single.require_field(), double.require_field(), 1e-3)
-
-
-def test_a_step_within_a_tenth_of_binary32s_range_is_warned_of():
- """Seven steps of the same capillary: |Re dPhi| = 86, between 79.8 and 88.72."""
- length = 0.001
- schedule = FixedSchedule.uniform(0.0, length, length / 7)
- with pytest.warns(
- ExponentBudgetWarning, match="within a tenth of binary32's range"
- ):
- capillary("single", length=length, schedule=schedule).translate()
-
-
-def test_a_noise_model_is_refused_under_single_precision():
- with pytest.raises(Exception, match="noise model"):
- Simulation.from_gnlse(
- 0.11, 0.05, [0.0, 0.0, -1e-26], wavelength=835e-9, trange=1e-12,
- wavelength_limits=(500e-9, 2e-6), width=50e-15, peak_power=1.0,
- shape="sech",
- precision="single", backend="numpy_ref", noise="input", seed=1,
- ).translate() # fmt: skip
-
-
-def test_the_precision_is_in_every_configuration_and_its_hash():
- double, single = gnlse("double"), gnlse("single")
- assert double.metadata().configuration["precision"] == "double"
- assert single.metadata().configuration["precision"] == "single"
- assert (
- double.metadata().configuration_hash() != single.metadata().configuration_hash()
- )
-
-
-def test_a_resume_in_the_other_precision_is_refused(tmp_path):
- path = tmp_path / "run.h5"
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- keywords = {
- "wavelength": 835e-9, "wavelength_limits": (500e-9, 2e-6), "trange": 1e-12,
- "width": 50e-15, "peak_power": 10.0, "shape": "sech", "n_save": 2,
- "backend": "numpy_ref", "integrator": RungeKutta(), "progress": "none",
- } # fmt: skip
- prop_gnlse(
- 0.11, 0.01, [0.0, 0.0, -1e-26], output=path, **{"noise": None, **keywords}
- )
- with pytest.raises(Exception, match="precision"):
- prop_gnlse(
- 0.11, 0.01, [0.0, 0.0, -1e-26], output=path, resume=True,
- precision="single", **{"noise": None, **keywords},
- ) # fmt: skip
-
-
-# The state, its scale and what crosses to the host.
-
-
-def test_the_state_is_binary32_at_the_launch_scale_and_observed_exactly():
- simulation = gnlse("single")
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(simulation, cleanup))
- state = backend._require_state()
- assert state.dtype == np.complex64
- assert backend.scale == 8.0
- # The launch's peak temporal amplitude over the scale lies in [1, 2).
- peak = float(np.max(np.abs(np.fft.ifft(state.astype(np.complex128)))))
- assert 1.0 <= peak < 2.0
- observed = backend.observe().field
- assert observed is not None and observed.dtype == np.complex128
- assert np.array_equal(observed, state[0].astype(np.complex128) * 8.0)
- # A reload of the observed plane holds the same binary32 state: class P.
- backend.propagate_to(0.02)
- plane = backend.observe().field
- held = backend._require_state().copy()
- assert plane is not None
- backend.set_initial(plane, None, z=0.02)
- assert np.array_equal(backend._require_state(), held)
- # The scale is the launch's, whatever the peak of a state loaded later.
- backend.set_initial(4 * plane, None, z=0.02)
- assert backend.scale == 8.0
-
-
-def test_a_zero_launch_takes_the_unit_scale_and_stays_zero():
- problem = gnlse("single", gamma=0.0, raman=False).translate()
- run_request = gnlse("single", gamma=0.0, raman=False)._run_request()
- zeros = np.zeros_like(problem.initial.field0)
- zeros.setflags(write=False)
- problem = dataclasses.replace(
- problem, initial=dataclasses.replace(problem.initial, field0=zeros)
- )
- backend = cast(NumpySingleBackend, select("numpy_ref", problem, run_request))
- try:
- assert backend.scale == 1.0
- backend.set_initial(zeros, None)
- backend.propagate_to(0.05)
- field = backend.observe().field
- assert field is not None and not np.any(field)
- finally:
- backend.close()
-
-
-def test_scaling_by_a_power_of_two_is_exact():
- """Scaled and unscaled runs differ by powers of two alone: class P (section 4.3)."""
- for simulation in (gnlse("single"), capillary("single")):
- results = []
- for exponent in (None, 0, 11):
- with ExitStack() as cleanup:
- backend = loaded(simulation, cleanup)
- if exponent is not None:
- rescaled(backend, exponent)
- stepping(backend).propagate_to(
- float(simulation.settings.save_planes[-1])
- )
- results.append(stepping(backend).observe().field)
- assert np.array_equal(results[0], results[1])
- assert np.array_equal(results[0], results[2])
-
-
-# Class R: the binary32 path against the binary64 reference, same schedule.
-
-
-@pytest.mark.parametrize("steps", [1, 10])
-def test_the_oracle_agrees_with_binary64_within_a_binary32_bound(steps):
- """A step carries the state's rounding and its seven stages' transform rounding.
-
- Each stage's two transforms of 1024 samples and its pointwise response are
- bounded by ``16 eps ceil(log2 n)`` relative to the stage, which ``dz phi_NL`` (here
- below 0.1) scales into the state; the state itself is rounded once a step. The
- bound is ``steps (2 + 7 * 16 eps log2 n)`` in binary32's ``eps``.
- """
- single = finish(gnlse("single", steps=steps))
- double = finish(gnlse("double", steps=steps))
- n_to = gnlse("double").translate().grids.n_to
- bound = steps * (2 * U32 + 7 * 16 * EPS_32 * math.ceil(math.log2(n_to)))
- assert_within(single, double, bound, what=f"{steps} steps")
-
-
-def test_the_polarisation_taper_agrees_with_binary64_within_its_bound():
- single = finish(gnlse("single", taper=True))
- double = finish(gnlse("double", taper=True))
- bound = 10 * (2 * U32 + 7 * 16 * EPS_32 * 10)
- assert_within(single, double, bound)
-
-
-def test_a_capillary_with_plasma_agrees_with_binary64_within_its_bound():
- """16 steps of an ionising field-grid capillary; the scans add their tree depth."""
- simulation = capillary("double")
- n_to = simulation.translate().grids.n_to
- depth = math.ceil(math.log2(n_to))
- bound = 16 * (2 * U32 + 7 * (16 * EPS_32 * depth + 4 * higham_gamma(depth, U32)))
- assert_within(finish(capillary("single")), finish(simulation), bound)
-
-
-def test_low_storage_agrees_with_binary64_within_its_bound():
- method = LowStorageRungeKutta()
- single = finish(gnlse("single", integrator=method))
- double = finish(gnlse("double", integrator=method))
- bound = 10 * (2 * U32 + 4 * 16 * EPS_32 * 10)
- assert_within(single, double, bound)
-
-
-def test_a_batch_member_is_its_solo_run():
- """Members agree with their solo runs within the matched-discrete bound."""
- with ExitStack() as cleanup:
- members = tuple(loaded(gnlse("single"), cleanup) for _ in range(2))
- runner = select_batch(cast(Sequence[Backend], members))
- cleanup.callback(runner.close)
- runner.propagate_to(0.05)
- batch = [member.field for member in runner.observe_members()]
- solo = finish(gnlse("single"))
- bound = 10 * (2 * U32 + 7 * 16 * EPS_32 * 10)
- for field in batch:
- assert_within(field, solo, bound)
-
-
-def spatial_kerr(
- route: str,
- precision: str,
- *,
- steps: int = 10,
- absorbed: bool = False,
- energy: float = 3e-4,
- chi3: float = 1e-25,
-) -> Simulation:
- """Return a self-focusing Kerr beam on the radial or Cartesian route.
-
- `absorbed` adds the grid's collars: the transverse window's in-stage rate beside
- the temporal one.
- """
- length = 0.1
- grid = EnvelopeGrid.from_limits(
- length, 800e-9, (600e-9, 1.2e-6), 150e-15, thg=False, **LEGACY_GRID
- )
- spaces = {
- "radial": RadialGrid(2e-3, 32),
- "cartesian": FreeGrid(2e-3, 16, 2e-3, 16),
- "line": LineGrid(2e-3, 16),
- }
- pulse = SpatialPulse(
- GaussianPulse(wavelength=800e-9, fwhm=50e-15, energy=energy), 300e-6
- )
- settings = SimulationSettings(
- controller=PIController(rtol=1e-6, atol=0.0),
- save_planes=[length],
- dz_max=length,
- dz_init=length / steps,
- schedule=FixedSchedule.uniform(0.0, length, length / steps),
- backend="numpy_ref",
- integrator=RungeKutta(),
- precision=precision, # pyright: ignore[reportArgumentType]
- absorber=(
- absorber(grid, boundary_length=100 * length)
- if absorbed
- else bare_absorber(grid)
- ),
- )
- constructor = Simulation.from_radial if route == "radial" else Simulation.from_free
- return constructor(
- grid,
- spaces[route],
- SpatialMedium(),
- [KerrResponse(chi3, thg=False)],
- pulse,
- settings=settings,
- )
-
-
-def plasma_beam(
- precision: str, *, route: str = "radial", gas: str = "Ar"
-) -> Simulation:
- """Return a short filament on the radial (or Cartesian) route, ten steps.
-
- Nitrogen adds its molecular Raman response beside the plasma.
- """
- length = 1e-3
- grid = FieldGrid.from_limits(length, 800e-9, (350e-9, 2e-6), 150e-15, **LEGACY_GRID)
- pulse = SpatialPulse(
- GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=2e-4), 100e-6
- )
- responses: list[object] = [
- KerrResponse(gamma3(gas, wavelength=800e-9), thg=False),
- PlasmaResponse(gas, OptmatRate(gas, 800e-9)),
- ]
- if gas == "N2":
- selection = MaterialSelection(carrier_wavelength=800e-9)
- responses.append(gas_raman(gas, selection=selection, thg=False))
- settings = SimulationSettings(
- controller=PIController(rtol=1e-6, atol=0.0),
- save_planes=[length],
- dz_max=length,
- dz_init=length / 10,
- schedule=FixedSchedule.uniform(0.0, length, length / 10),
- backend="numpy_ref",
- integrator=RungeKutta(),
- ionisation_field_range=(1e7, 1e11),
- precision=precision, # pyright: ignore[reportArgumentType]
- absorber=bare_absorber(grid),
- )
- if route == "cartesian":
- return Simulation.from_free(
- grid,
- FreeGrid(1e-3, 16, 1e-3, 16),
- SpatialMedium(gas, 1.0),
- responses,
- pulse,
- settings=settings,
- noise=None,
- )
- return Simulation.from_radial(
- grid,
- RadialGrid(1e-3, 32),
- SpatialMedium(gas, 1.0),
- responses,
- pulse,
- settings=settings,
- noise=None,
- )
-
-
-@pytest.mark.parametrize("route", ["radial", "cartesian"])
-def test_the_spatial_routes_agree_with_binary64_within_their_bounds(route):
- """Ten steps of a self-focusing Kerr beam; a transverse transform per stage.
-
- The radial route's Hankel GEMM over ``n_r`` nodes and the Cartesian route's
- two-dimensional transform add their depth to the temporal transform's.
- """
- fields = {p: finish(spatial_kerr(route, p)) for p in ("double", "single")}
- n_t = spatial_kerr(route, "double").grid.n_t
- transverse = 32 if route == "radial" else 2 * 4
- depth = math.ceil(math.log2(n_t)) + transverse
- bound = 10 * (2 * U32 + 7 * 16 * EPS_32 * depth)
- assert_within(fields["single"], fields["double"], bound)
-
-
-def test_a_radial_plasma_beam_agrees_with_binary64_within_its_bound():
- """The radial route's own plasma spectrum (held current on nodes), ten steps."""
- fields = {p: finish(plasma_beam(p)) for p in ("double", "single")}
- depth = math.ceil(math.log2(plasma_beam("double").grid.n_t)) + 32
- bound = 10 * (2 * U32 + 7 * (16 * EPS_32 * depth + 4 * higham_gamma(depth, U32)))
- assert_within(fields["single"], fields["double"], bound)
-
-
-@pytest.mark.parametrize(
- ("family", "material"),
- [
- ("envelope", "H2"),
- ("field", "H2"),
- ("field_real", "H2"),
- ("envelope", "CH4"),
- ],
-)
-def test_tensor_raman_on_two_components_agrees_with_binary64(family, material):
- """Both helicities and an oblique delayed probe through H2's tensor ladder.
-
- The real field grid keeps the harmonic in the drive; methane, a spherical top, has
- the isotropic kernel alone.
- """
- problem, run_request, grid, modes = vector_translation(
- family=family, material=material, steps=8
- )
- fields = {}
- cases: tuple[tuple[PrecisionName, type[NumpyRefBackend]], ...] = (
- ("double", NumpyRefBackend),
- ("single", NumpySingleBackend),
- )
- for precision, backend_class in cases:
- case = dataclasses.replace(problem, precision=precision_named(precision))
- backend = backend_class(
- case, run_request, grid=grid, modes=modes, options=VECTOR_CHUNK
- )
- try:
- backend.set_initial(case.initial.field0, None)
- backend.propagate_to(float(run_request.stepper.save_planes[-1]))
- fields[precision] = backend.observe().field
- finally:
- backend.close()
- depth = math.ceil(math.log2(2 * grid.n_t)) + 4
- assert_within(fields["single"], fields["double"], step_bound_oracle(8, depth))
-
-
-def step_bound_oracle(steps: int, depth: int) -> float:
- """Return the oracle tests' bound for `steps` steps of transforms of `depth`."""
- return steps * (2 * U32 + 7 * 16 * EPS_32 * depth)
-
-
-# Targeted tests: the review's counterexamples.
-
-
-def test_a_molecular_raman_response_survives_narrowing():
- """Nitrogen's per-particle kernel spectrum peaks near 5e-50 (review R1)."""
- simulation = capillary(
- "single", gas="N2", energy=20e-6, wavelength_limits=(200e-9, 3e-6)
- )
- problem = simulation.translate()
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(simulation, cleanup))
- bundle = backend._stage_scalars(0.0, 0.0)
- wide = NumpySingleBackend.__mro__[1]._raman_spectrum( # pyright: ignore[reportAttributeAccessIssue]
- backend, bundle.raman_rates, bundle.raman_densities
- )
- backend._raman_kernel = None
- narrowed = backend._raman_spectrum(bundle.raman_rates, bundle.raman_densities)
- exponent = backend.block_exponents["raman_kernel"]
- assert narrowed is not None and wide is not None
- assert float(np.max(np.abs(wide))) < 1e-45
- assert not np.any(wide.astype(np.complex64))
- t = np.arange(problem.grids.n_to) * problem.grids.dt_fine
- field = 3e10 * np.exp(-(((t - t.mean()) / 20e-15) ** 2)) * np.cos(2.35e15 * t)
- density = bundle.raman_densities[0] if bundle.raman_densities else bundle.density
- expected = passes.raman_polarisation(
- field[None, None, :], wide, kind="field", thg=False, density=density
- )
- scale = 2.0**34
- factor = combine(EPS0, density, exponent=2 * 34 + exponent).binary32()
- actual = raman_single(
- (field / scale).astype(np.float32)[None, None, :],
- narrowed,
- factor,
- kind="field",
- thg=False,
- )
- assert np.any(actual)
- bound = 64 * EPS_32 * math.ceil(math.log2(2 * problem.grids.n_to))
- assert_within(actual.astype(np.float64) * scale, expected, bound)
-
-
-def test_the_stable_populations_keep_small_and_saturated_exposures():
- """Review R4's two cases, against the binary64 forms."""
- exposure = np.array([1e-8, 1e-3, 1.0, 20.0, 60.0], dtype=np.float32)
- for preionisation in (0.0, 0.3):
- fraction, survival = stable_population(exposure, preionisation)
- wide = exposure.astype(np.float64)
- expected_fraction = preionisation + (1 - preionisation) * -np.expm1(-wide)
- expected_survival = (1 - preionisation) * np.exp(-wide)
- assert np.all(
- np.abs(fraction - expected_fraction) <= 4 * U32 * expected_fraction
- )
- assert np.all(
- np.abs(survival - expected_survival) <= 4 * U32 * expected_survival
- )
- # The subtraction the binary64 path keeps loses both in binary32.
- naive = np.float32(1.0) - np.exp(-exposure)
- assert naive[0] == 0.0
- assert np.float32(1.0) - naive[3] == 0.0
-
-
-def test_the_ionisation_work_is_the_population_it_ionises():
- """``I_p int W sigma dt = I_p (f_end - p)``: the loss's work from the survival."""
- t = np.linspace(-100e-15, 100e-15, 4097)
- dt = float(t[1] - t[0])
- rule = RunningIntegral.trapezoid()
- for peak in (1e6, 1e14, 5e14):
- rate = (peak * np.exp(-((t / 20e-15) ** 2))).astype(np.float32)
- exposure = running_integral(rate, dt, rule)
- fraction, survival = stable_population(exposure, 0.1)
- work = running_integral(rate * survival, dt, rule)[-1]
- depth = math.ceil(math.log2(t.size)) + 4
- # The population gained, ``(1 - p)(-expm1(-X))``: ``f - p`` from the rounded
- # fraction would lose the digits ``p`` holds.
- gained = 0.9 * -math.expm1(-float(exposure[-1]))
- assert gained > 0.0 and float(fraction[-1]) > 0.1
- # The quadrature of d f/dt = W sigma on this grid is second order: the
- # reference integral and its binary32 evaluation are compared at the
- # trapezoid's own error plus the scan's depth.
- wide_exposure = np.concatenate(
- ([0.0], np.cumsum(0.5 * (rate[1:] + rate[:-1]) * dt))
- )
- wide_work = np.sum(
- 0.5
- * dt
- * (
- (rate * 0.9 * np.exp(-wide_exposure))[1:]
- + (rate * 0.9 * np.exp(-wide_exposure))[:-1]
- )
- )
- assert abs(work - wide_work) <= 8 * higham_gamma(depth, U32) * wide_work
- assert abs(wide_work - gained) <= 1e-3 * gained
-
-
-def test_a_running_integral_is_within_its_prefix_bound():
- rng = np.random.default_rng(7)
- values = rng.random((3, 1 << 13)).astype(np.float32)
- dt = 1e-16
- rule = RunningIntegral.trapezoid()
- actual = running_integral(values, dt, rule).astype(np.float64)
- wide = values.astype(np.float64)
- increments = 0.5 * (wide[..., 1:] + wide[..., :-1]) * dt
- expected = np.concatenate(
- (np.zeros((3, 1)), np.cumsum(increments, axis=-1)), axis=-1
- )
- depth = math.ceil(math.log2(values.shape[-1])) + 3
- bound = prefix_bound(increments, depth, u=U32)
- assert np.all(np.abs(actual[..., 1:] - expected[..., 1:]) <= bound)
-
-
-def test_the_plasma_current_vanishes_without_rate_or_field():
- simulation = capillary("single")
- channels = simulation.translate().responses.plasma_channels
- field = np.zeros((1, 1, 64), dtype=np.float32)
- field[0, 0, 10] = 1e5
- current = plasma_current_single(
- field, channels, (2.5e25,), (0.0,), 1e-16, RunningIntegral.trapezoid()
- )
- assert np.all(np.isfinite(current))
- assert current.dtype == np.float32
-
-
-def test_the_propagator_drift_is_linear_in_the_step_count():
- """A constant operator, no source, an occupied band: ``c M u`` with ``c <= 1``.
-
- The binary64-accurate propagator rounds once per step (section 4.1, item 3),
- whatever ``|dPhi|``; the survival record states the measured ``c`` and the
- study's binary32-exponent variant beside it.
- """
- linear = {"gamma": 0.0, "raman": False, "length": 0.5}
- for steps in (10, 100, 1000):
- exact = finish(gnlse("double", steps=steps, **linear))
- actual = finish(gnlse("single", steps=steps, **linear))
- assert_within(actual, exact, steps * U32, what=f"{steps} steps")
-
-
-# Guards and failures.
-
-
-def test_a_binary64_array_in_a_pass_is_refused():
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(gnlse("single"), cleanup))
- wide = NumpySingleBackend.__mro__[1]._prefactor # pyright: ignore[reportAttributeAccessIssue]
- backend._prefactor = lambda bundle: wide(backend, bundle)
- with pytest.raises(BackendError, match="binary64 array reached"):
- backend.propagate_to(0.05)
-
-
-@pytest.mark.parametrize("march", ["adaptive", "replayed", "low_storage"])
-def test_a_non_finite_source_stops_every_march(march):
- simulation = {
- "adaptive": gnlse("single", steps=None),
- "replayed": gnlse("single"),
- "low_storage": gnlse("single", integrator=LowStorageRungeKutta()),
- }[march]
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(simulation, cleanup))
- original = backend._column_sources
-
- def poisoned(field_t, bundle, raman):
- sources = original(field_t, bundle, raman)
- return passes.NonlinearSources(
- sources.polarisation * np.float32(np.nan), sources.current
- )
-
- backend._column_sources = poisoned
- with pytest.raises(ConvergenceError):
- backend.propagate_to(0.05)
-
-
-def test_a_table_beyond_binary32_range_is_refused_by_name():
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(gnlse("single"), cleanup))
- with pytest.raises(BackendError, match="must carry its block exponent"):
- backend._upload("probe", np.array([1e-3, 1e-41]))
- with pytest.raises(BackendError, match="beyond binary32"):
- backend._upload("probe", np.array([1e39]))
- assert backend._upload("probe", np.array([3.0, 1e-41])).dtype == np.float32
-
-
-# The oracle's own branches: routes, switches and the host side's helpers.
-
-
-def test_the_single_oracle_refuses_a_double_problem():
- problem = gnlse("double").translate()
- run_request = gnlse("double")._run_request()
- with pytest.raises(BackendError, match="needs a single problem"):
- NumpySingleBackend(problem, run_request)
-
-
-@pytest.mark.parametrize(
- "build",
- [
- pytest.param(
- lambda p: capillary(p, modes=4, length=0.002, energy=20e-6), id="multimode"
- ),
- pytest.param(
- lambda p: capillary(p, envelope=True, plasma=False), id="envelope"
- ),
- pytest.param(
- lambda p: spatial_kerr("radial", p, absorbed=True), id="radial-collars"
- ),
- pytest.param(
- lambda p: capillary(p, envelope=True, plasma=False, thg=True),
- id="envelope-harmonic",
- ),
- pytest.param(lambda p: capillary(p, gas={"Ar": 0.5, "He": 0.5}), id="mixture"),
- pytest.param(
- lambda p: capillary(
- p, gas="N2", thg=True, wavelength_limits=(200e-9, 3e-6)
- ),
- id="field-raman",
- ),
- ],
-)
-def test_more_routes_agree_with_binary64_within_their_bounds(build):
- """The embedded quadrature, the envelope's harmonic carrier, the radial absorber,
- a two-species plasma and the field grid's Raman drive with its harmonic.
-
- Each takes a path of the oracle the cases above do not.
- """
- single, double = finish(build("single")), finish(build("double"))
- assert_within(single, double, step_bound_oracle(16, 13 + 32))
-
-
-def test_the_statistics_are_taken_from_the_widened_state():
- with ExitStack() as cleanup:
- single = loaded(capillary("single"), cleanup)
- double = loaded(capillary("double"), cleanup)
- for backend in (single, double):
- stepping(backend).propagate_to(0.001)
- spec = StatsSpec(names=("energy", "electron_density"))
- ours, theirs = stepping(single).stats(spec), stepping(double).stats(spec)
- assert set(ours) == set(theirs)
- assert_within(np.asarray(ours["energy"]), np.asarray(theirs["energy"]), 1e-5)
-
-
-def test_the_binary32_exponent_switch_rounds_the_exponent_first():
- """The study's variant: the exponent rounded to binary32 before the exponential."""
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(gnlse("single"), cleanup))
- backend.binary32_exponent = True
- backend.propagate_to(0.01)
- field = backend.observe().field
- assert field is not None and np.all(np.isfinite(field))
-
-
-def test_a_non_finite_state_after_a_step_is_refused():
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(gnlse("single"), cleanup))
- state = np.full((1, *backend._problem.shape), np.nan, dtype=np.complex64)
- with pytest.raises(ConvergenceError, match="not finite after a step"):
- backend._finish(state, 1e-3)
-
-
-# The split-step collar.
-
-
-def collar_case(kind: str) -> tuple[np.ndarray, np.ndarray, float]:
- """Return a two-mode state on 256 samples, a collar's rate and a step.
-
- The collar rises quadratically over the outer eighth of the window on each side
- to ``alpha h / 2 = 0.5``; a Gaussian pulse sits in the middle, so the factor is
- exactly one where the light is.
- """
- n_t, dz = 256, 1e-4
- t = np.arange(n_t) - n_t / 2
- edge = np.clip((np.abs(t) - 0.375 * n_t) / (0.125 * n_t), 0.0, 1.0)
- rate = 1e4 * edge**2
- rng = np.random.default_rng(79)
- pulse = np.exp(-((t / 20.0) ** 2))
- samples = pulse * rng.standard_normal((2, n_t))
- if kind == "envelope":
- samples = samples + 1j * pulse * rng.standard_normal((2, n_t))
- state = fourier.forward(samples, kind=kind)[None, :, None, :] # pyright: ignore[reportArgumentType]
- return state, rate, dz
-
-
-@pytest.mark.parametrize("kind", ["field", "envelope"])
-def test_the_split_collar_keeps_binary32_in_binary32(kind, monkeypatch):
- state, rate, dz = collar_case(kind)
- # Binary64 keeps its form, operation for operation.
- wide = passes.apply_temporal_rate(state, rate, dz, kind=kind)
- product = fourier.inverse(state, kind=kind) * np.exp(-0.5 * rate * dz)
- assert wide.dtype == np.complex128
- assert np.array_equal(wide, fourier.forward(product, kind=kind))
- narrow = state.astype(np.complex64)
- # Every transform the pass takes of a binary32 state is of binary32 operands.
- operands: list[np.dtype] = []
- for name in ("forward", "inverse"):
- original = getattr(fourier, name)
-
- def spy(values, *, kind, original=original):
- operands.append(np.asarray(values).dtype)
- return original(values, kind=kind)
-
- monkeypatch.setattr(passes.fourier, name, spy)
- absorbed = passes.apply_temporal_rate(narrow, rate, dz, kind=kind)
- monkeypatch.undo()
- assert absorbed.dtype == np.complex64
- assert len(operands) == 2
- assert set(operands) <= {np.dtype(np.float32), np.dtype(np.complex64)}
- # Against the binary64 pass on the same binary32 state: the correction's round
- # trip, two binary32 transforms of the oracle tests' 16 eps log2 n each and the
- # rounded factor and product, scaled by the correction's largest share
- # |f - 1| = 1 - exp(-0.5); and the final addition's rounding.
- change = float(np.max(-np.expm1(-0.5 * rate * dz)))
- transform = 16 * EPS_32 * math.ceil(math.log2(rate.size))
- widened = narrow.astype(np.complex128)
- exact = passes.apply_temporal_rate(widened, rate, dz, kind=kind)
- assert_within(absorbed, exact, 2 * U32 + change * (2 * transform + 2 * U32))
- # Without a collar the correction is exactly nothing.
- unchanged = passes.apply_temporal_rate(narrow, np.zeros_like(rate), dz, kind=kind)
- assert np.array_equal(unchanged, narrow)
-
-
-def test_a_binary64_collar_in_the_finish_is_refused(monkeypatch):
- # The finish used to narrow whatever its collar returned; a binary64 result is now
- # a pass that left binary32, refused by name.
- widening = passes.apply_temporal_rate
-
- def widened(state, rate, dz, *, kind):
- return widening(state.astype(np.complex128), rate, dz, kind=kind)
-
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(capillary("single"), cleanup))
- state = backend._require_state()
- assert backend._finish(state, 1e-4).dtype == np.complex64
- monkeypatch.setattr(passes, "apply_temporal_rate", widened)
- with pytest.raises(BackendError, match="the finished state as complex128"):
- backend._finish(state, 1e-4)
-
-
-def low_storage_split(route: str, precision: str) -> Simulation:
- """Return a low-storage march under the split-step collar: a field-grid capillary
- (Kerr only, 16 steps) or a Cartesian Kerr beam with its collars (10 steps), whose
- finish transforms each row across the plane and back around the collar."""
- if route == "capillary":
- return capillary(precision, plasma=False, integrator=LowStorageRungeKutta())
- simulation = spatial_kerr("cartesian", precision, absorbed=True)
- simulation.settings = dataclasses.replace(
- simulation.settings,
- integrator=LowStorageRungeKutta(),
- absorber_scheme="split_step",
- )
- return simulation
-
-
-@pytest.mark.parametrize("route", ["capillary", "cartesian"])
-def test_low_storage_with_the_split_collar_stays_in_binary32(route, monkeypatch):
- """The low-storage register step commits through the same checked finish."""
- original = passes.apply_temporal_rate
- results: list[np.dtype] = []
-
- def spy(state, rate, dz, *, kind):
- absorbed = original(state, rate, dz, kind=kind)
- results.append(absorbed.dtype)
- return absorbed
-
- monkeypatch.setattr(passes, "apply_temporal_rate", spy)
- single = finish(low_storage_split(route, "single"))
- assert results and set(results) == {np.dtype(np.complex64)}
- monkeypatch.undo()
- double = finish(low_storage_split(route, "double"))
- simulation = low_storage_split(route, "double")
- steps = 16 if route == "capillary" else 10
- transverse = 0 if route == "capillary" else 2 * 4
- depth = math.ceil(math.log2(simulation.translate().grids.n_to)) + transverse
- # The four stages' transforms of the low-storage test, and the collar's round
- # trip counted as a fifth: its rounding is further scaled by |f - 1| < 1.
- bound = steps * (2 * U32 + 5 * 16 * EPS_32 * depth)
- assert_within(single, double, bound)
- if route == "cartesian":
- # The plane's rows are written back into its own complex64 buffer, so a
- # widened collar would be narrowed there, not refused: the spy above is
- # this route's check.
- return
-
- def widened(state, rate, dz, *, kind):
- return original(state.astype(np.complex128), rate, dz, kind=kind)
-
- monkeypatch.setattr(passes, "apply_temporal_rate", widened)
- with ExitStack() as cleanup:
- backend = loaded(low_storage_split(route, "single"), cleanup)
- with pytest.raises(BackendError, match="the finished state as complex128"):
- stepping(backend).propagate_to(float(simulation.settings.save_planes[-1]))
-
-
-def test_a_single_precision_batch_takes_each_member_s_own_trial():
- # The reference's batch arithmetic over the member axis is binary64: the
- # single-precision members step in turn, each through its binary32 trial.
- members = tuple(loaded(gnlse("single")) for _ in range(2))
- runner = select_batch(cast(Sequence[Backend], members))
- try:
- assert type(runner) is HostGroup
- finally:
- runner.close()
-
-
-@pytest.mark.parametrize("order", [("single", "double"), ("double", "single")])
-def test_a_batch_of_mixed_precisions_is_refused(order):
- with ExitStack() as cleanup:
- mixed = tuple(loaded(gnlse(precision), cleanup) for precision in order)
- with pytest.raises(BackendError, match="mixed precisions"):
- select_batch(cast(Sequence[Backend], mixed))
- # The shared member validation, whichever runner is built.
- with pytest.raises(BackendError, match="mixed precisions"):
- HostGroup(cast(Sequence[HostDriver[object]], mixed))
-
-
-def test_the_reference_s_binary64_batch_refuses_a_single_member():
- with ExitStack() as cleanup:
- member = cast(NumpySingleBackend, loaded(gnlse("single"), cleanup))
- with pytest.raises(BackendError, match="refuses a single-precision member"):
- NumpyBatchBackend([member])
-
-
-def test_a_weighted_controller_divides_by_its_bins():
- """A per-bin weight makes the controller's scale an array, applied in binary32."""
- n_omega = gnlse("single").translate().shape[-1]
- simulation = gnlse("single", steps=None)
- simulation.settings = dataclasses.replace(
- simulation.settings,
- controller=PIController(rtol=1e-5, atol=1e-6, weights=np.ones(n_omega)),
- )
- field = finish(simulation)
- assert np.all(np.isfinite(field))
-
-
-def gradient(precision: str) -> Simulation:
- """Return an argon capillary on a pressure gradient, Kerr only, adaptive Adams."""
- return Simulation.from_capillary(
- 50e-6,
- 0.004,
- "Ar",
- (1.0, 0.5),
- wavelength=800e-9,
- wavelength_limits=(300e-9, 3e-6),
- trange=300e-15,
- fwhm=10e-15,
- energy=20e-6,
- plasma=False,
- n_save=2,
- backend="numpy_ref",
- precision=precision, # pyright: ignore[reportArgumentType]
- integrator=Multistep(),
- rtol=1e-6,
- noise=None,
- )
-
-
-@pytest.mark.parametrize(
- "build",
- [
- pytest.param(
- lambda p: gnlse(p, steps=None, integrator=Multistep()), id="constant"
- ),
- pytest.param(gradient, id="gradient"),
- ],
-)
-def test_adams_runs_in_binary32(build):
- """The flow, its moments and the prediction rounded once (#342).
-
- Its binary32 result agrees with its binary64 one to the tolerance's truncation; on
- a gradient the operator is an expansion and the predictor carries its compensation.
- """
- single, double = finish(build("single")), finish(build("double"))
- assert_within(single, double, 1e-4)
-
-
-def test_the_intermediate_ranges_record_each_pass():
- """The study's range record: the field, the polarisation and the current."""
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(capillary("single"), cleanup))
- backend.intermediate_ranges = {}
- backend.propagate_to(0.0005)
- ranges = backend.intermediate_ranges
- assert {"field", "polarisation", "current"} <= set(ranges)
- assert all(0 < low <= high for low, high in ranges.values())
-
-
-def test_a_zero_launch_records_no_range():
- with ExitStack() as cleanup:
- backend = cast(NumpySingleBackend, loaded(gnlse("single"), cleanup))
- backend.set_initial(np.zeros(backend._problem.shape, dtype=np.complex128), None)
- backend.intermediate_ranges = {}
- backend.propagate_to(0.005)
- assert backend.intermediate_ranges == {}
-
-
-def test_the_envelope_plasma_runs_in_binary32_on_the_oracle():
- """Two steps of a 1 mm envelope-plasma capillary on 256 samples, both precisions."""
- length = 0.001
-
- def build(precision: str) -> Simulation:
- return capillary(
- precision,
- envelope=True,
- plasma=True,
- length=length,
- trange=150e-15,
- wavelength_limits=(300e-9, 2e-6),
- schedule=FixedSchedule.uniform(0.0, length, length / 8),
- )
-
- with ExitStack() as cleanup:
- single, double = (loaded(build(p), cleanup) for p in ("single", "double"))
- for backend in (single, double):
- stepping(backend).propagate_to(length / 4)
- fields = [stepping(backend).observe().field for backend in (single, double)]
- assert fields[0] is not None and fields[1] is not None
- assert_within(fields[0], fields[1], 1e-5)
-
-
-def test_single_precision_lists_the_oracle_only():
- """SEP 47 retired mlx: the reference is the one single-precision backend."""
- assert available("single") == ("numpy_ref",)
- assert simulation_module._single_backend("numpy_ref") == "numpy_ref"
- with pytest.raises(DriverError, match="backend='numpy_ref'"):
- simulation_module._single_backend("auto")
-
-
-@pytest.mark.parametrize("n", [255, 256])
-def test_the_binary32_analytic_signal_is_the_hilbert_transforms(n):
- """Odd and even lengths: the one-sided weights of :func:`scipy.signal.hilbert`."""
- values = np.random.default_rng(n).standard_normal(n).astype(np.float32)
- expected = signal.hilbert(values.astype(np.float64))
- assert_within(analytic_single(values), expected, 64 * U32)
-
-
-# A whole run, as a user sees it.
-
-
-def drifting_pulse(backend: str) -> Simulation:
- """Return a single-precision fibre run whose pulse walks deep into the collar.
-
- A ten-kilowatt Gaussian pulse, offset in frequency so that the group-velocity
- walk-off carries it 0.65 of the window over a metre of 200 fixed steps, under the
- split-step collar, its boundary check relaxed so the walk is recorded, not
- stopped. The nonlinearity is scaled down with the power: the walk is linear.
- """
- length, trange, beta2, peak_power = 1.0, 1e-12, 2e-26, 1e4
- grid = EnvelopeGrid.from_limits(length, 1e-6, (0.8e-6, 1.25e-6), trange)
- omega0 = 2 * math.pi * C / 1e-6
- offset = 0.65 * trange / (beta2 * length)
- simulation = Simulation.from_gnlse(
- 1e-9 / peak_power, length, [0.0, 0.0, beta2], wavelength=1e-6, grid=grid,
- pulses=[GaussianPulse(
- wavelength=2 * math.pi * C / (omega0 + offset), fwhm=200e-15,
- peak_power=peak_power,
- )],
- noise=None, raman=False, shock=False, n_save=3, precision="single",
- backend=backend, integrator=RungeKutta(), rtol=1e-5,
- schedule=FixedSchedule.uniform(0.0, length, length / 200),
- ) # fmt: skip
- simulation.settings = dataclasses.replace(
- simulation.settings,
- boundary_check=BoundaryCheck(budget=1.0, stop_factor=1.0),
- absorber_scheme="split_step",
- )
- return simulation
-
-
-def check_a_whole_run(backend: str) -> None:
- """Run :func:`drifting_pulse` through ``Simulation.run`` and check what it reports.
-
- The summary names the precision and the backend, the saved field is widened to
- binary64 (observation is exact), and the boundary monitor's record counts every
- step and reads as removed what the run lost.
- """
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", BoundaryWarning)
- run = drifting_pulse(backend).run(progress="none")
- assert isinstance(run, PlaneStore)
- (summary,) = cast(
- "list[dict[str, list[list[str]]]]", run.annotations[RUN_SUMMARY_ANNOTATION]
- )
- rows = {row[0]: row[1] for row in summary["rows"]}
- assert rows["precision"] == "single"
- assert rows["backend"] == backend
- field = np.asarray(run["field"])
- assert field.dtype == np.complex128
- assert field.shape[0] == 3 and np.all(np.isfinite(field))
- outcome = run.outcome
- assert outcome is not None and outcome.execution is not None
- record = outcome.execution["boundary_monitor"]
- assert isinstance(record, dict)
- assert record["steps"] == 200
- assert record["launch_energy"] > 0.0
- energy = np.asarray(run["stats/energy"])[:, 0, 0]
- lost = float(1.0 - energy[-1] / energy[0])
- assert lost > 0.5
- # Binary32's rounding over 200 steps: each step's removal is a non-negative sum,
- # within gamma of its few hundred samples of itself, and the states it is taken
- # from agree with the energies' to the march's rounding.
- assert record["removed_fraction"] == pytest.approx(lost, rel=1e-4)
- assert 0.0 < record["collar_max"] <= 1.0
-
-
-@pytest.mark.medium
-def test_a_whole_single_precision_run_reports_what_it_ran():
- check_a_whole_run("numpy_ref")
diff --git a/tests/backends/test_tdse_march.py b/tests/backends/test_tdse_march.py
index c68387c26..39c499e5f 100644
--- a/tests/backends/test_tdse_march.py
+++ b/tests/backends/test_tdse_march.py
@@ -16,7 +16,7 @@
from sessions.executors import IDENTITY, Started, drive
from sessions.runs import run as session_run
from solprop.backends import registry
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.backends.session import Start
from solprop.backends.sessions import start_session
diff --git a/tests/backends/test_tdse_sources.py b/tests/backends/test_tdse_sources.py
index 289b66f57..1d12b4764 100644
--- a/tests/backends/test_tdse_sources.py
+++ b/tests/backends/test_tdse_sources.py
@@ -33,7 +33,6 @@
from solprop.stepper import PIController
from solprop.stepper.multistep import Multistep
from solprop.translate.problem import assemble
-from solprop.translate.responses import ResponseStage
from solprop.translate.routes import TDSESource, tdse_descriptor
from solprop.translate.stage import scalars
from solprop.translate.tdse import TDSEDescriptor, TDSEResult, waveform_identity
@@ -116,9 +115,7 @@ def model_case(case, model, *, remainder_scale=2.0, cubic_scale=3.0):
compiled = problem.responses
if model != "optmat_cubic":
# Only the optmat closure owns an ordinary compiled cubic response.
- compiled = dataclasses.replace(
- compiled, kerr_coefficients=None, response_order=(ResponseStage.ACCUMULATE,)
- )
+ compiled = dataclasses.replace(compiled, kerr_coefficients=None)
problem = dataclasses.replace(
problem,
responses=compiled,
@@ -201,7 +198,6 @@ def test_native_scale_preserves_complete_nonlinearity_after_linear_subtraction(
responses=dataclasses.replace(
problem.responses,
kerr_coefficients=None,
- response_order=(ResponseStage.ACCUMULATE,),
),
route=dataclasses.replace(
problem.route,
@@ -485,7 +481,6 @@ def test_hacc_source_is_the_sae_native_formula_with_its_gate(source_case, window
compiled = dataclasses.replace(
problem.responses,
kerr_coefficients=None,
- response_order=(ResponseStage.ACCUMULATE,),
)
results = []
for declared_response in (native, hacc):
diff --git a/tests/backends/test_transverse_backends.py b/tests/backends/test_transverse_backends.py
index 0e74666b4..d5090d3f8 100644
--- a/tests/backends/test_transverse_backends.py
+++ b/tests/backends/test_transverse_backends.py
@@ -6,10 +6,9 @@
Every backend reads the edge on the host from its state, as ``numpy_ref`` does; the
native Adams march holds its steps to the edge's next read and hands the state back
-there (section 5.3). ``numpy_ref``'s single-precision path is compared with its
-binary64 one. The same run on two backends must then stop at the same read, read the
-same number of times and keep the same record; a requested collar must remove the same
-energy.
+there (section 5.3). The same run on two backends must then stop at the same read,
+read the same number of times and keep the same record; a requested collar must remove
+the same energy.
"""
import dataclasses
@@ -216,41 +215,3 @@ def test_a_backend_that_does_not_read_the_edge_runs_unwatched(
record = edge_record(output)
assert record["transverse_status"] == "unwatched"
assert record["reads"] == 0
-
-
-def single(simulation: Simulation, backend: str) -> Simulation:
- """Return `simulation` on `backend` in binary32, under Runge-Kutta."""
- simulation.settings = dataclasses.replace(
- simulation.settings,
- backend=backend,
- precision="single",
- integrator=RungeKutta(),
- controller=PIController(rtol=1e-5, atol=0.0),
- )
- return simulation
-
-
-def test_numpy_ref_s_binary32_path_carries_the_radial_collar() -> None:
- # Binary32 agrees with binary64 to the single-precision tolerance, and the edge
- # reads alike.
- outputs: list[PlaneStore] = []
- for precision in ("double", "single"):
- simulation = single(radial_collar("numpy_ref", RungeKutta()), "numpy_ref")
- simulation.settings = dataclasses.replace(
- simulation.settings, precision=precision
- )
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", BoundaryWarning)
- output = simulation.run(progress="none")
- assert isinstance(output, PlaneStore)
- outputs.append(output)
- reference, found = outputs
- expected = np.asarray(reference["field"][-1])
- error = float(
- np.linalg.norm(np.asarray(found["field"][-1]) - expected)
- / np.linalg.norm(expected)
- )
- assert error < 1e-5
- first, second = edge_record(reference), edge_record(found)
- assert second["reads"] == first["reads"]
- assert second["transverse_status"] == first["transverse_status"] == "clear"
diff --git a/tests/bounds.py b/tests/bounds.py
index ff810136a..0f5cee3db 100644
--- a/tests/bounds.py
+++ b/tests/bounds.py
@@ -28,10 +28,8 @@
``u`` is the unit roundoff ``2**-53`` and ``EPS = 2 u`` NumPy's machine epsilon; the
two are not interchangeable (SEP 25 section 4.1). Every formula takes its roundoff as a
-parameter, binary64's by default, so that SEP 33's binary32 bounds are the same formulas
-evaluated at :data:`UNIT_ROUNDOFF_32` (or :data:`EPS_32`) rather than binary64 bounds
-scaled: ``gamma_k``'s denominator matters at binary32, and each formula refuses a
-roundoff outside its domain (``k u < 1``). The established ladder of
+parameter, binary64's by default, and refuses a roundoff outside its domain
+(``k u < 1``). The established ladder of
``docs/dev/accuracy.md`` section 7 is written in ``EPS``: :func:`gamma`,
:func:`fft_bound` and :func:`scan_bound` keep its constants, which were fixed before
the first device result. The CPU rows retain their established bounds; the Kokkos
@@ -50,12 +48,6 @@
UNIT_ROUNDOFF = 2.0**-53
"""The unit roundoff ``u`` of binary64 round-to-nearest arithmetic."""
-UNIT_ROUNDOFF_32 = 2.0**-24
-"""The unit roundoff of binary32 round-to-nearest arithmetic (SEP 33)."""
-
-EPS_32 = 2.0**-23
-"""Binary32's machine epsilon, ``2 u``: the ladder's unit at binary32 (SEP 33)."""
-
def gamma(operations: int, eps: float = EPS) -> float:
"""Return the ladder's ``k eps / (1 - k eps)``: twice Higham's gamma, in ``EPS``."""
diff --git a/tests/data/retired_backends/generate.py b/tests/data/retired_backends/generate.py
deleted file mode 100644
index 1574411b2..000000000
--- a/tests/data/retired_backends/generate.py
+++ /dev/null
@@ -1,61 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""Write the Numba files of ``tests/driver/test_retired_backend_runs.py`` (SEP 43).
-
-``numba_cpu`` no longer exists, so these can't be regenerated from this tree. Each came
-from a version that had it, run from outside the checkout:
-
-- ``numba_cpu_0.1.0.h5``: the published 0.1.0, output schema 1, a finished run.
-- ``numba_cpu_stopped.h5``: the last commit before the removal, f8fd5f0, output schema
- 2, a run stopped after its second saved plane.
-"""
-
-import contextlib
-import sys
-
-from solprop.driver import prop_gnlse
-from solprop.log import NullReporter
-
-
-class StopError(RuntimeError):
- """Stands in for a machine going away."""
-
-
-class StopAfter(NullReporter):
- """Stop the run once it has saved enough planes."""
-
- def __init__(self, planes):
- self.planes = planes
- self.seen = 0
-
- def plane(self, z, summary):
- self.seen += 1
- if self.seen >= self.planes:
- raise StopError
-
-
-def main(path: str, stopped: bool) -> None:
- """Write one file."""
- with contextlib.suppress(StopError):
- prop_gnlse(
- 0.11,
- 2e-4,
- [0.0, 0.0, -11.83e-27],
- wavelength=835e-9,
- wavelength_limits=(500e-9, 2e-6),
- trange=1e-12,
- fwhm=50e-15,
- peak_power=1e3,
- shape="sech",
- backend="numba_cpu",
- noise=None,
- output=path,
- n_save=5,
- progress=StopAfter(2) if stopped else "none",
- )
-
-
-if __name__ == "__main__":
- main(sys.argv[1], sys.argv[2] == "stopped")
diff --git a/tests/data/retired_backends/numba_cpu_0.1.0.h5 b/tests/data/retired_backends/numba_cpu_0.1.0.h5
deleted file mode 100644
index 27a19337a..000000000
Binary files a/tests/data/retired_backends/numba_cpu_0.1.0.h5 and /dev/null differ
diff --git a/tests/data/retired_backends/numba_cpu_stopped.h5 b/tests/data/retired_backends/numba_cpu_stopped.h5
deleted file mode 100644
index 693a8986c..000000000
Binary files a/tests/data/retired_backends/numba_cpu_stopped.h5 and /dev/null differ
diff --git a/tests/data/shard_weights.json b/tests/data/shard_weights.json
index e50028485..56241face 100644
--- a/tests/data/shard_weights.json
+++ b/tests/data/shard_weights.json
@@ -5,11 +5,9 @@
"src/solprop/_interrupts.py": 0.0,
"src/solprop/_process.py": 0.1,
"src/solprop/adiabatic.py": 0.0,
- "src/solprop/backends/driver.py": 0.0,
"src/solprop/backends/exponential.py": 0.0,
"src/solprop/backends/low_storage.py": 0.0,
"src/solprop/backends/memory.py": 3.8,
- "src/solprop/backends/numpy_ref/admission.py": 0.1,
"src/solprop/backends/numpy_ref/backend.py": 0.1,
"src/solprop/backends/numpy_ref/general_modal.py": 0.0,
"src/solprop/backends/numpy_ref/passes.py": 0.9,
@@ -26,7 +24,6 @@
"src/solprop/coupling/transport.py": 0.1,
"src/solprop/driver/_scan_model.py": 0.0,
"src/solprop/driver/interface.py": 4.1,
- "src/solprop/driver/memory.py": 5.4,
"src/solprop/driver/run.py": 0.3,
"src/solprop/driver/scaffold.py": 0.9,
"src/solprop/driver/simulation.py": 1.9,
@@ -171,7 +168,6 @@
"tests/backends/test_line_free.py": 52.1,
"tests/backends/test_low_storage.py": 66.0,
"tests/backends/test_luna_mixture_propagation.py": 7.4,
- "tests/backends/test_memory.py": 409.8,
"tests/backends/test_mixture_channels.py": 1.2,
"tests/backends/test_mixture_ladder.py": 66.2,
"tests/backends/test_mixture_native_threads.py": 4.9,
@@ -255,7 +251,6 @@
"tests/driver/test_low_storage_settings.py": 0.0,
"tests/driver/test_material_electronic.py": 1.5,
"tests/driver/test_material_manifest.py": 31.4,
- "tests/driver/test_memory.py": 8.5,
"tests/driver/test_mixture_interface.py": 84.5,
"tests/driver/test_mixture_interface_baseline.py": 5.1,
"tests/driver/test_mixture_update.py": 19.6,
@@ -497,7 +492,6 @@
"tests/test_tools.py": 131.2,
"tests/test_vector_raman_oracles.py": 0.4,
"tests/translate/test_batched_sampling.py": 45.5,
- "tests/translate/test_cf4.py": 0.2,
"tests/translate/test_chebyshev.py": 24.9,
"tests/translate/test_choked_tables.py": 7.0,
"tests/translate/test_compiled_responses.py": 9.4,
diff --git a/tests/driver/test_batch.py b/tests/driver/test_batch.py
index 0acb19c3e..74ce09778 100644
--- a/tests/driver/test_batch.py
+++ b/tests/driver/test_batch.py
@@ -24,7 +24,7 @@
)
from solprop.driver.scan import Scan
from solprop.driver.simulation import Simulation
-from solprop.exceptions import DriverError
+from solprop.exceptions import BackendError, DriverError
from solprop.grid import RadialGrid
from solprop.pulses import GaussianPulse, SpatialPulse
from solprop.spatial import SpatialMedium
@@ -124,11 +124,10 @@ def planned(problem, options):
@pytest.mark.parametrize("backend", ["jax", "numba", "unregistered_backend"])
def test_batch_refuses_unsupported_backends_by_name(backend):
- # On the Runge-Kutta march: the default Adams integrator refuses a backend that
- # does not implement it when the settings are built, before a batch is formed.
- point = member(0, backend=backend, integrator=RungeKutta())
- with pytest.raises(DriverError, match=backend):
- list(compatible_groups([point], 2))
+ # A name that is neither backend is refused by the registry when the member's
+ # settings are built (ADR-077), so no batch is ever formed for it.
+ with pytest.raises(BackendError, match=rf"^'{backend}' is not a backend"):
+ member(0, backend=backend, integrator=RungeKutta())
@pytest.mark.parametrize("backend", ["numpy_ref", "native"])
diff --git a/tests/driver/test_coherent_capillary.py b/tests/driver/test_coherent_capillary.py
index 9b7b4732d..c0b3f9d3d 100644
--- a/tests/driver/test_coherent_capillary.py
+++ b/tests/driver/test_coherent_capillary.py
@@ -20,7 +20,7 @@
import warnings
from collections.abc import Mapping, Sequence
from dataclasses import replace
-from typing import Literal, cast
+from typing import cast
import mpmath
import numpy as np
@@ -37,13 +37,10 @@
from modal_provider_cases import GENERAL_Z_END, general_grid, general_guide
from solprop.backends import available
from solprop.backends.numpy_ref import NumpyRefBackend
-from solprop.backends.numpy_ref.admission import refuse_coupled_flow
from solprop.backends.numpy_ref.backend import (
_MAX_FLOW_SQUARINGS,
- _coupled_flow_maps,
_triangular_exponential,
)
-from solprop.backends.numpy_ref.backend import _cf4_action as _backend_cf4_action
from solprop.boundaries import absorber
from solprop.constants import EPS0, C
from solprop.driver import Simulation, prop_capillary, prop_gnlse
@@ -83,13 +80,10 @@
from solprop.spatial import SpatialMedium
from solprop.stats import StatsSpec
from solprop.stepper import DOPRI5, FixedSchedule, RungeKutta
+from solprop.translate.admission import check_general_route
from solprop.translate.coupled import (
- CF4_GROWTH_LIMIT,
SECTION_TOL,
- _cf4_action,
advisory_step,
- cf4_defect,
- cf4_growth_bound,
constant_schur_flow,
flow_weights,
frozen_generator,
@@ -98,7 +92,6 @@
residual_truncation,
schur_blocks,
section_expansion,
- wall_identity,
)
from solprop.translate.linop import delta_phi
from solprop.translate.problem import (
@@ -170,11 +163,6 @@ def kept(field: np.ndarray | None) -> np.ndarray:
return field
-def _steps_cf4(wall) -> bool:
- """Return whether a wall declares CF4 sections, which only the RK march steps."""
- return wall is not None and getattr(wall, "method", None) == "cf4"
-
-
def capillary(
*,
wall=None,
@@ -208,9 +196,6 @@ def capillary(
"plasma": False,
"raman": False,
} | overrides
- if _steps_cf4(wall) and "integrator" not in arguments:
- # CF4 sections are stepped by the Runge-Kutta march alone.
- arguments["integrator"] = RungeKutta()
return Simulation.from_capillary(
radius,
LENGTH,
@@ -245,9 +230,6 @@ def run_capillary(
"plasma": False,
"raman": False,
} | overrides
- if _steps_cf4(wall) and "integrator" not in arguments:
- # CF4 sections are stepped by the Runge-Kutta march alone.
- arguments["integrator"] = RungeKutta()
radius = arguments.pop("radius_override", RADIUS)
return cast(
"MemoryOutput",
@@ -883,15 +865,15 @@ def spoiled(**changes):
)
# Refused by the Adams path's check or the Runge-Kutta march's, by name either way.
- with pytest.raises(BackendError, match=r"'diagonal'"):
+ with pytest.raises((BackendError, TranslationError), match=r"'diagonal'"):
NumpyRefBackend(spoiled(section_methods=("diagonal",)), run_request, grid=grid)
- with pytest.raises(BackendError, match="executes no taper connection"):
+ with pytest.raises(TranslationError, match="executes no taper connection"):
NumpyRefBackend(
spoiled(connection=np.zeros((1, 1, 3, 3, grid.n_omega), dtype=complex)),
run_request,
grid=grid,
)
- with pytest.raises(BackendError, match="no explicit"):
+ with pytest.raises(TranslationError, match="no explicit"):
NumpyRefBackend(
spoiled(residual_norm_bound=np.ones((1, grid.n_omega))),
run_request,
@@ -899,7 +881,7 @@ def spoiled(**changes):
)
assert flow.generator is not None
doubled = np.concatenate([flow.generator, flow.generator], axis=1)
- with pytest.raises(BackendError, match="Chebyshev degrees along z"):
+ with pytest.raises(TranslationError, match="Chebyshev degrees along z"):
NumpyRefBackend(spoiled(generator=doubled), run_request, grid=grid)
factored = replace(
problem,
@@ -915,7 +897,7 @@ def spoiled(**changes):
),
),
)
- with pytest.raises(BackendError, match="dense generator"):
+ with pytest.raises(TranslationError, match="dense generator"):
NumpyRefBackend(factored, run_request, grid=grid)
@@ -929,7 +911,7 @@ def test_the_reference_refuses_a_problem_the_flow_cannot_sit_beside():
assert constant is not None
varied = constant.copy()
varied[0, 1] *= 1.0000001
- with pytest.raises(BackendError, match="mode-independent Phi"):
+ with pytest.raises(TranslationError, match="mode-independent Phi"):
NumpyRefBackend(
replace(problem, linear=replace(problem.linear, phi_constant=varied)),
run_request,
@@ -942,7 +924,7 @@ def test_the_reference_refuses_a_problem_the_flow_cannot_sit_beside():
gauge_id="lab",
certificate_id="sampled-gram",
)
- with pytest.raises(BackendError, match="no explicit metric"):
+ with pytest.raises(TranslationError, match="no explicit metric"):
NumpyRefBackend(
replace(problem, linear=replace(problem.linear, modal_metric=metric)),
run_request,
@@ -952,11 +934,11 @@ def test_the_reference_refuses_a_problem_the_flow_cannot_sit_beside():
# second polarisation would be mixed by it as though it described both. The
# refusal is taken on the check itself, because a state whose polarisation axis
# disagrees with the rest of the problem is refused by `PropagationProblem.validate`
- # before any backend sees it -- which is exactly why this one is defence in
- # depth rather than the only door.
+ # for its shape as well -- which is exactly why this one is defence in depth
+ # rather than the only door.
channels, _, bins = problem.shape
- with pytest.raises(BackendError, match="only for a scalar state") as refused:
- refuse_coupled_flow(replace(problem, shape=(channels, 2, bins)))
+ with pytest.raises(TranslationError, match="only for a scalar state") as refused:
+ check_general_route(replace(problem, shape=(channels, 2, bins)))
assert "not the state's polarisation axis" in str(refused.value)
# It names the state's two components, the product route, and not the
# synthesis, which may resolve two.
@@ -1010,7 +992,7 @@ def test_the_reference_refuses_a_flow_beside_a_varying_phi():
dz_max=run_request.stepper.dz_max,
edges=edges,
)
- with pytest.raises(BackendError, match="exact constant representation"):
+ with pytest.raises(TranslationError, match="exact constant representation"):
NumpyRefBackend(spoiled, run_request, grid=simulation.grid)
@@ -1029,7 +1011,7 @@ def test_the_reference_refuses_a_noise_draw_beside_a_flow():
),
)
with pytest.raises(
- BackendError, match="no 'modified' noise model beside a coupled flow"
+ TranslationError, match="no 'modified' noise model beside a coupled flow"
):
NumpyRefBackend(noisy, run_request, grid=grid)
@@ -1599,9 +1581,6 @@ def varying(
"plasma": False,
"raman": False,
} | overrides
- if _steps_cf4(wall) and "integrator" not in arguments:
- # CF4 sections are stepped by the Runge-Kutta march alone.
- arguments["integrator"] = RungeKutta()
return Simulation.from_capillary(
taper() if radius is None else radius,
LENGTH,
@@ -2158,11 +2137,11 @@ def test_a_frozen_residual_flow_is_refused_beside_a_carrier_it_cannot_share(
"""
problem = varying(wall=CoherentWall()).translate()
monkeypatch.setattr(type(problem), "device_ready", property(lambda self: False))
- with pytest.raises(BackendError, match="took the z table"):
- refuse_coupled_flow(problem)
+ with pytest.raises(TranslationError, match="took the z table"):
+ check_general_route(problem)
constant = capillary(wall=CoherentWall()).translate()
- with pytest.raises(BackendError, match="exact constant representation"):
- refuse_coupled_flow(
+ with pytest.raises(TranslationError, match="exact constant representation"):
+ check_general_route(
replace(
problem,
linear=replace(
@@ -3030,9 +3009,6 @@ def _vector_taper_run(**overrides) -> MemoryOutput:
"plasma": False,
"raman": False,
} | overrides
- if _steps_cf4(arguments["wall"]) and "integrator" not in arguments:
- # CF4 sections are stepped by the Runge-Kutta march alone.
- arguments["integrator"] = RungeKutta()
return cast(
"MemoryOutput",
prop_capillary(taper(), LENGTH, "He", 1.0, **{"noise": None, **arguments}),
@@ -3358,18 +3334,16 @@ def test_on_a_taper_the_families_exchange_power_only_through_the_wall_anisotropy
assert float(power[1]) > 0.05, power
-def _vector_refined(
- steps, method: Literal["frozen_residual", "cf4"] = "frozen_residual"
-):
+def _vector_refined(steps):
"""Return the final plane of the vector taper on a schedule of `steps`.
Stepped by the Runge-Kutta march: the refinement measures DOPRI5's fifth order on
- the frozen split, and CF4 sections are stepped by that march alone.
+ the frozen split.
"""
return propagated(
*translated(
varying(
- wall=CoherentWall(impedance="te_tm", method=method),
+ wall=CoherentWall(impedance="te_tm"),
integrator=RungeKutta(),
modes=vector_modes(radius=taper()),
schedule=FixedSchedule(np.full(steps, LENGTH / steps)),
@@ -3382,18 +3356,13 @@ def _vector_refined(
@pytest.mark.medium
-def test_the_vector_taper_converges_at_each_methods_own_order():
- """Independent refinement: fifth order frozen, fourth order CF4, one reference.
+def test_the_vector_taper_converges_at_the_frozen_split_s_order():
+ """Independent refinement: fifth order frozen, against one reference.
Bounds, stated first. The embedded DOPRI5 pair integrates the frozen split's
explicit residual at fifth order, so halving the step divides the departure from a
160-step frozen run by 32 -- gate an order above 4.0 on the first halving, and a
- ten-step departure below 1e-8 of the state (measured 6.2e-11, then 4.82). CF4
- integrates the same stored expansion whole and is at most fourth order: gate an
- order between 3.8 and 4.2 on both halvings (measured 3.996 and 3.999; 3.993 and
- 3.998 on the scalar taper), which is what "CF4 composes with the vector block
- trivially" means -- it reads the expansion and the connection and nothing of the
- block that built them.
+ ten-step departure below 1e-8 of the state (measured 6.2e-11, then 4.82).
"""
reference = _vector_refined(STEPS * 16)
scale = float(np.max(abs(reference)))
@@ -3403,12 +3372,6 @@ def test_the_vector_taper_converges_at_each_methods_own_order():
]
assert frozen[0] < 1e-8, frozen
assert math.log2(frozen[0] / frozen[1]) > 4.0, frozen
- cf4 = [
- float(np.max(abs(_vector_refined(STEPS * count, "cf4") - reference))) / scale
- for count in (1, 2, 4)
- ]
- orders = [math.log2(cf4[index] / cf4[index + 1]) for index in range(2)]
- assert all(3.8 < order < 4.2 for order in orders), (cf4, orders)
def test_the_vector_flow_record_survives_a_file_and_a_resume_checks_it(tmp_path):
@@ -3431,533 +3394,6 @@ def test_the_vector_flow_record_survives_a_file_and_a_resume_checks_it(tmp_path)
capillary(wall=CoherentWall(), modes=1).run(path, resume=True)
-# ------------------------------------------------------------ the CF4 varying flow
-
-CF4_WALL = CoherentWall(method="cf4")
-"""The commutator-free fourth-order alternative on the qualification taper."""
-
-
-def test_a_cf4_wall_declares_cf4_sections_on_the_frozen_split_s_expansion():
- """The same expansion, a different certificate: no residual, the pair bound.
-
- `method="cf4"` changes what the sections are certified as and nothing about what
- they carry: the generator and the connection are the frozen-residual leaf's to the
- last bit, because both come from one `section_expansion` of one profile. What
- differs is the rows. Bound, stated first: the pair conditioning is
- `exp((2/sqrt(3)) dz_max (m_hi - m_lo))` over the Weyl enclosure of each step's
- reach, at least the frozen block's own `exp(dz_max (mu_max - mu_min))` and here
- 1.0190 against 1.0068 to 1.0132; the growth bound is that to the power
- `(2 - sqrt(3))/4`,
- 1.00127 at worst -- the qualification taper is deep inside the asymptotic regime
- the growth-bound guard admits.
- """
- flow = varying(wall=CF4_WALL).translate().linear.coupled_flow
- frozen = varying(wall=CoherentWall()).translate().linear.coupled_flow
- assert flow is not None and frozen is not None
- assert flow.section_methods == ("cf4",) * 4
- assert flow.certificate_id == "cf4"
- assert flow.generator is not None and frozen.generator is not None
- assert np.array_equal(flow.generator, frozen.generator)
- assert flow.connection is not None and frozen.connection is not None
- assert np.array_equal(flow.connection, frozen.connection)
- assert np.all(flow.residual_norm_bound == 0.0)
- assert np.all(np.isinf(advisory_step(flow)))
- assert np.all(flow.inverse_condition_bound >= frozen.inverse_condition_bound)
- assert float(np.max(flow.inverse_condition_bound)) < 1.02
- assert 1.0 < float(np.max(cf4_growth_bound(flow))) < 1.0013
-
-
-def test_the_executor_and_the_estimator_are_two_implementations_of_one_step():
- """`numpy_ref`'s scaled Taylor series and SciPy's exponential agree on every step.
-
- The translation layer's estimator forms a CF4 step with `scipy.linalg.expm`; the
- backend forms it with its own scaled Taylor series, from the stage bundle's
- evaluation form rather than the leaf's arrays. Bound, stated first: the two
- agree to a few ulp of the action's norm (measured 2.2e-16 at worst over four
- steps, one per section), the backend's inverse is the inverse of its forward to
- rounding (7.8e-16), and the four factor norms it measures stay under the declared
- pair bound -- 1.0132 against 1.0153 on a straight section, which is the
- `1/(1 + 4|a|) = sqrt(3)/2` of the bound's logarithm a constant generator reaches
- exactly (measured 0.8660241), and 1.0073 against 1.0190 on the transition.
- """
- problem = varying(wall=CF4_WALL).translate()
- flow = problem.linear.coupled_flow
- assert flow is not None and flow.generator is not None
- assert flow.connection is not None
- held = _coupled_flow_maps(problem)
- assert held is not None
- terms = (flow.generator - flow.connection).reshape(-1, *flow.generator.shape[2:])
- for start, length, section in (
- (0.0, 2e-5, 0),
- (6e-5, 2e-5, 1),
- (1.0e-4, 1e-5, 1),
- (1.5e-4, 2e-5, 2),
- ):
- reference = _cf4_action(flow, terms, start, length, section)[held.active]
- forward, inverse, growth = _backend_cf4_action(held, start, length, section)
- departure = np.linalg.norm(forward - reference, 2, axis=(-2, -1))
- assert np.all(
- departure
- <= 8 * np.finfo(float).eps * np.linalg.norm(reference, 2, axis=(-2, -1))
- )
- identity = np.linalg.norm(forward @ inverse - np.eye(3), 2, axis=(-2, -1))
- assert float(np.max(identity)) < 8 * np.finfo(float).eps
- declared = flow.inverse_condition_bound[section][held.active]
- assert np.all(growth <= declared)
- straight = _backend_cf4_action(held, 1.5e-4, 2e-5, 2)[2]
- bound = flow.inverse_condition_bound[2][held.active]
- ratio = np.log(straight) / np.log(bound)
- # To 1.3e-6 (measured 1.28e-6). This is not the samples' round-off: it was the
- # same 1.28e-6 before and after the wall's diagonal stopped carrying the rounding
- # of a difference of two wavenumbers, and the straight section's
- # residual enclosure is exactly zero. Its source is not established here.
- assert float(np.max(ratio)) == pytest.approx(math.sqrt(3.0) / 2.0, abs=1e-5)
-
-
-def _taper_generator(flow):
- """Return ``z -> L(z)`` on the active bins, frequency first, from the leaf."""
- assert flow.generator is not None and flow.connection is not None
- terms = (flow.generator - flow.connection).reshape(-1, *flow.generator.shape[2:])
- active = np.any(flow.generator != 0.0, axis=(0, 1, 2, 3))
-
- def generator(z):
- combined = np.tensordot(flow_weights(flow, z), terms, axes=1)
- return np.moveaxis(combined, -1, 0)[active]
-
- return generator, terms, active
-
-
-def test_the_step_doubling_defect_tracks_the_true_error_on_the_lossy_taper():
- """The step-doubling estimator against an independent solve of the flow alone.
-
- The taper's generator is lossy -- the Hermitian part of `L` spans -373 to +21 1/m
- relative to the carrier -- and nonnormal: Henrici's departure
- `||L L^H - L^H L||_F / ||L||_F^2` reaches 4.7e-2 at the transition's middle. The
- reference is `solve_ivp`'s DOP853 on `u' = L(z) u` at rtol 1e-13, which shares
- nothing with the CF4 scheme but the stored expansion. Predeclared: at the
- lowest, the middle and the highest active bin, one CF4 step from the start of the
- transition has a true relative error within 1 % of the step-doubling estimate
- `(16/15) ||V(h) - V(h/2)^2|| / ||V(h/2)^2||`, at 20, 10 and 5 micrometres, and
- both fall at the step's fifth power. Measured ratios 0.99987 to 0.99999, true
- errors 6.19e-8, 1.79e-9 and 5.39e-11 at the lowest bin.
- """
- flow = varying(wall=CF4_WALL).translate().linear.coupled_flow
- assert flow is not None
- generator, terms, active = _taper_generator(flow)
- count = int(np.count_nonzero(active))
- truths = []
- for length in (2e-5, 1e-5, 5e-6):
- coarse = _cf4_action(flow, terms, 6e-5, length, 1)[active]
- fine = (
- _cf4_action(flow, terms, 6e-5 + 0.5 * length, 0.5 * length, 1)[active]
- @ _cf4_action(flow, terms, 6e-5, 0.5 * length, 1)[active]
- )
- for index in (0, count // 2, count - 1):
- solution = solve_ivp(
- lambda z, state, index=index: (
- generator(z)[index] @ state.reshape(3, 3)
- ).ravel(),
- (6e-5, 6e-5 + length),
- np.eye(3, dtype=complex).ravel(),
- method="DOP853",
- rtol=1e-13,
- atol=1e-16,
- )
- exact = solution.y[:, -1].reshape(3, 3)
- truth = np.linalg.norm(coarse[index] - exact, 2) / np.linalg.norm(exact, 2)
- estimate = (
- 16.0
- / 15.0
- * np.linalg.norm(coarse[index] - fine[index], 2)
- / np.linalg.norm(fine[index], 2)
- )
- assert abs(estimate / truth - 1.0) < 1e-2, (length, index)
- if index == 0:
- truths.append(float(truth))
- orders = np.log2(np.array(truths[:-1]) / np.array(truths[1:]))
- assert np.all(abs(orders - 5.0) < 0.3), orders
-
-
-@pytest.mark.medium
-def test_a_cf4_run_converges_at_fourth_order_against_the_frozen_split():
- """Independent refinement: CF4's schedule against the frozen split's finest run.
-
- The combined method is **at most fourth order**: a fourth-order flow inside a
- fifth-order Runge-Kutta source. The reference is the other method -- the frozen
- split on 160 steps, fifth order and already 3.4e-9 at ten -- so that the refinement
- is not the scheme checked against itself. Predeclared: the observed orders over 10,
- 20 and 40 steps within 4 +- 0.3. Measured 3.99 and 4.00, from 4.71e-8 of the state
- at ten steps; the frozen split's own error there is 3.45e-9, so on this taper and
- this step CF4 is the less accurate method by a factor 14.
-
- Bound, stated first, on the ten-step error: the sum of the ten steps' estimated
- local defects, `10 * max cf4_defect(h) * max ||U||||U^-1||`, is 1.76e-6 -- an
- asymptotic estimate, not a proof -- and the measured error sits under it.
- """
- reference = _refined(STEPS * 16)
- scale = float(np.max(abs(reference)))
- departures = []
- for count in (1, 2, 4):
- steps = STEPS * count
- final = propagated(
- *translated(
- varying(
- wall=CF4_WALL,
- schedule=FixedSchedule(np.full(steps, LENGTH / steps)),
- dz_max=LENGTH / steps,
- dz_init=LENGTH / steps,
- )
- ),
- GRID,
- )
- departures.append(float(np.max(abs(final - reference))) / scale)
- orders = np.log2(np.array(departures[:-1]) / np.array(departures[1:]))
- assert np.all(abs(orders - 4.0) < 0.3), (departures, orders)
- flow = varying(wall=CF4_WALL).translate().linear.coupled_flow
- assert flow is not None
- estimate = (
- STEPS
- * float(np.max(cf4_defect(flow, LENGTH / STEPS)))
- * float(np.max(flow.inverse_condition_bound))
- )
- assert departures[0] < estimate
- frozen = float(np.max(abs(_refined(STEPS) - reference))) / scale
- assert frozen < departures[0]
-
-
-def test_the_growth_guard_falls_back_by_section_only_where_declared():
- """A limit between the sections' bounds rejects some; the declaration decides what.
-
- The transition's two sections carry growth bounds 1.00125 and 1.00127 at
- `dz_max` = 20 um, the
- straight tail 1.00102; a limit of 1.0011 lies between. Without a fallback the
- translation refuses by name; with `fallback="frozen_residual"` the rejected
- sections take the frozen split, the warning names them, the leaf mixes the two
- methods, and those sections carry exactly the frozen rows.
- """
- with pytest.raises(TranslationError, match=r"reject 2 of its 4 section"):
- varying(wall=CoherentWall(method="cf4", growth_limit=1.0011)).translate()
- with pytest.warns(SolpropWarning, match=r"section 0 has internal growth bound"):
- problem = varying(
- wall=CoherentWall(
- method="cf4", growth_limit=1.0011, fallback="frozen_residual"
- )
- ).translate()
- flow = problem.linear.coupled_flow
- frozen = varying(wall=CoherentWall()).translate().linear.coupled_flow
- assert flow is not None and frozen is not None
- assert flow.section_methods == ("frozen_residual", "frozen_residual", "cf4", "cf4")
- for name in ("generator_error", "residual_norm_bound", "inverse_condition_bound"):
- assert np.array_equal(getattr(flow, name)[:2], getattr(frozen, name)[:2])
- assert np.all(flow.residual_norm_bound[2:] == 0.0)
-
-
-@pytest.mark.medium
-def test_a_mixed_flow_runs_each_section_by_its_own_method():
- """Ten steps of the mixed leaf land between the two pure runs' own errors.
-
- Each step is integrated by the method of the section its anchor lies in -- a
- frozen block with the explicit residual, or CF4 with none -- so the mixed run
- solves the same equation and must agree with the frozen split's 160-step reference
- to within the larger of the two pure runs' own ten-step errors, 4.71e-8 (CF4).
- Measured 3.45e-9, the frozen split's own: the two sections rejected are the
- entrance and the transition, and the straight tail CF4 keeps is a constant
- generator, which it integrates exactly.
- """
- reference = _refined(STEPS * 16)
- scale = float(np.max(abs(reference)))
- with pytest.warns(SolpropWarning, match="fall back"):
- problem, run_request = translated(
- varying(
- wall=CoherentWall(
- method="cf4", growth_limit=1.0011, fallback="frozen_residual"
- )
- )
- )
- mixed = (
- float(np.max(abs(propagated(problem, run_request, GRID) - reference))) / scale
- )
- assert mixed < 4.8e-8
-
-
-def test_the_reference_measures_the_cf4_factors_against_the_declared_bound():
- """A pair bound declared too small is caught by the factors the backend forms.
-
- The growth-bound guard at the executor: with the conditioning row of every CF4
- section set to one -- a bound no dissipative generator meets -- the first stage's
- measured four-factor product, 1.0073 on the transition, is refused by name.
- """
- simulation = varying(wall=CF4_WALL)
- problem = simulation.translate()
- flow = problem.linear.coupled_flow
- assert flow is not None
- understated = replace(
- problem,
- linear=replace(
- problem.linear,
- coupled_flow=replace(
- flow, inverse_condition_bound=np.ones_like(flow.inverse_condition_bound)
- ),
- ),
- )
- with pytest.raises(BackendError, match="CF4 factor-norm product"):
- propagated(understated, simulation._run_request(), simulation.grid)
-
-
-def test_a_cf4_step_above_the_estimated_defect_warns_by_name():
- """The step-doubling diagnostic: a declared step whose defect exceeds the tolerance.
-
- The embedded pair does not see a CF4 flow's error, so `validate` compares the
- step-doubling estimate at the largest declared step with the controller's
- relative tolerance and warns, advisory like the advisory step. At `dz_max` =
- 100 um the transition's estimate is 1.02e-4 -- over its whole 80 um, because a
- step ends at the next hard kink -- a hundred times the default tolerance of
- 1e-6; at the ten-step schedule's 20 um it is 1.72e-7, under it, and nothing is
- said.
- """
- with (
- warnings.catch_warnings(),
- pytest.warns(SolpropWarning, match="estimated local defect"),
- ):
- warnings.simplefilter("ignore", MaterialRangeWarning)
- warnings.simplefilter("ignore", GrazingIncidenceWarning)
- varying(wall=CF4_WALL, dz_max=1e-4, dz_init=1e-4, schedule=None).translate()
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- varying(wall=CF4_WALL).translate()
- assert not [one for one in caught if "estimated local defect" in str(one.message)]
-
-
-def test_cf4_is_refused_wherever_it_would_be_ignored():
- """Every door a CF4 declaration passes, each refusing what it cannot honour.
-
- The declaration: an unknown method, and a growth limit or a fallback without CF4 --
- or out of range with it. The translation layer: CF4 on a guide that does not vary,
- whose constant-Schur flow is already exact, for either wall impedance. CF4 beside
- the vector block is **admitted**: it integrates the stored expansion and reads
- nothing of the block (`test_the_vector_taper_converges_at_each_methods_own_order`).
- """
- assert CoherentWall(method="cf4", impedance="te_tm").method == "cf4"
- with pytest.raises(ModeError, match="read only with method='cf4'"):
- CoherentWall(growth_limit=3.0)
- with pytest.raises(ModeError, match="read only with method='cf4'"):
- CoherentWall(fallback="frozen_residual")
- for bad in (0.5, math.inf):
- with pytest.raises(ModeError, match="at least one"):
- CoherentWall(method="cf4", growth_limit=bad)
- for field, bad, match in (
- ("method", "midpoint", "'frozen_residual' or 'cf4'"),
- ("fallback", "midpoint", "which integrates it"),
- ):
- # Built without `__init__`: the test session's runtime type check stops a
- # value outside the `Literal` at the annotation, and production has no hook.
- wall = object.__new__(CoherentWall)
- for name, value in (
- ("condition_limit", 1.0e4),
- ("validity_limit", 0.25),
- ("sections", 1),
- ("impedance", "mean"),
- ("method", "cf4"),
- ("growth_limit", None),
- ("fallback", None),
- ):
- object.__setattr__(wall, name, value)
- object.__setattr__(wall, field, bad)
- with pytest.raises(ModeError, match=match):
- wall.__post_init__()
- with pytest.raises(TranslationError, match="its one constant section"):
- capillary(wall=CF4_WALL).translate()
- with pytest.raises(TranslationError, match="its one constant section"):
- capillary(
- wall=CoherentWall(method="cf4", impedance="te_tm"),
- modes=vector_modes(counts=(1, 1)),
- ).translate()
- assert repr(CoherentWall(method="cf4", growth_limit=3.0)).endswith(
- "method=cf4, growth_limit=3, fallback=None)"
- )
- assert "growth limit 3, fallback None" in str(
- CoherentWall(method="cf4", growth_limit=3.0)
- )
- assert "TE/TM impedances, paraxial, frozen per section" in str(VECTOR_WALL)
- assert "TE/TM impedances, paraxial, commutator-free per section" in str(
- CoherentWall(method="cf4", impedance="te_tm")
- )
-
-
-def test_a_cf4_wall_refuses_every_declaration_the_frozen_one_refuses():
- """The varying route's doors stand for CF4 too: noise, polarisation, batch, kinks.
-
- The rule: a CF4 taper reaches every door the frozen one does rather
- than slipping past on its new branch. The batch axis is refused by the reference
- backend's own `max_batch = 1` before a coupled flow is looked at, and a fixed
- schedule straddling the taper's corner by `flow_edge_slack`.
- """
- with pytest.raises(TranslationError, match="refuses the 'modified' noise"):
- varying(wall=CF4_WALL, noise="modified", seed=1).translate()
- with pytest.raises(DriverError, match="polarisation"):
- varying(wall=CF4_WALL, polarisation="linear").translate()
- with pytest.raises(DriverError, match="loss=False"):
- varying(wall=CF4_WALL, loss=False).translate()
- edges = np.linspace(0.0, LENGTH, STEPS + 1)
- edges[3] -= 1e-13
- with pytest.raises(TranslationError, match=r"does not land on it.*coupled_flow"):
- varying(wall=CF4_WALL, schedule=FixedSchedule(np.diff(edges))).translate()
- simulation = varying(wall=CF4_WALL)
- problem = simulation.translate()
- channels, _, bins = problem.shape
- with pytest.raises(BackendError, match="only for a scalar state") as refused:
- refuse_coupled_flow(replace(problem, shape=(channels, 2, bins)))
- assert "not the state's polarisation axis" in str(refused.value)
- # It names the state's two components, the product route, and not the
- # synthesis, which may resolve two.
- assert "a state with two polarisation components" in str(refused.value)
- # `native` executes the frozen flow and refuses a CF4 one at the entry point: CF4 is
- # not yet implemented on the compiled backend. "auto" follows when it selects it.
- for name in ("native", *(("auto",) if "native" in available() else ())):
- with pytest.raises(DriverError, match=r"method='cf4'"):
- varying(wall=CF4_WALL, backend=name).translate()
-
-
-@pytest.mark.medium
-def test_prop_capillary_runs_a_cf4_wall_end_to_end_and_records_its_guards():
- """The entry point runs the CF4 taper, and the record carries both guards.
-
- Ten steps through `prop_capillary(taper, ..., wall=CoherentWall(method="cf4"))`.
- Bound, stated first: the run solves the equation the frozen split does, so
- its final plane agrees with that run's to the two methods' own ten-step errors,
- 4.71e-8 and 3.45e-9 of the state (measured 4.58e-8), and every channel's energy
- fraction with it; the record names four `"cf4"` sections, no advisory limit, the
- growth bound 1.00127 against the default limit 2 and the step-doubling defect
- 1.72e-7 at the declared step, named as the asymptotic estimator it is.
- """
- arguments = {
- "wavelength": 800e-9,
- "grid": GRID,
- "absorber": WEAK_COLLAR,
- "fwhm": 30e-15,
- "energy": 1e-9,
- "modes": 3,
- "n_save": 2,
- "backend": "numpy_ref",
- "dz_max": LENGTH / STEPS,
- "dz_init": LENGTH / STEPS,
- "schedule": FixedSchedule(np.full(STEPS, LENGTH / STEPS)),
- "plasma": False,
- "raman": False,
- # CF4 sections are stepped by the Runge-Kutta march alone, and the frozen
- # run is compared with them on the same steps.
- "integrator": RungeKutta(),
- }
- walled = cast(
- "MemoryOutput",
- prop_capillary(
- taper(),
- LENGTH,
- "He",
- 1.0,
- wall=CF4_WALL,
- **{"noise": None, **arguments},
- ),
- )
- frozen = cast(
- "MemoryOutput",
- prop_capillary(
- taper(),
- LENGTH,
- "He",
- 1.0,
- wall=CoherentWall(),
- **{"noise": None, **arguments},
- ),
- )
- written = walled.metadata
- assert written is not None and written.modal is not None
- record = cast("Mapping[str, object]", written.modal["flow"])
- assert record["section_methods"] == ["cf4"] * 4
- assert record["certificate_id"] == "cf4"
- assert math.isinf(float(cast("float", record["advisory_step"])))
- guards = cast("Mapping[str, object]", record["cf4"])
- assert guards["growth_limit"] == CF4_GROWTH_LIMIT
- assert guards["fallback"] is None
- assert guards["defect_estimator"] == "step doubling, asymptotic"
- assert 1.0 < float(cast("float", guards["growth_bound"])) < 1.0013
- assert 1e-7 < float(cast("float", guards["defect"])) < 1e-6
- field = kept(walled.planes()[-1].field)
- control = kept(frozen.planes()[-1].field)
- scale = float(np.max(abs(control)))
- assert float(np.max(abs(field - control))) / scale < 4.71e-8 + 3.45e-9
- assert_allclose(_channel_fractions(walled), _channel_fractions(frozen), atol=1e-7)
- control_metadata = frozen.metadata
- assert control_metadata is not None and control_metadata.modal is not None
- assert "cf4" not in cast("Mapping[str, object]", control_metadata.modal["flow"])
-
-
-def test_the_cf4_record_needs_its_declaration():
- """A CF4 flow recorded without its growth limit or its step is refused by name."""
- simulation = varying(wall=CF4_WALL)
- flow = simulation.translate().linear.coupled_flow
- assert flow is not None
- limits = worst_wall_limits(
- cast("Sequence[MarcatiliMode]", simulation.modes),
- GRID.omega,
- positions=[0.0],
- active=GRID.active_mask,
- )
- with pytest.raises(TranslationError, match="pass growth_limit and dz_max"):
- wall_identity(
- flow, limits, channels=[mode.channel_label for mode in simulation.modes]
- )
-
-
-def _six_channel_taper(wall, dz):
- """Return the qualification taper with six channels on a uniform schedule."""
- return propagated(
- *translated(
- varying(
- wall=wall,
- modes=6,
- schedule=FixedSchedule.uniform(
- 0.0,
- LENGTH,
- dz,
- save_planes=[0.0, LENGTH],
- hard_kinks=list(SMOOTH_STAGES[1:3]),
- ),
- dz_max=dz,
- dz_init=dz,
- )
- ),
- GRID,
- )
-
-
-@pytest.mark.slow
-# Both advisory diagnostics fire at this step on purpose: it is beyond the frozen
-# split's advisory limit and CF4's estimated defect, which is the regime measured.
-@pytest.mark.filterwarnings("ignore:the coherent wall's")
-def test_cf4_wins_at_a_step_beyond_the_frozen_split_s_advisory_limit_and_not_below():
- """Where CF4 is better: at equal step, only once the frozen residual is stiff.
-
- The frozen split's explicit residual has an advisory step `0.9/(K cond)`, 2.2e-5 m
- on the six-channel taper (K = 3.3e4 1/m). Below it the fifth-order split is the more
- accurate at the same step (the three-channel qualification taper at 20 um: 3.45e-9
- against CF4's 4.71e-8, in the medium tier); at three times it, CF4 is, because it
- has no explicit linear term to be stiff. Predeclared: at four steps of at most
- 6.7e-5 m, CF4's error against the frozen split's 320-step reference is below the
- frozen split's own; measured 4.75e-6 against 1.78e-5. **At equal cost the frozen
- split still wins** -- CF4 costs 3.3 times as much per step (measured) and the frozen
- split gains 32 per halving -- which is why CF4 is the certified alternative and not
- the default.
- """
- reference = _six_channel_taper(CoherentWall(sections=4), LENGTH / 320)
- scale = float(np.max(abs(reference)))
- frozen = float(
- np.max(abs(_six_channel_taper(CoherentWall(), LENGTH / 3) - reference))
- )
- cf4 = float(np.max(abs(_six_channel_taper(CF4_WALL, LENGTH / 3) - reference)))
- assert cf4 < frozen, (cf4 / scale, frozen / scale)
-
-
def _taper_note_bore(count: int) -> Simulation:
"""Return the taper study's narrow end as a straight bore: 62.5 um at 800 nm.
diff --git a/tests/driver/test_initial_z_and_checkpoint.py b/tests/driver/test_initial_z_and_checkpoint.py
index f21f2b607..7bf10d4da 100644
--- a/tests/driver/test_initial_z_and_checkpoint.py
+++ b/tests/driver/test_initial_z_and_checkpoint.py
@@ -13,7 +13,7 @@
from tests.driver.test_resume import run_until
from tests.driver.test_run import NullBackend
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.backends.protocol import stepping
from solprop.backends.registry import select
diff --git a/tests/driver/test_interface_defaults.py b/tests/driver/test_interface_defaults.py
index 9edac5a1e..833bbfae9 100644
--- a/tests/driver/test_interface_defaults.py
+++ b/tests/driver/test_interface_defaults.py
@@ -4,14 +4,13 @@
"""Each interface's defaults (SEP 37): integrator, tolerances, noise, edge watch.
-Every route in both precisions, through its ``Simulation.from_*`` constructor and its
+Every route, through its ``Simulation.from_*`` constructor and its
``prop_*`` function, and the refusals that name the option at fault.
"""
import dataclasses
import inspect
import json
-from typing import TypedDict
import numpy as np
import pytest
@@ -21,13 +20,11 @@
from solprop.driver import Simulation, SimulationSettings, interface
from solprop.driver.defaults import (
ROUTE_DEFAULTS,
- SINGLE_PRECISION_NOISE,
default_integrator,
default_rtol,
default_scheme,
resolve_noise,
route_defaults,
- rtol_in_precision,
)
from solprop.driver.simulation import (
CAPILLARY_RTOL,
@@ -55,12 +52,6 @@
from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse
from solprop.spatial import SpatialMedium
from solprop.stepper import Multistep, PIController, RungeKutta
-from solprop.translate.precision import (
- DOUBLE,
- SINGLE_RTOL_CAP,
- SINGLE_RTOL_FLOOR,
- PrecisionName,
-)
GNLSE = {
"wavelength": 835e-9,
@@ -179,9 +170,9 @@ def test_a_copy_of_the_settings_still_names_no_integrator():
settings = SimulationSettings(save_planes=[1.0], dz_max=1.0, dz_init=1e-3)
assert not dataclasses.replace(settings, step_planes=2).integrator_named
assert dataclasses.replace(settings, integrator=Multistep()).integrator_named
- single = dataclasses.replace(settings, precision="single", backend="numpy_ref")
- assert isinstance(single.integrator, Multistep)
- assert not single.integrator_named
+ reference = dataclasses.replace(settings, backend="numpy_ref")
+ assert isinstance(reference.integrator, Multistep)
+ assert not reference.integrator_named
def test_a_simulation_built_directly_needs_a_controller():
@@ -212,35 +203,16 @@ def test_noise_beside_the_tdse_is_warned_on():
assert simulation.noise is not None
-# ------------------------------------------- every route in both precisions
-
-
-class Arithmetic(TypedDict, total=False):
- """The keywords that put a run in single precision, typed for unpacking."""
-
- precision: PrecisionName
- backend: str
-
-
-SINGLE: Arithmetic = {"precision": "single", "backend": "numpy_ref"}
-"""Single precision on the oracle, the one backend that runs it."""
+# --------------------------------------------------------------- every route
PLANAR = EnvelopeGrid.from_limits(1e-3, 800e-9, (650e-9, 1000e-9), 600e-15)
SPATIAL = EnvelopeGrid.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 150e-15, thg=False)
-def _keyword(precision) -> Arithmetic:
- """Return the keyword arguments of a keyword interface in `precision`."""
- return {} if precision == "double" else SINGLE
-
-
-def _settings(grid, precision):
+def _settings(grid):
"""Return spatial settings without a controller, so the interface's applies."""
return SimulationSettings(
- save_planes=[grid.length],
- dz_max=grid.length,
- dz_init=grid.length,
- **_keyword(precision),
+ save_planes=[grid.length], dz_max=grid.length, dz_init=grid.length
)
@@ -259,7 +231,7 @@ def _slabs():
return [SlabMode(63.5e-6, "Ar", 1.0, order=p) for p in (1, 3)]
-def _general(precision, entry):
+def _general(entry):
grid = general_grid()
return entry(
(),
@@ -272,7 +244,6 @@ def _general(precision, entry):
shape="gauss",
n_save=2,
noise=None,
- **_keyword(precision),
)
@@ -281,14 +252,13 @@ def _general(precision, entry):
WAVEGUIDE_ARGS = ([MarcatiliMode(100e-6)], 0.01, "He", 1.0)
-def _build(route, precision, through): # noqa: PLR0911 - one return per route family
- """Build `route` in `precision` through its ``"from"`` constructor or ``"prop"``.
+def _build(route, through): # noqa: PLR0911 - one return per route family
+ """Build `route` through its ``"from"`` constructor or ``"prop"``.
Under the `intercepted` fixture a ``prop_*`` function returns a placeholder
output; :func:`_built` then finds the Simulation it built.
"""
- keywords = _keyword(precision)
- fibre = {**PULSE, **WINDOW, **keywords}
+ fibre = {**PULSE, **WINDOW}
constructors = {
"from": {
"gnlse": Simulation.from_gnlse,
@@ -310,7 +280,7 @@ def _build(route, precision, through): # noqa: PLR0911 - one return per route f
family, _, variant = route.partition(" ")
entry = constructors[family]
if family == "gnlse":
- return entry(*GNLSE_ARGS, **GNLSE, **keywords)
+ return entry(*GNLSE_ARGS, **GNLSE)
if family == "capillary":
extra = {
"": {},
@@ -321,7 +291,7 @@ def _build(route, precision, through): # noqa: PLR0911 - one return per route f
return entry(*CAPILLARY_ARGS, **fibre, **extra)
if family == "waveguide":
if variant == "general":
- return _general(precision, entry)
+ return _general(entry)
extra = {"mode_average": True} if variant == "mode_average" else {}
return entry(*WAVEGUIDE_ARGS, **fibre, **extra)
if family == "radial":
@@ -331,7 +301,7 @@ def _build(route, precision, through): # noqa: PLR0911 - one return per route f
SpatialMedium(),
[],
_beam(),
- settings=_settings(SPATIAL, precision),
+ settings=_settings(SPATIAL),
)
if family == "free":
space = (
@@ -345,33 +315,31 @@ def _build(route, precision, through): # noqa: PLR0911 - one return per route f
SpatialMedium(),
[],
_beam(),
- settings=_settings(SPATIAL, precision),
+ settings=_settings(SPATIAL),
)
return entry(
PLANAR,
PlanarGrid(_slabs(), 1e-3, 8),
[KerrResponse(gamma3("Ar", wavelength=800e-9), thg=False)],
_planar_launch(),
- settings=_settings(PLANAR, precision),
+ settings=_settings(PLANAR),
)
-# route: (integrator, binary64 rtol, binary32 rtol). Both precisions step with the
-# route's integrator (#342); binary32's rtol is the larger of binary64's and the floor
-# 1e-6, capped at 1e-4 (the maintainers' ruling on the single-precision default).
+# route: (integrator, rtol).
DEFAULTS = {
- "gnlse": (RungeKutta, 1e-7, 1e-6),
- "capillary": (Multistep, 1e-6, 1e-6),
- "capillary modes=2": (Multistep, 1e-6, 1e-6),
- "capillary mode_average": (Multistep, 1e-6, 1e-6),
- "capillary envelope": (Multistep, 1e-6, 1e-6),
- "waveguide": (RungeKutta, 1e-5, 1e-5),
- "waveguide mode_average": (RungeKutta, 1e-5, 1e-5),
- "waveguide general": (RungeKutta, 1e-5, 1e-5),
- "radial": (Multistep, 1e-5, 1e-5),
- "free cartesian": (RungeKutta, 1e-4, 1e-4),
- "free line": (RungeKutta, 1e-4, 1e-4),
- "planar": (Multistep, 1e-5, 1e-5),
+ "gnlse": (RungeKutta, 1e-7),
+ "capillary": (Multistep, 1e-6),
+ "capillary modes=2": (Multistep, 1e-6),
+ "capillary mode_average": (Multistep, 1e-6),
+ "capillary envelope": (Multistep, 1e-6),
+ "waveguide": (RungeKutta, 1e-5),
+ "waveguide mode_average": (RungeKutta, 1e-5),
+ "waveguide general": (RungeKutta, 1e-5),
+ "radial": (Multistep, 1e-5),
+ "free cartesian": (RungeKutta, 1e-4),
+ "free line": (RungeKutta, 1e-4),
+ "planar": (Multistep, 1e-5),
}
@@ -394,70 +362,29 @@ def _built(result, intercepted):
@pytest.mark.parametrize("through", ["from", "prop"])
-@pytest.mark.parametrize("precision", ["double", "single"])
@pytest.mark.parametrize("route", list(DEFAULTS))
def test_every_route_resolves_its_default_integrator_and_tolerance(
- route, precision, through, intercepted
+ route, through, intercepted
):
- simulation = _built(_build(route, precision, through), intercepted)
- double_integrator, double_rtol, single_rtol = DEFAULTS[route]
- integrator, rtol = _stepping(simulation)
+ simulation = _built(_build(route, through), intercepted)
# The resolved defaults are exact structural values, not computed ones.
- if precision == "double":
- assert (integrator, rtol) == (double_integrator, double_rtol)
- else:
- assert (integrator, rtol) == (double_integrator, single_rtol)
+ assert _stepping(simulation) == DEFAULTS[route]
assert not simulation.settings.integrator_named
@pytest.mark.parametrize("route", list(DEFAULTS))
-def test_a_copy_in_the_other_precision_takes_the_route_s_default(route):
+def test_a_copy_takes_the_route_s_default(route):
"""An interface's default is not a named integrator, whatever the copy changes."""
- single = _build(route, "single", "from").settings
- double = dataclasses.replace(single, precision="double")
- assert type(double.integrator) is DEFAULTS[route][0]
- assert not double.integrator_named
- back = dataclasses.replace(double, precision="single")
- assert type(back.integrator) is DEFAULTS[route][0]
- assert not back.integrator_named
-
-
-def test_the_single_precision_tolerance_rule():
- assert [rtol_in_precision(rtol, "single") for rtol in (1e-8, 1e-7, 1e-6)] == [
- SINGLE_RTOL_FLOOR
- ] * 3
- assert rtol_in_precision(1e-5, "single") == 1e-5
- assert [rtol_in_precision(rtol, "single") for rtol in (1e-4, 1e-3)] == [
- SINGLE_RTOL_CAP
- ] * 2
- assert rtol_in_precision(1e-4, "double") == 1e-4
- # An explicit tolerance is the caller's, above the cap included.
- simulation = Simulation.from_waveguide(
- *WAVEGUIDE_ARGS, rtol=1e-4, **PULSE, **WINDOW, **SINGLE
- )
- assert _stepping(simulation) == (RungeKutta, 1e-4)
+ settings = _build(route, "from").settings
+ copied = dataclasses.replace(settings, backend="numpy_ref")
+ assert type(copied.integrator) is DEFAULTS[route][0]
+ assert not copied.integrator_named
# ------------------------------------------------- refusals name their option
-def test_single_precision_admits_the_adams_method():
- # #342: binary32 Adams agrees with binary64 Adams to the method's own truncation.
- settings = SimulationSettings(
- PIController(rtol=1e-6, atol=0.0),
- save_planes=[1.0],
- dz_max=1.0,
- dz_init=1e-3,
- precision="single",
- backend="numpy_ref",
- integrator=Multistep(),
- )
- assert isinstance(settings.integrator, Multistep)
-
-
-def test_a_tdse_run_s_refusals_name_the_precision_and_the_integrator():
- with pytest.raises(DriverError, match="precision='single' refuses a TDSE"):
- _capillary(tdse=_sae(), **SINGLE)
+def test_a_tdse_run_s_refusals_name_the_backend_and_the_integrator():
with pytest.raises(DriverError, match=r"integrator=Multistep\(\).*RungeKutta"):
_capillary(tdse=_sae(), integrator=RungeKutta())
with pytest.raises(DriverError, match="explicit backend='numpy_ref'"):
@@ -474,7 +401,7 @@ def test_a_tdse_run_s_refusals_name_the_precision_and_the_integrator():
)
object.__setattr__(settings, "tdse_execution", object())
with pytest.raises(DriverError, match="must be a TDSEExecution"):
- inspect.unwrap(_admit_tdse_execution)(settings, DOUBLE)
+ inspect.unwrap(_admit_tdse_execution)(settings)
# ---------------------------------------------------------------- slab noise
@@ -530,7 +457,7 @@ def test_boundary_check_reaches_the_settings(route, through, intercepted):
def _with_check(route, through, check):
- """Build a double-precision `route` passing `check` as the keyword."""
+ """Build `route` passing `check` as the keyword."""
family = route.partition(" ")[0]
if family in ("radial", "free", "planar"):
# The spatial entry points take it beside their settings.
@@ -563,7 +490,7 @@ def _with_check(route, through, check):
return entry(
grid,
*arguments,
- settings=_settings(grid, "double"),
+ settings=_settings(grid),
boundary_check=check,
)
entry = {
@@ -586,7 +513,7 @@ def _with_check(route, through, check):
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, "double"), boundary_check=None)
+ settings = dataclasses.replace(_settings(SPATIAL), boundary_check=None)
with pytest.raises(DriverError, match="already declare a boundary_check"):
Simulation.from_radial(
SPATIAL,
@@ -634,12 +561,11 @@ def test_a_spatial_keyword_and_settings_naming_two_checks_are_refused():
@pytest.mark.parametrize("route", list(DEFAULTS))
def test_the_table_holds_what_every_route_resolves(route):
row = route_defaults(TABLE_ROWS[route])
- integrator, double_rtol, single_rtol = DEFAULTS[route]
+ integrator, rtol = DEFAULTS[route]
assert row.integrator == integrator.__name__
- assert row.rtol == double_rtol
- assert default_rtol(TABLE_ROWS[route], "single") == single_rtol
- assert type(default_integrator(TABLE_ROWS[route], "double")) is integrator
- assert type(default_integrator(TABLE_ROWS[route], "single")) is integrator
+ assert row.rtol == rtol
+ assert default_rtol(TABLE_ROWS[route]) == rtol
+ assert type(default_integrator(TABLE_ROWS[route])) is integrator
def test_every_row_of_the_table_is_measured_or_a_named_variant():
@@ -655,22 +581,19 @@ def test_every_row_of_the_table_is_measured_or_a_named_variant():
@pytest.mark.parametrize(
- ("route", "precision", "model", "reason"),
+ ("route", "model", "reason"),
[
- ("gnlse", "double", "input", None),
- ("capillary", "double", "input", None),
- ("capillary tdse", "double", None, TDSE_NOISE),
- ("waveguide slab", "double", None, SLAB_NOISE),
- ("radial", "double", "input", None),
- ("free", "double", None, FREE_NOISE),
- ("planar", "double", None, route_defaults("planar").noise_reason),
- *((route, "single", None, SINGLE_PRECISION_NOISE) for route in ROUTE_DEFAULTS),
+ ("gnlse", "input", None),
+ ("capillary", "input", None),
+ ("capillary tdse", None, TDSE_NOISE),
+ ("waveguide slab", None, SLAB_NOISE),
+ ("radial", "input", None),
+ ("free", None, FREE_NOISE),
+ ("planar", None, route_defaults("planar").noise_reason),
],
)
-def test_every_route_and_precision_resolves_its_default_noise(
- route, precision, model, reason
-):
- resolved, resolution = resolve_noise(route, "default", None, precision)
+def test_every_route_resolves_its_default_noise(route, model, reason):
+ resolved, resolution = resolve_noise(route, "default", None)
assert resolved == model
assert resolution == NoiseResolution("default", model, reason)
@@ -678,8 +601,8 @@ def test_every_route_and_precision_resolves_its_default_noise(
def test_a_model_is_refused_where_no_photon_measure_is_declared():
for route in ("waveguide slab", "planar"):
with pytest.raises(DriverError, match="pass noise=None"):
- resolve_noise(route, "input", None, "double")
- assert resolve_noise("free", "input", None, "double")[0] == "input"
+ resolve_noise(route, "input", None)
+ assert resolve_noise("free", "input", None)[0] == "input"
@pytest.mark.parametrize(
@@ -706,39 +629,19 @@ def _control(settings):
return settings.controller
-def test_a_default_tolerance_is_resolved_again_in_the_other_precision():
- double = _gnlse_settings(backend="numpy_ref")
- assert double.controller_defaulted
- assert _control(double).rtol == DEFAULT_RTOL
- single = dataclasses.replace(double, precision="single")
- # 1e-7 would be refused under single precision; the copy takes that default.
- assert _control(single).rtol == SINGLE_RTOL_FLOOR
- assert _control(single).atol == _control(double).atol
- assert single.controller_defaulted
- back = dataclasses.replace(single, precision="double")
- assert _control(back).rtol == DEFAULT_RTOL
-
-
-def test_a_named_tolerance_is_kept_in_the_other_precision():
+def test_a_default_tolerance_counts_as_defaulted_and_a_named_one_does_not():
+ default = _gnlse_settings(backend="numpy_ref")
+ assert default.controller_defaulted
+ assert _control(default).rtol == DEFAULT_RTOL
named = _gnlse_settings(backend="numpy_ref", rtol=1e-5)
assert not named.controller_defaulted
- assert _control(dataclasses.replace(named, precision="single")).rtol == 1e-5
+ assert _control(named).rtol == 1e-5
replaced = dataclasses.replace(
named, controller=PIController(rtol=1e-4, atol=1e-14)
)
assert not replaced.controller_defaulted
-def test_a_copy_in_single_precision_resolves_auto_again():
- double = _gnlse_settings()
- # Single precision's "auto" is refused by design, not kept as the binary64 choice.
- with pytest.raises(DriverError, match="backend='auto' does not choose it"):
- dataclasses.replace(double, precision="single")
- # A named backend is not resolved again: binary32 refuses it by name.
- named = _gnlse_settings(backend="numpy_ref")
- assert dataclasses.replace(named, precision="single").backend == "numpy_ref"
-
-
def test_the_stepping_defaults_are_recorded_and_read_back():
simulation = Simulation.from_gnlse(*GNLSE_ARGS, **GNLSE, backend="numpy_ref")
record = simulation.metadata().defaults
@@ -783,15 +686,10 @@ def test_an_incident_launch_records_a_default_noise_as_default():
assert launched.noise_resolution.requested == "default"
-def test_an_interior_launch_keeps_the_single_precision_reason():
- single = Simulation.from_gnlse(
- *GNLSE_ARGS,
- **{**GNLSE, "noise": "default"},
- precision="single",
- backend="numpy_ref",
- )
- moved = single.with_input(single._field0, initial_z=0.001)
- assert moved.noise_resolution.reason == SINGLE_PRECISION_NOISE
+def test_an_interior_launch_keeps_the_route_s_reason():
+ tdse = _capillary(tdse=_sae(), noise="default")
+ moved = tdse.with_input(tdse._field0, initial_z=tdse.grid.length / 2)
+ assert moved.noise_resolution.reason == TDSE_NOISE
def test_the_settings_leave_the_caller_s_planes_writeable():
diff --git a/tests/driver/test_moving_guide.py b/tests/driver/test_moving_guide.py
index 1d87b4b92..761dad80f 100644
--- a/tests/driver/test_moving_guide.py
+++ b/tests/driver/test_moving_guide.py
@@ -40,7 +40,6 @@
make_provider,
)
from solprop.backends import select
-from solprop.backends.numpy_ref.admission import refuse_general_route
from solprop.backends.numpy_ref.backend import NumpyRefBackend
from solprop.backends.numpy_ref.batch import NumpyBatchBackend
from solprop.constants import C
@@ -79,6 +78,7 @@
from solprop.profiles import Linear, PiecewiseLinear, ZProfile
from solprop.stats import analytic_field
from solprop.stepper import FixedSchedule, RungeKutta
+from solprop.translate.admission import check_general_route
from solprop.translate.coupled import flow_weights, residual_truncation
from solprop.translate.general import general_modal_route
from solprop.translate.modal_path import path_rule, path_snapshot
@@ -266,46 +266,15 @@ def test_a_moving_guide_runs_ten_steps_end_to_end_and_records_its_path(
)
-def test_every_compiled_backend_admits_moving_maps_and_a_new_one_refuses_them(
- kinked_problem,
-):
- """The compiled backend admits the moving maps; the default refuses them by name.
-
- A backend that calls the shared admission without declaring ``moving_maps``
- has not built the per-stage combination, and is refused by name.
- """
+def test_both_backends_admit_moving_maps(kinked_problem):
+ """The moving maps beside their connection pass the problem's own validation."""
simulation, problem = kinked_problem
- refuse_general_route(problem, backend="numpy_ref", moving_maps=True)
- with pytest.raises(BackendError, match=r"new executes .*moving maps.*not build"):
- refuse_general_route(problem, backend="new")
+ check_general_route(problem)
pytest.importorskip("solprop_native")
run_request = simulation._run_request()
select("native", problem, run_request, grid=simulation.grid).close()
-def test_the_reference_refuses_a_cf4_flow_beside_moving_maps(kinked_problem):
- """CF4 composes on paper and is not yet built or measured on this route.
-
- CF4 is the Runge-Kutta march's section method: the default Adams history refuses
- it before the route is reached.
- """
- simulation, problem = kinked_problem
- run_request = simulation._run_request()
- run_request = run_request.with_stepper(
- dataclasses.replace(run_request.stepper, integrator=None)
- )
- flow = problem.linear.coupled_flow
- assert flow is not None
- spoiled_flow = dataclasses.replace(flow)
- object.__setattr__(spoiled_flow, "section_methods", ("cf4", "cf4"))
- linear = dataclasses.replace(problem.linear)
- object.__setattr__(linear, "coupled_flow", spoiled_flow)
- spoiled = dataclasses.replace(problem)
- object.__setattr__(spoiled, "linear", linear)
- with pytest.raises(BackendError, match="CF4 beside moving maps"):
- NumpyRefBackend(spoiled, run_request, grid=simulation.grid)
-
-
def test_a_lockstep_batch_refuses_a_moving_guide(kinked_problem):
"""The batch applies neither half of a flow, so a member carrying one is refused."""
simulation, problem = kinked_problem
diff --git a/tests/driver/test_native_coupled_flow.py b/tests/driver/test_native_coupled_flow.py
index 3510d7035..d66e46776 100644
--- a/tests/driver/test_native_coupled_flow.py
+++ b/tests/driver/test_native_coupled_flow.py
@@ -4,8 +4,7 @@
"""The coupled flow on the native CPU backend, against ``numpy_ref``.
-Native executes ``linear.coupled_flow`` in the combinations the reference admits but the
-commutator-free CF4 flow, which is not yet implemented on the compiled backends: the
+Native executes ``linear.coupled_flow`` in every combination the reference admits: the
scalar wall, the TE/TM wall and the split block, each one constant-Schur section, and
the frozen-residual flow of a varying guide. The section's actions are formed natively
from the eigenbasis of its Schur factor, or by the reference's scaling and squaring
@@ -36,7 +35,6 @@
thread_environment,
)
from driver.test_coherent_capillary import (
- CF4_WALL,
LENGTH,
STEPS,
VECTOR_WALL,
@@ -66,7 +64,6 @@
from solprop.driver.scan import Scan, run_scan
from solprop.exceptions import (
BackendError,
- DriverError,
GasFlowWarning,
GrazingIncidenceWarning,
MaterialRangeWarning,
@@ -436,17 +433,6 @@ def test_a_step_longer_than_the_certificate_is_refused_by_name_on_native():
final_field(backend, problem, bare(run_request), ended=True, owns=True)
-def test_cf4_is_refused_by_name_at_the_backend_and_the_entry_point():
- """CF4 stays the reference's: both doors name it and the backend."""
- simulation = varying(wall=CF4_WALL)
- problem, run_request = simulation.translate(), simulation._run_request()
- with pytest.raises(BackendError, match=r"native does not execute a 'cf4' section"):
- native_class()(problem, run_request, grid=simulation.grid)
- for name in ("native", "auto"):
- with pytest.raises(DriverError, match=r"method='cf4'.*numpy_ref"):
- varying(wall=CF4_WALL, backend=name).translate()
-
-
def test_a_native_group_admits_a_coupled_flow():
"""A coupled flow runs in a native group (SEP 47 section 6.7): each member's flow
is its own session's, so nothing of its step is fused. The members' numerics are
diff --git a/tests/driver/test_native_flow_refusals.py b/tests/driver/test_native_flow_refusals.py
index 0f049737d..d9335e098 100644
--- a/tests/driver/test_native_flow_refusals.py
+++ b/tests/driver/test_native_flow_refusals.py
@@ -25,9 +25,8 @@
ten_step_bound,
)
from solprop.backends.numpy_ref import NumpyRefBackend
-from solprop.backends.numpy_ref.admission import refuse_general_leaves
from solprop.backends.numpy_ref.backend import _coupled_flow_maps
-from solprop.exceptions import BackendError
+from solprop.exceptions import BackendError, TranslationError
from solprop.translate.coupled import constant_schur_flow, orthonormal_metric
pytestmark = [
@@ -86,24 +85,11 @@ def on_march(run_request, march):
MARCHES = pytest.mark.parametrize("march", ["default", "runge_kutta"])
-def test_a_backend_that_does_not_admit_the_leaf_is_refused_by_its_name(cases):
- """The shared leaf door, which both in-tree backends now pass for a flow.
-
- Every in-tree backend names all four general-modal leaves, so the door
- ``refuse_general_leaves`` keeps for a separately built adapter is exercised here
- directly: a backend admitting none of them is refused by the flow's dotted name.
- """
- _, problem = cases("scalar")
- with pytest.raises(BackendError, match=r"adapter does not execute linear\.coupled"):
- refuse_general_leaves(problem, "adapter")
- refuse_general_leaves(problem, "adapter", admits=("linear.coupled_flow",))
-
-
@pytest.mark.parametrize("backend", ["numpy_ref", "native"])
def test_a_varying_carrier_beside_a_constant_section_is_refused_in_the_same_words(
backend,
):
- """The reference's constant-representation door, under each backend's name."""
+ """The constant-representation door, the same on each backend."""
if backend == "native":
pytest.importorskip("solprop_native")
implementation = NumpyRefBackend if backend == "numpy_ref" else native_class()
@@ -125,7 +111,7 @@ def test_a_varying_carrier_beside_a_constant_section_is_refused_in_the_same_word
),
),
)
- with pytest.raises(BackendError, match=f"{backend} executes a constant-Schur"):
+ with pytest.raises(TranslationError, match="solprop executes a constant-Schur"):
implementation(spoiled_problem, run_request, grid=simulation.grid)
@@ -197,12 +183,10 @@ def test_a_varying_flow_s_factors_are_the_reference_s_to_their_rounding(cases, n
def test_every_flow_the_reference_refuses_native_refuses_in_the_same_words(
cases, march
):
- """The reference's own refusals run under native's name: one list.
+ """Every flow one backend refuses, the other refuses in the same words: one list.
- Each spoiler is refused by both, and the two messages differ in the backend's
- name alone, so the admitted sets cannot drift apart. Under the default
- integrator a section method it does not step is refused first, by the
- integrator; the Runge-Kutta march reaches the leaf's own list.
+ Under the default integrator a section method it does not step is refused first,
+ by the integrator; the Runge-Kutta march reaches the problem's own validation.
"""
simulation, problem = cases("scalar")
run_request = on_march(simulation._run_request(), march)
@@ -251,18 +235,12 @@ def test_every_flow_the_reference_refuses_native_refuses_in_the_same_words(
),
}
for name, problem_spoiled in spoilers.items():
- with pytest.raises(BackendError) as theirs:
+ with pytest.raises((BackendError, TranslationError)) as theirs:
NumpyRefBackend(problem_spoiled, run_request, grid=grid)
- with pytest.raises(BackendError) as ours:
+ with pytest.raises((BackendError, TranslationError)) as ours:
native_class()(problem_spoiled, run_request, grid=grid)
- assert "numpy_ref" in str(theirs.value), name
- # The one difference by design: a varying flow's methods, where native names
- # 'frozen_residual' alone because it refuses 'cf4' first.
- expected = (
- str(theirs.value)
- .replace("numpy_ref", "native")
- .replace("'frozen_residual' or 'cf4'", "'frozen_residual'")
- )
+ assert type(ours.value) is type(theirs.value), name
+ expected = str(theirs.value).replace("numpy_ref", "native")
assert str(ours.value) == expected, name
diff --git a/tests/driver/test_native_flow_session.py b/tests/driver/test_native_flow_session.py
index 7e955d8d5..802039283 100644
--- a/tests/driver/test_native_flow_session.py
+++ b/tests/driver/test_native_flow_session.py
@@ -56,8 +56,12 @@
from sessions.runs import run as run_session
from solprop.backends.events import ObservationReady, Outcome
from solprop.backends.exponential import carrier_reference
-from solprop.backends.multistep import CoupledBlock, coupled_moments, trial_tables
from solprop.backends.numpy_ref import NumpyRefBackend
+from solprop.backends.numpy_ref.multistep import (
+ CoupledBlock,
+ coupled_moments,
+ trial_tables,
+)
from solprop.backends.registry import available
from solprop.backends.session import Start
from solprop.backends.sessions import start_session
@@ -465,7 +469,7 @@ def _resident(simulation: Simulation, problem: Any, request: Any) -> Any:
assert type(engine).__name__ == "_ResidentAdams"
field = np.ascontiguousarray(problem.initial.field0, dtype=np.complex128)
load_native(backend, field, clear_fsal=False)
- engine.start(0.0, stored=True)
+ engine.start(0.0)
rows = {
name: np.broadcast_to(row[:1], row.shape).copy()
for name, row in engine.snapshot_rows().items()
diff --git a/tests/driver/test_native_moving_guide_bound.py b/tests/driver/test_native_moving_guide_bound.py
index ebd3084a2..7b0fe7a1f 100644
--- a/tests/driver/test_native_moving_guide_bound.py
+++ b/tests/driver/test_native_moving_guide_bound.py
@@ -155,7 +155,7 @@ def source_at(implementation, problem, run_request, grid, positions):
sources = []
for z in positions:
load_native(backend, state, clear_fsal=False)
- engine.start(float(z), stored=True)
+ engine.start(float(z))
sources.append(np.array(engine.snapshot_rows()["store"][0]))
return sources
finally:
diff --git a/tests/driver/test_native_resume_history.py b/tests/driver/test_native_resume_history.py
index 1a7f2bcff..d99267b88 100644
--- a/tests/driver/test_native_resume_history.py
+++ b/tests/driver/test_native_resume_history.py
@@ -43,13 +43,11 @@
from solprop.backends.session import Start
from solprop.backends.sessions import start_session
from solprop.driver import Simulation
-from solprop.exceptions import SolpropWarning
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.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta
-from solprop.stepper.multistep import ComparisonMultistep
pytestmark = [
pytest.mark.skipif("native" not in available(), reason="needs solprop-native"),
@@ -523,23 +521,6 @@ def test_a_snapshot_from_elsewhere_or_another_march_is_not_continued(build_simul
session.close()
-@pytest.mark.medium
-def test_a_march_that_cannot_carry_its_history_warns_on_resume(
- build_simulation, tmp_path
-):
- # The general path of a comparison method keeps host arrays no snapshot holds:
- # its resume restarts the history, once, with a warning that says why.
- build = soliton(
- build_simulation, "numpy_ref", integrator=ComparisonMultistep(order=3)
- )
- path = tmp_path / "run.h5"
- run_until(build(), path, after=2)
- assert resume(path).march is None
- with pytest.warns(SolpropWarning, match="general Adams path") as caught:
- build().run(path, resume=True, progress="none")
- assert len([one for one in caught if AFRESH in str(one.message)]) == 1
-
-
@pytest.mark.medium
def test_a_native_resume_under_modified_noise_continues_the_same_march(
tmp_path, monkeypatch
diff --git a/tests/driver/test_noise_default.py b/tests/driver/test_noise_default.py
index db1e95760..800435582 100644
--- a/tests/driver/test_noise_default.py
+++ b/tests/driver/test_noise_default.py
@@ -5,15 +5,14 @@
"""The input-noise default and its recorded resolution (SEP 37 phase 2)."""
import json
+import re
import numpy as np
import pytest
from solprop.driver import Simulation, SimulationSettings, prop_gnlse
-from solprop.driver.simulation import (
- INTERIOR_LAUNCH_NOISE,
- SINGLE_PRECISION_NOISE,
-)
+from solprop.driver.defaults import FREE_NOISE, resolve_noise
+from solprop.driver.simulation import INTERIOR_LAUNCH_NOISE
from solprop.driver.summary import _noise
from solprop.exceptions import DriverError
from solprop.grid import EnvelopeGrid, RadialGrid
@@ -76,18 +75,15 @@ def test_a_seed_beside_no_noise_is_refused():
_gnlse(noise=None, seed=3)
-def test_single_precision_falls_back_and_says_why():
- simulation = _gnlse(precision="single")
+def test_a_fallback_says_why():
+ simulation = _interior(_gnlse())
assert simulation.noise is None
- assert simulation.noise_resolution == NoiseResolution(
- "default", None, SINGLE_PRECISION_NOISE
- )
- assert _noise(simulation.metadata()) == f"off (default: {SINGLE_PRECISION_NOISE})"
+ assert _noise(simulation.metadata()) == f"off (default: {INTERIOR_LAUNCH_NOISE})"
def test_a_seed_beside_a_fallback_is_refused():
- with pytest.raises(DriverError, match="single precision carries no noise"):
- _gnlse(precision="single", seed=3)
+ with pytest.raises(DriverError, match=re.escape(f"by default ({FREE_NOISE})")):
+ resolve_noise("free", "default", 3)
def test_the_capillary_and_radial_routes_default_to_input_noise():
diff --git a/tests/driver/test_plane_adoption.py b/tests/driver/test_plane_adoption.py
index 9208f1d1c..ffebec109 100644
--- a/tests/driver/test_plane_adoption.py
+++ b/tests/driver/test_plane_adoption.py
@@ -27,8 +27,7 @@
from solprop.backends.export import ExportRecipe
from solprop.backends.host.driver import HostDriver
from solprop.backends.host.session import HostSession
-from solprop.backends.numpy_ref.single import NumpySingleBackend
-from solprop.backends.protocol import Backend, BackendHistory, Observation
+from solprop.backends.protocol import BackendHistory, Observation
from solprop.backends.protocol import stepping as host_stepping
from solprop.backends.registry import available, select
from solprop.backends.session import Start
@@ -42,7 +41,6 @@
from solprop.stepper import Multistep, RungeKutta
from solprop.stepper.low_storage import LowStorageRungeKutta
from solprop.translate.linop import power_weight_at
-from solprop.translate.problem import PropagationProblem
IDENTITY = ExportRecipe(power=False, optics=False)
"""The state as the session holds it."""
@@ -80,11 +78,6 @@ def _single_mode(backend: str) -> Simulation:
return simulation
-def _single(simulation: Simulation) -> Simulation:
- simulation.settings = dataclasses.replace(simulation.settings, precision="single")
- return simulation
-
-
CASES: dict[str, Callable[[str], Simulation]] = {
"runge-kutta replayed": cartesian,
"runge-kutta adaptive": lambda name: cartesian(name, replay=False),
@@ -142,19 +135,6 @@ def test_a_kept_plane_never_changes(backend, case):
assert np.array_equal(held, as_it_arrived)
-@pytest.mark.parametrize(
- "case", ["runge-kutta replayed", "runge-kutta modal", "runge-kutta single-mode"]
-)
-def test_a_kept_single_precision_plane_never_changes(case):
- """The widened state is the observation's own (SEP 47 section 5.4)."""
- simulation = _single(CASES[case]("numpy_ref"))
- handler = _Keeping()
- simulation.run(output=handler, progress="none")
- assert handler.n_saved > 1
- for held, as_it_arrived in handler.kept:
- assert np.array_equal(held, as_it_arrived)
-
-
@pytest.mark.parametrize("backend", available())
def test_a_kept_plane_survives_the_field_free_planes_after_it(backend):
"""The low-storage march steps in place again after a plane without a field."""
@@ -273,18 +253,13 @@ def exporting(self, **bounds):
@pytest.mark.parametrize("handler", ["memory", "hdf5"])
@pytest.mark.parametrize(
- ("backend", "case", "precision"),
- [
- *((backend, case, "double") for backend in available() for case in STORED),
- *(("numpy_ref", case, "single") for case in STORED),
- ],
+ ("backend", "case"),
+ [(backend, case) for backend in available() for case in STORED],
)
def test_a_stored_field_has_the_bits_of_the_conversion_in_two_steps(
- backend, case, precision, handler, tmp_path, monkeypatch
+ backend, case, handler, tmp_path, monkeypatch
):
simulation = STORED[case](backend)
- if precision == "single":
- simulation = _single(simulation)
problem = simulation.translate()
copies = _copied_observations(monkeypatch)
path = tmp_path / "run.h5"
@@ -308,34 +283,16 @@ def test_a_stored_field_has_the_bits_of_the_conversion_in_two_steps(
assert expected.tobytes() == stored[index].tobytes()
-def _observed_at_its_first_plane(
- name: str, simulation: Simulation
-) -> tuple[PropagationProblem, Backend, Observation]:
- """Return a loaded backend stepped to the first save plane, and its observation."""
- problem = simulation.translate()
- run_request = simulation._run_request()
- backend = select(name, problem, run_request, grid=simulation.grid)
- host_stepping(backend).set_initial(problem.initial.field0, None)
- host_stepping(backend).propagate_to(float(run_request.stepper.save_planes[0]))
- return problem, backend, host_stepping(backend).observe()
-
-
-@pytest.mark.parametrize("precision", ["double", "single"])
@pytest.mark.parametrize("case", ["modal", "single-mode"])
-def test_an_observed_field_is_converted_in_place_only_where_it_is_owned(
- precision, case
-):
- """The session converts an owned field where it is; a borrowed one is detached.
+def test_a_borrowed_observed_field_is_detached(case):
+ """The session's export of its driver's own state is a new array, detached from it.
- The reference session's own conversion of its driver's observation: the widened
- single-precision state is a fresh, owned array, the binary64 state the driver's
- own. The native session exports from its resident state instead
+ The reference session's own conversion of its driver's observation, the driver's
+ own state. The native session exports from its resident state instead
(``tests/sessions/test_destination_export.py``).
"""
backend = "numpy_ref"
simulation = STORED[case](backend)
- if precision == "single":
- simulation = _single(simulation)
problem = simulation.translate()
run_request = simulation._run_request()
stepping = select(
@@ -373,46 +330,12 @@ def recorded(*, field: bool = True) -> Observation:
break
session.acknowledge(batch.receipt)
held = session.export(event.ticket, ExportRecipe(power=True, optics=False))
- owned = precision == "single"
- assert seen[-1].field_owned is owned
expected = power_amplitude(raws[-1], power_weight_at(problem, event.z))
assert held.tobytes() == expected.tobytes()
# The observation's own array stays frozen; a fresh export is the caller's.
- assert held.flags.writeable is not owned
- # Converted where it is when owned: the observation's own array then holds it.
- assert np.shares_memory(held, seen[-1].field) is owned
- if not owned:
- state = cast("HostDriver[np.ndarray]", stepping)._state
- assert not np.shares_memory(held, state)
+ assert held.flags.writeable
+ assert not np.shares_memory(held, seen[-1].field)
+ state = cast("HostDriver[np.ndarray]", stepping)._state
+ assert not np.shares_memory(held, state)
finally:
session.close()
-
-
-def test_the_widened_single_precision_state_is_owned_and_has_the_same_bits():
- _, stepping, observation = _observed_at_its_first_plane(
- "numpy_ref", _single(cartesian("numpy_ref"))
- )
- try:
- assert isinstance(stepping, NumpySingleBackend)
- state, scale = stepping._state, stepping._scale
- assert isinstance(state, np.ndarray) and state.dtype == np.complex64
- assert observation.field_owned
- field = observation.require_field()
- assert field.tobytes() == (state.astype(np.complex128) * scale).tobytes()
- assert not np.shares_memory(field, state)
- finally:
- stepping.close()
-
-
-def test_an_observation_without_a_field_owns_none():
- empty = np.empty(0)
- observation = Observation(
- 0.0,
- None,
- None,
- BackendHistory(empty, empty, empty, np.empty(0, bool), empty),
- 0,
- 0,
- field_owned=True,
- )
- assert not observation.field_owned
diff --git a/tests/driver/test_prop_capillary.py b/tests/driver/test_prop_capillary.py
index d29114579..e04cfeaa3 100644
--- a/tests/driver/test_prop_capillary.py
+++ b/tests/driver/test_prop_capillary.py
@@ -62,7 +62,7 @@
from sessions.runs import to_planes
from solprop import tools
from solprop.backends.events import ObservationReady, join_attempts
-from solprop.backends.numpy_ref import QUADRATURE_ALARM, NumpyRefBackend, passes
+from solprop.backends.numpy_ref import NumpyRefBackend, passes
from solprop.backends.registry import select
from solprop.backends.session import Start
from solprop.backends.sessions import start_session
@@ -109,6 +109,7 @@
from solprop.pulses import GaussianPulse
from solprop.stats import StatsSpec, analytic_field, energy
from solprop.stepper import FixedSchedule, RungeKutta, observed_order
+from solprop.translate.modal import QUADRATURE_ALARM
from solprop.translate.responses import KerrCoefficients, ResponseStage
# A millimetre of capillary on a small grid: the surface is what is measured, not the
@@ -2254,7 +2255,7 @@ def witness_run(
def gated_witness_run(case, tag, **keywords):
"""Compare an archive with its original equation and unchanged tolerance."""
with _gated_source():
- return witness_run(case, tag, backend="numpy_ref", **keywords)
+ return witness_run(case, tag, backend=TEST_BACKEND, **keywords)
def band_mask(grid, limits):
@@ -3112,8 +3113,6 @@ def ctc_run(
A `seed` launches the case with input shot noise of that seed; without one the run
is noise-free, as the witness is.
"""
- if luna_vibration and backend != "numpy_ref":
- raise ValueError("the historical CtC witness requires the NumPy gated hook")
grid = ctc_grid(case)
context = _gated_source() if luna_vibration else nullcontext()
with warnings.catch_warnings(), context:
@@ -3477,9 +3476,8 @@ def test_the_reduced_ctc_metrics_are_bounded_at_milestone_m4(
a cascade in both codes alike, which is what these bounds once described (0.154,
1.044, and a Stokes ladder); the seeded cascade is now solprop's own check.
"""
- del test_backend_name
ctc_reduced = ctc_run(
- CTC_REDUCED, "REDUCED", backend="numpy_ref", luna_vibration=True
+ CTC_REDUCED, "REDUCED", backend=test_backend_name, luna_vibration=True
)
grid = ctc_grid(CTC_REDUCED)
fields = ctc_reduced.fields("fft")
diff --git a/tests/driver/test_restart_record.py b/tests/driver/test_restart_record.py
index 4f1fd7bd9..2dbc59712 100644
--- a/tests/driver/test_restart_record.py
+++ b/tests/driver/test_restart_record.py
@@ -184,6 +184,28 @@ def test_a_history_this_backend_cannot_install_is_recorded_as_incompatible(
)
+def test_a_resuming_march_that_cannot_carry_a_history_warns_once_and_resets(
+ build_simulation, tmp_path, monkeypatch
+):
+ # The file keeps a history, and the resuming engine says before installing it that
+ # its march cannot carry one (the protocol's `march_export_refusal`): the history
+ # restarts, once, with a warning that gives the engine's reason.
+ build = soliton(build_simulation, "numpy_ref")
+ path = tmp_path / "run.h5"
+ interrupted(build, path)
+ assert restart(path)["retention"]["state"] == "retained"
+ monkeypatch.setattr(
+ HostDriver, "march_export_refusal", lambda self: "refused by the test"
+ )
+ caught = resumed(build, path)
+ assert [str(one.message) for one in caught] == [f"{AFRESH}: refused by the test"]
+ restoration = restart(path)["restoration"]
+ assert (restoration["state"], restoration["reason"]) == (
+ "reset",
+ "incompatible_representation",
+ )
+
+
def test_a_runge_kutta_resume_records_its_reset_controller(build_simulation, tmp_path):
build = soliton(build_simulation, "numpy_ref", integrator=RungeKutta())
path = tmp_path / "run.h5"
diff --git a/tests/driver/test_resume_history.py b/tests/driver/test_resume_history.py
index 6e57368ac..a5428b8cf 100644
--- a/tests/driver/test_resume_history.py
+++ b/tests/driver/test_resume_history.py
@@ -42,8 +42,6 @@
from solprop.output.hdf5 import open_file, resume
from solprop.output.schema import MarchSnapshot, SavedPlane, _example_metadata
from solprop.stepper import Multistep, RungeKutta
-from solprop.stepper.multistep import ComparisonMultistep
-from solprop.translate.precision import SINGLE_RTOL_FLOOR
# The fixtures here launch noise-free into time windows kept tight for speed, which
# hold more than a millionth of the energy in their guards or collar: the boundary
@@ -90,15 +88,6 @@ def build():
return build
-def single(rtol):
- """Return the precision of a single-precision case at `rtol`.
-
- The tight reference runs below single precision's tolerance floor, so it is the
- binary64 run, which the binary32 one is held against (#342).
- """
- return {"precision": "single" if rtol >= SINGLE_RTOL_FLOOR else "double"}
-
-
def coupled(backend, rtol=RTOL):
"""Return a coherent wall along a gradient: a frozen-residual coupled flow."""
@@ -158,10 +147,6 @@ def tight_run(build_tight, path):
"varying-native": lambda _, rtol=RTOL: varying("native", rtol),
"coupled-numpy_ref": lambda _, rtol=RTOL: coupled("numpy_ref", rtol),
"coupled-native": lambda _, rtol=RTOL: coupled("native", rtol),
- # #342: binary32 Adams resumes in binary32, its history and state narrowed.
- "single-soliton-numpy_ref": lambda build, rtol=RTOL: soliton(
- build, "numpy_ref", rtol=rtol, atol=0.0, **single(rtol)
- ),
}
@@ -600,33 +585,3 @@ def test_rows_another_history_keeps_are_refused_by_name(build_simulation):
assert backend.march_snapshot() is None
finally:
backend.close()
-
-
-def test_the_general_path_refuses_a_snapshot_it_cannot_install(build_simulation):
- # Called directly, a march that keeps no history store says so rather than
- # installing rows it has nowhere to put.
- simulation = build_simulation(
- length=LENGTH, n_save=N_SAVE, backend="numpy_ref", integrator=Multistep()
- )
- problem = simulation.translate()
- run_request = simulation._run_request()
- general = build_simulation(
- length=LENGTH,
- n_save=N_SAVE,
- backend="numpy_ref",
- integrator=ComparisonMultistep(order=3),
- )
- backends = [
- NumpyRefBackend(problem, run_request, grid=simulation.grid),
- NumpyRefBackend(general.translate(), general._run_request(), grid=general.grid),
- ]
- try:
- backends[0].set_initial(problem.initial.field0, None)
- backends[0].propagate_to(float(run_request.stepper.save_planes[0]))
- snapshot = backends[0].march_snapshot()
- assert snapshot is not None
- backends[1].set_initial(problem.initial.field0, None, z=snapshot.z)
- assert "general Adams path" in str(backends[1].restore_march(snapshot))
- finally:
- for backend in backends:
- backend.close()
diff --git a/tests/driver/test_retired_backend_runs.py b/tests/driver/test_retired_backend_runs.py
deleted file mode 100644
index b710aabc7..000000000
--- a/tests/driver/test_retired_backend_runs.py
+++ /dev/null
@@ -1,150 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""Runs that ``numba_cpu`` wrote, after its removal (SEP 43 section 9).
-
-The files are real, written by versions that had the backend
-(``tests/data/retired_backends/generate.py``). They read, and a run may name no other
-version's record to continue it (ADR-066). The backend itself is an execution resource,
-outside a run's identity, so a stopped run continues on another backend by changing the
-name. The continuation is an adaptive pair, held to an envelope against a tighter run
-(SEP 25), never to the original's steps.
-"""
-
-import shutil
-from pathlib import Path
-from typing import Any
-
-import h5py
-import numpy as np
-import pytest
-
-from march_bounds import TIGHTER, assert_in_envelope
-from solprop.driver import prop_gnlse
-from solprop.exceptions import BackendError, OutputError
-from solprop.log import NullReporter
-
-DATA = Path(__file__).parents[1] / "data" / "retired_backends"
-STOPPED = DATA / "numba_cpu_stopped.h5"
-PUBLISHED = DATA / "numba_cpu_0.1.0.h5"
-RTOL = 1e-7 # the default, which generate.py used
-
-
-def run(backend, output=None, *, rtol=RTOL, resume=False):
- """Run the problem generate.py wrote."""
- return prop_gnlse(
- 0.11,
- 2e-4,
- [0.0, 0.0, -11.83e-27],
- wavelength=835e-9,
- wavelength_limits=(500e-9, 2e-6),
- trange=1e-12,
- fwhm=50e-15,
- peak_power=1e3,
- shape="sech",
- backend=backend,
- noise=None,
- output=output,
- n_save=5,
- rtol=rtol,
- resume=resume,
- progress="none",
- )
-
-
-class CrashError(RuntimeError):
- """What stops a run at a plane, standing in for a machine going away."""
-
-
-class CrashAtTheThirdPlane(NullReporter):
- """Raise at the third saved plane."""
-
- planes = 0
-
- def plane(self, z, summary):
- self.planes += 1
- if self.planes == 3:
- raise CrashError
-
-
-def stopped_on(backend, path):
- """Stop the run generate.py wrote at its third saved plane, on `backend`."""
- with pytest.raises(CrashError):
- prop_gnlse(
- 0.11,
- 2e-4,
- [0.0, 0.0, -11.83e-27],
- wavelength=835e-9,
- wavelength_limits=(500e-9, 2e-6),
- trange=1e-12,
- fwhm=50e-15,
- peak_power=1e3,
- shape="sech",
- backend=backend,
- noise=None,
- output=path,
- n_save=5,
- rtol=RTOL,
- progress=CrashAtTheThirdPlane(),
- )
-
-
-def recorded_backend(path):
- with h5py.File(path) as file:
- return file.attrs["backend"]
-
-
-def test_the_stopped_file_is_a_numba_run_of_the_current_schema():
- assert recorded_backend(STOPPED) == "numba_cpu"
- with h5py.File(STOPPED) as file:
- assert file.attrs["schema_version"] == 2
- assert "terminal_checkpoint" in file
-
-
-def backend_available(name):
- if name == "native":
- pytest.importorskip("solprop_native")
-
-
-@pytest.mark.parametrize("backend", ["native", "numpy_ref"])
-def test_a_stopped_run_continues_on_another_backend(tmp_path, backend):
- backend_available(backend)
- path = tmp_path / "continued.h5"
- stopped_on("numpy_ref" if backend == "native" else "native", path)
- with h5py.File(path) as handle:
- before: Any = handle
- kept = np.array(before["field"][:3])
- run(backend, path, resume=True)
- with h5py.File(path) as handle:
- after: Any = handle
- assert after["z"][-1] == 2e-4
- field = np.array(after["field"][:])
- # What the original run saved stays as it was written.
- np.testing.assert_array_equal(field[:3], kept)
- # Over every plane, not the last alone: this soliton's error at the last plane is
- # about 1e-11 by coincidence, against about 1e-8 at the third, for an uninterrupted
- # run on every backend, so the last plane alone would set no real envelope.
- whole = np.asarray(run(backend)["field"])
- reference = np.asarray(run(backend, rtol=RTOL / TIGHTER)["field"])
- assert_in_envelope([field], reference, whole, what=backend)
-
-
-def test_naming_the_removed_backend_says_what_to_use(tmp_path):
- with pytest.raises(BackendError, match="numba_cpu backend was removed"):
- run("numba_cpu")
- path = tmp_path / "continued.h5"
- shutil.copyfile(STOPPED, path)
- with pytest.raises(BackendError, match="numba_cpu backend was removed"):
- run("numba_cpu", path, resume=True)
-
-
-@pytest.mark.parametrize("backend", ["native", "numpy_ref"])
-def test_a_file_from_the_published_release_is_refused_by_its_schema(tmp_path, backend):
- """0.1.0 wrote schema 1, which this release doesn't read, whatever the backend."""
- backend_available(backend)
- assert recorded_backend(PUBLISHED) == "numba_cpu"
- path = tmp_path / "published.h5"
- shutil.copyfile(PUBLISHED, path)
- with pytest.raises(OutputError, match="read it with the solprop version"):
- run(backend, path, resume=True)
diff --git a/tests/driver/test_sep12_sep14_integration.py b/tests/driver/test_sep12_sep14_integration.py
index 76e5d5dc0..ed5212c26 100644
--- a/tests/driver/test_sep12_sep14_integration.py
+++ b/tests/driver/test_sep12_sep14_integration.py
@@ -10,7 +10,7 @@
import pytest
from numpy.testing import assert_allclose, assert_array_equal
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.numpy_ref import NumpyRefBackend
from solprop.driver import Simulation
from solprop.driver import tdse as runtime_module
@@ -30,7 +30,7 @@
)
from solprop.output import MemoryOutput, open_file, resume
from solprop.stats import StatsSpec
-from solprop.stepper.multistep import ComparisonMultistep, Multistep
+from solprop.stepper.multistep import Multistep
from solprop.translate.routes import tdse_descriptor
from solprop.translate.tdse import AtomicCallBudget, TDSEResult, waveform_identity
@@ -178,7 +178,7 @@ def test_fused_predictor_failure_charges_both_ledgers_before_invocation(formulat
initial = backend.observe()
assert backend._adams is not None
# The interaction formulation's fused path, or the exponential one's own.
- assert backend._adams._fused is (formulation == "interaction")
+ assert (backend._adams._exponential is None) is (formulation == "interaction")
with pytest.raises(BackendError, match="predictor chunk failure"):
backend.propagate_to(simulation.grid.length)
actual = backend.observe()
@@ -197,44 +197,6 @@ def test_fused_predictor_failure_charges_both_ledgers_before_invocation(formulat
binding.close()
-@pytest.mark.medium
-def test_corrected_provider_failure_checkpoints_the_previous_accepted_state(
- monkeypatch,
-):
- method = ComparisonMultistep(order=1, corrector="pece", corrector_order=2)
- simulation = _atomic_simulation(method=method)
- descriptor = tdse_descriptor(simulation.translate().route)
- assert descriptor is not None
- binding = _Binding(descriptor, 20, fail_at=5)
- monkeypatch.setattr(
- runtime_module, "prepare_binding", lambda *args, **kwargs: binding
- )
- output = MemoryOutput()
- with pytest.raises(BackendError, match="corrected chunk failure"):
- simulation.run(output, progress="none")
- assert binding.closed
- assert binding.purposes == ["startup", "predictor", "corrected"]
- # This guard makes rollback observable even though the manufactured nonlinear
- # current vanishes: the linear evolution changed the attempted end field.
- assert not np.array_equal(binding.inputs[0], binding.inputs[-1])
- checkpoint = output.terminal_checkpoint
- assert checkpoint is not None and checkpoint.landed
- assert checkpoint.plane.z == simulation.initial_z
- assert checkpoint.next_save_index == 0
- assert checkpoint.plane.step_history.n_steps == 0
- assert_array_equal(checkpoint.plane.field, output.fields(convention="fft")[0])
- outcome = output.outcome
- assert outcome is not None and outcome.status == "failed"
- assert outcome.z == checkpoint.plane.z
- assert outcome.logical_calls["startup"] == 1
- assert outcome.logical_calls["predictor"] == 1
- assert outcome.logical_calls["corrected"] == 1
- assert outcome.logical_calls["accepted_steps"] == 0
- assert outcome.atomic_calls["attempted"] == 5
- assert outcome.atomic_calls["failed_calls"] == 1
- assert outcome.atomic_calls["reserved"] == 0
-
-
def _install_bindings(monkeypatch):
bindings = []
diff --git a/tests/driver/test_split_block.py b/tests/driver/test_split_block.py
index 291e667a5..2556be1bc 100644
--- a/tests/driver/test_split_block.py
+++ b/tests/driver/test_split_block.py
@@ -389,8 +389,8 @@ def test_the_modified_noise_model_is_refused_beside_a_coupled_flow(rotated, grid
):
build(rotated, grid, noise="modified", seed=17).translate()
assert build(rotated, grid, noise="input", seed=17).translate() is not None
- # The backend names it too, for a problem that reaches it by hand: the
- # translation layer refuses first, so the draw is spoofed onto a clean problem.
+ # The problem's own validation names it too, for a problem that reaches a backend
+ # by hand: the assembly refuses first, so the draw is spoofed onto a clean problem.
simulation = build(rotated, grid)
problem, run_request = simulation.translate(), simulation._run_request()
initial = problem.initial
@@ -401,7 +401,7 @@ def test_the_modified_noise_model_is_refused_beside_a_coupled_flow(rotated, grid
),
)
with pytest.raises(
- BackendError, match="'modified' noise model beside a coupled flow"
+ TranslationError, match="'modified' noise model beside a coupled flow"
):
NumpyRefBackend(
spoofed,
diff --git a/tests/driver/test_summary.py b/tests/driver/test_summary.py
index 67b3e3f16..0ab96595d 100644
--- a/tests/driver/test_summary.py
+++ b/tests/driver/test_summary.py
@@ -56,7 +56,6 @@
from solprop.stepper.low_storage import LowStorageRungeKutta
from solprop.stepper.multistep import Multistep
from solprop.stepper.schedule import FixedSchedule
-from solprop.translate.precision import Precision
pytestmark = [
pytest.mark.filterwarnings("ignore::solprop.exceptions.MaterialRangeWarning"),
@@ -324,20 +323,6 @@ def test_the_planes_row_names_per_step_and_interpolated_saving():
assert planes.endswith(", and every 3 accepted steps, interpolated")
-def test_precision_is_the_state_s():
- simulation = gnlse()
- problem = simulation.translate()
- summary = run_summary(
- dataclasses.replace(problem, precision=Precision("single", "single")),
- simulation._run_request(),
- simulation.metadata(),
- backend="numpy_ref",
- execution={},
- output=MemoryOutput(),
- )
- assert dict(summary.rows)["precision"] == "single"
-
-
def test_threads_come_from_the_backend_s_record():
native = {"thread_budget": 6, "thread_budget_source": "SOLPROP_THREADS"}
assert _threads("native", native) == "budget 6 (SOLPROP_THREADS)"
diff --git a/tests/output/test_handlers.py b/tests/output/test_handlers.py
index 5415999c7..863953576 100644
--- a/tests/output/test_handlers.py
+++ b/tests/output/test_handlers.py
@@ -17,7 +17,7 @@
import pytest
from solprop import _process, fourier
-from solprop.backends.registry import REMOVED_BACKENDS
+from solprop.backends.registry import BACKEND_NAMES
from solprop.exceptions import OutputError
from solprop.grid import EnvelopeGrid, FieldGrid
from solprop.noise import input_noise
@@ -1032,7 +1032,7 @@ def test_a_file_recording_a_removed_backend_still_opens_and_resumes(
before SEP 47 retired it; the name is refused where a run asks for it
(``tests/backends/test_registry.py``), never where a file is read.
"""
- assert name in REMOVED_BACKENDS
+ assert name not in BACKEND_NAMES
record = BackendRecord.from_metadata(
{"backend": name, "propagator_mode": "recompute", **settings}
)
diff --git a/tests/recipe/test_free_space.py b/tests/recipe/test_free_space.py
index ef6134c9d..bff604de8 100644
--- a/tests/recipe/test_free_space.py
+++ b/tests/recipe/test_free_space.py
@@ -382,8 +382,6 @@ def test_the_object_routes_list_their_objects_and_leave_the_absorber_to_settings
assert resolver is not None
assert resolver({}).value == "input"
assert "transverse column" in resolver({}).rule
- single = {"settings": {"type": "SimulationSettings", "precision": "single"}}
- assert resolver(single).value is None
@pytest.mark.parametrize("key", SETTINGS_KEYWORDS)
@@ -403,6 +401,20 @@ def test_the_execution_settings_are_refused_in_the_settings_table():
assert caught.value.parameter == "settings.fields"
+def test_a_double_precision_settings_table_still_builds_and_single_is_refused():
+ """``precision = "double"`` under settings is what solprop does anyway: dropped."""
+ double = RADIAL.replace(
+ 'backend = "numpy_ref"', 'backend = "numpy_ref"\nprecision = "double"'
+ )
+ assert _hash(Recipe.loads(double).build()) == _hash(_radial_call())
+ single = Recipe.loads(double.replace('"double"', '"single"'))
+ with pytest.raises(
+ RecipeError, match=r"binary64 only \(ADR-077\); remove the key"
+ ) as caught:
+ single.build()
+ assert caught.value.parameter == "settings.precision"
+
+
def test_what_only_python_holds_points_to_the_export():
recipe = Recipe.loads(RADIAL)
for value in ([1.0, 2.0], {"file": "launch.npy", "sha256": "0" * 64}):
diff --git a/tests/recipe/test_metadata.py b/tests/recipe/test_metadata.py
index 5108cbb6a..c3f68e300 100644
--- a/tests/recipe/test_metadata.py
+++ b/tests/recipe/test_metadata.py
@@ -210,22 +210,13 @@ def test_the_resolvable_keywords_are_those_the_sep_measured():
}
-@pytest.mark.parametrize("precision", ["double", "single"])
-def test_the_resolvers_say_what_the_build_resolves(precision):
- gnlse = Simulation.from_gnlse(
- 0.11, 0.01, [0.0, 0.0, -1.183e-26], **GNLSE, precision=precision
- )
- capillary = Simulation.from_capillary(
- 125e-6, 0.1, "He", 1.0, **CAPILLARY, precision=precision
- )
+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, precision=precision
- )
+ 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, precision=precision
- )
+ planar = Simulation.from_waveguide(slab, 0.1, "Ar", 1.0, **CAPILLARY)
for route, simulation, modes in (
("gnlse", gnlse, None),
("capillary", capillary, None),
@@ -233,7 +224,7 @@ def test_the_resolvers_say_what_the_build_resolves(precision):
("waveguide", planar, slab),
):
table = metadata.parameters(route)
- values = {"precision": precision, "modes": modes}
+ values = {"modes": modes}
settings = simulation.settings
controller = settings.controller
assert controller is not None
@@ -260,7 +251,7 @@ def test_the_capillary_s_rules_follow_the_envelope_and_the_tdse():
def test_a_capillary_beside_a_tdse_reads_its_own_defaults_row():
table = metadata.parameters("capillary")
resolution = _resolve(table, "rtol", {"tdse": object()})
- assert resolution.value == default_rtol("capillary tdse", "double")
+ assert resolution.value == default_rtol("capillary tdse")
assert "the capillary tdse route's default" in resolution.rule
assert "the capillary route's default" in _resolve(table, "rtol", {}).rule
diff --git a/tests/recipe/test_recipe.py b/tests/recipe/test_recipe.py
index eb272fda3..d1d13b5af 100644
--- a/tests/recipe/test_recipe.py
+++ b/tests/recipe/test_recipe.py
@@ -130,20 +130,59 @@ def test_get_and_set_take_paths_and_check_the_value(recipe):
def test_a_recipe_naming_the_retired_mlx_backend_is_refused_by_name():
"""SEP 47 retired mlx: its recipe is refused, never run on another backend."""
retired = Recipe.loads(GNLSE.replace('backend = "numpy_ref"', 'backend = "mlx"'))
- with pytest.raises(BackendError, match="mlx backend was retired in SEP 47"):
+ with pytest.raises(BackendError, match="'mlx' is not a backend"):
retired.build()
-def test_a_grid_s_retired_transforms_keyword_is_refused_by_name():
- """SEP 47 removed the grids' transforms keyword with the mlx backend."""
+@pytest.mark.parametrize(
+ ("old", "new", "parameter", "reason"),
+ [
+ (
+ 'trange = "300 fs"',
+ 'trange = "300 fs", transforms = "fftw"',
+ "grid.transforms",
+ "transforms was retired in SEP 47",
+ ),
+ (
+ 'type = "CoherentWall"',
+ 'type = "CoherentWall", method = "cf4"',
+ "wall.method",
+ "CF4 coupled-flow sections were retired",
+ ),
+ (
+ 'type = "CoherentWall"',
+ 'type = "CoherentWall", growth_limit = 2.0',
+ "wall.growth_limit",
+ "the default frozen-residual flow is used",
+ ),
+ (
+ 'type = "CoherentWall"',
+ 'type = "CoherentWall", fallback = "frozen_residual"',
+ "wall.fallback",
+ "remove the key",
+ ),
+ ],
+)
+def test_a_retired_object_keyword_is_refused_with_its_reason(
+ old, new, parameter, reason
+):
+ """A retired keyword names why it went and the fix, not an unknown key."""
text = Recipe.example("coherent_wall").to_text()
- assert 'trange = "300 fs"' in text
- retired = Recipe.loads(
- text.replace('trange = "300 fs"', 'trange = "300 fs", transforms = "fftw"', 1)
- )
- (issue,) = retired.check()
- assert issue.parameter == "grid.transforms"
- assert "transforms was retired in SEP 47" in issue.message
+ assert old in text
+ (issue,) = Recipe.loads(text.replace(old, new, 1)).check()
+ assert issue.parameter == parameter
+ assert reason in issue.message
+ assert "unknown key" not in issue.message
+
+
+def test_a_double_precision_recipe_still_loads_and_single_is_refused():
+ """``precision = "double"`` is what solprop does anyway, so it is dropped."""
+ double = Recipe.loads(GNLSE.replace("[build]\n", '[build]\nprecision = "double"\n'))
+ with pytest.raises(RecipeError):
+ double.get("precision")
+ assert _hash(double.build()) == _hash(Recipe.loads(GNLSE).build())
+ with pytest.raises(RecipeError, match=r"binary64 only \(ADR-077\); remove the key"):
+ Recipe.loads(GNLSE.replace("[build]\n", '[build]\nprecision = "single"\n'))
def test_unknown_keys_and_routes_are_refused():
diff --git a/tests/service/test_results.py b/tests/service/test_results.py
index abb840662..a920f9aeb 100644
--- a/tests/service/test_results.py
+++ b/tests/service/test_results.py
@@ -272,7 +272,6 @@ def test_run_health_reads_what_the_record_holds(
) -> None:
assert results._health_facts({}) == {
"backend": None,
- "precision": None,
"threads": None,
"wall_time": None,
"peak_memory": None,
diff --git a/tests/service/test_sheet.py b/tests/service/test_sheet.py
index 540737b3d..ecd5ab801 100644
--- a/tests/service/test_sheet.py
+++ b/tests/service/test_sheet.py
@@ -14,6 +14,7 @@
from solprop import tools
from solprop.recipe import Recipe
from solprop.service.handlers import HANDLERS
+from solprop.service.messages import PROTOCOL
from solprop.service.sheet import (
CURVE_POINTS,
_curve_range,
@@ -313,10 +314,12 @@ def test_the_run_options_are_solprop_s() -> None:
options: Any = HANDLERS["run_options"]({})
# A kernel run in this process may have set its own budget: a run's is at least it.
assert options["threads"] >= 1
- assert options["double"] == list(available("double"))
- assert "native" in options["double"] or "numpy_ref" in options["double"]
- assert options["single"] == ["numpy_ref"]
- assert "metal" not in options # the mlx backend was retired in SEP 47
+ assert options["backends"] == list(available())
+ assert "native" in options["backends"] or "numpy_ref" in options["backends"]
+ assert set(options) == {"threads", "limit", "backends"}
+ # The reply's shape changed from protocol 1's `double` and `single`, so a peer of
+ # protocol 1 is refused by version rather than reading a key that is gone.
+ assert PROTOCOL >= 2
def test_the_threads_offered_are_a_run_s_not_the_kernel_s(
diff --git a/tests/sessions/executors.py b/tests/sessions/executors.py
index 7e0f87df6..ace204f9a 100644
--- a/tests/sessions/executors.py
+++ b/tests/sessions/executors.py
@@ -76,7 +76,6 @@
from solprop.stepper.low_storage import LowStorageRungeKutta
from solprop.stepper.multistep import Multistep
from solprop.stepper.schedule import FixedSchedule
-from solprop.translate.precision import DOUBLE, Precision
from solprop.translate.problem import PropagationProblem, Translation, assemble
from solprop.translate.run_request import (
IntegrationSpec,
@@ -155,7 +154,6 @@ def soliton(
interpolate: bool = False,
integrator: Multistep | None = None,
planes: Collection[float] = PLANES,
- precision: Precision = DOUBLE,
) -> Translation:
"""Return the fundamental soliton over :data:`PLANES`, and its run's request.
@@ -171,8 +169,6 @@ def soliton(
The Adams method; the default Runge-Kutta tableau by default.
planes : collection of float, optional
The save planes, m, increasing and ending at :data:`LENGTH`.
- precision : solprop.translate.precision.Precision, optional
- The run's arithmetic; binary64 by default.
Returns
-------
@@ -196,7 +192,6 @@ def soliton(
step_planes=step_planes,
interpolate_planes=interpolate,
integrator=integrator,
- precision=precision,
stats_spec=StatsSpec(
names=("energy", "peak_power"), periodic=("energy",), period=period
),
diff --git a/tests/sessions/runs.py b/tests/sessions/runs.py
index 5dc44cca9..32cebc653 100644
--- a/tests/sessions/runs.py
+++ b/tests/sessions/runs.py
@@ -46,7 +46,7 @@
from sessions.executors import IDENTITY, Plane, Run, Started, drive
from solprop.backends.events import EventBatch, ObservationReady, Terminal
from solprop.backends.export import ExportRecipe
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.protocol import StepSummary
from solprop.backends.registry import select
from solprop.backends.session import ExecutionOptions, RunSession, Start
@@ -641,7 +641,7 @@ def adams_ledger(backend: Any, record: Mapping[str, object]) -> CallLedger:
Returns
-------
- solprop.backends.multistep.CallLedger
+ solprop.backends.numpy_ref.multistep.CallLedger
The ledger.
"""
if backend.name == "numpy_ref":
diff --git a/tests/sessions/test_destination_export.py b/tests/sessions/test_destination_export.py
index 43c9912fe..3b1ea4c48 100644
--- a/tests/sessions/test_destination_export.py
+++ b/tests/sessions/test_destination_export.py
@@ -299,18 +299,12 @@ def save(self, plane: SavedPlane) -> None:
super().save(plane)
-@pytest.mark.parametrize(
- ("precision", "noise"),
- [("double", False), ("double", True), ("single", False)],
- ids=["double", "noise", "single"],
-)
-def test_a_run_stores_the_bits_of_the_two_step_path(precision, noise):
+@pytest.mark.parametrize("noise", [False, True], ids=["double", "noise"])
+def test_a_run_stores_the_bits_of_the_two_step_path(noise):
"""The reference's whole run, exported into the rows or converted by the store.
- A Cartesian Kerr run, with and without a noise model (which single precision
- refuses), in double and single precision (whose widened state carries its
- power-of-two scale): the stored fields and noise phases are the bits the
- two-step path stores.
+ A Cartesian Kerr run, with and without a noise model: the stored fields and noise
+ phases are the bits the two-step path stores.
"""
from driver.test_field_free_planes import cartesian # noqa: PLC0415
@@ -319,9 +313,6 @@ def test_a_run_stores_the_bits_of_the_two_step_path(precision, noise):
assert not stores["two steps"].takes_exports
for store in stores.values():
simulation = cartesian("numpy_ref", noise=noise)
- simulation.settings = dataclasses.replace(
- simulation.settings, precision=precision
- )
simulation.run(output=store, progress="none")
names = ["field", "z", *(["noise_phase"] if noise else [])]
for name in names:
diff --git a/tests/sessions/test_group_seam.py b/tests/sessions/test_group_seam.py
index df4328122..c916d9347 100644
--- a/tests/sessions/test_group_seam.py
+++ b/tests/sessions/test_group_seam.py
@@ -2,221 +2,48 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
-"""A backend registers with one row and runs its own group through the seam (SEP 47).
+"""A group starts through its backend's own ``prepare_group`` (SEP 47 section 6.7).
-``toy`` is a test-only backend: the reference's arithmetic under another registry
-name, with a prepared group of its own and its own lockstep runner. It is registered
-with one :class:`~solprop.backends.registry.BackendEntry`, nothing else in solprop
-names it, and :func:`solprop.backends.registry.select`,
-:func:`~solprop.backends.registry.select_batch` and
-:func:`solprop.backends.sessions.start_group` reach it through that row and its
-``prepare_group`` alone (SEP 47 sections 6.1, 6.7).
+:func:`solprop.backends.sessions.start_group` asks the members' backend to prepare the
+group, so a backend that runs no group refuses it there, before any member is loaded.
"""
-from collections.abc import Sequence
from contextlib import ExitStack
-from typing import Any, ClassVar
-import numpy as np
import pytest
-from bounds import assert_within
-from sessions.test_group import FIELD_BOUND, IDENTITY, PLANES, drive, member
-from solprop.backends import registry
-from solprop.backends.events import ObservationReady, Outcome
-from solprop.backends.host.driver import HostDriver
-from solprop.backends.host.group import HostGroup
-from solprop.backends.host.group_session import HostGroupSession, HostPreparedGroup
+from sessions.test_group import member
from solprop.backends.numpy_ref import NumpyRefBackend
-from solprop.backends.protocol import Backend, BatchBackend
-from solprop.backends.registry import BackendEntry, select, select_batch
-from solprop.backends.session import GroupSession, PreparedGroup, Start
-from solprop.backends.sessions import start_group, start_session
-from solprop.driver import Simulation
+from solprop.backends.registry import select
+from solprop.backends.session import Start
+from solprop.backends.sessions import start_group
from solprop.exceptions import BackendError
-from solprop.log import StopFlag
-from solprop.translate.problem import PropagationProblem
-from solprop.translate.run_request import RunRequest
-class Toy(NumpyRefBackend):
- """The reference's arithmetic under another name, preparing its own group."""
-
- name = "toy"
-
- def prepare_group(
- self,
- members: Sequence[Backend],
- problems: Sequence[PropagationProblem],
- requests: Sequence[RunRequest],
- ) -> PreparedGroup:
- """Prepare the toy's own group."""
- return ToyGroup(members, problems, requests)
-
-
-class ToyGroup(HostPreparedGroup):
- """The toy's prepared group: the group over its lockstep runner, counted."""
-
- started: ClassVar[list[int]] = []
-
- def start(
- self, starts: Sequence[Start], *, stop: StopFlag | None = None
- ) -> HostGroupSession:
- """Record the start and open the group."""
- ToyGroup.started.append(len(starts))
- return super().start(starts, stop=stop)
-
-
-def lockstep(owners: Sequence[Backend]) -> BatchBackend:
- """Return the toy's lockstep runner: each member's own trial, in turn."""
- return HostGroup([member for member in owners if isinstance(member, HostDriver)])
-
-
-TOY = BackendEntry(
- requires=(),
- extras=(),
- factory=f"{__name__}:Toy",
- batch=f"{__name__}:lockstep",
-)
-"""The toy's one registry row."""
-
-
-@pytest.fixture
-def toy(monkeypatch: pytest.MonkeyPatch) -> None:
- """Register the toy for one test."""
- monkeypatch.setitem(registry.BACKENDS, "toy", TOY)
- monkeypatch.setattr(ToyGroup, "started", [])
-
-
-def constructed(name: str, simulation: Simulation, cleanup: ExitStack) -> Any:
- """Return a member's backend of the named row, constructed and holding no state."""
- owner = select(
- name,
- simulation.translate(),
- simulation._run_request(),
- grid=simulation.grid,
- modes=simulation.modes,
- profiles=simulation.profiles,
- )
- cleanup.callback(owner.close)
- return owner
-
-
-def fields_of(name: str, simulations: Sequence[Simulation]) -> list[list[np.ndarray]]:
- """Run the members as one group of the named row; return each member's planes."""
- problems = [one.translate() for one in simulations]
- fields: list[list[np.ndarray]] = [[] for _ in simulations]
- with ExitStack() as cleanup:
- owners = [constructed(name, one, cleanup) for one in simulations]
- group: GroupSession = start_group(
- owners,
- problems,
- [one._run_request() for one in simulations],
- [Start.entrance(problem) for problem in problems],
- )
- cleanup.callback(group.close)
- outcome = None
- while not group.finished:
- batch = group.advance()
- outcome = batch.outcome
- for index, events in enumerate(batch.members):
- for event in events:
- if isinstance(event, ObservationReady):
- fields[index].append(
- np.array(group.export(index, event.ticket, IDENTITY))
- )
- group.acknowledge(batch.receipt)
- assert outcome is Outcome.COMPLETED
- for index in range(len(simulations)):
- status = group.member_status(index)
- assert status.outcome is Outcome.COMPLETED
- assert status.z == pytest.approx(PLANES[-1])
- return fields
-
-
-def alone(simulation: Simulation) -> list[np.ndarray]:
- """Run one member alone on the reference; return its planes."""
- problem = simulation.translate()
- planes = []
- with ExitStack() as cleanup:
- owner = constructed("numpy_ref", simulation, cleanup)
- lone = start_session(
- owner, problem, simulation._run_request(), Start.entrance(problem)
- )
- cleanup.callback(lone.close)
- while not lone.finished:
- batch = lone.advance()
- for event in batch.events:
- if isinstance(event, ObservationReady):
- planes.append(np.array(lone.export(event.ticket, IDENTITY)))
- lone.acknowledge(batch.receipt)
- return planes
-
-
-@pytest.mark.usefixtures("toy")
-def test_a_registered_backend_runs_its_own_group_through_the_seam():
- simulations = [member(index, "numpy_ref") for index in range(2)]
- with ExitStack() as cleanup:
- owner = constructed("toy", simulations[0], cleanup)
- assert type(owner) is Toy
- toy = fields_of("toy", simulations)
- # The toy prepared and started its own group, once, over its own runner.
- assert ToyGroup.started == [2]
- reference = [alone(one) for one in simulations]
- for index, (planes, expected) in enumerate(zip(toy, reference, strict=True)):
- assert len(planes) == len(expected) == len(PLANES) + 1
- for one, other in zip(planes, expected, strict=True):
- assert_within(one, other, FIELD_BOUND, what=f"member {index}")
-
-
-@pytest.mark.usefixtures("toy")
-def test_the_lockstep_runner_is_the_registered_row_s(monkeypatch):
- simulations = [member(index, "numpy_ref") for index in range(2)]
- problems = [one.translate() for one in simulations]
- with ExitStack() as cleanup:
- owners = [constructed("toy", one, cleanup) for one in simulations]
- group = start_group(
- owners,
- problems,
- [one._run_request() for one in simulations],
- [Start.entrance(problem) for problem in problems],
- )
- cleanup.callback(group.close)
- assert type(getattr(group, "_runner")) is HostGroup # noqa: B009 - private
- drive(group)
- monkeypatch.setitem(registry.BACKENDS, "toy", BackendEntry((), (), TOY.factory))
- with ExitStack() as cleanup:
- owners = [constructed("toy", one, cleanup) for one in simulations]
- for owner, problem in zip(owners, problems, strict=True):
- owner.set_initial(np.asarray(Start.entrance(problem).take_field()), None)
- with pytest.raises(BackendError, match="Batch has no runner for backend 'toy'"):
- select_batch(owners)
-
-
-@pytest.mark.usefixtures("toy")
def test_a_backend_that_runs_no_group_refuses_it_at_the_door(monkeypatch):
def refused(self, members, problems, requests):
- raise BackendError("the toy runs no group")
+ raise BackendError("the reference runs no group")
- monkeypatch.setattr(Toy, "prepare_group", refused)
+ monkeypatch.setattr(NumpyRefBackend, "prepare_group", refused)
simulations = [member(index, "numpy_ref") for index in range(2)]
problems = [one.translate() for one in simulations]
with ExitStack() as cleanup:
- owners = [constructed("toy", one, cleanup) for one in simulations]
- with pytest.raises(BackendError, match="the toy runs no group"):
+ owners = []
+ for simulation in simulations:
+ owner = select(
+ "numpy_ref",
+ simulation.translate(),
+ simulation._run_request(),
+ grid=simulation.grid,
+ modes=simulation.modes,
+ profiles=simulation.profiles,
+ )
+ cleanup.callback(owner.close)
+ owners.append(owner)
+ with pytest.raises(BackendError, match="the reference runs no group"):
start_group(
owners,
problems,
[one._run_request() for one in simulations],
[Start.entrance(problem) for problem in problems],
)
- assert ToyGroup.started == []
-
-
-@pytest.mark.usefixtures("toy")
-def test_a_row_without_a_device_is_refused_a_required_device_by_name(monkeypatch):
- monkeypatch.setenv(registry.REQUIRE_DEVICE_SETTING, "any")
- with pytest.raises(BackendError, match="this run is on toy"):
- registry._check_required_device("toy")
- assert not registry._device_build("toy")
- assert registry.statistics_refusal("toy", ["beam_spectrum"]) is None
diff --git a/tests/sessions/test_native_load.py b/tests/sessions/test_native_load.py
index f6d58ffda..5dbc05256 100644
--- a/tests/sessions/test_native_load.py
+++ b/tests/sessions/test_native_load.py
@@ -22,7 +22,7 @@
low_storage,
soliton,
)
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.registry import select
from solprop.backends.session import Start
from solprop.exceptions import BackendError
diff --git a/tests/sessions/test_native_quadrature_alarm.py b/tests/sessions/test_native_quadrature_alarm.py
index a4a01292b..a58d04a37 100644
--- a/tests/sessions/test_native_quadrature_alarm.py
+++ b/tests/sessions/test_native_quadrature_alarm.py
@@ -6,8 +6,8 @@
A rule too coarse for the field is a statistic (``quadrature_error``) and one logged
warning a plane, never a controller input. The native session reads the largest
-accepted step's score once an advance and gives the reference's warning, on its logger
-and at its threshold (``solprop.backends.numpy_ref.QUADRATURE_ALARM``), once between
+accepted step's score once an advance and gives the shared warning
+(``solprop.translate.modal.warn_quadrature``, at ``QUADRATURE_ALARM``), once between
two observations; a rule that resolves the field gives none.
"""
@@ -65,7 +65,7 @@ def _warnings(name: str, n_r: int, caplog: pytest.LogCaptureFixture) -> list[str
grid, modes, problem, request = _problem(n_r)
backend = select(name, problem, request, grid=grid, modes=modes)
caplog.clear()
- with caplog.at_level(logging.WARNING, logger="solprop.backends.numpy_ref.backend"):
+ with caplog.at_level(logging.WARNING, logger="solprop.translate.modal"):
session = start_session(backend, problem, request, entrance(problem))
drive(Started(session))
if name == "native":
diff --git a/tests/sessions/test_native_session.py b/tests/sessions/test_native_session.py
index a707f81da..543f6d31b 100644
--- a/tests/sessions/test_native_session.py
+++ b/tests/sessions/test_native_session.py
@@ -55,7 +55,7 @@
from solprop.stats import StatsSpec
from solprop.stepper import PIController
from solprop.stepper.low_storage import LowStorageRungeKutta
-from solprop.stepper.multistep import ComparisonMultistep, Multistep
+from solprop.stepper.multistep import Multistep
from solprop.stepper.schedule import FixedSchedule
from solprop.translate.problem import Translation, assemble
@@ -427,14 +427,6 @@ def built(*args, **kwargs):
)
-class _OwnAccept(PIController):
- """A controller that overrides a decision: a reference-only experiment."""
-
- def accepts(self, norm):
- """Accept as the base does."""
- return super().accepts(norm)
-
-
@pytest.mark.parametrize(
("build", "name"),
[
@@ -456,54 +448,6 @@ def test_the_prototype_refuses_by_name_before_allocating(build, name, monkeypatc
_refused(problem, run_request.for_run(**settings), name, monkeypatch)
-@pytest.mark.parametrize(
- ("options", "name"),
- [
- (
- {"integrator": ComparisonMultistep(corrector="pece", order=4)},
- "the comparison variant adams-pece4: ",
- ),
- (
- {
- "integrator": ComparisonMultistep(
- corrector="pec", order=4, corrector_order=5
- )
- },
- "the comparison variant adams-pec4/5: ",
- ),
- (
- {"controller": _OwnAccept(rtol=1e-4, atol=1e-16)},
- "the controller _OwnAccept, which overrides PIController's accepts: ",
- ),
- ],
- ids=["comparison", "selected-pec-comparison", "controller"],
-)
-def test_a_reference_only_run_reaches_no_session(options, name, monkeypatch):
- """SEP 47 section 6.3: native refuses a reference-only run where it is built.
-
- A comparison method and a controller that overrides a decision run on
- ``numpy_ref`` alone: the backend refuses them by name before the native state,
- its first allocation, is built, so no native session ever runs them.
- """
- problem, run_request = _case(**options)
- built_states = []
- monkeypatch.setattr(
- native_session._core, "State", lambda *args: built_states.append(args)
- )
- # The session's own refusal never sees them: the backend's admission is first.
- assert native_session.refusal(problem, run_request) is None
- with pytest.raises(BackendError, match="reference-only") as selected:
- select("native", problem, run_request, grid=window(), modes=[fibre().mode])
- with pytest.raises(BackendError, match="reference-only") as opened:
- native_session.NativeSession(
- problem, run_request, entrance(problem), grid=window(), modes=[fibre().mode]
- )
- for refused in (selected, opened):
- assert name in str(refused.value)
- assert "backend='numpy_ref'" in str(refused.value)
- assert built_states == []
-
-
def test_a_session_leases_its_state_exclusively():
"""SEP 47 section 5.2: the session is the one mutable owner of its state.
diff --git a/tests/sessions/test_native_session_axis_plasma.py b/tests/sessions/test_native_session_axis_plasma.py
index 76945e899..85fb1cbad 100644
--- a/tests/sessions/test_native_session_axis_plasma.py
+++ b/tests/sessions/test_native_session_axis_plasma.py
@@ -11,7 +11,7 @@
natively. At every observation the session's statistics are the plan's own
evaluation of its exported field, loaded into the native state (bit for bit on a
deterministic build), and are held to the NumPy reference's host scan of the same
-field (:func:`solprop.backends.populations.axis_populations`) within the node
+field (:func:`solprop.backends.numpy_ref.populations.axis_populations`) within the node
scans' parity tolerance. An envelope's scan reads the polarisation ellipse, so on a
one-component transform whose modes have a y field on the axis the session scans the
row's two components as one node of two.
diff --git a/tests/sessions/test_session_records.py b/tests/sessions/test_session_records.py
index 3193af1bd..7c5b80805 100644
--- a/tests/sessions/test_session_records.py
+++ b/tests/sessions/test_session_records.py
@@ -18,18 +18,15 @@
from sessions.executors import (
CONSUMER,
- EXECUTORS,
PLANES,
STORED,
ScriptedError,
- entrance,
soliton,
spy_monitor,
)
from solprop.backends.events import ObservationKind, ObservationReady, Outcome
from solprop.backends.session import RunSession
from solprop.stepper import Multistep
-from solprop.translate.precision import SINGLE
# The soliton's 1 ps window is tight for a monitor's guards; the window is not tested.
pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning")
@@ -71,17 +68,6 @@ def test_a_second_recipe_after_an_in_place_conversion_leaves_the_first_alone(sta
_second_recipe_leaves_the_first_alone(start(soliton()).session)
-def test_an_owned_single_precision_field_is_converted_once_and_left_alone():
- """The same, where the driver owns the observed field: the reference's widened
- single-precision state (SEP 33), which its session converts in place."""
- problem, request = soliton(precision=SINGLE)
- started = EXECUTORS["reference"].start(problem, request, entrance(problem))
- try:
- _second_recipe_leaves_the_first_alone(started.session)
- finally:
- started.session.close()
-
-
def _second_recipe_leaves_the_first_alone(session: RunSession) -> None:
"""Export a first plane for the store, again, for a consumer and into a room."""
_, plane = _first_plane(session)
diff --git a/tests/stepper/test_controller.py b/tests/stepper/test_controller.py
index 2ff118da1..3bc082190 100644
--- a/tests/stepper/test_controller.py
+++ b/tests/stepper/test_controller.py
@@ -10,7 +10,7 @@
import numpy as np
import pytest
-from solprop.exceptions import ConvergenceError
+from solprop.exceptions import ConvergenceError, StepperError
from solprop.stepper.controller import (
DZ_MIN_RELATIVE,
ControlRecord,
@@ -396,3 +396,13 @@ def test_an_empty_record_has_no_worst_step_and_meets_tolerance():
record = ControlRecord.from_norms(BASE_CONTROLLER, [])
assert record.worst_step == -1
assert record.met_tolerance
+
+
+def test_the_controller_is_final():
+ # Both backends implement PIController's decisions and norm themselves, so a
+ # subclass that changed one would run differently on each: it is refused when the
+ # class is defined (ADR-077).
+ with pytest.raises(StepperError, match="PIController is final: _OwnAccept"):
+
+ class _OwnAccept(PIController): # pyright: ignore[reportGeneralTypeIssues]
+ pass
diff --git a/tests/stepper/test_multistep.py b/tests/stepper/test_multistep.py
index db08fba8d..49a7d8447 100644
--- a/tests/stepper/test_multistep.py
+++ b/tests/stepper/test_multistep.py
@@ -12,12 +12,9 @@
from bounds import gamma
from solprop.exceptions import StepperError
from solprop.stepper.multistep import (
- ComparisonMultistep,
Multistep,
- defect_integral,
integrated_weights,
lagrange_coefficients,
- milne_factor,
)
@@ -86,19 +83,6 @@ def test_irregular_moment_witness_rejects_wrong_weights(defect):
_assert_scaled_moments(weights, nodes, upper)
-def test_defect_constants_and_milne_factor():
- # 251/720 and -19/720 times 4!, and the corrector's defect -19/270 of v_C - v_P.
- assert defect_integral([0, -1, -2, -3]) / math.factorial(4) == pytest.approx(
- 251 / 720, rel=1e-13
- )
- assert defect_integral([1, 0, -1, -2]) / math.factorial(4) == pytest.approx(
- -19 / 720, rel=1e-13
- )
- assert milne_factor([0, -1, -2, -3], [1, 0, -1, -2]) == pytest.approx(
- -19 / 270, rel=1e-13
- )
-
-
def test_fifth_difference_is_the_ab4_defect():
# The quartic and cubic predictors differ by 251/720 times the fourth backward
# difference (SEP 13 section 4.3).
@@ -109,54 +93,9 @@ def test_fifth_difference_is_the_ab4_defect():
)
-@pytest.mark.parametrize(
- ("method", "name"),
- [
- (ComparisonMultistep(corrector="none", order=4), "adams-ab4"),
- (ComparisonMultistep(corrector="pec", order=3), "adams-pec3"),
- (
- ComparisonMultistep(corrector="pec", order=1, corrector_order=2),
- "adams-pec1/2",
- ),
- (
- ComparisonMultistep(corrector="pece", order=5, corrector_order=5),
- "adams-pece5",
- ),
- (
- ComparisonMultistep(order=4, corrector_order=5, variable_order=True),
- "adams-pece4/5-variable",
- ),
- ],
-)
-def test_multistep_name(method, name):
- assert method.name == name
-
-
-@pytest.mark.parametrize(
- "fields",
- [
- {"order": 0},
- {"order": 7},
- {"order": 3, "corrector_order": 5},
- {"max_growth": 1.0},
- {"min_factor": 1.0},
- {"safety": 0.0},
- {"order": 4, "variable_order": True},
- {"corrector": "none", "variable_order": True},
- ],
-)
-def test_multistep_refuses_out_of_range(fields):
- with pytest.raises(StepperError):
- ComparisonMultistep(**fields)
-
-
def test_algebra_refuses_degenerate_input():
with pytest.raises(StepperError, match="distinct"):
integrated_weights([0.0, 0.0])
- with pytest.raises(StepperError, match="equal node counts"):
- milne_factor([0, -1], [1, 0, -1])
- with pytest.raises(StepperError, match="coincide"):
- milne_factor([1, 0], [1, 0])
with pytest.raises(StepperError, match="non-empty and finite"):
integrated_weights([])
with pytest.raises(StepperError, match="non-empty and finite"):
@@ -186,24 +125,28 @@ def test_selected_method_defaults():
method = Multistep()
assert method.name == "exponential-adams-pec5/6-variable"
assert method.formulation == "exponential"
- assert method.corrector == "pec"
- assert method.corrector_nodes(3) == 4
interaction = Multistep(order=3, variable_order=False, formulation="interaction")
assert interaction.name == "adams-pec3/4"
def test_the_representation_names_the_formulation():
# A run's record hashes the representation: it names the
- # formulation whichever it is, and the comparison variants keep the interaction one.
+ # formulation whichever it is.
# Since SEP 35 it names the carrier frame too, the default "off" included.
assert repr(Multistep()).endswith("formulation='exponential', carrier_frame='off')")
assert "formulation='interaction'" in repr(Multistep(formulation="interaction"))
- assert ComparisonMultistep(order=4).formulation == "interaction"
@pytest.mark.parametrize(
"fields",
- [{"order": 0}, {"order": 7}, {"max_calls": 0}, {"max_growth": 1.0}],
+ [
+ {"order": 0},
+ {"order": 7},
+ {"max_calls": 0},
+ {"max_growth": 1.0},
+ {"min_factor": 1.0},
+ {"safety": 0.0},
+ ],
)
def test_selected_method_refuses_out_of_range(fields):
with pytest.raises(StepperError):
@@ -315,11 +258,3 @@ def test_the_basis_is_the_python_loops_to_rounding(seed):
got_rows = lagrange_coefficients(nodes, check=False)
assert_basis_close(got_weights, weights, weight_bound, count)
assert_basis_close(got_rows, rows, row_bound, count)
-
-
-def test_any_change_to_a_comparison_variant_restarts_its_history():
- variant = ComparisonMultistep(corrector="pec", order=3)
- assert variant.history_key == repr(variant)
- assert (
- variant.history_key != ComparisonMultistep(corrector="pec", order=4).history_key
- )
diff --git a/tests/test_benchmark_ab.py b/tests/test_benchmark_ab.py
index 2ad0654f1..7024ff62d 100644
--- a/tests/test_benchmark_ab.py
+++ b/tests/test_benchmark_ab.py
@@ -214,14 +214,13 @@ def test_small_case_runs_both_builds_in_alternating_order(
assert read_record(out / "plan.json")["harness_files"]
-def test_each_arm_runs_its_own_backend_in_double_precision():
- values = {"case": "x", "backend": "native", "precision": "single"}
+def test_each_arm_runs_its_own_backend():
+ values = {"case": "x", "backend": "native"}
assert ab.arm_configuration(values, "numpy_ref") == {
"case": "x",
"backend": "numpy_ref",
- "precision": "double",
}
- assert ab.arm_configuration(values, "native")["precision"] == "double"
+ assert ab.arm_configuration(values, "native")["backend"] == "native"
def test_each_arm_takes_its_own_environment(tmp_path, monkeypatch):
diff --git a/tests/test_benchmark_cases.py b/tests/test_benchmark_cases.py
index b8d08873c..6cc104d90 100644
--- a/tests/test_benchmark_cases.py
+++ b/tests/test_benchmark_cases.py
@@ -236,13 +236,8 @@ def test_tracked_definitions_change_only_with_a_new_revision():
assert set(revisions.values()) == {1}
-def test_a_single_precision_variant_is_noiseless_and_a_pow2_grid_a_power_of_two():
- values = {**configuration("he_rdw_averaged"), "precision": "single"}
- single = build(values)
- assert single.settings.precision == "single"
- assert single.noise is None or single.noise.model is None
+def test_a_noiseless_variant_is_noiseless_and_a_pow2_grid_a_power_of_two():
quiet = build({**configuration("he_rdw_averaged"), "noise": "none"})
- assert quiet.settings.precision == "double"
assert quiet.noise is None or quiet.noise.model is None
pow2 = build({**configuration("he_rdw_averaged"), "fft_size": "pow2"})
n_t = pow2.grid.n_t
@@ -252,7 +247,7 @@ def test_a_single_precision_variant_is_noiseless_and_a_pow2_grid_a_power_of_two(
assert spatial.grid.n_t & (spatial.grid.n_t - 1) == 0
-def test_every_configuration_runs_in_double_precision():
+def test_every_configuration_runs_on_the_backend_asked_for():
from benchmarks.runner import plan_rows # noqa: PLC0415
from benchmarks.track import BACKENDS # noqa: PLC0415
@@ -267,7 +262,7 @@ def test_every_configuration_runs_in_double_precision():
fft_size="pow2",
)
assert "he_rdw_averaged.multistep-interaction.t1" in [row["id"] for row in rows]
- assert all(row["configuration"]["precision"] == "double" for row in rows)
+ assert all(row["configuration"]["backend"] == "numpy_ref" for row in rows)
assert all(row["configuration"]["fft_size"] == "pow2" for row in rows)
assert all("fixed-step case" in reason for reason in skipped)
diff --git a/tests/test_convergence_records.py b/tests/test_convergence_records.py
index 7c15795d0..272e388a0 100644
--- a/tests/test_convergence_records.py
+++ b/tests/test_convergence_records.py
@@ -24,7 +24,7 @@
import pytest
import convergence_records as cr
-from solprop.backends.registry import BACKENDS, available
+from solprop.backends.registry import BACKEND_NAMES, available
RETIRED_BACKENDS = frozenset({"numba_cuda", "numba_cpu", "mlx"})
"""Backends whose rows stay as evidence after the backend itself was deleted."""
@@ -220,7 +220,7 @@ def _writer_exists(node, collected):
if node in collected:
return True
base, _, parameter = node.partition("[")
- absent = (set(BACKENDS) | RETIRED_BACKENDS) - set(available())
+ absent = (set(BACKEND_NAMES) | RETIRED_BACKENDS) - set(available())
coordinates = parameter.rstrip("]").split("-")
for index, coordinate in enumerate(coordinates):
if coordinate not in absent:
@@ -260,7 +260,7 @@ def test_a_writer_of_a_removed_backend_counts_as_existing():
"""A row of a deleted backend keeps its numbers without a writer to collect."""
collected = {"tests/backends/test_x.py::test_y[numpy_ref]"}
for removed in RETIRED_BACKENDS:
- assert removed not in BACKENDS
+ assert removed not in BACKEND_NAMES
assert _writer_exists(f"tests/backends/test_x.py::test_y[{removed}]", collected)
assert not _writer_exists(
f"tests/backends/test_z.py::test_y[{removed}]", collected
@@ -285,7 +285,7 @@ def test_a_writer_of_an_absent_backend_counts_and_a_renamed_one_does_not(monkeyp
"""The relaxation is exactly one parametrisation on an uninstalled backend."""
monkeypatch.setattr("test_convergence_records.available", lambda: ("numpy_ref",))
collected = {"tests/backends/test_x.py::test_y[numpy_ref]"}
- assert "native" in BACKENDS
+ assert "native" in BACKEND_NAMES
assert _writer_exists("tests/backends/test_x.py::test_y[native]", collected)
assert not _writer_exists("tests/backends/test_z.py::test_y[native]", collected)
assert _writer_exists("tests/backends/test_x.py::test_y[numpy_ref]", collected)
diff --git a/tests/test_display.py b/tests/test_display.py
index 80c4ec35f..c46895a89 100644
--- a/tests/test_display.py
+++ b/tests/test_display.py
@@ -205,8 +205,6 @@ def modal_records():
# The coherent wall block: its declaration, the ratios it was admitted under and
# the operator itself.
"CoherentWall": CoherentWall(),
- # Its CF4 declaration, whose growth limit and fallback only a CF4 wall prints.
- "CoherentWallCF4": CoherentWall(method="cf4", fallback="frozen_residual"),
# The moving-basis connection: a synthetic two-channel record, because the
# measured one costs three provider solves and this gallery is about the
# printing.
@@ -517,7 +515,6 @@ def examples(tmp_path_factory):
"CompressionBounces",
"CepOptimum",
"CoherentWall",
- "CoherentWallCF4",
"ModalConnection",
"WallOperator",
"WallLimits",
@@ -658,8 +655,8 @@ def test_the_run_record_is_what_it_was_before_the_printing(examples):
# the transverse quadrature, the norm's region in the controller, the
# interaction formulation's name in an Adams method's representation, the
# held-current identity, whose record and hash
- # separation have their own held-source contract test, the precision, which its
- # own resume test separates (SEP 33), and the absorber's spatial flag (SEP 21).
+ # separation have their own held-source contract test, and the absorber's spatial
+ # flag (SEP 21).
# Every built-in material is optmat's since SEP 3, so the manifest is never
# empty here.
#
@@ -680,7 +677,7 @@ def test_the_run_record_is_what_it_was_before_the_printing(examples):
hashed["configuration"] = {
key: value
for key, value in hashed["configuration"].items()
- if key not in ("plasma_source", "precision")
+ if key != "plasma_source"
}
stepper = dict(hashed["stepper"])
stepper["controller"] = {
@@ -938,7 +935,6 @@ def rounding(block: str) -> str:
"SolitonParams",
"CompressionTaylor",
"CoherentWall",
- "CoherentWallCF4",
"ModalConnection",
"WallOperator",
"WallLimits",
diff --git a/tests/test_display/CoherentWall.txt b/tests/test_display/CoherentWall.txt
index e63d10367..a11e069b4 100644
--- a/tests/test_display/CoherentWall.txt
+++ b/tests/test_display/CoherentWall.txt
@@ -3,5 +3,4 @@ CoherentWall: the condensed Robin wall of a capillary block
condition limit: 10000
validity limit: 0.25
sections per interval: 1
- varying method: frozen_residual
model: mean impedance, paraxial, frozen per section
diff --git a/tests/test_display/CoherentWallCF4.txt b/tests/test_display/CoherentWallCF4.txt
deleted file mode 100644
index cc5b46cf8..000000000
--- a/tests/test_display/CoherentWallCF4.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-CoherentWall: the condensed Robin wall of a capillary block
- wall impedance: mean
- condition limit: 10000
- validity limit: 0.25
- sections per interval: 1
- varying method: cf4, growth limit default, fallback frozen_residual
- model: mean impedance, paraxial, commutator-free per section
diff --git a/tests/test_grid.py b/tests/test_grid.py
index 0e8b2e9be..b7373a936 100644
--- a/tests/test_grid.py
+++ b/tests/test_grid.py
@@ -175,34 +175,6 @@ def test_auto_sizes_cost_no_more_than_the_power_of_two():
assert auto.n_to % auto.n_t == 0
-def test_a_single_precision_interface_grid_is_the_double_one():
- from solprop.driver import Simulation # noqa: PLC0415
- from solprop.translate.precision import PrecisionName # noqa: PLC0415
-
- sizes = {}
- runs: tuple[tuple[PrecisionName, str], ...] = (
- ("double", "auto"),
- ("single", "numpy_ref"),
- )
- for precision, backend in runs:
- simulation = Simulation.from_capillary(
- 125e-6,
- 1.5,
- "He",
- (0.8, 0.0),
- wavelength=800e-9,
- wavelength_limits=(90e-9, 4e-6),
- trange=400e-15,
- fwhm=7.5e-15,
- energy=275e-6,
- precision=precision,
- backend=backend,
- noise=None,
- )
- sizes[precision, backend] = simulation.grid.n_to
- assert sizes["double", "auto"] == sizes["single", "numpy_ref"] == 10240
-
-
@pytest.mark.parametrize("grid_name", ["field_grid", "envelope_grid"])
def test_coarse_and_fine_grids_satisfy_the_invariants(grid_name, request):
grid = request.getfixturevalue(grid_name)
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 7b3594a94..7cd8227c3 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -20,8 +20,8 @@
from bounds import EPS, assert_within, gamma
from solprop._helpers import PLAIN, Helpers
from solprop.backends.host.driver import HostDriver
-from solprop.backends.multistep import trial_tables
from solprop.backends.numpy_ref import NumpyRefBackend
+from solprop.backends.numpy_ref.multistep import trial_tables
from solprop.stepper.multistep import integrated_weights, lagrange_coefficients
from solprop.translate.chebyshev import build
diff --git a/tests/test_monitor.py b/tests/test_monitor.py
index cfc68b65a..9d7329e00 100644
--- a/tests/test_monitor.py
+++ b/tests/test_monitor.py
@@ -26,7 +26,7 @@
import pytest
from runtime_types import unchecked
-from solprop.backends.multistep import CallLedger
+from solprop.backends.ledger import CallLedger
from solprop.backends.registry import available
from solprop.constants import C
from solprop.driver import Simulation, SimulationSettings, _scan_batch, prop_gnlse
@@ -287,39 +287,6 @@ def test_the_split_scheme_s_removal_is_read_exactly(
assert record_of(run)["removed_fraction"] == pytest.approx(lost, rel=1e-3)
-def test_a_single_precision_split_reads_the_physical_removal() -> None:
- # A single-precision state is held as a / s, s a power of two chosen from the
- # launch (2**7 for ten kilowatts): the removal is of the physical state, not of
- # the scaled one, which read 1/s**2 of it. The nonlinearity scales down with the
- # power, so the walk is the watt's, inside binary32's range.
- peak_power = 1e4
- grid = EnvelopeGrid.from_limits(LENGTH, 1e-6, (0.8e-6, 1.25e-6), TRANGE)
- omega0 = 2 * np.pi * C / 1e-6
- offset = 0.65 * TRANGE / (BETA2 * LENGTH)
- simulation = Simulation.from_gnlse(
- 1e-9 / peak_power, LENGTH, [0.0, 0.0, BETA2], wavelength=1e-6, grid=grid,
- pulses=[GaussianPulse(
- wavelength=2 * np.pi * C / (omega0 + offset), fwhm=200e-15,
- peak_power=peak_power,
- )],
- noise=None, raman=False, shock=False, n_save=3, precision="single",
- backend="numpy_ref", integrator=RungeKutta(), rtol=1e-5,
- schedule=FixedSchedule.uniform(0.0, LENGTH, LENGTH / 200),
- ) # fmt: skip
- simulation.settings = dataclasses.replace(
- simulation.settings,
- boundary_check=BoundaryCheck(budget=1.0, stop_factor=1.0),
- absorber_scheme="split_step",
- )
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", BoundaryWarning)
- run = simulation.run(progress="none")
- lost = _lost(run)
- assert lost > 0.5
- # Binary32's rounding over 200 steps, far inside the 1/s**2 the bug read.
- assert record_of(run)["removed_fraction"] == pytest.approx(lost, rel=1e-4)
-
-
@pytest.mark.parametrize("backend", ["numpy_ref", "native"])
def test_a_deep_collar_reads_no_removal_from_residue(backend: str) -> None:
# A capillary whose ten steps take the collar's deepest samples to alpha h of
diff --git a/tests/test_transverse_monitor.py b/tests/test_transverse_monitor.py
index 83ecfa286..2b6e9d8b5 100644
--- a/tests/test_transverse_monitor.py
+++ b/tests/test_transverse_monitor.py
@@ -726,16 +726,14 @@ def test_under_input_noise_a_collar_s_strip_is_judged_above_the_noise_s() -> Non
assert floor > 0.0
-@pytest.mark.parametrize("dtype", [np.complex128, np.complex64])
-def test_row_energy_sums_each_rows_squared_magnitude(dtype: type) -> None:
- """A read's row sums equal ``sum |E|^2`` per row, in either precision.
+def test_row_energy_sums_each_rows_squared_magnitude() -> None:
+ """A read's row sums equal ``sum |E|^2`` per row, from the real-view sum.
- Double precision takes the real-view sum, including on a non-contiguous slice;
- single precision takes the plain one.
+ Including on a non-contiguous slice.
"""
rng = np.random.default_rng(3)
real, imaginary = rng.standard_normal((2, 5, 14))
- rows = (real + 1j * imaginary).astype(dtype)
+ rows = real + 1j * imaginary
sliced = rows[:, ::2]
- expected = np.sum(np.abs(sliced.astype(np.complex128)) ** 2, axis=1)
+ expected = np.sum(np.abs(sliced) ** 2, axis=1)
np.testing.assert_allclose(_row_energy(sliced), expected, rtol=1e-6)
diff --git a/tests/translate/test_cf4.py b/tests/translate/test_cf4.py
deleted file mode 100644
index 998690982..000000000
--- a/tests/translate/test_cf4.py
+++ /dev/null
@@ -1,547 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""The CF4 varying flow's guards on synthetic generators.
-
-Every generator here has an exact solution the tests compare against, written out
-independently of :mod:`solprop.translate.coupled`: a rotating frame, whose ordered
-exponential is ``R(w z) expm((D - w J) z)`` for ``A(z) = R(w z) D R(w z)^T``. Every
-bound is stated in the docstring of the test that takes it, before the measurement.
-"""
-
-import inspect
-import math
-
-import numpy as np
-import pytest
-from scipy.linalg import expm
-
-from solprop.exceptions import SolpropWarning, TranslationError
-from solprop.translate.coupled import (
- CF4_GROWTH_LIMIT,
- CF4_NODES,
- CF4_WEIGHTS,
- cf4_defect,
- cf4_flow,
- cf4_growth_bound,
- constant_schur_flow,
- frozen_residual_flow,
- section_expansion,
-)
-
-ROTATION = np.array([[0.0, -1.0], [1.0, 0.0]])
-"""The generator of a plane rotation, ``R(t) = expm(t J)``."""
-
-RATE = 3.0
-"""Rotation rate of the rotating frame, rad/m."""
-
-
-def rotation(angle):
- """Return the plane rotation by `angle`."""
- return np.array(
- [[math.cos(angle), -math.sin(angle)], [math.sin(angle), math.cos(angle)]]
- )
-
-
-def rotating(diagonal):
- """Return ``z -> R(3 z) diag R(3 z)^T``, the rotating generator."""
-
- def operator(z):
- basis = rotation(RATE * z)
- return basis @ np.diag(diagonal) @ basis.T
-
- return operator
-
-
-def exact(diagonal, z0, z1):
- """Return the exact ordered exponential of `rotating(diagonal)` over ``[z0, z1]``.
-
- In the co-rotating frame ``b = R(-3z) a`` the generator is the constant
- ``diag - 3 J``, so ``a(z1) = R(3 z1) expm((diag - 3J)(z1 - z0)) R(-3 z0) a(z0)``.
- """
- return (
- rotation(RATE * z1)
- @ expm((np.diag(diagonal) - RATE * ROTATION) * (z1 - z0))
- @ rotation(-RATE * z0)
- )
-
-
-def leaf_coefficients(operator, edges):
- """Return the Chebyshev coefficients of `operator` on `edges`, one bin wide."""
- coefficients, tail = section_expansion(
- lambda positions: np.stack([operator(z)[:, :, None] for z in positions]),
- edges,
- )
- return coefficients.astype(complex), tail
-
-
-def cf4_step(operator, z, length):
- """Return the two CF4 factors of one step, independently of the package."""
- early = operator(z + CF4_NODES[0] * length)
- late = operator(z + CF4_NODES[1] * length)
- small, large = CF4_WEIGHTS
- return (
- expm(length * (large * early + small * late)),
- expm(length * (small * early + large * late)),
- )
-
-
-# -------------------------------------------------------------- the scheme itself
-
-
-def test_the_cf4_constants_are_the_gauss_nodes_and_the_matched_weights():
- """The nodes are Gauss-Legendre's; the weights solve the two matching conditions.
-
- ``a + b = 1/2`` matches the integral of the generator, ``b - a = 1/sqrt(3)`` the
- cubic Magnus commutator; ``a`` is negative, which is what lets an internal factor
- grow even when every generator is dissipative.
- """
- nodes, weights = np.polynomial.legendre.leggauss(2)
- assert pytest.approx(tuple(0.5 * (nodes + 1.0)), abs=1e-15) == CF4_NODES
- assert weights == pytest.approx([1.0, 1.0], abs=1e-15)
- small, large = CF4_WEIGHTS
- assert small + large == pytest.approx(0.5, abs=1e-16)
- assert large - small == pytest.approx(1.0 / math.sqrt(3.0), abs=1e-15)
- assert small < 0.0 < large
-
-
-def test_the_ordering_is_fourth_order_and_the_reversed_one_is_second():
- """The early-weighted factor acts first; the other order loses two orders.
-
- Against the exact solution of the lossless rotating frame -- a nonnormal ordered
- flow whose generator does not commute with itself along z -- over 16, 32 and 64
- steps of a metre. Predeclared gate: the implemented ordering's observed orders
- within 4 +- 0.3, the reversed one's within 2 +- 0.3. Measured 3.998 and 4.000,
- and 1.995 and 1.999.
- """
- operator = rotating([1j, -2j])
- reference = exact([1j, -2j], 0.0, 1.0)
- errors: dict[str, list[float]] = {"implemented": [], "reversed": []}
- for count in (16, 32, 64):
- for name, recorded in errors.items():
- flow = np.eye(2, dtype=complex)
- for index in range(count):
- first, second = cf4_step(operator, index / count, 1.0 / count)
- pair = second @ first if name == "implemented" else first @ second
- flow = pair @ flow
- recorded.append(float(np.linalg.norm(flow - reference, 2)))
- for name, expected in (("implemented", 4.0), ("reversed", 2.0)):
- orders = np.log2(np.array(errors[name][:-1]) / np.array(errors[name][1:]))
- assert np.all(abs(orders - expected) < 0.3), (name, orders)
-
-
-# ------------------------------------------------------------------ internal growth
-
-DISSIPATIVE = [0.0, -200.0]
-"""The dissipative case: eigenvalues 0 and -200/m, rotating at 3 rad/m."""
-
-
-def test_the_dissipative_case_reproduces_its_internal_amplification():
- """One CF4 action over a metre: factors of 1898, an action of 5e5, an exact 0.96.
-
- The recorded numbers, reproduced from the scheme alone: each internal factor has
- spectral norm about 1898, the CF4 action 502653, the exact action 0.9564, while
- every generator on the way is negative semidefinite. The growth bound of
- :func:`solprop.translate.coupled.cf4_flow`, evaluated on the exact Hermitian spread
- of 200/m, is ``exp((2 sqrt(3) - 3)/6 * 200) = 5.23e6`` over the largest the exact
- action can be (``exp(h m_hi) = 1``); the measured product of the two factors,
- 3.60e6, lies under it -- the bound is tight to a factor 1.45 here -- and so does the
- action.
- """
- operator = rotating(DISSIPATIVE)
- first, second = cf4_step(operator, 0.0, 1.0)
- norms = [float(np.linalg.norm(factor, 2)) for factor in (first, second)]
- action = float(np.linalg.norm(second @ first, 2))
- truth = float(np.linalg.norm(exact(DISSIPATIVE, 0.0, 1.0), 2))
- assert norms == pytest.approx([1898.0, 1898.0], rel=1e-3)
- assert action == pytest.approx(502653.0, rel=1e-5)
- assert truth == pytest.approx(0.9564, rel=1e-4)
- growth = math.exp((2.0 * math.sqrt(3.0) - 3.0) / 6.0 * 200.0)
- assert norms[0] * norms[1] <= growth
- assert action <= growth
- assert growth / (norms[0] * norms[1]) < 1.5
-
-
-@pytest.fixture(scope="module")
-def dissipative_leaf():
- """The dissipative generator on 64 sections of a metre."""
- edges = np.linspace(0.0, 1.0, 65)
- coefficients, tail = leaf_coefficients(rotating(DISSIPATIVE), edges)
- return edges, coefficients, tail
-
-
-def test_the_growth_guard_refuses_the_dissipative_case_by_name(dissipative_leaf):
- """The declared default limit refuses the dissipative case, section by section.
-
- The case is recorded with a metre-long step, so ``dz_max = 1`` m. The conditioning
- limit is lifted so that it is the **growth** guard that refuses; the refusal names
- the bound, the limit and the fallback that could be declared.
- """
- edges, coefficients, tail = dissipative_leaf
- with pytest.raises(TranslationError, match=r"reject 64 of its 64 section") as info:
- cf4_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- dz_max=1.0,
- condition_limit=math.inf,
- representation_error=tail,
- gauge_id="gauge-a",
- certificate_id="cf4",
- )
- assert "internal growth bound" in str(info.value)
- assert f"limit {CF4_GROWTH_LIMIT:.4g}" in str(info.value)
- assert "fallback='frozen_residual'" in str(info.value)
-
-
-def test_the_enclosed_growth_bound_holds_and_says_how_loose_it_is(dissipative_leaf):
- """The bound the leaf declares is above every internal product it can meet.
-
- Admitted with the limits lifted, the leaf's growth bound is built from the
- **enclosed** Hermitian spread -- Weyl's inequality on the stored expansion, over
- the whole reach of a metre-long step -- rather than from the exact 200/m, so it is
- looser than 5.23e6 by the sections' own variation. Bound, stated first: it must
- exceed the measured product of the two factors of every one-metre step and every
- half-metre step the reach allows, and it must fall towards the exact spread's bound
- as the sections narrow. Measured 1.08e7 at 64 sections -- a factor 2.06 above the
- exact-spread bound and 3.0 above the worst product measured -- against 9.43e7 at
- 16 sections and 6.27e6 at 256.
- """
- edges, coefficients, tail = dissipative_leaf
- flow = cf4_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- dz_max=1.0,
- condition_limit=math.inf,
- growth_limit=math.exp(60.0),
- representation_error=tail,
- gauge_id="gauge-a",
- certificate_id="cf4",
- )
- declared = float(np.max(cf4_growth_bound(flow)))
- operator = rotating(DISSIPATIVE)
- measured = 0.0
- for length, starts in ((1.0, [0.0]), (0.5, [0.0, 0.25, 0.5])):
- for start in starts:
- first, second = cf4_step(operator, start, length)
- measured = max(
- measured,
- float(np.linalg.norm(first, 2) * np.linalg.norm(second, 2)),
- )
- exact_spread = math.exp((2.0 * math.sqrt(3.0) - 3.0) / 6.0 * 200.0)
- assert measured <= exact_spread <= declared
- assert declared / exact_spread < 3.0
- coarse_edges = np.linspace(0.0, 1.0, 17)
- coarse, coarse_tail = leaf_coefficients(operator, coarse_edges)
- looser = cf4_flow(
- np.array([1e15]),
- coarse_edges,
- coarse,
- None,
- dz_max=1.0,
- condition_limit=math.inf,
- growth_limit=math.exp(60.0),
- representation_error=coarse_tail,
- gauge_id="gauge-a",
- certificate_id="cf4",
- )
- assert float(np.max(cf4_growth_bound(looser))) > declared
-
-
-def test_a_declared_fallback_is_taken_only_when_the_frozen_block_admits_it(
- dissipative_leaf,
-):
- """The fallback is recoverable only where the frozen split's own bound holds.
-
- The frozen block of this case has conditioning ``exp(200)`` over a metre, so
- falling back under the default conditioning limit is refused by name too; with
- that limit lifted, every section falls back, with a warning naming them.
- """
- edges, coefficients, tail = dissipative_leaf
- arguments = {
- "dz_max": 1.0,
- "representation_error": tail,
- "gauge_id": "gauge-a",
- "certificate_id": "cf4",
- "fallback": "frozen_residual",
- }
- with pytest.raises(TranslationError, match="cannot fall back either"):
- cf4_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- condition_limit=1e4,
- **arguments,
- )
- with pytest.warns(SolpropWarning, match="fall back to 'frozen_residual'"):
- flow = cf4_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- condition_limit=math.inf,
- **arguments,
- )
- assert flow.section_methods == ("frozen_residual",) * 64
- # A fallen-back section has no internal factors, so no growth to bound.
- assert np.all(cf4_growth_bound(flow) == 1.0)
- frozen = frozen_residual_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- dz_max=1.0,
- condition_limit=math.inf,
- representation_error=tail,
- gauge_id="gauge-a",
- certificate_id="cf4",
- )
- for name in ("generator_error", "residual_norm_bound", "inverse_condition_bound"):
- assert np.array_equal(getattr(flow, name), getattr(frozen, name))
-
-
-# ------------------------------------------------------ the rows of a CF4 section
-
-
-def mild_leaf():
- """A mildly dissipative rotating generator on two sections of a centimetre.
-
- Loss 20/m rotating at 3 rad/m over 1 cm: well inside the asymptotic regime, so
- both guards admit it at ``dz_max = 5e-3`` m.
- """
- edges = np.array([0.0, 5e-3, 1e-2])
- coefficients, tail = leaf_coefficients(rotating([0.0, -20.0]), edges)
- return edges, coefficients, tail
-
-
-def test_a_cf4_section_declares_no_residual_and_its_pair_bound():
- """The three rows of an admitted CF4 section, each against what it claims.
-
- ``residual_norm_bound`` is zero, the whole generator being in the exponential;
- ``generator_error`` is the expansion's tail alone; ``inverse_condition_bound``
- bounds the product of the four factor norms of every forward and inverse pair a
- step anchored in the section can form. That last is checked against the factors
- themselves -- formed here independently, by SciPy, at 41 starts and three lengths
- up to ``dz_max`` -- and against the exact Hermitian spread, 20/m, which the
- enclosure must contain.
- """
- edges, coefficients, tail = mild_leaf()
- flow = cf4_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- dz_max=5e-3,
- condition_limit=10.0,
- representation_error=tail,
- gauge_id="mild",
- certificate_id="cf4",
- )
- assert flow.section_methods == ("cf4", "cf4")
- assert np.all(flow.residual_norm_bound == 0.0)
- assert np.all(flow.generator_error == tail)
- declared = float(np.max(flow.inverse_condition_bound))
- assert declared >= math.exp(2.0 / math.sqrt(3.0) * 5e-3 * 20.0)
- operator = rotating([0.0, -20.0])
- for length in (5e-3, 2.5e-3, 1e-3):
- for start in np.linspace(0.0, 1e-2 - length, 41):
- first, second = cf4_step(operator, float(start), length)
- product = np.prod(
- [
- np.linalg.norm(factor, 2)
- for factor in (first, second, np.linalg.inv(first))
- ]
- ) * np.linalg.norm(np.linalg.inv(second), 2)
- assert product <= declared
- power = (2.0 - math.sqrt(3.0)) / 4.0
- assert np.allclose(
- cf4_growth_bound(flow), flow.inverse_condition_bound**power, rtol=1e-15
- )
-
-
-def test_a_constant_section_is_exact_under_cf4_and_its_bounds_are_the_frozen_ones():
- """A generator that does not vary is integrated exactly, and spread is spread.
-
- With ``A_1 = A_2 = G`` the two factors are ``exp(h G/2)`` twice, so a CF4 step is
- the exact action to rounding, and :func:`cf4_defect` estimates a defect at the
- rounding floor. The enclosure of a constant section is its own spectrum, so the
- growth bound is ``exp(2|a| dz (m_hi - m_lo))`` exactly.
- """
- block = np.zeros((1, 1, 2, 2, 1), dtype=complex)
- block[0, 0, 0, 0] = -1.0
- block[0, 0, 1, 1] = -101.0
- flow = cf4_flow(
- np.array([1e15]),
- np.array([0.0, 1e-3]),
- block,
- None,
- dz_max=1e-3,
- condition_limit=10.0,
- representation_error=0.0,
- gauge_id="constant",
- certificate_id="cf4",
- )
- assert float(cf4_growth_bound(flow)[0, 0]) == pytest.approx(
- math.exp((2.0 * math.sqrt(3.0) - 3.0) / 6.0 * 1e-3 * 100.0), rel=1e-14
- )
- assert float(np.max(cf4_defect(flow, 1e-3))) < 1e-15
-
-
-def test_a_step_reaching_the_next_section_is_bounded_by_its_enclosure_too():
- """A step anchored near a section's end reaches into the next one; not backwards.
-
- Two constant sections, the first the lossier. A step anchored in the first can
- end in the second, so the first's bound is the union's -- here its own spread,
- 100/m, the larger -- while a step anchored in the second never reaches back, so
- the second's is its own 10/m however long ``dz_max`` is.
- """
- block = np.zeros((2, 1, 2, 2, 1), dtype=complex)
- block[0, 0, 1, 1] = -100.0
- block[1, 0, 1, 1] = -10.0
- flow = cf4_flow(
- np.array([1e15]),
- np.array([0.0, 1e-3, 2e-3]),
- block,
- None,
- dz_max=5e-4,
- condition_limit=10.0,
- representation_error=0.0,
- gauge_id="two",
- certificate_id="cf4",
- )
- exponent = 2.0 / math.sqrt(3.0) * 5e-4
- assert flow.inverse_condition_bound[:, 0] == pytest.approx(
- [math.exp(exponent * 100.0), math.exp(exponent * 10.0)], rel=1e-14
- )
- block[0, 0, 1, 1], block[1, 0, 1, 1] = -10.0, -100.0
- reversed_flow = cf4_flow(
- np.array([1e15]),
- np.array([0.0, 1e-3, 2e-3]),
- block,
- None,
- dz_max=5e-4,
- condition_limit=10.0,
- representation_error=0.0,
- gauge_id="two",
- certificate_id="cf4",
- )
- assert reversed_flow.inverse_condition_bound[:, 0] == pytest.approx(
- [math.exp(exponent * 100.0), math.exp(exponent * 100.0)], rel=1e-14
- )
-
-
-def test_the_builder_checks_its_own_arguments():
- """A growth limit below one, a non-finite one and an unknown fallback are named."""
- edges, coefficients, tail = mild_leaf()
- base = {
- "dz_max": 5e-3,
- "condition_limit": 10.0,
- "representation_error": tail,
- "gauge_id": "mild",
- "certificate_id": "cf4",
- }
- for bad in (0.5, math.nan, math.inf):
- with pytest.raises(TranslationError, match="growth_limit"):
- cf4_flow(
- np.array([1e15]), edges, coefficients, None, growth_limit=bad, **base
- )
- # A string the annotation would stop in the test session's runtime check, so the
- # builder's own door is reached around it, as a production caller reaches it.
- unchecked = inspect.unwrap(cf4_flow)
- with pytest.raises(TranslationError, match="fallback"):
- unchecked(
- np.array([1e15]), edges, coefficients, None, fallback="midpoint", **base
- )
-
-
-def test_the_defect_estimator_refuses_what_it_cannot_estimate():
- """A factored flow has no dense generator to step, and a step must be positive."""
- edges, coefficients, tail = mild_leaf()
- flow = cf4_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- dz_max=5e-3,
- condition_limit=10.0,
- representation_error=tail,
- gauge_id="mild",
- certificate_id="cf4",
- )
- with pytest.raises(TranslationError, match="positive step"):
- cf4_defect(flow, 0.0)
- block = np.zeros((2, 2, 1), dtype=complex)
- constant = constant_schur_flow(
- np.array([1e15]),
- np.array([0.0, 1e-3]),
- block,
- dz_max=1e-4,
- condition_limit=10.0,
- gauge_id="lab",
- certificate_id="schur",
- )
- factored = constant.__class__(
- constant.omega,
- constant.z_edges,
- None,
- np.zeros((1, 2, 2, 1), dtype=complex),
- np.zeros((1, 1, 1), dtype=complex),
- None,
- None,
- None,
- constant.generator_error,
- constant.residual_norm_bound,
- constant.inverse_condition_bound,
- ("cf4",),
- "lab",
- "cf4",
- )
- with pytest.raises(TranslationError, match="factored"):
- cf4_defect(factored, 1e-4)
- assert np.all(cf4_defect(constant, 1e-4) == 0.0)
-
-
-def test_the_step_doubling_estimate_tracks_the_true_local_error():
- """The step-doubling estimator against the exact rotating-frame solution.
-
- ``(16/15) ||V(h) - V(h/2)^2||`` estimates the full step's local defect
- asymptotically. Predeclared: within 5 % of the true error of one CF4 step, from
- the start of the section, at 2.5, 1.25 and 0.625 mm on the mildly dissipative
- case, and falling at the step's fifth power (observed order within 5 +- 0.3).
- Measured ratios 1.000002, 1.0001 and 1.002, and orders 4.98 and 4.99.
- """
- edges, coefficients, tail = mild_leaf()
- flow = cf4_flow(
- np.array([1e15]),
- edges,
- coefficients,
- None,
- dz_max=5e-3,
- condition_limit=10.0,
- representation_error=tail,
- gauge_id="mild",
- certificate_id="cf4",
- )
- operator = rotating([0.0, -20.0])
- truths, estimates = [], []
- for length in (2.5e-3, 1.25e-3, 6.25e-4):
- first, second = cf4_step(operator, 0.0, length)
- reference = exact([0.0, -20.0], 0.0, length)
- truths.append(
- float(np.linalg.norm(second @ first - reference, 2))
- / float(np.linalg.norm(reference, 2))
- )
- # The estimator samples the section's start, middle and end and keeps the
- # worst. A rotating generator is the same one up to a rotation at every
- # start, and a rotation leaves both norms alone, so every anchor's truth is
- # the start's.
- estimates.append(float(cf4_defect(flow, length)[0, 0]))
- ratios = np.array(estimates) / np.array(truths)
- assert np.all(abs(ratios - 1.0) < 0.05), ratios
- orders = np.log2(np.array(truths[:-1]) / np.array(truths[1:]))
- assert np.all(abs(orders - 5.0) < 0.3), orders
diff --git a/tests/translate/test_compiled_responses.py b/tests/translate/test_compiled_responses.py
index a32c2e5c6..69d5540d8 100644
--- a/tests/translate/test_compiled_responses.py
+++ b/tests/translate/test_compiled_responses.py
@@ -398,7 +398,6 @@ def test_the_fields_of_the_responses_not_declared_stay_empty(envelope_grid, mode
assert compiled.raman_coefficients is None
assert compiled.raman_coefficients is None
assert compiled.raman_coefficients is None
- assert compiled.spectral_responses == ()
assert compiled.ionisation_table == ()
@@ -604,13 +603,11 @@ def test_one_rate_without_the_other_is_refused(envelope_grid, mode):
compiled = compile_for(envelope_grid, mode, absorber=absorber(envelope_grid))
with pytest.raises(TranslationError, match="must both be present"):
CompiledResponses(
- response_order=compiled.response_order,
kerr_coefficients=None,
ionisation_table=(),
plasma_channels=(),
raman_terms=(),
density=compiled.density,
- spectral_responses=(),
prefactor_representation=compiled.prefactor_representation,
prefactor=compiled.prefactor,
prefactor_expansion=compiled.prefactor_expansion,
@@ -625,13 +622,11 @@ def test_a_prefactor_representation_without_its_member_is_refused(envelope_grid,
compiled = compile_for(envelope_grid, mode)
with pytest.raises(TranslationError, match="prefactor_expansion must be present"):
CompiledResponses(
- response_order=compiled.response_order,
kerr_coefficients=None,
ionisation_table=(),
plasma_channels=(),
raman_terms=(),
density=compiled.density,
- spectral_responses=(),
prefactor_representation="expansion",
prefactor=compiled.prefactor,
prefactor_expansion=None,
@@ -678,13 +673,11 @@ def test_a_constant_prefactor_of_the_wrong_shape_is_refused(envelope_grid, mode)
compiled = compile_for(envelope_grid, mode)
with pytest.raises(TranslationError, match="prefactor_constant has shape"):
CompiledResponses(
- response_order=compiled.response_order,
kerr_coefficients=None,
ionisation_table=(),
plasma_channels=(),
raman_terms=(),
density=compiled.density,
- spectral_responses=(),
prefactor_representation=compiled.prefactor_representation,
prefactor=compiled.prefactor,
prefactor_expansion=compiled.prefactor_expansion,
@@ -988,13 +981,11 @@ def test_a_prefactor_table_without_its_kind_is_refused(envelope_grid, mode):
compiled = compile_for(envelope_grid, mode)
with pytest.raises(TranslationError, match="prefactor must be present"):
CompiledResponses(
- response_order=compiled.response_order,
kerr_coefficients=None,
ionisation_table=(),
plasma_channels=(),
raman_terms=(),
density=compiled.density,
- spectral_responses=(),
prefactor_representation="table",
prefactor=None,
prefactor_expansion=None,
diff --git a/tests/translate/test_envelope_plasma_compilation.py b/tests/translate/test_envelope_plasma_compilation.py
index 8d95e575a..c8be73d4f 100644
--- a/tests/translate/test_envelope_plasma_compilation.py
+++ b/tests/translate/test_envelope_plasma_compilation.py
@@ -73,13 +73,11 @@ def test_envelope_plasma_contract_fields_are_frozen():
"ionisation_potential",
),
CompiledResponses: (
- "response_order",
"kerr_coefficients",
"ionisation_table",
"plasma_channels",
"raman_terms",
"density",
- "spectral_responses",
"prefactor_representation",
"prefactor",
"prefactor_expansion",
@@ -232,7 +230,6 @@ def test_compilation_keeps_currents_separate_from_ordinary_polarisation(grid):
assembly = compiled.envelope_plasma
assert assembly is not None
assert compiled.plasma_channels == ()
- assert compiled.spectral_responses == (assembly,)
assert compiled.population_channels == assembly.channels
assert compiled.ionisation_table[0] is assembly.channels[0].rate
assert assembly.channels[0].preionisation(0) == pytest.approx(0.2, rel=0, abs=1e-16)
@@ -337,8 +334,6 @@ def test_compiled_envelope_channel_and_spectral_consistency(grid, cycle):
channel = assembly.channels[0]
with pytest.raises(TranslationError, match="channel constants"):
replace(channel, electrons_per_event=2)
- with pytest.raises(TranslationError, match="spectral_responses"):
- replace(compiled, spectral_responses=())
field_channel = PlasmaChannel(
label=channel.label,
rate=channel.rate,
diff --git a/tests/translate/test_general_leaves.py b/tests/translate/test_general_leaves.py
index 25404ba60..eb706d860 100644
--- a/tests/translate/test_general_leaves.py
+++ b/tests/translate/test_general_leaves.py
@@ -28,6 +28,7 @@
from solprop.nonlinear.responses import KerrResponse
from solprop.pulses import GaussianPulse
from solprop.stepper import PIController
+from solprop.translate import problem as problem_module
from solprop.translate.coupled import (
CoupledFlowTables,
ModalMetricTables,
@@ -587,7 +588,7 @@ def test_a_flow_prints_one_line_and_a_block():
generator_error=np.zeros((2, 3)),
residual_norm_bound=np.zeros((2, 3)),
inverse_condition_bound=np.ones((2, 3)),
- section_methods=("diagonal", "cf4"),
+ section_methods=("diagonal", "frozen_residual"),
)
assert "2 sections" in repr(wide)
@@ -665,22 +666,22 @@ def test_a_delayed_tensor_must_have_six_axes():
def test_a_delayed_regional_term_is_carried_with_its_kernel_and_support():
"""A regional term's tensor, kernel and support arrays must agree."""
record = kerr_regions(
- delayed_tensor=np.zeros((1, 1, 3, 3, 3, 3)),
- delayed_kernel=np.zeros((1, 1, 1, 1, 16)),
- kernel_valid_samples=np.zeros((1, 1), dtype=np.int32),
+ delayed_tensor=np.zeros((1, 2, 3, 3, 3, 3)),
+ delayed_kernel=np.zeros((1, 1, 1, 2, 16)),
+ kernel_valid_samples=np.zeros((1, 2), dtype=np.int32),
)
- assert record.n_terms == 1
+ assert record.n_terms == 2
with pytest.raises(TranslationError, match="delayed_kernel must have shape"):
kerr_regions(
- delayed_tensor=np.zeros((1, 1, 3, 3, 3, 3)),
- delayed_kernel=np.zeros((1, 1, 1, 2, 16)),
- kernel_valid_samples=np.zeros((1, 1), dtype=np.int32),
+ delayed_tensor=np.zeros((1, 2, 3, 3, 3, 3)),
+ delayed_kernel=np.zeros((1, 1, 1, 3, 16)),
+ kernel_valid_samples=np.zeros((1, 2), dtype=np.int32),
)
with pytest.raises(TranslationError, match="kernel_valid_samples must have shape"):
kerr_regions(
- delayed_tensor=np.zeros((1, 1, 3, 3, 3, 3)),
- delayed_kernel=np.zeros((1, 1, 1, 1, 16)),
- kernel_valid_samples=np.zeros((1, 2), dtype=np.int32),
+ delayed_tensor=np.zeros((1, 2, 3, 3, 3, 3)),
+ delayed_kernel=np.zeros((1, 1, 1, 2, 16)),
+ kernel_valid_samples=np.zeros((1, 3), dtype=np.int32),
)
@@ -901,9 +902,17 @@ def furnished(strand, strand_grid, strand_request):
def test_four_leaves_on_the_canonical_grids_validate(
- furnished, strand, strand_grid, strand_request
+ furnished, strand, strand_grid, strand_request, monkeypatch
):
- """Every axis of every leaf is the state's own, so the whole problem validates."""
+ """Every axis of every leaf is the state's own, so the whole problem validates.
+
+ The leaves are structural fixtures, not a combination solprop executes: the
+ general route's policy (``check_general_route``) refuses them by name, and is set
+ aside here so that what is checked is the structure alone.
+ """
+ with pytest.raises(TranslationError, match=r"^solprop executes"):
+ validate_run(furnished, strand_request)
+ monkeypatch.setattr(problem_module, "check_general_route", lambda problem: None)
tables = furnished.transform.modal_tables
metric = furnished.linear.modal_metric
flow = furnished.linear.coupled_flow
diff --git a/tests/translate/test_precision.py b/tests/translate/test_precision.py
deleted file mode 100644
index 08eed1e88..000000000
--- a/tests/translate/test_precision.py
+++ /dev/null
@@ -1,171 +0,0 @@
-# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-
-"""The precision record, its admission and the range helpers of SEP 33."""
-
-import math
-
-import numpy as np
-import pytest
-from jaxtyping import TypeCheckError
-from scipy import integrate
-
-from bounds import UNIT_ROUNDOFF_32, higham_gamma, prefix_bound
-from solprop.exceptions import TranslationError
-from solprop.translate.precision import (
- DOUBLE,
- SINGLE,
- SINGLE_RTOL_FLOOR,
- Precision,
- admit_precision,
- block_exponent,
- combine,
- launch_peak,
- narrow,
- pairwise_sum,
- precision_named,
- prefix_sum,
- scaled_square_sum,
- state_scale,
-)
-
-TINY32 = float(np.finfo(np.float32).tiny)
-
-
-def test_the_public_values_name_equal_fields_and_a_mixed_record_is_refused():
- assert precision_named("double") is DOUBLE
- assert precision_named("single") is SINGLE
- assert SINGLE.complex_dtype == np.complex64 and DOUBLE.real_dtype == np.float64
- with pytest.raises(TranslationError, match="mixed precision"):
- Precision("single", "double")
- # The annotation refuses the value first wherever annotations are checked.
- with pytest.raises((TranslationError, TypeCheckError)):
- precision_named("half") # pyright: ignore[reportArgumentType]
-
-
-def test_admission_refuses_by_name_and_the_study_reaches_adams():
- def admit(
- precision: Precision,
- backend: str = "numpy_ref",
- *,
- rtol: float = 1e-6,
- schedule: bool = False,
- ) -> None:
- admit_precision(precision, backend=backend, rtol=rtol, schedule=schedule)
-
- admit(SINGLE)
- admit(DOUBLE, "native", rtol=1e-12)
- with pytest.raises(TranslationError, match=r"native.*binary64 only"):
- admit(SINGLE, "native")
- with pytest.raises(TranslationError, match="floor 1e-06"):
- admit(SINGLE, rtol=1e-7)
- # A replayed schedule reads no tolerance.
- admit(SINGLE, rtol=1e-12, schedule=True)
- assert SINGLE_RTOL_FLOOR == 1e-6
-
-
-def test_a_molecular_kernel_keeps_every_bin_under_its_block_exponent():
- """The review's counterexample: a spectrum near 5e-50 is all zero in binary32."""
- rng = np.random.default_rng(33)
- kernel = 5.2e-50 * (rng.random(64) + 1j * rng.random(64))
- assert not np.any(kernel.astype(np.complex64))
- narrowed = narrow(kernel)
- assert narrowed.values.dtype == np.complex64
- assert 1.0 <= float(np.max(np.abs(narrowed.values))) < 2.0
- # One rounding per entry: the widened values are the kernel to a unit roundoff.
- relative = np.abs(narrowed.widened() - kernel) / np.abs(kernel)
- assert float(np.max(relative)) <= 2 * UNIT_ROUNDOFF_32
- assert narrow(np.zeros(3)).exponent == 0
-
-
-def test_block_exponents_and_the_state_scale_are_exact_powers_of_two():
- for value in (1.0, 1.5, 2.0, 3.0e4, 7.3e-12):
- exponent = block_exponent(value)
- assert 1.0 <= value * 2.0**-exponent < 2.0
- assert state_scale(value) == 2.0**exponent
- assert state_scale(0.0) == 1.0
- with pytest.raises(TranslationError, match="finite"):
- block_exponent(np.array([1.0, np.inf]))
-
-
-def test_the_launch_peak_is_the_largest_temporal_magnitude():
- spectrum = np.zeros((1, 1, 1, 8), dtype=complex)
- spectrum[..., 0] = 8.0
- assert launch_peak(spectrum, "envelope") == pytest.approx(1.0)
- assert launch_peak(np.zeros((1, 1, 1, 8), dtype=complex), "envelope") == 0.0
-
-
-def test_a_combination_never_leaves_binary64_and_checks_binary32():
- density, eps0 = 2.5e25, 8.8541878128e-12
- coefficient = combine(density, eps0, exponent=-164)
- assert math.isclose(
- coefficient.value, density * eps0 * 2.0**-164, rel_tol=4 * 2.0**-53
- )
- # The exponents add as integers: no intermediate 2**-1100 is formed.
- tiny = combine(1e-300, 1e-300, exponent=1000)
- assert math.isclose(tiny.value, (1e-300 * 2.0**500) ** 2, rel_tol=1e-15)
- assert combine(0.0, 1.0).value == 0.0
- with pytest.raises(TranslationError, match="normal range"):
- combine(1e-30, 1e-30).binary32()
- assert combine(3.0, 0.5).binary32() == 1.5
-
-
-def test_the_pairwise_order_is_within_its_tree_bound():
- rng = np.random.default_rng(1)
- values = rng.standard_normal(10_000).astype(np.float32)
- exact = math.fsum(values.astype(np.float64))
- depth = math.ceil(math.log2(values.size))
- bound = higham_gamma(depth, UNIT_ROUNDOFF_32) * float(np.sum(np.abs(values)))
- assert abs(float(pairwise_sum(values)) - exact) <= bound
- assert pairwise_sum(np.zeros(0, np.float32)).shape == ()
-
-
-def test_the_prefix_order_holds_its_depth_where_a_sequential_sum_would_not():
- """Every prefix of a doubling scan is a sum of depth at most ceil(log2 n)."""
- rng = np.random.default_rng(2)
- increments = rng.random(1 << 14).astype(np.float32)
- exact = np.cumsum(increments.astype(np.float64))
- depth = math.ceil(math.log2(increments.size))
- bound = prefix_bound(increments.astype(np.float64), depth, u=UNIT_ROUNDOFF_32)
- error = np.abs(prefix_sum(increments).astype(np.float64) - exact)
- assert np.all(error <= bound)
- assert prefix_sum(increments).dtype == np.float32
- # The trapezoid's increments through the same scan reproduce SciPy's integral.
- values = rng.random(4096)
- trapezoid = np.concatenate(
- ([0.0], prefix_sum(0.5 * (values[:-1] + values[1:]) * 1e-15))
- )
- reference = integrate.cumulative_trapezoid(values, dx=1e-15, initial=0)
- assert np.max(np.abs(trapezoid - reference)) < 1e-13 * reference[-1]
-
-
-def test_a_sum_of_squares_that_overflows_binary32_is_scaled_first():
- """The Cartesian case of SEP 33 section 4.3: squares near 1e37, a sum near 1e44."""
- values = np.full(4096, 4.0e18 + 3.0e18j, dtype=np.complex64)
- with np.errstate(over="ignore"):
- naive = np.sum(np.abs(values) ** 2)
- assert not np.isfinite(naive)
- total = scaled_square_sum(values)
- assert math.isclose(total, 4096 * 25e36, rel_tol=16 * UNIT_ROUNDOFF_32)
- weights = np.full(4096, 0.5, dtype=np.float32)
- assert math.isclose(
- scaled_square_sum(values, weights), 0.5 * total, rel_tol=4 * 2.0**-24
- )
- subnormal = np.full(8, 1e-40, dtype=np.float32)
- assert scaled_square_sum(subnormal) > 0.0
- assert TINY32 > 1e-40
-
-
-def test_a_record_names_its_shared_value_and_passes_through():
- assert SINGLE.name == "single"
- assert precision_named(SINGLE) is SINGLE
-
-
-def test_an_empty_launch_has_no_peak():
- assert launch_peak(np.zeros((1, 1, 1, 0), dtype=np.complex128), "envelope") == 0.0
-
-
-def test_a_coefficient_past_binary64s_range_is_refused():
- with pytest.raises(TranslationError, match="overflows binary64"):
- _ = combine(1.0, exponent=2000).value
diff --git a/tests/translate/test_problem.py b/tests/translate/test_problem.py
index ad5e3d8f8..c43f8edf4 100644
--- a/tests/translate/test_problem.py
+++ b/tests/translate/test_problem.py
@@ -295,7 +295,6 @@ def test_the_soliton_problem_assembles(soliton, envelope_grid, soliton_request):
scalar_pol=True,
has_plasma=False,
has_raman=False,
- has_spectral_responses=False,
has_noise=False,
has_temporal_absorber=False,
constant_linop=True,
@@ -1078,7 +1077,7 @@ def test_a_broadcast_free_space_input_runs_as_its_expansion():
runs.append((simulation.translate(), simulation.run(progress="none")))
(broadcast, held), (expanded, reference) = runs
assert broadcast.initial.field0.strides[0] == 0
- # A single-precision placeholder converts its column alone, once.
+ # A complex64 placeholder converts its column alone, once.
single = Simulation.from_free(
grid,
space,
diff --git a/tests/translate/test_run_request.py b/tests/translate/test_run_request.py
index 814c6d1b9..c331e8166 100644
--- a/tests/translate/test_run_request.py
+++ b/tests/translate/test_run_request.py
@@ -21,31 +21,18 @@
from solprop.output.schema import FieldRetention
from solprop.stats import StatsSpec
from solprop.stepper import Multistep
-from solprop.stepper.multistep import ComparisonMultistep
from solprop.translate.problem import StepperSettings
from solprop.translate.run_request import HistoryPolicy, MethodKind, describe_run
@pytest.mark.parametrize(
- ("integrator", "kind", "reference_only"),
- [
- (None, MethodKind.RUNGE_KUTTA, False),
- (Multistep(), MethodKind.ADAMS, False),
- (ComparisonMultistep(order=3), MethodKind.ADAMS, True),
- # The PEC variant one order higher is the selected recurrence, but the
- # comparison family is benchmark-only as a whole (SEP 47 section 6.3).
- (
- ComparisonMultistep(corrector="pec", order=3, corrector_order=4),
- MethodKind.ADAMS,
- True,
- ),
- ],
- ids=["runge-kutta", "adams", "comparison", "comparison-pec"],
+ ("integrator", "kind"),
+ [(None, MethodKind.RUNGE_KUTTA), (Multistep(), MethodKind.ADAMS)],
+ ids=["runge-kutta", "adams"],
)
-def test_only_the_comparison_family_is_reference_only(integrator, kind, reference_only):
+def test_the_integrator_sets_the_method_kind(integrator, kind):
integration = soliton(integrator=integrator).request.integration
assert integration.kind is kind
- assert integration.reference_only is reference_only
@pytest.mark.parametrize("start_plane", [-1, 5])
diff --git a/tests/translate/test_tdse_admission.py b/tests/translate/test_tdse_admission.py
index bbddbf876..42521a941 100644
--- a/tests/translate/test_tdse_admission.py
+++ b/tests/translate/test_tdse_admission.py
@@ -29,7 +29,6 @@
from solprop.translate.masks import AbsorberScheme
from solprop.translate.problem import _route, assemble, validate_run
from solprop.translate.responses import (
- ResponseStage,
compile_responses,
kerr_coefficients,
)
@@ -299,7 +298,6 @@ def test_atomic_cubic_models_refuse_an_extra_compiled_kerr(
ordinary = dataclasses.replace(
admitted.responses,
kerr_coefficients=kerr_coefficients(KerrResponse(chi3=1e-50, thg=True), grid),
- response_order=(ResponseStage.KERR, ResponseStage.ACCUMULATE),
)
damaged = dataclasses.replace(
_with_source(admitted, descriptor=TDSEDescriptor.from_response(response, grid)),
diff --git a/tools/size_budget.toml b/tools/size_budget.toml
index 0989c925d..573fa156a 100644
--- a/tools/size_budget.toml
+++ b/tools/size_budget.toml
@@ -10,8 +10,8 @@
# 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, and the single-precision reference modules.
-max_sectioned = 37
+# re-exported, which keeps its sections.
+max_sectioned = 12
[modules]
# No module grows past this many lines.
@@ -27,7 +27,7 @@ headroom_lines = 50
[modules.allow]
# The modules already larger. Remove an entry once its module is split below max_lines.
"src/solprop/driver/simulation.py" = 10956
-"src/solprop/translate/problem.py" = 5816
+"src/solprop/translate/problem.py" = 5507
"src/solprop/output/schema.py" = 5174
[private_imports]
@@ -35,4 +35,4 @@ headroom_lines = 50
# module of a subpackage, or a private module itself (`from solprop import _process`).
# Public names from the root's private modules (`from solprop._process import ...`) are
# shared by design and do not count.
-max_cross_package = 91
+max_cross_package = 89