Skip to main content

numra_pde/
mol3d.rs

1//! 3D Method of Lines for converting 3D PDEs to ODE systems.
2//!
3//! Uses sparse matrix assembly for the spatial operator (7-point stencil),
4//! then the ODE RHS is a sparse matrix-vector product plus an optional
5//! pointwise reaction term.
6//!
7//! Author: Moussa Leblouba
8//! Date: 7 May 2026
9
10use crate::boundary2d::BoundaryConditions3D;
11use crate::grid::Grid3D;
12use crate::sparse_assembly::{
13    assemble_laplacian_3d, assemble_operator_3d, Operator3DCoefficients, SparseScalar,
14};
15use numra_linalg::SparseMatrix;
16use numra_ode::OdeSystem;
17
18/// Type alias for a reaction term closure: (t, x, y, z, u) -> f(u).
19type ReactionFn<S> = Box<dyn Fn(S, S, S, S, S) -> S + Send + Sync>;
20
21/// 3D Method of Lines system.
22///
23/// Converts a 3D PDE of the form `u_t = L[u] + R(t, x, y, z, 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 MOLSystem3D<S: SparseScalar> {
27    /// Spatial grid
28    grid: Grid3D<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, z, u)
34    reaction: Option<ReactionFn<S>>,
35}
36
37impl<S: SparseScalar> MOLSystem3D<S> {
38    /// Create a 3D MOL system for the heat equation: u_t = alpha * laplacian(u).
39    pub fn heat(grid: Grid3D<S>, alpha: S, bc: &BoundaryConditions3D<S>) -> Self {
40        let coeffs = Operator3DCoefficients::scaled_laplacian(alpha);
41        let (operator, bc_rhs) =
42            assemble_operator_3d(&grid, &coeffs, bc).expect("Failed to assemble 3D operator");
43        Self {
44            grid,
45            operator,
46            bc_rhs,
47            reaction: None,
48        }
49    }
50
51    /// Create a 3D MOL system for the Laplacian: u_t = laplacian(u).
52    pub fn laplacian(grid: Grid3D<S>, bc: &BoundaryConditions3D<S>) -> Self {
53        let (operator, bc_rhs) =
54            assemble_laplacian_3d(&grid, bc).expect("Failed to assemble 3D Laplacian");
55        Self {
56            grid,
57            operator,
58            bc_rhs,
59            reaction: None,
60        }
61    }
62
63    /// Create a 3D MOL system with a general linear operator.
64    pub fn with_operator(
65        grid: Grid3D<S>,
66        coeffs: &Operator3DCoefficients<S>,
67        bc: &BoundaryConditions3D<S>,
68    ) -> Self {
69        let (operator, bc_rhs) =
70            assemble_operator_3d(&grid, coeffs, bc).expect("Failed to assemble 3D 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, z, u) to the system.
80    ///
81    /// The full PDE becomes: `u_t = L[u] + R(t, x, y, z, u)`.
82    pub fn with_reaction<F>(mut self, reaction: F) -> Self
83    where
84        F: Fn(S, 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) -> &Grid3D<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:
103    ///   `u[kk * (nx_int * ny_int) + jj * nx_int + ii]`.
104    /// The full array has `nx*ny*nz` entries in the same column-major
105    /// layout used by `Grid3D::linear_index`.
106    pub fn build_full_solution(&self, u_interior: &[S]) -> Vec<S> {
107        let nx = self.grid.x_grid.len();
108        let ny = self.grid.y_grid.len();
109        let nz = self.grid.z_grid.len();
110        let nx_int = self.grid.x_grid.n_interior();
111        let ny_int = self.grid.y_grid.n_interior();
112        let nz_int = self.grid.z_grid.n_interior();
113
114        let mut u_full = vec![S::ZERO; nx * ny * nz];
115
116        for kk in 0..nz_int {
117            for jj in 0..ny_int {
118                for ii in 0..nx_int {
119                    let full_idx = self.grid.linear_index(ii + 1, jj + 1, kk + 1);
120                    let int_idx = kk * (nx_int * ny_int) + jj * nx_int + ii;
121                    u_full[full_idx] = u_interior[int_idx];
122                }
123            }
124        }
125
126        u_full
127    }
128}
129
130impl<S: SparseScalar> OdeSystem<S> for MOLSystem3D<S> {
131    fn dim(&self) -> usize {
132        self.n_interior()
133    }
134
135    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
136        // Sparse matvec: dydt = operator * y + bc_rhs
137        let matvec = self.operator.mul_vec(y).expect("Sparse matvec failed");
138
139        let n = self.n_interior();
140        for i in 0..n {
141            dydt[i] = matvec[i] + self.bc_rhs[i];
142        }
143
144        // Add reaction term if present
145        if let Some(ref reaction) = self.reaction {
146            let nx_int = self.grid.x_grid.n_interior();
147            let ny_int = self.grid.y_grid.n_interior();
148            let nz_int = self.grid.z_grid.n_interior();
149            for kk in 0..nz_int {
150                for jj in 0..ny_int {
151                    for ii in 0..nx_int {
152                        let idx = kk * (nx_int * ny_int) + jj * nx_int + ii;
153                        let x = self.grid.x_grid.points()[ii + 1];
154                        let y_coord = self.grid.y_grid.points()[jj + 1];
155                        let z_coord = self.grid.z_grid.points()[kk + 1];
156                        dydt[idx] = dydt[idx] + reaction(t, x, y_coord, z_coord, y[idx]);
157                    }
158                }
159            }
160        }
161    }
162
163    /// Analytical Jacobian: copy the assembled sparse spatial operator
164    /// (which already equals `∂(L[u])/∂u` by construction) into the row-major
165    /// dense buffer the solver expects, then add the reaction term's
166    /// contribution to the diagonal.
167    ///
168    /// The reaction Jacobian is diagonal *because* the reaction is
169    /// pointwise: `R(t, x_i, y_j, z_k, u_i)` depends only on the local
170    /// state `u_i`, so `∂R_i/∂u_m` is identically zero for any `m != i`.
171    /// Off-diagonal entries cannot be populated by a pointwise reaction,
172    /// no matter what the closure contains. This is what makes the
173    /// FD-on-the-diagonal fallback affordable: one extra closure call per
174    /// interior point versus the `O(N)` rhs evaluations the trait-default
175    /// FD would need to populate the same entries through perturbation.
176    /// If a future reaction model couples grid points (nonlocal /
177    /// integro-PDE / multi-component), the trait default's full FD path is
178    /// the correct fallback — but that's out of scope for v1.
179    fn jacobian(&self, t: S, y: &[S], jac: &mut [S]) {
180        let n = self.n_interior();
181        let nn = n * n;
182
183        // Zero the dense buffer; the sparse operator only fills nonzero
184        // entries below.
185        for v in jac.iter_mut().take(nn) {
186            *v = S::ZERO;
187        }
188
189        // Linear operator: walk the CSC representation directly into the
190        // row-major buffer. Avoids the intermediate DenseMatrix allocation
191        // that .to_dense() would do.
192        let col_ptrs = self.operator.col_ptrs();
193        let row_indices = self.operator.row_indices();
194        let values = self.operator.values();
195        for j in 0..n {
196            let start = col_ptrs[j];
197            let end = col_ptrs[j + 1];
198            for idx in start..end {
199                let i = row_indices[idx];
200                jac[i * n + j] = values[idx];
201            }
202        }
203
204        // Reaction: diagonal-only FD. Same step formula as the trait
205        // default (sqrt(S::EPSILON) * (1 + |u|)) so the
206        // partial-FD-partial-analytical mix stays numerically
207        // consistent.
208        if let Some(ref reaction) = self.reaction {
209            let h_factor = S::EPSILON.sqrt();
210            let nx_int = self.grid.x_grid.n_interior();
211            let ny_int = self.grid.y_grid.n_interior();
212            let nz_int = self.grid.z_grid.n_interior();
213            for kk in 0..nz_int {
214                for jj in 0..ny_int {
215                    for ii in 0..nx_int {
216                        let idx = kk * (nx_int * ny_int) + jj * nx_int + ii;
217                        let x = self.grid.x_grid.points()[ii + 1];
218                        let y_coord = self.grid.y_grid.points()[jj + 1];
219                        let z_coord = self.grid.z_grid.points()[kk + 1];
220                        let u = y[idx];
221                        let h = h_factor * (S::ONE + u.abs());
222                        let r0 = reaction(t, x, y_coord, z_coord, u);
223                        let r1 = reaction(t, x, y_coord, z_coord, u + h);
224                        let dr_du = (r1 - r0) / h;
225                        jac[idx * n + idx] = jac[idx * n + idx] + dr_du;
226                    }
227                }
228            }
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use numra_ode::{DoPri5, Solver, SolverOptions};
237
238    #[test]
239    fn test_mol3d_dim_and_zero_state() {
240        // Trivial: zero IC + zero Dirichlet BCs => stays at zero.
241        let grid = Grid3D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5, 0.0, 1.0, 5);
242        let bc = BoundaryConditions3D::all_zero_dirichlet();
243        let mol = MOLSystem3D::heat(grid, 0.01_f64, &bc);
244
245        // 3*3*3 = 27 interior points
246        assert_eq!(mol.dim(), 27);
247
248        let u0 = vec![0.0; 27];
249        let options = SolverOptions::default().rtol(1e-6);
250        let result = DoPri5::solve(&mol, 0.0, 0.1, &u0, &options).unwrap();
251        assert!(result.success);
252
253        let y_final = result.y_final().unwrap();
254        for &v in &y_final {
255            assert!(v.abs() < 1e-10, "Expected zero, got {}", v);
256        }
257    }
258
259    #[test]
260    fn test_mol3d_heat_decay() {
261        // 3D heat equation: u_t = alpha*(u_xx + u_yy + u_zz)
262        // IC: u(x,y,z,0) = sin(pi*x)*sin(pi*y)*sin(pi*z) on [0,1]^3
263        // BC: zero Dirichlet
264        // Exact: u(x,y,z,t) = IC * exp(-3*pi^2*alpha*t)
265        let alpha = 0.01_f64;
266        let n = 13;
267        let grid = Grid3D::uniform(0.0, 1.0, n, 0.0, 1.0, n, 0.0, 1.0, n);
268        let bc = BoundaryConditions3D::all_zero_dirichlet();
269        let mol = MOLSystem3D::heat(grid.clone(), alpha, &bc);
270
271        let nx_int = n - 2;
272        let ny_int = n - 2;
273        let nz_int = n - 2;
274        let n_int = nx_int * ny_int * nz_int;
275
276        // IC
277        let mut u0 = vec![0.0; n_int];
278        let pi = std::f64::consts::PI;
279        for kk in 0..nz_int {
280            for jj in 0..ny_int {
281                for ii in 0..nx_int {
282                    let x = grid.x_grid.points()[ii + 1];
283                    let y = grid.y_grid.points()[jj + 1];
284                    let z = grid.z_grid.points()[kk + 1];
285                    u0[kk * (nx_int * ny_int) + jj * nx_int + ii] =
286                        (pi * x).sin() * (pi * y).sin() * (pi * z).sin();
287                }
288            }
289        }
290
291        let t_final = 0.5;
292        let options = SolverOptions::default().rtol(1e-6).atol(1e-9);
293        let result = DoPri5::solve(&mol, 0.0, t_final, &u0, &options).unwrap();
294        assert!(result.success);
295
296        let y_final = result.y_final().unwrap();
297        let decay = (-3.0 * pi * pi * alpha * t_final).exp();
298
299        // Check at the center of the cube (max amplitude); coarse FD on a
300        // 13^3 grid limits accuracy to ~3% at the center.
301        let mid_i = nx_int / 2;
302        let mid_j = ny_int / 2;
303        let mid_k = nz_int / 2;
304        let idx = mid_k * (nx_int * ny_int) + mid_j * nx_int + mid_i;
305        let x = grid.x_grid.points()[mid_i + 1];
306        let y = grid.y_grid.points()[mid_j + 1];
307        let z = grid.z_grid.points()[mid_k + 1];
308        let exact = (pi * x).sin() * (pi * y).sin() * (pi * z).sin() * decay;
309        let rel_err = (y_final[idx] - exact).abs() / exact.abs();
310        assert!(
311            rel_err < 0.05,
312            "Center: computed={:.6}, exact={:.6}, rel_err={:.4}",
313            y_final[idx],
314            exact,
315            rel_err
316        );
317    }
318
319    #[test]
320    fn test_mol3d_reaction_diffusion() {
321        // u_t = D*laplacian(u) + u*(1-u) (Fisher-KPP in 3D)
322        // Just verify it runs and stays in a sensible range.
323        let d = 0.01_f64;
324        let n = 7;
325        let grid = Grid3D::uniform(0.0, 1.0, n, 0.0, 1.0, n, 0.0, 1.0, n);
326        let bc = BoundaryConditions3D::all_zero_dirichlet();
327        let mol = MOLSystem3D::heat(grid.clone(), d, &bc)
328            .with_reaction(|_t, _x, _y, _z, u| u * (1.0 - u));
329
330        let nx_int = n - 2;
331        let ny_int = n - 2;
332        let nz_int = n - 2;
333        let n_int = nx_int * ny_int * nz_int;
334
335        // IC: bump in center
336        let mut u0 = vec![0.0; n_int];
337        for kk in 0..nz_int {
338            for jj in 0..ny_int {
339                for ii in 0..nx_int {
340                    let x = grid.x_grid.points()[ii + 1];
341                    let y = grid.y_grid.points()[jj + 1];
342                    let z = grid.z_grid.points()[kk + 1];
343                    let r2 = (x - 0.5).powi(2) + (y - 0.5).powi(2) + (z - 0.5).powi(2);
344                    if r2 < 0.05 {
345                        u0[kk * (nx_int * ny_int) + jj * nx_int + ii] = 0.5;
346                    }
347                }
348            }
349        }
350
351        let options = SolverOptions::default().rtol(1e-4);
352        let result = DoPri5::solve(&mol, 0.0, 0.5, &u0, &options).unwrap();
353        assert!(result.success);
354
355        let y_final = result.y_final().unwrap();
356        for &v in &y_final {
357            assert!(
358                (-0.1..=1.1).contains(&v),
359                "Solution out of expected range: {}",
360                v
361            );
362        }
363    }
364
365    #[test]
366    fn test_mol3d_nonzero_dirichlet() {
367        // Hot left face, cold elsewhere.
368        // After enough time, near-left interior should warm up,
369        // far-right should stay cool.
370        let n = 9;
371        let grid = Grid3D::uniform(0.0, 1.0, n, 0.0, 1.0, n, 0.0, 1.0, n);
372        let bc = BoundaryConditions3D {
373            x_min: crate::boundary::BoxedBC::dirichlet(1.0),
374            x_max: crate::boundary::BoxedBC::dirichlet(0.0),
375            y_min: crate::boundary::BoxedBC::dirichlet(0.0),
376            y_max: crate::boundary::BoxedBC::dirichlet(0.0),
377            z_min: crate::boundary::BoxedBC::dirichlet(0.0),
378            z_max: crate::boundary::BoxedBC::dirichlet(0.0),
379        };
380        let mol = MOLSystem3D::heat(grid, 0.1_f64, &bc);
381
382        let u0 = vec![0.0; mol.dim()];
383        let options = SolverOptions::default().rtol(1e-6);
384        let result = DoPri5::solve(&mol, 0.0, 5.0, &u0, &options).unwrap();
385        assert!(result.success);
386
387        let y_final = result.y_final().unwrap();
388        let nx_int = n - 2;
389        let ny_int = n - 2;
390        let mid_j = ny_int / 2;
391        let mid_k = (n - 2) / 2;
392        let left = y_final[mid_k * (nx_int * ny_int) + mid_j * nx_int];
393        let right = y_final[mid_k * (nx_int * ny_int) + mid_j * nx_int + (nx_int - 1)];
394        assert!(left > 0.3, "Near left face should be warm: {}", left);
395        assert!(right < 0.3, "Near right face should be cool: {}", right);
396    }
397
398    #[test]
399    fn test_mol3d_build_full_solution() {
400        let grid = Grid3D::uniform(0.0, 1.0, 4, 0.0, 1.0, 4, 0.0, 1.0, 4);
401        let bc = BoundaryConditions3D::all_zero_dirichlet();
402        let mol = MOLSystem3D::heat(grid, 0.01_f64, &bc);
403
404        let u_int = vec![1.0; 8]; // 2*2*2
405        let u_full = mol.build_full_solution(&u_int);
406        assert_eq!(u_full.len(), 64); // 4*4*4
407
408        // Interior cell (1,1,1) should be 1.0
409        let center = mol.grid().linear_index(1, 1, 1);
410        assert!((u_full[center] - 1.0).abs() < 1e-10);
411
412        // Corner (0,0,0) should be 0.0 (boundary)
413        let corner = mol.grid().linear_index(0, 0, 0);
414        assert!(u_full[corner].abs() < 1e-10);
415    }
416
417    /// Trait-default FD Jacobian helper, identical to the one in mol2d.rs.
418    /// Used as the agreement reference for the analytical-override
419    /// regression — keeps the test self-contained.
420    fn fd_jacobian<Sys: numra_ode::OdeSystem<f64>>(sys: &Sys, t: f64, y: &[f64]) -> Vec<f64> {
421        let n = sys.dim();
422        let h_factor = f64::EPSILON.sqrt();
423        let mut jac = vec![0.0; n * n];
424        let mut y_pert = y.to_vec();
425        let mut f0 = vec![0.0; n];
426        let mut f1 = vec![0.0; n];
427        sys.rhs(t, y, &mut f0);
428        for j in 0..n {
429            let yj = y_pert[j];
430            let h = h_factor * (1.0 + yj.abs());
431            y_pert[j] = yj + h;
432            sys.rhs(t, &y_pert, &mut f1);
433            y_pert[j] = yj;
434            for i in 0..n {
435                jac[i * n + j] = (f1[i] - f0[i]) / h;
436            }
437        }
438        jac
439    }
440
441    #[test]
442    fn test_mol3d_jacobian_agrees_with_fd_no_reaction() {
443        // Pure linear 3D PDE — analytical and FD must agree.
444        let grid = Grid3D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5, 0.0, 1.0, 5);
445        let bc = BoundaryConditions3D::all_zero_dirichlet();
446        let mol = MOLSystem3D::heat(grid, 0.01_f64, &bc);
447        let n = mol.dim();
448        let y: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
449
450        let mut jac_analytical = vec![0.0; n * n];
451        OdeSystem::jacobian(&mol, 0.0, &y, &mut jac_analytical);
452        let jac_fd = fd_jacobian(&mol, 0.0, &y);
453
454        for i in 0..n {
455            for j in 0..n {
456                let a = jac_analytical[i * n + j];
457                let f = jac_fd[i * n + j];
458                let tol = 1e-5_f64.max(1e-5 * a.abs());
459                assert!(
460                    (a - f).abs() < tol,
461                    "Jacobian mismatch at ({},{}): analytical={}, fd={}",
462                    i,
463                    j,
464                    a,
465                    f
466                );
467            }
468        }
469    }
470
471    #[test]
472    fn test_mol3d_jacobian_agrees_with_fd_with_reaction() {
473        // Reaction R(u) = -u^3, so dR/du = -3 u^2. Diagonal-FD path
474        // matches; off-diagonals come from the operator only.
475        let grid = Grid3D::uniform(0.0, 1.0, 4, 0.0, 1.0, 4, 0.0, 1.0, 4);
476        let bc = BoundaryConditions3D::all_zero_dirichlet();
477        let mol = MOLSystem3D::heat(grid, 0.05_f64, &bc)
478            .with_reaction(|_t, _x, _y, _z, u: f64| -u * u * u);
479        let n = mol.dim();
480        let y: Vec<f64> = (0..n).map(|i| 0.1 + (i as f64) * 0.01).collect();
481
482        let mut jac_analytical = vec![0.0; n * n];
483        OdeSystem::jacobian(&mol, 0.0, &y, &mut jac_analytical);
484        let jac_fd = fd_jacobian(&mol, 0.0, &y);
485
486        for i in 0..n {
487            for j in 0..n {
488                let a = jac_analytical[i * n + j];
489                let f = jac_fd[i * n + j];
490                assert!(
491                    (a - f).abs() < 1e-4,
492                    "Jacobian mismatch at ({},{}): analytical={}, fd={}",
493                    i,
494                    j,
495                    a,
496                    f
497                );
498            }
499        }
500    }
501
502    #[test]
503    fn test_mol3d_radau5_uses_analytical_jacobian() {
504        // End-to-end composability: solve a stiff 3D heat problem with
505        // Radau5. Confirms the analytical Jacobian path doesn't break
506        // the solver's Newton convergence on a real workload.
507        use numra_ode::{Radau5, Solver, SolverOptions};
508        let n = 7;
509        let grid = Grid3D::uniform(0.0, 1.0, n, 0.0, 1.0, n, 0.0, 1.0, n);
510        let bc = BoundaryConditions3D::all_zero_dirichlet();
511        let mol = MOLSystem3D::heat(grid.clone(), 0.5_f64, &bc); // large alpha => stiff
512
513        let nx_int = n - 2;
514        let n_int = nx_int * nx_int * nx_int;
515        let pi = std::f64::consts::PI;
516        let mut u0 = vec![0.0; n_int];
517        for kk in 0..nx_int {
518            for jj in 0..nx_int {
519                for ii in 0..nx_int {
520                    let x = grid.x_grid.points()[ii + 1];
521                    let yc = grid.y_grid.points()[jj + 1];
522                    let zc = grid.z_grid.points()[kk + 1];
523                    u0[kk * (nx_int * nx_int) + jj * nx_int + ii] =
524                        (pi * x).sin() * (pi * yc).sin() * (pi * zc).sin();
525                }
526            }
527        }
528
529        let options = SolverOptions::default().rtol(1e-5).atol(1e-8);
530        let result = Radau5::solve(&mol, 0.0, 0.02, &u0, &options).unwrap();
531        assert!(result.success);
532
533        // Compare against analytical decay at the cube centre.
534        let y_final = result.y_final().unwrap();
535        let mid = (nx_int / 2) * (nx_int * nx_int) + (nx_int / 2) * nx_int + (nx_int / 2);
536        let exact = (-3.0 * pi * pi * 0.5_f64 * 0.02).exp();
537        // Coarse 7³ grid; centre relative error should still come in well
538        // under 10%.
539        let rel_err = (y_final[mid] - exact).abs() / exact;
540        assert!(
541            rel_err < 0.1,
542            "computed={}, exact={}, rel_err={}",
543            y_final[mid],
544            exact,
545            rel_err
546        );
547    }
548}