Skip to main content

gridoxide_core/
ratings.rs

1//! Branch operating limits — the permanent rating and its temporary
2//! overload allowances.
3//!
4//! Nothing in [`crate::types`] carries a rating: a [`Line`](crate::types::Line)
5//! is `r`/`x`/`b_shunt`/`g_shunt` and a
6//! [`Transformer`](crate::types::Transformer) is admittances plus a tap. The
7//! only rating anywhere before this module was
8//! gridoxide's `opf::model::BranchLimit` — a single MVA
9//! figure per branch, arriving through the companion OPF document, which is
10//! enough to ask "is this dispatch feasible" and not enough to ask "is this
11//! network secure".
12//!
13//! Security analysis needs the distinction the grid codes actually draw:
14//!
15//! - a **PATL** (permanent admissible transmission loading) that may be
16//!   carried indefinitely, and
17//! - one or more **TATLs** (temporary admissible transmission loading), each
18//!   valid only for a stated duration.
19//!
20//! The gap between them is not a detail — it is the room a curative remedial
21//! action is sized to cover. A flow above PATL but below the 20-minute TATL is
22//! acceptable *provided* something brings it back down within 20 minutes, so a
23//! preventive study judges against the TATL and a curative study against the
24//! PATL. Collapsing the two into one number makes that whole distinction
25//! inexpressible.
26//!
27//! Units are **amperes**, matching CGMES `CurrentLimit`, UCTE's `##L`/`##T`
28//! current field and IIDM's `currentLimits` — every source gridoxide reads
29//! states the permanent rating as a current. A limit set may be absent
30//! entirely (`None`), which means *unlimited* and not *zero*; the same
31//! convention MATPOWER's `rate = 0` carries and
32//! gridoxide's `opf::model::BranchLimit::rate_pu`
33//! already honours.
34
35/// One temporary rating: a current, and how long it may be carried.
36#[derive(Clone, Copy, Debug, PartialEq)]
37pub struct TemporaryLimit {
38    /// Seconds this rating may be carried for. `None` means "no stated
39    /// duration", which CGMES spells `OperationalLimitType.isInfiniteDuration`
40    /// and which is therefore a *second permanent* limit rather than a
41    /// temporary one — kept distinct from `PATL` because a network can carry
42    /// several, and taking the tightest is the caller's decision, not the
43    /// importer's.
44    pub acceptable_duration_s: Option<f64>,
45    /// The rating itself, in amperes.
46    pub value_a: f64,
47}
48
49/// The operating limits of one branch terminal.
50///
51/// Terminals are kept separate because they genuinely differ: a transformer's
52/// two windings sit at different voltages and therefore different current
53/// ratings, and CGMES/IIDM both model limits per terminal. A source that
54/// states one rating for the whole branch (UCTE does) populates both sides
55/// with the same value, which is the honest representation of what it said.
56#[derive(Clone, Debug, Default, PartialEq)]
57pub struct BranchLimits {
58    /// Permanent admissible transmission loading, amperes. `None` = unlimited.
59    pub patl_a: Option<f64>,
60    /// Temporary ratings, in no particular order. Use
61    /// [`limit_for`](Self::limit_for) rather than indexing.
62    pub tatl: Vec<TemporaryLimit>,
63}
64
65impl BranchLimits {
66    /// A limit set with a permanent rating and nothing else.
67    pub fn permanent(value_a: f64) -> Self {
68        Self {
69            patl_a: Some(value_a),
70            tatl: Vec::new(),
71        }
72    }
73
74    /// True when this terminal has no rating of any kind — the state every
75    /// branch is in before an importer that reads limits has run.
76    pub fn is_unlimited(&self) -> bool {
77        self.patl_a.is_none() && self.tatl.is_empty()
78    }
79
80    /// The rating that applies when the overload may last `duration_s`.
81    ///
82    /// This is the *tightest* temporary rating whose acceptable duration is at
83    /// least `duration_s`, falling back to the PATL when no temporary rating
84    /// covers that long. Picking the tightest rather than the loosest is
85    /// deliberate: several TATLs may nominally cover the requested duration,
86    /// and honouring all of them means honouring the smallest.
87    ///
88    /// `duration_s = 0.0` asks for the most permissive instantaneous rating,
89    /// which is what an outage-instant CNEC is judged against; passing
90    /// [`f64::INFINITY`] asks for the permanent one.
91    pub fn limit_for(&self, duration_s: f64) -> Option<f64> {
92        let temporary = self
93            .tatl
94            .iter()
95            .filter(|t| match t.acceptable_duration_s {
96                Some(d) => d >= duration_s,
97                // An "infinite duration" temporary limit covers any request.
98                None => true,
99            })
100            .map(|t| t.value_a)
101            .fold(f64::INFINITY, f64::min);
102        match (temporary.is_finite(), self.patl_a) {
103            (true, Some(patl)) => Some(temporary.max(patl)),
104            (true, None) => Some(temporary),
105            (false, patl) => patl,
106        }
107    }
108
109    /// The single tightest rating this terminal carries, whatever its
110    /// duration. The conservative reading, and the right one for a plain
111    /// "is anything overloaded" check that has no time axis.
112    pub fn tightest(&self) -> Option<f64> {
113        let temporary = self
114            .tatl
115            .iter()
116            .map(|t| t.value_a)
117            .fold(f64::INFINITY, f64::min);
118        match (temporary.is_finite(), self.patl_a) {
119            (true, Some(patl)) => Some(temporary.min(patl)),
120            (true, None) => Some(temporary),
121            (false, patl) => patl,
122        }
123    }
124}
125
126/// Convert a current rating in amperes to an apparent-power rating in
127/// per-unit, at a stated line-to-line base voltage.
128///
129/// `S = √3 · U · I`, so `S_pu = √3 · u_rated · i_a / s_base_va`. The `√3` is
130/// the whole reason this helper exists rather than being written inline at
131/// each call site: dropping it is a 73% error that still looks plausible.
132pub fn current_to_power_pu(current_a: f64, u_rated_v: f64, s_base_va: f64) -> f64 {
133    3f64.sqrt() * u_rated_v * current_a / s_base_va
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn an_empty_limit_set_is_unlimited_rather_than_zero() {
142        let limits = BranchLimits::default();
143        assert!(limits.is_unlimited());
144        assert_eq!(limits.tightest(), None);
145        assert_eq!(limits.limit_for(0.0), None);
146    }
147
148    #[test]
149    fn a_temporary_limit_applies_only_while_its_duration_covers_the_request() {
150        let limits = BranchLimits {
151            patl_a: Some(1000.0),
152            tatl: vec![TemporaryLimit {
153                acceptable_duration_s: Some(1200.0),
154                value_a: 1200.0,
155            }],
156        };
157        // Asking for a 20-minute overload gets the temporary rating ...
158        assert_eq!(limits.limit_for(1200.0), Some(1200.0));
159        // ... asking for an hour falls back to the permanent one.
160        assert_eq!(limits.limit_for(3600.0), Some(1000.0));
161        // The unqualified answer is the tightest.
162        assert_eq!(limits.tightest(), Some(1000.0));
163    }
164
165    #[test]
166    fn several_covering_temporary_limits_resolve_to_the_tightest() {
167        let limits = BranchLimits {
168            patl_a: Some(1000.0),
169            tatl: vec![
170                TemporaryLimit {
171                    acceptable_duration_s: Some(600.0),
172                    value_a: 1500.0,
173                },
174                TemporaryLimit {
175                    acceptable_duration_s: Some(900.0),
176                    value_a: 1300.0,
177                },
178            ],
179        };
180        // Both cover 600 s; honouring both means honouring 1300.
181        assert_eq!(limits.limit_for(600.0), Some(1300.0));
182    }
183
184    #[test]
185    fn a_temporary_limit_never_reports_below_the_permanent_one() {
186        // Badly-ordered data does occur; a TATL under the PATL would otherwise
187        // make a short overload stricter than a permanent one.
188        let limits = BranchLimits {
189            patl_a: Some(1000.0),
190            tatl: vec![TemporaryLimit {
191                acceptable_duration_s: Some(60.0),
192                value_a: 800.0,
193            }],
194        };
195        assert_eq!(limits.limit_for(60.0), Some(1000.0));
196    }
197
198    #[test]
199    fn current_converts_to_apparent_power_through_the_root_three() {
200        // 1000 A at 380 kV is 658.2 MVA.
201        let s = current_to_power_pu(1000.0, 380_000.0, 100e6);
202        assert!((s - 6.5818).abs() < 1e-3, "got {s}");
203    }
204}