gridoxide_core/outerloop/slack.rs
1//! Distributed slack: sharing the system imbalance across generators instead
2//! of dumping all of it on one bus.
3//!
4//! A single slack bus absorbs every megawatt the rest of the schedule does not
5//! account for, transmission losses included. On a real network that is a few
6//! hundred megawatts appearing at one point, and every branch flow around it is
7//! distorted by the difference. Distributing the imbalance by participation
8//! weight is what the reference tools all do instead.
9//!
10//! # Why it converges, and why not in one pass
11//!
12//! Write the slack's excess over its own schedule as \\(\Delta\\), and let
13//! \\(\alpha_s\\) be the slack's own share. Distributing adds
14//! \\((1-\alpha_s)\Delta\\) to the *other* participants' schedules, so the next
15//! solve asks the slack for that much less while its own schedule has risen by
16//! \\(\alpha_s\Delta\\). Those cancel exactly, which is what makes this
17//! affordable as an outer loop.
18//!
19//! What is left over is the change in transmission losses caused by the
20//! redistributed flows, and that does not vanish — it shrinks. Convergence is
21//! therefore linear at roughly the fractional loss sensitivity, a few per cent
22//! per pass. Measured on pglib at `tolerance = 1e-8`: `case14_ieee`,
23//! `case30_ieee` and `case118_ieee` each take **seven** passes, moving 2.3,
24//! 2.4 and 16.5 per-unit off their slacks respectively.
25//!
26//! # The schedule is the slack's own `p_spec`
27//!
28//! Ordinary power flow ignores that field for a slack bus — the slack's output
29//! is an *answer*, not an input — so it is free to carry the schedule here.
30//! Worth knowing that a document which never needed it may leave it at zero, in
31//! which case the slack is treated as scheduled for nothing and its entire
32//! output is redistributed.
33
34use crate::network::{effective_injection, power_injections};
35use crate::types::{Bus, BusType};
36
37use super::{Invalidates, OuterLoop, OuterLoopContext, OuterLoopStatus};
38
39/// Who participates in picking up the imbalance, and by how much.
40///
41/// Weights are normalized **per island**, which is the only reading that works
42/// on a disconnected network: each island has its own slack and its own
43/// imbalance, and a global normalization would size one island's correction by
44/// another island's generators.
45#[derive(Clone, Debug)]
46pub struct SlackDistribution {
47 /// Participation weight per bus, indexed by [`Bus::idx`].
48 pub factors: Vec<f64>,
49 /// Convergence tolerance on the slack's remaining deviation from its
50 /// schedule, per-unit.
51 pub tolerance: f64,
52 /// Cap on outer passes. Around seven is typical at the default tolerance.
53 pub max_outer_iter: usize,
54}
55
56impl SlackDistribution {
57 /// Every generator bus — `Slack` and `PV` — takes an equal share.
58 ///
59 /// `PQ` buses are excluded even when they carry positive `p_spec`. A
60 /// positive injection at a `PQ` bus is a fixed schedule, not a machine
61 /// under governor control, and the distinction is exactly what
62 /// participation means.
63 pub fn uniform(buses: &[Bus]) -> Self {
64 let factors = buses
65 .iter()
66 .map(|b| match b.bus_type {
67 BusType::Slack | BusType::PV => 1.0,
68 BusType::PQ => 0.0,
69 })
70 .collect();
71 Self {
72 factors,
73 tolerance: 1e-8,
74 max_outer_iter: 20,
75 }
76 }
77
78 /// Explicit per-bus weights.
79 pub fn from_weights(factors: Vec<f64>) -> Self {
80 Self {
81 factors,
82 tolerance: 1e-8,
83 max_outer_iter: 20,
84 }
85 }
86}
87
88/// What [`DistributedSlack`] did.
89#[derive(Clone, Debug, Default)]
90pub struct SlackDistributionReport {
91 /// Per bus, how much its active schedule moved, per-unit. Summing this
92 /// over an island gives that island's total losses-plus-imbalance.
93 pub shift: Vec<f64>,
94 /// Each island's remaining slack deviation at exit, in the order the
95 /// island reports come back. Islands that could not be distributed over
96 /// carry their untouched deviation here rather than a zero.
97 pub residual: Vec<f64>,
98 /// Outer passes taken.
99 pub outer_iterations: usize,
100 /// False if the loop hit `max_outer_iter` with a deviation still above
101 /// tolerance, or if the inner solve stopped converging.
102 pub converged: bool,
103 /// Islands that were left on a single slack, and why — no reference bus,
104 /// an ambiguous one, or no participating generator in that island.
105 pub undistributed: Vec<(usize, &'static str)>,
106}
107
108/// Moves the slack's deviation from its own schedule onto the participating
109/// generators, by normalized weight, until the deviation is inside tolerance.
110///
111/// Reports [`Invalidates::Nothing`]: only `p_spec` changes between passes, so
112/// bus types, `n_unknowns` and the Jacobian's sparsity pattern all hold and the
113/// solver keeps its symbolic factorization across the whole loop. That is the
114/// opposite of [`ReactiveLimits`](super::ReactiveLimits), which switches
115/// `PV → PQ` and must reset each time it does.
116///
117/// # What it mutates
118///
119/// `buses[i].p_spec` ends up holding the **dispatched** value rather than the
120/// schedule it went in with, for every participating bus. That is the answer —
121/// who ended up producing what — and [`SlackDistributionReport::shift`] records
122/// how far each moved, so the original is recoverable.
123#[derive(Clone, Debug)]
124pub struct DistributedSlack {
125 distribution: SlackDistribution,
126 report: SlackDistributionReport,
127 /// Set once the pass cap is hit, so the pass after the final redistribution
128 /// accepts the state rather than starting another round.
129 exhausted: bool,
130}
131
132impl DistributedSlack {
133 pub fn new(distribution: SlackDistribution) -> Self {
134 Self {
135 distribution,
136 report: SlackDistributionReport::default(),
137 exhausted: false,
138 }
139 }
140
141 /// What it did, readable after the solve.
142 pub fn report(&self) -> &SlackDistributionReport {
143 &self.report
144 }
145
146 /// Consumes the loop for its report, for callers that do not need it back.
147 pub fn into_report(self) -> SlackDistributionReport {
148 self.report
149 }
150}
151
152impl OuterLoop for DistributedSlack {
153 fn name(&self) -> &'static str {
154 "DistributedSlack"
155 }
156
157 fn invalidates(&self) -> Invalidates {
158 Invalidates::Nothing
159 }
160
161 fn initialize(&mut self, ctx: &mut OuterLoopContext<'_, '_>) {
162 let n = ctx.net.buses.len();
163 assert_eq!(
164 self.distribution.factors.len(),
165 n,
166 "participation factors must carry one weight per bus"
167 );
168 self.report.shift = vec![0.0; n];
169 }
170
171 fn check(&mut self, ctx: &mut OuterLoopContext<'_, '_>) -> OuterLoopStatus {
172 if self.exhausted {
173 return OuterLoopStatus::Stable;
174 }
175 self.report.outer_iterations += 1;
176
177 let (p_calc, _) = power_injections(ctx.net.buses, ctx.net.ybus);
178 let mut residual = Vec::with_capacity(ctx.islands.len());
179 let mut undistributed = Vec::new();
180 let mut worst = 0.0f64;
181 // Collected first, applied after, so the deviation every island is
182 // measured against comes from one consistent solved state.
183 let mut updates: Vec<(usize, f64)> = Vec::new();
184
185 for (island, report) in ctx.islands.iter().enumerate() {
186 let slack = match report.slack_indices.as_slice() {
187 [only] => *only,
188 [] => {
189 residual.push(0.0);
190 undistributed.push((island, "no reference bus"));
191 continue;
192 }
193 _ => {
194 residual.push(0.0);
195 undistributed.push((island, "ambiguous reference bus"));
196 continue;
197 }
198 };
199
200 let scheduled = effective_injection(&ctx.net.buses[slack]).0;
201 let delta = p_calc[slack] - scheduled;
202
203 let total: f64 = report
204 .bus_indices
205 .iter()
206 .map(|&i| self.distribution.factors[i].max(0.0))
207 .sum();
208 if total <= 0.0 {
209 residual.push(delta);
210 undistributed.push((island, "no participating generator in this island"));
211 continue;
212 }
213
214 residual.push(delta);
215 worst = worst.max(delta.abs());
216 for &i in &report.bus_indices {
217 let weight = self.distribution.factors[i].max(0.0);
218 if weight > 0.0 {
219 updates.push((i, weight / total * delta));
220 }
221 }
222 }
223
224 self.report.residual = residual;
225 self.report.undistributed = undistributed;
226
227 if worst <= self.distribution.tolerance {
228 self.report.converged = true;
229 return OuterLoopStatus::Stable;
230 }
231
232 for (i, amount) in updates {
233 ctx.net.buses[i].p_spec += amount;
234 self.report.shift[i] += amount;
235 }
236
237 // The cap is spent on this redistribution: re-solve once more so the
238 // returned state matches the final schedules, then accept it.
239 if self.report.outer_iterations >= self.distribution.max_outer_iter.max(1) {
240 self.exhausted = true;
241 }
242 OuterLoopStatus::Unstable
243 }
244}