gridoxide_sparse/block_sparse.rs
1//! Experimental block-per-bus sparse LU backend for symmetric power flow,
2//! mirroring power-grid-model's approach of grouping each bus's own unknowns
3//! (angle, voltage magnitude) into one dense 2×2 block instead of two
4//! separate scalar unknowns scattered across the Jacobian.
5//!
6//! This is a **parallel, opt-in** backend (`solver::JacobianBackend::Block`)
7//! — the `Scalar` path (`sparse.rs`, backed by `faer`) and the default
8//! `KluNative` are untouched by this module, so a bug here cannot affect
9//! them.
10//! Scoped to symmetric power flow only. Every non-slack bus, PV or PQ, gets
11//! a uniform 2×2 block here — this module itself has no notion of bus
12//! types; `solver::build_jacobian_blocks` is what encodes a `PV` bus's
13//! missing Q-mismatch equation, by substituting a dummy `ΔVmag = 0` row
14//! instead of varying block size per bus.
15//!
16//! Only the fill-reducing *ordering* is reused from `faer`
17//! (`faer::sparse::linalg::colamd::order`, which is purely structural and
18//! works identically for scalar or block payloads). The numeric block LU
19//! (symbolic elimination-graph reachability + block-pivoted factorization +
20//! block triangular solve) is hand-written here, following the classic
21//! Gilbert-Peierls left-looking sparse LU algorithm, adapted from scalar
22//! arithmetic to 2×2 block arithmetic at each step.
23
24use faer::dyn_stack::{MemBuffer, MemStack};
25use faer::prelude::Reborrow;
26use faer::sparse::linalg::colamd;
27use faer::sparse::{Pair, SymbolicSparseColMat};
28
29/// A 2×2 real block, row-major: `[[a, b], [c, d]]`.
30pub type Block2 = [[f64; 2]; 2];
31
32pub fn block_zero() -> Block2 {
33 [[0.0, 0.0], [0.0, 0.0]]
34}
35
36pub fn block_add(a: Block2, b: Block2) -> Block2 {
37 [
38 [a[0][0] + b[0][0], a[0][1] + b[0][1]],
39 [a[1][0] + b[1][0], a[1][1] + b[1][1]],
40 ]
41}
42
43pub fn block_sub(a: Block2, b: Block2) -> Block2 {
44 [
45 [a[0][0] - b[0][0], a[0][1] - b[0][1]],
46 [a[1][0] - b[1][0], a[1][1] - b[1][1]],
47 ]
48}
49
50pub fn block_mul(a: Block2, b: Block2) -> Block2 {
51 [
52 [
53 a[0][0] * b[0][0] + a[0][1] * b[1][0],
54 a[0][0] * b[0][1] + a[0][1] * b[1][1],
55 ],
56 [
57 a[1][0] * b[0][0] + a[1][1] * b[1][0],
58 a[1][0] * b[0][1] + a[1][1] * b[1][1],
59 ],
60 ]
61}
62
63pub fn block_vec_mul(a: Block2, v: [f64; 2]) -> [f64; 2] {
64 [
65 a[0][0] * v[0] + a[0][1] * v[1],
66 a[1][0] * v[0] + a[1][1] * v[1],
67 ]
68}
69
70pub fn vec_sub(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
71 [a[0] - b[0], a[1] - b[1]]
72}
73
74/// A 2×2 block's Frobenius-like magnitude, used for pivot comparison.
75pub fn block_abs(a: Block2) -> f64 {
76 (a[0][0] * a[0][0] + a[0][1] * a[0][1] + a[1][0] * a[1][0] + a[1][1] * a[1][1]).sqrt()
77}
78
79/// Inverts a 2×2 block via the explicit determinant formula. Returns `None`
80/// if the block is (near-)singular.
81pub fn block_inv(a: Block2) -> Option<Block2> {
82 let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
83 if !det.is_finite() || det.abs() < 1e-300 {
84 return None;
85 }
86 let inv_det = 1.0 / det;
87 Some([
88 [a[1][1] * inv_det, -a[0][1] * inv_det],
89 [-a[1][0] * inv_det, a[0][0] * inv_det],
90 ])
91}
92
93/// A mutable block-COO Y-bus/Jacobian accumulator: one 2×2 block per (bus,
94/// bus) slot instead of `network::YBus`'s one `Complex<f64>` per (unknown,
95/// unknown) slot. Duplicate `(i, j)` entries are summed once finalized,
96/// matching `YBus`'s accumulation semantics.
97pub struct BlockMatrix {
98 n: usize,
99 entries: Vec<(usize, usize, Block2)>,
100}
101
102impl BlockMatrix {
103 pub fn new(n: usize) -> Self {
104 Self {
105 n,
106 entries: Vec::new(),
107 }
108 }
109
110 pub fn add(&mut self, i: usize, j: usize, block: Block2) {
111 debug_assert!(i < self.n && j < self.n, "block index out of range");
112 self.entries.push((i, j, block));
113 }
114
115 pub fn n(&self) -> usize {
116 self.n
117 }
118
119 /// Consolidates duplicate `(i, j)` entries (summing them) and returns a
120 /// row-major *and* column-major adjacency list: `row(i)` gives all
121 /// `(j, block)` pairs with a nonzero block in row `i` (including the
122 /// diagonal), `col(j)` gives all `(i, block)` pairs with a nonzero block
123 /// in column `j` — both sorted by the other index. Kept as two separate
124 /// views (not just one, transposed on demand) because the real
125 /// power-flow Jacobian is *not* value-symmetric (`block(i, j) !=
126 /// block(j, i)` in general, even though the sparsity *pattern* is
127 /// structurally symmetric) — `BlockLu::refactor` genuinely needs
128 /// column `j`'s own values, not row `j`'s reused under a symmetry
129 /// assumption that doesn't hold for this matrix.
130 ///
131 /// Sort-based, not hash-based: `newton_raphson_block` calls this once
132 /// per iteration (the Jacobian's numeric values change every iteration
133 /// even though its sparsity pattern doesn't), so this runs thousands of
134 /// times per solve on realistic networks — profiling with `perf` showed
135 /// a `HashMap`-based merge costing ~25% of total block-backend runtime
136 /// at 2,605 nodes. Sorting by `(i, j)` avoids hashing entirely and
137 /// leaves each row already grouped in ascending-`j` order, so no
138 /// separate per-row sort is needed afterwards either; iterating that
139 /// same ascending-`i` order while building `cols` likewise leaves each
140 /// column already grouped in ascending-`i` order for free.
141 pub fn finish(mut self) -> BlockAdjacency {
142 self.entries.sort_unstable_by_key(|&(i, j, _)| (i, j));
143
144 let mut rows: Vec<Vec<(usize, Block2)>> = vec![Vec::new(); self.n];
145 let mut cols: Vec<Vec<(usize, Block2)>> = vec![Vec::new(); self.n];
146 let mut idx = 0;
147 while idx < self.entries.len() {
148 let (i, j, mut acc) = self.entries[idx];
149 let mut k = idx + 1;
150 while k < self.entries.len() && self.entries[k].0 == i && self.entries[k].1 == j {
151 acc = block_add(acc, self.entries[k].2);
152 k += 1;
153 }
154 rows[i].push((j, acc));
155 cols[j].push((i, acc));
156 idx = k;
157 }
158 BlockAdjacency {
159 n: self.n,
160 rows,
161 cols,
162 }
163 }
164}
165
166/// The finalized, consolidated block structure of a `BlockMatrix`, indexed
167/// both by row and by column (see `BlockMatrix::finish`'s doc comment for
168/// why both are needed rather than one derived from the other on demand).
169pub struct BlockAdjacency {
170 n: usize,
171 rows: Vec<Vec<(usize, Block2)>>,
172 cols: Vec<Vec<(usize, Block2)>>,
173}
174
175impl BlockAdjacency {
176 pub fn n(&self) -> usize {
177 self.n
178 }
179
180 pub fn row(&self, i: usize) -> &[(usize, Block2)] {
181 &self.rows[i]
182 }
183
184 /// All `(row, block)` pairs with a nonzero block in column `j`, sorted
185 /// by `row`. `block` is the real `A[row][j]` value — *not* `A[j][row]`
186 /// reused under a symmetry assumption, since the Jacobian doesn't
187 /// satisfy one (see `BlockMatrix::finish`).
188 pub fn col(&self, j: usize) -> &[(usize, Block2)] {
189 &self.cols[j]
190 }
191
192 pub fn get(&self, i: usize, j: usize) -> Block2 {
193 self.rows[i]
194 .binary_search_by_key(&j, |&(col, _)| col)
195 .map(|idx| self.rows[i][idx].1)
196 .unwrap_or_else(|_| block_zero())
197 }
198
199 /// Computes a fill-reducing column permutation over the *collapsed*
200 /// bus-level structural graph (one node per bus, ignoring the internal
201 /// 2×2 block contents — `colamd`'s ordering is purely structural),
202 /// reusing `faer`'s tested `colamd::order` rather than hand-writing an
203 /// ordering heuristic. `perm[k]` = the original bus index placed at
204 /// elimination position `k`.
205 pub fn colamd_order(&self) -> Vec<usize> {
206 let pairs: Vec<Pair<usize, usize>> = self
207 .rows
208 .iter()
209 .enumerate()
210 .flat_map(|(i, row)| row.iter().map(move |&(j, _)| Pair { row: i, col: j }))
211 .collect();
212 let (symbolic, _argsort) =
213 SymbolicSparseColMat::<usize>::try_new_from_indices(self.n, self.n, &pairs)
214 .expect("block adjacency should always form a valid sparsity pattern");
215
216 let nnz = pairs.len();
217 let scratch_req = colamd::order_scratch::<usize>(self.n, self.n, nnz);
218 let mut buffer = MemBuffer::try_new(scratch_req).expect("colamd scratch allocation");
219 let stack = MemStack::new(&mut buffer);
220
221 let mut perm = vec![0usize; self.n];
222 let mut perm_inv = vec![0usize; self.n];
223 colamd::order(
224 &mut perm,
225 &mut perm_inv,
226 symbolic.rb(),
227 colamd::Control::default(),
228 stack,
229 )
230 .expect("colamd ordering should always succeed on a valid sparsity pattern");
231 perm
232 }
233}
234
235/// The symbolic (structure-only) part of a block LU factorization: the
236/// `colamd` permutation plus which rows are nonzero in each column of L and
237/// U after fill-in. This is fixed for as long as the *sparsity pattern*
238/// doesn't change — exactly the case across Newton-Raphson iterations, where
239/// the bus topology stays constant and only numeric Jacobian values change —
240/// so it's computed once via `analyze` and reused by `BlockLu::refactor` for
241/// cheap numeric-only refactorization on every iteration, mirroring
242/// `sparse::RealSparseSystem`'s `SymbolicLu` reuse for the scalar backend.
243pub struct BlockSymbolic {
244 n: usize,
245 /// `perm[k]` = original bus index placed at elimination position `k`.
246 perm: Vec<usize>,
247 /// `l_struct[k]` = sorted rows `> k` with a nonzero L entry in column `k`.
248 l_struct: Vec<Vec<usize>>,
249 /// `u_struct[j]` = sorted rows `<= j` with a nonzero U entry in column
250 /// `j` (always includes `j` itself, the diagonal).
251 u_struct: Vec<Vec<usize>>,
252}
253
254impl BlockSymbolic {
255 /// Discovers the elimination structure (fill-in pattern) via the same
256 /// left-looking DFS reachability `BlockLu` used to use inline, but
257 /// tracking only *which* positions become nonzero, not their values.
258 pub fn analyze(adj: &BlockAdjacency) -> Self {
259 let n = adj.n();
260 let perm = adj.colamd_order();
261 let mut pos = vec![0usize; n];
262 for (k, &orig) in perm.iter().enumerate() {
263 pos[orig] = k;
264 }
265
266 let mut l_struct: Vec<Vec<usize>> = vec![Vec::new(); n];
267 let mut u_struct: Vec<Vec<usize>> = vec![Vec::new(); n];
268
269 let mut touched_at: Vec<i64> = vec![-1; n];
270 let mut reach_visited_at: Vec<i64> = vec![-1; n];
271 let mut touched_list: Vec<usize> = Vec::new();
272
273 for j in 0..n {
274 touched_list.clear();
275
276 for &(orig_neighbor, _) in adj.row(perm[j]) {
277 let row = pos[orig_neighbor];
278 if touched_at[row] != j as i64 {
279 touched_at[row] = j as i64;
280 touched_list.push(row);
281 }
282 }
283
284 let mut stack: Vec<usize> = Vec::new();
285 for &row in &touched_list {
286 if row < j && reach_visited_at[row] != j as i64 {
287 reach_visited_at[row] = j as i64;
288 stack.push(row);
289 }
290 }
291 while let Some(v) = stack.pop() {
292 for &row in &l_struct[v] {
293 if row < j && reach_visited_at[row] != j as i64 {
294 reach_visited_at[row] = j as i64;
295 stack.push(row);
296 }
297 }
298 }
299
300 for k in 0..j {
301 if reach_visited_at[k] != j as i64 {
302 continue;
303 }
304 for &row in &l_struct[k] {
305 if touched_at[row] != j as i64 {
306 touched_at[row] = j as i64;
307 touched_list.push(row);
308 }
309 }
310 }
311
312 for &row in &touched_list {
313 if row <= j {
314 u_struct[j].push(row);
315 } else {
316 l_struct[j].push(row);
317 }
318 }
319 u_struct[j].sort_unstable();
320 l_struct[j].sort_unstable();
321 }
322
323 Self {
324 n,
325 perm,
326 l_struct,
327 u_struct,
328 }
329 }
330}
331
332/// A numeric block LU factorization, computed against a `BlockSymbolic`'s
333/// known structure — either freshly analyzed (`factorize`) or reusing an
334/// existing `BlockSymbolic` across calls (`refactor`, the cheap path used
335/// once per Newton-Raphson iteration).
336///
337/// **Simplification, clearly scoped**: this uses the diagonal entry as the
338/// pivot for each column directly — no partial pivoting (row swapping).
339/// Partial pivoting on a *sparse* block matrix would mean swapping entries
340/// across every already-factored column's stored structure, potentially
341/// inserting or removing sparse entries (new fill-in from the swap itself),
342/// a substantially larger and more fragile undertaking than the rest of this
343/// module. Power-system Y-bus/Jacobian matrices are normally diagonally
344/// dominant enough for this to succeed; factorization returns `None`
345/// (treated as singular, same contract as `sparse::RealSparseSystem`) if a
346/// diagonal pivot is ever too close to singular, rather than silently
347/// producing a wrong answer.
348///
349/// Relies on the Y-bus/Jacobian's sparsity *pattern* being structurally
350/// symmetric (if bus `i` affects bus `k`, bus `k` affects bus `i` too, since
351/// branch admittance couples both directions) for `BlockSymbolic::analyze`'s
352/// structural reachability search, which uses `BlockAdjacency::row` for both
353/// row and column structural lookups on that assumption. The numeric scatter
354/// step below needs actual column *values* though (`block(i, j) !=
355/// block(j, i)` in general for this matrix), so it reads
356/// `BlockAdjacency::col` rather than reusing row values under the same
357/// assumption.
358pub struct BlockLu {
359 n: usize,
360 /// `perm[k]` = original bus index placed at elimination position `k`.
361 perm: Vec<usize>,
362 /// `l_cols[k]` = `(row, multiplier)` pairs for the strictly-below-diagonal
363 /// part of L's column `k` (unit diagonal implied), in permuted indexing.
364 l_cols: Vec<Vec<(usize, Block2)>>,
365 /// `u_cols[j]` = `(row, value)` pairs for the upper-triangular part
366 /// (including the diagonal at `row == j`) of U's column `j`, in permuted
367 /// indexing.
368 u_cols: Vec<Vec<(usize, Block2)>>,
369}
370
371impl BlockLu {
372 /// Analyzes `adj`'s structure and factorizes it in one step. Prefer
373 /// `refactor` when solving the same sparsity pattern repeatedly (e.g.
374 /// across Newton-Raphson iterations) — it skips the `colamd` ordering
375 /// and elimination-graph reachability search entirely, reusing a cached
376 /// `BlockSymbolic` instead.
377 pub fn factorize(adj: &BlockAdjacency) -> Option<Self> {
378 let symbolic = BlockSymbolic::analyze(adj);
379 Self::refactor(&symbolic, adj)
380 }
381
382 /// Numeric-only refactorization against `symbolic`'s known structure —
383 /// no ordering or reachability recomputation, just walking the already
384 /// known `l_struct`/`u_struct` positions and computing their (new)
385 /// numeric values. Returns `None` if any diagonal pivot is singular.
386 ///
387 /// `adj` must have the exact same sparsity pattern `symbolic` was built
388 /// from (values may differ) — this is not re-validated, matching
389 /// `sparse::RealSparseSystem::factor_and_solve`'s same-pattern
390 /// requirement.
391 pub fn refactor(symbolic: &BlockSymbolic, adj: &BlockAdjacency) -> Option<Self> {
392 let n = symbolic.n;
393 let perm = &symbolic.perm;
394 let mut pos = vec![0usize; n];
395 for (k, &orig) in perm.iter().enumerate() {
396 pos[orig] = k;
397 }
398
399 let mut l_cols: Vec<Vec<(usize, Block2)>> = vec![Vec::new(); n];
400 let mut u_cols: Vec<Vec<(usize, Block2)>> = vec![Vec::new(); n];
401
402 let mut x: Vec<Block2> = vec![block_zero(); n];
403 let mut touched_at: Vec<i64> = vec![-1; n];
404
405 for j in 0..n {
406 // 1. Scatter (permuted) column j's numeric entries into x —
407 // `adj.col`, not `adj.row`: this needs A's actual column
408 // values, and `block(i, j) != block(j, i)` in general for
409 // this matrix (see this type's doc comment).
410 for &(orig_row, block) in adj.col(perm[j]) {
411 let row = pos[orig_row];
412 if touched_at[row] != j as i64 {
413 touched_at[row] = j as i64;
414 x[row] = block;
415 } else {
416 x[row] = block_add(x[row], block);
417 }
418 }
419
420 // 2. Apply updates from every k < j in the known reach set
421 // (`u_struct[j]` minus the diagonal), in ascending order —
422 // no DFS needed, the structure is already known. Iterate
423 // `l_cols[k]` directly (already finalized this pass, since
424 // k < j) rather than `symbolic.l_struct[k]`, avoiding an
425 // O(size) lookup per entry.
426 for &k in &symbolic.u_struct[j] {
427 if k == j {
428 continue;
429 }
430 let xk = if touched_at[k] == j as i64 {
431 x[k]
432 } else {
433 block_zero()
434 };
435 for &(row, lval) in &l_cols[k] {
436 let delta = block_mul(lval, xk);
437 if touched_at[row] != j as i64 {
438 touched_at[row] = j as i64;
439 x[row] = block_sub(block_zero(), delta);
440 } else {
441 x[row] = block_sub(x[row], delta);
442 }
443 }
444 }
445
446 // 3. Diagonal pivot (no partial pivoting — see doc comment).
447 let pivot = if touched_at[j] == j as i64 {
448 x[j]
449 } else {
450 block_zero()
451 };
452 let pivot_inv = block_inv(pivot)?;
453
454 // 4. Finalize U's and L's column j using the known structure.
455 for &row in &symbolic.u_struct[j] {
456 let v = if touched_at[row] == j as i64 {
457 x[row]
458 } else {
459 block_zero()
460 };
461 u_cols[j].push((row, v));
462 }
463 for &row in &symbolic.l_struct[j] {
464 let v = if touched_at[row] == j as i64 {
465 x[row]
466 } else {
467 block_zero()
468 };
469 l_cols[j].push((row, block_mul(v, pivot_inv)));
470 }
471 }
472
473 Some(Self {
474 n,
475 perm: perm.clone(),
476 l_cols,
477 u_cols,
478 })
479 }
480
481 /// Solves `A x = b` using this factorization. `b` is in original
482 /// (unpermuted) bus indexing; the result is too.
483 pub fn solve(&self, b: &[[f64; 2]]) -> Vec<[f64; 2]> {
484 let n = self.n;
485 // Permute b into elimination-order indexing.
486 let mut y: Vec<[f64; 2]> = (0..n).map(|k| b[self.perm[k]]).collect();
487
488 // Forward substitution: solve L y = b (L unit lower triangular),
489 // column-oriented.
490 for k in 0..n {
491 let yk = y[k];
492 for &(row, lval) in &self.l_cols[k] {
493 y[row] = vec_sub(y[row], block_vec_mul(lval, yk));
494 }
495 }
496
497 // Backward substitution: solve U x = y (U upper triangular incl.
498 // diagonal), column-oriented, descending.
499 let mut x = y;
500 for j in (0..n).rev() {
501 // The diagonal entry is always the last (largest row index <= j,
502 // i.e. exactly j) in u_cols[j] since entries are sorted ascending
503 // and all rows are <= j.
504 let diag = self.u_cols[j]
505 .last()
506 .filter(|&&(row, _)| row == j)
507 .map(|&(_, v)| v)
508 .expect("factorize() always records the diagonal entry for column j");
509 let diag_inv = block_inv(diag)
510 .expect("diagonal was already validated non-singular during factorize()");
511 x[j] = block_vec_mul(diag_inv, x[j]);
512 let xj = x[j];
513 for &(row, uval) in &self.u_cols[j] {
514 if row < j {
515 x[row] = vec_sub(x[row], block_vec_mul(uval, xj));
516 }
517 }
518 }
519
520 // Permute back into original bus indexing.
521 let mut result = vec![[0.0, 0.0]; n];
522 for k in 0..n {
523 result[self.pos_to_orig(k)] = x[k];
524 }
525 result
526 }
527
528 fn pos_to_orig(&self, k: usize) -> usize {
529 self.perm[k]
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn block_inv_identity() {
539 let id: Block2 = [[1.0, 0.0], [0.0, 1.0]];
540 let inv = block_inv(id).unwrap();
541 assert_eq!(inv, id);
542 }
543
544 #[test]
545 fn block_inv_general() {
546 let a: Block2 = [[4.0, 3.0], [6.0, 3.0]];
547 let inv = block_inv(a).unwrap();
548 let product = block_mul(a, inv);
549 assert!((product[0][0] - 1.0).abs() < 1e-12);
550 assert!(product[0][1].abs() < 1e-12);
551 assert!(product[1][0].abs() < 1e-12);
552 assert!((product[1][1] - 1.0).abs() < 1e-12);
553 }
554
555 #[test]
556 fn block_inv_singular_returns_none() {
557 let a: Block2 = [[1.0, 2.0], [2.0, 4.0]];
558 assert!(block_inv(a).is_none());
559 }
560
561 #[test]
562 fn colamd_order_is_a_valid_permutation() {
563 // A small chain graph: 0-1-2-3.
564 let mut m = BlockMatrix::new(4);
565 let id = [[1.0, 0.0], [0.0, 1.0]];
566 for &(i, j) in &[
567 (0, 0),
568 (1, 1),
569 (2, 2),
570 (3, 3),
571 (0, 1),
572 (1, 0),
573 (1, 2),
574 (2, 1),
575 (2, 3),
576 (3, 2),
577 ] {
578 m.add(i, j, id);
579 }
580 let adj = m.finish();
581 let perm = adj.colamd_order();
582 let mut sorted = perm.clone();
583 sorted.sort_unstable();
584 assert_eq!(
585 sorted,
586 vec![0, 1, 2, 3],
587 "permutation must be a bijection on 0..n"
588 );
589 }
590
591 #[test]
592 fn finish_sums_duplicate_entries() {
593 // (0,0) contributed twice as [[1,0],[0,1]]+[[1,0],[0,1]]=[[2,0],[0,2]],
594 // matching YBus's/BlockMatrix's documented `+=` accumulation
595 // semantics (e.g. parallel branches between the same two buses).
596 let mut m = BlockMatrix::new(2);
597 m.add(0, 0, [[1.0, 0.0], [0.0, 1.0]]);
598 m.add(0, 0, [[1.0, 0.0], [0.0, 1.0]]);
599 m.add(0, 1, [[0.5, 0.0], [0.0, 0.5]]);
600 m.add(1, 0, [[0.5, 0.0], [0.0, 0.5]]);
601 m.add(1, 1, [[3.0, 0.0], [0.0, 3.0]]);
602 let adj = m.finish();
603 assert_eq!(adj.get(0, 0), [[2.0, 0.0], [0.0, 2.0]]);
604 assert_eq!(adj.get(0, 1), [[0.5, 0.0], [0.0, 0.5]]);
605 assert_eq!(adj.get(1, 1), [[3.0, 0.0], [0.0, 3.0]]);
606 // rows must stay sorted by column for BlockLu's structural-symmetry
607 // and binary-search assumptions to hold.
608 let cols: Vec<usize> = adj.row(0).iter().map(|&(j, _)| j).collect();
609 assert!(
610 cols.windows(2).all(|w| w[0] < w[1]),
611 "row(0) columns must be strictly ascending: {cols:?}"
612 );
613 }
614
615 /// A ground-truth oracle: expands an `n`-bus block system into a `2n×2n`
616 /// scalar dense matrix and solves it via straightforward Gaussian
617 /// elimination with partial pivoting. Independent of `BlockLu`'s
618 /// implementation (no shared code, no shared assumptions like "diagonal
619 /// pivoting is good enough" or "the sparsity pattern is symmetric"), so
620 /// cross-checking against it catches indexing/fill-in/ordering bugs that
621 /// small hand-verified cases alone could miss.
622 fn dense_reference_solve(adj: &BlockAdjacency, b: &[[f64; 2]]) -> Vec<[f64; 2]> {
623 let n = adj.n();
624 let m = 2 * n;
625 let mut a = vec![vec![0.0f64; m]; m];
626 for i in 0..n {
627 for &(j, block) in adj.row(i) {
628 for p in 0..2 {
629 for q in 0..2 {
630 a[2 * i + p][2 * j + q] = block[p][q];
631 }
632 }
633 }
634 }
635 let mut rhs = vec![0.0f64; m];
636 for i in 0..n {
637 rhs[2 * i] = b[i][0];
638 rhs[2 * i + 1] = b[i][1];
639 }
640
641 // Gaussian elimination with partial pivoting.
642 for col in 0..m {
643 let pivot_row = (col..m)
644 .max_by(|&r1, &r2| a[r1][col].abs().total_cmp(&a[r2][col].abs()))
645 .unwrap();
646 assert!(
647 a[pivot_row][col].abs() > 1e-12,
648 "dense reference matrix is singular"
649 );
650 a.swap(col, pivot_row);
651 rhs.swap(col, pivot_row);
652 for row in (col + 1)..m {
653 let factor = a[row][col] / a[col][col];
654 for k in col..m {
655 a[row][k] -= factor * a[col][k];
656 }
657 rhs[row] -= factor * rhs[col];
658 }
659 }
660 let mut x = vec![0.0f64; m];
661 for row in (0..m).rev() {
662 let mut sum = rhs[row];
663 for k in (row + 1)..m {
664 sum -= a[row][k] * x[k];
665 }
666 x[row] = sum / a[row][row];
667 }
668 (0..n).map(|i| [x[2 * i], x[2 * i + 1]]).collect()
669 }
670
671 fn assert_close(a: &[[f64; 2]], b: &[[f64; 2]], tol: f64) {
672 assert_eq!(a.len(), b.len());
673 for (i, (&x, &y)) in a.iter().zip(b.iter()).enumerate() {
674 assert!(
675 (x[0] - y[0]).abs() < tol && (x[1] - y[1]).abs() < tol,
676 "index {i}: {x:?} vs {y:?}"
677 );
678 }
679 }
680
681 #[test]
682 fn solve_single_bus() {
683 let mut m = BlockMatrix::new(1);
684 m.add(0, 0, [[4.0, 1.0], [1.0, 3.0]]);
685 let adj = m.finish();
686 let lu = BlockLu::factorize(&adj).unwrap();
687 let b = [[9.0, 8.0]];
688 let x = lu.solve(&b);
689 // [[4,1],[1,3]] * [x0,x1] = [9,8] => x0=(9*3-1*8)/(4*3-1*1)=19/11, x1=(4*8-1*9)/11=23/11
690 assert_close(&x, &[[19.0 / 11.0, 23.0 / 11.0]], 1e-10);
691 }
692
693 #[test]
694 fn solve_two_coupled_buses_hand_verified() {
695 // Block system:
696 // [[4,0],[0,4]] [[-1,0],[0,-1]] [x0] [3]
697 // [[-1,0],[0,-1]] [[4,0],[0,4]] * [x1] = [3]
698 // (decouples into two independent scalar systems 4y-z=3, -y+4z=3 per phase)
699 // scalar solution: y=z=1 for each phase => x0=x1=[1,1]
700 let mut m = BlockMatrix::new(2);
701 m.add(0, 0, [[4.0, 0.0], [0.0, 4.0]]);
702 m.add(1, 1, [[4.0, 0.0], [0.0, 4.0]]);
703 m.add(0, 1, [[-1.0, 0.0], [0.0, -1.0]]);
704 m.add(1, 0, [[-1.0, 0.0], [0.0, -1.0]]);
705 let adj = m.finish();
706 let lu = BlockLu::factorize(&adj).unwrap();
707 let b = [[3.0, 3.0], [3.0, 3.0]];
708 let x = lu.solve(&b);
709 assert_close(&x, &[[1.0, 1.0], [1.0, 1.0]], 1e-10);
710 }
711
712 #[test]
713 fn solve_singular_returns_none() {
714 let mut m = BlockMatrix::new(1);
715 // Singular block (rows are multiples of each other).
716 m.add(0, 0, [[1.0, 2.0], [2.0, 4.0]]);
717 let adj = m.finish();
718 assert!(BlockLu::factorize(&adj).is_none());
719 }
720
721 #[test]
722 fn solve_chain_matches_dense_reference() {
723 // A radial chain of 6 buses: 0-1-2-3-4-5, each with self-admittance
724 // plus coupling to its neighbor(s) — deliberately not diagonally
725 // trivial, to exercise real fill-in during elimination.
726 let n = 6;
727 let mut m = BlockMatrix::new(n);
728 for i in 0..n {
729 m.add(
730 i,
731 i,
732 [[3.0 + i as f64 * 0.3, 0.2], [0.15, 2.5 + i as f64 * 0.2]],
733 );
734 }
735 for i in 0..n - 1 {
736 let off = [[-0.8, 0.05], [0.03, -0.7]];
737 m.add(i, i + 1, off);
738 m.add(i + 1, i, off);
739 }
740 let adj = m.finish();
741 let lu = BlockLu::factorize(&adj).unwrap();
742 let b: Vec<[f64; 2]> = (0..n)
743 .map(|i| [1.0 + i as f64, 2.0 - i as f64 * 0.5])
744 .collect();
745 let x = lu.solve(&b);
746 let expected = dense_reference_solve(&adj, &b);
747 assert_close(&x, &expected, 1e-8);
748 }
749
750 #[test]
751 fn solve_asymmetric_off_diagonal_matches_dense_reference() {
752 // Deliberately `block(i, j) != block(j, i)` — unlike every other
753 // test in this file, which always adds the same `off` value both
754 // ways. The real power-flow Jacobian is exactly this shape (its
755 // H/N/M/L formulas aren't symmetric under swapping bus i and k), and
756 // `BlockLu::refactor` used to scatter `adj.row(perm[j])`'s values
757 // where it needed `adj.col(perm[j])`'s — silently wrong on any
758 // matrix without value symmetry, which every prior hand-written
759 // test here happened to have.
760 let n = 3;
761 let mut m = BlockMatrix::new(n);
762 m.add(0, 0, [[6.0, 0.2], [0.15, 5.0]]);
763 m.add(1, 1, [[7.0, 0.1], [0.2, 4.5]]);
764 m.add(2, 2, [[8.0, 0.3], [0.1, 5.5]]);
765 m.add(0, 1, [[-0.9, 0.3], [-0.2, -0.4]]);
766 m.add(1, 0, [[-0.5, -0.1], [0.4, -0.7]]);
767 m.add(1, 2, [[-0.6, 0.2], [0.1, -0.3]]);
768 m.add(2, 1, [[-0.3, -0.2], [0.25, -0.55]]);
769 let adj = m.finish();
770 let lu = BlockLu::factorize(&adj).unwrap();
771 let b: Vec<[f64; 2]> = vec![[1.0, 2.0], [3.0, -1.0], [0.5, 1.5]];
772 let x = lu.solve(&b);
773 let expected = dense_reference_solve(&adj, &b);
774 assert_close(&x, &expected, 1e-8);
775 }
776
777 #[test]
778 fn solve_mesh_matches_dense_reference() {
779 // A small ring/mesh (0-1-2-3-4-0) plus a chord (0-2), so colamd's
780 // ordering must handle genuine fill-in from a cycle, not just a
781 // tree/chain.
782 let n = 5;
783 let mut m = BlockMatrix::new(n);
784 for i in 0..n {
785 m.add(
786 i,
787 i,
788 [[5.0 + i as f64 * 0.4, 0.3], [0.2, 4.0 + i as f64 * 0.25]],
789 );
790 }
791 let edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2)];
792 for &(i, j) in &edges {
793 let off = [[-0.6, 0.04], [0.02, -0.5]];
794 m.add(i, j, off);
795 m.add(j, i, off);
796 }
797 let adj = m.finish();
798 let lu = BlockLu::factorize(&adj).unwrap();
799 let b: Vec<[f64; 2]> = (0..n)
800 .map(|i| [0.5 * i as f64 + 1.0, 3.0 - 0.3 * i as f64])
801 .collect();
802 let x = lu.solve(&b);
803 let expected = dense_reference_solve(&adj, &b);
804 assert_close(&x, &expected, 1e-8);
805 }
806
807 #[test]
808 fn solve_matches_dense_reference_random_systems() {
809 // Several pseudo-random diagonally-dominant sparse systems on
810 // varying small topologies, cross-checked against the dense oracle.
811 // Fixed, hand-rolled LCG (no extra dependency) for determinism.
812 let mut seed: u64 = 0x2545F4914F6CDD1D;
813 let mut next = move || {
814 seed ^= seed << 13;
815 seed ^= seed >> 7;
816 seed ^= seed << 17;
817 (seed >> 11) as f64 / (1u64 << 53) as f64
818 };
819
820 for trial in 0..8 {
821 let n = 4 + trial % 5;
822 let mut m = BlockMatrix::new(n);
823 // Random tree (guarantees connectivity) plus a few extra chords.
824 for i in 1..n {
825 let parent = (next() * i as f64) as usize;
826 let off = [[-0.3 - next(), 0.1 * next()], [0.1 * next(), -0.3 - next()]];
827 m.add(parent, i, off);
828 m.add(i, parent, off);
829 }
830 for _ in 0..n / 2 {
831 let a = (next() * n as f64) as usize;
832 let b_idx = (next() * n as f64) as usize;
833 if a != b_idx {
834 let off = [
835 [-0.2 - next(), 0.05 * next()],
836 [0.05 * next(), -0.2 - next()],
837 ];
838 m.add(a, b_idx, off);
839 m.add(b_idx, a, off);
840 }
841 }
842 // Diagonally dominant self-admittance, well within the no-pivoting scope.
843 for i in 0..n {
844 m.add(
845 i,
846 i,
847 [[6.0 + next(), 0.2 * next()], [0.2 * next(), 6.0 + next()]],
848 );
849 }
850 let adj = m.finish();
851 let lu = BlockLu::factorize(&adj)
852 .unwrap_or_else(|| panic!("trial {trial}: expected non-singular"));
853 let rhs: Vec<[f64; 2]> = (0..n)
854 .map(|_| [next() * 4.0 - 2.0, next() * 4.0 - 2.0])
855 .collect();
856 let x = lu.solve(&rhs);
857 let expected = dense_reference_solve(&adj, &rhs);
858 assert_close(&x, &expected, 1e-7);
859 }
860 }
861
862 #[test]
863 fn refactor_reuses_symbolic_across_different_values() {
864 // Same shape as sparse::tests::real_sparse_system_refactor_reuses_symbolic:
865 // analyze the structure once, then refactor twice with different
866 // numeric values but the *same* sparsity pattern (as across
867 // Newton-Raphson iterations), checking each refactor's result
868 // independently matches a from-scratch `factorize`.
869 let n = 5;
870 let build = |scale: f64| {
871 let mut m = BlockMatrix::new(n);
872 for i in 0..n {
873 m.add(
874 i,
875 i,
876 [
877 [6.0 + i as f64 * scale, 0.2],
878 [0.15, 5.0 + i as f64 * scale],
879 ],
880 );
881 }
882 for i in 0..n - 1 {
883 let off = [[-0.9 * scale, 0.05], [0.04, -0.8 * scale]];
884 m.add(i, i + 1, off);
885 m.add(i + 1, i, off);
886 }
887 m.finish()
888 };
889
890 let adj_a = build(1.0);
891 let symbolic = BlockSymbolic::analyze(&adj_a);
892
893 let b: Vec<[f64; 2]> = (0..n)
894 .map(|i| [1.0 + i as f64 * 0.3, -0.5 + i as f64 * 0.2])
895 .collect();
896
897 for &scale in &[1.0, 2.3, 0.6] {
898 let adj = build(scale);
899 let via_refactor = BlockLu::refactor(&symbolic, &adj).unwrap();
900 let via_factorize = BlockLu::factorize(&adj).unwrap();
901 let x_refactor = via_refactor.solve(&b);
902 let x_factorize = via_factorize.solve(&b);
903 assert_close(&x_refactor, &x_factorize, 1e-10);
904 }
905 }
906}