1use std::collections::{BTreeMap, VecDeque};
15
16use powerio_dist::MulticonductorNetwork;
17use powerio_matrix::BalancedNetwork;
18
19const ELECTRICAL_TOL: f64 = 1e-8;
22
23const MODEL_TOL: f64 = 1e-9;
26
27pub const MAX_MODEL_DIFFS: usize = 24;
30
31#[derive(Debug, Clone, PartialEq)]
34pub struct YbusChange {
35 pub from_bus: usize,
36 pub to_bus: usize,
37 pub before: (f64, f64),
38 pub after: (f64, f64),
39}
40
41impl std::fmt::Display for YbusChange {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(
44 f,
45 "Y[{}, {}] was {}+j{}, now {}+j{}",
46 self.from_bus, self.to_bus, self.before.0, self.before.1, self.after.0, self.after.1
47 )
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Injection {
59 LoadP,
60 LoadQ,
61 GenP,
62 GenQ,
63 DcRectifierP,
65 DcInverterP,
67 DcRectifierQ,
68 DcInverterQ,
69}
70
71impl Injection {
72 #[must_use]
73 pub const fn label(self) -> &'static str {
74 match self {
75 Self::LoadP => "load p",
76 Self::LoadQ => "load q",
77 Self::GenP => "gen p",
78 Self::GenQ => "gen q",
79 Self::DcRectifierP => "dc rectifier p",
80 Self::DcInverterP => "dc inverter p",
81 Self::DcRectifierQ => "dc rectifier q",
82 Self::DcInverterQ => "dc inverter q",
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
89pub struct InjectionChange {
90 pub bus: usize,
91 pub quantity: Injection,
92 pub before: f64,
93 pub after: f64,
94 pub status_only: bool,
103}
104
105impl std::fmt::Display for InjectionChange {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 write!(
108 f,
109 "bus {} {}: {} -> {}",
110 self.bus,
111 self.quantity.label(),
112 self.before,
113 self.after
114 )
115 }
116}
117
118#[derive(Debug, Clone, PartialEq)]
121struct BusRow {
122 diagonal: (f64, f64),
123 injections: [f64; 4],
124}
125
126type ElectricalDigest = (Vec<BusRow>, Vec<(f64, f64)>);
131
132fn unmatched_multiset_item<T>(
140 before: &[T],
141 after: &[T],
142 matches: impl Fn(&T, &T) -> bool,
143) -> Option<usize> {
144 if before.len() != after.len() {
145 return Some(0);
146 }
147 let candidates = before
148 .iter()
149 .map(|before| {
150 after
151 .iter()
152 .enumerate()
153 .filter_map(|(index, after)| matches(before, after).then_some(index))
154 .collect::<Vec<_>>()
155 })
156 .collect::<Vec<_>>();
157 let mut order = (0..before.len()).collect::<Vec<_>>();
158 order.sort_by_key(|index| candidates[*index].len());
159
160 let mut left_match: Vec<Option<usize>> = vec![None; before.len()];
161 let mut right_match: Vec<Option<usize>> = vec![None; after.len()];
162 for root in order {
163 let mut visited_left = vec![false; before.len()];
164 let mut visited_right = vec![false; after.len()];
165 let mut parent_right = vec![None; after.len()];
166 let mut queue = VecDeque::from([root]);
167 visited_left[root] = true;
168 let mut free = None;
169 while let Some(left) = queue.pop_front() {
170 for &right in &candidates[left] {
171 if visited_right[right] {
172 continue;
173 }
174 visited_right[right] = true;
175 parent_right[right] = Some(left);
176 if let Some(next_left) = right_match[right] {
177 if !visited_left[next_left] {
178 visited_left[next_left] = true;
179 queue.push_back(next_left);
180 }
181 } else {
182 free = Some(right);
183 break;
184 }
185 }
186 if free.is_some() {
187 break;
188 }
189 }
190 let Some(mut right) = free else {
191 return Some(root);
192 };
193 loop {
194 let left = parent_right[right].expect("an augmenting edge records its left endpoint");
195 let previous = left_match[left].replace(right);
196 right_match[right] = Some(left);
197 let Some(previous) = previous else {
198 break;
199 };
200 right = previous;
201 }
202 }
203 None
204}
205
206fn bus_rows_match(before: &BusRow, after: &BusRow, tolerance: f64) -> bool {
207 !beyond_tol(before.diagonal.0, after.diagonal.0, tolerance)
208 && !beyond_tol(before.diagonal.1, after.diagonal.1, tolerance)
209 && before
210 .injections
211 .iter()
212 .zip(&after.injections)
213 .all(|(before, after)| !beyond_tol(*before, *after, tolerance))
214}
215
216fn admittances_match(before: &(f64, f64), after: &(f64, f64), tolerance: f64) -> bool {
217 !beyond_tol(before.0, after.0, tolerance) && !beyond_tol(before.1, after.1, tolerance)
218}
219
220#[must_use]
233pub fn electrical_change_up_to_relabeling(
234 before: &BalancedNetwork,
235 after: &BalancedNetwork,
236 tolerance: f64,
237) -> Option<String> {
238 let physical = [before, after].iter().all(|net| {
245 net.buses()
246 .iter()
247 .all(|bus| bus.base_kv.is_finite() && bus.base_kv > 0.0)
248 });
249 let digest = |net: &BalancedNetwork| -> Option<ElectricalDigest> {
250 let nominal = net
251 .buses()
252 .iter()
253 .map(|bus| (bus.id.0, bus.base_kv))
254 .collect::<BTreeMap<_, _>>();
255 let scale = |from: usize, to: usize| -> f64 {
256 if !physical {
257 return 1.0;
258 }
259 let voltage = |bus: usize| nominal.get(&bus).copied().unwrap_or(1.0);
260 net.base_mva() / (voltage(from) * voltage(to))
261 };
262 let view = powerio_matrix::IndexedNetwork::new(net);
263 let parts =
264 powerio_matrix::calc_admittance_matrix(&view, &powerio_matrix::BuildOptions::default())
265 .ok()?;
266 let mut entries = BTreeMap::<(usize, usize), (f64, f64)>::new();
267 for (value, (i, j)) in &parts.g {
268 entries
269 .entry((view.bus_id(i).0, view.bus_id(j).0))
270 .or_insert((0.0, 0.0))
271 .0 = *value;
272 }
273 for (value, (i, j)) in &parts.b {
274 entries
275 .entry((view.bus_id(i).0, view.bus_id(j).0))
276 .or_insert((0.0, 0.0))
277 .1 = *value;
278 }
279 let injections = per_bus(net, true);
280 let mut rows = Vec::new();
281 let mut off_diagonal = Vec::new();
282 for ((from, to), value) in entries {
283 let value = (value.0 * scale(from, to), value.1 * scale(from, to));
284 if from == to {
285 rows.push(BusRow {
286 diagonal: value,
287 injections: injections.get(&from).copied().unwrap_or_default(),
288 });
289 } else {
290 off_diagonal.push(value);
291 }
292 }
293 Some((rows, off_diagonal))
294 };
295 let (before_rows, before_off) = digest(before)?;
296 let (after_rows, after_off) = digest(after)?;
297 if before_rows.len() != after_rows.len() {
298 return Some(format!(
299 "{} bus admittance row(s) became {}",
300 before_rows.len(),
301 after_rows.len()
302 ));
303 }
304 if before_off.len() != after_off.len() {
305 return Some(format!(
306 "{} off-diagonal admittance entr(ies) became {}",
307 before_off.len(),
308 after_off.len()
309 ));
310 }
311 if let Some(index) = unmatched_multiset_item(&before_rows, &after_rows, |before, after| {
312 bus_rows_match(before, after, tolerance)
313 }) {
314 return Some(format!(
315 "bus row {:?} has no matching result row",
316 before_rows[index]
317 ));
318 }
319 if let Some(index) = unmatched_multiset_item(&before_off, &after_off, |before, after| {
320 admittances_match(before, after, tolerance)
321 }) {
322 return Some(format!(
323 "off-diagonal admittance {:?} has no matching result entry",
324 before_off[index]
325 ));
326 }
327 None
328}
329
330#[cfg(test)]
331mod relabeling_tests {
332 use super::{BusRow, bus_rows_match, unmatched_multiset_item};
333
334 #[test]
335 fn near_equal_rows_pair_by_all_electrical_values() {
336 let before = [
337 BusRow {
338 diagonal: (1.0, 0.0),
339 injections: [0.0; 4],
340 },
341 BusRow {
342 diagonal: (1.000_001, 0.0),
343 injections: [5.0, 0.0, 0.0, 0.0],
344 },
345 ];
346 let after = [
347 BusRow {
348 diagonal: (1.0, 0.0),
349 injections: [5.0, 0.0, 0.0, 0.0],
350 },
351 BusRow {
352 diagonal: (1.000_001, 0.0),
353 injections: [0.0; 4],
354 },
355 ];
356
357 assert_eq!(
358 unmatched_multiset_item(&before, &after, |before, after| {
359 bus_rows_match(before, after, 1e-3)
360 }),
361 None
362 );
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum YbusUnavailable {
373 Before,
374 After,
375}
376
377pub fn ybus_change(
404 before: &BalancedNetwork,
405 after: &BalancedNetwork,
406) -> Result<Option<YbusChange>, YbusUnavailable> {
407 ybus_change_within(before, after, ELECTRICAL_TOL)
408}
409
410pub fn ybus_change_within(
419 before: &BalancedNetwork,
420 after: &BalancedNetwork,
421 tolerance: f64,
422) -> Result<Option<YbusChange>, YbusUnavailable> {
423 let nominal = |net: &BalancedNetwork| -> BTreeMap<usize, f64> {
424 net.buses()
425 .iter()
426 .filter(|bus| bus.base_kv.is_finite() && bus.base_kv > 0.0)
427 .map(|bus| (bus.id.0, bus.base_kv))
428 .collect()
429 };
430 let nominal_before = nominal(before);
431 let nominal_after = nominal(after);
432 let base_ratio = |bus: usize| -> f64 {
433 match (nominal_before.get(&bus), nominal_after.get(&bus)) {
434 (Some(before), Some(after)) => before / after,
435 _ => 1.0,
436 }
437 };
438 let entries = |net: &BalancedNetwork| -> Option<BTreeMap<(usize, usize), (f64, f64)>> {
439 let view = powerio_matrix::IndexedNetwork::new(net);
440 let parts =
441 powerio_matrix::calc_admittance_matrix(&view, &powerio_matrix::BuildOptions::default())
442 .ok()?;
443 let mut map = BTreeMap::new();
444 for (value, (i, j)) in &parts.g {
445 map.entry((view.bus_id(i).0, view.bus_id(j).0))
446 .or_insert((0.0, 0.0))
447 .0 = *value;
448 }
449 for (value, (i, j)) in &parts.b {
450 map.entry((view.bus_id(i).0, view.bus_id(j).0))
451 .or_insert((0.0, 0.0))
452 .1 = *value;
453 }
454 Some(map)
455 };
456 let a = entries(before).ok_or(YbusUnavailable::Before)?;
457 let b = entries(after).ok_or(YbusUnavailable::After)?;
458 for key in a.keys().chain(b.keys().filter(|k| !a.contains_key(*k))) {
459 let (ga, ba) = a.get(key).copied().unwrap_or((0.0, 0.0));
460 let (gb, bb) = b.get(key).copied().unwrap_or((0.0, 0.0));
461 let scale = base_ratio(key.0) * base_ratio(key.1);
462 let (gb, bb) = (gb * scale, bb * scale);
463 if beyond_tol(ga, gb, tolerance) || beyond_tol(ba, bb, tolerance) {
464 return Ok(Some(YbusChange {
465 from_bus: key.0,
466 to_bus: key.1,
467 before: (ga, ba),
468 after: (gb, bb),
469 }));
470 }
471 }
472 Ok(None)
473}
474
475#[must_use]
493pub fn injection_change(
494 before: &BalancedNetwork,
495 after: &BalancedNetwork,
496) -> Option<InjectionChange> {
497 injection_change_within(before, after, ELECTRICAL_TOL)
498}
499
500#[must_use]
504pub fn injection_change_within(
505 before: &BalancedNetwork,
506 after: &BalancedNetwork,
507 tolerance: f64,
508) -> Option<InjectionChange> {
509 let change = first_moved(
510 &per_bus(before, true),
511 &per_bus(after, true),
512 AC_INJECTIONS,
513 tolerance,
514 )?;
515 let status_only = first_moved(
516 &per_bus(before, false),
517 &per_bus(after, false),
518 AC_INJECTIONS,
519 tolerance,
520 )
521 .is_none();
522 Some(InjectionChange {
523 status_only,
524 ..change
525 })
526}
527
528const AC_INJECTIONS: [Injection; 4] = [
530 Injection::LoadP,
531 Injection::LoadQ,
532 Injection::GenP,
533 Injection::GenQ,
534];
535
536const DC_TERMINALS: [Injection; 4] = [
538 Injection::DcRectifierP,
539 Injection::DcInverterP,
540 Injection::DcRectifierQ,
541 Injection::DcInverterQ,
542];
543
544fn per_bus(net: &BalancedNetwork, in_service_only: bool) -> BTreeMap<usize, [f64; 4]> {
545 let mut map: BTreeMap<usize, [f64; 4]> = BTreeMap::new();
546 for l in net
547 .loads()
548 .iter()
549 .filter(|l| l.in_service || !in_service_only)
550 {
551 let e = map.entry(l.bus.0).or_default();
552 e[0] += l.p;
553 e[1] += l.q;
554 }
555 for g in net
556 .generators()
557 .iter()
558 .filter(|g| g.in_service || !in_service_only)
559 {
560 let e = map.entry(g.bus.0).or_default();
561 e[2] += g.pg;
562 e[3] += g.qg;
563 }
564 map
565}
566
567fn per_bus_dc(net: &BalancedNetwork, in_service_only: bool) -> BTreeMap<usize, [f64; 4]> {
575 let mut map: BTreeMap<usize, [f64; 4]> = BTreeMap::new();
576 for d in net
577 .hvdc()
578 .iter()
579 .filter(|d| d.in_service || !in_service_only)
580 {
581 let from = map.entry(d.from.0).or_default();
582 from[0] += d.pf;
583 from[2] += d.qf;
584 let to = map.entry(d.to.0).or_default();
585 to[1] += d.pt;
586 to[3] += d.qt;
587 }
588 map
589}
590
591#[must_use]
606pub fn dc_terminal_change(
607 before: &BalancedNetwork,
608 after: &BalancedNetwork,
609) -> Option<InjectionChange> {
610 let change = first_moved(
611 &per_bus_dc(before, true),
612 &per_bus_dc(after, true),
613 DC_TERMINALS,
614 ELECTRICAL_TOL,
615 )?;
616 let status_only = first_moved(
617 &per_bus_dc(before, false),
618 &per_bus_dc(after, false),
619 DC_TERMINALS,
620 ELECTRICAL_TOL,
621 )
622 .is_none();
623 Some(InjectionChange {
624 status_only,
625 ..change
626 })
627}
628
629fn first_moved<const N: usize>(
635 a: &BTreeMap<usize, [f64; N]>,
636 b: &BTreeMap<usize, [f64; N]>,
637 quantities: [Injection; N],
638 tolerance: f64,
639) -> Option<InjectionChange> {
640 for key in a.keys().chain(b.keys().filter(|k| !a.contains_key(*k))) {
641 let x = a.get(key).copied().unwrap_or([0.0; N]);
642 let y = b.get(key).copied().unwrap_or([0.0; N]);
643 for (i, quantity) in quantities.into_iter().enumerate() {
644 if beyond_tol(x[i], y[i], tolerance) {
645 return Some(InjectionChange {
646 bus: *key,
647 quantity,
648 before: x[i],
649 after: y[i],
650 status_only: false,
651 });
652 }
653 }
654 }
655 None
656}
657
658fn beyond_tol(x: f64, y: f64, tol: f64) -> bool {
666 if !x.is_finite() || !y.is_finite() {
667 #[allow(clippy::float_cmp)]
672 return !(x.is_nan() && y.is_nan()) && x != y;
673 }
674 (x - y).abs() > tol * x.abs().max(y.abs()).max(1.0)
675}
676
677#[must_use]
686pub fn status_disagreements(a: &BalancedNetwork, b: &BalancedNetwork) -> usize {
687 let off = |net: &BalancedNetwork| {
688 [
689 net.generators().iter().filter(|g| !g.in_service).count(),
690 net.branches().iter().filter(|x| !x.in_service).count(),
691 net.loads().iter().filter(|l| !l.in_service).count(),
692 net.shunts().iter().filter(|s| !s.in_service).count(),
693 net.switches().iter().filter(|x| !x.closed).count(),
694 net.storage().iter().filter(|x| !x.in_service).count(),
695 net.hvdc().iter().filter(|d| !d.in_service).count(),
696 net.transformers_3w()
697 .iter()
698 .filter(|t| !t.in_service)
699 .count(),
700 ]
701 };
702 off(a)
703 .iter()
704 .zip(off(b).iter())
705 .map(|(x, y)| x.abs_diff(*y))
706 .sum()
707}
708
709#[derive(Debug, Clone, PartialEq, Eq)]
711pub struct TransmissionCore {
712 pub buses: usize,
713 pub branches: usize,
714 pub generators: usize,
715 pub loads: usize,
716 pub shunts: usize,
717 pub load_p: i64,
718 pub load_q: i64,
719 pub gen_p: i64,
720 pub base_mva: i64,
721}
722
723fn milli(x: f64) -> i64 {
724 (x * 1e3).round() as i64
725}
726
727impl TransmissionCore {
728 #[must_use]
735 pub fn agrees_within(&self, other: &Self, tolerance: f64) -> bool {
736 let totals = |core: &Self| [core.load_p, core.load_q, core.gen_p, core.base_mva];
737 self.buses == other.buses
738 && self.branches == other.branches
739 && self.generators == other.generators
740 && self.loads == other.loads
741 && self.shunts == other.shunts
742 && totals(self)
743 .into_iter()
744 .zip(totals(other))
745 .all(|(before, after)| !beyond_tol(before as f64, after as f64, tolerance))
746 }
747}
748
749#[must_use]
750pub fn transmission_core(net: &BalancedNetwork) -> TransmissionCore {
751 TransmissionCore {
752 buses: net.buses().len(),
753 branches: net.branches().len(),
754 generators: net.generators().len(),
755 loads: net.loads().len(),
756 shunts: net.shunts().len(),
757 load_p: milli(net.loads().iter().map(|load| load.p).sum()),
758 load_q: milli(net.loads().iter().map(|load| load.q).sum()),
759 gen_p: milli(net.generators().iter().map(|generator| generator.pg).sum()),
760 base_mva: milli(net.base_mva()),
761 }
762}
763
764#[derive(Debug, Clone, PartialEq, Eq)]
766pub struct DistributionCore {
767 pub buses: usize,
768 pub loads: usize,
769 pub generators: usize,
770 pub shunts: usize,
771 pub load_p: i64,
772 pub load_q: i64,
773}
774
775#[must_use]
776pub fn distribution_core(net: &MulticonductorNetwork) -> DistributionCore {
777 DistributionCore {
778 buses: net.buses().len(),
779 loads: net.loads().len(),
780 generators: net.generators().len(),
781 shunts: net.shunts().len(),
782 load_p: milli(net.loads().iter().flat_map(|load| load.p_nom.iter()).sum()),
783 load_q: milli(net.loads().iter().flat_map(|load| load.q_nom.iter()).sum()),
784 }
785}
786
787#[must_use]
801pub fn transmission_value(net: &BalancedNetwork) -> serde_json::Value {
802 let mut net = net.clone();
803 *net.name_mut() = String::new();
804 *net.source_format_mut() = powerio_matrix::SourceFormat::Matpower;
805 for br in net.branches_mut() {
806 br.charging = Some(br.calc_terminal_charging());
807 }
808 net.buses_mut().sort_by_key(|b| b.id);
809 net.branches_mut().sort_by_key(|a| (a.from, a.to));
810 net.loads_mut().sort_by_key(|l| l.bus);
811 net.shunts_mut().sort_by_key(|s| s.bus);
812 net.generators_mut().sort_by_key(|g| g.bus);
813 net.storage_mut().sort_by_key(|s| s.bus);
814 net.hvdc_mut().sort_by_key(|d| (d.from, d.to));
815 serde_json::to_value(&net).unwrap()
816}
817
818#[must_use]
824pub fn distribution_value(net: &MulticonductorNetwork) -> serde_json::Value {
825 let mut net = net.clone();
826 *net.name_mut() = None;
827 *net.source_format_mut() = None;
828 serde_json::to_value(&net).unwrap()
829}
830
831#[derive(Debug, Clone, PartialEq)]
833pub struct ModelDiff {
834 pub path: String,
837 pub before: DiffSide,
838 pub after: DiffSide,
839}
840
841#[derive(Debug, Clone, PartialEq)]
842pub enum DiffSide {
843 Number(f64),
844 Value(String),
847 Len(usize),
849 Absent,
850}
851
852impl std::fmt::Display for DiffSide {
853 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
854 match self {
855 Self::Number(x) => write!(f, "{x}"),
856 Self::Value(v) => write!(f, "{v}"),
857 Self::Len(n) => write!(f, "{n} entries"),
858 Self::Absent => write!(f, "absent"),
859 }
860 }
861}
862
863impl std::fmt::Display for ModelDiff {
864 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
865 match (&self.before, &self.after) {
868 (DiffSide::Len(x), DiffSide::Len(y)) => {
869 write!(f, "{}: {x} entries -> {y}", self.path)
870 }
871 (before, after) => write!(f, "{}: {before} -> {after}", self.path),
872 }
873 }
874}
875
876#[must_use]
883pub fn model_diffs(before: &serde_json::Value, after: &serde_json::Value) -> Vec<ModelDiff> {
884 let mut out = Vec::new();
885 diff_values(before, after, "", &mut out);
886 out
887}
888
889fn diff_values(a: &serde_json::Value, b: &serde_json::Value, path: &str, out: &mut Vec<ModelDiff>) {
890 use serde_json::Value;
891 if out.len() >= MAX_MODEL_DIFFS {
892 return;
893 }
894 match (a, b) {
895 (Value::Number(x), Value::Number(y)) => {
896 let (x, y) = (
897 x.as_f64().unwrap_or(f64::NAN),
898 y.as_f64().unwrap_or(f64::NAN),
899 );
900 let within = (x - y).abs() <= MODEL_TOL * x.abs().max(y.abs()).max(1.0);
904 if !(within || (x.is_nan() && y.is_nan())) {
905 out.push(ModelDiff {
906 path: path.to_string(),
907 before: DiffSide::Number(x),
908 after: DiffSide::Number(y),
909 });
910 }
911 }
912 (Value::Array(xs), Value::Array(ys)) => {
913 if xs.len() != ys.len() {
914 out.push(ModelDiff {
915 path: path.to_string(),
916 before: DiffSide::Len(xs.len()),
917 after: DiffSide::Len(ys.len()),
918 });
919 return;
920 }
921 for (i, (x, y)) in xs.iter().zip(ys).enumerate() {
922 diff_values(x, y, &format!("{path}[{i}]"), out);
923 }
924 }
925 (Value::Object(xs), Value::Object(ys)) => {
926 for key in xs.keys().chain(ys.keys().filter(|k| !xs.contains_key(*k))) {
927 if out.len() >= MAX_MODEL_DIFFS {
931 return;
932 }
933 let sub = format!("{path}.{key}");
934 match (xs.get(key), ys.get(key)) {
935 (Some(x), Some(y)) => diff_values(x, y, &sub, out),
936 (Some(x), None) => out.push(ModelDiff {
937 path: sub,
938 before: DiffSide::Value(x.to_string()),
939 after: DiffSide::Absent,
940 }),
941 (None, Some(y)) => out.push(ModelDiff {
942 path: sub,
943 before: DiffSide::Absent,
944 after: DiffSide::Value(y.to_string()),
945 }),
946 (None, None) => unreachable!(),
947 }
948 }
949 }
950 (x, y) if x == y => {}
951 (x, y) => out.push(ModelDiff {
952 path: path.to_string(),
953 before: DiffSide::Value(x.to_string()),
954 after: DiffSide::Value(y.to_string()),
955 }),
956 }
957}