const LCG_MULT: u64 = 6364136223846793005u64;
const LCG_ADD: u64 = 1442695040888963407u64;
#[inline]
fn lcg_next(state: &mut u64) -> f64 {
*state = state.wrapping_mul(LCG_MULT).wrapping_add(LCG_ADD);
(*state >> 11) as f64 / (1u64 << 53) as f64
}
#[derive(Debug, Clone)]
pub enum AttackVector {
FalseDataInjection {
target_buses: Vec<usize>,
magnitude_pu: f64,
},
DenialOfService {
target_components: Vec<String>,
duration_s: f64,
},
ReplayAttack {
target: String,
delay_s: f64,
},
CommandInjection {
target_controller: String,
false_setpoint_mw: f64,
},
SensorTampering {
sensor_ids: Vec<String>,
bias_pu: f64,
},
LoadAlteringAttack {
target_buses: Vec<usize>,
delta_mw: f64,
},
}
#[derive(Debug, Clone)]
pub enum DefenseLayer {
Firewall {
effectiveness: f64,
},
IntrusionDetection {
detection_rate: f64,
false_positive_rate: f64,
},
Encryption {
key_strength_bits: u32,
},
PhysicalSecurity {
protection_level: u8,
},
Redundancy {
backup_systems: usize,
},
AnomalyDetection {
threshold: f64,
},
}
impl DefenseLayer {
pub fn layer_effectiveness(&self) -> f64 {
match self {
DefenseLayer::Firewall { effectiveness } => effectiveness.clamp(0.0, 1.0),
DefenseLayer::IntrusionDetection { detection_rate, .. } => {
detection_rate.clamp(0.0, 1.0)
}
DefenseLayer::Encryption { key_strength_bits } => {
let bits = *key_strength_bits as f64;
(bits / (bits + 128.0)).clamp(0.0, 1.0)
}
DefenseLayer::PhysicalSecurity { protection_level } => {
((*protection_level as f64).clamp(1.0, 5.0) - 1.0) / 4.0
}
DefenseLayer::Redundancy { backup_systems } => {
let n = *backup_systems as f64;
(n / (n + 1.0)).clamp(0.0, 1.0)
}
DefenseLayer::AnomalyDetection { threshold } => {
(1.0 - (-*threshold / 10.0).exp()).clamp(0.0, 1.0)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttackTiming {
PeakLoad,
MinimumInertia,
PostFault,
Coordinated {
simultaneous_attacks: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttackObjective {
MaximizeLoadShed,
TriggerCascade,
StealthDataCorruption,
RansomwarePreparation,
}
#[derive(Debug, Clone)]
pub struct CpAttackScenario {
pub scenario_id: String,
pub attacker_capability: u8,
pub attack_vector: AttackVector,
pub timing: AttackTiming,
pub objective: AttackObjective,
}
#[derive(Debug, Clone)]
pub struct CyberPhysicalSimConfig {
pub num_buses: usize,
pub monte_carlo_runs: usize,
pub time_horizon_s: f64,
pub recovery_time_s: f64,
}
impl Default for CyberPhysicalSimConfig {
fn default() -> Self {
Self {
num_buses: 14,
monte_carlo_runs: 200,
time_horizon_s: 3600.0,
recovery_time_s: 600.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ImpactSeverity {
Negligible,
Minor,
Moderate,
Severe,
Catastrophic,
}
impl ImpactSeverity {
fn from_load_shed_fraction(frac: f64, cascade: bool) -> Self {
if cascade || frac >= 0.50 {
ImpactSeverity::Catastrophic
} else if frac >= 0.30 {
ImpactSeverity::Severe
} else if frac >= 0.10 {
ImpactSeverity::Moderate
} else if frac > 0.0 {
ImpactSeverity::Minor
} else {
ImpactSeverity::Negligible
}
}
}
#[derive(Debug, Clone)]
pub struct AttackImpactResult {
pub attack_success: bool,
pub penetration_probability: f64,
pub load_shed_mw: f64,
pub voltage_violations: usize,
pub frequency_deviation_hz: f64,
pub cascade_triggered: bool,
pub recovery_time_s: f64,
pub impact_severity: ImpactSeverity,
}
#[derive(Debug, Clone)]
pub struct PhysicalImpact {
pub load_shed_mw: f64,
pub cascade_probability: f64,
pub frequency_deviation_hz: f64,
pub voltage_stability_margin: f64,
}
#[derive(Debug, Clone)]
pub struct RiskMatrix {
pub expected_annual_loss_mwh: f64,
pub p_cascade: f64,
pub p_blackout: f64,
pub risk_score: f64,
pub worst_case_loss_mwh: f64,
}
#[derive(Debug, Clone)]
pub struct DefenseRoi {
pub risk_reduction_pct: f64,
pub risk_reduction_mwh_per_year: f64,
pub benefit_to_cost_ratio: f64,
pub payback_years: f64,
}
#[derive(Debug, Clone)]
pub struct AnomalyReport {
pub chi_squared: f64,
pub threshold: f64,
pub anomaly_detected: bool,
pub suspicious_measurements: Vec<usize>,
}
#[derive(Debug, Clone)]
pub struct ResilienceMetrics {
pub absorptive_capacity: f64,
pub adaptive_capacity: f64,
pub restorative_capacity: f64,
pub resilience_index: f64,
}
pub struct CyberPhysicalSim {
pub config: CyberPhysicalSimConfig,
pub defense_layers: Vec<DefenseLayer>,
pub lcg_state: u64,
}
impl CyberPhysicalSim {
pub fn new(config: CyberPhysicalSimConfig) -> Self {
Self {
lcg_state: 0xDEAD_C0DE_CAFE_BABEu64,
config,
defense_layers: Vec::new(),
}
}
pub fn add_defense(&mut self, layer: DefenseLayer) {
self.defense_layers.push(layer);
}
pub fn simulate_attack(
&mut self,
scenario: &CpAttackScenario,
bus_voltages_pu: &[f64],
load_mw: &[f64],
) -> AttackImpactResult {
let penetration_probability = self.calculate_penetration_probability(scenario);
let u = lcg_next(&mut self.lcg_state);
let attack_success = u < penetration_probability;
if !attack_success {
return AttackImpactResult {
attack_success: false,
penetration_probability,
load_shed_mw: 0.0,
voltage_violations: 0,
frequency_deviation_hz: 0.0,
cascade_triggered: false,
recovery_time_s: 0.0,
impact_severity: ImpactSeverity::Negligible,
};
}
let (load_shed_mw, voltage_violations, frequency_deviation_hz, cascade_triggered) =
self.compute_vector_impact(scenario, bus_voltages_pu, load_mw);
let total_load: f64 = load_mw.iter().sum::<f64>().max(1.0);
let shed_fraction = (load_shed_mw / total_load).clamp(0.0, 1.0);
let impact_severity =
ImpactSeverity::from_load_shed_fraction(shed_fraction, cascade_triggered);
let recovery_time_s = self.config.recovery_time_s
* (1.0 + 2.0 * shed_fraction)
* if cascade_triggered { 3.0 } else { 1.0 };
AttackImpactResult {
attack_success,
penetration_probability,
load_shed_mw,
voltage_violations,
frequency_deviation_hz,
cascade_triggered,
recovery_time_s,
impact_severity,
}
}
pub fn calculate_penetration_probability(&self, scenario: &CpAttackScenario) -> f64 {
let cap = scenario.attacker_capability.clamp(1, 5) as f64;
let base = 0.1 * (cap * cap) / 25.0;
let timing_mult = match &scenario.timing {
AttackTiming::PeakLoad => 1.3,
AttackTiming::MinimumInertia => 1.5,
AttackTiming::PostFault => 1.8,
AttackTiming::Coordinated {
simultaneous_attacks,
} => 1.0 + 0.3 * (*simultaneous_attacks as f64).min(5.0),
};
let raw = (base * timing_mult).clamp(0.0, 1.0);
let survival = self.defense_layers.iter().fold(1.0_f64, |acc, layer| {
acc * (1.0 - layer.layer_effectiveness())
});
(raw * survival).clamp(0.0, 1.0)
}
pub fn assess_physical_impact(
&self,
attack_result: &AttackImpactResult,
bus_voltages_pu: &[f64],
) -> PhysicalImpact {
if !attack_result.attack_success {
return PhysicalImpact {
load_shed_mw: 0.0,
cascade_probability: 0.0,
frequency_deviation_hz: 0.0,
voltage_stability_margin: 1.0,
};
}
let n = bus_voltages_pu.len().max(1);
let voltage_violations = bus_voltages_pu.iter().filter(|&&v| v < 0.9).count();
let voltage_stability_margin = 1.0 - (voltage_violations as f64 / n as f64).clamp(0.0, 1.0);
let load_shed_mw = attack_result.load_shed_mw;
let total_load_estimate = load_shed_mw / 0.20_f64.max(1e-9); let shed_fraction = if total_load_estimate > 0.0 {
(load_shed_mw / total_load_estimate).clamp(0.0, 1.0)
} else {
0.0
};
let cascade_probability = if shed_fraction > 0.20 {
shed_fraction.clamp(0.0, 1.0)
} else {
0.0
};
PhysicalImpact {
load_shed_mw,
cascade_probability,
frequency_deviation_hz: attack_result.frequency_deviation_hz,
voltage_stability_margin,
}
}
pub fn monte_carlo_risk_assessment(
&mut self,
scenarios: &[(CpAttackScenario, Vec<f64>, Vec<f64>)],
) -> RiskMatrix {
if scenarios.is_empty() {
return RiskMatrix {
expected_annual_loss_mwh: 0.0,
p_cascade: 0.0,
p_blackout: 0.0,
risk_score: 0.0,
worst_case_loss_mwh: 0.0,
};
}
let n_runs = self.config.monte_carlo_runs;
let time_horizon_h = self.config.time_horizon_s / 3600.0;
let mut total_loss_mwh = 0.0_f64;
let mut cascade_count = 0u64;
let mut blackout_count = 0u64;
let mut worst_loss_mwh = 0.0_f64;
for _ in 0..n_runs {
let mut run_loss_mwh = 0.0_f64;
let mut run_cascade = false;
let mut run_blackout = false;
for (scenario, voltages, loads) in scenarios {
let result = self.simulate_attack(scenario, voltages, loads);
if result.attack_success {
let loss_mwh = result.load_shed_mw * time_horizon_h;
run_loss_mwh += loss_mwh;
if result.cascade_triggered {
run_cascade = true;
}
if result.impact_severity == ImpactSeverity::Catastrophic {
run_blackout = true;
}
}
}
total_loss_mwh += run_loss_mwh;
if run_loss_mwh > worst_loss_mwh {
worst_loss_mwh = run_loss_mwh;
}
if run_cascade {
cascade_count += 1;
}
if run_blackout {
blackout_count += 1;
}
}
let n = n_runs as f64;
let expected_annual_loss_mwh = (total_loss_mwh / n) * (8760.0 / time_horizon_h);
let p_cascade = (cascade_count as f64 / n).clamp(0.0, 1.0);
let p_blackout = (blackout_count as f64 / n).clamp(0.0, 1.0);
let risk_score = (expected_annual_loss_mwh / 10_000.0 * 100.0).clamp(0.0, 100.0);
RiskMatrix {
expected_annual_loss_mwh,
p_cascade,
p_blackout,
risk_score,
worst_case_loss_mwh: worst_loss_mwh,
}
}
pub fn evaluate_defense_investment(
&mut self,
new_defense: DefenseLayer,
attack_scenarios: &[(CpAttackScenario, Vec<f64>, Vec<f64>)],
defense_cost_usd: f64,
voll_usd_per_mwh: f64,
) -> DefenseRoi {
let baseline = self.monte_carlo_risk_assessment(attack_scenarios);
self.defense_layers.push(new_defense);
let with_defense = self.monte_carlo_risk_assessment(attack_scenarios);
self.defense_layers.pop();
let baseline_score = baseline.risk_score.max(1e-9);
let new_score = with_defense.risk_score;
let risk_reduction_pct =
((baseline_score - new_score) / baseline_score * 100.0).clamp(0.0, 100.0);
let risk_reduction_mwh_per_year =
(baseline.expected_annual_loss_mwh - with_defense.expected_annual_loss_mwh).max(0.0);
let annual_benefit_usd = risk_reduction_mwh_per_year * voll_usd_per_mwh;
let benefit_to_cost_ratio = if defense_cost_usd > 0.0 {
annual_benefit_usd / defense_cost_usd
} else {
f64::INFINITY
};
let payback_years = if annual_benefit_usd > 0.0 {
defense_cost_usd / annual_benefit_usd
} else {
f64::INFINITY
};
DefenseRoi {
risk_reduction_pct,
risk_reduction_mwh_per_year,
benefit_to_cost_ratio,
payback_years,
}
}
pub fn detect_anomaly(
&self,
measurements_normal: &[f64],
measurements_current: &[f64],
) -> AnomalyReport {
let n = measurements_normal.len().min(measurements_current.len());
if n == 0 {
return AnomalyReport {
chi_squared: 0.0,
threshold: 9.0,
anomaly_detected: false,
suspicious_measurements: vec![],
};
}
let mean: f64 = measurements_normal.iter().sum::<f64>() / n as f64;
let variance: f64 = measurements_normal
.iter()
.map(|&x| (x - mean).powi(2))
.sum::<f64>()
/ n as f64;
let std_dev = variance.sqrt().max(1e-9);
let residuals: Vec<f64> = (0..n)
.map(|i| (measurements_current[i] - measurements_normal[i]) / std_dev)
.collect();
let chi_squared: f64 = residuals.iter().map(|&r| r * r).sum::<f64>() / n as f64;
let threshold = 9.0_f64;
let anomaly_detected = chi_squared > threshold;
let suspicious_measurements: Vec<usize> = residuals
.iter()
.enumerate()
.filter(|(_, &r)| r.abs() > 3.0)
.map(|(i, _)| i)
.collect();
AnomalyReport {
chi_squared,
threshold,
anomaly_detected,
suspicious_measurements,
}
}
pub fn resilience_metrics(
&self,
impact_history: &[(AttackImpactResult, f64)],
) -> ResilienceMetrics {
if impact_history.is_empty() {
return ResilienceMetrics {
absorptive_capacity: 1.0,
adaptive_capacity: 1.0,
restorative_capacity: 1.0,
resilience_index: 1.0,
};
}
let max_shed_fraction = impact_history
.iter()
.map(|(r, total)| {
if *total > 0.0 {
r.load_shed_mw / total
} else {
0.0
}
})
.fold(0.0_f64, f64::max)
.clamp(0.0, 1.0);
let absorptive_capacity = (1.0 - max_shed_fraction).clamp(0.0, 1.0);
let mitigated = impact_history
.iter()
.filter(|(r, _)| !r.attack_success || r.impact_severity == ImpactSeverity::Negligible)
.count();
let adaptive_capacity = (mitigated as f64 / impact_history.len() as f64).clamp(0.0, 1.0);
let total_recovery: f64 = impact_history
.iter()
.map(|(r, _)| {
if r.attack_success {
r.recovery_time_s
} else {
0.0
}
})
.sum();
let n_successful = impact_history
.iter()
.filter(|(r, _)| r.attack_success)
.count();
let restorative_capacity = if n_successful == 0 {
1.0 } else {
let mean_recovery = total_recovery / n_successful as f64;
(1.0 / mean_recovery.max(1.0)).clamp(0.0, 1.0)
};
let product = absorptive_capacity * adaptive_capacity * restorative_capacity;
let resilience_index = product.powf(1.0 / 3.0).clamp(0.0, 1.0);
ResilienceMetrics {
absorptive_capacity,
adaptive_capacity,
restorative_capacity,
resilience_index,
}
}
fn compute_vector_impact(
&mut self,
scenario: &CpAttackScenario,
bus_voltages_pu: &[f64],
load_mw: &[f64],
) -> (f64, usize, f64, bool) {
let total_load: f64 = load_mw.iter().sum::<f64>().max(1.0);
let n_buses = bus_voltages_pu.len().max(1);
match &scenario.attack_vector {
AttackVector::FalseDataInjection {
target_buses,
magnitude_pu,
} => {
let fraction = (target_buses.len() as f64 / n_buses as f64).clamp(0.0, 1.0);
let load_shed_mw = magnitude_pu * fraction * total_load;
let voltage_violations = target_buses
.iter()
.filter(|&&b| {
bus_voltages_pu
.get(b)
.map(|&v| !(0.9..=1.1).contains(&v))
.unwrap_or(false)
})
.count()
+ (fraction * 2.0) as usize;
let freq_dev = 0.1 * magnitude_pu * fraction;
let cascade = load_shed_mw / total_load > 0.30;
(load_shed_mw, voltage_violations, freq_dev, cascade)
}
AttackVector::DenialOfService {
target_components,
duration_s,
} => {
let component_fraction = (target_components.len() as f64 / n_buses as f64).min(1.0);
let redispatch_fraction = component_fraction * (duration_s / 3600.0).min(1.0);
let load_shed_mw = redispatch_fraction * total_load * 0.5;
let voltage_violations = (component_fraction * n_buses as f64 * 0.2) as usize;
let freq_dev = 0.05 * component_fraction;
let cascade = load_shed_mw / total_load > 0.30;
(load_shed_mw, voltage_violations, freq_dev, cascade)
}
AttackVector::ReplayAttack { target: _, delay_s } => {
let delay_fraction = (delay_s / self.config.time_horizon_s).clamp(0.0, 1.0);
let load_shed_mw = delay_fraction * total_load * 0.10;
let voltage_violations = (delay_fraction * n_buses as f64 * 0.1) as usize;
let freq_dev = 0.02 * delay_fraction;
(load_shed_mw, voltage_violations, freq_dev, false)
}
AttackVector::CommandInjection {
target_controller: _,
false_setpoint_mw,
} => {
let imbalance = false_setpoint_mw.abs();
let load_shed_mw = (imbalance - total_load * 0.05).max(0.0).min(total_load);
let freq_dev = imbalance / (total_load * 10.0);
let voltage_violations = if freq_dev > 0.5 {
(n_buses / 4).max(1)
} else {
0
};
let cascade = load_shed_mw / total_load > 0.25;
(load_shed_mw, voltage_violations, freq_dev, cascade)
}
AttackVector::SensorTampering {
sensor_ids,
bias_pu,
} => {
let sensor_fraction = (sensor_ids.len() as f64 / n_buses as f64).min(1.0);
let load_shed_mw = bias_pu * sensor_fraction * total_load * 0.5;
let voltage_violations = sensor_ids.len().min(n_buses / 2);
let freq_dev = 0.05 * bias_pu * sensor_fraction;
(load_shed_mw, voltage_violations, freq_dev, false)
}
AttackVector::LoadAlteringAttack {
target_buses,
delta_mw,
} => {
let n_target = target_buses.len() as f64;
let total_delta = (delta_mw.abs() * n_target).min(total_load);
let load_shed_mw = if *delta_mw > 0.0 {
(total_delta - total_load * 0.10).max(0.0)
} else {
0.0
};
let voltage_violations = target_buses
.iter()
.filter(|&&b| b < n_buses)
.count()
.min(n_buses);
let freq_dev = total_delta / (total_load * 10.0);
let cascade = load_shed_mw / total_load > 0.30;
(load_shed_mw, voltage_violations, freq_dev, cascade)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_sim() -> CyberPhysicalSim {
CyberPhysicalSim::new(CyberPhysicalSimConfig {
num_buses: 10,
monte_carlo_runs: 200,
time_horizon_s: 3600.0,
recovery_time_s: 600.0,
})
}
fn flat_voltages(n: usize, v: f64) -> Vec<f64> {
vec![v; n]
}
fn uniform_loads(n: usize, p: f64) -> Vec<f64> {
vec![p; n]
}
#[test]
fn test_high_capability_weak_defense_high_penetration() {
let sim = default_sim(); let scenario = CpAttackScenario {
scenario_id: "T1".into(),
attacker_capability: 5, attack_vector: AttackVector::FalseDataInjection {
target_buses: vec![0, 1, 2],
magnitude_pu: 0.3,
},
timing: AttackTiming::PeakLoad,
objective: AttackObjective::MaximizeLoadShed,
};
let p = sim.calculate_penetration_probability(&scenario);
assert!(
p > 0.05,
"Nation-state attacker with no defense should have high penetration: {p:.4}"
);
}
#[test]
fn test_multiple_defense_layers_reduce_probability() {
let scenario = CpAttackScenario {
scenario_id: "T2".into(),
attacker_capability: 5,
attack_vector: AttackVector::FalseDataInjection {
target_buses: vec![0, 1, 2, 3, 4],
magnitude_pu: 0.5,
},
timing: AttackTiming::MinimumInertia,
objective: AttackObjective::TriggerCascade,
};
let sim_no_defense = default_sim();
let p_no_defense = sim_no_defense.calculate_penetration_probability(&scenario);
let mut sim_layered = default_sim();
sim_layered.add_defense(DefenseLayer::Firewall { effectiveness: 0.8 });
sim_layered.add_defense(DefenseLayer::IntrusionDetection {
detection_rate: 0.7,
false_positive_rate: 0.05,
});
sim_layered.add_defense(DefenseLayer::Encryption {
key_strength_bits: 256,
});
let p_layered = sim_layered.calculate_penetration_probability(&scenario);
assert!(
p_layered < p_no_defense,
"Layered defense must reduce penetration probability: {p_layered:.6} vs {p_no_defense:.6}"
);
}
#[test]
fn test_fdi_load_shed_proportional_to_magnitude() {
let voltages = flat_voltages(10, 1.0);
let loads = uniform_loads(10, 100.0);
let mut sim_low = CyberPhysicalSim {
config: CyberPhysicalSimConfig {
num_buses: 10,
monte_carlo_runs: 1,
..Default::default()
},
defense_layers: vec![],
lcg_state: 0, };
let scenario_low = CpAttackScenario {
scenario_id: "low".into(),
attacker_capability: 5,
attack_vector: AttackVector::FalseDataInjection {
target_buses: vec![0, 1, 2, 3, 4],
magnitude_pu: 0.1,
},
timing: AttackTiming::PeakLoad,
objective: AttackObjective::MaximizeLoadShed,
};
let scenario_high = CpAttackScenario {
scenario_id: "high".into(),
attacker_capability: 5,
attack_vector: AttackVector::FalseDataInjection {
target_buses: vec![0, 1, 2, 3, 4],
magnitude_pu: 0.5,
},
timing: AttackTiming::PeakLoad,
objective: AttackObjective::MaximizeLoadShed,
};
let (shed_low, _, _, _) = sim_low.compute_vector_impact(&scenario_low, &voltages, &loads);
let (shed_high, _, _, _) = sim_low.compute_vector_impact(&scenario_high, &voltages, &loads);
assert!(
shed_high > shed_low,
"Higher FDI magnitude should cause more load shed: {shed_high:.2} vs {shed_low:.2}"
);
}
#[test]
fn test_dos_attack_severe_impact() {
let voltages = flat_voltages(10, 1.0);
let loads = uniform_loads(10, 100.0);
let mut sim = CyberPhysicalSim {
config: CyberPhysicalSimConfig {
num_buses: 10,
monte_carlo_runs: 1,
time_horizon_s: 3600.0,
recovery_time_s: 600.0,
},
defense_layers: vec![],
lcg_state: 0,
};
let scenario = CpAttackScenario {
scenario_id: "dos".into(),
attacker_capability: 5,
attack_vector: AttackVector::DenialOfService {
target_components: (0..8).map(|i| format!("RTU-{i}")).collect(),
duration_s: 3600.0,
},
timing: AttackTiming::PostFault,
objective: AttackObjective::MaximizeLoadShed,
};
let (shed, violations, _freq, _cascade) =
sim.compute_vector_impact(&scenario, &voltages, &loads);
assert!(
shed > 0.0,
"DoS on 8/10 components should cause load shed: {shed:.2} MW"
);
assert!(
violations > 0,
"DoS on critical components should cause voltage violations"
);
}
#[test]
fn test_monte_carlo_valid_output_ranges() {
let mut sim = CyberPhysicalSim::new(CyberPhysicalSimConfig {
num_buses: 5,
monte_carlo_runs: 50,
time_horizon_s: 3600.0,
recovery_time_s: 300.0,
});
let voltages = flat_voltages(5, 1.0);
let loads = uniform_loads(5, 50.0);
let scenarios = vec![
(
CpAttackScenario {
scenario_id: "mc1".into(),
attacker_capability: 3,
attack_vector: AttackVector::LoadAlteringAttack {
target_buses: vec![0, 1],
delta_mw: 30.0,
},
timing: AttackTiming::PeakLoad,
objective: AttackObjective::MaximizeLoadShed,
},
voltages.clone(),
loads.clone(),
),
(
CpAttackScenario {
scenario_id: "mc2".into(),
attacker_capability: 4,
attack_vector: AttackVector::CommandInjection {
target_controller: "AGC".into(),
false_setpoint_mw: 200.0,
},
timing: AttackTiming::MinimumInertia,
objective: AttackObjective::TriggerCascade,
},
voltages.clone(),
loads.clone(),
),
];
let risk = sim.monte_carlo_risk_assessment(&scenarios);
assert!(
risk.risk_score >= 0.0 && risk.risk_score <= 100.0,
"Risk score must be in [0,100]: {:.2}",
risk.risk_score
);
assert!(
(0.0..=1.0).contains(&risk.p_cascade),
"P(cascade) must be in [0,1]: {:.4}",
risk.p_cascade
);
assert!(
(0.0..=1.0).contains(&risk.p_blackout),
"P(blackout) must be in [0,1]: {:.4}",
risk.p_blackout
);
assert!(
risk.expected_annual_loss_mwh >= 0.0,
"Expected annual loss must be non-negative"
);
}
#[test]
fn test_anomaly_detection_at_3_sigma() {
let sim = default_sim();
let normal: Vec<f64> = vec![1.0; 20];
let mut current = normal.clone();
current[5] = 1.0 + 20.0;
let report = sim.detect_anomaly(&normal, ¤t);
assert!(
report.anomaly_detected,
"Large measurement corruption (20σ) must be detected; chi²={:.2}, threshold={:.2}",
report.chi_squared, report.threshold
);
assert!(
report.suspicious_measurements.contains(&5),
"Corrupted index 5 must be flagged; suspicious: {:?}",
report.suspicious_measurements
);
assert!(
report.chi_squared > report.threshold,
"chi² must exceed threshold: {:.2} vs {:.2}",
report.chi_squared,
report.threshold
);
}
#[test]
fn test_defense_roi_stronger_is_better_roi() {
let voltages = flat_voltages(10, 1.0);
let loads = uniform_loads(10, 100.0);
let scenarios = vec![(
CpAttackScenario {
scenario_id: "roi".into(),
attacker_capability: 4,
attack_vector: AttackVector::FalseDataInjection {
target_buses: vec![0, 1, 2, 3],
magnitude_pu: 0.4,
},
timing: AttackTiming::PeakLoad,
objective: AttackObjective::MaximizeLoadShed,
},
voltages.clone(),
loads.clone(),
)];
let mut sim = CyberPhysicalSim::new(CyberPhysicalSimConfig {
num_buses: 10,
monte_carlo_runs: 50,
time_horizon_s: 3600.0,
recovery_time_s: 600.0,
});
let roi_weak = sim.evaluate_defense_investment(
DefenseLayer::Firewall { effectiveness: 0.3 },
&scenarios,
10_000.0,
3_000.0,
);
let roi_strong = sim.evaluate_defense_investment(
DefenseLayer::Firewall { effectiveness: 0.9 },
&scenarios,
10_000.0,
3_000.0,
);
assert!(
roi_strong.risk_reduction_pct >= roi_weak.risk_reduction_pct,
"Stronger defense should reduce risk more: strong={:.2}% vs weak={:.2}%",
roi_strong.risk_reduction_pct,
roi_weak.risk_reduction_pct
);
}
#[test]
fn test_resilience_no_attacks_perfect_index() {
let sim = default_sim();
let metrics = sim.resilience_metrics(&[]);
assert!(
(metrics.resilience_index - 1.0).abs() < 1e-9,
"No attacks → resilience_index must be 1.0; got {:.6}",
metrics.resilience_index
);
assert!(
(metrics.absorptive_capacity - 1.0).abs() < 1e-9,
"absorptive_capacity must be 1.0 with no attacks"
);
assert!(
(metrics.adaptive_capacity - 1.0).abs() < 1e-9,
"adaptive_capacity must be 1.0 with no attacks"
);
assert!(
(metrics.restorative_capacity - 1.0).abs() < 1e-9,
"restorative_capacity must be 1.0 with no attacks"
);
}
#[test]
fn test_physical_security_layer_effectiveness() {
let low = DefenseLayer::PhysicalSecurity {
protection_level: 1,
};
let high = DefenseLayer::PhysicalSecurity {
protection_level: 5,
};
assert!(
high.layer_effectiveness() > low.layer_effectiveness(),
"Higher protection level must have higher effectiveness: {:.3} vs {:.3}",
high.layer_effectiveness(),
low.layer_effectiveness()
);
assert!(
(low.layer_effectiveness() - 0.0).abs() < 1e-9,
"Level 1 protection should have 0 effectiveness"
);
assert!(
(high.layer_effectiveness() - 1.0).abs() < 1e-9,
"Level 5 protection should have 1.0 effectiveness"
);
}
#[test]
fn test_resilience_all_failed_attacks_max_adaptive() {
let sim = default_sim();
let history: Vec<(AttackImpactResult, f64)> = (0..5)
.map(|_| {
(
AttackImpactResult {
attack_success: false,
penetration_probability: 0.1,
load_shed_mw: 0.0,
voltage_violations: 0,
frequency_deviation_hz: 0.0,
cascade_triggered: false,
recovery_time_s: 0.0,
impact_severity: ImpactSeverity::Negligible,
},
1000.0,
)
})
.collect();
let metrics = sim.resilience_metrics(&history);
assert!(
(metrics.adaptive_capacity - 1.0).abs() < 1e-9,
"All failed attacks → adaptive_capacity must be 1.0; got {:.6}",
metrics.adaptive_capacity
);
assert!(
(metrics.absorptive_capacity - 1.0).abs() < 1e-9,
"No load shed → absorptive_capacity must be 1.0; got {:.6}",
metrics.absorptive_capacity
);
}
}