Skip to main content

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}