use crate::error::{OxiGridError, Result};
use crate::network::topology::PowerNetwork;
use crate::protection::fault_symmetric::FaultType;
use crate::protection::relay::OcRelay;
use num_complex::Complex64;
use serde::{Deserialize, Serialize};
pub const C_MAX_LV: f64 = 1.10;
pub const C_MAX_HV: f64 = 1.05;
pub const C_MIN: f64 = 0.95;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum Iec60909Method {
EquivalentVoltage,
Superposition,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum GroundingConfig {
SolidlyGrounded,
UngroundedDelta,
Impedance { r_ohm: f64, x_ohm: f64 },
EffectivelyGrounded,
}
impl GroundingConfig {
pub fn allows_zero_sequence(&self) -> bool {
!matches!(self, Self::UngroundedDelta)
}
pub fn impedance_pu(&self, base_kv: f64, base_mva: f64) -> Complex64 {
match self {
Self::SolidlyGrounded | Self::EffectivelyGrounded => Complex64::new(0.0, 0.0),
Self::UngroundedDelta => {
Complex64::new(1e9, 0.0)
}
Self::Impedance { r_ohm, x_ohm } => {
let z_base = (base_kv * base_kv) / base_mva;
if z_base < 1e-12 {
Complex64::new(0.0, 0.0)
} else {
Complex64::new(r_ohm / z_base, x_ohm / z_base)
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShortCircuitInput {
pub fault_bus: usize,
pub fault_type: FaultType,
pub fault_impedance: Complex64,
pub pre_fault_voltage_pu: f64,
}
impl ShortCircuitInput {
pub fn bolted_three_phase(fault_bus: usize) -> Self {
Self {
fault_bus,
fault_type: FaultType::ThreePhase,
fault_impedance: Complex64::new(0.0, 0.0),
pre_fault_voltage_pu: 1.0,
}
}
pub fn bolted_slg(fault_bus: usize) -> Self {
Self {
fault_bus,
fault_type: FaultType::SingleLineGround,
fault_impedance: Complex64::new(0.0, 0.0),
pre_fault_voltage_pu: 1.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShortCircuitResult {
pub fault_bus: usize,
pub fault_type: FaultType,
pub i_k3_ka: f64,
pub i_k2_ka: f64,
pub i_k1_ka: f64,
pub i_k_e2e1_ka: f64,
pub s_k_mva: f64,
pub z_k_ohm: f64,
pub r_x_ratio: f64,
pub i_p_ka: f64,
pub kappa: f64,
pub i_th_ka: f64,
pub thermal_energy_ka2s: f64,
pub i_b_ka: Option<f64>,
}
#[allow(clippy::needless_range_loop)]
fn dense_invert(mat_in: &[Vec<Complex64>]) -> Result<Vec<Vec<Complex64>>> {
let n = mat_in.len();
let mut aug: Vec<Vec<Complex64>> = mat_in
.iter()
.enumerate()
.map(|(i, row)| {
let mut r = row.clone();
for j in 0..n {
r.push(if i == j {
Complex64::new(1.0, 0.0)
} else {
Complex64::new(0.0, 0.0)
});
}
r
})
.collect();
for col in 0..n {
let mut max_row = col;
let mut max_val = aug[col][col].norm();
for row in (col + 1)..n {
let v = aug[row][col].norm();
if v > max_val {
max_val = v;
max_row = row;
}
}
if max_val < 1e-14 {
return Err(OxiGridError::LinearAlgebra(
"Impedance matrix is singular — network may be disconnected".into(),
));
}
aug.swap(col, max_row);
let pivot = aug[col][col];
for j in col..2 * n {
aug[col][j] /= pivot;
}
for row in 0..n {
if row == col {
continue;
}
let factor = aug[row][col];
for j in col..2 * n {
let sub = factor * aug[col][j];
aug[row][j] -= sub;
}
}
}
Ok((0..n).map(|i| aug[i][n..].to_vec()).collect())
}
fn dense_ybus(network: &PowerNetwork) -> Result<Vec<Vec<Complex64>>> {
let n = network.bus_count();
let mut y: Vec<Vec<Complex64>> = vec![vec![Complex64::new(0.0, 0.0); n]; n];
for branch in &network.branches {
if !branch.status {
continue;
}
let i = network.bus_index(branch.from_bus)?;
let j = network.bus_index(branch.to_bus)?;
let ys = Complex64::new(branch.r, branch.x).inv();
let bc = Complex64::new(0.0, branch.b / 2.0);
let tap = branch.tap_complex();
let tap_conj = tap.conj();
let tap_mag_sq = tap.norm_sqr();
y[i][i] += ys / tap_mag_sq + bc;
y[j][j] += ys + bc;
y[i][j] += -ys / tap_conj;
y[j][i] += -ys / tap;
}
for (idx, bus) in network.buses.iter().enumerate() {
if bus.gs != 0.0 || bus.bs != 0.0 {
let y_shunt = Complex64::new(bus.gs, bus.bs) / network.base_mva;
y[idx][idx] += y_shunt;
}
}
Ok(y)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Iec60909Calculator {
pub c_factor: f64,
pub method: Iec60909Method,
pub clearing_time_s: f64,
}
impl Iec60909Calculator {
pub fn new(c_factor: f64) -> Self {
Self {
c_factor,
method: Iec60909Method::EquivalentVoltage,
clearing_time_s: 0.1,
}
}
pub fn with_method(mut self, method: Iec60909Method) -> Self {
self.method = method;
self
}
pub fn with_clearing_time(mut self, tc_s: f64) -> Self {
self.clearing_time_s = tc_s;
self
}
pub fn build_z1_matrix(&self, network: &PowerNetwork) -> Result<Vec<Vec<Complex64>>> {
let y1 = dense_ybus(network)?;
dense_invert(&y1)
}
pub fn build_z2_matrix(&self, network: &PowerNetwork) -> Result<Vec<Vec<Complex64>>> {
self.build_z1_matrix(network)
}
pub fn build_z0_matrix(
&self,
network: &PowerNetwork,
grounding: &[GroundingConfig],
) -> Result<Vec<Vec<Complex64>>> {
let mut y0 = dense_ybus(network)?;
for (idx, bus) in network.buses.iter().enumerate() {
let gcfg = grounding
.get(idx)
.copied()
.unwrap_or(GroundingConfig::SolidlyGrounded);
let base_kv = if bus.base_kv.0 > 1e-6 {
bus.base_kv.0
} else {
1.0
};
let zn = gcfg.impedance_pu(base_kv, network.base_mva);
match gcfg {
GroundingConfig::UngroundedDelta => {
y0[idx][idx] += Complex64::new(0.0, -1e9);
}
GroundingConfig::Impedance { .. } => {
let three_zn = zn * 3.0;
if three_zn.norm() > 1e-14 {
y0[idx][idx] += Complex64::new(1.0, 0.0) / three_zn;
}
}
GroundingConfig::SolidlyGrounded | GroundingConfig::EffectivelyGrounded => {
}
}
}
dense_invert(&y0)
}
pub fn compute_kappa(r_x_ratio: f64) -> f64 {
let rx = r_x_ratio.max(0.0); 1.02 + 0.98 * (-3.0 * rx).exp()
}
pub fn compute_thermal_factor(tc_s: f64, r_x_ratio: f64) -> (f64, f64) {
let f = 50.0; let rx = r_x_ratio.max(1e-6);
let kappa = Self::compute_kappa(rx);
let kappa_term = (kappa - 1.02).max(1e-6) / 0.98;
let exponent = 4.0 * f * tc_s * kappa_term.ln();
let m = if exponent.abs() < 1e-9 {
1.0
} else {
(exponent.exp() - 1.0) / exponent
};
let n = 1.0;
(m.max(0.0), n)
}
pub fn compute(
&self,
network: &PowerNetwork,
input: &ShortCircuitInput,
) -> Result<ShortCircuitResult> {
self.compute_with_grounding(network, input, &[])
}
pub fn compute_with_grounding(
&self,
network: &PowerNetwork,
input: &ShortCircuitInput,
grounding: &[GroundingConfig],
) -> Result<ShortCircuitResult> {
let n = network.bus_count();
let f = input.fault_bus;
if f >= n {
return Err(OxiGridError::InvalidParameter(format!(
"fault_bus {f} out of range {n}"
)));
}
let z1 = self.build_z1_matrix(network)?;
let z2 = self.build_z2_matrix(network)?;
let z0 = self.build_z0_matrix(network, grounding)?;
let z1ff = z1[f][f];
let z2ff = z2[f][f];
let z0ff = z0[f][f];
let v_eq = Complex64::new(self.c_factor * input.pre_fault_voltage_pu, 0.0);
let zf = input.fault_impedance;
let z_total_3ph = z1ff + zf;
let i_k3_pu = if z_total_3ph.norm() < 1e-12 {
0.0
} else {
(v_eq / z_total_3ph).norm()
};
let z_total_2ph = z1ff + z2ff + zf;
let i_k2_pu = if z_total_2ph.norm() < 1e-12 {
0.0
} else {
(v_eq / z_total_2ph).norm() * 3.0_f64.sqrt()
};
let z_total_slg = z1ff + z2ff + z0ff + zf * 3.0;
let i_k1_pu = if z_total_slg.norm() < 1e-12 {
0.0
} else {
(v_eq * 3.0 / z_total_slg).norm()
};
let z_sum_20 = z2ff + z0ff;
let z_par = if z_sum_20.norm() < 1e-12 {
Complex64::new(0.0, 0.0)
} else {
z2ff * z0ff / z_sum_20
};
let z_total_dlg = z1ff + z_par + zf;
let i1_dlg_pu = if z_total_dlg.norm() < 1e-12 {
Complex64::new(0.0, 0.0)
} else {
v_eq / z_total_dlg
};
let i2_dlg_pu = if z_sum_20.norm() < 1e-12 {
Complex64::new(0.0, 0.0)
} else {
-i1_dlg_pu * z0ff / z_sum_20
};
let i0_dlg_pu = if z_sum_20.norm() < 1e-12 {
Complex64::new(0.0, 0.0)
} else {
-i1_dlg_pu * z2ff / z_sum_20
};
let a = Complex64::from_polar(1.0, 2.0 * std::f64::consts::PI / 3.0);
let a2 = a * a;
let ib_dlg = i0_dlg_pu + a2 * i1_dlg_pu + a * i2_dlg_pu;
let ic_dlg = i0_dlg_pu + a * i1_dlg_pu + a2 * i2_dlg_pu;
let i_ke2e1_pu = ib_dlg.norm().max(ic_dlg.norm());
let bus_kv = {
let bus = &network.buses[f];
if bus.base_kv.0 > 1e-6 {
bus.base_kv.0
} else {
1.0
}
};
let i_base_ka = network.base_mva / (3.0_f64.sqrt() * bus_kv);
let z_base_ohm = (bus_kv * bus_kv) / network.base_mva;
let i_k3_ka = i_k3_pu * i_base_ka;
let i_k2_ka = i_k2_pu * i_base_ka;
let i_k1_ka = i_k1_pu * i_base_ka;
let i_k_e2e1_ka = i_ke2e1_pu * i_base_ka;
let s_k_mva = 3.0_f64.sqrt() * bus_kv * i_k3_ka;
let z_k_ohm = z1ff.norm() * z_base_ohm;
let r_x_ratio = if z1ff.im.abs() < 1e-12 {
10.0 } else {
(z1ff.re / z1ff.im).abs()
};
let kappa = Self::compute_kappa(r_x_ratio);
let i_p_ka = kappa * 2.0_f64.sqrt() * i_k3_ka;
let tc = self.clearing_time_s;
let (m, n_factor) = Self::compute_thermal_factor(tc, r_x_ratio);
let i_th_ka = i_k3_ka * (m + n_factor).sqrt();
let thermal_energy_ka2s = i_th_ka * i_th_ka * tc;
let i_b_ka = Some(i_k3_ka);
Ok(ShortCircuitResult {
fault_bus: f,
fault_type: input.fault_type,
i_k3_ka,
i_k2_ka,
i_k1_ka,
i_k_e2e1_ka,
s_k_mva,
z_k_ohm,
r_x_ratio,
i_p_ka,
kappa,
i_th_ka,
thermal_energy_ka2s,
i_b_ka,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InductionMotorGroup {
pub bus: usize,
pub total_mva: f64,
pub rated_voltage_kv: f64,
pub power_factor: f64,
pub efficiency: f64,
pub locked_rotor_current: f64,
pub x_d_prime: f64,
pub t_d_prime_s: f64,
}
impl InductionMotorGroup {
pub fn new(bus: usize, total_mva: f64, voltage_kv: f64) -> Self {
Self {
bus,
total_mva,
rated_voltage_kv: voltage_kv,
power_factor: 0.85,
efficiency: 0.92,
locked_rotor_current: 5.5,
x_d_prime: 0.20,
t_d_prime_s: 0.040,
}
}
pub fn sc_contribution_ka(&self, bus_voltage_pu: f64, _base_mva: f64, _base_kv: f64) -> f64 {
if self.total_mva < 1e-9 || self.rated_voltage_kv < 1e-9 {
return 0.0;
}
let i_rated_ka = self.total_mva / (3.0_f64.sqrt() * self.rated_voltage_kv);
i_rated_ka * self.locked_rotor_current * bus_voltage_pu
}
pub fn contribution_at_t(&self, t_s: f64, initial_ka: f64) -> f64 {
if self.t_d_prime_s < 1e-9 {
return 0.0;
}
initial_ka * (-t_s / self.t_d_prime_s).exp()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SynchronousMotorGroup {
pub bus: usize,
pub total_mva: f64,
pub rated_voltage_kv: f64,
pub x_d_subtransient: f64,
pub x_d_transient: f64,
pub t_d_subtransient_s: f64,
pub t_d_transient_s: f64,
pub e_fd: f64,
}
impl SynchronousMotorGroup {
pub fn new(bus: usize, total_mva: f64, voltage_kv: f64) -> Self {
Self {
bus,
total_mva,
rated_voltage_kv: voltage_kv,
x_d_subtransient: 0.15,
x_d_transient: 0.25,
t_d_subtransient_s: 0.015,
t_d_transient_s: 0.80,
e_fd: 1.05,
}
}
fn base_current_ka(&self) -> f64 {
if self.rated_voltage_kv < 1e-9 {
return 0.0;
}
self.total_mva / (3.0_f64.sqrt() * self.rated_voltage_kv)
}
pub fn subtransient_sc_ka(&self, _base_mva: f64, _base_kv: f64) -> f64 {
if self.x_d_subtransient < 1e-9 {
return 0.0;
}
let i_pp_pu = self.e_fd / self.x_d_subtransient;
i_pp_pu * self.base_current_ka()
}
fn transient_sc_ka(&self) -> f64 {
if self.x_d_transient < 1e-9 {
return 0.0;
}
let i_p_pu = self.e_fd / self.x_d_transient;
i_p_pu * self.base_current_ka()
}
fn steady_state_sc_ka(&self) -> f64 {
let x_d_synch = (self.x_d_transient * 1.5).max(self.x_d_transient);
if x_d_synch < 1e-9 {
return 0.0;
}
let i_d_pu = self.e_fd / x_d_synch;
i_d_pu * self.base_current_ka()
}
pub fn contribution_at_t(&self, t_s: f64) -> f64 {
let i_pp = self.subtransient_sc_ka(0.0, 0.0); let i_p = self.transient_sc_ka();
let i_d = self.steady_state_sc_ka();
let subtransient_decay = if self.t_d_subtransient_s > 1e-9 {
(-t_s / self.t_d_subtransient_s).exp()
} else {
0.0
};
let transient_decay = if self.t_d_transient_s > 1e-9 {
(-t_s / self.t_d_transient_s).exp()
} else {
0.0
};
(i_pp - i_p) * subtransient_decay + (i_p - i_d) * transient_decay + i_d
}
}
#[allow(clippy::needless_range_loop)]
pub fn short_circuit_survey(
network: &PowerNetwork,
calculator: &Iec60909Calculator,
motor_groups: &[InductionMotorGroup],
) -> Result<Vec<(usize, ShortCircuitResult)>> {
let n = network.bus_count();
if n == 0 {
return Ok(Vec::new());
}
let z1 = calculator.build_z1_matrix(network)?;
let mut results = Vec::with_capacity(n);
for bus_idx in 0..n {
let input = ShortCircuitInput::bolted_three_phase(bus_idx);
let mut result = calculator.compute(network, &input)?;
let motor_contribution_ka: f64 = motor_groups
.iter()
.filter(|mg| mg.bus == bus_idx)
.map(|mg| {
let z1ff = z1[bus_idx][bus_idx];
let v_eq = calculator.c_factor;
let i_k3_base = if z1ff.norm() > 1e-12 {
v_eq / z1ff.norm()
} else {
0.0
};
let bus_kv = {
let b = &network.buses[bus_idx];
if b.base_kv.0 > 1e-6 {
b.base_kv.0
} else {
1.0
}
};
let i_base_ka = network.base_mva / (3.0_f64.sqrt() * bus_kv);
let v_bus_pu = (i_k3_base * i_base_ka).clamp(0.0, 1.0);
mg.sc_contribution_ka(
v_bus_pu.max(0.9), network.base_mva,
bus_kv,
)
})
.sum();
result.i_k3_ka += motor_contribution_ka;
result.i_k2_ka += motor_contribution_ka * 3.0_f64.sqrt() / 2.0;
result.i_k1_ka += motor_contribution_ka;
result.i_p_ka = result.kappa * 2.0_f64.sqrt() * result.i_k3_ka;
let (m, n_f) = Iec60909Calculator::compute_thermal_factor(
calculator.clearing_time_s,
result.r_x_ratio,
);
result.i_th_ka = result.i_k3_ka * (m + n_f).sqrt();
result.thermal_energy_ka2s = result.i_th_ka * result.i_th_ka * calculator.clearing_time_s;
result.i_b_ka = Some(result.i_k3_ka);
results.push((bus_idx, result));
}
Ok(results)
}
pub fn sc_level_extremes(survey: &[(usize, ShortCircuitResult)]) -> (usize, f64, usize, f64) {
if survey.is_empty() {
return (0, 0.0, 0, 0.0);
}
let mut min_bus = survey[0].0;
let mut min_ka = survey[0].1.i_k3_ka;
let mut max_bus = survey[0].0;
let mut max_ka = survey[0].1.i_k3_ka;
for (bus_idx, result) in survey.iter() {
let ka = result.i_k3_ka;
if ka < min_ka {
min_ka = ka;
min_bus = *bus_idx;
}
if ka > max_ka {
max_ka = ka;
max_bus = *bus_idx;
}
}
(min_bus, min_ka, max_bus, max_ka)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayVerification {
pub relay_idx: usize,
pub bus: usize,
pub max_reach_ok: bool,
pub min_reach_ok: bool,
pub coordination_ok: bool,
pub notes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RelaySettings {
pub relay: OcRelay,
pub bus: usize,
pub min_reach_ka: f64,
}
pub fn verify_relay_settings(
survey: &[(usize, ShortCircuitResult)],
relay_settings: &[RelaySettings],
) -> Vec<RelayVerification> {
let mut sc_at_bus: std::collections::HashMap<usize, f64> = std::collections::HashMap::new();
for (bus_idx, result) in survey {
sc_at_bus.insert(*bus_idx, result.i_k3_ka);
}
relay_settings
.iter()
.enumerate()
.map(|(relay_idx, rs)| {
let i_pickup_ka = rs.relay.i_pickup; let max_sc_ka = sc_at_bus.get(&rs.bus).copied().unwrap_or(0.0);
let mut notes = Vec::new();
let max_reach_ok = i_pickup_ka < max_sc_ka;
if !max_reach_ok {
notes.push(format!(
"Relay {relay_idx} at bus {}: pickup {:.3} kA >= max SC {:.3} kA — relay is blind",
rs.bus, i_pickup_ka, max_sc_ka
));
}
let min_reach_ok = if rs.min_reach_ka > 1e-9 {
let ok = i_pickup_ka > rs.min_reach_ka;
if !ok {
notes.push(format!(
"Relay {relay_idx} at bus {}: pickup {:.3} kA < far-end min SC {:.3} kA — over-reach risk",
rs.bus, i_pickup_ka, rs.min_reach_ka
));
}
ok
} else {
true };
RelayVerification {
relay_idx,
bus: rs.bus,
max_reach_ok,
min_reach_ok,
coordination_ok: max_reach_ok && min_reach_ok,
notes,
}
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnsiCalculator {
pub multiplying_factor: f64,
}
impl Default for AnsiCalculator {
fn default() -> Self {
Self::new()
}
}
impl AnsiCalculator {
pub fn new() -> Self {
Self {
multiplying_factor: 1.6,
}
}
pub fn with_factor(mf: f64) -> Self {
Self {
multiplying_factor: mf,
}
}
pub fn momentary_duty(
&self,
z_source: Complex64,
v_pre_fault: f64,
base_kv: f64,
base_mva: f64,
) -> f64 {
if z_source.norm() < 1e-12 || base_kv < 1e-9 {
return 0.0;
}
let i_sym_pu = v_pre_fault / z_source.norm();
let i_base_ka = base_mva / (3.0_f64.sqrt() * base_kv);
let i_sym_ka = i_sym_pu * i_base_ka;
self.multiplying_factor * 2.0_f64.sqrt() * i_sym_ka
}
pub fn interrupting_duty(
&self,
i_sym_ka: f64,
r_x_ratio: f64,
contact_parting_time_s: f64,
) -> f64 {
if i_sym_ka < 0.0 {
return 0.0;
}
let omega = 2.0 * std::f64::consts::PI * 60.0; let rx = r_x_ratio.max(0.0);
let dc_decay = (-2.0 * omega * contact_parting_time_s * rx).exp();
let asym_factor = (1.0 + 2.0 * dc_decay).sqrt();
i_sym_ka * asym_factor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::branch::Branch;
use crate::network::bus::{Bus, BusType};
use crate::network::topology::PowerNetwork;
use crate::units::Voltage;
fn two_bus_network() -> PowerNetwork {
let mut net = PowerNetwork::new(100.0);
let mut b1 = Bus::new(1, BusType::Slack);
b1.base_kv = Voltage(110.0);
let mut b2 = Bus::new(2, BusType::PQ);
b2.base_kv = Voltage(110.0);
net.buses.push(b1);
net.buses.push(b2);
net.branches.push(Branch {
from_bus: 1,
to_bus: 2,
r: 0.01,
x: 0.10,
b: 0.02,
rate_a: 200.0,
rate_b: 200.0,
rate_c: 200.0,
tap: 0.0,
shift: 0.0,
status: true,
});
net
}
fn three_bus_network() -> PowerNetwork {
let mut net = PowerNetwork::new(100.0);
for id in 1..=3usize {
let bt = if id == 1 { BusType::Slack } else { BusType::PQ };
let mut b = Bus::new(id, bt);
b.base_kv = Voltage(33.0);
net.buses.push(b);
}
for (from, to, r, x) in [(1, 2, 0.02, 0.15), (2, 3, 0.03, 0.20), (1, 3, 0.01, 0.12)] {
net.branches.push(Branch {
from_bus: from,
to_bus: to,
r,
x,
b: 0.01,
rate_a: 100.0,
rate_b: 100.0,
rate_c: 100.0,
tap: 0.0,
shift: 0.0,
status: true,
});
}
net
}
#[test]
fn test_iec60909_bolted_3phase_positive() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let input = ShortCircuitInput::bolted_three_phase(0);
let result = calc.compute(&net, &input).expect("compute failed");
assert!(result.i_k3_ka > 0.0, "3-phase SC current must be positive");
assert!(result.s_k_mva > 0.0, "SC MVA must be positive");
assert!(result.z_k_ohm > 0.0, "Z_k must be positive");
}
#[test]
fn test_iec60909_c_factor_scales_current() {
let net = two_bus_network();
let calc_1 = Iec60909Calculator::new(1.0);
let calc_11 = Iec60909Calculator::new(1.1);
let input = ShortCircuitInput::bolted_three_phase(0);
let r1 = calc_1.compute(&net, &input).expect("compute failed");
let r11 = calc_11.compute(&net, &input).expect("compute failed");
assert!(
r11.i_k3_ka > r1.i_k3_ka,
"c=1.1 should give higher current than c=1.0"
);
let ratio = r11.i_k3_ka / r1.i_k3_ka;
assert!((ratio - 1.1).abs() < 0.01, "ratio={:.4}", ratio);
}
#[test]
fn test_iec60909_all_fault_types_positive() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let input = ShortCircuitInput::bolted_three_phase(0);
let result = calc.compute(&net, &input).expect("compute failed");
assert!(result.i_k3_ka > 0.0);
assert!(result.i_k2_ka > 0.0);
assert!(result.i_k1_ka > 0.0);
}
#[test]
fn test_iec60909_fault_impedance_reduces_current() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let input_bolted = ShortCircuitInput::bolted_three_phase(0);
let input_zf = ShortCircuitInput {
fault_bus: 0,
fault_type: FaultType::ThreePhase,
fault_impedance: Complex64::new(1.0, 0.0),
pre_fault_voltage_pu: 1.0,
};
let r_bolted = calc.compute(&net, &input_bolted).expect("compute failed");
let r_zf = calc.compute(&net, &input_zf).expect("compute failed");
assert!(
r_bolted.i_k3_ka > r_zf.i_k3_ka,
"Bolted fault must exceed resistive-impedance fault: {:.6} vs {:.6}",
r_bolted.i_k3_ka,
r_zf.i_k3_ka
);
}
#[test]
fn test_iec60909_kappa_high_rx() {
let kappa = Iec60909Calculator::compute_kappa(0.0);
assert!(
(kappa - 2.0).abs() < 1e-6,
"κ(R/X=0) should be 2.0, got {:.6}",
kappa
);
}
#[test]
fn test_iec60909_kappa_low_rx() {
let kappa = Iec60909Calculator::compute_kappa(10.0);
assert!(
(kappa - 1.02).abs() < 1e-4,
"κ(R/X=10) should be ≈1.02, got {:.6}",
kappa
);
}
#[test]
fn test_iec60909_kappa_range() {
for &rx in &[0.0, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0] {
let k = Iec60909Calculator::compute_kappa(rx);
assert!(
(1.02..=2.001).contains(&k),
"κ={:.4} out of [1.02, 2.0] for R/X={rx}",
k
);
}
}
#[test]
fn test_iec60909_peak_current_consistent() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let result = calc
.compute(&net, &ShortCircuitInput::bolted_three_phase(0))
.unwrap();
let expected_ip = result.kappa * 2.0_f64.sqrt() * result.i_k3_ka;
assert!(
(result.i_p_ka - expected_ip).abs() < 1e-9,
"ip={:.6} expected={:.6}",
result.i_p_ka,
expected_ip
);
}
#[test]
fn test_z_matrix_dimensions_2bus() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let z1 = calc.build_z1_matrix(&net).expect("Z1 build failed");
let z2 = calc.build_z2_matrix(&net).expect("Z2 build failed");
let z0 = calc.build_z0_matrix(&net, &[]).expect("Z0 build failed");
let n = net.bus_count();
assert_eq!(z1.len(), n);
assert_eq!(z1[0].len(), n);
assert_eq!(z2.len(), n);
assert_eq!(z2[0].len(), n);
assert_eq!(z0.len(), n);
assert_eq!(z0[0].len(), n);
}
#[test]
fn test_z_matrix_dimensions_3bus() {
let net = three_bus_network();
let calc = Iec60909Calculator::new(1.0);
let z1 = calc.build_z1_matrix(&net).expect("Z1 build failed");
let n = net.bus_count();
assert_eq!(z1.len(), n);
for row in &z1 {
assert_eq!(row.len(), n, "Z1 must be n×n");
}
}
#[test]
fn test_z1_z2_equal_balanced() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let z1 = calc.build_z1_matrix(&net).unwrap();
let z2 = calc.build_z2_matrix(&net).unwrap();
let n = net.bus_count();
for i in 0..n {
for j in 0..n {
assert!(
(z1[i][j] - z2[i][j]).norm() < 1e-10,
"Z1[{i},{j}] != Z2[{i},{j}]"
);
}
}
}
#[test]
fn test_z_matrix_symmetric() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let z1 = calc.build_z1_matrix(&net).unwrap();
let n = net.bus_count();
#[allow(clippy::needless_range_loop)]
for i in 0..n {
for j in 0..n {
assert!(
(z1[i][j] - z1[j][i]).norm() < 1e-8,
"Z1 not symmetric at [{i},{j}]"
);
}
}
}
#[test]
fn test_grounding_solidly_zero_impedance() {
let cfg = GroundingConfig::SolidlyGrounded;
let z = cfg.impedance_pu(110.0, 100.0);
assert!(z.norm() < 1e-12, "Solidly grounded impedance must be 0");
}
#[test]
fn test_grounding_delta_infinite_impedance() {
let cfg = GroundingConfig::UngroundedDelta;
let z = cfg.impedance_pu(110.0, 100.0);
assert!(z.re > 1e6, "Delta impedance must be very large");
}
#[test]
fn test_grounding_impedance_conversion() {
let cfg = GroundingConfig::Impedance {
r_ohm: 10.0,
x_ohm: 0.0,
};
let z = cfg.impedance_pu(110.0, 100.0);
let z_base = 110.0_f64.powi(2) / 100.0;
let expected = 10.0 / z_base;
assert!(
(z.re - expected).abs() < 1e-8,
"Z_pu.re={:.6} expected={:.6}",
z.re,
expected
);
}
#[test]
fn test_motor_contribution_positive() {
let mg = InductionMotorGroup::new(0, 10.0, 6.3);
let contrib = mg.sc_contribution_ka(1.0, 100.0, 6.3);
assert!(contrib > 0.0, "Motor SC contribution must be positive");
}
#[test]
fn test_motor_contribution_zero_mva() {
let mg = InductionMotorGroup::new(0, 0.0, 6.3);
let contrib = mg.sc_contribution_ka(1.0, 100.0, 6.3);
assert!(
(contrib).abs() < 1e-12,
"Zero-MVA motor should give zero contribution"
);
}
#[test]
fn test_motor_contribution_decays() {
let mg = InductionMotorGroup::new(0, 10.0, 6.3);
let i0 = mg.sc_contribution_ka(1.0, 100.0, 6.3);
let i_t1 = mg.contribution_at_t(0.05, i0);
let i_t2 = mg.contribution_at_t(0.10, i0);
assert!(
i_t1 < i0,
"Contribution at t=50ms should be less than t=0: {i_t1:.4} < {i0:.4}"
);
assert!(
i_t2 < i_t1,
"Contribution at t=100ms should be less than t=50ms: {i_t2:.4} < {i_t1:.4}"
);
}
#[test]
fn test_motor_contribution_decays_to_near_zero() {
let mg = InductionMotorGroup::new(0, 10.0, 6.3);
let i0 = mg.sc_contribution_ka(1.0, 100.0, 6.3);
let i_late = mg.contribution_at_t(mg.t_d_prime_s * 10.0, i0);
assert!(
i_late < i0 * 1e-3,
"Motor contribution should nearly vanish at 10*T'd: {i_late:.6} vs {i0:.4}"
);
}
#[test]
fn test_synchronous_motor_subtransient_positive() {
let mg = SynchronousMotorGroup::new(0, 50.0, 11.0);
let i_pp = mg.subtransient_sc_ka(100.0, 11.0);
assert!(
i_pp > 0.0,
"Synchronous motor subtransient contribution must be positive"
);
}
#[test]
fn test_synchronous_motor_subtransient_gt_transient() {
let mg = SynchronousMotorGroup::new(0, 50.0, 11.0);
let i_pp = mg.subtransient_sc_ka(100.0, 11.0);
let i_p = mg.transient_sc_ka();
assert!(
i_pp > i_p,
"Subtransient current {i_pp:.4} should exceed transient {i_p:.4}"
);
}
#[test]
fn test_synchronous_motor_contribution_at_t() {
let mg = SynchronousMotorGroup::new(0, 50.0, 11.0);
let i_0 = mg.contribution_at_t(0.0);
let i_50ms = mg.contribution_at_t(0.05);
let i_1s = mg.contribution_at_t(1.0);
assert!(i_0 > i_50ms, "t=0 must exceed t=50ms");
assert!(
i_1s > 0.0,
"Synchronous motor has sustained contribution (I_d > 0)"
);
}
#[test]
fn test_sc_survey_all_buses() {
let net = three_bus_network();
let calc = Iec60909Calculator::new(1.0);
let survey = short_circuit_survey(&net, &calc, &[]).expect("survey failed");
assert_eq!(
survey.len(),
net.bus_count(),
"Survey must cover all buses: expected {}, got {}",
net.bus_count(),
survey.len()
);
}
#[test]
fn test_sc_survey_bus_indices_match() {
let net = three_bus_network();
let calc = Iec60909Calculator::new(1.0);
let survey = short_circuit_survey(&net, &calc, &[]).expect("survey failed");
for (i, (bus_idx, _)) in survey.iter().enumerate() {
assert_eq!(*bus_idx, i, "Survey bus index mismatch at position {i}");
}
}
#[test]
fn test_sc_survey_positive_currents() {
let net = three_bus_network();
let calc = Iec60909Calculator::new(1.0);
let survey = short_circuit_survey(&net, &calc, &[]).expect("survey failed");
for (bus_idx, result) in &survey {
assert!(
result.i_k3_ka > 0.0,
"Bus {bus_idx}: i_k3_ka must be positive, got {:.4}",
result.i_k3_ka
);
}
}
#[test]
fn test_sc_survey_with_motor_groups() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let motors = vec![InductionMotorGroup::new(0, 5.0, 110.0)];
let survey_no_motor = short_circuit_survey(&net, &calc, &[]).unwrap();
let survey_with_motor = short_circuit_survey(&net, &calc, &motors).unwrap();
assert!(
survey_with_motor[0].1.i_k3_ka >= survey_no_motor[0].1.i_k3_ka,
"Motor contribution should increase bus 0 SC current"
);
}
#[test]
fn test_sc_extremes_valid_ordering() {
let net = three_bus_network();
let calc = Iec60909Calculator::new(1.0);
let survey = short_circuit_survey(&net, &calc, &[]).expect("survey failed");
let (min_bus, min_ka, max_bus, max_ka) = sc_level_extremes(&survey);
assert!(
min_ka <= max_ka,
"min SC {min_ka:.4} must be <= max SC {max_ka:.4}"
);
assert!(min_bus < net.bus_count(), "min_bus index must be valid");
assert!(max_bus < net.bus_count(), "max_bus index must be valid");
}
#[test]
fn test_sc_extremes_single_entry() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let survey = short_circuit_survey(&net, &calc, &[]).unwrap();
let (min_b, min_ka, max_b, max_ka) = sc_level_extremes(&survey);
assert!(min_ka > 0.0);
assert!(max_ka >= min_ka);
assert!(min_b < 2);
assert!(max_b < 2);
}
#[test]
fn test_sc_extremes_empty() {
let (mb, mk, xb, xk) = sc_level_extremes(&[]);
assert_eq!(mb, 0);
assert_eq!(xb, 0);
assert!((mk).abs() < 1e-12);
assert!((xk).abs() < 1e-12);
}
#[test]
fn test_verify_relay_sensitive_relay() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let survey = short_circuit_survey(&net, &calc, &[]).unwrap();
let sc_ka = survey[0].1.i_k3_ka;
use crate::protection::relay::{OcRelay, RelayCharacteristic};
let relay_settings = vec![RelaySettings {
relay: OcRelay::new(sc_ka * 0.1, 0.5, RelayCharacteristic::StandardInverse),
bus: 0,
min_reach_ka: 0.0,
}];
let verifications = verify_relay_settings(&survey, &relay_settings);
assert_eq!(verifications.len(), 1);
assert!(
verifications[0].max_reach_ok,
"Relay with low pickup should be sensitive"
);
}
#[test]
fn test_verify_relay_blind_relay() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let survey = short_circuit_survey(&net, &calc, &[]).unwrap();
let sc_ka = survey[0].1.i_k3_ka;
use crate::protection::relay::{OcRelay, RelayCharacteristic};
let relay_settings = vec![RelaySettings {
relay: OcRelay::new(sc_ka * 10.0, 0.5, RelayCharacteristic::StandardInverse),
bus: 0,
min_reach_ka: 0.0,
}];
let verifications = verify_relay_settings(&survey, &relay_settings);
assert!(
!verifications[0].max_reach_ok,
"Relay with very high pickup should be blind"
);
}
#[test]
fn test_ansi_momentary_duty_positive() {
let calc = AnsiCalculator::new();
let z = Complex64::new(0.01, 0.12);
let duty = calc.momentary_duty(z, 1.0, 110.0, 100.0);
assert!(duty > 0.0, "Momentary duty must be positive: {duty:.4}");
}
#[test]
fn test_ansi_momentary_duty_zero_for_zero_impedance() {
let calc = AnsiCalculator::new();
let z = Complex64::new(0.0, 0.0);
let duty = calc.momentary_duty(z, 1.0, 110.0, 100.0);
assert!(
(duty).abs() < 1e-12,
"Zero impedance should return 0 (protect against inf)"
);
}
#[test]
fn test_ansi_interrupting_duty_positive() {
let calc = AnsiCalculator::new();
let duty = calc.interrupting_duty(10.0, 0.1, 0.05);
assert!(duty > 0.0, "Interrupting duty must be positive");
}
#[test]
fn test_ansi_interrupting_duty_decays_with_time() {
let calc = AnsiCalculator::new();
let i_fast = calc.interrupting_duty(10.0, 5.0, 0.033);
let i_slow = calc.interrupting_duty(10.0, 5.0, 0.083);
assert!(
i_fast >= i_slow,
"Faster interruption should have higher asymmetry: {i_fast:.4} >= {i_slow:.4}"
);
}
#[test]
fn test_ansi_interrupting_duty_approaches_sym_at_large_t() {
let calc = AnsiCalculator::new();
let i_asym = calc.interrupting_duty(10.0, 0.0, 10.0); let i_asym_rx = calc.interrupting_duty(10.0, 100.0, 1.0);
assert!(
(i_asym_rx - 10.0).abs() < 0.01,
"High R/X should make asym ≈ sym: {i_asym_rx:.6}"
);
let _ = i_asym; }
#[test]
fn test_ansi_interrupting_duty_negative_input() {
let calc = AnsiCalculator::new();
let duty = calc.interrupting_duty(-5.0, 0.1, 0.05);
assert!((duty).abs() < 1e-12, "Negative I_sym should yield 0");
}
#[test]
fn test_ansi_default_factor() {
let calc = AnsiCalculator::new();
assert!((calc.multiplying_factor - 1.6).abs() < 1e-9);
}
#[test]
fn test_thermal_factor_positive() {
let (m, n) = Iec60909Calculator::compute_thermal_factor(0.1, 0.1);
assert!(m >= 0.0, "m factor must be non-negative");
assert!(n > 0.0, "n factor must be positive");
}
#[test]
fn test_thermal_equivalent_current_positive() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let result = calc
.compute(&net, &ShortCircuitInput::bolted_three_phase(0))
.unwrap();
assert!(
result.i_th_ka > 0.0,
"Thermal equivalent current must be positive"
);
assert!(
result.thermal_energy_ka2s > 0.0,
"Thermal energy must be positive"
);
}
#[test]
fn test_sc_out_of_range_bus() {
let net = two_bus_network();
let calc = Iec60909Calculator::new(1.0);
let input = ShortCircuitInput::bolted_three_phase(99);
let result = calc.compute(&net, &input);
assert!(result.is_err(), "Out-of-range bus should return error");
}
}