Skip to main content

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}