use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DerType {
BatteryStorage,
PvWithStorage,
WindWithStorage,
EvFleet,
IndustrialDr,
CombinedHeatPower,
ElectrolyzerP2g,
Biogas,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerState {
pub current_power_mw: f64,
pub soc: Option<f64>,
pub available: bool,
pub last_dispatch_time_h: f64,
}
impl DerState {
pub fn idle_with_soc(soc: f64) -> Self {
Self {
current_power_mw: 0.0,
soc: Some(soc.clamp(0.0, 1.0)),
available: true,
last_dispatch_time_h: 0.0,
}
}
pub fn idle_no_storage() -> Self {
Self {
current_power_mw: 0.0,
soc: None,
available: true,
last_dispatch_time_h: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerResource {
pub resource_id: usize,
pub resource_type: DerType,
pub bus: usize,
pub p_max_mw: f64,
pub p_min_mw: f64,
pub e_max_mwh: Option<f64>,
pub current_state: DerState,
pub availability: f64,
pub response_time_s: f64,
pub cost_mwh: f64,
}
impl DerResource {
pub fn effective_p_max(&self) -> f64 {
if !self.current_state.available {
return 0.0;
}
self.p_max_mw * self.availability
}
pub fn effective_p_min(&self) -> f64 {
if !self.current_state.available {
return 0.0;
}
self.p_min_mw * self.availability
}
pub fn available_energy_mwh(&self) -> f64 {
match (self.e_max_mwh, self.current_state.soc) {
(Some(e_cap), Some(soc)) => e_cap * soc.clamp(0.0, 1.0),
_ => 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VppEnvelope {
pub time_slots: Vec<f64>,
pub p_max_mw: Vec<f64>,
pub p_min_mw: Vec<f64>,
pub energy_remaining_mwh: Vec<f64>,
pub ramp_up_mw_per_min: f64,
pub ramp_down_mw_per_min: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VppMetrics {
pub total_capacity_mw: f64,
pub available_capacity_mw: f64,
pub storage_energy_mwh: f64,
pub weighted_avg_cost: f64,
pub average_response_time_s: f64,
pub n_resources: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VppDispatchResult {
pub dispatched: Vec<(usize, f64)>,
pub total_power_mw: f64,
pub curtailed_mw: f64,
pub total_cost: f64,
pub response_time_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VirtualPowerPlant {
pub vpp_id: usize,
pub name: String,
pub resources: Vec<DerResource>,
pub grid_connection_bus: usize,
pub grid_connection_limit_mw: f64,
pub forecast_horizon_h: usize,
}
impl VirtualPowerPlant {
pub fn new(vpp_id: usize, resources: Vec<DerResource>, bus: usize, limit_mw: f64) -> Self {
Self {
vpp_id,
name: format!("VPP-{vpp_id}"),
resources,
grid_connection_bus: bus,
grid_connection_limit_mw: limit_mw,
forecast_horizon_h: 24,
}
}
pub fn compute_envelope(&self, n_slots: usize, dt_h: f64) -> VppEnvelope {
let mut p_max_slots = Vec::with_capacity(n_slots);
let mut p_min_slots = Vec::with_capacity(n_slots);
let mut energy_slots = Vec::with_capacity(n_slots);
let mut time_slots = Vec::with_capacity(n_slots);
let mut storage_remaining: Vec<f64> = self
.resources
.iter()
.map(|r| r.available_energy_mwh())
.collect();
for slot in 0..n_slots {
time_slots.push(slot as f64 * dt_h);
let mut p_max_agg = 0.0_f64;
let mut p_min_agg = 0.0_f64;
let mut energy_agg = 0.0_f64;
for (idx, res) in self.resources.iter().enumerate() {
if !res.current_state.available {
continue;
}
let eff_pmax = res.effective_p_max();
let eff_pmin = res.effective_p_min();
let pmax_slot = if res.e_max_mwh.is_some() {
let max_by_energy = if dt_h > 1e-12 {
storage_remaining[idx] / dt_h
} else {
eff_pmax
};
eff_pmax.min(max_by_energy).max(0.0)
} else {
eff_pmax
};
p_max_agg += pmax_slot;
p_min_agg += eff_pmin;
energy_agg += storage_remaining[idx];
if res.e_max_mwh.is_some() {
let discharged = (pmax_slot * dt_h).min(storage_remaining[idx]);
storage_remaining[idx] = (storage_remaining[idx] - discharged).max(0.0);
}
}
let p_max_clamp = p_max_agg.min(self.grid_connection_limit_mw);
let p_min_clamp = p_min_agg.max(-self.grid_connection_limit_mw);
p_max_slots.push(p_max_clamp);
p_min_slots.push(p_min_clamp);
energy_slots.push(energy_agg);
}
let total_p_max: f64 = self.resources.iter().map(|r| r.effective_p_max()).sum();
let ramp_mw_per_min = total_p_max / 5.0;
VppEnvelope {
time_slots,
p_max_mw: p_max_slots,
p_min_mw: p_min_slots,
energy_remaining_mwh: energy_slots,
ramp_up_mw_per_min: ramp_mw_per_min,
ramp_down_mw_per_min: ramp_mw_per_min,
}
}
pub fn dispatch(&mut self, target_mw: f64, slot: usize) -> Result<VppDispatchResult> {
let _ = slot;
let target_clamped = target_mw.clamp(
-self.grid_connection_limit_mw,
self.grid_connection_limit_mw,
);
let mut indices: Vec<usize> = (0..self.resources.len()).collect();
if target_clamped >= 0.0 {
indices.sort_by(|&a, &b| {
self.resources[a]
.cost_mwh
.partial_cmp(&self.resources[b].cost_mwh)
.unwrap_or(core::cmp::Ordering::Equal)
});
} else {
indices.sort_by(|&a, &b| {
self.resources[b]
.cost_mwh
.partial_cmp(&self.resources[a].cost_mwh)
.unwrap_or(core::cmp::Ordering::Equal)
});
}
let mut remaining = target_clamped;
let mut dispatched = Vec::with_capacity(self.resources.len());
let mut total_cost = 0.0_f64;
let mut max_response_s = 0.0_f64;
for &idx in &indices {
if remaining.abs() < 1e-9 {
break;
}
let res = &self.resources[idx];
if !res.current_state.available {
dispatched.push((res.resource_id, 0.0));
continue;
}
let p_max = res.effective_p_max();
let p_min = res.effective_p_min();
let p_dispatch = if remaining > 0.0 {
remaining.min(p_max).max(0.0)
} else {
remaining.max(p_min).min(0.0)
};
let p_feasible =
if let (Some(e_cap), Some(soc)) = (res.e_max_mwh, res.current_state.soc) {
if p_dispatch > 0.0 {
let max_by_soc = e_cap * soc.clamp(0.0, 1.0);
p_dispatch.min(max_by_soc)
} else {
let max_absorb = e_cap * (1.0 - soc.clamp(0.0, 1.0));
p_dispatch.max(-max_absorb)
}
} else {
p_dispatch
};
if p_feasible.abs() > 1e-9 {
remaining -= p_feasible;
total_cost += p_feasible.abs() * res.cost_mwh;
max_response_s = max_response_s.max(res.response_time_s);
dispatched.push((res.resource_id, p_feasible));
self.resources[idx].current_state.current_power_mw = p_feasible;
} else {
dispatched.push((res.resource_id, 0.0));
}
}
let total_power_mw = target_clamped - remaining;
let curtailed_mw = (target_clamped - total_power_mw).abs();
Ok(VppDispatchResult {
dispatched,
total_power_mw,
curtailed_mw,
total_cost,
response_time_s: max_response_s,
})
}
pub fn forecast_output(&self, price_forecast: &[f64], n_slots: usize, dt_h: f64) -> Vec<f64> {
let envelope = self.compute_envelope(n_slots, dt_h);
let metrics = self.metrics();
let avg_cost = metrics.weighted_avg_cost;
let n = n_slots
.min(price_forecast.len())
.min(envelope.p_max_mw.len());
let mut output = Vec::with_capacity(n);
for (i, &price) in price_forecast.iter().enumerate().take(n) {
let p_max = envelope.p_max_mw[i];
let p_min = envelope.p_min_mw[i];
let p = if price > avg_cost {
p_max
} else if price < avg_cost * 0.5 {
p_min
} else {
0.0_f64.clamp(p_min, p_max)
};
output.push(p);
}
output
}
pub fn metrics(&self) -> VppMetrics {
let n = self.resources.len();
if n == 0 {
return VppMetrics {
total_capacity_mw: 0.0,
available_capacity_mw: 0.0,
storage_energy_mwh: 0.0,
weighted_avg_cost: 0.0,
average_response_time_s: 0.0,
n_resources: 0,
};
}
let total_capacity_mw: f64 = self.resources.iter().map(|r| r.p_max_mw).sum();
let available_capacity_mw: f64 = self.resources.iter().map(|r| r.effective_p_max()).sum();
let storage_energy_mwh: f64 = self
.resources
.iter()
.map(|r| r.available_energy_mwh())
.sum();
let weight_sum: f64 = self.resources.iter().map(|r| r.effective_p_max()).sum();
let weighted_avg_cost = if weight_sum > 1e-12 {
self.resources
.iter()
.map(|r| r.effective_p_max() * r.cost_mwh)
.sum::<f64>()
/ weight_sum
} else {
0.0
};
let average_response_time_s = if weight_sum > 1e-12 {
self.resources
.iter()
.map(|r| r.effective_p_max() * r.response_time_s)
.sum::<f64>()
/ weight_sum
} else {
0.0
};
VppMetrics {
total_capacity_mw,
available_capacity_mw,
storage_energy_mwh,
weighted_avg_cost,
average_response_time_s,
n_resources: n,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_battery_resource(
id: usize,
p_max: f64,
e_max: f64,
soc: 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(e_max),
current_state: DerState::idle_with_soc(soc),
availability: 1.0,
response_time_s: 30.0,
cost_mwh: cost,
}
}
fn make_dr_resource(id: usize, p_max: f64, cost: f64) -> DerResource {
DerResource {
resource_id: id,
resource_type: DerType::IndustrialDr,
bus: id,
p_max_mw: p_max,
p_min_mw: 0.0,
e_max_mwh: None,
current_state: DerState::idle_no_storage(),
availability: 1.0,
response_time_s: 120.0,
cost_mwh: cost,
}
}
fn make_vpp_two_batteries() -> VirtualPowerPlant {
let resources = vec![
make_battery_resource(0, 5.0, 20.0, 0.8, 10.0),
make_battery_resource(1, 3.0, 12.0, 0.6, 20.0),
];
VirtualPowerPlant::new(0, resources, 0, 10.0)
}
#[test]
fn test_envelope_aggregates_resource_p_max() {
let vpp = make_vpp_two_batteries();
let env = vpp.compute_envelope(3, 1.0);
assert_eq!(env.p_max_mw.len(), 3);
assert!(env.p_max_mw[0] > 0.0, "p_max should be positive");
assert!(env.p_max_mw[0] <= 10.0, "p_max must not exceed grid limit");
}
#[test]
fn test_envelope_p_min_negative() {
let vpp = make_vpp_two_batteries();
let env = vpp.compute_envelope(2, 1.0);
assert!(
env.p_min_mw[0] < 0.0,
"aggregate p_min should be negative for storage"
);
}
#[test]
fn test_envelope_slot_count() {
let vpp = make_vpp_two_batteries();
let env = vpp.compute_envelope(5, 0.5);
assert_eq!(env.time_slots.len(), 5);
assert_eq!(env.p_max_mw.len(), 5);
assert_eq!(env.p_min_mw.len(), 5);
assert_eq!(env.energy_remaining_mwh.len(), 5);
}
#[test]
fn test_dispatch_merit_order_cheapest_first() {
let mut vpp = VirtualPowerPlant::new(
0,
vec![
make_battery_resource(0, 5.0, 20.0, 0.9, 30.0), make_battery_resource(1, 5.0, 20.0, 0.9, 10.0), ],
0,
20.0,
);
let result = vpp.dispatch(4.0, 0).expect("dispatch ok");
let power_cheap = result
.dispatched
.iter()
.find(|(id, _)| *id == 1)
.map(|(_, p)| *p)
.unwrap_or(0.0);
let power_expensive = result
.dispatched
.iter()
.find(|(id, _)| *id == 0)
.map(|(_, p)| *p)
.unwrap_or(0.0);
assert!(
power_cheap >= power_expensive,
"cheap resource ({power_cheap:.2} MW) should be dispatched before expensive ({power_expensive:.2} MW)"
);
}
#[test]
fn test_dispatch_respects_individual_limits() {
let mut vpp = VirtualPowerPlant::new(
0,
vec![
make_battery_resource(0, 2.0, 8.0, 0.9, 10.0),
make_battery_resource(1, 3.0, 12.0, 0.9, 15.0),
],
0,
10.0,
);
let result = vpp.dispatch(4.0, 0).expect("dispatch ok");
for (id, p) in &result.dispatched {
let res = vpp.resources.iter().find(|r| r.resource_id == *id);
if let Some(r) = res {
assert!(
*p <= r.p_max_mw + 1e-9,
"resource {id} dispatched {p:.3} > p_max {:.3}",
r.p_max_mw
);
}
}
}
#[test]
fn test_dispatch_grid_limit_respected() {
let mut vpp = VirtualPowerPlant::new(
0,
vec![
make_battery_resource(0, 10.0, 50.0, 0.9, 10.0),
make_battery_resource(1, 10.0, 50.0, 0.9, 15.0),
],
0,
5.0, );
let result = vpp.dispatch(20.0, 0).expect("dispatch ok");
assert!(
result.total_power_mw <= 5.0 + 1e-9,
"total power {} must not exceed grid limit 5 MW",
result.total_power_mw
);
}
#[test]
fn test_dispatch_unavailable_resource_skipped() {
let mut vpp = VirtualPowerPlant::new(
0,
vec![{
let mut r = make_battery_resource(0, 5.0, 20.0, 0.9, 10.0);
r.current_state.available = false;
r
}],
0,
10.0,
);
let result = vpp.dispatch(3.0, 0).expect("dispatch ok");
assert_eq!(
result.total_power_mw, 0.0,
"unavailable resource should provide 0 power"
);
}
#[test]
fn test_metrics_n_resources() {
let vpp = make_vpp_two_batteries();
let m = vpp.metrics();
assert_eq!(m.n_resources, 2);
}
#[test]
fn test_metrics_storage_energy() {
let vpp = make_vpp_two_batteries();
let m = vpp.metrics();
let expected = 20.0 * 0.8 + 12.0 * 0.6;
assert!(
(m.storage_energy_mwh - expected).abs() < 1e-9,
"storage energy {:.4} != expected {:.4}",
m.storage_energy_mwh,
expected
);
}
#[test]
fn test_metrics_weighted_avg_cost() {
let resources = vec![
make_battery_resource(0, 4.0, 16.0, 0.8, 10.0),
make_battery_resource(1, 4.0, 16.0, 0.8, 30.0),
];
let vpp = VirtualPowerPlant::new(0, resources, 0, 20.0);
let m = vpp.metrics();
assert!(
(m.weighted_avg_cost - 20.0).abs() < 1e-9,
"weighted_avg_cost should be 20.0, got {:.4}",
m.weighted_avg_cost
);
}
#[test]
fn test_forecast_output_high_price_generates() {
let vpp = make_vpp_two_batteries();
let prices = vec![200.0; 4]; let output = vpp.forecast_output(&prices, 4, 1.0);
for p in &output {
assert!(*p >= 0.0, "should not absorb at very high price");
}
}
#[test]
fn test_forecast_output_low_price_absorbs() {
let vpp = make_vpp_two_batteries();
let prices = vec![1.0; 4]; let output = vpp.forecast_output(&prices, 4, 1.0);
for p in &output {
assert!(
*p <= 0.0 + 1e-9,
"should charge at very low price, got {p:.4}"
);
}
}
#[test]
fn test_der_availability_partial() {
let mut res = make_battery_resource(0, 10.0, 40.0, 0.8, 15.0);
res.availability = 0.5;
assert!(
(res.effective_p_max() - 5.0).abs() < 1e-9,
"effective p_max should be 5.0 with 50% availability"
);
}
#[test]
fn test_der_unavailable_zero_power() {
let mut res = make_dr_resource(0, 10.0, 15.0);
res.current_state.available = false;
assert_eq!(res.effective_p_max(), 0.0);
}
}