Skip to main content

gridoxide_sparse/
sparse.rs

1//! Thin internal wrapper around `faer`'s sparse LU, isolating gridoxide's
2//! power-flow math from the specific sparse-solver backend. Both a general
3//! (non-symmetric, since phase-shifting transformers make the admittance
4//! matrix non-Hermitian) complex solve and a real solve are provided, since
5//! Newton-Raphson needs the latter for its Jacobian and the linear
6//! initial-guess warm start needs the former for the Y-bus.
7//!
8//! Each of the two arithmetics comes in a *pattern-fixed* flavour, which
9//! recomputes numeric values on every solve ([`RealSparseSystem`]), and a
10//! *fully-fixed* flavour, which factorizes once and then only moves the
11//! right-hand side ([`ComplexSparseSystem`], [`RealFactorization`]). Which one
12//! a caller wants is decided by whether its matrix changes between solves, not
13//! by how often it solves.
14//!
15//! No `faer` type appears in any signature here: keeping the backend behind
16//! this module is what lets it be swapped without touching the power-flow math.
17
18use faer::Col;
19use faer::prelude::{Reborrow, Solve};
20use faer::sparse::linalg::solvers::{Lu, SymbolicLu};
21use faer::sparse::{Argsort, Pair, SparseColMat, SymbolicSparseColMat, Triplet};
22use num_complex::Complex;
23
24/// The one operation the Newton loop needs from a sparse-LU backend: build a
25/// reusable symbolic factorization from a sparsity pattern once, then
26/// numerically refactorize and solve against that same pattern every
27/// iteration.
28///
29/// Every backend already had this exact pair of inherent methods before this
30/// trait existed — `sparse::RealSparseSystem`, `sparse_klu::KluRealSystem`,
31/// `klu_native::KluNativeSystem` and `sparse_pardiso::PardisoRealSystem` are
32/// all four one-line forwarders. The trait is what lets `newton_raphson_cached`
33/// be a single generic function instead of four near-identical copies of the
34/// same Newton loop, one per backend.
35///
36/// **Precondition, shared by every implementor:** the `entries` passed to
37/// `factor_and_solve` must carry the exact same `(row, col)` pairs in the
38/// exact same order as those passed to `new` — only the values may differ.
39/// Each backend caches a positional mapping from triplet index into its own
40/// CSC layout, so this is positional correspondence, not merely set equality.
41/// `jacobian::JacobianPattern` guarantees it by construction: it emits every
42/// topological neighbor regardless of computed value, in a fixed order
43/// derived once from the topology.
44pub trait LinearSolver: Sized {
45    /// Analyzes `entries`' sparsity pattern (fill-reducing ordering,
46    /// elimination structure) and factors it once. `None` if the pattern is
47    /// structurally invalid or the matrix is singular.
48    fn new(n: usize, entries: &[(usize, usize, f64)]) -> Option<Self>;
49
50    /// Numeric-only refactorization against the cached pattern, then solves
51    /// `A x = b`. `None` if the matrix is singular at these values.
52    fn factor_and_solve(
53        &mut self,
54        entries: &[(usize, usize, f64)],
55        rhs: &[f64],
56    ) -> Option<Vec<f64>>;
57
58    /// As [`factor_and_solve`](Self::factor_and_solve), but taking only the
59    /// nonzero *values*, positionally matching the `entries` passed to
60    /// [`new`](Self::new).
61    ///
62    /// Every implementor already ignores the `(row, col)` half of `entries`
63    /// after construction — each caches its own positional mapping into its
64    /// CSC layout and reads nothing but `entries[i].2`. Handing over just the
65    /// values lets the Newton loop refill one reused `Vec<f64>` per iteration
66    /// via gridoxide's `jacobian::JacobianPattern`
67    /// instead of rebuilding a `Vec<(usize, usize, f64)>` from scratch, which
68    /// is 3x the bytes and a fresh allocation every time.
69    fn factor_and_solve_values(&mut self, values: &[f64], rhs: &[f64]) -> Option<Vec<f64>>;
70}
71
72/// A sparse matrix built once from a fixed set of (row, col, value) triplets
73/// (duplicates summed), kept around for repeated matrix-vector products —
74/// `power_injections` needs one every Newton-Raphson iteration, but the
75/// Y-bus itself never changes across iterations, so the conversion from
76/// triplets happens once at construction, not on every call.
77#[derive(Clone)]
78pub struct SparseMatrix {
79    n: usize,
80    mat: SparseColMat<usize, Complex<f64>>,
81}
82
83impl SparseMatrix {
84    pub fn build(n: usize, entries: &[(usize, usize, Complex<f64>)]) -> Option<Self> {
85        let triplets: Vec<Triplet<usize, usize, Complex<f64>>> = entries
86            .iter()
87            .map(|&(r, c, v)| Triplet::new(r, c, v))
88            .collect();
89        let mat =
90            SparseColMat::<usize, Complex<f64>>::try_new_from_triplets(n, n, &triplets).ok()?;
91        Some(Self { n, mat })
92    }
93
94    pub fn mul_vec(&self, v: &[Complex<f64>]) -> Vec<Complex<f64>> {
95        let vcol = Col::<Complex<f64>>::from_fn(self.n, |i| v[i]);
96        let result = &self.mat * &vcol;
97        (0..self.n).map(|i| result[i]).collect()
98    }
99}
100
101/// Solves a one-shot general complex sparse linear system `A x = b`.
102/// Returns `None` if the matrix is singular. Used by `linear_initial_guess`,
103/// which only solves once per power-flow run.
104///
105/// Unlike `nalgebra`'s dense LU, `faer`'s sparse LU does not detect
106/// singularity itself — it silently returns NaN-poisoned values instead of
107/// an error, so singularity is detected here via an explicit finiteness
108/// check on the result, to preserve the `None`-on-singular contract callers
109/// rely on.
110pub fn solve_complex(
111    n: usize,
112    entries: &[(usize, usize, Complex<f64>)],
113    rhs: &[Complex<f64>],
114) -> Option<Vec<Complex<f64>>> {
115    let triplets: Vec<Triplet<usize, usize, Complex<f64>>> = entries
116        .iter()
117        .map(|&(r, c, v)| Triplet::new(r, c, v))
118        .collect();
119    let mat = SparseColMat::<usize, Complex<f64>>::try_new_from_triplets(n, n, &triplets).ok()?;
120    let lu = mat.sp_lu().ok()?;
121    let b = Col::<Complex<f64>>::from_fn(n, |i| rhs[i]);
122    let x = lu.solve(&b);
123    if (0..n).any(|i| !x[i].re.is_finite() || !x[i].im.is_finite()) {
124        return None;
125    }
126    Some((0..n).map(|i| x[i]).collect())
127}
128
129/// A real sparse system whose sparsity *pattern* is fixed across repeated
130/// solves (Newton-Raphson's Jacobian: same bus topology every iteration,
131/// only numeric values change) — caches both the LU symbolic factorization
132/// (ordering + fill-in) and the triplet *argsort* (which sparse-matrix slot
133/// each triplet's value lands in) once, and reuses both on each call.
134///
135/// Reusing the argsort matters on its own, separately from the LU symbolic
136/// reuse: without it, every call would still re-sort and re-deduplicate the
137/// full triplet list from scratch even though only the *values* differ
138/// between calls — profiling with `perf` on a 2,605-node benchmark showed
139/// this re-sorting cost was ~10% of total Newton-Raphson time (comparable to
140/// the LU factorization itself), since a fresh `try_new_from_triplets` was
141/// being paid for on every iteration despite the (row, col) pattern never
142/// changing. `new_from_argsort` skips straight to placing values using the
143/// cached order.
144pub struct RealSparseSystem {
145    n: usize,
146    /// How many values the cached argsort expects — i.e. `entries.len()` at
147    /// construction. Kept so a mismatched call can be refused rather than
148    /// tripping an assertion inside `faer`; see [`solve_values`](Self::solve_values).
149    n_values: usize,
150    symbolic_mat: SymbolicSparseColMat<usize>,
151    argsort: Argsort<usize>,
152    symbolic_lu: SymbolicLu<usize>,
153}
154
155impl RealSparseSystem {
156    /// Builds the symbolic factorization and argsort from an initial
157    /// sparsity pattern. Subsequent calls to `factor_and_solve` must supply
158    /// entries with the exact same (row, col) pairs in the exact same order
159    /// (values may differ) — the cached argsort assumes positional
160    /// correspondence, not just set equality.
161    pub fn new(n: usize, entries: &[(usize, usize, f64)]) -> Option<Self> {
162        let pairs: Vec<Pair<usize, usize>> = entries
163            .iter()
164            .map(|&(r, c, _)| Pair { row: r, col: c })
165            .collect();
166        let (symbolic_mat, argsort) =
167            SymbolicSparseColMat::try_new_from_indices(n, n, &pairs).ok()?;
168        let symbolic_lu = SymbolicLu::try_new(symbolic_mat.rb()).ok()?;
169        Some(Self {
170            n,
171            n_values: entries.len(),
172            symbolic_mat,
173            argsort,
174            symbolic_lu,
175        })
176    }
177
178    /// Numeric-only refactorization against the cached symbolic pattern and
179    /// argsort, then solves `A x = b`. Returns `None` if the matrix is
180    /// singular (see `solve_complex`'s doc comment for why this needs an
181    /// explicit check).
182    pub fn factor_and_solve(
183        &self,
184        entries: &[(usize, usize, f64)],
185        rhs: &[f64],
186    ) -> Option<Vec<f64>> {
187        let vals: Vec<f64> = entries.iter().map(|&(_, _, v)| v).collect();
188        self.solve_values(&vals, rhs)
189    }
190
191    /// The body of [`factor_and_solve`](Self::factor_and_solve) once the
192    /// `(row, col)` half of the triplets has been dropped — which this
193    /// backend never needed after `new` built the argsort.
194    ///
195    /// A `vals` length other than the one `new` was given means the caller's
196    /// pattern is not this factorization's — a stale cache, which
197    /// gridoxide's `PersistentSolver::invalidate_admittances`
198    /// can produce when a topology change alters the *bus types* and therefore
199    /// the unknown count. That is refused here rather than left to trip an
200    /// assertion inside `faer`: this type's whole contract is `None` on a
201    /// system it cannot solve, and a panic crossing an FFI boundary is a much
202    /// worse failure than a reported one.
203    pub fn solve_values(&self, vals: &[f64], rhs: &[f64]) -> Option<Vec<f64>> {
204        if vals.len() != self.n_values || rhs.len() < self.n {
205            return None;
206        }
207        let mat =
208            SparseColMat::new_from_argsort(self.symbolic_mat.clone(), &self.argsort, vals).ok()?;
209        let lu = Lu::try_new_with_symbolic(self.symbolic_lu.clone(), mat.as_ref()).ok()?;
210        let b = Col::<f64>::from_fn(self.n, |i| rhs[i]);
211        let x = lu.solve(&b);
212        if (0..self.n).any(|i| !x[i].is_finite()) {
213            return None;
214        }
215        Some((0..self.n).map(|i| x[i]).collect())
216    }
217}
218
219/// `factor_and_solve` is inherently `&self` here (faer keeps no mutable
220/// per-solve state), but the trait takes `&mut self` because the FFI-backed
221/// backends genuinely need it. Taking the stricter receiver costs nothing.
222impl LinearSolver for RealSparseSystem {
223    fn new(n: usize, entries: &[(usize, usize, f64)]) -> Option<Self> {
224        RealSparseSystem::new(n, entries)
225    }
226
227    fn factor_and_solve(
228        &mut self,
229        entries: &[(usize, usize, f64)],
230        rhs: &[f64],
231    ) -> Option<Vec<f64>> {
232        RealSparseSystem::factor_and_solve(self, entries, rhs)
233    }
234
235    fn factor_and_solve_values(&mut self, values: &[f64], rhs: &[f64]) -> Option<Vec<f64>> {
236        self.solve_values(values, rhs)
237    }
238}
239
240/// A complex sparse system whose sparsity *pattern* and *values* are both fixed
241/// across repeated solves, so the whole factorization — not merely the symbolic
242/// half — is computed once and reused.
243///
244/// This is the stronger guarantee [`RealSparseSystem`] cannot make: Newton's
245/// Jacobian changes numerically every iteration, so only the ordering can be
246/// cached. The iterative-linear state estimator
247/// (gridoxide's `se::iterative`) is different — its system matrix
248/// depends on the measurement set and on the *assumption* that voltage
249/// magnitudes stay near their initial values, so it is genuinely constant and
250/// only the right-hand side moves. That is exactly what makes the method
251/// cheaper per iteration than Newton-Raphson, and it is why this type exists.
252pub struct ComplexSparseSystem {
253    n: usize,
254    lu: Lu<usize, Complex<f64>>,
255}
256
257impl ComplexSparseSystem {
258    /// Factorizes once. Returns `None` if the matrix is singular or malformed.
259    pub fn new(n: usize, entries: &[(usize, usize, Complex<f64>)]) -> Option<Self> {
260        let triplets: Vec<Triplet<usize, usize, Complex<f64>>> = entries
261            .iter()
262            .map(|&(r, c, v)| Triplet::new(r, c, v))
263            .collect();
264        let mat =
265            SparseColMat::<usize, Complex<f64>>::try_new_from_triplets(n, n, &triplets).ok()?;
266        Some(Self {
267            n,
268            lu: mat.sp_lu().ok()?,
269        })
270    }
271
272    /// Solves `A x = b` against the cached factorization.
273    ///
274    /// Singularity is checked on the result rather than reported by `faer`,
275    /// which returns NaN-poisoned values instead of an error — the same
276    /// contract [`solve_complex`] documents.
277    pub fn solve(&self, rhs: &[Complex<f64>]) -> Option<Vec<Complex<f64>>> {
278        let b = Col::<Complex<f64>>::from_fn(self.n, |i| rhs[i]);
279        let x = self.lu.solve(&b);
280        if (0..self.n).any(|i| !x[i].re.is_finite() || !x[i].im.is_finite()) {
281            return None;
282        }
283        Some((0..self.n).map(|i| x[i]).collect())
284    }
285
286    /// Solves `Aᴴ x = b` — the *conjugate* transpose — against the same cached
287    /// factorization.
288    ///
289    /// No second factorization is involved, for the reason
290    /// [`RealFactorization::solve_transpose`] documents: an `LU = PAQ`
291    /// decomposition solves the transposed system by running the same
292    /// triangular factors in the opposite order.
293    ///
294    /// The conjugate transpose rather than the plain one, because that is the
295    /// form the question comes in. A *left* eigenvector satisfies
296    /// `wᴴ A = λ wᴴ`, i.e. `Aᴴ w = λ̄ w`, and left eigenvectors are what
297    /// participation factors and eigenvalue sensitivities are built from
298    /// (`dynamics::smallsignal`). `faer` offers it directly as
299    /// `solve_adjoint_in_place`, so nothing here has to conjugate by hand.
300    pub fn solve_adjoint(&self, rhs: &[Complex<f64>]) -> Option<Vec<Complex<f64>>> {
301        debug_assert_eq!(rhs.len(), self.n);
302        let mut b = Col::<Complex<f64>>::from_fn(self.n, |i| rhs[i]);
303        self.lu.solve_adjoint_in_place(b.as_mut());
304        if (0..self.n).any(|i| !b[i].re.is_finite() || !b[i].im.is_finite()) {
305            return None;
306        }
307        Some((0..self.n).map(|i| b[i]).collect())
308    }
309}
310
311/// A real sparse system whose sparsity *pattern* and *values* are both fixed
312/// across repeated solves, so the whole factorization — not merely the
313/// symbolic half — is computed once and reused.
314///
315/// This is the real-arithmetic twin of [`ComplexSparseSystem`], and it exists
316/// for the same reason that type does: the DC (Bθ) susceptance matrix
317/// (`linear::btheta`) depends only on the topology, so a DC solve, a PTDF
318/// column and a LODF column are all just different right-hand sides against
319/// one factorization.
320///
321/// [`RealSparseSystem`] cannot serve that. Its contract — the one
322/// [`LinearSolver`] codifies — is
323/// "pattern fixed, values change every call", so it numerically refactorizes
324/// on every `factor_and_solve`. That is exactly right for Newton-Raphson,
325/// whose Jacobian really does change every iteration, and exactly wrong for
326/// PTDF, which would pay a fresh factorization once per bus.
327pub struct RealFactorization {
328    n: usize,
329    lu: Lu<usize, f64>,
330}
331
332impl RealFactorization {
333    /// Factorizes once. Returns `None` if the matrix is singular or malformed.
334    pub fn new(n: usize, entries: &[(usize, usize, f64)]) -> Option<Self> {
335        let triplets: Vec<Triplet<usize, usize, f64>> = entries
336            .iter()
337            .map(|&(r, c, v)| Triplet::new(r, c, v))
338            .collect();
339        let mat = SparseColMat::<usize, f64>::try_new_from_triplets(n, n, &triplets).ok()?;
340        Some(Self {
341            n,
342            lu: mat.sp_lu().ok()?,
343        })
344    }
345
346    pub fn n(&self) -> usize {
347        self.n
348    }
349
350    /// Solves `A x = b` against the cached factorization.
351    ///
352    /// Singularity is checked on the result rather than reported by `faer`,
353    /// which returns NaN-poisoned values instead of an error — the same
354    /// contract [`solve_complex`] documents.
355    pub fn solve(&self, rhs: &[f64]) -> Option<Vec<f64>> {
356        let mut out = vec![0.0; self.n];
357        self.solve_into(rhs, &mut out).then_some(out)
358    }
359
360    /// Solves `Aᵀ x = b` against the *same* cached factorization.
361    ///
362    /// No second factorization is involved: an `LU = PAQ` decomposition solves
363    /// the transposed system by running the same triangular factors in the
364    /// opposite order, which is what `faer`'s `solve_transpose_in_place` does.
365    ///
366    /// This is what makes an adjoint sensitivity affordable. A forward
367    /// sensitivity answers "how does everything respond to *this* variable" in
368    /// one solve against `A`; the adjoint answers "how does *this* quantity
369    /// respond to everything" in one solve against `Aᵀ`. Both directions are
370    /// useful and neither is derivable from the other cheaply, so
371    /// `ac_sensitivity` offers both — see `linear::sensitivity`'s
372    /// `ptdf_column`/`ptdf_row` pair for the same duality on the DC side.
373    pub fn solve_transpose(&self, rhs: &[f64]) -> Option<Vec<f64>> {
374        debug_assert_eq!(rhs.len(), self.n);
375        let mut b = Col::<f64>::from_fn(self.n, |i| rhs[i]);
376        self.lu.solve_transpose_in_place(b.as_mut());
377        if (0..self.n).any(|i| !b[i].is_finite()) {
378            return None;
379        }
380        Some((0..self.n).map(|i| b[i]).collect())
381    }
382
383    /// The body of [`solve`](Self::solve), writing into a caller-owned buffer
384    /// rather than allocating.
385    ///
386    /// Private because nothing needs the buffer-reusing form yet: the
387    /// sensitivity accessors in `linear::sensitivity` each return an owned
388    /// `Vec` anyway, and one allocation per column is nowhere near the cost of
389    /// the triangular solve beside it. Worth making public if a caller ever
390    /// does want to hoist that allocation out of a loop.
391    ///
392    /// Returns `false` (leaving `out` in an unspecified state) if the matrix
393    /// is singular at these values.
394    fn solve_into(&self, rhs: &[f64], out: &mut [f64]) -> bool {
395        debug_assert_eq!(rhs.len(), self.n);
396        debug_assert_eq!(out.len(), self.n);
397        let b = Col::<f64>::from_fn(self.n, |i| rhs[i]);
398        let x = self.lu.solve(&b);
399        if (0..self.n).any(|i| !x[i].is_finite()) {
400            return false;
401        }
402        for i in 0..self.n {
403            out[i] = x[i];
404        }
405        true
406    }
407}
408
409/// Solves a small dense `A x = b` by Gaussian elimination with partial
410/// pivoting. Returns `None` if `A` is singular — no pivot clears a small
411/// absolute threshold.
412///
413/// Deliberately dense and deliberately unsophisticated: both callers pass a
414/// matrix whose order is a handful, where a sparse factorization's setup
415/// would cost more than the whole solve. `dc::solve_dc_network` uses it for
416/// the HVDC Newton step, and `linear::sensitivity` for the `k × k`
417/// generalized-LODF system over an outage set of `k` branches. It lives here
418/// rather than in either of those because it is a numeric utility with no
419/// power-system content, and because a reader looking for the crate's linear
420/// algebra looks here.
421///
422/// The absolute pivot threshold is meaningful for both callers: their
423/// matrices are O(1)-scaled (a Jacobian in per-unit, and `I - Ψ` whose
424/// entries are transfer fractions), so "no pivot above 1e-12" really does mean
425/// structurally singular rather than merely badly scaled.
426pub fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
427    let n = b.len();
428    for col in 0..n {
429        let pivot_row = (col..n).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?;
430        if a[pivot_row][col].abs() < 1e-12 {
431            return None;
432        }
433        a.swap(col, pivot_row);
434        b.swap(col, pivot_row);
435        for row in (col + 1)..n {
436            let factor = a[row][col] / a[col][col];
437            if factor == 0.0 {
438                continue;
439            }
440            for k in col..n {
441                a[row][k] -= factor * a[col][k];
442            }
443            b[row] -= factor * b[col];
444        }
445    }
446    let mut x = vec![0.0; n];
447    for row in (0..n).rev() {
448        let sum: f64 = (row + 1..n).map(|k| a[row][k] * x[k]).sum();
449        x[row] = (b[row] - sum) / a[row][row];
450    }
451    Some(x)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn solve_dense_matches_a_hand_solution() {
460        // [[2,1],[1,3]] x = [5,10] => x = [1,3]
461        let x = solve_dense(vec![vec![2.0, 1.0], vec![1.0, 3.0]], vec![5.0, 10.0]).unwrap();
462        assert!(
463            (x[0] - 1.0).abs() < 1e-12 && (x[1] - 3.0).abs() < 1e-12,
464            "{x:?}"
465        );
466    }
467
468    /// Partial pivoting must handle a zero leading pivot rather than dividing
469    /// by it.
470    #[test]
471    fn solve_dense_pivots_past_a_zero_diagonal() {
472        let x = solve_dense(vec![vec![0.0, 2.0], vec![1.0, 1.0]], vec![4.0, 3.0]).unwrap();
473        assert!(
474            (x[0] - 1.0).abs() < 1e-12 && (x[1] - 2.0).abs() < 1e-12,
475            "{x:?}"
476        );
477    }
478
479    #[test]
480    fn solve_dense_singular_returns_none() {
481        assert!(solve_dense(vec![vec![1.0, 1.0], vec![2.0, 2.0]], vec![1.0, 2.0]).is_none());
482    }
483
484    #[test]
485    fn solve_complex_simple_system() {
486        // [[2, 1], [1, 3]] * [x0, x1] = [5, 10]  =>  x0=1, x1=3
487        let entries = vec![
488            (0, 0, Complex::new(2.0, 0.0)),
489            (0, 1, Complex::new(1.0, 0.0)),
490            (1, 0, Complex::new(1.0, 0.0)),
491            (1, 1, Complex::new(3.0, 0.0)),
492        ];
493        let rhs = vec![Complex::new(5.0, 0.0), Complex::new(10.0, 0.0)];
494        let x = solve_complex(2, &entries, &rhs).unwrap();
495        assert!((x[0] - Complex::new(1.0, 0.0)).norm() < 1e-10);
496        assert!((x[1] - Complex::new(3.0, 0.0)).norm() < 1e-10);
497    }
498
499    #[test]
500    fn solve_complex_duplicate_entries_sum() {
501        // (0,0) contributed twice as 1.0+1.0=2.0, matching dense += semantics.
502        let entries = vec![
503            (0, 0, Complex::new(1.0, 0.0)),
504            (0, 0, Complex::new(1.0, 0.0)),
505            (0, 1, Complex::new(1.0, 0.0)),
506            (1, 0, Complex::new(1.0, 0.0)),
507            (1, 1, Complex::new(3.0, 0.0)),
508        ];
509        let rhs = vec![Complex::new(5.0, 0.0), Complex::new(10.0, 0.0)];
510        let x = solve_complex(2, &entries, &rhs).unwrap();
511        assert!((x[0] - Complex::new(1.0, 0.0)).norm() < 1e-10);
512        assert!((x[1] - Complex::new(3.0, 0.0)).norm() < 1e-10);
513    }
514
515    #[test]
516    fn real_sparse_system_refactor_reuses_symbolic() {
517        let entries_a = vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
518        let sys = RealSparseSystem::new(2, &entries_a).unwrap();
519        let x = sys.factor_and_solve(&entries_a, &[5.0, 10.0]).unwrap();
520        assert!((x[0] - 1.0).abs() < 1e-10);
521        assert!((x[1] - 3.0).abs() < 1e-10);
522
523        // Same sparsity pattern, different numeric values (as across NR iterations).
524        let entries_b = vec![(0, 0, 4.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 2.0)];
525        // [[4,1],[1,2]] * x = [6, 5] => x0=1, x1=2
526        let x2 = sys.factor_and_solve(&entries_b, &[6.0, 5.0]).unwrap();
527        assert!((x2[0] - 1.0).abs() < 1e-10);
528        assert!((x2[1] - 2.0).abs() < 1e-10);
529    }
530
531    #[test]
532    fn singular_matrix_returns_none() {
533        let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 2.0), (1, 1, 2.0)];
534        let sys = RealSparseSystem::new(2, &entries);
535        // Symbolic analysis may succeed even for a numerically singular matrix
536        // (it only depends on the sparsity pattern); the failure surfaces at
537        // factor_and_solve time.
538        if let Some(sys) = sys {
539            assert!(sys.factor_and_solve(&entries, &[1.0, 2.0]).is_none());
540        }
541    }
542
543    /// The whole point of the type: one factorization, many right-hand sides.
544    /// Solving the two unit vectors recovers the inverse column by column, so
545    /// this doubles as a check that the cached `Lu` is genuinely reusable
546    /// rather than consumed by the first solve.
547    #[test]
548    fn real_factorization_reuses_one_factorization_across_right_hand_sides() {
549        // [[2, 1], [1, 3]], det = 5, inverse = [[0.6, -0.2], [-0.2, 0.4]].
550        let entries = vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
551        let sys = RealFactorization::new(2, &entries).unwrap();
552        assert_eq!(sys.n(), 2);
553
554        let x = sys.solve(&[5.0, 10.0]).unwrap();
555        assert!((x[0] - 1.0).abs() < 1e-12, "got {}", x[0]);
556        assert!((x[1] - 3.0).abs() < 1e-12, "got {}", x[1]);
557
558        let c0 = sys.solve(&[1.0, 0.0]).unwrap();
559        assert!(
560            (c0[0] - 0.6).abs() < 1e-12 && (c0[1] + 0.2).abs() < 1e-12,
561            "{c0:?}"
562        );
563        let c1 = sys.solve(&[0.0, 1.0]).unwrap();
564        assert!(
565            (c1[0] + 0.2).abs() < 1e-12 && (c1[1] - 0.4).abs() < 1e-12,
566            "{c1:?}"
567        );
568    }
569
570    #[test]
571    fn real_factorization_solve_into_matches_solve() {
572        let entries = vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
573        let sys = RealFactorization::new(2, &entries).unwrap();
574        let mut out = vec![0.0; 2];
575        assert!(sys.solve_into(&[5.0, 10.0], &mut out));
576        assert_eq!(out, sys.solve(&[5.0, 10.0]).unwrap());
577    }
578
579    /// Duplicate `(row, col)` triplets sum, matching `solve_complex`'s
580    /// documented `+=` semantics — the B-matrix assembly in `linear::btheta`
581    /// relies on this, since parallel branches each stamp their own entry.
582    #[test]
583    fn real_factorization_duplicate_entries_sum() {
584        let entries = vec![
585            (0, 0, 1.0),
586            (0, 0, 1.0),
587            (0, 1, 1.0),
588            (1, 0, 1.0),
589            (1, 1, 3.0),
590        ];
591        let sys = RealFactorization::new(2, &entries).unwrap();
592        let x = sys.solve(&[5.0, 10.0]).unwrap();
593        assert!(
594            (x[0] - 1.0).abs() < 1e-12 && (x[1] - 3.0).abs() < 1e-12,
595            "{x:?}"
596        );
597    }
598
599    /// The transposed solve must use the *same* factorization and still give
600    /// the answer a separately-factorized `Aᵀ` would. Checked on a
601    /// deliberately asymmetric matrix, since on a symmetric one the two
602    /// directions agree for the wrong reason.
603    #[test]
604    fn real_factorization_solve_transpose_matches_an_explicit_transpose() {
605        let entries = vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 5.0), (1, 1, 3.0)];
606        let transposed: Vec<(usize, usize, f64)> =
607            entries.iter().map(|&(r, c, v)| (c, r, v)).collect();
608
609        let a = RealFactorization::new(2, &entries).unwrap();
610        let at = RealFactorization::new(2, &transposed).unwrap();
611
612        for rhs in [[1.0, 0.0], [0.0, 1.0], [3.0, -7.0]] {
613            let via_transpose = a.solve_transpose(&rhs).unwrap();
614            let via_explicit = at.solve(&rhs).unwrap();
615            for i in 0..2 {
616                assert!(
617                    (via_transpose[i] - via_explicit[i]).abs() < 1e-12,
618                    "rhs {rhs:?}: {via_transpose:?} vs {via_explicit:?}"
619                );
620            }
621        }
622
623        // And it is genuinely the transpose, not the same solve twice.
624        let forward = a.solve(&[1.0, 0.0]).unwrap();
625        let adjoint = a.solve_transpose(&[1.0, 0.0]).unwrap();
626        assert!(
627            (forward[1] - adjoint[1]).abs() > 1e-6,
628            "{forward:?} vs {adjoint:?}"
629        );
630    }
631
632    /// The complex adjoint solve must reuse the same factorization and agree
633    /// with a separately-factorized `Aᴴ`. Checked on a matrix that is neither
634    /// symmetric nor real, since on either of those the conjugate transpose
635    /// coincides with something else and the test would pass for the wrong
636    /// reason.
637    #[test]
638    fn complex_system_solve_adjoint_matches_an_explicit_conjugate_transpose() {
639        let c = |re: f64, im: f64| Complex::new(re, im);
640        let entries = vec![
641            (0, 0, c(2.0, 1.0)),
642            (0, 1, c(1.0, -3.0)),
643            (1, 0, c(5.0, 2.0)),
644            (1, 1, c(3.0, 0.5)),
645        ];
646        let adjoint_entries: Vec<(usize, usize, Complex<f64>)> = entries
647            .iter()
648            .map(|&(r, c_, v)| (c_, r, v.conj()))
649            .collect();
650
651        let a = ComplexSparseSystem::new(2, &entries).unwrap();
652        let ah = ComplexSparseSystem::new(2, &adjoint_entries).unwrap();
653
654        for rhs in [
655            [c(1.0, 0.0), c(0.0, 0.0)],
656            [c(0.0, 0.0), c(1.0, 0.0)],
657            [c(3.0, -1.0), c(-7.0, 2.0)],
658        ] {
659            let via_adjoint = a.solve_adjoint(&rhs).unwrap();
660            let via_explicit = ah.solve(&rhs).unwrap();
661            for i in 0..2 {
662                assert!(
663                    (via_adjoint[i] - via_explicit[i]).norm() < 1e-12,
664                    "rhs {rhs:?}: {via_adjoint:?} vs {via_explicit:?}"
665                );
666            }
667        }
668
669        // And it is genuinely the adjoint, not the same solve twice.
670        let forward = a.solve(&[c(1.0, 0.0), c(0.0, 0.0)]).unwrap();
671        let adjoint = a.solve_adjoint(&[c(1.0, 0.0), c(0.0, 0.0)]).unwrap();
672        assert!(
673            (forward[1] - adjoint[1]).norm() > 1e-6,
674            "{forward:?} vs {adjoint:?}"
675        );
676    }
677
678    #[test]
679    fn real_factorization_singular_returns_none() {
680        let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 2.0), (1, 1, 2.0)];
681        // As above, `new` may or may not detect this; if it does not, the
682        // finiteness check on the solve must.
683        if let Some(sys) = RealFactorization::new(2, &entries) {
684            assert!(sys.solve(&[1.0, 2.0]).is_none());
685        }
686    }
687}