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;
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/// Why a `Y_bus` comparison produced no answer.
119///
120/// Distinguished from "the matrices agree" on purpose: a network the matrix
121/// builder refuses is a conversion failure, and reporting it as agreement is
122/// how an invariant passes vacuously.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum YbusUnavailable {
125    Before,
126    After,
127}
128
129/// The admittance matrix, entry for entry by bus id pair.
130///
131/// Warnings account for *dropped* data, never for corrupted electrics, so this
132/// holds on yellow cells too. A format may relocate admittance (pandapower
133/// rides MATPOWER transformer charging as bus shunts) or restate it in other
134/// units, but `Y_bus` is where all of those spellings meet.
135///
136/// # Errors
137///
138/// Returns [`YbusUnavailable`] when either side has no buildable admittance
139/// matrix.
140pub fn ybus_change(
141    before: &BalancedNetwork,
142    after: &BalancedNetwork,
143) -> Result<Option<YbusChange>, YbusUnavailable> {
144    let entries = |net: &BalancedNetwork| -> Option<BTreeMap<(usize, usize), (f64, f64)>> {
145        let view = powerio_matrix::IndexedNetwork::new(net);
146        let parts =
147            powerio_matrix::build_ybus(&view, &powerio_matrix::BuildOptions::default()).ok()?;
148        let mut map = BTreeMap::new();
149        for (value, (i, j)) in &parts.g {
150            map.entry((view.bus_id(i).0, view.bus_id(j).0))
151                .or_insert((0.0, 0.0))
152                .0 = *value;
153        }
154        for (value, (i, j)) in &parts.b {
155            map.entry((view.bus_id(i).0, view.bus_id(j).0))
156                .or_insert((0.0, 0.0))
157                .1 = *value;
158        }
159        Some(map)
160    };
161    let a = entries(before).ok_or(YbusUnavailable::Before)?;
162    let b = entries(after).ok_or(YbusUnavailable::After)?;
163    for key in a.keys().chain(b.keys().filter(|k| !a.contains_key(*k))) {
164        let (ga, ba) = a.get(key).copied().unwrap_or((0.0, 0.0));
165        let (gb, bb) = b.get(key).copied().unwrap_or((0.0, 0.0));
166        if beyond_tol(ga, gb, ELECTRICAL_TOL) || beyond_tol(ba, bb, ELECTRICAL_TOL) {
167            return Ok(Some(YbusChange {
168                from_bus: key.0,
169                to_bus: key.1,
170                before: (ga, ba),
171                after: (gb, bb),
172            }));
173        }
174    }
175    Ok(None)
176}
177
178/// The other half of the AC operating point: per-bus demand and generation.
179///
180/// [`ybus_change`] pins the network matrices; this pins the injections at each
181/// bus, so together a conversion that passes both leaves the power flow
182/// problem unchanged — whatever it dropped in costs, limits, names, or extras.
183/// No conversion may move power between buses; formats only merge or split
184/// devices at one bus.
185///
186/// The injection compared is the *connected* one — in-service elements only,
187/// which is what a solver sees. Comparing every element regardless of status
188/// would let a conversion promote an out-of-service load to a live one without
189/// moving the total, which changes the power flow problem while leaving the
190/// stated numbers alone. `status_only` marks that case: connected power moved,
191/// stated power did not.
192///
193/// Data loss on out-of-service elements is a loss, not corrupted electrics, so
194/// it belongs to the model diff and the warning accounting rather than here.
195#[must_use]
196pub fn injection_change(
197    before: &BalancedNetwork,
198    after: &BalancedNetwork,
199) -> Option<InjectionChange> {
200    let change = first_moved(&per_bus(before, true), &per_bus(after, true), AC_INJECTIONS)?;
201    let status_only = first_moved(
202        &per_bus(before, false),
203        &per_bus(after, false),
204        AC_INJECTIONS,
205    )
206    .is_none();
207    Some(InjectionChange {
208        status_only,
209        ..change
210    })
211}
212
213/// What each slot of [`per_bus`] holds.
214const AC_INJECTIONS: [Injection; 4] = [
215    Injection::LoadP,
216    Injection::LoadQ,
217    Injection::GenP,
218    Injection::GenQ,
219];
220
221/// What each slot of [`per_bus_dc`] holds.
222const DC_TERMINALS: [Injection; 4] = [
223    Injection::DcRectifierP,
224    Injection::DcInverterP,
225    Injection::DcRectifierQ,
226    Injection::DcInverterQ,
227];
228
229fn per_bus(net: &BalancedNetwork, in_service_only: bool) -> BTreeMap<usize, [f64; 4]> {
230    let mut map: BTreeMap<usize, [f64; 4]> = BTreeMap::new();
231    for l in net
232        .loads()
233        .iter()
234        .filter(|l| l.in_service || !in_service_only)
235    {
236        let e = map.entry(l.bus.0).or_default();
237        e[0] += l.p;
238        e[1] += l.q;
239    }
240    for g in net
241        .generators()
242        .iter()
243        .filter(|g| g.in_service || !in_service_only)
244    {
245        let e = map.entry(g.bus.0).or_default();
246        e[2] += g.pg;
247        e[3] += g.qg;
248    }
249    map
250}
251
252/// Per-bus DC terminal power, the same tally for HVDC that [`per_bus`] is for
253/// loads and generators.
254///
255/// The two converter roles keep separate slots rather than netting, so a bus
256/// hosting one line's rectifier and another's inverter states both. Netting
257/// them would let a conversion swap the ends of a pair and still compare
258/// equal.
259fn per_bus_dc(net: &BalancedNetwork, in_service_only: bool) -> BTreeMap<usize, [f64; 4]> {
260    let mut map: BTreeMap<usize, [f64; 4]> = BTreeMap::new();
261    for d in net
262        .hvdc()
263        .iter()
264        .filter(|d| d.in_service || !in_service_only)
265    {
266        let from = map.entry(d.from.0).or_default();
267        from[0] += d.pf;
268        from[2] += d.qf;
269        let to = map.entry(d.to.0).or_default();
270        to[1] += d.pt;
271        to[3] += d.qt;
272    }
273    map
274}
275
276/// Whether a DC line's terminal power moved across a conversion.
277///
278/// Separate from [`injection_change`] because the two carry different weight.
279/// No format may move AC power between buses — they only merge and split
280/// devices at one bus — so that is an outright failure. DC terminal power is
281/// different: PowerWorld's aux vocabulary states no HVDC at all, PSS/E carries
282/// the two ends only through `SETVL`/`RDC`/`VSCHD`, and those losses are real
283/// and declared. A caller that wants the strict reading treats any result here
284/// as a failure; the conversion matrix instead requires the loss to be
285/// declared, which is what its warning parity and typed-model gates already
286/// do.
287///
288/// A DC line never enters `Y_bus`, so without this property the two-terminal
289/// DC surface sits outside every electrical check in this module.
290#[must_use]
291pub fn dc_terminal_change(
292    before: &BalancedNetwork,
293    after: &BalancedNetwork,
294) -> Option<InjectionChange> {
295    let change = first_moved(
296        &per_bus_dc(before, true),
297        &per_bus_dc(after, true),
298        DC_TERMINALS,
299    )?;
300    let status_only = first_moved(
301        &per_bus_dc(before, false),
302        &per_bus_dc(after, false),
303        DC_TERMINALS,
304    )
305    .is_none();
306    Some(InjectionChange {
307        status_only,
308        ..change
309    })
310}
311
312/// The first slot whose tally moved, named by `quantities`, which states what
313/// each slot holds. One tally per domain rather than one wide one shared
314/// between them: a shared array lets a caller write an AC total into a DC slot
315/// with nothing to catch it, and makes every comparison scan the other
316/// domain's always-zero half.
317fn first_moved<const N: usize>(
318    a: &BTreeMap<usize, [f64; N]>,
319    b: &BTreeMap<usize, [f64; N]>,
320    quantities: [Injection; N],
321) -> Option<InjectionChange> {
322    for key in a.keys().chain(b.keys().filter(|k| !a.contains_key(*k))) {
323        let x = a.get(key).copied().unwrap_or([0.0; N]);
324        let y = b.get(key).copied().unwrap_or([0.0; N]);
325        for (i, quantity) in quantities.into_iter().enumerate() {
326            if beyond_tol(x[i], y[i], ELECTRICAL_TOL) {
327                return Some(InjectionChange {
328                    bus: *key,
329                    quantity,
330                    before: x[i],
331                    after: y[i],
332                    status_only: false,
333                });
334            }
335        }
336    }
337    None
338}
339
340/// Whether two values differ by more than `tol` relative.
341///
342/// A non-finite value on either side counts as a difference unless both sides
343/// are the same kind of non-finite. Every comparison against NaN is false, so
344/// the plain arithmetic would report a setpoint that turned into NaN as "no
345/// change" — the one answer that must never be given about a value that stopped
346/// being a number.
347fn beyond_tol(x: f64, y: f64, tol: f64) -> bool {
348    if !x.is_finite() || !y.is_finite() {
349        // Two NaNs are the same absence of a number; every other pairing
350        // involving a non-finite value is a change. Compared bitwise on
351        // purpose: there is no margin of error to speak of once a value has
352        // left the reals.
353        #[allow(clippy::float_cmp)]
354        return !(x.is_nan() && y.is_nan()) && x != y;
355    }
356    (x - y).abs() > tol * x.abs().max(y.abs()).max(1.0)
357}
358
359/// How many elements the two networks disagree about the service status of.
360///
361/// Counted by class rather than matched element by element, because two
362/// formats number and order their tables differently and the count is what
363/// makes the disagreement actionable. An out of service machine normally
364/// states zero output, so this difference does not show up in the injections:
365/// the two files agree about today's dispatch and disagree about what exists
366/// to dispatch.
367#[must_use]
368pub fn status_disagreements(a: &BalancedNetwork, b: &BalancedNetwork) -> usize {
369    let off = |net: &BalancedNetwork| {
370        [
371            net.generators().iter().filter(|g| !g.in_service).count(),
372            net.branches().iter().filter(|x| !x.in_service).count(),
373            net.loads().iter().filter(|l| !l.in_service).count(),
374            net.shunts().iter().filter(|s| !s.in_service).count(),
375            net.switches().iter().filter(|x| !x.closed).count(),
376            net.storage().iter().filter(|x| !x.in_service).count(),
377            net.hvdc().iter().filter(|d| !d.in_service).count(),
378            net.transformers_3w()
379                .iter()
380                .filter(|t| !t.in_service)
381                .count(),
382        ]
383    };
384    off(a)
385        .iter()
386        .zip(off(b).iter())
387        .map(|(x, y)| x.abs_diff(*y))
388        .sum()
389}
390
391/// Element counts and the totals that must survive any conversion.
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct TransmissionCore {
394    pub buses: usize,
395    pub branches: usize,
396    pub generators: usize,
397    pub loads: usize,
398    pub shunts: usize,
399    pub load_p: i64,
400    pub load_q: i64,
401    pub gen_p: i64,
402    pub base_mva: i64,
403}
404
405fn milli(x: f64) -> i64 {
406    (x * 1e3).round() as i64
407}
408
409#[must_use]
410pub fn transmission_core(net: &BalancedNetwork) -> TransmissionCore {
411    TransmissionCore {
412        buses: net.buses().len(),
413        branches: net.branches().len(),
414        generators: net.generators().len(),
415        loads: net.loads().len(),
416        shunts: net.shunts().len(),
417        load_p: milli(net.loads().iter().map(|load| load.p).sum()),
418        load_q: milli(net.loads().iter().map(|load| load.q).sum()),
419        gen_p: milli(net.generators().iter().map(|generator| generator.pg).sum()),
420        base_mva: milli(net.base_mva()),
421    }
422}
423
424/// Element counts and totals for a multiconductor network.
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct DistributionCore {
427    pub buses: usize,
428    pub loads: usize,
429    pub generators: usize,
430    pub shunts: usize,
431    pub load_p: i64,
432    pub load_q: i64,
433}
434
435#[must_use]
436pub fn distribution_core(net: &MulticonductorNetwork) -> DistributionCore {
437    DistributionCore {
438        buses: net.buses().len(),
439        loads: net.loads().len(),
440        generators: net.generators().len(),
441        shunts: net.shunts().len(),
442        load_p: milli(net.loads().iter().flat_map(|load| load.p_nom.iter()).sum()),
443        load_q: milli(net.loads().iter().flat_map(|load| load.q_nom.iter()).sum()),
444    }
445}
446
447/// A [`BalancedNetwork`] as a JSON value with the conversion-neutral identity
448/// fields cleared and two representation choices canonicalized, so the model
449/// diff compares data, not provenance or spelling:
450///
451/// - `charging: None` and the symmetric split it abbreviates are one fact;
452///   both sides expand to the explicit terminal form.
453/// - Element order is a table-layout artifact (pandapower re-groups lines and
454///   trafos; several formats sort by id); everything sorts by identity.
455///
456/// # Panics
457///
458/// Panics if the network does not serialize, which would mean a model field
459/// whose `Serialize` fails — a bug in the model rather than in the case.
460#[must_use]
461pub fn transmission_value(net: &BalancedNetwork) -> serde_json::Value {
462    let mut net = net.clone();
463    *net.name_mut() = String::new();
464    *net.source_format_mut() = powerio_matrix::SourceFormat::Matpower;
465    for br in net.branches_mut() {
466        br.charging = Some(br.terminal_charging());
467    }
468    net.buses_mut().sort_by_key(|b| b.id);
469    net.branches_mut().sort_by_key(|a| (a.from, a.to));
470    net.loads_mut().sort_by_key(|l| l.bus);
471    net.shunts_mut().sort_by_key(|s| s.bus);
472    net.generators_mut().sort_by_key(|g| g.bus);
473    net.storage_mut().sort_by_key(|s| s.bus);
474    net.hvdc_mut().sort_by_key(|d| (d.from, d.to));
475    serde_json::to_value(&net).unwrap()
476}
477
478/// A [`MulticonductorNetwork`] as a JSON value with identity fields cleared.
479///
480/// # Panics
481///
482/// Panics if the network does not serialize; see [`transmission_value`].
483#[must_use]
484pub fn distribution_value(net: &MulticonductorNetwork) -> serde_json::Value {
485    let mut net = net.clone();
486    *net.name_mut() = None;
487    *net.source_format_mut() = None;
488    serde_json::to_value(&net).unwrap()
489}
490
491/// One typed-model field that changed across a conversion.
492#[derive(Debug, Clone, PartialEq)]
493pub struct ModelDiff {
494    /// Serde path, e.g. `.loads[3].p`. Element *names* never appear here: the
495    /// typed model holds elements in arrays, so a path carries indices only.
496    pub path: String,
497    pub before: DiffSide,
498    pub after: DiffSide,
499}
500
501#[derive(Debug, Clone, PartialEq)]
502pub enum DiffSide {
503    Number(f64),
504    /// A JSON value rendered compactly. May hold case data, so the corpus
505    /// reporter never emits it verbatim.
506    Value(String),
507    /// The array length, when two arrays differ in length.
508    Len(usize),
509    Absent,
510}
511
512impl std::fmt::Display for DiffSide {
513    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
514        match self {
515            Self::Number(x) => write!(f, "{x}"),
516            Self::Value(v) => write!(f, "{v}"),
517            Self::Len(n) => write!(f, "{n} entries"),
518            Self::Absent => write!(f, "absent"),
519        }
520    }
521}
522
523impl std::fmt::Display for ModelDiff {
524    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
525        // Matches the rendering the matrix report has always used, so its
526        // committed baselines and details tables do not move.
527        match (&self.before, &self.after) {
528            (DiffSide::Len(x), DiffSide::Len(y)) => {
529                write!(f, "{}: {x} entries -> {y}", self.path)
530            }
531            (before, after) => write!(f, "{}: {before} -> {after}", self.path),
532        }
533    }
534}
535
536/// Every typed-model field the conversion changed, capped at
537/// [`MAX_MODEL_DIFFS`].
538///
539/// Numbers compare within a last-ulp relative tolerance — the writers print
540/// shortest round-trip floats, so anything beyond a text wobble is a real
541/// change — and everything else compares exactly.
542#[must_use]
543pub fn model_diffs(before: &serde_json::Value, after: &serde_json::Value) -> Vec<ModelDiff> {
544    let mut out = Vec::new();
545    diff_values(before, after, "", &mut out);
546    out
547}
548
549fn diff_values(a: &serde_json::Value, b: &serde_json::Value, path: &str, out: &mut Vec<ModelDiff>) {
550    use serde_json::Value;
551    if out.len() >= MAX_MODEL_DIFFS {
552        return;
553    }
554    match (a, b) {
555        (Value::Number(x), Value::Number(y)) => {
556            let (x, y) = (
557                x.as_f64().unwrap_or(f64::NAN),
558                y.as_f64().unwrap_or(f64::NAN),
559            );
560            // Written as "not within" rather than "beyond": a NaN compares
561            // false to everything, so it must fall through to a diff unless
562            // both sides are NaN.
563            let within = (x - y).abs() <= MODEL_TOL * x.abs().max(y.abs()).max(1.0);
564            if !(within || (x.is_nan() && y.is_nan())) {
565                out.push(ModelDiff {
566                    path: path.to_string(),
567                    before: DiffSide::Number(x),
568                    after: DiffSide::Number(y),
569                });
570            }
571        }
572        (Value::Array(xs), Value::Array(ys)) => {
573            if xs.len() != ys.len() {
574                out.push(ModelDiff {
575                    path: path.to_string(),
576                    before: DiffSide::Len(xs.len()),
577                    after: DiffSide::Len(ys.len()),
578                });
579                return;
580            }
581            for (i, (x, y)) in xs.iter().zip(ys).enumerate() {
582                diff_values(x, y, &format!("{path}[{i}]"), out);
583            }
584        }
585        (Value::Object(xs), Value::Object(ys)) => {
586            for key in xs.keys().chain(ys.keys().filter(|k| !xs.contains_key(*k))) {
587                // Rechecked per key: the recursive call guards itself, but the
588                // two direct pushes below do not, and an object of many
589                // one-sided keys would otherwise carry the list past the cap.
590                if out.len() >= MAX_MODEL_DIFFS {
591                    return;
592                }
593                let sub = format!("{path}.{key}");
594                match (xs.get(key), ys.get(key)) {
595                    (Some(x), Some(y)) => diff_values(x, y, &sub, out),
596                    (Some(x), None) => out.push(ModelDiff {
597                        path: sub,
598                        before: DiffSide::Value(x.to_string()),
599                        after: DiffSide::Absent,
600                    }),
601                    (None, Some(y)) => out.push(ModelDiff {
602                        path: sub,
603                        before: DiffSide::Absent,
604                        after: DiffSide::Value(y.to_string()),
605                    }),
606                    (None, None) => unreachable!(),
607                }
608            }
609        }
610        (x, y) if x == y => {}
611        (x, y) => out.push(ModelDiff {
612            path: path.to_string(),
613            before: DiffSide::Value(x.to_string()),
614            after: DiffSide::Value(y.to_string()),
615        }),
616    }
617}