Skip to main content

outram_foam_basic_lib/ldu_matrix/
fv_vector_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::fv_matrix::{SolverPerformance, SolverSettings};
26use super::ldu_matrix::LduMatrix;
27use super::solvers::gauss_seidel::gauss_seidel as gs;
28use super::solvers::krylov_solve::{krylov_solve, KrylovMethod, KrylovOptions};
29use crate::fields::boundary::bc::PatchField;
30use crate::fields::field::Field;
31use crate::fields::vol_field::{VolScalarField, VolVectorField};
32use crate::mesh::fv_mesh::FvMesh;
33use crate::primitives::Vector3;
34
35/// Implicit vector equation `A·U = b` for a `VolVectorField`.
36///
37/// Mirrors `Foam::fvVectorMatrix` (`fvMatrix<vector>`).
38///
39/// The LDU coefficients are **scalar** — they multiply the entire velocity
40/// vector equally in all three directions.  The source vector is a
41/// `Field<Vector3>`.  Solving decomposes into three independent scalar
42/// Gauss-Seidel solves (one per component).
43#[derive(Debug, Clone)]
44pub struct FvVectorMatrix {
45    /// Mesh the equation is defined on (shares the face addressing).
46    pub mesh: Arc<FvMesh>,
47    /// Scalar LDU coefficients of the operator `A` (shared by all 3 components).
48    pub ldu: LduMatrix,
49    /// Right-hand-side vector source per cell, length `n_cells`.
50    pub source: Field<Vector3>,
51}
52
53impl FvVectorMatrix {
54    /// Allocate a zero-initialised vector matrix for `mesh` (zero coefficients,
55    /// zero source).
56    ///
57    /// As with [`FvMatrix::new`](crate::ldu_matrix::FvMatrix::new), the LDU face
58    /// addressing holds the internal faces, then one slot per
59    /// [`CyclicCoupling`](crate::mesh::CyclicCoupling), then one slot per
60    /// [`AmiWeight`](crate::mesh::AmiWeight) of each
61    /// [`AmiCoupling`](crate::mesh::AmiCoupling), so every vector matrix on a
62    /// given mesh shares one structure and the `+`/`−` operators line up.
63    pub fn new(mesh: Arc<FvMesh>) -> Self {
64        let n_cells = mesh.n_cells;
65        let mut owner = mesh.owner[..mesh.n_internal_faces].to_vec();
66        let mut neighbour = mesh.neighbour.clone();
67        for cc in &mesh.cyclic_couplings {
68            owner.push(cc.owner);
69            neighbour.push(cc.neighbour);
70        }
71        // Non-conformal (AMI) seams: one LDU face per weighted (target, source)
72        // pair, appended after the cyclic couplings in `ami_couplings` order.
73        for cc in &mesh.ami_couplings {
74            for w in &cc.weights {
75                owner.push(cc.target_cell);
76                neighbour.push(w.source_cell);
77            }
78        }
79        Self {
80            ldu: LduMatrix::new(n_cells, owner, neighbour),
81            source: Field::from_fn(n_cells, |_| Vector3::ZERO),
82            mesh,
83        }
84    }
85
86    /// Add `coeff[c]` to the diagonal of cell `c` (e.g. a time-derivative term).
87    pub fn add_to_diag(&mut self, coeff: &Field<f64>) {
88        for c in 0..self.mesh.n_cells {
89            self.ldu.diag[c] += coeff[c];
90        }
91    }
92
93    /// Add `term[c]` to the vector source of cell `c`.
94    pub fn add_to_source(&mut self, term: &Field<Vector3>) {
95        for c in 0..self.mesh.n_cells {
96            self.source[c] = self.source[c] + term[c];
97        }
98    }
99
100    /// Pin one cell's velocity to a fixed value (reference cell for closed domains).
101    pub fn set_reference(&mut self, cell: usize, value: Vector3) {
102        self.ldu.diag[cell] += 1e30;
103        self.source[cell] = self.source[cell] + value * 1e30;
104    }
105
106    /// Diagonal coefficient per cell: `A[c] = diag[c]`.
107    ///
108    /// Used in PISO: `rAU = 1 / UEqn.a_field()`.
109    pub fn a_field(&self) -> VolScalarField {
110        let mesh = self.mesh.clone();
111        let boundary = mesh
112            .patches
113            .iter()
114            .map(|p| PatchField::zero_gradient(p.size))
115            .collect();
116        VolScalarField::new(
117            "A",
118            mesh.clone(),
119            Field::new(self.ldu.diag.clone()),
120            boundary,
121        )
122    }
123
124    /// Off-diagonal + source residual: `H[c] = source[c] − Σ off-diag · U`.
125    ///
126    /// For a zero field x this returns `source[c]` directly.
127    /// Used in PISO: `HbyA = rAU * UEqn.h_field(U)`.
128    pub fn h_field(&self, u: &VolVectorField) -> VolVectorField {
129        let mesh = self.mesh.clone();
130        let n = mesh.n_cells;
131        let mut h = vec![Vector3::ZERO; n];
132        for c in 0..n {
133            h[c] = self.source[c];
134        }
135        // Full LDU face count (internal + appended cyclic couplings) so
136        // periodic-seam off-diagonals contribute to H.
137        for f in 0..self.ldu.n_internal_faces {
138            let o = self.ldu.owner[f];
139            let nb = self.ldu.neighbour[f];
140            h[o] = h[o] - u.internal[nb] * self.ldu.upper[f];
141            h[nb] = h[nb] - u.internal[o] * self.ldu.lower[f];
142        }
143        let boundary = mesh
144            .patches
145            .iter()
146            .map(|p| PatchField::zero_gradient_vec(p.size))
147            .collect();
148        VolVectorField::new("H", mesh, Field::new(h), boundary)
149    }
150
151    /// Solve each component (x, y, z) as an independent scalar Gauss-Seidel problem.
152    pub fn solve(
153        &self,
154        name: &str,
155        settings: SolverSettings,
156    ) -> (VolVectorField, SolverPerformance) {
157        let mesh = self.mesh.clone();
158        let n = mesh.n_cells;
159
160        let bx: Vec<f64> = (0..n).map(|c| self.source[c].x).collect();
161        let by: Vec<f64> = (0..n).map(|c| self.source[c].y).collect();
162        let bz: Vec<f64> = (0..n).map(|c| self.source[c].z).collect();
163
164        let mut xx = vec![0.0_f64; n];
165        let mut xy = vec![0.0_f64; n];
166        let mut xz = vec![0.0_f64; n];
167
168        let (ix, rx) = gs(
169            &self.ldu,
170            &bx,
171            &mut xx,
172            settings.tolerance,
173            settings.max_iter,
174        );
175        let (iy, ry) = gs(
176            &self.ldu,
177            &by,
178            &mut xy,
179            settings.tolerance,
180            settings.max_iter,
181        );
182        let (iz, rz) = gs(
183            &self.ldu,
184            &bz,
185            &mut xz,
186            settings.tolerance,
187            settings.max_iter,
188        );
189
190        let internal = Field::from_fn(n, |c| Vector3::new(xx[c], xy[c], xz[c]));
191        let boundary = mesh
192            .patches
193            .iter()
194            .map(|p| PatchField::zero_gradient_vec(p.size))
195            .collect();
196        let u = VolVectorField::new(name, mesh, internal, boundary);
197
198        let n_iters = ix.max(iy).max(iz);
199        let final_res = rx.max(ry).max(rz);
200        let perf = SolverPerformance {
201            n_iterations: n_iters,
202            final_residual: final_res,
203            converged: final_res < settings.tolerance,
204        };
205        (u, perf)
206    }
207
208    // ── Asymmetric Krylov solves (the momentum matrix) ─────────────────────
209
210    /// Solve each velocity component with **preconditioned BiCGStab**,
211    /// cold-started from `U = 0`.
212    ///
213    /// The momentum matrix assembled by `fvm::div_vec(phi, U) −
214    /// fvm::laplacian_vec(nu, U)` is **asymmetric** — upwind convection puts the
215    /// face flux on the donor side only, so `lower[f] != upper[f]`. That rules
216    /// out PCG and GAMG, and before this method the only option was the
217    /// Gauss-Seidel [`solve`](Self::solve), whose sweep count grows like the
218    /// matrix condition number. With the default [`KrylovOptions`] this is
219    /// ILU(0)-preconditioned BiCGStab — the analogue of OpenFOAM's `PBiCGStab`
220    /// with `DILU` on the `U` equation.
221    ///
222    /// Because the LDU coefficients are scalar and identical for all three
223    /// components, this runs three independent scalar Krylov solves sharing one
224    /// preconditioner build per component. The returned
225    /// [`SolverPerformance`] reports the **worst** of the three: the largest
226    /// iteration count and the largest relative 2-norm residual
227    /// `||b − A·U||₂ / ||b||₂`, and `converged` only if all three converged.
228    ///
229    /// Units: `U` is a velocity `[m·s⁻¹]`; the matrix and source carry whatever
230    /// units the assembling operators produced. The solve itself is
231    /// dimensionless.
232    pub fn solve_bicgstab(
233        &self,
234        name: &str,
235        options: KrylovOptions,
236        settings: SolverSettings,
237    ) -> (VolVectorField, SolverPerformance) {
238        self.solve_krylov(name, None, KrylovMethod::BiCGStab, options, settings)
239    }
240
241    /// Solve each velocity component with **restarted GMRES(m)**, cold-started
242    /// from `U = 0`.
243    ///
244    /// See [`solve_bicgstab`](Self::solve_bicgstab) for why an asymmetric solver
245    /// is needed at all. GMRES cannot break down and its residual is monotone,
246    /// but it stores `options.restart` basis vectors per component; prefer it
247    /// when BiCGStab reports `converged = false`.
248    pub fn solve_gmres(
249        &self,
250        name: &str,
251        options: KrylovOptions,
252        settings: SolverSettings,
253    ) -> (VolVectorField, SolverPerformance) {
254        self.solve_krylov(name, None, KrylovMethod::Gmres, options, settings)
255    }
256
257    /// Solve each velocity component with the Krylov method named by `method`,
258    /// optionally warm-started from `initial` (typically the previous time
259    /// step's velocity field).
260    ///
261    /// The general form behind [`solve_bicgstab`](Self::solve_bicgstab) and
262    /// [`solve_gmres`](Self::solve_gmres). Each component `x`, `y`, `z` of
263    /// `initial` seeds the corresponding scalar solve; `None` starts from zero.
264    pub fn solve_krylov(
265        &self,
266        name: &str,
267        initial: Option<&VolVectorField>,
268        method: KrylovMethod,
269        options: KrylovOptions,
270        settings: SolverSettings,
271    ) -> (VolVectorField, SolverPerformance) {
272        let mesh = self.mesh.clone();
273        let n = mesh.n_cells;
274
275        let b: [Vec<f64>; 3] = [
276            (0..n).map(|c| self.source[c].x).collect(),
277            (0..n).map(|c| self.source[c].y).collect(),
278            (0..n).map(|c| self.source[c].z).collect(),
279        ];
280        let x0: Option<[Vec<f64>; 3]> = initial.map(|f| {
281            [
282                (0..n).map(|c| f.internal[c].x).collect(),
283                (0..n).map(|c| f.internal[c].y).collect(),
284                (0..n).map(|c| f.internal[c].z).collect(),
285            ]
286        });
287
288        let mut sol: [Vec<f64>; 3] = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
289        let mut n_iters = 0usize;
290        let mut final_res = 0.0_f64;
291        let mut converged = true;
292        for (k, item) in sol.iter_mut().enumerate() {
293            let guess = x0.as_ref().map(|g| g[k].as_slice());
294            let (x, perf) = krylov_solve(&self.ldu, &b[k], guess, method, options, &settings);
295            *item = x;
296            n_iters = n_iters.max(perf.n_iterations);
297            final_res = final_res.max(perf.final_residual);
298            converged &= perf.converged;
299        }
300
301        let internal = Field::from_fn(n, |c| Vector3::new(sol[0][c], sol[1][c], sol[2][c]));
302        let boundary = mesh
303            .patches
304            .iter()
305            .map(|p| PatchField::zero_gradient_vec(p.size))
306            .collect();
307        let u = VolVectorField::new(name, mesh, internal, boundary);
308        let perf = SolverPerformance {
309            n_iterations: n_iters,
310            final_residual: final_res,
311            converged,
312        };
313        (u, perf)
314    }
315}
316
317// ── Arithmetic ────────────────────────────────────────────────────────────────
318
319impl Add for FvVectorMatrix {
320    type Output = Self;
321    fn add(mut self, rhs: Self) -> Self {
322        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
323            *a += b;
324        }
325        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
326            *a += b;
327        }
328        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
329            *a += b;
330        }
331        for c in 0..self.source.len() {
332            self.source[c] = self.source[c] + rhs.source[c];
333        }
334        self
335    }
336}
337
338impl Sub for FvVectorMatrix {
339    type Output = Self;
340    fn sub(mut self, rhs: Self) -> Self {
341        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
342            *a -= b;
343        }
344        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
345            *a -= b;
346        }
347        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
348            *a -= b;
349        }
350        for c in 0..self.source.len() {
351            self.source[c] = self.source[c] - rhs.source[c];
352        }
353        self
354    }
355}
356
357impl Neg for FvVectorMatrix {
358    type Output = Self;
359    fn neg(mut self) -> Self {
360        for x in self.ldu.diag.iter_mut() {
361            *x = -*x;
362        }
363        for x in self.ldu.lower.iter_mut() {
364            *x = -*x;
365        }
366        for x in self.ldu.upper.iter_mut() {
367            *x = -*x;
368        }
369        for c in 0..self.source.len() {
370            self.source[c] = -self.source[c];
371        }
372        self
373    }
374}
375
376impl AddAssign for FvVectorMatrix {
377    fn add_assign(&mut self, rhs: Self) {
378        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
379            *a += b;
380        }
381        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
382            *a += b;
383        }
384        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
385            *a += b;
386        }
387        for c in 0..self.source.len() {
388            self.source[c] = self.source[c] + rhs.source[c];
389        }
390    }
391}
392
393impl SubAssign for FvVectorMatrix {
394    fn sub_assign(&mut self, rhs: Self) {
395        for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
396            *a -= b;
397        }
398        for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
399            *a -= b;
400        }
401        for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
402            *a -= b;
403        }
404        for c in 0..self.source.len() {
405            self.source[c] = self.source[c] - rhs.source[c];
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::mesh::fv_mesh::{BoundaryPatch, FvMeshBuilder, PatchKind};
414
415    fn unit_mesh() -> Arc<FvMesh> {
416        Arc::new(
417            FvMeshBuilder::new()
418                .n_cells(2)
419                .n_internal_faces(1)
420                .owner(vec![0, 1, 0])
421                .neighbour(vec![1])
422                .patches(vec![
423                    BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
424                    BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
425                ])
426                .cell_volumes(vec![1.0, 1.0])
427                .cell_centres(vec![
428                    Vector3::new(0.25, 0.0, 0.0),
429                    Vector3::new(0.75, 0.0, 0.0),
430                ])
431                .face_area_vectors(vec![
432                    Vector3::new(1.0, 0.0, 0.0),
433                    Vector3::new(1.0, 0.0, 0.0),
434                    Vector3::new(-1.0, 0.0, 0.0),
435                ])
436                .face_centres(vec![
437                    Vector3::new(0.5, 0.0, 0.0),
438                    Vector3::new(1.0, 0.0, 0.0),
439                    Vector3::new(0.0, 0.0, 0.0),
440                ])
441                .build()
442                .unwrap(),
443        )
444    }
445
446    #[test]
447    fn diagonal_system_solves_vector() {
448        let m = unit_mesh();
449        let mut mat = FvVectorMatrix::new(m.clone());
450        mat.ldu.diag[0] = 2.0;
451        mat.ldu.diag[1] = 3.0;
452        mat.source[0] = Vector3::new(4.0, 6.0, 8.0);
453        mat.source[1] = Vector3::new(6.0, 9.0, 12.0);
454        let (u, perf) = mat.solve("U", SolverSettings::default());
455        assert!(perf.converged, "residual = {}", perf.final_residual);
456        assert!((u.internal[0].x - 2.0).abs() < 1e-8);
457        assert!((u.internal[0].y - 3.0).abs() < 1e-8);
458        assert!((u.internal[1].x - 2.0).abs() < 1e-8);
459    }
460
461    #[test]
462    fn a_field_returns_diagonal() {
463        let m = unit_mesh();
464        let mut mat = FvVectorMatrix::new(m.clone());
465        mat.ldu.diag[0] = 5.0;
466        mat.ldu.diag[1] = 7.0;
467        let a = mat.a_field();
468        assert!((a.internal[0] - 5.0).abs() < 1e-12);
469        assert!((a.internal[1] - 7.0).abs() < 1e-12);
470    }
471}