gridoxide_core/qv.rs
1//! Q-V curves: how much reactive headroom does one bus have?
2//!
3//! [`continuation`](crate::continuation) asks how much further the *system* can
4//! be loaded before it collapses. This asks a narrower and more operational
5//! question about a *single* bus: how much reactive support would it take to
6//! hold this bus at a given voltage, and how much margin is there before that
7//! stops being possible?
8//!
9//! # The method, and why it needs no continuation
10//!
11//! Put a fictitious synchronous condenser at the bus — an ideal machine that
12//! holds a voltage and produces whatever reactive power that takes — by
13//! retyping the bus to `PV`. Then sweep its setpoint downward and read the
14//! reactive power it has to supply at each step:
15//!
16//! ```text
17//! Q the curve is traced left to right by lowering |V|
18//! | \ (or right to left; the sweep direction is a choice)
19//! | \___ Q > 0: the bus must be *supported* to hold this high
20//! | \___
21//! +-----------\------------------ |V|
22//! | \___
23//! | \__.-- the minimum: dQ/d|V| = 0.
24//! | ^ Below it no setpoint is reachable with less
25//! the nose reactive power — that is the margin.
26//! ```
27//!
28//! Every point is an **ordinary power flow**. Fixing `|V|` is what makes it so:
29//! the bus's reactive equation is dropped and its magnitude stops being an
30//! unknown, which is exactly a `PV` bus, and the Jacobian stays non-singular
31//! all the way through the nose. A P-V curve needs a predictor–corrector
32//! precisely because it does *not* fix a magnitude — λ is a free parameter and
33//! the Jacobian goes singular at the fold. Here the fold is in `Q`, which is an
34//! output rather than an unknown, so nothing goes singular and a sweep is
35//! enough.
36//!
37//! That is a real difference in kind, not an implementation shortcut, and it is
38//! why this module reuses [`crate::run_power_flow`] rather than
39//! [`continuation`](crate::continuation)'s bordered corrector.
40//!
41//! # What the margin means
42//!
43//! The minimum of the curve is the most reactive power the bus can absorb
44//! before the relationship inverts. Reported as a positive number of MVAr: a
45//! larger margin is a stronger bus. A bus whose curve barely dips below zero is
46//! one small contingency away from having no reachable setpoint at all.
47//!
48//! # What this shares with the P-V side
49//!
50//! The answer, when both are asked. The bus a P-V trace names critical — the
51//! largest component of the tangent at the nose — should be the bus with the
52//! smallest Q-V margin, because both are measuring the same weakness by
53//! different means. `tests/qv_test.rs` asserts exactly that, and it is the
54//! strongest gate here: two independent methods agreeing is worth more than
55//! either agreeing with itself.
56
57use crate::PowerFlowOptions;
58use crate::network::{ShuntAdm, build_ybus, effective_injection, power_injections, stamp_shunts};
59use crate::solver::SolveStatus;
60use crate::types::{Bus, BusType, Line, Transformer};
61
62/// Everything a Q-V sweep needs to know.
63#[derive(Clone, Debug)]
64pub struct QvOptions {
65 /// The solve at every point. `method` must be `NewtonRaphson`.
66 pub pf: PowerFlowOptions,
67 /// Highest setpoint to try, per-unit.
68 pub v_max: f64,
69 /// Lowest. The nose is typically well above 0.4 on a healthy bus; a bus
70 /// whose curve is still falling at `v_min` reports `NoseNotReached` rather
71 /// than pretending the last sample is a minimum.
72 pub v_min: f64,
73 /// Setpoint decrement, per-unit.
74 pub step: f64,
75 /// Refine the minimum by fitting a parabola through the three samples
76 /// around it. Exact for a locally-quadratic curve, which this is near a
77 /// smooth minimum, and free — no extra solves.
78 pub refine: bool,
79}
80
81impl Default for QvOptions {
82 fn default() -> Self {
83 Self {
84 pf: PowerFlowOptions {
85 max_iter: 60,
86 ..Default::default()
87 },
88 v_max: 1.10,
89 v_min: 0.40,
90 step: 0.01,
91 refine: true,
92 }
93 }
94}
95
96/// One setpoint and the reactive power holding it would take.
97#[derive(Clone, Debug, PartialEq)]
98pub struct QvPoint {
99 /// The setpoint, per-unit.
100 pub voltage: f64,
101 /// What the fictitious condenser must supply, per-unit. Positive means the
102 /// bus has to be *supported* to reach this voltage.
103 pub q: f64,
104 pub iterations: usize,
105}
106
107/// The bottom of the curve: the most reactive power this bus can absorb.
108#[derive(Clone, Debug, PartialEq)]
109pub struct QvNose {
110 pub voltage: f64,
111 pub q: f64,
112 /// `−q` at the nose, per-unit — positive, and larger is stronger.
113 pub margin_pu: f64,
114 /// True when the voltage and `q` were interpolated rather than sampled.
115 pub refined: bool,
116}
117
118impl QvNose {
119 /// The margin in MVAr on a stated system base.
120 pub fn margin_mvar(&self, base_mva: f64) -> f64 {
121 self.margin_pu * base_mva
122 }
123}
124
125/// How a sweep ended.
126#[derive(Clone, Debug, PartialEq)]
127pub enum QvStatus {
128 /// The curve turned: a minimum was bracketed inside the swept range.
129 NoseFound,
130 /// Still falling at `v_min`. The lowest sample is a *lower bound* on the
131 /// margin, not the margin.
132 NoseNotReached,
133 /// Too few points converged to say anything.
134 Failed,
135 /// The request was not attempted.
136 Rejected(QvError),
137}
138
139/// A request a sweep will not attempt, stated rather than approximated.
140#[derive(Clone, Debug, PartialEq)]
141pub enum QvError {
142 /// A slack bus already fixes its own magnitude and already has a free
143 /// reactive injection, so there is no condenser to add and no curve to
144 /// trace.
145 SlackBus(usize),
146 BusOutOfRange {
147 bus: usize,
148 n: usize,
149 },
150 /// `v_min >= v_max`, or a non-positive step.
151 EmptyRange,
152 MethodUnsupported(crate::PowerFlowMethod),
153}
154
155/// A bus's Q-V curve.
156#[derive(Clone, Debug)]
157pub struct QvCurve {
158 pub bus: usize,
159 /// Converged samples, in sweep order (highest setpoint first).
160 pub points: Vec<QvPoint>,
161 pub nose: Option<QvNose>,
162 pub status: QvStatus,
163 /// The bus's voltage in the base case, for context: a curve is read
164 /// relative to where the bus actually sits.
165 pub base_voltage: f64,
166 /// Setpoints that did not converge. Worth seeing — a gap in the middle of a
167 /// curve is a different thing from a run of failures at the bottom.
168 pub failed: Vec<f64>,
169 /// The bus was already voltage-controlled, so the curve describes moving an
170 /// existing machine's setpoint rather than adding a condenser, and `q`
171 /// includes what that machine was already producing.
172 pub already_controlled: bool,
173}
174
175impl QvCurve {
176 fn rejected(bus: usize, why: QvError) -> Self {
177 Self {
178 bus,
179 points: Vec::new(),
180 nose: None,
181 status: QvStatus::Rejected(why),
182 base_voltage: f64::NAN,
183 failed: Vec::new(),
184 already_controlled: false,
185 }
186 }
187
188 /// The reactive margin in per-unit, when a nose was found.
189 pub fn margin_pu(&self) -> Option<f64> {
190 self.nose.as_ref().map(|n| n.margin_pu)
191 }
192}
193
194/// Traces one bus's Q-V curve.
195///
196/// See the module docs for what the curve means and why it needs no
197/// continuation.
198pub fn qv_curve(
199 buses: &[Bus],
200 lines: &[Line],
201 transformers: &[Transformer],
202 shunts: &[ShuntAdm],
203 bus: usize,
204 opts: QvOptions,
205) -> QvCurve {
206 if bus >= buses.len() {
207 return QvCurve::rejected(
208 bus,
209 QvError::BusOutOfRange {
210 bus,
211 n: buses.len(),
212 },
213 );
214 }
215 if buses[bus].bus_type == BusType::Slack {
216 return QvCurve::rejected(bus, QvError::SlackBus(bus));
217 }
218 if opts.pf.method != crate::PowerFlowMethod::NewtonRaphson {
219 return QvCurve::rejected(bus, QvError::MethodUnsupported(opts.pf.method));
220 }
221 // Every case spelled out, so a NaN bound is visibly refused rather than
222 // incidentally caught by a negated comparison.
223 let range_ok = opts.v_min.is_finite() && opts.v_max.is_finite() && opts.v_min < opts.v_max;
224 if !opts.step.is_finite() || opts.step <= 0.0 || !range_ok {
225 return QvCurve::rejected(bus, QvError::EmptyRange);
226 }
227
228 let mut y = build_ybus(buses.len(), lines, transformers);
229 stamp_shunts(&mut y, shunts);
230 let ybus = y.finish();
231
232 // Where the bus actually sits, for context.
233 let base = crate::run_power_flow(
234 buses.to_vec(),
235 lines,
236 transformers,
237 shunts,
238 crate::TapData::none(),
239 opts.pf.clone(),
240 );
241 let base_voltage = if base.stats.status == SolveStatus::Converged {
242 base.buses[bus].voltage_mag
243 } else {
244 f64::NAN
245 };
246
247 let already_controlled = buses[bus].bus_type == BusType::PV;
248 let mut points: Vec<QvPoint> = Vec::new();
249 let mut failed: Vec<f64> = Vec::new();
250
251 let steps = ((opts.v_max - opts.v_min) / opts.step).floor() as usize;
252 for k in 0..=steps {
253 let v = opts.v_max - opts.step * k as f64;
254
255 let mut trial = buses.to_vec();
256 // The fictitious condenser. Retyping to `PV` is the whole of it: the
257 // bus's reactive equation is dropped, so `Q` becomes free, and its
258 // magnitude stops being an unknown.
259 trial[bus].bus_type = BusType::PV;
260 trial[bus].voltage_mag = v;
261 // An unlimited machine, deliberately: the curve measures how much
262 // reactive power holding this voltage *would* take, which a limit would
263 // truncate rather than answer. Other buses keep their own limits.
264 trial[bus].q_min = f64::NEG_INFINITY;
265 trial[bus].q_max = f64::INFINITY;
266
267 let report = crate::run_power_flow(
268 trial,
269 lines,
270 transformers,
271 shunts,
272 crate::TapData::none(),
273 opts.pf.clone(),
274 );
275 if report.stats.status != SolveStatus::Converged {
276 failed.push(v);
277 continue;
278 }
279
280 let (_, q_calc) = power_injections(&report.buses, &ybus);
281 // What the condenser supplies is the bus's net injection less whatever
282 // the bus injects on its own account. Evaluated through
283 // `effective_injection` at the *solved* voltage so a voltage-dependent
284 // load is subtracted at the voltage it actually sees, rather than at
285 // its nominal.
286 let own = Bus {
287 voltage_mag: report.buses[bus].voltage_mag,
288 ..buses[bus].clone()
289 };
290 let (_, q_own) = effective_injection(&own);
291 points.push(QvPoint {
292 voltage: report.buses[bus].voltage_mag,
293 q: q_calc[bus] - q_own,
294 iterations: report.stats.iterations(),
295 });
296 }
297
298 let (nose, status) = locate_nose(&points, opts.refine);
299 QvCurve {
300 bus,
301 points,
302 nose,
303 status,
304 base_voltage,
305 failed,
306 already_controlled,
307 }
308}
309
310/// The minimum of a sampled curve, and whether it is genuinely inside the
311/// swept range.
312///
313/// A minimum at either end is not a minimum — it means the sweep stopped before
314/// the curve turned, and the sample is a bound rather than an answer. Saying so
315/// matters more here than usual: the margin is the headline number, and
316/// reporting the last sample of a still-falling curve would understate a bus's
317/// weakness in exactly the direction that misleads.
318fn locate_nose(points: &[QvPoint], refine: bool) -> (Option<QvNose>, QvStatus) {
319 if points.len() < 3 {
320 return (None, QvStatus::Failed);
321 }
322 let mut lowest = 0usize;
323 for (i, p) in points.iter().enumerate() {
324 if p.q < points[lowest].q {
325 lowest = i;
326 }
327 }
328 if lowest == 0 || lowest == points.len() - 1 {
329 return (
330 Some(QvNose {
331 voltage: points[lowest].voltage,
332 q: points[lowest].q,
333 margin_pu: -points[lowest].q,
334 refined: false,
335 }),
336 QvStatus::NoseNotReached,
337 );
338 }
339
340 let (a, b, c) = (&points[lowest - 1], &points[lowest], &points[lowest + 1]);
341 if !refine {
342 return (
343 Some(QvNose {
344 voltage: b.voltage,
345 q: b.q,
346 margin_pu: -b.q,
347 refined: false,
348 }),
349 QvStatus::NoseFound,
350 );
351 }
352
353 // Parabola through the three samples, minimized. Exact where the curve is
354 // locally quadratic, which it is near a smooth minimum.
355 let (x1, y1) = (a.voltage, a.q);
356 let (x2, y2) = (b.voltage, b.q);
357 let (x3, y3) = (c.voltage, c.q);
358 let denom = (x1 - x2) * (y2 - y3) - (x2 - x3) * (y1 - y2);
359 let nose = if denom.abs() < 1e-15 {
360 QvNose {
361 voltage: x2,
362 q: y2,
363 margin_pu: -y2,
364 refined: false,
365 }
366 } else {
367 let vertex = x2
368 - 0.5 * ((x2 - x1).powi(2) * (y2 - y3) - (x2 - x3).powi(2) * (y2 - y1))
369 / ((x2 - x1) * (y2 - y3) - (x2 - x3) * (y2 - y1));
370 // Refuse a vertex outside the bracketing samples — that means the three
371 // points do not describe a minimum and the parabola is extrapolating.
372 if vertex > x1.max(x3) || vertex < x1.min(x3) || !vertex.is_finite() {
373 QvNose {
374 voltage: x2,
375 q: y2,
376 margin_pu: -y2,
377 refined: false,
378 }
379 } else {
380 // Lagrange interpolation at the vertex.
381 let q = y1 * (vertex - x2) * (vertex - x3) / ((x1 - x2) * (x1 - x3))
382 + y2 * (vertex - x1) * (vertex - x3) / ((x2 - x1) * (x2 - x3))
383 + y3 * (vertex - x1) * (vertex - x2) / ((x3 - x1) * (x3 - x2));
384 QvNose {
385 voltage: vertex,
386 q,
387 margin_pu: -q,
388 refined: true,
389 }
390 }
391 };
392 (Some(nose), QvStatus::NoseFound)
393}