Skip to main content

numra_pde/
mol2d_parametric.rs

1//! Parametric 2D Method of Lines for forward sensitivity analysis.
2//!
3//! Wraps a 2D heat-equation MOL discretisation as a [`ParametricOdeSystem`],
4//! letting users compute `∂y/∂p` for parameters `p = [α, reaction_p_0, ...]`
5//! via [`numra_ode::sensitivity::solve_forward_sensitivity`] without
6//! hand-rolling the parametric system.
7//!
8//! # Design
9//!
10//! For the equation `u_t = α∇²u + R(t, x, y, u, p_react)` discretised on a
11//! uniform 2D grid by 5-point central differences, the assembled spatial
12//! operator scales linearly with α. So the parametric system stores the
13//! *un-scaled* Laplacian `L0` and the un-scaled boundary contribution
14//! `bc_rhs_0` once; at each rhs / jacobian call it scales by the current
15//! parameter `α`. No per-call matrix re-assembly.
16//!
17//! Mixed Dirichlet / Neumann boundary conditions both work automatically:
18//! the Neumann ghost-point flux contribution `x_minus * 2*dx*flux` carries
19//! `x_minus = α / dx²` (for a pure-Laplacian operator), so its α scaling
20//! propagates through the full assembly. No split between α-scaled and
21//! α-independent boundary terms is needed at v1 scope.
22//!
23//! # Parameter layout
24//!
25//! `params() = [α, reaction_p_0, reaction_p_1, ...]`. Slot 0 is reserved
26//! for the diffusion coefficient α; the remaining slots are reaction
27//! parameters. The reaction closure receives the *full* parameter slice
28//! `&[S]` so it can index `p[1..]` for its own params (or `p[0]` for α
29//! if the reaction depends on diffusion).
30//!
31//! Author: Moussa Leblouba
32//! Date: 8 May 2026
33
34use crate::boundary2d::BoundaryConditions2D;
35use crate::grid::Grid2D;
36use crate::sparse_assembly::{assemble_operator_2d, Operator2DCoefficients, SparseScalar};
37use numra_linalg::SparseMatrix;
38use numra_ode::ParametricOdeSystem;
39
40/// Type alias for a parametric reaction closure: `(t, x, y, u, p) -> R`.
41///
42/// The full parameter slice is passed; the closure can read `p[0]` for the
43/// diffusion coefficient α or `p[1..]` for its reaction-specific parameters.
44type ParametricReactionFn<S> = Box<dyn Fn(S, S, S, S, &[S]) -> S + Send + Sync>;
45
46/// Parametric 2D Method of Lines system.
47///
48/// See the module-level docs for the parameter layout, the linearity
49/// argument that makes the scaled-operator path correct, and the
50/// boundary-condition handling.
51pub struct ParametricMOLSystem2D<S: SparseScalar> {
52    grid: Grid2D<S>,
53    /// Un-scaled Laplacian (built with α = 1).
54    l0: SparseMatrix<S>,
55    /// Un-scaled boundary RHS contribution (built with α = 1).
56    bc_rhs_0: Vec<S>,
57    /// Nominal parameter vector. `params[0]` = α, `params[1..]` = reaction.
58    nominal_params: Vec<S>,
59    /// Optional parametric reaction term `R(t, x, y, u, &p)`.
60    reaction: Option<ParametricReactionFn<S>>,
61}
62
63impl<S: SparseScalar> ParametricMOLSystem2D<S> {
64    /// Build a parametric heat equation `u_t = α∇²u` with the diffusion
65    /// coefficient α as the single parameter.
66    pub fn heat(grid: Grid2D<S>, alpha_nominal: S, bc: &BoundaryConditions2D<S>) -> Self {
67        let coeffs = Operator2DCoefficients::laplacian();
68        let (l0, bc_rhs_0) =
69            assemble_operator_2d(&grid, &coeffs, bc).expect("Failed to assemble 2D Laplacian");
70        Self {
71            grid,
72            l0,
73            bc_rhs_0,
74            nominal_params: vec![alpha_nominal],
75            reaction: None,
76        }
77    }
78
79    /// Build a parametric heat equation with a parametric reaction term.
80    ///
81    /// The full parameter vector is `[α, reaction_params...]` —
82    /// `nominal_reaction_params` supplies nominal values for the reaction
83    /// parameters only; α goes in slot 0 separately.
84    ///
85    /// The reaction closure receives `(t, x, y, u, &p_full)` where `p_full`
86    /// is the full parameter slice (`p_full[0]` is α; `p_full[1..]` are the
87    /// reaction parameters supplied here).
88    pub fn heat_with_reaction<R>(
89        grid: Grid2D<S>,
90        alpha_nominal: S,
91        bc: &BoundaryConditions2D<S>,
92        nominal_reaction_params: Vec<S>,
93        reaction: R,
94    ) -> Self
95    where
96        R: Fn(S, S, S, S, &[S]) -> S + Send + Sync + 'static,
97    {
98        let coeffs = Operator2DCoefficients::laplacian();
99        let (l0, bc_rhs_0) =
100            assemble_operator_2d(&grid, &coeffs, bc).expect("Failed to assemble 2D Laplacian");
101        let mut nominal_params = Vec::with_capacity(1 + nominal_reaction_params.len());
102        nominal_params.push(alpha_nominal);
103        nominal_params.extend(nominal_reaction_params);
104        Self {
105            grid,
106            l0,
107            bc_rhs_0,
108            nominal_params,
109            reaction: Some(Box::new(reaction)),
110        }
111    }
112
113    /// Get the spatial grid.
114    pub fn grid(&self) -> &Grid2D<S> {
115        &self.grid
116    }
117
118    /// Number of interior points (the ODE state dimension).
119    pub fn n_interior(&self) -> usize {
120        self.grid.n_interior()
121    }
122}
123
124impl<S: SparseScalar> ParametricOdeSystem<S> for ParametricMOLSystem2D<S> {
125    fn n_states(&self) -> usize {
126        self.grid.n_interior()
127    }
128
129    fn n_params(&self) -> usize {
130        self.nominal_params.len()
131    }
132
133    fn params(&self) -> &[S] {
134        &self.nominal_params
135    }
136
137    fn rhs_with_params(&self, t: S, y: &[S], p: &[S], dydt: &mut [S]) {
138        let alpha = p[0];
139        // α · (L0·y + bc_rhs_0). Both terms scale linearly with α; see the
140        // module-level docs for the linearity argument.
141        let matvec = self.l0.mul_vec(y).expect("Sparse matvec failed");
142        let n = self.grid.n_interior();
143        for i in 0..n {
144            dydt[i] = alpha * (matvec[i] + self.bc_rhs_0[i]);
145        }
146
147        // Pointwise reaction term, if present. The closure sees the full
148        // parameter slice so it can index p[0] for α or p[1..] for its
149        // own params.
150        if let Some(ref reaction) = self.reaction {
151            let nx_int = self.grid.nx_interior();
152            for jj in 0..self.grid.ny_interior() {
153                for ii in 0..nx_int {
154                    let idx = jj * nx_int + ii;
155                    let x = self.grid.x_grid.points()[ii + 1];
156                    let y_coord = self.grid.y_grid.points()[jj + 1];
157                    dydt[idx] = dydt[idx] + reaction(t, x, y_coord, y[idx], p);
158                }
159            }
160        }
161    }
162
163    /// Analytical state Jacobian: `α · L0` plus the diagonal reaction
164    /// contribution. Same structure as `MOLSystem2D::jacobian`, but the
165    /// linear part is scaled by `α` from the parameter vector — and the
166    /// reaction sees the full parameter slice.
167    fn jacobian_y(&self, t: S, y: &[S], jac: &mut [S]) {
168        let n = self.n_states();
169        let nn = n * n;
170        let alpha = self.nominal_params[0];
171
172        for v in jac.iter_mut().take(nn) {
173            *v = S::ZERO;
174        }
175
176        // α · L0 → row-major dense.
177        let col_ptrs = self.l0.col_ptrs();
178        let row_indices = self.l0.row_indices();
179        let values = self.l0.values();
180        for j in 0..n {
181            for idx in col_ptrs[j]..col_ptrs[j + 1] {
182                let i = row_indices[idx];
183                jac[i * n + j] = alpha * values[idx];
184            }
185        }
186
187        // Diagonal reaction Jacobian: `∂R_i/∂u_j = δ_ij · ∂R_i/∂u_i`
188        // (pointwise reaction ⇒ no off-diagonal entries by structure).
189        // FD on the closure at the nominal parameter vector. Same step
190        // formula as `ParametricOdeSystem::jacobian_y` default.
191        if let Some(ref reaction) = self.reaction {
192            let h_factor = S::EPSILON.sqrt();
193            let nx_int = self.grid.nx_interior();
194            for jj in 0..self.grid.ny_interior() {
195                for ii in 0..nx_int {
196                    let idx = jj * nx_int + ii;
197                    let x = self.grid.x_grid.points()[ii + 1];
198                    let y_coord = self.grid.y_grid.points()[jj + 1];
199                    let u = y[idx];
200                    let h = h_factor * (S::ONE + u.abs());
201                    let r0 = reaction(t, x, y_coord, u, &self.nominal_params);
202                    let r1 = reaction(t, x, y_coord, u + h, &self.nominal_params);
203                    let dr_du = (r1 - r0) / h;
204                    jac[idx * n + idx] = jac[idx * n + idx] + dr_du;
205                }
206            }
207        }
208    }
209
210    /// Analytical parameter Jacobian. Column 0 (the α slot) is `L0·y +
211    /// bc_rhs_0` analytically, plus any `∂R/∂α` contribution from the
212    /// reaction closure (FD-detected). Columns 1..N_s are pure reaction
213    /// contributions (FD on the closure with the relevant slot perturbed).
214    /// The reaction is pointwise so each column has only diagonal entries
215    /// (one nonzero per grid point).
216    fn jacobian_p(&self, t: S, y: &[S], jp: &mut [S]) {
217        let n = self.n_states();
218        let np = self.n_params();
219
220        for v in jp.iter_mut().take(n * np) {
221            *v = S::ZERO;
222        }
223
224        // Column 0 (α): linear part. ∂(α · L0·y + α · bc_rhs_0)/∂α
225        //                          = L0·y + bc_rhs_0.
226        let lin = self.l0.mul_vec(y).expect("Sparse matvec failed");
227        for i in 0..n {
228            jp[i] = lin[i] + self.bc_rhs_0[i];
229        }
230
231        // Reaction contribution to every column (including column 0 if R
232        // depends on α). FD with the matching parameter slot perturbed.
233        // Each grid point contributes only its own row — pointwise R.
234        // Same step formula as `ParametricOdeSystem::jacobian_p` default.
235        if let Some(ref reaction) = self.reaction {
236            let h_factor = S::EPSILON.sqrt();
237            let nx_int = self.grid.nx_interior();
238            let mut p_pert = self.nominal_params.clone();
239            for k in 0..np {
240                let pk = self.nominal_params[k];
241                let h = h_factor * (S::ONE + pk.abs());
242                p_pert[k] = pk + h;
243                for jj in 0..self.grid.ny_interior() {
244                    for ii in 0..nx_int {
245                        let idx = jj * nx_int + ii;
246                        let x = self.grid.x_grid.points()[ii + 1];
247                        let y_coord = self.grid.y_grid.points()[jj + 1];
248                        let u = y[idx];
249                        let r0 = reaction(t, x, y_coord, u, &self.nominal_params);
250                        let r1 = reaction(t, x, y_coord, u, &p_pert);
251                        let dr_dpk = (r1 - r0) / h;
252                        jp[k * n + idx] = jp[k * n + idx] + dr_dpk;
253                    }
254                }
255                p_pert[k] = pk;
256            }
257        }
258    }
259
260    fn has_analytical_jacobian_y(&self) -> bool {
261        true
262    }
263
264    fn has_analytical_jacobian_p(&self) -> bool {
265        true
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use numra_ode::sensitivity::solve_forward_sensitivity;
273    use numra_ode::Radau5;
274
275    /// Trait-default FD Jacobian helper. Used for analytical-vs-FD
276    /// agreement regression. Identical to the FD path in
277    /// `ParametricOdeSystem::jacobian_y`/`jacobian_p` defaults
278    /// (`sqrt(EPSILON) * (1 + |y|)`).
279    fn fd_jacobian_y<Sys: ParametricOdeSystem<f64>>(sys: &Sys, t: f64, y: &[f64]) -> Vec<f64> {
280        let n = sys.n_states();
281        let p = sys.params().to_vec();
282        let h_factor = f64::EPSILON.sqrt();
283        let mut jac = vec![0.0; n * n];
284        let mut f0 = vec![0.0; n];
285        let mut f1 = vec![0.0; n];
286        let mut y_pert = y.to_vec();
287        sys.rhs_with_params(t, y, &p, &mut f0);
288        for j in 0..n {
289            let yj = y_pert[j];
290            let h = h_factor * (1.0 + yj.abs());
291            y_pert[j] = yj + h;
292            sys.rhs_with_params(t, &y_pert, &p, &mut f1);
293            y_pert[j] = yj;
294            for i in 0..n {
295                jac[i * n + j] = (f1[i] - f0[i]) / h;
296            }
297        }
298        jac
299    }
300
301    fn fd_jacobian_p<Sys: ParametricOdeSystem<f64>>(sys: &Sys, t: f64, y: &[f64]) -> Vec<f64> {
302        let n = sys.n_states();
303        let np = sys.n_params();
304        let p_nom = sys.params().to_vec();
305        let h_factor = f64::EPSILON.sqrt();
306        let mut jp = vec![0.0; n * np];
307        let mut f0 = vec![0.0; n];
308        let mut f1 = vec![0.0; n];
309        let mut p_pert = p_nom.clone();
310        sys.rhs_with_params(t, y, &p_nom, &mut f0);
311        for k in 0..np {
312            let pk = p_pert[k];
313            let h = h_factor * (1.0 + pk.abs());
314            p_pert[k] = pk + h;
315            sys.rhs_with_params(t, y, &p_pert, &mut f1);
316            p_pert[k] = pk;
317            for i in 0..n {
318                jp[k * n + i] = (f1[i] - f0[i]) / h;
319            }
320        }
321        jp
322    }
323
324    #[test]
325    fn test_jacobian_y_agrees_with_fd_no_reaction() {
326        let grid = Grid2D::uniform(0.0, 1.0, 7, 0.0, 1.0, 7);
327        let bc = BoundaryConditions2D::all_zero_dirichlet();
328        let mol = ParametricMOLSystem2D::heat(grid, 0.05_f64, &bc);
329        let n = mol.n_states();
330        let y: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
331
332        let mut jac_a = vec![0.0; n * n];
333        mol.jacobian_y(0.0, &y, &mut jac_a);
334        let jac_fd = fd_jacobian_y(&mol, 0.0, &y);
335
336        for i in 0..n {
337            for j in 0..n {
338                let a = jac_a[i * n + j];
339                let f = jac_fd[i * n + j];
340                let tol = 1e-5_f64.max(1e-5 * a.abs());
341                assert!(
342                    (a - f).abs() < tol,
343                    "jac_y mismatch at ({},{}): analytical={}, fd={}",
344                    i,
345                    j,
346                    a,
347                    f
348                );
349            }
350        }
351    }
352
353    #[test]
354    fn test_jacobian_y_agrees_with_fd_with_reaction() {
355        // Parametric reaction: R(u, p) = -p[1] * u^3   (Allen-Cahn-like with
356        // a parametric reaction rate at slot 1).
357        let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
358        let bc = BoundaryConditions2D::all_zero_dirichlet();
359        let mol = ParametricMOLSystem2D::heat_with_reaction(
360            grid,
361            0.05_f64,
362            &bc,
363            vec![1.0],
364            |_t, _x, _y, u, p: &[f64]| -p[1] * u * u * u,
365        );
366        let n = mol.n_states();
367        let y: Vec<f64> = (0..n).map(|i| 0.1 + (i as f64) * 0.01).collect();
368
369        let mut jac_a = vec![0.0; n * n];
370        mol.jacobian_y(0.0, &y, &mut jac_a);
371        let jac_fd = fd_jacobian_y(&mol, 0.0, &y);
372
373        for i in 0..n {
374            for j in 0..n {
375                let a = jac_a[i * n + j];
376                let f = jac_fd[i * n + j];
377                assert!(
378                    (a - f).abs() < 1e-4,
379                    "jac_y mismatch at ({},{}): analytical={}, fd={}",
380                    i,
381                    j,
382                    a,
383                    f
384                );
385            }
386        }
387    }
388
389    #[test]
390    fn test_jacobian_p_agrees_with_fd_no_reaction() {
391        // Pure heat equation: only one parameter, α. ∂rhs/∂α = L·y + bc_rhs.
392        let grid = Grid2D::uniform(0.0, 1.0, 7, 0.0, 1.0, 7);
393        let bc = BoundaryConditions2D::all_zero_dirichlet();
394        let mol = ParametricMOLSystem2D::heat(grid, 0.05_f64, &bc);
395        let n = mol.n_states();
396        let np = mol.n_params();
397        assert_eq!(np, 1);
398
399        let y: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
400        let mut jp_a = vec![0.0; n * np];
401        mol.jacobian_p(0.0, &y, &mut jp_a);
402        let jp_fd = fd_jacobian_p(&mol, 0.0, &y);
403
404        for i in 0..n {
405            let a = jp_a[i];
406            let f = jp_fd[i];
407            let tol = 1e-5_f64.max(1e-5 * a.abs());
408            assert!(
409                (a - f).abs() < tol,
410                "jac_p[α] mismatch at i={}: analytical={}, fd={}",
411                i,
412                a,
413                f
414            );
415        }
416    }
417
418    #[test]
419    fn test_jacobian_p_agrees_with_fd_with_reaction() {
420        // Two reaction params at slots 1 and 2: R(u, p) = -p[1] * u + p[2].
421        let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
422        let bc = BoundaryConditions2D::all_zero_dirichlet();
423        let mol = ParametricMOLSystem2D::heat_with_reaction(
424            grid,
425            0.05_f64,
426            &bc,
427            vec![0.5, 0.1],
428            |_t, _x, _y, u, p: &[f64]| -p[1] * u + p[2],
429        );
430        let n = mol.n_states();
431        let np = mol.n_params();
432        assert_eq!(np, 3);
433
434        let y: Vec<f64> = (0..n).map(|i| 0.1 + (i as f64) * 0.01).collect();
435        let mut jp_a = vec![0.0; n * np];
436        mol.jacobian_p(0.0, &y, &mut jp_a);
437        let jp_fd = fd_jacobian_p(&mol, 0.0, &y);
438
439        for k in 0..np {
440            for i in 0..n {
441                let a = jp_a[k * n + i];
442                let f = jp_fd[k * n + i];
443                assert!(
444                    (a - f).abs() < 1e-4,
445                    "jac_p[k={},i={}] mismatch: analytical={}, fd={}",
446                    k,
447                    i,
448                    a,
449                    f
450                );
451            }
452        }
453    }
454
455    #[test]
456    fn test_forward_sensitivity_matches_analytical_decay() {
457        // Heat equation u_t = α∇²u on [0,1]² with zero Dirichlet BCs.
458        // IC: u(x,y,0) = sin(πx)sin(πy).
459        // Exact: u(x,y,t) = sin(πx)sin(πy)·exp(-2π²·α·t).
460        // Sensitivity ∂u/∂α = -2π²t · u.
461        // We compute the numerical sensitivity at t=0.5, compare to the
462        // closed form at the cube centre.
463        use numra_ode::SolverOptions;
464        let alpha = 0.01_f64;
465        let n = 17;
466        let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
467        let bc = BoundaryConditions2D::all_zero_dirichlet();
468        let mol = ParametricMOLSystem2D::heat(grid.clone(), alpha, &bc);
469
470        let nx_int = n - 2;
471        let n_int = nx_int * nx_int;
472
473        let pi = std::f64::consts::PI;
474        let mut u0 = vec![0.0; n_int];
475        for jj in 0..nx_int {
476            for ii in 0..nx_int {
477                let x = grid.x_grid.points()[ii + 1];
478                let yc = grid.y_grid.points()[jj + 1];
479                u0[jj * nx_int + ii] = (pi * x).sin() * (pi * yc).sin();
480            }
481        }
482
483        let t_final = 0.5;
484        let opts = SolverOptions::default().rtol(1e-7).atol(1e-10);
485        let result =
486            solve_forward_sensitivity::<Radau5, f64, _>(&mol, 0.0, t_final, &u0, &opts).unwrap();
487
488        // Centre cell.
489        let mid = (nx_int / 2) * nx_int + (nx_int / 2);
490        let exact_u =
491            (pi * 0.5).sin() * (pi * 0.5).sin() * (-2.0 * pi * pi * alpha * t_final).exp();
492        let exact_du_dalpha = -2.0 * pi * pi * t_final * exact_u;
493
494        let computed_u = result.final_state()[mid];
495        let last_t = result.t.len() - 1;
496        let computed_du_dalpha = result.dyi_dpj(last_t, mid, 0);
497
498        // Coarse 17² grid + low-order spatial discretisation: a few percent
499        // error at the centre is normal.
500        let rel_err_u = (computed_u - exact_u).abs() / exact_u;
501        let rel_err_s = (computed_du_dalpha - exact_du_dalpha).abs() / exact_du_dalpha.abs();
502        assert!(
503            rel_err_u < 0.02,
504            "state: computed={}, exact={}, rel_err={}",
505            computed_u,
506            exact_u,
507            rel_err_u
508        );
509        assert!(
510            rel_err_s < 0.02,
511            "∂u/∂α: computed={}, exact={}, rel_err={}",
512            computed_du_dalpha,
513            exact_du_dalpha,
514            rel_err_s
515        );
516    }
517}