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}