const R_J_PER_MOL_K: f64 = 8.314_462_618;
const M_H2_KG_PER_MOL: f64 = 2.016e-3;
const T_REF_K: f64 = 298.15;
const P_REF_PA: f64 = 1.0e5;
const HOURS_PER_WEEK: usize = 168;
const KG_PER_TONNE: f64 = 1_000.0;
pub const H2_LHV_KWH_PER_KG: f64 = 33.3;
pub const GREEN_H2_THRESHOLD_G_CO2_PER_KG: f64 = 1_000.0;
#[derive(Debug, Clone)]
pub enum SeasonalStorageType {
SaltCavern {
working_volume_m3: f64,
min_pressure_bar: f64,
max_pressure_bar: f64,
cushion_gas_pct: f64,
},
LinerCavern {
volume_m3: f64,
operating_pressure_bar: f64,
},
UndergroundTank {
capacity_t_h2: f64,
pressure_bar: f64,
},
PipelineBuffer {
pipe_length_km: f64,
diameter_m: f64,
max_pressure_bar: f64,
},
}
#[derive(Debug, Clone)]
pub struct ElectrolyzerFleet {
pub capacity_mw: f64,
pub efficiency_kwh_per_kg: f64,
pub min_load_pct: f64,
pub startup_time_min: f64,
pub ramp_rate_pct_per_min: f64,
}
impl Default for ElectrolyzerFleet {
fn default() -> Self {
Self {
capacity_mw: 100.0,
efficiency_kwh_per_kg: 55.0,
min_load_pct: 0.10,
startup_time_min: 30.0,
ramp_rate_pct_per_min: 10.0,
}
}
}
#[derive(Debug, Clone)]
pub struct FuelCellFleet {
pub capacity_mw: f64,
pub efficiency_kwh_per_kg: f64,
pub min_load_pct: f64,
pub startup_time_min: f64,
}
impl Default for FuelCellFleet {
fn default() -> Self {
Self {
capacity_mw: 50.0,
efficiency_kwh_per_kg: 20.0,
min_load_pct: 0.10,
startup_time_min: 15.0,
}
}
}
#[derive(Debug, Clone)]
pub struct SeasonalStorageConfig {
pub planning_weeks: usize,
pub h2_lower_heating_value_kwh_per_kg: f64,
pub electricity_price_seasonal: Vec<f64>,
pub renewable_surplus_mw: Vec<f64>,
pub grid_co2_intensity_g_per_kwh: Vec<f64>,
pub h2_demand_t_per_week: Vec<f64>,
pub discount_rate: f64,
pub opex_fraction: f64,
}
impl Default for SeasonalStorageConfig {
fn default() -> Self {
let weeks = 52_usize;
Self {
planning_weeks: weeks,
h2_lower_heating_value_kwh_per_kg: H2_LHV_KWH_PER_KG,
electricity_price_seasonal: vec![50.0; weeks],
renewable_surplus_mw: vec![200.0; weeks],
grid_co2_intensity_g_per_kwh: vec![300.0; weeks],
h2_demand_t_per_week: vec![100.0; weeks],
discount_rate: 0.08,
opex_fraction: 0.03,
}
}
}
#[derive(Debug, Clone)]
pub struct WeeklyDispatch {
pub week: usize,
pub h2_produced_kg: f64,
pub h2_consumed_kg: f64,
pub h2_to_market_kg: f64,
pub electricity_generated_mwh: f64,
pub electricity_consumed_mwh: f64,
pub end_inventory_kg: f64,
pub revenue_usd: f64,
pub cost_usd: f64,
}
#[derive(Debug, Clone)]
pub struct YearlySimResult {
pub weekly_results: Vec<WeeklyDispatch>,
pub total_h2_produced_t: f64,
pub total_revenue_usd: f64,
pub total_cost_usd: f64,
pub net_profit_usd: f64,
pub round_trip_efficiency_pct: f64,
pub storage_utilization_pct: f64,
pub unserved_h2_demand_t: f64,
}
#[derive(Debug, Clone)]
pub struct H2EconomicsResult {
pub lcoh_per_kg: f64,
pub npv_usd: f64,
pub payback_years: f64,
pub annual_revenue_usd: f64,
pub is_green_hydrogen: bool,
pub carbon_intensity_g_co2_per_kg: f64,
}
#[derive(Clone)]
pub struct SeasonalH2Optimizer {
pub storage: SeasonalStorageType,
pub electrolyzers: ElectrolyzerFleet,
pub fuel_cells: FuelCellFleet,
pub config: SeasonalStorageConfig,
}
impl SeasonalH2Optimizer {
pub fn storage_capacity_kg(&self) -> f64 {
match &self.storage {
SeasonalStorageType::SaltCavern {
working_volume_m3,
min_pressure_bar,
max_pressure_bar,
cushion_gas_pct,
} => {
let delta_p_pa = (max_pressure_bar - min_pressure_bar) * P_REF_PA;
let total_mass_kg =
(delta_p_pa * working_volume_m3) / (R_J_PER_MOL_K * T_REF_K) * M_H2_KG_PER_MOL;
total_mass_kg * (1.0 - cushion_gas_pct.clamp(0.0, 0.99))
}
SeasonalStorageType::LinerCavern {
volume_m3,
operating_pressure_bar,
} => {
let p_pa = operating_pressure_bar * P_REF_PA;
let total_mass_kg =
(p_pa * volume_m3) / (R_J_PER_MOL_K * T_REF_K) * M_H2_KG_PER_MOL;
total_mass_kg * 0.95
}
SeasonalStorageType::UndergroundTank { capacity_t_h2, .. } => {
capacity_t_h2 * KG_PER_TONNE
}
SeasonalStorageType::PipelineBuffer {
pipe_length_km,
diameter_m,
max_pressure_bar,
} => {
let length_m = pipe_length_km * 1_000.0;
let volume_m3 = std::f64::consts::FRAC_PI_4 * diameter_m * diameter_m * length_m;
let delta_p_pa = (max_pressure_bar - 1.0).max(0.0) * P_REF_PA;
(delta_p_pa * volume_m3) / (R_J_PER_MOL_K * T_REF_K) * M_H2_KG_PER_MOL
}
}
}
pub fn electrolyzer_dispatch(
&self,
renewable_surplus_mw: f64,
h2_price_per_kg: f64,
electricity_price: f64,
) -> f64 {
let eff = self.electrolyzers.efficiency_kwh_per_kg;
let break_even_price = h2_price_per_kg * 1_000.0 / eff;
if electricity_price >= break_even_price {
return 0.0;
}
let available_mw = renewable_surplus_mw.min(self.electrolyzers.capacity_mw);
if available_mw <= 0.0 {
return 0.0;
}
let ramp_fraction = (self.electrolyzers.ramp_rate_pct_per_min / 100.0 * 60.0).min(1.0);
let max_load_pct = ramp_fraction;
let min_load_pct = self.electrolyzers.min_load_pct;
let load_fraction = (available_mw / self.electrolyzers.capacity_mw)
.min(max_load_pct)
.max(0.0);
if load_fraction < min_load_pct {
return 0.0;
}
let power_mw = load_fraction * self.electrolyzers.capacity_mw;
power_mw * 1_000.0 / eff
}
pub fn fuel_cell_dispatch(
&self,
h2_inventory_kg: f64,
electricity_price: f64,
h2_price_per_kg: f64,
) -> f64 {
let eff = self.fuel_cells.efficiency_kwh_per_kg; let threshold = h2_price_per_kg * 1_000.0 / eff;
if electricity_price <= threshold {
return 0.0;
}
if h2_inventory_kg <= 0.0 {
return 0.0;
}
let max_output_mw = self.fuel_cells.capacity_mw;
let max_h2_per_h_kg = max_output_mw * 1_000.0 / eff;
let h2_consumed = max_h2_per_h_kg.min(h2_inventory_kg);
h2_consumed * eff / 1_000.0
}
pub fn optimize_weekly_dispatch(
&self,
week_idx: usize,
h2_inventory_kg: f64,
) -> WeeklyDispatch {
let weeks = self.config.planning_weeks;
let w = week_idx.min(weeks.saturating_sub(1));
let elec_price = self
.config
.electricity_price_seasonal
.get(w)
.copied()
.unwrap_or(50.0);
let surplus_mw = self
.config
.renewable_surplus_mw
.get(w)
.copied()
.unwrap_or(0.0);
let demand_t = self
.config
.h2_demand_t_per_week
.get(w)
.copied()
.unwrap_or(0.0);
let demand_kg = demand_t * KG_PER_TONNE;
let capacity_kg = self.storage_capacity_kg();
let price_cheap = elec_price * 0.70;
let price_mid = elec_price;
let price_peak = elec_price * 1.30;
let h2_price = 3.0 + elec_price * 0.01;
let break_even =
h2_price * 1_000.0 / self.electrolyzers.efficiency_kwh_per_kg.max(f64::EPSILON);
let fc_threshold =
h2_price * 1_000.0 / self.fuel_cells.efficiency_kwh_per_kg.max(f64::EPSILON);
let mut inventory = h2_inventory_kg.max(0.0);
let mut h2_produced = 0.0_f64;
let mut h2_consumed_fc = 0.0_f64;
let mut h2_to_market = 0.0_f64;
let mut elec_generated = 0.0_f64;
let mut elec_consumed = 0.0_f64;
let mut revenue = 0.0_f64;
let mut cost = 0.0_f64;
let demand_per_hour = demand_kg / HOURS_PER_WEEK as f64;
for hour in 0..HOURS_PER_WEEK {
let hourly_price = if hour < 56 {
price_cheap
} else if hour < 112 {
price_mid
} else {
price_peak
};
let served = demand_per_hour.min(inventory);
inventory -= served;
h2_to_market += served;
revenue += served * h2_price;
if hourly_price < break_even {
let headroom = (capacity_kg - inventory).max(0.0);
let prod_kg = self
.electrolyzer_dispatch(surplus_mw, h2_price, hourly_price)
.min(headroom);
if prod_kg > 0.0 {
let power_mw = prod_kg * self.electrolyzers.efficiency_kwh_per_kg / 1_000.0;
inventory += prod_kg;
h2_produced += prod_kg;
elec_consumed += power_mw; cost += power_mw * hourly_price / 1_000.0; }
}
if hourly_price > fc_threshold {
let min_inventory = capacity_kg * 0.10;
if inventory > min_inventory {
let gen_mwh = self.fuel_cell_dispatch(inventory, hourly_price, h2_price);
let h2_used =
gen_mwh * 1_000.0 / self.fuel_cells.efficiency_kwh_per_kg.max(f64::EPSILON);
inventory = (inventory - h2_used).max(0.0);
h2_consumed_fc += h2_used;
elec_generated += gen_mwh;
revenue += gen_mwh * hourly_price / 1_000.0; }
}
}
WeeklyDispatch {
week: week_idx,
h2_produced_kg: h2_produced,
h2_consumed_kg: h2_consumed_fc,
h2_to_market_kg: h2_to_market,
electricity_generated_mwh: elec_generated,
electricity_consumed_mwh: elec_consumed,
end_inventory_kg: inventory,
revenue_usd: revenue,
cost_usd: cost,
}
}
pub fn full_year_simulation(&self, initial_inventory_kg: f64) -> YearlySimResult {
let capacity_kg = self.storage_capacity_kg();
let mut inventory = initial_inventory_kg.clamp(0.0, capacity_kg);
let mut weekly_results = Vec::with_capacity(self.config.planning_weeks);
let mut total_h2_produced_kg = 0.0_f64;
let mut total_revenue = 0.0_f64;
let mut total_cost = 0.0_f64;
let mut total_elec_in = 0.0_f64;
let mut total_elec_out = 0.0_f64;
let mut sum_utilisation = 0.0_f64;
let mut unserved_kg = 0.0_f64;
for w in 0..self.config.planning_weeks {
let wd = self.optimize_weekly_dispatch(w, inventory);
let demand_kg = self
.config
.h2_demand_t_per_week
.get(w)
.copied()
.unwrap_or(0.0)
* KG_PER_TONNE;
let served = wd.h2_to_market_kg;
unserved_kg += (demand_kg - served).max(0.0);
total_h2_produced_kg += wd.h2_produced_kg;
total_revenue += wd.revenue_usd;
total_cost += wd.cost_usd;
total_elec_in += wd.electricity_consumed_mwh;
total_elec_out += wd.electricity_generated_mwh;
inventory = wd.end_inventory_kg.clamp(0.0, capacity_kg);
sum_utilisation += inventory / capacity_kg.max(f64::EPSILON);
weekly_results.push(WeeklyDispatch {
end_inventory_kg: inventory,
..wd
});
}
let n_weeks = self.config.planning_weeks as f64;
let round_trip_efficiency_pct = if total_elec_in > 0.0 {
(total_elec_out / total_elec_in) * 100.0
} else {
0.0
};
let storage_utilization_pct = if n_weeks > 0.0 {
(sum_utilisation / n_weeks) * 100.0
} else {
0.0
};
YearlySimResult {
weekly_results,
total_h2_produced_t: total_h2_produced_kg / KG_PER_TONNE,
total_revenue_usd: total_revenue,
total_cost_usd: total_cost,
net_profit_usd: total_revenue - total_cost,
round_trip_efficiency_pct,
storage_utilization_pct,
unserved_h2_demand_t: unserved_kg / KG_PER_TONNE,
}
}
pub fn size_storage_for_reliability(
&self,
weekly_demand: &[f64],
seasonal_surplus: &[f64],
target_reliability: f64,
) -> f64 {
let eff = self.electrolyzers.efficiency_kwh_per_kg;
let weeks = weekly_demand.len().min(seasonal_surplus.len());
let surplus_as_h2_kg: Vec<f64> = (0..weeks)
.map(|i| {
let surplus_mwh = seasonal_surplus[i] * HOURS_PER_WEEK as f64;
surplus_mwh * 1_000.0 / eff
})
.collect();
let deficits: Vec<f64> = (0..weeks)
.map(|i| {
let demand_kg = weekly_demand[i] * KG_PER_TONNE;
(demand_kg - surplus_as_h2_kg[i]).max(0.0)
})
.collect();
let window = 13_usize; let mut max_deficit = 0.0_f64;
for start in 0..weeks.saturating_sub(window).saturating_add(1) {
let window_sum: f64 = deficits[start..(start + window).min(weeks)].iter().sum();
max_deficit = max_deficit.max(window_sum);
}
let reliability = target_reliability.clamp(0.5, 0.9999);
let safety_factor = 1.0 + (1.0 - reliability) / reliability;
(max_deficit * safety_factor).max(0.0)
}
pub fn economic_assessment(
&self,
capex_per_kg: f64,
opex_fraction: f64,
lifetime_years: f64,
) -> H2EconomicsResult {
let capacity_kg = self.storage_capacity_kg();
let r = self.config.discount_rate;
let crf = if r > 0.0 {
r * (1.0 + r).powf(lifetime_years) / ((1.0 + r).powf(lifetime_years) - 1.0)
} else {
1.0 / lifetime_years.max(1.0)
};
let storage_capex = capacity_kg * capex_per_kg;
let electrolyzer_capex = self.electrolyzers.capacity_mw * 1_000.0 * 1_000.0; let fuel_cell_capex = self.fuel_cells.capacity_mw * 2_000.0 * 1_000.0; let total_capex = storage_capex + electrolyzer_capex + fuel_cell_capex;
let annual_capex_charge = total_capex * crf;
let annual_opex = total_capex * opex_fraction;
let annual_fixed_cost = annual_capex_charge + annual_opex;
let initial_inventory = capacity_kg * 0.50;
let sim = self.full_year_simulation(initial_inventory);
let annual_h2_kg = sim.total_h2_produced_t * KG_PER_TONNE;
let annual_revenue = sim.total_revenue_usd;
let annual_variable_cost = sim.total_cost_usd;
let total_annual_cost = annual_fixed_cost + annual_variable_cost;
let lcoh_per_kg = if annual_h2_kg > 0.0 {
total_annual_cost / annual_h2_kg
} else {
f64::INFINITY
};
let annual_net = annual_revenue - annual_fixed_cost - annual_variable_cost;
let pv_factor = if r > 0.0 {
(1.0 - (1.0 + r).powf(-lifetime_years)) / r
} else {
lifetime_years
};
let npv_usd = annual_net * pv_factor - total_capex;
let payback_years = if annual_net > 0.0 {
total_capex / annual_net
} else {
f64::INFINITY
};
let ci_vec = &self.config.grid_co2_intensity_g_per_kwh;
let (weighted_sum, total_consumed) = sim.weekly_results.iter().enumerate().fold(
(0.0_f64, 0.0_f64),
|(ws, tc), (i, week)| {
let ci = ci_vec.get(i).copied().unwrap_or(300.0);
let consumed = week.electricity_consumed_mwh;
(ws + ci * consumed, tc + consumed)
},
);
let eff_ci = weighted_sum / total_consumed.max(f64::EPSILON);
let carbon_intensity = self.carbon_intensity(eff_ci);
H2EconomicsResult {
lcoh_per_kg,
npv_usd,
payback_years,
annual_revenue_usd: annual_revenue,
is_green_hydrogen: carbon_intensity < GREEN_H2_THRESHOLD_G_CO2_PER_KG,
carbon_intensity_g_co2_per_kg: carbon_intensity,
}
}
pub fn carbon_intensity(&self, grid_co2_intensity_g_per_kwh: f64) -> f64 {
self.electrolyzers.efficiency_kwh_per_kg * grid_co2_intensity_g_per_kwh
}
pub fn is_green_hydrogen(&self, grid_co2_intensity_g_per_kwh: f64) -> bool {
self.carbon_intensity(grid_co2_intensity_g_per_kwh) < GREEN_H2_THRESHOLD_G_CO2_PER_KG
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_optimizer(capacity_mw: f64) -> SeasonalH2Optimizer {
let weeks = 52_usize;
let prices: Vec<f64> = (0..weeks)
.map(|w| 40.0 + 30.0 * ((2.0 * std::f64::consts::PI * w as f64 / 52.0).sin()))
.collect();
let surpluses: Vec<f64> = (0..weeks)
.map(|w| 300.0 + 200.0 * ((2.0 * std::f64::consts::PI * w as f64 / 52.0).sin()))
.collect();
let demands: Vec<f64> = vec![80.0; weeks];
SeasonalH2Optimizer {
storage: SeasonalStorageType::SaltCavern {
working_volume_m3: 500_000.0,
min_pressure_bar: 40.0,
max_pressure_bar: 200.0,
cushion_gas_pct: 0.25,
},
electrolyzers: ElectrolyzerFleet {
capacity_mw,
..ElectrolyzerFleet::default()
},
fuel_cells: FuelCellFleet {
capacity_mw: capacity_mw * 0.5,
..FuelCellFleet::default()
},
config: SeasonalStorageConfig {
planning_weeks: weeks,
electricity_price_seasonal: prices,
renewable_surplus_mw: surpluses,
h2_demand_t_per_week: demands,
..SeasonalStorageConfig::default()
},
}
}
#[test]
fn test_salt_cavern_capacity_scales_with_volume() {
let small = SeasonalH2Optimizer {
storage: SeasonalStorageType::SaltCavern {
working_volume_m3: 100_000.0,
min_pressure_bar: 40.0,
max_pressure_bar: 200.0,
cushion_gas_pct: 0.0,
},
electrolyzers: ElectrolyzerFleet::default(),
fuel_cells: FuelCellFleet::default(),
config: SeasonalStorageConfig::default(),
};
let large = SeasonalH2Optimizer {
storage: SeasonalStorageType::SaltCavern {
working_volume_m3: 1_000_000.0,
min_pressure_bar: 40.0,
max_pressure_bar: 200.0,
cushion_gas_pct: 0.0,
},
..small.clone()
};
let cap_small = small.storage_capacity_kg();
let cap_large = large.storage_capacity_kg();
assert!(
cap_large > cap_small,
"larger volume must give more storage"
);
let ratio = cap_large / cap_small;
assert!(
(ratio - 10.0).abs() < 0.01,
"capacity should scale 10× with 10× volume, got {ratio}"
);
}
#[test]
fn test_electrolyzer_produces_when_electricity_cheap() {
let opt = default_optimizer(100.0);
let prod = opt.electrolyzer_dispatch(200.0, 3.0, 20.0);
assert!(prod > 0.0, "should produce H₂ when electricity is cheap");
}
#[test]
fn test_electrolyzer_idle_when_electricity_expensive() {
let opt = default_optimizer(100.0);
let prod = opt.electrolyzer_dispatch(200.0, 3.0, 200.0);
assert_eq!(
prod, 0.0,
"should not produce H₂ when electricity is expensive"
);
}
#[test]
fn test_fuel_cell_generates_at_high_price() {
let opt = default_optimizer(100.0);
let gen = opt.fuel_cell_dispatch(10_000.0, 300.0, 3.0);
assert!(
gen > 0.0,
"fuel cell should generate at high electricity price"
);
}
#[test]
fn test_sizing_high_surplus_reduces_storage() {
let opt = default_optimizer(100.0);
let demand = vec![100.0_f64; 52];
let low_surplus = vec![10.0_f64; 52]; let high_surplus = vec![500.0_f64; 52];
let storage_low = opt.size_storage_for_reliability(&demand, &low_surplus, 0.95);
let storage_high = opt.size_storage_for_reliability(&demand, &high_surplus, 0.95);
assert!(
storage_low > storage_high,
"low surplus should require more storage: {storage_low} vs {storage_high}"
);
}
#[test]
fn test_year_simulation_non_negative_inventory() {
let opt = default_optimizer(200.0);
let sim = opt.full_year_simulation(500_000.0);
for wd in &sim.weekly_results {
assert!(
wd.end_inventory_kg >= 0.0,
"inventory went negative at week {}: {}",
wd.week,
wd.end_inventory_kg
);
}
}
#[test]
fn test_lcoh_within_realistic_range() {
let opt = default_optimizer(200.0);
let econ = opt.economic_assessment(10.0, 0.03, 20.0);
assert!(
econ.lcoh_per_kg.is_finite(),
"LCOH must be finite, got {}",
econ.lcoh_per_kg
);
assert!(
econ.lcoh_per_kg >= 0.5,
"LCOH suspiciously low: {}",
econ.lcoh_per_kg
);
assert!(
econ.lcoh_per_kg <= 500.0,
"LCOH suspiciously high: {}",
econ.lcoh_per_kg
);
}
#[test]
fn test_green_hydrogen_low_carbon_grid() {
let opt = default_optimizer(100.0);
assert!(
opt.is_green_hydrogen(10.0),
"should be green at 10 gCO₂/kWh grid intensity"
);
assert!(
!opt.is_green_hydrogen(30.0),
"should not be green at 30 gCO₂/kWh grid intensity"
);
}
#[test]
fn test_pipeline_buffer_capacity() {
let opt = SeasonalH2Optimizer {
storage: SeasonalStorageType::PipelineBuffer {
pipe_length_km: 100.0,
diameter_m: 0.5,
max_pressure_bar: 80.0,
},
electrolyzers: ElectrolyzerFleet::default(),
fuel_cells: FuelCellFleet::default(),
config: SeasonalStorageConfig::default(),
};
let cap = opt.storage_capacity_kg();
assert!(
cap > 0.0,
"pipeline buffer should have positive capacity: {cap}"
);
}
#[test]
fn test_underground_tank_capacity() {
let opt = SeasonalH2Optimizer {
storage: SeasonalStorageType::UndergroundTank {
capacity_t_h2: 1_000.0,
pressure_bar: 200.0,
},
electrolyzers: ElectrolyzerFleet::default(),
fuel_cells: FuelCellFleet::default(),
config: SeasonalStorageConfig::default(),
};
let cap = opt.storage_capacity_kg();
assert!(
(cap - 1_000_000.0).abs() < 1.0,
"underground tank capacity should equal 1 000 t × 1000 kg/t = 1 000 000 kg, got {cap}"
);
}
#[test]
fn test_carbon_intensity_zero_ci_is_green() {
let weeks = 52_usize;
let prices: Vec<f64> = (0..weeks)
.map(|w| 40.0 + 30.0 * ((2.0 * std::f64::consts::PI * w as f64 / 52.0).sin()))
.collect();
let surpluses: Vec<f64> = (0..weeks)
.map(|w| 300.0 + 200.0 * ((2.0 * std::f64::consts::PI * w as f64 / 52.0).sin()))
.collect();
let demands: Vec<f64> = vec![80.0; weeks];
let opt = SeasonalH2Optimizer {
storage: SeasonalStorageType::SaltCavern {
working_volume_m3: 500_000.0,
min_pressure_bar: 40.0,
max_pressure_bar: 200.0,
cushion_gas_pct: 0.25,
},
electrolyzers: ElectrolyzerFleet {
capacity_mw: 200.0,
..ElectrolyzerFleet::default()
},
fuel_cells: FuelCellFleet {
capacity_mw: 100.0,
..FuelCellFleet::default()
},
config: SeasonalStorageConfig {
planning_weeks: weeks,
electricity_price_seasonal: prices,
renewable_surplus_mw: surpluses,
h2_demand_t_per_week: demands,
grid_co2_intensity_g_per_kwh: vec![0.0; weeks],
..SeasonalStorageConfig::default()
},
};
let result = opt.economic_assessment(1000.0, 0.02, 20.0);
assert!(result.is_green_hydrogen, "zero CI must always be green");
assert!(
result.carbon_intensity_g_co2_per_kg < 1e-9,
"CI must be ~0, got {}",
result.carbon_intensity_g_co2_per_kg
);
}
#[test]
fn test_carbon_intensity_high_ci_not_green() {
let weeks = 52_usize;
let prices: Vec<f64> = (0..weeks)
.map(|w| 40.0 + 30.0 * ((2.0 * std::f64::consts::PI * w as f64 / 52.0).sin()))
.collect();
let surpluses: Vec<f64> = (0..weeks)
.map(|w| 300.0 + 200.0 * ((2.0 * std::f64::consts::PI * w as f64 / 52.0).sin()))
.collect();
let demands: Vec<f64> = vec![80.0; weeks];
let opt = SeasonalH2Optimizer {
storage: SeasonalStorageType::SaltCavern {
working_volume_m3: 500_000.0,
min_pressure_bar: 40.0,
max_pressure_bar: 200.0,
cushion_gas_pct: 0.25,
},
electrolyzers: ElectrolyzerFleet {
capacity_mw: 200.0,
..ElectrolyzerFleet::default()
},
fuel_cells: FuelCellFleet {
capacity_mw: 100.0,
..FuelCellFleet::default()
},
config: SeasonalStorageConfig {
planning_weeks: weeks,
electricity_price_seasonal: prices,
renewable_surplus_mw: surpluses,
h2_demand_t_per_week: demands,
grid_co2_intensity_g_per_kwh: vec![700.0; weeks],
..SeasonalStorageConfig::default()
},
};
let result = opt.economic_assessment(1000.0, 0.02, 20.0);
assert!(!result.is_green_hydrogen, "high CI must not be green");
assert!(
result.carbon_intensity_g_co2_per_kg > 1000.0,
"CI should be >>1000, got {}",
result.carbon_intensity_g_co2_per_kg
);
}
#[test]
fn test_carbon_intensity_default_config_is_finite() {
let opt = default_optimizer(200.0);
let result = opt.economic_assessment(1000.0, 0.02, 20.0);
assert!(
result.carbon_intensity_g_co2_per_kg.is_finite(),
"CI must be finite"
);
assert!(
result.carbon_intensity_g_co2_per_kg >= 0.0,
"CI must be non-negative, got {}",
result.carbon_intensity_g_co2_per_kg
);
}
}