Skip to content

Commit 3b7c6a2

Browse files
Oxygen output from PEM electrolyzer (#642)
* added oxygen output from electrolyzer * updated example 14 to calc LCOO --------- Co-authored-by: John Jasa <johnjasa11@gmail.com>
1 parent 910e872 commit 3b7c6a2

8 files changed

Lines changed: 169 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- Made generating an XDSM diagram from connections in a model optional and added documentation on model visualization. [PR 629](https://github.com/NatLabRockies/H2Integrate/pull/629)
3333
- Added a storage performance baseclass model `StoragePerformanceBase` and updated the other storage performance models to inherit it [PR 624](https://github.com/NatLabRockies/H2Integrate/pull/624)
3434
- Modified the calc tilt angle function for pysam solar to support latitudes in the southern hemisphere [PR 646](https://github.com/NatLabRockies/H2Integrate/pull/646)
35+
- Added oxygen production metrics and as outputs to `ECOElectrolyzerPerformanceModel` [PR 642](https://github.com/NatLabRockies/H2Integrate/pull/642)
3536

3637
## 0.7.1 [March 13, 2026]
3738

examples/14_wind_hydrogen_dispatch/inputs/plant_config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,7 @@ finance_parameters:
5959
commodity: hydrogen
6060
commodity_stream: h2_storage # use only dispatched hydrogen from h2_storage controller in finance calc
6161
technologies: [wind, electrolyzer, h2_storage]
62+
oxygen:
63+
commodity: oxygen
64+
commodity_stream: electrolyzer
65+
technologies: [wind, electrolyzer]

examples/test/test_all_examples.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,14 @@ def test_hydrogen_dispatch_example(subtests, temp_copy_of_example):
827827
)
828828
== 7.564000289456695
829829
)
830+
with subtests.test("Check LCOO"):
831+
assert (
832+
pytest.approx(
833+
model.prob.get_val("finance_subgroup_oxygen.LCOO", units="USD/kg")[0],
834+
rel=1e-5,
835+
)
836+
== 0.666523050
837+
)
830838

831839

832840
@pytest.mark.integration

h2integrate/converters/hydrogen/pem_electrolyzer.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ def setup(self):
9090
units="MW",
9191
desc="Size of the electrolyzer in MW",
9292
)
93+
94+
self.add_output("oxygen_out", val=0, shape=self.n_timesteps, units="kg/h")
95+
self.add_output("rated_oxygen_production", val=0, shape=1, units="kg/h")
96+
self.add_output("annual_oxygen_produced", val=0, shape=self.plant_life, units="kg/yr")
97+
9398
self.add_input("cluster_size", val=-1.0, units="MW")
9499
self.add_input("max_hydrogen_capacity", val=1000.0, units="kg/h")
95100
# TODO: add feedstock inputs and consumption outputs
@@ -188,8 +193,13 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
188193
"Capacity Factor [-]"
189194
].values
190195
outputs["annual_hydrogen_produced"] = H2_Results["Life: Annual H2 production [kg/year]"]
191-
192196
# TODO: replace above line w below
193197
# outputs["annual_hydrogen_produced"] = H2_Results["Performance Schedules"][
194198
# "Annual H2 Production [kg/year]"
195199
# ].values
200+
201+
outputs["oxygen_out"] = H2_Results["Oxygen Hourly Production [kg/hr]"]
202+
outputs["rated_oxygen_production"] = H2_Results["Rated BOL: O2 Production [kg/hr]"]
203+
outputs["annual_oxygen_produced"] = H2_Results["Performance Schedules"][
204+
"Annual O2 Production [kg/year]"
205+
]

h2integrate/converters/hydrogen/pem_model/PEM_H2_LT_electrolyzer_Clusters.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import rainflow
3030
from scipy import interpolate
3131

32+
from h2integrate.tools.constants import O2_MW
33+
3234

3335
np.set_printoptions(threshold=sys.maxsize)
3436

@@ -174,7 +176,7 @@ def run(self, input_external_power_kw):
174176
cluster_cycling = np.array(cluster_cycling)
175177

176178
# how much to reduce h2 by based on cycling status
177-
h2_multiplier = np.where(cluster_cycling > 0, startup_ratio, 1)
179+
production_multiplier = np.where(cluster_cycling > 0, startup_ratio, 1)
178180
# number of "stacks" on at a single time
179181
self.n_stacks_op = self.max_stacks * self.cluster_status
180182
# n_stacks_op is now either number of pem per cluster or 0 if cluster is off!
@@ -218,10 +220,15 @@ def run(self, input_external_power_kw):
218220
# h20_gal_used_system=self.water_supply(h2_kg_hr_system_init)
219221
p_consumed_max, rated_h2_hr = self.rated_h2_prod()
220222
# scales h2 production to account for start-up time if going from off->on
221-
h2_kg_hr_system = h2_kg_hr_system_init * h2_multiplier
223+
h2_kg_hr_system = h2_kg_hr_system_init * production_multiplier
222224

223225
h20_gal_used_system = self.water_supply(h2_kg_hr_system)
224226

227+
# Get oxygen production and rated used
228+
rated_o2_hr = self.rated_o2_prod()
229+
o2_kg_hr_system_init = self.o2_production_rate(stack_current, self.n_stacks_op)
230+
o2_kg_hr_system = o2_kg_hr_system_init * production_multiplier
231+
225232
pem_cf = np.sum(h2_kg_hr_system) / (rated_h2_hr * len(input_power_kw) * self.max_stacks)
226233
efficiency = self.system_efficiency(input_power_kw, stack_current) # Efficiency as %-HHV
227234

@@ -231,6 +238,7 @@ def run(self, input_external_power_kw):
231238
h2_results["hydrogen production no start-up time"] = h2_kg_hr_system_init
232239
h2_results["hydrogen_hourly_production"] = h2_kg_hr_system
233240
h2_results["water_hourly_usage_kg"] = h20_gal_used_system * 3.79
241+
h2_results["oxygen_hourly_production"] = o2_kg_hr_system
234242
h2_results["electrolyzer_total_efficiency_perc"] = efficiency
235243
h2_results["kwh_per_kgH2"] = input_power_kw / h2_kg_hr_system
236244
h2_results["Power Consumed [kWh]"] = system_power_consumed
@@ -246,13 +254,17 @@ def run(self, input_external_power_kw):
246254
p_consumed_max * self.max_stacks
247255
)
248256
h2_results_aggregates["Cluster Rated H2 Production [kg/hr]"] = rated_h2_hr * self.max_stacks
257+
h2_results_aggregates["Cluster Rated O2 Production [kg/hr]"] = rated_o2_hr * self.max_stacks
249258
h2_results_aggregates["gal H20 per kg H2"] = np.sum(h20_gal_used_system) / np.sum(
250259
h2_kg_hr_system
251260
)
252261
h2_results_aggregates["Stack Rated Efficiency [kWh/kg]"] = p_consumed_max / rated_h2_hr
253262
h2_results_aggregates["Cluster Rated H2 Production [kg/yr]"] = (
254263
rated_h2_hr * len(input_power_kw) * self.max_stacks
255264
)
265+
h2_results_aggregates["Cluster Rated O2 Production [kg/yr]"] = (
266+
rated_o2_hr * len(input_power_kw) * self.max_stacks
267+
)
256268
h2_results_aggregates["Operational Time / Simulation Time (ratio)"] = (
257269
self.percent_of_sim_operating
258270
) # added
@@ -367,13 +379,17 @@ def make_yearly_performance_dict(self, power_in_kW, V_deg, V_cell, I_op, grid_co
367379
cluster_cycling = [0, *list(np.diff(self.cluster_status))] # no delay at beginning of sim
368380
cluster_cycling = np.array(cluster_cycling)
369381
startup_ratio = 1 - (600 / 3600) # TODO: don't have this hard-coded
370-
h2_multiplier = np.where(cluster_cycling > 0, startup_ratio, 1)
382+
production_multiplier = np.where(cluster_cycling > 0, startup_ratio, 1)
371383

372384
_, rated_h2_pr_stack_BOL = self.rated_h2_prod()
385+
rated_o2_pr_stack_BOL = self.rated_o2_prod()
373386
rated_h2_pr_sim = rated_h2_pr_stack_BOL * self.max_stacks * sim_length
387+
rated_o2_pr_sim = rated_o2_pr_stack_BOL * self.max_stacks * sim_length
374388

375389
kg_h2_pr_sim = np.zeros(int(self.plant_life_years))
390+
kg_o2_pr_sim = np.zeros(int(self.plant_life_years))
376391
capfac_per_sim = np.zeros(int(self.plant_life_years))
392+
o2_capfac_per_sim = np.zeros(int(self.plant_life_years))
377393
d_sim = np.zeros(int(self.plant_life_years))
378394
power_pr_yr_kWh = np.zeros(int(self.plant_life_years))
379395
Vdeg0 = 0
@@ -396,20 +412,28 @@ def make_yearly_performance_dict(self, power_in_kW, V_deg, V_cell, I_op, grid_co
396412
power_in_kW, V_cell, V_deg_pr_sim
397413
)
398414
h2_kg_hr_system_init = self.h2_production_rate(stack_current, self.n_stacks_op)
415+
o2_kg_hr_system_init = self.o2_production_rate(stack_current, self.n_stacks_op)
399416
# total_sim_input_power = self.max_stacks*np.sum(power_in_kW)
400417
power_pr_yr_kWh[i] = self.max_stacks * np.sum(power_in_kW)
401418
else:
402419
h2_kg_hr_system_init = self.h2_production_rate(I_op, self.n_stacks_op)
403420
h2_kg_hr_system_init = h2_kg_hr_system_init * np.ones(len(power_in_kW))
421+
422+
o2_kg_hr_system_init = self.o2_production_rate(I_op, self.n_stacks_op)
423+
o2_kg_hr_system_init = o2_kg_hr_system_init * np.ones(len(power_in_kW))
424+
404425
annual_power_consumed_kWh = (
405426
self.max_stacks * I_op * (V_cell + V_deg_pr_sim) * self.N_cells / 1000
406427
)
407428
# total_sim_input_power = np.sum(annual_power_consumed_kWh)
408429
power_pr_yr_kWh[i] = np.sum(annual_power_consumed_kWh)
409430

410-
h2_kg_hr_system = h2_kg_hr_system_init * h2_multiplier
431+
h2_kg_hr_system = h2_kg_hr_system_init * production_multiplier
432+
o2_kg_hr_system = o2_kg_hr_system_init * production_multiplier
433+
kg_o2_pr_sim[i] = np.sum(o2_kg_hr_system)
411434
kg_h2_pr_sim[i] = np.sum(h2_kg_hr_system)
412435
capfac_per_sim[i] = np.sum(h2_kg_hr_system) / rated_h2_pr_sim
436+
o2_capfac_per_sim[i] = np.sum(o2_kg_hr_system) / rated_o2_pr_sim
413437
d_sim[i] = V_deg_pr_sim[sim_length - 1]
414438
Vdeg0 = V_deg_pr_sim[sim_length - 1]
415439
performance_by_year = {}
@@ -427,7 +451,8 @@ def make_yearly_performance_dict(self, power_in_kW, V_deg, V_cell, I_op, grid_co
427451
zip(year, self.eta_h2_hhv / (power_pr_yr_kWh / kg_h2_pr_sim))
428452
)
429453
performance_by_year["Annual Energy Used [kWh/year]"] = dict(zip(year, power_pr_yr_kWh))
430-
454+
performance_by_year["Annual O2 Production [kg/year]"] = dict(zip(year, kg_o2_pr_sim))
455+
performance_by_year["O2 Capacity Factor [-]"] = dict(zip(year, o2_capfac_per_sim))
431456
return performance_by_year
432457

433458
def reset_uptime_degradation_rate(self, uptime_hours_until_eol):
@@ -606,6 +631,22 @@ def rated_h2_prod(self):
606631
max_h2_stack_kg = self.h2_production_rate(I_max, 1)
607632
return P_consumed_stack_kw, max_h2_stack_kg
608633

634+
def rated_o2_prod(self):
635+
"""Calculates the oxygen production per stack at the rated current in kg
636+
637+
Returns:
638+
float: rated oxygen production in kg/dt
639+
"""
640+
i = self.output_dict["BOL Efficiency Curve Info"].index[
641+
self.output_dict["BOL Efficiency Curve Info"]["Power Sent [kWh]"]
642+
== self.stack_rating_kW
643+
]
644+
I_max = self.output_dict["BOL Efficiency Curve Info"]["Current"].iloc[i].values[0]
645+
# I_max = calc_current((self.stack_rating_kW,self.T_C),*self.curve_coeff)
646+
647+
max_o2_stack_kg = self.o2_production_rate(I_max, 1)
648+
return max_o2_stack_kg
649+
609650
def external_power_supply(self, input_external_power_kw):
610651
"""
611652
External power source (grid or REG) which will need to be stepped
@@ -892,6 +933,33 @@ def h2_production_rate(self, stack_current, n_stacks_op):
892933

893934
return h2_produced_kg_hr_system
894935

936+
def o2_production_rate(self, stack_current, n_stacks_op):
937+
"""Calculate the oxygen production rate of the cluster without warm-up losses.
938+
These equations are based on Faraday's law:
939+
940+
O2 [mol/sec] = eta_F*(n_cells*I)/(4F)
941+
942+
where eta_F is the faradaic efficiency. This can be found in Equation 12 of
943+
`Simple PEM water electrolyser model and experimental validation <http://dx.doi.org/10.1016/j.ijhydene.2011.09.027>`_.
944+
945+
Args:
946+
stack_current (float | np.array): current of the stack in A
947+
n_stacks_op (int | np.array[int]): number of stacks that are operating in the stack.
948+
949+
Returns:
950+
float | np.array: oxygen production profile of the cluster in kg/dt
951+
"""
952+
# Calculate the faradaic efficiency
953+
n_Tot = self.faradaic_efficiency(stack_current)
954+
# Faraday's law
955+
o2_production_rate = n_Tot * ((self.N_cells * stack_current) / (4 * self.F)) # mol/s
956+
# O2_MW is in g/mol
957+
# mol/s * g/mol = g/s
958+
o2_production_rate_g_s = o2_production_rate * O2_MW
959+
o2_produced_kg_dt = o2_production_rate_g_s * self.dt / 1000
960+
o2_produced_kg_dt_system = o2_produced_kg_dt * n_stacks_op
961+
return o2_produced_kg_dt_system
962+
895963
def water_supply(self, h2_kg_hr):
896964
"""
897965
Calculate water supply rate based system efficiency and H2 production
@@ -920,7 +988,7 @@ def run_grid_connected_workaround(self, power_input_signal, current_signal):
920988
cluster_cycling = np.array(cluster_cycling)
921989
power_per_stack = np.where(self.n_stacks_op > 0, power_input_signal / self.n_stacks_op, 0)
922990

923-
h2_multiplier = np.where(cluster_cycling > 0, startup_ratio, 1)
991+
production_multiplier = np.where(cluster_cycling > 0, startup_ratio, 1)
924992
self.n_stacks_op = self.max_stacks * self.cluster_status
925993

926994
V_init = self.cell_design(self.T_C, current_signal)
@@ -936,7 +1004,9 @@ def run_grid_connected_workaround(self, power_input_signal, current_signal):
9361004

9371005
h2_kg_hr_system_init = self.h2_production_rate(current_signal, self.n_stacks_op)
9381006
p_consumed_max, rated_h2_hr = self.rated_h2_prod()
939-
h2_kg_hr_system = h2_kg_hr_system_init * h2_multiplier # scales h2 production to account
1007+
h2_kg_hr_system = (
1008+
h2_kg_hr_system_init * production_multiplier
1009+
) # scales h2 production to account
9401010
# for start-up time if going from off->on
9411011
h20_gal_used_system = self.water_supply(h2_kg_hr_system)
9421012

h2integrate/converters/hydrogen/pem_model/run_h2_PEM.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ def clean_up_final_outputs(h2_tot, h2_ts):
1313
"Cluster Rated H2 Production [kg/yr]",
1414
"Stack Rated H2 Production [kg/hr]",
1515
"Stack Rated Power Consumed [kWh]",
16+
"Cluster Rated O2 Production [kg/hr]",
17+
"Cluster Rated O2 Production [kg/yr]",
1618
]
1719
)
1820
h2_ts.sum(axis=1)
@@ -21,6 +23,7 @@ def clean_up_final_outputs(h2_tot, h2_ts):
2123
"Power Consumed [kWh]",
2224
"hydrogen production no start-up time",
2325
"hydrogen_hourly_production",
26+
"oxygen_hourly_production",
2427
"water_hourly_usage_kg",
2528
]
2629

