use std::collections::HashMap;
use crate::error::OxiGridError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum MarketType {
EnergyArbitrage,
FrequencyRegulationUp,
FrequencyRegulationDown,
SpinningReserve,
NonSpinningReserve,
CapacityMarket,
DemandResponse,
VoltageSupport,
}
impl MarketType {
pub fn required_response_time_s(&self) -> f64 {
match self {
MarketType::FrequencyRegulationUp | MarketType::FrequencyRegulationDown => 0.5,
MarketType::SpinningReserve => 10.0,
MarketType::EnergyArbitrage => 300.0,
MarketType::VoltageSupport => 1.0,
MarketType::NonSpinningReserve => 1_800.0,
MarketType::DemandResponse => 3_600.0,
MarketType::CapacityMarket => 3_600.0,
}
}
pub fn label(&self) -> &'static str {
match self {
MarketType::EnergyArbitrage => "energy_arbitrage",
MarketType::FrequencyRegulationUp => "freq_reg_up",
MarketType::FrequencyRegulationDown => "freq_reg_down",
MarketType::SpinningReserve => "spinning_reserve",
MarketType::NonSpinningReserve => "non_spinning_reserve",
MarketType::CapacityMarket => "capacity_market",
MarketType::DemandResponse => "demand_response",
MarketType::VoltageSupport => "voltage_support",
}
}
pub fn priority(&self) -> u8 {
match self {
MarketType::FrequencyRegulationUp => 0,
MarketType::FrequencyRegulationDown => 1,
MarketType::SpinningReserve => 2,
MarketType::EnergyArbitrage => 3,
MarketType::NonSpinningReserve => 4,
MarketType::VoltageSupport => 5,
MarketType::DemandResponse => 6,
MarketType::CapacityMarket => 7,
}
}
pub fn is_ancillary(&self) -> bool {
matches!(
self,
MarketType::FrequencyRegulationUp
| MarketType::FrequencyRegulationDown
| MarketType::SpinningReserve
| MarketType::NonSpinningReserve
| MarketType::VoltageSupport
| MarketType::DemandResponse
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BidStrategy {
PriceTaker,
PriceAdaptive,
StrategicBidding,
}
#[derive(Debug, Clone)]
pub struct MarketOpportunity {
pub market: MarketType,
pub clearing_price_usd_per_mwh: f64,
pub accepted_mw: f64,
pub duration_h: f64,
pub revenue_usd: f64,
pub required_response_time_s: f64,
pub min_commitment_h: f64,
}
impl MarketOpportunity {
pub fn compute_revenue(&self) -> f64 {
self.clearing_price_usd_per_mwh * self.accepted_mw * self.duration_h
}
}
#[derive(Debug, Clone)]
pub struct StorageUnit {
pub id: usize,
pub energy_capacity_mwh: f64,
pub power_capacity_mw: f64,
pub roundtrip_efficiency: f64,
pub soc_min: f64,
pub soc_max: f64,
pub soc_initial: f64,
pub degradation_cost_usd_per_mwh: f64,
pub ramp_rate_mw_per_s: f64,
pub min_response_time_s: f64,
}
impl StorageUnit {
pub fn new_lithium_ion(id: usize, energy_mwh: f64, power_mw: f64) -> Self {
Self {
id,
energy_capacity_mwh: energy_mwh,
power_capacity_mw: power_mw,
roundtrip_efficiency: 0.92,
soc_min: 0.10,
soc_max: 0.90,
soc_initial: 0.50,
degradation_cost_usd_per_mwh: 5.0,
ramp_rate_mw_per_s: power_mw * 0.5,
min_response_time_s: 0.5,
}
}
pub fn can_participate(&self, market: &MarketType) -> bool {
self.min_response_time_s <= market.required_response_time_s()
}
#[inline]
pub fn discharge_efficiency(&self) -> f64 {
self.roundtrip_efficiency.sqrt()
}
#[inline]
pub fn charge_efficiency(&self) -> f64 {
self.roundtrip_efficiency.sqrt()
}
}
#[derive(Debug, Clone)]
pub struct MultiMarketBid {
pub unit_id: usize,
pub market: MarketType,
pub quantity_mw: f64,
pub price_usd_per_mwh: f64,
pub hour: usize,
}
#[derive(Debug, Clone)]
pub struct MultiMarketSolution {
pub bids: Vec<MultiMarketBid>,
pub soc_trajectory: Vec<f64>,
pub energy_revenue_usd: f64,
pub ancillary_revenue_usd: f64,
pub capacity_revenue_usd: f64,
pub total_revenue_usd: f64,
pub degradation_cost_usd: f64,
pub net_profit_usd: f64,
pub market_participation: Vec<(MarketType, f64)>,
}
#[derive(Debug, Clone)]
pub struct MarketConstraint {
pub market: MarketType,
pub max_total_mw: f64,
pub min_duration_h: f64,
pub requires_certification: bool,
}
#[derive(Debug, Clone)]
pub struct MarketClearingResult {
pub accepted_bids: Vec<MultiMarketBid>,
pub marginal_price_usd_per_mwh: f64,
pub total_cleared_mw: f64,
pub social_welfare_usd: f64,
}
pub struct RevenueStacking;
impl RevenueStacking {
pub fn compute_marginal_value(unit: &StorageUnit, market: &MarketType, price: f64) -> f64 {
let eta = unit.discharge_efficiency();
let degradation = unit.degradation_cost_usd_per_mwh;
match market {
MarketType::EnergyArbitrage
| MarketType::SpinningReserve
| MarketType::NonSpinningReserve
| MarketType::DemandResponse => price * eta - degradation,
MarketType::FrequencyRegulationUp | MarketType::FrequencyRegulationDown => {
price * eta - degradation * 0.5
}
MarketType::CapacityMarket | MarketType::VoltageSupport => price,
}
}
pub fn identify_dominant_market(prices: &HashMap<MarketType, f64>) -> MarketType {
let dummy = StorageUnit {
id: 0,
energy_capacity_mwh: 1.0,
power_capacity_mw: 1.0,
roundtrip_efficiency: 1.0,
soc_min: 0.0,
soc_max: 1.0,
soc_initial: 0.5,
degradation_cost_usd_per_mwh: 0.0,
ramp_rate_mw_per_s: 1.0,
min_response_time_s: 0.0,
};
let mut best_market = MarketType::EnergyArbitrage;
let mut best_value = f64::NEG_INFINITY;
for (market, &price) in prices {
let val = Self::compute_marginal_value(&dummy, market, price);
if val > best_value {
best_value = val;
best_market = *market;
}
}
best_market
}
pub fn compute_opportunity_cost(soc: f64, future_prices: &[f64], efficiency: f64) -> f64 {
if future_prices.is_empty() {
return 0.0;
}
let mut weighted_sum = 0.0_f64;
let mut weight_sum = 0.0_f64;
for (i, &p) in future_prices.iter().enumerate() {
let w = 1.0 / (1.0 + i as f64);
weighted_sum += w * p.max(0.0);
weight_sum += w;
}
let avg_future = if weight_sum > 1e-12 {
weighted_sum / weight_sum
} else {
0.0
};
let discharge_fraction = (soc - 0.1).max(0.0) / 0.8;
avg_future * efficiency * discharge_fraction
}
}
pub struct MultiMarketOptimizer {
pub units: Vec<StorageUnit>,
pub price_forecasts: HashMap<MarketType, Vec<f64>>,
pub market_constraints: Vec<MarketConstraint>,
pub horizon_hours: usize,
pub strategy: BidStrategy,
}
impl MultiMarketOptimizer {
pub fn new(units: Vec<StorageUnit>, horizon_hours: usize) -> Self {
Self {
units,
price_forecasts: HashMap::new(),
market_constraints: Vec::new(),
horizon_hours,
strategy: BidStrategy::PriceTaker,
}
}
pub fn set_price_forecast(&mut self, market: MarketType, prices: Vec<f64>) {
self.price_forecasts.insert(market, prices);
}
pub fn add_constraint(&mut self, constraint: MarketConstraint) {
self.market_constraints.push(constraint);
}
pub fn solve_dynamic_programming(&self) -> Result<MultiMarketSolution, OxiGridError> {
if self.units.is_empty() {
return Err(OxiGridError::InvalidParameter(
"no storage units configured".to_string(),
));
}
const N_SOC: usize = 20;
const N_PWR: usize = 21;
let t = self.horizon_hours;
let mut all_bids: Vec<MultiMarketBid> = Vec::new();
let mut total_energy_rev = 0.0_f64;
let mut total_anc_rev = 0.0_f64;
let mut total_cap_rev = 0.0_f64;
let mut total_deg = 0.0_f64;
let mut rep_soc_traj: Vec<f64> = vec![0.0; t + 1];
for unit in &self.units {
let soc_lo = unit.soc_min;
let soc_hi = unit.soc_max;
let e_cap = unit.energy_capacity_mwh;
let p_max = unit.power_capacity_mw;
let eta_d = unit.discharge_efficiency();
let eta_c = unit.charge_efficiency();
let deg = unit.degradation_cost_usd_per_mwh;
let soc_grid: Vec<f64> = (0..N_SOC)
.map(|s| soc_lo + s as f64 * (soc_hi - soc_lo) / (N_SOC - 1) as f64)
.collect();
let pwr_grid: Vec<f64> = (0..N_PWR)
.map(|k| -p_max + k as f64 * 2.0 * p_max / (N_PWR - 1) as f64)
.collect();
let mut v_next = vec![0.0_f64; N_SOC]; let mut policy: Vec<Vec<usize>> = vec![vec![N_PWR / 2; N_SOC]; t];
for step in (0..t).rev() {
let mut v_curr = vec![f64::NEG_INFINITY; N_SOC];
for (s_idx, &soc) in soc_grid.iter().enumerate() {
let mut best = f64::NEG_INFINITY;
let mut best_k = N_PWR / 2;
for (k, &p) in pwr_grid.iter().enumerate() {
let dsoc = Self::soc_delta(p, eta_d, eta_c, e_cap);
let soc_next = soc + dsoc;
if soc_next < soc_lo - 1e-9 || soc_next > soc_hi + 1e-9 {
continue;
}
let s_next = Self::soc_to_index(
soc_next.clamp(soc_lo, soc_hi),
soc_lo,
soc_hi,
N_SOC,
);
let revenue = self.step_revenue(unit, step, p);
let deg_cost = if p > 0.0 { deg * p } else { 0.0 };
let val = revenue - deg_cost + v_next[s_next];
if val > best {
best = val;
best_k = k;
}
}
v_curr[s_idx] = if best == f64::NEG_INFINITY { 0.0 } else { best };
policy[step][s_idx] = best_k;
}
v_next = v_curr;
}
let mut soc = unit.soc_initial.clamp(soc_lo, soc_hi);
let mut unit_soc_traj = vec![0.0_f64; t + 1];
unit_soc_traj[0] = soc;
for step in 0..t {
let s_idx = Self::soc_to_index(soc, soc_lo, soc_hi, N_SOC);
let k = policy[step][s_idx];
let p = pwr_grid[k];
self.allocate_bids_to_markets(unit, step, p, &mut all_bids);
let step_rev = self.step_revenue(unit, step, p);
let deg_cost = if p > 0.0 { deg * p } else { 0.0 };
total_deg += deg_cost;
self.classify_revenue(
step,
step_rev,
&mut total_energy_rev,
&mut total_anc_rev,
&mut total_cap_rev,
);
let dsoc = Self::soc_delta(p, eta_d, eta_c, e_cap);
soc = (soc + dsoc).clamp(soc_lo, soc_hi);
unit_soc_traj[step + 1] = soc;
}
if unit.id == self.units[0].id {
rep_soc_traj = unit_soc_traj;
}
}
self.build_solution(
all_bids,
rep_soc_traj,
total_energy_rev,
total_anc_rev,
total_cap_rev,
total_deg,
)
}
pub fn solve_lp_relaxation(&self) -> Result<MultiMarketSolution, OxiGridError> {
if self.units.is_empty() {
return Err(OxiGridError::InvalidParameter(
"no storage units configured".to_string(),
));
}
const MAX_ITER: usize = 50;
let t = self.horizon_hours;
let mut all_bids: Vec<MultiMarketBid> = Vec::new();
let mut total_energy_rev = 0.0_f64;
let mut total_anc_rev = 0.0_f64;
let mut total_cap_rev = 0.0_f64;
let mut total_deg = 0.0_f64;
let mut rep_soc_traj = vec![0.0_f64; t + 1];
for unit in &self.units {
let soc_lo = unit.soc_min;
let soc_hi = unit.soc_max;
let e_cap = unit.energy_capacity_mwh;
let p_max = unit.power_capacity_mw;
let eta_d = unit.discharge_efficiency();
let eta_c = unit.charge_efficiency();
let deg = unit.degradation_cost_usd_per_mwh;
let mut lambdas = vec![0.0_f64; t];
let mut best_dispatch = vec![0.0_f64; t];
for iter in 0..MAX_ITER {
let alpha = 1.0 / (iter as f64 + 1.0).sqrt();
let mut dispatch = vec![0.0_f64; t];
let mut soc = unit.soc_initial.clamp(soc_lo, soc_hi);
for step in 0..t {
let p_best = self.subproblem_optimal_power(
unit,
step,
soc,
lambdas[step],
p_max,
soc_lo,
soc_hi,
e_cap,
eta_d,
eta_c,
deg,
);
dispatch[step] = p_best;
let dsoc = Self::soc_delta(p_best, eta_d, eta_c, e_cap);
soc = (soc + dsoc).clamp(soc_lo, soc_hi);
}
soc = unit.soc_initial.clamp(soc_lo, soc_hi);
let mut max_violation = 0.0_f64;
for step in 0..t {
let p = dispatch[step];
let dsoc = Self::soc_delta(p, eta_d, eta_c, e_cap);
let soc_next = (soc + dsoc).clamp(soc_lo, soc_hi);
let violation = if soc + dsoc < soc_lo {
soc + dsoc - soc_lo
} else if soc + dsoc > soc_hi {
soc + dsoc - soc_hi
} else {
0.0
};
lambdas[step] += alpha * violation;
max_violation = max_violation.max(violation.abs());
soc = soc_next;
}
best_dispatch = dispatch;
if max_violation < 1e-6 {
break;
}
}
let mut soc = unit.soc_initial.clamp(soc_lo, soc_hi);
let is_first = unit.id == self.units[0].id;
if is_first {
rep_soc_traj[0] = soc;
}
for step in 0..t {
let p = best_dispatch[step];
self.allocate_bids_to_markets(unit, step, p, &mut all_bids);
let step_rev = self.step_revenue(unit, step, p);
let deg_cost = if p > 0.0 { deg * p } else { 0.0 };
total_deg += deg_cost;
self.classify_revenue(
step,
step_rev,
&mut total_energy_rev,
&mut total_anc_rev,
&mut total_cap_rev,
);
let dsoc = Self::soc_delta(p, eta_d, eta_c, e_cap);
soc = (soc + dsoc).clamp(soc_lo, soc_hi);
if is_first {
rep_soc_traj[step + 1] = soc;
}
}
}
self.build_solution(
all_bids,
rep_soc_traj,
total_energy_rev,
total_anc_rev,
total_cap_rev,
total_deg,
)
}
pub fn simulate_market_clearing(&self, bids: &[MultiMarketBid]) -> MarketClearingResult {
if bids.is_empty() {
return MarketClearingResult {
accepted_bids: Vec::new(),
marginal_price_usd_per_mwh: 0.0,
total_cleared_mw: 0.0,
social_welfare_usd: 0.0,
};
}
let mut sorted: Vec<&MultiMarketBid> = bids.iter().collect();
sorted.sort_by(|a, b| {
a.price_usd_per_mwh
.partial_cmp(&b.price_usd_per_mwh)
.unwrap_or(core::cmp::Ordering::Equal)
});
let market = bids[0].market;
let cap_mw = self
.market_constraints
.iter()
.find(|c| c.market == market)
.map(|c| c.max_total_mw)
.unwrap_or(f64::MAX);
let mut accepted: Vec<MultiMarketBid> = Vec::new();
let mut cleared_mw = 0.0_f64;
let mut marginal_price = 0.0_f64;
for bid in &sorted {
if cleared_mw + bid.quantity_mw > cap_mw + 1e-9 {
break;
}
cleared_mw += bid.quantity_mw;
marginal_price = bid.price_usd_per_mwh;
accepted.push((*bid).clone());
}
let social_welfare = accepted.iter().fold(0.0, |acc, b| {
acc + (marginal_price - b.price_usd_per_mwh) * b.quantity_mw
});
MarketClearingResult {
accepted_bids: accepted,
marginal_price_usd_per_mwh: marginal_price,
total_cleared_mw: cleared_mw,
social_welfare_usd: social_welfare,
}
}
pub fn compute_revenue_decomposition(
&self,
solution: &MultiMarketSolution,
) -> HashMap<String, f64> {
let mut map = HashMap::new();
map.insert("energy_arbitrage".to_string(), solution.energy_revenue_usd);
map.insert(
"ancillary_services".to_string(),
solution.ancillary_revenue_usd,
);
map.insert("capacity_market".to_string(), solution.capacity_revenue_usd);
map.insert("total_revenue".to_string(), solution.total_revenue_usd);
map.insert(
"degradation_cost".to_string(),
solution.degradation_cost_usd,
);
map.insert("net_profit".to_string(), solution.net_profit_usd);
let mut per_market: HashMap<MarketType, f64> = HashMap::new();
for bid in &solution.bids {
let price = self
.price_forecasts
.get(&bid.market)
.and_then(|v| v.get(bid.hour))
.copied()
.unwrap_or(0.0);
*per_market.entry(bid.market).or_insert(0.0) += price * bid.quantity_mw;
}
for (market, rev) in &per_market {
map.insert(market.label().to_string(), *rev);
}
map
}
pub fn run_sensitivity_analysis(
&self,
param: &str,
values: &[f64],
) -> Vec<MultiMarketSolution> {
let empty_sol = || MultiMarketSolution {
bids: Vec::new(),
soc_trajectory: Vec::new(),
energy_revenue_usd: 0.0,
ancillary_revenue_usd: 0.0,
capacity_revenue_usd: 0.0,
total_revenue_usd: 0.0,
degradation_cost_usd: 0.0,
net_profit_usd: 0.0,
market_participation: Vec::new(),
};
values
.iter()
.map(|&v| {
let cloned = Self::clone_with_param(self, param, v);
cloned
.solve_dynamic_programming()
.unwrap_or_else(|_| empty_sol())
})
.collect()
}
#[inline]
fn soc_delta(power_mw: f64, eta_d: f64, eta_c: f64, e_cap: f64) -> f64 {
if power_mw >= 0.0 {
-power_mw * eta_d / e_cap
} else {
-power_mw * eta_c / e_cap }
}
#[inline]
fn soc_to_index(soc: f64, soc_lo: f64, soc_hi: f64, n: usize) -> usize {
let frac = (soc - soc_lo) / (soc_hi - soc_lo).max(1e-12);
let idx = (frac * (n - 1) as f64).round() as isize;
idx.clamp(0, (n - 1) as isize) as usize
}
fn step_revenue(&self, unit: &StorageUnit, step: usize, power_mw: f64) -> f64 {
if power_mw.abs() < 1e-9 {
return 0.0;
}
let discharge = power_mw > 0.0;
let mut remaining = power_mw.abs();
let mut revenue = 0.0_f64;
for (market, price) in self.ordered_markets_at(step, discharge) {
if remaining < 1e-9 {
break;
}
if !unit.can_participate(&market) {
continue;
}
let cap = self.market_cap_remaining(&market, remaining);
let allocated = remaining.min(cap);
revenue += price * allocated;
remaining -= allocated;
}
match self.strategy {
BidStrategy::PriceTaker => revenue,
BidStrategy::PriceAdaptive => revenue * 1.05,
BidStrategy::StrategicBidding => revenue * 1.10,
}
}
fn ordered_markets_at(&self, step: usize, discharge: bool) -> Vec<(MarketType, f64)> {
let mut markets: Vec<(MarketType, f64)> = self
.price_forecasts
.iter()
.filter_map(|(m, prices)| {
if discharge && *m == MarketType::FrequencyRegulationDown {
return None;
}
if !discharge && *m == MarketType::FrequencyRegulationUp {
return None;
}
let price = prices.get(step).copied().unwrap_or(0.0);
Some((*m, price))
})
.collect();
markets.sort_by_key(|(m, _)| m.priority());
markets
}
fn market_cap_remaining(&self, market: &MarketType, requested: f64) -> f64 {
self.market_constraints
.iter()
.find(|c| c.market == *market)
.map(|c| c.max_total_mw.min(requested))
.unwrap_or(requested)
}
fn dominant_market_at(&self, step: usize) -> MarketType {
let prices: HashMap<MarketType, f64> = self
.price_forecasts
.iter()
.map(|(m, v)| (*m, v.get(step).copied().unwrap_or(0.0)))
.collect();
if prices.is_empty() {
return MarketType::EnergyArbitrage;
}
RevenueStacking::identify_dominant_market(&prices)
}
fn classify_revenue(
&self,
step: usize,
revenue: f64,
energy: &mut f64,
ancillary: &mut f64,
capacity: &mut f64,
) {
match self.dominant_market_at(step) {
MarketType::EnergyArbitrage => *energy += revenue,
MarketType::CapacityMarket => *capacity += revenue,
_ => *ancillary += revenue,
}
}
fn allocate_bids_to_markets(
&self,
unit: &StorageUnit,
step: usize,
power_mw: f64,
out: &mut Vec<MultiMarketBid>,
) {
if power_mw.abs() < 1e-9 {
return;
}
let discharge = power_mw > 0.0;
let mut remaining = power_mw.abs();
for (market, price) in self.ordered_markets_at(step, discharge) {
if remaining < 1e-9 {
break;
}
if !unit.can_participate(&market) {
continue;
}
let cap = self.market_cap_remaining(&market, remaining);
let qty = remaining.min(cap);
let bid_price = match self.strategy {
BidStrategy::PriceTaker => price,
BidStrategy::PriceAdaptive => price * 0.95,
BidStrategy::StrategicBidding => price + unit.degradation_cost_usd_per_mwh * 1.2,
};
out.push(MultiMarketBid {
unit_id: unit.id,
market,
quantity_mw: qty,
price_usd_per_mwh: bid_price,
hour: step,
});
remaining -= qty;
}
}
#[allow(clippy::too_many_arguments)]
fn subproblem_optimal_power(
&self,
unit: &StorageUnit,
step: usize,
soc: f64,
lambda: f64,
p_max: f64,
soc_lo: f64,
soc_hi: f64,
e_cap: f64,
eta_d: f64,
eta_c: f64,
deg: f64,
) -> f64 {
const N: usize = 41;
let mut best_val = f64::NEG_INFINITY;
let mut best_p = 0.0_f64;
for k in 0..N {
let p = -p_max + k as f64 * 2.0 * p_max / (N - 1) as f64;
let dsoc = Self::soc_delta(p, eta_d, eta_c, e_cap);
let soc_next = soc + dsoc;
if soc_next < soc_lo - 1e-9 || soc_next > soc_hi + 1e-9 {
continue;
}
let rev = self.step_revenue(unit, step, p);
let deg_cost = if p > 0.0 { deg * p } else { 0.0 };
let val = rev - deg_cost - lambda * dsoc;
if val > best_val {
best_val = val;
best_p = p;
}
}
best_p
}
fn compute_market_participation(&self, bids: &[MultiMarketBid]) -> Vec<(MarketType, f64)> {
let mut map: HashMap<MarketType, std::collections::HashSet<usize>> = HashMap::new();
for bid in bids {
map.entry(bid.market).or_default().insert(bid.hour);
}
let mut result: Vec<(MarketType, f64)> = map
.into_iter()
.map(|(m, hours)| (m, hours.len() as f64))
.collect();
result.sort_by_key(|(m, _)| m.priority());
result
}
fn build_solution(
&self,
bids: Vec<MultiMarketBid>,
soc_trajectory: Vec<f64>,
energy_revenue_usd: f64,
ancillary_revenue_usd: f64,
capacity_revenue_usd: f64,
degradation_cost_usd: f64,
) -> Result<MultiMarketSolution, OxiGridError> {
let total_revenue_usd = energy_revenue_usd + ancillary_revenue_usd + capacity_revenue_usd;
let net_profit_usd = total_revenue_usd - degradation_cost_usd;
let market_participation = self.compute_market_participation(&bids);
Ok(MultiMarketSolution {
bids,
soc_trajectory,
energy_revenue_usd,
ancillary_revenue_usd,
capacity_revenue_usd,
total_revenue_usd,
degradation_cost_usd,
net_profit_usd,
market_participation,
})
}
fn clone_with_param(src: &MultiMarketOptimizer, param: &str, value: f64) -> Self {
let units = match param {
"roundtrip_efficiency" => src
.units
.iter()
.map(|u| {
let mut u2 = u.clone();
u2.roundtrip_efficiency = value.clamp(0.5, 1.0);
u2
})
.collect(),
"degradation_cost" => src
.units
.iter()
.map(|u| {
let mut u2 = u.clone();
u2.degradation_cost_usd_per_mwh = value.max(0.0);
u2
})
.collect(),
_ => src.units.clone(),
};
let price_forecasts = match param {
"energy_price_scale" => {
let mut pf = src.price_forecasts.clone();
if let Some(prices) = pf.get_mut(&MarketType::EnergyArbitrage) {
for p in prices.iter_mut() {
*p *= value;
}
}
pf
}
_ => src.price_forecasts.clone(),
};
Self {
units,
price_forecasts,
market_constraints: src.market_constraints.clone(),
horizon_hours: src.horizon_hours,
strategy: src.strategy,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_unit(id: usize) -> StorageUnit {
StorageUnit::new_lithium_ion(id, 4.0, 1.0)
}
fn make_energy_optimizer(peak_hours: &[usize]) -> MultiMarketOptimizer {
let mut opt = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
let prices: Vec<f64> = (0..24)
.map(|h| if peak_hours.contains(&h) { 100.0 } else { 20.0 })
.collect();
opt.set_price_forecast(MarketType::EnergyArbitrage, prices);
opt
}
#[test]
fn test_single_unit_energy_arbitrage() {
let opt = make_energy_optimizer(&[18, 19]);
let sol = opt.solve_dynamic_programming().expect("dp solve");
assert!(sol.total_revenue_usd >= 0.0);
assert_eq!(sol.soc_trajectory.len(), 25);
}
#[test]
fn test_dp_soc_trajectory() {
let mut opt = make_energy_optimizer(&[17, 18, 19]);
opt.set_price_forecast(MarketType::FrequencyRegulationUp, vec![50.0; 24]);
let sol = opt.solve_dynamic_programming().expect("dp solve");
let unit = &opt.units[0];
for &soc in &sol.soc_trajectory {
assert!(soc >= unit.soc_min - 1e-6, "SoC below min: {soc}");
assert!(soc <= unit.soc_max + 1e-6, "SoC above max: {soc}");
}
}
#[test]
fn test_dp_terminal_soc() {
let opt = make_energy_optimizer(&[12]);
let sol = opt.solve_dynamic_programming().expect("dp solve");
let initial = opt.units[0].soc_initial;
let terminal = sol.soc_trajectory.last().copied().unwrap_or(initial);
assert!(
(terminal - initial).abs() < 0.15,
"Terminal SoC {terminal:.3} too far from initial {initial:.3}"
);
}
#[test]
fn test_revenue_stacking_dominance() {
let mut prices = HashMap::new();
prices.insert(MarketType::EnergyArbitrage, 50.0_f64);
prices.insert(MarketType::FrequencyRegulationUp, 200.0_f64);
let dominant = RevenueStacking::identify_dominant_market(&prices);
assert_eq!(dominant, MarketType::FrequencyRegulationUp);
}
#[test]
fn test_market_clearing_uniform_price() {
let opt = MultiMarketOptimizer::new(vec![], 24);
let bids = vec![
MultiMarketBid {
unit_id: 0,
market: MarketType::SpinningReserve,
quantity_mw: 0.5,
price_usd_per_mwh: 30.0,
hour: 0,
},
MultiMarketBid {
unit_id: 1,
market: MarketType::SpinningReserve,
quantity_mw: 0.5,
price_usd_per_mwh: 40.0,
hour: 0,
},
];
let result = opt.simulate_market_clearing(&bids);
assert!((result.marginal_price_usd_per_mwh - 40.0).abs() < 1e-6);
assert_eq!(result.accepted_bids.len(), 2);
}
#[test]
fn test_lp_relaxation_feasibility() {
let opt = make_energy_optimizer(&[18, 19]);
let sol = opt.solve_lp_relaxation().expect("lp solve");
let unit = &opt.units[0];
for &soc in &sol.soc_trajectory {
assert!(soc >= unit.soc_min - 1e-6);
assert!(soc <= unit.soc_max + 1e-6);
}
}
#[test]
fn test_degradation_cost() {
let mut opt_high = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt_high.set_price_forecast(
MarketType::EnergyArbitrage,
(0..24).map(|h| if h >= 18 { 300.0 } else { 1.0 }).collect(),
);
let mut opt_zero = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt_zero.set_price_forecast(MarketType::EnergyArbitrage, vec![0.0; 24]);
let sol_high = opt_high.solve_dynamic_programming().expect("dp");
let sol_zero = opt_zero.solve_dynamic_programming().expect("dp");
assert!(
sol_high.degradation_cost_usd >= sol_zero.degradation_cost_usd,
"high spread degradation {:.4} < zero-price degradation {:.4}",
sol_high.degradation_cost_usd,
sol_zero.degradation_cost_usd,
);
}
#[test]
fn test_frequency_regulation_requirements() {
assert!(MarketType::FrequencyRegulationUp.required_response_time_s() <= 10.0);
assert!(MarketType::FrequencyRegulationDown.required_response_time_s() <= 10.0);
}
#[test]
fn test_sensitivity_price_impact() {
let mut opt_base = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt_base.set_price_forecast(MarketType::EnergyArbitrage, vec![50.0; 24]);
let mut opt_high = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt_high.set_price_forecast(MarketType::EnergyArbitrage, vec![150.0; 24]);
let sol_base = opt_base.solve_dynamic_programming().expect("dp");
let sol_high = opt_high.solve_dynamic_programming().expect("dp");
assert!(sol_high.total_revenue_usd >= sol_base.total_revenue_usd);
}
#[test]
fn test_efficiency_impact() {
let prices: Vec<f64> = (0..24).map(|h| if h >= 20 { 500.0 } else { 1.0 }).collect();
let unit_good = StorageUnit {
roundtrip_efficiency: 0.95,
degradation_cost_usd_per_mwh: 0.0, ..make_unit(0)
};
let unit_poor = StorageUnit {
roundtrip_efficiency: 0.70,
degradation_cost_usd_per_mwh: 0.0,
..make_unit(0)
};
let mv_good = RevenueStacking::compute_marginal_value(
&unit_good,
&MarketType::EnergyArbitrage,
500.0,
);
let mv_poor = RevenueStacking::compute_marginal_value(
&unit_poor,
&MarketType::EnergyArbitrage,
500.0,
);
assert!(
mv_good > mv_poor,
"good efficiency marginal value {mv_good:.3} should exceed poor {mv_poor:.3}"
);
let mut opt_good = MultiMarketOptimizer::new(vec![unit_good], 24);
opt_good.set_price_forecast(MarketType::EnergyArbitrage, prices.clone());
let mut opt_poor = MultiMarketOptimizer::new(vec![unit_poor], 24);
opt_poor.set_price_forecast(MarketType::EnergyArbitrage, prices);
let sol_good = opt_good.solve_dynamic_programming().expect("dp");
let sol_poor = opt_poor.solve_dynamic_programming().expect("dp");
assert!(sol_good.net_profit_usd >= -1e-6);
assert!(sol_poor.net_profit_usd >= -1e-6);
assert!(
sol_good.net_profit_usd >= sol_poor.net_profit_usd,
"good efficiency profit {:.4} < poor efficiency {:.4}",
sol_good.net_profit_usd,
sol_poor.net_profit_usd,
);
}
#[test]
fn test_revenue_decomposition() {
let opt = make_energy_optimizer(&[18]);
let sol = opt.solve_dynamic_programming().expect("dp");
let decomp = opt.compute_revenue_decomposition(&sol);
let total = decomp.get("total_revenue").copied().unwrap_or(0.0);
let sum = decomp.get("energy_arbitrage").copied().unwrap_or(0.0)
+ decomp.get("ancillary_services").copied().unwrap_or(0.0)
+ decomp.get("capacity_market").copied().unwrap_or(0.0);
assert!(
(total - sum).abs() < 1e-6,
"decomp mismatch: sum={sum} total={total}"
);
}
#[test]
fn test_multiple_units_coordination() {
let mut opt = MultiMarketOptimizer::new(vec![make_unit(0), make_unit(1)], 24);
opt.set_price_forecast(
MarketType::EnergyArbitrage,
(0..24)
.map(|h| if h >= 18 { 100.0 } else { 20.0 })
.collect(),
);
let sol = opt.solve_dynamic_programming().expect("dp");
let u0 = &opt.units[0];
for &s in &sol.soc_trajectory {
assert!(s >= u0.soc_min - 1e-6 && s <= u0.soc_max + 1e-6);
}
}
#[test]
fn test_opportunity_cost_soc() {
let eta = 0.92_f64.sqrt();
let soc = 0.8;
let oc_low = RevenueStacking::compute_opportunity_cost(soc, &[10.0; 6], eta);
let oc_high = RevenueStacking::compute_opportunity_cost(soc, &[200.0; 6], eta);
assert!(oc_high > oc_low);
}
#[test]
fn test_capacity_market_bid() {
let mut opt = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt.set_price_forecast(MarketType::CapacityMarket, vec![10.0; 24]);
let sol = opt.solve_dynamic_programming().expect("dp");
let has_cap = sol
.bids
.iter()
.any(|b| b.market == MarketType::CapacityMarket);
assert!(has_cap, "should have capacity market bids");
}
#[test]
fn test_empty_market_prices() {
let mut opt = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt.set_price_forecast(MarketType::EnergyArbitrage, vec![0.0; 24]);
let sol = opt.solve_dynamic_programming().expect("dp");
assert!(sol.total_revenue_usd >= 0.0);
assert!(sol.net_profit_usd >= -1e-6);
}
#[test]
fn test_soc_discretization() {
let soc_lo = 0.1_f64;
let soc_hi = 0.9_f64;
const N: usize = 20;
let grid: Vec<f64> = (0..N)
.map(|s| soc_lo + s as f64 * (soc_hi - soc_lo) / (N - 1) as f64)
.collect();
assert!((grid[0] - soc_lo).abs() < 1e-9);
assert!((grid[N - 1] - soc_hi).abs() < 1e-9);
assert!(grid[N / 2] > soc_lo && grid[N / 2] < soc_hi);
}
#[test]
fn test_compute_marginal_value() {
let unit = StorageUnit {
degradation_cost_usd_per_mwh: 0.0,
..make_unit(0)
};
let price = 100.0;
let eta = unit.discharge_efficiency();
let mv =
RevenueStacking::compute_marginal_value(&unit, &MarketType::EnergyArbitrage, price);
assert!((mv - price * eta).abs() < 1e-6);
}
#[test]
fn test_bid_strategy_price_taker() {
let mut opt = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt.strategy = BidStrategy::PriceTaker;
opt.set_price_forecast(
MarketType::EnergyArbitrage,
(0..24)
.map(|h| if h >= 18 { 100.0 } else { 20.0 })
.collect(),
);
let sol = opt.solve_dynamic_programming().expect("dp");
assert!(sol.total_revenue_usd >= 0.0);
}
#[test]
fn test_bid_strategy_adaptive() {
let prices: Vec<f64> = (0..24)
.map(|h| if h >= 18 { 100.0 } else { 20.0 })
.collect();
let mut opt_taker = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt_taker.strategy = BidStrategy::PriceTaker;
opt_taker.set_price_forecast(MarketType::EnergyArbitrage, prices.clone());
let mut opt_adapt = MultiMarketOptimizer::new(vec![make_unit(0)], 24);
opt_adapt.strategy = BidStrategy::PriceAdaptive;
opt_adapt.set_price_forecast(MarketType::EnergyArbitrage, prices);
let sol_taker = opt_taker.solve_dynamic_programming().expect("dp");
let sol_adapt = opt_adapt.solve_dynamic_programming().expect("dp");
assert!(
sol_adapt.total_revenue_usd >= sol_taker.total_revenue_usd,
"adaptive should not earn less than price-taker"
);
}
#[test]
fn test_market_constraint_max_mw() {
let mut opt = MultiMarketOptimizer::new(vec![], 24);
opt.add_constraint(MarketConstraint {
market: MarketType::SpinningReserve,
max_total_mw: 0.8,
min_duration_h: 1.0,
requires_certification: false,
});
let bids = vec![
MultiMarketBid {
unit_id: 0,
market: MarketType::SpinningReserve,
quantity_mw: 0.5,
price_usd_per_mwh: 30.0,
hour: 0,
},
MultiMarketBid {
unit_id: 1,
market: MarketType::SpinningReserve,
quantity_mw: 0.5,
price_usd_per_mwh: 35.0,
hour: 0,
},
MultiMarketBid {
unit_id: 2,
market: MarketType::SpinningReserve,
quantity_mw: 0.5,
price_usd_per_mwh: 40.0,
hour: 0,
},
];
let result = opt.simulate_market_clearing(&bids);
assert!(
result.total_cleared_mw <= 0.8 + 1e-6,
"cleared {:.3} MW exceeds cap 0.8",
result.total_cleared_mw
);
}
#[test]
fn test_lp_dp_consistency() {
let opt = make_energy_optimizer(&[18, 19]);
let sol_dp = opt.solve_dynamic_programming().expect("dp");
let sol_lp = opt.solve_lp_relaxation().expect("lp");
assert!(sol_dp.net_profit_usd >= -1e-6);
assert!(sol_lp.net_profit_usd >= -1e-6);
}
#[test]
fn test_sensitivity_returns_count() {
let opt = make_energy_optimizer(&[18]);
let vals = [0.5_f64, 1.0, 1.5, 2.0];
let results = opt.run_sensitivity_analysis("energy_price_scale", &vals);
assert_eq!(results.len(), vals.len());
}
#[test]
fn test_can_participate_response_time() {
let mut slow_unit = make_unit(0);
slow_unit.min_response_time_s = 20.0;
assert!(!slow_unit.can_participate(&MarketType::FrequencyRegulationUp));
assert!(!slow_unit.can_participate(&MarketType::SpinningReserve));
assert!(slow_unit.can_participate(&MarketType::NonSpinningReserve));
assert!(slow_unit.can_participate(&MarketType::EnergyArbitrage));
let mut fast_unit = make_unit(1);
fast_unit.min_response_time_s = 5.0;
assert!(!fast_unit.can_participate(&MarketType::FrequencyRegulationUp));
assert!(fast_unit.can_participate(&MarketType::SpinningReserve));
}
#[test]
fn test_market_opportunity_compute_revenue() {
let opp = MarketOpportunity {
market: MarketType::SpinningReserve,
clearing_price_usd_per_mwh: 50.0,
accepted_mw: 2.0,
duration_h: 4.0,
revenue_usd: 0.0,
required_response_time_s: 10.0,
min_commitment_h: 1.0,
};
assert!((opp.compute_revenue() - 400.0).abs() < 1e-6);
}
#[test]
fn test_power_within_bounds() {
let opt = make_energy_optimizer(&[18, 19]);
let sol = opt.solve_lp_relaxation().expect("lp solve");
let unit = &opt.units[0];
for b in &sol.bids {
assert!(
b.quantity_mw >= -1e-6,
"bid quantity_mw {:.6} below zero",
b.quantity_mw
);
assert!(
b.quantity_mw <= unit.power_capacity_mw + 1e-6,
"bid quantity_mw {:.6} exceeds power_capacity_mw {:.6}",
b.quantity_mw,
unit.power_capacity_mw
);
}
}
#[test]
fn test_cycle_counting_increases_degradation() {
let prices: Vec<f64> = (0..24).map(|h| if h >= 18 { 200.0 } else { 5.0 }).collect();
let mut unit_high = make_unit(0);
unit_high.degradation_cost_usd_per_mwh = 20.0;
let mut opt_high = MultiMarketOptimizer::new(vec![unit_high], 24);
opt_high.set_price_forecast(MarketType::EnergyArbitrage, prices.clone());
let mut unit_low = make_unit(0);
unit_low.degradation_cost_usd_per_mwh = 1.0;
let mut opt_low = MultiMarketOptimizer::new(vec![unit_low], 24);
opt_low.set_price_forecast(MarketType::EnergyArbitrage, prices);
let sol_high = opt_high.solve_dynamic_programming().expect("dp high deg");
let sol_low = opt_low.solve_dynamic_programming().expect("dp low deg");
assert!(sol_high.degradation_cost_usd >= 0.0);
assert!(sol_low.degradation_cost_usd >= 0.0);
assert!(
sol_high.degradation_cost_usd >= sol_low.degradation_cost_usd - 1e-6
|| sol_high.total_revenue_usd <= sol_low.total_revenue_usd + 1e-6,
"high deg unit should either have higher deg cost or lower revenue than low deg unit"
);
}
#[test]
fn test_total_profit_finite() {
let mut opt = MultiMarketOptimizer::new(vec![make_unit(0)], 48);
opt.set_price_forecast(
MarketType::EnergyArbitrage,
(0..48)
.map(|h| if h % 24 >= 18 { 90.0 } else { 10.0 })
.collect(),
);
let sol = opt.solve_dynamic_programming().expect("dp 48h");
assert!(
sol.net_profit_usd.is_finite(),
"net_profit_usd is not finite"
);
assert!(
sol.total_revenue_usd.is_finite(),
"total_revenue_usd is not finite"
);
}
#[test]
fn test_market_priority_ordering() {
assert!(
MarketType::FrequencyRegulationUp.priority() < MarketType::SpinningReserve.priority(),
"FrequencyRegulationUp priority should be lower number (higher priority) than SpinningReserve"
);
assert!(
MarketType::SpinningReserve.priority() < MarketType::EnergyArbitrage.priority(),
"SpinningReserve priority should be lower number than EnergyArbitrage"
);
assert!(
MarketType::EnergyArbitrage.priority() < MarketType::CapacityMarket.priority(),
"EnergyArbitrage priority should be lower number than CapacityMarket"
);
}
#[test]
fn test_capacity_fade_reduces_net_profit() {
let prices: Vec<f64> = (0..24)
.map(|h| if h >= 18 { 120.0 } else { 10.0 })
.collect();
let unit_a = StorageUnit::new_lithium_ion(0, 4.0, 2.0);
let mut opt_a = MultiMarketOptimizer::new(vec![unit_a], 24);
opt_a.set_price_forecast(MarketType::EnergyArbitrage, prices.clone());
let unit_b = StorageUnit::new_lithium_ion(1, 2.0, 2.0);
let mut opt_b = MultiMarketOptimizer::new(vec![unit_b], 24);
opt_b.set_price_forecast(MarketType::EnergyArbitrage, prices);
let sol_a = opt_a.solve_dynamic_programming().expect("dp full capacity");
let sol_b = opt_b
.solve_dynamic_programming()
.expect("dp faded capacity");
assert!(
sol_a.net_profit_usd >= sol_b.net_profit_usd - 1e-6,
"full capacity net_profit {:.4} should be >= faded capacity {:.4}",
sol_a.net_profit_usd,
sol_b.net_profit_usd
);
}
#[test]
fn test_revenue_decomposition_sums_correctly() {
let opt = make_energy_optimizer(&[18, 19]);
let sol = opt.solve_dynamic_programming().expect("dp solve");
let decomp = opt.compute_revenue_decomposition(&sol);
let total = decomp
.get("total_revenue")
.copied()
.expect("total_revenue key missing");
let energy = decomp.get("energy_arbitrage").copied().unwrap_or(0.0);
let ancillary = decomp.get("ancillary_services").copied().unwrap_or(0.0);
let capacity = decomp.get("capacity_market").copied().unwrap_or(0.0);
let sum = energy + ancillary + capacity;
assert!(
(sum - total).abs() < 1e-3,
"decomposition sum {:.6} != total_revenue {:.6}",
sum,
total
);
}
#[test]
fn test_multi_unit_fleet_total_revenue() {
let units = vec![
StorageUnit::new_lithium_ion(0, 4.0, 2.0),
StorageUnit::new_lithium_ion(1, 4.0, 2.0),
StorageUnit::new_lithium_ion(2, 4.0, 2.0),
];
let mut opt = MultiMarketOptimizer::new(units, 24);
opt.set_price_forecast(
MarketType::EnergyArbitrage,
(0..24)
.map(|h| if (18..=20).contains(&h) { 130.0 } else { 15.0 })
.collect(),
);
let sol = opt.solve_dynamic_programming().expect("dp 3-unit fleet");
assert!(
sol.total_revenue_usd >= 0.0,
"total_revenue_usd should be non-negative"
);
assert_eq!(
sol.soc_trajectory.len(),
25,
"soc_trajectory length should be horizon + 1 = 25"
);
}
#[test]
fn test_ancillary_service_flag() {
assert!(
MarketType::FrequencyRegulationUp.is_ancillary(),
"FrequencyRegulationUp should be ancillary"
);
assert!(
MarketType::FrequencyRegulationDown.is_ancillary(),
"FrequencyRegulationDown should be ancillary"
);
assert!(
MarketType::SpinningReserve.is_ancillary(),
"SpinningReserve should be ancillary"
);
assert!(
!MarketType::EnergyArbitrage.is_ancillary(),
"EnergyArbitrage should not be ancillary"
);
assert!(
!MarketType::CapacityMarket.is_ancillary(),
"CapacityMarket should not be ancillary"
);
}
}