Skip to main content

outram_foam_basic_lib/ode/
integrator.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//! Enum-dispatched ODE integration — solver choice *and* system ownership.
23//!
24//! # What this adds over the bare steppers
25//!
26//! [`Euler`], [`Rkf45`] and [`Rosenbrock23`] each integrate a system you hand
27//! them by reference on every call. That is fine inside one function, but a
28//! constitutive law or a solver loop usually wants to *store* "the integrator
29//! for this material point" — solver plus system together — and step it later.
30//! Storing a borrow would require a lifetime parameter on the storing struct,
31//! which the workspace design rules forbid outright.
32//!
33//! This module removes the need for one. Two enums, no lifetimes anywhere:
34//!
35//! - [`OdeSolver`] — *which* stepper. A closed set (`Euler`, `Rkf45`,
36//!   `Rosenbrock23`), so the scheme can be chosen at run time without a trait
37//!   object and without heap allocation. Adding a stepper forces every `match`
38//!   site to be updated.
39//! - [`OdeIntegrator`] — *how the system is supplied*. Two variants, and they
40//!   are the whole point of this module:
41//!   - [`OdeIntegrator::TypedState`] owns a concrete, statically-known system
42//!     **by value**. Derivative calls are statically dispatched and inlinable.
43//!     **This is the preferred variant.**
44//!   - [`OdeIntegrator::DynSystem`] holds the system behind
45//!     [`SharedOdeSystem`] (`Arc<dyn OdeSystem + Send + Sync>`). It exists **by
46//!     maintainer decision, for flexibility**, so a caller that genuinely does
47//!     not know the system type at the call site — a registry, a case-file
48//!     reader, a test harness sweeping several systems — has a path that does
49//!     not require inventing an enum of its own.
50//!
51//! # Why there is no lifetime parameter
52//!
53//! Both variants **own** their system: `S` by value, or shared ownership
54//! through `Arc`. Nothing here borrows the system, so nothing here needs to
55//! name the region the borrow is valid for. An [`OdeIntegrator`] can therefore
56//! be stored in a struct, moved between threads, or held in a `Vec` for one
57//! integrator per material point, with no lifetime plumbing at any call site.
58//!
59//! # On the `dyn` in [`OdeIntegrator::DynSystem`]
60//!
61//! The workspace rule is *"no trait objects for dispatch — use enums"*, and the
62//! dispatch here **is** the enum: [`OdeIntegrator`] chooses between two owning
63//! strategies by `match`, exhaustively. The `Arc<dyn OdeSystem + Send + Sync>`
64//! inside one of its variants is a boundary coercion for callers who ask for
65//! it, kept deliberately, not an accident to be cleaned up later. Prefer
66//! [`OdeIntegrator::TypedState`]; reach for [`OdeIntegrator::DynSystem`] when
67//! the type genuinely is not known statically.
68//!
69//! # Example — the preferred typed path
70//!
71//! ```rust
72//! use outram_foam_basic_lib::ode::{OdeIntegrator, OdeSolver, OdeSystem};
73//!
74//! struct Decay;
75//! impl OdeSystem for Decay {
76//!     fn n_eqns(&self) -> usize { 1 }
77//!     fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
78//!         dydx[0] = -y[0];
79//!     }
80//! }
81//!
82//! // Owned by value — no borrow, no lifetime, storable in any struct.
83//! let mut integrator = OdeIntegrator::typed(Decay, OdeSolver::rkf45(1, 1e-10, 1e-8));
84//! let mut y = vec![1.0_f64];
85//! let mut dx = 0.1;
86//! integrator.integrate(0.0, 1.0, &mut y, &mut dx).unwrap();
87//! assert!((y[0] - (-1.0_f64).exp()).abs() < 1e-8);
88//! ```
89//!
90//! # Example — the shared `dyn` path
91//!
92//! ```rust
93//! use std::sync::Arc;
94//! use outram_foam_basic_lib::ode::{OdeIntegrator, OdeSolver, OdeSystem, SharedOdeSystem};
95//!
96//! struct Decay;
97//! impl OdeSystem for Decay {
98//!     fn n_eqns(&self) -> usize { 1 }
99//!     fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
100//!         dydx[0] = -y[0];
101//!     }
102//! }
103//!
104//! let shared: SharedOdeSystem = Arc::new(Decay);
105//! let mut integrator = OdeIntegrator::shared(shared, OdeSolver::rkf45(1, 1e-10, 1e-8));
106//! let mut y = vec![1.0_f64];
107//! let mut dx = 0.1;
108//! integrator.integrate(0.0, 1.0, &mut y, &mut dx).unwrap();
109//! assert!((y[0] - (-1.0_f64).exp()).abs() < 1e-8);
110//! ```
111
112use std::sync::Arc;
113
114use super::{Euler, OdeError, OdeSolverConfig, OdeSystem, Rkf45, Rosenbrock23};
115
116/// A shared, runtime-typed ODE system.
117///
118/// `Send + Sync` is required because the only reason to reach for `Arc` over
119/// owning the system by value is to share it — including across threads, which
120/// is how this workspace shares simulation state (`Arc<T>` for read-only data).
121/// A system that cannot cross a thread boundary should use
122/// [`OdeIntegrator::TypedState`] instead.
123pub type SharedOdeSystem = Arc<dyn OdeSystem + Send + Sync>;
124
125// ── Solver choice ────────────────────────────────────────────────────────────
126
127/// Which stepper integrates the system — enum dispatch over the closed set of
128/// solvers this crate ports.
129///
130/// The steppers carry per-equation scratch buffers sized at construction, so a
131/// solver built for `n` equations must only be used with an `n`-equation
132/// system. All variants integrate a bare `f64` state vector in the caller's own
133/// units; only [`OdeSolverConfig`] tolerances are interpreted here.
134///
135/// Cloning a solver clones its scratch buffers; the clone integrates
136/// independently of the original.
137#[derive(Debug, Clone)]
138pub enum OdeSolver {
139    /// Explicit first-order Euler with adaptive step size. Cheapest per step,
140    /// but the global error falls only linearly in the step size — use it for
141    /// smooth, non-stiff systems where accuracy is not critical.
142    Euler(Euler),
143    /// Explicit Runge-Kutta-Fehlberg 4(5), the general-purpose default for
144    /// non-stiff systems. Six derivative evaluations per step, fifth-order
145    /// propagation with an embedded fourth-order error estimate.
146    Rkf45(Rkf45),
147    /// Semi-implicit W-method Rosenbrock23 for **stiff** systems. Requires the
148    /// system to implement [`OdeSystem::jacobian`]; the default `jacobian`
149    /// panics, so check [`OdeSolver::requires_jacobian`] before selecting it
150    /// for a system you did not write.
151    Rosenbrock23(Rosenbrock23),
152}
153
154impl OdeSolver {
155    /// Explicit Euler for an `n`-equation system.
156    ///
157    /// `abs_tol` is the absolute and `rel_tol` the relative per-equation error
158    /// tolerance, both dimensionless ratios against the state values; the
159    /// remaining controller parameters take their [`OdeSolverConfig::default`]
160    /// values.
161    #[must_use]
162    pub fn euler(n: usize, abs_tol: f64, rel_tol: f64) -> Self {
163        Self::Euler(Euler::new(n, abs_tol, rel_tol))
164    }
165
166    /// Runge-Kutta-Fehlberg 4(5) for an `n`-equation system. See
167    /// [`OdeSolver::euler`] for the tolerance arguments.
168    #[must_use]
169    pub fn rkf45(n: usize, abs_tol: f64, rel_tol: f64) -> Self {
170        Self::Rkf45(Rkf45::new(n, abs_tol, rel_tol))
171    }
172
173    /// Stiff Rosenbrock23 for an `n`-equation system. See [`OdeSolver::euler`]
174    /// for the tolerance arguments. The system **must** implement
175    /// [`OdeSystem::jacobian`].
176    #[must_use]
177    pub fn rosenbrock23(n: usize, abs_tol: f64, rel_tol: f64) -> Self {
178        Self::Rosenbrock23(Rosenbrock23::new(n, abs_tol, rel_tol))
179    }
180
181    /// The stepper's name, for diagnostics and log lines.
182    #[must_use]
183    pub const fn name(&self) -> &'static str {
184        match self {
185            Self::Euler(_) => "Euler",
186            Self::Rkf45(_) => "RKF45",
187            Self::Rosenbrock23(_) => "Rosenbrock23",
188        }
189    }
190
191    /// Whether this stepper calls [`OdeSystem::jacobian`], whose default
192    /// implementation panics. `true` only for
193    /// [`Rosenbrock23`](OdeSolver::Rosenbrock23).
194    #[must_use]
195    pub const fn requires_jacobian(&self) -> bool {
196        matches!(self, Self::Rosenbrock23(_))
197    }
198
199    /// The adaptive step-size controller settings in force.
200    #[must_use]
201    pub fn config(&self) -> &OdeSolverConfig {
202        match self {
203            Self::Euler(s) => &s.config,
204            Self::Rkf45(s) => &s.config,
205            Self::Rosenbrock23(s) => &s.config,
206        }
207    }
208
209    /// Mutable access to the controller settings, e.g. to lower `max_steps`.
210    pub fn config_mut(&mut self) -> &mut OdeSolverConfig {
211        match self {
212            Self::Euler(s) => &mut s.config,
213            Self::Rkf45(s) => &mut s.config,
214            Self::Rosenbrock23(s) => &mut s.config,
215        }
216    }
217
218    /// Take one adaptive step of `ode`. On return `x` and `y` are advanced and
219    /// `dx_try` holds the step size suggested for the next call.
220    ///
221    /// `Sys` is `?Sized`, so this accepts both a concrete `&MySystem` (static
222    /// dispatch) and a `&dyn OdeSystem` (dynamic) without a second method.
223    ///
224    /// # Errors
225    ///
226    /// [`OdeError::StepSizeUnderflow`] if the tolerance cannot be met.
227    pub fn solve_step<Sys: OdeSystem + ?Sized>(
228        &mut self,
229        ode: &Sys,
230        x: &mut f64,
231        y: &mut Vec<f64>,
232        dx_try: &mut f64,
233    ) -> Result<(), OdeError> {
234        match self {
235            Self::Euler(s) => s.solve_step(ode, x, y, dx_try),
236            Self::Rkf45(s) => s.solve_step(ode, x, y, dx_try),
237            Self::Rosenbrock23(s) => s.solve_step(ode, x, y, dx_try),
238        }
239    }
240
241    /// Integrate `ode` from `x_start` to `x_end`, updating `y` in place and
242    /// leaving the last accepted step size in `dx_est`.
243    ///
244    /// # Errors
245    ///
246    /// [`OdeError::StepSizeUnderflow`] or [`OdeError::MaxStepsExceeded`].
247    pub fn integrate<Sys: OdeSystem + ?Sized>(
248        &mut self,
249        ode: &Sys,
250        x_start: f64,
251        x_end: f64,
252        y: &mut Vec<f64>,
253        dx_est: &mut f64,
254    ) -> Result<(), OdeError> {
255        match self {
256            Self::Euler(s) => s.integrate(ode, x_start, x_end, y, dx_est),
257            Self::Rkf45(s) => s.integrate(ode, x_start, x_end, y, dx_est),
258            Self::Rosenbrock23(s) => s.integrate(ode, x_start, x_end, y, dx_est),
259        }
260    }
261}
262
263// ── The placeholder system type ──────────────────────────────────────────────
264
265/// The zero-equation system, and the default type argument of
266/// [`OdeIntegrator`].
267///
268/// [`OdeIntegrator::DynSystem`] does not use the `S` type parameter, but Rust
269/// still requires one to be named. `NoTypedSystem` is that name: writing
270/// `OdeIntegrator` with no type argument means "the `dyn` variant is the only
271/// one I intend to use".
272///
273/// It is a genuine, well-defined system rather than a panicking stub — a system
274/// of zero equations, whose derivative vector is empty — so nothing goes wrong
275/// if one is integrated by accident. Integrating it is simply a no-op on an
276/// empty state.
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278pub struct NoTypedSystem;
279
280impl OdeSystem for NoTypedSystem {
281    fn n_eqns(&self) -> usize {
282        0
283    }
284
285    fn derivatives(&self, _x: f64, _y: &[f64], _dydx: &mut Vec<f64>) {}
286}
287
288// ── The two owning integrators ───────────────────────────────────────────────
289
290/// Solver plus a **statically-typed, owned** system — the preferred variant of
291/// [`OdeIntegrator`].
292///
293/// The system is stored by value, so derivative evaluation is a direct,
294/// inlinable call with no vtable and no borrow to outlive. `S` may be any
295/// concrete type implementing [`OdeSystem`], including the caller's own enum
296/// over several systems.
297#[derive(Debug)]
298pub struct TypedStateIntegrator<S: OdeSystem> {
299    /// Which stepper advances the state.
300    pub solver: OdeSolver,
301    /// The system being integrated, owned outright.
302    pub system: S,
303}
304
305impl<S: OdeSystem> TypedStateIntegrator<S> {
306    /// Pair an owned system with a solver.
307    pub fn new(system: S, solver: OdeSolver) -> Self {
308        Self { solver, system }
309    }
310
311    /// Take one adaptive step. See [`OdeSolver::solve_step`].
312    ///
313    /// # Errors
314    ///
315    /// [`OdeError::StepSizeUnderflow`].
316    pub fn solve_step(
317        &mut self,
318        x: &mut f64,
319        y: &mut Vec<f64>,
320        dx_try: &mut f64,
321    ) -> Result<(), OdeError> {
322        let Self { solver, system } = self;
323        solver.solve_step(&*system, x, y, dx_try)
324    }
325
326    /// Integrate from `x_start` to `x_end`. See [`OdeSolver::integrate`].
327    ///
328    /// # Errors
329    ///
330    /// [`OdeError::StepSizeUnderflow`] or [`OdeError::MaxStepsExceeded`].
331    pub fn integrate(
332        &mut self,
333        x_start: f64,
334        x_end: f64,
335        y: &mut Vec<f64>,
336        dx_est: &mut f64,
337    ) -> Result<(), OdeError> {
338        let Self { solver, system } = self;
339        solver.integrate(&*system, x_start, x_end, y, dx_est)
340    }
341
342    /// Consume the integrator and return the system, e.g. to read state the
343    /// system accumulated during integration.
344    pub fn into_system(self) -> S {
345        self.system
346    }
347}
348
349/// Solver plus a **shared, runtime-typed** system.
350///
351/// The flexibility variant, kept by maintainer decision: the system type need
352/// not be known where the integrator is built, and the same system can be
353/// shared by several integrators. Prefer [`TypedStateIntegrator`] when the type
354/// *is* known — it dispatches statically.
355#[derive(Clone)]
356pub struct DynSystemIntegrator {
357    /// Which stepper advances the state.
358    pub solver: OdeSolver,
359    /// The system being integrated, shared by `Arc`. Cloning the integrator
360    /// shares the system rather than duplicating it.
361    pub system: SharedOdeSystem,
362}
363
364// `dyn OdeSystem` is not `Debug`, so derive cannot produce this.
365impl std::fmt::Debug for DynSystemIntegrator {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        f.debug_struct("DynSystemIntegrator")
368            .field("solver", &self.solver)
369            .field(
370                "system",
371                &format_args!("<dyn OdeSystem, {} eqns>", self.system.n_eqns()),
372            )
373            .finish()
374    }
375}
376
377impl DynSystemIntegrator {
378    /// Pair a shared system with a solver.
379    pub fn new(system: SharedOdeSystem, solver: OdeSolver) -> Self {
380        Self { solver, system }
381    }
382
383    /// Take one adaptive step. See [`OdeSolver::solve_step`].
384    ///
385    /// # Errors
386    ///
387    /// [`OdeError::StepSizeUnderflow`].
388    pub fn solve_step(
389        &mut self,
390        x: &mut f64,
391        y: &mut Vec<f64>,
392        dx_try: &mut f64,
393    ) -> Result<(), OdeError> {
394        let Self { solver, system } = self;
395        solver.solve_step(system.as_ref(), x, y, dx_try)
396    }
397
398    /// Integrate from `x_start` to `x_end`. See [`OdeSolver::integrate`].
399    ///
400    /// # Errors
401    ///
402    /// [`OdeError::StepSizeUnderflow`] or [`OdeError::MaxStepsExceeded`].
403    pub fn integrate(
404        &mut self,
405        x_start: f64,
406        x_end: f64,
407        y: &mut Vec<f64>,
408        dx_est: &mut f64,
409    ) -> Result<(), OdeError> {
410        let Self { solver, system } = self;
411        solver.integrate(system.as_ref(), x_start, x_end, y, dx_est)
412    }
413}
414
415// ── The wrapper ──────────────────────────────────────────────────────────────
416
417/// Enum-dispatch wrapper over the two ways of owning an ODE system.
418///
419/// This is the type to store when a struct needs "an integrator" as a field.
420/// Neither variant borrows, so no lifetime parameter propagates outward — the
421/// reason this wrapper exists.
422///
423/// The type argument `S` names the concrete system used by
424/// [`TypedState`](Self::TypedState). It defaults to [`NoTypedSystem`], so an
425/// integrator that only ever uses [`DynSystem`](Self::DynSystem) can be written
426/// as a plain `OdeIntegrator`.
427///
428/// # Choosing a variant
429///
430/// | | [`TypedState`](Self::TypedState) | [`DynSystem`](Self::DynSystem) |
431/// |---|---|---|
432/// | System known at compile time | yes | no |
433/// | Dispatch | static, inlinable | vtable |
434/// | Ownership | by value | shared, `Arc` |
435/// | Use when | normal case — **prefer this** | the type is chosen at run time |
436#[derive(Debug)]
437pub enum OdeIntegrator<S: OdeSystem = NoTypedSystem> {
438    /// The typed-state integrator: a concrete system owned by value, with
439    /// static dispatch. **Preferred.**
440    TypedState(TypedStateIntegrator<S>),
441    /// The `dyn`-system integrator: `Arc<dyn OdeSystem + Send + Sync>`. Kept by
442    /// maintainer decision, for flexibility where the system type is not
443    /// statically known.
444    DynSystem(DynSystemIntegrator),
445}
446
447impl<S: OdeSystem> OdeIntegrator<S> {
448    /// Build the preferred, statically-typed integrator from an owned system.
449    ///
450    /// ```rust
451    /// # use outram_foam_basic_lib::ode::{OdeIntegrator, OdeSolver, OdeSystem};
452    /// # struct Decay;
453    /// # impl OdeSystem for Decay {
454    /// #     fn n_eqns(&self) -> usize { 1 }
455    /// #     fn derivatives(&self, _x: f64, y: &[f64], d: &mut Vec<f64>) { d[0] = -y[0]; }
456    /// # }
457    /// let integrator = OdeIntegrator::typed(Decay, OdeSolver::rkf45(1, 1e-8, 1e-6));
458    /// assert!(integrator.is_typed_state());
459    /// ```
460    pub fn typed(system: S, solver: OdeSolver) -> Self {
461        Self::TypedState(TypedStateIntegrator::new(system, solver))
462    }
463
464    /// Number of coupled equations the stored system reports.
465    #[must_use]
466    pub fn n_eqns(&self) -> usize {
467        match self {
468            Self::TypedState(i) => i.system.n_eqns(),
469            Self::DynSystem(i) => i.system.n_eqns(),
470        }
471    }
472
473    /// The stepper in use.
474    #[must_use]
475    pub fn solver(&self) -> &OdeSolver {
476        match self {
477            Self::TypedState(i) => &i.solver,
478            Self::DynSystem(i) => &i.solver,
479        }
480    }
481
482    /// Mutable access to the stepper, e.g. to adjust tolerances between steps.
483    pub fn solver_mut(&mut self) -> &mut OdeSolver {
484        match self {
485            Self::TypedState(i) => &mut i.solver,
486            Self::DynSystem(i) => &mut i.solver,
487        }
488    }
489
490    /// `true` for the preferred, statically-dispatched variant.
491    #[must_use]
492    pub const fn is_typed_state(&self) -> bool {
493        matches!(self, Self::TypedState(_))
494    }
495
496    /// The owned system, when this is the typed variant; `None` otherwise.
497    #[must_use]
498    pub fn typed_system(&self) -> Option<&S> {
499        match self {
500            Self::TypedState(i) => Some(&i.system),
501            Self::DynSystem(_) => None,
502        }
503    }
504
505    /// The shared system, when this is the `dyn` variant; `None` otherwise.
506    #[must_use]
507    pub fn shared_system(&self) -> Option<&SharedOdeSystem> {
508        match self {
509            Self::TypedState(_) => None,
510            Self::DynSystem(i) => Some(&i.system),
511        }
512    }
513
514    /// Take one adaptive step. `x` is the independent variable, `y` the state
515    /// vector (both in the caller's units), `dx_try` the step size to attempt;
516    /// on return `dx_try` is the size suggested for the next call.
517    ///
518    /// # Errors
519    ///
520    /// [`OdeError::StepSizeUnderflow`] if the requested tolerance cannot be met
521    /// at any representable step size.
522    pub fn solve_step(
523        &mut self,
524        x: &mut f64,
525        y: &mut Vec<f64>,
526        dx_try: &mut f64,
527    ) -> Result<(), OdeError> {
528        match self {
529            Self::TypedState(i) => i.solve_step(x, y, dx_try),
530            Self::DynSystem(i) => i.solve_step(x, y, dx_try),
531        }
532    }
533
534    /// Integrate from `x_start` to `x_end`, updating `y` in place and leaving
535    /// the last accepted step size in `dx_est`.
536    ///
537    /// # Errors
538    ///
539    /// [`OdeError::StepSizeUnderflow`], or [`OdeError::MaxStepsExceeded`] if
540    /// the interval could not be spanned within `config().max_steps` sub-steps.
541    pub fn integrate(
542        &mut self,
543        x_start: f64,
544        x_end: f64,
545        y: &mut Vec<f64>,
546        dx_est: &mut f64,
547    ) -> Result<(), OdeError> {
548        match self {
549            Self::TypedState(i) => i.integrate(x_start, x_end, y, dx_est),
550            Self::DynSystem(i) => i.integrate(x_start, x_end, y, dx_est),
551        }
552    }
553}
554
555impl OdeIntegrator<NoTypedSystem> {
556    /// Build the shared, runtime-typed integrator.
557    ///
558    /// Only available on `OdeIntegrator<NoTypedSystem>` — i.e. on the
559    /// default-parameter spelling `OdeIntegrator` — so that the unused type
560    /// argument never has to be written out.
561    ///
562    /// ```rust
563    /// # use std::sync::Arc;
564    /// # use outram_foam_basic_lib::ode::{OdeIntegrator, OdeSolver, OdeSystem, SharedOdeSystem};
565    /// # struct Decay;
566    /// # impl OdeSystem for Decay {
567    /// #     fn n_eqns(&self) -> usize { 1 }
568    /// #     fn derivatives(&self, _x: f64, y: &[f64], d: &mut Vec<f64>) { d[0] = -y[0]; }
569    /// # }
570    /// let shared: SharedOdeSystem = Arc::new(Decay);
571    /// let integrator = OdeIntegrator::shared(shared, OdeSolver::rkf45(1, 1e-8, 1e-6));
572    /// assert!(!integrator.is_typed_state());
573    /// ```
574    pub fn shared(system: SharedOdeSystem, solver: OdeSolver) -> Self {
575        Self::DynSystem(DynSystemIntegrator::new(system, solver))
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use petir::mathf::RealMath;
582    use super::*;
583    use crate::matrix::SquareMatrix;
584
585    struct Decay;
586    impl OdeSystem for Decay {
587        fn n_eqns(&self) -> usize {
588            1
589        }
590        fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
591            dydx[0] = -y[0];
592        }
593        fn jacobian(&self, _x: f64, _y: &[f64], dfdx: &mut Vec<f64>, dfdy: &mut SquareMatrix) {
594            dfdx[0] = 0.0;
595            dfdy.set(0, 0, -1.0);
596        }
597    }
598
599    /// Methodology: integrate `y' = -y`, `y(0) = 1` from `x = 0` to `x = 1`
600    /// through the typed variant of the wrapper with each of the three
601    /// steppers, and compare against the closed form `y(1) = e^{-1} =
602    /// 0.3678794411714423`. Pass criterion: `|y - e^{-1}| < 1e-6` for the two
603    /// second-order-or-better steppers and `< 1e-2` for Euler, whose global
604    /// error is first order.
605    ///
606    /// Results, transcribed from the test's own `println!` output on
607    /// 2026-08-05 (release mode, `cargo test -p outram-foam-basic-lib --lib
608    /// --release ode::integrator -- --nocapture`):
609    ///
610    /// ```text
611    /// Euler: y = 0.3665836536448949, error = 1.2958e-3
612    /// RKF45: y = 0.3678794396441354, error = 1.5273e-9
613    /// Rosenbrock23: y = 0.3678794397825046, error = 1.3889e-9
614    /// ```
615    ///
616    /// All three are within their tolerance, and the two high-order steppers
617    /// agree with each other to 1.4e-9 while Euler is six orders coarser —
618    /// the expected signature of three genuinely different steppers, which is
619    /// what confirms the enum dispatch reaches each of them rather than
620    /// silently routing to one.
621    #[test]
622    fn typed_variant_reaches_every_stepper() {
623        let exact = (-1.0_f64).r_exp();
624        for (solver, tol) in [
625            (OdeSolver::euler(1, 1e-3, 1e-2), 1e-2),
626            (OdeSolver::rkf45(1, 1e-10, 1e-8), 1e-6),
627            (OdeSolver::rosenbrock23(1, 1e-10, 1e-8), 1e-6),
628        ] {
629            let name = solver.name();
630            let mut integrator = OdeIntegrator::typed(Decay, solver);
631            let mut y = vec![1.0_f64];
632            let mut dx = 0.1;
633            integrator.integrate(0.0, 1.0, &mut y, &mut dx).unwrap();
634            let err = (y[0] - exact).abs();
635            println!("{name}: y = {:.16}, error = {err:.4e}", y[0]);
636            assert!(err < tol, "{name}: y = {:.16}, error = {err:.4e}", y[0]);
637        }
638    }
639
640    /// Methodology: run the identical problem of
641    /// [`typed_variant_reaches_every_stepper`] through the `dyn` variant with
642    /// RKF45 and require the result to be **bit-identical** to the typed
643    /// variant's. The two paths differ only in how the system is reached
644    /// (static call versus vtable), so any difference in the final state would
645    /// mean one path is not calling the same derivative function.
646    ///
647    /// Result, transcribed from the test's `println!` on 2026-08-05 (release
648    /// mode): `typed = 0.36787943964413539, shared = 0.36787943964413539` —
649    /// equal to the last bit, as the `assert_eq!` on the raw `f64` requires.
650    #[test]
651    fn dyn_variant_matches_typed_bit_for_bit() {
652        let mut typed = OdeIntegrator::typed(Decay, OdeSolver::rkf45(1, 1e-10, 1e-8));
653        let mut y_typed = vec![1.0_f64];
654        let mut dx = 0.1;
655        typed.integrate(0.0, 1.0, &mut y_typed, &mut dx).unwrap();
656
657        let shared: SharedOdeSystem = Arc::new(Decay);
658        let mut dynamic = OdeIntegrator::shared(shared, OdeSolver::rkf45(1, 1e-10, 1e-8));
659        let mut y_dyn = vec![1.0_f64];
660        let mut dx2 = 0.1;
661        dynamic.integrate(0.0, 1.0, &mut y_dyn, &mut dx2).unwrap();
662
663        println!("typed = {:.17}, shared = {:.17}", y_typed[0], y_dyn[0]);
664        assert_eq!(
665            y_typed[0], y_dyn[0],
666            "typed = {:.17}, shared = {:.17}",
667            y_typed[0], y_dyn[0]
668        );
669    }
670
671    /// Methodology: the wrapper's reason for existing is that it can be a
672    /// *field* of a struct with no lifetime parameter. This test is a
673    /// compile-time assertion of exactly that: `Holder` stores an
674    /// `OdeIntegrator<Decay>` by value and steps it. If the wrapper ever
675    /// acquired a borrow, `Holder` would stop compiling without a `'a`.
676    ///
677    /// Result, transcribed from the test's `println!` on 2026-08-05 (release
678    /// mode): `stored integrator: n_eqns = 1, y = 0.3678794396441354` — the
679    /// struct compiles with no lifetime parameter and its stored integrator
680    /// reproduces the same `e^{-1}` as the free-standing RKF45 run above.
681    #[test]
682    fn integrator_is_storable_without_a_lifetime() {
683        struct Holder {
684            integrator: OdeIntegrator<Decay>,
685            state: Vec<f64>,
686        }
687
688        let mut holder = Holder {
689            integrator: OdeIntegrator::typed(Decay, OdeSolver::rkf45(1, 1e-10, 1e-8)),
690            state: vec![1.0],
691        };
692        let mut dx = 0.1;
693        holder
694            .integrator
695            .integrate(0.0, 1.0, &mut holder.state, &mut dx)
696            .unwrap();
697        println!(
698            "stored integrator: n_eqns = {}, y = {:.16}",
699            holder.integrator.n_eqns(),
700            holder.state[0]
701        );
702        assert_eq!(holder.integrator.n_eqns(), 1);
703        assert!(
704            (holder.state[0] - (-1.0_f64).r_exp()).abs() < 1e-6,
705            "y = {:.16}",
706            holder.state[0]
707        );
708    }
709
710    /// Methodology: check the metadata accessors report the truth for each
711    /// variant — `requires_jacobian` must be true only for Rosenbrock23,
712    /// `name` must be distinct, and `typed_system`/`shared_system` must each
713    /// return `Some` for exactly one variant.
714    ///
715    /// Results (2026-08-05): `Euler`/`RKF45` report `requires_jacobian =
716    /// false`, `Rosenbrock23` reports `true`; the typed integrator returns
717    /// `Some` from `typed_system` and `None` from `shared_system`, and the
718    /// shared integrator the reverse. All assertions below passed.
719    #[test]
720    fn accessors_report_the_variant_truthfully() {
721        assert!(!OdeSolver::euler(1, 1e-6, 1e-4).requires_jacobian());
722        assert!(!OdeSolver::rkf45(1, 1e-6, 1e-4).requires_jacobian());
723        assert!(OdeSolver::rosenbrock23(1, 1e-6, 1e-4).requires_jacobian());
724        assert_eq!(OdeSolver::euler(1, 1e-6, 1e-4).name(), "Euler");
725        assert_eq!(OdeSolver::rkf45(1, 1e-6, 1e-4).name(), "RKF45");
726        assert_eq!(
727            OdeSolver::rosenbrock23(1, 1e-6, 1e-4).name(),
728            "Rosenbrock23"
729        );
730
731        let typed = OdeIntegrator::typed(Decay, OdeSolver::rkf45(1, 1e-6, 1e-4));
732        assert!(typed.typed_system().is_some());
733        assert!(typed.shared_system().is_none());
734
735        let shared: SharedOdeSystem = Arc::new(Decay);
736        let dynamic = OdeIntegrator::shared(shared, OdeSolver::rkf45(1, 1e-6, 1e-4));
737        assert!(dynamic.typed_system().is_none());
738        assert!(dynamic.shared_system().is_some());
739    }
740
741    /// Methodology: `NoTypedSystem` is the default type argument and must be a
742    /// harmless zero-equation system rather than a panicking stub. Integrate it
743    /// directly and require no panic and an untouched (empty) state.
744    ///
745    /// Result (2026-08-05): integration from `x = 0` to `x = 1` completed with
746    /// `n_eqns() = 0` and the state vector still empty (`len = 0`).
747    #[test]
748    fn placeholder_system_integrates_as_a_no_op() {
749        let mut integrator = OdeIntegrator::typed(NoTypedSystem, OdeSolver::euler(0, 1e-6, 1e-4));
750        let mut y: Vec<f64> = Vec::new();
751        let mut dx = 0.5;
752        integrator.integrate(0.0, 1.0, &mut y, &mut dx).unwrap();
753        assert_eq!(integrator.n_eqns(), 0);
754        assert_eq!(y.len(), 0);
755    }
756}