Skip to content
Draft
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 @@ -40,6 +40,7 @@
- Added dynamic operating constraints (turndown, ramping, warm/cold start delays) to `AmmoniaSynLoopPerformanceModel` and split `AmmoniaSynLoopCostModel` into its own module. [PR 770](https://github.com/NatLabRockies/H2Integrate/pull/770)
- Speed up the slowest tests in the suite by swapping the Floris wind model for `PYSAMWindPlantPerformanceModel` in examples 01 (`01_onshore_steel_mn`) and 02 (`02_texas_ammonia`), updating the affected `test_steel_example`/`test_simple_ammonia_example` expected values, fixing a pre-existing `cases.sql` cache-path bug and module-scoping the fixtures in `h2integrate/postprocess/test/test_sql_timeseries_to_csv.py` so the example only runs once for all four tests. [PR 782](https://github.com/NatLabRockies/H2Integrate/pull/782)
- Exposed `n_timesteps`, `dt`, `plant_life`, and `fraction_of_year_simulated` as attributes on `CostModelBaseClass` (matching `PerformanceModelBaseClass`) and updated all cost and performance model subclasses across `h2integrate/` to use these attributes instead of reading them from `plant_config`, removing redundant boilerplate from individual components. [PR 783](https://github.com/NatLabRockies/H2Integrate/pull/783)
- Connect each cost-aware system-level controller's `{tech}_buy_price` input directly to the technology's own buy-price input via OpenMDAO 3.44 input-to-input connections, so a single `prob.set_val()` on (for example) `grid.electricity_buy_price` now propagates to the SLC. [PR 791](https://github.com/NatLabRockies/H2Integrate/pull/791)

## 0.8 [April 15, 2026]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,84 @@
import openmdao.api as om


def _get_tech_buy_price_input_name(tech_config, tech_name):
"""Return the variable name of a tech's buy-price input, or ``None`` if absent.

Used by the ``"buy_price"`` ``cost_per_tech`` mode to figure out which
OpenMDAO input on the technology cost model carries the per-unit purchase
price. Currently recognizes:

- ``"electricity_buy_price"`` (Grid technologies)
- ``"price"`` (Feedstock technologies)

Args:
tech_config (dict): The full ``tech_config`` dictionary.
tech_name (str): Name of the technology.

Returns:
str | None: The input variable name, or ``None`` if the tech has no
recognized buy-price input in its cost / shared parameters.
"""
tech_def = tech_config.get("technologies", {}).get(tech_name, {})
model_inputs = tech_def.get("model_inputs", {})
cost_params = model_inputs.get("cost_parameters", {})
shared_params = model_inputs.get("shared_parameters", {})
all_params = {**shared_params, **cost_params}
if "electricity_buy_price" in all_params:
return "electricity_buy_price"
if "price" in all_params:
return "price"
return None


def _get_buy_price_default_and_shape(tech_config, tech_name, n_timesteps, plant_life):
"""Return the default buy-price value and OpenMDAO input shape for a tech.

Mirrors the shape logic used by the technology cost models themselves so
the SLC's ``{tech_name}_buy_price`` input can be safely connected
input-to-input with the tech's own buy-price input:

- Grid (``electricity_buy_price``): shape is determined by
``buy_price_mode`` (``per_timestep`` → ``n_timesteps``, ``per_year`` →
``plant_life``, ``constant`` → ``1``).
- Feedstock (``price``): shape is the length of the configured price
array, or ``1`` for a scalar.
- Anything else: falls back to ``n_timesteps`` with a default of ``0.0``.

Args:
tech_config (dict): The full ``tech_config`` dictionary.
tech_name (str): Name of the technology.
n_timesteps (int): Number of simulation timesteps.
plant_life (int): Plant life in years.

Returns:
tuple[float | list | np.ndarray, int]: ``(default_value, shape)``
suitable for ``add_input(val=..., shape=...)``.
"""
tech_def = tech_config.get("technologies", {}).get(tech_name, {})
model_inputs = tech_def.get("model_inputs", {})
cost_params = model_inputs.get("cost_parameters", {})
shared_params = model_inputs.get("shared_parameters", {})
all_params = {**shared_params, **cost_params}

if "electricity_buy_price" in all_params:
default_price = all_params["electricity_buy_price"]
buy_price_mode = all_params.get("buy_price_mode", "per_timestep")
if buy_price_mode == "per_year":
return default_price, plant_life
if buy_price_mode == "constant":
return default_price, 1
return default_price, n_timesteps

if "price" in all_params:
default_price = all_params["price"]
if isinstance(default_price, list | np.ndarray):
return default_price, len(default_price)
return default_price, 1

return 0.0, n_timesteps


class SystemLevelControlBase(om.ExplicitComponent):
"""Base class for system-level controllers.

Expand Down Expand Up @@ -560,23 +638,22 @@ def _setup_marginal_costs(self):
self.dispatchable_marginal_cost_types.append(("scalar", cost_spec))

elif cost_spec == "buy_price":
# Read default buy price from tech config
tech_config = self.options["tech_config"]
tech_def = tech_config.get("technologies", {}).get(tech_name, {})
model_inputs = tech_def.get("model_inputs", {})
cost_params = model_inputs.get("cost_parameters", {})
shared_params = model_inputs.get("shared_parameters", {})
all_params = {**shared_params, **cost_params}

default_price = all_params.get(
"electricity_buy_price",
all_params.get("price", 0.0),
# Read default buy price from tech config and create an input on
# the SLC whose shape matches the tech's own buy-price input.
# That allows ``H2IntegrateModel`` to wire the tech's buy-price
# input directly to this SLC input (input-to-input connection),
# so a single ``prob.set_val()`` on the tech propagates here.
default_price, input_shape = _get_buy_price_default_and_shape(
self.options["tech_config"],
tech_name,
self.n_timesteps,
plant_life,
)

self.add_input(
f"{tech_name}_buy_price",
val=default_price,
shape=self.n_timesteps,
shape=input_shape,
units=f"USD/({self.commodity_rate_units}*h)",
desc=f"Buy price for {tech_name}",
)
Expand Down Expand Up @@ -648,9 +725,22 @@ def _buy_price_marginal_cost(self, inputs, tech_name):
"""Compute marginal cost from buy price.

Returns a per-timestep marginal cost array equal to the
technology's buy price (scalar or time-varying).
technology's buy price. The underlying input may be scalar
(shape ``(1,)``), per-timestep (shape ``(n_timesteps,)``) or
per-year (shape ``(plant_life,)``); the value is broadcast or
repeated as needed to span all simulation timesteps.
"""
return np.broadcast_to(inputs[f"{tech_name}_buy_price"], self.n_timesteps).copy()
buy_price = np.asarray(inputs[f"{tech_name}_buy_price"])

if buy_price.shape == (self.n_timesteps,) or buy_price.shape == (1,):
return np.broadcast_to(buy_price, self.n_timesteps).copy()

if buy_price.shape == (int(self.options["plant_config"]["plant"]["plant_life"]),):
# Per-year price: use the first year's value as a representative
# per-timestep marginal cost for dispatch decisions.
return np.full(self.n_timesteps, buy_price[0])

return np.broadcast_to(buy_price, self.n_timesteps).copy()

def _varopex_marginal_cost(self, inputs, tech_name):
"""Compute marginal cost from VarOpEx and commodity output.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,5 +304,10 @@ def test_slc_complex_profit_max(subtests, temp_copy_of_example):
with subtests.test("natural gas dispatched"):
assert ng_out.sum() > 0

with subtests.test("grid used when needed"):
assert grid_out.sum() > 0
with subtests.test("grid not dispatched when always unprofitable"):
# grid_buy_price = sell_price + 0.02 everywhere, so grid is never
# profitable to buy under ProfitMaximizationControl. After the
# buy_price input-to-input connection fix, the SLC sees the actual
# per-timestep buy price (not the constant default from the tech
# config), so the merit-order check correctly skips grid.
assert grid_out.sum() == 0
29 changes: 26 additions & 3 deletions h2integrate/core/h2integrate_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
from h2integrate.control.control_strategies.system_level.solver_options import (
SLCSolverOptionsConfig,
)
from h2integrate.control.control_strategies.system_level.system_level_control_base import (
_get_tech_buy_price_input_name,
)


try:
Expand Down Expand Up @@ -671,8 +674,11 @@ def add_system_level_controller(self, slc_config):
at any depth and connects each feedstock's ``VarOpEx`` output.
This is consistent with the ``_find_feedstock_techs`` method
used by the controller component internally.
- ``"buy_price"``: no connection needed; the controller reads a default value from the
tech config that can be overridden at runtime via ``prob.set_val()``.
- ``"buy_price"``: the controller's ``{tech_name}_buy_price`` input is
connected input-to-input to the technology's own buy-price input
(``electricity_buy_price`` for Grid, ``price`` for Feedstock) so a
single ``prob.set_val()`` on the tech propagates to the SLC. The
default value still comes from the tech config.
- Numeric scalar: no connection needed; the value is used directly as a constant
marginal cost.

Expand Down Expand Up @@ -814,7 +820,24 @@ def add_system_level_controller(self, slc_config):
f"{feedstock_name}.VarOpEx",
f"system_level_controller.{feedstock_name}_VarOpEx",
)
# "buy_price": default from tech config, overridable via set_val
elif cost_spec == "buy_price":
# Input-to-input connection (OpenMDAO 3.44+): tie the
# tech's own buy-price input to the SLC's buy-price
# input so a single ``prob.set_val()`` on the tech
# updates both the cost model and the controller.
#
# OpenMDAO 3.44 requires input-to-input connections to
# be made on the top-level model (not a subgroup); we
# use promoted names from ``self.plant`` since the
# plant is added to ``self.model`` with ``promotes=*``.
tech_buy_price_input = _get_tech_buy_price_input_name(
self.technology_config, tech_name
)
if tech_buy_price_input is not None:
self.model.connect(
f"{tech_name}.{tech_buy_price_input}",
f"system_level_controller.{tech_name}_buy_price",
)
# numeric scalar: used directly, no connection needed

# --- Step 5: Connect the demand profile to the controller ---------
Expand Down
Loading