QV curves and reactive margin#

A QV curve shows the reactive power required to maintain a selected bus voltage. The lowest point on the curve defines the reactive margin at that bus. solve_qv_curve traces the curve and reports the margin and the voltage at the lowest point.

These examples create cases in Python, but the calculation also accepts RAW, RAWX, and MATPOWER cases. PV curves explains the related active power and voltage calculation.

A feeder with a known bottom#

This example uses the lossless feeder from PV curves. For line reactance \(X\), fixed load \(P\), and initial reactive load \(Q_0\), the reactive margin is \((1/4 - X^2 P^2)/X - Q_0\). The lowest-point voltage is \(\sqrt{1/4 + X^2 P^2}\). All values in these equations use per unit. For \(X = 0.3\), \(P = 0.1\), and \(Q_0 = 0.02\), the margin is 81.03 Mvar and the lowest-point voltage is 0.5009 pu.

import math

from gpf import (Bus, Branch, DCOPFError, Generator, Load, GpfCase,
                 PfContinuationOptions, solve_qv_curve)


def build_feeder():
    """A two-bus feeder: unit-voltage source, X = 0.3 pu line, 10 MW base
    load at 0.98 power factor."""
    buses = [Bus(ibus=1, ide=3, baskv=230.0), Bus(ibus=2, ide=1, baskv=230.0)]
    gens = [Generator(ibus=1, machid="1", stat=1, pg=10.0, pt=500.0, pb=0.0,
                      vs=1.0, qt=9999.0, qb=-9999.0)]
    branches = [Branch(ibus=1, jbus=2, ckt="1", r=0.0, x=0.3, b=0.0, stat=1)]
    loads = [Load(ibus=2, loadid="1", pl=10.0, ql=2.0, stat=1)]
    return GpfCase(sbase=100.0, rev=33, basfrq=60.0, buses=buses,
                   generators=gens, branches=branches, loads=loads,
                   fixed_shunts=[], transformers=[], gen_costs=[])


feeder = build_feeder()

X, P, Q0 = 0.3, 0.1, 0.02
(round(100 * ((0.25 - X**2 * P**2) / X - Q0), 2),
 round(math.sqrt(0.25 + X**2 * P**2), 4))
(81.03, 0.5009)

Tracing the curve#

solve_qv_curve traces the curve at bus 2. The result identifies a smooth fold at the calculated margin and voltage.

curve = solve_qv_curve(feeder, 2)
{"nose_kind": curve.nose_kind, "margin_mvar": round(curve.margin_mvar, 2),
 "pinned": bool(curve.pinned), "v_bottom": round(curve.v_bottom, 4)}
{'nose_kind': 'fold', 'margin_mvar': 81.03, 'pinned': True, 'v_bottom': 0.5009}

The curve#

The plot shows voltage on the horizontal axis and reactive-power injection on the vertical axis. The curve minimum gives the reactive margin below zero. The calculation selects points along the curve, so the voltage intervals are not uniform.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(5.6, 3.6))
ax.plot(curve.v, curve.q_inj_mvar, color="#2a78d6", lw=2, marker="o", ms=4)
ax.plot(curve.v_bottom, -curve.margin_mvar, marker="*", ms=13,
        color="#1a1a19", ls="none")
ax.annotate(f"{curve.v_bottom:.3f} pu, {-curve.margin_mvar:.1f} Mvar",
            xy=(curve.v_bottom, -curve.margin_mvar),
            xytext=(8, -4), textcoords="offset points", fontsize=9)
ax.axhline(0.0, color="#1a1a19", lw=0.6)
ax.set_xlabel("bus 2 voltage (pu)")
ax.set_ylabel("injected reactive power (Mvar)")
ax.grid(alpha=0.3, lw=0.5)
for side in ("top", "right"):
    ax.spines[side].set_visible(False)
fig.tight_layout()
../_images/3f2efdf32e7ba1fa9843c21268dcf6cb685066fc30fcd11bf028d61ef46b0513.png

A reactive limit lowers the margin#

