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}