gridoxide_core/outerloop/mod.rs
1//! Outer loops: the controls that sit *around* a Newton-Raphson solve.
2//!
3//! A Newton solve answers "what is the state, given these injections, these
4//! bus types and these tap positions". Several real controls are not
5//! expressible that way, because they decide one of those *inputs* from the
6//! solved state: a generator that runs out of reactive capability stops
7//! holding its voltage, an imbalance is shared across governors rather than
8//! dumped on one slack, an on-load tap changer moves until its controlled bus
9//! is inside a deadband. Each is a loop around the solve, not a term inside
10//! it.
11//!
12//! # Why this module exists
13//!
14//! gridoxide grew two such loops as standalone entry points —
15//! `newton_raphson_enforcing_q_limits` and
16//! `newton_raphson_distributing_slack` — each constructing its own
17//! [`PersistentSolver`] and running its own
18//! pass counter. The consequence was that **they could not be used together**:
19//! a caller picked one. Real transmission networks want both, and tap control
20//! would have been a third.
21//!
22//! So the loops are [`OuterLoop`] implementations now, and
23//! [`solve_with_loops`] drives an ordered list of them.
24//!
25//! # Ordering is a physical claim
26//!
27//! The list is **innermost first**. Each loop is run to its own stability
28//! before the next is consulted, and any loop that moves something sends the
29//! driver back to the start of the list, so an outer loop's decision is
30//! re-examined by the inner ones. [`solve_with_loops`] documents the schedule;
31//! [`ordered`] gives the order powsybl-open-loadflow uses, which encodes
32//! that generators respond faster than tap changers.
33//!
34//! # Invalidation is the driver's job, never a loop's
35//!
36//! Three things must happen together after a tap move — write
37//! [`Transformer::tap`](crate::types::Transformer::tap), restamp the Y-bus
38//! entries derived from it, drop the cached Jacobian — and a loop that does
39//! one or two of them is a bug that surfaces as a wrong answer on the *next*
40//! pass rather than as a failure on this one. A loop therefore declares what
41//! it invalidated ([`Invalidates`]) and the driver acts on it.
42
43use crate::network::ShuntAdm;
44use crate::network::{YBusSparse, build_ybus, stamp_shunts};
45use crate::solver::{IslandReport, IslandStatus, JacobianBackend, PersistentSolver};
46use crate::types::{Bus, Line, TapChanger, Transformer};
47
48pub mod area;
49pub mod qlimits;
50pub mod remote;
51pub mod slack;
52pub mod taps;
53
54pub use area::{AreaDefinition, AreaInterchange, AreaInterchangeReport};
55pub use qlimits::{QLimitReport, ReactiveLimits};
56pub use remote::{RemoteControlReport, RemoteOutcome, RemoteVoltageControl};
57pub use slack::{DistributedSlack, SlackDistribution, SlackDistributionReport};
58pub use taps::{
59 ControllerOutcome, ControllerReport, PhaseControl, RegulationMode, TapControlReport,
60 TapRegulation, TransformerVoltageControl,
61};
62
63/// What a loop decided after looking at a converged state.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum OuterLoopStatus {
66 /// Nothing to do — this loop's criterion is met.
67 Stable,
68 /// Something was changed; the network needs re-solving.
69 Unstable,
70 /// This loop cannot proceed, and the whole run should stop.
71 Failed(String),
72}
73
74/// What a loop invalidated when it reported [`OuterLoopStatus::Unstable`].
75///
76/// Returned by [`OuterLoop::invalidates`] and acted on by the driver — see the
77/// module docs for why the loop does not act on it itself.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum Invalidates {
80 /// Only bus *values* moved (`p_spec`, `q_spec`). The Jacobian's pattern and
81 /// its cached admittances are both still valid. Distributed slack.
82 Nothing,
83 /// `bus_type` moved, so `n_unknowns` and the Jacobian's sparsity pattern
84 /// did too. Forces a full [`PersistentSolver::reset`]. Reactive limits.
85 Pattern,
86 /// [`Transformer::tap`] moved, so the Y-bus *values* did. The pattern
87 /// survives, so the symbolic factorization is kept and only the cached
88 /// admittances are re-derived — the same position a switch flip is in.
89 /// Both tap loops.
90 Admittances,
91}
92
93/// The mutable network a loop is allowed to act on, plus the last solve's
94/// result.
95///
96/// powsybl passes a network object here; gridoxide has none — the
97/// parallel-`Vec` convention is crate-wide — so this bundle of borrows is the
98/// honest translation.
99pub struct OuterLoopContext<'a, 'n> {
100 pub net: &'a mut SolveContext<'n>,
101 /// The islands the last inner solve reported. Never empty when a loop's
102 /// `check` is called: the driver stops before that if the solve did not
103 /// settle.
104 pub islands: &'a [IslandReport],
105 /// How many times *this* loop has already reported `Unstable`.
106 pub iteration: usize,
107}
108
109/// The network a solve runs against.
110///
111/// Constructed once by the caller and passed to [`solve_with_loops`]. The
112/// branch and tap fields are optional in the sense that a caller with no tap
113/// control simply never supplies them — [`SolveContext::new`] leaves them
114/// empty, and only [`Invalidates::Admittances`] loops read them.
115pub struct SolveContext<'a> {
116 pub buses: &'a mut [Bus],
117 pub lines: &'a [Line],
118 pub transformers: &'a mut [Transformer],
119 pub shunts: &'a [ShuntAdm],
120 /// Parallel to `transformers`. Empty when the importer retained no tap
121 /// tables, which is what makes tap control silently inapplicable rather
122 /// than an error.
123 pub tap_changers: &'a mut [Option<TapChanger>],
124 /// The regulating controls read from the network document.
125 pub regulation: &'a [TapRegulation],
126 pub ybus: &'a mut YBusSparse,
127}
128
129impl<'a> SolveContext<'a> {
130 /// Buses and a Y-bus: everything a loop that never moves a tap needs.
131 pub fn new(buses: &'a mut [Bus], ybus: &'a mut YBusSparse) -> Self {
132 Self {
133 buses,
134 lines: &[],
135 transformers: &mut [],
136 shunts: &[],
137 tap_changers: &mut [],
138 regulation: &[],
139 ybus,
140 }
141 }
142
143 /// The branch data the Y-bus is built from. Required before any tap loop
144 /// can restamp it.
145 pub fn with_branches(
146 mut self,
147 lines: &'a [Line],
148 transformers: &'a mut [Transformer],
149 shunts: &'a [ShuntAdm],
150 ) -> Self {
151 self.lines = lines;
152 self.transformers = transformers;
153 self.shunts = shunts;
154 self
155 }
156
157 /// The tap tables and the regulating controls that act on them.
158 pub fn with_taps(
159 mut self,
160 tap_changers: &'a mut [Option<TapChanger>],
161 regulation: &'a [TapRegulation],
162 ) -> Self {
163 self.tap_changers = tap_changers;
164 self.regulation = regulation;
165 self
166 }
167
168 /// Rebuild the Y-bus from the current branch data.
169 ///
170 /// Called by the driver after an [`Invalidates::Admittances`] loop moved a
171 /// tap. A tap change alters admittance *values* and not the sparsity
172 /// pattern, so this produces a structurally identical matrix and the
173 /// symbolic factorization stays valid.
174 ///
175 /// Does nothing when the caller supplied no branch data, since there is
176 /// then nothing to rebuild *from* — a tap loop cannot be configured in
177 /// that case either, so this is unreachable rather than lossy.
178 pub fn restamp_ybus(&mut self) {
179 if self.lines.is_empty() && self.transformers.is_empty() {
180 return;
181 }
182 let n = self.ybus.n();
183 let mut y = build_ybus(n, self.lines, self.transformers);
184 stamp_shunts(&mut y, self.shunts);
185 *self.ybus = y.finish();
186 }
187}
188
189/// What a loop is allowed to do to a solve.
190///
191/// Deliberately narrower than powsybl's `OuterLoop`, which is ServiceLoader-
192/// discovered so third parties can register their own. That is a
193/// Java-ecosystem affordance; here the list is built by the crate, and the
194/// abstraction exists to make the loops *compose*, not to be extended from
195/// outside.
196pub trait OuterLoop {
197 fn name(&self) -> &'static str;
198
199 /// What this loop invalidates when it reports
200 /// [`OuterLoopStatus::Unstable`]. Read by the driver, which then resets
201 /// or restamps as required. See the module docs.
202 fn invalidates(&self) -> Invalidates {
203 Invalidates::Nothing
204 }
205
206 /// Called once, before the first solve, with the network in its initial
207 /// state. The place to record starting tap positions or bus types.
208 fn initialize(&mut self, _ctx: &mut OuterLoopContext<'_, '_>) {}
209
210 /// Look at the converged state and either accept it or change something.
211 fn check(&mut self, ctx: &mut OuterLoopContext<'_, '_>) -> OuterLoopStatus;
212}
213
214/// Per-loop accounting from one [`solve_with_loops`] run.
215#[derive(Clone, Debug)]
216pub struct LoopReport {
217 pub name: &'static str,
218 /// How many times this loop reported `Unstable`, i.e. how many re-solves
219 /// it caused.
220 pub iterations: usize,
221 /// The status it finished on.
222 pub status: OuterLoopStatus,
223}
224
225/// What the whole outer-loop run did.
226#[derive(Clone, Debug)]
227pub struct OuterLoopReport {
228 pub loops: Vec<LoopReport>,
229 /// Re-solves across every loop.
230 pub total_iterations: usize,
231 /// Inner Newton solves run, including the first one.
232 pub solves: usize,
233 /// True when every loop reported `Stable` on the final pass. False when
234 /// the budget ran out, a loop failed, or an inner solve stopped
235 /// converging.
236 pub converged: bool,
237 /// Set when the run stopped because `budget` was reached.
238 pub budget_exhausted: bool,
239}
240
241impl OuterLoopReport {
242 /// The report of the named loop, if it ran.
243 pub fn loop_named(&self, name: &str) -> Option<&LoopReport> {
244 self.loops.iter().find(|l| l.name == name)
245 }
246}
247
248/// The order powsybl-open-loadflow applies by default, restricted to the loops
249/// gridoxide has: distributed slack innermost, then reactive limits, then the
250/// two tap controls.
251///
252/// The ordering is a physical claim rather than a convention — generators
253/// respond faster than tap changers, so a tap should be chosen against a
254/// reactive dispatch that has already settled, and re-examined when the tap
255/// move disturbs it.
256/// The loops are passed as `&mut dyn` rather than owned boxes so a caller
257/// keeps its concrete values and can read their reports — `DistributedSlack`'s
258/// shifts, `ReactiveLimits`' switches, a tap loop's per-controller outcomes —
259/// after the run, without downcasting.
260pub fn ordered<'a>(
261 slack: Option<&'a mut DistributedSlack>,
262 qlimits: Option<&'a mut ReactiveLimits>,
263 phase: Option<&'a mut PhaseControl>,
264 voltage: Option<&'a mut TransformerVoltageControl>,
265) -> Vec<&'a mut dyn OuterLoop> {
266 let mut loops: Vec<&mut dyn OuterLoop> = Vec::new();
267 if let Some(l) = slack {
268 loops.push(l);
269 }
270 if let Some(l) = qlimits {
271 loops.push(l);
272 }
273 if let Some(l) = phase {
274 loops.push(l);
275 }
276 if let Some(l) = voltage {
277 loops.push(l);
278 }
279 loops
280}
281
282fn settled(reports: &[IslandReport]) -> bool {
283 reports.iter().all(|r| {
284 !matches!(
285 r.status,
286 IslandStatus::Singular | IslandStatus::MaxIterationsReached
287 )
288 })
289}
290
291/// Solve, then run an ordered list of outer loops to a fixed point.
292///
293/// # The schedule
294///
295/// Transcribed from powsybl-open-loadflow's `AcloadFlowEngine`, because it is
296/// not the obvious one:
297///
298/// - An initial solve runs first. If it does not settle, no loop is consulted
299/// at all — there is nothing useful a control can decide from a state that
300/// is not a power flow.
301/// - The loops are **nested, innermost first**. Each is run to *its own*
302/// stability (an inner `check`/re-solve loop) before the next is consulted.
303/// - Any loop reporting `Unstable` re-solves and becomes the "last unstable"
304/// loop. The driver then continues down the list, and wraps back to the
305/// start.
306/// - Termination is reaching the last-unstable loop again having changed
307/// nothing on the way: the whole list has been walked and every criterion
308/// holds simultaneously, which is what a fixed point of the combined
309/// controls means.
310///
311/// `budget` caps total re-solves across every loop, as one shared figure
312/// rather than a per-loop count. An empty `loops` makes this exactly one
313/// ordinary solve.
314pub fn solve_with_loops(
315 ctx: &mut SolveContext<'_>,
316 tol: f64,
317 max_iter: usize,
318 backend: JacobianBackend,
319 loops: &mut [&mut dyn OuterLoop],
320 budget: usize,
321) -> (Vec<IslandReport>, OuterLoopReport) {
322 let mut solver = PersistentSolver::new(backend);
323 let mut counts = vec![0usize; loops.len()];
324 let mut statuses = vec![OuterLoopStatus::Stable; loops.len()];
325
326 {
327 let mut lc = OuterLoopContext {
328 net: ctx,
329 islands: &[],
330 iteration: 0,
331 };
332 for l in loops.iter_mut() {
333 l.initialize(&mut lc);
334 }
335 }
336
337 let mut reports = solver.solve(ctx.buses, ctx.ybus, tol, max_iter);
338 let mut solves = 1usize;
339 let mut total = 0usize;
340 let mut budget_exhausted = false;
341
342 let finish = |reports: Vec<IslandReport>,
343 loops: &[&mut dyn OuterLoop],
344 counts: &[usize],
345 statuses: &[OuterLoopStatus],
346 total: usize,
347 solves: usize,
348 converged: bool,
349 budget_exhausted: bool| {
350 let report = OuterLoopReport {
351 loops: loops
352 .iter()
353 .enumerate()
354 .map(|(i, l)| LoopReport {
355 name: l.name(),
356 iterations: counts[i],
357 status: statuses[i].clone(),
358 })
359 .collect(),
360 total_iterations: total,
361 solves,
362 converged,
363 budget_exhausted,
364 };
365 (reports, report)
366 };
367
368 if loops.is_empty() || !settled(&reports) {
369 let ok = settled(&reports);
370 return finish(reports, loops, &counts, &statuses, total, solves, ok, false);
371 }
372
373 // Index of the loop that most recently reported `Unstable`. Reaching it
374 // again with nothing changed in between is the termination condition.
375 let mut last_unstable: Option<usize> = None;
376 let mut failed = false;
377
378 'outer: loop {
379 let before = total;
380
381 for i in 0..loops.len() {
382 if Some(i) == last_unstable {
383 // Walked the whole list; nothing else moved. Done.
384 break 'outer;
385 }
386 if total >= budget {
387 budget_exhausted = true;
388 break 'outer;
389 }
390
391 // Run this loop to its own stability before moving on.
392 loop {
393 let status = {
394 let mut lc = OuterLoopContext {
395 net: ctx,
396 islands: &reports,
397 iteration: counts[i],
398 };
399 loops[i].check(&mut lc)
400 };
401 statuses[i] = status.clone();
402
403 match status {
404 OuterLoopStatus::Stable => break,
405 OuterLoopStatus::Failed(_) => {
406 failed = true;
407 break 'outer;
408 }
409 OuterLoopStatus::Unstable => {
410 match loops[i].invalidates() {
411 Invalidates::Nothing => {}
412 Invalidates::Pattern => solver.reset(),
413 Invalidates::Admittances => {
414 ctx.restamp_ybus();
415 solver.invalidate_admittances();
416 }
417 }
418 reports = solver.solve(ctx.buses, ctx.ybus, tol, max_iter);
419 solves += 1;
420 counts[i] += 1;
421 total += 1;
422 last_unstable = Some(i);
423
424 if !settled(&reports) {
425 break 'outer;
426 }
427 if total >= budget {
428 budget_exhausted = true;
429 break 'outer;
430 }
431 }
432 }
433 }
434 }
435
436 // A full pass that changed nothing: every criterion holds.
437 if total == before {
438 break;
439 }
440 }
441
442 let converged = !failed
443 && !budget_exhausted
444 && settled(&reports)
445 && statuses.iter().all(|s| *s == OuterLoopStatus::Stable);
446 finish(
447 reports,
448 loops,
449 &counts,
450 &statuses,
451 total,
452 solves,
453 converged,
454 budget_exhausted,
455 )
456}