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 ¶ms {
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}