Skip to main content

dualis_core/
integrator.rs

1//! Time evolution for systems that have no closed form.
2//!
3//! [`Motion`](crate::motion::Motion) is a function of `t`: ask for the world at
4//! 0.7 s and you get it, without having computed 0.6 s first. That is worth a
5//! great deal — an exposure can be sampled at seven instants for motion blur, and
6//! frame 7 of a recording does not depend on having rendered frame 6 — and it is
7//! why drift, oscillation and spin are written the way they are.
8//!
9//! It is also not available in general. Three bodies under gravity have no closed
10//! form, and neither do contact, heat conduction, or a stiff reaction network.
11//! Those systems have to be rolled forward, and frame 7 genuinely does depend on
12//! frame 6.
13//!
14//! # Reproducibility survives the trade, under three rules
15//!
16//! - **Fixed steps only.** [`Integrator::step`] takes `dt` and uses it. An
17//!   adaptive step chosen from the local error makes the floating-point path
18//!   depend on the values, so two runs that should agree diverge at the first
19//!   place one of them decided to halve the step. Where stability demands a
20//!   smaller step, take a fixed *number* of substeps — see
21//!   [`substeps_for`].
22//! - **No wall clock.** Nothing here reads a timer.
23//! - **Ordered reduction.** Summing forces in parallel changes the answer,
24//!   because floating-point addition is not associative. That rule belongs to the
25//!   domains, but it is the reason [`State::axpy`] is a sequential operation on a
26//!   whole state rather than a per-element one to be farmed out.
27//!
28//! # Symplectic versus accurate
29//!
30//! [`Integrator::Rk4`] is fourth-order accurate and loses energy steadily.
31//! [`velocity_verlet`] is second-order and does not: its energy error oscillates
32//! within a bound instead of drifting, because it preserves the geometric
33//! structure of a Newtonian system rather than merely fitting its derivative.
34//! Over ten steps RK4 wins; over ten million, it has quietly cooled the system
35//! down. For anything conservative — orbits, molecules, an undamped spring — use
36//! the symplectic one and let [`crate::conserved::audit`] confirm it.
37//!
38//! The test module proves exactly this on a harmonic oscillator, against the
39//! closed-form energy.
40
41use dualis_units::Time;
42
43/// A state vector that an integrator can do arithmetic on.
44///
45/// Deliberately tiny: an integrator needs to scale a state and add a multiple of
46/// another to it, and nothing else. Anything a domain wants to keep alongside its
47/// numbers — a mesh, a material table, a name — stays out of the state and lives
48/// on the [`Dynamics`] instead, where it costs nothing to carry.
49pub trait State: Clone {
50    /// `self += a * other`, the one operation every explicit integrator is made
51    /// of. Implementations must visit their elements in a fixed order.
52    fn axpy(&mut self, a: f64, other: &Self);
53
54    /// `self *= a`.
55    fn scale(&mut self, a: f64);
56
57    /// A zero of the same shape as this state.
58    fn zeros_like(&self) -> Self;
59}
60
61/// A first-order system: `ds/dt = f(s, t)`.
62///
63/// The derivative has a different dimension from the state, which is a thing this
64/// crate's unit types cannot express through a trait — so [`State`] is in raw SI
65/// numbers and the dimensions live in the domain's own types on either side.
66pub trait Dynamics {
67    /// The state this system evolves.
68    type S: State;
69
70    /// `f(s, t)`. Must be a pure function: two calls with the same arguments have
71    /// to give the same answer, or none of the reproducibility above holds.
72    fn derivative(&self, s: &Self::S, t: Time) -> Self::S;
73}
74
75/// A Newtonian system: `d²x/dt² = a(x, t)`, with no dependence on velocity.
76///
77/// The restriction is what buys the symplectic integrator. A velocity-dependent
78/// force — drag, friction, a magnetic field — is not conservative, so there is no
79/// energy for a symplectic method to preserve; express those through [`Dynamics`]
80/// and [`Integrator::Rk4`] instead.
81pub trait Newtonian {
82    /// The configuration space — positions, not positions and velocities.
83    type Coords: State;
84
85    /// `a(x, t)`. Must not depend on velocity; see the note on this trait for why.
86    fn acceleration(&self, x: &Self::Coords, t: Time) -> Self::Coords;
87}
88
89/// Explicit fixed-step integrators.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum Integrator {
92    /// First order. Cheap, and wrong fast enough to be visible — useful mainly as
93    /// the thing a better integrator is compared against.
94    Euler,
95    /// Second-order midpoint.
96    Midpoint,
97    /// Classical fourth-order Runge-Kutta. Accurate per step, and it dissipates:
98    /// see the module docs before using it on anything conservative.
99    Rk4,
100}
101
102impl Integrator {
103    /// One step of `dt`, from `t`.
104    pub fn step<D: Dynamics>(&self, system: &D, s: &D::S, t: Time, dt: Time) -> D::S {
105        let h = dt.to_si();
106        match self {
107            Integrator::Euler => {
108                let mut next = s.clone();
109                next.axpy(h, &system.derivative(s, t));
110                next
111            }
112            Integrator::Midpoint => {
113                let k1 = system.derivative(s, t);
114                let mut mid = s.clone();
115                mid.axpy(h / 2.0, &k1);
116                let k2 = system.derivative(&mid, t + dt / 2.0);
117                let mut next = s.clone();
118                next.axpy(h, &k2);
119                next
120            }
121            Integrator::Rk4 => {
122                let k1 = system.derivative(s, t);
123                let mut y = s.clone();
124                y.axpy(h / 2.0, &k1);
125                let k2 = system.derivative(&y, t + dt / 2.0);
126                let mut y = s.clone();
127                y.axpy(h / 2.0, &k2);
128                let k3 = system.derivative(&y, t + dt / 2.0);
129                let mut y = s.clone();
130                y.axpy(h, &k3);
131                let k4 = system.derivative(&y, t + dt);
132
133                // (k1 + 2 k2 + 2 k3 + k4) / 6, accumulated in one fixed order.
134                let mut slope = k1;
135                slope.axpy(2.0, &k2);
136                slope.axpy(2.0, &k3);
137                slope.axpy(1.0, &k4);
138                slope.scale(1.0 / 6.0);
139                let mut next = s.clone();
140                next.axpy(h, &slope);
141                next
142            }
143        }
144    }
145
146    /// `n` steps of `dt`, which is how a domain subcycles inside a larger step.
147    pub fn advance<D: Dynamics>(&self, system: &D, s: &D::S, t: Time, dt: Time, n: u32) -> D::S {
148        let mut state = s.clone();
149        let mut now = t;
150        for _ in 0..n {
151            state = self.step(system, &state, now, dt);
152            now += dt;
153        }
154        state
155    }
156}
157
158/// One velocity-Verlet step, in place. Symplectic, second order, and the right
159/// default for anything whose energy is supposed to stay put.
160///
161/// The classic kick-drift-kick form: half a kick from the acceleration where we
162/// are, a full drift, then half a kick from the acceleration where we arrived. It
163/// needs one acceleration evaluation per step, since the second half-kick's value
164/// is reused as the next step's first.
165pub fn velocity_verlet<N: Newtonian>(
166    system: &N,
167    x: &mut N::Coords,
168    v: &mut N::Coords,
169    t: Time,
170    dt: Time,
171) {
172    let h = dt.to_si();
173    let a0 = system.acceleration(x, t);
174    v.axpy(h / 2.0, &a0);
175    x.axpy(h, v);
176    let a1 = system.acceleration(x, t + dt);
177    v.axpy(h / 2.0, &a1);
178}
179
180/// How many equal substeps of at most `limit` it takes to cover `dt`.
181///
182/// The deterministic answer to a stability limit: rather than shrinking the step
183/// to whatever the local error asks for, take an integer number of equal ones.
184/// Two runs of the same scene take the same count, so the arithmetic follows the
185/// same path.
186pub fn substeps_for(dt: Time, limit: Time) -> u32 {
187    let (dt, limit) = (dt.to_si(), limit.to_si());
188    // An infinite limit means "no limit", a NaN one means the domain does not know,
189    // and both answer 1 — as does a step that is not going anywhere.
190    if !limit.is_finite() || limit <= 0.0 || dt <= 0.0 {
191        return 1;
192    }
193    let n = (dt / limit).ceil();
194    if !n.is_finite() || n < 1.0 {
195        1
196    } else {
197        n.min(u32::MAX as f64) as u32
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    /// A one-dimensional harmonic oscillator: `x'' = -x`, whose energy
206    /// `(x² + v²)/2` is exactly constant and whose solution is a cosine. Every
207    /// claim below is checked against that closed form rather than against
208    /// another integrator.
209    #[derive(Clone, Debug, PartialEq)]
210    struct Pair(f64, f64);
211
212    impl State for Pair {
213        fn axpy(&mut self, a: f64, other: &Self) {
214            self.0 += a * other.0;
215            self.1 += a * other.1;
216        }
217        fn scale(&mut self, a: f64) {
218            self.0 *= a;
219            self.1 *= a;
220        }
221        fn zeros_like(&self) -> Self {
222            Pair(0.0, 0.0)
223        }
224    }
225
226    /// As a first-order system: state is (x, v), derivative is (v, -x).
227    struct Spring;
228
229    impl Dynamics for Spring {
230        type S = Pair;
231        fn derivative(&self, s: &Pair, _t: Time) -> Pair {
232            Pair(s.1, -s.0)
233        }
234    }
235
236    /// As a Newtonian system: coordinate is x, acceleration is -x.
237    #[derive(Clone, Debug)]
238    struct Scalar(f64);
239
240    impl State for Scalar {
241        fn axpy(&mut self, a: f64, other: &Self) {
242            self.0 += a * other.0;
243        }
244        fn scale(&mut self, a: f64) {
245            self.0 *= a;
246        }
247        fn zeros_like(&self) -> Self {
248            Scalar(0.0)
249        }
250    }
251
252    impl Newtonian for Spring {
253        type Coords = Scalar;
254        fn acceleration(&self, x: &Scalar, _t: Time) -> Scalar {
255            Scalar(-x.0)
256        }
257    }
258
259    fn energy(x: f64, v: f64) -> f64 {
260        (x * x + v * v) / 2.0
261    }
262
263    /// Order of accuracy, measured rather than asserted: halving the step should
264    /// cut Euler's error by 2, the midpoint rule's by 4 and RK4's by 16.
265    #[test]
266    fn each_integrator_shows_its_order() {
267        let exact = |t: f64| t.cos();
268        let error_at = |method: Integrator, steps: u32| {
269            let dt = Time::s(1.0 / steps as f64);
270            let end = method.advance(&Spring, &Pair(1.0, 0.0), Time::ZERO, dt, steps);
271            (end.0 - exact(1.0)).abs()
272        };
273        for (method, expected_ratio) in [
274            (Integrator::Euler, 2.0),
275            (Integrator::Midpoint, 4.0),
276            (Integrator::Rk4, 16.0),
277        ] {
278            let coarse = error_at(method, 200);
279            let fine = error_at(method, 400);
280            let ratio = coarse / fine;
281            assert!(
282                (ratio - expected_ratio).abs() / expected_ratio < 0.15,
283                "{method:?}: halving the step changed the error by {ratio:.2}, \
284                 expected about {expected_ratio}"
285            );
286        }
287    }
288
289    /// The reason the symplectic integrator exists. Over 200 000 steps RK4 —
290    /// which is *more* accurate per step — has visibly drained the oscillator,
291    /// while velocity-Verlet's energy error is still bounded and oscillating.
292    ///
293    /// This is the whole argument for `velocity_verlet` in one assertion, and it
294    /// is why "use the higher-order method" is the wrong instinct for anything
295    /// whose conservation is being audited.
296    #[test]
297    fn only_the_symplectic_integrator_keeps_its_energy() {
298        // The step has to be a real fraction of the period for RK4's dissipation to
299        // show at all: its energy loss per step goes as dt⁶, so at dt = 0.05 the
300        // drift over these many steps is only 4e-5 and the comparison would be
301        // measuring nothing. At dt = 0.2 — still thirty steps per period — it is
302        // visible, and that is the honest regime in which the two differ.
303        const STEPS: u32 = 200_000;
304        let dt = Time::s(0.2);
305
306        let rk4 = Integrator::Rk4.advance(&Spring, &Pair(1.0, 0.0), Time::ZERO, dt, STEPS);
307        let rk4_drift = (energy(rk4.0, rk4.1) - 0.5).abs() / 0.5;
308
309        let (mut x, mut v) = (Scalar(1.0), Scalar(0.0));
310        let mut t = Time::ZERO;
311        let mut worst = 0.0f64;
312        for _ in 0..STEPS {
313            velocity_verlet(&Spring, &mut x, &mut v, t, dt);
314            t += dt;
315            // Verlet's velocity is half a step out of phase with its position, so
316            // the energy read from the pair wobbles by O(dt²) — bounded, which is
317            // the property being tested, and not drifting.
318            worst = worst.max((energy(x.0, v.0) - 0.5).abs() / 0.5);
319        }
320
321        assert!(
322            rk4_drift > 0.05,
323            "RK4 should have leaked energy over {STEPS} steps, drift {rk4_drift:.3e}"
324        );
325        assert!(
326            worst < 0.05,
327            "velocity-Verlet's energy error should stay bounded, worst {worst:.3e}"
328        );
329        assert!(
330            worst < rk4_drift / 5.0,
331            "the symplectic method should hold energy far better: {worst:.3e} vs {rk4_drift:.3e}"
332        );
333    }
334
335    /// Integration is reproducible to the last bit, including when it is done in
336    /// two halves instead of one run.
337    #[test]
338    fn integration_is_bit_reproducible() {
339        let dt = Time::s(0.01);
340        let once = Integrator::Rk4.advance(&Spring, &Pair(1.0, 0.0), Time::ZERO, dt, 500);
341        let twice = {
342            let half = Integrator::Rk4.advance(&Spring, &Pair(1.0, 0.0), Time::ZERO, dt, 250);
343            Integrator::Rk4.advance(&Spring, &half, Time::s(2.5), dt, 250)
344        };
345        assert_eq!(once, twice, "restarting mid-run must change nothing");
346
347        let again = Integrator::Rk4.advance(&Spring, &Pair(1.0, 0.0), Time::ZERO, dt, 500);
348        assert_eq!(once, again);
349    }
350
351    /// Substep counts are integers derived from a fixed limit, never from the
352    /// local error — that is what keeps two runs on the same arithmetic path.
353    #[test]
354    fn substep_counts_are_deterministic_integers() {
355        assert_eq!(substeps_for(Time::s(1.0), Time::s(0.3)), 4);
356        assert_eq!(substeps_for(Time::s(1.0), Time::s(0.5)), 2);
357        assert_eq!(substeps_for(Time::s(1.0), Time::s(2.0)), 1);
358        // A quasi-static domain reports no limit at all.
359        assert_eq!(substeps_for(Time::s(1.0), Time::from_si(f64::INFINITY)), 1);
360        // Degenerate limits do not produce a zero or an overflowing count.
361        assert_eq!(substeps_for(Time::s(1.0), Time::ZERO), 1);
362        assert_eq!(substeps_for(Time::s(1.0), Time::s(-1.0)), 1);
363        assert_eq!(substeps_for(Time::ZERO, Time::s(0.1)), 1);
364        // And covering the step is guaranteed: n * limit >= dt.
365        for (dt, limit) in [(1.0, 0.3), (7.3, 0.11), (1e-3, 1e-7)] {
366            let n = substeps_for(Time::s(dt), Time::s(limit));
367            assert!(n as f64 * limit >= dt - 1e-12, "{n} x {limit} < {dt}");
368        }
369    }
370}