Source code for astronomix.time_stepping._time_loop

"""Generic JAX time-integration loop driver.

Repo-agnostic: depends only on ``jax`` (and equinox's checkpointed while loop
for reverse-mode-friendly adaptive integration).  It owns the parts of a
time-stepping loop that are independent of the physics being integrated:

  * the loop backend — fixed-step ``fori_loop`` / adaptive ``while_loop`` /
    reverse-mode-friendly ``checkpointed_while_loop``;
  * collecting snapshots of the evolving state into preallocated buffers at
    chosen times (plus an optional final snapshot);
  * a host-side progress callback;
  * counting the number of steps taken.

The physics is supplied entirely through caller closures, so this module
carries no domain knowledge and can be reused by any project:

    step(t, state, snapshot_index) -> (dt, new_state)
        Advance ``state`` by one step.  Returns the timestep actually taken
        (the driver advances ``t``) and the new state.  ``snapshot_index`` is
        the current snapshot counter (0 when snapshots are disabled), passed
        through so the step can clamp ``dt`` to land on snapshot times.

    SnapshotSpec.record(t, state, store, index) -> store
        Write whatever diagnostics are wanted for snapshot ``index`` into the
        preallocated ``store`` pytree (typically ``store.field.at[index].set``).

    SnapshotSpec.should_record(t, index) -> bool
        Whether snapshot ``index`` is due at the start of a step at time ``t``
        (e.g. evenly spaced, or matching explicit timepoints).

``state`` and ``store`` are opaque pytrees to the driver.
"""

# typing
from typing import Any, Callable, NamedTuple, Optional

# jax
import jax
import jax.numpy as jnp

# checkpointed loop
from equinox.internal._loop.checkpointed import checkpointed_while_loop


# -------------------------------------------------------------
# =================== ↓ Loop backends ↓ =======================
# -------------------------------------------------------------
# Identifiers selecting which underlying JAX loop construct ``integrate`` uses.
# ``FIXED_STEP`` and ``ADAPTIVE_WHILE`` are not reverse-mode differentiable on
# their own; ``ADAPTIVE_CHECKPOINTED`` trades memory for a reverse-mode-friendly
# while loop via equinox.
FIXED_STEP = 0            # jax.lax.fori_loop over a fixed number of steps
ADAPTIVE_WHILE = 1        # jax.lax.while_loop until t >= t_end (forward-mode AD)
ADAPTIVE_CHECKPOINTED = 2  # checkpointed_while_loop until t >= t_end (reverse-mode AD)
# -------------------------------------------------------------
# =================== ↑ Loop backends ↑ =======================
# -------------------------------------------------------------


[docs] def times_close(t, target): """Float-precision-aware test that ``t`` has reached ``target``. The tolerance is scaled by the working float epsilon and the magnitude of ``target``, so the test behaves correctly in both float32 and float64. A fixed absolute tolerance (e.g. ``1e-12``) silently fails in float32, where a step landing on ``target`` is typically only accurate to ~1e-7·|target|. """ dtype = jnp.result_type(jnp.asarray(t), jnp.asarray(target)) atol = 8.0 * jnp.finfo(dtype).eps * jnp.maximum(jnp.abs(target), 1.0) return jnp.abs(t - target) <= atol
[docs] class SnapshotSpec(NamedTuple): """Bundles everything the driver needs to collect snapshots. Attributes: store: Preallocated output pytree the ``record`` callback writes into. record: ``record(t, state, store, index) -> store``. should_record: ``should_record(t, index) -> bool`` crossing test. record_final: Also record the true final state once after the loop. final_index: Buffer slot the final state is written into. When ``None`` the running snapshot counter is used; for fixed-size buffers this should be the last slot (``num_snapshots - 1``) so the final state at ``t_end`` is captured reliably regardless of step alignment. """ store: Any record: Callable should_record: Callable record_final: bool = True final_index: Optional[int] = None
[docs] def integrate( state: Any, step: Callable, t_end, *, backend: int, t_start=0.0, num_steps: Optional[int] = None, num_checkpoints: Optional[int] = None, snapshots: Optional[SnapshotSpec] = None, progress: Optional[Callable] = None, ): """Run a time-integration loop. Args: state: Initial evolving-state pytree (opaque to the driver). step: ``step(t, state, snapshot_index) -> (dt, new_state)``. t_end: Integration end time. backend: One of ``FIXED_STEP`` / ``ADAPTIVE_WHILE`` / ``ADAPTIVE_CHECKPOINTED``. t_start: Initial integration time (the loop clock starts here). num_steps: Number of steps for ``FIXED_STEP``. num_checkpoints: Checkpoint count for ``ADAPTIVE_CHECKPOINTED``. snapshots: A :class:`SnapshotSpec`, or ``None`` to disable collection. progress: ``progress(t, t_end)`` host callback, or ``None``. Returns: ``(t, state, store, num_iterations)``. ``store`` is the (possibly updated) snapshot store, or ``None`` when snapshots are disabled. """ has_snapshots = snapshots is not None def body(carry): # The carry has an extra snapshot-store slot only when snapshots are # collected; the snapshot index is carried either way (and stays 0 when # snapshots are disabled) so ``step`` always receives a valid counter. if has_snapshots: t, state, snapshot_index, num_iterations, store = carry # Record the snapshot that is due at the start of this step. This # deliberately captures the state *before* this step's update, so a # snapshot reflects the field at exactly its recorded time. def record_due_snapshot(store_and_index): store, snapshot_index = store_and_index return ( snapshots.record(t, state, store, snapshot_index), snapshot_index + 1, ) store, snapshot_index = jax.lax.cond( snapshots.should_record(t, snapshot_index), record_due_snapshot, lambda store_and_index: store_and_index, (store, snapshot_index), ) else: t, state, snapshot_index, num_iterations = carry dt, state = step(t, state, snapshot_index) t = t + dt num_iterations = num_iterations + 1 if progress is not None: jax.debug.callback(progress, t, t_end) if has_snapshots: return (t, state, snapshot_index, num_iterations, store) return (t, state, snapshot_index, num_iterations) if has_snapshots: carry = (t_start, state, 0, 0, snapshots.store) else: carry = (t_start, state, 0, 0) # Fixed-step runs use a plain ``fori_loop``; adaptive runs use a while loop # whose predicate runs until the clock reaches ``t_end``, optionally in the # checkpointed variant for reverse-mode differentiability. if backend == FIXED_STEP: carry = jax.lax.fori_loop(0, num_steps, lambda _i, c: body(c), carry) elif backend == ADAPTIVE_WHILE: carry = jax.lax.while_loop(lambda c: c[0] < t_end, body, carry) elif backend == ADAPTIVE_CHECKPOINTED: carry = checkpointed_while_loop( lambda c: c[0] < t_end, body, carry, checkpoints=num_checkpoints ) else: raise ValueError(f"Unknown loop backend: {backend}") if has_snapshots: t, state, snapshot_index, num_iterations, store = carry # The evenly spaced ``should_record`` grid never lands exactly on # ``t_end`` (the loop exits at the first step past it), so the final # state would otherwise be missing. Record it once here into the # reserved final slot — guaranteed written regardless of step # alignment, and differentiable since it acts on the loop output. if snapshots.record_final: final_snapshot_index = ( snapshot_index if snapshots.final_index is None else snapshots.final_index ) store = snapshots.record(t, state, store, final_snapshot_index) return t, state, store, num_iterations t, state, _snapshot_index, num_iterations = carry return t, state, None, num_iterations