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}