use std::collections::HashMap;
use std::f64::consts::PI;
#[derive(Debug, Clone, PartialEq)]
pub enum RenewableType {
OnshoreWind,
OffshoreWind,
UtilitySolar,
DistributedSolar,
SolarStorage,
Hydro,
Geothermal,
Tidal,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ScenarioWeightingMethod {
EqualWeight,
MomentsMatching,
WassersteinDistance,
KMeansClustering,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RiskMeasure {
ExpectedValue,
ValueAtRisk,
ConditionalValueAtRisk,
MeanVariance,
MinimaxRegret,
}
#[derive(Debug, Clone)]
pub struct RenewableCandidate {
pub id: usize,
pub name: String,
pub renewable_type: RenewableType,
pub location_id: usize,
pub capacity_mw: f64,
pub capacity_factor_mean: f64,
pub capacity_factor_std: f64,
pub capital_cost_musd: f64,
pub annual_opex_musd: f64,
pub lifetime_years: f64,
pub lead_time_years: f64,
pub interconnection_cost_musd: f64,
pub land_use_km2: f64,
pub co2_intensity_g_per_kwh: f64,
}
#[derive(Debug, Clone)]
pub struct EnergyScenario {
pub id: usize,
pub capacity_factors: Vec<f64>,
pub energy_price_usd_per_mwh: f64,
pub carbon_price_usd_per_tco2: f64,
pub probability: f64,
}
#[derive(Debug, Clone)]
pub struct PortfolioDecision {
pub candidate_id: usize,
pub selected: bool,
pub capacity_installed_mw: f64,
}
#[derive(Debug, Clone)]
pub struct PortfolioResult {
pub decisions: Vec<PortfolioDecision>,
pub expected_generation_gwh: f64,
pub expected_revenue_musd: f64,
pub expected_cost_musd: f64,
pub expected_npv_musd: f64,
pub npv_std_dev_musd: f64,
pub risk_measure_value_musd: f64,
pub co2_avoided_ktpy: f64,
pub land_use_km2: f64,
pub portfolio_capacity_mw: f64,
pub capacity_factor_portfolio: f64,
pub diversification_index: f64,
}
#[derive(Debug, Clone)]
pub struct StochasticPortfolioOptimizer {
pub candidates: Vec<RenewableCandidate>,
pub budget_musd: f64,
pub min_capacity_mw: f64,
pub max_capacity_mw: f64,
pub target_energy_gwh_per_year: f64,
pub n_scenarios: usize,
pub seed: u64,
pub risk_measure: RiskMeasure,
pub risk_aversion: f64,
pub discount_rate: f64,
pub co2_constraint_tpy: Option<f64>,
pub land_use_constraint_km2: Option<f64>,
}
#[inline]
fn lcg_next(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6_364_136_223_846_793_005_u64)
.wrapping_add(1_442_695_040_888_963_407_u64);
(*state as f64 + 1.0) / (u64::MAX as f64 + 1.0)
}
#[inline]
fn box_muller(u1: f64, u2: f64) -> f64 {
let u1_safe = u1.max(1e-300);
(-2.0 * u1_safe.ln()).sqrt() * (2.0 * PI * u2).cos()
}
#[inline]
fn sample_normal_clamped(state: &mut u64, mean: f64, std: f64, lo: f64, hi: f64) -> f64 {
let u1 = lcg_next(state);
let u2 = lcg_next(state);
let z = box_muller(u1, u2);
(mean + z * std).clamp(lo, hi)
}
impl StochasticPortfolioOptimizer {
pub fn new(candidates: Vec<RenewableCandidate>, budget_musd: f64) -> Self {
Self {
candidates,
budget_musd,
min_capacity_mw: 0.0,
max_capacity_mw: f64::MAX,
target_energy_gwh_per_year: 0.0,
n_scenarios: 500,
seed: 42,
risk_measure: RiskMeasure::ConditionalValueAtRisk,
risk_aversion: 0.5,
discount_rate: 0.07,
co2_constraint_tpy: None,
land_use_constraint_km2: None,
}
}
pub fn generate_scenarios(&mut self) -> Vec<EnergyScenario> {
let n = self.n_scenarios.max(1);
let prob = 1.0 / n as f64;
let mut state = self.seed;
let mut scenarios = Vec::with_capacity(n);
for i in 0..n {
let mut cfs = Vec::with_capacity(self.candidates.len());
for c in &self.candidates {
let cf = sample_normal_clamped(
&mut state,
c.capacity_factor_mean,
c.capacity_factor_std,
0.0,
1.0,
);
cfs.push(cf);
}
let price = sample_normal_clamped(&mut state, 60.0, 15.0, 10.0, 200.0);
let carbon = sample_normal_clamped(&mut state, 40.0, 10.0, 0.0, 200.0);
scenarios.push(EnergyScenario {
id: i,
capacity_factors: cfs,
energy_price_usd_per_mwh: price,
carbon_price_usd_per_tco2: carbon,
probability: prob,
});
}
self.seed = state;
scenarios
}
pub fn compute_scenario_npv(
&self,
decisions: &[PortfolioDecision],
scenario: &EnergyScenario,
) -> f64 {
let grid_emission_factor_g_per_kwh = 500.0_f64;
let mut total_npv = 0.0_f64;
for dec in decisions {
if !dec.selected || dec.capacity_installed_mw <= 0.0 {
continue;
}
let candidate = match self.candidates.iter().find(|c| c.id == dec.candidate_id) {
Some(c) => c,
None => continue,
};
let cap_ratio = if candidate.capacity_mw > 0.0 {
dec.capacity_installed_mw / candidate.capacity_mw
} else {
0.0
};
let cand_idx = self
.candidates
.iter()
.position(|c| c.id == dec.candidate_id)
.unwrap_or(0);
let cf = scenario
.capacity_factors
.get(cand_idx)
.copied()
.unwrap_or(candidate.capacity_factor_mean);
let total_capex =
(candidate.capital_cost_musd + candidate.interconnection_cost_musd) * cap_ratio;
let annual_energy_mwh = dec.capacity_installed_mw * cf * 8760.0;
let annual_revenue_musd =
annual_energy_mwh * scenario.energy_price_usd_per_mwh / 1_000_000.0;
let co2_avoided_kg = annual_energy_mwh
* (grid_emission_factor_g_per_kwh - candidate.co2_intensity_g_per_kwh)
/ 1000.0;
let annual_co2_revenue_musd =
co2_avoided_kg / 1000.0 * scenario.carbon_price_usd_per_tco2 / 1_000_000.0;
let opex_scaled = candidate.annual_opex_musd * cap_ratio;
let lifetime = candidate.lifetime_years.max(1.0) as usize;
let r = self.discount_rate;
let annuity_factor = if r.abs() < 1e-12 {
lifetime as f64
} else {
(1.0 - (1.0 + r).powi(-(lifetime as i32))) / r
};
let npv_candidate = -total_capex
+ (annual_revenue_musd + annual_co2_revenue_musd - opex_scaled) * annuity_factor;
total_npv += npv_candidate;
}
total_npv
}
pub fn compute_expected_metrics(
&self,
decisions: &[PortfolioDecision],
scenarios: &[EnergyScenario],
) -> (f64, f64, f64) {
if scenarios.is_empty() {
return (0.0, 0.0, 0.0);
}
let npvs: Vec<f64> = scenarios
.iter()
.map(|s| self.compute_scenario_npv(decisions, s))
.collect();
let e_npv: f64 = scenarios
.iter()
.zip(npvs.iter())
.map(|(s, &n)| s.probability * n)
.sum();
let var_npv: f64 = scenarios
.iter()
.zip(npvs.iter())
.map(|(s, &n)| s.probability * (n - e_npv).powi(2))
.sum();
let std_npv = var_npv.sqrt();
let cvar = self.compute_portfolio_cvar(&npvs, 0.95);
(e_npv, std_npv, cvar)
}
pub fn compute_portfolio_cvar(&self, npvs: &[f64], alpha: f64) -> f64 {
if npvs.is_empty() {
return 0.0;
}
let mut sorted = npvs.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
let tail_count = ((1.0 - alpha) * n as f64).ceil() as usize;
let tail_count = tail_count.max(1).min(n);
let tail_sum: f64 = sorted.iter().take(tail_count).sum();
tail_sum / tail_count as f64
}
pub fn compute_diversification_index(&self, decisions: &[PortfolioDecision]) -> f64 {
let mut type_capacity: HashMap<String, f64> = HashMap::new();
let mut total_cap = 0.0_f64;
for dec in decisions {
if !dec.selected || dec.capacity_installed_mw <= 0.0 {
continue;
}
if let Some(c) = self.candidates.iter().find(|c| c.id == dec.candidate_id) {
let key = format!("{:?}", c.renewable_type);
*type_capacity.entry(key).or_insert(0.0) += dec.capacity_installed_mw;
total_cap += dec.capacity_installed_mw;
}
}
if total_cap <= 0.0 {
return 1.0;
}
type_capacity
.values()
.map(|&cap| (cap / total_cap).powi(2))
.sum()
}
pub fn check_constraints(&self, decisions: &[PortfolioDecision]) -> Vec<String> {
let mut violations = Vec::new();
let mut total_capex = 0.0_f64;
let mut total_cap = 0.0_f64;
let mut total_land = 0.0_f64;
let mut total_co2_tpy = 0.0_f64;
for dec in decisions {
if !dec.selected || dec.capacity_installed_mw <= 0.0 {
continue;
}
if let Some(c) = self.candidates.iter().find(|c| c.id == dec.candidate_id) {
let cap_ratio = if c.capacity_mw > 0.0 {
dec.capacity_installed_mw / c.capacity_mw
} else {
0.0
};
total_capex += (c.capital_cost_musd + c.interconnection_cost_musd) * cap_ratio;
total_cap += dec.capacity_installed_mw;
total_land += c.land_use_km2 * cap_ratio;
let annual_energy_mwh = dec.capacity_installed_mw * c.capacity_factor_mean * 8760.0;
let co2_t_year = annual_energy_mwh * c.co2_intensity_g_per_kwh / 1000.0;
total_co2_tpy += co2_t_year;
}
}
if total_capex > self.budget_musd {
violations.push(format!(
"Budget exceeded: {:.2} > {:.2} MUSD",
total_capex, self.budget_musd
));
}
if self.min_capacity_mw > 0.0 && total_cap < self.min_capacity_mw {
violations.push(format!(
"Below minimum capacity: {:.1} < {:.1} MW",
total_cap, self.min_capacity_mw
));
}
if total_cap > self.max_capacity_mw {
violations.push(format!(
"Exceeds maximum capacity: {:.1} > {:.1} MW",
total_cap, self.max_capacity_mw
));
}
if let Some(limit) = self.co2_constraint_tpy {
if total_co2_tpy > limit {
violations.push(format!(
"CO2 constraint violated: {:.1} > {:.1} t/year",
total_co2_tpy, limit
));
}
}
if let Some(limit) = self.land_use_constraint_km2 {
if total_land > limit {
violations.push(format!(
"Land use constraint violated: {:.3} > {:.3} km2",
total_land, limit
));
}
}
violations
}
pub fn solve_greedy_mean_value(&self, scenarios: &[EnergyScenario]) -> PortfolioResult {
if self.candidates.is_empty() || scenarios.is_empty() {
return self.build_portfolio_result(vec![], scenarios);
}
let mean_price = if scenarios.is_empty() {
60.0
} else {
scenarios
.iter()
.map(|s| s.energy_price_usd_per_mwh)
.sum::<f64>()
/ scenarios.len() as f64
};
let mean_carbon = if scenarios.is_empty() {
40.0
} else {
scenarios
.iter()
.map(|s| s.carbon_price_usd_per_tco2)
.sum::<f64>()
/ scenarios.len() as f64
};
let n_cands = self.candidates.len();
let mut mean_cfs = vec![0.0_f64; n_cands];
for s in scenarios {
for (i, &cf) in s.capacity_factors.iter().enumerate() {
if i < n_cands {
mean_cfs[i] += cf;
}
}
}
let n_s = scenarios.len() as f64;
for cf in &mut mean_cfs {
*cf /= n_s;
}
let mut scored: Vec<(usize, f64)> = self
.candidates
.iter()
.enumerate()
.map(|(idx, c)| {
let capex = (c.capital_cost_musd + c.interconnection_cost_musd).max(1e-9);
let cf = mean_cfs.get(idx).copied().unwrap_or(c.capacity_factor_mean);
let mock_scenario = EnergyScenario {
id: 0,
capacity_factors: mean_cfs.clone(),
energy_price_usd_per_mwh: mean_price,
carbon_price_usd_per_tco2: mean_carbon,
probability: 1.0,
};
let dec = PortfolioDecision {
candidate_id: c.id,
selected: true,
capacity_installed_mw: c.capacity_mw,
};
let npv = self.compute_scenario_npv(&[dec], &mock_scenario);
let _ = cf; let score = npv / capex;
(idx, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let decisions = self.greedy_select(&scored, &mean_cfs);
self.build_portfolio_result(decisions, scenarios)
}
pub fn solve_stochastic_greedy(&self, scenarios: &[EnergyScenario]) -> PortfolioResult {
self.solve_stochastic_greedy_with_lambda(scenarios, self.risk_aversion)
}
fn solve_stochastic_greedy_with_lambda(
&self,
scenarios: &[EnergyScenario],
lambda: f64,
) -> PortfolioResult {
if self.candidates.is_empty() || scenarios.is_empty() {
return self.build_portfolio_result(vec![], scenarios);
}
let mean_cfs: Vec<f64> = (0..self.candidates.len())
.map(|i| {
let sum: f64 = scenarios
.iter()
.map(|s| s.capacity_factors.get(i).copied().unwrap_or(0.0))
.sum();
sum / scenarios.len() as f64
})
.collect();
let mut scored: Vec<(usize, f64)> = self
.candidates
.iter()
.enumerate()
.map(|(idx, c)| {
let dec = PortfolioDecision {
candidate_id: c.id,
selected: true,
capacity_installed_mw: c.capacity_mw,
};
let (e_npv, std_npv, _) = self.compute_expected_metrics(&[dec], scenarios);
let score = e_npv - lambda * std_npv;
(idx, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let decisions = self.greedy_select(&scored, &mean_cfs);
self.build_portfolio_result(decisions, scenarios)
}
pub fn solve_mean_variance(&self, scenarios: &[EnergyScenario]) -> PortfolioResult {
if self.candidates.is_empty() || scenarios.is_empty() {
return self.build_portfolio_result(vec![], scenarios);
}
let lambdas: Vec<f64> = (0..=20).map(|i| i as f64 * 0.1).collect();
let mut best_result: Option<PortfolioResult> = None;
let mut best_score = f64::NEG_INFINITY;
for lambda in lambdas {
let result = self.solve_stochastic_greedy_with_lambda(scenarios, lambda);
let score =
result.expected_npv_musd - self.risk_aversion * result.npv_std_dev_musd.powi(2);
if score > best_score {
best_score = score;
best_result = Some(result);
}
}
best_result.unwrap_or_else(|| self.build_portfolio_result(vec![], scenarios))
}
pub fn solve(&mut self) -> PortfolioResult {
let scenarios = self.generate_scenarios();
match &self.risk_measure.clone() {
RiskMeasure::ExpectedValue => self.solve_greedy_mean_value(&scenarios),
RiskMeasure::ValueAtRisk | RiskMeasure::ConditionalValueAtRisk => {
self.solve_stochastic_greedy(&scenarios)
}
RiskMeasure::MeanVariance => self.solve_mean_variance(&scenarios),
RiskMeasure::MinimaxRegret => self.solve_stochastic_greedy(&scenarios),
}
}
fn greedy_select(&self, scored: &[(usize, f64)], _mean_cfs: &[f64]) -> Vec<PortfolioDecision> {
let mut cum_capex = 0.0_f64;
let mut cum_cap = 0.0_f64;
let mut decisions: Vec<PortfolioDecision> = Vec::new();
for &(idx, _score) in scored {
let c = &self.candidates[idx];
let capex = (c.capital_cost_musd + c.interconnection_cost_musd).max(0.0);
if cum_capex + capex > self.budget_musd + 1e-9 {
continue;
}
if cum_cap + c.capacity_mw > self.max_capacity_mw + 1e-6 {
continue;
}
cum_capex += capex;
cum_cap += c.capacity_mw;
decisions.push(PortfolioDecision {
candidate_id: c.id,
selected: true,
capacity_installed_mw: c.capacity_mw,
});
}
for (idx, c) in self.candidates.iter().enumerate() {
if !decisions.iter().any(|d| d.candidate_id == c.id) {
let _ = idx;
decisions.push(PortfolioDecision {
candidate_id: c.id,
selected: false,
capacity_installed_mw: 0.0,
});
}
}
decisions
}
fn build_portfolio_result(
&self,
decisions: Vec<PortfolioDecision>,
scenarios: &[EnergyScenario],
) -> PortfolioResult {
let (e_npv, std_npv, cvar) = if scenarios.is_empty() {
(0.0, 0.0, 0.0)
} else {
self.compute_expected_metrics(&decisions, scenarios)
};
let mut total_cap = 0.0_f64;
let mut total_capex = 0.0_f64;
let mut total_land = 0.0_f64;
let n_cands = self.candidates.len();
let mut mean_cfs = vec![0.0_f64; n_cands];
if !scenarios.is_empty() {
for s in scenarios {
for (i, &cf) in s.capacity_factors.iter().enumerate() {
if i < n_cands {
mean_cfs[i] += cf;
}
}
}
for cf in &mut mean_cfs {
*cf /= scenarios.len() as f64;
}
} else {
for (i, c) in self.candidates.iter().enumerate() {
if i < n_cands {
mean_cfs[i] = c.capacity_factor_mean;
}
}
}
let mean_price = if scenarios.is_empty() {
60.0
} else {
scenarios
.iter()
.map(|s| s.energy_price_usd_per_mwh)
.sum::<f64>()
/ scenarios.len() as f64
};
let mut total_generation_mwh = 0.0_f64;
let mut total_co2_avoided_kg = 0.0_f64;
let grid_ef = 500.0_f64;
for dec in &decisions {
if !dec.selected || dec.capacity_installed_mw <= 0.0 {
continue;
}
if let Some(c) = self.candidates.iter().find(|c| c.id == dec.candidate_id) {
let cap_ratio = if c.capacity_mw > 0.0 {
dec.capacity_installed_mw / c.capacity_mw
} else {
0.0
};
let cand_idx = self
.candidates
.iter()
.position(|x| x.id == c.id)
.unwrap_or(0);
let cf = mean_cfs
.get(cand_idx)
.copied()
.unwrap_or(c.capacity_factor_mean);
total_cap += dec.capacity_installed_mw;
total_capex += (c.capital_cost_musd + c.interconnection_cost_musd) * cap_ratio;
total_land += c.land_use_km2 * cap_ratio;
let annual_mwh = dec.capacity_installed_mw * cf * 8760.0;
total_generation_mwh += annual_mwh;
total_co2_avoided_kg += annual_mwh * (grid_ef - c.co2_intensity_g_per_kwh) / 1000.0;
}
}
let expected_generation_gwh = total_generation_mwh / 1000.0;
let expected_revenue_musd = total_generation_mwh * mean_price / 1_000_000.0;
let co2_avoided_ktpy = total_co2_avoided_kg / 1_000_000.0;
let capacity_factor_portfolio = if total_cap > 0.0 {
expected_generation_gwh * 1000.0 / (total_cap * 8760.0)
} else {
0.0
};
let risk_measure_value_musd = match &self.risk_measure {
RiskMeasure::ConditionalValueAtRisk => cvar,
RiskMeasure::ValueAtRisk => {
if scenarios.is_empty() {
e_npv
} else {
let mut npvs: Vec<f64> = scenarios
.iter()
.map(|s| self.compute_scenario_npv(&decisions, s))
.collect();
npvs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = ((1.0 - 0.95) * npvs.len() as f64) as usize;
npvs.get(idx).copied().unwrap_or(e_npv)
}
}
RiskMeasure::MeanVariance => e_npv - self.risk_aversion * std_npv.powi(2),
RiskMeasure::ExpectedValue | RiskMeasure::MinimaxRegret => e_npv,
};
let diversification_index = self.compute_diversification_index(&decisions);
PortfolioResult {
decisions,
expected_generation_gwh,
expected_revenue_musd,
expected_cost_musd: total_capex,
expected_npv_musd: e_npv,
npv_std_dev_musd: std_npv,
risk_measure_value_musd,
co2_avoided_ktpy,
land_use_km2: total_land,
portfolio_capacity_mw: total_cap,
capacity_factor_portfolio,
diversification_index,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_candidate(
id: usize,
rtype: RenewableType,
cap_mw: f64,
cf_mean: f64,
capex: f64,
land: f64,
) -> RenewableCandidate {
RenewableCandidate {
id,
name: format!("Candidate_{}", id),
renewable_type: rtype,
location_id: id,
capacity_mw: cap_mw,
capacity_factor_mean: cf_mean,
capacity_factor_std: 0.05,
capital_cost_musd: capex,
annual_opex_musd: capex * 0.02,
lifetime_years: 25.0,
lead_time_years: 2.0,
interconnection_cost_musd: capex * 0.05,
land_use_km2: land,
co2_intensity_g_per_kwh: 10.0,
}
}
fn make_optimizer(budget: f64) -> StochasticPortfolioOptimizer {
let candidates = vec![
make_candidate(0, RenewableType::OnshoreWind, 100.0, 0.35, 120.0, 5.0),
make_candidate(1, RenewableType::UtilitySolar, 80.0, 0.20, 80.0, 3.0),
make_candidate(2, RenewableType::OffshoreWind, 150.0, 0.45, 250.0, 0.5),
make_candidate(3, RenewableType::Hydro, 50.0, 0.55, 100.0, 1.0),
];
let mut opt = StochasticPortfolioOptimizer::new(candidates, budget);
opt.n_scenarios = 50;
opt
}
#[test]
fn test_scenario_generation_count() {
let mut opt = make_optimizer(500.0);
opt.n_scenarios = 50;
let scenarios = opt.generate_scenarios();
assert_eq!(scenarios.len(), 50);
}
#[test]
fn test_scenario_probability_sum() {
let mut opt = make_optimizer(500.0);
opt.n_scenarios = 100;
let scenarios = opt.generate_scenarios();
let total_prob: f64 = scenarios.iter().map(|s| s.probability).sum();
assert!(
(total_prob - 1.0).abs() < 1e-10,
"probability sum = {}",
total_prob
);
}
#[test]
fn test_cf_samples_in_bounds() {
let mut opt = make_optimizer(500.0);
opt.n_scenarios = 200;
let scenarios = opt.generate_scenarios();
for s in &scenarios {
for &cf in &s.capacity_factors {
assert!((0.0..=1.0).contains(&cf), "CF out of bounds: {}", cf);
}
}
}
#[test]
fn test_greedy_stays_within_budget() {
let mut opt = make_optimizer(300.0);
let scenarios = opt.generate_scenarios();
let result = opt.solve_greedy_mean_value(&scenarios);
assert!(
result.expected_cost_musd <= 300.0 + 1e-6,
"cost {} > budget 300",
result.expected_cost_musd
);
}
#[test]
fn test_greedy_positive_npv() {
let mut opt = make_optimizer(1000.0);
let scenarios = opt.generate_scenarios();
let result = opt.solve_greedy_mean_value(&scenarios);
assert!(
result.expected_npv_musd > 0.0,
"Expected positive NPV, got {}",
result.expected_npv_musd
);
}
#[test]
fn test_diversification_single_type() {
let opt = make_optimizer(500.0);
let decisions = vec![PortfolioDecision {
candidate_id: 0,
selected: true,
capacity_installed_mw: 100.0,
}];
let hhi = opt.compute_diversification_index(&decisions);
assert!(
(hhi - 1.0).abs() < 1e-9,
"Single type HHI should be 1.0, got {}",
hhi
);
}
#[test]
fn test_diversification_two_equal_types() {
let opt = make_optimizer(500.0);
let decisions = vec![
PortfolioDecision {
candidate_id: 0,
selected: true,
capacity_installed_mw: 100.0,
},
PortfolioDecision {
candidate_id: 1,
selected: true,
capacity_installed_mw: 100.0,
},
];
let hhi = opt.compute_diversification_index(&decisions);
assert!(
(hhi - 0.5).abs() < 1e-9,
"Two equal types HHI should be 0.5, got {}",
hhi
);
}
#[test]
fn test_diversification_three_equal_types() {
let candidates = vec![
make_candidate(0, RenewableType::OnshoreWind, 100.0, 0.35, 120.0, 5.0),
make_candidate(1, RenewableType::UtilitySolar, 100.0, 0.20, 80.0, 3.0),
make_candidate(2, RenewableType::Hydro, 100.0, 0.55, 100.0, 1.0),
];
let opt = StochasticPortfolioOptimizer::new(candidates, 1000.0);
let decisions = vec![
PortfolioDecision {
candidate_id: 0,
selected: true,
capacity_installed_mw: 100.0,
},
PortfolioDecision {
candidate_id: 1,
selected: true,
capacity_installed_mw: 100.0,
},
PortfolioDecision {
candidate_id: 2,
selected: true,
capacity_installed_mw: 100.0,
},
];
let hhi = opt.compute_diversification_index(&decisions);
let expected = 1.0_f64 / 3.0;
assert!(
(hhi - expected).abs() < 1e-9,
"Three equal types HHI ≈ 0.333, got {}",
hhi
);
}
#[test]
fn test_portfolio_capacity_sum() {
let mut opt = make_optimizer(1000.0);
let scenarios = opt.generate_scenarios();
let result = opt.solve_greedy_mean_value(&scenarios);
let sum_cap: f64 = result
.decisions
.iter()
.filter(|d| d.selected)
.map(|d| d.capacity_installed_mw)
.sum();
assert!(
(result.portfolio_capacity_mw - sum_cap).abs() < 1e-6,
"portfolio_capacity_mw {} != sum {}",
result.portfolio_capacity_mw,
sum_cap
);
}
#[test]
fn test_npv_positive_viable_project() {
let candidates = vec![make_candidate(
0,
RenewableType::OnshoreWind,
100.0,
0.40,
100.0,
3.0,
)];
let opt = StochasticPortfolioOptimizer::new(candidates, 200.0);
let scenario = EnergyScenario {
id: 0,
capacity_factors: vec![0.40],
energy_price_usd_per_mwh: 80.0,
carbon_price_usd_per_tco2: 50.0,
probability: 1.0,
};
let dec = PortfolioDecision {
candidate_id: 0,
selected: true,
capacity_installed_mw: 100.0,
};
let npv = opt.compute_scenario_npv(&[dec], &scenario);
assert!(
npv > 0.0,
"Viable project should have positive NPV, got {}",
npv
);
}
#[test]
fn test_cvar_ge_var() {
let opt = make_optimizer(500.0);
let npvs: Vec<f64> = (0..100).map(|i| i as f64 - 50.0).collect();
let cvar = opt.compute_portfolio_cvar(&npvs, 0.95);
let mut sorted = npvs.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let var_idx = ((1.0 - 0.95) * sorted.len() as f64).ceil() as usize;
let var_idx = var_idx.max(1).min(sorted.len()) - 1;
let var = sorted[var_idx];
assert!(
cvar <= var + 1e-9,
"CVaR {} should be <= VaR {} (worse outcomes)",
cvar,
var
);
}
#[test]
fn test_risk_averse_conservative() {
let mut opt_low = make_optimizer(1000.0);
opt_low.risk_aversion = 0.0;
let scenarios_low = opt_low.generate_scenarios();
let result_low = opt_low.solve_stochastic_greedy(&scenarios_low);
let mut opt_high = make_optimizer(1000.0);
opt_high.risk_aversion = 5.0;
let scenarios_high = opt_high.generate_scenarios();
let result_high = opt_high.solve_stochastic_greedy(&scenarios_high);
assert!(result_low.npv_std_dev_musd >= 0.0);
assert!(result_high.npv_std_dev_musd >= 0.0);
}
#[test]
fn test_co2_constraint_respected() {
let mut opt = make_optimizer(1000.0);
opt.co2_constraint_tpy = Some(1e10); let result = opt.solve();
let violations = opt.check_constraints(&result.decisions);
let co2_violations: Vec<_> = violations.iter().filter(|v| v.contains("CO2")).collect();
assert!(
co2_violations.is_empty(),
"CO2 constraint should not be violated"
);
}
#[test]
fn test_land_use_constraint() {
let mut opt = make_optimizer(1000.0);
opt.land_use_constraint_km2 = Some(1000.0); let result = opt.solve();
let violations = opt.check_constraints(&result.decisions);
let land_violations: Vec<_> = violations.iter().filter(|v| v.contains("Land")).collect();
assert!(
land_violations.is_empty(),
"Land use constraint should not be violated"
);
}
#[test]
fn test_solve_dispatches_correctly() {
let mut opt = make_optimizer(500.0);
opt.risk_measure = RiskMeasure::ConditionalValueAtRisk;
let result = opt.solve();
assert_eq!(result.decisions.len(), opt.candidates.len());
}
#[test]
fn test_mean_variance_portfolio() {
let mut opt = make_optimizer(500.0);
opt.risk_measure = RiskMeasure::MeanVariance;
let scenarios = opt.generate_scenarios();
let result = opt.solve_mean_variance(&scenarios);
assert!(
!result.decisions.is_empty(),
"mean-variance should return non-empty decisions"
);
}
#[test]
fn test_expected_metrics_correct_dimensions() {
let mut opt = make_optimizer(500.0);
let scenarios = opt.generate_scenarios();
let decisions = vec![PortfolioDecision {
candidate_id: 0,
selected: true,
capacity_installed_mw: 100.0,
}];
let (e_npv, std_npv, cvar) = opt.compute_expected_metrics(&decisions, &scenarios);
assert!(e_npv.is_finite(), "E[NPV] not finite");
assert!(std_npv.is_finite(), "std[NPV] not finite");
assert!(cvar.is_finite(), "CVaR not finite");
}
#[test]
fn test_check_constraints_no_violations() {
let opt = make_optimizer(1000.0);
let decisions: Vec<PortfolioDecision> = opt
.candidates
.iter()
.map(|c| PortfolioDecision {
candidate_id: c.id,
selected: true,
capacity_installed_mw: c.capacity_mw,
})
.collect();
let violations = opt.check_constraints(&decisions);
assert!(
violations.is_empty(),
"Expected no violations but got: {:?}",
violations
);
}
#[test]
fn test_check_constraints_budget_violation() {
let opt = make_optimizer(10.0); let decisions: Vec<PortfolioDecision> = opt
.candidates
.iter()
.map(|c| PortfolioDecision {
candidate_id: c.id,
selected: true,
capacity_installed_mw: c.capacity_mw,
})
.collect();
let violations = opt.check_constraints(&decisions);
let budget_viol: Vec<_> = violations.iter().filter(|v| v.contains("Budget")).collect();
assert!(!budget_viol.is_empty(), "Should detect budget violation");
}
#[test]
fn test_empty_candidates() {
let mut opt = StochasticPortfolioOptimizer::new(vec![], 500.0);
opt.n_scenarios = 10;
let result = opt.solve();
assert_eq!(result.decisions.len(), 0);
assert_eq!(result.portfolio_capacity_mw, 0.0);
assert_eq!(result.expected_npv_musd, 0.0);
}
}