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}