Skip to content
Open
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 @@ -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]

Expand Down
25 changes: 25 additions & 0 deletions docs/user_guide/connecting_technologies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clarification question: When it's "Source only", then the destination length isn't needed? Does this mean that if my source_param is a 100 element array and my destination is a 50 element array, then I just need to do the connection as
["source_tech", "destination_tech", ["source_param[0:100:2]", "destination_param"]]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, that is correct.

- **Destination only** or **both sides equal**: no `src_indices` are applied (the slice is treated as documentation of the target shape).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Idk if the definition of "destination only" needs to be explained here? Isn't "Destination only" when we don't specify indices?
["source_tech", "destination_tech", ["source_param", "destination_param"]]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, that is what it means by "no src_indices are applied". Basically it treats it as though no slice was provided.

- **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:
Expand Down
42 changes: 41 additions & 1 deletion examples/15_wind_solar_electrolyzer/plant_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
22 changes: 22 additions & 0 deletions examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
36 changes: 36 additions & 0 deletions examples/15_wind_solar_electrolyzer/tech_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions examples/test/test_all_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
99 changes: 97 additions & 2 deletions h2integrate/core/h2integrate_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)

Comment thread
jaredthomas68 marked this conversation as resolved.
# 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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Comment thread
elenya-grant marked this conversation as resolved.
(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:
Comment thread
elenya-grant marked this conversation as resolved.
# 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
Loading
Loading