use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum HealingError {
#[error("invalid fault location: segment {0} not found in any feeder")]
InvalidSegment(usize),
#[error("fault buses (upstream={0}, downstream={1}) not found in feeders")]
InvalidFaultBuses(usize, usize),
#[error("no feeders configured — call add_feeder() first")]
NoFeeders,
#[error("load vector length {0} does not match n_buses {1}")]
LoadSizeMismatch(usize, usize),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelfHealingConfig {
pub n_buses: usize,
pub restoration_time_target_s: f64,
pub max_load_transfer_mw: f64,
pub priority_loads: Vec<usize>,
pub automation_level: AutomationLevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutomationLevel {
Manual,
SemiAutomatic,
Automatic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaultEvent {
pub fault_type: FaultType,
pub location: FaultLocation,
pub severity: f64,
pub timestamp_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FaultType {
PermanentPhaseToGround,
TemporaryArcFault,
Equipment(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaultLocation {
pub segment: usize,
pub bus_upstream: usize,
pub bus_downstream: usize,
pub distance_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealingAction {
pub action_type: HealingActionType,
pub device_id: usize,
pub timestamp_s: f64,
pub expected_customers_restored: usize,
pub expected_load_mw: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum HealingActionType {
OpenSwitch,
CloseSwitch,
LoadTransfer {
from_feeder: usize,
to_feeder: usize,
},
DerActivation {
der_bus: usize,
p_mw: f64,
},
SectionIsolation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelfHealingResult {
pub fault_located: bool,
pub fault_isolation_time_s: f64,
pub restoration_time_s: f64,
pub actions: Vec<HealingAction>,
pub customers_restored: usize,
pub customers_unrestored: usize,
pub load_restored_mw: f64,
pub load_unrestored_mw: f64,
pub priority_loads_restored: bool,
pub automatic_execution: bool,
}
#[derive(Debug, Clone)]
pub struct SelfHealingController {
config: SelfHealingConfig,
feeders: Vec<Vec<usize>>,
switches: Vec<(usize, bool)>,
der_resources: Vec<(usize, f64)>,
load_mw: Vec<f64>,
customers: Vec<usize>,
}
impl SelfHealingController {
pub fn new(config: SelfHealingConfig) -> Self {
Self {
config,
feeders: Vec::new(),
switches: Vec::new(),
der_resources: Vec::new(),
load_mw: Vec::new(),
customers: Vec::new(),
}
}
pub fn add_feeder(&mut self, bus_sequence: Vec<usize>) {
self.feeders.push(bus_sequence);
}
pub fn add_switch(&mut self, branch_idx: usize, normally_open: bool) {
self.switches.push((branch_idx, normally_open));
}
pub fn add_der(&mut self, bus: usize, available_mw: f64) {
self.der_resources.push((bus, available_mw));
}
pub fn set_loads(&mut self, load_mw: Vec<f64>, customers: Vec<usize>) {
self.load_mw = load_mw;
self.customers = customers;
}
pub fn respond_to_fault(&self, event: &FaultEvent) -> Result<SelfHealingResult, HealingError> {
if self.feeders.is_empty() {
return Err(HealingError::NoFeeders);
}
let (feeder_idx, upstream_buses, downstream_buses) = self.locate_fault(&event.location)?;
let mut actions: Vec<HealingAction> = Vec::new();
let mut t = event.timestamp_s;
t += 2.0; actions.push(HealingAction {
action_type: HealingActionType::SectionIsolation,
device_id: event.location.segment,
timestamp_s: t,
expected_customers_restored: 0,
expected_load_mw: 0.0,
});
let fault_isolation_time_s = t - event.timestamp_s;
t += 1.0;
actions.push(HealingAction {
action_type: HealingActionType::OpenSwitch,
device_id: event.location.bus_upstream,
timestamp_s: t,
expected_customers_restored: 0,
expected_load_mw: 0.0,
});
t += 0.5;
actions.push(HealingAction {
action_type: HealingActionType::OpenSwitch,
device_id: event.location.bus_downstream,
timestamp_s: t,
expected_customers_restored: 0,
expected_load_mw: 0.0,
});
let upstream_load: f64 = self.sum_load(&upstream_buses);
let upstream_customers: usize = self.sum_customers(&upstream_buses);
t += 1.0;
actions.push(HealingAction {
action_type: HealingActionType::CloseSwitch,
device_id: event.location.bus_upstream,
timestamp_s: t,
expected_customers_restored: upstream_customers,
expected_load_mw: upstream_load,
});
let downstream_load: f64 = self.sum_load(&downstream_buses);
let downstream_customers: usize = self.sum_customers(&downstream_buses);
let tie_switch = self.find_tie_switch(feeder_idx);
let (downstream_restored_customers, downstream_restored_load) =
if let Some((sw_branch, alt_feeder)) = tie_switch {
if downstream_load <= self.config.max_load_transfer_mw {
t += 3.0;
actions.push(HealingAction {
action_type: HealingActionType::LoadTransfer {
from_feeder: feeder_idx,
to_feeder: alt_feeder,
},
device_id: sw_branch,
timestamp_s: t,
expected_customers_restored: downstream_customers,
expected_load_mw: downstream_load,
});
t += 1.0;
actions.push(HealingAction {
action_type: HealingActionType::CloseSwitch,
device_id: sw_branch,
timestamp_s: t,
expected_customers_restored: downstream_customers,
expected_load_mw: downstream_load,
});
(downstream_customers, downstream_load)
} else {
(0, 0.0)
}
} else if let Some((der_bus, der_mw)) = self.find_der_for_downstream(&downstream_buses)
{
let der_load = downstream_load.min(der_mw);
let der_customers = if der_load >= downstream_load {
downstream_customers
} else {
(downstream_customers as f64 * der_load / downstream_load.max(1e-9)) as usize
};
t += 5.0;
actions.push(HealingAction {
action_type: HealingActionType::DerActivation {
der_bus,
p_mw: der_load,
},
device_id: der_bus,
timestamp_s: t,
expected_customers_restored: der_customers,
expected_load_mw: der_load,
});
(der_customers, der_load)
} else {
(0, 0.0)
};
let restoration_time_s = t - event.timestamp_s;
let customers_restored = upstream_customers + downstream_restored_customers;
let total_customers = upstream_customers + downstream_customers;
let customers_unrestored = total_customers.saturating_sub(customers_restored);
let load_restored_mw = upstream_load + downstream_restored_load;
let total_load = upstream_load + downstream_load;
let load_unrestored_mw = (total_load - load_restored_mw).max(0.0);
let priority_loads_restored = self.check_priority_loads_restored(&upstream_buses)
|| self.check_priority_loads_restored(&if downstream_restored_customers > 0 {
downstream_buses.clone()
} else {
Vec::new()
});
let automatic_execution = self.config.automation_level == AutomationLevel::Automatic;
Ok(SelfHealingResult {
fault_located: true,
fault_isolation_time_s,
restoration_time_s,
actions,
customers_restored,
customers_unrestored,
load_restored_mw,
load_unrestored_mw,
priority_loads_restored,
automatic_execution,
})
}
fn locate_fault(
&self,
loc: &FaultLocation,
) -> Result<(usize, Vec<usize>, Vec<usize>), HealingError> {
for (fi, feeder) in self.feeders.iter().enumerate() {
let up_pos = feeder.iter().position(|&b| b == loc.bus_upstream);
let down_pos = feeder.iter().position(|&b| b == loc.bus_downstream);
if let (Some(up_i), Some(down_i)) = (up_pos, down_pos) {
if up_i < down_i {
let upstream: Vec<usize> = feeder[..=up_i].to_vec();
let downstream: Vec<usize> = feeder[down_i..].to_vec();
return Ok((fi, upstream, downstream));
}
}
}
Err(HealingError::InvalidFaultBuses(
loc.bus_upstream,
loc.bus_downstream,
))
}
fn sum_load(&self, buses: &[usize]) -> f64 {
if self.load_mw.is_empty() {
return 0.0;
}
buses.iter().filter_map(|&b| self.load_mw.get(b)).sum()
}
fn sum_customers(&self, buses: &[usize]) -> usize {
if self.customers.is_empty() {
return 0;
}
buses.iter().filter_map(|&b| self.customers.get(b)).sum()
}
fn find_tie_switch(&self, feeder_idx: usize) -> Option<(usize, usize)> {
if self.feeders.len() < 2 {
return None;
}
for &(branch_idx, normally_open) in &self.switches {
if normally_open {
let alt = (0..self.feeders.len())
.find(|&f| f != feeder_idx)
.unwrap_or(0);
return Some((branch_idx, alt));
}
}
None
}
fn find_der_for_downstream(&self, downstream_buses: &[usize]) -> Option<(usize, f64)> {
for &(der_bus, der_mw) in &self.der_resources {
if downstream_buses.contains(&der_bus) {
return Some((der_bus, der_mw));
}
}
self.der_resources.first().copied()
}
fn check_priority_loads_restored(&self, restored_buses: &[usize]) -> bool {
if self.config.priority_loads.is_empty() {
return true;
}
self.config
.priority_loads
.iter()
.all(|pl| restored_buses.contains(pl))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_ctrl() -> SelfHealingController {
let cfg = SelfHealingConfig {
n_buses: 8,
restoration_time_target_s: 60.0,
max_load_transfer_mw: 10.0,
priority_loads: vec![1, 2],
automation_level: AutomationLevel::Automatic,
};
let mut ctrl = SelfHealingController::new(cfg);
ctrl.add_feeder(vec![0, 1, 2, 3, 4]);
ctrl.add_feeder(vec![5, 6, 7]);
ctrl.add_switch(2, false); ctrl.add_switch(10, true); ctrl.add_der(4, 3.0);
ctrl.set_loads(
vec![0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0],
vec![0, 100, 100, 100, 100, 0, 100, 100],
);
ctrl
}
fn fault_between(up: usize, down: usize) -> FaultEvent {
FaultEvent {
fault_type: FaultType::PermanentPhaseToGround,
location: FaultLocation {
segment: 0,
bus_upstream: up,
bus_downstream: down,
distance_pct: 50.0,
},
severity: 0.5,
timestamp_s: 0.0,
}
}
#[test]
fn test_fault_isolation() {
let ctrl = build_ctrl();
let event = fault_between(2, 3);
let result = ctrl.respond_to_fault(&event).expect("should succeed");
assert!(result.fault_located, "Fault must be located");
assert!(
result.fault_isolation_time_s > 0.0,
"Isolation must take nonzero time"
);
let isolations = result
.actions
.iter()
.filter(|a| matches!(a.action_type, HealingActionType::SectionIsolation))
.count();
assert!(
isolations >= 1,
"At least one section isolation action required"
);
let opens = result
.actions
.iter()
.filter(|a| matches!(a.action_type, HealingActionType::OpenSwitch))
.count();
assert!(opens >= 2, "Two boundary switches must be opened");
}
#[test]
fn test_upstream_restoration() {
let ctrl = build_ctrl();
let event = fault_between(2, 3);
let result = ctrl.respond_to_fault(&event).expect("should succeed");
assert!(
result.load_restored_mw >= 2.0,
"Upstream load (2 MW) must be restored"
);
assert!(
result.customers_restored >= 200,
"At least upstream customers restored"
);
}
#[test]
fn test_downstream_via_tie_switch() {
let ctrl = build_ctrl();
let event = fault_between(2, 3);
let result = ctrl.respond_to_fault(&event).expect("should succeed");
let has_load_transfer = result
.actions
.iter()
.any(|a| matches!(a.action_type, HealingActionType::LoadTransfer { .. }));
assert!(has_load_transfer, "Load transfer via tie switch must occur");
assert_eq!(
result.customers_unrestored, 0,
"All customers should be restored"
);
}
#[test]
fn test_der_activation() {
let cfg = SelfHealingConfig {
n_buses: 5,
restoration_time_target_s: 60.0,
max_load_transfer_mw: 5.0,
priority_loads: vec![],
automation_level: AutomationLevel::Automatic,
};
let mut ctrl = SelfHealingController::new(cfg);
ctrl.add_feeder(vec![0, 1, 2, 3, 4]);
ctrl.add_switch(2, false);
ctrl.add_der(4, 3.0); ctrl.set_loads(vec![0.0, 1.0, 1.0, 1.0, 1.0], vec![0, 100, 100, 100, 100]);
let event = fault_between(2, 3);
let result = ctrl.respond_to_fault(&event).expect("should succeed");
let has_der = result
.actions
.iter()
.any(|a| matches!(a.action_type, HealingActionType::DerActivation { .. }));
assert!(
has_der,
"DER activation must be used when no tie switch available"
);
}
#[test]
fn test_priority_loads_restored() {
let ctrl = build_ctrl();
let event = fault_between(3, 4);
let result = ctrl.respond_to_fault(&event).expect("should succeed");
assert!(
result.priority_loads_restored,
"Priority loads on upstream side must be restored"
);
}
#[test]
fn test_manual_automation_level() {
let cfg = SelfHealingConfig {
n_buses: 5,
restoration_time_target_s: 60.0,
max_load_transfer_mw: 5.0,
priority_loads: vec![],
automation_level: AutomationLevel::Manual,
};
let mut ctrl = SelfHealingController::new(cfg);
ctrl.add_feeder(vec![0, 1, 2, 3, 4]);
ctrl.add_switch(2, false);
ctrl.set_loads(vec![0.0, 1.0, 1.0, 1.0, 1.0], vec![0, 100, 100, 100, 100]);
let event = fault_between(2, 3);
let result = ctrl.respond_to_fault(&event).expect("should succeed");
assert!(
!result.automatic_execution,
"Manual mode must not auto-execute"
);
assert!(!result.actions.is_empty(), "Actions must still be planned");
}
#[test]
fn test_invalid_fault_location() {
let ctrl = build_ctrl();
let event = FaultEvent {
fault_type: FaultType::PermanentPhaseToGround,
location: FaultLocation {
segment: 99,
bus_upstream: 99,
bus_downstream: 100,
distance_pct: 50.0,
},
severity: 0.5,
timestamp_s: 0.0,
};
let result = ctrl.respond_to_fault(&event);
assert!(result.is_err(), "Invalid fault buses must return error");
}
}