1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//! Per-machine reactive dispatch, against RealGrid's own published solution.
//!
//! The invariants are exact and asserted as such: shares sum to what was
//! attributed, no machine is handed more than it can produce, and nothing is
//! silently dropped. The *split rule* is a modelling choice and is measured
//! rather than pinned tightly — the published dispatch came from another tool,
//! whose apportionment need not be capability-proportional, so an exact match
//! is a bonus and a tuned tolerance would be self-deception.
//!
//! What makes the comparison meaningful is separating two errors that are easy
//! to conflate. gridoxide's solve differs from the published one by ~7.5% on
//! these buses, which has nothing to do with attribution; measured against the
//! *published* bus total, the split itself lands within ~7.4% on the shared
//! buses — the only buses where a split is a choice at all.
mod cgmes_common;
use std::path::{Path, PathBuf};
use gridoxide::PowerFlowOptions;
use gridoxide::cgmes::{cgmes_to_network, load_profiles};
use gridoxide::dispatch::{KeyBasis, allocate, reactive_keys};
use gridoxide::network::{build_ybus, power_injections, stamp_shunts};
const S_BASE_MVA: f64 = 100.0;
fn realgrid() -> Option<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/CGMES-Test-Configurations/v3.0/RealGrid/RealGrid-Merged");
if !dir.exists() {
eprintln!(
"skipping: {} not found — run `git submodule update --init \
tests/data/CGMES-Test-Configurations`",
dir.display()
);
return None;
}
Some(dir)
}
/// Import, solve with reactive limits on, and attribute.
struct Solved {
dispatch: Vec<gridoxide::dispatch::BusDispatch>,
machines: Vec<gridoxide::types::RegulatingMachine>,
q_calc: Vec<f64>,
nonregulating_q: Vec<f64>,
shared_buses: usize,
published: std::collections::HashMap<String, f64>,
}
fn solve() -> Option<Solved> {
let dir = realgrid()?;
let ds = load_profiles(&[
&dir.join("RealGrid_EQ.xml"),
&dir.join("RealGrid_SSH.xml"),
&dir.join("RealGrid_TP.xml"),
&dir.join("RealGrid_SV.xml"),
])
.expect("decode");
let net = cgmes_to_network(&ds, S_BASE_MVA * 1e6).expect("convert");
let vc = net.voltage_control;
let report = gridoxide::run_power_flow(
net.buses,
&net.lines,
&net.transformers,
&net.shunts,
gridoxide::TapData::none(),
PowerFlowOptions {
enforce_q_limits: true,
max_iter: 60,
max_outer_iter: 60,
..Default::default()
},
);
assert_eq!(
report.stats.status,
gridoxide::solver::SolveStatus::Converged
);
let mut y = build_ybus(report.buses.len(), &net.lines, &net.transformers);
stamp_shunts(&mut y, &net.shunts);
let ybus = y.finish();
let (_, q_calc) = power_injections(&report.buses, &ybus);
let published = cgmes_common::expected_injections(&ds, S_BASE_MVA)
.into_iter()
.map(|e| (e.equipment_mrid, e.q))
.collect();
Some(Solved {
dispatch: allocate(&vc.machines, &vc.nonregulating_q, &report.buses, &ybus),
machines: vc.machines,
q_calc,
nonregulating_q: vc.nonregulating_q,
shared_buses: vc.shared_buses,
published,
})
}
/// The arithmetic has to hold exactly, whatever the split rule is.
#[test]
fn the_split_is_exact_and_accounts_for_everything() {
let Some(s) = solve() else { return };
assert_eq!(
s.machines.len(),
496,
"every regulating machine is retained"
);
assert!(
s.shared_buses >= 62,
"RealGrid's shared-control buses: {}",
s.shared_buses
);
for bd in &s.dispatch {
let summed: f64 = bd.machines.iter().map(|m| m.q).sum();
assert!(
(summed - bd.attributed).abs() < 1e-12,
"bus {}: shares sum to {summed}, attributed says {}",
bd.bus,
bd.attributed
);
assert!(
(bd.attributed + bd.unattributed - bd.required).abs() < 1e-12,
"bus {}: attributed + unattributed must be what was required",
bd.bus
);
// What was required is the bus's own solved injection less the part
// that is not the machines' — recomputed here rather than trusted.
let required = s.q_calc[bd.bus] - s.nonregulating_q[bd.bus];
assert!(
(bd.required - required).abs() < 1e-9,
"bus {}: required {} vs independently computed {required}",
bd.bus,
bd.required
);
for m in &bd.machines {
assert!(
m.q <= m.q_max + 1e-9 && m.q >= m.q_min - 1e-9,
"bus {}: machine {} given {} outside its own [{}, {}]",
bd.bus,
m.id,
m.q,
m.q_min,
m.q_max
);
// One direction only. A share can land exactly on a limit without
// having been clamped there, and calling that "at limit" would be
// reading a coincidence as a constraint.
if m.at_limit {
assert!(
(m.q - m.q_max).abs() < 1e-9 || (m.q - m.q_min).abs() < 1e-9,
"bus {}: machine {} is flagged at its limit but sits at {}, \
inside [{}, {}]",
bd.bus,
m.id,
m.q,
m.q_min,
m.q_max
);
}
}
}
}
/// Unattributable reactive power is not noise — it is a measurement of a
/// documented simplification, and it should stay confined to the buses that
/// simplification affects.
///
/// `ReactiveLimits` bounds the bus's *net* injection with limits the importer
/// filled from the machines' *own* capability. Where nothing else sits at the
/// bus the two coincide and every bus is fully attributable. Where a reactive
/// load shares the bus, the machine must cover it too, so it saturates before
/// the net injection reaches the bound and the clamp fires late.
#[test]
fn unattributed_power_appears_only_where_a_load_shares_the_bus() {
let Some(s) = solve() else { return };
// A physical threshold, not a floating-point one: 1e-6 p.u. is 0.1 kVAr on
// a 100 MVA base. Below it the residue is accumulation noise across a
// 6252-bus network — one bus sits at 2e-8 — and calling that a shortfall
// would be reading arithmetic as physics.
const NEGLIGIBLE: f64 = 1e-6;
let mut unattributable = 0;
for bd in &s.dispatch {
if bd.unattributed.abs() <= NEGLIGIBLE {
continue;
}
unattributable += 1;
assert!(
s.nonregulating_q[bd.bus].abs() > 1e-9,
"bus {} cannot account for {} but has nothing else injecting there — \
that would be an allocation bug, not the bus-model simplification",
bd.bus,
bd.unattributed
);
assert!(
bd.machines.iter().all(|m| m.at_limit),
"bus {} left {} unattributed while a machine still had headroom",
bd.bus,
bd.unattributed
);
}
let total: f64 = s
.dispatch
.iter()
.map(|b| b.unattributed.abs())
.filter(|u| *u > NEGLIGIBLE)
.sum();
// Recorded, not targeted. If either moves, the bus model changed and the
// reason is worth knowing before this number is updated.
assert_eq!(
unattributable, 6,
"buses whose machines cannot account for their own injection"
);
assert!(
(total - 0.0436).abs() < 5e-4,
"total unattributed {total:.4} pu against the recorded 0.0436"
);
}
/// Against the fixture's own published per-machine solution.
///
/// Two errors, deliberately separated. The bus totals differ because
/// gridoxide's solve differs from the published one — pre-existing, and nothing
/// to do with attribution. Measured against the *published* total instead, what
/// is left is the split rule alone.
#[test]
fn the_split_reproduces_the_published_attribution() {
let Some(s) = solve() else { return };
let (mut solve_err, mut solve_abs) = (0.0f64, 0.0f64);
let (mut split_err, mut split_abs) = (0.0f64, 0.0f64);
let mut compared = 0usize;
for bd in &s.dispatch {
let group: Vec<&gridoxide::types::RegulatingMachine> = s
.machines
.iter()
.filter(|m| m.controls_bus == bd.bus)
.collect();
if !bd.machines.iter().all(|m| s.published.contains_key(&m.id)) {
continue;
}
let published_total: f64 = bd.machines.iter().map(|m| s.published[&m.id]).sum();
solve_err += (bd.attributed - published_total).abs();
solve_abs += published_total.abs();
if bd.machines.len() < 2 {
continue; // a lone machine's "split" is not a choice
}
compared += bd.machines.len();
let (keys, _) = reactive_keys(&group);
for (k, m) in bd.machines.iter().enumerate() {
split_err += (published_total * keys[k] - s.published[&m.id]).abs();
split_abs += s.published[&m.id].abs();
}
}
assert!(
compared > 100,
"expected RealGrid's shared buses to be compared, got {compared}"
);
let solve_rel = 100.0 * solve_err / solve_abs;
let split_rel = 100.0 * split_err / split_abs;
eprintln!("bus totals (the solve): {solve_rel:.1}% split rule: {split_rel:.1}%");
// Recorded. The split is the thing under test; the solve error is the
// backdrop it has to be read against, and asserting it here keeps the two
// from being confused if either moves.
assert!(
solve_rel < 12.0,
"solve-vs-published on these buses drifted to {solve_rel:.1}%"
);
assert!(
split_rel < 15.0,
"capability-proportional split drifted to {split_rel:.1}% against the published \
attribution — worth understanding before widening this"
);
}
/// Nearly all of RealGrid's shared buses state a usable reactive range, so the
/// split has a real basis rather than falling back to equal shares.
///
/// One does not, and it is worth naming rather than rounding away: bus 6232's
/// two machines each declare a ±0.001 p.u. range — 0.2 MVAr, an order of
/// magnitude below anything a transmission machine plausibly has — so the
/// capability basis is rejected and the split is uniform. Since both declare the
/// *same* implausible range, uniform and capability agree there anyway; the
/// guard costs nothing and stops one mis-stated nameplate from taking a bus.
#[test]
fn realgrid_splits_on_capability_apart_from_one_implausible_pair() {
let Some(s) = solve() else { return };
let mut by_basis = std::collections::BTreeMap::new();
for bd in &s.dispatch {
if bd.machines.len() < 2 {
continue;
}
*by_basis.entry(bd.basis).or_insert(0usize) += 1;
}
let shared: usize = by_basis.values().sum();
assert!(
shared >= 62,
"expected at least RealGrid's 62 shared buses, got {shared}"
);
assert_eq!(by_basis.get(&KeyBasis::Capability), Some(&61));
assert_eq!(by_basis.get(&KeyBasis::Uniform), Some(&1));
assert_eq!(
by_basis.get(&KeyBasis::Explicit),
None,
"CGMES states no per-machine keys"
);
let fallback = s
.dispatch
.iter()
.find(|b| b.machines.len() > 1 && b.basis == KeyBasis::Uniform)
.expect("the one uniform bus");
for m in &fallback.machines {
assert!(
m.q_max - m.q_min < 0.01,
"bus {} was expected to fall back because its ranges are implausibly narrow, \
but machine {} spans {}",
fallback.bus,
m.id,
m.q_max - m.q_min
);
}
}