Distributed slack: who covers the imbalance?

Level: Advanced, deepens the slack chapters of the basic tour.

Open in Colab

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 minute or two 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 defines what the chapters share (the using clause, the study case with its case_path, and the helpers load_case, print_beyond_schedule and print_participation), so any chapter can run on its own after it; a code cell that uses a name from another cell says so in a comment. Its last line, warmup(), runs every solver path the chapters use once (classical single slack and every distributed-slack weight mode) on the study case itself, so nothing stalls mid-notebook; its code is in docs/lit/warmup/workshop_distributed_slack.jl of the package. How long the warm-up takes depends on the machine: a Colab session is several times slower than a desktop; every later cell then runs without compile pauses.

The cell writes the 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.

using Sparlectra

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())

# the helpers of the chapters, defined here so that any chapter runs on
# its own after this cell:
#   write_demo_case(dir)         writes the study case (case_path below)
#   load_case()                  fresh import of the study case (every example)
#   print_beyond_schedule(net)   extra active power per bus (every example)
#   print_participation(net)     participation table (Examples 2 to 5)

# 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

# compile every path the chapters use once; the warm-up code is in
# docs/lit/warmup/workshop_distributed_slack.jl of the installed package
include(pkgdir(Sparlectra, "docs", "lit", "warmup", "workshop_distributed_slack.jl"))
warmup()
warm-up: compiles the paths of this workshop once; how long it takes depends on the machine (a Colab session is several times slower than a desktop)
warm: classical 0.08 s, distributed 1.67 s (first calls compile)
further paths  : 0.13 s (weight modes, participation tables); everything warm

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.

# uses: load_case, print_beyond_schedule (warm-up cell)
net = load_case()
runpf!(net, 30, 1e-8, 0)
println("classical single slack:")
print_beyond_schedule(net)
classical single slack:
    bus 1 (1)  +20.58 MW beyond schedule

Reading 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".

# uses: load_case, print_beyond_schedule, print_participation (warm-up cell)
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
    2: alpha = 0.6  ->  dP = 12.33 MW (scheduled 30.0 MW)
    3: alpha = 0.4  ->  dP = 8.22 MW (scheduled 20.0 MW)
    bus 2 (2)  +12.33 MW beyond schedule
    bus 3 (3)  +8.22 MW beyond schedule

Chapter 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:

# uses: load_case, print_beyond_schedule, print_participation (warm-up cell)
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
    2: alpha = 0.75  ->  dP = 15.44 MW (scheduled 30.0 MW)
    3: alpha = 0.25  ->  dP = 5.15 MW (scheduled 20.0 MW)
    bus 2 (2)  +15.44 MW beyond schedule
    bus 3 (3)  +5.15 MW beyond schedule

Chapter 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.

# uses: load_case, print_beyond_schedule, print_participation (warm-up cell)
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
    1: alpha = 0.625  ->  dP = 12.85 MW (scheduled 0.0 MW)
    2: alpha = 0.175  ->  dP = 3.6 MW (scheduled 30.0 MW)
    3: alpha = 0.2  ->  dP = 4.11 MW (scheduled 20.0 MW)
    bus 1 (1)  +12.85 MW beyond schedule
    bus 2 (2)  +3.6 MW beyond schedule
    bus 3 (3)  +4.11 MW beyond schedule

Chapter 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:

# uses: load_case, print_beyond_schedule, print_participation (warm-up cell)
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
    2: alpha = 1.0  ->  dP = 20.63 MW (scheduled 30.0 MW)
    bus 2 (2)  +20.63 MW beyond schedule

The 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:

# uses: load_case, print_beyond_schedule (warm-up cell)
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:166
fallback ref_only: solved classically, the slack absorbs everything again
    bus 1 (1)  +20.58 MW beyond schedule

Reading 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