use serde::{Deserialize, Serialize};
use std::fmt::Write as FmtWrite;
pub(super) const DEFAULT_LINE_ANGLE_DEG: f64 = 75.0;
const DEFAULT_ZONE1_REACH: f64 = 0.80;
const DEFAULT_ZONE2_LINE_FACTOR: f64 = 1.20;
const DEFAULT_ZONE2_ADJ_FACTOR: f64 = 0.50;
const DEFAULT_ZONE3_LINE_FACTOR: f64 = 1.00;
const DEFAULT_ZONE3_ADJ_FACTOR: f64 = 1.00;
const ZONE1_DELAY_S: f64 = 0.0;
const ZONE2_DELAY_S: f64 = 0.40;
const ZONE3_DELAY_S: f64 = 0.80;
#[allow(dead_code)]
pub(super) const DEFAULT_CTI_S: f64 = 0.30;
const ZONE1_MAX_REACH_PCT: f64 = 85.0;
const ZONE2_MIN_REACH_PCT: f64 = 120.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZoneDirectional {
Forward,
Reverse,
Nondirectional,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DistanceCharacteristic {
Mho {
mho_angle_deg: f64,
},
Quadrilateral {
r_reach_pu: f64,
x_reach_pu: f64,
angle_deg: f64,
},
Lens,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DifferentialZoneType {
BusDifferential,
TransformerDifferential,
LineDifferential,
GeneratorDifferential,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProtFaultType {
ThreePhase,
SingleLineGround,
LineToLine,
DoubleLineGround,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistanceZone {
pub zone_num: u8,
pub reach_pu: f64,
pub time_delay_s: f64,
pub directional: ZoneDirectional,
pub coverage_pct: f64,
}
impl DistanceZone {
pub fn new(
zone_num: u8,
reach_pu: f64,
time_delay_s: f64,
directional: ZoneDirectional,
coverage_pct: f64,
) -> Self {
Self {
zone_num,
reach_pu,
time_delay_s,
directional,
coverage_pct,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistanceRelay {
pub relay_id: usize,
pub bus_id: usize,
pub protected_line_id: usize,
pub line_impedance_pu: f64,
pub zones: Vec<DistanceZone>,
pub characteristic: DistanceCharacteristic,
pub ct_ratio: f64,
pub vt_ratio: f64,
}
impl DistanceRelay {
pub fn new(relay_id: usize, bus_id: usize, line_id: usize, z_line: f64) -> Self {
Self {
relay_id,
bus_id,
protected_line_id: line_id,
line_impedance_pu: z_line,
zones: Vec::new(),
characteristic: DistanceCharacteristic::Mho {
mho_angle_deg: DEFAULT_LINE_ANGLE_DEG,
},
ct_ratio: 1.0,
vt_ratio: 1.0,
}
}
pub fn add_zone(&mut self, zone: DistanceZone) {
self.zones.push(zone);
}
pub fn apparent_impedance(&self, v_relay: f64, i_relay: f64, angle_diff: f64) -> (f64, f64) {
if i_relay.abs() < 1e-12 {
return (f64::INFINITY, f64::INFINITY);
}
let z_mag = (v_relay / i_relay) * (self.ct_ratio / self.vt_ratio);
let angle_rad = angle_diff.to_radians();
let r = z_mag * angle_rad.cos();
let x = z_mag * angle_rad.sin();
(r, x)
}
pub fn operating_zone(&self, z_apparent: (f64, f64)) -> Option<&DistanceZone> {
let mut sorted: Vec<&DistanceZone> = self.zones.iter().collect();
sorted.sort_by_key(|z| z.zone_num);
sorted
.into_iter()
.find(|&zone| self.is_inside_characteristic(z_apparent, zone))
.map(|v| v as _)
}
fn is_inside_characteristic(&self, z_fault: (f64, f64), zone: &DistanceZone) -> bool {
match &self.characteristic {
DistanceCharacteristic::Mho { mho_angle_deg } => {
is_inside_mho(z_fault, zone.reach_pu, *mho_angle_deg)
}
DistanceCharacteristic::Quadrilateral {
r_reach_pu,
x_reach_pu,
angle_deg: _,
} => {
let (r, x) = z_fault;
r >= 0.0 && r <= *r_reach_pu && x >= 0.0 && x <= *x_reach_pu
}
DistanceCharacteristic::Lens => {
is_inside_mho(z_fault, zone.reach_pu * 0.7, DEFAULT_LINE_ANGLE_DEG)
}
}
}
}
pub(super) fn is_inside_mho(z_fault: (f64, f64), reach_pu: f64, mho_angle_deg: f64) -> bool {
let (r_f, x_f) = z_fault;
let angle_rad = mho_angle_deg.to_radians();
let cx = reach_pu * angle_rad.cos() / 2.0;
let cy = reach_pu * angle_rad.sin() / 2.0;
let radius = (cx * cx + cy * cy).sqrt();
let dx = r_f - cx;
let dy = x_f - cy;
let dist = (dx * dx + dy * dy).sqrt();
dist < radius
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifferentialZone {
pub zone_id: usize,
pub zone_type: DifferentialZoneType,
pub equipment_name: String,
pub boundary_cts: Vec<(usize, f64)>,
pub pickup_pu: f64,
pub slope_pct: f64,
pub i_min_operate_pu: f64,
}
impl DifferentialZone {
pub fn new(zone_id: usize, zone_type: DifferentialZoneType, equipment: String) -> Self {
Self {
zone_id,
zone_type,
equipment_name: equipment,
boundary_cts: Vec::new(),
pickup_pu: 0.20,
slope_pct: 30.0,
i_min_operate_pu: 0.10,
}
}
pub fn add_ct(&mut self, bus_id: usize, ct_ratio: f64) {
self.boundary_cts.push((bus_id, ct_ratio));
}
pub fn check_operation(&self, currents: &[(f64, bool)]) -> bool {
if currents.is_empty() {
return false;
}
let i_diff_signed: f64 = currents
.iter()
.map(|(mag, is_in)| if *is_in { *mag } else { -*mag })
.sum();
let i_diff = i_diff_signed.abs();
let i_restraint: f64 = currents.iter().map(|(mag, _)| *mag).sum::<f64>() / 2.0;
let pickup_threshold = self
.i_min_operate_pu
.max((self.slope_pct / 100.0) * i_restraint);
i_diff > pickup_threshold
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneCoverage {
pub zone_id: usize,
pub protected_equipment: Vec<String>,
pub backup_zones: Vec<usize>,
pub coverage_overlap: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtectionZoneMap {
pub substation_name: String,
pub buses: Vec<usize>,
pub distance_relays: Vec<DistanceRelay>,
pub differential_zones: Vec<DifferentialZone>,
pub coverage: Vec<ZoneCoverage>,
}
impl ProtectionZoneMap {
pub fn new(substation_name: impl Into<String>) -> Self {
Self {
substation_name: substation_name.into(),
buses: Vec::new(),
distance_relays: Vec::new(),
differential_zones: Vec::new(),
coverage: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CoordinationIssue {
InsufficientTimeMargin {
primary_relay: usize,
backup_relay: usize,
margin_s: f64,
},
OverlapTooLarge {
zone_a: usize,
zone_b: usize,
overlap_pct: f64,
},
GapInCoverage {
location: f64,
coverage_pct: f64,
},
Zone1TooLong {
relay_id: usize,
coverage_pct: f64,
},
MissingBackup {
equipment: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoordinationResult {
pub is_coordinated: bool,
pub coordination_margin_s: f64,
pub issues: Vec<CoordinationIssue>,
pub total_clearing_time_s: f64,
pub backup_reach_pu: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaultLocation {
pub per_unit_distance: f64,
pub fault_type: ProtFaultType,
pub fault_resistance_pu: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtectionPerformance {
pub fault_location: FaultLocation,
pub operating_relay_id: usize,
pub operating_zone: u8,
pub clearing_time_s: f64,
pub measured_impedance_pu: f64,
pub is_correct_operation: bool,
pub backup_relay_id: Option<usize>,
pub backup_clearing_time_s: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneCoordinator {
pub zone_map: ProtectionZoneMap,
pub cti_s: f64,
pub zone1_max_reach_pct: f64,
pub zone2_min_reach_pct: f64,
}
impl ZoneCoordinator {
pub fn new(zone_map: ProtectionZoneMap, cti_s: f64) -> Self {
Self {
zone_map,
cti_s,
zone1_max_reach_pct: ZONE1_MAX_REACH_PCT,
zone2_min_reach_pct: ZONE2_MIN_REACH_PCT,
}
}
pub fn check_coordination(&self) -> CoordinationResult {
let relays = &self.zone_map.distance_relays;
let mut issues = Vec::new();
let mut min_margin = f64::INFINITY;
let mut max_clearing_time: f64 = 0.0;
let mut max_backup_reach: f64 = 0.0;
for relay in relays {
for zone in &relay.zones {
if zone.zone_num == 1 {
let cov = zone.coverage_pct;
if cov > self.zone1_max_reach_pct {
issues.push(CoordinationIssue::Zone1TooLong {
relay_id: relay.relay_id,
coverage_pct: cov,
});
}
let delay = zone.time_delay_s;
if delay > max_clearing_time {
max_clearing_time = delay;
}
}
if zone.zone_num >= 2 {
if zone.reach_pu > max_backup_reach {
max_backup_reach = zone.reach_pu;
}
if zone.time_delay_s > max_clearing_time {
max_clearing_time = zone.time_delay_s;
}
}
}
}
for i in 0..relays.len() {
for j in 0..relays.len() {
if i == j {
continue;
}
let primary = &relays[i];
let backup = &relays[j];
let margin = self.compute_coordination_margin(primary, backup);
if margin < f64::INFINITY {
if margin < min_margin {
min_margin = margin;
}
if margin < self.cti_s {
issues.push(CoordinationIssue::InsufficientTimeMargin {
primary_relay: primary.relay_id,
backup_relay: backup.relay_id,
margin_s: margin,
});
}
}
}
}
let gap_issues = self.find_gaps_in_coverage();
issues.extend(gap_issues);
let effective_min_margin = if min_margin == f64::INFINITY {
self.cti_s
} else {
min_margin
};
let is_coordinated = !issues
.iter()
.any(|i| matches!(i, CoordinationIssue::InsufficientTimeMargin { .. }));
CoordinationResult {
is_coordinated,
coordination_margin_s: effective_min_margin,
issues,
total_clearing_time_s: max_clearing_time,
backup_reach_pu: max_backup_reach,
}
}
pub fn evaluate_fault(&self, fault: &FaultLocation, line_id: usize) -> ProtectionPerformance {
let d = fault.per_unit_distance.clamp(0.0, 1.0);
let r_f = fault.fault_resistance_pu;
let line_relays: Vec<&DistanceRelay> = self
.zone_map
.distance_relays
.iter()
.filter(|r| r.protected_line_id == line_id)
.collect();
if line_relays.is_empty() {
return ProtectionPerformance {
fault_location: fault.clone(),
operating_relay_id: usize::MAX,
operating_zone: 0,
clearing_time_s: f64::INFINITY,
measured_impedance_pu: f64::INFINITY,
is_correct_operation: false,
backup_relay_id: None,
backup_clearing_time_s: None,
};
}
let angle_rad = DEFAULT_LINE_ANGLE_DEG.to_radians();
let mut best_relay_id = usize::MAX;
let mut best_zone_num = 0u8;
let mut best_clearing = f64::INFINITY;
let mut best_z_app = 0.0f64;
for relay in &line_relays {
let z_line = relay.line_impedance_pu;
let r_app = d * z_line * angle_rad.cos() + r_f;
let x_app = d * z_line * angle_rad.sin();
let z_app_mag = (r_app * r_app + x_app * x_app).sqrt();
if let Some(zone) = relay.operating_zone((r_app, x_app)) {
if zone.time_delay_s < best_clearing {
best_clearing = zone.time_delay_s;
best_relay_id = relay.relay_id;
best_zone_num = zone.zone_num;
best_z_app = z_app_mag;
}
}
}
let is_correct = best_relay_id != usize::MAX;
let mut backup_relay_id = None;
let mut backup_clearing = None;
if is_correct {
for relay in &self.zone_map.distance_relays {
if relay.relay_id == best_relay_id {
continue;
}
let z_line = relay.line_impedance_pu;
let r_app = d * z_line * angle_rad.cos() + r_f;
let x_app = d * z_line * angle_rad.sin();
for zone in &relay.zones {
if zone.zone_num > best_zone_num
&& relay.is_inside_characteristic((r_app, x_app), zone)
{
let ct = zone.time_delay_s;
if backup_clearing.map_or(true, |bc: f64| ct < bc) {
backup_relay_id = Some(relay.relay_id);
backup_clearing = Some(ct);
}
}
}
}
if backup_clearing.is_none() {
if let Some(primary_relay) =
line_relays.iter().find(|r| r.relay_id == best_relay_id)
{
for zone in &primary_relay.zones {
if zone.zone_num > best_zone_num {
let bt = zone.time_delay_s;
if backup_clearing.map_or(true, |bc: f64| bt < bc) {
backup_relay_id = Some(primary_relay.relay_id);
backup_clearing = Some(bt);
}
}
}
}
}
}
ProtectionPerformance {
fault_location: fault.clone(),
operating_relay_id: best_relay_id,
operating_zone: best_zone_num,
clearing_time_s: best_clearing,
measured_impedance_pu: best_z_app,
is_correct_operation: is_correct,
backup_relay_id,
backup_clearing_time_s: backup_clearing,
}
}
pub fn auto_set_zones(
&self,
line_impedance_pu: f64,
adjacent_impedance_pu: f64,
) -> Vec<DistanceZone> {
let z1_reach = DEFAULT_ZONE1_REACH * line_impedance_pu;
let z2_reach = DEFAULT_ZONE2_LINE_FACTOR * line_impedance_pu
+ DEFAULT_ZONE2_ADJ_FACTOR * adjacent_impedance_pu;
let z3_reach = DEFAULT_ZONE3_LINE_FACTOR * line_impedance_pu
+ DEFAULT_ZONE3_ADJ_FACTOR * adjacent_impedance_pu;
let z1_cov = (z1_reach / line_impedance_pu) * 100.0;
let z2_cov = (z2_reach / line_impedance_pu) * 100.0;
let z3_cov = (z3_reach / line_impedance_pu) * 100.0;
vec![
DistanceZone::new(1, z1_reach, ZONE1_DELAY_S, ZoneDirectional::Forward, z1_cov),
DistanceZone::new(2, z2_reach, ZONE2_DELAY_S, ZoneDirectional::Forward, z2_cov),
DistanceZone::new(3, z3_reach, ZONE3_DELAY_S, ZoneDirectional::Forward, z3_cov),
]
}
pub fn check_differential_operation(&self, zone_id: usize, i_in: f64, i_out: f64) -> bool {
if let Some(dz) = self
.zone_map
.differential_zones
.iter()
.find(|z| z.zone_id == zone_id)
{
dz.check_operation(&[(i_in, true), (i_out, false)])
} else {
false
}
}
pub fn is_inside_mho(
&self,
z_fault: (f64, f64),
zone: &DistanceZone,
char: &DistanceCharacteristic,
) -> bool {
match char {
DistanceCharacteristic::Mho { mho_angle_deg } => {
is_inside_mho(z_fault, zone.reach_pu, *mho_angle_deg)
}
DistanceCharacteristic::Quadrilateral {
r_reach_pu,
x_reach_pu,
angle_deg: _,
} => {
let (r, x) = z_fault;
r >= 0.0 && r <= *r_reach_pu && x >= 0.0 && x <= *x_reach_pu
}
DistanceCharacteristic::Lens => {
is_inside_mho(z_fault, zone.reach_pu * 0.7, DEFAULT_LINE_ANGLE_DEG)
}
}
}
pub fn compute_coordination_margin(
&self,
primary: &DistanceRelay,
backup: &DistanceRelay,
) -> f64 {
let primary_delay = primary
.zones
.iter()
.filter(|z| z.zone_num <= 2)
.map(|z| z.time_delay_s)
.fold(f64::INFINITY, f64::min);
let backup_delay = backup
.zones
.iter()
.filter(|z| z.zone_num >= 2)
.map(|z| z.time_delay_s)
.fold(f64::INFINITY, f64::min);
if primary_delay == f64::INFINITY || backup_delay == f64::INFINITY {
return f64::INFINITY;
}
backup_delay - primary_delay
}
pub fn find_gaps_in_coverage(&self) -> Vec<CoordinationIssue> {
let mut issues = Vec::new();
for relay in &self.zone_map.distance_relays {
for zone in &relay.zones {
if zone.zone_num == 1 && zone.coverage_pct > self.zone1_max_reach_pct {
issues.push(CoordinationIssue::Zone1TooLong {
relay_id: relay.relay_id,
coverage_pct: zone.coverage_pct,
});
}
}
}
for cov in &self.zone_map.coverage {
if cov.backup_zones.is_empty() {
for equip in &cov.protected_equipment {
issues.push(CoordinationIssue::MissingBackup {
equipment: equip.clone(),
});
}
}
}
for dz in &self.zone_map.differential_zones {
let has_backup = self.zone_map.coverage.iter().any(|c| {
c.protected_equipment.contains(&dz.equipment_name) && !c.backup_zones.is_empty()
});
if !has_backup && !self.zone_map.coverage.is_empty() {
let is_covered = self
.zone_map
.coverage
.iter()
.any(|c| c.protected_equipment.contains(&dz.equipment_name));
if !is_covered {
issues.push(CoordinationIssue::MissingBackup {
equipment: dz.equipment_name.clone(),
});
}
}
}
issues
}
pub fn summary_report(&self) -> String {
let mut out = String::new();
let result = self.check_coordination();
let _ = writeln!(out, "=== Protection Zone Coordination Report ===");
let _ = writeln!(out, "Substation: {}", self.zone_map.substation_name);
let _ = writeln!(
out,
"Distance relays: {}",
self.zone_map.distance_relays.len()
);
let _ = writeln!(
out,
"Differential zones: {}",
self.zone_map.differential_zones.len()
);
let _ = writeln!(out, "CTI setting: {:.2} s", self.cti_s);
let _ = writeln!(
out,
"Zone 1 max reach: {:.1}% | Zone 2 min reach: {:.1}%",
self.zone1_max_reach_pct, self.zone2_min_reach_pct
);
let _ = writeln!(out);
let status = if result.is_coordinated {
"COORDINATED"
} else {
"NOT COORDINATED"
};
let _ = writeln!(out, "Coordination status: {}", status);
let _ = writeln!(
out,
"Minimum CTI margin: {:.3} s",
result.coordination_margin_s
);
let _ = writeln!(
out,
"Total clearing time: {:.3} s",
result.total_clearing_time_s
);
let _ = writeln!(out, "Max backup reach: {:.4} pu", result.backup_reach_pu);
if result.issues.is_empty() {
let _ = writeln!(out, "\nNo coordination issues detected.");
} else {
let _ = writeln!(out, "\nCoordination issues ({}):", result.issues.len());
for issue in &result.issues {
match issue {
CoordinationIssue::InsufficientTimeMargin {
primary_relay,
backup_relay,
margin_s,
} => {
let _ = writeln!(
out,
" [CTI] Relay {} (primary) → Relay {} (backup): margin={:.3} s < {:.3} s",
primary_relay, backup_relay, margin_s, self.cti_s
);
}
CoordinationIssue::Zone1TooLong {
relay_id,
coverage_pct,
} => {
let _ = writeln!(
out,
" [REACH] Relay {}: Zone 1 coverage {:.1}% > {:.1}% limit",
relay_id, coverage_pct, self.zone1_max_reach_pct
);
}
CoordinationIssue::OverlapTooLarge {
zone_a,
zone_b,
overlap_pct,
} => {
let _ = writeln!(
out,
" [OVERLAP] Zones {} and {}: overlap={:.1}%",
zone_a, zone_b, overlap_pct
);
}
CoordinationIssue::GapInCoverage {
location,
coverage_pct,
} => {
let _ = writeln!(
out,
" [GAP] Coverage gap at {:.2} pu: only {:.1}% covered",
location, coverage_pct
);
}
CoordinationIssue::MissingBackup { equipment } => {
let _ = writeln!(out, " [BACKUP] No backup protection for: {}", equipment);
}
}
}
}
if !self.zone_map.distance_relays.is_empty() {
let _ = writeln!(out, "\nDistance relay zones:");
for relay in &self.zone_map.distance_relays {
let _ = writeln!(
out,
" Relay {} (bus {}, line {}): Z_line={:.4} pu",
relay.relay_id, relay.bus_id, relay.protected_line_id, relay.line_impedance_pu
);
for zone in &relay.zones {
let _ = writeln!(
out,
" Zone {}: reach={:.4} pu, delay={:.2} s, coverage={:.1}%",
zone.zone_num, zone.reach_pu, zone.time_delay_s, zone.coverage_pct
);
}
}
}
out
}
}
#[cfg(test)]
mod coordination;