Reactive Power Limits (PV → PQ Switching)
Motivation
The plain Newton-Raphson formulation on the Powerflow page treats every PV bus as if its generator could supply or absorb any amount of reactive power needed to hold \(\vert V_k \vert\) at its setpoint. Real generators can't: each has a nameplate reactive capability \(Q_k^{min} \le Q_k \le Q_k^{max}\), usually tightest for round-rotor machines operating near their active-power limit. If the voltage setpoint on a PV bus would require \(Q_k\) outside that range, the unconstrained solution is not physically achievable — the generator saturates at its limit and the bus can no longer hold its voltage.
The standard fix is PV → PQ switching: solve normally, check every PV bus's computed \(Q_k\) against its limits, and for any bus that violates one, convert it to a PQ bus with \(Q_k\) pinned at the violated limit — trading the voltage-magnitude equation for a reactive-power equation at that bus — then re-solve. Repeat until no bus violates its limits.
What changes in the equation system
Recall from the Powerflow page that a PV bus contributes only a \(P\)-mismatch row to \(f(x)\) — its voltage magnitude \(\vert V_k \vert\) is known (the setpoint), so it isn't part of the unknown vector \(x\) and there's no \(Q\)-mismatch row for it. A PQ bus contributes both a \(P\)- and a \(Q\)-mismatch row, and its \(\vert V_k \vert\) is part of \(x\).
Switching bus \(k\) from PV to PQ therefore:
- adds \(\vert V_k \vert\) to the unknown vector \(x\) (it's no longer fixed at the setpoint — the voltage is now free to float),
- adds a \(Q\)-mismatch row \(Q_k^{calc}(x) - Q_k^{spec}\) to \(f(x)\), with \(Q_k^{spec}\) pinned at whichever limit was violated (\(Q_k^{min}\) or \(Q_k^{max}\)) instead of the bus's original \(q\_spec\),
- leaves that bus's \(P\)-mismatch row untouched — the same equation, unaffected by bus type.
This changes \(n_{unknowns}\) itself, so the Jacobian's dimensions grow by one row and one column per switched bus. Any cached symbolic factorization (fill-reducing ordering) computed for the old sparsity pattern is invalid once a switch happens and must be redone from scratch.
Switching strategies
That equation-system change is common to every implementation. What differs is when the check runs and whether a switch can ever be undone — three genuinely different designs, in increasing order of bookkeeping.
1. One-directional outer-loop switching
Solve to full convergence, check limits, switch every violating bus to PQ, re-solve; repeat until a pass
switches nothing. A bus that has been switched to PQ stays PQ for the rest of the call, even if a later
pass's solution would put its \(Q\) back within limits.
The appeal is that the anti-oscillation problem simply doesn't arise: with switching one-way, the set of
PQ buses grows monotonically, so the outer loop terminates on its own. The cost is that a bus which was
only transiently out of range — say during an early pass, still far from the real solution — is committed
to PQ permanently, giving a slightly different, slightly more conservative answer than a scheme that could
release it.
2. Bidirectional switching with a switch-count cap
The same outer loop, except a bus moved to PQ may move back to PV in a later pass if its computed
\(Q\) has returned within limits — recovering exactly the transiently-violating case strategy 1 gives up
on. This reintroduces the risk strategy 1 avoids: a bus can flip between PV and PQ indefinitely across
passes, since each type change moves the solution enough to justify the opposite change next time. The
standard mitigation is a hard cap on how many times any one bus may switch, after which it is forced to stay
wherever it last was.
That cap is real bookkeeping — a per-bus counter carried across outer passes — and it makes the result mildly path-dependent (which bus hits its cap first depends on iteration order). In exchange, no bus is written off on the strength of one early, poorly-conditioned iterate.
3. Mid-iteration switching
Rather than a separate outer loop that fully re-solves after every switch, the check-and-switch step is folded directly into the Newton iteration: once the mismatch drops below some threshold, bus types are switched and the same iteration sequence continues instead of a fresh solve starting. This saves the redundant re-convergence strategies 1 and 2 pay for — the iterate is already near the solution when the switch happens, so it doesn't have to be rediscovered — at the cost of mutating the equation system underneath a running Newton iteration, so the Jacobian's dimensions (and any cached factorization) change mid-flight.
Published versions of this technique also widen the switch-back criterion: "On PV-PQ Bus Type Switching Logic in Power Flow Computation" (Jinquan Zhao) bases the decision to release a bus on comparing its voltage against its setpoint in addition to comparing its \(Q\) against its limits, not on \(Q\) alone.
Where this fits in gridoxide today
outerloop::ReactiveLimits implements strategy 1, as an outer loop around a
solver::PersistentSolver:
- Solve to convergence with every
PVbus free (PersistentSolver::solve). If this doesn't converge, stop and return that status — there's no point checking limits on a non-converged solution. - Compute every bus's actual \(Q_k\) (
network::power_injections). - For each
PVbus whose \(Q_k\) violatesq_min/q_max: switchbus_typetoPQand pinq_specto the violated limit. - If no bus was switched this pass, the solution is self-consistent — stop and return
Converged. - Otherwise, reset the cached factorization (
PersistentSolver::reset) and go back to step 1. - If the outer-loop budget runs out without stabilizing, the run reports
budget_exhaustedrather than dressing the state up as converged.
Steps 4 and 5 are the driver's job rather than this loop's: it returns
Unstable after switching anything, declares Invalidates::Pattern, and the driver resets the
factorization and re-solves. That separation is what lets reactive limits run alongside
distributed slack and tap control rather than
instead of them — before the outer-loop layer existed, each was its own entry point and a caller
picked one.
Strategies 2 and 3 both add real complexity (anti-oscillation bookkeeping, or reworking the iteration loop itself) to recover a case gridoxide's simpler design just accepts as a one-way commitment — a deliberate scope trade-off, not an oversight.
One further simplification on top of the one-directional rule: bus voltages carry over between outer passes rather than resetting to a flat start. Since only one bus's type changes per pass, the previous pass's converged state is normally very close to the next equilibrium, so re-solving after a switch typically takes only a few extra Newton iterations, not a full fresh solve.
Scope: net injection, not per-device limits
Bus::q_min/q_max bound the bus's net reactive injection — the same aggregate quantity q_spec
already represents when multiple loads/generators share a node (see pgm::PgmVoltageRegulator's own
q_min/q_max parsing, which sums each active generator's limits at a bus exactly the way p_spec is
already summed). Pinning q_spec to a violated limit only achieves that limit exactly if the bus carries
no voltage-dependent ZIP-model load terms (Bus::zip_terms) — true for the common case of a bus that's
purely a PV/generator connection, not true in general if a voltage-dependent load is co-located there.
Validated against real data
tests/q_limits_test.rs checks the mechanics directly: a 3-bus fixture with one PV bus whose q_min is
set tight enough to force a violation, confirmed to switch to PQ, pin q_spec at exactly q_min, and
converge to the same voltages across every Jacobian backend (Scalar, Block, Klu) — as well as a
control case with a loose q_min that shouldn't trigger any switch at all, converging to the same answer
as plain unconstrained Newton-Raphson.
Beyond the unit fixture, this has been exercised against all 12 real MATPOWER benchmark cases
(gridoxide.matpower — python/gridoxide/matpower.py, with scripts/bench/matpower_to_pgm.py as a thin
CLI wrapper around it — populates voltage_regulator.q_min/q_max from each case's own gen matrix): 11 of the 12 have at least one PV bus whose unconstrained \(Q\) genuinely exceeds its nameplate
limit — from 4 violations on the smallest case up to 166 simultaneous violations on case3120sp.
Tool reference
| Tool | Strategy | Where |
|---|---|---|
| gridoxide | 1 — one-directional outer loop | outerloop::ReactiveLimits (opt-in; plain newton_raphson ignores q_min/q_max) |
| MATPOWER | 1 — one-directional outer loop | runpf's enforce_q_lims option |
| pandapower | 1 — one-directional outer loop, pypower/MATPOWER-derived | enforce_q_lims (NR algorithm only, per its own docstring) |
| powsybl-open-loadflow | 2 — bidirectional, capped switch count per bus | ReactiveLimitsOuterLoop (handles capability curves too, not just fixed limits) |
| VeraGrid | 3 — mid-iteration, per Zhao's switching logic (cited in its own source) | PowerFlowOptions.control_q |
Which machine, not just which bus
A PV bus has one reactive injection and the solver decides it. The limits above are the summed
capability of everything holding that bus — which is the right bound to enforce, and is what the
importer accumulates. But it leaves a question unanswered: when six machines hold one bus, which of
them is carrying the reactive power?
gridoxide solve --dispatch (and PowerFlowModel.machine_dispatch()) answers it, by attributing
the solved injection after the fact:
- by an explicit per-machine key where the source document states one;
- else in proportion to each machine's own reactive range;
- else evenly.
The fallback is wholesale: one implausible or missing value discards the whole basis rather than leaving a split half-derived from capability and half from nothing. Shares are then clamped to each machine's own range and the excess re-split, so no machine is handed more than it can produce while another sits idle.
This changes no answer. It is arithmetic on a solved state, not a constraint inside the solve.
Every shared-control case in the vendored CGMES corpus is machines sitting at the bus they regulate,
so that bus is an ordinary PV bus with a single injection. The genuinely different problem —
several machines at different buses holding one remote bus — needs extra equations inside the
Newton system, and is not built, because no fixture exercises it.
What the attribution exposed
It is worth reading unattributed on the result. A non-zero value means the machines at that bus
are all at their limits and still cannot account for the reactive power the solve put there.
That is not an arithmetic failure. It happens because the clamp above compares q_min/q_max
against the bus's net injection, while the CGMES importer fills those fields from the machines'
own capability. Where a reactive load shares the bus, the machine must cover it too — so the
machine saturates before the net injection reaches the bound, and the clamp fires late. On RealGrid,
50 of 416 regulated buses share their bus with another reactive injection and 6 come out
unattributable, 4.4 MVAr in total.
The simplification was already documented (pgm::PgmVoltageRegulator's doc comment names it);
nothing had measured it before. Correcting it means bounding the machine part rather than the net,
which changes power-flow answers and so is deliberately not folded into an attribution pass.