outram_foam_basic_lib/ode/mod.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
22//! Ordinary differential equation solvers for systems `dy/dx = f(x, y)`.
23//!
24//! Ports the OpenFOAM `ODE` layer: user systems implement the [`OdeSystem`](crate::ode::OdeSystem)
25//! trait, and one of the concrete steppers integrates them with adaptive step
26//! control — [`Euler`](crate::ode::euler::Euler) (explicit 1st order),
27//! [`Rkf45`](crate::ode::rkf45::Rkf45) (explicit Runge-Kutta-Fehlberg 4(5)), and
28//! [`Rosenbrock23`](crate::ode::rosenbrock23::Rosenbrock23) (semi-implicit, for stiff systems,
29//! requiring a Jacobian). The independent variable `x`, state `y`, and step
30//! size are bare `f64` in the caller's own units; tolerances are set through
31//! [`OdeSolverConfig`](crate::ode::OdeSolverConfig).
32//!
33//! # Storing an integrator: [`OdeIntegrator`](crate::ode::integrator::OdeIntegrator)
34//!
35//! The three steppers above take the system by reference on every call, which
36//! is awkward for any caller that wants to *keep* "the integrator for this
37//! material point" as a struct field — storing a borrow would force a lifetime
38//! parameter, which the workspace design rules forbid.
39//!
40//! [`integrator`](crate::ode::integrator) solves that with two enums that own
41//! what they integrate: [`OdeSolver`](crate::ode::integrator::OdeSolver) selects
42//! the stepper, and
43//! [`OdeIntegrator`](crate::ode::integrator::OdeIntegrator) selects how the
44//! system is supplied —
45//! [`OdeIntegrator::TypedState`](crate::ode::integrator::OdeIntegrator::TypedState)
46//! (a concrete system owned
47//! by value, statically dispatched, **preferred**) or
48//! [`OdeIntegrator::DynSystem`](crate::ode::integrator::OdeIntegrator::DynSystem)
49//! (an `Arc<dyn OdeSystem + Send + Sync>`, kept by
50//! maintainer decision for flexibility). Neither borrows, so neither needs a
51//! lifetime.
52
53pub mod euler;
54pub mod integrator;
55pub mod parallel;
56pub mod rkf45;
57pub mod rosenbrock23;
58
59use petir::mathf::RealMath;
60pub use euler::Euler;
61pub use integrator::{
62 DynSystemIntegrator, NoTypedSystem, OdeIntegrator, OdeSolver, SharedOdeSystem,
63 TypedStateIntegrator,
64};
65pub use parallel::{
66 adaptive_quadrature_batch, ensemble_backend_for, integrate_ensemble, integrate_ensemble_mixed,
67 quadrature_backend_for, quadrature_batch, AdaptiveSettings, GaussOrder, OdeEnsemble,
68 OdeEnsembleFailure, OdeLane, OdeLaneSolution, OdeLaneStatus, QuadratureBatch,
69 QuadratureBatchFailure, QuadratureInterval, QuadratureRule, QuadratureSolution,
70 QuadratureStatus,
71};
72pub use rkf45::Rkf45;
73pub use rosenbrock23::Rosenbrock23;
74
75use crate::matrix::SquareMatrix;
76
77// ── ODE system trait ─────────────────────────────────────────────────────────
78
79/// Abstract ODE system `dy/dx = f(x, y)`. Maps to `Foam::ODESystem`.
80pub trait OdeSystem {
81 /// Number of coupled equations (the length of the state vector `y`).
82 fn n_eqns(&self) -> usize;
83
84 /// Fill `dydx` with the derivatives at `(x, y)`.
85 fn derivatives(&self, x: f64, y: &[f64], dydx: &mut Vec<f64>);
86
87 /// Fill `dfdx` and `dfdy` with the Jacobian at `(x, y)`.
88 ///
89 /// Required only by stiff solvers (Rosenbrock23). The default panics.
90 fn jacobian(&self, _x: f64, _y: &[f64], _dfdx: &mut Vec<f64>, _dfdy: &mut SquareMatrix) {
91 unimplemented!("jacobian not implemented for this ODE system");
92 }
93}
94
95// ── Solver configuration ─────────────────────────────────────────────────────
96
97/// Common parameters for the adaptive step-size controller.
98/// Defaults match `Foam::adaptiveSolver` and `Foam::ODESolver`.
99#[derive(Debug, Clone)]
100pub struct OdeSolverConfig {
101 /// Absolute per-equation tolerance.
102 pub abs_tol: f64,
103 /// Relative per-equation tolerance.
104 pub rel_tol: f64,
105 /// Safety factor on the step-size scaling (0 < safeScale < 1).
106 pub safe_scale: f64,
107 /// Exponent for step *increase*.
108 pub alpha_inc: f64,
109 /// Exponent for step *decrease*.
110 pub alpha_dec: f64,
111 /// Minimum scale factor applied per step.
112 pub min_scale: f64,
113 /// Maximum scale factor applied per step.
114 pub max_scale: f64,
115 /// Maximum sub-steps for one `integrate()` call.
116 pub max_steps: usize,
117}
118
119impl Default for OdeSolverConfig {
120 fn default() -> Self {
121 Self {
122 abs_tol: 1e-6,
123 rel_tol: 1e-4,
124 safe_scale: 0.9,
125 alpha_inc: 0.2,
126 alpha_dec: 0.25,
127 min_scale: 0.2,
128 max_scale: 10.0,
129 max_steps: 10_000,
130 }
131 }
132}
133
134// ── Error type ───────────────────────────────────────────────────────────────
135
136/// Failure modes of an adaptive integration.
137#[derive(Debug, Clone, PartialEq)]
138pub enum OdeError {
139 /// The step size shrank below machine epsilon while trying to meet the
140 /// error tolerance — the system is too stiff for the chosen solver, or the
141 /// tolerances are unattainable.
142 StepSizeUnderflow,
143 /// The interval could not be spanned within `max_steps` sub-steps; carries
144 /// the number of steps taken.
145 MaxStepsExceeded(usize),
146 /// The system produced a non-finite (NaN or infinite) error estimate, so
147 /// the state cannot be trusted and integration stopped.
148 ///
149 /// The usual cause is `derivatives` or `jacobian` returning a non-finite
150 /// value — for example a numerically-differenced Jacobian
151 /// ([`crate::math::differentiate::NumericalJacobian`]) that could not form
152 /// a column, or an evaluation that left the model's valid range.
153 ///
154 /// # Why this variant exists
155 ///
156 /// Before bead `op-ad6h` this case did not error at all: it returned
157 /// `Ok(())` with a NaN state, because the per-equation error fold used
158 /// `f64::max`, which discards NaN. A wrong answer reported as success is
159 /// the worst failure mode available to a solver, so it is now a distinct
160 /// error rather than being folded into
161 /// [`StepSizeUnderflow`](Self::StepSizeUnderflow), which would have named
162 /// the wrong cause.
163 NonFiniteState,
164}
165
166impl std::fmt::Display for OdeError {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 match self {
169 Self::StepSizeUnderflow => write!(f, "ODE step size underflow"),
170 Self::MaxStepsExceeded(n) => write!(f, "ODE exceeded {n} steps"),
171 Self::NonFiniteState => write!(
172 f,
173 "ODE produced a non-finite error estimate (NaN or infinite state)"
174 ),
175 }
176 }
177}
178
179impl std::error::Error for OdeError {}
180
181// ── Shared utilities ─────────────────────────────────────────────────────────
182
183/// Normalised scalar error — max over all equations.
184/// Maps to `Foam::ODESolver::normalizeError`.
185pub(crate) fn normalize_error(
186 y0: &[f64],
187 y: &[f64],
188 err: &[f64],
189 abs_tol: f64,
190 rel_tol: f64,
191) -> f64 {
192 err.iter()
193 .zip(y0)
194 .zip(y)
195 .map(|((e, &a), &b)| {
196 let tol = abs_tol + rel_tol * a.abs().max(b.abs());
197 e.abs() / tol
198 })
199 // NB: `fold(0.0, f64::max)` here was a silent-wrong-answer bug
200 // (bead `op-ad6h`). Rust's `f64::max` follows IEEE-754 `maxNum` and
201 // DISCARDS a NaN operand, so `f64::max(0.0, NaN) == 0.0`: a NaN error
202 // read as *zero* error, the step was accepted as perfectly converged,
203 // and `integrate` returned `Ok(())` with a NaN state. Returning NaN
204 // instead would not fix it either, because the caller's accept test is
205 // `err <= 1.0` and every comparison against NaN is false — the step
206 // would be rejected, but `dx` would then shrink to underflow with no
207 // indication of why.
208 //
209 // So a non-finite error is reported as `INFINITY`, which is both true
210 // ("this step is unboundedly bad") and actionable: the caller rejects
211 // it and, per the check in `adaptive_step`, returns
212 // `OdeError::NonFiniteState` rather than a misleading
213 // `StepSizeUnderflow`.
214 .fold(0.0_f64, |acc, e| {
215 if e.is_nan() || acc.is_nan() {
216 f64::INFINITY
217 } else {
218 acc.max(e)
219 }
220 })
221}
222
223/// Adaptive step-size loop shared by all explicit solvers.
224///
225/// Calls `inner_step(x0, y0, dydx0, dx, y_out) -> err`, retrying with a
226/// smaller `dx` whenever `err > 1`. Updates `x`, `y`, and `dx_try`.
227/// Matches `Foam::adaptiveSolver::solve`.
228pub(crate) fn adaptive_step<Sys: OdeSystem + ?Sized>(
229 cfg: &OdeSolverConfig,
230 mut inner_step: impl FnMut(f64, &[f64], &[f64], f64, &mut Vec<f64>) -> f64,
231 ode: &Sys,
232 x: &mut f64,
233 y: &mut Vec<f64>,
234 dydx0: &mut Vec<f64>,
235 y_temp: &mut Vec<f64>,
236 dx_try: &mut f64,
237) -> Result<(), OdeError> {
238 let mut dx = *dx_try;
239 ode.derivatives(*x, y, dydx0);
240
241 let err = loop {
242 let err = inner_step(*x, y, dydx0, dx, y_temp);
243 // A non-finite error means the system produced NaN or an infinite
244 // value; shrinking `dx` cannot recover from that, so fail immediately
245 // and name the real cause instead of grinding down to a misleading
246 // `StepSizeUnderflow`. See `OdeError::NonFiniteState` and bead
247 // `op-ad6h`.
248 if !err.is_finite() {
249 return Err(OdeError::NonFiniteState);
250 }
251 if err <= 1.0 {
252 break err;
253 }
254 let scale = (cfg.safe_scale * err.r_powf(-cfg.alpha_dec)).max(cfg.min_scale);
255 dx *= scale;
256 if dx.abs() < f64::EPSILON {
257 return Err(OdeError::StepSizeUnderflow);
258 }
259 };
260
261 *x += dx;
262 std::mem::swap(y, y_temp);
263
264 let threshold = (cfg.max_scale / cfg.safe_scale).r_powf(-1.0 / cfg.alpha_inc);
265 *dx_try = if err > threshold {
266 let scale = (cfg.safe_scale * err.r_powf(-cfg.alpha_inc)).clamp(cfg.min_scale, cfg.max_scale);
267 dx * scale
268 } else {
269 dx * cfg.safe_scale * cfg.max_scale
270 };
271
272 Ok(())
273}
274
275/// Integrate from `x_start` to `x_end` using repeated adaptive steps.
276pub(crate) fn integrate_interval(
277 cfg: &OdeSolverConfig,
278 step_fn: &mut dyn FnMut(&mut f64, &mut Vec<f64>, &mut f64) -> Result<(), OdeError>,
279 x_start: f64,
280 x_end: f64,
281 y: &mut Vec<f64>,
282 dx_est: &mut f64,
283) -> Result<(), OdeError> {
284 let mut x = x_start;
285 let mut dx = *dx_est;
286 let mut n_steps = 0usize;
287
288 while x < x_end {
289 let dx_try = (x + dx).min(x_end) - x;
290 let mut dx_limited = dx_try;
291 step_fn(&mut x, y, &mut dx_limited)?;
292 dx = dx_limited;
293 n_steps += 1;
294 if n_steps > cfg.max_steps {
295 return Err(OdeError::MaxStepsExceeded(n_steps));
296 }
297 }
298
299 *dx_est = dx;
300 Ok(())
301}