Skip to main content

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}