diff --git a/CHANGELOG.md b/CHANGELOG.md index 11cf18f49..7c32c78c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,9 @@ data between functions in a module. [PR 590](https://github.com/NatLabRockies/H2Integrate/pull/590) - Adds `H2IntegrateModel.state` as an `IntEnum` to handle setup and run status checks. [PR 590](https://github.com/NatLabRockies/H2Integrate/pull/590) +- Reclassified open-loop converter control strategies as demand components and updated output naming convention to align with output naming convention in storage performance models [PR 631](https://github.com/NatLabRockies/H2Integrate/pull/631). + - The `FlexibleDemandOpenLoopConverterController` has been renamed to `FlexibleDemandComponent` + - The `DemandOpenLoopConverterController` has been renamed to `GenericDemandComponent` - Modified CI setup so Windows is temporarily disabled and also so unit, regression, and integration tests are run in separate jobs to speed up testing and provide more information on test failures. [PR 668](https://github.com/NatLabRockies/H2Integrate/pull/668) ## 0.7.2 [April 9, 2026] diff --git a/docs/_toc.yml b/docs/_toc.yml index 5a1a3dd5e..e14090382 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -69,6 +69,9 @@ parts: - file: control/open-loop_controllers - file: control/pyomo_controllers - file: control/controller_demonstrations + - caption: Demand + chapters: + - file: demand/demand_components - caption: Finance Models chapters: - file: finance_models/financial_analyses diff --git a/docs/control/control_overview.md b/docs/control/control_overview.md index 902e7462f..9c98af5e9 100644 --- a/docs/control/control_overview.md +++ b/docs/control/control_overview.md @@ -9,8 +9,7 @@ The first approach, [open-loop control](#open-loop-control), assumes no feedback Supported controllers: - [`SimpleStorageOpenLoopController`](#pass-through-controller) - [`DemandOpenLoopStorageController`](#demand-open-loop-storage-controller) -- [`DemandOpenLoopConverterController`](#demand-open-loop-converter-controller) -- [`FlexibleDemandOpenLoopConverterController`](#flexible-demand-open-loop-converter-controller) + (pyomo-control-framework)= diff --git a/docs/control/open-loop_controllers.md b/docs/control/open-loop_controllers.md index 61210eabf..dfb7f241c 100644 --- a/docs/control/open-loop_controllers.md +++ b/docs/control/open-loop_controllers.md @@ -26,93 +26,3 @@ An example of an N2 diagram for a system using the open-loop control framework f For examples of how to use the `DemandOpenLoopStorageController` open-loop control framework, see the following: - `examples/14_wind_hydrogen_dispatch/` - `examples/19_simple_dispatch/` - -## Open-Loop Converter Controllers - -Open-loop converter controllers define rule-based logic for meeting commodity demand profiles without using dynamic system feedback. These controllers operate independently at each timestep. - -This page documents two core controller types: -1. Demand Open-Loop Converter Controller — meets a fixed demand profile. -2. Flexible Demand Open-Loop Converter Controller — adjusts demand up or down within flexible bounds. - -(demand-open-loop-converter-controller)= -### Demand Open-Loop Converter Controller -The `DemandOpenLoopConverterController` allocates commodity input to meet a defined demand profile. It does not contain energy storage logic, only **instantaneous** matching of supply and demand. - -The controller computes each value per timestep: -- Unmet demand (non-zero when supply < demand, otherwise 0.) -- Unused commodity (non-zero when supply > demand, otherwise 0.) -- Delivered output (commodity supplied to demand sink) - -This provides a simple baseline for understanding supply–demand balance before adding complex controls. - -#### Configuration -The controller is defined within the `tech_config` and requires these inputs. - -| Field | Type | Description | -| ----------------- | -------------- | ------------------------------------- | -| `commodity_name` | `str` | Commodity name (e.g., `"hydrogen"`). | -| `commodity_units` | `str` | Units (e.g., `"kg/h"`). | -| `demand_profile` | scalar or list | Timeseries demand or constant demand. | - -```yaml -control_strategy: - model: DemandOpenLoopConverterController -model_inputs: - control_parameters: - commodity_name: hydrogen - commodity_units: kg/h - demand_profile: [10, 10, 12, 15, 14] -``` -For an example of how to use the `DemandOpenLoopConverterController` open-loop control framework, see the following: -- `examples/23_solar_wind_ng_demand` - -(flexible-demand-open-loop-converter-controller)= -### Flexible Demand Open-Loop Converter Controller -The `FlexibleDemandOpenLoopConverterController` extends the fixed-demand controller by allowing the actual demand to flex up or down within defined bounds. This is useful for demand-side management scenarios where: -- Processes can defer demand (e.g., flexible industrial loads) -- The system requires demand elasticity without dynamic optimization - -The controller computes: -- Flexible demand (clamped within allowable ranges) -- Unmet flexible demand -- Unused commodity -- Delivered output - -Everything remains open-loop no storage, no intertemporal coupling. - -For an example of how to use the `FlexibleDemandOpenLoopConverterController` open-loop control framework, see the following: -- `examples/23_solar_wind_ng_demand` - -The flexible demand component takes an input commodity production profile, the maximum demand profile, and various constraints (listed below), and creates a "flexible demand profile" that follows the original input commodity production profile while satisfying varying constraint. -Please see the figure below for an example of how the flexible demand profile can vary from the original demand profile based on the input commodity production profile and the ramp rates. -The axes are unlabeled to allow for generalization to any commodity and unit type. - -| ![Flexible Demand Example](figures/flex_demand_fig.png) | -|-| - - -#### Configuration -The flexible demand controller is defined within the `tech_config` with the following parameters: - -| Field | Type | Description | -| ------------------- | -------------- | -------------------------------------------- | -| `commodity_name` | `str` | Commodity name. | -| `commodity_units` | `str` | Units for all values. | -| `demand_profile` | scalar or list | Default (nominal) demand profile. | -| `turndown_ratio` | float | Minimum fraction of baseline demand allowed. | -| `ramp_down_rate_fraction` | float | Maximum ramp-down rate per timestep expressed as a fraction of baseline demand. | -| `ramp_up_rate_fraction` | float | Maximum ramp-up rate per timestep expressed as a fraction of baseline demand. | -| `min_utilization` | float | Minimum total fraction of baseline demand that must be met over the entire simulation. | - -```yaml -model_inputs: - control_parameters: - commodity_name: hydrogen - commodity_units: kg/h - demand_profile: [10, 12, 10, 8] - turndown_ratio: 0.1 - ramp_down_rate_fraction: 0.5 - ramp_up_rate_fraction: 0.5 - min_utilization: 0 -``` diff --git a/docs/demand/demand_components.md b/docs/demand/demand_components.md new file mode 100644 index 000000000..32e0e79ee --- /dev/null +++ b/docs/demand/demand_components.md @@ -0,0 +1,90 @@ +# Demand components + +- [`GenericDemandComponent`](#generic-demand-component) +- [`FlexibleDemandComponent`](#flexible-demand-component) + +Demand components define rule-based logic for meeting commodity demand profiles without using dynamic system feedback. These components operate independently at each timestep. + +This page documents two core demand types: +1. Generic Demand Component — meets a fixed demand profile. +2. Flexible Demand Component — adjusts demand up or down within flexible bounds. + +(generic-demand-component)= +### Generic Demand Component +The `GenericDemandComponent` allocates commodity input to meet a defined demand profile. It does not contain energy storage logic, only **instantaneous** matching of supply and demand. + +The demand component computes each value per timestep: +- Unmet demand (non-zero when supply < demand, otherwise 0.) +- Unused commodity (non-zero when supply > demand, otherwise 0.) +- Delivered output (commodity supplied to demand sink) + +This provides a simple baseline for understanding supply–demand balance. + +#### Configuration +The demand is defined within the `tech_config` and requires these inputs. + +| Field | Type | Description | +| ----------------- | -------------- | ------------------------------------- | +| `commodity_name` | `str` | Commodity name (e.g., `"hydrogen"`). | +| `commodity_units` | `str` | Units (e.g., `"kg/h"`). | +| `demand_profile` | scalar or list | Timeseries demand or constant demand. | + +```yaml +performance_model: + model: GenericDemandComponent +model_inputs: + performance_parameters: + commodity_name: hydrogen + commodity_units: kg/h + demand_profile: [10, 10, 12, 15, 14] +``` +For an example of how to use the `GenericDemandComponent` framework, see the following: +- `examples/23_solar_wind_ng_demand` + +(flexible-demand-component)= +### Flexible Demand Component +The `FlexibleDemandComponent` extends the generic demand component by allowing the actual demand to flex up or down within defined bounds. This is useful for demand-side management scenarios where: +- Processes can defer demand (e.g., flexible industrial loads) +- The system requires demand elasticity without dynamic optimization + +The component computes: +- Flexible demand (clamped within allowable ranges) +- Unmet flexible demand +- Unused commodity +- Delivered output + +For an example of how to use the `FlexibleDemandComponent` demand component, see the following: +- `examples/23_solar_wind_ng_demand` + +The flexible demand component takes an input commodity production profile, the maximum demand profile, and various constraints (listed below), and creates a "flexible demand profile" that follows the original input commodity production profile while satisfying varying constraint. +Please see the figure below for an example of how the flexible demand profile can vary from the original demand profile based on the input commodity production profile and the ramp rates. +The axes are unlabeled to allow for generalization to any commodity and unit type. + +| ![Flexible Demand Example](figures/flex_demand_fig.png) | +|-| + + +#### Configuration +The flexible demand component is defined within the `tech_config` with the following parameters: + +| Field | Type | Description | +| ------------------- | -------------- | -------------------------------------------- | +| `commodity_name` | `str` | Commodity name. | +| `commodity_units` | `str` | Units for all values. | +| `demand_profile` | scalar or list | Default (nominal) demand profile. | +| `turndown_ratio` | float | Minimum fraction of baseline demand allowed. | +| `ramp_down_rate_fraction` | float | Maximum ramp-down rate per timestep expressed as a fraction of baseline demand. | +| `ramp_up_rate_fraction` | float | Maximum ramp-up rate per timestep expressed as a fraction of baseline demand. | +| `min_utilization` | float | Minimum total fraction of baseline demand that must be met over the entire simulation. | + +```yaml +model_inputs: + performance_parameters: + commodity_name: hydrogen + commodity_units: kg/h + demand_profile: [10, 12, 10, 8] + turndown_ratio: 0.1 + ramp_down_rate_fraction: 0.5 + ramp_up_rate_fraction: 0.5 + min_utilization: 0 +``` diff --git a/docs/user_guide/model_overview.md b/docs/user_guide/model_overview.md index f2074bdf7..492986ce8 100644 --- a/docs/user_guide/model_overview.md +++ b/docs/user_guide/model_overview.md @@ -67,6 +67,7 @@ Below summarizes the available performance, cost, and financial models for each - [Storage Models](#storage-models) - [Basic Operations](#basic-operations) - [Control Models](#control-models) + - [DemandModels](#demand-models) (resource-models)= ## Resource models @@ -284,8 +285,10 @@ Below summarizes the available performance, cost, and financial models for each - `'SimpleStorageOpenLoopController'`: open-loop control; manages resource flow based on demand and input commodity - `'DemandOpenLoopStorageController'`: open-loop control; manages resource flow based on demand and storage constraints - `'HeuristicLoadFollowingController'`: open-loop control that works on a time window basis to set dispatch commands; uses Pyomo -- Converter Controllers: - - `'DemandOpenLoopConverterController'`: open-loop control; manages resource flow based on demand constraints - - `'FlexibleDemandOpenLoopConverterController'`: open-loop control; manages resource flow based on demand and flexibility constraints - Optimized Dispatch: - `'OptimizedDispatchController'`: optimization-based dispatch using Pyomo + +(demand-models)= +## Demand Models +- `'GenericDemandComponent'`: manages resource flow based on demand constraints +- `'FlexibleDemandComponent'`: manages resource flow based on demand and flexibility constraints diff --git a/examples/19_simple_dispatch/run_wind_battery.py b/examples/19_simple_dispatch/run_wind_battery.py index 7fff85678..8e2ecdaf6 100644 --- a/examples/19_simple_dispatch/run_wind_battery.py +++ b/examples/19_simple_dispatch/run_wind_battery.py @@ -42,14 +42,14 @@ ) ax[1].plot( range(start_hour, end_hour), - model.prob.get_val("battery.electricity_unused_commodity", units="MW")[start_hour:end_hour], + model.prob.get_val("battery.unused_electricity_out", units="MW")[start_hour:end_hour], linestyle=":", label="Unused Electricity commodity (MW)", linewidth=2, ) ax[1].plot( range(start_hour, end_hour), - model.prob.get_val("battery.electricity_unmet_demand", units="MW")[start_hour:end_hour], + model.prob.get_val("battery.unmet_electricity_demand_out", units="MW")[start_hour:end_hour], linestyle=":", label="Electricity Unmet Demand (MW)", linewidth=2, diff --git a/examples/23_solar_wind_ng_demand/flexible_demand_tech_config.yaml b/examples/23_solar_wind_ng_demand/flexible_demand_tech_config.yaml index 4f10f7ddc..d0c83fab0 100644 --- a/examples/23_solar_wind_ng_demand/flexible_demand_tech_config.yaml +++ b/examples/23_solar_wind_ng_demand/flexible_demand_tech_config.yaml @@ -91,10 +91,10 @@ technologies: variable_opex_per_mwh: 0.0 # $/MWh cost_year: 2023 electrical_load_demand: - control_strategy: - model: FlexibleDemandOpenLoopConverterController + performance_model: + model: FlexibleDemandComponent model_inputs: - control_parameters: + performance_parameters: commodity: electricity commodity_rate_units: kW rated_demand: 100000 diff --git a/examples/23_solar_wind_ng_demand/plant_config.yaml b/examples/23_solar_wind_ng_demand/plant_config.yaml index 30457af94..048314999 100644 --- a/examples/23_solar_wind_ng_demand/plant_config.yaml +++ b/examples/23_solar_wind_ng_demand/plant_config.yaml @@ -27,7 +27,7 @@ technology_interconnections: # connect NG feedstock to NG plant - [combiner, electrical_load_demand, [electricity_out, electricity_in]] # subtract wind and solar from demand - - [electrical_load_demand, natural_gas_plant, [electricity_unmet_demand, electricity_demand]] + - [electrical_load_demand, natural_gas_plant, [unmet_electricity_demand_out, electricity_demand]] # give remaining load demand to natural gas plant - [combiner, fin_combiner, electricity, cable] - [natural_gas_plant, fin_combiner, electricity, cable] diff --git a/examples/23_solar_wind_ng_demand/tech_config.yaml b/examples/23_solar_wind_ng_demand/tech_config.yaml index 5fbabe17f..e044c1788 100644 --- a/examples/23_solar_wind_ng_demand/tech_config.yaml +++ b/examples/23_solar_wind_ng_demand/tech_config.yaml @@ -91,10 +91,10 @@ technologies: variable_opex_per_mwh: 0.0 # $/MWh cost_year: 2023 electrical_load_demand: - control_strategy: - model: DemandOpenLoopConverterController + performance_model: + model: GenericDemandComponent model_inputs: - control_parameters: + performance_parameters: commodity: electricity commodity_rate_units: kW demand_profile: 100000 # 100 MW diff --git a/examples/24_solar_battery_grid/README.md b/examples/24_solar_battery_grid/README.md index 2cdf79ef6..cce607d62 100644 --- a/examples/24_solar_battery_grid/README.md +++ b/examples/24_solar_battery_grid/README.md @@ -53,8 +53,8 @@ The grid performance model handles: - `electricity_in`: Power flowing INTO the grid (selling to grid) - limited by interconnection size - `electricity_out`: Power flowing OUT OF the grid (buying from grid) - limited by interconnection size - `electricity_sold`: Actual electricity sold (up to interconnection limit) -- `electricity_unmet_demand`: Demand that couldn't be met due to interconnection limit -- `electricity_excess`: Electricity that couldn't be sold due to interconnection limit +- `unmet_electricity_demand_out`: Demand that couldn't be met due to interconnection limit +- `unused_electricity_out`: Electricity that couldn't be sold due to interconnection limit **Cost Model:** - CapEx: Based on interconnection size ($/kW) plus fixed costs @@ -72,16 +72,16 @@ Solar → Battery → [Grid Buy (purchases) | Grid Sell (sales)] - Solar generates electricity - Battery stores excess and follows 100 MW demand profile -- Grid Buy purchases electricity when battery cannot meet demand (via `electricity_unmet_demand` → `electricity_demand` connection) -- Grid Sell accepts excess electricity when battery has surplus (via `electricity_unused_commodity` → `electricity_in` connection) +- Grid Buy purchases electricity when battery cannot meet demand (via `unmet_electricity_demand_out` → `electricity_demand` connection) +- Grid Sell accepts excess electricity when battery has surplus (via `unused_electricity_out` → `electricity_in` connection) ### Technology Interconnections ```yaml technology_interconnections: [ ["solar", "battery", "electricity", "cable"], - ["battery", "grid_buy", ["electricity_unmet_demand", "electricity_demand"]], - ["battery", "grid_sell", ["electricity_unused_commodity", "electricity_in"]] + ["battery", "grid_buy", ["unmet_electricity_demand_out", "electricity_demand"]], + ["battery", "grid_sell", ["unused_electricity_out", "electricity_in"]] ] ``` diff --git a/h2integrate/control/control_strategies/converters/demand_openloop_converter_controller.py b/h2integrate/control/control_strategies/converters/demand_openloop_converter_controller.py deleted file mode 100644 index 7615e2c21..000000000 --- a/h2integrate/control/control_strategies/converters/demand_openloop_converter_controller.py +++ /dev/null @@ -1,77 +0,0 @@ -import numpy as np - -from h2integrate.control.control_strategies.converters.openloop_controller_base import ( - ConverterOpenLoopControlBase, - ConverterOpenLoopControlBaseConfig, -) - - -class DemandOpenLoopConverterController(ConverterOpenLoopControlBase): - """Open-loop controller for converting input supply into met demand. - - This controller computes unmet demand, unused (curtailed) production, and - the resulting commodity output profile based on the incoming supply and an - externally specified demand profile. It uses simple arithmetic rules: - - * If demand exceeds supplied commodity, the difference is unmet demand. - * If supply exceeds demand, the excess is unused (curtailed) commodity. - * Output equals supplied commodity minus curtailed commodity. - - This component relies on configuration provided through the - ``tech_config`` dictionary, which must define the controller's - ``control_parameters``. - """ - - def setup(self): - """Set up the load controller configuration. - - Loads the controller configuration from ``tech_config`` and then calls - the base class ``setup`` to create inputs/outputs. - - Raises: - KeyError: If the expected configuration keys are missing from - ``tech_config``. - """ - self.config = ConverterOpenLoopControlBaseConfig.from_dict( - self.options["tech_config"]["model_inputs"]["control_parameters"], - additional_cls_name=self.__class__.__name__, - ) - super().setup() - - def compute(self, inputs, outputs): - """Compute unmet demand, unused commodity, and converter output. - - This method compares the demand profile to the supplied commodity for - each timestep and assigns unmet demand, curtailed production, and - actual delivered output. - - Args: - inputs (dict-like): Mapping of input variable names to their - current values, including: - - * ``{commodity}_demand``: Demand profile. - * ``{commodity}_in``: Supplied commodity. - outputs (dict-like): Mapping of output variable names where results - will be written, including: - - * ``{commodity}_unmet_demand``: Unmet demand. - * ``{commodity}_unused_commodity``: Curtailed production. - * ``{commodity}_out``: Actual output delivered. - - Notes: - All variables operate on a per-timestep basis and typically have - array shape ``(n_timesteps,)``. - """ - commodity = self.config.commodity - remaining_demand = inputs[f"{commodity}_demand"] - inputs[f"{commodity}_in"] - - # Calculate missed load and curtailed production - outputs[f"{commodity}_unmet_demand"] = np.where(remaining_demand > 0, remaining_demand, 0) - outputs[f"{commodity}_unused_commodity"] = np.where( - remaining_demand < 0, -1 * remaining_demand, 0 - ) - - # Calculate actual output based on demand met and curtailment - outputs[f"{commodity}_set_point"] = ( - inputs[f"{commodity}_in"] - outputs[f"{commodity}_unused_commodity"] - ) diff --git a/h2integrate/control/control_strategies/converters/openloop_controller_base.py b/h2integrate/control/control_strategies/converters/openloop_controller_base.py deleted file mode 100644 index a4818301d..000000000 --- a/h2integrate/control/control_strategies/converters/openloop_controller_base.py +++ /dev/null @@ -1,121 +0,0 @@ -import openmdao.api as om -from attrs import field, define - -from h2integrate.core.utilities import BaseConfig - - -@define(kw_only=True) -class ConverterOpenLoopControlBaseConfig(BaseConfig): - """Configuration for defining an open-loop demand profile. - - This configuration object specifies the commodity being controlled and the - demand profile that should be met by downstream components. - - Attributes: - commodity (str): Name of the commodity being controlled - (e.g., "hydrogen"). Converted to lowercase and stripped of whitespace. - commodity_rate_units (str): Units of the commodity (e.g., "kg/h"). - demand_profile (int | float | list): Demand values for each timestep, in - the same units as `commodity_rate_units`. May be a scalar for constant - demand or a list/array for time-varying demand. - """ - - commodity: str = field(converter=(str.strip, str.lower)) - commodity_rate_units: str = field(converter=str.strip) - demand_profile: int | float | list = field() - - -class ConverterOpenLoopControlBase(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, supplied commodity, and outputs - tracking unmet demand, unused production, and total unmet demand. - Subclasses must implement the :meth:`compute` method to define the - controller behavior. - """ - - def initialize(self): - """Declare component options. - - Options: - driver_config (dict): Driver-level configuration parameters. - plant_config (dict): Plant-level configuration, including number of - simulation timesteps. - tech_config (dict): Technology-specific configuration, including - controller settings. - - """ - self.options.declare("driver_config", types=dict) - self.options.declare("plant_config", types=dict) - self.options.declare("tech_config", types=dict) - - def setup(self): - """Define inputs and outputs for demand control. - - Creates time-series inputs and outputs for commodity demand, supply, - unmet demand, unused commodity, and total unmet demand. Shapes and units - are determined by the plant configuration and controller configuration. - - Raises: - KeyError: If required configuration keys are missing from - ``plant_config`` or ``tech_config``. - """ - n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) - - commodity = self.config.commodity - - self.add_input( - f"{commodity}_demand", - val=self.config.demand_profile, - shape=(n_timesteps), - units=self.config.commodity_rate_units, # NOTE: hardcoded to align with controllers - desc=f"Demand profile of {commodity}", - ) - - self.add_input( - f"{commodity}_in", - val=0.0, - shape=(n_timesteps), - units=self.config.commodity_rate_units, - desc=f"Amount of {commodity} demand that has already been supplied", - ) - - self.add_output( - f"{commodity}_unmet_demand", - val=self.config.demand_profile, - shape=(n_timesteps), - units=self.config.commodity_rate_units, - desc=f"Remaining demand profile of {commodity}", - ) - - self.add_output( - f"{commodity}_unused_commodity", - val=0.0, - shape=(n_timesteps), - units=self.config.commodity_rate_units, - desc=f"Excess production of {commodity}", - ) - - self.add_output( - f"{commodity}_set_point", - val=0.0, - shape=(n_timesteps), - units=self.config.commodity_rate_units, - desc=f"Production profile of {commodity}", - ) - - self.add_output( - f"total_{commodity}_unmet_demand", - units=self.config.commodity_rate_units, - desc="Total unmet demand", - ) - - def compute(): - """This method must be implemented by subclasses to define the - controller. - - Raises: - NotImplementedError: Always, unless implemented in a subclass. - """ - raise NotImplementedError("This method should be implemented in a subclass.") diff --git a/h2integrate/control/test/test_openloop_controllers.py b/h2integrate/control/test/test_openloop_controllers.py index 402f201b9..2a9f90f2a 100644 --- a/h2integrate/control/test/test_openloop_controllers.py +++ b/h2integrate/control/test/test_openloop_controllers.py @@ -14,12 +14,6 @@ from h2integrate.control.control_strategies.storage.demand_openloop_storage_controller import ( DemandOpenLoopStorageController, ) -from h2integrate.control.control_strategies.converters.flexible_demand_openloop_controller import ( - FlexibleDemandOpenLoopConverterController, -) -from h2integrate.control.control_strategies.converters.demand_openloop_converter_controller import ( - DemandOpenLoopConverterController, -) @fixture @@ -55,7 +49,7 @@ def test_pass_through_controller(subtests): # Set up the OpenMDAO problem prob = om.Problem() - plant_config = {"plant": {"plant_life": 30, "simulation": {"n_timesteps": 10}}} + plant_config = {"plant": {"plant_life": 30, "simulation": {"n_timesteps": 10, "dt": 3600}}} prob.model.add_subsystem( name="IVC", @@ -503,257 +497,3 @@ def test_generic_storage_demand_controller(subtests): assert prob.get_val("unmet_hydrogen_demand_out", units="kg/h") == pytest.approx( [0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] ) - - -@pytest.mark.regression -def test_demand_converter_controller(subtests): - # Test is the same as the demand controller test test_demand_controller for the "h2_storage" - # performance model but with the "StoragePerformanceModel" performance model - - # Get the directory of the current script - current_dir = Path(__file__).parent - - # Resolve the paths to the configuration files - tech_config_path = current_dir / "inputs" / "tech_config.yaml" - - # Load the technology configuration - tech_config = load_yaml(tech_config_path) - - tech_config["technologies"]["load"] = { - "control_strategy": { - "model": "DemandOpenLoopConverterController", - }, - "model_inputs": { - "control_parameters": { - "commodity": "hydrogen", - "commodity_rate_units": "kg", - "demand_profile": [5.0] * 10, # Example: 10 time steps with 5 kg/time step demand - }, - }, - } - - plant_config = {"plant": {"plant_life": 30, "simulation": {"n_timesteps": 10}}} - - # Set up OpenMDAO problem - prob = om.Problem() - - prob.model.add_subsystem( - name="IVC", - subsys=om.IndepVarComp(name="hydrogen_in", val=np.arange(10)), - promotes=["*"], - ) - - prob.model.add_subsystem( - "demand_open_loop_storage_controller", - DemandOpenLoopConverterController( - plant_config=plant_config, tech_config=tech_config["technologies"]["load"] - ), - promotes=["*"], - ) - - prob.setup() - - prob.run_model() - - # # Run the test - with subtests.test("Check output"): - assert prob.get_val("hydrogen_set_point") == pytest.approx( - [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 5.0, 5.0, 5.0, 5.0] - ) - - with subtests.test("Check curtailment"): - assert prob.get_val("hydrogen_unused_commodity") == pytest.approx( - [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0] - ) - - with subtests.test("Check missed load"): - assert prob.get_val("hydrogen_unmet_demand") == pytest.approx( - [5.0, 4.0, 3.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0] - ) - - -### Add test for flexible load demand controller here - - -@pytest.mark.unit -def test_flexible_demand_converter_controller(subtests, variable_h2_production_profile): - # Get the directory of the current script - current_dir = Path(__file__).parent - - # Resolve the paths to the configuration files - tech_config_path = current_dir / "inputs" / "tech_config.yaml" - - # Load the technology configuration - tech_config = load_yaml(tech_config_path) - - end_use_rated_demand = 10.0 # kg/h - ramp_up_rate_kg = 4.0 - ramp_down_rate_kg = 2.0 - min_demand_kg = 2.5 - tech_config["technologies"]["load"] = { - "control_strategy": { - "model": "FlexibleDemandOpenLoopConverterController", - }, - "model_inputs": { - "control_parameters": { - "commodity": "hydrogen", - "commodity_rate_units": "kg", - "rated_demand": end_use_rated_demand, - "demand_profile": end_use_rated_demand, # flat demand profile - "turndown_ratio": min_demand_kg / end_use_rated_demand, - "ramp_down_rate_fraction": ramp_down_rate_kg / end_use_rated_demand, - "ramp_up_rate_fraction": ramp_up_rate_kg / end_use_rated_demand, - "min_utilization": 0.1, - }, - }, - } - - plant_config = { - "plant": { - "plant_life": 30, - "simulation": {"n_timesteps": len(variable_h2_production_profile)}, - } - } - - # Set up OpenMDAO problem - prob = om.Problem() - - prob.model.add_subsystem( - name="IVC", - subsys=om.IndepVarComp(name="hydrogen_in", val=variable_h2_production_profile), - promotes=["*"], - ) - - prob.model.add_subsystem( - "flexible_demand_open_loop_converter_controller", - FlexibleDemandOpenLoopConverterController( - plant_config=plant_config, tech_config=tech_config["technologies"]["load"] - ), - promotes=["*"], - ) - - prob.setup() - - prob.run_model() - - flexible_total_demand = prob.get_val("hydrogen_flexible_demand_profile", units="kg") - - rated_production = end_use_rated_demand * len(variable_h2_production_profile) - - with subtests.test("Check that total demand profile is less than rated"): - assert np.all(flexible_total_demand <= end_use_rated_demand) - - with subtests.test("Check curtailment"): # failed - assert np.sum(prob.get_val("hydrogen_unused_commodity", units="kg")) == pytest.approx(6.6) - - # check ramping constraints and turndown constraints are met - with subtests.test("Check turndown ratio constraint"): - assert np.all(flexible_total_demand >= min_demand_kg) - - ramping_down = np.where( - np.diff(flexible_total_demand) < 0, -1 * np.diff(flexible_total_demand), 0 - ) - ramping_up = np.where(np.diff(flexible_total_demand) > 0, np.diff(flexible_total_demand), 0) - - with subtests.test("Check ramping down constraint"): - assert np.max(ramping_down) == pytest.approx(ramp_down_rate_kg, rel=1e-6) - - with subtests.test("Check ramping up constraint"): # failed - assert np.max(ramping_up) == pytest.approx(ramp_up_rate_kg, rel=1e-6) - - with subtests.test("Check min utilization constraint"): - assert np.sum(flexible_total_demand) / rated_production >= 0.1 - - with subtests.test("Check min utilization value"): - flexible_demand_utilization = np.sum(flexible_total_demand) / rated_production - assert flexible_demand_utilization == pytest.approx(0.5822142857142857, rel=1e-6) - - # flexible_demand_profile[i] >= commodity_in[i] (as long as you are not curtailing - # any commodity in) - with subtests.test("Check that flexible demand is greater than hydrogen_in"): - hydrogen_available = variable_h2_production_profile - prob.get_val( - "hydrogen_unused_commodity", units="kg" - ) - assert np.all(flexible_total_demand >= hydrogen_available) - - with subtests.test("Check that remaining demand was calculated properly"): - unmet_demand = flexible_total_demand - hydrogen_available - assert np.all(unmet_demand == prob.get_val("hydrogen_unmet_demand", units="kg")) - - -@pytest.mark.regression -def test_flexible_demand_converter_controller_min_utilization( - subtests, variable_h2_production_profile -): - # give it a min utilization larger than utilization resulting from above test - - # Get the directory of the current script - current_dir = Path(__file__).parent - - # Resolve the paths to the configuration files - tech_config_path = current_dir / "inputs" / "tech_config.yaml" - - # Load the technology configuration - tech_config = load_yaml(tech_config_path) - - end_use_rated_demand = 10.0 # kg/h - ramp_up_rate_kg = 4.0 - ramp_down_rate_kg = 2.0 - min_demand_kg = 2.5 - tech_config["technologies"]["load"] = { - "control_strategy": { - "model": "FlexibleDemandOpenLoopConverterController", - }, - "model_inputs": { - "control_parameters": { - "commodity": "hydrogen", - "commodity_rate_units": "kg", - "rated_demand": end_use_rated_demand, - "demand_profile": end_use_rated_demand, # flat demand profile - "turndown_ratio": min_demand_kg / end_use_rated_demand, - "ramp_down_rate_fraction": ramp_down_rate_kg / end_use_rated_demand, - "ramp_up_rate_fraction": ramp_up_rate_kg / end_use_rated_demand, - "min_utilization": 0.8, - }, - }, - } - - plant_config = { - "plant": { - "plant_life": 30, - "simulation": {"n_timesteps": len(variable_h2_production_profile)}, - } - } - - # Set up OpenMDAO problem - prob = om.Problem() - - prob.model.add_subsystem( - name="IVC", - subsys=om.IndepVarComp(name="hydrogen_in", val=variable_h2_production_profile), - promotes=["*"], - ) - - prob.model.add_subsystem( - "DemandOpenLoopStorageController", - FlexibleDemandOpenLoopConverterController( - plant_config=plant_config, tech_config=tech_config["technologies"]["load"] - ), - promotes=["*"], - ) - - prob.setup() - - prob.run_model() - - flexible_total_demand = prob.get_val("hydrogen_flexible_demand_profile", units="kg") - - rated_production = end_use_rated_demand * len(variable_h2_production_profile) - - flexible_demand_utilization = np.sum(flexible_total_demand) / rated_production - - with subtests.test("Check min utilization constraint"): - assert flexible_demand_utilization >= 0.8 - - with subtests.test("Check min utilization value"): - assert flexible_demand_utilization == pytest.approx(0.8010612244, rel=1e-6) diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 3fbb1ed6b..b9fed1750 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -6,8 +6,10 @@ from h2integrate.converters.grid.grid import GridCostModel, GridPerformanceModel from h2integrate.finances.profast_lco import ProFastLCO from h2integrate.finances.profast_npv import ProFastNPV +from h2integrate.demand.generic_demand import GenericDemandComponent from h2integrate.converters.steel.steel import SteelPerformanceModel, SteelCostAndFinancialModel from h2integrate.converters.wind.floris import FlorisWindPlantPerformanceModel +from h2integrate.demand.flexible_demand import FlexibleDemandComponent from h2integrate.converters.wind.wind_pysam import PYSAMWindPlantPerformanceModel from h2integrate.transporters.generic_summer import GenericSummerPerformanceModel from h2integrate.converters.hopp.hopp_wrapper import HOPPComponent @@ -170,12 +172,6 @@ from h2integrate.control.control_strategies.storage.demand_openloop_storage_controller import ( DemandOpenLoopStorageController, ) -from h2integrate.control.control_strategies.converters.flexible_demand_openloop_controller import ( - FlexibleDemandOpenLoopConverterController, -) -from h2integrate.control.control_strategies.converters.demand_openloop_converter_controller import ( - DemandOpenLoopConverterController, -) supported_models = { @@ -284,8 +280,8 @@ "DemandOpenLoopStorageController": DemandOpenLoopStorageController, "HeuristicLoadFollowingController": HeuristicLoadFollowingController, "OptimizedDispatchController": OptimizedDispatchController, - "DemandOpenLoopConverterController": DemandOpenLoopConverterController, - "FlexibleDemandOpenLoopConverterController": FlexibleDemandOpenLoopConverterController, + "GenericDemandComponent": GenericDemandComponent, + "FlexibleDemandComponent": FlexibleDemandComponent, # Dispatch "PyomoDispatchGenericConverter": PyomoDispatchGenericConverter, "PyomoRuleStorageBaseclass": PyomoRuleStorageBaseclass, diff --git a/h2integrate/control/control_strategies/converters/__init__.py b/h2integrate/demand/__init__.py similarity index 100% rename from h2integrate/control/control_strategies/converters/__init__.py rename to h2integrate/demand/__init__.py diff --git a/h2integrate/demand/demand_base.py b/h2integrate/demand/demand_base.py new file mode 100644 index 000000000..129dee08c --- /dev/null +++ b/h2integrate/demand/demand_base.py @@ -0,0 +1,151 @@ +import numpy as np +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig +from h2integrate.core.model_baseclasses import PerformanceModelBaseClass + + +@define(kw_only=True) +class DemandComponentBaseConfig(BaseConfig): + """Configuration for defining a demand profile. + + This configuration object specifies the commodity being demanded and the + demand profile that should be met by downstream components. + + Attributes: + commodity (str): Name of the commodity being demanded + (e.g., "hydrogen"). Converted to lowercase and stripped of whitespace. + commodity_rate_units (str): Units of the commodity (e.g., "kg/h"). + demand_profile (int | float | list): Demand values for each timestep, in + the same units as `commodity_rate_units`. May be a scalar for constant + demand or a list/array for time-varying demand. + commodity_amount_units (str | None, optional): Units of the commodity as an amount + (i.e., kW*h or kg). If not provided, defaults to commodity_rate_units*h. + """ + + commodity: str = field(converter=str.strip) + commodity_rate_units: str = field(converter=str.strip) + demand_profile: int | float | list = field() + commodity_amount_units: str = field(default=None) + + def __attrs_post_init__(self): + if self.commodity_amount_units is None: + self.commodity_amount_units = f"({self.commodity_rate_units})*h" + + +class DemandComponentBase(PerformanceModelBaseClass): + """Base OpenMDAO component for open-loop demand tracking. + + This component defines the interfaces required for demand + components, including inputs for demand, supplied commodity, and outputs + tracking unmet demand, unused production, and total unmet demand. + Subclasses must implement the :meth:`compute` method to define the + demand component behavior. + """ + + def setup(self): + """Define inputs and outputs for demand component. + + Creates time-series inputs and outputs for commodity demand, supply, + unmet demand, unused commodity, and total unmet demand. Shapes and units + are determined by the plant configuration and load component configuration. + + Raises: + KeyError: If required configuration keys are missing from + ``plant_config`` or ``tech_config``. + """ + self.commodity = self.config.commodity + self.commodity_rate_units = self.config.commodity_rate_units + self.commodity_amount_units = self.config.commodity_amount_units + + super().setup() + + self.add_input( + f"{self.commodity}_demand", + val=self.config.demand_profile, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Demand profile of {self.commodity}", + ) + + self.add_input( + f"{self.commodity}_in", + val=0.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Amount of {self.commodity} demand that has already been supplied", + ) + + self.add_output( + f"unmet_{self.commodity}_demand_out", + val=self.config.demand_profile, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Remaining demand profile of {self.commodity}", + ) + + self.add_output( + f"unused_{self.commodity}_out", + val=0.0, + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Excess production of {self.commodity}", + ) + + def compute(): + """This method must be implemented by subclasses to define the + demand component. + + Raises: + NotImplementedError: Always, unless implemented in a subclass. + """ + raise NotImplementedError("This method should be implemented in a subclass.") + + def calculate_outputs(self, commodity_in, commodity_demand, outputs): + """Compute unmet demand, unused commodity, and converter output. + + This method compares the demand profile to the supplied commodity for + each timestep and assigns unmet demand, curtailed production, and + actual delivered output. + + Args: + commodity_in (np.array): supplied commodity profile + commodity_demand (np.array): entire commodity demand profile + outputs (dict-like): Mapping of output variable names where results + will be written, including: + + * ``unmet_{commodity}_demand_out``: Unmet demand. + * ``unused_{commodity}_out``: Curtailed production. + * ``{commodity}_out``: Actual output delivered. + + Notes: + All variables operate on a per-timestep basis and typically have + array shape ``(n_timesteps,)``. + """ + + remaining_demand = commodity_demand - commodity_in + + # Calculate missed load and curtailed production + outputs[f"unmet_{self.commodity}_demand_out"] = np.where( + remaining_demand > 0, remaining_demand, 0 + ) + outputs[f"unused_{self.commodity}_out"] = np.where( + remaining_demand < 0, -1 * remaining_demand, 0 + ) + + # Calculate actual output based on demand met and curtailment + outputs[f"{self.commodity}_out"] = commodity_in - outputs[f"unused_{self.commodity}_out"] + + outputs[f"rated_{self.commodity}_production"] = commodity_demand.mean() + + outputs[f"total_{self.commodity}_produced"] = np.sum(outputs[f"{self.commodity}_out"]) * ( + self.dt / 3600 + ) + + outputs[f"annual_{self.commodity}_produced"] = ( + outputs[f"total_{self.commodity}_produced"] / self.fraction_of_year_simulated + ) + + outputs["capacity_factor"] = outputs[f"{self.commodity}_out"].sum() / commodity_demand.sum() + + return outputs diff --git a/h2integrate/control/control_strategies/converters/flexible_demand_openloop_controller.py b/h2integrate/demand/flexible_demand.py similarity index 81% rename from h2integrate/control/control_strategies/converters/flexible_demand_openloop_controller.py rename to h2integrate/demand/flexible_demand.py index 1cc5e8bba..43af90d1a 100644 --- a/h2integrate/control/control_strategies/converters/flexible_demand_openloop_controller.py +++ b/h2integrate/demand/flexible_demand.py @@ -1,18 +1,16 @@ import numpy as np from attrs import field, define +from h2integrate.core.utilities import merge_shared_inputs from h2integrate.core.validators import gte_zero, range_val -from h2integrate.control.control_strategies.converters.openloop_controller_base import ( - ConverterOpenLoopControlBase, - ConverterOpenLoopControlBaseConfig, -) +from h2integrate.demand.demand_base import DemandComponentBase, DemandComponentBaseConfig @define(kw_only=True) -class FlexibleDemandOpenLoopConverterControllerConfig(ConverterOpenLoopControlBaseConfig): - """Configuration for defining a flexible demand open-loop controller. +class FlexibleDemandComponentConfig(DemandComponentBaseConfig): + """Configuration for defining a flexible demand component. - Extends :class:`DemandOpenLoopControlBaseConfig` with additional parameters + Extends :class:`DemandComponentBaseConfig` with additional parameters required for dynamically adjusting demand based on turndown, ramping, and minimum utilization constraints. These parameters are expressed as fractions of ``maximum_demand`` and must lie within ``(0, 1)``. @@ -42,37 +40,35 @@ class FlexibleDemandOpenLoopConverterControllerConfig(ConverterOpenLoopControlBa min_utilization: float = field(validator=range_val(0, 1.0)) -class FlexibleDemandOpenLoopConverterController(ConverterOpenLoopControlBase): - """Open-loop controller for flexible demand with ramping and utilization constraints. +class FlexibleDemandComponent(DemandComponentBase): + """Demand component for flexible demand with ramping and utilization constraints. - This controller extends the base demand controller by allowing the effective + This component extends the base demand component by allowing the effective demand to vary dynamically based on turndown constraints, ramp-rate limits, and minimum-utilization requirements. A flexible demand profile is generated and used to compute unmet demand, unused commodity, and delivered output. """ def setup(self): - """Set up component inputs and outputs for flexible demand control. + """Set up component inputs and outputs for flexible demand. Adds inputs for turndown ratio, ramp up/down rates, and minimum utilization, all expressed as fractions of maximum demand. Adds the flexible demand output profile, which will be populated in ``compute``. """ - self.config = FlexibleDemandOpenLoopConverterControllerConfig.from_dict( - self.options["tech_config"]["model_inputs"]["control_parameters"], + self.config = FlexibleDemandComponentConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance"), + strict=True, additional_cls_name=self.__class__.__name__, ) super().setup() - n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) - commodity = self.config.commodity - self.add_input( - f"rated_{commodity}_demand", + f"rated_{self.commodity}_demand", val=self.config.demand_profile, - shape=(n_timesteps), - units=self.config.commodity_rate_units, - desc=f"Rated demand of {commodity}", + shape=self.n_timesteps, + units=self.commodity_rate_units, + desc=f"Rated demand of {self.commodity}", ) self.add_input( @@ -104,11 +100,11 @@ def setup(self): ) self.add_output( - f"{commodity}_flexible_demand_profile", + f"{self.commodity}_flexible_demand_profile", val=0.0, - shape=(n_timesteps), + shape=self.n_timesteps, units=self.config.commodity_rate_units, - desc=f"Flexible demand profile of {commodity}", + desc=f"Flexible demand profile of {self.commodity}", ) def adjust_demand_for_ramping(self, pre_demand_met_clipped, demand_bounds, ramp_rate_bounds): @@ -250,7 +246,7 @@ def compute(self, inputs, outputs): """Compute unmet demand, unused commodity, and output under flexible demand. If ``min_utilization == 1.0``, the behavior matches the regular open-loop - controller with no flexible demand adjustments. + component with no flexible demand adjustments. Otherwise: * Construct a flexible demand profile. @@ -263,40 +259,33 @@ def compute(self, inputs, outputs): outputs (dict-like): Mapping where computed outputs are written. """ - commodity = self.config.commodity - remaining_demand = inputs[f"{commodity}_demand"] - inputs[f"{commodity}_in"] if self.config.min_utilization == 1.0: + outputs[f"{self.commodity}_flexible_demand_profile"] = inputs[ + f"{self.commodity}_demand" + ] + # Calculate missed load and curtailed production - outputs[f"{commodity}_unmet_demand"] = np.where( - remaining_demand > 0, remaining_demand, 0 - ) - outputs[f"{commodity}_unused_commodity"] = np.where( - remaining_demand < 0, -1 * remaining_demand, 0 + outputs = self.calculate_outputs( + inputs[f"{self.commodity}_in"], inputs[f"{self.commodity}_demand"], outputs ) + else: + remaining_demand = inputs[f"{self.commodity}_demand"] - inputs[f"{self.commodity}_in"] + # when remaining demand is less than 0, that means input exceeds demand # multiply by -1 to make it positive curtailed = np.where(remaining_demand < 0, -1 * remaining_demand, 0) # subtract out the excess input commodity - inflexible_out = inputs[f"{commodity}_in"] - curtailed + inflexible_out = inputs[f"{self.commodity}_in"] - curtailed flexible_demand_profile = self.make_flexible_demand( - inputs[f"{commodity}_demand"], inflexible_out, inputs + inputs[f"{self.commodity}_demand"], inflexible_out, inputs ) - outputs[f"{commodity}_flexible_demand_profile"] = flexible_demand_profile - flexible_remaining_demand = flexible_demand_profile - inputs[f"{commodity}_in"] + outputs[f"{self.commodity}_flexible_demand_profile"] = flexible_demand_profile - outputs[f"{commodity}_unmet_demand"] = np.where( - flexible_remaining_demand > 0, flexible_remaining_demand, 0 - ) - outputs[f"{commodity}_unused_commodity"] = np.where( - flexible_remaining_demand < 0, -1 * flexible_remaining_demand, 0 + outputs = self.calculate_outputs( + inputs[f"{self.commodity}_in"], flexible_demand_profile, outputs ) - - # Calculate actual output based on demand met and curtailment - outputs[f"{commodity}_set_point"] = ( - inputs[f"{commodity}_in"] - outputs[f"{commodity}_unused_commodity"] - ) diff --git a/h2integrate/demand/generic_demand.py b/h2integrate/demand/generic_demand.py new file mode 100644 index 000000000..b65e713f1 --- /dev/null +++ b/h2integrate/demand/generic_demand.py @@ -0,0 +1,56 @@ +from h2integrate.core.utilities import merge_shared_inputs +from h2integrate.demand.demand_base import DemandComponentBase, DemandComponentBaseConfig + + +class GenericDemandComponent(DemandComponentBase): + """Component for for converting input supply into met demand. + + This component computes unmet demand, unused (curtailed) production, and + the resulting commodity output profile based on the incoming supply and an + externally specified demand profile. It uses simple arithmetic rules: + + * If demand exceeds supplied commodity, the difference is unmet demand. + * If supply exceeds demand, the excess is unused (curtailed) commodity. + * Output equals supplied commodity minus curtailed commodity. + + This component relies on configuration provided through the + ``tech_config`` dictionary, which must define the demand's + ``performance_parameters``. + """ + + def setup(self): + self.config = DemandComponentBaseConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance"), + strict=True, + additional_cls_name=self.__class__.__name__, + ) + super().setup() + + def compute(self, inputs, outputs): + """Compute unmet demand, unused commodity, and converter output. + + This method compares the demand profile to the supplied commodity for + each timestep and assigns unmet demand, curtailed production, and + actual delivered output. + + Args: + inputs (dict-like): Mapping of input variable names to their + current values, including: + + * ``{commodity}_demand``: Demand profile. + * ``{commodity}_in``: Supplied commodity. + outputs (dict-like): Mapping of output variable names where results + will be written, including: + + * ``unmet_{commodity}_demand_out``: Unmet demand. + * ``unused_{commodity}_out``: Curtailed production. + * ``{commodity}_out``: Actual output delivered. + + Notes: + All variables operate on a per-timestep basis and typically have + array shape ``(n_timesteps,)``. + """ + + outputs = self.calculate_outputs( + inputs[f"{self.commodity}_in"], inputs[f"{self.commodity}_demand"], outputs + ) diff --git a/h2integrate/demand/test/test_demand_components.py b/h2integrate/demand/test/test_demand_components.py new file mode 100644 index 000000000..45cbca3b9 --- /dev/null +++ b/h2integrate/demand/test/test_demand_components.py @@ -0,0 +1,256 @@ +import numpy as np +import pytest +import openmdao.api as om +from pytest import fixture + +from h2integrate.demand.generic_demand import GenericDemandComponent +from h2integrate.demand.flexible_demand import FlexibleDemandComponent + + +@fixture +def variable_h2_production_profile(): + end_use_rated_demand = 10.0 # kg/h + ramp_up_rate_kg = 4.0 + ramp_down_rate_kg = 2.0 + slow_ramp_up = np.arange(0, end_use_rated_demand * 1.1, 0.5) + slow_ramp_down = np.arange(end_use_rated_demand * 1.1, -0.5, -0.5) + fast_ramp_up = np.arange(0, end_use_rated_demand, ramp_up_rate_kg * 1.2) + fast_ramp_down = np.arange(end_use_rated_demand, 0.0, ramp_down_rate_kg * 1.1) + variable_profile = np.concat( + [slow_ramp_up, fast_ramp_down, slow_ramp_up, slow_ramp_down, fast_ramp_up] + ) + variable_h2_profile = np.tile(variable_profile, 2) + return variable_h2_profile + + +@pytest.mark.regression +def test_demand_converter_controller(subtests): + # Test is the same as the demand controller test test_demand_controller for the "h2_storage" + # performance model but with the "StoragePerformanceModel" performance model + + # Define the technology configuration + tech_config = {"technologies": {}} + + tech_config["technologies"]["load"] = { + "performance_model": { + "model": "GenericDemandComponent", + }, + "model_inputs": { + "performance_parameters": { + "commodity": "hydrogen", + "commodity_rate_units": "kg", + "demand_profile": [5.0] * 10, # Example: 10 time steps with 5 kg/time step demand + }, + }, + } + + plant_config = {"plant": {"plant_life": 30, "simulation": {"n_timesteps": 10, "dt": 3600}}} + + # Set up OpenMDAO problem + prob = om.Problem() + + prob.model.add_subsystem( + name="IVC", + subsys=om.IndepVarComp(name="hydrogen_in", val=np.arange(10)), + promotes=["*"], + ) + + prob.model.add_subsystem( + "demand_open_loop_storage_controller", + GenericDemandComponent( + plant_config=plant_config, tech_config=tech_config["technologies"]["load"] + ), + promotes=["*"], + ) + + prob.setup() + + prob.run_model() + + # # Run the test + with subtests.test("Check output"): + assert prob.get_val("hydrogen_out") == pytest.approx( + [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 5.0, 5.0, 5.0, 5.0] + ) + + with subtests.test("Check curtailment"): + assert prob.get_val("unused_hydrogen_out") == pytest.approx( + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0] + ) + + with subtests.test("Check missed load"): + assert prob.get_val("unmet_hydrogen_demand_out") == pytest.approx( + [5.0, 4.0, 3.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + ) + + +@pytest.mark.unit +def test_flexible_demand_converter_controller(subtests, variable_h2_production_profile): + # Define the technology configuration + tech_config = {"technologies": {}} + + end_use_rated_demand = 10.0 # kg/h + ramp_up_rate_kg = 4.0 + ramp_down_rate_kg = 2.0 + min_demand_kg = 2.5 + tech_config["technologies"]["load"] = { + "performance_model": { + "model": "FlexibleDemandComponent", + }, + "model_inputs": { + "performance_parameters": { + "commodity": "hydrogen", + "commodity_rate_units": "kg", + "rated_demand": end_use_rated_demand, + "demand_profile": end_use_rated_demand, # flat demand profile + "turndown_ratio": min_demand_kg / end_use_rated_demand, + "ramp_down_rate_fraction": ramp_down_rate_kg / end_use_rated_demand, + "ramp_up_rate_fraction": ramp_up_rate_kg / end_use_rated_demand, + "min_utilization": 0.1, + }, + }, + } + + plant_config = { + "plant": { + "plant_life": 30, + "simulation": {"n_timesteps": len(variable_h2_production_profile), "dt": 3600}, + } + } + + # Set up OpenMDAO problem + prob = om.Problem() + + prob.model.add_subsystem( + name="IVC", + subsys=om.IndepVarComp(name="hydrogen_in", val=variable_h2_production_profile), + promotes=["*"], + ) + + prob.model.add_subsystem( + "flexible_demand_open_loop_converter_controller", + FlexibleDemandComponent( + plant_config=plant_config, tech_config=tech_config["technologies"]["load"] + ), + promotes=["*"], + ) + + prob.setup() + + prob.run_model() + + flexible_total_demand = prob.get_val("hydrogen_flexible_demand_profile", units="kg") + + rated_production = end_use_rated_demand * len(variable_h2_production_profile) + + with subtests.test("Check that total demand profile is less than rated"): + assert np.all(flexible_total_demand <= end_use_rated_demand) + + with subtests.test("Check curtailment"): # failed + assert np.sum(prob.get_val("unused_hydrogen_out", units="kg")) == pytest.approx(6.6) + + # check ramping constraints and turndown constraints are met + with subtests.test("Check turndown ratio constraint"): + assert np.all(flexible_total_demand >= min_demand_kg) + + ramping_down = np.where( + np.diff(flexible_total_demand) < 0, -1 * np.diff(flexible_total_demand), 0 + ) + ramping_up = np.where(np.diff(flexible_total_demand) > 0, np.diff(flexible_total_demand), 0) + + with subtests.test("Check ramping down constraint"): + assert np.max(ramping_down) == pytest.approx(ramp_down_rate_kg, rel=1e-6) + + with subtests.test("Check ramping up constraint"): # failed + assert np.max(ramping_up) == pytest.approx(ramp_up_rate_kg, rel=1e-6) + + with subtests.test("Check min utilization constraint"): + assert np.sum(flexible_total_demand) / rated_production >= 0.1 + + with subtests.test("Check min utilization value"): + flexible_demand_utilization = np.sum(flexible_total_demand) / rated_production + assert flexible_demand_utilization == pytest.approx(0.5822142857142857, rel=1e-6) + + # flexible_demand_profile[i] >= commodity_in[i] (as long as you are not curtailing + # any commodity in) + with subtests.test("Check that flexible demand is greater than hydrogen_in"): + hydrogen_available = variable_h2_production_profile - prob.get_val( + "unused_hydrogen_out", units="kg" + ) + assert np.all(flexible_total_demand >= hydrogen_available) + + with subtests.test("Check that remaining demand was calculated properly"): + unmet_demand = flexible_total_demand - hydrogen_available + assert np.all(unmet_demand == prob.get_val("unmet_hydrogen_demand_out", units="kg")) + + +@pytest.mark.regression +def test_flexible_demand_converter_controller_min_utilization( + subtests, variable_h2_production_profile +): + # give it a min utilization larger than utilization resulting from above test + + # Define the technology configuration + tech_config = {"technologies": {}} + + end_use_rated_demand = 10.0 # kg/h + ramp_up_rate_kg = 4.0 + ramp_down_rate_kg = 2.0 + min_demand_kg = 2.5 + tech_config["technologies"]["load"] = { + "performance_model": { + "model": "FlexibleDemandComponent", + }, + "model_inputs": { + "performance_parameters": { + "commodity": "hydrogen", + "commodity_rate_units": "kg", + "rated_demand": end_use_rated_demand, + "demand_profile": end_use_rated_demand, # flat demand profile + "turndown_ratio": min_demand_kg / end_use_rated_demand, + "ramp_down_rate_fraction": ramp_down_rate_kg / end_use_rated_demand, + "ramp_up_rate_fraction": ramp_up_rate_kg / end_use_rated_demand, + "min_utilization": 0.8, + }, + }, + } + + plant_config = { + "plant": { + "plant_life": 30, + "simulation": {"n_timesteps": len(variable_h2_production_profile), "dt": 3600}, + } + } + + # Set up OpenMDAO problem + prob = om.Problem() + + prob.model.add_subsystem( + name="IVC", + subsys=om.IndepVarComp(name="hydrogen_in", val=variable_h2_production_profile), + promotes=["*"], + ) + + prob.model.add_subsystem( + "DemandOpenLoopStorageController", + FlexibleDemandComponent( + plant_config=plant_config, tech_config=tech_config["technologies"]["load"] + ), + promotes=["*"], + ) + + prob.setup() + + prob.run_model() + + flexible_total_demand = prob.get_val("hydrogen_flexible_demand_profile", units="kg") + + rated_production = end_use_rated_demand * len(variable_h2_production_profile) + + flexible_demand_utilization = np.sum(flexible_total_demand) / rated_production + + with subtests.test("Check min utilization constraint"): + assert flexible_demand_utilization >= 0.8 + + with subtests.test("Check min utilization value"): + assert flexible_demand_utilization == pytest.approx(0.8010612244, rel=1e-6)