Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- Adds `H2IntegrateModel`, `load_yaml`, `write_yaml`, and `write_readable_yaml` as package-level imports [PR 728](https://github.com/NatLabRockies/H2Integrate/pull/728).
- Update N2 diagram for Pyomo heuristic control from static image to dynamic and interactive embedded diagram [PR 726](https://github.com/NatLabRockies/H2Integrate/pull/726)
- Added ability to use timeseries for finance calculations [PR 725](https://github.com/NatLabRockies/H2Integrate/pull/725)
- Added multivariable purge gas stream output to `AmmoniaSynLoopPerformanceModel` [PR 760](https://github.com/NatLabRockies/H2Integrate/pull/760)
- Add `constant` pricing mode for Grid cost models, allowing an explicit scalar price configuration alongside `per_timestep` and `per_year` modes. [PR 764](https://github.com/NatLabRockies/H2Integrate/pull/764)

## 0.8 [April 15, 2026]
Expand Down
27 changes: 27 additions & 0 deletions docs/technology_models/ammonia.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,30 @@ Ammonia is a common fertilizer, and also has the potential to serve as a lower-c
1. The 'Simple' Ammonia Model: A model that only uses the ratios of feedstocks to ammonia output as performance parameters. This model is found at `h2integrate/converters/ammonia/simple_ammonia_model.py`. This model is based off of ASPEN modeling performed at Argonne National Lab (ANL) by [Lee et al.](https:/doi.org/10.1039/d2gc00843b), and was orginally developed for H2I by [Reznicek et al.](https://doi.org/10.1016/j.crsus.2025.100338). The cost modeling in this model directly follows the two above studies. One of the plants included in the Reznicek et al study is shown in the example `examples/02_texas_ammonia/`.

2. The Synloop Ammonia Model: This model allows direct stream table measurements (or modeled values) from an ammonia synthesis loop to be used as performance parameters. The cost parameters are largely the same as the simple ammonia model. This model is found at `h2integrate/converters/ammonia/ammonia_synloop.py`. The example in `examples/12_ammonia_synloop/` uses mostly the same parameters as those used by Reznicek et al., but with updated capex values for the air separator and synthesis loop derived from an NETL baseline study of ammonia production by [Brasington et al.](https://doi.org/10.2172/1515254)

## Synloop purge gas output

The Synloop Ammonia Model exposes the purge gas exiting the synthesis loop as a **multivariable stream** called `process_gas_mixture`. This is a general-purpose stream type for process gas mixtures that can be reused by other components. It bundles seven constituent variables into a single connection type:

| Variable | Units | Description |
|---|---|---|
| `mass_flow` | kg/h | Total gas mass flow rate |
| `hydrogen_mass_fraction` | unitless | Mass fraction of hydrogen in the gas stream |
| `nitrogen_mass_fraction` | unitless | Mass fraction of nitrogen in the gas stream |
| `argon_mass_fraction` | unitless | Mass fraction of argon in the gas stream |
| `ammonia_mass_fraction` | unitless | Mass fraction of ammonia in the gas stream |
| `temperature` | K | Gas stream temperature |
| `pressure` | bar | Gas stream pressure |

The purge gas composition is determined by the `purge_gas_x_h2`, `purge_gas_x_n2`, `purge_gas_x_ar`, and `purge_gas_x_nh3` molar fractions in the performance config, which are converted to mass fractions using the molecular weights of all species. The total purge gas mass flow is `purge_gas_mass_ratio × ammonia_produced`.

The `hydrogen_out` and `nitrogen_out` outputs of the synloop model represent only the **unused feedstock** that was available but not consumed. Purge gas hydrogen and nitrogen are reported separately through the `process_gas_mixture` stream. This separation allows downstream components (such as a hydrogen recovery unit or a recycle loop) to receive the purge gas as a distinct physical stream with its own temperature, pressure, and composition.

To connect the purge gas to a downstream consumer at the plant level, add a single interconnection line:

```yaml
technology_interconnections:
- [ammonia, purge_gas_consumer, process_gas_mixture]
```

The framework handles connecting all constituent variables automatically. See `examples/32_multivariable_streams/` for a general example of multivariable stream connections.
63 changes: 53 additions & 10 deletions h2integrate/converters/ammonia/ammonia_synloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@

from h2integrate.core.utilities import merge_shared_inputs
from h2integrate.core.validators import gt_zero, range_val
from h2integrate.tools.constants import H_MW, N_MW
from h2integrate.tools.constants import H_MW, N_MW, AR_MW
from h2integrate.core.model_baseclasses import (
CostModelBaseClass,
CostModelBaseConfig,
ResizeablePerformanceModelBaseClass,
ResizeablePerformanceModelBaseConfig,
)
from h2integrate.tools.inflation.inflate import inflate_cpi, inflate_cepci
from h2integrate.core.commodity_stream_definitions import add_multivariable_output


@define(kw_only=True)
Expand Down Expand Up @@ -88,6 +89,12 @@ class AmmoniaSynLoopPerformanceModel(ResizeablePerformanceModelBaseClass):
hydrogen, nitrogen, and electricity (as heat), as well as the total ammonia
produced over the modeled period.

Purge gas exiting the synthesis loop is exposed as a ``process_gas_mixture``
multivariable stream with mass flow, hydrogen/nitrogen mass fractions,
temperature, and pressure. The ``hydrogen_out`` and ``nitrogen_out`` outputs
represent only unused feedstock (not consumed by the reactor) and no longer
include the purge gas contribution.

Attributes
----------
config : AmmoniaSynLoopPerformanceConfig
Expand All @@ -108,13 +115,27 @@ class AmmoniaSynLoopPerformanceModel(ResizeablePerformanceModelBaseClass):
ammonia_out : array [kg/h]
Hourly ammonia produced by the synthesis loop.
nitrogen_out : array [kg/h]
Hourly unused nitrogen after synthesis loop.
Hourly unused nitrogen feedstock (does not include purge gas).
hydrogen_out : array [kg/h]
Hourly unused hydrogen after synthesis loop.
Hourly unused hydrogen feedstock (does not include purge gas).
electricity_out : array [MW]
Hourly unused electricity after synthesis loop.
heat_out : array [MW]
Hourly heat generated by synthesis loop.
process_gas_mixture:mass_flow_out : array [kg/h]
Hourly total purge gas mass flow rate.
process_gas_mixture:hydrogen_mass_fraction_out : array [-]
Mass fraction of hydrogen in the purge gas.
process_gas_mixture:nitrogen_mass_fraction_out : array [-]
Mass fraction of nitrogen in the purge gas.
process_gas_mixture:argon_mass_fraction_out : array [-]
Mass fraction of argon in the purge gas.
process_gas_mixture:ammonia_mass_fraction_out : array [-]
Mass fraction of ammonia in the purge gas.
process_gas_mixture:temperature_out : array [K]
Purge gas temperature.
process_gas_mixture:pressure_out : array [bar]
Purge gas pressure.
catalyst_mass: float [kg]
Total catalyst mass needed in synthesis loop.
total_ammonia_produced : float [kg/year]
Expand Down Expand Up @@ -185,6 +206,9 @@ def setup(self):
self.add_output("limiting_input", val=0, shape=self.n_timesteps, units="unitless")
self.add_output("max_hydrogen_capacity", val=1000.0, units="kg/h")

# Purge gas as a multivariable stream output
add_multivariable_output(self, "process_gas_mixture", self.n_timesteps)

def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
# Get config values
nh3_cap = inputs["ammonia_production_capacity"][0]
Expand Down Expand Up @@ -254,22 +278,41 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
used_n2 = nh3_prod * n2_rate
used_elec = nh3_prod * energy_demand # kW

# Calculate output in purge gas
purge_mw = x_h2_purge * H_MW * 2 + x_n2_purge * N_MW * 2 # g / mol
# Calculate purge gas composition
x_ar_purge = self.config.purge_gas_x_ar # mol frac
x_nh3_purge = self.config.purge_gas_x_nh3 # mol frac
purge_mw = (
x_h2_purge * H_MW * 2
+ x_n2_purge * N_MW * 2
+ x_ar_purge * AR_MW
+ x_nh3_purge * (N_MW + 3 * H_MW)
) # g / mol (effective molar mass of purge gas)

w_h2_purge = x_h2_purge * H_MW * 2 / purge_mw # kg H2 / kg purge gas
h2_purge = w_h2_purge * ratio_purge * nh3_prod # kg H2 / hr

w_n2_purge = x_n2_purge * N_MW * 2 / purge_mw # kg N2 / kg purge gas
n2_purge = w_n2_purge * ratio_purge * nh3_prod # kg N2 / hr
w_ar_purge = x_ar_purge * AR_MW / purge_mw # kg Ar / kg purge gas
w_nh3_purge = x_nh3_purge * (N_MW + 3 * H_MW) / purge_mw # kg NH3 / kg purge gas

# Total purge gas mass flow
purge_total = ratio_purge * nh3_prod # kg/h total purge

# Populate the purge gas multivariable stream (process_gas_mixture)
outputs["process_gas_mixture:mass_flow_out"] = purge_total
outputs["process_gas_mixture:hydrogen_mass_fraction_out"] = w_h2_purge
outputs["process_gas_mixture:nitrogen_mass_fraction_out"] = w_n2_purge
outputs["process_gas_mixture:argon_mass_fraction_out"] = w_ar_purge
outputs["process_gas_mixture:ammonia_mass_fraction_out"] = w_nh3_purge
outputs["process_gas_mixture:temperature_out"] = self.config.purge_gas_t
outputs["process_gas_mixture:pressure_out"] = self.config.purge_gas_p

# Calculate catalyst mass
cat_rate = cat_consume * nh3_prod # kg Cat / hr
cat_mass = np.sum(cat_rate) * cat_replace # kg

outputs["ammonia_out"] = nh3_prod
outputs["hydrogen_out"] = h2_in - used_h2 + h2_purge
outputs["nitrogen_out"] = n2_in - used_n2 + n2_purge
# Unused feedstock only (purge gas now in separate stream)
outputs["hydrogen_out"] = h2_in - used_h2
outputs["nitrogen_out"] = n2_in - used_n2
outputs["electricity_out"] = elec_in - used_elec # kW
outputs["heat_out"] = nh3_prod * heat_output
outputs["catalyst_mass"] = cat_mass
Expand Down
101 changes: 101 additions & 0 deletions h2integrate/converters/ammonia/test/test_ammonia_synloop_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,69 @@ def test_ammonia_synloop_outputs(synloop_config, subtests):
< prob.get_val("comp.electricity_in", units="kW").max()
)

# Test purge gas multivariable stream outputs exist and have correct shape
with subtests.test("process_gas_mixture:mass_flow_out shape"):
purge_flow = prob.get_val("comp.process_gas_mixture:mass_flow_out", units="kg/h")
assert len(purge_flow) == n_timesteps

with subtests.test("process_gas_mixture:mass_flow_out > 0"):
assert np.all(purge_flow > 0)

with subtests.test("process_gas_mixture:hydrogen_mass_fraction_out shape"):
purge_h2_frac = prob.get_val(
"comp.process_gas_mixture:hydrogen_mass_fraction_out", units="unitless"
)
assert len(purge_h2_frac) == n_timesteps

with subtests.test("process_gas_mixture:hydrogen_mass_fraction_out in [0, 1]"):
assert np.all(purge_h2_frac >= 0) and np.all(purge_h2_frac <= 1)

with subtests.test("process_gas_mixture:nitrogen_mass_fraction_out shape"):
purge_n2_frac = prob.get_val(
"comp.process_gas_mixture:nitrogen_mass_fraction_out", units="unitless"
)
assert len(purge_n2_frac) == n_timesteps

with subtests.test("process_gas_mixture:nitrogen_mass_fraction_out in [0, 1]"):
assert np.all(purge_n2_frac >= 0) and np.all(purge_n2_frac <= 1)

with subtests.test("process_gas_mixture:argon_mass_fraction_out shape"):
purge_ar_frac = prob.get_val(
"comp.process_gas_mixture:argon_mass_fraction_out", units="unitless"
)
assert len(purge_ar_frac) == n_timesteps

with subtests.test("process_gas_mixture:argon_mass_fraction_out in [0, 1]"):
assert np.all(purge_ar_frac >= 0) and np.all(purge_ar_frac <= 1)

with subtests.test("process_gas_mixture:ammonia_mass_fraction_out shape"):
purge_nh3_frac = prob.get_val(
"comp.process_gas_mixture:ammonia_mass_fraction_out", units="unitless"
)
assert len(purge_nh3_frac) == n_timesteps

with subtests.test("process_gas_mixture:ammonia_mass_fraction_out in [0, 1]"):
assert np.all(purge_nh3_frac >= 0) and np.all(purge_nh3_frac <= 1)

with subtests.test("process_gas_mixture:temperature_out shape"):
purge_t = prob.get_val("comp.process_gas_mixture:temperature_out", units="K")
assert len(purge_t) == n_timesteps

with subtests.test("process_gas_mixture:pressure_out shape"):
purge_p = prob.get_val("comp.process_gas_mixture:pressure_out", units="bar")
assert len(purge_p) == n_timesteps

# hydrogen_out and nitrogen_out should be unused feedstock only (no purge gas)
with subtests.test("hydrogen_out == hydrogen_in - hydrogen_consumed"):
h2_out = prob.get_val("comp.hydrogen_out", units="kg/h")
h2_consumed = prob.get_val("comp.hydrogen_consumed", units="kg/h")
assert np.allclose(h2_out, h2 - h2_consumed)

with subtests.test("nitrogen_out == nitrogen_in - nitrogen_consumed"):
n2_out = prob.get_val("comp.nitrogen_out", units="kg/h")
n2_consumed = prob.get_val("comp.nitrogen_consumed", units="kg/h")
assert np.allclose(n2_out, n2 - n2_consumed)


@pytest.mark.regression
def test_ammonia_synloop_limiting_cases(synloop_config, subtests):
Expand Down Expand Up @@ -222,6 +285,44 @@ def test_ammonia_synloop_limiting_cases(synloop_config, subtests):
with subtests.test("Total ammonia"):
assert np.allclose(total, np.sum(expected_nh3), rtol=1e-6)

# Check purge gas multivariable stream outputs
purge_flow = prob.get_val("synloop.process_gas_mixture:mass_flow_out", units="kg/h")
purge_h2_frac = prob.get_val(
"synloop.process_gas_mixture:hydrogen_mass_fraction_out", units="unitless"
)
purge_n2_frac = prob.get_val(
"synloop.process_gas_mixture:nitrogen_mass_fraction_out", units="unitless"
)
purge_temp = prob.get_val("synloop.process_gas_mixture:temperature_out", units="K")
purge_pres = prob.get_val("synloop.process_gas_mixture:pressure_out", units="bar")

with subtests.test("Purge gas mass flow = ratio_purge * nh3_prod"):
expected_purge_flow = 0.07 * expected_nh3
assert np.allclose(purge_flow, expected_purge_flow, rtol=1e-6)

with subtests.test("Purge gas H2 fraction constant"):
assert np.all(purge_h2_frac == purge_h2_frac[0])

with subtests.test("Purge gas N2 fraction constant"):
assert np.all(purge_n2_frac == purge_n2_frac[0])

with subtests.test("Purge gas temperature matches config"):
assert np.allclose(purge_temp, 7.5)

with subtests.test("Purge gas pressure matches config"):
assert np.allclose(purge_pres, 275.0)

# Verify hydrogen_out and nitrogen_out no longer include purge gas
with subtests.test("hydrogen_out = h2_in - used_h2 (no purge)"):
h2_out = prob.get_val("synloop.hydrogen_out", units="kg/h")
h2_consumed = prob.get_val("synloop.hydrogen_consumed", units="kg/h")
assert np.allclose(h2_out, h2 - h2_consumed)

with subtests.test("nitrogen_out = n2_in - used_n2 (no purge)"):
n2_out = prob.get_val("synloop.nitrogen_out", units="kg/h")
n2_consumed = prob.get_val("synloop.nitrogen_consumed", units="kg/h")
assert np.allclose(n2_out, n2 - n2_consumed)


@pytest.mark.regression
def test_size_mode_outputs(subtests, temp_dir):
Expand Down
31 changes: 30 additions & 1 deletion h2integrate/core/commodity_stream_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,36 @@
"desc": "Pressure of the gas stream",
},
},
# Future multivariable stream definitions can be added here
"process_gas_mixture": {
"mass_flow": {
"units": "kg/h",
"desc": "Total gas mass flow rate",
},
"hydrogen_mass_fraction": {
"units": "unitless",
"desc": "Mass fraction of hydrogen in the gas stream",
},
"nitrogen_mass_fraction": {
"units": "unitless",
"desc": "Mass fraction of nitrogen in the gas stream",
},
"argon_mass_fraction": {
"units": "unitless",
"desc": "Mass fraction of argon in the gas stream",
},
"ammonia_mass_fraction": {
"units": "unitless",
"desc": "Mass fraction of ammonia in the gas stream",
},
"temperature": {
"units": "K",
"desc": "Gas stream temperature",
},
"pressure": {
"units": "bar",
"desc": "Gas stream pressure",
},
},
}


Expand Down
67 changes: 67 additions & 0 deletions h2integrate/core/test/test_commodity_stream_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,70 @@ def test_add_multivariable_invalid_stream():
add_multivariable_output(component, "nonexistent_stream", 10)
with pytest.raises(KeyError):
add_multivariable_input(component, "nonexistent_stream", 10)


@pytest.mark.unit
def test_add_process_gas_mixture_output(subtests):
stream_name = "process_gas_mixture"
n_timesteps = 8760
component = MagicMock()

add_multivariable_output(component, stream_name, n_timesteps)

stream_def = multivariable_streams[stream_name]

with subtests.test("called once per variable"):
assert component.add_output.call_count == len(stream_def)

with subtests.test("correct variable names"):
expected_calls = [
call(
f"{stream_name}:{var_name}_out",
val=0.0,
shape=n_timesteps,
units=var_props.get("units"),
desc=var_props.get("desc", ""),
)
for var_name, var_props in stream_def.items()
]
component.add_output.assert_has_calls(expected_calls, any_order=False)

with subtests.test("has expected variables"):
called_names = [c.args[0] for c in component.add_output.call_args_list]
expected_vars = [
"process_gas_mixture:mass_flow_out",
"process_gas_mixture:hydrogen_mass_fraction_out",
"process_gas_mixture:nitrogen_mass_fraction_out",
"process_gas_mixture:argon_mass_fraction_out",
"process_gas_mixture:ammonia_mass_fraction_out",
"process_gas_mixture:temperature_out",
"process_gas_mixture:pressure_out",
]
assert called_names == expected_vars


@pytest.mark.unit
def test_add_process_gas_mixture_input(subtests):
stream_name = "process_gas_mixture"
n_timesteps = 8760
component = MagicMock()

add_multivariable_input(component, stream_name, n_timesteps)

stream_def = multivariable_streams[stream_name]

with subtests.test("called once per variable"):
assert component.add_input.call_count == len(stream_def)

with subtests.test("has expected variables"):
called_names = [c.args[0] for c in component.add_input.call_args_list]
expected_vars = [
"process_gas_mixture:mass_flow_in",
"process_gas_mixture:hydrogen_mass_fraction_in",
"process_gas_mixture:nitrogen_mass_fraction_in",
"process_gas_mixture:argon_mass_fraction_in",
"process_gas_mixture:ammonia_mass_fraction_in",
"process_gas_mixture:temperature_in",
"process_gas_mixture:pressure_in",
]
assert called_names == expected_vars
Loading