Skip to content

Add new PEM and NG solid oxide fuel cell models#794

Draft
genevievestarke wants to merge 23 commits into
NatLabRockies:developfrom
genevievestarke:feature/add_new_fc
Draft

Add new PEM and NG solid oxide fuel cell models#794
genevievestarke wants to merge 23 commits into
NatLabRockies:developfrom
genevievestarke:feature/add_new_fc

Conversation

@genevievestarke

@genevievestarke genevievestarke commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Add new PEM and NG fuel cell models

This PR adds two new fuel cell models to H2I:

  1. A PEM fuel cell model
  2. A natural gas solid oxide fuel cell model

Each of these models include an I-V curve and basic reaction modeling to determine the feedstocks consumed.

Section 1: Type of Contribution

  • Feature Enhancement
    • Framework
    • New Model
    • Updated Model
    • Tools/Utilities
    • Other (please describe):
  • Bug Fix
  • Documentation Update
  • CI Changes
  • Other (please describe):

Section 2: Draft PR Checklist

  • Open draft PR
  • Describe the feature that will be added
  • Fill out TODO list steps
  • Describe requested feedback from reviewers on draft PR
  • Complete Section 7: New Model Checklist (if applicable)

TODO:

  • Implement reaction efficiencies?

Type of Reviewer Feedback Requested (on Draft PR)

Structural feedback:
There is some potential overlap here with the steam natural gas reforming model that already exists in the hydrogen folder. In the SO NG fuel cell model, the fuel cell itself still uses a reaction of hydrogen and oxygen, but the natural gas is put through a steam reforming process before the deconstructed H_2 and CO are fed into the fuel cell. The addition of other things in the fuel cell input other than pure hydrogen does affect the fuel cell performance, so I think it's a worthwhile addition to the code on its own. In the model added, natural gas goes into the model, and electricity, water, and carbon dioxide come out.
Let me know your thoughts on possible overlap issues!

Implementation feedback:
Are these models where you would expect them to be in the code?
Thoughts on Solid Oxide Natural Gas model (SONG)? Should it be NGSO (natural gas solid oxide) instead?

Other feedback:
Are these being worked on/needed in/for other projects?

Section 3: General PR Checklist

  • PR description thoroughly describes the new feature, bug fix, etc.
  • Added tests for new functionality or bug fixes
  • Tests pass (If not, and this is expected, please elaborate in the Section 6: Test Results)
  • Documentation
    • Docstrings are up-to-date
    • Related docs/ files are up-to-date, or added when necessary
    • Documentation has been rebuilt successfully
    • Examples have been updated (if applicable)
  • CHANGELOG.md
    • At least one complete sentence has been provided to describe the changes made in this PR
    • After the above, a hyperlink has been provided to the PR using the following format:
      "A complete thought. [PR XYZ]((https://github.com/NatLabRockies/H2Integrate/pull/XYZ)", where
      XYZ should be replaced with the actual number.

Section 4: Related Issues

Section 5: Impacted Areas of the Software

Section 5.1: New Files

  • path/to/file.extension
    • method1: What and why something was changed in one sentence or less.

Section 5.2: Modified Files

  • path/to/file.extension
    • method1: What and why something was changed in one sentence or less.

Section 6: Additional Supporting Information

Section 7: Test Results, if applicable

Section 8 (Optional): New Model Checklist

  • Model Structure:
    • Follows established naming conventions outlined in docs/developer_guide/coding_guidelines.md
    • Used attrs class to define the Config to load in attributes for the model
      • If applicable: inherit from BaseConfig or CostModelBaseConfig
    • Added: initialize() method, setup() method, compute() method
      • If applicable: inherit from CostModelBaseClass
  • Integration: Model has been properly integrated into H2Integrate
    • Added to supported_models.py
    • If a new commodity_type is added, update create_financial_model in h2integrate_model.py
  • Tests: Unit tests have been added for the new model
    • Pytest-style unit tests
    • Unit tests are in a "test" folder within the folder a new model was added to
    • If applicable add integration tests
  • Example: If applicable, a working example demonstrating the new model has been created
    • Input file comments
    • Run file comments
    • Example has been tested and runs successfully in test_all_examples.py
  • Documentation:
    • Write docstrings using the Google style
    • Model added to the main models list in docs/user_guide/model_overview.md
      • Model documentation page added to the appropriate docs/ section
      • <model_name>.md is added to the _toc.yml
    • Run generate_class_hierarchy.py to update the class hierarchy diagram in docs/developer_guide/class_structure.md

