1use crate::boundary2d::BoundaryConditions2D;
11use crate::grid::Grid2D;
12use crate::sparse_assembly::{
13 assemble_laplacian_2d, assemble_operator_2d, Operator2DCoefficients, SparseScalar,
14};
15use numra_linalg::SparseMatrix;
16use numra_ode::OdeSystem;
17
18type ReactionFn<S> = Box<dyn Fn(S, S, S, S) -> S + Send + Sync>;
20
21pub struct MOLSystem2D<S: SparseScalar> {
27 grid: Grid2D<S>,
29 operator: SparseMatrix<S>,
31 bc_rhs: Vec<S>,
33 reaction: Option<ReactionFn<S>>,
35}
36
37impl<S: SparseScalar> MOLSystem2D<S> {
38 pub fn heat(grid: Grid2D<S>, alpha: S, bc: &BoundaryConditions2D<S>) -> Self {
40 let coeffs = Operator2DCoefficients::scaled_laplacian(alpha);
41 let (operator, bc_rhs) =
42 assemble_operator_2d(&grid, &coeffs, bc).expect("Failed to assemble 2D operator");
43 Self {
44 grid,
45 operator,
46 bc_rhs,
47 reaction: None,
48 }
49 }
50
51 pub fn laplacian(grid: Grid2D<S>, bc: &BoundaryConditions2D<S>) -> Self {
53 let (operator, bc_rhs) =
54 assemble_laplacian_2d(&grid, bc).expect("Failed to assemble 2D Laplacian");
55 Self {
56 grid,
57 operator,
58 bc_rhs,
59 reaction: None,
60 }
61 }
62
63 pub fn with_operator(
65 grid: Grid2D<S>,
66 coeffs: &Operator2DCoefficients<S>,
67 bc: &BoundaryConditions2D<S>,
68 ) -> Self {
69 let (operator, bc_rhs) =
70 assemble_operator_2d(&grid, coeffs, bc).expect("Failed to assemble 2D operator");
71 Self {
72 grid,
73 operator,
74 bc_rhs,
75 reaction: None,
76 }
77 }
78
79 pub fn with_reaction<F>(mut self, reaction: F) -> Self
83 where
84 F: Fn(S, S, S, S) -> S + Send + Sync + 'static,
85 {
86 self.reaction = Some(Box::new(reaction));
87 self
88 }
89
90 pub fn grid(&self) -> &Grid2D<S> {
92 &self.grid
93 }
94
95 pub fn n_interior(&self) -> usize {
97 self.grid.n_interior()
98 }
99
100 pub fn build_full_solution(&self, u_interior: &[S]) -> Vec<S> {
105 let nx = self.grid.nx();
106 let ny = self.grid.ny();
107 let nx_int = self.grid.nx_interior();
108
109 let mut u_full = vec![S::ZERO; nx * ny];
110
111 for jj in 0..self.grid.ny_interior() {
113 for ii in 0..nx_int {
114 let full_idx = (jj + 1) * nx + (ii + 1);
115 let int_idx = jj * nx_int + ii;
116 u_full[full_idx] = u_interior[int_idx];
117 }
118 }
119
120 u_full
122 }
123}
124
125impl<S: SparseScalar> OdeSystem<S> for MOLSystem2D<S> {
126 fn dim(&self) -> usize {
127 self.n_interior()
128 }
129
130 fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
131 let matvec = self.operator.mul_vec(y).expect("Sparse matvec failed");
133
134 let n = self.n_interior();
135 for i in 0..n {
136 dydt[i] = matvec[i] + self.bc_rhs[i];
137 }
138
139 if let Some(ref reaction) = self.reaction {
141 let nx_int = self.grid.nx_interior();
142 for jj in 0..self.grid.ny_interior() {
143 for ii in 0..nx_int {
144 let idx = jj * nx_int + ii;
145 let x = self.grid.x_grid.points()[ii + 1];
146 let y_coord = self.grid.y_grid.points()[jj + 1];
147 dydt[idx] = dydt[idx] + reaction(t, x, y_coord, y[idx]);
148 }
149 }
150 }
151 }
152
153 fn jacobian(&self, t: S, y: &[S], jac: &mut [S]) {
170 let n = self.n_interior();
171 let nn = n * n;
172
173 for v in jac.iter_mut().take(nn) {
176 *v = S::ZERO;
177 }
178
179 let col_ptrs = self.operator.col_ptrs();
183 let row_indices = self.operator.row_indices();
184 let values = self.operator.values();
185 for j in 0..n {
186 let start = col_ptrs[j];
187 let end = col_ptrs[j + 1];
188 for idx in start..end {
189 let i = row_indices[idx];
190 jac[i * n + j] = values[idx];
191 }
192 }
193
194 if let Some(ref reaction) = self.reaction {
199 let h_factor = S::EPSILON.sqrt();
200 let nx_int = self.grid.nx_interior();
201 for jj in 0..self.grid.ny_interior() {
202 for ii in 0..nx_int {
203 let idx = jj * nx_int + ii;
204 let x = self.grid.x_grid.points()[ii + 1];
205 let y_coord = self.grid.y_grid.points()[jj + 1];
206 let u = y[idx];
207 let h = h_factor * (S::ONE + u.abs());
208 let r0 = reaction(t, x, y_coord, u);
209 let r1 = reaction(t, x, y_coord, u + h);
210 let dr_du = (r1 - r0) / h;
211 jac[idx * n + idx] = jac[idx * n + idx] + dr_du;
212 }
213 }
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use numra_ode::{DoPri5, Solver, SolverOptions};
222
223 #[test]
224 fn test_mol2d_heat_steady_state() {
225 let grid = Grid2D::uniform(0.0, 1.0, 11, 0.0, 1.0, 11);
228 let bc = BoundaryConditions2D::all_zero_dirichlet();
229 let mol = MOLSystem2D::heat(grid, 0.01_f64, &bc);
230
231 assert_eq!(mol.dim(), 81); let u0 = vec![0.0; 81];
234 let options = SolverOptions::default().rtol(1e-6);
235 let result = DoPri5::solve(&mol, 0.0, 0.1, &u0, &options).unwrap();
236 assert!(result.success);
237
238 let y_final = result.y_final().unwrap();
239 for &v in &y_final {
240 assert!(v.abs() < 1e-10, "Expected zero, got {}", v);
241 }
242 }
243
244 #[test]
245 fn test_mol2d_heat_decay() {
246 let alpha = 0.01_f64;
251 let n = 21;
252 let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
253 let bc = BoundaryConditions2D::all_zero_dirichlet();
254 let mol = MOLSystem2D::heat(grid.clone(), alpha, &bc);
255
256 let nx_int = n - 2;
257 let ny_int = n - 2;
258 let n_int = nx_int * ny_int;
259
260 let mut u0 = vec![0.0; n_int];
262 let pi = std::f64::consts::PI;
263 for jj in 0..ny_int {
264 for ii in 0..nx_int {
265 let x = grid.x_grid.points()[ii + 1];
266 let y = grid.y_grid.points()[jj + 1];
267 u0[jj * nx_int + ii] = (pi * x).sin() * (pi * y).sin();
268 }
269 }
270
271 let t_final = 0.5;
272 let options = SolverOptions::default().rtol(1e-6).atol(1e-9);
273 let result = DoPri5::solve(&mol, 0.0, t_final, &u0, &options).unwrap();
274 assert!(result.success);
275
276 let y_final = result.y_final().unwrap();
277 let decay = (-2.0 * pi * pi * alpha * t_final).exp();
278
279 for jj in 0..ny_int {
280 for ii in 0..nx_int {
281 let idx = jj * nx_int + ii;
282 let x = grid.x_grid.points()[ii + 1];
283 let y = grid.y_grid.points()[jj + 1];
284 let exact = (pi * x).sin() * (pi * y).sin() * decay;
285 assert!(
286 (y_final[idx] - exact).abs() < 0.02,
287 "At ({:.2}, {:.2}): computed={:.6}, exact={:.6}",
288 x,
289 y,
290 y_final[idx],
291 exact
292 );
293 }
294 }
295 }
296
297 #[test]
298 fn test_mol2d_reaction_diffusion() {
299 let d = 0.01_f64;
302 let n = 11;
303 let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
304 let bc = BoundaryConditions2D::all_zero_dirichlet();
305 let mol =
306 MOLSystem2D::heat(grid.clone(), d, &bc).with_reaction(|_t, _x, _y, u| u * (1.0 - u));
307
308 let nx_int = n - 2;
309 let ny_int = n - 2;
310 let n_int = nx_int * ny_int;
311
312 let mut u0 = vec![0.0; n_int];
314 for jj in 0..ny_int {
315 for ii in 0..nx_int {
316 let x = grid.x_grid.points()[ii + 1];
317 let y = grid.y_grid.points()[jj + 1];
318 let r2 = (x - 0.5) * (x - 0.5) + (y - 0.5) * (y - 0.5);
319 if r2 < 0.04 {
320 u0[jj * nx_int + ii] = 0.5;
321 }
322 }
323 }
324
325 let options = SolverOptions::default().rtol(1e-4);
326 let result = DoPri5::solve(&mol, 0.0, 0.5, &u0, &options).unwrap();
327 assert!(result.success);
328
329 let y_final = result.y_final().unwrap();
330 for &v in &y_final {
331 assert!(v >= -0.1 && v <= 1.1, "Solution out of range: {}", v);
332 }
333 }
334
335 #[test]
336 fn test_mol2d_nonzero_dirichlet() {
337 let n = 11;
340 let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
341 let bc = BoundaryConditions2D {
342 x_min: crate::boundary::BoxedBC::dirichlet(1.0),
343 x_max: crate::boundary::BoxedBC::dirichlet(0.0),
344 y_min: crate::boundary::BoxedBC::dirichlet(0.0),
345 y_max: crate::boundary::BoxedBC::dirichlet(0.0),
346 };
347 let mol = MOLSystem2D::heat(grid.clone(), 0.1_f64, &bc);
348
349 let u0 = vec![0.0; mol.dim()];
350 let options = SolverOptions::default().rtol(1e-6);
351 let result = DoPri5::solve(&mol, 0.0, 5.0, &u0, &options).unwrap();
352 assert!(result.success);
353
354 let y_final = result.y_final().unwrap();
355 let nx_int = n - 2;
357 let mid_j = (n - 2) / 2;
358 let left_val = y_final[mid_j * nx_int + 0];
359 let right_val = y_final[mid_j * nx_int + (nx_int - 1)];
360 assert!(left_val > 0.3, "Near left should be warm: {}", left_val);
361 assert!(right_val < 0.3, "Near right should be cool: {}", right_val);
362 }
363
364 #[test]
365 fn test_mol2d_build_full_solution() {
366 let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
367 let bc = BoundaryConditions2D::all_zero_dirichlet();
368 let mol = MOLSystem2D::heat(grid, 0.01_f64, &bc);
369
370 let u_int = vec![1.0; 9]; let u_full = mol.build_full_solution(&u_int);
372 assert_eq!(u_full.len(), 25); assert!((u_full[1 * 5 + 1] - 1.0).abs() < 1e-10);
376 assert!((u_full[2 * 5 + 2] - 1.0).abs() < 1e-10);
377 assert!(u_full[0].abs() < 1e-10);
379 assert!(u_full[4].abs() < 1e-10);
380 }
381
382 fn fd_jacobian<Sys: numra_ode::OdeSystem<f64>>(sys: &Sys, t: f64, y: &[f64]) -> Vec<f64> {
387 let n = sys.dim();
388 let h_factor = f64::EPSILON.sqrt();
389 let mut jac = vec![0.0; n * n];
390 let mut y_pert = y.to_vec();
391 let mut f0 = vec![0.0; n];
392 let mut f1 = vec![0.0; n];
393 sys.rhs(t, y, &mut f0);
394 for j in 0..n {
395 let yj = y_pert[j];
396 let h = h_factor * (1.0 + yj.abs());
397 y_pert[j] = yj + h;
398 sys.rhs(t, &y_pert, &mut f1);
399 y_pert[j] = yj;
400 for i in 0..n {
401 jac[i * n + j] = (f1[i] - f0[i]) / h;
402 }
403 }
404 jac
405 }
406
407 #[test]
408 fn test_mol2d_jacobian_agrees_with_fd_no_reaction() {
409 let grid = Grid2D::uniform(0.0, 1.0, 7, 0.0, 1.0, 7);
412 let bc = BoundaryConditions2D::all_zero_dirichlet();
413 let mol = MOLSystem2D::heat(grid, 0.01_f64, &bc);
414 let n = mol.dim();
415
416 let y: Vec<f64> = (0..n).map(|i| ((i + 1) as f64).sin()).collect();
418
419 let mut jac_analytical = vec![0.0; n * n];
420 OdeSystem::jacobian(&mol, 0.0, &y, &mut jac_analytical);
421 let jac_fd = fd_jacobian(&mol, 0.0, &y);
422
423 for i in 0..n {
424 for j in 0..n {
425 let a = jac_analytical[i * n + j];
426 let f = jac_fd[i * n + j];
427 let tol = 1e-5_f64.max(1e-5 * a.abs());
428 assert!(
429 (a - f).abs() < tol,
430 "Jacobian mismatch at ({},{}): analytical={}, fd={}",
431 i,
432 j,
433 a,
434 f
435 );
436 }
437 }
438 }
439
440 #[test]
441 fn test_mol2d_jacobian_agrees_with_fd_with_reaction() {
442 let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
446 let bc = BoundaryConditions2D::all_zero_dirichlet();
447 let mol =
448 MOLSystem2D::heat(grid, 0.05_f64, &bc).with_reaction(|_t, _x, _y, u: f64| -u * u * u);
449 let n = mol.dim();
450 let y: Vec<f64> = (0..n).map(|i| 0.1 + (i as f64) * 0.01).collect();
451
452 let mut jac_analytical = vec![0.0; n * n];
453 OdeSystem::jacobian(&mol, 0.0, &y, &mut jac_analytical);
454 let jac_fd = fd_jacobian(&mol, 0.0, &y);
455
456 for i in 0..n {
457 for j in 0..n {
458 let a = jac_analytical[i * n + j];
459 let f = jac_fd[i * n + j];
460 assert!(
461 (a - f).abs() < 1e-4,
462 "Jacobian mismatch at ({},{}): analytical={}, fd={}",
463 i,
464 j,
465 a,
466 f
467 );
468 }
469 }
470 }
471
472 #[test]
473 fn test_mol2d_radau5_uses_analytical_jacobian() {
474 use numra_ode::{Radau5, Solver, SolverOptions};
481 let n = 9;
482 let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
483 let bc = BoundaryConditions2D::all_zero_dirichlet();
484 let mol = MOLSystem2D::heat(grid.clone(), 0.5_f64, &bc); let nx_int = n - 2;
487 let pi = std::f64::consts::PI;
488 let mut u0 = vec![0.0; nx_int * nx_int];
489 for jj in 0..nx_int {
490 for ii in 0..nx_int {
491 let x = grid.x_grid.points()[ii + 1];
492 let yc = grid.y_grid.points()[jj + 1];
493 u0[jj * nx_int + ii] = (pi * x).sin() * (pi * yc).sin();
494 }
495 }
496
497 let options = SolverOptions::default().rtol(1e-6).atol(1e-9);
498 let result = Radau5::solve(&mol, 0.0, 0.05, &u0, &options).unwrap();
499 assert!(result.success);
500
501 let y_final = result.y_final().unwrap();
503 let mid = (nx_int / 2) * nx_int + (nx_int / 2);
504 let exact = (-2.0 * pi * pi * 0.5_f64 * 0.05).exp();
505 let rel_err = (y_final[mid] - exact).abs() / exact;
507 assert!(
508 rel_err < 0.05,
509 "computed={}, exact={}, rel_err={}",
510 y_final[mid],
511 exact,
512 rel_err
513 );
514 }
515}