use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterruptionEvent {
pub duration_min: f64,
pub customers_affected: u64,
pub cause: Option<String>,
}
impl InterruptionEvent {
pub fn is_momentary(&self) -> bool {
self.duration_min < 5.0
}
pub fn is_sustained(&self) -> bool {
!self.is_momentary()
}
pub fn customer_minutes(&self) -> f64 {
self.duration_min * self.customers_affected as f64
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReliabilityIndices1366 {
pub saidi_min: f64,
pub saifi: f64,
pub caidi_min: f64,
pub maifi: f64,
pub asai: f64,
pub total_cmi: f64,
pub n_sustained: usize,
pub total_customers: u64,
}
impl ReliabilityIndices1366 {
pub fn compute(events: &[InterruptionEvent], total_customers: u64, period_hours: f64) -> Self {
let n = total_customers as f64;
if n < 1.0 || period_hours <= 0.0 {
return Self::zero(total_customers);
}
let sustained: Vec<&InterruptionEvent> =
events.iter().filter(|e| e.is_sustained()).collect();
let momentary: Vec<&InterruptionEvent> =
events.iter().filter(|e| e.is_momentary()).collect();
let total_cmi: f64 = sustained.iter().map(|e| e.customer_minutes()).sum();
let saidi_min = total_cmi / n;
let total_customer_interruptions: f64 =
sustained.iter().map(|e| e.customers_affected as f64).sum();
let saifi = total_customer_interruptions / n;
let caidi_min = if saifi > 1e-12 {
saidi_min / saifi
} else {
0.0
};
let momentary_customer_events: f64 =
momentary.iter().map(|e| e.customers_affected as f64).sum();
let maifi = momentary_customer_events / n;
let period_min = period_hours * 60.0;
let asai = 1.0 - saidi_min / period_min;
Self {
saidi_min,
saifi,
caidi_min,
maifi,
asai: asai.clamp(0.0, 1.0),
total_cmi,
n_sustained: sustained.len(),
total_customers,
}
}
fn zero(total_customers: u64) -> Self {
Self {
saidi_min: 0.0,
saifi: 0.0,
caidi_min: 0.0,
maifi: 0.0,
asai: 1.0,
total_cmi: 0.0,
n_sustained: 0,
total_customers,
}
}
pub fn performance_tier(&self) -> &'static str {
match self.saidi_min as u64 {
0..=60 => "Excellent",
61..=120 => "Good",
121..=240 => "Average",
_ => "Below Average",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopologyMetrics {
pub n_buses: usize,
pub n_branches: usize,
pub meshedness: f64,
pub average_degree: f64,
pub max_degree: usize,
pub diameter: usize,
pub average_path_length: f64,
pub n_components: usize,
}
impl TopologyMetrics {
pub fn compute(n_buses: usize, edges: &[(usize, usize)]) -> Self {
let n_branches = edges.len();
let mut degree = vec![0usize; n_buses];
for &(u, v) in edges {
if u < n_buses {
degree[u] += 1;
}
if v < n_buses {
degree[v] += 1;
}
}
let avg_degree = degree.iter().sum::<usize>() as f64 / n_buses.max(1) as f64;
let max_degree = *degree.iter().max().unwrap_or(&0);
let meshedness = if n_buses >= 3 {
let numer = (n_branches as f64 - n_buses as f64 + 1.0).max(0.0);
let denom = (2.0 * n_buses as f64 - 5.0).max(1.0);
(numer / denom).min(1.0)
} else {
0.0
};
let (diameter, avg_path, n_components) = bfs_metrics(n_buses, edges);
Self {
n_buses,
n_branches,
meshedness,
average_degree: avg_degree,
max_degree,
diameter,
average_path_length: avg_path,
n_components,
}
}
pub fn small_world_index(&self) -> f64 {
if self.n_buses < 2 {
return 1.0;
}
let n = self.n_buses as f64;
let expected_random_path = if self.average_degree > 1.0 {
n.ln() / self.average_degree.ln()
} else {
n
};
if self.average_path_length > 0.0 {
expected_random_path / self.average_path_length
} else {
1.0
}
}
pub fn is_radial(&self) -> bool {
self.meshedness < 1e-9
}
}
fn bfs_metrics(n: usize, edges: &[(usize, usize)]) -> (usize, f64, usize) {
let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
for &(u, v) in edges {
if u < n && v < n {
adj[u].push(v);
adj[v].push(u);
}
}
let mut total_path = 0usize;
let mut n_pairs = 0usize;
let mut diameter = 0usize;
let mut visited_global = vec![false; n];
let mut n_components = 0;
for start in 0..n {
if visited_global[start] {
continue;
}
n_components += 1;
let mut dist = vec![usize::MAX; n];
dist[start] = 0;
let mut queue = std::collections::VecDeque::new();
queue.push_back(start);
while let Some(u) = queue.pop_front() {
visited_global[u] = true;
for &v in &adj[u] {
if dist[v] == usize::MAX {
dist[v] = dist[u] + 1;
queue.push_back(v);
}
}
}
for d in dist.iter() {
if *d != usize::MAX && *d > 0 {
total_path += d;
n_pairs += 1;
if *d > diameter {
diameter = *d;
}
}
}
}
let avg_path = if n_pairs > 0 {
total_path as f64 / n_pairs as f64
} else {
0.0
};
(diameter, avg_path, n_components)
}
pub fn n1_connectivity_index(n_buses: usize, edges: &[(usize, usize)]) -> f64 {
if edges.is_empty() {
return 1.0;
}
let mut n_connected = 0usize;
for i in 0..edges.len() {
let reduced: Vec<(usize, usize)> = edges
.iter()
.enumerate()
.filter(|&(j, _)| j != i)
.map(|(_, &e)| e)
.collect();
let (_, _, nc) = bfs_metrics(n_buses, &reduced);
if nc == 1 {
n_connected += 1;
}
}
n_connected as f64 / edges.len() as f64
}
pub fn electrical_distance_matrix(b_bus: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = b_bus.len();
if n == 0 {
return vec![];
}
let x_bus = invert_bbus(b_bus);
let mut dist = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..n {
dist[i][j] = x_bus[i][i] + x_bus[j][j] - 2.0 * x_bus[i][j];
}
}
dist
}
fn invert_bbus(mat: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = mat.len();
let mut m: Vec<Vec<f64>> = (0..n)
.map(|i| {
let mut row = mat[i].clone();
for j in 0..n {
row.push(if i == j { 1.0 } else { 0.0 });
}
row
})
.collect();
for col in 0..n {
let mut max_row = col;
let mut max_val = m[col][col].abs();
for (r, row) in m.iter().enumerate().skip(col + 1) {
if row[col].abs() > max_val {
max_val = row[col].abs();
max_row = r;
}
}
m.swap(col, max_row);
let pivot = m[col][col];
if pivot.abs() < 1e-14 {
continue; }
#[allow(clippy::needless_range_loop)]
for j in col..2 * n {
m[col][j] /= pivot;
}
for row in 0..n {
if row == col {
continue;
}
let factor = m[row][col];
#[allow(clippy::needless_range_loop)]
for j in col..2 * n {
let sub = factor * m[col][j];
m[row][j] -= sub;
}
}
}
(0..n).map(|i| m[i][n..].to_vec()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_events() -> Vec<InterruptionEvent> {
vec![
InterruptionEvent {
duration_min: 30.0,
customers_affected: 100,
cause: None,
},
InterruptionEvent {
duration_min: 120.0,
customers_affected: 200,
cause: None,
},
InterruptionEvent {
duration_min: 2.0,
customers_affected: 50,
cause: None,
}, ]
}
#[test]
fn test_saidi_calculation() {
let events = simple_events();
let idx = ReliabilityIndices1366::compute(&events, 1000, 8760.0);
assert!(
(idx.saidi_min - 27.0).abs() < 1e-6,
"SAIDI={:.4}",
idx.saidi_min
);
}
#[test]
fn test_saifi_calculation() {
let events = simple_events();
let idx = ReliabilityIndices1366::compute(&events, 1000, 8760.0);
assert!((idx.saifi - 0.3).abs() < 1e-6, "SAIFI={:.4}", idx.saifi);
}
#[test]
fn test_caidi_calculation() {
let events = simple_events();
let idx = ReliabilityIndices1366::compute(&events, 1000, 8760.0);
assert!(
(idx.caidi_min - 90.0).abs() < 1e-6,
"CAIDI={:.4}",
idx.caidi_min
);
}
#[test]
fn test_maifi_momentary() {
let events = simple_events();
let idx = ReliabilityIndices1366::compute(&events, 1000, 8760.0);
assert!((idx.maifi - 0.05).abs() < 1e-6, "MAIFI={:.4}", idx.maifi);
}
#[test]
fn test_asai_near_one_low_interruptions() {
let events = vec![InterruptionEvent {
duration_min: 10.0,
customers_affected: 10,
cause: None,
}];
let idx = ReliabilityIndices1366::compute(&events, 100_000, 8760.0);
assert!(idx.asai > 0.9999, "ASAI should be near 1: {:.6}", idx.asai);
}
#[test]
fn test_no_events_perfect_reliability() {
let idx = ReliabilityIndices1366::compute(&[], 1000, 8760.0);
assert_eq!(idx.saidi_min, 0.0);
assert_eq!(idx.saifi, 0.0);
assert_eq!(idx.asai, 1.0);
}
#[test]
fn test_performance_tier_excellent() {
let idx = ReliabilityIndices1366::compute(&[], 1000, 8760.0);
assert_eq!(idx.performance_tier(), "Excellent");
}
#[test]
fn test_momentary_classification() {
let e1 = InterruptionEvent {
duration_min: 4.9,
customers_affected: 1,
cause: None,
};
let e2 = InterruptionEvent {
duration_min: 5.0,
customers_affected: 1,
cause: None,
};
assert!(e1.is_momentary());
assert!(e2.is_sustained());
}
#[test]
fn test_radial_network_meshedness_zero() {
let edges = [(0, 1), (0, 2), (0, 3), (0, 4)];
let m = TopologyMetrics::compute(5, &edges);
assert!(
m.is_radial(),
"Star network should be radial: γ={:.4}",
m.meshedness
);
}
#[test]
fn test_ring_network_meshedness_positive() {
let edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)];
let m = TopologyMetrics::compute(5, &edges);
assert!(
m.meshedness > 0.0,
"Ring should have positive meshedness: γ={:.4}",
m.meshedness
);
}
#[test]
fn test_average_degree_star() {
let edges = [(0, 1), (0, 2), (0, 3)];
let m = TopologyMetrics::compute(4, &edges);
assert!((m.average_degree - 1.5).abs() < 1e-10);
}
#[test]
fn test_diameter_line_graph() {
let edges = [(0, 1), (1, 2), (2, 3), (3, 4)];
let m = TopologyMetrics::compute(5, &edges);
assert_eq!(m.diameter, 4, "Line graph diameter should be 4");
}
#[test]
fn test_connected_components_disconnected() {
let edges = [(0, 1), (2, 3)];
let m = TopologyMetrics::compute(4, &edges);
assert_eq!(m.n_components, 2);
}
#[test]
fn test_connected_single_component() {
let edges = [(0, 1), (1, 2), (2, 0)];
let m = TopologyMetrics::compute(3, &edges);
assert_eq!(m.n_components, 1);
}
#[test]
fn test_n1_ring_all_redundant() {
let edges = [(0, 1), (1, 2), (2, 3), (3, 0)];
let idx = n1_connectivity_index(4, &edges);
assert!(
(idx - 1.0).abs() < 1e-10,
"Ring should be N-1 redundant: {idx:.4}"
);
}
#[test]
fn test_n1_radial_none_redundant() {
let edges = [(0, 1), (1, 2), (2, 3)];
let idx = n1_connectivity_index(4, &edges);
assert!(
idx < 1e-10,
"Radial line should have 0 N-1 redundancy: {idx:.4}"
);
}
#[test]
fn test_electrical_distance_self_zero() {
let b = vec![vec![3.0, -1.0], vec![-1.0, 3.0]];
let dist = electrical_distance_matrix(&b);
assert_eq!(dist.len(), 2);
assert!(dist[0][0].abs() < 1e-6, "Self-distance should be 0");
assert!(dist[1][1].abs() < 1e-6);
}
#[test]
fn test_electrical_distance_symmetric() {
let b = vec![
vec![4.0, -2.0, -1.0],
vec![-2.0, 4.0, -1.0],
vec![-1.0, -1.0, 3.0],
];
let dist = electrical_distance_matrix(&b);
#[allow(clippy::needless_range_loop)]
for i in 0..3 {
for j in 0..3 {
assert!(
(dist[i][j] - dist[j][i]).abs() < 1e-8,
"D should be symmetric"
);
}
}
}
#[test]
fn test_electrical_distance_nonnegative() {
let b = vec![
vec![5.0, -1.0, -1.0],
vec![-1.0, 5.0, -1.0],
vec![-1.0, -1.0, 5.0],
];
let dist = electrical_distance_matrix(&b);
for row in &dist {
for &d in row {
assert!(
d >= -1e-6,
"Electrical distance must be non-negative: {d:.4}"
);
}
}
}
#[test]
fn test_topology_metrics_n_branches() {
let edges = [(0, 1), (1, 2), (2, 3)];
let m = TopologyMetrics::compute(4, &edges);
assert_eq!(m.n_branches, 3);
assert_eq!(m.n_buses, 4);
}
}