Skip to main content

surge_network/network/
discrete_control.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Discrete voltage control devices: OLTC transformers and switched shunts.
3//!
4//! These models represent equipment that regulates bus voltage by taking
5//! discrete steps (tap changes or capacitor/reactor bank switching) rather
6//! than continuously varying a setpoint.  Because discrete steps cannot be
7//! expressed as differentiable constraints in the Newton-Raphson Jacobian,
8//! they are handled in an outer control loop that re-solves NR after each
9//! round of tap/shunt adjustments.
10
11use serde::{Deserialize, Serialize};
12use tracing::debug;
13
14/// On-Load Tap Changer (OLTC) control data for a transformer branch.
15///
16/// An OLTC regulates the voltage at a remote (or local) bus by stepping its
17/// tap ratio in discrete increments.  After Newton-Raphson converges, the
18/// solver checks each OLTC transformer:
19///
20/// 1. If `|vm[bus_regulated] - v_target| > v_band / 2`, tap is stepped toward
21///    the target and NR is re-solved.
22/// 2. Steps are bounded by `[tap_min, tap_max]`.
23/// 3. The loop terminates when all regulated bus voltages are within band or
24///    `oltc_max_iter` outer iterations are exhausted.
25///
26/// The `branch_index` field refers to the 0-based index into
27/// `PowerNetwork::branches`.  The tap adjustment modifies
28/// `branches[branch_index].tap` in place before each NR re-solve.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct OltcControl {
31    /// 0-based index into `Network::branches` for the transformer being controlled.
32    pub branch_index: usize,
33    /// 0-based index into `Network::buses` for the bus whose voltage is regulated.
34    ///
35    /// May differ from the transformer's from/to bus for remote voltage control.
36    pub bus_regulated: usize,
37    /// Voltage target in per-unit.
38    pub v_target: f64,
39    /// Dead-band half-width in per-unit (control activates when |V - v_target| > v_band / 2).
40    pub v_band: f64,
41    /// Minimum allowable tap ratio in per-unit (e.g. 0.9).
42    pub tap_min: f64,
43    /// Maximum allowable tap ratio in per-unit (e.g. 1.1).
44    pub tap_max: f64,
45    /// Discrete tap step size in per-unit (e.g. 0.00625 = 1/160, typical 16-step OLTC).
46    pub tap_step: f64,
47}
48
49impl OltcControl {
50    /// Create a standard OLTC with symmetric ±10 % range and 16 tap steps per side.
51    ///
52    /// `branch_index` — 0-based index of the transformer in `Network::branches`.
53    /// `bus_regulated` — 0-based bus index to regulate.
54    pub fn standard(branch_index: usize, bus_regulated: usize, v_target: f64) -> Self {
55        debug!(
56            branch_index,
57            bus_regulated, v_target, "creating standard OLTC control"
58        );
59        Self {
60            branch_index,
61            bus_regulated,
62            v_target,
63            v_band: 0.01, // ±0.005 p.u. dead-band
64            tap_min: 0.9,
65            tap_max: 1.1,
66            tap_step: 0.00625, // 1/160 — 16 steps over ±10 %
67        }
68    }
69}
70
71/// Switched shunt (capacitor/reactor bank) discrete voltage control.
72///
73/// A switched shunt injects reactive power in discrete MVAr steps to regulate
74/// bus voltage.  Capacitor banks raise voltage (positive susceptance); reactor
75/// banks lower voltage (negative susceptance).
76///
77/// The total shunt susceptance injected is:
78///   `B_total = b_step × n_active_steps`
79///
80/// where `n_active_steps` is in `[-n_steps_react, n_steps_cap]`.  The shunt
81/// susceptance is added to `buses[bus].shunt_susceptance_mvar` before each NR re-solve
82/// after converting from p.u. to MVAr using the network base MVA.
83///
84/// After NR converges, if `vm[bus_regulated]` is outside the voltage band, the
85/// solver increments or decrements `n_active_steps` by 1 and re-solves until
86/// the voltage is within band or the step limit is reached.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SwitchedShunt {
89    /// Stable switched-shunt identifier.
90    #[serde(default, skip_serializing_if = "String::is_empty")]
91    pub id: String,
92    /// External bus number for the bus that hosts this shunt (where the
93    /// susceptance is injected).
94    pub bus: u32,
95    /// External bus number whose voltage is regulated.
96    ///
97    /// Equal to `bus` for local regulation (the common case). May differ for
98    /// remote voltage regulation (PSS/E `SWREM` field points to another bus).
99    pub bus_regulated: u32,
100    /// Susceptance per step in per-unit (must be positive; reactors use n_steps_react).
101    pub b_step: f64,
102    /// Maximum number of capacitor steps (positive susceptance, raises voltage).
103    pub n_steps_cap: i32,
104    /// Maximum number of reactor steps (negative susceptance, lowers voltage).
105    pub n_steps_react: i32,
106    /// Voltage target in per-unit.
107    pub v_target: f64,
108    /// Dead-band half-width in per-unit (control activates when |V - v_target| > v_band / 2).
109    pub v_band: f64,
110    /// Current number of active steps (positive = capacitor, negative = reactor).
111    /// Initialised to 0 (all banks open).
112    pub n_active_steps: i32,
113}
114
115impl SwitchedShunt {
116    /// Create a capacitor-only switched shunt with `n_steps` equal-sized banks.
117    ///
118    /// `bus` — external bus number.
119    /// `b_total_cap_pu` — total capacitive susceptance in per-unit at full switching.
120    /// `n_steps` — number of discrete steps (banks).
121    pub fn capacitor_only(bus: u32, b_total_cap_pu: f64, n_steps: i32, v_target: f64) -> Self {
122        let b_step = if n_steps > 0 {
123            b_total_cap_pu / n_steps as f64
124        } else {
125            b_total_cap_pu
126        };
127        Self {
128            id: String::new(),
129            bus,
130            bus_regulated: bus,
131            b_step,
132            n_steps_cap: n_steps,
133            n_steps_react: 0,
134            v_target,
135            v_band: 0.02,
136            n_active_steps: 0,
137        }
138    }
139
140    /// Total susceptance currently injected in per-unit.
141    #[inline]
142    pub fn b_injected(&self) -> f64 {
143        let b = self.b_step * self.n_active_steps as f64;
144        debug!(
145            bus = self.bus,
146            n_active_steps = self.n_active_steps,
147            b_injected = b,
148            "switched shunt reactive injection"
149        );
150        b
151    }
152}
153
154/// OLTC specification stored in the network model, using external bus numbers.
155///
156/// Populated by the PSS/E parser from transformer control fields (COD1 = 1 or 2).
157/// At solve time the NR wrapper converts each `OltcSpec` to an [`OltcControl`]
158/// (0-indexed) so the discrete-control outer loop can act on it directly.
159///
160/// The regulated bus is the external bus whose voltage is held within
161/// `[v_target − v_band/2, v_target + v_band/2]`. A `regulated_bus` of zero
162/// means *local* regulation (the transformer's to-bus).
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct OltcSpec {
165    /// External bus number of the transformer from-bus.
166    pub from_bus: u32,
167    /// External bus number of the transformer to-bus.
168    pub to_bus: u32,
169    /// Circuit identifier string.
170    pub circuit: String,
171    /// External bus number whose voltage is regulated (0 → to-bus).
172    pub regulated_bus: u32,
173    /// Voltage target in per-unit.
174    pub v_target: f64,
175    /// Dead-band full-width in per-unit (control activates when |V − v_target| > v_band/2).
176    pub v_band: f64,
177    /// Minimum allowable tap ratio in per-unit.
178    pub tap_min: f64,
179    /// Maximum allowable tap ratio in per-unit.
180    pub tap_max: f64,
181    /// Discrete tap step size in per-unit.
182    pub tap_step: f64,
183}
184
185/// Phase Angle Regulator (PAR) specification stored in the network model.
186///
187/// Populated by the PSS/E parser from transformer control fields (COD1 = 3).
188/// At solve time the NR wrapper converts each `ParSpec` to a [`ParControl`]
189/// (0-indexed) so the discrete-control outer loop can act on it.
190///
191/// The PAR adjusts its phase-shift angle (ANG, degrees) in discrete steps to
192/// drive active power flow on the monitored branch toward a target band
193/// `[p_target_mw − p_band_mw/2, p_target_mw + p_band_mw/2]`.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ParSpec {
196    /// External bus number of the PAR transformer from-bus.
197    pub from_bus: u32,
198    /// External bus number of the PAR transformer to-bus.
199    pub to_bus: u32,
200    /// Circuit identifier string.
201    pub circuit: String,
202    /// External bus number of the monitored branch from-bus
203    /// (0 = monitor the PAR branch itself).
204    pub monitored_from_bus: u32,
205    /// External bus number of the monitored branch to-bus
206    /// (0 = monitor the PAR branch itself).
207    pub monitored_to_bus: u32,
208    /// Circuit of the monitored branch (used when monitored branch ≠ PAR branch).
209    pub monitored_circuit: String,
210    /// Target active power flow in MW (midpoint of control band).
211    pub p_target_mw: f64,
212    /// Dead-band full-width in MW.
213    pub p_band_mw: f64,
214    /// Minimum phase-angle shift in degrees.
215    #[serde(alias = "ang_min_deg")]
216    pub angle_min_deg: f64,
217    /// Maximum phase-angle shift in degrees.
218    #[serde(alias = "ang_max_deg")]
219    pub angle_max_deg: f64,
220    /// Discrete step size in degrees.
221    pub ang_step_deg: f64,
222}
223
224/// Phase Angle Regulator control data (0-indexed), consumed by the NR outer loop.
225///
226/// Created from a [`ParSpec`] by resolving external bus numbers to 0-based
227/// branch indices.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ParControl {
230    /// 0-based index into `Network::branches` for the PAR transformer.
231    pub branch_index: usize,
232    /// 0-based index into `Network::branches` for the monitored branch.
233    ///
234    /// Equals `branch_index` when the PAR branch itself is the monitored element.
235    pub monitored_branch_index: usize,
236    /// Target active power flow in MW.
237    pub p_target_mw: f64,
238    /// Dead-band full-width in MW.
239    pub p_band_mw: f64,
240    /// Minimum phase-angle shift in degrees.
241    #[serde(alias = "ang_min_deg")]
242    pub angle_min_deg: f64,
243    /// Maximum phase-angle shift in degrees.
244    #[serde(alias = "ang_max_deg")]
245    pub angle_max_deg: f64,
246    /// Discrete step size in degrees.
247    pub ang_step_deg: f64,
248}
249
250/// Switched shunt for continuous OPF relaxation (AC-OPF co-optimization).
251///
252/// Rather than a discrete stepped model, this represents the same physical
253/// capacitor/reactor bank as a continuous susceptance variable for the NLP.
254/// After the NLP converges, the optimal `b_val` is rounded to the nearest
255/// realizable discrete step via [`SwitchedShuntOpf::round_to_steps`].
256///
257/// This struct is used exclusively in the AC-OPF pipeline; the discrete-step
258/// version ([`SwitchedShunt`]) drives the outer NR-based control loop.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct SwitchedShuntOpf {
261    /// Stable switched-shunt identifier.
262    #[serde(default, skip_serializing_if = "String::is_empty")]
263    pub id: String,
264    /// External bus number hosting this shunt.
265    pub bus: u32,
266    /// Minimum susceptance (pu) — most inductive (negative for reactors).
267    pub b_min_pu: f64,
268    /// Maximum susceptance (pu) — most capacitive.
269    pub b_max_pu: f64,
270    /// Initial/current susceptance (pu) — used as NLP warm-start.
271    pub b_init_pu: f64,
272    /// Discrete step size (pu). Used for post-solve rounding.
273    /// Set to 0 to skip rounding (continuous shunt).
274    pub b_step_pu: f64,
275}
276
277impl SwitchedShuntOpf {
278    /// Round the optimal continuous `b_val` to the nearest realizable step
279    /// within `[b_min_pu, b_max_pu]`.
280    ///
281    /// If `b_step_pu <= 0`, returns `b_val` clamped to `[b_min_pu, b_max_pu]`
282    /// without rounding (continuous shunt).
283    pub fn round_to_steps(&self, b_val: f64) -> f64 {
284        if self.b_step_pu <= 0.0 {
285            return b_val.clamp(self.b_min_pu, self.b_max_pu);
286        }
287        let clamped = b_val.clamp(self.b_min_pu, self.b_max_pu);
288        let n_steps = ((clamped - self.b_min_pu) / self.b_step_pu).round() as i64;
289        (self.b_min_pu + n_steps as f64 * self.b_step_pu).clamp(self.b_min_pu, self.b_max_pu)
290    }
291}
292
293/// Round a continuous tap ratio to the nearest discrete step within bounds.
294///
295/// If `tap_step <= 0`, returns `tap` clamped to `[tap_min, tap_max]` (continuous).
296pub fn round_tap(tap: f64, tap_min: f64, tap_max: f64, tap_step: f64) -> f64 {
297    if tap_step <= 0.0 {
298        return tap.clamp(tap_min, tap_max);
299    }
300    let clamped = tap.clamp(tap_min, tap_max);
301    let n = ((clamped - tap_min) / tap_step).round() as i64;
302    (tap_min + n as f64 * tap_step).clamp(tap_min, tap_max)
303}
304
305/// Round a continuous phase shift (radians) to the nearest discrete step within bounds.
306///
307/// `step_deg` is the step size in degrees. If `step_deg <= 0`, returns `shift_rad`
308/// clamped to `[min_rad, max_rad]` (continuous).
309pub fn round_phase(shift_rad: f64, min_rad: f64, max_rad: f64, step_deg: f64) -> f64 {
310    if step_deg <= 0.0 {
311        return shift_rad.clamp(min_rad, max_rad);
312    }
313    let step_rad = step_deg.to_radians();
314    let clamped = shift_rad.clamp(min_rad, max_rad);
315    let n = ((clamped - min_rad) / step_rad).round() as i64;
316    (min_rad + n as f64 * step_rad).clamp(min_rad, max_rad)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_round_tap_exact_step() {
325        // 16-step OLTC: step = 0.00625, range [0.9, 1.1]
326        let step = 0.00625;
327        // 0.953 should round to step 8.48 → nearest 8 → 0.9 + 8*0.00625 = 0.95
328        assert!((round_tap(0.953, 0.9, 1.1, step) - 0.95).abs() < 1e-12);
329        // Exact step value should be preserved
330        assert!((round_tap(0.95, 0.9, 1.1, step) - 0.95).abs() < 1e-12);
331        // Just above midpoint: 0.9535 → step index = (0.0535/0.00625) = 8.56 → round to 9 → 0.95625
332        assert!((round_tap(0.9535, 0.9, 1.1, step) - 0.95625).abs() < 1e-12);
333    }
334
335    #[test]
336    fn test_round_tap_continuous() {
337        // step=0 means continuous: no rounding, just clamp
338        assert!((round_tap(0.953, 0.9, 1.1, 0.0) - 0.953).abs() < 1e-12);
339        // Out of bounds clamp
340        assert!((round_tap(0.85, 0.9, 1.1, 0.0) - 0.9).abs() < 1e-12);
341        assert!((round_tap(1.15, 0.9, 1.1, 0.0) - 1.1).abs() < 1e-12);
342    }
343
344    #[test]
345    fn test_round_phase_exact_step() {
346        // 1° step, range [-30°, 30°] in radians
347        let min_rad = (-30.0_f64).to_radians();
348        let max_rad = (30.0_f64).to_radians();
349        let step_deg = 1.0;
350        // 5.5° in radians → should round to 6° (nearest step from min)
351        let val = (5.5_f64).to_radians();
352        let rounded = round_phase(val, min_rad, max_rad, step_deg);
353        // -30° + n*1° → n = round((5.5+30)/1) = 36 → -30+36 = 6°
354        let expected = (6.0_f64).to_radians();
355        assert!((rounded - expected).abs() < 1e-10);
356    }
357
358    #[test]
359    fn test_round_phase_continuous() {
360        let min_rad = (-30.0_f64).to_radians();
361        let max_rad = (30.0_f64).to_radians();
362        let val = (5.5_f64).to_radians();
363        assert!((round_phase(val, min_rad, max_rad, 0.0) - val).abs() < 1e-12);
364    }
365}