use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutorecloserConfig {
pub n_shots: usize,
pub dead_times_s: Vec<f64>,
pub reclaim_time_s: f64,
pub first_shot_fast: bool,
pub sync_check_enabled: bool,
pub sync_check_dv_pu: f64,
pub min_line_voltage_pu: f64,
pub holdoff_time_s: f64,
}
impl AutorecloserConfig {
pub fn distribution_3shot() -> Self {
Self {
n_shots: 3,
dead_times_s: vec![0.3, 15.0, 30.0],
reclaim_time_s: 120.0,
first_shot_fast: true,
sync_check_enabled: false,
sync_check_dv_pu: 0.15,
min_line_voltage_pu: 0.05,
holdoff_time_s: 0.0,
}
}
pub fn transmission_1shot_sync() -> Self {
Self {
n_shots: 1,
dead_times_s: vec![0.5],
reclaim_time_s: 300.0,
first_shot_fast: true,
sync_check_enabled: true,
sync_check_dv_pu: 0.10,
min_line_voltage_pu: 0.05,
holdoff_time_s: 0.05,
}
}
pub fn cable_no_reclose() -> Self {
Self {
n_shots: 0,
dead_times_s: vec![],
reclaim_time_s: 600.0,
first_shot_fast: false,
sync_check_enabled: false,
sync_check_dv_pu: 0.15,
min_line_voltage_pu: 0.05,
holdoff_time_s: 0.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RecloserPhase {
Normal,
WaitingDeadTime { shot: usize, elapsed_s: f64 },
ReclosingAttempt { shot: usize },
Reclaiming { shots_used: usize, elapsed_s: f64 },
Lockout { n_shots: usize },
Inhibited,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutorecloserState {
pub phase: RecloserPhase,
pub breaker_closed: bool,
pub total_shots_fired: usize,
pub time_in_state_s: f64,
}
impl AutorecloserState {
pub fn new() -> Self {
Self {
phase: RecloserPhase::Normal,
breaker_closed: true,
total_shots_fired: 0,
time_in_state_s: 0.0,
}
}
pub fn is_locked_out(&self) -> bool {
matches!(self.phase, RecloserPhase::Lockout { .. })
}
pub fn is_normal(&self) -> bool {
matches!(self.phase, RecloserPhase::Normal)
}
}
impl Default for AutorecloserState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RecloserEvent {
FaultTrip,
FaultCleared,
FaultPersisted,
InhibitSignal,
InhibitReleased,
ManualReset,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RecloserCommand {
OpenBreaker,
CloseBreaker,
LockOut,
Alarm(String),
None,
}
pub fn process_event(
state: &mut AutorecloserState,
config: &AutorecloserConfig,
event: RecloserEvent,
) -> RecloserCommand {
match (&state.phase, event) {
(RecloserPhase::Normal, RecloserEvent::FaultTrip) => {
if config.n_shots == 0 {
state.phase = RecloserPhase::Lockout { n_shots: 0 };
state.breaker_closed = false;
state.total_shots_fired += 1;
return RecloserCommand::LockOut;
}
state.phase = RecloserPhase::WaitingDeadTime {
shot: 1,
elapsed_s: 0.0,
};
state.breaker_closed = false;
state.total_shots_fired += 1;
RecloserCommand::OpenBreaker
}
(RecloserPhase::WaitingDeadTime { shot, .. }, RecloserEvent::FaultTrip) => {
let shot = *shot;
if shot >= config.n_shots {
state.phase = RecloserPhase::Lockout {
n_shots: state.total_shots_fired,
};
RecloserCommand::LockOut
} else {
state.phase = RecloserPhase::WaitingDeadTime {
shot: shot + 1,
elapsed_s: 0.0,
};
state.total_shots_fired += 1;
RecloserCommand::OpenBreaker
}
}
(RecloserPhase::ReclosingAttempt { shot }, RecloserEvent::FaultPersisted) => {
let shot = *shot;
state.breaker_closed = false;
if shot >= config.n_shots {
state.phase = RecloserPhase::Lockout {
n_shots: state.total_shots_fired,
};
RecloserCommand::LockOut
} else {
state.total_shots_fired += 1;
state.phase = RecloserPhase::WaitingDeadTime {
shot: shot + 1,
elapsed_s: 0.0,
};
RecloserCommand::OpenBreaker
}
}
(RecloserPhase::ReclosingAttempt { shot }, RecloserEvent::FaultCleared) => {
let shot = *shot;
state.breaker_closed = true;
state.phase = RecloserPhase::Reclaiming {
shots_used: shot,
elapsed_s: 0.0,
};
RecloserCommand::None
}
(RecloserPhase::Reclaiming { .. }, RecloserEvent::FaultTrip) => {
let shot = state.total_shots_fired;
if shot >= config.n_shots {
state.phase = RecloserPhase::Lockout { n_shots: shot };
state.breaker_closed = false;
RecloserCommand::LockOut
} else {
state.total_shots_fired += 1;
state.phase = RecloserPhase::WaitingDeadTime {
shot: shot + 1,
elapsed_s: 0.0,
};
state.breaker_closed = false;
RecloserCommand::OpenBreaker
}
}
(_, RecloserEvent::InhibitSignal) => {
state.phase = RecloserPhase::Inhibited;
RecloserCommand::Alarm("Recloser inhibited".to_string())
}
(RecloserPhase::Inhibited, RecloserEvent::InhibitReleased) => {
state.phase = RecloserPhase::Normal;
RecloserCommand::None
}
(_, RecloserEvent::ManualReset) => {
state.phase = RecloserPhase::Normal;
state.breaker_closed = false; RecloserCommand::Alarm("Lockout reset — manual close required".to_string())
}
_ => RecloserCommand::None,
}
}
pub fn tick(
state: &mut AutorecloserState,
config: &AutorecloserConfig,
dt_s: f64,
) -> RecloserCommand {
state.time_in_state_s += dt_s;
match &mut state.phase {
RecloserPhase::WaitingDeadTime { shot, elapsed_s } => {
let shot_idx = shot
.saturating_sub(1)
.min(config.dead_times_s.len().saturating_sub(1));
let dead_time = config.dead_times_s.get(shot_idx).copied().unwrap_or(30.0);
*elapsed_s += dt_s;
if *elapsed_s >= dead_time + config.holdoff_time_s {
let shot_val = *shot;
state.phase = RecloserPhase::ReclosingAttempt { shot: shot_val };
state.breaker_closed = true;
return RecloserCommand::CloseBreaker;
}
}
RecloserPhase::Reclaiming { elapsed_s, .. } => {
*elapsed_s += dt_s;
if *elapsed_s >= config.reclaim_time_s {
state.phase = RecloserPhase::Normal;
}
}
_ => {}
}
RecloserCommand::None
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FaultClassification {
Transient,
SemiPermanent { shot: usize },
Permanent,
InProgress,
}
pub fn classify_fault(state: &AutorecloserState) -> FaultClassification {
match &state.phase {
RecloserPhase::Lockout { .. } => FaultClassification::Permanent,
RecloserPhase::Reclaiming { shots_used, .. } => {
if *shots_used == 1 {
FaultClassification::Transient
} else {
FaultClassification::SemiPermanent { shot: *shots_used }
}
}
RecloserPhase::Normal => FaultClassification::Transient,
_ => FaultClassification::InProgress,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecloserReliabilityStats {
pub total_faults: usize,
pub transient_faults: usize,
pub semi_permanent_faults: usize,
pub permanent_faults: usize,
pub transient_rate: f64,
pub permanent_rate: f64,
pub avg_shots_per_fault: f64,
}
impl RecloserReliabilityStats {
pub fn from_classifications(events: &[(FaultClassification, usize)]) -> Self {
let total = events.len();
let transient = events
.iter()
.filter(|(c, _)| *c == FaultClassification::Transient)
.count();
let semi = events
.iter()
.filter(|(c, _)| matches!(c, FaultClassification::SemiPermanent { .. }))
.count();
let permanent = events
.iter()
.filter(|(c, _)| *c == FaultClassification::Permanent)
.count();
let total_shots: usize = events.iter().map(|(_, s)| s).sum();
Self {
total_faults: total,
transient_faults: transient,
semi_permanent_faults: semi,
permanent_faults: permanent,
transient_rate: if total > 0 {
transient as f64 / total as f64
} else {
0.0
},
permanent_rate: if total > 0 {
permanent as f64 / total as f64
} else {
0.0
},
avg_shots_per_fault: if total > 0 {
total_shots as f64 / total as f64
} else {
0.0
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run_sequence(
config: &AutorecloserConfig,
fault_clears_on_shot: Option<usize>,
) -> AutorecloserState {
let mut state = AutorecloserState::new();
process_event(&mut state, config, RecloserEvent::FaultTrip);
assert!(!state.breaker_closed);
for shot in 1..=config.n_shots {
let dt = 0.001;
let dead = config.dead_times_s.get(shot - 1).copied().unwrap_or(30.0) + 0.01;
let steps = (dead / dt).ceil() as usize;
for _ in 0..steps {
tick(&mut state, config, dt);
}
if matches!(state.phase, RecloserPhase::ReclosingAttempt { .. }) {
if fault_clears_on_shot == Some(shot) {
process_event(&mut state, config, RecloserEvent::FaultCleared);
break;
} else {
process_event(&mut state, config, RecloserEvent::FaultPersisted);
}
}
}
state
}
#[test]
fn test_transient_fault_clears_on_shot1() {
let config = AutorecloserConfig::distribution_3shot();
let state = run_sequence(&config, Some(1));
assert!(
state.breaker_closed,
"Breaker should be closed after successful reclose"
);
assert!(matches!(state.phase, RecloserPhase::Reclaiming { .. }));
}
#[test]
fn test_permanent_fault_lockout() {
let config = AutorecloserConfig::distribution_3shot();
let state = run_sequence(&config, None);
assert!(
state.is_locked_out(),
"All shots exhausted → lockout: {:?}",
state.phase
);
assert!(!state.breaker_closed);
}
#[test]
fn test_no_reclose_configured_immediate_lockout() {
let config = AutorecloserConfig::cable_no_reclose();
let mut state = AutorecloserState::new();
let cmd = process_event(&mut state, &config, RecloserEvent::FaultTrip);
assert_eq!(cmd, RecloserCommand::LockOut);
assert!(state.is_locked_out());
}
#[test]
fn test_manual_reset_after_lockout() {
let config = AutorecloserConfig::distribution_3shot();
let mut state = AutorecloserState::new();
state.phase = RecloserPhase::Lockout { n_shots: 3 };
process_event(&mut state, &config, RecloserEvent::ManualReset);
assert!(state.is_normal());
}
#[test]
fn test_inhibit_signal() {
let config = AutorecloserConfig::distribution_3shot();
let mut state = AutorecloserState::new();
let cmd = process_event(&mut state, &config, RecloserEvent::InhibitSignal);
assert!(matches!(cmd, RecloserCommand::Alarm(_)));
assert_eq!(state.phase, RecloserPhase::Inhibited);
process_event(&mut state, &config, RecloserEvent::InhibitReleased);
assert!(state.is_normal());
}
#[test]
fn test_dead_time_triggers_reclose() {
let config = AutorecloserConfig::distribution_3shot();
let mut state = AutorecloserState::new();
process_event(&mut state, &config, RecloserEvent::FaultTrip);
let mut issued_close = false;
for _ in 0..400 {
if tick(&mut state, &config, 0.001) == RecloserCommand::CloseBreaker {
issued_close = true;
}
}
assert!(
issued_close || matches!(state.phase, RecloserPhase::ReclosingAttempt { .. }),
"Should have issued close or entered ReclosingAttempt: {:?}",
state.phase
);
}
#[test]
fn test_reclaim_time_returns_to_normal() {
let mut config = AutorecloserConfig::distribution_3shot();
config.reclaim_time_s = 0.1; let mut state = AutorecloserState::new();
state.phase = RecloserPhase::Reclaiming {
shots_used: 1,
elapsed_s: 0.0,
};
for _ in 0..200 {
tick(&mut state, &config, 0.001);
}
assert!(
state.is_normal(),
"Should return to normal after reclaim: {:?}",
state.phase
);
}
#[test]
fn test_shots_fired_tracked() {
let config = AutorecloserConfig::distribution_3shot();
let state = run_sequence(&config, None);
assert_eq!(state.total_shots_fired, 3, "Should have fired 3 shots");
}
#[test]
fn test_fault_classification_transient() {
let mut state = AutorecloserState::new();
state.phase = RecloserPhase::Reclaiming {
shots_used: 1,
elapsed_s: 0.0,
};
assert_eq!(classify_fault(&state), FaultClassification::Transient);
}
#[test]
fn test_fault_classification_permanent() {
let mut state = AutorecloserState::new();
state.phase = RecloserPhase::Lockout { n_shots: 3 };
assert_eq!(classify_fault(&state), FaultClassification::Permanent);
}
#[test]
fn test_reliability_stats() {
let events = vec![
(FaultClassification::Transient, 1),
(FaultClassification::Transient, 1),
(FaultClassification::SemiPermanent { shot: 2 }, 2),
(FaultClassification::Permanent, 3),
];
let stats = RecloserReliabilityStats::from_classifications(&events);
assert_eq!(stats.total_faults, 4);
assert_eq!(stats.transient_faults, 2);
assert_eq!(stats.permanent_faults, 1);
assert!((stats.transient_rate - 0.5).abs() < 1e-9);
assert!((stats.avg_shots_per_fault - 1.75).abs() < 1e-9);
}
#[test]
fn test_transmission_config_1shot() {
let config = AutorecloserConfig::transmission_1shot_sync();
assert_eq!(config.n_shots, 1);
assert!(config.sync_check_enabled);
}
}