use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AuctionMechanism {
PayAsBid,
UniformPrice,
PayAsClear,
DescendingClock,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SupportScheme {
Cfd {
strike_price_per_mwh: f64,
reference_price_per_mwh: f64,
duration_years: usize,
},
FeedInTariff {
tariff_per_mwh: f64,
duration_years: usize,
},
RenewableObligation {
certificate_value_per_mwh: f64,
},
PremiumFeedIn {
premium_per_mwh: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum RenewableTech {
SolarPv,
OnshoreWind,
OffshoreWind,
Hydro,
Geothermal,
Biomass,
Tidal,
}
impl RenewableTech {
pub fn name(&self) -> &'static str {
match self {
RenewableTech::SolarPv => "Solar PV",
RenewableTech::OnshoreWind => "Onshore Wind",
RenewableTech::OffshoreWind => "Offshore Wind",
RenewableTech::Hydro => "Hydro",
RenewableTech::Geothermal => "Geothermal",
RenewableTech::Biomass => "Biomass",
RenewableTech::Tidal => "Tidal",
}
}
pub fn capex_per_mw(&self) -> f64 {
match self {
RenewableTech::SolarPv => 800_000.0,
RenewableTech::OnshoreWind => 1_300_000.0,
RenewableTech::OffshoreWind => 3_000_000.0,
RenewableTech::Hydro => 2_000_000.0,
RenewableTech::Geothermal => 4_000_000.0,
RenewableTech::Biomass => 2_500_000.0,
RenewableTech::Tidal => 5_000_000.0,
}
}
pub fn opex_per_mw_yr(&self) -> f64 {
match self {
RenewableTech::SolarPv => 15_000.0,
RenewableTech::OnshoreWind => 40_000.0,
RenewableTech::OffshoreWind => 100_000.0,
RenewableTech::Hydro => 20_000.0,
RenewableTech::Geothermal => 60_000.0,
RenewableTech::Biomass => 80_000.0,
RenewableTech::Tidal => 150_000.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenewableBid {
pub bidder_id: String,
pub technology: RenewableTech,
pub capacity_mw: f64,
pub strike_price_per_mwh: f64,
pub capacity_factor_pct: f64,
pub commissioning_year: usize,
pub lifetime_years: usize,
pub location: String,
pub grid_connection_cost_m_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechBand {
pub technology: RenewableTech,
pub quota_mw: f64,
pub max_price_per_mwh: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualificationCriteria {
pub min_capacity_factor_pct: f64,
pub max_distance_to_grid_km: f64,
pub require_grid_connection_study: bool,
pub local_content_pct: f64,
}
impl Default for QualificationCriteria {
fn default() -> Self {
Self {
min_capacity_factor_pct: 20.0,
max_distance_to_grid_km: f64::MAX,
require_grid_connection_study: false,
local_content_pct: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuctionConfig {
pub mechanism: AuctionMechanism,
pub support_scheme: SupportScheme,
pub procurement_target_mw: f64,
pub max_bid_price_per_mwh: f64,
pub min_bid_price_per_mwh: f64,
pub technology_bands: Vec<TechBand>,
pub qualification_criteria: QualificationCriteria,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuctionWinner {
pub bidder_id: String,
pub technology: RenewableTech,
pub capacity_mw: f64,
pub awarded_price_per_mwh: f64,
pub support_scheme: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuctionResult {
pub winners: Vec<AuctionWinner>,
pub clearing_price_per_mwh: f64,
pub total_mw_awarded: f64,
pub oversubscription_ratio: f64,
pub auction_cost_m_usd_per_year: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfdPayment {
pub year: usize,
pub generation_mwh: f64,
pub strike_price: f64,
pub reference_price: f64,
pub net_payment_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuctionMetrics {
pub consumer_cost_m_usd_per_year: f64,
pub consumer_surplus_m_usd: f64,
pub producer_surplus_m_usd: f64,
pub oversubscription_ratio: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechMixReport {
pub mw_by_tech: Vec<(String, f64)>,
pub avg_price_by_tech: Vec<(String, f64)>,
pub hhi_concentration: f64,
}
pub struct RenewableAuction {
pub auction_id: String,
pub bids: Vec<RenewableBid>,
pub config: AuctionConfig,
auction_log: Vec<String>,
}
impl RenewableAuction {
pub fn new(auction_id: String, config: AuctionConfig) -> Self {
Self {
auction_id,
bids: Vec::new(),
config,
auction_log: Vec::new(),
}
}
pub fn submit_bid(&mut self, bid: RenewableBid) {
self.auction_log.push(format!(
"BID RECEIVED: {} | {:.1} MW @ ${:.2}/MWh",
bid.bidder_id, bid.capacity_mw, bid.strike_price_per_mwh
));
self.bids.push(bid);
}
pub fn log(&self) -> &[String] {
&self.auction_log
}
pub fn qualify_bids(&self) -> Vec<&RenewableBid> {
let crit = &self.config.qualification_criteria;
self.bids
.iter()
.filter(|bid| {
if bid.capacity_factor_pct < crit.min_capacity_factor_pct {
return false;
}
if bid.strike_price_per_mwh > self.config.max_bid_price_per_mwh {
return false;
}
if bid.strike_price_per_mwh < self.config.min_bid_price_per_mwh {
return false;
}
for band in &self.config.technology_bands {
if band.technology == bid.technology
&& bid.strike_price_per_mwh > band.max_price_per_mwh
{
return false;
}
}
true
})
.collect()
}
pub fn run_auction(&mut self) -> Result<AuctionResult, String> {
let total_submitted_mw: f64 = self.bids.iter().map(|b| b.capacity_mw).sum();
let mut qualified: Vec<RenewableBid> = self.qualify_bids().into_iter().cloned().collect();
qualified.sort_by(|a, b| {
a.strike_price_per_mwh
.partial_cmp(&b.strike_price_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
self.auction_log.push(format!(
"AUCTION START: {} qualified bids, target {:.1} MW",
qualified.len(),
self.config.procurement_target_mw
));
if self.config.mechanism == AuctionMechanism::DescendingClock {
return self.run_descending_clock_owned(&qualified, total_submitted_mw);
}
let mut remaining_target = self.config.procurement_target_mw;
let mut band_awarded: HashMap<String, f64> = HashMap::new();
let mut winners: Vec<AuctionWinner> = Vec::new();
let mut clearing_price = 0.0_f64;
for bid in &qualified {
if remaining_target <= 0.0 {
break;
}
let band_remaining = self.band_remaining_mw(bid, &band_awarded);
let award_mw = bid.capacity_mw.min(remaining_target).min(band_remaining);
if award_mw <= 0.0 {
self.auction_log.push(format!(
"SKIP (band full): {} [{:?}]",
bid.bidder_id, bid.technology
));
continue;
}
clearing_price = bid.strike_price_per_mwh;
remaining_target -= award_mw;
*band_awarded
.entry(bid.technology.name().to_owned())
.or_insert(0.0) += award_mw;
let scheme_label = self.support_scheme_label();
winners.push(AuctionWinner {
bidder_id: bid.bidder_id.clone(),
technology: bid.technology.clone(),
capacity_mw: award_mw,
awarded_price_per_mwh: bid.strike_price_per_mwh, support_scheme: scheme_label,
});
self.auction_log.push(format!(
"SELECTED: {} | {:.1} MW @ ${:.2}/MWh",
bid.bidder_id, award_mw, bid.strike_price_per_mwh
));
}
if matches!(
self.config.mechanism,
AuctionMechanism::UniformPrice | AuctionMechanism::PayAsClear
) {
for w in winners.iter_mut() {
w.awarded_price_per_mwh = clearing_price;
}
}
let total_mw_awarded: f64 = winners.iter().map(|w| w.capacity_mw).sum();
let oversubscription_ratio = if self.config.procurement_target_mw > 0.0 {
total_submitted_mw / self.config.procurement_target_mw
} else {
0.0
};
let auction_cost_m_usd_per_year = self.annual_support_cost(&winners, clearing_price) / 1e6;
self.auction_log.push(format!(
"AUCTION CLOSE: {:.1} MW awarded @ ${:.2}/MWh clearing, cost ${:.2}M/yr",
total_mw_awarded, clearing_price, auction_cost_m_usd_per_year
));
Ok(AuctionResult {
winners,
clearing_price_per_mwh: clearing_price,
total_mw_awarded,
oversubscription_ratio,
auction_cost_m_usd_per_year,
})
}
pub fn calculate_cfd_payments(
&self,
winners: &[AuctionWinner],
reference_prices: &[f64],
generation_profiles: &[f64],
) -> Vec<CfdPayment> {
if winners.is_empty() {
return vec![];
}
let (strike, duration_years) = match &self.config.support_scheme {
SupportScheme::Cfd {
strike_price_per_mwh,
duration_years,
..
} => (*strike_price_per_mwh, *duration_years),
_ => return vec![],
};
let n_years = duration_years
.min(reference_prices.len())
.min(generation_profiles.len());
(0..n_years)
.map(|i| {
let ref_price = reference_prices[i];
let gen_mwh = generation_profiles[i];
let net_payment_usd = (strike - ref_price) * gen_mwh;
CfdPayment {
year: i + 1,
generation_mwh: gen_mwh,
strike_price: strike,
reference_price: ref_price,
net_payment_usd,
}
})
.collect()
}
pub fn lcoe_estimation(&self, bid: &RenewableBid) -> f64 {
let r = 0.07_f64;
let n = bid.lifetime_years as f64;
let capex = bid.technology.capex_per_mw() * bid.capacity_mw;
let opex = bid.technology.opex_per_mw_yr() * bid.capacity_mw;
let crf = if n > 0.0 {
let factor = (1.0 + r).powf(n);
r * factor / (factor - 1.0)
} else {
1.0
};
let annual_capex = capex * crf; let cf = bid.capacity_factor_pct / 100.0;
let annual_output_mwh = cf * 8760.0 * bid.capacity_mw;
if annual_output_mwh < 1e-6 {
return f64::INFINITY;
}
(annual_capex + opex) / annual_output_mwh
}
pub fn auction_efficiency_metrics(&self, auction_result: &AuctionResult) -> AuctionMetrics {
let clearing = auction_result.clearing_price_per_mwh;
let max_price = self.config.max_bid_price_per_mwh;
let mut consumer_cost = 0.0_f64;
let mut consumer_surplus = 0.0_f64;
let mut producer_surplus = 0.0_f64;
for winner in &auction_result.winners {
let cf = self
.bids
.iter()
.find(|b| b.bidder_id == winner.bidder_id)
.map(|b| b.capacity_factor_pct / 100.0)
.unwrap_or(0.25);
let annual_output_mwh = winner.capacity_mw * cf * 8760.0;
consumer_cost += winner.capacity_mw * winner.awarded_price_per_mwh * cf * 8760.0;
consumer_surplus += (max_price - clearing) * winner.capacity_mw;
if let Some(bid) = self.bids.iter().find(|b| b.bidder_id == winner.bidder_id) {
let lcoe = self.lcoe_estimation(bid);
let ps = (winner.awarded_price_per_mwh - lcoe) * annual_output_mwh;
producer_surplus += ps;
}
}
AuctionMetrics {
consumer_cost_m_usd_per_year: consumer_cost / 1e6,
consumer_surplus_m_usd: consumer_surplus / 1e6,
producer_surplus_m_usd: producer_surplus / 1e6,
oversubscription_ratio: auction_result.oversubscription_ratio,
}
}
pub fn technology_mix_analysis(&self, winners: &[AuctionWinner]) -> TechMixReport {
let mut mw_map: HashMap<String, f64> = HashMap::new();
let mut price_x_mw: HashMap<String, f64> = HashMap::new();
for w in winners {
let key = w.technology.name().to_owned();
*mw_map.entry(key.clone()).or_insert(0.0) += w.capacity_mw;
*price_x_mw.entry(key).or_insert(0.0) += w.awarded_price_per_mwh * w.capacity_mw;
}
let total_mw: f64 = mw_map.values().sum();
let mut mw_by_tech: Vec<(String, f64)> =
mw_map.iter().map(|(k, v)| (k.clone(), *v)).collect();
mw_by_tech.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let avg_price_by_tech: Vec<(String, f64)> = mw_by_tech
.iter()
.map(|(name, mw)| {
let wavg = if *mw > 0.0 {
price_x_mw.get(name).copied().unwrap_or(0.0) / mw
} else {
0.0
};
(name.clone(), wavg)
})
.collect();
let hhi = if total_mw > 0.0 {
mw_by_tech
.iter()
.map(|(_, mw)| {
let share = mw / total_mw;
share * share
})
.sum()
} else {
0.0
};
TechMixReport {
mw_by_tech,
avg_price_by_tech,
hhi_concentration: hhi,
}
}
pub fn descending_clock_round(&self, current_price: f64, submitted_mw: f64) -> (f64, bool) {
if submitted_mw > self.config.procurement_target_mw {
let new_price = current_price * 0.95;
(new_price, false)
} else {
(current_price, true)
}
}
fn support_scheme_label(&self) -> String {
match &self.config.support_scheme {
SupportScheme::Cfd { duration_years, .. } => {
format!("CfD ({duration_years}yr)")
}
SupportScheme::FeedInTariff { duration_years, .. } => {
format!("FiT ({duration_years}yr)")
}
SupportScheme::RenewableObligation { .. } => "RO".to_owned(),
SupportScheme::PremiumFeedIn { .. } => "Premium FiT".to_owned(),
}
}
fn band_remaining_mw(&self, bid: &RenewableBid, awarded: &HashMap<String, f64>) -> f64 {
let band = self
.config
.technology_bands
.iter()
.find(|b| b.technology == bid.technology);
match band {
None => f64::MAX,
Some(b) => {
let already = awarded.get(bid.technology.name()).copied().unwrap_or(0.0);
(b.quota_mw - already).max(0.0)
}
}
}
fn annual_support_cost(&self, winners: &[AuctionWinner], clearing_price: f64) -> f64 {
winners
.iter()
.map(|w| {
let cf = self
.bids
.iter()
.find(|b| b.bidder_id == w.bidder_id)
.map(|b| b.capacity_factor_pct / 100.0)
.unwrap_or(0.30);
let effective_support = match &self.config.support_scheme {
SupportScheme::Cfd {
reference_price_per_mwh,
..
} => (clearing_price - reference_price_per_mwh).max(0.0),
SupportScheme::FeedInTariff { tariff_per_mwh, .. } => *tariff_per_mwh,
SupportScheme::RenewableObligation {
certificate_value_per_mwh,
} => *certificate_value_per_mwh,
SupportScheme::PremiumFeedIn { premium_per_mwh } => *premium_per_mwh,
};
w.capacity_mw * cf * 8760.0 * effective_support
})
.sum()
}
fn run_descending_clock_owned(
&mut self,
qualified: &[RenewableBid],
total_submitted_mw: f64,
) -> Result<AuctionResult, String> {
let mut current_price = self.config.max_bid_price_per_mwh;
let max_rounds = 200_usize;
for round in 0..max_rounds {
let submitted: f64 = qualified
.iter()
.filter(|b| b.strike_price_per_mwh <= current_price)
.map(|b| b.capacity_mw)
.sum();
self.auction_log.push(format!(
"DCR round {round}: price=${current_price:.2}/MWh submitted={submitted:.1} MW"
));
let (new_price, cleared) = self.descending_clock_round(current_price, submitted);
if cleared {
let mut remaining = self.config.procurement_target_mw;
let mut band_awarded: HashMap<String, f64> = HashMap::new();
let mut winners: Vec<AuctionWinner> = Vec::new();
for bid in qualified
.iter()
.filter(|b| b.strike_price_per_mwh <= current_price)
{
if remaining <= 0.0 {
break;
}
let band_rem = self.band_remaining_mw(bid, &band_awarded);
let award_mw = bid.capacity_mw.min(remaining).min(band_rem);
if award_mw <= 0.0 {
continue;
}
remaining -= award_mw;
*band_awarded
.entry(bid.technology.name().to_owned())
.or_insert(0.0) += award_mw;
let scheme_label = self.support_scheme_label();
winners.push(AuctionWinner {
bidder_id: bid.bidder_id.clone(),
technology: bid.technology.clone(),
capacity_mw: award_mw,
awarded_price_per_mwh: current_price,
support_scheme: scheme_label,
});
}
let total_mw_awarded: f64 = winners.iter().map(|w| w.capacity_mw).sum();
let oversubscription_ratio = if self.config.procurement_target_mw > 0.0 {
total_submitted_mw / self.config.procurement_target_mw
} else {
0.0
};
let auction_cost_m_usd_per_year =
self.annual_support_cost(&winners, current_price) / 1e6;
return Ok(AuctionResult {
winners,
clearing_price_per_mwh: current_price,
total_mw_awarded,
oversubscription_ratio,
auction_cost_m_usd_per_year,
});
}
current_price = new_price;
if current_price < self.config.min_bid_price_per_mwh {
break;
}
}
Err(format!(
"Descending-clock auction '{}' did not clear after {max_rounds} rounds",
self.auction_id
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_bid(id: &str, tech: RenewableTech, mw: f64, price: f64, cf_pct: f64) -> RenewableBid {
RenewableBid {
bidder_id: id.to_owned(),
technology: tech,
capacity_mw: mw,
strike_price_per_mwh: price,
capacity_factor_pct: cf_pct,
commissioning_year: 2026,
lifetime_years: 20,
location: "TestGrid".to_owned(),
grid_connection_cost_m_usd: 5.0,
}
}
fn base_config(mechanism: AuctionMechanism) -> AuctionConfig {
AuctionConfig {
mechanism,
support_scheme: SupportScheme::Cfd {
strike_price_per_mwh: 80.0,
reference_price_per_mwh: 55.0,
duration_years: 15,
},
procurement_target_mw: 300.0,
max_bid_price_per_mwh: 120.0,
min_bid_price_per_mwh: 0.0,
technology_bands: vec![],
qualification_criteria: QualificationCriteria::default(),
}
}
#[test]
fn test_pay_as_bid_own_price() {
let mut auction =
RenewableAuction::new("T1".into(), base_config(AuctionMechanism::PayAsBid));
auction.submit_bid(make_bid("A", RenewableTech::SolarPv, 150.0, 60.0, 25.0));
auction.submit_bid(make_bid("B", RenewableTech::OnshoreWind, 200.0, 75.0, 35.0));
let result = auction.run_auction().expect("auction should succeed");
for winner in &result.winners {
let original_bid = auction
.bids
.iter()
.find(|b| b.bidder_id == winner.bidder_id)
.expect("bid must exist");
assert!(
(winner.awarded_price_per_mwh - original_bid.strike_price_per_mwh).abs() < 1e-9,
"Pay-as-bid: {} should be paid own bid {:.2}, got {:.2}",
winner.bidder_id,
original_bid.strike_price_per_mwh,
winner.awarded_price_per_mwh
);
}
}
#[test]
fn test_uniform_price_clearing() {
let mut auction =
RenewableAuction::new("T2".into(), base_config(AuctionMechanism::UniformPrice));
auction.submit_bid(make_bid("A", RenewableTech::SolarPv, 150.0, 60.0, 25.0));
auction.submit_bid(make_bid("B", RenewableTech::OnshoreWind, 200.0, 75.0, 35.0));
let result = auction.run_auction().expect("auction should succeed");
let cp = result.clearing_price_per_mwh;
for winner in &result.winners {
assert!(
(winner.awarded_price_per_mwh - cp).abs() < 1e-9,
"Uniform price: {} should be paid clearing {:.2}, got {:.2}",
winner.bidder_id,
cp,
winner.awarded_price_per_mwh
);
}
}
#[test]
fn test_merit_order_cheapest_first() {
let mut auction =
RenewableAuction::new("T3".into(), base_config(AuctionMechanism::UniformPrice));
auction.submit_bid(make_bid(
"Expensive",
RenewableTech::Biomass,
100.0,
110.0,
30.0,
));
auction.submit_bid(make_bid("Cheap", RenewableTech::SolarPv, 100.0, 55.0, 25.0));
auction.submit_bid(make_bid(
"Mid",
RenewableTech::OnshoreWind,
200.0,
70.0,
35.0,
));
let mut cfg = base_config(AuctionMechanism::UniformPrice);
cfg.procurement_target_mw = 150.0;
let mut auction2 = RenewableAuction::new("T3b".into(), cfg);
auction2.submit_bid(make_bid(
"Expensive",
RenewableTech::Biomass,
100.0,
110.0,
30.0,
));
auction2.submit_bid(make_bid("Cheap", RenewableTech::SolarPv, 100.0, 55.0, 25.0));
auction2.submit_bid(make_bid(
"Mid",
RenewableTech::OnshoreWind,
200.0,
70.0,
35.0,
));
let result = auction2.run_auction().expect("should succeed");
let winner_ids: Vec<&str> = result
.winners
.iter()
.map(|w| w.bidder_id.as_str())
.collect();
assert!(
winner_ids.contains(&"Cheap"),
"Cheapest bid should win: {winner_ids:?}"
);
assert!(
!winner_ids.contains(&"Expensive"),
"Expensive bid should not win within 150 MW: {winner_ids:?}"
);
}
#[test]
fn test_technology_band_quota() {
let mut cfg = base_config(AuctionMechanism::UniformPrice);
cfg.procurement_target_mw = 400.0;
cfg.technology_bands = vec![TechBand {
technology: RenewableTech::SolarPv,
quota_mw: 100.0, max_price_per_mwh: 120.0,
}];
let mut auction = RenewableAuction::new("T4".into(), cfg);
auction.submit_bid(make_bid("Sol1", RenewableTech::SolarPv, 150.0, 50.0, 25.0));
auction.submit_bid(make_bid("Sol2", RenewableTech::SolarPv, 150.0, 55.0, 25.0));
auction.submit_bid(make_bid(
"Wind1",
RenewableTech::OnshoreWind,
200.0,
70.0,
35.0,
));
let result = auction.run_auction().expect("should succeed");
let solar_awarded: f64 = result
.winners
.iter()
.filter(|w| w.technology == RenewableTech::SolarPv)
.map(|w| w.capacity_mw)
.sum();
assert!(
solar_awarded <= 100.0 + 1e-6,
"Solar band quota 100 MW must be respected; got {solar_awarded:.1} MW"
);
}
#[test]
fn test_qualification_price_ceiling() {
let cfg = base_config(AuctionMechanism::UniformPrice); let mut auction = RenewableAuction::new("T5".into(), cfg);
auction.submit_bid(make_bid(
"TooExpensive",
RenewableTech::Tidal,
50.0,
150.0,
30.0,
));
auction.submit_bid(make_bid("OK", RenewableTech::SolarPv, 300.0, 70.0, 25.0));
let qualified = auction.qualify_bids();
let ids: Vec<&str> = qualified.iter().map(|b| b.bidder_id.as_str()).collect();
assert!(
!ids.contains(&"TooExpensive"),
"Bid above ceiling must be excluded: {ids:?}"
);
assert!(
ids.contains(&"OK"),
"Bid within ceiling must qualify: {ids:?}"
);
}
#[test]
fn test_cfd_positive_payment() {
let cfg = base_config(AuctionMechanism::UniformPrice); let mut auction = RenewableAuction::new("T6".into(), cfg);
auction.submit_bid(make_bid(
"W1",
RenewableTech::OnshoreWind,
200.0,
70.0,
35.0,
));
let result = auction.run_auction().expect("should succeed");
let ref_prices = vec![50.0_f64; 15];
let gen_profiles = vec![200.0 * 0.35 * 8760.0; 15];
let payments = auction.calculate_cfd_payments(&result.winners, &ref_prices, &gen_profiles);
assert!(!payments.is_empty(), "Should produce CfD payments");
for p in &payments {
assert!(
p.net_payment_usd > 0.0,
"Strike 80 > reference 50: generator should receive; got {:.2}",
p.net_payment_usd
);
}
}
#[test]
fn test_cfd_negative_payment() {
let mut cfg = base_config(AuctionMechanism::UniformPrice);
cfg.support_scheme = SupportScheme::Cfd {
strike_price_per_mwh: 60.0,
reference_price_per_mwh: 80.0,
duration_years: 5,
};
let mut auction = RenewableAuction::new("T7".into(), cfg);
auction.submit_bid(make_bid("W1", RenewableTech::SolarPv, 200.0, 60.0, 25.0));
let result = auction.run_auction().expect("should succeed");
let ref_prices = vec![90.0_f64; 5];
let gen_profiles = vec![50_000.0_f64; 5];
let payments = auction.calculate_cfd_payments(&result.winners, &ref_prices, &gen_profiles);
assert!(!payments.is_empty(), "Should produce CfD payments");
for p in &payments {
assert!(
p.net_payment_usd < 0.0,
"Strike 60 < reference 90: generator pays back; got {:.2}",
p.net_payment_usd
);
}
}
#[test]
fn test_consumer_surplus_formula() {
let mut cfg = base_config(AuctionMechanism::UniformPrice);
cfg.max_bid_price_per_mwh = 100.0;
cfg.procurement_target_mw = 200.0;
let mut auction = RenewableAuction::new("T8".into(), cfg);
auction.submit_bid(make_bid("A", RenewableTech::SolarPv, 100.0, 60.0, 25.0));
auction.submit_bid(make_bid("B", RenewableTech::OnshoreWind, 150.0, 70.0, 35.0));
let result = auction.run_auction().expect("should succeed");
let metrics = auction.auction_efficiency_metrics(&result);
let clearing = result.clearing_price_per_mwh;
let total_mw: f64 = result.winners.iter().map(|w| w.capacity_mw).sum();
let expected_cs_musd = (100.0 - clearing) * total_mw / 1e6;
assert!(
(metrics.consumer_surplus_m_usd - expected_cs_musd).abs() < 1e-6,
"Consumer surplus mismatch: expected {expected_cs_musd:.6} got {:.6}",
metrics.consumer_surplus_m_usd
);
}
#[test]
fn test_oversubscription_ratio() {
let mut cfg = base_config(AuctionMechanism::UniformPrice);
cfg.procurement_target_mw = 100.0;
let mut auction = RenewableAuction::new("T9".into(), cfg);
auction.submit_bid(make_bid("A", RenewableTech::SolarPv, 150.0, 60.0, 25.0));
auction.submit_bid(make_bid("B", RenewableTech::OnshoreWind, 200.0, 70.0, 35.0));
let result = auction.run_auction().expect("should succeed");
assert!(
(result.oversubscription_ratio - 3.5).abs() < 1e-6,
"Oversubscription ratio should be 3.5, got {:.4}",
result.oversubscription_ratio
);
}
#[test]
fn test_tech_mix_hhi_monopoly() {
let cfg = base_config(AuctionMechanism::UniformPrice);
let mut auction = RenewableAuction::new("T10".into(), cfg);
auction.submit_bid(make_bid("S1", RenewableTech::SolarPv, 200.0, 60.0, 25.0));
auction.submit_bid(make_bid("S2", RenewableTech::SolarPv, 200.0, 65.0, 25.0));
let result = auction.run_auction().expect("should succeed");
let report = auction.technology_mix_analysis(&result.winners);
assert!(
(report.hhi_concentration - 1.0).abs() < 1e-6,
"Single-tech HHI should be 1.0, got {:.4}",
report.hhi_concentration
);
}
#[test]
fn test_descending_clock_clears() {
let mut cfg = base_config(AuctionMechanism::DescendingClock);
cfg.procurement_target_mw = 200.0;
cfg.max_bid_price_per_mwh = 100.0;
let mut auction = RenewableAuction::new("T11".into(), cfg);
auction.submit_bid(make_bid("A", RenewableTech::SolarPv, 100.0, 75.0, 25.0));
auction.submit_bid(make_bid("B", RenewableTech::OnshoreWind, 200.0, 95.0, 35.0));
let result = auction
.run_auction()
.expect("descending clock should clear");
assert!(
result.total_mw_awarded > 0.0,
"Should award some MW: {result:?}"
);
}
#[test]
fn test_lcoe_positive() {
let cfg = base_config(AuctionMechanism::UniformPrice);
let auction = RenewableAuction::new("T12".into(), cfg);
let techs = [
RenewableTech::SolarPv,
RenewableTech::OnshoreWind,
RenewableTech::OffshoreWind,
];
for tech in &techs {
let bid = make_bid("X", tech.clone(), 100.0, 80.0, 30.0);
let lcoe = auction.lcoe_estimation(&bid);
assert!(lcoe > 0.0, "LCOE must be positive for {tech:?}: {lcoe}");
}
}
}