Skip to main content

numra_pde/
mol.rs

1//! Method of Lines (MOL) for converting PDEs to ODE systems.
2//!
3//! The Method of Lines discretizes spatial derivatives while leaving
4//! time continuous, converting a PDE to a system of ODEs.
5//!
6//! Author: Moussa Leblouba
7//! Date: 4 February 2026
8//! Modified: 2 May 2026
9
10use crate::boundary::BoundaryCondition;
11use crate::discretize::FDM;
12use crate::grid::Grid1D;
13use numra_core::Scalar;
14use numra_ode::OdeSystem;
15
16/// Trait for 1D PDE systems that can be solved via MOL.
17///
18/// The PDE has the form:
19/// ```text
20/// ∂u/∂t = F(t, x, u, ∂u/∂x, ∂²u/∂x², ...)
21/// ```
22pub trait PdeSystem<S: Scalar> {
23    /// Compute the RHS of the PDE at a single spatial point.
24    ///
25    /// # Arguments
26    /// * `t` - Current time
27    /// * `x` - Spatial coordinate
28    /// * `u` - Solution value at this point
29    /// * `du_dx` - First spatial derivative
30    /// * `d2u_dx2` - Second spatial derivative
31    fn rhs(&self, t: S, x: S, u: S, du_dx: S, d2u_dx2: S) -> S;
32
33    /// Number of solution components (for systems).
34    fn n_components(&self) -> usize {
35        1
36    }
37}
38
39/// Method of Lines system that wraps a PDE for use with ODE solvers.
40///
41/// This converts a PDE to an ODE system by:
42/// 1. Storing solution values at interior grid points
43/// 2. Computing spatial derivatives using FDM
44/// 3. Applying boundary conditions
45#[derive(Clone)]
46pub struct MOLSystem<
47    S: Scalar,
48    P: PdeSystem<S>,
49    BcL: BoundaryCondition<S>,
50    BcR: BoundaryCondition<S>,
51> {
52    /// The PDE system
53    pde: P,
54    /// Spatial grid
55    grid: Grid1D<S>,
56    /// Left boundary condition
57    bc_left: BcL,
58    /// Right boundary condition
59    bc_right: BcR,
60}
61
62impl<S: Scalar, P: PdeSystem<S>, BcL: BoundaryCondition<S>, BcR: BoundaryCondition<S>>
63    MOLSystem<S, P, BcL, BcR>
64{
65    /// Create a new MOL system.
66    pub fn new(pde: P, grid: Grid1D<S>, bc_left: BcL, bc_right: BcR) -> Self {
67        Self {
68            pde,
69            grid,
70            bc_left,
71            bc_right,
72        }
73    }
74
75    /// Get the spatial grid.
76    pub fn grid(&self) -> &Grid1D<S> {
77        &self.grid
78    }
79
80    /// Number of interior points (ODE dimension).
81    pub fn n_interior(&self) -> usize {
82        self.grid.n_interior()
83    }
84
85    /// Build full solution array including boundaries.
86    ///
87    /// Takes interior values and adds boundary values.
88    pub fn build_full_solution(&self, t: S, u_interior: &[S]) -> Vec<S> {
89        let n = self.grid.len();
90        let mut u_full = vec![S::ZERO; n];
91
92        // Left boundary
93        if self.bc_left.is_dirichlet() {
94            u_full[0] = self.bc_left.value(t).unwrap();
95        } else {
96            // Ghost point approach for Neumann
97            let dx = self.grid.dx_uniform();
98            u_full[0] = self.bc_left.apply(t, u_interior[0], dx);
99        }
100
101        // Interior points
102        for (i, &val) in u_interior.iter().enumerate() {
103            u_full[i + 1] = val;
104        }
105
106        // Right boundary
107        if self.bc_right.is_dirichlet() {
108            u_full[n - 1] = self.bc_right.value(t).unwrap();
109        } else {
110            let dx = self.grid.dx_uniform();
111            u_full[n - 1] = self.bc_right.apply(t, u_interior[u_interior.len() - 1], dx);
112        }
113
114        u_full
115    }
116}
117
118impl<S: Scalar, P: PdeSystem<S>, BcL: BoundaryCondition<S>, BcR: BoundaryCondition<S>> OdeSystem<S>
119    for MOLSystem<S, P, BcL, BcR>
120{
121    fn dim(&self) -> usize {
122        self.n_interior() * self.pde.n_components()
123    }
124
125    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
126        let n_interior = self.n_interior();
127        let _n_full = self.grid.len();
128        let dx = self.grid.dx_uniform();
129
130        // Build full solution with boundary values
131        let u_full = self.build_full_solution(t, y);
132        let points = self.grid.points();
133
134        // Compute derivatives at each interior point
135        for i in 0..n_interior {
136            let idx_full = i + 1; // Index in full array
137
138            let x = points[idx_full];
139            let u = u_full[idx_full];
140
141            // Compute spatial derivatives
142            let du_dx = FDM::d1_central(&u_full, dx, idx_full);
143            let d2u_dx2 = FDM::d2_central(&u_full, dx, idx_full);
144
145            // Evaluate PDE RHS
146            dydt[i] = self.pde.rhs(t, x, u, du_dx, d2u_dx2);
147        }
148    }
149}
150
151/// Simple heat equation PDE: ∂u/∂t = α ∂²u/∂x².
152///
153/// Infrastructure for MOL-based heat equation solvers.
154#[allow(dead_code)]
155#[derive(Clone, Debug)]
156pub struct HeatEquationPde<S: Scalar> {
157    /// Thermal diffusivity
158    pub alpha: S,
159}
160
161#[allow(dead_code)]
162impl<S: Scalar> HeatEquationPde<S> {
163    pub fn new(alpha: S) -> Self {
164        Self { alpha }
165    }
166}
167
168impl<S: Scalar> PdeSystem<S> for HeatEquationPde<S> {
169    fn rhs(&self, _t: S, _x: S, _u: S, _du_dx: S, d2u_dx2: S) -> S {
170        self.alpha * d2u_dx2
171    }
172}
173
174/// Diffusion-reaction PDE: ∂u/∂t = D ∂²u/∂x² + R(u).
175///
176/// Infrastructure for MOL-based diffusion-reaction solvers.
177#[allow(dead_code)]
178#[derive(Clone)]
179pub struct DiffusionReactionPde<S: Scalar, F>
180where
181    F: Fn(S) -> S + Clone,
182{
183    /// Diffusion coefficient
184    pub diffusion: S,
185    /// Reaction term R(u)
186    pub reaction: F,
187}
188
189#[allow(dead_code)]
190impl<S: Scalar, F: Fn(S) -> S + Clone> DiffusionReactionPde<S, F> {
191    pub fn new(diffusion: S, reaction: F) -> Self {
192        Self {
193            diffusion,
194            reaction,
195        }
196    }
197}
198
199impl<S: Scalar, F: Fn(S) -> S + Clone> PdeSystem<S> for DiffusionReactionPde<S, F> {
200    fn rhs(&self, _t: S, _x: S, u: S, _du_dx: S, d2u_dx2: S) -> S {
201        self.diffusion * d2u_dx2 + (self.reaction)(u)
202    }
203}
204
205/// Advection-diffusion PDE: ∂u/∂t = D ∂²u/∂x² - v ∂u/∂x.
206///
207/// Infrastructure for MOL-based advection-diffusion solvers.
208#[allow(dead_code)]
209#[derive(Clone, Debug)]
210pub struct AdvectionDiffusionPde<S: Scalar> {
211    /// Diffusion coefficient
212    pub diffusion: S,
213    /// Advection velocity
214    pub velocity: S,
215}
216
217#[allow(dead_code)]
218impl<S: Scalar> AdvectionDiffusionPde<S> {
219    pub fn new(diffusion: S, velocity: S) -> Self {
220        Self {
221            diffusion,
222            velocity,
223        }
224    }
225}
226
227impl<S: Scalar> PdeSystem<S> for AdvectionDiffusionPde<S> {
228    fn rhs(&self, _t: S, _x: S, _u: S, du_dx: S, d2u_dx2: S) -> S {
229        self.diffusion * d2u_dx2 - self.velocity * du_dx
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::boundary::DirichletBC;
237    use numra_ode::{DoPri5, Solver, SolverOptions};
238
239    #[test]
240    fn test_mol_system_dim() {
241        let grid = Grid1D::uniform(0.0, 1.0, 11);
242        let pde = HeatEquationPde::new(0.01_f64);
243        let bc_left = DirichletBC::new(1.0);
244        let bc_right = DirichletBC::new(0.0);
245
246        let mol = MOLSystem::new(pde, grid, bc_left, bc_right);
247        assert_eq!(mol.dim(), 9); // 11 - 2 boundary points
248    }
249
250    #[test]
251    fn test_heat_equation_dirichlet() {
252        // Heat equation with T(0) = 1, T(1) = 0
253        // Initial: T(x,0) = 1 - x (linear)
254        // Should diffuse toward steady state (linear)
255
256        let grid = Grid1D::uniform(0.0, 1.0, 21);
257        let pde = HeatEquationPde::new(0.01_f64);
258        let bc_left = DirichletBC::new(1.0);
259        let bc_right = DirichletBC::new(0.0);
260
261        let mol = MOLSystem::new(pde, grid.clone(), bc_left, bc_right);
262
263        // Initial condition: T = 1 - x (interior points only)
264        let u0: Vec<f64> = grid.interior_points().iter().map(|&x| 1.0 - x).collect();
265
266        let options = SolverOptions::default().rtol(1e-6).atol(1e-9);
267        let result = DoPri5::solve(&mol, 0.0, 1.0, &u0, &options).unwrap();
268
269        // After diffusion, should still be approximately linear
270        let y_final = result.y_final().unwrap();
271        for (i, &y) in y_final.iter().enumerate() {
272            let x = grid.interior_points()[i];
273            let expected = 1.0 - x;
274            assert!(
275                (y - expected).abs() < 0.1,
276                "At x={}: y={}, expected={}",
277                x,
278                y,
279                expected
280            );
281        }
282    }
283
284    #[test]
285    fn test_diffusion_reaction() {
286        // Fisher equation: ∂u/∂t = D ∂²u/∂x² + u(1-u)
287        let grid = Grid1D::uniform(0.0, 1.0, 21);
288        let pde = DiffusionReactionPde::new(0.01_f64, |u: f64| u * (1.0 - u));
289        let bc_left = DirichletBC::new(1.0);
290        let bc_right = DirichletBC::new(0.0);
291
292        let mol = MOLSystem::new(pde, grid.clone(), bc_left, bc_right);
293
294        let u0: Vec<f64> = grid
295            .interior_points()
296            .iter()
297            .map(|&x| if x < 0.3 { 1.0 } else { 0.0 })
298            .collect();
299
300        let options = SolverOptions::default().rtol(1e-6);
301        let result = DoPri5::solve(&mol, 0.0, 0.5, &u0, &options).unwrap();
302
303        assert!(result.success);
304        // Just check it ran successfully
305        let y_final = result.y_final().unwrap();
306        for &y in &y_final {
307            assert!(
308                (-0.1..=1.1).contains(&y),
309                "Solution out of expected range: {}",
310                y
311            );
312        }
313    }
314}