Skip to main content

numra_pde/
sparse_assembly.rs

1//! Sparse matrix assembly for 2D and 3D finite difference operators.
2//!
3//! Assembles standard FDM operators as sparse matrices in CSC format,
4//! incorporating boundary conditions into the operator.
5//!
6//! Author: Moussa Leblouba
7//! Date: 9 February 2026
8//! Modified: 2 May 2026
9
10use crate::boundary::BoundaryCondition;
11use crate::boundary2d::{BoundaryConditions2D, BoundaryConditions3D};
12use crate::grid::{Grid2D, Grid3D};
13use faer::{ComplexField, Conjugate, SimpleEntity};
14use numra_core::Scalar;
15use numra_linalg::{LinalgError, SparseMatrix};
16
17/// Trait alias for the faer bounds needed by SparseMatrix.
18/// Both f32 and f64 satisfy these bounds.
19pub trait SparseScalar: Scalar + SimpleEntity + Conjugate<Canonical = Self> + ComplexField {}
20impl<S: Scalar + SimpleEntity + Conjugate<Canonical = S> + ComplexField> SparseScalar for S {}
21
22/// Coefficients for a general 2D operator:
23///   a*u_xx + b*u_yy + c*u_x + d*u_y + e*u
24#[derive(Clone, Debug)]
25pub struct Operator2DCoefficients<S: Scalar> {
26    /// Coefficient for u_xx (second x-derivative)
27    pub a: S,
28    /// Coefficient for u_yy (second y-derivative)
29    pub b: S,
30    /// Coefficient for u_x (first x-derivative)
31    pub c: S,
32    /// Coefficient for u_y (first y-derivative)
33    pub d: S,
34    /// Coefficient for u (zeroth order)
35    pub e: S,
36}
37
38impl<S: Scalar> Operator2DCoefficients<S> {
39    /// Pure Laplacian: u_xx + u_yy
40    pub fn laplacian() -> Self {
41        Self {
42            a: S::ONE,
43            b: S::ONE,
44            c: S::ZERO,
45            d: S::ZERO,
46            e: S::ZERO,
47        }
48    }
49
50    /// Scaled Laplacian: alpha * (u_xx + u_yy)
51    pub fn scaled_laplacian(alpha: S) -> Self {
52        Self {
53            a: alpha,
54            b: alpha,
55            c: S::ZERO,
56            d: S::ZERO,
57            e: S::ZERO,
58        }
59    }
60
61    /// Advection-diffusion: D*(u_xx + u_yy) - vx*u_x - vy*u_y
62    pub fn advection_diffusion(diffusion: S, vx: S, vy: S) -> Self {
63        Self {
64            a: diffusion,
65            b: diffusion,
66            c: -vx,
67            d: -vy,
68            e: S::ZERO,
69        }
70    }
71}
72
73/// Interior-point linear index for 2D grid.
74///
75/// Maps interior point (i, j) (0-based in interior, so i in 0..nx-2, j in 0..ny-2)
76/// to a linear index in the unknown vector.
77#[inline]
78fn interior_index_2d(i: usize, j: usize, nx_int: usize) -> usize {
79    j * nx_int + i
80}
81
82/// Assemble 2D Laplacian as a sparse matrix for interior grid points.
83///
84/// Uses a 5-point stencil on a uniform grid:
85///   (u_{i-1,j} + u_{i+1,j} + u_{i,j-1} + u_{i,j+1} - 4*u_{i,j}) / h^2
86///
87/// For Dirichlet boundaries, the known boundary values are moved to the RHS
88/// (returned in the `rhs_contribution` vector). For Neumann boundaries, ghost
89/// point formulas modify the stencil.
90///
91/// Returns `(operator, rhs_contribution)` where:
92/// - `operator` is the N_int x N_int sparse matrix
93/// - `rhs_contribution` is a vector of length N_int with boundary contributions
94pub fn assemble_laplacian_2d<S: SparseScalar>(
95    grid: &Grid2D<S>,
96    bc: &BoundaryConditions2D<S>,
97) -> Result<(SparseMatrix<S>, Vec<S>), LinalgError> {
98    let coeffs = Operator2DCoefficients::laplacian();
99    assemble_operator_2d(grid, &coeffs, bc)
100}
101
102/// Assemble a general 2D FDM operator as a sparse matrix.
103///
104/// Operator: a*u_xx + b*u_yy + c*u_x + d*u_y + e*u
105///
106/// Uses second-order central differences on a uniform grid.
107///
108/// Returns `(operator, rhs_contribution)`.
109pub fn assemble_operator_2d<S: SparseScalar>(
110    grid: &Grid2D<S>,
111    coeffs: &Operator2DCoefficients<S>,
112    bc: &BoundaryConditions2D<S>,
113) -> Result<(SparseMatrix<S>, Vec<S>), LinalgError> {
114    let nx = grid.x_grid.len();
115    let ny = grid.y_grid.len();
116    let nx_int = nx - 2; // interior points in x
117    let ny_int = ny - 2; // interior points in y
118    let n_int = nx_int * ny_int;
119
120    let dx = grid.x_grid.dx_uniform();
121    let dy = grid.y_grid.dx_uniform();
122    let inv_dx2 = S::ONE / (dx * dx);
123    let inv_dy2 = S::ONE / (dy * dy);
124    let inv_2dx = S::ONE / (S::from_f64(2.0) * dx);
125    let inv_2dy = S::ONE / (S::from_f64(2.0) * dy);
126    let two = S::from_f64(2.0);
127
128    // Stencil coefficients for the general operator
129    let center = -two * coeffs.a * inv_dx2 - two * coeffs.b * inv_dy2 + coeffs.e;
130    let x_plus = coeffs.a * inv_dx2 + coeffs.c * inv_2dx; // u_{i+1,j}
131    let x_minus = coeffs.a * inv_dx2 - coeffs.c * inv_2dx; // u_{i-1,j}
132    let y_plus = coeffs.b * inv_dy2 + coeffs.d * inv_2dy; // u_{i,j+1}
133    let y_minus = coeffs.b * inv_dy2 - coeffs.d * inv_2dy; // u_{i,j-1}
134
135    let mut triplets = Vec::with_capacity(5 * n_int);
136    let mut rhs = vec![S::ZERO; n_int];
137
138    for jj in 0..ny_int {
139        for ii in 0..nx_int {
140            let row = interior_index_2d(ii, jj, nx_int);
141            // Grid indices (1-based in full grid)
142            let gi = ii + 1;
143            let gj = jj + 1;
144
145            // Center
146            triplets.push((row, row, center));
147
148            // x-1 neighbor
149            if ii == 0 {
150                // Left boundary
151                if bc.x_min.is_dirichlet() {
152                    let bval = bc.x_min.value(S::ZERO).unwrap_or(S::ZERO);
153                    rhs[row] = rhs[row] + x_minus * bval;
154                } else {
155                    // Neumann: ghost point u[-1,j] = u[1,j] - 2*dx*flux
156                    // So stencil at center gets += x_minus, and rhs gets flux contribution
157                    triplets.push((row, row, x_minus));
158                    let flux = bc.x_min.flux(S::ZERO).unwrap_or(S::ZERO);
159                    rhs[row] = rhs[row] + x_minus * two * dx * flux;
160                }
161            } else {
162                let col = interior_index_2d(ii - 1, jj, nx_int);
163                triplets.push((row, col, x_minus));
164            }
165
166            // x+1 neighbor
167            if ii == nx_int - 1 {
168                // Right boundary
169                if bc.x_max.is_dirichlet() {
170                    let bval = bc.x_max.value(S::ZERO).unwrap_or(S::ZERO);
171                    rhs[row] = rhs[row] + x_plus * bval;
172                } else {
173                    triplets.push((row, row, x_plus));
174                    let flux = bc.x_max.flux(S::ZERO).unwrap_or(S::ZERO);
175                    rhs[row] = rhs[row] - x_plus * two * dx * flux;
176                }
177            } else {
178                let col = interior_index_2d(ii + 1, jj, nx_int);
179                triplets.push((row, col, x_plus));
180            }
181
182            // y-1 neighbor
183            if jj == 0 {
184                // Bottom boundary
185                if bc.y_min.is_dirichlet() {
186                    let bval = bc.y_min.value(S::ZERO).unwrap_or(S::ZERO);
187                    rhs[row] = rhs[row] + y_minus * bval;
188                } else {
189                    triplets.push((row, row, y_minus));
190                    let flux = bc.y_min.flux(S::ZERO).unwrap_or(S::ZERO);
191                    rhs[row] = rhs[row] + y_minus * two * dy * flux;
192                }
193            } else {
194                let col = interior_index_2d(ii, jj - 1, nx_int);
195                triplets.push((row, col, y_minus));
196            }
197
198            // y+1 neighbor
199            if jj == ny_int - 1 {
200                // Top boundary
201                if bc.y_max.is_dirichlet() {
202                    let bval = bc.y_max.value(S::ZERO).unwrap_or(S::ZERO);
203                    rhs[row] = rhs[row] + y_plus * bval;
204                } else {
205                    triplets.push((row, row, y_plus));
206                    let flux = bc.y_max.flux(S::ZERO).unwrap_or(S::ZERO);
207                    rhs[row] = rhs[row] - y_plus * two * dy * flux;
208                }
209            } else {
210                let col = interior_index_2d(ii, jj + 1, nx_int);
211                triplets.push((row, col, y_plus));
212            }
213
214            let _ = (gi, gj); // suppress unused warnings
215        }
216    }
217
218    let matrix = SparseMatrix::from_triplets(n_int, n_int, &triplets)?;
219    Ok((matrix, rhs))
220}
221
222/// Coefficients for a general 3D operator:
223///   a*u_xx + b*u_yy + c*u_zz + d*u_x + e*u_y + f*u_z + g*u
224#[derive(Clone, Debug)]
225pub struct Operator3DCoefficients<S: Scalar> {
226    /// Coefficient for u_xx (second x-derivative)
227    pub a: S,
228    /// Coefficient for u_yy (second y-derivative)
229    pub b: S,
230    /// Coefficient for u_zz (second z-derivative)
231    pub c: S,
232    /// Coefficient for u_x (first x-derivative)
233    pub d: S,
234    /// Coefficient for u_y (first y-derivative)
235    pub e: S,
236    /// Coefficient for u_z (first z-derivative)
237    pub f: S,
238    /// Coefficient for u (zeroth order)
239    pub g: S,
240}
241
242impl<S: Scalar> Operator3DCoefficients<S> {
243    /// Pure Laplacian: u_xx + u_yy + u_zz
244    pub fn laplacian() -> Self {
245        Self {
246            a: S::ONE,
247            b: S::ONE,
248            c: S::ONE,
249            d: S::ZERO,
250            e: S::ZERO,
251            f: S::ZERO,
252            g: S::ZERO,
253        }
254    }
255
256    /// Scaled Laplacian: alpha * (u_xx + u_yy + u_zz)
257    pub fn scaled_laplacian(alpha: S) -> Self {
258        Self {
259            a: alpha,
260            b: alpha,
261            c: alpha,
262            d: S::ZERO,
263            e: S::ZERO,
264            f: S::ZERO,
265            g: S::ZERO,
266        }
267    }
268
269    /// Advection-diffusion: D*(u_xx + u_yy + u_zz) - vx*u_x - vy*u_y - vz*u_z
270    pub fn advection_diffusion(diffusion: S, vx: S, vy: S, vz: S) -> Self {
271        Self {
272            a: diffusion,
273            b: diffusion,
274            c: diffusion,
275            d: -vx,
276            e: -vy,
277            f: -vz,
278            g: S::ZERO,
279        }
280    }
281}
282
283/// Interior-point linear index for 3D grid.
284#[inline]
285fn interior_index_3d(i: usize, j: usize, k: usize, nx_int: usize, ny_int: usize) -> usize {
286    k * (nx_int * ny_int) + j * nx_int + i
287}
288
289/// Assemble 3D Laplacian as a sparse matrix for interior grid points.
290///
291/// Uses a 7-point stencil on a uniform grid:
292///   (u_{i±1,j,k} + u_{i,j±1,k} + u_{i,j,k±1} - 6*u_{i,j,k}) / h^2
293///
294/// Returns `(operator, rhs_contribution)`.
295pub fn assemble_laplacian_3d<S: SparseScalar>(
296    grid: &Grid3D<S>,
297    bc: &BoundaryConditions3D<S>,
298) -> Result<(SparseMatrix<S>, Vec<S>), LinalgError> {
299    let coeffs = Operator3DCoefficients::laplacian();
300    assemble_operator_3d(grid, &coeffs, bc)
301}
302
303/// Assemble a general 3D FDM operator as a sparse matrix.
304///
305/// Operator: a*u_xx + b*u_yy + c*u_zz + d*u_x + e*u_y + f*u_z + g*u
306///
307/// Uses second-order central differences on a uniform grid.
308///
309/// Returns `(operator, rhs_contribution)`.
310pub fn assemble_operator_3d<S: SparseScalar>(
311    grid: &Grid3D<S>,
312    coeffs: &Operator3DCoefficients<S>,
313    bc: &BoundaryConditions3D<S>,
314) -> Result<(SparseMatrix<S>, Vec<S>), LinalgError> {
315    let nx = grid.x_grid.len();
316    let ny = grid.y_grid.len();
317    let nz = grid.z_grid.len();
318    let nx_int = nx - 2;
319    let ny_int = ny - 2;
320    let nz_int = nz - 2;
321    let n_int = nx_int * ny_int * nz_int;
322
323    let dx = grid.x_grid.dx_uniform();
324    let dy = grid.y_grid.dx_uniform();
325    let dz = grid.z_grid.dx_uniform();
326    let inv_dx2 = S::ONE / (dx * dx);
327    let inv_dy2 = S::ONE / (dy * dy);
328    let inv_dz2 = S::ONE / (dz * dz);
329    let inv_2dx = S::ONE / (S::from_f64(2.0) * dx);
330    let inv_2dy = S::ONE / (S::from_f64(2.0) * dy);
331    let inv_2dz = S::ONE / (S::from_f64(2.0) * dz);
332    let two = S::from_f64(2.0);
333
334    // Stencil coefficients for the general operator
335    let center =
336        -two * coeffs.a * inv_dx2 - two * coeffs.b * inv_dy2 - two * coeffs.c * inv_dz2 + coeffs.g;
337    let x_plus = coeffs.a * inv_dx2 + coeffs.d * inv_2dx; // u_{i+1,j,k}
338    let x_minus = coeffs.a * inv_dx2 - coeffs.d * inv_2dx; // u_{i-1,j,k}
339    let y_plus = coeffs.b * inv_dy2 + coeffs.e * inv_2dy; // u_{i,j+1,k}
340    let y_minus = coeffs.b * inv_dy2 - coeffs.e * inv_2dy; // u_{i,j-1,k}
341    let z_plus = coeffs.c * inv_dz2 + coeffs.f * inv_2dz; // u_{i,j,k+1}
342    let z_minus = coeffs.c * inv_dz2 - coeffs.f * inv_2dz; // u_{i,j,k-1}
343
344    let mut triplets = Vec::with_capacity(7 * n_int);
345    let mut rhs = vec![S::ZERO; n_int];
346
347    for kk in 0..nz_int {
348        for jj in 0..ny_int {
349            for ii in 0..nx_int {
350                let row = interior_index_3d(ii, jj, kk, nx_int, ny_int);
351
352                // Center
353                triplets.push((row, row, center));
354
355                // x-1
356                if ii == 0 {
357                    if bc.x_min.is_dirichlet() {
358                        let bval = bc.x_min.value(S::ZERO).unwrap_or(S::ZERO);
359                        rhs[row] = rhs[row] + x_minus * bval;
360                    } else {
361                        triplets.push((row, row, x_minus));
362                        let flux = bc.x_min.flux(S::ZERO).unwrap_or(S::ZERO);
363                        rhs[row] = rhs[row] + x_minus * two * dx * flux;
364                    }
365                } else {
366                    let col = interior_index_3d(ii - 1, jj, kk, nx_int, ny_int);
367                    triplets.push((row, col, x_minus));
368                }
369
370                // x+1
371                if ii == nx_int - 1 {
372                    if bc.x_max.is_dirichlet() {
373                        let bval = bc.x_max.value(S::ZERO).unwrap_or(S::ZERO);
374                        rhs[row] = rhs[row] + x_plus * bval;
375                    } else {
376                        triplets.push((row, row, x_plus));
377                        let flux = bc.x_max.flux(S::ZERO).unwrap_or(S::ZERO);
378                        rhs[row] = rhs[row] - x_plus * two * dx * flux;
379                    }
380                } else {
381                    let col = interior_index_3d(ii + 1, jj, kk, nx_int, ny_int);
382                    triplets.push((row, col, x_plus));
383                }
384
385                // y-1
386                if jj == 0 {
387                    if bc.y_min.is_dirichlet() {
388                        let bval = bc.y_min.value(S::ZERO).unwrap_or(S::ZERO);
389                        rhs[row] = rhs[row] + y_minus * bval;
390                    } else {
391                        triplets.push((row, row, y_minus));
392                        let flux = bc.y_min.flux(S::ZERO).unwrap_or(S::ZERO);
393                        rhs[row] = rhs[row] + y_minus * two * dy * flux;
394                    }
395                } else {
396                    let col = interior_index_3d(ii, jj - 1, kk, nx_int, ny_int);
397                    triplets.push((row, col, y_minus));
398                }
399
400                // y+1
401                if jj == ny_int - 1 {
402                    if bc.y_max.is_dirichlet() {
403                        let bval = bc.y_max.value(S::ZERO).unwrap_or(S::ZERO);
404                        rhs[row] = rhs[row] + y_plus * bval;
405                    } else {
406                        triplets.push((row, row, y_plus));
407                        let flux = bc.y_max.flux(S::ZERO).unwrap_or(S::ZERO);
408                        rhs[row] = rhs[row] - y_plus * two * dy * flux;
409                    }
410                } else {
411                    let col = interior_index_3d(ii, jj + 1, kk, nx_int, ny_int);
412                    triplets.push((row, col, y_plus));
413                }
414
415                // z-1
416                if kk == 0 {
417                    if bc.z_min.is_dirichlet() {
418                        let bval = bc.z_min.value(S::ZERO).unwrap_or(S::ZERO);
419                        rhs[row] = rhs[row] + z_minus * bval;
420                    } else {
421                        triplets.push((row, row, z_minus));
422                        let flux = bc.z_min.flux(S::ZERO).unwrap_or(S::ZERO);
423                        rhs[row] = rhs[row] + z_minus * two * dz * flux;
424                    }
425                } else {
426                    let col = interior_index_3d(ii, jj, kk - 1, nx_int, ny_int);
427                    triplets.push((row, col, z_minus));
428                }
429
430                // z+1
431                if kk == nz_int - 1 {
432                    if bc.z_max.is_dirichlet() {
433                        let bval = bc.z_max.value(S::ZERO).unwrap_or(S::ZERO);
434                        rhs[row] = rhs[row] + z_plus * bval;
435                    } else {
436                        triplets.push((row, row, z_plus));
437                        let flux = bc.z_max.flux(S::ZERO).unwrap_or(S::ZERO);
438                        rhs[row] = rhs[row] - z_plus * two * dz * flux;
439                    }
440                } else {
441                    let col = interior_index_3d(ii, jj, kk + 1, nx_int, ny_int);
442                    triplets.push((row, col, z_plus));
443                }
444            }
445        }
446    }
447
448    let matrix = SparseMatrix::from_triplets(n_int, n_int, &triplets)?;
449    Ok((matrix, rhs))
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use numra_linalg::Matrix;
456
457    #[test]
458    fn test_laplacian_2d_size() {
459        // 5x5 grid => 3x3 = 9 interior points
460        let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
461        let bc = BoundaryConditions2D::all_zero_dirichlet();
462        let (mat, rhs) = assemble_laplacian_2d(&grid, &bc).unwrap();
463        assert_eq!(mat.nrows(), 9);
464        assert_eq!(mat.ncols(), 9);
465        assert_eq!(rhs.len(), 9);
466        // All zero Dirichlet => rhs should be all zeros
467        for &v in &rhs {
468            assert!(v.abs() < 1e-10);
469        }
470    }
471
472    #[test]
473    fn test_laplacian_2d_diagonal() {
474        // 4x4 grid => 2x2 = 4 interior points, dx=dy=1/3
475        let grid = Grid2D::uniform(0.0, 1.0, 4, 0.0, 1.0, 4);
476        let bc = BoundaryConditions2D::all_zero_dirichlet();
477        let (mat, _rhs) = assemble_laplacian_2d(&grid, &bc).unwrap();
478
479        // Diagonal entry: -2/dx^2 - 2/dy^2 = -2*(9) - 2*(9) = -36
480        let h = 1.0 / 3.0;
481        let expected_diag = -4.0 / (h * h);
482        assert!(
483            (mat.get(0, 0) - expected_diag).abs() < 1e-8,
484            "diag = {}, expected = {}",
485            mat.get(0, 0),
486            expected_diag
487        );
488    }
489
490    #[test]
491    fn test_laplacian_2d_symmetry() {
492        // Laplacian should be symmetric for uniform grid + zero Dirichlet
493        let grid = Grid2D::uniform(0.0, 1.0, 6, 0.0, 1.0, 6);
494        let bc = BoundaryConditions2D::all_zero_dirichlet();
495        let (mat, _rhs) = assemble_laplacian_2d(&grid, &bc).unwrap();
496
497        let n = mat.nrows();
498        let dense = mat.to_dense();
499        for i in 0..n {
500            for j in i + 1..n {
501                let a_ij = dense.get(i, j);
502                let a_ji = dense.get(j, i);
503                assert!(
504                    (a_ij - a_ji).abs() < 1e-10,
505                    "Not symmetric at ({}, {}): {} vs {}",
506                    i,
507                    j,
508                    a_ij,
509                    a_ji
510                );
511            }
512        }
513    }
514
515    #[test]
516    fn test_laplacian_2d_matvec() {
517        // For u(x,y) = x(1-x)*y(1-y), Laplacian = -2y(1-y) - 2x(1-x)
518        // With zero Dirichlet BCs, verify L*u_interior ≈ laplacian values
519        let n = 11;
520        let grid = Grid2D::uniform(0.0, 1.0, n, 0.0, 1.0, n);
521        let bc = BoundaryConditions2D::all_zero_dirichlet();
522        let (mat, rhs) = assemble_laplacian_2d(&grid, &bc).unwrap();
523
524        let nx_int = n - 2;
525        let ny_int = n - 2;
526        let n_int = nx_int * ny_int;
527
528        // Build u at interior points
529        let mut u = vec![0.0_f64; n_int];
530        for jj in 0..ny_int {
531            for ii in 0..nx_int {
532                let x = grid.x_grid.points()[ii + 1];
533                let y = grid.y_grid.points()[jj + 1];
534                u[jj * nx_int + ii] = x * (1.0 - x) * y * (1.0 - y);
535            }
536        }
537
538        // L*u + rhs should approximate the analytical Laplacian
539        let lu = mat.mul_vec(&u).unwrap();
540        for jj in 0..ny_int {
541            for ii in 0..nx_int {
542                let idx = jj * nx_int + ii;
543                let x = grid.x_grid.points()[ii + 1];
544                let y = grid.y_grid.points()[jj + 1];
545                let exact_lap = -2.0 * y * (1.0 - y) - 2.0 * x * (1.0 - x);
546                let computed = lu[idx] + rhs[idx];
547                assert!(
548                    (computed - exact_lap).abs() < 0.05,
549                    "At ({}, {}): computed={}, exact={}",
550                    x,
551                    y,
552                    computed,
553                    exact_lap
554                );
555            }
556        }
557    }
558
559    #[test]
560    fn test_laplacian_2d_dirichlet_rhs() {
561        // Non-zero Dirichlet BC: x_min = 1.0, rest = 0
562        let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
563        let bc = BoundaryConditions2D {
564            x_min: crate::boundary::BoxedBC::dirichlet(1.0),
565            x_max: crate::boundary::BoxedBC::dirichlet(0.0),
566            y_min: crate::boundary::BoxedBC::dirichlet(0.0),
567            y_max: crate::boundary::BoxedBC::dirichlet(0.0),
568        };
569        let (_mat, rhs) = assemble_laplacian_2d(&grid, &bc).unwrap();
570
571        // First column of interior (ii=0) should have nonzero rhs from x_min BC
572        let nx_int = 3;
573        for jj in 0..3 {
574            let idx = jj * nx_int + 0;
575            assert!(
576                rhs[idx].abs() > 1e-10,
577                "Expected nonzero rhs at ii=0, jj={}",
578                jj
579            );
580        }
581        // Last column (ii=2) should have zero rhs (x_max = 0)
582        for jj in 0..3 {
583            let idx = jj * nx_int + 2;
584            assert!(
585                rhs[idx].abs() < 1e-10,
586                "Expected zero rhs at ii=2, jj={}",
587                jj
588            );
589        }
590    }
591
592    #[test]
593    fn test_operator_2d_advection_diffusion() {
594        let grid = Grid2D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5);
595        let bc = BoundaryConditions2D::all_zero_dirichlet();
596        let coeffs = Operator2DCoefficients::advection_diffusion(0.1, 1.0, 0.0);
597        let (mat, _rhs) = assemble_operator_2d(&grid, &coeffs, &bc).unwrap();
598        assert_eq!(mat.nrows(), 9);
599        assert_eq!(mat.ncols(), 9);
600    }
601
602    #[test]
603    fn test_laplacian_3d_size() {
604        // 4x4x4 grid => 2x2x2 = 8 interior points
605        let grid = Grid3D::uniform(0.0, 1.0, 4, 0.0, 1.0, 4, 0.0, 1.0, 4);
606        let bc = BoundaryConditions3D::all_zero_dirichlet();
607        let (mat, rhs) = assemble_laplacian_3d(&grid, &bc).unwrap();
608        assert_eq!(mat.nrows(), 8);
609        assert_eq!(mat.ncols(), 8);
610        assert_eq!(rhs.len(), 8);
611    }
612
613    #[test]
614    fn test_laplacian_3d_diagonal() {
615        let grid = Grid3D::uniform(0.0, 1.0, 4, 0.0, 1.0, 4, 0.0, 1.0, 4);
616        let bc = BoundaryConditions3D::all_zero_dirichlet();
617        let (mat, _rhs) = assemble_laplacian_3d(&grid, &bc).unwrap();
618
619        // Diagonal: -2/dx^2 - 2/dy^2 - 2/dz^2 = -6/h^2 where h = 1/3
620        let h = 1.0 / 3.0;
621        let expected_diag = -6.0 / (h * h);
622        assert!(
623            (mat.get(0, 0) - expected_diag).abs() < 1e-8,
624            "diag = {}, expected = {}",
625            mat.get(0, 0),
626            expected_diag
627        );
628    }
629
630    #[test]
631    fn test_laplacian_3d_symmetry() {
632        let grid = Grid3D::uniform(0.0, 1.0, 5, 0.0, 1.0, 5, 0.0, 1.0, 5);
633        let bc = BoundaryConditions3D::all_zero_dirichlet();
634        let (mat, _rhs) = assemble_laplacian_3d(&grid, &bc).unwrap();
635
636        let n = mat.nrows();
637        let dense = mat.to_dense();
638        for i in 0..n {
639            for j in i + 1..n {
640                let a_ij = dense.get(i, j);
641                let a_ji = dense.get(j, i);
642                assert!(
643                    (a_ij - a_ji).abs() < 1e-10,
644                    "Not symmetric at ({}, {}): {} vs {}",
645                    i,
646                    j,
647                    a_ij,
648                    a_ji
649                );
650            }
651        }
652    }
653}