Skip to main content

numra_pde/
mol3d_parametric.rs

1//! Parametric 3D Method of Lines for forward sensitivity analysis.
2//!
3//! Mirror of [`crate::ParametricMOLSystem2D`] with a third spatial axis
4//! added. Same parameter layout (`[α, reaction_p_0, reaction_p_1, ...]`),
5//! same linearity argument for the operator scaling, same flag contract.
6//!
7//! Author: Moussa Leblouba
8//! Date: 8 May 2026
9
10use crate::boundary2d::BoundaryConditions3D;
11use crate::grid::Grid3D;
12use crate::sparse_assembly::{assemble_operator_3d, Operator3DCoefficients, SparseScalar};
13use numra_linalg::SparseMatrix;
14use numra_ode::ParametricOdeSystem;
15
16/// Parametric reaction closure: `(t, x, y, z, u, p) -> R`.
17type ParametricReactionFn<S> = Box<dyn Fn(S, S, S, S, S, &[S]) -> S + Send + Sync>;
18
19/// Parametric 3D Method of Lines system. See
20/// [`crate::ParametricMOLSystem2D`] for the parameter layout, linearity
21/// argument, and BC handling notes — they apply identically here.
22pub struct ParametricMOLSystem3D<S: SparseScalar> {
23    grid: Grid3D<S>,
24    /// Un-scaled Laplacian (built with α = 1).
25    l0: SparseMatrix<S>,
26    /// Un-scaled boundary RHS contribution (built with α = 1).
27    bc_rhs_0: Vec<S>,
28    /// Nominal parameter vector. `params[0]` = α, `params[1..]` = reaction.
29    nominal_params: Vec<S>,
30    reaction: Option<ParametricReactionFn<S>>,
31}
32
33impl<S: SparseScalar> ParametricMOLSystem3D<S> {
34    /// Build a parametric heat equation `u_t = α∇²u`.
35    pub fn heat(grid: Grid3D<S>, alpha_nominal: S, bc: &BoundaryConditions3D<S>) -> Self {
36        let coeffs = Operator3DCoefficients::laplacian();
37        let (l0, bc_rhs_0) =
38            assemble_operator_3d(&grid, &coeffs, bc).expect("Failed to assemble 3D Laplacian");
39        Self {
40            grid,
41            l0,
42            bc_rhs_0,
43            nominal_params: vec![alpha_nominal],
44            reaction: None,
45        }
46    }
47
48    /// Build a parametric heat equation with a parametric reaction term.
49    pub fn heat_with_reaction<R>(
50        grid: Grid3D<S>,
51        alpha_nominal: S,
52        bc: &BoundaryConditions3D<S>,
53        nominal_reaction_params: Vec<S>,
54        reaction: R,
55    ) -> Self
56    where
57        R: Fn(S, S, S, S, S, &[S]) -> S + Send + Sync + 'static,
58    {
59        let coeffs = Operator3DCoefficients::laplacian();
60        let (l0, bc_rhs_0) =
61            assemble_operator_3d(&grid, &coeffs, bc).expect("Failed to assemble 3D Laplacian");
62        let mut nominal_params = Vec::with_capacity(1 + nominal_reaction_params.len());
63        nominal_params.push(alpha_nominal);
64        nominal_params.extend(nominal_reaction_params);
65        Self {
66            grid,
67            l0,
68            bc_rhs_0,
69            nominal_params,
70            reaction: Some(Box::new(reaction)),
71        }
72    }
73
74    pub fn grid(&self) -> &Grid3D<S> {
75        &self.grid
76    }
77
78    pub fn n_interior(&self) -> usize {
79        self.grid.n_interior()
80    }
81}
82
83impl<S: SparseScalar> ParametricOdeSystem<S> for ParametricMOLSystem3D<S> {
84    fn n_states(&self) -> usize {
85        self.grid.n_interior()
86    }
87
88    fn n_params(&self) -> usize {
89        self.nominal_params.len()
90    }
91
92    fn params(&self) -> &[S] {
93        &self.nominal_params
94    }
95
96    fn rhs_with_params(&self, t: S, y: &[S], p: &[S], dydt: &mut [S]) {
97        let alpha = p[0];
98        let matvec = self.l0.mul_vec(y).expect("Sparse matvec failed");
99        let n = self.grid.n_interior();
100        for i in 0..n {
101            dydt[i] = alpha * (matvec[i] + self.bc_rhs_0[i]);
102        }
103        if let Some(ref reaction) = self.reaction {
104            let nx_int = self.grid.x_grid.n_interior();
105            let ny_int = self.grid.y_grid.n_interior();
106            let nz_int = self.grid.z_grid.n_interior();
107            for kk in 0..nz_int {
108                for jj in 0..ny_int {
109                    for ii in 0..nx_int {
110                        let idx = kk * (nx_int * ny_int) + jj * nx_int + ii;
111                        let x = self.grid.x_grid.points()[ii + 1];
112                        let y_coord = self.grid.y_grid.points()[jj + 1];
113                        let z_coord = self.grid.z_grid.points()[kk + 1];
114                        dydt[idx] = dydt[idx] + reaction(t, x, y_coord, z_coord, y[idx], p);
115                    }
116                }
117            }
118        }
119    }
120
121    fn jacobian_y(&self, t: S, y: &[S], jac: &mut [S]) {
122        let n = self.n_states();
123        let nn = n * n;
124        let alpha = self.nominal_params[0];
125
126        for v in jac.iter_mut().take(nn) {
127            *v = S::ZERO;
128        }
129
130        let col_ptrs = self.l0.col_ptrs();
131        let row_indices = self.l0.row_indices();
132        let values = self.l0.values();
133        for j in 0..n {
134            for idx in col_ptrs[j]..col_ptrs[j + 1] {
135                let i = row_indices[idx];
136                jac[i * n + j] = alpha * values[idx];
137            }
138        }
139
140        // Diagonal reaction Jacobian; same pointwise-reaction reasoning as
141        // the 2D variant — see ParametricMOLSystem2D::jacobian_y. Same step
142        // formula as `ParametricOdeSystem::jacobian_y` default.
143        if let Some(ref reaction) = self.reaction {
144            let h_factor = S::EPSILON.sqrt();
145            let nx_int = self.grid.x_grid.n_interior();
146            let ny_int = self.grid.y_grid.n_interior();
147            let nz_int = self.grid.z_grid.n_interior();
148            for kk in 0..nz_int {
149                for jj in 0..ny_int {
150                    for ii in 0..nx_int {
151                        let idx = kk * (nx_int * ny_int) + jj * nx_int + ii;
152                        let x = self.grid.x_grid.points()[ii + 1];
153                        let y_coord = self.grid.y_grid.points()[jj + 1];
154                        let z_coord = self.grid.z_grid.points()[kk + 1];
155                        let u = y[idx];
156                        let h = h_factor * (S::ONE + u.abs());
157                        let r0 = reaction(t, x, y_coord, z_coord, u, &self.nominal_params);
158                        let r1 = reaction(t, x, y_coord, z_coord, u + h, &self.nominal_params);
159                        let dr_du = (r1 - r0) / h;
160                        jac[idx * n + idx] = jac[idx * n + idx] + dr_du;
161                    }
162                }
163            }
164        }
165    }
166
167    fn jacobian_p(&self, t: S, y: &[S], jp: &mut [S]) {
168        let n = self.n_states();
169        let np = self.n_params();
170
171        for v in jp.iter_mut().take(n * np) {
172            *v = S::ZERO;
173        }
174
175        // Column 0 (α): linear part L0·y + bc_rhs_0.
176        let lin = self.l0.mul_vec(y).expect("Sparse matvec failed");
177        for i in 0..n {
178            jp[i] = lin[i] + self.bc_rhs_0[i];
179        }
180
181        // Reaction contribution to every column. FD on the closure with
182        // the matching parameter slot perturbed. Same step formula as
183        // `ParametricOdeSystem::jacobian_p` default.
184        if let Some(ref reaction) = self.reaction {
185            let h_factor = S::EPSILON.sqrt();
186            let nx_int = self.grid.x_grid.n_interior();
187            let ny_int = self.grid.y_grid.n_interior();
188            let nz_int = self.grid.z_grid.n_interior();
189            let mut p_pert = self.nominal_params.clone();
190            for k in 0..np {
191                let pk = self.nominal_params[k];
192                let h = h_factor * (S::ONE + pk.abs());
193                p_pert[k] = pk + h;
194                for kk in 0..nz_int {
195                    for jj in 0..ny_int {
196                        for ii in 0..nx_int {
197                            let idx = kk * (nx_int * ny_int) + jj * nx_int + ii;
198                            let x = self.grid.x_grid.points()[ii + 1];
199                            let y_coord = self.grid.y_grid.points()[jj + 1];
200                            let z_coord = self.grid.z_grid.points()[kk + 1];
201                            let u = y[idx];
202                            let r0 = reaction(t, x, y_coord, z_coord, u, &self.nominal_params);
203                            let r1 = reaction(t, x, y_coord, z_coord, u, &p_pert);
204                            let dr_dpk = (r1 - r0) / h;
205                            jp[k * n + idx] = jp[k * n + idx] + dr_dpk;
206                        }
207                    }
208                }
209                p_pert[k] = pk;
210            }
211        }
212    }
213
214    fn has_analytical_jacobian_y(&self) -> bool {
215        true
216    }
217
218    fn has_analytical_jacobian_p(&self) -> bool {
219        true
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use numra_ode::sensitivity::solve_forward_sensitivity;
227    use numra_ode::Radau5;
228
229    fn fd_jacobian_y<Sys: ParametricOdeSystem<f64>>(sys: &Sys, t: f64, y: &[f64]) -> Vec<f64> {
230        let n = sys.n_states();
231        let p = sys.params().to_vec();
232        let h_factor = f64::EPSILON.sqrt();
233        let mut jac = vec![0.0; n * n];
234        let mut f0 = vec![0.0; n];
235        let mut f1 = vec![0.0; n];
236        let mut y_pert = y.to_vec();
237        sys.rhs_with_params(t, y, &p, &mut f0);
238        for j in 0..n {
239            let yj = y_pert[j];
240            let h = h_factor * (1.0 + yj.abs());
241            y_pert[j] = yj + h;
242            sys.rhs_with_params(t, &y_pert, &p, &mut f1);
243            y_pert[j] = yj;
244            for i in 0..n {
245                jac[i * n + j] = (f1[i] - f0[i]) / h;
246            }
247        }
248        jac
249    }
250
251    fn fd_jacobian_p<Sys: ParametricOdeSystem<f64>>(sys: &Sys, t: f64, y: &[f64]) -> Vec<f64> {
252        let n = sys.n_states();
253        let np = sys.n_params();
254        let p_nom = sys.params().to_vec();
255        let h_factor = f64::EPSILON.sqrt();
256        let mut jp = vec![0.0; n * np];
257        let mut f0 = vec![0.0; n];
258        let mut f1 = vec![0.0; n];
259        let mut p_pert = p_nom.clone();
260        sys.rhs_with_params(t, y, &p_nom, &mut f0);
261        for k in 0..np {
262            let pk = p_pert[k];
263            let h = h_factor * (1.0 + pk.abs());
264            p_pert[k] = pk + h;
265            sys.rhs_with_params(t, y, &p_pert, &mut f1);
266            p_pert[k] = pk;
267            for i in 0..n {
268                jp[k * n + i] = (f1[i] - f0[i]) / h;
269            }
270        }
271        jp
272    }
273
274    #[test]
275    fn test_jacobian_y_agrees_with_fd_no_reaction() {
276        let grid = Grid3D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5, 0.0, 1.0, 5);
277        let bc = BoundaryConditions3D::all_zero_dirichlet();
278        let mol = ParametricMOLSystem3D::heat(grid, 0.05_f64, &bc);
279        let n = mol.n_states();
280        let y: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
281
282        let mut jac_a = vec![0.0; n * n];
283        mol.jacobian_y(0.0, &y, &mut jac_a);
284        let jac_fd = fd_jacobian_y(&mol, 0.0, &y);
285
286        for i in 0..n {
287            for j in 0..n {
288                let a = jac_a[i * n + j];
289                let f = jac_fd[i * n + j];
290                let tol = 1e-5_f64.max(1e-5 * a.abs());
291                assert!(
292                    (a - f).abs() < tol,
293                    "jac_y mismatch at ({},{}): analytical={}, fd={}",
294                    i,
295                    j,
296                    a,
297                    f
298                );
299            }
300        }
301    }
302
303    #[test]
304    fn test_jacobian_p_agrees_with_fd_with_reaction() {
305        let grid = Grid3D::uniform(0.0, 1.0, 4, 0.0, 1.0, 4, 0.0, 1.0, 4);
306        let bc = BoundaryConditions3D::all_zero_dirichlet();
307        let mol = ParametricMOLSystem3D::heat_with_reaction(
308            grid,
309            0.05_f64,
310            &bc,
311            vec![0.5, 0.1],
312            |_t, _x, _y, _z, u, p: &[f64]| -p[1] * u + p[2],
313        );
314        let n = mol.n_states();
315        let np = mol.n_params();
316        assert_eq!(np, 3);
317
318        let y: Vec<f64> = (0..n).map(|i| 0.1 + (i as f64) * 0.01).collect();
319        let mut jp_a = vec![0.0; n * np];
320        mol.jacobian_p(0.0, &y, &mut jp_a);
321        let jp_fd = fd_jacobian_p(&mol, 0.0, &y);
322
323        for k in 0..np {
324            for i in 0..n {
325                let a = jp_a[k * n + i];
326                let f = jp_fd[k * n + i];
327                assert!(
328                    (a - f).abs() < 1e-4,
329                    "jac_p[k={},i={}] mismatch: analytical={}, fd={}",
330                    k,
331                    i,
332                    a,
333                    f
334                );
335            }
336        }
337    }
338
339    // Runs only in the dedicated slow-tests workflow. On 2-vCPU GitHub
340    // runners the dense Radau5 sensitivity factorisations on the 9³ × 2
341    // augmented Jacobian push this past 10 minutes; on a beefier machine
342    // (`cargo nextest run --run-ignored=ignored-only` locally, or the
343    // weekly `.github/workflows/slow-tests.yml` job) it finishes in ~2
344    // minutes.
345    #[test]
346    #[ignore = "slow on shared CI runners; exercised by slow-tests workflow"]
347    fn test_forward_sensitivity_matches_analytical_decay() {
348        // 3D heat equation with the analytic exp(-3π²αt) decay.
349        // Sensitivity ∂u/∂α = -3π²t · u at cube centre.
350        use numra_ode::SolverOptions;
351        let alpha = 0.01_f64;
352        let n = 11;
353        let grid = Grid3D::uniform(0.0, 1.0, n, 0.0, 1.0, n, 0.0, 1.0, n);
354        let bc = BoundaryConditions3D::all_zero_dirichlet();
355        let mol = ParametricMOLSystem3D::heat(grid.clone(), alpha, &bc);
356
357        let nx_int = n - 2;
358        let n_int = nx_int * nx_int * nx_int;
359
360        let pi = std::f64::consts::PI;
361        let mut u0 = vec![0.0; n_int];
362        for kk in 0..nx_int {
363            for jj in 0..nx_int {
364                for ii in 0..nx_int {
365                    let x = grid.x_grid.points()[ii + 1];
366                    let yc = grid.y_grid.points()[jj + 1];
367                    let zc = grid.z_grid.points()[kk + 1];
368                    u0[kk * (nx_int * nx_int) + jj * nx_int + ii] =
369                        (pi * x).sin() * (pi * yc).sin() * (pi * zc).sin();
370                }
371            }
372        }
373
374        let t_final = 0.3;
375        let opts = SolverOptions::default().rtol(1e-6).atol(1e-9);
376        let result =
377            solve_forward_sensitivity::<Radau5, f64, _>(&mol, 0.0, t_final, &u0, &opts).unwrap();
378
379        let mid = (nx_int / 2) * (nx_int * nx_int) + (nx_int / 2) * nx_int + (nx_int / 2);
380        let exact_u = (pi * 0.5).sin().powi(3) * (-3.0 * pi * pi * alpha * t_final).exp();
381        let exact_du_dalpha = -3.0 * pi * pi * t_final * exact_u;
382
383        let computed_u = result.final_state()[mid];
384        let last_t = result.t.len() - 1;
385        let computed_du_dalpha = result.dyi_dpj(last_t, mid, 0);
386
387        // Coarser 11³ grid + larger spatial discretisation error in 3D:
388        // allow 5% rel error.
389        let rel_err_u = (computed_u - exact_u).abs() / exact_u;
390        let rel_err_s = (computed_du_dalpha - exact_du_dalpha).abs() / exact_du_dalpha.abs();
391        assert!(
392            rel_err_u < 0.05,
393            "state: computed={}, exact={}, rel_err={}",
394            computed_u,
395            exact_u,
396            rel_err_u
397        );
398        assert!(
399            rel_err_s < 0.05,
400            "∂u/∂α: computed={}, exact={}, rel_err={}",
401            computed_du_dalpha,
402            exact_du_dalpha,
403            rel_err_s
404        );
405    }
406}