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, used by MATPOWER's runpf (enforce_q_lims) and implemented the same way in
gridoxide, 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.
The outer-loop algorithm
solver::newton_raphson_enforcing_q_limits(buses, ybus, tol, max_iter, backend, max_outer_iter) implements
this 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.
Two simplifications versus a fully general implementation (deliberate, and both match MATPOWER's own
default runpf behavior rather than being a gridoxide-specific shortcut):
- Switching is one-directional. A bus switched to
PQnever switches back toPVwithin the same call, even if a later pass's solution would put it back within limits. A bidirectional scheme needs real anti-oscillation logic (a bus can otherwise flip back and forth indefinitely across outer passes); this avoids that whole problem by not attempting it. - 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.
Alternative switching strategies
One-directional switching is a deliberate simplification, not the only viable design. Two genuinely different strategies exist for the same underlying problem:
- Bidirectional switching with a hard switch-count cap. A bus moved to
PQis allowed to move back toPVin a later pass if its computed \(Q\) returns within limits — recovering a bus that was only transiently out of range (e.g. during an early, poorly-conditioned iteration far from the solution) instead of committing it toPQfor the rest of the solve. The risk this reintroduces is a bus oscillating betweenPVandPQindefinitely across passes; the standard mitigation (used this way by powsybl-open-loadflow'sReactiveLimitsOuterLoop) is a hard cap on how many times any one bus may switch, after which it's forced to stay wherever it last was — real bookkeeping gridoxide's one-directional rule avoids needing at all. - Checking mid-iteration instead of only after full convergence. Rather than a separate outer loop that fully re-solves after every switch, the check-and-switch step can instead be folded directly into the Newton iteration itself once the mismatch drops below some threshold, switching bus types and continuing the same iteration sequence rather than starting a fresh solve. One published version of this technique ("On PV-PQ Bus Type Switching Logic in Power Flow Computation", Jinquan Zhao — cited directly in VeraGrid's own source as the basis for its implementation) also bases the switch-back decision on comparing the bus's voltage against its setpoint in addition to its \(Q\) against its limits, not \(Q\) alone.
Both alternatives 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 matching MATPOWER's own default behavior, not an oversight.
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
(scripts/bench/matpower_to_pgm.py 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.