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