use thiserror::Error;
#[derive(Debug, Error)]
pub enum HealthError {
#[error("equipment id {0} already registered")]
DuplicateId(usize),
#[error("invalid sensor reading for {field}: {value}")]
InvalidReading { field: &'static str, value: f64 },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MaintenanceAction {
NoAction,
IncreasedMonitoring,
ScheduledMaintenance {
within_months: usize,
},
UrgentMaintenance {
reason: String,
},
ImmediateOutage {
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct DgaResult {
pub hydrogen_ppm: f64,
pub methane_ppm: f64,
pub ethane_ppm: f64,
pub ethylene_ppm: f64,
pub acetylene_ppm: f64,
pub co_ppm: f64,
pub co2_ppm: f64,
pub o2_ppm: f64,
pub n2_ppm: f64,
pub total_dissolved_gas_ppm: f64,
}
impl DgaResult {
pub fn tdcg(&self) -> f64 {
self.hydrogen_ppm
+ self.methane_ppm
+ self.acetylene_ppm
+ self.ethylene_ppm
+ self.ethane_ppm
+ self.co_ppm
}
}
pub struct TransformerHealthIndicators {
pub id: usize,
pub age_years: f64,
pub loading_pct: f64,
pub hotspot_temp_c: f64,
pub top_oil_temp_c: f64,
pub moisture_ppm: f64,
pub acidity_mg_koh_per_g: f64,
pub dissolved_gas_analysis: DgaResult,
pub power_factor_pct: f64,
pub insulation_resistance_mohm: f64,
}
pub struct BreakerHealthIndicators {
pub id: usize,
pub age_years: f64,
pub operation_count: usize,
pub contact_wear_pct: f64,
pub sf6_pressure_pct: f64,
pub sf6_moisture_ppm: f64,
pub control_voltage_v: f64,
pub charging_current_a: f64,
pub timing_open_ms: f64,
pub timing_close_ms: f64,
pub arcing_current_ka_sq_cycles: f64,
}
pub struct HealthScore {
pub equipment_id: usize,
pub equipment_type: String,
pub overall_score: f64,
pub indicators: Vec<(String, f64, f64)>,
pub risk_level: RiskLevel,
pub remaining_life_years: f64,
pub maintenance_recommendation: MaintenanceAction,
}
impl HealthScore {
fn classify_risk(score: f64) -> RiskLevel {
if score >= 75.0 {
RiskLevel::Low
} else if score >= 50.0 {
RiskLevel::Medium
} else if score >= 25.0 {
RiskLevel::High
} else {
RiskLevel::Critical
}
}
fn recommend_action(
risk: &RiskLevel,
critical_reasons: &[&str],
urgent_reasons: &[&str],
) -> MaintenanceAction {
if !critical_reasons.is_empty() {
return MaintenanceAction::ImmediateOutage {
reason: critical_reasons.join("; "),
};
}
if !urgent_reasons.is_empty() {
return MaintenanceAction::UrgentMaintenance {
reason: urgent_reasons.join("; "),
};
}
match risk {
RiskLevel::Low => MaintenanceAction::NoAction,
RiskLevel::Medium => MaintenanceAction::IncreasedMonitoring,
RiskLevel::High => MaintenanceAction::ScheduledMaintenance { within_months: 6 },
RiskLevel::Critical => MaintenanceAction::ImmediateOutage {
reason: String::from("overall health score critical"),
},
}
}
}
pub struct EquipmentHealthConfig {
pub alarm_on_threshold: f64,
pub critical_threshold: f64,
pub monitoring_interval_s: f64,
}
impl Default for EquipmentHealthConfig {
fn default() -> Self {
Self {
alarm_on_threshold: 70.0,
critical_threshold: 30.0,
monitoring_interval_s: 300.0,
}
}
}
pub struct EquipmentHealthMonitor {
config: EquipmentHealthConfig,
}
impl EquipmentHealthMonitor {
pub fn new(config: EquipmentHealthConfig) -> Self {
Self { config }
}
pub fn score_transformer(&self, ind: &TransformerHealthIndicators) -> HealthScore {
const NOMINAL_LIFE: f64 = 40.0; const RATED_HOTSPOT: f64 = 98.0;
let mut indicators: Vec<(String, f64, f64)> = Vec::new();
let age_score = (100.0 - (ind.age_years / NOMINAL_LIFE) * 20.0).clamp(0.0, 100.0);
indicators.push(("age".into(), ind.age_years, age_score));
let thermal_score =
(100.0 - (ind.hotspot_temp_c - RATED_HOTSPOT).max(0.0) * 2.0).clamp(0.0, 100.0);
indicators.push(("hotspot_temp_c".into(), ind.hotspot_temp_c, thermal_score));
let moisture_score = (100.0 - ind.moisture_ppm * 2.0).clamp(0.0, 100.0);
indicators.push(("moisture_ppm".into(), ind.moisture_ppm, moisture_score));
let acid_score = (100.0 - ind.acidity_mg_koh_per_g * 200.0).clamp(0.0, 100.0);
indicators.push((
"acidity_mg_koh_per_g".into(),
ind.acidity_mg_koh_per_g,
acid_score,
));
let (_, severity) = self.rogers_ratio(&ind.dissolved_gas_analysis);
let dga_score = (100.0 - severity * 100.0).clamp(0.0, 100.0);
indicators.push(("dga_severity".into(), severity, dga_score));
let pf_score = (100.0 - ind.power_factor_pct * 50.0).clamp(0.0, 100.0);
indicators.push(("power_factor_pct".into(), ind.power_factor_pct, pf_score));
let weights = [0.20, 0.25, 0.20, 0.10, 0.15, 0.10];
let overall_score: f64 = indicators
.iter()
.zip(weights.iter())
.map(|((_n, _v, s), w)| s * w)
.sum();
let risk_level = HealthScore::classify_risk(overall_score);
let remaining_life_years = if overall_score > 0.0 {
(NOMINAL_LIFE - ind.age_years) * (overall_score / 100.0)
} else {
0.0
}
.max(0.0);
let mut critical_reasons: Vec<&str> = Vec::new();
let mut urgent_reasons: Vec<&str> = Vec::new();
if ind.moisture_ppm > 35.0 {
critical_reasons.push("extreme moisture (>35 ppm)");
}
if ind.hotspot_temp_c > 140.0 {
critical_reasons.push("hotspot temperature critical (>140 °C)");
}
if ind.dissolved_gas_analysis.acetylene_ppm > 35.0 {
critical_reasons.push("high acetylene (arcing fault indication)");
}
if ind.moisture_ppm > 20.0 && ind.moisture_ppm <= 35.0 {
urgent_reasons.push("moisture elevated (>20 ppm)");
}
if ind.acidity_mg_koh_per_g > 0.3 {
urgent_reasons.push("oil acidity high (>0.3 mg KOH/g)");
}
let recommendation =
HealthScore::recommend_action(&risk_level, &critical_reasons, &urgent_reasons);
HealthScore {
equipment_id: ind.id,
equipment_type: String::from("Transformer"),
overall_score,
indicators,
risk_level,
remaining_life_years,
maintenance_recommendation: recommendation,
}
}
pub fn score_breaker(&self, ind: &BreakerHealthIndicators) -> HealthScore {
const NOMINAL_LIFE: f64 = 30.0; const RATED_OPS: f64 = 10_000.0; const SPEC_OPEN_MS: f64 = 50.0; const SPEC_CLOSE_MS: f64 = 60.0;
let mut indicators: Vec<(String, f64, f64)> = Vec::new();
let sf6_score = if ind.sf6_pressure_pct >= 80.0 {
100.0
} else {
((ind.sf6_pressure_pct - 50.0) / 30.0 * 100.0).clamp(0.0, 100.0)
};
indicators.push(("sf6_pressure_pct".into(), ind.sf6_pressure_pct, sf6_score));
let wear_score = (100.0 - ind.contact_wear_pct).clamp(0.0, 100.0);
indicators.push(("contact_wear_pct".into(), ind.contact_wear_pct, wear_score));
let open_dev =
((ind.timing_open_ms - SPEC_OPEN_MS).abs() / SPEC_OPEN_MS * 100.0).min(100.0);
let close_dev =
((ind.timing_close_ms - SPEC_CLOSE_MS).abs() / SPEC_CLOSE_MS * 100.0).min(100.0);
let timing_score = (100.0 - (open_dev + close_dev) / 2.0).clamp(0.0, 100.0);
indicators.push((
"timing_deviation_pct".into(),
(open_dev + close_dev) / 2.0,
timing_score,
));
let ops_ratio = ind.operation_count as f64 / RATED_OPS;
let ops_score = (100.0 - ops_ratio * 100.0).clamp(0.0, 100.0);
indicators.push((
"operation_count".into(),
ind.operation_count as f64,
ops_score,
));
let age_score = (100.0 - (ind.age_years / NOMINAL_LIFE) * 20.0).clamp(0.0, 100.0);
indicators.push(("age_years".into(), ind.age_years, age_score));
let weights = [0.30, 0.25, 0.20, 0.15, 0.10];
let overall_score: f64 = indicators
.iter()
.zip(weights.iter())
.map(|((_n, _v, s), w)| s * w)
.sum();
let risk_level = HealthScore::classify_risk(overall_score);
let remaining_life_years =
((NOMINAL_LIFE - ind.age_years) * (overall_score / 100.0)).max(0.0);
let mut critical_reasons: Vec<&str> = Vec::new();
let mut urgent_reasons: Vec<&str> = Vec::new();
if ind.sf6_pressure_pct < 70.0 {
critical_reasons.push("SF6 pressure critically low (<70%)");
} else if ind.sf6_pressure_pct < 80.0 {
urgent_reasons.push("SF6 pressure low (<80%)");
}
if ind.contact_wear_pct > 90.0 {
urgent_reasons.push("contact wear excessive (>90%)");
}
if ind.sf6_moisture_ppm > 200.0 {
urgent_reasons.push("SF6 moisture high (>200 ppm)");
}
let recommendation =
HealthScore::recommend_action(&risk_level, &critical_reasons, &urgent_reasons);
HealthScore {
equipment_id: ind.id,
equipment_type: String::from("CircuitBreaker"),
overall_score,
indicators,
risk_level,
remaining_life_years,
maintenance_recommendation: recommendation,
}
}
pub fn rogers_ratio(&self, dga: &DgaResult) -> (String, f64) {
let r1 = if dga.hydrogen_ppm > 1e-6 {
dga.methane_ppm / dga.hydrogen_ppm
} else {
0.0
};
let r2 = if dga.ethylene_ppm > 1e-6 {
dga.acetylene_ppm / dga.ethylene_ppm
} else {
0.0
};
let r3 = if dga.ethane_ppm > 1e-6 {
dga.ethylene_ppm / dga.ethane_ppm
} else {
0.0
};
let fault_type = match (r1_code(r1), r2_code(r2), r3_code(r3)) {
(0, 0, 0) => "No fault (normal)",
(0, 0, 1) => "Low-energy thermal fault (T1 <300°C)",
(0, 0, 2) => "Low-energy thermal fault (T2 300–700°C)",
(0, 1, 0) => "No fault or PD",
(0, 1, 2) => "High-energy thermal fault (T3 >700°C)",
(1, 0, 0) => "Partial discharge (PD)",
(1, 0, 1) => "Partial discharge with low energy discharges",
(2, 0, 0) => "Electrical discharge (D1 low energy)",
(2, 0, 1) => "Electrical discharge (D2 high energy)",
(2, 1, 0) => "Electrical discharge with thermal overlay (DT)",
_ => "Mixed / indeterminate fault",
};
let tdcg = dga.tdcg();
let severity = if dga.acetylene_ppm > 35.0 || tdcg > 4630.0 {
1.0
} else if dga.acetylene_ppm > 9.0 || tdcg > 1920.0 {
0.75
} else if dga.acetylene_ppm > 4.0 || tdcg > 720.0 {
0.50
} else if tdcg > 300.0 {
0.25
} else {
0.05
};
(fault_type.into(), severity)
}
pub fn duval_triangle(&self, dga: &DgaResult) -> String {
let total = dga.methane_ppm + dga.ethylene_ppm + dga.acetylene_ppm;
if total < 1e-9 {
return String::from("No combustible hydrocarbons detected");
}
let pct_ch4 = dga.methane_ppm / total * 100.0;
let pct_c2h4 = dga.ethylene_ppm / total * 100.0;
let pct_c2h2 = dga.acetylene_ppm / total * 100.0;
if pct_c2h2 > 29.0 {
format!(
"D2: High-energy electrical discharge (C2H2={:.1}%, C2H4={:.1}%, CH4={:.1}%)",
pct_c2h2, pct_c2h4, pct_ch4
)
} else if pct_c2h2 > 13.0 && pct_c2h4 > 20.0 {
format!(
"DT: Electrical + thermal fault (C2H2={:.1}%, C2H4={:.1}%, CH4={:.1}%)",
pct_c2h2, pct_c2h4, pct_ch4
)
} else if pct_c2h2 > 13.0 {
format!(
"D1: Low-energy electrical discharge (C2H2={:.1}%, C2H4={:.1}%, CH4={:.1}%)",
pct_c2h2, pct_c2h4, pct_ch4
)
} else if pct_c2h4 > 50.0 {
format!(
"T3: High-temperature thermal fault >700°C (C2H4={:.1}%, CH4={:.1}%)",
pct_c2h4, pct_ch4
)
} else if pct_c2h4 > 20.0 {
format!(
"T2: Medium-temperature thermal fault 300–700°C (C2H4={:.1}%, CH4={:.1}%)",
pct_c2h4, pct_ch4
)
} else {
format!(
"T1: Low-temperature thermal fault <300°C (CH4={:.1}%, C2H4={:.1}%)",
pct_ch4, pct_c2h4
)
}
}
pub fn alarm_threshold(&self) -> f64 {
self.config.alarm_on_threshold
}
pub fn critical_threshold(&self) -> f64 {
self.config.critical_threshold
}
}
fn r1_code(r: f64) -> u8 {
if (0.1..1.0).contains(&r) {
0
} else if r < 0.1 {
1
} else {
2
}
}
fn r2_code(r: f64) -> u8 {
if r < 0.1 {
0
} else if r < 3.0 {
1
} else {
2
}
}
fn r3_code(r: f64) -> u8 {
if r < 1.0 {
0
} else if r < 3.0 {
1
} else {
2
}
}
#[cfg(test)]
mod tests {
use super::*;
fn monitor() -> EquipmentHealthMonitor {
EquipmentHealthMonitor::new(EquipmentHealthConfig::default())
}
fn clean_dga() -> DgaResult {
DgaResult {
hydrogen_ppm: 10.0,
methane_ppm: 5.0,
ethane_ppm: 3.0,
ethylene_ppm: 2.0,
acetylene_ppm: 0.0,
co_ppm: 50.0,
co2_ppm: 400.0,
o2_ppm: 5000.0,
n2_ppm: 40000.0,
total_dissolved_gas_ppm: 70.0,
}
}
fn new_transformer() -> TransformerHealthIndicators {
TransformerHealthIndicators {
id: 1,
age_years: 2.0,
loading_pct: 60.0,
hotspot_temp_c: 80.0,
top_oil_temp_c: 60.0,
moisture_ppm: 5.0,
acidity_mg_koh_per_g: 0.02,
dissolved_gas_analysis: clean_dga(),
power_factor_pct: 0.1,
insulation_resistance_mohm: 5000.0,
}
}
fn old_wet_transformer() -> TransformerHealthIndicators {
TransformerHealthIndicators {
id: 2,
age_years: 38.0,
loading_pct: 95.0,
hotspot_temp_c: 130.0,
top_oil_temp_c: 90.0,
moisture_ppm: 30.0, acidity_mg_koh_per_g: 0.35,
dissolved_gas_analysis: DgaResult {
hydrogen_ppm: 300.0,
methane_ppm: 150.0,
ethane_ppm: 50.0,
ethylene_ppm: 100.0,
acetylene_ppm: 40.0, co_ppm: 500.0,
co2_ppm: 5000.0,
o2_ppm: 1000.0,
n2_ppm: 30000.0,
total_dissolved_gas_ppm: 1140.0,
},
power_factor_pct: 0.8,
insulation_resistance_mohm: 200.0,
}
}
fn new_breaker() -> BreakerHealthIndicators {
BreakerHealthIndicators {
id: 10,
age_years: 1.0,
operation_count: 100,
contact_wear_pct: 2.0,
sf6_pressure_pct: 100.0,
sf6_moisture_ppm: 10.0,
control_voltage_v: 125.0,
charging_current_a: 2.0,
timing_open_ms: 50.0,
timing_close_ms: 60.0,
arcing_current_ka_sq_cycles: 0.5,
}
}
fn critical_sf6_breaker() -> BreakerHealthIndicators {
BreakerHealthIndicators {
id: 11,
age_years: 15.0,
operation_count: 5000,
contact_wear_pct: 40.0,
sf6_pressure_pct: 55.0, sf6_moisture_ppm: 80.0,
control_voltage_v: 110.0,
charging_current_a: 3.5,
timing_open_ms: 50.0,
timing_close_ms: 60.0,
arcing_current_ka_sq_cycles: 20.0,
}
}
#[test]
fn test_new_transformer_high_score() {
let score = monitor().score_transformer(&new_transformer());
assert!(
score.overall_score > 80.0,
"New transformer must score > 80, got {:.2}",
score.overall_score
);
assert!(
score.risk_level == RiskLevel::Low,
"Risk should be Low for new transformer"
);
}
#[test]
fn test_old_wet_transformer_low_score() {
let score = monitor().score_transformer(&old_wet_transformer());
assert!(
score.overall_score < 50.0,
"Old wet transformer must score < 50, got {:.2}",
score.overall_score
);
assert!(
matches!(
score.maintenance_recommendation,
MaintenanceAction::ImmediateOutage { .. }
),
"Expected ImmediateOutage, got {:?}",
score.maintenance_recommendation
);
}
#[test]
fn test_critical_sf6_immediate_outage() {
let score = monitor().score_breaker(&critical_sf6_breaker());
assert!(
matches!(
score.maintenance_recommendation,
MaintenanceAction::ImmediateOutage { .. }
| MaintenanceAction::UrgentMaintenance { .. }
),
"SF6 <70% should be ImmediateOutage or UrgentMaintenance, got {:?}",
score.maintenance_recommendation
);
}
#[test]
fn test_new_breaker_no_action() {
let score = monitor().score_breaker(&new_breaker());
assert!(
score.overall_score > 85.0,
"New breaker must score > 85, got {:.2}",
score.overall_score
);
assert!(
matches!(
score.maintenance_recommendation,
MaintenanceAction::NoAction
),
"New breaker: expected NoAction, got {:?}",
score.maintenance_recommendation
);
}
#[test]
fn test_rogers_ratio_thermal_fault() {
let dga = DgaResult {
hydrogen_ppm: 100.0,
methane_ppm: 50.0, ethane_ppm: 200.0, ethylene_ppm: 300.0,
acetylene_ppm: 1.0, co_ppm: 200.0,
co2_ppm: 1500.0,
o2_ppm: 4000.0,
n2_ppm: 35000.0,
total_dissolved_gas_ppm: 851.0,
};
let (fault_type, severity) = monitor().rogers_ratio(&dga);
assert!(
fault_type.to_lowercase().contains("thermal"),
"Should identify thermal fault (code 0,0,1 → T1), got: {}",
fault_type
);
assert!(severity > 0.0, "Severity must be positive for active fault");
}
#[test]
fn test_duval_triangle_d2_zone() {
let dga = DgaResult {
hydrogen_ppm: 200.0,
methane_ppm: 10.0,
ethane_ppm: 5.0,
ethylene_ppm: 20.0, acetylene_ppm: 70.0, co_ppm: 50.0,
co2_ppm: 500.0,
o2_ppm: 3000.0,
n2_ppm: 30000.0,
total_dissolved_gas_ppm: 355.0,
};
let zone = monitor().duval_triangle(&dga);
assert!(zone.starts_with("D2"), "Expected D2 zone, got: {}", zone);
}
#[test]
fn test_duval_triangle_t3_zone() {
let dga = DgaResult {
hydrogen_ppm: 100.0,
methane_ppm: 30.0,
ethane_ppm: 10.0,
ethylene_ppm: 65.0, acetylene_ppm: 2.0, co_ppm: 80.0,
co2_ppm: 600.0,
o2_ppm: 2000.0,
n2_ppm: 25000.0,
total_dissolved_gas_ppm: 289.0,
};
let zone = monitor().duval_triangle(&dga);
assert!(zone.starts_with("T3"), "Expected T3 zone, got: {}", zone);
}
#[test]
fn test_breaker_high_wear_maintenance() {
let mut br = new_breaker();
br.contact_wear_pct = 92.0; let score = monitor().score_breaker(&br);
assert!(
matches!(
score.maintenance_recommendation,
MaintenanceAction::UrgentMaintenance { .. }
| MaintenanceAction::ScheduledMaintenance { .. }
| MaintenanceAction::ImmediateOutage { .. }
),
"High wear should recommend maintenance, got {:?}",
score.maintenance_recommendation
);
}
}