gridoxide_core/outerloop/remote.rs
1//! Remote voltage control: holding a bus the machine is not connected to.
2//!
3//! A generator regulating the far side of its own step-up transformer is
4//! ordinary, and gridoxide has always imported it — `RegulatingControl.Terminal`
5//! resolves to the controlled bus, which is then pinned to `PV` at the target.
6//! That gets the target right and the reactive power **in the wrong place**: it
7//! appears at the bus being held rather than at the machine, so the reactive
8//! flow on the path between them is missing and the machine's own bus sits at
9//! whatever the network makes it, rather than at whatever holding the far bus
10//! requires.
11//!
12//! # The formulation, and why this one
13//!
14//! Exactly, the problem is a small generalization of the `PV` bus. A `PV` bus
15//! today means two things at once — *this bus's magnitude is fixed* and *this
16//! bus's reactive injection is free* — and remote control simply separates
17//! them: fix `|V|` at the controlled bus, free `Q` at the controller. The
18//! unknown count still balances, one magnitude removed against one reactive
19//! equation removed.
20//!
21//! Implementing it that way means the unknown layout stops being derivable from
22//! `BusType`, which is the assumption `jacobian::JacobianPattern`,
23//! `solver::newton_raphson_cached`, `ac_sensitivity`, `bde`, `block_sparse` and
24//! `continuation::augmented` all share. That is a large change to the most
25//! load-bearing code in the crate, for a configuration two vendored fixtures
26//! have.
27//!
28//! This loop reaches the **same fixed point** without touching any of it. Make
29//! the *controller* bus `PV` — which is what makes its reactive power free, and
30//! free at the right bus — and drive its own setpoint until the controlled bus
31//! reaches the target. At convergence `|V|` at the controlled bus is the target
32//! and `Q` at the controller is whatever holding it takes, which is precisely
33//! what the exact formulation asserts. The difference is iteration, not answer,
34//! and iterating a control to a fixed point is what this module is for.
35//!
36//! It also inherits the reactive limits for free: the controller bus is a `PV`
37//! bus carrying the machine's own capability, so
38//! [`ReactiveLimits`](super::ReactiveLimits) clamps the machine rather than the
39//! bus it happens to be holding — which is the more correct bound as well as
40//! the easier one.
41//!
42//! # The step
43//!
44//! `Δ|V|_controller = gain · (target − |V|_controlled)`, with `gain` starting at
45//! 1.0 and refined by secant from the response actually observed.
46//!
47//! The initial 1.0 is not arbitrary: a machine holding the far side of its own
48//! transformer moves that bus nearly one-for-one in per-unit, so the first step
49//! lands close and the secant corrects the rest. A derived sensitivity
50//! (`ac_sensitivity` would need a voltage-setpoint variable, which it has no
51//! other caller for) is the better answer once a second fixture asks for it;
52//! deriving it for one case would be building the general thing on a sample of
53//! one.
54
55use crate::types::{Bus, BusType, RegulatingMachine};
56
57use super::{Invalidates, OuterLoop, OuterLoopContext, OuterLoopStatus};
58
59/// Below this response the controller cannot move its target and says so,
60/// rather than dividing by it and stepping to infinity. Mirrors
61/// `taps::MIN_SENSITIVITY`'s role.
62const MIN_RESPONSE: f64 = 1e-4;
63
64/// How far the setpoint may move in one pass, per-unit. The secant gain is
65/// exact only locally, so an early large step overshoots and oscillates;
66/// capping trades passes for stability, exactly as `max_tap_shift` does.
67const MAX_STEP: f64 = 0.1;
68
69/// What one remote controller did.
70#[derive(Clone, Debug, PartialEq)]
71pub struct RemoteControlReport {
72 pub machine: String,
73 pub controller_bus: usize,
74 pub controlled_bus: usize,
75 pub target: f64,
76 /// The controlled bus's voltage as the run left it.
77 pub reached: f64,
78 /// The controller's own setpoint as the run left it.
79 pub setpoint: f64,
80 pub moves: usize,
81 pub outcome: RemoteOutcome,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum RemoteOutcome {
86 /// Inside the deadband. The success case.
87 Held,
88 /// The controlled bus barely responds to this machine, so no setpoint
89 /// reaches the target. A property of the network, not a solver failure.
90 Insensitive,
91 /// The budget ran out before the deadband was reached.
92 Unfinished,
93 /// The machine ran out of reactive capability on the way, so it stopped
94 /// holding anything — `ReactiveLimits` switched its bus to `PQ` and this
95 /// controller is no longer in control.
96 AtReactiveLimit,
97}
98
99struct Controller {
100 machine: String,
101 controller_bus: usize,
102 controlled_bus: usize,
103 target: f64,
104 moves: usize,
105 /// The previous (setpoint, reached) pair, for the secant.
106 previous: Option<(f64, f64)>,
107 gain: f64,
108 outcome: RemoteOutcome,
109}
110
111/// Drives each remotely-regulating machine's own voltage until the bus it holds
112/// reaches its target.
113///
114/// See the module docs for why this is a loop rather than an equation.
115pub struct RemoteVoltageControl {
116 controllers: Vec<Controller>,
117 /// Every regulating machine, not just the remote ones — the capability a
118 /// bus ends up with is the sum of everything regulating *at* it, and a
119 /// controller bus may host locally-regulating machines too.
120 machines: Vec<RegulatingMachine>,
121 /// Full width, per-unit; a controller is satisfied within `±deadband/2`.
122 deadband: f64,
123 /// Buses this loop re-typed at `initialize`, so a caller can see that the
124 /// network it solved is not quite the one it handed over.
125 retyped: Vec<(usize, BusType, BusType)>,
126 /// Machines whose controlled bus is also held locally by something else —
127 /// reported rather than resolved, since two controls on one bus disagreeing
128 /// is a statement about the document, not a case to arbitrate.
129 contested: Vec<String>,
130}
131
132impl RemoteVoltageControl {
133 /// Builds a loop for every machine in `machines` that regulates a bus other
134 /// than its own. Machines under local control are left entirely alone —
135 /// they are already `PV` at the bus they hold, which is correct.
136 pub fn new(machines: &[RegulatingMachine]) -> Self {
137 let controllers = machines
138 .iter()
139 .filter(|m| m.at_bus != m.controls_bus)
140 .map(|m| Controller {
141 machine: m.id.clone(),
142 controller_bus: m.at_bus,
143 controlled_bus: m.controls_bus,
144 target: m.target_pu,
145 moves: 0,
146 previous: None,
147 gain: 1.0,
148 outcome: RemoteOutcome::Unfinished,
149 })
150 .collect();
151 Self {
152 controllers,
153 machines: machines.to_vec(),
154 deadband: 2e-4,
155 retyped: Vec::new(),
156 contested: Vec::new(),
157 }
158 }
159
160 /// Full deadband width, per-unit. Default `2e-4`.
161 pub fn deadband(mut self, width: f64) -> Self {
162 self.deadband = width.abs().max(1e-9);
163 self
164 }
165
166 pub fn is_empty(&self) -> bool {
167 self.controllers.is_empty()
168 }
169
170 /// Buses whose type this loop changed, as `(bus, from, to)`.
171 pub fn retyped(&self) -> &[(usize, BusType, BusType)] {
172 &self.retyped
173 }
174
175 /// Machines whose controlled bus is held locally as well. Their remote
176 /// control is dropped rather than fought over.
177 pub fn contested(&self) -> &[String] {
178 &self.contested
179 }
180
181 pub fn reports(&self) -> Vec<RemoteControlReport> {
182 self.controllers
183 .iter()
184 .map(|c| RemoteControlReport {
185 machine: c.machine.clone(),
186 controller_bus: c.controller_bus,
187 controlled_bus: c.controlled_bus,
188 target: c.target,
189 reached: f64::NAN,
190 setpoint: f64::NAN,
191 moves: c.moves,
192 outcome: c.outcome,
193 })
194 .collect()
195 }
196
197 /// As [`reports`](Self::reports), filled in from the state the run ended at.
198 pub fn report_against(&self, buses: &[Bus]) -> Vec<RemoteControlReport> {
199 self.controllers
200 .iter()
201 .map(|c| RemoteControlReport {
202 machine: c.machine.clone(),
203 controller_bus: c.controller_bus,
204 controlled_bus: c.controlled_bus,
205 target: c.target,
206 reached: buses[c.controlled_bus].voltage_mag,
207 setpoint: buses[c.controller_bus].voltage_mag,
208 moves: c.moves,
209 outcome: c.outcome,
210 })
211 .collect()
212 }
213}
214
215impl OuterLoop for RemoteVoltageControl {
216 fn name(&self) -> &'static str {
217 "RemoteVoltageControl"
218 }
219
220 fn invalidates(&self) -> Invalidates {
221 // Only the controller bus's own magnitude setpoint moves once running.
222 // The bus-type changes all happen in `initialize`, before any
223 // factorization exists to invalidate.
224 Invalidates::Nothing
225 }
226
227 /// Moves the control from the bus being held to the machine holding it.
228 ///
229 /// The importer pins the *controlled* bus to `PV`, which is where the
230 /// reactive power wrongly ends up. Here that is undone and the machine's own
231 /// bus is pinned instead, carrying the machine's own capability so the
232 /// reactive-limit clamp applies to the machine rather than to a bus it is
233 /// not connected to.
234 fn initialize(&mut self, ctx: &mut OuterLoopContext<'_, '_>) {
235 let buses = &mut *ctx.net.buses;
236
237 // A controlled bus that some *other* machine holds locally is already
238 // legitimately `PV`, and un-pinning it would break that control to
239 // serve this one. Neither answer is right, so the remote control is
240 // dropped and named.
241 let mut keep: Vec<bool> = vec![true; self.controllers.len()];
242 for (i, c) in self.controllers.iter().enumerate() {
243 let held_locally = self.controllers.iter().any(|o| {
244 o.controller_bus == c.controlled_bus && o.controlled_bus == c.controlled_bus
245 });
246 if held_locally {
247 keep[i] = false;
248 self.contested.push(c.machine.clone());
249 }
250 }
251 let mut idx = 0;
252 self.controllers.retain(|_| {
253 idx += 1;
254 keep[idx - 1]
255 });
256
257 // The capability has to move with the control, or the machine is
258 // clamped against a limit belonging to a bus it is not connected to —
259 // which for a bus with nothing else holding it means no limit at all.
260 // Summed over everything regulating *at* each bus, because a controller
261 // bus may host locally-regulating machines as well.
262 let capability = |bus: usize, machines: &[RegulatingMachine]| {
263 let here: Vec<&RegulatingMachine> =
264 machines.iter().filter(|m| m.at_bus == bus).collect();
265 if here.is_empty() {
266 return None;
267 }
268 Some((
269 here.iter().map(|m| m.q_min).sum::<f64>(),
270 here.iter().map(|m| m.q_max).sum::<f64>(),
271 ))
272 };
273
274 for c in &self.controllers {
275 let was = buses[c.controlled_bus].bus_type;
276 if was == BusType::PV {
277 buses[c.controlled_bus].bus_type = BusType::PQ;
278 // Whatever still regulates *at* this bus keeps its own
279 // capability; a bus left with nothing holding it is unlimited,
280 // which is "no constraint", not "no capability".
281 let (lo, hi) = capability(c.controlled_bus, &self.machines)
282 .unwrap_or((f64::NEG_INFINITY, f64::INFINITY));
283 buses[c.controlled_bus].q_min = lo;
284 buses[c.controlled_bus].q_max = hi;
285 self.retyped.push((c.controlled_bus, was, BusType::PQ));
286 }
287 }
288 for c in &self.controllers {
289 let was = buses[c.controller_bus].bus_type;
290 if was == BusType::PQ {
291 buses[c.controller_bus].bus_type = BusType::PV;
292 self.retyped.push((c.controller_bus, was, BusType::PV));
293 }
294 if was == BusType::Slack {
295 // A slack bus already fixes its own magnitude and already has a
296 // free reactive injection, so there is nothing for this loop to
297 // move; its target is simply unreachable through this machine.
298 continue;
299 }
300 if let Some((lo, hi)) = capability(c.controller_bus, &self.machines) {
301 buses[c.controller_bus].q_min = lo;
302 buses[c.controller_bus].q_max = hi;
303 }
304 // Start where the target is. In per-unit the two buses track each
305 // other closely across a transformer, so this lands near the answer
306 // and leaves the loop a short correction rather than a search.
307 buses[c.controller_bus].voltage_mag = c.target;
308 }
309 }
310
311 fn check(&mut self, ctx: &mut OuterLoopContext<'_, '_>) -> OuterLoopStatus {
312 let half = self.deadband / 2.0;
313 let mut moved = false;
314
315 for c in &mut self.controllers {
316 // A machine that ran out of reactive capability is no longer
317 // holding anything, and `ReactiveLimits` has already said so by
318 // switching its bus. Continuing to move a setpoint nothing reads
319 // would spend the budget to no effect.
320 if ctx.net.buses[c.controller_bus].bus_type != BusType::PV {
321 c.outcome = RemoteOutcome::AtReactiveLimit;
322 continue;
323 }
324
325 let reached = ctx.net.buses[c.controlled_bus].voltage_mag;
326 let error = c.target - reached;
327 if error.abs() <= half {
328 c.outcome = RemoteOutcome::Held;
329 continue;
330 }
331
332 let setpoint = ctx.net.buses[c.controller_bus].voltage_mag;
333 if let Some((prev_set, prev_reached)) = c.previous {
334 let d_set = setpoint - prev_set;
335 let d_reached = reached - prev_reached;
336 if d_set.abs() > 1e-12 {
337 let response = d_reached / d_set;
338 if response.abs() < MIN_RESPONSE {
339 c.outcome = RemoteOutcome::Insensitive;
340 continue;
341 }
342 c.gain = 1.0 / response;
343 }
344 }
345
346 let step = (c.gain * error).clamp(-MAX_STEP, MAX_STEP);
347 c.previous = Some((setpoint, reached));
348 ctx.net.buses[c.controller_bus].voltage_mag = setpoint + step;
349 c.moves += 1;
350 c.outcome = RemoteOutcome::Unfinished;
351 moved = true;
352 }
353
354 if moved {
355 OuterLoopStatus::Unstable
356 } else {
357 OuterLoopStatus::Stable
358 }
359 }
360}