Source code for lyapax.integrators

"""General-purpose fixed-step ODE integrators.

Independent of the vendored (Euler/Heun) integrators in
``lyapax.simulator.step`` - those are tied to the coupling/ring-buffer
plumbing for network models (M3+). These are plain ``state -> new_state``
maps for standalone benchmark systems (Lorenz, Rössler, ...), where RK4's
better accuracy at a given dt matters for getting a clean Lyapunov-exponent
estimate (see :ref:`Tier 2 <validation-tier-2>` of the validation guide).
"""
from __future__ import annotations

from typing import Callable

import jax.numpy as jnp

RHS = Callable[[jnp.ndarray], jnp.ndarray]
Step = Callable[[jnp.ndarray], jnp.ndarray]


def euler_step(rhs: RHS, dt: float) -> Step:
    def step(state: jnp.ndarray) -> jnp.ndarray:
        return state + dt * rhs(state)
    return step


[docs] def heun_step(rhs: RHS, dt: float) -> Step: """Fixed-step 2nd-order Heun (explicit trapezoidal).""" def step(state: jnp.ndarray) -> jnp.ndarray: k1 = rhs(state) k2 = rhs(state + dt * k1) return state + 0.5 * dt * (k1 + k2) return step
[docs] def rk4_step(rhs: RHS, dt: float) -> Step: """Classic fixed-step 4th-order Runge-Kutta.""" def step(state: jnp.ndarray) -> jnp.ndarray: k1 = rhs(state) k2 = rhs(state + 0.5 * dt * k1) k3 = rhs(state + 0.5 * dt * k2) k4 = rhs(state + dt * k3) return state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4) return step
RK6_STAGE_C = (0.0, 3 / 50, 1439 / 15000, 1439 / 10000, 4973 / 10000, 389 / 400, 1999 / 2000, 1.0) """Butcher ``c_i`` (fractional position within the step) for each of ``rk6_combine``'s 8 stages -- verified via each stage's row-sum consistency condition (``sum_j a_ij == c_i``) against the source tableau, see ``rk6_combine``'s docstring. Exposed so a coupled/delayed caller can recompute a state- or time-dependent input (e.g. network coupling, or a DDE's delayed history lookup) at the *correct* intra-step point for each stage, instead of freezing it at the step's start -- see :ref:`dde-rk-stage-order`."""
[docs] def rk6_combine( f: Callable[[jnp.ndarray, float], jnp.ndarray], state: jnp.ndarray, dt: float, ) -> jnp.ndarray: """One fixed-step 6th-order Runge-Kutta update, ``state -> new_state``, against a derivative evaluator ``f(y, c) -> dy``, where ``c`` is that stage's Butcher node (``RK6_STAGE_C``) -- the fractional position within the step, ``0`` at the first stage, ``1`` at the last. Purely autonomous callers (no coupling, no explicit time-dependence) can ignore ``c``; ``lyapax.simulator.step``'s coupled-network RK6 uses it to recompute coupling (or a delayed history lookup) fresh at each stage's own intra-step state/time, rather than freezing it once per step (see :ref:`dde-rk-stage-order`) -- this and ``rk6_step`` share the same tableau instead of duplicating ~30 lines of coefficients twice. The 8-stage tableau is the ``a_ij`` coefficients (stages 1-8) of Verner's "efficient order 6(5)" embedded pair -- the coefficients underlying ``Vern6`` in Julia's OrdinaryDiffEq.jl -- with the 6th-order solution taken as the FSAL point these stages build towards (``c=1``, i.e. what would be stage 9's *argument*), rather than a separately weighted sum over 9 evaluated stages: an earlier draft of this function used the ``b1..b9`` weights given as comments in the Julia source directly, which turned out to be a red herring -- symbolically expanding that combination's stability function against ``e^z`` (exact rational arithmetic, ``state' = z*state``) showed it matches only through ``z^5``, i.e. order 5, not 6. The 8-stage FSAL combination below was verified the same way and matches ``e^z`` through ``z^6`` (mismatching first at ``z^7``, the expected order-6 signature), and separately confirmed with an empirical convergence-order check against a genuinely nonlinear system with a known exact solution -- see ``tests/test_integrators.py``. """ c = RK6_STAGE_C k1 = f(state, c[0]) k2 = f(state + dt * (3 / 50 * k1), c[1]) k3 = f(state + dt * (519479 / 27000000 * k1 + 2070721 / 27000000 * k2), c[2]) k4 = f(state + dt * (1439 / 40000 * k1 + 4317 / 40000 * k3), c[3]) k5 = f(state + dt * ( 109225017611 / 82828840000 * k1 - 417627820623 / 82828840000 * k3 + 43699198143 / 10353605000 * k4 ), c[4]) k6 = f(state + dt * ( -8036815292643907349452552172369 / 191934985946683241245914401600 * k1 + 246134619571490020064824665 / 1543816496655405117602368 * k3 - 13880495956885686234074067279 / 113663489566254201783474344 * k4 + 755005057777788994734129 / 136485922925633667082436 * k5 ), c[5]) k7 = f(state + dt * ( -1663299841566102097180506666498880934230261 / 30558424506156170307020957791311384232000 * k1 + 130838124195285491799043628811093033 / 631862949514135618861563657970240 * k3 - 3287100453856023634160618787153901962873 / 20724314915376755629135711026851409200 * k4 + 2771826790140332140865242520369241 / 396438716042723436917079980147600 * k5 - 1799166916139193 / 96743806114007800 * k6 ), c[6]) k8 = f(state + dt * ( -832144750039369683895428386437986853923637763 / 15222974550069600748763651844667619945204887 * k1 + 818622075710363565982285196611368750 / 3936576237903728151856072395343129 * k3 - 9818985165491658464841194581385463434793741875 / 61642597962658994069869370923196463581866011 * k4 + 31796692141848558720425711042548134769375 / 4530254033500045975557858016006308628092 * k5 - 14064542118843830075 / 766928748264306853644 * k6 - 1424670304836288125 / 2782839104764768088217 * k7 ), c[7]) return state + dt * ( 382735282417 / 11129397249634 * k1 + 5535620703125000 / 21434089949505429 * k4 + 13867056347656250 / 32943296570459319 * k5 + 626271188750 / 142160006043 * k6 - 51160788125000 / 289890548217 * k7 + 163193540017 / 946795234 * k8 )
[docs] def rk6_step(rhs: RHS, dt: float) -> Step: """Fixed-step 6th-order Runge-Kutta, for when RK4's O(dt^4) local error is the accuracy bottleneck (e.g. wanting a much larger dt at the same error, or a much smaller error at the same dt, than RK4 gives). See ``rk6_combine`` for the tableau, provenance, and correctness checks. """ def step(state: jnp.ndarray) -> jnp.ndarray: return rk6_combine(lambda y, _c: rhs(y), state, dt) return step
_ODE_INTEGRATORS: dict[str, Callable[[RHS, float], Step]] = { "euler": euler_step, "heun": heun_step, "rk4": rk4_step, "rk6": rk6_step, }
[docs] def get_integrator(name: str) -> Callable[[RHS, float], Step]: """Look up one of the built-in fixed-step ODE integrator builders by name (``"euler"``, ``"heun"``, ``"rk4"``, or ``"rk6"``), each mapping ``(rhs, dt) -> step_fn``. """ try: return _ODE_INTEGRATORS[name] except KeyError: raise ValueError( f"unknown integrator {name!r}; choose one of " f"{sorted(_ODE_INTEGRATORS)}, or pass a callable " "(rhs, dt) -> step_fn directly." ) from None
[docs] def ode_step( rhs: RHS, dt: float, integrator: str | Callable[[RHS, float], Step] = "rk4", ) -> Step: """ Build a single-step ``state -> new_state`` map for a plain (uncoupled) ODE, with the integration method as an explicit, swappable argument. :param rhs: right-hand side, ``state (d,) -> dstate (d,)``. :param dt: fixed step size. :param integrator: one of ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable ``(rhs, dt) -> step_fn`` (e.g. ``rk4_step`` itself, or a user-supplied builder with the same signature). """ builder = get_integrator(integrator) if isinstance(integrator, str) else integrator return builder(rhs, dt)