gridoxide_core/outerloop/qlimits.rs
1//! Reactive-limit enforcement: the `PV → PQ` switch.
2//!
3//! A `PV` bus holds its voltage by producing whatever reactive power that
4//! takes. A real machine cannot: past `q_max` (or below `q_min`) it saturates,
5//! the voltage falls away from target, and the bus is no longer a `PV` bus at
6//! all — it is a `PQ` bus injecting exactly its limit.
7//!
8//! This is the standard MATPOWER-style **one-directional** switch: a bus that
9//! moves `PV → PQ` stays there for the rest of the run. The reverse switch
10//! (a `PQ`-clamped bus whose voltage overshoots its target, which should
11//! return to `PV`) is deliberately not implemented — it is where the
12//! literature's oscillation reports come from, and the one-directional form is
13//! what every reference gridoxide is checked against uses by default.
14
15use crate::network::power_injections;
16use crate::types::BusType;
17
18use super::{Invalidates, OuterLoop, OuterLoopContext, OuterLoopStatus};
19
20/// Which buses this loop moved off voltage control, and whether it settled.
21#[derive(Clone, Debug, Default)]
22pub struct QLimitReport {
23 /// Buses switched `PV → PQ`, in switch order.
24 pub switches: Vec<usize>,
25}
26
27/// Enforces `q_min`/`q_max` on every `PV` bus by switching violators to `PQ`.
28///
29/// Reports [`Invalidates::Pattern`]: a bus type change moves `n_unknowns`, so
30/// the Jacobian's sparsity pattern changes and the cached symbolic
31/// factorization cannot be kept. That is the opposite of
32/// [`DistributedSlack`](super::DistributedSlack), which only moves `p_spec`.
33#[derive(Clone, Debug, Default)]
34pub struct ReactiveLimits {
35 switches: Vec<usize>,
36}
37
38impl ReactiveLimits {
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 /// What it did, readable after the solve.
44 pub fn report(&self) -> QLimitReport {
45 QLimitReport {
46 switches: self.switches.clone(),
47 }
48 }
49
50 /// Buses switched `PV → PQ`, in switch order.
51 pub fn switches(&self) -> &[usize] {
52 &self.switches
53 }
54}
55
56impl OuterLoop for ReactiveLimits {
57 fn name(&self) -> &'static str {
58 "ReactiveLimits"
59 }
60
61 fn invalidates(&self) -> Invalidates {
62 Invalidates::Pattern
63 }
64
65 fn check(&mut self, ctx: &mut OuterLoopContext<'_, '_>) -> OuterLoopStatus {
66 let (_, q_calc) = power_injections(ctx.net.buses, ctx.net.ybus);
67 let mut switched = false;
68 for b in ctx.net.buses.iter_mut() {
69 if b.bus_type != BusType::PV {
70 continue;
71 }
72 let q = q_calc[b.idx];
73 if q < b.q_min {
74 b.bus_type = BusType::PQ;
75 b.q_spec = b.q_min;
76 self.switches.push(b.idx);
77 switched = true;
78 } else if q > b.q_max {
79 b.bus_type = BusType::PQ;
80 b.q_spec = b.q_max;
81 self.switches.push(b.idx);
82 switched = true;
83 }
84 }
85 if switched {
86 OuterLoopStatus::Unstable
87 } else {
88 OuterLoopStatus::Stable
89 }
90 }
91}