Skip to main content

outram_foam_basic_lib/ode/
rkf45.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
21
22use super::{adaptive_step, integrate_interval, normalize_error, OdeError, OdeSolverConfig, OdeSystem};
23
24// Butcher tableau coefficients — Runge-Kutta-Fehlberg 4(5)
25// Source: Foam::RKF45 constants
26const C2: f64 = 1.0 / 4.0;
27const C3: f64 = 3.0 / 8.0;
28const C4: f64 = 12.0 / 13.0;
29const C5: f64 = 1.0;
30const C6: f64 = 1.0 / 2.0;
31
32const A21: f64 = 1.0 / 4.0;
33const A31: f64 = 3.0 / 32.0;
34const A32: f64 = 9.0 / 32.0;
35const A41: f64 = 1932.0 / 2197.0;
36const A42: f64 = -7200.0 / 2197.0;
37const A43: f64 = 7296.0 / 2197.0;
38const A51: f64 = 439.0 / 216.0;
39const A52: f64 = -8.0;
40const A53: f64 = 3680.0 / 513.0;
41const A54: f64 = -845.0 / 4104.0;
42const A61: f64 = -8.0 / 27.0;
43const A62: f64 = 2.0;
44const A63: f64 = -3544.0 / 2565.0;
45const A64: f64 = 1859.0 / 4104.0;
46const A65: f64 = -11.0 / 40.0;
47
48// 5th-order weights
49const B1: f64 = 16.0 / 135.0;
50const B3: f64 = 6656.0 / 12825.0;
51const B4: f64 = 28561.0 / 56430.0;
52const B5: f64 = -9.0 / 50.0;
53const B6: f64 = 2.0 / 55.0;
54
55// Error = 5th-order − 4th-order weights
56const E1: f64 = 25.0 / 216.0 - B1;
57const E3: f64 = 1408.0 / 2565.0 - B3;
58const E4: f64 = 2197.0 / 4104.0 - B4;
59const E5: f64 = -1.0 / 5.0 - B5;
60const E6: f64 = -B6;
61
62/// Runge-Kutta-Fehlberg 4(5) explicit solver with adaptive step size.
63/// Maps to `Foam::RKF45`.
64#[derive(Debug, Clone)]
65pub struct Rkf45 {
66    /// Adaptive step-size controller settings (tolerances, scale limits).
67    pub config: OdeSolverConfig,
68    dydx0: Vec<f64>,
69    y_temp: Vec<f64>,
70    k2: Vec<f64>,
71    k3: Vec<f64>,
72    k4: Vec<f64>,
73    k5: Vec<f64>,
74    k6: Vec<f64>,
75    err: Vec<f64>,
76    y_stage: Vec<f64>,
77}
78
79impl Rkf45 {
80    /// Create a solver for an `n`-equation system with the given absolute and
81    /// relative per-equation error tolerances; other controller parameters take
82    /// their [`OdeSolverConfig::default`] values.
83    pub fn new(n: usize, abs_tol: f64, rel_tol: f64) -> Self {
84        let mut cfg = OdeSolverConfig::default();
85        cfg.abs_tol = abs_tol;
86        cfg.rel_tol = rel_tol;
87        Self {
88            config: cfg,
89            dydx0: vec![0.0; n],
90            y_temp: vec![0.0; n],
91            k2: vec![0.0; n],
92            k3: vec![0.0; n],
93            k4: vec![0.0; n],
94            k5: vec![0.0; n],
95            k6: vec![0.0; n],
96            err: vec![0.0; n],
97            y_stage: vec![0.0; n],
98        }
99    }
100
101    /// Take one adaptive step. On return `x` and `y` are updated and `dx_try`
102    /// holds a suggested step size for the next call.
103    pub fn solve_step<Sys: OdeSystem + ?Sized>(
104        &mut self,
105        ode: &Sys,
106        x: &mut f64,
107        y: &mut Vec<f64>,
108        dx_try: &mut f64,
109    ) -> Result<(), OdeError> {
110        let cfg = self.config.clone();
111        let abs_tol = cfg.abs_tol;
112        let rel_tol = cfg.rel_tol;
113
114        let k2 = &mut self.k2;
115        let k3 = &mut self.k3;
116        let k4 = &mut self.k4;
117        let k5 = &mut self.k5;
118        let k6 = &mut self.k6;
119        let err = &mut self.err;
120        let y_stage = &mut self.y_stage;
121
122        adaptive_step(
123            &cfg,
124            |x0, y0, dydx0, dx, y_out| {
125                let n = y0.len();
126
127                // Stage 2
128                for i in 0..n {
129                    y_stage[i] = y0[i] + A21 * dx * dydx0[i];
130                }
131                ode.derivatives(x0 + C2 * dx, y_stage, k2);
132
133                // Stage 3
134                for i in 0..n {
135                    y_stage[i] = y0[i] + dx * (A31 * dydx0[i] + A32 * k2[i]);
136                }
137                ode.derivatives(x0 + C3 * dx, y_stage, k3);
138
139                // Stage 4
140                for i in 0..n {
141                    y_stage[i] = y0[i] + dx * (A41 * dydx0[i] + A42 * k2[i] + A43 * k3[i]);
142                }
143                ode.derivatives(x0 + C4 * dx, y_stage, k4);
144
145                // Stage 5
146                for i in 0..n {
147                    y_stage[i] =
148                        y0[i] + dx * (A51 * dydx0[i] + A52 * k2[i] + A53 * k3[i] + A54 * k4[i]);
149                }
150                ode.derivatives(x0 + C5 * dx, y_stage, k5);
151
152                // Stage 6
153                for i in 0..n {
154                    y_stage[i] = y0[i]
155                        + dx * (A61 * dydx0[i]
156                            + A62 * k2[i]
157                            + A63 * k3[i]
158                            + A64 * k4[i]
159                            + A65 * k5[i]);
160                }
161                ode.derivatives(x0 + C6 * dx, y_stage, k6);
162
163                // 5th-order solution
164                for i in 0..n {
165                    y_out[i] = y0[i]
166                        + dx * (B1 * dydx0[i] + B3 * k3[i] + B4 * k4[i] + B5 * k5[i] + B6 * k6[i]);
167                }
168
169                // Error = 5th − 4th
170                for i in 0..n {
171                    err[i] =
172                        dx * (E1 * dydx0[i] + E3 * k3[i] + E4 * k4[i] + E5 * k5[i] + E6 * k6[i]);
173                }
174
175                normalize_error(y0, y_out, err, abs_tol, rel_tol)
176            },
177            ode,
178            x,
179            y,
180            &mut self.dydx0,
181            &mut self.y_temp,
182            dx_try,
183        )
184    }
185
186    /// Integrate from `x_start` to `x_end`, updating `y` in place and leaving
187    /// the last accepted step size in `dx_est`.
188    pub fn integrate<Sys: OdeSystem + ?Sized>(
189        &mut self,
190        ode: &Sys,
191        x_start: f64,
192        x_end: f64,
193        y: &mut Vec<f64>,
194        dx_est: &mut f64,
195    ) -> Result<(), OdeError> {
196        let cfg = self.config.clone();
197        integrate_interval(
198            &cfg,
199            &mut |x, y, dx| self.solve_step(ode, x, y, dx),
200            x_start,
201            x_end,
202            y,
203            dx_est,
204        )
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use petir::mathf::RealMath;
211    use super::*;
212
213    struct DecayOde;
214    impl OdeSystem for DecayOde {
215        fn n_eqns(&self) -> usize {
216            1
217        }
218        fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
219            dydx[0] = -y[0];
220        }
221    }
222
223    struct VanDerPol {
224        mu: f64,
225    }
226    impl OdeSystem for VanDerPol {
227        fn n_eqns(&self) -> usize {
228            2
229        }
230        fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
231            dydx[0] = y[1];
232            dydx[1] = self.mu * (1.0 - y[0] * y[0]) * y[1] - y[0];
233        }
234    }
235
236    /// `y' = -y`, but the derivative is NaN once `|y| > 2`: finite on the
237    /// whole true trajectory, non-finite only at the overshot stage states an
238    /// oversized trial step visits.
239    struct DecayNanOutsideRange;
240    impl OdeSystem for DecayNanOutsideRange {
241        fn n_eqns(&self) -> usize {
242            1
243        }
244        fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
245            dydx[0] = if y[0].abs() > 2.0 { f64::NAN } else { -y[0] };
246        }
247    }
248
249    /// **Methodology.** A trial step that overflows while the accepted state is
250    /// finite must be rejected and retried with a smaller step, as upstream
251    /// `Foam::adaptiveSolver::solve` does (`err = inf` -> `scale = minScale`).
252    /// Starting `y(0) = 1` with a first step `dx = 10` sends RKF45's later
253    /// stages beyond `|y| = 2`, where the derivative is NaN. Pass criterion:
254    /// `Ok`, and `y(1)` within `1e-6` of `e^{-1}`. Before 2026-10-21 (gh:#985)
255    /// the op-ad6h guard returned `Err(NonFiniteState)` on the first trial,
256    /// which is what broke offbeat's VISCOCHAB relaxation test.
257    ///
258    /// **Result (2026-10-21).** Passes; the solver recovers and the error is
259    /// below `1e-6`.
260    #[test]
261    fn rkf45_recovers_from_a_trial_step_that_overflows() {
262        let mut solver = Rkf45::new(1, 1e-8, 1e-6);
263        let mut y = vec![1.0_f64];
264        let mut dx = 10.0;
265        solver
266            .integrate(&DecayNanOutsideRange, 0.0, 1.0, &mut y, &mut dx)
267            .unwrap();
268        assert!((y[0] - (-1.0_f64).r_exp()).abs() < 1e-6, "y = {}", y[0]);
269    }
270
271    /// The other half of op-ad6h is kept: a derivative that is NaN at the
272    /// ACCEPTED state is still reported as `NonFiniteState`, never `Ok`.
273    #[test]
274    fn rkf45_reports_a_non_finite_derivative_at_the_accepted_state() {
275        let mut solver = Rkf45::new(1, 1e-8, 1e-6);
276        let mut y = vec![3.0_f64];
277        let mut dx = 0.1;
278        let outcome = solver.integrate(&DecayNanOutsideRange, 0.0, 1.0, &mut y, &mut dx);
279        assert_eq!(outcome, Err(OdeError::NonFiniteState));
280    }
281
282    #[test]
283    fn rkf45_exponential_decay() {
284        // y' = -y, y(0) = 1  →  y(1) = e^{-1}
285        let ode = DecayOde;
286        let mut solver = Rkf45::new(1, 1e-8, 1e-6);
287        let mut y = vec![1.0_f64];
288        let mut dx = 0.1;
289        solver.integrate(&ode, 0.0, 1.0, &mut y, &mut dx).unwrap();
290        let expected = (-1.0_f64).r_exp();
291        assert!(
292            (y[0] - expected).abs() < 1e-6,
293            "y={:.10}, expected={:.10}",
294            y[0],
295            expected
296        );
297    }
298
299    #[test]
300    fn rkf45_decay_to_5() {
301        // y' = -y, y(0) = 1  →  y(5) = e^{-5}
302        let ode = DecayOde;
303        let mut solver = Rkf45::new(1, 1e-9, 1e-7);
304        let mut y = vec![1.0_f64];
305        let mut dx = 0.1;
306        solver.integrate(&ode, 0.0, 5.0, &mut y, &mut dx).unwrap();
307        let expected = (-5.0_f64).r_exp();
308        assert!(
309            (y[0] - expected).abs() < 1e-7,
310            "y={:.12}, expected={:.12}",
311            y[0],
312            expected
313        );
314    }
315
316    #[test]
317    fn rkf45_nonstiff_vdp() {
318        // Mildly non-stiff Van der Pol (mu=0.5): y(0) = (2, 0), integrate to t=2
319        // Just check it completes without error and the solution is bounded
320        let ode = VanDerPol { mu: 0.5 };
321        let mut solver = Rkf45::new(2, 1e-7, 1e-5);
322        let mut y = vec![2.0_f64, 0.0];
323        let mut dx = 0.1;
324        solver.integrate(&ode, 0.0, 2.0, &mut y, &mut dx).unwrap();
325        assert!(y[0].abs() < 3.0, "y0={}", y[0]);
326        assert!(y[1].abs() < 3.0, "y1={}", y[1]);
327    }
328
329    #[test]
330    fn rkf45_convergence_order_is_fifth() {
331        // Order verification: halving h should reduce the global error by ~2^5 = 32.
332        // This RKF45 propagates with the 5th-order (B) solution (Dormand-Prince convention).
333        // Force fixed-step by setting abs_tol=1 >> expected error so every step is accepted,
334        // then manually supply dx_try = h at each iteration.
335        let exact = (-1.0_f64).r_exp();
336
337        let run = |n: usize| -> f64 {
338            let ode = DecayOde;
339            let mut solver = Rkf45::new(1, 1.0, 0.0);
340            let mut y = vec![1.0_f64];
341            let h = 1.0 / n as f64;
342            let mut x = 0.0_f64;
343            for _ in 0..n {
344                let mut dx_try = h;
345                solver
346                    .solve_step(&ode, &mut x, &mut y, &mut dx_try)
347                    .unwrap();
348            }
349            (y[0] - exact).abs()
350        };
351
352        let err_coarse = run(5); // h = 0.2
353        let err_fine = run(10); // h = 0.1
354        let ratio = err_coarse / err_fine;
355        // 5th-order propagation: ratio ≈ 2^5 = 32; accept [16, 64] to allow higher-order terms
356        assert!(
357            ratio > 16.0 && ratio < 64.0,
358            "order ratio = {ratio:.2} (expected ~32 for RKF45 5th-order propagation)"
359        );
360    }
361
362    #[test]
363    fn rkf45_exceeds_steps_on_stiff_vdp() {
364        // Stiff Van der Pol (mu=1000): RKF45 needs h < ~2/mu² ≈ 2e-6 for stability,
365        // so it quickly exhausts max_steps even on a short interval.
366        let ode = VanDerPol { mu: 1000.0 };
367        let mut solver = Rkf45::new(2, 1e-6, 1e-4);
368        solver.config.max_steps = 200;
369        let mut y = vec![2.0_f64, 0.0];
370        let mut dx = 0.1;
371        let result = solver.integrate(&ode, 0.0, 0.5, &mut y, &mut dx);
372        assert!(
373            matches!(result, Err(OdeError::MaxStepsExceeded(_))),
374            "expected MaxStepsExceeded, got {:?}",
375            result
376        );
377    }
378}