Skip to main content

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}