use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum HealthStatus {
Critical,
Warning,
Healthy,
}
impl HealthStatus {
pub fn from_score(score: f64) -> Self {
if score >= 80.0 {
Self::Healthy
} else if (60.0..80.0).contains(&score) {
Self::Warning
} else {
Self::Critical
}
}
pub fn is_critical(self) -> bool {
self == Self::Critical
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ComponentCategory {
Transmission,
Distribution,
Generation,
Protection,
Communication,
}
#[derive(Debug, Clone)]
pub struct ComponentHealth {
pub id: String,
pub name: String,
pub category: ComponentCategory,
pub score: f64,
pub weight: f64,
pub timestamp_s: f64,
}
#[derive(Debug, Clone)]
pub struct CategoryAggregate {
pub category: ComponentCategory,
pub weighted_score: f64,
pub status: HealthStatus,
pub component_count: usize,
pub critical_count: usize,
}
#[derive(Debug, Clone)]
pub struct GridHealthReport {
pub composite_score: f64,
pub overall_status: HealthStatus,
pub categories: Vec<CategoryAggregate>,
pub critical_components: Vec<String>,
pub total_components: usize,
pub healthy_count: usize,
pub warning_count: usize,
pub critical_count: usize,
}
impl GridHealthReport {
pub fn all_healthy(&self) -> bool {
self.critical_count == 0 && self.warning_count == 0
}
pub fn critical_fraction(&self) -> f64 {
if self.total_components == 0 {
return 0.0;
}
self.critical_count as f64 / self.total_components as f64
}
}
#[derive(Debug, Clone)]
pub struct CategoryWeight {
pub transmission: f64,
pub distribution: f64,
pub generation: f64,
pub protection: f64,
pub communication: f64,
}
impl Default for CategoryWeight {
fn default() -> Self {
Self {
transmission: 1.0,
distribution: 1.0,
generation: 1.0,
protection: 1.0,
communication: 1.0,
}
}
}
impl CategoryWeight {
pub fn get(&self, category: ComponentCategory) -> f64 {
match category {
ComponentCategory::Transmission => self.transmission,
ComponentCategory::Distribution => self.distribution,
ComponentCategory::Generation => self.generation,
ComponentCategory::Protection => self.protection,
ComponentCategory::Communication => self.communication,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum GridHealthError {
NoComponents,
InvalidWeight {
component_id: String,
},
ScoreOutOfRange {
component_id: String,
score: f64,
},
}
impl std::fmt::Display for GridHealthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoComponents => write!(f, "no component health readings supplied"),
Self::InvalidWeight { component_id } => {
write!(f, "component '{}' has non-positive weight", component_id)
}
Self::ScoreOutOfRange {
component_id,
score,
} => {
write!(
f,
"component '{}' has score {:.2} outside [0, 100]",
component_id, score
)
}
}
}
}
impl std::error::Error for GridHealthError {}
#[derive(Debug, Clone, Default)]
pub struct GridHealthScorer {
pub category_weights: CategoryWeight,
}
impl GridHealthScorer {
pub fn new() -> Self {
Self::default()
}
pub fn with_weights(weights: CategoryWeight) -> Self {
Self {
category_weights: weights,
}
}
pub fn compute(
&self,
components: &[ComponentHealth],
) -> Result<GridHealthReport, GridHealthError> {
if components.is_empty() {
return Err(GridHealthError::NoComponents);
}
for c in components {
if c.weight <= 0.0 {
return Err(GridHealthError::InvalidWeight {
component_id: c.id.clone(),
});
}
if !(0.0..=100.0).contains(&c.score) {
return Err(GridHealthError::ScoreOutOfRange {
component_id: c.id.clone(),
score: c.score,
});
}
}
let mut by_category: HashMap<u8, Vec<&ComponentHealth>> = HashMap::new();
for c in components {
by_category
.entry(category_key(c.category))
.or_default()
.push(c);
}
let mut categories: Vec<CategoryAggregate> = Vec::new();
for (key, members) in &by_category {
let cat = category_from_key(*key);
let total_weight: f64 = members.iter().map(|m| m.weight).sum();
let weighted_score: f64 = members.iter().map(|m| m.weight * m.score).sum::<f64>()
/ total_weight.max(f64::EPSILON);
let critical_count = members
.iter()
.filter(|m| HealthStatus::from_score(m.score).is_critical())
.count();
categories.push(CategoryAggregate {
category: cat,
weighted_score,
status: HealthStatus::from_score(weighted_score),
component_count: members.len(),
critical_count,
});
}
let mut numerator = 0.0_f64;
let mut denominator = 0.0_f64;
for agg in &categories {
let w = self.category_weights.get(agg.category);
numerator += w * agg.weighted_score;
denominator += w;
}
let composite_score = if denominator > f64::EPSILON {
numerator / denominator
} else {
0.0
};
let mut healthy_count = 0usize;
let mut warning_count = 0usize;
let mut critical_count = 0usize;
let mut critical_components: Vec<(String, f64)> = Vec::new();
for c in components {
match HealthStatus::from_score(c.score) {
HealthStatus::Healthy => healthy_count += 1,
HealthStatus::Warning => warning_count += 1,
HealthStatus::Critical => {
critical_count += 1;
critical_components.push((c.id.clone(), c.score));
}
}
}
critical_components
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let critical_ids: Vec<String> = critical_components.into_iter().map(|(id, _)| id).collect();
categories.sort_by_key(|a| category_key(a.category));
Ok(GridHealthReport {
composite_score,
overall_status: HealthStatus::from_score(composite_score),
categories,
critical_components: critical_ids,
total_components: components.len(),
healthy_count,
warning_count,
critical_count,
})
}
}
fn category_key(cat: ComponentCategory) -> u8 {
match cat {
ComponentCategory::Transmission => 0,
ComponentCategory::Distribution => 1,
ComponentCategory::Generation => 2,
ComponentCategory::Protection => 3,
ComponentCategory::Communication => 4,
}
}
fn category_from_key(key: u8) -> ComponentCategory {
match key {
0 => ComponentCategory::Transmission,
1 => ComponentCategory::Distribution,
2 => ComponentCategory::Generation,
3 => ComponentCategory::Protection,
4 => ComponentCategory::Communication,
_ => ComponentCategory::Communication, }
}
pub fn weighted_average_score(components: &[ComponentHealth]) -> Option<f64> {
if components.is_empty() {
return None;
}
let total_weight: f64 = components.iter().map(|c| c.weight).sum();
if total_weight <= f64::EPSILON {
return None;
}
let numerator: f64 = components.iter().map(|c| c.weight * c.score).sum();
Some(numerator / total_weight)
}
pub fn components_below_threshold(components: &[ComponentHealth], threshold: f64) -> Vec<&str> {
let mut below: Vec<(&str, f64)> = components
.iter()
.filter(|c| c.score <= threshold)
.map(|c| (c.id.as_str(), c.score))
.collect();
below.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
below.into_iter().map(|(id, _)| id).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_component(
id: &str,
category: ComponentCategory,
score: f64,
weight: f64,
) -> ComponentHealth {
ComponentHealth {
id: id.to_string(),
name: format!("Component {}", id),
category,
score,
weight,
timestamp_s: 0.0,
}
}
#[test]
fn test_health_status_boundary_classification() {
assert_eq!(HealthStatus::from_score(100.0), HealthStatus::Healthy);
assert_eq!(HealthStatus::from_score(80.0), HealthStatus::Healthy);
assert_eq!(HealthStatus::from_score(79.9), HealthStatus::Warning);
assert_eq!(HealthStatus::from_score(60.0), HealthStatus::Warning);
assert_eq!(HealthStatus::from_score(59.9), HealthStatus::Critical);
assert_eq!(HealthStatus::from_score(0.0), HealthStatus::Critical);
}
#[test]
fn test_empty_components_returns_error() {
let scorer = GridHealthScorer::new();
let result = scorer.compute(&[]);
assert!(
matches!(result, Err(GridHealthError::NoComponents)),
"empty component list must yield NoComponents error"
);
}
#[test]
fn test_all_healthy_components_yield_healthy_report() {
let components = vec![
make_component("T1", ComponentCategory::Transmission, 90.0, 1.0),
make_component("T2", ComponentCategory::Transmission, 85.0, 1.0),
make_component("G1", ComponentCategory::Generation, 95.0, 1.0),
];
let report = GridHealthScorer::new()
.compute(&components)
.expect("valid components must not error");
assert_eq!(report.overall_status, HealthStatus::Healthy);
assert!(
report.all_healthy(),
"no warning/critical components expected"
);
assert_eq!(report.critical_count, 0);
assert_eq!(report.warning_count, 0);
assert_eq!(report.healthy_count, 3);
}
#[test]
fn test_composite_score_single_category_equal_weights() {
let components = vec![
make_component("A", ComponentCategory::Transmission, 70.0, 1.0),
make_component("B", ComponentCategory::Transmission, 80.0, 1.0),
make_component("C", ComponentCategory::Transmission, 90.0, 1.0),
];
let report = GridHealthScorer::new()
.compute(&components)
.expect("valid inputs");
assert!(
(report.composite_score - 80.0).abs() < 1e-9,
"composite score should be 80.0, got {:.4}",
report.composite_score
);
}
#[test]
fn test_category_weighted_average_non_uniform_weights() {
let components = vec![
make_component("G_low", ComponentCategory::Generation, 40.0, 3.0),
make_component("G_high", ComponentCategory::Generation, 100.0, 1.0),
];
let report = GridHealthScorer::new()
.compute(&components)
.expect("valid inputs");
let gen_agg = report
.categories
.iter()
.find(|a| a.category == ComponentCategory::Generation)
.expect("Generation category must be present");
assert!(
(gen_agg.weighted_score - 55.0).abs() < 1e-9,
"generation weighted score should be 55.0, got {:.4}",
gen_agg.weighted_score
);
assert_eq!(
gen_agg.status,
HealthStatus::Critical,
"55.0 < 60.0 must classify as Critical"
);
}
#[test]
fn test_critical_components_sorted_worst_first() {
let components = vec![
make_component("C30", ComponentCategory::Protection, 30.0, 1.0),
make_component("C10", ComponentCategory::Protection, 10.0, 1.0),
make_component("C50", ComponentCategory::Protection, 50.0, 1.0),
];
let report = GridHealthScorer::new()
.compute(&components)
.expect("valid inputs");
assert_eq!(report.critical_count, 3);
assert_eq!(
report.critical_components,
vec!["C10", "C30", "C50"],
"critical components must be sorted ascending by score"
);
}
#[test]
fn test_multi_category_composite_with_custom_weights() {
let components = vec![
make_component("TX", ComponentCategory::Transmission, 90.0, 1.0),
make_component("PR", ComponentCategory::Protection, 30.0, 1.0),
];
let weights = CategoryWeight {
transmission: 1.0,
distribution: 1.0,
generation: 1.0,
protection: 2.0,
communication: 1.0,
};
let scorer = GridHealthScorer::with_weights(weights);
let report = scorer.compute(&components).expect("valid inputs");
assert!(
(report.composite_score - 50.0).abs() < 1e-9,
"composite should be 50.0, got {:.4}",
report.composite_score
);
assert_eq!(
report.overall_status,
HealthStatus::Critical,
"50.0 must classify as Critical"
);
}
#[test]
fn test_weighted_average_score_utility() {
let components = vec![
make_component("X1", ComponentCategory::Distribution, 60.0, 2.0),
make_component("X2", ComponentCategory::Distribution, 80.0, 2.0),
];
let avg = weighted_average_score(&components).expect("non-empty list must return Some");
assert!(
(avg - 70.0).abs() < 1e-9,
"weighted average should be 70.0, got {:.4}",
avg
);
assert!(
weighted_average_score(&[]).is_none(),
"empty slice must return None"
);
}
#[test]
fn test_components_below_threshold_sorted_and_edge_cases() {
let components = vec![
make_component("H1", ComponentCategory::Communication, 90.0, 1.0),
make_component("W1", ComponentCategory::Communication, 70.0, 1.0),
make_component("C1", ComponentCategory::Communication, 50.0, 1.0),
make_component("C2", ComponentCategory::Communication, 30.0, 1.0),
];
let below_60 = components_below_threshold(&components, 60.0);
assert_eq!(below_60, vec!["C2", "C1"], "must return worst-first");
let all_below = components_below_threshold(&components, 100.0);
assert_eq!(
all_below.len(),
4,
"threshold 100 should include all components"
);
let none_below = components_below_threshold(&components, -1.0);
assert!(
none_below.is_empty(),
"threshold -1 should match no components"
);
}
#[test]
fn test_critical_fraction_and_status_counts() {
let components = vec![
make_component("H", ComponentCategory::Transmission, 90.0, 1.0),
make_component("W", ComponentCategory::Transmission, 65.0, 1.0),
make_component("C1", ComponentCategory::Transmission, 50.0, 1.0),
make_component("C2", ComponentCategory::Transmission, 20.0, 1.0),
];
let report = GridHealthScorer::new()
.compute(&components)
.expect("valid inputs");
assert_eq!(report.total_components, 4);
assert_eq!(report.healthy_count, 1);
assert_eq!(report.warning_count, 1);
assert_eq!(report.critical_count, 2);
assert!(
(report.critical_fraction() - 0.5).abs() < 1e-9,
"2 out of 4 components critical → fraction 0.5"
);
}
#[test]
fn test_score_out_of_range_returns_error() {
let bad = vec![make_component(
"X",
ComponentCategory::Generation,
101.0,
1.0,
)];
let result = GridHealthScorer::new().compute(&bad);
assert!(
matches!(result, Err(GridHealthError::ScoreOutOfRange { .. })),
"score > 100 must yield ScoreOutOfRange error"
);
let neg = vec![make_component(
"Y",
ComponentCategory::Generation,
-0.1,
1.0,
)];
let result2 = GridHealthScorer::new().compute(&neg);
assert!(
matches!(result2, Err(GridHealthError::ScoreOutOfRange { .. })),
"negative score must yield ScoreOutOfRange error"
);
}
#[test]
fn test_health_status_is_critical_and_ordering() {
assert!(HealthStatus::Critical.is_critical());
assert!(!HealthStatus::Warning.is_critical());
assert!(!HealthStatus::Healthy.is_critical());
assert!(HealthStatus::Critical < HealthStatus::Warning);
assert!(HealthStatus::Warning < HealthStatus::Healthy);
}
}