use std::collections::{BTreeMap, VecDeque};
use powerio_dist::MulticonductorNetwork;
use powerio_matrix::BalancedNetwork;
const ELECTRICAL_TOL: f64 = 1e-8;
const MODEL_TOL: f64 = 1e-9;
pub const MAX_MODEL_DIFFS: usize = 24;
#[derive(Debug, Clone, PartialEq)]
pub struct YbusChange {
pub from_bus: usize,
pub to_bus: usize,
pub before: (f64, f64),
pub after: (f64, f64),
}
impl std::fmt::Display for YbusChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Y[{}, {}] was {}+j{}, now {}+j{}",
self.from_bus, self.to_bus, self.before.0, self.before.1, self.after.0, self.after.1
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Injection {
LoadP,
LoadQ,
GenP,
GenQ,
DcRectifierP,
DcInverterP,
DcRectifierQ,
DcInverterQ,
}
impl Injection {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::LoadP => "load p",
Self::LoadQ => "load q",
Self::GenP => "gen p",
Self::GenQ => "gen q",
Self::DcRectifierP => "dc rectifier p",
Self::DcInverterP => "dc inverter p",
Self::DcRectifierQ => "dc rectifier q",
Self::DcInverterQ => "dc inverter q",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct InjectionChange {
pub bus: usize,
pub quantity: Injection,
pub before: f64,
pub after: f64,
pub status_only: bool,
}
impl std::fmt::Display for InjectionChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"bus {} {}: {} -> {}",
self.bus,
self.quantity.label(),
self.before,
self.after
)
}
}
#[derive(Debug, Clone, PartialEq)]
struct BusRow {
diagonal: (f64, f64),
injections: [f64; 4],
}
type ElectricalDigest = (Vec<BusRow>, Vec<(f64, f64)>);
fn unmatched_multiset_item<T>(
before: &[T],
after: &[T],
matches: impl Fn(&T, &T) -> bool,
) -> Option<usize> {
if before.len() != after.len() {
return Some(0);
}
let candidates = before
.iter()
.map(|before| {
after
.iter()
.enumerate()
.filter_map(|(index, after)| matches(before, after).then_some(index))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut order = (0..before.len()).collect::<Vec<_>>();
order.sort_by_key(|index| candidates[*index].len());
let mut left_match: Vec<Option<usize>> = vec![None; before.len()];
let mut right_match: Vec<Option<usize>> = vec![None; after.len()];
for root in order {
let mut visited_left = vec![false; before.len()];
let mut visited_right = vec![false; after.len()];
let mut parent_right = vec![None; after.len()];
let mut queue = VecDeque::from([root]);
visited_left[root] = true;
let mut free = None;
while let Some(left) = queue.pop_front() {
for &right in &candidates[left] {
if visited_right[right] {
continue;
}
visited_right[right] = true;
parent_right[right] = Some(left);
if let Some(next_left) = right_match[right] {
if !visited_left[next_left] {
visited_left[next_left] = true;
queue.push_back(next_left);
}
} else {
free = Some(right);
break;
}
}
if free.is_some() {
break;
}
}
let Some(mut right) = free else {
return Some(root);
};
loop {
let left = parent_right[right].expect("an augmenting edge records its left endpoint");
let previous = left_match[left].replace(right);
right_match[right] = Some(left);
let Some(previous) = previous else {
break;
};
right = previous;
}
}
None
}
fn bus_rows_match(before: &BusRow, after: &BusRow, tolerance: f64) -> bool {
!beyond_tol(before.diagonal.0, after.diagonal.0, tolerance)
&& !beyond_tol(before.diagonal.1, after.diagonal.1, tolerance)
&& before
.injections
.iter()
.zip(&after.injections)
.all(|(before, after)| !beyond_tol(*before, *after, tolerance))
}
fn admittances_match(before: &(f64, f64), after: &(f64, f64), tolerance: f64) -> bool {
!beyond_tol(before.0, after.0, tolerance) && !beyond_tol(before.1, after.1, tolerance)
}
#[must_use]
pub fn electrical_change_up_to_relabeling(
before: &BalancedNetwork,
after: &BalancedNetwork,
tolerance: f64,
) -> Option<String> {
let physical = [before, after].iter().all(|net| {
net.buses()
.iter()
.all(|bus| bus.base_kv.is_finite() && bus.base_kv > 0.0)
});
let digest = |net: &BalancedNetwork| -> Option<ElectricalDigest> {
let nominal = net
.buses()
.iter()
.map(|bus| (bus.id.0, bus.base_kv))
.collect::<BTreeMap<_, _>>();
let scale = |from: usize, to: usize| -> f64 {
if !physical {
return 1.0;
}
let voltage = |bus: usize| nominal.get(&bus).copied().unwrap_or(1.0);
net.base_mva() / (voltage(from) * voltage(to))
};
let view = powerio_matrix::IndexedNetwork::new(net);
let parts =
powerio_matrix::calc_admittance_matrix(&view, &powerio_matrix::BuildOptions::default())
.ok()?;
let mut entries = BTreeMap::<(usize, usize), (f64, f64)>::new();
for (value, (i, j)) in &parts.g {
entries
.entry((view.bus_id(i).0, view.bus_id(j).0))
.or_insert((0.0, 0.0))
.0 = *value;
}
for (value, (i, j)) in &parts.b {
entries
.entry((view.bus_id(i).0, view.bus_id(j).0))
.or_insert((0.0, 0.0))
.1 = *value;
}
let injections = per_bus(net, true);
let mut rows = Vec::new();
let mut off_diagonal = Vec::new();
for ((from, to), value) in entries {
let value = (value.0 * scale(from, to), value.1 * scale(from, to));
if from == to {
rows.push(BusRow {
diagonal: value,
injections: injections.get(&from).copied().unwrap_or_default(),
});
} else {
off_diagonal.push(value);
}
}
Some((rows, off_diagonal))
};
let (before_rows, before_off) = digest(before)?;
let (after_rows, after_off) = digest(after)?;
if before_rows.len() != after_rows.len() {
return Some(format!(
"{} bus admittance row(s) became {}",
before_rows.len(),
after_rows.len()
));
}
if before_off.len() != after_off.len() {
return Some(format!(
"{} off-diagonal admittance entr(ies) became {}",
before_off.len(),
after_off.len()
));
}
if let Some(index) = unmatched_multiset_item(&before_rows, &after_rows, |before, after| {
bus_rows_match(before, after, tolerance)
}) {
return Some(format!(
"bus row {:?} has no matching result row",
before_rows[index]
));
}
if let Some(index) = unmatched_multiset_item(&before_off, &after_off, |before, after| {
admittances_match(before, after, tolerance)
}) {
return Some(format!(
"off-diagonal admittance {:?} has no matching result entry",
before_off[index]
));
}
None
}
#[cfg(test)]
mod relabeling_tests {
use super::{BusRow, bus_rows_match, unmatched_multiset_item};
#[test]
fn near_equal_rows_pair_by_all_electrical_values() {
let before = [
BusRow {
diagonal: (1.0, 0.0),
injections: [0.0; 4],
},
BusRow {
diagonal: (1.000_001, 0.0),
injections: [5.0, 0.0, 0.0, 0.0],
},
];
let after = [
BusRow {
diagonal: (1.0, 0.0),
injections: [5.0, 0.0, 0.0, 0.0],
},
BusRow {
diagonal: (1.000_001, 0.0),
injections: [0.0; 4],
},
];
assert_eq!(
unmatched_multiset_item(&before, &after, |before, after| {
bus_rows_match(before, after, 1e-3)
}),
None
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum YbusUnavailable {
Before,
After,
}
pub fn ybus_change(
before: &BalancedNetwork,
after: &BalancedNetwork,
) -> Result<Option<YbusChange>, YbusUnavailable> {
ybus_change_within(before, after, ELECTRICAL_TOL)
}
pub fn ybus_change_within(
before: &BalancedNetwork,
after: &BalancedNetwork,
tolerance: f64,
) -> Result<Option<YbusChange>, YbusUnavailable> {
let nominal = |net: &BalancedNetwork| -> BTreeMap<usize, f64> {
net.buses()
.iter()
.filter(|bus| bus.base_kv.is_finite() && bus.base_kv > 0.0)
.map(|bus| (bus.id.0, bus.base_kv))
.collect()
};
let nominal_before = nominal(before);
let nominal_after = nominal(after);
let base_ratio = |bus: usize| -> f64 {
match (nominal_before.get(&bus), nominal_after.get(&bus)) {
(Some(before), Some(after)) => before / after,
_ => 1.0,
}
};
let entries = |net: &BalancedNetwork| -> Option<BTreeMap<(usize, usize), (f64, f64)>> {
let view = powerio_matrix::IndexedNetwork::new(net);
let parts =
powerio_matrix::calc_admittance_matrix(&view, &powerio_matrix::BuildOptions::default())
.ok()?;
let mut map = BTreeMap::new();
for (value, (i, j)) in &parts.g {
map.entry((view.bus_id(i).0, view.bus_id(j).0))
.or_insert((0.0, 0.0))
.0 = *value;
}
for (value, (i, j)) in &parts.b {
map.entry((view.bus_id(i).0, view.bus_id(j).0))
.or_insert((0.0, 0.0))
.1 = *value;
}
Some(map)
};
let a = entries(before).ok_or(YbusUnavailable::Before)?;
let b = entries(after).ok_or(YbusUnavailable::After)?;
for key in a.keys().chain(b.keys().filter(|k| !a.contains_key(*k))) {
let (ga, ba) = a.get(key).copied().unwrap_or((0.0, 0.0));
let (gb, bb) = b.get(key).copied().unwrap_or((0.0, 0.0));
let scale = base_ratio(key.0) * base_ratio(key.1);
let (gb, bb) = (gb * scale, bb * scale);
if beyond_tol(ga, gb, tolerance) || beyond_tol(ba, bb, tolerance) {
return Ok(Some(YbusChange {
from_bus: key.0,
to_bus: key.1,
before: (ga, ba),
after: (gb, bb),
}));
}
}
Ok(None)
}
#[must_use]
pub fn injection_change(
before: &BalancedNetwork,
after: &BalancedNetwork,
) -> Option<InjectionChange> {
injection_change_within(before, after, ELECTRICAL_TOL)
}
#[must_use]
pub fn injection_change_within(
before: &BalancedNetwork,
after: &BalancedNetwork,
tolerance: f64,
) -> Option<InjectionChange> {
let change = first_moved(
&per_bus(before, true),
&per_bus(after, true),
AC_INJECTIONS,
tolerance,
)?;
let status_only = first_moved(
&per_bus(before, false),
&per_bus(after, false),
AC_INJECTIONS,
tolerance,
)
.is_none();
Some(InjectionChange {
status_only,
..change
})
}
const AC_INJECTIONS: [Injection; 4] = [
Injection::LoadP,
Injection::LoadQ,
Injection::GenP,
Injection::GenQ,
];
const DC_TERMINALS: [Injection; 4] = [
Injection::DcRectifierP,
Injection::DcInverterP,
Injection::DcRectifierQ,
Injection::DcInverterQ,
];
fn per_bus(net: &BalancedNetwork, in_service_only: bool) -> BTreeMap<usize, [f64; 4]> {
let mut map: BTreeMap<usize, [f64; 4]> = BTreeMap::new();
for l in net
.loads()
.iter()
.filter(|l| l.in_service || !in_service_only)
{
let e = map.entry(l.bus.0).or_default();
e[0] += l.p;
e[1] += l.q;
}
for g in net
.generators()
.iter()
.filter(|g| g.in_service || !in_service_only)
{
let e = map.entry(g.bus.0).or_default();
e[2] += g.pg;
e[3] += g.qg;
}
map
}
fn per_bus_dc(net: &BalancedNetwork, in_service_only: bool) -> BTreeMap<usize, [f64; 4]> {
let mut map: BTreeMap<usize, [f64; 4]> = BTreeMap::new();
for d in net
.hvdc()
.iter()
.filter(|d| d.in_service || !in_service_only)
{
let from = map.entry(d.from.0).or_default();
from[0] += d.pf;
from[2] += d.qf;
let to = map.entry(d.to.0).or_default();
to[1] += d.pt;
to[3] += d.qt;
}
map
}
#[must_use]
pub fn dc_terminal_change(
before: &BalancedNetwork,
after: &BalancedNetwork,
) -> Option<InjectionChange> {
let change = first_moved(
&per_bus_dc(before, true),
&per_bus_dc(after, true),
DC_TERMINALS,
ELECTRICAL_TOL,
)?;
let status_only = first_moved(
&per_bus_dc(before, false),
&per_bus_dc(after, false),
DC_TERMINALS,
ELECTRICAL_TOL,
)
.is_none();
Some(InjectionChange {
status_only,
..change
})
}
fn first_moved<const N: usize>(
a: &BTreeMap<usize, [f64; N]>,
b: &BTreeMap<usize, [f64; N]>,
quantities: [Injection; N],
tolerance: f64,
) -> Option<InjectionChange> {
for key in a.keys().chain(b.keys().filter(|k| !a.contains_key(*k))) {
let x = a.get(key).copied().unwrap_or([0.0; N]);
let y = b.get(key).copied().unwrap_or([0.0; N]);
for (i, quantity) in quantities.into_iter().enumerate() {
if beyond_tol(x[i], y[i], tolerance) {
return Some(InjectionChange {
bus: *key,
quantity,
before: x[i],
after: y[i],
status_only: false,
});
}
}
}
None
}
fn beyond_tol(x: f64, y: f64, tol: f64) -> bool {
if !x.is_finite() || !y.is_finite() {
#[allow(clippy::float_cmp)]
return !(x.is_nan() && y.is_nan()) && x != y;
}
(x - y).abs() > tol * x.abs().max(y.abs()).max(1.0)
}
#[must_use]
pub fn status_disagreements(a: &BalancedNetwork, b: &BalancedNetwork) -> usize {
let off = |net: &BalancedNetwork| {
[
net.generators().iter().filter(|g| !g.in_service).count(),
net.branches().iter().filter(|x| !x.in_service).count(),
net.loads().iter().filter(|l| !l.in_service).count(),
net.shunts().iter().filter(|s| !s.in_service).count(),
net.switches().iter().filter(|x| !x.closed).count(),
net.storage().iter().filter(|x| !x.in_service).count(),
net.hvdc().iter().filter(|d| !d.in_service).count(),
net.transformers_3w()
.iter()
.filter(|t| !t.in_service)
.count(),
]
};
off(a)
.iter()
.zip(off(b).iter())
.map(|(x, y)| x.abs_diff(*y))
.sum()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransmissionCore {
pub buses: usize,
pub branches: usize,
pub generators: usize,
pub loads: usize,
pub shunts: usize,
pub load_p: i64,
pub load_q: i64,
pub gen_p: i64,
pub base_mva: i64,
}
fn milli(x: f64) -> i64 {
(x * 1e3).round() as i64
}
impl TransmissionCore {
#[must_use]
pub fn agrees_within(&self, other: &Self, tolerance: f64) -> bool {
let totals = |core: &Self| [core.load_p, core.load_q, core.gen_p, core.base_mva];
self.buses == other.buses
&& self.branches == other.branches
&& self.generators == other.generators
&& self.loads == other.loads
&& self.shunts == other.shunts
&& totals(self)
.into_iter()
.zip(totals(other))
.all(|(before, after)| !beyond_tol(before as f64, after as f64, tolerance))
}
}
#[must_use]
pub fn transmission_core(net: &BalancedNetwork) -> TransmissionCore {
TransmissionCore {
buses: net.buses().len(),
branches: net.branches().len(),
generators: net.generators().len(),
loads: net.loads().len(),
shunts: net.shunts().len(),
load_p: milli(net.loads().iter().map(|load| load.p).sum()),
load_q: milli(net.loads().iter().map(|load| load.q).sum()),
gen_p: milli(net.generators().iter().map(|generator| generator.pg).sum()),
base_mva: milli(net.base_mva()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DistributionCore {
pub buses: usize,
pub loads: usize,
pub generators: usize,
pub shunts: usize,
pub load_p: i64,
pub load_q: i64,
}
#[must_use]
pub fn distribution_core(net: &MulticonductorNetwork) -> DistributionCore {
DistributionCore {
buses: net.buses().len(),
loads: net.loads().len(),
generators: net.generators().len(),
shunts: net.shunts().len(),
load_p: milli(net.loads().iter().flat_map(|load| load.p_nom.iter()).sum()),
load_q: milli(net.loads().iter().flat_map(|load| load.q_nom.iter()).sum()),
}
}
#[must_use]
pub fn transmission_value(net: &BalancedNetwork) -> serde_json::Value {
let mut net = net.clone();
*net.name_mut() = String::new();
*net.source_format_mut() = powerio_matrix::SourceFormat::Matpower;
for br in net.branches_mut() {
br.charging = Some(br.calc_terminal_charging());
}
net.buses_mut().sort_by_key(|b| b.id);
net.branches_mut().sort_by_key(|a| (a.from, a.to));
net.loads_mut().sort_by_key(|l| l.bus);
net.shunts_mut().sort_by_key(|s| s.bus);
net.generators_mut().sort_by_key(|g| g.bus);
net.storage_mut().sort_by_key(|s| s.bus);
net.hvdc_mut().sort_by_key(|d| (d.from, d.to));
serde_json::to_value(&net).unwrap()
}
#[must_use]
pub fn distribution_value(net: &MulticonductorNetwork) -> serde_json::Value {
let mut net = net.clone();
*net.name_mut() = None;
*net.source_format_mut() = None;
serde_json::to_value(&net).unwrap()
}
#[derive(Debug, Clone, PartialEq)]
pub struct ModelDiff {
pub path: String,
pub before: DiffSide,
pub after: DiffSide,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiffSide {
Number(f64),
Value(String),
Len(usize),
Absent,
}
impl std::fmt::Display for DiffSide {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Number(x) => write!(f, "{x}"),
Self::Value(v) => write!(f, "{v}"),
Self::Len(n) => write!(f, "{n} entries"),
Self::Absent => write!(f, "absent"),
}
}
}
impl std::fmt::Display for ModelDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.before, &self.after) {
(DiffSide::Len(x), DiffSide::Len(y)) => {
write!(f, "{}: {x} entries -> {y}", self.path)
}
(before, after) => write!(f, "{}: {before} -> {after}", self.path),
}
}
}
#[must_use]
pub fn model_diffs(before: &serde_json::Value, after: &serde_json::Value) -> Vec<ModelDiff> {
let mut out = Vec::new();
diff_values(before, after, "", &mut out);
out
}
fn diff_values(a: &serde_json::Value, b: &serde_json::Value, path: &str, out: &mut Vec<ModelDiff>) {
use serde_json::Value;
if out.len() >= MAX_MODEL_DIFFS {
return;
}
match (a, b) {
(Value::Number(x), Value::Number(y)) => {
let (x, y) = (
x.as_f64().unwrap_or(f64::NAN),
y.as_f64().unwrap_or(f64::NAN),
);
let within = (x - y).abs() <= MODEL_TOL * x.abs().max(y.abs()).max(1.0);
if !(within || (x.is_nan() && y.is_nan())) {
out.push(ModelDiff {
path: path.to_string(),
before: DiffSide::Number(x),
after: DiffSide::Number(y),
});
}
}
(Value::Array(xs), Value::Array(ys)) => {
if xs.len() != ys.len() {
out.push(ModelDiff {
path: path.to_string(),
before: DiffSide::Len(xs.len()),
after: DiffSide::Len(ys.len()),
});
return;
}
for (i, (x, y)) in xs.iter().zip(ys).enumerate() {
diff_values(x, y, &format!("{path}[{i}]"), out);
}
}
(Value::Object(xs), Value::Object(ys)) => {
for key in xs.keys().chain(ys.keys().filter(|k| !xs.contains_key(*k))) {
if out.len() >= MAX_MODEL_DIFFS {
return;
}
let sub = format!("{path}.{key}");
match (xs.get(key), ys.get(key)) {
(Some(x), Some(y)) => diff_values(x, y, &sub, out),
(Some(x), None) => out.push(ModelDiff {
path: sub,
before: DiffSide::Value(x.to_string()),
after: DiffSide::Absent,
}),
(None, Some(y)) => out.push(ModelDiff {
path: sub,
before: DiffSide::Absent,
after: DiffSide::Value(y.to_string()),
}),
(None, None) => unreachable!(),
}
}
}
(x, y) if x == y => {}
(x, y) => out.push(ModelDiff {
path: path.to_string(),
before: DiffSide::Value(x.to_string()),
after: DiffSide::Value(y.to_string()),
}),
}
}