@elenya-grant elenya-grant left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi Gen! I really only looked at the PEM_h2_fuel_cell.py method but wanted to give some preliminary feedback. My main thoughts are:

  • I think keeping track of what's calculated at the cell level vs stack level vs the system level is a bit confusing in the compute() method and the calc_current function. I think some extra in-line comments would be helpful for improving clarity.
  • I think that there are some mis-matches on units (kg/hr vs kg/dt)
  • I think the looping in the compute() method could be removed.
  • using I for current and J for current density would help me follow the logic!

Overall - this is a great start! I haven't dug into the doc page or the examples or tests yet but would love to on a re-review! Let me know if you want to chat about any of my comments sometime! Happy to hop on a call!

Comment on lines +244 to +256
self.f_c = 96485.33 # Faraday's constant in A/mol
self.M_H2 = 0.002016 # Molar mass of H2 in kg/mol
self.M_O2 = 0.032 # Molar mass of O2 in kg/mol
self.M_H2O = 0.018 # Molar mass of H2O in kg/mol
self.Tref = 298.15 # Standard room temperature in K [25 deg Celsius]
self.cp_H2 = 14300 # Specific heat of H2 in J/(kg*K)
self.cp_air = 1005 # Specific heat of air in J/(kg*K)
self.cp_H2O = 4184 # Specific heat of water in J/(kg*K)
self.cp_N2 = 1040 # Specific heat of nitrogen in J/(kg*K)
self.cp_O2 = 918 # Specific heat of oxygen in J/(kg*K)
self.hhv_h2 = 141.8 * 1e6 # Higher heating value of hydrogen in J/kg
self.hhv_air = 0 # No higher heating value of air
self.hhv_H2O = 2260 # Higher heating value of water in J/kg

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some of these already exist in h2integrate/tools/constants.py. I think you could remove these as attributes and import the constants instead. Some of the constants that exist in constants.py are:

  • constants.py var = your variable equivalent
  • f_c = faraday
  • H_MW = M_H2*1e3/2
  • O2_MW = M_O2*1e3
  • H_MW*2 + (O2_MW/2) = M_H2O/1e3
  • HHV_H2_MJ_PER_KG = hhv_h2/1e6

I think the other constants you have here could be moved to constants.py also!

desc="Electricity command value for PEM fuel cell",
)

def compute(self, inputs, outputs):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that you need to set any variables as attributes in the compute() method. I think that most instances of self.var = something could be changed to var=something then use var instead of self.var elsewhere.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this locally and it seems like only f_c, M_H2, M_O2, and M_H2O are used.

Comment thread h2integrate/converters/hydrogen/PEM_h2_fuel_cell.py Outdated
# fuel_cell_efficiency_hhv: float = field(validator=range_val(0, 1))


def calc_current(power_ref, cell_area, n_cells, stack_number):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may not be fully appreciating what this function does, but I don't think the power_curve part is needed, power = current*voltage. I have some ideas on possible revisions but add those in later.

@elenya-grant elenya-grant Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what's partially confusing about this method is what scale it should be on. like power_ref is the power to the entire system. Then power is divided among stacks. Every cell in the stack has the same current and voltage. below is what I tried to do instead and it gave similar results as your method:

def calc_current_refactor(power_to_system,  cell_area, n_cells, n_stacks):
   # note: power_to_system should be in W
    J_curve = np.array([
        0.0356,
        0.05413333,
        0.0796,
        0.11366667,
        0.244,
        0.454,
    ]) # A/cm2
    voltage_curve = np.array([
        0.987,
        0.936,
        0.884,
        0.838,
        0.786,
        0.736])

    # 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)
    J_coefs = np.polyfit(P_curve, J_curve, 5)
    J_P_curve = np.poly1d(J_coefs)

    power_per_stack = power_to_system/n_stacks
    
    stack_current_density = J_P_curve(power_per_stack)
    stack_current = stack_current_density*cell_area

    cell_voltage = V_J_curve(stack_current_density)

    return stack_current, cell_voltage, V_J_curve

