Skip to main content

outram_foam_basic_lib/matrix/
square_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
22/// Error type for `SquareMatrix::solve`.
23#[derive(Debug, Clone, PartialEq)]
24pub enum MatrixError {
25    /// The matrix is exactly singular: the LU decomposition found a zero pivot
26    /// at the given column (the entire remaining column was zero).
27    Singular { col: usize },
28}
29
30impl std::fmt::Display for MatrixError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            MatrixError::Singular { col } => {
34                write!(f, "matrix is singular: zero pivot at column {col}")
35            }
36        }
37    }
38}
39
40impl std::error::Error for MatrixError {}
41
42/// Row-major n×n dense matrix of `f64`. Maps to `Foam::scalarSquareMatrix`.
43///
44/// LU decomposition uses Crout's algorithm with scaled partial pivoting,
45/// matching `Foam::LUDecompose(scalarSquareMatrix&, labelList&)`.
46#[derive(Debug, Clone)]
47pub struct SquareMatrix {
48    n: usize,
49    data: Vec<f64>,
50}
51
52impl SquareMatrix {
53    pub fn new(n: usize) -> Self {
54        Self {
55            n,
56            data: vec![0.0; n * n],
57        }
58    }
59
60    pub fn n(&self) -> usize {
61        self.n
62    }
63
64    #[inline]
65    pub fn get(&self, i: usize, j: usize) -> f64 {
66        self.data[i * self.n + j]
67    }
68
69    #[inline]
70    pub fn set(&mut self, i: usize, j: usize, v: f64) {
71        self.data[i * self.n + j] = v;
72    }
73
74    #[inline]
75    pub fn add(&mut self, i: usize, j: usize, v: f64) {
76        self.data[i * self.n + j] += v;
77    }
78
79    pub fn fill_zero(&mut self) {
80        self.data.iter_mut().for_each(|x| *x = 0.0);
81    }
82
83    /// In-place LU decomposition with scaled partial pivoting.
84    ///
85    /// After the call, `self` holds the combined LU factors (lower strictly
86    /// triangular, upper including diagonal). Returns the pivot-row indices.
87    pub fn lu_decompose(&mut self) -> Vec<usize> {
88        let n = self.n;
89        let mut pivot = vec![0usize; n];
90
91        // Row scaling factors: 1 / max(|row|)
92        let mut vv: Vec<f64> = (0..n)
93            .map(|i| {
94                let mx = self.data[i * n..(i + 1) * n]
95                    .iter()
96                    .map(|x| x.abs())
97                    .fold(0.0_f64, f64::max);
98                if mx > 0.0 {
99                    1.0 / mx
100                } else {
101                    0.0
102                }
103            })
104            .collect();
105
106        for j in 0..n {
107            // Update upper triangle elements above diagonal (rows i < j)
108            for i in 0..j {
109                let mut sum = self.data[i * n + j];
110                for k in 0..i {
111                    sum -= self.data[i * n + k] * self.data[k * n + j];
112                }
113                self.data[i * n + j] = sum;
114            }
115
116            // Update diagonal and lower: find pivot simultaneously
117            let mut i_max = j;
118            let mut largest = 0.0_f64;
119            for i in j..n {
120                let mut sum = self.data[i * n + j];
121                for k in 0..j {
122                    sum -= self.data[i * n + k] * self.data[k * n + j];
123                }
124                self.data[i * n + j] = sum;
125                let tmp = vv[i] * sum.abs();
126                if tmp >= largest {
127                    largest = tmp;
128                    i_max = i;
129                }
130            }
131
132            // Swap rows j ↔ i_max
133            pivot[j] = i_max;
134            if j != i_max {
135                for k in 0..n {
136                    self.data.swap(j * n + k, i_max * n + k);
137                }
138                vv[i_max] = vv[j];
139            }
140
141            // Guard against exact singularity
142            if self.data[j * n + j] == 0.0 {
143                self.data[j * n + j] = f64::EPSILON;
144            }
145
146            // Scale column below diagonal (store L multipliers)
147            if j < n - 1 {
148                let r = 1.0 / self.data[j * n + j];
149                for i in (j + 1)..n {
150                    self.data[i * n + j] *= r;
151                }
152            }
153        }
154
155        pivot
156    }
157
158    /// Solve `LU·x = b` in-place (`b` is overwritten with the solution).
159    ///
160    /// Must be called after `lu_decompose`. Matches
161    /// `Foam::LUBacksubstitute(scalarSquareMatrix&, labelList&, List<scalar>&)`.
162    pub fn lu_back_substitute(&self, pivot: &[usize], b: &mut Vec<f64>) {
163        let n = self.n;
164
165        // Forward substitution with pivoting (lazy first-nonzero optimisation)
166        let mut first_nz: Option<usize> = None;
167        for i in 0..n {
168            let ip = pivot[i];
169            let mut sum = b[ip];
170            b[ip] = b[i];
171            if let Some(start) = first_nz {
172                for j in start..i {
173                    sum -= self.data[i * n + j] * b[j];
174                }
175            } else if sum != 0.0 {
176                first_nz = Some(i);
177            }
178            b[i] = sum;
179        }
180
181        // Back substitution
182        for i in (0..n).rev() {
183            let mut sum = b[i];
184            for j in (i + 1)..n {
185                sum -= self.data[i * n + j] * b[j];
186            }
187            b[i] = sum / self.data[i * n + i];
188        }
189    }
190
191    /// Convenience: decompose a copy and solve `A·x = b`.
192    ///
193    /// Returns `Err(MatrixError::Singular)` if the matrix has an exactly-zero
194    /// pivot column (the LU sentinel `f64::EPSILON` was written to the diagonal).
195    /// Ill-conditioned but non-singular matrices are not detected; for those,
196    /// check the residual `‖Ax − b‖` manually.
197    pub fn solve(&self, rhs: &[f64]) -> Result<Vec<f64>, MatrixError> {
198        let mut a = self.clone();
199        let pivot = a.lu_decompose();
200        // lu_decompose writes exactly f64::EPSILON to the diagonal when the
201        // pivot column was entirely zero — detect that sentinel here.
202        for i in 0..self.n {
203            if a.data[i * self.n + i] == f64::EPSILON {
204                return Err(MatrixError::Singular { col: i });
205            }
206        }
207        let mut b = rhs.to_vec();
208        a.lu_back_substitute(&pivot, &mut b);
209        Ok(b)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn lu_solve_2x2() {
219        // 2x + y = 5,  x + 3y = 10  →  x = 1, y = 3
220        let mut m = SquareMatrix::new(2);
221        m.set(0, 0, 2.0);
222        m.set(0, 1, 1.0);
223        m.set(1, 0, 1.0);
224        m.set(1, 1, 3.0);
225        let x = m.solve(&[5.0, 10.0]).unwrap();
226        assert!((x[0] - 1.0).abs() < 1e-12, "x={}", x[0]);
227        assert!((x[1] - 3.0).abs() < 1e-12, "y={}", x[1]);
228    }
229
230    #[test]
231    fn lu_solve_3x3() {
232        // A = [2 1 1; 1 3 1; 1 1 4], RHS chosen so solution = (1,2,3)
233        // verify: 2+2+3=7, 1+6+3=10, 1+2+12=15
234        let mut m = SquareMatrix::new(3);
235        m.set(0, 0, 2.0);
236        m.set(0, 1, 1.0);
237        m.set(0, 2, 1.0);
238        m.set(1, 0, 1.0);
239        m.set(1, 1, 3.0);
240        m.set(1, 2, 1.0);
241        m.set(2, 0, 1.0);
242        m.set(2, 1, 1.0);
243        m.set(2, 2, 4.0);
244        let x = m.solve(&[7.0, 10.0, 15.0]).unwrap();
245        assert!((x[0] - 1.0).abs() < 1e-12, "x={}", x[0]);
246        assert!((x[1] - 2.0).abs() < 1e-12, "y={}", x[1]);
247        assert!((x[2] - 3.0).abs() < 1e-12, "z={}", x[2]);
248    }
249
250    #[test]
251    fn lu_solve_needs_pivoting() {
252        // First row has a zero on the diagonal — requires pivoting
253        // 0·x + 1·y = 3,  2·x + 0·y = 4  →  x=2, y=3
254        let mut m = SquareMatrix::new(2);
255        m.set(0, 0, 0.0);
256        m.set(0, 1, 1.0);
257        m.set(1, 0, 2.0);
258        m.set(1, 1, 0.0);
259        let x = m.solve(&[3.0, 4.0]).unwrap();
260        assert!((x[0] - 2.0).abs() < 1e-12, "x={}", x[0]);
261        assert!((x[1] - 3.0).abs() < 1e-12, "y={}", x[1]);
262    }
263
264    #[test]
265    fn lu_identity() {
266        let mut m = SquareMatrix::new(3);
267        m.set(0, 0, 1.0);
268        m.set(1, 1, 1.0);
269        m.set(2, 2, 1.0);
270        let x = m.solve(&[7.0, -2.0, 5.0]).unwrap();
271        assert!((x[0] - 7.0).abs() < 1e-12);
272        assert!((x[1] + 2.0).abs() < 1e-12);
273        assert!((x[2] - 5.0).abs() < 1e-12);
274    }
275
276    #[test]
277    fn singular_matrix_returns_err() {
278        // A = [[1, 1], [1, 1]] — rank 1, col 1 of L is all-zero after elimination
279        let mut m = SquareMatrix::new(2);
280        m.set(0, 0, 1.0);
281        m.set(0, 1, 1.0);
282        m.set(1, 0, 1.0);
283        m.set(1, 1, 1.0);
284        let result = m.solve(&[2.0, 2.0]);
285        assert!(
286            matches!(result, Err(MatrixError::Singular { .. })),
287            "expected Singular, got {:?}",
288            result
289        );
290    }
291
292    #[test]
293    fn fully_zero_matrix_returns_err() {
294        let m = SquareMatrix::new(3); // all zeros
295        let result = m.solve(&[1.0, 0.0, 0.0]);
296        assert!(
297            matches!(result, Err(MatrixError::Singular { col: 0 })),
298            "expected Singular at col 0, got {:?}",
299            result
300        );
301    }
302
303    #[test]
304    fn hilbert_5x5_residual_acceptable() {
305        // Hilbert matrix H[i,j] = 1/(i+j+1) — condition number ≈ 5e5 for n=5.
306        // f64 LU should still give ‖Ax − b‖/‖b‖ < 1e-8.
307        let n = 5;
308        let mut m = SquareMatrix::new(n);
309        for i in 0..n {
310            for j in 0..n {
311                m.set(i, j, 1.0 / (i + j + 1) as f64);
312            }
313        }
314        let b: Vec<f64> = (0..n).map(|_| 1.0).collect();
315        let x = m
316            .solve(&b)
317            .expect("Hilbert 5×5 should not be detected as singular");
318
319        // Compute residual r = Ax - b using the original matrix (reconstruct it).
320        let mut m2 = SquareMatrix::new(n);
321        for i in 0..n {
322            for j in 0..n {
323                m2.set(i, j, 1.0 / (i + j + 1) as f64);
324            }
325        }
326        let mut r_norm_sq = 0.0_f64;
327        let mut b_norm_sq = 0.0_f64;
328        for i in 0..n {
329            let ax_i: f64 = (0..n).map(|j| m2.get(i, j) * x[j]).sum();
330            r_norm_sq += (ax_i - b[i]).powi(2);
331            b_norm_sq += b[i].powi(2);
332        }
333        let rel_residual = r_norm_sq.sqrt() / b_norm_sq.sqrt();
334        assert!(
335            rel_residual < 1e-8,
336            "relative residual {rel_residual:.3e} too large"
337        );
338    }
339
340    #[test]
341    fn scaled_pivoting_large_row_contrast() {
342        // Row 0 has magnitude ~1e8, row 1 has magnitude ~1.
343        // Scaled partial pivoting selects row 0 by scaled magnitude = 1/1e8 vs 1/1,
344        // which differs from naïve partial pivoting. Both should give the correct answer;
345        // this test verifies the result is accurate despite the magnitude contrast.
346        // A = [[1e8, 1], [1, 1e8]], solution x = [1, 1]:
347        //   row 0: 1e8 + 1 = 1e8+1 ✓
348        //   row 1: 1 + 1e8 = 1e8+1 ✓
349        let mut m = SquareMatrix::new(2);
350        m.set(0, 0, 1.0e8);
351        m.set(0, 1, 1.0);
352        m.set(1, 0, 1.0);
353        m.set(1, 1, 1.0e8);
354        let rhs = [1.0e8 + 1.0, 1.0e8 + 1.0];
355        let x = m.solve(&rhs).unwrap();
356        assert!((x[0] - 1.0).abs() < 1e-6, "x[0]={}", x[0]);
357        assert!((x[1] - 1.0).abs() < 1e-6, "x[1]={}", x[1]);
358    }
359
360    #[test]
361    fn lu_multiple_rhs() {
362        // A = [3 1; 1 2]. Solve two RHS against the same LU factorisation.
363        // RHS (5,5) → sol (1,2): 3+2=5 ✓, 1+4=5 ✓
364        // RHS (13,11) → sol (3,4): 9+4=13 ✓, 3+8=11 ✓
365        let mut m = SquareMatrix::new(2);
366        m.set(0, 0, 3.0);
367        m.set(0, 1, 1.0);
368        m.set(1, 0, 1.0);
369        m.set(1, 1, 2.0);
370        let pivot = m.lu_decompose();
371
372        let mut b1 = vec![5.0, 5.0];
373        m.lu_back_substitute(&pivot, &mut b1);
374        assert!((b1[0] - 1.0).abs() < 1e-12, "b1[0]={}", b1[0]);
375        assert!((b1[1] - 2.0).abs() < 1e-12, "b1[1]={}", b1[1]);
376
377        let mut b2 = vec![13.0, 11.0];
378        m.lu_back_substitute(&pivot, &mut b2);
379        assert!((b2[0] - 3.0).abs() < 1e-12, "b2[0]={}", b2[0]);
380        assert!((b2[1] - 4.0).abs() < 1e-12, "b2[1]={}", b2[1]);
381    }
382}