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}