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
solver::newton_raphson_enforcing_q_limits() 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
max_outer_iterouter passes pass without stabilizing, stop and returnMaxIterationsReached.
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 | solver::newton_raphson_enforcing_q_limits (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 |