Skip to main content

outram_foam_basic_lib/krylov/
mod.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3//
4// This file is part of OUTRAM PARK.
5//
6// OUTRAM PARK is free software: you can redistribute it and/or modify it
7// under the terms of the GNU General Public License as published by the
8// Free Software Foundation, either version 3 of the License, or (at your
9// option) any later version.
10//
11// OUTRAM PARK is distributed in the hope that it will be useful, but
12// WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14// General Public License for more details.
15//
16// You should have received a copy of the GNU General Public License along
17// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
18
19//! Asymmetric Krylov iterative solvers and preconditioners for sparse `A x = b`.
20//!
21//! This module complements the crate's existing SPD-only machinery (DIC-PCG and
22//! GAMG in [`crate::ldu_matrix::solvers`]) with the **nonsymmetric** iterative
23//! solvers a Newton–Krylov subsurface-flow solver needs, where the Jacobian is
24//! not symmetric:
25//!
26//! - [`bicgstab`] — preconditioned BiCGStab: fixed work/storage per iteration,
27//!   breakdown-guarded.
28//! - [`gmres`] — restarted, right-preconditioned GMRES(m): residual-minimising,
29//!   robust, `O(m)` storage.
30//!
31//! and three preconditioners dispatched by the [`Preconditioner`] enum (never
32//! trait objects, per the workspace design rules):
33//!
34//! - [`Preconditioner::identity`] — no preconditioning (`M = I`).
35//! - [`Preconditioner::jacobi`] — diagonal scaling; always applicable.
36//! - [`Preconditioner::ilu0`] — genuine ILU(0) incomplete factorisation.
37//!
38//! # Matrix representation and conventions
39//!
40//! All solvers act on [`crate::ldu_matrix::LduMatrix`], the crate's face-addressed
41//! sparse matrix, and use only its `multiply` (SpMV) and `residual` kernels. The
42//! system size `n` is `LduMatrix::n_cells`; all right-hand-side, guess, and
43//! solution slices have length `n`. Every quantity here is a dimensionless `f64`:
44//! a Krylov subspace mixes residuals, search directions and increments that share
45//! no single physical dimension, so no `uom` typing is applied — apply units at
46//! the field/equation layer that assembles the matrix.
47//!
48//! # Convergence
49//!
50//! The stopping test for both solvers is the **relative** residual
51//! `||b − A x||₂ / ||b||₂ ≤ tolerance`. The reported `final_residual` is always the
52//! *true* residual of the returned iterate (recomputed from `A` and `b`), not an
53//! internal estimate. A right-hand side that is exactly zero returns `x = 0`,
54//! `converged = true`, `0` iterations.
55//!
56//! # Example
57//!
58//! ```rust
59//! use outram_foam_basic_lib::ldu_matrix::LduMatrix;
60//! use outram_foam_basic_lib::krylov::{bicgstab, Preconditioner, KrylovSettings};
61//!
62//! // 3-cell chain: cells 0-1 and 1-2 share a face each.
63//! let mut a = LduMatrix::new(3, vec![0, 1], vec![1, 2]);
64//! a.diag = vec![4.0, 4.0, 4.0];
65//! a.lower = vec![-1.0, -1.0];
66//! a.upper = vec![-1.0, -1.0];
67//! let b = vec![1.0, 2.0, 3.0];
68//!
69//! let precond = Preconditioner::jacobi(&a);
70//! let settings = KrylovSettings::default();
71//! let (x, result) = bicgstab(&a, &b, None, &precond, &settings);
72//!
73//! assert!(result.converged);
74//! // Verify: A x ≈ b.
75//! let ax = a.multiply(&x);
76//! for i in 0..3 {
77//!     assert!((ax[i] - b[i]).abs() < 1e-6);
78//! }
79//! ```
80
81mod bicgstab;
82mod gmres;
83mod preconditioner;
84pub mod vecops;
85
86pub use bicgstab::bicgstab;
87pub use gmres::gmres;
88pub use preconditioner::{Ilu0Preconditioner, JacobiPreconditioner};
89
90use crate::ldu_matrix::LduMatrix;
91
92/// Iteration controls shared by [`bicgstab`] and [`gmres`].
93///
94/// All fields are plain scalars with no units.
95#[derive(Debug, Clone, Copy)]
96pub struct KrylovSettings {
97    /// Relative convergence tolerance on `||b − A x||₂ / ||b||₂`. Must be `> 0`;
98    /// typical range `1e-12 .. 1e-4`. Default `1e-8`.
99    pub tolerance: f64,
100    /// Maximum total number of iterations (matrix–vector products) before the
101    /// solver returns unconverged. Default `1000`.
102    pub max_iter: usize,
103    /// GMRES restart length `m` — the Krylov subspace dimension per outer cycle,
104    /// trading memory (`O(m·n)`) against convergence robustness. Ignored by
105    /// BiCGStab. `0` means "no restart" (`m = max_iter`). Default `30`.
106    pub restart: usize,
107}
108
109impl Default for KrylovSettings {
110    /// Defaults: `tolerance = 1e-8`, `max_iter = 1000`, `restart = 30`.
111    fn default() -> Self {
112        Self {
113            tolerance: 1.0e-8,
114            max_iter: 1000,
115            restart: 30,
116        }
117    }
118}
119
120/// Outcome of a Krylov solve.
121///
122/// All fields are plain scalars with no units.
123#[derive(Debug, Clone, Copy)]
124pub struct KrylovResult {
125    /// Number of iterations (matrix–vector products) actually performed.
126    pub n_iterations: usize,
127    /// The **true** relative residual `||b − A x||₂ / ||b||₂` of the returned
128    /// iterate, recomputed from `A` and `b` (dimensionless, `>= 0`).
129    pub final_residual: f64,
130    /// `true` iff `final_residual <= settings.tolerance`.
131    pub converged: bool,
132}
133
134/// Preconditioner `M^{-1} ≈ A^{-1}`, dispatched by enum (no trait objects).
135///
136/// A preconditioner turns a residual `r` into `z = M^{-1} r`, an approximate
137/// error, which the Krylov solvers use to accelerate convergence. Construct one
138/// from the system matrix with [`Preconditioner::jacobi`] or
139/// [`Preconditioner::ilu0`], or use [`Preconditioner::identity`] for none.
140pub enum Preconditioner {
141    /// No preconditioning: `M = I`, so `z = r`.
142    Identity,
143    /// Diagonal (Jacobi) scaling: `z = r / diag(A)`.
144    Jacobi(JacobiPreconditioner),
145    /// ILU(0) incomplete factorisation: `z = (LU)^{-1} r`.
146    Ilu0(Ilu0Preconditioner),
147}
148
149impl Preconditioner {
150    /// Identity preconditioner (`M = I`) — equivalent to no preconditioning.
151    pub fn identity() -> Self {
152        Preconditioner::Identity
153    }
154
155    /// Build a Jacobi (reciprocal-diagonal) preconditioner from `a`.
156    ///
157    /// Cannot break down: a zero diagonal entry is treated as `1.0`. The
158    /// guaranteed-robust choice when ILU(0) is not warranted.
159    pub fn jacobi(a: &LduMatrix) -> Self {
160        Preconditioner::Jacobi(JacobiPreconditioner::new(a))
161    }
162
163    /// Build an ILU(0) preconditioner from `a` (same sparsity pattern as `A`).
164    ///
165    /// A genuine incomplete LU factorisation with a small-pivot guard; see
166    /// [`Ilu0Preconditioner`]. Typically far stronger than Jacobi on
167    /// diagonally-dominant / mildly ill-conditioned systems.
168    pub fn ilu0(a: &LduMatrix) -> Self {
169        Preconditioner::Ilu0(Ilu0Preconditioner::new(a))
170    }
171
172    /// Apply the preconditioner: write `z = M^{-1} r`.
173    ///
174    /// `r` and `z` must both have length `n_cells`. For [`Preconditioner::Identity`]
175    /// this copies `r` into `z`.
176    pub fn apply(&self, r: &[f64], z: &mut [f64]) {
177        debug_assert_eq!(r.len(), z.len(), "Preconditioner::apply length mismatch");
178        match self {
179            Preconditioner::Identity => z.copy_from_slice(r),
180            Preconditioner::Jacobi(p) => preconditioner::jacobi_apply(p, r, z),
181            Preconditioner::Ilu0(p) => preconditioner::ilu0_apply(p, r, z),
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::matrix::SquareMatrix;
190
191    /// Materialise an LduMatrix as a dense SquareMatrix (for cross-checking).
192    fn dense_of(a: &LduMatrix) -> SquareMatrix {
193        let mut m = SquareMatrix::new(a.n_cells);
194        for i in 0..a.n_cells {
195            m.set(i, i, a.diag[i]);
196        }
197        for f in 0..a.n_internal_faces {
198            let o = a.owner[f];
199            let n = a.neighbour[f];
200            m.set(o, n, a.upper[f]);
201            m.set(n, o, a.lower[f]);
202        }
203        m
204    }
205
206    /// Symmetric SPD tridiagonal: diag d, off-diag e (lower==upper).
207    fn spd_tridiag(n: usize, d: f64, e: f64) -> LduMatrix {
208        let mut owner = Vec::new();
209        let mut neigh = Vec::new();
210        for i in 0..n - 1 {
211            owner.push(i);
212            neigh.push(i + 1);
213        }
214        let mut a = LduMatrix::new(n, owner, neigh);
215        for i in 0..n {
216            a.diag[i] = d;
217        }
218        for f in 0..a.n_internal_faces {
219            a.lower[f] = e;
220            a.upper[f] = e;
221        }
222        a
223    }
224
225    /// Nonsymmetric tridiagonal: distinct lower and upper off-diagonals.
226    fn nonsym_tridiag(n: usize, d: f64, lo: f64, up: f64) -> LduMatrix {
227        let mut owner = Vec::new();
228        let mut neigh = Vec::new();
229        for i in 0..n - 1 {
230            owner.push(i);
231            neigh.push(i + 1);
232        }
233        let mut a = LduMatrix::new(n, owner, neigh);
234        for i in 0..n {
235            a.diag[i] = d;
236        }
237        for f in 0..a.n_internal_faces {
238            a.lower[f] = lo;
239            a.upper[f] = up;
240        }
241        a
242    }
243
244    fn rel_err(x: &[f64], y: &[f64]) -> f64 {
245        let mut num = 0.0;
246        let mut den = 0.0;
247        for i in 0..x.len() {
248            num += (x[i] - y[i]).powi(2);
249            den += y[i].powi(2);
250        }
251        (num / den).sqrt()
252    }
253
254    // (1) Small SPD system, known solution, both solvers.
255    #[test]
256    fn spd_known_solution() {
257        let n = 4;
258        let a = spd_tridiag(n, 2.0, -1.0);
259        // Choose x_true, form b = A x.
260        let x_true = vec![1.0, 2.0, 3.0, 4.0];
261        let b = a.multiply(&x_true);
262        let settings = KrylovSettings {
263            tolerance: 1e-10,
264            max_iter: 200,
265            restart: 30,
266        };
267
268        let (xb, rb) = bicgstab(&a, &b, None, &Preconditioner::identity(), &settings);
269        assert!(rb.converged, "bicgstab did not converge: {:?}", rb);
270        assert!(rel_err(&xb, &x_true) < 1e-7);
271
272        let (xg, rg) = gmres(&a, &b, None, &Preconditioner::identity(), &settings);
273        assert!(rg.converged, "gmres did not converge: {:?}", rg);
274        assert!(rel_err(&xg, &x_true) < 1e-7);
275    }
276
277    // (2) Nonsymmetric system solved by both, cross-checked vs dense LU.
278    #[test]
279    fn nonsymmetric_vs_dense_lu() {
280        let n = 6;
281        let a = nonsym_tridiag(n, 4.0, -1.0, -2.0); // lower != upper
282        let b: Vec<f64> = (0..n).map(|i| (i as f64) - 2.5).collect();
283
284        // Dense reference.
285        let dense = dense_of(&a);
286        let x_ref = dense.solve(&b).expect("dense LU failed");
287
288        let settings = KrylovSettings {
289            tolerance: 1e-8,
290            max_iter: 500,
291            restart: 30,
292        };
293
294        for precond in [
295            Preconditioner::identity(),
296            Preconditioner::jacobi(&a),
297            Preconditioner::ilu0(&a),
298        ] {
299            let (xb, rb) = bicgstab(&a, &b, None, &precond, &settings);
300            assert!(rb.converged, "bicgstab unconverged: {:?}", rb);
301            assert!(
302                rel_err(&xb, &x_ref) < 1e-6,
303                "bicgstab mismatch vs dense LU: {}",
304                rel_err(&xb, &x_ref)
305            );
306
307            let (xg, rg) = gmres(&a, &b, None, &precond, &settings);
308            assert!(rg.converged, "gmres unconverged: {:?}", rg);
309            assert!(
310                rel_err(&xg, &x_ref) < 1e-6,
311                "gmres mismatch vs dense LU: {}",
312                rel_err(&xg, &x_ref)
313            );
314        }
315    }
316
317    // (3) Preconditioning helps (or at least still converges) on an
318    //     ill-conditioned-ish system: ILU(0) should not need MORE iterations
319    //     than Identity, and usually far fewer.
320    #[test]
321    fn preconditioning_reduces_iterations() {
322        let n = 40;
323        // Mildly ill-conditioned SPD-ish, diagonally dominant tridiagonal.
324        let a = nonsym_tridiag(n, 2.05, -1.0, -1.0);
325        let b = vec![1.0; n];
326        let settings = KrylovSettings {
327            tolerance: 1e-8,
328            max_iter: 2000,
329            restart: 50,
330        };
331
332        let (_, r_id) = bicgstab(&a, &b, None, &Preconditioner::identity(), &settings);
333        let (_, r_jac) = bicgstab(&a, &b, None, &Preconditioner::jacobi(&a), &settings);
334        let (_, r_ilu) = bicgstab(&a, &b, None, &Preconditioner::ilu0(&a), &settings);
335
336        assert!(r_id.converged && r_jac.converged && r_ilu.converged);
337        // ILU(0) must not be worse than identity, and should help here.
338        assert!(
339            r_ilu.n_iterations <= r_id.n_iterations,
340            "ILU(0) {} vs Identity {} iters",
341            r_ilu.n_iterations,
342            r_id.n_iterations
343        );
344
345        // Same check for GMRES.
346        let (_, g_id) = gmres(&a, &b, None, &Preconditioner::identity(), &settings);
347        let (_, g_ilu) = gmres(&a, &b, None, &Preconditioner::ilu0(&a), &settings);
348        assert!(g_id.converged && g_ilu.converged);
349        assert!(g_ilu.n_iterations <= g_id.n_iterations);
350    }
351
352    // (4) Zero RHS shortcut.
353    #[test]
354    fn zero_rhs() {
355        let a = spd_tridiag(5, 3.0, -1.0);
356        let b = vec![0.0; 5];
357        let s = KrylovSettings::default();
358        let (xb, rb) = bicgstab(&a, &b, None, &Preconditioner::identity(), &s);
359        assert!(rb.converged && rb.n_iterations == 0 && xb.iter().all(|&v| v == 0.0));
360        let (xg, rg) = gmres(&a, &b, None, &Preconditioner::identity(), &s);
361        assert!(rg.converged && rg.n_iterations == 0 && xg.iter().all(|&v| v == 0.0));
362    }
363
364    // (5) Nonzero initial guess is honoured.
365    #[test]
366    fn initial_guess() {
367        let a = spd_tridiag(4, 2.0, -1.0);
368        let x_true = vec![0.5, 1.5, -2.0, 3.0];
369        let b = a.multiply(&x_true);
370        let s = KrylovSettings {
371            tolerance: 1e-10,
372            max_iter: 200,
373            restart: 30,
374        };
375        let guess = x_true.clone();
376        let (x, r) = gmres(&a, &b, Some(&guess), &Preconditioner::ilu0(&a), &s);
377        assert!(r.converged);
378        assert!(rel_err(&x, &x_true) < 1e-7);
379    }
380}