Skip to main content

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}