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 crate::fields::boundary::bc::PatchField;
30use crate::fields::field::Field;
31use crate::fields::vol_field::VolScalarField;
32use crate::mesh::fv_mesh::FvMesh;
33
34/// Sparse implicit matrix equation `A·φ = b` for a scalar field φ.
35///
36/// Mirrors `Foam::fvMatrix<scalar>` from
37/// `src/finiteVolume/fvMatrices/fvMatrix/fvMatrix.H`.
38///
39/// Assembled incrementally by `fvm::` operators in Layer 3; solved via
40/// `self.solve()`.
41pub struct FvMatrix {
42    /// Mesh the equation is defined on (shares the face addressing).
43    pub mesh: Arc<FvMesh>,
44    /// Sparse LDU coefficients of the operator `A`.
45    pub ldu: LduMatrix,
46    /// Right-hand-side source term, length `n_cells`.
47    pub source: Field<f64>,
48}
49
50/// Solver settings passed to `FvMatrix::solve`.
51#[derive(Debug, Clone, Copy)]
52pub struct SolverSettings {
53    /// Convergence tolerance on the normalised residual (dimensionless).
54    pub tolerance: f64,
55    /// Maximum iteration/sweep count before giving up.
56    pub max_iter: usize,
57}
58
59impl Default for SolverSettings {
60    fn default() -> Self {
61        Self {
62            tolerance: 1e-7,
63            max_iter: 1000,
64        }
65    }
66}
67
68/// Summary of a linear solve.
69#[derive(Debug, Clone, Copy)]
70pub struct SolverPerformance {
71    /// Number of iterations/sweeps actually performed.
72    pub n_iterations: usize,
73    /// Normalised residual at exit (dimensionless).
74    pub final_residual: f64,
75    /// `true` if `final_residual` dropped below the requested tolerance.
76    pub converged: bool,
77}
78
79impl FvMatrix {
80    /// Create a new zero-initialised FvMatrix for the given mesh.
81    pub fn new(mesh: Arc<FvMesh>) -> Self {
82        let owner = mesh.owner[..mesh.n_internal_faces].to_vec();
83        let neighbour = mesh.neighbour.to_vec();
84        let n_cells = mesh.n_cells;
85        Self {
86            mesh,
87            ldu: LduMatrix::new(n_cells, owner, neighbour),
88            source: Field::zeros(n_cells),
89        }
90    }
91
92    /// Solve `A·φ = source` and return the solution as a `VolScalarField`.
93    ///
94    /// Uses Gauss-Seidel as the default solver.  Layer 3 will add conjugate
95    /// gradient / GAMG when those are ported.
96    pub fn solve(
97        &self,
98        name: impl Into<String>,
99        settings: SolverSettings,
100    ) -> (VolScalarField, SolverPerformance) {
101        let mut x = vec![0.0_f64; self.mesh.n_cells];
102        let b: Vec<f64> = self.source.iter().copied().collect();
103        let (iters, res) =
104            gauss_seidel(&self.ldu, &b, &mut x, settings.tolerance, settings.max_iter);
105
106        let boundary = self
107            .mesh
108            .patches
109            .iter()
110            .map(|p| PatchField::zero_gradient(p.size))
111            .collect();
112        let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
113        let perf = SolverPerformance {
114            n_iterations: iters,
115            final_residual: res,
116            converged: res < settings.tolerance,
117        };
118        (field, perf)
119    }
120
121    /// Solve the system with preconditioned conjugate gradient (cold start).
122    ///
123    /// PCG requires a symmetric SPD matrix (`upper == lower`), which holds for
124    /// the pressure Poisson equation assembled by `fvm::laplacian`. It converges
125    /// in O(√κ) iterations versus O(κ) for Gauss-Seidel — dramatically faster
126    /// for the elliptic pressure solve. Do **not** use it for the asymmetric
127    /// convection-bearing momentum matrix.
128    ///
129    /// This starts the iteration from `x = 0`. In a transient solver, prefer
130    /// [`solve_cg_with_guess`](Self::solve_cg_with_guess) to warm-start from the
131    /// previous time step's field — near steady state that converges in a
132    /// handful of iterations instead of from scratch every step.
133    pub fn solve_cg(
134        &self,
135        name: impl Into<String>,
136        settings: SolverSettings,
137    ) -> (VolScalarField, SolverPerformance) {
138        self.solve_cg_inner(name, None, settings)
139    }
140
141    /// Solve with PCG, **warm-started** from `initial` (typically the previous
142    /// time step's field).
143    ///
144    /// The DIC-preconditioned CG begins from `initial.internal` instead of zero.
145    /// For a transient run approaching steady state the solution barely changes
146    /// between steps, so the starting residual is tiny and the solve completes
147    /// in very few iterations — often zero. See
148    /// [`conjugate_gradient`](crate::ldu_matrix::conjugate_gradient) for the
149    /// preconditioner and warm-start details.
150    pub fn solve_cg_with_guess(
151        &self,
152        name: impl Into<String>,
153        initial: &VolScalarField,
154        settings: SolverSettings,
155    ) -> (VolScalarField, SolverPerformance) {
156        self.solve_cg_inner(name, Some(initial.internal.as_slice()), settings)
157    }
158
159    fn solve_cg_inner(
160        &self,
161        name: impl Into<String>,
162        x0: Option<&[f64]>,
163        settings: SolverSettings,
164    ) -> (VolScalarField, SolverPerformance) {
165        let b: Vec<f64> = self.source.iter().copied().collect();
166        let (x, perf) = conjugate_gradient(&self.ldu, &b, x0, &settings);
167
168        let boundary = self
169            .mesh
170            .patches
171            .iter()
172            .map(|p| PatchField::zero_gradient(p.size))
173            .collect();
174        let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
175        (field, perf)
176    }
177
178    /// Solve the system with GAMG (algebraic multigrid), cold-started from
179    /// `x = 0`.
180    ///
181    /// GAMG requires a symmetric SPD matrix (`upper == lower`) — the pressure
182    /// Poisson case. It is near mesh-independent (a handful of V-cycles), unlike
183    /// PCG whose iteration count grows with the mesh, so it is the better choice
184    /// for the pressure solve on fine grids. As with PCG, prefer
185    /// [`solve_gamg_with_guess`](Self::solve_gamg_with_guess) in a transient
186    /// loop to warm-start from the previous time step's field.
187    pub fn solve_gamg(
188        &self,
189        name: impl Into<String>,
190        settings: SolverSettings,
191    ) -> (VolScalarField, SolverPerformance) {
192        self.solve_gamg_inner(name, None, settings)
193    }
194
195    /// Solve with GAMG, **warm-started** from `initial` (typically the previous
196    /// time step's field). See [`gamg`](crate::ldu_matrix::gamg) for the
197    /// multigrid algorithm and [`solve_gamg`](Self::solve_gamg) for the cold
198    /// variant.
199    pub fn solve_gamg_with_guess(
200        &self,
201        name: impl Into<String>,
202        initial: &VolScalarField,
203        settings: SolverSettings,
204    ) -> (VolScalarField, SolverPerformance) {
205        self.solve_gamg_inner(name, Some(initial.internal.as_slice()), settings)
206    }
207
208    fn solve_gamg_inner(
209        &self,
210        name: impl Into<String>,
211        x0: Option<&[f64]>,
212        settings: SolverSettings,
213    ) -> (VolScalarField, SolverPerformance) {
214        let b: Vec<f64> = self.source.iter().copied().collect();
215        let (x, perf) = gamg(&self.ldu, &b, x0, &settings);
216
217        let boundary = self
218            .mesh
219            .patches
220            .iter()
221            .map(|p| PatchField::zero_gradient(p.size))
222            .collect();
223        let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
224        (field, perf)
225    }
226
227    // ── Operator helpers (used by fvm:: in Layer 3) ────────────────────────
228
229    /// Add `coeff * I` to the diagonal (e.g. from a time derivative term).
230    pub fn add_to_diag(&mut self, coeff: &Field<f64>) {
231        for (d, &c) in self.ldu.diag.iter_mut().zip(coeff.iter()) {
232            *d += c;
233        }
234    }
235
236    /// Add `coeff[c]` to the source at cell `c`.
237    pub fn add_to_source(&mut self, term: &Field<f64>) {
238        self.source += term.clone();
239    }
240
241    /// Add upper/lower contributions from a face (used by fvm::laplacian etc.).
242    pub fn add_face_coeff(&mut self, face: usize, coeff: f64) {
243        let o = self.ldu.owner[face];
244        let n = self.ldu.neighbour[face];
245        // Laplacian: upper[f] = lower[f] = -coeff (off-diagonal negative)
246        self.ldu.upper[face] -= coeff;
247        self.ldu.lower[face] -= coeff;
248        self.ldu.diag[o] += coeff;
249        self.ldu.diag[n] += coeff;
250    }
251
252    /// Pin one cell to a reference value — fixes the singular pressure matrix
253    /// in a closed domain (no fixed-pressure BCs).
254    ///
255    /// Adds a large coefficient to `diag[cell]` and a matching term to
256    /// `source[cell]` so that the solution is forced toward `value`.
257    pub fn set_reference(&mut self, cell: usize, value: f64) {
258        self.ldu.diag[cell] += 1e30;
259        self.source[cell] += 1e30 * value;
260    }
261
262    /// Diagonal coefficient per cell: `A[c] = diag[c]`.
263    ///
264    /// Used in PISO: `rAU = 1 / pEqn.a_field()`.
265    pub fn a_field(&self) -> VolScalarField {
266        let boundary = self
267            .mesh
268            .patches
269            .iter()
270            .map(|p| PatchField::zero_gradient(p.size))
271            .collect();
272        VolScalarField::new(
273            "A",
274            self.mesh.clone(),
275            Field::new(self.ldu.diag.clone()),
276            boundary,
277        )
278    }
279
280    /// Off-diagonal residual: `H[c] = source[c] − Σ off-diag · x`.
281    ///
282    /// For a zero initial guess `x = 0` this is just `source`.
283    /// Used in pressure correction: `H = pEqn.h_field(p)`.
284    pub fn h_field(&self, x: &VolScalarField) -> VolScalarField {
285        let n = self.mesh.n_cells;
286        let mut h = vec![0.0_f64; n];
287        for c in 0..n {
288            h[c] = self.source[c];
289        }
290        for f in 0..self.mesh.n_internal_faces {
291            let o = self.ldu.owner[f];
292            let nb = self.ldu.neighbour[f];
293            h[o] -= x.internal[nb] * self.ldu.upper[f];
294            h[nb] -= x.internal[o] * self.ldu.lower[f];
295        }
296        let boundary = self
297            .mesh
298            .patches
299            .iter()
300            .map(|p| PatchField::zero_gradient(p.size))
301            .collect();
302        VolScalarField::new("H", self.mesh.clone(), Field::new(h), boundary)
303    }
304}
305
306// ── Arithmetic ────────────────────────────────────────────────────────────────
307
308impl Add for FvMatrix {
309    type Output = Self;
310    fn add(mut self, rhs: Self) -> Self {
311        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
312            *a += b;
313        }
314        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
315            *a += b;
316        }
317        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
318            *a += b;
319        }
320        self.source += rhs.source;
321        self
322    }
323}
324
325impl Sub for FvMatrix {
326    type Output = Self;
327    fn sub(mut self, rhs: Self) -> Self {
328        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
329            *a -= b;
330        }
331        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
332            *a -= b;
333        }
334        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
335            *a -= b;
336        }
337        self.source -= rhs.source;
338        self
339    }
340}
341
342impl Neg for FvMatrix {
343    type Output = Self;
344    fn neg(mut self) -> Self {
345        for x in self.ldu.diag.iter_mut() {
346            *x = -*x;
347        }
348        for x in self.ldu.lower.iter_mut() {
349            *x = -*x;
350        }
351        for x in self.ldu.upper.iter_mut() {
352            *x = -*x;
353        }
354        self.source = -self.source;
355        self
356    }
357}
358
359impl AddAssign for FvMatrix {
360    fn add_assign(&mut self, rhs: Self) {
361        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
362            *a += b;
363        }
364        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
365            *a += b;
366        }
367        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
368            *a += b;
369        }
370        self.source += rhs.source;
371    }
372}
373
374impl SubAssign for FvMatrix {
375    fn sub_assign(&mut self, rhs: Self) {
376        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
377            *a -= b;
378        }
379        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
380            *a -= b;
381        }
382        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
383            *a -= b;
384        }
385        self.source -= rhs.source;
386    }
387}
388
389// ── Tests ─────────────────────────────────────────────────────────────────────
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::mesh::fv_mesh::{BoundaryPatch, FvMeshBuilder, PatchKind};
395    use crate::primitives::Vector3;
396    use approx::assert_relative_eq;
397
398    fn unit_mesh() -> Arc<FvMesh> {
399        Arc::new(
400            FvMeshBuilder::new()
401                .n_cells(2)
402                .n_internal_faces(1)
403                .owner(vec![0, 1, 0])
404                .neighbour(vec![1])
405                .patches(vec![
406                    BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
407                    BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
408                ])
409                .cell_volumes(vec![1.0, 1.0])
410                .cell_centres(vec![
411                    Vector3::new(0.25, 0.0, 0.0),
412                    Vector3::new(0.75, 0.0, 0.0),
413                ])
414                .face_area_vectors(vec![
415                    Vector3::new(1.0, 0.0, 0.0),
416                    Vector3::new(1.0, 0.0, 0.0),
417                    Vector3::new(-1.0, 0.0, 0.0),
418                ])
419                .face_centres(vec![
420                    Vector3::new(0.5, 0.0, 0.0),
421                    Vector3::new(1.0, 0.0, 0.0),
422                    Vector3::new(0.0, 0.0, 0.0),
423                ])
424                .build()
425                .unwrap(),
426        )
427    }
428
429    #[test]
430    fn solve_diagonal_system() {
431        let mesh = unit_mesh();
432        let mut mat = FvMatrix::new(mesh);
433
434        // Set A = [[3,0],[0,5]], b = [6, 10]  → x = [2, 2]
435        mat.ldu.diag[0] = 3.0;
436        mat.ldu.diag[1] = 5.0;
437        mat.source = Field::new(vec![6.0, 10.0]);
438
439        let (phi, perf) = mat.solve("phi", SolverSettings::default());
440        assert!(
441            perf.converged,
442            "did not converge: residual = {}",
443            perf.final_residual
444        );
445        assert_relative_eq!(phi.internal[0], 2.0, epsilon = 1e-6);
446        assert_relative_eq!(phi.internal[1], 2.0, epsilon = 1e-6);
447    }
448
449    #[test]
450    fn add_face_coeff_symmetrises_diag() {
451        let mesh = unit_mesh();
452        let mut mat = FvMatrix::new(mesh);
453        mat.add_face_coeff(0, 1.0);
454        // owner = 0, neighbour = 1 → diag[0] += 1, diag[1] += 1
455        assert_relative_eq!(mat.ldu.diag[0], 1.0);
456        assert_relative_eq!(mat.ldu.diag[1], 1.0);
457        assert_relative_eq!(mat.ldu.upper[0], -1.0);
458        assert_relative_eq!(mat.ldu.lower[0], -1.0);
459    }
460}