gridoxide_core/shortcircuit/mod.rs
1//! Short-circuit calculation, IEC 60909 style.
2//!
3//! Answers the question a protection study asks: *if a fault happens here, how
4//! much current flows, and what does the rest of the network see while it
5//! does?* The initial symmetrical short-circuit current \\(I_k''\\) that comes
6//! out is what breaker ratings, conductor sizing and relay coordination are
7//! all derived from.
8//!
9//! # What makes this not a power flow
10//!
11//! Three things, all of them deliberate simplifications the standard makes so
12//! that the answer does not depend on the operating point:
13//!
14//! - **Loads and generation are ignored entirely.** Not approximated —
15//! ignored. A short-circuit current is a property of the network's
16//! impedances, and IEC 60909 isolates it from whatever the dispatch happened
17//! to be. Shunt admittances *are* kept: a shunt is a passive element, not a
18//! dispatch decision.
19//! - **Sources sit behind a fixed EMF**, scaled by the voltage factor `c`
20//! ([`VoltageScaling`]) rather than by their own setpoint.
21//! - **The problem is linear.** No iteration, no initial guess, no convergence
22//! to fail — one sparse factorization, exactly like
23//! [`crate::linear::impedance`]. A short-circuit calculation either has a
24//! solvable network or it does not.
25//!
26//! # Formulation
27//!
28//! Phase domain (abc), following power-grid-model's `ShortCircuitSolver`. The
29//! fault's boundary conditions are stamped directly into the 3N×3N admittance
30//! matrix — rows and columns at the faulted bus are rewritten — and the whole
31//! thing is solved in one shot:
32//!
33//! \\[ I_N = Y_{bus} U_N \\]
34//!
35//! The alternative formulation, and the one IEC 60909 itself is written in, is
36//! the sequence domain: build \\(Z_0\\), \\(Z_1\\), \\(Z_2\\) Thevenin
37//! equivalents at the fault point and connect them per fault type. The two
38//! agree; the phase domain was chosen because it extends to unbalanced faults
39//! without special-casing each one, and because power-grid-model's fixtures
40//! then cross-validate this implementation exactly.
41//!
42//! It inherits one limitation from that choice, which power-grid-model
43//! documents for itself too: **the network needs a path to ground**. An
44//! ungrounded network has a singular zero-sequence system in the phase domain
45//! (it does not in the sequence domain), and comes back as
46//! [`ShortCircuitError::Singular`].
47//!
48//! Results are reported in both bases: phase quantities, which is what
49//! power-grid-model emits and what the fixtures check, and symmetrical
50//! components via [`fortescue`], which is what powsybl's short-circuit API
51//! models and what the standard's own vocabulary is written in.
52
53pub mod fortescue;
54pub mod network;
55pub mod solver;
56
57use num_complex::Complex;
58
59pub use fortescue::SequenceValue;
60pub use network::{ScNetwork3Ph, ScSource};
61pub use solver::{
62 BranchResult, FaultResult, NodeResult, ShortCircuitError, ShortCircuitReport, ShuntResult,
63 SourceResult, short_circuit,
64};
65
66/// The nominal phase angles, in the order power-grid-model's own
67/// `ComplexValue<asymmetric_t>` scalar constructor produces: `[x, x·a², x·a]`
68/// with `a = exp(j2π/3)`. Phase b lags by 120°, phase c leads by 120°.
69pub(crate) const PHASE_ANG: [f64; 3] = [
70 0.0,
71 -2.0 * std::f64::consts::PI / 3.0,
72 2.0 * std::f64::consts::PI / 3.0,
73];
74
75/// Which of the four fault situations IEC 60909 distinguishes.
76///
77/// The discriminants are power-grid-model's own `FaultType` codes, as they
78/// appear on `pgm::PgmFault::fault_type`.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum FaultType {
81 /// All three phases shorted together (and to ground). The balanced case,
82 /// and usually — though not always — the largest current.
83 ThreePhase,
84 /// One phase to ground. The most common fault in practice by a wide
85 /// margin.
86 SinglePhaseToGround,
87 /// Two phases shorted to each other, clear of ground.
88 TwoPhase,
89 /// Two phases shorted to each other *and* to ground.
90 TwoPhaseToGround,
91}
92
93impl FaultType {
94 /// Reads power-grid-model's integer code.
95 pub fn from_code(code: i8) -> Result<Self, ShortCircuitError> {
96 match code {
97 0 => Ok(Self::ThreePhase),
98 1 => Ok(Self::SinglePhaseToGround),
99 2 => Ok(Self::TwoPhase),
100 3 => Ok(Self::TwoPhaseToGround),
101 other => Err(ShortCircuitError::UnknownFaultType(other)),
102 }
103 }
104
105 /// The phase combination this fault type assumes when the document leaves
106 /// `fault_phase` at its default sentinel.
107 ///
108 /// Mirrors power-grid-model's `Fault::get_fault_phase`. **Note that the
109 /// two two-phase types default to `bc`, not `ab`** — most of the reference
110 /// fixtures rely on this, and assuming `ab` would put the current on the
111 /// wrong pair of phases while still producing an entirely plausible
112 /// magnitude.
113 pub fn default_phase(self) -> FaultPhase {
114 match self {
115 Self::ThreePhase => FaultPhase::Abc,
116 Self::SinglePhaseToGround => FaultPhase::A,
117 Self::TwoPhase | Self::TwoPhaseToGround => FaultPhase::Bc,
118 }
119 }
120}
121
122/// Which phase or phase pair a fault involves.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum FaultPhase {
125 Abc,
126 A,
127 B,
128 C,
129 Ab,
130 Ac,
131 Bc,
132}
133
134impl FaultPhase {
135 /// Reads power-grid-model's integer code. `-1` is its `default_value`
136 /// sentinel, which is not a phase — it is resolved against the fault type
137 /// by [`FaultType::default_phase`], so it is returned as `None` here
138 /// rather than guessed at.
139 pub fn from_code(code: i8) -> Result<Option<Self>, ShortCircuitError> {
140 match code {
141 -1 => Ok(None),
142 0 => Ok(Some(Self::Abc)),
143 1 => Ok(Some(Self::A)),
144 2 => Ok(Some(Self::B)),
145 3 => Ok(Some(Self::C)),
146 4 => Ok(Some(Self::Ab)),
147 5 => Ok(Some(Self::Ac)),
148 6 => Ok(Some(Self::Bc)),
149 other => Err(ShortCircuitError::UnknownFaultPhase(other)),
150 }
151 }
152
153 /// The zero-based phase indices this involves, as
154 /// power-grid-model's `set_phase_index` computes them. `Abc` involves all
155 /// three and so names none individually.
156 pub(crate) fn indices(self) -> (Option<usize>, Option<usize>) {
157 match self {
158 Self::Abc => (None, None),
159 Self::A => (Some(0), None),
160 Self::B => (Some(1), None),
161 Self::C => (Some(2), None),
162 Self::Ab => (Some(0), Some(1)),
163 Self::Ac => (Some(0), Some(2)),
164 Self::Bc => (Some(1), Some(2)),
165 }
166 }
167}
168
169/// Whether to compute the maximum or the minimum short-circuit current.
170///
171/// The two are different studies with different purposes: the maximum sizes
172/// equipment for the worst mechanical and thermal stress it must survive, the
173/// minimum checks that protection will still reliably *detect* a fault under
174/// the least favourable conditions. IEC 60909 expresses both through a single
175/// voltage factor `c` applied to the source EMF — see [`c_factor`].
176#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
177pub enum VoltageScaling {
178 #[default]
179 Maximum,
180 Minimum,
181}
182
183/// The IEC 60909 voltage scaling factor `c`, for a source on a network of the
184/// given rated line-to-line voltage.
185///
186/// | | `c_max` | `c_min` |
187/// |---|---|---|
188/// | `U_nom` ≤ 1 kV | 1.10 | 0.95 |
189/// | `U_nom` > 1 kV | 1.10 | 1.00 |
190///
191/// The standard distinguishes a 6% and a 10% voltage tolerance for the
192/// low-voltage `c_min`; like power-grid-model, this assumes 10%.
193pub fn c_factor(u_rated: f64, scaling: VoltageScaling) -> f64 {
194 match scaling {
195 VoltageScaling::Maximum => 1.10,
196 VoltageScaling::Minimum if u_rated <= 1000.0 => 0.95,
197 VoltageScaling::Minimum => 1.00,
198 }
199}
200
201/// A fault's admittance to the fault point.
202#[derive(Clone, Copy, Debug, PartialEq)]
203pub enum FaultAdmittance {
204 /// A dead short — zero impedance, therefore infinite admittance.
205 ///
206 /// Kept as its own variant rather than an actual `f64::INFINITY` because
207 /// the two cases are solved by genuinely different matrix surgery, not by
208 /// the same formula evaluated at a limit: a bolted fault *replaces* the
209 /// faulted bus's equations rather than adding to them. Letting an infinity
210 /// into the matrix would produce `NaN`, not a large current.
211 ///
212 /// This is the default for a `fault` entry that specifies no `r_f`/`x_f`.
213 Bolted,
214 /// A fault through a finite admittance, per unit.
215 Finite(Complex<f64>),
216}
217
218/// One fault, resolved against the network it applies to.
219#[derive(Clone, Copy, Debug)]
220pub struct Fault {
221 /// The document's own `fault` id, carried through to the result.
222 pub id: u64,
223 /// Physical node index of the faulted bus.
224 pub bus: usize,
225 pub fault_type: FaultType,
226 pub fault_phase: FaultPhase,
227 pub y_fault: FaultAdmittance,
228}
229
230/// Options for a short-circuit calculation.
231///
232/// Deliberately small, and for the same reason [`crate::linear::DcOptions`] is:
233/// a direct solve has no tolerance and no iteration limit. The only real
234/// choice IEC 60909 leaves open at this level is maximum versus minimum
235/// current.
236#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
237pub struct ShortCircuitOptions {
238 pub scaling: VoltageScaling,
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 /// The `c` table, at every branch including the 1 kV boundary itself.
246 /// No fixture exercises the low-voltage `c_min`, so this is the only thing
247 /// pinning it.
248 #[test]
249 fn the_voltage_scaling_factor_follows_the_iec_table() {
250 assert_eq!(c_factor(400.0, VoltageScaling::Maximum), 1.10);
251 assert_eq!(c_factor(150_000.0, VoltageScaling::Maximum), 1.10);
252
253 assert_eq!(c_factor(400.0, VoltageScaling::Minimum), 0.95);
254 // The boundary is inclusive on the low-voltage side.
255 assert_eq!(c_factor(1000.0, VoltageScaling::Minimum), 0.95);
256 assert_eq!(c_factor(1000.1, VoltageScaling::Minimum), 1.00);
257 assert_eq!(c_factor(150_000.0, VoltageScaling::Minimum), 1.00);
258 }
259
260 /// The default phase for the two two-phase fault types is `bc`. Getting
261 /// this wrong puts a correct-looking magnitude on the wrong conductors.
262 #[test]
263 fn two_phase_faults_default_to_bc() {
264 assert_eq!(FaultType::TwoPhase.default_phase(), FaultPhase::Bc);
265 assert_eq!(FaultType::TwoPhaseToGround.default_phase(), FaultPhase::Bc);
266 assert_eq!(FaultType::ThreePhase.default_phase(), FaultPhase::Abc);
267 assert_eq!(
268 FaultType::SinglePhaseToGround.default_phase(),
269 FaultPhase::A
270 );
271 }
272
273 #[test]
274 fn the_default_phase_sentinel_is_not_mistaken_for_a_phase() {
275 assert_eq!(FaultPhase::from_code(-1).unwrap(), None);
276 assert_eq!(FaultPhase::from_code(0).unwrap(), Some(FaultPhase::Abc));
277 assert!(FaultPhase::from_code(9).is_err());
278 }
279
280 #[test]
281 fn phase_indices_match_the_reference_mapping() {
282 assert_eq!(FaultPhase::Abc.indices(), (None, None));
283 assert_eq!(FaultPhase::A.indices(), (Some(0), None));
284 assert_eq!(FaultPhase::C.indices(), (Some(2), None));
285 assert_eq!(FaultPhase::Ab.indices(), (Some(0), Some(1)));
286 assert_eq!(FaultPhase::Ac.indices(), (Some(0), Some(2)));
287 assert_eq!(FaultPhase::Bc.indices(), (Some(1), Some(2)));
288 }
289}