1use 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
16type ParametricReactionFn<S> = Box<dyn Fn(S, S, S, S, S, &[S]) -> S + Send + Sync>;
18
19pub struct ParametricMOLSystem3D<S: SparseScalar> {
23 grid: Grid3D<S>,
24 l0: SparseMatrix<S>,
26 bc_rhs_0: Vec<S>,
28 nominal_params: Vec<S>,
30 reaction: Option<ParametricReactionFn<S>>,
31}
32
33impl<S: SparseScalar> ParametricMOLSystem3D<S> {
34 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 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 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 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 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 #[test]
346 #[ignore = "slow on shared CI runners; exercised by slow-tests workflow"]
347 fn test_forward_sensitivity_matches_analytical_decay() {
348 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 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}