use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LifecycleError {
#[error("no assets registered")]
NoAssets,
#[error("planning_horizon_years must be > 0")]
ZeroHorizon,
#[error("discount_rate must be in (0, 1), got {0}")]
InvalidDiscountRate(f64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetType {
PowerTransformer,
CircuitBreaker,
TransmissionLine,
DistributionTransformer,
UndergroundCable,
CapacitorBank,
LightningArrester,
Insulator,
}
impl AssetType {
pub fn nominal_lifetime_years(&self) -> f64 {
match self {
AssetType::PowerTransformer => 40.0,
AssetType::CircuitBreaker => 30.0,
AssetType::TransmissionLine => 50.0,
AssetType::DistributionTransformer => 35.0,
AssetType::UndergroundCable => 35.0,
AssetType::CapacitorBank => 20.0,
AssetType::LightningArrester => 25.0,
AssetType::Insulator => 30.0,
}
}
pub fn decay_lambda(&self) -> f64 {
let ln_10_over_3 = (10.0_f64 / 3.0).ln();
ln_10_over_3 / self.nominal_lifetime_years()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridAsset {
pub id: usize,
pub asset_type: AssetType,
pub installation_year: usize,
pub current_year: usize,
pub condition_score: f64,
pub criticality: f64,
pub failure_rate_per_year: f64,
pub failure_cost_usd: f64,
pub maintenance_cost_usd: f64,
pub replacement_cost_usd: f64,
pub remaining_life_years: f64,
}
impl GridAsset {
pub fn risk_score(&self) -> f64 {
self.failure_rate_per_year * self.failure_cost_usd * self.criticality
}
pub fn age_years(&self) -> usize {
self.current_year.saturating_sub(self.installation_year)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlannedAction {
Inspection,
MinorMaintenance,
MajorOverhaul,
Replacement,
Refurbishment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum MaintenancePriority {
Urgent,
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintenancePlan {
pub asset_id: usize,
pub planned_year: usize,
pub action: PlannedAction,
pub estimated_cost_usd: f64,
pub expected_life_extension_years: f64,
pub risk_reduction_factor: f64,
pub priority: MaintenancePriority,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleResult {
pub maintenance_plans: Vec<MaintenancePlan>,
pub annual_budget: Vec<f64>,
pub annual_risk_cost: Vec<f64>,
pub total_npv_cost_usd: f64,
pub risk_reduction_pct: f64,
pub assets_at_risk: Vec<usize>,
pub budget_peaks: Vec<(usize, f64)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetLifecycleConfig {
pub planning_horizon_years: usize,
pub discount_rate: f64,
pub corrective_maintenance_multiplier: f64,
pub reliability_weight: f64,
}
impl Default for AssetLifecycleConfig {
fn default() -> Self {
Self {
planning_horizon_years: 10,
discount_rate: 0.05,
corrective_maintenance_multiplier: 3.0,
reliability_weight: 0.5,
}
}
}
pub struct AssetLifecycleOptimizer {
config: AssetLifecycleConfig,
assets: Vec<GridAsset>,
annual_budget_limit: f64,
}
impl AssetLifecycleOptimizer {
pub fn new(config: AssetLifecycleConfig) -> Self {
Self {
config,
assets: Vec::new(),
annual_budget_limit: f64::MAX,
}
}
pub fn add_asset(&mut self, asset: GridAsset) {
self.assets.push(asset);
}
pub fn set_budget_limit(&mut self, usd_per_year: f64) {
self.annual_budget_limit = usd_per_year;
}
pub fn optimize(&self) -> Result<LifecycleResult, LifecycleError> {
if self.assets.is_empty() {
return Err(LifecycleError::NoAssets);
}
if self.config.planning_horizon_years == 0 {
return Err(LifecycleError::ZeroHorizon);
}
let r = self.config.discount_rate;
if r <= 0.0 || r >= 1.0 {
return Err(LifecycleError::InvalidDiscountRate(r));
}
let horizon = self.config.planning_horizon_years;
let base_year = self
.assets
.iter()
.map(|a| a.current_year)
.max()
.unwrap_or(2024);
let initial_total_risk: f64 = self.assets.iter().map(|a| a.risk_score()).sum();
let mut sorted_ids: Vec<usize> = (0..self.assets.len()).collect();
sorted_ids.sort_by(|&a, &b| {
let ra = self.assets[a].risk_score();
let rb = self.assets[b].risk_score();
rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
});
let mut maintenance_plans: Vec<MaintenancePlan> = Vec::new();
let mut annual_budget: Vec<f64> = vec![0.0; horizon];
let mut annual_risk_cost: Vec<f64> = vec![0.0; horizon];
let mut post_maint_risk: Vec<f64> = vec![0.0; self.assets.len()];
for &idx in &sorted_ids {
let asset = &self.assets[idx];
let (action, planned_year_offset) = self.choose_action(asset, base_year, horizon);
if planned_year_offset >= horizon {
continue;
}
let cost = self.action_cost(asset, &action);
let life_ext = self.life_extension(&action);
let risk_factor = self.post_maintenance_failure_rate(asset, &action)
/ asset.failure_rate_per_year.max(1e-9);
let priority = self.compute_priority(asset, &action);
let yr = planned_year_offset;
if annual_budget[yr] + cost <= self.annual_budget_limit {
annual_budget[yr] += cost;
post_maint_risk[idx] = asset.failure_rate_per_year
* risk_factor
* asset.failure_cost_usd
* asset.criticality;
maintenance_plans.push(MaintenancePlan {
asset_id: asset.id,
planned_year: base_year + yr,
action,
estimated_cost_usd: cost,
expected_life_extension_years: life_ext,
risk_reduction_factor: 1.0 - risk_factor,
priority,
});
} else {
let next_yr = yr + 1;
if next_yr < horizon && annual_budget[next_yr] + cost <= self.annual_budget_limit {
annual_budget[next_yr] += cost;
post_maint_risk[idx] = asset.failure_rate_per_year
* risk_factor
* asset.failure_cost_usd
* asset.criticality;
maintenance_plans.push(MaintenancePlan {
asset_id: asset.id,
planned_year: base_year + next_yr,
action,
estimated_cost_usd: cost,
expected_life_extension_years: life_ext,
risk_reduction_factor: 1.0 - risk_factor,
priority,
});
} else {
post_maint_risk[idx] = asset.risk_score();
}
}
}
for (yr, risk_slot) in annual_risk_cost.iter_mut().enumerate() {
*risk_slot = self
.assets
.iter()
.enumerate()
.map(|(idx, asset)| {
let projected_score = self.project_condition(asset, yr);
let risk_scale = if projected_score > 0.0 {
(100.0 - projected_score) / 100.0
} else {
1.0
};
let base_risk = if post_maint_risk[idx] > 0.0 {
post_maint_risk[idx]
} else {
asset.risk_score()
};
base_risk * (1.0 + risk_scale)
})
.sum();
}
let mut total_npv = 0.0_f64;
for yr in 0..horizon {
let discount = (1.0 + r).powi(yr as i32 + 1);
total_npv += (annual_budget[yr] + annual_risk_cost[yr]) / discount;
}
let final_total_risk: f64 = post_maint_risk.iter().copied().sum::<f64>()
+ self
.assets
.iter()
.enumerate()
.filter(|(i, _)| post_maint_risk[*i] == 0.0)
.map(|(_, a)| a.risk_score())
.sum::<f64>();
let risk_reduction_pct = if initial_total_risk > 0.0 {
((initial_total_risk - final_total_risk) / initial_total_risk * 100.0).max(0.0)
} else {
0.0
};
let assets_at_risk: Vec<usize> = self
.assets
.iter()
.filter(|a| a.criticality > 0.7 && a.condition_score < 40.0)
.map(|a| a.id)
.collect();
let budget_peaks: Vec<(usize, f64)> = annual_budget
.iter()
.enumerate()
.filter(|(_, &c)| c > self.annual_budget_limit * 0.9)
.map(|(yr, &c)| (base_year + yr, c))
.collect();
Ok(LifecycleResult {
maintenance_plans,
annual_budget,
annual_risk_cost,
total_npv_cost_usd: total_npv,
risk_reduction_pct,
assets_at_risk,
budget_peaks,
})
}
fn choose_action(
&self,
asset: &GridAsset,
_base_year: usize,
horizon: usize,
) -> (PlannedAction, usize) {
let condition = asset.condition_score;
let remaining = asset.remaining_life_years;
if condition < 20.0 || remaining < 1.0 {
(PlannedAction::Replacement, 0)
} else if condition < 40.0 || remaining < 3.0 {
(PlannedAction::MajorOverhaul, 0)
} else if condition < 60.0 {
(
PlannedAction::MinorMaintenance,
1.min(horizon.saturating_sub(1)),
)
} else {
(PlannedAction::Inspection, 2.min(horizon.saturating_sub(1)))
}
}
fn action_cost(&self, asset: &GridAsset, action: &PlannedAction) -> f64 {
match action {
PlannedAction::Inspection => asset.maintenance_cost_usd * 0.1,
PlannedAction::MinorMaintenance => asset.maintenance_cost_usd * 0.4,
PlannedAction::MajorOverhaul => asset.maintenance_cost_usd,
PlannedAction::Replacement => asset.replacement_cost_usd,
PlannedAction::Refurbishment => asset.replacement_cost_usd * 0.5,
}
}
fn life_extension(&self, action: &PlannedAction) -> f64 {
match action {
PlannedAction::Inspection => 0.0,
PlannedAction::MinorMaintenance => 2.0,
PlannedAction::MajorOverhaul => 8.0,
PlannedAction::Replacement => 40.0,
PlannedAction::Refurbishment => 15.0,
}
}
fn post_maintenance_failure_rate(&self, asset: &GridAsset, action: &PlannedAction) -> f64 {
let base = asset.failure_rate_per_year;
match action {
PlannedAction::Inspection => base,
PlannedAction::MinorMaintenance => base * 0.80,
PlannedAction::MajorOverhaul => base * 0.50,
PlannedAction::Replacement => base * 0.10,
PlannedAction::Refurbishment => base * 0.65,
}
}
fn project_condition(&self, asset: &GridAsset, years: usize) -> f64 {
let lambda = asset.asset_type.decay_lambda();
asset.condition_score * (-lambda * years as f64).exp()
}
fn compute_priority(&self, asset: &GridAsset, action: &PlannedAction) -> MaintenancePriority {
match action {
PlannedAction::Replacement => {
if asset.condition_score < 20.0 {
MaintenancePriority::Urgent
} else {
MaintenancePriority::High
}
}
PlannedAction::MajorOverhaul => {
if asset.criticality > 0.8 {
MaintenancePriority::High
} else {
MaintenancePriority::Medium
}
}
PlannedAction::MinorMaintenance | PlannedAction::Refurbishment => {
MaintenancePriority::Medium
}
PlannedAction::Inspection => MaintenancePriority::Low,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> AssetLifecycleConfig {
AssetLifecycleConfig {
planning_horizon_years: 5,
discount_rate: 0.05,
corrective_maintenance_multiplier: 3.0,
reliability_weight: 0.5,
}
}
fn make_asset(id: usize, condition: f64, criticality: f64, failure_rate: f64) -> GridAsset {
GridAsset {
id,
asset_type: AssetType::PowerTransformer,
installation_year: 1990,
current_year: 2024,
condition_score: condition,
criticality,
failure_rate_per_year: failure_rate,
failure_cost_usd: 500_000.0,
maintenance_cost_usd: 50_000.0,
replacement_cost_usd: 2_000_000.0,
remaining_life_years: condition / 100.0 * 40.0,
}
}
#[test]
fn test_high_risk_asset_scheduled_first() {
let config = make_config();
let mut opt = AssetLifecycleOptimizer::new(config);
opt.set_budget_limit(10_000_000.0);
opt.add_asset(make_asset(0, 80.0, 0.3, 0.01));
opt.add_asset(make_asset(1, 30.0, 0.9, 0.5));
let result = opt.optimize().expect("optimize");
let plans_for_1: Vec<&MaintenancePlan> = result
.maintenance_plans
.iter()
.filter(|p| p.asset_id == 1)
.collect();
let plans_for_0: Vec<&MaintenancePlan> = result
.maintenance_plans
.iter()
.filter(|p| p.asset_id == 0)
.collect();
assert!(!plans_for_1.is_empty(), "High-risk asset must be scheduled");
if let Some(p) = plans_for_1.first() {
assert!(
matches!(
p.action,
PlannedAction::MajorOverhaul | PlannedAction::Replacement
),
"Expected overhaul or replacement for high-risk, got {:?}",
p.action
);
}
if let Some(p) = plans_for_0.first() {
assert!(
matches!(
p.action,
PlannedAction::Inspection | PlannedAction::MinorMaintenance
),
"Expected lighter action for low-risk, got {:?}",
p.action
);
}
}
#[test]
fn test_budget_constraint_respected() {
let config = make_config();
let mut opt = AssetLifecycleOptimizer::new(config);
opt.set_budget_limit(60_000.0);
for i in 0..5 {
opt.add_asset(make_asset(i, 50.0, 0.7, 0.1));
}
let result = opt.optimize().expect("optimize");
for (yr, &cost) in result.annual_budget.iter().enumerate() {
assert!(
cost <= 60_001.0, "Year {yr}: budget {cost:.0} exceeds limit 60000"
);
}
}
#[test]
fn test_replacement_triggered_at_low_condition() {
let config = make_config();
let mut opt = AssetLifecycleOptimizer::new(config);
opt.set_budget_limit(5_000_000.0);
opt.add_asset(make_asset(0, 10.0, 0.9, 0.8));
let result = opt.optimize().expect("optimize");
let plan = result
.maintenance_plans
.iter()
.find(|p| p.asset_id == 0)
.expect("asset 0 should be scheduled");
assert_eq!(
plan.action,
PlannedAction::Replacement,
"Asset at 10% condition should be replaced"
);
}
#[test]
fn test_npv_maintenance_cheaper_than_failure() {
let config = AssetLifecycleConfig {
planning_horizon_years: 5,
discount_rate: 0.05,
corrective_maintenance_multiplier: 3.0,
reliability_weight: 0.5,
};
let mut opt = AssetLifecycleOptimizer::new(config.clone());
opt.set_budget_limit(10_000_000.0);
opt.add_asset(make_asset(0, 40.0, 1.0, 1.0));
let result = opt.optimize().expect("optimize");
assert!(
result.total_npv_cost_usd.is_finite() && result.total_npv_cost_usd > 0.0,
"Expected positive finite NPV, got {}",
result.total_npv_cost_usd
);
assert!(
result.risk_reduction_pct >= 0.0,
"Expected non-negative risk reduction"
);
}
#[test]
fn test_risk_reduction_achieved() {
let config = make_config();
let mut opt = AssetLifecycleOptimizer::new(config);
opt.set_budget_limit(5_000_000.0);
opt.add_asset(make_asset(0, 35.0, 0.9, 0.4));
opt.add_asset(make_asset(1, 25.0, 0.8, 0.6));
let result = opt.optimize().expect("optimize");
assert!(
result.risk_reduction_pct >= 0.0 && result.risk_reduction_pct <= 100.0,
"Risk reduction {:.1}% out of range",
result.risk_reduction_pct
);
assert!(
result.maintenance_plans.len() >= 2,
"Expected at least 2 maintenance plans"
);
}
#[test]
fn test_assets_at_risk_identified() {
let config = make_config();
let mut opt = AssetLifecycleOptimizer::new(config);
opt.set_budget_limit(10_000_000.0);
opt.add_asset(make_asset(10, 25.0, 0.95, 0.5));
opt.add_asset(make_asset(11, 60.0, 0.3, 0.05));
let result = opt.optimize().expect("optimize");
assert!(
result.assets_at_risk.contains(&10),
"Asset 10 (high criticality, low condition) should be at risk"
);
assert!(
!result.assets_at_risk.contains(&11),
"Asset 11 (low criticality, medium condition) should not be at risk"
);
}
#[test]
fn test_no_assets_error() {
let opt = AssetLifecycleOptimizer::new(make_config());
let result = opt.optimize();
assert!(matches!(result, Err(LifecycleError::NoAssets)));
}
#[test]
fn test_project_condition_decays() {
let opt = AssetLifecycleOptimizer::new(make_config());
let asset = make_asset(0, 100.0, 1.0, 0.1);
let c0 = opt.project_condition(&asset, 0);
let c10 = opt.project_condition(&asset, 10);
let c40 = opt.project_condition(&asset, 40);
assert!((c0 - 100.0).abs() < 1e-6, "At t=0 condition should be 100");
assert!(c10 < c0, "Condition should decrease over time");
assert!(c40 < c10, "Condition at t=40 should be less than at t=10");
assert!(c40 > 20.0, "At nominal lifetime, condition ≈ 30% = 30.0");
}
}