use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, thiserror::Error)]
pub enum MesError {
#[error("no energy hubs registered")]
NoHubs,
#[error("demand series length {got} does not match n_hours {expected}")]
DemandLengthMismatch { got: usize, expected: usize },
#[error("no converter available for energy carrier {0:?}")]
NoConverterForCarrier(EnergyCarrier),
#[error("numerical error in storage DP: {0}")]
Numerical(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EnergyCarrier {
Electricity,
NaturalGas,
Heat,
Cooling,
Hydrogen,
Biomass,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConverterType {
CombinedHeatPower,
HeatPump,
AbsorptionChiller,
Electrolyzer,
FuelCell,
Boiler,
AirConditioner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyConverter {
pub id: usize,
pub converter_type: ConverterType,
pub input_carrier: EnergyCarrier,
pub output_carriers: Vec<(EnergyCarrier, f64)>,
pub capacity_kw: f64,
pub min_load_factor: f64,
pub cost_per_kwh_input: f64,
}
impl EnergyConverter {
fn efficiency_for(&self, carrier: &EnergyCarrier) -> f64 {
self.output_carriers
.iter()
.find(|(c, _)| c == carrier)
.map(|(_, eta)| *eta)
.unwrap_or(0.0)
}
fn max_output_kw(&self, carrier: &EnergyCarrier) -> f64 {
self.capacity_kw * self.efficiency_for(carrier)
}
fn produces(&self, carrier: &EnergyCarrier) -> bool {
self.output_carriers.iter().any(|(c, _)| c == carrier)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyStorage {
pub carrier: EnergyCarrier,
pub capacity_kwh: f64,
pub charge_efficiency: f64,
pub discharge_efficiency: f64,
pub max_charge_kw: f64,
pub max_discharge_kw: f64,
pub soc_min: f64,
pub soc_max: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyDemand {
pub carrier: EnergyCarrier,
pub demand_kw: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyHub {
pub id: usize,
pub converters: Vec<EnergyConverter>,
pub storages: Vec<EnergyStorage>,
pub demands: Vec<EnergyDemand>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MesOptConfig {
pub n_hours: usize,
pub electricity_price: Vec<f64>,
pub gas_price: f64,
pub carbon_price_usd_per_t: f64,
pub export_price: Vec<f64>,
}
impl MesOptConfig {
fn elec_price_at(&self, h: usize) -> f64 {
self.electricity_price.get(h).copied().unwrap_or(0.06)
}
fn export_price_at(&self, h: usize) -> f64 {
self.export_price.get(h).copied().unwrap_or(0.03)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HubHourlyDispatch {
pub hour: usize,
pub converter_dispatch: Vec<(usize, f64)>,
pub storage_charge: Vec<(usize, f64)>,
pub storage_discharge: Vec<(usize, f64)>,
pub grid_import_kw: f64,
pub grid_export_kw: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MesOptResult {
pub hub_dispatch: Vec<HubHourlyDispatch>,
pub total_cost_usd: f64,
pub co2_emissions_kg: f64,
pub renewable_fraction: f64,
pub energy_cost_usd_per_kwh: f64,
pub peak_demand_kw: f64,
}
fn storage_dispatch_greedy(
storage: &EnergyStorage,
soc: f64,
price_now: f64,
price_mean: f64,
) -> (f64, f64, f64) {
let cap = storage.capacity_kwh;
let e_stored = soc * cap;
let e_room = (storage.soc_max - soc) * cap;
let e_avail = (soc - storage.soc_min) * cap;
if price_now < price_mean * 0.85 && e_room > 1e-6 {
let charge_kw = storage
.max_charge_kw
.min(e_room * storage.charge_efficiency);
let new_soc =
((e_stored + charge_kw / storage.charge_efficiency) / cap).min(storage.soc_max);
(charge_kw, 0.0, new_soc)
} else if price_now > price_mean * 1.15 && e_avail > 1e-6 {
let discharge_kw = storage
.max_discharge_kw
.min(e_avail * storage.discharge_efficiency);
let new_soc =
((e_stored - discharge_kw / storage.discharge_efficiency) / cap).max(storage.soc_min);
(0.0, discharge_kw, new_soc)
} else {
(0.0, 0.0, soc)
}
}
pub struct MesOptimizer {
config: MesOptConfig,
hubs: Vec<EnergyHub>,
}
impl MesOptimizer {
pub fn new(config: MesOptConfig) -> Self {
Self {
config,
hubs: Vec::new(),
}
}
pub fn add_hub(&mut self, hub: EnergyHub) {
self.hubs.push(hub);
}
pub fn optimize(&self) -> Result<MesOptResult, MesError> {
if self.hubs.is_empty() {
return Err(MesError::NoHubs);
}
let n = self.config.n_hours;
let mean_elec_price = self.config.electricity_price.iter().sum::<f64>() / n.max(1) as f64;
for hub in &self.hubs {
for demand in &hub.demands {
if demand.demand_kw.len() != n {
return Err(MesError::DemandLengthMismatch {
got: demand.demand_kw.len(),
expected: n,
});
}
}
}
let mut all_dispatch: Vec<HubHourlyDispatch> = Vec::with_capacity(n * self.hubs.len());
let mut total_cost = 0.0_f64;
let mut total_co2_kg = 0.0_f64;
let mut total_demand_kwh = 0.0_f64;
let mut total_low_carbon_kwh = 0.0_f64;
let mut peak_import_kw = 0.0_f64;
for hub in &self.hubs {
let mut storage_soc: Vec<f64> = hub.storages.iter().map(|_| 0.5_f64).collect();
for h in 0..n {
let price_now = self.config.elec_price_at(h);
let export_price = self.config.export_price_at(h);
let mut demand_map: HashMap<EnergyCarrier, f64> = HashMap::new();
for dem in &hub.demands {
*demand_map.entry(dem.carrier.clone()).or_insert(0.0) +=
dem.demand_kw.get(h).copied().unwrap_or(0.0);
}
let mut converter_dispatch: Vec<(usize, f64)> = Vec::new();
let mut elec_balance = 0.0_f64; let mut step_cost = 0.0_f64;
let mut step_co2 = 0.0_f64;
let heat_demand = demand_map.get(&EnergyCarrier::Heat).copied().unwrap_or(0.0);
if heat_demand > 1e-6 {
let (cd, elec_adj, cost, co2) =
self.dispatch_heat(hub, heat_demand, h, price_now);
converter_dispatch.extend_from_slice(&cd);
elec_balance += elec_adj;
step_cost += cost;
step_co2 += co2;
}
let cool_demand = demand_map
.get(&EnergyCarrier::Cooling)
.copied()
.unwrap_or(0.0);
if cool_demand > 1e-6 {
let (cd, elec_adj, cost, co2) =
self.dispatch_cooling(hub, cool_demand, price_now);
converter_dispatch.extend_from_slice(&cd);
elec_balance += elec_adj;
step_cost += cost;
step_co2 += co2;
}
let elec_demand = demand_map
.get(&EnergyCarrier::Electricity)
.copied()
.unwrap_or(0.0);
elec_balance -= elec_demand;
let mut storage_charge_vec: Vec<(usize, f64)> = Vec::new();
let mut storage_discharge_vec: Vec<(usize, f64)> = Vec::new();
for (i, storage) in hub.storages.iter().enumerate() {
if storage.carrier != EnergyCarrier::Electricity {
continue;
}
let (charge, discharge, new_soc) = storage_dispatch_greedy(
storage,
storage_soc[i],
price_now,
mean_elec_price,
);
if charge > 1e-6 {
elec_balance -= charge;
storage_charge_vec.push((i, charge));
}
if discharge > 1e-6 {
elec_balance += discharge;
storage_discharge_vec.push((i, discharge));
}
storage_soc[i] = new_soc;
}
let (grid_import, grid_export, grid_cost, grid_co2) = if elec_balance < -1e-6 {
let import = -elec_balance;
let cost = import * price_now; let co2 = import * 0.4; (import, 0.0, cost, co2)
} else {
let export = elec_balance;
let revenue = export * export_price;
(0.0, export, -revenue, 0.0)
};
step_cost += grid_cost;
step_co2 += grid_co2;
peak_import_kw = peak_import_kw.max(grid_import);
total_cost += step_cost;
total_co2_kg += step_co2;
for dem in &hub.demands {
let d = dem.demand_kw.get(h).copied().unwrap_or(0.0);
total_demand_kwh += d;
if dem.carrier == EnergyCarrier::Heat {
total_low_carbon_kwh += d * 0.5; }
}
all_dispatch.push(HubHourlyDispatch {
hour: h,
converter_dispatch,
storage_charge: storage_charge_vec,
storage_discharge: storage_discharge_vec,
grid_import_kw: grid_import,
grid_export_kw: grid_export,
});
}
}
let renewable_fraction = if total_demand_kwh > 1e-6 {
(total_low_carbon_kwh / total_demand_kwh).clamp(0.0, 1.0)
} else {
0.0
};
let energy_cost_usd_per_kwh = if total_demand_kwh > 1e-6 {
total_cost / total_demand_kwh
} else {
0.0
};
Ok(MesOptResult {
hub_dispatch: all_dispatch,
total_cost_usd: total_cost,
co2_emissions_kg: total_co2_kg,
renewable_fraction,
energy_cost_usd_per_kwh,
peak_demand_kw: peak_import_kw,
})
}
#[allow(unused_assignments)]
fn dispatch_heat(
&self,
hub: &EnergyHub,
heat_demand_kw: f64,
_hour: usize,
elec_price: f64,
) -> (Vec<(usize, f64)>, f64, f64, f64) {
let mut remaining = heat_demand_kw;
let mut cd = Vec::new();
let mut elec_adj = 0.0_f64;
let mut cost = 0.0_f64;
let mut co2 = 0.0_f64;
for conv in hub.converters.iter().filter(|c| {
c.converter_type == ConverterType::CombinedHeatPower && c.produces(&EnergyCarrier::Heat)
}) {
if remaining <= 1e-6 {
break;
}
let eta_heat = conv.efficiency_for(&EnergyCarrier::Heat);
let eta_elec = conv.efficiency_for(&EnergyCarrier::Electricity);
let deliverable = conv.max_output_kw(&EnergyCarrier::Heat).min(remaining);
if deliverable < conv.capacity_kw * conv.min_load_factor * eta_heat {
continue; }
let input_kw = deliverable / eta_heat;
cd.push((conv.id, input_kw));
elec_adj += input_kw * eta_elec;
cost += input_kw * conv.cost_per_kwh_input;
co2 += input_kw * 0.2; remaining -= deliverable;
}
if remaining > 1e-6 {
let heat_pump = hub.converters.iter().find(|c| {
c.converter_type == ConverterType::HeatPump && c.produces(&EnergyCarrier::Heat)
});
let boiler = hub.converters.iter().find(|c| {
c.converter_type == ConverterType::Boiler && c.produces(&EnergyCarrier::Heat)
});
let hp_cost_per_kwh_heat = heat_pump.map_or(f64::INFINITY, |hp| {
let cop = hp.efficiency_for(&EnergyCarrier::Heat);
if cop < 1e-6 {
f64::INFINITY
} else {
elec_price / cop
}
});
let boiler_cost_per_kwh_heat = boiler.map_or(f64::INFINITY, |b| {
let eta = b.efficiency_for(&EnergyCarrier::Heat);
if eta < 1e-6 {
f64::INFINITY
} else {
b.cost_per_kwh_input / eta
}
});
if hp_cost_per_kwh_heat <= boiler_cost_per_kwh_heat {
if let Some(hp) = heat_pump {
let cop = hp.efficiency_for(&EnergyCarrier::Heat);
let deliverable = hp.max_output_kw(&EnergyCarrier::Heat).min(remaining);
let input_kw = deliverable / cop;
cd.push((hp.id, input_kw));
elec_adj -= input_kw; cost += input_kw * elec_price;
remaining -= deliverable;
}
} else if let Some(b) = boiler {
let eta = b.efficiency_for(&EnergyCarrier::Heat);
let deliverable = b.max_output_kw(&EnergyCarrier::Heat).min(remaining);
let input_kw = deliverable / eta;
cd.push((b.id, input_kw));
cost += input_kw * b.cost_per_kwh_input;
co2 += input_kw * 0.2;
remaining -= deliverable;
}
}
(cd, elec_adj, cost, co2)
}
#[allow(unused_assignments)]
fn dispatch_cooling(
&self,
hub: &EnergyHub,
cool_demand_kw: f64,
elec_price: f64,
) -> (Vec<(usize, f64)>, f64, f64, f64) {
let mut remaining = cool_demand_kw;
let mut cd = Vec::new();
let mut elec_adj = 0.0_f64;
let mut cost = 0.0_f64;
let co2 = 0.0_f64;
for conv in hub.converters.iter().filter(|c| {
c.converter_type == ConverterType::AbsorptionChiller
&& c.produces(&EnergyCarrier::Cooling)
}) {
if remaining <= 1e-6 {
break;
}
let deliverable = conv.max_output_kw(&EnergyCarrier::Cooling).min(remaining);
let eta = conv.efficiency_for(&EnergyCarrier::Cooling);
let input_kw = if eta > 1e-9 { deliverable / eta } else { 0.0 };
cd.push((conv.id, input_kw));
cost += input_kw * conv.cost_per_kwh_input;
remaining -= deliverable;
}
for conv in hub.converters.iter().filter(|c| {
c.converter_type == ConverterType::AirConditioner && c.produces(&EnergyCarrier::Cooling)
}) {
if remaining <= 1e-6 {
break;
}
let cop = conv.efficiency_for(&EnergyCarrier::Cooling);
let deliverable = conv.max_output_kw(&EnergyCarrier::Cooling).min(remaining);
let input_kw = if cop > 1e-9 { deliverable / cop } else { 0.0 };
cd.push((conv.id, input_kw));
elec_adj -= input_kw;
cost += input_kw * elec_price;
remaining -= deliverable;
}
(cd, elec_adj, cost, co2)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_config(n: usize, price: f64) -> MesOptConfig {
MesOptConfig {
n_hours: n,
electricity_price: vec![price; n],
gas_price: 0.04,
carbon_price_usd_per_t: 50.0,
export_price: vec![0.02; n],
}
}
fn chp_converter() -> EnergyConverter {
EnergyConverter {
id: 1,
converter_type: ConverterType::CombinedHeatPower,
input_carrier: EnergyCarrier::NaturalGas,
output_carriers: vec![
(EnergyCarrier::Electricity, 0.35),
(EnergyCarrier::Heat, 0.45),
],
capacity_kw: 100.0,
min_load_factor: 0.0,
cost_per_kwh_input: 0.04,
}
}
fn boiler_converter() -> EnergyConverter {
EnergyConverter {
id: 2,
converter_type: ConverterType::Boiler,
input_carrier: EnergyCarrier::NaturalGas,
output_carriers: vec![(EnergyCarrier::Heat, 0.90)],
capacity_kw: 200.0,
min_load_factor: 0.0,
cost_per_kwh_input: 0.04,
}
}
fn heat_pump_converter(cop: f64) -> EnergyConverter {
EnergyConverter {
id: 3,
converter_type: ConverterType::HeatPump,
input_carrier: EnergyCarrier::Electricity,
output_carriers: vec![(EnergyCarrier::Heat, cop)],
capacity_kw: 50.0,
min_load_factor: 0.0,
cost_per_kwh_input: 0.0, }
}
fn ac_converter() -> EnergyConverter {
EnergyConverter {
id: 4,
converter_type: ConverterType::AirConditioner,
input_carrier: EnergyCarrier::Electricity,
output_carriers: vec![(EnergyCarrier::Cooling, 3.0)],
capacity_kw: 60.0,
min_load_factor: 0.0,
cost_per_kwh_input: 0.0,
}
}
fn elec_storage() -> EnergyStorage {
EnergyStorage {
carrier: EnergyCarrier::Electricity,
capacity_kwh: 100.0,
charge_efficiency: 0.95,
discharge_efficiency: 0.95,
max_charge_kw: 20.0,
max_discharge_kw: 20.0,
soc_min: 0.1,
soc_max: 0.9,
}
}
#[test]
fn test_chp_dispatch_covers_heat_demand() {
let hub = EnergyHub {
id: 0,
converters: vec![chp_converter(), boiler_converter()],
storages: vec![],
demands: vec![EnergyDemand {
carrier: EnergyCarrier::Heat,
demand_kw: vec![40.0; 4],
}],
};
let mut opt = MesOptimizer::new(simple_config(4, 0.08));
opt.add_hub(hub);
let result = opt.optimize().expect("optimise should succeed");
let first_hour = &result.hub_dispatch[0];
let chp_active = first_hour
.converter_dispatch
.iter()
.any(|&(id, kw)| id == 1 && kw > 1e-6);
assert!(
chp_active,
"CHP (id=1) should be dispatched for heat demand"
);
assert!(result.total_cost_usd >= 0.0);
}
#[test]
fn test_heat_pump_vs_boiler_cost_selection() {
let hub = EnergyHub {
id: 0,
converters: vec![heat_pump_converter(3.0), boiler_converter()],
storages: vec![],
demands: vec![EnergyDemand {
carrier: EnergyCarrier::Heat,
demand_kw: vec![30.0; 2],
}],
};
let mut opt = MesOptimizer::new(simple_config(2, 0.03)); opt.add_hub(hub);
let result = opt.optimize().expect("optimise should succeed");
let first_hour = &result.hub_dispatch[0];
let hp_active = first_hour.converter_dispatch.iter().any(|&(id, _)| id == 3);
let boiler_active = first_hour.converter_dispatch.iter().any(|&(id, _)| id == 2);
assert!(
hp_active || boiler_active,
"Either heat pump or boiler should serve heat demand"
);
let hub_exp = EnergyHub {
id: 1,
converters: vec![heat_pump_converter(3.0), boiler_converter()],
storages: vec![],
demands: vec![EnergyDemand {
carrier: EnergyCarrier::Heat,
demand_kw: vec![30.0; 2],
}],
};
let mut opt_exp = MesOptimizer::new(simple_config(2, 0.20)); opt_exp.add_hub(hub_exp);
let result_exp = opt_exp
.optimize()
.expect("optimise expensive should succeed");
let first_exp = &result_exp.hub_dispatch[0];
let boiler_exp = first_exp.converter_dispatch.iter().any(|&(id, _)| id == 2);
assert!(
boiler_exp,
"Boiler should be selected when electricity price is high"
);
}
#[test]
fn test_storage_arbitrage_charge_low_discharge_high() {
let n = 6;
let prices: Vec<f64> = (0..n).map(|h| if h < 3 { 0.03 } else { 0.15 }).collect();
let hub = EnergyHub {
id: 0,
converters: vec![],
storages: vec![elec_storage()],
demands: vec![EnergyDemand {
carrier: EnergyCarrier::Electricity,
demand_kw: vec![5.0; n],
}],
};
let cfg = MesOptConfig {
n_hours: n,
electricity_price: prices.clone(),
gas_price: 0.04,
carbon_price_usd_per_t: 30.0,
export_price: vec![0.02; n],
};
let mut opt = MesOptimizer::new(cfg);
opt.add_hub(hub);
let result = opt.optimize().expect("should optimise");
let charges_in_cheap: Vec<bool> = result.hub_dispatch[..3]
.iter()
.map(|d| !d.storage_charge.is_empty())
.collect();
let discharges_in_peak: Vec<bool> = result.hub_dispatch[3..]
.iter()
.map(|d| !d.storage_discharge.is_empty())
.collect();
let any_charge = charges_in_cheap.iter().any(|&b| b);
let any_discharge = discharges_in_peak.iter().any(|&b| b);
assert!(
any_charge || any_discharge,
"Storage should (dis)charge to exploit price spread"
);
assert!(result.total_cost_usd.is_finite());
}
#[test]
fn test_carbon_accounting_positive() {
let hub = EnergyHub {
id: 0,
converters: vec![chp_converter()],
storages: vec![],
demands: vec![
EnergyDemand {
carrier: EnergyCarrier::Heat,
demand_kw: vec![50.0; 3],
},
EnergyDemand {
carrier: EnergyCarrier::Electricity,
demand_kw: vec![10.0; 3],
},
],
};
let mut opt = MesOptimizer::new(simple_config(3, 0.07));
opt.add_hub(hub);
let result = opt.optimize().expect("should optimise");
assert!(
result.co2_emissions_kg >= 0.0,
"CO₂ should be non-negative: {:.2}",
result.co2_emissions_kg
);
assert!(
result.co2_emissions_kg > 0.0,
"CHP with gas should produce CO₂"
);
}
#[test]
fn test_cooling_dispatch_ac() {
let hub = EnergyHub {
id: 0,
converters: vec![ac_converter()],
storages: vec![],
demands: vec![EnergyDemand {
carrier: EnergyCarrier::Cooling,
demand_kw: vec![20.0; 2],
}],
};
let mut opt = MesOptimizer::new(simple_config(2, 0.08));
opt.add_hub(hub);
let result = opt.optimize().expect("should optimise");
let ac_dispatched = result
.hub_dispatch
.iter()
.any(|d| d.converter_dispatch.iter().any(|&(id, _)| id == 4));
assert!(ac_dispatched, "AC (id=4) should be dispatched for cooling");
assert!(
result.total_cost_usd > 0.0,
"AC electricity cost should be positive"
);
}
#[test]
fn test_multi_hub_no_cross_subsidisation() {
let hub1 = EnergyHub {
id: 0,
converters: vec![chp_converter()],
storages: vec![],
demands: vec![EnergyDemand {
carrier: EnergyCarrier::Heat,
demand_kw: vec![30.0; 4],
}],
};
let hub2 = EnergyHub {
id: 1,
converters: vec![ac_converter()],
storages: vec![],
demands: vec![EnergyDemand {
carrier: EnergyCarrier::Cooling,
demand_kw: vec![15.0; 4],
}],
};
let mut opt = MesOptimizer::new(simple_config(4, 0.07));
opt.add_hub(hub1);
opt.add_hub(hub2);
let result = opt.optimize().expect("multi-hub should succeed");
assert_eq!(
result.hub_dispatch.len(),
8,
"Should have 8 dispatch records"
);
assert!(result.total_cost_usd >= 0.0);
assert!(result.co2_emissions_kg >= 0.0);
}
}