use std::f64::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommProtocol {
IEC61850Goose,
IEC61850Sampled,
Dnp3Serial,
Dnp3Tcp,
ModbusTcp,
IEC60870_104,
Mqtt,
FiveGUrllc,
Fiber,
Wireless4G,
}
impl CommProtocol {
pub fn name(&self) -> &'static str {
match self {
Self::IEC61850Goose => "IEC 61850 GOOSE",
Self::IEC61850Sampled => "IEC 61850 Sampled Values",
Self::Dnp3Serial => "DNP3 Serial",
Self::Dnp3Tcp => "DNP3 TCP",
Self::ModbusTcp => "Modbus TCP",
Self::IEC60870_104 => "IEC 60870-5-104",
Self::Mqtt => "MQTT",
Self::FiveGUrllc => "5G URLLC",
Self::Fiber => "Fiber",
Self::Wireless4G => "4G Wireless",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MessagePriority {
Critical = 3,
High = 2,
Normal = 1,
Low = 0,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommMessageType {
ProtectionTrip,
ControlCommand,
Measurement,
StateEstimation,
Alarm,
Configuration,
}
impl CommMessageType {
pub fn deadline_ms(&self) -> f64 {
match self {
Self::ProtectionTrip => 4.0,
Self::ControlCommand => 100.0,
Self::Measurement => 100.0,
Self::StateEstimation => 500.0,
Self::Alarm => 1_000.0,
Self::Configuration => f64::INFINITY,
}
}
}
#[derive(Debug, Clone)]
pub struct CommLink {
pub link_id: String,
pub protocol: CommProtocol,
pub bandwidth_kbps: f64,
pub nominal_latency_ms: f64,
pub max_latency_ms: f64,
pub packet_loss_rate: f64,
pub jitter_ms: f64,
pub availability: f64,
}
#[derive(Debug, Clone)]
pub struct CommMessage {
pub message_id: u64,
pub priority: MessagePriority,
pub payload_bytes: usize,
pub timestamp_sent_ms: f64,
pub source: String,
pub destination: String,
pub message_type: CommMessageType,
}
#[derive(Debug, Clone)]
pub enum CongestionModel {
None,
Simple {
utilization_threshold: f64,
},
DetailedQueue {
queue_depth: usize,
},
}
#[derive(Debug, Clone)]
pub struct CommNetworkConfig {
pub simulation_duration_ms: f64,
pub congestion_model: CongestionModel,
pub security_overhead_ms: f64,
}
impl Default for CommNetworkConfig {
fn default() -> Self {
Self {
simulation_duration_ms: 10_000.0,
congestion_model: CongestionModel::None,
security_overhead_ms: 0.5,
}
}
}
#[derive(Debug, Clone)]
pub struct DeliveryResult {
pub delivered: bool,
pub latency_ms: f64,
pub deadline_met: bool,
pub hops: usize,
}
#[derive(Debug, Clone)]
pub struct ControlLoopAnalysis {
pub total_latency_ms: f64,
pub stability_margin_ms: f64,
pub stability_maintained: bool,
pub nyquist_limit_ms: f64,
}
#[derive(Debug, Clone)]
pub struct ProtectionAssessment {
pub fraction_on_time: f64,
pub worst_case_latency_ms: f64,
pub missed_deadlines: usize,
pub reliability: f64,
}
#[derive(Debug, Clone)]
pub struct LatencyStats {
pub mean_ms: f64,
pub std_ms: f64,
pub p95_ms: f64,
pub p99_ms: f64,
pub max_ms: f64,
}
#[derive(Debug, Clone)]
pub struct AttackImpact {
pub messages_delayed: usize,
pub messages_lost: usize,
pub max_latency_ms: f64,
pub control_disrupted: bool,
}
#[derive(Debug, Clone)]
pub struct UpgradeRecommendation {
pub link_id: String,
pub issue: String,
pub recommended_protocol: CommProtocol,
pub expected_improvement: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttackType {
DenialOfService,
ManInTheMiddle,
}
#[derive(Debug, Clone)]
pub struct PerformanceRequirements {
pub max_latency_ms: f64,
pub max_loss_rate: f64,
pub min_availability: f64,
}
pub struct CommNetworkSim {
pub links: Vec<CommLink>,
pub config: CommNetworkConfig,
lcg_state: u64,
}
impl CommNetworkSim {
pub fn new(config: CommNetworkConfig, seed: u64) -> Self {
Self {
links: Vec::new(),
config,
lcg_state: seed.wrapping_add(1),
}
}
pub fn with_default_config(seed: u64) -> Self {
Self::new(CommNetworkConfig::default(), seed)
}
pub fn add_link(&mut self, link: CommLink) -> usize {
let idx = self.links.len();
self.links.push(link);
idx
}
fn lcg_next(&mut self) -> u64 {
self.lcg_state = self
.lcg_state
.wrapping_mul(6_364_136_223_846_793_005_u64)
.wrapping_add(1_442_695_040_888_963_407_u64);
self.lcg_state
}
fn lcg_f64(&mut self) -> f64 {
(self.lcg_next() >> 11) as f64 / (1u64 << 53) as f64
}
fn lcg_normal(&mut self) -> f64 {
let u1 = self.lcg_f64().max(f64::EPSILON);
let u2 = self.lcg_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
fn sample_latency(
&mut self,
link: &CommLink,
payload_bytes: usize,
current_utilization: f64,
) -> Option<f64> {
let loss_roll = self.lcg_f64();
if loss_roll < link.packet_loss_rate {
return None;
}
let jitter = self.lcg_normal() * link.jitter_ms;
let base = (link.nominal_latency_ms + jitter).max(0.0);
let lat = base + self.config.security_overhead_ms;
let lat = match &self.config.congestion_model {
CongestionModel::None => lat,
CongestionModel::Simple {
utilization_threshold,
} => {
if current_utilization >= *utilization_threshold {
lat * 2.0
} else {
lat
}
}
CongestionModel::DetailedQueue { queue_depth } => {
let bw_bytes_per_ms = link.bandwidth_kbps * 1_000.0 / 8.0 / 1_000.0;
let tx_ms = if bw_bytes_per_ms > 0.0 {
payload_bytes as f64 / bw_bytes_per_ms
} else {
0.0
};
let occupancy = (current_utilization * *queue_depth as f64)
.min(*queue_depth as f64)
/ (*queue_depth as f64).max(1.0);
lat + tx_ms * occupancy
}
};
Some(lat.min(link.max_latency_ms))
}
pub fn simulate_message_delivery(
&mut self,
message: &CommMessage,
link_idx: usize,
) -> Result<DeliveryResult, String> {
let link = self
.links
.get(link_idx)
.ok_or_else(|| format!("link index {link_idx} out of range"))?
.clone();
let util = self.calculate_channel_utilization(
std::slice::from_ref(message),
link_idx,
self.config.simulation_duration_ms,
)?;
match self.sample_latency(&link, message.payload_bytes, util) {
None => Ok(DeliveryResult {
delivered: false,
latency_ms: 0.0,
deadline_met: false,
hops: 1,
}),
Some(lat) => {
let deadline = message.message_type.deadline_ms();
Ok(DeliveryResult {
delivered: true,
latency_ms: lat,
deadline_met: lat <= deadline,
hops: 1,
})
}
}
}
pub fn analyze_control_loop_delay(
&mut self,
link_indices: &[usize],
message_sequence: &[CommMessage],
control_bandwidth_hz: f64,
) -> Result<ControlLoopAnalysis, String> {
if message_sequence.len() < link_indices.len() {
return Err(format!(
"message_sequence length {} < link_indices length {}",
message_sequence.len(),
link_indices.len()
));
}
let nyquist_limit_ms = if control_bandwidth_hz > 0.0 {
1_000.0 / (4.0 * control_bandwidth_hz)
} else {
f64::INFINITY
};
let mut total_latency_ms = 0.0_f64;
for (hop, &idx) in link_indices.iter().enumerate() {
let link = self
.links
.get(idx)
.ok_or_else(|| format!("link index {idx} out of range"))?
.clone();
let msg = &message_sequence[hop];
let util = self.calculate_channel_utilization(
std::slice::from_ref(msg),
idx,
self.config.simulation_duration_ms,
)?;
let lat = self
.sample_latency(&link, msg.payload_bytes, util)
.unwrap_or(link.max_latency_ms);
total_latency_ms += lat;
}
let stability_margin_ms = nyquist_limit_ms;
let stability_maintained = total_latency_ms <= stability_margin_ms;
Ok(ControlLoopAnalysis {
total_latency_ms,
stability_margin_ms,
stability_maintained,
nyquist_limit_ms,
})
}
pub fn calculate_channel_utilization(
&self,
messages: &[CommMessage],
link_idx: usize,
window_ms: f64,
) -> Result<f64, String> {
let link = self
.links
.get(link_idx)
.ok_or_else(|| format!("link index {link_idx} out of range"))?;
if window_ms <= 0.0 {
return Err("window_ms must be positive".to_string());
}
let total_bytes: usize = messages.iter().map(|m| m.payload_bytes).sum();
let total_bits = total_bytes as f64 * 8.0;
let capacity_bits = link.bandwidth_kbps * 1_000.0 * (window_ms / 1_000.0);
if capacity_bits <= 0.0 {
return Err("link bandwidth_kbps must be positive".to_string());
}
Ok((total_bits / capacity_bits).min(1.0))
}
pub fn assess_protection_performance(
&mut self,
protection_messages: &[CommMessage],
link_idx: usize,
) -> Result<ProtectionAssessment, String> {
let link = self
.links
.get(link_idx)
.ok_or_else(|| format!("link index {link_idx} out of range"))?
.clone();
let is_goose = link.protocol == CommProtocol::IEC61850Goose;
let mut on_time = 0usize;
let mut missed = 0usize;
let mut delivered = 0usize;
let mut worst_latency = 0.0_f64;
for msg in protection_messages {
let deadline = if is_goose {
4.0_f64
} else {
msg.message_type.deadline_ms()
};
let util = self.calculate_channel_utilization(
std::slice::from_ref(msg),
link_idx,
self.config.simulation_duration_ms,
)?;
match self.sample_latency(&link, msg.payload_bytes, util) {
None => {
missed += 1;
}
Some(lat) => {
delivered += 1;
if lat > worst_latency {
worst_latency = lat;
}
if lat <= deadline {
on_time += 1;
} else {
missed += 1;
}
}
}
}
let total = protection_messages.len();
let fraction_on_time = if total == 0 {
1.0
} else {
on_time as f64 / total as f64
};
let reliability = if total == 0 {
1.0
} else {
delivered as f64 / total as f64
};
Ok(ProtectionAssessment {
fraction_on_time,
worst_case_latency_ms: worst_latency,
missed_deadlines: missed,
reliability,
})
}
pub fn network_availability(
&self,
link_indices: &[usize],
parallel: bool,
) -> Result<f64, String> {
if link_indices.is_empty() {
return Ok(1.0);
}
let mut result = 1.0_f64;
for &idx in link_indices {
let link = self
.links
.get(idx)
.ok_or_else(|| format!("link index {idx} out of range"))?;
if parallel {
result *= 1.0 - link.availability;
} else {
result *= link.availability;
}
}
if parallel {
Ok(1.0 - result)
} else {
Ok(result)
}
}
pub fn simulate_cyber_attack_impact(
&mut self,
attack_type: AttackType,
link_idx: usize,
messages: &[CommMessage],
duration_ms: f64,
) -> Result<AttackImpact, String> {
let mut attacked_link = self
.links
.get(link_idx)
.ok_or_else(|| format!("link index {link_idx} out of range"))?
.clone();
let mitm_overhead_ms = match attack_type {
AttackType::DenialOfService => {
attacked_link.nominal_latency_ms *= 10.0;
attacked_link.nominal_latency_ms = attacked_link
.nominal_latency_ms
.min(attacked_link.max_latency_ms);
0.0
}
AttackType::ManInTheMiddle => 5.0,
};
let original_link = self.links[link_idx].clone();
self.links[link_idx] = attacked_link;
let mut delayed = 0usize;
let mut lost = 0usize;
let mut max_lat = 0.0_f64;
let mut control_disrupted = false;
let forced_util = if attack_type == AttackType::DenialOfService {
1.0
} else {
0.0
};
for msg in messages {
let only_in_window = msg.timestamp_sent_ms < duration_ms;
if !only_in_window {
continue;
}
let link_snapshot = self.links[link_idx].clone();
let lat_opt = self.sample_latency(&link_snapshot, msg.payload_bytes, forced_util);
match lat_opt {
None => {
lost += 1;
if msg.message_type == CommMessageType::ProtectionTrip
|| msg.message_type == CommMessageType::ControlCommand
{
control_disrupted = true;
}
}
Some(mut lat) => {
lat += mitm_overhead_ms;
if lat > max_lat {
max_lat = lat;
}
let deadline = msg.message_type.deadline_ms();
if lat > deadline {
delayed += 1;
if msg.message_type == CommMessageType::ProtectionTrip
|| msg.message_type == CommMessageType::ControlCommand
{
control_disrupted = true;
}
}
}
}
}
self.links[link_idx] = original_link;
Ok(AttackImpact {
messages_delayed: delayed,
messages_lost: lost,
max_latency_ms: max_lat,
control_disrupted,
})
}
pub fn monte_carlo_latency_distribution(
&mut self,
link_idx: usize,
n_samples: usize,
) -> Result<LatencyStats, String> {
if n_samples == 0 {
return Err("n_samples must be > 0".to_string());
}
let link = self
.links
.get(link_idx)
.ok_or_else(|| format!("link index {link_idx} out of range"))?
.clone();
let mut samples: Vec<f64> = Vec::with_capacity(n_samples);
for _ in 0..n_samples {
if let Some(lat) = self.sample_latency(&link, 64, 0.0) {
samples.push(lat);
}
}
if samples.is_empty() {
return Ok(LatencyStats {
mean_ms: 0.0,
std_ms: 0.0,
p95_ms: 0.0,
p99_ms: 0.0,
max_ms: 0.0,
});
}
let n = samples.len() as f64;
let mean_ms = samples.iter().sum::<f64>() / n;
let variance = samples.iter().map(|&x| (x - mean_ms).powi(2)).sum::<f64>() / n;
let std_ms = variance.sqrt();
let mut sorted = samples.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p95_ms = percentile_sorted(&sorted, 95.0);
let p99_ms = percentile_sorted(&sorted, 99.0);
let max_ms = sorted.last().copied().unwrap_or(0.0);
Ok(LatencyStats {
mean_ms,
std_ms,
p95_ms,
p99_ms,
max_ms,
})
}
pub fn recommend_comm_upgrade(
&self,
current_analysis: &LatencyStats,
requirements: &PerformanceRequirements,
) -> Vec<UpgradeRecommendation> {
let mut recs: Vec<UpgradeRecommendation> = Vec::new();
for link in &self.links {
if current_analysis.p99_ms > requirements.max_latency_ms {
let (rec_proto, improvement) = recommend_faster_protocol(link.protocol);
recs.push(UpgradeRecommendation {
link_id: link.link_id.clone(),
issue: format!(
"P99 latency {:.2} ms exceeds requirement {:.2} ms",
current_analysis.p99_ms, requirements.max_latency_ms
),
recommended_protocol: rec_proto,
expected_improvement: improvement,
});
}
if link.packet_loss_rate > requirements.max_loss_rate.max(1e-3) {
recs.push(UpgradeRecommendation {
link_id: link.link_id.clone(),
issue: format!(
"packet loss rate {:.4} exceeds threshold {:.4}; redundant path recommended",
link.packet_loss_rate,
requirements.max_loss_rate
),
recommended_protocol: CommProtocol::Fiber,
expected_improvement:
"Redundant fibre path reduces effective loss to < 1e-6".to_string(),
});
}
if link.availability < requirements.min_availability {
recs.push(UpgradeRecommendation {
link_id: link.link_id.clone(),
issue: format!(
"availability {:.4} below required {:.4}; backup link recommended",
link.availability, requirements.min_availability
),
recommended_protocol: CommProtocol::FiveGUrllc,
expected_improvement:
"5G URLLC backup raises composite availability above 0.9999".to_string(),
});
}
}
recs
}
}
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let idx = ((p / 100.0) * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn recommend_faster_protocol(current: CommProtocol) -> (CommProtocol, String) {
match current {
CommProtocol::Wireless4G => (
CommProtocol::FiveGUrllc,
"5G URLLC reduces latency from 20–100 ms to < 1 ms".to_string(),
),
CommProtocol::Dnp3Serial => (
CommProtocol::Dnp3Tcp,
"DNP3/TCP reduces latency from 50–500 ms to 10–100 ms".to_string(),
),
CommProtocol::Dnp3Tcp | CommProtocol::ModbusTcp | CommProtocol::IEC60870_104 => (
CommProtocol::IEC61850Goose,
"IEC 61850 GOOSE reduces latency to < 4 ms".to_string(),
),
CommProtocol::Mqtt => (
CommProtocol::Fiber,
"Dedicated fibre reduces latency to < 2 ms".to_string(),
),
CommProtocol::IEC61850Goose | CommProtocol::IEC61850Sampled | CommProtocol::FiveGUrllc => (
CommProtocol::FiveGUrllc,
"Already on a low-latency protocol; consider 5G URLLC if not already deployed"
.to_string(),
),
CommProtocol::Fiber => (
CommProtocol::IEC61850Sampled,
"IEC 61850 Sampled Values over existing fibre achieves < 1 ms".to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_link(id: &str, protocol: CommProtocol, loss: f64, latency_ms: f64) -> CommLink {
CommLink {
link_id: id.to_string(),
protocol,
bandwidth_kbps: 100_000.0, nominal_latency_ms: latency_ms,
max_latency_ms: latency_ms * 10.0 + 50.0,
packet_loss_rate: loss,
jitter_ms: latency_ms * 0.1,
availability: 0.9999,
}
}
fn make_message(id: u64, msg_type: CommMessageType, bytes: usize) -> CommMessage {
CommMessage {
message_id: id,
priority: MessagePriority::Normal,
payload_bytes: bytes,
timestamp_sent_ms: id as f64,
source: "A".to_string(),
destination: "B".to_string(),
message_type: msg_type,
}
}
fn default_sim(seed: u64) -> CommNetworkSim {
CommNetworkSim::with_default_config(seed)
}
#[test]
fn test_high_loss_rate_majority_dropped() {
let mut sim = default_sim(42);
let idx = sim.add_link(make_link("lossy", CommProtocol::Wireless4G, 0.8, 50.0));
let mut lost = 0usize;
let trials = 100usize;
for i in 0..trials {
let msg = make_message(i as u64, CommMessageType::Measurement, 128);
let result = sim
.simulate_message_delivery(&msg, idx)
.expect("simulate_message_delivery failed");
if !result.delivered {
lost += 1;
}
}
assert!(
lost > 50,
"Expected >50 % packet loss with 80 % loss rate, got {lost}/{trials} lost"
);
}
#[test]
fn test_zero_loss_all_delivered() {
let mut sim = default_sim(1234);
let idx = sim.add_link(make_link("perfect", CommProtocol::Fiber, 0.0, 1.0));
let trials = 50usize;
for i in 0..trials {
let msg = make_message(i as u64, CommMessageType::Measurement, 64);
let result = sim
.simulate_message_delivery(&msg, idx)
.expect("simulate_message_delivery failed");
assert!(
result.delivered,
"Zero-loss link must deliver every message; message {i} was lost"
);
}
}
#[test]
fn test_latency_stats_ordering() {
let mut sim = CommNetworkSim::new(
CommNetworkConfig {
simulation_duration_ms: 10_000.0,
congestion_model: CongestionModel::None,
security_overhead_ms: 0.0,
},
7,
);
let idx = sim.add_link(make_link("jittery", CommProtocol::Wireless4G, 0.0, 50.0));
let stats = sim
.monte_carlo_latency_distribution(idx, 500)
.expect("monte_carlo failed");
assert!(
stats.p95_ms >= stats.mean_ms,
"P95 ({:.3}) must be >= mean ({:.3})",
stats.p95_ms,
stats.mean_ms
);
assert!(
stats.p99_ms >= stats.p95_ms,
"P99 ({:.3}) must be >= P95 ({:.3})",
stats.p99_ms,
stats.p95_ms
);
assert!(
stats.max_ms >= stats.p99_ms,
"max ({:.3}) must be >= P99 ({:.3})",
stats.max_ms,
stats.p99_ms
);
}
#[test]
fn test_protection_goose_all_on_time() {
let mut sim = CommNetworkSim::new(
CommNetworkConfig {
simulation_duration_ms: 10_000.0,
congestion_model: CongestionModel::None,
security_overhead_ms: 0.0, },
99,
);
let idx = sim.add_link(CommLink {
link_id: "goose-link".to_string(),
protocol: CommProtocol::IEC61850Goose,
bandwidth_kbps: 100_000.0,
nominal_latency_ms: 1.0,
max_latency_ms: 4.0,
packet_loss_rate: 0.0,
jitter_ms: 0.1,
availability: 1.0,
});
let msgs: Vec<CommMessage> = (0..20)
.map(|i| make_message(i, CommMessageType::ProtectionTrip, 128))
.collect();
let assessment = sim
.assess_protection_performance(&msgs, idx)
.expect("assess_protection_performance failed");
assert!(
(assessment.fraction_on_time - 1.0).abs() < 1e-9,
"GOOSE link with 1 ms latency must deliver all messages on time; fraction={:.4}",
assessment.fraction_on_time
);
assert_eq!(assessment.missed_deadlines, 0);
}
#[test]
fn test_channel_utilization_calculation() {
let mut sim = default_sim(0);
sim.add_link(CommLink {
link_id: "bw-link".to_string(),
protocol: CommProtocol::Fiber,
bandwidth_kbps: 1_000.0, nominal_latency_ms: 1.0,
max_latency_ms: 10.0,
packet_loss_rate: 0.0,
jitter_ms: 0.0,
availability: 1.0,
});
let msgs: Vec<CommMessage> = (0..10)
.map(|i| make_message(i, CommMessageType::Measurement, 1_000))
.collect();
let util = sim
.calculate_channel_utilization(&msgs, 0, 100.0) .expect("utilization failed");
assert!(
(util - 0.8).abs() < 1e-9,
"Expected utilization 0.8, got {util:.6}"
);
}
#[test]
fn test_control_loop_large_delay_instability() {
let mut sim = CommNetworkSim::new(
CommNetworkConfig {
simulation_duration_ms: 60_000.0,
congestion_model: CongestionModel::None,
security_overhead_ms: 0.0,
},
55,
);
let idx0 = sim.add_link(make_link("slow1", CommProtocol::Dnp3Serial, 0.0, 200.0));
let idx1 = sim.add_link(make_link("slow2", CommProtocol::Dnp3Serial, 0.0, 200.0));
let msgs = vec![
make_message(0, CommMessageType::ControlCommand, 64),
make_message(1, CommMessageType::ControlCommand, 64),
];
let analysis = sim
.analyze_control_loop_delay(&[idx0, idx1], &msgs, 1.0)
.expect("analyze_control_loop_delay failed");
assert!(
!analysis.stability_maintained,
"Expected instability with {:.1} ms total delay vs {:.1} ms limit",
analysis.total_latency_ms, analysis.stability_margin_ms
);
}
#[test]
fn test_series_network_availability() {
let mut sim = default_sim(0);
let idx0 = sim.add_link(CommLink {
link_id: "L0".to_string(),
protocol: CommProtocol::Fiber,
bandwidth_kbps: 10_000.0,
nominal_latency_ms: 1.0,
max_latency_ms: 5.0,
packet_loss_rate: 0.0,
jitter_ms: 0.0,
availability: 0.99,
});
let idx1 = sim.add_link(CommLink {
link_id: "L1".to_string(),
protocol: CommProtocol::Fiber,
bandwidth_kbps: 10_000.0,
nominal_latency_ms: 1.0,
max_latency_ms: 5.0,
packet_loss_rate: 0.0,
jitter_ms: 0.0,
availability: 0.98,
});
let avail = sim
.network_availability(&[idx0, idx1], false)
.expect("network_availability failed");
let expected = 0.99 * 0.98;
assert!(
(avail - expected).abs() < 1e-12,
"Series availability must be product: expected {expected:.6}, got {avail:.6}"
);
}
#[test]
fn test_monte_carlo_stats_within_expected_range() {
let mut sim = CommNetworkSim::new(
CommNetworkConfig {
simulation_duration_ms: 60_000.0,
congestion_model: CongestionModel::None,
security_overhead_ms: 0.0,
},
314,
);
let idx = sim.add_link(CommLink {
link_id: "fiber".to_string(),
protocol: CommProtocol::Fiber,
bandwidth_kbps: 1_000_000.0,
nominal_latency_ms: 2.0,
max_latency_ms: 20.0,
packet_loss_rate: 0.0,
jitter_ms: 0.2,
availability: 1.0,
});
let stats = sim
.monte_carlo_latency_distribution(idx, 500)
.expect("monte_carlo failed");
assert!(
(stats.mean_ms - 2.0).abs() < 1.0,
"Mean latency {:.3} ms should be near nominal 2.0 ms",
stats.mean_ms
);
assert!(
stats.std_ms < 1.5,
"Std dev {:.3} ms is implausibly large for a 0.2 ms jitter link",
stats.std_ms
);
assert!(
stats.p99_ms >= stats.mean_ms,
"P99 ({:.3}) must be >= mean ({:.3})",
stats.p99_ms,
stats.mean_ms
);
}
#[test]
fn test_parallel_network_availability() {
let mut sim = default_sim(0);
let idx0 = sim.add_link(CommLink {
link_id: "P0".to_string(),
protocol: CommProtocol::Fiber,
bandwidth_kbps: 10_000.0,
nominal_latency_ms: 1.0,
max_latency_ms: 5.0,
packet_loss_rate: 0.0,
jitter_ms: 0.0,
availability: 0.9,
});
let idx1 = sim.add_link(CommLink {
link_id: "P1".to_string(),
protocol: CommProtocol::Wireless4G,
bandwidth_kbps: 10_000.0,
nominal_latency_ms: 50.0,
max_latency_ms: 200.0,
packet_loss_rate: 0.0,
jitter_ms: 5.0,
availability: 0.9,
});
let avail = sim
.network_availability(&[idx0, idx1], true)
.expect("network_availability failed");
let expected = 1.0 - (0.1 * 0.1); assert!(
(avail - expected).abs() < 1e-12,
"Parallel availability must be 1-(1-a0)(1-a1): expected {expected:.4}, got {avail:.4}"
);
}
#[test]
fn test_upgrade_recommendation_high_latency() {
let mut sim = default_sim(0);
sim.add_link(make_link("slow", CommProtocol::Dnp3Serial, 0.0, 300.0));
let stats = LatencyStats {
mean_ms: 300.0,
std_ms: 50.0,
p95_ms: 380.0,
p99_ms: 400.0,
max_ms: 500.0,
};
let req = PerformanceRequirements {
max_latency_ms: 100.0,
max_loss_rate: 1e-3,
min_availability: 0.999,
};
let recs = sim.recommend_comm_upgrade(&stats, &req);
assert!(
!recs.is_empty(),
"Should produce at least one recommendation for a slow link"
);
assert!(
recs.iter()
.any(|r| r.issue.to_lowercase().contains("latency")),
"Expected a latency-related recommendation"
);
}
#[test]
fn test_protocol_names_non_empty() {
let protocols = [
CommProtocol::IEC61850Goose,
CommProtocol::IEC61850Sampled,
CommProtocol::Dnp3Serial,
CommProtocol::Dnp3Tcp,
CommProtocol::ModbusTcp,
CommProtocol::IEC60870_104,
CommProtocol::Mqtt,
CommProtocol::FiveGUrllc,
CommProtocol::Fiber,
CommProtocol::Wireless4G,
];
for proto in protocols {
let name = proto.name();
assert!(
!name.is_empty(),
"CommProtocol::{proto:?} must return a non-empty name"
);
}
}
#[test]
fn test_message_type_deadlines() {
assert!(
(CommMessageType::ProtectionTrip.deadline_ms() - 4.0).abs() < f64::EPSILON,
"ProtectionTrip deadline must be 4 ms"
);
assert!(
(CommMessageType::ControlCommand.deadline_ms() - 100.0).abs() < f64::EPSILON,
"ControlCommand deadline must be 100 ms"
);
assert!(
(CommMessageType::Measurement.deadline_ms() - 100.0).abs() < f64::EPSILON,
"Measurement deadline must be 100 ms"
);
assert!(
(CommMessageType::StateEstimation.deadline_ms() - 500.0).abs() < f64::EPSILON,
"StateEstimation deadline must be 500 ms"
);
assert!(
CommMessageType::Configuration.deadline_ms().is_infinite(),
"Configuration deadline must be infinite"
);
}
#[test]
fn test_out_of_range_link_index_errors() {
let mut sim = default_sim(0);
let msg = make_message(0, CommMessageType::Measurement, 64);
let err = sim.simulate_message_delivery(&msg, 0);
assert!(
err.is_err(),
"simulate_message_delivery on empty sim must return Err"
);
let util_err = sim.calculate_channel_utilization(std::slice::from_ref(&msg), 5, 1_000.0);
assert!(
util_err.is_err(),
"calculate_channel_utilization with invalid index must return Err"
);
}
#[test]
fn test_empty_link_set_availability_one() {
let sim = default_sim(0);
let series = sim
.network_availability(&[], false)
.expect("empty series must succeed");
assert!(
(series - 1.0).abs() < f64::EPSILON,
"Empty series availability must be 1.0, got {series}"
);
let parallel = sim
.network_availability(&[], true)
.expect("empty parallel must succeed");
assert!(
(parallel - 1.0).abs() < f64::EPSILON,
"Empty parallel availability must be 1.0, got {parallel}"
);
}
#[test]
fn test_simple_congestion_increases_latency() {
let config_no_cong = CommNetworkConfig {
simulation_duration_ms: 10_000.0,
congestion_model: CongestionModel::None,
security_overhead_ms: 0.0,
};
let config_cong = CommNetworkConfig {
simulation_duration_ms: 10_000.0,
congestion_model: CongestionModel::Simple {
utilization_threshold: 0.0, },
security_overhead_ms: 0.0,
};
let link = CommLink {
link_id: "cong-link".to_string(),
protocol: CommProtocol::Fiber,
bandwidth_kbps: 100_000.0,
nominal_latency_ms: 10.0,
max_latency_ms: 1_000.0,
packet_loss_rate: 0.0,
jitter_ms: 0.0, availability: 1.0,
};
let mut sim_none = CommNetworkSim::new(config_no_cong, 77);
let idx_none = sim_none.add_link(link.clone());
let stats_none = sim_none
.monte_carlo_latency_distribution(idx_none, 100)
.expect("monte_carlo (no congestion) failed");
let mut sim_cong = CommNetworkSim::new(config_cong, 77);
let idx_cong = sim_cong.add_link(link.clone());
let stats_cong = sim_cong
.monte_carlo_latency_distribution(idx_cong, 100)
.expect("monte_carlo (congestion) failed");
assert!(
stats_cong.mean_ms > stats_none.mean_ms,
"Congestion model must increase mean latency: cong={:.3} none={:.3}",
stats_cong.mean_ms,
stats_none.mean_ms
);
}
#[test]
fn test_dos_attack_raises_max_latency() {
let mut sim = CommNetworkSim::new(
CommNetworkConfig {
simulation_duration_ms: 60_000.0,
congestion_model: CongestionModel::None,
security_overhead_ms: 0.0,
},
888,
);
let idx = sim.add_link(CommLink {
link_id: "attack-link".to_string(),
protocol: CommProtocol::Dnp3Tcp,
bandwidth_kbps: 10_000.0,
nominal_latency_ms: 20.0,
max_latency_ms: 5_000.0, packet_loss_rate: 0.0,
jitter_ms: 1.0,
availability: 1.0,
});
let msgs: Vec<CommMessage> = (0..10)
.map(|i| make_message(i, CommMessageType::ControlCommand, 256))
.collect();
let impact = sim
.simulate_cyber_attack_impact(AttackType::DenialOfService, idx, &msgs, 60_000.0)
.expect("simulate_cyber_attack_impact failed");
assert!(
impact.max_latency_ms > 20.0,
"DoS attack must raise max latency above 20 ms (nominal), got {:.3}",
impact.max_latency_ms
);
}
#[test]
fn test_message_priority_ordering() {
assert!(
MessagePriority::Critical > MessagePriority::High,
"Critical must rank above High"
);
assert!(
MessagePriority::High > MessagePriority::Normal,
"High must rank above Normal"
);
assert!(
MessagePriority::Normal > MessagePriority::Low,
"Normal must rank above Low"
);
}
#[test]
fn test_add_link_returns_sequential_indices() {
let mut sim = default_sim(0);
let link_a = make_link("a", CommProtocol::Fiber, 0.0, 1.0);
let link_b = make_link("b", CommProtocol::Mqtt, 0.0, 10.0);
let link_c = make_link("c", CommProtocol::Wireless4G, 0.01, 50.0);
let idx_a = sim.add_link(link_a);
let idx_b = sim.add_link(link_b);
let idx_c = sim.add_link(link_c);
assert_eq!(idx_a, 0, "First link must get index 0");
assert_eq!(idx_b, 1, "Second link must get index 1");
assert_eq!(idx_c, 2, "Third link must get index 2");
assert_eq!(sim.links.len(), 3, "Sim must contain exactly 3 links");
}
}