use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetStorageUnit {
pub unit_id: usize,
pub bus: usize,
pub e_max_mwh: f64,
pub p_max_mw: f64,
pub eta_roundtrip: f64,
pub soc_current: f64,
pub soc_min: f64,
pub soc_max: f64,
pub priority: usize,
pub degradation_cost: f64,
}
impl FleetStorageUnit {
pub fn max_discharge_mw(&self, dt_h: f64) -> f64 {
let usable = (self.soc_current - self.soc_min) * self.e_max_mwh;
let eta_d = self.eta_roundtrip.sqrt().max(1e-12); let p_by_energy = if dt_h > 1e-12 {
usable * eta_d / dt_h
} else {
self.p_max_mw
};
p_by_energy.min(self.p_max_mw).max(0.0)
}
pub fn max_charge_mw(&self, dt_h: f64) -> f64 {
let headroom = (self.soc_max - self.soc_current) * self.e_max_mwh;
let eta_c = self.eta_roundtrip.sqrt().max(1e-12); let p_by_energy = if dt_h > 1e-12 {
headroom / (eta_c * dt_h)
} else {
self.p_max_mw
};
p_by_energy.min(self.p_max_mw).max(0.0)
}
pub fn update_soc(&mut self, power_mw: f64, dt_h: f64) {
let eta_c = self.eta_roundtrip.sqrt().max(1e-12);
let eta_d = self.eta_roundtrip.sqrt().max(1e-12);
let e_cap = self.e_max_mwh.max(1e-12);
let delta_soc = if power_mw >= 0.0 {
-(power_mw * dt_h) / (eta_d * e_cap)
} else {
(-power_mw * eta_c * dt_h) / e_cap
};
self.soc_current = (self.soc_current + delta_soc).clamp(self.soc_min, self.soc_max);
}
pub fn available_discharge_energy_mwh(&self) -> f64 {
(self.soc_current - self.soc_min) * self.e_max_mwh
}
pub fn available_charge_headroom_mwh(&self) -> f64 {
(self.soc_max - self.soc_current) * self.e_max_mwh
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CoordinationAlgorithm {
MeritOrder,
EqualSoc,
ProRata,
PriorityBased,
OptimalDp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetDispatch {
pub unit_dispatches: Vec<(usize, f64)>,
pub total_power_mw: f64,
pub total_cost: f64,
pub soc_after: Vec<f64>,
pub soc_imbalance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageFleetCoordinator {
pub storage_units: Vec<FleetStorageUnit>,
pub coordination_algorithm: CoordinationAlgorithm,
}
impl StorageFleetCoordinator {
pub fn new(units: Vec<FleetStorageUnit>, algorithm: CoordinationAlgorithm) -> Self {
Self {
storage_units: units,
coordination_algorithm: algorithm,
}
}
pub fn dispatch(&mut self, target_mw: f64, dt_h: f64) -> Result<FleetDispatch> {
if dt_h <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"dt_h must be positive".to_string(),
));
}
if self.storage_units.is_empty() {
return Err(OxiGridError::InvalidParameter(
"fleet has no storage units".to_string(),
));
}
let dispatch = match self.coordination_algorithm {
CoordinationAlgorithm::EqualSoc => self.dispatch_equal_soc(target_mw, dt_h),
CoordinationAlgorithm::MeritOrder => self.dispatch_merit_order(target_mw, dt_h),
CoordinationAlgorithm::ProRata => self.dispatch_pro_rata(target_mw, dt_h),
CoordinationAlgorithm::PriorityBased => self.dispatch_priority(target_mw, dt_h),
CoordinationAlgorithm::OptimalDp => {
self.dispatch_merit_order(target_mw, dt_h)
}
};
Ok(dispatch)
}
pub fn dispatch_equal_soc(&mut self, target_mw: f64, dt_h: f64) -> FleetDispatch {
let n = self.storage_units.len();
if n == 0 {
return FleetDispatch {
unit_dispatches: Vec::new(),
total_power_mw: 0.0,
total_cost: 0.0,
soc_after: Vec::new(),
soc_imbalance: 0.0,
};
}
let total_e_cap: f64 = self.storage_units.iter().map(|u| u.e_max_mwh).sum();
let total_soe: f64 = self
.storage_units
.iter()
.map(|u| u.soc_current * u.e_max_mwh)
.sum();
let eta_avg = self
.storage_units
.iter()
.map(|u| u.eta_roundtrip)
.sum::<f64>()
/ n as f64;
let eta = eta_avg.sqrt().max(1e-12);
let delta_e = if target_mw >= 0.0 {
-(target_mw * dt_h / eta) } else {
-target_mw * dt_h * eta };
let target_soe = (total_soe + delta_e).clamp(
self.storage_units
.iter()
.map(|u| u.soc_min * u.e_max_mwh)
.sum::<f64>(),
self.storage_units
.iter()
.map(|u| u.soc_max * u.e_max_mwh)
.sum::<f64>(),
);
let target_soc_fleet = if total_e_cap > 1e-12 {
target_soe / total_e_cap
} else {
0.5
};
let avail: Vec<f64> = self
.storage_units
.iter()
.map(|u| {
if target_mw >= 0.0 {
u.max_discharge_mw(dt_h)
} else {
u.max_charge_mw(dt_h)
}
})
.collect();
let avail_sum: f64 = avail.iter().sum();
let weights: Vec<f64> = self
.storage_units
.iter()
.enumerate()
.map(|(i, u)| {
let dev = u.soc_current - target_soc_fleet;
let w = if target_mw >= 0.0 {
dev
} else {
-dev
};
w.max(0.0).min(avail[i])
})
.collect();
let weight_sum: f64 = weights.iter().sum();
let eff_weights: Vec<f64> = if weight_sum > 1e-12 {
weights
} else {
avail.clone()
};
let eff_sum: f64 = eff_weights.iter().sum();
let mut powers = vec![0.0_f64; n];
let mut remaining = target_mw;
if eff_sum > 1e-12 {
for i in 0..n {
let share = eff_weights[i] / eff_sum;
let p_raw = target_mw * share;
let p_clipped = if target_mw >= 0.0 {
p_raw.clamp(0.0, avail[i])
} else {
p_raw.clamp(-avail[i], 0.0)
};
powers[i] = p_clipped;
remaining -= p_clipped;
}
}
if remaining.abs() > 1e-9 && avail_sum > 1e-9 {
let residual_avail: Vec<f64> = (0..n)
.map(|i| (avail[i] - powers[i].abs()).max(0.0))
.collect();
let res_sum: f64 = residual_avail.iter().sum();
if res_sum > 1e-9 {
for i in 0..n {
let extra = remaining * (residual_avail[i] / res_sum);
let extra_clipped = if remaining > 0.0 {
extra.clamp(0.0, residual_avail[i])
} else {
extra.clamp(-residual_avail[i], 0.0)
};
powers[i] += extra_clipped;
}
}
}
let mut unit_dispatches = Vec::with_capacity(n);
let mut total_cost = 0.0_f64;
for (i, unit) in self.storage_units.iter_mut().enumerate() {
unit.update_soc(powers[i], dt_h);
total_cost += powers[i].abs() * unit.degradation_cost;
unit_dispatches.push((unit.unit_id, powers[i]));
}
let total_power_mw: f64 = powers.iter().sum();
let soc_after: Vec<f64> = self.storage_units.iter().map(|u| u.soc_current).collect();
let soc_imbalance = std_dev(&soc_after);
FleetDispatch {
unit_dispatches,
total_power_mw,
total_cost,
soc_after,
soc_imbalance,
}
}
pub fn dispatch_merit_order(&mut self, target_mw: f64, dt_h: f64) -> FleetDispatch {
let n = self.storage_units.len();
let mut indices: Vec<usize> = (0..n).collect();
indices.sort_by(|&a, &b| {
self.storage_units[a]
.degradation_cost
.partial_cmp(&self.storage_units[b].degradation_cost)
.unwrap_or(core::cmp::Ordering::Equal)
});
self.greedy_dispatch_ordered(target_mw, dt_h, &indices)
}
pub fn dispatch_pro_rata(&mut self, target_mw: f64, dt_h: f64) -> FleetDispatch {
let n = self.storage_units.len();
let avail: Vec<f64> = self
.storage_units
.iter()
.map(|u| {
if target_mw >= 0.0 {
u.max_discharge_mw(dt_h)
} else {
u.max_charge_mw(dt_h)
}
})
.collect();
let total_avail: f64 = avail.iter().sum();
let mut unit_dispatches = Vec::with_capacity(n);
let mut total_cost = 0.0_f64;
let powers: Vec<f64> = if total_avail > 1e-9 {
avail
.iter()
.map(|&a| {
let share = a / total_avail;
let p_raw = target_mw * share;
if target_mw >= 0.0 {
p_raw.clamp(0.0, a)
} else {
p_raw.clamp(-a, 0.0)
}
})
.collect()
} else {
vec![0.0; n]
};
for (idx, (&power, unit)) in powers.iter().zip(self.storage_units.iter_mut()).enumerate() {
let _ = idx;
unit_dispatches.push((unit.unit_id, power));
total_cost += power.abs() * unit.degradation_cost;
unit.update_soc(power, dt_h);
}
let total_power_mw = unit_dispatches.iter().map(|(_, p)| p).sum();
let soc_after: Vec<f64> = self.storage_units.iter().map(|u| u.soc_current).collect();
let soc_imbalance = std_dev(&soc_after);
FleetDispatch {
unit_dispatches,
total_power_mw,
total_cost,
soc_after,
soc_imbalance,
}
}
fn dispatch_priority(&mut self, target_mw: f64, dt_h: f64) -> FleetDispatch {
let n = self.storage_units.len();
let mut indices: Vec<usize> = (0..n).collect();
indices.sort_by(|&a, &b| {
self.storage_units[b]
.priority
.cmp(&self.storage_units[a].priority)
});
self.greedy_dispatch_ordered(target_mw, dt_h, &indices)
}
fn greedy_dispatch_ordered(
&mut self,
target_mw: f64,
dt_h: f64,
order: &[usize],
) -> FleetDispatch {
let n = self.storage_units.len();
let mut powers = vec![0.0_f64; n];
let mut remaining = target_mw;
let mut total_cost = 0.0_f64;
for &idx in order {
if remaining.abs() < 1e-9 {
break;
}
let unit = &self.storage_units[idx];
let p = if remaining > 0.0 {
let max_d = unit.max_discharge_mw(dt_h);
remaining.min(max_d)
} else {
let max_c = unit.max_charge_mw(dt_h);
remaining.max(-max_c)
};
powers[idx] = p;
remaining -= p;
total_cost += p.abs() * unit.degradation_cost;
}
let mut unit_dispatches = Vec::with_capacity(n);
for (idx, unit) in self.storage_units.iter_mut().enumerate() {
unit.update_soc(powers[idx], dt_h);
unit_dispatches.push((unit.unit_id, powers[idx]));
}
let total_power_mw: f64 = powers.iter().sum();
let soc_after: Vec<f64> = self.storage_units.iter().map(|u| u.soc_current).collect();
let soc_imbalance = std_dev(&soc_after);
FleetDispatch {
unit_dispatches,
total_power_mw,
total_cost,
soc_after,
soc_imbalance,
}
}
pub fn optimize_dispatch(
&self,
power_profile: &[f64],
prices: &[f64],
dt_h: f64,
) -> Result<Vec<FleetDispatch>> {
if power_profile.is_empty() {
return Err(OxiGridError::InvalidParameter(
"power_profile must not be empty".to_string(),
));
}
if dt_h <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"dt_h must be positive".to_string(),
));
}
let n_slots = power_profile.len();
let n_prices = prices.len();
let mut sim = self.clone();
let mut results = Vec::with_capacity(n_slots);
for (slot, &target) in power_profile.iter().enumerate() {
let price = if slot < n_prices { prices[slot] } else { 0.0 };
let remaining_slots = (n_slots - slot) as f64;
let future_avg_price = if slot + 1 < n_prices {
prices[slot + 1..n_prices.min(n_slots)].iter().sum::<f64>()
/ remaining_slots.max(1.0)
} else {
0.0
};
let scale = if target > 0.0 && future_avg_price > price * 1.2 {
0.70_f64
} else {
1.0_f64
};
let scaled_target = target * scale;
let n = sim.storage_units.len();
let mut indices: Vec<usize> = (0..n).collect();
indices.sort_by(|&a, &b| {
sim.storage_units[a]
.degradation_cost
.partial_cmp(&sim.storage_units[b].degradation_cost)
.unwrap_or(core::cmp::Ordering::Equal)
});
let dispatch = sim.greedy_dispatch_ordered(scaled_target, dt_h, &indices);
results.push(dispatch);
}
Ok(results)
}
pub fn total_soe_mwh(&self) -> f64 {
self.storage_units
.iter()
.map(|u| u.soc_current * u.e_max_mwh)
.sum()
}
pub fn total_available_power_mw(&self) -> f64 {
self.storage_units
.iter()
.map(|u| u.max_discharge_mw(1.0))
.sum()
}
pub fn soc_imbalance(&self) -> f64 {
let socs: Vec<f64> = self.storage_units.iter().map(|u| u.soc_current).collect();
std_dev(&socs)
}
}
fn std_dev(values: &[f64]) -> f64 {
let n = values.len();
if n < 2 {
return 0.0;
}
let mean = values.iter().sum::<f64>() / n as f64;
let variance = values.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n as f64;
variance.sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_unit(id: usize, e_max: f64, p_max: f64, soc: f64, deg_cost: f64) -> FleetStorageUnit {
FleetStorageUnit {
unit_id: id,
bus: id,
e_max_mwh: e_max,
p_max_mw: p_max,
eta_roundtrip: 0.9,
soc_current: soc,
soc_min: 0.1,
soc_max: 0.9,
priority: id,
degradation_cost: deg_cost,
}
}
fn make_fleet_two(algorithm: CoordinationAlgorithm) -> StorageFleetCoordinator {
StorageFleetCoordinator::new(
vec![
make_unit(0, 10.0, 5.0, 0.7, 5.0),
make_unit(1, 10.0, 5.0, 0.5, 10.0),
],
algorithm,
)
}
#[test]
fn test_fleet_total_soe() {
let fleet = make_fleet_two(CoordinationAlgorithm::MeritOrder);
let expected = 0.7 * 10.0 + 0.5 * 10.0; assert!(
(fleet.total_soe_mwh() - expected).abs() < 1e-9,
"total SoE {:.4} != {:.4}",
fleet.total_soe_mwh(),
expected
);
}
#[test]
fn test_fleet_soc_imbalance_nonzero() {
let fleet = make_fleet_two(CoordinationAlgorithm::EqualSoc);
assert!(
fleet.soc_imbalance() > 1e-9,
"initial SoC imbalance should be non-zero"
);
}
#[test]
fn test_fleet_merit_order_cheapest_first() {
let mut fleet = make_fleet_two(CoordinationAlgorithm::MeritOrder);
let dispatch = fleet.dispatch(3.0, 1.0).expect("dispatch ok");
let p0 = dispatch
.unit_dispatches
.iter()
.find(|(id, _)| *id == 0)
.map(|(_, p)| *p)
.unwrap_or(0.0);
let p1 = dispatch
.unit_dispatches
.iter()
.find(|(id, _)| *id == 1)
.map(|(_, p)| *p)
.unwrap_or(0.0);
assert!(
p0 >= p1 - 1e-9,
"merit order: cheap unit should dispatch first. p0={p0:.3} p1={p1:.3}"
);
}
#[test]
fn test_fleet_equal_soc_reduces_imbalance() {
let mut fleet = make_fleet_two(CoordinationAlgorithm::EqualSoc);
let imbalance_before = fleet.soc_imbalance();
fleet.dispatch(4.0, 1.0).expect("dispatch ok");
let imbalance_after = fleet.soc_imbalance();
assert!(
imbalance_after <= imbalance_before + 1e-9,
"EqualSoc should reduce or maintain SoC imbalance: before={imbalance_before:.4} after={imbalance_after:.4}"
);
}
#[test]
fn test_fleet_pro_rata_proportional() {
let e_max = 10.0;
let p_max = 5.0;
let soc = 0.8;
let mut fleet = StorageFleetCoordinator::new(
vec![
make_unit(0, e_max, p_max, soc, 5.0),
make_unit(1, e_max, p_max, soc, 10.0),
],
CoordinationAlgorithm::ProRata,
);
let dispatch = fleet.dispatch(4.0, 1.0).expect("dispatch ok");
let p0 = dispatch
.unit_dispatches
.iter()
.find(|(id, _)| *id == 0)
.map(|(_, p)| *p)
.unwrap_or(-1.0);
let p1 = dispatch
.unit_dispatches
.iter()
.find(|(id, _)| *id == 1)
.map(|(_, p)| *p)
.unwrap_or(-1.0);
assert!(
(p0 - p1).abs() < 0.1,
"pro-rata: both units should get equal share. p0={p0:.3} p1={p1:.3}"
);
}
#[test]
fn test_fleet_total_power_meets_target() {
let mut fleet = make_fleet_two(CoordinationAlgorithm::MeritOrder);
let target = 6.0;
let dispatch = fleet.dispatch(target, 1.0).expect("dispatch ok");
assert!(
(dispatch.total_power_mw - target).abs() < 1e-9,
"total power {:.4} should equal target {target}",
dispatch.total_power_mw
);
}
#[test]
fn test_fleet_soc_updated_after_dispatch() {
let mut fleet = make_fleet_two(CoordinationAlgorithm::MeritOrder);
let soc_before: Vec<f64> = fleet.storage_units.iter().map(|u| u.soc_current).collect();
fleet.dispatch(3.0, 1.0).expect("dispatch ok");
let soc_after: Vec<f64> = fleet.storage_units.iter().map(|u| u.soc_current).collect();
let any_decreased = soc_before
.iter()
.zip(soc_after.iter())
.any(|(b, a)| *b > *a + 1e-12);
assert!(
any_decreased,
"SoC should decrease after discharge dispatch"
);
}
#[test]
fn test_fleet_dp_optimize_returns_all_slots() {
let fleet = make_fleet_two(CoordinationAlgorithm::OptimalDp);
let profile = vec![3.0, 2.0, 4.0, 1.0];
let prices = vec![50.0, 60.0, 40.0, 70.0];
let results = fleet
.optimize_dispatch(&profile, &prices, 1.0)
.expect("optimize ok");
assert_eq!(results.len(), profile.len());
}
#[test]
fn test_fleet_empty_returns_error() {
let mut fleet = StorageFleetCoordinator::new(vec![], CoordinationAlgorithm::MeritOrder);
let result = fleet.dispatch(1.0, 1.0);
assert!(result.is_err(), "empty fleet should return error");
}
#[test]
fn test_fleet_charge_direction() {
let mut fleet = make_fleet_two(CoordinationAlgorithm::EqualSoc);
let dispatch = fleet.dispatch(-3.0, 1.0).expect("dispatch ok");
assert!(
dispatch.total_power_mw < 0.0,
"charging dispatch should have negative total power"
);
}
}