Distributed slack: who covers the imbalance?
Level: Advanced, deepens the slack chapters of the basic tour.
Note: This workshop was created with AI assistance and is reviewed and curated by the maintainer; it is not a fully machine-generated text.
A classical power flow gives the entire active-power imbalance, every megawatt of load not covered by scheduled generation, plus all network losses, to ONE reference bus. Real interconnections do not work that way: primary and secondary control spread the imbalance over many governors, each contributing according to its participation factor. The distributed slack in Sparlectra.jl models exactly that: the imbalance $\lambda$ becomes an extra solver state, and every participating generator picks up its share $\Delta P_i = \alpha_i \, \lambda$ with $\sum_i \alpha_i = 1$.
This notebook walks through the one question everything else hangs on: WHERE do the weights $\alpha_i$ come from? You will run the same shortfall case under every weight mode, watch the shares change, and see the honest failure when no valid participant exists.
Note: On Google Colab the install cell takes a few minutes on a fresh session (package download and precompilation). Colab's Julia version may change over time; this notebook targets Julia ≥ 1.12.
Warm-up and shared helpers
Julia compiles each function on first use. This cell loads the package, collects the helpers of the whole notebook, and warms both solver paths (classical single slack and distributed slack) on the study case itself, so the chapters below run at full speed.
using SparlectraThe study case as a MATPOWER file: a four-bus ring, drawn as a diagram in the study-case section below, with a DELIBERATE 20 MW shortfall (70 MW load, only 50 MW scheduled PV generation), so somebody must visibly cover the gap. The 21st generator column is the MATPOWER participation factor APF: 0.75 for G2 and 0.25 for G3, deliberately DIFFERENT from their 30:20 schedule ratio, so the weight modes below give visibly different shares.
function write_demo_case(dir)
path = joinpath(dir, "case4_distributed_slack.m")
write(path, """
function mpc = case4_distributed_slack
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.00 0 110 1 1.1 0.9;
2 2 0 0 0 0 1 1.01 0 110 1 1.1 0.9;
3 2 0 0 0 0 1 1.00 0 110 1 1.1 0.9;
4 1 70 20 0 0 1 1.00 0 110 1 1.1 0.9;
];
mpc.gen = [
1 0 0 300 -300 1.00 100 1 250 -250 0 0 0 0 0 0 0 0 0 0 0;
2 30 0 50 -50 1.01 100 1 100 0 0 0 0 0 0 0 0 0 0 0 0.75;
3 20 0 50 -50 1.00 100 1 100 0 0 0 0 0 0 0 0 0 0 0 0.25;
];
mpc.branch = [
1 2 0.01 0.08 0.02 0 0 0 0 0 1 -360 360;
2 3 0.02 0.10 0.02 0 0 0 0 0 1 -360 360;
3 4 0.01 0.06 0.02 0 0 0 0 0 1 -360 360;
1 4 0.03 0.12 0.02 0 0 0 0 0 1 -360 360;
];
""")
return path
end
case_path = write_demo_case(mktempdir())
# fresh import for every run (each run mutates the net in place)
load_case() = createNetFromMatPowerFile(filename = case_path)
# the interesting quantity per run: which bus delivers how much active
# power BEYOND its schedule (uses two solver internals, fine for a demo)
function print_beyond_schedule(net)
Y = Sparlectra.createYBUS(net = net, sparse = true, printYBUS = false)
V = [n._vm_pu * cis(deg2rad(n._va_deg)) for n in net.nodeVec]
extra = (real.(Sparlectra.calc_injections(Y, V)) .- real.(Sparlectra.buildComplexSVec(net))) .* net.baseMVA
for (i, n) in enumerate(net.nodeVec)
abs(extra[i]) < 0.005 && continue
println(" bus ", i, " (", Sparlectra.getCompName(n.comp), ") +", round(extra[i]; digits = 2), " MW beyond schedule")
end
end
# participation table from the solver status (bus, alpha, delta P)
function print_participation(net)
st = Sparlectra.rectangular_pf_status(net)
println(" lambda = ", round(st.distributed_slack_lambda_p_mw; digits = 2), " MW over ", st.distributed_slack_participants, " participant bus(es), mode ", st.distributed_slack_mode)
for row in st.distributed_slack_participation
println(" ", row.bus, ": alpha = ", round(row.alpha; digits = 3), " -> dP = ", round(row.dp_mw; digits = 2), " MW (scheduled ", round(row.pg_mw; digits = 1), " MW)")
end
end
t_first = @elapsed runpf!(load_case(), 30, 1e-8, 0)
t_dslack = @elapsed runpf!(load_case(), 30, 1e-8, 0; distributed_slack_enabled = true)
println("warm: classical ", round(t_first; digits = 2), " s, distributed ", round(t_dslack; digits = 2), " s (first calls compile)")warm: classical 33.49 s, distributed 1.33 s (first calls compile)The study case
G1 (slack) G2 (PV, Pg 30 MW, APF 0.75)
| |
B1 ------- line ------- B2
| |
line line
| |
B4 ------- line ------- B3
| |
load 70 MW G3 (PV, Pg 20 MW, APF 0.25)Scheduled PV generation covers 50 of the 70 MW; the remaining 20 MW plus the network losses are the imbalance $\lambda$ someone has to deliver.
Chapter 1: the classical single slack
Example 1: the classical single slack. Without distributed slack, the answer is brutal: the REF bus alone absorbs everything. The PV buses deliver exactly their schedule, to the kilowatt, no matter how large the gap is.
net = load_case()
runpf!(net, 30, 1e-8, 0)
println("classical single slack:")
print_beyond_schedule(net)classical single slack:
bus 1 (Bus_1_110) +20.58 MW beyond scheduleReading aid (Example 1): G1 delivers the 20 MW shortfall plus all losses. On a small demo that is harmless; on a real interconnection it concentrates hundreds of MW on one arbitrary bus, distorts the flows around it, and tells you nothing about how the fleet would actually respond.
Chapter 2: pg_weighted, share by schedule (the default)
Example 2: share by schedule. distributed_slack_enabled = true turns the imbalance into a solver state. The weights come from p_mode. The default :pg_weighted uses the SCHEDULED Pg of every generator-type prosumer at a REF or PV bus: G2 with 30 MW and G3 with 20 MW get raw weights 30 and 20, normalized to $\alpha = 0.6 / 0.4$. Big units take proportionally more, the classical "participation by size".
net = load_case()
runpf!(net, 30, 1e-8, 0; distributed_slack_enabled = true, distributed_slack_p_mode = :pg_weighted)
println("pg_weighted (raw weights 30 : 20):")
print_participation(net)
print_beyond_schedule(net)pg_weighted (raw weights 30 : 20):
lambda = 20.56 MW over 2 participant bus(es), mode pg_weighted
Bus_2_110: alpha = 0.6 -> dP = 12.33 MW (scheduled 30.0 MW)
Bus_3_110: alpha = 0.4 -> dP = 8.22 MW (scheduled 20.0 MW)
bus 2 (Bus_2_110) +12.33 MW beyond schedule
bus 3 (Bus_3_110) +8.22 MW beyond scheduleChapter 3: imported, share by declared participation factors
Example 3: share by declared participation factors. Grid data often DECLARES the participation: MATPOWER carries it in the optional APF generator column, CGMES in normalPF. Sparlectra imports both into ProSumer.participationFactor, and p_mode = :imported uses them as raw weights. Our case declares 0.75 / 0.25, deliberately NOT the 30:20 schedule ratio, and the shares follow the declaration, not the schedule:
net = load_case()
runpf!(net, 30, 1e-8, 0; distributed_slack_enabled = true, distributed_slack_p_mode = :imported)
println("imported (MATPOWER APF 0.75 : 0.25):")
print_participation(net)
print_beyond_schedule(net)imported (MATPOWER APF 0.75 : 0.25):
lambda = 20.58 MW over 2 participant bus(es), mode imported
Bus_2_110: alpha = 0.75 -> dP = 15.44 MW (scheduled 30.0 MW)
Bus_3_110: alpha = 0.25 -> dP = 5.15 MW (scheduled 20.0 MW)
bus 2 (Bus_2_110) +15.44 MW beyond schedule
bus 3 (Bus_3_110) +5.15 MW beyond scheduleChapter 4: headroom_weighted, share by free capacity
Example 4: share by free capacity. :headroom_weighted uses max(maxP - Pg, 0): whoever has the most UNUSED capacity takes the most. Two things change against the earlier modes (Examples 2 and 3). First, the REFERENCE generator joins the pool: candidates are the generators at REF or PV buses, and under pgweighted (Example 2, Pg = 0) and imported (Example 3, no APF) the slack unit was dropped as invalid, but its headroom of 250 MW is a perfectly valid weight. The raw weights are therefore 250 : 70 : 80. Second, note the reversal among the PV units: G3, the SMALLER unit, now takes more than G2, because 100 - 20 leaves more reserve than 100 - 30. There is also `:pmaxweighted` (share by installed size) for markets that contract by capacity.
net = load_case()
runpf!(net, 30, 1e-8, 0; distributed_slack_enabled = true, distributed_slack_p_mode = :headroom_weighted)
println("headroom_weighted (raw weights 250 : 70 : 80):")
print_participation(net)
print_beyond_schedule(net)headroom_weighted (raw weights 250 : 70 : 80):
lambda = 20.55 MW over 3 participant bus(es), mode headroom_weighted
Bus_1_110: alpha = 0.625 -> dP = 12.85 MW (scheduled 0.0 MW)
Bus_2_110: alpha = 0.175 -> dP = 3.6 MW (scheduled 30.0 MW)
Bus_3_110: alpha = 0.2 -> dP = 4.11 MW (scheduled 20.0 MW)
bus 1 (Bus_1_110) +12.85 MW beyond schedule
bus 2 (Bus_2_110) +3.6 MW beyond schedule
bus 3 (Bus_3_110) +4.11 MW beyond scheduleChapter 5: explicit, you decide
Example 5: explicit weights. p_mode = :explicit takes the weights from a table you pass in (config key power_flow.distributed_slack.weights, or the keyword below), keyed by bus name or bus index. Weights need not sum to one; normalization is automatic. Here bus 2 gets everything:
net = load_case()
runpf!(net, 30, 1e-8, 0; distributed_slack_enabled = true, distributed_slack_p_mode = :explicit, distributed_slack_weights = Dict("2" => 1.0))
println("explicit (all on bus 2):")
print_participation(net)
print_beyond_schedule(net)explicit (all on bus 2):
lambda = 20.63 MW over 1 participant bus(es), mode explicit
Bus_2_110: alpha = 1.0 -> dP = 20.63 MW (scheduled 30.0 MW)
bus 2 (Bus_2_110) +20.63 MW beyond scheduleThe rules behind the weights, in one place
Whatever the mode, the same pipeline runs per synchronous island:
- Candidates are the generator-type prosumers at the island's REF or PV buses. Fixed PQ injections (HVDC converter stand-ins, boundary equivalents) never participate; the bus-type gate excludes them.
- Invalid candidates are dropped, not guessed: a weight that is missing, zero, negative, NaN, or Inf removes the unit from the pool (with a debug log and a drop counter in the result metadata).
- The surviving raw weights are normalized to $\sum \alpha = 1$ and aggregated per bus; the solver then adds one state $\lambda$ and every participant moves by $\alpha_i \lambda$. With
distributed_slack_respect_p_limits = true(default) a participant is clamped at its P limit and the remainder redistributes over the rest.
The honest failure: no valid participant
Example 6: the honest failure. What if the declared data is missing? :imported (Example 3) on a case WITHOUT any APF column drops every candidate. The default distributed_slack_fallback = :error refuses to guess and throws an actionable error; fallback = :ref_only warns and solves classically instead, so batch runs survive a case with missing declarations:
no_apf = load_case()
for ps in no_apf.prosumpsVec ## strip the imported factors
ps.participationFactor = nothing
end
runpf!(no_apf, 30, 1e-8, 0; distributed_slack_enabled = true, distributed_slack_p_mode = :imported, distributed_slack_fallback = :ref_only)
println("fallback ref_only: solved classically, the slack absorbs everything again")
print_beyond_schedule(no_apf)┌ Warning: Distributed slack: no valid participant — falling back to the classical slack for this island.
│ island = "case4_distributed_slack"
│ mode = :imported
│ dropped = 3
└ @ Sparlectra ~/work/Sparlectra.jl/Sparlectra.jl/src/powerflow_rectangular/rectangular_distributed_slack.jl:156
fallback ref_only: solved classically, the slack absorbs everything again
bus 1 (Bus_1_110) +20.58 MW beyond scheduleReading aid (Example 6): with :error (the default) the message names the island, the mode, and the number of dropped candidates, and points at fallback=ref_only; nothing silently degrades to a single slack.
One configuration note for run_sparlectra/YAML users: the same switches live under power_flow.distributed_slack.* (enabled, p_mode, fallback, weights, respect_p_limits). Distributed slack and the non-ideal external-grid source are mutually exclusive by validation: both answer "who covers the imbalance", so you enable at most one.
Where to go next
- Slack types and short circuit: ideal slack vs. external-grid source vs. distributed slack on one 8-bus network.
- Solver documentation: the rectangular Newton-Raphson formulation the extra state plugs into.
- Workshop tour: all workshop examples in one Colab session.