Skip to main content

powerio_cli/
invariants.rs

1//! The conversion invariants, shared by the matrix test and the corpus
2//! harness.
3//!
4//! Every property here answers one question about a conversion: did the
5//! electrical problem survive, did the element counts survive, and which
6//! typed-model fields moved. The matrix test holds seven vendored cases to
7//! them; [`crate::corpus`] holds an arbitrary corpus to the same ones.
8//!
9//! Results are structured rather than preformatted. The matrix test renders
10//! bus ids and values into its report; the corpus renders class ordinals and
11//! magnitudes into a findings file that may leave the machine. A shared
12//! `format!` would have forced one of those two to be wrong.
13
14use std::collections::{BTreeMap, VecDeque};
15
16use powerio_dist::MulticonductorNetwork;
17use powerio_matrix::BalancedNetwork;
18
19/// Relative tolerance for the electrical invariants. Conversions print
20/// shortest round-trip floats, so anything past this is a real change.
21const ELECTRICAL_TOL: f64 = 1e-8;
22
23/// Relative tolerance for the typed-model diff, one decade looser than the
24/// electrical one because it compares values that crossed a text format.
25const MODEL_TOL: f64 = 1e-9;
26
27/// The number of diff paths kept per comparison; enough to name the disease
28/// without drowning the report.
29pub const MAX_MODEL_DIFFS: usize = 24;
30
31/// A `Y_bus` entry that changed across a conversion, addressed by external bus
32/// id pair so a renumbering target cannot hide behind its own dense index.
33#[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/// Which per-bus injection moved.
52///
53/// The DC entries are per-bus too: a two-terminal DC line contributes at the
54/// bus each of its ends sits on. They name the end by its converter role
55/// rather than by the line's `from`/`to`, which are branch words for a
56/// quantity tallied at a bus.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Injection {
59    LoadP,
60    LoadQ,
61    GenP,
62    GenQ,
63    /// Real power a DC line takes out of its rectifier bus.
64    DcRectifierP,
65    /// Real power a DC line delivers to its inverter bus.
66    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/// A per-bus injection that moved across a conversion.
88#[derive(Debug, Clone, PartialEq)]
89pub struct InjectionChange {
90    pub bus: usize,
91    pub quantity: Injection,
92    pub before: f64,
93    pub after: f64,
94    /// Whether the per-bus totals over *every* element agree while the
95    /// in-service totals do not.
96    ///
97    /// That is weaker than "only the statuses differ": the tallies are per bus,
98    /// so two changes at one bus that cancel — an idle load switched on while a
99    /// live one of the same size is switched off — also satisfy it. Read it as
100    /// "the numbers in the file are unchanged", which is what it checks, and
101    /// not as a promise about which elements moved.
102    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/// One bus row of the relabeling-free electrical digest: the bus's own
119/// admittance and its injections.
120#[derive(Debug, Clone, PartialEq)]
121struct BusRow {
122    diagonal: (f64, f64),
123    injections: [f64; 4],
124}
125
126/// The relabeling-free electrical digest: one row per bus and one conductance
127/// and susceptance pair per off-diagonal admittance entry. Comparison treats
128/// both collections as tolerance-aware multisets, so a renumbering does not
129/// change them.
130type ElectricalDigest = (Vec<BusRow>, Vec<(f64, f64)>);
131
132/// Return one left-hand item that cannot participate in a complete matching.
133///
134/// The alternating-path search compares each item with the predicate instead
135/// of sorting approximate values by their exact float bits. Processing the
136/// most constrained items first keeps the ordinary case linear in the number
137/// of candidate pairs while still finding a complete matching when a greedy
138/// pairing would choose the wrong near-equal row.
139fn 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/// The electrical problem compared up to bus relabeling, for a target format
221/// that states no bus number.
222///
223/// Every bus contributes its diagonal admittance and its four injection
224/// columns, every branch its off-diagonal admittance, and the two collections
225/// are compared as sorted multisets. That is the power flow problem itself: an
226/// admittance that changed, an injection that moved to a bus with different
227/// neighbours, or an element that vanished all show up, while a renumbering
228/// alone does not. Two buses carrying rows equal within the requested tolerance
229/// are interchangeable, and exchanging them is the same power flow problem.
230///
231/// Returns the first row that disagrees, in the digest's own order.
232#[must_use]
233pub fn electrical_change_up_to_relabeling(
234    before: &BalancedNetwork,
235    after: &BalancedNetwork,
236    tolerance: f64,
237) -> Option<String> {
238    // Where every bus states a nominal voltage, the digest holds physical
239    // admittance: a format that admits only a fixed set of voltage levels
240    // writes a 345 kV case under the 330 kV level, and the per-unit values move
241    // with the base while the network does not. Where a bus states none, the
242    // writer divided by the same substituted nominal voltage the reader
243    // multiplies back, so the per-unit values are the comparable ones.
244    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/// Why a `Y_bus` comparison produced no answer.
367///
368/// Distinguished from "the matrices agree" on purpose: a network the matrix
369/// builder refuses is a conversion failure, and reporting it as agreement is
370/// how an invariant passes vacuously.
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum YbusUnavailable {
373    Before,
374    After,
375}
376
377/// The admittance matrix, entry for entry by bus id pair, on one voltage base.
378///
379/// Warnings account for *dropped* data, never for corrupted electrics, so this
380/// holds on yellow cells too. A format may relocate admittance (pandapower
381/// rides MATPOWER transformer charging as bus shunts) or restate it in other
382/// units, but `Y_bus` is where all of those spellings meet.
383///
384/// A format that states impedances in ohms rather than per unit carries its own
385/// bus nominal voltages, and some of those formats admit only a fixed set: a
386/// UCTE-DEF node code names one of ten voltage levels, so a 345 kV case is
387/// written under the 330 kV level and reads back per unit on 330 kV. The
388/// physical network is the same; only the per-unit reference moved. Each entry
389/// is therefore compared after the standard change of base, scaling the result
390/// entry by `V_before(i) * V_before(j) / (V_after(i) * V_after(j))`, which is
391/// exactly 1 whenever both sides state the same nominal voltage. A bus with no
392/// positive nominal voltage on either side contributes a unit ratio, because a
393/// writer that substitutes one nominal voltage divides by the same value the
394/// reader multiplies back.
395///
396/// A silent base change is still a loss: `base_kv` is a typed field, so the
397/// model comparison reports it and a cell claiming zero warnings fails on it.
398///
399/// # Errors
400///
401/// Returns [`YbusUnavailable`] when either side has no buildable admittance
402/// matrix.
403pub fn ybus_change(
404    before: &BalancedNetwork,
405    after: &BalancedNetwork,
406) -> Result<Option<YbusChange>, YbusUnavailable> {
407    ybus_change_within(before, after, ELECTRICAL_TOL)
408}
409
410/// [`ybus_change`] under a stated relative tolerance, for a format that states
411/// each value in a fixed width field and so cannot return more digits than the
412/// field holds.
413///
414/// # Errors
415///
416/// Returns [`YbusUnavailable`] when either side has no buildable admittance
417/// matrix.
418pub 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/// The other half of the AC operating point: per-bus demand and generation.
476///
477/// [`ybus_change`] pins the network matrices; this pins the injections at each
478/// bus, so together a conversion that passes both leaves the power flow
479/// problem unchanged — whatever it dropped in costs, limits, names, or extras.
480/// No conversion may move power between buses; formats only merge or split
481/// devices at one bus.
482///
483/// The injection compared is the *connected* one — in-service elements only,
484/// which is what a solver sees. Comparing every element regardless of status
485/// would let a conversion promote an out-of-service load to a live one without
486/// moving the total, which changes the power flow problem while leaving the
487/// stated numbers alone. `status_only` marks that case: connected power moved,
488/// stated power did not.
489///
490/// Data loss on out-of-service elements is a loss, not corrupted electrics, so
491/// it belongs to the model diff and the warning accounting rather than here.
492#[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/// [`injection_change`] under a stated relative tolerance, for a format that
501/// states each value in a fixed width field and so cannot return more digits
502/// than the field holds.
503#[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
528/// What each slot of [`per_bus`] holds.
529const AC_INJECTIONS: [Injection; 4] = [
530    Injection::LoadP,
531    Injection::LoadQ,
532    Injection::GenP,
533    Injection::GenQ,
534];
535
536/// What each slot of [`per_bus_dc`] holds.
537const 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
567/// Per-bus DC terminal power, the same tally for HVDC that [`per_bus`] is for
568/// loads and generators.
569///
570/// The two converter roles keep separate slots rather than netting, so a bus
571/// hosting one line's rectifier and another's inverter states both. Netting
572/// them would let a conversion swap the ends of a pair and still compare
573/// equal.
574fn 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/// Whether a DC line's terminal power moved across a conversion.
592///
593/// Separate from [`injection_change`] because the two carry different weight.
594/// No format may move AC power between buses — they only merge and split
595/// devices at one bus — so that is an outright failure. DC terminal power is
596/// different: PowerWorld's aux vocabulary states no HVDC at all, PSS/E carries
597/// the two ends only through `SETVL`/`RDC`/`VSCHD`, and those losses are real
598/// and declared. A caller that wants the strict reading treats any result here
599/// as a failure; the conversion matrix instead requires the loss to be
600/// declared, which is what its warning parity and typed-model gates already
601/// do.
602///
603/// A DC line never enters `Y_bus`, so without this property the two-terminal
604/// DC surface sits outside every electrical check in this module.
605#[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
629/// The first slot whose tally moved, named by `quantities`, which states what
630/// each slot holds. One tally per domain rather than one wide one shared
631/// between them: a shared array lets a caller write an AC total into a DC slot
632/// with nothing to catch it, and makes every comparison scan the other
633/// domain's always-zero half.
634fn 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
658/// Whether two values differ by more than `tol` relative.
659///
660/// A non-finite value on either side counts as a difference unless both sides
661/// are the same kind of non-finite. Every comparison against NaN is false, so
662/// the plain arithmetic would report a setpoint that turned into NaN as "no
663/// change" — the one answer that must never be given about a value that stopped
664/// being a number.
665fn beyond_tol(x: f64, y: f64, tol: f64) -> bool {
666    if !x.is_finite() || !y.is_finite() {
667        // Two NaNs are the same absence of a number; every other pairing
668        // involving a non-finite value is a change. Compared bitwise on
669        // purpose: there is no margin of error to speak of once a value has
670        // left the reals.
671        #[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/// How many elements the two networks disagree about the service status of.
678///
679/// Counted by class rather than matched element by element, because two
680/// formats number and order their tables differently and the count is what
681/// makes the disagreement actionable. An out of service machine normally
682/// states zero output, so this difference does not show up in the injections:
683/// the two files agree about today's dispatch and disagree about what exists
684/// to dispatch.
685#[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/// Element counts and the totals that must survive any conversion.
710#[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    /// Whether two cores state the same case under a relative tolerance on the
729    /// power totals.
730    ///
731    /// Element counts are compared exactly. A format that states each value in
732    /// a fixed width field returns fewer digits than the total carries, so the
733    /// totals are compared within `tolerance`.
734    #[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/// Element counts and totals for a multiconductor network.
765#[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/// A [`BalancedNetwork`] as a JSON value with the conversion-neutral identity
788/// fields cleared and two representation choices canonicalized, so the model
789/// diff compares data, not provenance or spelling:
790///
791/// - `charging: None` and the symmetric split it abbreviates are one fact;
792///   both sides expand to the explicit terminal form.
793/// - Element order is a table-layout artifact (pandapower re-groups lines and
794///   trafos; several formats sort by id); everything sorts by identity.
795///
796/// # Panics
797///
798/// Panics if the network does not serialize, which would mean a model field
799/// whose `Serialize` fails — a bug in the model rather than in the case.
800#[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/// A [`MulticonductorNetwork`] as a JSON value with identity fields cleared.
819///
820/// # Panics
821///
822/// Panics if the network does not serialize; see [`transmission_value`].
823#[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/// One typed-model field that changed across a conversion.
832#[derive(Debug, Clone, PartialEq)]
833pub struct ModelDiff {
834    /// Serde path, e.g. `.loads[3].p`. Element *names* never appear here: the
835    /// typed model holds elements in arrays, so a path carries indices only.
836    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    /// A JSON value rendered compactly. May hold case data, so the corpus
845    /// reporter never emits it verbatim.
846    Value(String),
847    /// The array length, when two arrays differ in length.
848    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        // Matches the rendering the matrix report has always used, so its
866        // committed baselines and details tables do not move.
867        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/// Every typed-model field the conversion changed, capped at
877/// [`MAX_MODEL_DIFFS`].
878///
879/// Numbers compare within a last-ulp relative tolerance — the writers print
880/// shortest round-trip floats, so anything beyond a text wobble is a real
881/// change — and everything else compares exactly.
882#[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            // Written as "not within" rather than "beyond": a NaN compares
901            // false to everything, so it must fall through to a diff unless
902            // both sides are NaN.
903            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                // Rechecked per key: the recursive call guards itself, but the
928                // two direct pushes below do not, and an object of many
929                // one-sided keys would otherwise carry the list past the cap.
930                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}