Skip to main content

phyz_guardian/
time_step.rs

1//! Adaptive time-stepping controllers and error estimation.
2//!
3//! Implements:
4//! - PI controller for adaptive dt
5//! - Embedded RK methods for error estimation
6//! - Shadow Hamiltonian monitoring
7
8use phyz_math::DVec;
9use phyz_model::{Model, State};
10
11/// PI controller for adaptive time-stepping.
12///
13/// Adjusts dt based on current and previous error estimates:
14/// dt_new = dt_old * (tol/error)^α * (tol/error_prev)^β
15///
16/// Typical values: α = 0.4, β = 0.2 (PID without derivative term).
17#[derive(Debug, Clone)]
18pub struct PiController {
19    /// Target tolerance for local error
20    pub tolerance: f64,
21    /// Proportional gain (typically 0.4)
22    pub alpha: f64,
23    /// Integral gain (typically 0.2)
24    pub beta: f64,
25    /// Minimum allowed timestep
26    pub dt_min: f64,
27    /// Maximum allowed timestep
28    pub dt_max: f64,
29    /// Previous error estimate (for integral term)
30    prev_error: f64,
31}
32
33impl PiController {
34    /// Create a new PI controller with given tolerance.
35    pub fn new(tolerance: f64, dt_min: f64, dt_max: f64) -> Self {
36        Self {
37            tolerance,
38            alpha: 0.4,
39            beta: 0.2,
40            dt_min,
41            dt_max,
42            prev_error: tolerance, // Initialize to tolerance
43        }
44    }
45
46    /// Compute new timestep based on current error.
47    ///
48    /// Returns (new_dt, accept_step).
49    pub fn adjust(&mut self, current_dt: f64, error: f64) -> (f64, bool) {
50        // Safety factor to be conservative
51        let safety = 0.9;
52
53        // Proportional term
54        let p_term = if error > 1e-14 {
55            (self.tolerance / error).powf(self.alpha)
56        } else {
57            2.0 // If error is tiny, allow aggressive growth
58        };
59
60        // Integral term (based on previous error)
61        let i_term = if self.prev_error > 1e-14 {
62            (self.tolerance / self.prev_error).powf(self.beta)
63        } else {
64            1.0
65        };
66
67        let factor = safety * p_term * i_term;
68
69        // Clamp factor to avoid wild swings
70        let factor = factor.clamp(0.1, 5.0);
71
72        let new_dt = (current_dt * factor).clamp(self.dt_min, self.dt_max);
73
74        // Accept step if error is below tolerance
75        let accept = error <= self.tolerance;
76
77        self.prev_error = error;
78
79        (new_dt, accept)
80    }
81
82    /// Reset controller state (e.g., after manual dt change).
83    pub fn reset(&mut self) {
84        self.prev_error = self.tolerance;
85    }
86}
87
88/// Embedded Runge-Kutta error estimator.
89///
90/// Uses two RK methods of different orders to estimate local truncation error.
91/// Example: RK4 (order 4) and RK5 (order 5) for error estimation.
92pub struct EmbeddedRkError;
93
94impl EmbeddedRkError {
95    /// Estimate error by comparing RK4 and RK5 solutions.
96    ///
97    /// Returns maximum relative error over all state components.
98    pub fn estimate(
99        model: &Model,
100        state: &State,
101        derivatives_fn: impl Fn(&Model, &State) -> (DVec, DVec),
102    ) -> f64 {
103        let dt = model.dt;
104
105        // RK4 solution
106        let state_rk4 = Self::rk4_step(model, state, dt, &derivatives_fn);
107
108        // RK5 solution (Cash-Karp coefficients)
109        let state_rk5 = Self::rk5_step(model, state, dt, &derivatives_fn);
110
111        // Compute error: max relative difference
112        let mut max_error = 0.0_f64;
113
114        for i in 0..state.q.len() {
115            let err = (state_rk4.q[i] - state_rk5.q[i]).abs();
116            let scale = state_rk4.q[i].abs().max(1e-6);
117            max_error = max_error.max(err / scale);
118        }
119
120        for i in 0..state.v.len() {
121            let err = (state_rk4.v[i] - state_rk5.v[i]).abs();
122            let scale = state_rk4.v[i].abs().max(1e-6);
123            max_error = max_error.max(err / scale);
124        }
125
126        max_error
127    }
128
129    fn rk4_step(
130        model: &Model,
131        state: &State,
132        dt: f64,
133        derivatives_fn: &impl Fn(&Model, &State) -> (DVec, DVec),
134    ) -> State {
135        let mut result = state.clone();
136
137        // k1
138        let (dq1, dv1) = derivatives_fn(model, state);
139
140        // k2
141        let mut s2 = state.clone();
142        s2.q += &dq1 * (dt / 2.0);
143        s2.v += &dv1 * (dt / 2.0);
144        let (dq2, dv2) = derivatives_fn(model, &s2);
145
146        // k3
147        let mut s3 = state.clone();
148        s3.q += &dq2 * (dt / 2.0);
149        s3.v += &dv2 * (dt / 2.0);
150        let (dq3, dv3) = derivatives_fn(model, &s3);
151
152        // k4
153        let mut s4 = state.clone();
154        s4.q += &dq3 * dt;
155        s4.v += &dv3 * dt;
156        let (dq4, dv4) = derivatives_fn(model, &s4);
157
158        // Combine
159        result.q += &(&dq1 + &dq2 * 2.0 + &dq3 * 2.0 + &dq4) * (dt / 6.0);
160        result.v += &(&dv1 + &dv2 * 2.0 + &dv3 * 2.0 + &dv4) * (dt / 6.0);
161
162        result
163    }
164
165    fn rk5_step(
166        model: &Model,
167        state: &State,
168        dt: f64,
169        derivatives_fn: &impl Fn(&Model, &State) -> (DVec, DVec),
170    ) -> State {
171        let mut result = state.clone();
172
173        // Cash-Karp RK5 coefficients (simplified version)
174        // k1
175        let (dq1, dv1) = derivatives_fn(model, state);
176
177        // k2
178        let mut s2 = state.clone();
179        s2.q += &dq1 * (dt / 5.0);
180        s2.v += &dv1 * (dt / 5.0);
181        let (dq2, dv2) = derivatives_fn(model, &s2);
182
183        // k3
184        let mut s3 = state.clone();
185        s3.q += &(&dq1 * (3.0 / 40.0) + &dq2 * (9.0 / 40.0)) * dt;
186        s3.v += &(&dv1 * (3.0 / 40.0) + &dv2 * (9.0 / 40.0)) * dt;
187        let (dq3, dv3) = derivatives_fn(model, &s3);
188
189        // k4
190        let mut s4 = state.clone();
191        s4.q += &(&dq1 * (3.0 / 10.0) - &dq2 * (9.0 / 10.0) + &dq3 * (6.0 / 5.0)) * dt;
192        s4.v += &(&dv1 * (3.0 / 10.0) - &dv2 * (9.0 / 10.0) + &dv3 * (6.0 / 5.0)) * dt;
193        let (dq4, dv4) = derivatives_fn(model, &s4);
194
195        // k5
196        let mut s5 = state.clone();
197        s5.q += &(&dq1 * (-11.0 / 54.0) + &dq2 * (5.0 / 2.0) - &dq3 * (70.0 / 27.0)
198            + &dq4 * (35.0 / 27.0))
199            * dt;
200        s5.v += &(&dv1 * (-11.0 / 54.0) + &dv2 * (5.0 / 2.0) - &dv3 * (70.0 / 27.0)
201            + &dv4 * (35.0 / 27.0))
202            * dt;
203        let (dq5, dv5) = derivatives_fn(model, &s5);
204
205        // 5th order solution
206        result.q += &(&dq1 * (37.0 / 378.0)
207            + &dq3 * (250.0 / 621.0)
208            + &dq4 * (125.0 / 594.0)
209            + &dq5 * (512.0 / 1771.0))
210            * dt;
211        result.v += &(&dv1 * (37.0 / 378.0)
212            + &dv3 * (250.0 / 621.0)
213            + &dv4 * (125.0 / 594.0)
214            + &dv5 * (512.0 / 1771.0))
215            * dt;
216
217        result
218    }
219}
220
221/// Adaptive time-stepping manager.
222///
223/// Combines error estimation with PI controller to automatically adjust dt.
224pub struct AdaptiveTimeStep {
225    pub controller: PiController,
226    /// Number of rejected steps
227    pub rejected_steps: usize,
228    /// Number of accepted steps
229    pub accepted_steps: usize,
230}
231
232impl AdaptiveTimeStep {
233    /// Create a new adaptive timestepper.
234    pub fn new(tolerance: f64, dt_min: f64, dt_max: f64) -> Self {
235        Self {
236            controller: PiController::new(tolerance, dt_min, dt_max),
237            rejected_steps: 0,
238            accepted_steps: 0,
239        }
240    }
241
242    /// Perform one adaptive step.
243    ///
244    /// Returns (accepted, new_dt).
245    pub fn step<F>(&mut self, model: &mut Model, state: &mut State, step_fn: F) -> (bool, f64)
246    where
247        F: Fn(&Model, &mut State),
248    {
249        // Estimate error using embedded RK
250        let error = EmbeddedRkError::estimate(model, state, |m, s| {
251            use phyz_rigid::aba;
252            let qdd = aba(m, s);
253            (s.v.clone(), qdd)
254        });
255
256        // Adjust timestep based on error
257        let (new_dt, accept) = self.controller.adjust(model.dt, error);
258
259        if accept {
260            // Accept step and advance
261            step_fn(model, state);
262            self.accepted_steps += 1;
263        } else {
264            // Reject step, reduce dt and try again
265            self.rejected_steps += 1;
266        }
267
268        // Update model's dt for next step
269        model.dt = new_dt;
270
271        (accept, new_dt)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_pi_controller() {
281        let mut controller = PiController::new(1e-4, 1e-6, 1e-2);
282
283        // Error below tolerance should increase dt
284        let (new_dt, accept) = controller.adjust(0.001, 1e-5);
285        assert!(accept);
286        assert!(new_dt > 0.001);
287
288        // Error above tolerance should decrease dt
289        let (new_dt, accept) = controller.adjust(0.001, 1e-3);
290        assert!(!accept);
291        assert!(new_dt < 0.001);
292    }
293
294    #[test]
295    fn test_pi_controller_clamping() {
296        let mut controller = PiController::new(1e-4, 1e-5, 1e-3);
297
298        // Should clamp to dt_max
299        let (new_dt, _) = controller.adjust(1e-3, 1e-10);
300        assert!((new_dt - 1e-3).abs() < 1e-10);
301
302        // Should clamp to dt_min
303        let (new_dt, _) = controller.adjust(1e-5, 1.0);
304        assert!((new_dt - 1e-5).abs() < 1e-10);
305    }
306}