use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SasError {
#[error("bay not found: id={0}")]
BayNotFound(usize),
#[error("synchronism check failed: close rejected")]
SyncCheckFailed,
#[error("breaker already in requested state")]
BreakerStateConflict,
#[error("invalid measurement: {0}")]
InvalidMeasurement(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubstationConfig {
pub name: String,
pub voltage_kv: f64,
pub n_bays: usize,
pub busbar_configuration: BusbarConfig,
pub ied_polling_interval_ms: f64,
pub scada_report_interval_s: f64,
}
impl Default for SubstationConfig {
fn default() -> Self {
Self {
name: "Unnamed Substation".into(),
voltage_kv: 110.0,
n_bays: 4,
busbar_configuration: BusbarConfig::SingleBus,
ied_polling_interval_ms: 100.0,
scada_report_interval_s: 10.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BusbarConfig {
SingleBus,
DoubleBus,
OneAndHalf,
RingBus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bay {
pub id: usize,
pub name: String,
pub bay_type: BayType,
pub breaker_status: BreakerStatus,
pub disconnector_status: Vec<bool>,
pub measured_current_a: f64,
pub measured_power_mw: f64,
pub measured_reactive_mvar: f64,
pub protection_status: ProtectionStatus,
pub rated_current_a: f64,
}
impl Bay {
pub fn new(id: usize, name: impl Into<String>, bay_type: BayType) -> Self {
Self {
id,
name: name.into(),
bay_type,
breaker_status: BreakerStatus::default(),
disconnector_status: vec![true, true],
measured_current_a: 0.0,
measured_power_mw: 0.0,
measured_reactive_mvar: 0.0,
protection_status: ProtectionStatus::Normal,
rated_current_a: 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BayType {
Transformer,
FeederLine,
Busbar,
Capacitor,
Reactor,
Generator,
MeasurementVt,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreakerStatus {
pub is_closed: bool,
pub trip_count: usize,
pub last_operation: Option<f64>,
pub contact_wear_pct: f64,
pub sf6_pressure_bar: f64,
}
impl Default for BreakerStatus {
fn default() -> Self {
Self {
is_closed: true,
trip_count: 0,
last_operation: None,
contact_wear_pct: 0.0,
sf6_pressure_bar: 6.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProtectionStatus {
Normal,
AlarmActive(String),
ProtectionTripped {
time_s: f64,
cause: String,
},
InTest,
OutOfService,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubstationEvent {
pub timestamp_s: f64,
pub event_type: EventType,
pub bay_id: Option<usize>,
pub description: String,
pub severity: EventSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventType {
BreakerOperation,
Protection,
Alarm,
Measurement,
Control,
Communication,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum EventSeverity {
Information,
Warning,
Major,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScadaReport {
pub timestamp_s: f64,
pub substation: String,
pub total_load_mw: f64,
pub total_reactive_mvar: f64,
pub busbar_voltage_pu: f64,
pub n_alarms: usize,
pub n_trips: usize,
pub bay_statuses: Vec<(usize, bool, f64)>,
}
pub struct SubstationAutomationSystem {
config: SubstationConfig,
bays: Vec<Bay>,
event_log: Vec<SubstationEvent>,
}
impl SubstationAutomationSystem {
pub fn new(config: SubstationConfig) -> Self {
Self {
config,
bays: Vec::new(),
event_log: Vec::new(),
}
}
pub fn add_bay(&mut self, bay: Bay) {
self.bays.push(bay);
}
fn find_bay_mut(&mut self, bay_id: usize) -> Result<&mut Bay, SasError> {
self.bays
.iter_mut()
.find(|b| b.id == bay_id)
.ok_or(SasError::BayNotFound(bay_id))
}
fn find_bay(&self, bay_id: usize) -> Result<&Bay, SasError> {
self.bays
.iter()
.find(|b| b.id == bay_id)
.ok_or(SasError::BayNotFound(bay_id))
}
pub fn process_ied_data(
&mut self,
bay_id: usize,
current_a: f64,
timestamp_s: f64,
) -> Result<Vec<SubstationEvent>, SasError> {
if current_a.is_nan() || current_a < 0.0 {
return Err(SasError::InvalidMeasurement(format!(
"current_a={current_a} is invalid"
)));
}
let rated = {
let bay = self.find_bay(bay_id)?;
bay.rated_current_a
};
{
let bay = self.find_bay_mut(bay_id)?;
bay.measured_current_a = current_a;
}
let mut new_events: Vec<SubstationEvent> = Vec::new();
if rated > 0.0 {
let ratio = current_a / rated;
if ratio >= 3.0 {
let trip_event = SubstationEvent {
timestamp_s,
event_type: EventType::Protection,
bay_id: Some(bay_id),
description: format!(
"Critical overcurrent {current_a:.1} A ({:.1}× rated) — auto-trip",
ratio
),
severity: EventSeverity::Critical,
};
new_events.push(trip_event.clone());
self.event_log.push(trip_event);
let bay = self.find_bay_mut(bay_id)?;
bay.breaker_status.is_closed = false;
bay.breaker_status.trip_count += 1;
bay.breaker_status.last_operation = Some(timestamp_s);
bay.protection_status = ProtectionStatus::ProtectionTripped {
time_s: timestamp_s,
cause: format!("overcurrent {current_a:.1} A"),
};
} else if ratio >= 2.0 {
let alarm_event = SubstationEvent {
timestamp_s,
event_type: EventType::Alarm,
bay_id: Some(bay_id),
description: format!(
"High overcurrent alarm {current_a:.1} A ({:.1}× rated)",
ratio
),
severity: EventSeverity::Warning,
};
new_events.push(alarm_event.clone());
self.event_log.push(alarm_event);
let bay = self.find_bay_mut(bay_id)?;
bay.protection_status =
ProtectionStatus::AlarmActive(format!("overcurrent {current_a:.1} A"));
}
}
let meas_event = SubstationEvent {
timestamp_s,
event_type: EventType::Measurement,
bay_id: Some(bay_id),
description: format!("IED update: current={current_a:.2} A"),
severity: EventSeverity::Information,
};
new_events.push(meas_event.clone());
self.event_log.push(meas_event);
Ok(new_events)
}
pub fn trip_breaker(
&mut self,
bay_id: usize,
cause: &str,
timestamp_s: f64,
) -> Result<(), SasError> {
let bay = self.find_bay_mut(bay_id)?;
bay.breaker_status.is_closed = false;
bay.breaker_status.trip_count += 1;
bay.breaker_status.last_operation = Some(timestamp_s);
bay.protection_status = ProtectionStatus::ProtectionTripped {
time_s: timestamp_s,
cause: cause.to_string(),
};
let event = SubstationEvent {
timestamp_s,
event_type: EventType::Protection,
bay_id: Some(bay_id),
description: format!("Breaker tripped: {cause}"),
severity: EventSeverity::Major,
};
self.event_log.push(event);
Ok(())
}
pub fn close_breaker(
&mut self,
bay_id: usize,
sync_ok: bool,
timestamp_s: f64,
) -> Result<(), SasError> {
if !sync_ok {
return Err(SasError::SyncCheckFailed);
}
let bay = self.find_bay_mut(bay_id)?;
bay.breaker_status.is_closed = true;
bay.breaker_status.last_operation = Some(timestamp_s);
bay.protection_status = ProtectionStatus::Normal;
let event = SubstationEvent {
timestamp_s,
event_type: EventType::BreakerOperation,
bay_id: Some(bay_id),
description: "Breaker closed (sync OK)".to_string(),
severity: EventSeverity::Information,
};
self.event_log.push(event);
Ok(())
}
pub fn generate_report(&self, timestamp_s: f64) -> ScadaReport {
let mut total_load_mw = 0.0_f64;
let mut total_reactive_mvar = 0.0_f64;
let mut n_alarms = 0usize;
let mut n_trips = 0usize;
let mut bay_statuses = Vec::with_capacity(self.bays.len());
for bay in &self.bays {
total_load_mw += bay.measured_power_mw;
total_reactive_mvar += bay.measured_reactive_mvar;
match &bay.protection_status {
ProtectionStatus::AlarmActive(_) => n_alarms += 1,
ProtectionStatus::ProtectionTripped { .. } => n_trips += 1,
_ => {}
}
bay_statuses.push((bay.id, bay.breaker_status.is_closed, bay.measured_power_mw));
}
ScadaReport {
timestamp_s,
substation: self.config.name.clone(),
total_load_mw,
total_reactive_mvar,
busbar_voltage_pu: self.estimate_busbar_voltage(),
n_alarms,
n_trips,
bay_statuses,
}
}
pub fn estimate_busbar_voltage(&self) -> f64 {
let vt_bays: Vec<&Bay> = self
.bays
.iter()
.filter(|b| b.bay_type == BayType::MeasurementVt)
.collect();
if !vt_bays.is_empty() {
let sum: f64 = vt_bays.iter().map(|b| b.measured_power_mw).sum();
return sum / vt_bays.len() as f64;
}
1.0
}
pub fn check_maintenance_needs(&self) -> Vec<(usize, String)> {
let mut needs = Vec::new();
for bay in &self.bays {
let bs = &bay.breaker_status;
if bs.contact_wear_pct > 80.0 {
needs.push((
bay.id,
format!(
"Contact wear {:.1}% exceeds 80% threshold",
bs.contact_wear_pct
),
));
}
if bs.sf6_pressure_bar < 4.5 {
needs.push((
bay.id,
format!(
"SF6 pressure {:.2} bar below 4.5 bar minimum",
bs.sf6_pressure_bar
),
));
}
if bs.trip_count > 50 {
needs.push((
bay.id,
format!(
"Trip count {} exceeds 50 — contact inspection due",
bs.trip_count
),
));
}
}
needs
}
pub fn event_log(&self) -> &[SubstationEvent] {
&self.event_log
}
pub fn bays(&self) -> &[Bay] {
&self.bays
}
pub fn config(&self) -> &SubstationConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_sas() -> SubstationAutomationSystem {
let config = SubstationConfig {
name: "TestSS".into(),
voltage_kv: 110.0,
n_bays: 3,
busbar_configuration: BusbarConfig::SingleBus,
ied_polling_interval_ms: 100.0,
scada_report_interval_s: 10.0,
};
let mut sas = SubstationAutomationSystem::new(config);
let mut bay0 = Bay::new(0, "Line Bay 1", BayType::FeederLine);
bay0.measured_power_mw = 20.0;
bay0.measured_reactive_mvar = 5.0;
bay0.rated_current_a = 500.0;
sas.add_bay(bay0);
let mut bay1 = Bay::new(1, "Transformer Bay", BayType::Transformer);
bay1.measured_power_mw = 30.0;
bay1.measured_reactive_mvar = 10.0;
bay1.rated_current_a = 800.0;
sas.add_bay(bay1);
let mut bay2 = Bay::new(2, "Generator Bay", BayType::Generator);
bay2.measured_power_mw = 50.0;
bay2.measured_reactive_mvar = 15.0;
bay2.rated_current_a = 1000.0;
sas.add_bay(bay2);
sas
}
#[test]
fn test_trip_breaker() {
let mut sas = make_sas();
assert!(sas.bays()[0].breaker_status.is_closed);
sas.trip_breaker(0, "overcurrent", 100.0)
.expect("trip must succeed");
let bay = &sas.bays()[0];
assert!(
!bay.breaker_status.is_closed,
"breaker must be open after trip"
);
assert_eq!(bay.breaker_status.trip_count, 1);
assert_eq!(bay.breaker_status.last_operation, Some(100.0));
match &bay.protection_status {
ProtectionStatus::ProtectionTripped { cause, .. } => {
assert_eq!(cause, "overcurrent");
}
other => panic!("expected ProtectionTripped, got {:?}", other),
}
let has_prot_event = sas
.event_log()
.iter()
.any(|e| e.event_type == EventType::Protection && e.bay_id == Some(0));
assert!(has_prot_event, "protection event must be logged");
}
#[test]
fn test_close_breaker_sync_ok() {
let mut sas = make_sas();
sas.trip_breaker(0, "test trip", 50.0)
.expect("trip must succeed");
assert!(!sas.bays()[0].breaker_status.is_closed);
sas.close_breaker(0, true, 60.0)
.expect("close must succeed with sync OK");
let bay = &sas.bays()[0];
assert!(bay.breaker_status.is_closed, "breaker must be closed");
assert_eq!(bay.breaker_status.last_operation, Some(60.0));
match &bay.protection_status {
ProtectionStatus::Normal => {}
other => panic!("expected Normal after close, got {:?}", other),
}
}
#[test]
fn test_close_breaker_no_sync_rejected() {
let mut sas = make_sas();
sas.trip_breaker(0, "test", 10.0).expect("trip OK");
assert!(!sas.bays()[0].breaker_status.is_closed);
let result = sas.close_breaker(0, false, 20.0);
assert!(
matches!(result, Err(SasError::SyncCheckFailed)),
"close without sync must be rejected"
);
assert!(
!sas.bays()[0].breaker_status.is_closed,
"breaker must remain open"
);
}
#[test]
fn test_scada_report_totals() {
let mut sas = make_sas();
sas.trip_breaker(1, "test", 0.0).expect("trip OK");
let report = sas.generate_report(200.0);
assert_eq!(report.substation, "TestSS");
assert!(
(report.total_load_mw - 100.0).abs() < 1e-9,
"total load must be 20+30+50=100 MW, got {}",
report.total_load_mw
);
assert!(
(report.total_reactive_mvar - 30.0).abs() < 1e-9,
"total reactive must be 5+10+15=30 MVAr"
);
assert_eq!(report.n_trips, 1, "one trip should be counted");
assert_eq!(report.bay_statuses.len(), 3);
let bay1_status = report.bay_statuses.iter().find(|(id, _, _)| *id == 1);
assert!(bay1_status.is_some());
assert!(!bay1_status.unwrap().1, "bay 1 breaker should be open");
}
#[test]
fn test_maintenance_high_wear_flagged() {
let mut sas = make_sas();
sas.bays[0].breaker_status.contact_wear_pct = 85.0;
let needs = sas.check_maintenance_needs();
let has_wear = needs
.iter()
.any(|(id, reason)| *id == 0 && reason.contains("wear"));
assert!(has_wear, "high wear must be flagged for bay 0");
}
#[test]
fn test_maintenance_low_sf6_flagged() {
let mut sas = make_sas();
sas.bays[2].breaker_status.sf6_pressure_bar = 3.8;
let needs = sas.check_maintenance_needs();
let has_sf6 = needs
.iter()
.any(|(id, reason)| *id == 2 && reason.contains("SF6"));
assert!(has_sf6, "low SF6 pressure must be flagged for bay 2");
}
#[test]
fn test_ied_critical_overcurrent_auto_trip() {
let mut sas = make_sas();
let events = sas.process_ied_data(0, 1600.0, 300.0).expect("process OK");
let has_critical = events.iter().any(|e| e.severity == EventSeverity::Critical);
assert!(has_critical, "critical event must be generated at 3× rated");
assert!(
!sas.bays()[0].breaker_status.is_closed,
"breaker auto-tripped"
);
assert_eq!(sas.bays()[0].breaker_status.trip_count, 1);
}
#[test]
fn test_bay_not_found_error() {
let mut sas = make_sas();
let result = sas.trip_breaker(99, "test", 0.0);
assert!(
matches!(result, Err(SasError::BayNotFound(99))),
"must return BayNotFound for unknown bay"
);
}
}