Skip to main content

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}