use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum IbrType {
SolarPv {
capacity_mw: f64,
control_mode: String,
},
WindTurbine {
capacity_mw: f64,
},
BatteryStorage {
capacity_mw: f64,
capacity_mwh: f64,
},
Hvdc {
capacity_mw: f64,
},
Hybrid {
mw_pv: f64,
mw_wind: f64,
mw_bess: f64,
},
}
impl IbrType {
pub fn capacity_mw(&self) -> f64 {
match self {
IbrType::SolarPv { capacity_mw, .. } => *capacity_mw,
IbrType::WindTurbine { capacity_mw } => *capacity_mw,
IbrType::BatteryStorage { capacity_mw, .. } => *capacity_mw,
IbrType::Hvdc { capacity_mw } => *capacity_mw,
IbrType::Hybrid {
mw_pv,
mw_wind,
mw_bess,
} => mw_pv + mw_wind + mw_bess,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IbrUnit {
pub unit_id: String,
pub bus_id: usize,
pub ibr_type: IbrType,
pub fault_current_pu: f64,
pub negative_seq_capability: bool,
pub fast_frequency_response: bool,
pub lvrt_capability: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdaptiveDistanceRelay {
pub relay_id: String,
pub protected_branch_idx: usize,
pub zone1_reach_pu: f64,
pub zone2_reach_pu: f64,
pub zone3_reach_pu: f64,
pub ibr_infeed_correction: bool,
pub negative_seq_supervision: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LomMethod {
Rocof,
VectorShift,
UnderOverFreq,
Combined,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LomDetector {
pub method: LomMethod,
pub rate_of_change_hz_per_s: f64,
pub vector_shift_deg: f64,
pub freq_min_hz: f64,
pub freq_max_hz: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IbrProtectionConfig {
pub ibr_penetration_pct: f64,
pub min_fault_current_pu: f64,
pub use_negative_sequence: bool,
pub communication_aided: bool,
pub directional_element: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IbrProtectionScheme {
pub ibr_units: Vec<IbrUnit>,
pub relays: Vec<AdaptiveDistanceRelay>,
pub lom_detectors: Vec<LomDetector>,
pub config: IbrProtectionConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaultCurrentAssessment {
pub total_fault_current_pu: f64,
pub ibr_contribution_pu: f64,
pub sync_contribution_pu: f64,
pub adequate_for_overcurrent: bool,
pub min_relay_setting_pu: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LomResult {
pub lom_detected: bool,
pub rocof_hz_per_s: f64,
pub vector_shift_deg: f64,
pub triggering_method: String,
pub confidence: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum FaultDirection {
Forward,
Reverse,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtectionReliability {
pub dependability_pct: f64,
pub security_pct: f64,
pub adequate: bool,
pub recommendation: String,
}
pub fn assess_fault_current(
ibr_units: &[IbrUnit],
fault_location_bus: usize,
sync_contribution_pu: f64,
) -> FaultCurrentAssessment {
let ibr_contribution_pu: f64 = ibr_units
.iter()
.filter(|u| u.bus_id == fault_location_bus)
.map(|u| u.fault_current_pu)
.sum();
let total_fault_current_pu = ibr_contribution_pu + sync_contribution_pu;
const OC_ADEQUACY_THRESHOLD_PU: f64 = 1.8;
let adequate_for_overcurrent = total_fault_current_pu >= OC_ADEQUACY_THRESHOLD_PU;
let min_relay_setting_pu = total_fault_current_pu * 0.80;
FaultCurrentAssessment {
total_fault_current_pu,
ibr_contribution_pu,
sync_contribution_pu,
adequate_for_overcurrent,
min_relay_setting_pu,
}
}
pub fn correct_distance_reach(
relay: &AdaptiveDistanceRelay,
ibr_infeed_current: f64,
relay_current: f64,
) -> f64 {
if !relay.ibr_infeed_correction || relay_current <= 0.0 {
return relay.zone1_reach_pu;
}
let correction_factor = (relay_current + ibr_infeed_current) / relay_current;
let adjusted_reach = relay.zone1_reach_pu / correction_factor;
adjusted_reach.max(relay.zone1_reach_pu * 0.50)
}
pub fn detect_loss_of_mains(
freq_hz: f64,
freq_history: &[f64],
angle_deg: f64,
angle_history: &[f64],
detector: &LomDetector,
sample_period_s: f64,
) -> LomResult {
let rocof_hz_per_s = compute_rocof(freq_history, freq_hz, sample_period_s);
let vector_shift = compute_vector_shift(angle_history, angle_deg);
let under_over_freq = freq_hz < detector.freq_min_hz || freq_hz > detector.freq_max_hz;
let rocof_triggered = rocof_hz_per_s.abs() > detector.rate_of_change_hz_per_s;
let vs_triggered = vector_shift.abs() > detector.vector_shift_deg;
let uof_triggered = under_over_freq;
let (lom_detected, triggering_method, confidence) = match &detector.method {
LomMethod::Rocof => {
let triggered = rocof_triggered;
let conf = if triggered {
(rocof_hz_per_s.abs() / detector.rate_of_change_hz_per_s).min(1.0)
} else {
0.0
};
(
triggered,
if triggered {
"ROCOF".to_string()
} else {
String::new()
},
conf,
)
}
LomMethod::VectorShift => {
let triggered = vs_triggered;
let conf = if triggered {
(vector_shift.abs() / detector.vector_shift_deg).min(1.0)
} else {
0.0
};
(
triggered,
if triggered {
"VectorShift".to_string()
} else {
String::new()
},
conf,
)
}
LomMethod::UnderOverFreq => {
let triggered = uof_triggered;
let conf = if triggered { 0.95 } else { 0.0 };
(
triggered,
if triggered {
"UnderOverFreq".to_string()
} else {
String::new()
},
conf,
)
}
LomMethod::Combined => {
let mut methods: Vec<&str> = Vec::new();
let mut conf = 0.0_f64;
if rocof_triggered {
methods.push("ROCOF");
conf = conf.max((rocof_hz_per_s.abs() / detector.rate_of_change_hz_per_s).min(1.0));
}
if vs_triggered {
methods.push("VectorShift");
conf = conf.max((vector_shift.abs() / detector.vector_shift_deg).min(1.0));
}
if uof_triggered {
methods.push("UnderOverFreq");
conf = conf.max(0.95);
}
let triggered = !methods.is_empty();
let trig_str = if triggered {
methods.join("+")
} else {
String::new()
};
(triggered, trig_str, conf)
}
};
LomResult {
lom_detected,
rocof_hz_per_s,
vector_shift_deg: vector_shift,
triggering_method,
confidence,
}
}
fn compute_rocof(freq_history: &[f64], current_freq: f64, sample_period_s: f64) -> f64 {
if freq_history.is_empty() || sample_period_s <= 0.0 {
return 0.0;
}
let oldest = *freq_history.first().unwrap_or(¤t_freq);
let n_steps = freq_history.len() as f64; let time_window_s = n_steps * sample_period_s;
if time_window_s <= 0.0 {
return 0.0;
}
(current_freq - oldest) / time_window_s
}
fn compute_vector_shift(angle_history: &[f64], current_angle: f64) -> f64 {
if let Some(&prev) = angle_history.last() {
let diff = current_angle - prev;
wrap_angle_deg(diff)
} else {
0.0
}
}
fn wrap_angle_deg(diff: f64) -> f64 {
let mut d = diff % 360.0;
if d > 180.0 {
d -= 360.0;
} else if d <= -180.0 {
d += 360.0;
}
d
}
pub fn negative_sequence_supervision(i1_pu: f64, i2_pu: f64, v2_pu: f64) -> bool {
const I2_ABS_THRESHOLD: f64 = 0.10; const I2_I1_RATIO_THRESHOLD: f64 = 0.20; const V2_THRESHOLD: f64 = 0.05;
let abs_fault = i2_pu > I2_ABS_THRESHOLD;
let ratio_fault = if i1_pu > 1e-6 {
(i2_pu / i1_pu) > I2_I1_RATIO_THRESHOLD
} else {
false
};
let voltage_confirm = v2_pu > V2_THRESHOLD;
(abs_fault || ratio_fault) && voltage_confirm
}
pub fn directional_element(
v_pu: f64,
i_pu: f64,
line_angle_deg: f64,
fault_angle_deg: f64,
) -> FaultDirection {
const MIN_CURRENT_PU: f64 = 0.05;
const MIN_VOLTAGE_PU: f64 = 0.02;
if i_pu < MIN_CURRENT_PU || v_pu < MIN_VOLTAGE_PU {
return FaultDirection::Unknown;
}
let angle_diff = wrap_angle_deg(fault_angle_deg - line_angle_deg);
if angle_diff.abs() <= 90.0 {
FaultDirection::Forward
} else if angle_diff.abs() > 90.0 {
FaultDirection::Reverse
} else {
FaultDirection::Unknown
}
}
pub fn rate_protection_reliability(scheme: &IbrProtectionScheme) -> ProtectionReliability {
let pct = scheme.config.ibr_penetration_pct;
let base_dependability = if pct <= 30.0 {
99.95
} else if pct <= 50.0 {
99.5 - (pct - 30.0) * 0.20 } else if pct <= 70.0 {
95.5 - (pct - 50.0) * 0.25
} else {
90.5 - (pct - 70.0) * 0.25
};
let mut dependability = base_dependability;
if scheme.config.use_negative_sequence {
dependability += 1.5;
}
if scheme.config.communication_aided {
dependability += 2.5;
}
if scheme.config.directional_element {
dependability += 0.5;
}
dependability = dependability.min(99.99);
let mut security = if pct <= 50.0 {
99.95
} else {
99.95 - (pct - 50.0) * 0.02
};
if scheme.config.communication_aided {
security = security.max(99.9);
}
security = security.min(99.99);
let adequate = dependability >= 99.9 && security >= 99.9;
let recommendation = build_reliability_recommendation(dependability, security, pct, scheme);
ProtectionReliability {
dependability_pct: dependability,
security_pct: security,
adequate,
recommendation,
}
}
fn build_reliability_recommendation(
dependability_pct: f64,
security_pct: f64,
ibr_pct: f64,
scheme: &IbrProtectionScheme,
) -> String {
let mut parts: Vec<String> = Vec::new();
if dependability_pct < 99.9 {
parts.push(format!(
"Dependability {:.2}% below 99.9% target — improve detection sensitivity.",
dependability_pct
));
}
if security_pct < 99.9 {
parts.push(format!(
"Security {:.2}% below 99.9% target — review relay settings.",
security_pct
));
}
if ibr_pct > 30.0 && !scheme.config.use_negative_sequence {
parts.push("Enable negative-sequence supervision for IBR penetration > 30%.".to_string());
}
if ibr_pct > 50.0 && !scheme.config.communication_aided {
parts.push(
"Add pilot/communication-aided protection for IBR penetration > 50%.".to_string(),
);
}
if parts.is_empty() {
"Protection scheme meets reliability targets for current IBR penetration.".to_string()
} else {
parts.join(" ")
}
}
pub fn recommend_protection_upgrades(
current_scheme: &IbrProtectionScheme,
ibr_penetration_pct: f64,
) -> Vec<String> {
let mut recommendations: Vec<String> = Vec::new();
if ibr_penetration_pct > 30.0 {
if !current_scheme.config.use_negative_sequence {
recommendations.push(
"Add negative-sequence (I2/V2) supervision elements: \
conventional overcurrent relays lose sensitivity at >30% IBR penetration."
.to_string(),
);
}
recommendations.push(
"Review overcurrent relay pickup settings: minimum fault current may have \
decreased due to IBR displacement of synchronous machines."
.to_string(),
);
recommendations.push(
"Deploy adaptive distance relay reach correction for IBR infeed compensation."
.to_string(),
);
}
if ibr_penetration_pct > 50.0 {
if !current_scheme.config.communication_aided {
recommendations.push(
"Implement pilot/communication-aided protection (POTT or DUTT scheme): \
overcurrent and distance relays alone are insufficient at >50% IBR penetration."
.to_string(),
);
}
recommendations.push(
"Install synchrophasor-based wide-area protection for islanding detection \
and system integrity protection."
.to_string(),
);
recommendations.push(
"Configure ROCOF and vector-shift LOM detectors at every IBR point-of-connection: \
loss-of-mains risk increases with reduced synchronous inertia."
.to_string(),
);
}
if ibr_penetration_pct > 70.0 {
recommendations.push(
"Mandate grid-forming inverter control for large IBR units (>10 MW): \
grid-forming converters emulate synchronous inertia and provide higher \
fault current (up to 2 pu) to support protection operation."
.to_string(),
);
recommendations.push(
"Consider virtual inertia / synthetic inertia emulation to maintain ROCOF \
below protection thresholds during grid disturbances."
.to_string(),
);
recommendations.push(
"Implement centralized protection coordination using real-time IBR \
dispatch data to adaptively update relay settings."
.to_string(),
);
}
if ibr_penetration_pct > 20.0 && !current_scheme.config.directional_element {
recommendations.push(
"Enable directional elements on all distance/overcurrent relays: \
bidirectional power flow from IBR can cause maloperation without directional supervision."
.to_string(),
);
}
let lom_count = current_scheme.lom_detectors.len();
let ibr_count = current_scheme.ibr_units.len();
if ibr_penetration_pct > 25.0 && lom_count < ibr_count {
recommendations.push(format!(
"Deploy LOM detectors at all {} IBR connection points (currently {} configured).",
ibr_count, lom_count
));
}
recommendations
}
#[cfg(test)]
mod tests {
use super::*;
fn make_ibr_unit(bus_id: usize, fault_current_pu: f64) -> IbrUnit {
IbrUnit {
unit_id: format!("IBR-{}", bus_id),
bus_id,
ibr_type: IbrType::SolarPv {
capacity_mw: 50.0,
control_mode: "grid-following".to_string(),
},
fault_current_pu,
negative_seq_capability: false,
fast_frequency_response: false,
lvrt_capability: true,
}
}
fn make_relay(zone1: f64) -> AdaptiveDistanceRelay {
AdaptiveDistanceRelay {
relay_id: "R1".to_string(),
protected_branch_idx: 0,
zone1_reach_pu: zone1,
zone2_reach_pu: zone1 * 1.5,
zone3_reach_pu: zone1 * 2.5,
ibr_infeed_correction: true,
negative_seq_supervision: true,
}
}
fn make_lom_detector(method: LomMethod) -> LomDetector {
LomDetector {
method,
rate_of_change_hz_per_s: 1.0,
vector_shift_deg: 12.0,
freq_min_hz: 49.0,
freq_max_hz: 51.0,
}
}
fn make_scheme(
penetration_pct: f64,
use_neg_seq: bool,
comm_aided: bool,
) -> IbrProtectionScheme {
IbrProtectionScheme {
ibr_units: vec![make_ibr_unit(0, 1.5)],
relays: vec![make_relay(0.8)],
lom_detectors: vec![make_lom_detector(LomMethod::Combined)],
config: IbrProtectionConfig {
ibr_penetration_pct: penetration_pct,
min_fault_current_pu: 1.5,
use_negative_sequence: use_neg_seq,
communication_aided: comm_aided,
directional_element: true,
},
}
}
#[test]
fn test_fault_current_all_ibr_inadequate() {
let units = vec![make_ibr_unit(0, 0.8), make_ibr_unit(0, 0.8)];
let assessment = assess_fault_current(&units, 0, 0.0);
assert!(
assessment.total_fault_current_pu < 2.0,
"Expected < 2.0 pu total, got {}",
assessment.total_fault_current_pu
);
assert!(
!assessment.adequate_for_overcurrent,
"Should be inadequate for OC at 1.6 pu"
);
assert_eq!(assessment.ibr_contribution_pu, 1.6);
assert_eq!(assessment.sync_contribution_pu, 0.0);
}
#[test]
fn test_distance_reach_correction_ibr_infeed() {
let relay = make_relay(0.8);
let adjusted = correct_distance_reach(&relay, 0.5, 1.0);
assert!(
adjusted < relay.zone1_reach_pu,
"Adjusted reach {} should be less than original {}",
adjusted,
relay.zone1_reach_pu
);
let expected = 0.8 / 1.5;
assert!(
(adjusted - expected).abs() < 1e-9,
"Expected {:.4}, got {:.4}",
expected,
adjusted
);
}
#[test]
fn test_lom_rocof_detected() {
let detector = make_lom_detector(LomMethod::Rocof);
let freq_history = vec![50.0];
let result = detect_loss_of_mains(51.0, &freq_history, 0.0, &[], &detector, 0.5);
assert!(result.lom_detected, "ROCOF LOM should be detected");
assert!(
result.rocof_hz_per_s.abs() > 1.0,
"ROCOF should exceed threshold, got {}",
result.rocof_hz_per_s
);
assert_eq!(result.triggering_method, "ROCOF");
}
#[test]
fn test_lom_vector_shift_detected() {
let detector = make_lom_detector(LomMethod::VectorShift);
let angle_history = vec![0.0]; let result = detect_loss_of_mains(
50.0,
&[50.0],
15.0, &angle_history,
&detector,
0.02,
);
assert!(result.lom_detected, "Vector shift LOM should be detected");
assert!(
result.vector_shift_deg.abs() > 12.0,
"Vector shift {} should exceed 12°",
result.vector_shift_deg
);
assert_eq!(result.triggering_method, "VectorShift");
}
#[test]
fn test_negative_sequence_supervision_fault_indicated() {
let fault_indicated = negative_sequence_supervision(0.8, 0.15, 0.08);
assert!(
fault_indicated,
"Fault should be indicated with I2=0.15, V2=0.08"
);
}
#[test]
fn test_negative_sequence_supervision_no_fault() {
let fault_indicated = negative_sequence_supervision(1.0, 0.02, 0.02);
assert!(!fault_indicated, "No fault should be indicated at low I2");
}
#[test]
fn test_directional_forward_fault() {
let direction = directional_element(0.9, 2.0, 75.0, 72.0);
assert_eq!(
direction,
FaultDirection::Forward,
"Expected Forward for fault angle near line angle"
);
}
#[test]
fn test_directional_reverse_fault() {
let direction = directional_element(0.9, 2.0, 75.0, -120.0);
assert_eq!(
direction,
FaultDirection::Reverse,
"Expected Reverse for fault angle 180° from line angle"
);
}
#[test]
fn test_reliability_low_ibr_penetration_adequate() {
let scheme = make_scheme(20.0, false, false);
let reliability = rate_protection_reliability(&scheme);
assert!(
reliability.dependability_pct > 99.9,
"Low IBR penetration should give >99.9% dependability, got {}",
reliability.dependability_pct
);
assert!(
reliability.adequate,
"Scheme should be adequate at 20% IBR penetration"
);
}
#[test]
fn test_upgrade_recommendations_60pct_ibr() {
let scheme = make_scheme(60.0, false, false);
let recs = recommend_protection_upgrades(&scheme, 60.0);
let has_pilot = recs.iter().any(|r| {
r.to_lowercase().contains("pilot") || r.to_lowercase().contains("communication")
});
assert!(
has_pilot,
"Should recommend pilot protection at 60% IBR penetration. Got: {:?}",
recs
);
assert!(
!recs.is_empty(),
"Should have multiple recommendations at 60% penetration"
);
}
#[test]
fn test_lom_combined_multiple_triggers() {
let mut detector = make_lom_detector(LomMethod::Combined);
detector.freq_min_hz = 49.5;
let freq_history = vec![50.0];
let result = detect_loss_of_mains(48.5, &freq_history, 0.0, &[], &detector, 0.5);
assert!(result.lom_detected, "Combined LOM should trigger");
assert!(
result.triggering_method.contains("ROCOF")
|| result.triggering_method.contains("UnderOverFreq"),
"Should report triggering methods: {}",
result.triggering_method
);
}
#[test]
fn test_upgrade_recommendations_80pct_grid_forming() {
let scheme = make_scheme(80.0, true, true);
let recs = recommend_protection_upgrades(&scheme, 80.0);
let has_grid_forming = recs
.iter()
.any(|r| r.to_lowercase().contains("grid-forming"));
assert!(
has_grid_forming,
"Should recommend grid-forming inverters at 80% penetration. Got: {:?}",
recs
);
}
#[test]
fn test_fault_current_mixed_sync_ibr() {
let units = vec![make_ibr_unit(0, 1.2)]; let assessment = assess_fault_current(&units, 0, 3.5);
assert!(
assessment.adequate_for_overcurrent,
"Mixed sync+IBR should be adequate: total={}",
assessment.total_fault_current_pu
);
assert!((assessment.ibr_contribution_pu - 1.2).abs() < 1e-9);
assert!((assessment.sync_contribution_pu - 3.5).abs() < 1e-9);
}
#[test]
fn test_distance_reach_no_correction() {
let mut relay = make_relay(0.8);
relay.ibr_infeed_correction = false;
let adjusted = correct_distance_reach(&relay, 0.5, 1.0);
assert!(
(adjusted - 0.8).abs() < 1e-9,
"Without correction, reach should remain 0.8 pu"
);
}
}