Power flow¶
mambo_power.pf holds the power-flow solvers. Public entry points take a Network and return
a typed result with provenance; the array-level solvers work on
NetworkArrays only and return plain positional arrays. The network is never
modified.
| Entry point | Returns |
|---|---|
pf.solve_dc(net) |
DcPowerFlowResult |
pf.dc.solve(arr) |
DcSolution (positional, pu) |
pf.solve_ac(net, *, options=None) |
AcPowerFlowResult |
pf.ac_newton.newton(arr, roles, opts, v0=None) |
AcSolution (positional, pu) |
Runnable scripts: 02_ac_power_flow.py,
03_dc_power_flow.py,
05_roles_and_islands.py.
DC power flow¶
The DC model is the lossless linearisation: flat voltage magnitudes, small angle differences, resistance and line charging ignored. It is exact for what it models and is the workhorse of PTDF-based contingency analysis and DC optimal power flow.
Formulation¶
With \(B'\) the DC susceptance matrix, \(B_f\) the from-side flow matrix, and the phase-shifter injections \(P_\text{shift} = C_{ft}^\top P_{f,\text{shift}}\), \(P_{f,\text{shift}} = -b \odot \phi\) (all from numerics), the declared net injection per bus in per unit is
where \(G_\text{shunt}\) is the shunt conductance consumption at 1.0 pu. The angles solve the linear system with the slack row and column removed and the slack angle fixed at zero:
Flows and realised injections follow:
\(P_\text{inj}\) equals \(P_\text{bus}\) on every non-slack bus; at the slack it is whatever
closes the balance (lossless, so \(\sum P_\text{inj} = 0\)). These are exactly MATPOWER
rundcpf's steps — which pandapower's rundcpp copies — and the parity test asserts equality
with rundcpp within 1e-9 on every fixture including case300.
The reduced system is factorised with scipy.sparse.linalg.splu, the same backend as the PTDF
builder and the AC solver; the provenance records solver = "scipy.sparse.linalg.splu".
Slack convention¶
The slack-bus balance goes entirely to the first in-service generator at the slack bus;
every other generator keeps its declared dispatch. This is MATPOWER's rule
(gen(on(refgen(1)), PG) += ...) and the number pandapower reports on res_ext_grid. Bus-level
generation is therefore engine-independent; the per-generator split is this documented
convention. If the slack bus has no in-service generator the balance cannot be closed;
naming that situation is the job of effective roles.
Errors¶
pf.dc.solve raises UnsolvableNetworkError when a branch has x == 0 (susceptance
undefined — a valid network the DC numerics cannot solve, not a solver bug) or ValueError
when the reduced \(B'\) is singular or yields non-finite angles — an islanded bus set that
slipped past validation. Through the jobs API these become structured failures
(UNSOLVABLE_NETWORK and INTERNAL respectively).
The result¶
solve_dc returns a DcPowerFlowResult in MW keyed by ids: vm_pu = 1.0 on every bus
(MATPOWER rundcpf sets VM = 1), all reactive columns 0, converged = True always,
p_to_mw = -p_from_mw on every branch, loading_pct from the from-side flow over
rating_mva or None when unrated. role_effective reports the
effective role of each bus. See Results.
from mambo_power import pf
from mambo_power.io import matpower
net = matpower.load("fixtures/matpower/case14.m")
result = pf.solve_dc(net)
print(result.provenance.kind, result.provenance.solver, result.converged)
print(result.buses[1].va_deg, result.branches[0].p_from_mw, result.generators[0].p_mw)
Positional use, when you already have arrays:
from mambo_power.numerics import NetworkArrays
from mambo_power.pf import dc
arr = NetworkArrays.from_network(net)
sol = dc.solve(arr) # DcSolution: theta_rad, p_from_pu, p_inj_pu, gen_p_pu
print(sol.theta_rad[arr.slack], sol.p_from_pu[0] * arr.base_mva)
AC power flow¶
pf.solve_ac is a sparse polar Newton-Raphson solver with pandapower-semantics reactive-limit
enforcement. It matches pandapower runpp at machine precision on every bundled fixture
(verification) and solves case300 from a flat start in a few hundredths of a
second.
API¶
from mambo_power import pf
from mambo_power.io import matpower
net = matpower.load("fixtures/matpower/case118.m")
result = pf.solve_ac(net, options=pf.AcOptions(init="flat"))
print(result.converged, result.iterations, result.q_limit_rounds, f"{result.max_mismatch_mva:.1e}")
print([(g.bus, g.q_limited) for g in result.generators if g.q_limited != "none"])
True 7 1 8.6e-12
[('bus-19', 'min'), ('bus-32', 'min'), ('bus-34', 'min'), ('bus-92', 'min'), ('bus-103', 'max'), ('bus-105', 'min')]
solve_ac(net, *, options: AcOptions | None = None) -> AcPowerFlowResult. AcOptions is a
frozen pydantic model (extra="forbid"), which is also what the jobs API validates
options against for kind="pf.ac"; the options as run are stamped into
result.provenance.options.
| Option | Default | Meaning |
|---|---|---|
tol |
1e-8 |
Convergence tolerance on the ∞-norm of the per-unit power mismatch (MATPOWER pf.tol; pandapower's tolerance_mva is the same pu quantity). Must be > 0. |
max_iter |
20 |
Newton iterations per solve (per Q-limit round). |
q_limits |
True |
Enforce generator reactive limits (see below). |
max_q_rounds |
10 |
Maximum number of re-solves after pinning. |
init |
"auto" |
"auto" warm-starts from Bus.vm_pu / Bus.va_deg when every in-service bus carries both, else flat; "flat" forces 1.0∠0 at PQ buses with PV and slack buses at their setpoint. |
The result adds three diagnostics to the common tables: iterations (Newton iterations
summed over every Q-limit round), q_limit_rounds and max_mismatch_mva (the final
∞-norm, in MVA). A solve that does not converge is reported through converged = False with
the last iterate — never raised; through the jobs API it is status="ok" with
converged=False.
stuck = pf.solve_ac(net, options=pf.AcOptions(init="flat", max_iter=1))
print(stuck.converged, stuck.iterations, f"{stuck.max_mismatch_mva:.3f} MVA")
The only exceptions that escape solve_ac are NoSlackGeneratorError (a slack bus without an
in-service generator, from effective roles) and ValueError for a
singular admittance matrix.
Formulation: polar Newton-Raphson¶
With \(Y\) the bus admittance matrix, \(V = |V| e^{j\theta}\) and the specified net injections \(S^\text{spec} = (P_\text{gen} - P_\text{load}) + j(Q_\text{gen} - Q_\text{load})\) in per unit (shunts live in \(Y\)), the mismatch is
Each iteration solves \(J\,\Delta x = -f\) with the Jacobian assembled from the sparse partial
derivatives of MATPOWER's dSbus_dV (polar form):
factorised with scipy.sparse.linalg.splu every iteration. The mismatch is tested before
each step, so a start that already satisfies \(\|f\|_\infty \le \text{tol}\) reports zero
iterations (MATPOWER's convention; MATPOWER and pandapower use a strict <, which is
immaterial at 1e-8). The loop stops with converged = False after max_iter updates, on a
singular Jacobian, or when an update produces a non-finite voltage.
Start. Flat: \(|V| = 1, \theta = 0\) at PQ buses, \(|V| = v_\text{set}\) (the effective
setpoint) and \(\theta = 0\) at PV and slack buses. Warm (init="auto"): the buses' stored
vm_pu / va_deg with PV and slack magnitudes replaced by the setpoint; the slack keeps its
stored angle, so angles are slack-relative only for flat starts.
Generator allocation (MATPOWER pfsoln; pandapower pypower/pfsoln.py:109-141 is a
verbatim copy). Every generator keeps its dispatch except the first in-service generator at
the slack bus, which absorbs the slack balance — the same rule DC uses. The bus reactive total
is split among the bus's in-service generators proportionally to each one's reactive range
(equally when every range is zero), so a pinned bus's generators sit exactly at their
individual limits.
Q-limit enforcement (pandapower semantics, decision D2)¶
The implementation follows pandapower 3.3.0 pf/run_newton_raphson_pf.py:182-249
(_run_ac_pf_with_qlims_enforced), itself a port of MATPOWER runpf.m:366-440 with
pf.enforce_q_lims = 1. After every converged Newton solve the reactive generation per
bus is \(Q_g = \Im\{V (YV)^*\} + Q_\text{load}\); every bus still PV whose \(Q_g > \sum Q_\max\)
or \(Q_g < \sum Q_\min\) (aggregate over its in-service generators) is converted to PQ with
\(Q^\text{spec} = Q_\text{limit} - Q_\text{load}\):
- the comparison is strict (pandapower
:199-200; MATPOWER adds a 5e-6opf.violationslack, pandapower does not); - all violators of a round are converted together (
enforce_q_lims=1, simultaneous), and the next solve warm-starts from the current voltages; - pins accumulate — a pinned bus is never restored to PV (
limited = r_[limited, mx],:235; the spec rejects the PQ→PV restore, which has no oracle); - the slack bus is never converted (
setdiff1d(changed_gens, ref),:227; MATPOWER's re-slack when the slack generator hits a limit is not implemented); - the loop ends when a converged solve shows no new violation; if violations persist after
max_q_roundsre-solves the result carriesconverged = Falseand a message listing the buses (pandapower raisesLoadflowNotConvergedinstead); - a Newton solve that fails to converge ends the loop at once without pinning — pinning from an unconverged state has no oracle.
Pinned buses report the limit itself (as pandapower's fixedQg restore does) and each of their
generators' GenResult.q_limited is "min" or "max"; AcPowerFlowResult.q_limit_rounds
counts the re-solves. The parity test requires the same set of pinned buses on the same
side as pandapower on every fixture where limits bind. Limits are aggregated per bus, as the
spec says; pandapower tests per generator — the two coincide whenever a bus's generators have
non-zero ranges, which holds on every bundled fixture (one generator per bus).
flowchart TD
S["effective roles, start voltage<br/>(flat or warm)"] --> NR["Newton-Raphson solve<br/>mismatch tested before each step"]
NR --> C{"converged within max_iter?"}
C -- no --> F1["return converged = False<br/>with the last iterate"]
C -- yes --> Q{"q_limits on and a PV bus has<br/>Qg > ΣQmax or Qg < ΣQmin?"}
Q -- no --> OK["allocate P and Q to generators<br/>return converged = True"]
Q -- yes --> R{"rounds < max_q_rounds?"}
R -- no --> F2["return converged = False<br/>message lists the violating buses"]
R -- yes --> PIN["pin every violator to PQ at its limit<br/>(pins accumulate, slack exempt)<br/>warm-start from the current voltages"]
PIN --> NR
Warm start¶
Copy a solved state into the buses and init="auto" picks it up; a start already inside
tolerance is accepted with zero iterations:
off = pf.solve_ac(net, options=pf.AcOptions(init="flat", q_limits=False))
for bus, row in zip(net.buses, off.buses, strict=True):
bus.vm_pu, bus.va_deg = row.vm_pu, row.va_deg
again = pf.solve_ac(net, options=pf.AcOptions(q_limits=False)) # init="auto"
print(again.iterations, again.q_limit_rounds, again.converged)
With Q-limits on, a previously pinned bus is PV again at the start (its magnitude snaps back to
the setpoint), so one re-pin round is needed; from the shipped MATPOWER columns "auto" needs
2–6 iterations on the upstream fixtures and reaches the same fixed point as a flat start.
Effective bus roles¶
The declared Bus.type is the input; the role the solver uses is derived by
numerics.effective_roles(arr), the single derivation site (W3):
| Situation | Effective role |
|---|---|
| PV bus with at least one in-service generator | PV, setpoint from the generators |
| PV bus with no in-service generator | PQ (MATPOWER bustypes, pandapower build_gen agree) |
| Slack bus with no in-service generator | raises NoSlackGeneratorError (no MATPOWER-style re-slack) |
| Several in-service generators on one bus | setpoint is the last one's v_set_pu (MATPOWER runpf.m:296); a SetpointConflictWarning names the bus, the generators and both values when they differ by more than 1e-9 pu |
NetworkArrays.bus_type keeps the declared roles; both solvers consume the effective ones, and
every BusResult.role_effective reports the role the bus was actually solved with (for AC,
after Q-limit pinning). pandapower's converter picks the first generator's setpoint, so on
the case14_roles fixture the two disagree by design at bus 2 — the warning is about exactly
that. See 05_roles_and_islands.py.
Islands (decision D1)¶
The AC solver assumes the in-service subset is connected — the model guarantees it. Files
with islands are repaired by the importer (buses unreachable from the slack and their
elements are deactivated, with an ISLAND_DEACTIVATED issue) before the network is
validated; see File formats › Islands and
model.repair_islands.
Verification¶
pandapower runpp(init="flat", tolerance_mva=1e-8, trafo_model="pi", enforce_q_lims=…) is
the primary oracle (bands 1e-6 pu, 1e-4 deg, 1e-4 MVA); MATPOWER's stored VM/VA columns are
the secondary check at 2e-3 pu / 0.5 deg with a per-fixture exclusion list. Measured on the
wave head (tests/parity/test_ac_vs_pandapower.py, test_ac_vs_matpower_stored.py):
| Fixture | Q-limits | NR iterations | Rounds | Pinned buses (side) | max Δ|V| vs runpp | max Δθ | max Δflow | Stored-column residual (VM, bus) |
|---|---|---|---|---|---|---|---|---|
| case14 | on | 4 | 0 | — | 8.9e-16 pu | 2.8e-14° | 4.1e-13 MVA | 1.33e-3 (bus 4) |
| case_ieee30 | on | 6 | 1 | 2 (max) | 1.1e-15 pu | 6.4e-14° | 7.1e-13 MVA | 6.1e-4 (bus 16); bus 3 excluded |
| case57 | on | 4 | 0 | — | 5.6e-15 pu | 1.7e-13° | 2.1e-12 MVA | 8.7e-4 (bus 32); 14, 46, 47 excluded |
| case118 | on | 7 | 1 | 19, 32, 34, 92, 105 (min); 103 (max) | 8.9e-16 pu | 2.0e-13° | 6.3e-12 MVA | 9.9e-4 (bus 105); 17, 30, 38, 68 excluded |
| case300 | off | 5 | 0 | — | 3.2e-14 pu | 4.3e-12° | 1.3e-10 MVA | 8.5e-3; 11 of 300 buses beyond 2e-3 (not gated) |
| case300 | on | 7 | 1 | 10, 20, 156, 170, 171, 236, 7003, 7055, 7062, 9002 (all max) | 4.0e-14 pu | 4.3e-12° | 9.2e-11 MVA | — |
"NR iterations" is the total across rounds (case118: 3 + 4 after pinning). The stored-column bands were ratified at 2e-3 pu / 0.5 deg because the stored solutions are CDF-era solved points that no solver reproduces to file precision — case14 bus 4 is 1.33e-3 pu off for pandapower and for us alike; the measured residual is pinned in the test so it cannot drift. The excluded buses are the ones the reference-quality gate (5 MVA mismatch of the stored state) flags in each file. case30's stored state is flat, so it is a self-consistency fixture only. The case118 negative pair for Q-limits: bus 103 is stored at 1.001 pu; with limits off it sits at its 1.01 setpoint (9e-3 off, outside the band), with limits on at 1.00071.
Timing (AC-7). case300, Q-limits off, flat start, first call in a fresh interpreter
(arrays, Ybus, five sparse LU factorisations, result construction): 0.029 s cold, 0.018 s
warm on the development machine (Windows 11, Python 3.12). The contracted surface is the CI
ubuntu 3.12 job, where tests/parity/test_ac_timing.py asserts < 1.0 s and a dedicated step
prints the measured cold and warm seconds into the job log; the figure above is not a CI
measurement.
Choosing between DC and AC¶
| DC | AC | |
|---|---|---|
| Models | active power, angles | active + reactive power, magnitudes + angles |
| Losses | none | yes |
| Solve | one sparse linear solve | Newton iterations, Q-limit rounds |
| Always converges | yes (if connected) | no — check converged |
| Use for | screening, PTDF/LODF, DC-OPF, markets | voltage studies, AC feasibility check after DC-OPF |
03_dc_power_flow.py puts the two side by side on
case300: the DC flows are within a few MW of AC on most branches and blind to the ~400 MW of
losses.