Power flow
Three engines implement IPowerFlowSolver: the dense Newton-Raphson
(src/GridSim.Core/PowerFlow/NewtonRaphsonPowerFlow.cs), the sparse
Newton-Raphson (src/GridSim.Core/PowerFlow/SparseNewtonRaphsonPowerFlow.cs)
and the backward/forward sweep for distribution feeders
(src/GridSim.Core/PowerFlow/BackwardForwardSweep.cs). Everything that does
not depend on the matrix representation - bus-spec assembly, the Q-limit
outer loop, result assembly, line flows - is shared, as pure functions, in
src/GridSim.Core/PowerFlow/NewtonShell.cs, so the dense and sparse engines
produce bit-for-bit identical results on the same case.
The Newton-Raphson formulation as implemented
Bus spec and start point
NewtonShell.BuildBusSpec aggregates generators and loads onto buses:
Pspec[i] = (sum Pg - Pd) / BaseMva,Qspec[i] = (sum Qg - Qd) / BaseMva.- Per-bus aggregate
Qmin/Qmax(summed over the bus's generators) and the generator voltage setpointVgare kept for the Q-limit loop. - Start point: a voltage-controlled bus (Slack or PV) with a generator starts
at
Vg; otherwiseVm = 1.0underFlatStart, else the bus's modelVm. The slack angle is always the model'sVaDeg(in radians internally); other angles start at 0 underFlatStart, else at the model values.
Mismatch and unknown ordering
Each iteration evaluates the polar power injections at the current state
(NewtonRaphsonPowerFlow.ComputeInjections - internal so the state
estimator's measurement model evaluates h(x) through the exact same code
path):
P[i] = Vm[i] * sum_k Vm[k]*(G[i,k]*cos thetaik + B[i,k]*sin thetaik)
Q[i] = Vm[i] * sum_k Vm[k]*(G[i,k]*sin thetaik - B[i,k]*cos thetaik) thetaik = Va[i] - Va[k]
The mismatch vector stacks real power over the pvpq buses (every non-slack bus, in bus order) then reactive power over the pq buses:
f = [ Pspec - P over pvpq (npvpq rows)
Qspec - Q over pq (npq rows) ] m = npvpq + npq
Convergence is max|f| < Tolerance, checked before the linear solve, so a
converged start point costs zero factorisations. Both Newton engines also call
PowerFlowOptions.Cancellation.ThrowIfCancellationRequested() at the top of
every inner iteration (~ms granularity), so a long deferrable solve can be
aborted the instant a cycle deadline passes; the default None token never
throws, so an ordinary solve is unchanged. The unknowns follow the same
ordering: deltatheta for pvpq buses, then deltaVm for pq buses. The dense solver keeps
explicit pvpq/pq index lists; the sparse solver keeps two position arrays
pPos[i]/qPos[i] (-1 when bus i has no P/Q equation) that map a bus to its
row/column in the linear system - the same ordering, indexed differently.
Jacobian blocks
Both engines build the standard polar blocks H, N, M, L
(dP/dtheta, dP/dVm, dQ/dtheta, dQ/dVm), with the diagonal entries expressed
through the already-computed injections:
| Entry | i = j (diagonal) | i != j (off-diagonal) |
|---|---|---|
| H = dP/dtheta | -Q[i] - B[i,i]*Vm[i]^2 |
Vm[i]*Vm[j]*(G*sin theta - B*cos theta) |
| N = dP/dVm | P[i]/Vm[i] + G[i,i]*Vm[i] |
Vm[i]*(G*cos theta + B*sin theta) |
| M = dQ/dtheta | P[i] - G[i,i]*Vm[i]^2 |
-Vm[i]*Vm[j]*(G*cos theta + B*sin theta) |
| L = dQ/dVm | Q[i]/Vm[i] - B[i,i]*Vm[i] |
Vm[i]*(G*sin theta - B*cos theta) |
The dense engine writes them into a reused double[m,m]
(NewtonRaphsonPowerFlow.BuildJacobianInto); the sparse engine walks each
bus's Y-bus row and emits only structurally non-zero entries into a
CsrMatrix.Builder (SparseNewtonRaphsonPowerFlow.BuildJacobian) - the
Jacobian inherits the admittance graph, four blocks interleaved.
Linear solve and update
- Dense:
ILinearSolver.SolveInPlace(J, f)- partial-pivoting LU;fbecomes the correction. Updates are undamped:Va += deltaon pvpq,Vm += deltaon pq. A near-singular pivot throws. - Sparse:
ISparseLinearSolver.Solve(J, f)(direct Gilbert-Peierls LU by default). If it reports singular (null), the engine falls back to ILU(0)-preconditioned BiCGSTAB withtol = min(1e-10, Tolerance*1e-3)andmaxIter = 4m + 200; if that also fails, the iteration stops and the solve reports non-convergence. A NaN/Inf correction is never applied. The sparse update is adaptively damped: if the largest correction component exceeds 0.4 the whole step is scaled to 0.4 (direction preserved), andVmis floored at 0.2 pu - guards against a large flat-start step on a stiff network overshooting into a negative-voltage region. Near the solution the cap never binds, so quadratic convergence is intact.
The reactive-limit outer loop
With EnforceQLimits (default on), after each inner Newton run
NewtonShell.ApplyQLimits checks every bus that started PV:
- Forward: if the bus's generators would need more than the aggregate
Qmax(or less thanQmin), withinQLimitTolMva = 1e-3MVA, the output is pinned at the limit and the bus is released to PQ (Qspecfixed at the limit) - the numerical analogue of a machine hitting its excitation limit. The pinned limit is recorded for revert bookkeeping. - Revert (opt-in,
RevertQLimits, default off): a pinned-PQ bus returns to PV when its solved voltage shows the limit is no longer binding - pinned at Qmax withVm > Vg + 1e-4, or at Qmin withVm < Vg - 1e-4(RevertVoltTolPu). At most one revert per bus per solve (anti-oscillation); a re-violating bus is re-pinned and stays pinned. The default stays off so historical one-way switching - and every pinned published-solution regression - is unchanged.
The loop re-solves until no bus switches, capped at MaxQLimitPasses (12).
The slack bus is never limited; NewtonShell.DiagnoseSlackQ reports (as
SlackQNote on either Newton engine, diagnostic only) when the slack's solved
reactive injection exceeds the aggregate limits of the machines on it -
reassigning the slack or a distributed slack is deliberately out of scope.
Options
src/GridSim.Core/PowerFlow/PowerFlowOptions.cs:
| Option | Default | Meaning |
|---|---|---|
Tolerance |
1e-8 |
Convergence bound on max|f|, per-unit power. |
MaxIterations |
30 | Inner Newton cap (per Q-limit pass). |
FlatStart |
false | Start PQ voltages from 1.0 / angles from 0. |
EnforceQLimits |
true | Run the PV->PQ outer loop. |
MaxQLimitPasses |
12 | Outer-loop cap. |
RevertQLimits |
false | Opt-in PQ->PV revert (MATPOWER enforce_q_lims style). |
Trace |
null | Opt-in SolveTrace capture of everything the solve computes (Y-bus, spec, per-iteration mismatch/correction/Jacobian, Q-limit events); null = zero overhead. |
Cancellation |
None |
CancellationToken checked at the top of every NR inner iteration in both NewtonRaphsonPowerFlow and SparseNewtonRaphsonPowerFlow, so the parallel cycle engine can abort a deferrable solve at its deadline. default/None is a guaranteed no-op - ThrowIfCancellationRequested never throws for it, so every existing solve is bit-for-bit unchanged (73 solver-regression tests green; an explicit test pins exact Vm/VaDeg equality between the token and no-token paths). |
The OLTC outer loop
src/GridSim.Core/PowerFlow/SteadyStateSolver.cs:SteadyStateSolver.Solve
wraps any IPowerFlowSolver in tap regulation. With no OLTCs it is the plain
power flow. Otherwise, after each solve, every OLTC is examined against the
solved voltage of its controlled bus:
Vm < Target - Deadband-> tap down oneStepPu(bounded byMinTap)- lowering the tap raises the controlled bus;
Vm > Target + Deadband-> tap up one step (bounded byMaxTap);- inside the deadband -> no move.
If any tap moved, the branch list is cloned with the new ratios
(Dispatcher.Clone) and the flow re-solved; the loop ends when no tap moves
or after maxOltcPasses (default 25). One step per pass per transformer means
convergence is monotone and bounded, at the cost of one full power flow per
step of total tap travel.
Two properties matter to consumers:
RegulatedModel- the model actually solved, whoseBranch.TapRatios match the returned result. The loop solves on internal clones, so anything needing post-solve taps (the live dashboard, SCADA) must read this, never the model it passed in.OltcPasses- how many re-solve passes the last call took.
Both are mutable per-solve state: the class is not thread-safe. Parallel
N-1 sweeps use one instance per thread; a concurrent Solve on a shared
instance is detected by an interlocked guard and throws rather than
serialising, because sharing is a caller bug.
The distribution BFS sweep solver
BackwardForwardSweep handles what transmission-oriented NR does badly:
radial feeders with high R/X ratios. It requires a single slack (the source)
and a connected, at-most-weakly-meshed topology; each iteration is two passes
over the feeder tree:
- Backward (leaves -> source): node currents
I = conj(S_draw/V) + y_shunt*Vare accumulated up the tree to give each branch's series current. Folding across a tapped branch applies the ideal-transformer ratio (I/conj(tap)when the parent is the tapped from end,conj(tap)*Iwhen it is the to end). - Forward (source -> leaves): voltages step across the series impedance:
V_to = V_from/tap - Z*J, or for a child on the tapped from sideV_from = tap*V_to - |tap|^2*Z*J. Half line-charging appears as end shunts, the from-end half referred through1/|tap|^2- exactly the shunt part ofYff, so the converged voltages solve the same MATPOWER pi+tap model as the Newton engines, not just at unity tap.
On top of the plain radial sweep:
- Weakly-meshed feeders. A spanning tree is grown from the source; the
leftover branches become breakpoints. A loop-impedance matrix is built once
by superposition (one linear sweep per loop, loads off, slack grounded) and
each iteration solves it (small complex Gauss-Jordan with partial pivoting)
to inject compensating breakpoint currents until every loop's closure
residual
V_f - V_t - z*Ivanishes - Shirmohammadi-style multi-port compensation. At mostMaxCompensatedLoops = 6loops are accepted; anything more meshed (IEEE 14-bus already has 7 independent loops) is declined as "not a distribution feeder". - PV buses. A non-slack regulating generator holds
|V|at its setpoint by adjusting a reactive correction each sweep, using the source->bus path reactance as the dV/dQ sensitivity, clamped to the aggregate Q-limits; a saturated bus acts as PQ at the limit.
Convergence requires all three at once: the largest per-bus voltage change
below Tolerance, all PV setpoint errors and all loop residuals below
max(Tolerance, 1e-6) (controls settle to a looser tolerance than voltages).
A disconnected network, a missing slack, or too many loops returns the
non-converged report (Vm = 1, MaxMismatch = inf) rather than a doubtful
answer. Result assembly reuses the pi+tap stamps, computes the real power
mismatch per bus as a check figure (a regulating PV bus's Q is not checked -
it solves its own reactive, like the slack), and applies the same non-finite
guard as the Newton engines.
Engine selection
src/GridSim.Core/PowerFlow/SolverSelector.cs:SolverSelector is the single
source of truth; the CLI and both dashboards route through it so a case always
solves with the same engine.
| Rule | Value | Engine |
|---|---|---|
LooksRadial: Branches.Count == BusCount - 1 and exactly one slack |
- | DistributionSweep (BFS) |
otherwise, BusCount > sparseThreshold |
DefaultSparseThreshold = 3000 |
SparseNewton |
| otherwise | - | DenseNewton |
The radial test only catches genuine tree networks - any meshed transmission
case (case9 upward) fails it. The 3,000-bus threshold is the dense LU
memory/fill-in ceiling; the same constant gates the estimator's dense/sparse
step choice (EstimationOptions.SparseBusThreshold) and the CLI's
SolverBackend.Pick. Create returns the engine plus a display label
("Newton-Raphson (dense)"/"(sparse)"/"Backward-forward sweep") - Auto is
always resolved, never returned.
PowerFlowResult and LineFlow
src/GridSim.Core/PowerFlow/PowerFlowResult.cs:
| Field | Meaning |
|---|---|
Status |
Converged / MaxIterations / NonFinite. NonFinite means a NaN/Inf appeared in the solved state - the linear solve diverged - and the state is invalid even if a stale mismatch reads below tolerance. Defaults to Converged for presolved states loaded from disk/bridge that only carry the boolean. |
Converged |
true only when Status == Converged. Every other status means the state is untrustworthy. |
Iterations |
Total inner Newton iterations across all Q-limit passes (or sweep iterations for BFS). |
MaxMismatch |
The final max|f| in per-unit (for a non-converged BFS, the larger of mismatch and last voltage delta). |
BusIds |
Result index -> external bus id; the only way to map arrays back to the model. |
Vm, VaDeg |
Solved voltages, pu and degrees. |
PInjMw, QInjMvar |
Net injection (generation - demand) per bus, engineering units. |
TotalLossMw, TotalLossMvar |
Sum of net injections = network losses. |
LineFlows |
Per-branch flows (below). |
LineFlow(From, To, PFromMw, QFromMvar, PToMw, QToMvar, PLossMw, QLossMvar, Name, RatingMva, LoadingPercent) conventions
(NewtonShell.ComputeLineFlows):
PFrom/QFromandPTo/QToare the complex power flowing into the branch at each end, computed from the pi+tap admittances (S_f = V_f*conj(Yff*V_f + Yft*V_t)*BaseMva, likewise for the to end). A through-flow is therefore positive at one end and negative at the other.PLossMw = PFromMw + PToMw(and likewise Q) - the loss in this branch; charging makesQLossnegative on lightly loaded lines.LoadingPercent = max(|S_f|, |S_t|) / RateAMva x 100, and 0 when the branch is unrated (RateAMva = 0).From/Toare external bus ids, not indices.
See also
- 02-data-model-and-per-unit.md - the fields the solve consumes and the Y-bus stamps.
- 04-sparse-and-gpu-numerics.md - the linear solvers behind each Newton step.
- 01-architecture.md - where solves sit in the data flow.
- ../manual/03-running-power-flows.md - driving these engines from the CLI.
- ../manual/17-troubleshooting.md - reading non-convergence.