Skip to main content

powerio_cli/corpus/
fingerprint.rs

1//! Electrical fingerprints, which is how the harness finds siblings.
2//!
3//! Two files hold the same case when their networks agree electrically, and
4//! nothing else counts: not the filename, not the directory, not the element
5//! names, not the format. A utility archive states one case as `feeder_a.raw`
6//! and `FeederA_2019_final_v3.m`; a fingerprint built from bus count, base
7//! MVA, the bus graph's degree sequence and the quantized impedance multiset
8//! groups them anyway, and groups nothing else with them.
9//!
10//! Every component is invariant under the things a conversion is allowed to
11//! change (renumbering, renaming, reordering, merging devices at one bus) and
12//! sensitive to the things it is not (topology, impedance, demand).
13//!
14//! Nothing here reads `in_service`, and that is the point. A fingerprint built
15//! from the operating point splits exactly the siblings whose disagreement
16//! about the operating point is the finding: two exports of one case that
17//! differ only in which machines are open would land in separate buckets and
18//! never be compared. The key describes what a file holds; what a reader made
19//! of it belongs in the findings.
20//!
21//! Generator capability stays out for the same reason at one remove — two
22//! honest exports of one case routinely state different limits, so a key that
23//! includes them splits real siblings.
24
25use std::collections::BTreeMap;
26
27use powerio_matrix::BalancedNetwork;
28
29/// Quantum for per-unit impedance magnitudes. Coarser than any writer's
30/// printed precision, so a value that survived a text format lands on the same
31/// step as its source.
32const IMPEDANCE_QUANTUM: f64 = 1e-5;
33
34/// Quantum for MW/MVAr totals.
35const POWER_QUANTUM: f64 = 1e-2;
36
37/// Quantum for base MVA.
38const BASE_MVA_QUANTUM: f64 = 1e-3;
39
40fn quantize(x: f64, q: f64) -> i64 {
41    if x.is_finite() {
42        (x / q).round() as i64
43    } else {
44        i64::MIN
45    }
46}
47
48/// What makes two files the same case.
49///
50/// The whole struct is the bucketing key. It deliberately leaves out the
51/// load, generator and shunt counts: formats merge and split devices at a bus
52/// (PSS/E states three loads where MATPOWER states one bus demand), so those
53/// counts differ between honest siblings while the demand they sum to does
54/// not.
55#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
56pub struct Fingerprint {
57    pub buses: usize,
58    pub base_mva: i64,
59    /// Sorted bus degrees over in-service branches. Invariant under bus
60    /// renumbering, sensitive to a lost or added branch.
61    pub degrees: Vec<usize>,
62    /// Sorted quantized `|z|` of every branch.
63    pub impedances: Vec<i64>,
64    /// Number of DC lines. A case with an HVDC link is a different case from
65    /// the AC network underneath it, however alike the two look.
66    pub hvdc: usize,
67    pub load_p: i64,
68    pub load_q: i64,
69}
70
71impl Fingerprint {
72    #[must_use]
73    pub fn of(net: &BalancedNetwork) -> Self {
74        let mut degree: BTreeMap<usize, usize> = net.buses().iter().map(|b| (b.id.0, 0)).collect();
75        let mut impedances = Vec::new();
76        for br in net.branches() {
77            *degree.entry(br.from.0).or_default() += 1;
78            *degree.entry(br.to.0).or_default() += 1;
79            impedances.push(quantize(br.r.hypot(br.x), IMPEDANCE_QUANTUM));
80        }
81        impedances.sort_unstable();
82        let mut degrees: Vec<usize> = degree.into_values().collect();
83        degrees.sort_unstable();
84        Self {
85            buses: net.buses().len(),
86            base_mva: quantize(net.base_mva(), BASE_MVA_QUANTUM),
87            degrees,
88            impedances,
89            hvdc: net.hvdc().len(),
90            load_p: quantize(net.loads().iter().map(|l| l.p).sum(), POWER_QUANTUM),
91            load_q: quantize(net.loads().iter().map(|l| l.q).sum(), POWER_QUANTUM),
92        }
93    }
94
95    /// A multiconductor network's fingerprint.
96    ///
97    /// Coarser than the balanced one: a distribution line carries an impedance
98    /// matrix rather than a scalar, so there is no `|z|` multiset to compare,
99    /// and the degree sequence runs over lines, switches and transformers
100    /// together because formats disagree about which of the three a given
101    /// device is.
102    #[must_use]
103    pub fn of_distribution(net: &powerio_dist::MulticonductorNetwork) -> Self {
104        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
105        for (a, b) in net
106            .lines()
107            .iter()
108            .map(|l| (l.bus_from.as_str(), l.bus_to.as_str()))
109            .chain(
110                net.switches()
111                    .iter()
112                    .map(|s| (s.bus_from.as_str(), s.bus_to.as_str())),
113            )
114        {
115            *counts.entry(a.to_string()).or_default() += 1;
116            *counts.entry(b.to_string()).or_default() += 1;
117        }
118        let mut degrees: Vec<usize> = counts.into_values().collect();
119        degrees.sort_unstable();
120        Self {
121            buses: net.buses().len(),
122            // A multiconductor network states no system base; the domain in
123            // the primary key is what keeps these apart from balanced cases.
124            base_mva: 0,
125            degrees,
126            impedances: Vec::new(),
127            hvdc: 0,
128            load_p: quantize(
129                net.loads().iter().flat_map(|l| l.p_nom.iter()).sum(),
130                POWER_QUANTUM,
131            ),
132            load_q: quantize(
133                net.loads().iter().flat_map(|l| l.q_nom.iter()).sum(),
134                POWER_QUANTUM,
135            ),
136        }
137    }
138
139    /// The coarse bucketing key: the part of the fingerprint that is exact
140    /// even across a lossy text format. Sorting by it gives the bucket
141    /// ordinals, so a bucket id derives from electrical content and from
142    /// nothing in the source.
143    #[must_use]
144    pub fn primary(&self) -> PrimaryKey {
145        PrimaryKey {
146            buses: self.buses,
147            base_mva: self.base_mva,
148            hvdc: self.hvdc,
149            degrees: self.degrees.clone(),
150        }
151    }
152
153    /// Whether two fingerprints sharing a [`PrimaryKey`] really are the same
154    /// case.
155    ///
156    /// Compared with a tolerance rather than by quantized equality: two
157    /// siblings whose impedance lands either side of a quantum boundary are
158    /// still the same case, and exact equality on a rounded value would split
159    /// them. The quantized fields exist to make the fingerprint serializable
160    /// and sortable; agreement is decided here.
161    #[must_use]
162    pub fn agrees_with(&self, other: &Self) -> bool {
163        let close = |a: i64, b: i64, tolerance: i64| (a - b).abs() <= tolerance;
164        self.impedances.len() == other.impedances.len()
165            && std::iter::zip(&self.impedances, &other.impedances)
166                .all(|(a, b)| close(*a, *b, slack(*a)))
167            && close(self.load_p, other.load_p, slack(self.load_p))
168            && close(self.load_q, other.load_q, slack(self.load_q))
169    }
170}
171
172/// One part in `1e4` of the value, and never less than one quantum: enough to
173/// absorb a fixed-width column's truncation, too little to merge two cases.
174///
175/// One rule for impedances and for power. They were two identically-bodied
176/// functions, which is how a later change to one tolerance silently leaves the
177/// other behind.
178fn slack(quantized: i64) -> i64 {
179    1.max(quantized.abs() / 10_000)
180}
181
182/// Whether two networks state the same electrical data, ignoring which
183/// elements are in service.
184///
185/// Bucketing is deliberately loose — topology and demand — so that a case and
186/// its variants land together and get compared. That looseness is wrong for
187/// the sibling leg, which asks whether two *readers* disagree: a base case and
188/// its pglib derivative differ in generator limits for honest reasons, and
189/// reporting that as a reader disagreement buries the real findings.
190///
191/// Status is excluded on purpose. Two exports of one case that differ only in
192/// which machines are open are the same data, and the disagreement about
193/// status is exactly the finding the sibling leg exists to raise.
194#[must_use]
195pub fn same_data(a: &BalancedNetwork, b: &BalancedNetwork) -> bool {
196    let branches = |net: &BalancedNetwork| {
197        let mut v: Vec<[i64; 5]> = net
198            .branches()
199            .iter()
200            .map(|br| {
201                [
202                    quantize(br.r, IMPEDANCE_QUANTUM),
203                    quantize(br.x, IMPEDANCE_QUANTUM),
204                    quantize(br.b, IMPEDANCE_QUANTUM),
205                    quantize(br.effective_tap(), IMPEDANCE_QUANTUM),
206                    quantize(br.shift, IMPEDANCE_QUANTUM),
207                ]
208            })
209            .collect();
210        v.sort_unstable();
211        v
212    };
213    let generators = |net: &BalancedNetwork| {
214        let mut v: Vec<[i64; 4]> = net
215            .generators()
216            .iter()
217            .map(|g| {
218                [
219                    quantize(g.pmax, POWER_QUANTUM),
220                    quantize(g.pmin, POWER_QUANTUM),
221                    quantize(g.qmax, POWER_QUANTUM),
222                    quantize(g.qmin, POWER_QUANTUM),
223                ]
224            })
225            .collect();
226        v.sort_unstable();
227        v
228    };
229    let loads = |net: &BalancedNetwork| {
230        let mut v: Vec<[i64; 2]> = net
231            .loads()
232            .iter()
233            .map(|l| [quantize(l.p, POWER_QUANTUM), quantize(l.q, POWER_QUANTUM)])
234            .collect();
235        v.sort_unstable();
236        v
237    };
238    branches(a) == branches(b) && generators(a) == generators(b) && loads(a) == loads(b)
239}
240
241/// The exact half of a fingerprint. Buckets group by this, then split by
242/// [`Fingerprint::agrees_with`].
243#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
244pub struct PrimaryKey {
245    pub buses: usize,
246    pub base_mva: i64,
247    pub hvdc: usize,
248    pub degrees: Vec<usize>,
249}