From e117db0faa0448457060d490e6447b72d071aa3a Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Tue, 2 Jun 2026 11:33:45 -0600 Subject: [PATCH 01/12] move tests for split indices --- h2integrate/core/h2integrate_model.py | 72 ++++++++++++++++++++- h2integrate/core/inputs/plant_schema.yaml | 2 +- h2integrate/core/test/test_split_indices.py | 63 ++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 h2integrate/core/test/test_split_indices.py diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 65cd0c4ee..1c0412744 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -1240,8 +1240,14 @@ def connect_technologies(self): ) elif len(connection) == 3: + # initialize src_indices to allow connections between different shaped variables + if isinstance(connection[-1], list): + source_tech, dest_tech, connected_parameter = connection + connected_parameter, src_indices = ( + self._split_indices_from_connected_parameter_definition(connected_parameter) + ) + # connect directly from source to dest - source_tech, dest_tech, connected_parameter = connection if isinstance(connected_parameter, tuple | list): source_parameter, dest_parameter = connected_parameter # Check if this is a multivariable stream connection @@ -1255,7 +1261,9 @@ def connect_technologies(self): ) else: self.plant.connect( - f"{source_tech}.{source_parameter}", f"{dest_tech}.{dest_parameter}" + f"{source_tech}.{source_parameter}", + f"{dest_tech}.{dest_parameter}", + src_indices=src_indices, ) else: # Check if the connected_parameter is a multivariable stream @@ -1271,6 +1279,7 @@ def connect_technologies(self): self.plant.connect( f"{source_tech}.{connected_parameter}", f"{dest_tech}.{connected_parameter}", + src_indices=src_indices, ) else: @@ -1810,3 +1819,62 @@ def _get_commodity_for_tech(self, tech_name): tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] return tech_commodities + + @staticmethod + def _split_indices_from_connected_parameter_definition(connected_parameter): + """Extract and parse slice indices from connected parameter definitions for OpenMDAO + connections. + + This function processes parameter names containing slice patterns in square brackets + (e.g., "power[0:8760]") and generates OpenMDAO-compatible src_indices for connections + between variables of different shapes. + + Args: + connected_parameter (list[str]): A two-element list containing: + - [0] source parameter name, optionally with pattern like "var[slice_spec]" + - [1] destination parameter name, optionally with pattern like "var[slice_spec]" + + Example: ["power[0:8760]", "demand[:]"] + + Returns: + tuple: A two-element tuple containing: + - connected_parameter (list[str]): The parameter names with slices removed + (e.g., ["power", "demand"]) + - src_indices: OpenMDAO slicer object for indexing source outputs to match + destination input shapes. Returns om.slicer[slice] for indexing. + + Note: + If the destination has a slice pattern, it must include the length ":N" + (e.g., "[0:N]"), the function extracts N as the destination length and + multiplies the source slice by this factor to create properly scaled indices. + The length is required because the length is not known in the OpenMDAO model + until prob.setup() has been called. + """ + source_parameter, dest_parameter = connected_parameter + + # Regex pattern to match anything enclosed in square brackets + pattern = re.compile(r"\[.*?\]") + + source_match = pattern.search(source_parameter) + dest_match = pattern.search(dest_parameter) + + # Extract the matched slice pattern or None if no pattern found + source_slice = source_match if source_match is None else source_match.group() + dest_slice = dest_match if dest_match is None else dest_match.group() + if source_slice == dest_slice: + src_indices = None + elif dest_slice is not None and source_slice is not None: + pattern = re.compile(r":(\d+)") + dest_length = int(pattern.search(dest_slice).group().split(":")[-1]) + + # Scale source indices by destination length to handle shape mismatches + # Example: source_slice="[0:1]" with dest_length=8760 -> multiply 1 by 8760 + src_indices = eval(f"om.slicer[{source_slice} * {dest_length}]") + else: + # No destination slice pattern; use source slice pattern directly + src_indices = None if source_slice is None else eval(f"om.slicer{source_slice}") + + # Remove the slice patterns from parameter names to get clean names + connected_parameter = [source_parameter.split("[")[0], dest_parameter.split("[")[0]] + + return connected_parameter, src_indices diff --git a/h2integrate/core/inputs/plant_schema.yaml b/h2integrate/core/inputs/plant_schema.yaml index 1254f65e4..563d28bca 100644 --- a/h2integrate/core/inputs/plant_schema.yaml +++ b/h2integrate/core/inputs/plant_schema.yaml @@ -71,6 +71,6 @@ properties: items: type: array items: - type: [string, array] + type: [string, array, number] description: Technology names and connection types required: [name, description] diff --git a/h2integrate/core/test/test_split_indices.py b/h2integrate/core/test/test_split_indices.py new file mode 100644 index 000000000..dca232293 --- /dev/null +++ b/h2integrate/core/test/test_split_indices.py @@ -0,0 +1,63 @@ +"""Tests for _split_indices_from_connected_parameter_definition function.""" + +import pytest +import openmdao.api as om + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +class TestSplitIndicesFromConnectedParameterDefinition: + """Test suite for _split_indices_from_connected_parameter_definition method.""" + + @staticmethod + def split_indices(connected_parameter): + """Helper to call the static method.""" + return H2IntegrateModel._split_indices_from_connected_parameter_definition( + connected_parameter + ) + + @pytest.mark.unit + def test_no_slices(self): + """Test with parameter names that have no slice patterns.""" + params, src_indices = self.split_indices(["power_out", "power_in"]) + assert params == ["power_out", "power_in"] + assert src_indices is None + + @pytest.mark.unit + def test_source_slice_only(self): + """Test with slice pattern only in source parameter.""" + params, src_indices = self.split_indices(["power_out[0:100]", "power_in"]) + assert params == ["power_out", "power_in"] + assert src_indices == om.slicer[0:100] + + @pytest.mark.unit + def test_dest_slice_only(self): + """Test with slice pattern only in destination parameter.""" + params, src_indices = self.split_indices(["power_out", "power_in[0:8760]"]) + assert params == ["power_out", "power_in"] + assert src_indices is None + + @pytest.mark.unit + def test_both_slices(self): + """Test with slice patterns in both parameters.""" + params, src_indices = self.split_indices(["power_out[0]", "power_in[0:8760]"]) + assert params == ["power_out", "power_in"] + assert src_indices == om.slicer[[0] * 8760] + + @pytest.mark.unit + def test_full_range_source_slice(self): + """Test extraction of full range slices like [:].""" + params, src_indices = self.split_indices(["source[:]", "dest"]) + assert params == ["source", "dest"] + assert src_indices == om.slicer[:] + + @pytest.mark.unit + def test_stepped_range_source_slice(self): + """Test extraction of stepped slices like [0:100:2].""" + params, src_indices = self.split_indices(["source[0:100:2]", "dest"]) + assert params == ["source", "dest"] + assert src_indices == om.slicer[0:100:2] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 58664693efeb5c2ca2dbbb04149618db82bfb0bb Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Tue, 2 Jun 2026 12:25:13 -0600 Subject: [PATCH 02/12] revert obsolete changes to plant schema --- h2integrate/core/inputs/plant_schema.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h2integrate/core/inputs/plant_schema.yaml b/h2integrate/core/inputs/plant_schema.yaml index 563d28bca..1254f65e4 100644 --- a/h2integrate/core/inputs/plant_schema.yaml +++ b/h2integrate/core/inputs/plant_schema.yaml @@ -71,6 +71,6 @@ properties: items: type: array items: - type: [string, array, number] + type: [string, array] description: Technology names and connection types required: [name, description] From 8f8d5b0be3c2a46c035952f00c11b5d225ab6403 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 9 Jul 2026 13:00:53 -0600 Subject: [PATCH 03/12] fix bugs in length 3 connections for src_indices --- h2integrate/core/h2integrate_model.py | 6 ++++-- h2integrate/core/test/test_split_indices.py | 4 ---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 7c6e834a0..15d1f7983 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -1670,9 +1670,11 @@ def connect_technologies(self): ) elif len(connection) == 3: + source_tech, dest_tech, connected_parameter = connection + src_indices = None + # initialize src_indices to allow connections between different shaped variables - if isinstance(connection[-1], list): - source_tech, dest_tech, connected_parameter = connection + if isinstance(connected_parameter, list): connected_parameter, src_indices = ( self._split_indices_from_connected_parameter_definition(connected_parameter) ) diff --git a/h2integrate/core/test/test_split_indices.py b/h2integrate/core/test/test_split_indices.py index dca232293..4891a9958 100644 --- a/h2integrate/core/test/test_split_indices.py +++ b/h2integrate/core/test/test_split_indices.py @@ -57,7 +57,3 @@ def test_stepped_range_source_slice(self): params, src_indices = self.split_indices(["source[0:100:2]", "dest"]) assert params == ["source", "dest"] assert src_indices == om.slicer[0:100:2] - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) From 700db44496f05cde22af81f921ad567a9c51fab1 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 9 Jul 2026 15:00:44 -0600 Subject: [PATCH 04/12] remove use of eval --- h2integrate/core/h2integrate_model.py | 37 +++++++++++++++------------ 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 15d1f7983..71fbe8605 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -2289,29 +2289,34 @@ def _split_indices_from_connected_parameter_definition(connected_parameter): """ source_parameter, dest_parameter = connected_parameter - # Regex pattern to match anything enclosed in square brackets - pattern = re.compile(r"\[.*?\]") + def _extract_slice(parameter): + """Return the contents inside the brackets (e.g. '0:8760'), or None.""" + match = re.search(r"\[(.*)\]", parameter) + return None if match is None else match.group(1) - source_match = pattern.search(source_parameter) - dest_match = pattern.search(dest_parameter) + def _to_indices(spec): + """Convert a bracket spec string into a slice or list of ints.""" + if ":" in spec: + return slice(*(int(p) if p.strip() else None for p in spec.split(":"))) + return [int(p) for p in spec.split(",")] + + source_slice = _extract_slice(source_parameter) + dest_slice = _extract_slice(dest_parameter) - # Extract the matched slice pattern or None if no pattern found - source_slice = source_match if source_match is None else source_match.group() - dest_slice = dest_match if dest_match is None else dest_match.group() if source_slice == dest_slice: src_indices = None elif dest_slice is not None and source_slice is not None: - pattern = re.compile(r":(\d+)") - dest_length = int(pattern.search(dest_slice).group().split(":")[-1]) - - # Scale source indices by destination length to handle shape mismatches - # Example: source_slice="[0:1]" with dest_length=8760 -> multiply 1 by 8760 - src_indices = eval(f"om.slicer[{source_slice} * {dest_length}]") + # Scale source indices by destination length to handle shape mismatches. + # Example: source_slice="0" with dest_length=8760 -> [0] repeated 8760 times. + dest_length = int(dest_slice.split(":")[-1]) + src_indices = om.slicer[_to_indices(source_slice) * dest_length] else: - # No destination slice pattern; use source slice pattern directly - src_indices = None if source_slice is None else eval(f"om.slicer{source_slice}") + # No destination slice pattern; use source slice pattern directly. + src_indices = None if source_slice is None else om.slicer[_to_indices(source_slice)] - # Remove the slice patterns from parameter names to get clean names + # Remove the slice patterns from parameter names to get clean names. connected_parameter = [source_parameter.split("[")[0], dest_parameter.split("[")[0]] + import pdb + pdb.set_trace() return connected_parameter, src_indices From f414c21a50a945fd52bcb91a1d59be45ae3f6fbb Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 9 Jul 2026 15:30:25 -0600 Subject: [PATCH 05/12] add ValueError for non-zero destination slice starts --- h2integrate/core/h2integrate_model.py | 7 +++++-- h2integrate/core/test/test_split_indices.py | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 71fbe8605..ea7ea4167 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -2308,6 +2308,11 @@ def _to_indices(spec): elif dest_slice is not None and source_slice is not None: # Scale source indices by destination length to handle shape mismatches. # Example: source_slice="0" with dest_length=8760 -> [0] repeated 8760 times. + if dest_slice.split(":")[0] not in ("", "0"): + raise ValueError( + "A non-zero start was provided for the slice for destination " + f"parameter <{dest_parameter}>" + ) dest_length = int(dest_slice.split(":")[-1]) src_indices = om.slicer[_to_indices(source_slice) * dest_length] else: @@ -2316,7 +2321,5 @@ def _to_indices(spec): # Remove the slice patterns from parameter names to get clean names. connected_parameter = [source_parameter.split("[")[0], dest_parameter.split("[")[0]] - import pdb - pdb.set_trace() return connected_parameter, src_indices diff --git a/h2integrate/core/test/test_split_indices.py b/h2integrate/core/test/test_split_indices.py index 4891a9958..ff09dbb8e 100644 --- a/h2integrate/core/test/test_split_indices.py +++ b/h2integrate/core/test/test_split_indices.py @@ -44,6 +44,13 @@ def test_both_slices(self): assert params == ["power_out", "power_in"] assert src_indices == om.slicer[[0] * 8760] + @pytest.mark.unit + def test_dest_partial_slice(self): + """Test that a non-zero destination slice start raises a ValueError.""" + error_msg = "A non-zero start was provided for the slice for destination parameter" + with pytest.raises(ValueError, match=error_msg): + self.split_indices(["power_out[0]", "power_in[2:8760]"]) + @pytest.mark.unit def test_full_range_source_slice(self): """Test extraction of full range slices like [:].""" From fcd3e021bdbe3fe69d9dc7638264e03cb953487d Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 9 Jul 2026 15:43:20 -0600 Subject: [PATCH 06/12] allow for src_indices tiling into destination --- h2integrate/core/h2integrate_model.py | 23 ++++++++++++++++++--- h2integrate/core/test/test_split_indices.py | 7 +++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index ea7ea4167..f5cf06b03 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -2306,15 +2306,32 @@ def _to_indices(spec): if source_slice == dest_slice: src_indices = None elif dest_slice is not None and source_slice is not None: - # Scale source indices by destination length to handle shape mismatches. - # Example: source_slice="0" with dest_length=8760 -> [0] repeated 8760 times. + # Tile the source indices to fill the destination length to handle shape + # mismatches. Examples: + # source="0", dest_length=8760 -> [0] repeated 8760 times + # source="0,1", dest_length=10 -> [0, 1] cycled to fill 10 slots if dest_slice.split(":")[0] not in ("", "0"): raise ValueError( "A non-zero start was provided for the slice for destination " f"parameter <{dest_parameter}>" ) dest_length = int(dest_slice.split(":")[-1]) - src_indices = om.slicer[_to_indices(source_slice) * dest_length] + + source_indices = _to_indices(source_slice) + if isinstance(source_indices, slice): + source_indices = list( + range( + source_indices.start or 0, + source_indices.stop, + source_indices.step or 1, + ) + ) + + # Repeat the source values enough times to cover the destination, then + # truncate so the result is exactly dest_length long. This cycles through + # the source values when the source is shorter than the destination. + n_repeats = -(-dest_length // len(source_indices)) # ceiling division + src_indices = om.slicer[(source_indices * n_repeats)[:dest_length]] else: # No destination slice pattern; use source slice pattern directly. src_indices = None if source_slice is None else om.slicer[_to_indices(source_slice)] diff --git a/h2integrate/core/test/test_split_indices.py b/h2integrate/core/test/test_split_indices.py index ff09dbb8e..09e24095d 100644 --- a/h2integrate/core/test/test_split_indices.py +++ b/h2integrate/core/test/test_split_indices.py @@ -44,6 +44,13 @@ def test_both_slices(self): assert params == ["power_out", "power_in"] assert src_indices == om.slicer[[0] * 8760] + @pytest.mark.unit + def test_both_slices_tiled(self): + """Test with slice patterns in both parameters.""" + params, src_indices = self.split_indices(["power_out[0,1]", "power_in[0:8760]"]) + assert params == ["power_out", "power_in"] + assert src_indices == om.slicer[[0, 1] * 4380] + @pytest.mark.unit def test_dest_partial_slice(self): """Test that a non-zero destination slice start raises a ValueError.""" From 3ca1e9715874295fa7ea483d0cad04ae535f6b9b Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 10 Jul 2026 09:27:17 -0600 Subject: [PATCH 07/12] update wind solar electrolyzer example to demonstrate different finance and connection approaches --- .../plant_config.yaml | 42 ++++++++++++++++++- .../run_wind_solar_electrolyzer.py | 18 ++++++++ .../tech_config.yaml | 36 ++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/examples/15_wind_solar_electrolyzer/plant_config.yaml b/examples/15_wind_solar_electrolyzer/plant_config.yaml index c5e1c38c8..402955359 100644 --- a/examples/15_wind_solar_electrolyzer/plant_config.yaml +++ b/examples/15_wind_solar_electrolyzer/plant_config.yaml @@ -24,10 +24,32 @@ sites: # with the reverse definition. # this will naturally grow as we mature the interconnected tech technology_interconnections: + # Physical plant: wind + solar are combined and used to run a single electrolyzer. + # This same physical plant is financed three different ways below (see finance_subgroups). - [wind, combiner, electricity, cable] - # source_tech, dest_tech, transport_item, transport_type = connection - [solar, combiner, electricity, cable] - [combiner, electrolyzer, electricity, cable] + + # --- Approach B wiring: upstream LCOE used as a feedstock price -------------- + # Compute the wind + solar levelized cost of electricity (LCOE) in the + # `electricity` finance subgroup, then charge the electrolyzer for the + # electricity it consumes at that LCOE through the `electricity_feedstock` tech. + # + # 1. Feed the computed LCOE in as the feedstock purchase price (scalar slice): + - [finance_subgroup_electricity, electricity_feedstock, [LCOE, price]] + # 2. Charge the feedstock for exactly the electricity the electrolyzer consumes: + - [electrolyzer, electricity_feedstock, electricity_consumed] + # 3. Provide the feedstock supply profile (used for its capacity factor): + - [electricity_feedstock_source, electricity_feedstock, electricity_out] + + # --- Approach C wiring: upstream LCOE used as a grid purchase price ---------- + # Same idea as Approach B, but the electricity is "purchased" through a + # `grid_buy` interconnection priced at the LCOE instead of a generic feedstock. + # + # 1. Feed the computed LCOE in as the grid buy price (scalar tiled to 8760 h): + - [finance_subgroup_electricity, grid_buy, ['LCOE[0]', 'electricity_buy_price[0:8760]']] + # 2. Buy from the grid exactly the electricity the electrolyzer consumes: + - [electrolyzer, grid_buy, [electricity_consumed, electricity_set_point]] resource_to_tech_connections: # connect the wind resource to the wind technology - [wind_site.wind_resource, wind, wind_resource_data] @@ -61,14 +83,32 @@ finance_parameters: depr_period: 5 # 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.] finance_subgroups: + # Upstream electricity finance group: levelized cost of the wind + solar + # electricity. Its LCOE output feeds Approaches B and C below. electricity: commodity: electricity commodity_stream: combiner # use the total electricity output from the combiner for the finance calc technologies: [wind, solar] + # Approach A - "same finance model": wind + solar + electrolyzer are all + # financed together, so the renewable capital is embedded directly in the LCOH. hydrogen: commodity: hydrogen commodity_stream: electrolyzer technologies: [wind, solar, electrolyzer] + # Approach B - "upstream LCOE feedstock": only the electrolyzer capital plus the + # purchased-electricity cost (electricity_feedstock priced at the LCOE above) + # are financed here. This should give an LCOH close to Approach A. + hydrogen_lcoe: + commodity: hydrogen + commodity_stream: electrolyzer + technologies: [electricity_feedstock, electrolyzer] + # Approach C - "grid buy": like Approach B, but the electricity is purchased + # through a grid_buy interconnection priced at the LCOE above. This should also + # give an LCOH close to Approaches A and B. + hydrogen_grid_buy: + commodity: hydrogen + commodity_stream: electrolyzer + technologies: [grid_buy, electrolyzer] cost_adjustment_parameters: cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year target_dollar_year: 2022 diff --git a/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py b/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py index 26cf38a94..3541e944a 100644 --- a/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py +++ b/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py @@ -8,3 +8,21 @@ model.run() model.post_process() + +# Compare three ways of financing the hydrogen produced by the same physical plant: +# Approach A ("same finance model"): wind + solar + electrolyzer financed together. +# Approach B ("upstream LCOE feedstock"): the electrolyzer buys electricity as a +# generic feedstock priced at the wind + solar LCOE. +# Approach C ("grid buy"): the electrolyzer buys electricity through a grid +# interconnection priced at the wind + solar LCOE. +# Approaches B and C should both give an identical result for LCOH, which should be +# close to the LOCH in Approach A. +lcoe = model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/kW/h")[0] +lcoh_integrated = model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg")[0] +lcoh_feedstock = model.prob.get_val("finance_subgroup_hydrogen_lcoe.LCOH", units="USD/kg")[0] +lcoh_grid_buy = model.prob.get_val("finance_subgroup_hydrogen_grid_buy.LCOH", units="USD/kg")[0] + +print(f"Upstream electricity LCOE: {lcoe:.4f} USD/kWh") +print(f"LCOH - A: wind + solar + electrolyzer together: {lcoh_integrated:.4f} USD/kg") +print(f"LCOH - B: electrolyzer + LCOE feedstock: {lcoh_feedstock:.4f} USD/kg") +print(f"LCOH - C: electrolyzer + grid buy at LCOE: {lcoh_grid_buy:.4f} USD/kg") diff --git a/examples/15_wind_solar_electrolyzer/tech_config.yaml b/examples/15_wind_solar_electrolyzer/tech_config.yaml index e7e7bf88d..a28b46a23 100644 --- a/examples/15_wind_solar_electrolyzer/tech_config.yaml +++ b/examples/15_wind_solar_electrolyzer/tech_config.yaml @@ -63,6 +63,42 @@ technologies: performance_parameters: commodity: electricity commodity_rate_units: kW + electricity_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + performance_parameters: + rated_capacity: 500000000.0 # kW + cost_parameters: + cost_year: 2022 + price: 0.0 # USD/kWh - to be set through connections + annual_cost: 0. + start_up_cost: 0. + # Grid interconnection used purely to purchase electricity for the electrolyzer + # (Approach C). NOTE: the tech name MUST start with "grid_buy" for the financial + # logic to treat its variable cost as a purchase (expense) rather than a coproduct. + grid_buy: + performance_model: + model: GridPerformanceModel + cost_model: + model: GridCostModel + model_inputs: + shared_parameters: + interconnection_size: 5.e8 # kW - large so it never limits the electrolyzer's purchases + cost_parameters: + cost_year: 2022 + # Purchase price is set through a connection to the wind + solar LCOE. + electricity_buy_price: 0.0 # USD/kWh - overwritten by the finance_subgroup_electricity.LCOE + buy_price_mode: per_timestep + # No interconnection capital/O&M charged here so only the energy cost matters. + interconnection_capex_per_kw: 0.0 # $/kW capital cost + interconnection_opex_per_kw: 0.0 # $/kW/year O&M cost + fixed_interconnection_cost: 0.0 # $ one-time fixed cost electrolyzer: performance_model: model: ECOElectrolyzerPerformanceModel From 2c85261632a3f18895db520e5853c12310cd4fbd Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 10 Jul 2026 09:51:18 -0600 Subject: [PATCH 08/12] add tests for each finance/connection approach added to wind solar electrolyzer example --- .../plant_config.yaml | 4 ++-- .../run_wind_solar_electrolyzer.py | 8 +++++-- examples/test/test_all_examples.py | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/examples/15_wind_solar_electrolyzer/plant_config.yaml b/examples/15_wind_solar_electrolyzer/plant_config.yaml index 402955359..6bef00eaf 100644 --- a/examples/15_wind_solar_electrolyzer/plant_config.yaml +++ b/examples/15_wind_solar_electrolyzer/plant_config.yaml @@ -98,14 +98,14 @@ finance_parameters: # Approach B - "upstream LCOE feedstock": only the electrolyzer capital plus the # purchased-electricity cost (electricity_feedstock priced at the LCOE above) # are financed here. This should give an LCOH close to Approach A. - hydrogen_lcoe: + hydrogen_elec_feedstock: commodity: hydrogen commodity_stream: electrolyzer technologies: [electricity_feedstock, electrolyzer] # Approach C - "grid buy": like Approach B, but the electricity is purchased # through a grid_buy interconnection priced at the LCOE above. This should also # give an LCOH close to Approaches A and B. - hydrogen_grid_buy: + hydrogen_elec_grid_buy: commodity: hydrogen commodity_stream: electrolyzer technologies: [grid_buy, electrolyzer] diff --git a/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py b/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py index 3541e944a..2dd56d2e8 100644 --- a/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py +++ b/examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py @@ -19,8 +19,12 @@ # close to the LOCH in Approach A. lcoe = model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/kW/h")[0] lcoh_integrated = model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg")[0] -lcoh_feedstock = model.prob.get_val("finance_subgroup_hydrogen_lcoe.LCOH", units="USD/kg")[0] -lcoh_grid_buy = model.prob.get_val("finance_subgroup_hydrogen_grid_buy.LCOH", units="USD/kg")[0] +lcoh_feedstock = model.prob.get_val( + "finance_subgroup_hydrogen_elec_feedstock.LCOH", units="USD/kg" +)[0] +lcoh_grid_buy = model.prob.get_val("finance_subgroup_hydrogen_elec_grid_buy.LCOH", units="USD/kg")[ + 0 +] print(f"Upstream electricity LCOE: {lcoe:.4f} USD/kWh") print(f"LCOH - A: wind + solar + electrolyzer together: {lcoh_integrated:.4f} USD/kg") diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index 3f8c73413..ec4f80960 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -1322,6 +1322,28 @@ def test_wind_solar_electrolyzer_example(subtests, temp_copy_of_example): == 5.3063358423 ) + with subtests.test("Check LCOH from LCOE feedstock"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_hydrogen_elec_feedstock.LCOH", units="USD/kg")[ + 0 + ], + rel=1e-5, + ) + == 5.50083 + ) + + with subtests.test("Check LCOH from grid buy"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_hydrogen_elec_grid_buy.LCOH", units="USD/kg")[ + 0 + ], + rel=1e-5, + ) + == 5.50083 + ) + wind_generation = model.prob.get_val("wind.electricity_out", units="kW") solar_generation = model.prob.get_val("solar.electricity_out", units="kW") total_generation = model.prob.get_val("combiner.electricity_out", units="kW") From a6c53cf0cebf1925f7658dda230fa8f14475424d Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 10 Jul 2026 10:20:00 -0600 Subject: [PATCH 09/12] add documentation for variable slicing in technology connections --- docs/user_guide/connecting_technologies.md | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/user_guide/connecting_technologies.md b/docs/user_guide/connecting_technologies.md index e70bbe6b7..41f2ff0aa 100644 --- a/docs/user_guide/connecting_technologies.md +++ b/docs/user_guide/connecting_technologies.md @@ -61,6 +61,31 @@ There are two connection formats: The `source_parameter` and `destination_parameter` should be input into the array as another array. If it's input as a tuple the model will raise an error. ``` +##### Slice notation for mismatched shapes + +3-element connections with different shared parameter names allow the user append a NumPy-style slice in square brackets to the source and/or destination parameter name. +This is used to connect variables whose shapes differ, for example feeding a scalar finance output into a per-timestep input. +The slice is parsed into OpenMDAO `src_indices`, and the bracketed text is stripped from the parameter name before the connection is made. + +```yaml +technology_interconnections: [ + # select a subset of the source before connecting + ["tech_a", "tech_b", ["source_param[0:100]", "dest_param"]], + # tile a single source element across an 8760-length destination + ["finance_subgroup_electricity", "grid_buy", ["LCOE[0]", "electricity_buy_price[0:8760]"]], +] +``` + +Behavior depends on which side carries a slice: + +- **Source only** (`"source_param[0:100]"`): the slice selects source indices directly (supports start/stop/step, e.g. `[0:100:2]`, and `[:]` for the full range). +- **Destination only** or **both sides equal**: no `src_indices` are applied (the slice is treated as documentation of the target shape). +- **Both sides** (`"LCOE[0]"` → `"electricity_buy_price[0:8760]"`): the source indices are *tiled* to fill the destination length. A single index is repeated (e.g. `[0]` → 8760 copies) and a multi-index source such as `[0,1]` is cycled to fill the destination. + +```{note} +When the destination has a slice, it must (1) start at `0` (a non-zero start raises a `ValueError`) and (2) include the destination length, e.g. `[0:8760]`. The length is required because the input shape is not known until `prob.setup()` has run. +``` + ### Internal connection logic H2Integrate processes these connections in the `connect_technologies()` method of `h2integrate_model.py`. Here's what happens internally: From 97f0fd696ab38f6e5fff1beda614d1b914174d81 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 10 Jul 2026 10:21:54 -0600 Subject: [PATCH 10/12] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a92475ec8..6f3f2bc98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ feedstock data with the resource data methodology [PR 801](https://github.com/NatLabRockies/H2Integrate/pull/801). - Added capability to specify demand technology for system-level control, and renamed the framework-derived system-level control classification dict from `slc_config` to `slc_topology` to distinguish it from the user-authored `control_parameters` block. [PR 784](https://github.com/NatLabRockies/H2Integrate/pull/784) +- Add support for slice notation in technology connections to allow users to connect between variables of different shapes. [PR 774](https://github.com/NatLabRockies/H2Integrate/pull/774) ## 0.8 [April 15, 2026] From 5fc2abd535fbd629b984ae130c33232a5e32041d Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 10 Jul 2026 10:34:17 -0600 Subject: [PATCH 11/12] fix typo --- docs/user_guide/connecting_technologies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user_guide/connecting_technologies.md b/docs/user_guide/connecting_technologies.md index 41f2ff0aa..4e733bcf9 100644 --- a/docs/user_guide/connecting_technologies.md +++ b/docs/user_guide/connecting_technologies.md @@ -63,7 +63,7 @@ The `source_parameter` and `destination_parameter` should be input into the arra ##### Slice notation for mismatched shapes -3-element connections with different shared parameter names allow the user append a NumPy-style slice in square brackets to the source and/or destination parameter name. +3-element connections with different shared parameter names allow the user to append a NumPy-style slice in square brackets to the source and/or destination parameter name. This is used to connect variables whose shapes differ, for example feeding a scalar finance output into a per-timestep input. The slice is parsed into OpenMDAO `src_indices`, and the bracketed text is stripped from the parameter name before the connection is made. From 3ebf8e2a2459d49093e9d6400bb5bd563e1b603e Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 10 Jul 2026 12:07:25 -0600 Subject: [PATCH 12/12] Update docs/user_guide/connecting_technologies.md improve comment Co-authored-by: elenya-grant <116225007+elenya-grant@users.noreply.github.com> --- docs/user_guide/connecting_technologies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user_guide/connecting_technologies.md b/docs/user_guide/connecting_technologies.md index 4e733bcf9..6cf2cad9e 100644 --- a/docs/user_guide/connecting_technologies.md +++ b/docs/user_guide/connecting_technologies.md @@ -69,7 +69,7 @@ The slice is parsed into OpenMDAO `src_indices`, and the bracketed text is strip ```yaml technology_interconnections: [ - # select a subset of the source before connecting + # select a subset of the source to connect to the destination ["tech_a", "tech_b", ["source_param[0:100]", "dest_param"]], # tile a single source element across an 8760-length destination ["finance_subgroup_electricity", "grid_buy", ["LCOE[0]", "electricity_buy_price[0:8760]"]],