use thiserror::Error;
#[derive(Debug, Error)]
pub enum ThermalError {
#[error("Price forecast length {got} does not match n_hours {expected}")]
PriceLengthMismatch { got: usize, expected: usize },
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("No feasible dispatch: {0}")]
Infeasible(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThermalStorageType {
MoltenSalt,
PumpedheatElectrical,
AquiferThermal,
IceStorage,
SteamAccumulator,
}
#[derive(Debug, Clone)]
pub struct ThermalStorageConfig {
pub storage_type: ThermalStorageType,
pub capacity_gwh_thermal: f64,
pub rated_charge_mw_thermal: f64,
pub rated_discharge_mw_thermal: f64,
pub charge_efficiency: f64,
pub discharge_efficiency: f64,
pub self_discharge_pct_per_day: f64,
pub min_soc_pct: f64,
pub max_soc_pct: f64,
pub operating_temp_hot_c: f64,
pub operating_temp_cold_c: f64,
}
impl ThermalStorageConfig {
pub fn validate(&self) -> Result<(), ThermalError> {
if self.capacity_gwh_thermal <= 0.0 {
return Err(ThermalError::InvalidConfig(
"capacity_gwh_thermal must be positive".to_string(),
));
}
if !(0.0..=1.0).contains(&self.charge_efficiency) {
return Err(ThermalError::InvalidConfig(
"charge_efficiency must be in [0,1]".to_string(),
));
}
if !(0.0..=1.0).contains(&self.discharge_efficiency) {
return Err(ThermalError::InvalidConfig(
"discharge_efficiency must be in [0,1]".to_string(),
));
}
if self.min_soc_pct >= self.max_soc_pct {
return Err(ThermalError::InvalidConfig(
"min_soc_pct must be less than max_soc_pct".to_string(),
));
}
if self.operating_temp_hot_c <= self.operating_temp_cold_c {
return Err(ThermalError::InvalidConfig(
"operating_temp_hot_c must exceed operating_temp_cold_c".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ThermalDispatch {
pub hour: usize,
pub charge_mw_electric: f64,
pub discharge_mw_electric: f64,
pub soc_gwh: f64,
pub heat_output_mw: f64,
pub ambient_temp_c: f64,
}
#[derive(Debug, Clone)]
pub struct ThermalStorageResult {
pub dispatch: Vec<ThermalDispatch>,
pub total_charged_gwh: f64,
pub total_discharged_gwh: f64,
pub roundtrip_efficiency_pct: f64,
pub capacity_factor_pct: f64,
pub self_discharge_loss_gwh: f64,
pub net_revenue_usd: f64,
pub carnot_cop: f64,
}
pub struct ThermalStorageOptimizer {
config: ThermalStorageConfig,
price_forecast: Vec<f64>,
heat_demand: Vec<f64>,
ambient_temp: Vec<f64>,
}
impl ThermalStorageOptimizer {
pub fn new(config: ThermalStorageConfig, prices: Vec<f64>) -> Self {
let n = prices.len();
Self {
config,
price_forecast: prices,
heat_demand: vec![0.0; n],
ambient_temp: vec![15.0; n], }
}
pub fn set_heat_demand(&mut self, demand: Vec<f64>) {
self.heat_demand = demand;
}
pub fn set_ambient_temperature(&mut self, temp: Vec<f64>) {
self.ambient_temp = temp;
}
pub fn optimize(&self) -> Result<ThermalStorageResult, ThermalError> {
self.config.validate()?;
let n = self.price_forecast.len();
if n == 0 {
return Err(ThermalError::PriceLengthMismatch {
got: 0,
expected: 1,
});
}
let mut sorted_prices = self.price_forecast.clone();
sorted_prices.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let low_idx = (n as f64 * 0.33) as usize;
let high_idx = (n as f64 * 0.67).min(n as f64 - 1.0) as usize;
let price_low = sorted_prices[low_idx];
let price_high = sorted_prices[high_idx];
let cap_gwh = self.config.capacity_gwh_thermal;
let min_soc = cap_gwh * self.config.min_soc_pct / 100.0;
let max_soc = cap_gwh * self.config.max_soc_pct / 100.0;
let self_disc_per_hour = self.config.self_discharge_pct_per_day / 100.0 / 24.0;
let max_charge_gwh =
self.config.rated_charge_mw_thermal * self.config.charge_efficiency / 1000.0;
let max_discharge_gwh_th = self.config.rated_discharge_mw_thermal / 1000.0;
let mut soc = (min_soc + max_soc) / 2.0; let mut dispatch = Vec::with_capacity(n);
let mut total_charged = 0.0f64;
let mut total_discharged_e = 0.0f64;
let mut self_disc_loss = 0.0f64;
let mut net_revenue = 0.0f64;
let mut cum_cop = 0.0f64;
for h in 0..n {
let price = self.price_forecast[h];
let ambient = *self.ambient_temp.get(h).unwrap_or(&15.0);
let heat_dem = *self.heat_demand.get(h).unwrap_or(&0.0);
let cop = self.carnot_cop(ambient);
cum_cop += cop;
let sd_loss = soc * self_disc_per_hour;
soc = (soc - sd_loss).max(0.0);
self_disc_loss += sd_loss;
let mut charge_e = 0.0f64;
let mut discharge_e = 0.0f64;
let mut heat_out = 0.0f64;
if price <= price_low && soc < max_soc {
let available_capacity = max_soc - soc;
let charge_th = max_charge_gwh.min(available_capacity);
soc += charge_th;
charge_e = charge_th * 1000.0 / self.config.charge_efficiency; total_charged += charge_th;
net_revenue -= charge_e * price; } else if price >= price_high && soc > min_soc {
let available_th = soc - min_soc;
let discharge_th = max_discharge_gwh_th.min(available_th);
soc -= discharge_th;
discharge_e = discharge_th * 1000.0 * self.config.discharge_efficiency; total_discharged_e += discharge_th * self.config.discharge_efficiency;
net_revenue += discharge_e * price; }
if heat_dem > 0.0 && soc > min_soc {
let heat_th_gwh = (heat_dem / 1000.0).min(soc - min_soc);
soc -= heat_th_gwh;
heat_out = heat_th_gwh * 1000.0;
}
soc = soc.clamp(min_soc, max_soc);
dispatch.push(ThermalDispatch {
hour: h,
charge_mw_electric: charge_e,
discharge_mw_electric: discharge_e,
soc_gwh: soc,
heat_output_mw: heat_out,
ambient_temp_c: ambient,
});
}
let rt_eff = if total_charged > 1e-9 {
(total_discharged_e / total_charged) * 100.0
} else {
0.0
};
let avg_throughput_gwh = (total_charged + total_discharged_e) / 2.0;
let cap_factor = if cap_gwh > 1e-9 {
(avg_throughput_gwh / (cap_gwh * n as f64)) * 100.0
} else {
0.0
};
let avg_cop = cum_cop / n as f64;
Ok(ThermalStorageResult {
dispatch,
total_charged_gwh: total_charged,
total_discharged_gwh: total_discharged_e,
roundtrip_efficiency_pct: rt_eff,
capacity_factor_pct: cap_factor,
self_discharge_loss_gwh: self_disc_loss,
net_revenue_usd: net_revenue,
carnot_cop: avg_cop,
})
}
pub fn carnot_cop(&self, ambient_c: f64) -> f64 {
let t_hot_k = self.config.operating_temp_hot_c + 273.15;
let t_amb_k = ambient_c + 273.15;
let delta = t_hot_k - t_amb_k;
if delta <= 0.0 {
return 1.0;
}
(t_hot_k / delta).max(1.0)
}
pub fn self_discharge_rate(&self, soc_gwh: f64, ambient_c: f64) -> f64 {
let nominal_delta = self.config.operating_temp_hot_c - self.config.operating_temp_cold_c;
let actual_delta = (self.config.operating_temp_hot_c - ambient_c).max(0.0);
let temp_factor = if nominal_delta > 0.0 {
actual_delta / nominal_delta
} else {
1.0
};
soc_gwh * (self.config.self_discharge_pct_per_day / 100.0 / 24.0) * temp_factor
}
}
#[cfg(test)]
mod tests {
use super::*;
fn molten_salt_config() -> ThermalStorageConfig {
ThermalStorageConfig {
storage_type: ThermalStorageType::MoltenSalt,
capacity_gwh_thermal: 1.0, rated_charge_mw_thermal: 200.0,
rated_discharge_mw_thermal: 150.0,
charge_efficiency: 0.95,
discharge_efficiency: 0.40, self_discharge_pct_per_day: 0.5,
min_soc_pct: 5.0,
max_soc_pct: 95.0,
operating_temp_hot_c: 565.0,
operating_temp_cold_c: 290.0,
}
}
fn price_signal_low_then_high(n: usize) -> Vec<f64> {
let mut prices = Vec::with_capacity(n);
for i in 0..n {
if i < n / 2 {
prices.push(20.0); } else {
prices.push(120.0); }
}
prices
}
#[test]
fn test_charge_low_discharge_high() {
let prices = price_signal_low_then_high(24);
let optimizer = ThermalStorageOptimizer::new(molten_salt_config(), prices);
let result = optimizer.optimize().expect("optimize ok");
let charge_sum: f64 = result.dispatch[0..12]
.iter()
.map(|d| d.charge_mw_electric)
.sum();
let discharge_sum: f64 = result.dispatch[12..24]
.iter()
.map(|d| d.discharge_mw_electric)
.sum();
assert!(charge_sum > 0.0, "Should charge in low-price hours");
assert!(discharge_sum > 0.0, "Should discharge in high-price hours");
}
#[test]
fn test_soc_bounds_respected() {
let prices = price_signal_low_then_high(48);
let config = molten_salt_config();
let optimizer = ThermalStorageOptimizer::new(config.clone(), prices);
let result = optimizer.optimize().expect("ok");
let cap = config.capacity_gwh_thermal;
let min_gwh = cap * config.min_soc_pct / 100.0;
let max_gwh = cap * config.max_soc_pct / 100.0;
for d in &result.dispatch {
assert!(
d.soc_gwh >= min_gwh - 1e-9,
"SoC {:.4} below min {:.4} at h={}",
d.soc_gwh,
min_gwh,
d.hour
);
assert!(
d.soc_gwh <= max_gwh + 1e-9,
"SoC {:.4} above max {:.4} at h={}",
d.soc_gwh,
max_gwh,
d.hour
);
}
}
#[test]
fn test_roundtrip_efficiency_below_100pct() {
let prices = price_signal_low_then_high(24);
let optimizer = ThermalStorageOptimizer::new(molten_salt_config(), prices);
let result = optimizer.optimize().expect("ok");
if result.total_charged_gwh > 1e-6 {
assert!(
result.roundtrip_efficiency_pct < 100.0,
"Round-trip efficiency must be < 100 %, got {:.2}",
result.roundtrip_efficiency_pct
);
}
}
#[test]
fn test_self_discharge_accumulates() {
let prices = vec![200.0; 24]; let config = molten_salt_config();
let optimizer = ThermalStorageOptimizer::new(config, prices);
let result = optimizer.optimize().expect("ok");
assert!(
result.self_discharge_loss_gwh > 0.0,
"Self-discharge must accumulate: {:.6}",
result.self_discharge_loss_gwh
);
}
#[test]
fn test_carnot_cop_physically_correct() {
let config = molten_salt_config(); let optimizer = ThermalStorageOptimizer::new(config, vec![50.0; 1]);
let cop = optimizer.carnot_cop(20.0);
assert!(
cop > 1.0,
"Carnot COP must be > 1.0 for heat pump, got {cop}"
);
let cop_warm = optimizer.carnot_cop(290.0);
assert!(
cop_warm > cop,
"Warmer ambient → higher COP ({cop_warm:.3} vs {cop:.3})"
);
}
#[test]
fn test_net_revenue_positive_for_arbitrage() {
let mut prices = vec![10.0; 12];
prices.extend(vec![200.0; 12]);
let optimizer = ThermalStorageOptimizer::new(molten_salt_config(), prices);
let result = optimizer.optimize().expect("ok");
assert!(
result.net_revenue_usd > 0.0,
"Arbitrage between $10 and $200 should be profitable, got ${:.2}",
result.net_revenue_usd
);
}
#[test]
fn test_ptes_config_valid() {
let config = ThermalStorageConfig {
storage_type: ThermalStorageType::PumpedheatElectrical,
capacity_gwh_thermal: 0.5,
rated_charge_mw_thermal: 100.0,
rated_discharge_mw_thermal: 80.0,
charge_efficiency: 0.90,
discharge_efficiency: 0.55,
self_discharge_pct_per_day: 0.2,
min_soc_pct: 10.0,
max_soc_pct: 90.0,
operating_temp_hot_c: 300.0,
operating_temp_cold_c: -50.0,
};
assert!(config.validate().is_ok());
let optimizer = ThermalStorageOptimizer::new(config, vec![50.0; 24]);
let result = optimizer.optimize().expect("ok");
assert_eq!(result.dispatch.len(), 24);
}
}