use crate::optimize::vpp::aggregator::VirtualPowerPlant;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MarketService {
EnergyDayAhead,
EnergyRealTime,
FrequencyRegulationUp,
FrequencyRegulationDown,
SpinningReserve,
NonSpinningReserve,
VoltageSupport,
Balancing,
}
impl MarketService {
pub fn required_response_s(&self) -> f64 {
match self {
Self::FrequencyRegulationUp | Self::FrequencyRegulationDown => 4.0,
Self::SpinningReserve => 600.0,
Self::NonSpinningReserve => 1800.0,
Self::VoltageSupport => 1.0,
Self::EnergyDayAhead | Self::EnergyRealTime => 300.0,
Self::Balancing => 900.0,
}
}
pub fn typical_premium(&self) -> f64 {
match self {
Self::FrequencyRegulationUp | Self::FrequencyRegulationDown => 0.20,
Self::SpinningReserve => 0.10,
Self::NonSpinningReserve => 0.05,
Self::VoltageSupport => 0.08,
Self::EnergyDayAhead | Self::EnergyRealTime => 0.0,
Self::Balancing => 0.03,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketBid {
pub period: usize,
pub service: MarketService,
pub volume_mw: f64,
pub price_mwh: f64,
pub probability_of_acceptance: f64,
}
impl MarketBid {
pub fn expected_revenue_per_period(&self, dt_h: f64) -> f64 {
self.volume_mw * self.price_mwh * dt_h * self.probability_of_acceptance
}
}
pub struct VppBiddingStrategy {
pub vpp: VirtualPowerPlant,
pub risk_aversion: f64,
pub forecast_error_std: f64,
}
impl VppBiddingStrategy {
pub fn new(vpp: VirtualPowerPlant, risk_aversion: f64) -> Self {
Self {
vpp,
risk_aversion: risk_aversion.clamp(0.0, 1.0),
forecast_error_std: 0.05, }
}
pub fn compute_da_bids(
&self,
price_forecast: &[f64],
price_forecast_std: &[f64],
dt_h: f64,
) -> Vec<MarketBid> {
let n = price_forecast.len().min(price_forecast_std.len());
if n == 0 {
return Vec::new();
}
let envelope = self.vpp.compute_envelope(n, dt_h);
let metrics = self.vpp.metrics();
let avg_cost = metrics.weighted_avg_cost;
let mut bids = Vec::with_capacity(n);
for i in 0..n {
let price_expected = price_forecast[i];
let price_sigma = price_forecast_std[i].max(0.0);
let price_bid = price_expected - self.risk_aversion * price_sigma;
let p_max = if i < envelope.p_max_mw.len() {
envelope.p_max_mw[i]
} else {
0.0
};
let volume = if price_bid > avg_cost {
p_max
} else {
0.0_f64.max(p_max * 0.0)
};
let margin = price_expected - price_bid;
let z = if price_sigma > 1e-9 {
margin / price_sigma
} else {
if margin >= 0.0 {
3.0
} else {
-3.0
}
};
let p_accept = logistic(z);
bids.push(MarketBid {
period: i,
service: MarketService::EnergyDayAhead,
volume_mw: volume.max(0.0),
price_mwh: price_bid,
probability_of_acceptance: p_accept,
});
}
bids
}
pub fn compute_ancillary_bid(
&self,
service: MarketService,
requirement_mw: f64,
price_signal: f64,
) -> Option<MarketBid> {
let metrics = self.vpp.metrics();
if metrics.average_response_time_s > service.required_response_s() {
return None;
}
let available_mw = metrics.available_capacity_mw;
if available_mw < requirement_mw - 1e-9 {
return None;
}
let premium_price = price_signal * (1.0 + service.typical_premium());
let bid_price =
premium_price - self.risk_aversion * premium_price * self.forecast_error_std;
let volume = available_mw.min(requirement_mw * 2.0);
Some(MarketBid {
period: 0,
service,
volume_mw: volume,
price_mwh: bid_price,
probability_of_acceptance: 0.8 - self.risk_aversion * 0.3,
})
}
pub fn expected_revenue(&self, bids: &[MarketBid], realized_prices: &[f64]) -> f64 {
let dt_h = 1.0; bids.iter()
.map(|bid| {
let realized = if bid.period < realized_prices.len() {
realized_prices[bid.period]
} else {
0.0
};
if realized >= bid.price_mwh {
bid.volume_mw * realized * dt_h
} else {
0.0
}
})
.sum()
}
pub fn value_at_risk(&self, bids: &[MarketBid], alpha: f64, n_scenarios: usize) -> f64 {
if bids.is_empty() || n_scenarios == 0 {
return 0.0;
}
let alpha = alpha.clamp(0.0, 1.0);
let dt_h = 1.0;
let mut revenues = Vec::with_capacity(n_scenarios);
let mut lcg_state: u64 = 0xDEAD_BEEF_CAFE_1234_u64;
for _ in 0..n_scenarios {
let mut scenario_revenue = 0.0_f64;
for bid in bids {
let u1 = lcg_next(&mut lcg_state);
let u2 = lcg_next(&mut lcg_state);
let z = box_muller(u1, u2);
let price_scale = bid.price_mwh.abs().max(1.0) * self.forecast_error_std;
let sim_price = bid.price_mwh + price_scale * z;
if sim_price >= bid.price_mwh {
scenario_revenue += bid.volume_mw * sim_price * dt_h;
}
}
revenues.push(scenario_revenue);
}
revenues.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let var_idx = ((1.0 - alpha) * n_scenarios as f64) as usize;
let var_idx = var_idx.min(revenues.len().saturating_sub(1));
let threshold_revenue = revenues[var_idx];
(-threshold_revenue).max(0.0)
}
}
pub fn coordinate_vpp_bids(vpps: &[VppBiddingStrategy], market_cap_mw: f64) -> Vec<Vec<MarketBid>> {
let n_vpps = vpps.len();
if n_vpps == 0 {
return Vec::new();
}
let max_share_mw = (market_cap_mw / n_vpps as f64).max(0.0);
vpps.iter()
.map(|strategy| {
let metrics = strategy.vpp.metrics();
let envelope = strategy.vpp.compute_envelope(24, 1.0);
let mut bids: Vec<MarketBid> = (0..24)
.map(|period| {
let p_max = if period < envelope.p_max_mw.len() {
envelope.p_max_mw[period]
} else {
metrics.available_capacity_mw
};
let volume = p_max.min(max_share_mw);
let bid_price = metrics.weighted_avg_cost
* (1.0 + strategy.vpp.resources.len() as f64 * 0.01);
MarketBid {
period,
service: MarketService::EnergyDayAhead,
volume_mw: volume.max(0.0),
price_mwh: bid_price,
probability_of_acceptance: 0.75,
}
})
.collect();
bids.retain(|b| b.volume_mw > 1e-9);
bids
})
.collect()
}
fn lcg_next(state: &mut u64) -> f64 {
const A: u64 = 6_364_136_223_846_793_005_u64;
const C: u64 = 1_442_695_040_888_963_407_u64;
*state = state.wrapping_mul(A).wrapping_add(C);
let mantissa = *state >> 11;
mantissa as f64 * (1.0 / (1u64 << 53) as f64)
}
fn box_muller(u1: f64, u2: f64) -> f64 {
let u1 = u1.max(1e-15); let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * core::f64::consts::PI * u2;
r * theta.cos()
}
fn logistic(z: f64) -> f64 {
1.0 / (1.0 + (-z).exp())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::optimize::vpp::aggregator::{DerResource, DerState, DerType};
fn make_battery_der(id: usize, p_max: f64, cost: f64) -> DerResource {
DerResource {
resource_id: id,
resource_type: DerType::BatteryStorage,
bus: id,
p_max_mw: p_max,
p_min_mw: -p_max,
e_max_mwh: Some(p_max * 4.0),
current_state: DerState::idle_with_soc(0.8),
availability: 1.0,
response_time_s: 30.0,
cost_mwh: cost,
}
}
fn make_vpp(p_max: f64, cost: f64) -> VirtualPowerPlant {
VirtualPowerPlant::new(
0,
vec![
make_battery_der(0, p_max, cost),
make_battery_der(1, p_max, cost * 1.2),
],
0,
p_max * 2.0,
)
}
fn make_strategy(vpp: VirtualPowerPlant, risk: f64) -> VppBiddingStrategy {
VppBiddingStrategy::new(vpp, risk)
}
#[test]
fn test_da_bid_risk_adjustment_reduces_price() {
let vpp = make_vpp(5.0, 30.0);
let risk_neutral = make_strategy(make_vpp(5.0, 30.0), 0.0);
let risk_averse = make_strategy(vpp, 0.8);
let prices = vec![80.0; 5];
let sigmas = vec![10.0; 5];
let bids_neutral = risk_neutral.compute_da_bids(&prices, &sigmas, 1.0);
let bids_averse = risk_averse.compute_da_bids(&prices, &sigmas, 1.0);
for (bn, ba) in bids_neutral.iter().zip(bids_averse.iter()) {
assert!(
ba.price_mwh <= bn.price_mwh + 1e-9,
"risk-averse bid price {:.4} should be <= risk-neutral {:.4}",
ba.price_mwh,
bn.price_mwh
);
}
}
#[test]
fn test_da_bid_volume_nonnegative() {
let strategy = make_strategy(make_vpp(5.0, 30.0), 0.5);
let prices = vec![80.0; 4];
let sigmas = vec![5.0; 4];
let bids = strategy.compute_da_bids(&prices, &sigmas, 1.0);
for bid in &bids {
assert!(bid.volume_mw >= 0.0, "bid volume must be non-negative");
}
}
#[test]
fn test_da_bid_count_matches_horizon() {
let strategy = make_strategy(make_vpp(5.0, 20.0), 0.3);
let n = 6;
let prices = vec![50.0; n];
let sigmas = vec![8.0; n];
let bids = strategy.compute_da_bids(&prices, &sigmas, 1.0);
assert_eq!(bids.len(), n);
}
#[test]
fn test_ancillary_bid_response_time_filter() {
let mut vpp = make_vpp(10.0, 20.0);
for res in vpp.resources.iter_mut() {
res.response_time_s = 1000.0; }
let strategy = make_strategy(vpp, 0.2);
let bid = strategy.compute_ancillary_bid(MarketService::FrequencyRegulationUp, 1.0, 60.0);
assert!(
bid.is_none(),
"slow-response VPP should not qualify for freq reg"
);
}
#[test]
fn test_ancillary_bid_sufficient_capacity() {
let strategy = make_strategy(make_vpp(10.0, 20.0), 0.2);
let bid = strategy.compute_ancillary_bid(MarketService::SpinningReserve, 5.0, 60.0);
assert!(bid.is_some(), "VPP with sufficient capacity should bid");
let b = bid.expect("bid present");
assert!(b.volume_mw > 0.0);
assert!(b.price_mwh > 0.0);
}
#[test]
fn test_expected_revenue_positive_when_price_exceeds_bid() {
let strategy = make_strategy(make_vpp(5.0, 20.0), 0.0);
let bids = vec![MarketBid {
period: 0,
service: MarketService::EnergyDayAhead,
volume_mw: 10.0,
price_mwh: 50.0,
probability_of_acceptance: 0.9,
}];
let realized = vec![70.0]; let revenue = strategy.expected_revenue(&bids, &realized);
assert!(
revenue > 0.0,
"revenue should be positive when clearing > bid price"
);
}
#[test]
fn test_expected_revenue_zero_when_price_below_bid() {
let strategy = make_strategy(make_vpp(5.0, 20.0), 0.0);
let bids = vec![MarketBid {
period: 0,
service: MarketService::EnergyDayAhead,
volume_mw: 10.0,
price_mwh: 80.0,
probability_of_acceptance: 0.9,
}];
let realized = vec![40.0]; let revenue = strategy.expected_revenue(&bids, &realized);
assert_eq!(revenue, 0.0, "bid not accepted → zero revenue");
}
#[test]
fn test_var_nonnegative() {
let strategy = make_strategy(make_vpp(5.0, 20.0), 0.5);
let bids = vec![MarketBid {
period: 0,
service: MarketService::EnergyDayAhead,
volume_mw: 10.0,
price_mwh: 50.0,
probability_of_acceptance: 0.8,
}];
let var = strategy.value_at_risk(&bids, 0.95, 200);
assert!(var >= 0.0, "VaR must be non-negative, got {var:.4}");
}
#[test]
fn test_var_increases_with_uncertainty() {
let mut strategy_low = make_strategy(make_vpp(5.0, 20.0), 0.3);
strategy_low.forecast_error_std = 0.02; let mut strategy_high = make_strategy(make_vpp(5.0, 20.0), 0.3);
strategy_high.forecast_error_std = 0.30;
let bids = vec![MarketBid {
period: 0,
service: MarketService::EnergyDayAhead,
volume_mw: 10.0,
price_mwh: 60.0,
probability_of_acceptance: 0.8,
}];
let var_low = strategy_low.value_at_risk(&bids, 0.95, 500);
let var_high = strategy_high.value_at_risk(&bids, 0.95, 500);
assert!(
var_high >= var_low * 0.5,
"higher uncertainty should produce comparable or higher VaR. low={var_low:.4} high={var_high:.4}"
);
}
#[test]
fn test_multi_vpp_bid_limit() {
let vpps = vec![
make_strategy(make_vpp(20.0, 25.0), 0.2),
make_strategy(make_vpp(20.0, 30.0), 0.3),
];
let market_cap = 15.0; let all_bids = coordinate_vpp_bids(&vpps, market_cap);
assert_eq!(all_bids.len(), vpps.len());
let per_vpp_cap = market_cap / 2.0; for vpp_bids in &all_bids {
for bid in vpp_bids {
assert!(
bid.volume_mw <= per_vpp_cap + 1e-9,
"bid volume {:.3} MW exceeds per-VPP market cap {per_vpp_cap:.3} MW",
bid.volume_mw
);
}
}
}
#[test]
fn test_multi_vpp_coordination_returns_bids_for_each_vpp() {
let vpps = vec![
make_strategy(make_vpp(5.0, 20.0), 0.1),
make_strategy(make_vpp(8.0, 25.0), 0.2),
make_strategy(make_vpp(3.0, 15.0), 0.4),
];
let bids = coordinate_vpp_bids(&vpps, 50.0);
assert_eq!(bids.len(), 3, "should have one bid set per VPP");
}
#[test]
fn test_lcg_determinism() {
let mut s1: u64 = 12345;
let mut s2: u64 = 12345;
for _ in 0..10 {
let v1 = lcg_next(&mut s1);
let v2 = lcg_next(&mut s2);
assert_eq!(v1, v2, "LCG must be deterministic");
}
}
#[test]
fn test_lcg_range() {
let mut state: u64 = 0xABCD_1234;
for _ in 0..100 {
let v = lcg_next(&mut state);
assert!((0.0..1.0).contains(&v), "LCG must produce values in [0, 1)");
}
}
}