use std::path::PathBuf;
use gridoxide::linear::{DcOptions, DcSensitivity, dc_branches, dc_power_flow};
use gridoxide::pgm::pgm_to_buses_and_branches;
use gridoxide::types::{Bus, Line, Transformer};
mod common;
fn fixture(rel: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/pgm/powerflow")
.join(rel)
.join("input.json")
}
struct Case {
buses: Vec<Bus>,
lines: Vec<Line>,
transformers: Vec<Transformer>,
}
impl Case {
fn load(rel: &str) -> Self {
let input = common::load_pgm_input(&fixture(rel));
let (buses, lines, transformers) = pgm_to_buses_and_branches(input, 1e6, 50.0);
Self {
buses,
lines,
transformers,
}
}
fn n_branches(&self) -> usize {
self.lines.len() + self.transformers.len()
}
fn flows_with(&self, delta: &[f64]) -> Vec<f64> {
let mut buses = self.buses.clone();
for (b, d) in buses.iter_mut().zip(delta) {
b.p_spec += d;
}
dc_power_flow(
&mut buses,
&self.lines,
&self.transformers,
DcOptions::default(),
)
.branch_p
}
fn sensitivity(&self) -> DcSensitivity {
let branches = dc_branches(&self.lines, &self.transformers, DcOptions::default());
DcSensitivity::new(&self.buses, &branches, self.n_branches())
.expect("every island here has a reference and a nonsingular B")
}
}
#[test]
fn ptdf_columns_match_a_finite_difference_of_the_solve() {
for name in ["symmetric/transmission-case", "symmetric/distribution-case"] {
let case = Case::load(name);
let sensitivity = case.sensitivity();
let base = case.flows_with(&vec![0.0; case.buses.len()]);
let eps = 1e-3;
let mut checked = 0;
for bus in 0..case.buses.len() {
let Some(column) = sensitivity.ptdf_column(bus) else {
continue;
};
let mut delta = vec![0.0; case.buses.len()];
delta[bus] = eps;
let perturbed = case.flows_with(&delta);
for k in 0..case.n_branches() {
let measured = (perturbed[k] - base[k]) / eps;
assert!(
(measured - column[k]).abs() < 1e-9,
"{name}: PTDF[{k}, {bus}] = {} but re-solving gives {measured}",
column[k]
);
}
checked += 1;
}
assert!(checked > 2, "{name}: only {checked} buses had PTDF columns");
}
}
#[test]
fn ptdf_rows_agree_with_ptdf_columns() {
let case = Case::load("symmetric/transmission-case");
let sensitivity = case.sensitivity();
let columns: Vec<Option<Vec<f64>>> = (0..case.buses.len())
.map(|b| sensitivity.ptdf_column(b))
.collect();
let mut checked = 0;
for branch in 0..case.n_branches() {
let Some(row) = sensitivity.ptdf_row(branch) else {
continue;
};
for (bus, column) in columns.iter().enumerate() {
let Some(column) = column else { continue };
assert!(
(row[bus] - column[branch]).abs() < 1e-12,
"branch {branch}, bus {bus}: row {} vs column {}",
row[bus],
column[branch]
);
}
checked += 1;
}
assert!(checked > 2, "only {checked} branches had PTDF rows");
}
#[test]
fn lodf_columns_match_an_actual_outage_resolve() {
let case = Case::load("symmetric/transmission-case");
let sensitivity = case.sensitivity();
let base = case.flows_with(&vec![0.0; case.buses.len()]);
let mut checked = 0;
for outaged in 0..case.lines.len() {
let Some(column) = sensitivity.lodf_column(outaged) else {
continue;
};
let mut lines = case.lines.clone();
lines[outaged].to = lines[outaged].from;
let mut buses = case.buses.clone();
let after =
dc_power_flow(&mut buses, &lines, &case.transformers, DcOptions::default()).branch_p;
let lost = base[outaged];
if lost.abs() < 1e-9 {
continue;
}
for k in 0..case.n_branches() {
if k == outaged {
continue;
}
let predicted = base[k] + column[k] * lost;
assert!(
(after[k] - predicted).abs() < 1e-9,
"outage of {outaged}: branch {k} went to {} but LODF predicted {predicted}",
after[k]
);
}
assert_eq!(column[outaged], -1.0);
checked += 1;
}
assert!(
checked > 0,
"no non-radial, load-carrying branch was checked"
);
}
#[test]
fn radial_branches_are_identified_and_have_no_factors() {
let case = Case::load("symmetric/transmission-case");
let sensitivity = case.sensitivity();
let mut radial = 0;
let mut meshed = 0;
for branch in 0..case.n_branches() {
if sensitivity.is_radial(branch) {
assert!(
sensitivity.lodf_column(branch).is_none(),
"branch {branch} is radial but produced an LODF column"
);
radial += 1;
} else {
assert!(sensitivity.lodf_column(branch).is_some());
meshed += 1;
}
}
assert!(
radial > 0,
"fixture has no radial branch, so is_radial is untested here"
);
assert!(
meshed > 0,
"fixture has no meshed branch, so lodf_column is untested here"
);
}
#[test]
fn transfer_factors_match_a_multi_bus_re_solve() {
let case = Case::load("symmetric/transmission-case");
let sensitivity = case.sensitivity();
let base = case.flows_with(&vec![0.0; case.buses.len()]);
let mut delta = vec![0.0; case.buses.len()];
for (i, d) in delta.iter_mut().enumerate() {
if case.buses[i].bus_type != gridoxide::types::BusType::Slack {
*d = 0.01 * ((i % 3) as f64 - 1.0);
}
}
let predicted = sensitivity.transfer_factors(&delta).unwrap();
let actual = case.flows_with(&delta);
for k in 0..case.n_branches() {
assert!(
(actual[k] - base[k] - predicted[k]).abs() < 1e-9,
"branch {k}: measured {} but transfer_factors predicted {}",
actual[k] - base[k],
predicted[k]
);
}
}
#[test]
fn dense_matrices_agree_with_the_accessors() {
let case = Case::load("symmetric/transmission-case");
let sensitivity = case.sensitivity();
let ptdf = sensitivity.ptdf_dense().unwrap();
assert_eq!(
(ptdf.rows, ptdf.cols),
(case.n_branches(), case.buses.len())
);
for bus in 0..case.buses.len() {
let Some(column) = sensitivity.ptdf_column(bus) else {
continue;
};
for branch in 0..case.n_branches() {
assert_eq!(ptdf.get(branch, bus), column[branch]);
}
}
let lodf = sensitivity.lodf_dense().unwrap();
assert_eq!(
(lodf.rows, lodf.cols),
(case.n_branches(), case.n_branches())
);
for l in 0..case.n_branches() {
let Some(column) = sensitivity.lodf_column(l) else {
continue;
};
for k in 0..case.n_branches() {
assert_eq!(lodf.get(k, l), column[k]);
}
}
}