Skip to main content

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}