use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffRelayParams {
pub i_min_pu: f64,
pub slope1: f64,
pub slope2: f64,
pub i_break_pu: f64,
pub h2_threshold: f64,
pub h5_threshold: f64,
pub i_instantaneous_pu: f64,
pub trip_delay_s: f64,
}
impl DiffRelayParams {
pub fn standard_87t() -> Self {
Self {
i_min_pu: 0.20, slope1: 0.30, slope2: 0.60, i_break_pu: 2.0,
h2_threshold: 0.15, h5_threshold: 0.35, i_instantaneous_pu: 8.0, trip_delay_s: 0.02, }
}
pub fn generator_87g() -> Self {
Self {
i_min_pu: 0.05,
slope1: 0.15,
slope2: 0.30,
i_break_pu: 1.0,
h2_threshold: 0.10,
h5_threshold: 0.25,
i_instantaneous_pu: 5.0,
trip_delay_s: 0.015,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CtSample {
pub i_fundamental: f64,
pub i_2nd_harmonic: f64,
pub i_5th_harmonic: f64,
pub i_instantaneous: f64,
pub ct_saturated: bool,
}
impl CtSample {
pub fn ideal(i_pu: f64) -> Self {
Self {
i_fundamental: i_pu,
i_2nd_harmonic: 0.0,
i_5th_harmonic: 0.0,
i_instantaneous: i_pu,
ct_saturated: false,
}
}
pub fn with_harmonics(i_pu: f64, h2: f64, h5: f64) -> Self {
Self {
i_fundamental: i_pu,
i_2nd_harmonic: h2,
i_5th_harmonic: h5,
i_instantaneous: i_pu,
ct_saturated: false,
}
}
pub fn with_saturation(mut self) -> Self {
self.ct_saturated = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RelayDecision {
Restrain,
BlockedByInrush,
BlockedByOverexcitation,
BlockedBySaturation,
Trip {
trip_time_s: f64,
i_diff: f64,
i_rst: f64,
},
InstantaneousTrip { i_diff: f64 },
}
impl RelayDecision {
pub fn is_trip(&self) -> bool {
matches!(
self,
RelayDecision::Trip { .. } | RelayDecision::InstantaneousTrip { .. }
)
}
pub fn is_operate_zone(&self) -> bool {
!matches!(self, RelayDecision::Restrain)
}
}
pub fn evaluate_87t(params: &DiffRelayParams, i1: &CtSample, i2: &CtSample) -> RelayDecision {
let i_diff = (i1.i_fundamental + i2.i_fundamental).abs();
let i_rst = (i1.i_fundamental.abs() + i2.i_fundamental.abs()) / 2.0;
let i_inst = (i1.i_instantaneous + i2.i_instantaneous).abs();
if i_inst > params.i_instantaneous_pu {
return RelayDecision::InstantaneousTrip { i_diff: i_inst };
}
let slope = if i_rst > params.i_break_pu {
params.slope2
} else {
params.slope1
};
let operate = i_diff > params.i_min_pu && i_diff > slope * i_rst;
if !operate {
return RelayDecision::Restrain;
}
if i1.ct_saturated || i2.ct_saturated {
return RelayDecision::BlockedBySaturation;
}
let h2_ratio_1 = if i1.i_fundamental > 1e-6 {
i1.i_2nd_harmonic / i1.i_fundamental
} else {
0.0
};
let h2_ratio_2 = if i2.i_fundamental > 1e-6 {
i2.i_2nd_harmonic / i2.i_fundamental
} else {
0.0
};
if h2_ratio_1 > params.h2_threshold || h2_ratio_2 > params.h2_threshold {
return RelayDecision::BlockedByInrush;
}
let h5_ratio_1 = if i1.i_fundamental > 1e-6 {
i1.i_5th_harmonic / i1.i_fundamental
} else {
0.0
};
let h5_ratio_2 = if i2.i_fundamental > 1e-6 {
i2.i_5th_harmonic / i2.i_fundamental
} else {
0.0
};
if h5_ratio_1 > params.h5_threshold || h5_ratio_2 > params.h5_threshold {
return RelayDecision::BlockedByOverexcitation;
}
RelayDecision::Trip {
trip_time_s: params.trip_delay_s,
i_diff,
i_rst,
}
}
pub fn bias_characteristic(params: &DiffRelayParams, i_rst: f64) -> f64 {
let slope = if i_rst > params.i_break_pu {
params.slope2
} else {
params.slope1
};
(params.i_min_pu).max(slope * i_rst)
}
pub fn bias_curve(params: &DiffRelayParams, i_rst_max: f64, n_points: usize) -> Vec<(f64, f64)> {
(0..n_points)
.map(|i| {
let i_rst = i_rst_max * i as f64 / (n_points - 1).max(1) as f64;
(i_rst, bias_characteristic(params, i_rst))
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffRelayState {
pub operate_timer_s: f64,
pub has_tripped: bool,
pub operate_count: usize,
pub last_decision: Option<RelayDecision>,
}
impl DiffRelayState {
pub fn new() -> Self {
Self {
operate_timer_s: 0.0,
has_tripped: false,
operate_count: 0,
last_decision: None,
}
}
pub fn step(
&mut self,
params: &DiffRelayParams,
i1: &CtSample,
i2: &CtSample,
dt: f64,
) -> Option<RelayDecision> {
if self.has_tripped {
return None; }
let decision = evaluate_87t(params, i1, i2);
match &decision {
RelayDecision::InstantaneousTrip { .. } => {
self.has_tripped = true;
self.last_decision = Some(decision.clone());
Some(decision)
}
RelayDecision::Trip { trip_time_s, .. } => {
self.operate_timer_s += dt;
self.operate_count += 1;
if self.operate_timer_s >= *trip_time_s {
self.has_tripped = true;
self.last_decision = Some(decision.clone());
Some(decision)
} else {
self.last_decision = Some(decision);
None
}
}
RelayDecision::Restrain => {
self.operate_timer_s = 0.0;
self.operate_count = 0;
self.last_decision = Some(decision);
None
}
_ => {
self.operate_timer_s = (self.operate_timer_s - dt).max(0.0);
self.last_decision = Some(decision);
None
}
}
}
pub fn reset(&mut self) {
self.operate_timer_s = 0.0;
self.has_tripped = false;
self.operate_count = 0;
self.last_decision = None;
}
}
impl Default for DiffRelayState {
fn default() -> Self {
Self::new()
}
}
pub fn minimum_detectable_fault(params: &DiffRelayParams, i_load_pu: f64) -> f64 {
let slope = if i_load_pu > params.i_break_pu {
params.slope2
} else {
params.slope1
};
let denom = (1.0 - slope / 2.0).max(0.01);
let from_bias = slope * i_load_pu / denom;
params.i_min_pu.max(from_bias)
}
pub fn minimum_turn_fault_coverage(params: &DiffRelayParams, i_load_pu: f64) -> f64 {
let i_min_fault = minimum_detectable_fault(params, i_load_pu);
i_min_fault.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal_fault_trips() {
let params = DiffRelayParams::standard_87t();
let i1 = CtSample::ideal(1.0);
let i2 = CtSample::ideal(0.0); let decision = evaluate_87t(¶ms, &i1, &i2);
assert!(
decision.is_trip(),
"Internal fault should trip: {:?}",
decision
);
}
#[test]
fn test_external_fault_restrains() {
let params = DiffRelayParams::standard_87t();
let i1 = CtSample::ideal(3.0);
let i2 = CtSample::ideal(-3.0); let decision = evaluate_87t(¶ms, &i1, &i2);
assert_eq!(decision, RelayDecision::Restrain);
}
#[test]
fn test_inrush_blocking() {
let params = DiffRelayParams::standard_87t();
let i1 = CtSample::with_harmonics(1.5, 0.4, 0.0); let i2 = CtSample::ideal(0.0);
let decision = evaluate_87t(¶ms, &i1, &i2);
assert_eq!(
decision,
RelayDecision::BlockedByInrush,
"High 2nd harmonic should block: {:?}",
decision
);
}
#[test]
fn test_overexcitation_blocking() {
let params = DiffRelayParams::standard_87t();
let i1 = CtSample::with_harmonics(1.2, 0.0, 0.5); let i2 = CtSample::ideal(0.0);
let decision = evaluate_87t(¶ms, &i1, &i2);
assert_eq!(decision, RelayDecision::BlockedByOverexcitation);
}
#[test]
fn test_ct_saturation_blocking() {
let params = DiffRelayParams::standard_87t();
let i1 = CtSample::ideal(2.0).with_saturation();
let i2 = CtSample::ideal(0.0);
let decision = evaluate_87t(¶ms, &i1, &i2);
assert_eq!(decision, RelayDecision::BlockedBySaturation);
}
#[test]
fn test_instantaneous_trip() {
let params = DiffRelayParams::standard_87t();
let i1 = CtSample {
i_fundamental: 5.0,
i_2nd_harmonic: 0.0,
i_5th_harmonic: 0.0,
i_instantaneous: 9.0,
ct_saturated: false,
};
let i2 = CtSample::ideal(0.0);
let decision = evaluate_87t(¶ms, &i1, &i2);
assert!(matches!(decision, RelayDecision::InstantaneousTrip { .. }));
}
#[test]
fn test_bias_characteristic_slope1() {
let params = DiffRelayParams::standard_87t();
let pickup = bias_characteristic(¶ms, 1.0);
assert!((pickup - 0.30_f64.max(params.i_min_pu)).abs() < 1e-6);
}
#[test]
fn test_bias_characteristic_slope2() {
let params = DiffRelayParams::standard_87t();
let pickup = bias_characteristic(¶ms, 3.0);
assert!((pickup - 1.80_f64.max(params.i_min_pu)).abs() < 1e-6);
}
#[test]
fn test_bias_curve_length() {
let params = DiffRelayParams::standard_87t();
let curve = bias_curve(¶ms, 5.0, 50);
assert_eq!(curve.len(), 50);
for w in curve.windows(2) {
assert!(w[1].1 >= w[0].1, "Bias curve should be non-decreasing");
}
}
#[test]
fn test_relay_state_machine_trips_after_delay() {
let params = DiffRelayParams::standard_87t();
let mut state = DiffRelayState::new();
let i1 = CtSample::ideal(1.0);
let i2 = CtSample::ideal(0.0);
let dt = 0.005;
let mut tripped = false;
for _ in 0..20 {
if let Some(dec) = state.step(¶ms, &i1, &i2, dt) {
assert!(dec.is_trip());
tripped = true;
break;
}
}
assert!(tripped, "Should have tripped after 20 ms delay");
}
#[test]
fn test_relay_state_machine_resets() {
let _params = DiffRelayParams::standard_87t();
let mut state = DiffRelayState::new();
state.operate_timer_s = 0.01;
state.reset();
assert_eq!(state.operate_timer_s, 0.0);
assert!(!state.has_tripped);
}
#[test]
fn test_minimum_detectable_fault() {
let params = DiffRelayParams::standard_87t();
let i_fault_min = minimum_detectable_fault(¶ms, 0.5);
assert!(
i_fault_min >= params.i_min_pu,
"Minimum fault must exceed pickup: {:.4}",
i_fault_min
);
}
#[test]
fn test_minimum_turn_fault_coverage() {
let params = DiffRelayParams::standard_87t();
let coverage = minimum_turn_fault_coverage(¶ms, 0.5);
assert!(coverage > 0.0 && coverage <= 1.0);
}
#[test]
fn test_below_minimum_pickup_restrains() {
let params = DiffRelayParams::standard_87t();
let i1 = CtSample::ideal(0.05);
let i2 = CtSample::ideal(0.0);
let decision = evaluate_87t(¶ms, &i1, &i2);
assert_eq!(decision, RelayDecision::Restrain);
}
#[test]
fn test_generator_relay_more_sensitive() {
let params_gen = DiffRelayParams::generator_87g();
let params_xfmr = DiffRelayParams::standard_87t();
assert!(params_gen.i_min_pu < params_xfmr.i_min_pu);
assert!(params_gen.slope1 < params_xfmr.slope1);
}
}