use crate::types::{Bus, BusType};
use gridoxide_sparse::sparse::RealFactorization;
use super::btheta::{DcBranch, NOT_UNKNOWN, Partition, partition, reduced_b_triplets};
use crate::network::Verdict;
const LODF_BRIDGE_TOL: f64 = 1e-9;
#[derive(Clone, Debug)]
pub struct DenseMatrix {
pub rows: usize,
pub cols: usize,
pub data: Vec<f64>,
}
impl DenseMatrix {
pub fn get(&self, row: usize, col: usize) -> f64 {
self.data[row * self.cols + col]
}
pub fn row(&self, row: usize) -> &[f64] {
&self.data[row * self.cols..(row + 1) * self.cols]
}
}
struct Island {
factorization: RealFactorization,
unknown: Vec<usize>,
branch_positions: Vec<usize>,
}
pub struct DcSensitivity {
n_buses: usize,
n_branches: usize,
branches: Vec<DcBranch>,
islands: Vec<Island>,
island_of: Vec<usize>,
referenced: Vec<bool>,
reduced_pos: Vec<usize>,
branch_position: Vec<usize>,
}
impl DcSensitivity {
pub fn new(buses: &[Bus], branches: &[DcBranch], n_branches: usize) -> Option<Self> {
let n = buses.len();
let Partition {
classified,
island_branches,
} = partition(buses, branches);
let mut islands = Vec::new();
let mut island_of = vec![NOT_UNKNOWN; n];
let mut referenced = vec![false; n];
let mut reduced_pos = vec![NOT_UNKNOWN; n];
let mut branch_position = vec![NOT_UNKNOWN; n_branches];
for (pos, br) in branches.iter().enumerate() {
branch_position[br.index] = pos;
}
for (ci, c) in classified.iter().enumerate() {
if matches!(c.verdict, Verdict::NoReferenceBus) {
continue;
}
for &i in &c.bus_indices {
referenced[i] = true;
}
let mut unknown = Vec::new();
for &i in &c.bus_indices {
if buses[i].bus_type != BusType::Slack {
reduced_pos[i] = unknown.len();
unknown.push(i);
}
}
let m = unknown.len();
if m == 0 {
continue;
}
let triplets = reduced_b_triplets(branches, &island_branches[ci], &reduced_pos);
let factorization = RealFactorization::new(m, &triplets)?;
let idx = islands.len();
for &i in &c.bus_indices {
island_of[i] = idx;
}
islands.push(Island {
factorization,
unknown,
branch_positions: island_branches[ci].clone(),
});
}
Some(Self {
n_buses: n,
n_branches,
branches: branches.to_vec(),
islands,
island_of,
referenced,
reduced_pos,
branch_position,
})
}
pub fn n_buses(&self) -> usize {
self.n_buses
}
pub fn n_branches(&self) -> usize {
self.n_branches
}
fn flows_from_angles(&self, island: &Island, theta: &[f64]) -> Vec<f64> {
let angle = |bus: usize| -> f64 {
match self.reduced_pos[bus] {
NOT_UNKNOWN => 0.0,
r => theta[r],
}
};
let mut out = vec![0.0; self.n_branches];
for &pos in &island.branch_positions {
let br = &self.branches[pos];
out[br.index] = br.b * (angle(br.from) - angle(br.to));
}
out
}
pub fn transfer_factors(&self, injections: &[f64]) -> Option<Vec<f64>> {
self.response(injections).map(|(_, flows)| flows)
}
pub fn response(&self, injections: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
if injections.len() != self.n_buses {
return None;
}
let mut d_angle = vec![0.0; self.n_buses];
let mut d_flow = vec![0.0; self.n_branches];
for island in &self.islands {
let rhs: Vec<f64> = island.unknown.iter().map(|&i| injections[i]).collect();
if rhs.iter().all(|v| *v == 0.0) {
continue;
}
let theta = island.factorization.solve(&rhs)?;
for (dst, src) in d_flow
.iter_mut()
.zip(self.flows_from_angles(island, &theta))
{
*dst += src;
}
for (r, &bus) in island.unknown.iter().enumerate() {
d_angle[bus] += theta[r];
}
}
Some((d_angle, d_flow))
}
pub fn outage_flows(&self, base_flows: &[f64], branch: usize) -> Option<Vec<f64>> {
self.multi_outage_flows(base_flows, &[branch])
}
pub fn multi_outage_flows(&self, base_flows: &[f64], branches: &[usize]) -> Option<Vec<f64>> {
if base_flows.len() != self.n_branches {
return None;
}
if branches.is_empty() {
return Some(base_flows.to_vec());
}
for (i, a) in branches.iter().enumerate() {
if branches[i + 1..].contains(a) {
return None;
}
}
let k = branches.len();
let mut psi = Vec::with_capacity(k);
for &l in branches {
psi.push(self.outage_response(l)?.0);
}
let mut m = vec![vec![0.0; k]; k];
for (row, &branch_m) in branches.iter().enumerate() {
for (col, _) in branches.iter().enumerate() {
let identity = if row == col { 1.0 } else { 0.0 };
m[row][col] = identity - psi[col][branch_m];
}
}
let rhs: Vec<f64> = branches.iter().map(|&l| base_flows[l]).collect();
let c = gridoxide_sparse::sparse::solve_dense(m, rhs)?;
let mut out = base_flows.to_vec();
for (idx, &factor) in c.iter().enumerate() {
for (dst, src) in out.iter_mut().zip(&psi[idx]) {
*dst += factor * src;
}
}
for &l in branches {
out[l] = 0.0;
}
Some(out)
}
pub fn is_breaking_set(&self, branches: &[usize]) -> bool {
self.multi_outage_flows(&vec![0.0; self.n_branches], branches)
.is_none()
}
pub fn ptdf_column(&self, bus: usize) -> Option<Vec<f64>> {
if !self.referenced.get(bus).copied().unwrap_or(false) {
return None;
}
let r = self.reduced_pos[bus];
if r == NOT_UNKNOWN || self.island_of[bus] == NOT_UNKNOWN {
return Some(vec![0.0; self.n_branches]);
}
let island = &self.islands[self.island_of[bus]];
let mut rhs = vec![0.0; island.unknown.len()];
rhs[r] = 1.0;
let theta = island.factorization.solve(&rhs)?;
Some(self.flows_from_angles(island, &theta))
}
fn branch_and_island(&self, branch: usize) -> Option<(&DcBranch, &Island)> {
let pos = *self.branch_position.get(branch)?;
if pos == NOT_UNKNOWN {
return None;
}
let br = &self.branches[pos];
let island_idx = self.island_of[br.from];
if island_idx == NOT_UNKNOWN {
return None;
}
Some((br, &self.islands[island_idx]))
}
pub fn ptdf_row(&self, branch: usize) -> Option<Vec<f64>> {
let (br, island) = self.branch_and_island(branch)?;
let mut rhs = vec![0.0; island.unknown.len()];
if self.reduced_pos[br.from] != NOT_UNKNOWN {
rhs[self.reduced_pos[br.from]] += br.b;
}
if self.reduced_pos[br.to] != NOT_UNKNOWN {
rhs[self.reduced_pos[br.to]] -= br.b;
}
let x = island.factorization.solve(&rhs)?;
let mut out = vec![0.0; self.n_buses];
for (r, &bus) in island.unknown.iter().enumerate() {
out[bus] = x[r];
}
Some(out)
}
fn outage_response(&self, branch: usize) -> Option<(Vec<f64>, f64)> {
let (br, island) = self.branch_and_island(branch)?;
let mut rhs = vec![0.0; island.unknown.len()];
if self.reduced_pos[br.from] != NOT_UNKNOWN {
rhs[self.reduced_pos[br.from]] += 1.0;
}
if self.reduced_pos[br.to] != NOT_UNKNOWN {
rhs[self.reduced_pos[br.to]] -= 1.0;
}
let theta = island.factorization.solve(&rhs)?;
let flows = self.flows_from_angles(island, &theta);
let d = flows[branch];
Some((flows, d))
}
pub fn lodf_column(&self, branch: usize) -> Option<Vec<f64>> {
let (flows, d) = self.outage_response(branch)?;
if (1.0 - d).abs() < LODF_BRIDGE_TOL {
return None;
}
let scale = 1.0 / (1.0 - d);
let mut out: Vec<f64> = flows.iter().map(|f| f * scale).collect();
out[branch] = -1.0;
Some(out)
}
pub fn is_radial(&self, branch: usize) -> bool {
match self.outage_response(branch) {
Some((_, d)) => (1.0 - d).abs() < LODF_BRIDGE_TOL,
None => true,
}
}
pub fn ptdf_dense(&self) -> Option<DenseMatrix> {
let mut data = vec![0.0; self.n_branches * self.n_buses];
for bus in 0..self.n_buses {
let Some(column) = self.ptdf_column(bus) else {
continue;
};
for (k, v) in column.into_iter().enumerate() {
data[k * self.n_buses + bus] = v;
}
}
Some(DenseMatrix {
rows: self.n_branches,
cols: self.n_buses,
data,
})
}
pub fn lodf_dense(&self) -> Option<DenseMatrix> {
let mut data = vec![0.0; self.n_branches * self.n_branches];
for l in 0..self.n_branches {
let Some(column) = self.lodf_column(l) else {
continue;
};
for (k, v) in column.into_iter().enumerate() {
data[k * self.n_branches + l] = v;
}
}
Some(DenseMatrix {
rows: self.n_branches,
cols: self.n_branches,
data,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linear::{DcOptions, dc_branches};
use crate::types::Line;
fn bus(idx: usize, bus_type: BusType) -> Bus {
Bus {
idx,
bus_type,
voltage_mag: 1.0,
voltage_ang: 0.0,
p_spec: 0.0,
q_spec: 0.0,
q_min: 0.0,
q_max: 0.0,
u_rated: 0.0,
zip_terms: Vec::new(),
}
}
fn line(from: usize, to: usize, x: f64) -> Line {
Line {
from,
to,
r: 0.0,
x,
b_shunt: 0.0,
g_shunt: 0.0,
}
}
fn triangle() -> (Vec<Bus>, Vec<Line>) {
(
vec![
bus(0, BusType::Slack),
bus(1, BusType::PQ),
bus(2, BusType::PQ),
],
vec![line(0, 1, 0.1), line(1, 2, 0.1), line(0, 2, 0.1)],
)
}
fn sensitivity(buses: &[Bus], lines: &[Line]) -> DcSensitivity {
let branches = dc_branches(lines, &[], DcOptions::default());
DcSensitivity::new(buses, &branches, lines.len()).unwrap()
}
#[test]
fn ptdf_column_matches_the_symmetric_two_thirds_split() {
let (buses, lines) = triangle();
let s = sensitivity(&buses, &lines);
let col = s.ptdf_column(1).unwrap();
assert!((col[0] + 2.0 / 3.0).abs() < 1e-12, "{col:?}");
assert!((col[1] - 1.0 / 3.0).abs() < 1e-12, "{col:?}");
assert!((col[2] + 1.0 / 3.0).abs() < 1e-12, "{col:?}");
}
#[test]
fn ptdf_row_is_the_transpose_of_the_columns() {
let (buses, lines) = triangle();
let s = sensitivity(&buses, &lines);
assert!(s.ptdf_column(0).unwrap().iter().all(|v| *v == 0.0));
let columns: Vec<Vec<f64>> = (0..3).map(|b| s.ptdf_column(b).unwrap()).collect();
for branch in 0..3 {
let row = s.ptdf_row(branch).unwrap();
for bus in 0..3 {
assert!(
(row[bus] - columns[bus][branch]).abs() < 1e-12,
"branch {branch} bus {bus}: row {} vs column {}",
row[bus],
columns[bus][branch]
);
}
}
}
#[test]
fn lodf_of_a_symmetric_triangle_is_unity() {
let (buses, lines) = triangle();
let s = sensitivity(&buses, &lines);
let col = s.lodf_column(0).unwrap();
assert_eq!(col[0], -1.0);
assert!((col[1].abs() - 1.0).abs() < 1e-12, "{col:?}");
assert!((col[2].abs() - 1.0).abs() < 1e-12, "{col:?}");
assert!(!s.is_radial(0));
}
#[test]
fn a_radial_branch_has_no_lodf_column() {
let buses = vec![
bus(0, BusType::Slack),
bus(1, BusType::PQ),
bus(2, BusType::PQ),
];
let lines = vec![line(0, 1, 0.1), line(1, 2, 0.2)];
let s = sensitivity(&buses, &lines);
for branch in 0..2 {
assert!(s.is_radial(branch), "branch {branch} should be radial");
assert!(
s.lodf_column(branch).is_none(),
"branch {branch} should have no LODF"
);
}
let col = s.ptdf_column(2).unwrap();
assert!(
(col[0] + 1.0).abs() < 1e-12 && (col[1] + 1.0).abs() < 1e-12,
"{col:?}"
);
}
#[test]
fn transfer_factors_are_a_linear_combination_of_ptdf_columns() {
let (buses, lines) = triangle();
let s = sensitivity(&buses, &lines);
let got = s.transfer_factors(&[0.0, 0.3, 0.7]).unwrap();
let c1 = s.ptdf_column(1).unwrap();
let c2 = s.ptdf_column(2).unwrap();
for k in 0..3 {
let expected = 0.3 * c1[k] + 0.7 * c2[k];
assert!(
(got[k] - expected).abs() < 1e-12,
"branch {k}: {} vs {expected}",
got[k]
);
}
assert!(
s.transfer_factors(&[0.0, 0.0]).is_none(),
"length mismatch must be rejected"
);
}
#[test]
fn dense_matrices_agree_with_the_accessors() {
let (buses, lines) = triangle();
let s = sensitivity(&buses, &lines);
let ptdf = s.ptdf_dense().unwrap();
assert_eq!((ptdf.rows, ptdf.cols), (3, 3));
for bus in 0..3 {
let col = s.ptdf_column(bus).unwrap();
for branch in 0..3 {
assert_eq!(ptdf.get(branch, bus), col[branch]);
}
}
let lodf = s.lodf_dense().unwrap();
for l in 0..3 {
let col = s.lodf_column(l).unwrap();
for k in 0..3 {
assert_eq!(lodf.get(k, l), col[k]);
}
}
}
#[test]
fn an_unreferenced_island_has_no_factors() {
let buses = vec![
bus(0, BusType::Slack),
bus(1, BusType::PQ),
bus(2, BusType::PQ),
bus(3, BusType::PQ),
];
let lines = vec![line(0, 1, 0.1), line(2, 3, 0.2)];
let s = sensitivity(&buses, &lines);
assert!(s.ptdf_column(1).is_some());
assert!(s.ptdf_column(2).is_none());
assert!(s.ptdf_column(3).is_none());
assert!(
s.ptdf_row(1).is_none(),
"the unreferenced island's branch has no row"
);
assert!(s.is_radial(1));
}
#[test]
fn a_lone_reference_bus_has_a_zero_column_not_a_missing_one() {
let buses = vec![
bus(0, BusType::Slack),
bus(1, BusType::Slack),
bus(2, BusType::PQ),
];
let lines = vec![line(1, 2, 0.1)];
let s = sensitivity(&buses, &lines);
let column = s
.ptdf_column(0)
.expect("a referenced island must not report None");
assert!(column.iter().all(|v| *v == 0.0), "{column:?}");
assert!(s.ptdf_column(2).is_some());
assert!(s.ptdf_column(3).is_none());
}
#[test]
fn branch_lookup_handles_gaps_in_the_flat_index() {
let buses = vec![
bus(0, BusType::Slack),
bus(1, BusType::PQ),
bus(2, BusType::PQ),
];
let lines = vec![
line(0, 1, 0.1),
line(1, 1, 0.2),
line(1, 2, 0.3),
line(0, 2, 0.4),
];
let s = sensitivity(&buses, &lines);
assert!(s.ptdf_row(0).is_some());
assert!(
s.ptdf_row(1).is_none(),
"the skipped self-loop must not resolve"
);
assert!(s.ptdf_row(2).is_some());
assert!(s.ptdf_row(3).is_some());
assert!(s.ptdf_row(4).is_none(), "out of range must not resolve");
assert!(
s.is_radial(1),
"a branch carrying no DC flow has no factors"
);
}
}