Comment on lines +103 to +105
I_cell = max(power_I_curve(power_density), 0)
V_cell = V_I_curve(I_cell)
I_cell = I_cell * cell_area

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may need to work out the units for myself to check the logic here but I think that if you're doing this:

I_cell = I_cell * cell_area

that makes me think that current_curve is actually current density (A/cm2), even though the comment says A. could you clarify whether the I_cell being returned is current or current density and similar question for current_curve?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the current curve is for current density! I'll clarify that!

"oxygen_consumed",
val=0.0,
shape=self.n_timesteps,
units="kg/h",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the units are f"kg/({self.dt}*s)" for the hydrogen consumed, oxygen consumed, and the water output.

)

# Calculate hydrogen and oxygen consumed
H2_consumed_rate = ((I_cell * self.N_series * self.M_H2) / (2.0 * self.f_c)) * (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it may be nice to split this line up a bit:

h2_consumption_rate_stack =(n_cells * I_cell) / (2 * faradays) # mol/s
h2_consumption_rate_system = n_stacks*h2_consumption_rate_stack*H2_MW/1e3 # kg/s
h2_consumed = h2_consumption_rate_system*self.dt

h2o_generated = np.zeros(self.n_timesteps)
commodity_out = np.zeros(self.n_timesteps)

for i in range(self.n_timesteps):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this loop could be removed?

@elenya-grant elenya-grant Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here's a potentially alternative approach to the looping

        power_demanded = np.clip(inputs[f"{self.commodity}_command_value"], a_min=0.0, a_max=inputs["system_capacity"])
        i_stack, v_cell, V_J_curve = calc_current_refactor(power_demanded*1e3, cell_active_area, n_cells, self.config.n_stacks)
        h2_in_kg_per_s = inputs["hydrogen_in"]/3600
        o2_in_kg_per_s = inputs["oxygen_in"]/3600
        # stack current
        i_from_h2 = (h2_in_kg_per_s*2*f_c)/(M_H2*self.config.n_stacks*n_cells)
        i_from_o2 = (o2_in_kg_per_s*4*f_c)/(M_O2*self.config.n_stacks*n_cells)

        i_op = np.minimum.reduce([i_stack, i_from_h2, i_from_o2])
        j_op = i_op/cell_active_area
        v_op = V_J_curve(j_op)
        power_out = v_op*n_cells*i_op*self.config.n_stacks/1e3 # kW
        H2_consumed_rate = ((i_op * M_H2) / (2.0 * f_c)) * (
                3600 * self.config.n_stacks * n_cells
            )  # kg/hr
        O2_consumed_rate = ((i_op * M_O2) / (4.0 * f_c)) * (
            3600 * self.config.n_stacks * n_cells
        )  # kg/hr

note that calc_current_refactor is a function that I put in an above comment.

# is n_cells = N_series?
self.N_series = 1
self.stack_size = self.config.system_capacity_kw / self.config.n_stacks
self.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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should cell active area be recalculated so that stack_size = n_cells*cell_active_area*power_dens?

# print(self.stack_size, self.n_cells)

# TODO:
if H2_consumed_rate > H2in or O2_consumed_rate > O2in:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

H2in and H2_consumed_rate won't be in the same units for non-hourly timesteps. H2_consumed_rate is in kg/dt but H2in is in kg/h.

Genevieve Starke and others added 2 commits July 6, 2026 16:50
Co-authored-by: elenya-grant <116225007+elenya-grant@users.noreply.github.com>
@elenya-grant elenya-grant added the needs modifications This PR has been reviewed, at least partially, and is ready for PR author response label Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs modifications This PR has been reviewed, at least partially, and is ready for PR author response

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants