use crate::error::{OxiGridError, Result};
use crate::network::topology::PowerNetwork;
use crate::optimize::expansion::robust_tep::{
InvestmentCandidate, LoadScenario, RobustTepConfig, RobustTepSolver, RobustnessCriterion,
};
#[derive(Debug, Clone)]
pub struct StochasticTepResult {
pub first_stage_investment: Vec<usize>,
pub second_stage_investment: Vec<Vec<usize>>,
pub total_expected_cost: f64,
pub value_of_stochastic_solution: f64,
pub expected_value_of_perfect_info: f64,
pub scenario_costs: Vec<f64>,
}
pub struct StochasticTepSolver {
pub scenarios: Vec<LoadScenario>,
pub candidates: Vec<InvestmentCandidate>,
pub budget: f64,
pub planning_years: usize,
}
impl StochasticTepSolver {
pub fn new(
scenarios: Vec<LoadScenario>,
candidates: Vec<InvestmentCandidate>,
budget: f64,
) -> Self {
Self {
scenarios,
candidates,
budget,
planning_years: 20,
}
}
pub fn with_planning_years(mut self, years: usize) -> Self {
self.planning_years = years;
self
}
pub fn solve(&self, network: &PowerNetwork) -> Result<StochasticTepResult> {
if self.scenarios.is_empty() {
return Err(OxiGridError::InvalidParameter(
"StochasticTepSolver: no scenarios provided".into(),
));
}
if self.candidates.is_empty() {
return Err(OxiGridError::InvalidParameter(
"StochasticTepSolver: no candidates provided".into(),
));
}
let stage1_config = RobustTepConfig {
candidates: self.candidates.clone(),
scenarios: self.scenarios.clone(),
planning_horizon_years: self.planning_years,
discount_rate: 0.08,
n1_security_required: false,
max_candidates: self.candidates.len(),
total_budget_m: self.budget * 0.6, robustness_criterion: RobustnessCriterion::MinMax,
};
let stage1_solver = RobustTepSolver::new(stage1_config);
let stage1_result = stage1_solver.solve(network)?;
let first_stage = stage1_result.selected_candidates.clone();
let spent_stage1: f64 = first_stage
.iter()
.map(|&i| self.candidates[i].investment_cost_m)
.sum();
let residual_budget = (self.budget - spent_stage1).max(0.0);
let mut second_stage: Vec<Vec<usize>> = Vec::with_capacity(self.scenarios.len());
let mut scenario_costs: Vec<f64> = Vec::with_capacity(self.scenarios.len());
for scenario in &self.scenarios {
let remaining_candidates: Vec<InvestmentCandidate> = self
.candidates
.iter()
.filter(|c| !first_stage.contains(&c.id))
.cloned()
.collect();
let stage2_additional = if remaining_candidates.is_empty() || residual_budget < 1.0 {
vec![]
} else {
let stage2_config = RobustTepConfig {
candidates: remaining_candidates.clone(),
scenarios: vec![scenario.clone()],
planning_horizon_years: self.planning_years,
discount_rate: 0.08,
n1_security_required: false,
max_candidates: remaining_candidates.len(),
total_budget_m: residual_budget,
robustness_criterion: RobustnessCriterion::MinMax,
};
let s2_solver = RobustTepSolver::new(stage2_config);
s2_solver
.solve(network)
.map(|r| r.selected_candidates)
.unwrap_or_default()
};
let combined: Vec<usize> = first_stage
.iter()
.chain(stage2_additional.iter())
.copied()
.collect();
let cost = stage1_solver
.evaluate_investment(network, &combined)
.map(|v| v.first().copied().unwrap_or(0.0))
.unwrap_or(0.0);
second_stage.push(stage2_additional);
scenario_costs.push(cost);
}
let ss: f64 = scenario_costs
.iter()
.zip(self.scenarios.iter())
.map(|(c, s)| s.probability * c)
.sum();
let eev = self.compute_eev(network);
let evpi_cost = self.compute_evpi(network);
let vss = (eev - ss).max(0.0);
let evpi = (ss - evpi_cost).max(0.0);
Ok(StochasticTepResult {
first_stage_investment: first_stage,
second_stage_investment: second_stage,
total_expected_cost: ss,
value_of_stochastic_solution: vss,
expected_value_of_perfect_info: evpi,
scenario_costs,
})
}
pub fn compute_eev(&self, network: &PowerNetwork) -> f64 {
let n_buses = network.buses.len();
let mean_load: Vec<f64> = (0..n_buses)
.map(|i| {
self.scenarios
.iter()
.map(|s| s.probability * s.load_mult(i))
.sum::<f64>()
})
.collect();
let mean_scenario = LoadScenario {
scenario_id: usize::MAX,
probability: 1.0,
load_multipliers: mean_load,
renewable_multipliers: vec![1.0; n_buses],
description: "mean_ev_scenario".into(),
};
let ev_config = RobustTepConfig {
candidates: self.candidates.clone(),
scenarios: vec![mean_scenario],
planning_horizon_years: self.planning_years,
discount_rate: 0.08,
n1_security_required: false,
max_candidates: self.candidates.len(),
total_budget_m: self.budget,
robustness_criterion: RobustnessCriterion::MinMax,
};
let ev_solver = RobustTepSolver::new(ev_config);
let ev_investment = match ev_solver.solve(network) {
Ok(r) => r.selected_candidates,
Err(_) => vec![],
};
let all_scenarios_config = RobustTepConfig {
candidates: self.candidates.clone(),
scenarios: self.scenarios.clone(),
planning_horizon_years: self.planning_years,
discount_rate: 0.08,
n1_security_required: false,
max_candidates: self.candidates.len(),
total_budget_m: self.budget,
robustness_criterion: RobustnessCriterion::MinMax,
};
let eval_solver = RobustTepSolver::new(all_scenarios_config);
eval_solver
.evaluate_investment(network, &ev_investment)
.map(|costs| {
costs
.iter()
.zip(self.scenarios.iter())
.map(|(c, s)| s.probability * c)
.sum()
})
.unwrap_or(f64::INFINITY)
}
pub fn compute_evpi(&self, network: &PowerNetwork) -> f64 {
let ws_cost: f64 = self
.scenarios
.iter()
.map(|scenario| {
let config = RobustTepConfig {
candidates: self.candidates.clone(),
scenarios: vec![scenario.clone()],
planning_horizon_years: self.planning_years,
discount_rate: 0.08,
n1_security_required: false,
max_candidates: self.candidates.len(),
total_budget_m: self.budget,
robustness_criterion: RobustnessCriterion::MinMax,
};
let solver = RobustTepSolver::new(config);
let investment = match solver.solve(network) {
Ok(r) => r.selected_candidates,
Err(_) => vec![],
};
solver
.evaluate_investment(network, &investment)
.map(|v| v.first().copied().unwrap_or(0.0))
.unwrap_or(0.0)
* scenario.probability
})
.sum();
ws_cost
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::topology::PowerNetwork;
use crate::optimize::expansion::robust_tep::InvestmentCandidate;
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 + i as f64 * 50.0,
investment_cost_m: 30.0 + i as f64 * 20.0,
annual_fixed_cost_m: 0.3,
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.4, 0.9, n_buses),
LoadScenario::uniform(1, 0.4, 1.0, n_buses),
LoadScenario::uniform(2, 0.2, 1.3, n_buses),
]
}
#[test]
fn test_stochastic_tep_runs() {
let net = make_network();
let n_buses = net.buses.len();
let solver = StochasticTepSolver::new(make_scenarios(n_buses), make_candidates(3), 200.0);
let result = solver.solve(&net).expect("stochastic solve");
assert!(result.total_expected_cost >= 0.0);
assert_eq!(
result.second_stage_investment.len(),
make_scenarios(n_buses).len()
);
}
#[test]
fn test_stochastic_vss_non_negative() {
let net = make_network();
let n_buses = net.buses.len();
let solver = StochasticTepSolver::new(make_scenarios(n_buses), make_candidates(2), 150.0);
let result = solver.solve(&net).expect("solve");
assert!(
result.value_of_stochastic_solution >= -1e-6,
"VSS = {} should be >= 0",
result.value_of_stochastic_solution
);
}
#[test]
fn test_stochastic_evpi_non_negative() {
let net = make_network();
let n_buses = net.buses.len();
let solver = StochasticTepSolver::new(make_scenarios(n_buses), make_candidates(2), 150.0);
let result = solver.solve(&net).expect("solve");
assert!(
result.expected_value_of_perfect_info >= -1e-6,
"EVPI = {} should be >= 0",
result.expected_value_of_perfect_info
);
}
#[test]
fn test_stochastic_scenario_costs_length() {
let net = make_network();
let n_buses = net.buses.len();
let scenarios = make_scenarios(n_buses);
let n_sc = scenarios.len();
let solver = StochasticTepSolver::new(scenarios, make_candidates(2), 200.0);
let result = solver.solve(&net).expect("solve");
assert_eq!(result.scenario_costs.len(), n_sc);
}
#[test]
fn test_stochastic_empty_scenarios_fails() {
let net = make_network();
let solver = StochasticTepSolver::new(vec![], make_candidates(2), 200.0);
assert!(solver.solve(&net).is_err());
}
#[test]
fn test_stochastic_empty_candidates_fails() {
let net = make_network();
let n_buses = net.buses.len();
let solver = StochasticTepSolver::new(make_scenarios(n_buses), vec![], 200.0);
assert!(solver.solve(&net).is_err());
}
#[test]
fn test_eev_positive() {
let net = make_network();
let n_buses = net.buses.len();
let solver = StochasticTepSolver::new(make_scenarios(n_buses), make_candidates(2), 150.0);
let eev = solver.compute_eev(&net);
assert!(eev.is_finite(), "EEV should be finite");
assert!(eev >= 0.0, "EEV should be >= 0, got {eev}");
}
#[test]
fn test_evpi_finite() {
let net = make_network();
let n_buses = net.buses.len();
let solver = StochasticTepSolver::new(make_scenarios(n_buses), make_candidates(2), 150.0);
let evpi_cost = solver.compute_evpi(&net);
assert!(evpi_cost.is_finite(), "WS cost (for EVPI) should be finite");
}
#[test]
fn test_with_planning_years() {
let net = make_network();
let n_buses = net.buses.len();
let solver = StochasticTepSolver::new(make_scenarios(n_buses), make_candidates(2), 150.0)
.with_planning_years(30);
assert_eq!(solver.planning_years, 30);
let result = solver.solve(&net).expect("solve with 30-year horizon");
assert!(result.total_expected_cost >= 0.0);
}
}