Skip to main content

numra_ode/
problem.rs

1//! ODE problem definition.
2//!
3//! This module defines the traits and types for representing ODE initial value problems.
4//! Also supports DAEs (Differential-Algebraic Equations) via mass matrices.
5//!
6//! Author: Moussa Leblouba
7//! Date: 4 February 2026
8//! Modified: 2 May 2026
9
10use numra_core::Scalar;
11
12/// A system of ordinary differential equations.
13///
14/// Represents: dy/dt = f(t, y)
15pub trait OdeSystem<S: Scalar> {
16    /// Dimension of the system.
17    fn dim(&self) -> usize;
18
19    /// Compute the right-hand side: dydt = f(t, y)
20    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]);
21
22    /// Optionally compute the Jacobian: `J = ∂f/∂y`, row-major
23    /// (`jac[i*n + j] = ∂f_i/∂y_j`, length `n²`).
24    ///
25    /// Default: forward finite differences. Step size is the textbook
26    /// precision-aware choice for forward FD,
27    /// `h = sqrt(S::EPSILON) * (1 + |y_j|)`. The
28    /// `sqrt(S::EPSILON)`-not-a-hardcoded-constant form keeps the FD
29    /// useful at every `Scalar` precision: `f64` lands at `≈1.49e-8`,
30    /// `f32` at `≈3.45e-4`. A hardcoded `1e-8` would fall below
31    /// `f32::EPSILON ≈ 1.19e-7` and quantise the perturbation to zero.
32    fn jacobian(&self, t: S, y: &[S], jac: &mut [S]) {
33        let n = self.dim();
34        let h_factor = S::EPSILON.sqrt();
35        let mut y_pert = y.to_vec();
36        let mut f0 = vec![S::ZERO; n];
37        let mut f1 = vec![S::ZERO; n];
38
39        self.rhs(t, y, &mut f0);
40
41        for j in 0..n {
42            let yj_save = y_pert[j];
43            let h = h_factor * (S::ONE + yj_save.abs());
44            y_pert[j] = yj_save + h;
45            self.rhs(t, &y_pert, &mut f1);
46            y_pert[j] = yj_save;
47
48            for i in 0..n {
49                jac[i * n + j] = (f1[i] - f0[i]) / h;
50            }
51        }
52    }
53
54    /// Is the system autonomous? (f does not depend on t explicitly)
55    fn is_autonomous(&self) -> bool {
56        false
57    }
58
59    /// Does this system have a mass matrix?
60    fn has_mass_matrix(&self) -> bool {
61        false
62    }
63
64    /// Get the mass matrix M for the DAE: M * y' = f(t, y)
65    ///
66    /// Default returns identity (standard ODE).
67    /// For DAEs, override to return the (possibly singular) mass matrix.
68    ///
69    /// The matrix is stored in row-major order: mass[i * n + j] = M[i, j]
70    fn mass_matrix(&self, mass: &mut [S]) {
71        let n = self.dim();
72        for i in 0..n {
73            for j in 0..n {
74                mass[i * n + j] = if i == j { S::ONE } else { S::ZERO };
75            }
76        }
77    }
78
79    /// Is the mass matrix singular? (i.e., is this a DAE?)
80    fn is_singular_mass(&self) -> bool {
81        false
82    }
83
84    /// Return indices of algebraic variables (where `M[i,i] = 0`).
85    ///
86    /// Default returns empty (all differential).
87    fn algebraic_indices(&self) -> Vec<usize> {
88        Vec::new()
89    }
90}
91
92/// ODE initial value problem.
93///
94/// Represents: dy/dt = f(t, y), y(t0) = y0, solve from t0 to tf
95pub struct OdeProblem<S: Scalar, F>
96where
97    F: Fn(S, &[S], &mut [S]),
98{
99    /// Right-hand side function
100    pub f: F,
101    /// Initial time
102    pub t0: S,
103    /// Final time
104    pub tf: S,
105    /// Initial state
106    pub y0: Vec<S>,
107}
108
109impl<S: Scalar, F> OdeProblem<S, F>
110where
111    F: Fn(S, &[S], &mut [S]),
112{
113    /// Create a new ODE problem.
114    pub fn new(f: F, t0: S, tf: S, y0: Vec<S>) -> Self {
115        Self { f, t0, tf, y0 }
116    }
117
118    /// Dimension of the system.
119    pub fn dim(&self) -> usize {
120        self.y0.len()
121    }
122
123    /// Time span.
124    pub fn tspan(&self) -> (S, S) {
125        (self.t0, self.tf)
126    }
127
128    /// Integration direction: 1 for forward, -1 for backward.
129    pub fn direction(&self) -> S {
130        if self.tf >= self.t0 {
131            S::ONE
132        } else {
133            -S::ONE
134        }
135    }
136}
137
138impl<S: Scalar, F> OdeSystem<S> for OdeProblem<S, F>
139where
140    F: Fn(S, &[S], &mut [S]),
141{
142    fn dim(&self) -> usize {
143        self.y0.len()
144    }
145
146    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
147        (self.f)(t, y, dydt);
148    }
149}
150
151/// DAE (Differential-Algebraic Equation) problem.
152///
153/// Represents: M * y' = f(t, y)
154///
155/// where M is a (possibly singular) mass matrix.
156/// - When M is nonsingular, this is equivalent to y' = M⁻¹ f(t, y)
157/// - When M is singular (index-1 DAE), some equations are algebraic constraints
158///
159/// # Example: Pendulum DAE
160///
161/// The pendulum in Cartesian coordinates:
162/// ```text
163/// x'' = -λx         (x-component of constraint force)
164/// y'' = -λy - g     (y-component + gravity)
165/// x² + y² = L²      (constraint: pendulum length)
166/// ```
167///
168/// Written as first-order DAE with y = [x, y, vx, vy, λ]:
169/// ```text
170/// x'  = vx           (differential)
171/// y'  = vy           (differential)
172/// vx' = -λx          (differential)
173/// vy' = -λy - g      (differential)
174/// 0   = x² + y² - L² (algebraic)
175/// ```
176pub struct DaeProblem<S: Scalar, F, M>
177where
178    F: Fn(S, &[S], &mut [S]),
179    M: Fn(&mut [S]),
180{
181    /// Right-hand side function
182    pub f: F,
183    /// Mass matrix function
184    pub mass: M,
185    /// Initial time
186    pub t0: S,
187    /// Final time
188    pub tf: S,
189    /// Initial state (must be consistent!)
190    pub y0: Vec<S>,
191    /// Indices of algebraic variables (where `M[i,i] = 0`)
192    pub alg_indices: Vec<usize>,
193}
194
195impl<S: Scalar, F, M> DaeProblem<S, F, M>
196where
197    F: Fn(S, &[S], &mut [S]),
198    M: Fn(&mut [S]),
199{
200    /// Create a new DAE problem.
201    ///
202    /// # Arguments
203    /// * `f` - RHS function
204    /// * `mass` - Mass matrix function (fills row-major array)
205    /// * `t0` - Initial time
206    /// * `tf` - Final time
207    /// * `y0` - Initial state (must satisfy algebraic constraints!)
208    /// * `alg_indices` - Indices of algebraic equations
209    pub fn new(f: F, mass: M, t0: S, tf: S, y0: Vec<S>, alg_indices: Vec<usize>) -> Self {
210        Self {
211            f,
212            mass,
213            t0,
214            tf,
215            y0,
216            alg_indices,
217        }
218    }
219
220    /// Dimension of the system.
221    pub fn dim(&self) -> usize {
222        self.y0.len()
223    }
224}
225
226impl<S: Scalar, F, M> OdeSystem<S> for DaeProblem<S, F, M>
227where
228    F: Fn(S, &[S], &mut [S]),
229    M: Fn(&mut [S]),
230{
231    fn dim(&self) -> usize {
232        self.y0.len()
233    }
234
235    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
236        (self.f)(t, y, dydt);
237    }
238
239    fn has_mass_matrix(&self) -> bool {
240        true
241    }
242
243    fn mass_matrix(&self, mass: &mut [S]) {
244        (self.mass)(mass);
245    }
246
247    fn is_singular_mass(&self) -> bool {
248        !self.alg_indices.is_empty()
249    }
250
251    fn algebraic_indices(&self) -> Vec<usize> {
252        self.alg_indices.clone()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_ode_problem_creation() {
262        let f = |_t: f64, y: &[f64], dydt: &mut [f64]| {
263            dydt[0] = -y[0];
264        };
265        let problem = OdeProblem::new(f, 0.0, 1.0, vec![1.0]);
266        assert_eq!(problem.dim(), 1);
267        assert_eq!(problem.tspan(), (0.0, 1.0));
268    }
269
270    #[test]
271    fn test_rhs_evaluation() {
272        let f = |_t: f64, y: &[f64], dydt: &mut [f64]| {
273            dydt[0] = -2.0 * y[0];
274        };
275        let problem = OdeProblem::new(f, 0.0, 1.0, vec![1.0]);
276
277        let mut dydt = vec![0.0];
278        problem.rhs(0.0, &[1.0], &mut dydt);
279        assert!((dydt[0] + 2.0).abs() < 1e-10);
280    }
281
282    #[test]
283    fn test_jacobian_finite_diff() {
284        // dy/dt = -2*y, so J = -2
285        let f = |_t: f64, y: &[f64], dydt: &mut [f64]| {
286            dydt[0] = -2.0 * y[0];
287        };
288        let problem = OdeProblem::new(f, 0.0, 1.0, vec![1.0]);
289
290        let mut jac = vec![0.0];
291        problem.jacobian(0.0, &[1.0], &mut jac);
292        assert!((jac[0] + 2.0).abs() < 1e-5);
293    }
294
295    /// Pin the f32 viability of the FD-default Jacobian. The previous
296    /// hardcoded `1e-8` step was below `f32::EPSILON ≈ 1.19e-7`, so the
297    /// perturbation quantised to zero and the FD column came back as
298    /// `0.0` instead of the true `-2.0`. F-FD-STEP switched to the
299    /// precision-aware `sqrt(S::EPSILON)`, which scales correctly across
300    /// every `Scalar` impl. This test guards against a regression to the
301    /// old constant.
302    #[test]
303    fn test_jacobian_finite_diff_f32() {
304        let f = |_t: f32, y: &[f32], dydt: &mut [f32]| {
305            dydt[0] = -2.0 * y[0];
306        };
307        let problem = OdeProblem::new(f, 0.0_f32, 1.0_f32, vec![1.0_f32]);
308
309        let mut jac = vec![0.0_f32];
310        problem.jacobian(0.0, &[1.0], &mut jac);
311        // FD column is non-zero (the silent-quantisation failure mode is
312        // exactly `jac[0] == 0.0`) and within FD truncation of the true
313        // derivative.
314        assert!(jac[0] != 0.0, "FD step quantised to zero on f32");
315        assert!(
316            (jac[0] + 2.0).abs() < 1e-3,
317            "FD Jacobian on f32 too inaccurate: jac[0]={}",
318            jac[0]
319        );
320    }
321}