diff --git a/CHANGELOG.md b/CHANGELOG.md index 01151283a..887c4b0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Added dynamic operating constraints (turndown, ramping, warm/cold start delays) to `AmmoniaSynLoopPerformanceModel` and split `AmmoniaSynLoopCostModel` into its own module. [PR 770](https://github.com/NatLabRockies/H2Integrate/pull/770) - Speed up the slowest tests in the suite by swapping the Floris wind model for `PYSAMWindPlantPerformanceModel` in examples 01 (`01_onshore_steel_mn`) and 02 (`02_texas_ammonia`), updating the affected `test_steel_example`/`test_simple_ammonia_example` expected values, fixing a pre-existing `cases.sql` cache-path bug and module-scoping the fixtures in `h2integrate/postprocess/test/test_sql_timeseries_to_csv.py` so the example only runs once for all four tests. [PR 782](https://github.com/NatLabRockies/H2Integrate/pull/782) - Exposed `n_timesteps`, `dt`, `plant_life`, and `fraction_of_year_simulated` as attributes on `CostModelBaseClass` (matching `PerformanceModelBaseClass`) and updated all cost and performance model subclasses across `h2integrate/` to use these attributes instead of reading them from `plant_config`, removing redundant boilerplate from individual components. [PR 783](https://github.com/NatLabRockies/H2Integrate/pull/783) +- Added two new fuel cell models: `PEMH2FuelCellPerformanceModel` and `PEMH2FuelCellCostModel` to model a PEM hydrogen fuel cell and `SONGFuelCellPerformanceModel` and `SONGFuelCellCostModel` to model a natural gas solid oxide fuel cell [PR 794](https://github.com/NatLabRockies/H2Integrate/pull/794) ## 0.8 [April 15, 2026] diff --git a/docs/_toc.yml b/docs/_toc.yml index 5a47b5529..2e2d92cbc 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -49,6 +49,8 @@ parts: - file: technology_models/iron_dri.md - file: technology_models/steel_eaf.md - file: technology_models/steel_eaf_cmu.md + - file: technology_models/PEM_fuel_cell.md + - file: technology_models/SO_NG_fuel_cell.md - caption: Storage Models chapters: - file: storage/storage_models_index.md diff --git a/docs/technology_models/PEM_h2_fuel_cell.md b/docs/technology_models/PEM_h2_fuel_cell.md new file mode 100644 index 000000000..8f182e2c4 --- /dev/null +++ b/docs/technology_models/PEM_h2_fuel_cell.md @@ -0,0 +1,49 @@ +# PEM Hydrogen Fuel Cell Model + +The PEM hydrogen fuel cell performance model implemented in H2Integrate is an electrochemical model that simulates the conversion of hydrogen and oxygen into electricity and water. The model uses a polynomial fit of an I-V (current-voltage) curve to determine the operating point of each cell based on the input `electricity_set_point`, then computes `hydrogen_consumed`, `oxygen_consumed`, `water_out`, and `electricity_out` as the outputs of the system for each timestep. + +The model is sized by `system_capacity_kw` and `n_stacks`. The number of cells per stack is calculated from the stack size, a fixed cell active area (400 cm²)([1](https://www.energy.gov/sites/prod/files/2018/02/f49/fcto_battelle_mfg_cost_analysis_1%20_to_25kw_pp_chp_fc_systems_jan2017_0.pdf)), and a maximum cell power density. Hydrogen and oxygen availability is checked against the demand at each timestep. If the available oxygen or hydrogen is insuffucient for the requested power, the operating current is reduced to match the lowest available feedstock. Electricity output is clipped to the system capacity. + +There are no non-linear operational considerations in this model such as warm-up delays, degraded performance over operational life, voltage recalculation after current adjustment for limited feedstock supply, or thermal dynamics beyond a constant stack temperature input. + +The PEM hydrogen fuel cell cost model is implemented to use cost values in dollars per kilowatt (or per kilowatt per year), decomposed into capital cost components for the stack, hydrogen supply, air supply, cooling, controls and instrumentation, electrical, assembly, and additional labor. + +```{note} +The I-V curve is currently internally defined in the model and not adjustable. +``` + +## Performance Model + +```{eval-rst} +.. autoclass:: h2integrate.converters.hydrogen.PEM_h2_fuel_cell.PEMH2FuelCellPerformanceConfig + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` + +```{eval-rst} +.. autoclass:: h2integrate.converters.hydrogen.PEM_h2_fuel_cell.PEMH2FuelCellPerformanceModel + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` + +## Cost Model + +```{eval-rst} +.. autoclass:: h2integrate.converters.hydrogen.PEM_h2_fuel_cell.PEMH2FuelCellCostConfig + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` + +```{eval-rst} +.. autoclass:: h2integrate.converters.hydrogen.PEM_h2_fuel_cell.PEMH2FuelCellCostModel + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` diff --git a/docs/technology_models/SO_NG_fuel_cell.md b/docs/technology_models/SO_NG_fuel_cell.md new file mode 100644 index 000000000..c2709d4bc --- /dev/null +++ b/docs/technology_models/SO_NG_fuel_cell.md @@ -0,0 +1,49 @@ +# Solid Oxide Natural Gas Fuel Cell Model + +The solid oxide natural gas (SO NG) fuel cell performance model implemented in H2Integrate is an electrochemical model that simulates the conversion of natural gas (assumed to be methane in the chemical reaction equations), steam, and oxygen into electricity, water, and carbon dioxide. The model uses a polynomial fit of an I-V (current-voltage) curve to determine the operating point of each cell based on the input `electricity_set_point`, then computes `natural_gas_consumed`, `oxygen_consumed`, `water_out`, `carbon_dioxide_out`, and `electricity_out` as the outputs of the system for each timestep. + +The model is sized by `system_capacity_kw` and `n_stacks`. The number of cells per stack is calculated from the stack size, a fixed cell active area (400 cm²), and a maximum cell power density. Natural gas and oxygen consumption are computed from the cell current via Faraday's law assuming complete electrochemical oxidation of methane. The model uses the methane-reforming equation rather than a direct electrochemical methan-combusting reaction. This assumes that the methane is broken down in a steam-reforming process before the feedstock enteres the fuel cell ([1](https://www.sciencedirect.com/science/article/pii/S0360319906005726)). Electricity output is clipped to the system capacity. + +There are no non-linear operational considerations in this model such as warm-up delays, degraded performance over operational life, or thermal dynamics beyond a constant stack temperature input. + +The SO NG fuel cell cost model is implemented to use cost values in dollars per kilowatt (or per kilowatt per year), decomposed into capital cost components for the stack, fuel supply, air supply, cooling, controls and instrumentation, electrical, assembly, and additional labor. + +```{note} +The I-V curve is currently internally defined in the model and not adjustable. +``` + +## Performance Model + +```{eval-rst} +.. autoclass:: h2integrate.converters.natural_gas.SO_NG_fuel_cell.SONGFuelCellPerformanceConfig + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` + +```{eval-rst} +.. autoclass:: h2integrate.converters.natural_gas.SO_NG_fuel_cell.SONGFuelCellPerformanceModel + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` + +## Cost Model + +```{eval-rst} +.. autoclass:: h2integrate.converters.natural_gas.SO_NG_fuel_cell.SONGFuelCellCostConfig + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` + +```{eval-rst} +.. autoclass:: h2integrate.converters.natural_gas.SO_NG_fuel_cell.SONGFuelCellCostModel + :members: + :undoc-members: + :show-inheritance: + :no-index: +``` diff --git a/examples/37_pem_fc/37_pem_fc.yaml b/examples/37_pem_fc/37_pem_fc.yaml new file mode 100644 index 000000000..a2e06f587 --- /dev/null +++ b/examples/37_pem_fc/37_pem_fc.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This reference contains a hydrogen fuel cell meeting an electricity demand +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/examples/37_pem_fc/driver_config.yaml b/examples/37_pem_fc/driver_config.yaml new file mode 100644 index 000000000..e6b823fec --- /dev/null +++ b/examples/37_pem_fc/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a hybrid plant to match the first example in H2Integrate +general: + folder_output: outputs diff --git a/examples/37_pem_fc/plant_config.yaml b/examples/37_pem_fc/plant_config.yaml new file mode 100644 index 000000000..1237e0198 --- /dev/null +++ b/examples/37_pem_fc/plant_config.yaml @@ -0,0 +1,60 @@ +name: plant_config +description: This plant is located in MN, USA... +sites: + site: + latitude: 32.31714 + longitude: -100.18 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + # connect feedstocks to fuel cell + - [h2_feedstock, PEM_fuel_cell, hydrogen, pipe] + - [o2_feedstock, PEM_fuel_cell, oxygen, pipe] + # connect fuel cell to demand component + - [PEM_fuel_cell, electricity_load_demand, electricity, cable] +plant: + plant_life: 2 +finance_parameters: + finance_groups: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for real analysis + discount_rate: 0.06 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 0.724 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.025 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.2574 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.0 # average combined state and local sales tax https://taxfoundation.org/location/texas/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 7 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 + finance_subgroups: + h2: + commodity: hydrogen + commodity_stream: h2_feedstock + technologies: [h2_feedstock] + o2: + commodity: oxygen + commodity_stream: o2_feedstock + technologies: [o2_feedstock] + electricity: + commodity: electricity + commodity_stream: PEM_fuel_cell + technologies: + - PEM_fuel_cell + - h2_feedstock + - o2_feedstock diff --git a/examples/37_pem_fc/run_pem_fuel_cell.py b/examples/37_pem_fc/run_pem_fuel_cell.py new file mode 100644 index 000000000..daa9ce6f3 --- /dev/null +++ b/examples/37_pem_fc/run_pem_fuel_cell.py @@ -0,0 +1,18 @@ +import numpy as np + +from h2integrate import H2IntegrateModel + + +# Create a H2Integrate model +model = H2IntegrateModel("37_pem_fc.yaml") + +# Setup the model +model.setup() + +# Set fuel cell demand profile +demand_profile = np.ones(8760) * 1000 +model.prob.set_val("PEM_fuel_cell.electricity_set_point", demand_profile, units="kW") + +# Run model +model.run() +model.post_process() diff --git a/examples/37_pem_fc/tech_config.yaml b/examples/37_pem_fc/tech_config.yaml new file mode 100644 index 000000000..955059111 --- /dev/null +++ b/examples/37_pem_fc/tech_config.yaml @@ -0,0 +1,65 @@ +name: technology_config +description: This hybrid plant produces ammonia +technologies: + PEM_fuel_cell: + performance_model: + model: PEMH2FuelCellPerformanceModel + cost_model: + model: PEMH2FuelCellCostModel + model_inputs: + shared_parameters: + system_capacity_kw: 1500 + performance_parameters: + n_stacks: 60 + stack_temperature_K: 278 # Not used yet + cost_parameters: + capex_stack_per_kw: 800 + capex_hydrogen_supply_per_kw: 140.67 + capex_air_supply_per_kw: 138.79 + capex_cooling_per_kw: 64.14 + capex_controls_instrumentation_per_kw: 106.52 + capex_electrical_per_kw: 447.83 + capex_assembly_per_kw: 63.51 + capex_additional_labor_per_kw: 137.96 + fixed_opex_per_kw_per_year: 31 + cost_year: 2026 + electricity_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 1000 + h2_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + performance_parameters: + rated_capacity: 50.0 # kg/h of hydrogen + cost_parameters: + cost_year: 2022 + price: 5.0 + annual_cost: 0. + start_up_cost: 0.0 + o2_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: oxygen + commodity_rate_units: kg/h + performance_parameters: + rated_capacity: 400.0 # kg/h of oxygen + cost_parameters: + cost_year: 2022 + price: 0.5 # $5/kg of oxygen. price is extremely variable + annual_cost: 0. + start_up_cost: 0.0 diff --git a/examples/38_song_fc/38_song_fc.yaml b/examples/38_song_fc/38_song_fc.yaml new file mode 100644 index 000000000..a2e06f587 --- /dev/null +++ b/examples/38_song_fc/38_song_fc.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This reference contains a hydrogen fuel cell meeting an electricity demand +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/examples/38_song_fc/driver_config.yaml b/examples/38_song_fc/driver_config.yaml new file mode 100644 index 000000000..e6b823fec --- /dev/null +++ b/examples/38_song_fc/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a hybrid plant to match the first example in H2Integrate +general: + folder_output: outputs diff --git a/examples/38_song_fc/plant_config.yaml b/examples/38_song_fc/plant_config.yaml new file mode 100644 index 000000000..45bf0db2a --- /dev/null +++ b/examples/38_song_fc/plant_config.yaml @@ -0,0 +1,60 @@ +name: plant_config +description: This plant is located in MN, USA... +sites: + site: + latitude: 32.31714 + longitude: -100.18 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + # connect feedstocks to fuel cell + - [ng_feedstock, SONG_fuel_cell, natural_gas, pipe] + - [o2_feedstock, SONG_fuel_cell, oxygen, pipe] + # connect fuel cell to demand component + - [SONG_fuel_cell, electricity_load_demand, electricity, cable] +plant: + plant_life: 2 +finance_parameters: + finance_groups: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for real analysis + discount_rate: 0.06 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 0.724 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.025 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.2574 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.0 # average combined state and local sales tax https://taxfoundation.org/location/texas/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + depr_period: 7 # 5 years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + refurb: [0.] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 + finance_subgroups: + # h2: + # commodity: hydrogen + # commodity_stream: h2_feedstock + # technologies: [h2_feedstock] + # o2: + # commodity: oxygen + # commodity_stream: o2_feedstock + # technologies: [o2_feedstock] + electricity: + commodity: electricity + commodity_stream: SONG_fuel_cell + technologies: + - SONG_fuel_cell + - ng_feedstock + - o2_feedstock diff --git a/examples/38_song_fc/run_song_fuel_cell.py b/examples/38_song_fc/run_song_fuel_cell.py new file mode 100644 index 000000000..38ee3bb26 --- /dev/null +++ b/examples/38_song_fc/run_song_fuel_cell.py @@ -0,0 +1,18 @@ +import numpy as np + +from h2integrate import H2IntegrateModel + + +# Create a H2Integrate model +model = H2IntegrateModel("38_song_fc.yaml") + +# Setup the model +model.setup() + +# Set fuel cell demand profile +demand_profile = np.ones(8760) * 1000 +model.prob.set_val("SONG_fuel_cell.electricity_set_point", demand_profile, units="kW") + +# Run model +model.run() +model.post_process() diff --git a/examples/38_song_fc/tech_config.yaml b/examples/38_song_fc/tech_config.yaml new file mode 100644 index 000000000..fa4a67e0a --- /dev/null +++ b/examples/38_song_fc/tech_config.yaml @@ -0,0 +1,65 @@ +name: technology_config +description: This hybrid plant produces ammonia +technologies: + SONG_fuel_cell: + performance_model: + model: SONGFuelCellPerformanceModel + cost_model: + model: SONGFuelCellCostModel + model_inputs: + shared_parameters: + system_capacity_kw: 1500 + performance_parameters: + n_stacks: 60 + stack_temperature_K: 1073 # Not used yet + cost_parameters: + capex_stack_per_kw: 800 + capex_fuel_supply_per_kw: 140.67 + capex_air_supply_per_kw: 138.79 + capex_cooling_per_kw: 64.14 + capex_controls_instrumentation_per_kw: 106.52 + capex_electrical_per_kw: 447.83 + capex_assembly_per_kw: 63.51 + capex_additional_labor_per_kw: 137.96 + fixed_opex_per_kw_per_year: 31 + cost_year: 2026 + electricity_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 1000 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 20.0 # MMBtu/h of natural gas + cost_parameters: + cost_year: 2022 + price: 0.1 + annual_cost: 0. + start_up_cost: 0.0 + o2_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: oxygen + commodity_rate_units: kg/h + performance_parameters: + rated_capacity: 400.0 # kg/h of oxygen + cost_parameters: + cost_year: 2022 + price: 0.5 # $0.5/kg of oxygen. price is extremely variable + annual_cost: 0. + start_up_cost: 0.0 diff --git a/h2integrate/converters/hydrogen/PEM_h2_fuel_cell.py b/h2integrate/converters/hydrogen/PEM_h2_fuel_cell.py new file mode 100644 index 000000000..f31887497 --- /dev/null +++ b/h2integrate/converters/hydrogen/PEM_h2_fuel_cell.py @@ -0,0 +1,368 @@ +import numpy as np +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig, merge_shared_inputs +from h2integrate.core.validators import gte_zero +from h2integrate.tools.constants import H_MW, O2_MW, faraday +from h2integrate.core.model_baseclasses import ( + CostModelBaseClass, + CostModelBaseConfig, + PerformanceModelBaseClass, +) + + +@define(kw_only=True) +class PEMH2FuelCellPerformanceConfig(BaseConfig): + """Configuration class for the hydrogen fuel cell performance model. + + Attributes: + system_capacity_kw (float): The capacity of the fuel cell system in kilowatts (kW). + n_stacks (int): The number of stacks in the fuel cell system. + stack_temperature_K (float): The operating temperature of the fuel cell stack in Kelvin (K). + """ + + # TODO: how to size the fuel cell? N_cells + N_stacks? + # How does N_cells translate to electricity rating? + + system_capacity_kw: float = field(validator=gte_zero) + n_stacks: int + stack_temperature_K: float + # min_system_power_fraction_kw: float + # fuel_cell_efficiency_hhv: float = field(validator=range_val(0, 1)) + + +def calc_current(system_power_reference, cell_area, n_cells, n_stacks): + """_summary_ + + Args: + system_power_reference (np.ndarray): power demanded of the entire system in W + cell_area (float): cell active area in cm^2 + n_cells (int): number of cells per stack + n_stacks (int): number of stacks in the system + + Returns: + tuple(np.ndarray, np.poly1d): stack current and + function to convert from current density to voltage + """ + # Calculates the current and voltage from IV curve based on power reference + J_curve = np.array([0.0356, 0.05413333, 0.0796, 0.11366667, 0.244, 0.454]) # in A/cm^2 + voltage_curve = np.array([0.987, 0.936, 0.884, 0.838, 0.786, 0.736]) # in V + power_curve = ( + np.array([35.16666667, 50.53333333, 70.33333333, 95.46666667, 191.66666667, 334.33333333]) + / 1e3 + ) # in W/cm^2 + + # function to calculate voltage from current density + V_coefs = np.polyfit(J_curve, voltage_curve, 5) + V_J_curve = np.poly1d(V_coefs) + + # function to calculate current density from power + # I_curve = J_curve * cell_area + # P_curve = I_curve * (n_cells * voltage_curve) + stack_P_curve = power_curve * cell_area * n_cells + J_coefs = np.polyfit(stack_P_curve, J_curve, 5) + J_P_curve = np.poly1d(J_coefs) + + # Calculate power per stack and power density + power_per_stack = system_power_reference / n_stacks + + stack_current_density = J_P_curve(power_per_stack) # convert to kW for the curve + stack_current = stack_current_density * cell_area * n_cells # in A + stack_current = np.clip(stack_current, a_min=0.0, a_max=None) # clip negative values + + return stack_current, V_J_curve + + +class PEMH2FuelCellPerformanceModel(PerformanceModelBaseClass): + """ + Performance model for a PEM hydrogen fuel cell. + + The model simulates electrochemical conversion of hydrogen and oxygen into electricity + and water. It calculates: + - hydrogen and oxygen consumption based on electrochemical reactions + - water production as a byproduct + - electricity output based on system capacity and operational conditions + + Inputs: + - hydrogen_in: mass flow rate of hydrogen (kg/h) + - oxygen_in: mass flow rate of oxygen (kg/h) + - stack_temperature: operating temperature of the fuel cell stack (K) + - system_capacity: rated capacity of the fuel cell system (kW) + + Outputs: + - hydrogen_consumed: hydrogen consumption rate (kg/h) + - oxygen_consumed: oxygen consumption rate (kg/h) + - water_out: water production rate (kg/h) + - electricity_out: electricity output (kW) + """ + + _time_step_bounds = ( + 3600, + 3600, + ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" + + def initialize(self): + super().initialize() + self.commodity = "electricity" + self.commodity_rate_units = "kW" + self.commodity_amount_units = "kW*h" + + def setup(self): + super().setup() + + self.config = PEMH2FuelCellPerformanceConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance"), + additional_cls_name=self.__class__.__name__, + ) + + self.add_input( + "hydrogen_in", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + ) + + self.add_input( + "oxygen_in", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + ) + + self.add_input( + "stack_temperature", + val=self.config.stack_temperature_K, + units="K", + desc="Operating temperature of the stack", + ) + + # self.add_input( + # "fuel_cell_efficiency", + # val=self.config.fuel_cell_efficiency_hhv, + # units=None, + # desc="HHV efficiency of the fuel cell (0 <= efficiency <= 1)", + # ) + + # Add rated capacity as an input with config value as default + self.add_input( + "system_capacity", + val=self.config.system_capacity_kw, + units="kW", + desc="Capacity of the h2 fuel cell system", + ) + + self.add_output( + "hydrogen_consumed", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + desc="Mass flow rate of hydrogen consumed by the fuel cell", + ) + + self.add_output( + "oxygen_consumed", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + desc="Mass flow rate of oxygen consumed by the fuel cell", + ) + + self.add_output( + "water_out", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + desc="Mass flow rate of water produced by the fuel cell", + ) + + # Default the electricity command value input as the rated capacity + self.add_input( + f"{self.commodity}_command_value", + val=self.config.system_capacity_kw, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc="Electricity command value for PEM fuel cell", + ) + + def compute(self, inputs, outputs): + """ + Compute electricity output from the fuel cell based on hydrogen and oxygen input. + + Uses I-V curve characteristics to calculate fuel cell current and voltage, + then computes hydrogen consumed, oxygen consumed, and water generated for + each timestep based on electrochemical reactions. + + Args: + inputs: OpenMDAO inputs object containing hydrogen_in, oxygen_in, + stack_temperature, electricity_command_value, and system_capacity. + outputs: OpenMDAO outputs object for electricity_out, hydrogen_consumed, + oxygen_consumed, water_out, and various electricity production quantities. + """ + + # Set calculation constants: + M_H2 = H_MW * 2 / 1000 # Molar mass of H2 in kg/mol + M_O2 = O2_MW / 1000 # Molar mass of O2 in kg/mol + M_H2O = M_H2 + M_O2 / 2 # Molar mass of H2O in kg/mol + + # Sizing the cells + max_cell_power_density = 0.000334 # in W/cm^2 + stack_size = inputs["system_capacity"][0] / self.config.n_stacks + cell_active_area = 400 # [cm^2] from Battelle (https://www.energy.gov/sites/prod/files/2018/02/f49/fcto_battelle_mfg_cost_analysis_1%20_to_25kw_pp_chp_fc_systems_jan2017_0.pdf) + n_cells = round(stack_size / (cell_active_area * max_cell_power_density)) + # Recalculate the rated power production based on final fuel cell sizing + rated_power_production = ( + max_cell_power_density * n_cells * cell_active_area * self.config.n_stacks + ) + + ################## Model Calculations ################## + # 1. Receive power setpoint into fuel cell + power_reference = np.clip( + inputs[f"{self.commodity}_command_value"], a_min=0.0, a_max=rated_power_production + ) + + # 2. Find stack current from power reference + commanded_I_stack, V_J_curve = calc_current( + power_reference * 1e3, cell_active_area, n_cells, self.config.n_stacks + ) + + # 3. Find available hydrogen and oxygen for each timestep + h2_in_kg_per_s = inputs["hydrogen_in"] / 3600 # convert from kg/h to kg/s + o2_in_kg_per_s = inputs["oxygen_in"] / 3600 # convert from kg/h to kg/s + + # convert from kg/s to A per stack - current that feedstocks in can support + I_stack_from_h2 = (h2_in_kg_per_s * 2 * faraday) / (M_H2 * self.config.n_stacks) + I_stack_from_o2 = (o2_in_kg_per_s * 4 * faraday) / (M_O2 * self.config.n_stacks) + + # 4. Take minimum current from power reference, hydrogen available, and oxygen available + I_stack = np.minimum(commanded_I_stack, np.minimum(I_stack_from_h2, I_stack_from_o2)) + + # 5. Calculate current density and voltage from I-V curve + J_cell = I_stack / (cell_active_area * n_cells) # in A/cm^2 + I_cell = J_cell * cell_active_area # in A + V_cell = V_J_curve(J_cell) # in V + + # 6. Calculate power output from current and voltage + power_out = V_cell * I_cell * n_cells * self.config.n_stacks / 1e3 # in kW + + # 7. Calculate hydrogen and oxygen consumed and water produced + # based on electrochemical reactions + h2_consumed = ((I_cell * M_H2) / (2.0 * faraday)) * ( + self.dt * self.config.n_stacks * n_cells + ) # kg/time step + o2_consumed = ((I_cell * M_O2) / (4.0 * faraday)) * ( + self.dt * self.config.n_stacks * n_cells + ) # kg/time step + h2o_generated = ((I_cell * M_H2O) / (2 * faraday)) * ( + self.dt * self.config.n_stacks * n_cells + ) # kg/time step + + # Set Outputs + # clip the electricity output to the system capacity + outputs["rated_electricity_production"] = rated_power_production + + outputs["electricity_out"] = np.minimum(power_out, rated_power_production) + outputs["total_electricity_produced"] = np.sum(outputs["electricity_out"]) * ( + self.dt / 3600 + ) + outputs["annual_electricity_produced"] = outputs["total_electricity_produced"] * ( + 1 / self.fraction_of_year_simulated + ) + outputs["capacity_factor"] = outputs["total_electricity_produced"] / ( + rated_power_production * self.n_timesteps * (self.dt / 3600) + ) + outputs["hydrogen_consumed"] = h2_consumed + outputs["oxygen_consumed"] = o2_consumed + outputs["water_out"] = h2o_generated + + # TODO: implement a hydrogen and oxygen conversion efficiency based on stack + # temperature and other factors + + +@define(kw_only=True) +class PEMH2FuelCellCostConfig(CostModelBaseConfig): + """Configuration class for the hydrogen fuel cell cost model. + + Fields include `system_capacity_kw`, `capex_stack_per_kw`, `capex_hydrogen_supply_per_kw`, + `capex_air_supply_per_kw`, `capex_cooling_per_kw`, `capex_controls_instrumentation_per_kw`, + `capex_electrical_per_kw`, `capex_assembly_per_kw`, `capex_additional_labor_per_kw`, + and `fixed_opex_per_kw_per_year`. The `cost_year` field is inherited from `CostModelBaseConfig`. + """ + + system_capacity_kw: float = field(validator=gte_zero) + capex_stack_per_kw: float = field(validator=gte_zero) + capex_hydrogen_supply_per_kw: float = field(validator=gte_zero) + capex_air_supply_per_kw: float = field(validator=gte_zero) + capex_cooling_per_kw: float = field(validator=gte_zero) + capex_controls_instrumentation_per_kw: float = field(validator=gte_zero) + capex_electrical_per_kw: float = field(validator=gte_zero) + capex_assembly_per_kw: float = field(validator=gte_zero) + capex_additional_labor_per_kw: float = field(validator=gte_zero) + fixed_opex_per_kw_per_year: float = field(validator=gte_zero) + + +class PEMH2FuelCellCostModel(CostModelBaseClass): + """ + Cost model for a hydrogen fuel cell system. + + The model calculates capital and fixed operating costs based on system capacity and + specified cost parameters. + """ + + _time_step_bounds = ( + 3600, + 3600, + ) # (min, max) time step lengths (in seconds) compatible with this model + + def setup(self): + self.config = PEMH2FuelCellCostConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost"), + additional_cls_name=self.__class__.__name__, + ) + + super().setup() + + self.add_input( + "system_capacity", + val=self.config.system_capacity_kw, + units="kW", + desc="Capacity of the h2 fuel cell system", + ) + + self.add_input( + "unit_capex", + val=self.config.capex_stack_per_kw + + self.config.capex_hydrogen_supply_per_kw + + self.config.capex_air_supply_per_kw + + self.config.capex_cooling_per_kw + + self.config.capex_controls_instrumentation_per_kw + + self.config.capex_electrical_per_kw + + self.config.capex_assembly_per_kw + + self.config.capex_additional_labor_per_kw, + units="USD/kW", + desc="Capital cost per unit capacity", + ) + + self.add_input( + "fixed_opex_per_year", + val=self.config.fixed_opex_per_kw_per_year, + units="(USD/kW)/year", + desc="Fixed operating expenses per unit capacity per year", + ) + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + """ + Compute capital and fixed operating costs for the fuel cell system. + + Args: + inputs: OpenMDAO inputs object containing system_capacity. + outputs: OpenMDAO outputs object for capital_cost and fixed_operating_cost_per_year. + """ + + system_capacity_kw = inputs["system_capacity"] + + # Calculate capital cost + outputs["CapEx"] = system_capacity_kw * inputs["unit_capex"] + + # Calculate fixed operating cost per year + outputs["OpEx"] = system_capacity_kw * inputs["fixed_opex_per_year"] diff --git a/h2integrate/converters/hydrogen/__init__.py b/h2integrate/converters/hydrogen/__init__.py index b39fb2556..a5be25669 100644 --- a/h2integrate/converters/hydrogen/__init__.py +++ b/h2integrate/converters/hydrogen/__init__.py @@ -9,6 +9,10 @@ LinearH2FuelCellPerformanceModel, H2FuelCellCostModel, ) +from h2integrate.converters.hydrogen.PEM_h2_fuel_cell import ( + PEMH2FuelCellPerformanceModel, + PEMH2FuelCellCostModel, +) from h2integrate.converters.hydrogen.steam_methane_reformer import ( SteamMethaneReformerPerformanceModel, SteamMethaneReformerCostModel, diff --git a/h2integrate/converters/hydrogen/test/test_PEM_fuel_cell.py b/h2integrate/converters/hydrogen/test/test_PEM_fuel_cell.py new file mode 100644 index 000000000..e67288920 --- /dev/null +++ b/h2integrate/converters/hydrogen/test/test_PEM_fuel_cell.py @@ -0,0 +1,290 @@ +import numpy as np +import pytest +import openmdao.api as om +from pytest import fixture + +from h2integrate.converters.hydrogen.PEM_h2_fuel_cell import ( + PEMH2FuelCellCostModel, + PEMH2FuelCellPerformanceModel, +) + + +@fixture +def plant_config(): + plant_config = { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 48, + "dt": 3600, + }, + }, + } + return plant_config + + +@fixture +def tech_config(): + config = { + "model_inputs": { + "performance_parameters": { + "system_capacity_kw": 1500.0, + "n_stacks": 60, + "stack_temperature_K": 278.0, + } + } + } + return config + + +@fixture +def cost_config(): + config = { + "model_inputs": { + "cost_parameters": { + "system_capacity_kw": 1500.0, + "capex_stack_per_kw": 800.0, + "capex_hydrogen_supply_per_kw": 140.67, + "capex_air_supply_per_kw": 138.79, + "capex_cooling_per_kw": 64.14, + "capex_controls_instrumentation_per_kw": 106.52, + "capex_electrical_per_kw": 447.83, + "capex_assembly_per_kw": 63.51, + "capex_additional_labor_per_kw": 137.96, + "fixed_opex_per_kw_per_year": 31.0, + "cost_year": 2026, + } + } + } + return config + + +@pytest.mark.regression +def test_fuel_cell_performance(tech_config, plant_config, subtests): + n_timesteps = int(plant_config["plant"]["simulation"]["n_timesteps"]) + + prob = om.Problem() + + fuel_cell = PEMH2FuelCellPerformanceModel( + plant_config=plant_config, tech_config=tech_config, driver_config={} + ) + + prob.model.add_subsystem("fuel_cell", fuel_cell, promotes=["*"]) + + prob.setup() + + # Provide ample hydrogen and oxygen to run at the default command (rated capacity) + hydrogen_input = np.ones(n_timesteps) * 200.0 # kg/h + oxygen_input = np.ones(n_timesteps) * 2000.0 # kg/h + + prob.set_val("fuel_cell.hydrogen_in", hydrogen_input, units="kg/h") + prob.set_val("fuel_cell.oxygen_in", oxygen_input, units="kg/h") + prob.set_val("fuel_cell.electricity_command_value", np.ones(n_timesteps) * 1000.0, units="kW") + + prob.run_model() + + electricity_output = prob.get_val("fuel_cell.electricity_out", units="kW") + prob.get_val("fuel_cell.hydrogen_consumed", units="kg/h") + prob.get_val("fuel_cell.oxygen_consumed", units="kg/h") + prob.get_val("fuel_cell.water_out", units="kg/h") + + with subtests.test("max electricity output bounded by system capacity"): + assert np.max(electricity_output) <= 1500.0 + 1e-6 + + with subtests.test("electricity output is non-negative"): + assert np.min(electricity_output) >= 0.0 + + with subtests.test("total_electricity_produced matches sum of output"): + assert pytest.approx( + prob.get_val("fuel_cell.total_electricity_produced", units="kW*h"), rel=1e-6 + ) == np.sum(electricity_output) + + with subtests.test("electricity out"): + assert ( + pytest.approx(np.sum(prob.get_val("fuel_cell.electricity_out", units="kW")), rel=1e-6) + == 48209.20157 + ) + + with subtests.test("capacity_factor"): + assert ( + pytest.approx(prob.get_val("fuel_cell.capacity_factor", units="unitless"), rel=1e-2) + == 0.669 + ) + + with subtests.test("annual_electricity_production"): + assert ( + pytest.approx( + prob.get_val("fuel_cell.annual_electricity_produced", units="kW*h/year"), rel=1e-2 + ) + == 8760000.86 + ) + + with subtests.test("rated_electricity_production"): + assert ( + pytest.approx( + prob.get_val("fuel_cell.rated_electricity_production", units="kW"), rel=1e-4 + ) + == 1498.9 + ) + + with subtests.test("total_electricity_produced"): + assert ( + pytest.approx( + prob.get_val("fuel_cell.total_electricity_produced", units="kW*h"), rel=1e-6 + ) + == 48209.20157 + ) + + with subtests.test("hydrogen consumed"): + assert ( + pytest.approx( + np.sum(prob.get_val("fuel_cell.hydrogen_consumed", units="kg/h")), rel=1e-6 + ) + == 2291.481556 + ) + + with subtests.test("oxygen consumed"): + assert ( + pytest.approx(np.sum(prob.get_val("fuel_cell.oxygen_consumed", units="kg/h")), rel=1e-6) + == 18185.22451853 + ) + + with subtests.test("water out"): + assert ( + pytest.approx(np.sum(prob.get_val("fuel_cell.water_out", units="kg/h")), rel=1e-6) + == 20476.70602546 + ) + + +@pytest.mark.unit +def test_fuel_cell_demand(tech_config, plant_config, subtests): + n_timesteps = int(plant_config["plant"]["simulation"]["n_timesteps"]) + + prob = om.Problem() + + fuel_cell = PEMH2FuelCellPerformanceModel( + plant_config=plant_config, tech_config=tech_config, driver_config={} + ) + + prob.model.add_subsystem("fuel_cell", fuel_cell, promotes=["*"]) + + prob.setup() + + # Provide ample feedstock for most timesteps; constrain a few to test edge cases + hydrogen_input = np.ones(n_timesteps) * 200.0 # kg/h + oxygen_input = np.ones(n_timesteps) * 2000.0 # kg/h + + # Edge cases for feedstock supply at the first 6 timesteps + hydrogen_input[:6] = ( + 500000000.0, # very high H2 supply + 500000000.0, # very high H2 supply with low set point + 200.0, # ample + 0.0, # zero hydrogen supply + 1.0, # very limited hydrogen supply + 200.0, # ample + ) + oxygen_input[:6] = ( + 2000.0, + 2000.0, + 2000.0, + 2000.0, + 2000.0, + 0.0, # zero oxygen supply + ) + + prob.set_val("fuel_cell.hydrogen_in", hydrogen_input, units="kg/h") + prob.set_val("fuel_cell.oxygen_in", oxygen_input, units="kg/h") + + elec_set_point = np.ones(n_timesteps) * 1500.0 # kW + elec_set_point[:6] = ( + 1500.0, # set point equal to system capacity + 500.0, # set point below system capacity + 1500.0, # set point equal to system capacity + 1500.0, # set point equal to system capacity, no H2 supply + 1500.0, # set point equal to system capacity, limited H2 supply + 0.0, # zero set point + ) + + prob.set_val("fuel_cell.electricity_command_value", elec_set_point, units="kW") + + prob.run_model() + + electricity_output = prob.get_val("fuel_cell.electricity_out", units="kW") + hydrogen_consumed = prob.get_val("fuel_cell.hydrogen_consumed", units="kg/h") + oxygen_consumed = prob.get_val("fuel_cell.oxygen_consumed", units="kg/h") + water_out = prob.get_val("fuel_cell.water_out", units="kg/h") + + with subtests.test("output clipped to system capacity"): + assert electricity_output[0] == pytest.approx(1500.0, rel=1e-3) + + with subtests.test("output follows reduced set point"): + # When set point is below capacity and feedstock is ample, output tracks set point + assert electricity_output[1] == pytest.approx(500.0, rel=1e-2) + + with subtests.test("output non-negative when ample supply"): + assert electricity_output[2] >= 0.0 + assert electricity_output[2] <= 1500.0 + 1e-6 + + with subtests.test("zero hydrogen feedstock supply yields zero output"): + assert electricity_output[3] == pytest.approx(0.0, abs=1e-6) + + with subtests.test("limited hydrogen feedstock supply yields reduced output"): + assert electricity_output[4] < 30.0 + assert electricity_output[4] > 0.0 + + with subtests.test("zero set point yields zero output"): + assert electricity_output[5] == pytest.approx(0.0, abs=1e-6) + + # Test hydrogen_consumed, oxygen_consumed, and water_out for the first 6 timesteps + with subtests.test("hydrogen consumed"): + expected_h2_consumed = [76.501247, 22.920501, 76.501247, 0, 1, 0.0] + np.testing.assert_allclose(hydrogen_consumed[:6], expected_h2_consumed, rtol=1e-4) + + with subtests.test("oxygen consumed"): + expected_o2_consumed = [607.114803, 181.897366, 607.114803, 0, 7.936012, 0.0] + np.testing.assert_allclose(oxygen_consumed[:6], expected_o2_consumed, rtol=1e-4) + + with subtests.test("water out"): + expected_water_out = [683.61605, 204.817867, 683.61605, 0.0, 8.936012, 0.0] + np.testing.assert_allclose(water_out[:6], expected_water_out, rtol=1e-4) + + +@pytest.mark.regression +def test_fuel_cell_cost(cost_config, plant_config, subtests): + prob = om.Problem() + + fuel_cell_cost = PEMH2FuelCellCostModel( + plant_config=plant_config, tech_config=cost_config, driver_config={} + ) + + prob.model.add_subsystem("fuel_cell_cost", fuel_cell_cost, promotes=["*"]) + + prob.setup() + + prob.run_model() + + cp = cost_config["model_inputs"]["cost_parameters"] + expected_unit_capex = ( + cp["capex_stack_per_kw"] + + cp["capex_hydrogen_supply_per_kw"] + + cp["capex_air_supply_per_kw"] + + cp["capex_cooling_per_kw"] + + cp["capex_controls_instrumentation_per_kw"] + + cp["capex_electrical_per_kw"] + + cp["capex_assembly_per_kw"] + + cp["capex_additional_labor_per_kw"] + ) + expected_capex = cp["system_capacity_kw"] * expected_unit_capex + expected_opex = cp["system_capacity_kw"] * cp["fixed_opex_per_kw_per_year"] + + with subtests.test("capex value"): + assert ( + pytest.approx(prob.get_val("fuel_cell_cost.CapEx", units="USD"), rel=1e-6) + == expected_capex + ) + + with subtests.test("opex value"): + assert ( + pytest.approx(prob.get_val("fuel_cell_cost.OpEx", units="USD/year"), rel=1e-6) + == expected_opex + ) diff --git a/h2integrate/converters/natural_gas/SO_NG_fuel_cell.py b/h2integrate/converters/natural_gas/SO_NG_fuel_cell.py new file mode 100644 index 000000000..1bdfbf9e6 --- /dev/null +++ b/h2integrate/converters/natural_gas/SO_NG_fuel_cell.py @@ -0,0 +1,390 @@ +import numpy as np +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig, merge_shared_inputs +from h2integrate.core.validators import gte_zero +from h2integrate.tools.constants import H_MW, O2_MW, CH4_MW, CO2_MW, faraday +from h2integrate.core.model_baseclasses import ( + CostModelBaseClass, + CostModelBaseConfig, + PerformanceModelBaseClass, +) + + +@define(kw_only=True) +class SONGFuelCellPerformanceConfig(BaseConfig): + """Configuration class for the solid oxide natural gas fuel cell performance model. + + Attributes: + system_capacity_kw (float): The capacity of the fuel cell system in kilowatts (kW). + n_stacks (int): The number of stacks in the fuel cell system. + stack_temperature_K (float): The operating temperature of the fuel cell stack in Kelvin (K). + """ + + # TODO: how to size the fuel cell? N_cells + N_stacks? + # How does N_cells translate to electricity rating? + + system_capacity_kw: float = field(validator=gte_zero) + n_stacks: int + stack_temperature_K: float + # min_system_power_fraction_kw: float + # fuel_cell_efficiency_hhv: float = field(validator=range_val(0, 1)) + + +def calc_current(system_power_reference, cell_area, n_cells, n_stacks): + """_summary_ + + Args: + system_power_reference (np.ndarray): power demanded of the entire system in W + cell_area (float): cell active area in cm^2 + n_cells (int): number of cells per stack + n_stacks (int): number of stacks in the system + + Returns: + tuple(np.ndarray, np.poly1d): stack current and + function to convert from current density to voltage + """ + # Calculates the current and voltage from IV curve based on power reference + J_curve = np.array([0.0356, 0.05413333, 0.0796, 0.11366667, 0.244, 0.454]) # in A/cm^2 + voltage_curve = np.array([0.987, 0.936, 0.884, 0.838, 0.786, 0.736]) # in V + power_curve = ( + np.array([35.16666667, 50.53333333, 70.33333333, 95.46666667, 191.66666667, 334.33333333]) + / 1e3 + ) # in W/cm^2 + + # function to calculate voltage from current density + V_coefs = np.polyfit(J_curve, voltage_curve, 5) + V_J_curve = np.poly1d(V_coefs) + + # function to calculate current density from power + # I_curve = J_curve * cell_area + # P_curve = I_curve * (n_cells * voltage_curve) + stack_P_curve = power_curve * cell_area * n_cells + J_coefs = np.polyfit(stack_P_curve, J_curve, 5) + J_P_curve = np.poly1d(J_coefs) + + # Calculate power per stack and power density + power_per_stack = system_power_reference / n_stacks + + stack_current_density = J_P_curve(power_per_stack) # convert to kW for the curve + stack_current = stack_current_density * cell_area * n_cells # in A + stack_current = np.clip(stack_current, a_min=0.0, a_max=None) # clip negative values + + return stack_current, V_J_curve + + +class SONGFuelCellPerformanceModel(PerformanceModelBaseClass): + """ + Performance model for a solid oxide natural gas fuel cell. + + The model calculates electricity output based on natural gas and oxygen inputs, + with current and voltage determined from power density using IV curves. + Produces water and carbon dioxide as byproducts. + Possible source: https://www.pnnl.gov/main/publications/external/technical_reports/PNNL-18338.pdf + + where: + - natural_gas_in is the mass flow rate of natural gas in kg/hr + - oxygen_in is the mass flow rate of oxygen in kg/hr + - water_out is the mass flow rate of water produced in kg/hr + - carbon_dioxide_out is the mass flow rate of carbon dioxide produced in kg/hr + """ + + _time_step_bounds = ( + 3600, + 3600, + ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" + + def initialize(self): + super().initialize() + self.commodity = "electricity" + self.commodity_rate_units = "kW" + self.commodity_amount_units = "kW*h" + + def setup(self): + super().setup() + + self.config = SONGFuelCellPerformanceConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance"), + additional_cls_name=self.__class__.__name__, + ) + + # Add natural gas input, default to 0 --> set using feedstock component + # or upstream hydrogen converter component + self.add_input( + "natural_gas_in", + val=0.0, + shape=self.n_timesteps, + units="MMBtu/h", + ) + + self.add_input( + "oxygen_in", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + ) + + self.add_input( + "stack_temperature", + val=self.config.stack_temperature_K, + units="K", + desc="Operating temperature of the stack", + ) + + # self.add_input( + # "fuel_cell_efficiency", + # val=self.config.fuel_cell_efficiency_hhv, + # units=None, + # desc="HHV efficiency of the fuel cell (0 <= efficiency <= 1)", + # ) + + # Add rated capacity as an input with config value as default + self.add_input( + "system_capacity", + val=self.config.system_capacity_kw, + units="kW", + desc="Capacity of the h2 fuel cell system", + ) + + self.add_output( + "natural_gas_consumed", + val=0.0, + shape=self.n_timesteps, + units="MMBtu/h", + desc="Mass flow rate of natural gas consumed by the fuel cell", + ) + + self.add_output( + "oxygen_consumed", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + desc="Mass flow rate of oxygen consumed by the fuel cell", + ) + + self.add_output( + "water_out", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + desc="Mass flow rate of water produced by the fuel cell", + ) + + self.add_output( + "carbon_dioxide_out", + val=0.0, + shape=self.n_timesteps, + units="kg/h", + desc="Mass flow rate of carbon dioxide produced by the fuel cell", + ) + + # Default the electricity command value input as the rated capacity + self.add_input( + f"{self.commodity}_command_value", + val=self.config.system_capacity_kw, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc="Electricity command value for SOFC plant", + ) + + def compute(self, inputs, outputs): + """ + Compute electricity output from the SOFC based on natural gas input, + oxygen availability, and fuel cell electrochemical reactions. + + Args: + inputs: OpenMDAO inputs object containing natural_gas_in, oxygen_in, + stack_temperature, electricity_command_value, and system_capacity. + outputs: OpenMDAO outputs object for electricity_out, natural_gas_consumed, + oxygen_consumed, water_out, and carbon_dioxide_out. + """ + # Set calculation constants: + M_H2 = H_MW * 2 / 1000 # Molar mass of H2 in kg/mol + M_O2 = O2_MW / 1000 # Molar mass of O2 in kg/mol + M_H2O = M_H2 + M_O2 / 2 # Molar mass of H2O in kg/mol + M_CH4 = CH4_MW / 1000 # Molar mass of CH4 in kg/mol + M_CO2 = CO2_MW / 1000 # Molar mass of CO2 in kg/mol + + # calculate max input and output + inputs["oxygen_in"] # kg/h + stack_temperature = inputs["stack_temperature"] + natural_gas_in = inputs["natural_gas_in"] # MMBtu/h + # fuel_cell_efficiency = inputs["fuel_cell_efficiency"] + + ############################################################################ + # Can convert from MMBtu to Joules in openmdao + # Then can convert from Joules to kg using some heating value or enthalpy + + # Convert natural gas input from MMBtu/h to kg/h for CH4 + # Assume 1 MMBtu ~ 1 MCF + # Use ideal gas law to account for temperature difference + ng_density_cold = 0.717 # kg/m^3 at 25 deg C and 1 atm + ng_density_hot = ng_density_cold * (298.15 / stack_temperature) # kg/m^3 at stack temp + + # Use Mass = volume * density, and 1 MCF = 1e3 ft^3 = 28.3168 m^3 + mcf_to_kg = 28.3168 * ng_density_hot # kg + natural_gas_kg_hr = natural_gas_in * mcf_to_kg # Convert MMBtu/h to kg/h for CH4 + + # Add consumption of water for steam reforming of natural gas to hydrogen + + # Sizing the cells + max_cell_power_density = 0.000334 # in W/cm^2 + stack_size = inputs["system_capacity"][0] / self.config.n_stacks + cell_active_area = 400 # [cm^2] from Battelle (https://www.energy.gov/sites/prod/files/2018/02/f49/fcto_battelle_mfg_cost_analysis_1%20_to_25kw_pp_chp_fc_systems_jan2017_0.pdf) + n_cells = round(stack_size / (cell_active_area * max_cell_power_density)) + # Recalculate the rated power production based on final fuel cell sizing + rated_power_production = ( + max_cell_power_density * n_cells * cell_active_area * self.config.n_stacks + ) + + ################## Model Calculations ################## + # 1. Receive power setpoint into fuel cell + power_reference = np.clip( + inputs[f"{self.commodity}_command_value"], a_min=0.0, a_max=rated_power_production + ) + + # 2. Find stack current from power reference + commanded_I_stack, V_J_curve = calc_current( + power_reference * 1e3, cell_active_area, n_cells, self.config.n_stacks + ) + + # 3. Find available hydrogen and oxygen for each timestep + ng_in_kg_per_s = natural_gas_kg_hr / 3600 # convert from kg/h to kg/s + o2_in_kg_per_s = inputs["oxygen_in"] / 3600 # convert from kg/h to kg/s + + # convert from kg/s to A per stack - current that feedstocks in can support + I_stack_from_ng = (ng_in_kg_per_s * 8 * faraday) / (M_CH4 * self.config.n_stacks) + I_stack_from_o2 = (o2_in_kg_per_s * 4 * faraday) / (M_O2 * self.config.n_stacks) + + # 4. Take minimum current from power reference, hydrogen available, and oxygen available + I_stack = np.minimum(commanded_I_stack, np.minimum(I_stack_from_ng, I_stack_from_o2)) + + # 5. Calculate current density and voltage from I-V curve + J_cell = I_stack / (cell_active_area * n_cells) # in A/cm^2 + I_cell = J_cell * cell_active_area # in A + V_cell = V_J_curve(J_cell) # in V + + # 6. Calculate power output from current and voltage + power_out = V_cell * I_cell * n_cells * self.config.n_stacks / 1e3 # in kW + + # 7. Calculate hydrogen and oxygen consumed and water produced + # based on electrochemical reactions + ng_consumed = ((I_cell * M_CH4) / (8.0 * faraday)) * ( + self.dt * self.config.n_stacks * n_cells + ) # kg/time step + o2_consumed = ((I_cell * M_O2) / (4.0 * faraday)) * ( + self.dt * self.config.n_stacks * n_cells + ) # kg/time step + h2o_generated = ((I_cell * M_H2O) / (2 * faraday)) * ( + self.dt * self.config.n_stacks * n_cells + ) # kg/time step + + co2_generated = ((I_cell * M_CO2) / (8 * faraday)) * ( + self.dt * self.config.n_stacks * n_cells + ) # kg/time step + + # Set Outputs + # clip the electricity output to the system capacity + outputs["rated_electricity_production"] = rated_power_production + outputs["electricity_out"] = np.minimum(power_out, rated_power_production) + outputs["total_electricity_produced"] = np.sum(outputs["electricity_out"]) * ( + self.dt / 3600 + ) + outputs["annual_electricity_produced"] = outputs["total_electricity_produced"] * ( + 1 / self.fraction_of_year_simulated + ) + outputs["capacity_factor"] = outputs["total_electricity_produced"] / ( + self.config.system_capacity_kw * self.n_timesteps * (self.dt / 3600) + ) + outputs["natural_gas_consumed"] = ng_consumed / mcf_to_kg # Convert back to MMBtu/h + outputs["oxygen_consumed"] = o2_consumed + outputs["water_out"] = h2o_generated + outputs["carbon_dioxide_out"] = co2_generated + + # TODO: implement a natural gas and oxygen conversion efficiency based on stack + # temperature and other factors + + +@define(kw_only=True) +class SONGFuelCellCostConfig(CostModelBaseConfig): + """Configuration class for the solid oxide natural gas fuel cell cost model. + + Attributes: + system_capacity_kw (float): The capacity of the fuel cell system in kilowatts (kW). + capex_stack_per_kw (float): Capital expenditure for the stack per kilowatt ($/kW). + capex_bop (float): Capital expenditure for balance of plant ($/kW). + capex_indirect_costs_per_kw (float): Indirect capital costs per kilowatt ($/kW). + fixed_opex_per_kw_per_year (float): Fixed operating expenditure per + kilowatt per year ($/kW/year). + + The `cost_year` field is inherited from `CostModelBaseConfig`. + """ + + system_capacity_kw: float = field(validator=gte_zero) + capex_stack_per_kw: float = field(validator=gte_zero) + capex_bop_per_kw: float = field(validator=gte_zero) + capex_indirect_costs_per_kw: float = field(validator=gte_zero) + fixed_opex_per_kw_per_year: float = field(validator=gte_zero) + + +class SONGFuelCellCostModel(CostModelBaseClass): + """ + Cost model for a solid oxide natural gas fuel cell system. + + The model calculates capital and fixed operating costs based on system capacity and + specified cost parameters. + """ + + _time_step_bounds = ( + 3600, + 3600, + ) # (min, max) time step lengths (in seconds) compatible with this model + + def setup(self): + self.config = SONGFuelCellCostConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost"), + additional_cls_name=self.__class__.__name__, + ) + + super().setup() + + self.add_input( + "system_capacity", + val=self.config.system_capacity_kw, + units="kW", + desc="Capacity of the solid oxide natural gas fuel cell system", + ) + + self.add_input( + "unit_capex", + val=self.config.capex_stack_per_kw + + self.config.capex_bop_per_kw + + self.config.capex_indirect_costs_per_kw, + units="USD/kW", + desc="Capital cost per unit capacity", + ) + + self.add_input( + "fixed_opex_per_year", + val=self.config.fixed_opex_per_kw_per_year, + units="(USD/kW)/year", + desc="Fixed operating expenses per unit capacity per year", + ) + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + """ + Compute capital and fixed operating costs for the solid oxide natural gas fuel cell system. + + Args: + inputs: OpenMDAO inputs object containing system_capacity. + outputs: OpenMDAO outputs object for capital_cost and fixed_operating_cost_per_year. + """ + + system_capacity_kw = inputs["system_capacity"] + + # Calculate capital cost + outputs["CapEx"] = system_capacity_kw * inputs["unit_capex"] + + # Calculate fixed operating cost per year + outputs["OpEx"] = system_capacity_kw * inputs["fixed_opex_per_year"] diff --git a/h2integrate/converters/natural_gas/__init__.py b/h2integrate/converters/natural_gas/__init__.py index 50d2251a8..d349717f8 100644 --- a/h2integrate/converters/natural_gas/__init__.py +++ b/h2integrate/converters/natural_gas/__init__.py @@ -8,3 +8,7 @@ SimpleGasConsumerPerformance, SimpleGasConsumerCost, ) +from h2integrate.converters.natural_gas.SO_NG_fuel_cell import ( + SONGFuelCellPerformanceModel, + SONGFuelCellCostModel, +) diff --git a/h2integrate/converters/natural_gas/test/test_SO_NG_fuel_cell.py b/h2integrate/converters/natural_gas/test/test_SO_NG_fuel_cell.py new file mode 100644 index 000000000..e919e7bff --- /dev/null +++ b/h2integrate/converters/natural_gas/test/test_SO_NG_fuel_cell.py @@ -0,0 +1,302 @@ +import numpy as np +import pytest +import openmdao.api as om +from pytest import fixture + +from h2integrate.converters.natural_gas.SO_NG_fuel_cell import ( + SONGFuelCellCostModel, + SONGFuelCellPerformanceModel, +) + + +@fixture +def plant_config(): + plant_config = { + "plant": { + "plant_life": 1, + "simulation": { + "n_timesteps": 48, + "dt": 3600, + }, + }, + } + return plant_config + + +@fixture +def tech_config(): + config = { + "model_inputs": { + "performance_parameters": { + "system_capacity_kw": 1500.0, + "n_stacks": 60, + "stack_temperature_K": 1073.0, + } + } + } + return config + + +@fixture +def cost_config(): + config = { + "model_inputs": { + "cost_parameters": { + "system_capacity_kw": 1500.0, + "capex_stack_per_kw": 500.0, + "capex_bop_per_kw": 1000, + "capex_indirect_costs_per_kw": 1500, + "fixed_opex_per_kw_per_year": 31.0, + "cost_year": 2026, + } + } + } + return config + + +@pytest.mark.regression +def test_fuel_cell_performance(tech_config, plant_config, subtests): + n_timesteps = int(plant_config["plant"]["simulation"]["n_timesteps"]) + + prob = om.Problem() + + fuel_cell = SONGFuelCellPerformanceModel( + plant_config=plant_config, tech_config=tech_config, driver_config={} + ) + + prob.model.add_subsystem("fuel_cell", fuel_cell, promotes=["*"]) + + prob.setup() + + # Provide ample natural gas and oxygen to run at the default command (rated capacity) + natural_gas_input = np.ones(n_timesteps) * 20.0 # MMBtu/h + oxygen_input = np.ones(n_timesteps) * 2000.0 # kg/h + + prob.set_val("fuel_cell.natural_gas_in", natural_gas_input, units="MMBtu/h") + prob.set_val("fuel_cell.oxygen_in", oxygen_input, units="kg/h") + prob.set_val("fuel_cell.electricity_command_value", np.ones(n_timesteps) * 1000.0, units="kW") + + prob.run_model() + + with subtests.test("max electricity output"): + assert ( + pytest.approx(np.max(prob.get_val("fuel_cell.electricity_out", units="kW")), rel=1e-2) + == 1000.0 + ) + + with subtests.test("electricity out"): + assert ( + pytest.approx(np.sum(prob.get_val("fuel_cell.electricity_out", units="kW")), rel=1e-6) + == 48209.2 + ) + + with subtests.test("capacity_factor"): + assert ( + pytest.approx(prob.get_val("fuel_cell.capacity_factor", units="unitless"), rel=1e-2) + == 0.669 + ) + + with subtests.test("annual_electricity_production"): + assert ( + pytest.approx( + prob.get_val("fuel_cell.annual_electricity_produced", units="kW*h/year"), rel=1e-2 + ) + == 8760000.86 + ) + + with subtests.test("rated_electricity_production"): + assert ( + pytest.approx( + prob.get_val("fuel_cell.rated_electricity_production", units="kW"), rel=1e-4 + ) + == 1498.9 + ) + + with subtests.test("total_electricity_produced"): + assert ( + pytest.approx( + prob.get_val("fuel_cell.total_electricity_produced", units="kW*h"), rel=1e-6 + ) + == 48209.2 + ) + + with subtests.test("natural gas consumed"): + assert ( + pytest.approx( + np.sum(prob.get_val("fuel_cell.natural_gas_consumed", units="MMBtu/h")), rel=1e-6 + ) + == 807.926382702 + ) + + with subtests.test("oxygen consumed"): + assert ( + pytest.approx(np.sum(prob.get_val("fuel_cell.oxygen_consumed", units="kg/h")), rel=1e-6) + == 18185.2245185 + ) + + with subtests.test("water out"): + assert ( + pytest.approx(np.sum(prob.get_val("fuel_cell.water_out", units="kg/h")), rel=1e-6) + == 20476.7060254 + ) + + with subtests.test("carbon dioxide out"): + assert ( + pytest.approx( + np.sum(prob.get_val("fuel_cell.carbon_dioxide_out", units="kg/h")), rel=1e-6 + ) + == 12505.9649206 + ) + + +@pytest.mark.unit +def test_fuel_cell_demand(tech_config, plant_config, subtests): + n_timesteps = int(plant_config["plant"]["simulation"]["n_timesteps"]) + + prob = om.Problem() + + fuel_cell = SONGFuelCellPerformanceModel( + plant_config=plant_config, tech_config=tech_config, driver_config={} + ) + + prob.model.add_subsystem("fuel_cell", fuel_cell, promotes=["*"]) + + prob.setup() + + # Provide ample feedstock supply for most timesteps; constrain a few to test + # feedstock-limited dynamics. + natural_gas_input = np.ones(n_timesteps) * 30.0 # MMBtu/h + oxygen_input = np.ones(n_timesteps) * 2000.0 # kg/h + + # Edge cases for feedstock supply at timesteps 5-7 + natural_gas_input[5:8] = ( + 0.0, # zero NG supply -> output should collapse to zero + 1, # severely limited NG supply -> output reduced + 20.0, # ample NG supply, but O2 will be limited below + ) + oxygen_input[5:8] = ( + 2000.0, + 2000.0, + 0.0, # zero O2 supply -> output should collapse to zero + ) + + prob.set_val("fuel_cell.natural_gas_in", natural_gas_input, units="MMBtu/h") + prob.set_val("fuel_cell.oxygen_in", oxygen_input, units="kg/h") + + elec_set_point = np.ones(n_timesteps) * 1500.0 # kW + + # First 5 timesteps test set-point edge cases (with ample feedstock). + # Timesteps 5-7 test feedstock-limited dynamics at rated set point. + elec_set_point[:5] = ( + 1500.0, # set point equal to system capacity + 500.0, # set point below system capacity + 2500.0, # very high set point (should be clipped to system capacity) + 0.0, # zero set point + 750.0, # set point at half of system capacity + ) + + prob.set_val("fuel_cell.electricity_command_value", elec_set_point, units="kW") + + prob.run_model() + + electricity_output = prob.get_val("fuel_cell.electricity_out", units="kW") + ng_consumed = prob.get_val("fuel_cell.natural_gas_consumed", units="MMBtu/h") + o2_consumed = prob.get_val("fuel_cell.oxygen_consumed", units="kg/h") + water_out = prob.get_val("fuel_cell.water_out", units="kg/h") + co2_out = prob.get_val("fuel_cell.carbon_dioxide_out", units="kg/h") + + with subtests.test("output bounded by system capacity"): + assert np.max(electricity_output) <= 1500.0 + 1e-6 + + with subtests.test("output non-negative"): + assert np.min(electricity_output) >= 0.0 + + with subtests.test("output clipped to system capacity at rated set point"): + assert electricity_output[0] == pytest.approx(1500.0, rel=1e-2) + + with subtests.test("output follows reduced set point"): + assert electricity_output[1] == pytest.approx(500.0, rel=1e-2) + + with subtests.test("very high set point clipped to system capacity"): + assert electricity_output[2] == pytest.approx(1500.0, rel=1e-2) + + with subtests.test("zero set point yields zero output"): + assert electricity_output[3] == pytest.approx(0.0, abs=1e-6) + + with subtests.test("half-rated set point yields ~half output"): + assert electricity_output[4] == pytest.approx(750.0, rel=5e-2) + + with subtests.test("natural gas consumed non-negative"): + assert np.min(ng_consumed) >= 0.0 + + with subtests.test("oxygen consumed non-negative"): + assert np.min(o2_consumed) >= 0.0 + + with subtests.test("water produced non-negative"): + assert np.min(water_out) >= 0.0 + + with subtests.test("CO2 produced non-negative"): + assert np.min(co2_out) >= 0.0 + + with subtests.test("zero set point yields zero consumption and byproducts"): + assert ng_consumed[3] == pytest.approx(0.0, abs=1e-6) + assert o2_consumed[3] == pytest.approx(0.0, abs=1e-6) + assert water_out[3] == pytest.approx(0.0, abs=1e-6) + assert co2_out[3] == pytest.approx(0.0, abs=1e-6) + + # Feedstock-limited dynamics: + + with subtests.test("zero NG supply collapses output to zero"): + assert electricity_output[5] == pytest.approx(0.0, abs=1e-6) + assert ng_consumed[5] == pytest.approx(0.0, abs=1e-6) + assert o2_consumed[5] == pytest.approx(0.0, abs=1e-6) + assert water_out[5] == pytest.approx(0.0, abs=1e-6) + assert co2_out[5] == pytest.approx(0.0, abs=1e-6) + + with subtests.test("limited NG supply reduces electricity output and byproducts"): + assert electricity_output[6] == pytest.approx(79.43635323, rel=1e-2) + assert ng_consumed[6] == pytest.approx(1.0, rel=1e-2) + assert o2_consumed[6] == pytest.approx(22.509924, rel=1e-2) + assert water_out[6] == pytest.approx(25.323664, rel=1e-2) + assert co2_out[6] == pytest.approx(15.475572, rel=1e-2) + + with subtests.test("zero O2 supply collapses output to zero"): + assert electricity_output[7] == pytest.approx(0.0, abs=1e-6) + assert ng_consumed[7] == pytest.approx(0.0, abs=1e-6) + assert o2_consumed[7] == pytest.approx(0.0, abs=1e-6) + assert water_out[7] == pytest.approx(0.0, abs=1e-6) + assert co2_out[7] == pytest.approx(0.0, abs=1e-6) + + +@pytest.mark.regression +def test_fuel_cell_cost(cost_config, plant_config, subtests): + prob = om.Problem() + + fuel_cell_cost = SONGFuelCellCostModel( + plant_config=plant_config, tech_config=cost_config, driver_config={} + ) + + prob.model.add_subsystem("fuel_cell_cost", fuel_cell_cost, promotes=["*"]) + + prob.setup() + + prob.run_model() + + cp = cost_config["model_inputs"]["cost_parameters"] + expected_unit_capex = ( + cp["capex_stack_per_kw"] + cp["capex_bop_per_kw"] + cp["capex_indirect_costs_per_kw"] + ) + expected_capex = cp["system_capacity_kw"] * expected_unit_capex + expected_opex = cp["system_capacity_kw"] * cp["fixed_opex_per_kw_per_year"] + + with subtests.test("capex value"): + assert ( + pytest.approx(prob.get_val("fuel_cell_cost.CapEx", units="USD"), rel=1e-6) + == expected_capex + ) + + with subtests.test("opex value"): + assert ( + pytest.approx(prob.get_val("fuel_cell_cost.OpEx", units="USD/year"), rel=1e-6) + == expected_opex + ) diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index f9e43ce89..3398e5303 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -80,9 +80,13 @@ def copy(self): "CustomElectrolyzerCostModel": "converters.hydrogen:CustomElectrolyzerCostModel", "WOMBATElectrolyzerModel": "converters.hydrogen:WOMBATElectrolyzerModel", "LinearH2FuelCellPerformanceModel": "converters.hydrogen:LinearH2FuelCellPerformanceModel", + "PEMH2FuelCellPerformanceModel": "converters.hydrogen:PEMH2FuelCellPerformanceModel", + "PEMH2FuelCellCostModel": "converters.hydrogen:PEMH2FuelCellCostModel", "H2FuelCellCostModel": "converters.hydrogen:H2FuelCellCostModel", "SteamMethaneReformerPerformanceModel": "converters.hydrogen:SteamMethaneReformerPerformanceModel", "SteamMethaneReformerCostModel": "converters.hydrogen:SteamMethaneReformerCostModel", + "SONGFuelCellPerformanceModel": "converters.natural_gas:SONGFuelCellPerformanceModel", + "SONGFuelCellCostModel": "converters.natural_gas:SONGFuelCellCostModel", "SimpleASUCostModel": "converters.nitrogen:SimpleASUCostModel", "SimpleASUPerformanceModel": "converters.nitrogen:SimpleASUPerformanceModel", "HOPPComponent": "converters.hopp:HOPPComponent", @@ -188,7 +192,6 @@ def copy(self): } ) - # This next section is to demarcate specific models that belong to certain categories that are # relevant for processing in the model stackup. Right now, these designations are # used in `h2integrate_model.py`.