Skip to main content

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}