|
| 1 | +import numpy as np |
| 2 | +import openmdao.api as om |
| 3 | +from attrs import field, define |
| 4 | + |
| 5 | +from h2integrate.core.utilities import BaseConfig, merge_shared_inputs |
| 6 | + |
| 7 | + |
| 8 | +@define(kw_only=True) |
| 9 | +class PassThroughOpenLoopControllerConfig(BaseConfig): |
| 10 | + """Configuration class for the PassThroughOpenLoopController |
| 11 | +
|
| 12 | + Attributes: |
| 13 | + commodity (str): name of commodity |
| 14 | + commodity_rate_units (str): Units of the commodity (e.g., kW or kg/h). |
| 15 | + set_demand_as_avg_commodity_in (bool): If True, assume the demand is |
| 16 | + equal to the mean input commodity. If False, uses the demand input. |
| 17 | + demand_profile (int | float | list, optional): Demand values for each timestep, in |
| 18 | + the same units as `commodity_rate_units`. May be a scalar for constant |
| 19 | + demand or a list/array for time-varying demand. |
| 20 | + Only used if `set_demand_as_avg_commodity_in` is False. Defaults to 0. |
| 21 | +
|
| 22 | + """ |
| 23 | + |
| 24 | + commodity: str = field() |
| 25 | + commodity_rate_units: str = field() |
| 26 | + set_demand_as_avg_commodity_in: bool = field() |
| 27 | + demand_profile: int | float | list = field(default=0.0) |
| 28 | + |
| 29 | + def __attrs_post_init__(self): |
| 30 | + if isinstance(self.demand_profile, list | np.ndarray): |
| 31 | + user_input_dmd = True if sum(self.demand_profile) > 0 else False |
| 32 | + else: |
| 33 | + user_input_dmd = True if self.demand_profile > 0 else False |
| 34 | + |
| 35 | + if self.set_demand_as_avg_commodity_in and user_input_dmd: |
| 36 | + # If using the average commodity in as the demand, |
| 37 | + # warn users if they input the demand profile |
| 38 | + msg = ( |
| 39 | + "A non-zero demand profile was provided but set_demand_as_avg_commodity_in is True." |
| 40 | + " The provided demand profile will not be used, the demand profile will be " |
| 41 | + f"calculated as the mean of ``{self.commodity}_in``. " |
| 42 | + ) |
| 43 | + raise ValueError(msg) |
| 44 | + |
| 45 | + |
| 46 | +class PassThroughOpenLoopController(om.ExplicitComponent): |
| 47 | + """ |
| 48 | + A simple pass-through controller for open-loop systems. |
| 49 | +
|
| 50 | + This controller directly sets a storage control set point as the difference between the |
| 51 | + demand and the available input commodity. It is useful for testing, as a placeholder for |
| 52 | + more complex storage controllers, and for maintaining consistency between controlled and |
| 53 | + uncontrolled frameworks. |
| 54 | + """ |
| 55 | + |
| 56 | + def initialize(self): |
| 57 | + """ |
| 58 | + Declare options for the component. See "Attributes" section in class doc strings for |
| 59 | + details. |
| 60 | + """ |
| 61 | + |
| 62 | + self.options.declare("driver_config", types=dict) |
| 63 | + self.options.declare("plant_config", types=dict) |
| 64 | + self.options.declare("tech_config", types=dict) |
| 65 | + |
| 66 | + def setup(self): |
| 67 | + self.config = PassThroughOpenLoopControllerConfig.from_dict( |
| 68 | + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "control"), |
| 69 | + additional_cls_name=self.__class__.__name__, |
| 70 | + ) |
| 71 | + |
| 72 | + self.n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] |
| 73 | + |
| 74 | + self.add_input( |
| 75 | + f"{self.config.commodity}_in", |
| 76 | + shape_by_conn=True, |
| 77 | + units=self.config.commodity_rate_units, |
| 78 | + desc=f"{self.config.commodity} input timeseries from production to storage", |
| 79 | + ) |
| 80 | + |
| 81 | + self.add_input( |
| 82 | + f"{self.config.commodity}_demand", |
| 83 | + units=f"{self.config.commodity_rate_units}", |
| 84 | + val=self.config.demand_profile, |
| 85 | + shape=self.n_timesteps, |
| 86 | + desc=f"{self.config.commodity} demand profile timeseries", |
| 87 | + ) |
| 88 | + |
| 89 | + self.add_output( |
| 90 | + f"{self.config.commodity}_set_point", |
| 91 | + copy_shape=f"{self.config.commodity}_in", |
| 92 | + units=self.config.commodity_rate_units, |
| 93 | + desc=f"{self.config.commodity} output timeseries from plant after storage", |
| 94 | + ) |
| 95 | + |
| 96 | + def compute(self, inputs, outputs): |
| 97 | + """ |
| 98 | + Simple controller. |
| 99 | +
|
| 100 | + Args: |
| 101 | + inputs (dict): Dictionary of input values. |
| 102 | + - {commodity}_in: Input commodity flow. |
| 103 | + - {commodity}_demand: Commodity demand profile. |
| 104 | + Only used if `set_demand_as_avg_commodity_in` is False. |
| 105 | + outputs (dict): Dictionary of output values. |
| 106 | + - {commodity}_set_point: Dispatch set-points for each timestep |
| 107 | + in `commodity_rate_units`. Negative values command charging; |
| 108 | + positive values command discharging. |
| 109 | + """ |
| 110 | + |
| 111 | + if ( |
| 112 | + self.config.set_demand_as_avg_commodity_in |
| 113 | + and inputs[f"{self.config.commodity}_demand"].sum() > 0 |
| 114 | + ): |
| 115 | + msg = ( |
| 116 | + "A non-zero demand profile was input but set_demand_as_avg_commodity_in is True." |
| 117 | + " The input demand profile will not be used, the demand profile will be " |
| 118 | + f"calculated as the mean of ``{self.config.commodity}_in``. " |
| 119 | + ) |
| 120 | + raise ValueError(msg) |
| 121 | + |
| 122 | + if self.config.set_demand_as_avg_commodity_in: |
| 123 | + # Assume the demand is the average of the input commodity |
| 124 | + commodity_demand = np.mean(inputs[f"{self.config.commodity}_in"]) * np.ones( |
| 125 | + self.n_timesteps |
| 126 | + ) |
| 127 | + else: |
| 128 | + commodity_demand = inputs[f"{self.config.commodity}_demand"] |
| 129 | + |
| 130 | + # Assign the set point as the difference between the demand and the input commodity |
| 131 | + # when demand > input, the set point is positive to command discharging |
| 132 | + # when demand < input, the set point is negative to command charging |
| 133 | + outputs[f"{self.config.commodity}_set_point"] = ( |
| 134 | + commodity_demand - inputs[f"{self.config.commodity}_in"] |
| 135 | + ) |
0 commit comments