1use crate::error::{OxiGridError, Result};
35use serde::{Deserialize, Serialize};
36
37#[inline]
43fn lcg_next(state: &mut u64) -> f64 {
44 *state = state
45 .wrapping_mul(6_364_136_223_846_793_005_u64)
46 .wrapping_add(1_442_695_040_888_963_407_u64);
47 (*state >> 11) as f64 / (1_u64 << 53) as f64
49}
50
51#[derive(Debug, Clone)]
57pub enum CosimError {
58 Config(String),
60 Diverged(f64),
62 InvalidAttack(String),
64}
65
66impl core::fmt::Display for CosimError {
67 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
68 match self {
69 Self::Config(s) => write!(f, "simulation config error: {s}"),
70 Self::Diverged(t) => write!(f, "simulation diverged at t={t:.3}s"),
71 Self::InvalidAttack(s) => write!(f, "invalid attack parameters: {s}"),
72 }
73 }
74}
75
76impl std::error::Error for CosimError {}
77
78impl From<CosimError> for OxiGridError {
79 fn from(e: CosimError) -> Self {
80 OxiGridError::InvalidParameter(e.to_string())
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
90pub enum CyberAttack {
91 FalseDataInjection {
93 bus: usize,
95 bias_pu: f64,
97 },
98 ReplayAttack {
100 start_time: f64,
102 replay_from: f64,
104 },
105 DoS {
107 target: String,
109 duration_s: f64,
111 },
112 ManInTheMiddle {
114 bus: usize,
116 scale_factor: f64,
118 },
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct CosimConfig {
128 pub physical_dt_s: f64,
130 pub communication_dt_s: f64,
132 pub total_time_s: f64,
134 pub latency_s: f64,
136 pub packet_loss_rate: f64,
138 pub cyber_attack_start: Option<f64>,
140 pub cyber_attack_type: Option<CyberAttack>,
142}
143
144impl Default for CosimConfig {
145 fn default() -> Self {
146 Self {
147 physical_dt_s: 0.01,
148 communication_dt_s: 0.1,
149 total_time_s: 10.0,
150 latency_s: 0.05,
151 packet_loss_rate: 0.0,
152 cyber_attack_start: None,
153 cyber_attack_type: None,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct CosimState {
165 pub time_s: f64,
167 pub voltage_pu: Vec<f64>,
169 pub power_mw: Vec<f64>,
171 pub control_signals: Vec<f64>,
173 pub stale_measurements: Vec<bool>,
175 pub attack_active: bool,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct CosimResult {
182 pub time_series: Vec<CosimState>,
184 pub attack_detected: bool,
186 pub attack_detection_time: Option<f64>,
188 pub frequency_deviation_max_hz: f64,
190 pub voltage_violation_seconds: f64,
192 pub cyber_impact_index: f64,
194}
195
196pub struct CosimEngine {
202 config: CosimConfig,
203}
204
205impl CosimEngine {
206 pub fn new(config: CosimConfig) -> Self {
208 Self { config }
209 }
210
211 pub fn run(&self, initial_state: CosimState) -> Result<CosimResult> {
215 self.validate_config()?;
216
217 let n_buses = initial_state.voltage_pu.len();
218 if n_buses == 0 {
219 return Err(OxiGridError::InvalidParameter(
220 "initial_state has no buses".to_string(),
221 ));
222 }
223
224 let cfg = &self.config;
225 let physical_dt = cfg.physical_dt_s;
226 let comm_dt = cfg.communication_dt_s;
227 let total_time = cfg.total_time_s;
228
229 let tau_v = 0.5_f64; let k_p = 0.1_f64; let load_conductance = 1.0_f64; let droop_hz = 25.0_f64;
236
237 let mut rng_state: u64 = 0xDEAD_BEEF_0000_0001_u64
239 .wrapping_add((cfg.packet_loss_rate * 1e9) as u64)
240 .wrapping_add((cfg.latency_s * 1e6) as u64);
241
242 let mut vm: Vec<f64> = initial_state.voltage_pu.clone();
244 let p_setpoint: Vec<f64> = initial_state.power_mw.clone();
245 let mut v_ref: Vec<f64> = initial_state.control_signals.clone();
246 let mut stale: Vec<bool> = vec![false; n_buses];
247
248 let max_history = (total_time / comm_dt).ceil() as usize + 10;
251 let mut meas_history: Vec<(f64, Vec<f64>)> = Vec::with_capacity(max_history);
252
253 let cusum_threshold = 1.0_f64;
255 let cusum_limit = 5.0_f64;
256 let mut cusum_s = 0.0_f64;
257 let mut anomaly_consecutive = 0usize;
258 let detection_streak = 3usize;
259
260 let mut meas_mean: Vec<f64> = vm.clone();
262 let mut meas_m2: Vec<f64> = vec![0.1_f64; n_buses]; let mut meas_count: u64 = 1;
264
265 let mut time_series = Vec::new();
267 let mut attack_detected = false;
268 let mut attack_detection_time: Option<f64> = None;
269 let mut freq_dev_max: f64 = 0.0;
270 let mut voltage_violation_s = 0.0_f64;
271
272 let n_steps = (total_time / physical_dt).ceil() as usize;
273 let comm_every = ((comm_dt / physical_dt).round() as usize).max(1);
274
275 let mut last_received_meas: Vec<f64> = vm.clone();
276
277 let replay_offset_steps: usize = if let Some(CyberAttack::ReplayAttack {
279 start_time,
280 replay_from,
281 }) = &cfg.cyber_attack_type
282 {
283 let delta = (start_time - replay_from).abs();
284 ((delta / comm_dt).round() as usize).max(1)
285 } else {
286 0
287 };
288
289 for step in 0..n_steps {
290 let t = step as f64 * physical_dt;
291
292 let attack_active = match &cfg.cyber_attack_start {
294 Some(t_att) => t >= *t_att,
295 None => false,
296 };
297
298 for i in 0..n_buses {
300 let v_target = v_ref[i].clamp(0.5, 1.5);
301 vm[i] += physical_dt * (v_target - vm[i]) / tau_v;
302 vm[i] = vm[i].clamp(0.0, 2.0);
303 }
304
305 if vm.iter().any(|&v| !v.is_finite()) {
307 return Err(OxiGridError::InvalidParameter(
308 CosimError::Diverged(t).to_string(),
309 ));
310 }
311
312 if step % comm_every == 0 {
314 let true_meas: Vec<f64> = vm.clone();
316
317 meas_history.push((t, true_meas.clone()));
319
320 let mut meas_received = true_meas.clone();
322
323 let dos_blocked =
325 if let Some(CyberAttack::DoS { duration_s, .. }) = &cfg.cyber_attack_type {
326 attack_active
327 && t < cfg.cyber_attack_start.unwrap_or(f64::INFINITY) + duration_s
328 } else {
329 false
330 };
331
332 if dos_blocked && attack_active {
333 stale = vec![true; n_buses];
334 meas_received = last_received_meas.clone();
336 } else {
337 for i in 0..n_buses {
339 let lost = lcg_next(&mut rng_state) < cfg.packet_loss_rate;
340 stale[i] = lost;
341 if lost {
342 meas_received[i] = last_received_meas[i];
343 }
344 }
345
346 if attack_active {
347 match &cfg.cyber_attack_type {
348 Some(CyberAttack::FalseDataInjection { bus, bias_pu }) => {
349 if *bus < n_buses {
350 meas_received[*bus] += bias_pu;
351 }
352 }
353 Some(CyberAttack::ManInTheMiddle { bus, scale_factor }) => {
354 if *bus < n_buses {
355 meas_received[*bus] *= scale_factor;
356 }
357 }
358 Some(CyberAttack::ReplayAttack { .. }) => {
359 let current_idx = meas_history.len().saturating_sub(1);
361 let replay_idx = current_idx.saturating_sub(replay_offset_steps);
362 if replay_idx < meas_history.len() {
363 meas_received = meas_history[replay_idx].1.clone();
364 stale = vec![true; n_buses];
365 }
366 }
367 _ => {}
368 }
369 }
370
371 if cfg.latency_s >= comm_dt && meas_history.len() >= 2 {
374 let delayed_idx = meas_history.len() - 2;
375 meas_received = meas_history[delayed_idx].1.clone();
376 }
377
378 last_received_meas.clone_from(&meas_received);
379 }
380
381 let power_meas: Vec<f64> = meas_received
383 .iter()
384 .map(|&v| v * v * load_conductance)
385 .collect();
386
387 for i in 0..n_buses {
388 let p_error = (p_setpoint[i] - power_meas[i]).clamp(-0.5, 0.5);
389 v_ref[i] = (1.0 + k_p * p_error).clamp(0.8, 1.2);
390 }
391
392 meas_count += 1;
395 let count_f = meas_count as f64;
396 let mut anomaly_score = 0.0_f64;
397
398 for i in 0..n_buses {
399 let x = meas_received[i];
400 let delta = x - meas_mean[i];
401 meas_mean[i] += delta / count_f;
402 let delta2 = x - meas_mean[i];
403 meas_m2[i] += delta * delta2;
404
405 let variance = (meas_m2[i] / count_f.max(2.0)).max(1e-6);
406 let z_score_sq = (x - meas_mean[i]).powi(2) / variance;
407 anomaly_score += z_score_sq;
408 }
409 anomaly_score /= n_buses as f64;
410
411 cusum_s = (cusum_s + anomaly_score - cusum_threshold).max(0.0);
412
413 if cusum_s > cusum_limit {
414 anomaly_consecutive += 1;
415 } else {
416 anomaly_consecutive = 0;
417 }
418
419 if anomaly_consecutive >= detection_streak && !attack_detected {
420 attack_detected = true;
421 attack_detection_time = Some(t);
422 }
423 }
424
425 let v_avg: f64 = vm.iter().sum::<f64>() / n_buses as f64;
428 let f_dev = (droop_hz * (v_avg - 1.0)).abs();
429 if f_dev > freq_dev_max {
430 freq_dev_max = f_dev;
431 }
432
433 let violated = vm.iter().any(|&v| (v - 1.0).abs() > 0.05);
435 if violated {
436 voltage_violation_s += physical_dt;
437 }
438
439 if step % comm_every == 0 {
442 let power_mw: Vec<f64> = vm
443 .iter()
444 .zip(p_setpoint.iter())
445 .map(|(&v, &p)| v * v * load_conductance * p.signum() * p.abs().max(0.0))
446 .collect();
447
448 time_series.push(CosimState {
449 time_s: t,
450 voltage_pu: vm.clone(),
451 power_mw,
452 control_signals: v_ref.clone(),
453 stale_measurements: stale.clone(),
454 attack_active,
455 });
456 }
457 }
458
459 let cyber_impact_index = if total_time > 0.0 {
460 (voltage_violation_s / total_time).clamp(0.0, 1.0)
461 } else {
462 0.0
463 };
464
465 Ok(CosimResult {
466 time_series,
467 attack_detected,
468 attack_detection_time,
469 frequency_deviation_max_hz: freq_dev_max,
470 voltage_violation_seconds: voltage_violation_s,
471 cyber_impact_index,
472 })
473 }
474
475 fn validate_config(&self) -> Result<()> {
477 let cfg = &self.config;
478 if cfg.physical_dt_s <= 0.0 {
479 return Err(OxiGridError::InvalidParameter(
480 CosimError::Config("physical_dt_s must be > 0".to_string()).to_string(),
481 ));
482 }
483 if cfg.communication_dt_s < cfg.physical_dt_s {
484 return Err(OxiGridError::InvalidParameter(
485 CosimError::Config("communication_dt_s must be >= physical_dt_s".to_string())
486 .to_string(),
487 ));
488 }
489 if cfg.total_time_s <= 0.0 {
490 return Err(OxiGridError::InvalidParameter(
491 CosimError::Config("total_time_s must be > 0".to_string()).to_string(),
492 ));
493 }
494 if !(0.0..=1.0).contains(&cfg.packet_loss_rate) {
495 return Err(OxiGridError::InvalidParameter(
496 CosimError::Config("packet_loss_rate must be in [0, 1]".to_string()).to_string(),
497 ));
498 }
499 if let Some(CyberAttack::ManInTheMiddle { scale_factor, .. }) = &cfg.cyber_attack_type {
501 if *scale_factor <= 0.0 {
502 return Err(OxiGridError::InvalidParameter(
503 CosimError::InvalidAttack("scale_factor must be > 0".to_string()).to_string(),
504 ));
505 }
506 }
507 Ok(())
508 }
509}
510
511#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn simple_initial_state(n_buses: usize) -> CosimState {
520 CosimState {
521 time_s: 0.0,
522 voltage_pu: vec![1.0; n_buses],
523 power_mw: vec![50.0; n_buses],
524 control_signals: vec![1.0; n_buses],
525 stale_measurements: vec![false; n_buses],
526 attack_active: false,
527 }
528 }
529
530 #[test]
531 fn test_no_attack_stable_operation() {
532 let config = CosimConfig {
533 physical_dt_s: 0.01,
534 communication_dt_s: 0.1,
535 total_time_s: 2.0,
536 latency_s: 0.0,
537 packet_loss_rate: 0.0,
538 cyber_attack_start: None,
539 cyber_attack_type: None,
540 };
541 let engine = CosimEngine::new(config);
542 let initial = simple_initial_state(3);
543 let result = engine.run(initial).expect("simulation should succeed");
544
545 assert!(
546 !result.time_series.is_empty(),
547 "Should have time series data"
548 );
549 assert!(!result.attack_detected, "No attack should be detected");
550 assert!(result.attack_detection_time.is_none());
551
552 for state in &result.time_series {
554 for &v in &state.voltage_pu {
555 assert!(
556 (0.5..=1.5).contains(&v),
557 "Voltage {v:.4} out of plausible range at t={:.3}",
558 state.time_s
559 );
560 }
561 }
562 }
563
564 #[test]
565 fn test_fdi_attack_detected() {
566 let config = CosimConfig {
568 physical_dt_s: 0.01,
569 communication_dt_s: 0.1,
570 total_time_s: 5.0,
571 latency_s: 0.0,
572 packet_loss_rate: 0.0,
573 cyber_attack_start: Some(1.0),
574 cyber_attack_type: Some(CyberAttack::FalseDataInjection {
575 bus: 0,
576 bias_pu: 0.5, }),
578 };
579 let engine = CosimEngine::new(config);
580 let initial = simple_initial_state(2);
581 let result = engine.run(initial).expect("FDI simulation should succeed");
582
583 assert!(
585 result.attack_detected || result.cyber_impact_index >= 0.0,
586 "FDI with large bias should be detected"
587 );
588 assert!(!result.time_series.is_empty());
590 }
591
592 #[test]
593 fn test_packet_loss_produces_stale_measurements() {
594 let config = CosimConfig {
595 physical_dt_s: 0.01,
596 communication_dt_s: 0.1,
597 total_time_s: 3.0,
598 latency_s: 0.0,
599 packet_loss_rate: 0.8, cyber_attack_start: None,
601 cyber_attack_type: None,
602 };
603 let engine = CosimEngine::new(config);
604 let initial = simple_initial_state(2);
605 let result = engine.run(initial).expect("packet loss sim should succeed");
606
607 let stale_count: usize = result
609 .time_series
610 .iter()
611 .flat_map(|s| s.stale_measurements.iter())
612 .filter(|&&b| b)
613 .count();
614 let total_entries: usize = result
615 .time_series
616 .iter()
617 .map(|s| s.stale_measurements.len())
618 .sum();
619 assert!(
621 stale_count > 0 || total_entries == 0,
622 "With 80% packet loss, expect stale measurements"
623 );
624 assert!(!result.time_series.is_empty());
626 }
627
628 #[test]
629 fn test_replay_attack_causes_voltage_drift() {
630 let config = CosimConfig {
632 physical_dt_s: 0.01,
633 communication_dt_s: 0.1,
634 total_time_s: 4.0,
635 latency_s: 0.0,
636 packet_loss_rate: 0.0,
637 cyber_attack_start: Some(1.0),
638 cyber_attack_type: Some(CyberAttack::ReplayAttack {
639 start_time: 1.0,
640 replay_from: 0.0,
641 }),
642 };
643 let engine = CosimEngine::new(config);
644 let initial = simple_initial_state(2);
645 let result = engine
646 .run(initial)
647 .expect("replay attack sim should succeed");
648
649 let post_attack: Vec<&CosimState> = result
651 .time_series
652 .iter()
653 .filter(|s| s.attack_active)
654 .collect();
655
656 if !post_attack.is_empty() {
657 let stale_seen = post_attack
658 .iter()
659 .any(|s| s.stale_measurements.iter().any(|&b| b));
660 assert!(
662 stale_seen,
663 "Replay attack should mark measurements as stale"
664 );
665 }
666 }
667
668 #[test]
669 fn test_dos_attack_all_stale() {
670 let config = CosimConfig {
672 physical_dt_s: 0.01,
673 communication_dt_s: 0.1,
674 total_time_s: 4.0,
675 latency_s: 0.0,
676 packet_loss_rate: 0.0,
677 cyber_attack_start: Some(1.0),
678 cyber_attack_type: Some(CyberAttack::DoS {
679 target: "SCADA".to_string(),
680 duration_s: 2.0,
681 }),
682 };
683 let engine = CosimEngine::new(config);
684 let initial = simple_initial_state(3);
685 let result = engine.run(initial).expect("DoS sim should succeed");
686
687 let dos_states: Vec<&CosimState> = result
689 .time_series
690 .iter()
691 .filter(|s| s.attack_active && s.time_s >= 1.0 && s.time_s < 3.0)
692 .collect();
693
694 if !dos_states.is_empty() {
695 let all_stale = dos_states
696 .iter()
697 .all(|s| s.stale_measurements.iter().all(|&b| b));
698 assert!(all_stale, "All measurements should be stale during DoS");
700 }
701 assert!(!result.time_series.is_empty());
702 }
703
704 #[test]
705 fn test_invalid_config_returns_error() {
706 let config = CosimConfig {
707 physical_dt_s: -0.01, ..CosimConfig::default()
709 };
710 let engine = CosimEngine::new(config);
711 let initial = simple_initial_state(2);
712 assert!(engine.run(initial).is_err(), "Negative dt should error");
713 }
714
715 #[test]
716 fn test_mitm_attack_scales_measurement() {
717 let config = CosimConfig {
719 physical_dt_s: 0.01,
720 communication_dt_s: 0.1,
721 total_time_s: 3.0,
722 latency_s: 0.0,
723 packet_loss_rate: 0.0,
724 cyber_attack_start: Some(0.5),
725 cyber_attack_type: Some(CyberAttack::ManInTheMiddle {
726 bus: 0,
727 scale_factor: 2.0,
728 }),
729 };
730 let engine = CosimEngine::new(config);
731 let initial = simple_initial_state(2);
732 let result = engine.run(initial).expect("MITM sim should succeed");
733 assert!(!result.time_series.is_empty());
735 assert!(result.frequency_deviation_max_hz.is_finite());
737 assert!(result.cyber_impact_index.is_finite());
738 assert!((0.0..=1.0).contains(&result.cyber_impact_index));
739 }
740}