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