use serde::{Deserialize, Serialize};
pub const MIN_DIVISIBLE_MAGNITUDE: f64 = 1.491_668_146_240_041_3e-154;
pub(crate) fn series_admittance_parts(r: f64, x: f64) -> (f64, f64) {
let denom = r * r + x * x;
if denom.is_finite() {
return (r / denom, -x / denom);
}
let scale = r.abs().max(x.abs());
let (r, x) = (r / scale, x / scale);
let denom = (r * r + x * x) * scale;
(r / denom, -x / denom)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum DcConvention {
ReactanceOnly,
#[serde(alias = "Matpower")]
TapAdjustedReactance,
#[default]
#[serde(alias = "SeriesImpedance")]
SeriesSusceptance,
}
impl DcConvention {
#[must_use]
pub fn branch_susceptance(self, resistance: f64, reactance: f64, effective_tap: f64) -> f64 {
let negated_reciprocal = |denominator: f64| {
if denominator.is_finite() {
-1.0 / denominator
} else {
f64::NAN
}
};
match self {
Self::ReactanceOnly => negated_reciprocal(reactance),
Self::TapAdjustedReactance => negated_reciprocal(reactance * effective_tap),
Self::SeriesSusceptance => series_admittance_parts(resistance, reactance).1,
}
}
#[must_use]
pub fn solver_edge_weight(self, resistance: f64, reactance: f64, effective_tap: f64) -> f64 {
-self.branch_susceptance(resistance, reactance, effective_tap)
}
#[must_use]
pub fn reads_tap(self) -> bool {
matches!(self, Self::TapAdjustedReactance)
}
#[must_use]
pub fn includes_phase_shifts(self) -> bool {
match self {
Self::ReactanceOnly => false,
Self::TapAdjustedReactance | Self::SeriesSusceptance => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn three_bus_network() -> crate::BalancedNetwork {
use crate::{Branch, Bus, BusId, BusType};
let mut shifted = Branch::new(BusId(2), BusId(3), 0.0, 0.2);
shifted.shift = 30.0;
let mut out = Branch::new(BusId(1), BusId(3), 0.01, 0.1);
out.in_service = false;
crate::BalancedNetwork::in_memory(
"dc-data",
100.0,
vec![
Bus::new(BusId(1), BusType::Ref, 230.0),
Bus::new(BusId(2), BusType::Pq, 230.0),
Bus::new(BusId(3), BusType::Pq, 230.0),
],
vec![Branch::new(BusId(1), BusId(2), 0.0, 0.1), shifted, out],
)
}
#[test]
fn dc_network_data_maps_rows_and_omissions() {
let network = three_bus_network();
let view = crate::IndexedNetwork::new(&network);
let data = dc_network_data(&view, DcConvention::SeriesSusceptance);
assert_eq!(data.formula, "series_susceptance");
assert_eq!(data.from_indices, vec![0, 1]);
assert_eq!(data.to_indices, vec![1, 2]);
assert_eq!(data.row_ids, vec!["branches:0", "branches:1"]);
assert_eq!(data.bus_ids, vec!["1", "2", "3"]);
assert_eq!(data.omitted.len(), 1);
assert_eq!(data.omitted[0].0, "branches:2");
assert!(data.omitted[0].1.contains("out of service"));
let b = data.susceptance[1];
assert!((b + 5.0).abs() < 1e-12);
let shift = 30.0_f64.to_radians();
assert!(data.shift[0].abs() < 1e-15);
assert!((data.shift[1] - shift).abs() < 1e-12);
assert!((data.shift_injection[1] - (b * shift)).abs() < 1e-12);
assert!((data.shift_injection[2] - (-b * shift)).abs() < 1e-12);
assert!(data.shift_injection[0].abs() < 1e-15);
let p_branch = -b * 0.0 + b * shift;
assert!((p_branch - 5.0 * (0.0 - shift)).abs() < 1e-12);
}
#[test]
fn every_branch_is_included_or_omitted_exactly_once() {
use crate::{Branch, Bus, BusId, BusType};
let mut branches = vec![
Branch::new(BusId(1), BusId(2), 0.0, 0.1),
Branch::new(BusId(2), BusId(2), 0.0, 0.1),
Branch::new(BusId(1), BusId(9), 0.01, 0.1),
Branch::new(BusId(1), BusId(3), 0.0, 0.0),
Branch::new(BusId(2), BusId(3), 0.0, f64::NAN),
Branch::new(BusId(1), BusId(3), 0.02, 0.2),
];
branches[5].in_service = false;
let mut giant_tap = Branch::new(BusId(2), BusId(3), 0.0, 1.0e308);
giant_tap.tap = 1.0e308;
branches.push(giant_tap);
let network = crate::BalancedNetwork::in_memory(
"partition",
100.0,
vec![
Bus::new(BusId(1), BusType::Ref, 230.0),
Bus::new(BusId(2), BusType::Pq, 230.0),
Bus::new(BusId(3), BusType::Pq, 230.0),
],
branches,
);
let view = crate::IndexedNetwork::new(&network);
for convention in [
DcConvention::SeriesSusceptance,
DcConvention::TapAdjustedReactance,
DcConvention::ReactanceOnly,
] {
let data = dc_network_data(&view, convention);
let included = data.row_ids.len();
assert_eq!(included, data.susceptance.len());
assert_eq!(included, data.from_indices.len());
assert_eq!(
included + data.omitted.len(),
network.branches().len(),
"{convention:?}"
);
let mut ids: Vec<&str> = data
.row_ids
.iter()
.map(String::as_str)
.chain(data.omitted.iter().map(|(id, _)| id.as_str()))
.collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), network.branches().len(), "{convention:?}");
assert!(data.susceptance.iter().all(|b| b.is_finite()));
}
}
#[test]
fn the_degeneracy_bound_follows_the_formula() {
use crate::{Branch, Bus, BusId, BusType};
let mut resistive = Branch::new(BusId(1), BusId(2), 0.05, 0.0);
resistive.uid = Some("resistive".to_owned());
let mut nothing = Branch::new(BusId(2), BusId(3), 0.0, 0.0);
nothing.uid = Some("nothing".to_owned());
let network = crate::BalancedNetwork::in_memory(
"dc-degenerate",
100.0,
vec![
Bus::new(BusId(1), BusType::Ref, 230.0),
Bus::new(BusId(2), BusType::Pq, 230.0),
Bus::new(BusId(3), BusType::Pq, 230.0),
],
vec![resistive, nothing],
);
let view = crate::IndexedNetwork::new(&network);
let series = dc_network_data(&view, DcConvention::SeriesSusceptance);
assert_eq!(series.row_ids, vec!["resistive"]);
assert_eq!(series.from_indices, vec![0]);
assert_eq!(series.to_indices, vec![1]);
assert!(series.susceptance[0].abs() < 1e-15);
assert_eq!(series.omitted.len(), 1);
assert_eq!(series.omitted[0].0, "nothing");
for convention in [
DcConvention::TapAdjustedReactance,
DcConvention::ReactanceOnly,
] {
let data = dc_network_data(&view, convention);
assert!(data.row_ids.is_empty(), "{convention:?}");
let omitted: Vec<&str> = data.omitted.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(omitted, vec!["resistive", "nothing"], "{convention:?}");
for (_, reason) in &data.omitted {
assert!(reason.contains("reactance"), "{reason}");
}
}
}
#[test]
fn three_winding_expansion_keeps_every_table_aligned() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../tests/data/psse/case3_3w_v33.raw"
);
let source = powerio_core::Source::open(std::path::Path::new(path)).unwrap();
let module =
crate::parse(source.with_format(powerio_core::FormatId::new("psse").unwrap())).unwrap();
let network = module.value();
let view = crate::IndexedNetwork::new(network);
let data = dc_network_data(&view, DcConvention::SeriesSusceptance);
assert_eq!(data.bus_ids.len(), view.n());
assert_eq!(data.bus_ids.len(), 4);
assert!(
data.row_ids.len() + data.omitted.len() >= 3,
"winding branches missing: {} rows, {} omitted",
data.row_ids.len(),
data.omitted.len()
);
for index in &data.from_indices {
assert!(*index < data.bus_ids.len());
}
for index in &data.to_indices {
assert!(*index < data.bus_ids.len());
}
}
#[test]
fn formula_names_round_trip() {
for convention in [
DcConvention::SeriesSusceptance,
DcConvention::TapAdjustedReactance,
DcConvention::ReactanceOnly,
] {
assert_eq!(
DcConvention::from_formula_name(convention.formula_name()),
Some(convention)
);
}
assert_eq!(DcConvention::from_formula_name("mystery"), None);
}
#[test]
fn series_susceptance_reduces_to_negated_one_over_x() {
let b = DcConvention::SeriesSusceptance.branch_susceptance(0.0, 0.25, 1.0);
assert!((b + 4.0).abs() < 1e-12);
let weight = DcConvention::SeriesSusceptance.solver_edge_weight(0.0, 0.25, 1.0);
assert!((weight - 4.0).abs() < 1e-12);
}
#[test]
fn resistance_lowers_the_susceptance_magnitude() {
let lossless = DcConvention::SeriesSusceptance.branch_susceptance(0.0, 0.1, 1.0);
let lossy = DcConvention::SeriesSusceptance.branch_susceptance(0.1, 0.1, 1.0);
assert!(lossy.abs() < lossless.abs());
assert!((lossy + 5.0).abs() < 1e-12);
}
#[test]
fn matpower_scales_by_the_tap() {
let b = DcConvention::TapAdjustedReactance.branch_susceptance(0.01, 0.2, 2.0);
assert!((b + 2.5).abs() < 1e-12);
}
#[test]
fn an_unread_tap_never_rejects_a_branch() {
for conv in [DcConvention::ReactanceOnly, DcConvention::SeriesSusceptance] {
assert!(!conv.reads_tap());
let b = conv.branch_susceptance(0.01, 0.1, 1e-200);
assert!(b.is_finite(), "{conv:?} read the tap it never divides by");
}
assert!(DcConvention::TapAdjustedReactance.reads_tap());
}
#[test]
fn a_non_finite_denominator_is_not_a_susceptance() {
for x in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
for conv in [
DcConvention::ReactanceOnly,
DcConvention::TapAdjustedReactance,
DcConvention::SeriesSusceptance,
] {
let b = conv.branch_susceptance(0.01, x, 1.0);
assert!(!b.is_finite(), "{conv:?} read x = {x} as b = {b}");
}
}
for (x, tap) in [
(0.1, f64::INFINITY),
(0.1, f64::NAN),
(1e300, 1e300),
(1e300, -1e300),
] {
let b = DcConvention::TapAdjustedReactance.branch_susceptance(0.0, x, tap);
assert!(!b.is_finite(), "x = {x}, tap = {tap} read as b = {b}");
}
}
#[test]
fn an_impedance_whose_square_overflows_still_has_a_susceptance() {
let (r, x) = (1e160, 1e160);
assert!(r * r + x * x == f64::INFINITY, "the direct form overflows");
let b = DcConvention::SeriesSusceptance.branch_susceptance(r, x, 1.0);
assert!(
(b / -5e-161 - 1.0).abs() < 1e-12,
"the branch is not dropped, got {b}"
);
let (g, susceptance) = series_admittance_parts(r, x);
assert!((g / 5e-161 - 1.0).abs() < 1e-12, "got {g}");
assert!(
(susceptance - b).abs() < 1e-175,
"the public rule is the series susceptance itself"
);
}
#[test]
fn the_ordinary_range_is_bit_identical_to_the_direct_quotient() {
for (r, x) in [
(0.01, 0.1),
(0.03, 0.04),
(0.0, 0.25),
(1e-6, 1e-5),
(7.0, 3.0),
] {
let denom = r * r + x * x;
assert_eq!(series_admittance_parts(r, x), (r / denom, -x / denom));
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct DcNetworkData {
pub from_indices: Vec<usize>,
pub to_indices: Vec<usize>,
pub susceptance: Vec<f64>,
pub shift: Vec<f64>,
pub shift_injection: Vec<f64>,
pub row_ids: Vec<String>,
pub bus_ids: Vec<String>,
pub omitted: Vec<(String, String)>,
pub formula: &'static str,
}
impl DcConvention {
#[must_use]
pub fn formula_name(self) -> &'static str {
match self {
Self::SeriesSusceptance => "series_susceptance",
Self::TapAdjustedReactance => "tap_adjusted_reactance",
Self::ReactanceOnly => "reactance_only",
}
}
#[must_use]
pub fn from_formula_name(name: &str) -> Option<Self> {
match name {
"series_susceptance" | "series" => Some(Self::SeriesSusceptance),
"tap_adjusted_reactance" | "matpower" => Some(Self::TapAdjustedReactance),
"reactance_only" => Some(Self::ReactanceOnly),
_ => None,
}
}
}
#[must_use]
pub fn dc_network_data(
view: &crate::IndexedNetwork<'_>,
convention: DcConvention,
) -> DcNetworkData {
let network = view.network();
let n = view.n();
let mut data = DcNetworkData {
from_indices: Vec::new(),
to_indices: Vec::new(),
susceptance: Vec::new(),
shift: Vec::new(),
shift_injection: vec![0.0; n],
row_ids: Vec::new(),
bus_ids: network
.buses()
.iter()
.map(|bus| bus.id.0.to_string())
.collect(),
omitted: Vec::new(),
formula: convention.formula_name(),
};
for (idx, branch) in network.branches().iter().enumerate() {
let id = branch
.uid
.clone()
.unwrap_or_else(|| format!("branches:{idx}"));
if !branch.in_service {
data.omitted.push((id, "out of service".to_owned()));
continue;
}
let (Some(i), Some(j)) = (view.bus_index(branch.from), view.bus_index(branch.to)) else {
data.omitted
.push((id, "references an undeclared bus".to_owned()));
continue;
};
if i == j {
data.omitted.push((id, "self loop".to_owned()));
continue;
}
let degenerate = match convention {
DcConvention::SeriesSusceptance => branch.r.hypot(branch.x) < MIN_DIVISIBLE_MAGNITUDE,
DcConvention::TapAdjustedReactance | DcConvention::ReactanceOnly => {
branch.x.abs() < MIN_DIVISIBLE_MAGNITUDE
}
};
if degenerate {
let reason = match convention {
DcConvention::SeriesSusceptance => {
"zero impedance: the series impedance magnitude is below the divisibility \
floor"
}
DcConvention::TapAdjustedReactance | DcConvention::ReactanceOnly => {
"zero reactance: the selected formula divides by reactance"
}
};
data.omitted.push((id, reason.to_owned()));
continue;
}
let tap = match branch.divisible_tap(idx) {
Ok(tap) => tap,
Err(error) => {
data.omitted.push((id, error.to_string()));
continue;
}
};
let b = convention.branch_susceptance(branch.r, branch.x, tap);
if !b.is_finite() {
data.omitted
.push((id, "susceptance is not finite".to_owned()));
continue;
}
let row_shift = if convention.includes_phase_shifts() {
view.angle_radians(branch.shift)
} else {
0.0
};
if row_shift != 0.0 {
data.shift_injection[i] += b * row_shift;
data.shift_injection[j] -= b * row_shift;
}
data.from_indices.push(i);
data.to_indices.push(j);
data.susceptance.push(b);
data.shift.push(row_shift);
data.row_ids.push(id);
}
data
}