1use 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
40type ParametricReactionFn<S> = Box<dyn Fn(S, S, S, S, &[S]) -> S + Send + Sync>;
45
46pub struct ParametricMOLSystem2D<S: SparseScalar> {
52 grid: Grid2D<S>,
53 l0: SparseMatrix<S>,
55 bc_rhs_0: Vec<S>,
57 nominal_params: Vec<S>,
59 reaction: Option<ParametricReactionFn<S>>,
61}
62
63impl<S: SparseScalar> ParametricMOLSystem2D<S> {
64 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 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 pub fn grid(&self) -> &Grid2D<S> {
115 &self.grid
116 }
117
118 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}