Skip to main content

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}