use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BessParams {
pub energy_capacity_mwh: f64,
pub p_charge_max_mw: f64,
pub p_discharge_max_mw: f64,
pub eta_charge: f64,
pub eta_discharge: f64,
pub soc_min: f64,
pub soc_max: f64,
pub deg_cost_per_mwh: f64,
pub self_discharge_per_h: f64,
}
impl BessParams {
pub fn utility_scale() -> Self {
Self {
energy_capacity_mwh: 2.0,
p_charge_max_mw: 1.0,
p_discharge_max_mw: 1.0,
eta_charge: 0.95,
eta_discharge: 0.95,
soc_min: 0.10,
soc_max: 0.90,
deg_cost_per_mwh: 5.0,
self_discharge_per_h: 0.0001,
}
}
pub fn residential() -> Self {
Self {
energy_capacity_mwh: 0.010,
p_charge_max_mw: 0.005,
p_discharge_max_mw: 0.005,
eta_charge: 0.93,
eta_discharge: 0.93,
soc_min: 0.05,
soc_max: 0.95,
deg_cost_per_mwh: 10.0,
self_discharge_per_h: 0.0002,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenData {
pub id: usize,
pub p_min_mw: f64,
pub p_max_mw: f64,
pub cost_per_mwh: f64,
}
impl GenData {
pub fn new(id: usize, p_min: f64, p_max: f64, cost: f64) -> Self {
Self {
id,
p_min_mw: p_min,
p_max_mw: p_max,
cost_per_mwh: cost,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BessOPFConfig {
pub dt_h: f64,
pub demand_mw: Vec<f64>,
pub prices: Option<Vec<f64>>,
pub soc_initial: f64,
pub cyclic_soc: bool,
pub gen_ramp_mw: Option<Vec<f64>>,
}
impl BessOPFConfig {
pub fn flat_24h(demand_mw: f64, soc_init: f64) -> Self {
Self {
dt_h: 1.0,
demand_mw: vec![demand_mw; 24],
prices: None,
soc_initial: soc_init,
cyclic_soc: true,
gen_ramp_mw: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodDispatch {
pub p_gen_mw: Vec<f64>,
pub p_charge_mw: f64,
pub p_discharge_mw: f64,
pub soc_end: f64,
pub gen_cost: f64,
pub deg_cost: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BessOPFResult {
pub periods: Vec<PeriodDispatch>,
pub soc_trajectory: Vec<f64>,
pub total_gen_cost: f64,
pub total_deg_cost: f64,
pub total_cost: f64,
pub energy_discharged_mwh: f64,
pub energy_charged_mwh: f64,
pub peak_gen_mw: f64,
pub cyclic_soc_ok: bool,
}
pub struct BessOpfSolver<'a> {
gens: &'a [GenData],
bess: &'a BessParams,
config: &'a BessOPFConfig,
}
impl<'a> BessOpfSolver<'a> {
pub fn new(gens: &'a [GenData], bess: &'a BessParams, config: &'a BessOPFConfig) -> Self {
Self { gens, bess, config }
}
pub fn run(&self) -> BessOPFResult {
let n_periods = self.config.demand_mw.len();
let dt = self.config.dt_h;
let mut soc = self.config.soc_initial;
let mut soc_traj = Vec::with_capacity(n_periods + 1);
soc_traj.push(soc);
let mut periods = Vec::with_capacity(n_periods);
let mut total_gen_cost = 0.0;
let mut total_deg_cost = 0.0;
let mut total_discharged = 0.0;
let mut total_charged = 0.0;
let mut peak_gen = 0.0f64;
let mut sorted_gens: Vec<usize> = (0..self.gens.len()).collect();
sorted_gens.sort_by(|&a, &b| {
self.gens[a]
.cost_per_mwh
.partial_cmp(&self.gens[b].cost_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
for t in 0..n_periods {
let demand = self.config.demand_mw[t];
let price = self
.config
.prices
.as_ref()
.and_then(|p| p.get(t).copied())
.unwrap_or_else(|| self.marginal_gen_cost(demand));
let (p_charge, p_discharge) = self.bess_decision(soc, price, demand, dt);
let net_demand = (demand + p_charge - p_discharge).max(0.0);
let (p_gen, gen_cost) = self.merit_order_dispatch(net_demand, &sorted_gens);
let self_disc = soc * self.bess.self_discharge_per_h * dt;
soc = soc * (1.0 - self.bess.self_discharge_per_h * dt)
+ p_charge * dt * self.bess.eta_charge / self.bess.energy_capacity_mwh
- p_discharge * dt / (self.bess.eta_discharge * self.bess.energy_capacity_mwh);
soc = soc.clamp(self.bess.soc_min, self.bess.soc_max);
let _ = self_disc;
let throughput = (p_charge + p_discharge) * dt;
let deg_cost = throughput * self.bess.deg_cost_per_mwh;
let total_p: f64 = p_gen.iter().sum();
peak_gen = peak_gen.max(total_p);
total_gen_cost += gen_cost;
total_deg_cost += deg_cost;
total_discharged += p_discharge * dt;
total_charged += p_charge * dt;
soc_traj.push(soc);
periods.push(PeriodDispatch {
p_gen_mw: p_gen,
p_charge_mw: p_charge,
p_discharge_mw: p_discharge,
soc_end: soc,
gen_cost,
deg_cost,
});
}
let cyclic_ok = (soc - self.config.soc_initial).abs() < 0.05;
let total_cost = total_gen_cost + total_deg_cost;
BessOPFResult {
periods,
soc_trajectory: soc_traj,
total_gen_cost,
total_deg_cost,
total_cost,
energy_discharged_mwh: total_discharged,
energy_charged_mwh: total_charged,
peak_gen_mw: peak_gen,
cyclic_soc_ok: cyclic_ok,
}
}
fn bess_decision(&self, soc: f64, price: f64, demand: f64, dt: f64) -> (f64, f64) {
let eff_discharge_cost =
self.bess.deg_cost_per_mwh / (self.bess.eta_discharge * self.bess.eta_charge);
let threshold_high = self.marginal_gen_cost(demand) + eff_discharge_cost;
let threshold_low = threshold_high * 0.6;
if price < threshold_low && soc < self.bess.soc_max - 0.01 {
let soc_headroom = (self.bess.soc_max - soc) * self.bess.energy_capacity_mwh
/ (self.bess.eta_charge * dt);
let p_c = soc_headroom.min(self.bess.p_charge_max_mw);
return (p_c.max(0.0), 0.0);
}
if price > threshold_high && soc > self.bess.soc_min + 0.01 {
let soc_available =
(soc - self.bess.soc_min) * self.bess.energy_capacity_mwh * self.bess.eta_discharge
/ dt;
let p_d = soc_available.min(self.bess.p_discharge_max_mw);
return (0.0, p_d.max(0.0));
}
(0.0, 0.0)
}
fn merit_order_dispatch(&self, net_demand: f64, sorted_gens: &[usize]) -> (Vec<f64>, f64) {
let mut p_gen = vec![0.0f64; self.gens.len()];
let mut remaining = net_demand;
let mut cost = 0.0;
for &gi in sorted_gens {
if remaining <= 0.0 {
break;
}
let g = &self.gens[gi];
let dispatch = remaining.min(g.p_max_mw).max(g.p_min_mw);
let dispatch = dispatch.min(remaining);
p_gen[gi] = dispatch;
cost += dispatch * g.cost_per_mwh * self.config.dt_h;
remaining -= dispatch;
}
if remaining > 1e-6 {
for &gi in sorted_gens {
p_gen[gi] = self.gens[gi].p_max_mw;
}
}
(p_gen, cost)
}
fn marginal_gen_cost(&self, demand: f64) -> f64 {
let mut cumulative = 0.0;
let mut sorted: Vec<&GenData> = self.gens.iter().collect();
sorted.sort_by(|a, b| {
a.cost_per_mwh
.partial_cmp(&b.cost_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
for g in &sorted {
cumulative += g.p_max_mw;
if cumulative >= demand {
return g.cost_per_mwh;
}
}
sorted.last().map(|g| g.cost_per_mwh).unwrap_or(100.0)
}
}
pub fn run_bess_opf(gens: &[GenData], bess: &BessParams, config: &BessOPFConfig) -> BessOPFResult {
BessOpfSolver::new(gens, bess, config).run()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SocStats {
pub min_soc: f64,
pub max_soc: f64,
pub mean_soc: f64,
pub soc_swing: f64,
pub equivalent_cycles: f64,
}
impl SocStats {
pub fn from_result(result: &BessOPFResult, capacity_mwh: f64) -> Self {
let traj = &result.soc_trajectory;
let min_soc = traj.iter().cloned().fold(f64::INFINITY, f64::min);
let max_soc = traj.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mean_soc = traj.iter().sum::<f64>() / traj.len() as f64;
let soc_swing = max_soc - min_soc;
let throughput = result.energy_charged_mwh + result.energy_discharged_mwh;
let eq_cycles = throughput / (2.0 * capacity_mwh.max(1e-9));
Self {
min_soc,
max_soc,
mean_soc,
soc_swing,
equivalent_cycles: eq_cycles,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThroughputDegModel {
pub lifetime_throughput_mwh: f64,
pub initial_capacity: f64,
}
impl ThroughputDegModel {
pub fn nmc_2mwh() -> Self {
Self {
lifetime_throughput_mwh: 6000.0,
initial_capacity: 1.0,
}
}
pub fn lfp_2mwh() -> Self {
Self {
lifetime_throughput_mwh: 12000.0,
initial_capacity: 1.0,
}
}
pub fn remaining_capacity(&self, cumulative_mwh: f64) -> f64 {
let frac = (cumulative_mwh / self.lifetime_throughput_mwh).min(1.0);
self.initial_capacity * (1.0 - 0.2 * frac)
}
pub fn years_to_eol(&self, daily_throughput_mwh: f64) -> f64 {
if daily_throughput_mwh < 1e-9 {
return f64::INFINITY;
}
self.lifetime_throughput_mwh / (daily_throughput_mwh * 365.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_gens() -> Vec<GenData> {
vec![
GenData::new(0, 0.0, 50.0, 30.0), GenData::new(1, 0.0, 30.0, 60.0), GenData::new(2, 0.0, 20.0, 90.0), ]
}
fn flat_config() -> BessOPFConfig {
BessOPFConfig {
dt_h: 1.0,
demand_mw: vec![40.0; 24],
prices: None,
soc_initial: 0.5,
cyclic_soc: true,
gen_ramp_mw: None,
}
}
#[test]
fn test_bess_opf_runs_without_panic() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
assert_eq!(result.periods.len(), 24);
}
#[test]
fn test_soc_stays_in_bounds() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
for &soc in &result.soc_trajectory {
assert!(
soc >= bess.soc_min - 1e-9,
"SOC {:.4} below min {}",
soc,
bess.soc_min
);
assert!(
soc <= bess.soc_max + 1e-9,
"SOC {:.4} above max {}",
soc,
bess.soc_max
);
}
}
#[test]
fn test_gen_cost_positive() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
assert!(
result.total_gen_cost > 0.0,
"Generation cost should be positive"
);
}
#[test]
fn test_deg_cost_positive_with_cycling() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let mut prices = vec![25.0; 24];
for p in prices.iter_mut().take(16).skip(10) {
*p = 120.0;
} let config = BessOPFConfig {
dt_h: 1.0,
demand_mw: vec![40.0; 24],
prices: Some(prices),
soc_initial: 0.5,
cyclic_soc: false,
gen_ramp_mw: None,
};
let result = run_bess_opf(&gens, &bess, &config);
assert!(result.total_deg_cost >= 0.0);
}
#[test]
fn test_soc_trajectory_length() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
assert_eq!(
result.soc_trajectory.len(),
25,
"SOC trajectory should be T+1 = 25"
);
}
#[test]
fn test_peak_gen_non_negative() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
assert!(result.peak_gen_mw >= 0.0);
}
#[test]
fn test_energy_discharged_non_negative() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
assert!(result.energy_discharged_mwh >= 0.0);
assert!(result.energy_charged_mwh >= 0.0);
}
#[test]
fn test_soc_stats() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
let stats = SocStats::from_result(&result, bess.energy_capacity_mwh);
assert!(stats.min_soc <= stats.max_soc);
assert!(stats.mean_soc >= stats.min_soc);
assert!(stats.mean_soc <= stats.max_soc);
assert!(stats.equivalent_cycles >= 0.0);
}
#[test]
fn test_degradation_model_remaining_capacity() {
let model = ThroughputDegModel::nmc_2mwh();
assert!((model.remaining_capacity(0.0) - 1.0).abs() < 1e-9);
let cap_half = model.remaining_capacity(model.lifetime_throughput_mwh / 2.0);
assert!(
(cap_half - 0.90).abs() < 1e-9,
"Half-life capacity should be 90%"
);
let cap_eol = model.remaining_capacity(model.lifetime_throughput_mwh);
assert!((cap_eol - 0.80).abs() < 1e-9, "EOL capacity should be 80%");
}
#[test]
fn test_degradation_model_years_to_eol() {
let model = ThroughputDegModel::lfp_2mwh();
let yrs = model.years_to_eol(4.0); assert!(
yrs > 5.0 && yrs < 30.0,
"LFP EOL in realistic range: {:.1} yr",
yrs
);
}
#[test]
fn test_residential_bess() {
let gens = vec![GenData::new(0, 0.0, 0.02, 50.0)];
let bess = BessParams::residential();
let config = BessOPFConfig {
dt_h: 1.0,
demand_mw: vec![0.003; 24],
prices: None,
soc_initial: 0.5,
cyclic_soc: true,
gen_ramp_mw: None,
};
let result = run_bess_opf(&gens, &bess, &config);
assert_eq!(result.periods.len(), 24);
}
#[test]
fn test_total_cost_is_sum_of_parts() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
let expected = result.total_gen_cost + result.total_deg_cost;
assert!((result.total_cost - expected).abs() < 1e-9);
}
#[test]
fn test_period_dispatch_vectors_correct_length() {
let gens = simple_gens();
let bess = BessParams::utility_scale();
let config = flat_config();
let result = run_bess_opf(&gens, &bess, &config);
for period in &result.periods {
assert_eq!(period.p_gen_mw.len(), gens.len());
}
}
#[test]
fn test_bess_opf_with_empty_gens_no_panic() {
let gens: Vec<GenData> = vec![];
let bess = BessParams::utility_scale();
let config = BessOPFConfig {
dt_h: 1.0,
demand_mw: vec![0.0; 4],
prices: None,
soc_initial: 0.5,
cyclic_soc: false,
gen_ramp_mw: None,
};
let result = run_bess_opf(&gens, &bess, &config);
assert_eq!(result.periods.len(), 4);
}
}