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    ///
68    /// # Out of range reads `NaN`, and never panics
69    ///
70    /// `i >= n` or `j >= n` is a caller bug either way. Returning `NaN` rather
71    /// than panicking is what lets this type be lifted verbatim into `petir`,
72    /// which targets bare metal -- there a panic is not a stack trace and a
73    /// non-zero exit, it is the end of the program. `NaN` propagates through
74    /// every later operation and compares unequal to everything, so the bug
75    /// still surfaces, just downstream rather than at the read.
76    #[inline]
77    pub fn get(&self, i: usize, j: usize) -> f64 {
78        match self.data.get(self.offset(i, j)) {
79            Some(&v) => v,
80            None => f64::NAN,
81        }
82    }
83
84    /// Row-major offset of `(i, j)`, saturating rather than wrapping.
85    ///
86    /// Saturation matters: `i * n` can overflow `usize` for an absurd `i`, and
87    /// an overflow panics under `debug_assertions`. Saturating sends it past
88    /// the end of `data` instead, where the bounds check turns it into the
89    /// documented out-of-range behaviour.
90    #[inline]
91    fn offset(&self, i: usize, j: usize) -> usize {
92        i.saturating_mul(self.n).saturating_add(j)
93    }
94
95    /// Set the element in row `i`, column `j` to `v` (0-based indices `< n`).
96    ///
97    /// # Out of range is ignored, and never panics
98    ///
99    /// See [`get`](Self::get) for why this does not panic. A write with
100    /// `i >= n` or `j >= n` is discarded; the matrix is left unchanged.
101    #[inline]
102    pub fn set(&mut self, i: usize, j: usize, v: f64) {
103        let idx = self.offset(i, j);
104        if let Some(slot) = self.data.get_mut(idx) {
105            *slot = v;
106        }
107    }
108
109    /// Add `v` to the element in row `i`, column `j` (0-based indices `< n`).
110    ///
111    /// # Out of range is ignored, and never panics
112    ///
113    /// See [`get`](Self::get). An accumulation with `i >= n` or `j >= n` is
114    /// discarded.
115    #[inline]
116    pub fn add(&mut self, i: usize, j: usize, v: f64) {
117        let idx = self.offset(i, j);
118        if let Some(slot) = self.data.get_mut(idx) {
119            *slot += v;
120        }
121    }
122
123    /// Exchange two entries, ignoring the request if either is out of range.
124    ///
125    /// Used by the row swap in [`lu_decompose`](Self::lu_decompose), where
126    /// `Vec::swap` would panic on a bad index.
127    #[inline]
128    fn swap_entries(&mut self, i1: usize, j1: usize, i2: usize, j2: usize) {
129        let a = self.offset(i1, j1);
130        let b = self.offset(i2, j2);
131        if a < self.data.len() && b < self.data.len() {
132            self.data.swap(a, b);
133        }
134    }
135
136    /// Reset every entry to zero, keeping the same order `n`.
137    pub fn fill_zero(&mut self) {
138        self.data.iter_mut().for_each(|x| *x = 0.0);
139    }
140
141    /// In-place LU decomposition with scaled partial pivoting.
142    ///
143    /// After the call, `self` holds the combined LU factors (lower strictly
144    /// triangular, upper including diagonal). Returns the pivot-row indices.
145    pub fn lu_decompose(&mut self) -> Vec<usize> {
146        let n = self.n;
147        // Grown by `push` inside the j-loop below, which visits j = 0..n in
148        // order, so `pivot[j] = i_max` needs no subscript.
149        let mut pivot: Vec<usize> = Vec::with_capacity(n);
150
151        // Row scaling factors: 1 / max(|row|). `chunks_exact` walks the rows
152        // without a range subscript; its argument must be non-zero, and for
153        // n == 0 the `take` yields nothing either way.
154        let mut vv: Vec<f64> = self
155            .data
156            .chunks_exact(n.max(1))
157            .take(n)
158            .map(|row| {
159                let mx = row.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
160                if mx > 0.0 {
161                    1.0 / mx
162                } else {
163                    0.0
164                }
165            })
166            .collect();
167
168        for j in 0..n {
169            // Update upper triangle elements above diagonal (rows i < j)
170            for i in 0..j {
171                let mut sum = self.get(i, j);
172                for k in 0..i {
173                    sum -= self.get(i, k) * self.get(k, j);
174                }
175                self.set(i, j, sum);
176            }
177
178            // Update diagonal and lower: find pivot simultaneously
179            let mut i_max = j;
180            let mut largest = 0.0_f64;
181            for i in j..n {
182                let mut sum = self.get(i, j);
183                for k in 0..j {
184                    sum -= self.get(i, k) * self.get(k, j);
185                }
186                self.set(i, j, sum);
187                // `i < n == vv.len()`, so the fallback never fires; 0.0 would
188                // simply mean "this row cannot win the pivot".
189                let tmp = vv.get(i).copied().unwrap_or(0.0) * sum.abs();
190                if tmp >= largest {
191                    largest = tmp;
192                    i_max = i;
193                }
194            }
195
196            // Swap rows j ↔ i_max
197            pivot.push(i_max);
198            if j != i_max {
199                for k in 0..n {
200                    self.swap_entries(j, k, i_max, k);
201                }
202                if let Some(scale_j) = vv.get(j).copied() {
203                    if let Some(slot) = vv.get_mut(i_max) {
204                        *slot = scale_j;
205                    }
206                }
207            }
208
209            // Guard against exact singularity
210            if self.get(j, j) == 0.0 {
211                self.set(j, j, f64::EPSILON);
212            }
213
214            // Scale column below diagonal (store L multipliers).
215            // `j + 1 < n` rather than `j < n - 1`: the same condition, without
216            // the subtraction that would wrap for n == 0.
217            if j + 1 < n {
218                let r = 1.0 / self.get(j, j);
219                for i in (j + 1)..n {
220                    let scaled = self.get(i, j) * r;
221                    self.set(i, j, scaled);
222                }
223            }
224        }
225
226        pivot
227    }
228
229    /// Solve `LU·x = b` in-place (`b` is overwritten with the solution).
230    ///
231    /// `pivot` must hold `n` entries and `b` must hold `n` values. A shorter
232    /// one used to panic; it now returns early with `b` partly updated, so
233    /// that this type can be lifted into `petir`, which targets bare metal
234    /// where a panic ends the program. See [`get`](Self::get).
235    ///
236    /// Must be called after `lu_decompose`. Matches
237    /// `Foam::LUBacksubstitute(scalarSquareMatrix&, labelList&, List<scalar>&)`.
238    pub fn lu_back_substitute(&self, pivot: &[usize], b: &mut Vec<f64>) {
239        let n = self.n;
240
241        // Forward substitution with pivoting (lazy first-nonzero optimisation)
242        let mut first_nz: Option<usize> = None;
243        for i in 0..n {
244            // A `pivot` or `b` shorter than `n` is a broken call, and used to
245            // panic here. It now stops instead, leaving `b` partly updated --
246            // see this method's doc comment.
247            let (Some(&ip), Some(b_i)) = (pivot.get(i), b.get(i).copied()) else {
248                return;
249            };
250            let Some(mut sum) = b.get(ip).copied() else {
251                return;
252            };
253            if let Some(slot) = b.get_mut(ip) {
254                *slot = b_i;
255            }
256            if let Some(start) = first_nz {
257                for j in start..i {
258                    sum -= self.get(i, j) * b.get(j).copied().unwrap_or(0.0);
259                }
260            } else if sum != 0.0 {
261                first_nz = Some(i);
262            }
263            if let Some(slot) = b.get_mut(i) {
264                *slot = sum;
265            }
266        }
267
268        // Back substitution
269        for i in (0..n).rev() {
270            let Some(mut sum) = b.get(i).copied() else {
271                return;
272            };
273            for j in (i + 1)..n {
274                sum -= self.get(i, j) * b.get(j).copied().unwrap_or(0.0);
275            }
276            let diagonal = self.get(i, i);
277            if let Some(slot) = b.get_mut(i) {
278                *slot = sum / diagonal;
279            }
280        }
281    }
282
283    /// Convenience: decompose a copy and solve `A·x = b`.
284    ///
285    /// Returns `Err(MatrixError::Singular)` if the matrix has an exactly-zero
286    /// pivot column (the LU sentinel `f64::EPSILON` was written to the diagonal).
287    /// Ill-conditioned but non-singular matrices are not detected; for those,
288    /// check the residual `‖Ax − b‖` manually.
289    pub fn solve(&self, rhs: &[f64]) -> Result<Vec<f64>, MatrixError> {
290        let mut a = self.clone();
291        let pivot = a.lu_decompose();
292        // lu_decompose writes exactly f64::EPSILON to the diagonal when the
293        // pivot column was entirely zero — detect that sentinel here.
294        for i in 0..self.n {
295            if a.get(i, i) == f64::EPSILON {
296                return Err(MatrixError::Singular { col: i });
297            }
298        }
299        let mut b = rhs.to_vec();
300        a.lu_back_substitute(&pivot, &mut b);
301        Ok(b)
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use petir::mathf::RealMath;
308    use super::*;
309
310    #[test]
311    fn lu_solve_2x2() {
312        // 2x + y = 5,  x + 3y = 10  →  x = 1, y = 3
313        let mut m = SquareMatrix::new(2);
314        m.set(0, 0, 2.0);
315        m.set(0, 1, 1.0);
316        m.set(1, 0, 1.0);
317        m.set(1, 1, 3.0);
318        let x = m.solve(&[5.0, 10.0]).unwrap();
319        assert!((x[0] - 1.0).abs() < 1e-12, "x={}", x[0]);
320        assert!((x[1] - 3.0).abs() < 1e-12, "y={}", x[1]);
321    }
322
323    #[test]
324    fn lu_solve_3x3() {
325        // A = [2 1 1; 1 3 1; 1 1 4], RHS chosen so solution = (1,2,3)
326        // verify: 2+2+3=7, 1+6+3=10, 1+2+12=15
327        let mut m = SquareMatrix::new(3);
328        m.set(0, 0, 2.0);
329        m.set(0, 1, 1.0);
330        m.set(0, 2, 1.0);
331        m.set(1, 0, 1.0);
332        m.set(1, 1, 3.0);
333        m.set(1, 2, 1.0);
334        m.set(2, 0, 1.0);
335        m.set(2, 1, 1.0);
336        m.set(2, 2, 4.0);
337        let x = m.solve(&[7.0, 10.0, 15.0]).unwrap();
338        assert!((x[0] - 1.0).abs() < 1e-12, "x={}", x[0]);
339        assert!((x[1] - 2.0).abs() < 1e-12, "y={}", x[1]);
340        assert!((x[2] - 3.0).abs() < 1e-12, "z={}", x[2]);
341    }
342
343    #[test]
344    fn lu_solve_needs_pivoting() {
345        // First row has a zero on the diagonal — requires pivoting
346        // 0·x + 1·y = 3,  2·x + 0·y = 4  →  x=2, y=3
347        let mut m = SquareMatrix::new(2);
348        m.set(0, 0, 0.0);
349        m.set(0, 1, 1.0);
350        m.set(1, 0, 2.0);
351        m.set(1, 1, 0.0);
352        let x = m.solve(&[3.0, 4.0]).unwrap();
353        assert!((x[0] - 2.0).abs() < 1e-12, "x={}", x[0]);
354        assert!((x[1] - 3.0).abs() < 1e-12, "y={}", x[1]);
355    }
356
357    #[test]
358    fn lu_identity() {
359        let mut m = SquareMatrix::new(3);
360        m.set(0, 0, 1.0);
361        m.set(1, 1, 1.0);
362        m.set(2, 2, 1.0);
363        let x = m.solve(&[7.0, -2.0, 5.0]).unwrap();
364        assert!((x[0] - 7.0).abs() < 1e-12);
365        assert!((x[1] + 2.0).abs() < 1e-12);
366        assert!((x[2] - 5.0).abs() < 1e-12);
367    }
368
369    #[test]
370    fn singular_matrix_returns_err() {
371        // A = [[1, 1], [1, 1]] — rank 1, col 1 of L is all-zero after elimination
372        let mut m = SquareMatrix::new(2);
373        m.set(0, 0, 1.0);
374        m.set(0, 1, 1.0);
375        m.set(1, 0, 1.0);
376        m.set(1, 1, 1.0);
377        let result = m.solve(&[2.0, 2.0]);
378        assert!(
379            matches!(result, Err(MatrixError::Singular { .. })),
380            "expected Singular, got {:?}",
381            result
382        );
383    }
384
385    #[test]
386    fn fully_zero_matrix_returns_err() {
387        let m = SquareMatrix::new(3); // all zeros
388        let result = m.solve(&[1.0, 0.0, 0.0]);
389        assert!(
390            matches!(result, Err(MatrixError::Singular { col: 0 })),
391            "expected Singular at col 0, got {:?}",
392            result
393        );
394    }
395
396    #[test]
397    fn hilbert_5x5_residual_acceptable() {
398        // Hilbert matrix H[i,j] = 1/(i+j+1) — condition number ≈ 5e5 for n=5.
399        // f64 LU should still give ‖Ax − b‖/‖b‖ < 1e-8.
400        let n = 5;
401        let mut m = SquareMatrix::new(n);
402        for i in 0..n {
403            for j in 0..n {
404                m.set(i, j, 1.0 / (i + j + 1) as f64);
405            }
406        }
407        let b: Vec<f64> = (0..n).map(|_| 1.0).collect();
408        let x = m
409            .solve(&b)
410            .expect("Hilbert 5×5 should not be detected as singular");
411
412        // Compute residual r = Ax - b using the original matrix (reconstruct it).
413        let mut m2 = SquareMatrix::new(n);
414        for i in 0..n {
415            for j in 0..n {
416                m2.set(i, j, 1.0 / (i + j + 1) as f64);
417            }
418        }
419        let mut r_norm_sq = 0.0_f64;
420        let mut b_norm_sq = 0.0_f64;
421        for i in 0..n {
422            let ax_i: f64 = (0..n).map(|j| m2.get(i, j) * x[j]).sum();
423            r_norm_sq += (ax_i - b[i]).r_powi(2);
424            b_norm_sq += b[i].r_powi(2);
425        }
426        let rel_residual = r_norm_sq.sqrt() / b_norm_sq.sqrt();
427        assert!(
428            rel_residual < 1e-8,
429            "relative residual {rel_residual:.3e} too large"
430        );
431    }
432
433    #[test]
434    fn scaled_pivoting_large_row_contrast() {
435        // Row 0 has magnitude ~1e8, row 1 has magnitude ~1.
436        // Scaled partial pivoting selects row 0 by scaled magnitude = 1/1e8 vs 1/1,
437        // which differs from naïve partial pivoting. Both should give the correct answer;
438        // this test verifies the result is accurate despite the magnitude contrast.
439        // A = [[1e8, 1], [1, 1e8]], solution x = [1, 1]:
440        //   row 0: 1e8 + 1 = 1e8+1 ✓
441        //   row 1: 1 + 1e8 = 1e8+1 ✓
442        let mut m = SquareMatrix::new(2);
443        m.set(0, 0, 1.0e8);
444        m.set(0, 1, 1.0);
445        m.set(1, 0, 1.0);
446        m.set(1, 1, 1.0e8);
447        let rhs = [1.0e8 + 1.0, 1.0e8 + 1.0];
448        let x = m.solve(&rhs).unwrap();
449        assert!((x[0] - 1.0).abs() < 1e-6, "x[0]={}", x[0]);
450        assert!((x[1] - 1.0).abs() < 1e-6, "x[1]={}", x[1]);
451    }
452
453    #[test]
454    fn lu_multiple_rhs() {
455        // A = [3 1; 1 2]. Solve two RHS against the same LU factorisation.
456        // RHS (5,5) → sol (1,2): 3+2=5 ✓, 1+4=5 ✓
457        // RHS (13,11) → sol (3,4): 9+4=13 ✓, 3+8=11 ✓
458        let mut m = SquareMatrix::new(2);
459        m.set(0, 0, 3.0);
460        m.set(0, 1, 1.0);
461        m.set(1, 0, 1.0);
462        m.set(1, 1, 2.0);
463        let pivot = m.lu_decompose();
464
465        let mut b1 = vec![5.0, 5.0];
466        m.lu_back_substitute(&pivot, &mut b1);
467        assert!((b1[0] - 1.0).abs() < 1e-12, "b1[0]={}", b1[0]);
468        assert!((b1[1] - 2.0).abs() < 1e-12, "b1[1]={}", b1[1]);
469
470        let mut b2 = vec![13.0, 11.0];
471        m.lu_back_substitute(&pivot, &mut b2);
472        assert!((b2[0] - 3.0).abs() < 1e-12, "b2[0]={}", b2[0]);
473        assert!((b2[1] - 4.0).abs() < 1e-12, "b2[1]={}", b2[1]);
474    }
475}