Skip to main content

numra_pde/
mol2d.rs

1//! 2D Method of Lines for converting 2D PDEs to ODE systems.
2//!
3//! Uses sparse matrix assembly for the spatial operator, then
4//! the ODE RHS is simply a sparse matrix-vector product.
5//!
6//! Author: Moussa Leblouba
7//! Date: 9 February 2026
8//! Modified: 2 May 2026
9
10use crate::boundary2d::BoundaryConditions2D;
11use crate::grid::Grid2D;
12use crate::sparse_assembly::{
13    assemble_laplacian_2d, assemble_operator_2d, Operator2DCoefficients, SparseScalar,
14};
15use numra_linalg::SparseMatrix;
16use numra_ode::OdeSystem;
17
18/// Type alias for a reaction term closure: (t, x, y, u) -> f(u).
19type ReactionFn<S> = Box<dyn Fn(S, S, S, S) -> S + Send + Sync>;
20
21/// 2D Method of Lines system.
22///
23/// Converts a 2D PDE of the form `u_t = L[u] + R(t, x, y, u)`
24/// into an ODE system, where L is a linear spatial operator assembled
25/// as a sparse matrix and R is an optional nonlinear reaction term.
26pub struct MOLSystem2D<S: SparseScalar> {
27    /// Spatial grid
28    grid: Grid2D<S>,
29    /// Assembled sparse operator matrix (n_int x n_int)
30    operator: SparseMatrix<S>,
31    /// RHS contribution from boundary conditions
32    bc_rhs: Vec<S>,
33    /// Optional nonlinear reaction term R(t, x, y, u)
34    reaction: Option<ReactionFn<S>>,
35}
36
37impl<S: SparseScalar> MOLSystem2D<S> {
38    /// Create a 2D MOL system for the heat equation: u_t = alpha * laplacian(u).
39    pub fn heat(grid: Grid2D<S>, alpha: S, bc: &BoundaryConditions2D<S>) -> Self {
40        let coeffs = Operator2DCoefficients::scaled_laplacian(alpha);
41        let (operator, bc_rhs) =
42            assemble_operator_2d(&grid, &coeffs, bc).expect("Failed to assemble 2D operator");
43        Self {
44            grid,
45            operator,
46            bc_rhs,
47            reaction: None,
48        }
49    }
50
51    /// Create a 2D MOL system for the Laplacian: u_t = laplacian(u).
52    pub fn laplacian(grid: Grid2D<S>, bc: &BoundaryConditions2D<S>) -> Self {
53        let (operator, bc_rhs) =
54            assemble_laplacian_2d(&grid, bc).expect("Failed to assemble 2D Laplacian");
55        Self {
56            grid,
57            operator,
58            bc_rhs,
59            reaction: None,
60        }
61    }
62
63    /// Create a 2D MOL system with a general linear operator.
64    pub fn with_operator(
65        grid: Grid2D<S>,
66        coeffs: &Operator2DCoefficients<S>,
67        bc: &BoundaryConditions2D<S>,
68    ) -> Self {
69        let (operator, bc_rhs) =
70            assemble_operator_2d(&grid, coeffs, bc).expect("Failed to assemble 2D operator");
71        Self {
72            grid,
73            operator,
74            bc_rhs,
75            reaction: None,
76        }
77    }
78
79    /// Add a nonlinear reaction term R(t, x, y, u) to the system.
80    ///
81    /// The full PDE becomes: `u_t = L[u] + R(t, x, y, u)`.
82    pub fn with_reaction<F>(mut self, reaction: F) -> Self
83    where
84        F: Fn(S, S, S, S) -> S + Send + Sync + 'static,
85    {
86        self.reaction = Some(Box::new(reaction));
87        self
88    }
89
90    /// Get the spatial grid.
91    pub fn grid(&self) -> &Grid2D<S> {
92        &self.grid
93    }
94
95    /// Number of interior points (ODE dimension).
96    pub fn n_interior(&self) -> usize {
97        self.grid.n_interior()
98    }
99
100    /// Build the full solution array including boundaries.
101    ///
102    /// Interior values are stored in column-major order: u[jj * nx_int + ii].
103    /// The full array has nx*ny entries in the same column-major layout.
104    pub fn build_full_solution(&self, u_interior: &[S]) -> Vec<S> {
105        let nx = self.grid.nx();
106        let ny = self.grid.ny();
107        let nx_int = self.grid.nx_interior();
108
109        let mut u_full = vec![S::ZERO; nx * ny];
110
111        // Fill interior
112        for jj in 0..self.grid.ny_interior() {
113            for ii in 0..nx_int {
114                let full_idx = (jj + 1) * nx + (ii + 1);
115                let int_idx = jj * nx_int + ii;
116                u_full[full_idx] = u_interior[int_idx];
117            }
118        }
119
120        // Boundaries are zero (Dirichlet=0) or not needed for output
121        u_full
122    }
123}
124
125impl<S: SparseScalar> OdeSystem<S> for MOLSystem2D<S> {
126    fn dim(&self) -> usize {
127        self.n_interior()
128    }
129
130    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
131        // Sparse matvec: dydt = operator * y + bc_rhs
132        let matvec = self.operator.mul_vec(y).expect("Sparse matvec failed");
133
134        let n = self.n_interior();
135        for i in 0..n {
136            dydt[i] = matvec[i] + self.bc_rhs[i];
137        }
138
139        // Add reaction term if present
140        if let Some(ref reaction) = self.reaction {
141            let nx_int = self.grid.nx_interior();
142            for jj in 0..self.grid.ny_interior() {
143                for ii in 0..nx_int {
144                    let idx = jj * nx_int + ii;
145                    let x = self.grid.x_grid.points()[ii + 1];
146                    let y_coord = self.grid.y_grid.points()[jj + 1];
147                    dydt[idx] = dydt[idx] + reaction(t, x, y_coord, y[idx]);
148                }
149            }
150        }
151    }
152
153    /// Analytical Jacobian: copy the assembled sparse spatial operator
154    /// (which already equals `∂(L[u])/∂u` by construction) into the row-major
155    /// dense buffer the solver expects, then add the reaction term's
156    /// contribution to the diagonal.
157    ///
158    /// The reaction Jacobian is diagonal *because* the reaction is
159    /// pointwise: `R(t, x_i, y_j, u_i)` depends only on the local state
160    /// `u_i`, so `∂R_i/∂u_k` is identically zero for any `k != i`. Off-
161    /// diagonal entries cannot be populated by a pointwise reaction, no
162    /// matter what the closure contains. This is what makes the
163    /// FD-on-the-diagonal fallback affordable: one extra closure call per
164    /// interior point versus the `O(N)` rhs evaluations the trait-default
165    /// FD would need to populate the same entries through perturbation.
166    /// If a future reaction model couples grid points (nonlocal /
167    /// integro-PDE / multi-component), the trait default's full FD path is
168    /// the correct fallback — but that's out of scope for v1.
169    fn jacobian(&self, t: S, y: &[S], jac: &mut [S]) {
170        let n = self.n_interior();
171        let nn = n * n;
172
173        // Zero the dense buffer; the sparse operator only fills nonzero
174        // entries below.
175        for v in jac.iter_mut().take(nn) {
176            *v = S::ZERO;
177        }
178
179        // Linear operator: walk the CSC representation directly, writing
180        // each (row, col) pair to its row-major position. Avoids the
181        // intermediate DenseMatrix allocation that .to_dense() would do.
182        let col_ptrs = self.operator.col_ptrs();
183        let row_indices = self.operator.row_indices();
184        let values = self.operator.values();
185        for j in 0..n {
186            let start = col_ptrs[j];
187            let end = col_ptrs[j + 1];
188            for idx in start..end {
189                let i = row_indices[idx];
190                jac[i * n + j] = values[idx];
191            }
192        }
193
194        // Reaction: diagonal-only FD. Same step formula as the trait
195        // default (sqrt(S::EPSILON) * (1 + |u|)) so the
196        // partial-FD-partial-analytical mix stays numerically
197        // consistent.
198        if let Some(ref reaction) = self.reaction {
199            let h_factor = S::EPSILON.sqrt();
200            let nx_int = self.grid.nx_interior();
201            for jj in 0..self.grid.ny_interior() {
202                for ii in 0..nx_int {
203                    let idx = jj * nx_int + ii;
204                    let x = self.grid.x_grid.points()[ii + 1];
205                    let y_coord = self.grid.y_grid.points()[jj + 1];
206                    let u = y[idx];
207                    let h = h_factor * (S::ONE + u.abs());
208                    let r0 = reaction(t, x, y_coord, u);
209                    let r1 = reaction(t, x, y_coord, u + h);
210                    let dr_du = (r1 - r0) / h;
211                    jac[idx * n + idx] = jac[idx * n + idx] + dr_du;
212                }
213            }
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use numra_ode::{DoPri5, Solver, SolverOptions};
222
223    #[test]
224    fn test_mol2d_heat_steady_state() {
225        // 2D heat equation with zero Dirichlet BCs and zero initial condition
226        // Should stay at zero (trivial test)
227        let grid = Grid2D::uniform(0.0, 1.0, 11, 0.0, 1.0, 11);
228        let bc = BoundaryConditions2D::all_zero_dirichlet();
229        let mol = MOLSystem2D::heat(grid, 0.01_f64, &bc);
230
231        assert_eq!(mol.dim(), 81); // 9*9
232
233        let u0 = vec![0.0; 81];
234        let options = SolverOptions::default().rtol(1e-6);
235        let result = DoPri5::solve(&mol, 0.0, 0.1, &u0, &options).unwrap();
236        assert!(result.success);
237
238        let y_final = result.y_final().unwrap();
239        for &v in &y_final {
240            assert!(v.abs() < 1e-10, "Expected zero, got {}", v);
241        }
242    }
243
244    #[test]
245    fn test_mol2d_heat_decay() {
246        // 2D heat equation: u_t = alpha*(u_xx + u_yy)
247        // IC: u(x,y,0) = sin(pi*x)*sin(pi*y)
248        // BC: zero Dirichlet
249        // Exact: u(x,y,t) = sin(pi*x)*sin(pi*y)*exp(-2*pi^2*alpha*t)
250        let alpha = 0.01_f64;
251        let n = 21;
252        let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
253        let bc = BoundaryConditions2D::all_zero_dirichlet();
254        let mol = MOLSystem2D::heat(grid.clone(), alpha, &bc);
255
256        let nx_int = n - 2;
257        let ny_int = n - 2;
258        let n_int = nx_int * ny_int;
259
260        // IC
261        let mut u0 = vec![0.0; n_int];
262        let pi = std::f64::consts::PI;
263        for jj in 0..ny_int {
264            for ii in 0..nx_int {
265                let x = grid.x_grid.points()[ii + 1];
266                let y = grid.y_grid.points()[jj + 1];
267                u0[jj * nx_int + ii] = (pi * x).sin() * (pi * y).sin();
268            }
269        }
270
271        let t_final = 0.5;
272        let options = SolverOptions::default().rtol(1e-6).atol(1e-9);
273        let result = DoPri5::solve(&mol, 0.0, t_final, &u0, &options).unwrap();
274        assert!(result.success);
275
276        let y_final = result.y_final().unwrap();
277        let decay = (-2.0 * pi * pi * alpha * t_final).exp();
278
279        for jj in 0..ny_int {
280            for ii in 0..nx_int {
281                let idx = jj * nx_int + ii;
282                let x = grid.x_grid.points()[ii + 1];
283                let y = grid.y_grid.points()[jj + 1];
284                let exact = (pi * x).sin() * (pi * y).sin() * decay;
285                assert!(
286                    (y_final[idx] - exact).abs() < 0.02,
287                    "At ({:.2}, {:.2}): computed={:.6}, exact={:.6}",
288                    x,
289                    y,
290                    y_final[idx],
291                    exact
292                );
293            }
294        }
295    }
296
297    #[test]
298    fn test_mol2d_reaction_diffusion() {
299        // u_t = D*laplacian(u) + u*(1-u) (Fisher equation in 2D)
300        // Just verify it runs and solution stays in [0, 1] range
301        let d = 0.01_f64;
302        let n = 11;
303        let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
304        let bc = BoundaryConditions2D::all_zero_dirichlet();
305        let mol =
306            MOLSystem2D::heat(grid.clone(), d, &bc).with_reaction(|_t, _x, _y, u| u * (1.0 - u));
307
308        let nx_int = n - 2;
309        let ny_int = n - 2;
310        let n_int = nx_int * ny_int;
311
312        // IC: small bump in center
313        let mut u0 = vec![0.0; n_int];
314        for jj in 0..ny_int {
315            for ii in 0..nx_int {
316                let x = grid.x_grid.points()[ii + 1];
317                let y = grid.y_grid.points()[jj + 1];
318                let r2 = (x - 0.5) * (x - 0.5) + (y - 0.5) * (y - 0.5);
319                if r2 < 0.04 {
320                    u0[jj * nx_int + ii] = 0.5;
321                }
322            }
323        }
324
325        let options = SolverOptions::default().rtol(1e-4);
326        let result = DoPri5::solve(&mol, 0.0, 0.5, &u0, &options).unwrap();
327        assert!(result.success);
328
329        let y_final = result.y_final().unwrap();
330        for &v in &y_final {
331            assert!(v >= -0.1 && v <= 1.1, "Solution out of range: {}", v);
332        }
333    }
334
335    #[test]
336    fn test_mol2d_nonzero_dirichlet() {
337        // Heat equation with T=1 on left, T=0 on other sides
338        // After long time, should approach Laplace equation solution
339        let n = 11;
340        let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
341        let bc = BoundaryConditions2D {
342            x_min: crate::boundary::BoxedBC::dirichlet(1.0),
343            x_max: crate::boundary::BoxedBC::dirichlet(0.0),
344            y_min: crate::boundary::BoxedBC::dirichlet(0.0),
345            y_max: crate::boundary::BoxedBC::dirichlet(0.0),
346        };
347        let mol = MOLSystem2D::heat(grid.clone(), 0.1_f64, &bc);
348
349        let u0 = vec![0.0; mol.dim()];
350        let options = SolverOptions::default().rtol(1e-6);
351        let result = DoPri5::solve(&mol, 0.0, 5.0, &u0, &options).unwrap();
352        assert!(result.success);
353
354        let y_final = result.y_final().unwrap();
355        // Near the left boundary (ii=0), values should be close to 1
356        let nx_int = n - 2;
357        let mid_j = (n - 2) / 2;
358        let left_val = y_final[mid_j * nx_int + 0];
359        let right_val = y_final[mid_j * nx_int + (nx_int - 1)];
360        assert!(left_val > 0.3, "Near left should be warm: {}", left_val);
361        assert!(right_val < 0.3, "Near right should be cool: {}", right_val);
362    }
363
364    #[test]
365    fn test_mol2d_build_full_solution() {
366        let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
367        let bc = BoundaryConditions2D::all_zero_dirichlet();
368        let mol = MOLSystem2D::heat(grid, 0.01_f64, &bc);
369
370        let u_int = vec![1.0; 9]; // 3x3 interior = 9
371        let u_full = mol.build_full_solution(&u_int);
372        assert_eq!(u_full.len(), 25); // 5x5
373
374        // Interior should be 1.0
375        assert!((u_full[1 * 5 + 1] - 1.0).abs() < 1e-10);
376        assert!((u_full[2 * 5 + 2] - 1.0).abs() < 1e-10);
377        // Boundary should be 0.0
378        assert!(u_full[0].abs() < 1e-10);
379        assert!(u_full[4].abs() < 1e-10);
380    }
381
382    /// Helper: trait-default FD Jacobian, used as the agreement reference
383    /// for the analytical-override regression. Inlined here rather than
384    /// pulled from numra-ode to avoid a circular test dep; bit-identical
385    /// to the OdeSystem::jacobian default.
386    fn fd_jacobian<Sys: numra_ode::OdeSystem<f64>>(sys: &Sys, t: f64, y: &[f64]) -> Vec<f64> {
387        let n = sys.dim();
388        let h_factor = f64::EPSILON.sqrt();
389        let mut jac = vec![0.0; n * n];
390        let mut y_pert = y.to_vec();
391        let mut f0 = vec![0.0; n];
392        let mut f1 = vec![0.0; n];
393        sys.rhs(t, y, &mut f0);
394        for j in 0..n {
395            let yj = y_pert[j];
396            let h = h_factor * (1.0 + yj.abs());
397            y_pert[j] = yj + h;
398            sys.rhs(t, &y_pert, &mut f1);
399            y_pert[j] = yj;
400            for i in 0..n {
401                jac[i * n + j] = (f1[i] - f0[i]) / h;
402            }
403        }
404        jac
405    }
406
407    #[test]
408    fn test_mol2d_jacobian_agrees_with_fd_no_reaction() {
409        // Pure linear PDE — analytical and FD must agree to roundoff plus
410        // FD truncation, well below 1e-5 in absolute terms.
411        let grid = Grid2D::uniform(0.0, 1.0, 7, 0.0, 1.0, 7);
412        let bc = BoundaryConditions2D::all_zero_dirichlet();
413        let mol = MOLSystem2D::heat(grid, 0.01_f64, &bc);
414        let n = mol.dim();
415
416        // Non-trivial state so the FD perturbations matter.
417        let y: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
418
419        let mut jac_analytical = vec![0.0; n * n];
420        OdeSystem::jacobian(&mol, 0.0, &y, &mut jac_analytical);
421        let jac_fd = fd_jacobian(&mol, 0.0, &y);
422
423        for i in 0..n {
424            for j in 0..n {
425                let a = jac_analytical[i * n + j];
426                let f = jac_fd[i * n + j];
427                let tol = 1e-5_f64.max(1e-5 * a.abs());
428                assert!(
429                    (a - f).abs() < tol,
430                    "Jacobian mismatch at ({},{}): analytical={}, fd={}",
431                    i,
432                    j,
433                    a,
434                    f
435                );
436            }
437        }
438    }
439
440    #[test]
441    fn test_mol2d_jacobian_agrees_with_fd_with_reaction() {
442        // Reaction R(u) = -u^3, so dR/du = -3 u^2. The diagonal-FD path
443        // should land within FD truncation of this analytical value, and
444        // off-diagonal entries should match the operator-only baseline.
445        let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
446        let bc = BoundaryConditions2D::all_zero_dirichlet();
447        let mol =
448            MOLSystem2D::heat(grid, 0.05_f64, &bc).with_reaction(|_t, _x, _y, u: f64| -u * u * u);
449        let n = mol.dim();
450        let y: Vec<f64> = (0..n).map(|i| 0.1 + (i as f64) * 0.01).collect();
451
452        let mut jac_analytical = vec![0.0; n * n];
453        OdeSystem::jacobian(&mol, 0.0, &y, &mut jac_analytical);
454        let jac_fd = fd_jacobian(&mol, 0.0, &y);
455
456        for i in 0..n {
457            for j in 0..n {
458                let a = jac_analytical[i * n + j];
459                let f = jac_fd[i * n + j];
460                assert!(
461                    (a - f).abs() < 1e-4,
462                    "Jacobian mismatch at ({},{}): analytical={}, fd={}",
463                    i,
464                    j,
465                    a,
466                    f
467                );
468            }
469        }
470    }
471
472    #[test]
473    fn test_mol2d_radau5_uses_analytical_jacobian() {
474        // End-to-end composability: solve a stiff 2D heat problem with
475        // Radau5 and confirm success. The analytical-Jacobian override
476        // means Radau5 stops paying for the N+1 rhs evaluations FD would
477        // need per Jacobian rebuild; the test asserts it still produces
478        // a correct answer — the perf win is verified separately by the
479        // numra-bench harness.
480        use numra_ode::{Radau5, Solver, SolverOptions};
481        let n = 9;
482        let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
483        let bc = BoundaryConditions2D::all_zero_dirichlet();
484        let mol = MOLSystem2D::heat(grid.clone(), 0.5_f64, &bc); // large alpha => stiff
485
486        let nx_int = n - 2;
487        let pi = std::f64::consts::PI;
488        let mut u0 = vec![0.0; nx_int * nx_int];
489        for jj in 0..nx_int {
490            for ii in 0..nx_int {
491                let x = grid.x_grid.points()[ii + 1];
492                let yc = grid.y_grid.points()[jj + 1];
493                u0[jj * nx_int + ii] = (pi * x).sin() * (pi * yc).sin();
494            }
495        }
496
497        let options = SolverOptions::default().rtol(1e-6).atol(1e-9);
498        let result = Radau5::solve(&mol, 0.0, 0.05, &u0, &options).unwrap();
499        assert!(result.success);
500
501        // Compare to analytical decay at the centre.
502        let y_final = result.y_final().unwrap();
503        let mid = (nx_int / 2) * nx_int + (nx_int / 2);
504        let exact = (-2.0 * pi * pi * 0.5_f64 * 0.05).exp();
505        // Coarse 9² grid limits FD accuracy to a few percent at the centre.
506        let rel_err = (y_final[mid] - exact).abs() / exact;
507        assert!(
508            rel_err < 0.05,
509            "computed={}, exact={}, rel_err={}",
510            y_final[mid],
511            exact,
512            rel_err
513        );
514    }
515}