use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum RelayCharacteristic {
StandardInverse,
VeryInverse,
ExtremelyInverse,
LongTimeInverse,
IeeeModeInverse,
IeeeVeryInverse,
IeeeExtrInverse,
}
impl RelayCharacteristic {
fn iec_coeffs(self) -> Option<(f64, f64, f64)> {
match self {
Self::StandardInverse => Some((0.14, 0.0, 0.02)),
Self::VeryInverse => Some((13.5, 0.0, 1.0)),
Self::ExtremelyInverse => Some((80.0, 0.0, 2.0)),
Self::LongTimeInverse => Some((120.0, 0.0, 1.0)),
_ => None,
}
}
fn ieee_coeffs(self) -> Option<(f64, f64, f64)> {
match self {
Self::IeeeModeInverse => Some((0.0515, 0.1140, 0.02)),
Self::IeeeVeryInverse => Some((19.61, 0.4910, 2.0)),
Self::IeeeExtrInverse => Some((28.2, 0.1217, 2.0)),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OcRelay {
pub i_pickup: f64,
pub tms: f64,
pub curve: RelayCharacteristic,
pub inst_level: f64,
pub inst_time: f64,
}
impl OcRelay {
pub fn new(i_pickup: f64, tms: f64, curve: RelayCharacteristic) -> Self {
Self {
i_pickup,
tms,
curve,
inst_level: 0.0,
inst_time: 0.0,
}
}
pub fn with_instantaneous(mut self, level: f64, time: f64) -> Self {
self.inst_level = level;
self.inst_time = time;
self
}
pub fn trip_time(&self, i_fault: f64) -> Option<f64> {
if i_fault <= self.i_pickup {
return None;
}
if self.inst_level > 0.0 && i_fault >= self.inst_level {
return Some(self.inst_time);
}
let m = i_fault / self.i_pickup;
if let Some((a, _b, p)) = self.curve.iec_coeffs() {
let denom = m.powf(p) - 1.0;
if denom < 1e-12 {
return Some(f64::INFINITY);
}
Some(self.tms * a / denom)
} else if let Some((a, b, p)) = self.curve.ieee_coeffs() {
let denom = m.powf(p) - 1.0;
if denom < 1e-12 {
return Some(f64::INFINITY);
}
Some(self.tms * (a / denom + b))
} else {
None
}
}
pub fn check_coordination(
&self,
primary: &OcRelay,
i_fault: f64,
margin_s: f64,
) -> std::result::Result<f64, f64> {
let t_primary = primary.trip_time(i_fault).unwrap_or(f64::INFINITY);
let t_backup = self.trip_time(i_fault).unwrap_or(f64::INFINITY);
let actual_margin = t_backup - t_primary;
if actual_margin >= margin_s {
Ok(actual_margin)
} else {
Err(actual_margin)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistanceRelay {
pub z1_reach: f64,
pub z2_reach: f64,
pub z3_reach: f64,
pub t1: f64,
pub t2: f64,
pub t3: f64,
}
impl DistanceRelay {
pub fn trip_time(&self, z_app: f64) -> Option<f64> {
if z_app <= self.z1_reach {
Some(self.t1)
} else if z_app <= self.z2_reach {
Some(self.t2)
} else if z_app <= self.z3_reach {
Some(self.t3)
} else {
None }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_trip_below_pickup() {
let relay = OcRelay::new(100.0, 0.5, RelayCharacteristic::StandardInverse);
assert!(relay.trip_time(50.0).is_none());
assert!(relay.trip_time(100.0).is_none()); }
#[test]
fn test_si_trip_time_finite() {
let relay = OcRelay::new(100.0, 0.5, RelayCharacteristic::StandardInverse);
let t = relay.trip_time(300.0).expect("should trip");
assert!(t > 0.0 && t.is_finite(), "t={:.4}", t);
}
#[test]
fn test_higher_current_faster_trip() {
let relay = OcRelay::new(100.0, 0.5, RelayCharacteristic::VeryInverse);
let t1 = relay.trip_time(200.0).unwrap();
let t2 = relay.trip_time(500.0).unwrap();
assert!(t2 < t1, "t1={:.4} t2={:.4}", t1, t2);
}
#[test]
fn test_instantaneous_trip_overrides() {
let relay = OcRelay::new(100.0, 0.5, RelayCharacteristic::StandardInverse)
.with_instantaneous(800.0, 0.03);
let t = relay.trip_time(1000.0).unwrap();
assert!((t - 0.03).abs() < 1e-9, "should be instantaneous: {}", t);
}
#[test]
fn test_ieee_very_inverse_curve() {
let relay = OcRelay::new(100.0, 1.0, RelayCharacteristic::IeeeVeryInverse);
let t = relay.trip_time(500.0).expect("should trip at 5× pickup");
assert!(t > 0.0 && t < 10.0, "t={:.4}", t);
}
#[test]
fn test_coordination_check_pass() {
let primary = OcRelay::new(100.0, 0.2, RelayCharacteristic::VeryInverse);
let backup = OcRelay::new(80.0, 0.5, RelayCharacteristic::VeryInverse);
let result = backup.check_coordination(&primary, 500.0, 0.3);
assert!(result.is_ok(), "margin={:.3}", result.unwrap_or_else(|e| e));
}
#[test]
fn test_distance_relay_zones() {
let relay = DistanceRelay {
z1_reach: 8.0,
z2_reach: 12.0,
z3_reach: 20.0,
t1: 0.02,
t2: 0.40,
t3: 1.0,
};
assert!((relay.trip_time(5.0).unwrap() - 0.02).abs() < 1e-9);
assert!((relay.trip_time(10.0).unwrap() - 0.40).abs() < 1e-9);
assert!((relay.trip_time(18.0).unwrap() - 1.0).abs() < 1e-9);
assert!(relay.trip_time(25.0).is_none());
}
}