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}