use crate::error::{OxiGridError, Result};
use crate::network::branch::Branch;
use crate::network::topology::PowerNetwork;
use crate::optimize::opf::dc_opf::{solve_dc_opf, GenCost};
#[derive(Debug, Clone)]
pub struct InvestmentCandidate {
pub id: usize,
pub from_bus: usize,
pub to_bus: usize,
pub capacity_mw: f64,
pub investment_cost_m: f64,
pub annual_fixed_cost_m: f64,
pub resistance_pu: f64,
pub reactance_pu: f64,
pub n_parallel_max: usize,
pub can_expand_existing: bool,
pub lead_time_years: f64,
}
impl InvestmentCandidate {
pub fn to_branch(&self) -> Branch {
Branch {
from_bus: self.from_bus,
to_bus: self.to_bus,
r: self.resistance_pu,
x: self.reactance_pu,
b: 0.0,
rate_a: self.capacity_mw,
rate_b: self.capacity_mw,
rate_c: self.capacity_mw,
tap: 0.0,
shift: 0.0,
status: true,
}
}
pub fn annualised_cost(&self, lifetime_years: f64, discount_rate: f64) -> f64 {
if discount_rate < 1e-10 || lifetime_years < 1.0 {
return self.investment_cost_m / lifetime_years.max(1.0);
}
let r = discount_rate;
let n = lifetime_years;
let crf = r * (1.0 + r).powf(n) / ((1.0 + r).powf(n) - 1.0);
self.investment_cost_m * crf + self.annual_fixed_cost_m
}
}
#[derive(Debug, Clone)]
pub struct LoadScenario {
pub scenario_id: usize,
pub probability: f64,
pub load_multipliers: Vec<f64>,
pub renewable_multipliers: Vec<f64>,
pub description: String,
}
impl LoadScenario {
pub fn uniform(id: usize, prob: f64, load_mult: f64, n_buses: usize) -> Self {
Self {
scenario_id: id,
probability: prob,
load_multipliers: vec![load_mult; n_buses],
renewable_multipliers: vec![1.0; n_buses],
description: format!("uniform_load_{load_mult:.2}"),
}
}
pub fn load_mult(&self, i: usize) -> f64 {
self.load_multipliers.get(i).copied().unwrap_or(1.0)
}
}
#[derive(Debug, Clone, Copy)]
pub enum RobustnessCriterion {
MinMax,
MinMaxRegret,
Percentile95,
MeanPlusStd {
k: f64,
},
}
#[derive(Debug, Clone)]
pub struct RobustTepConfig {
pub candidates: Vec<InvestmentCandidate>,
pub scenarios: Vec<LoadScenario>,
pub planning_horizon_years: usize,
pub discount_rate: f64,
pub n1_security_required: bool,
pub max_candidates: usize,
pub total_budget_m: f64,
pub robustness_criterion: RobustnessCriterion,
}
impl Default for RobustTepConfig {
fn default() -> Self {
Self {
candidates: Vec::new(),
scenarios: Vec::new(),
planning_horizon_years: 20,
discount_rate: 0.08,
n1_security_required: true,
max_candidates: 10,
total_budget_m: f64::INFINITY,
robustness_criterion: RobustnessCriterion::MinMax,
}
}
}
#[derive(Debug, Clone)]
pub struct TepResult {
pub selected_candidates: Vec<usize>,
pub n_parallel: Vec<usize>,
pub total_investment_cost_m: f64,
pub total_npc_m: f64,
pub expected_cost_m: f64,
pub worst_case_cost_m: f64,
pub n95_cost_m: f64,
pub regret: Vec<f64>,
pub n1_secure: bool,
pub unserved_energy_mwh: Vec<f64>,
pub loss_reduction_mwh_per_year: f64,
}
#[derive(Debug, Clone)]
pub struct SubproblemResult {
pub scenario_id: usize,
pub total_cost: f64,
pub unserved_energy_mwh: f64,
pub dual_variables: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct BendersCut {
pub cut_type: CutType,
pub coefficients: Vec<f64>,
pub rhs: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CutType {
Optimality,
Feasibility,
}
pub struct RobustTepSolver {
pub config: RobustTepConfig,
}
impl RobustTepSolver {
pub fn new(config: RobustTepConfig) -> Self {
Self { config }
}
pub fn solve(&self, network: &PowerNetwork) -> Result<TepResult> {
if self.config.candidates.is_empty() {
return Err(OxiGridError::InvalidParameter(
"RobustTepConfig: no candidates provided".into(),
));
}
if self.config.scenarios.is_empty() {
return Err(OxiGridError::InvalidParameter(
"RobustTepConfig: no scenarios provided".into(),
));
}
let max_benders_iter = 20;
let mut cuts: Vec<BendersCut> = Vec::new();
let mut current_investment = self.greedy_investment(network);
for _iter in 0..max_benders_iter {
let sp_results = self.evaluate_subproblems(network, ¤t_investment)?;
let cut = self.generate_cut(¤t_investment, &sp_results);
cuts.push(cut);
let new_investment = self.solve_master(&cuts, self.config.total_budget_m)?;
if new_investment == current_investment {
break;
}
current_investment = new_investment;
}
let scenario_costs = self.evaluate_investment(network, ¤t_investment)?;
self.build_result(network, ¤t_investment, &scenario_costs)
}
fn solve_master(&self, cuts: &[BendersCut], budget: f64) -> Result<Vec<usize>> {
let n = self.config.candidates.len();
let mut scores: Vec<(f64, usize)> = (0..n)
.map(|i| {
let cand = &self.config.candidates[i];
let horizon = self.config.planning_horizon_years as f64;
let ann_cost = cand.annualised_cost(horizon, self.config.discount_rate);
let base_score = if ann_cost > 1e-10 {
cand.capacity_mw / ann_cost
} else {
0.0
};
let cut_penalty: f64 = cuts
.iter()
.map(|c| c.coefficients.get(i).copied().unwrap_or(0.0))
.sum::<f64>()
/ cuts.len().max(1) as f64;
let score = base_score - cut_penalty.max(0.0) * 0.01;
(score, i)
})
.collect();
scores.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
let mut selected: Vec<usize> = Vec::new();
let mut spent = 0.0_f64;
for (_, idx) in &scores {
let cand = &self.config.candidates[*idx];
if selected.len() >= self.config.max_candidates {
break;
}
if spent + cand.investment_cost_m <= budget + 1e-6 {
selected.push(*idx);
spent += cand.investment_cost_m;
}
}
Ok(selected)
}
fn solve_subproblem(
&self,
network: &PowerNetwork,
investment: &[usize],
scenario: &LoadScenario,
) -> Result<SubproblemResult> {
let mut aug_net = network.clone();
for &idx in investment {
let cand = &self.config.candidates[idx];
aug_net.branches.push(cand.to_branch());
}
for (i, bus) in aug_net.buses.iter_mut().enumerate() {
let mult = scenario.load_mult(i);
bus.pd = crate::units::Power(bus.pd.0 * mult);
}
let gen_costs: Vec<GenCost> = aug_net
.generators
.iter()
.map(|g| GenCost::quadratic(0.0, 30.0, 0.05, g.pmin.max(0.0), g.pmax.max(1.0)))
.collect();
let total_cost_per_h = match solve_dc_opf(&aug_net, &gen_costs) {
Ok(res) => res.total_cost,
Err(_) => {
let total_load: f64 = aug_net.buses.iter().map(|b| b.pd.0).sum();
total_load * 10_000.0 }
};
let total_cost_m = total_cost_per_h * 8760.0 * 1e-6;
let n_cands = self.config.candidates.len();
let mut duals = vec![0.0_f64; n_cands];
for (j, cand) in self.config.candidates.iter().enumerate() {
if investment.contains(&j) {
duals[j] = -cand.capacity_mw * 1e-3;
} else {
duals[j] = cand.capacity_mw * 1e-3;
}
}
let total_load: f64 = network
.buses
.iter()
.enumerate()
.map(|(i, b)| b.pd.0 * scenario.load_mult(i))
.sum();
let unserved = if total_cost_per_h > total_load * 1000.0 {
total_load * 0.01 } else {
0.0
};
Ok(SubproblemResult {
scenario_id: scenario.scenario_id,
total_cost: total_cost_m,
unserved_energy_mwh: unserved,
dual_variables: duals,
})
}
fn evaluate_subproblems(
&self,
network: &PowerNetwork,
investment: &[usize],
) -> Result<Vec<SubproblemResult>> {
self.config
.scenarios
.iter()
.map(|sc| self.solve_subproblem(network, investment, sc))
.collect()
}
pub fn generate_cut(
&self,
investment: &[usize],
sp_results: &[SubproblemResult],
) -> BendersCut {
let n = self.config.candidates.len();
let mut coefficients = vec![0.0_f64; n];
for sp in sp_results {
let prob = self
.config
.scenarios
.iter()
.find(|s| s.scenario_id == sp.scenario_id)
.map(|s| s.probability)
.unwrap_or(1.0 / sp_results.len() as f64);
for (j, dual) in sp.dual_variables.iter().enumerate() {
coefficients[j] += prob * dual;
}
}
let expected_cost: f64 = sp_results
.iter()
.zip(self.config.scenarios.iter())
.map(|(sp, sc)| sc.probability * sp.total_cost)
.sum();
let correction: f64 = investment
.iter()
.map(|&i| coefficients.get(i).copied().unwrap_or(0.0))
.sum();
let rhs = expected_cost - correction;
BendersCut {
cut_type: CutType::Optimality,
coefficients,
rhs,
}
}
pub fn evaluate_investment(
&self,
network: &PowerNetwork,
candidates: &[usize],
) -> Result<Vec<f64>> {
self.config
.scenarios
.iter()
.map(|sc| {
self.solve_subproblem(network, candidates, sc)
.map(|r| r.total_cost)
})
.collect()
}
pub fn greedy_investment(&self, _network: &PowerNetwork) -> Vec<usize> {
let mut ranked: Vec<(f64, usize)> = self
.config
.candidates
.iter()
.enumerate()
.map(|(i, c)| {
let score = if c.investment_cost_m > 1e-10 {
c.capacity_mw / c.investment_cost_m
} else {
0.0
};
(score, i)
})
.collect();
ranked.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
let mut selected = Vec::new();
let mut spent = 0.0_f64;
for (_, idx) in ranked {
if selected.len() >= self.config.max_candidates {
break;
}
let cost = self.config.candidates[idx].investment_cost_m;
if spent + cost <= self.config.total_budget_m + 1e-6 {
selected.push(idx);
spent += cost;
}
}
selected
}
fn check_n1_security(&self, network: &PowerNetwork, investment: &[usize]) -> bool {
let mut aug_net = network.clone();
for &idx in investment {
aug_net
.branches
.push(self.config.candidates[idx].to_branch());
}
let gen_costs: Vec<GenCost> = aug_net
.generators
.iter()
.map(|g| GenCost::quadratic(0.0, 30.0, 0.05, g.pmin.max(0.0), g.pmax.max(1.0)))
.collect();
if solve_dc_opf(&aug_net, &gen_costs).is_err() {
return false;
}
let n_branches = aug_net.branches.len();
for k in 0..n_branches {
let mut contingency = aug_net.clone();
contingency.branches[k].status = false;
if solve_dc_opf(&contingency, &gen_costs).is_err() {
return false;
}
}
true
}
fn build_result(
&self,
network: &PowerNetwork,
selected: &[usize],
scenario_costs: &[f64],
) -> Result<TepResult> {
let total_investment = selected
.iter()
.map(|&i| self.config.candidates[i].investment_cost_m)
.sum::<f64>();
let horizon = self.config.planning_horizon_years as f64;
let r = self.config.discount_rate;
let annuity_factor = if r > 1e-10 {
(1.0 - (1.0 + r).powf(-horizon)) / r
} else {
horizon
};
let annual_om: f64 = selected
.iter()
.map(|&i| self.config.candidates[i].annual_fixed_cost_m)
.sum();
let total_npc = total_investment + annual_om * annuity_factor;
let expected_cost: f64 = scenario_costs
.iter()
.zip(self.config.scenarios.iter())
.map(|(c, s)| s.probability * c)
.sum();
let worst_case = scenario_costs
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let n95 = percentile_95(scenario_costs);
let min_cost = scenario_costs.iter().copied().fold(f64::INFINITY, f64::min);
let regret: Vec<f64> = scenario_costs
.iter()
.map(|c| (c - min_cost).max(0.0))
.collect();
let unserved: Vec<f64> = self
.config
.scenarios
.iter()
.map(|sc| {
self.solve_subproblem(network, selected, sc)
.map(|r| r.unserved_energy_mwh)
.unwrap_or(0.0)
})
.collect();
let base_costs = self
.config
.scenarios
.iter()
.map(|sc| {
self.solve_subproblem(network, &[], sc)
.map(|r| r.total_cost)
.unwrap_or(0.0)
})
.collect::<Vec<_>>();
let base_expected: f64 = base_costs
.iter()
.zip(self.config.scenarios.iter())
.map(|(c, s)| s.probability * c)
.sum();
let cost_improvement = (base_expected - expected_cost).max(0.0);
let loss_reduction_mwh = cost_improvement * 1e6 / 50.0;
let n1_secure = if self.config.n1_security_required {
self.check_n1_security(network, selected)
} else {
true
};
Ok(TepResult {
selected_candidates: selected.to_vec(),
n_parallel: selected.iter().map(|_| 1_usize).collect(),
total_investment_cost_m: total_investment,
total_npc_m: total_npc,
expected_cost_m: expected_cost,
worst_case_cost_m: worst_case,
n95_cost_m: n95,
regret,
n1_secure,
unserved_energy_mwh: unserved,
loss_reduction_mwh_per_year: loss_reduction_mwh,
})
}
}
pub fn compute_npv(cash_flows: &[f64], discount_rate: f64) -> f64 {
cash_flows
.iter()
.enumerate()
.map(|(t, &c)| c / (1.0 + discount_rate).powf(t as f64))
.sum()
}
pub fn compute_lcoe(
investment_cost: f64,
annual_om_cost: f64,
annual_energy_mwh: f64,
lifetime_years: usize,
discount_rate: f64,
) -> f64 {
if annual_energy_mwh < 1e-10 || lifetime_years == 0 {
return 0.0;
}
let n = lifetime_years as f64;
let r = discount_rate;
let annuity_factor = if r > 1e-10 {
(1.0 - (1.0 + r).powf(-n)) / r
} else {
n
};
let pv_costs = investment_cost + annual_om_cost * annuity_factor;
let pv_energy = annual_energy_mwh * annuity_factor;
if pv_energy < 1e-10 {
return 0.0;
}
pv_costs / pv_energy
}
pub fn compute_irr(cash_flows: &[f64]) -> Option<f64> {
if cash_flows.is_empty() {
return None;
}
let has_negative = cash_flows.iter().any(|&c| c < 0.0);
let has_positive = cash_flows.iter().any(|&c| c > 0.0);
if !has_negative || !has_positive {
return None;
}
let lo = 0.0_f64;
let hi = 2.0_f64;
let npv_lo = compute_npv(cash_flows, lo);
let npv_hi = compute_npv(cash_flows, hi);
if npv_lo * npv_hi > 0.0 {
return None;
}
let mut a = lo;
let mut b = hi;
let tol = 1e-8;
let max_iter = 200;
for _ in 0..max_iter {
let mid = 0.5 * (a + b);
let npv_mid = compute_npv(cash_flows, mid);
if npv_mid.abs() < tol {
return Some(mid);
}
if compute_npv(cash_flows, a) * npv_mid < 0.0 {
b = mid;
} else {
a = mid;
}
if (b - a) < tol {
break;
}
}
Some(0.5 * (a + b))
}
fn percentile_95(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let n = sorted.len();
let idx_f = 0.95 * (n - 1) as f64;
let lo = idx_f.floor() as usize;
let hi = idx_f.ceil() as usize;
if lo == hi {
return sorted[lo];
}
let frac = idx_f - lo as f64;
sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::topology::PowerNetwork;
fn make_network() -> PowerNetwork {
PowerNetwork::from_matpower(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ieee14.m"))
.expect("ieee14")
}
fn make_candidates(n: usize) -> Vec<InvestmentCandidate> {
(0..n)
.map(|i| InvestmentCandidate {
id: i,
from_bus: 1,
to_bus: 5,
capacity_mw: 100.0,
investment_cost_m: 50.0,
annual_fixed_cost_m: 0.5,
resistance_pu: 0.01,
reactance_pu: 0.05,
n_parallel_max: 2,
can_expand_existing: false,
lead_time_years: 3.0,
})
.collect()
}
fn make_scenarios(n_buses: usize) -> Vec<LoadScenario> {
vec![
LoadScenario::uniform(0, 0.5, 1.0, n_buses),
LoadScenario::uniform(1, 0.5, 1.2, n_buses),
]
}
#[test]
fn test_robust_tep_selects_within_budget() {
let net = make_network();
let n_buses = net.buses.len();
let candidates = make_candidates(3);
let config = RobustTepConfig {
candidates: candidates.clone(),
scenarios: make_scenarios(n_buses),
total_budget_m: 120.0, max_candidates: 5,
..Default::default()
};
let solver = RobustTepSolver::new(config);
let result = solver.solve(&net).expect("solve");
assert!(
result.total_investment_cost_m <= 120.0 + 1e-6,
"investment {} exceeds budget 120",
result.total_investment_cost_m
);
}
#[test]
fn test_robust_tep_n1_secure() {
let net = make_network();
let n_buses = net.buses.len();
let config = RobustTepConfig {
candidates: make_candidates(1),
scenarios: make_scenarios(n_buses),
n1_security_required: true,
total_budget_m: 200.0,
..Default::default()
};
let solver = RobustTepSolver::new(config);
let result = solver.solve(&net).expect("solve");
let _ = result.n1_secure; }
#[test]
fn test_benders_cut_coefficients() {
let net = make_network();
let n_buses = net.buses.len();
let candidates = make_candidates(4);
let n_cands = candidates.len();
let config = RobustTepConfig {
candidates,
scenarios: make_scenarios(n_buses),
total_budget_m: 300.0,
..Default::default()
};
let solver = RobustTepSolver::new(config);
let investment = solver.greedy_investment(&net);
let sp_results = solver
.evaluate_subproblems(&net, &investment)
.expect("subproblems");
let cut = solver.generate_cut(&investment, &sp_results);
assert_eq!(
cut.coefficients.len(),
n_cands,
"cut coefficients length mismatch"
);
}
#[test]
fn test_greedy_investment_feasible() {
let net = make_network();
let budget = 75.0_f64;
let config = RobustTepConfig {
candidates: make_candidates(3),
scenarios: make_scenarios(net.buses.len()),
total_budget_m: budget,
max_candidates: 10,
..Default::default()
};
let solver = RobustTepSolver::new(config);
let selected = solver.greedy_investment(&net);
let spent: f64 = selected
.iter()
.map(|&i| solver.config.candidates[i].investment_cost_m)
.sum();
assert!(
spent <= budget + 1e-6,
"greedy spent {spent} exceeds budget {budget}"
);
}
#[test]
fn test_tep_empty_candidates_fails() {
let net = make_network();
let config = RobustTepConfig {
candidates: vec![],
scenarios: make_scenarios(net.buses.len()),
..Default::default()
};
let solver = RobustTepSolver::new(config);
assert!(solver.solve(&net).is_err());
}
#[test]
fn test_tep_empty_scenarios_fails() {
let net = make_network();
let config = RobustTepConfig {
candidates: make_candidates(1),
scenarios: vec![],
..Default::default()
};
let solver = RobustTepSolver::new(config);
assert!(solver.solve(&net).is_err());
}
#[test]
fn test_npv_single_period() {
let npv = compute_npv(&[0.0, 100.0], 0.1);
assert!(
(npv - 90.909).abs() < 0.01,
"NPV = {npv}, expected ≈ 90.909"
);
}
#[test]
fn test_npv_zero_rate() {
let npv = compute_npv(&[100.0, 200.0, -50.0], 0.0);
assert!((npv - 250.0).abs() < 1e-10, "NPV at r=0: {npv}");
}
#[test]
fn test_lcoe_calculation() {
let lcoe = compute_lcoe(1000.0, 20.0, 1000.0, 20, 0.08);
assert!(lcoe > 0.0, "LCOE should be positive, got {lcoe}");
assert!(lcoe < 1000.0, "LCOE unreasonably large: {lcoe}");
}
#[test]
fn test_lcoe_zero_energy_returns_zero() {
let lcoe = compute_lcoe(1000.0, 20.0, 0.0, 20, 0.08);
assert_eq!(lcoe, 0.0);
}
#[test]
fn test_irr_simple() {
let irr = compute_irr(&[-100.0, 120.0]).expect("IRR exists");
assert!(
(irr - 0.20).abs() < 1e-4,
"IRR = {:.4}, expected ≈ 0.20",
irr
);
}
#[test]
fn test_irr_no_sign_change_returns_none() {
let irr = compute_irr(&[-100.0, -50.0]);
assert!(irr.is_none(), "Expected None for all-negative cash flows");
}
#[test]
fn test_irr_multi_period() {
let irr = compute_irr(&[-1000.0, 300.0, 300.0, 300.0, 300.0, 300.0]).expect("IRR exists");
assert!(
irr > 0.10 && irr < 0.30,
"IRR = {:.4} out of expected range [0.10, 0.30]",
irr
);
}
#[test]
fn test_percentile_95_sorted() {
let values: Vec<f64> = (1..=100).map(|i| i as f64).collect();
let p95 = percentile_95(&values);
assert!((p95 - 95.0).abs() < 2.0, "p95 = {p95}");
}
#[test]
fn test_percentile_95_single() {
let p95 = percentile_95(&[42.0]);
assert_eq!(p95, 42.0);
}
}