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
//! Remote voltage control on the one vendored case that has it.
//!
//! MicroGrid-Type1 has a single machine at 10.5 kV holding the 110 kV side of
//! its own step-up transformer. FullGrid has one too, in the configuration with
//! the 50 GW shunt that does not solve, so this is the only usable case in the
//! corpus.
//!
//! **This fixture referees the numbers, and it took two fixes to make it able
//! to.** When remote control landed, gridoxide's solution of this network
//! differed from the published one by 4.4% on its 400 kV buses — more than the
//! reactive power remote control moves — so the published machine output was
//! not usable as a gate and this file checked only the structure.
//!
//! That 4.4% turned out to be a second, unrelated defect: five of its thirteen
//! lines join buses declaring different nominal voltages (380 kV in Belgium,
//! 400 kV in the Netherlands, the same wire), and per-unitizing a line on one
//! end's base leaves its two ends in different per-unit systems. With
//! `cgmes::harmonize_voltage_bases` giving each galvanic group one base, the
//! worst voltage error falls to 1.3%, and with the control also in the right
//! place, to **0.09%** — at which point the machine's own reactive output can
//! be compared against the published one directly, which is what
//! `the_machine_produces_what_the_published_solution_says` does.
//!
//! The analytic gates in `remote_voltage_test.rs` remain the ones that settle
//! the formulation; these settle it against a real document.
mod cgmes_common;
use std::path::{Path, PathBuf};
use gridoxide::PowerFlowOptions;
use gridoxide::cgmes::{cgmes_to_network, load_profiles};
use gridoxide::network::{build_ybus, power_injections, stamp_shunts};
use gridoxide::outerloop::RemoteOutcome;
use gridoxide::solver::SolveStatus;
use gridoxide::types::BusType;
const S_BASE_VA: f64 = 100e6;
fn microgrid() -> Option<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(
"tests/data/CGMES-Test-Configurations/v3.0/MicroGrid/MicroGrid-Type1/MicroGrid-Type1-Merged",
);
if !dir.exists() {
eprintln!(
"skipping: {} not found — run `git submodule update --init \
tests/data/CGMES-Test-Configurations`",
dir.display()
);
return None;
}
Some(dir)
}
struct Case {
net: gridoxide::cgmes::CgmesNetwork,
/// Kept so the published solution can be read back — this fixture is
/// checked against its own `SvPowerFlow`, not only against itself.
ds: gridoxide::cgmes::CimDataset,
}
fn load() -> Option<Case> {
let dir = microgrid()?;
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("read dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "xml"))
.collect();
paths.sort();
let refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
let ds = load_profiles(&refs).expect("decode");
let net = cgmes_to_network(&ds, S_BASE_VA).expect("convert");
Some(Case { net, ds })
}
fn solve(case: &Case, on: bool) -> gridoxide::PowerFlowReport {
gridoxide::run_power_flow_with_remote(
case.net.buses.clone(),
&case.net.lines,
&case.net.transformers,
&case.net.shunts,
gridoxide::TapData::none(),
gridoxide::RemoteControlData {
machines: &case.net.voltage_control.machines,
},
PowerFlowOptions {
control_remote_voltage: on,
enforce_q_limits: true,
max_iter: 60,
max_outer_iter: 60,
..Default::default()
},
)
}
/// The fixture has exactly one remotely-regulating machine, and it is the
/// arrangement the whole feature is about: a generator holding the far side of
/// its own step-up transformer.
#[test]
fn microgrid_has_one_remote_machine_behind_its_own_transformer() {
let Some(case) = load() else { return };
let remote: Vec<_> = case
.net
.voltage_control
.machines
.iter()
.filter(|m| m.at_bus != m.controls_bus)
.collect();
assert_eq!(
remote.len(),
1,
"MicroGrid-Type1's single remote controller"
);
let m = remote[0];
let adjacent = case.net.transformers.iter().any(|t| {
(t.from == m.at_bus && t.to == m.controls_bus)
|| (t.to == m.at_bus && t.from == m.controls_bus)
});
assert!(
adjacent,
"the two buses should be the ends of one transformer"
);
assert!(
case.net.buses[m.at_bus].u_rated < case.net.buses[m.controls_bus].u_rated,
"the machine should be on the low-voltage side"
);
}
/// **The gate this fixture can settle.** The control moves onto the machine, so
/// the reactive power is produced where the machine is.
///
/// Checked as a *reversal*, because that is what the defect was: with the loop
/// off, the bus being held carries a free reactive injection and the machine is
/// pinned at its schedule; with it on, exactly the opposite — which is the
/// structure the published solution has.
#[test]
fn the_control_moves_from_the_held_bus_onto_the_machine() {
let Some(case) = load() else { return };
let m = case
.net
.voltage_control
.machines
.iter()
.find(|m| m.at_bus != m.controls_bus)
.expect("one remote machine");
let mut y = build_ybus(
case.net.buses.len(),
&case.net.lines,
&case.net.transformers,
);
stamp_shunts(&mut y, &case.net.shunts);
let ybus = y.finish();
let off = solve(&case, false);
let on = solve(&case, true);
assert_eq!(off.stats.status, SolveStatus::Converged);
assert_eq!(on.stats.status, SolveStatus::Converged);
// Off: the pin is on the bus being held, and the machine is a fixed
// injection — the defect.
assert_eq!(off.buses[m.controls_bus].bus_type, BusType::PV);
assert_eq!(off.buses[m.at_bus].bus_type, BusType::PQ);
// On: reversed.
assert_eq!(on.buses[m.controls_bus].bus_type, BusType::PQ);
assert_eq!(on.buses[m.at_bus].bus_type, BusType::PV);
let outer = on.outer.as_ref().expect("the loop ran");
assert_eq!(outer.remote.len(), 1);
assert_eq!(
outer.remote[0].outcome,
RemoteOutcome::Held,
"{:?}",
outer.remote[0]
);
assert_eq!(
outer.retyped.len(),
2,
"one bus freed, one pinned: {:?}",
outer.retyped
);
// The target is still met — moving the control must not lose it.
assert!(
(on.buses[m.controls_bus].voltage_mag - m.target_pu).abs() < 2e-4,
"held bus reached {} against a target of {}",
on.buses[m.controls_bus].voltage_mag,
m.target_pu
);
// And the reactive power is now where the machine is: the held bus injects
// only its own load, as the published solution has it.
let (_, q_on) = power_injections(&on.buses, &ybus);
let (_, q_off) = power_injections(&off.buses, &ybus);
let held_spec = case.net.buses[m.controls_bus].q_spec;
assert!(
(q_on[m.controls_bus] - held_spec).abs() < 1e-6,
"with the control moved, the held bus should inject only its own load ({held_spec}), \
got {}",
q_on[m.controls_bus]
);
assert!(
(q_off[m.controls_bus] - held_spec).abs() > 0.1,
"without it, the held bus carries reactive power it has no source for ({:+.4} against a \
load of {held_spec:+.4}) — which is the defect this fixes",
q_off[m.controls_bus]
);
// Active power is untouched either way, and matches the published dispatch.
let (p_on, _) = power_injections(&on.buses, &ybus);
assert!(
(p_on[m.at_bus] - case.net.buses[m.at_bus].p_spec).abs() < 1e-6,
"the machine's active output is a schedule and must not move"
);
}
/// The 4.4% the fixture's own voltage assertion tolerates is **not** remote
/// control, and this pins that so the two are never confused.
///
/// Every 400 kV bus is off by the same amount in both modes. Only the machine's
/// own bus moves, which is exactly the bus whose voltage the control decides.
#[test]
fn moving_the_control_changes_only_the_machines_own_bus() {
let Some(case) = load() else { return };
let m = case
.net
.voltage_control
.machines
.iter()
.find(|m| m.at_bus != m.controls_bus)
.expect("one remote machine");
let off = solve(&case, false);
let on = solve(&case, true);
for i in 0..case.net.buses.len() {
if i == m.at_bus {
assert!(
(on.buses[i].voltage_mag - off.buses[i].voltage_mag).abs() > 1e-3,
"the machine's own bus is the one the control decides and should move"
);
continue;
}
assert!(
(on.buses[i].voltage_mag - off.buses[i].voltage_mag).abs() < 5e-3,
"bus {i} moved by {} — moving the control should not disturb the rest of the \
network this much",
(on.buses[i].voltage_mag - off.buses[i].voltage_mag).abs()
);
}
}
/// The machine produces what the fixture's own published solution says it does.
///
/// This is the gate the two fixes together made possible. `SvPowerFlow` at the
/// machine's own terminal is the reference answer for exactly the quantity
/// remote control decides, and it is only meaningful once the surrounding
/// network agrees: with the control in the wrong place, or with the voltage
/// bases incoherent, the comparison measures those defects instead.
///
/// Held to 5%, and recorded rather than tightened — this is one document's
/// solution of a network gridoxide still models slightly differently, not an
/// analytic answer. The analytic gates live in `remote_voltage_test.rs`.
#[test]
fn the_machine_produces_what_the_published_solution_says() {
let Some(case) = load() else { return };
let m = case
.net
.voltage_control
.machines
.iter()
.find(|m| m.at_bus != m.controls_bus)
.expect("one remote machine");
// The published per-terminal flow, in injection sign.
let published: f64 = cgmes_common::expected_injections(&case.ds, S_BASE_VA / 1e6)
.into_iter()
.find(|e| e.equipment_mrid == m.id)
.map(|e| e.q)
.expect("the machine's own terminal has a published flow");
let mut y = build_ybus(
case.net.buses.len(),
&case.net.lines,
&case.net.transformers,
);
stamp_shunts(&mut y, &case.net.shunts);
let ybus = y.finish();
let on = solve(&case, true);
let (_, q_on) = power_injections(&on.buses, &ybus);
let ours = q_on[m.at_bus];
assert!(
(ours - published).abs() < 0.05,
"the machine produced {ours:+.5} p.u. against a published {published:+.5} — the whole \\
point of putting the control on the machine is that this is the quantity it decides"
);
// And with the control in the wrong place it is not merely less accurate,
// it has the wrong sign: the machine sits pinned at its schedule while the
// bus it holds absorbs the difference.
let off = solve(&case, false);
let (_, q_off) = power_injections(&off.buses, &ybus);
assert!(
(q_off[m.at_bus] - published).abs() > (ours - published).abs(),
"moving the control onto the machine should bring its output closer to the published \\
one, got {:+.5} before and {ours:+.5} after, against {published:+.5}",
q_off[m.at_bus]
);
}