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] diff --git a/docs/user_guide/connecting_technologies.md b/docs/user_guide/connecting_technologies.md index e70bbe6b7..6cf2cad9e 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 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. + +```yaml +technology_interconnections: [ + # 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]"]], +] +``` + +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: diff --git a/examples/15_wind_solar_electrolyzer/plant_config.yaml b/examples/15_wind_solar_electrolyzer/plant_config.yaml index c5e1c38c8..6bef00eaf 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_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_elec_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..2dd56d2e8 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,25 @@ 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_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") +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 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") diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 580739255..f5cf06b03 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -1670,8 +1670,16 @@ def connect_technologies(self): ) elif len(connection) == 3: - # connect directly from source to dest source_tech, dest_tech, connected_parameter = connection + src_indices = None + + # initialize src_indices to allow connections between different shaped variables + if isinstance(connected_parameter, list): + connected_parameter, src_indices = ( + self._split_indices_from_connected_parameter_definition(connected_parameter) + ) + + # connect directly from source to dest if isinstance(connected_parameter, tuple | list): source_parameter, dest_parameter = connected_parameter # Check if this is a multivariable stream connection @@ -1685,7 +1693,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 @@ -1701,6 +1711,7 @@ def connect_technologies(self): self.plant.connect( f"{source_tech}.{connected_parameter}", f"{dest_tech}.{connected_parameter}", + src_indices=src_indices, ) else: @@ -2245,3 +2256,87 @@ 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 + + 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) + + 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) + + if source_slice == dest_slice: + src_indices = None + elif dest_slice is not None and source_slice is not None: + # 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]) + + 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)] + + # 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/test/test_split_indices.py b/h2integrate/core/test/test_split_indices.py new file mode 100644 index 000000000..09e24095d --- /dev/null +++ b/h2integrate/core/test/test_split_indices.py @@ -0,0 +1,73 @@ +"""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_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.""" + 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 [:].""" + 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]