Skip to main content

numra_ode/
step_control.rs

1//! Step size control for ODE solvers.
2//!
3//! This module provides step size controllers for adaptive ODE methods.
4//!
5//! Author: Moussa Leblouba
6//! Date: 5 March 2026
7//! Modified: 2 May 2026
8
9use numra_core::Scalar;
10
11/// Result of step size proposal.
12#[derive(Clone, Debug)]
13pub struct StepProposal<S: Scalar> {
14    /// Proposed new step size
15    pub h_new: S,
16    /// Should the current step be accepted?
17    pub accept: bool,
18}
19
20/// Trait for step size controllers.
21pub trait StepController<S: Scalar> {
22    /// Propose a new step size based on the error estimate.
23    ///
24    /// # Arguments
25    /// * `h` - Current step size
26    /// * `err` - Error estimate (scaled, should be < 1 for acceptance)
27    /// * `order` - Order of the method
28    fn propose(&mut self, h: S, err: S, order: usize) -> StepProposal<S>;
29
30    /// Called when a step is accepted.
31    fn accept(&mut self, h: S, err: S);
32
33    /// Called when a step is rejected.
34    fn reject(&mut self, h: S, err: S);
35
36    /// Reset the controller state.
37    fn reset(&mut self);
38}
39
40/// PI (Proportional-Integral) step size controller.
41///
42/// This is the standard controller used in most production ODE codes.
43/// It provides smoother step size changes than a simple error-based controller.
44///
45/// The new step size is computed as:
46/// h_new = h * (1/err)^(beta1/p) * (err_old/err)^(beta2/p)
47///
48/// where p is the method order.
49#[derive(Clone, Debug)]
50pub struct PIController<S: Scalar> {
51    /// Safety factor (typically 0.9)
52    pub safety: S,
53    /// Minimum step size factor
54    pub fac_min: S,
55    /// Maximum step size factor
56    pub fac_max: S,
57    /// I-controller gain (typically 0.7/p)
58    pub beta1: S,
59    /// P-controller gain (typically 0.4/p)
60    pub beta2: S,
61    /// Previous error estimate
62    err_old: Option<S>,
63    /// Previous step size
64    h_old: Option<S>,
65}
66
67impl<S: Scalar> Default for PIController<S> {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl<S: Scalar> PIController<S> {
74    /// Create a new PI controller with default parameters.
75    pub fn new() -> Self {
76        Self {
77            safety: S::from_f64(0.9),
78            fac_min: S::from_f64(0.2),
79            fac_max: S::from_f64(10.0),
80            beta1: S::from_f64(0.7),
81            beta2: S::from_f64(0.4),
82            err_old: None,
83            h_old: None,
84        }
85    }
86
87    /// Create with custom parameters.
88    pub fn with_params(safety: S, fac_min: S, fac_max: S, beta1: S, beta2: S) -> Self {
89        Self {
90            safety,
91            fac_min,
92            fac_max,
93            beta1,
94            beta2,
95            err_old: None,
96            h_old: None,
97        }
98    }
99
100    /// Create a controller tuned for a specific method order.
101    pub fn for_order(order: usize) -> Self {
102        let p = S::from_usize(order);
103        Self {
104            safety: S::from_f64(0.9),
105            fac_min: S::from_f64(0.2),
106            fac_max: S::from_f64(10.0),
107            beta1: S::from_f64(0.7) / p,
108            beta2: S::from_f64(0.4) / p,
109            err_old: None,
110            h_old: None,
111        }
112    }
113}
114
115impl<S: Scalar> StepController<S> for PIController<S> {
116    fn propose(&mut self, h: S, err: S, order: usize) -> StepProposal<S> {
117        let p = S::from_usize(order);
118
119        // Avoid division by zero
120        let err_safe = err.max(S::EPSILON * S::from_f64(100.0));
121
122        // Step size factor depends on whether we have previous error (PI mode)
123        let fac = match self.err_old {
124            Some(err_old) if err_old > S::ZERO => {
125                // Full PI controller: h_new = h * safety * (1/err)^beta1 * (err_old/err)^beta2
126                let err_old_safe = err_old.max(S::EPSILON * S::from_f64(100.0));
127                self.safety
128                    * err_safe.powf(-self.beta1)
129                    * (err_old_safe / err_safe).powf(self.beta2)
130            }
131            _ => {
132                // First step: use standard I-controller formula (1/err)^(1/p)
133                self.safety * err_safe.powf(-S::ONE / p)
134            }
135        };
136
137        // Apply safety limits
138        let fac = fac.clamp(self.fac_min, self.fac_max);
139
140        let h_new = h * fac;
141        let accept = err <= S::ONE;
142
143        StepProposal { h_new, accept }
144    }
145
146    fn accept(&mut self, h: S, err: S) {
147        self.err_old = Some(err);
148        self.h_old = Some(h);
149    }
150
151    fn reject(&mut self, _h: S, _err: S) {
152        // On rejection, reset the PI memory to avoid oscillations
153        self.err_old = None;
154    }
155
156    fn reset(&mut self) {
157        self.err_old = None;
158        self.h_old = None;
159    }
160}
161
162/// Simple I-controller (no proportional term).
163///
164/// Simpler but can cause step size oscillations.
165#[derive(Clone, Debug)]
166pub struct IController<S: Scalar> {
167    pub safety: S,
168    pub fac_min: S,
169    pub fac_max: S,
170}
171
172impl<S: Scalar> Default for IController<S> {
173    fn default() -> Self {
174        Self {
175            safety: S::from_f64(0.9),
176            fac_min: S::from_f64(0.2),
177            fac_max: S::from_f64(10.0),
178        }
179    }
180}
181
182impl<S: Scalar> StepController<S> for IController<S> {
183    fn propose(&mut self, h: S, err: S, order: usize) -> StepProposal<S> {
184        let p = S::from_usize(order);
185        let err_safe = err.max(S::EPSILON * S::from_f64(100.0));
186
187        let fac = self.safety * err_safe.powf(-S::ONE / p);
188        let fac = fac.clamp(self.fac_min, self.fac_max);
189
190        let h_new = h * fac;
191        let accept = err <= S::ONE;
192
193        StepProposal { h_new, accept }
194    }
195
196    fn accept(&mut self, _h: S, _err: S) {}
197    fn reject(&mut self, _h: S, _err: S) {}
198    fn reset(&mut self) {}
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_pi_controller_accept() {
207        let mut ctrl = PIController::<f64>::for_order(5);
208
209        // Error = 0.5, should accept and increase step
210        let prop = ctrl.propose(0.1, 0.5, 5);
211        assert!(prop.accept);
212        assert!(prop.h_new > 0.1); // Step should increase
213    }
214
215    #[test]
216    fn test_pi_controller_reject() {
217        let mut ctrl = PIController::<f64>::for_order(5);
218
219        // Error = 2.0, should reject and decrease step
220        let prop = ctrl.propose(0.1, 2.0, 5);
221        assert!(!prop.accept);
222        assert!(prop.h_new < 0.1); // Step should decrease
223    }
224
225    #[test]
226    fn test_pi_controller_limits() {
227        let mut ctrl = PIController::<f64>::for_order(5);
228
229        // Very small error, step increase should be limited
230        let prop = ctrl.propose(0.1, 1e-10, 5);
231        assert!(prop.accept);
232        assert!(prop.h_new <= 0.1 * ctrl.fac_max);
233
234        // Very large error, step decrease should be limited
235        let prop = ctrl.propose(0.1, 1e10, 5);
236        assert!(!prop.accept);
237        assert!(prop.h_new >= 0.1 * ctrl.fac_min);
238    }
239
240    #[test]
241    fn test_i_controller() {
242        let mut ctrl = IController::<f64>::default();
243
244        // Error = 0.5, should accept
245        let prop = ctrl.propose(0.1, 0.5, 5);
246        assert!(prop.accept);
247        assert!(prop.h_new > 0.1);
248
249        // Error = 2.0, should reject
250        let prop = ctrl.propose(0.1, 2.0, 5);
251        assert!(!prop.accept);
252        assert!(prop.h_new < 0.1);
253    }
254
255    // ============================================================================
256    // Edge Case Tests
257    // ============================================================================
258
259    #[test]
260    fn test_pi_controller_exact_one_error() {
261        let mut ctrl = PIController::<f64>::for_order(5);
262
263        // Error exactly 1.0, should accept but not change step much
264        let prop = ctrl.propose(0.1, 1.0, 5);
265        assert!(prop.accept);
266        // h_new should be close to h (within safety factor)
267        assert!((prop.h_new - 0.1).abs() < 0.05);
268    }
269
270    #[test]
271    fn test_pi_controller_zero_error() {
272        let mut ctrl = PIController::<f64>::for_order(5);
273
274        // Very small error, should still be limited by fac_max
275        let prop = ctrl.propose(0.1, 1e-15, 5);
276        assert!(prop.accept);
277        assert!(prop.h_new <= 0.1 * ctrl.fac_max);
278    }
279
280    #[test]
281    fn test_pi_controller_reset() {
282        let mut ctrl = PIController::<f64>::for_order(5);
283
284        // Accept a step to set err_old
285        let _ = ctrl.propose(0.1, 0.5, 5);
286        ctrl.accept(0.1, 0.5);
287
288        // Reset should clear the memory
289        ctrl.reset();
290
291        // Next proposal should use I-controller formula (no memory)
292        let prop = ctrl.propose(0.1, 0.5, 5);
293        assert!(prop.accept);
294    }
295
296    #[test]
297    fn test_pi_controller_consecutive_accepts() {
298        let mut ctrl = PIController::<f64>::for_order(5);
299
300        // Multiple consecutive accepted steps
301        for _ in 0..5 {
302            let prop = ctrl.propose(0.1, 0.5, 5);
303            assert!(prop.accept);
304            ctrl.accept(0.1, 0.5);
305        }
306    }
307
308    #[test]
309    fn test_pi_controller_reject_clears_memory() {
310        let mut ctrl = PIController::<f64>::for_order(5);
311
312        // Accept to set memory
313        let _ = ctrl.propose(0.1, 0.5, 5);
314        ctrl.accept(0.1, 0.5);
315
316        // Reject should clear memory
317        let _ = ctrl.propose(0.1, 5.0, 5);
318        ctrl.reject(0.1, 5.0);
319
320        // err_old should be None now
321        assert!(ctrl.err_old.is_none());
322    }
323
324    #[test]
325    fn test_i_controller_stateless() {
326        let mut ctrl = IController::<f64>::default();
327
328        // I-controller should give same result regardless of history
329        let prop1 = ctrl.propose(0.1, 0.5, 5);
330        ctrl.accept(0.1, 0.5);
331
332        let prop2 = ctrl.propose(0.1, 0.5, 5);
333
334        assert!((prop1.h_new - prop2.h_new).abs() < 1e-15);
335    }
336
337    #[test]
338    fn test_controller_with_order_1() {
339        let mut ctrl = PIController::<f64>::for_order(1);
340
341        // First order methods should still work
342        let prop = ctrl.propose(0.1, 0.5, 1);
343        assert!(prop.accept);
344    }
345
346    #[test]
347    fn test_controller_large_error_multiple() {
348        let mut ctrl = PIController::<f64>::for_order(5);
349
350        // Multiple rejections in a row
351        for _ in 0..3 {
352            let prop = ctrl.propose(0.1, 100.0, 5);
353            assert!(!prop.accept);
354            // Step should be reduced but not below fac_min
355            assert!(prop.h_new >= 0.1 * ctrl.fac_min);
356            ctrl.reject(0.1, 100.0);
357        }
358    }
359
360    #[test]
361    fn test_step_proposal_fields() {
362        let prop = StepProposal {
363            h_new: 0.05,
364            accept: false,
365        };
366        assert!((prop.h_new - 0.05).abs() < 1e-15);
367        assert!(!prop.accept);
368    }
369
370    #[test]
371    fn test_custom_params() {
372        let ctrl = PIController::<f64>::with_params(
373            0.8, // safety
374            0.1, // fac_min
375            5.0, // fac_max
376            0.5, // beta1
377            0.3, // beta2
378        );
379
380        assert!((ctrl.safety - 0.8).abs() < 1e-15);
381        assert!((ctrl.fac_min - 0.1).abs() < 1e-15);
382        assert!((ctrl.fac_max - 5.0).abs() < 1e-15);
383    }
384}