Skip to main content

gridoxide_core/outerloop/
area.rs

1//! Area interchange control: holding each control area's net export at its
2//! scheduled value.
3//!
4//! [`DistributedSlack`](super::DistributedSlack) drives one number to one
5//! target — the slack's output to its own schedule. Interconnected systems
6//! schedule more than that: each control area agrees a **net position** with
7//! its neighbours, and the area's generators are dispatched to meet it. An
8//! area importing 300 MW when it agreed to import 200 has every tie-line flow
9//! around it wrong, for the same reason a single slack absorbing an unscheduled
10//! few hundred megawatts does.
11//!
12//! # The formulation
13//!
14//! An area's **interchange** is the active power leaving it across its own
15//! boundary branches, measured at its own side of each:
16//!
17//! \\[ X_a = \sum_{b \in \partial a} P_b^{(a\text{-side})} \\]
18//!
19//! and its mismatch is that against the schedule. The knob is the same one
20//! distributed slack uses: the participating generators' `p_spec`.
21//!
22//! # One area's position is dependent, and it has to be
23//!
24//! The targets are **not independent**. Each area's side of a tie line is
25//! measured at its own terminal, *into* the branch, so both ends of a tie
26//! contribute positively and their sum is what the tie dissipates:
27//!
28//! \\[ \sum_a X_a = +\ell_{\text{tie}} \\]
29//!
30//! — a quantity nobody knows before the solve. (The sign is easy to get
31//! backwards: it is not that one area's export is the other's import, because
32//! the loss falls between them and belongs to neither.) Targets that sum to zero, which
33//! is what a set of agreed net positions looks like, are therefore unachievable
34//! by exactly the tie losses. Counting it out: the knobs are each area's
35//! aggregate participant schedule, \\(N\\) of them; the conditions wanted are
36//! \\(N\\) interchange targets plus the slack on its own schedule, \\(N+1\\).
37//! Over-determined by one, always.
38//!
39//! So **the area holding the slack is the dependent one**: its own target is
40//! not enforced, and its condition is that the slack produces its schedule
41//! instead. Every other area's position is met exactly, and the slack's area
42//! absorbs the tie losses — which is what a real interconnection does, and what
43//! makes one area's position a residual rather than an agreement.
44//! [`AreaInterchangeReport::dependent`] names it, and its
45//! [`residual`](AreaInterchangeReport::residual) entry reports how far its
46//! nominal target was missed, so the number is visible rather than implied.
47//!
48//! # Deriving the sign, which is the rest of the difficulty
49//!
50//! For an area that is not the slack's, an export above target means it should
51//! generate less, so the mismatch \\(X_a - X_a^{target}\\) is **subtracted**
52//! from the participants by weight.
53//!
54//! For the slack's area, write \\(\delta_s\\) for the slack's excess over its
55//! own schedule. Its participants must take that excess up — exactly
56//! [`DistributedSlack`](super::DistributedSlack)'s move — so the quantity
57//! subtracted is \\(-\delta_s\\).
58//!
59//! Worth checking the degenerate case, because it is also a test
60//! (`area_interchange_with_one_area_is_distributed_slack`): one area covering
61//! the whole network *is* the slack's area, so the only condition is
62//! \\(-\delta_s\\), and the loop becomes distributed slack exactly. Area
63//! interchange **generalizes** it rather than sitting beside it, which is why
64//! powsybl-open-loadflow's own `AcAreaInterchangeControlOuterLoop` constructs a
65//! `DistributedSlackOuterLoop` as its no-area fallback and why the two must not
66//! both be configured.
67
68use crate::branch_flow::{Terminal, branch_params, bus_voltages, terminal_flow};
69use crate::network::{effective_injection, power_injections};
70use crate::types::{Bus, BusType};
71
72use super::{Invalidates, OuterLoop, OuterLoopContext, OuterLoopStatus};
73
74/// Which buses belong to which control area, what each area is scheduled to
75/// export, and who adjusts to get there.
76#[derive(Clone, Debug)]
77pub struct AreaDefinition {
78    /// Area index per bus, indexed by [`Bus::idx`]. `None` means the bus is in
79    /// no area — its injections are nobody's to schedule, and any branch it
80    /// shares with an area counts as that area's boundary.
81    pub of_bus: Vec<Option<usize>>,
82    /// Scheduled net export per area, per-unit. **Positive is exporting**, the
83    /// sign convention ENTSO-E net positions use.
84    pub targets: Vec<f64>,
85    /// Participation weight per bus. Normalized **within each area**, so only
86    /// relative magnitudes matter and raw megawatt headroom can be passed.
87    pub factors: Vec<f64>,
88    /// Convergence tolerance on an area's remaining mismatch, per-unit.
89    pub tolerance: f64,
90    /// Cap on outer passes.
91    pub max_outer_iter: usize,
92}
93
94impl AreaDefinition {
95    /// Every generator bus — `Slack` and `PV` — participates equally within its
96    /// own area, with every area scheduled for zero net export.
97    ///
98    /// A zero target is rarely what a real schedule says, but it is the
99    /// meaningful default: it asks each area to serve its own load, which is
100    /// the reading of "no interchange agreed".
101    pub fn uniform(buses: &[Bus], of_bus: Vec<Option<usize>>, n_areas: usize) -> Self {
102        let factors = buses
103            .iter()
104            .map(|b| match b.bus_type {
105                BusType::Slack | BusType::PV => 1.0,
106                BusType::PQ => 0.0,
107            })
108            .collect();
109        Self {
110            of_bus,
111            targets: vec![0.0; n_areas],
112            factors,
113            tolerance: 1e-8,
114            max_outer_iter: 30,
115        }
116    }
117
118    /// Weighted by each bus's own scheduled generation, whatever its bus type.
119    ///
120    /// # Why this differs from [`uniform`](Self::uniform), and from distributed slack
121    ///
122    /// [`SlackDistribution::uniform`](super::SlackDistribution::uniform)
123    /// participates `Slack` and `PV` buses only, on the grounds that a positive
124    /// injection at a `PQ` bus is a fixed schedule rather than a machine under
125    /// governor control. That reasoning is about **frequency** response, and it
126    /// is right: a machine not on governor control does not pick up imbalance.
127    ///
128    /// A net position is not frequency response. It is met by **redispatch** —
129    /// a scheduling action over the hour — and a generator held at a fixed
130    /// active set-point is precisely the machine an operator redispatches.
131    /// Excluding it makes whole areas uncontrollable: in
132    /// `two_area_case.xiidm`, both of AREA2's generators are
133    /// `voltageRegulatorOn="false"` and so arrive as `PQ` buses, leaving that
134    /// area with no participant at all under `uniform` and its −400 MW
135    /// schedule unreachable.
136    ///
137    /// So this weights by `p_spec.max(0.0)` at every bus. An area whose
138    /// generation is all at fixed set-points is dispatchable; a load bus, with
139    /// no positive injection, still is not.
140    pub fn by_generation(buses: &[Bus], of_bus: Vec<Option<usize>>, n_areas: usize) -> Self {
141        let factors = buses.iter().map(|b| b.p_spec.max(0.0)).collect();
142        Self {
143            of_bus,
144            targets: vec![0.0; n_areas],
145            factors,
146            tolerance: 1e-8,
147            max_outer_iter: 30,
148        }
149    }
150
151    fn n_areas(&self) -> usize {
152        self.targets.len()
153    }
154}
155
156/// What [`AreaInterchange`] did.
157#[derive(Clone, Debug, Default)]
158pub struct AreaInterchangeReport {
159    /// Per area, its measured net export at exit, per-unit.
160    pub interchange: Vec<f64>,
161    /// Per area, how far its interchange is from its target at exit. The
162    /// [`dependent`](Self::dependent) area's entry is a *reported* residual
163    /// rather than a driven one — see the module docs.
164    pub residual: Vec<f64>,
165    /// The area holding the slack, whose own target is not enforced because the
166    /// system is over-determined by one. `None` when the slack is in no area,
167    /// in which case nothing drives the slack to its schedule and
168    /// [`unbalanced`](Self::unbalanced) says so.
169    pub dependent: Option<usize>,
170    /// Per bus, how much its active schedule moved, per-unit.
171    pub shift: Vec<f64>,
172    pub outer_iterations: usize,
173    /// False if the pass cap was hit with an area still off its schedule, or
174    /// if an inner solve stopped converging.
175    pub converged: bool,
176    /// Areas left alone, and why — no participating generator, or a boundary
177    /// this network does not have.
178    pub unbalanced: Vec<(usize, &'static str)>,
179}
180
181/// Drives every area's net export to its scheduled value, and the slack to its
182/// own schedule, by moving the participating generators.
183///
184/// Reports [`Invalidates::Nothing`] for the same reason
185/// [`DistributedSlack`](super::DistributedSlack) does: only `p_spec` moves, so
186/// bus types, `n_unknowns` and the Jacobian's sparsity pattern all hold, and
187/// the solver keeps its symbolic factorization across the whole loop.
188///
189/// **Do not configure this alongside `DistributedSlack`.** It subsumes it (see
190/// the module docs), and running both would move the same schedules twice.
191#[derive(Clone, Debug)]
192pub struct AreaInterchange {
193    areas: AreaDefinition,
194    report: AreaInterchangeReport,
195    exhausted: bool,
196}
197
198impl AreaInterchange {
199    pub fn new(areas: AreaDefinition) -> Self {
200        Self {
201            areas,
202            report: AreaInterchangeReport::default(),
203            exhausted: false,
204        }
205    }
206
207    pub fn report(&self) -> &AreaInterchangeReport {
208        &self.report
209    }
210
211    pub fn into_report(self) -> AreaInterchangeReport {
212        self.report
213    }
214
215    /// Each area's net export at the current state, measured at the area's own
216    /// side of every boundary branch.
217    ///
218    /// Public because it is useful on its own: "what is this area actually
219    /// exchanging" is a question worth asking of a solved network without
220    /// running a control loop over it.
221    pub fn measure(
222        buses: &[Bus],
223        lines: &[crate::types::Line],
224        transformers: &[crate::types::Transformer],
225        of_bus: &[Option<usize>],
226        n_areas: usize,
227    ) -> Vec<f64> {
228        let params = branch_params(lines, transformers);
229        let v = bus_voltages(buses);
230        let mut interchange = vec![0.0; n_areas];
231        for bp in &params {
232            let (a_from, a_to) = (of_bus[bp.from], of_bus[bp.to]);
233            if a_from == a_to {
234                // Internal to one area, or outside every area. Either way it
235                // crosses no boundary.
236                continue;
237            }
238            // Each side's own terminal flow is the power leaving that side's
239            // area into the branch. Taking each independently — rather than one
240            // and its negation — is what makes losses on the tie line itself
241            // land on neither area, which is the convention a net position
242            // uses.
243            if let Some(a) = a_from {
244                interchange[a] += terminal_flow(bp, Terminal::From, &v).0;
245            }
246            if let Some(a) = a_to {
247                interchange[a] += terminal_flow(bp, Terminal::To, &v).0;
248            }
249        }
250        interchange
251    }
252}
253
254impl OuterLoop for AreaInterchange {
255    fn name(&self) -> &'static str {
256        "AreaInterchange"
257    }
258
259    fn invalidates(&self) -> Invalidates {
260        Invalidates::Nothing
261    }
262
263    fn initialize(&mut self, ctx: &mut OuterLoopContext<'_, '_>) {
264        let n = ctx.net.buses.len();
265        assert_eq!(
266            self.areas.of_bus.len(),
267            n,
268            "area assignment must carry one entry per bus"
269        );
270        assert_eq!(
271            self.areas.factors.len(),
272            n,
273            "participation factors must carry one weight per bus"
274        );
275        self.report.shift = vec![0.0; n];
276        self.report.interchange = vec![0.0; self.areas.n_areas()];
277    }
278
279    fn check(&mut self, ctx: &mut OuterLoopContext<'_, '_>) -> OuterLoopStatus {
280        if self.exhausted {
281            return OuterLoopStatus::Stable;
282        }
283        if ctx.net.lines.is_empty() && ctx.net.transformers.is_empty() {
284            return OuterLoopStatus::Failed(
285                "area interchange needs the branch lists; build the SolveContext with \
286                 `with_branches`"
287                    .into(),
288            );
289        }
290        self.report.outer_iterations += 1;
291
292        let n_areas = self.areas.n_areas();
293        let interchange = Self::measure(
294            ctx.net.buses,
295            ctx.net.lines,
296            ctx.net.transformers,
297            &self.areas.of_bus,
298            n_areas,
299        );
300
301        // The slack's excess over its own schedule, attributed to the area it
302        // sits in. powsybl attributes it the same way; a slack outside every
303        // area has nowhere to put it, and is reported rather than ignored.
304        let (p_calc, _) = power_injections(ctx.net.buses, ctx.net.ybus);
305        let mut slack_delta = vec![0.0; n_areas];
306        let mut homeless_slack = false;
307        // The first area found holding a slack. With several islands there are
308        // several slacks; the first is enough, because an island with no
309        // reference has nothing to schedule against anyway and one with its own
310        // reference is balanced by its own participants.
311        let mut dependent: Option<usize> = None;
312        for island in ctx.islands {
313            let [slack] = island.slack_indices.as_slice() else {
314                continue;
315            };
316            let delta = p_calc[*slack] - effective_injection(&ctx.net.buses[*slack]).0;
317            match self.areas.of_bus[*slack] {
318                Some(a) => {
319                    slack_delta[a] += delta;
320                    dependent = dependent.or(Some(a));
321                }
322                None => homeless_slack = homeless_slack || delta.abs() > self.areas.tolerance,
323            }
324        }
325
326        let mut unbalanced = Vec::new();
327        if homeless_slack {
328            unbalanced.push((
329                usize::MAX,
330                "the slack is in no area; nothing drives it to its schedule",
331            ));
332        }
333
334        let mut residual = vec![0.0; n_areas];
335        let mut worst = 0.0f64;
336        // Collected first, applied after, so every area's mismatch is measured
337        // against one consistent solved state.
338        let mut updates: Vec<(usize, f64)> = Vec::new();
339
340        for a in 0..n_areas {
341            // The reported residual is always the same quantity — how far this
342            // area's position is from what was asked — whether or not the loop
343            // is driving it.
344            residual[a] = interchange[a] - self.areas.targets[a];
345
346            // What the loop actually drives. For the slack's area that is the
347            // slack's own deviation, not its position: the position is the
348            // dependent one, and it absorbs the tie losses. For every other
349            // area it is the position. See the module docs for the count that
350            // forces this.
351            let driven = if dependent == Some(a) {
352                -slack_delta[a]
353            } else {
354                residual[a]
355            };
356
357            let total: f64 = (0..ctx.net.buses.len())
358                .filter(|&i| self.areas.of_bus[i] == Some(a))
359                .map(|i| self.areas.factors[i].max(0.0))
360                .sum();
361            if total <= 0.0 {
362                if driven.abs() > self.areas.tolerance {
363                    unbalanced.push((a, "no participating generator in this area"));
364                }
365                continue;
366            }
367            worst = worst.max(driven.abs());
368            for i in 0..ctx.net.buses.len() {
369                if self.areas.of_bus[i] != Some(a) {
370                    continue;
371                }
372                let weight = self.areas.factors[i].max(0.0);
373                if weight > 0.0 {
374                    updates.push((i, -weight / total * driven));
375                }
376            }
377        }
378
379        self.report.dependent = dependent;
380        self.report.interchange = interchange;
381        self.report.residual = residual;
382        self.report.unbalanced = unbalanced;
383
384        if worst <= self.areas.tolerance {
385            self.report.converged = true;
386            return OuterLoopStatus::Stable;
387        }
388
389        for (i, amount) in updates {
390            ctx.net.buses[i].p_spec += amount;
391            self.report.shift[i] += amount;
392        }
393
394        // The cap is spent on this redistribution: re-solve once more so the
395        // returned state matches the final schedules, then accept it.
396        if self.report.outer_iterations >= self.areas.max_outer_iter.max(1) {
397            self.exhausted = true;
398        }
399        OuterLoopStatus::Unstable
400    }
401}