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-bus effective PTDF coefficients for the monitored aggregate.
174 ///
175 /// When non-empty, the LP row builder switches from the theta-form
176 /// constraint `Σ coeff·b_dc·Δθ ≤ limit` to the PTDF/injection form
177 /// `Σ_i ptdf_eff_i · p_net_inj_i ≤ limit`. The latter directly
178 /// constrains generator/load/storage/HVDC variables and is the
179 /// only form that binds dispatch when the SCUC LP runs in
180 /// `scuc_disable_bus_power_balance` mode (where theta is decoupled
181 /// from `pg`). Each entry is `(bus_number, eff_ptdf_pu)` where
182 /// `eff_ptdf_pu = Σ_term coefficient_term · ptdf_l_term[bus_idx]`,
183 /// summed over the flowgate's `monitored` terms. Only buses with
184 /// non-trivial PTDF contribution are stored.
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub ptdf_per_bus: Vec<(u32, f64)>,
187 /// Per-band HVDC coefficients for banded N-1 HVDC contingency constraints.
188 /// Each entry: `(hvdc_link_index, band_index, coefficient_pu)`.
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub hvdc_band_coefficients: Vec<(usize, usize, f64)>,
191 /// Compact single-active-period marker. When `Some(p)`,
192 /// [`Flowgate::effective_limit_mw`] returns `limit_mw` at timestep
193 /// `p` and the [`INACTIVE_FLOWGATE_LIMIT_MW`] sentinel for all
194 /// other timesteps — producing the same LP behaviour as a 18-slot
195 /// `limit_mw_schedule` with 17 sentinel entries but without the
196 /// per-flowgate `Vec<f64>` allocation (~1.2 GB savings on
197 /// 617-bus explicit N-1 SCUC, where this is populated by
198 /// `build_branch_security_flowgate`). When `None`, the legacy
199 /// `limit_mw_schedule` / `limit_mw` lookup is used unchanged.
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub limit_mw_active_period: Option<u32>,
202 /// Which side(s) of the flowgate limit can bind. Defaults to
203 /// [`FlowgateBreachSides::Both`] for user-supplied or preseeded
204 /// cuts where the screener doesn't know the direction. The
205 /// iterative security-SCUC screener emits
206 /// [`FlowgateBreachSides::Upper`] or [`FlowgateBreachSides::Lower`]
207 /// after observing which side of `±limit_mw` the monitored-branch
208 /// flow actually crossed, so the bounds layer can pin the
209 /// non-breached side's slack column to zero and let the surge
210 /// lp-reduce presolve drop it. Saves a factor-of-two on slack
211 /// columns per (flowgate, period) pair when set.
212 #[serde(default, skip_serializing_if = "FlowgateBreachSides::is_both")]
213 pub breach_sides: FlowgateBreachSides,
214}
215
216/// Which side(s) of a [`Flowgate`]'s limit the LP allocates slack
217/// columns for. `Both` (default) keeps the symmetric encoding; `Upper`
218/// and `Lower` restrict slack allocation to the matching side,
219/// collapsing the other side's slack column to zero.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum FlowgateBreachSides {
223 /// The symmetric default: slack on both sides of the limit band.
224 #[default]
225 Both,
226 /// Only the upper side (`monitored_flow ≤ +limit`) can bind. The
227 /// lower-side slack column is pinned to zero.
228 Upper,
229 /// Only the lower side (`-limit ≤ monitored_flow`) can bind. The
230 /// upper-side slack column is pinned to zero.
231 Lower,
232}
233
234impl FlowgateBreachSides {
235 /// Helper for `#[serde(skip_serializing_if)]`: treats the default
236 /// `Both` variant as elidable in JSON to preserve on-disk format
237 /// compatibility for all flowgates where no direction is known.
238 pub fn is_both(&self) -> bool {
239 matches!(self, FlowgateBreachSides::Both)
240 }
241
242 /// Whether an upper-side slack column should be allocated for
243 /// this flowgate.
244 pub fn allocates_upper_slack(&self) -> bool {
245 matches!(self, FlowgateBreachSides::Both | FlowgateBreachSides::Upper)
246 }
247
248 /// Whether a lower-side slack column should be allocated for
249 /// this flowgate.
250 pub fn allocates_lower_slack(&self) -> bool {
251 matches!(self, FlowgateBreachSides::Both | FlowgateBreachSides::Lower)
252 }
253}
254
255#[derive(Debug, Deserialize)]
256#[serde(untagged)]
257enum BranchRefSerde {
258 Structured(BranchRef),
259 Legacy((u32, u32, String)),
260}
261
262impl From<BranchRefSerde> for BranchRef {
263 fn from(value: BranchRefSerde) -> Self {
264 match value {
265 BranchRefSerde::Structured(branch) => branch,
266 BranchRefSerde::Legacy(branch) => branch.into(),
267 }
268 }
269}
270
271#[derive(Debug, Deserialize)]
272struct FlowgateSerde {
273 pub name: String,
274 #[serde(default)]
275 pub monitored: Vec<WeightedBranchRef>,
276 #[serde(default)]
277 pub monitored_branches: Vec<(u32, u32, String)>,
278 #[serde(default)]
279 pub monitored_coefficients: Vec<f64>,
280 pub contingency_branch: Option<BranchRefSerde>,
281 pub limit_mw: f64,
282 #[serde(default)]
283 pub limit_reverse_mw: f64,
284 pub in_service: bool,
285 #[serde(default)]
286 pub limit_mw_schedule: Vec<f64>,
287 #[serde(default)]
288 pub limit_reverse_mw_schedule: Vec<f64>,
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
290 pub hvdc_coefficients: Vec<(usize, f64)>,
291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
292 pub hvdc_band_coefficients: Vec<(usize, usize, f64)>,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub ptdf_per_bus: Vec<(u32, f64)>,
295 #[serde(default)]
296 pub limit_mw_active_period: Option<u32>,
297 #[serde(default)]
298 pub breach_sides: FlowgateBreachSides,
299}
300
301impl TryFrom<FlowgateSerde> for Flowgate {
302 type Error = String;
303
304 fn try_from(value: FlowgateSerde) -> Result<Self, Self::Error> {
305 let monitored = if !value.monitored.is_empty() {
306 value.monitored
307 } else if value.monitored_branches.is_empty() && value.monitored_coefficients.is_empty() {
308 Vec::new()
309 } else {
310 if value.monitored_branches.len() != value.monitored_coefficients.len() {
311 return Err(format!(
312 "flowgate '{}' has {} legacy monitored branches but {} coefficients",
313 value.name,
314 value.monitored_branches.len(),
315 value.monitored_coefficients.len()
316 ));
317 }
318 value
319 .monitored_branches
320 .into_iter()
321 .zip(value.monitored_coefficients)
322 .map(|(branch, coefficient)| WeightedBranchRef {
323 branch: branch.into(),
324 coefficient,
325 })
326 .collect()
327 };
328
329 Ok(Self {
330 name: value.name,
331 monitored,
332 contingency_branch: value.contingency_branch.map(Into::into),
333 limit_mw: value.limit_mw,
334 limit_reverse_mw: value.limit_reverse_mw,
335 in_service: value.in_service,
336 limit_mw_schedule: value.limit_mw_schedule,
337 limit_reverse_mw_schedule: value.limit_reverse_mw_schedule,
338 hvdc_coefficients: value.hvdc_coefficients,
339 hvdc_band_coefficients: value.hvdc_band_coefficients,
340 ptdf_per_bus: value.ptdf_per_bus,
341 limit_mw_active_period: value.limit_mw_active_period,
342 breach_sides: value.breach_sides,
343 })
344 }
345}
346
347impl Flowgate {
348 /// Forward MW limit at timestep `t`.
349 ///
350 /// Resolution order:
351 /// 1. If `limit_mw_active_period` is `Some(p)`: return `limit_mw` at
352 /// `t == p`, and [`INACTIVE_FLOWGATE_LIMIT_MW`] otherwise. This is
353 /// the compact encoding used by explicit N-1 security flowgates
354 /// (one period active, all others disabled). Avoids allocating an
355 /// `n_periods`-length `Vec<f64>` per flowgate.
356 /// 2. Else if `limit_mw_schedule[t]` exists: return it.
357 /// 3. Else: fall back to `limit_mw`.
358 pub fn effective_limit_mw(&self, t: usize) -> f64 {
359 if let Some(active) = self.limit_mw_active_period {
360 return if t == active as usize {
361 self.limit_mw
362 } else {
363 INACTIVE_FLOWGATE_LIMIT_MW
364 };
365 }
366 self.limit_mw_schedule
367 .get(t)
368 .copied()
369 .unwrap_or(self.limit_mw)
370 }
371
372 /// Reverse MW limit at timestep `t`.
373 ///
374 /// Returns `limit_reverse_mw_schedule[t]` when available, else
375 /// `limit_reverse_mw`. When the result is zero (or negative), callers
376 /// should fall back to the forward limit for symmetric enforcement.
377 pub fn effective_limit_reverse_mw(&self, t: usize) -> f64 {
378 self.limit_reverse_mw_schedule
379 .get(t)
380 .copied()
381 .unwrap_or(self.limit_reverse_mw)
382 }
383
384 /// Effective reverse limit, falling back to forward limit when zero.
385 ///
386 /// This is the convenience method for constraint generation: returns
387 /// the reverse limit if explicitly set (> 0), otherwise the forward limit
388 /// for symmetric enforcement.
389 pub fn effective_reverse_or_forward(&self, t: usize) -> f64 {
390 let rev = self.effective_limit_reverse_mw(t);
391 if rev > 0.0 {
392 rev
393 } else {
394 self.effective_limit_mw(t)
395 }
396 }
397}
398
399/// A piecewise-linear operating nomogram: restricts one flowgate's MW limit
400/// based on the real-time MW flow measured on a second "index" flowgate.
401///
402/// The `points` vector is a sorted list of `(index_flow_mw, constrained_limit_mw)`
403/// pairs defining the nomogram curve. `evaluate(index_flow_mw)` performs
404/// piecewise-linear interpolation with flat extrapolation at the endpoints.
405///
406/// # Example
407/// ```
408/// use surge_network::network::OperatingNomogram;
409/// let nom = OperatingNomogram {
410/// name: "NomA".into(),
411/// index_flowgate: "FG_North".into(),
412/// constrained_flowgate: "FG_South".into(),
413/// points: vec![(-500.0, 1000.0), (0.0, 800.0), (500.0, 500.0)],
414/// in_service: true,
415/// };
416/// assert!((nom.evaluate(250.0) - 650.0).abs() < 1e-9);
417/// ```
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct OperatingNomogram {
420 /// Human-readable name.
421 pub name: String,
422 /// Name of the flowgate whose flow is used as the x-axis input.
423 pub index_flowgate: String,
424 /// Name of the flowgate whose MW limit is tightened by this nomogram.
425 pub constrained_flowgate: String,
426 /// Sorted `(index_flow_mw, constrained_limit_mw)` breakpoints.
427 ///
428 /// Must have at least one point. Need not cover the full operating range;
429 /// out-of-range inputs are clamped to the nearest endpoint (flat extrapolation).
430 pub points: Vec<(f64, f64)>,
431 /// Whether this nomogram is actively enforced.
432 pub in_service: bool,
433}
434
435impl OperatingNomogram {
436 /// Evaluate the nomogram: return the constrained flowgate's MW limit
437 /// given `index_flow_mw` on the index flowgate.
438 ///
439 /// Uses piecewise-linear interpolation between breakpoints, with flat
440 /// extrapolation outside the defined range. Returns `f64::INFINITY` if
441 /// `points` is empty (no constraint).
442 pub fn evaluate(&self, index_flow_mw: f64) -> f64 {
443 if self.points.is_empty() {
444 return f64::INFINITY;
445 }
446 // Flat extrapolation at left endpoint.
447 if index_flow_mw <= self.points[0].0 {
448 return self.points[0].1;
449 }
450 // Flat extrapolation at right endpoint.
451 let last = self.points[self.points.len() - 1];
452 if index_flow_mw >= last.0 {
453 return last.1;
454 }
455 // Linear interpolation between adjacent breakpoints.
456 for w in self.points.windows(2) {
457 let (x0, y0) = w[0];
458 let (x1, y1) = w[1];
459 if index_flow_mw < x1 {
460 let t = (index_flow_mw - x0) / (x1 - x0);
461 return y0 + t * (y1 - y0);
462 }
463 }
464 last.1
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn test_nomogram_evaluate() {
474 let nom = OperatingNomogram {
475 name: "N1".into(),
476 index_flowgate: "FG_A".into(),
477 constrained_flowgate: "FG_B".into(),
478 points: vec![(-500.0, 1000.0), (0.0, 800.0), (500.0, 500.0)],
479 in_service: true,
480 };
481 // Left flat extrapolation.
482 assert!((nom.evaluate(-600.0) - 1000.0).abs() < 1e-9);
483 // Exact breakpoint.
484 assert!((nom.evaluate(0.0) - 800.0).abs() < 1e-9);
485 // Midpoint interpolation.
486 assert!((nom.evaluate(250.0) - 650.0).abs() < 1e-9);
487 // Right flat extrapolation.
488 assert!((nom.evaluate(600.0) - 500.0).abs() < 1e-9);
489 }
490
491 #[test]
492 fn test_effective_limit_mw_schedule() {
493 let fg = Flowgate {
494 name: "FG".into(),
495 monitored: vec![],
496 contingency_branch: None,
497 limit_mw: 100.0,
498 limit_reverse_mw: 0.0,
499 in_service: true,
500 limit_mw_schedule: vec![90.0, 80.0, 70.0],
501 limit_reverse_mw_schedule: vec![],
502 hvdc_coefficients: vec![],
503 hvdc_band_coefficients: vec![],
504 ptdf_per_bus: vec![],
505 limit_mw_active_period: None,
506 breach_sides: FlowgateBreachSides::Both,
507 };
508 assert_eq!(fg.effective_limit_mw(0), 90.0);
509 assert_eq!(fg.effective_limit_mw(2), 70.0);
510 // Beyond schedule: fall back to limit_mw.
511 assert_eq!(fg.effective_limit_mw(5), 100.0);
512 // Reverse limit: 0 → falls back to forward.
513 assert_eq!(fg.effective_reverse_or_forward(0), 90.0);
514 }
515}