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-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}