diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3c66a5d..335536a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- **Controller signal naming overhaul**: Standardized controller input/output names across the codebase. SLC outputs are now `{tech}_{commodity}_set_point` (was `{tech}_{commodity}_demand`); technology-level controllers take `{commodity}_set_point` as input and emit `{commodity}_command_value` as output (was `{commodity}_demand`/`{commodity}_set_point`). Storage performance models in feedback mode receive `{commodity}_set_point`; in open-loop mode they receive `{commodity}_command_value`. The SLC's own input (`{commodity}_demand`) and demand component I/O remain unchanged. - Change commodity in DRI and EAF model from pig iron to sponge iron based on likely carbon content [PR 670](https://github.com/NatLabRockies/H2Integrate/pull/670) - Bugfix for round-trip efficiency handling when calling `check_inputs` around `StoragePerformanceModel` [PR 684](https://github.com/NatLabRockies/H2Integrate/pull/684) - Bugfix. Include nuclear in electricity producing tech list and improve error message for zero-length electricity producing techs in model when electricity is specified as the commodity. [PR 685](https://github.com/NatLabRockies/H2Integrate/pull/685) diff --git a/docs/_toc.yml b/docs/_toc.yml index 9cb8182b1..e525a72b4 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -70,10 +70,20 @@ parts: - file: resource/tidal_resource - caption: Control chapters: - - file: control/control_overview - - file: control/open-loop_controllers - - file: control/pyomo_controllers - - file: control/controller_demonstrations + - file: control/system_level_control/system_level_control + sections: + - file: control/system_level_control/system_level_control_base + - file: control/system_level_control/control_classifier + - file: control/system_level_control/controllers + sections: + - file: control/system_level_control/slc_demand_following + - file: control/system_level_control/slc_profit_max + - file: control/system_level_control/slc_cost_min + - file: control/technology_level_control/technology_control_overview + sections: + - file: control/technology_level_control/open-loop_controllers + - file: control/technology_level_control/pyomo_controllers + - file: control/technology_level_control/controller_demonstrations - caption: Demand chapters: - file: demand/demand_components diff --git a/docs/control/system_level_control/control_classifier.md b/docs/control/system_level_control/control_classifier.md new file mode 100644 index 000000000..b670b6935 --- /dev/null +++ b/docs/control/system_level_control/control_classifier.md @@ -0,0 +1,82 @@ +# System Level Control Technology Performance Classifiers + +To enable a generic system level control framework we need to classify each technology based on how the model that is included in H2I can operate within the system. + +```{note} +While in real life there are a lot of controllable parameters allowing for ramping production up or down for a particular technology (e.g., wind or solar curtailment), the model of that technology in H2I might not be capable of the same response behavior to input signals. +These classifications are for specific H2I dispatch formulations and are based on how the models in H2I are implemented, **not always** on how the actual physical subsystem might operate. +This is a useful and necessary distinction that delineates different model capabilities clearly. +``` + +We have identified five key classifiers that are able to represent the different behaviors that we can expect from the models. Each performance model includes a parameter setting the classifier `_control_classifier`. + +Classifier | Meaning | Example Technology Models +-- | -- | -- +fixed | Always produces commodity and cannot be controlled or reduced; does not receive a set-point | classical nuclear +flexible | Resource-driven; can only be *reduced* (curtailed) below the resource-determined maximum via a set-point | wind, solar +dispatchable | Can modulate production within bounds in response to a set-point | grid, electrolyzer, NG turbine +storage | Can modulate consumption/production within bounds while tracking SOC | battery, h2 storage, any storage +feedstock | Are not directly controlled, but useful for SLC to make dispatch decisions | feedstocks + +To add a classifier for a particular model it would look something like this in the class: +```{python} +_control_classifier = "flexible" +``` + +```{note} +**Flexible vs. dispatchable.** Both classifiers receive a `{commodity}_set_point` from the system-level controller, so the distinction is about *what the set-point can do*. A flexible model is a strictly more restricted case of a dispatchable one: the set-point can only *cap* the output below whatever the underlying resource (sun, wind, etc.) makes available. A dispatchable model, by contrast, can be ramped up or down anywhere within its operating bounds in direct response to the set-point. +``` + +## Fixed +A fixed performance model represents anything that always produces at its rated capacity and cannot be controlled or reduced by the system level controller. The SLC reads the output from a fixed technology and subtracts it from the demand, but does not send a set-point back to the technology. A good example of this is a classical nuclear plant model: it produces a constant output that the rest of the system must accommodate. + +## Flexible +A flexible performance model represents anything whose production is determined by an external resource (e.g., wind speed, solar irradiance) and that can only be *reduced* below that resource-determined maximum and never increased above it. The system-level controller sends a `{commodity}_set_point` that acts as an upper bound: when the resource-driven output exceeds the set-point, output is curtailed down to the set-point; otherwise, output is left at the resource-driven value. A good example is the PVWatts PySAM solar plant in H2I; its performance is a function of the input solar resource, and we cannot tell the sun to shine more, but we can curtail the panel output below the available solar production. + +In other words, flexible is a strictly more restricted case of [dispatchable](#dispatchable): a dispatchable model can be ramped both up and down in response to a set-point, while a flexible model can only be ramped down. + +To simplify the implementation of applying this curtailment we added a method, `apply_curtailment()`, to the `PerformanceBaseClass`. + +```{figure} figures/curtailable.png +:width: 70% +:align: center +``` + +### Apply curtailment based on set_point +Within the `compute()` method in the performance model you can apply the curtailment using the `apply_curtailment()` method. +``` +self.apply_curtailment(outputs) +``` +which applies curtailment to `{commodity}_out` based on `{commodity}_set_point`. This adds `uncurtailed_{commodity}_out` and `{commodity}_out` as outputs from the performance model. + +(dispatchable)= +## Dispatchable +A dispatchable performance model represents anything that can be ramped both *up and down* within its operating bounds in response to a `{commodity}_set_point` from the system-level controller. Unlike a [flexible](#flexible) model, the set-point for a dispatchable model can request any production level within the model's rated capacity (and minimum load, if applicable), and the model will produce at that level. Examples include a grid connection, an electrolyzer, or a natural-gas turbine. + +There aren't additional special methods to handle this because the set-point response is internal to each performance model. + +```{figure} figures/dispatchable.png +:width: 70% +:align: center +``` + +## Storage +Storage is a unique control classifier because it assumes that within the model that energy isn't created or destroyed (minus some efficiency losses). While it's technically "dispatchable" in that it can receive and change its performance based on a set point, its handling within H2I is unique because it's attached to storage performance models, which is handled differently than converter performance models. A converter model only has positive (or zero) `{commodity}_out`, whereas a storage model can have positive or negative `{commodity}_out`. + +There are two types of cases for the storage control classifier: +1. **with a storage controller** +When the storage performance model is controlled with a storage-level controller (open-loop or feedback controlled), the system-level controller outputs combined demand, that is always positive to the storage-level controller. The demand is `{commodity}_in` from the technologies upstream of the storage that output the same commodity to the storage performance model and the `remaining_demand`. + +2. **without a storage controller** +The system-level controller outputs set points to the storage performance model which can be considered charge (negative) and discharge (positive) commands (storage-level set points) to the storage performance model, directly. + + +```{figure} figures/storage.png +:width: 85% +:align: center +``` + +## Feedstock +Feedstocks represent commodity *inputs* to the controllable system: they are consumed by other technologies but their availability is not itself something the controller can adjust. Although feedstocks themselves cannot be dispatched, knowing how much of each feedstock is available is valuable information for more advanced controllers, since feedstock supply can constrain what the controllable technologies are actually able to produce. + +For example, consider an ammonia plant that consumes both hydrogen and nitrogen. If the nitrogen feedstock supply is insufficient to meet the ammonia demand, the ammonia output is capped by the nitrogen availability regardless of how much hydrogen and electricity are produced. A controller that is aware of the nitrogen feedstock can recognize that the ammonia demand cannot be met, and can adjust the set-points for the hydrogen and electricity technologies accordingly (e.g., avoiding over-production of hydrogen that would otherwise go unused). This is why feedstocks are classified separately rather than being ignored by the controller: they are uncontrollable, but they are not irrelevant. diff --git a/docs/control/system_level_control/controllers.md b/docs/control/system_level_control/controllers.md new file mode 100644 index 000000000..fed919828 --- /dev/null +++ b/docs/control/system_level_control/controllers.md @@ -0,0 +1,15 @@ +# Control Strategies +There are several simple control strategies already implemented in the SLC paradigm. While fairly simplistic, they are meant to illustrate how information can be passed from different blocks/components (converters, storage, feedstocks, demand, etc.) and models (performance, cost, finance) to use within the SLC. + +The current control strategies are: +1. [Demand Following](#slc-demand-following) +2. [Profit Maximization](#slc-profit-max) +3. [Cost Minimization](#slc-cost-min) + +```{note} +The strategies currently implemented are experimental and will likely require further development for specific analyses. +``` + +All control strategies inherit `SystemLevelControlBase`, which is a base class that has common setup logic shared by all system-level control strategies. + +See additional information, which is more developer focused, about the [`SystemLevelControlBase`](#slc-base). diff --git a/docs/control/system_level_control/figures/dispatchable.png b/docs/control/system_level_control/figures/dispatchable.png new file mode 100644 index 000000000..d0968c777 Binary files /dev/null and b/docs/control/system_level_control/figures/dispatchable.png differ diff --git a/docs/control/system_level_control/figures/flexible.png b/docs/control/system_level_control/figures/flexible.png new file mode 100644 index 000000000..052a43d7a Binary files /dev/null and b/docs/control/system_level_control/figures/flexible.png differ diff --git a/docs/control/system_level_control/figures/slc_basic.png b/docs/control/system_level_control/figures/slc_basic.png new file mode 100644 index 000000000..db77963e0 Binary files /dev/null and b/docs/control/system_level_control/figures/slc_basic.png differ diff --git a/docs/control/system_level_control/figures/storage.png b/docs/control/system_level_control/figures/storage.png new file mode 100644 index 000000000..c71e10e5d Binary files /dev/null and b/docs/control/system_level_control/figures/storage.png differ diff --git a/docs/control/system_level_control/slc_cost_min.md b/docs/control/system_level_control/slc_cost_min.md new file mode 100644 index 000000000..dd4832020 --- /dev/null +++ b/docs/control/system_level_control/slc_cost_min.md @@ -0,0 +1,67 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.18.1 +kernelspec: + display_name: Python 3.11.13 ('h2i_env') + language: python + name: python3 +--- + +(slc-cost-min)= +# Cost Minimization System Level Controller + +The cost minimization controller, `CostMinimizationControl`, meets demand at minimum variable cost using merit-order dispatch. +Unlike the {ref}`demand following controller `, which splits demand evenly across dispatchable technologies, this controller dispatches the cheapest technologies first. + +## Dispatch Logic + +The controller follows a three-step dispatch process: + +1. **Flexible technologies** run at their available capacity (assumed zero marginal cost). Their output is subtracted from the demand. +2. **Storage technologies** absorb any surplus (charging) or provide the deficit (discharging). Residual demand is split evenly across storage technologies producing the demanded commodity. +3. **Dispatchable technologies** are dispatched by cheapest marginal cost first, each up to its rated capacity, until the remaining demand is met. + +## Marginal Cost Configuration + +Marginal costs are specified per dispatchable technology in the `cost_per_tech` dictionary under `system_level_control.control_parameters` in the plant config. Each entry can be: + +| Value | Description | +| --- | --- | +| Numeric (e.g. `0.05`) | Constant marginal cost in `$/(commodity_amount_units)` | +| `"buy_price"` | Uses the technology's configured purchase price | +| `"VarOpEx"` | Derives marginal cost from the technology's variable operating expenditure divided by total production | +| `"feedstock"` | Sums upstream feedstock `VarOpEx` values and divides by the technology's total production | + +```{note} +The dispatch order is determined by sorting dispatchable technologies by their **mean** marginal cost across all timesteps (cheapest first). +``` + +### Example Configuration + +```yaml +system_level_control: + control_strategy: CostMinimizationControl + control_parameters: + cost_per_tech: + natural_gas_plant: feedstock +``` + +## Inputs and Outputs + +In addition to the standard inputs inherited from `SystemLevelControlBase`, the cost minimization controller adds marginal cost inputs based on the `cost_per_tech` configuration (see above). + +The base inputs for technologies classified as `flexible`, `dispatchable`, and `storage` are: + +- `f"{tech_name}_{tech_output_commodity}_out"` +- `f"{tech_name}_rated_{tech_output_commodity}_production"` +- `f"{tech_name}_{tech_output_commodity}_demand"` + +## Limitations + +- Greedy dispatch: The merit-order approach is greedy - it does not look ahead across timesteps to optimize total cost over the simulation horizon. +- Even splitting across storage: Residual demand is split evenly across storage technologies regardless of capacity or state of charge. +- Demand is always met: Unlike the {ref}`profit maximization controller `, this controller always attempts to meet demand regardless of cost. diff --git a/docs/control/system_level_control/slc_demand_following.md b/docs/control/system_level_control/slc_demand_following.md new file mode 100644 index 000000000..3dbd9b3b6 --- /dev/null +++ b/docs/control/system_level_control/slc_demand_following.md @@ -0,0 +1,102 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.18.1 +kernelspec: + display_name: Python 3.11.13 ('h2i_env') + language: python + name: python3 +--- + +(slc-demand-following)= +# Demand Following System Level Controller + +The demand following controller, `DemandFollowingControl`, aims to fully meet the demand and does not have any inputs related to cost. + +The N2 diagram below shows an example system using the demand following controller with wind, natural gas, and battery storage technologies. + +```{code-cell} ipython3 +:tags: [remove-input] + +from h2integrate.core.h2integrate_model import H2IntegrateModel +import openmdao.api as om +import os + +import html +from pathlib import Path +from h2integrate import EXAMPLE_DIR +from IPython.display import HTML, display + +os.chdir(EXAMPLE_DIR / "35_system_level_control/battery_with_controller/") + +h2i_model = H2IntegrateModel("wind_ng_demand.yaml") +h2i_model.setup() + +om.n2( + h2i_model.prob, + outfile="h2i_n2.html", + display_in_notebook=False, + show_browser=False, +) + +n2_html = "h2i_n2.html" +n2_srcdoc = html.escape(Path(n2_html).read_text(encoding="utf-8")) +display( + HTML( + f'
' + f'' + '
' + ) +) +``` +## Dispatch Logic + +The demand is satisfied in a fixed three-step priority order, and each step's shortfall or surplus is passed to the next: + +1. **Curtailable techs** run at their available capacity. Their total output is subtracted from the demand, which may drive the residual demand negative (surplus). + +2. **Storage techs** receive the residual demand (which may be positive or negative). When residual demand is positive the storage is commanded to discharge; when negative it is commanded to charge. If multiple storage techs produce the demanded commodity, the residual demand is +split **evenly** across them (each receives ``demand / n_storage``). + +3. **Dispatchable techs** cover any remaining positive demand after storage. The remaining demand (floored at zero) is split **evenly** across all dispatchable techs that produce the demanded commodity (each receives ``remaining_demand / n_dispatchable``). + +### Example Configuration + +```yaml +system_level_control: + control_strategy: DemandFollowingControl + solver_options: # solver options for resolving feedback + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +``` + +## Inputs and Outputs + +The inputs for technologies classified as `curtailable`, `dispatchable`, and `storage` are: + +- `f"{tech_name}_{tech_output_commodity}_out"` +- `f"{tech_name}_rated_{tech_output_commodity}_production"` +- `f"{tech_name}_{tech_output_commodity}_demand"` + +The inputs for technologies classified as `feedstock` are: +- `f"{tech_name}_{commodity}_out"` + +## Systems with Heterogeneous Commodities + +The `DemandFollowingControl` controller can be used in hybrid systems where technologies produce different commodities. +For example, in a system where an electrolyzer produces hydrogen and the demand commodity is hydrogen, the controller can set the electricity-generating *curtailable* technologies' set-points to meet the hydrogen demand. + +This framework provides a starting point for hybrid energy system control but is intended to be extended with more sophisticated strategies for complex multi-commodity systems. + +## Limitations + +- No cost awareness: The controller dispatches technologies purely to meet demand without considering operational costs, commodity prices, or economic optimization. +- Even splitting across storage: When multiple storage technologies produce the demanded commodity, the residual demand is divided evenly among them (`demand / n_storage`), regardless of differences in capacity, state of charge, or efficiency. +- Even splitting across dispatchable technologies: Similarly, any remaining demand after storage dispatch is split evenly across all dispatchable technologies (`remaining_demand / n_dispatchable`), without accounting for marginal costs or capacity constraints. +- Fixed priority order: The dispatch order (curtailable → storage → dispatchable) is fixed in the current implementation. diff --git a/docs/control/system_level_control/slc_profit_max.md b/docs/control/system_level_control/slc_profit_max.md new file mode 100644 index 000000000..15a87657e --- /dev/null +++ b/docs/control/system_level_control/slc_profit_max.md @@ -0,0 +1,116 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.18.1 +kernelspec: + display_name: Python 3.11.13 ('h2i_env') + language: python + name: python3 +--- + +(slc-profit-max)= +# Profit Maximization System Level Controller + +The profit maximization controller, `ProfitMaximizationControl`, dispatches technologies only when the revenue from selling the commodity exceeds the marginal cost of production. This means demand may go **unmet** if dispatch is unprofitable. + +The N2 diagram below shows an example system using the profit maximization controller with wind, natural gas, and battery storage technologies. + +```{code-cell} ipython3 +:tags: [remove-input] + +from h2integrate.core.h2integrate_model import H2IntegrateModel +import openmdao.api as om +import os + +import html +from pathlib import Path +from IPython.display import HTML, display + +os.chdir("../../../examples/35_system_level_control/profit_maximization/") + +h2i_model = H2IntegrateModel("wind_ng_demand.yaml") +h2i_model.setup() + +om.n2( + h2i_model.prob, + outfile="h2i_n2.html", + display_in_notebook=False, + show_browser=False, +) + +n2_html = "h2i_n2.html" +n2_srcdoc = html.escape(Path(n2_html).read_text(encoding="utf-8")) +display( + HTML( + f'
' + f'' + '
' + ) +) +``` + +## Dispatch Logic + +The controller follows a three-step dispatch process: + +1. **Flexible technologies** run at available capacity - they are always profitable to produce (zero marginal cost). +2. **Storage technologies** absorb any surplus (charging) or provide the deficit (discharging), split evenly across storage technologies producing the demanded commodity. +3. **Dispatchable technologies** are dispatched in merit order (cheapest first), but **only at timesteps where their marginal cost is below the sell price**. At each timestep, the dispatch is the minimum of the remaining demand and the rated capacity, gated by the profitability check. + +```{note} +This is the key difference from the {ref}`cost minimization controller `: unprofitable dispatch is skipped entirely, so demand may go unmet. +``` + +## Commodity Sell Price + +The sell price can be configured in two ways in `system_level_control.control_parameters`: + +| Value | Description | +| --- | --- | +| Numeric (e.g. `0.06`) | Constant sell price in `$/(commodity_amount_units)` | +| String (e.g. `"profast_npv"`) | Name of a finance group in `finance_parameters.finance_groups` whose `model_inputs.commodity_sell_price` will be used | + +## Marginal Cost Configuration + +Marginal costs are configured identically to the {ref}`cost minimization controller ` via `cost_per_tech`. Each dispatchable technology's entry can be: + +| Value | Description | +| --- | --- | +| Numeric (e.g. `0.05`) | Constant marginal cost in `$/(commodity_amount_units)` | +| `"buy_price"` | Uses the technology's configured purchase price | +| `"VarOpEx"` | Derives cost from VarOpEx / total production | +| `"feedstock"` | Sums upstream feedstock VarOpEx / total production | + +### Example Configuration + +```yaml +system_level_control: + control_strategy: ProfitMaximizationControl + control_parameters: + commodity_sell_price: profast_npv # look up from finance group + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock VarOpEx +``` + +## Inputs and Outputs + +In addition to the standard inputs inherited from `SystemLevelControlBase`, this controller adds: + +- `commodity_sell_price` - the sell price per unit of the demanded commodity, shape `(n_timesteps,)` +- Marginal cost inputs per dispatchable technology based on `cost_per_tech` configuration + +The base inputs for technologies classified as `flexible`, `dispatchable`, and `storage` are: + +- `f"{tech_name}_{tech_output_commodity}_out"` +- `f"{tech_name}_rated_{tech_output_commodity}_production"` +- `f"{tech_name}_{tech_output_commodity}_demand"` + +## Limitations + +- Demand may go unmet: If no dispatchable technology is profitable at a given timestep, the remaining demand is not served. +- Even splitting across storage: Residual demand is split evenly across storage technologies regardless of capacity or state of charge. diff --git a/docs/control/system_level_control/system_level_control.md b/docs/control/system_level_control/system_level_control.md new file mode 100644 index 000000000..ad2ff2ecb --- /dev/null +++ b/docs/control/system_level_control/system_level_control.md @@ -0,0 +1,65 @@ +# System-Level Control + +System-level control (SLC) within H2I is meant to operate to control the entire plant with performance and cost feedback driving the operation of the plant or system in a closed-loop. It acts as a supervisory controller meaning that it can work to coordinate the entire system and can work with other technology level controllers. + +```{note} +The SLC framework is *technology-agnostic* and works with any H2I technology (converters, storage, feedstocks, demand components, etc.). It only cares about a technology's [`_control_classifier`](control_classifier.md) and the commodity it produces. To opt a technology in, set `_control_classifier` on its performance model; for `flexible` models, also call `self.apply_curtailment(outputs)` at the end of `compute()`. See the [developer guide on adding a new technology](../../developer_guide/adding_a_new_technology.md) for the full checklist. +``` + +The most basic SLC is shown in the figure below, where the SLC receives a `{commodity}_demand` signal. Based on that demand it emits a per-technology `{tech_name}_{commodity}_set_point` signal to each controlled technology. Each technology group contains a controller that converts the incoming `{commodity}_set_point` into the `{commodity}_command_value` actually consumed by the technology's performance model. From each technology block there is `{commodity}_out` (potentially changed by the command-value signal) that is connected via feedback to the SLC. The SLC will then attempt to converge the system where it will loop through changing the per-tech set points in attempts to meet the system demand until the overall system stops changing how much `{commodity}_out` each technology is outputting. + +```{note} +Every technology group has an *implicit passthrough controller* that converts `{commodity}_set_point` into `{commodity}_command_value`. If a technology defines its own `control_strategy`, that controller is used instead. This convention keeps the framework consistent and makes the set-point to command-value hand-off uniform for every technology, regardless of whether an SLC is present. +``` + +```{important} +SLC demand is set by connecting a demand component (for example, `GenericDemandComponent`) to the system. When SLC is enabled, only one demand component is currently supported. +``` + +```{figure} figures/slc_basic.png +:width: 70% +:align: center +``` + +The SLC control strategy and solver options are set within `plant_config.yaml` under the `"system_level_control"` section. + +```yaml +system_level_control: + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +``` + +To set the demand for SLC, define exactly one demand block/component in `tech_config.yaml`. For example: + +```yaml +electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 +``` + +## Control Strategies +There are several simple control strategies already implemented in the SLC paradigm. While fairly simplistic, they are meant to illustrate how information can be passed from different blocks/components (converters, storage, feedstocks, demand, etc.) and models (performance, cost, finance) to use within the SLC. + +The current control strategies are: +1. [Demand Following](#slc-demand-following) +2. [Profit Maximization](#slc-profit-max) +3. [Cost Minimization](#slc-cost-min) + +```{note} +The strategies currently implemented are experimental and will likely require further development for specific analyses. +``` + +All control strategies inherit [`SystemLevelControlBase`](#slc-base), which is a base class that has common setup logic shared by all system-level control strategies. + +See additional information, which is more developer focused, about the [`SystemLevelControlBase`](#slc-base). + +## Solver Options +The system attempts to converge the system using a solver. The solver is defined in `solver_options`. diff --git a/docs/control/system_level_control/system_level_control_base.md b/docs/control/system_level_control/system_level_control_base.md new file mode 100644 index 000000000..7319cad36 --- /dev/null +++ b/docs/control/system_level_control/system_level_control_base.md @@ -0,0 +1,43 @@ +(slc-base)= +# System Level Control Base Class + +The system-level control base class provides a common framework that all controllers (advanced control strategies) can use to configure required inputs and outputs for both the controllers and the components they control or track. This generalization is necessary to implement system-level control in H2I. If the technologies and controllers in a given system were fully specified, this base class would not be needed. + +```{important} +SLC demand is supplied by a demand component. When SLC is enabled, only one demand component is currently supported. +``` + +The base class also abstracts logic that may be shared across different controller types. It includes methods that could be useful, but not all methods will be relevant to every controller you implement. + +There are several methods that are already used in the simple controllers that inherit these system. + +Setup I/O for SLC controllers. +- `initialize()` +- `setup()` +- `_setup_commodity()` +- `_setup_tech_category()` +- `_setup_feedstock_category()` +- `find_converter_techs()` + - Note: this method is currently is not used but will be used for heterogeneous commodity systems. + +Functions for controlling components based on assigned control classifier. +- `_subtract_curtailable()` +- `_dispatch_storage()` +- `get_upstream_techs_for_commodity()` + +Helper functions for cost-aware controllers. +- `_setup_marginal_costs()` +- `_compute_marginal_costs()` +- `_buy_price_marginal_cost()` +- `_varopex_marginal_cost()` +- `_find_feedstock_techs()` +- `_feedstock_marginal_cost()` + +## Base Class and Methods + +```{eval-rst} +.. autoclass:: h2integrate.control.control_strategies.system_level.system_level_control_base.SystemLevelControlBase + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/control/controller_demonstrations.md b/docs/control/technology_level_control/controller_demonstrations.md similarity index 94% rename from docs/control/controller_demonstrations.md rename to docs/control/technology_level_control/controller_demonstrations.md index 825ca8675..e6e42af58 100644 --- a/docs/control/controller_demonstrations.md +++ b/docs/control/technology_level_control/controller_demonstrations.md @@ -28,7 +28,7 @@ The following example is an expanded form of `examples/14_wind_hydrogen_dispatch Here, we're highlighting the dispatch controller setup from `examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml`. Please note some sections are removed simply to highlight the controller sections -```{literalinclude} ../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml +```{literalinclude} ../../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml :language: yaml :lineno-start: 52 :linenos: true @@ -37,7 +37,7 @@ Here, we're highlighting the dispatch controller setup from We also include a demand technology to calculate how much demand is met, how much commodity is unused to meet the demand, and how much demand is remaining: -```{literalinclude} ../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml +```{literalinclude} ../../../examples/14_wind_hydrogen_dispatch/inputs/tech_config.yaml :language: yaml :lineno-start: 79 :linenos: true diff --git a/docs/control/figures/Pyomo_dispatch_figure.png b/docs/control/technology_level_control/figures/Pyomo_dispatch_figure.png similarity index 100% rename from docs/control/figures/Pyomo_dispatch_figure.png rename to docs/control/technology_level_control/figures/Pyomo_dispatch_figure.png diff --git a/docs/control/figures/example_peak_load_dispatch.png b/docs/control/technology_level_control/figures/example_peak_load_dispatch.png similarity index 100% rename from docs/control/figures/example_peak_load_dispatch.png rename to docs/control/technology_level_control/figures/example_peak_load_dispatch.png diff --git a/docs/control/figures/plm_optimized_dispatch.png b/docs/control/technology_level_control/figures/plm_optimized_dispatch.png similarity index 100% rename from docs/control/figures/plm_optimized_dispatch.png rename to docs/control/technology_level_control/figures/plm_optimized_dispatch.png diff --git a/docs/control/open-loop_controllers.md b/docs/control/technology_level_control/open-loop_controllers.md similarity index 99% rename from docs/control/open-loop_controllers.md rename to docs/control/technology_level_control/open-loop_controllers.md index cc601a137..60b7f378d 100644 --- a/docs/control/open-loop_controllers.md +++ b/docs/control/technology_level_control/open-loop_controllers.md @@ -49,7 +49,7 @@ from pathlib import Path from IPython.display import HTML, display # Change to an example directory -os.chdir("../../examples/14_wind_hydrogen_dispatch/") +os.chdir("../../../examples/14_wind_hydrogen_dispatch/") # Build and set up the model h2i_model = H2IntegrateModel("inputs/h2i_wind_to_h2_storage.yaml") diff --git a/docs/control/pyomo_controllers.md b/docs/control/technology_level_control/pyomo_controllers.md similarity index 94% rename from docs/control/pyomo_controllers.md rename to docs/control/technology_level_control/pyomo_controllers.md index 09f93e5af..19ebfc9f4 100644 --- a/docs/control/pyomo_controllers.md +++ b/docs/control/technology_level_control/pyomo_controllers.md @@ -33,7 +33,7 @@ from pathlib import Path from IPython.display import HTML, display # Change to an example directory -os.chdir("../../examples/18_pyomo_heuristic_dispatch/") +os.chdir("../../../examples/18_pyomo_heuristic_dispatch/") # Build and set up the model h2i_model = H2IntegrateModel("pyomo_heuristic_dispatch.yaml") @@ -75,7 +75,7 @@ For an example of how to use the heuristic Pyomo control framework with the `Heu ## Optimized Load Following Controller The optimized dispatch method is specified by setting the storage control to `OptimizedDispatchStorageController`. Unlike the heuristic method, the optimized dispatch method does not use `dispatch_rule_set` as an input in the `tech_config`. The `OptimizedDispatchStorageController` method maximizes the load met while minimizing the cost of the system (operating cost) over each specified time window. -The optimized dispatch using Pyomo is implemented differently than the heuristic dispatch in order to be able to properly aggregate the individual Pyomo technology models into a cohesive Pyomo plant model for the optimization solver. Practically, this means that the Pyomo elements of the dispatch (including the individual technology models and the plant model) are not exposed to the main H2I code flow, and do not appear in the N2 diagram. The figure below shows a flow diagram of how the dispatch is implemented. The green blocks below represent what is represented in the N2 diagram of the system. The dispatch routine is currently self-contained within the storage technology of the system, though it includes solving an aggregated plant model in the optimization +The optimized dispatch using Pyomo is implemented differently than the heuristic dispatch in order to be able to properly aggregate the individual Pyomo technology models into a cohesive Pyomo plant model for the optimization solver. The Pyomo plant model is from the perspective of the storage technology and is meant to track inflows of commodities and other parameters that might impact the dispatch of the storage from upstream technologies. Practically, this means that the Pyomo elements of the dispatch (including the individual technology models and the plant model) are not exposed to the main H2I code flow, and do not appear in the N2 diagram. The figure below shows a flow diagram of how the dispatch is implemented. The green blocks below represent what is represented in the N2 diagram of the system. The dispatch routine is currently self-contained within the storage technology of the system, though it includes solving an aggregated plant model in the optimization ```{note} Only the PySAM battery performance model can call Pyomo dispatch at this time. ``` diff --git a/docs/control/control_overview.md b/docs/control/technology_level_control/technology_control_overview.md similarity index 51% rename from docs/control/control_overview.md rename to docs/control/technology_level_control/technology_control_overview.md index 1b18fd85b..79b687b53 100644 --- a/docs/control/control_overview.md +++ b/docs/control/technology_level_control/technology_control_overview.md @@ -1,6 +1,20 @@ -# Control Overview +# Technology-Level Control -There are two different systematic approaches, or frameworks, in H2Integrate for control: [open-loop](#open-loop-control) and [pyomo](#pyomo-control). These two frameworks are useful in different situations and have different impacts on the system and control strategies that can be implemented. Both control frameworks are focused on technology-level dispatching. The open-loop framework has logic that is applicable to both storage technologies and converter technologies and the pyomo framework is currently applicable to storage technologies. However, we plan to extend them to work more generally as system controllers. Although the controllers are not operating at the system-level for now, they behave somewhat like system controllers in that they may curtail/discard commodity amounts exceeding the needs of the storage technology and the specified demand. However, any unused commodity may be connected to another down-stream component to avoid actual curtailment. +Every technology group in H2Integrate contains a controller subsystem. Its job is to translate a `{commodity}_set_point` signal into the `{commodity}_command_value` consumed by the technology's performance model. This convention keeps the framework consistent: every technology exposes the same set-point/command-value interface, regardless of whether a system-level controller (SLC) is present and regardless of how complex the underlying control logic is. + +(implicit-passthrough-controller)= +## Implicit passthrough controller + +If a technology does not define its own `control_strategy`, H2Integrate automatically inserts a `PassthroughController` into the technology group. This controller simply copies `{commodity}_set_point` to `{commodity}_command_value` so that: + +- When an SLC is present, the SLC's per-tech set-point is fed straight to the performance model. +- When no SLC is present, the set-point input defaults to a large value so the performance model behaves as if unconstrained (the model typically saturates at its rated capacity). + +If you add your own controller via `control_strategy` in the technology config, that controller is used instead of the passthrough. User-defined controllers must produce the same `{commodity}_command_value` output so the rest of the framework can connect to them in a uniform way. + +## Control frameworks + +There are two different systematic approaches, or frameworks, in H2Integrate for technology-level control: [open-loop](#open-loop-control) and [pyomo](#pyomo-control). These two frameworks are useful in different situations and have different impacts on the system and control strategies that can be implemented. Both control frameworks are focused on technology-level dispatching. The open-loop framework has logic that is applicable to both storage technologies and converter technologies and the pyomo framework is currently applicable to storage technologies. The technology-level storage controllers may curtail/discard commodity amounts exceeding the needs of the storage technology and the specified demand. However, any unused commodity may be connected to another down-stream component to avoid actual curtailment. (open-loop-control-framework)= ## Open-loop control framework diff --git a/docs/demand/demand_demo.md b/docs/demand/demand_demo.md index c0b0dd52b..51d8a037e 100644 --- a/docs/demand/demand_demo.md +++ b/docs/demand/demand_demo.md @@ -107,7 +107,7 @@ If we wanted to change the demand profiles for the battery (`battery`) or the de electrolyzer_capacity_MW = 60 ## Set the battery demand equal to the minimum electricity needed to keep the electrolyzer on -# h2i.prob.set_val("battery.electricity_demand", 0.1 * electrolyzer_capacity_MW, units="MW") +# h2i.prob.set_val("battery.electricity_set_point", 0.1 * electrolyzer_capacity_MW, units="MW") ## Set the demand of the demand component equal to the rated electrical capacity of the electrolyzer # h2i.prob.set_val("elec_load_demand.electricity_demand", electrolyzer_capacity_MW, units="MW") @@ -186,13 +186,13 @@ end_hour = 1000 x = list(range(start_hour, end_hour)) generation = h2i.prob.get_val("battery.electricity_in", units="MW") -battery_demand = h2i.prob.get_val("battery.electricity_demand", units="MW") +battery_demand = h2i.prob.get_val("battery.electricity_set_point", units="MW") battery_charge_discharge = h2i.prob.get_val("battery.electricity_out", units="MW") where_charge = [True if d<0 else False for d in battery_charge_discharge[start_hour:end_hour]] where_discharge = [True if d>0 else False for d in battery_charge_discharge[start_hour:end_hour]] -ax.plot(x, battery_demand[start_hour:end_hour], color="tab:green", alpha=0.5, lw=1.5, ls='-.', zorder=2, label="battery.electricity_demand") +ax.plot(x, battery_demand[start_hour:end_hour], color="tab:green", alpha=0.5, lw=1.5, ls='-.', zorder=2, label="battery.electricity_set_point") ax.plot(x, generation[start_hour:end_hour], color="tab:blue", alpha=1.0, lw=1.5, ls='--', zorder=3, label="battery.electricity_in") ax.plot(x, generation_with_battery[start_hour:end_hour], color="tab:pink", alpha=1.0, lw=1.5, ls='-', zorder=3, label="elec_combiner.electricity_out") ax.fill_between(x, generation[start_hour:end_hour], generation_with_battery[start_hour:end_hour], where=where_charge, color="tab:cyan", alpha=0.5, zorder=0, label="battery charging") @@ -243,7 +243,7 @@ If we re-run H2I and set the battery demand equal to the electrolyzer capacity i ```{code-cell} ipython3 # Set the battery demand equal to the rated electrical capacity of the electrolyzer -h2i.prob.set_val("battery.electricity_demand",electrolyzer_capacity_MW, units="MW") +h2i.prob.set_val("battery.electricity_set_point",electrolyzer_capacity_MW, units="MW") # Set the demand of the demand component equal to the rated electrical capacity of the electrolyzer h2i.prob.set_val("elec_load_demand.electricity_demand", electrolyzer_capacity_MW, units="MW") diff --git a/docs/developer_guide/adding_a_new_technology.md b/docs/developer_guide/adding_a_new_technology.md index 5c22e6d19..fbf3322c7 100644 --- a/docs/developer_guide/adding_a_new_technology.md +++ b/docs/developer_guide/adding_a_new_technology.md @@ -11,30 +11,55 @@ We'll start by walking through the process to add a simple solar performance mod 1. **Determine what type of technology you're adding** and if it fits into an existing H2Integrate bucket. In this case, we're adding a solar technology, which has an existing set of baseclasses that we will use. These baseclasses are defined in `h2integrate/converters/solar/solar_baseclass.py`. -They provide the basic structure for a solar technology, including the required inputs and outputs for the models. +They provide the basic structure for a solar technology, including the required class attributes, inputs, and outputs for the models. Here's what that baseclass looks like: ```python -class SolarPerformanceBaseClass(om.ExplicitComponent): +from h2integrate.core.model_baseclasses import PerformanceModelBaseClass + + +class SolarPerformanceBaseClass(PerformanceModelBaseClass): + # (min, max) time step lengths (in seconds) compatible with this model + _time_step_bounds = (3600, 3600) + # System-level control classifier; see the control classifier docs. + _control_classifier = "flexible" def initialize(self): - self.options.declare('plant_config', types=dict) - self.options.declare('tech_config', types=dict) - self.options.declare('driver_config', types=dict) + super().initialize() + # Commodity attributes are required by PerformanceModelBaseClass.setup() + self.commodity = "electricity" + self.commodity_rate_units = "kW" + self.commodity_amount_units = "kW*h" def setup(self): - self.add_output('electricity_out', val=0.0, shape=n_timesteps, units='kW', desc='Power output from SolarPlant') - - def compute(self, inputs, outputs): - """ - Computation for the OM component. + # PerformanceModelBaseClass.setup() registers the standard outputs: + # `{commodity}_out`, `total_{commodity}_produced`, + # `annual_{commodity}_produced`, `rated_{commodity}_production`, + # `replacement_schedule`, `capacity_factor`, `operational_life`. + # When `_control_classifier == "flexible"`, it also registers the + # `{commodity}_command_value` input and `uncurtailed_{commodity}_out` + # output used by `apply_curtailment()`. + super().setup() - For a template class this is not implement and raises an error. - """ + self.add_discrete_input( + "solar_resource_data", + val={}, + desc="Solar resource data dictionary", + ) + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): raise NotImplementedError("This method should be implemented in a subclass.") ``` +Note that the baseclass inherits from `PerformanceModelBaseClass` (defined in `h2integrate/core/model_baseclasses.py`) rather than `om.ExplicitComponent` directly. This baseclass: + +- Declares the standard `driver_config` / `plant_config` / `tech_config` options. +- Reads `n_timesteps`, `dt`, `plant_life`, and `fraction_of_year_simulated` from `plant_config`. +- Validates that `commodity`, `commodity_rate_units`, and `commodity_amount_units` are set on the subclass and registers all of the standard production outputs from those attributes. +- Adds command-value input and uncurtailed output for `flexible` models, and provides the `apply_curtailment()` helper. + +Every performance model must therefore define three class attributes and three commodity attributes; see the [Required class attributes](#required-class-attributes) section below for details. + 2. **Write the performance model for your technology.** We'll be wrapping a PySAM model for this example. We inherit from the baseclass and implement the `setup` and `compute` methods. @@ -59,16 +84,35 @@ class PYSAMSolarPlantPerformanceComponent(SolarPerformanceBaseClass): solar_resource = SolarResource(lat, lon, year) self.system_model.value("solar_resource_data", solar_resource.data) - def compute(self, inputs, outputs): + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): self.system_model.execute(0) outputs['electricity_out'] = self.system_model.Outputs.gen + + # Flexible models must apply curtailment from the upstream + # controller's command value at the end of compute(). This clips + # `{commodity}_out` to `min(uncurtailed, command_value)` and copies the + # raw output into `uncurtailed_{commodity}_out`. It is a no-op when + # no upstream controller is configured. + self.apply_curtailment(outputs) ``` ```{note} The `setup` method is where we initialize the PySAM model and set the solar resource data. We call the baseclass's `setup` method using the `super()` function, then added additional setup steps for the PySAM model. +The `compute` signature is `compute(self, inputs, outputs, discrete_inputs, discrete_outputs)` because performance models may use discrete I/O (e.g. resource data dictionaries). ``` +(required-class-attributes)= +#### Required class attributes + +Every performance model (whether it inherits from a category-specific baseclass like `SolarPerformanceBaseClass` or directly from `PerformanceModelBaseClass`) must define the following class attributes. These are typically set on the category baseclass so that all subclasses inherit them, but they can also be set or overridden on individual model classes. + +- `_control_classifier` (str): How the system-level controller (SLC) should treat this model. One of `"fixed"`, `"flexible"`, `"dispatchable"`, `"storage"`, or `"feedstock"`. The classifier determines whether the SLC sends a set-point to the model and how its output is folded into the dispatch logic. See the {ref}`control classifier docs ` (`docs/control/system_level_control/control_classifier.md`) for details. +- `_time_step_bounds` (tuple[int, int]): `(min, max)` simulation time-step lengths (in seconds) the model can run at. Use `(3600, 3600)` for hourly-only models and a wider range (e.g. `(300, 3600)`) for models that support sub-hourly time steps. The plant simulation `dt` must lie within every model's bounds. +- `commodity` (str), `commodity_rate_units` (str), `commodity_amount_units` (str): set in `initialize()` (or before calling `super().setup()`). These define the commodity produced by the model and the units used for its rate (e.g. `"kW"`, `"kg/h"`) and cumulative amount (e.g. `"kW*h"`, `"kg"`). `PerformanceModelBaseClass.setup()` uses them to register all of the standard outputs and will raise `NotImplementedError` if any are missing. + +For `flexible` models specifically, the baseclass automatically registers the `{commodity}_command_value` input and `uncurtailed_{commodity}_out` output, and the `compute()` method must call `self.apply_curtailment(outputs)` after writing the raw production to `outputs[f"{commodity}_out"]`. For `dispatchable` models the command value is consumed by the model's own internal logic; no curtailment helper is needed. `fixed` and `feedstock` models do not receive a command value at all. + 3. **Write the cost model for your technology.** The process for writing a cost model is similar to the performance model, with the required inputs and outputs defined in the technology cost model baseclass. The technology cost model baseclass should inherit the main cost model baseclass (`CostModelBaseClass`) with additional inputs, outputs, and setup added as necessary. The `CostModelBaseClass` has no predefined inputs, but all cost models must output `CapEx`, `OpEx`, and `cost_year`. @@ -151,10 +195,10 @@ class ATBUtilityPVCostModel(CostModelBaseClass): outputs["OpEx"] = opex ``` -4. **Write the control model for your technology.** -For this simplistic case, we will skip the control model because controls models can currently only be added to -storage technologies. The process for writing a control model is similar to the performance model, with the -required inputs and outputs defined in the baseclass. +4. **Write the control model for your technology (optional).** +Every technology group in H2Integrate contains a controller subsystem that converts a `{commodity}_set_point` signal into the `{commodity}_command_value` consumed by the performance model. If you do not specify a `control_strategy` for your technology, H2Integrate automatically inserts a `PassthroughController` that simply copies set-point to command value, so most new performance models do not need a custom controller. + +You only need to write a control model if you want to override that default — for example, to implement a heuristic or optimized dispatch strategy for a storage technology. The process is similar to the performance model: the controller's required inputs and outputs (`{commodity}_set_point` in, `{commodity}_command_value` out) are defined in the relevant control baseclass. See the [technology-level control overview](../control/technology_level_control/technology_control_overview.md) for available frameworks and supported controllers. 5. **Next, add the new technology to the `supported_models.py` file.** This file contains a dictionary of all the available technologies in H2Integrate. @@ -245,9 +289,6 @@ for tech_name, individual_tech_config in self.technology_config['technologies']. else: tech_group = self.plant.add_subsystem(tech_name, om.Group()) self.tech_names.append(tech_name) - - # Special HOPP handling for short-term - if tech_name in combined_performance_and_cost_model_technologies: ``` There are also situations where the models are still related but can be treated separately. @@ -259,7 +300,8 @@ This would require additional logic to first check if the cached object exists a There is an example of this in the `hopp_wrapper.py` file. ### Specifying allowable time step for your model -If you want your model to run with time steps other than 1 hour (3600 s), then you must specify the `_time_step_bounds` as a class attribute in each of your model classes. To run a simulation with a given time step, all models in the plant must be compatible with the desired time step. + +`_time_step_bounds` is a required class attribute (see [Required class attributes](#required-class-attributes)). The default category baseclasses use `(3600, 3600)` (hourly timestep only). If your underlying model supports sub-hourly or multi-hour simulation, set `_time_step_bounds` on your subclass: ```python class ECOElectrolyzerPerformanceModel(ElectrolyzerPerformanceBaseClass): @@ -272,6 +314,8 @@ class ECOElectrolyzerPerformanceModel(ElectrolyzerPerformanceBaseClass): _time_step_bounds = (300, 3600) # (5-min, 1-hour) ``` +To run a simulation with a given time step, every model in the plant must be compatible with the desired `dt` set in `plant_config`. + ### Other cases If you encounter a case that isn't covered here, please discuss it with the H2Integrate dev team for guidance. diff --git a/docs/storage/storage_models_index.md b/docs/storage/storage_models_index.md index d9a626d42..869459859 100644 --- a/docs/storage/storage_models_index.md +++ b/docs/storage/storage_models_index.md @@ -19,11 +19,11 @@ The inputs and outputs of storage performance models are generalized here for an - `commodity_in`: commodity available to use for charging storage If using a **feedback control strategy** (this means that the controller received the actual storage state periodically), the control-related inputs for control to the storage performance include: -- `commodity_demand`: the target demand profile to satisfy with the storage performance model and the input commodity. This is passed to the control strategy through the `pyomo_dispatch_solver` method. +- `commodity_set_point`: the target set-point profile to satisfy with the storage performance model and the input commodity. This is passed to the control strategy through the `pyomo_dispatch_solver` method. - `pyomo_dispatch_solver`: the control function from the storage controller that outputs dispatch commands to the storage performance model. If using an **open-loop control strategy**, the control input to the storage performance model is: -- `commodity_set_point`: the dispatch commands to the storage performance model, negative values indicate charge commands and positive values indicate discharge commands +- `commodity_command_value`: the dispatch commands to the storage performance model, negative values indicate charge commands and positive values indicate discharge commands Some storage models may also have design inputs of `max_charge_rate`, `storage_capacity`, and `max_discharge_rate`. diff --git a/docs/user_guide/how_to_set_up_an_analysis.md b/docs/user_guide/how_to_set_up_an_analysis.md index 384bd0398..2d871b987 100644 --- a/docs/user_guide/how_to_set_up_an_analysis.md +++ b/docs/user_guide/how_to_set_up_an_analysis.md @@ -94,6 +94,10 @@ Each model has its own set of inputs, which are defined in the source code for t Because there are no default values for the parameters, we suggest you look at an existing example that uses the model you are interested in to see what inputs are required or look at the source code for the model. The different models are defined in the `supported_models.py` file in the `h2integrate` package. +```{note} +Every technology group also contains a controller that converts a `{commodity}_demand` signal into the `{commodity}_set_point` consumed by the performance model. If you do not specify a `control_strategy` for a technology, H2Integrate automatically inserts an implicit passthrough controller that simply maps demand to set-point. See the [technology-level control overview](../control/technology_level_control/technology_control_overview.md) for more details. +``` + ## Plant config file The plant config file defines the system configuration, any parameters that might be shared across technologies, and how the technologies are connected together. diff --git a/examples/01_onshore_steel_mn/run_onshore_steel_mn.py b/examples/01_onshore_steel_mn/run_onshore_steel_mn.py index 7f35f4093..3132f54a4 100644 --- a/examples/01_onshore_steel_mn/run_onshore_steel_mn.py +++ b/examples/01_onshore_steel_mn/run_onshore_steel_mn.py @@ -9,7 +9,7 @@ # TODO: Update with demand module once it is developed demand_profile = np.ones(8760) * 720.0 model.setup() -model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") +model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() diff --git a/examples/02_texas_ammonia/run_texas_ammonia_plant.py b/examples/02_texas_ammonia/run_texas_ammonia_plant.py index 7fa013503..3508f6bab 100644 --- a/examples/02_texas_ammonia/run_texas_ammonia_plant.py +++ b/examples/02_texas_ammonia/run_texas_ammonia_plant.py @@ -11,7 +11,7 @@ # TODO: Update with demand module once it is developed demand_profile = np.ones(8760) * 640.0 model.setup() -model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") +model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() diff --git a/examples/09_co2/direct_ocean_capture/run_wind_wave_doc.py b/examples/09_co2/direct_ocean_capture/run_wind_wave_doc.py index 230801089..c329b8948 100644 --- a/examples/09_co2/direct_ocean_capture/run_wind_wave_doc.py +++ b/examples/09_co2/direct_ocean_capture/run_wind_wave_doc.py @@ -10,7 +10,7 @@ # TODO: Update with demand module once it is developed demand_profile = np.ones(8760) * 340.0 h2i_model.setup() -h2i_model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") +h2i_model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model h2i_model.run() diff --git a/examples/09_co2/ocean_alkalinity_enhancement/run_wind_wave_oae.py b/examples/09_co2/ocean_alkalinity_enhancement/run_wind_wave_oae.py index e70f724e3..012d20fd6 100644 --- a/examples/09_co2/ocean_alkalinity_enhancement/run_wind_wave_oae.py +++ b/examples/09_co2/ocean_alkalinity_enhancement/run_wind_wave_oae.py @@ -10,7 +10,7 @@ # TODO: Update with demand module once it is developed demand_profile = np.ones(8760) * 330.0 h2i_model.setup() -h2i_model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") +h2i_model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model h2i_model.run() diff --git a/examples/12_ammonia_synloop/run_ammonia_synloop.py b/examples/12_ammonia_synloop/run_ammonia_synloop.py index c0ee5d21d..df973f8b4 100644 --- a/examples/12_ammonia_synloop/run_ammonia_synloop.py +++ b/examples/12_ammonia_synloop/run_ammonia_synloop.py @@ -25,6 +25,6 @@ # TODO: Update with demand module once it is developed demand_profile = np.ones(8760) * 640.0 model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") model.run() model.post_process() diff --git a/examples/13_dispatch_for_electrolyzer/run_dispatch_for_electrolyzer.py b/examples/13_dispatch_for_electrolyzer/run_dispatch_for_electrolyzer.py index 24b5f646f..5c2ad309f 100644 --- a/examples/13_dispatch_for_electrolyzer/run_dispatch_for_electrolyzer.py +++ b/examples/13_dispatch_for_electrolyzer/run_dispatch_for_electrolyzer.py @@ -31,7 +31,7 @@ h2i.setup() electrolyzer_capacity_MW = 60 -h2i.prob.set_val("battery.electricity_demand", 0.1 * electrolyzer_capacity_MW, units="MW") +h2i.prob.set_val("battery.electricity_set_point", 0.1 * electrolyzer_capacity_MW, units="MW") h2i.prob.set_val("elec_load_demand.electricity_demand", electrolyzer_capacity_MW, units="MW") h2i.run() diff --git a/examples/16_natural_gas/plant_config.yaml b/examples/16_natural_gas/plant_config.yaml index 5024ff790..7dcdfc8ed 100644 --- a/examples/16_natural_gas/plant_config.yaml +++ b/examples/16_natural_gas/plant_config.yaml @@ -25,7 +25,7 @@ technology_interconnections: - [solar, elec_combiner, electricity, cable] # subtract the combined generation from the demand profile - [elec_combiner, electrical_load_demand, electricity, cable] - # connect the remaining electricity demand to the NG plant + # connect the remaining electricity demand to the NG plant's passthrough controller - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] # connect NG feedstock to NG plant - [ng_feedstock, natural_gas_plant, natural_gas, pipe] diff --git a/examples/18_pyomo_heuristic_dispatch/run_pyomo_heuristic_dispatch.py b/examples/18_pyomo_heuristic_dispatch/run_pyomo_heuristic_dispatch.py index 98bec0f88..2c617f700 100644 --- a/examples/18_pyomo_heuristic_dispatch/run_pyomo_heuristic_dispatch.py +++ b/examples/18_pyomo_heuristic_dispatch/run_pyomo_heuristic_dispatch.py @@ -12,7 +12,7 @@ # TODO: Update with demand module once it is developed model.setup() -model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") +model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() diff --git a/examples/23_solar_wind_ng_demand/plant_config.yaml b/examples/23_solar_wind_ng_demand/plant_config.yaml index cdb2b956b..5bf31591d 100644 --- a/examples/23_solar_wind_ng_demand/plant_config.yaml +++ b/examples/23_solar_wind_ng_demand/plant_config.yaml @@ -21,14 +21,14 @@ sites: # this will naturally grow as we mature the interconnected tech technology_interconnections: - [wind, combiner, electricity, cable] - # source_tech, dest_tech, transport_item, transport_type = connection + # source_tech, dest_tech, transport_item, transport_type = connection - [solar, combiner, electricity, cable] - [ng_feedstock, natural_gas_plant, natural_gas, pipe] - # connect NG feedstock to NG plant - - [combiner, electrical_load_demand, [electricity_out, electricity_in]] - # subtract wind and solar from demand + # connect NG feedstock to NG plant + - [combiner, electrical_load_demand, electricity, cable] + # subtract wind and solar from demand - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_set_point]] - # give remaining load demand to natural gas plant + # give remaining load demand to natural gas plant - [combiner, fin_combiner, electricity, cable] - [natural_gas_plant, fin_combiner, electricity, cable] resource_to_tech_connections: diff --git a/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py b/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py index cbdbefc0e..51de68b12 100644 --- a/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py +++ b/examples/30_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py @@ -12,7 +12,7 @@ # TODO: Update with demand module once it is developed model.setup() -model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") +model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() diff --git a/examples/33_peak_load_management/run_peak_load_management.py b/examples/33_peak_load_management/run_peak_load_management.py index e40497820..075ec6c61 100644 --- a/examples/33_peak_load_management/run_peak_load_management.py +++ b/examples/33_peak_load_management/run_peak_load_management.py @@ -45,7 +45,7 @@ ] ) -secondary_demand = model.prob.get_val("battery.electricity_demand", units="kW") +secondary_demand = model.prob.get_val("battery.electricity_set_point", units="kW") grid_output = model.prob.get_val("grid_buy.electricity_out", units="MW") time_series = build_time_series_from_plant_config(model.plant_config) diff --git a/examples/35_system_level_control/battery_with_controller/driver_config.yaml b/examples/35_system_level_control/battery_with_controller/driver_config.yaml new file mode 100644 index 000000000..74ae9ca19 --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a battery with a simple controller +general: + folder_output: outputs diff --git a/examples/35_system_level_control/battery_with_controller/plant_config.yaml b/examples/35_system_level_control/battery_with_controller/plant_config.yaml new file mode 100644 index 000000000..24e0aeb39 --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/plant_config.yaml @@ -0,0 +1,108 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + # connect NG feedstock to NG plant + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # wind output available for battery charging (electricity_in) + - [wind, battery, electricity, cable] + # wind to combined output + - [wind, combiner, electricity, cable] + # battery output to combined output + - [battery, combiner, electricity, cable] + # NG to combined output + - [natural_gas_plant, combiner, electricity, cable] + # combined supply to demand + - [combiner, electrical_load_demand, electricity, cable] +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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.] + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.05167052 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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: + renewables: + commodity: electricity + commodity_stream: wind + finance_groups: [profast_lco, profast_npv] + technologies: [wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock] + 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/35_system_level_control/battery_with_controller/run_wind_ng_demand.py b/examples/35_system_level_control/battery_with_controller/run_wind_ng_demand.py new file mode 100644 index 000000000..e3eedabbd --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/run_wind_ng_demand.py @@ -0,0 +1,67 @@ +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() + +# Plot the first 168 hours (1 week) +n_hours = 168 +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] + +fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True) + +# Stacked bar chart: wind + battery discharge + NG = total supply +axes[0].bar(hours, wind_out, width=1.0, color="tab:blue", label="Wind", align="edge") +axes[0].bar( + hours, + batt_discharge, + width=1.0, + bottom=wind_out, + color="tab:purple", + label="Battery Discharge", + align="edge", +) +axes[0].bar( + hours, + ng_out, + width=1.0, + bottom=wind_out + batt_discharge, + color="tab:orange", + label="Natural Gas", + align="edge", +) +axes[0].plot(hours, demand, color="black", linewidth=1.5, linestyle="--", label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("System-Level Control: First 168 Hours") +axes[0].legend() + +axes[1].plot(hours, batt_soc, color="tab:cyan") +axes[1].set_ylabel("Battery SOC (%)") + +axes[2].bar(hours, curtailed, width=1.0, color="tab:red", align="edge") +axes[2].set_ylabel("Curtailed (kW)") +axes[2].set_xlabel("Hour") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("slc_results.png", dpi=150) +plt.show() diff --git a/examples/35_system_level_control/battery_with_controller/tech_config.yaml b/examples/35_system_level_control/battery_with_controller/tech_config.yaml new file mode 100644 index 000000000..20d6e1f8a --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/tech_config.yaml @@ -0,0 +1,107 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + control_strategy: + model: SimpleStorageOpenLoopController + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 20000 # kW, required by storage base config + max_charge_rate: 20000 # kW (20 MW) + max_capacity: 80000 # kWh (80 MWh, 4-hour duration) + performance_parameters: + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + # performance_parameters: + round_trip_efficiency: 0.90 + control_parameters: + set_demand_as_avg_commodity_in: false + cost_parameters: + cost_year: 2022 + capacity_capex: 310 # $/kWh + charge_capex: 311 # $/kW + opex_fraction: 0.025 + combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 diff --git a/examples/35_system_level_control/battery_with_controller/wind_ng_demand.yaml b/examples/35_system_level_control/battery_with_controller/wind_ng_demand.yaml new file mode 100644 index 000000000..f2b5599a0 --- /dev/null +++ b/examples/35_system_level_control/battery_with_controller/wind_ng_demand.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This example uses wind, solar and a natural gas power plant to meet a fixed electrical load demand. +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/examples/35_system_level_control/complex_profit_max/complex_profit_max.yaml b/examples/35_system_level_control/complex_profit_max/complex_profit_max.yaml new file mode 100644 index 000000000..cd142e5eb --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/complex_profit_max.yaml @@ -0,0 +1,8 @@ +name: H2Integrate_config +system_summary: > + Complex profit-maximization example using wind, solar, battery storage, + natural gas, and grid electricity to meet a time-varying demand with + realistic wholesale electricity pricing. +driver_config: driver_config.yaml +plant_config: plant_config.yaml +technology_config: tech_config.yaml diff --git a/examples/35_system_level_control/complex_profit_max/driver_config.yaml b/examples/35_system_level_control/complex_profit_max/driver_config.yaml new file mode 100644 index 000000000..4cfa2fe2d --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: Complex profit-maximization dispatch with variable pricing and demand +general: + folder_output: outputs diff --git a/examples/35_system_level_control/complex_profit_max/plant_config.yaml b/examples/35_system_level_control/complex_profit_max/plant_config.yaml new file mode 100644 index 000000000..9270b35a2 --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/plant_config.yaml @@ -0,0 +1,51 @@ +name: plant_config +description: > + Wind + solar + battery + NG + grid plant in west Texas with + profit-maximization system-level control. Demonstrates complex + multi-source dispatch with realistic pricing. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 + solar_resource: + resource_model: OpenMeteoHistoricalSolarResource + resource_parameters: + resource_year: 2013 +technology_interconnections: + # Combine wind and solar into a single renewable stream + - [wind, renewable_combiner, electricity, cable] + - [solar, renewable_combiner, electricity, cable] + # Renewables charge the battery + - [renewable_combiner, battery, electricity, cable] + # Everything feeds into the final combiner + - [renewable_combiner, fin_combiner, electricity, cable] + - [battery, fin_combiner, electricity, cable] + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + - [natural_gas_plant, fin_combiner, electricity, cable] + - [grid_buy, fin_combiner, electricity, cable] + # Final combiner delivers to demand + - [fin_combiner, electrical_load_demand, electricity, cable] +resource_to_tech_connections: + - [site.wind_resource, wind, wind_resource_data] + - [site.solar_resource, solar, solar_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: ProfitMaximizationControl + control_parameters: + commodity_sell_price: 0.06 # $/kWh default; overridden in run script + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx + grid_buy: buy_price # use electricity_buy_price from cost config + solver_options: + solver_name: gauss_seidel + max_iter: 30 + convergence_tolerance: 1.0e-6 diff --git a/examples/35_system_level_control/complex_profit_max/run_complex_profit_max.py b/examples/35_system_level_control/complex_profit_max/run_complex_profit_max.py new file mode 100644 index 000000000..fadfbf92a --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/run_complex_profit_max.py @@ -0,0 +1,201 @@ +""" +Complex profit-maximization example with wind, solar, battery, NG, and grid. + +This example demonstrates profit-driven dispatch with: + - Wind + solar (flexible) combined into a single renewable stream + - Battery storage (200 MWh) for renewable energy shifting + - Natural gas turbine with marginal cost of $0.05/kWh (dispatchable) + - Grid buying with time-varying marginal cost (dispatchable) + - Non-constant demand (commercial load profile with seasonal variation) + - Realistic wholesale electricity sell prices (ERCOT-like diurnal + seasonal) + +The controller dispatches NG and grid only during hours when the sell price +exceeds each source's marginal cost, preferring the cheaper source first +(merit-order dispatch). Renewables run at full capacity (zero marginal cost) +and the battery shifts energy toward high-price hours. +""" + +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +# --------------------------------------------------------------------------- +# Build realistic time-varying profiles +# --------------------------------------------------------------------------- +n_timesteps = 8760 +hours_of_day = np.tile(np.arange(24), 365) +day_of_year = np.repeat(np.arange(365), 24) + +# --- Non-constant demand (commercial/industrial load) --- +# Base: 50 MW, business-hours bump to ~80 MW, summer cooling adds ~20 MW +base_demand = 50_000 # kW +daytime_bump = np.where((hours_of_day >= 7) & (hours_of_day < 21), 30_000, 0) +# Seasonal factor: 1.0 in winter, peaks at 1.4 in summer (day ~172 = June 21) +seasonal_demand = 1.0 + 0.4 * np.sin(2 * np.pi * (day_of_year - 172) / 365) +demand_profile = (base_demand + daytime_bump) * seasonal_demand + +# --- Realistic ERCOT-like wholesale sell price ($/kWh) --- +sell_price = np.zeros(n_timesteps) +for h in range(n_timesteps): + hour = hours_of_day[h] + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + # Seasonal base: higher in summer + season = 1.0 + 0.35 * np.sin(2 * np.pi * (day - 172) / 365) + + # Diurnal wholesale price shape (duck curve) + if hour < 6: + price = 0.025 # overnight trough + elif hour < 10: + price = 0.025 + (hour - 6) * 0.008 # morning ramp + elif hour < 15: + price = 0.035 # midday dip (solar flood) + elif hour < 20: + price = 0.035 + (hour - 15) * 0.018 # evening ramp to peak + else: + price = 0.125 - (hour - 20) * 0.025 # evening decline + + sell_price[h] = price * season + +# Add summer evening price spikes (simulate heat-wave scarcity) +for h in range(n_timesteps): + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + hour = hours_of_day[h] + if 150 <= day <= 250 and 17 <= hour <= 20 and day % 5 == 0: + sell_price[h] = max(sell_price[h], 0.20) + +# --- Grid buy marginal cost: tracks wholesale price + retail markup --- +grid_buy_price = sell_price + 0.02 # grid is always more expensive than selling + +# --------------------------------------------------------------------------- +# Create and run model +# --------------------------------------------------------------------------- +h2i = H2IntegrateModel("complex_profit_max.yaml") +h2i.setup() + +# Override demand profile +h2i.prob.set_val( + "plant.electrical_load_demand.electricity_demand", + demand_profile, +) + +# Override sell price with time-varying profile +h2i.prob.set_val( + "plant.system_level_controller.commodity_sell_price", + sell_price, + units="USD/(kW*h)", +) + +# Override grid buy price with time-varying profile +h2i.prob.set_val( + "plant.grid_buy.electricity_buy_price", + grid_buy_price, + units="USD/(kW*h)", +) + +h2i.run() +h2i.post_process() + +# --------------------------------------------------------------------------- +# Extract results +# --------------------------------------------------------------------------- +n_hours = 336 # two weeks for clearer patterns +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +solar_out = h2i.prob.get_val("plant.solar.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +grid_out = h2i.prob.get_val("plant.grid_buy.electricity_out")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = demand_profile[:n_hours] +price = sell_price[:n_hours] + +# --------------------------------------------------------------------------- +# Plot +# --------------------------------------------------------------------------- +fig, axes = plt.subplots(5, 1, figsize=(16, 16), sharex=True) + +# Panel 1: stacked bar supply vs demand +axes[0].bar(hours, wind_out, width=1.0, color="tab:blue", label="Wind", align="edge") +axes[0].bar( + hours, + solar_out, + width=1.0, + bottom=wind_out, + color="gold", + label="Solar", + align="edge", +) +axes[0].bar( + hours, + batt_discharge, + width=1.0, + bottom=wind_out + solar_out, + color="tab:purple", + label="Battery", + align="edge", +) +axes[0].bar( + hours, + ng_out, + width=1.0, + bottom=wind_out + solar_out + batt_discharge, + color="tab:orange", + label="Natural Gas", + align="edge", +) +axes[0].bar( + hours, + grid_out, + width=1.0, + bottom=wind_out + solar_out + batt_discharge + ng_out, + color="tab:gray", + label="Grid Buy", + align="edge", +) +axes[0].plot(hours, demand, "k--", linewidth=1.5, label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("Complex Profit Maximization: First Two Weeks") +axes[0].legend(loc="upper right", ncol=3) + +# Panel 2: battery state of charge +axes[1].plot(hours, batt_soc, color="tab:green") +axes[1].set_ylabel("SOC (kWh)") +axes[1].set_title("Battery State of Charge") + +# Panel 3: sell price vs marginal costs +axes[2].plot(hours, price * 100, color="tab:red", linewidth=0.8, label="Sell Price") +axes[2].axhline( + y=5.0, color="tab:orange", linestyle="--", alpha=0.8, label="NG Marginal Cost (5 ¢/kWh)" +) +axes[2].plot( + hours, (price + 0.02) * 100, color="tab:gray", linewidth=0.6, alpha=0.7, label="Grid Buy Cost" +) +axes[2].set_ylabel("Price (¢/kWh)") +axes[2].set_title("Electricity Prices vs Dispatch Costs") +axes[2].legend(loc="upper right") + +# Panel 4: individual dispatch decisions +axes[3].plot(hours, ng_out / 1000, color="tab:orange", label="NG (MW)") +axes[3].plot(hours, grid_out / 1000, color="tab:gray", label="Grid Buy (MW)") +axes[3].set_ylabel("Power (MW)") +axes[3].set_title("Dispatchable Generation Decisions") +axes[3].legend(loc="upper right") + +# Panel 5: renewable generation +axes[4].plot(hours, wind_out / 1000, color="tab:blue", label="Wind (MW)") +axes[4].plot(hours, solar_out / 1000, color="gold", label="Solar (MW)") +axes[4].set_ylabel("Power (MW)") +axes[4].set_xlabel("Hour") +axes[4].set_title("Flexible Renewable Generation") +axes[4].legend(loc="upper right") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("complex_profit_max_results.png", dpi=150) +print("Plot saved to complex_profit_max_results.png") +# plt.show() diff --git a/examples/35_system_level_control/complex_profit_max/tech_config.yaml b/examples/35_system_level_control/complex_profit_max/tech_config.yaml new file mode 100644 index 000000000..56879ab71 --- /dev/null +++ b/examples/35_system_level_control/complex_profit_max/tech_config.yaml @@ -0,0 +1,149 @@ +name: technology_config +description: > + Wind + solar + battery + NG + grid with marginal costs for + profit-maximization dispatch. Non-constant demand and realistic + grid pricing create a challenging dispatch optimization problem. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + solar: + performance_model: + model: PYSAMSolarPlantPerformanceModel + cost_model: + model: ATBUtilityPVCostModel + model_inputs: + performance_parameters: + pv_capacity_kWdc: 100000 # 100 MWdc + dc_ac_ratio: 1.3 + create_model_from: default + config_name: PVWattsSingleOwner + tilt_angle_func: lat-func + pysam_options: + SystemDesign: + inv_eff: 96.0 + module_type: 0 + losses: 14.08 + Lifetime: + dc_degradation: [0] + cost_parameters: + capex_per_kWac: 900 + opex_per_kWac_per_year: 15 + cost_year: 2024 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. + cost_parameters: + cost_year: 2023 + price: 4.2 + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 + system_capacity_mw: 100. + cost_parameters: + capex_per_kw: 1000 + fixed_opex_per_kw_per_year: 10.0 + variable_opex_per_mwh: 0.0 + cost_year: 2023 + grid_buy: + performance_model: + model: GridPerformanceModel + cost_model: + model: GridCostModel + model_inputs: + shared_parameters: + interconnection_size: 200000. # 200 MW interconnection limit + cost_parameters: + cost_year: 2024 + electricity_buy_price: 0.06 # $/kWh default; overridden in run script + interconnection_capex_per_kw: 0.0 + interconnection_opex_per_kw: 0.0 + fixed_interconnection_cost: 0.0 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 80000 # 80 MW default; overridden in run script + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 50000 # 50 MW charge rate + max_capacity: 200000 # 200 MWh capacity + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 50000 + cost_parameters: + cost_year: 2024 + capacity_capex: 280 + charge_capex: 300 + opex_fraction: 0.025 + renewable_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 2 + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 4 diff --git a/examples/35_system_level_control/no_battery/driver_config.yaml b/examples/35_system_level_control/no_battery/driver_config.yaml new file mode 100644 index 000000000..5b6b7e05a --- /dev/null +++ b/examples/35_system_level_control/no_battery/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a natural gas power plant +general: + folder_output: outputs diff --git a/examples/35_system_level_control/no_battery/plant_config.yaml b/examples/35_system_level_control/no_battery/plant_config.yaml new file mode 100644 index 000000000..010794a6e --- /dev/null +++ b/examples/35_system_level_control/no_battery/plant_config.yaml @@ -0,0 +1,104 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + # source_tech, dest_tech, transport_item, transport_type = connection + # connect NG feedstock to NG plant + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # combine the electricity and natural gas production + - [wind, combiner, electricity, cable] + - [natural_gas_plant, combiner, electricity, cable] + # subtract the combined electricity production from the demand + - [combiner, electrical_load_demand, electricity, cable] +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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.] + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.05167052 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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: + renewables: + commodity: electricity + commodity_stream: wind + finance_groups: [profast_lco, profast_npv] + technologies: [wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: combiner + finance_groups: [profast_lco] + technologies: [wind, natural_gas_plant, ng_feedstock] + 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/35_system_level_control/no_battery/run_wind_ng_demand.py b/examples/35_system_level_control/no_battery/run_wind_ng_demand.py new file mode 100644 index 000000000..38a7b16c4 --- /dev/null +++ b/examples/35_system_level_control/no_battery/run_wind_ng_demand.py @@ -0,0 +1,53 @@ +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() + +# Plot the first 100 hours +n_hours = 100 +hours = np.arange(n_hours) + +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] + +fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True) + +# Stacked bar chart of supply per hour with demand overlay +axes[0].bar(hours, wind_out, width=1.0, color="tab:blue", label="Wind", align="edge") +axes[0].bar( + hours, + ng_out, + width=1.0, + bottom=wind_out, + color="tab:orange", + label="Natural Gas", + align="edge", +) +axes[0].plot(hours, demand, color="black", linewidth=1.5, linestyle="--", label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("System-Level Control: First 100 Hours") +axes[0].legend(loc="upper right") + +axes[1].bar(hours, curtailed, width=1.0, color="tab:red", align="edge") +axes[1].set_ylabel("Curtailed (kW)") +axes[1].set_xlabel("Hour") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("slc_results.png", dpi=150) +plt.show() diff --git a/examples/35_system_level_control/no_battery/tech_config.yaml b/examples/35_system_level_control/no_battery/tech_config.yaml new file mode 100644 index 000000000..fa4881fbd --- /dev/null +++ b/examples/35_system_level_control/no_battery/tech_config.yaml @@ -0,0 +1,79 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 50000 + combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW diff --git a/examples/35_system_level_control/no_battery/wind_ng_demand.yaml b/examples/35_system_level_control/no_battery/wind_ng_demand.yaml new file mode 100644 index 000000000..f2b5599a0 --- /dev/null +++ b/examples/35_system_level_control/no_battery/wind_ng_demand.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This example uses wind, solar and a natural gas power plant to meet a fixed electrical load demand. +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/examples/35_system_level_control/profit_maximization/driver_config.yaml b/examples/35_system_level_control/profit_maximization/driver_config.yaml new file mode 100644 index 000000000..d7190b031 --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: Profit-maximization dispatch with diurnal pricing +general: + folder_output: outputs diff --git a/examples/35_system_level_control/profit_maximization/plant_config.yaml b/examples/35_system_level_control/profit_maximization/plant_config.yaml new file mode 100644 index 000000000..fff67f46d --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/plant_config.yaml @@ -0,0 +1,68 @@ +name: plant_config +description: Wind + NG plant with profit-maximization control and diurnal pricing. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +technology_interconnections: + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + - [wind, battery, electricity, cable] + - [wind, fin_combiner, electricity, cable] + - [battery, fin_combiner, electricity, cable] + - [natural_gas_plant, fin_combiner, electricity, cable] + - [fin_combiner, electrical_load_demand, electricity, cable] +resource_to_tech_connections: + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: ProfitMaximizationControl + control_parameters: + commodity_sell_price: profast_npv # name of finance group whose commodity_sell_price to use + cost_per_tech: + natural_gas_plant: feedstock # use upstream feedstock (ng_feedstock) VarOpEx + solver_options: + solver_name: gauss_seidel + max_iter: 20 +finance_parameters: + finance_groups: + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.06 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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: + electricity: + commodity: electricity + commodity_stream: fin_combiner + finance_groups: [profast_npv] + technologies: [wind, natural_gas_plant, ng_feedstock, battery] + 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/35_system_level_control/profit_maximization/run_profit_max.py b/examples/35_system_level_control/profit_maximization/run_profit_max.py new file mode 100644 index 000000000..1c1b4cbfa --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/run_profit_max.py @@ -0,0 +1,85 @@ +""" +Profit-maximization example with simple electricity price profiles. + +The controller dispatches the NG plant only during hours when the sell price +exceeds the marginal cost, demonstrating profit-driven curtailment of +dispatchable generation. +""" + +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +# -- Create and run model -- +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +# Setup first so we can set values +h2i.setup() + +h2i.run() +h2i.post_process() + +# -- Extract results -- +n_hours = 168 # first week +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] +price = h2i.prob.get_val("system_level_controller.commodity_sell_price")[:n_hours] + +# -- Plot -- +fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True) + +# Panel 1: stacked bar supply vs demand +axes[0].bar(hours, ng_out, width=1.0, color="tab:orange", label="Natural Gas", align="edge") +axes[0].bar( + hours, + batt_discharge, + width=1.0, + bottom=ng_out, + color="tab:purple", + label="Battery Discharge", + align="edge", +) +axes[0].bar( + hours, + wind_out, + width=1.0, + bottom=ng_out + batt_discharge, + color="tab:blue", + label="Wind", + align="edge", +) +axes[0].plot(hours, demand, "k--", linewidth=1.5, label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("Profit Maximization: First 168 Hours") +axes[0].legend(loc="upper right") + +# Panel 2: battery SOC +axes[1].plot(hours, batt_soc, color="tab:green") +axes[1].set_ylabel("SOC (kWh)") +axes[1].set_title("Battery State of Charge") + +# Panel 3: sell price vs NG marginal cost +axes[2].plot(hours, price * 100, color="tab:red", label="Sell Price") +axes[2].axhline(y=5.0, color="tab:orange", linestyle="--", label="NG Marginal Cost (5 ¢/kWh)") +axes[2].set_ylabel("Price (¢/kWh)") +axes[2].set_title("Electricity Sell Price vs NG Marginal Cost") +axes[2].legend(loc="upper right") + +# Panel 4: curtailed energy +axes[3].bar(hours, curtailed, width=1.0, color="tab:gray", align="edge") +axes[3].set_ylabel("Curtailed (kW)") +axes[3].set_xlabel("Hour") +axes[3].set_title("Curtailed Electricity") + +plt.tight_layout() +plt.savefig("profit_max_results.png", dpi=150) +print("Plot saved to profit_max_results.png") +# plt.show() diff --git a/examples/35_system_level_control/profit_maximization/tech_config.yaml b/examples/35_system_level_control/profit_maximization/tech_config.yaml new file mode 100644 index 000000000..b498acf4a --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/tech_config.yaml @@ -0,0 +1,103 @@ +name: technology_config +description: > + Wind farm, battery, NG plant with marginal cost for profit-maximization dispatch, + and a fixed electrical demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. + cost_parameters: + cost_year: 2023 + price: 4.2 + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 + system_capacity_mw: 100. + cost_parameters: + capex_per_kw: 1000 + fixed_opex_per_kw_per_year: 10.0 + variable_opex_per_mwh: 0.0 + cost_year: 2023 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 20000 + max_capacity: 80000 + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 20000 + cost_parameters: + cost_year: 2022 + capacity_capex: 310 + charge_capex: 311 + opex_fraction: 0.025 + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 diff --git a/examples/35_system_level_control/profit_maximization/wind_ng_demand.yaml b/examples/35_system_level_control/profit_maximization/wind_ng_demand.yaml new file mode 100644 index 000000000..e09f3dcda --- /dev/null +++ b/examples/35_system_level_control/profit_maximization/wind_ng_demand.yaml @@ -0,0 +1,4 @@ +name: H2Integrate_config +driver_config: driver_config.yaml +plant_config: plant_config.yaml +technology_config: tech_config.yaml diff --git a/examples/35_system_level_control/yes_battery/driver_config.yaml b/examples/35_system_level_control/yes_battery/driver_config.yaml new file mode 100644 index 000000000..5b6b7e05a --- /dev/null +++ b/examples/35_system_level_control/yes_battery/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a natural gas power plant +general: + folder_output: outputs diff --git a/examples/35_system_level_control/yes_battery/plant_config.yaml b/examples/35_system_level_control/yes_battery/plant_config.yaml new file mode 100644 index 000000000..d53a525ca --- /dev/null +++ b/examples/35_system_level_control/yes_battery/plant_config.yaml @@ -0,0 +1,108 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + # connect NG feedstock to NG plant + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # wind output available for battery charging (electricity_in) + - [wind, battery, electricity, cable] + # wind to combined output + - [wind, combiner, electricity, cable] + # battery net output to combined output + - [battery, combiner, electricity, cable] + # NG to combined output + - [natural_gas_plant, combiner, electricity, cable] + # combined supply to demand + - [combiner, electrical_load_demand, electricity, cable] +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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.] + profast_npv: + finance_model: ProFastNPV + model_inputs: + commodity_sell_price: 0.05167052 + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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: + renewables: + commodity: electricity + commodity_stream: wind + finance_groups: [profast_lco, profast_npv] + technologies: [wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock] + 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/35_system_level_control/yes_battery/run_wind_ng_demand.py b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py new file mode 100644 index 000000000..e3eedabbd --- /dev/null +++ b/examples/35_system_level_control/yes_battery/run_wind_ng_demand.py @@ -0,0 +1,67 @@ +import numpy as np +import matplotlib.pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() + +# Plot the first 168 hours (1 week) +n_hours = 168 +hours = np.arange(n_hours) + +wind_out = h2i.prob.get_val("plant.wind.electricity_out")[:n_hours] +ng_out = h2i.prob.get_val("plant.natural_gas_plant.electricity_out", units="kW")[:n_hours] +batt_discharge = h2i.prob.get_val("plant.battery.storage_electricity_discharge")[:n_hours] +batt_soc = h2i.prob.get_val("plant.battery.SOC")[:n_hours] +demand = h2i.prob.get_val("plant.electrical_load_demand.electricity_demand")[:n_hours] +curtailed = h2i.prob.get_val("plant.electrical_load_demand.unused_electricity_out")[:n_hours] + +fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=True) + +# Stacked bar chart: wind + battery discharge + NG = total supply +axes[0].bar(hours, wind_out, width=1.0, color="tab:blue", label="Wind", align="edge") +axes[0].bar( + hours, + batt_discharge, + width=1.0, + bottom=wind_out, + color="tab:purple", + label="Battery Discharge", + align="edge", +) +axes[0].bar( + hours, + ng_out, + width=1.0, + bottom=wind_out + batt_discharge, + color="tab:orange", + label="Natural Gas", + align="edge", +) +axes[0].plot(hours, demand, color="black", linewidth=1.5, linestyle="--", label="Demand") +axes[0].set_ylabel("Power (kW)") +axes[0].set_title("System-Level Control: First 168 Hours") +axes[0].legend() + +axes[1].plot(hours, batt_soc, color="tab:cyan") +axes[1].set_ylabel("Battery SOC (%)") + +axes[2].bar(hours, curtailed, width=1.0, color="tab:red", align="edge") +axes[2].set_ylabel("Curtailed (kW)") +axes[2].set_xlabel("Hour") + +for ax in axes: + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.savefig("slc_results.png", dpi=150) +plt.show() diff --git a/examples/35_system_level_control/yes_battery/tech_config.yaml b/examples/35_system_level_control/yes_battery/tech_config.yaml new file mode 100644 index 000000000..258e95da2 --- /dev/null +++ b/examples/35_system_level_control/yes_battery/tech_config.yaml @@ -0,0 +1,102 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + electrical_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + demand_profile: 30000 + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 20000 # kW (20 MW) + max_capacity: 80000 # kWh (80 MWh, 4-hour duration) + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 20000 # kW, required by storage base config + cost_parameters: + cost_year: 2022 + capacity_capex: 310 # $/kWh + charge_capex: 311 # $/kW + opex_fraction: 0.025 + combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 diff --git a/examples/35_system_level_control/yes_battery/wind_ng_demand.yaml b/examples/35_system_level_control/yes_battery/wind_ng_demand.yaml new file mode 100644 index 000000000..f2b5599a0 --- /dev/null +++ b/examples/35_system_level_control/yes_battery/wind_ng_demand.yaml @@ -0,0 +1,5 @@ +name: H2Integrate_config +system_summary: This example uses wind, solar and a natural gas power plant to meet a fixed electrical load demand. +driver_config: driver_config.yaml +technology_config: tech_config.yaml +plant_config: plant_config.yaml diff --git a/examples/35_system_level_control/yes_hydrogen/driver_config.yaml b/examples/35_system_level_control/yes_hydrogen/driver_config.yaml new file mode 100644 index 000000000..5b6b7e05a --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/driver_config.yaml @@ -0,0 +1,4 @@ +name: driver_config +description: This analysis runs a natural gas power plant +general: + folder_output: outputs diff --git a/examples/35_system_level_control/yes_hydrogen/plant_config.yaml b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml new file mode 100644 index 000000000..9244ce3fb --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/plant_config.yaml @@ -0,0 +1,97 @@ +name: plant_config +description: This plant is located in Texas, USA. +sites: + site: + latitude: 30.6617 + longitude: -101.7096 + resources: + wind_resource: + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2013 +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# this will naturally grow as we mature the interconnected tech +technology_interconnections: + - [ng_feedstock, natural_gas_plant, natural_gas, pipe] + # connect NG feedstock to NG plant + - [wind, battery, electricity, cable] + # wind output available for battery charging (electricity_in) + - [wind, elec_combiner, electricity, cable] + # wind to combined output + - [battery, elec_combiner, electricity, cable] + # battery net output to combined output + - [natural_gas_plant, elec_combiner, electricity, cable] + # electricity to electrolyzer + - [elec_combiner, electrolyzer, electricity, cable] + # electrolyzer to storage + - [electrolyzer, h2_storage, hydrogen, pipe] + # combine hydrogen streams + - [electrolyzer, h2_combiner, hydrogen, pipe] + - [h2_storage, h2_combiner, hydrogen, pipe] + # send hydrogen to load + - [h2_combiner, h2_load_demand, hydrogen, pipe] + # combined supply to demand +resource_to_tech_connections: + # connect the wind resource to the wind technology + - [site.wind_resource, wind, wind_resource_data] +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +system_level_control: + control_strategy: DemandFollowingControl + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +finance_parameters: + finance_groups: + profast_lco: + finance_model: ProFastLCO + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 # nominal return based on 2024 ATB baseline workbook for land-based wind + debt_equity_ratio: 2.62 # 2024 ATB uses 72.4% debt for land-based wind + property_tax_and_insurance: 0.03 # percent of CAPEX estimated based on https://www.nlr.gov/docs/fy25osti/91775.pdf https://www.house.mn.gov/hrd/issinfo/clsrates.aspx + total_income_tax_rate: 0.257 # 0.257 tax rate in 2024 atb baseline workbook, value here is based on federal (21%) and state in MN (9.8) + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 # total state and local sales tax in St. Louis County https://taxmaps.state.mn.us/salestax/ + debt_interest_rate: 0.07 # based on 2024 ATB nominal interest rate for land-based wind + debt_type: Revolving debt # can be "Revolving debt" or "One time loan". Revolving debt is H2FAST default and leads to much lower LCOH + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: MACRS # can be "MACRS" or "Straight line" + 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: + renewables: + commodity: electricity + commodity_stream: wind + finance_groups: [profast_lco] + technologies: [wind] + natural_gas: + commodity: electricity + commodity_stream: natural_gas_plant + finance_groups: [profast_lco] + technologies: [natural_gas_plant, ng_feedstock] + electricity: + commodity: electricity + commodity_stream: elec_combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock] + hydrogen: + commodity: hydrogen + commodity_stream: h2_combiner + finance_groups: [profast_lco] + technologies: [wind, battery, natural_gas_plant, ng_feedstock, electrolyzer, h2_storage] + 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/35_system_level_control/yes_hydrogen/run_multi_commodity.py b/examples/35_system_level_control/yes_hydrogen/run_multi_commodity.py new file mode 100644 index 000000000..d6662e37c --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/run_multi_commodity.py @@ -0,0 +1,19 @@ +import os + +from h2integrate import EXAMPLE_DIR +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +os.chdir(EXAMPLE_DIR / "35_system_level_control" / "yes_hydrogen") + +################################## +# Create an H2I model with a fixed electricity load demand +h2i = H2IntegrateModel("wind_ng_demand.yaml") + +h2i.setup() + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() diff --git a/examples/35_system_level_control/yes_hydrogen/tech_config.yaml b/examples/35_system_level_control/yes_hydrogen/tech_config.yaml new file mode 100644 index 000000000..739b34083 --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/tech_config.yaml @@ -0,0 +1,156 @@ +name: technology_config +description: This plant produces electricity with wind, solar, and a natural gas power plant to meet a fixed electrical load + demand. +technologies: + wind: + performance_model: + model: PYSAMWindPlantPerformanceModel + cost_model: + model: ATBWindPlantCostModel + model_inputs: + performance_parameters: + num_turbines: 20 + turbine_rating_kw: 6000 + hub_height: 115 + rotor_diameter: 170 + create_model_from: default + config_name: WindPowerSingleOwner + pysam_options: + Farm: + wind_farm_wake_model: 0 + Losses: + ops_strategies_loss: 10.0 + layout: + layout_mode: basicgrid + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: square + cost_parameters: + capex_per_kW: 1300 + opex_per_kW_per_year: 39 + cost_year: 2022 + ng_feedstock: + performance_model: + model: FeedstockPerformanceModel + cost_model: + model: FeedstockCostModel + model_inputs: + shared_parameters: + commodity: natural_gas + commodity_rate_units: MMBtu/h + performance_parameters: + rated_capacity: 750. # MMBtu + cost_parameters: + cost_year: 2023 + price: 4.2 # USD/MMBtu + annual_cost: 0. + start_up_cost: 0. + natural_gas_plant: + performance_model: + model: NaturalGasPerformanceModel + cost_model: + model: NaturalGasCostModel + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + system_capacity_mw: 100. # MW + cost_parameters: + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 0.0 # $/MWh + cost_year: 2023 + battery: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + model_inputs: + shared_parameters: + commodity: electricity + commodity_rate_units: kW + max_charge_rate: 20000 # kW (20 MW) + max_capacity: 80000 # kWh (80 MWh, 4-hour duration) + init_soc_fraction: 0.5 + max_soc_fraction: 1.0 + min_soc_fraction: 0.1 + performance_parameters: + round_trip_efficiency: 0.90 + demand_profile: 20000 # kW, required by storage base config + cost_parameters: + cost_year: 2022 + capacity_capex: 310 # $/kWh + charge_capex: 311 # $/kW + opex_fraction: 0.025 + elec_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 + electrolyzer: + performance_model: + model: ECOElectrolyzerPerformanceModel + cost_model: + model: SingliticoCostModel + model_inputs: + shared_parameters: + location: onshore + electrolyzer_capex: 1295 # $/kW overnight installed capital costs for a 1 MW system in 2022 USD/kW (DOE hydrogen program record 24005 Clean Hydrogen Production Cost Scenarios with PEM Electrolyzer Technology 05/20/24) (https://www.hydrogen.energy.gov/docs/hydrogenprogramlibraries/pdfs/24005-clean-hydrogen-production-cost-pem-electrolyzer.pdf?sfvrsn=8cb10889_1) + performance_parameters: + size_mode: normal + n_clusters: 10 + cluster_rating_MW: 3 + eol_eff_percent_loss: 10 # eol defined as x% change in efficiency from bol + uptime_hours_until_eol: 80000. # number of 'on' hours until electrolyzer reaches eol + include_degradation_penalty: true # include degradation + turndown_ratio: 0.1 # turndown_ratio = minimum_cluster_power/cluster_rating_MW + financial_parameters: + capital_items: + depr_period: 7 # based on PEM Electrolysis H2A Production Case Study Documentation estimate of 7 years. also see https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + replacement_cost_percent: 0.15 # percent of capex - H2A default case + h2_storage: + performance_model: + model: StoragePerformanceModel + cost_model: + model: GenericStorageCostModel + # control_strategy: + # model: DemandOpenLoopStorageController + model_inputs: + shared_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + max_charge_rate: 100.0 # kg/time step + max_capacity: 300.0 # kg + performance_parameters: + max_soc_fraction: 1.0 # fraction (0-1) + min_soc_fraction: 0.1 # fraction (0-1) + init_soc_fraction: 0.1 # fraction (0-1) + max_discharge_rate: 100.0 # kg/time step + charge_efficiency: 1.0 # fraction (0-1) + discharge_efficiency: 1.0 # fraction (0-1) + demand_profile: 500.0 # constant demand of 5000 kg per hour (see commodity_rate_units) + cost_parameters: + cost_year: 2022 + capacity_capex: 100 # $/kg + charge_capex: 100 # $/kg/h + opex_fraction: 0.025 + h2_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + h2_load_demand: + performance_model: + model: GenericDemandComponent + model_inputs: + performance_parameters: + commodity: hydrogen + commodity_rate_units: kg/h + demand_profile: 500.0 diff --git a/examples/35_system_level_control/yes_hydrogen/wind_ng_demand.yaml b/examples/35_system_level_control/yes_hydrogen/wind_ng_demand.yaml new file mode 100644 index 000000000..e09f3dcda --- /dev/null +++ b/examples/35_system_level_control/yes_hydrogen/wind_ng_demand.yaml @@ -0,0 +1,4 @@ +name: H2Integrate_config +driver_config: driver_config.yaml +plant_config: plant_config.yaml +technology_config: tech_config.yaml diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index c9221816c..9b6faba5c 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -25,7 +25,7 @@ def test_steel_example(subtests, temp_copy_of_example): # Set battery demand profile to electrolyzer capacity demand_profile = np.ones(8760) * 720.0 model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() @@ -137,7 +137,7 @@ def test_simple_ammonia_example(subtests, temp_copy_of_example): # Set battery demand profile to electrolyzer capacity demand_profile = np.ones(8760) * 640.0 model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() @@ -270,7 +270,7 @@ def test_ammonia_synloop_example(subtests, temp_copy_of_example): # Set battery demand profile to electrolyzer capacity demand_profile = np.ones(8760) * 640.0 model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() @@ -612,7 +612,7 @@ def test_wind_wave_doc_example(subtests, temp_copy_of_example): # Set battery demand profile demand_profile = np.ones(8760) * 340.0 model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() @@ -874,7 +874,7 @@ def test_electrolyzer_demand(subtests, temp_copy_of_example): electrolyzer_capacity_MW = 60 # Set the battery demand as 10% of the electrolyzer capacity - h2i.prob.set_val("battery.electricity_demand", 0.1 * electrolyzer_capacity_MW, units="MW") + h2i.prob.set_val("battery.electricity_set_point", 0.1 * electrolyzer_capacity_MW, units="MW") h2i.prob.set_val("elec_load_demand.electricity_demand", electrolyzer_capacity_MW, units="MW") h2i.run() @@ -911,7 +911,7 @@ def test_electrolyzer_demand(subtests, temp_copy_of_example): assert pytest.approx(127705.51498100, rel=1e-6) == electricity_to_electrolyzer # Re-run where we set the battery demand equal to the electrolyzer capacity - h2i.prob.set_val("battery.electricity_demand", electrolyzer_capacity_MW, units="MW") + h2i.prob.set_val("battery.electricity_set_point", electrolyzer_capacity_MW, units="MW") h2i.prob.set_val("elec_load_demand.electricity_demand", electrolyzer_capacity_MW, units="MW") h2i.run() @@ -1041,7 +1041,7 @@ def test_wind_wave_oae_example(subtests, temp_copy_of_example): # Set battery demand profile demand_profile = np.ones(8760) * 330.0 model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() @@ -1155,7 +1155,7 @@ def test_natural_gas_example(subtests, temp_copy_of_example): "electrical_load_demand.unmet_electricity_demand_out", units="kW" ) ng_electricity_set_point = model.prob.get_val( - "natural_gas_plant.electricity_set_point", units="kW" + "natural_gas_plant.electricity_command_value", units="kW" ) ng_electricity_production = model.prob.get_val("natural_gas_plant.electricity_out", units="kW") bat_init_charge = 200000.0 * 0.1 # max capacity in kW and initial charge rate percentage @@ -1408,7 +1408,7 @@ def test_pyomo_heuristic_dispatch_example(subtests, temp_copy_of_example): # TODO: Update with demand module once it is developed model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model model.run() @@ -1695,7 +1695,7 @@ def test_windard_pv_battery_dispatch_example(subtests, temp_copy_of_example): # Demand should be met for the last part of the year assert np.allclose( dispatched_electricity[8700:], - model.prob.get_val("battery.electricity_demand", units="MW")[8700:], + model.prob.get_val("battery.electricity_set_point", units="MW")[8700:], ) # Subtest for LCOE @@ -2694,7 +2694,7 @@ def test_pyomo_optimized_dispatch_example(subtests, temp_copy_of_example): # TODO: Update with demand module once it is developed model.setup() - model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") model.prob.set_val("electrical_load_demand.electricity_demand", demand_profile, units="MW") # Run the model @@ -2958,7 +2958,7 @@ def test_peak_load_management_example(subtests, temp_copy_of_example): assert soc.min() >= 10.0 - 1e-3 with subtests.test("Battery set point sum"): - set_point = model.prob.get_val("battery.electricity_set_point", units="kW") + set_point = model.prob.get_val("battery.electricity_command_value", units="kW") assert set_point.sum() == pytest.approx(60.0, rel=1e-3) with subtests.test("Battery electricity out sum"): diff --git a/h2integrate/control/control_rules/converters/generic_converter_min_operating_cost.py b/h2integrate/control/control_rules/converters/generic_converter_min_operating_cost.py index 56cd25d47..17c6a0cd6 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_min_operating_cost.py +++ b/h2integrate/control/control_rules/converters/generic_converter_min_operating_cost.py @@ -66,7 +66,7 @@ def initialize_parameters(self, inputs: dict, dispatch_inputs: dict): inputs (dict): Dictionary of numpy arrays (length = self.n_timesteps) containing at least: f"{commodity}_in" : Available generated commodity profile. - f"{commodity}_demand" : Demanded commodity output profile. + f"{commodity}_set_point" : Demanded commodity output profile. dispatch_inputs (dict): Dictionary of the dispatch input parameters from config """ diff --git a/h2integrate/control/control_rules/plant_dispatch_model.py b/h2integrate/control/control_rules/plant_dispatch_model.py index a28f29007..79074ed43 100644 --- a/h2integrate/control/control_rules/plant_dispatch_model.py +++ b/h2integrate/control/control_rules/plant_dispatch_model.py @@ -104,7 +104,7 @@ def initialize_parameters(self, inputs: dict, dispatch_params: dict): inputs (dict): Dictionary of numpy arrays (length = self.n_timesteps) containing at least: f"{commodity}_in" : Available generated commodity profile. - f"{commodity}_demand" : Demanded commodity output profile. + f"{commodity}_set_point" : Demanded commodity output profile. dispatch_inputs (dict): Dictionary of the dispatch input parameters from config """ diff --git a/h2integrate/control/control_rules/storage/pyomo_storage_rule_min_operating_cost.py b/h2integrate/control/control_rules/storage/pyomo_storage_rule_min_operating_cost.py index 6faf5b513..407585aaf 100644 --- a/h2integrate/control/control_rules/storage/pyomo_storage_rule_min_operating_cost.py +++ b/h2integrate/control/control_rules/storage/pyomo_storage_rule_min_operating_cost.py @@ -69,11 +69,11 @@ def initialize_parameters(self, inputs: dict, dispatch_inputs: dict): inputs (dict): Dictionary of numpy arrays (length = self.n_timesteps) containing at least: f"{commodity}_in" : Available generated commodity profile. - f"{commodity}_demand" : Demanded commodity output profile. + f"{commodity}_set_point" : Demanded commodity output profile. dispatch_inputs (dict): Dictionary of the dispatch input parameters from config """ - commodity_demand = inputs[f"{self.commodity_name}_demand"] + commodity_demand = inputs[f"{self.commodity_name}_set_point"] # Dispatch Parameters self.set_timeseries_parameter("cost_per_charge", dispatch_inputs["cost_per_charge"]) diff --git a/h2integrate/control/control_strategies/converters/__init__.py b/h2integrate/control/control_strategies/converters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/h2integrate/control/control_strategies/converters/curtailable_component.py b/h2integrate/control/control_strategies/converters/curtailable_component.py new file mode 100644 index 000000000..b6d9d44aa --- /dev/null +++ b/h2integrate/control/control_strategies/converters/curtailable_component.py @@ -0,0 +1,48 @@ +import numpy as np +import openmdao.api as om + + +class CurtailableComponentModel(om.ExplicitComponent): + _time_step_bounds = ( + 1, + 1e9, + ) # (min, max) time step lengths (in seconds) compatible with this model + + def initialize(self): + self.options.declare("commodity", types=str) + self.options.declare("plant_config", types=dict) + + def setup(self): + self.commodity = self.options["commodity"] + n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) + self.add_input(f"{self.commodity}_out", shape=n_timesteps, units=None, units_by_conn=True) + self.add_input( + f"{self.commodity}_command_value", + shape=n_timesteps, + units=None, + copy_units=f"{self.commodity}_out", + ) + + self.add_output( + f"modulated_{self.commodity}_out", + shape=n_timesteps, + units=None, + copy_units=f"{self.commodity}_out", + ) + self.add_output( + f"curtailed_{self.commodity}_out", + shape=n_timesteps, + units=None, + copy_units=f"{self.commodity}_out", + ) + + def compute(self, inputs, outputs): + set_point_difference = ( + inputs[f"{self.commodity}_out"] - inputs[f"{self.commodity}_command_value"] + ) + # commodity_out exceeds setpoint + excess_commodity = np.where(set_point_difference > 0, set_point_difference, 0) + commodity_to_setpoint = inputs[f"{self.commodity}_out"] - excess_commodity + + outputs[f"modulated_{self.commodity}_out"] = commodity_to_setpoint + outputs[f"curtailed_{self.commodity}_out"] = excess_commodity diff --git a/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py b/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py new file mode 100644 index 000000000..8b4fc0538 --- /dev/null +++ b/h2integrate/control/control_strategies/converters/test/test_curtailable_component.py @@ -0,0 +1,58 @@ +import numpy as np +import pytest +import openmdao.api as om +from pytest import fixture + +from h2integrate.control.control_strategies.converters.curtailable_component import ( + CurtailableComponentModel, +) + + +@fixture +def plant_config_base(): + plant_config = { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + "dt": 3600, + "timezone": 0, + "start_time": "01/01/2000 00:00:00", + }, + } + } + + return plant_config + + +@pytest.mark.unit +def test_curtailable_component(plant_config_base, subtests): + prob = om.Problem() + + prob.model.add_subsystem( + name="IVC1", + subsys=om.IndepVarComp(name="hydrogen_out", val=20, shape=8760, units="kg/h"), + promotes=["*"], + ) + + prob.model.add_subsystem( + name="IVC2", + subsys=om.IndepVarComp(name="hydrogen_command_value", val=10, shape=8760, units="kg/h"), + promotes=["*"], + ) + + prob.model.add_subsystem( + "comp", + CurtailableComponentModel( + plant_config=plant_config_base, + commodity="hydrogen", + ), + promotes=["*"], + ) + + prob.setup() + + prob.run_model() + + with subtests.test("modulated output"): + assert np.all(prob.get_val("comp.modulated_hydrogen_out", units="kg/h") == 10) diff --git a/h2integrate/control/control_strategies/passthrough_controller.py b/h2integrate/control/control_strategies/passthrough_controller.py new file mode 100644 index 000000000..589e7a598 --- /dev/null +++ b/h2integrate/control/control_strategies/passthrough_controller.py @@ -0,0 +1,88 @@ +import openmdao.api as om + + +class PassthroughController(om.ExplicitComponent): + """Simple controller that passes a set-point signal directly through as a command value. + + Every technology group is expected to have a controller subsystem. When a + technology does not define its own ``control_strategy``, this passthrough + controller is inserted automatically so that the group exposes a uniform + ``{commodity}_set_point`` input and ``{commodity}_command_value`` output interface. + + In a system-level-control (SLC) configuration the SLC output is connected to + ``{commodity}_set_point``; this component copies that signal to + ``{commodity}_command_value`` which the performance model consumes. + + When no SLC is present the input defaults to a very large value so that + production is unconstrained, making the component a harmless no-op. + """ + + _time_step_bounds = (1, float("inf")) + + def initialize(self): + self.options.declare("commodity", types=str) + self.options.declare("n_timesteps", types=int) + self.options.declare( + "commodity_rate_units", + types=str, + default=None, + desc="Units for the commodity rate (e.g. 'kW', 'kg/h'). " + "When provided, explicit units are used on the set-point input " + "so the variable works even when unconnected (no SLC). " + "The command-value output always uses units_by_conn to inherit " + "units from the connected performance model.", + ) + + def setup(self): + commodity = self.options["commodity"] + n_timesteps = self.options["n_timesteps"] + commodity_rate_units = self.options["commodity_rate_units"] + + # Use explicit units on the input when available so that the + # variable remains valid even when no SLC is connected + # (units_by_conn fails on unconnected variables). + # Default to a large value so that when no SLC is connected the + # downstream performance model behaves as if unconstrained (the perf + # model typically saturates the command value at its rated capacity). + # We avoid extreme values (e.g. 1e30) here because they pollute the + # solver relative-residual check and cause premature false convergence + # in cyclic system-level control configurations. + default_val = 1.0e9 + + if commodity_rate_units is not None: + self.add_input( + f"{commodity}_set_point", + val=default_val, + shape=n_timesteps, + desc=f"Set-point signal for {commodity}", + units=commodity_rate_units, + ) + else: + self.add_input( + f"{commodity}_set_point", + val=default_val, + shape=n_timesteps, + desc=f"Set-point signal for {commodity}", + units_by_conn=True, + ) + + if commodity_rate_units is not None: + self.add_output( + f"{commodity}_command_value", + val=default_val, + shape=n_timesteps, + desc=f"Command value for {commodity} (passthrough of set-point)", + units=commodity_rate_units, + ) + else: + self.add_output( + f"{commodity}_command_value", + val=default_val, + shape=n_timesteps, + desc=f"Command value for {commodity} (passthrough of set-point)", + units_by_conn=True, + ) + + def compute(self, inputs, outputs): + commodity = self.options["commodity"] + outputs[f"{commodity}_command_value"] = inputs[f"{commodity}_set_point"] diff --git a/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py b/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py index 94203ddf3..0f6822831 100644 --- a/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py +++ b/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py @@ -171,7 +171,7 @@ def pyomo_dispatch_solver( inputs (dict): Dictionary of numpy arrays (length = self.n_timesteps) containing at least: f"{commodity}_in" : available generated commodity profile. - f"{commodity}_demand" : demanded commodity output profile. + f"{commodity}_set_point" : set-point commodity output profile. commodity (str, optional): Base commodity name (e.g. "electricity", "hydrogen"). Default: self.config.commodity. diff --git a/h2integrate/control/control_strategies/storage/demand_openloop_storage_controller.py b/h2integrate/control/control_strategies/storage/demand_openloop_storage_controller.py index 4455fb11e..74ad84d20 100644 --- a/h2integrate/control/control_strategies/storage/demand_openloop_storage_controller.py +++ b/h2integrate/control/control_strategies/storage/demand_openloop_storage_controller.py @@ -96,12 +96,12 @@ def compute(self, inputs, outputs): Expected input keys: * ``_in``: Timeseries of commodity available at each time step. - * ``_demand``: Timeseries demand profile. + * ``_set_point``: Timeseries set-point profile. * ``max_charge_rate``: Maximum charge rate permitted. * ``max_capacity``: Maximum total storage capacity. Outputs populated: - * ``_set_point``: Dispatch command to storage, + * ``_command_value``: Dispatch command to storage, negative when charging, positive when discharging. Control logic includes: @@ -111,7 +111,7 @@ def compute(self, inputs, outputs): * Tracking energy shortfalls and excesses at each time step. Raises: - UserWarning: If the demand profile is entirely zero. + UserWarning: If the set-point profile is entirely zero. UserWarning: If ``max_charge_rate`` or ``max_capacity`` is negative. Returns: @@ -140,7 +140,7 @@ def compute(self, inputs, outputs): # the previous time step's value soc = deepcopy(init_soc_fraction) - demand_profile = inputs[f"{commodity}_demand"] + demand_profile = inputs[f"{commodity}_set_point"] # initialize outputs soc_array = np.zeros(self.n_timesteps) @@ -192,4 +192,4 @@ def compute(self, inputs, outputs): # Record the SOC for the current time step soc_array[t] = deepcopy(soc) - outputs[f"{commodity}_set_point"] = set_point_array + outputs[f"{commodity}_command_value"] = set_point_array diff --git a/h2integrate/control/control_strategies/storage/heuristic_pyomo_controller.py b/h2integrate/control/control_strategies/storage/heuristic_pyomo_controller.py index 5eebf4fb3..aeba9e66d 100644 --- a/h2integrate/control/control_strategies/storage/heuristic_pyomo_controller.py +++ b/h2integrate/control/control_strategies/storage/heuristic_pyomo_controller.py @@ -176,7 +176,7 @@ def pyomo_dispatch_solver( inputs (dict): Dictionary of numpy arrays (length = self.n_timesteps) containing at least: f"{commodity}_in" : available generated commodity profile. - f"{commodity}_demand" : demanded commodity output profile. + f"{commodity}_set_point" : set-point commodity output profile. commodity (str, optional): Base commodity name (e.g. "electricity", "hydrogen"). Default: self.config.commodity. @@ -213,7 +213,7 @@ def pyomo_dispatch_solver( commodity_in = inputs[self.config.commodity + "_in"][ t : t + self.config.n_control_window_hours ] - demand_in = inputs[f"{commodity_name}_demand"][ + demand_in = inputs[f"{commodity_name}_set_point"][ t : t + self.config.n_control_window_hours ] diff --git a/h2integrate/control/control_strategies/storage/openloop_storage_control_base.py b/h2integrate/control/control_strategies/storage/openloop_storage_control_base.py index d7a93c59b..27b91ad42 100644 --- a/h2integrate/control/control_strategies/storage/openloop_storage_control_base.py +++ b/h2integrate/control/control_strategies/storage/openloop_storage_control_base.py @@ -142,7 +142,7 @@ class StorageOpenLoopControlBase(om.ExplicitComponent): """Base OpenMDAO component for open-loop demand tracking. This component defines the interfaces required for open-loop demand - controllers, including inputs for demand, available commodity, and outputs + controllers, including inputs for set-point, available commodity, and outputs dispatch command profile. """ @@ -164,11 +164,11 @@ def setup(self): demand_data = self.config.demand_profile self.add_input( - f"{commodity}_demand", + f"{commodity}_set_point", val=demand_data if not isinstance(demand_data, dict) else demand_data["demand"], shape=self.n_timesteps, units=self.config.commodity_rate_units, - desc=f"Demand profile of {commodity}", + desc=f"Set-point profile of {commodity}", ) self.add_input( @@ -180,7 +180,7 @@ def setup(self): ) self.add_output( - f"{commodity}_set_point", + f"{commodity}_command_value", val=0.0, shape=self.n_timesteps, units=self.config.commodity_rate_units, @@ -197,8 +197,8 @@ def compute(): raise NotImplementedError("This method should be implemented in a subclass.") def common_checks_needed_in_compute(self, inputs): - if np.all(inputs[f"{self.config.commodity}_demand"] == 0.0): - msg = "Demand profile is zero, check that demand profile is input" + if np.all(inputs[f"{self.config.commodity}_set_point"] == 0.0): + msg = "Set-point profile is zero, check that set-point profile is input" raise UserWarning(msg) if inputs["max_charge_rate"][0] < 0: msg = ( diff --git a/h2integrate/control/control_strategies/storage/optimized_pyomo_controller.py b/h2integrate/control/control_strategies/storage/optimized_pyomo_controller.py index ddb9cef8e..58c6108f0 100644 --- a/h2integrate/control/control_strategies/storage/optimized_pyomo_controller.py +++ b/h2integrate/control/control_strategies/storage/optimized_pyomo_controller.py @@ -201,7 +201,7 @@ def pyomo_dispatch_solver( inputs (dict): Dictionary of numpy arrays (length = self.n_timesteps) containing at least: f"{commodity}_in" : available generated commodity profile. - f"{commodity}_demand" : demanded commodity output profile. + f"{commodity}_set_point" : set-point commodity output profile. commodity (str, optional): Base commodity name (e.g. "electricity", "hydrogen"). Default: self.config.commodity. @@ -239,7 +239,7 @@ def pyomo_dispatch_solver( commodity_in = inputs[f"{self.config.commodity}_in"][ t : t + self.config.n_control_window_hours ] - demand_in = inputs[f"{commodity_name}_demand"][ + demand_in = inputs[f"{commodity_name}_set_point"][ t : t + self.config.n_control_window_hours ] @@ -290,7 +290,7 @@ def initialize_parameters(self, inputs): inputs (dict): Dictionary of numpy arrays (length = self.n_timesteps) containing at least: f"{commodity}_in" : Available generated commodity profile. - f"{commodity}_demand" : Demanded commodity output profile. + f"{commodity}_set_point" : Set-point commodity output profile. """ # Where pyomo model communicates with the rest of the controller diff --git a/h2integrate/control/control_strategies/storage/plm_openloop_storage_controller.py b/h2integrate/control/control_strategies/storage/plm_openloop_storage_controller.py index 31624ed8a..4651199ff 100644 --- a/h2integrate/control/control_strategies/storage/plm_openloop_storage_controller.py +++ b/h2integrate/control/control_strategies/storage/plm_openloop_storage_controller.py @@ -242,16 +242,16 @@ def compute(self, inputs, outputs): Expected input keys: * ``_in``: Timeseries of commodity available at each time step. - * ``_demand``: Timeseries demand profile. + * ``_set_point``: Timeseries set-point profile. * ``max_charge_rate``: Maximum charge rate permitted. * ``max_capacity``: Maximum total storage capacity. Outputs populated: - * ``_set_point``: Dispatch command to storage, + * ``_command_value``: Dispatch command to storage, negative when charging, positive when discharging. Raises: - UserWarning: If the demand profile is entirely zero. + UserWarning: If the set-point profile is entirely zero. UserWarning: If ``max_charge_rate`` or ``max_capacity`` is negative. Returns: @@ -279,7 +279,7 @@ def compute(self, inputs, outputs): # Build timestamped demand dictionaries from simulation timeline. demand_profile = self._build_demand_profile_dict( - inputs[f"{commodity}_demand"], + inputs[f"{commodity}_set_point"], self.time_index, ) @@ -381,7 +381,7 @@ def compute(self, inputs, outputs): if soc >= soc_max: charging = False - outputs[f"{commodity}_set_point"] = set_point_array + outputs[f"{commodity}_command_value"] = set_point_array # insert warning message if for any time step the magnitude of # any negative entry in set_point_array is greater than inputs[f"{commodity}_in"] diff --git a/h2integrate/control/control_strategies/storage/simple_openloop_controller.py b/h2integrate/control/control_strategies/storage/simple_openloop_controller.py index 418a8ec05..82e95d380 100644 --- a/h2integrate/control/control_strategies/storage/simple_openloop_controller.py +++ b/h2integrate/control/control_strategies/storage/simple_openloop_controller.py @@ -72,19 +72,19 @@ def setup(self): def compute(self, inputs, outputs): """ - Simple controller that outputs `commodity_set_point`, - the dispatch set-points for each timestep in `commodity_rate_units`. + Simple controller that outputs `commodity_command_value`, + the dispatch command values for each timestep in `commodity_rate_units`. Negative values command charging, positive values command discharging. """ if ( self.config.set_demand_as_avg_commodity_in - and inputs[f"{self.config.commodity}_demand"].sum() > 0 + and inputs[f"{self.config.commodity}_set_point"].sum() > 0 ): msg = ( - "A non-zero demand profile was input but set_demand_as_avg_commodity_in is True." - " The input demand profile will not be used, the demand profile will be " + "A non-zero set-point profile was input but set_demand_as_avg_commodity_in is True." + " The input set-point profile will not be used, the set-point profile will be " f"calculated as the mean of ``{self.config.commodity}_in``. " ) raise ValueError(msg) @@ -95,11 +95,11 @@ def compute(self, inputs, outputs): self.n_timesteps ) else: - commodity_demand = inputs[f"{self.config.commodity}_demand"] + commodity_demand = inputs[f"{self.config.commodity}_set_point"] - # Assign the set point as the difference between the demand and the input commodity - # when demand > input, the set point is positive to command discharging - # when demand < input, the set point is negative to command charging - outputs[f"{self.config.commodity}_set_point"] = ( + # Assign the command value as the difference between the set-point and the input commodity + # when set-point > input, the command value is positive to command discharging + # when set-point < input, the command value is negative to command charging + outputs[f"{self.config.commodity}_command_value"] = ( commodity_demand - inputs[f"{self.config.commodity}_in"] ) diff --git a/h2integrate/control/control_strategies/storage/test/test_heuristic_controllers.py b/h2integrate/control/control_strategies/storage/test/test_heuristic_controllers.py index 4530af4ab..1ac14ab73 100644 --- a/h2integrate/control/control_strategies/storage/test/test_heuristic_controllers.py +++ b/h2integrate/control/control_strategies/storage/test/test_heuristic_controllers.py @@ -223,7 +223,7 @@ def test_heuristic_load_following_battery_dispatch( # Setup the system and required values prob.setup() prob.set_val("battery.electricity_in", electricity_in) - prob.set_val("battery.electricity_demand", demand_in) + prob.set_val("battery.electricity_set_point", demand_in) # Run the model prob.run_model() @@ -399,7 +399,7 @@ def test_heuristic_load_following_battery_dispatch( prob.setup() prob.set_val("battery.electricity_in", electricity_in) - prob.set_val("battery.electricity_demand", demand_in) + prob.set_val("battery.electricity_set_point", demand_in) # Run the model prob.run_model() @@ -449,7 +449,7 @@ def test_heuristic_load_following_battery_dispatch( # Setup the system and required values prob.setup() prob.set_val("battery.electricity_in", electricity_in) - prob.set_val("battery.electricity_demand", demand_in) + prob.set_val("battery.electricity_set_point", demand_in) # Run the model prob.run_model() @@ -586,7 +586,7 @@ def test_heuristic_load_following_battery_dispatch_change_capacities( prob.set_val("IVC2.storage_capacity", 200000, units="kW*h") prob.set_val("battery.electricity_in", electricity_in) - prob.set_val("battery.electricity_demand", demand_in) + prob.set_val("battery.electricity_set_point", demand_in) # Run the model prob.run_model() @@ -806,7 +806,7 @@ def test_heuristic_load_following_dispatch_with_generic_storage( # Setup the system and required values prob.setup() prob.set_val("h2_storage.hydrogen_in", commodity_in) - prob.set_val("h2_storage.hydrogen_demand", commodity_demand) + prob.set_val("h2_storage.hydrogen_set_point", commodity_demand) # Run the model prob.run_model() @@ -954,7 +954,7 @@ def test_heuristic_dispatch_with_autosizing_storage_demand_less_than_avg_in( # Setup the system and required values prob.setup() prob.set_val("h2_storage.hydrogen_in", commodity_in) - prob.set_val("h2_storage.hydrogen_demand", commodity_demand) + prob.set_val("h2_storage.hydrogen_set_point", commodity_demand) # Run the model prob.run_model() diff --git a/h2integrate/control/control_strategies/storage/test/test_openloop_controllers.py b/h2integrate/control/control_strategies/storage/test/test_openloop_controllers.py index 350d90c30..074bea700 100644 --- a/h2integrate/control/control_strategies/storage/test/test_openloop_controllers.py +++ b/h2integrate/control/control_strategies/storage/test/test_openloop_controllers.py @@ -83,7 +83,7 @@ def test_pass_through_controller(subtests): with subtests.test("Check output"): expected_set_point = np.mean(np.arange(10)) - np.arange(10) assert expected_set_point == ( - pytest.approx(prob.get_val("hydrogen_set_point", units="kg/h"), rel=1e-3) + pytest.approx(prob.get_val("hydrogen_command_value", units="kg/h"), rel=1e-3) ) @@ -254,14 +254,14 @@ def set_up_and_run_problem(config): calculate_combined_outputs( prob_rte.get_val("hydrogen_out", units="kg/h"), prob_rte.get_val("hydrogen_in", units="kg/h"), - prob_rte.get_val("hydrogen_demand", units="kg/h"), + prob_rte.get_val("hydrogen_set_point", units="kg/h"), ) ) unmet_demand_ioe, unused_commodity_ioe, combined_out_for_demand_ioe = ( calculate_combined_outputs( prob_ioe.get_val("hydrogen_out", units="kg/h"), prob_ioe.get_val("hydrogen_in", units="kg/h"), - prob_ioe.get_val("hydrogen_demand", units="kg/h"), + prob_ioe.get_val("hydrogen_set_point", units="kg/h"), ) ) @@ -396,14 +396,14 @@ def set_up_and_run_problem(config): calculate_combined_outputs( prob_rte.get_val("hydrogen_out", units="kg/h"), prob_rte.get_val("hydrogen_in", units="kg/h"), - prob_rte.get_val("hydrogen_demand", units="kg/h"), + prob_rte.get_val("hydrogen_set_point", units="kg/h"), ) ) unmet_demand_ioe, unused_commodity_ioe, combined_out_for_demand_ioe = ( calculate_combined_outputs( prob_ioe.get_val("hydrogen_out", units="kg/h"), prob_ioe.get_val("hydrogen_in", units="kg/h"), - prob_ioe.get_val("hydrogen_demand", units="kg/h"), + prob_ioe.get_val("hydrogen_set_point", units="kg/h"), ) ) @@ -516,7 +516,7 @@ def test_generic_storage_demand_controller(subtests): unmet_demand, unused_commodity, combined_out_for_demand = calculate_combined_outputs( prob.get_val("hydrogen_out", units="kg/h"), prob.get_val("hydrogen_in", units="kg/h"), - prob.get_val("hydrogen_demand", units="kg/h"), + prob.get_val("hydrogen_set_point", units="kg/h"), ) # # Run the test diff --git a/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py b/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py index d20666442..e85e54c65 100644 --- a/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py +++ b/h2integrate/control/control_strategies/storage/test/test_optimal_controllers.py @@ -216,7 +216,7 @@ def test_min_operating_cost_load_following_battery_dispatch( # Setup the system and required values prob.setup() prob.set_val("battery.electricity_in", electricity_in) - prob.set_val("battery.electricity_demand", demand_in) + prob.set_val("battery.electricity_set_point", demand_in) # Run the model prob.run_model() @@ -365,7 +365,7 @@ def test_optimal_control_with_generic_storage( # Setup the system and required values prob.setup() prob.set_val("h2_storage.hydrogen_in", commodity_in) - prob.set_val("h2_storage.hydrogen_demand", commodity_demand) + prob.set_val("h2_storage.hydrogen_set_point", commodity_demand) # Run the model prob.run_model() @@ -518,7 +518,7 @@ def test_optimal_dispatch_with_autosizing_storage_demand_less_than_avg_in( # Setup the system and required values prob.setup() prob.set_val("h2_storage.hydrogen_in", commodity_in) - prob.set_val("h2_storage.hydrogen_demand", commodity_demand) + prob.set_val("h2_storage.hydrogen_set_point", commodity_demand) # Run the model prob.run_model() diff --git a/h2integrate/control/control_strategies/storage/test/test_plm_openloop_storage_controller.py b/h2integrate/control/control_strategies/storage/test/test_plm_openloop_storage_controller.py index 492510c3c..5c0efb6c2 100644 --- a/h2integrate/control/control_strategies/storage/test/test_plm_openloop_storage_controller.py +++ b/h2integrate/control/control_strategies/storage/test/test_plm_openloop_storage_controller.py @@ -521,7 +521,7 @@ def test_plm_controller_basic_discharge_before_peak(subtests, tech_config_base, prob.setup() prob.run_model() - set_point = prob.get_val("hydrogen_set_point", units="kg/h") + set_point = prob.get_val("hydrogen_command_value", units="kg/h") soc = prob.get_val("SOC", units="unitless") with subtests.test("Discharge occurs before peak (hours 8-9)"): @@ -679,7 +679,7 @@ def test_plm_controller_blocking_charge_in_peak_range( prob.setup() prob.run_model() - set_point = prob.get_val("hydrogen_set_point", units="kg/h") + set_point = prob.get_val("hydrogen_command_value", units="kg/h") soc = prob.get_val("SOC", units="unitless") with subtests.test("Controller instantiates and runs without error"): diff --git a/h2integrate/control/control_strategies/system_level/cost_minimization_control.py b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py new file mode 100644 index 000000000..869976d50 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/cost_minimization_control.py @@ -0,0 +1,94 @@ +import numpy as np + +from h2integrate.control.control_strategies.system_level.system_level_control_base import ( + SystemLevelControlBase, +) + + +class CostMinimizationControl(SystemLevelControlBase): + """Cost-minimizing system-level controller. + + Meets demand at minimum variable cost using merit-order dispatch: + + 1. Fixed techs always produce (cannot be controlled). + 2. Flexible techs run at rated capacity (assuming zero marginal cost). + 3. Storage absorbs surplus / provides deficit. + 4. Dispatchable techs are dispatched in ascending marginal-cost order, + each up to its rated capacity, until remaining demand is met. + + Marginal costs are configured via ``cost_per_tech`` in the + ``system_level_control["control_parameters"]`` section of ``plant_config``. Each + dispatchable technology's entry can be: + + - A numeric value (``$/(commodity_rate_unit*h)``, e.g. ``0.05`` for + ``$0.05/kWh``) used directly as a constant marginal cost. + - ``"buy_price"`` - use the technology's own purchase price input + (e.g. ``electricity_buy_price`` for a Grid tech, ``price`` for a + Feedstock tech). The default is read from the tech's cost config + and may be overridden at runtime via ``prob.set_val()``. + - ``"VarOpEx"`` - derive the marginal cost from the technology's own + ``VarOpEx`` output divided by its annualized total production. + - ``"feedstock"`` - sum the ``VarOpEx`` of all feedstock technologies + that are upstream of the dispatchable tech in + ``technology_interconnections`` (using graph ancestors, so feedstocks + behind intermediate components like combiners are included), and + divide by the dispatchable tech's annualized total production. + """ + + def setup(self): + super().setup() + + # Set up marginal cost inputs based on cost_per_tech config + self._setup_marginal_costs() + + def compute(self, inputs, outputs): + demand = inputs[self.demand_input_name].copy() + + # 1. Fixed techs: always produce, subtract from demand + for fixed_tech in self.fixed_techs: + commodity_from_tech = self._get_commodity_for_tech(fixed_tech) + if self.commodity in commodity_from_tech: + demand = self._subtract_fixed(fixed_tech, demand, self.commodity, inputs) + + # 2. Flexible techs: full production + for flexible_tech in self.flexible_techs: + commodity_from_tech = self._get_commodity_for_tech(flexible_tech) + if self.commodity in commodity_from_tech: + demand = self._subtract_flexible( + flexible_tech, demand, self.commodity, inputs, outputs + ) + + # 3. Storage dispatch + # number of storage components that produce the demanded commodity + n_storage = len( + [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + ) + for storage_tech in self.storage_techs: + commodity_from_tech = self._get_commodity_for_tech(storage_tech) + if self.commodity in commodity_from_tech: + demand = self._dispatch_storage( + storage_tech, demand / n_storage, self.commodity, inputs, outputs + ) + + # 4. Merit-order dispatch: cheapest dispatchable first + remaining = np.maximum(demand, 0.0) + + marginal_costs = self._compute_marginal_costs(inputs) + + # Merit order: sort by mean marginal cost (cheapest first) + mean_costs = np.array([mc.mean() for mc in marginal_costs]) + dispatch_order = np.argsort(mean_costs) + + # Initialize all dispatchable set-point outputs to zero + for set_point_name in self.dispatchable_set_point_names: + outputs[set_point_name] = np.zeros(self.n_timesteps) + + # Dispatch in merit order + for idx in dispatch_order: + set_point_name = self.dispatchable_set_point_names[idx] + rated_name = self.dispatchable_rated_names[idx] + rated = inputs[rated_name] + + dispatch = np.minimum(remaining, rated) + outputs[set_point_name] = dispatch + remaining -= dispatch diff --git a/h2integrate/control/control_strategies/system_level/demand_following_control.py b/h2integrate/control/control_strategies/system_level/demand_following_control.py new file mode 100644 index 000000000..bb83712d0 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/demand_following_control.py @@ -0,0 +1,85 @@ +import numpy as np + +from h2integrate.control.control_strategies.system_level.system_level_control_base import ( + SystemLevelControlBase, +) + + +class DemandFollowingControl(SystemLevelControlBase): + """Demand-following system-level controller. + + Dispatches technologies to meet a time-varying demand profile without + considering costs. The demand is satisfied in a fixed four-step priority + order, and each step's shortfall or surplus is passed to the next: + + 1. **Fixed techs** always produce at their rated capacity and cannot be + controlled. Their total output is subtracted from the demand. + + 2. **Flexible techs** run at their available capacity. Their total + output is subtracted from the demand, which may drive the residual + demand negative (surplus). + + 3. **Storage techs** receive the residual demand (which may be positive + or negative). When demand is positive the storage is commanded to + discharge; when negative it is commanded to charge. If multiple + storage techs produce the demanded commodity, the residual demand is + split **evenly** across them (each receives ``demand / n_storage``). + + 4. **Dispatchable techs** cover any remaining positive demand after + storage. The remaining demand (floored at zero) is split **evenly** + across all dispatchable techs that produce the demanded commodity + (each receives ``remaining_demand / n_dispatchable``). + """ + + def compute(self, inputs, outputs): + commodity = self.commodity + demand = inputs[self.demand_input_name].copy() + + # 1. Fixed techs: always produce, subtract from demand + for fixed_tech in self.fixed_techs: + commodity_from_tech = self._get_commodity_for_tech(fixed_tech) + for tech_commodity in commodity_from_tech: + if tech_commodity == commodity: + demand = self._subtract_fixed(fixed_tech, demand, commodity, inputs) + + # 2. Flexible techs: operate at full production + for flexible_tech in self.flexible_techs: + commodity_from_tech = self._get_commodity_for_tech(flexible_tech) + for tech_commodity in commodity_from_tech: + if tech_commodity == commodity: + demand = self._subtract_flexible( + flexible_tech, demand, commodity, inputs, outputs + ) + else: + if f"{flexible_tech}_rated_{tech_commodity}_production" in inputs: + # set the per-tech set-point as the rated production + outputs[f"{flexible_tech}_{tech_commodity}_set_point"] = inputs[ + f"{flexible_tech}_rated_{tech_commodity}_production" + ] * np.ones(self.n_timesteps) + + # 3. Storage dispatch + # number of storage components that produce the demanded commodity + n_storage = len( + [s for s in self.storage_techs if commodity in self._get_commodity_for_tech(s)] + ) + for storage_tech in self.storage_techs: + commodity_from_tech = self._get_commodity_for_tech(storage_tech) + if commodity in commodity_from_tech: + demand = self._dispatch_storage( + storage_tech, demand / n_storage, commodity, inputs, outputs + ) + + # 4. Dispatchable techs + remaining_demand = np.maximum(demand, 0.0) + + # calculate the number of dispatchable technologies that + # produce the demanded commodity + n_dispatchable = len( + [s for s in self.dispatchable_techs if commodity in self._get_commodity_for_tech(s)] + ) + for dispatchable_tech in self.dispatchable_techs: + commodity_from_tech = self._get_commodity_for_tech(dispatchable_tech) + if commodity in commodity_from_tech: + outputs[f"{dispatchable_tech}_{commodity}_set_point"] = ( + remaining_demand / n_dispatchable + ) diff --git a/h2integrate/control/control_strategies/system_level/profit_maximization_control.py b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py new file mode 100644 index 000000000..9a8eb0a42 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/profit_maximization_control.py @@ -0,0 +1,157 @@ +import numpy as np +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig +from h2integrate.control.control_strategies.system_level.system_level_control_base import ( + SystemLevelControlBase, +) + + +@define(kw_only=True) +class ProfitMaximizationControlConfig(BaseConfig): + commodity_sell_price: float = field(default=0.0) + cost_per_tech: dict = field(default={}) + + +class ProfitMaximizationControl(SystemLevelControlBase): + """Profit-maximizing system-level controller. + + Dispatches technologies only when the commodity sell price exceeds + the marginal cost of production: + + 1. Fixed techs always produce (cannot be controlled). + 2. Flexible techs run at rated capacity (zero marginal cost, + always profitable to produce). + 3. Storage absorbs surplus / provides deficit. + 4. Dispatchable techs are dispatched in merit order (cheapest first), + but **only if** their marginal cost is below the sell price. + Demand may go unmet if dispatch is unprofitable. + + Configuration: + ``plant_config["system_level_control"]["control_parameters"]["commodity_sell_price"]`` + must be set as either a numeric value (``$/(commodity_rate_unit*h)``, + e.g. ``$/kWh``) or the name of a finance group (e.g. ``"profast_npv"``) + whose ``model_inputs.commodity_sell_price`` will be used. + + Marginal costs are configured via ``cost_per_tech`` in the + ``system_level_control["control_parameters"]`` section of ``plant_config``. Each + dispatchable technology's entry can be: + + - A numeric value (``$/(commodity_rate_unit*h)``, e.g. ``0.05`` for + ``$0.05/kWh``) used directly as a constant marginal cost. + - ``"buy_price"`` — use the technology's own purchase price input + (e.g. ``electricity_buy_price`` for a Grid tech, ``price`` for a + Feedstock tech). The default is read from the tech's cost config + and may be overridden at runtime via ``prob.set_val()``. + - ``"VarOpEx"`` — derive the marginal cost from the technology's own + ``VarOpEx`` output divided by its annualized total production. + - ``"feedstock"`` — sum the ``VarOpEx`` of all feedstock technologies + that are upstream of the dispatchable tech in + ``technology_interconnections`` (using graph ancestors, so feedstocks + behind intermediate components like combiners are included), and + divide by the dispatchable tech's annualized total production. + """ + + def _resolve_sell_price(self, config): + """Resolve commodity_sell_price from config. + + If the value is a string, look it up from + ``finance_parameters.finance_groups..model_inputs.commodity_sell_price``. + Otherwise return it as-is (numeric). + """ + raw = config.commodity_sell_price + if isinstance(raw, str): + finance_groups = ( + self.options["plant_config"].get("finance_parameters", {}).get("finance_groups", {}) + ) + group = finance_groups.get(raw) + if group is None: + raise ValueError( + f"commodity_sell_price references finance group '{raw}', " + f"but it was not found in finance_parameters.finance_groups. " + f"Available groups: {list(finance_groups.keys())}" + ) + price = group.get("model_inputs", {}).get("commodity_sell_price", None) + if price is None: + raise ValueError( + f"Finance group '{raw}' does not contain " f"model_inputs.commodity_sell_price." + ) + return price + return raw + + def setup(self): + super().setup() + + config = ProfitMaximizationControlConfig.from_dict( + self.options["plant_config"]["system_level_control"]["control_parameters"] + ) + + # Commodity sell price - user-set in config, can be scalar or time-varying + # Accepts a numeric value or the name of a finance group to look up + commodity_sell_price = self._resolve_sell_price(config) + self.add_input( + "commodity_sell_price", + val=commodity_sell_price, + shape=self.n_timesteps, + units=f"USD/({self.commodity_rate_units}*h)", + desc=f"Sell price per unit of {self.commodity}", + ) + + # Set up marginal cost inputs based on cost_per_tech config + self._setup_marginal_costs() + + def compute(self, inputs, outputs): + demand = inputs[self.demand_input_name].copy() + sell_price = inputs["commodity_sell_price"] # shape (n_timesteps,) + + # 1. Fixed techs: always produce, subtract from demand + for fixed_tech in self.fixed_techs: + commodity_from_tech = self._get_commodity_for_tech(fixed_tech) + if self.commodity in commodity_from_tech: + demand = self._subtract_fixed(fixed_tech, demand, self.commodity, inputs) + + # 2. Flexible techs: full production (always profitable) + for flexible_tech in self.flexible_techs: + commodity_from_tech = self._get_commodity_for_tech(flexible_tech) + if self.commodity in commodity_from_tech: + demand = self._subtract_flexible( + flexible_tech, demand, self.commodity, inputs, outputs + ) + + # 3. Storage dispatch + # number of storage components that produce the demanded commodity + n_storage = len( + [s for s in self.storage_techs if self.commodity in self._get_commodity_for_tech(s)] + ) + for storage_tech in self.storage_techs: + commodity_from_tech = self._get_commodity_for_tech(storage_tech) + if self.commodity in commodity_from_tech: + demand = self._dispatch_storage( + storage_tech, demand / n_storage, self.commodity, inputs, outputs + ) + + # 4. Profit-driven merit-order dispatch + remaining = np.maximum(demand, 0.0) + + marginal_costs = self._compute_marginal_costs(inputs) + + # Merit order: sort by mean marginal cost (cheapest first) + mean_costs = np.array([mc.mean() for mc in marginal_costs]) + dispatch_order = np.argsort(mean_costs) + + # Initialize all dispatchable set-point outputs to zero + for set_point_name in self.dispatchable_set_point_names: + outputs[set_point_name] = np.zeros(self.n_timesteps) + + # Dispatch only where profitable (element-wise comparison) + for idx in dispatch_order: + mc = marginal_costs[idx] # per-timestep array + profitable = mc < sell_price # boolean mask per timestep + + set_point_name = self.dispatchable_set_point_names[idx] + rated_name = self.dispatchable_rated_names[idx] + rated = inputs[rated_name] + + dispatch = np.where(profitable, np.minimum(remaining, rated), 0.0) + outputs[set_point_name] = dispatch + remaining -= dispatch diff --git a/h2integrate/control/control_strategies/system_level/solver_options.py b/h2integrate/control/control_strategies/system_level/solver_options.py new file mode 100644 index 000000000..e3b5186c8 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/solver_options.py @@ -0,0 +1,82 @@ +from typing import ClassVar + +import openmdao.api as om +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig +from h2integrate.core.validators import gt_zero, contains, gte_zero + + +@define(kw_only=True) +class SLCSolverOptionsConfig(BaseConfig): + """Configuration for the nonlinear solver used by the system-level controller. + + Controls which OpenMDAO nonlinear solver is applied to the plant group and + how it converges. The ``convergence_tolerance`` sets both ``atol`` and ``rtol`` + by default; either can be overridden individually. + + Attributes: + solver_name: Solver type. One of ``"gauss_seidel"``, ``"newton"``, or + ``"block_jacobi"``. + max_iter: Maximum number of nonlinear iterations. + atol: Absolute convergence tolerance. Defaults to ``convergence_tolerance``. + rtol: Relative convergence tolerance. Defaults to ``convergence_tolerance``. + convergence_tolerance: Convenience value used to set both ``atol`` and ``rtol`` + when they are not specified individually. + iprint: Solver print level (0 = silent, 2 = verbose). + solver_option_kwargs: Additional keyword arguments passed directly to the + solver's ``options`` dict. + """ + + solver_name: str = field( + default="gauss_seidel", validator=contains(["gauss_seidel", "newton", "block_jacobi"]) + ) + max_iter: int = field(default=20, converter=int, validator=gte_zero) + atol: float | None = field(default=None) + rtol: float | None = field(default=None) + convergence_tolerance: float = field(default=1e-6, validator=gt_zero) + iprint: int = field(default=2) + solver_option_kwargs: dict = field(default={}) + + # Maps user-facing solver names to OpenMDAO solver classes + solver_map: ClassVar = { + "gauss_seidel": om.NonlinearBlockGS, + "newton": om.NewtonSolver, + "block_jacobi": om.NonlinearBlockJac, + } + + def __attrs_post_init__(self): + # Default atol/rtol to the shared convergence_tolerance if not set + if self.atol is None: + self.atol = self.convergence_tolerance + if self.rtol is None: + self.rtol = self.convergence_tolerance + + def get_solver_options(self): + """Build the options dict to apply to the nonlinear solver. + + Merges config attributes with any extra ``solver_option_kwargs`` and + renames ``max_iter`` to ``maxiter`` (the OpenMDAO option name). + + Returns: + dict: Keyword arguments suitable for ``solver.options[k] = v``. + """ + d = self.as_dict() + # These attrs configure *which* solver or are handled separately + non_solver_option_attrs = [ + "solver_name", + "solver_map", + "solver_option_kwargs", + "convergence_tolerance", + "max_iter", + ] + solver_options = {k: v for k, v in d.items() if k not in non_solver_option_attrs} + # Merge extra kwargs and translate max_iter → maxiter for OpenMDAO + solver_options_full = ( + solver_options | self.solver_option_kwargs | {"maxiter": self.max_iter} + ) + return solver_options_full + + def return_nonlinear_solver(self): + """Return the OpenMDAO nonlinear solver class for ``solver_name``.""" + return self.solver_map[self.solver_name] 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 new file mode 100644 index 000000000..d1b8c0348 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/system_level_control_base.py @@ -0,0 +1,846 @@ +import numpy as np +import networkx as nx +import openmdao.api as om + + +class SystemLevelControlBase(om.ExplicitComponent): + """Base class for system-level controllers. + + Provides common setup logic shared by all system-level control strategies: + demand input, fixed/flexible/dispatchable/storage/feedstock technology I/O + creation, and technology classification reading from ``plant_config`` and + ``slc_config``. + + Subclasses must implement ``compute()`` with their dispatch strategy. + + Each technology group is expected to contain a controller (either user-defined or an + auto-injected ``PassthroughController``) that consumes a ``{commodity}_set_point`` input and + produces the ``{commodity}_command_value`` actually fed to the performance/cost models. The + system-level controller therefore reasons in terms of *demand* values and emits + ``{tech_name}_{commodity}_set_point`` outputs for every controlled technology. + + The SLC demand signal is provided by a demand component (for example, + ``GenericDemandComponent``) connected by ``H2IntegrateModel``. When SLC is + enabled, only one demand component is currently supported. + + Information passed to the controller from H2IntegrateModel is input in the ``slc_config``, + which must contain: + + - ``demand_commodity``: the commodity being controlled (e.g. "electricity") + - ``demand_commodity_rate_units``: units string (or None) of the demand commodity + - ``demand_tech``: name of the demand technology + - ``storage_techs_to_control``: dictionary with keys of the technology names. The value is True + if the technology is classified as "storage" and has an attached controller. + Otherwise the value is False. + - ``technology_graph``: directional graph object representation of the + technology_interconnections found in the ``plant_config`` + - ``tech_to_commodity``: set of tuples formatted as (tech_name, tech_output_commodity) + - ``tech_control_classifiers``: dictionary of technologies with key-value pairs of each + technology name and its corresponding control classifier (one of + ``"fixed"``, ``"flexible"``, ``"dispatchable"``, ``"storage"``, or + ``"feedstock"``). + + Controller-specific configuration parameters may be read from + ``plant_config["system_level_control"]["control_parameters"]``. + + Cost-aware subclasses (e.g. ``CostMinimizationControl``, + ``ProfitMaximizationControl``) call ``_setup_marginal_costs()`` to register + marginal-cost inputs for each dispatchable technology based on the + ``cost_per_tech`` mapping. Supported values per dispatchable tech are: + + - A numeric value (constant marginal cost in ``$/(commodity_rate_unit*h)``). + - ``"buy_price"`` — use the technology's own purchase price input. + - ``"VarOpEx"`` — derive marginal cost from the tech's own ``VarOpEx`` + divided by its annualized total production. + - ``"feedstock"`` — sum ``VarOpEx`` from all feedstock technologies + upstream of the tech in ``technology_interconnections`` (graph + ancestors, so feedstocks behind intermediate components are included) + and divide by the dispatchable tech's annualized total production. + """ + + def initialize(self): + self.options.declare("driver_config", types=dict) + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) + self.options.declare("slc_config", types=dict) + + def setup(self): + plant_config = self.options["plant_config"] + slc_config = self.options["slc_config"] + + self.n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] + + # Read pre-computed classification from plant_config + self.commodity = slc_config["demand_commodity"] + self.commodity_rate_units = slc_config.get("demand_commodity_rate_units", None) + self.demand_tech = slc_config["demand_tech"] + self.storage_techs_to_control = slc_config.get("storage_techs_to_control", {}) + self.technology_graph = slc_config["technology_graph"] + + self.fixed_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "fixed" + ] + self.flexible_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "flexible" + ] + self.dispatchable_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "dispatchable" + ] + self.storage_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "storage" + ] + self.feedstock_comps = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] + + self.input_techs = set( + self.fixed_techs + self.flexible_techs + self.dispatchable_techs + self.storage_techs + ) + + # Input: demand profile + self.demand_input_name = f"{self.commodity}_demand" + self.add_input( + self.demand_input_name, + val=10.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Demand profile of {self.commodity}", + ) + + self.techs_to_commodities = slc_config["tech_to_commodity"] + + # There are multiple commodities being produced by technologies in the system + self.multi_commodity_system = ( + True if len({e[-1] for e in self.techs_to_commodities}) > 1 else False + ) + + self.commodities_to_units = {self.commodity: self.commodity_rate_units} + self.commodities_to_ref_var = {} + self._setup_fixed_category(self.fixed_techs) + self._setup_tech_category("flexible", self.flexible_techs) + self._setup_tech_category("dispatchable", self.dispatchable_techs) + self._setup_tech_category("storage", self.storage_techs) + self._setup_feedstock_category(self.feedstock_comps) + + def _setup_commodity( + self, + tech_name, + commodity, + commodity_rate_units=None, + commodity_reference_var=None, + add_in_name=True, + initial_demand=1.0, + ): + """Register OpenMDAO inputs and outputs for a single (tech, commodity) pair. + + This method handles unit specification in two mutually exclusive ways: + + 1. **Explicit units** - pass ``commodity_rate_units`` (e.g. ``"kW"``). + Each variable is created with ``units=commodity_rate_units``. + 2. **Copied units** - pass ``commodity_reference_var`` (the name of an + already-registered input whose units should be reused). + Each variable is created with ``units=None, copy_units=commodity_reference_var``. + + Exactly one of ``commodity_rate_units`` or ``commodity_reference_var`` must be + provided. + + The following OpenMDAO variables are created: + + - Input ``"{tech_name}_{commodity}_out"`` - commodity produced by the tech + (only if ``add_in_name=True``). + - Input ``"{tech_name}_rated_{commodity}_production"`` - rated production + capacity of the tech. + - Output ``"{tech_name}_{commodity}_set_point"`` - set-point signal sent to the + tech's controller (which translates it into a performance-model command value). + + Args: + tech_name (str): Name of the technology. + commodity (str): Commodity produced by ``tech_name``. + commodity_rate_units (str | None): Explicit unit string for the commodity. + Mutually exclusive with ``commodity_reference_var``. + commodity_reference_var (str | None): Name of an existing input + variable whose units should be copied. Mutually exclusive with + ``commodity_rate_units``. + add_in_name (bool, optional): If True, register the + ``"{tech_name}_{commodity}_out"`` input. Defaults to True. + initial_demand (float, optional): Initial value for the + set-point output. Defaults to 1.0. + + Returns: + tuple[str, str, str]: ``(in_name, set_point_name, rated_name)`` + """ + # --- Determine unit kwargs for add_input / add_output --------- + # Either explicit units or copy_units from a reference variable. + if commodity_rate_units is not None: + unit_kwargs = {"units": commodity_rate_units} + else: + unit_kwargs = {"units": None, "copy_units": commodity_reference_var} + + # --- Build variable names ------------------------------------- + in_name = f"{tech_name}_{commodity}_out" + rated_name = f"{tech_name}_rated_{commodity}_production" + set_point_name = f"{tech_name}_{commodity}_set_point" + + # --- Register inputs and output ------------------------------- + if add_in_name: + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + desc=f"{commodity} output from {tech_name}", + **unit_kwargs, + ) + self.add_input( + rated_name, + val=0.0, + desc=f"Rated {commodity} production for {tech_name}", + **unit_kwargs, + ) + self.add_output( + set_point_name, + val=initial_demand, + shape=self.n_timesteps, + desc=f"Set-point sent to {tech_name} for {commodity}", + **unit_kwargs, + ) + + return in_name, set_point_name, rated_name + + def _setup_tech_category(self, category, tech_list): + """Create OpenMDAO I/O variables for all technologies in a given category. + + This single method handles flexible, dispatchable, and storage + technologies. The logic is identical for all three categories — + iterate over each technology's commodities and register the + appropriate inputs (production output, rated capacity) and output + (per-tech demand). + + All initial demand values are ``1.0``; the solver converges from there + using the connected rated-production inputs at run time. + + After this method returns, four lists are stored on ``self`` under + names produced by the *category* prefix: + + ``self.{category}_input_names`` + ``self.{category}_set_point_names`` + ``self.{category}_rated_names`` + ``self.{category}_commodity_names`` + + These lists are consumed by ``compute()`` and the helper methods + ``_subtract_flexible`` and ``_dispatch_storage``. + + Args: + category (str): One of ``"flexible"``, ``"dispatchable"``, + or ``"storage"``. Used to name the attribute lists. + tech_list (list[str]): Technology names belonging to this category + (e.g. ``self.flexible_techs``). + """ + initial_demand = 1.0 + + # --- Initialize the four per-category bookkeeping lists ------- + input_names = [] + set_point_names = [] + rated_names = [] + commodity_names = [] + + # --- Register I/O for every (tech, commodity) pair ------------ + for tech_name in tech_list: + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + for commodity in tech_commodities: + if commodity in self.commodities_to_units: + # Units are already known explicitly + in_name, set_point_name, rated_name = self._setup_commodity( + tech_name, + commodity, + commodity_rate_units=self.commodities_to_units[commodity], + add_in_name=True, + initial_demand=initial_demand, + ) + elif commodity in self.commodities_to_ref_var: + # Units are inferred from a previously-registered reference variable + in_name, set_point_name, rated_name = self._setup_commodity( + tech_name, + commodity, + commodity_reference_var=self.commodities_to_ref_var[commodity], + add_in_name=True, + initial_demand=initial_demand, + ) + else: + # Units are unknown; try to discover them from the connection + in_name = f"{tech_name}_{commodity}_out" + meta_data = self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + units_by_conn=True, + desc=f"{commodity} output from {tech_name}", + ) + if meta_data["units"] is None: + # Still unknown: register in_name as the reference + # variable so later techs with this commodity can + # copy its units. + self.commodities_to_ref_var[commodity] = in_name + in_name, set_point_name, rated_name = self._setup_commodity( + tech_name, + commodity, + commodity_reference_var=self.commodities_to_ref_var[commodity], + add_in_name=False, + initial_demand=initial_demand, + ) + else: + # Connection provided units — record them for future use + self.commodities_to_units[commodity] = meta_data["units"] + in_name, set_point_name, rated_name = self._setup_commodity( + tech_name, + commodity, + commodity_rate_units=self.commodities_to_units[commodity], + add_in_name=False, + initial_demand=initial_demand, + ) + + if category == "storage": + self.add_input( + f"{tech_name}_{commodity}_storage_duration", val=0.0, shape=1, units="h" + ) + + commodity_names.append(commodity) + input_names.append(in_name) + set_point_names.append(set_point_name) + rated_names.append(rated_name) + + # --- Store lists as self._ attributes ------- + setattr(self, f"{category}_input_names", input_names) + setattr(self, f"{category}_set_point_names", set_point_names) + setattr(self, f"{category}_rated_names", rated_names) + setattr(self, f"{category}_commodity_names", commodity_names) + + def _setup_fixed_category(self, fixed_list): + """Create OpenMDAO input variables for fixed technologies. + + Fixed technologies always produce at their rated capacity and do not + receive a set-point from the controller. Only commodity output inputs + are registered so the controller can read their production and subtract + it from demand. + + This method is separate from the more general ``_setup_tech_category`` because the logic + for fixed techs is dramatically simpler + (no demand or rated inputs, only production inputs). + + After this method returns, two lists are stored on ``self``: + + ``self.fixed_input_names`` + ``self.fixed_commodity_names`` + + Args: + fixed_list (list[str]): Technology names classified as ``"fixed"``. + """ + input_names = [] + commodity_names = [] + + for tech_name in fixed_list: + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + for commodity in tech_commodities: + in_name = f"{tech_name}_{commodity}_out" + + if commodity in self.commodities_to_units: + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodities_to_units[commodity], + desc=f"{commodity} output from {tech_name}", + ) + elif commodity in self.commodities_to_ref_var: + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + copy_units=self.commodities_to_ref_var[commodity], + desc=f"{commodity} output from {tech_name}", + ) + else: + meta_data = self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + units_by_conn=True, + desc=f"{commodity} output from {tech_name}", + ) + if meta_data["units"] is None: + self.commodities_to_ref_var[commodity] = in_name + else: + self.commodities_to_units[commodity] = meta_data["units"] + + input_names.append(in_name) + commodity_names.append(commodity) + + self.fixed_input_names = input_names + self.fixed_commodity_names = commodity_names + + def _setup_feedstock_category(self, feedstock_list): + """Iterate over the feedstocks and add inputs for the available feedstock + + Args: + feedstock_list (list[str]): name of feedstock techs + """ + for tech_name in feedstock_list: + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + for commodity in tech_commodities: + in_name = f"{tech_name}_{commodity}_out" + + if commodity in self.commodities_to_units: + # Units are already known explicitly + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=self.commodities_to_units[commodity], + desc=f"{commodity} output from {tech_name}", + ) + elif commodity in self.commodities_to_ref_var: + # Units are inferred from a previously-registered reference variable + self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + copy_units=self.commodities_to_ref_var[commodity], + desc=f"{commodity} output from {tech_name}", + ) + else: + # Units are unknown; try to discover them from the connection + meta_data = self.add_input( + in_name, + val=0.0, + shape=self.n_timesteps, + units=None, + units_by_conn=True, + desc=f"{commodity} output from {tech_name}", + ) + if meta_data["units"] is None: + # Still unknown: register in_name as the reference + # variable so later techs with this commodity can + # copy its units. + self.commodities_to_ref_var[commodity] = in_name + else: + # Connection provided units — record them for future use + self.commodities_to_units[commodity] = meta_data["units"] + + def _subtract_fixed(self, fixed_tech, remaining_demand, commodity, inputs): + """Apply fixed techs: subtract their output from demand. + + Fixed techs always produce and do not receive a set-point. + + Returns the updated demand array. + """ + if fixed_tech not in self.fixed_techs: + return remaining_demand + + in_name = f"{fixed_tech}_{commodity}_out" + if in_name not in inputs: + return remaining_demand + + remaining_demand -= inputs[in_name] + return remaining_demand + + def _subtract_flexible(self, flexible_tech, remaining_demand, commodity, inputs, outputs): + """Apply flexible techs: demand = rated, subtract output from demand. + + Returns the updated demand array. + """ + if flexible_tech not in self.flexible_techs: + return + + if f"{flexible_tech}_rated_{commodity}_production" not in inputs: + return + + # Set per-tech set-point equal to the rated production of that technology + outputs[f"{flexible_tech}_{commodity}_set_point"] = inputs[ + f"{flexible_tech}_rated_{commodity}_production" + ] * np.ones(self.n_timesteps) + remaining_demand -= inputs[f"{flexible_tech}_{commodity}_out"] + + return remaining_demand + + def _dispatch_storage(self, storage_tech, remaining_demand, commodity, inputs, outputs): + if storage_tech not in self.storage_techs: + return + + if f"{storage_tech}_{commodity}_out" not in inputs: + return + + set_point_name = f"{storage_tech}_{commodity}_set_point" + if set_point_name not in outputs: + return + + if self.storage_techs_to_control.get(storage_tech, False): + # Storage tech has its own sub-controller: emit a combined demand + # signal (always positive) equal to the commodity flowing into + # storage from upstream techs plus any remaining demand. + upstream_techs = self.get_upstream_techs_for_commodity(storage_tech, commodity) + commodity_into_storage = np.zeros(self.n_timesteps) + for tech_name in upstream_techs: + commodity_into_storage += inputs[f"{tech_name}_{commodity}_out"] + + outputs[set_point_name] = commodity_into_storage + remaining_demand + else: + # Storage without a sub-controller: emit a charge/discharge + # command directly. Charge when remaining demand is negative, + # discharge when positive. + outputs[set_point_name] = remaining_demand + + remaining_demand -= inputs[f"{storage_tech}_{commodity}_out"] + return remaining_demand + + def _get_commodity_for_tech(self, tech_name): + """Get a list of the commodities produced for a technology. + + Args: + tech_name (str): name of technology + + Returns: + list[str]: list of commodities produced by the tech_name + """ + tech_commodities = [e[1] for e in self.techs_to_commodities if e[0] == tech_name] + + return tech_commodities + + # ------------------------------------------------------------------ + # Marginal-cost helpers for cost-aware controllers + # ------------------------------------------------------------------ + + def _setup_marginal_costs(self): + """Set up marginal cost inputs for dispatchable techs based on ``cost_per_tech``. + + Should be called from ``setup()`` of cost-aware controllers + (e.g., ``CostMinimizationControl``, ``ProfitMaximizationControl``). + + Reads ``cost_per_tech`` from + ``plant_config["system_level_control"]["control_parameters"]`` and creates appropriate + OpenMDAO inputs for each dispatchable technology: + + - Numeric value (e.g. ``0.05``): used directly as a constant + marginal cost in ``USD/(commodity_rate_unit*h)``. No additional + inputs or connections are required. + - ``"buy_price"``: creates a ``{tech_name}_buy_price`` input + whose default value is read from the technology's cost config + (``electricity_buy_price`` for Grid, ``price`` for Feedstock). + Can be scalar or time-varying and may be overridden at runtime + via ``prob.set_val()``. + - ``"VarOpEx"``: creates a ``{tech_name}_VarOpEx`` input + connected to the cost model's ``VarOpEx`` output. The + per-unit marginal cost is computed at run time by dividing + ``VarOpEx`` by the total production. + - ``"feedstock"``: looks up ``technology_interconnections`` to + find all feedstock technologies connected upstream of the + dispatchable tech, sums their ``VarOpEx`` outputs, and + divides by the tech's total production. Handles the common + single-feedstock case as well as multiple feedstock streams. + """ + + self.cost_per_tech = ( + self.options["plant_config"]["system_level_control"] + .get("control_parameters", {}) + .get("cost_per_tech", {}) + ) + self.dt_hours = self.options["plant_config"]["plant"]["simulation"]["dt"] / 3600 + hours_simulated = self.dt_hours * self.n_timesteps + self.fraction_of_year_simulated = hours_simulated / 8760 + plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) + + self.dispatchable_marginal_cost_types = [] + + for tech_name in self.dispatchable_techs: + cost_spec = self.cost_per_tech.get(tech_name, 0.0) + + if isinstance(cost_spec, int | float): + 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), + ) + + self.add_input( + f"{tech_name}_buy_price", + val=default_price, + shape=self.n_timesteps, + units=f"USD/({self.commodity_rate_units}*h)", + desc=f"Buy price for {tech_name}", + ) + self.dispatchable_marginal_cost_types.append(("buy_price", tech_name)) + + elif cost_spec == "VarOpEx": + self.add_input( + f"{tech_name}_VarOpEx", + val=0.0, + shape=plant_life, + units="USD/year", + desc=f"Variable operating expenditure from {tech_name}", + ) + self.dispatchable_marginal_cost_types.append(("VarOpEx", tech_name)) + + elif cost_spec == "feedstock": + # Find feedstock techs connected upstream of this tech + feedstock_names = self._find_feedstock_techs(tech_name) + if not feedstock_names: + raise ValueError( + f"cost_per_tech '{cost_spec}' for '{tech_name}' requires " + f"at least one feedstock connected upstream in " + f"technology_interconnections, but none were found." + ) + for feedstock_name in feedstock_names: + self.add_input( + f"{feedstock_name}_VarOpEx", + val=0.0, + shape=plant_life, + units="USD/year", + desc=f"Variable operating expenditure from feedstock {feedstock_name}", + ) + self.dispatchable_marginal_cost_types.append( + ("feedstock", (tech_name, feedstock_names)) + ) + + else: + raise ValueError( + f"Unknown cost_per_tech value '{cost_spec}' for '{tech_name}'. " + f"Must be a numeric value, 'buy_price', 'VarOpEx', or 'feedstock'." + ) + + def _compute_marginal_costs(self, inputs): + """Compute per-timestep marginal costs for each dispatchable tech. + + Returns: + list[np.ndarray]: marginal cost arrays, one per dispatchable + tech, each of shape ``(n_timesteps,)``. + """ + marginal_costs = [] + + for marginal_cost_type, marginal_cost_data in self.dispatchable_marginal_cost_types: + if marginal_cost_type == "scalar": + marginal_cost = np.full(self.n_timesteps, marginal_cost_data) + elif marginal_cost_type == "buy_price": + marginal_cost = self._buy_price_marginal_cost(inputs, marginal_cost_data) + elif marginal_cost_type == "VarOpEx": + marginal_cost = self._varopex_marginal_cost(inputs, marginal_cost_data) + elif marginal_cost_type == "feedstock": + marginal_cost = self._feedstock_marginal_cost(inputs, marginal_cost_data) + else: + marginal_cost = np.zeros(self.n_timesteps) + + marginal_costs.append(marginal_cost) + + return marginal_costs + + 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). + """ + return np.broadcast_to(inputs[f"{tech_name}_buy_price"], self.n_timesteps).copy() + + def _varopex_marginal_cost(self, inputs, tech_name): + """Compute marginal cost from VarOpEx and commodity output. + + Divides the first-year ``VarOpEx`` (``$/year``) by the + annualized total production to obtain an average marginal cost + in ``$/(commodity_amount_unit)``. + + Returns a constant per-timestep array. + """ + varopex = inputs[f"{tech_name}_VarOpEx"] # $/year, shape=plant_life + + # Use commodity_out already connected for this dispatchable tech + tech_commodities = self._get_commodity_for_tech(tech_name) + commodity = tech_commodities[0] if tech_commodities else self.commodity + + production = inputs[f"{tech_name}_{commodity}_out"] # rate units, shape=n_timesteps + total_production = production.sum() * self.dt_hours + + if total_production > 0: + annual_production = total_production / self.fraction_of_year_simulated + marginal_cost_scalar = varopex[0] / annual_production + else: + marginal_cost_scalar = 0.0 + + return np.full(self.n_timesteps, marginal_cost_scalar) + + def _find_feedstock_techs(self, tech_name): + """Find feedstock technologies upstream of ``tech_name`` at any depth. + + Uses graph ancestors rather than direct interconnections so that + feedstocks behind intermediate components (e.g. combiners) are found. + + Args: + tech_name (str): The dispatchable technology name. + + Returns: + list[str]: Names of upstream feedstock technologies. + """ + # All ancestors at any depth, filtered to feedstocks + ancestors = nx.ancestors(self.technology_graph, tech_name) + return [tech for tech in ancestors if tech in self.feedstock_comps] + + def _feedstock_marginal_cost(self, inputs, marginal_cost_data): + """Compute marginal cost from upstream feedstock VarOpEx values. + + Sums the first-year ``VarOpEx`` from all feedstock technologies + connected to the dispatchable tech, then divides by the tech's + annualized total production. + + Args: + marginal_cost_data (tuple): ``(tech_name, feedstock_names)`` where + tech_name is the dispatchable tech and feedstock_names + is a list of upstream feedstock technology names. + + Returns: + np.ndarray: constant per-timestep marginal cost array. + """ + tech_name, feedstock_names = marginal_cost_data + + # Sum VarOpEx from all connected feedstocks (first year) + total_varopex = sum(inputs[f"{fs}_VarOpEx"][0] for fs in feedstock_names) + + # Get the tech's production + tech_commodities = self._get_commodity_for_tech(tech_name) + commodity = tech_commodities[0] if tech_commodities else self.commodity + + production = inputs[f"{tech_name}_{commodity}_out"] + total_production = production.sum() * self.dt_hours + + if total_production > 0: + annual_production = total_production / self.fraction_of_year_simulated + marginal_cost_scalar = total_varopex / annual_production + else: + marginal_cost_scalar = 0.0 + + return np.full(self.n_timesteps, marginal_cost_scalar) + + def get_upstream_techs_for_commodity( + self, tech_name: str, commodity: str, include_feedstock_sources=True + ): + """Find controlled technologies upstream of ``tech_name`` that output ``commodity``. + + Walks the technology graph backwards from ``tech_name``, finds all ancestor + nodes that have an outgoing edge carrying ``commodity``, then filters to only + those managed by the controller. + + Args: + tech_name (str): Technology whose upstream suppliers are sought. + commodity (str): Commodity of interest (e.g. ``"electricity"``). + include_feedstock_sources (bool, optional): If True, feedstock techs are + included in the set of controller-managed technologies. Defaults to True. + + Returns: + list[str]: Controller-managed technologies upstream of ``tech_name`` + that produce ``commodity``. + """ + # Build the set of techs the controller can see + if include_feedstock_sources: + input_techs = self.input_techs | set(self.feedstock_comps) + else: + input_techs = self.input_techs.copy() + + # All graph ancestors of tech_name (any depth) + ancestors = nx.ancestors(self.technology_graph, tech_name) + + # Keep only ancestors that have an outgoing edge carrying the target commodity. + # Edges are (source, dest, commodity) tuples + ancestors_with_commodity = { + src + for src, _, comm in self.technology_graph.edges(data="commodity") + if src in ancestors and comm == commodity + } + + # Intersect with controller-managed techs + return list(ancestors_with_commodity & input_techs) + + def find_converter_techs(self, include_feedstock_sources=True): + """Identify technologies that transform one commodity into another. + + A "converter" is a tech whose output commodities differ from the commodities + produced by its upstream ancestors (e.g. an electrolyzer: electricity → hydrogen). + + Args: + include_feedstock_sources (bool, optional): If True, include feedstock techs + in the set of candidate technologies. Defaults to True. + + Returns: + set[tuple[str, str, str]]: Set of ``(input_commodity, tech_name, output_commodity)`` + tuples for each detected conversion. Returns ``None`` for single-commodity systems. + """ + if include_feedstock_sources: + input_techs = self.input_techs | set(self.feedstock_comps) + else: + input_techs = self.input_techs.copy() + + # Single-commodity systems have no special handling by definition + if not self.multi_commodity_system: + return + + converter_techs = set() + node_order = list(self.technology_graph.nodes()) + edges = list(self.technology_graph.edges(data="commodity")) + + # Track the most recently discovered converter so we can scope + # upstream searches for chained converters (A→B→C where B and C + # both convert). Without this, C would see A's commodity as upstream + # input even though B already consumed it. + last_converter = None + + for source_tech, _, _ in edges: + if source_tech not in input_techs: + continue + + # Get the commodities produced by this tech (the "output" side of the conversion) + output_commodities = set(self._get_commodity_for_tech(source_tech)) + + # Find controlled ancestors of this tech + all_ancestors = nx.ancestors(self.technology_graph, source_tech) & input_techs + + if last_converter is not None: + # Only consider ancestors that appear after the last converter + # in topological order, preventing double-counting across + # chained converters. + converter_idx = node_order.index(last_converter) + nodes_after_converter = set(node_order[converter_idx + 1 :]) + ancestors = all_ancestors & nodes_after_converter + else: + ancestors = all_ancestors + + # Keep only ancestors actually connected (reachable) to this tech + connected_ancestors = [ + t for t in ancestors if nx.has_path(self.technology_graph, t, source_tech) + ] + + # Gather all commodities produced by connected ancestors + input_commodities = set() + for ancestor in connected_ancestors: + input_commodities.update(self._get_commodity_for_tech(ancestor)) + + # A converter has commodities that appear only on one side: + # upstream-only commodities are consumed, output-only are produced. + consumed = input_commodities - output_commodities + produced = output_commodities - input_commodities + + # If both sides have unique commodities, this tech is a converter + if consumed and produced: + for in_comm in consumed: + for out_comm in produced: + converter_techs.add((in_comm, source_tech, out_comm)) + last_converter = source_tech + + return converter_techs diff --git a/h2integrate/control/control_strategies/system_level/test/conftest.py b/h2integrate/control/control_strategies/system_level/test/conftest.py new file mode 100644 index 000000000..3380e4e17 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/test/conftest.py @@ -0,0 +1,7 @@ +from test.conftest import ( # noqa: F401 + temp_dir, + temp_dir_module, + temp_copy_of_example, + pytest_collection_modifyitems, + temp_copy_of_example_module_scope, +) diff --git a/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py new file mode 100644 index 000000000..91cd39e9d --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_controllers.py @@ -0,0 +1,724 @@ +"""Unit tests for system-level control base class and all controller strategies.""" + +import numpy as np +import pytest +import networkx as nx +import openmdao.api as om + +from h2integrate.control.control_strategies.system_level.demand_following_control import ( + DemandFollowingControl, +) +from h2integrate.control.control_strategies.system_level.cost_minimization_control import ( + CostMinimizationControl, +) +from h2integrate.control.control_strategies.system_level.profit_maximization_control import ( + ProfitMaximizationControl, +) + + +def _build_plant_config( + technology_interconnections, n_timesteps=4, sell_price=0.06, cost_per_tech=None +): + if cost_per_tech is None: + return { + "plant": {"simulation": {"n_timesteps": n_timesteps, "dt": 3600}, "plant_life": 30}, + "system_level_control": {"control_parameters": {"commodity_sell_price": sell_price}}, + "technology_interconnections": technology_interconnections, + } + return { + "plant": {"simulation": {"n_timesteps": n_timesteps, "dt": 3600}, "plant_life": 30}, + "system_level_control": { + "control_parameters": { + "commodity_sell_price": sell_price, + "cost_per_tech": cost_per_tech, + } + }, + "technology_interconnections": technology_interconnections, + } + + +def _build_technology_graph(technology_interconnections): + technology_graph = nx.DiGraph() + for connection in technology_interconnections: + source = connection[0] + destination = connection[1] + if len(connection) == 4: + technology_graph.add_edge(source, destination, commodity=connection[2]) + else: + technology_graph.add_edge(source, destination) + return technology_graph + + +def _build_tech_control_classifiers( + fixed=None, flexible=None, dispatchable=None, storage=None, feedstock=None +): + tech_control_classifiers = {k: "fixed" for k in (fixed or [])} + tech_control_classifiers |= {k: "flexible" for k in (flexible or [])} + tech_control_classifiers |= {k: "dispatchable" for k in (dispatchable or [])} + tech_control_classifiers |= {k: "storage" for k in (storage or [])} + tech_control_classifiers |= {k: "feedstock" for k in (feedstock or [])} + return tech_control_classifiers + + +def _build_slc_config( + technology_graph, + tech_control_classifiers: dict, + demand_tech: str = "demand", + demand_commodity: str = "electricity", + demand_commodity_rate_units: str = "kW", + storage_techs_with_control: list = [], +): + sources_to_commodities = { + (e[0], e[-1]) for e in technology_graph.edges(data="commodity") if e[-1] is not None + } + + tech_to_commodities = { + (e[0], e[-1]) for e in sources_to_commodities if e[0] in tech_control_classifiers + } + + storage_techs = [k for k, v in tech_control_classifiers.items() if v == "storage"] + storage_techs_to_control = { + k: True if k in storage_techs_with_control else False for k in storage_techs + } + + slc_config = { + "demand_commodity": demand_commodity, + "demand_commodity_rate_units": demand_commodity_rate_units, + "demand_tech": demand_tech, + "tech_to_commodity": tech_to_commodities, + "storage_techs_to_control": storage_techs_to_control, + "technology_graph": technology_graph, + "tech_control_classifiers": tech_control_classifiers, + } + return slc_config + + +def _build_problem(slc_cls, plant_config, slc_config, demand=50000, tech_config={}): + """Create and setup an OpenMDAO Problem with the given controller.""" + prob = om.Problem() + + feedstock_techs = [ + k for k, v in slc_config["tech_control_classifiers"].items() if v == "feedstock" + ] + feedstock_subsystem_names = [] + for fi, feedstock_tech in enumerate(feedstock_techs): + feedstock_commodity = [ + e[-1] for e in slc_config["tech_to_commodity"] if e[0] == feedstock_tech + ] + feedstock_comp = prob.model.add_subsystem(f"IVC{fi}", om.Group()) + feedstock_comp.add_subsystem( + "feedstock", + om.IndepVarComp( + name=f"{feedstock_tech}_{feedstock_commodity[0]}_out", + val=np.full(plant_config["plant"]["simulation"]["n_timesteps"], 1e9), + units="MMBtu/h", + ), + ) + + feedstock_subsystem_names.append( + f"IVC{fi}.feedstock.{feedstock_tech}_{feedstock_commodity[0]}_out" + ) + + prob.model.add_subsystem( + "slc", + slc_cls( + driver_config={}, + plant_config=plant_config, + tech_config=tech_config, + slc_config=slc_config, + ), + ) + + for feedstock_name in feedstock_subsystem_names: + connection_destination = feedstock_name.split(".")[-1] + prob.model.connect(feedstock_name, f"slc.{connection_destination}") + + prob.setup() + + # Set demand profile from config + demand_name = f"slc.{slc_config['demand_commodity']}_demand" + prob.set_val(demand_name, demand) + + return prob + + +# --------------------------------------------------------------------------- +# SystemLevelControlBase +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestSystemLevelControlBase: + """Tests for the abstract base class setup logic.""" + + def test_base_creates_flexible_io(self): + tech_connections = [["wind", "demand", "electricity", "cable"]] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(flexible=["wind"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + # Use DemandFollowingControl since base is abstract + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + # _var_rel2meta uses relative names (no "slc." prefix) + assert "wind_electricity_out" in prob.model.slc._var_rel2meta + assert "wind_rated_electricity_production" in prob.model.slc._var_rel2meta + assert "wind_electricity_set_point" in prob.model.slc._var_rel2meta + + def test_base_creates_dispatchable_io(self): + tech_connections = [["ng", "demand", "electricity", "cable"]] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + assert "ng_electricity_out" in prob.model.slc._var_rel2meta + assert "ng_rated_electricity_production" in prob.model.slc._var_rel2meta + assert "ng_electricity_set_point" in prob.model.slc._var_rel2meta + + def test_base_creates_storage_io(self): + tech_connections = [["battery", "demand", "electricity", "cable"]] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(storage=["battery"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + + assert "battery_electricity_out" in prob.model.slc._var_rel2meta + assert "battery_rated_electricity_production" in prob.model.slc._var_rel2meta + assert "battery_electricity_set_point" in prob.model.slc._var_rel2meta + + def test_base_creates_demand_input(self): + plant_config = _build_plant_config([]) + tech_graph = _build_technology_graph([]) + tech_control_classifiers = _build_tech_control_classifiers() + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + + assert "electricity_demand" in prob.model.slc._var_rel2meta + + def test_backward_compat_alias(self): + """DemandFollowingControl should be an alias for DemandFollowingControl.""" + assert DemandFollowingControl is DemandFollowingControl + + +# --------------------------------------------------------------------------- +# DemandFollowingControl +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestDemandFollowingControl: + """Tests for the demand-following (equal-share) controller.""" + + def test_equal_share_two_dispatchable(self): + tech_connections = [ + ["ng1", "combiner", "electricity", "cable"], + ["ng2", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng1", "ng2"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + + prob.set_val("slc.ng1_rated_electricity_production", 80000) + prob.set_val("slc.ng2_rated_electricity_production", 40000) + prob.run_model() + + sp1 = prob.get_val("slc.ng1_electricity_set_point") + sp2 = prob.get_val("slc.ng2_electricity_set_point") + np.testing.assert_allclose(sp1, 25000) + np.testing.assert_allclose(sp2, 25000) + + def test_flexible_reduces_demand(self): + tech_connections = [ + ["wind", "combiner", "electricity", "cable"], + ["ng", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + flexible=["wind"], dispatchable=["ng"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + + prob.set_val("slc.wind_electricity_out", [30000, 60000, 50000, 10000]) + prob.set_val("slc.wind_rated_electricity_production", 120000) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # demand=50k, wind outputs [30k,60k,50k,10k] → remaining = max(0, demand-wind) + expected = np.maximum(50000 - np.array([30000, 60000, 50000, 10000]), 0) + np.testing.assert_allclose(ng_sp, expected) + + def test_storage_absorbs_surplus(self): + tech_connections = [ + ["wind", "battery", "electricity", "cable"], + ["wind", "combiner", "electricity", "cable"], + ["battery", "combiner", "electricity", "cable"], + ["ng", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + flexible=["wind"], storage=["battery"], dispatchable=["ng"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + + prob.set_val("slc.wind_electricity_out", [70000, 30000, 50000, 50000]) + prob.set_val("slc.wind_rated_electricity_production", 120000) + prob.set_val("slc.battery_electricity_out", [0, 0, 0, 0]) + prob.set_val("slc.battery_rated_electricity_production", 50000) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.run_model() + + batt_sp = prob.get_val("slc.battery_electricity_set_point") + # demand - wind = [50k-70k, 50k-30k, 0, 0] = [-20k, 20k, 0, 0] + expected = np.array([-20000, 20000, 0, 0]) + np.testing.assert_allclose(batt_sp, expected) + + def test_no_techs_runs(self): + """Controller with no techs should still run without error.""" + plant_config = _build_plant_config([]) + tech_graph = _build_technology_graph([]) + tech_control_classifiers = _build_tech_control_classifiers() + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(DemandFollowingControl, plant_config, slc_config) + + prob.run_model() # should not raise + + +# --------------------------------------------------------------------------- +# CostMinimizationControl +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestCostMinimizationControl: + """Tests for the merit-order cost-minimization controller.""" + + def test_cheapest_dispatched_first(self): + tech_connections = [ + ["cheap", "combiner", "electricity", "cable"], + ["expensive", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, cost_per_tech={"cheap": 0.03, "expensive": 0.08} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["cheap", "expensive"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.cheap_rated_electricity_production", 80000) + prob.set_val("slc.expensive_rated_electricity_production", 40000) + prob.run_model() + + cheap_sp = prob.get_val("slc.cheap_electricity_set_point") + expensive_sp = prob.get_val("slc.expensive_electricity_set_point") + # Cheap can handle all 50k (rated 80k), so expensive gets 0 + np.testing.assert_allclose(cheap_sp, 50000) + np.testing.assert_allclose(expensive_sp, 0) + + def test_overflow_to_expensive(self): + tech_connections = [ + ["cheap", "combiner", "electricity", "cable"], + ["expensive", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, cost_per_tech={"cheap": 0.03, "expensive": 0.08} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["cheap", "expensive"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.cheap_rated_electricity_production", 30000) + prob.set_val("slc.expensive_rated_electricity_production", 40000) + prob.run_model() + + cheap_sp = prob.get_val("slc.cheap_electricity_set_point") + expensive_sp = prob.get_val("slc.expensive_electricity_set_point") + # Cheap maxes at 30k, expensive picks up remaining 20k + np.testing.assert_allclose(cheap_sp, 30000) + np.testing.assert_allclose(expensive_sp, 20000) + + def test_with_flexible_reduces_dispatch(self): + tech_connections = [ + ["wind", "combiner", "electricity", "cable"], + ["ng", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections, cost_per_tech={"ng": 0.05}) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + flexible=["wind"], dispatchable=["ng"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.wind_electricity_out", [40000, 40000, 40000, 40000]) + prob.set_val("slc.wind_rated_electricity_production", 120000) + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # demand 50k - wind 40k = 10k remaining + np.testing.assert_allclose(ng_sp, 10000) + + +# --------------------------------------------------------------------------- +# ProfitMaximizationControl +# --------------------------------------------------------------------------- +@pytest.mark.unit +class TestProfitMaximizationControl: + """Tests for the profit-maximization controller.""" + + def test_unprofitable_tech_not_dispatched(self): + tech_connections = [ + ["cheap", "combiner", "electricity", "cable"], + ["expensive", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.06, cost_per_tech={"cheap": 0.03, "expensive": 0.08} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["cheap", "expensive"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.cheap_rated_electricity_production", 30000) + prob.set_val("slc.expensive_rated_electricity_production", 40000) + prob.set_val("slc.commodity_sell_price", 0.06) + prob.run_model() + + cheap_sp = prob.get_val("slc.cheap_electricity_set_point") + expensive_sp = prob.get_val("slc.expensive_electricity_set_point") + # Cheap (0.03 < 0.06) dispatched up to rated 30k + # Expensive (0.08 >= 0.06) NOT dispatched, demand unmet + np.testing.assert_allclose(cheap_sp, 30000) + np.testing.assert_allclose(expensive_sp, 0) + + def test_all_profitable(self): + tech_connections = [ + ["a", "combiner", "electricity", "cable"], + ["b", "combiner", "electricity", "cable"], + ["combiner", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"a": 0.03, "b": 0.05} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["a", "b"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.a_rated_electricity_production", 80000) + prob.set_val("slc.b_rated_electricity_production", 40000) + prob.set_val("slc.commodity_sell_price", 0.10) + prob.run_model() + + a_sp = prob.get_val("slc.a_electricity_set_point") + b_sp = prob.get_val("slc.b_electricity_set_point") + # Both profitable, cheapest first: a gets 50k (rated 80k), b gets 0 + np.testing.assert_allclose(a_sp, 50000) + np.testing.assert_allclose(b_sp, 0) + + def test_none_profitable(self): + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.01, cost_per_tech={"ng": 0.05} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.01) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # NG cost (0.05) >= sell price (0.01), not dispatched + np.testing.assert_allclose(ng_sp, 0) + + def test_sell_price_from_config(self): + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"ng": 0.03} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.ng_rated_electricity_production", 100000) + # Don't set sell_price explicitly — should use config default 0.10 + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # Config sell_price=0.10 > marginal 0.03 → dispatched + np.testing.assert_allclose(ng_sp, 50000) + + def test_time_varying_sell_price(self): + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.06, cost_per_tech={"ng": 0.05} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.ng_rated_electricity_production", 100000) + # Sell price varies: 2 profitable hours, 2 unprofitable + prob.set_val("slc.commodity_sell_price", [0.08, 0.03, 0.10, 0.02]) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # mc=0.05: profitable when sell>0.05 (hours 0,2), not when sell<0.05 (hours 1,3) + np.testing.assert_allclose(ng_sp, [50000, 0, 50000, 0]) + + def test_buy_price_scalar(self): + """buy_price mode with a scalar buy price from tech config.""" + tech_config = { + "technologies": { + "grid": { + "model_inputs": { + "cost_parameters": {"electricity_buy_price": 0.04}, + } + } + } + } + + tech_connections = [ + ["grid", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"grid": "buy_price"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["grid"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem( + ProfitMaximizationControl, + plant_config, + slc_config, + demand=50000, + tech_config=tech_config, + ) + + prob.set_val("slc.electricity_demand", 50000) + prob.set_val("slc.grid_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.10) + prob.run_model() + + grid_sp = prob.get_val("slc.grid_electricity_set_point") + # buy_price=0.04 < sell_price=0.10 → dispatched + np.testing.assert_allclose(grid_sp, 50000) + + def test_buy_price_time_varying(self): + """buy_price mode with time-varying prices (override via set_val).""" + + tech_config = { + "technologies": { + "grid": { + "model_inputs": { + "cost_parameters": {"electricity_buy_price": 0.04}, + } + } + } + } + tech_connections = [ + ["grid", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.06, cost_per_tech={"grid": "buy_price"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["grid"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem( + ProfitMaximizationControl, + plant_config, + slc_config, + demand=50000, + tech_config=tech_config, + ) + + prob.set_val("slc.electricity_demand", 50000) + prob.set_val("slc.grid_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.06) + # Time-varying buy price: profitable at hours 0,2; unprofitable at hours 1,3 + prob.set_val("slc.grid_buy_price", [0.03, 0.08, 0.04, 0.09]) + prob.run_model() + + grid_sp = prob.get_val("slc.grid_electricity_set_point") + np.testing.assert_allclose(grid_sp, [50000, 0, 50000, 0]) + + def test_varopex_mode(self): + """VarOpEx mode computes marginal cost from VarOpEx / production.""" + tech_connections = [ + ["gen", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"gen": "VarOpEx"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["gen"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.gen_rated_electricity_production", 100000) + # Set VarOpEx ($/year, shape=plant_life=30) and production + prob.set_val("slc.gen_VarOpEx", np.full(30, 500000.0)) + # Simulate 4 hours of 100 MW production → 400 MWh + prob.set_val("slc.gen_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + gen_sp = prob.get_val("slc.gen_electricity_set_point") + # VarOpEx=500k $/yr, production=100MW*4h=400MWh over 4h + # Annual production = 400 MWh / (4/8760) = 876,000 MWh + # mc = 500k / 876k ≈ 0.571 $/MWh ≈ 0.000571 $/kWh + # This is very cheap, so it should be dispatched fully + np.testing.assert_allclose(gen_sp, 50000) + + def test_cost_per_tech_default_zero(self): + """Techs not listed in cost_per_tech default to zero marginal cost.""" + + tech_connections = [ + ["ng", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config(tech_connections, sell_price=0.10, cost_per_tech={}) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.ng_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.10) + prob.run_model() + + ng_sp = prob.get_val("slc.ng_electricity_set_point") + # mc=0.0 < sell_price=0.10 → dispatched + np.testing.assert_allclose(ng_sp, 50000) + + def test_feedstock_single(self): + """feedstock mode: single upstream feedstock drives marginal cost.""" + + tech_connections = [ + ["ng_feed", "ng_plant", "natural_gas", "pipe"], + ["ng_plant", "demand", "electricity", "cable"], + ] + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"ng_plant": "feedstock"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["ng_plant"], feedstock=["ng_feed"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.ng_plant_rated_electricity_production", 100000) + # Feedstock VarOpEx: $1M/yr; production: 100 MW * 4 h = 400 MWh + prob.set_val("slc.ng_feed_VarOpEx", np.full(30, 1_000_000.0)) + prob.set_val("slc.ng_plant_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + sp = prob.get_val("slc.ng_plant_electricity_set_point") + # Annual production = 400 MWh / (4/8760) = 876,000 MWh + # mc = 1M / 876k ≈ 1.14 $/MWh ≈ 0.00114 $/kWh → very cheap + np.testing.assert_allclose(sp, 50000) + + def test_feedstock_multiple(self): + """feedstock mode: multiple upstream feedstocks are summed.""" + tech_connections = [ + ["feed_a", "plant", "gas_a", "pipe"], + ["feed_b", "plant", "gas_b", "pipe"], + ["other_tech", "plant", "something", "cable"], + ["plant", "demand", "electricity", "cable"], + ] + + plant_config = _build_plant_config( + tech_connections, sell_price=0.10, cost_per_tech={"plant": "feedstock"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["plant"], feedstock=["feed_a", "feed_b"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.plant_rated_electricity_production", 100000) + # Two feedstocks: $500k and $300k → total $800k/yr + prob.set_val("slc.feed_a_VarOpEx", np.full(30, 500_000.0)) + prob.set_val("slc.feed_b_VarOpEx", np.full(30, 300_000.0)) + prob.set_val("slc.plant_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + sp = prob.get_val("slc.plant_electricity_set_point") + # Total VarOpEx = 800k, annual production = 876,000 MWh + # mc ≈ 0.913 $/MWh ≈ 0.000913 $/kWh → very cheap + np.testing.assert_allclose(sp, 50000) + + def test_feedstock_profit_max_unprofitable(self): + """feedstock mode in profit max: unprofitable when feedstock costs exceed sell price.""" + + tech_connections = [ + ["ng_feed", "ng_plant", "natural_gas", "pipe"], + ["ng_plant", "demand", "electricity", "cable"], + ] + # use a very low sell price + plant_config = _build_plant_config( + tech_connections, sell_price=0.01, cost_per_tech={"ng_plant": "feedstock"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers( + dispatchable=["ng_plant"], feedstock=["ng_feed"] + ) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + prob = _build_problem(ProfitMaximizationControl, plant_config, slc_config, demand=50000) + + prob.set_val("slc.ng_plant_rated_electricity_production", 100000) + prob.set_val("slc.commodity_sell_price", 0.01) + # Very expensive feedstock: $100M/yr → high marginal cost + prob.set_val("slc.ng_feed_VarOpEx", np.full(30, 100_000_000.0)) + prob.set_val("slc.ng_plant_electricity_out", np.full(4, 100000.0)) + prob.run_model() + + sp = prob.get_val("slc.ng_plant_electricity_set_point") + # mc = 100M / 876k ≈ 114 $/MWh ≈ 0.114 $/kWh > sell 0.01 → NOT dispatched + np.testing.assert_allclose(sp, 0) + + def test_feedstock_no_feedstock_raises(self): + """feedstock mode raises ValueError when no feedstock is found upstream.""" + + tech_connections = [ + ["some_tech", "ng_plant", "electricity", "cable"], + ] + + plant_config = _build_plant_config( + tech_connections, sell_price=0.01, cost_per_tech={"ng_plant": "feedstock"} + ) + tech_graph = _build_technology_graph(tech_connections) + tech_control_classifiers = _build_tech_control_classifiers(dispatchable=["ng_plant"]) + slc_config = _build_slc_config(tech_graph, tech_control_classifiers) + + with pytest.raises(ValueError, match="at least one feedstock"): + _build_problem(CostMinimizationControl, plant_config, slc_config, demand=50000) 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 new file mode 100644 index 000000000..275d78b96 --- /dev/null +++ b/h2integrate/control/control_strategies/system_level/test/test_slc_examples.py @@ -0,0 +1,308 @@ +import numpy as np +import pytest + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("35_system_level_control/no_battery", None)] +) +def test_slc_no_battery(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + with subtests.test("Wind set point == rated"): + assert np.all( + model.prob.get_val("system_level_controller.wind_electricity_set_point", units="kW") + == model.prob.get_val("wind.rated_electricity_production", units="kW") + ) + + with subtests.test("Natural gas plant set point"): + remaining_demand = model.prob.get_val( + "electrical_load_demand.electricity_demand_out", units="kW" + ) - model.prob.get_val("wind.electricity_out", units="kW") + ng_set_point = model.prob.get_val( + "system_level_controller.natural_gas_plant_electricity_set_point", units="kW" + ) + expected_ng_set_point = np.clip( + remaining_demand, + a_min=0.0, + a_max=model.prob.get_val("natural_gas_plant.rated_electricity_production", units="kW")[ + 0 + ], + ) + assert np.allclose(expected_ng_set_point, ng_set_point, rtol=1e-6, atol=1e-8) + + with subtests.test("Total unmet demand"): + assert ( + pytest.approx(0.0, rel=1e-6, abs=1e-8) + == model.prob.get_val( + "electrical_load_demand.unmet_electricity_demand_out", units="kW" + ).sum() + ) + + with subtests.test("Wind LCOE"): + assert pytest.approx(77.07060204, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.LCOE_profast_lco", units="USD/(MW*h)" + ) + with subtests.test("Natural gas LCOE"): + assert pytest.approx(85.5774049107076, rel=1e-6) == model.prob.get_val( + "finance_subgroup_natural_gas.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Electricity LCOE"): + assert pytest.approx(80.79533451532551, rel=1e-6) == model.prob.get_val( + "finance_subgroup_electricity.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Wind NPV"): + assert pytest.approx(-38.5777102298, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.NPV_electricity__profast_npv", units="MUSD" + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("35_system_level_control/yes_battery", None)] +) +def test_slc_yes_battery(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + with subtests.test("Wind set point == rated"): + assert np.all( + model.prob.get_val("system_level_controller.wind_electricity_set_point", units="kW") + == model.prob.get_val("wind.rated_electricity_production", units="kW") + ) + + with subtests.test("Battery set point"): + remaining_demand = model.prob.get_val( + "electrical_load_demand.electricity_demand_out", units="kW" + ) - model.prob.get_val("wind.electricity_out", units="kW") + battery_set_point = model.prob.get_val( + "system_level_controller.battery_electricity_set_point", units="kW" + ) + assert np.allclose(remaining_demand, battery_set_point, rtol=1e-6, atol=1e-8) + + with subtests.test("Natural gas plant set point"): + remaining_demand = remaining_demand - model.prob.get_val( + "battery.electricity_out", units="kW" + ) + ng_set_point = model.prob.get_val( + "system_level_controller.natural_gas_plant_electricity_set_point", units="kW" + ) + expected_ng_set_point = np.clip( + remaining_demand, + a_min=0.0, + a_max=model.prob.get_val("natural_gas_plant.rated_electricity_production", units="kW")[ + 0 + ], + ) + assert np.allclose(expected_ng_set_point, ng_set_point, rtol=1e-6, atol=1e-8) + + with subtests.test("Total unmet demand"): + assert ( + pytest.approx(0.0, rel=1e-6, abs=1e-8) + == model.prob.get_val( + "electrical_load_demand.unmet_electricity_demand_out", units="kW" + ).sum() + ) + + with subtests.test("Wind LCOE"): + assert pytest.approx(77.07060204, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.LCOE_profast_lco", units="USD/(MW*h)" + ) + with subtests.test("Natural gas LCOE"): + assert pytest.approx(161.0833612618841, rel=1e-6) == model.prob.get_val( + "finance_subgroup_natural_gas.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Electricity LCOE"): + assert pytest.approx(109.02003689718997, rel=1e-6) == model.prob.get_val( + "finance_subgroup_electricity.LCOE", units="USD/(MW*h)" + ) + with subtests.test("Wind NPV"): + assert pytest.approx(-38.5777102298, rel=1e-6) == model.prob.get_val( + "finance_subgroup_renewables.NPV_electricity__profast_npv", units="MUSD" + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", + [("35_system_level_control/profit_maximization", None)], +) +def test_slc_profit_max(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + n_timesteps = 8760 + sell_price = np.zeros(n_timesteps) + for h in range(n_timesteps): + hour_of_day = h % 24 + if 16 <= hour_of_day < 22: + sell_price[h] = 0.08 # peak + else: + sell_price[h] = 0.03 # night (cheap) + + model.setup() + + model.prob.set_val( + "system_level_controller.commodity_sell_price", + sell_price, + units="USD/(kW*h)", + ) + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", [("35_system_level_control/yes_hydrogen", None)] +) +def test_slc_yes_hydrogen(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 + + with subtests.test("LCOH"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_hydrogen.LCOH", units="USD/kg"), rel=1e-6 + ) + == 14.878096642042243 + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", + [("35_system_level_control/battery_with_controller", None)], +) +def test_slc_battery_with_controller(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "wind_ng_demand.yaml") + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 + with subtests.test("natural gas not dispatched when wind+battery cover demand"): + demand = model.prob.get_val("electrical_load_demand.electricity_demand_out", units="kW") + battery_out = model.prob.get_val("battery.electricity_out", units="kW") + assert np.all(battery_out[wind_out < demand] >= 0) + with subtests.test("lcoe"): + assert ( + pytest.approx( + model.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/(kW*h)"), + rel=1e-6, + ) + == 0.109020041 + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "example_folder,resource_example_folder", + [("35_system_level_control/complex_profit_max", None)], +) +def test_slc_complex_profit_max(subtests, temp_copy_of_example): + example_folder = temp_copy_of_example + + model = H2IntegrateModel(example_folder / "complex_profit_max.yaml") + + n_timesteps = 8760 + hours_of_day = np.tile(np.arange(24), 365) + day_of_year = np.repeat(np.arange(365), 24) + + # Non-constant demand: base 50 MW, daytime bump to ~80 MW, summer cooling peak + base_demand = 50_000 # kW + daytime_bump = np.where((hours_of_day >= 7) & (hours_of_day < 21), 30_000, 0) + seasonal_demand = 1.0 + 0.4 * np.sin(2 * np.pi * (day_of_year - 172) / 365) + demand_profile = (base_demand + daytime_bump) * seasonal_demand + + # ERCOT-like wholesale sell price ($/kWh) with diurnal shape + sell_price = np.zeros(n_timesteps) + for h in range(n_timesteps): + hour = hours_of_day[h] + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + season = 1.0 + 0.35 * np.sin(2 * np.pi * (day - 172) / 365) + + if hour < 6: + price = 0.025 + elif hour < 10: + price = 0.025 + (hour - 6) * 0.008 + elif hour < 15: + price = 0.035 + elif hour < 20: + price = 0.035 + (hour - 15) * 0.018 + else: + price = 0.125 - (hour - 20) * 0.025 + + sell_price[h] = price * season + + # Summer evening price spikes + for h in range(n_timesteps): + day = day_of_year[h // 24] if h // 24 < 365 else day_of_year[-1] + hour = hours_of_day[h] + if 150 <= day <= 250 and 17 <= hour <= 20 and day % 5 == 0: + sell_price[h] = max(sell_price[h], 0.20) + + # Grid buy price: wholesale + retail markup + grid_buy_price = sell_price + 0.02 + + model.setup() + + model.prob.set_val( + "electrical_load_demand.electricity_demand", + demand_profile, + ) + model.prob.set_val( + "system_level_controller.commodity_sell_price", + sell_price, + units="USD/(kW*h)", + ) + model.prob.set_val( + "grid_buy.electricity_buy_price", + grid_buy_price, + units="USD/(kW*h)", + ) + + model.run() + + wind_out = model.prob.get_val("wind.electricity_out") + solar_out = model.prob.get_val("solar.electricity_out") + ng_out = model.prob.get_val("natural_gas_plant.electricity_out", units="kW") + grid_out = model.prob.get_val("grid_buy.electricity_out") + + with subtests.test("wind farm generates power"): + assert wind_out.sum() > 0 + + with subtests.test("solar farm generates power"): + assert solar_out.sum() > 0 + + with subtests.test("natural gas dispatched"): + assert ng_out.sum() > 0 + + with subtests.test("grid used when needed"): + assert grid_out.sum() > 0 diff --git a/h2integrate/converters/ammonia/ammonia_synloop.py b/h2integrate/converters/ammonia/ammonia_synloop.py index 30e90c172..99aea7a1b 100644 --- a/h2integrate/converters/ammonia/ammonia_synloop.py +++ b/h2integrate/converters/ammonia/ammonia_synloop.py @@ -164,6 +164,7 @@ class AmmoniaSynLoopPerformanceModel(ResizeablePerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/ammonia/simple_ammonia_model.py b/h2integrate/converters/ammonia/simple_ammonia_model.py index 527e2333b..79e8e2adc 100644 --- a/h2integrate/converters/ammonia/simple_ammonia_model.py +++ b/h2integrate/converters/ammonia/simple_ammonia_model.py @@ -34,6 +34,7 @@ class SimpleAmmoniaPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/co2/marine/direct_ocean_capture.py b/h2integrate/converters/co2/marine/direct_ocean_capture.py index 0702aab32..7f72eae12 100644 --- a/h2integrate/converters/co2/marine/direct_ocean_capture.py +++ b/h2integrate/converters/co2/marine/direct_ocean_capture.py @@ -80,6 +80,7 @@ class DOCPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py b/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py index 96995c8f5..6089c0658 100644 --- a/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py +++ b/h2integrate/converters/co2/marine/ocean_alkalinity_enhancement.py @@ -73,6 +73,7 @@ class OAEPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/grid/grid.py b/h2integrate/converters/grid/grid.py index 69f99828d..fee16ddc2 100644 --- a/h2integrate/converters/grid/grid.py +++ b/h2integrate/converters/grid/grid.py @@ -42,7 +42,7 @@ class GridPerformanceModel(PerformanceModelBaseClass): Inputs interconnection_size (float): Maximum power capacity for grid connection (kW). electricity_in (array): Power flowing into the grid (selling) (kW). - electricity_set_point (array): Downstream electricity set point (kW). + electricity_command_value (array): Downstream electricity command value (kW). Outputs electricity_out (array): Power flowing out of the grid (buying) (kW). @@ -52,6 +52,7 @@ class GridPerformanceModel(PerformanceModelBaseClass): 300, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -85,13 +86,13 @@ def setup(self): desc="Electricity flowing into grid interconnection point (selling to grid)", ) - # Electricity set point from downstream (for buying from grid) + # Electricity command value from downstream (for buying from grid) self.add_input( - "electricity_set_point", + "electricity_command_value", val=0.0, shape=n_timesteps, units=self.commodity_rate_units, - desc="Electricity set point from downstream technologies", + desc="Electricity command value from downstream technologies", ) # electricity_out is electricity flowing OUT OF the grid (buying from grid) @@ -135,12 +136,14 @@ def compute(self, inputs, outputs): electricity_sold = np.clip(inputs["electricity_in"], 0, interconnection_size) outputs["electricity_sold"] = electricity_sold - # Buying: electricity flows out of grid to meet set point, limited by interconnection - electricity_bought = np.clip(inputs["electricity_set_point"], 0, interconnection_size) + # Buying: electricity flows out of grid to meet command value, limited by interconnection + electricity_bought = np.clip(inputs["electricity_command_value"], 0, interconnection_size) outputs["electricity_out"] = electricity_bought - # Unmet demand if set point exceeds interconnection size - outputs["electricity_unmet_demand"] = inputs["electricity_set_point"] - electricity_bought + # Unmet demand if command value exceeds interconnection size + outputs["electricity_unmet_demand"] = ( + inputs["electricity_command_value"] - electricity_bought + ) # Not sold electricity if demand exceeds interconnection size outputs["electricity_excess"] = inputs["electricity_in"] - electricity_sold diff --git a/h2integrate/converters/grid/test/test_grid.py b/h2integrate/converters/grid/test/test_grid.py index 173d05181..80d908648 100644 --- a/h2integrate/converters/grid/test/test_grid.py +++ b/h2integrate/converters/grid/test/test_grid.py @@ -45,7 +45,7 @@ def test_grid_performance_outputs(plant_config, subtests): # Set demand below interconnection limit demand = np.full(n_timesteps, 30000.0) # 30 MW demand - prob.set_val("comp.electricity_set_point", demand) + prob.set_val("comp.electricity_command_value", demand) prob.run_model() @@ -148,7 +148,7 @@ def test_buying_electricity(plant_config, n_timesteps): # Set demand below interconnection limit demand = np.full(n_timesteps, 30000.0) # 30 MW demand - prob.set_val("grid.electricity_set_point", demand) + prob.set_val("grid.electricity_command_value", demand) prob.run_model() @@ -177,7 +177,7 @@ def test_buying_with_interconnection_limit(plant_config, n_timesteps): # Set demand above interconnection limit demand = np.full(n_timesteps, 60000.0) # 60 MW demand - prob.set_val("grid.electricity_set_point", demand) + prob.set_val("grid.electricity_command_value", demand) prob.run_model() @@ -247,7 +247,7 @@ def test_simultaneous_buy_and_sell(plant_config, n_timesteps): electricity_demand = np.full(n_timesteps, 40000.0) # 40 MW out prob.set_val("grid.electricity_in", electricity_in) - prob.set_val("grid.electricity_set_point", electricity_demand) + prob.set_val("grid.electricity_command_value", electricity_demand) prob.run_model() @@ -272,7 +272,7 @@ def test_varying_demand_profile(plant_config, n_timesteps): # Create varying demand profile demand = np.array([10000, 20000, 30000, 50000, 70000, 90000, 110000, 80000, 60000, 40000]) - prob.set_val("grid.electricity_set_point", demand) + prob.set_val("grid.electricity_command_value", demand) prob.run_model() @@ -302,7 +302,7 @@ def test_non_hourly_dt_demand_profile(subtests, plant_config, n_timesteps): # Create varying demand profile demand = np.array([10000, 20000, 30000, 50000, 70000, 90000, 110000, 80000, 60000, 40000]) - prob.set_val("grid.electricity_set_point", demand, units="kW") + prob.set_val("grid.electricity_command_value", demand, units="kW") prob.run_model() diff --git a/h2integrate/converters/hopp/hopp_wrapper.py b/h2integrate/converters/hopp/hopp_wrapper.py index 06579c601..06c4b55c8 100644 --- a/h2integrate/converters/hopp/hopp_wrapper.py +++ b/h2integrate/converters/hopp/hopp_wrapper.py @@ -16,6 +16,7 @@ class HOPPComponentModelConfig(CacheBaseConfig): hopp_config: dict = field() cost_year: int = field(converter=int) electrolyzer_rating: int | float | None = field(default=None) + marginal_cost: float = field(default=0.0) class HOPPComponent(PerformanceModelBaseClass, CacheBaseClass): @@ -32,6 +33,7 @@ class HOPPComponent(PerformanceModelBaseClass, CacheBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "flexible" def initialize(self): super().initialize() @@ -202,5 +204,10 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["power_capacity_to_interconnect_ratio"] = total_power_capacity / interconnect_kw + # Honor a system-level controller's set-point by curtailing + # `electricity_out`. Done before caching so cached outputs already + # reflect the post-curtailment values for this set point. + self.apply_curtailment(outputs) + # Cache the results for future use if enabled self.cache_outputs(inputs, outputs, discrete_inputs) diff --git a/h2integrate/converters/hydrogen/electrolyzer_baseclass.py b/h2integrate/converters/hydrogen/electrolyzer_baseclass.py index c0934a013..84f5ffdc0 100644 --- a/h2integrate/converters/hydrogen/electrolyzer_baseclass.py +++ b/h2integrate/converters/hydrogen/electrolyzer_baseclass.py @@ -9,6 +9,7 @@ class ElectrolyzerPerformanceBaseClass(ResizeablePerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -22,6 +23,16 @@ def setup(self): # Define inputs for electricity self.add_input("electricity_in", val=0.0, shape=self.n_timesteps, units="kW") + # Dispatchable models receive a command value from the system-level controller + if "system_level_control" in self.options["plant_config"]: + self.add_input( + f"{self.commodity}_command_value", + val=0.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Command value for {self.commodity} production from SLC", + ) + def compute(self, inputs, outputs): """ Computation for the OM component. diff --git a/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py b/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py index fc3c24fea..adaf61d9c 100644 --- a/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py +++ b/h2integrate/converters/hydrogen/geologic/h2_well_subsurface_baseclass.py @@ -46,6 +46,8 @@ class GeoH2SubsurfacePerformanceConfig(BaseConfig): class GeoH2SubsurfacePerformanceBaseClass(PerformanceModelBaseClass): + _control_classifier = "dispatchable" + """OpenMDAO component for modeling the performance of the well subsurface for geologic hydrogen. diff --git a/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py b/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py index e9107249c..84edafcc8 100644 --- a/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py +++ b/h2integrate/converters/hydrogen/geologic/h2_well_surface_baseclass.py @@ -28,6 +28,8 @@ class GeoH2SurfacePerformanceConfig(BaseConfig): class GeoH2SurfacePerformanceBaseClass(PerformanceModelBaseClass): + _control_classifier = "dispatchable" + """OpenMDAO component for modeling the performance of the wellhead surface processing for geologic hydrogen. diff --git a/h2integrate/converters/hydrogen/h2_fuel_cell.py b/h2integrate/converters/hydrogen/h2_fuel_cell.py index c0fe905f1..f6288386d 100644 --- a/h2integrate/converters/hydrogen/h2_fuel_cell.py +++ b/h2integrate/converters/hydrogen/h2_fuel_cell.py @@ -42,6 +42,7 @@ class LinearH2FuelCellPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -89,13 +90,13 @@ def setup(self): desc="Mass flow rate of hydrogen consumed by the fuel cell", ) - # Default the electricity set point input as the rated capacity + # Default the electricity command value input as the rated capacity self.add_input( - f"{self.commodity}_set_point", + f"{self.commodity}_command_value", val=self.config.system_capacity_kw, shape=self.n_timesteps, units=self.commodity_rate_units, - desc="Electricity set point for natural gas plant", + desc="Electricity command value for natural gas plant", ) def compute(self, inputs, outputs): @@ -105,7 +106,7 @@ def compute(self, inputs, outputs): Args: inputs: OpenMDAO inputs object containing hydrogen_in, fuel cell - HHV efficiency, electricity_set_point, and system_capacity. + HHV efficiency, electricity_command_value, and system_capacity. outputs: OpenMDAO outputs object for electricity_out, hydrogen_consumed. """ @@ -120,14 +121,14 @@ def compute(self, inputs, outputs): max_h2_consumption = system_capacity * kw_to_kgh_h2 - # electrical set point, saturated at maximum rated system capacity - electricity_set_point = np.where( - inputs["electricity_set_point"] > system_capacity, + # electrical command value, saturated at maximum rated system capacity + electricity_command_value = np.where( + inputs["electricity_command_value"] > system_capacity, system_capacity, - inputs["electricity_set_point"], + inputs["electricity_command_value"], ) - h2_demand = electricity_set_point * kw_to_kgh_h2 + h2_demand = electricity_command_value * kw_to_kgh_h2 # available feedstock, saturated at maximum system feedstock consumption h2_available = np.where( diff --git a/h2integrate/converters/hydrogen/pem_electrolyzer.py b/h2integrate/converters/hydrogen/pem_electrolyzer.py index 02b754462..5ca2a80e3 100644 --- a/h2integrate/converters/hydrogen/pem_electrolyzer.py +++ b/h2integrate/converters/hydrogen/pem_electrolyzer.py @@ -63,6 +63,7 @@ class ECOElectrolyzerPerformanceModel(ElectrolyzerPerformanceBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def setup(self): self.config = ECOElectrolyzerPerformanceModelConfig.from_dict( @@ -221,3 +222,9 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["annual_oxygen_produced"] = H2_Results["Performance Schedules"][ "Annual O2 Production [kg/year]" ] + + # Apply command_value from system-level controller if present + if "system_level_control" in self.options["plant_config"]: + command_value = inputs[f"{self.commodity}_command_value"] + commodity_out_key = f"{self.commodity}_out" + outputs[commodity_out_key] = np.minimum(outputs[commodity_out_key], command_value) diff --git a/h2integrate/converters/hydrogen/steam_methane_reformer.py b/h2integrate/converters/hydrogen/steam_methane_reformer.py index d82d585c8..19a46c667 100644 --- a/h2integrate/converters/hydrogen/steam_methane_reformer.py +++ b/h2integrate/converters/hydrogen/steam_methane_reformer.py @@ -48,6 +48,7 @@ class SteamMethaneReformerPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -88,13 +89,13 @@ def setup(self): desc="SMR plant rated capacity in t/d", ) - # Default the hydrogen set point input as the rated capacity + # Hydrogen command value (set by upstream controller, default = rated capacity) self.add_input( - f"{self.commodity}_set_point", + f"{self.commodity}_command_value", val=self.config.system_capacity_tonnes_per_day * (1000 / 24), # convert t/d to kg/h shape=n_timesteps, units=self.commodity_rate_units, - desc="Hydrogen set point for SMR plant", + desc="Hydrogen command value for SMR plant", ) # Add natural gas input, default to 0 --> set using feedstock component @@ -168,7 +169,7 @@ def compute(self, inputs, outputs): Args: inputs: OpenMDAO inputs object containing natural_gas_in, natural_gas_usage_rate, electricity_usage_rate, - system_capacity, and hydrogen_set_point. + system_capacity, and hydrogen_command_value. outputs: OpenMDAO outputs object for hydrogen_out, natural_gas_consumed, electricity_consumed, and unmet_hydrogen_demand. """ @@ -182,14 +183,14 @@ def compute(self, inputs, outputs): electricity_usage_kWh_per_kg = inputs["electricity_usage_rate"] max_electricity_consumption = system_capacity_kg_per_hour * electricity_usage_kWh_per_kg - # hydrogen set point, saturated at maximum rated system capacity - hydrogen_set_point = np.where( - inputs["hydrogen_set_point"] > system_capacity_kg_per_hour, + # saturate the hydrogen command value at maximum rated system capacity + saturated_command_value = np.where( + inputs["hydrogen_command_value"] > system_capacity_kg_per_hour, system_capacity_kg_per_hour, - inputs["hydrogen_set_point"], + inputs["hydrogen_command_value"], ) - natural_gas_demand = hydrogen_set_point * natural_gas_usage_mmbtu_per_kg - electricity_demand = hydrogen_set_point * electricity_usage_kWh_per_kg + natural_gas_demand = saturated_command_value * natural_gas_usage_mmbtu_per_kg + electricity_demand = saturated_command_value * electricity_usage_kWh_per_kg # available feedstock, saturated at maximum system feedstock consumption natural_gas_available = np.where( @@ -241,7 +242,7 @@ def compute(self, inputs, outputs): outputs["annual_hydrogen_produced"] = outputs["total_hydrogen_produced"] * ( 1 / self.fraction_of_year_simulated ) - outputs["unmet_hydrogen_demand"] = inputs["hydrogen_set_point"] - hydrogen_out + outputs["unmet_hydrogen_demand"] = inputs["hydrogen_command_value"] - hydrogen_out outputs["total_energy_conversion_ratio"] = total_energy_conversion_ratio diff --git a/h2integrate/converters/hydrogen/test/test_h2_fuel_cell.py b/h2integrate/converters/hydrogen/test/test_h2_fuel_cell.py index 2a49f9cbe..cfce4935d 100644 --- a/h2integrate/converters/hydrogen/test/test_h2_fuel_cell.py +++ b/h2integrate/converters/hydrogen/test/test_h2_fuel_cell.py @@ -160,7 +160,7 @@ def test_fuel_cell_demand(tech_config, plant_config, subtests): 0.0, # test case with set point equal to zero ) - prob.set_val("fuel_cell.electricity_set_point", elec_set_point, units="kW") + prob.set_val("fuel_cell.electricity_command_value", elec_set_point, units="kW") prob.run_model() diff --git a/h2integrate/converters/iron/humbert_ewin_perf.py b/h2integrate/converters/iron/humbert_ewin_perf.py index 1fc65f46d..0d87433ee 100644 --- a/h2integrate/converters/iron/humbert_ewin_perf.py +++ b/h2integrate/converters/iron/humbert_ewin_perf.py @@ -79,6 +79,7 @@ class HumbertEwinPerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): self.commodity = "sponge_iron" diff --git a/h2integrate/converters/iron/iron_dri_base.py b/h2integrate/converters/iron/iron_dri_base.py index 3b4e9efba..83ed3e817 100644 --- a/h2integrate/converters/iron/iron_dri_base.py +++ b/h2integrate/converters/iron/iron_dri_base.py @@ -34,6 +34,7 @@ class IronReductionPlantBasePerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -75,13 +76,13 @@ def setup(self): desc=f"{feedstock} consumed for iron reduction", ) - # Default the sponge iron set point input as the rated capacity + # Default the sponge iron command value input as the rated capacity self.add_input( - "sponge_iron_set_point", + "sponge_iron_command_value", val=self.config.sponge_iron_production_rate_tonnes_per_hr, shape=n_timesteps, units="t/h", - desc="Pig iron set point for iron plant", + desc="Pig iron command value for iron plant", ) coeff_fpath = ROOT_DIR / "converters" / "iron" / "rosner" / "perf_coeffs.csv" @@ -224,20 +225,20 @@ def compute(self, inputs, outputs): feedstocks["Name"] == "Reformer Catalyst" ]["Value"].sum() - # sponge iron set point, saturated at maximum rated system capacity - sponge_iron_set_point = np.where( - inputs["sponge_iron_set_point"] > inputs["system_capacity"], + # sponge iron command value, saturated at maximum rated system capacity + sponge_iron_command_value = np.where( + inputs["sponge_iron_command_value"] > inputs["system_capacity"], inputs["system_capacity"], - inputs["sponge_iron_set_point"], + inputs["sponge_iron_command_value"], ) # initialize an array of how much sponge iron could be produced - # from the available feedstocks and the set point + # from the available feedstocks and the command value sponge_iron_from_feedstocks = np.zeros( - (len(feedstocks_usage_rates) + 1, len(inputs["sponge_iron_set_point"])) + (len(feedstocks_usage_rates) + 1, len(inputs["sponge_iron_command_value"])) ) - # first entry is the sponge iron set point - sponge_iron_from_feedstocks[0] = sponge_iron_set_point + # first entry is the sponge iron command value + sponge_iron_from_feedstocks[0] = sponge_iron_command_value ii = 1 for feedstock_type, consumption_rate in feedstocks_usage_rates.items(): # calculate max inputs/outputs based on rated capacity diff --git a/h2integrate/converters/iron/iron_transport.py b/h2integrate/converters/iron/iron_transport.py index 93223b453..ff823ecfa 100644 --- a/h2integrate/converters/iron/iron_transport.py +++ b/h2integrate/converters/iron/iron_transport.py @@ -165,6 +165,7 @@ def compute(self, inputs, outputs): class IronTransportCostConfig(BaseConfig): transport_year: int = field(converter=int, validator=range_val(2022, 2065)) cost_year: int = field(converter=int, validator=range_val(2010, 2024)) + marginal_cost: float = field(default=0.0) class IronTransportCostComponent(CostModelBaseClass): diff --git a/h2integrate/converters/iron/martin_mine_perf_model.py b/h2integrate/converters/iron/martin_mine_perf_model.py index 8c77d0777..404fb9cab 100644 --- a/h2integrate/converters/iron/martin_mine_perf_model.py +++ b/h2integrate/converters/iron/martin_mine_perf_model.py @@ -35,6 +35,7 @@ class MartinIronMinePerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -76,13 +77,13 @@ def setup(self): desc="Crude ore input", ) - # Default the ore set point input as the rated capacity + # Default the ore command value input as the rated capacity self.add_input( - "iron_ore_set_point", + "iron_ore_command_value", val=self.config.max_ore_production_rate_tonnes_per_hr, shape=n_timesteps, units="t/h", - desc="Iron ore set point for iron mine", + desc="Iron ore command value for iron mine", ) self.add_output( @@ -202,11 +203,11 @@ def compute(self, inputs, outputs): max_crude_ore_consumption = inputs["system_capacity"] * crude_ore_usage_per_processed_ore max_energy_consumption = inputs["system_capacity"] * energy_usage_per_processed_ore - # iron ore set point, saturated at maximum rated system capacity - processed_ore_set_point = np.where( - inputs["iron_ore_set_point"] > inputs["system_capacity"], + # iron ore command value, saturated at maximum rated system capacity + processed_ore_command_value = np.where( + inputs["iron_ore_command_value"] > inputs["system_capacity"], inputs["system_capacity"], - inputs["iron_ore_set_point"], + inputs["iron_ore_command_value"], ) # available feedstocks, saturated at maximum system feedstock consumption @@ -226,9 +227,13 @@ def compute(self, inputs, outputs): processed_ore_from_electricity = energy_available / energy_usage_per_processed_ore processed_ore_from_crude_ore = crude_ore_available / crude_ore_usage_per_processed_ore - # output is minimum between available feedstocks and output set point + # output is minimum between available feedstocks and output command value processed_ore_production = np.minimum.reduce( - [processed_ore_from_crude_ore, processed_ore_from_electricity, processed_ore_set_point] + [ + processed_ore_from_crude_ore, + processed_ore_from_electricity, + processed_ore_command_value, + ] ) # energy consumption diff --git a/h2integrate/converters/iron/test/test_martin_mine.py b/h2integrate/converters/iron/test/test_martin_mine.py index a1865eb4d..052a51179 100644 --- a/h2integrate/converters/iron/test/test_martin_mine.py +++ b/h2integrate/converters/iron/test/test_martin_mine.py @@ -41,7 +41,7 @@ def test_iron_mine_performance_outputs( prob.set_val("comp.electricity_in", [annual_electricity / 8760] * 8760, units="kW") prob.set_val("comp.crude_ore_in", [annual_crude_ore / 8760] * 8760, units="t/h") - prob.set_val("comp.iron_ore_set_point", [ore_rated_capacity] * 8760, units="t/h") + prob.set_val("comp.iron_ore_command_value", [ore_rated_capacity] * 8760, units="t/h") prob.run_model() commodity = "iron_ore" @@ -152,7 +152,7 @@ def test_baseline_iron_ore_costs(plant_config, driver_config, iron_ore_config_ma prob.set_val("ore_perf.electricity_in", [annual_electricity / 8760] * 8760, units="kW") prob.set_val("ore_perf.crude_ore_in", [annual_crude_ore / 8760] * 8760, units="t/h") - prob.set_val("ore_perf.iron_ore_set_point", [ore_rated_capacity] * 8760, units="t/h") + prob.set_val("ore_perf.iron_ore_command_value", [ore_rated_capacity] * 8760, units="t/h") prob.run_model() diff --git a/h2integrate/converters/methanol/methanol_baseclass.py b/h2integrate/converters/methanol/methanol_baseclass.py index 1d918303d..b860c99de 100644 --- a/h2integrate/converters/methanol/methanol_baseclass.py +++ b/h2integrate/converters/methanol/methanol_baseclass.py @@ -38,6 +38,7 @@ class MethanolPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/natural_gas/dummy_gas_components.py b/h2integrate/converters/natural_gas/dummy_gas_components.py index 96525ff5f..f35537b74 100644 --- a/h2integrate/converters/natural_gas/dummy_gas_components.py +++ b/h2integrate/converters/natural_gas/dummy_gas_components.py @@ -65,6 +65,7 @@ class SimpleGasProducerPerformance(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -148,6 +149,7 @@ class SimpleGasConsumerPerformance(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/natural_gas/natural_gas_cc_ct.py b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py index 41771ebae..ff540f950 100644 --- a/h2integrate/converters/natural_gas/natural_gas_cc_ct.py +++ b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py @@ -48,7 +48,7 @@ class NaturalGasPerformanceModel(PerformanceModelBaseClass): system_capacity (float): Natural gas plant rated capacity in MW natural_gas_in (array): Natural gas input energy in MMBtu/h heat_rate_mmbtu_per_mwh (float): Plant heat rate in MMBtu/MWh - electricity_set_point (array): Electricity set point in MW for each timestep + electricity_command_value (array): Electricity command value in MW for each timestep Outputs: electricity_out (array): Electricity output in MW for each timestep @@ -60,6 +60,7 @@ class NaturalGasPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -101,13 +102,13 @@ def setup(self): desc="Natural gas plant rated capacity in MW", ) - # Default the electricity set point input as the rated capacity + # Default the electricity command value input as the rated capacity self.add_input( - f"{self.commodity}_set_point", + f"{self.commodity}_command_value", val=self.config.system_capacity_mw, shape=n_timesteps, units=self.commodity_rate_units, - desc="Electricity set point for natural gas plant", + desc="Electricity command value for natural gas plant", ) # Add natural gas input, default to 0 --> set using feedstock component @@ -137,7 +138,7 @@ def compute(self, inputs, outputs): Args: inputs: OpenMDAO inputs object containing natural_gas_in, heat_rate_mmbtu_per_mwh, - system_capacity, and electricity_set_point. + system_capacity, and electricity_command_value. outputs: OpenMDAO outputs object for electricity_out, natural_gas_consumed, and unmet_electricity_demand. """ @@ -147,13 +148,13 @@ def compute(self, inputs, outputs): heat_rate_mmbtu_per_mwh = inputs["heat_rate_mmbtu_per_mwh"] max_natural_gas_consumption = system_capacity * heat_rate_mmbtu_per_mwh - # electrical set point, saturated at maximum rated system capacity - electricity_set_point = np.where( - inputs["electricity_set_point"] > system_capacity, + # electrical command value, saturated at maximum rated system capacity + electricity_command_value = np.where( + inputs["electricity_command_value"] > system_capacity, system_capacity, - inputs["electricity_set_point"], + inputs["electricity_command_value"], ) - natural_gas_demand = electricity_set_point * heat_rate_mmbtu_per_mwh + natural_gas_demand = electricity_command_value * heat_rate_mmbtu_per_mwh # available feedstock, saturated at maximum system feedstock consumption natural_gas_available = np.where( @@ -180,7 +181,7 @@ def compute(self, inputs, outputs): outputs["annual_electricity_produced"] = outputs["total_electricity_produced"] * ( 1 / self.fraction_of_year_simulated ) - outputs["unmet_electricity_demand"] = inputs["electricity_set_point"] - electricity_out + outputs["unmet_electricity_demand"] = inputs["electricity_command_value"] - electricity_out @define(kw_only=True) diff --git a/h2integrate/converters/natural_gas/test/test_natural_gas_models.py b/h2integrate/converters/natural_gas/test/test_natural_gas_models.py index 0027989a9..3fd3b079b 100644 --- a/h2integrate/converters/natural_gas/test/test_natural_gas_models.py +++ b/h2integrate/converters/natural_gas/test/test_natural_gas_models.py @@ -375,7 +375,7 @@ def test_ngcc_performance_demand(plant_config, ngcc_performance_params, subtests # Set the natural gas input prob.set_val("natural_gas_in", natural_gas_input) - prob.set_val("electricity_set_point", electricity_demand_MW) + prob.set_val("electricity_command_value", electricity_demand_MW) prob.run_model() electricity_out = prob.get_val("electricity_out", units="MW") diff --git a/h2integrate/converters/nitrogen/simple_ASU.py b/h2integrate/converters/nitrogen/simple_ASU.py index 12ef88b48..da4762517 100644 --- a/h2integrate/converters/nitrogen/simple_ASU.py +++ b/h2integrate/converters/nitrogen/simple_ASU.py @@ -69,6 +69,7 @@ class SimpleASUPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/nuclear/nuclear_plant.py b/h2integrate/converters/nuclear/nuclear_plant.py index fc9a7e3f1..d137cf373 100644 --- a/h2integrate/converters/nuclear/nuclear_plant.py +++ b/h2integrate/converters/nuclear/nuclear_plant.py @@ -37,6 +37,7 @@ class QuinnNuclearPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "fixed" def initialize(self): super().initialize() @@ -60,18 +61,18 @@ def setup(self): desc="Nuclear plant rated capacity", ) self.add_input( - f"{self.commodity}_set_point", + f"{self.commodity}_command_value", val=self.config.system_capacity_kw, shape=n_timesteps, units=self.commodity_rate_units, - desc="Electricity set point for nuclear plant", + desc="Electricity command value for nuclear plant", ) def compute(self, inputs, outputs): system_capacity = inputs["system_capacity"] - electricity_set_point = inputs[f"{self.commodity}_set_point"] + electricity_command_value = inputs[f"{self.commodity}_command_value"] - electricity_out = np.minimum(electricity_set_point, system_capacity) + electricity_out = np.minimum(electricity_command_value, system_capacity) electricity_out = np.clip(electricity_out, 0.0, system_capacity) outputs["electricity_out"] = electricity_out diff --git a/h2integrate/converters/nuclear/test/test_nuclear_plant.py b/h2integrate/converters/nuclear/test/test_nuclear_plant.py index 6c143693e..7f6221512 100644 --- a/h2integrate/converters/nuclear/test/test_nuclear_plant.py +++ b/h2integrate/converters/nuclear/test/test_nuclear_plant.py @@ -64,7 +64,7 @@ def test_nuclear_performance_demand(plant_config, nuclear_performance_params, su prob.model.add_subsystem("nuc_perf", perf_comp, promotes=["*"]) prob.setup() - prob.set_val("electricity_set_point", electricity_demand) + prob.set_val("electricity_command_value", electricity_demand) prob.run_model() electricity_out = prob.get_val("electricity_out") diff --git a/h2integrate/converters/solar/solar_baseclass.py b/h2integrate/converters/solar/solar_baseclass.py index 0bfa4019c..dcf01bbc5 100644 --- a/h2integrate/converters/solar/solar_baseclass.py +++ b/h2integrate/converters/solar/solar_baseclass.py @@ -6,6 +6,7 @@ class SolarPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "flexible" def initialize(self): super().initialize() diff --git a/h2integrate/converters/solar/solar_pysam.py b/h2integrate/converters/solar/solar_pysam.py index 2fefd5986..1a400485d 100644 --- a/h2integrate/converters/solar/solar_pysam.py +++ b/h2integrate/converters/solar/solar_pysam.py @@ -311,3 +311,6 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["capacity_factor"] = outputs["total_electricity_produced"] / max_production outputs["annual_electricity_produced"] = self.system_model.value("ac_annual") + + # Apply curtailment based on set_point + self.apply_curtailment(outputs) diff --git a/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py b/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py index ff9eb0461..0c5ab9764 100644 --- a/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py +++ b/h2integrate/converters/steel/cmu_electric_arc_furnace_dri.py @@ -143,6 +143,7 @@ class CMUElectricArcFurnaceDRIPerformanceComponent(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -177,15 +178,15 @@ def setup(self): desc="Actual steel production", ) - # Default the steel demand input as the production rate + # Default the steel command value input as the production rate self.add_input( - "steel_demand", + "steel_command_value", val=units.convert_units( self.config.steel_production_rate_tonnes_per_year, "t/year", "t/h" ), shape=n_timesteps, units=self.commodity_rate_units, - desc="Steel demand for steel plant", + desc="Steel command value for steel plant", ) self.add_input( @@ -326,20 +327,20 @@ def compute(self, inputs, outputs): "lime": energy_mass_per_tonne["burnt_lime_per_tLS"], # t/t } - # steel demand, saturated at maximum rated system capacity - steel_demand = np.where( - inputs["steel_demand"] > system_production, + # steel command value, saturated at maximum rated system capacity + steel_command_value = np.where( + inputs["steel_command_value"] > system_production, system_production, - inputs["steel_demand"], + inputs["steel_command_value"], ) # initialize an array of how much steel could be produced - # from the available feedstocks and the demand + # from the available feedstocks and the command value steel_from_feedstocks = np.zeros( - (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_demand"])) + (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_command_value"])) ) - # first entry is the steel demand - steel_from_feedstocks[0] = steel_demand + # first entry is the steel command value + steel_from_feedstocks[0] = steel_command_value ii = 1 for feedstock_type, consumption_rate in feedstocks_usage_per_tonne_steel.items(): diff --git a/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py b/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py index 00dfa74d6..dcacda28c 100644 --- a/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py +++ b/h2integrate/converters/steel/cmu_electric_arc_furnace_scrap.py @@ -105,6 +105,7 @@ class CMUElectricArcFurnaceScrapOnlyPerformanceComponent(PerformanceModelBaseCla 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -139,15 +140,15 @@ def setup(self): desc="Actual steel production", ) - # Default the steel demand input as the production rate + # Default the steel command value input as the production rate self.add_input( - "steel_demand", + "steel_command_value", val=units.convert_units( self.config.steel_production_rate_tonnes_per_year, "t/year", "t/h" ), shape=n_timesteps, units=self.commodity_rate_units, - desc="Steel demand for steel plant", + desc="Steel command value for steel plant", ) feedstocks_to_units = { @@ -258,20 +259,20 @@ def compute(self, inputs, outputs): "lime": energy_mass_per_tonne["burnt_lime_per_tLS"], # t/t } - # steel demand, saturated at maximum rated system capacity - steel_demand = np.where( - inputs["steel_demand"] > system_production, + # steel command value, saturated at maximum rated system capacity + steel_command_value = np.where( + inputs["steel_command_value"] > system_production, system_production, - inputs["steel_demand"], + inputs["steel_command_value"], ) # initialize an array of how much steel could be produced - # from the available feedstocks and the demand + # from the available feedstocks and the command value steel_from_feedstocks = np.zeros( - (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_demand"])) + (len(feedstocks_usage_per_tonne_steel) + 1, len(inputs["steel_command_value"])) ) - # first entry is the steel demand - steel_from_feedstocks[0] = steel_demand + # first entry is the steel command value + steel_from_feedstocks[0] = steel_command_value ii = 1 for feedstock_type, consumption_rate in feedstocks_usage_per_tonne_steel.items(): diff --git a/h2integrate/converters/steel/steel_baseclass.py b/h2integrate/converters/steel/steel_baseclass.py index 24eb57f5c..121ac3087 100644 --- a/h2integrate/converters/steel/steel_baseclass.py +++ b/h2integrate/converters/steel/steel_baseclass.py @@ -6,6 +6,7 @@ class SteelPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/steel/steel_eaf_base.py b/h2integrate/converters/steel/steel_eaf_base.py index a73c3863e..6a8658912 100644 --- a/h2integrate/converters/steel/steel_eaf_base.py +++ b/h2integrate/converters/steel/steel_eaf_base.py @@ -34,6 +34,7 @@ class ElectricArcFurnacePlantBasePerformanceComponent(PerformanceModelBaseClass) 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() @@ -76,13 +77,13 @@ def setup(self): desc=f"{feedstock} consumed for steel production", ) - # Default the steel set point input as the rated capacity + # Default the steel command value input as the rated capacity self.add_input( - "steel_set_point", + "steel_command_value", val=self.config.steel_production_rate_tonnes_per_hr, shape=n_timesteps, units=self.commodity_rate_units, - desc="Steel set point for steel plant", + desc="Steel command value for steel plant", ) coeff_fpath = ROOT_DIR / "converters" / "iron" / "rosner" / "perf_coeffs.csv" @@ -230,20 +231,20 @@ def compute(self, inputs, outputs): "Value" ].sum() # t/t - # steel set point, saturated at maximum rated system capacity - steel_set_point = np.where( - inputs["steel_set_point"] > inputs["system_capacity"], + # steel command value, saturated at maximum rated system capacity + steel_command_value = np.where( + inputs["steel_command_value"] > inputs["system_capacity"], inputs["system_capacity"], - inputs["steel_set_point"], + inputs["steel_command_value"], ) # initialize an array of how much steel could be produced - # from the available feedstocks and the set point + # from the available feedstocks and the command value steel_from_feedstocks = np.zeros( - (len(feedstocks_usage_rates) + 1, len(inputs["steel_set_point"])) + (len(feedstocks_usage_rates) + 1, len(inputs["steel_command_value"])) ) - # first entry is the steel set point - steel_from_feedstocks[0] = steel_set_point + # first entry is the steel command value + steel_from_feedstocks[0] = steel_command_value ii = 1 for feedstock_type, consumption_rate in feedstocks_usage_rates.items(): # calculate max inputs/outputs based on rated capacity diff --git a/h2integrate/converters/water/desal/desalination_baseclass.py b/h2integrate/converters/water/desal/desalination_baseclass.py index fcabf728e..6b87e7c70 100644 --- a/h2integrate/converters/water/desal/desalination_baseclass.py +++ b/h2integrate/converters/water/desal/desalination_baseclass.py @@ -6,6 +6,7 @@ class DesalinationPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "dispatchable" def initialize(self): super().initialize() diff --git a/h2integrate/converters/water_power/hydro_plant_run_of_river.py b/h2integrate/converters/water_power/hydro_plant_run_of_river.py index c00575901..56de3b8cf 100644 --- a/h2integrate/converters/water_power/hydro_plant_run_of_river.py +++ b/h2integrate/converters/water_power/hydro_plant_run_of_river.py @@ -40,6 +40,7 @@ class RunOfRiverHydroPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "flexible" def initialize(self): super().initialize() @@ -85,6 +86,10 @@ def compute(self, inputs, outputs): max_production = plant_capacity_kw * self.n_timesteps * (self.dt / 3600) outputs["capacity_factor"] = outputs["total_electricity_produced"].sum() / max_production + # Honor a system-level controller's set-point by curtailing + # `electricity_out`. No-op when there is no system-level controller. + self.apply_curtailment(outputs) + @define(kw_only=True) class RunOfRiverHydroCostConfig(CostModelBaseConfig): diff --git a/h2integrate/converters/water_power/tidal_pysam.py b/h2integrate/converters/water_power/tidal_pysam.py index f0e961175..41ef504d4 100644 --- a/h2integrate/converters/water_power/tidal_pysam.py +++ b/h2integrate/converters/water_power/tidal_pysam.py @@ -124,6 +124,7 @@ class PySAMTidalPerformanceModel(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "flexible" def initialize(self): super().initialize() @@ -228,3 +229,7 @@ def compute(self, inputs, outputs): outputs["capacity_factor"] = ( self.system_model.Outputs.capacity_factor / 100 ) # divide by 100 to make it unitless + + # Honor a system-level controller's set-point by curtailing + # `electricity_out`. No-op when there is no system-level controller. + self.apply_curtailment(outputs) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 02c26ef1e..90d66b2d0 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -287,6 +287,9 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): 1 / self.fraction_of_year_simulated ) + # Apply curtailment based on set_point + self.apply_curtailment(outputs) + # 3. Cache the results for future use if enabled self.cache_outputs( inputs, outputs, discrete_inputs, discrete_outputs={}, config_dict=config_dict diff --git a/h2integrate/converters/wind/wind_plant_ard.py b/h2integrate/converters/wind/wind_plant_ard.py index fedb9ab33..3b05d10f9 100644 --- a/h2integrate/converters/wind/wind_plant_ard.py +++ b/h2integrate/converters/wind/wind_plant_ard.py @@ -38,6 +38,7 @@ class WindArdPerformanceCompatibilityComponent(PerformanceModelBaseClass): """ _time_step_bounds = (3600, 3600) # (min, max) time step lengths compatible with this model + _control_classifier = "flexible" def initialize(self): super().initialize() @@ -85,6 +86,10 @@ def compute(self, inputs, outputs): outputs["rated_electricity_production"] = self.plant_rating_kw outputs["capacity_factor"] = aep / self.plant_capacity + # Honor a system-level controller's set-point by curtailing + # `electricity_out`. No-op when there is no system-level controller. + self.apply_curtailment(outputs) + class WindArdCostCompatibilityComponent(CostModelBaseClass): """The class is needed to allow connecting the Ard cost_year easily in H2Integrate. @@ -148,12 +153,17 @@ class ArdWindPlantModel(om.Group): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "flexible" def initialize(self): self.options.declare("driver_config", types=dict) self.options.declare("plant_config", types=dict) self.options.declare("tech_config", types=dict) + self.commodity = "electricity" + self.commodity_rate_units = "kW" + self.commodity_amount_units = "kW*h" + if set_up_ard_model is None: msg = ( "Please install `ard-nrel` or `h2integrate[ard]` to use the" diff --git a/h2integrate/converters/wind/wind_plant_baseclass.py b/h2integrate/converters/wind/wind_plant_baseclass.py index 6e4de0bd2..08e8907b4 100644 --- a/h2integrate/converters/wind/wind_plant_baseclass.py +++ b/h2integrate/converters/wind/wind_plant_baseclass.py @@ -6,6 +6,7 @@ class WindPerformanceBaseClass(PerformanceModelBaseClass): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "flexible" def initialize(self): super().initialize() diff --git a/h2integrate/converters/wind/wind_pysam.py b/h2integrate/converters/wind/wind_pysam.py index 46dd84a2e..310657a1a 100644 --- a/h2integrate/converters/wind/wind_pysam.py +++ b/h2integrate/converters/wind/wind_pysam.py @@ -478,6 +478,9 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): ) outputs["capacity_factor"] = outputs["total_electricity_produced"] / max_production + # Apply curtailment based on set_point + self.apply_curtailment(outputs) + def post_process(self, show_plots=False): def plot_turbine_points( ax: plt.Axes = None, diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 65cd0c4ee..43865c5bd 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -18,6 +18,10 @@ multivariable_streams, is_electricity_producer, ) +from h2integrate.control.control_strategies.passthrough_controller import PassthroughController +from h2integrate.control.control_strategies.system_level.solver_options import ( + SLCSolverOptionsConfig, +) try: @@ -38,6 +42,11 @@ def __init__(self, config_input): # read in config file; it's a yaml dict that looks like this: self.load_config(config_input) + # add bool for whether using system-level control + self.slc = False + if "system_level_control" in self.plant_config: + self.slc = True + # create technology connection graph based on technology interconnections # defined in plant config self.create_technology_graph() @@ -72,6 +81,11 @@ def __init__(self, config_input): self.create_finance_model() + # add system-level controller if configured + if self.slc: + slc_config = self._classify_slc_technologies() + self.add_system_level_controller(slc_config) + # connect technologies # technologies are connected within the `technology_interconnections` section of the # plant config @@ -462,6 +476,355 @@ def create_plant_model(self): # Create the plant model group and add components self.plant = self.model.add_subsystem("plant", plant_group, promotes=["*"]) + def _classify_slc_technologies(self): + """Classify technologies for system-level control. + + Uses ``self.tech_control_classifiers`` (populated by ``create_technology_models()``) + to partition technologies into fixed, flexible, dispatchable, and storage lists. + Also identifies the single demand technology and its commodity. + + SLC demand is supplied by a demand component (for example, + ``GenericDemandComponent``). When SLC is enabled, only one demand + component is currently supported. + + Returns: + dict: Classification dictionary (``slc_config``) with keys: + + - ``"demand_tech"`` (str): Name of the demand technology (the tech whose + performance model is a ``DemandComponent``). + - ``"demand_commodity"`` (str): Commodity the demand technology consumes + (e.g. ``"electricity"``, ``"hydrogen"``). + - ``"demand_commodity_rate_units"`` (str | None): Units string for the + demand commodity rate (e.g. ``"kW"``, ``"kg/h"``), or ``None`` if not + specified in the demand tech config. + - ``"tech_to_commodity"`` (set[tuple[str, str]]): Set of + ``(tech_name, commodity)`` pairs for every technology that the SLC + controls or reads from. Built from outgoing edges of the technology + graph and filtered to fixed, flexible, dispatchable, storage, and + feedstock classifiers. + - ``"technology_graph"`` (nx.DiGraph): Directed graph of technology + interconnections, with edge attribute ``commodity`` indicating the + commodity carried on each edge. Used by cost-aware controllers to + trace upstream feedstocks. + - ``"tech_control_classifiers"`` (dict[str, str]): Mapping of tech name + to its ``_control_classifier`` (one of ``"fixed"``, ``"flexible"``, + ``"dispatchable"``, ``"storage"``, ``"feedstock"``). Determines how + the SLC interacts with each tech. + """ + slc_config = {} + technologies = self.technology_config.get("technologies", {}) + + # Identify the (single) demand technology + demand_tech = None + demand_commodity = None + demand_commodity_rate_units = None + for tech_name, tech_def in technologies.items(): + model_name = tech_def.get("performance_model", {}).get("model", "") + if "DemandComponent" not in model_name: + continue + + model_inputs = tech_def.get("model_inputs", {}) + perf_params = model_inputs.get("performance_parameters", {}) + shared_params = model_inputs.get("shared_parameters", {}) + all_params = {**shared_params, **perf_params} + + if demand_commodity is not None: + # NOTE: this error should only be raised if two demand components + # are in the tech connections + raise ValueError( + "System-level control currently supports only one demand " + "component, but multiple demand components were found " + f"for '{demand_commodity}' and " + f"'{all_params.get('commodity', tech_name)}'." + ) + + demand_commodity = all_params["commodity"] + demand_commodity_rate_units = all_params.get("commodity_rate_units", None) + demand_tech = tech_name + # Check that the demand tech is in the technology_interconnections + tech_interconnections = self.plant_config["technology_interconnections"] + demand_is_source_connection = [ + tech_connection + for tech_connection in tech_interconnections + if tech_connection[0] == demand_tech + ] + demand_is_destination_connection = [ + tech_connection + for tech_connection in tech_interconnections + if tech_connection[1] == demand_tech + ] + if len(demand_is_source_connection) == 0 and len(demand_is_destination_connection) == 0: + # demand is not in tech interconnections + demand_tech = None + demand_commodity = None + + demand_commodity_rate_units = None + + # Raise error if no demand commodity was defined + if demand_tech is None: + msg = ( + "No demand commodity was found in the technology interconnections. " + "Please define a demand component." + ) + raise ValueError(msg) + + # Classify technologies based on their output commodity (or commodities) + # Use a set to remove duplicates (in case one tech produces multiple commodities) + sources_to_commodities = { + (e[0], e[-1]) + for e in self.technology_graph.edges(data="commodity") + if e[-1] is not None + } + + # Check if storage models have a controller + storage_tech_to_control = {} + for tech, classifier in self.tech_control_classifiers.items(): + if classifier == "storage": + control_model = ( + self.technology_config["technologies"][tech] + .get("control_strategy", {}) + .get("model", None) + ) + if control_model is None: + storage_tech_to_control[tech] = False + else: + # storage model does use a controller + storage_tech_to_control[tech] = True + + # Remove feedstocks and connectors + control_classifiers_to_connect = [ + "fixed", + "flexible", + "dispatchable", + "storage", + "feedstock", + ] + tech_to_commodity = { + (e[0], e[-1]) + for e in sources_to_commodities + if self.tech_control_classifiers[e[0]] in control_classifiers_to_connect + } + + # Store classification results in plant_config for SLC component + slc_config["demand_tech"] = demand_tech + slc_config["demand_commodity"] = demand_commodity + slc_config["demand_commodity_rate_units"] = demand_commodity_rate_units + slc_config["tech_to_commodity"] = tech_to_commodity + slc_config["storage_techs_to_control"] = storage_tech_to_control + slc_config["technology_graph"] = self.technology_graph + + slc_config["tech_control_classifiers"] = self.tech_control_classifiers + + return slc_config + + def add_system_level_controller(self, slc_config): + """Add a system-level controller component and connect it within the plant. + + Instantiates the controller specified by ``control_strategy`` in the plant configuration, + adds it as an OpenMDAO subsystem named ``"system_level_controller"``, configures + solvers on the plant group to resolve the feedback loop, and creates all + necessary OpenMDAO connections between the controller and the technology models it + dispatches. + + The method executes in five sequential steps: + + 1. **Select and instantiate the controller** - Looks up the class from + ``supported_models`` using the ``control_strategy`` string (e.g. + ``"DemandFollowingControl"``, ``"ProfitMaximizationControl"``). Raises ``ValueError`` + if the strategy name is not found. The instantiated component is added to + ``self.plant`` as ``"system_level_controller"``. + + 2. **Configure the plant-level nonlinear solver** - Because the controller creates a + feedback loop (controller outputs become technology inputs, whose outputs feed back to + the controller), a nonlinear solver is required. Solver type and options are read from + ``plant_config["system_level_control"]["solver_options"]`` via + ``SLCSolverOptionsConfig``. A ``DirectSolver`` is set as the linear solver and + is largely inconsequential as we're not propagating derivatives at this time. + + 3. **Connect technology outputs to controller inputs** - For each ``(tech_name, + commodity)`` pair in ``slc_config["tech_to_commodity"]``: + + - **Feedstock techs**: Only the commodity output + (``{tech_name}_source.{commodity}_out``) is connected to the controller. Feedstocks + have no demand-input connection. + - **Fixed techs**: Only the commodity output + (``{tech_name}.{commodity}_out``) is connected to the controller. Fixed techs + always produce and receive no demand-input connection. + - **Flexible / dispatchable / storage techs**: Both the commodity output + (``{tech_name}.{commodity}_out``) and rated production + (``{tech_name}.rated_{commodity}_production``) are connected as controller inputs. + The controller's per-tech ``{tech_name}_{commodity}_set_point`` output is then + connected to the tech group's ``{commodity}_set_point`` input. Every controlled + tech group is expected to expose this input — either via a user-defined + ``control_strategy`` or via the auto-injected ``PassthroughController`` — which + converts the set-point signal into the appropriate performance-model command value. + + 4. **Connect marginal-cost inputs for cost-aware strategies** - Only executed when + ``control_strategy`` is ``"CostMinimizationControl"`` or + ``"ProfitMaximizationControl"``. Additional cost-aware control strategies + would need to be added here. For each dispatchable tech, the ``cost_per_tech`` + specification determines which cost signal is connected: + + - ``"VarOpEx"``: connects the tech's own ``VarOpEx`` output. + - ``"feedstock"``: uses graph traversal (``nx.ancestors``) on the + ``technology_graph`` to find all upstream feedstock technologies + 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()``. + - Numeric scalar: no connection needed; the value is used directly as a constant + marginal cost. + + 5. **Connect the demand profile** - Connects the demand technology's output + (``{demand_tech}.{demand_commodity}_demand_out``) to the controller's demand input + (``system_level_controller.{demand_commodity}_demand``). This relies on the + current SLC constraint that exactly one demand component is defined. + + Args: + slc_config (dict): Pre-computed dictionary produced by + ``_classify_slc_technologies()``. Expected keys: + + - ``"demand_tech"`` (str): Name of the demand technology. + - ``"demand_commodity"`` (str): Commodity the demand consumes. + - ``"tech_to_commodity"`` (set[tuple[str, str]]): Set of ``(tech_name, + commodity)`` pairs for all controlled techs. + - ``"tech_control_classifiers"`` (dict[str, str]): Mapping of tech name to + classifier (``"fixed"``, ``"flexible"``, ``"dispatchable"``, ``"storage"``, + ``"feedstock"``). + - ``"storage_techs_to_control"`` (dict[str, bool]): Whether each storage tech + has its own sub-controller. + - ``"technology_graph"`` (nx.DiGraph): Directed graph of technology + interconnections. + + Raises: + ValueError: If ``control_strategy`` is not found in ``self.supported_models``. + + Side Effects: + - Adds ``"system_level_controller"`` subsystem to ``self.plant``. + - Sets ``self.plant.nonlinear_solver`` and ``self.plant.linear_solver``. + - Creates OpenMDAO connections within ``self.plant``. + """ + plant_slc_config = self.plant_config["system_level_control"] + + # --- Step 1: Select and instantiate the controller class ---------- + strategy_name = plant_slc_config.get("control_strategy") + slc_cls = self.supported_models.get(strategy_name) + if slc_cls is None: + raise ValueError( + f"Unknown control_strategy '{strategy_name}' in system_level_control. " + f"Must be a valid model name in supported_models." + ) + + slc_comp = slc_cls( + driver_config=self.driver_config, + plant_config=self.plant_config, + tech_config=self.technology_config, + slc_config=slc_config, + ) + self.plant.add_subsystem("system_level_controller", slc_comp) + + # --- Step 2: Configure the nonlinear solver on the plant group ---- + # The feedback loop (controller <-> technologies) requires an + # iterative nonlinear solver to converge. + solver_config = SLCSolverOptionsConfig.from_dict(plant_slc_config.get("solver_options", {})) + solver_cls = solver_config.return_nonlinear_solver() + solver = solver_cls() + solver_options = solver_config.get_solver_options() + for k, v in solver_options.items(): + solver.options[k] = v + self.plant.nonlinear_solver = solver + self.plant.linear_solver = om.DirectSolver() + + # --- Step 3: Connect technology outputs/inputs to the controller -- + for tech_to_commodity in slc_config["tech_to_commodity"]: + tech_name, commodity = tech_to_commodity + + if slc_config["tech_control_classifiers"][tech_name] == "feedstock": + # Feedstocks only provide their commodity output to the + # controller; they receive no set-point back. + self.plant.connect( + f"{tech_name}_source.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) + continue + + if slc_config["tech_control_classifiers"][tech_name] == "fixed": + # Fixed techs only provide their commodity output to the + # controller; they always produce and receive no set-point. + self.plant.connect( + f"{tech_name}.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) + continue + + # Flexible, dispatchable, and storage techs: connect their + # commodity output and rated production as controller inputs. + self.plant.connect( + f"{tech_name}.{commodity}_out", + f"system_level_controller.{tech_name}_{commodity}_out", + ) + + self.plant.connect( + f"{tech_name}.rated_{commodity}_production", + f"system_level_controller.{tech_name}_rated_{commodity}_production", + ) + + # Storage tech: connect the storage duration as a controller input + if slc_config["tech_control_classifiers"][tech_name] == "storage": + self.plant.connect( + f"{tech_name}.storage_duration", + f"system_level_controller.{tech_name}_{commodity}_storage_duration", + ) + + # Every controlled tech group exposes a ``{commodity}_set_point`` + # input (provided by either a user-defined control_strategy or an + # auto-injected PassthroughController). Route the SLC's per-tech + # set-point output to that input. + self.plant.connect( + f"system_level_controller.{tech_name}_{commodity}_set_point", + f"{tech_name}.{commodity}_set_point", + ) + + # --- Step 4: Connect marginal-cost inputs (cost-aware strategies) - + if strategy_name in ("CostMinimizationControl", "ProfitMaximizationControl"): + cost_per_tech = plant_slc_config.get("control_parameters", {}).get("cost_per_tech", {}) + technology_graph = slc_config["technology_graph"] + for tech_name, _ in slc_config["tech_to_commodity"]: + if self.tech_control_classifiers[tech_name] == "dispatchable": + cost_spec = cost_per_tech.get(tech_name, 0.0) + if cost_spec == "VarOpEx": + # Tech's own variable operating expenditure + self.plant.connect( + f"{tech_name}.VarOpEx", + f"system_level_controller.{tech_name}_VarOpEx", + ) + elif cost_spec == "feedstock": + # Find all upstream feedstock technologies using + # graph traversal (matches _find_feedstock_techs + # in the SLC component). + ancestors = nx.ancestors(technology_graph, tech_name) + feedstock_names = [ + t + for t in ancestors + if self.tech_control_classifiers.get(t) == "feedstock" + ] + for feedstock_name in feedstock_names: + self.plant.connect( + f"{feedstock_name}.VarOpEx", + f"system_level_controller.{feedstock_name}_VarOpEx", + ) + # "buy_price": default from tech config, overridable via set_val + # numeric scalar: used directly, no connection needed + + # --- Step 5: Connect the demand profile to the controller --------- + demand_tech = slc_config["demand_tech"] + demand_commodity = slc_config["demand_commodity"] + self.plant.connect( + f"{demand_tech}.{demand_commodity}_demand_out", + f"system_level_controller.{demand_commodity}_demand", + ) + def create_technology_models(self): # Loop through each technology and instantiate an OpenMDAO object (assume it exists) # for each technology @@ -483,6 +846,7 @@ def create_technology_models(self): self.dispatch_rule_sets = [] self.cost_models = [] self.finance_models = [] + self.tech_control_classifiers = {} # for system-level control combined_performance_and_cost_models = [ "HOPPComponent", @@ -540,6 +904,7 @@ def create_technology_models(self): tech_config=individual_tech_config, ) self._check_time_step(perf_model, comp) + self.tech_control_classifiers.update({tech_name: "feedstock"}) self.plant.add_subsystem(f"{tech_name}_source", comp) else: tech_group = self.plant.add_subsystem(tech_name, om.Group()) @@ -575,12 +940,17 @@ def create_technology_models(self): plant_config=self.plant_config, tech_config=individual_tech_config, ) + + self._check_control_classifier(perf_model, comp) + self.tech_control_classifiers.update({tech_name: comp._control_classifier}) self._check_time_step(perf_model, comp) om_model_object = tech_group.add_subsystem(perf_model, comp, promotes=["*"]) self.performance_models.append(om_model_object) self.cost_models.append(om_model_object) self.finance_models.append(om_model_object) + self._add_passthrough_controller(tech_group, comp, individual_tech_config) + continue # Process the models @@ -592,6 +962,7 @@ def create_technology_models(self): "cost_model", ] + perf_om_object = None for model_type in model_types: if model_type in individual_tech_config: om_model_object = self._process_model( @@ -603,6 +974,22 @@ def create_technology_models(self): plural_model_type_name = model_type + "s" getattr(self, plural_model_type_name).append(om_model_object) + if model_type == "performance_model": + perf_om_object = om_model_object + + # Collect control classifier for system-level control + if model_type == "performance_model" and self.slc: + perf_cls = self.supported_models.get(perf_model) + if perf_cls is not None: + classifier = getattr(perf_cls, "_control_classifier", None) + if classifier is not None: + self.tech_control_classifiers[tech_name] = classifier + + if perf_om_object is not None: + self._add_passthrough_controller( + tech_group, perf_om_object, individual_tech_config + ) + # Process the finance models if "finance_model" in individual_tech_config: if "model" in individual_tech_config["finance_model"]: @@ -668,6 +1055,88 @@ def _check_time_step(self, model_name, model_object): ) raise ValueError(msg) + def _check_control_classifier(self, model_name, model_object): + if not self.slc: + return + if not hasattr(model_object, "_control_classifier"): + msg = f"Model {model_name} is missing a control classifier" + raise ValueError(msg) + + def _add_passthrough_controller(self, tech_group, perf_comp, individual_tech_config): + """Automatically add a PassthroughController to a tech group if appropriate. + + A controller is auto-inserted only when: + - the technology has no user-defined ``control_strategy`` in its config, + - the performance model exposes a ``_control_classifier`` of + ``"flexible"``, ``"dispatchable"``, or ``"storage"``, + - the performance model has set ``commodity`` and ``commodity_rate_units`` + attributes (typically set in its ``initialize()``), or those values + can be read from the individual tech config. + + The controller's ``{commodity}_set_point`` input becomes the tech group's + external set-point-input promoted at the tech group level, and its + ``{commodity}_command_value`` output is auto-connected (via promotion) to the + performance model's ``{commodity}_command_value`` input if one exists. + """ + # Skip if the user has already specified a control strategy for this tech; + # their explicit choice takes precedence over the auto-injected passthrough. + if "control_strategy" in individual_tech_config: + return + + # Only flexible/dispatchable/storage techs accept an externally + # provided demand signal. Fixed, feedstock, and connector techs are + # handled elsewhere (fixed/feedstock have no demand input) and must + # not get a passthrough. + classifier = getattr(perf_comp, "_control_classifier", None) + if classifier not in ("flexible", "dispatchable", "storage"): + return + + # The performance model must declare the commodity it produces and the + # units of its set-point so the PassthroughController can size its I/O + # consistently. If they aren't yet set on the component (some models + # only assign these in ``setup()``), fall back to reading them from the + # individual tech config's model_inputs. + commodity = getattr(perf_comp, "commodity", None) + commodity_rate_units = getattr(perf_comp, "commodity_rate_units", None) + if commodity is None or commodity_rate_units is None: + model_inputs = individual_tech_config.get("model_inputs", {}) + shared = model_inputs.get("shared_parameters", {}) + perf_inputs = model_inputs.get("performance", {}) + if commodity is None: + commodity = perf_inputs.get("commodity", shared.get("commodity")) + if commodity_rate_units is None: + commodity_rate_units = perf_inputs.get( + "commodity_rate_units", shared.get("commodity_rate_units") + ) + if commodity is None or commodity_rate_units is None: + return + + # Build the controller sized to the plant's simulation horizon so its + # vector I/O matches the performance model's time-series I/O. + n_timesteps = int(self.plant_config["plant"]["simulation"]["n_timesteps"]) + controller = PassthroughController( + commodity=commodity, + n_timesteps=n_timesteps, + commodity_rate_units=commodity_rate_units, + ) + + # Promote all controller variables so: + # - `{commodity}_set_point` becomes the tech group's external input + # (this is what the system-level controller connects to), and + # - `{commodity}_command_value` is auto-connected by name to the + # performance model's matching input via promotion. + om_controller = tech_group.add_subsystem("controller", controller, promotes=["*"]) + self.control_strategies.append(om_controller) + + # Ensure the controller runs before the performance/cost models that + # consume its command_value output. Subsystem creation order otherwise + # places the controller last in the group's execution order, which + # would delay the command_value by one solver iteration. + existing_order = list(tech_group._static_subsystems_allprocs.keys()) + if "controller" in existing_order: + new_order = ["controller"] + [n for n in existing_order if n != "controller"] + tech_group.set_order(new_order) + def create_finance_model(self): """ Create and configure the finance model(s) for the plant. @@ -1454,7 +1923,7 @@ def run(self): # do model setup based on the driver config # might add a recorder, driver, set solver tolerances, etc if self.state < State.SETUP: - self.prob.setup() + self.setup() if self.state < State.RUN: # OpenMDAO will skip this step if it encounters an issue leading to silent failures @@ -1750,7 +2219,7 @@ def _check_tech_connections(self): if group is None: continue - io_params.update(group.get_io_metadata().keys()) + io_params.update([key.split(".")[-1] for key in group.get_io_metadata().keys()]) tech_io[tech_name] = io_params diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index c6adf5cd6..183dab9d1 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -3,6 +3,7 @@ from pathlib import Path import dill +import numpy as np import openmdao.api as om from attrs import field, define @@ -98,6 +99,47 @@ def setup(self): # operational life of the technology if the technology cannot be replaced self.add_output("operational_life", val=self.plant_life, units="yr") + # Flexible models get additional I/O for command-value-based curtailment + if getattr(self, "_control_classifier", None) == "flexible": + self.add_input( + f"{self.commodity}_command_value", + val=1.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Command value for {self.commodity} production (curtailment limit)", + ) + self.add_output( + f"uncurtailed_{self.commodity}_out", + val=1.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Full (uncurtailed) {self.commodity} output", + ) + + def apply_curtailment(self, outputs): + """Apply curtailment to ``{commodity}_out`` based on ``{commodity}_command_value``. + + Copies the current ``{commodity}_out`` into ``uncurtailed_{commodity}_out``, + then clips ``{commodity}_out`` to ``min(uncurtailed, command_value)`` element-wise. + + Only operates when the model has ``_control_classifier == "flexible"``. + Should be called at the end of each flexible model's ``compute()`` method + after the raw production has been written to ``outputs[f"{commodity}_out"]``. + """ + if "system_level_control" in self.options["plant_config"]: + if getattr(self, "_control_classifier", None) != "flexible": + return + + commodity_out_key = f"{self.commodity}_out" + uncurtailed_key = f"uncurtailed_{self.commodity}_out" + command_value_key = f"{self.commodity}_command_value" + + uncurtailed = np.array(outputs[commodity_out_key]) + outputs[uncurtailed_key] = uncurtailed + + command_value = self._inputs[command_value_key] + outputs[commodity_out_key] = np.minimum(uncurtailed, command_value) + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ Computation for the OM component. @@ -151,6 +193,18 @@ def setup(self): "cost_year", val=self.config.cost_year, desc="Dollar year for costs" ) + # Marginal cost output for dispatch decisions + model_inputs = self.options["tech_config"].get("model_inputs", {}) + shared = model_inputs.get("shared_parameters", {}) + commodity_rate_units = shared.get("commodity_rate_units", "kW") + + self.add_output( + "marginal_cost", + val=getattr(self.config, "marginal_cost", 0.0), + units=f"USD/({commodity_rate_units}*h)", + desc="Marginal cost of production for dispatch decisions", + ) + # dt is seconds per timestep self.dt = int(self.options["plant_config"]["plant"]["simulation"]["dt"]) diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index a22ae427e..f9e43ce89 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -181,6 +181,10 @@ def copy(self): "SimpleGasConsumerPerformance": "converters.natural_gas:SimpleGasConsumerPerformance", "SimpleGasConsumerCost": "converters.natural_gas:SimpleGasConsumerCost", "GasStreamCombinerPerformanceModel": "transporters:GasStreamCombinerPerformanceModel", + # System-level control strategies + "DemandFollowingControl": "control.control_strategies.system_level.demand_following_control:DemandFollowingControl", + "CostMinimizationControl": "control.control_strategies.system_level.cost_minimization_control:CostMinimizationControl", + "ProfitMaximizationControl": "control.control_strategies.system_level.profit_maximization_control:ProfitMaximizationControl", } ) diff --git a/h2integrate/core/test/test_framework.py b/h2integrate/core/test/test_framework.py index 85f3ab748..1dd348bd5 100644 --- a/h2integrate/core/test/test_framework.py +++ b/h2integrate/core/test/test_framework.py @@ -455,7 +455,7 @@ def test_technology_connections(temp_dir): h2i_model = H2IntegrateModel(temp_highlevel_yaml) demand_profile = np.ones(8760) * 720.0 h2i_model.setup() - h2i_model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + h2i_model.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") h2i_model.run() diff --git a/h2integrate/demand/demand_base.py b/h2integrate/demand/demand_base.py index 09072594f..9417bdf83 100644 --- a/h2integrate/demand/demand_base.py +++ b/h2integrate/demand/demand_base.py @@ -94,6 +94,14 @@ def setup(self): desc=f"Excess production of {self.commodity}", ) + self.add_output( + f"{self.commodity}_demand_out", + val=self.config.demand_profile, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Pass-through of {self.commodity} demand profile", + ) + def compute(): """This method must be implemented by subclasses to define the demand component. @@ -125,6 +133,8 @@ def calculate_outputs(self, commodity_in, commodity_demand, outputs): array shape ``(n_timesteps,)``. """ + outputs[f"{self.commodity}_demand_out"] = commodity_demand + remaining_demand = commodity_demand - commodity_in # Calculate missed load and curtailed production diff --git a/h2integrate/finances/profast_lco.py b/h2integrate/finances/profast_lco.py index 566b393d3..ab353ffe3 100644 --- a/h2integrate/finances/profast_lco.py +++ b/h2integrate/finances/profast_lco.py @@ -1,3 +1,4 @@ +import warnings from pathlib import Path import numpy as np @@ -106,6 +107,18 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ pf = self.populate_profast(inputs) + if "system_level_control" in self.options["plant_config"] and np.all( + inputs["capacity_factor"] == 0.0 + ): + outputs[self.LCO_str] = 1e12 + msg = ( + f"Commodity stream for finance group has a zero capacity factor. " + "If you recieve this warning multiple times, there may be a problem " + "with your setup. ProFAST is not being run on this iteration and the " + f"{self.LCO_str} is being set to default value of 1e12 ({self.price_units})" + ) + warnings.warn(msg, UserWarning) + return # simulate ProFAST sol, summary, price_breakdown = run_profast(pf) diff --git a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py index c78e7039e..318f2351d 100644 --- a/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py +++ b/h2integrate/postprocess/test/test_sql_timeseries_to_csv.py @@ -42,7 +42,7 @@ def run_example_02_sql_fpath(configuration): # Set the battery demand profile demand_profile = np.ones(8760) * 640.0 h2i.setup() - h2i.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + h2i.prob.set_val("battery.electricity_set_point", demand_profile, units="MW") # Run the model h2i.run() @@ -59,7 +59,7 @@ def test_save_csv_all_results(subtests, configuration, run_example_02_sql_fpath) res = save_case_timeseries_as_csv(run_example_02_sql_fpath, save_to_file=True) with subtests.test("Check number of columns"): - assert len(res.columns.to_list()) == 51 + assert len(res.columns.to_list()) == 61 with subtests.test("Check number of rows"): assert len(res) == 8760 diff --git a/h2integrate/storage/battery/test/test_pysam_battery.py b/h2integrate/storage/battery/test/test_pysam_battery.py index aecaedb8c..e6334f28e 100644 --- a/h2integrate/storage/battery/test/test_pysam_battery.py +++ b/h2integrate/storage/battery/test/test_pysam_battery.py @@ -57,14 +57,14 @@ def test_pysam_battery_performance_model_without_controller(plant_config, subtes prob.model.add_subsystem( name="IVC3", - subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"), + subsys=om.IndepVarComp(name="electricity_set_point", val=electricity_demand, units="kW"), promotes=["*"], ) prob.model.add_subsystem( name="IVC4", subsys=om.IndepVarComp( - name="electricity_set_point", val=electricity_demand - electricity_in, units="kW" + name="electricity_command_value", val=electricity_demand - electricity_in, units="kW" ), promotes=["*"], ) @@ -338,7 +338,7 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): prob_init = om.Problem() prob_init.model.add_subsystem( name="IVC1", - subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"), + subsys=om.IndepVarComp(name="electricity_set_point", val=electricity_demand, units="kW"), promotes=["*"], ) @@ -351,7 +351,7 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): prob_init.model.add_subsystem( name="IVC3", subsys=om.IndepVarComp( - name="electricity_set_point", val=electricity_demand - electricity_in, units="kW" + name="electricity_command_value", val=electricity_demand - electricity_in, units="kW" ), promotes=["*"], ) @@ -406,7 +406,7 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): prob = om.Problem() prob.model.add_subsystem( name="IVC1", - subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"), + subsys=om.IndepVarComp(name="electricity_set_point", val=electricity_demand, units="kW"), promotes=["*"], ) @@ -419,7 +419,7 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests): prob.model.add_subsystem( name="IVC3", subsys=om.IndepVarComp( - name="electricity_set_point", val=electricity_demand - electricity_in, units="kW" + name="electricity_command_value", val=electricity_demand - electricity_in, units="kW" ), promotes=["*"], ) diff --git a/h2integrate/storage/hydrogen/h2_storage_cost.py b/h2integrate/storage/hydrogen/h2_storage_cost.py index bbc29c796..9868797b3 100644 --- a/h2integrate/storage/hydrogen/h2_storage_cost.py +++ b/h2integrate/storage/hydrogen/h2_storage_cost.py @@ -51,6 +51,7 @@ class HydrogenStorageBaseCostModelConfig(BaseConfig): storage_pressure_bar: float = field(default=200, validator=range_val(0, 700)) cg_capex_per_kg_350_bar: float = field(default=1333.11625, validator=gte_zero) cg_capex_per_kg_700_bar: float = field(default=1999.67437, validator=gte_zero) + marginal_cost: float = field(default=0.0) def __attrs_post_init__(self): undefined_capacities = self.max_capacity is None or self.max_charge_rate is None diff --git a/h2integrate/storage/hydrogen/mch_storage.py b/h2integrate/storage/hydrogen/mch_storage.py index 4888519c4..7d6ae90af 100644 --- a/h2integrate/storage/hydrogen/mch_storage.py +++ b/h2integrate/storage/hydrogen/mch_storage.py @@ -24,6 +24,7 @@ class MCHTOLStorageCostModelConfig(BaseConfig): commodity_units: str = field(default="kg/h", validator=contains(["kg/h", "g/h", "t/h"])) cost_year: int = field(default=2024, converter=int, validator=contains([2024])) + marginal_cost: float = field(default=0.0) def __attrs_post_init__(self): if self.charge_equals_discharge: diff --git a/h2integrate/storage/simple_storage_auto_sizing.py b/h2integrate/storage/simple_storage_auto_sizing.py index 9dc579990..785bf3b59 100644 --- a/h2integrate/storage/simple_storage_auto_sizing.py +++ b/h2integrate/storage/simple_storage_auto_sizing.py @@ -170,9 +170,9 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): 1) Calculate the max charge and discharge rate as the maximum of the `commodity_in` profile and oversize to account for charge/discharge efficiencies. 2) Estimate the storage SOC (in `commodity_amount_units`). The SOC increases when - charging and decreases when discharging. If `commodity_set_point` is input, + charging and decreases when discharging. If `commodity_command_value` is input, calculate the storage SOC as the cumulative summation of the negative of - `commodity_set_point` input (`commodity_set_point` input is + `commodity_command_value` input (`commodity_command_value` input is negative when charging and positive when discharging). Otherwise, calculate the storage SOC as the cumulative summation of `commodity_in - demand`. @@ -207,6 +207,8 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): commodity_demand = np.mean(inputs[f"{self.commodity}_in"]) * np.ones(self.n_timesteps) + elif self.using_feedback_control: + commodity_demand = inputs[f"{self.commodity}_set_point"] else: commodity_demand = inputs[f"{self.commodity}_demand"] @@ -219,11 +221,11 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): # Auto-size the storage capacity to meet the demand as much as possible # 2. Estimate the storage SOC in `commodity_amount_units` # NOTE: commodity_storage_soc is just an absolute value and is not a percentage. - if f"{self.commodity}_set_point" in inputs: - # `{self.commodity}_set_point` is negative when charging and positive when - # discharging, the negative of `{self.commodity}_set_point` can be used to + if f"{self.commodity}_command_value" in inputs: + # `{self.commodity}_command_value` is negative when charging and positive when + # discharging, the negative of `{self.commodity}_command_value` can be used to # estimate the SOC (which increases when charging and decreases when discharging) - commodity_storage_soc = np.cumsum(-1 * inputs[f"{self.commodity}_set_point"]) + commodity_storage_soc = np.cumsum(-1 * inputs[f"{self.commodity}_command_value"]) else: # estimate the SOC (which increases when charging and decreases when discharging) # based on the demand profile and the input commodity @@ -263,7 +265,7 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): # (such as demand profile, charge rate, and storage capacity) inputs_adjusted = dict(inputs.items()) if self.config.set_demand_as_avg_commodity_in: - inputs_adjusted[f"{self.commodity}_demand"] = commodity_demand + inputs_adjusted[f"{self.commodity}_set_point"] = commodity_demand if "pyomo_dispatch_solver" in discrete_inputs: inputs_adjusted["storage_capacity"] = np.array([rated_storage_capacity]) diff --git a/h2integrate/storage/storage_baseclass.py b/h2integrate/storage/storage_baseclass.py index 4e4d36c54..aa2e8e456 100644 --- a/h2integrate/storage/storage_baseclass.py +++ b/h2integrate/storage/storage_baseclass.py @@ -50,6 +50,7 @@ class StoragePerformanceBase(PerformanceModelBaseClass): 1, 36000, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "storage" def setup(self): """Set up the storage performance model in OpenMDAO. @@ -165,21 +166,21 @@ def setup(self): ]: if any(intended_dispatch_tech == name for name in self.tech_group_name): self.add_input( - f"{commodity}_demand", + f"{commodity}_set_point", val=self.config.demand_profile, shape=n_timesteps, units=commodity_rate_units, - desc=f"{commodity} demand profile", + desc=f"{commodity} set-point profile", ) self.add_discrete_input("pyomo_dispatch_solver", val=lambda: None) - # the controller gets demand from the storage model + # the controller gets set-point from the storage model # set the using feedback control variable to True using_feedback_control = True break if not using_feedback_control: # using an open-loop storage controller self.add_input( - f"{commodity}_set_point", + f"{commodity}_command_value", val=0.0, shape=n_timesteps, units=commodity_rate_units, @@ -273,7 +274,7 @@ def run_storage( else: storage_commodity_out, soc = self.simulate( - storage_dispatch_commands=inputs[f"{self.commodity}_set_point"], + storage_dispatch_commands=inputs[f"{self.commodity}_command_value"], charge_rate=charge_rate, discharge_rate=discharge_rate, storage_capacity=storage_capacity, diff --git a/h2integrate/storage/test/test_storage_auto_sizing.py b/h2integrate/storage/test/test_storage_auto_sizing.py index 3fa782f2c..077f0ee84 100644 --- a/h2integrate/storage/test/test_storage_auto_sizing.py +++ b/h2integrate/storage/test/test_storage_auto_sizing.py @@ -41,7 +41,7 @@ def test_storage_autosizing_basic_performance_no_losses(plant_config, subtests): prob.model.add_subsystem( name="IVC2", subsys=om.IndepVarComp( - name="hydrogen_set_point", val=commodity_demand - commodity_in, units="kg/h" + name="hydrogen_command_value", val=commodity_demand - commodity_in, units="kg/h" ), promotes=["*"], ) @@ -228,7 +228,7 @@ def test_storage_autosizing_soc_bounds(plant_config, subtests): prob.model.add_subsystem( name="IVC2", subsys=om.IndepVarComp( - name="hydrogen_set_point", val=commodity_demand - commodity_in, units="kg/h" + name="hydrogen_command_value", val=commodity_demand - commodity_in, units="kg/h" ), promotes=["*"], ) @@ -335,7 +335,7 @@ def test_storage_autosizing_losses(plant_config, subtests): prob.model.add_subsystem( name="IVC2", subsys=om.IndepVarComp( - name="hydrogen_set_point", val=commodity_demand - commodity_in, units="kg/h" + name="hydrogen_command_value", val=commodity_demand - commodity_in, units="kg/h" ), promotes=["*"], ) diff --git a/h2integrate/storage/test/test_storage_performance_model.py b/h2integrate/storage/test/test_storage_performance_model.py index 2ad903400..86b52925e 100644 --- a/h2integrate/storage/test/test_storage_performance_model.py +++ b/h2integrate/storage/test/test_storage_performance_model.py @@ -45,7 +45,7 @@ def test_generic_storage_with_simple_control_dmd_lessthan_charge_rate(plant_conf prob.model.add_subsystem( name="IVC2", - subsys=om.IndepVarComp(name="hydrogen_demand", val=commodity_demand, units="kg/h"), + subsys=om.IndepVarComp(name="hydrogen_set_point", val=commodity_demand, units="kg/h"), promotes=["*"], ) @@ -239,7 +239,7 @@ def test_generic_storage_with_simple_control_charge_rate_lessthan_demand(plant_c prob.model.add_subsystem( name="IVC2", - subsys=om.IndepVarComp(name="hydrogen_demand", val=commodity_demand, units="kg/h"), + subsys=om.IndepVarComp(name="hydrogen_set_point", val=commodity_demand, units="kg/h"), promotes=["*"], ) @@ -456,7 +456,7 @@ def test_generic_storage_with_simple_control_zero_size(plant_config, subtests): prob.model.add_subsystem( name="IVC2", - subsys=om.IndepVarComp(name="hydrogen_demand", val=commodity_demand, units="kg/h"), + subsys=om.IndepVarComp(name="hydrogen_set_point", val=commodity_demand, units="kg/h"), promotes=["*"], ) @@ -637,7 +637,7 @@ def test_generic_storage_with_simple_control_with_losses(plant_config, subtests) prob.model.add_subsystem( name="IVC2", - subsys=om.IndepVarComp(name="hydrogen_demand", val=commodity_demand, units="kg/h"), + subsys=om.IndepVarComp(name="hydrogen_set_point", val=commodity_demand, units="kg/h"), promotes=["*"], ) @@ -876,7 +876,7 @@ def test_generic_storage_with_simple_control_with_losses_round_trip(plant_config prob.model.add_subsystem( name="IVC2", - subsys=om.IndepVarComp(name="hydrogen_demand", val=commodity_demand, units="kg/h"), + subsys=om.IndepVarComp(name="hydrogen_set_point", val=commodity_demand, units="kg/h"), promotes=["*"], ) @@ -1080,13 +1080,13 @@ def test_generic_storage_charge_more_than_available(plant_config, subtests): prob.model.add_subsystem( name="IVC2", - subsys=om.IndepVarComp(name="hydrogen_demand", val=commodity_demand, units="kg/h"), + subsys=om.IndepVarComp(name="hydrogen_set_point", val=commodity_demand, units="kg/h"), promotes=["*"], ) prob.model.add_subsystem( name="IVC3", - subsys=om.IndepVarComp(name="hydrogen_set_point", val=nominal_set_point, units="kg/h"), + subsys=om.IndepVarComp(name="hydrogen_command_value", val=nominal_set_point, units="kg/h"), promotes=["*"], ) @@ -1275,7 +1275,7 @@ def test_storage_half_hourly_known_outputs(subtests, plant_config_non_hourly): ) prob.model.add_subsystem( "IVC2", - om.IndepVarComp("hydrogen_set_point", val=set_point, units="kg/h"), + om.IndepVarComp("hydrogen_command_value", val=set_point, units="kg/h"), promotes=["*"], ) prob.model.add_subsystem( @@ -1378,7 +1378,7 @@ def test_storage_half_hourly_known_outputs_kg_s(subtests, plant_config_non_hourl ) prob.model.add_subsystem( "IVC2", - om.IndepVarComp("hydrogen_set_point", val=set_point, units="kg/s"), + om.IndepVarComp("hydrogen_command_value", val=set_point, units="kg/s"), promotes=["*"], ) prob.model.add_subsystem( @@ -1468,7 +1468,7 @@ def test_storage_half_hourly_kw_kwh_2hr(subtests): ) prob.model.add_subsystem( "IVC2", - om.IndepVarComp("electricity_set_point", val=set_point, units="kW"), + om.IndepVarComp("electricity_command_value", val=set_point, units="kW"), promotes=["*"], ) prob.model.add_subsystem( diff --git a/h2integrate/transporters/generic_combiner.py b/h2integrate/transporters/generic_combiner.py index d9d11a2ef..c02ef56f4 100644 --- a/h2integrate/transporters/generic_combiner.py +++ b/h2integrate/transporters/generic_combiner.py @@ -43,6 +43,8 @@ class GenericCombinerPerformanceModel(om.ExplicitComponent): 1e9, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "connector" + def initialize(self): self.options.declare("driver_config", types=dict) self.options.declare("plant_config", types=dict) diff --git a/h2integrate/transporters/generic_splitter.py b/h2integrate/transporters/generic_splitter.py index 3f07a3df8..1573a83f5 100644 --- a/h2integrate/transporters/generic_splitter.py +++ b/h2integrate/transporters/generic_splitter.py @@ -68,6 +68,8 @@ class GenericSplitterPerformanceModel(om.ExplicitComponent): 1e9, ) # (min, max) time step lengths (in seconds) compatible with this model + _control_classifier = "connector" + def initialize(self): self.options.declare("driver_config", types=dict, default={}) self.options.declare("plant_config", types=dict, default={}) diff --git a/pyproject.toml b/pyproject.toml index 0237186c8..3346b8887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "matplotlib", "numpy", "numpy-financial", - "openmdao", + "openmdao>=3.44.0", "openmeteo-requests", "pandas>=2.0.3", "ProFAST",