use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
const OMEGA_50HZ: f64 = 2.0 * PI * 50.0;
const SQRT3: f64 = 1.732_050_808_56;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FclTechnology {
ResistiveSuperconducting {
critical_current_ka: f64,
normal_resistance_ohm: f64,
recovery_time_s: f64,
quench_rise_time_ms: f64,
},
InductiveSuperconducting {
saturated_inductance_mh: f64,
unsaturated_inductance_mh: f64,
saturation_current_ka: f64,
bias_coil_current_a: f64,
},
SolidStateSeries {
trigger_current_ka: f64,
inserted_resistance_ohm: f64,
inserted_reactance_ohm: f64,
switching_time_ms: f64,
},
Bridge {
reactor_inductance_mh: f64,
trigger_current_ka: f64,
bypass_resistance_ohm: f64,
},
IsLimiter {
fuse_current_ka: f64,
parallel_impedance_ohm: f64,
reset_required: bool,
},
}
impl FclTechnology {
pub fn trigger_threshold_ka(&self) -> f64 {
match self {
Self::ResistiveSuperconducting {
critical_current_ka,
..
} => *critical_current_ka,
Self::InductiveSuperconducting {
saturation_current_ka,
..
} => *saturation_current_ka,
Self::SolidStateSeries {
trigger_current_ka, ..
} => *trigger_current_ka,
Self::Bridge {
trigger_current_ka, ..
} => *trigger_current_ka,
Self::IsLimiter {
fuse_current_ka, ..
} => *fuse_current_ka,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FclState {
Normal,
Triggered,
Quenching,
Recovering,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaultCurrentLimiter {
pub id: usize,
pub branch_from: usize,
pub branch_to: usize,
pub technology: FclTechnology,
pub rated_current_ka: f64,
pub rated_voltage_kv: f64,
pub operating_state: FclState,
pub fault_count: u32,
pub total_energy_absorbed_j: f64,
time_in_state_ms: f64,
}
impl FaultCurrentLimiter {
pub fn new(
id: usize,
branch_from: usize,
branch_to: usize,
technology: FclTechnology,
rated_current_ka: f64,
rated_voltage_kv: f64,
) -> Self {
Self {
id,
branch_from,
branch_to,
technology,
rated_current_ka,
rated_voltage_kv,
operating_state: FclState::Normal,
fault_count: 0,
total_energy_absorbed_j: 0.0,
time_in_state_ms: 0.0,
}
}
pub fn should_trigger(&self, current_ka: f64) -> bool {
if self.operating_state != FclState::Normal {
return false;
}
current_ka > self.technology.trigger_threshold_ka()
}
pub fn effective_impedance(&self, time_after_trigger_ms: f64) -> (f64, f64) {
match &self.technology {
FclTechnology::ResistiveSuperconducting {
normal_resistance_ohm,
quench_rise_time_ms,
..
} => match self.operating_state {
FclState::Normal | FclState::Recovering => (0.0, 0.0),
FclState::Quenching => {
let frac = if *quench_rise_time_ms > 0.0 {
(time_after_trigger_ms / quench_rise_time_ms).min(1.0)
} else {
1.0
};
(frac * normal_resistance_ohm, 0.0)
}
FclState::Triggered => (*normal_resistance_ohm, 0.0),
FclState::Failed => (0.0, 0.0),
},
FclTechnology::InductiveSuperconducting {
saturated_inductance_mh,
unsaturated_inductance_mh,
..
} => {
let x_sat = OMEGA_50HZ * saturated_inductance_mh * 1e-3;
let x_unsat = OMEGA_50HZ * unsaturated_inductance_mh * 1e-3;
match self.operating_state {
FclState::Normal | FclState::Recovering => (0.0, x_sat),
FclState::Triggered | FclState::Quenching => (0.0, x_unsat),
FclState::Failed => (0.0, x_sat),
}
}
FclTechnology::SolidStateSeries {
inserted_resistance_ohm,
inserted_reactance_ohm,
switching_time_ms,
..
} => match self.operating_state {
FclState::Normal | FclState::Recovering => (0.0, 0.0),
FclState::Quenching => {
let frac = if *switching_time_ms > 0.0 {
(time_after_trigger_ms / switching_time_ms).min(1.0)
} else {
1.0
};
(
frac * inserted_resistance_ohm,
frac * inserted_reactance_ohm,
)
}
FclState::Triggered => (*inserted_resistance_ohm, *inserted_reactance_ohm),
FclState::Failed => (0.0, 0.0),
},
FclTechnology::Bridge {
reactor_inductance_mh,
bypass_resistance_ohm,
..
} => {
let x_reactor = OMEGA_50HZ * reactor_inductance_mh * 1e-3;
match self.operating_state {
FclState::Normal | FclState::Recovering => (0.0, x_reactor),
FclState::Triggered | FclState::Quenching => (*bypass_resistance_ohm, 0.0),
FclState::Failed => (0.0, x_reactor),
}
}
FclTechnology::IsLimiter {
parallel_impedance_ohm,
..
} => match self.operating_state {
FclState::Normal | FclState::Recovering => (0.0, 0.0),
FclState::Triggered | FclState::Quenching => (*parallel_impedance_ohm, 0.0),
FclState::Failed => (0.0, 0.0),
},
}
}
pub fn update_state(&mut self, current_ka: f64, dt_ms: f64) {
self.time_in_state_ms += dt_ms;
match self.operating_state.clone() {
FclState::Normal => {
if self.should_trigger(current_ka) {
self.operating_state = FclState::Quenching;
self.time_in_state_ms = 0.0;
self.fault_count += 1;
}
}
FclState::Quenching => {
let rise_time = self.quench_rise_time_ms();
if self.time_in_state_ms >= rise_time {
self.operating_state = FclState::Triggered;
self.time_in_state_ms = 0.0;
}
}
FclState::Triggered => {
if current_ka <= self.rated_current_ka {
self.operating_state = FclState::Recovering;
self.time_in_state_ms = 0.0;
}
}
FclState::Recovering => {
let recovery_ms = self.recovery_time_s() * 1000.0;
if self.time_in_state_ms >= recovery_ms {
self.operating_state = FclState::Normal;
self.time_in_state_ms = 0.0;
}
if current_ka > self.technology.trigger_threshold_ka() {
self.operating_state = FclState::Quenching;
self.time_in_state_ms = 0.0;
self.fault_count += 1;
}
}
FclState::Failed => {
}
}
}
pub fn compute_energy_absorbed(&self, fault_current_ka: f64, fault_duration_ms: f64) -> f64 {
let (r_eff, _x_eff) = self.effective_impedance(fault_duration_ms);
let i_a = fault_current_ka * 1e3; let t_s = fault_duration_ms * 1e-3; i_a * i_a * r_eff * t_s
}
pub fn is_overloaded(&self, fault_current_ka: f64, fault_duration_ms: f64) -> bool {
let absorbed = self.compute_energy_absorbed(fault_current_ka, fault_duration_ms);
let (r_rated, _) = self.effective_impedance(fault_duration_ms);
let i_rated_a = self.rated_current_ka * 1e3;
let max_energy_j = (i_rated_a * i_rated_a * r_rated * 1.0).max(1.0e6);
absorbed > max_energy_j
}
fn quench_rise_time_ms(&self) -> f64 {
match &self.technology {
FclTechnology::ResistiveSuperconducting {
quench_rise_time_ms,
..
} => *quench_rise_time_ms,
FclTechnology::SolidStateSeries {
switching_time_ms, ..
} => *switching_time_ms,
_ => 0.0,
}
}
fn recovery_time_s(&self) -> f64 {
match &self.technology {
FclTechnology::ResistiveSuperconducting {
recovery_time_s, ..
} => *recovery_time_s,
FclTechnology::SolidStateSeries { .. } => 0.1,
FclTechnology::InductiveSuperconducting { .. } => 0.5,
FclTechnology::Bridge { .. } => 0.2,
FclTechnology::IsLimiter { reset_required, .. } => {
if *reset_required {
f64::INFINITY
} else {
0.05
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FclSizing {
pub network_voltage_kv: f64,
pub base_mva: f64,
pub prospective_fault_current_ka: f64,
pub target_fault_current_ka: f64,
pub normal_load_current_ka: f64,
pub fault_duration_ms: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FclSizingResult {
pub required_impedance_pu: f64,
pub required_resistance_ohm: f64,
pub required_reactance_ohm: f64,
pub current_reduction_pct: f64,
pub energy_absorption_kj: f64,
pub recommended_technology: FclTechnology,
pub cost_estimate_million_eur: f64,
}
impl FclSizing {
pub fn new(
network_voltage_kv: f64,
base_mva: f64,
prospective_fault_current_ka: f64,
target_fault_current_ka: f64,
normal_load_current_ka: f64,
fault_duration_ms: f64,
) -> Self {
Self {
network_voltage_kv,
base_mva,
prospective_fault_current_ka,
target_fault_current_ka,
normal_load_current_ka,
fault_duration_ms,
}
}
pub fn compute_required_impedance(&self) -> Result<(f64, f64)> {
if self.prospective_fault_current_ka <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"prospective_fault_current_ka must be > 0".into(),
));
}
if self.target_fault_current_ka <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"target_fault_current_ka must be > 0".into(),
));
}
if self.target_fault_current_ka >= self.prospective_fault_current_ka {
return Err(OxiGridError::InvalidParameter(
"target_fault_current_ka must be less than prospective_fault_current_ka".into(),
));
}
let v_phase = self.network_voltage_kv * 1000.0 / SQRT3; let i_prosp_a = self.prospective_fault_current_ka * 1000.0;
let i_target_a = self.target_fault_current_ka * 1000.0;
let z_source = v_phase / i_prosp_a;
let z_target = v_phase / i_target_a;
let z_fcl = (z_target - z_source).max(0.0);
let r_ohm = z_fcl / SQRT3;
let x_ohm = z_fcl - r_ohm;
Ok((r_ohm, x_ohm))
}
pub fn recommend_technology(&self) -> FclTechnology {
if self.prospective_fault_current_ka < 5.0 {
let critical_current_ka = self.normal_load_current_ka * 1.5;
let r_required = self
.compute_required_impedance()
.map(|(r, _)| r)
.unwrap_or(1.0);
FclTechnology::ResistiveSuperconducting {
critical_current_ka,
normal_resistance_ohm: r_required.max(0.5),
recovery_time_s: 30.0,
quench_rise_time_ms: 2.0,
}
} else if self.fault_duration_ms < 50.0 {
let r_required = self
.compute_required_impedance()
.map(|(r, _)| r)
.unwrap_or(1.0);
let x_required = self
.compute_required_impedance()
.map(|(_, x)| x)
.unwrap_or(0.5);
FclTechnology::SolidStateSeries {
trigger_current_ka: self.normal_load_current_ka * 1.2,
inserted_resistance_ohm: r_required.max(0.1),
inserted_reactance_ohm: x_required.max(0.1),
switching_time_ms: 0.5,
}
} else {
let l_mh = self
.compute_required_impedance()
.map(|(_, x)| x / OMEGA_50HZ * 1000.0)
.unwrap_or(5.0);
FclTechnology::Bridge {
reactor_inductance_mh: l_mh.max(1.0),
trigger_current_ka: self.normal_load_current_ka * 1.3,
bypass_resistance_ohm: 0.5,
}
}
}
pub fn estimate_cost(&self, tech: &FclTechnology) -> f64 {
match tech {
FclTechnology::ResistiveSuperconducting {
critical_current_ka,
..
} => {
2.5 * (critical_current_ka / 1.0).max(1.0)
}
FclTechnology::InductiveSuperconducting {
saturation_current_ka,
..
} => 3.0 * (saturation_current_ka / 1.0).max(1.0),
FclTechnology::SolidStateSeries {
trigger_current_ka, ..
} => 1.5 * (trigger_current_ka / 1.0).max(1.0),
FclTechnology::Bridge {
trigger_current_ka, ..
} => 1.8 * (trigger_current_ka / 1.0).max(1.0),
FclTechnology::IsLimiter {
fuse_current_ka, ..
} => 0.8 * (fuse_current_ka / 1.0).max(1.0),
}
}
pub fn size(&self) -> Result<FclSizingResult> {
let (r_ohm, x_ohm) = self.compute_required_impedance()?;
let v_kv = self.network_voltage_kv;
let z_base = v_kv * v_kv / self.base_mva;
let z_ohm = (r_ohm * r_ohm + x_ohm * x_ohm).sqrt();
let z_pu = if z_base > 0.0 { z_ohm / z_base } else { 0.0 };
let current_reduction_pct = (self.prospective_fault_current_ka
- self.target_fault_current_ka)
/ self.prospective_fault_current_ka
* 100.0;
let i_a = self.target_fault_current_ka * 1e3;
let t_s = self.fault_duration_ms * 1e-3;
let energy_j = i_a * i_a * r_ohm * t_s;
let energy_kj = energy_j / 1000.0;
let recommended_technology = self.recommend_technology();
let cost_estimate_million_eur = self.estimate_cost(&recommended_technology);
Ok(FclSizingResult {
required_impedance_pu: z_pu,
required_resistance_ohm: r_ohm,
required_reactance_ohm: x_ohm,
current_reduction_pct,
energy_absorption_kj: energy_kj,
recommended_technology,
cost_estimate_million_eur,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FclPlacementOptimizer {
pub n_buses: usize,
pub n_branches: usize,
pub fault_currents_ka: Vec<Vec<f64>>,
pub branch_ratings_ka: Vec<f64>,
pub protection_coordination: Vec<(usize, usize)>,
pub max_fcl_budget: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FclPlacementResult {
pub placed_fcls: Vec<(usize, FclSizing)>,
pub max_fault_reduction_pct: f64,
pub n_protected_buses: usize,
pub total_cost_million_eur: f64,
pub remaining_overloads: Vec<usize>,
}
impl FclPlacementOptimizer {
pub fn new(
n_buses: usize,
n_branches: usize,
fault_currents_ka: Vec<Vec<f64>>,
branch_ratings_ka: Vec<f64>,
max_fcl_budget: f64,
) -> Self {
Self {
n_buses,
n_branches,
fault_currents_ka,
branch_ratings_ka,
protection_coordination: Vec::new(),
max_fcl_budget,
}
}
pub fn optimize_greedy(&self) -> FclPlacementResult {
let mut max_fault_per_branch = vec![0.0_f64; self.n_branches];
for fault_row in &self.fault_currents_ka {
for (b, &i) in fault_row.iter().enumerate() {
if b < self.n_branches && i > max_fault_per_branch[b] {
max_fault_per_branch[b] = i;
}
}
}
let mut excess: Vec<(usize, f64)> = (0..self.n_branches)
.filter_map(|b| {
let rating = *self.branch_ratings_ka.get(b).unwrap_or(&f64::INFINITY);
let fault_i = max_fault_per_branch[b];
if fault_i > rating {
Some((b, fault_i / rating))
} else {
None
}
})
.collect();
excess.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut placed_fcls: Vec<(usize, FclSizing)> = Vec::new();
let mut remaining_budget = self.max_fcl_budget;
let mut placed_branch_set: std::collections::HashSet<usize> =
std::collections::HashSet::new();
let mut max_reduction = 0.0_f64;
let nominal_voltage_kv = 110.0;
let base_mva = 100.0;
let fault_duration_ms = 100.0;
for (branch_idx, _excess_ratio) in &excess {
let b = *branch_idx;
if placed_branch_set.contains(&b) {
continue;
}
let prospective = max_fault_per_branch[b];
let rating = *self.branch_ratings_ka.get(b).unwrap_or(&prospective);
let target = rating * 0.9;
if target >= prospective {
continue;
}
let load = rating * 0.6;
let sizing = FclSizing::new(
nominal_voltage_kv,
base_mva,
prospective,
target,
load,
fault_duration_ms,
);
let tech = sizing.recommend_technology();
let cost = sizing.estimate_cost(&tech);
if cost <= remaining_budget {
let reduction = (prospective - target) / prospective * 100.0;
if reduction > max_reduction {
max_reduction = reduction;
}
remaining_budget -= cost;
placed_branch_set.insert(b);
placed_fcls.push((b, sizing));
}
}
let remaining_overloads: Vec<usize> = excess
.iter()
.map(|(b, _)| *b)
.filter(|b| !placed_branch_set.contains(b))
.collect();
let n_protected_buses = self.count_protected_buses(&placed_branch_set);
let total_cost = self.max_fcl_budget - remaining_budget;
FclPlacementResult {
placed_fcls,
max_fault_reduction_pct: max_reduction,
n_protected_buses,
total_cost_million_eur: total_cost,
remaining_overloads,
}
}
pub fn check_coordination(&self, placed: &[(usize, FclSizing)]) -> Vec<String> {
let placed_branches: std::collections::HashSet<usize> =
placed.iter().map(|(b, _)| *b).collect();
let mut warnings = Vec::new();
for &(breaker_branch, fcl_branch) in &self.protection_coordination {
if placed_branches.contains(&fcl_branch) {
warnings.push(format!(
"Coordination concern: FCL on branch {} may cause under-reach of breaker \
protecting branch {}. Verify CTI and relay pickup settings.",
fcl_branch, breaker_branch
));
}
if placed_branches.contains(&breaker_branch) {
warnings.push(format!(
"Coordination concern: FCL on branch {} (breaker branch) reduces fault \
current visibility. Check backup protection on branch {}.",
breaker_branch, fcl_branch
));
}
}
warnings
}
pub fn post_fcl_fault_current(
pre_fault_current_ka: f64,
z_source_ohm: f64,
z_fcl_ohm: f64,
) -> f64 {
let denominator = z_source_ohm + z_fcl_ohm;
if denominator < 1e-12 {
return pre_fault_current_ka;
}
pre_fault_current_ka * z_source_ohm / denominator
}
fn count_protected_buses(&self, placed_branches: &std::collections::HashSet<usize>) -> usize {
placed_branches.len().min(self.n_buses)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FclPerformanceAnalyzer {
pub fcls: Vec<FaultCurrentLimiter>,
pub simulation_duration_ms: f64,
pub dt_ms: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FclPerformanceResult {
pub peak_fault_current_ka: f64,
pub limited_fault_current_ka: f64,
pub reduction_factor: f64,
pub time_to_trigger_ms: f64,
pub energy_absorbed_kj: f64,
pub fcl_states_timeline: Vec<(f64, Vec<FclState>)>,
pub protection_time_saved_ms: f64,
}
impl FclPerformanceAnalyzer {
pub fn new(fcls: Vec<FaultCurrentLimiter>, simulation_duration_ms: f64, dt_ms: f64) -> Self {
Self {
fcls,
simulation_duration_ms,
dt_ms,
}
}
pub fn simulate_fault(&mut self, fault_current_profile: &[(f64, f64)]) -> FclPerformanceResult {
let mut peak_pre_fcl = 0.0_f64;
let mut peak_post_fcl = 0.0_f64;
let mut total_energy_j = 0.0_f64;
let mut time_to_trigger_ms = f64::INFINITY;
let mut timeline: Vec<(f64, Vec<FclState>)> = Vec::new();
let n_steps = ((self.simulation_duration_ms / self.dt_ms).ceil() as usize).max(1);
for step in 0..n_steps {
let t_ms = step as f64 * self.dt_ms;
let pre_current_ka = interpolate_profile(fault_current_profile, t_ms);
if pre_current_ka > peak_pre_fcl {
peak_pre_fcl = pre_current_ka;
}
let mut total_r_ohm = 0.0_f64;
for fcl in &mut self.fcls {
let was_normal = fcl.operating_state == FclState::Normal;
fcl.update_state(pre_current_ka, self.dt_ms);
if was_normal
&& fcl.operating_state != FclState::Normal
&& time_to_trigger_ms == f64::INFINITY
{
time_to_trigger_ms = t_ms;
}
let (r, _x) = fcl.effective_impedance(self.dt_ms);
total_r_ohm += r;
}
let v_nominal_kv = self
.fcls
.first()
.map(|f| f.rated_voltage_kv)
.unwrap_or(110.0);
let v_phase_v = v_nominal_kv * 1000.0 / SQRT3;
let i_pre_a = pre_current_ka * 1e3;
let z_source_ohm = if i_pre_a > 1.0 {
v_phase_v / i_pre_a
} else {
1.0
};
let post_current_ka = FclPlacementOptimizer::post_fcl_fault_current(
pre_current_ka,
z_source_ohm,
total_r_ohm,
);
if post_current_ka > peak_post_fcl {
peak_post_fcl = post_current_ka;
}
for fcl in &self.fcls {
let (r, _) = fcl.effective_impedance(self.dt_ms);
let i_a = post_current_ka * 1e3;
total_energy_j += i_a * i_a * r * (self.dt_ms * 1e-3);
}
let states: Vec<FclState> = self
.fcls
.iter()
.map(|f| f.operating_state.clone())
.collect();
timeline.push((t_ms, states));
}
if time_to_trigger_ms == f64::INFINITY {
time_to_trigger_ms = 0.0;
}
let reduction_factor = if peak_pre_fcl > 1e-12 {
peak_post_fcl / peak_pre_fcl
} else {
1.0
};
let protection_time_saved_ms = if reduction_factor < 1.0 {
(1.0 - reduction_factor) * 50.0 } else {
0.0
};
FclPerformanceResult {
peak_fault_current_ka: peak_pre_fcl,
limited_fault_current_ka: peak_post_fcl,
reduction_factor,
time_to_trigger_ms,
energy_absorbed_kj: total_energy_j / 1000.0,
fcl_states_timeline: timeline,
protection_time_saved_ms,
}
}
}
fn interpolate_profile(profile: &[(f64, f64)], t: f64) -> f64 {
if profile.is_empty() {
return 0.0;
}
if t <= profile[0].0 {
return profile[0].1;
}
let last = profile[profile.len() - 1];
if t >= last.0 {
return last.1;
}
let mut lo = 0usize;
let mut hi = profile.len() - 1;
while lo + 1 < hi {
let mid = (lo + hi) / 2;
if profile[mid].0 <= t {
lo = mid;
} else {
hi = mid;
}
}
let (t0, v0) = profile[lo];
let (t1, v1) = profile[hi];
let dt = t1 - t0;
if dt.abs() < 1e-12 {
return v0;
}
v0 + (v1 - v0) * (t - t0) / dt
}
#[cfg(test)]
mod tests {
use super::*;
fn make_rsfcl() -> FaultCurrentLimiter {
FaultCurrentLimiter::new(
1,
0,
1,
FclTechnology::ResistiveSuperconducting {
critical_current_ka: 2.0,
normal_resistance_ohm: 5.0,
recovery_time_s: 30.0,
quench_rise_time_ms: 10.0,
},
1.5,
110.0,
)
}
fn make_solid_state_fcl() -> FaultCurrentLimiter {
FaultCurrentLimiter::new(
2,
1,
2,
FclTechnology::SolidStateSeries {
trigger_current_ka: 3.0,
inserted_resistance_ohm: 2.0,
inserted_reactance_ohm: 1.5,
switching_time_ms: 0.5,
},
2.0,
110.0,
)
}
#[test]
fn test_fcl_creation_resistive_superconducting() {
let fcl = make_rsfcl();
assert_eq!(fcl.id, 1);
assert_eq!(fcl.branch_from, 0);
assert_eq!(fcl.branch_to, 1);
assert_eq!(fcl.rated_current_ka, 1.5);
assert_eq!(fcl.rated_voltage_kv, 110.0);
assert_eq!(fcl.operating_state, FclState::Normal);
assert_eq!(fcl.fault_count, 0);
assert_eq!(fcl.total_energy_absorbed_j, 0.0);
match &fcl.technology {
FclTechnology::ResistiveSuperconducting {
critical_current_ka,
normal_resistance_ohm,
..
} => {
assert!((critical_current_ka - 2.0).abs() < 1e-10);
assert!((normal_resistance_ohm - 5.0).abs() < 1e-10);
}
_ => panic!("wrong technology variant"),
}
}
#[test]
fn test_fcl_creation_solid_state() {
let fcl = make_solid_state_fcl();
assert_eq!(fcl.id, 2);
assert_eq!(fcl.operating_state, FclState::Normal);
match &fcl.technology {
FclTechnology::SolidStateSeries {
trigger_current_ka,
inserted_resistance_ohm,
inserted_reactance_ohm,
switching_time_ms,
} => {
assert!((trigger_current_ka - 3.0).abs() < 1e-10);
assert!((inserted_resistance_ohm - 2.0).abs() < 1e-10);
assert!((inserted_reactance_ohm - 1.5).abs() < 1e-10);
assert!((switching_time_ms - 0.5).abs() < 1e-10);
}
_ => panic!("wrong technology variant"),
}
}
#[test]
fn test_fcl_should_trigger_above_threshold() {
let fcl = make_rsfcl();
assert!(fcl.should_trigger(2.5));
}
#[test]
fn test_fcl_should_not_trigger_below_threshold() {
let fcl = make_rsfcl();
assert!(!fcl.should_trigger(1.0));
assert!(!fcl.should_trigger(2.0));
}
#[test]
fn test_fcl_state_transitions() {
let mut fcl = make_rsfcl();
assert_eq!(fcl.operating_state, FclState::Normal);
fcl.update_state(3.0, 1.0);
assert_eq!(fcl.operating_state, FclState::Quenching);
assert_eq!(fcl.fault_count, 1);
for _ in 0..12 {
fcl.update_state(3.0, 1.0);
}
assert_eq!(fcl.operating_state, FclState::Triggered);
fcl.update_state(0.5, 1.0);
assert_eq!(fcl.operating_state, FclState::Recovering);
}
#[test]
fn test_effective_impedance_normal_state() {
let fcl = make_rsfcl();
let (r, x) = fcl.effective_impedance(0.0);
assert!(r.abs() < 1e-10);
assert!(x.abs() < 1e-10);
}
#[test]
fn test_effective_impedance_triggered_state() {
let mut fcl = make_rsfcl();
fcl.operating_state = FclState::Triggered;
let (r, x) = fcl.effective_impedance(100.0);
assert!((r - 5.0).abs() < 1e-10);
assert!(x.abs() < 1e-10);
}
#[test]
fn test_effective_impedance_quenching_linear_ramp() {
let mut fcl = make_rsfcl(); fcl.operating_state = FclState::Quenching;
let (r, _) = fcl.effective_impedance(5.0);
assert!((r - 2.5).abs() < 1e-10);
}
#[test]
fn test_energy_absorbed_calculation() {
let mut fcl = make_rsfcl();
fcl.operating_state = FclState::Triggered;
let e = fcl.compute_energy_absorbed(3.0, 100.0);
let expected = 3000.0_f64.powi(2) * 5.0 * 0.1;
assert!(
(e - expected).abs() < 1.0,
"energy mismatch: {} vs {}",
e,
expected
);
}
#[test]
fn test_overload_check() {
let mut fcl = make_rsfcl();
fcl.operating_state = FclState::Triggered;
let overloaded = fcl.is_overloaded(50.0, 1000.0);
assert!(overloaded);
let not_overloaded = fcl.is_overloaded(0.001, 1.0);
assert!(!not_overloaded);
}
#[test]
fn test_fcl_sizing_new() {
let s = FclSizing::new(110.0, 100.0, 10.0, 6.0, 1.0, 100.0);
assert!((s.network_voltage_kv - 110.0).abs() < 1e-10);
assert!((s.base_mva - 100.0).abs() < 1e-10);
assert!((s.prospective_fault_current_ka - 10.0).abs() < 1e-10);
assert!((s.target_fault_current_ka - 6.0).abs() < 1e-10);
assert!((s.normal_load_current_ka - 1.0).abs() < 1e-10);
assert!((s.fault_duration_ms - 100.0).abs() < 1e-10);
}
#[test]
fn test_required_impedance_calculation() {
let s = FclSizing::new(110.0, 100.0, 10.0, 5.0, 1.0, 100.0);
let (r, x) = s
.compute_required_impedance()
.expect("sizing should succeed");
assert!(r > 0.0);
assert!(x > 0.0);
let z_fcl_magnitude = (r * r + x * x).sqrt();
assert!(z_fcl_magnitude > 0.0);
assert!(
(r + x - 6.351_f64).abs() < 0.2,
"R+X should sum to Z_fcl: {}",
r + x
);
}
#[test]
fn test_required_impedance_error_on_invalid_target() {
let s = FclSizing::new(110.0, 100.0, 5.0, 10.0, 1.0, 100.0);
assert!(s.compute_required_impedance().is_err());
}
#[test]
fn test_current_reduction_pct() {
let s = FclSizing::new(110.0, 100.0, 10.0, 5.0, 1.0, 100.0);
let result = s.size().expect("sizing should succeed");
assert!((result.current_reduction_pct - 50.0).abs() < 1e-6);
}
#[test]
fn test_recommend_technology_superconducting() {
let s = FclSizing::new(33.0, 100.0, 3.0, 1.5, 0.5, 100.0);
match s.recommend_technology() {
FclTechnology::ResistiveSuperconducting { .. } => {}
other => panic!("expected RSFCL, got {:?}", other),
}
}
#[test]
fn test_recommend_technology_solid_state() {
let s = FclSizing::new(110.0, 100.0, 8.0, 4.0, 1.0, 30.0);
match s.recommend_technology() {
FclTechnology::SolidStateSeries { .. } => {}
other => panic!("expected SolidStateSeries, got {:?}", other),
}
}
#[test]
fn test_recommend_technology_bridge() {
let s = FclSizing::new(110.0, 100.0, 8.0, 4.0, 1.0, 200.0);
match s.recommend_technology() {
FclTechnology::Bridge { .. } => {}
other => panic!("expected Bridge, got {:?}", other),
}
}
#[test]
fn test_sizing_full_calculation() {
let s = FclSizing::new(110.0, 100.0, 10.0, 6.0, 1.0, 100.0);
let result = s.size().expect("sizing should succeed");
assert!(result.required_impedance_pu > 0.0);
assert!(result.required_resistance_ohm > 0.0);
assert!(result.required_reactance_ohm > 0.0);
assert!(result.current_reduction_pct > 0.0 && result.current_reduction_pct < 100.0);
assert!(result.energy_absorption_kj > 0.0);
assert!(result.cost_estimate_million_eur > 0.0);
}
#[test]
fn test_placement_optimizer_greedy() {
let fault_currents = vec![
vec![12.0, 4.0, 3.0],
vec![8.0, 3.0, 2.0],
vec![6.0, 2.5, 1.5],
];
let branch_ratings = vec![5.0, 6.0, 4.0];
let opt = FclPlacementOptimizer::new(3, 3, fault_currents, branch_ratings, 100.0);
let result = opt.optimize_greedy();
assert!(!result.placed_fcls.is_empty());
assert!(result.total_cost_million_eur <= 100.0 + 1e-6);
assert!(result.max_fault_reduction_pct > 0.0);
}
#[test]
fn test_placement_budget_constraint() {
let fault_currents = vec![vec![10.0, 5.0]];
let branch_ratings = vec![4.0, 3.0];
let opt = FclPlacementOptimizer::new(2, 2, fault_currents, branch_ratings, 0.0);
let result = opt.optimize_greedy();
assert!(result.placed_fcls.is_empty());
assert_eq!(result.total_cost_million_eur, 0.0);
}
#[test]
fn test_post_fcl_fault_current_reduction() {
let i_new = FclPlacementOptimizer::post_fcl_fault_current(10.0, 1.0, 1.0);
assert!((i_new - 5.0).abs() < 1e-10);
}
#[test]
fn test_post_fcl_fault_current_zero_fcl() {
let i_new = FclPlacementOptimizer::post_fcl_fault_current(10.0, 2.0, 0.0);
assert!((i_new - 10.0).abs() < 1e-10);
}
#[test]
fn test_performance_analyzer_simulate_fault() {
let fcl = make_rsfcl();
let mut analyzer = FclPerformanceAnalyzer::new(vec![fcl], 100.0, 1.0);
let profile = vec![
(0.0, 0.0),
(10.0, 5.0),
(50.0, 5.0),
(80.0, 0.5),
(100.0, 0.0),
];
let result = analyzer.simulate_fault(&profile);
assert!(result.peak_fault_current_ka > 4.5);
assert!(result.limited_fault_current_ka <= result.peak_fault_current_ka);
assert!(!result.fcl_states_timeline.is_empty());
assert!(result.energy_absorbed_kj >= 0.0);
}
#[test]
fn test_performance_result_reduction_factor() {
let fcl = make_rsfcl();
let mut analyzer = FclPerformanceAnalyzer::new(vec![fcl], 50.0, 0.5);
let profile = vec![(0.0, 4.0), (50.0, 4.0)];
let result = analyzer.simulate_fault(&profile);
assert!(result.reduction_factor <= 1.0 + 1e-6);
assert!(result.limited_fault_current_ka <= result.peak_fault_current_ka + 1e-6);
}
#[test]
fn test_performance_analyzer_state_timeline_length() {
let fcl = make_solid_state_fcl();
let mut analyzer = FclPerformanceAnalyzer::new(vec![fcl], 20.0, 2.0);
let profile = vec![(0.0, 0.0), (20.0, 0.0)];
let result = analyzer.simulate_fault(&profile);
assert_eq!(result.fcl_states_timeline.len(), 10);
}
#[test]
fn test_check_coordination_warning() {
let fault_currents = vec![vec![10.0, 5.0]];
let branch_ratings = vec![4.0, 6.0];
let mut opt = FclPlacementOptimizer::new(2, 2, fault_currents, branch_ratings, 100.0);
opt.protection_coordination = vec![(0, 1)];
let sizing = FclSizing::new(110.0, 100.0, 5.0, 3.0, 1.0, 100.0);
let placed = vec![(1usize, sizing)];
let warnings = opt.check_coordination(&placed);
assert!(!warnings.is_empty());
assert!(warnings[0].contains("branch 1"));
}
#[test]
fn test_is_limiter_effective_impedance() {
let fcl = FaultCurrentLimiter::new(
10,
0,
1,
FclTechnology::IsLimiter {
fuse_current_ka: 5.0,
parallel_impedance_ohm: 3.0,
reset_required: true,
},
4.0,
110.0,
);
let (r, x) = fcl.effective_impedance(0.0);
assert!(r.abs() < 1e-10);
assert!(x.abs() < 1e-10);
let mut fcl2 = fcl;
fcl2.operating_state = FclState::Triggered;
let (r2, x2) = fcl2.effective_impedance(10.0);
assert!((r2 - 3.0).abs() < 1e-10);
assert!(x2.abs() < 1e-10);
}
#[test]
fn test_inductive_fcl_effective_impedance() {
let fcl = FaultCurrentLimiter::new(
20,
2,
3,
FclTechnology::InductiveSuperconducting {
saturated_inductance_mh: 1.0,
unsaturated_inductance_mh: 100.0,
saturation_current_ka: 2.0,
bias_coil_current_a: 200.0,
},
1.5,
110.0,
);
let (r, x) = fcl.effective_impedance(0.0);
let expected_x = OMEGA_50HZ * 1e-3;
assert!(r.abs() < 1e-10);
assert!((x - expected_x).abs() < 1e-6);
let mut fcl2 = fcl;
fcl2.operating_state = FclState::Triggered;
let (_, x2) = fcl2.effective_impedance(0.0);
let expected_x2 = OMEGA_50HZ * 100e-3;
assert!((x2 - expected_x2).abs() < 1e-6);
}
#[test]
fn test_fcl_trigger_threshold_exceeded_sets_quenching() {
let mut fcl = make_rsfcl();
assert!(fcl.should_trigger(2.5));
fcl.update_state(2.5, 1.0);
assert_eq!(fcl.operating_state, FclState::Quenching);
assert_eq!(fcl.fault_count, 1);
}
#[test]
fn test_limited_current_le_unlimited_via_impedance() {
let pre = 10.0_f64;
let z_src = 2.0_f64;
let z_fcl = 3.0_f64;
let limited = FclPlacementOptimizer::post_fcl_fault_current(pre, z_src, z_fcl);
assert!(limited < pre);
assert!((limited - 4.0).abs() < 1e-10);
}
#[test]
fn test_fcl_impedance_in_valid_range_after_trigger() {
let mut fcl = make_rsfcl();
fcl.update_state(3.0, 1.0);
assert_eq!(fcl.operating_state, FclState::Quenching);
let (r0, x0) = fcl.effective_impedance(0.0);
assert!(r0.abs() < 1e-10);
assert!(x0.abs() < 1e-10);
let (r_full, x_full) = fcl.effective_impedance(10.0);
assert!((r_full - 5.0).abs() < 1e-10);
assert!(x_full.abs() < 1e-10);
fcl.operating_state = FclState::Triggered;
let (r_t, x_t) = fcl.effective_impedance(0.0);
assert!((r_t - 5.0).abs() < 1e-10);
assert!(x_t.abs() < 1e-10);
}
#[test]
fn test_resistive_vs_inductive_behavior_difference() {
let rsfcl = make_rsfcl();
let (r_res, x_res) = rsfcl.effective_impedance(0.0);
assert!(r_res.abs() < 1e-10);
assert!(x_res.abs() < 1e-10);
let ind = FaultCurrentLimiter::new(
20,
2,
3,
FclTechnology::InductiveSuperconducting {
saturated_inductance_mh: 1.0,
unsaturated_inductance_mh: 100.0,
saturation_current_ka: 2.0,
bias_coil_current_a: 200.0,
},
1.5,
110.0,
);
let (r_ind, x_ind) = ind.effective_impedance(0.0);
assert!(r_ind.abs() < 1e-10);
assert!(x_ind > 0.0);
let expected_x_sat = OMEGA_50HZ * 1e-3;
assert!((x_ind - expected_x_sat).abs() < 1e-6);
}
#[test]
fn test_recovery_state_after_fault_clearance() {
let mut fcl = make_rsfcl();
fcl.operating_state = FclState::Triggered;
fcl.update_state(1.0, 1.0);
assert_eq!(fcl.operating_state, FclState::Recovering);
let (r, x) = fcl.effective_impedance(0.0);
assert!(r.abs() < 1e-10);
assert!(x.abs() < 1e-10);
}
#[test]
fn test_no_false_trigger_during_normal_load() {
let mut fcl = make_rsfcl();
assert!(!fcl.should_trigger(1.0));
fcl.update_state(1.0, 5.0);
assert_eq!(fcl.operating_state, FclState::Normal);
assert_eq!(fcl.fault_count, 0);
fcl.operating_state = FclState::Triggered;
assert!(!fcl.should_trigger(10.0));
}
#[test]
fn test_protection_coordination_overcurrent_interaction() {
let overcurrent_threshold_ka = 5.0;
let pre_fault_ka = 10.0;
let z_source_ohm = 1.0;
let z_fcl_ohm = 1.0;
let i_limited =
FclPlacementOptimizer::post_fcl_fault_current(pre_fault_ka, z_source_ohm, z_fcl_ohm);
assert!(i_limited < pre_fault_ka);
assert!((i_limited - 5.0).abs() < 1e-10);
assert!((i_limited - overcurrent_threshold_ka).abs() < 1e-10);
}
#[test]
fn test_energy_dissipation_estimate_positive_after_fault() {
let mut fcl = make_rsfcl();
let e_normal = fcl.compute_energy_absorbed(3.0, 100.0);
assert!(e_normal.abs() < 1e-10);
fcl.operating_state = FclState::Triggered;
let e_triggered = fcl.compute_energy_absorbed(3.0, 100.0);
assert!(e_triggered > 0.0);
let expected_j = (3.0e3_f64).powi(2) * 5.0 * 0.1;
assert!((e_triggered - expected_j).abs() < 1.0);
}
}