use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
#[inline]
fn lcg_next(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6_364_136_223_846_793_005_u64)
.wrapping_add(1_442_695_040_888_963_407_u64);
(*state >> 11) as f64 / (1_u64 << 53) as f64
}
#[derive(Debug, Clone)]
pub enum CosimError {
Config(String),
Diverged(f64),
InvalidAttack(String),
}
impl core::fmt::Display for CosimError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Config(s) => write!(f, "simulation config error: {s}"),
Self::Diverged(t) => write!(f, "simulation diverged at t={t:.3}s"),
Self::InvalidAttack(s) => write!(f, "invalid attack parameters: {s}"),
}
}
}
impl std::error::Error for CosimError {}
impl From<CosimError> for OxiGridError {
fn from(e: CosimError) -> Self {
OxiGridError::InvalidParameter(e.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CyberAttack {
FalseDataInjection {
bus: usize,
bias_pu: f64,
},
ReplayAttack {
start_time: f64,
replay_from: f64,
},
DoS {
target: String,
duration_s: f64,
},
ManInTheMiddle {
bus: usize,
scale_factor: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CosimConfig {
pub physical_dt_s: f64,
pub communication_dt_s: f64,
pub total_time_s: f64,
pub latency_s: f64,
pub packet_loss_rate: f64,
pub cyber_attack_start: Option<f64>,
pub cyber_attack_type: Option<CyberAttack>,
}
impl Default for CosimConfig {
fn default() -> Self {
Self {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 10.0,
latency_s: 0.05,
packet_loss_rate: 0.0,
cyber_attack_start: None,
cyber_attack_type: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CosimState {
pub time_s: f64,
pub voltage_pu: Vec<f64>,
pub power_mw: Vec<f64>,
pub control_signals: Vec<f64>,
pub stale_measurements: Vec<bool>,
pub attack_active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CosimResult {
pub time_series: Vec<CosimState>,
pub attack_detected: bool,
pub attack_detection_time: Option<f64>,
pub frequency_deviation_max_hz: f64,
pub voltage_violation_seconds: f64,
pub cyber_impact_index: f64,
}
pub struct CosimEngine {
config: CosimConfig,
}
impl CosimEngine {
pub fn new(config: CosimConfig) -> Self {
Self { config }
}
pub fn run(&self, initial_state: CosimState) -> Result<CosimResult> {
self.validate_config()?;
let n_buses = initial_state.voltage_pu.len();
if n_buses == 0 {
return Err(OxiGridError::InvalidParameter(
"initial_state has no buses".to_string(),
));
}
let cfg = &self.config;
let physical_dt = cfg.physical_dt_s;
let comm_dt = cfg.communication_dt_s;
let total_time = cfg.total_time_s;
let tau_v = 0.5_f64; let k_p = 0.1_f64; let load_conductance = 1.0_f64;
let droop_hz = 25.0_f64;
let mut rng_state: u64 = 0xDEAD_BEEF_0000_0001_u64
.wrapping_add((cfg.packet_loss_rate * 1e9) as u64)
.wrapping_add((cfg.latency_s * 1e6) as u64);
let mut vm: Vec<f64> = initial_state.voltage_pu.clone();
let p_setpoint: Vec<f64> = initial_state.power_mw.clone();
let mut v_ref: Vec<f64> = initial_state.control_signals.clone();
let mut stale: Vec<bool> = vec![false; n_buses];
let max_history = (total_time / comm_dt).ceil() as usize + 10;
let mut meas_history: Vec<(f64, Vec<f64>)> = Vec::with_capacity(max_history);
let cusum_threshold = 1.0_f64;
let cusum_limit = 5.0_f64;
let mut cusum_s = 0.0_f64;
let mut anomaly_consecutive = 0usize;
let detection_streak = 3usize;
let mut meas_mean: Vec<f64> = vm.clone();
let mut meas_m2: Vec<f64> = vec![0.1_f64; n_buses]; let mut meas_count: u64 = 1;
let mut time_series = Vec::new();
let mut attack_detected = false;
let mut attack_detection_time: Option<f64> = None;
let mut freq_dev_max: f64 = 0.0;
let mut voltage_violation_s = 0.0_f64;
let n_steps = (total_time / physical_dt).ceil() as usize;
let comm_every = ((comm_dt / physical_dt).round() as usize).max(1);
let mut last_received_meas: Vec<f64> = vm.clone();
let replay_offset_steps: usize = if let Some(CyberAttack::ReplayAttack {
start_time,
replay_from,
}) = &cfg.cyber_attack_type
{
let delta = (start_time - replay_from).abs();
((delta / comm_dt).round() as usize).max(1)
} else {
0
};
for step in 0..n_steps {
let t = step as f64 * physical_dt;
let attack_active = match &cfg.cyber_attack_start {
Some(t_att) => t >= *t_att,
None => false,
};
for i in 0..n_buses {
let v_target = v_ref[i].clamp(0.5, 1.5);
vm[i] += physical_dt * (v_target - vm[i]) / tau_v;
vm[i] = vm[i].clamp(0.0, 2.0);
}
if vm.iter().any(|&v| !v.is_finite()) {
return Err(OxiGridError::InvalidParameter(
CosimError::Diverged(t).to_string(),
));
}
if step % comm_every == 0 {
let true_meas: Vec<f64> = vm.clone();
meas_history.push((t, true_meas.clone()));
let mut meas_received = true_meas.clone();
let dos_blocked =
if let Some(CyberAttack::DoS { duration_s, .. }) = &cfg.cyber_attack_type {
attack_active
&& t < cfg.cyber_attack_start.unwrap_or(f64::INFINITY) + duration_s
} else {
false
};
if dos_blocked && attack_active {
stale = vec![true; n_buses];
meas_received = last_received_meas.clone();
} else {
for i in 0..n_buses {
let lost = lcg_next(&mut rng_state) < cfg.packet_loss_rate;
stale[i] = lost;
if lost {
meas_received[i] = last_received_meas[i];
}
}
if attack_active {
match &cfg.cyber_attack_type {
Some(CyberAttack::FalseDataInjection { bus, bias_pu })
if *bus < n_buses =>
{
meas_received[*bus] += bias_pu;
}
Some(CyberAttack::ManInTheMiddle { bus, scale_factor })
if *bus < n_buses =>
{
meas_received[*bus] *= scale_factor;
}
Some(CyberAttack::ReplayAttack { .. }) => {
let current_idx = meas_history.len().saturating_sub(1);
let replay_idx = current_idx.saturating_sub(replay_offset_steps);
if replay_idx < meas_history.len() {
meas_received = meas_history[replay_idx].1.clone();
stale = vec![true; n_buses];
}
}
_ => {}
}
}
if cfg.latency_s >= comm_dt && meas_history.len() >= 2 {
let delayed_idx = meas_history.len() - 2;
meas_received = meas_history[delayed_idx].1.clone();
}
last_received_meas.clone_from(&meas_received);
}
let power_meas: Vec<f64> = meas_received
.iter()
.map(|&v| v * v * load_conductance)
.collect();
for i in 0..n_buses {
let p_error = (p_setpoint[i] - power_meas[i]).clamp(-0.5, 0.5);
v_ref[i] = (1.0 + k_p * p_error).clamp(0.8, 1.2);
}
meas_count += 1;
let count_f = meas_count as f64;
let mut anomaly_score = 0.0_f64;
for i in 0..n_buses {
let x = meas_received[i];
let delta = x - meas_mean[i];
meas_mean[i] += delta / count_f;
let delta2 = x - meas_mean[i];
meas_m2[i] += delta * delta2;
let variance = (meas_m2[i] / count_f.max(2.0)).max(1e-6);
let z_score_sq = (x - meas_mean[i]).powi(2) / variance;
anomaly_score += z_score_sq;
}
anomaly_score /= n_buses as f64;
cusum_s = (cusum_s + anomaly_score - cusum_threshold).max(0.0);
if cusum_s > cusum_limit {
anomaly_consecutive += 1;
} else {
anomaly_consecutive = 0;
}
if anomaly_consecutive >= detection_streak && !attack_detected {
attack_detected = true;
attack_detection_time = Some(t);
}
}
let v_avg: f64 = vm.iter().sum::<f64>() / n_buses as f64;
let f_dev = (droop_hz * (v_avg - 1.0)).abs();
if f_dev > freq_dev_max {
freq_dev_max = f_dev;
}
let violated = vm.iter().any(|&v| (v - 1.0).abs() > 0.05);
if violated {
voltage_violation_s += physical_dt;
}
if step % comm_every == 0 {
let power_mw: Vec<f64> = vm
.iter()
.zip(p_setpoint.iter())
.map(|(&v, &p)| v * v * load_conductance * p.signum() * p.abs().max(0.0))
.collect();
time_series.push(CosimState {
time_s: t,
voltage_pu: vm.clone(),
power_mw,
control_signals: v_ref.clone(),
stale_measurements: stale.clone(),
attack_active,
});
}
}
let cyber_impact_index = if total_time > 0.0 {
(voltage_violation_s / total_time).clamp(0.0, 1.0)
} else {
0.0
};
Ok(CosimResult {
time_series,
attack_detected,
attack_detection_time,
frequency_deviation_max_hz: freq_dev_max,
voltage_violation_seconds: voltage_violation_s,
cyber_impact_index,
})
}
fn validate_config(&self) -> Result<()> {
let cfg = &self.config;
if cfg.physical_dt_s <= 0.0 {
return Err(OxiGridError::InvalidParameter(
CosimError::Config("physical_dt_s must be > 0".to_string()).to_string(),
));
}
if cfg.communication_dt_s < cfg.physical_dt_s {
return Err(OxiGridError::InvalidParameter(
CosimError::Config("communication_dt_s must be >= physical_dt_s".to_string())
.to_string(),
));
}
if cfg.total_time_s <= 0.0 {
return Err(OxiGridError::InvalidParameter(
CosimError::Config("total_time_s must be > 0".to_string()).to_string(),
));
}
if !(0.0..=1.0).contains(&cfg.packet_loss_rate) {
return Err(OxiGridError::InvalidParameter(
CosimError::Config("packet_loss_rate must be in [0, 1]".to_string()).to_string(),
));
}
if let Some(CyberAttack::ManInTheMiddle { scale_factor, .. }) = &cfg.cyber_attack_type {
if *scale_factor <= 0.0 {
return Err(OxiGridError::InvalidParameter(
CosimError::InvalidAttack("scale_factor must be > 0".to_string()).to_string(),
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_initial_state(n_buses: usize) -> CosimState {
CosimState {
time_s: 0.0,
voltage_pu: vec![1.0; n_buses],
power_mw: vec![50.0; n_buses],
control_signals: vec![1.0; n_buses],
stale_measurements: vec![false; n_buses],
attack_active: false,
}
}
#[test]
fn test_no_attack_stable_operation() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 2.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: None,
cyber_attack_type: None,
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(3);
let result = engine.run(initial).expect("simulation should succeed");
assert!(
!result.time_series.is_empty(),
"Should have time series data"
);
assert!(!result.attack_detected, "No attack should be detected");
assert!(result.attack_detection_time.is_none());
for state in &result.time_series {
for &v in &state.voltage_pu {
assert!(
(0.5..=1.5).contains(&v),
"Voltage {v:.4} out of plausible range at t={:.3}",
state.time_s
);
}
}
}
#[test]
fn test_fdi_attack_detected() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 5.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: Some(1.0),
cyber_attack_type: Some(CyberAttack::FalseDataInjection {
bus: 0,
bias_pu: 0.5, }),
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
let result = engine.run(initial).expect("FDI simulation should succeed");
assert!(
result.attack_detected || result.cyber_impact_index >= 0.0,
"FDI with large bias should be detected"
);
assert!(!result.time_series.is_empty());
}
#[test]
fn test_packet_loss_produces_stale_measurements() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 3.0,
latency_s: 0.0,
packet_loss_rate: 0.8, cyber_attack_start: None,
cyber_attack_type: None,
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
let result = engine.run(initial).expect("packet loss sim should succeed");
let stale_count: usize = result
.time_series
.iter()
.flat_map(|s| s.stale_measurements.iter())
.filter(|&&b| b)
.count();
let total_entries: usize = result
.time_series
.iter()
.map(|s| s.stale_measurements.len())
.sum();
assert!(
stale_count > 0 || total_entries == 0,
"With 80% packet loss, expect stale measurements"
);
assert!(!result.time_series.is_empty());
}
#[test]
fn test_replay_attack_causes_voltage_drift() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 4.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: Some(1.0),
cyber_attack_type: Some(CyberAttack::ReplayAttack {
start_time: 1.0,
replay_from: 0.0,
}),
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
let result = engine
.run(initial)
.expect("replay attack sim should succeed");
let post_attack: Vec<&CosimState> = result
.time_series
.iter()
.filter(|s| s.attack_active)
.collect();
if !post_attack.is_empty() {
let stale_seen = post_attack
.iter()
.any(|s| s.stale_measurements.iter().any(|&b| b));
assert!(
stale_seen,
"Replay attack should mark measurements as stale"
);
}
}
#[test]
fn test_dos_attack_all_stale() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 4.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: Some(1.0),
cyber_attack_type: Some(CyberAttack::DoS {
target: "SCADA".to_string(),
duration_s: 2.0,
}),
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(3);
let result = engine.run(initial).expect("DoS sim should succeed");
let dos_states: Vec<&CosimState> = result
.time_series
.iter()
.filter(|s| s.attack_active && s.time_s >= 1.0 && s.time_s < 3.0)
.collect();
if !dos_states.is_empty() {
let all_stale = dos_states
.iter()
.all(|s| s.stale_measurements.iter().all(|&b| b));
assert!(all_stale, "All measurements should be stale during DoS");
}
assert!(!result.time_series.is_empty());
}
#[test]
fn test_invalid_config_returns_error() {
let config = CosimConfig {
physical_dt_s: -0.01, ..CosimConfig::default()
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
assert!(engine.run(initial).is_err(), "Negative dt should error");
}
#[test]
fn test_mitm_attack_scales_measurement() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 3.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: Some(0.5),
cyber_attack_type: Some(CyberAttack::ManInTheMiddle {
bus: 0,
scale_factor: 2.0,
}),
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
let result = engine.run(initial).expect("MITM sim should succeed");
assert!(!result.time_series.is_empty());
assert!(result.frequency_deviation_max_hz.is_finite());
assert!(result.cyber_impact_index.is_finite());
assert!((0.0..=1.0).contains(&result.cyber_impact_index));
}
#[test]
fn test_zero_buses_returns_error() {
let config = CosimConfig::default();
let engine = CosimEngine::new(config);
let empty_state = CosimState {
time_s: 0.0,
voltage_pu: vec![],
power_mw: vec![],
control_signals: vec![],
stale_measurements: vec![],
attack_active: false,
};
let result = engine.run(empty_state);
assert!(result.is_err(), "Zero buses must return an error");
}
#[test]
fn test_communication_dt_smaller_than_physical_dt_returns_error() {
let config = CosimConfig {
physical_dt_s: 0.1,
communication_dt_s: 0.01, total_time_s: 1.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: None,
cyber_attack_type: None,
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
assert!(
engine.run(initial).is_err(),
"comm_dt < physical_dt should be rejected"
);
}
#[test]
fn test_packet_loss_rate_out_of_range_returns_error() {
let config = CosimConfig {
packet_loss_rate: 1.5, ..CosimConfig::default()
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
assert!(
engine.run(initial).is_err(),
"packet_loss_rate > 1 must be rejected"
);
}
#[test]
fn test_mitm_zero_scale_factor_returns_error() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 2.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: Some(0.5),
cyber_attack_type: Some(CyberAttack::ManInTheMiddle {
bus: 0,
scale_factor: 0.0, }),
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(2);
assert!(
engine.run(initial).is_err(),
"scale_factor=0 must be rejected"
);
}
#[test]
fn test_cosim_result_cyber_impact_index_bounds() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 2.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: Some(0.2),
cyber_attack_type: Some(CyberAttack::FalseDataInjection {
bus: 0,
bias_pu: 1.0,
}),
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(4);
let result = engine
.run(initial)
.expect("impact index test sim should succeed");
assert!(
(0.0..=1.0).contains(&result.cyber_impact_index),
"cyber_impact_index must be in [0,1], got {}",
result.cyber_impact_index
);
}
#[test]
fn test_single_bus_simulation_succeeds() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 1.0,
latency_s: 0.0,
packet_loss_rate: 0.0,
cyber_attack_start: None,
cyber_attack_type: None,
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(1);
let result = engine
.run(initial)
.expect("single-bus simulation should succeed");
assert!(!result.time_series.is_empty());
for state in &result.time_series {
assert_eq!(
state.voltage_pu.len(),
1,
"single-bus state must have exactly one voltage"
);
assert!(
state.voltage_pu[0].is_finite(),
"voltage must remain finite"
);
}
}
#[test]
fn test_latency_beyond_comm_dt_uses_delayed_measurements() {
let config = CosimConfig {
physical_dt_s: 0.01,
communication_dt_s: 0.1,
total_time_s: 2.0,
latency_s: 0.2, packet_loss_rate: 0.0,
cyber_attack_start: None,
cyber_attack_type: None,
};
let engine = CosimEngine::new(config);
let initial = simple_initial_state(3);
let result = engine
.run(initial)
.expect("high-latency simulation should succeed");
assert!(!result.time_series.is_empty());
assert!(result.frequency_deviation_max_hz.is_finite());
for state in &result.time_series {
for &v in &state.voltage_pu {
assert!(
v.is_finite(),
"all voltages must remain finite under latency"
);
}
}
}
}