@@ -89,6 +92,7 @@ def run_h2_PEM(
8992
# time-series info (unchanged)
9093
energy_input_to_electrolyzer = h2_ts.loc["Input Power [kWh]"].sum()
9194
hydrogen_hourly_production = h2_ts.loc["hydrogen_hourly_production"].sum()
95+
oxygen_hourly_production = h2_ts.loc["oxygen_hourly_production"].sum()
9296
hourly_system_electrical_usage = h2_ts.loc["Power Consumed [kWh]"].sum()
9397
water_hourly_usage = h2_ts.loc["water_hourly_usage_kg"].sum()
9498
avg_eff_perc = eta_h2_hhv * hydrogen_hourly_production / hourly_system_electrical_usage
@@ -102,6 +106,7 @@ def run_h2_PEM(
102106

103107
# Beginning of Life (BOL) Rated Specs (attributes/system design)
104108
max_h2_pr_hr = h2_tot.loc["Cluster Rated H2 Production [kg/hr]"].sum()
109+
max_o2_pr_hr = h2_tot.loc["Cluster Rated O2 Production [kg/hr]"].sum()
105110
max_pwr_pr_hr = h2_tot.loc["Cluster Rated Power Consumed [kWh]"].sum()
106111
rated_kWh_pr_kg = h2_tot.loc["Stack Rated Efficiency [kWh/kg]"].mean()
107112
elec_rated_h2_capacity_kgpy = h2_tot.loc["Cluster Rated H2 Production [kg/yr]"].sum()
@@ -110,6 +115,7 @@ def run_h2_PEM(
110115
atrribute_desc = [
111116
"Efficiency [kWh/kg]",
112117
"H2 Production [kg/hr]",
118+
"O2 Production [kg/hr]",
113119
"Power Consumed [kWh]",
114120
"Annual H2 Production [kg/year]",
115121
"Gal H2O per kg-H2",
@@ -118,6 +124,7 @@ def run_h2_PEM(
118124
attributes = [
119125
rated_kWh_pr_kg,
120126
max_h2_pr_hr,
127+
max_o2_pr_hr,
121128
max_pwr_pr_hr,
122129
elec_rated_h2_capacity_kgpy,
123130
gal_h20_pr_kg_h2,
@@ -188,6 +195,7 @@ def run_h2_PEM(
188195
H2_Results.update(dict(zip(life_desc, life_vals)))
189196
H2_Results.update({"Performance Schedules": pd.DataFrame(annual_avg_performance)})
190197
H2_Results.update({"Hydrogen Hourly Production [kg/hr]": hydrogen_hourly_production})
198+
H2_Results.update({"Oxygen Hourly Production [kg/hr]": oxygen_hourly_production})
191199
H2_Results.update({"Water Hourly Consumption [kg/hr]": water_hourly_usage})
192200

193201
if not debug_mode:

h2integrate/converters/hydrogen/test/test_pem_electrolyzer_performance.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,62 @@ def test_electrolyzer_outputs(tech_config, plant_config, subtests):
129129
assert prob.get_val("comp.operational_life", units="yr") == plant_life
130130
with subtests.test("replacement_schedule value"):
131131
assert np.any(prob.get_val("comp.replacement_schedule", units="unitless") == 0)
132+
133+
134+
@pytest.mark.regression
135+
def test_electrolyzer_results(tech_config, plant_config, subtests):
136+
prob = om.Problem()
137+
comp = ECOElectrolyzerPerformanceModel(
138+
plant_config=plant_config, tech_config=tech_config, driver_config={}
139+
)
140+
prob.model.add_subsystem("comp", comp, promotes=["*"])
141+
prob.setup()
142+
power_profile = np.full(8760, 32.0)
143+
prob.set_val("comp.electricity_in", power_profile, units="MW")
144+
145+
prob.run_model()
146+
147+
with subtests.test("Total hydrogen produced"):
148+
assert (
149+
pytest.approx(5297814.89908964, rel=1e-6)
150+
== prob.get_val("comp.hydrogen_out", units="kg/h").sum()
151+
)
152+
153+
with subtests.test("Total oxygen produced"):
154+
assert (
155+
pytest.approx(42045916.90741967, rel=1e-6)
156+
== prob.get_val("comp.oxygen_out", units="kg/h").sum()
157+
)
158+
159+
with subtests.test("Year 0 capacity factor"):
160+
assert (
161+
pytest.approx(77.10460139, rel=1e-6)
162+
== prob.get_val("comp.capacity_factor", units="percent")[0]
163+
)
164+
165+
with subtests.test("Rated H2 production"):
166+
assert pytest.approx(784.3544735823235, rel=1e-6) == prob.get_val(
167+
"comp.rated_hydrogen_production", units="kg/h"
168+
)
169+
170+
with subtests.test("Rated O2 production"):
171+
assert pytest.approx(6225.00099576, rel=1e-6) == prob.get_val(
172+
"comp.rated_oxygen_production", units="kg/h"
173+
)
174+
175+
with subtests.test("H2: CF*Rated = Annual"):
176+
np.testing.assert_allclose(
177+
prob.get_val("comp.rated_hydrogen_production", units="kg/h")
178+
* prob.get_val("comp.capacity_factor", units="unitless")
179+
* 8760,
180+
prob.get_val("comp.annual_hydrogen_produced", units="kg/yr"),
181+
rtol=1e-6,
182+
)
183+
with subtests.test("O2: CF*Rated = Annual"):
184+
np.testing.assert_allclose(
185+
prob.get_val("comp.rated_oxygen_production", units="kg/h")
186+
* prob.get_val("comp.capacity_factor", units="unitless")
187+
* 8760,
188+
prob.get_val("comp.annual_oxygen_produced", units="kg/yr"),
189+
rtol=1e-6,
190+
)

h2integrate/postprocess/test/test_sql_timeseries_to_csv.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_save_csv_all_results(subtests, configuration, run_example_02_sql_fpath)
5757
res = save_case_timeseries_as_csv(run_example_02_sql_fpath, save_to_file=True)
5858

5959
with subtests.test("Check number of columns"):
60-
assert len(res.columns.to_list()) == 40
60+
assert len(res.columns.to_list()) == 41
6161

6262
with subtests.test("Check number of rows"):
6363
assert len(res) == 8760

0 commit comments

Comments
 (0)