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 derived cases 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_case_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.calc_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}