use num_complex::Complex64;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Zone {
Zone1,
Zone2,
Zone3,
}
impl Zone {
pub fn default_delay_s(&self) -> f64 {
match self {
Zone::Zone1 => 0.0,
Zone::Zone2 => 0.3,
Zone::Zone3 => 0.6,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MhoCharacteristic {
pub z_reach: Complex64,
pub line_angle_deg: f64,
pub zone: Zone,
pub time_delay_s: f64,
}
impl MhoCharacteristic {
pub fn new(z_reach: Complex64, line_angle_deg: f64, zone: Zone) -> Self {
Self {
z_reach,
line_angle_deg,
zone,
time_delay_s: zone.default_delay_s(),
}
}
pub fn centre(&self) -> Complex64 {
self.z_reach / 2.0
}
pub fn radius(&self) -> f64 {
self.z_reach.norm() / 2.0
}
pub fn is_inside(&self, z_app: Complex64) -> bool {
(z_app - self.centre()).norm() <= self.radius()
}
pub fn operate_signal(&self, z_app: Complex64) -> f64 {
self.radius() - (z_app - self.centre()).norm()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuadrilateralCharacteristic {
pub x_reach: f64,
pub r_reach: f64,
pub x_offset: f64,
pub line_angle_deg: f64,
pub zone: Zone,
pub time_delay_s: f64,
}
impl QuadrilateralCharacteristic {
pub fn new(x_reach: f64, r_reach: f64, line_angle_deg: f64, zone: Zone) -> Self {
Self {
x_reach,
r_reach,
x_offset: -0.05 * x_reach, line_angle_deg,
zone,
time_delay_s: zone.default_delay_s(),
}
}
pub fn is_inside(&self, z_app: Complex64) -> bool {
let r = z_app.re;
let x = z_app.im;
if x > self.x_reach || x < self.x_offset {
return false;
}
let phi = self.line_angle_deg.to_radians();
let proj = r * (-phi.sin()) + x * phi.cos();
proj.abs() <= self.r_reach
}
pub fn margin(&self, z_app: Complex64) -> f64 {
let r = z_app.re;
let x = z_app.im;
let phi = self.line_angle_deg.to_radians();
let proj = r * (-phi.sin()) + x * phi.cos();
let margins = [
self.x_reach - x,
x - self.x_offset,
self.r_reach - proj.abs(),
];
margins.iter().cloned().fold(f64::INFINITY, f64::min)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoadBlinder {
pub z_load_min: f64,
pub phi_load_max_deg: f64,
}
impl LoadBlinder {
pub fn typical_transmission() -> Self {
Self {
z_load_min: 0.8,
phi_load_max_deg: 30.0,
}
}
pub fn typical_distribution() -> Self {
Self {
z_load_min: 0.5,
phi_load_max_deg: 40.0,
}
}
pub fn is_load_region(&self, z_app: Complex64) -> bool {
let mag = z_app.norm();
let angle_deg = z_app.im.atan2(z_app.re).to_degrees().abs();
mag >= self.z_load_min && angle_deg <= self.phi_load_max_deg
}
}
#[derive(Debug, Clone)]
pub struct RelayMeasurement {
pub v_relay: Complex64,
pub i_relay: Complex64,
pub k0: Complex64,
}
impl RelayMeasurement {
pub fn z_apparent_phase(&self) -> Complex64 {
if self.i_relay.norm() < 1e-9 {
Complex64::new(f64::INFINITY, 0.0)
} else {
self.v_relay / self.i_relay
}
}
pub fn z_apparent_ground(&self, i_zero_seq: Complex64) -> Complex64 {
let i_comp = self.i_relay + self.k0 * i_zero_seq;
if i_comp.norm() < 1e-9 {
Complex64::new(f64::INFINITY, 0.0)
} else {
self.v_relay / i_comp
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DistanceTripDecision {
NoTrip,
Trip { zone: Zone, delay_s: f64 },
Blocked,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistanceRelay {
pub zone1: QuadrilateralCharacteristic,
pub zone2: QuadrilateralCharacteristic,
pub zone3: Option<QuadrilateralCharacteristic>,
pub load_blinder: LoadBlinder,
pub i_min_pu: f64,
}
impl DistanceRelay {
pub fn from_line_impedance(z_line: Complex64, line_angle_deg: f64) -> Self {
let x_line = z_line.norm() * line_angle_deg.to_radians().sin();
let r_line = z_line.norm() * line_angle_deg.to_radians().cos();
let zone1 = QuadrilateralCharacteristic::new(
0.80 * x_line,
0.80 * r_line.max(0.2 * x_line),
line_angle_deg,
Zone::Zone1,
);
let zone2 = QuadrilateralCharacteristic::new(
1.20 * x_line,
1.20 * r_line.max(0.25 * x_line),
line_angle_deg,
Zone::Zone2,
);
let zone3 = QuadrilateralCharacteristic::new(
2.00 * x_line,
2.00 * r_line.max(0.3 * x_line),
line_angle_deg,
Zone::Zone3,
);
Self {
zone1,
zone2,
zone3: Some(zone3),
load_blinder: LoadBlinder::typical_transmission(),
i_min_pu: 0.05,
}
}
pub fn evaluate(&self, z_app: Complex64, i_mag: f64) -> DistanceTripDecision {
if i_mag < self.i_min_pu {
return DistanceTripDecision::NoTrip;
}
if self.load_blinder.is_load_region(z_app) {
return DistanceTripDecision::Blocked;
}
if self.zone1.is_inside(z_app) {
return DistanceTripDecision::Trip {
zone: Zone::Zone1,
delay_s: self.zone1.time_delay_s,
};
}
if self.zone2.is_inside(z_app) {
return DistanceTripDecision::Trip {
zone: Zone::Zone2,
delay_s: self.zone2.time_delay_s,
};
}
if let Some(ref z3) = self.zone3 {
if z3.is_inside(z_app) {
return DistanceTripDecision::Trip {
zone: Zone::Zone3,
delay_s: z3.time_delay_s,
};
}
}
DistanceTripDecision::NoTrip
}
}
pub fn apparent_impedance(v: Complex64, i: Complex64) -> Complex64 {
if i.norm() < 1e-12 {
Complex64::new(f64::INFINITY, 0.0)
} else {
v / i
}
}
pub fn mho_circle_points(mho: &MhoCharacteristic, n: usize) -> Vec<(f64, f64)> {
let centre = mho.centre();
let r = mho.radius();
(0..=n)
.map(|k| {
let theta = 2.0 * std::f64::consts::PI * k as f64 / n as f64;
(centre.re + r * theta.cos(), centre.im + r * theta.sin())
})
.collect()
}
pub fn fault_distance_pu(z_apparent: Complex64, z_line: Complex64) -> f64 {
let z_line_mag = z_line.norm();
if z_line_mag < 1e-12 {
return 0.0;
}
let z_line_unit = z_line / z_line_mag;
let projection = (z_apparent * z_line_unit.conj()).re;
(projection / z_line_mag).clamp(0.0, 1.0)
}
pub fn pu_to_secondary_ohms(z_pu: f64, z_base_ohm: f64, ct_ratio: f64, vt_ratio: f64) -> f64 {
z_pu * z_base_ohm * vt_ratio / ct_ratio
}
#[cfg(test)]
mod tests {
use super::*;
fn line_z() -> Complex64 {
Complex64::new(0.05, 0.40)
}
#[test]
fn test_mho_inside_at_origin() {
let mho = MhoCharacteristic::new(line_z(), 83.0, Zone::Zone1);
let sig = mho.operate_signal(Complex64::new(0.0, 0.0));
assert!(
sig.abs() < 1e-10,
"Origin should be on Mho boundary: sig={sig:.2e}"
);
}
#[test]
fn test_mho_fault_inside_reach() {
let z_reach = line_z() * 0.8;
let mho = MhoCharacteristic::new(z_reach, 83.0, Zone::Zone1);
let z_fault = z_reach * 0.5;
assert!(
mho.is_inside(z_fault),
"50% fault should be inside Zone 1 Mho"
);
}
#[test]
fn test_mho_fault_outside_reach() {
let z_reach = line_z() * 0.8;
let mho = MhoCharacteristic::new(z_reach, 83.0, Zone::Zone1);
let z_fault = z_reach * 1.2;
assert!(
!mho.is_inside(z_fault),
"120% reach should be outside Zone 1 Mho"
);
}
#[test]
fn test_mho_circle_points_count() {
let mho = MhoCharacteristic::new(line_z(), 80.0, Zone::Zone2);
let pts = mho_circle_points(&mho, 36);
assert_eq!(pts.len(), 37); }
#[test]
fn test_mho_radius_positive() {
let mho = MhoCharacteristic::new(line_z(), 80.0, Zone::Zone1);
assert!(mho.radius() > 0.0);
assert!((mho.radius() - line_z().norm() / 2.0).abs() < 1e-10);
}
#[test]
fn test_quad_fault_inside() {
let quad = QuadrilateralCharacteristic::new(0.35, 0.15, 83.0, Zone::Zone1);
let z_app = Complex64::new(0.04, 0.20);
assert!(
quad.is_inside(z_app),
"Fault should be inside quadrilateral"
);
}
#[test]
fn test_quad_fault_outside_x() {
let quad = QuadrilateralCharacteristic::new(0.35, 0.15, 83.0, Zone::Zone1);
let z_app = Complex64::new(0.04, 0.40);
assert!(!quad.is_inside(z_app), "X > x_reach should be outside");
}
#[test]
fn test_quad_fault_outside_r() {
let quad = QuadrilateralCharacteristic::new(0.35, 0.05, 83.0, Zone::Zone1);
let z_app = Complex64::new(0.50, 0.20);
assert!(!quad.is_inside(z_app), "Large R should be outside blinder");
}
#[test]
fn test_quad_margin_positive_inside() {
let quad = QuadrilateralCharacteristic::new(0.35, 0.15, 83.0, Zone::Zone1);
let z_app = Complex64::new(0.01, 0.10);
assert!(
quad.margin(z_app) > 0.0,
"Inside point should have positive margin"
);
}
#[test]
fn test_quad_zone_delays() {
let z1 = QuadrilateralCharacteristic::new(0.3, 0.1, 80.0, Zone::Zone1);
let z2 = QuadrilateralCharacteristic::new(0.4, 0.15, 80.0, Zone::Zone2);
assert_eq!(z1.time_delay_s, 0.0);
assert!((z2.time_delay_s - 0.3).abs() < 1e-10);
}
#[test]
fn test_blinder_heavy_load_blocked() {
let blinder = LoadBlinder::typical_transmission();
let phi = 20_f64.to_radians();
let z_load = Complex64::new(1.5 * phi.cos(), 1.5 * phi.sin());
assert!(
blinder.is_load_region(z_load),
"Heavy load should be in load region"
);
}
#[test]
fn test_blinder_fault_not_blocked() {
let blinder = LoadBlinder::typical_transmission();
let phi = 80_f64.to_radians();
let z_fault = Complex64::new(0.1 * phi.cos(), 0.1 * phi.sin());
assert!(
!blinder.is_load_region(z_fault),
"Fault should not be in load region"
);
}
#[test]
fn test_relay_zone1_trip() {
let relay = DistanceRelay::from_line_impedance(line_z(), 83.0);
let z_app = line_z() * 0.8 * 0.5;
let dec = relay.evaluate(z_app, 2.0);
assert_eq!(
dec,
DistanceTripDecision::Trip {
zone: Zone::Zone1,
delay_s: 0.0
}
);
}
#[test]
fn test_relay_zone2_trip() {
let relay = DistanceRelay::from_line_impedance(line_z(), 83.0);
let z_app = line_z() * 0.95; let dec = relay.evaluate(z_app, 2.0);
assert_eq!(
dec,
DistanceTripDecision::Trip {
zone: Zone::Zone2,
delay_s: 0.3
}
);
}
#[test]
fn test_relay_no_trip_outside_all_zones() {
let relay = DistanceRelay::from_line_impedance(line_z(), 83.0);
let z_app = line_z() * 5.0;
let dec = relay.evaluate(z_app, 2.0);
assert_eq!(dec, DistanceTripDecision::NoTrip);
}
#[test]
fn test_relay_blocked_by_load_blinder() {
let mut relay = DistanceRelay::from_line_impedance(line_z(), 83.0);
relay.load_blinder = LoadBlinder {
z_load_min: 0.01,
phi_load_max_deg: 89.0,
};
let z_load = Complex64::new(1.5, 0.1); let dec = relay.evaluate(z_load, 2.0);
assert_eq!(dec, DistanceTripDecision::Blocked);
}
#[test]
fn test_relay_current_supervision() {
let relay = DistanceRelay::from_line_impedance(line_z(), 83.0);
let z_app = line_z() * 0.3;
let dec = relay.evaluate(z_app, 0.001); assert_eq!(dec, DistanceTripDecision::NoTrip);
}
#[test]
fn test_fault_distance_pu_midline() {
let z_line = line_z();
let z_app = z_line * 0.5;
let d = fault_distance_pu(z_app, z_line);
assert!((d - 0.5).abs() < 1e-6, "d={d:.4}");
}
#[test]
fn test_fault_distance_pu_clamped() {
let z_line = line_z();
let z_remote = z_line * 3.0; let d = fault_distance_pu(z_remote, z_line);
assert!(d <= 1.0, "d should be clamped to 1.0");
}
#[test]
fn test_pu_to_secondary_ohms() {
let z_sec = pu_to_secondary_ohms(0.1, 264.0, 120.0, 1200.0);
assert!(z_sec > 0.0);
let expected = 0.1 * 264.0 * 1200.0 / 120.0; assert!((z_sec - expected).abs() < 1e-6, "z_sec={z_sec:.4}");
}
#[test]
fn test_apparent_impedance_zero_current() {
let v = Complex64::new(1.0, 0.0);
let i = Complex64::new(0.0, 0.0);
let z = apparent_impedance(v, i);
assert!(z.re.is_infinite());
}
#[test]
fn test_zone_delays() {
assert_eq!(Zone::Zone1.default_delay_s(), 0.0);
assert!((Zone::Zone2.default_delay_s() - 0.3).abs() < 1e-10);
assert!((Zone::Zone3.default_delay_s() - 0.6).abs() < 1e-10);
}
}