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