From 0fe2a7614204b786a9e293e38c2b49cb9c27e80d Mon Sep 17 00:00:00 2001 From: John Jasa Date: Mon, 18 May 2026 17:10:22 -0600 Subject: [PATCH 1/2] Added purge gas as a multivariable output --- docs/technology_models/ammonia.md | 27 +++++ .../converters/ammonia/ammonia_synloop.py | 63 +++++++++-- .../test/test_ammonia_synloop_model.py | 101 ++++++++++++++++++ .../core/commodity_stream_definitions.py | 31 +++++- .../test/test_commodity_stream_definitions.py | 67 ++++++++++++ 5 files changed, 278 insertions(+), 11 deletions(-) diff --git a/docs/technology_models/ammonia.md b/docs/technology_models/ammonia.md index 7f0c72785..08f919679 100644 --- a/docs/technology_models/ammonia.md +++ b/docs/technology_models/ammonia.md @@ -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. diff --git a/h2integrate/converters/ammonia/ammonia_synloop.py b/h2integrate/converters/ammonia/ammonia_synloop.py index ac1308c29..30e90c172 100644 --- a/h2integrate/converters/ammonia/ammonia_synloop.py +++ b/h2integrate/converters/ammonia/ammonia_synloop.py @@ -3,7 +3,7 @@ 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, @@ -11,6 +11,7 @@ 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) @@ -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 @@ -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] @@ -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] @@ -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 diff --git a/h2integrate/converters/ammonia/test/test_ammonia_synloop_model.py b/h2integrate/converters/ammonia/test/test_ammonia_synloop_model.py index 2c641d313..4145014c7 100644 --- a/h2integrate/converters/ammonia/test/test_ammonia_synloop_model.py +++ b/h2integrate/converters/ammonia/test/test_ammonia_synloop_model.py @@ -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): @@ -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): diff --git a/h2integrate/core/commodity_stream_definitions.py b/h2integrate/core/commodity_stream_definitions.py index e52cc96b6..dc5bfebfe 100644 --- a/h2integrate/core/commodity_stream_definitions.py +++ b/h2integrate/core/commodity_stream_definitions.py @@ -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", + }, + }, } diff --git a/h2integrate/core/test/test_commodity_stream_definitions.py b/h2integrate/core/test/test_commodity_stream_definitions.py index 7eb29c5b5..98339c59e 100644 --- a/h2integrate/core/test/test_commodity_stream_definitions.py +++ b/h2integrate/core/test/test_commodity_stream_definitions.py @@ -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 From 57bbff2aad9a663b5af725a49f43a00d6a233299 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 19 May 2026 08:49:16 -0600 Subject: [PATCH 2/2] Added to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d4744c7..edd72d17e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ## 0.8 [April 15, 2026] - Updated README and docs intro page with expanded H2I description, reorganized sections, and streamlined installation instructions [PR 677](https://github.com/NatLabRockies/H2Integrate/pull/677)