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}