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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
//! Q-V curves and reactive margins.
//!
//! The headline gate is analytic, as it is for the P-V side: on two buses the
//! whole curve is closed-form, so the solver is checked against arithmetic
//! rather than against itself.
//!
//! **What is deliberately not asserted is that Q-V and P-V agree on the weakest
//! bus.** That seemed like the obvious cross-check and it is false — measured on
//! `case14`, continuation ranks [4, 3, 8, 9, 6] and Q-V ranks [7, 13, 5, 9, 11],
//! sharing one bus of five. Re-running the Q-V sweep at the nose loading, on the
//! theory that the two were describing different operating points, makes the
//! agreement *worse* rather than better. The two measure related but distinct
//! things — a system-wide collapse mode along one loading direction, against one
//! bus's local reactive headroom — which is why utilities run both rather than
//! either. A test asserting they agree would have been asserting something
//! untrue about the physics.
use gridoxide::PowerFlowOptions;
use gridoxide::network::{build_ybus, power_injections};
use gridoxide::qv::{QvError, QvOptions, QvStatus, qv_curve};
use gridoxide::types::{Bus, BusType, Line};
mod pglib;
fn bus(idx: usize, bus_type: BusType, p: f64, q: f64) -> Bus {
Bus {
idx,
bus_type,
voltage_mag: 1.0,
voltage_ang: 0.0,
p_spec: p,
q_spec: q,
q_min: f64::NEG_INFINITY,
q_max: f64::INFINITY,
u_rated: 1.0,
zip_terms: Vec::new(),
}
}
/// Slack at `1∠0`, one lossless line, one constant-power load.
fn two_bus(x: f64, p: f64, q: f64) -> (Vec<Bus>, Vec<Line>) {
(
vec![bus(0, BusType::Slack, 0.0, 0.0), bus(1, BusType::PQ, p, q)],
vec![Line {
from: 0,
to: 1,
r: 0.0,
x,
b_shunt: 0.0,
g_shunt: 0.0,
}],
)
}
fn options(step: f64) -> QvOptions {
QvOptions {
pf: PowerFlowOptions {
max_iter: 60,
..Default::default()
},
v_max: 1.20,
v_min: 0.20,
step,
refine: true,
}
}
/// The closed-form Q-V nose of [`two_bus`], with `E = 1` and no resistance.
///
/// Eliminating the angle from the two injection equations gives
/// `u² − (2a + E²)u + (a² + b²) = 0` with `a = xQ`, `b = xP`, `u = |V|²`. Read
/// as a quadratic in `Q` instead, the operating branch is
/// `Q = [u − √(E²u − x²P²)] / x`, and `dQ/du = 0` gives the minimum:
///
/// ```text
/// u_nose = (E⁴ + 4x²P²) / (4E²) Q_nose = (4x²P² − E⁴) / (4E²x)
/// ```
fn analytic_nose(x: f64, p: f64) -> (f64, f64) {
let u = (1.0 + 4.0 * x * x * p * p) / 4.0;
(u.sqrt(), (4.0 * x * x * p * p - 1.0) / (4.0 * x))
}
/// **The gate.** The nose against arithmetic, over a range of line reactances
/// and loadings.
#[test]
fn the_two_bus_nose_matches_the_closed_form() {
for (x, p) in [(0.10, -1.0), (0.08, -0.8), (0.25, -0.4), (0.05, -1.5)] {
let (buses, lines) = two_bus(x, p, 0.0);
let curve = qv_curve(&buses, &lines, &[], &[], 1, options(0.005));
assert_eq!(curve.status, QvStatus::NoseFound, "x={x} P={p}");
let nose = curve.nose.as_ref().expect("a nose was found");
let (v_expected, q_expected) = analytic_nose(x, p);
assert!(
(nose.q - q_expected).abs() < 1e-5,
"x={x} P={p}: nose Q {} against analytic {q_expected}",
nose.q
);
// Held looser than Q, and the asymmetry is the physics rather than
// slack: at a minimum dQ/d|V| vanishes, so a given error in Q
// corresponds to a much larger one in the voltage it occurs at.
assert!(
(nose.voltage - v_expected).abs() < 1e-3,
"x={x} P={p}: nose |V| {} against analytic {v_expected}",
nose.voltage
);
}
}
/// Interpolating the minimum is worth doing: it recovers a nose the sampling
/// grid cannot represent.
///
/// Without it the answer is pinned to the nearest sample, so a 0.02 grid can be
/// out by up to 0.01 in voltage by construction. The parabola through the three
/// bracketing samples is exact where the curve is locally quadratic, which it is
/// at a smooth minimum.
#[test]
fn refining_the_minimum_beats_the_nearest_sample() {
let (x, p) = (0.10, -1.0);
let (buses, lines) = two_bus(x, p, 0.0);
let (v_expected, _) = analytic_nose(x, p);
let coarse = QvOptions {
step: 0.02,
..options(0.02)
};
let raw = qv_curve(
&buses,
&lines,
&[],
&[],
1,
QvOptions {
refine: false,
..coarse.clone()
},
);
let refined = qv_curve(&buses, &lines, &[], &[], 1, coarse);
let raw_err = (raw.nose.as_ref().unwrap().voltage - v_expected).abs();
let refined_err = (refined.nose.as_ref().unwrap().voltage - v_expected).abs();
assert!(!raw.nose.as_ref().unwrap().refined);
assert!(refined.nose.as_ref().unwrap().refined);
assert!(
refined_err < raw_err / 10.0,
"refining should be an order of magnitude better than the nearest sample, \
got {refined_err:.2e} against {raw_err:.2e}"
);
}
/// Every point on the curve is a real power flow at its own setpoint.
///
/// Rebuilt independently — the bus retyped, the network re-solved through the
/// ordinary entry point, the mismatch taken with `power_injections` — so an
/// error in what the sweep reports as "the reactive power needed" cannot hide
/// behind a plausible-looking curve.
#[test]
fn every_point_is_a_power_flow_at_its_own_setpoint() {
let (buses, lines) = two_bus(0.08, -0.8, -0.2);
let ybus = build_ybus(buses.len(), &lines, &[]).finish();
let curve = qv_curve(&buses, &lines, &[], &[], 1, options(0.02));
assert!(curve.points.len() > 20);
for p in &curve.points {
let mut state = buses.clone();
state[1].bus_type = BusType::PV;
state[1].voltage_mag = p.voltage;
state[1].q_min = f64::NEG_INFINITY;
state[1].q_max = f64::INFINITY;
let report = gridoxide::run_power_flow(
state,
&lines,
&[],
&[],
gridoxide::TapData::none(),
PowerFlowOptions {
max_iter: 60,
..Default::default()
},
);
assert_eq!(
report.stats.status,
gridoxide::solver::SolveStatus::Converged
);
// The condenser supplies the bus's net injection less the bus's own.
let (_, q_calc) = power_injections(&report.buses, &ybus);
let needed = q_calc[1] - buses[1].q_spec;
assert!(
(needed - p.q).abs() < 1e-6,
"at |V| {}: the curve says {} but an independent solve says {needed}",
p.voltage,
p.q
);
assert!(
(report.buses[1].voltage_mag - p.voltage).abs() < 1e-9,
"the setpoint must be held exactly"
);
}
}
/// The curve passes through zero at the voltage the bus already sits at.
///
/// A condenser that holds a bus where the network was going to put it anyway
/// has nothing to do. This is the one point on the curve whose answer is known
/// without solving anything, which makes it a free check that the sweep is
/// measuring what it claims to.
#[test]
fn the_curve_crosses_zero_at_the_buses_own_voltage() {
let (buses, lines) = two_bus(0.08, -0.8, -0.2);
let curve = qv_curve(&buses, &lines, &[], &[], 1, options(0.005));
assert!(curve.base_voltage.is_finite());
// The two samples straddling zero must straddle the base voltage too.
let mut crossing = None;
for w in curve.points.windows(2) {
if w[0].q.signum() != w[1].q.signum() {
crossing = Some((
w[0].voltage.max(w[1].voltage),
w[0].voltage.min(w[1].voltage),
));
break;
}
}
let (hi, lo) = crossing.expect("the curve should cross zero");
assert!(
curve.base_voltage <= hi + 1e-9 && curve.base_voltage >= lo - 1e-9,
"zero crossing is between {lo} and {hi}, but the bus solves at {}",
curve.base_voltage
);
}
/// Above its own voltage the bus has to be supported; below it, held down.
#[test]
fn the_sign_of_q_says_which_way_the_bus_is_being_pushed() {
let (buses, lines) = two_bus(0.08, -0.8, -0.2);
let curve = qv_curve(&buses, &lines, &[], &[], 1, options(0.01));
let base = curve.base_voltage;
for p in &curve.points {
if p.voltage > base + 0.02 {
assert!(
p.q > 0.0,
"holding {} above {base} should need support, got {}",
p.voltage,
p.q
);
}
if p.voltage < base - 0.02 && p.voltage > 0.7 {
assert!(
p.q < 0.0,
"holding {} below {base} should need absorption, got {}",
p.voltage,
p.q
);
}
}
}
/// A weaker connection has less margin. The direction is the physics; the
/// magnitude is whatever the network says.
#[test]
fn a_weaker_line_leaves_a_smaller_margin() {
let mut previous = f64::INFINITY;
for x in [0.05, 0.10, 0.20, 0.40] {
let (buses, lines) = two_bus(x, -0.5, 0.0);
let curve = qv_curve(&buses, &lines, &[], &[], 1, options(0.005));
let margin = curve.margin_pu().expect("a nose was found");
assert!(
margin < previous,
"reactance {x} gave margin {margin}, not less than the previous {previous}"
);
previous = margin;
}
}
/// A sweep that stops before the curve turns reports a bound, not a margin.
///
/// The distinction matters more here than it looks: the margin is the headline
/// number, and calling the last sample of a still-falling curve "the margin"
/// would overstate a bus's weakness in exactly the direction that misleads.
#[test]
fn a_sweep_that_stops_too_early_says_so() {
let (buses, lines) = two_bus(0.10, -1.0, 0.0);
// The nose is near 0.51; stop well above it.
let curve = qv_curve(
&buses,
&lines,
&[],
&[],
1,
QvOptions {
v_min: 0.80,
..options(0.01)
},
);
assert_eq!(curve.status, QvStatus::NoseNotReached);
let bound = curve
.nose
.as_ref()
.expect("a lowest sample is still reported");
assert!(!bound.refined, "a bound is not an interpolated minimum");
let full = qv_curve(&buses, &lines, &[], &[], 1, options(0.01));
assert_eq!(full.status, QvStatus::NoseFound);
assert!(
full.margin_pu().unwrap() > bound.margin_pu,
"the truncated sweep should under-report the margin"
);
}
/// Requests that cannot mean anything are refused rather than approximated.
#[test]
fn impossible_requests_are_refused() {
let (buses, lines) = two_bus(0.10, -1.0, 0.0);
// A slack bus already fixes its magnitude and already has a free reactive
// injection: there is no condenser to add.
let slack = qv_curve(&buses, &lines, &[], &[], 0, options(0.01));
assert_eq!(slack.status, QvStatus::Rejected(QvError::SlackBus(0)));
let missing = qv_curve(&buses, &lines, &[], &[], 9, options(0.01));
assert_eq!(
missing.status,
QvStatus::Rejected(QvError::BusOutOfRange { bus: 9, n: 2 })
);
let backwards = qv_curve(
&buses,
&lines,
&[],
&[],
1,
QvOptions {
v_min: 1.2,
v_max: 0.8,
..options(0.01)
},
);
assert_eq!(backwards.status, QvStatus::Rejected(QvError::EmptyRange));
}
/// On a network with real structure, every bus that reports a nose reports a
/// coherent one, and the ranking is stable under a finer sweep.
#[test]
fn case14_margins_are_coherent_and_grid_independent() {
use gridoxide::pgm::{PgmInput, pgm_to_buses_and_branches};
let path = pglib::path("pglib_opf_case14_ieee", ".json");
let raw = std::fs::read_to_string(&path).expect("committed pglib fixture");
let input: PgmInput = serde_json::from_str(&raw).expect("parse");
let (buses, lines, transformers) = pgm_to_buses_and_branches(input, 100e6, 50.0);
let rank = |step: f64| -> Vec<usize> {
let mut rows: Vec<(f64, usize)> = Vec::new();
for b in 0..buses.len() {
if buses[b].bus_type == BusType::Slack {
continue;
}
let c = qv_curve(
&buses,
&lines,
&transformers,
&[],
b,
QvOptions {
v_min: 0.20,
step,
..options(step)
},
);
if c.status == QvStatus::NoseFound {
let n = c.nose.as_ref().unwrap();
assert!(
n.margin_pu > 0.0,
"bus {b}: a margin should be positive, got {}",
n.margin_pu
);
assert!(
n.voltage > 0.2 && n.voltage < 1.2,
"bus {b}: nose at {} is outside the swept range",
n.voltage
);
rows.push((n.margin_pu, b));
}
}
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
rows.into_iter().map(|r| r.1).collect()
};
let coarse = rank(0.02);
let fine = rank(0.005);
assert!(
coarse.len() >= 10,
"most buses should yield a curve, got {}",
coarse.len()
);
assert_eq!(
&coarse[..3],
&fine[..3],
"the three weakest buses should not depend on the sampling grid"
);
}