#[derive(Debug, Clone, PartialEq)]
pub enum DistributionAssetType {
Substation,
PrimaryFeeder,
SecondaryFeeder,
Transformer,
CapacitorBank,
VoltageRegulator,
SectionalizingSwitch,
AutoRecloser,
UndergroundCable,
OverheadLine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlanningHorizon {
ShortTerm,
MediumTerm,
LongTerm,
}
impl PlanningHorizon {
pub fn years(&self) -> usize {
match self {
PlanningHorizon::ShortTerm => 5,
PlanningHorizon::MediumTerm => 15,
PlanningHorizon::LongTerm => 30,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum LoadGrowthModel {
Linear,
Exponential,
Logistic,
SPatternGrowth,
Saturation,
}
#[derive(Debug, Clone)]
pub struct DistributionAssetNew {
pub id: usize,
pub name: String,
pub asset_type: DistributionAssetType,
pub capacity_kva: f64,
pub current_loading_pct: f64,
pub age_years: f64,
pub expected_lifetime_years: f64,
pub capital_cost_usd: f64,
pub annual_om_usd: f64,
pub installation_year: u32,
pub bus_id: usize,
pub feeder_id: usize,
pub criticality: f64,
}
#[derive(Debug, Clone)]
pub struct LoadForecast {
pub feeder_id: usize,
pub base_load_kw: f64,
pub peak_load_kw: f64,
pub annual_growth_rate: f64,
pub growth_model: LoadGrowthModel,
pub horizon_years: usize,
pub ev_adoption_factor: f64,
pub solar_pen_factor: f64,
}
#[derive(Debug, Clone)]
pub struct CapacityNeed {
pub feeder_id: usize,
pub year: u32,
pub capacity_deficit_kva: f64,
pub cause: String,
pub urgency_score: f64,
pub recommended_action: String,
}
#[derive(Debug, Clone)]
pub struct ExpansionProject {
pub id: usize,
pub name: String,
pub asset_type: DistributionAssetType,
pub feeder_id: usize,
pub capacity_added_kva: f64,
pub capital_cost_usd: f64,
pub annual_savings_usd: f64,
pub implementation_year: u32,
pub lead_time_years: f64,
pub npv_usd: f64,
pub benefit_cost_ratio: f64,
pub priority_score: f64,
}
#[derive(Debug, Clone)]
pub struct DistributionPlan {
pub planning_horizon: PlanningHorizon,
pub base_year: u32,
pub projects: Vec<ExpansionProject>,
pub total_capital_cost_usd: f64,
pub total_npv_usd: f64,
pub reliability_improvement: f64,
pub loss_reduction_mwh: f64,
pub renewable_integration_mw: f64,
pub co2_reduction_tpy: f64,
}
#[derive(Debug, Clone)]
pub struct DerIntegrationPlan {
pub feeder_id: usize,
pub solar_hosting_capacity_kw: f64,
pub ev_hosting_capacity_kw: f64,
pub bess_hosting_capacity_kw: f64,
pub required_upgrades: Vec<String>,
pub upgrade_cost_usd: f64,
pub net_benefit_usd: f64,
}
#[derive(Debug, Clone)]
pub struct DistributionPlanner {
pub assets: Vec<DistributionAssetNew>,
pub load_forecasts: Vec<LoadForecast>,
pub base_year: u32,
pub discount_rate: f64,
pub voll_usd_per_kwh: f64,
pub loss_factor: f64,
}
impl DistributionPlanner {
pub fn new(
assets: Vec<DistributionAssetNew>,
load_forecasts: Vec<LoadForecast>,
base_year: u32,
) -> Self {
Self {
assets,
load_forecasts,
base_year,
discount_rate: 0.07,
voll_usd_per_kwh: 10.0,
loss_factor: 0.08,
}
}
pub fn forecast_load(&self, feeder_id: usize, year: u32) -> f64 {
let fc = match self
.load_forecasts
.iter()
.find(|f| f.feeder_id == feeder_id)
{
Some(f) => f,
None => return 0.0,
};
let t = year.saturating_sub(self.base_year) as f64;
let g = fc.annual_growth_rate;
let l0 = fc.base_load_kw;
let gross = match fc.growth_model {
LoadGrowthModel::Linear => l0 * (1.0 + g * t),
LoadGrowthModel::Exponential => l0 * (1.0 + g).powf(t),
LoadGrowthModel::Logistic => {
let l_max = 2.0 * l0;
let ratio = l_max / l0 - 1.0;
l_max / (1.0 + ratio * (-g * t).exp())
}
LoadGrowthModel::SPatternGrowth => {
let delay = fc.horizon_years as f64 / 3.0;
let t_eff = (t - delay).max(0.0);
let l_max = 2.0 * l0;
let ratio = l_max / l0 - 1.0;
l_max / (1.0 + ratio * (-g * t_eff).exp())
}
LoadGrowthModel::Saturation => {
let l_max = 1.5 * l0;
l_max * (1.0 - (-g * t / 5.0).exp())
}
};
let ev_uplift = l0 * fc.ev_adoption_factor * (t / fc.horizon_years.max(1) as f64).min(1.0);
let solar_reduction =
l0 * fc.solar_pen_factor * (t / fc.horizon_years.max(1) as f64).min(1.0);
(gross + ev_uplift - solar_reduction).max(0.0)
}
pub fn identify_capacity_needs(&self, year: u32) -> Vec<CapacityNeed> {
let mut needs = Vec::new();
for fc in &self.load_forecasts {
let capacity_kva: f64 = self
.assets
.iter()
.filter(|a| a.feeder_id == fc.feeder_id)
.map(|a| a.capacity_kva)
.sum::<f64>()
.max(1.0);
let forecast_kw = self.forecast_load(fc.feeder_id, year);
let forecast_kva = forecast_kw / 0.9;
let loading_pct = forecast_kva / capacity_kva * 100.0;
if loading_pct > 80.0 {
let deficit = forecast_kva - capacity_kva * 0.8;
let t = year.saturating_sub(self.base_year) as f64;
let urgency = (loading_pct / 100.0).min(1.0);
let cause = if fc.ev_adoption_factor > 0.05 {
"EV adoption".to_string()
} else if t < 3.0 {
"aging asset".to_string()
} else {
"load growth".to_string()
};
needs.push(CapacityNeed {
feeder_id: fc.feeder_id,
year,
capacity_deficit_kva: deficit.max(0.0),
cause,
urgency_score: urgency,
recommended_action: "Upgrade feeder or add substation capacity".to_string(),
});
}
}
needs
}
pub fn generate_expansion_projects(&self, needs: &[CapacityNeed]) -> Vec<ExpansionProject> {
needs
.iter()
.enumerate()
.map(|(idx, need)| {
let capacity_added_kva = need.capacity_deficit_kva * 1.5; let capital_cost_usd = capacity_added_kva * 200.0; let annual_savings_usd =
Self::estimate_loss_savings(capacity_added_kva, need.urgency_score * 100.0)
* 50.0 + need.urgency_score * self.voll_usd_per_kwh * capacity_added_kva * 0.01;
let npv = Self::compute_npv_with_rate(
capital_cost_usd,
annual_savings_usd,
20.0,
self.discount_rate,
);
let bcr = if capital_cost_usd > 0.0 {
(annual_savings_usd * 10.0) / capital_cost_usd
} else {
1.0
};
ExpansionProject {
id: idx + 1,
name: format!("Feeder-{}-Upgrade-{}", need.feeder_id, idx + 1),
asset_type: DistributionAssetType::PrimaryFeeder,
feeder_id: need.feeder_id,
capacity_added_kva,
capital_cost_usd,
annual_savings_usd,
implementation_year: need.year,
lead_time_years: 1.5,
npv_usd: npv,
benefit_cost_ratio: bcr,
priority_score: 0.0, }
})
.collect()
}
pub fn rank_projects_by_priority(&self, projects: &[ExpansionProject]) -> Vec<usize> {
if projects.is_empty() {
return vec![];
}
let max_cap = projects
.iter()
.map(|p| p.capacity_added_kva)
.fold(f64::NEG_INFINITY, f64::max)
.max(1.0);
let mut scored: Vec<(usize, f64)> = projects
.iter()
.enumerate()
.map(|(i, p)| {
let urgency = (p.benefit_cost_ratio - 1.0).clamp(0.0, 5.0) / 5.0;
let bcr_term = (p.benefit_cost_ratio - 1.0).max(0.0) / 5.0;
let year_term = 1.0
- (p.implementation_year.saturating_sub(self.base_year) as f64 / 30.0).min(1.0);
let cap_term = p.capacity_added_kva / max_cap;
let score = 0.4 * urgency + 0.3 * bcr_term + 0.2 * year_term + 0.1 * cap_term;
(i, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.iter().map(|(i, _)| *i).collect()
}
pub fn create_distribution_plan(&self, horizon: PlanningHorizon) -> DistributionPlan {
let horizon_years = horizon.years() as u32;
let target_year = self.base_year + horizon_years;
let needs = self.identify_capacity_needs(target_year);
let mut projects = self.generate_expansion_projects(&needs);
let ranked = self.rank_projects_by_priority(&projects);
let max_cap = projects
.iter()
.map(|p| p.capacity_added_kva)
.fold(f64::NEG_INFINITY, f64::max)
.max(1.0);
for (i, p) in projects.iter_mut().enumerate() {
let urgency = (p.benefit_cost_ratio - 1.0).clamp(0.0, 5.0) / 5.0;
let bcr_term = (p.benefit_cost_ratio - 1.0).max(0.0) / 5.0;
let year_term =
1.0 - (p.implementation_year.saturating_sub(self.base_year) as f64 / 30.0).min(1.0);
let cap_term = p.capacity_added_kva / max_cap;
p.priority_score = 0.4 * urgency + 0.3 * bcr_term + 0.2 * year_term + 0.1 * cap_term;
let _ = i;
}
let mut sorted_projects: Vec<ExpansionProject> =
ranked.iter().map(|&i| projects[i].clone()).collect();
if sorted_projects.is_empty() {
sorted_projects = projects;
}
let total_capital = sorted_projects.iter().map(|p| p.capital_cost_usd).sum();
let total_npv = sorted_projects.iter().map(|p| p.npv_usd).sum();
let loss_reduction: f64 = sorted_projects
.iter()
.map(|p| Self::estimate_loss_savings(p.capacity_added_kva, 90.0))
.sum();
let renewable_mw = sorted_projects
.iter()
.map(|p| p.capacity_added_kva / 1000.0)
.sum::<f64>()
* 0.3;
let co2_reduction = loss_reduction * 0.4;
DistributionPlan {
planning_horizon: horizon,
base_year: self.base_year,
projects: sorted_projects,
total_capital_cost_usd: total_capital,
total_npv_usd: total_npv,
reliability_improvement: 15.0, loss_reduction_mwh: loss_reduction,
renewable_integration_mw: renewable_mw,
co2_reduction_tpy: co2_reduction,
}
}
pub fn analyze_der_integration(&self, feeder_id: usize) -> DerIntegrationPlan {
let transformer_kva: f64 = self
.assets
.iter()
.filter(|a| {
a.feeder_id == feeder_id
&& matches!(
a.asset_type,
DistributionAssetType::Transformer | DistributionAssetType::Substation
)
})
.map(|a| a.capacity_kva)
.sum::<f64>()
.max(500.0);
let total_feeder_kva: f64 = self
.assets
.iter()
.filter(|a| a.feeder_id == feeder_id)
.map(|a| a.capacity_kva)
.sum::<f64>()
.max(500.0);
let current_load_kw = self.forecast_load(feeder_id, self.base_year);
let current_kva = current_load_kw / 0.9;
let headroom_kva = (total_feeder_kva - current_kva).max(0.0);
let solar_kw = f64::min(transformer_kva * 0.3, headroom_kva);
let ev_kw = f64::min(transformer_kva * 0.4, headroom_kva * 0.5);
let bess_kw = transformer_kva * 0.5;
let mut upgrades = Vec::new();
if solar_kw < 200.0 {
upgrades.push("Upgrade distribution transformer for solar PV".to_string());
}
if ev_kw < 100.0 {
upgrades.push("Install smart EV charging management system".to_string());
}
if headroom_kva < 500.0 {
upgrades.push("Feeder reconductoring to increase capacity headroom".to_string());
}
if upgrades.is_empty() {
upgrades.push("No immediate upgrades required".to_string());
}
let upgrade_cost = upgrades.len() as f64 * 50_000.0;
let annual_der_benefit = (solar_kw + ev_kw) * 0.15 * 8760.0 * 0.05; let net_benefit =
Self::compute_npv_with_rate(upgrade_cost, annual_der_benefit, 20.0, self.discount_rate);
DerIntegrationPlan {
feeder_id,
solar_hosting_capacity_kw: solar_kw.max(0.0),
ev_hosting_capacity_kw: ev_kw.max(0.0),
bess_hosting_capacity_kw: bess_kw.max(0.0),
required_upgrades: upgrades,
upgrade_cost_usd: upgrade_cost,
net_benefit_usd: net_benefit,
}
}
pub fn compute_npv(capex: f64, annual_savings: f64, years: f64) -> f64 {
Self::compute_npv_with_rate(capex, annual_savings, years, 0.07)
}
fn compute_npv_with_rate(capex: f64, annual_savings: f64, years: f64, rate: f64) -> f64 {
let n = years.floor() as usize;
let pv_savings: f64 = (1..=n)
.map(|t| annual_savings / (1.0 + rate).powi(t as i32))
.sum();
-capex + pv_savings
}
pub fn estimate_loss_savings(capacity_added_kva: f64, loading_pct: f64) -> f64 {
let reduction_fraction = (loading_pct / 100.0).powi(2) * 0.1;
capacity_added_kva * reduction_fraction * 8760.0 / 1000.0 }
pub fn assess_aging_risk(&self) -> Vec<(usize, f64)> {
self.assets
.iter()
.filter_map(|a| {
if a.expected_lifetime_years <= 0.0 {
return None;
}
let age_ratio = a.age_years / a.expected_lifetime_years;
if age_ratio > 0.8 {
let risk = (age_ratio - 0.8) / 0.2 * a.criticality;
Some((a.id, risk.min(1.0)))
} else {
None
}
})
.collect()
}
}
#[cfg(test)]
mod new_planner_tests {
use super::*;
fn make_asset(
id: usize,
feeder_id: usize,
capacity_kva: f64,
loading_pct: f64,
age: f64,
life: f64,
) -> DistributionAssetNew {
DistributionAssetNew {
id,
name: format!("Asset-{}", id),
asset_type: DistributionAssetType::Transformer,
capacity_kva,
current_loading_pct: loading_pct,
age_years: age,
expected_lifetime_years: life,
capital_cost_usd: capacity_kva * 200.0,
annual_om_usd: 2_000.0,
installation_year: 2000,
bus_id: id,
feeder_id,
criticality: 0.8,
}
}
fn make_forecast(
feeder_id: usize,
base_kw: f64,
growth: f64,
model: LoadGrowthModel,
) -> LoadForecast {
LoadForecast {
feeder_id,
base_load_kw: base_kw,
peak_load_kw: base_kw * 1.3,
annual_growth_rate: growth,
growth_model: model,
horizon_years: 20,
ev_adoption_factor: 0.0,
solar_pen_factor: 0.0,
}
}
fn planner_with_overload() -> DistributionPlanner {
let assets = vec![make_asset(1, 1, 1000.0, 90.0, 5.0, 40.0)];
let forecasts = vec![LoadForecast {
feeder_id: 1,
base_load_kw: 900.0, peak_load_kw: 1000.0,
annual_growth_rate: 0.03,
growth_model: LoadGrowthModel::Exponential,
horizon_years: 20,
ev_adoption_factor: 0.0,
solar_pen_factor: 0.0,
}];
DistributionPlanner::new(assets, forecasts, 2025)
}
#[test]
fn test_load_forecast_linear() {
let p = DistributionPlanner::new(
vec![],
vec![make_forecast(1, 1000.0, 0.02, LoadGrowthModel::Linear)],
2025,
);
let l5 = p.forecast_load(1, 2030);
let expected = 1000.0 * (1.0 + 0.02 * 5.0);
assert!(
(l5 - expected).abs() < 0.1,
"Linear: got {l5:.2} expected {expected:.2}"
);
let _ = p.discount_rate; }
#[test]
fn test_load_forecast_exponential() {
let p = DistributionPlanner::new(
vec![],
vec![make_forecast(1, 1000.0, 0.03, LoadGrowthModel::Exponential)],
2025,
);
let l10 = p.forecast_load(1, 2035);
let expected = 1000.0 * (1.03_f64).powi(10);
assert!(
(l10 - expected).abs() < 0.1,
"Exponential: got {l10:.2} expected {expected:.2}"
);
}
#[test]
fn test_load_forecast_logistic() {
let p = DistributionPlanner::new(
vec![],
vec![make_forecast(1, 1000.0, 0.1, LoadGrowthModel::Logistic)],
2025,
);
let l20 = p.forecast_load(1, 2045);
assert!(l20 < 2100.0, "Logistic must approach saturation: {l20:.2}");
assert!(l20 > 1000.0, "Logistic must grow from base: {l20:.2}");
}
#[test]
fn test_load_forecast_ev_adoption() {
let mut fc = make_forecast(1, 1000.0, 0.02, LoadGrowthModel::Linear);
fc.ev_adoption_factor = 0.2; let p = DistributionPlanner::new(vec![], vec![fc], 2025);
let with_ev = p.forecast_load(1, 2035);
let p_no_ev = DistributionPlanner::new(
vec![],
vec![make_forecast(1, 1000.0, 0.02, LoadGrowthModel::Linear)],
2025,
);
let without_ev = p_no_ev.forecast_load(1, 2035);
assert!(
with_ev > without_ev,
"EV adoption must increase load: {with_ev:.2} vs {without_ev:.2}"
);
}
#[test]
fn test_load_forecast_solar_reduction() {
let mut fc = make_forecast(1, 1000.0, 0.02, LoadGrowthModel::Linear);
fc.solar_pen_factor = 0.15;
let p = DistributionPlanner::new(vec![], vec![fc], 2025);
let with_solar = p.forecast_load(1, 2035);
let p_no_solar = DistributionPlanner::new(
vec![],
vec![make_forecast(1, 1000.0, 0.02, LoadGrowthModel::Linear)],
2025,
);
let without_solar = p_no_solar.forecast_load(1, 2035);
assert!(
with_solar < without_solar,
"Solar must reduce net load: {with_solar:.2} vs {without_solar:.2}"
);
}
#[test]
fn test_capacity_needs_overloaded() {
let p = planner_with_overload();
let needs = p.identify_capacity_needs(2030);
assert!(
!needs.is_empty(),
"Overloaded feeder must generate a capacity need"
);
}
#[test]
fn test_capacity_needs_headroom() {
let assets = vec![make_asset(1, 1, 5000.0, 30.0, 5.0, 40.0)];
let forecasts = vec![make_forecast(1, 1000.0, 0.02, LoadGrowthModel::Linear)];
let p = DistributionPlanner::new(assets, forecasts, 2025);
let needs = p.identify_capacity_needs(2030);
assert!(
needs.is_empty(),
"Lightly loaded feeder must not generate capacity needs"
);
}
#[test]
fn test_expansion_projects_generated() {
let p = planner_with_overload();
let needs = p.identify_capacity_needs(2030);
let projects = p.generate_expansion_projects(&needs);
assert_eq!(projects.len(), needs.len(), "One project per need");
}
#[test]
fn test_project_npv_positive() {
let _p = DistributionPlanner::new(vec![], vec![], 2025);
let npv = DistributionPlanner::compute_npv(10_000.0, 5_000.0, 20.0);
assert!(
npv > 0.0,
"NPV must be positive for high-savings project: {npv:.2}"
);
}
#[test]
fn test_project_bcr_greater_one() {
let p = planner_with_overload();
let needs = p.identify_capacity_needs(2030);
let projects = p.generate_expansion_projects(&needs);
if !projects.is_empty() {
let any_viable = projects.iter().any(|pr| pr.benefit_cost_ratio >= 0.0);
assert!(any_viable, "All projects must have non-negative BCR");
}
}
#[test]
fn test_project_priority_sort() {
let p = planner_with_overload();
let needs = p.identify_capacity_needs(2030);
let projects = p.generate_expansion_projects(&needs);
let ranked = p.rank_projects_by_priority(&projects);
let mut sorted = ranked.clone();
sorted.sort_unstable();
let expected: Vec<usize> = (0..projects.len()).collect();
assert_eq!(sorted, expected, "Ranked indices must cover all projects");
}
#[test]
fn test_distribution_plan_total_cost() {
let p = planner_with_overload();
let plan = p.create_distribution_plan(PlanningHorizon::MediumTerm);
let computed: f64 = plan.projects.iter().map(|pr| pr.capital_cost_usd).sum();
assert!(
(plan.total_capital_cost_usd - computed).abs() < 1.0,
"Plan total cost must equal sum of project costs: {} vs {}",
plan.total_capital_cost_usd,
computed
);
}
#[test]
fn test_der_integration_solar_capacity() {
let assets = vec![make_asset(1, 1, 2000.0, 40.0, 5.0, 40.0)];
let forecasts = vec![make_forecast(1, 800.0, 0.02, LoadGrowthModel::Linear)];
let p = DistributionPlanner::new(assets, forecasts, 2025);
let der = p.analyze_der_integration(1);
assert!(
der.solar_hosting_capacity_kw >= 0.0,
"Solar hosting capacity must be non-negative"
);
}
#[test]
fn test_der_integration_ev_capacity() {
let assets = vec![make_asset(1, 1, 2000.0, 40.0, 5.0, 40.0)];
let forecasts = vec![make_forecast(1, 800.0, 0.02, LoadGrowthModel::Linear)];
let p = DistributionPlanner::new(assets, forecasts, 2025);
let der = p.analyze_der_integration(1);
assert!(
der.ev_hosting_capacity_kw >= 0.0,
"EV hosting capacity must be non-negative"
);
}
#[test]
fn test_der_upgrades_required() {
let assets = vec![make_asset(1, 1, 100.0, 95.0, 5.0, 40.0)];
let forecasts = vec![make_forecast(1, 90.0, 0.03, LoadGrowthModel::Exponential)];
let p = DistributionPlanner::new(assets, forecasts, 2025);
let der = p.analyze_der_integration(1);
assert!(
!der.required_upgrades.is_empty(),
"Upgrades list must not be empty"
);
}
#[test]
fn test_aging_risk_old_assets() {
let assets = vec![make_asset(1, 1, 1000.0, 50.0, 38.0, 40.0)]; let p = DistributionPlanner::new(assets, vec![], 2025);
let risks = p.assess_aging_risk();
assert!(
!risks.is_empty(),
"Old asset (95% of life used) must appear in aging risk list"
);
assert!(
risks[0].1 > 0.0,
"Risk score must be positive for old asset"
);
}
#[test]
fn test_aging_risk_new_assets() {
let assets = vec![make_asset(1, 1, 1000.0, 50.0, 2.0, 40.0)]; let p = DistributionPlanner::new(assets, vec![], 2025);
let risks = p.assess_aging_risk();
assert!(
risks.is_empty(),
"New asset (5% of life used) must not appear in aging risk list"
);
}
#[test]
fn test_loss_savings_proportional() {
let low = DistributionPlanner::estimate_loss_savings(1000.0, 50.0);
let high = DistributionPlanner::estimate_loss_savings(1000.0, 90.0);
assert!(
high > low,
"Higher loading must produce more loss savings: high={high:.4} low={low:.4}"
);
}
#[test]
fn test_planning_horizon_years() {
assert!(PlanningHorizon::LongTerm.years() > PlanningHorizon::MediumTerm.years());
assert!(PlanningHorizon::MediumTerm.years() > PlanningHorizon::ShortTerm.years());
}
#[test]
fn test_empty_assets() {
let p = DistributionPlanner::new(vec![], vec![], 2025);
let needs = p.identify_capacity_needs(2030);
assert!(needs.is_empty(), "No forecasts → no capacity needs");
let risks = p.assess_aging_risk();
assert!(risks.is_empty(), "No assets → no aging risks");
}
#[test]
fn test_multiple_feeders() {
let assets = vec![
make_asset(1, 1, 1000.0, 90.0, 5.0, 40.0),
make_asset(2, 2, 1000.0, 90.0, 5.0, 40.0),
make_asset(3, 3, 5000.0, 20.0, 2.0, 40.0),
];
let forecasts = vec![
make_forecast(1, 900.0, 0.03, LoadGrowthModel::Exponential),
make_forecast(2, 900.0, 0.03, LoadGrowthModel::Exponential),
make_forecast(3, 500.0, 0.01, LoadGrowthModel::Linear),
];
let p = DistributionPlanner::new(assets, forecasts, 2025);
let needs = p.identify_capacity_needs(2030);
let feeder_ids: Vec<usize> = needs.iter().map(|n| n.feeder_id).collect();
assert!(
feeder_ids.contains(&1) || feeder_ids.contains(&2),
"Overloaded feeders must be identified"
);
assert!(
!feeder_ids.contains(&3),
"Well-loaded feeder 3 must not appear"
);
}
#[test]
fn test_compute_npv_formula() {
let npv = DistributionPlanner::compute_npv(10_000.0, 3_000.0, 3.0);
let manual: f64 = -10_000.0
+ 3_000.0 / 1.07_f64.powi(1)
+ 3_000.0 / 1.07_f64.powi(2)
+ 3_000.0 / 1.07_f64.powi(3);
assert!(
(npv - manual).abs() < 0.01,
"NPV formula mismatch: got {npv:.4} expected {manual:.4}"
);
}
}