use crate::error::OxiGridError;
use crate::optimize::ev::charging::SmartCharger;
use crate::optimize::ev::fleet::{EvFleet, FleetAlgorithm, FleetCharger, FleetScheduleResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GridService {
FrequencyRegulation {
capacity_mw: f64,
price_mwh: f64,
},
PeakShaving {
threshold_mw: f64,
price_event: f64,
},
EnergyArbitrage {
price_buy: Vec<f64>,
price_sell: Vec<f64>,
},
SpinningReserve {
capacity_mw: f64,
price_mwh: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V2gResult {
pub fleet_schedules: Vec<FleetScheduleResult>,
pub total_v2g_mw: Vec<f64>,
pub grid_service_revenue: f64,
pub total_battery_degradation: f64,
pub net_benefit: f64,
pub participating_vehicles: usize,
}
pub struct V2gAggregator {
pub fleets: Vec<EvFleet>,
pub grid_service: GridService,
}
impl V2gAggregator {
pub fn new(fleets: Vec<EvFleet>, service: GridService) -> Self {
Self {
fleets,
grid_service: service,
}
}
pub fn optimize(&self, dt_hours: f64, n_slots: usize) -> Result<V2gResult, OxiGridError> {
let (buy_prices, sell_prices) = self.build_price_profiles(n_slots, dt_hours);
let mut fleet_schedules: Vec<FleetScheduleResult> = Vec::with_capacity(self.fleets.len());
let mut total_v2g_mw = vec![0.0_f64; n_slots];
let mut total_degradation = 0.0_f64;
let mut participating = 0usize;
for fleet in &self.fleets {
let charger =
SmartCharger::new(dt_hours, sell_prices.clone(), self.fleet_capacity_kw(fleet));
let fc = FleetCharger::new(charger, FleetAlgorithm::V2gOptimized);
let fleet_result = fc.schedule_fleet(fleet)?;
for (vi, sched) in fleet_result.schedules.iter().enumerate() {
let _ = vi; let mut this_vehicle_v2g = false;
for (k, &p) in sched.power_kw.iter().enumerate() {
if p < 0.0 {
let t_hours = sched.time_slots[k];
let slot = ((t_hours / dt_hours).floor() as usize).min(n_slots - 1);
total_v2g_mw[slot] += p.abs() / 1000.0; this_vehicle_v2g = true;
}
}
if this_vehicle_v2g {
participating += 1;
}
total_degradation += sched.degradation_cost;
}
fleet_schedules.push(fleet_result);
}
let grid_revenue =
self.compute_service_revenue(&total_v2g_mw, &buy_prices, &sell_prices, dt_hours);
Ok(V2gResult {
fleet_schedules,
total_v2g_mw,
grid_service_revenue: grid_revenue,
total_battery_degradation: total_degradation,
net_benefit: grid_revenue - total_degradation,
participating_vehicles: participating,
})
}
pub fn available_capacity(&self, t: usize) -> f64 {
let dt = 0.25_f64;
let t_hours = t as f64 * dt;
self.fleets
.iter()
.flat_map(|f| f.sessions.iter())
.filter(|s| s.arrival_time <= t_hours && t_hours < s.departure_time)
.map(|s| s.max_discharge_kw)
.sum()
}
pub fn weighted_availability(&self, t: usize, soc_weight: f64) -> f64 {
let dt = 0.25_f64;
let t_hours = t as f64 * dt;
self.fleets
.iter()
.flat_map(|f| f.sessions.iter())
.filter(|s| s.arrival_time <= t_hours && t_hours < s.departure_time)
.map(|s| {
let headroom = (s.soc_arrival - s.soc_target).max(0.0);
let weight = (headroom * soc_weight).clamp(0.0, 1.0);
s.max_discharge_kw * weight
})
.sum()
}
fn build_price_profiles(&self, n_slots: usize, _dt_hours: f64) -> (Vec<f64>, Vec<f64>) {
match &self.grid_service {
GridService::FrequencyRegulation {
capacity_mw: _,
price_mwh,
} => {
let price_kwh = price_mwh / 1000.0;
let buy = vec![price_kwh * 0.5; n_slots]; let sell = vec![price_kwh; n_slots];
(buy, sell)
}
GridService::PeakShaving {
threshold_mw: _,
price_event,
} => {
let price_kwh = price_event / 1000.0;
let buy = vec![0.05; n_slots]; let sell = vec![price_kwh; n_slots];
(buy, sell)
}
GridService::EnergyArbitrage {
price_buy,
price_sell,
} => {
let buy = if price_buy.len() >= n_slots {
price_buy[..n_slots].to_vec()
} else {
let mut v = price_buy.clone();
v.resize(n_slots, *price_buy.last().unwrap_or(&0.05));
v
};
let sell = if price_sell.len() >= n_slots {
price_sell[..n_slots].to_vec()
} else {
let mut v = price_sell.clone();
v.resize(n_slots, *price_sell.last().unwrap_or(&0.15));
v
};
(buy, sell)
}
GridService::SpinningReserve {
capacity_mw: _,
price_mwh,
} => {
let price_kwh = price_mwh / 1000.0;
let buy = vec![price_kwh * 0.3; n_slots];
let sell = vec![price_kwh; n_slots];
(buy, sell)
}
}
}
fn compute_service_revenue(
&self,
v2g_mw: &[f64],
_buy_prices: &[f64],
sell_prices: &[f64],
dt_hours: f64,
) -> f64 {
match &self.grid_service {
GridService::FrequencyRegulation { price_mwh, .. }
| GridService::SpinningReserve { price_mwh, .. } => {
v2g_mw.iter().map(|&p| p * price_mwh * dt_hours).sum()
}
GridService::PeakShaving { price_event, .. } => v2g_mw
.iter()
.map(|&p| p * price_event / 1000.0 * dt_hours)
.sum(),
GridService::EnergyArbitrage { .. } => {
v2g_mw
.iter()
.zip(sell_prices.iter())
.map(|(&p_mw, &price)| p_mw * 1000.0 * price * dt_hours) .sum()
}
}
}
fn fleet_capacity_kw(&self, fleet: &EvFleet) -> f64 {
fleet
.sessions
.iter()
.map(|s| s.max_charge_kw.max(s.max_discharge_kw))
.fold(0.0_f64, f64::max)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlexibilityEnvelope {
pub time_slots: Vec<f64>,
pub max_charge_kw: Vec<f64>,
pub max_discharge_kw: Vec<f64>,
pub baseline_kw: Vec<f64>,
}
pub fn compute_flexibility_envelope(
fleet: &EvFleet,
dt_hours: f64,
n_slots: usize,
) -> FlexibilityEnvelope {
let mut max_charge = vec![0.0_f64; n_slots];
let mut max_discharge = vec![0.0_f64; n_slots];
let mut baseline = vec![0.0_f64; n_slots];
for session in &fleet.sessions {
let mut soc_baseline = session.soc_arrival;
for t in 0..n_slots {
let t_hours = t as f64 * dt_hours;
if t_hours < session.arrival_time || t_hours >= session.departure_time {
continue;
}
let soc_headroom_c = (1.0 - soc_baseline).max(0.0);
let p_chg_max = if soc_headroom_c > 1e-9 {
session
.max_charge_kw
.min(soc_headroom_c * session.battery_kwh / (session.eta_charge * dt_hours))
} else {
0.0
};
max_charge[t] += p_chg_max;
let soc_above_target = (soc_baseline - session.soc_target).max(0.0);
let p_dis_max = if session.max_discharge_kw > 1e-9 {
session
.max_discharge_kw
.min(soc_above_target * session.battery_kwh * session.eta_discharge / dt_hours)
} else {
0.0
};
max_discharge[t] += p_dis_max;
let p_base = if soc_baseline < session.soc_target - 1e-9 {
let soc_need = (session.soc_target - soc_baseline).max(0.0);
let p_limit = soc_need * session.battery_kwh / (session.eta_charge * dt_hours);
session.max_charge_kw.min(p_limit)
} else {
0.0
};
baseline[t] += p_base;
let delta = session.eta_charge * p_base * dt_hours / session.battery_kwh;
soc_baseline = (soc_baseline + delta).clamp(0.0, 1.0);
}
}
let time_slots: Vec<f64> = (0..n_slots).map(|t| t as f64 * dt_hours).collect();
FlexibilityEnvelope {
time_slots,
max_charge_kw: max_charge,
max_discharge_kw: max_discharge,
baseline_kw: baseline,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::optimize::ev::charging::EvSession;
fn make_session(id: usize, arrival: f64, departure: f64) -> EvSession {
EvSession {
vehicle_id: id,
arrival_time: arrival,
departure_time: departure,
soc_arrival: 0.5,
soc_target: 0.8,
battery_kwh: 60.0,
max_charge_kw: 11.0,
max_discharge_kw: 7.4,
eta_charge: 0.92,
eta_discharge: 0.92,
degradation_cost: 0.05,
}
}
fn make_fleet(id: usize) -> EvFleet {
EvFleet {
fleet_id: id,
bus: id,
sessions: vec![
make_session(0, 17.0, 31.0),
make_session(1, 18.0, 31.0),
make_session(2, 19.0, 31.0),
],
transformer_limit_kw: 100.0,
charger_slots: 5,
}
}
#[test]
fn test_v2g_aggregator_capacity() {
let fleet1 = make_fleet(0);
let fleet2 = make_fleet(1);
let service = GridService::FrequencyRegulation {
capacity_mw: 0.1,
price_mwh: 80.0,
};
let agg = V2gAggregator::new(vec![fleet1, fleet2], service);
let cap = agg.available_capacity(72);
assert!(
cap > 0.0,
"Aggregator capacity should be > 0: {:.2} kW",
cap
);
}
#[test]
fn test_v2g_aggregator_optimize() {
let fleet1 = make_fleet(0);
let service = GridService::EnergyArbitrage {
price_buy: vec![0.05; 96],
price_sell: vec![0.25; 96],
};
let agg = V2gAggregator::new(vec![fleet1], service);
let result = agg.optimize(0.25, 96).expect("V2G optimize");
assert_eq!(result.fleet_schedules.len(), 1);
}
#[test]
fn test_flexibility_envelope_bounds() {
let fleet = make_fleet(0);
let envelope = compute_flexibility_envelope(&fleet, 0.25, 96);
for t in 0..96 {
let charge = envelope.max_charge_kw[t];
let discharge = envelope.max_discharge_kw[t];
let base = envelope.baseline_kw[t];
assert!(
charge >= -1e-9,
"Slot {}: max_charge negative: {:.4}",
t,
charge
);
assert!(
discharge >= -1e-9,
"Slot {}: max_discharge negative: {:.4}",
t,
discharge
);
assert!(
base <= charge + 1e-6,
"Slot {}: baseline {:.4} > max_charge {:.4}",
t,
base,
charge
);
}
}
#[test]
fn test_weighted_availability_decreases_with_low_weight() {
let fleet = make_fleet(0);
let service = GridService::SpinningReserve {
capacity_mw: 0.05,
price_mwh: 60.0,
};
let agg = V2gAggregator::new(vec![fleet], service);
let full = agg.available_capacity(72);
let weighted = agg.weighted_availability(72, 1.0);
assert!(
weighted <= full + 1e-6,
"weighted {:.2} > full {:.2}",
weighted,
full
);
}
#[test]
fn test_peak_shaving_grid_service() {
let fleet = make_fleet(0);
let service = GridService::PeakShaving {
threshold_mw: 5.0,
price_event: 200.0,
};
let agg = V2gAggregator::new(vec![fleet], service);
let result = agg.optimize(0.25, 96).expect("peak shaving V2G");
let _ = result.net_benefit;
}
}