Skip to main content

outram_foam_basic_lib/ldu_matrix/
fv_matrix.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
21
22use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
23use std::sync::Arc;
24
25use super::ldu_matrix::LduMatrix;
26use super::solvers::conjugate_gradient::conjugate_gradient;
27use super::solvers::gamg::gamg;
28use super::solvers::gauss_seidel::gauss_seidel;
29use super::solvers::krylov_solve::{krylov_solve, KrylovMethod, KrylovOptions};
30use crate::fields::boundary::bc::PatchField;
31use crate::fields::field::Field;
32use crate::fields::vol_field::VolScalarField;
33use crate::mesh::fv_mesh::FvMesh;
34
35/// Sparse implicit matrix equation `A·φ = b` for a scalar field φ.
36///
37/// Mirrors `Foam::fvMatrix<scalar>` from
38/// `src/finiteVolume/fvMatrices/fvMatrix/fvMatrix.H`.
39///
40/// Assembled incrementally by `fvm::` operators in Layer 3; solved via
41/// `self.solve()`.
42pub struct FvMatrix {
43    /// Mesh the equation is defined on (shares the face addressing).
44    pub mesh: Arc<FvMesh>,
45    /// Sparse LDU coefficients of the operator `A`.
46    pub ldu: LduMatrix,
47    /// Right-hand-side source term, length `n_cells`.
48    pub source: Field<f64>,
49}
50
51/// Solver settings passed to `FvMatrix::solve`.
52#[derive(Debug, Clone, Copy)]
53pub struct SolverSettings {
54    /// Convergence tolerance on the normalised residual (dimensionless).
55    pub tolerance: f64,
56    /// Maximum iteration/sweep count before giving up.
57    pub max_iter: usize,
58}
59
60impl Default for SolverSettings {
61    fn default() -> Self {
62        Self {
63            tolerance: 1e-7,
64            max_iter: 1000,
65        }
66    }
67}
68
69/// Summary of a linear solve.
70#[derive(Debug, Clone, Copy)]
71pub struct SolverPerformance {
72    /// Number of iterations/sweeps actually performed.
73    pub n_iterations: usize,
74    /// Normalised residual at exit (dimensionless).
75    pub final_residual: f64,
76    /// `true` if `final_residual` dropped below the requested tolerance.
77    pub converged: bool,
78}
79
80impl FvMatrix {
81    /// Create a new zero-initialised FvMatrix for the given mesh.
82    ///
83    /// The LDU face addressing holds the mesh's internal faces, then one slot
84    /// per [`CyclicCoupling`](crate::mesh::CyclicCoupling) (conformal periodic
85    /// seam), then one slot per [`AmiWeight`](crate::mesh::AmiWeight) of each
86    /// [`AmiCoupling`](crate::mesh::AmiCoupling) (non-conformal periodic / AMI
87    /// seam, in `ami_couplings` order — see
88    /// [`FvMesh::ami_ldu_start`](crate::mesh::FvMesh::ami_ldu_start)). Every
89    /// matrix built on a given mesh therefore shares one structure — a
90    /// prerequisite for the coefficient-wise matrix arithmetic (`+`/`−`).
91    /// Operators that ignore seams (e.g. `fvm::ddt`) simply leave those slots
92    /// zero; `fvm::laplacian`/`fvm::div` fill them via the paired-cell coupling.
93    pub fn new(mesh: Arc<FvMesh>) -> Self {
94        let mut owner = mesh.owner[..mesh.n_internal_faces].to_vec();
95        let mut neighbour = mesh.neighbour.to_vec();
96        for cc in &mesh.cyclic_couplings {
97            owner.push(cc.owner);
98            neighbour.push(cc.neighbour);
99        }
100        // Non-conformal (AMI) seams: one LDU face per weighted (target, source)
101        // pair, appended after the cyclic couplings in `ami_couplings` order.
102        for cc in &mesh.ami_couplings {
103            for w in &cc.weights {
104                owner.push(cc.target_cell);
105                neighbour.push(w.source_cell);
106            }
107        }
108        let n_cells = mesh.n_cells;
109        Self {
110            mesh,
111            ldu: LduMatrix::new(n_cells, owner, neighbour),
112            source: Field::zeros(n_cells),
113        }
114    }
115
116    /// Solve `A·φ = source` and return the solution as a `VolScalarField`.
117    ///
118    /// Uses Gauss-Seidel as the default solver.  Layer 3 will add conjugate
119    /// gradient / GAMG when those are ported.
120    pub fn solve(
121        &self,
122        name: impl Into<String>,
123        settings: SolverSettings,
124    ) -> (VolScalarField, SolverPerformance) {
125        let mut x = vec![0.0_f64; self.mesh.n_cells];
126        let b: Vec<f64> = self.source.iter().copied().collect();
127        let (iters, res) =
128            gauss_seidel(&self.ldu, &b, &mut x, settings.tolerance, settings.max_iter);
129
130        let boundary = self
131            .mesh
132            .patches
133            .iter()
134            .map(|p| PatchField::zero_gradient(p.size))
135            .collect();
136        let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
137        let perf = SolverPerformance {
138            n_iterations: iters,
139            final_residual: res,
140            converged: res < settings.tolerance,
141        };
142        (field, perf)
143    }
144
145    /// Solve the system with preconditioned conjugate gradient (cold start).
146    ///
147    /// PCG requires a symmetric SPD matrix (`upper == lower`), which holds for
148    /// the pressure Poisson equation assembled by `fvm::laplacian`. It converges
149    /// in O(√κ) iterations versus O(κ) for Gauss-Seidel — dramatically faster
150    /// for the elliptic pressure solve. Do **not** use it for the asymmetric
151    /// convection-bearing momentum matrix.
152    ///
153    /// This starts the iteration from `x = 0`. In a transient solver, prefer
154    /// [`solve_cg_with_guess`](Self::solve_cg_with_guess) to warm-start from the
155    /// previous time step's field — near steady state that converges in a
156    /// handful of iterations instead of from scratch every step.
157    pub fn solve_cg(
158        &self,
159        name: impl Into<String>,
160        settings: SolverSettings,
161    ) -> (VolScalarField, SolverPerformance) {
162        self.solve_cg_inner(name, None, settings)
163    }
164
165    /// Solve with PCG, **warm-started** from `initial` (typically the previous
166    /// time step's field).
167    ///
168    /// The DIC-preconditioned CG begins from `initial.internal` instead of zero.
169    /// For a transient run approaching steady state the solution barely changes
170    /// between steps, so the starting residual is tiny and the solve completes
171    /// in very few iterations — often zero. See
172    /// [`conjugate_gradient`](crate::ldu_matrix::solvers::conjugate_gradient()) for the
173    /// preconditioner and warm-start details.
174    pub fn solve_cg_with_guess(
175        &self,
176        name: impl Into<String>,
177        initial: &VolScalarField,
178        settings: SolverSettings,
179    ) -> (VolScalarField, SolverPerformance) {
180        self.solve_cg_inner(name, Some(initial.internal.as_slice()), settings)
181    }
182
183    fn solve_cg_inner(
184        &self,
185        name: impl Into<String>,
186        x0: Option<&[f64]>,
187        settings: SolverSettings,
188    ) -> (VolScalarField, SolverPerformance) {
189        let b: Vec<f64> = self.source.iter().copied().collect();
190        let (x, perf) = conjugate_gradient(&self.ldu, &b, x0, &settings);
191
192        let boundary = self
193            .mesh
194            .patches
195            .iter()
196            .map(|p| PatchField::zero_gradient(p.size))
197            .collect();
198        let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
199        (field, perf)
200    }
201
202    /// Solve the system with GAMG (algebraic multigrid), cold-started from
203    /// `x = 0`.
204    ///
205    /// GAMG requires a symmetric SPD matrix (`upper == lower`) — the pressure
206    /// Poisson case. It is near mesh-independent (a handful of V-cycles), unlike
207    /// PCG whose iteration count grows with the mesh, so it is the better choice
208    /// for the pressure solve on fine grids. As with PCG, prefer
209    /// [`solve_gamg_with_guess`](Self::solve_gamg_with_guess) in a transient
210    /// loop to warm-start from the previous time step's field.
211    pub fn solve_gamg(
212        &self,
213        name: impl Into<String>,
214        settings: SolverSettings,
215    ) -> (VolScalarField, SolverPerformance) {
216        self.solve_gamg_inner(name, None, settings)
217    }
218
219    /// Solve with GAMG, **warm-started** from `initial` (typically the previous
220    /// time step's field). See [`gamg`](crate::ldu_matrix::solvers::gamg()) for the
221    /// multigrid algorithm and [`solve_gamg`](Self::solve_gamg) for the cold
222    /// variant.
223    pub fn solve_gamg_with_guess(
224        &self,
225        name: impl Into<String>,
226        initial: &VolScalarField,
227        settings: SolverSettings,
228    ) -> (VolScalarField, SolverPerformance) {
229        self.solve_gamg_inner(name, Some(initial.internal.as_slice()), settings)
230    }
231
232    fn solve_gamg_inner(
233        &self,
234        name: impl Into<String>,
235        x0: Option<&[f64]>,
236        settings: SolverSettings,
237    ) -> (VolScalarField, SolverPerformance) {
238        let b: Vec<f64> = self.source.iter().copied().collect();
239        let (x, perf) = gamg(&self.ldu, &b, x0, &settings);
240
241        let boundary = self
242            .mesh
243            .patches
244            .iter()
245            .map(|p| PatchField::zero_gradient(p.size))
246            .collect();
247        let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
248        (field, perf)
249    }
250
251    // ── Asymmetric Krylov solves (convection-bearing matrices) ─────────────
252
253    /// Solve the system with **preconditioned BiCGStab**, cold-started from
254    /// `x = 0`.
255    ///
256    /// This is the solver to use for any equation carrying a convection term —
257    /// scalar transport, energy, species — where the matrix is **asymmetric**
258    /// (`lower[f] != upper[f]`) because upwinding puts the flux on the donor side
259    /// only. PCG ([`solve_cg`](Self::solve_cg)) and GAMG
260    /// ([`solve_gamg`](Self::solve_gamg)) require symmetry and must not be used
261    /// on those matrices; the only prior alternative was
262    /// [`solve`](Self::solve)'s Gauss-Seidel, whose iteration count grows like
263    /// the condition number.
264    ///
265    /// With the default [`KrylovOptions`] this is ILU(0)-preconditioned
266    /// BiCGStab — the direct analogue of OpenFOAM's `PBiCGStab` with `DILU`.
267    ///
268    /// `settings.tolerance` is tested against the **relative 2-norm residual**
269    /// `||b − A·phi||₂ / ||b||₂` (the same measure `solve_cg` uses), not the
270    /// L1-scaled measure `solve` reports.
271    ///
272    /// In a transient loop prefer
273    /// [`solve_bicgstab_with_guess`](Self::solve_bicgstab_with_guess) to
274    /// warm-start from the previous time step.
275    pub fn solve_bicgstab(
276        &self,
277        name: impl Into<String>,
278        options: KrylovOptions,
279        settings: SolverSettings,
280    ) -> (VolScalarField, SolverPerformance) {
281        self.solve_krylov_inner(name, None, KrylovMethod::BiCGStab, options, settings)
282    }
283
284    /// Solve with preconditioned BiCGStab, **warm-started** from `initial`
285    /// (typically the previous time step's field).
286    ///
287    /// See [`solve_bicgstab`](Self::solve_bicgstab) for when to use BiCGStab at
288    /// all. Near steady state the starting residual is already tiny, so the
289    /// solve completes in a handful of iterations — often zero.
290    pub fn solve_bicgstab_with_guess(
291        &self,
292        name: impl Into<String>,
293        initial: &VolScalarField,
294        options: KrylovOptions,
295        settings: SolverSettings,
296    ) -> (VolScalarField, SolverPerformance) {
297        self.solve_krylov_inner(
298            name,
299            Some(initial.internal.as_slice()),
300            KrylovMethod::BiCGStab,
301            options,
302            settings,
303        )
304    }
305
306    /// Solve the system with **restarted, right-preconditioned GMRES(m)**,
307    /// cold-started from `x = 0`.
308    ///
309    /// GMRES minimises the residual over the Krylov subspace, so its residual
310    /// history is monotone and it cannot break down the way BiCGStab can — at
311    /// the cost of storing `options.restart` basis vectors
312    /// (`O(restart · n_cells)` memory). Reach for it when
313    /// [`solve_bicgstab`](Self::solve_bicgstab) reports `converged = false` on a
314    /// strongly nonnormal (high-Peclet, highly non-orthogonal) matrix.
315    ///
316    /// Like [`solve_bicgstab`](Self::solve_bicgstab), `settings.tolerance` is
317    /// tested against `||b − A·phi||₂ / ||b||₂`.
318    pub fn solve_gmres(
319        &self,
320        name: impl Into<String>,
321        options: KrylovOptions,
322        settings: SolverSettings,
323    ) -> (VolScalarField, SolverPerformance) {
324        self.solve_krylov_inner(name, None, KrylovMethod::Gmres, options, settings)
325    }
326
327    /// Solve with restarted GMRES(m), **warm-started** from `initial`.
328    ///
329    /// See [`solve_gmres`](Self::solve_gmres).
330    pub fn solve_gmres_with_guess(
331        &self,
332        name: impl Into<String>,
333        initial: &VolScalarField,
334        options: KrylovOptions,
335        settings: SolverSettings,
336    ) -> (VolScalarField, SolverPerformance) {
337        self.solve_krylov_inner(
338            name,
339            Some(initial.internal.as_slice()),
340            KrylovMethod::Gmres,
341            options,
342            settings,
343        )
344    }
345
346    /// Solve with the Krylov method named by `method`, optionally warm-started.
347    ///
348    /// The general form behind [`solve_bicgstab`](Self::solve_bicgstab) and
349    /// [`solve_gmres`](Self::solve_gmres); use it when the method is chosen at
350    /// run time (e.g. fall back from BiCGStab to GMRES after a breakdown)
351    /// instead of being fixed at the call site.
352    ///
353    /// `initial` is the starting iterate; `None` starts from zero.
354    pub fn solve_krylov(
355        &self,
356        name: impl Into<String>,
357        initial: Option<&VolScalarField>,
358        method: KrylovMethod,
359        options: KrylovOptions,
360        settings: SolverSettings,
361    ) -> (VolScalarField, SolverPerformance) {
362        self.solve_krylov_inner(
363            name,
364            initial.map(|f| f.internal.as_slice()),
365            method,
366            options,
367            settings,
368        )
369    }
370
371    fn solve_krylov_inner(
372        &self,
373        name: impl Into<String>,
374        x0: Option<&[f64]>,
375        method: KrylovMethod,
376        options: KrylovOptions,
377        settings: SolverSettings,
378    ) -> (VolScalarField, SolverPerformance) {
379        let b: Vec<f64> = self.source.iter().copied().collect();
380        let (x, perf) = krylov_solve(&self.ldu, &b, x0, method, options, &settings);
381
382        let boundary = self
383            .mesh
384            .patches
385            .iter()
386            .map(|p| PatchField::zero_gradient(p.size))
387            .collect();
388        let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
389        (field, perf)
390    }
391
392    // ── Operator helpers (used by fvm:: in Layer 3) ────────────────────────
393
394    /// Add `coeff * I` to the diagonal (e.g. from a time derivative term).
395    pub fn add_to_diag(&mut self, coeff: &Field<f64>) {
396        for (d, &c) in self.ldu.diag.iter_mut().zip(coeff.iter()) {
397            *d += c;
398        }
399    }
400
401    /// Add `coeff[c]` to the source at cell `c`.
402    pub fn add_to_source(&mut self, term: &Field<f64>) {
403        self.source += term.clone();
404    }
405
406    /// Add upper/lower contributions from a face (used by fvm::laplacian etc.).
407    pub fn add_face_coeff(&mut self, face: usize, coeff: f64) {
408        let o = self.ldu.owner[face];
409        let n = self.ldu.neighbour[face];
410        // Laplacian: upper[f] = lower[f] = -coeff (off-diagonal negative)
411        self.ldu.upper[face] -= coeff;
412        self.ldu.lower[face] -= coeff;
413        self.ldu.diag[o] += coeff;
414        self.ldu.diag[n] += coeff;
415    }
416
417    /// Pin one cell to a reference value — fixes the singular pressure matrix
418    /// in a closed domain (no fixed-pressure BCs).
419    ///
420    /// Adds a large coefficient to `diag[cell]` and a matching term to
421    /// `source[cell]` so that the solution is forced toward `value`.
422    pub fn set_reference(&mut self, cell: usize, value: f64) {
423        self.ldu.diag[cell] += 1e30;
424        self.source[cell] += 1e30 * value;
425    }
426
427    /// Diagonal coefficient per cell: `A[c] = diag[c]`.
428    ///
429    /// Used in PISO: `rAU = 1 / pEqn.a_field()`.
430    pub fn a_field(&self) -> VolScalarField {
431        let boundary = self
432            .mesh
433            .patches
434            .iter()
435            .map(|p| PatchField::zero_gradient(p.size))
436            .collect();
437        VolScalarField::new(
438            "A",
439            self.mesh.clone(),
440            Field::new(self.ldu.diag.clone()),
441            boundary,
442        )
443    }
444
445    /// Off-diagonal residual: `H[c] = source[c] − Σ off-diag · x`.
446    ///
447    /// For a zero initial guess `x = 0` this is just `source`.
448    /// Used in pressure correction: `H = pEqn.h_field(p)`.
449    pub fn h_field(&self, x: &VolScalarField) -> VolScalarField {
450        let n = self.mesh.n_cells;
451        let mut h = vec![0.0_f64; n];
452        for c in 0..n {
453            h[c] = self.source[c];
454        }
455        // Iterate the full LDU face count (internal + appended cyclic couplings)
456        // so periodic-seam off-diagonals contribute to H.
457        for f in 0..self.ldu.n_internal_faces {
458            let o = self.ldu.owner[f];
459            let nb = self.ldu.neighbour[f];
460            h[o] -= x.internal[nb] * self.ldu.upper[f];
461            h[nb] -= x.internal[o] * self.ldu.lower[f];
462        }
463        let boundary = self
464            .mesh
465            .patches
466            .iter()
467            .map(|p| PatchField::zero_gradient(p.size))
468            .collect();
469        VolScalarField::new("H", self.mesh.clone(), Field::new(h), boundary)
470    }
471}
472
473// ── Arithmetic ────────────────────────────────────────────────────────────────
474
475impl Add for FvMatrix {
476    type Output = Self;
477    fn add(mut self, rhs: Self) -> Self {
478        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
479            *a += b;
480        }
481        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
482            *a += b;
483        }
484        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
485            *a += b;
486        }
487        self.source += rhs.source;
488        self
489    }
490}
491
492impl Sub for FvMatrix {
493    type Output = Self;
494    fn sub(mut self, rhs: Self) -> Self {
495        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
496            *a -= b;
497        }
498        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
499            *a -= b;
500        }
501        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
502            *a -= b;
503        }
504        self.source -= rhs.source;
505        self
506    }
507}
508
509impl Neg for FvMatrix {
510    type Output = Self;
511    fn neg(mut self) -> Self {
512        for x in self.ldu.diag.iter_mut() {
513            *x = -*x;
514        }
515        for x in self.ldu.lower.iter_mut() {
516            *x = -*x;
517        }
518        for x in self.ldu.upper.iter_mut() {
519            *x = -*x;
520        }
521        self.source = -self.source;
522        self
523    }
524}
525
526impl AddAssign for FvMatrix {
527    fn add_assign(&mut self, rhs: Self) {
528        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
529            *a += b;
530        }
531        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
532            *a += b;
533        }
534        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
535            *a += b;
536        }
537        self.source += rhs.source;
538    }
539}
540
541impl SubAssign for FvMatrix {
542    fn sub_assign(&mut self, rhs: Self) {
543        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
544            *a -= b;
545        }
546        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
547            *a -= b;
548        }
549        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
550            *a -= b;
551        }
552        self.source -= rhs.source;
553    }
554}
555
556// ── Tests ─────────────────────────────────────────────────────────────────────
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::mesh::fv_mesh::{BoundaryPatch, FvMeshBuilder, PatchKind};
562    use crate::primitives::Vector3;
563    use approx::assert_relative_eq;
564
565    fn unit_mesh() -> Arc<FvMesh> {
566        Arc::new(
567            FvMeshBuilder::new()
568                .n_cells(2)
569                .n_internal_faces(1)
570                .owner(vec![0, 1, 0])
571                .neighbour(vec![1])
572                .patches(vec![
573                    BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
574                    BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
575                ])
576                .cell_volumes(vec![1.0, 1.0])
577                .cell_centres(vec![
578                    Vector3::new(0.25, 0.0, 0.0),
579                    Vector3::new(0.75, 0.0, 0.0),
580                ])
581                .face_area_vectors(vec![
582                    Vector3::new(1.0, 0.0, 0.0),
583                    Vector3::new(1.0, 0.0, 0.0),
584                    Vector3::new(-1.0, 0.0, 0.0),
585                ])
586                .face_centres(vec![
587                    Vector3::new(0.5, 0.0, 0.0),
588                    Vector3::new(1.0, 0.0, 0.0),
589                    Vector3::new(0.0, 0.0, 0.0),
590                ])
591                .build()
592                .unwrap(),
593        )
594    }
595
596    #[test]
597    fn solve_diagonal_system() {
598        let mesh = unit_mesh();
599        let mut mat = FvMatrix::new(mesh);
600
601        // Set A = [[3,0],[0,5]], b = [6, 10]  → x = [2, 2]
602        mat.ldu.diag[0] = 3.0;
603        mat.ldu.diag[1] = 5.0;
604        mat.source = Field::new(vec![6.0, 10.0]);
605
606        let (phi, perf) = mat.solve("phi", SolverSettings::default());
607        assert!(
608            perf.converged,
609            "did not converge: residual = {}",
610            perf.final_residual
611        );
612        assert_relative_eq!(phi.internal[0], 2.0, epsilon = 1e-6);
613        assert_relative_eq!(phi.internal[1], 2.0, epsilon = 1e-6);
614    }
615
616    #[test]
617    fn add_face_coeff_symmetrises_diag() {
618        let mesh = unit_mesh();
619        let mut mat = FvMatrix::new(mesh);
620        mat.add_face_coeff(0, 1.0);
621        // owner = 0, neighbour = 1 → diag[0] += 1, diag[1] += 1
622        assert_relative_eq!(mat.ldu.diag[0], 1.0);
623        assert_relative_eq!(mat.ldu.diag[1], 1.0);
624        assert_relative_eq!(mat.ldu.upper[0], -1.0);
625        assert_relative_eq!(mat.ldu.lower[0], -1.0);
626    }
627}