Skip to content

Commit bceeeb4

Browse files
Allow for connections including slice specifications (#774)
* move tests for split indices * revert obsolete changes to plant schema * fix bugs in length 3 connections for src_indices * remove use of eval * add ValueError for non-zero destination slice starts * allow for src_indices tiling into destination * update wind solar electrolyzer example to demonstrate different finance and connection approaches * add tests for each finance/connection approach added to wind solar electrolyzer example * add documentation for variable slicing in technology connections * update changelog * fix typo * Update docs/user_guide/connecting_technologies.md improve comment Co-authored-by: elenya-grant <116225007+elenya-grant@users.noreply.github.com> --------- Co-authored-by: elenya-grant <116225007+elenya-grant@users.noreply.github.com>
1 parent d336ecf commit bceeeb4

8 files changed

Lines changed: 317 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
feedstock data with the resource data methodology
5858
[PR 801](https://github.com/NatLabRockies/H2Integrate/pull/801).
5959
- 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)
60+
- 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)
6061

6162
## 0.8 [April 15, 2026]
6263

docs/user_guide/connecting_technologies.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,31 @@ There are two connection formats:
6161
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.
6262
```
6363

64+
##### Slice notation for mismatched shapes
65+
66+
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.
67+
This is used to connect variables whose shapes differ, for example feeding a scalar finance output into a per-timestep input.
68+
The slice is parsed into OpenMDAO `src_indices`, and the bracketed text is stripped from the parameter name before the connection is made.
69+
70+
```yaml
71+
technology_interconnections: [
72+
# select a subset of the source to connect to the destination
73+
["tech_a", "tech_b", ["source_param[0:100]", "dest_param"]],
74+
# tile a single source element across an 8760-length destination
75+
["finance_subgroup_electricity", "grid_buy", ["LCOE[0]", "electricity_buy_price[0:8760]"]],
76+
]
77+
```
78+
79+
Behavior depends on which side carries a slice:
80+
81+
- **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).
82+
- **Destination only** or **both sides equal**: no `src_indices` are applied (the slice is treated as documentation of the target shape).
83+
- **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.
84+
85+
```{note}
86+
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.
87+
```
88+
6489
### Internal connection logic
6590

6691
H2Integrate processes these connections in the `connect_technologies()` method of `h2integrate_model.py`. Here's what happens internally:

examples/15_wind_solar_electrolyzer/plant_config.yaml

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,32 @@ sites:
2424
# with the reverse definition.
2525
# this will naturally grow as we mature the interconnected tech
2626
technology_interconnections:
27+
# Physical plant: wind + solar are combined and used to run a single electrolyzer.
28+
# This same physical plant is financed three different ways below (see finance_subgroups).
2729
- [wind, combiner, electricity, cable]
28-
# source_tech, dest_tech, transport_item, transport_type = connection
2930
- [solar, combiner, electricity, cable]
3031
- [combiner, electrolyzer, electricity, cable]
32+
33+
# --- Approach B wiring: upstream LCOE used as a feedstock price --------------
34+
# Compute the wind + solar levelized cost of electricity (LCOE) in the
35+
# `electricity` finance subgroup, then charge the electrolyzer for the
36+
# electricity it consumes at that LCOE through the `electricity_feedstock` tech.
37+
#
38+
# 1. Feed the computed LCOE in as the feedstock purchase price (scalar slice):
39+
- [finance_subgroup_electricity, electricity_feedstock, [LCOE, price]]
40+
# 2. Charge the feedstock for exactly the electricity the electrolyzer consumes:
41+
- [electrolyzer, electricity_feedstock, electricity_consumed]
42+
# 3. Provide the feedstock supply profile (used for its capacity factor):
43+
- [electricity_feedstock_source, electricity_feedstock, electricity_out]
44+
45+
# --- Approach C wiring: upstream LCOE used as a grid purchase price ----------
46+
# Same idea as Approach B, but the electricity is "purchased" through a
47+
# `grid_buy` interconnection priced at the LCOE instead of a generic feedstock.
48+
#
49+
# 1. Feed the computed LCOE in as the grid buy price (scalar tiled to 8760 h):
50+
- [finance_subgroup_electricity, grid_buy, ['LCOE[0]', 'electricity_buy_price[0:8760]']]
51+
# 2. Buy from the grid exactly the electricity the electrolyzer consumes:
52+
- [electrolyzer, grid_buy, [electricity_consumed, electricity_set_point]]
3153
resource_to_tech_connections:
3254
# connect the wind resource to the wind technology
3355
- [wind_site.wind_resource, wind, wind_resource_data]
@@ -61,14 +83,32 @@ finance_parameters:
6183
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
6284
refurb: [0.]
6385
finance_subgroups:
86+
# Upstream electricity finance group: levelized cost of the wind + solar
87+
# electricity. Its LCOE output feeds Approaches B and C below.
6488
electricity:
6589
commodity: electricity
6690
commodity_stream: combiner # use the total electricity output from the combiner for the finance calc
6791
technologies: [wind, solar]
92+
# Approach A - "same finance model": wind + solar + electrolyzer are all
93+
# financed together, so the renewable capital is embedded directly in the LCOH.
6894
hydrogen:
6995
commodity: hydrogen
7096
commodity_stream: electrolyzer
7197
technologies: [wind, solar, electrolyzer]
98+
# Approach B - "upstream LCOE feedstock": only the electrolyzer capital plus the
99+
# purchased-electricity cost (electricity_feedstock priced at the LCOE above)
100+
# are financed here. This should give an LCOH close to Approach A.
101+
hydrogen_elec_feedstock:
102+
commodity: hydrogen
103+
commodity_stream: electrolyzer
104+
technologies: [electricity_feedstock, electrolyzer]
105+
# Approach C - "grid buy": like Approach B, but the electricity is purchased
106+
# through a grid_buy interconnection priced at the LCOE above. This should also
107+
# give an LCOH close to Approaches A and B.
108+
hydrogen_elec_grid_buy:
109+
commodity: hydrogen
110+
commodity_stream: electrolyzer
111+
technologies: [grid_buy, electrolyzer]
72112
cost_adjustment_parameters:
73113
cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year
74114
target_dollar_year: 2022

examples/15_wind_solar_electrolyzer/run_wind_solar_electrolyzer.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,25 @@
88
model.run()
99

1010
model.post_process()
11+
12+
# Compare three ways of financing the hydrogen produced by the same physical plant:
13+
# Approach A ("same finance model"): wind + solar + electrolyzer financed together.
14+
# Approach B ("upstream LCOE feedstock"): the electrolyzer buys electricity as a
15+
# generic feedstock priced at the wind + solar LCOE.
16+
# Approach C ("grid buy"): the electrolyzer buys electricity through a grid
17+
# interconnection priced at the wind + solar LCOE.
18+
# Approaches B and C should both give an identical result for LCOH, which should be
19+
# close to the LOCH in Approach A.
20+
lcoe = model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/kW/h")[0]
21+
lcoh_integrated = model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg")[0]
22+
lcoh_feedstock = model.prob.get_val(
23+
"finance_subgroup_hydrogen_elec_feedstock.LCOH", units="USD/kg"
24+
)[0]
25+
lcoh_grid_buy = model.prob.get_val("finance_subgroup_hydrogen_elec_grid_buy.LCOH", units="USD/kg")[
26+
0
27+
]
28+
29+
print(f"Upstream electricity LCOE: {lcoe:.4f} USD/kWh")
30+
print(f"LCOH - A: wind + solar + electrolyzer together: {lcoh_integrated:.4f} USD/kg")
31+
print(f"LCOH - B: electrolyzer + LCOE feedstock: {lcoh_feedstock:.4f} USD/kg")
32+
print(f"LCOH - C: electrolyzer + grid buy at LCOE: {lcoh_grid_buy:.4f} USD/kg")

examples/15_wind_solar_electrolyzer/tech_config.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,42 @@ technologies:
6363
performance_parameters:
6464
commodity: electricity
6565
commodity_rate_units: kW
66+
electricity_feedstock:
67+
performance_model:
68+
model: FeedstockPerformanceModel
69+
cost_model:
70+
model: FeedstockCostModel
71+
model_inputs:
72+
shared_parameters:
73+
commodity: electricity
74+
commodity_rate_units: kW
75+
performance_parameters:
76+
rated_capacity: 500000000.0 # kW
77+
cost_parameters:
78+
cost_year: 2022
79+
price: 0.0 # USD/kWh - to be set through connections
80+
annual_cost: 0.
81+
start_up_cost: 0.
82+
# Grid interconnection used purely to purchase electricity for the electrolyzer
83+
# (Approach C). NOTE: the tech name MUST start with "grid_buy" for the financial
84+
# logic to treat its variable cost as a purchase (expense) rather than a coproduct.
85+
grid_buy:
86+
performance_model:
87+
model: GridPerformanceModel
88+
cost_model:
89+
model: GridCostModel
90+
model_inputs:
91+
shared_parameters:
92+
interconnection_size: 5.e8 # kW - large so it never limits the electrolyzer's purchases
93+
cost_parameters:
94+
cost_year: 2022
95+
# Purchase price is set through a connection to the wind + solar LCOE.
96+
electricity_buy_price: 0.0 # USD/kWh - overwritten by the finance_subgroup_electricity.LCOE
97+
buy_price_mode: per_timestep
98+
# No interconnection capital/O&M charged here so only the energy cost matters.
99+
interconnection_capex_per_kw: 0.0 # $/kW capital cost
100+
interconnection_opex_per_kw: 0.0 # $/kW/year O&M cost
101+
fixed_interconnection_cost: 0.0 # $ one-time fixed cost
66102
electrolyzer:
67103
performance_model:
68104
model: ECOElectrolyzerPerformanceModel

examples/test/test_all_examples.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,28 @@ def test_wind_solar_electrolyzer_example(subtests, temp_copy_of_example):
13221322
== 5.3063358423
13231323
)
13241324

1325+
with subtests.test("Check LCOH from LCOE feedstock"):
1326+
assert (
1327+
pytest.approx(
1328+
model.prob.get_val("finance_subgroup_hydrogen_elec_feedstock.LCOH", units="USD/kg")[
1329+
0
1330+
],
1331+
rel=1e-5,
1332+
)
1333+
== 5.50083
1334+
)
1335+
1336+
with subtests.test("Check LCOH from grid buy"):
1337+
assert (
1338+
pytest.approx(
1339+
model.prob.get_val("finance_subgroup_hydrogen_elec_grid_buy.LCOH", units="USD/kg")[
1340+
0
1341+
],
1342+
rel=1e-5,
1343+
)
1344+
== 5.50083
1345+
)
1346+
13251347
wind_generation = model.prob.get_val("wind.electricity_out", units="kW")
13261348
solar_generation = model.prob.get_val("solar.electricity_out", units="kW")
13271349
total_generation = model.prob.get_val("combiner.electricity_out", units="kW")

h2integrate/core/h2integrate_model.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,8 +1670,16 @@ def connect_technologies(self):
16701670
)
16711671

16721672
elif len(connection) == 3:
1673-
# connect directly from source to dest
16741673
source_tech, dest_tech, connected_parameter = connection
1674+
src_indices = None
1675+
1676+
# initialize src_indices to allow connections between different shaped variables
1677+
if isinstance(connected_parameter, list):
1678+
connected_parameter, src_indices = (
1679+
self._split_indices_from_connected_parameter_definition(connected_parameter)
1680+
)
1681+
1682+
# connect directly from source to dest
16751683
if isinstance(connected_parameter, tuple | list):
16761684
source_parameter, dest_parameter = connected_parameter
16771685
# Check if this is a multivariable stream connection
@@ -1685,7 +1693,9 @@ def connect_technologies(self):
16851693
)
16861694
else:
16871695
self.plant.connect(
1688-
f"{source_tech}.{source_parameter}", f"{dest_tech}.{dest_parameter}"
1696+
f"{source_tech}.{source_parameter}",
1697+
f"{dest_tech}.{dest_parameter}",
1698+
src_indices=src_indices,
16891699
)
16901700
else:
16911701
# Check if the connected_parameter is a multivariable stream
@@ -1701,6 +1711,7 @@ def connect_technologies(self):
17011711
self.plant.connect(
17021712
f"{source_tech}.{connected_parameter}",
17031713
f"{dest_tech}.{connected_parameter}",
1714+
src_indices=src_indices,
17041715
)
17051716

17061717
else:
@@ -2245,3 +2256,87 @@ def _get_commodity_for_tech(self, tech_name):
22452256
tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name]
22462257

22472258
return tech_commodities
2259+
2260+
@staticmethod
2261+
def _split_indices_from_connected_parameter_definition(connected_parameter):
2262+
"""Extract and parse slice indices from connected parameter definitions for OpenMDAO
2263+
connections.
2264+
2265+
This function processes parameter names containing slice patterns in square brackets
2266+
(e.g., "power[0:8760]") and generates OpenMDAO-compatible src_indices for connections
2267+
between variables of different shapes.
2268+
2269+
Args:
2270+
connected_parameter (list[str]): A two-element list containing:
2271+
- [0] source parameter name, optionally with pattern like "var[slice_spec]"
2272+
- [1] destination parameter name, optionally with pattern like "var[slice_spec]"
2273+
2274+
Example: ["power[0:8760]", "demand[:]"]
2275+
2276+
Returns:
2277+
tuple: A two-element tuple containing:
2278+
- connected_parameter (list[str]): The parameter names with slices removed
2279+
(e.g., ["power", "demand"])
2280+
- src_indices: OpenMDAO slicer object for indexing source outputs to match
2281+
destination input shapes. Returns om.slicer[slice] for indexing.
2282+
2283+
Note:
2284+
If the destination has a slice pattern, it must include the length ":N"
2285+
(e.g., "[0:N]"), the function extracts N as the destination length and
2286+
multiplies the source slice by this factor to create properly scaled indices.
2287+
The length is required because the length is not known in the OpenMDAO model
2288+
until prob.setup() has been called.
2289+
"""
2290+
source_parameter, dest_parameter = connected_parameter
2291+
2292+
def _extract_slice(parameter):
2293+
"""Return the contents inside the brackets (e.g. '0:8760'), or None."""
2294+
match = re.search(r"\[(.*)\]", parameter)
2295+
return None if match is None else match.group(1)
2296+
2297+
def _to_indices(spec):
2298+
"""Convert a bracket spec string into a slice or list of ints."""
2299+
if ":" in spec:
2300+
return slice(*(int(p) if p.strip() else None for p in spec.split(":")))
2301+
return [int(p) for p in spec.split(",")]
2302+
2303+
source_slice = _extract_slice(source_parameter)
2304+
dest_slice = _extract_slice(dest_parameter)
2305+
2306+
if source_slice == dest_slice:
2307+
src_indices = None
2308+
elif dest_slice is not None and source_slice is not None:
2309+
# Tile the source indices to fill the destination length to handle shape
2310+
# mismatches. Examples:
2311+
# source="0", dest_length=8760 -> [0] repeated 8760 times
2312+
# source="0,1", dest_length=10 -> [0, 1] cycled to fill 10 slots
2313+
if dest_slice.split(":")[0] not in ("", "0"):
2314+
raise ValueError(
2315+
"A non-zero start was provided for the slice for destination "
2316+
f"parameter <{dest_parameter}>"
2317+
)
2318+
dest_length = int(dest_slice.split(":")[-1])
2319+
2320+
source_indices = _to_indices(source_slice)
2321+
if isinstance(source_indices, slice):
2322+
source_indices = list(
2323+
range(
2324+
source_indices.start or 0,
2325+
source_indices.stop,
2326+
source_indices.step or 1,
2327+
)
2328+
)
2329+
2330+
# Repeat the source values enough times to cover the destination, then
2331+
# truncate so the result is exactly dest_length long. This cycles through
2332+
# the source values when the source is shorter than the destination.
2333+
n_repeats = -(-dest_length // len(source_indices)) # ceiling division
2334+
src_indices = om.slicer[(source_indices * n_repeats)[:dest_length]]
2335+
else:
2336+
# No destination slice pattern; use source slice pattern directly.
2337+
src_indices = None if source_slice is None else om.slicer[_to_indices(source_slice)]
2338+
2339+
# Remove the slice patterns from parameter names to get clean names.
2340+
connected_parameter = [source_parameter.split("[")[0], dest_parameter.split("[")[0]]
2341+
2342+
return connected_parameter, src_indices

0 commit comments

Comments
 (0)