--- bibliography: ../../bibliography.bib --- # Explicit ODE Integrators **Module**: `maddening.core.simulation.integrators` **Stability**: {term}`evolving ` **Algorithm ID**: `MADD-ALG-INT-001` **Version**: 1.1.0 ## Summary Three explicit Runge-Kutta steppers for {term}`nodes ` that implement `derivatives()`, plus a dispatch helper. They are {term}`pure ` {term}`JAX` functions, so each composes with `jit`, `grad` and `scan`. ## Governing Equations Each stepper advances one step of an initial-value problem $$ \frac{dx}{dt} = f(x, u), \qquad x(t_n) = x_n $$ where $x$ is the node state and $u$ the {term}`boundary inputs `. ## Discretization An $s$-stage explicit Runge-Kutta method with Butcher tableau $(A, b, c)$ computes $$ k_i = f\!\left(x_n + h \sum_{j ```python def forced(state, _unused): t = state["time"] rest = {k: v for k, v in state.items() if k != "time"} return {**node.derivatives(rest, {"u": u_of(t)}), "time": jnp.asarray(1.0)} state = {**node.initial_state(), "time": jnp.asarray(0.0)} for _ in range(n_steps): state = rk4_step(forced, state, {}, dt) ``` Two alternatives were measured and rejected. Interpolating the input linearly between step endpoints caps RK4 at order 2.000, since the interpolant's own $\mathcal{O}(h^2)$ error becomes the leading term. Evaluating the frozen input at the step midpoint instead of its start likewise caps Heun and RK4 at 2.000. Neither reaches 4, and both would need a second input argument; autonomisation reaches 4 with none. ## Implementation Mapping | Equation Term | Implementation | Notes | |---------------|---------------|-------| | $x_{n+1} = x_n + h k_1$ (Euler update) | `maddening.core.simulation.integrators.euler_step` | $b = (1)$, one stage | | $k_1, k_2$ and the trapezoid weights (Heun) | `maddening.core.simulation.integrators.heun_step` | $c = (0,1)$, $b = (\tfrac12,\tfrac12)$ | | $k_1 \ldots k_4$ and $b = (\tfrac16,\tfrac13,\tfrac13,\tfrac16)$ (RK4) | `maddening.core.simulation.integrators.rk4_step` | Stage offsets $\tfrac12 h$, $\tfrac12 h$, $h$ | | $f(x, u)$, the right-hand side | `maddening.core.node.SimulationNode.derivatives` | Supplied by the node; default raises | | Method selection by name | `maddening.core.simulation.integrators.integrate_node` | Dispatch only; adds no arithmetic | ## Assumptions and Simplifications 1. $f$ is evaluated with the caller's `boundary_inputs` at every stage, i.e. a zero-order hold on the input across the step. 2. The state is a flat `dict` of array leaves and the returned state carries exactly the fields `derivatives_fn` returned a derivative for; a field it omits is dropped rather than held. 3. Fixed step size. Nothing here estimates or adapts $h$; see `maddening.core.simulation.adaptive`. 4. Explicit methods only, so stiff problems are bounded by stability rather than accuracy; see `maddening.core.simulation.implicit`. ## Validated Physical Regimes | Parameter | Verified Range | Notes | |-----------|---------------|-------| | Observed temporal order, autonomous input | 1.003 / 2.005 / 4.006 | euler / heun / rk4, $dt$ ladder $0.075 \to 0.0047$, float64 | | Observed temporal order, per-step input | 1.022 / 1.017 / 1.016 | Same ladder, sinusoidal $u(t)$ — `MADD-ANO-014` | | Observed temporal order, time in state | 1.022 / 1.999 / 4.002 | Same ladder and $u(t)$, autonomised | | $z = h \lambda$ for the identity check | $-2.5, -0.3, 0.7$ | One step reproduces $R(z)$ to $10^{-13}$ relative | ## Known Limitations and Failure Modes 1. **A time-varying input supplied once per step gives order 1 from every method** (`MADD-ANO-014`). Nothing warns: the input dict is opaque to the stepper, which cannot tell a constant from a sample of a waveform. 2. **`rk4_step` can be less accurate than `euler_step`** under that regime — measured 1.5x worse at $dt = 0.0047$ — so selecting a higher-order method is not a conservative default. 3. **`integrate_node` cannot be fixed by its caller.** It passes `node.derivatives` straight through, and its signature has nowhere to put a stage time; a caller who needs one composes the autonomised function above and calls `rk4_step` directly. 4. **Inputs that are not functions of time alone** — data exchanged with another node during the step — cannot be reached at stage times by any single-node integrator. The order is then set by the {term}`coupling ` scheme; see `docs/algorithm_guide/coupling/`. 5. Explicit stability limits apply. `rk4_step` is unstable for real $h\lambda < -2.785$, `heun_step` for $h\lambda < -2$, `euler_step` for $h\lambda < -2$. ## Stability Conditions One step of $x' = \lambda x$ multiplies the state by the stability polynomial $R(z)$, $z = h\lambda$, which is $\exp(z)$ truncated at the method's order: $$ R_{\text{euler}} = 1 + z, \qquad R_{\text{heun}} = 1 + z + \tfrac{z^2}{2}, \qquad R_{\text{rk4}} = 1 + z + \tfrac{z^2}{2} + \tfrac{z^3}{6} + \tfrac{z^4}{24} $$ The step is stable where $|R(z)| \le 1$. $R$ also serves as an exact identity check on the coefficients, which a convergence ladder cannot provide: under a frozen time-varying input all three methods measure order ~1.02, so the *order* alone cannot distinguish one scheme from another. The errors do not all agree — `heun` and `rk4` do, to three significant figures (7.038e-3 and 7.034e-3 relative at $dt = 0.0047$), but `euler` sits at 4.567e-3, a factor of 1.540 better than either, which is the same measurement the limitation above states as "1.5x worse". An earlier revision of this sentence said all three errors agreed, which would have made the two paragraphs contradict each other. ## State Variables | Field | Shape | Units | Description | |-------|-------|-------|-------------| | caller-defined | any | any | Whatever `derivatives_fn` returns a derivative for | ## Parameters | Parameter | Type | Default | Units | Description | |-----------|------|---------|-------|-------------| | `dt` | float | — | s | Step size, fixed | | `method` | str | `"rk4"` | — | `integrate_node` only: `"euler"`, `"heun"`, `"rk4"` | | `params` | dict | `None` | — | `integrate_node` only: the node's `gm.params` entry, bound onto `derivatives(..., params=)` for every stage; a non-empty value for an override without the keyword is a `ValueError` | ## Boundary Inputs | Field | Shape | Default | Description | |-------|-------|---------|-------------| | caller-defined | any | — | Passed unchanged to every stage | ## References - [@HairerNorsettWanner1993] Hairer, E., Nørsett, S. P. & Wanner, G. (1993). *Solving Ordinary Differential Equations I: Nonstiff Problems*. Springer. — Butcher tableaux, the order conditions, stability polynomials, and the autonomisation of a non-autonomous system (§II.1-II.3). - [@Hairer2006] Hairer, E., Lubich, C. & Wanner, G. (2006). *Geometric Numerical Integration*. Springer. — Why a fixed-step explicit RK method's long-time behaviour is not implied by its order. ## Verification Evidence `tests/verification/test_integrator_order.py` — the three studies in the table above, plus the stability-polynomial identity and the `integrate_node` dispatch pin. `tests/verification/hypothesis/test_hypothesis_integrators.py` covers the autonomous orders under Hypothesis. ## Changelog | Version | Date | Change | |---------|------|--------| | 1.0.0 | 2026-01-15 | Initial implementation | | 1.1.0 | 2026-09-20 | Order claims stated conditionally and measured; `MADD-ANO-014` registered. No change to the arithmetic. |