From 0c81a5a009419441373189c9ad75e6dbfc8ec116 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 24 Jun 2026 11:25:15 -0600 Subject: [PATCH] Hooking up electricity buy price and more correctly --- CHANGELOG.md | 1 + .../system_level/system_level_control_base.py | 118 +++++++++++++++--- .../system_level/test/test_slc_examples.py | 9 +- h2integrate/core/h2integrate_model.py | 29 ++++- 4 files changed, 138 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01151283a..2e0d2b0be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/h2integrate/control/control_strategies/system_level/system_level_control_base.py b/h2integrate/control/control_strategies/system_level/system_level_control_base.py index d1b8c0348..d36dcea7f 100644 --- a/h2integrate/control/control_strategies/system_level/system_level_control_base.py +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -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. @@ -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}", ) @@ -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. diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py index 275d78b96..5d91cb5e5 100644 --- a/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -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 diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 43865c5bd..c3135c29e 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -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: @@ -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. @@ -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 ---------