use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum StudyError {
#[error("coordination study has no protection zones")]
NoZones,
#[error("zone {zone_id} references unknown relay {relay_id}")]
UnknownRelay { zone_id: usize, relay_id: usize },
#[error("upstream relay {0} not found in relay list")]
UnknownUpstreamRelay(usize),
#[error("fault current must be positive, got {0}")]
InvalidFaultCurrent(f64),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoordinationStudyConfig {
pub system_voltage_kv: f64,
pub base_mva: f64,
pub max_fault_current_ka: f64,
pub min_fault_current_ka: f64,
pub grading_margin_ms: f64,
pub instantaneous_margin_pct: f64,
}
impl Default for CoordinationStudyConfig {
fn default() -> Self {
Self {
system_voltage_kv: 11.0,
base_mva: 100.0,
max_fault_current_ka: 10.0,
min_fault_current_ka: 0.3,
grading_margin_ms: 300.0,
instantaneous_margin_pct: 10.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZoneType {
Main,
Backup,
Tertiary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProtectedElement {
Line {
length_km: f64,
impedance_pu: f64,
},
Transformer {
mva: f64,
impedance_pct: f64,
},
Bus {
busbar_id: usize,
},
Generator {
mva: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtectionZone {
pub id: usize,
pub relay_id: usize,
pub zone_type: ZoneType,
pub protected_element: ProtectedElement,
pub upstream_relay: Option<usize>,
pub downstream_relays: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelayType {
OvercurrentInverse,
OvercurrentDefiniteTime,
Distance,
Differential,
HighImpedance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum IdmtCurve {
StandardInverse,
VeryInverse,
ExtremelyInverse,
LongTimeInverse,
UsCoInverse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayCharacteristics {
pub relay_id: usize,
pub relay_type: RelayType,
pub time_dial: f64,
pub pickup_current_a: f64,
pub instantaneous_pickup_a: f64,
pub ct_ratio: f64,
pub curve_type: IdmtCurve,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoordinationCheck {
pub upstream_relay: usize,
pub downstream_relay: usize,
pub fault_current_a: f64,
pub upstream_trip_time_ms: f64,
pub downstream_trip_time_ms: f64,
pub margin_ms: f64,
pub coordinated: bool,
pub margin_violation: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoordinationReport {
pub checks: Vec<CoordinationCheck>,
pub all_coordinated: bool,
pub total_violations: usize,
pub worst_violation_ms: f64,
pub recommended_adjustments: Vec<(usize, f64, f64)>,
}
pub struct CoordinationStudy {
config: CoordinationStudyConfig,
zones: Vec<ProtectionZone>,
relays: Vec<RelayCharacteristics>,
}
impl CoordinationStudy {
pub fn new(config: CoordinationStudyConfig) -> Self {
Self {
config,
zones: Vec::new(),
relays: Vec::new(),
}
}
pub fn add_zone(&mut self, zone: ProtectionZone) {
self.zones.push(zone);
}
pub fn add_relay(&mut self, relay: RelayCharacteristics) {
self.relays.push(relay);
}
pub fn idmt_trip_time(curve: &IdmtCurve, tds: f64, m: f64) -> f64 {
if m <= 1.0 {
return f64::INFINITY;
}
match curve {
IdmtCurve::StandardInverse => {
tds * 0.14 / (m.powf(0.02) - 1.0)
}
IdmtCurve::VeryInverse => {
tds * 13.5 / (m - 1.0)
}
IdmtCurve::ExtremelyInverse => {
tds * 80.0 / (m * m - 1.0)
}
IdmtCurve::LongTimeInverse => {
tds * 120.0 / (m - 1.0)
}
IdmtCurve::UsCoInverse => {
tds * 5.95 / (m * m - 1.0) + 0.18 * tds
}
}
}
fn relay_trip_time_s(r: &RelayCharacteristics, fault_current_a: f64) -> f64 {
if r.instantaneous_pickup_a > 0.0 && fault_current_a >= r.instantaneous_pickup_a {
return 0.02; }
let m = fault_current_a / r.pickup_current_a;
Self::idmt_trip_time(&r.curve_type, r.time_dial, m)
}
pub fn auto_tune_tds(&self, relay_id: usize, fault_current_a: f64) -> f64 {
let Some(relay) = self.relays.iter().find(|r| r.relay_id == relay_id) else {
return 1.0; };
let downstream_ids: Vec<usize> = self
.zones
.iter()
.filter(|z| z.upstream_relay == Some(relay_id))
.flat_map(|z| z.downstream_relays.iter().copied())
.collect();
if downstream_ids.is_empty() {
return relay.time_dial;
}
let margin_s = self.config.grading_margin_ms / 1000.0;
let mut max_downstream_s = 0.0_f64;
for &ds_id in &downstream_ids {
if let Some(ds_relay) = self.relays.iter().find(|r| r.relay_id == ds_id) {
let t = Self::relay_trip_time_s(ds_relay, fault_current_a);
if t.is_finite() {
max_downstream_s = max_downstream_s.max(t);
}
}
}
let required_t = max_downstream_s + margin_s;
let m = fault_current_a / relay.pickup_current_a;
if m <= 1.0 {
return relay.time_dial;
}
let denominator = match relay.curve_type {
IdmtCurve::StandardInverse => 0.14 / (m.powf(0.02) - 1.0),
IdmtCurve::VeryInverse => 13.5 / (m - 1.0),
IdmtCurve::ExtremelyInverse => 80.0 / (m * m - 1.0),
IdmtCurve::LongTimeInverse => 120.0 / (m - 1.0),
IdmtCurve::UsCoInverse => 5.95 / (m * m - 1.0) + 0.18,
};
if denominator <= 0.0 {
return relay.time_dial;
}
(required_t / denominator).max(0.05)
}
pub fn run(&self) -> Result<CoordinationReport, StudyError> {
if self.zones.is_empty() {
return Err(StudyError::NoZones);
}
for zone in &self.zones {
if !self.relays.iter().any(|r| r.relay_id == zone.relay_id) {
return Err(StudyError::UnknownRelay {
zone_id: zone.id,
relay_id: zone.relay_id,
});
}
}
let max_i_a = self.config.max_fault_current_ka * 1000.0;
let min_i_a = self.config.min_fault_current_ka * 1000.0;
let margin_ms = self.config.grading_margin_ms;
let test_currents = [max_i_a, (max_i_a + min_i_a) / 2.0, min_i_a];
let mut checks: Vec<CoordinationCheck> = Vec::new();
for zone in &self.zones {
let Some(upstream_relay_id) = zone.upstream_relay else {
continue; };
let Some(upstream_relay) = self.relays.iter().find(|r| r.relay_id == upstream_relay_id)
else {
return Err(StudyError::UnknownUpstreamRelay(upstream_relay_id));
};
let Some(downstream_relay) = self.relays.iter().find(|r| r.relay_id == zone.relay_id)
else {
return Err(StudyError::UnknownRelay {
zone_id: zone.id,
relay_id: zone.relay_id,
});
};
for &i_fault in &test_currents {
let t_down_s = Self::relay_trip_time_s(downstream_relay, i_fault);
let t_up_s = Self::relay_trip_time_s(upstream_relay, i_fault);
if !t_down_s.is_finite() {
continue;
}
let t_down_ms = t_down_s * 1000.0;
let t_up_ms = if t_up_s.is_finite() {
t_up_s * 1000.0
} else {
f64::INFINITY
};
let actual_margin = t_up_ms - t_down_ms;
let coordinated = actual_margin >= margin_ms;
let margin_violation = if coordinated {
None
} else {
Some(margin_ms - actual_margin)
};
checks.push(CoordinationCheck {
upstream_relay: upstream_relay_id,
downstream_relay: zone.relay_id,
fault_current_a: i_fault,
upstream_trip_time_ms: t_up_ms,
downstream_trip_time_ms: t_down_ms,
margin_ms: actual_margin,
coordinated,
margin_violation,
});
}
}
let total_violations = checks.iter().filter(|c| !c.coordinated).count();
let worst_violation_ms = checks
.iter()
.filter_map(|c| c.margin_violation)
.fold(0.0_f64, f64::max);
let all_coordinated = total_violations == 0;
let mut recommended_adjustments: Vec<(usize, f64, f64)> = Vec::new();
let violated_relay_ids: Vec<usize> = {
let mut ids: Vec<usize> = checks
.iter()
.filter(|c| !c.coordinated)
.map(|c| c.upstream_relay)
.collect();
ids.sort_unstable();
ids.dedup();
ids
};
for relay_id in violated_relay_ids {
let new_tds = self.auto_tune_tds(relay_id, max_i_a);
let existing_pickup = self
.relays
.iter()
.find(|r| r.relay_id == relay_id)
.map(|r| r.pickup_current_a)
.unwrap_or(0.0);
recommended_adjustments.push((relay_id, new_tds, existing_pickup));
}
Ok(CoordinationReport {
checks,
all_coordinated,
total_violations,
worst_violation_ms,
recommended_adjustments,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn si_relay(id: usize, pickup_a: f64, tds: f64) -> RelayCharacteristics {
RelayCharacteristics {
relay_id: id,
relay_type: RelayType::OvercurrentInverse,
time_dial: tds,
pickup_current_a: pickup_a,
instantaneous_pickup_a: 0.0,
ct_ratio: 200.0,
curve_type: IdmtCurve::StandardInverse,
}
}
#[test]
fn test_idmt_si_at_m5() {
let tds = 1.0;
let m = 5.0;
let expected = 0.14 / (5.0_f64.powf(0.02) - 1.0);
let got = CoordinationStudy::idmt_trip_time(&IdmtCurve::StandardInverse, tds, m);
assert!(
(got - expected).abs() < 1e-9,
"SI curve mismatch: {got:.6} vs {expected:.6}"
);
}
#[test]
fn test_idmt_vi_at_m5() {
let got = CoordinationStudy::idmt_trip_time(&IdmtCurve::VeryInverse, 1.0, 5.0);
let expected = 13.5 / 4.0;
assert!(
(got - expected).abs() < 1e-9,
"VI: {got:.6} vs {expected:.6}"
);
}
#[test]
fn test_idmt_ei_at_m5() {
let got = CoordinationStudy::idmt_trip_time(&IdmtCurve::ExtremelyInverse, 1.0, 5.0);
let expected = 80.0 / 24.0;
assert!(
(got - expected).abs() < 1e-9,
"EI: {got:.6} vs {expected:.6}"
);
}
#[test]
fn test_idmt_lti_at_m5() {
let got = CoordinationStudy::idmt_trip_time(&IdmtCurve::LongTimeInverse, 1.0, 5.0);
assert!((got - 30.0).abs() < 1e-9, "LTI: {got:.6}");
}
#[test]
fn test_idmt_co_at_m5() {
let got = CoordinationStudy::idmt_trip_time(&IdmtCurve::UsCoInverse, 1.0, 5.0);
let expected = 5.95 / 24.0 + 0.18;
assert!(
(got - expected).abs() < 1e-9,
"CO-8: {got:.6} vs {expected:.6}"
);
}
#[test]
fn test_coordinated_system_passes() {
let config = CoordinationStudyConfig {
system_voltage_kv: 11.0,
base_mva: 100.0,
max_fault_current_ka: 5.0,
min_fault_current_ka: 0.5,
grading_margin_ms: 300.0,
instantaneous_margin_pct: 10.0,
};
let mut study = CoordinationStudy::new(config);
study.add_relay(si_relay(1, 400.0, 0.2));
study.add_relay(si_relay(2, 300.0, 0.6));
study.add_zone(ProtectionZone {
id: 1,
relay_id: 1,
zone_type: ZoneType::Main,
protected_element: ProtectedElement::Line {
length_km: 5.0,
impedance_pu: 0.1,
},
upstream_relay: Some(2),
downstream_relays: vec![],
});
let report = study.run().expect("study should succeed");
assert!(
report.all_coordinated,
"Expected all coordinated; violations={:?}",
report.total_violations
);
assert_eq!(report.total_violations, 0);
}
#[test]
fn test_violation_detected() {
let config = CoordinationStudyConfig {
system_voltage_kv: 11.0,
base_mva: 100.0,
max_fault_current_ka: 5.0,
min_fault_current_ka: 0.5,
grading_margin_ms: 300.0,
instantaneous_margin_pct: 10.0,
};
let mut study = CoordinationStudy::new(config);
study.add_relay(si_relay(1, 400.0, 0.2));
study.add_relay(si_relay(2, 400.0, 0.2));
study.add_zone(ProtectionZone {
id: 1,
relay_id: 1,
zone_type: ZoneType::Main,
protected_element: ProtectedElement::Line {
length_km: 5.0,
impedance_pu: 0.1,
},
upstream_relay: Some(2),
downstream_relays: vec![],
});
let report = study.run().expect("study should run");
assert!(
!report.all_coordinated,
"Expected violations when both relays identical"
);
assert!(report.total_violations > 0);
assert!(report.worst_violation_ms > 0.0);
}
#[test]
fn test_auto_tune_achieves_coordination() {
let config = CoordinationStudyConfig {
system_voltage_kv: 11.0,
base_mva: 100.0,
max_fault_current_ka: 5.0,
min_fault_current_ka: 0.5,
grading_margin_ms: 300.0,
instantaneous_margin_pct: 10.0,
};
let mut study = CoordinationStudy::new(config);
let ds_relay = si_relay(1, 400.0, 0.2);
study.add_relay(ds_relay.clone());
study.add_relay(si_relay(2, 300.0, 0.2));
study.add_zone(ProtectionZone {
id: 1,
relay_id: 1,
zone_type: ZoneType::Main,
protected_element: ProtectedElement::Line {
length_km: 5.0,
impedance_pu: 0.1,
},
upstream_relay: Some(2),
downstream_relays: vec![1],
});
let fault_a = 5000.0;
let new_tds = study.auto_tune_tds(2, fault_a);
let m_up = fault_a / 300.0;
let t_up = CoordinationStudy::idmt_trip_time(&IdmtCurve::StandardInverse, new_tds, m_up);
let m_down = fault_a / 400.0;
let t_down = CoordinationStudy::idmt_trip_time(
&IdmtCurve::StandardInverse,
ds_relay.time_dial,
m_down,
);
let actual_margin_ms = (t_up - t_down) * 1000.0;
assert!(
actual_margin_ms >= 299.0, "Auto-tuned margin {actual_margin_ms:.2} ms < 300 ms required"
);
}
#[test]
fn test_instantaneous_faster_than_idmt_at_high_current() {
let relay_idmt = RelayCharacteristics {
relay_id: 10,
relay_type: RelayType::OvercurrentInverse,
time_dial: 1.0,
pickup_current_a: 100.0,
instantaneous_pickup_a: 0.0, ct_ratio: 100.0,
curve_type: IdmtCurve::VeryInverse,
};
let relay_inst = RelayCharacteristics {
relay_id: 11,
relay_type: RelayType::OvercurrentInverse,
time_dial: 1.0,
pickup_current_a: 100.0,
instantaneous_pickup_a: 1500.0, ct_ratio: 100.0,
curve_type: IdmtCurve::VeryInverse,
};
let i_high = 3000.0_f64;
let t_idmt = CoordinationStudy::relay_trip_time_s(&relay_idmt, i_high);
let t_inst = CoordinationStudy::relay_trip_time_s(&relay_inst, i_high);
assert!(
t_inst < t_idmt,
"Instantaneous ({t_inst:.4} s) should be faster than IDMT ({t_idmt:.4} s)"
);
assert!(
(t_inst - 0.02).abs() < 1e-6,
"Instantaneous trip time should be 0.02 s, got {t_inst}"
);
}
#[test]
fn test_empty_study_returns_error() {
let study = CoordinationStudy::new(CoordinationStudyConfig::default());
let err = study.run().unwrap_err();
assert!(
matches!(err, StudyError::NoZones),
"Expected NoZones, got {err}"
);
}
}