use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArbitrageBattery {
pub capacity_kwh: f64,
pub p_charge_max_kw: f64,
pub p_discharge_max_kw: f64,
pub efficiency_rt: f64,
pub soc_min: f64,
pub soc_max: f64,
pub soc_init: f64,
}
impl ArbitrageBattery {
pub fn lithium_ion(capacity_kwh: f64, power_kw: f64) -> Self {
Self {
capacity_kwh,
p_charge_max_kw: power_kw,
p_discharge_max_kw: power_kw,
efficiency_rt: 0.90,
soc_min: 0.10,
soc_max: 0.90,
soc_init: 0.50,
}
}
pub fn charge_efficiency(&self) -> f64 {
self.efficiency_rt.sqrt()
}
pub fn discharge_efficiency(&self) -> f64 {
self.efficiency_rt.sqrt()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct DispatchInterval {
pub power_kw: f64,
pub soc_end: f64,
pub revenue: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArbitrageResult {
pub schedule: Vec<DispatchInterval>,
pub total_profit: f64,
pub energy_charged_kwh: f64,
pub energy_discharged_kwh: f64,
pub cycles: f64,
}
pub fn optimise_arbitrage(
battery: &ArbitrageBattery,
prices: &[f64],
dt_h: f64,
) -> ArbitrageResult {
let n = prices.len();
let mut power = vec![0.0_f64; n];
let e_available = (battery.soc_max - battery.soc_min) * battery.capacity_kwh;
let e_chargeable = e_available;
let e_dischargeable = e_available;
let mut idx_sorted: Vec<usize> = (0..n).collect();
idx_sorted.sort_by(|&a, &b| {
prices[a]
.partial_cmp(&prices[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut e_charged = 0.0_f64;
let mut e_discharged = 0.0_f64;
let eta_c = battery.charge_efficiency();
let eta_d = battery.discharge_efficiency();
for &i in &idx_sorted {
let e_step_max = battery.p_charge_max_kw * dt_h; let e_remaining = e_chargeable - e_charged;
if e_remaining <= 1e-6 {
break;
}
let e_this = e_step_max.min(e_remaining);
power[i] = e_this / dt_h; e_charged += e_this;
}
for &i in idx_sorted.iter().rev() {
if power[i] > 1e-6 {
continue;
}
let e_step_max = battery.p_discharge_max_kw * dt_h;
let e_remaining = e_dischargeable - e_discharged;
if e_remaining <= 1e-6 {
break;
}
let e_this = e_step_max.min(e_remaining);
power[i] = -e_this / dt_h; e_discharged += e_this;
}
let mut soc = battery.soc_init;
let mut schedule = Vec::with_capacity(n);
let mut total_profit = 0.0_f64;
for (i, &p) in power.iter().enumerate() {
let revenue;
if p > 0.0 {
let e_input = p * dt_h;
let e_stored = e_input * eta_c;
soc = (soc + e_stored / battery.capacity_kwh).min(battery.soc_max);
revenue = -e_input * prices[i]; } else if p < 0.0 {
let e_delivered = p.abs() * dt_h;
let e_from_battery = e_delivered / eta_d;
soc = (soc - e_from_battery / battery.capacity_kwh).max(battery.soc_min);
revenue = e_delivered * prices[i]; } else {
revenue = 0.0;
}
total_profit += revenue;
schedule.push(DispatchInterval {
power_kw: p,
soc_end: soc,
revenue,
});
}
ArbitrageResult {
schedule,
total_profit,
energy_charged_kwh: e_charged,
energy_discharged_kwh: e_discharged,
cycles: e_discharged / battery.capacity_kwh,
}
}
pub fn minimum_viable_spread(efficiency_rt: f64) -> f64 {
(1.0 - efficiency_rt) / efficiency_rt
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_battery() -> ArbitrageBattery {
ArbitrageBattery::lithium_ion(100.0, 50.0)
}
#[test]
fn test_arbitrage_charges_at_low_price() {
let bat = sample_battery();
let mut prices = vec![0.05_f64; 12]; prices.extend(vec![0.20_f64; 12]); let result = optimise_arbitrage(&bat, &prices, 1.0);
let charge_in_cheap: f64 = result.schedule[..12]
.iter()
.filter(|d| d.power_kw > 0.0)
.map(|d| d.power_kw)
.sum();
assert!(charge_in_cheap > 0.0, "Should charge during cheap hours");
}
#[test]
fn test_arbitrage_discharges_at_high_price() {
let bat = sample_battery();
let mut prices = vec![0.05_f64; 12];
prices.extend(vec![0.20_f64; 12]);
let result = optimise_arbitrage(&bat, &prices, 1.0);
let discharge_in_peak: f64 = result.schedule[12..]
.iter()
.filter(|d| d.power_kw < 0.0)
.map(|d| d.power_kw.abs())
.sum();
assert!(
discharge_in_peak > 0.0,
"Should discharge during expensive hours"
);
}
#[test]
fn test_soc_stays_in_bounds() {
let bat = sample_battery();
let prices: Vec<f64> = (0..24).map(|h| if h < 12 { 0.05 } else { 0.25 }).collect();
let result = optimise_arbitrage(&bat, &prices, 1.0);
for d in &result.schedule {
assert!(
d.soc_end >= bat.soc_min - 1e-6,
"SoC below min: {:.4}",
d.soc_end
);
assert!(
d.soc_end <= bat.soc_max + 1e-6,
"SoC above max: {:.4}",
d.soc_end
);
}
}
#[test]
fn test_profit_positive_with_spread() {
let bat = sample_battery();
let mut prices = vec![0.05_f64; 12];
prices.extend(vec![0.20_f64; 12]);
let result = optimise_arbitrage(&bat, &prices, 1.0);
assert!(
result.total_profit > 0.0,
"Should be profitable: ${:.2}",
result.total_profit
);
}
#[test]
fn test_minimum_viable_spread() {
let spread = minimum_viable_spread(0.90);
assert!(
spread > 0.0 && spread < 1.0,
"Min spread={:.4} $/kWh",
spread
);
}
#[test]
fn test_flat_price_no_profit() {
let bat = sample_battery();
let prices = vec![0.10_f64; 24];
let result = optimise_arbitrage(&bat, &prices, 1.0);
let net = result.total_profit;
assert!(
net <= 1e-6,
"Flat price should give ≤ 0 profit: ${:.4}",
net
);
}
#[test]
fn test_schedule_length_matches_prices() {
let bat = sample_battery();
let prices = vec![0.05_f64, 0.10, 0.15, 0.20, 0.25, 0.30];
let result = optimise_arbitrage(&bat, &prices, 1.0);
assert_eq!(
result.schedule.len(),
prices.len(),
"Schedule length must equal number of price intervals"
);
}
#[test]
fn test_charge_efficiency_sqrt() {
let bat = ArbitrageBattery::lithium_ion(100.0, 50.0);
let expected = 0.90_f64.sqrt();
assert!(
(bat.charge_efficiency() - expected).abs() < 1e-12,
"charge_efficiency should be sqrt(0.90), got {:.12}",
bat.charge_efficiency()
);
}
#[test]
fn test_discharge_efficiency_sqrt() {
let bat = ArbitrageBattery::lithium_ion(100.0, 50.0);
let expected = 0.90_f64.sqrt();
assert!(
(bat.discharge_efficiency() - expected).abs() < 1e-12,
"discharge_efficiency should be sqrt(0.90), got {:.12}",
bat.discharge_efficiency()
);
}
#[test]
fn test_minimum_viable_spread_perfect_efficiency() {
let spread = minimum_viable_spread(1.0);
assert!(
spread.abs() < 1e-12,
"With perfect efficiency, min viable spread should be 0.0, got {:.12}",
spread
);
}
#[test]
fn test_cycles_nonnegative() {
let bat = sample_battery();
let mut prices = vec![0.05_f64; 12];
prices.extend(vec![0.20_f64; 12]);
let result = optimise_arbitrage(&bat, &prices, 1.0);
assert!(
result.cycles >= 0.0,
"Cycles must be non-negative, got {:.6}",
result.cycles
);
}
#[test]
fn test_empty_prices() {
let bat = sample_battery();
let result = optimise_arbitrage(&bat, &[], 1.0);
assert!(
result.schedule.is_empty(),
"Empty price list should yield empty schedule"
);
}
#[test]
fn test_energy_charged_equals_discharged_symmetric() {
let bat = sample_battery();
let mut prices = vec![0.05_f64; 12];
prices.extend(vec![0.20_f64; 12]);
let result = optimise_arbitrage(&bat, &prices, 1.0);
let diff = (result.energy_charged_kwh - result.energy_discharged_kwh).abs();
assert!(
diff < 1e-6,
"For symmetric profile, charged ({:.4} kWh) should equal discharged ({:.4} kWh)",
result.energy_charged_kwh,
result.energy_discharged_kwh
);
}
}