Q-V Curves and the Reactive Margin
Motivation
Continuation power flow asks how much further the system can be loaded before it collapses. This asks a narrower and more operational question about one bus: how much reactive support would it take to hold this bus at a given voltage, and how much margin is there before that stops being possible?
It is the question a reactive-planning study actually asks. A bus with 200 MVAr of margin can absorb a contingency; one with 20 cannot, and no amount of system-wide loadability tells you which is which.
The method
Put a fictitious synchronous condenser at the bus — an ideal machine that holds
a voltage and produces whatever reactive power that takes — by retyping the bus
to PV. Then sweep its setpoint downward and read the reactive power it has to
supply at each step:
Q
| \
| \___ Q > 0: the bus must be supported to hold it here
| \___
+-------------\----------------------------- |V|
| \___ the bus's own solved voltage: Q = 0,
| \___ a condenser here has nothing to do
| \__.--
| ^
| the nose: dQ/d|V| = 0.
| No setpoint needs less reactive power than this,
| and −Q there is the margin.
Why this needs no continuation
Every point is an ordinary power flow, and fixing \(|V|\) is what makes it
so. The bus's reactive equation is dropped and its magnitude stops being an
unknown — which is exactly a PV bus — so the Jacobian stays non-singular all
the way through the nose.
A P-V curve needs a predictor–corrector precisely because it does not fix a magnitude: λ is a free parameter and the Jacobian goes singular at the fold. Here the fold is in \(Q\), which is an output rather than an unknown, so nothing goes singular and a sweep is enough.
That is a difference in kind, not an implementation shortcut, and it is why this
reuses run_power_flow rather than continuation's bordered
corrector.
Interpolating the minimum
The nose falls between samples, and pinning it to the nearest one wastes accuracy the sweep already paid for. A parabola through the three bracketing samples is exact where the curve is locally quadratic, which it is at a smooth minimum, and costs no extra solves. Measured on the two-bus case with samples 0.01 apart, the interpolated nose lands within \(4 \times 10^{-6}\) of the analytic voltage where the nearest sample can be out by 0.005.
The vertex is refused if it falls outside the bracketing samples — that means the three points do not describe a minimum and the parabola is extrapolating.
A curve in closed form
On two buses the whole curve can be written down, which is what the solver is gated against. Slack at \(E\angle 0\), a lossless line \(jx\), one bus with net injection \(P + jQ\). Eliminating the angle gives
\[ u^{2} - (2a + E^{2})u + (a^{2} + b^{2}) = 0, \qquad a = xQ,\ b = xP,\ u = |V|^{2} \]
Read as a quadratic in \(Q\) instead of in \(u\), the operating branch is
\[ Q(u) = \frac{u - \sqrt{E^{2}u - x^{2}P^{2}}}{x} \]
and \(dQ/du = 0\) gives the nose directly:
\[ u_{nose} = \frac{E^{4} + 4x^{2}P^{2}}{4E^{2}}, \qquad Q_{nose} = \frac{4x^{2}P^{2} - E^{4}}{4E^{2}x} \]
tests/qv_test.rs matches both to \(10^{-5}\) in \(Q\) across a range of
reactances and loadings — the solver checked against arithmetic rather than
against itself.
Q-V and P-V do not agree on the weakest bus
This is worth stating plainly, because the opposite is the natural assumption and it is false.
Both are voltage-stability measures, so it is tempting to expect the bus a P-V
trace names critical — the largest component of the tangent at the nose — to be
the bus with the smallest Q-V margin. Measured on case14:
| ranking, weakest first | |
|---|---|
| P-V (continuation tangent at the nose) | 4, 3, 8, 9, 6 |
| Q-V (reactive margin at base loading) | 7, 13, 5, 9, 11 |
One bus in common out of five. The obvious explanation — that they are evaluated at different operating points, one at the nose and one at base loading — does not rescue it either: re-running the Q-V sweep at 98% of \(\lambda_{max}\) makes the agreement worse, not better.
They measure related but distinct things. A P-V critical bus is a property of the system-wide collapse mode along one particular loading direction. A Q-V margin is one bus's local reactive headroom at the current operating point. A bus can be locally weak without participating much in the system's collapse mode, and the reverse.
This is why utilities run both rather than either, and why tests/qv_test.rs
deliberately does not assert that they agree — a test to that effect would have
been asserting something untrue about the physics.
Reading the result
- The margin is a positive number of MVAr, and larger is stronger.
nose_not_reachedmeans the sweep stopped while the curve was still falling. The number is then a lower bound on the margin, not the margin, and the CLI says so — understating a bus's weakness is the direction that misleads.already_controlledmeans the bus was alreadyPV, so the curve describes moving an existing machine's setpoint rather than adding a condenser, and \(Q\) includes what that machine was already producing.- The condenser is deliberately unlimited. The curve measures how much reactive power holding a voltage would take, which a limit would truncate rather than answer. Other buses keep their own limits.
Using it
gridoxide qv network.json --weakest 10 # rank buses by margin
gridoxide qv network.json --bus 13 --curve # one bus, with the curve
gridoxide qv network.json --bus 13 --v-min 0.2 # sweep further down
curve = model.qv_curve(13, v_min=0.20)
print(curve["status"], curve["nose"]["margin_pu"])
for p in curve["points"]:
print(p["voltage"], p["q"])
#![allow(unused)] fn main() { use gridoxide::qv::{qv_curve, QvOptions}; let curve = qv_curve(&buses, &lines, &transformers, &shunts, 13, QvOptions::default()); if let Some(nose) = &curve.nose { println!("{:.1} MVAr at |V| = {:.4}", nose.margin_mvar(100.0), nose.voltage); } }
Limitations
- One bus at a time. A ranking is the caller looping, and each point is a
full solve, so a sweep over every bus of a large network is
buses × stepspower flows. Embarrassingly parallel, and not parallelized. - The margin is measured at one operating point. It says nothing about the margin after a contingency, which is the question a security study asks — that would be this sweep inside a contingency loop.
- A slack bus is refused: it already fixes its own magnitude and already has a free reactive injection, so there is no condenser to add.