surge_network/network/flowgate.rs
1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Transmission interfaces and flowgates.
3//!
4//! An **Interface** is a set of transmission branches defining a flow boundary
5//! between two areas. Interface flow = sum of (coefficient * branch MW flow).
6//!
7//! A **Flowgate** is a monitored element (or set of elements) under a specific
8//! N-1 contingency. All in-service flowgates are enforced in DC-OPF/SCED/SCUC
9//! as linear constraints on base-case monitored-element flow; for contingency
10//! flowgates the OTDF-adjusted limit is pre-computed offline and stored in
11//! `limit_mw`. Contingency flowgates (contingency_branch = Some(...)) are enforced dynamically
12//! in SCOPF via OTDF-based cuts; see surge_opf::scopf::solve_scopf.
13
14use serde::{Deserialize, Serialize};
15
16use crate::network::{BranchRef, WeightedBranchRef};
17
18/// A transmission interface: a set of branches defining a flow boundary.
19///
20/// Interface flow = sum of (coefficient * branch MW flow).
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(try_from = "InterfaceSerde")]
23pub struct Interface {
24 /// Human-readable name (e.g. "Houston Import").
25 pub name: String,
26 /// Weighted branch members defining the interface flow boundary.
27 pub members: Vec<WeightedBranchRef>,
28 /// MW limit (forward direction).
29 pub limit_forward_mw: f64,
30 /// MW limit (reverse direction, typically a positive value representing the
31 /// magnitude of allowable reverse flow).
32 pub limit_reverse_mw: f64,
33 /// Whether this interface is actively monitored.
34 pub in_service: bool,
35 /// Per-timestep forward MW limit schedule (optional).
36 ///
37 /// When non-empty, `effective_limit_forward_mw(t)` returns `schedule[t]`
38 /// for timesteps within range, falling back to `limit_forward_mw` otherwise.
39 /// Enables dynamic interface limits (e.g., ambient-adjusted thermal limits).
40 #[serde(default)]
41 pub limit_forward_mw_schedule: Vec<f64>,
42 /// Per-timestep reverse MW limit schedule (optional).
43 ///
44 /// When non-empty, `effective_limit_reverse_mw(t)` returns `schedule[t]`
45 /// for timesteps within range, falling back to `limit_reverse_mw` otherwise.
46 #[serde(default)]
47 pub limit_reverse_mw_schedule: Vec<f64>,
48}
49
50#[derive(Debug, Deserialize)]
51struct InterfaceSerde {
52 pub name: String,
53 #[serde(default)]
54 pub members: Vec<WeightedBranchRef>,
55 #[serde(default)]
56 pub branches: Vec<(u32, u32, String)>,
57 #[serde(default)]
58 pub coefficients: Vec<f64>,
59 pub limit_forward_mw: f64,
60 pub limit_reverse_mw: f64,
61 pub in_service: bool,
62 #[serde(default)]
63 pub limit_forward_mw_schedule: Vec<f64>,
64 #[serde(default)]
65 pub limit_reverse_mw_schedule: Vec<f64>,
66}
67
68impl TryFrom<InterfaceSerde> for Interface {
69 type Error = String;
70
71 fn try_from(value: InterfaceSerde) -> Result<Self, Self::Error> {
72 let members = if !value.members.is_empty() {
73 value.members
74 } else if value.branches.is_empty() && value.coefficients.is_empty() {
75 Vec::new()
76 } else {
77 if value.branches.len() != value.coefficients.len() {
78 return Err(format!(
79 "interface '{}' has {} legacy branches but {} coefficients",
80 value.name,
81 value.branches.len(),
82 value.coefficients.len()
83 ));
84 }
85 value
86 .branches
87 .into_iter()
88 .zip(value.coefficients)
89 .map(|(branch, coefficient)| WeightedBranchRef {
90 branch: branch.into(),
91 coefficient,
92 })
93 .collect()
94 };
95
96 Ok(Self {
97 name: value.name,
98 members,
99 limit_forward_mw: value.limit_forward_mw,
100 limit_reverse_mw: value.limit_reverse_mw,
101 in_service: value.in_service,
102 limit_forward_mw_schedule: value.limit_forward_mw_schedule,
103 limit_reverse_mw_schedule: value.limit_reverse_mw_schedule,
104 })
105 }
106}
107
108impl Interface {
109 /// Forward MW limit at timestep `t`.
110 ///
111 /// Returns `limit_forward_mw_schedule[t]` when available, else `limit_forward_mw`.
112 pub fn effective_limit_forward_mw(&self, t: usize) -> f64 {
113 self.limit_forward_mw_schedule
114 .get(t)
115 .copied()
116 .unwrap_or(self.limit_forward_mw)
117 }
118
119 /// Reverse MW limit at timestep `t`.
120 ///
121 /// Returns `limit_reverse_mw_schedule[t]` when available, else `limit_reverse_mw`.
122 pub fn effective_limit_reverse_mw(&self, t: usize) -> f64 {
123 self.limit_reverse_mw_schedule
124 .get(t)
125 .copied()
126 .unwrap_or(self.limit_reverse_mw)
127 }
128}
129
130/// Sentinel forward-limit value returned by [`Flowgate::effective_limit_mw`]
131/// on periods where a single-period flowgate is inactive. Downstream LP
132/// builders translate this into a row whose bounds are so wide the
133/// constraint is trivially satisfied (Gurobi's GRB_INFINITY convention).
134pub const INACTIVE_FLOWGATE_LIMIT_MW: f64 = 1e30;
135
136/// A flowgate: a monitored element under a specific contingency.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(try_from = "FlowgateSerde")]
139pub struct Flowgate {
140 /// Human-readable name (e.g. "FG_123").
141 pub name: String,
142 /// The monitored element(s) with signed coefficients.
143 pub monitored: Vec<WeightedBranchRef>,
144 /// The contingency element (branch that trips). `None` = base-case-only flowgate.
145 pub contingency_branch: Option<BranchRef>,
146 /// Forward MW limit (positive direction defined by monitored_coefficients).
147 pub limit_mw: f64,
148 /// Reverse MW limit (magnitude of allowable reverse flow).
149 /// When zero (default), the forward limit is applied symmetrically.
150 #[serde(default)]
151 pub limit_reverse_mw: f64,
152 /// Whether this flowgate is actively monitored.
153 pub in_service: bool,
154 /// Per-timestep forward MW limit schedule (optional).
155 ///
156 /// When non-empty, `effective_limit_mw(t)` returns `schedule[t]`
157 /// for timesteps within range, falling back to `limit_mw` otherwise.
158 /// Enables dynamic flowgate limits (ambient ratings, planned outage windows).
159 #[serde(default)]
160 pub limit_mw_schedule: Vec<f64>,
161 /// Per-timestep reverse MW limit schedule (optional).
162 ///
163 /// When non-empty, `effective_limit_reverse_mw(t)` returns `schedule[t]`
164 /// for timesteps within range, falling back to `limit_reverse_mw` otherwise.
165 #[serde(default)]
166 pub limit_reverse_mw_schedule: Vec<f64>,
167 /// HVDC link coefficients for N-1 HVDC contingency constraints.
168 /// Each entry: `(hvdc_link_index, coefficient_pu)`.
169 /// When non-empty, the flowgate constraint includes HVDC dispatch variable terms:
170 /// `Σ coeff_i·b_dc_i·(θ_from_i − θ_to_i) + Σ hvdc_coeff_k·P_hvdc[k] ∈ [-limit, limit]`
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub hvdc_coefficients: Vec<(usize, f64)>,
173 /// Per-band HVDC coefficients for banded N-1 HVDC contingency constraints.
174 /// Each entry: `(hvdc_link_index, band_index, coefficient_pu)`.
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub hvdc_band_coefficients: Vec<(usize, usize, f64)>,
177 /// Compact single-active-period marker. When `Some(p)`,
178 /// [`Flowgate::effective_limit_mw`] returns `limit_mw` at timestep
179 /// `p` and the [`INACTIVE_FLOWGATE_LIMIT_MW`] sentinel for all
180 /// other timesteps — producing the same LP behaviour as a 18-slot
181 /// `limit_mw_schedule` with 17 sentinel entries but without the
182 /// per-flowgate `Vec<f64>` allocation (~1.2 GB savings on
183 /// 617-bus explicit N-1 SCUC, where this is populated by
184 /// `build_branch_security_flowgate`). When `None`, the legacy
185 /// `limit_mw_schedule` / `limit_mw` lookup is used unchanged.
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub limit_mw_active_period: Option<u32>,
188}
189
190#[derive(Debug, Deserialize)]
191#[serde(untagged)]
192enum BranchRefSerde {
193 Structured(BranchRef),
194 Legacy((u32, u32, String)),
195}
196
197impl From<BranchRefSerde> for BranchRef {
198 fn from(value: BranchRefSerde) -> Self {
199 match value {
200 BranchRefSerde::Structured(branch) => branch,
201 BranchRefSerde::Legacy(branch) => branch.into(),
202 }
203 }
204}
205
206#[derive(Debug, Deserialize)]
207struct FlowgateSerde {
208 pub name: String,
209 #[serde(default)]
210 pub monitored: Vec<WeightedBranchRef>,
211 #[serde(default)]
212 pub monitored_branches: Vec<(u32, u32, String)>,
213 #[serde(default)]
214 pub monitored_coefficients: Vec<f64>,
215 pub contingency_branch: Option<BranchRefSerde>,
216 pub limit_mw: f64,
217 #[serde(default)]
218 pub limit_reverse_mw: f64,
219 pub in_service: bool,
220 #[serde(default)]
221 pub limit_mw_schedule: Vec<f64>,
222 #[serde(default)]
223 pub limit_reverse_mw_schedule: Vec<f64>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub hvdc_coefficients: Vec<(usize, f64)>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub hvdc_band_coefficients: Vec<(usize, usize, f64)>,
228 #[serde(default)]
229 pub limit_mw_active_period: Option<u32>,
230}
231
232impl TryFrom<FlowgateSerde> for Flowgate {
233 type Error = String;
234
235 fn try_from(value: FlowgateSerde) -> Result<Self, Self::Error> {
236 let monitored = if !value.monitored.is_empty() {
237 value.monitored
238 } else if value.monitored_branches.is_empty() && value.monitored_coefficients.is_empty() {
239 Vec::new()
240 } else {
241 if value.monitored_branches.len() != value.monitored_coefficients.len() {
242 return Err(format!(
243 "flowgate '{}' has {} legacy monitored branches but {} coefficients",
244 value.name,
245 value.monitored_branches.len(),
246 value.monitored_coefficients.len()
247 ));
248 }
249 value
250 .monitored_branches
251 .into_iter()
252 .zip(value.monitored_coefficients)
253 .map(|(branch, coefficient)| WeightedBranchRef {
254 branch: branch.into(),
255 coefficient,
256 })
257 .collect()
258 };
259
260 Ok(Self {
261 name: value.name,
262 monitored,
263 contingency_branch: value.contingency_branch.map(Into::into),
264 limit_mw: value.limit_mw,
265 limit_reverse_mw: value.limit_reverse_mw,
266 in_service: value.in_service,
267 limit_mw_schedule: value.limit_mw_schedule,
268 limit_reverse_mw_schedule: value.limit_reverse_mw_schedule,
269 hvdc_coefficients: value.hvdc_coefficients,
270 hvdc_band_coefficients: value.hvdc_band_coefficients,
271 limit_mw_active_period: value.limit_mw_active_period,
272 })
273 }
274}
275
276impl Flowgate {
277 /// Forward MW limit at timestep `t`.
278 ///
279 /// Resolution order:
280 /// 1. If `limit_mw_active_period` is `Some(p)`: return `limit_mw` at
281 /// `t == p`, and [`INACTIVE_FLOWGATE_LIMIT_MW`] otherwise. This is
282 /// the compact encoding used by explicit N-1 security flowgates
283 /// (one period active, all others disabled). Avoids allocating an
284 /// `n_periods`-length `Vec<f64>` per flowgate.
285 /// 2. Else if `limit_mw_schedule[t]` exists: return it.
286 /// 3. Else: fall back to `limit_mw`.
287 pub fn effective_limit_mw(&self, t: usize) -> f64 {
288 if let Some(active) = self.limit_mw_active_period {
289 return if t == active as usize {
290 self.limit_mw
291 } else {
292 INACTIVE_FLOWGATE_LIMIT_MW
293 };
294 }
295 self.limit_mw_schedule
296 .get(t)
297 .copied()
298 .unwrap_or(self.limit_mw)
299 }
300
301 /// Reverse MW limit at timestep `t`.
302 ///
303 /// Returns `limit_reverse_mw_schedule[t]` when available, else
304 /// `limit_reverse_mw`. When the result is zero (or negative), callers
305 /// should fall back to the forward limit for symmetric enforcement.
306 pub fn effective_limit_reverse_mw(&self, t: usize) -> f64 {
307 self.limit_reverse_mw_schedule
308 .get(t)
309 .copied()
310 .unwrap_or(self.limit_reverse_mw)
311 }
312
313 /// Effective reverse limit, falling back to forward limit when zero.
314 ///
315 /// This is the convenience method for constraint generation: returns
316 /// the reverse limit if explicitly set (> 0), otherwise the forward limit
317 /// for symmetric enforcement.
318 pub fn effective_reverse_or_forward(&self, t: usize) -> f64 {
319 let rev = self.effective_limit_reverse_mw(t);
320 if rev > 0.0 {
321 rev
322 } else {
323 self.effective_limit_mw(t)
324 }
325 }
326}
327
328/// A piecewise-linear operating nomogram: restricts one flowgate's MW limit
329/// based on the real-time MW flow measured on a second "index" flowgate.
330///
331/// The `points` vector is a sorted list of `(index_flow_mw, constrained_limit_mw)`
332/// pairs defining the nomogram curve. `evaluate(index_flow_mw)` performs
333/// piecewise-linear interpolation with flat extrapolation at the endpoints.
334///
335/// # Example
336/// ```
337/// use surge_network::network::OperatingNomogram;
338/// let nom = OperatingNomogram {
339/// name: "NomA".into(),
340/// index_flowgate: "FG_North".into(),
341/// constrained_flowgate: "FG_South".into(),
342/// points: vec![(-500.0, 1000.0), (0.0, 800.0), (500.0, 500.0)],
343/// in_service: true,
344/// };
345/// assert!((nom.evaluate(250.0) - 650.0).abs() < 1e-9);
346/// ```
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct OperatingNomogram {
349 /// Human-readable name.
350 pub name: String,
351 /// Name of the flowgate whose flow is used as the x-axis input.
352 pub index_flowgate: String,
353 /// Name of the flowgate whose MW limit is tightened by this nomogram.
354 pub constrained_flowgate: String,
355 /// Sorted `(index_flow_mw, constrained_limit_mw)` breakpoints.
356 ///
357 /// Must have at least one point. Need not cover the full operating range;
358 /// out-of-range inputs are clamped to the nearest endpoint (flat extrapolation).
359 pub points: Vec<(f64, f64)>,
360 /// Whether this nomogram is actively enforced.
361 pub in_service: bool,
362}
363
364impl OperatingNomogram {
365 /// Evaluate the nomogram: return the constrained flowgate's MW limit
366 /// given `index_flow_mw` on the index flowgate.
367 ///
368 /// Uses piecewise-linear interpolation between breakpoints, with flat
369 /// extrapolation outside the defined range. Returns `f64::INFINITY` if
370 /// `points` is empty (no constraint).
371 pub fn evaluate(&self, index_flow_mw: f64) -> f64 {
372 if self.points.is_empty() {
373 return f64::INFINITY;
374 }
375 // Flat extrapolation at left endpoint.
376 if index_flow_mw <= self.points[0].0 {
377 return self.points[0].1;
378 }
379 // Flat extrapolation at right endpoint.
380 let last = self.points[self.points.len() - 1];
381 if index_flow_mw >= last.0 {
382 return last.1;
383 }
384 // Linear interpolation between adjacent breakpoints.
385 for w in self.points.windows(2) {
386 let (x0, y0) = w[0];
387 let (x1, y1) = w[1];
388 if index_flow_mw < x1 {
389 let t = (index_flow_mw - x0) / (x1 - x0);
390 return y0 + t * (y1 - y0);
391 }
392 }
393 last.1
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_nomogram_evaluate() {
403 let nom = OperatingNomogram {
404 name: "N1".into(),
405 index_flowgate: "FG_A".into(),
406 constrained_flowgate: "FG_B".into(),
407 points: vec![(-500.0, 1000.0), (0.0, 800.0), (500.0, 500.0)],
408 in_service: true,
409 };
410 // Left flat extrapolation.
411 assert!((nom.evaluate(-600.0) - 1000.0).abs() < 1e-9);
412 // Exact breakpoint.
413 assert!((nom.evaluate(0.0) - 800.0).abs() < 1e-9);
414 // Midpoint interpolation.
415 assert!((nom.evaluate(250.0) - 650.0).abs() < 1e-9);
416 // Right flat extrapolation.
417 assert!((nom.evaluate(600.0) - 500.0).abs() < 1e-9);
418 }
419
420 #[test]
421 fn test_effective_limit_mw_schedule() {
422 let fg = Flowgate {
423 name: "FG".into(),
424 monitored: vec![],
425 contingency_branch: None,
426 limit_mw: 100.0,
427 limit_reverse_mw: 0.0,
428 in_service: true,
429 limit_mw_schedule: vec![90.0, 80.0, 70.0],
430 limit_reverse_mw_schedule: vec![],
431 hvdc_coefficients: vec![],
432 hvdc_band_coefficients: vec![],
433 limit_mw_active_period: None,
434 };
435 assert_eq!(fg.effective_limit_mw(0), 90.0);
436 assert_eq!(fg.effective_limit_mw(2), 70.0);
437 // Beyond schedule: fall back to limit_mw.
438 assert_eq!(fg.effective_limit_mw(5), 100.0);
439 // Reverse limit: 0 → falls back to forward.
440 assert_eq!(fg.effective_reverse_or_forward(0), 90.0);
441 }
442}