The second system from PV curves has a generator that regulates its bus at 1.02 pu and has a 30 Mvar reactive-power limit. The default limit_mode="enforce" setting applies the limit. The generator reaches the limit and stops regulating the bus voltage. This reduces the reactive margin to 48.3 Mvar. If the calculation ignores the limit, the margin is 63.8 Mvar. Both curves reach a smooth fold.

def build_system():
    """Slack, a regulating generator (1.02 pu, 30 Mvar ceiling), and a
    30 MW load bus behind an X = 0.35 pu line."""
    buses = [Bus(ibus=1, ide=3, baskv=230.0), Bus(ibus=2, ide=2, baskv=230.0),
             Bus(ibus=3, ide=1, baskv=230.0)]
    gens = [Generator(ibus=1, machid="1", stat=1, pg=0.0, pt=500.0, pb=0.0,
                      vs=1.0, qt=9999.0, qb=-9999.0),
            Generator(ibus=2, machid="1", stat=1, pg=30.0, pt=500.0, pb=0.0,
                      vs=1.02, qt=30.0, qb=-900.0)]
    branches = [Branch(ibus=1, jbus=2, ckt="1", r=0.01, x=0.10, b=0.0, stat=1),
                Branch(ibus=2, jbus=3, ckt="1", r=0.03, x=0.35, b=0.0, stat=1)]
    loads = [Load(ibus=3, loadid="1", pl=30.0, ql=6.0, stat=1)]
    return GpfCase(sbase=100.0, rev=33, basfrq=60.0, buses=buses,
                   generators=gens, branches=branches, loads=loads,
                   fixed_shunts=[], transformers=[], gen_costs=[])


system = build_system()
limited = solve_qv_curve(system, 3)
held = solve_qv_curve(system, 3,
                      options=PfContinuationOptions(limit_mode="hold"))
{"enforced": (limited.nose_kind, round(limited.margin_mvar, 1)),
 "voltage_held": (held.nose_kind, round(held.margin_mvar, 1))}
{'enforced': ('fold', 48.3), 'voltage_held': ('fold', 63.8)}

Voltage-controlled buses#

A regulated bus cannot change voltage while its regulator maintains the setpoint. With enforced limits, the trace starts at 1.02 pu. It moves downward after the generator reaches its reactive-power limit and stops regulating the bus. With limit_mode="hold", the regulator does not release the bus, so the calculation rejects the trace. The calculation also rejects a trace at the swing bus because its voltage is fixed.

at_machine = solve_qv_curve(system, 2)
(round(float(at_machine.v[0]), 4), round(float(at_machine.v.min()), 4))
(1.02, 0.5663)
try:
    solve_qv_curve(system, 2,
                   options=PfContinuationOptions(limit_mode="hold"))
except DCOPFError as err:
    message = str(err)
message
'the voltage at bus 2 is held by a regulator that never released over the trace, so the QV direction has no free Q row; enforce (finite) reactive limits or study another bus'

Screening several buses#

A multi-bus screen traces one curve for each selected bus. The bus with the smallest reactive margin is the weakest. This example adds bus 4 behind another 0.25 pu of reactance and places a 10 MW load there. The remote bus has a margin of 28.7 Mvar, compared with 41.9 Mvar at bus 3. Both curves reach a smooth fold.

outer = build_system()
outer.buses.append(Bus(ibus=4, ide=1, baskv=230.0))
outer.branches.append(Branch(ibus=3, jbus=4, ckt="1", r=0.02, x=0.25,
                             b=0.0, stat=1))
outer.loads.append(Load(ibus=4, loadid="1", pl=10.0, ql=2.0, stat=1))

{bus: round(solve_qv_curve(outer, bus).margin_mvar, 1) for bus in (3, 4)}
{3: 41.9, 4: 28.7}

Run the same calculation from the command line with gpf study qv CASE --bus BUS. Repeat --bus to trace more than one bus. Use --limit-mode hold to trace the voltage-held curve. PV curves explains the related result fields.