Packages

Core mathematical functions for VIVA - sentient digital life. PAD emotions, Cusp catastrophe, Free Energy Principle, attractor dynamics.

Current section

Files

Jump to
viva_math src viva_math ode.gleam
Raw

src/viva_math/ode.gleam

//// Numerical ODE / SDE solvers.
////
//// Single-step integrators for scalar systems `dx/dt = f(t, x)` and
//// stochastic systems `dx = f(t, x) dt + g(t, x) dW`. Step functions are
//// pure: they take the current `(t, x)` and produce the next state.
////
//// ## Methods
////
//// | Function | Order | Use case |
//// | ----------------- | ------ | ------------------------------------------- |
//// | `euler` | 1 | Quick, stiff systems with small dt |
//// | `rk2_midpoint` | 2 | Mid-accuracy, half the cost of RK4 |
//// | `rk2_heun` | 2 | Trapezoidal/improved Euler |
//// | `rk4` | 4 | Workhorse for non-stiff systems |
//// | `euler_maruyama` | 0.5* | SDE with additive noise (* strong order) |
//// | `milstein` | 1.0* | SDE with multiplicative noise |
////
//// For full trajectories use `integrate` / `integrate_sde`.
import gleam/list
import viva_math/random
import viva_math/scalar
/// Drift function f(t, x).
pub type Drift =
fn(Float, Float) -> Float
/// Diffusion function g(t, x) for SDEs.
pub type Diffusion =
fn(Float, Float) -> Float
// ============================================================================
// Deterministic single-step
// ============================================================================
/// Euler step: xₙ₊₁ = xₙ + dt · f(tₙ, xₙ).
pub fn euler(f: Drift, t: Float, x: Float, dt: Float) -> Float {
x +. dt *. f(t, x)
}
/// Midpoint Runge-Kutta (RK2): evaluates f at the half-step midpoint.
pub fn rk2_midpoint(f: Drift, t: Float, x: Float, dt: Float) -> Float {
let k1 = f(t, x)
let k2 = f(t +. dt /. 2.0, x +. dt *. k1 /. 2.0)
x +. dt *. k2
}
/// Heun (improved Euler) RK2: trapezoidal predictor-corrector.
pub fn rk2_heun(f: Drift, t: Float, x: Float, dt: Float) -> Float {
let k1 = f(t, x)
let k2 = f(t +. dt, x +. dt *. k1)
x +. dt *. { k1 +. k2 } /. 2.0
}
/// Classical 4th-order Runge-Kutta.
pub fn rk4(f: Drift, t: Float, x: Float, dt: Float) -> Float {
let k1 = f(t, x)
let k2 = f(t +. dt /. 2.0, x +. dt *. k1 /. 2.0)
let k3 = f(t +. dt /. 2.0, x +. dt *. k2 /. 2.0)
let k4 = f(t +. dt, x +. dt *. k3)
x +. dt *. { k1 +. 2.0 *. k2 +. 2.0 *. k3 +. k4 } /. 6.0
}
// ============================================================================
// Stochastic single-step (SDE)
// ============================================================================
/// Euler-Maruyama step for dx = f dt + g dW with normal increment dW = √dt·Z.
///
/// Returns the new state and the advanced PRNG seed.
pub fn euler_maruyama(
f: Drift,
g: Diffusion,
t: Float,
x: Float,
dt: Float,
seed: random.Seed,
) -> #(Float, random.Seed) {
// SDE step sizes must be positive; reverse-time SDEs are not supported here.
let dt_safe = case dt <. 0.0 {
True -> 0.0
False -> dt
}
let #(z, s) = random.standard_normal(seed)
let dw = z *. scalar.sqrt(dt_safe)
#(x +. f(t, x) *. dt_safe +. g(t, x) *. dw, s)
}
/// Milstein step — corrects Euler-Maruyama with the Itô derivative of g.
///
/// dx = f dt + g dW + ½g·g'(t,x)·(dW² - dt)
///
/// `dg_dx` is the partial derivative ∂g/∂x. If `g` doesn't depend on x
/// (additive noise), use `euler_maruyama` instead — they coincide.
pub fn milstein(
f: Drift,
g: Diffusion,
dg_dx: Drift,
t: Float,
x: Float,
dt: Float,
seed: random.Seed,
) -> #(Float, random.Seed) {
let #(z, s) = random.standard_normal(seed)
let dw = z *. scalar.sqrt(dt)
let g_val = g(t, x)
let correction = 0.5 *. g_val *. dg_dx(t, x) *. { dw *. dw -. dt }
#(x +. f(t, x) *. dt +. g_val *. dw +. correction, s)
}
// ============================================================================
// Adaptive Runge-Kutta-Fehlberg 4(5)
// ============================================================================
// ============================================================================
// Dormand-Prince 5(4) — the modern default for non-stiff ODEs
// ============================================================================
//
// Explicit Runge-Kutta method of order 5 with an embedded 4th-order error
// estimator, used by scipy.integrate.RK45, MATLAB's ode45, and Diffrax's
// Dopri5. Seven stages with First-Same-As-Last (FSAL) optimisation: k₇ of
// step n equals k₁ of step n+1, so practical cost is 6 function
// evaluations per step.
//
// The Dormand-Prince 8(5,3) (DOP853) variant exists but its 12-stage
// Butcher tableau is large enough that hand-coding it is bug-prone;
// scipy.integrate.DOP853 uses pre-computed binary tables. We ship the
// well-vetted 5(4) here and leave DOP853 for a future NIF.
//
// References:
// Dormand & Prince (1980) "A family of embedded Runge-Kutta formulae"
// Hairer, Nørsett, Wanner (1993) "Solving ODEs I" §II.5
/// One step of Dormand-Prince 5(4) with embedded error estimate.
///
/// Returns `#(x_new, err)` where `x_new` is the 5th-order solution and
/// `err` is the absolute difference from the embedded 4th-order estimate,
/// suitable for step-size control. Per-step truncation error is O(dt⁶).
pub fn dop54(f: Drift, t: Float, x: Float, dt: Float) -> #(Float, Float) {
// Butcher tableau (Dormand-Prince 5(4))
let k1 = f(t, x)
let k2 = f(t +. dt /. 5.0, x +. dt *. k1 /. 5.0)
let k3 =
f(
t +. 3.0 *. dt /. 10.0,
x +. dt *. { 3.0 *. k1 /. 40.0 +. 9.0 *. k2 /. 40.0 },
)
let k4 =
f(
t +. 4.0 *. dt /. 5.0,
x
+. dt
*. { 44.0 *. k1 /. 45.0 -. 56.0 *. k2 /. 15.0 +. 32.0 *. k3 /. 9.0 },
)
let k5 =
f(
t +. 8.0 *. dt /. 9.0,
x
+. dt
*. {
19_372.0
*. k1
/. 6561.0
-. 25_360.0
*. k2
/. 2187.0
+. 64_448.0
*. k3
/. 6561.0
-. 212.0
*. k4
/. 729.0
},
)
let k6 =
f(
t +. dt,
x
+. dt
*. {
9017.0
*. k1
/. 3168.0
-. 355.0
*. k2
/. 33.0
+. 46_732.0
*. k3
/. 5247.0
+. 49.0
*. k4
/. 176.0
-. 5103.0
*. k5
/. 18_656.0
},
)
// 5th-order solution coefficients
let x_new =
x
+. dt
*. {
35.0
*. k1
/. 384.0
+. 500.0
*. k3
/. 1113.0
+. 125.0
*. k4
/. 192.0
-. 2187.0
*. k5
/. 6784.0
+. 11.0
*. k6
/. 84.0
}
// 7th stage uses the 5th-order solution (FSAL)
let k7 = f(t +. dt, x_new)
// Embedded 4th-order solution
let x_alt =
x
+. dt
*. {
5179.0
*. k1
/. 57_600.0
+. 7571.0
*. k3
/. 16_695.0
+. 393.0
*. k4
/. 640.0
-. 92_097.0
*. k5
/. 339_200.0
+. 187.0
*. k6
/. 2100.0
+. k7
/. 40.0
}
let err = case x_new >=. x_alt {
True -> x_new -. x_alt
False -> x_alt -. x_new
}
#(x_new, err)
}
/// Deprecated alias retained for backwards compatibility. Returns the same
/// pair as `dop54`. To be removed once external callers migrate.
/// One step of RKF45 with an error estimate.
///
/// Returns `#(x5, error)` where `x5` is the 5th-order estimate and `error` is
/// |x5 - x4|, useful for step-size control.
pub fn rkf45(f: Drift, t: Float, x: Float, dt: Float) -> #(Float, Float) {
let k1 = dt *. f(t, x)
let k2 = dt *. f(t +. dt /. 4.0, x +. k1 /. 4.0)
let k3 =
dt *. f(t +. 3.0 *. dt /. 8.0, x +. 3.0 *. k1 /. 32.0 +. 9.0 *. k2 /. 32.0)
let k4 =
dt
*. f(
t +. 12.0 *. dt /. 13.0,
x
+. 1932.0
*. k1
/. 2197.0
-. 7200.0
*. k2
/. 2197.0
+. 7296.0
*. k3
/. 2197.0,
)
let k5 =
dt
*. f(
t +. dt,
x
+. 439.0
*. k1
/. 216.0
-. 8.0
*. k2
+. 3680.0
*. k3
/. 513.0
-. 845.0
*. k4
/. 4104.0,
)
let k6 =
dt
*. f(
t +. dt /. 2.0,
x
-. 8.0
*. k1
/. 27.0
+. 2.0
*. k2
-. 3544.0
*. k3
/. 2565.0
+. 1859.0
*. k4
/. 4104.0
-. 11.0
*. k5
/. 40.0,
)
let x4 =
x
+. 25.0
*. k1
/. 216.0
+. 1408.0
*. k3
/. 2565.0
+. 2197.0
*. k4
/. 4104.0
-. k5
/. 5.0
let x5 =
x
+. 16.0
*. k1
/. 135.0
+. 6656.0
*. k3
/. 12_825.0
+. 28_561.0
*. k4
/. 56_430.0
-. 9.0
*. k5
/. 50.0
+. 2.0
*. k6
/. 55.0
let err = case x5 >=. x4 {
True -> x5 -. x4
False -> x4 -. x5
}
#(x5, err)
}
// ============================================================================
// Symplectic integrators — Hamiltonian / energy-preserving systems
// ============================================================================
//
// For systems `dq/dt = ∂H/∂p`, `dp/dt = -∂H/∂q` (Hamilton's equations)
// symplectic integrators preserve a discrete approximation of energy over
// arbitrarily long time spans, unlike RK methods whose energy drifts
// linearly. Useful for MD, orbital mechanics, HMC, and any long-time
// integration where energy conservation matters more than local accuracy.
/// Force law `F(q)`: acceleration as a function of position (unit mass).
pub type ForceLaw =
fn(Float) -> Float
/// Velocity Verlet — order 2, symplectic, time-reversible.
pub fn velocity_verlet(
force: ForceLaw,
q: Float,
v: Float,
dt: Float,
) -> #(Float, Float) {
let a = force(q)
let q_new = q +. v *. dt +. 0.5 *. a *. dt *. dt
let a_new = force(q_new)
let v_new = v +. 0.5 *. { a +. a_new } *. dt
#(q_new, v_new)
}
/// Leapfrog (kick-drift-kick) — order 2, symplectic.
pub fn leapfrog(
force: ForceLaw,
q: Float,
v: Float,
dt: Float,
) -> #(Float, Float) {
let v_half = v +. 0.5 *. force(q) *. dt
let q_new = q +. v_half *. dt
let v_new = v_half +. 0.5 *. force(q_new) *. dt
#(q_new, v_new)
}
/// Position Verlet — `q(t+dt) = 2·q(t) - q(t-dt) + a(q(t))·dt²`.
pub fn position_verlet(
force: ForceLaw,
q_prev: Float,
q: Float,
dt: Float,
) -> Float {
2.0 *. q -. q_prev +. force(q) *. dt *. dt
}
/// Yoshida 4th-order symplectic integrator.
///
/// Composes three leapfrog steps with Yoshida (1990) coefficients. Order 4
/// global error, ~3× the cost of leapfrog per step but allows ~10× larger
/// `dt` for the same accuracy.
pub fn yoshida4(
force: ForceLaw,
q: Float,
v: Float,
dt: Float,
) -> #(Float, Float) {
let cbrt_2 = 1.259_921_049_894_873_2
let w1 = 1.0 /. { 2.0 -. cbrt_2 }
let w0 = 0.0 -. cbrt_2 *. w1
let #(q1, v1) = leapfrog(force, q, v, w1 *. dt)
let #(q2, v2) = leapfrog(force, q1, v1, w0 *. dt)
leapfrog(force, q2, v2, w1 *. dt)
}
/// Integrate a Hamiltonian system with a symplectic method.
pub fn integrate_symplectic(
method: fn(ForceLaw, Float, Float, Float) -> #(Float, Float),
force: ForceLaw,
t0: Float,
q0: Float,
v0: Float,
dt: Float,
steps: Int,
) -> List(#(Float, Float, Float)) {
symplectic_loop(method, force, t0, q0, v0, dt, steps, [#(t0, q0, v0)])
}
fn symplectic_loop(
method: fn(ForceLaw, Float, Float, Float) -> #(Float, Float),
force: ForceLaw,
t: Float,
q: Float,
v: Float,
dt: Float,
steps: Int,
acc: List(#(Float, Float, Float)),
) -> List(#(Float, Float, Float)) {
case steps <= 0 {
True -> list.reverse(acc)
False -> {
let #(q_new, v_new) = method(force, q, v, dt)
let t_new = t +. dt
symplectic_loop(method, force, t_new, q_new, v_new, dt, steps - 1, [
#(t_new, q_new, v_new),
..acc
])
}
}
}
// ============================================================================
// Trajectory builders
// ============================================================================
/// Integrate a deterministic ODE with a fixed-step method.
pub fn integrate(
method: fn(Drift, Float, Float, Float) -> Float,
f: Drift,
t0: Float,
x0: Float,
dt: Float,
steps: Int,
) -> List(#(Float, Float)) {
integrate_loop(method, f, t0, x0, dt, steps, [#(t0, x0)])
}
fn integrate_loop(
method: fn(Drift, Float, Float, Float) -> Float,
f: Drift,
t: Float,
x: Float,
dt: Float,
steps: Int,
acc: List(#(Float, Float)),
) -> List(#(Float, Float)) {
case steps <= 0 {
True -> list.reverse(acc)
False -> {
let next_t = t +. dt
let next_x = method(f, t, x, dt)
integrate_loop(method, f, next_t, next_x, dt, steps - 1, [
#(next_t, next_x),
..acc
])
}
}
}
/// Integrate a stochastic system with Euler-Maruyama.
pub fn integrate_sde(
f: Drift,
g: Diffusion,
t0: Float,
x0: Float,
dt: Float,
steps: Int,
seed: random.Seed,
) -> #(List(#(Float, Float)), random.Seed) {
integrate_sde_loop(f, g, t0, x0, dt, steps, seed, [#(t0, x0)])
}
fn integrate_sde_loop(
f: Drift,
g: Diffusion,
t: Float,
x: Float,
dt: Float,
steps: Int,
seed: random.Seed,
acc: List(#(Float, Float)),
) -> #(List(#(Float, Float)), random.Seed) {
case steps <= 0 {
True -> #(list.reverse(acc), seed)
False -> {
let #(next_x, next_seed) = euler_maruyama(f, g, t, x, dt, seed)
let next_t = t +. dt
integrate_sde_loop(f, g, next_t, next_x, dt, steps - 1, next_seed, [
#(next_t, next_x),
..acc
])
}
}
}