powerio_matrix/matrix/laplacian.rs
1//! Weighted bus Laplacians `L = A diag(w) Aᵀ`, reference grounding, and the
2//! index bookkeeping for mapping a grounded solve back to full size. For DC
3//! power flow, `w` is the branch susceptance vector `b`, and `L` is the DC bus
4//! susceptance matrix.
5//!
6//! Built from the same `A`, `w` factors the incidence module produces, so
7//! `L` and its reference-grounded form share an exact factorization.
8
9use sprs::CsMat;
10
11use crate::matrix::incidence::diagonal;
12use crate::matrix::triplet::CooBuilder;
13
14/// `L = A diag(w) Aᵀ` (n×n). With `w = b` this is the DC bus susceptance matrix;
15/// with `w = b²·θ_f⁻¹` it is the reweighted bus Laplacian `L₁` from the KKT
16/// system.
17pub fn build_weighted_laplacian(a: &CsMat<f64>, w: &[f64]) -> CsMat<f64> {
18 let d = diagonal(w);
19 let at = a.transpose_view().to_csr();
20 a * &(&d * &at)
21}
22
23/// Delete row `r` and column `r` from a square matrix, returning the
24/// `(n−1)×(n−1)` grounded matrix. Used to remove the slack bus so a singular
25/// Laplacian becomes SPD. The single-reference case of [`ground_at_each`].
26///
27/// # Panics
28///
29/// Panics if `r >= matrix.rows()`: with no row/column to remove the result
30/// would be silently the wrong shape.
31pub fn ground_at(matrix: &CsMat<f64>, r: usize) -> CsMat<f64> {
32 ground_at_each(matrix, &[r])
33}
34
35/// Delete every row and column in `refs` from a square matrix, returning the
36/// grounded matrix of side `n − k`, where `k` is the count of distinct
37/// in-range references. Grounding one bus per connected component turns a
38/// singular Laplacian SPD. Grounding several buses within one component fixes
39/// several angles to zero; this is not a participation factor based slack model.
40///
41/// # Panics
42///
43/// Panics if any reference is `>= matrix.rows()`: the builder is sized `n − k`,
44/// so an out-of-range index would silently yield the wrong shape.
45pub fn ground_at_each(matrix: &CsMat<f64>, refs: &[usize]) -> CsMat<f64> {
46 ground_with(matrix, &Grounding::new(refs))
47}
48
49/// A sorted, de-duplicated set of grounded indices and the reduced-index map it
50/// induces: drop the grounded rows/columns and shift the survivors down to a
51/// dense `[0, n − k)` range. The PTDF builder shares it, so the row/column
52/// removal lives in one place.
53pub(crate) struct Grounding {
54 grounds: Vec<usize>,
55}
56
57impl Grounding {
58 pub(crate) fn new(refs: &[usize]) -> Self {
59 let mut grounds = refs.to_vec();
60 // Sorted + de-duplicated so the shift is monotone and a repeated
61 // reference doesn't over-count the removal.
62 grounds.sort_unstable();
63 grounds.dedup();
64 Self { grounds }
65 }
66
67 /// Number of grounded indices `k`.
68 pub(crate) fn len(&self) -> usize {
69 self.grounds.len()
70 }
71
72 /// The largest grounded index, or `None` if nothing is grounded.
73 pub(crate) fn max(&self) -> Option<usize> {
74 self.grounds.last().copied()
75 }
76
77 /// Full index → reduced index, or `None` for a grounded index. The shift is
78 /// `i − (number of grounds strictly below i)`.
79 pub(crate) fn reduced(&self, i: usize) -> Option<usize> {
80 // One search, not two: `below` is the count of grounds under `i`, and
81 // the entry at that position is `i` itself exactly when `i` is
82 // grounded.
83 let below = self.grounds.partition_point(|&g| g < i);
84 if self.grounds.get(below) == Some(&i) {
85 None
86 } else {
87 Some(i - below)
88 }
89 }
90
91 /// Full indices of the surviving rows, reduced index order. Hoists
92 /// [`Self::reduced`] out of a loop that would otherwise call it per
93 /// column.
94 pub(crate) fn full_of_reduced(&self, n: usize) -> Vec<usize> {
95 (0..n).filter(|&i| self.reduced(i).is_some()).collect()
96 }
97}
98
99/// Drop the rows and columns a [`Grounding`] marks, shifting survivors down.
100///
101/// # Panics
102///
103/// Panics if a grounded index is `>= matrix.rows()`: the builder is sized
104/// `n − k`, so an out-of-range index would silently yield the wrong shape.
105pub(crate) fn ground_with(matrix: &CsMat<f64>, g: &Grounding) -> CsMat<f64> {
106 let n = matrix.rows();
107 debug_assert_eq!(n, matrix.cols(), "ground_with expects a square matrix");
108 // Hard assert: an out-of-range index removes no row/column
109 // yet shrinks the builder. These are `pub` entry points, so guard the
110 // invariant unconditionally.
111 if let Some(last) = g.max() {
112 assert!(
113 last < n,
114 "ground_with: index {last} out of range for {n}x{n} matrix"
115 );
116 }
117 let mut out = CooBuilder::new(n.saturating_sub(g.len()));
118 for (&v, (i, j)) in matrix {
119 if let (Some(ri), Some(rj)) = (g.reduced(i), g.reduced(j)) {
120 out.add(ri, rj, v);
121 }
122 }
123 out.finish_csr()
124}
125
126/// Maps indices between the full `[0, n)` space and the grounded `[0, n−1)`
127/// space (row/column `r` removed). Problem specific solvers use it to place a
128/// grounded solve back into full bus order.
129#[derive(Debug, Clone, Copy)]
130pub struct GroundedIndexMap {
131 pub n: usize,
132 pub r: usize,
133}
134
135impl GroundedIndexMap {
136 #[inline]
137 pub fn new(n: usize, r: usize) -> Self {
138 Self { n, r }
139 }
140
141 /// Full index → grounded index. `None` for the grounded-out bus `r`.
142 #[inline]
143 pub fn full_to_reduced(&self, i: usize) -> Option<usize> {
144 match i {
145 _ if i == self.r => None,
146 _ if i > self.r => Some(i - 1),
147 _ => Some(i),
148 }
149 }
150
151 /// Grounded index → full index.
152 #[inline]
153 pub fn reduced_to_full(&self, i: usize) -> usize {
154 if i >= self.r { i + 1 } else { i }
155 }
156}
157
158/// The unit vector `e_r`, length `n`.
159pub fn unit_vector(n: usize, r: usize) -> Vec<f64> {
160 let mut e = vec![0.0; n];
161 if r < n {
162 e[r] = 1.0;
163 }
164 e
165}
166
167/// The reference indicator, length `n`: `1` at every grounded (slack) bus, `0`
168/// elsewhere. The multi-reference form of [`unit_vector`]; a downstream solver
169/// reads it to recover which buses were grounded.
170pub fn reference_indicator(n: usize, refs: &[usize]) -> Vec<f64> {
171 let mut e = vec![0.0; n];
172 for &r in refs {
173 if r < n {
174 e[r] = 1.0;
175 }
176 }
177 e
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn grounding_reduced_shifts_survivors() {
186 // Ground indices 1 and 3 of a 5-wide space: survivors 0,2,4 -> 0,1,2.
187 let g = Grounding::new(&[1, 3]);
188 assert_eq!(g.len(), 2);
189 assert_eq!(g.reduced(0), Some(0));
190 assert_eq!(g.reduced(1), None);
191 assert_eq!(g.reduced(2), Some(1));
192 assert_eq!(g.reduced(3), None);
193 assert_eq!(g.reduced(4), Some(2));
194 }
195
196 #[test]
197 fn grounding_sorts_and_dedups() {
198 // Unsorted, repeated input collapses so the shift stays monotone and a
199 // repeated reference doesn't over-remove a row/column.
200 let g = Grounding::new(&[3, 1, 3]);
201 assert_eq!(g.len(), 2);
202 assert_eq!(g.max(), Some(3));
203 assert_eq!(g.reduced(2), Some(1));
204 assert_eq!(g.reduced(4), Some(2));
205 }
206
207 fn diag_matrix(vals: &[f64]) -> CsMat<f64> {
208 let mut b = CooBuilder::new(vals.len());
209 for (i, &v) in vals.iter().enumerate() {
210 b.add(i, i, v);
211 }
212 b.finish_csr()
213 }
214
215 #[test]
216 fn ground_at_each_removes_rows_and_cols() {
217 let m = diag_matrix(&[10.0, 20.0, 30.0, 40.0]);
218 // Ground index 1: a 3x3 with diag 10,30,40 (survivors shifted down).
219 let g1 = ground_at_each(&m, &[1]);
220 assert_eq!((g1.rows(), g1.cols()), (3, 3));
221 assert_eq!(g1.get(0, 0), Some(&10.0));
222 assert_eq!(g1.get(1, 1), Some(&30.0));
223 assert_eq!(g1.get(2, 2), Some(&40.0));
224 // Ground 0 and 2 from an unsorted set: a 2x2 with diag 20,40.
225 let g2 = ground_at_each(&m, &[2, 0]);
226 assert_eq!((g2.rows(), g2.cols()), (2, 2));
227 assert_eq!(g2.get(0, 0), Some(&20.0));
228 assert_eq!(g2.get(1, 1), Some(&40.0));
229 }
230
231 #[test]
232 fn reference_indicator_marks_each_ref() {
233 assert_eq!(reference_indicator(4, &[0, 2]), vec![1.0, 0.0, 1.0, 0.0]);
234 // Out-of-range refs are ignored, not a panic.
235 assert_eq!(reference_indicator(3, &[5]), vec![0.0, 0.0, 0.0]);
236 // The single-reference case is exactly unit_vector.
237 assert_eq!(reference_indicator(3, &[1]), unit_vector(3, 1));
238 }
239}