# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""The reference's low-storage interaction-picture RK4 march (SEP 24, SEP 47).
:class:`HostLowStorageMarch` replays a prescribed schedule and accepts each step in
place, through the host engine (:class:`solprop.backends.low_storage.HostLowStorage`),
which holds the registers and makes the step's four passes. It is owned by the NumPy
reference since SEP 47 phase 2: a copy of the march the native bridge still inherits
(:class:`solprop.backends.low_storage.LowStorageMarch`), sharing only the stateless
engine. The method, the register form and the in-flight contract are those of
:mod:`solprop.backends.low_storage`'s module documentation.
"""
import logging
import math
from typing import Any
from solprop._interrupts import committing
from solprop.backends.host.driver import HostDriver, replay_tolerance
from solprop.backends.low_storage import HostLowStorage
from solprop.exceptions import BackendError, ConvergenceError
from solprop.stepper.low_storage import LowStorageRungeKutta
from solprop.stepper.schedule import FixedSchedule
__all__ = ["HostLowStorageMarch"]
logger = logging.getLogger(__name__)
[docs]
class HostLowStorageMarch:
"""Replay a schedule with the low-storage RK4IP march, accepting in place.
Follows :class:`solprop.backends.host.adams.HostAdamsMarch`: it owns its registers
through an engine and replays a prescribed schedule, which is the only thing it
does (SEP 24 section 6.2). Each step calls the engine, applies the backend's
``_finish`` (the hard band mask, and the split-step collar where chosen) and moves
the position in one commit, then records the step through the backend.
Parameters
----------
backend : solprop.backends.host.driver.HostDriver
The backend whose state is marched; it owns the step history and the
statistics rows.
method : solprop.stepper.low_storage.LowStorageRungeKutta
The march.
Attributes
----------
source_evaluations : int
Source evaluations of the committed steps since the state was last loaded
(``HostDriver.set_initial``, which sets it to zero), four a step: a physical
count, never a logical response call (SEP 24 section 5). A step that did not
commit -- abandoned in flight, or diverged -- adds none.
Notes
-----
The march has no error estimate to warn on. It logs a warning, once per call of
:meth:`replay_to`, when the engine's stiffness indicator passes RK4's
negative-real stability interval
(:data:`~solprop.stepper.low_storage.LOW_STORAGE_REAL_INTERVAL`): an explicitly
integrated damping term the translation cannot bound in advance -- plasma loss --
is then near the step's stability limit.
"""
def __init__(self, backend: HostDriver[Any], method: LowStorageRungeKutta) -> None:
"""Hold the backend and its engine."""
self._backend = backend
self._method = method
self._warned = False
# The host engine is the oracle (SEP 24 section 6.5).
self._engine = HostLowStorage(backend, method)
self.source_evaluations = 0
[docs]
@property
def engine(self) -> HostLowStorage:
"""Return the engine that holds the registers.
Returns
-------
solprop.backends.low_storage.HostLowStorage
The host engine.
"""
return self._engine
[docs]
def reset(self) -> None:
"""Forget what the engine holds: the state was loaded outside the march."""
self._engine.reset()
[docs]
def close(self) -> None:
"""Release the engine's registers and table, with the backend's buffers."""
self._engine.close()
[docs]
def snapshot(self) -> None:
"""Return nothing for a resume to continue: the march keeps no history.
A resume at a schedule edge needs no history and no recycled stage
(SEP 24 section 5), as under ``RungeKutta()``.
"""
return
[docs]
def replay_to(self, schedule: FixedSchedule, target: float) -> None:
"""Replay the schedule's steps to `target`, mirroring the Adams replay.
Parameters
----------
schedule : solprop.stepper.schedule.FixedSchedule
The schedule to replay.
target : float
Position to reach, m.
Raises
------
BackendError
If the schedule runs out before the target is reached.
"""
backend = self._backend
edges = schedule.edges
slack = replay_tolerance(schedule, target)
self._warned = False
while (
backend._schedule_index < schedule.n_steps and backend._z < target - slack
):
backend._poll_stop()
dz = float(schedule.steps[backend._schedule_index])
end = float(edges[backend._schedule_index + 1])
norm = self.replay_step(dz, end, slack)
backend._schedule_index += 1
backend._record(end, dz, norm, accepted=True)
backend._accepted_norms.append(norm)
backend._take_step_stats()
if backend._natural_stop(target):
return
if backend._z < target - slack:
msg = (
f"the replayed schedule ends at {float(schedule.z_end):.6g} m and "
f"cannot reach z = {target:.6g} m"
)
raise BackendError(msg)
[docs]
def replay_step(self, dz: float, end: float, slack: float) -> float:
"""Take one prescribed step from the backend's position and accept it.
The unit a lockstep batch advances each member by
(:class:`solprop.backends.host.group.HostGroup`), with
:meth:`solprop.backends.host.adams.HostAdamsMarch.replay_step`'s signature.
Parameters
----------
dz : float
The step, m.
end : float
The schedule's edge it ends on, m.
slack : float
How far an edge may sit from a target it lands on, m; unused, since the
march restarts nothing at a kink.
Returns
-------
float
The norm the history records: 0.0, "not estimated".
Raises
------
BackendError
If an earlier step was abandoned in flight.
ConvergenceError
If the accepted state is not finite: the field has diverged, and a replay
cannot reject the step. Raised in flight, so the state is lost with it.
"""
del slack
backend = self._backend
backend._refuse_in_flight()
z = backend._z
backend._in_flight = (z, dz)
# The host passes are Python calls a signal may interrupt (the engine is not
# atomic): an interrupted step stays in flight, and only the move commits.
squared = self._engine.step(z, dz)
finished = self._accept(z, dz, squared)
with committing():
self._move(finished, end)
self._check_stiffness(z, dz)
return 0.0
def _move(self, finished: object, end: float) -> None:
"""Make the finished state the backend's at the step's end; clear the flag.
Parameters
----------
finished : object
The state after the backend's ``_finish``.
end : float
The schedule's edge the step ends on, m.
"""
backend = self._backend
backend._state = finished
backend._z = end
backend._in_flight = None
self.source_evaluations += self._method.evaluations
def _accept(self, z: float, dz: float, squared: float) -> object:
"""Refuse a diverged step, or apply the backend's finish to the state.
Parameters
----------
z : float
Where the step started, m.
dz : float
The step, m.
squared : float
The accepted state's squared norm the engine returned.
Returns
-------
object
The state after the backend's ``_finish``.
Raises
------
ConvergenceError
If `squared` is not finite.
"""
backend = self._backend
if not math.isfinite(squared):
msg = (
f"{backend.name}: a replayed {self._method.name} step of {dz:.4g} m "
f"from z = {z:.6g} m ended in a non-finite state: the field has "
"diverged. A replayed schedule takes its steps as given and cannot "
"reject one, so a step beyond an explicit stability limit -- the "
"in-stage collars' -- grows until it overflows; the translation "
"refuses a declared step past the collars' limit. The state was "
"accepted in place and is lost; take shorter steps"
)
raise ConvergenceError(msg)
return backend._finish(backend._state, dz)
def _check_stiffness(self, z: float, dz: float) -> None:
"""Warn, once per replay, when a step's stiffness passes RK4's interval.
Parameters
----------
z : float
Where the step started, m.
dz : float
The step, m.
"""
stiffness = self._engine.stiffness
interval = self._method.real_stability_interval
if self._warned or not stiffness > interval:
return
self._warned = True
logger.warning(
"%s: the %s march's stiffness indicator h rho = %.3g passed RK4's "
"stability interval %.4g on the step of %.4g m from z = %.6g m; an "
"explicitly integrated damping term (plasma loss, an in-stage collar) is "
"near the step's stability limit and may make the replay diverge. Take "
"shorter steps",
self._backend.name,
self._method.name,
stiffness,
interval,
dz,
z,
)