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}