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
//! The OPF data layer, against the pglib-opf fixtures.
//!
//! `src/opf/model.rs`'s own tests cover parsing and unit conversion on
//! hand-written input. What is checked here is that the *real* documents —
//! produced from pglib's `.m` files by `python/gridoxide/matpower.py` — carry
//! what an OPF actually needs, and that the network and OPF documents agree
//! with each other.
//!
//! The last point matters because the two are built differently on purpose:
//! branch limits key into the network document by component id, while
//! generators deliberately do not, since the converter aggregates generators
//! per bus and an OPF needs them individually. A test that did not check the
//! keyed half would not notice the ids drifting apart.
use std::collections::HashSet;
use gridoxide::opf::model::{CostCurve, OpfData};
mod pglib;
/// Every case `tests/data/pglib_opf.py` converts, with the counts pglib's own
/// documentation states for it. Hard-coding these is the point: they come from
/// the upstream description, not from what our converter happened to produce,
/// so a conversion that silently dropped a component fails here.
const CASES: &[(&str, usize, usize)] = &[
// (case, buses, branches)
("pglib_opf_case3_lmbd", 3, 3),
("pglib_opf_case5_pjm", 5, 6),
("pglib_opf_case14_ieee", 14, 20),
("pglib_opf_case30_ieee", 30, 41),
("pglib_opf_case118_ieee", 118, 186),
];
fn load(name: &str) -> OpfData {
let text = std::fs::read_to_string(pglib::path(name, ".opf.json"))
.unwrap_or_else(|e| panic!("reading {name}.opf.json: {e}"));
OpfData::from_json(&text).unwrap_or_else(|e| panic!("parsing {name}.opf.json: {e}"))
}
fn network(name: &str) -> serde_json::Value {
let text = std::fs::read_to_string(pglib::path(name, ".json"))
.unwrap_or_else(|e| panic!("reading {name}.json: {e}"));
serde_json::from_str(&text).unwrap()
}
#[test]
fn every_case_carries_a_complete_opf_document() {
for &(name, _buses, branches) in CASES {
let data = load(name);
assert_eq!(
data.base_mva, 100.0,
"{name}: pglib cases are all on a 100 MVA base"
);
assert!(!data.generator.is_empty(), "{name}: no generators");
assert_eq!(
data.branch_limit.len(),
branches,
"{name}: branch limits should cover every branch"
);
for g in &data.generator {
assert!(
g.cost.is_some(),
"{name}: generator {} has no cost curve",
g.index
);
assert!(
g.p_max >= g.p_min,
"{name}: generator {} has crossed active limits",
g.index
);
}
}
}
/// **The reason these fixtures exist.** Every pglib branch is rated, so every
/// congestion constraint can actually bind — unlike the power-flow cases in
/// `benchmark-grids/`, where `case14`, `case118` and `case300` have `rateA = 0`
/// on every single branch and no flow limit is ever active.
///
/// A fixture set that cannot make a constraint bind cannot test an optimizer,
/// so this is a property of the data worth asserting rather than assuming.
#[test]
fn every_branch_can_bind() {
for &(name, _, branches) in CASES {
let data = load(name);
assert_eq!(
data.binding_capable_branches(),
branches,
"{name}: some branches are unlimited, so congestion would go untested"
);
}
}
/// Costs must be convex, or the "optimal is provable" argument the whole
/// DC-first plan rests on does not apply.
#[test]
fn every_cost_curve_is_convex() {
for &(name, _, _) in CASES {
let data = load(name);
assert!(data.costs_are_convex(), "{name}: a non-convex cost curve");
}
}
/// Branch limits key into the network document by component id, so those ids
/// must actually exist there — and cover the lines and transformers exactly.
#[test]
fn branch_limits_key_into_the_network_document() {
for &(name, _, _) in CASES {
let data = load(name);
let net = network(name);
let mut network_ids = HashSet::new();
for kind in ["line", "transformer"] {
if let Some(items) = net["data"][kind].as_array() {
for item in items {
network_ids.insert(item["id"].as_u64().unwrap());
}
}
}
assert_eq!(
network_ids.len(),
data.branch_limit.len(),
"{name}: branch count differs between the two documents"
);
for limit in &data.branch_limit {
assert!(
network_ids.contains(&limit.id),
"{name}: branch limit {} names no component in the network document",
limit.id
);
}
}
}
/// Generators are *not* keyed into the network document, and this pins why —
/// two independent reasons, each demonstrated on a case that exhibits it.
///
/// **The reference bus's generator has no `sym_gen` at all.** The converter
/// turns it into a `source`, because for a power flow the slack's output is
/// the solve's own result rather than an input. So on every case the OPF's
/// generator count exceeds the network's `sym_gen` count by exactly the number
/// of reference buses. Keying generators by `sym_gen` id would silently drop
/// the slack generator — which is usually the cheapest unit and the one an OPF
/// most wants to dispatch.
///
/// **And generators are aggregated per bus.** `case5_pjm` puts five generators
/// on four buses, so one bus's two units become a single `sym_gen` with their
/// powers summed. Two different cost curves cannot be summed into one, so an
/// id-keyed design loses that unit's curve outright.
#[test]
fn the_network_document_cannot_represent_the_generator_set() {
for &(name, _, _) in CASES {
let data = load(name);
let net = network(name);
let sym_gens = net["data"]["sym_gen"].as_array().map_or(0, |a| a.len());
let sources = net["data"]["source"].as_array().map_or(0, |a| a.len());
// `>=` rather than `==`, and the gap is itself informative: the
// reference bus accounts for `sources`, and anything beyond that is a
// bus with more than one generator collapsed into one `sym_gen`.
assert!(
data.generator.len() >= sym_gens + sources,
"{name}: {} OPF generators but {sym_gens} sym_gen + {sources} source — the \
network document cannot be carrying fewer units than it aggregates",
data.generator.len()
);
assert!(
sym_gens < data.generator.len(),
"{name}: the reference bus's generator becomes a `source`, so `sym_gen` must \
always be short of the full generator set"
);
}
// The aggregation half, on the case that shows it.
let data = load("pglib_opf_case5_pjm");
let distinct_nodes: HashSet<u64> = data.generator.iter().map(|g| g.node).collect();
assert!(
distinct_nodes.len() < data.generator.len(),
"case5_pjm should have two generators sharing a bus, got {} across {} nodes",
data.generator.len(),
distinct_nodes.len()
);
}
/// Per-unit conversion, checked against a real document rather than a literal.
#[test]
fn limits_convert_to_per_unit_on_the_documents_own_base() {
let data = load("pglib_opf_case5_pjm");
let base = data.base_mva;
for g in &data.generator {
let (p_min, p_max) = g.p_limits_pu(base);
assert!((p_min - g.p_min / base).abs() < 1e-12);
assert!((p_max - g.p_max / base).abs() < 1e-12);
assert!(
p_max <= 100.0,
"a per-unit limit of {p_max} suggests an unconverted MW value"
);
}
for limit in &data.branch_limit {
let rate = limit.rate_pu(base).expect("every pglib branch is rated");
assert!((rate - limit.rate_a / base).abs() < 1e-12);
}
}
/// Marginal cost must be positive across each generator's own operating range
/// — a unit that pays you to produce would make the dispatch unbounded below
/// in the direction of that generator, and it is a cheap way to catch a
/// coefficient ordering that got reversed on the way in.
#[test]
fn marginal_cost_is_positive_across_the_operating_range() {
for &(name, _, _) in CASES {
let data = load(name);
for g in &data.generator {
let Some(cost) = &g.cost else { continue };
for fraction in [0.0, 0.5, 1.0] {
let p = g.p_min + fraction * (g.p_max - g.p_min);
let marginal = cost.marginal(p);
assert!(
marginal >= 0.0,
"{name}: generator {} has marginal cost {marginal} at {p} MW",
g.index
);
}
}
}
}
/// A spot check against the source file, so the whole chain is anchored to
/// something a human can read.
///
/// `pglib_opf_case3_lmbd`'s first generator carries
/// `2 0 0 3 0.11 5.0 0.0` — model 2 (polynomial), 3 coefficients, highest
/// degree first. Ascending, that is `[0.0, 5.0, 0.11]`.
#[test]
fn a_known_cost_row_survives_the_conversion_in_ascending_order() {
let data = load("pglib_opf_case3_lmbd");
let first = data
.generator
.iter()
.find(|g| g.index == 0)
.expect("generator 0");
match first.cost.as_ref().expect("a cost curve") {
CostCurve::Polynomial { coefficients } => {
assert_eq!(coefficients.len(), 3, "{coefficients:?}");
assert!(
coefficients[0].abs() < 1e-12,
"constant term: {coefficients:?}"
);
assert!(
coefficients[2] < coefficients[1],
"the quadratic term should be the small one — ordering looks reversed: \
{coefficients:?}"
);
}
other => panic!("expected a polynomial, got {other:?}"),
}
}