use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum SwitchAction {
Open,
Close,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwitchDevice {
pub id: usize,
pub branch_idx: usize,
pub from_bus: usize,
pub to_bus: usize,
pub is_normally_open: bool,
pub can_operate: bool,
pub operation_time_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaultIndicator {
pub id: usize,
pub bus_idx: usize,
pub current_threshold_a: f64,
pub tripped: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwitchOperation {
pub switch_id: usize,
pub action: SwitchAction,
pub time_s: f64,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlisrResult {
pub fault_location: Option<usize>,
pub isolated_section: Vec<usize>,
pub restoration_steps: Vec<SwitchOperation>,
pub restored_load_mw: f64,
pub unrestored_load_mw: f64,
pub total_operation_time_s: f64,
pub customers_restored: usize,
}
#[derive(Debug, Clone)]
pub struct FlisrController {
pub switches: Vec<SwitchDevice>,
pub fault_indicators: Vec<FaultIndicator>,
pub bus_loads_mw: Vec<f64>,
pub feeder_capacity_mw: f64,
pub bus_customers: Vec<usize>,
pub n_buses: usize,
pub adjacency: Vec<Vec<usize>>,
pub substation_buses: Vec<usize>,
}
impl FlisrController {
pub fn execute(&self) -> Result<FlisrResult> {
let fault_branch = self.locate_fault();
let mut steps: Vec<SwitchOperation> = Vec::new();
let mut current_time = 0.0_f64;
let isolated_section = if let Some(fbranch) = fault_branch {
let (section, isolation_ops, iso_time) = self.isolate_fault(fbranch, current_time)?;
current_time = iso_time;
steps.extend(isolation_ops);
section
} else {
Vec::new()
};
let opened_branches: HashSet<usize> = fault_branch.into_iter().collect();
let isolated_set: HashSet<usize> = isolated_section.iter().cloned().collect();
let (restore_ops, restored_mw, unrestored_mw, customers_restored, final_time) =
self.restore_service(&isolated_set, &opened_branches, current_time)?;
steps.extend(restore_ops);
Ok(FlisrResult {
fault_location: fault_branch,
isolated_section,
restoration_steps: steps.clone(),
restored_load_mw: restored_mw,
unrestored_load_mw: unrestored_mw,
total_operation_time_s: final_time,
customers_restored,
})
}
fn locate_fault(&self) -> Option<usize> {
let mut bus_tripped: HashMap<usize, bool> = HashMap::new();
for fi in &self.fault_indicators {
bus_tripped.insert(fi.bus_idx, fi.tripped);
}
for sw in &self.switches {
if sw.is_normally_open {
continue; }
let from_tripped = bus_tripped.get(&sw.from_bus).copied().unwrap_or(false);
let to_tripped = bus_tripped.get(&sw.to_bus).copied().unwrap_or(false);
if from_tripped && !to_tripped {
return Some(sw.branch_idx);
}
}
let mut last_tripped_branch: Option<usize> = None;
for sw in &self.switches {
if sw.is_normally_open {
continue;
}
let from_tripped = bus_tripped.get(&sw.from_bus).copied().unwrap_or(false);
if from_tripped {
last_tripped_branch = Some(sw.branch_idx);
}
}
last_tripped_branch
}
fn isolate_fault(
&self,
fault_branch: usize,
start_time: f64,
) -> Result<(Vec<usize>, Vec<SwitchOperation>, f64)> {
let bounding_switches: Vec<&SwitchDevice> = self
.switches
.iter()
.filter(|sw| !sw.is_normally_open && sw.can_operate && sw.branch_idx == fault_branch)
.collect();
if bounding_switches.is_empty() {
let nearest: Vec<&SwitchDevice> = self
.switches
.iter()
.filter(|sw| !sw.is_normally_open && sw.can_operate)
.take(2)
.collect();
if nearest.is_empty() {
return Err(OxiGridError::InvalidNetwork(
"No operable sectionalizing switches found for fault isolation".into(),
));
}
let mut ops = Vec::new();
let mut t = start_time;
for sw in &nearest {
t += sw.operation_time_s;
ops.push(SwitchOperation {
switch_id: sw.id,
action: SwitchAction::Open,
time_s: t,
reason: format!("Isolate fault on branch {} (nearest switch)", fault_branch),
});
}
let isolated: Vec<usize> = nearest
.iter()
.flat_map(|sw| [sw.from_bus, sw.to_bus])
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
return Ok((isolated, ops, t));
}
let mut ops = Vec::new();
let mut t = start_time;
for sw in &bounding_switches {
t += sw.operation_time_s;
ops.push(SwitchOperation {
switch_id: sw.id,
action: SwitchAction::Open,
time_s: t,
reason: format!("Isolate fault on branch {}", fault_branch),
});
}
let isolated: Vec<usize> = bounding_switches
.iter()
.flat_map(|sw| [sw.from_bus, sw.to_bus])
.collect::<HashSet<_>>()
.into_iter()
.collect();
Ok((isolated, ops, t))
}
fn restore_service(
&self,
isolated_set: &HashSet<usize>,
opened_branches: &HashSet<usize>,
start_time: f64,
) -> Result<(Vec<SwitchOperation>, f64, f64, usize, f64)> {
let sub_set: HashSet<usize> = self.substation_buses.iter().cloned().collect();
let de_energised = self.find_de_energised_buses(isolated_set, opened_branches, &sub_set);
if de_energised.is_empty() {
return Ok((Vec::new(), 0.0, 0.0, 0, start_time));
}
let mut ops: Vec<SwitchOperation> = Vec::new();
let mut t = start_time;
let mut restored_mw = 0.0_f64;
let mut customers_restored = 0usize;
let mut restored_buses: HashSet<usize> = sub_set.clone();
let tie_switches: Vec<&SwitchDevice> = self
.switches
.iter()
.filter(|sw| sw.is_normally_open && sw.can_operate)
.collect();
for tie_sw in &tie_switches {
let reachable_de_energised = self.find_reachable_de_energised(
tie_sw,
&de_energised,
isolated_set,
&restored_buses,
);
if reachable_de_energised.is_empty() {
continue;
}
let section_load: f64 = reachable_de_energised
.iter()
.filter_map(|&b| self.bus_loads_mw.get(b))
.sum();
let existing_load: f64 = restored_buses
.iter()
.filter(|&&b| !sub_set.contains(&b))
.filter_map(|&b| self.bus_loads_mw.get(b))
.sum();
if existing_load + section_load > self.feeder_capacity_mw {
continue;
}
t += tie_sw.operation_time_s;
ops.push(SwitchOperation {
switch_id: tie_sw.id,
action: SwitchAction::Close,
time_s: t,
reason: format!(
"Restore {} buses ({:.2} MW) via tie switch",
reachable_de_energised.len(),
section_load
),
});
for &b in &reachable_de_energised {
restored_buses.insert(b);
restored_mw += self.bus_loads_mw.get(b).copied().unwrap_or(0.0);
customers_restored += self.bus_customers.get(b).copied().unwrap_or(0);
}
}
let unrestored_mw: f64 = de_energised
.iter()
.filter(|b| !restored_buses.contains(b))
.filter_map(|&b| self.bus_loads_mw.get(b))
.sum();
Ok((ops, restored_mw, unrestored_mw, customers_restored, t))
}
fn find_de_energised_buses(
&self,
isolated_set: &HashSet<usize>,
opened_branches: &HashSet<usize>,
sub_set: &HashSet<usize>,
) -> HashSet<usize> {
let mut adj: HashMap<usize, Vec<usize>> = HashMap::new();
for sw in &self.switches {
if sw.is_normally_open || opened_branches.contains(&sw.branch_idx) {
continue;
}
adj.entry(sw.from_bus).or_default().push(sw.to_bus);
adj.entry(sw.to_bus).or_default().push(sw.from_bus);
}
let mut energised: HashSet<usize> = sub_set.clone();
let mut queue: VecDeque<usize> = sub_set.iter().cloned().collect();
while let Some(bus) = queue.pop_front() {
if isolated_set.contains(&bus) {
continue; }
if let Some(neighbors) = adj.get(&bus) {
for &nb in neighbors {
if !energised.contains(&nb) && !isolated_set.contains(&nb) {
energised.insert(nb);
queue.push_back(nb);
}
}
}
}
(0..self.n_buses)
.filter(|b| !energised.contains(b) && !isolated_set.contains(b) && !sub_set.contains(b))
.collect()
}
fn find_reachable_de_energised(
&self,
tie_sw: &SwitchDevice,
de_energised: &HashSet<usize>,
isolated_set: &HashSet<usize>,
restored_buses: &HashSet<usize>,
) -> Vec<usize> {
let seed = if de_energised.contains(&tie_sw.to_bus) {
tie_sw.to_bus
} else if de_energised.contains(&tie_sw.from_bus) {
tie_sw.from_bus
} else {
return Vec::new();
};
let mut adj: HashMap<usize, Vec<usize>> = HashMap::new();
for sw in &self.switches {
if sw.is_normally_open {
continue;
}
adj.entry(sw.from_bus).or_default().push(sw.to_bus);
adj.entry(sw.to_bus).or_default().push(sw.from_bus);
}
let mut reachable = Vec::new();
let mut visited: HashSet<usize> = HashSet::new();
visited.insert(seed);
let mut queue: VecDeque<usize> = VecDeque::new();
queue.push_back(seed);
while let Some(bus) = queue.pop_front() {
if de_energised.contains(&bus) && !isolated_set.contains(&bus) {
reachable.push(bus);
}
if let Some(neighbors) = adj.get(&bus) {
for &nb in neighbors {
if !visited.contains(&nb)
&& !isolated_set.contains(&nb)
&& !restored_buses.contains(&nb)
{
visited.insert(nb);
queue.push_back(nb);
}
}
}
}
reachable
}
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_feeder() -> FlisrController {
let n_buses = 6;
let bus_loads_mw = vec![0.0, 1.0, 2.0, 1.5, 1.0, 0.0];
let bus_customers = vec![0, 100, 200, 150, 100, 0];
let adjacency = vec![
vec![1], vec![0, 2], vec![1, 3], vec![2, 4], vec![3], vec![], ];
let switches = vec![
SwitchDevice {
id: 10,
branch_idx: 0,
from_bus: 0,
to_bus: 1,
is_normally_open: false,
can_operate: true,
operation_time_s: 0.5,
},
SwitchDevice {
id: 11,
branch_idx: 1,
from_bus: 1,
to_bus: 2,
is_normally_open: false,
can_operate: true,
operation_time_s: 0.5,
},
SwitchDevice {
id: 12,
branch_idx: 2,
from_bus: 2,
to_bus: 3,
is_normally_open: false,
can_operate: true,
operation_time_s: 0.5,
},
SwitchDevice {
id: 13,
branch_idx: 3,
from_bus: 3,
to_bus: 4,
is_normally_open: false,
can_operate: true,
operation_time_s: 0.5,
},
SwitchDevice {
id: 20,
branch_idx: 4,
from_bus: 4,
to_bus: 5,
is_normally_open: true,
can_operate: true,
operation_time_s: 0.5,
},
];
let fault_indicators = vec![
FaultIndicator {
id: 1,
bus_idx: 0,
current_threshold_a: 100.0,
tripped: true,
},
FaultIndicator {
id: 2,
bus_idx: 1,
current_threshold_a: 100.0,
tripped: true,
},
FaultIndicator {
id: 3,
bus_idx: 2,
current_threshold_a: 100.0,
tripped: true,
},
FaultIndicator {
id: 4,
bus_idx: 3,
current_threshold_a: 100.0,
tripped: false,
}, FaultIndicator {
id: 5,
bus_idx: 4,
current_threshold_a: 100.0,
tripped: false,
},
];
FlisrController {
switches,
fault_indicators,
bus_loads_mw,
feeder_capacity_mw: 10.0,
bus_customers,
n_buses,
adjacency,
substation_buses: vec![0, 5],
}
}
#[test]
fn test_fault_location_mid_feeder() {
let ctrl = simple_feeder();
let result = ctrl.execute().expect("flisr execute");
assert_eq!(
result.fault_location,
Some(2),
"Expected fault on branch 2, got {:?}",
result.fault_location
);
}
#[test]
fn test_isolation_step_generated() {
let ctrl = simple_feeder();
let result = ctrl.execute().expect("flisr execute");
let open_ops: Vec<_> = result
.restoration_steps
.iter()
.filter(|op| op.action == SwitchAction::Open)
.collect();
assert!(
!open_ops.is_empty(),
"Expected at least one Open operation for fault isolation"
);
}
#[test]
fn test_restoration_via_tie_switch() {
let ctrl = simple_feeder();
let result = ctrl.execute().expect("flisr execute");
let close_ops: Vec<_> = result
.restoration_steps
.iter()
.filter(|op| op.action == SwitchAction::Close)
.collect();
assert!(
!close_ops.is_empty(),
"Expected tie switch closure for restoration, restored_mw={:.2}",
result.restored_load_mw
);
assert!(
result.restored_load_mw > 0.0,
"Expected positive restored load"
);
}
#[test]
fn test_no_restoration_path_available() {
let mut ctrl = simple_feeder();
for sw in &mut ctrl.switches {
if sw.is_normally_open {
sw.can_operate = false;
}
}
let result = ctrl.execute().expect("flisr execute");
let close_ops: Vec<_> = result
.restoration_steps
.iter()
.filter(|op| op.action == SwitchAction::Close)
.collect();
assert!(
close_ops.is_empty(),
"Expected no restoration ops when tie switch inoperable"
);
assert_eq!(result.customers_restored, 0);
}
#[test]
fn test_capacity_limit_prevents_restoration() {
let mut ctrl = simple_feeder();
ctrl.feeder_capacity_mw = 0.001; let result = ctrl.execute().expect("flisr execute");
let close_ops: Vec<_> = result
.restoration_steps
.iter()
.filter(|op| op.action == SwitchAction::Close)
.collect();
assert!(
close_ops.is_empty(),
"Expected no restoration when capacity too low"
);
}
#[test]
fn test_operation_time_accumulates() {
let ctrl = simple_feeder();
let result = ctrl.execute().expect("flisr execute");
let last_time = result
.restoration_steps
.last()
.map(|op| op.time_s)
.unwrap_or(0.0);
assert!(
result.total_operation_time_s >= 0.0,
"Total operation time should be non-negative"
);
assert!(
(result.total_operation_time_s - last_time).abs() < 1e-9
|| result.total_operation_time_s >= last_time,
"Total operation time should be at least last step time"
);
}
}