#[derive(Debug, Clone, PartialEq)]
pub enum ProjectType {
NewFeeder,
SubstationUpgrade,
CableReplacement,
AutomationAddition,
VoltageRegulator,
CapacitorBank,
MicrogridConnection,
EvChargingInfrastructure,
}
#[derive(Debug, Clone)]
pub struct ProjectBenefits {
pub loss_reduction_mwh_per_year: f64,
pub reliability_improvement_minutes: f64,
pub capacity_released_mva: f64,
pub deferred_investment: f64,
pub co2_reduction_tco2_per_year: f64,
}
#[derive(Debug, Clone)]
pub struct DistributionProject {
pub id: usize,
pub name: String,
pub project_type: ProjectType,
pub capital_cost: f64,
pub annual_opex: f64,
pub capacity_mva: f64,
pub feeder_length_km: f64,
pub construction_years: u32,
pub year_commissioned: u32,
pub expected_life_years: u32,
pub benefits: ProjectBenefits,
}
#[derive(Debug, Clone)]
pub struct ExpansionPlan {
pub selected_projects: Vec<usize>,
pub total_capex: f64,
pub total_npv: f64,
pub annual_schedule: Vec<(u32, Vec<usize>)>,
}
#[derive(Debug, Clone)]
pub struct DistributionExpansionPlanner {
pub base_year: u32,
pub planning_horizon_years: u32,
pub discount_rate: f64,
pub load_growth_rate_pct: f64,
pub candidate_projects: Vec<DistributionProject>,
pub budget_per_year: Vec<f64>,
}
impl DistributionExpansionPlanner {
pub fn npv(&self, project: &DistributionProject) -> f64 {
let r = self.discount_rate;
let loss_value_per_mwh = 50.0_f64; let annual_benefit = project.benefits.loss_reduction_mwh_per_year * loss_value_per_mwh
+ project.benefits.deferred_investment / project.expected_life_years as f64;
let net_annual = annual_benefit - project.annual_opex;
let life = project.expected_life_years as f64;
let annuity = if r.abs() < 1e-12 {
life
} else {
(1.0 - (1.0 + r).powf(-life)) / r
};
net_annual * annuity - project.capital_cost
}
pub fn benefit_cost_ratio(&self, project: &DistributionProject, voll: f64) -> f64 {
let r = self.discount_rate;
let loss_value_per_mwh = 50.0_f64;
let co2_price_per_tco2 = 30.0_f64;
let capacity_value_per_mva_year = 30_000.0_f64;
let annual_benefit = project.benefits.loss_reduction_mwh_per_year * loss_value_per_mwh
+ project.benefits.reliability_improvement_minutes / 60.0 * voll
+ project.benefits.capacity_released_mva * capacity_value_per_mva_year
+ project.benefits.co2_reduction_tco2_per_year * co2_price_per_tco2
+ project.benefits.deferred_investment / project.expected_life_years as f64;
let life = project.expected_life_years as f64;
let annuity = if r.abs() < 1e-12 {
life
} else {
(1.0 - (1.0 + r).powf(-life)) / r
};
let total_cost = project.capital_cost + project.annual_opex * annuity;
if total_cost <= 0.0 {
return 0.0;
}
annual_benefit * annuity / total_cost
}
pub fn plan_greedy(&self) -> ExpansionPlan {
let voll = 100_000.0_f64; let mut ranked: Vec<(usize, f64)> = self
.candidate_projects
.iter()
.map(|p| (p.id, self.benefit_cost_ratio(p, voll)))
.collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let horizon = self.planning_horizon_years as usize;
let budget_available: Vec<f64> = (0..horizon)
.map(|i| {
self.budget_per_year
.get(i)
.copied()
.unwrap_or(f64::INFINITY)
})
.collect();
let mut remaining_budget = budget_available.clone();
let mut selected: Vec<usize> = Vec::new();
let mut total_capex = 0.0_f64;
let mut total_npv = 0.0_f64;
let mut schedule: std::collections::HashMap<u32, Vec<usize>> =
std::collections::HashMap::new();
for (pid, _bcr) in &ranked {
let proj = match self.candidate_projects.iter().find(|p| p.id == *pid) {
Some(p) => p,
None => continue,
};
let yr_idx = proj.year_commissioned.saturating_sub(self.base_year) as usize;
if yr_idx >= horizon {
continue;
}
if remaining_budget[yr_idx] >= proj.capital_cost {
remaining_budget[yr_idx] -= proj.capital_cost;
selected.push(proj.id);
total_capex += proj.capital_cost;
total_npv += self.npv(proj);
schedule
.entry(proj.year_commissioned)
.or_default()
.push(proj.id);
}
}
let mut annual_schedule: Vec<(u32, Vec<usize>)> = schedule.into_iter().collect();
annual_schedule.sort_by_key(|(y, _)| *y);
ExpansionPlan {
selected_projects: selected,
total_capex,
total_npv,
annual_schedule,
}
}
pub fn plan_deferred(&self, project: &DistributionProject, deferral_years: u32) -> f64 {
let npv_now = self.npv(project);
let r = self.discount_rate;
let discount = (1.0 + r).powi(deferral_years as i32);
let npv_deferred = self.npv(project) / discount;
npv_now - npv_deferred
}
}
#[derive(Debug, Clone)]
pub struct GrowthScenario {
pub name: String,
pub annual_energy_growth_pct: f64,
pub peak_growth_pct: f64,
pub ev_penetration_by_year: Vec<f64>,
pub pv_penetration_by_year: Vec<f64>,
pub dr_participation_pct: f64,
}
#[derive(Debug, Clone)]
pub struct DistributionLoadForecast {
pub base_demand_mw: f64,
pub base_peak_mw: f64,
pub growth_scenarios: Vec<GrowthScenario>,
}
impl DistributionLoadForecast {
pub fn forecast_peak_mw(&self, scenario_idx: usize, years_ahead: u32) -> Result<f64, String> {
let scenario = self
.growth_scenarios
.get(scenario_idx)
.ok_or_else(|| format!("scenario index {} out of range", scenario_idx))?;
let g = scenario.peak_growth_pct / 100.0;
Ok(self.base_peak_mw * (1.0 + g).powi(years_ahead as i32))
}
pub fn forecast_energy_mwh(
&self,
scenario_idx: usize,
years_ahead: u32,
) -> Result<f64, String> {
let scenario = self
.growth_scenarios
.get(scenario_idx)
.ok_or_else(|| format!("scenario index {} out of range", scenario_idx))?;
let g = scenario.annual_energy_growth_pct / 100.0;
Ok(self.base_demand_mw * 8_760.0 * (1.0 + g).powi(years_ahead as i32))
}
pub fn net_peak_with_der(&self, scenario_idx: usize, years_ahead: u32) -> Result<f64, String> {
let scenario = self
.growth_scenarios
.get(scenario_idx)
.ok_or_else(|| format!("scenario index {} out of range", scenario_idx))?;
let gross_peak = self.forecast_peak_mw(scenario_idx, years_ahead)?;
let pv_penetration = scenario
.pv_penetration_by_year
.get(years_ahead as usize)
.copied()
.unwrap_or_else(|| {
scenario
.pv_penetration_by_year
.last()
.copied()
.unwrap_or(0.0)
});
let ev_penetration = scenario
.ev_penetration_by_year
.get(years_ahead as usize)
.copied()
.unwrap_or_else(|| {
scenario
.ev_penetration_by_year
.last()
.copied()
.unwrap_or(0.0)
});
let pv_credit = gross_peak * pv_penetration * 0.8;
let ev_addition = gross_peak * ev_penetration * 0.3;
let dr_credit = gross_peak * scenario.dr_participation_pct / 100.0 * 0.5;
let net = (gross_peak - pv_credit + ev_addition - dr_credit).max(0.0);
Ok(net)
}
pub fn load_growth_factor(&self, scenario_idx: usize, years: u32) -> Result<f64, String> {
let scenario = self
.growth_scenarios
.get(scenario_idx)
.ok_or_else(|| format!("scenario index {} out of range", scenario_idx))?;
let g = scenario.peak_growth_pct / 100.0;
Ok((1.0 + g).powi(years as i32))
}
pub fn planning_peak(&self, confidence_pct: f64) -> f64 {
if self.growth_scenarios.is_empty() {
return self.base_peak_mw;
}
let horizon = self
.growth_scenarios
.iter()
.map(|s| {
s.ev_penetration_by_year
.len()
.max(s.pv_penetration_by_year.len())
})
.max()
.unwrap_or(1) as u32;
let mut peaks: Vec<f64> = self
.growth_scenarios
.iter()
.enumerate()
.filter_map(|(i, _)| self.forecast_peak_mw(i, horizon).ok())
.collect();
peaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if peaks.is_empty() {
return self.base_peak_mw;
}
let idx = ((confidence_pct / 100.0) * peaks.len() as f64 - 1.0)
.max(0.0)
.min((peaks.len() - 1) as f64) as usize;
peaks[idx]
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DistAssetType {
Transformer,
Cable,
Switchgear,
Meter,
Relay,
CapBank,
}
#[derive(Debug, Clone)]
pub struct DistributionAsset {
pub id: usize,
pub asset_type: DistAssetType,
pub installation_year: u32,
pub expected_life_years: u32,
pub condition_score: f64,
pub failure_rate_per_year: f64,
pub replacement_cost: f64,
pub criticality: f64,
}
#[derive(Debug, Clone)]
pub struct AssetConditionAssessor {
pub assets: Vec<DistributionAsset>,
}
impl AssetConditionAssessor {
pub fn age_years(&self, asset: &DistributionAsset, current_year: u32) -> u32 {
current_year.saturating_sub(asset.installation_year)
}
pub fn remaining_life_pct(&self, asset: &DistributionAsset, current_year: u32) -> f64 {
let age = self.age_years(asset, current_year) as f64;
let life = asset.expected_life_years as f64;
if life <= 0.0 {
return 0.0;
}
((life - age) / life).clamp(0.0, 1.0)
}
pub fn health_index(&self, asset: &DistributionAsset, current_year: u32) -> f64 {
let rl = self.remaining_life_pct(asset, current_year);
let hi = asset.condition_score * (1.0 - asset.failure_rate_per_year).max(0.0) * rl;
hi.clamp(0.0, 100.0)
}
pub fn replacement_priority(&self, asset: &DistributionAsset, current_year: u32) -> f64 {
let hi = self.health_index(asset, current_year).max(1e-6);
asset.criticality * asset.failure_rate_per_year / hi
}
pub fn top_priority_replacements(&self, current_year: u32, top_n: usize) -> Vec<usize> {
let mut scored: Vec<(usize, f64)> = self
.assets
.iter()
.enumerate()
.map(|(i, a)| (i, self.replacement_priority(a, current_year)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(top_n);
scored.into_iter().map(|(i, _)| i).collect()
}
pub fn total_replacement_cost_within_horizon(&self, years: u32, current_year: u32) -> f64 {
self.assets
.iter()
.filter(|a| {
let remaining = self.remaining_life_pct(a, current_year);
let remaining_years = (remaining * a.expected_life_years as f64).ceil() as u32;
remaining_years <= years
})
.map(|a| a.replacement_cost)
.sum()
}
}
#[derive(Debug, Clone)]
pub struct MaintenanceActivity {
pub asset_id: usize,
pub activity_type: String,
pub interval_years: f64,
pub annual_cost: f64,
}
#[derive(Debug, Clone)]
pub struct MaintenancePlan {
pub selected_assets: Vec<usize>,
pub total_cost: f64,
pub risk_reduction_pct: f64,
pub activities: Vec<MaintenanceActivity>,
}
#[derive(Debug, Clone)]
pub struct RcmAnalyzer {
pub assets: Vec<DistributionAsset>,
pub maintenance_budget: f64,
pub failure_consequence: f64,
}
impl RcmAnalyzer {
pub fn expected_annual_failures(&self) -> f64 {
self.assets.iter().map(|a| a.failure_rate_per_year).sum()
}
pub fn expected_annual_failure_cost(&self) -> f64 {
self.assets
.iter()
.map(|a| a.failure_rate_per_year * self.failure_consequence)
.sum()
}
pub fn preventive_maintenance_interval_years(&self, asset: &DistributionAsset) -> f64 {
let maintenance_cost = asset.replacement_cost * 0.01; let denom = asset.failure_rate_per_year * self.failure_consequence;
if denom <= 0.0 {
return f64::INFINITY;
}
(2.0 * maintenance_cost / denom).sqrt()
}
pub fn risk_reduction_from_maintenance(&self, asset: &DistributionAsset) -> f64 {
asset.failure_rate_per_year * 0.5 * self.failure_consequence
}
pub fn maintenance_plan(&self) -> Vec<(usize, f64, f64)> {
self.assets
.iter()
.map(|a| {
let interval = self.preventive_maintenance_interval_years(a);
let annual_cost = if interval.is_finite() && interval > 0.0 {
a.replacement_cost * 0.01 / interval
} else {
0.0
};
(a.id, interval, annual_cost)
})
.collect()
}
pub fn optimize_maintenance_budget(&self) -> MaintenancePlan {
let plan_raw = self.maintenance_plan();
let mut candidates: Vec<(usize, f64, f64, f64)> = self
.assets
.iter()
.enumerate()
.filter_map(|(i, a)| {
let (_, interval, annual_cost) = plan_raw.get(i)?;
let rr = self.risk_reduction_from_maintenance(a);
if *annual_cost <= 0.0 {
return None;
}
Some((i, rr, *annual_cost, *interval))
})
.collect();
candidates.sort_by(|a, b| {
let ra = a.1 / a.2;
let rb = b.1 / b.2;
rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
});
let baseline_risk = self.expected_annual_failure_cost();
let mut remaining_budget = self.maintenance_budget;
let mut selected_assets: Vec<usize> = Vec::new();
let mut total_cost = 0.0_f64;
let mut total_rr = 0.0_f64;
let mut activities: Vec<MaintenanceActivity> = Vec::new();
for (i, rr, annual_cost, interval) in &candidates {
if remaining_budget < *annual_cost {
continue;
}
remaining_budget -= annual_cost;
total_cost += annual_cost;
total_rr += rr;
selected_assets.push(self.assets[*i].id);
activities.push(MaintenanceActivity {
asset_id: self.assets[*i].id,
activity_type: format!("{:?} preventive maintenance", self.assets[*i].asset_type),
interval_years: *interval,
annual_cost: *annual_cost,
});
}
let risk_reduction_pct = if baseline_risk > 0.0 {
(total_rr / baseline_risk * 100.0).min(100.0)
} else {
0.0
};
MaintenancePlan {
selected_assets,
total_cost,
risk_reduction_pct,
activities,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DerCandidateType {
Pv,
Wind,
Battery,
Ev,
FuelCell,
CombinedHeatPower,
}
#[derive(Debug, Clone)]
pub struct DerCandidate {
pub id: usize,
pub der_type: DerCandidateType,
pub capacity_mw: f64,
pub location_bus: usize,
pub connection_cost: f64,
pub annual_generation_mwh: f64,
pub curtailment_risk_pct: f64,
}
#[derive(Debug, Clone)]
pub struct DerIntegrationPlanner {
pub feeder_capacity_mva: f64,
pub existing_load_mw: f64,
pub existing_der_mw: f64,
pub hosting_capacity_mw: f64,
pub der_candidates: Vec<DerCandidate>,
}
impl DerIntegrationPlanner {
pub fn available_hosting_mw(&self) -> f64 {
(self.hosting_capacity_mw - self.existing_der_mw).max(0.0)
}
pub fn prioritize_candidates(&self) -> Vec<(usize, f64)> {
let mut scored: Vec<(usize, f64)> = self
.der_candidates
.iter()
.map(|c| {
let score = if c.connection_cost <= 0.0 {
0.0
} else {
c.annual_generation_mwh / c.connection_cost
* (1.0 - c.curtailment_risk_pct / 100.0)
};
(c.id, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored
}
pub fn can_integrate(&self, candidate: &DerCandidate) -> bool {
candidate.capacity_mw <= self.available_hosting_mw()
}
pub fn network_upgrade_cost(&self, additional_mw: f64) -> f64 {
let upgrade_needed = (additional_mw - self.available_hosting_mw()).max(0.0);
upgrade_needed * 50_000.0
}
pub fn der_benefit_analysis(
&self,
candidate: &DerCandidate,
electricity_price_mwh: f64,
) -> f64 {
let r = 0.07_f64;
let annual_revenue = candidate.annual_generation_mwh
* electricity_price_mwh
* (1.0 - candidate.curtailment_risk_pct / 100.0);
let perpetuity_value = annual_revenue / r;
let upgrade = self.network_upgrade_cost(candidate.capacity_mw);
perpetuity_value - candidate.connection_cost - upgrade
}
}
#[derive(Debug, Clone)]
pub struct StrategyMetrics {
pub total_capacity_added_mva: f64,
pub total_reliability_improvement_minutes: f64,
pub total_co2_reduction_tco2: f64,
pub overall_npv: f64,
pub budget_utilization_pct: f64,
}
#[derive(Debug, Clone)]
pub struct LongTermStrategy {
pub planning_horizon_years: u32,
pub total_budget: f64,
pub strategic_objectives: Vec<String>,
pub projects: Vec<DistributionProject>,
pub expansion_planner: DistributionExpansionPlanner,
}
impl LongTermStrategy {
pub fn new(horizon: u32, budget: f64) -> Self {
let budget_per_year = if horizon > 0 {
vec![budget / horizon as f64; horizon as usize]
} else {
vec![]
};
LongTermStrategy {
planning_horizon_years: horizon,
total_budget: budget,
strategic_objectives: Vec::new(),
projects: Vec::new(),
expansion_planner: DistributionExpansionPlanner {
base_year: 2025,
planning_horizon_years: horizon,
discount_rate: 0.07,
load_growth_rate_pct: 1.5,
candidate_projects: Vec::new(),
budget_per_year,
},
}
}
pub fn total_capex_requirements(&self) -> f64 {
self.projects.iter().map(|p| p.capital_cost).sum()
}
pub fn capex_gap(&self) -> f64 {
(self.total_capex_requirements() - self.total_budget).max(0.0)
}
pub fn prioritized_investment_plan(&self) -> Vec<(u32, String, f64)> {
let mut planner = self.expansion_planner.clone();
planner.candidate_projects = self.projects.clone();
let plan = planner.plan_greedy();
let id_to_project: std::collections::HashMap<usize, &DistributionProject> =
self.projects.iter().map(|p| (p.id, p)).collect();
let mut result: Vec<(u32, String, f64)> = Vec::new();
for (year, ids) in &plan.annual_schedule {
for pid in ids {
if let Some(proj) = id_to_project.get(pid) {
result.push((*year, proj.name.clone(), proj.capital_cost));
}
}
}
result.sort_by_key(|(y, _, _)| *y);
result
}
pub fn strategy_metrics(&self) -> StrategyMetrics {
let mut planner = self.expansion_planner.clone();
planner.candidate_projects = self.projects.clone();
let plan = planner.plan_greedy();
let id_to_project: std::collections::HashMap<usize, &DistributionProject> =
self.projects.iter().map(|p| (p.id, p)).collect();
let mut total_capacity_added_mva = 0.0_f64;
let mut total_reliability_improvement_minutes = 0.0_f64;
let mut total_co2_reduction_tco2 = 0.0_f64;
let mut total_capex_selected = 0.0_f64;
for pid in &plan.selected_projects {
if let Some(proj) = id_to_project.get(pid) {
total_capacity_added_mva += proj.capacity_mva;
total_reliability_improvement_minutes +=
proj.benefits.reliability_improvement_minutes
* self.planning_horizon_years as f64;
total_co2_reduction_tco2 +=
proj.benefits.co2_reduction_tco2_per_year * self.planning_horizon_years as f64;
total_capex_selected += proj.capital_cost;
}
}
let budget_utilization_pct = if self.total_budget > 0.0 {
(total_capex_selected / self.total_budget * 100.0).min(100.0)
} else {
0.0
};
StrategyMetrics {
total_capacity_added_mva,
total_reliability_improvement_minutes,
total_co2_reduction_tco2,
overall_npv: plan.total_npv,
budget_utilization_pct,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_project(id: usize, capex: f64, year: u32) -> DistributionProject {
DistributionProject {
id,
name: format!("Project-{}", id),
project_type: ProjectType::NewFeeder,
capital_cost: capex,
annual_opex: 1_000.0,
capacity_mva: 5.0,
feeder_length_km: 2.0,
construction_years: 1,
year_commissioned: year,
expected_life_years: 30,
benefits: ProjectBenefits {
loss_reduction_mwh_per_year: 500.0,
reliability_improvement_minutes: 60.0,
capacity_released_mva: 2.0,
deferred_investment: 50_000.0,
co2_reduction_tco2_per_year: 10.0,
},
}
}
fn sample_planner() -> DistributionExpansionPlanner {
DistributionExpansionPlanner {
base_year: 2025,
planning_horizon_years: 10,
discount_rate: 0.07,
load_growth_rate_pct: 1.5,
candidate_projects: vec![
sample_project(1, 100_000.0, 2025),
sample_project(2, 200_000.0, 2026),
sample_project(3, 50_000.0, 2027),
],
budget_per_year: vec![150_000.0; 10],
}
}
#[test]
fn test_npv_decreases_with_higher_discount_rate() {
let proj = sample_project(1, 100_000.0, 2025);
let mut planner = sample_planner();
planner.candidate_projects = vec![proj.clone()];
planner.discount_rate = 0.03;
let npv_low = planner.npv(&proj);
planner.discount_rate = 0.12;
let npv_high = planner.npv(&proj);
assert!(
npv_low > npv_high,
"NPV should decrease as discount rate increases: low={npv_low:.2} high={npv_high:.2}"
);
}
#[test]
fn test_plan_greedy_selects_highest_bcr_within_budget() {
let planner = sample_planner();
let plan = planner.plan_greedy();
assert!(
plan.selected_projects.contains(&3),
"Greedy planner should select the cheapest high-BCR project (id=3)"
);
assert!(
plan.total_capex <= planner.budget_per_year[0] * planner.planning_horizon_years as f64,
"Total CAPEX must not exceed total horizon budget"
);
}
#[test]
fn test_plan_greedy_respects_budget() {
let mut planner = sample_planner();
planner.budget_per_year = vec![60_000.0; 10];
let plan = planner.plan_greedy();
assert!(
!plan.selected_projects.contains(&1),
"Project 1 should not be selected with tight budget"
);
assert!(
!plan.selected_projects.contains(&2),
"Project 2 should not be selected with tight budget"
);
}
#[test]
fn test_deferred_value_positive_for_positive_npv_project() {
let planner = sample_planner();
let proj = sample_project(99, 10_000.0, 2025); let npv = planner.npv(&proj);
let deferred_val = planner.plan_deferred(&proj, 5);
if npv > 0.0 {
assert!(
deferred_val > 0.0,
"Deferral value should be positive for a positive-NPV project: npv={npv:.2}"
);
}
}
#[test]
fn test_bcr_positive() {
let planner = sample_planner();
let proj = sample_project(1, 100_000.0, 2025);
let bcr = planner.benefit_cost_ratio(&proj, 100_000.0);
assert!(bcr >= 0.0, "BCR must be non-negative");
}
fn sample_forecast() -> DistributionLoadForecast {
let scenario = GrowthScenario {
name: "Central".to_string(),
annual_energy_growth_pct: 2.0,
peak_growth_pct: 2.5,
ev_penetration_by_year: vec![0.0, 0.02, 0.05, 0.08, 0.12],
pv_penetration_by_year: vec![0.05, 0.08, 0.10, 0.12, 0.15],
dr_participation_pct: 5.0,
};
DistributionLoadForecast {
base_demand_mw: 50.0,
base_peak_mw: 70.0,
growth_scenarios: vec![scenario],
}
}
#[test]
fn test_forecast_peak_increases_with_growth() {
let fc = sample_forecast();
let peak_0 = fc.forecast_peak_mw(0, 0).expect("forecast 0");
let peak_10 = fc.forecast_peak_mw(0, 10).expect("forecast 10");
assert!(peak_10 > peak_0, "Peak must grow over time");
}
#[test]
fn test_net_peak_less_than_gross_peak() {
let scenario = GrowthScenario {
name: "High PV".to_string(),
annual_energy_growth_pct: 1.0,
peak_growth_pct: 1.0,
ev_penetration_by_year: vec![0.0],
pv_penetration_by_year: vec![0.5],
dr_participation_pct: 20.0,
};
let fc = DistributionLoadForecast {
base_demand_mw: 50.0,
base_peak_mw: 70.0,
growth_scenarios: vec![scenario],
};
let gross = fc.forecast_peak_mw(0, 0).expect("gross peak");
let net = fc.net_peak_with_der(0, 0).expect("net peak");
assert!(
net < gross,
"Net peak [MW] should be less than gross peak due to PV and DR: gross={gross:.2} net={net:.2}"
);
}
#[test]
fn test_planning_peak_ge_base_demand() {
let fc = sample_forecast();
let pp = fc.planning_peak(50.0);
assert!(
pp >= fc.base_peak_mw,
"Planning peak [MW] must be ≥ base peak [MW]"
);
}
#[test]
fn test_load_growth_factor_gt_one() {
let fc = sample_forecast();
let gf = fc.load_growth_factor(0, 5).expect("growth factor");
assert!(gf > 1.0, "Growth factor over 5 years must exceed 1.0");
}
fn sample_asset(id: usize, install_year: u32, life: u32) -> DistributionAsset {
DistributionAsset {
id,
asset_type: DistAssetType::Transformer,
installation_year: install_year,
expected_life_years: life,
condition_score: 70.0,
failure_rate_per_year: 0.05,
replacement_cost: 200_000.0,
criticality: 0.8,
}
}
#[test]
fn test_remaining_life_decreases_with_age() {
let assessor = AssetConditionAssessor {
assets: vec![sample_asset(1, 2000, 40)],
};
let a = &assessor.assets[0];
let rl_2010 = assessor.remaining_life_pct(a, 2010);
let rl_2020 = assessor.remaining_life_pct(a, 2020);
assert!(
rl_2010 > rl_2020,
"Remaining life [%] must decrease over time: 2010={rl_2010:.3} 2020={rl_2020:.3}"
);
}
#[test]
fn test_health_index_in_range() {
let assessor = AssetConditionAssessor {
assets: vec![sample_asset(1, 2010, 40)],
};
let a = &assessor.assets[0];
let hi = assessor.health_index(a, 2025);
assert!(
(0.0..=100.0).contains(&hi),
"Health index must be in [0, 100]: got {hi:.2}"
);
}
#[test]
fn test_top_priority_selects_high_failure_low_health() {
let mut a1 = sample_asset(1, 2000, 40); a1.failure_rate_per_year = 0.3;
let mut a2 = sample_asset(2, 2020, 40); a2.failure_rate_per_year = 0.01;
let assessor = AssetConditionAssessor {
assets: vec![a1, a2],
};
let top = assessor.top_priority_replacements(2025, 1);
assert_eq!(top.len(), 1);
assert_eq!(
top[0], 0,
"High failure-rate old asset must be top priority"
);
}
#[test]
fn test_preventive_maintenance_interval_positive() {
let asset = sample_asset(1, 2000, 40);
let rcm = RcmAnalyzer {
assets: vec![asset.clone()],
maintenance_budget: 20_000.0,
failure_consequence: 500_000.0,
};
let interval = rcm.preventive_maintenance_interval_years(&asset);
assert!(
interval > 0.0,
"Maintenance interval [years] must be positive: {interval:.3}"
);
}
#[test]
fn test_rcm_expected_failures_positive() {
let rcm = RcmAnalyzer {
assets: vec![sample_asset(1, 2000, 40), sample_asset(2, 2010, 40)],
maintenance_budget: 30_000.0,
failure_consequence: 200_000.0,
};
assert!(rcm.expected_annual_failures() > 0.0);
assert!(rcm.expected_annual_failure_cost() > 0.0);
}
fn sample_der_planner() -> DerIntegrationPlanner {
DerIntegrationPlanner {
feeder_capacity_mva: 20.0,
existing_load_mw: 10.0,
existing_der_mw: 3.0,
hosting_capacity_mw: 8.0,
der_candidates: vec![
DerCandidate {
id: 1,
der_type: DerCandidateType::Pv,
capacity_mw: 4.0,
location_bus: 5,
connection_cost: 80_000.0,
annual_generation_mwh: 5_000.0,
curtailment_risk_pct: 5.0,
},
DerCandidate {
id: 2,
der_type: DerCandidateType::Wind,
capacity_mw: 10.0,
location_bus: 7,
connection_cost: 200_000.0,
annual_generation_mwh: 20_000.0,
curtailment_risk_pct: 15.0,
},
],
}
}
#[test]
fn test_available_hosting_equals_capacity_minus_existing() {
let planner = sample_der_planner();
let expected = planner.hosting_capacity_mw - planner.existing_der_mw;
let actual = planner.available_hosting_mw();
assert!(
(actual - expected).abs() < 1e-9,
"Available hosting [MW] mismatch: expected {expected:.3} got {actual:.3}"
);
}
#[test]
fn test_can_integrate_false_when_exceeds_hosting() {
let planner = sample_der_planner();
let c2 = planner.der_candidates[1].clone();
assert!(
!planner.can_integrate(&c2),
"DER candidate exceeding hosting capacity must not be integrable"
);
}
#[test]
fn test_can_integrate_true_when_within_hosting() {
let planner = sample_der_planner();
let c1 = planner.der_candidates[0].clone(); assert!(
planner.can_integrate(&c1),
"DER candidate within hosting capacity must be integrable"
);
}
#[test]
fn test_strategy_budget_utilization_le_100() {
let mut strategy = LongTermStrategy::new(10, 500_000.0);
strategy.projects = vec![
sample_project(1, 100_000.0, 2025),
sample_project(2, 200_000.0, 2026),
sample_project(3, 50_000.0, 2027),
];
strategy.expansion_planner.candidate_projects = strategy.projects.clone();
let metrics = strategy.strategy_metrics();
assert!(
metrics.budget_utilization_pct <= 100.0,
"Budget utilization [%] must not exceed 100: got {:.2}",
metrics.budget_utilization_pct
);
}
#[test]
fn test_capex_gap_non_negative() {
let mut strategy = LongTermStrategy::new(10, 100_000.0);
strategy.projects = vec![sample_project(1, 500_000.0, 2025)];
let gap = strategy.capex_gap();
assert!(gap >= 0.0, "CAPEX gap [$] must be non-negative");
}
#[test]
fn test_strategy_new_defaults() {
let s = LongTermStrategy::new(5, 1_000_000.0);
assert_eq!(s.planning_horizon_years, 5);
assert!((s.total_budget - 1_000_000.0).abs() < 1e-6);
assert_eq!(s.expansion_planner.budget_per_year.len(), 5);
}
#[test]
fn test_forecast_energy_mwh_grows_with_years() {
let fc = sample_forecast();
let e5 = fc.forecast_energy_mwh(0, 5).expect("forecast_energy_mwh");
let e10 = fc.forecast_energy_mwh(0, 10).expect("forecast_energy_mwh");
assert!(
e10 > e5,
"energy forecast at year 10 ({e10:.1}) should exceed year 5 ({e5:.1})"
);
}
#[test]
fn test_age_years_reflects_installation_year() {
let asset = sample_asset(1, 2000, 40);
let assessor = AssetConditionAssessor {
assets: vec![asset.clone()],
};
assert_eq!(
assessor.age_years(&asset, 2025),
25,
"age should be 25 in 2025"
);
assert_eq!(
assessor.age_years(&asset, 2000),
0,
"age should be 0 in install year"
);
}
#[test]
fn test_total_replacement_cost_within_horizon_includes_near_end_assets() {
let old_asset = DistributionAsset {
id: 1,
asset_type: DistAssetType::Transformer,
installation_year: 1985,
expected_life_years: 40,
condition_score: 50.0,
failure_rate_per_year: 0.10,
replacement_cost: 300_000.0,
criticality: 0.9,
};
let assessor = AssetConditionAssessor {
assets: vec![old_asset],
};
let cost = assessor.total_replacement_cost_within_horizon(5, 2025);
assert!(
(cost - 300_000.0).abs() < 1e-6,
"expired asset should be included, got {cost}"
);
}
#[test]
fn test_risk_reduction_from_maintenance_half_of_failure_cost() {
let asset = sample_asset(1, 2000, 40); let rcm = RcmAnalyzer {
assets: vec![asset.clone()],
maintenance_budget: 20_000.0,
failure_consequence: 200_000.0,
};
let rr = rcm.risk_reduction_from_maintenance(&asset);
assert!(
(rr - 5_000.0).abs() < 1e-6,
"risk_reduction should be 5_000, got {rr}"
);
}
#[test]
fn test_maintenance_plan_length_equals_asset_count() {
let rcm = RcmAnalyzer {
assets: vec![sample_asset(1, 2000, 40), sample_asset(2, 2010, 40)],
maintenance_budget: 50_000.0,
failure_consequence: 300_000.0,
};
let plan = rcm.maintenance_plan();
assert_eq!(
plan.len(),
2,
"maintenance_plan should have one entry per asset, got {}",
plan.len()
);
}
#[test]
fn test_optimize_maintenance_budget_within_budget() {
let rcm = RcmAnalyzer {
assets: vec![sample_asset(1, 2000, 40), sample_asset(2, 2010, 40)],
maintenance_budget: 5_000.0,
failure_consequence: 300_000.0,
};
let plan = rcm.optimize_maintenance_budget();
assert!(
plan.total_cost <= rcm.maintenance_budget + 1e-6,
"total maintenance cost ({:.2}) must not exceed budget ({:.2})",
plan.total_cost,
rcm.maintenance_budget
);
assert!(
plan.risk_reduction_pct >= 0.0 && plan.risk_reduction_pct <= 100.0,
"risk_reduction_pct out of range: {}",
plan.risk_reduction_pct
);
}
#[test]
fn test_prioritize_candidates_sorted_descending() {
let planner = sample_der_planner();
let ranked = planner.prioritize_candidates();
assert_eq!(ranked.len(), 2, "should rank all 2 candidates");
if ranked.len() >= 2 {
assert!(
ranked[0].1 >= ranked[1].1,
"candidates should be in descending score order: {:.4} >= {:.4}",
ranked[0].1,
ranked[1].1
);
}
}
#[test]
fn test_total_capex_requirements_sums_all_project_costs() {
let mut strategy = LongTermStrategy::new(10, 1_000_000.0);
strategy.projects = vec![
sample_project(1, 100_000.0, 2025),
sample_project(2, 200_000.0, 2026),
sample_project(3, 50_000.0, 2027),
];
let total = strategy.total_capex_requirements();
assert!(
(total - 350_000.0).abs() < 1e-6,
"total CAPEX should be 350_000, got {total}"
);
}
}