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