Skip to main content

powerio_matrix/matrix/
sensitivity.rs

1//! DC sensitivity matrices.
2//!
3//! PTDF maps nodal injections to branch flows (`f = PTDF · p`); LODF maps a
4//! branch outage to the flow it redistributes onto the others. Both come from
5//! the reference grounded DC Laplacian `ABA = ground_with(L, refs)`: one
6//! row/column removed per reference bus. Positive branch weights use a dense
7//! Cholesky factorization; nonsingular indefinite cases fall back to dense
8//! Gaussian elimination. Disconnected networks with one reference per island are
9//! supported. Several references in one island are fixed angle buses; this is
10//! not a participation factor based distributed slack model. PTDF is dense
11//! `m × n`; a future sparse path would compute selected columns or use sparse
12//! factors rather than make PTDF itself sparse.
13
14// Dense linear algebra: indexed triangular-solve loops and the `.iter()`
15// sparse traversal read clearer than the iterator rewrites clippy suggests.
16#![allow(clippy::needless_range_loop, clippy::explicit_iter_loop)]
17
18use sprs::CsMat;
19
20use crate::indexed::IndexedNetwork;
21use crate::matrix::BuildOptions;
22use crate::matrix::incidence::{DcConvention, IncidenceParts, build_flow_map, build_incidence};
23use crate::matrix::laplacian::{Grounding, build_weighted_laplacian, ground_with};
24use crate::matrix::triplet::CooBuilder;
25use crate::{Error, Result};
26
27/// Entries below this magnitude are dropped from the emitted sparse matrices.
28const PRUNE: f64 = 1e-12;
29
30/// PTDF (`m × n`): branch flows from nodal injections, `f = PTDF · p`. Every
31/// reference-bus column is zero. The Laplacian is grounded at the whole
32/// reference set (`reference_bus_indices`), one row/column per slack. One
33/// reference per island handles disconnected networks; several references within
34/// one island fixes all of those bus angles to zero.
35pub fn build_ptdf(case: &IndexedNetwork, conv: DcConvention) -> Result<CsMat<f64>> {
36    case.check_reference_coverage()?;
37    let refs = case.reference_bus_indices();
38    let inc = build_incidence(case, conv, &BuildOptions::default())?;
39    let (dense, m, n) = ptdf_dense(&inc, &refs)?;
40    Ok(dense_to_csr(&dense, m, n))
41}
42
43/// LODF (`m × m`): pre-outage flow on branch `k` redistributes onto branch `l`
44/// with factor `LODF[l, k]`. Diagonal is `−1`. A branch whose outage islands
45/// the network (denominator `≈ 0`) gets a zero column.
46pub fn build_lodf(case: &IndexedNetwork, conv: DcConvention) -> Result<CsMat<f64>> {
47    case.check_reference_coverage()?;
48    let refs = case.reference_bus_indices();
49    let inc = build_incidence(case, conv, &BuildOptions::default())?;
50    let (ptdf, m, n) = ptdf_dense(&inc, &refs)?;
51    Ok(lodf_from_dense(&ptdf, &inc.a, m, n))
52}
53
54/// Both DC sensitivity matrices `(PTDF, LODF)` from a single Laplacian
55/// factorization. When a caller needs both for the same case (the
56/// `sensitivities` bundle), this factors and inverts the grounded Laplacian
57/// once instead of paying the O(n³) twice across separate
58/// [`build_ptdf`]/[`build_lodf`] calls.
59pub fn build_ptdf_lodf(
60    case: &IndexedNetwork,
61    conv: DcConvention,
62) -> Result<(CsMat<f64>, CsMat<f64>)> {
63    case.check_reference_coverage()?;
64    let refs = case.reference_bus_indices();
65    let inc = build_incidence(case, conv, &BuildOptions::default())?;
66    let (dense, m, n) = ptdf_dense(&inc, &refs)?;
67    let ptdf = dense_to_csr(&dense, m, n);
68    let lodf = lodf_from_dense(&dense, &inc.a, m, n);
69    Ok((ptdf, lodf))
70}
71
72/// LODF from a dense PTDF and the signed incidence (the shared tail of
73/// [`build_lodf`] and [`build_ptdf_lodf`]).
74fn lodf_from_dense(ptdf: &[f64], a: &CsMat<f64>, m: usize, n: usize) -> CsMat<f64> {
75    // Branch endpoints (dense bus indices), recovered from the incidence.
76    let (from, to) = endpoints(a, m);
77
78    // δ[l,k] = PTDF[l, from_k] − PTDF[l, to_k]: flow on l from a unit transfer
79    // along branch k.
80    let delta = |l: usize, k: usize| ptdf[l * n + from[k]] - ptdf[l * n + to[k]];
81
82    let mut lodf = CooBuilder::new(m); // m × m
83    for k in 0..m {
84        let denom = 1.0 - delta(k, k);
85        let islands = denom.abs() < 1e-9;
86        for l in 0..m {
87            let v = if l == k {
88                -1.0
89            } else if islands {
90                0.0
91            } else {
92                delta(l, k) / denom
93            };
94            if v.abs() > PRUNE {
95                lodf.add(l, k, v);
96            }
97        }
98    }
99    lodf.finish_csr()
100}
101
102/// Dense PTDF (`m × n`, row-major) plus its shape. `refs` is the reference set;
103/// the Laplacian is grounded at every entry (one row/column each).
104fn ptdf_dense(inc: &IncidenceParts, refs: &[usize]) -> Result<(Vec<f64>, usize, usize)> {
105    let n = inc.n();
106    let m = inc.m();
107    let g = Grounding::new(refs);
108    let nr = n - g.len();
109
110    // Reduced inverse of the grounded Laplacian: Rinv = (ABA_refs)^{-1}.
111    let lr = ground_with(&build_weighted_laplacian(&inc.a, &inc.b), &g);
112    let dense_lr = densify(&lr, nr);
113    let rinv = DenseCholesky::factor(&dense_lr, nr).map_or_else(
114        || dense_inverse(&dense_lr, nr).ok_or(Error::SingularNetwork),
115        |chol| Ok(chol.inverse()),
116    )?; // nr × nr, row-major
117
118    // Minv (n × n) is Rinv padded with a zero row/col at every grounded bus, so
119    // each reference's PTDF column comes out zero. PTDF = (B Aᵀ) · Minv, computed
120    // sparse-times-dense: each nonzero of the flow map scatters a scaled Minv row
121    // into a PTDF row.
122    let flow = build_flow_map(&inc.a, &inc.b); // m × n
123    let mut ptdf = vec![0.0; m * n];
124    for (&w, (l, c)) in flow.iter() {
125        let Some(rc) = g.reduced(c) else { continue }; // Minv row at a slack is 0
126        for k in 0..n {
127            if let Some(rk) = g.reduced(k) {
128                ptdf[l * n + k] += w * rinv[rc * nr + rk];
129            }
130        }
131    }
132    Ok((ptdf, m, n))
133}
134
135/// Branch endpoints from the signed incidence: `+1` row is from, `−1` is to.
136fn endpoints(a: &CsMat<f64>, m: usize) -> (Vec<usize>, Vec<usize>) {
137    let mut from = vec![0usize; m];
138    let mut to = vec![0usize; m];
139    for (&v, (bus, branch)) in a.iter() {
140        if v > 0.0 {
141            from[branch] = bus;
142        } else {
143            to[branch] = bus;
144        }
145    }
146    (from, to)
147}
148
149fn densify(a: &CsMat<f64>, n: usize) -> Vec<f64> {
150    let mut d = vec![0.0; n * n];
151    for (&v, (i, j)) in a.iter() {
152        d[i * n + j] = v;
153    }
154    d
155}
156
157fn dense_to_csr(dense: &[f64], rows: usize, cols: usize) -> CsMat<f64> {
158    let mut coo = CooBuilder::with_capacity_rect(rows, cols, dense.len() / 2);
159    for i in 0..rows {
160        for j in 0..cols {
161            let v = dense[i * cols + j];
162            if v.abs() > PRUNE {
163                coo.add(i, j, v);
164            }
165        }
166    }
167    coo.finish_csr()
168}
169
170fn dense_inverse(a: &[f64], n: usize) -> Option<Vec<f64>> {
171    let mut a = a.to_vec();
172    let mut inv = vec![0.0; n * n];
173    for i in 0..n {
174        inv[i * n + i] = 1.0;
175    }
176
177    for col in 0..n {
178        let mut pivot_row = col;
179        let mut pivot_abs = a[col * n + col].abs();
180        for r in (col + 1)..n {
181            let v = a[r * n + col].abs();
182            if v > pivot_abs {
183                pivot_abs = v;
184                pivot_row = r;
185            }
186        }
187        if !pivot_abs.is_finite() || pivot_abs <= 1e-12 {
188            return None;
189        }
190        if pivot_row != col {
191            swap_dense_rows(&mut a, n, pivot_row, col);
192            swap_dense_rows(&mut inv, n, pivot_row, col);
193        }
194
195        let pivot = a[col * n + col];
196        for c in 0..n {
197            a[col * n + c] /= pivot;
198            inv[col * n + c] /= pivot;
199        }
200        for r in 0..n {
201            if r == col {
202                continue;
203            }
204            let factor = a[r * n + col];
205            if factor == 0.0 {
206                continue;
207            }
208            for c in 0..n {
209                a[r * n + c] -= factor * a[col * n + c];
210                inv[r * n + c] -= factor * inv[col * n + c];
211            }
212        }
213    }
214    Some(inv)
215}
216
217fn swap_dense_rows(a: &mut [f64], n: usize, r1: usize, r2: usize) {
218    for c in 0..n {
219        a.swap(r1 * n + c, r2 * n + c);
220    }
221}
222
223/// Dense lower-triangular Cholesky `A = L Lᵀ` for a small SPD matrix.
224struct DenseCholesky {
225    n: usize,
226    l: Vec<f64>, // row-major lower triangle
227}
228
229impl DenseCholesky {
230    fn factor(a: &[f64], n: usize) -> Option<Self> {
231        let mut l = vec![0.0; n * n];
232        for i in 0..n {
233            for j in 0..=i {
234                let mut s = a[i * n + j];
235                for k in 0..j {
236                    s -= l[i * n + k] * l[j * n + k];
237                }
238                if i == j {
239                    // `!(s > 0.0)` rejects negative, zero, AND NaN pivots:
240                    // `NaN <= 0.0` is false, so `s <= 0.0` would let a
241                    // NaN-poisoned matrix factor "successfully" into all-NaN.
242                    // The negated comparison is the point (NaN incomparability),
243                    // so the partial_cmp rewrite clippy suggests would obscure it.
244                    #[allow(clippy::neg_cmp_op_on_partial_ord)]
245                    if !(s > 0.0) {
246                        return None;
247                    }
248                    l[i * n + i] = s.sqrt();
249                } else {
250                    l[i * n + j] = s / l[j * n + j];
251                }
252            }
253        }
254        Some(Self { n, l })
255    }
256
257    /// Solve `A x = b` in place.
258    fn solve(&self, b: &mut [f64]) {
259        let n = self.n;
260        for i in 0..n {
261            let mut s = b[i];
262            for k in 0..i {
263                s -= self.l[i * n + k] * b[k];
264            }
265            b[i] = s / self.l[i * n + i];
266        }
267        for i in (0..n).rev() {
268            let mut s = b[i];
269            for k in (i + 1)..n {
270                s -= self.l[k * n + i] * b[k];
271            }
272            b[i] = s / self.l[i * n + i];
273        }
274    }
275
276    /// Full inverse, row-major. The matrix is symmetric, so rows = columns.
277    fn inverse(&self) -> Vec<f64> {
278        let n = self.n;
279        let mut inv = vec![0.0; n * n];
280        let mut e = vec![0.0; n];
281        for j in 0..n {
282            e.fill(0.0);
283            e[j] = 1.0;
284            self.solve(&mut e);
285            for (i, &x) in e.iter().enumerate() {
286                inv[i * n + j] = x;
287            }
288        }
289        inv
290    }
291}