mini_ode/solvers/mod.rs
1use anyhow::anyhow;
2use std::fmt;
3use std::sync::Arc;
4use tch::Tensor;
5
6use crate::utils::validation;
7
8use crate::optimizers;
9
10mod explicit;
11use explicit::solve_euler;
12use explicit::solve_rk4;
13use explicit::solve_rkf45;
14mod implicit;
15use implicit::solve_glrk4;
16use implicit::solve_implicit_euler;
17use implicit::solve_row1;
18
19/// Main enumeration of all supported ODE integration methods.
20///
21/// Each variant represents a different numerical algorithm with specific configuration parameters.
22/// Choose based on problem characteristics:
23///
24/// - **Non-stiff problems**: [`Euler`](Self::Euler) for simplicity, [`RK4`](Self::RK4) for accuracy
25/// - **Stiff problems**: [`ImplicitEuler`](Self::ImplicitEuler) or [`GLRK4`](Self::GLRK4)
26/// - **Unknown step-size requirements**: [`RKF45`](Self::RKF45) with automatic error control
27pub enum Solver {
28 /// Forward Euler method (first-order explicit).
29 ///
30 /// The simplest ODE integrator: `y_{n+1} = y_n + h * f(x_n, y_n)`
31 ///
32 /// **Characteristics:**
33 /// - Order: 1 (global error ~O(h), local truncation error ~O(h²))
34 /// - Radius of absolute stability on the negative real axis: 2
35 /// - Cost: One RHS evaluation per step
36 ///
37 /// **Best for:** Simple non-stiff systems where low accuracy is acceptable.
38 Euler {
39 /// Fixed step size for integration.
40 ///
41 /// Must be a finite positive value. Smaller steps improve accuracy but increase
42 /// computation time linearly.
43 step: f64,
44 },
45
46 /// Fourth-order Runge-Kutta method (RK4).
47 ///
48 /// The workhorse of ODE integration: uses four stage evaluations per step to achieve
49 /// fourth-order accuracy. Formula involves weighted average of slopes at intermediate points.
50 ///
51 /// **Characteristics:**
52 /// - Order: 4 (global error ~O(h⁴), local truncation error ~O(h⁵))
53 /// - Radius of absolute stability on the negative real axis: approximately 2.785
54 /// - Cost: Four RHS evaluations per step
55 ///
56 /// Halving the step size reduces the asymptotic global discretization error by
57 /// approximately 16×.
58 ///
59 /// **Best for:** General-purpose integration of non-stiff systems where a good
60 /// balance between accuracy and computational cost is required.
61 RK4 {
62 /// Fixed step size for integration.
63 ///
64 /// Must be a finite positive value. Fourth-order convergence means that,
65 /// asymptotically, halving the step reduces the global discretization error
66 /// by approximately 16×.
67 step: f64,
68 },
69
70 /// Implicit (backward) Euler method (first-order).
71 ///
72 /// Defines the next state implicitly: `y_{n+1} = y_n + h * f(x_{n+1}, y_{n+1})`
73 ///
74 /// Each step requires solving a nonlinear system. This implementation uses the
75 /// configured optimizer/nonlinear solver to perform this solve.
76 ///
77 /// **Characteristics:**
78 /// - Order: 1 (global error ~O(h), local truncation error ~O(h²))
79 /// - Stability: A-stable and L-stable
80 /// - Cost: Nonlinear solve iterations plus RHS evaluations per step
81 ///
82 /// **Best for:** Stiff problems where stability is more important than high-order
83 /// accuracy. Much slower per step than explicit methods but allows larger steps.
84 ImplicitEuler {
85 /// Fixed step size for integration.
86 step: f64,
87 /// Optimizer used to solve the implicit equation at each step.
88 ///
89 /// Common choices:
90 /// - [`optimizers::Newton`] for well-conditioned problems
91 /// - [`optimizers::CG`] for memory efficiency
92 optimizer: Arc<dyn optimizers::Optimizer>,
93 },
94
95 /// Fourth-order Gauss-Legendre Runge-Kutta method (collocation).
96 ///
97 /// An implicit Runge-Kutta method based on Gaussian quadrature collocation points.
98 /// Offers excellent stability and accuracy properties for stiff systems.
99 ///
100 /// **Characteristics:**
101 /// - Order: 4 (global error ~O(h⁴), local truncation error ~O(h⁵))
102 /// - Stability: A-stable
103 /// - Cost: Nonlinear solves involving multiple implicit stages
104 /// - Additional property: Symplectic for Hamiltonian systems
105 ///
106 /// **Best for:** High-accuracy integration of stiff systems where preservation of
107 /// qualitative structure is important.
108 GLRK4 {
109 /// Fixed step size for integration.
110 step: f64,
111 /// Optimizer used to solve the implicit system at each step.
112 ///
113 /// Common choices:
114 /// - [`optimizers::Newton`] for well-conditioned problems
115 /// - [`optimizers::CG`] for memory efficiency
116 optimizer: Arc<dyn optimizers::Optimizer>,
117 },
118
119 /// Runge-Kutta-Fehlberg method with adaptive step control (4th/5th order).
120 ///
121 /// Embedded Runge-Kutta pair that computes fourth- and fifth-order solutions
122 /// simultaneously. The fifth-order solution is accepted, while the difference
123 /// between the fourth- and fifth-order approximations provides an estimate of
124 /// the local truncation error, which is used to adapt the step size automatically.
125 ///
126 /// **Characteristics:**
127 /// - Orders: 4th-order error estimate and 5th-order solution
128 /// - Cost: Six RHS evaluations per attempted step
129 /// - Adaptive step control based on user-provided tolerances
130 ///
131 /// **Best for:** Problems where the appropriate step size is unknown or the
132 /// solution smoothness varies throughout the integration interval.
133 ///
134 /// **Note:** Explicit adaptive methods do not remove stiffness limitations;
135 /// stiff problems may still require extremely small step sizes.
136 RKF45 {
137 /// Relative error tolerance for step adaptation.
138 ///
139 /// Step size adjusted so that estimated error satisfies:
140 /// `|error| <= max(rtol * |y|, atol)`
141 rtol: f64,
142 /// Absolute error tolerance for step adaptation.
143 ///
144 /// Controls accuracy when state values are close to zero.
145 atol: f64,
146 /// Minimum allowable step size.
147 ///
148 /// Prevents step size from becoming too small (infinite loop protection).
149 min_step: f64,
150 /// Safety factor for step size adjustments.
151 ///
152 /// Typically 0.8-0.9; conservative factors reduce step rejections.
153 safety_factor: f64,
154 },
155
156 /// Rosenbrock-Wanner method (linearly implicit).
157 ///
158 /// A semi-implicit method that uses Jacobian information to transform the nonlinear
159 /// implicit solve into a single linear solve.
160 ///
161 /// This implementation uses `γ = 1`, i.e. it solves `(I - hJ) k = f(x, y)`, which
162 /// gives the same stability function as implicit Euler, `R(z) = 1/(1 - z)`. As a
163 /// result it is L-stable. The difference from implicit Euler is nonlinear behavior
164 /// and computational cost (a single linear solve instead of a nonlinear iteration),
165 /// not the linear stability region.
166 ///
167 /// **Characteristics:**
168 /// - Order: 1 (global error ~O(h), local truncation error ~O(h²))
169 /// - Stability: L-stable (same stability function as implicit Euler)
170 /// - Cost: One Jacobian evaluation and one linear solve per step
171 /// - Avoids the nonlinear iterations required by fully implicit methods
172 ///
173 /// **Best for:** Moderately stiff problems where explicit methods require
174 /// prohibitively small steps and full nonlinear implicit methods are too expensive.
175 ROW1 {
176 /// Fixed step size for integration.
177 step: f64,
178 },
179}
180
181impl Solver {
182 /// Solve the ODE initial value problem `dy/dx = f(x, y)` over the specified interval.
183 ///
184 /// This method performs comprehensive input validation before integration begins.
185 /// If validation passes, the appropriate solver algorithm is dispatched based on
186 /// the `Solver` variant.
187 ///
188 /// # Arguments
189 ///
190 /// * `f` - TorchScript module implementing the derivative function `f(x, y) -> dy/dx`.
191 /// Must accept scalar `x` and 1D tensor `y`, return 1D tensor of same dimension.
192 /// * `x_span` - Integration interval as `(start, end)` tuple. Both values must be finite
193 /// with `start <= end`.
194 /// * `y0` - Initial state as 1D tensor of shape `(n,)` where `n` is system dimension.
195 ///
196 /// # Returns
197 ///
198 /// On success, returns `(xs, ys)` where:
199 /// - `xs`: 1D tensor of integration points (shape `(num_points,)`)
200 /// - `ys`: 2D tensor of states at each point (shape `(num_points, n)`)
201 ///
202 /// # Errors
203 ///
204 /// Returns an error if:
205 /// - `x_span` contains non-finite values or `start > end`
206 /// - Step size/tolerance parameters are non-finite or non-positive
207 /// - `y0` is not 1D, contains non-finite values, or has unsupported dtype
208 /// - Derivative function `f` output dimension mismatches `y0`
209 /// - Device/dtype mismatch between `y0` and `f` output
210 /// - Integration fails numerically (NaN/Inf produced)
211 ///
212 /// # Example
213 ///
214 /// ```rust
215 /// use mini_ode::Solver;
216 /// use tch::{Tensor, CModule};
217 ///
218 ///# let y0 = Tensor::from_slice(&[1f64, 0f64]);
219 ///# let mut closure = |inputs: &[Tensor]| {
220 ///# let _x = &inputs[0];
221 ///# let y = &inputs[1];
222 ///# let y0 = y.get(0);
223 ///# let y1 = y.get(1);
224 ///#
225 ///# let dy0 = y1;
226 ///# let dy1 = &y0 - &y0.pow_tensor_scalar(3.0);
227 ///#
228 ///# vec![Tensor::stack(&[dy0, dy1], 0)]
229 ///# };
230 ///#
231 ///# // Trace the function
232 ///# let model = CModule::create_by_tracing(
233 ///# "ode_fn",
234 ///# "forward",
235 ///# &[Tensor::from(0.0f64), y0.shallow_clone()],
236 ///# &mut closure,
237 ///# )?;
238 /// let solver = Solver::RK4 { step: 0.01 };
239 /// let x_span = (0.0, 10.0);
240 /// let y0 = Tensor::from_slice(&[1.0, 0.0]);
241 ///
242 /// let (xs, ys) = solver.solve(model, x_span, y0)?;
243 /// # Ok::<(), Box<dyn std::error::Error>>(())
244 /// ```
245 pub fn solve(
246 &self,
247 f: tch::CModule,
248 x_span: (f64, f64),
249 y0: Tensor,
250 ) -> anyhow::Result<(Tensor, Tensor)> {
251 let kind = y0.kind();
252 let device = y0.device();
253
254 // Validate x_span
255 if !x_span.0.is_finite() || !x_span.1.is_finite() {
256 return Err(anyhow!("x_span must consist of finite values"));
257 }
258 if x_span.0 > x_span.1 {
259 return Err(anyhow!("x_span is not a valid interval"));
260 }
261
262 // Validate solver parameters
263 match self {
264 Self::Euler { step }
265 | Self::RK4 { step }
266 | Self::ImplicitEuler { step, .. }
267 | Self::GLRK4 { step, .. }
268 | Self::ROW1 { step } => {
269 if !step.is_finite() || *step <= 0.0 {
270 return Err(anyhow!(
271 "Step size must be a finite positive value, got {}",
272 step
273 ));
274 }
275 }
276
277 Self::RKF45 {
278 rtol,
279 atol,
280 min_step,
281 safety_factor,
282 } => {
283 if !rtol.is_finite() || *rtol <= 0.0 {
284 return Err(anyhow!(
285 "rtol must be a finite positive value, got {}",
286 rtol
287 ));
288 }
289
290 if !atol.is_finite() || *atol <= 0.0 {
291 return Err(anyhow!(
292 "atol must be a finite positive value, got {}",
293 atol
294 ));
295 }
296
297 if !min_step.is_finite() || *min_step <= 0.0 {
298 return Err(anyhow!(
299 "min_step must be a finite positive value, got {}",
300 min_step
301 ));
302 }
303
304 if !safety_factor.is_finite() || *safety_factor <= 0.0 {
305 return Err(anyhow!(
306 "safety_factor must be a finite positive value, got {}",
307 safety_factor
308 ));
309 }
310 }
311 }
312
313 // Validate y0 - check it's finite
314 validation::validate_finite_tensor(&y0, "initial state y0")?;
315
316 let y0_size = y0.size();
317
318 if y0_size.len() != 1 {
319 return Err(anyhow!(
320 "y0 must be a one-dimensional tensor but it has {} dimensions",
321 y0_size.len()
322 ));
323 }
324
325 if kind != tch::Kind::Double
326 && kind != tch::Kind::Float
327 && kind != tch::Kind::BFloat16
328 && kind != tch::Kind::Half
329 {
330 return Err(anyhow!("y0 is of unsupported kind {:?}", y0.kind()));
331 }
332
333 // Validate function f
334 let dy = f.forward_ts(&[
335 Tensor::from(x_span.0).to_kind(kind).to_device(device),
336 y0.copy(),
337 ])?;
338
339 let dy_size = dy.size();
340
341 if dy_size.len() != 1 {
342 return Err(anyhow!(
343 "Function `f` returns tensor of rank {}, expected one-dimensional tensor",
344 dy_size.len()
345 ));
346 }
347
348 if dy_size[0] != y0_size[0] {
349 return Err(anyhow!(
350 "Function `f` returns vector of length {}, expected vector of length {} (same as y0)",
351 dy_size[0],
352 y0_size[0]
353 ));
354 }
355
356 if dy.device() != device {
357 return Err(anyhow!(
358 "Function `f` returns tensor on device {:?}, expected tensor to be on device {:?} (same as y0)",
359 dy.device(),
360 device
361 ));
362 }
363
364 if dy.kind() != kind {
365 return Err(anyhow!(
366 "Function `f` returns tensor of kind {:?}, expected tensor to be of kind {:?} (same as y0)",
367 dy.kind(),
368 kind
369 ));
370 }
371
372 // Validate derivative output is finite
373 validation::validate_finite_tensor(&dy, "derivative function output at initial point")?;
374
375 match self {
376 Self::Euler { step } => solve_euler(f, x_span, y0, *step),
377
378 Self::RK4 { step } => solve_rk4(f, x_span, y0, *step),
379
380 Self::ImplicitEuler { step, optimizer } => {
381 solve_implicit_euler(f, x_span, y0, *step, optimizer.as_ref())
382 }
383
384 Self::GLRK4 { step, optimizer } => {
385 solve_glrk4(f, x_span, y0, *step, optimizer.as_ref())
386 }
387
388 Self::RKF45 {
389 rtol,
390 atol,
391 min_step,
392 safety_factor,
393 } => solve_rkf45(f, x_span, y0, *rtol, *atol, *min_step, *safety_factor),
394
395 Self::ROW1 { step } => solve_row1(f, x_span, y0, *step),
396 }
397 }
398
399 /// Compute the stability function (amplification factor) for this solver.
400 ///
401 /// The stability function `R(z)` describes how errors propagate for the test equation
402 /// `y' = λy` where `z = hλ`. A solver is absolutely stable when `|R(z)| ≤ 1`.
403 ///
404 /// # Arguments
405 ///
406 /// * `x` - The stability variable `z = hλ` (must be non-positive for meaningful results).
407 ///
408 /// # Returns
409 ///
410 /// The stability function value `R(x)`.
411 ///
412 /// # Errors
413 ///
414 /// Returns an error if `x > 0` because stability functions are conventionally analyzed
415 /// for `x ≤ 0` (left half-plane).
416 ///
417 /// # Mathematical Background
418 ///
419 /// For each solver:
420 /// - **Euler**: `R(z) = 1 + z`
421 /// - **RK4**: `R(z) = 1 + z + z²/2 + z³/6 + z⁴/24` (Taylor polynomial)
422 /// - **Implicit Euler**: `R(z) = 1/(1 - z)` (A-stable)
423 /// - **GLRK4**: `R(z) = (1 + z/2 + z²/12)/(1 - z/2 + z²/12)` (A-stable rational)
424 /// - **RKF45**: Stability polynomial determined by the Fehlberg tableau
425 /// - **ROW1**: `R(z) = 1/(1 - z)` (same as implicit Euler)
426 ///
427 /// # Example
428 ///
429 /// ```rust
430 /// use mini_ode::Solver;
431 ///
432 /// let solver = Solver::Euler { step: 0.1 };
433 /// let stability = solver.stability_function(-0.5)?;
434 /// assert!((stability - 0.5).abs() < 1e-10); // R(-0.5) = 1 - 0.5 = 0.5
435 /// # Ok::<(), Box<dyn std::error::Error>>(())
436 /// ```
437 pub fn stability_function(&self, x: f64) -> anyhow::Result<f64> {
438 if x > 0. {
439 anyhow::bail!("Stability function is not defined for positive numbers.");
440 }
441
442 Ok(match self {
443 Self::Euler { .. } => 1. + x,
444 Self::RK4 { .. } => 1. + (1. + (1. / 2. + (1. / 6. + (1. / 24.) * x) * x) * x) * x,
445 Self::ImplicitEuler { .. } => 1. / (1. - x),
446 Self::GLRK4 { .. } => (1. + x / 2. + x * x / 12.) / (1. - x / 2. + x * x / 12.),
447 Self::RKF45 { .. } => {
448 1. + (1.
449 + (1. / 2.
450 + (1. / 6. + (1. / 24. + (1. / 120. + (1. / 2080.) * x) * x) * x) * x)
451 * x)
452 * x
453 }
454 Self::ROW1 { .. } => 1. / (1. - x),
455 })
456 }
457
458 /// Return the radius of absolute stability for this solver.
459 ///
460 /// This is the extent of the stability region along the negative real axis.
461 /// Explicit methods have a finite radius, while A-stable methods have an
462 /// unbounded stability region.
463 ///
464 /// # Values by Solver
465 ///
466 /// | Solver | Radius of absolute stability | Type |
467 /// |--------|------------------------------|------|
468 /// | Euler | 2.0 | Explicit |
469 /// | RK4 | ~2.785 | Explicit |
470 /// | Implicit Euler | ∞ | A-stable/L-stable |
471 /// | GLRK4 | ∞ | A-stable |
472 /// | RKF45 | ~3.678 (for this Fehlberg tableau) | Explicit (adaptive) |
473 /// | ROW1 | ∞ | L-stable (γ = 1, same as implicit Euler) |
474 ///
475 /// # Practical Meaning
476 ///
477 /// A larger radius allows larger step sizes for stable integration. For stiff
478 /// systems where eigenvalues have large negative real parts, only A-stable
479 /// methods (unbounded radius) remain stable regardless of step size.
480 pub fn stability_radius(&self) -> f64 {
481 match self {
482 Self::Euler { .. } => 2f64,
483 Self::RK4 { .. } => 2.785293563f64,
484 Self::ImplicitEuler { .. } => f64::INFINITY,
485 Self::GLRK4 { .. } => f64::INFINITY,
486 Self::RKF45 { .. } => 3.677706621f64,
487 Self::ROW1 { .. } => f64::INFINITY,
488 }
489 }
490}
491
492impl fmt::Display for Solver {
493 /// Format the solver with its configuration parameters.
494 ///
495 /// Useful for logging, debugging, and displaying solver choice to users.
496 /// Example output: `"RK4(step=0.01)"` or `"RKF45(rtol=1e-5, atol=1e-5, min_step=1e-9, safety_factor=0.9)"`
497 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
498 match self {
499 Solver::Euler { step } => write!(f, "Euler(step={})", step),
500 Solver::RK4 { step } => write!(f, "RK4(step={})", step),
501 Solver::ImplicitEuler { step, optimizer } => {
502 write!(f, "ImplicitEuler(step={}, optimizer={})", step, optimizer)
503 }
504 Solver::GLRK4 { step, optimizer } => {
505 write!(f, "GLRK4(step={}, optimizer={})", step, optimizer)
506 }
507 Solver::RKF45 {
508 rtol,
509 atol,
510 min_step,
511 safety_factor,
512 } => write!(
513 f,
514 "RKF45(rtol={}, atol={}, min_step={}, safety_factor={})",
515 rtol, atol, min_step, safety_factor
516 ),
517 Solver::ROW1 { step } => write!(f, "ROW1(step={})", step),
518 }
519 }
520}