From 168766ae49d102eae83338659e15ddb7fdcb200a Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 2 Oct 2025 09:16:49 -0600 Subject: [PATCH 01/37] include code from usage: pyomo [-h] [--version] {build-extensions,convert,download-extensions,help,install-extras,model-viewer,run,solve,test-solvers} ... This is the main driver for the Pyomo optimization software. options: -h, --help show this help message and exit --version show program's version number and exit subcommands: {build-extensions,convert,download-extensions,help,install-extras,model-viewer,run,solve,test-solvers} build-extensions Build compiled extension modules convert Convert a Pyomo model to another format download-extensions Download compiled extension modules help Print help information. install-extras Install "extra" packages that Pyomo can leverage. model-viewer Run the Pyomo model viewer run Execute a command from the Pyomo bin (or Scripts) directory. solve Optimize a model test-solvers Test Pyomo solvers ------------------------------------------------------------------------- Pyomo supports a variety of modeling and optimization capabilities, which are executed either as subcommands of 'pyomo' or as separate commands. Use the 'help' subcommand to get information about the capabilities installed with Pyomo. Additionally, each subcommand supports independent command-line options. Use the -h option to print details for a subcommand. For example, type pyomo solve -h to print information about the `solve` subcommand. branch that needs to be saved for later --- .../control_rules/pyomo_control_options.py | 80 ++ .../control_strategies/pyomo_controllers.py | 777 ++++++++++++++++++ 2 files changed, 857 insertions(+) create mode 100644 h2integrate/control/control_rules/pyomo_control_options.py create mode 100644 h2integrate/control/control_strategies/pyomo_controllers.py diff --git a/h2integrate/control/control_rules/pyomo_control_options.py b/h2integrate/control/control_rules/pyomo_control_options.py new file mode 100644 index 000000000..607aa0263 --- /dev/null +++ b/h2integrate/control/control_rules/pyomo_control_options.py @@ -0,0 +1,80 @@ +import numpy as np +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig +from h2integrate.core.validators import gt_zero, contains, gte_zero, range_val + + +@define +class PyomoControlOptions(BaseConfig): + """ + Configuration class for setting dispatch options. + + This class inherits from BaseConfig and uses attrs for field definition and validation. + Configuration can be set by passing a dictionary to the from_dict() class method or by + providing values directly to the constructor. + + Attributes: + - **solver** (str, default='cbc'): MILP solver used for dispatch optimization problem. + Options are `('glpk', 'cbc', 'xpress', 'xpress_persistent', 'gurobi_ampl', 'gurobi')`. + + - **solver_options** (dict): Dispatch solver options. + + - **include_lifecycle_count** (bool, default=True): Should battery lifecycle counting + be included. + + - **lifecycle_cost_per_kWh_cycle** (float, default=0.0265): If include_lifecycle_count, + cost per kWh cycle. + + - **max_lifecycle_per_day** (int, default=None): If include_lifecycle_count, how many + cycles allowed per day. + + - **n_look_ahead_periods** (int, default=48): Number of time periods dispatch + looks ahead. + + - **n_roll_periods** (int, default=24): Number of time periods simulation rolls forward + after each dispatch. + + - **time_weighting_factor** (float, default=0.995): Discount factor for the time periods + in the look ahead period. + + - **log_name** (str, default=''): Dispatch log file name, empty str will result in no + log (for development). + + - **use_clustering** (bool, default=False): If True, the simulation will be run for a + selected set of "exemplar" days. + + - **n_clusters** (int, default=30). + + - **clustering_weights** (dict, default={}): Custom weights used for classification + metrics for data clustering. If empty, default weights will be used. + + - **clustering_divisions** (dict, default={}): Custom number of averaging periods for + classification metrics for data clustering. If empty, default values will be used. + + - **use_higher_hours** bool (default = False): if True, the simulation will run extra + hours analysis (must be used with load following) + + - **higher_hours** (dict, default = {}): Higher hour count parameters: the value of + power that must be available above the schedule and the number of hours in a row + + """ + + solver: str = field( + default="cbc", + validator=contains(["glpk", "cbc", "xpress", "xpress_persistent", "gurobi_ampl", "gurobi"]), + ) + solver_options: dict = field(default_factory=dict) + include_lifecycle_count: bool = field(default=True) + lifecycle_cost_per_kWh_cycle: float = field(default=0.0265, validator=gte_zero) + max_lifecycle_per_day: int | float = field(default=np.inf, validator=gt_zero) + n_look_ahead_periods: int = field(default=48, validator=gt_zero) + time_weighting_factor: float = field(default=0.995, validator=range_val(0, 1)) + n_roll_periods: int = field(default=24, validator=gt_zero) + log_name: str = field(default="") + use_clustering: bool = field(default=False) + n_clusters: int = field(default=30, validator=gt_zero) + clustering_weights: dict = field(default_factory=dict) + clustering_divisions: dict = field(default_factory=dict) + use_higher_hours: bool = field(default=False) + higher_hours: dict = field(default_factory=dict) diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py new file mode 100644 index 000000000..8f3360345 --- /dev/null +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -0,0 +1,777 @@ +from typing import TYPE_CHECKING + +import numpy as np +import pyomo.environ as pyomo +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig, merge_shared_inputs +from h2integrate.core.validators import range_val +from h2integrate.control.control_strategies.controller_baseclass import ControllerBaseClass + + +if TYPE_CHECKING: # to avoid circular imports + pass + + +@define +class PyomoControllerBaseConfig(BaseConfig): + """ + Configuration data container for Pyomo-based storage / dispatch controllers. + + This class groups the fundamental parameters needed by derived controller + implementations. Values are typically populated from the technology + `tech_config.yaml` (merged under the "control" section). + + Attributes: + max_capacity (float): + Physical maximum stored commodity capacity (inventory, not a rate). + Units correspond to the base commodity units (e.g., kg, MWh). + max_charge_percent (float): + Upper bound on state of charge expressed as a fraction in [0, 1]. + 1.0 means the controller may fill to max_capacity. + min_charge_percent (float): + Lower bound on state of charge expressed as a fraction in [0, 1]. + 0.0 allows full depletion; >0 reserves minimum inventory. + init_charge_percent (float): + Initial state of charge at simulation start as a fraction in [0, 1]. + n_control_window (int): + Number of consecutive timesteps processed per control action + (rolling control / dispatch window length). + n_horizon_window (int): + Number of timesteps considered for look ahead / optimization horizon. + May be >= n_control_window (used by predictive strategies). + commodity_name (str): + Base name of the controlled commodity (e.g., "hydrogen", "electricity"). + Used to construct input/output variable names (e.g., f"{commodity_name}_in"). + commodity_storage_units (str): + Units string for stored commodity rates (e.g., "kg/h", "MW"). + Used for unit annotations when creating model variables. + tech_name (str): + Technology identifier used to namespace Pyomo blocks / variables within + the broader OpenMDAO model (e.g., "battery", "h2_storage"). + """ + + max_capacity: float = field() + max_charge_percent: float = field(validator=range_val(0, 1)) + min_charge_percent: float = field(validator=range_val(0, 1)) + init_charge_percent: float = field(validator=range_val(0, 1)) + n_control_window: int = field() + n_horizon_window: int = field() + commodity_name: str = field() + commodity_storage_units: str = field() + tech_name: str = field() + + +def dummy_function(): + return None + + +class PyomoControllerBaseClass(ControllerBaseClass): + def dummy_method(self, in1, in2): + return None + + def setup(self): + # import pdb; pdb.set_trace() + # get technology group name + # TODO: Make this more general, right now it might go astray if for example "battery" is + # used twice in an OpenMDAO subsystem pathname + self.tech_group_name = self.pathname.split(".") + + # create inputs for all pyomo object creation functions from all connected technologies + self.dispatch_connections = self.options["plant_config"]["tech_to_dispatch_connections"] + for connection in self.dispatch_connections: + # get connection definition + source_tech, intended_dispatch_tech = connection + if any(intended_dispatch_tech in name for name in self.tech_group_name): + if source_tech == intended_dispatch_tech: + # When getting rules for the same tech, the tech name is not used in order to + # allow for automatic connections rather than complicating the h2i model set up + self.add_discrete_input("dispatch_block_rule_function", val=self.dummy_method) + else: + self.add_discrete_input( + f"{'dispatch_block_rule_function'}_{source_tech}", val=self.dummy_method + ) + else: + continue + + # create output for the pyomo control model + self.add_discrete_output( + "pyomo_dispatch_solver", + val=dummy_function, + desc="callable: fully formed pyomo model and execution logic to be run \ + by owning technologies performance model", + ) + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + discrete_outputs["pyomo_dispatch_solver"] = self.pyomo_setup(discrete_inputs) + + def pyomo_setup(self, discrete_inputs): + # initialize the pyomo model + self.pyomo_model = pyomo.ConcreteModel() + + index_set = pyomo.Set(initialize=range(self.config.n_control_window)) + + # run each pyomo rule set up function for each technology + for connection in self.dispatch_connections: + # get connection definition + source_tech, intended_dispatch_tech = connection + # only add connections to intended dispatch tech + if any(intended_dispatch_tech in name for name in self.tech_group_name): + # names are specified differently if connecting within the tech group vs + # connecting from an external tech group. This facilitates OM connections + if source_tech == intended_dispatch_tech: + dispatch_block_rule_function = discrete_inputs["dispatch_block_rule_function"] + else: + dispatch_block_rule_function = discrete_inputs[ + f"{'dispatch_block_rule_function'}_{source_tech}" + ] + # create pyomo block and set attr + blocks = pyomo.Block(index_set, rule=dispatch_block_rule_function) + setattr(self.pyomo_model, source_tech, blocks) + else: + continue + + # define dispatch solver + def pyomo_dispatch_solver( + performance_model: callable, + performance_model_kwargs, + inputs, + pyomo_model=self.pyomo_model, + ): + self.initialize_parameters() + + # initialize outputs + unmet_demand = np.zeros(self.n_timesteps) + storage_commodity_out = np.zeros(self.n_timesteps) + total_commodity_out = np.zeros(self.n_timesteps) + excess_commodity = np.zeros(self.n_timesteps) + soc = np.zeros(self.n_timesteps) + + ti = list(range(0, self.n_timesteps, self.config.n_control_window)) + control_strategy = self.options["tech_config"]["control_strategy"]["model"] + + for t in ti: + self.update_time_series_parameters() + + commodity_in = inputs[self.config.commodity_name + "_in"][ + t : t + self.config.n_control_window + ] + demand_in = inputs["demand_in"][t : t + self.config.n_control_window] + + if "heuristic" in control_strategy: + self.set_fixed_dispatch( + commodity_in, + self.config.max_charge_rate, + self.config.max_discharge_rate, + demand_in, + ) + + else: + raise ( + NotImplementedError( + f"Control strategy '{control_strategy}' was given, \ + but has not been implemented yet." + ) + ) + # TODO: implement optimized solutions; this is where pyomo_model would be used + + storage_commodity_out_control_window, soc_control_window = performance_model( + self.storage_dispatch_commands, + **performance_model_kwargs, + sim_start_index=t, + ) + + # store output values for every timestep + tj = list(range(t, t + self.config.n_control_window)) + for j in tj: + storage_commodity_out[j] = storage_commodity_out_control_window[j - t] + soc[j] = soc_control_window[j - t] + total_commodity_out[j] = np.minimum( + demand_in[j - t], storage_commodity_out[j] + commodity_in[j - t] + ) + + unmet_demand[j] = np.maximum(0, demand_in[j - t] - total_commodity_out[j]) + excess_commodity[j] = np.maximum( + 0, storage_commodity_out[j] + commodity_in[j - t] - demand_in[j - t] + ) + + return total_commodity_out, storage_commodity_out, unmet_demand, excess_commodity, soc + + return pyomo_dispatch_solver + + @staticmethod + def dispatch_block_rule(block, t): + raise NotImplementedError("This function must be overridden for specific dispatch model") + + def initialize_parameters(self): + raise NotImplementedError("This function must be overridden for specific dispatch model") + + def update_time_series_parameters(self, start_time: int): + raise NotImplementedError("This function must be overridden for specific dispatch model") + + @staticmethod + def _check_efficiency_value(efficiency): + """Checks efficiency is between 0 and 1. Returns fractional value""" + if efficiency < 0: + raise ValueError("Efficiency value must greater than 0") + elif efficiency > 1: + raise ValueError("Efficiency value must between 0 and 1") + return efficiency + + @property + def blocks(self) -> pyomo.Block: + return getattr(self.pyomo_model, self.config.tech_name) + + @property + def model(self) -> pyomo.ConcreteModel: + return self._model + + +@define +class PyomoControllerH2StorageConfig(PyomoControllerBaseConfig): + """ + Configuration class for the PyomoControllerH2Storage. + + This class defines the parameters required to configure the `PyomoControllerH2Storage`. + + Attributes: + max_charge_rate (float): Maximum rate at which the commodity can be charged (in units + per time step, e.g., "kg/time step"). + max_discharge_rate (float): Maximum rate at which the commodity can be discharged (in + units per time step, e.g., "kg/time step"). + charge_efficiency (float): Efficiency of charging the storage, represented as a decimal + between 0 and 1 (e.g., 0.9 for 90% efficiency). + discharge_efficiency (float): Efficiency of discharging the storage, represented as a + decimal between 0 and 1 (e.g., 0.9 for 90% efficiency). + """ + + max_charge_rate: float = field() + max_discharge_rate: float = field() + charge_efficiency: float = field() + discharge_efficiency: float = field() + + +class PyomoControllerH2Storage(PyomoControllerBaseClass): + def setup(self): + self.config = PyomoControllerH2StorageConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "control") + ) + super().setup() + + +class SimpleBatteryControllerHeuristic(PyomoControllerBaseClass): + """Fixes battery dispatch operations based on user input. + + Currently, enforces available generation and grid limit assuming no battery charging from grid. + + """ + + def setup(self): + """Initialize SimpleBatteryControllerHeuristic. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model (PySAMBatteryModel.BatteryStateful): Battery system model. + fixed_dispatch (Optional[List], optional): List of normalized values [-1, 1] + (Charging (-), Discharging (+)). Defaults to None. + block_set_name (str, optional): Name of block set. Defaults to 'heuristic_battery'. + control_options (dict, optional): Dispatch options. Defaults to None. + + """ + super().setup() + + # self._create_soc_linking_constraint() + + # TODO: implement and test lifecycle counting + # TODO: we could remove this option and just have lifecycle count default + # self.control_options = control_options + # if self.control_options.include_lifecycle_count: + # self._create_lifecycle_model() + # if self.control_options.max_lifecycle_per_day < np.inf: + # self._create_lifecycle_count_constraint() + + self.round_digits = 4 + + self.max_charge_fraction = [0.0] * self.config.n_control_window + self.max_discharge_fraction = [0.0] * self.config.n_control_window + self._fixed_dispatch = [0.0] * self.config.n_control_window + + # TODO: should I enforce either a day schedule or a year schedule year and save it as + # user input? Additionally, Should I drop it as input in the init function? + # if fixed_dispatch is not None: + # self.user_fixed_dispatch = fixed_dispatch + + def initialize_parameters(self): + """Initializes parameters.""" + # TODO: implement and test lifecycle counting + # if self.config.include_lifecycle_count: + # self.lifecycle_cost = ( + # self.options.lifecycle_cost_per_kWh_cycle + # * self._system_model.value("nominal_energy") + # ) + + # self.cost_per_charge = self._financial_model.value("om_batt_variable_cost")[ + # 0 + # ] # [$/MWh] + # self.cost_per_discharge = self._financial_model.value("om_batt_variable_cost")[ + # 0 + # ] # [$/MWh] + self.minimum_storage = 0.0 + self.maximum_storage = self.config.max_capacity + self.minimum_soc = self.config.min_charge_percent + self.maximum_soc = self.config.max_charge_percent + self.initial_soc = self.config.init_charge_percent + + # def _create_soc_linking_constraint(self): + # """Creates state-of-charge linking constraint.""" + # ################################## + # # Parameters # + # ################################## + # # self.model.initial_soc = pyomo.Param( + # # doc=self.block_set_name + " initial state-of-charge at beginning of the horizon[-]", + # # within=pyomo.PercentFraction, + # # default=0.5, + # # mutable=True, + # # units=u.dimensionless, + # # ) + # ################################## + # # Constraints # + # ################################## + + # # Linking time periods together + # def storage_soc_linking_rule(m, t): + # if t == self.blocks.index_set().first(): + # return self.blocks[t].soc0 == self.model.initial_soc + # return self.blocks[t].soc0 == self.blocks[t - 1].soc + + # self.model.soc_linking = pyomo.Constraint( + # self.blocks.index_set(), + # doc=self.block_set_name + " state-of-charge block linking constraint", + # rule=storage_soc_linking_rule, + # ) + + def update_time_series_parameters(self, start_time: int = 0): + """Updates time series parameters. + + Args: + start_time (int): The start time. + + """ + # TODO: provide more control; currently don't use `start_time` + self.time_duration = [1.0] * len(self.blocks.index_set()) + + def update_dispatch_initial_soc(self, initial_soc: float | None = None): + """Updates dispatch initial state of charge (SOC). + + Args: + initial_soc (float, optional): Initial state of charge. Defaults to None. + + """ + if initial_soc is not None: + self._system_model.value("initial_SOC", initial_soc) + self._system_model.setup() # TODO: Do I need to re-setup stateful battery? + self.initial_soc = self._system_model.value("SOC") + + def set_fixed_dispatch( + self, + commodity_in: list, + max_charge_rate: list, + max_discharge_rate: list, + ): + """Sets charge and discharge amount of storage dispatch using fixed_dispatch attribute + and enforces available generation and charge/discharge limits. + + Args: + commodity_in (list): commodity blocks. + max_charge_rate (list): Max charge capacity. + max_discharge_rate (list): Max discharge capacity. + + Raises: + ValueError: If commodity_in or max_charge_rate or max_discharge_rate length does not + match fixed_dispatch length. + + """ + self.check_commodity_in_discharge_limit(commodity_in, max_charge_rate, max_discharge_rate) + self._set_commodity_fraction_limits(commodity_in, max_charge_rate, max_discharge_rate) + self._heuristic_method(commodity_in) + self._fix_dispatch_model_variables() + + def check_commodity_in_discharge_limit( + self, commodity_in: list, max_charge_rate: list, max_discharge_rate: list + ): + """Checks if commodity in and discharge limit lengths match fixed_dispatch length. + + Args: + commodity_in (list): commodity blocks. + max_charge_rate (list): Maximum charge capacity. + max_discharge_rate (list): Maximum discharge capacity. + + Raises: + ValueError: If gen or max_discharge_rate length does not match fixed_dispatch length. + + """ + if len(commodity_in) != len(self.fixed_dispatch): + raise ValueError("gen must be the same length as fixed_dispatch.") + elif len(max_charge_rate) != len(self.fixed_dispatch): + raise ValueError("max_charge_rate must be the same length as fixed_dispatch.") + elif len(max_discharge_rate) != len(self.fixed_dispatch): + raise ValueError("max_discharge_rate must be the same length as fixed_dispatch.") + + def _set_commodity_fraction_limits( + self, commodity_in: list, max_charge_rate: list, max_discharge_rate: list + ): + """Set storage charge and discharge fraction limits based on + available generation and grid capacity, respectively. + + Args: + commodity_in (list): commodity blocks. + max_charge_rate (list): Maximum charge capacity. + max_discharge_rate (list): Maximum discharge capacity. + + NOTE: This method assumes that storage cannot be charged by the grid. + + """ + for t in self.blocks.index_set(): + self.max_charge_fraction[t] = self.enforce_power_fraction_simple_bounds( + (max_charge_rate[t] - commodity_in[t]) / self.maximum_storage + ) + self.max_discharge_fraction[t] = self.enforce_power_fraction_simple_bounds( + (max_discharge_rate[t] - commodity_in[t]) / self.maximum_storage + ) + + @staticmethod + def enforce_power_fraction_simple_bounds(storage_fraction: float) -> float: + """Enforces simple bounds (0, .9) for battery power fractions. + + Args: + storage_fraction (float): Storage fraction from heuristic method. + + Returns: + storage_fraction (float): Bounded storage fraction. + + """ + if storage_fraction > 0.9: + storage_fraction = 0.9 + elif storage_fraction < 0.0: + storage_fraction = 0.0 + return storage_fraction + + def update_soc(self, storage_fraction: float, soc0: float) -> float: + """Updates SOC based on storage fraction threshold (0.1). + + Args: + storage_fraction (float): Storage fraction from heuristic method. Below threshold + is charging, above is discharging. + soc0 (float): Initial SOC. + + Returns: + soc (float): Updated SOC. + + """ + if storage_fraction > 0.0: + discharge_commodity = storage_fraction * self.maximum_storage + soc = ( + soc0 + - self.time_duration[0] + * (1 / (self.discharge_efficiency) * discharge_commodity) + / self.maximum_storage + ) + elif storage_fraction < 0.0: + charge_commodity = -storage_fraction * self.maximum_storage + soc = ( + soc0 + + self.time_duration[0] + * (self.charge_efficiency * charge_commodity) + / self.maximum_storage + ) + else: + soc = soc0 + + return max(self.minimum_soc, min(self.maximum_soc, soc)) + + def _heuristic_method(self, _): + """Executes specific heuristic method to fix storage dispatch.""" + self._enforce_power_fraction_limits() + + def _enforce_power_fraction_limits(self): + """Enforces storage fraction limits and sets _fixed_dispatch attribute.""" + for t in self.blocks.index_set(): + fd = self.user_fixed_dispatch[t] + if fd > 0.0: # Discharging + if fd > self.max_discharge_fraction[t]: + fd = self.max_discharge_fraction[t] + elif fd < 0.0: # Charging + if -fd > self.max_charge_fraction[t]: + fd = -self.max_charge_fraction[t] + self._fixed_dispatch[t] = fd + + def _fix_dispatch_model_variables(self): + """Fixes dispatch model variables based on the fixed dispatch values.""" + soc0 = self.pyomo_model.initial_soc + for t in self.blocks.index_set(): + dispatch_factor = self._fixed_dispatch[t] + self.blocks[t].soc.fix(self.update_soc(dispatch_factor, soc0)) + soc0 = self.blocks[t].soc.value + + if dispatch_factor == 0.0: + # Do nothing + self.blocks[t].charge_commodity.fix(0.0) + self.blocks[t].discharge_commodity.fix(0.0) + elif dispatch_factor > 0.0: + # Discharging + self.blocks[t].charge_commodity.fix(0.0) + self.blocks[t].discharge_commodity.fix(dispatch_factor * self.maximum_storage) + elif dispatch_factor < 0.0: + # Charging + self.blocks[t].discharge_commodity.fix(0.0) + self.blocks[t].charge_commodity.fix(-dispatch_factor * self.maximum_storage) + + def _check_initial_soc(self, initial_soc): + """Checks initial state-of-charge. + + Args: + initial_soc: Initial state-of-charge value. + + Returns: + float: Checked initial state-of-charge. + + """ + initial_soc = round(initial_soc, self.round_digits) + if initial_soc > self.maximum_soc: + print( + "Warning: Storage dispatch was initialized with a state-of-charge greater than " + "maximum value!" + ) + print(f"Initial SOC = {initial_soc}") + print("Initial SOC was set to maximum value.") + initial_soc = self.maximum_soc + elif initial_soc < self.minimum_soc: + print( + "Warning: Storage dispatch was initialized with a state-of-charge less than " + "minimum value!" + ) + print(f"Initial SOC = {initial_soc}") + print("Initial SOC was set to minimum value.") + initial_soc = self.minimum_soc + return initial_soc + + @property + def fixed_dispatch(self) -> list: + """list: List of fixed dispatch.""" + return self._fixed_dispatch + + @property + def user_fixed_dispatch(self) -> list: + """list: List of user fixed dispatch.""" + return self._user_fixed_dispatch + + @user_fixed_dispatch.setter + def user_fixed_dispatch(self, fixed_dispatch: list): + # TODO: Annual dispatch array... + if len(fixed_dispatch) != len(self.blocks.index_set()): + raise ValueError("fixed_dispatch must be the same length as dispatch index set.") + elif max(fixed_dispatch) > 1.0 or min(fixed_dispatch) < -1.0: + raise ValueError("fixed_dispatch must be normalized values between -1 and 1.") + else: + self._user_fixed_dispatch = fixed_dispatch + + @property + def storage_dispatch_commands(self) -> list: + """ + Commanded dispatch including available commodity at current time step that has not + been used to charge the battery. + """ + return [ + (self.blocks[t].discharge_commodity.value - self.blocks[t].charge_commodity.value) + for t in self.blocks.index_set() + ] + + # @property + # def current(self) -> list: + # """Current.""" + # return [0.0 for t in self.blocks.index_set()] + + # @property + # def generation(self) -> list: + # """Generation.""" + # return self.storage_dispatch_commands + + @property + def soc(self) -> list: + """State-of-charge.""" + return [self.blocks[t].soc.value for t in self.blocks.index_set()] + + @property + def charge_commodity(self) -> list: + """Charge commodity.""" + return [self.blocks[t].charge_commodity.value for t in self.blocks.index_set()] + + @property + def discharge_commodity(self) -> list: + """Discharge commodity.""" + return [self.blocks[t].discharge_commodity.value for t in self.blocks.index_set()] + + @property + def initial_soc(self) -> float: + """Initial state-of-charge.""" + return self.pyomo_model.initial_soc.value + + @initial_soc.setter + def initial_soc(self, initial_soc: float): + initial_soc = self._check_initial_soc(initial_soc) + self.pyomo_model.initial_soc = round(initial_soc, self.round_digits) + + @property + def minimum_soc(self) -> float: + """Minimum state-of-charge.""" + for t in self.blocks.index_set(): + return self.blocks[t].minimum_soc.value + + @minimum_soc.setter + def minimum_soc(self, minimum_soc: float): + for t in self.blocks.index_set(): + self.blocks[t].minimum_soc = round(minimum_soc, self.round_digits) + + @property + def maximum_soc(self) -> float: + """Maximum state-of-charge.""" + for t in self.blocks.index_set(): + return self.blocks[t].maximum_soc.value + + @maximum_soc.setter + def maximum_soc(self, maximum_soc: float): + for t in self.blocks.index_set(): + self.blocks[t].maximum_soc = round(maximum_soc, self.round_digits) + + @property + def charge_efficiency(self) -> float: + """Charge efficiency.""" + for t in self.blocks.index_set(): + return self.blocks[t].charge_efficiency.value + + @charge_efficiency.setter + def charge_efficiency(self, efficiency: float): + efficiency = self._check_efficiency_value(efficiency) + for t in self.blocks.index_set(): + self.blocks[t].charge_efficiency = round(efficiency, self.round_digits) + + @property + def discharge_efficiency(self) -> float: + """Discharge efficiency.""" + for t in self.blocks.index_set(): + return self.blocks[t].discharge_efficiency.value + + @discharge_efficiency.setter + def discharge_efficiency(self, efficiency: float): + efficiency = self._check_efficiency_value(efficiency) + for t in self.blocks.index_set(): + self.blocks[t].discharge_efficiency = round(efficiency, self.round_digits) + + @property + def round_trip_efficiency(self) -> float: + """Round trip efficiency.""" + return self.charge_efficiency * self.discharge_efficiency + + @round_trip_efficiency.setter + def round_trip_efficiency(self, round_trip_efficiency: float): + round_trip_efficiency = self._check_efficiency_value(round_trip_efficiency) + # Assumes equal charge and discharge efficiencies + efficiency = round_trip_efficiency ** (1 / 2) + self.charge_efficiency = efficiency + self.discharge_efficiency = efficiency + + +@define +class HeuristicLoadFollowingControllerConfig(PyomoControllerBaseConfig): + rated_commodity_capacity: int = field() + max_discharge_rate: float = field(default=1e12) + max_charge_rate: float = field(default=1e12) + charge_efficiency: float = field(default=None) + discharge_efficiency: float = field(default=None) + include_lifecycle_count: bool = field(default=False) + + def __attrs_post_init__(self): + # TODO: Is this the best way to handle scalar charge/discharge rates? + if isinstance(self.max_charge_rate, (float, int)): + self.max_charge_rate = [self.max_charge_rate] * self.n_control_window + if isinstance(self.max_discharge_rate, (float, int)): + self.max_discharge_rate = [self.max_discharge_rate] * self.n_control_window + + +class HeuristicLoadFollowingController(SimpleBatteryControllerHeuristic): + """Operates the battery based on heuristic rules to meet the demand profile based power + available from power generation profiles and power demand profile. + + Currently, enforces available generation and grid limit assuming no battery charging from grid. + + """ + + def setup(self): + """Initialize HeuristicLoadFollowingController. + + Args: + pyomo_model (pyomo.ConcreteModel): Pyomo concrete model. + index_set (pyomo.Set): Indexed set. + system_model (PySAMBatteryModel.BatteryStateful): System model. + fixed_dispatch (Optional[List], optional): List of normalized values [-1, 1] + (Charging (-), Discharging (+)). Defaults to None. + block_set_name (str, optional): Name of the block set. Defaults to + 'heuristic_load_following_battery'. + control_options (Optional[dict], optional): Dispatch options. Defaults to None. + + """ + self.config = HeuristicLoadFollowingControllerConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "control") + ) + + self.n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + + super().setup() + + if self.config.charge_efficiency is not None: + self.charge_efficiency = self.config.charge_efficiency + if self.config.discharge_efficiency is not None: + self.discharge_efficiency = self.config.discharge_efficiency + + def set_fixed_dispatch( + self, + commodity_in: list, + max_charge_rate: list, + max_discharge_rate: list, + commodity_demand: list, + ): + """Sets charge and discharge power of battery dispatch using fixed_dispatch attribute + and enforces available generation and charge/discharge limits. + + Args: + commodity_in (list): List of generated commodity in. + max_charge_rate (list): List of max charge rates. + max_discharge_rate (list): List of max discharge rates. + commodity_demand (list): The demanded commodity. + + """ + + self.check_commodity_in_discharge_limit(commodity_in, max_charge_rate, max_discharge_rate) + self._set_commodity_fraction_limits(commodity_in, max_charge_rate, max_discharge_rate) + self._heuristic_method(commodity_in, commodity_demand) + self._fix_dispatch_model_variables() + + def _heuristic_method(self, commodity_in, goal_commodity): + """Enforces storage fraction limits and sets _fixed_dispatch attribute. + Sets the _fixed_dispatch based on goal_commodity and gen. + + Args: + generated_commodity: commodity generation profile. + goal_commodity: Goal amount of commodity. + + """ + for t in self.blocks.index_set(): + fd = (goal_commodity[t] - commodity_in[t]) / self.maximum_storage + if fd > 0.0: # Discharging + if fd > self.max_discharge_fraction[t]: + fd = self.max_discharge_fraction[t] + elif fd < 0.0: # Charging + if -fd > self.max_charge_fraction[t]: + fd = -self.max_charge_fraction[t] + self._fixed_dispatch[t] = fd From df63126a77b6cbc2d5129742e4bec82779ecea6e Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 9 Oct 2025 12:13:49 -0600 Subject: [PATCH 02/37] include storage rule file --- .../storage/pyomo_storage_rule_baseclass.py | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py diff --git a/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py b/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py new file mode 100644 index 000000000..01decd730 --- /dev/null +++ b/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py @@ -0,0 +1,236 @@ +import pyomo.environ as pyo +from pyomo.network import Port + +from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseClass + + +class PyomoRuleStorageBaseclass(PyomoRuleBaseClass): + def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): + """Creates storage parameters. + + Args: + storage: Storage instance. + + """ + ################################## + # Storage Parameters # + ################################## + pyomo_model.time_duration = pyo.Param( + doc=pyomo_model.name + " time step [hour]", + default=1.0, + within=pyo.NonNegativeReals, + mutable=True, + units=pyo.units.hr, + ) + # pyomo_model.cost_per_charge = pyo.Param( + # doc="Operating cost of " + self.block_set_name + " charging [$/MWh]", + # default=0.0, + # within=pyo.NonNegativeReals, + # mutable=True, + # units=pyo.units.USD / pyo.units.MWh, + # ) + # pyomo_model.cost_per_discharge = pyo.Param( + # doc="Operating cost of " + self.block_set_name + " discharging [$/MWh]", + # default=0.0, + # within=pyo.NonNegativeReals, + # mutable=True, + # units=pyo.units.USD / pyo.units.MWh, + # ) + pyomo_model.minimum_storage = pyo.Param( + doc=pyomo_model.name + + " minimum storage rating [" + + self.config.commodity_storage_units + + "]", + default=0.0, + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.maximum_storage = pyo.Param( + doc=pyomo_model.name + + " maximum storage rating [" + + self.config.commodity_storage_units + + "]", + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.minimum_soc = pyo.Param( + doc=pyomo_model.name + " minimum state-of-charge [-]", + default=0.1, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + pyomo_model.maximum_soc = pyo.Param( + doc=pyomo_model.name + " maximum state-of-charge [-]", + default=0.9, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + + ################################## + # Efficiency Parameters # + ################################## + pyomo_model.charge_efficiency = pyo.Param( + doc=pyomo_model.name + " Charging efficiency [-]", + default=0.938, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + pyomo_model.discharge_efficiency = pyo.Param( + doc=pyomo_model.name + " discharging efficiency [-]", + default=0.938, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + ################################## + # Capacity Parameters # + ################################## + + pyomo_model.capacity = pyo.Param( + doc=pyomo_model.name + " capacity [" + self.config.commodity_storage_units + "]", + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + + def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): + """Creates storage variables. + + Args: + storage: Storage instance. + + """ + ################################## + # Variables # + ################################## + pyomo_model.is_charging = pyo.Var( + doc="1 if " + pyomo_model.name + " is charging; 0 Otherwise [-]", + domain=pyo.Binary, + units=pyo.units.dimensionless, + ) + pyomo_model.is_discharging = pyo.Var( + doc="1 if " + pyomo_model.name + " is discharging; 0 Otherwise [-]", + domain=pyo.Binary, + units=pyo.units.dimensionless, + ) + pyomo_model.soc0 = pyo.Var( + doc=pyomo_model.name + " initial state-of-charge at beginning of period[-]", + domain=pyo.PercentFraction, + bounds=(pyomo_model.minimum_soc, pyomo_model.maximum_soc), + units=pyo.units.dimensionless, + ) + pyomo_model.soc = pyo.Var( + doc=pyomo_model.name + " state-of-charge at end of period [-]", + domain=pyo.PercentFraction, + bounds=(pyomo_model.minimum_soc, pyomo_model.maximum_soc), + units=pyo.units.dimensionless, + ) + pyomo_model.charge_commodity = pyo.Var( + doc=self.config.commodity_name + + " into " + + pyomo_model.name + + " [" + + self.config.commodity_storage_units + + "]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.discharge_commodity = pyo.Var( + doc=self.config.commodity_name + + " out of " + + pyomo_model.name + + " [" + + self.config.commodity_storage_units + + "]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + + def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): + ################################## + # Charging Constraints # + ################################## + # Charge commodity bounds + pyomo_model.charge_commodity_ub = pyo.Constraint( + doc=pyomo_model.name + " charging storage upper bound", + expr=pyomo_model.charge_commodity + <= pyomo_model.maximum_storage * pyomo_model.is_charging, + ) + pyomo_model.charge_commodity_lb = pyo.Constraint( + doc=pyomo_model.name + " charging storage lower bound", + expr=pyomo_model.charge_commodity + >= pyomo_model.minimum_storage * pyomo_model.is_charging, + ) + # Discharge commodity bounds + pyomo_model.discharge_commodity_lb = pyo.Constraint( + doc=pyomo_model.name + " Discharging storage lower bound", + expr=pyomo_model.discharge_commodity + >= pyomo_model.minimum_storage * pyomo_model.is_discharging, + ) + pyomo_model.discharge_commodity_ub = pyo.Constraint( + doc=pyomo_model.name + " Discharging storage upper bound", + expr=pyomo_model.discharge_commodity + <= pyomo_model.maximum_storage * pyomo_model.is_discharging, + ) + # Storage packing constraint + pyomo_model.charge_discharge_packing = pyo.Constraint( + doc=pyomo_model.name + " packing constraint for charging and discharging binaries", + expr=pyomo_model.is_charging + pyomo_model.is_discharging <= 1, + ) + + ################################## + # SOC Inventory Constraints # + ################################## + + def soc_inventory_rule(m): + return m.soc == ( + m.soc0 + + m.time_duration + * ( + m.charge_efficiency * m.charge_commodity + - (1 / m.discharge_efficiency) * m.discharge_commodity + ) + / m.capacity + ) + + # Storage State-of-charge balance + pyomo_model.soc_inventory = pyo.Constraint( + doc=pyomo_model.name + " state-of-charge inventory balance", + rule=soc_inventory_rule, + ) + + ################################## + # SOC Linking Constraints # + ################################## + + # TODO: Make work for pyomo optimization, not needed for heuristic method + # # Linking time periods together + # def storage_soc_linking_rule(m, t): + # if t == m.blocks.index_set().first(): + # return m.blocks[t].soc0 == m.initial_soc + # return m.blocks[t].soc0 == self.blocks[t - 1].soc + + # pyomo_model.soc_linking = pyo.Constraint( + # pyomo_model.blocks.index_set(), + # doc=self.block_set_name + " state-of-charge block linking constraint", + # rule=storage_soc_linking_rule, + # ) + + def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): + """Creates storage port. + + Args: + pyomo_model: Pyomo storage instance. + + """ + ################################## + # Ports # + ################################## + pyomo_model.port = Port() + pyomo_model.port.add(pyomo_model.charge_commodity) + pyomo_model.port.add(pyomo_model.discharge_commodity) From 6971220546dc05fee0243db7e13791188009fd07 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 2 Dec 2025 17:03:40 -0500 Subject: [PATCH 03/37] Halfway there for pyomo opt --- .../driver_config.yaml | 5 + .../plant_config.yaml | 72 +++ .../pyomo_heuristic_dispatch.yaml | 7 + ..._heuristic_dispatch_error_for_testing.yaml | 7 + .../pysam_options_8300MW.yaml | 413 ++++++++++++++++++ .../run_pyomo_heuristic_dispatch.py | 78 ++++ .../tech_config.yaml | 74 ++++ .../tech_config_error_for_testing.yaml | 73 ++++ .../converters/generic_converter_opt.py | 154 +++++++ .../control_rules/pyomo_control_options.py | 2 +- .../pyomo_storage_rule_min_operating_cost.py | 286 ++++++++++++ .../controller_opt_problem_state.py | 149 +++++++ .../control_strategies/pyomo_controllers.py | 268 +++++++++++- h2integrate/core/supported_models.py | 14 + 14 files changed, 1600 insertions(+), 2 deletions(-) create mode 100644 examples/25_pyomo_optimized_dispatch/driver_config.yaml create mode 100644 examples/25_pyomo_optimized_dispatch/plant_config.yaml create mode 100644 examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch.yaml create mode 100644 examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml create mode 100644 examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml create mode 100644 examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py create mode 100644 examples/25_pyomo_optimized_dispatch/tech_config.yaml create mode 100644 examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml create mode 100644 h2integrate/control/control_rules/converters/generic_converter_opt.py create mode 100644 h2integrate/control/control_rules/storage/pyomo_storage_rule_min_operating_cost.py create mode 100644 h2integrate/control/control_strategies/controller_opt_problem_state.py diff --git a/examples/25_pyomo_optimized_dispatch/driver_config.yaml b/examples/25_pyomo_optimized_dispatch/driver_config.yaml new file mode 100644 index 000000000..72d2e368a --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/driver_config.yaml @@ -0,0 +1,5 @@ +name: "driver_config" +description: "This analysis runs a hybrid plant to meet an electrical load." + +general: + folder_output: outputs diff --git a/examples/25_pyomo_optimized_dispatch/plant_config.yaml b/examples/25_pyomo_optimized_dispatch/plant_config.yaml new file mode 100644 index 000000000..b7c2897f9 --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/plant_config.yaml @@ -0,0 +1,72 @@ +name: "plant_config" +description: "This plant is located in TX, USA..." + +site: + latitude: 35.2018863 + longitude: -101.945027 + + resources: + wind_resource: + resource_model: "wind_toolkit_v2_api" + resource_parameters: + resource_year: 2012 + +plant: + plant_life: 30 + +# 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: [ + ["wind", "battery", "electricity", "cable"], +] + +# array of arrays containing left-to-right technology, technology doing the dispatching +# in this case, battery is connected to battery because there are controls rules for +# the battery and battery is controlling the dispatching +tech_to_dispatch_connections: [ + ["wind", "battery"], + ["battery", "battery"], +] + +resource_to_tech_connections: [ + # connect the wind resource to the wind technology + ['wind_resource', 'wind', 'wind_resource_data'], +] + +finance_parameters: + finance_groups: + commodity: "electricity" + finance_model: "ProFastComp" + 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 # p-tax https://www.house.mn.gov/hrd/issinfo/clsrates.aspx # insurance percent of CAPEX estimated based on https://www.nrel.gov/docs/fy25osti/91775.pdf + 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" - MACRS may be better and can reduce LCOH by more than $1/kg and is spec'd in the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + depr_period: 5 # years - for clean energy facilities as specified by the IRS MACRS schedule https://www.irs.gov/publications/p946#en_US_2020_publink1000107507 + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 + finance_subgroups: + all_electricity: + commodity: "electricity" + commodity_stream: "wind" # use all electricity generated from wind in finance calc + technologies: ["wind", "battery"] + dispatched_electricity: + commodity: "electricity" + commodity_stream: "battery" #use only dispatched electricity from battery in finance calc + technologies: ["wind", "battery"] diff --git a/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch.yaml b/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch.yaml new file mode 100644 index 000000000..af7329de0 --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch.yaml @@ -0,0 +1,7 @@ +name: "H2Integrate_config" + +system_summary: "This hybrid plant contains wind and battery storage technologies. The system is designed to meet a specific electrical load." + +driver_config: "driver_config.yaml" +technology_config: "tech_config.yaml" +plant_config: "plant_config.yaml" diff --git a/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml b/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml new file mode 100644 index 000000000..2166eb61d --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml @@ -0,0 +1,7 @@ +name: "H2Integrate_config" + +system_summary: "This hybrid plant contains wind and battery storage technologies. The system is designed to meet a specific electrical load." + +driver_config: "driver_config.yaml" +technology_config: "tech_config_error_for_testing.yaml" +plant_config: "plant_config.yaml" diff --git a/examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml b/examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml new file mode 100644 index 000000000..558b24cfb --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml @@ -0,0 +1,413 @@ +Turbine: + wind_resource_shear: 0.14 + wind_turbine_ct_curve: + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.4337 + - 0.4383 + - 0.4637 + - 0.4951 + - 0.5248 + - 0.5486 + - 0.5646 + - 0.5721 + - 0.5721 + - 0.5691 + - 0.5664 + - 0.566 + - 0.5684 + - 0.5715 + - 0.5738 + - 0.5742 + - 0.5726 + - 0.5704 + - 0.569 + - 0.5695 + - 0.5713 + - 0.5711 + - 0.5658 + - 0.5535 + - 0.5342 + - 0.5106 + - 0.4854 + - 0.4603 + - 0.4363 + - 0.413 + - 0.3898 + - 0.3666 + - 0.3432 + - 0.3205 + - 0.299 + - 0.2792 + - 0.2614 + - 0.2452 + - 0.2304 + - 0.2168 + - 0.2041 + - 0.1923 + - 0.1814 + - 0.1714 + - 0.1621 + - 0.1535 + - 0.1455 + - 0.138 + - 0.1311 + - 0.1246 + - 0.1186 + - 0.1129 + - 0.1076 + - 0.1027 + - 0.098 + - 0.0937 + - 0.0896 + - 0.0857 + - 0.082 + - 0.0786 + - 0.0753 + - 0.0723 + - 0.0694 + - 0.0666 + - 0.064 + - 0.0615 + - 0.0592 + - 0.057 + - 0.0548 + - 0.0528 + - 0.0509 + - 0.0491 + - 0.0474 + - 0.0457 + - 0.0441 + - 0.0426 + - 0.0412 + - 0.0398 + - 0.0385 + - 0.0373 + - 0.0361 + - 0.0349 + - 0.0338 + - 0.0328 + - 0.0317 + - 0.0308 + - 0.0298 + - 0.029 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + wind_turbine_hub_ht: 130.0 + wind_turbine_max_cp: 0.474457866 + wind_turbine_powercurve_powerout: + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 241.0757324 + - 303.7602033 + - 391.3910551 + - 500.8033128 + - 628.8320016 + - 772.3121467 + - 928.0787732 + - 1092.966906 + - 1265.273318 + - 1449.141772 + - 1650.177777 + - 1873.986843 + - 2124.586921 + - 2399.645733 + - 2695.243438 + - 3007.4602 + - 3334.216 + - 3680.790104 + - 4054.301598 + - 4461.869566 + - 4905.067075 + - 5363.283108 + - 5810.360625 + - 6220.142589 + - 6572.96016 + - 6875.097291 + - 7139.326132 + - 7378.418836 + - 7601.367393 + - 7802.043165 + - 7970.537352 + - 8096.941157 + - 8175.407321 + - 8216.334756 + - 8234.183913 + - 8243.415243 + - 8255.567745 + - 8270.494605 + - 8285.127555 + - 8296.398325 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 8300.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + wind_turbine_powercurve_windspeeds: + - 0.0 + - 0.25 + - 0.5 + - 0.75 + - 1.0 + - 1.25 + - 1.5 + - 1.75 + - 2.0 + - 2.25 + - 2.5 + - 2.75 + - 3.0 + - 3.25 + - 3.5 + - 3.75 + - 4.0 + - 4.25 + - 4.5 + - 4.75 + - 5.0 + - 5.25 + - 5.5 + - 5.75 + - 6.0 + - 6.25 + - 6.5 + - 6.75 + - 7.0 + - 7.25 + - 7.5 + - 7.75 + - 8.0 + - 8.25 + - 8.5 + - 8.75 + - 9.0 + - 9.25 + - 9.5 + - 9.75 + - 10.0 + - 10.25 + - 10.5 + - 10.75 + - 11.0 + - 11.25 + - 11.5 + - 11.75 + - 12.0 + - 12.25 + - 12.5 + - 12.75 + - 13.0 + - 13.25 + - 13.5 + - 13.75 + - 14.0 + - 14.25 + - 14.5 + - 14.75 + - 15.0 + - 15.25 + - 15.5 + - 15.75 + - 16.0 + - 16.25 + - 16.5 + - 16.75 + - 17.0 + - 17.25 + - 17.5 + - 17.75 + - 18.0 + - 18.25 + - 18.5 + - 18.75 + - 19.0 + - 19.25 + - 19.5 + - 19.75 + - 20.0 + - 20.25 + - 20.5 + - 20.75 + - 21.0 + - 21.25 + - 21.5 + - 21.75 + - 22.0 + - 22.25 + - 22.5 + - 22.75 + - 23.0 + - 23.25 + - 23.5 + - 23.75 + - 24.0 + - 24.25 + - 24.5 + - 24.75 + - 25.0 + - 26.0 + - 27.0 + - 28.0 + - 29.0 + - 30.0 + - 31.0 + - 32.0 + - 33.0 + - 34.0 + - 35.0 + - 36.0 + - 37.0 + - 38.0 + - 39.0 + - 40.0 + - 41.0 + - 42.0 + - 43.0 + - 44.0 + - 45.0 + - 46.0 + - 47.0 + - 48.0 + - 49.0 +Farm: + wind_farm_wake_model: 0.0 + wind_resource_turbulence_coeff: 0.1 +Losses: + avail_bop_loss: 0.5 + avail_grid_loss: 1.5 + avail_turb_loss: 3.58 + elec_eff_loss: 1.91 + elec_parasitic_loss: 0.1 + env_degrad_loss: 1.8 + env_env_loss: 0.4 + env_exposure_loss: 0.0 + env_icing_loss: 0.21 + ops_env_loss: 1.0 + ops_grid_loss: 0.84 + ops_load_loss: 0.99 + ops_strategies_loss: 0.0 + turb_generic_loss: 1.7 + turb_hysteresis_loss: 0.4 + turb_perf_loss: 1.1 + turb_specific_loss: 9.964851766642457 + wake_ext_loss: 1.1 + wake_future_loss: 0.0 + wake_int_loss: 0.0 +Resource: + weibull_k_factor: 2.0 + weibull_reference_height: 50.0 + weibull_wind_speed: 7.25 + wind_resource_model_choice: 0.0 +Uncertainty: + total_uncert: 12.085 diff --git a/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py b/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py new file mode 100644 index 000000000..642dd03a7 --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py @@ -0,0 +1,78 @@ +import numpy as np +from matplotlib import pyplot as plt + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +# Create an H2Integrate model +model = H2IntegrateModel("pyomo_heuristic_dispatch.yaml") + +demand_profile = np.ones(8760) * 50.0 + + +# TODO: Update with demand module once it is developed +model.setup() +model.prob.set_val("battery.electricity_demand", demand_profile, units="MW") + +# Run the model +model.run() + +# Plot the results +fig, ax = plt.subplots(2, 1, sharex=True) + +start_hour = 0 +end_hour = 200 + +ax[0].plot( + range(start_hour, end_hour), + model.prob.get_val("battery.SOC", units="percent")[start_hour:end_hour], + label="SOC", +) +ax[0].set_ylabel("SOC (%)") +ax[0].set_ylim([0, 110]) +ax[0].axhline(y=90.0, linestyle=":", color="k", alpha=0.5, label="Max Charge") +ax[0].legend() + +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("battery.electricity_in", units="MW")[start_hour:end_hour], + linestyle="-", + label="Electricity In (MW)", +) +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("battery.unused_electricity_out", units="MW")[start_hour:end_hour], + linestyle=":", + label="Unused Electricity (MW)", +) +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("battery.unmet_electricity_demand_out", units="MW")[start_hour:end_hour], + linestyle=":", + label="Unmet Electrical Demand (MW)", +) +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("battery.electricity_out", units="MW")[start_hour:end_hour], + linestyle="-", + label="Electricity Out (MW)", +) +ax[1].plot( + range(start_hour, end_hour), + model.prob.get_val("battery.battery_electricity_discharge", units="MW")[start_hour:end_hour], + linestyle="-.", + label="Battery Electricity Out (MW)", +) +ax[1].plot( + range(start_hour, end_hour), + demand_profile[start_hour:end_hour], + linestyle="--", + label="Eletrical Demand (MW)", +) +ax[1].set_ylim([-7e2, 7e2]) +ax[1].set_ylabel("Electricity Hourly (MW)") +ax[1].set_xlabel("Timestep (hr)") + +plt.legend(ncol=2, frameon=False) +plt.tight_layout() +plt.savefig("plot.png", dpi=300) diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml new file mode 100644 index 000000000..9500fa24a --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -0,0 +1,74 @@ +name: "technology_config" +description: "This hybrid plant produces hydrogen" + +technologies: + wind: + performance_model: + model: "pysam_wind_plant_performance" + cost_model: + model: "atb_wind_cost" + dispatch_rule_set: + model: "pyomo_dispatch_generic_converter_min_operating_cost" + resource: + type: "pysam_wind" + wind_speed: 9. + model_inputs: + performance_parameters: + num_turbines: 100 + turbine_rating_kw: 8300 + rotor_diameter: 196. + hub_height: 130. + create_model_from: "default" + config_name: "WindPowerSingleOwner" + pysam_options: !include pysam_options_8300MW.yaml + run_recalculate_power_curve: False + layout: + layout_mode: "basicgrid" + layout_options: + row_D_spacing: 10.0 + turbine_D_spacing: 10.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: "square" + cost_parameters: + capex_per_kW: 1500.0 + opex_per_kW_per_year: 45 + cost_year: 2019 + dispatch_rule_parameters: + commodity_name: "electricity" + commodity_storage_units: "kW" + battery: + dispatch_rule_set: + model: "pyomo_dispatch_battery_min_operating_cost" + control_strategy: + model: "optimized_dispatch_controller" + performance_model: + model: "pysam_battery" + cost_model: + model: "atb_battery_cost" + model_inputs: + shared_parameters: + commodity_name: "electricity" + max_charge_rate: 100000 + max_capacity: 500000 + n_control_window: 24 + n_horizon_window: 48 + init_charge_percent: 0.5 + max_charge_percent: 0.9 + min_charge_percent: 0.1 + system_commodity_interface_limit: 1e12 + performance_parameters: + system_model_source: "pysam" + chemistry: "LFPGraphite" + cost_parameters: + cost_year: 2022 + commodity_units: "kW" + energy_capex: 310 # $/kWh from 2024 ATB year 2025 + power_capex: 311 # $/kW from 2024 ATB year 2025 + opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB + control_parameters: + commodity_storage_units: "kW" + tech_name: "battery" + dispatch_rule_parameters: + commodity_name: "electricity" + commodity_storage_units: "kW" diff --git a/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml b/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml new file mode 100644 index 000000000..7478bc13d --- /dev/null +++ b/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml @@ -0,0 +1,73 @@ +name: "technology_config" +description: "This hybrid plant produces hydrogen" + +technologies: + wind: + performance_model: + model: "pysam_wind_plant_performance" + cost_model: + model: "atb_wind_cost" + dispatch_rule_set: + model: "pyomo_dispatch_generic_converter" + resource: + type: "pysam_wind" + wind_speed: 9. + model_inputs: + performance_parameters: + num_turbines: 100 + turbine_rating_kw: 8300 + rotor_diameter: 196. + hub_height: 130. + create_model_from: "default" + config_name: "WindPowerSingleOwner" + pysam_options: !include pysam_options_8300MW.yaml + run_recalculate_power_curve: False + layout: + layout_mode: "basicgrid" + layout_options: + row_D_spacing: 10.0 + turbine_D_spacing: 10.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: "square" + cost_parameters: + capex_per_kW: 1500.0 + opex_per_kW_per_year: 45 + cost_year: 2019 + dispatch_rule_parameters: + commodity_name: "electricity" + commodity_storage_units: "kW" + battery: + dispatch_rule_set: + model: "pyomo_dispatch_generic_storage" + control_strategy: + model: "heuristic_load_following_controller" + performance_model: + model: "pysam_battery" + cost_model: + model: "atb_battery_cost" + model_inputs: + shared_parameters: + max_charge_rate: 100000 + max_capacity: 500000 + n_control_window: 24 + n_horizon_window: 48 + init_charge_percent: 0.5 + max_charge_percent: 0.9 + min_charge_percent: 0.1 + system_commodity_interface_limit: 1e12 + performance_parameters: + system_model_source: "pysam" + chemistry: "LFPGraphite" + cost_parameters: + cost_year: 2022 + energy_capex: 310 # $/kWh from 2024 ATB year 2025 + power_capex: 311 # $/kW from 2024 ATB year 2025 + opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB + control_parameters: + commodity_name: "electricity" + commodity_storage_units: "kW" + tech_name: "wrong_tech_name" + dispatch_rule_parameters: + commodity_name: "electricity" + commodity_storage_units: "kW" diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py new file mode 100644 index 000000000..e47ccb808 --- /dev/null +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -0,0 +1,154 @@ +import pyomo.environ as pyo +from pyomo.network import Port +from attrs import field, define + +from h2integrate.control.control_rules.converters.generic_converter import ( + PyomoDispatchGenericConverter +) +from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseConfig + +@define +class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): + """ + Configuration class for the PyomoDispatchGenericConverterMinOperatingCostsConfig. + + This class defines the parameters required to configure the `PyomoRuleBaseConfig`. + + Attributes: + commodity_cost_per_generation (float): cost of the commodity per generation (in $/kWh). + """ + + commodity_cost_per_generation: str = field() + + +class PyomoDispatchGenericConverterMinOperatingCosts(PyomoDispatchGenericConverter): + def setup(self): + self.config = PyomoDispatchGenericConverterMinOperatingCostsConfig.from_dict( + self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] + ) + super().setup() + + def initialize_parameters(self): + """Initialize parameters method.""" + self.cost_per_generation = ( + self.config["commodity_cost_per_generation"] + ) + + def min_operating_cost_objective(self, pyomo_model: pyo.ConcreteModel, hybrid_blocks): + """ Create generice converter objective call to add to Pyomo model instance. + + Args: + pyomo_model (pyo.ConcreteModel): pyomo_model the variables should be added to. + tech_name (str): The name or key identifying the technology for which + variables are created. + """ + self.obj = sum( + pyomo_model.time_weighting_factor[t] + * pyomo_model.time_duration + * pyomo_model.cost_per_generation + * hybrid_blocks[t].generic_generation + for t in hybrid_blocks.index_set() + ) + + def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + """Create generic converter variables to add to Pyomo model instance. + + Args: + pyomo_model (pyo.ConcreteModel): pyomo_model the variables should be added to. + tech_name (str): The name or key identifying the technology for which + variables are created. + + """ + setattr( + pyomo_model, + f"{tech_name}_{self.config.commodity_name}", + pyo.Var( + doc=f"{self.config.commodity_name} generation \ + from {tech_name} [{self.config.commodity_storage_units}]", + domain=pyo.NonNegativeReals, + bounds=(0, pyomo_model.available_generation), + units=eval("pyo.units." + self.config.commodity_storage_units), + initialize=0.0, + ), + ) + + def _create_ports(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + """Create generic converter port to add to pyomo model instance. + + Args: + pyomo_model (pyo.ConcreteModel): pyomo_model the ports should be added to. + tech_name (str): The name or key identifying the technology for which + ports are created. + + """ + setattr( + pyomo_model, + f"{tech_name}_port", + Port( + initialize={ + f"{tech_name}_{self.config.commodity_name}": getattr( + pyomo_model, f"{tech_name}_{self.config.commodity_name}" + ) + } + ), + ) + + def _create_parameters(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + """Create technology Pyomo parameters to add to the Pyomo model instance. + + Method is currently passed but this can serve as a template to add parameters to the Pyomo + model instance. + + Args: + pyomo_model (pyo.ConcreteModel): pyomo_model that parameters are added to. + tech_name (str): The name or key identifying the technology for which + parameters are created. + + """ + ################################## + # Parameters # + ################################## + pyomo_model.time_duration = pyo.Param( + doc=pyomo_model.name + " time step [hour]", + default=1.0, + within=pyo.NonNegativeReals, + mutable=True, + units=pyo.units.hr, + ) + pyomo_model.cost_per_generation = pyo.Param( + doc="Generation cost for generator [$/MWh]", + default=0.0, + within=pyo.NonNegativeReals, + mutable=True, + units=pyo.units.USD / pyo.units.MWh, + ) + pyomo_model.available_generation = pyo.Param( + doc="Available generation for the generator [MW]", + default=0.0, + within=pyo.Reals, + mutable=True, + units=pyo.units.MW, + ) + + pass + + def _create_constraints(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + """Create technology Pyomo parameters to add to the Pyomo model instance. + + Method is currently passed but this can serve as a template to add constraints to the Pyomo + model instance. + + Args: + pyomo_model (pyo.ConcreteModel): pyomo_model that constraints are added to. + tech_name (str): The name or key identifying the technology for which + constraints are created. + + """ + + pass + + def initialize_parameters(self): + """Initialize parameters method.""" + self.cost_per_generation = ( + self._financial_model.value("om_capacity")[0] * 1e3 / 8760 + ) diff --git a/h2integrate/control/control_rules/pyomo_control_options.py b/h2integrate/control/control_rules/pyomo_control_options.py index 607aa0263..2f14a6a38 100644 --- a/h2integrate/control/control_rules/pyomo_control_options.py +++ b/h2integrate/control/control_rules/pyomo_control_options.py @@ -61,7 +61,7 @@ class PyomoControlOptions(BaseConfig): """ solver: str = field( - default="cbc", + default="glpk", validator=contains(["glpk", "cbc", "xpress", "xpress_persistent", "gurobi_ampl", "gurobi"]), ) solver_options: dict = field(default_factory=dict) 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 new file mode 100644 index 000000000..8e066dc7b --- /dev/null +++ b/h2integrate/control/control_rules/storage/pyomo_storage_rule_min_operating_cost.py @@ -0,0 +1,286 @@ +import pyomo.environ as pyo +from pyomo.network import Port + +from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import PyomoRuleStorageBaseclass + + +class PyomoRuleStorageMinOperatingCosts(PyomoRuleStorageBaseclass): + """Base class defining PYomo rules for generic commodity storage components.""" + + def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): + """Create storage-related parameters in the Pyomo model. + + This method defines key storage parameters such as capacity limits, + state-of-charge (SOC) bounds, efficiencies, and time duration for each + time step. + + Args: + pyomo_model (pyo.ConcreteModel): Pyomo model instance representing + the storage system. + t: Time index or iterable representing time steps (unused in this method). + """ + ################################## + # Storage Parameters # + ################################## + pyomo_model.time_duration = pyo.Param( + doc=pyomo_model.name + " time step [hour]", + default=1.0, + within=pyo.NonNegativeReals, + mutable=True, + units=pyo.units.hr, + ) + pyomo_model.cost_per_charge = pyo.Param( + doc="Operating cost of " + pyomo_model.name + " charging [$/MWh]", + default=0.0, + within=pyo.NonNegativeReals, + mutable=True, + units=pyo.units.USD / pyo.units.MWh, + ) + pyomo_model.cost_per_discharge = pyo.Param( + doc="Operating cost of " + pyomo_model.name + " discharging [$/MWh]", + default=0.0, + within=pyo.NonNegativeReals, + mutable=True, + units=pyo.units.USD / pyo.units.MWh, + ) + pyomo_model.minimum_storage = pyo.Param( + doc=pyomo_model.name + + " minimum storage rating [" + + self.config.commodity_storage_units + + "]", + default=0.0, + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.maximum_storage = pyo.Param( + doc=pyomo_model.name + + " maximum storage rating [" + + self.config.commodity_storage_units + + "]", + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.minimum_soc = pyo.Param( + doc=pyomo_model.name + " minimum state-of-charge [-]", + default=0.1, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + pyomo_model.maximum_soc = pyo.Param( + doc=pyomo_model.name + " maximum state-of-charge [-]", + default=0.9, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + + ################################## + # Efficiency Parameters # + ################################## + pyomo_model.charge_efficiency = pyo.Param( + doc=pyomo_model.name + " Charging efficiency [-]", + default=0.938, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + pyomo_model.discharge_efficiency = pyo.Param( + doc=pyomo_model.name + " discharging efficiency [-]", + default=0.938, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + ################################## + # Capacity Parameters # + ################################## + + pyomo_model.capacity = pyo.Param( + doc=pyomo_model.name + " capacity [" + self.config.commodity_storage_units + "]", + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + # Add in demand in as a parameter + + def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): + """Create storage-related decision variables in the Pyomo model. + + This method defines binary and continuous variables representing + charging/discharging modes, energy flows, and state-of-charge. + + Args: + pyomo_model (pyo.ConcreteModel): Pyomo model instance representing + the storage system. + t: Time index or iterable representing time steps (unused in this method). + """ + ################################## + # Variables # + ################################## + pyomo_model.is_charging = pyo.Var( + doc="1 if " + pyomo_model.name + " is charging; 0 Otherwise [-]", + domain=pyo.Binary, + units=pyo.units.dimensionless, + ) + pyomo_model.is_discharging = pyo.Var( + doc="1 if " + pyomo_model.name + " is discharging; 0 Otherwise [-]", + domain=pyo.Binary, + units=pyo.units.dimensionless, + ) + pyomo_model.soc0 = pyo.Var( + doc=pyomo_model.name + " initial state-of-charge at beginning of period[-]", + domain=pyo.PercentFraction, + bounds=(pyomo_model.minimum_soc, pyomo_model.maximum_soc), + units=pyo.units.dimensionless, + ) + pyomo_model.soc = pyo.Var( + doc=pyomo_model.name + " state-of-charge at end of period [-]", + domain=pyo.PercentFraction, + bounds=(pyomo_model.minimum_soc, pyomo_model.maximum_soc), + units=pyo.units.dimensionless, + ) + pyomo_model.charge_commodity = pyo.Var( + doc=self.config.commodity_name + + " into " + + pyomo_model.name + + " [" + + self.config.commodity_storage_units + + "]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.discharge_commodity = pyo.Var( + doc=self.config.commodity_name + + " out of " + + pyomo_model.name + + " [" + + self.config.commodity_storage_units + + "]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + + def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): + """Create operational and state-of-charge constraints for storage. + + This method defines constraints that enforce: + - Mutual exclusivity between charging and discharging. + - Upper and lower bounds on charge/discharge flows. + - The state-of-charge balance over time. + + Args: + pyomo_model (pyo.ConcreteModel): Pyomo model instance representing + the storage system. + t: Time index or iterable representing time steps (unused in this method). + """ + ################################## + # Charging Constraints # + ################################## + # Charge commodity bounds + pyomo_model.charge_commodity_ub = pyo.Constraint( + doc=pyomo_model.name + " charging storage upper bound", + expr=pyomo_model.charge_commodity + <= pyomo_model.maximum_storage * pyomo_model.is_charging, + ) + pyomo_model.charge_commodity_lb = pyo.Constraint( + doc=pyomo_model.name + " charging storage lower bound", + expr=pyomo_model.charge_commodity + >= pyomo_model.minimum_storage * pyomo_model.is_charging, + ) + # Discharge commodity bounds + pyomo_model.discharge_commodity_lb = pyo.Constraint( + doc=pyomo_model.name + " Discharging storage lower bound", + expr=pyomo_model.discharge_commodity + >= pyomo_model.minimum_storage * pyomo_model.is_discharging, + ) + pyomo_model.discharge_commodity_ub = pyo.Constraint( + doc=pyomo_model.name + " Discharging storage upper bound", + expr=pyomo_model.discharge_commodity + <= pyomo_model.maximum_storage * pyomo_model.is_discharging, + ) + # Storage packing constraint + pyomo_model.charge_discharge_packing = pyo.Constraint( + doc=pyomo_model.name + " packing constraint for charging and discharging binaries", + expr=pyomo_model.is_charging + pyomo_model.is_discharging <= 1, + ) + + ################################## + # SOC Inventory Constraints # + ################################## + + def soc_inventory_rule(m): + return m.soc == ( + m.soc0 + + m.time_duration + * ( + m.charge_efficiency * m.charge_commodity + - (1 / m.discharge_efficiency) * m.discharge_commodity + ) + / m.capacity + ) + + # Storage State-of-charge balance + pyomo_model.soc_inventory = pyo.Constraint( + doc=pyomo_model.name + " state-of-charge inventory balance", + rule=soc_inventory_rule, + ) + + ################################## + # SOC Linking Constraints # + ################################## + + # TODO: Make work for pyomo optimization, not needed for heuristic method + # # Linking time periods together + # def storage_soc_linking_rule(m, t): + # if t == m.blocks.index_set().first(): + # return m.blocks[t].soc0 == m.initial_soc + # return m.blocks[t].soc0 == self.blocks[t - 1].soc + + # pyomo_model.soc_linking = pyo.Constraint( + # pyomo_model.blocks.index_set(), + # doc=self.block_set_name + " state-of-charge block linking constraint", + # rule=storage_soc_linking_rule, + # ) + + def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): + """Create Pyomo ports for connecting the storage component. + + Ports are used to connect inflows and outflows of the storage system + (e.g., charging and discharging commodities) to the overall Pyomo model. + + Args: + pyomo_model (pyo.ConcreteModel): Pyomo model instance representing + the storage system. + t: Time index or iterable representing time steps (unused in this method). + """ + ################################## + # Ports # + ################################## + pyomo_model.port = Port() + pyomo_model.port.add(pyomo_model.charge_commodity) + pyomo_model.port.add(pyomo_model.discharge_commodity) + + def min_operating_cost_objective(self, pyomo_model: pyo.ConcreteModel, hybrid_blocks): + """Sets the min operating cost objective for the dispatch. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + objective = sum( + pyomo_model.time_weighting_factor[t] + * pyomo_model.time_duration + * ( + pyomo_model.cost_per_discharge * pyomo_model.charge_commodity[t] + - pyomo_model.cost_per_charge * pyomo_model.discharge_commodity[t] + ) # Try to incentivize battery charging + for t in hybrid_blocks.index_set() + ) + # if self.options.include_lifecycle_count: + # objective += self.model.lifecycle_cost * sum(self.model.lifecycles) + + self.obj = objective diff --git a/h2integrate/control/control_strategies/controller_opt_problem_state.py b/h2integrate/control/control_strategies/controller_opt_problem_state.py new file mode 100644 index 000000000..604c20f1c --- /dev/null +++ b/h2integrate/control/control_strategies/controller_opt_problem_state.py @@ -0,0 +1,149 @@ +from pyomo.opt import TerminationCondition + + +class DispatchProblemState: + """Class for tracking dispatch problem solve state and metrics""" + + def __init__(self): + self._start_time = () + self._n_days = () + self._termination_condition = () + self._solve_time = () + self._objective = () + self._upper_bound = () + self._lower_bound = () + self._constraints = () + self._variables = () + self._non_zeros = () + self._gap = () + self._n_non_optimal_solves = 0 + + def store_problem_metrics( + self, solver_results, start_time, n_days, objective_value + ): + self.start_time = start_time + self.n_days = n_days + self.termination_condition = str(solver_results.solver.termination_condition) + try: + self.solve_time = solver_results.solver.time + except AttributeError: + self.solve_time = solver_results.solver.wallclock_time + self.objective = objective_value + self.upper_bound = solver_results.problem.upper_bound + self.lower_bound = solver_results.problem.lower_bound + self.constraints = solver_results.problem.number_of_constraints + self.variables = solver_results.problem.number_of_variables + self.non_zeros = solver_results.problem.number_of_nonzeros + + # solver_results.solution.Gap not define + if solver_results.problem.upper_bound != 0.0: + self.gap = abs( + solver_results.problem.upper_bound - solver_results.problem.lower_bound + ) / abs(solver_results.problem.upper_bound) + elif solver_results.problem.lower_bound == 0.0: + self.gap = 0.0 + else: + self.gap = float("inf") + + if ( + not solver_results.solver.termination_condition + == TerminationCondition.optimal + ): + self._n_non_optimal_solves += 1 + + def _update_metric(self, metric_name, value): + data = list(getattr(self, metric_name)) + data.append(value) + setattr(self, "_" + metric_name, tuple(data)) + + @property + def start_time(self) -> tuple: + return self._start_time + + @start_time.setter + def start_time(self, start_hour: int): + self._update_metric("start_time", start_hour) + + @property + def n_days(self) -> tuple: + return self._n_days + + @n_days.setter + def n_days(self, solve_days: int): + self._update_metric("n_days", solve_days) + + @property + def termination_condition(self) -> tuple: + return self._termination_condition + + @termination_condition.setter + def termination_condition(self, condition: str): + self._update_metric("termination_condition", condition) + + @property + def solve_time(self) -> tuple: + return self._solve_time + + @solve_time.setter + def solve_time(self, time: float): + self._update_metric("solve_time", time) + + @property + def objective(self) -> tuple: + return self._objective + + @objective.setter + def objective(self, objective_value: float): + self._update_metric("objective", objective_value) + + @property + def upper_bound(self) -> tuple: + return self._upper_bound + + @upper_bound.setter + def upper_bound(self, bound: float): + self._update_metric("upper_bound", bound) + + @property + def lower_bound(self) -> tuple: + return self._lower_bound + + @lower_bound.setter + def lower_bound(self, bound: float): + self._update_metric("lower_bound", bound) + + @property + def constraints(self) -> tuple: + return self._constraints + + @constraints.setter + def constraints(self, constraint_count: int): + self._update_metric("constraints", constraint_count) + + @property + def variables(self) -> tuple: + return self._variables + + @variables.setter + def variables(self, variable_count: int): + self._update_metric("variables", variable_count) + + @property + def non_zeros(self) -> tuple: + return self._non_zeros + + @non_zeros.setter + def non_zeros(self, non_zeros_count: int): + self._update_metric("non_zeros", non_zeros_count) + + @property + def gap(self) -> tuple: + return self._gap + + @gap.setter + def gap(self, mip_gap: int): + self._update_metric("gap", mip_gap) + + @property + def n_non_optimal_solves(self) -> int: + return self._n_non_optimal_solves \ No newline at end of file diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 7082830cd..9e4eda98f 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -7,6 +7,7 @@ from h2integrate.core.utilities import BaseConfig, merge_shared_inputs from h2integrate.core.validators import range_val from h2integrate.control.control_strategies.controller_baseclass import ControllerBaseClass +from h2integrate.control.control_strategies.controller_opt_problem_state import DispatchProblemState if TYPE_CHECKING: # to avoid circular imports @@ -166,6 +167,7 @@ def pyomo_setup(self, discrete_inputs): ] # create pyomo block and set attr blocks = pyomo.Block(index_set, rule=dispatch_block_rule_function) + print("HIII", blocks) setattr(self.pyomo_model, source_tech, blocks) else: continue @@ -182,7 +184,7 @@ def pyomo_dispatch_solver( Execute rolling-window dispatch for the controlled technology. Iterates over the full simulation period in chunks of size - `self.config.n_control_window`, (re)configures per\-window dispatch + `self.config.n_control_window`, (re)configures per-window dispatch parameters, invokes a heuristic control strategy to set fixed dispatch decisions, and then calls the provided performance_model over each window to obtain storage output and SOC trajectories. @@ -257,6 +259,16 @@ def pyomo_dispatch_solver( demand_in, ) + if "optimized" in control_strategy: + # Run dispatch optimzation to minimize costs while meeting demand + self.solve_dispatch_model( + commodity_in, + self.config.system_commodity_interface_limit, + demand_in, + start_time=t, + n_days=self.n_timesteps // 24, + ) + else: raise ( NotImplementedError( @@ -796,3 +808,257 @@ def _heuristic_method(self, commodity_in, commodity_demand): if -fd > self.max_charge_fraction[t]: fd = -self.max_charge_fraction[t] self._fixed_dispatch[t] = fd + + +@define +class OptimizedDispatchControllerConfig(PyomoControllerBaseConfig): + max_charge_rate: int | float = field() + charge_efficiency: float = field(default=None) + discharge_efficiency: float = field(default=None) + include_lifecycle_count: bool = field(default=False) + demand_profile: list = field(default=None) + + +class OptimizedDispatchController(SimpleBatteryControllerHeuristic): + """Operates the battery based on heuristic rules to meet the demand profile based power + available from power generation profiles and power demand profile. + + Currently, enforces available generation and grid limit assuming no battery charging from grid. + + """ + + def setup(self): + """Initialize OptimizedDispatchController.""" + self.config = OptimizedDispatchControllerConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "control") + ) + + self.n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + + super().setup() + + if self.config.charge_efficiency is not None: + self.charge_efficiency = self.config.charge_efficiency + if self.config.discharge_efficiency is not None: + self.discharge_efficiency = self.config.discharge_efficiency + + # Create objective from pyomo blocks + + def solve_dispatch_model( + self, + commodity_in: list, + system_commodity_interface_limit: list, + commodity_demand: list, + start_time: int = 0, + n_days: int = 0, + ): + """Sets charge and discharge power of battery dispatch using fixed_dispatch attribute + and enforces available generation and charge/discharge limits. + + Args: + commodity_in (list): List of generated commodity in. + system_commodity_interface_limit (list): List of max flow rates through system + interface (e.g. grid interface). + commodity_demand (list): The demanded commodity. + + """ + + + self._create_objective_function(commodity_in, commodity_demand) + self.problem_state = DispatchProblemState() + solver_results = self.glpk_solve() + self.problem_state.store_problem_metrics( + solver_results, start_time, n_days, pyomo.value(self.model.objective) + ) + + self.check_commodity_in_discharge_limit(commodity_in, system_commodity_interface_limit) + self._set_commodity_fraction_limits(commodity_in, system_commodity_interface_limit) + self._heuristic_method(commodity_in, commodity_demand) + self._fix_dispatch_model_variables() + + def _heuristic_method(self, commodity_in, commodity_demand): + """Enforces storage fraction limits and sets _fixed_dispatch attribute. + Sets the _fixed_dispatch based on commodity_demand and commodity_in. + + Args: + commodity_in: commodity generation profile. + commodity_demand: Goal amount of commodity. + + """ + for t in self.blocks.index_set(): + fd = (commodity_demand[t] - commodity_in[t]) / self.maximum_storage + if fd > 0.0: # Discharging + if fd > self.max_discharge_fraction[t]: + fd = self.max_discharge_fraction[t] + elif fd < 0.0: # Charging + if -fd > self.max_charge_fraction[t]: + fd = -self.max_charge_fraction[t] + self._fixed_dispatch[t] = fd + + def _create_objective_function(self, commodity_in, commodity_demand): + """Creates objective function to minimize unmet demand over control window, while also + minimizing costs. + + Args: + commodity_in: commodity generation profile. + commodity_demand: Goal amount of commodity. + + """ + def objective_rule(m): + return sum( + ( + commodity_demand[t] + - ( + self.blocks[t].discharge_commodity + - self.blocks[t].charge_commodity + + commodity_in[t] + ) + ) + for t in self.blocks.index_set() + ) + + + def operating_cost_objective_rule(m) -> float: + obj = 0.0 + for tech in self.power_sources.keys(): + # Create the min_operating_cost_objective within each of the technology + # dispatch classes. + self.power_sources[tech]._dispatch.min_operating_cost_objective( + self.blocks + ) + + # Assemble the objective as a linear summation. + obj += self.power_sources[tech]._dispatch.obj + # hybrid_blocks = getattr(self.pyomo_model, "HybridDispatch") + # for t in self.blocks.index_set(): + # obj += sum( + # hybrid_blocks[t].time_weighting_factor + # * self.blocks[t].time_duration + # * self.blocks[t].electricity_sell_price + # * ( + # self.blocks[t].generation_transmission_limit + # - hybrid_blocks[t].electricity_sold + # ) + # ) + print(f"Objective value: {obj}") + + return obj + + self.objective = pyomo.Objective( + rule=operating_cost_objective_rule, sense=pyomo.minimize + ) + + def update_time_series_parameters(self, start_time: int): + """Update time series parameters method. + + Args: + start_time (int): Start time. + + Returns: + None + + """ + # Update time series parameters for blocks + for t in self.blocks: + t.update_time_series_parameters(start_time) + + # Update local time series parameters + + n_horizon = len(self.blocks.index_set()) + generation = self._system_model.value("gen") + if start_time + n_horizon > len(generation): + horizon_gen = list(generation[start_time:]) + horizon_gen.extend(list(generation[0 : n_horizon - len(horizon_gen)])) + else: + horizon_gen = generation[start_time : start_time + n_horizon] + + if len(horizon_gen) < len(self.blocks): + raise RuntimeError( + f"Dispatch parameter update error at start_time {start_time}: System model " + f"{type(self._system_model)} generation profile should have at least {len(self.blocks)} " + f"length but has only {len(generation)}" + ) + self.available_generation = [gen_kw / 1e3 for gen_kw in horizon_gen] + + @staticmethod + def glpk_solve_call( + pyomo_model: pyomo.ConcreteModel, + log_name: str = "", + user_solver_options: dict = None, + ): + + # log_name = "annual_solve_GLPK.log" # For debugging MILP solver + # Ref. on solver options: https://en.wikibooks.org/wiki/GLPK/Using_GLPSOL + glpk_solver_options = { + "cuts": None, + "presol": None, + # 'mostf': None, + # 'mipgap': 0.001, + "tmlim": 30, + } + solver_options = SolverOptions( + glpk_solver_options, log_name, user_solver_options, "log" + ) + with pyomo.SolverFactory("glpk") as solver: + results = solver.solve(pyomo_model, options=solver_options.constructed) + # HybridDispatchBuilderSolver.log_and_solution_check( + # log_name, + # solver_options.instance_log, + # results.solver.termination_condition, + # pyomo_model, + # ) + return results + + def glpk_solve(self): + return self.glpk_solve_call( + self.pyomo_model, self.options.log_name, self.options.solver_options + ) + + # @staticmethod + # def log_and_solution_check( + # log_name: str, solve_log: str, solver_termination_condition, pyomo_model + # ): + # if log_name != "": + # HybridDispatchBuilderSolver.append_solve_to_log(log_name, solve_log) + # HybridDispatchBuilderSolver.check_solve_condition( + # solver_termination_condition, pyomo_model + # ) + @property + def demand_profile(self) -> float: + """Demand profile for the dispatch. + Returns: + list: List of demand profile.""" + return [ + self.blocks[t].demand_profile.value for t in self.blocks.index_set() + ] + + @demand_profile.setter + def demand_profile(self, electricity_demand_profile: list): + if len(electricity_demand_profile) != len(self.blocks.index_set()): + raise ValueError("demand_profile must be the same length as dispatch index set.") + else: + for t in self.blocks.index_set(): + self.blocks[t].demand_profile = round( + electricity_demand_profile[t], self.round_digits + ) + +class SolverOptions: + """Class for housing solver options""" + + def __init__( + self, + solver_spec_options: dict, + log_name: str = "", + user_solver_options: dict = None, + solver_spec_log_key: str = "logfile", + ): + self.instance_log = "dispatch_solver.log" + self.solver_spec_options = solver_spec_options + self.user_solver_options = user_solver_options + + self.constructed = solver_spec_options + if log_name != "": + self.constructed[solver_spec_log_key] = self.instance_log + if user_solver_options is not None: + self.constructed.update(user_solver_options) + diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index d95593f58..1d887375c 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -82,6 +82,7 @@ from h2integrate.converters.co2.marine.direct_ocean_capture import DOCCostModel, DOCPerformanceModel from h2integrate.control.control_strategies.pyomo_controllers import ( HeuristicLoadFollowingController, + OptimizedDispatchController, ) from h2integrate.resource.solar.nrel_developer_goes_api_models import ( GOESTMYSolarAPI, @@ -124,6 +125,12 @@ from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( PyomoRuleStorageBaseclass, ) +from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost import ( + PyomoRuleStorageMinOperatingCosts, +) +from h2integrate.control.control_rules.converters.generic_converter_opt import ( + PyomoDispatchGenericConverterMinOperatingCosts, +) supported_models = { @@ -210,9 +217,16 @@ "pass_through_controller": PassThroughOpenLoopController, "demand_open_loop_controller": DemandOpenLoopController, "heuristic_load_following_controller": HeuristicLoadFollowingController, + "optimized_dispatch_controller": OptimizedDispatchController, # Dispatch "pyomo_dispatch_generic_converter": PyomoDispatchGenericConverter, "pyomo_dispatch_generic_storage": PyomoRuleStorageBaseclass, + "pyomo_dispatch_battery_min_operating_cost": ( + PyomoRuleStorageMinOperatingCosts + ), + "pyomo_dispatch_generic_converter_min_operating_cost": ( + PyomoDispatchGenericConverterMinOperatingCosts + ), # Feedstock "feedstock_performance": FeedstockPerformanceModel, "feedstock_cost": FeedstockCostModel, From 5a2ce8720263a9af953cbe99d567285cd57a2ce9 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Fri, 5 Dec 2025 10:30:35 -0500 Subject: [PATCH 04/37] Add first objective function --- .../converters/generic_converter_opt.py | 46 ++++++----- .../pyomo_storage_rule_min_operating_cost.py | 44 +++------- .../control_strategies/pyomo_controllers.py | 81 ++++++++++++++++++- 3 files changed, 113 insertions(+), 58 deletions(-) diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index e47ccb808..92e4aa3cb 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -34,22 +34,6 @@ def initialize_parameters(self): self.config["commodity_cost_per_generation"] ) - def min_operating_cost_objective(self, pyomo_model: pyo.ConcreteModel, hybrid_blocks): - """ Create generice converter objective call to add to Pyomo model instance. - - Args: - pyomo_model (pyo.ConcreteModel): pyomo_model the variables should be added to. - tech_name (str): The name or key identifying the technology for which - variables are created. - """ - self.obj = sum( - pyomo_model.time_weighting_factor[t] - * pyomo_model.time_duration - * pyomo_model.cost_per_generation - * hybrid_blocks[t].generic_generation - for t in hybrid_blocks.index_set() - ) - def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """Create generic converter variables to add to Pyomo model instance. @@ -147,8 +131,28 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, tech_name: str): pass - def initialize_parameters(self): - """Initialize parameters method.""" - self.cost_per_generation = ( - self._financial_model.value("om_capacity")[0] * 1e3 / 8760 - ) + def update_time_series_parameters(self, start_time: int, commodity_in:list): + """Update time series parameters method. + + Args: + start_time (int): Start time. + + Returns: + None + + """ + n_horizon = len(self.blocks.index_set()) + generation = commodity_in + if start_time + n_horizon > len(generation): + horizon_gen = list(generation[start_time:]) + horizon_gen.extend(list(generation[0 : n_horizon - len(horizon_gen)])) + else: + horizon_gen = generation[start_time : start_time + n_horizon] + + if len(horizon_gen) < len(self.blocks): + raise RuntimeError( + f"Dispatch parameter update error at start_time {start_time}: System model " + f"{type(self._system_model)} generation profile should have at least {len(self.blocks)} " + f"length but has only {len(generation)}" + ) + self.available_generation = [gen_kw / 1e3 for gen_kw in horizon_gen] \ No newline at end of file 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 8e066dc7b..cbe32a87c 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 @@ -232,18 +232,18 @@ def soc_inventory_rule(m): # SOC Linking Constraints # ################################## - # TODO: Make work for pyomo optimization, not needed for heuristic method - # # Linking time periods together - # def storage_soc_linking_rule(m, t): - # if t == m.blocks.index_set().first(): - # return m.blocks[t].soc0 == m.initial_soc - # return m.blocks[t].soc0 == self.blocks[t - 1].soc + TODO: Make work for pyomo optimization, not needed for heuristic method + # Linking time periods together + def storage_soc_linking_rule(m, t): + if t == m.blocks.index_set().first(): + return m.blocks[t].soc0 == m.initial_soc + return m.blocks[t].soc0 == self.blocks[t - 1].soc - # pyomo_model.soc_linking = pyo.Constraint( - # pyomo_model.blocks.index_set(), - # doc=self.block_set_name + " state-of-charge block linking constraint", - # rule=storage_soc_linking_rule, - # ) + pyomo_model.soc_linking = pyo.Constraint( + pyomo_model.blocks.index_set(), + doc=self.block_set_name + " state-of-charge block linking constraint", + rule=storage_soc_linking_rule, + ) def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): """Create Pyomo ports for connecting the storage component. @@ -262,25 +262,3 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.port = Port() pyomo_model.port.add(pyomo_model.charge_commodity) pyomo_model.port.add(pyomo_model.discharge_commodity) - - def min_operating_cost_objective(self, pyomo_model: pyo.ConcreteModel, hybrid_blocks): - """Sets the min operating cost objective for the dispatch. - - Args: - hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical - models by adding modeling components as attributes. - - """ - objective = sum( - pyomo_model.time_weighting_factor[t] - * pyomo_model.time_duration - * ( - pyomo_model.cost_per_discharge * pyomo_model.charge_commodity[t] - - pyomo_model.cost_per_charge * pyomo_model.discharge_commodity[t] - ) # Try to incentivize battery charging - for t in hybrid_blocks.index_set() - ) - # if self.options.include_lifecycle_count: - # objective += self.model.lifecycle_cost * sum(self.model.lifecycles) - - self.obj = objective diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 9e4eda98f..776463f07 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING import numpy as np -import pyomo.environ as pyomo +import pyomo.environ as pyomo, Expression from attrs import field, define from h2integrate.core.utilities import BaseConfig, merge_shared_inputs @@ -151,6 +151,8 @@ def pyomo_setup(self, discrete_inputs): index_set = pyomo.Set(initialize=range(self.config.n_control_window)) + self.source_techs = [] + # run each pyomo rule set up function for each technology for connection in self.dispatch_connections: # get connection definition @@ -169,6 +171,7 @@ def pyomo_setup(self, discrete_inputs): blocks = pyomo.Block(index_set, rule=dispatch_block_rule_function) print("HIII", blocks) setattr(self.pyomo_model, source_tech, blocks) + self.source_techs.append(source_tech) else: continue @@ -259,7 +262,7 @@ def pyomo_dispatch_solver( demand_in, ) - if "optimized" in control_strategy: + elif "optimized" in control_strategy: # Run dispatch optimzation to minimize costs while meeting demand self.solve_dispatch_model( commodity_in, @@ -817,6 +820,7 @@ class OptimizedDispatchControllerConfig(PyomoControllerBaseConfig): discharge_efficiency: float = field(default=None) include_lifecycle_count: bool = field(default=False) demand_profile: list = field(default=None) + time_weighting_factor: float = 0.995 class OptimizedDispatchController(SimpleBatteryControllerHeuristic): @@ -842,7 +846,13 @@ def setup(self): if self.config.discharge_efficiency is not None: self.discharge_efficiency = self.config.discharge_efficiency + # Create objective from pyomo blocks + def initialize_parameters(self): + self.time_weighting_factor = ( + self.config.time_weighting_factor + ) # Discount factor + self._create_objective_function() def solve_dispatch_model( self, @@ -920,10 +930,40 @@ def objective_rule(m): def operating_cost_objective_rule(m) -> float: obj = 0.0 - for tech in self.power_sources.keys(): + for source_tech in self.source_techs: + pyomo_block = getattr(self.pyomo_model, source_tech) + if source_tech in converters: + # Create the min_operating_cost_objective for converter technologies + self.obj = Expression( + expr = sum( + self.time_weighting_factor[t] + * pyomo_block.time_duration + * pyomo_block.cost_per_generation + * pyomo_block[t].generic_generation + for t in self.blocks.index_set() + ) + ) + # Copy the technology objective to the pyomo model. + setattr(m, source_tech + "_obj", self.obj) + else: + # Create the min_operating_cost_objective for storage technologies + self.obj = Expression( + expr = sum( + self.time_weighting_factor[t] + * pyomo_block.time_duration + * ( + pyomo_block.cost_per_discharge * pyomo_block.charge_commodity[t] + - pyomo_block.cost_per_charge * pyomo_block.discharge_commodity[t] + ) # Try to incentivize battery charging + for t in self.blocks.index_set() + ) + ) + # Copy the technology objective to the pyomo model. + setattr(m, source_tech + "_obj", self.obj) + # Create the min_operating_cost_objective within each of the technology # dispatch classes. - self.power_sources[tech]._dispatch.min_operating_cost_objective( + self.pyomo_model[source_tech].min_operating_cost_objective( self.blocks ) @@ -1041,6 +1081,38 @@ def demand_profile(self, electricity_demand_profile: list): self.blocks[t].demand_profile = round( electricity_demand_profile[t], self.round_digits ) + @property + def cost_per_discharge(self) -> float: + """Cost per discharge.""" + for t in self.blocks.index_set(): + return self.blocks[t].cost_per_discharge.value + + @cost_per_discharge.setter + def cost_per_discharge(self, cost_per_discharge: float): + for t in self.blocks.index_set(): + self.blocks[t].cost_per_discharge = round(cost_per_discharge, self.round_digits) + @property + def cost_per_charge(self) -> float: + """Cost per charge.""" + for t in self.blocks.index_set(): + return self.blocks[t].cost_per_charge.value + + @cost_per_charge.setter + def cost_per_charge(self, cost_per_charge: float): + for t in self.blocks.index_set(): + self.blocks[t].cost_per_charge = round(cost_per_charge, self.round_digits) + + @property + def time_weighting_factor(self) -> float: + for t in self.blocks.index_set(): + return self.blocks[t + 1].time_weighting_factor.value + + @time_weighting_factor.setter + def time_weighting_factor(self, weighting: float): + for t in self.blocks.index_set(): + self.blocks[t].time_weighting_factor = round( + weighting**t, self.round_digits + ) class SolverOptions: """Class for housing solver options""" @@ -1062,3 +1134,4 @@ def __init__( if user_solver_options is not None: self.constructed.update(user_solver_options) + From c7923c41471d16c705d8982ea7a80f22002344ee Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 9 Dec 2025 16:05:07 -0500 Subject: [PATCH 05/37] Updated dispatch optimization framework - add hybrid dispatch rule --- .../converters/generic_converter_opt.py | 117 ++++++--- .../control/control_rules/hybrid_rule.py | 168 ++++++++++++ .../control_rules/pyomo_rule_baseclass.py | 2 + .../pyomo_storage_rule_min_operating_cost.py | 244 +++++++++++++++++- .../control_strategies/pyomo_controllers.py | 107 ++++---- 5 files changed, 550 insertions(+), 88 deletions(-) create mode 100644 h2integrate/control/control_rules/hybrid_rule.py diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index 92e4aa3cb..2fa21cc35 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -1,5 +1,5 @@ import pyomo.environ as pyo -from pyomo.network import Port +from pyomo.network import Port, Expression from attrs import field, define from h2integrate.control.control_rules.converters.generic_converter import ( @@ -15,10 +15,10 @@ class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): This class defines the parameters required to configure the `PyomoRuleBaseConfig`. Attributes: - commodity_cost_per_generation (float): cost of the commodity per generation (in $/kWh). + commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). """ - commodity_cost_per_generation: str = field() + commodity_cost_per_production: str = field() class PyomoDispatchGenericConverterMinOperatingCosts(PyomoDispatchGenericConverter): @@ -28,10 +28,10 @@ def setup(self): ) super().setup() - def initialize_parameters(self): + def initialize_parameters(self, commodity_in: list, commodity_demand: list): """Initialize parameters method.""" - self.cost_per_generation = ( - self.config["commodity_cost_per_generation"] + self.cost_per_production = ( + self.config["commodity_cost_per_production"] ) def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): @@ -47,14 +47,18 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): pyomo_model, f"{tech_name}_{self.config.commodity_name}", pyo.Var( - doc=f"{self.config.commodity_name} generation \ + doc=f"{self.config.commodity_name} production \ from {tech_name} [{self.config.commodity_storage_units}]", domain=pyo.NonNegativeReals, - bounds=(0, pyomo_model.available_generation), + bounds=(0, pyomo_model.available_production), units=eval("pyo.units." + self.config.commodity_storage_units), initialize=0.0, ), ) + return getattr( + pyomo_model, + f"{tech_name}_{self.config.commodity_name}", + ), 0.0 # load var is zero for converters def _create_ports(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """Create generic converter port to add to pyomo model instance. @@ -76,6 +80,10 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, tech_name: str): } ), ) + return getattr( + pyomo_model, + f"{tech_name}_port", + ) def _create_parameters(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """Create technology Pyomo parameters to add to the Pyomo model instance. @@ -99,19 +107,23 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, tech_name: str): mutable=True, units=pyo.units.hr, ) - pyomo_model.cost_per_generation = pyo.Param( - doc="Generation cost for generator [$/MWh]", + pyomo_model.cost_per_production = pyo.Param( + doc="Production cost for generator [$/" + + self.config.commodity_storage_units + + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=pyo.units.USD / pyo.units.MWh, + units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), ) - pyomo_model.available_generation = pyo.Param( - doc="Available generation for the generator [MW]", + pyomo_model.available_production = pyo.Param( + doc="Available production for the generator [" + + self.config.commodity_storage_units + + "]", default=0.0, within=pyo.Reals, mutable=True, - units=pyo.units.MW, + units=eval("pyo.units." + self.config.commodity_storage_units), ) pass @@ -131,28 +143,75 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, tech_name: str): pass - def update_time_series_parameters(self, start_time: int, commodity_in:list): + def update_time_series_parameters(self, start_time: int, + commodity_in:list, + commodity_demand:list, + time_commodity_met_value:list + ): """Update time series parameters method. Args: - start_time (int): Start time. + start_time (int): The starting time index for the update. + commodity_in (list): List of commodity input values for each time step. + """ + self.available_production = [commodity_in[t] + for t in self.blocks.index_set()] + + @property + def available_production(self) -> list: + """Available generation. Returns: - None + list: List of available generation. """ - n_horizon = len(self.blocks.index_set()) - generation = commodity_in - if start_time + n_horizon > len(generation): - horizon_gen = list(generation[start_time:]) - horizon_gen.extend(list(generation[0 : n_horizon - len(horizon_gen)])) + return [ + self.blocks[t].available_production.value for t in self.blocks.index_set() + ] + + @available_production.setter + def available_production(self, resource: list): + if len(resource) == len(self.blocks): + for t, gen in zip(self.blocks, resource): + self.blocks[t].available_production.set_value( + round(gen, self.round_digits) + ) else: - horizon_gen = generation[start_time : start_time + n_horizon] + raise ValueError( + f"'resource' list ({len(resource)}) must be the same length as\ + time horizon ({len(self.blocks)})" + ) - if len(horizon_gen) < len(self.blocks): - raise RuntimeError( - f"Dispatch parameter update error at start_time {start_time}: System model " - f"{type(self._system_model)} generation profile should have at least {len(self.blocks)} " - f"length but has only {len(generation)}" + @property + def cost_per_production(self) -> float: + """Cost per generation [$/commodity_storage_units].""" + for t in self.blocks.index_set(): + return self.blocks[t].cost_per_production.value + + @cost_per_production.setter + def cost_per_production(self, om_dollar_per_kwh: float): + for t in self.blocks.index_set(): + self.blocks[t].cost_per_production.set_value( + round(om_dollar_per_kwh, self.round_digits) ) - self.available_generation = [gen_kw / 1e3 for gen_kw in horizon_gen] \ No newline at end of file + + def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): + """Wind instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr=sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].cost_per_production + * getattr( + hybrid_blocks, + f"{tech_name}_{self.config.commodity_name}", + )[t] + for t in hybrid_blocks.index_set() + ) + ) diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py new file mode 100644 index 000000000..b97d3960c --- /dev/null +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -0,0 +1,168 @@ +import pyomo.environ as pyo +from pyomo.network import Port, Arc +from attrs import field, define + +# from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseClass + + +# @define +# class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): +# """ +# Configuration class for the PyomoDispatchGenericConverterMinOperatingCostsConfig. + +# This class defines the parameters required to configure the `PyomoRuleBaseConfig`. + +# Attributes: +# commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). +# """ + +# commodity_cost_per_production: str = field() + + +class PyomoDispatchPlantRule: + def __init__( + self, + pyomo_model: pyo.ConcreteModel, + index_set: pyo.Set, + source_techs: dict, + tech_dispatch_models: pyo.ConcreteModel, + dispatch_options: dict, + block_set_name: str = "hybrid", + ): + # self.config = PyomoDispatchGenericConverterMinOperatingCostsConfig.from_dict( + # self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] + # ) + + + self.source_techs = source_techs + self.options = dispatch_options + self.power_source_gen_vars = {key: [] for key in index_set} + self.tech_dispatch_models = tech_dispatch_models + self.load_vars = {key: [] for key in index_set} + self.ports = {key: [] for key in index_set} + self.arcs = [] + + self.block_set_name = block_set_name + self.round_digits = int(4) + + self._model = pyomo_model + self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule) + setattr(self.model, self.block_set_name, self.blocks) + + + super().setup() + + def dispatch_block_rule(self, hybrid, t): + ################################## + # Parameters # + ################################## + self._create_parameters(hybrid) + ################################## + # Variables / Ports # + ################################## + self._create_variables_and_ports(hybrid, t) + ################################## + # Constraints # + ################################## + self._create_hybrid_constraints(hybrid, t) + + + def initialize_parameters(self, commodity_in: list, commodity_demand: list): + """Initialize parameters method.""" + self.time_weighting_factor = ( + self.options.time_weighting_factor + ) # Discount factor + for tech in self.source_techs.keys(): + pyomo_block = getattr(self.tech_dispatch_models, tech) + pyomo_block.initialize_parameters(commodity_in, commodity_demand) + + def _create_variables_and_ports(self, hybrid, t): + for tech in self.source_techs.keys(): + pyomo_block = getattr(self.tech_dispatch_models, tech) + gen_var, load_var = pyomo_block._create_variables(hybrid, tech) + + self.power_source_gen_vars[t].append(gen_var) + self.load_vars[t].append(load_var) + self.ports[t].append( + pyomo_block._create_port(hybrid) + ) + + @staticmethod + def _create_parameters(hybrid): + hybrid.time_weighting_factor = pyo.Param( + doc="Exponential time weighting factor [-]", + initialize=1.0, + within=pyo.PercentFraction, + mutable=True, + units=pyo.units.dimensionless, + ) + + def _create_hybrid_constraints(self, hybrid, t): + hybrid.production_total = pyo.Constraint( + doc="hybrid system generation total", + rule=hybrid.system_production == sum(self.power_source_gen_vars[t]), + ) + + hybrid.load_total = pyo.Constraint( + doc="hybrid system load total", + rule=hybrid.system_load == sum(self.load_vars[t]), + ) + + def create_arcs(self): + ################################## + # Arcs # + ################################## + for tech in self.source_techs.keys(): + pyomo_block = getattr(self.tech_dispatch_models, tech) + def arc_rule(m, t): + source_port = self.pyomo_block.blocks[t].port + destination_port = getattr(self.blocks[t], tech + "_port") + return {"source": source_port, "destination": destination_port} + + setattr( + self.model, + tech + "_hybrid_arc", + Arc(self.blocks.index_set(), rule=arc_rule), + ) + self.arcs.append(getattr(self.model, tech + "_hybrid_arc")) + + pyo.TransformationFactory("network.expand_arcs").apply_to(self.model) + + def update_time_series_parameters(self, start_time: int): + for tech in self.source_techs.keys(): + pyomo_block = getattr(self.tech_dispatch_models, tech) + pyomo_block.update_time_series_parameters(start_time) + + def update_time_series_parameters(self, start_time: int, commodity_in:list): + """Update time series parameters method. + + Args: + start_time (int): Start time. + + Returns: + None + + """ + n_horizon = len(self.blocks.index_set()) + production = commodity_in + if start_time + n_horizon > len(production): + horizon_gen = list(production[start_time:]) + horizon_gen.extend(list(production[0 : n_horizon - len(horizon_gen)])) + else: + horizon_gen = production[start_time : start_time + n_horizon] + + if len(horizon_gen) < len(self.blocks): + raise RuntimeError( + f"Dispatch parameter update error at start_time {start_time}: System model " + f"{type(self._system_model)} production profile should have at least {len(self.blocks)} " + f"length but has only {len(production)}" + ) + self.available_production = [gen_kw / 1e3 for gen_kw in horizon_gen] + + @property + def blocks(self) -> pyo.Block: + return self._blocks + + @property + def model(self) -> pyo.ConcreteModel: + return self._model diff --git a/h2integrate/control/control_rules/pyomo_rule_baseclass.py b/h2integrate/control/control_rules/pyomo_rule_baseclass.py index 60788d151..ae0a57377 100644 --- a/h2integrate/control/control_rules/pyomo_rule_baseclass.py +++ b/h2integrate/control/control_rules/pyomo_rule_baseclass.py @@ -32,6 +32,8 @@ def setup(self): self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] ) + self.round_digits = int(4) + self.add_discrete_output( "dispatch_block_rule_function", val=self.dispatch_block_rule_function, 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 cbe32a87c..7fe4fcf05 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 @@ -1,5 +1,5 @@ import pyomo.environ as pyo -from pyomo.network import Port +from pyomo.network import Port, Expression from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import PyomoRuleStorageBaseclass @@ -7,6 +7,14 @@ class PyomoRuleStorageMinOperatingCosts(PyomoRuleStorageBaseclass): """Base class defining PYomo rules for generic commodity storage components.""" + def initialize_parameters(self, commodity_in: list, commodity_demand: list): + self.commodity_load_demand = [commodity_demand[t] + for t in self.blocks.index_set() + ] + self.load_production_limit = [commodity_demand[t] + for t in self.blocks.index_set() + ] + def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): """Create storage-related parameters in the Pyomo model. @@ -30,18 +38,22 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): units=pyo.units.hr, ) pyomo_model.cost_per_charge = pyo.Param( - doc="Operating cost of " + pyomo_model.name + " charging [$/MWh]", + doc="Operating cost of " + pyomo_model.name + " charging [$/" + + self.config.commodity_storage_units + + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=pyo.units.USD / pyo.units.MWh, + units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), ) pyomo_model.cost_per_discharge = pyo.Param( - doc="Operating cost of " + pyomo_model.name + " discharging [$/MWh]", + doc="Operating cost of " + pyomo_model.name + " discharging [$/" + + self.config.commodity_storage_units + + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=pyo.units.USD / pyo.units.MWh, + units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), ) pyomo_model.minimum_storage = pyo.Param( doc=pyomo_model.name @@ -104,7 +116,50 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): mutable=True, units=eval("pyo.units." + self.config.commodity_storage_units), ) - # Add in demand in as a parameter + ################################## + # System Parameters # + ################################## + pyomo_model.epsilon = pyo.Param( + doc="A small value used in objective for binary logic", + default=1e-3, + within=pyo.NonNegativeReals, + mutable=True, + units=pyo.units.USD, + ) + pyomo_model.commodity_met_value = pyo.Param( + doc="Commodity demand met value per generation [$/" + + self.config.commodity_storage_units + + "]", + default=0.0, + within=pyo.Reals, + mutable=True, + units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), + ) + # grid.electricity_purchase_price = pyomo.Param( + # doc="Electricity purchase price [$/MWh]", + # default=0.0, + # within=pyomo.Reals, + # mutable=True, + # units=u.USD / u.MWh, + # ) + pyomo_model.commodity_load_demand = pyo.Param( + doc="Load demand for the commodity [" + + self.config.commodity_storage_units + + "]", + default=1000.0, + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.load_production_limit = pyo.Param( + doc="Production limit for load [" + + self.config.commodity_storage_units + + "]", + default=1000.0, + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): """Create storage-related decision variables in the Pyomo model. @@ -162,6 +217,36 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): domain=pyo.NonNegativeReals, units=eval("pyo.units." + self.config.commodity_storage_units), ) + pyomo_model.system_production = pyo.Var( + doc="System generation [MW]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.system_load = pyo.Var( + doc="System load [MW]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.commodity_out = pyo.Var( + doc="Electricity sold [MW]", + domain=pyo.NonNegativeReals, + bounds=(0, pyomo_model.commodity_load_demand), + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.is_generating = pyo.Var( + doc="System is generating power", + domain=pyo.Binary, + units=pyo.units.dimensionless + ) + # TODO: Not needed for now, add back in later if needed + # pyomo_model.electricity_purchased = pyo.Var( + # doc="Electricity purchased [MW]", + # domain=pyo.NonNegativeReals, + # units=u.MW, + # ) + return ( + pyomo_model.discharge_commodity, + pyomo_model.charge_commodity) def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): """Create operational and state-of-charge constraints for storage. @@ -207,6 +292,27 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): expr=pyomo_model.is_charging + pyomo_model.is_discharging <= 1, ) + pyomo_model.balance = pyo.Constraint( + doc="Transmission energy balance", + expr=( + pyomo_model.commodity_out + == pyomo_model.system_production - pyomo_model.system_load + ), + ) + pyomo_model.production_limit = pyo.Constraint( + doc="Transmission limit on electricity sales", + expr=pyomo_model.commodity_out + <= pyomo_model.commodity_load_demand * pyomo_model.is_generating, + ) + pyomo_model.production_link = pyo.Constraint() + # pyomo_model.purchases_transmission_limit = pyomo.Constraint( + # doc="Transmission limit on electricity purchases", + # expr=( + # grid.electricity_purchased + # <= grid.load_transmission_limit * (1 - grid.is_generating) + # ), + # ) + ################################## # SOC Inventory Constraints # ################################## @@ -232,7 +338,7 @@ def soc_inventory_rule(m): # SOC Linking Constraints # ################################## - TODO: Make work for pyomo optimization, not needed for heuristic method + # TODO: Make work for pyomo optimization, not needed for heuristic method # Linking time periods together def storage_soc_linking_rule(m, t): if t == m.blocks.index_set().first(): @@ -262,3 +368,127 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.port = Port() pyomo_model.port.add(pyomo_model.charge_commodity) pyomo_model.port.add(pyomo_model.discharge_commodity) + pyomo_model.port.add(pyomo_model.system_production) + pyomo_model.port.add(pyomo_model.system_load) + pyomo_model.port.add(pyomo_model.commodity_out) + # pyomo_model.port.add(pyomo_model.electricity_purchased) + + def update_time_series_parameters(self, start_time: int, + commodity_in:list, + commodity_demand:list, + time_commodity_met_value:list + ): + """Update time series parameters method. + + Args: + start_time (int): The starting time index for the update. + commodity_in (list): List of commodity input values for each time step. + """ + self.time_duration = [1.0] * len(self.blocks.index_set()) + self.commodity_load_demand = [commodity_demand[t] + for t in self.blocks.index_set()] + self.load_production_limit = [commodity_demand[t] + for t in self.blocks.index_set()] + self.commodity_met_value = [time_commodity_met_value[t] + for t in self.blocks.index_set()] + + def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): + """Wind instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr = sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * ( + self.blocks[t].cost_per_discharge * self.blocks[t].charge_commodity[t] + - self.blocks[t].cost_per_charge * self.blocks[t].discharge_commodity[t] + + (self.blocks[t].commodity_load_demand[t] + - self.blocks[t].commodity_out + ) * self.blocks[t].penalty_cost_per_unmet_demand + ) # Try to incentivize battery charging + for t in self.blocks.index_set() + ) + ) + + @property + def commodity_load_demand(self) -> list: + return [ + self.blocks[t].commodity_load_demand.value + for t in self.blocks.index_set() + ] + + @commodity_load_demand.setter + def commodity_load_demand(self, commodity_demand: list): + if len(commodity_demand) == len(self.blocks): + for t, limit in zip(self.blocks, commodity_demand): + self.blocks[t].commodity_load_demand.set_value( + round(limit, self.round_digits) + ) + else: + raise ValueError("'commodity_demand' list must be the same length as time horizon") + + @property + def load_production_limit(self) -> list: + return [ + self.blocks[t].load_production_limit.value + for t in self.blocks.index_set() + ] + + @load_production_limit.setter + def load_production_limit(self, commodity_demand: list): + if len(commodity_demand) == len(self.blocks): + for t, limit in zip(self.blocks, commodity_demand): + self.blocks[t].load_production_limit.set_value( + round(limit, self.round_digits) + ) + else: + raise ValueError("'commodity_demand' list must be the same length as time horizon") + + @property + def commodity_met_value(self) -> list: + return [ + self.blocks[t].commodity_met_value.value for t in self.blocks.index_set() + ] + + @commodity_met_value.setter + def commodity_met_value(self, price_per_kwh: list): + if len(price_per_kwh) == len(self.blocks): + for t, price in zip(self.blocks, price_per_kwh): + self.blocks[t].commodity_met_value.set_value( + round(price, self.round_digits) + ) + else: + raise ValueError( + "'price_per_kwh' list must be the same length as time horizon" + ) + + @property + def system_production(self) -> list: + return [self.blocks[t].system_production.value for t in self.blocks.index_set()] + + @property + def system_load(self) -> list: + return [self.blocks[t].system_load.value for t in self.blocks.index_set()] + + @property + def commodity_out(self) -> list: + return [self.blocks[t].commodity_out.value for t in self.blocks.index_set()] + + # @property + # def electricity_purchased(self) -> list: + # return [ + # self.blocks[t].electricity_purchased.value for t in self.blocks.index_set() + # ] + + @property + def is_generating(self) -> list: + return [self.blocks[t].is_generating.value for t in self.blocks.index_set()] + + @property + def not_generating(self) -> list: + return [self.blocks[t].not_generating.value for t in self.blocks.index_set()] \ No newline at end of file diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 776463f07..0fb5df21f 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -1,13 +1,14 @@ from typing import TYPE_CHECKING import numpy as np -import pyomo.environ as pyomo, Expression +import pyomo.environ as pyomo, Expression, del_component, assert_units_consistent from attrs import field, define from h2integrate.core.utilities import BaseConfig, merge_shared_inputs from h2integrate.core.validators import range_val from h2integrate.control.control_strategies.controller_baseclass import ControllerBaseClass from h2integrate.control.control_strategies.controller_opt_problem_state import DispatchProblemState +from h2integrate.control.control_rules.hybrid_rule import PyomoDispatchPlantRule if TYPE_CHECKING: # to avoid circular imports @@ -230,7 +231,8 @@ def pyomo_dispatch_solver( Notes: 1. Arrays returned have length self.n_timesteps (full simulation period). """ - self.initialize_parameters() + self.initialize_parameters(inputs[f"{commodity_name}_in"], + inputs[f"{commodity_name}_demand"]) # initialize outputs unmet_demand = np.zeros(self.n_timesteps) @@ -846,12 +848,22 @@ def setup(self): if self.config.discharge_efficiency is not None: self.discharge_efficiency = self.config.discharge_efficiency + self.hybrid_dispatch_model = self._create_dispatch_optimization_model() + self._create_objective_function() + self.hybrid_dispatch_model.create_arcs() + assert_ + + # Create objective from pyomo blocks - def initialize_parameters(self): + def initialize_parameters(self, commodity_in, commodity_demand): self.time_weighting_factor = ( self.config.time_weighting_factor ) # Discount factor + for source_tech in self.source_techs: + pyomo_block = getattr(self.pyomo_model, source_tech) + pyomo_block.initialize_parameters(commodity_in, commodity_demand) + self._create_objective_function() def solve_dispatch_model( @@ -873,8 +885,6 @@ def solve_dispatch_model( """ - - self._create_objective_function(commodity_in, commodity_demand) self.problem_state = DispatchProblemState() solver_results = self.glpk_solve() self.problem_state.store_problem_metrics( @@ -905,30 +915,14 @@ def _heuristic_method(self, commodity_in, commodity_demand): fd = -self.max_charge_fraction[t] self._fixed_dispatch[t] = fd - def _create_objective_function(self, commodity_in, commodity_demand): - """Creates objective function to minimize unmet demand over control window, while also - minimizing costs. - - Args: - commodity_in: commodity generation profile. - commodity_demand: Goal amount of commodity. - + def _create_objective_function(self): + """Operating cost objective rule. + Returns: + expression: Operating cost objective rule. """ - def objective_rule(m): - return sum( - ( - commodity_demand[t] - - ( - self.blocks[t].discharge_commodity - - self.blocks[t].charge_commodity - + commodity_in[t] - ) - ) - for t in self.blocks.index_set() - ) - + self._delete_objective() - def operating_cost_objective_rule(m) -> float: + def min_operating_cost_objective_rule(m): obj = 0.0 for source_tech in self.source_techs: pyomo_block = getattr(self.pyomo_model, source_tech) @@ -939,7 +933,9 @@ def operating_cost_objective_rule(m) -> float: self.time_weighting_factor[t] * pyomo_block.time_duration * pyomo_block.cost_per_generation - * pyomo_block[t].generic_generation + * getattr( + pyomo_block, f"{source_tech}_{self.commodity_name}" + )[t] for t in self.blocks.index_set() ) ) @@ -954,40 +950,27 @@ def operating_cost_objective_rule(m) -> float: * ( pyomo_block.cost_per_discharge * pyomo_block.charge_commodity[t] - pyomo_block.cost_per_charge * pyomo_block.discharge_commodity[t] + + (pyomo_block.commodity_load_demand[t] + - pyomo_block.commodity_out + ) * pyomo_block.penalty_cost_per_unmet_demand ) # Try to incentivize battery charging for t in self.blocks.index_set() - ) + ) ) # Copy the technology objective to the pyomo model. setattr(m, source_tech + "_obj", self.obj) - - # Create the min_operating_cost_objective within each of the technology - # dispatch classes. - self.pyomo_model[source_tech].min_operating_cost_objective( - self.blocks - ) - - # Assemble the objective as a linear summation. - obj += self.power_sources[tech]._dispatch.obj - # hybrid_blocks = getattr(self.pyomo_model, "HybridDispatch") - # for t in self.blocks.index_set(): - # obj += sum( - # hybrid_blocks[t].time_weighting_factor - # * self.blocks[t].time_duration - # * self.blocks[t].electricity_sell_price - # * ( - # self.blocks[t].generation_transmission_limit - # - hybrid_blocks[t].electricity_sold - # ) - # ) - print(f"Objective value: {obj}") + obj += getattr(m, source_tech + "_obj") return obj - self.objective = pyomo.Objective( - rule=operating_cost_objective_rule, sense=pyomo.minimize + self.pyomo_model.objective = pyomo.Objective( + expr=min_operating_cost_objective_rule, sense=pyomo.minimize ) + def _delete_objective(self): + if hasattr(self.pyomo_model, "objective"): + self.pyomo_model.del_component(self.model.objective) + def update_time_series_parameters(self, start_time: int): """Update time series parameters method. @@ -1020,6 +1003,26 @@ def update_time_series_parameters(self, start_time: int): ) self.available_generation = [gen_kw / 1e3 for gen_kw in horizon_gen] + def _create_dispatch_optimization_model(self): + """ + Creates monolith dispatch model + """ + model = pyomo.ConcreteModel(name="hybrid_dispatch") + ################################# + # Sets # + ################################# + model.forecast_horizon = pyomo.Set( + doc="Set of time periods in time horizon", + initialize=range(self.n_horizon_window), + ) + ################################# + # Blocks (technologies) # + ################################# + self.hybrid_dispatch_model = PyomoDispatchPlantRule( + model, model.forecast_horizon, self.source_techs, self.pyomo_model, self.options + ) + return model + @staticmethod def glpk_solve_call( pyomo_model: pyomo.ConcreteModel, From 03d7c1d7e50cfb72bc82c00f5480f51176bc969e Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 10 Dec 2025 19:38:55 -0500 Subject: [PATCH 06/37] Adding hybrid linking constraints and connecting variables in pyomo min operating costs framework --- .../converters/generic_converter_opt.py | 116 +++-- .../control/control_rules/hybrid_rule.py | 51 +- .../storage/pyomo_storage_rule_baseclass.py | 2 +- .../pyomo_storage_rule_min_operating_cost.py | 443 ++++++++++++++++-- .../control_strategies/pyomo_controllers.py | 231 ++------- 5 files changed, 532 insertions(+), 311 deletions(-) diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index 2fa21cc35..279cfe66d 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -18,7 +18,7 @@ class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). """ - commodity_cost_per_production: str = field() + commodity_cost_per_production: float = field() class PyomoDispatchGenericConverterMinOperatingCosts(PyomoDispatchGenericConverter): @@ -34,6 +34,7 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list): self.config["commodity_cost_per_production"] ) + # Base model setup def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """Create generic converter variables to add to Pyomo model instance. @@ -55,10 +56,6 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): initialize=0.0, ), ) - return getattr( - pyomo_model, - f"{tech_name}_{self.config.commodity_name}", - ), 0.0 # load var is zero for converters def _create_ports(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """Create generic converter port to add to pyomo model instance. @@ -69,21 +66,12 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, tech_name: str): ports are created. """ - setattr( - pyomo_model, - f"{tech_name}_port", - Port( - initialize={ - f"{tech_name}_{self.config.commodity_name}": getattr( - pyomo_model, f"{tech_name}_{self.config.commodity_name}" - ) - } + pyomo_model.port = Port() + pyomo_model.port.add( + getattr( + pyomo_model, f"{tech_name}_{self.config.commodity_name}" ), ) - return getattr( - pyomo_model, - f"{tech_name}_port", - ) def _create_parameters(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """Create technology Pyomo parameters to add to the Pyomo model instance. @@ -143,6 +131,7 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, tech_name: str): pass + # Update time series parameters for next optimization window def update_time_series_parameters(self, start_time: int, commodity_in:list, commodity_demand:list, @@ -157,6 +146,78 @@ def update_time_series_parameters(self, start_time: int, self.available_production = [commodity_in[t] for t in self.blocks.index_set()] + # Objective functions + def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): + """Wind instance of minimum operating cost objective. + + Args: + hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical + models by adding modeling components as attributes. + + """ + self.obj = Expression( + expr=sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * self.blocks[t].cost_per_production + * getattr( + hybrid_blocks, + f"{tech_name}_{self.config.commodity_name}", + )[t] + for t in hybrid_blocks.index_set() + ) + ) + + # System-level functions + def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): + """Create hybrid ports for storage to add to pyomo model instance. + + Args: + hybrid_model (pyo.ConcreteModel): hybrid_model the ports should be added to. + tech_name (str): The name or key identifying the technology for which + ports are created. + """ + setattr( + hybrid_model, + f"{tech_name}_port", + Port( + initialize={ + f"{tech_name}_{self.config.commodity_name}": getattr( + hybrid_model, f"{tech_name}_{self.config.commodity_name}" + ) + } + ), + ) + return getattr( + hybrid_model, + f"{tech_name}_port", + ) + + def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: str): + """Create hybrid variables for generic converter technology to add to pyomo model instance. + + Args: + hybrid_model (pyo.ConcreteModel): hybrid_model the variables should be added to. + tech_name (str): The name or key identifying the technology for which + variables are created. + """ + setattr( + hybrid_model, + f"{tech_name}_{self.config.commodity_name}", + pyo.Var( + doc=f"{self.config.commodity_name} production \ + from {tech_name} [{self.config.commodity_storage_units}]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + initialize=0.0, + ), + ) + return getattr( + hybrid_model, + f"{tech_name}_{self.config.commodity_name}", + ), 0.0 # load var is zero for converters + + # Property getters and setters for time series parameters @property def available_production(self) -> list: """Available generation. @@ -195,23 +256,4 @@ def cost_per_production(self, om_dollar_per_kwh: float): round(om_dollar_per_kwh, self.round_digits) ) - def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): - """Wind instance of minimum operating cost objective. - - Args: - hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical - models by adding modeling components as attributes. - """ - self.obj = Expression( - expr=sum( - hybrid_blocks[t].time_weighting_factor - * self.blocks[t].time_duration - * self.blocks[t].cost_per_production - * getattr( - hybrid_blocks, - f"{tech_name}_{self.config.commodity_name}", - )[t] - for t in hybrid_blocks.index_set() - ) - ) diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index b97d3960c..05b694b7c 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -50,8 +50,6 @@ def __init__( setattr(self.model, self.block_set_name, self.blocks) - super().setup() - def dispatch_block_rule(self, hybrid, t): ################################## # Parameters # @@ -79,12 +77,12 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list): def _create_variables_and_ports(self, hybrid, t): for tech in self.source_techs.keys(): pyomo_block = getattr(self.tech_dispatch_models, tech) - gen_var, load_var = pyomo_block._create_variables(hybrid, tech) + gen_var, load_var = pyomo_block._create_hybrid_variables(hybrid, tech) self.power_source_gen_vars[t].append(gen_var) self.load_vars[t].append(load_var) self.ports[t].append( - pyomo_block._create_port(hybrid) + pyomo_block._create_hybrid_port(hybrid) ) @staticmethod @@ -133,31 +131,26 @@ def update_time_series_parameters(self, start_time: int): pyomo_block = getattr(self.tech_dispatch_models, tech) pyomo_block.update_time_series_parameters(start_time) - def update_time_series_parameters(self, start_time: int, commodity_in:list): - """Update time series parameters method. - - Args: - start_time (int): Start time. - - Returns: - None - - """ - n_horizon = len(self.blocks.index_set()) - production = commodity_in - if start_time + n_horizon > len(production): - horizon_gen = list(production[start_time:]) - horizon_gen.extend(list(production[0 : n_horizon - len(horizon_gen)])) - else: - horizon_gen = production[start_time : start_time + n_horizon] - - if len(horizon_gen) < len(self.blocks): - raise RuntimeError( - f"Dispatch parameter update error at start_time {start_time}: System model " - f"{type(self._system_model)} production profile should have at least {len(self.blocks)} " - f"length but has only {len(production)}" - ) - self.available_production = [gen_kw / 1e3 for gen_kw in horizon_gen] + def create_min_operating_cost_expression(self): + self._delete_objective() + + def operationg_cost_objective_rule(m) -> float: + obj = 0.0 + for tech in self.source_techs.keys(): + # Create the min_operating_cost expression for each technology + pyomo_block = getattr(self.tech_dispatch_models, tech) + # Add to the overall hybrid operating cost expression + obj += pyomo_block.operating_cost_expression( self.blocks, tech) + return obj + + # Set operating cost rule in Pyomo problem objective + self.model.objective = pyo.Objective( + rule=operationg_cost_objective_rule, sense=pyo.minimize + ) + + def _delete_objective(self): + if hasattr(self.model, "objective"): + self.model.del_component(self.model.objective) @property def blocks(self) -> pyo.Block: diff --git a/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py b/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py index ef9873f51..c66006087 100644 --- a/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py +++ b/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py @@ -5,7 +5,7 @@ class PyomoRuleStorageBaseclass(PyomoRuleBaseClass): - """Base class defining PYomo rules for generic commodity storage components.""" + """Base class defining Pyomo rules for generic commodity storage components.""" def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): """Create storage-related parameters in the Pyomo model. 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 7fe4fcf05..96fb70603 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 @@ -1,20 +1,63 @@ import pyomo.environ as pyo from pyomo.network import Port, Expression +from attrs import field, define from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import PyomoRuleStorageBaseclass +from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseConfig -class PyomoRuleStorageMinOperatingCosts(PyomoRuleStorageBaseclass): - """Base class defining PYomo rules for generic commodity storage components.""" +@define +class PyomoDispatchStorageMinOperatingCostsConfig(PyomoRuleBaseConfig): + """ + Configuration class for the PyomoDispatchStorageMinOperatingCostsConfig. + + This class defines the parameters required to configure the `PyomoRuleBaseConfig`. + + Attributes: + cost_per_charge (float): cost of the commodity per charge (in $/kWh). + cost_per_discharge (float): cost of the commodity per discharge (in $/kWh). + """ - def initialize_parameters(self, commodity_in: list, commodity_demand: list): + cost_per_charge: float = field() + cost_per_discharge: float = field() + roundtrip_efficiency: float = field(default=0.88) + +class PyomoRuleStorageMinOperatingCosts(PyomoRuleStorageBaseclass): + """Class defining Pyomo rules for the optimized dispatch for load following + for generic commodity storage components.""" + def setup(self): + self.config = PyomoDispatchStorageMinOperatingCostsConfig.from_dict( + self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] + ) + super().setup() + self._create_soc_linking_constraint() + + def initialize_parameters(self, commodity_in: list, commodity_demand: list, + dispatch_inputs: dict): + # Storage parameters + self.cost_per_charge = ( + self.config["cost_per_charge"] + ) + self.cost_per_discharge = ( + self.config["cost_per_discharge"] + ) + self.minimum_storage = 0.0 + self.maximum_storage = dispatch_inputs["max_capacity"] + self.minimum_soc = dispatch_inputs["min_charge_percent"] + self.maximum_soc = dispatch_inputs["max_charge_percent"] + self.initial_soc = dispatch_inputs["initial_soc_percent"] + self.charge_efficiency = dispatch_inputs.get("charge_efficiency", 0.94) + self.discharge_commodity_efficiency = dispatch_inputs.get("discharge_efficiency", 0.94) + + + # System parameters self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set() ] self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set() ] - + # Base model setup def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): """Create storage-related parameters in the Pyomo model. @@ -217,24 +260,33 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): domain=pyo.NonNegativeReals, units=eval("pyo.units." + self.config.commodity_storage_units), ) + ################################## + # System Variables # + ################################## pyomo_model.system_production = pyo.Var( - doc="System generation [MW]", + doc="System generation [" + + self.config.commodity_storage_units + + "]", domain=pyo.NonNegativeReals, units=eval("pyo.units." + self.config.commodity_storage_units), ) pyomo_model.system_load = pyo.Var( - doc="System load [MW]", + doc="System load [" + + self.config.commodity_storage_units + + "]", domain=pyo.NonNegativeReals, units=eval("pyo.units." + self.config.commodity_storage_units), ) pyomo_model.commodity_out = pyo.Var( - doc="Electricity sold [MW]", + doc="Commodity out of the system [" + + self.config.commodity_storage_units + + "]", domain=pyo.NonNegativeReals, bounds=(0, pyomo_model.commodity_load_demand), units=eval("pyo.units." + self.config.commodity_storage_units), ) pyomo_model.is_generating = pyo.Var( - doc="System is generating power", + doc="System is producing commodity binary [-]", domain=pyo.Binary, units=pyo.units.dimensionless ) @@ -244,9 +296,6 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): # domain=pyo.NonNegativeReals, # units=u.MW, # ) - return ( - pyomo_model.discharge_commodity, - pyomo_model.charge_commodity) def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): """Create operational and state-of-charge constraints for storage. @@ -291,7 +340,9 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): doc=pyomo_model.name + " packing constraint for charging and discharging binaries", expr=pyomo_model.is_charging + pyomo_model.is_discharging <= 1, ) - + ################################## + # System constraints # + ################################## pyomo_model.balance = pyo.Constraint( doc="Transmission energy balance", expr=( @@ -304,7 +355,6 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): expr=pyomo_model.commodity_out <= pyomo_model.commodity_load_demand * pyomo_model.is_generating, ) - pyomo_model.production_link = pyo.Constraint() # pyomo_model.purchases_transmission_limit = pyomo.Constraint( # doc="Transmission limit on electricity purchases", # expr=( @@ -334,23 +384,6 @@ def soc_inventory_rule(m): rule=soc_inventory_rule, ) - ################################## - # SOC Linking Constraints # - ################################## - - # TODO: Make work for pyomo optimization, not needed for heuristic method - # Linking time periods together - def storage_soc_linking_rule(m, t): - if t == m.blocks.index_set().first(): - return m.blocks[t].soc0 == m.initial_soc - return m.blocks[t].soc0 == self.blocks[t - 1].soc - - pyomo_model.soc_linking = pyo.Constraint( - pyomo_model.blocks.index_set(), - doc=self.block_set_name + " state-of-charge block linking constraint", - rule=storage_soc_linking_rule, - ) - def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): """Create Pyomo ports for connecting the storage component. @@ -373,6 +406,7 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.port.add(pyomo_model.commodity_out) # pyomo_model.port.add(pyomo_model.electricity_purchased) + # Update time series parameters for next optimization window def update_time_series_parameters(self, start_time: int, commodity_in:list, commodity_demand:list, @@ -392,8 +426,9 @@ def update_time_series_parameters(self, start_time: int, self.commodity_met_value = [time_commodity_met_value[t] for t in self.blocks.index_set()] + # Objective functions def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): - """Wind instance of minimum operating cost objective. + """Storage instance of minimum operating cost objective. Args: hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical @@ -401,19 +436,307 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): """ self.obj = Expression( - expr = sum( - hybrid_blocks[t].time_weighting_factor - * self.blocks[t].time_duration - * ( - self.blocks[t].cost_per_discharge * self.blocks[t].charge_commodity[t] - - self.blocks[t].cost_per_charge * self.blocks[t].discharge_commodity[t] - + (self.blocks[t].commodity_load_demand[t] - - self.blocks[t].commodity_out - ) * self.blocks[t].penalty_cost_per_unmet_demand - ) # Try to incentivize battery charging - for t in self.blocks.index_set() - ) + expr = sum( + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * ( + self.blocks[t].cost_per_discharge * self.blocks[t].charge_commodity[t] + - self.blocks[t].cost_per_charge * self.blocks[t].discharge_commodity[t] + + (self.blocks[t].commodity_load_demand[t] + - self.blocks[t].commodity_out + ) * self.blocks[t].commodity_met_value + ) # Try to incentivize battery charging + for t in self.blocks.index_set() ) + ) + + # System-level functions + def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): + """Create hybrid ports for storage to add to pyomo model instance. + + Args: + hybrid_model (pyo.ConcreteModel): hybrid_model the ports should be added to. + tech_name (str): The name or key identifying the technology for which + ports are created. + """ + hybrid_model.storage_port = Port( + initialize={ + "system_production": hybrid_model.system_production, + "system_load": hybrid_model.system_load, + "commodity_out": hybrid_model.commodity_out, + "charge_commodity": hybrid_model.charge_commodity, + "discharge_commodity": hybrid_model.discharge_commodity, + } + ) + return hybrid_model.storage_port + + def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: str): + """Create hybrid variables for storage to add to pyomo model instance. + + Args: + hybrid_model (pyo.ConcreteModel): hybrid_model the variables should be added to. + tech_name (str): The name or key identifying the technology for which + variables are created. + """ + ################################## + # System Variables # + ################################## + hybrid_model.system_production = pyo.Var( + doc="System generation [MW]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + hybrid_model.system_load = pyo.Var( + doc="System load [MW]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + hybrid_model.commodity_out = pyo.Var( + doc="Electricity sold [MW]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + ################################## + # Storage Variables # + ################################## + hybrid_model.charge_commodity = pyo.Var( + doc=self.config.commodity_name + + " into " + + tech_name + + " [" + + self.config.commodity_storage_units + + "]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + hybrid_model.discharge_commodity = pyo.Var( + doc=self.config.commodity_name + + " out of " + + tech_name + + " [" + + self.config.commodity_storage_units + + "]", + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + return ( + hybrid_model.discharge_commodity, + hybrid_model.charge_commodity) + + # Battery operating functions + def _create_soc_linking_constraint(self): + """Creates state-of-charge linking constraint.""" + ################################## + # Parameters # + ################################## + self.pyomo_model.initial_soc = pyo.Param( + doc=self.block_set_name + " initial state-of-charge at beginning of the horizon[-]", + within=pyo.PercentFraction, + default=0.5, + mutable=True, + units=pyo.units.dimensionless, + ) + ################################## + # Constraints # + ################################## + + # Linking time periods together + def storage_soc_linking_rule(m, t): + if t == self.blocks.index_set().first(): + return self.blocks[t].soc0 == self.pyomo_model.initial_soc + return self.blocks[t].soc0 == self.blocks[t - 1].soc + + self.pyomo_model.soc_linking = pyo.Constraint( + self.blocks.index_set(), + doc=self.block_set_name + " state-of-charge block linking constraint", + rule=storage_soc_linking_rule, + ) + + def _check_initial_soc(self, initial_soc: float) -> float: + """Check that initial state-of-charge is within valid bounds. + + Args: + initial_soc (float): Initial state-of-charge to be checked. + Returns: + float: Validated initial state-of-charge. + """ + if initial_soc > 1: + initial_soc /= 100.0 + initial_soc = round(initial_soc, self.round_digits) + if initial_soc > self.maximum_soc: + print( + "Warning: Storage dispatch was initialized with a state-of-charge greater than " + "maximum value!" + ) + print(f"Initial SOC = {initial_soc}") + print("Initial SOC was set to maximum value.") + initial_soc = self.maximum_soc + elif initial_soc < self.minimum_soc: + print( + "Warning: Storage dispatch was initialized with a state-of-charge less than " + "minimum value!" + ) + print(f"Initial SOC = {initial_soc}") + print("Initial SOC was set to minimum value.") + initial_soc = self.minimum_soc + return initial_soc + + @staticmethod + def _check_efficiency_value(efficiency): + """Checks efficiency is between 0 and 1 or 0 and 100. Returns fractional value""" + if efficiency < 0: + raise ValueError("Efficiency value must greater than 0") + elif efficiency > 1: + efficiency /= 100 + if efficiency > 1: + raise ValueError("Efficiency value must between 0 and 1 or 0 and 100") + return efficiency + + + # INPUTS + @property + def time_duration(self) -> list: + """Time duration.""" + return [self.blocks[t].time_duration.value for t in self.blocks.index_set()] + + @time_duration.setter + def time_duration(self, time_duration: list): + if len(time_duration) == len(self.blocks): + for t, delta in zip(self.blocks, time_duration): + self.blocks[t].time_duration = round(delta, self.round_digits) + else: + raise ValueError( + self.time_duration.__name__ + + " list must be the same length as time horizon" + ) + + # Property getters and setters for time series parameters + @property + def capacity(self) -> float: + """Capacity.""" + for t in self.blocks.index_set(): + return self.blocks[t].capacity.value + + @capacity.setter + def capacity(self, capacity_value: float): + for t in self.blocks.index_set(): + self.blocks[t].capacity = round(capacity_value, self.round_digits) + + @property + def initial_soc(self) -> float: + """Initial state-of-charge.""" + return self.pyomo_model.initial_soc.value + + @initial_soc.setter + def initial_soc(self, initial_soc: float): + initial_soc = self._check_initial_soc(initial_soc) + self.pyomo_model.initial_soc = round(initial_soc, self.round_digits) + + @property + def minimum_soc(self) -> float: + """Minimum state-of-charge.""" + for t in self.blocks.index_set(): + return self.blocks[t].minimum_soc.value + + @minimum_soc.setter + def minimum_soc(self, minimum_soc: float): + for t in self.blocks.index_set(): + self.blocks[t].minimum_soc = round(minimum_soc, self.round_digits) + + @property + def maximum_soc(self) -> float: + """Maximum state-of-charge.""" + for t in self.blocks.index_set(): + return self.blocks[t].maximum_soc.value + + @maximum_soc.setter + def maximum_soc(self, maximum_soc: float): + for t in self.blocks.index_set(): + self.blocks[t].maximum_soc = round(maximum_soc, self.round_digits) + + @property + def minimum_storage(self) -> float: + """Minimum storage.""" + for t in self.blocks.index_set(): + return self.blocks[t].minimum_storage.value + + @minimum_storage.setter + def minimum_storage(self, minimum_storage: float): + for t in self.blocks.index_set(): + self.blocks[t].minimum_storage = round(minimum_storage, self.round_digits) + + @property + def maximum_storage(self) -> float: + """Maximum storage.""" + for t in self.blocks.index_set(): + return self.blocks[t].maximum_storage.value + + @maximum_storage.setter + def maximum_storage(self, maximum_storage: float): + for t in self.blocks.index_set(): + self.blocks[t].maximum_storage = round(maximum_storage, self.round_digits) + + @property + def charge_efficiency(self) -> float: + """Charge efficiency.""" + for t in self.blocks.index_set(): + return self.blocks[t].charge_efficiency.value + + @charge_efficiency.setter + def charge_efficiency(self, efficiency: float): + efficiency = self._check_efficiency_value(efficiency) + for t in self.blocks.index_set(): + self.blocks[t].charge_efficiency = round(efficiency, self.round_digits) + + @property + def discharge_efficiency(self) -> float: + """Discharge efficiency.""" + for t in self.blocks.index_set(): + return self.blocks[t].discharge_efficiency.value + + @discharge_efficiency.setter + def discharge_efficiency(self, efficiency: float): + efficiency = self._check_efficiency_value(efficiency) + for t in self.blocks.index_set(): + self.blocks[t].discharge_efficiency = round(efficiency, self.round_digits) + + @property + def round_trip_efficiency(self) -> float: + """Round trip efficiency.""" + return self.charge_efficiency * self.discharge_efficiency + + @round_trip_efficiency.setter + def round_trip_efficiency(self, round_trip_efficiency: float): + round_trip_efficiency = self._check_efficiency_value(round_trip_efficiency) + # Assumes equal charge and discharge efficiencies + efficiency = round_trip_efficiency ** (1 / 2) + self.charge_efficiency = efficiency + self.discharge_efficiency = efficiency + + @property + def cost_per_charge(self) -> float: + """Cost per charge.""" + for t in self.blocks.index_set(): + return self.blocks[t].cost_per_charge.value + + @cost_per_charge.setter + def cost_per_charge(self, om_dollar_per_kwh: float): + for t in self.blocks.index_set(): + self.blocks[t].cost_per_charge = round(om_dollar_per_kwh, self.round_digits) + + @property + def cost_per_discharge(self) -> float: + """Cost per discharge.""" + for t in self.blocks.index_set(): + return self.blocks[t].cost_per_discharge.value + + @cost_per_discharge.setter + def cost_per_discharge(self, om_dollar_per_kwh: float): + for t in self.blocks.index_set(): + self.blocks[t].cost_per_discharge = round( + om_dollar_per_kwh, self.round_digits + ) + @property def commodity_load_demand(self) -> list: @@ -467,6 +790,40 @@ def commodity_met_value(self, price_per_kwh: list): "'price_per_kwh' list must be the same length as time horizon" ) + # OUTPUTS + @property + def is_charging(self) -> list: + """Storage is charging.""" + return [self.blocks[t].is_charging.value for t in self.blocks.index_set()] + + @property + def is_discharging(self) -> list: + """Storage is discharging.""" + return [self.blocks[t].is_discharging.value for t in self.blocks.index_set()] + + @property + def soc(self) -> list: + """State-of-charge.""" + return [self.blocks[t].soc.value for t in self.blocks.index_set()] + + @property + def charge_commodity(self) -> list: + """Charge commodity.""" + return [self.blocks[t].charge_commodity.value for t in self.blocks.index_set()] + + @property + def discharge_commodity(self) -> list: + """Discharge commodity.""" + return [self.blocks[t].discharge_commodity.value for t in self.blocks.index_set()] + + @property + def storage_output(self) -> list: + """Storage Output.""" + return [ + self.blocks[t].discharge_commodity.value - self.blocks[t].charge_commodity.value + for t in self.blocks.index_set() + ] + @property def system_production(self) -> list: return [self.blocks[t].system_production.value for t in self.blocks.index_set()] diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 0fb5df21f..724a6484e 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -110,6 +110,9 @@ def setup(self): # get technology group name self.tech_group_name = self.pathname.split(".") + # initalize dispatch inputs to None + self.dispatch_options = None + # create inputs for all pyomo object creation functions from all connected technologies self.dispatch_connections = self.options["plant_config"]["tech_to_dispatch_connections"] for connection in self.dispatch_connections: @@ -231,6 +234,7 @@ def pyomo_dispatch_solver( Notes: 1. Arrays returned have length self.n_timesteps (full simulation period). """ + # TODO: implement optional kwargs for this method self.initialize_parameters(inputs[f"{commodity_name}_in"], inputs[f"{commodity_name}_demand"]) @@ -248,6 +252,7 @@ def pyomo_dispatch_solver( # loop over all control windows, where t is the starting index of each window for t in window_start_indices: + # TODO: add inputs for update_time_series_parameters self.update_time_series_parameters() # get the inputs over the current control window commodity_in = inputs[self.config.commodity_name + "_in"][ @@ -298,6 +303,7 @@ def pyomo_dispatch_solver( for j in window_indices: # save the output for the control window to the output for the full # simulation + # TODO: connect soc properly over multiple windows storage_commodity_out[j] = storage_commodity_out_control_window[j - t] soc[j] = soc_control_window[j - t] total_commodity_out[j] = np.minimum( @@ -387,34 +393,6 @@ def initialize_parameters(self): self.maximum_soc = self.config.max_charge_percent self.initial_soc = self.config.init_charge_percent - # def _create_soc_linking_constraint(self): - # """Creates state-of-charge linking constraint.""" - # ################################## - # # Parameters # - # ################################## - # # self.model.initial_soc = pyomo.Param( - # # doc=self.block_set_name + " initial state-of-charge at beginning of the horizon[-]", - # # within=pyomo.PercentFraction, - # # default=0.5, - # # mutable=True, - # # units=u.dimensionless, - # # ) - # ################################## - # # Constraints # - # ################################## - - # # Linking time periods together - # def storage_soc_linking_rule(m, t): - # if t == self.blocks.index_set().first(): - # return self.blocks[t].soc0 == self.model.initial_soc - # return self.blocks[t].soc0 == self.blocks[t - 1].soc - - # self.model.soc_linking = pyomo.Constraint( - # self.blocks.index_set(), - # doc=self.block_set_name + " state-of-charge block linking constraint", - # rule=storage_soc_linking_rule, - # ) - def update_time_series_parameters(self, start_time: int = 0): """Updates time series parameters. @@ -820,7 +798,6 @@ class OptimizedDispatchControllerConfig(PyomoControllerBaseConfig): max_charge_rate: int | float = field() charge_efficiency: float = field(default=None) discharge_efficiency: float = field(default=None) - include_lifecycle_count: bool = field(default=False) demand_profile: list = field(default=None) time_weighting_factor: float = 0.995 @@ -848,23 +825,29 @@ def setup(self): if self.config.discharge_efficiency is not None: self.discharge_efficiency = self.config.discharge_efficiency - self.hybrid_dispatch_model = self._create_dispatch_optimization_model() - self._create_objective_function() - self.hybrid_dispatch_model.create_arcs() - assert_ + self.dispatch_inputs = { + "max_capacity": self.config.max_capacity, + "max_charge_percent": self.config.max_charge_percent, + "min_charge_percent": self.config.min_charge_percent, + "initial_soc_percent": self.config.init_charge_percent, + "charge_efficiency": self.charge_efficiency, + "discharge_efficiency": self.discharge_efficiency, + } + self.hybrid_dispatch_model = self._create_dispatch_optimization_model() + self.hybrid_dispatch_rule.create_min_operating_cost_expression() + self.hybrid_dispatch_rule.create_arcs() + assert_units_consistent(self.hybrid_dispatch_model) + self.problem_state = DispatchProblemState() - # Create objective from pyomo blocks + # Initialize parameters for optimization model def initialize_parameters(self, commodity_in, commodity_demand): - self.time_weighting_factor = ( - self.config.time_weighting_factor - ) # Discount factor - for source_tech in self.source_techs: - pyomo_block = getattr(self.pyomo_model, source_tech) - pyomo_block.initialize_parameters(commodity_in, commodity_demand) + self.hybrid_dispatch_rule.initialize_parameters(commodity_in, commodity_demand) - self._create_objective_function() + def update_time_series_parameters(self, start_time = 0): + # TODO: connect input blocks to pyomo rules for updating time series parameters + self.hybrid_dispatch_rule.update_time_series_parameters(start_time) def solve_dispatch_model( self, @@ -891,117 +874,12 @@ def solve_dispatch_model( solver_results, start_time, n_days, pyomo.value(self.model.objective) ) - self.check_commodity_in_discharge_limit(commodity_in, system_commodity_interface_limit) - self._set_commodity_fraction_limits(commodity_in, system_commodity_interface_limit) - self._heuristic_method(commodity_in, commodity_demand) - self._fix_dispatch_model_variables() - - def _heuristic_method(self, commodity_in, commodity_demand): - """Enforces storage fraction limits and sets _fixed_dispatch attribute. - Sets the _fixed_dispatch based on commodity_demand and commodity_in. - - Args: - commodity_in: commodity generation profile. - commodity_demand: Goal amount of commodity. - - """ - for t in self.blocks.index_set(): - fd = (commodity_demand[t] - commodity_in[t]) / self.maximum_storage - if fd > 0.0: # Discharging - if fd > self.max_discharge_fraction[t]: - fd = self.max_discharge_fraction[t] - elif fd < 0.0: # Charging - if -fd > self.max_charge_fraction[t]: - fd = -self.max_charge_fraction[t] - self._fixed_dispatch[t] = fd - - def _create_objective_function(self): - """Operating cost objective rule. - Returns: - expression: Operating cost objective rule. - """ - self._delete_objective() - - def min_operating_cost_objective_rule(m): - obj = 0.0 - for source_tech in self.source_techs: - pyomo_block = getattr(self.pyomo_model, source_tech) - if source_tech in converters: - # Create the min_operating_cost_objective for converter technologies - self.obj = Expression( - expr = sum( - self.time_weighting_factor[t] - * pyomo_block.time_duration - * pyomo_block.cost_per_generation - * getattr( - pyomo_block, f"{source_tech}_{self.commodity_name}" - )[t] - for t in self.blocks.index_set() - ) - ) - # Copy the technology objective to the pyomo model. - setattr(m, source_tech + "_obj", self.obj) - else: - # Create the min_operating_cost_objective for storage technologies - self.obj = Expression( - expr = sum( - self.time_weighting_factor[t] - * pyomo_block.time_duration - * ( - pyomo_block.cost_per_discharge * pyomo_block.charge_commodity[t] - - pyomo_block.cost_per_charge * pyomo_block.discharge_commodity[t] - + (pyomo_block.commodity_load_demand[t] - - pyomo_block.commodity_out - ) * pyomo_block.penalty_cost_per_unmet_demand - ) # Try to incentivize battery charging - for t in self.blocks.index_set() - ) - ) - # Copy the technology objective to the pyomo model. - setattr(m, source_tech + "_obj", self.obj) - obj += getattr(m, source_tech + "_obj") - - return obj - - self.pyomo_model.objective = pyomo.Objective( - expr=min_operating_cost_objective_rule, sense=pyomo.minimize - ) - - def _delete_objective(self): - if hasattr(self.pyomo_model, "objective"): - self.pyomo_model.del_component(self.model.objective) + # TODO: Check that these function calls are appropriate for optimization model + # self.check_commodity_in_discharge_limit(commodity_in, system_commodity_interface_limit) + # self._set_commodity_fraction_limits(commodity_in, system_commodity_interface_limit) + # self._heuristic_method(commodity_in, commodity_demand) + # self._fix_dispatch_model_variables() - def update_time_series_parameters(self, start_time: int): - """Update time series parameters method. - - Args: - start_time (int): Start time. - - Returns: - None - - """ - # Update time series parameters for blocks - for t in self.blocks: - t.update_time_series_parameters(start_time) - - # Update local time series parameters - - n_horizon = len(self.blocks.index_set()) - generation = self._system_model.value("gen") - if start_time + n_horizon > len(generation): - horizon_gen = list(generation[start_time:]) - horizon_gen.extend(list(generation[0 : n_horizon - len(horizon_gen)])) - else: - horizon_gen = generation[start_time : start_time + n_horizon] - - if len(horizon_gen) < len(self.blocks): - raise RuntimeError( - f"Dispatch parameter update error at start_time {start_time}: System model " - f"{type(self._system_model)} generation profile should have at least {len(self.blocks)} " - f"length but has only {len(generation)}" - ) - self.available_generation = [gen_kw / 1e3 for gen_kw in horizon_gen] def _create_dispatch_optimization_model(self): """ @@ -1018,7 +896,7 @@ def _create_dispatch_optimization_model(self): ################################# # Blocks (technologies) # ################################# - self.hybrid_dispatch_model = PyomoDispatchPlantRule( + self.hybrid_dispatch_rule = PyomoDispatchPlantRule( model, model.forecast_horizon, self.source_techs, self.pyomo_model, self.options ) return model @@ -1066,56 +944,7 @@ def glpk_solve(self): # HybridDispatchBuilderSolver.check_solve_condition( # solver_termination_condition, pyomo_model # ) - @property - def demand_profile(self) -> float: - """Demand profile for the dispatch. - Returns: - list: List of demand profile.""" - return [ - self.blocks[t].demand_profile.value for t in self.blocks.index_set() - ] - - @demand_profile.setter - def demand_profile(self, electricity_demand_profile: list): - if len(electricity_demand_profile) != len(self.blocks.index_set()): - raise ValueError("demand_profile must be the same length as dispatch index set.") - else: - for t in self.blocks.index_set(): - self.blocks[t].demand_profile = round( - electricity_demand_profile[t], self.round_digits - ) - @property - def cost_per_discharge(self) -> float: - """Cost per discharge.""" - for t in self.blocks.index_set(): - return self.blocks[t].cost_per_discharge.value - - @cost_per_discharge.setter - def cost_per_discharge(self, cost_per_discharge: float): - for t in self.blocks.index_set(): - self.blocks[t].cost_per_discharge = round(cost_per_discharge, self.round_digits) - @property - def cost_per_charge(self) -> float: - """Cost per charge.""" - for t in self.blocks.index_set(): - return self.blocks[t].cost_per_charge.value - @cost_per_charge.setter - def cost_per_charge(self, cost_per_charge: float): - for t in self.blocks.index_set(): - self.blocks[t].cost_per_charge = round(cost_per_charge, self.round_digits) - - @property - def time_weighting_factor(self) -> float: - for t in self.blocks.index_set(): - return self.blocks[t + 1].time_weighting_factor.value - - @time_weighting_factor.setter - def time_weighting_factor(self, weighting: float): - for t in self.blocks.index_set(): - self.blocks[t].time_weighting_factor = round( - weighting**t, self.round_digits - ) class SolverOptions: """Class for housing solver options""" From c2f1bbb55636cf6b31e2093820985780ff6cd82c Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 16 Dec 2025 18:38:12 -0500 Subject: [PATCH 07/37] Final structural changes --- .../tech_config.yaml | 7 +++ .../converters/generic_converter_opt.py | 2 +- .../pyomo_storage_rule_min_operating_cost.py | 60 +++++++++++++------ .../control_strategies/pyomo_controllers.py | 19 ++++-- 4 files changed, 63 insertions(+), 25 deletions(-) diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index 9500fa24a..1dfea3e55 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -37,6 +37,7 @@ technologies: dispatch_rule_parameters: commodity_name: "electricity" commodity_storage_units: "kW" + commodity_cost_per_production: 5 battery: dispatch_rule_set: model: "pyomo_dispatch_battery_min_operating_cost" @@ -57,6 +58,9 @@ technologies: max_charge_percent: 0.9 min_charge_percent: 0.1 system_commodity_interface_limit: 1e12 + time_weighting_factor: 0.995 + charge_efficiency: 0.938 + discharge_efficiency: 0.938 performance_parameters: system_model_source: "pysam" chemistry: "LFPGraphite" @@ -72,3 +76,6 @@ technologies: dispatch_rule_parameters: commodity_name: "electricity" commodity_storage_units: "kW" + cost_per_charge: 10 + cost_per_discharge: 10 + commodity_met_value: 20 diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index 279cfe66d..f0ea4da50 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -135,7 +135,7 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, tech_name: str): def update_time_series_parameters(self, start_time: int, commodity_in:list, commodity_demand:list, - time_commodity_met_value:list + # time_commodity_met_value:list ): """Update time series parameters method. 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 96fb70603..a2d06c2a9 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 @@ -20,7 +20,8 @@ class PyomoDispatchStorageMinOperatingCostsConfig(PyomoRuleBaseConfig): cost_per_charge: float = field() cost_per_discharge: float = field() - roundtrip_efficiency: float = field(default=0.88) + # roundtrip_efficiency: float = field(default=0.88) + commodity_met_value = float = field() class PyomoRuleStorageMinOperatingCosts(PyomoRuleStorageBaseclass): """Class defining Pyomo rules for the optimized dispatch for load following @@ -41,6 +42,9 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list, self.cost_per_discharge = ( self.config["cost_per_discharge"] ) + self.commodity_met_value = ( + self.config["commodity_met_value"] + ) self.minimum_storage = 0.0 self.maximum_storage = dispatch_inputs["max_capacity"] self.minimum_soc = dispatch_inputs["min_charge_percent"] @@ -49,6 +53,9 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list, self.charge_efficiency = dispatch_inputs.get("charge_efficiency", 0.94) self.discharge_commodity_efficiency = dispatch_inputs.get("discharge_efficiency", 0.94) + # Set charge and discharge rate equal to eachother for now + self.max_charge_rate = dispatch_inputs["max_charge_rate"] + self.max_discharge_rate = dispatch_inputs["max_charge_rate"] # System parameters self.commodity_load_demand = [commodity_demand[t] @@ -115,7 +122,7 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): + "]", within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.config.commodity_storage_units + "*pyo.units.hr"), ) pyomo_model.minimum_soc = pyo.Param( doc=pyomo_model.name + " minimum state-of-charge [-]", @@ -152,9 +159,15 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): ################################## # Capacity Parameters # ################################## - - pyomo_model.capacity = pyo.Param( - doc=pyomo_model.name + " capacity [" + self.config.commodity_storage_units + "]", + pyomo_model.max_charge = pyo.Param( + doc=pyomo_model.name + " maximum charge [" + self.config.commodity_storage_units + "]", + within=pyo.NonNegativeReals, + mutable=True, + units=eval("pyo.units." + self.config.commodity_storage_units), + ) + pyomo_model.max_discharge = pyo.Param( + doc=pyomo_model.name + " maximum discharge [" + \ + self.config.commodity_storage_units + "]", within=pyo.NonNegativeReals, mutable=True, units=eval("pyo.units." + self.config.commodity_storage_units), @@ -317,7 +330,7 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.charge_commodity_ub = pyo.Constraint( doc=pyomo_model.name + " charging storage upper bound", expr=pyomo_model.charge_commodity - <= pyomo_model.maximum_storage * pyomo_model.is_charging, + <= pyomo_model.max_charge * pyomo_model.is_charging, ) pyomo_model.charge_commodity_lb = pyo.Constraint( doc=pyomo_model.name + " charging storage lower bound", @@ -333,7 +346,7 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.discharge_commodity_ub = pyo.Constraint( doc=pyomo_model.name + " Discharging storage upper bound", expr=pyomo_model.discharge_commodity - <= pyomo_model.maximum_storage * pyomo_model.is_discharging, + <= pyomo_model.max_discharge * pyomo_model.is_discharging, ) # Storage packing constraint pyomo_model.charge_discharge_packing = pyo.Constraint( @@ -375,7 +388,7 @@ def soc_inventory_rule(m): m.charge_efficiency * m.charge_commodity - (1 / m.discharge_efficiency) * m.discharge_commodity ) - / m.capacity + / m.maximum_storage ) # Storage State-of-charge balance @@ -409,8 +422,8 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): # Update time series parameters for next optimization window def update_time_series_parameters(self, start_time: int, commodity_in:list, - commodity_demand:list, - time_commodity_met_value:list + commodity_demand:list + # time_commodity_met_value:list ): """Update time series parameters method. @@ -423,8 +436,9 @@ def update_time_series_parameters(self, start_time: int, for t in self.blocks.index_set()] self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] - self.commodity_met_value = [time_commodity_met_value[t] - for t in self.blocks.index_set()] + # TODO: add back in if needed, needed for variable time series pricing + # self.commodity_met_value = [time_commodity_met_value[t] + # for t in self.blocks.index_set()] # Objective functions def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): @@ -612,15 +626,25 @@ def time_duration(self, time_duration: list): # Property getters and setters for time series parameters @property - def capacity(self) -> float: - """Capacity.""" + def max_charge(self) -> float: + """Maximum charge amount.""" + for t in self.blocks.index_set(): + return self.blocks[t].max_charge.value + + @max_charge.setter + def max_charge(self, max_charge: float): + for t in self.blocks.index_set(): + self.blocks[t].max_charge = round(max_charge, self.round_digits) + + def max_discharge(self) -> float: + """Maximum discharge amount.""" for t in self.blocks.index_set(): - return self.blocks[t].capacity.value + return self.blocks[t].max_discharge.value - @capacity.setter - def capacity(self, capacity_value: float): + @max_discharge.setter + def max_discharge(self, max_discharge: float): for t in self.blocks.index_set(): - self.blocks[t].capacity = round(capacity_value, self.round_digits) + self.blocks[t].max_discharge = round(max_discharge, self.round_digits) @property def initial_soc(self) -> float: diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 724a6484e..7bd6f1263 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -252,8 +252,6 @@ def pyomo_dispatch_solver( # loop over all control windows, where t is the starting index of each window for t in window_start_indices: - # TODO: add inputs for update_time_series_parameters - self.update_time_series_parameters() # get the inputs over the current control window commodity_in = inputs[self.config.commodity_name + "_in"][ t : t + self.config.n_control_window @@ -261,6 +259,8 @@ def pyomo_dispatch_solver( demand_in = inputs[f"{commodity_name}_demand"][t : t + self.config.n_control_window] if "heuristic" in control_strategy: + # Update time series parameters for the heuristic method + self.update_time_series_parameters() # determine dispatch commands for the current control window # using the heuristic method self.set_fixed_dispatch( @@ -270,6 +270,9 @@ def pyomo_dispatch_solver( ) elif "optimized" in control_strategy: + # Update time series parameters for the optimization method + self.update_time_series_parameters(commodity_in=commodity_in, + commodity_demand=demand_in) # Run dispatch optimzation to minimize costs while meeting demand self.solve_dispatch_model( commodity_in, @@ -825,6 +828,7 @@ def setup(self): if self.config.discharge_efficiency is not None: self.discharge_efficiency = self.config.discharge_efficiency + # Is this the best place to put this??? self.dispatch_inputs = { "max_capacity": self.config.max_capacity, "max_charge_percent": self.config.max_charge_percent, @@ -832,6 +836,7 @@ def setup(self): "initial_soc_percent": self.config.init_charge_percent, "charge_efficiency": self.charge_efficiency, "discharge_efficiency": self.discharge_efficiency, + "max_charge_rate": self.config.max_charge_rate, } self.hybrid_dispatch_model = self._create_dispatch_optimization_model() @@ -843,11 +848,13 @@ def setup(self): # Initialize parameters for optimization model def initialize_parameters(self, commodity_in, commodity_demand): - self.hybrid_dispatch_rule.initialize_parameters(commodity_in, commodity_demand) + self.hybrid_dispatch_rule.initialize_parameters(commodity_in, commodity_demand, + self.dispatch_inputs) - def update_time_series_parameters(self, start_time = 0): - # TODO: connect input blocks to pyomo rules for updating time series parameters - self.hybrid_dispatch_rule.update_time_series_parameters(start_time) + def update_time_series_parameters(self, start_time = 0, commodity_in = None, commodity_demand = None): + self.hybrid_dispatch_rule.update_time_series_parameters(start_time, + commodity_in, + commodity_demand) def solve_dispatch_model( self, From 4d676fb213c3f23d790d5f7fb869d5f36358365a Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 16 Dec 2025 18:40:21 -0500 Subject: [PATCH 08/37] Fix import statement --- h2integrate/core/supported_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 56a2b7f0d..d9e173249 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -125,6 +125,7 @@ ) from h2integrate.control.control_rules.converters.generic_converter_opt import ( PyomoDispatchGenericConverterMinOperatingCosts, +) from h2integrate.control.control_strategies.passthrough_openloop_controller import ( PassThroughOpenLoopController, ) From 0c642d08307315271a16ab63c14e185814295fa7 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 16 Dec 2025 18:47:16 -0500 Subject: [PATCH 09/37] Fix imports and setter method --- .../control_rules/converters/generic_converter_opt.py | 4 ++-- .../storage/pyomo_storage_rule_min_operating_cost.py | 5 +++-- h2integrate/control/control_strategies/pyomo_controllers.py | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index f0ea4da50..c258e5fa8 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -1,5 +1,5 @@ import pyomo.environ as pyo -from pyomo.network import Port, Expression +from pyomo.network import Port from attrs import field, define from h2integrate.control.control_rules.converters.generic_converter import ( @@ -155,7 +155,7 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): models by adding modeling components as attributes. """ - self.obj = Expression( + self.obj = pyo.Expression( expr=sum( hybrid_blocks[t].time_weighting_factor * self.blocks[t].time_duration 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 a2d06c2a9..0571879f7 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 @@ -1,5 +1,5 @@ import pyomo.environ as pyo -from pyomo.network import Port, Expression +from pyomo.network import Port from attrs import field, define from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import PyomoRuleStorageBaseclass @@ -449,7 +449,7 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): models by adding modeling components as attributes. """ - self.obj = Expression( + self.obj = pyo.Expression( expr = sum( hybrid_blocks[t].time_weighting_factor * self.blocks[t].time_duration @@ -636,6 +636,7 @@ def max_charge(self, max_charge: float): for t in self.blocks.index_set(): self.blocks[t].max_charge = round(max_charge, self.round_digits) + @property def max_discharge(self) -> float: """Maximum discharge amount.""" for t in self.blocks.index_set(): diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 7bd6f1263..08d3f0352 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -1,7 +1,8 @@ from typing import TYPE_CHECKING import numpy as np -import pyomo.environ as pyomo, Expression, del_component, assert_units_consistent +import pyomo.environ as pyomo +from pyomo.util.check_units import assert_units_consistent from attrs import field, define from h2integrate.core.utilities import BaseConfig, merge_shared_inputs From 22e411f7fcde2c9b08c54bbc9e282af2abecf894 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 23 Dec 2025 16:02:28 -0500 Subject: [PATCH 10/37] First draft of running code --- .../tech_config.yaml | 18 +- .../converters/generic_converter_opt.py | 169 ++++++--- .../control/control_rules/hybrid_rule.py | 149 ++++++-- .../control_rules/pyomo_rule_baseclass.py | 6 +- .../pyomo_storage_rule_min_operating_cost.py | 330 +++++++++++------- .../control_strategies/pyomo_controllers.py | 150 +++++--- 6 files changed, 562 insertions(+), 260 deletions(-) diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index 1dfea3e55..d9791d2ad 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -8,7 +8,7 @@ technologies: cost_model: model: "atb_wind_cost" dispatch_rule_set: - model: "pyomo_dispatch_generic_converter_min_operating_cost" + model: "pyomo_dispatch_generic_converter" resource: type: "pysam_wind" wind_speed: 9. @@ -37,10 +37,9 @@ technologies: dispatch_rule_parameters: commodity_name: "electricity" commodity_storage_units: "kW" - commodity_cost_per_production: 5 battery: dispatch_rule_set: - model: "pyomo_dispatch_battery_min_operating_cost" + model: "pyomo_dispatch_generic_storage" control_strategy: model: "optimized_dispatch_controller" performance_model: @@ -61,6 +60,11 @@ technologies: time_weighting_factor: 0.995 charge_efficiency: 0.938 discharge_efficiency: 0.938 + commodity_storage_units: "kW" + cost_per_charge: 10 + cost_per_discharge: 10 + commodity_met_value: 20 + cost_per_production: 5 performance_parameters: system_model_source: "pysam" chemistry: "LFPGraphite" @@ -71,11 +75,5 @@ technologies: power_capex: 311 # $/kW from 2024 ATB year 2025 opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB control_parameters: - commodity_storage_units: "kW" + # commodity_storage_units: "kW" tech_name: "battery" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" - cost_per_charge: 10 - cost_per_discharge: 10 - commodity_met_value: 20 diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index c258e5fa8..7d3afbec3 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -7,35 +7,75 @@ ) from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseConfig -@define -class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): - """ - Configuration class for the PyomoDispatchGenericConverterMinOperatingCostsConfig. - - This class defines the parameters required to configure the `PyomoRuleBaseConfig`. - - Attributes: - commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). - """ +# @define +# class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): +# """ +# Configuration class for the PyomoDispatchGenericConverterMinOperatingCostsConfig. + +# This class defines the parameters required to configure the `PyomoRuleBaseConfig`. +""" +Attributes: + commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). +""" + +commodity_cost_per_production: float = field() + + +class PyomoDispatchGenericConverterMinOperatingCosts: + + def __init__( + self, + commodity_info: dict, + pyomo_model: pyo.ConcreteModel, + index_set: pyo.Set, + block_set_name: str = "converter", + ): + + self.round_digits = int(4) + self.block_set_name = block_set_name + self.commodity_name = commodity_info["commodity_name"] + self.commodity_storage_units = commodity_info["commodity_storage_units"] + print(self.commodity_name, self.commodity_storage_units) + + self._model = pyomo_model + self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) + setattr(self.model, self.block_set_name, self.blocks) + self.time_duration = [1.0] * len(self.blocks.index_set()) + + print("HEYYYY") + + def initialize_parameters(self, commodity_in: list, commodity_demand: list, + dispatch_inputs: dict): + """Initialize parameters method. + """ - commodity_cost_per_production: float = field() + self.cost_per_production = dispatch_inputs["cost_per_production"] + def dispatch_block_rule_function(self, pyomo_model: pyo.ConcreteModel): + """ + Creates and initializes pyomo dispatch model components for a specific technology. -class PyomoDispatchGenericConverterMinOperatingCosts(PyomoDispatchGenericConverter): - def setup(self): - self.config = PyomoDispatchGenericConverterMinOperatingCostsConfig.from_dict( - self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] - ) - super().setup() + This method sets up all model elements (parameters, variables, constraints, + and ports) associated with a technology block within the dispatch model. + It is typically called in the setup_pyomo() method of the PyomoControllerBaseClass. - def initialize_parameters(self, commodity_in: list, commodity_demand: list): - """Initialize parameters method.""" - self.cost_per_production = ( - self.config["commodity_cost_per_production"] - ) + Args: + pyomo_model (pyo.ConcreteModel): The Pyomo model to which the technology + components will be added. + tech_name (str): The name or key identifying the technology (e.g., "battery", + "electrolyzer") for which model components are created. + """ + # Parameters + self._create_parameters(pyomo_model) + # Variables + self._create_variables(pyomo_model) + # Constraints + self._create_constraints(pyomo_model) + # Ports + self._create_ports(pyomo_model) # Base model setup - def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + def _create_variables(self, pyomo_model: pyo.ConcreteModel): """Create generic converter variables to add to Pyomo model instance. Args: @@ -46,18 +86,18 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """ setattr( pyomo_model, - f"{tech_name}_{self.config.commodity_name}", + f"{self.block_set_name}_{self.commodity_name}", pyo.Var( - doc=f"{self.config.commodity_name} production \ - from {tech_name} [{self.config.commodity_storage_units}]", + doc=f"{self.commodity_name} production \ + from {self.block_set_name} [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, bounds=(0, pyomo_model.available_production), - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), initialize=0.0, ), ) - def _create_ports(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + def _create_ports(self, pyomo_model: pyo.ConcreteModel): """Create generic converter port to add to pyomo model instance. Args: @@ -69,11 +109,11 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, tech_name: str): pyomo_model.port = Port() pyomo_model.port.add( getattr( - pyomo_model, f"{tech_name}_{self.config.commodity_name}" + pyomo_model, f"{self.block_set_name}_{self.commodity_name}" ), ) - def _create_parameters(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + def _create_parameters(self, pyomo_model: pyo.ConcreteModel): """Create technology Pyomo parameters to add to the Pyomo model instance. Method is currently passed but this can serve as a template to add parameters to the Pyomo @@ -97,26 +137,26 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, tech_name: str): ) pyomo_model.cost_per_production = pyo.Param( doc="Production cost for generator [$/" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+"h"), ) pyomo_model.available_production = pyo.Param( doc="Available production for the generator [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=0.0, within=pyo.Reals, mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pass - def _create_constraints(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + def _create_constraints(self, pyomo_model: pyo.ConcreteModel): """Create technology Pyomo parameters to add to the Pyomo model instance. Method is currently passed but this can serve as a template to add constraints to the Pyomo @@ -143,6 +183,7 @@ def update_time_series_parameters(self, start_time: int, start_time (int): The starting time index for the update. commodity_in (list): List of commodity input values for each time step. """ + self.time_duration = [1.0] * len(self.blocks.index_set()) self.available_production = [commodity_in[t] for t in self.blocks.index_set()] @@ -155,18 +196,26 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): models by adding modeling components as attributes. """ - self.obj = pyo.Expression( - expr=sum( + # commodity_name = getattr( + # hybrid_blocks, + # f"{tech_name}_{self.commodity_name}", + # ) + commodity_set = [getattr(hybrid_blocks[t], f"{tech_name}_{self.commodity_name}") + for t in self.blocks.index_set()] + i = hybrid_blocks.index_set()[1] + print("Units???",self.blocks[i].time_duration.get_units()) + print(commodity_set[i].get_units()) + print(self.blocks[i].cost_per_production.get_units()) + self.obj =sum( hybrid_blocks[t].time_weighting_factor * self.blocks[t].time_duration * self.blocks[t].cost_per_production - * getattr( - hybrid_blocks, - f"{tech_name}_{self.config.commodity_name}", - )[t] + # * commodity_set[t].value + * getattr(hybrid_blocks[t], f"{tech_name}_{self.commodity_name}") for t in hybrid_blocks.index_set() ) - ) + # print(self.obj.get_units()) + return self.obj # System-level functions def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): @@ -182,8 +231,8 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): f"{tech_name}_port", Port( initialize={ - f"{tech_name}_{self.config.commodity_name}": getattr( - hybrid_model, f"{tech_name}_{self.config.commodity_name}" + f"{tech_name}_{self.commodity_name}": getattr( + hybrid_model, f"{tech_name}_{self.commodity_name}" ) } ), @@ -203,18 +252,18 @@ def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: s """ setattr( hybrid_model, - f"{tech_name}_{self.config.commodity_name}", + f"{tech_name}_{self.commodity_name}", pyo.Var( - doc=f"{self.config.commodity_name} production \ - from {tech_name} [{self.config.commodity_storage_units}]", + doc=f"{self.commodity_name} production \ + from {tech_name} [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), initialize=0.0, ), ) return getattr( hybrid_model, - f"{tech_name}_{self.config.commodity_name}", + f"{tech_name}_{self.commodity_name}", ), 0.0 # load var is zero for converters # Property getters and setters for time series parameters @@ -256,4 +305,26 @@ def cost_per_production(self, om_dollar_per_kwh: float): round(om_dollar_per_kwh, self.round_digits) ) + @property + def time_duration(self) -> list: + """Time duration.""" + return [self.blocks[t].time_duration.value for t in self.blocks.index_set()] + + @time_duration.setter + def time_duration(self, time_duration: list): + if len(time_duration) == len(self.blocks): + for t, delta in zip(self.blocks, time_duration): + self.blocks[t].time_duration = round(delta, self.round_digits) + else: + raise ValueError( + self.time_duration.__name__ + + " list must be the same length as time horizon" + ) + + @property + def blocks(self) -> pyo.Block: + return self._blocks + @property + def model(self) -> pyo.ConcreteModel: + return self._model diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index 05b694b7c..2812da1a9 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -24,7 +24,7 @@ def __init__( self, pyomo_model: pyo.ConcreteModel, index_set: pyo.Set, - source_techs: dict, + source_techs: list, tech_dispatch_models: pyo.ConcreteModel, dispatch_options: dict, block_set_name: str = "hybrid", @@ -45,6 +45,8 @@ def __init__( self.block_set_name = block_set_name self.round_digits = int(4) + + self._model = pyomo_model self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule) setattr(self.model, self.block_set_name, self.blocks) @@ -65,26 +67,31 @@ def dispatch_block_rule(self, hybrid, t): self._create_hybrid_constraints(hybrid, t) - def initialize_parameters(self, commodity_in: list, commodity_demand: list): + def initialize_parameters(self, commodity_in: list, commodity_demand: list, + dispatch_params:dict): """Initialize parameters method.""" self.time_weighting_factor = ( self.options.time_weighting_factor ) # Discount factor - for tech in self.source_techs.keys(): - pyomo_block = getattr(self.tech_dispatch_models, tech) - pyomo_block.initialize_parameters(commodity_in, commodity_demand) + for tech in self.source_techs: + name = tech+"_rule" + pyomo_block = getattr(self.tech_dispatch_models, name) + pyomo_block.initialize_parameters(commodity_in, commodity_demand, dispatch_params) def _create_variables_and_ports(self, hybrid, t): - for tech in self.source_techs.keys(): - pyomo_block = getattr(self.tech_dispatch_models, tech) - gen_var, load_var = pyomo_block._create_hybrid_variables(hybrid, tech) + + for tech in self.source_techs: + name = tech+"_rule" + pyomo_block = getattr(self.tech_dispatch_models, name) + gen_var, load_var = pyomo_block._create_hybrid_variables(hybrid, name) self.power_source_gen_vars[t].append(gen_var) self.load_vars[t].append(load_var) self.ports[t].append( - pyomo_block._create_hybrid_port(hybrid) + pyomo_block._create_hybrid_port(hybrid, name) ) + @staticmethod def _create_parameters(hybrid): hybrid.time_weighting_factor = pyo.Param( @@ -110,11 +117,12 @@ def create_arcs(self): ################################## # Arcs # ################################## - for tech in self.source_techs.keys(): - pyomo_block = getattr(self.tech_dispatch_models, tech) + for tech in self.source_techs: + name = tech+"_rule" + pyomo_block = getattr(self.tech_dispatch_models, name) def arc_rule(m, t): - source_port = self.pyomo_block.blocks[t].port - destination_port = getattr(self.blocks[t], tech + "_port") + source_port = pyomo_block.blocks[t].port + destination_port = getattr(self.blocks[t], name + "_port") return {"source": source_port, "destination": destination_port} setattr( @@ -126,21 +134,28 @@ def arc_rule(m, t): pyo.TransformationFactory("network.expand_arcs").apply_to(self.model) - def update_time_series_parameters(self, start_time: int): - for tech in self.source_techs.keys(): - pyomo_block = getattr(self.tech_dispatch_models, tech) - pyomo_block.update_time_series_parameters(start_time) + def update_time_series_parameters(self, start_time: int, + commodity_in = list, + commodity_demand = list): + for tech in self.source_techs: + name = tech+"_rule" + pyomo_block = getattr(self.tech_dispatch_models, name) + pyomo_block.update_time_series_parameters(start_time, + commodity_in, + commodity_demand) def create_min_operating_cost_expression(self): self._delete_objective() def operationg_cost_objective_rule(m) -> float: obj = 0.0 - for tech in self.source_techs.keys(): + for tech in self.source_techs: + name = tech+"_rule" + print("Obj function", name) # Create the min_operating_cost expression for each technology - pyomo_block = getattr(self.tech_dispatch_models, tech) + pyomo_block = getattr(self.tech_dispatch_models, name) # Add to the overall hybrid operating cost expression - obj += pyomo_block.operating_cost_expression( self.blocks, tech) + obj += pyomo_block.min_operating_cost_objective(self.blocks, name) return obj # Set operating cost rule in Pyomo problem objective @@ -159,3 +174,97 @@ def blocks(self) -> pyo.Block: @property def model(self) -> pyo.ConcreteModel: return self._model + + @property + def time_weighting_factor(self) -> float: + for t in self.blocks.index_set(): + return self.blocks[t + 1].time_weighting_factor.value + + @time_weighting_factor.setter + def time_weighting_factor(self, weighting: float): + for t in self.blocks.index_set(): + self.blocks[t].time_weighting_factor = round( + weighting**t, self.round_digits + ) + + @property + def time_weighting_factor_list(self) -> list: + return [ + self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set() + ] + + # Outputs + @property + def objective_value(self): + return pyo.value(self.model.objective) + + @property + def get_production_value(self, tech_name, commodity_name): + return f"{tech_name}_{commodity_name}" + + # @property + # def pv_generation(self) -> list: + # return [self.blocks[t].pv_generation.value for t in self.blocks.index_set()] + + # @property + # def wind_generation(self) -> list: + # return [self.blocks[t].wind_generation.value for t in self.blocks.index_set()] + + # @property + # def wave_generation(self) -> list: + # return [self.blocks[t].wave_generation.value for t in self.blocks.index_set()] + + # @property + # def tidal_generation(self) -> list: + # return [self.blocks[t].tidal_generation.value for t in self.blocks.index_set()] + + # @property + # def generic_generation(self) -> list: + # return [self.blocks[t].generic_generation.value for t in self.blocks.index_set()] + + @property + def charge_commodity(self) -> list: + return [self.blocks[t].charge_commodity.value for t in self.blocks.index_set()] + + @property + def discharge_commodity(self) -> list: + return [self.blocks[t].discharge_commodity.value for t in self.blocks.index_set()] + + @property + def system_production(self) -> list: + return [self.blocks[t].system_production.value for t in self.blocks.index_set()] + + @property + def system_load(self) -> list: + return [self.blocks[t].system_load.value for t in self.blocks.index_set()] + + + @property + def storage_commodity_out(self) -> list: + """Storage commodity out.""" + return [ + self.blocks[t].discharge_commodity.value - self.blocks[t].charge_commodity.value + for t in self.blocks.index_set() + ] + + # @property + # def electricity_sales(self) -> list: + # if "grid" in self.power_sources: + # tb = self.power_sources["grid"].dispatch.blocks + # return [ + # tb[t].time_duration.value + # * tb[t].electricity_sell_price.value + # * self.blocks[t].electricity_sold.value + # for t in self.blocks.index_set() + # ] + + # @property + # def electricity_purchases(self) -> list: + # if "grid" in self.power_sources: + # tb = self.power_sources["grid"].dispatch.blocks + # return [ + # tb[t].time_duration.value + # * tb[t].electricity_purchase_price.value + # * self.blocks[t].electricity_purchased.value + # for t in self.blocks.index_set() + # ] diff --git a/h2integrate/control/control_rules/pyomo_rule_baseclass.py b/h2integrate/control/control_rules/pyomo_rule_baseclass.py index ae0a57377..9cf3c2914 100644 --- a/h2integrate/control/control_rules/pyomo_rule_baseclass.py +++ b/h2integrate/control/control_rules/pyomo_rule_baseclass.py @@ -2,7 +2,7 @@ import pyomo.environ as pyo from attrs import field, define -from h2integrate.core.utilities import BaseConfig +from h2integrate.core.utilities import BaseConfig, merge_shared_inputs @define @@ -29,7 +29,9 @@ def initialize(self): def setup(self): self.config = PyomoRuleBaseConfig.from_dict( - self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] + merge_shared_inputs(self.options["tech_config"]["model_inputs"], + "dispatch_rule"), + strict=False ) self.round_digits = int(4) 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 0571879f7..b760bbbac 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 @@ -6,47 +6,57 @@ from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseConfig -@define -class PyomoDispatchStorageMinOperatingCostsConfig(PyomoRuleBaseConfig): - """ - Configuration class for the PyomoDispatchStorageMinOperatingCostsConfig. +# @define +# class PyomoDispatchStorageMinOperatingCostsConfig(PyomoRuleBaseConfig): +# """ +# Configuration class for the PyomoDispatchStorageMinOperatingCostsConfig. - This class defines the parameters required to configure the `PyomoRuleBaseConfig`. +# This class defines the parameters required to configure the `PyomoRuleBaseConfig`. - Attributes: - cost_per_charge (float): cost of the commodity per charge (in $/kWh). - cost_per_discharge (float): cost of the commodity per discharge (in $/kWh). - """ +# Attributes: +# cost_per_charge (float): cost of the commodity per charge (in $/kWh). +# cost_per_discharge (float): cost of the commodity per discharge (in $/kWh). +# """ - cost_per_charge: float = field() - cost_per_discharge: float = field() - # roundtrip_efficiency: float = field(default=0.88) - commodity_met_value = float = field() +# cost_per_charge: float = field() +# cost_per_discharge: float = field() +# # roundtrip_efficiency: float = field(default=0.88) +# commodity_met_value: float = field() -class PyomoRuleStorageMinOperatingCosts(PyomoRuleStorageBaseclass): +class PyomoRuleStorageMinOperatingCosts: """Class defining Pyomo rules for the optimized dispatch for load following for generic commodity storage components.""" - def setup(self): - self.config = PyomoDispatchStorageMinOperatingCostsConfig.from_dict( - self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] - ) - super().setup() - self._create_soc_linking_constraint() + + def __init__( + self, + commodity_info: dict, + pyomo_model: pyo.ConcreteModel, + index_set: pyo.Set, + block_set_name: str = "storage", + ): + + self.round_digits = int(4) + self.block_set_name = block_set_name + self.commodity_name = commodity_info["commodity_name"] + self.commodity_storage_units = commodity_info["commodity_storage_units"] + + self._model = pyomo_model + self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) + setattr(self.model, self.block_set_name, self.blocks) + print("HEYYYY-storage") + def initialize_parameters(self, commodity_in: list, commodity_demand: list, dispatch_inputs: dict): + # Dispatch Parameters + self.cost_per_charge = dispatch_inputs["cost_per_charge"] + self.cost_per_discharge = dispatch_inputs["cost_per_discharge"] + self.commodity_met_value = dispatch_inputs["commodity_met_value"] # Storage parameters - self.cost_per_charge = ( - self.config["cost_per_charge"] - ) - self.cost_per_discharge = ( - self.config["cost_per_discharge"] - ) - self.commodity_met_value = ( - self.config["commodity_met_value"] - ) self.minimum_storage = 0.0 self.maximum_storage = dispatch_inputs["max_capacity"] + print("maximum_storage",self.maximum_storage) + print(self.minimum_storage) self.minimum_soc = dispatch_inputs["min_charge_percent"] self.maximum_soc = dispatch_inputs["max_charge_percent"] self.initial_soc = dispatch_inputs["initial_soc_percent"] @@ -54,8 +64,8 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list, self.discharge_commodity_efficiency = dispatch_inputs.get("discharge_efficiency", 0.94) # Set charge and discharge rate equal to eachother for now - self.max_charge_rate = dispatch_inputs["max_charge_rate"] - self.max_discharge_rate = dispatch_inputs["max_charge_rate"] + self.max_charge = dispatch_inputs["max_charge_rate"] + self.max_discharge = dispatch_inputs["max_charge_rate"] # System parameters self.commodity_load_demand = [commodity_demand[t] @@ -64,6 +74,32 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list, self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set() ] + self._set_initial_soc_constraint() + + + def dispatch_block_rule_function(self, pyomo_model: pyo.ConcreteModel, tech_name: str): + """ + Creates and initializes pyomo dispatch model components for a specific technology. + + This method sets up all model elements (parameters, variables, constraints, + and ports) associated with a technology block within the dispatch model. + It is typically called in the setup_pyomo() method of the PyomoControllerBaseClass. + + Args: + pyomo_model (pyo.ConcreteModel): The Pyomo model to which the technology + components will be added. + tech_name (str): The name or key identifying the technology (e.g., "battery", + "electrolyzer") for which model components are created. + """ + # Parameters + self._create_parameters(pyomo_model, tech_name) + # Variables + self._create_variables(pyomo_model, tech_name) + # Constraints + self._create_constraints(pyomo_model, tech_name) + # Ports + self._create_ports(pyomo_model, tech_name) + # Base model setup def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): """Create storage-related parameters in the Pyomo model. @@ -89,40 +125,43 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): ) pyomo_model.cost_per_charge = pyo.Param( doc="Operating cost of " + pyomo_model.name + " charging [$/" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+ + "h"), ) pyomo_model.cost_per_discharge = pyo.Param( doc="Operating cost of " + pyomo_model.name + " discharging [$/" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+ + "h"), ) pyomo_model.minimum_storage = pyo.Param( doc=pyomo_model.name + " minimum storage rating [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.maximum_storage = pyo.Param( doc=pyomo_model.name + " maximum storage rating [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", + default=1000.0, within=pyo.NonNegativeReals, - mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units + "*pyo.units.hr"), + mutable=False, + units=eval("pyo.units." + self.commodity_storage_units + "h"), ) pyomo_model.minimum_soc = pyo.Param( doc=pyomo_model.name + " minimum state-of-charge [-]", @@ -160,17 +199,17 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): # Capacity Parameters # ################################## pyomo_model.max_charge = pyo.Param( - doc=pyomo_model.name + " maximum charge [" + self.config.commodity_storage_units + "]", + doc=pyomo_model.name + " maximum charge [" + self.commodity_storage_units + "]", within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.max_discharge = pyo.Param( doc=pyomo_model.name + " maximum discharge [" + \ - self.config.commodity_storage_units + "]", + self.commodity_storage_units + "]", within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) ################################## # System Parameters # @@ -184,12 +223,13 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): ) pyomo_model.commodity_met_value = pyo.Param( doc="Commodity demand met value per generation [$/" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=0.0, within=pyo.Reals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+ + "h"), ) # grid.electricity_purchase_price = pyomo.Param( # doc="Electricity purchase price [$/MWh]", @@ -200,21 +240,21 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): # ) pyomo_model.commodity_load_demand = pyo.Param( doc="Load demand for the commodity [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=1000.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.load_production_limit = pyo.Param( doc="Production limit for load [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", default=1000.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): @@ -254,49 +294,49 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): units=pyo.units.dimensionless, ) pyomo_model.charge_commodity = pyo.Var( - doc=self.config.commodity_name + doc=self.commodity_name + " into " + pyomo_model.name + " [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.discharge_commodity = pyo.Var( - doc=self.config.commodity_name + doc=self.commodity_name + " out of " + pyomo_model.name + " [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) ################################## # System Variables # ################################## pyomo_model.system_production = pyo.Var( doc="System generation [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.system_load = pyo.Var( doc="System load [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.commodity_out = pyo.Var( doc="Commodity out of the system [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, bounds=(0, pyomo_model.commodity_load_demand), - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.is_generating = pyo.Var( doc="System is producing commodity binary [-]", @@ -397,6 +437,32 @@ def soc_inventory_rule(m): rule=soc_inventory_rule, ) + def _set_initial_soc_constraint(self): + ################################## + # SOC Linking # + ################################## + self.model.initial_soc = pyo.Param( + doc=self.commodity_name + " initial state-of-charge at beginning of the horizon[-]", + within=pyo.PercentFraction, + default=0.5, + mutable=True, + units=pyo.units.dimensionless, + ) + ################################## + # SOC Constraints # + ################################## + # Linking time periods together + def storage_soc_linking_rule(m, t): + if t == self.blocks.index_set().first(): + return self.blocks[t].soc0 == m.initial_soc + return self.blocks[t].soc0 == self.blocks[t - 1].soc + + self.model.soc_linking = pyo.Constraint( + self.blocks.index_set(), + doc=self.block_set_name + " state-of-charge block linking constraint", + rule=storage_soc_linking_rule, + ) + def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): """Create Pyomo ports for connecting the storage component. @@ -449,20 +515,25 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): models by adding modeling components as attributes. """ - self.obj = pyo.Expression( - expr = sum( + self.obj = sum( hybrid_blocks[t].time_weighting_factor * self.blocks[t].time_duration * ( - self.blocks[t].cost_per_discharge * self.blocks[t].charge_commodity[t] - - self.blocks[t].cost_per_charge * self.blocks[t].discharge_commodity[t] - + (self.blocks[t].commodity_load_demand[t] - - self.blocks[t].commodity_out + self.blocks[t].cost_per_discharge * hybrid_blocks[t].discharge_commodity + - self.blocks[t].cost_per_charge * hybrid_blocks[t].charge_commodity + + (self.blocks[t].commodity_load_demand + - hybrid_blocks[t].commodity_out ) * self.blocks[t].commodity_met_value - ) # Try to incentivize battery charging + + # + ( + # * self.blocks[t].electricity_purchase_price + # * hybrid_blocks[t].electricity_purchased + # ) + ) + # Try to incentivize battery charging for t in self.blocks.index_set() ) - ) + return self.obj # System-level functions def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): @@ -473,7 +544,7 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): tech_name (str): The name or key identifying the technology for which ports are created. """ - hybrid_model.storage_port = Port( + setattr(hybrid_model, f"{tech_name}_port", Port( initialize={ "system_production": hybrid_model.system_production, "system_load": hybrid_model.system_load, @@ -482,7 +553,8 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): "discharge_commodity": hybrid_model.discharge_commodity, } ) - return hybrid_model.storage_port + ) + return getattr(hybrid_model, f"{tech_name}_port") def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: str): """Create hybrid variables for storage to add to pyomo model instance. @@ -495,76 +567,46 @@ def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: s ################################## # System Variables # ################################## + # TODO: fix the units on these hybrid_model.system_production = pyo.Var( doc="System generation [MW]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) hybrid_model.system_load = pyo.Var( doc="System load [MW]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) hybrid_model.commodity_out = pyo.Var( doc="Electricity sold [MW]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) ################################## # Storage Variables # ################################## hybrid_model.charge_commodity = pyo.Var( - doc=self.config.commodity_name + doc=self.commodity_name + " into " + tech_name + " [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), + units=eval("pyo.units." + self.commodity_storage_units), ) hybrid_model.discharge_commodity = pyo.Var( - doc=self.config.commodity_name + doc=self.commodity_name + " out of " + tech_name + " [" - + self.config.commodity_storage_units + + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.config.commodity_storage_units), - ) - return ( - hybrid_model.discharge_commodity, - hybrid_model.charge_commodity) - - # Battery operating functions - def _create_soc_linking_constraint(self): - """Creates state-of-charge linking constraint.""" - ################################## - # Parameters # - ################################## - self.pyomo_model.initial_soc = pyo.Param( - doc=self.block_set_name + " initial state-of-charge at beginning of the horizon[-]", - within=pyo.PercentFraction, - default=0.5, - mutable=True, - units=pyo.units.dimensionless, - ) - ################################## - # Constraints # - ################################## - - # Linking time periods together - def storage_soc_linking_rule(m, t): - if t == self.blocks.index_set().first(): - return self.blocks[t].soc0 == self.pyomo_model.initial_soc - return self.blocks[t].soc0 == self.blocks[t - 1].soc - - self.pyomo_model.soc_linking = pyo.Constraint( - self.blocks.index_set(), - doc=self.block_set_name + " state-of-charge block linking constraint", - rule=storage_soc_linking_rule, + units=eval("pyo.units." + self.commodity_storage_units), ) + return hybrid_model.discharge_commodity, hybrid_model.charge_commodity def _check_initial_soc(self, initial_soc: float) -> float: """Check that initial state-of-charge is within valid bounds. @@ -606,6 +648,14 @@ def _check_efficiency_value(efficiency): raise ValueError("Efficiency value must between 0 and 1 or 0 and 100") return efficiency + # Dispatch Model Variables + @property + def blocks(self) -> pyo.Block: + return self._blocks + + @property + def model(self) -> pyo.ConcreteModel: + return self._model # INPUTS @property @@ -647,15 +697,15 @@ def max_discharge(self, max_discharge: float): for t in self.blocks.index_set(): self.blocks[t].max_discharge = round(max_discharge, self.round_digits) - @property - def initial_soc(self) -> float: - """Initial state-of-charge.""" - return self.pyomo_model.initial_soc.value + # @property + # def initial_soc(self) -> float: + # """Initial state-of-charge.""" + # return pyomo_model.initial_soc.value - @initial_soc.setter - def initial_soc(self, initial_soc: float): - initial_soc = self._check_initial_soc(initial_soc) - self.pyomo_model.initial_soc = round(initial_soc, self.round_digits) + # @initial_soc.setter + # def initial_soc(self, initial_soc: float): + # initial_soc = self._check_initial_soc(initial_soc) + # pyomo_model.initial_soc = round(initial_soc, self.round_digits) @property def minimum_soc(self) -> float: @@ -697,9 +747,9 @@ def maximum_storage(self) -> float: return self.blocks[t].maximum_storage.value @maximum_storage.setter - def maximum_storage(self, maximum_storage: float): + def maximum_storage(self, capcity_value: float): for t in self.blocks.index_set(): - self.blocks[t].maximum_storage = round(maximum_storage, self.round_digits) + self.blocks[t].maximum_storage = round(capcity_value, self.round_digits) @property def charge_efficiency(self) -> float: @@ -798,22 +848,26 @@ def load_production_limit(self, commodity_demand: list): raise ValueError("'commodity_demand' list must be the same length as time horizon") @property - def commodity_met_value(self) -> list: + def commodity_met_value(self) -> float: return [ self.blocks[t].commodity_met_value.value for t in self.blocks.index_set() ] @commodity_met_value.setter - def commodity_met_value(self, price_per_kwh: list): - if len(price_per_kwh) == len(self.blocks): - for t, price in zip(self.blocks, price_per_kwh): - self.blocks[t].commodity_met_value.set_value( - round(price, self.round_digits) - ) - else: - raise ValueError( - "'price_per_kwh' list must be the same length as time horizon" - ) + def commodity_met_value(self, price_per_kwh: float): + for t in self.blocks.index_set(): + self.blocks[t].commodity_met_value = round(price_per_kwh, self.round_digits) + + ### The following method is if the value of meeting the demand is variable + # if len(price_per_kwh) == len(self.blocks): + # for t, price in zip(self.blocks, price_per_kwh): + # self.blocks[t].commodity_met_value.set_value( + # round(price, self.round_digits) + # ) + # else: + # raise ValueError( + # "'price_per_kwh' list must be the same length as time horizon" + # ) # OUTPUTS @property @@ -861,6 +915,14 @@ def system_load(self) -> list: def commodity_out(self) -> list: return [self.blocks[t].commodity_out.value for t in self.blocks.index_set()] + @property + def storage_commodity_out(self) -> list: + """Storage commodity out.""" + return [ + self.blocks[t].discharge_commodity.value - self.blocks[t].charge_commodity.value + for t in self.blocks.index_set() + ] + # @property # def electricity_purchased(self) -> list: # return [ diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 08d3f0352..d80ceb94e 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -10,6 +10,10 @@ from h2integrate.control.control_strategies.controller_baseclass import ControllerBaseClass from h2integrate.control.control_strategies.controller_opt_problem_state import DispatchProblemState from h2integrate.control.control_rules.hybrid_rule import PyomoDispatchPlantRule +from h2integrate.control.control_rules.converters.generic_converter_opt \ +import PyomoDispatchGenericConverterMinOperatingCosts +from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost \ +import PyomoRuleStorageMinOperatingCosts if TYPE_CHECKING: # to avoid circular imports @@ -157,6 +161,7 @@ def pyomo_setup(self, discrete_inputs): index_set = pyomo.Set(initialize=range(self.config.n_control_window)) self.source_techs = [] + self.dispatch_tech = [] # run each pyomo rule set up function for each technology for connection in self.dispatch_connections: @@ -168,6 +173,7 @@ def pyomo_setup(self, discrete_inputs): # connecting from an external tech group. This facilitates OM connections if source_tech == intended_dispatch_tech: dispatch_block_rule_function = discrete_inputs["dispatch_block_rule_function"] + self.dispatch_tech.append(source_tech) else: dispatch_block_rule_function = discrete_inputs[ f"{'dispatch_block_rule_function'}_{source_tech}" @@ -177,6 +183,7 @@ def pyomo_setup(self, discrete_inputs): print("HIII", blocks) setattr(self.pyomo_model, source_tech, blocks) self.source_techs.append(source_tech) + print(getattr(self.pyomo_model, source_tech)) else: continue @@ -253,6 +260,7 @@ def pyomo_dispatch_solver( # loop over all control windows, where t is the starting index of each window for t in window_start_indices: + print("Iteration tracker:", t) # get the inputs over the current control window commodity_in = inputs[self.config.commodity_name + "_in"][ t : t + self.config.n_control_window @@ -686,42 +694,42 @@ def maximum_soc(self, maximum_soc: float): for t in self.blocks.index_set(): self.blocks[t].maximum_soc = round(maximum_soc, self.round_digits) - @property - def charge_efficiency(self) -> float: - """Charge efficiency.""" - for t in self.blocks.index_set(): - return self.blocks[t].charge_efficiency.value - - @charge_efficiency.setter - def charge_efficiency(self, efficiency: float): - efficiency = self._check_efficiency_value(efficiency) - for t in self.blocks.index_set(): - self.blocks[t].charge_efficiency = round(efficiency, self.round_digits) - - @property - def discharge_efficiency(self) -> float: - """Discharge efficiency.""" - for t in self.blocks.index_set(): - return self.blocks[t].discharge_efficiency.value - - @discharge_efficiency.setter - def discharge_efficiency(self, efficiency: float): - efficiency = self._check_efficiency_value(efficiency) - for t in self.blocks.index_set(): - self.blocks[t].discharge_efficiency = round(efficiency, self.round_digits) - - @property - def round_trip_efficiency(self) -> float: - """Round trip efficiency.""" - return self.charge_efficiency * self.discharge_efficiency - - @round_trip_efficiency.setter - def round_trip_efficiency(self, round_trip_efficiency: float): - round_trip_efficiency = self._check_efficiency_value(round_trip_efficiency) - # Assumes equal charge and discharge efficiencies - efficiency = round_trip_efficiency ** (1 / 2) - self.charge_efficiency = efficiency - self.discharge_efficiency = efficiency + # @property + # def charge_efficiency(self) -> float: + # """Charge efficiency.""" + # for t in self.blocks.index_set(): + # return self.blocks[t].charge_efficiency.value + + # @charge_efficiency.setter + # def charge_efficiency(self, efficiency: float): + # efficiency = self._check_efficiency_value(efficiency) + # for t in self.blocks.index_set(): + # self.blocks[t].charge_efficiency = round(efficiency, self.round_digits) + + # @property + # def discharge_efficiency(self) -> float: + # """Discharge efficiency.""" + # for t in self.blocks.index_set(): + # return self.blocks[t].discharge_efficiency.value + + # @discharge_efficiency.setter + # def discharge_efficiency(self, efficiency: float): + # efficiency = self._check_efficiency_value(efficiency) + # for t in self.blocks.index_set(): + # self.blocks[t].discharge_efficiency = round(efficiency, self.round_digits) + + # @property + # def round_trip_efficiency(self) -> float: + # """Round trip efficiency.""" + # return self.charge_efficiency * self.discharge_efficiency + + # @round_trip_efficiency.setter + # def round_trip_efficiency(self, round_trip_efficiency: float): + # round_trip_efficiency = self._check_efficiency_value(round_trip_efficiency) + # # Assumes equal charge and discharge efficiencies + # efficiency = round_trip_efficiency ** (1 / 2) + # self.charge_efficiency = efficiency + # self.discharge_efficiency = efficiency @define @@ -804,7 +812,12 @@ class OptimizedDispatchControllerConfig(PyomoControllerBaseConfig): discharge_efficiency: float = field(default=None) demand_profile: list = field(default=None) time_weighting_factor: float = 0.995 - + commodity_name: str = field(default=None) + commodity_storage_units: str = field(default=None) + cost_per_production: float = field(default=None) + cost_per_charge: float = field(default=None) + cost_per_discharge: float = field(default=None) + commodity_met_value: float = field(default=None) class OptimizedDispatchController(SimpleBatteryControllerHeuristic): """Operates the battery based on heuristic rules to meet the demand profile based power @@ -830,7 +843,18 @@ def setup(self): self.discharge_efficiency = self.config.discharge_efficiency # Is this the best place to put this??? + self.commodity_info = { + "commodity_name": self.config.commodity_name, + "commodity_storage_units": self.config.commodity_storage_units, + } + # TODO: note that this definition of cost_per_production is not generalizable to multiple + # production technologies. Would need a name adjustment to connect it to + # production tech self.dispatch_inputs = { + "cost_per_production": self.config.cost_per_production, + "cost_per_charge": self.config.cost_per_charge, + "cost_per_discharge": self.config.cost_per_discharge, + "commodity_met_value": self.config.commodity_met_value, "max_capacity": self.config.max_capacity, "max_charge_percent": self.config.max_charge_percent, "min_charge_percent": self.config.min_charge_percent, @@ -840,15 +864,18 @@ def setup(self): "max_charge_rate": self.config.max_charge_rate, } + self.n_control_window = self.config.n_control_window + self.n_horizon_window = self.config.n_control_window + + + # Initialize parameters for optimization model + def initialize_parameters(self, commodity_in, commodity_demand): self.hybrid_dispatch_model = self._create_dispatch_optimization_model() self.hybrid_dispatch_rule.create_min_operating_cost_expression() self.hybrid_dispatch_rule.create_arcs() assert_units_consistent(self.hybrid_dispatch_model) self.problem_state = DispatchProblemState() - - # Initialize parameters for optimization model - def initialize_parameters(self, commodity_in, commodity_demand): self.hybrid_dispatch_rule.initialize_parameters(commodity_in, commodity_demand, self.dispatch_inputs) @@ -876,10 +903,10 @@ def solve_dispatch_model( """ - self.problem_state = DispatchProblemState() + # self.problem_state = DispatchProblemState() solver_results = self.glpk_solve() self.problem_state.store_problem_metrics( - solver_results, start_time, n_days, pyomo.value(self.model.objective) + solver_results, start_time, n_days, pyomo.value(self.hybrid_dispatch_model.objective) ) # TODO: Check that these function calls are appropriate for optimization model @@ -901,11 +928,33 @@ def _create_dispatch_optimization_model(self): doc="Set of time periods in time horizon", initialize=range(self.n_horizon_window), ) + for tech in self.source_techs: + if tech == self.dispatch_tech[0]: + # tech.dispatch = PyomoRuleStorageMinOperatingCosts() + name = tech+"_rule" + dispatch = PyomoRuleStorageMinOperatingCosts( + self.commodity_info, + model, + model.forecast_horizon, + block_set_name=name + ) + setattr(self.pyomo_model, name, dispatch) + else: + name = tech+"_rule" + dispatch = PyomoDispatchGenericConverterMinOperatingCosts( + self.commodity_info, + model, + model.forecast_horizon, + block_set_name=name + ) + # tech.dispatch = PyomoDispatchGenericConverterMinOperatingCosts() + setattr(self.pyomo_model, name, dispatch) + ################################# # Blocks (technologies) # - ################################# + ################################# self.hybrid_dispatch_rule = PyomoDispatchPlantRule( - model, model.forecast_horizon, self.source_techs, self.pyomo_model, self.options + model, model.forecast_horizon, self.source_techs, self.pyomo_model, self.config ) return model @@ -940,8 +989,11 @@ def glpk_solve_call( def glpk_solve(self): return self.glpk_solve_call( - self.pyomo_model, self.options.log_name, self.options.solver_options + # self.pyomo_model + self.hybrid_dispatch_model ) + # self.pyomo_model, log_name='', user_solver_options=dict({}) + # @staticmethod # def log_and_solution_check( @@ -953,6 +1005,14 @@ def glpk_solve(self): # solver_termination_condition, pyomo_model # ) + @property + def storage_dispatch_commands(self) -> list: + """ + Commanded dispatch including available commodity at current time step that has not + been used to charge the battery. + """ + return self.hybrid_dispatch_rule.storage_commodity_out + class SolverOptions: """Class for housing solver options""" From efc2069e11495c1a20d6ca50b511bf698073649d Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 30 Dec 2025 10:17:33 -0500 Subject: [PATCH 11/37] Update example --- .../run_pyomo_heuristic_dispatch.py | 2 +- examples/25_pyomo_optimized_dispatch/tech_config.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py b/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py index 642dd03a7..87ca42ff0 100644 --- a/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py +++ b/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py @@ -7,7 +7,7 @@ # Create an H2Integrate model model = H2IntegrateModel("pyomo_heuristic_dispatch.yaml") -demand_profile = np.ones(8760) * 50.0 +demand_profile = np.ones(8760) * 100.0 # TODO: Update with demand module once it is developed diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index d9791d2ad..53be13677 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -61,10 +61,10 @@ technologies: charge_efficiency: 0.938 discharge_efficiency: 0.938 commodity_storage_units: "kW" - cost_per_charge: 10 - cost_per_discharge: 10 - commodity_met_value: 20 - cost_per_production: 5 + cost_per_charge: 0.05 + cost_per_discharge: 0.1 + commodity_met_value: 0.5 + cost_per_production: 0.01 performance_parameters: system_model_source: "pysam" chemistry: "LFPGraphite" From e9473324669152640af6919c4336d09bd12f063d Mon Sep 17 00:00:00 2001 From: kbrunik Date: Fri, 2 Jan 2026 12:16:06 -0600 Subject: [PATCH 12/37] test --- h2integrate/core/supported_models.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 3c3a7757b..410c52a49 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -93,8 +93,8 @@ from h2integrate.converters.hydrogen.singlitico_cost_model import SingliticoCostModel from h2integrate.converters.co2.marine.direct_ocean_capture import DOCCostModel, DOCPerformanceModel from h2integrate.control.control_strategies.pyomo_controllers import ( - HeuristicLoadFollowingController, OptimizedDispatchController, + HeuristicLoadFollowingController, ) from h2integrate.converters.hydrogen.geologic.mathur_modified import GeoH2SubsurfaceCostModel from h2integrate.resource.solar.nrel_developer_goes_api_models import ( @@ -126,18 +126,15 @@ from h2integrate.converters.hydrogen.custom_electrolyzer_cost_model import ( CustomElectrolyzerCostModel, ) +from h2integrate.control.control_rules.converters.generic_converter_opt import ( + PyomoDispatchGenericConverterMinOperatingCosts, +) from h2integrate.converters.hydrogen.geologic.templeton_serpentinization import ( StimulatedGeoH2PerformanceModel, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( PyomoRuleStorageBaseclass, ) -from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost import ( - PyomoRuleStorageMinOperatingCosts, -) -from h2integrate.control.control_rules.converters.generic_converter_opt import ( - PyomoDispatchGenericConverterMinOperatingCosts, -) from h2integrate.control.control_strategies.passthrough_openloop_controller import ( PassThroughOpenLoopController, ) @@ -151,6 +148,9 @@ from h2integrate.control.control_strategies.converters.demand_openloop_controller import ( DemandOpenLoopConverterController, ) +from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost import ( + PyomoRuleStorageMinOperatingCosts, +) from h2integrate.control.control_strategies.converters.flexible_demand_openloop_controller import ( FlexibleDemandOpenLoopConverterController, ) @@ -256,9 +256,7 @@ # Dispatch "pyomo_dispatch_generic_converter": PyomoDispatchGenericConverter, "pyomo_dispatch_generic_storage": PyomoRuleStorageBaseclass, - "pyomo_dispatch_battery_min_operating_cost": ( - PyomoRuleStorageMinOperatingCosts - ), + "pyomo_dispatch_battery_min_operating_cost": (PyomoRuleStorageMinOperatingCosts), "pyomo_dispatch_generic_converter_min_operating_cost": ( PyomoDispatchGenericConverterMinOperatingCosts ), From 581e10153c29483fd9706298802f30ef0990d2a7 Mon Sep 17 00:00:00 2001 From: kbrunik Date: Fri, 2 Jan 2026 12:20:39 -0600 Subject: [PATCH 13/37] fix precommits --- .../tech_config.yaml | 3 +- .../converters/generic_converter_opt.py | 70 +++---- .../control/control_rules/hybrid_rule.py | 56 ++---- .../control_rules/pyomo_rule_baseclass.py | 7 +- .../pyomo_storage_rule_min_operating_cost.py | 189 +++++++----------- .../controller_opt_problem_state.py | 11 +- .../control_strategies/pyomo_controllers.py | 70 +++---- 7 files changed, 161 insertions(+), 245 deletions(-) diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index 53be13677..229bfb850 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -1,6 +1,7 @@ name: "technology_config" description: "This hybrid plant produces hydrogen" + technologies: wind: performance_model: @@ -63,7 +64,7 @@ technologies: commodity_storage_units: "kW" cost_per_charge: 0.05 cost_per_discharge: 0.1 - commodity_met_value: 0.5 + commodity_met_value: 0.5 cost_per_production: 0.01 performance_parameters: system_model_source: "pysam" diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index 7d3afbec3..c96655f90 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -1,11 +1,7 @@ import pyomo.environ as pyo +from attrs import field from pyomo.network import Port -from attrs import field, define -from h2integrate.control.control_rules.converters.generic_converter import ( - PyomoDispatchGenericConverter -) -from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseConfig # @define # class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): @@ -22,7 +18,6 @@ class PyomoDispatchGenericConverterMinOperatingCosts: - def __init__( self, commodity_info: dict, @@ -30,8 +25,7 @@ def __init__( index_set: pyo.Set, block_set_name: str = "converter", ): - - self.round_digits = int(4) + self.round_digits = 4 self.block_set_name = block_set_name self.commodity_name = commodity_info["commodity_name"] self.commodity_storage_units = commodity_info["commodity_storage_units"] @@ -44,10 +38,10 @@ def __init__( print("HEYYYY") - def initialize_parameters(self, commodity_in: list, commodity_demand: list, - dispatch_inputs: dict): - """Initialize parameters method. - """ + def initialize_parameters( + self, commodity_in: list, commodity_demand: list, dispatch_inputs: dict + ): + """Initialize parameters method.""" self.cost_per_production = dispatch_inputs["cost_per_production"] @@ -108,9 +102,7 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel): """ pyomo_model.port = Port() pyomo_model.port.add( - getattr( - pyomo_model, f"{self.block_set_name}_{self.commodity_name}" - ), + getattr(pyomo_model, f"{self.block_set_name}_{self.commodity_name}"), ) def _create_parameters(self, pyomo_model: pyo.ConcreteModel): @@ -136,18 +128,14 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel): units=pyo.units.hr, ) pyomo_model.cost_per_production = pyo.Param( - doc="Production cost for generator [$/" - + self.commodity_storage_units - + "]", + doc="Production cost for generator [$/" + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+"h"), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), ) pyomo_model.available_production = pyo.Param( - doc="Available production for the generator [" - + self.commodity_storage_units - + "]", + doc="Available production for the generator [" + self.commodity_storage_units + "]", default=0.0, within=pyo.Reals, mutable=True, @@ -172,11 +160,13 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel): pass # Update time series parameters for next optimization window - def update_time_series_parameters(self, start_time: int, - commodity_in:list, - commodity_demand:list, - # time_commodity_met_value:list - ): + def update_time_series_parameters( + self, + start_time: int, + commodity_in: list, + commodity_demand: list, + # time_commodity_met_value:list + ): """Update time series parameters method. Args: @@ -184,8 +174,7 @@ def update_time_series_parameters(self, start_time: int, commodity_in (list): List of commodity input values for each time step. """ self.time_duration = [1.0] * len(self.blocks.index_set()) - self.available_production = [commodity_in[t] - for t in self.blocks.index_set()] + self.available_production = [commodity_in[t] for t in self.blocks.index_set()] # Objective functions def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): @@ -200,20 +189,22 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): # hybrid_blocks, # f"{tech_name}_{self.commodity_name}", # ) - commodity_set = [getattr(hybrid_blocks[t], f"{tech_name}_{self.commodity_name}") - for t in self.blocks.index_set()] + commodity_set = [ + getattr(hybrid_blocks[t], f"{tech_name}_{self.commodity_name}") + for t in self.blocks.index_set() + ] i = hybrid_blocks.index_set()[1] - print("Units???",self.blocks[i].time_duration.get_units()) + print("Units???", self.blocks[i].time_duration.get_units()) print(commodity_set[i].get_units()) print(self.blocks[i].cost_per_production.get_units()) - self.obj =sum( + self.obj = sum( hybrid_blocks[t].time_weighting_factor * self.blocks[t].time_duration * self.blocks[t].cost_per_production # * commodity_set[t].value * getattr(hybrid_blocks[t], f"{tech_name}_{self.commodity_name}") for t in hybrid_blocks.index_set() - ) + ) # print(self.obj.get_units()) return self.obj @@ -275,17 +266,13 @@ def available_production(self) -> list: list: List of available generation. """ - return [ - self.blocks[t].available_production.value for t in self.blocks.index_set() - ] + return [self.blocks[t].available_production.value for t in self.blocks.index_set()] @available_production.setter def available_production(self, resource: list): if len(resource) == len(self.blocks): for t, gen in zip(self.blocks, resource): - self.blocks[t].available_production.set_value( - round(gen, self.round_digits) - ) + self.blocks[t].available_production.set_value(round(gen, self.round_digits)) else: raise ValueError( f"'resource' list ({len(resource)}) must be the same length as\ @@ -317,8 +304,7 @@ def time_duration(self, time_duration: list): self.blocks[t].time_duration = round(delta, self.round_digits) else: raise ValueError( - self.time_duration.__name__ - + " list must be the same length as time horizon" + self.time_duration.__name__ + " list must be the same length as time horizon" ) @property diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index 2812da1a9..b778ca5c5 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -1,6 +1,6 @@ import pyomo.environ as pyo -from pyomo.network import Port, Arc -from attrs import field, define +from pyomo.network import Arc + # from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseClass @@ -33,7 +33,6 @@ def __init__( # self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] # ) - self.source_techs = source_techs self.options = dispatch_options self.power_source_gen_vars = {key: [] for key in index_set} @@ -43,15 +42,12 @@ def __init__( self.arcs = [] self.block_set_name = block_set_name - self.round_digits = int(4) - - + self.round_digits = 4 self._model = pyomo_model self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule) setattr(self.model, self.block_set_name, self.blocks) - def dispatch_block_rule(self, hybrid, t): ################################## # Parameters # @@ -66,31 +62,25 @@ def dispatch_block_rule(self, hybrid, t): ################################## self._create_hybrid_constraints(hybrid, t) - - def initialize_parameters(self, commodity_in: list, commodity_demand: list, - dispatch_params:dict): + def initialize_parameters( + self, commodity_in: list, commodity_demand: list, dispatch_params: dict + ): """Initialize parameters method.""" - self.time_weighting_factor = ( - self.options.time_weighting_factor - ) # Discount factor + self.time_weighting_factor = self.options.time_weighting_factor # Discount factor for tech in self.source_techs: - name = tech+"_rule" + name = tech + "_rule" pyomo_block = getattr(self.tech_dispatch_models, name) pyomo_block.initialize_parameters(commodity_in, commodity_demand, dispatch_params) def _create_variables_and_ports(self, hybrid, t): - for tech in self.source_techs: - name = tech+"_rule" + name = tech + "_rule" pyomo_block = getattr(self.tech_dispatch_models, name) gen_var, load_var = pyomo_block._create_hybrid_variables(hybrid, name) self.power_source_gen_vars[t].append(gen_var) self.load_vars[t].append(load_var) - self.ports[t].append( - pyomo_block._create_hybrid_port(hybrid, name) - ) - + self.ports[t].append(pyomo_block._create_hybrid_port(hybrid, name)) @staticmethod def _create_parameters(hybrid): @@ -118,8 +108,9 @@ def create_arcs(self): # Arcs # ################################## for tech in self.source_techs: - name = tech+"_rule" + name = tech + "_rule" pyomo_block = getattr(self.tech_dispatch_models, name) + def arc_rule(m, t): source_port = pyomo_block.blocks[t].port destination_port = getattr(self.blocks[t], name + "_port") @@ -134,15 +125,13 @@ def arc_rule(m, t): pyo.TransformationFactory("network.expand_arcs").apply_to(self.model) - def update_time_series_parameters(self, start_time: int, - commodity_in = list, - commodity_demand = list): + def update_time_series_parameters( + self, start_time: int, commodity_in=list, commodity_demand=list + ): for tech in self.source_techs: - name = tech+"_rule" + name = tech + "_rule" pyomo_block = getattr(self.tech_dispatch_models, name) - pyomo_block.update_time_series_parameters(start_time, - commodity_in, - commodity_demand) + pyomo_block.update_time_series_parameters(start_time, commodity_in, commodity_demand) def create_min_operating_cost_expression(self): self._delete_objective() @@ -150,7 +139,7 @@ def create_min_operating_cost_expression(self): def operationg_cost_objective_rule(m) -> float: obj = 0.0 for tech in self.source_techs: - name = tech+"_rule" + name = tech + "_rule" print("Obj function", name) # Create the min_operating_cost expression for each technology pyomo_block = getattr(self.tech_dispatch_models, name) @@ -183,15 +172,11 @@ def time_weighting_factor(self) -> float: @time_weighting_factor.setter def time_weighting_factor(self, weighting: float): for t in self.blocks.index_set(): - self.blocks[t].time_weighting_factor = round( - weighting**t, self.round_digits - ) + self.blocks[t].time_weighting_factor = round(weighting**t, self.round_digits) @property def time_weighting_factor_list(self) -> list: - return [ - self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set() - ] + return [self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set()] # Outputs @property @@ -238,7 +223,6 @@ def system_production(self) -> list: def system_load(self) -> list: return [self.blocks[t].system_load.value for t in self.blocks.index_set()] - @property def storage_commodity_out(self) -> list: """Storage commodity out.""" diff --git a/h2integrate/control/control_rules/pyomo_rule_baseclass.py b/h2integrate/control/control_rules/pyomo_rule_baseclass.py index c56fcb8eb..84f2e6768 100644 --- a/h2integrate/control/control_rules/pyomo_rule_baseclass.py +++ b/h2integrate/control/control_rules/pyomo_rule_baseclass.py @@ -29,12 +29,11 @@ def initialize(self): def setup(self): self.config = PyomoRuleBaseConfig.from_dict( - merge_shared_inputs(self.options["tech_config"]["model_inputs"], - "dispatch_rule"), - strict=False + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "dispatch_rule"), + strict=False, ) - self.round_digits = int(4) + self.round_digits = 4 self.add_discrete_output( "dispatch_block_rule_function", 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 b760bbbac..3ed82e3a1 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 @@ -1,10 +1,6 @@ import pyomo.environ as pyo from pyomo.network import Port -from attrs import field, define -from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import PyomoRuleStorageBaseclass - -from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseConfig # @define # class PyomoDispatchStorageMinOperatingCostsConfig(PyomoRuleBaseConfig): @@ -23,9 +19,10 @@ # # roundtrip_efficiency: float = field(default=0.88) # commodity_met_value: float = field() + class PyomoRuleStorageMinOperatingCosts: """Class defining Pyomo rules for the optimized dispatch for load following - for generic commodity storage components.""" + for generic commodity storage components.""" def __init__( self, @@ -34,8 +31,7 @@ def __init__( index_set: pyo.Set, block_set_name: str = "storage", ): - - self.round_digits = int(4) + self.round_digits = 4 self.block_set_name = block_set_name self.commodity_name = commodity_info["commodity_name"] self.commodity_storage_units = commodity_info["commodity_storage_units"] @@ -45,9 +41,9 @@ def __init__( setattr(self.model, self.block_set_name, self.blocks) print("HEYYYY-storage") - - def initialize_parameters(self, commodity_in: list, commodity_demand: list, - dispatch_inputs: dict): + def initialize_parameters( + self, commodity_in: list, commodity_demand: list, dispatch_inputs: dict + ): # Dispatch Parameters self.cost_per_charge = dispatch_inputs["cost_per_charge"] self.cost_per_discharge = dispatch_inputs["cost_per_discharge"] @@ -55,7 +51,7 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list, # Storage parameters self.minimum_storage = 0.0 self.maximum_storage = dispatch_inputs["max_capacity"] - print("maximum_storage",self.maximum_storage) + print("maximum_storage", self.maximum_storage) print(self.minimum_storage) self.minimum_soc = dispatch_inputs["min_charge_percent"] self.maximum_soc = dispatch_inputs["max_charge_percent"] @@ -68,15 +64,10 @@ def initialize_parameters(self, commodity_in: list, commodity_demand: list, self.max_discharge = dispatch_inputs["max_charge_rate"] # System parameters - self.commodity_load_demand = [commodity_demand[t] - for t in self.blocks.index_set() - ] - self.load_production_limit = [commodity_demand[t] - for t in self.blocks.index_set() - ] + self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] + self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] self._set_initial_soc_constraint() - def dispatch_block_rule_function(self, pyomo_model: pyo.ConcreteModel, tech_name: str): """ Creates and initializes pyomo dispatch model components for a specific technology. @@ -124,40 +115,36 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): units=pyo.units.hr, ) pyomo_model.cost_per_charge = pyo.Param( - doc="Operating cost of " + pyomo_model.name + " charging [$/" + doc="Operating cost of " + + pyomo_model.name + + " charging [$/" + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+ - "h"), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), ) pyomo_model.cost_per_discharge = pyo.Param( - doc="Operating cost of " + pyomo_model.name + " discharging [$/" + doc="Operating cost of " + + pyomo_model.name + + " discharging [$/" + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+ - "h"), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), ) pyomo_model.minimum_storage = pyo.Param( - doc=pyomo_model.name - + " minimum storage rating [" - + self.commodity_storage_units - + "]", + doc=pyomo_model.name + " minimum storage rating [" + self.commodity_storage_units + "]", default=0.0, within=pyo.NonNegativeReals, mutable=True, units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.maximum_storage = pyo.Param( - doc=pyomo_model.name - + " maximum storage rating [" - + self.commodity_storage_units - + "]", + doc=pyomo_model.name + " maximum storage rating [" + self.commodity_storage_units + "]", default=1000.0, within=pyo.NonNegativeReals, mutable=False, @@ -205,8 +192,7 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.max_discharge = pyo.Param( - doc=pyomo_model.name + " maximum discharge [" + \ - self.commodity_storage_units + "]", + doc=pyomo_model.name + " maximum discharge [" + self.commodity_storage_units + "]", within=pyo.NonNegativeReals, mutable=True, units=eval("pyo.units." + self.commodity_storage_units), @@ -228,8 +214,7 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): default=0.0, within=pyo.Reals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units+ - "h"), + units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), ) # grid.electricity_purchase_price = pyomo.Param( # doc="Electricity purchase price [$/MWh]", @@ -239,18 +224,14 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): # units=u.USD / u.MWh, # ) pyomo_model.commodity_load_demand = pyo.Param( - doc="Load demand for the commodity [" - + self.commodity_storage_units - + "]", + doc="Load demand for the commodity [" + self.commodity_storage_units + "]", default=1000.0, within=pyo.NonNegativeReals, mutable=True, units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.load_production_limit = pyo.Param( - doc="Production limit for load [" - + self.commodity_storage_units - + "]", + doc="Production limit for load [" + self.commodity_storage_units + "]", default=1000.0, within=pyo.NonNegativeReals, mutable=True, @@ -317,23 +298,17 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): # System Variables # ################################## pyomo_model.system_production = pyo.Var( - doc="System generation [" - + self.commodity_storage_units - + "]", + doc="System generation [" + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.system_load = pyo.Var( - doc="System load [" - + self.commodity_storage_units - + "]", + doc="System load [" + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, units=eval("pyo.units." + self.commodity_storage_units), ) pyomo_model.commodity_out = pyo.Var( - doc="Commodity out of the system [" - + self.commodity_storage_units - + "]", + doc="Commodity out of the system [" + self.commodity_storage_units + "]", domain=pyo.NonNegativeReals, bounds=(0, pyomo_model.commodity_load_demand), units=eval("pyo.units." + self.commodity_storage_units), @@ -341,7 +316,7 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.is_generating = pyo.Var( doc="System is producing commodity binary [-]", domain=pyo.Binary, - units=pyo.units.dimensionless + units=pyo.units.dimensionless, ) # TODO: Not needed for now, add back in later if needed # pyomo_model.electricity_purchased = pyo.Var( @@ -369,8 +344,7 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): # Charge commodity bounds pyomo_model.charge_commodity_ub = pyo.Constraint( doc=pyomo_model.name + " charging storage upper bound", - expr=pyomo_model.charge_commodity - <= pyomo_model.max_charge * pyomo_model.is_charging, + expr=pyomo_model.charge_commodity <= pyomo_model.max_charge * pyomo_model.is_charging, ) pyomo_model.charge_commodity_lb = pyo.Constraint( doc=pyomo_model.name + " charging storage lower bound", @@ -399,8 +373,7 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.balance = pyo.Constraint( doc="Transmission energy balance", expr=( - pyomo_model.commodity_out - == pyomo_model.system_production - pyomo_model.system_load + pyomo_model.commodity_out == pyomo_model.system_production - pyomo_model.system_load ), ) pyomo_model.production_limit = pyo.Constraint( @@ -448,6 +421,7 @@ def _set_initial_soc_constraint(self): mutable=True, units=pyo.units.dimensionless, ) + ################################## # SOC Constraints # ################################## @@ -486,11 +460,13 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): # pyomo_model.port.add(pyomo_model.electricity_purchased) # Update time series parameters for next optimization window - def update_time_series_parameters(self, start_time: int, - commodity_in:list, - commodity_demand:list - # time_commodity_met_value:list - ): + def update_time_series_parameters( + self, + start_time: int, + commodity_in: list, + commodity_demand: list, + # time_commodity_met_value:list + ): """Update time series parameters method. Args: @@ -498,10 +474,8 @@ def update_time_series_parameters(self, start_time: int, commodity_in (list): List of commodity input values for each time step. """ self.time_duration = [1.0] * len(self.blocks.index_set()) - self.commodity_load_demand = [commodity_demand[t] - for t in self.blocks.index_set()] - self.load_production_limit = [commodity_demand[t] - for t in self.blocks.index_set()] + self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] + self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] # TODO: add back in if needed, needed for variable time series pricing # self.commodity_met_value = [time_commodity_met_value[t] # for t in self.blocks.index_set()] @@ -516,23 +490,21 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): """ self.obj = sum( - hybrid_blocks[t].time_weighting_factor - * self.blocks[t].time_duration - * ( - self.blocks[t].cost_per_discharge * hybrid_blocks[t].discharge_commodity - - self.blocks[t].cost_per_charge * hybrid_blocks[t].charge_commodity - + (self.blocks[t].commodity_load_demand - - hybrid_blocks[t].commodity_out - ) * self.blocks[t].commodity_met_value - - # + ( - # * self.blocks[t].electricity_purchase_price - # * hybrid_blocks[t].electricity_purchased - # ) - ) - # Try to incentivize battery charging - for t in self.blocks.index_set() - ) + hybrid_blocks[t].time_weighting_factor + * self.blocks[t].time_duration + * ( + self.blocks[t].cost_per_discharge * hybrid_blocks[t].discharge_commodity + - self.blocks[t].cost_per_charge * hybrid_blocks[t].charge_commodity + + (self.blocks[t].commodity_load_demand - hybrid_blocks[t].commodity_out) + * self.blocks[t].commodity_met_value + # + ( + # * self.blocks[t].electricity_purchase_price + # * hybrid_blocks[t].electricity_purchased + # ) + ) + # Try to incentivize battery charging + for t in self.blocks.index_set() + ) return self.obj # System-level functions @@ -544,15 +516,18 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): tech_name (str): The name or key identifying the technology for which ports are created. """ - setattr(hybrid_model, f"{tech_name}_port", Port( - initialize={ - "system_production": hybrid_model.system_production, - "system_load": hybrid_model.system_load, - "commodity_out": hybrid_model.commodity_out, - "charge_commodity": hybrid_model.charge_commodity, - "discharge_commodity": hybrid_model.discharge_commodity, - } - ) + setattr( + hybrid_model, + f"{tech_name}_port", + Port( + initialize={ + "system_production": hybrid_model.system_production, + "system_load": hybrid_model.system_load, + "commodity_out": hybrid_model.commodity_out, + "charge_commodity": hybrid_model.charge_commodity, + "discharge_commodity": hybrid_model.discharge_commodity, + } + ), ) return getattr(hybrid_model, f"{tech_name}_port") @@ -670,8 +645,7 @@ def time_duration(self, time_duration: list): self.blocks[t].time_duration = round(delta, self.round_digits) else: raise ValueError( - self.time_duration.__name__ - + " list must be the same length as time horizon" + self.time_duration.__name__ + " list must be the same length as time horizon" ) # Property getters and setters for time series parameters @@ -808,50 +782,35 @@ def cost_per_discharge(self) -> float: @cost_per_discharge.setter def cost_per_discharge(self, om_dollar_per_kwh: float): for t in self.blocks.index_set(): - self.blocks[t].cost_per_discharge = round( - om_dollar_per_kwh, self.round_digits - ) - + self.blocks[t].cost_per_discharge = round(om_dollar_per_kwh, self.round_digits) @property def commodity_load_demand(self) -> list: - return [ - self.blocks[t].commodity_load_demand.value - for t in self.blocks.index_set() - ] + return [self.blocks[t].commodity_load_demand.value for t in self.blocks.index_set()] @commodity_load_demand.setter def commodity_load_demand(self, commodity_demand: list): if len(commodity_demand) == len(self.blocks): for t, limit in zip(self.blocks, commodity_demand): - self.blocks[t].commodity_load_demand.set_value( - round(limit, self.round_digits) - ) + self.blocks[t].commodity_load_demand.set_value(round(limit, self.round_digits)) else: raise ValueError("'commodity_demand' list must be the same length as time horizon") @property def load_production_limit(self) -> list: - return [ - self.blocks[t].load_production_limit.value - for t in self.blocks.index_set() - ] + return [self.blocks[t].load_production_limit.value for t in self.blocks.index_set()] @load_production_limit.setter def load_production_limit(self, commodity_demand: list): if len(commodity_demand) == len(self.blocks): for t, limit in zip(self.blocks, commodity_demand): - self.blocks[t].load_production_limit.set_value( - round(limit, self.round_digits) - ) + self.blocks[t].load_production_limit.set_value(round(limit, self.round_digits)) else: raise ValueError("'commodity_demand' list must be the same length as time horizon") @property def commodity_met_value(self) -> float: - return [ - self.blocks[t].commodity_met_value.value for t in self.blocks.index_set() - ] + return [self.blocks[t].commodity_met_value.value for t in self.blocks.index_set()] @commodity_met_value.setter def commodity_met_value(self, price_per_kwh: float): @@ -935,4 +894,4 @@ def is_generating(self) -> list: @property def not_generating(self) -> list: - return [self.blocks[t].not_generating.value for t in self.blocks.index_set()] \ No newline at end of file + return [self.blocks[t].not_generating.value for t in self.blocks.index_set()] diff --git a/h2integrate/control/control_strategies/controller_opt_problem_state.py b/h2integrate/control/control_strategies/controller_opt_problem_state.py index 604c20f1c..0f9dc0a17 100644 --- a/h2integrate/control/control_strategies/controller_opt_problem_state.py +++ b/h2integrate/control/control_strategies/controller_opt_problem_state.py @@ -18,9 +18,7 @@ def __init__(self): self._gap = () self._n_non_optimal_solves = 0 - def store_problem_metrics( - self, solver_results, start_time, n_days, objective_value - ): + def store_problem_metrics(self, solver_results, start_time, n_days, objective_value): self.start_time = start_time self.n_days = n_days self.termination_condition = str(solver_results.solver.termination_condition) @@ -45,10 +43,7 @@ def store_problem_metrics( else: self.gap = float("inf") - if ( - not solver_results.solver.termination_condition - == TerminationCondition.optimal - ): + if not solver_results.solver.termination_condition == TerminationCondition.optimal: self._n_non_optimal_solves += 1 def _update_metric(self, metric_name, value): @@ -146,4 +141,4 @@ def gap(self, mip_gap: int): @property def n_non_optimal_solves(self) -> int: - return self._n_non_optimal_solves \ No newline at end of file + return self._n_non_optimal_solves diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 9d180d7cb..075caf33a 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -2,18 +2,20 @@ import numpy as np import pyomo.environ as pyomo -from pyomo.util.check_units import assert_units_consistent from attrs import field, define +from pyomo.util.check_units import assert_units_consistent from h2integrate.core.utilities import BaseConfig, merge_shared_inputs from h2integrate.core.validators import range_val +from h2integrate.control.control_rules.hybrid_rule import PyomoDispatchPlantRule from h2integrate.control.control_strategies.controller_baseclass import ControllerBaseClass +from h2integrate.control.control_rules.converters.generic_converter_opt import ( + PyomoDispatchGenericConverterMinOperatingCosts, +) from h2integrate.control.control_strategies.controller_opt_problem_state import DispatchProblemState -from h2integrate.control.control_rules.hybrid_rule import PyomoDispatchPlantRule -from h2integrate.control.control_rules.converters.generic_converter_opt \ -import PyomoDispatchGenericConverterMinOperatingCosts -from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost \ -import PyomoRuleStorageMinOperatingCosts +from h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost import ( + PyomoRuleStorageMinOperatingCosts, +) if TYPE_CHECKING: # to avoid circular imports @@ -243,8 +245,9 @@ def pyomo_dispatch_solver( 1. Arrays returned have length self.n_timesteps (full simulation period). """ # TODO: implement optional kwargs for this method - self.initialize_parameters(inputs[f"{commodity_name}_in"], - inputs[f"{commodity_name}_demand"]) + self.initialize_parameters( + inputs[f"{commodity_name}_in"], inputs[f"{commodity_name}_demand"] + ) # initialize outputs unmet_demand = np.zeros(self.n_timesteps) @@ -280,8 +283,9 @@ def pyomo_dispatch_solver( elif "optimized" in control_strategy: # Update time series parameters for the optimization method - self.update_time_series_parameters(commodity_in=commodity_in, - commodity_demand=demand_in) + self.update_time_series_parameters( + commodity_in=commodity_in, commodity_demand=demand_in + ) # Run dispatch optimzation to minimize costs while meeting demand self.solve_dispatch_model( commodity_in, @@ -819,6 +823,7 @@ class OptimizedDispatchControllerConfig(PyomoControllerBaseConfig): cost_per_discharge: float = field(default=None) commodity_met_value: float = field(default=None) + class OptimizedDispatchController(SimpleBatteryControllerHeuristic): """Operates the battery based on heuristic rules to meet the demand profile based power available from power generation profiles and power demand profile. @@ -848,7 +853,7 @@ def setup(self): "commodity_storage_units": self.config.commodity_storage_units, } # TODO: note that this definition of cost_per_production is not generalizable to multiple - # production technologies. Would need a name adjustment to connect it to + # production technologies. Would need a name adjustment to connect it to # production tech self.dispatch_inputs = { "cost_per_production": self.config.cost_per_production, @@ -867,7 +872,6 @@ def setup(self): self.n_control_window = self.config.n_control_window self.n_horizon_window = self.config.n_control_window - # Initialize parameters for optimization model def initialize_parameters(self, commodity_in, commodity_demand): self.hybrid_dispatch_model = self._create_dispatch_optimization_model() @@ -876,13 +880,14 @@ def initialize_parameters(self, commodity_in, commodity_demand): assert_units_consistent(self.hybrid_dispatch_model) self.problem_state = DispatchProblemState() - self.hybrid_dispatch_rule.initialize_parameters(commodity_in, commodity_demand, - self.dispatch_inputs) + self.hybrid_dispatch_rule.initialize_parameters( + commodity_in, commodity_demand, self.dispatch_inputs + ) - def update_time_series_parameters(self, start_time = 0, commodity_in = None, commodity_demand = None): - self.hybrid_dispatch_rule.update_time_series_parameters(start_time, - commodity_in, - commodity_demand) + def update_time_series_parameters(self, start_time=0, commodity_in=None, commodity_demand=None): + self.hybrid_dispatch_rule.update_time_series_parameters( + start_time, commodity_in, commodity_demand + ) def solve_dispatch_model( self, @@ -915,7 +920,6 @@ def solve_dispatch_model( # self._heuristic_method(commodity_in, commodity_demand) # self._fix_dispatch_model_variables() - def _create_dispatch_optimization_model(self): """ Creates monolith dispatch model @@ -931,21 +935,15 @@ def _create_dispatch_optimization_model(self): for tech in self.source_techs: if tech == self.dispatch_tech[0]: # tech.dispatch = PyomoRuleStorageMinOperatingCosts() - name = tech+"_rule" + name = tech + "_rule" dispatch = PyomoRuleStorageMinOperatingCosts( - self.commodity_info, - model, - model.forecast_horizon, - block_set_name=name + self.commodity_info, model, model.forecast_horizon, block_set_name=name ) setattr(self.pyomo_model, name, dispatch) else: - name = tech+"_rule" + name = tech + "_rule" dispatch = PyomoDispatchGenericConverterMinOperatingCosts( - self.commodity_info, - model, - model.forecast_horizon, - block_set_name=name + self.commodity_info, model, model.forecast_horizon, block_set_name=name ) # tech.dispatch = PyomoDispatchGenericConverterMinOperatingCosts() setattr(self.pyomo_model, name, dispatch) @@ -962,9 +960,8 @@ def _create_dispatch_optimization_model(self): def glpk_solve_call( pyomo_model: pyomo.ConcreteModel, log_name: str = "", - user_solver_options: dict = None, + user_solver_options: dict | None = None, ): - # log_name = "annual_solve_GLPK.log" # For debugging MILP solver # Ref. on solver options: https://en.wikibooks.org/wiki/GLPK/Using_GLPSOL glpk_solver_options = { @@ -974,9 +971,7 @@ def glpk_solve_call( # 'mipgap': 0.001, "tmlim": 30, } - solver_options = SolverOptions( - glpk_solver_options, log_name, user_solver_options, "log" - ) + solver_options = SolverOptions(glpk_solver_options, log_name, user_solver_options, "log") with pyomo.SolverFactory("glpk") as solver: results = solver.solve(pyomo_model, options=solver_options.constructed) # HybridDispatchBuilderSolver.log_and_solution_check( @@ -992,8 +987,7 @@ def glpk_solve(self): # self.pyomo_model self.hybrid_dispatch_model ) - # self.pyomo_model, log_name='', user_solver_options=dict({}) - + # self.pyomo_model, log_name='', user_solver_options=dict({}) # @staticmethod # def log_and_solution_check( @@ -1021,7 +1015,7 @@ def __init__( self, solver_spec_options: dict, log_name: str = "", - user_solver_options: dict = None, + user_solver_options: dict | None = None, solver_spec_log_key: str = "logfile", ): self.instance_log = "dispatch_solver.log" @@ -1033,5 +1027,3 @@ def __init__( self.constructed[solver_spec_log_key] = self.instance_log if user_solver_options is not None: self.constructed.update(user_solver_options) - - From 4f2faea1ef4d5b300c99bd9feed04c57272deae5 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Mon, 5 Jan 2026 17:16:30 -0700 Subject: [PATCH 14/37] Fixing merge errors --- docs/control/pyomo_controllers.md | 2 +- .../18_pyomo_heuristic_dispatch/tech_config.yaml | 4 ++-- .../tech_config_error_for_testing.yaml | 4 ++-- .../25_pyomo_optimized_dispatch/tech_config.yaml | 4 ++-- .../tech_config_error_for_testing.yaml | 4 ++-- .../control_strategies/pyomo_controllers.py | 10 +++++----- .../control/test/test_pyomo_controllers.py | 4 ++-- h2integrate/core/supported_models.py | 15 +++++++-------- h2integrate/storage/battery/pysam_battery.py | 6 +++--- .../battery/test_battery/inputs/tech_config.yaml | 2 +- 10 files changed, 27 insertions(+), 28 deletions(-) diff --git a/docs/control/pyomo_controllers.md b/docs/control/pyomo_controllers.md index e0766b5d2..103dc8c2e 100644 --- a/docs/control/pyomo_controllers.md +++ b/docs/control/pyomo_controllers.md @@ -10,7 +10,7 @@ An example of an N2 diagram for a system using the pyomo control framework for h (heuristic-load-following-controller)= ## Heuristic Load Following Controller -The pyomo control framework currently supports only a simple heuristic method, `heuristic_load_following_controller`, but we plan to extend the framework to be able to run a full dispatch optimization using a pyomo solver. When using the pyomo framework, a `dispatch_rule_set` for each technology connected to the storage technology must also be specified. These will typically be `pyomo_dispatch_generic_converter` for generating technologies, and `pyomo_dispatch_generic_storage` for storage technologies. More complex rule sets may be developed as needed. +The pyomo control framework currently supports only a simple heuristic method, `heuristic_load_following_controller`, but we plan to extend the framework to be able to run a full dispatch optimization using a pyomo solver. When using the pyomo framework, a `dispatch_rule_set` for each technology connected to the storage technology must also be specified. These will typically be `pyomo_generic_converter` for generating technologies, and `pyomo_generic_storage` for storage technologies. More complex rule sets may be developed as needed. For an example of how to use the pyomo control framework with the `heuristic_load_following_controller`, see - `examples/18_pyomo_heuristic_wind_battery_dispatch` diff --git a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml index 03faf53b8..8d1c60ff3 100644 --- a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml +++ b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml @@ -8,7 +8,7 @@ technologies: cost_model: model: "atb_wind_cost" dispatch_rule_set: - model: "pyomo_dispatch_generic_converter" + model: "pyomo_generic_converter" resource: type: "pysam_wind" wind_speed: 9. @@ -39,7 +39,7 @@ technologies: commodity_storage_units: "kW" battery: dispatch_rule_set: - model: "pyomo_dispatch_generic_storage" + model: "pyomo_generic_storage" control_strategy: model: "heuristic_load_following_controller" performance_model: diff --git a/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml b/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml index 22d2d88b7..3a5ce9b36 100644 --- a/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml +++ b/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml @@ -8,7 +8,7 @@ technologies: cost_model: model: "atb_wind_cost" dispatch_rule_set: - model: "pyomo_dispatch_generic_converter" + model: "pyomo_generic_converter" resource: type: "pysam_wind" wind_speed: 9. @@ -39,7 +39,7 @@ technologies: commodity_storage_units: "kW" battery: dispatch_rule_set: - model: "pyomo_dispatch_generic_storage" + model: "pyomo_generic_storage" control_strategy: model: "heuristic_load_following_controller" performance_model: diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index 229bfb850..3e52bb651 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -9,7 +9,7 @@ technologies: cost_model: model: "atb_wind_cost" dispatch_rule_set: - model: "pyomo_dispatch_generic_converter" + model: "pyomo_generic_converter" resource: type: "pysam_wind" wind_speed: 9. @@ -40,7 +40,7 @@ technologies: commodity_storage_units: "kW" battery: dispatch_rule_set: - model: "pyomo_dispatch_generic_storage" + model: "pyomo_generic_storage" control_strategy: model: "optimized_dispatch_controller" performance_model: diff --git a/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml b/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml index 7478bc13d..ba5cd4f7e 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml @@ -8,7 +8,7 @@ technologies: cost_model: model: "atb_wind_cost" dispatch_rule_set: - model: "pyomo_dispatch_generic_converter" + model: "pyomo_generic_converter" resource: type: "pysam_wind" wind_speed: 9. @@ -39,7 +39,7 @@ technologies: commodity_storage_units: "kW" battery: dispatch_rule_set: - model: "pyomo_dispatch_generic_storage" + model: "pyomo_generic_storage" control_strategy: model: "heuristic_load_following_controller" performance_model: diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 075caf33a..c5c84341a 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -110,7 +110,7 @@ def setup(self): Adds discrete inputs named 'dispatch_block_rule_function' (and variants suffixed with source tech names for cross-tech connections) plus a - discrete output 'pyomo_dispatch_solver' that will hold the assembled + discrete output 'pyomo_solver' that will hold the assembled callable after compute(). """ @@ -139,7 +139,7 @@ def setup(self): # create output for the pyomo control model self.add_discrete_output( - "pyomo_dispatch_solver", + "pyomo_solver", val=dummy_function, desc="callable: fully formed pyomo model and execution logic to be run \ by owning technologies performance model", @@ -147,7 +147,7 @@ def setup(self): def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """Build Pyomo model blocks and assign the dispatch solver.""" - discrete_outputs["pyomo_dispatch_solver"] = self.pyomo_setup(discrete_inputs) + discrete_outputs["pyomo_solver"] = self.pyomo_setup(discrete_inputs) def pyomo_setup(self, discrete_inputs): """Create the Pyomo model, attach per-tech Blocks, and return dispatch solver. @@ -190,7 +190,7 @@ def pyomo_setup(self, discrete_inputs): continue # define dispatch solver - def pyomo_dispatch_solver( + def pyomo_solver( performance_model: callable, performance_model_kwargs, inputs, @@ -332,7 +332,7 @@ def pyomo_dispatch_solver( return total_commodity_out, storage_commodity_out, unmet_demand, unused_commodity, soc - return pyomo_dispatch_solver + return pyomo_solver @staticmethod def dispatch_block_rule(block, t): diff --git a/h2integrate/control/test/test_pyomo_controllers.py b/h2integrate/control/test/test_pyomo_controllers.py index 995c8f416..1959a6455 100644 --- a/h2integrate/control/test/test_pyomo_controllers.py +++ b/h2integrate/control/test/test_pyomo_controllers.py @@ -34,7 +34,7 @@ "description": "...", "technologies": { "battery": { - "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, + "dispatch_rule_set": {"model": "pyomo_generic_storage"}, "control_strategy": {"model": "heuristic_load_following_controller"}, "performance_model": {"model": "pysam_battery"}, "model_inputs": { @@ -84,7 +84,7 @@ def test_heuristic_load_following_battery_dispatch(subtests): prob = om.Problem() prob.model.add_subsystem( - "pyomo_dispatch_generic_storage", + "pyomo_generic_storage", PyomoRuleStorageBaseclass( plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] ), diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 891bdfd00..dafc1ca40 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -126,12 +126,13 @@ from h2integrate.converters.hydrogen.custom_electrolyzer_cost_model import ( CustomElectrolyzerCostModel, ) -from h2integrate.control.control_rules.converters.generic_converter_opt import ( - PyomoDispatchGenericConverterMinOperatingCosts, from h2integrate.converters.hydrogen.geologic.aspen_surface_processing import ( AspenGeoH2SurfaceCostModel, AspenGeoH2SurfacePerformanceModel, ) +from h2integrate.control.control_rules.converters.generic_converter_opt import ( + PyomoDispatchGenericConverterMinOperatingCosts, +) from h2integrate.converters.hydrogen.geologic.templeton_serpentinization import ( StimulatedGeoH2PerformanceModel, ) @@ -259,12 +260,10 @@ "demand_open_loop_converter_controller": DemandOpenLoopConverterController, "flexible_demand_open_loop_converter_controller": FlexibleDemandOpenLoopConverterController, # Dispatch - "pyomo_dispatch_generic_converter": PyomoDispatchGenericConverter, - "pyomo_dispatch_generic_storage": PyomoRuleStorageBaseclass, - "pyomo_dispatch_battery_min_operating_cost": (PyomoRuleStorageMinOperatingCosts), - "pyomo_dispatch_generic_converter_min_operating_cost": ( - PyomoDispatchGenericConverterMinOperatingCosts - ), + "pyomo_generic_converter": PyomoDispatchGenericConverter, + "pyomo_generic_storage": PyomoRuleStorageBaseclass, + "pyomo_battery_min_operating_cost": PyomoRuleStorageMinOperatingCosts, + "pyomo_generic_converter_min_operating_cost": PyomoDispatchGenericConverterMinOperatingCosts, # Feedstock "feedstock_performance": FeedstockPerformanceModel, "feedstock_cost": FeedstockCostModel, diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index 6b1b551fd..c7f3f9293 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -304,7 +304,7 @@ def setup(self): "tech_to_dispatch_connections" ]: if any(intended_dispatch_tech in name for name in self.tech_group_name): - self.add_discrete_input("pyomo_dispatch_solver", val=dummy_function) + self.add_discrete_input("pyomo_solver", val=dummy_function) break self.unmet_demand = 0.0 @@ -365,9 +365,9 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): self.system_model.value("input_power", 0.0) self.system_model.execute(0) - if "pyomo_dispatch_solver" in discrete_inputs: + if "pyomo_solver" in discrete_inputs: # Simulate the battery with provided dispatch inputs - dispatch = discrete_inputs["pyomo_dispatch_solver"] + dispatch = discrete_inputs["pyomo_solver"] kwargs = { "time_step_duration": self.dt_hr, "control_variable": self.config.control_variable, diff --git a/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml b/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml index 39114df9d..a3ef39a34 100644 --- a/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml +++ b/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml @@ -4,7 +4,7 @@ description: "This hybrid plant stores and discharges electricity" technologies: battery: dispatch_rule_set: - model: "pyomo_dispatch_generic_storage" + model: "pyomo_generic_storage" performance_model: model: "pysam_battery" cost_model: From 39ad18647def0c946839ddaa3cc4d7b98a521f88 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 6 Jan 2026 11:07:29 -0700 Subject: [PATCH 15/37] Minor spelling changes --- h2integrate/control/control_rules/hybrid_rule.py | 6 ++---- .../storage/pyomo_storage_rule_min_operating_cost.py | 6 +++--- h2integrate/control/control_strategies/pyomo_controllers.py | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index b778ca5c5..c717837ee 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -136,7 +136,7 @@ def update_time_series_parameters( def create_min_operating_cost_expression(self): self._delete_objective() - def operationg_cost_objective_rule(m) -> float: + def operating_cost_objective_rule(m) -> float: obj = 0.0 for tech in self.source_techs: name = tech + "_rule" @@ -148,9 +148,7 @@ def operationg_cost_objective_rule(m) -> float: return obj # Set operating cost rule in Pyomo problem objective - self.model.objective = pyo.Objective( - rule=operationg_cost_objective_rule, sense=pyo.minimize - ) + self.model.objective = pyo.Objective(rule=operating_cost_objective_rule, sense=pyo.minimize) def _delete_objective(self): if hasattr(self.model, "objective"): 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 3ed82e3a1..2d0c479a8 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 @@ -59,7 +59,7 @@ def initialize_parameters( self.charge_efficiency = dispatch_inputs.get("charge_efficiency", 0.94) self.discharge_commodity_efficiency = dispatch_inputs.get("discharge_efficiency", 0.94) - # Set charge and discharge rate equal to eachother for now + # Set charge and discharge rate equal to each other for now self.max_charge = dispatch_inputs["max_charge_rate"] self.max_discharge = dispatch_inputs["max_charge_rate"] @@ -721,9 +721,9 @@ def maximum_storage(self) -> float: return self.blocks[t].maximum_storage.value @maximum_storage.setter - def maximum_storage(self, capcity_value: float): + def maximum_storage(self, capacity_value: float): for t in self.blocks.index_set(): - self.blocks[t].maximum_storage = round(capcity_value, self.round_digits) + self.blocks[t].maximum_storage = round(capacity_value, self.round_digits) @property def charge_efficiency(self) -> float: diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index c5c84341a..f24c75bac 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -117,7 +117,7 @@ def setup(self): # get technology group name self.tech_group_name = self.pathname.split(".") - # initalize dispatch inputs to None + # initialize dispatch inputs to None self.dispatch_options = None # create inputs for all pyomo object creation functions from all connected technologies @@ -286,7 +286,7 @@ def pyomo_solver( self.update_time_series_parameters( commodity_in=commodity_in, commodity_demand=demand_in ) - # Run dispatch optimzation to minimize costs while meeting demand + # Run dispatch optimization to minimize costs while meeting demand self.solve_dispatch_model( commodity_in, self.config.system_commodity_interface_limit, From ec3ad183bf5a483d595294088975b21df82b6f83 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 7 Jan 2026 12:46:27 -0500 Subject: [PATCH 16/37] Update example --- .../pysam_options_8300MW.yaml | 413 ------------------ .../tech_config.yaml | 2 +- 2 files changed, 1 insertion(+), 414 deletions(-) delete mode 100644 examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml diff --git a/examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml b/examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml deleted file mode 100644 index 558b24cfb..000000000 --- a/examples/25_pyomo_optimized_dispatch/pysam_options_8300MW.yaml +++ /dev/null @@ -1,413 +0,0 @@ -Turbine: - wind_resource_shear: 0.14 - wind_turbine_ct_curve: - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.4337 - - 0.4383 - - 0.4637 - - 0.4951 - - 0.5248 - - 0.5486 - - 0.5646 - - 0.5721 - - 0.5721 - - 0.5691 - - 0.5664 - - 0.566 - - 0.5684 - - 0.5715 - - 0.5738 - - 0.5742 - - 0.5726 - - 0.5704 - - 0.569 - - 0.5695 - - 0.5713 - - 0.5711 - - 0.5658 - - 0.5535 - - 0.5342 - - 0.5106 - - 0.4854 - - 0.4603 - - 0.4363 - - 0.413 - - 0.3898 - - 0.3666 - - 0.3432 - - 0.3205 - - 0.299 - - 0.2792 - - 0.2614 - - 0.2452 - - 0.2304 - - 0.2168 - - 0.2041 - - 0.1923 - - 0.1814 - - 0.1714 - - 0.1621 - - 0.1535 - - 0.1455 - - 0.138 - - 0.1311 - - 0.1246 - - 0.1186 - - 0.1129 - - 0.1076 - - 0.1027 - - 0.098 - - 0.0937 - - 0.0896 - - 0.0857 - - 0.082 - - 0.0786 - - 0.0753 - - 0.0723 - - 0.0694 - - 0.0666 - - 0.064 - - 0.0615 - - 0.0592 - - 0.057 - - 0.0548 - - 0.0528 - - 0.0509 - - 0.0491 - - 0.0474 - - 0.0457 - - 0.0441 - - 0.0426 - - 0.0412 - - 0.0398 - - 0.0385 - - 0.0373 - - 0.0361 - - 0.0349 - - 0.0338 - - 0.0328 - - 0.0317 - - 0.0308 - - 0.0298 - - 0.029 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - wind_turbine_hub_ht: 130.0 - wind_turbine_max_cp: 0.474457866 - wind_turbine_powercurve_powerout: - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 241.0757324 - - 303.7602033 - - 391.3910551 - - 500.8033128 - - 628.8320016 - - 772.3121467 - - 928.0787732 - - 1092.966906 - - 1265.273318 - - 1449.141772 - - 1650.177777 - - 1873.986843 - - 2124.586921 - - 2399.645733 - - 2695.243438 - - 3007.4602 - - 3334.216 - - 3680.790104 - - 4054.301598 - - 4461.869566 - - 4905.067075 - - 5363.283108 - - 5810.360625 - - 6220.142589 - - 6572.96016 - - 6875.097291 - - 7139.326132 - - 7378.418836 - - 7601.367393 - - 7802.043165 - - 7970.537352 - - 8096.941157 - - 8175.407321 - - 8216.334756 - - 8234.183913 - - 8243.415243 - - 8255.567745 - - 8270.494605 - - 8285.127555 - - 8296.398325 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 8300.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - - 0.0 - wind_turbine_powercurve_windspeeds: - - 0.0 - - 0.25 - - 0.5 - - 0.75 - - 1.0 - - 1.25 - - 1.5 - - 1.75 - - 2.0 - - 2.25 - - 2.5 - - 2.75 - - 3.0 - - 3.25 - - 3.5 - - 3.75 - - 4.0 - - 4.25 - - 4.5 - - 4.75 - - 5.0 - - 5.25 - - 5.5 - - 5.75 - - 6.0 - - 6.25 - - 6.5 - - 6.75 - - 7.0 - - 7.25 - - 7.5 - - 7.75 - - 8.0 - - 8.25 - - 8.5 - - 8.75 - - 9.0 - - 9.25 - - 9.5 - - 9.75 - - 10.0 - - 10.25 - - 10.5 - - 10.75 - - 11.0 - - 11.25 - - 11.5 - - 11.75 - - 12.0 - - 12.25 - - 12.5 - - 12.75 - - 13.0 - - 13.25 - - 13.5 - - 13.75 - - 14.0 - - 14.25 - - 14.5 - - 14.75 - - 15.0 - - 15.25 - - 15.5 - - 15.75 - - 16.0 - - 16.25 - - 16.5 - - 16.75 - - 17.0 - - 17.25 - - 17.5 - - 17.75 - - 18.0 - - 18.25 - - 18.5 - - 18.75 - - 19.0 - - 19.25 - - 19.5 - - 19.75 - - 20.0 - - 20.25 - - 20.5 - - 20.75 - - 21.0 - - 21.25 - - 21.5 - - 21.75 - - 22.0 - - 22.25 - - 22.5 - - 22.75 - - 23.0 - - 23.25 - - 23.5 - - 23.75 - - 24.0 - - 24.25 - - 24.5 - - 24.75 - - 25.0 - - 26.0 - - 27.0 - - 28.0 - - 29.0 - - 30.0 - - 31.0 - - 32.0 - - 33.0 - - 34.0 - - 35.0 - - 36.0 - - 37.0 - - 38.0 - - 39.0 - - 40.0 - - 41.0 - - 42.0 - - 43.0 - - 44.0 - - 45.0 - - 46.0 - - 47.0 - - 48.0 - - 49.0 -Farm: - wind_farm_wake_model: 0.0 - wind_resource_turbulence_coeff: 0.1 -Losses: - avail_bop_loss: 0.5 - avail_grid_loss: 1.5 - avail_turb_loss: 3.58 - elec_eff_loss: 1.91 - elec_parasitic_loss: 0.1 - env_degrad_loss: 1.8 - env_env_loss: 0.4 - env_exposure_loss: 0.0 - env_icing_loss: 0.21 - ops_env_loss: 1.0 - ops_grid_loss: 0.84 - ops_load_loss: 0.99 - ops_strategies_loss: 0.0 - turb_generic_loss: 1.7 - turb_hysteresis_loss: 0.4 - turb_perf_loss: 1.1 - turb_specific_loss: 9.964851766642457 - wake_ext_loss: 1.1 - wake_future_loss: 0.0 - wake_int_loss: 0.0 -Resource: - weibull_k_factor: 2.0 - weibull_reference_height: 50.0 - weibull_wind_speed: 7.25 - wind_resource_model_choice: 0.0 -Uncertainty: - total_uncert: 12.085 diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index 3e52bb651..ca60b02ad 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -21,7 +21,7 @@ technologies: hub_height: 130. create_model_from: "default" config_name: "WindPowerSingleOwner" - pysam_options: !include pysam_options_8300MW.yaml + pysam_options: !include pysam_options_8.3MW.yaml run_recalculate_power_curve: False layout: layout_mode: "basicgrid" From b1cc99b19fb1484559e95e9f306bcac3942b88c5 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 7 Jan 2026 12:52:45 -0500 Subject: [PATCH 17/37] Update controller problem state method from Elenya --- .../controller_opt_problem_state.py | 161 +++++------------- 1 file changed, 45 insertions(+), 116 deletions(-) diff --git a/h2integrate/control/control_strategies/controller_opt_problem_state.py b/h2integrate/control/control_strategies/controller_opt_problem_state.py index 0f9dc0a17..c847a67b0 100644 --- a/h2integrate/control/control_strategies/controller_opt_problem_state.py +++ b/h2integrate/control/control_strategies/controller_opt_problem_state.py @@ -19,126 +19,55 @@ def __init__(self): self._n_non_optimal_solves = 0 def store_problem_metrics(self, solver_results, start_time, n_days, objective_value): - self.start_time = start_time - self.n_days = n_days - self.termination_condition = str(solver_results.solver.termination_condition) - try: - self.solve_time = solver_results.solver.time - except AttributeError: - self.solve_time = solver_results.solver.wallclock_time - self.objective = objective_value - self.upper_bound = solver_results.problem.upper_bound - self.lower_bound = solver_results.problem.lower_bound - self.constraints = solver_results.problem.number_of_constraints - self.variables = solver_results.problem.number_of_variables - self.non_zeros = solver_results.problem.number_of_nonzeros - - # solver_results.solution.Gap not define - if solver_results.problem.upper_bound != 0.0: - self.gap = abs( - solver_results.problem.upper_bound - solver_results.problem.lower_bound - ) / abs(solver_results.problem.upper_bound) - elif solver_results.problem.lower_bound == 0.0: - self.gap = 0.0 + self.value("start_time", start_time) + self.value("n_days", n_days) + + solver_results_dict = { + k.lower().replace(" ", "_"): v.value + for k, v in solver_results.solver._list[0].items() + if k != "Statistics" + } + solver_problem_dict = { + k.lower().replace(" ", "_"): v.value for k, v in solver_results.problem._list[0].items() + } + prob_to_attr_map = { + "number_of_nonzeros": "non_zeros", + "number_of_variables": "variables", + "number_of_constraints": "constraints", + "lower_bound": "lower_bound", + "upper_bound": "upper_bound", + } + + self.termination_condition = str(solver_results_dict["termination_condition"]) + if "time" in solver_results_dict: + self.value("solve_time", solver_results_dict["time"]) else: - self.gap = float("inf") - - if not solver_results.solver.termination_condition == TerminationCondition.optimal: - self._n_non_optimal_solves += 1 - - def _update_metric(self, metric_name, value): - data = list(getattr(self, metric_name)) - data.append(value) - setattr(self, "_" + metric_name, tuple(data)) - - @property - def start_time(self) -> tuple: - return self._start_time - - @start_time.setter - def start_time(self, start_hour: int): - self._update_metric("start_time", start_hour) - - @property - def n_days(self) -> tuple: - return self._n_days - - @n_days.setter - def n_days(self, solve_days: int): - self._update_metric("n_days", solve_days) - - @property - def termination_condition(self) -> tuple: - return self._termination_condition - - @termination_condition.setter - def termination_condition(self, condition: str): - self._update_metric("termination_condition", condition) - - @property - def solve_time(self) -> tuple: - return self._solve_time - - @solve_time.setter - def solve_time(self, time: float): - self._update_metric("solve_time", time) + self.value("solve_time", solver_results_dict["wallclock_time"]) - @property - def objective(self) -> tuple: - return self._objective + self.value("objective", objective_value) - @objective.setter - def objective(self, objective_value: float): - self._update_metric("objective", objective_value) + for solver_prob_key, attribute_name in prob_to_attr_map.items(): + self.value(attribute_name, solver_problem_dict[solver_prob_key]) - @property - def upper_bound(self) -> tuple: - return self._upper_bound - - @upper_bound.setter - def upper_bound(self, bound: float): - self._update_metric("upper_bound", bound) - - @property - def lower_bound(self) -> tuple: - return self._lower_bound - - @lower_bound.setter - def lower_bound(self, bound: float): - self._update_metric("lower_bound", bound) - - @property - def constraints(self) -> tuple: - return self._constraints - - @constraints.setter - def constraints(self, constraint_count: int): - self._update_metric("constraints", constraint_count) - - @property - def variables(self) -> tuple: - return self._variables - - @variables.setter - def variables(self, variable_count: int): - self._update_metric("variables", variable_count) - - @property - def non_zeros(self) -> tuple: - return self._non_zeros - - @non_zeros.setter - def non_zeros(self, non_zeros_count: int): - self._update_metric("non_zeros", non_zeros_count) + # solver_results.solution.Gap not define + upper_bound = solver_problem_dict["upper_bound"] + lower_bound = solver_problem_dict["lower_bound"] + if upper_bound != 0.0: + gap = abs(upper_bound - lower_bound) / abs(upper_bound) + elif lower_bound == 0.0: + gap = 0.0 + else: + gap = float("inf") + self.value("gap", gap) - @property - def gap(self) -> tuple: - return self._gap + if not solver_results_dict["termination_condition"] == TerminationCondition.optimal: + self._n_non_optimal_solves += 1 - @gap.setter - def gap(self, mip_gap: int): - self._update_metric("gap", mip_gap) + def value(self, metric_name: str, set_value=None): + if set_value is not None: + data = list(self.__getattribute__(f"_{metric_name}")) + data.append(set_value) + self.__setattr__(f"_{metric_name}", tuple(data)) - @property - def n_non_optimal_solves(self) -> int: - return self._n_non_optimal_solves + else: + return self.__getattribute__(f"_{metric_name}") From 24cc0b8acbc881b45d6d5438551c3e0a01f8ae7e Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 7 Jan 2026 12:59:16 -0500 Subject: [PATCH 18/37] Update example and changelog --- CHANGELOG.md | 1 + ...mo_heuristic_dispatch.yaml => pyomo_optimized_dispatch.yaml} | 2 +- ...mo_heuristic_dispatch.py => run_pyomo_optimized_dispatch.py} | 2 +- examples/25_pyomo_optimized_dispatch/tech_config.yaml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) rename examples/25_pyomo_optimized_dispatch/{pyomo_heuristic_dispatch.yaml => pyomo_optimized_dispatch.yaml} (65%) rename examples/25_pyomo_optimized_dispatch/{run_pyomo_heuristic_dispatch.py => run_pyomo_optimized_dispatch.py} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index bafd288cb..2f9d7a8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Added standlone iron DRI and steel EAF performance and cost models - Added capability to have transport models that require user input parameters - Add geologic hydrogen surface processing converter +- Add optimal dispatch of storage for load following ## 0.5.1 [December 18, 2025] diff --git a/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch.yaml b/examples/25_pyomo_optimized_dispatch/pyomo_optimized_dispatch.yaml similarity index 65% rename from examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch.yaml rename to examples/25_pyomo_optimized_dispatch/pyomo_optimized_dispatch.yaml index af7329de0..b68a2d872 100644 --- a/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch.yaml +++ b/examples/25_pyomo_optimized_dispatch/pyomo_optimized_dispatch.yaml @@ -1,6 +1,6 @@ name: "H2Integrate_config" -system_summary: "This hybrid plant contains wind and battery storage technologies. The system is designed to meet a specific electrical load." +system_summary: "This hybrid plant contains wind and battery storage technologies. The system is designed to dispatch storage optimally meet a specific electrical load." driver_config: "driver_config.yaml" technology_config: "tech_config.yaml" diff --git a/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py b/examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py similarity index 97% rename from examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py rename to examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py index 87ca42ff0..0e479f2ce 100644 --- a/examples/25_pyomo_optimized_dispatch/run_pyomo_heuristic_dispatch.py +++ b/examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py @@ -5,7 +5,7 @@ # Create an H2Integrate model -model = H2IntegrateModel("pyomo_heuristic_dispatch.yaml") +model = H2IntegrateModel("pyomo_optimized_dispatch.yaml") demand_profile = np.ones(8760) * 100.0 diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index ca60b02ad..2a214338f 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -1,5 +1,5 @@ name: "technology_config" -description: "This hybrid plant produces hydrogen" +description: "This hybrid plant produces electricity from wind and battery storage." technologies: From abf9d49a7427a6c66757cf09550ce0d84af00bb2 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 7 Jan 2026 13:04:13 -0500 Subject: [PATCH 19/37] Clean up pyomo storage baseclass file --- .../storage/pyomo_storage_rule_baseclass.py | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py b/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py index c66006087..053e6426c 100644 --- a/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py +++ b/h2integrate/control/control_rules/storage/pyomo_storage_rule_baseclass.py @@ -29,20 +29,6 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): mutable=True, units=pyo.units.hr, ) - # pyomo_model.cost_per_charge = pyo.Param( - # doc="Operating cost of " + self.block_set_name + " charging [$/MWh]", - # default=0.0, - # within=pyo.NonNegativeReals, - # mutable=True, - # units=pyo.units.USD / pyo.units.MWh, - # ) - # pyomo_model.cost_per_discharge = pyo.Param( - # doc="Operating cost of " + self.block_set_name + " discharging [$/MWh]", - # default=0.0, - # within=pyo.NonNegativeReals, - # mutable=True, - # units=pyo.units.USD / pyo.units.MWh, - # ) pyomo_model.minimum_storage = pyo.Param( doc=pyomo_model.name + " minimum storage rating [" @@ -227,23 +213,6 @@ def soc_inventory_rule(m): rule=soc_inventory_rule, ) - ################################## - # SOC Linking Constraints # - ################################## - - # TODO: Make work for pyomo optimization, not needed for heuristic method - # # Linking time periods together - # def storage_soc_linking_rule(m, t): - # if t == m.blocks.index_set().first(): - # return m.blocks[t].soc0 == m.initial_soc - # return m.blocks[t].soc0 == self.blocks[t - 1].soc - - # pyomo_model.soc_linking = pyo.Constraint( - # pyomo_model.blocks.index_set(), - # doc=self.block_set_name + " state-of-charge block linking constraint", - # rule=storage_soc_linking_rule, - # ) - def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): """Create Pyomo ports for connecting the storage component. From 5a26e5851bf41945d3e3d3d5fc84349d2868c661 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:06:23 -0700 Subject: [PATCH 20/37] Cleanups to feature/pyomo opt (#2) * refactored DispatchProblemState * updated tech config and removed pysam options file * minor cleanups to DispatchProblemState * minor updates to generic_converter_opt * initial cleanups to hybrid_rule.py * minor cleanups to pyomo_storage_rule_min_operating_cost * extra small cleanups to generic_converter_opt * added storage capacities as inputs to optimized controller * updated use of n_control_window and n_horizon_window --- .../converters/generic_converter_opt.py | 117 ++++---- .../control/control_rules/hybrid_rule.py | 113 ++++---- .../pyomo_storage_rule_min_operating_cost.py | 268 +++++++----------- .../control_strategies/pyomo_controllers.py | 90 ++++-- 4 files changed, 267 insertions(+), 321 deletions(-) diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index c96655f90..d94f4e373 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -1,5 +1,4 @@ import pyomo.environ as pyo -from attrs import field from pyomo.network import Port @@ -9,12 +8,12 @@ # Configuration class for the PyomoDispatchGenericConverterMinOperatingCostsConfig. # This class defines the parameters required to configure the `PyomoRuleBaseConfig`. -""" -Attributes: - commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). -""" +# """ +# Attributes: +# commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). +# """ -commodity_cost_per_production: float = field() +# commodity_cost_per_production: float = field() class PyomoDispatchGenericConverterMinOperatingCosts: @@ -24,16 +23,18 @@ def __init__( pyomo_model: pyo.ConcreteModel, index_set: pyo.Set, block_set_name: str = "converter", + round_digits: int = 4, ): - self.round_digits = 4 + self.round_digits = round_digits self.block_set_name = block_set_name self.commodity_name = commodity_info["commodity_name"] self.commodity_storage_units = commodity_info["commodity_storage_units"] print(self.commodity_name, self.commodity_storage_units) - self._model = pyomo_model - self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) - setattr(self.model, self.block_set_name, self.blocks) + self.model = pyomo_model + self.blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) + + self.model.__setattr__(self.block_set_name, self.blocks) self.time_duration = [1.0] * len(self.blocks.index_set()) print("HEYYYY") @@ -78,17 +79,18 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel): variables are created. """ - setattr( - pyomo_model, - f"{self.block_set_name}_{self.commodity_name}", - pyo.Var( - doc=f"{self.commodity_name} production \ + tech_var = pyo.Var( + doc=f"{self.commodity_name} production \ from {self.block_set_name} [{self.commodity_storage_units}]", - domain=pyo.NonNegativeReals, - bounds=(0, pyomo_model.available_production), - units=eval("pyo.units." + self.commodity_storage_units), - initialize=0.0, - ), + domain=pyo.NonNegativeReals, + bounds=(0, pyomo_model.available_production), + units=eval("pyo.units." + self.commodity_storage_units), + initialize=0.0, + ) + + pyomo_model.__setattr__( + f"{self.block_set_name}_{self.commodity_name}", + tech_var, ) def _create_ports(self, pyomo_model: pyo.ConcreteModel): @@ -100,10 +102,12 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel): ports are created. """ + # create port pyomo_model.port = Port() - pyomo_model.port.add( - getattr(pyomo_model, f"{self.block_set_name}_{self.commodity_name}"), - ) + # do something + tech_port = pyomo_model.__getattribute__(f"{self.block_set_name}_{self.commodity_name}") + # add port to pyomo_model + pyomo_model.port.add(tech_port) def _create_parameters(self, pyomo_model: pyo.ConcreteModel): """Create technology Pyomo parameters to add to the Pyomo model instance. @@ -121,29 +125,27 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel): # Parameters # ################################## pyomo_model.time_duration = pyo.Param( - doc=pyomo_model.name + " time step [hour]", + doc=f"{pyomo_model.name} time step [hour]", default=1.0, within=pyo.NonNegativeReals, mutable=True, units=pyo.units.hr, ) pyomo_model.cost_per_production = pyo.Param( - doc="Production cost for generator [$/" + self.commodity_storage_units + "]", + doc=f"Production cost for generator [$/{self.commodity_storage_units}]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), + units=eval(f"pyo.units.USD / pyo.units.{self.commodity_storage_units}h"), ) pyomo_model.available_production = pyo.Param( - doc="Available production for the generator [" + self.commodity_storage_units + "]", + doc=f"Available production for the generator [{self.commodity_storage_units}]", default=0.0, within=pyo.Reals, mutable=True, - units=eval("pyo.units." + self.commodity_storage_units), + units=eval(f"pyo.units.{self.commodity_storage_units}"), ) - pass - def _create_constraints(self, pyomo_model: pyo.ConcreteModel): """Create technology Pyomo parameters to add to the Pyomo model instance. @@ -190,7 +192,7 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): # f"{tech_name}_{self.commodity_name}", # ) commodity_set = [ - getattr(hybrid_blocks[t], f"{tech_name}_{self.commodity_name}") + hybrid_blocks[t].__getattribute__(f"{tech_name}_{self.commodity_name}") for t in self.blocks.index_set() ] i = hybrid_blocks.index_set()[1] @@ -202,7 +204,7 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): * self.blocks[t].time_duration * self.blocks[t].cost_per_production # * commodity_set[t].value - * getattr(hybrid_blocks[t], f"{tech_name}_{self.commodity_name}") + * hybrid_blocks[t].__getattribute__(f"{tech_name}_{self.commodity_name}") for t in hybrid_blocks.index_set() ) # print(self.obj.get_units()) @@ -217,21 +219,11 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): tech_name (str): The name or key identifying the technology for which ports are created. """ - setattr( - hybrid_model, - f"{tech_name}_port", - Port( - initialize={ - f"{tech_name}_{self.commodity_name}": getattr( - hybrid_model, f"{tech_name}_{self.commodity_name}" - ) - } - ), - ) - return getattr( - hybrid_model, - f"{tech_name}_port", - ) + hybrid_model_tech = hybrid_model.__getattribute__(f"{tech_name}_{self.commodity_name}") + tech_port = Port(initialize={f"{tech_name}_{self.commodity_name}": hybrid_model_tech}) + hybrid_model.__setattr__(f"{tech_name}_port", tech_port) + + return hybrid_model.__getattribute__(f"{tech_name}_port") def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: str): """Create hybrid variables for generic converter technology to add to pyomo model instance. @@ -241,21 +233,18 @@ def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: s tech_name (str): The name or key identifying the technology for which variables are created. """ - setattr( - hybrid_model, - f"{tech_name}_{self.commodity_name}", - pyo.Var( - doc=f"{self.commodity_name} production \ + tech_var = pyo.Var( + doc=f"{self.commodity_name} production \ from {tech_name} [{self.commodity_storage_units}]", - domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), - initialize=0.0, - ), + domain=pyo.NonNegativeReals, + units=eval("pyo.units." + self.commodity_storage_units), + initialize=0.0, ) - return getattr( - hybrid_model, - f"{tech_name}_{self.commodity_name}", - ), 0.0 # load var is zero for converters + + hybrid_model.__setattr__(f"{tech_name}_{self.commodity_name}", tech_var) + + # load var is zero for converters + return hybrid_model.__getattribute__(f"{tech_name}_{self.commodity_name}"), 0 # Property getters and setters for time series parameters @property @@ -306,11 +295,3 @@ def time_duration(self, time_duration: list): raise ValueError( self.time_duration.__name__ + " list must be the same length as time horizon" ) - - @property - def blocks(self) -> pyo.Block: - return self._blocks - - @property - def model(self) -> pyo.ConcreteModel: - return self._model diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index c717837ee..a2a6e50c9 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -33,20 +33,20 @@ def __init__( # self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] # ) - self.source_techs = source_techs - self.options = dispatch_options + self.source_techs = source_techs # self.pyomo_model + self.options = dispatch_options # only using dispatch_options.time_weighting_factor self.power_source_gen_vars = {key: [] for key in index_set} self.tech_dispatch_models = tech_dispatch_models self.load_vars = {key: [] for key in index_set} self.ports = {key: [] for key in index_set} self.arcs = [] - self.block_set_name = block_set_name self.round_digits = 4 - self._model = pyomo_model - self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule) - setattr(self.model, self.block_set_name, self.blocks) + self.model = pyomo_model + self.blocks = pyo.Block(index_set, rule=self.dispatch_block_rule) + + self.model.__setattr__(block_set_name, self.blocks) def dispatch_block_rule(self, hybrid, t): ################################## @@ -68,19 +68,17 @@ def initialize_parameters( """Initialize parameters method.""" self.time_weighting_factor = self.options.time_weighting_factor # Discount factor for tech in self.source_techs: - name = tech + "_rule" - pyomo_block = getattr(self.tech_dispatch_models, name) + pyomo_block = self.tech_dispatch_models.__getattribute__(f"{tech}_rule") pyomo_block.initialize_parameters(commodity_in, commodity_demand, dispatch_params) def _create_variables_and_ports(self, hybrid, t): for tech in self.source_techs: - name = tech + "_rule" - pyomo_block = getattr(self.tech_dispatch_models, name) - gen_var, load_var = pyomo_block._create_hybrid_variables(hybrid, name) + pyomo_block = self.tech_dispatch_models.__getattribute__(f"{tech}_rule") + gen_var, load_var = pyomo_block._create_hybrid_variables(hybrid, f"{tech}_rule") self.power_source_gen_vars[t].append(gen_var) self.load_vars[t].append(load_var) - self.ports[t].append(pyomo_block._create_hybrid_port(hybrid, name)) + self.ports[t].append(pyomo_block._create_hybrid_port(hybrid, f"{tech}_rule")) @staticmethod def _create_parameters(hybrid): @@ -104,24 +102,24 @@ def _create_hybrid_constraints(self, hybrid, t): ) def create_arcs(self): + # Defining the mapping between battery to system level + # ################################## # Arcs # ################################## for tech in self.source_techs: - name = tech + "_rule" - pyomo_block = getattr(self.tech_dispatch_models, name) + pyomo_block = self.tech_dispatch_models.__getattribute__(f"{tech}_rule") def arc_rule(m, t): source_port = pyomo_block.blocks[t].port - destination_port = getattr(self.blocks[t], name + "_port") + destination_port = self.blocks[t].__getattribute__(f"{tech}_rule_port") return {"source": source_port, "destination": destination_port} - setattr( - self.model, - tech + "_hybrid_arc", - Arc(self.blocks.index_set(), rule=arc_rule), - ) - self.arcs.append(getattr(self.model, tech + "_hybrid_arc")) + tech_hybrid_arc = Arc(self.blocks.index_set(), rule=arc_rule) + self.model.__setattr__(f"{tech}_hybrid_arc", tech_hybrid_arc) + + tech_arc = self.model.__getattribute__(f"{tech}_hybrid_arc") + self.arcs.append(tech_arc) pyo.TransformationFactory("network.expand_arcs").apply_to(self.model) @@ -130,7 +128,7 @@ def update_time_series_parameters( ): for tech in self.source_techs: name = tech + "_rule" - pyomo_block = getattr(self.tech_dispatch_models, name) + pyomo_block = self.tech_dispatch_models.__getattribute__(name) pyomo_block.update_time_series_parameters(start_time, commodity_in, commodity_demand) def create_min_operating_cost_expression(self): @@ -142,7 +140,7 @@ def operating_cost_objective_rule(m) -> float: name = tech + "_rule" print("Obj function", name) # Create the min_operating_cost expression for each technology - pyomo_block = getattr(self.tech_dispatch_models, name) + pyomo_block = self.tech_dispatch_models.__getattribute__(name) # Add to the overall hybrid operating cost expression obj += pyomo_block.min_operating_cost_objective(self.blocks, name) return obj @@ -154,13 +152,16 @@ def _delete_objective(self): if hasattr(self.model, "objective"): self.model.del_component(self.model.objective) - @property - def blocks(self) -> pyo.Block: - return self._blocks + # def get_block_value(self, var_name): + # return [self.blocks[t].__getattribute__(var_name).value for t in self.blocks.index_set()] - @property - def model(self) -> pyo.ConcreteModel: - return self._model + # @property + # def blocks(self) -> pyo.Block: + # return self._blocks + + # @property + # def model(self) -> pyo.ConcreteModel: + # return self._model @property def time_weighting_factor(self) -> float: @@ -172,57 +173,41 @@ def time_weighting_factor(self, weighting: float): for t in self.blocks.index_set(): self.blocks[t].time_weighting_factor = round(weighting**t, self.round_digits) - @property - def time_weighting_factor_list(self) -> list: - return [self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set()] + # @property + # def time_weighting_factor_list(self) -> list: + # return [self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set()] # Outputs - @property - def objective_value(self): - return pyo.value(self.model.objective) - - @property - def get_production_value(self, tech_name, commodity_name): - return f"{tech_name}_{commodity_name}" # @property - # def pv_generation(self) -> list: - # return [self.blocks[t].pv_generation.value for t in self.blocks.index_set()] + # def objective_value(self): + # return pyo.value(self.model.objective) # @property - # def wind_generation(self) -> list: - # return [self.blocks[t].wind_generation.value for t in self.blocks.index_set()] + # def get_production_value(self, tech_name, commodity_name): + # return f"{tech_name}_{commodity_name}" # @property - # def wave_generation(self) -> list: - # return [self.blocks[t].wave_generation.value for t in self.blocks.index_set()] + # def charge_commodity(self) -> list: + # val = self.get_block_value("charge_commodity") + # # below returns a lit of length 24 for 24 hours/timesteps + # return val #[self.blocks[t].charge_commodity.value for t in self.blocks.index_set()] # @property - # def tidal_generation(self) -> list: - # return [self.blocks[t].tidal_generation.value for t in self.blocks.index_set()] + # def discharge_commodity(self) -> list: + # return [self.blocks[t].discharge_commodity.value for t in self.blocks.index_set()] # @property - # def generic_generation(self) -> list: - # return [self.blocks[t].generic_generation.value for t in self.blocks.index_set()] + # def system_production(self) -> list: + # return [self.blocks[t].system_production.value for t in self.blocks.index_set()] - @property - def charge_commodity(self) -> list: - return [self.blocks[t].charge_commodity.value for t in self.blocks.index_set()] - - @property - def discharge_commodity(self) -> list: - return [self.blocks[t].discharge_commodity.value for t in self.blocks.index_set()] - - @property - def system_production(self) -> list: - return [self.blocks[t].system_production.value for t in self.blocks.index_set()] - - @property - def system_load(self) -> list: - return [self.blocks[t].system_load.value for t in self.blocks.index_set()] + # @property + # def system_load(self) -> list: + # return [self.blocks[t].system_load.value for t in self.blocks.index_set()] @property def storage_commodity_out(self) -> list: + # THIS IS USED """Storage commodity out.""" return [ self.blocks[t].discharge_commodity.value - self.blocks[t].charge_commodity.value 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 2d0c479a8..254e242df 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 @@ -30,38 +30,45 @@ def __init__( pyomo_model: pyo.ConcreteModel, index_set: pyo.Set, block_set_name: str = "storage", + round_digits=4, ): - self.round_digits = 4 + self.round_digits = round_digits self.block_set_name = block_set_name self.commodity_name = commodity_info["commodity_name"] self.commodity_storage_units = commodity_info["commodity_storage_units"] - self._model = pyomo_model - self._blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) - setattr(self.model, self.block_set_name, self.blocks) + self.model = pyomo_model + self.blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) + + self.model.__setattr__(self.block_set_name, self.blocks) + print("HEYYYY-storage") def initialize_parameters( self, commodity_in: list, commodity_demand: list, dispatch_inputs: dict ): # Dispatch Parameters - self.cost_per_charge = dispatch_inputs["cost_per_charge"] - self.cost_per_discharge = dispatch_inputs["cost_per_discharge"] - self.commodity_met_value = dispatch_inputs["commodity_met_value"] + self.set_timeseries_parameter("cost_per_charge", dispatch_inputs["cost_per_charge"]) + self.set_timeseries_parameter("cost_per_discharge", dispatch_inputs["cost_per_discharge"]) + self.set_timeseries_parameter("commodity_met_value", dispatch_inputs["commodity_met_value"]) + # Storage parameters - self.minimum_storage = 0.0 - self.maximum_storage = dispatch_inputs["max_capacity"] + self.set_timeseries_parameter("minimum_storage", 0.0) + self.set_timeseries_parameter("maximum_storage", dispatch_inputs["max_capacity"]) + print("maximum_storage", self.maximum_storage) print(self.minimum_storage) - self.minimum_soc = dispatch_inputs["min_charge_percent"] - self.maximum_soc = dispatch_inputs["max_charge_percent"] + + self.set_timeseries_parameter("minimum_soc", dispatch_inputs["min_charge_percent"]) + self.set_timeseries_parameter("maximum_soc", dispatch_inputs["max_charge_percent"]) + self.initial_soc = dispatch_inputs["initial_soc_percent"] self.charge_efficiency = dispatch_inputs.get("charge_efficiency", 0.94) self.discharge_commodity_efficiency = dispatch_inputs.get("discharge_efficiency", 0.94) # Set charge and discharge rate equal to each other for now - self.max_charge = dispatch_inputs["max_charge_rate"] - self.max_discharge = dispatch_inputs["max_charge_rate"] + self.set_timeseries_parameter("max_charge", dispatch_inputs["max_charge_rate"]) + self.set_timeseries_parameter("max_discharge", dispatch_inputs["max_charge_rate"]) # System parameters self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] @@ -107,58 +114,59 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): ################################## # Storage Parameters # ################################## + + pyo_commodity_storage_unit = eval(f"pyo.units.{self.commodity_storage_units}") + pyo_commodity_storage_unit_hrs = eval(f"pyo.units.{self.commodity_storage_units}h") + pyo_usd_per_commodity_storage_unit_hrs = eval( + f"pyo.units.USD / pyo.units.{self.commodity_storage_units}h" + ) + usd_pr_units_str = f"[$/{self.commodity_storage_units}]" + pyomo_model.time_duration = pyo.Param( - doc=pyomo_model.name + " time step [hour]", + doc=f"{pyomo_model.name} time step [hour]", default=1.0, within=pyo.NonNegativeReals, mutable=True, units=pyo.units.hr, ) + pyomo_model.cost_per_charge = pyo.Param( - doc="Operating cost of " - + pyomo_model.name - + " charging [$/" - + self.commodity_storage_units - + "]", + doc=f"Operating cost of {pyomo_model.name} charging {usd_pr_units_str}", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), + units=pyo_usd_per_commodity_storage_unit_hrs, ) pyomo_model.cost_per_discharge = pyo.Param( - doc="Operating cost of " - + pyomo_model.name - + " discharging [$/" - + self.commodity_storage_units - + "]", + doc=f"Operating cost of {pyomo_model.name} discharging {usd_pr_units_str}", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), + units=pyo_usd_per_commodity_storage_unit_hrs, ) pyomo_model.minimum_storage = pyo.Param( - doc=pyomo_model.name + " minimum storage rating [" + self.commodity_storage_units + "]", + doc=f"{pyomo_model.name} minimum storage rating [{self.commodity_storage_units}]", default=0.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_storage_unit, ) pyomo_model.maximum_storage = pyo.Param( - doc=pyomo_model.name + " maximum storage rating [" + self.commodity_storage_units + "]", + doc=f"{pyomo_model.name} maximum storage rating [{self.commodity_storage_units}]", default=1000.0, within=pyo.NonNegativeReals, mutable=False, - units=eval("pyo.units." + self.commodity_storage_units + "h"), + units=pyo_commodity_storage_unit_hrs, ) pyomo_model.minimum_soc = pyo.Param( - doc=pyomo_model.name + " minimum state-of-charge [-]", + doc=f"{pyomo_model.name} minimum state-of-charge [-]", default=0.1, within=pyo.PercentFraction, mutable=True, units=pyo.units.dimensionless, ) pyomo_model.maximum_soc = pyo.Param( - doc=pyomo_model.name + " maximum state-of-charge [-]", + doc=f"{pyomo_model.name} maximum state-of-charge [-]", default=0.9, within=pyo.PercentFraction, mutable=True, @@ -169,14 +177,14 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): # Efficiency Parameters # ################################## pyomo_model.charge_efficiency = pyo.Param( - doc=pyomo_model.name + " Charging efficiency [-]", + doc=f"{pyomo_model.name} Charging efficiency [-]", default=0.938, within=pyo.PercentFraction, mutable=True, units=pyo.units.dimensionless, ) pyomo_model.discharge_efficiency = pyo.Param( - doc=pyomo_model.name + " discharging efficiency [-]", + doc=f"{pyomo_model.name} discharging efficiency [-]", default=0.938, within=pyo.PercentFraction, mutable=True, @@ -186,16 +194,16 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): # Capacity Parameters # ################################## pyomo_model.max_charge = pyo.Param( - doc=pyomo_model.name + " maximum charge [" + self.commodity_storage_units + "]", + doc=f"{pyomo_model.name} maximum charge [{self.commodity_storage_units}]", within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_storage_unit, ) pyomo_model.max_discharge = pyo.Param( - doc=pyomo_model.name + " maximum discharge [" + self.commodity_storage_units + "]", + doc=f"{pyomo_model.name} maximum discharge [{self.commodity_storage_units}]", within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_storage_unit, ) ################################## # System Parameters # @@ -208,13 +216,11 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): units=pyo.units.USD, ) pyomo_model.commodity_met_value = pyo.Param( - doc="Commodity demand met value per generation [$/" - + self.commodity_storage_units - + "]", + doc=f"Commodity demand met value per generation [$/{self.commodity_storage_units}]", default=0.0, within=pyo.Reals, mutable=True, - units=eval("pyo.units.USD / pyo.units." + self.commodity_storage_units + "h"), + units=pyo_usd_per_commodity_storage_unit_hrs, ) # grid.electricity_purchase_price = pyomo.Param( # doc="Electricity purchase price [$/MWh]", @@ -224,18 +230,18 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): # units=u.USD / u.MWh, # ) pyomo_model.commodity_load_demand = pyo.Param( - doc="Load demand for the commodity [" + self.commodity_storage_units + "]", + doc=f"Load demand for the commodity [{self.commodity_storage_units}]", default=1000.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_storage_unit, ) pyomo_model.load_production_limit = pyo.Param( - doc="Production limit for load [" + self.commodity_storage_units + "]", + doc=f"Production limit for load [{self.commodity_storage_units}]", default=1000.0, within=pyo.NonNegativeReals, mutable=True, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_storage_unit, ) def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): @@ -253,65 +259,56 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): # Variables # ################################## pyomo_model.is_charging = pyo.Var( - doc="1 if " + pyomo_model.name + " is charging; 0 Otherwise [-]", + doc=f"1 if {pyomo_model.name} is charging; 0 Otherwise [-]", domain=pyo.Binary, units=pyo.units.dimensionless, ) pyomo_model.is_discharging = pyo.Var( - doc="1 if " + pyomo_model.name + " is discharging; 0 Otherwise [-]", + doc=f"1 if {pyomo_model.name} is discharging; 0 Otherwise [-]", domain=pyo.Binary, units=pyo.units.dimensionless, ) pyomo_model.soc0 = pyo.Var( - doc=pyomo_model.name + " initial state-of-charge at beginning of period[-]", + doc=f"{pyomo_model.name} initial state-of-charge at beginning of period[-]", domain=pyo.PercentFraction, bounds=(pyomo_model.minimum_soc, pyomo_model.maximum_soc), units=pyo.units.dimensionless, ) pyomo_model.soc = pyo.Var( - doc=pyomo_model.name + " state-of-charge at end of period [-]", + doc=f"{pyomo_model.name} state-of-charge at end of period [-]", domain=pyo.PercentFraction, bounds=(pyomo_model.minimum_soc, pyomo_model.maximum_soc), units=pyo.units.dimensionless, ) + pyomo_model.charge_commodity = pyo.Var( - doc=self.commodity_name - + " into " - + pyomo_model.name - + " [" - + self.commodity_storage_units - + "]", + doc=f"{self.commodity_name} into {pyomo_model.name} [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=eval(f"pyo.units.{self.commodity_storage_units}"), ) pyomo_model.discharge_commodity = pyo.Var( - doc=self.commodity_name - + " out of " - + pyomo_model.name - + " [" - + self.commodity_storage_units - + "]", + doc=f"{self.commodity_name} out of {pyomo_model.name} [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=eval(f"pyo.units.{self.commodity_storage_units}"), ) ################################## # System Variables # ################################## pyomo_model.system_production = pyo.Var( - doc="System generation [" + self.commodity_storage_units + "]", + doc=f"System generation [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=eval(f"pyo.units.{self.commodity_storage_units}"), ) pyomo_model.system_load = pyo.Var( - doc="System load [" + self.commodity_storage_units + "]", + doc=f"System load [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=eval(f"pyo.units.{self.commodity_storage_units}"), ) pyomo_model.commodity_out = pyo.Var( - doc="Commodity out of the system [" + self.commodity_storage_units + "]", + doc=f"Commodity out of the system [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, bounds=(0, pyomo_model.commodity_load_demand), - units=eval("pyo.units." + self.commodity_storage_units), + units=eval(f"pyo.units.{self.commodity_storage_units}"), ) pyomo_model.is_generating = pyo.Var( doc="System is producing commodity binary [-]", @@ -343,28 +340,28 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): ################################## # Charge commodity bounds pyomo_model.charge_commodity_ub = pyo.Constraint( - doc=pyomo_model.name + " charging storage upper bound", + doc=f"{pyomo_model.name} charging storage upper bound", expr=pyomo_model.charge_commodity <= pyomo_model.max_charge * pyomo_model.is_charging, ) pyomo_model.charge_commodity_lb = pyo.Constraint( - doc=pyomo_model.name + " charging storage lower bound", + doc=f"{pyomo_model.name} charging storage lower bound", expr=pyomo_model.charge_commodity >= pyomo_model.minimum_storage * pyomo_model.is_charging, ) # Discharge commodity bounds pyomo_model.discharge_commodity_lb = pyo.Constraint( - doc=pyomo_model.name + " Discharging storage lower bound", + doc=f"{pyomo_model.name} Discharging storage lower bound", expr=pyomo_model.discharge_commodity >= pyomo_model.minimum_storage * pyomo_model.is_discharging, ) pyomo_model.discharge_commodity_ub = pyo.Constraint( - doc=pyomo_model.name + " Discharging storage upper bound", + doc=f"{pyomo_model.name} Discharging storage upper bound", expr=pyomo_model.discharge_commodity <= pyomo_model.max_discharge * pyomo_model.is_discharging, ) # Storage packing constraint pyomo_model.charge_discharge_packing = pyo.Constraint( - doc=pyomo_model.name + " packing constraint for charging and discharging binaries", + doc=f"{pyomo_model.name} packing constraint for charging and discharging binaries", expr=pyomo_model.is_charging + pyomo_model.is_discharging <= 1, ) ################################## @@ -406,7 +403,7 @@ def soc_inventory_rule(m): # Storage State-of-charge balance pyomo_model.soc_inventory = pyo.Constraint( - doc=pyomo_model.name + " state-of-charge inventory balance", + doc=f"{pyomo_model.name} state-of-charge inventory balance", rule=soc_inventory_rule, ) @@ -415,7 +412,7 @@ def _set_initial_soc_constraint(self): # SOC Linking # ################################## self.model.initial_soc = pyo.Param( - doc=self.commodity_name + " initial state-of-charge at beginning of the horizon[-]", + doc=f"{self.commodity_name} initial state-of-charge at beginning of the horizon[-]", within=pyo.PercentFraction, default=0.5, mutable=True, @@ -516,20 +513,18 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): tech_name (str): The name or key identifying the technology for which ports are created. """ - setattr( - hybrid_model, - f"{tech_name}_port", - Port( - initialize={ - "system_production": hybrid_model.system_production, - "system_load": hybrid_model.system_load, - "commodity_out": hybrid_model.commodity_out, - "charge_commodity": hybrid_model.charge_commodity, - "discharge_commodity": hybrid_model.discharge_commodity, - } - ), + tech_port = Port( + initialize={ + "system_production": hybrid_model.system_production, + "system_load": hybrid_model.system_load, + "commodity_out": hybrid_model.commodity_out, + "charge_commodity": hybrid_model.charge_commodity, + "discharge_commodity": hybrid_model.discharge_commodity, + } ) - return getattr(hybrid_model, f"{tech_name}_port") + hybrid_model.__setattr__(f"{tech_name}_port", tech_port) + + return hybrid_model.__getattribute__(f"{tech_name}_port") def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: str): """Create hybrid variables for storage to add to pyomo model instance. @@ -543,43 +538,36 @@ def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: s # System Variables # ################################## # TODO: fix the units on these + pyo_commodity_units = eval("pyo.units." + self.commodity_storage_units) + hybrid_model.system_production = pyo.Var( - doc="System generation [MW]", + doc=f"System generation [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_units, ) hybrid_model.system_load = pyo.Var( - doc="System load [MW]", + doc=f"System load [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_units, ) hybrid_model.commodity_out = pyo.Var( - doc="Electricity sold [MW]", + doc=f"{self.commodity_name} sold [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_units, ) ################################## # Storage Variables # ################################## + hybrid_model.charge_commodity = pyo.Var( - doc=self.commodity_name - + " into " - + tech_name - + " [" - + self.commodity_storage_units - + "]", + doc=f"{self.commodity_name} into {tech_name} [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_units, ) hybrid_model.discharge_commodity = pyo.Var( - doc=self.commodity_name - + " out of " - + tech_name - + " [" - + self.commodity_storage_units - + "]", + doc=f"{self.commodity_name} out of {tech_name} [{self.commodity_storage_units}]", domain=pyo.NonNegativeReals, - units=eval("pyo.units." + self.commodity_storage_units), + units=pyo_commodity_units, ) return hybrid_model.discharge_commodity, hybrid_model.charge_commodity @@ -623,15 +611,6 @@ def _check_efficiency_value(efficiency): raise ValueError("Efficiency value must between 0 and 1 or 0 and 100") return efficiency - # Dispatch Model Variables - @property - def blocks(self) -> pyo.Block: - return self._blocks - - @property - def model(self) -> pyo.ConcreteModel: - return self._model - # INPUTS @property def time_duration(self) -> list: @@ -649,28 +628,24 @@ def time_duration(self, time_duration: list): ) # Property getters and setters for time series parameters + + def set_timeseries_parameter(self, param_name: str, param_val: float): + for t in self.blocks.index_set(): + val_rounded = round(param_val, self.round_digits) + self.blocks[t].__setattr__(param_name, val_rounded) + @property def max_charge(self) -> float: """Maximum charge amount.""" for t in self.blocks.index_set(): return self.blocks[t].max_charge.value - @max_charge.setter - def max_charge(self, max_charge: float): - for t in self.blocks.index_set(): - self.blocks[t].max_charge = round(max_charge, self.round_digits) - @property def max_discharge(self) -> float: """Maximum discharge amount.""" for t in self.blocks.index_set(): return self.blocks[t].max_discharge.value - @max_discharge.setter - def max_discharge(self, max_discharge: float): - for t in self.blocks.index_set(): - self.blocks[t].max_discharge = round(max_discharge, self.round_digits) - # @property # def initial_soc(self) -> float: # """Initial state-of-charge.""" @@ -687,44 +662,24 @@ def minimum_soc(self) -> float: for t in self.blocks.index_set(): return self.blocks[t].minimum_soc.value - @minimum_soc.setter - def minimum_soc(self, minimum_soc: float): - for t in self.blocks.index_set(): - self.blocks[t].minimum_soc = round(minimum_soc, self.round_digits) - @property def maximum_soc(self) -> float: """Maximum state-of-charge.""" for t in self.blocks.index_set(): return self.blocks[t].maximum_soc.value - @maximum_soc.setter - def maximum_soc(self, maximum_soc: float): - for t in self.blocks.index_set(): - self.blocks[t].maximum_soc = round(maximum_soc, self.round_digits) - @property def minimum_storage(self) -> float: """Minimum storage.""" for t in self.blocks.index_set(): return self.blocks[t].minimum_storage.value - @minimum_storage.setter - def minimum_storage(self, minimum_storage: float): - for t in self.blocks.index_set(): - self.blocks[t].minimum_storage = round(minimum_storage, self.round_digits) - @property def maximum_storage(self) -> float: """Maximum storage.""" for t in self.blocks.index_set(): return self.blocks[t].maximum_storage.value - @maximum_storage.setter - def maximum_storage(self, capacity_value: float): - for t in self.blocks.index_set(): - self.blocks[t].maximum_storage = round(capacity_value, self.round_digits) - @property def charge_efficiency(self) -> float: """Charge efficiency.""" @@ -768,22 +723,12 @@ def cost_per_charge(self) -> float: for t in self.blocks.index_set(): return self.blocks[t].cost_per_charge.value - @cost_per_charge.setter - def cost_per_charge(self, om_dollar_per_kwh: float): - for t in self.blocks.index_set(): - self.blocks[t].cost_per_charge = round(om_dollar_per_kwh, self.round_digits) - @property def cost_per_discharge(self) -> float: """Cost per discharge.""" for t in self.blocks.index_set(): return self.blocks[t].cost_per_discharge.value - @cost_per_discharge.setter - def cost_per_discharge(self, om_dollar_per_kwh: float): - for t in self.blocks.index_set(): - self.blocks[t].cost_per_discharge = round(om_dollar_per_kwh, self.round_digits) - @property def commodity_load_demand(self) -> list: return [self.blocks[t].commodity_load_demand.value for t in self.blocks.index_set()] @@ -812,11 +757,6 @@ def load_production_limit(self, commodity_demand: list): def commodity_met_value(self) -> float: return [self.blocks[t].commodity_met_value.value for t in self.blocks.index_set()] - @commodity_met_value.setter - def commodity_met_value(self, price_per_kwh: float): - for t in self.blocks.index_set(): - self.blocks[t].commodity_met_value = round(price_per_kwh, self.round_digits) - ### The following method is if the value of meeting the demand is variable # if len(price_per_kwh) == len(self.blocks): # for t, price in zip(self.blocks, price_per_kwh): diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index f24c75bac..7bceb7ce7 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -357,9 +357,9 @@ def _check_efficiency_value(efficiency): def blocks(self) -> pyomo.Block: return getattr(self.pyomo_model, self.config.tech_name) - @property - def model(self) -> pyomo.ConcreteModel: - return self._model + # @property + # def model(self) -> pyomo.ConcreteModel: + # return self._model class SimpleBatteryControllerHeuristic(PyomoControllerBaseClass): @@ -823,6 +823,24 @@ class OptimizedDispatchControllerConfig(PyomoControllerBaseConfig): cost_per_discharge: float = field(default=None) commodity_met_value: float = field(default=None) + def make_dispatch_inputs(self): + dispatch_keys = [ + "cost_per_production", + "cost_per_charge", + "cost_per_discharge", + "commodity_met_value", + "max_capacity", + "max_charge_percent", + "min_charge_percent", + "charge_efficiency", + "discharge_efficiency", + "max_charge_rate", + ] + + dispatch_inputs = {k: self.as_dict()[k] for k in dispatch_keys} + dispatch_inputs.update({"initial_soc_percent": self.init_charge_percent}) + return dispatch_inputs + class OptimizedDispatchController(SimpleBatteryControllerHeuristic): """Operates the battery based on heuristic rules to meet the demand profile based power @@ -838,6 +856,20 @@ def setup(self): merge_shared_inputs(self.options["tech_config"]["model_inputs"], "control") ) + self.add_input( + "max_charge_rate", + val=self.config.max_charge_rate, + units=self.config.commodity_storage_units, + desc="Battery charge rate", + ) + + self.add_input( + "storage_capacity", + val=self.config.max_capacity, + units=f"{self.config.commodity_storage_units}*h", + desc="Battery storage capacity", + ) + self.n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] super().setup() @@ -855,36 +887,31 @@ def setup(self): # TODO: note that this definition of cost_per_production is not generalizable to multiple # production technologies. Would need a name adjustment to connect it to # production tech - self.dispatch_inputs = { - "cost_per_production": self.config.cost_per_production, - "cost_per_charge": self.config.cost_per_charge, - "cost_per_discharge": self.config.cost_per_discharge, - "commodity_met_value": self.config.commodity_met_value, - "max_capacity": self.config.max_capacity, - "max_charge_percent": self.config.max_charge_percent, - "min_charge_percent": self.config.min_charge_percent, - "initial_soc_percent": self.config.init_charge_percent, - "charge_efficiency": self.charge_efficiency, - "discharge_efficiency": self.discharge_efficiency, - "max_charge_rate": self.config.max_charge_rate, - } - self.n_control_window = self.config.n_control_window - self.n_horizon_window = self.config.n_control_window + self.dispatch_inputs = self.config.make_dispatch_inputs() + + # self.n_control_window = self.config.n_control_window + # self.n_horizon_window = self.config.n_control_window # Initialize parameters for optimization model def initialize_parameters(self, commodity_in, commodity_demand): + # Where pyomo model communicates with the rest of the controller + # self.hybrid_dispatch_model is the pyomo model, this is the thing in hybrid_rule self.hybrid_dispatch_model = self._create_dispatch_optimization_model() self.hybrid_dispatch_rule.create_min_operating_cost_expression() self.hybrid_dispatch_rule.create_arcs() assert_units_consistent(self.hybrid_dispatch_model) + + # this is where dispatch problem state is made, this is used in the solver call self.problem_state = DispatchProblemState() + # hybrid_dispatch_rule is the thing where you can access variables from self.hybrid_dispatch_rule.initialize_parameters( commodity_in, commodity_demand, self.dispatch_inputs ) def update_time_series_parameters(self, start_time=0, commodity_in=None, commodity_demand=None): + # Where pyomo model communicates with the rest of the controller self.hybrid_dispatch_rule.update_time_series_parameters( start_time, commodity_in, commodity_demand ) @@ -930,23 +957,27 @@ def _create_dispatch_optimization_model(self): ################################# model.forecast_horizon = pyomo.Set( doc="Set of time periods in time horizon", - initialize=range(self.n_horizon_window), + initialize=range(self.config.n_control_window), ) for tech in self.source_techs: if tech == self.dispatch_tech[0]: # tech.dispatch = PyomoRuleStorageMinOperatingCosts() - name = tech + "_rule" dispatch = PyomoRuleStorageMinOperatingCosts( - self.commodity_info, model, model.forecast_horizon, block_set_name=name + self.commodity_info, + model, + model.forecast_horizon, + block_set_name=f"{tech}_rule", ) - setattr(self.pyomo_model, name, dispatch) + self.pyomo_model.__setattr__(f"{tech}_rule", dispatch) else: - name = tech + "_rule" dispatch = PyomoDispatchGenericConverterMinOperatingCosts( - self.commodity_info, model, model.forecast_horizon, block_set_name=name + self.commodity_info, + model, + model.forecast_horizon, + block_set_name=f"{tech}_rule", ) # tech.dispatch = PyomoDispatchGenericConverterMinOperatingCosts() - setattr(self.pyomo_model, name, dispatch) + self.pyomo_model.__setattr__(f"{tech}_rule", dispatch) ################################# # Blocks (technologies) # @@ -956,6 +987,15 @@ def _create_dispatch_optimization_model(self): ) return model + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + """Build Pyomo model blocks and assign the dispatch solver.""" + self.dispatch_inputs["max_charge_rate"] = inputs["max_charge_rate"][0] + self.dispatch_inputs["max_capacity"] = inputs["storage_capacity"][0] + self.config.max_capacity = inputs["storage_capacity"][0] + self.config.max_charge_rate = inputs["max_charge_rate"][0] + + discrete_outputs["pyomo_solver"] = self.pyomo_setup(discrete_inputs) + @staticmethod def glpk_solve_call( pyomo_model: pyomo.ConcreteModel, From 72205b2ddc1c72c8615a325721c038210a3d92cb Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Mon, 12 Jan 2026 16:25:49 -0500 Subject: [PATCH 21/37] Enable heuristic dispatch to run with new pyomo changes --- .../tech_config.yaml | 9 +-- .../control_strategies/pyomo_controllers.py | 67 +++++++++++-------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml index 8d1c60ff3..a91b054d6 100644 --- a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml +++ b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml @@ -49,6 +49,7 @@ technologies: model_inputs: shared_parameters: commodity_name: "electricity" + commodity_storage_units: "kW" max_charge_rate: 100000 max_capacity: 500000 n_control_window: 24 @@ -67,8 +68,8 @@ technologies: power_capex: 311 # $/kW from 2024 ATB year 2025 opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB control_parameters: - commodity_storage_units: "kW" + # commodity_storage_units: "kW" tech_name: "battery" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" + # dispatch_rule_parameters: + # commodity_name: "electricity" + # commodity_storage_units: "kW" diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 7bceb7ce7..ae80e4474 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -182,10 +182,8 @@ def pyomo_setup(self, discrete_inputs): ] # create pyomo block and set attr blocks = pyomo.Block(index_set, rule=dispatch_block_rule_function) - print("HIII", blocks) setattr(self.pyomo_model, source_tech, blocks) self.source_techs.append(source_tech) - print(getattr(self.pyomo_model, source_tech)) else: continue @@ -244,10 +242,6 @@ def pyomo_solver( Notes: 1. Arrays returned have length self.n_timesteps (full simulation period). """ - # TODO: implement optional kwargs for this method - self.initialize_parameters( - inputs[f"{commodity_name}_in"], inputs[f"{commodity_name}_demand"] - ) # initialize outputs unmet_demand = np.zeros(self.n_timesteps) @@ -261,6 +255,24 @@ def pyomo_solver( control_strategy = self.options["tech_config"]["control_strategy"]["model"] + # TODO: implement optional kwargs for this method: maybe this will remove if statement here + if "heuristic" in control_strategy: + # Initialize parameters for heruistic dispatch strategy + self.initialize_parameters() + elif "optimized" in control_strategy: + # Initialize parameters for optimized dispatch strategy + self.initialize_parameters( + inputs[f"{commodity_name}_in"], inputs[f"{commodity_name}_demand"] + ) + + else: + raise ( + NotImplementedError( + f"Control strategy '{control_strategy}' was given, \ + but has not been implemented yet." + ) + ) + # loop over all control windows, where t is the starting index of each window for t in window_start_indices: print("Iteration tracker:", t) @@ -698,29 +710,30 @@ def maximum_soc(self, maximum_soc: float): for t in self.blocks.index_set(): self.blocks[t].maximum_soc = round(maximum_soc, self.round_digits) - # @property - # def charge_efficiency(self) -> float: - # """Charge efficiency.""" - # for t in self.blocks.index_set(): - # return self.blocks[t].charge_efficiency.value + # Need these properties to define these values for methods in this class + @property + def charge_efficiency(self) -> float: + """Charge efficiency.""" + for t in self.blocks.index_set(): + return self.blocks[t].charge_efficiency.value - # @charge_efficiency.setter - # def charge_efficiency(self, efficiency: float): - # efficiency = self._check_efficiency_value(efficiency) - # for t in self.blocks.index_set(): - # self.blocks[t].charge_efficiency = round(efficiency, self.round_digits) + @charge_efficiency.setter + def charge_efficiency(self, efficiency: float): + efficiency = self._check_efficiency_value(efficiency) + for t in self.blocks.index_set(): + self.blocks[t].charge_efficiency = round(efficiency, self.round_digits) - # @property - # def discharge_efficiency(self) -> float: - # """Discharge efficiency.""" - # for t in self.blocks.index_set(): - # return self.blocks[t].discharge_efficiency.value - - # @discharge_efficiency.setter - # def discharge_efficiency(self, efficiency: float): - # efficiency = self._check_efficiency_value(efficiency) - # for t in self.blocks.index_set(): - # self.blocks[t].discharge_efficiency = round(efficiency, self.round_digits) + @property + def discharge_efficiency(self) -> float: + """Discharge efficiency.""" + for t in self.blocks.index_set(): + return self.blocks[t].discharge_efficiency.value + + @discharge_efficiency.setter + def discharge_efficiency(self, efficiency: float): + efficiency = self._check_efficiency_value(efficiency) + for t in self.blocks.index_set(): + self.blocks[t].discharge_efficiency = round(efficiency, self.round_digits) # @property # def round_trip_efficiency(self) -> float: From 5c163933d3e61e6225815c57743ab28282135943 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Fri, 16 Jan 2026 19:33:20 -0500 Subject: [PATCH 22/37] Clean up added files and example --- .../driver_config.yaml | 2 +- .../run_pyomo_optimized_dispatch.py | 2 +- .../tech_config.yaml | 14 +- .../converters/generic_converter_opt.py | 86 ++++------ .../control/control_rules/hybrid_rule.py | 156 +++++++----------- .../pyomo_storage_rule_min_operating_cost.py | 146 ++++------------ .../control_strategies/pyomo_controllers.py | 61 ++++--- 7 files changed, 160 insertions(+), 307 deletions(-) diff --git a/examples/25_pyomo_optimized_dispatch/driver_config.yaml b/examples/25_pyomo_optimized_dispatch/driver_config.yaml index 72d2e368a..6589a3934 100644 --- a/examples/25_pyomo_optimized_dispatch/driver_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/driver_config.yaml @@ -1,5 +1,5 @@ name: "driver_config" -description: "This analysis runs a hybrid plant to meet an electrical load." +description: "This analysis runs a hybrid plant to dispatch storage optimally to meet an electrical load." general: folder_output: outputs diff --git a/examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py b/examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py index 0e479f2ce..263a508db 100644 --- a/examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py +++ b/examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py @@ -69,7 +69,7 @@ linestyle="--", label="Eletrical Demand (MW)", ) -ax[1].set_ylim([-7e2, 7e2]) +ax[1].set_ylim([-1e2, 2.5e2]) ax[1].set_ylabel("Electricity Hourly (MW)") ax[1].set_xlabel("Timestep (hr)") diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/25_pyomo_optimized_dispatch/tech_config.yaml index 2a214338f..4a66e9d20 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/25_pyomo_optimized_dispatch/tech_config.yaml @@ -15,7 +15,7 @@ technologies: wind_speed: 9. model_inputs: performance_parameters: - num_turbines: 100 + num_turbines: 25 turbine_rating_kw: 8300 rotor_diameter: 196. hub_height: 130. @@ -59,13 +59,13 @@ technologies: min_charge_percent: 0.1 system_commodity_interface_limit: 1e12 time_weighting_factor: 0.995 - charge_efficiency: 0.938 - discharge_efficiency: 0.938 + charge_efficiency: 0.95 + discharge_efficiency: 0.95 commodity_storage_units: "kW" - cost_per_charge: 0.05 - cost_per_discharge: 0.1 - commodity_met_value: 0.5 - cost_per_production: 0.01 + cost_per_charge: 0.0027 + cost_per_discharge: 0.003 + commodity_met_value: 0.01 + cost_per_production: 0.001 performance_parameters: system_model_source: "pysam" chemistry: "LFPGraphite" diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index d94f4e373..52cf26978 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -2,21 +2,10 @@ from pyomo.network import Port -# @define -# class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): -# """ -# Configuration class for the PyomoDispatchGenericConverterMinOperatingCostsConfig. - -# This class defines the parameters required to configure the `PyomoRuleBaseConfig`. -# """ -# Attributes: -# commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). -# """ - -# commodity_cost_per_production: float = field() - - class PyomoDispatchGenericConverterMinOperatingCosts: + """Class defining Pyomo rules for the optimized dispatch for load following + for generic commodity production components.""" + def __init__( self, commodity_info: dict, @@ -29,7 +18,6 @@ def __init__( self.block_set_name = block_set_name self.commodity_name = commodity_info["commodity_name"] self.commodity_storage_units = commodity_info["commodity_storage_units"] - print(self.commodity_name, self.commodity_storage_units) self.model = pyomo_model self.blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) @@ -37,13 +25,17 @@ def __init__( self.model.__setattr__(self.block_set_name, self.blocks) self.time_duration = [1.0] * len(self.blocks.index_set()) - print("HEYYYY") - def initialize_parameters( self, commodity_in: list, commodity_demand: list, dispatch_inputs: dict ): - """Initialize parameters method.""" + """Initialize parameters for optimization model + Args: + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + dispatch_inputs (dict): Dictionary of the dispatch input parameters from config + + """ self.cost_per_production = dispatch_inputs["cost_per_production"] def dispatch_block_rule_function(self, pyomo_model: pyo.ConcreteModel): @@ -75,8 +67,6 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel): Args: pyomo_model (pyo.ConcreteModel): pyomo_model the variables should be added to. - tech_name (str): The name or key identifying the technology for which - variables are created. """ tech_var = pyo.Var( @@ -94,31 +84,27 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel): ) def _create_ports(self, pyomo_model: pyo.ConcreteModel): - """Create generic converter port to add to pyomo model instance. + """Create generic converter ports to add to pyomo model instance. Args: pyomo_model (pyo.ConcreteModel): pyomo_model the ports should be added to. - tech_name (str): The name or key identifying the technology for which - ports are created. """ # create port pyomo_model.port = Port() - # do something + # get port attribute from generic converter pyomo model tech_port = pyomo_model.__getattribute__(f"{self.block_set_name}_{self.commodity_name}") # add port to pyomo_model pyomo_model.port.add(tech_port) def _create_parameters(self, pyomo_model: pyo.ConcreteModel): - """Create technology Pyomo parameters to add to the Pyomo model instance. + """Create generic converter Pyomo parameters to add to the Pyomo model instance. - Method is currently passed but this can serve as a template to add parameters to the Pyomo - model instance. + This method defines converter parameters such as available production and the + cost per generation for the technology Args: pyomo_model (pyo.ConcreteModel): pyomo_model that parameters are added to. - tech_name (str): The name or key identifying the technology for which - parameters are created. """ ################################## @@ -147,15 +133,13 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel): ) def _create_constraints(self, pyomo_model: pyo.ConcreteModel): - """Create technology Pyomo parameters to add to the Pyomo model instance. + """Create generic converter Pyomo constraints to add to the Pyomo model instance. Method is currently passed but this can serve as a template to add constraints to the Pyomo model instance. Args: pyomo_model (pyo.ConcreteModel): pyomo_model that constraints are added to. - tech_name (str): The name or key identifying the technology for which - constraints are created. """ @@ -164,55 +148,42 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel): # Update time series parameters for next optimization window def update_time_series_parameters( self, - start_time: int, commodity_in: list, commodity_demand: list, - # time_commodity_met_value:list ): - """Update time series parameters method. + """Updates the pyomo optimization problem with parameters that change with time Args: - start_time (int): The starting time index for the update. - commodity_in (list): List of commodity input values for each time step. + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + """ self.time_duration = [1.0] * len(self.blocks.index_set()) self.available_production = [commodity_in[t] for t in self.blocks.index_set()] # Objective functions def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): - """Wind instance of minimum operating cost objective. + """Generic converter instance of minimum operating cost objective. Args: hybrid_blocks (Pyomo.block): A generalized container for defining hierarchical models by adding modeling components as attributes. - + tech_name (str): The name or key identifying the technology for which + ports are created. """ - # commodity_name = getattr( - # hybrid_blocks, - # f"{tech_name}_{self.commodity_name}", - # ) - commodity_set = [ - hybrid_blocks[t].__getattribute__(f"{tech_name}_{self.commodity_name}") - for t in self.blocks.index_set() - ] - i = hybrid_blocks.index_set()[1] - print("Units???", self.blocks[i].time_duration.get_units()) - print(commodity_set[i].get_units()) - print(self.blocks[i].cost_per_production.get_units()) + self.obj = sum( hybrid_blocks[t].time_weighting_factor * self.blocks[t].time_duration * self.blocks[t].cost_per_production - # * commodity_set[t].value * hybrid_blocks[t].__getattribute__(f"{tech_name}_{self.commodity_name}") for t in hybrid_blocks.index_set() ) - # print(self.obj.get_units()) return self.obj # System-level functions def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): - """Create hybrid ports for storage to add to pyomo model instance. + """Create generic converter ports to add to system-level pyomo model instance. Args: hybrid_model (pyo.ConcreteModel): hybrid_model the ports should be added to. @@ -226,7 +197,7 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): return hybrid_model.__getattribute__(f"{tech_name}_port") def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: str): - """Create hybrid variables for generic converter technology to add to pyomo model instance. + """Create generic converter variables to add to system-level pyomo model instance. Args: hybrid_model (pyo.ConcreteModel): hybrid_model the variables should be added to. @@ -243,16 +214,17 @@ def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: s hybrid_model.__setattr__(f"{tech_name}_{self.commodity_name}", tech_var) + # Returns to power_source_gen_vars and load_vars in hybrid_rule # load var is zero for converters return hybrid_model.__getattribute__(f"{tech_name}_{self.commodity_name}"), 0 # Property getters and setters for time series parameters @property def available_production(self) -> list: - """Available generation. + """Available production. Returns: - list: List of available generation. + list: List of available production. """ return [self.blocks[t].available_production.value for t in self.blocks.index_set()] diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index a2a6e50c9..bc74fb6f8 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -2,24 +2,9 @@ from pyomo.network import Arc -# from h2integrate.control.control_rules.pyomo_rule_baseclass import PyomoRuleBaseClass - - -# @define -# class PyomoDispatchGenericConverterMinOperatingCostsConfig(PyomoRuleBaseConfig): -# """ -# Configuration class for the PyomoDispatchGenericConverterMinOperatingCostsConfig. - -# This class defines the parameters required to configure the `PyomoRuleBaseConfig`. - -# Attributes: -# commodity_cost_per_production (float): cost of the commodity per production (in $/kWh). -# """ - -# commodity_cost_per_production: str = field() - - class PyomoDispatchPlantRule: + """Class defining Pyomo model and rule for the optimized dispatch for load following + for the overal optimization problem describing the system.""" def __init__( self, pyomo_model: pyo.ConcreteModel, @@ -29,9 +14,6 @@ def __init__( dispatch_options: dict, block_set_name: str = "hybrid", ): - # self.config = PyomoDispatchGenericConverterMinOperatingCostsConfig.from_dict( - # self.options["tech_config"]["model_inputs"]["dispatch_rule_parameters"] - # ) self.source_techs = source_techs # self.pyomo_model self.options = dispatch_options # only using dispatch_options.time_weighting_factor @@ -49,6 +31,17 @@ def __init__( self.model.__setattr__(block_set_name, self.blocks) def dispatch_block_rule(self, hybrid, t): + """ + Creates and initializes pyomo dispatch model components for a the system-level dispatch + + This method sets up all model elements (parameters, variables, constraints, + and ports) associated with a pyomo block within the dispatch model. + + Args: + hybrid (pyo.ConcreteModel): The Pyomo model to which the technology + components will be added. + t (int): integer location of variables in the control time window + """ ################################## # Parameters # ################################## @@ -65,23 +58,46 @@ def dispatch_block_rule(self, hybrid, t): def initialize_parameters( self, commodity_in: list, commodity_demand: list, dispatch_params: dict ): - """Initialize parameters method.""" + """Initialize parameters for optimization model + + Args: + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + dispatch_inputs (dict): Dictionary of the dispatch input parameters from config + + """ self.time_weighting_factor = self.options.time_weighting_factor # Discount factor for tech in self.source_techs: pyomo_block = self.tech_dispatch_models.__getattribute__(f"{tech}_rule") pyomo_block.initialize_parameters(commodity_in, commodity_demand, dispatch_params) def _create_variables_and_ports(self, hybrid, t): + """Connect variables and ports from individual technology model + to system-level pyomo model instance. + + Args: + hybrid (pyo.ConcreteModel): The Pyomo model to which the technology + components will be added. + t (int): integer location of variables in the control time window + """ + for tech in self.source_techs: pyomo_block = self.tech_dispatch_models.__getattribute__(f"{tech}_rule") gen_var, load_var = pyomo_block._create_hybrid_variables(hybrid, f"{tech}_rule") + # Add production and load variables to system-level list self.power_source_gen_vars[t].append(gen_var) self.load_vars[t].append(load_var) self.ports[t].append(pyomo_block._create_hybrid_port(hybrid, f"{tech}_rule")) @staticmethod def _create_parameters(hybrid): + """Create system-level pyomo model parameters + + Args: + hybrid (pyo.ConcreteModel): The Pyomo model to which the technology + components will be added. + """ hybrid.time_weighting_factor = pyo.Param( doc="Exponential time weighting factor [-]", initialize=1.0, @@ -91,6 +107,13 @@ def _create_parameters(hybrid): ) def _create_hybrid_constraints(self, hybrid, t): + """Define system-level constraints for pyomo model. + + Args: + hybrid (pyo.ConcreteModel): The Pyomo model to which the technology + components will be added. + t (int): integer location of variables in the control time window + """ hybrid.production_total = pyo.Constraint( doc="hybrid system generation total", rule=hybrid.system_production == sum(self.power_source_gen_vars[t]), @@ -102,8 +125,9 @@ def _create_hybrid_constraints(self, hybrid, t): ) def create_arcs(self): - # Defining the mapping between battery to system level - # + """ + Defines the mapping between individual technology variables to system level + """ ################################## # Arcs # ################################## @@ -124,21 +148,34 @@ def arc_rule(m, t): pyo.TransformationFactory("network.expand_arcs").apply_to(self.model) def update_time_series_parameters( - self, start_time: int, commodity_in=list, commodity_demand=list + self, commodity_in=list, commodity_demand=list ): + """ + Updates the pyomo optimization problem with parameters that change with time + + Args: + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + + """ + # Note: currently, storage techs use commodity_demand and converter techs use commodity_in + # Better way to do this? for tech in self.source_techs: name = tech + "_rule" pyomo_block = self.tech_dispatch_models.__getattribute__(name) - pyomo_block.update_time_series_parameters(start_time, commodity_in, commodity_demand) + pyomo_block.update_time_series_parameters( commodity_in, commodity_demand) def create_min_operating_cost_expression(self): + """ + Creates system-level instance of minimum operating cost objective for pyomo solver. + """ + self._delete_objective() def operating_cost_objective_rule(m) -> float: obj = 0.0 for tech in self.source_techs: name = tech + "_rule" - print("Obj function", name) # Create the min_operating_cost expression for each technology pyomo_block = self.tech_dispatch_models.__getattribute__(name) # Add to the overall hybrid operating cost expression @@ -152,17 +189,6 @@ def _delete_objective(self): if hasattr(self.model, "objective"): self.model.del_component(self.model.objective) - # def get_block_value(self, var_name): - # return [self.blocks[t].__getattribute__(var_name).value for t in self.blocks.index_set()] - - # @property - # def blocks(self) -> pyo.Block: - # return self._blocks - - # @property - # def model(self) -> pyo.ConcreteModel: - # return self._model - @property def time_weighting_factor(self) -> float: for t in self.blocks.index_set(): @@ -173,38 +199,6 @@ def time_weighting_factor(self, weighting: float): for t in self.blocks.index_set(): self.blocks[t].time_weighting_factor = round(weighting**t, self.round_digits) - # @property - # def time_weighting_factor_list(self) -> list: - # return [self.blocks[t].time_weighting_factor.value for t in self.blocks.index_set()] - - # Outputs - - # @property - # def objective_value(self): - # return pyo.value(self.model.objective) - - # @property - # def get_production_value(self, tech_name, commodity_name): - # return f"{tech_name}_{commodity_name}" - - # @property - # def charge_commodity(self) -> list: - # val = self.get_block_value("charge_commodity") - # # below returns a lit of length 24 for 24 hours/timesteps - # return val #[self.blocks[t].charge_commodity.value for t in self.blocks.index_set()] - - # @property - # def discharge_commodity(self) -> list: - # return [self.blocks[t].discharge_commodity.value for t in self.blocks.index_set()] - - # @property - # def system_production(self) -> list: - # return [self.blocks[t].system_production.value for t in self.blocks.index_set()] - - # @property - # def system_load(self) -> list: - # return [self.blocks[t].system_load.value for t in self.blocks.index_set()] - @property def storage_commodity_out(self) -> list: # THIS IS USED @@ -212,26 +206,4 @@ def storage_commodity_out(self) -> list: return [ self.blocks[t].discharge_commodity.value - self.blocks[t].charge_commodity.value for t in self.blocks.index_set() - ] - - # @property - # def electricity_sales(self) -> list: - # if "grid" in self.power_sources: - # tb = self.power_sources["grid"].dispatch.blocks - # return [ - # tb[t].time_duration.value - # * tb[t].electricity_sell_price.value - # * self.blocks[t].electricity_sold.value - # for t in self.blocks.index_set() - # ] - - # @property - # def electricity_purchases(self) -> list: - # if "grid" in self.power_sources: - # tb = self.power_sources["grid"].dispatch.blocks - # return [ - # tb[t].time_duration.value - # * tb[t].electricity_purchase_price.value - # * self.blocks[t].electricity_purchased.value - # for t in self.blocks.index_set() - # ] + ] \ No newline at end of file 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 254e242df..cb77ab074 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 @@ -2,24 +2,6 @@ from pyomo.network import Port -# @define -# class PyomoDispatchStorageMinOperatingCostsConfig(PyomoRuleBaseConfig): -# """ -# Configuration class for the PyomoDispatchStorageMinOperatingCostsConfig. - -# This class defines the parameters required to configure the `PyomoRuleBaseConfig`. - -# Attributes: -# cost_per_charge (float): cost of the commodity per charge (in $/kWh). -# cost_per_discharge (float): cost of the commodity per discharge (in $/kWh). -# """ - -# cost_per_charge: float = field() -# cost_per_discharge: float = field() -# # roundtrip_efficiency: float = field(default=0.88) -# commodity_met_value: float = field() - - class PyomoRuleStorageMinOperatingCosts: """Class defining Pyomo rules for the optimized dispatch for load following for generic commodity storage components.""" @@ -42,11 +24,17 @@ def __init__( self.model.__setattr__(self.block_set_name, self.blocks) - print("HEYYYY-storage") - def initialize_parameters( self, commodity_in: list, commodity_demand: list, dispatch_inputs: dict ): + """Initialize parameters for optimization model + + Args: + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + dispatch_inputs (dict): Dictionary of the dispatch input parameters from config + + """ # Dispatch Parameters self.set_timeseries_parameter("cost_per_charge", dispatch_inputs["cost_per_charge"]) self.set_timeseries_parameter("cost_per_discharge", dispatch_inputs["cost_per_discharge"]) @@ -56,9 +44,6 @@ def initialize_parameters( self.set_timeseries_parameter("minimum_storage", 0.0) self.set_timeseries_parameter("maximum_storage", dispatch_inputs["max_capacity"]) - print("maximum_storage", self.maximum_storage) - print(self.minimum_storage) - self.set_timeseries_parameter("minimum_soc", dispatch_inputs["min_charge_percent"]) self.set_timeseries_parameter("maximum_soc", dispatch_inputs["max_charge_percent"]) @@ -72,6 +57,8 @@ def initialize_parameters( # System parameters self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] + # This should not be set to the commodity_demand. Any way to set it to the production + # capacity of the plant? self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] self._set_initial_soc_constraint() @@ -81,7 +68,6 @@ def dispatch_block_rule_function(self, pyomo_model: pyo.ConcreteModel, tech_name This method sets up all model elements (parameters, variables, constraints, and ports) associated with a technology block within the dispatch model. - It is typically called in the setup_pyomo() method of the PyomoControllerBaseClass. Args: pyomo_model (pyo.ConcreteModel): The Pyomo model to which the technology @@ -104,7 +90,8 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): This method defines key storage parameters such as capacity limits, state-of-charge (SOC) bounds, efficiencies, and time duration for each - time step. + time step. This method also defined system parameters such as the value of + load the load met and the production limit of the system. Args: pyomo_model (pyo.ConcreteModel): Pyomo model instance representing @@ -222,13 +209,6 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): mutable=True, units=pyo_usd_per_commodity_storage_unit_hrs, ) - # grid.electricity_purchase_price = pyomo.Param( - # doc="Electricity purchase price [$/MWh]", - # default=0.0, - # within=pyomo.Reals, - # mutable=True, - # units=u.USD / u.MWh, - # ) pyomo_model.commodity_load_demand = pyo.Param( doc=f"Load demand for the commodity [{self.commodity_storage_units}]", default=1000.0, @@ -248,7 +228,8 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): """Create storage-related decision variables in the Pyomo model. This method defines binary and continuous variables representing - charging/discharging modes, energy flows, and state-of-charge. + charging/discharging modes, energy flows, and state-of-charge, as well + as system variables such as system load, system production, and commodity produced. Args: pyomo_model (pyo.ConcreteModel): Pyomo model instance representing @@ -315,20 +296,16 @@ def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): domain=pyo.Binary, units=pyo.units.dimensionless, ) - # TODO: Not needed for now, add back in later if needed - # pyomo_model.electricity_purchased = pyo.Var( - # doc="Electricity purchased [MW]", - # domain=pyo.NonNegativeReals, - # units=u.MW, - # ) def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): - """Create operational and state-of-charge constraints for storage. + """Create operational and state-of-charge constraints for storage and the system. This method defines constraints that enforce: - Mutual exclusivity between charging and discharging. - Upper and lower bounds on charge/discharge flows. - The state-of-charge balance over time. + - The system balance of output with system production and load + - The system output is less than or equal to the load (because of linear optimization) Args: pyomo_model (pyo.ConcreteModel): Pyomo model instance representing @@ -378,13 +355,6 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel, t): expr=pyomo_model.commodity_out <= pyomo_model.commodity_load_demand * pyomo_model.is_generating, ) - # pyomo_model.purchases_transmission_limit = pyomo.Constraint( - # doc="Transmission limit on electricity purchases", - # expr=( - # grid.electricity_purchased - # <= grid.load_transmission_limit * (1 - grid.is_generating) - # ), - # ) ################################## # SOC Inventory Constraints # @@ -408,6 +378,10 @@ def soc_inventory_rule(m): ) def _set_initial_soc_constraint(self): + """ + This method links the SOC between the end of one control period and the beginning + of the next control period. + """ ################################## # SOC Linking # ################################## @@ -454,28 +428,23 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): pyomo_model.port.add(pyomo_model.system_production) pyomo_model.port.add(pyomo_model.system_load) pyomo_model.port.add(pyomo_model.commodity_out) - # pyomo_model.port.add(pyomo_model.electricity_purchased) # Update time series parameters for next optimization window def update_time_series_parameters( self, - start_time: int, commodity_in: list, commodity_demand: list, - # time_commodity_met_value:list ): - """Update time series parameters method. + """Updates the pyomo optimization problem with parameters that change with time Args: - start_time (int): The starting time index for the update. - commodity_in (list): List of commodity input values for each time step. + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + """ self.time_duration = [1.0] * len(self.blocks.index_set()) self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] - # TODO: add back in if needed, needed for variable time series pricing - # self.commodity_met_value = [time_commodity_met_value[t] - # for t in self.blocks.index_set()] # Objective functions def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): @@ -494,10 +463,6 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): - self.blocks[t].cost_per_charge * hybrid_blocks[t].charge_commodity + (self.blocks[t].commodity_load_demand - hybrid_blocks[t].commodity_out) * self.blocks[t].commodity_met_value - # + ( - # * self.blocks[t].electricity_purchase_price - # * hybrid_blocks[t].electricity_purchased - # ) ) # Try to incentivize battery charging for t in self.blocks.index_set() @@ -506,7 +471,7 @@ def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): # System-level functions def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): - """Create hybrid ports for storage to add to pyomo model instance. + """Create generic storage ports to add to system-level pyomo model instance. Args: hybrid_model (pyo.ConcreteModel): hybrid_model the ports should be added to. @@ -527,7 +492,7 @@ def _create_hybrid_port(self, hybrid_model: pyo.ConcreteModel, tech_name: str): return hybrid_model.__getattribute__(f"{tech_name}_port") def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: str): - """Create hybrid variables for storage to add to pyomo model instance. + """Create generic storage variables to add to system-level pyomo model instance. Args: hybrid_model (pyo.ConcreteModel): hybrid_model the variables should be added to. @@ -569,37 +534,9 @@ def _create_hybrid_variables(self, hybrid_model: pyo.ConcreteModel, tech_name: s domain=pyo.NonNegativeReals, units=pyo_commodity_units, ) + # Returns to power_source_gen_vars and load_vars in hybrid_rule return hybrid_model.discharge_commodity, hybrid_model.charge_commodity - def _check_initial_soc(self, initial_soc: float) -> float: - """Check that initial state-of-charge is within valid bounds. - - Args: - initial_soc (float): Initial state-of-charge to be checked. - Returns: - float: Validated initial state-of-charge. - """ - if initial_soc > 1: - initial_soc /= 100.0 - initial_soc = round(initial_soc, self.round_digits) - if initial_soc > self.maximum_soc: - print( - "Warning: Storage dispatch was initialized with a state-of-charge greater than " - "maximum value!" - ) - print(f"Initial SOC = {initial_soc}") - print("Initial SOC was set to maximum value.") - initial_soc = self.maximum_soc - elif initial_soc < self.minimum_soc: - print( - "Warning: Storage dispatch was initialized with a state-of-charge less than " - "minimum value!" - ) - print(f"Initial SOC = {initial_soc}") - print("Initial SOC was set to minimum value.") - initial_soc = self.minimum_soc - return initial_soc - @staticmethod def _check_efficiency_value(efficiency): """Checks efficiency is between 0 and 1 or 0 and 100. Returns fractional value""" @@ -646,16 +583,6 @@ def max_discharge(self) -> float: for t in self.blocks.index_set(): return self.blocks[t].max_discharge.value - # @property - # def initial_soc(self) -> float: - # """Initial state-of-charge.""" - # return pyomo_model.initial_soc.value - - # @initial_soc.setter - # def initial_soc(self, initial_soc: float): - # initial_soc = self._check_initial_soc(initial_soc) - # pyomo_model.initial_soc = round(initial_soc, self.round_digits) - @property def minimum_soc(self) -> float: """Minimum state-of-charge.""" @@ -757,17 +684,6 @@ def load_production_limit(self, commodity_demand: list): def commodity_met_value(self) -> float: return [self.blocks[t].commodity_met_value.value for t in self.blocks.index_set()] - ### The following method is if the value of meeting the demand is variable - # if len(price_per_kwh) == len(self.blocks): - # for t, price in zip(self.blocks, price_per_kwh): - # self.blocks[t].commodity_met_value.set_value( - # round(price, self.round_digits) - # ) - # else: - # raise ValueError( - # "'price_per_kwh' list must be the same length as time horizon" - # ) - # OUTPUTS @property def is_charging(self) -> list: @@ -822,12 +738,6 @@ def storage_commodity_out(self) -> list: for t in self.blocks.index_set() ] - # @property - # def electricity_purchased(self) -> list: - # return [ - # self.blocks[t].electricity_purchased.value for t in self.blocks.index_set() - # ] - @property def is_generating(self) -> list: return [self.blocks[t].is_generating.value for t in self.blocks.index_set()] diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index ae80e4474..dfabf043f 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -275,7 +275,7 @@ def pyomo_solver( # loop over all control windows, where t is the starting index of each window for t in window_start_indices: - print("Iteration tracker:", t) + # get the inputs over the current control window commodity_in = inputs[self.config.commodity_name + "_in"][ t : t + self.config.n_control_window @@ -294,15 +294,16 @@ def pyomo_solver( ) elif "optimized" in control_strategy: + # Progress report + if t % (self.n_timesteps // 4) < self.n_control_window: + percentage = round((t / self.n_timesteps) * 100) + print(f"{percentage}% done with dispatch") # Update time series parameters for the optimization method self.update_time_series_parameters( commodity_in=commodity_in, commodity_demand=demand_in ) # Run dispatch optimization to minimize costs while meeting demand self.solve_dispatch_model( - commodity_in, - self.config.system_commodity_interface_limit, - demand_in, start_time=t, n_days=self.n_timesteps // 24, ) @@ -855,7 +856,7 @@ def make_dispatch_inputs(self): return dispatch_inputs -class OptimizedDispatchController(SimpleBatteryControllerHeuristic): +class OptimizedDispatchController(PyomoControllerBaseClass): """Operates the battery based on heuristic rules to meet the demand profile based power available from power generation profiles and power demand profile. @@ -892,6 +893,8 @@ def setup(self): if self.config.discharge_efficiency is not None: self.discharge_efficiency = self.config.discharge_efficiency + self.n_control_window = self.config.n_control_window + # Is this the best place to put this??? self.commodity_info = { "commodity_name": self.config.commodity_name, @@ -903,11 +906,14 @@ def setup(self): self.dispatch_inputs = self.config.make_dispatch_inputs() - # self.n_control_window = self.config.n_control_window - # self.n_horizon_window = self.config.n_control_window - - # Initialize parameters for optimization model def initialize_parameters(self, commodity_in, commodity_demand): + """Initialize parameters for optimization model + + Args: + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + + """ # Where pyomo model communicates with the rest of the controller # self.hybrid_dispatch_model is the pyomo model, this is the thing in hybrid_rule self.hybrid_dispatch_model = self._create_dispatch_optimization_model() @@ -918,27 +924,30 @@ def initialize_parameters(self, commodity_in, commodity_demand): # this is where dispatch problem state is made, this is used in the solver call self.problem_state = DispatchProblemState() - # hybrid_dispatch_rule is the thing where you can access variables from + # hybrid_dispatch_rule is the thing where you can access variables and hybrid_rule \ + # functions from self.hybrid_dispatch_rule.initialize_parameters( commodity_in, commodity_demand, self.dispatch_inputs ) - def update_time_series_parameters(self, start_time=0, commodity_in=None, commodity_demand=None): - # Where pyomo model communicates with the rest of the controller + def update_time_series_parameters(self, commodity_in=None, commodity_demand=None): + """Updates the pyomo optimization problem with parameters that change with time + + Args: + commodity_in (list): List of generated commodity in for this time slice. + commodity_demand (list): The demanded commodity for this time slice. + + """ self.hybrid_dispatch_rule.update_time_series_parameters( - start_time, commodity_in, commodity_demand + commodity_in, commodity_demand ) def solve_dispatch_model( self, - commodity_in: list, - system_commodity_interface_limit: list, - commodity_demand: list, start_time: int = 0, n_days: int = 0, ): - """Sets charge and discharge power of battery dispatch using fixed_dispatch attribute - and enforces available generation and charge/discharge limits. + """Sets charge and discharge power of battery dispatch from the optimized dispatch results Args: commodity_in (list): List of generated commodity in. @@ -948,21 +957,15 @@ def solve_dispatch_model( """ - # self.problem_state = DispatchProblemState() solver_results = self.glpk_solve() self.problem_state.store_problem_metrics( solver_results, start_time, n_days, pyomo.value(self.hybrid_dispatch_model.objective) ) - # TODO: Check that these function calls are appropriate for optimization model - # self.check_commodity_in_discharge_limit(commodity_in, system_commodity_interface_limit) - # self._set_commodity_fraction_limits(commodity_in, system_commodity_interface_limit) - # self._heuristic_method(commodity_in, commodity_demand) - # self._fix_dispatch_model_variables() - def _create_dispatch_optimization_model(self): """ - Creates monolith dispatch model + Creates monolith dispatch model by creating pyomo models for each technology, then + aggregating them into hybrid_rule """ model = pyomo.ConcreteModel(name="hybrid_dispatch") ################################# @@ -974,7 +977,6 @@ def _create_dispatch_optimization_model(self): ) for tech in self.source_techs: if tech == self.dispatch_tech[0]: - # tech.dispatch = PyomoRuleStorageMinOperatingCosts() dispatch = PyomoRuleStorageMinOperatingCosts( self.commodity_info, model, @@ -989,12 +991,9 @@ def _create_dispatch_optimization_model(self): model.forecast_horizon, block_set_name=f"{tech}_rule", ) - # tech.dispatch = PyomoDispatchGenericConverterMinOperatingCosts() self.pyomo_model.__setattr__(f"{tech}_rule", dispatch) - ################################# - # Blocks (technologies) # - ################################# + # Create hybrid pyomo model, inputting indivdual technology models self.hybrid_dispatch_rule = PyomoDispatchPlantRule( model, model.forecast_horizon, self.source_techs, self.pyomo_model, self.config ) From 6de980352f7ab3c302bd3af7d54af868a9bf0040 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 21 Jan 2026 14:51:32 -0500 Subject: [PATCH 23/37] Adding first tests - do not pass yet --- .../control_strategies/pyomo_controllers.py | 1 - .../control/test/test_pyomo_controllers.py | 254 ++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index dfabf043f..fcd868d15 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -315,7 +315,6 @@ def pyomo_solver( but has not been implemented yet." ) ) - # TODO: implement optimized solutions; this is where pyomo_model would be used # run the performance/simulation model for the current control window # using the dispatch commands diff --git a/h2integrate/control/test/test_pyomo_controllers.py b/h2integrate/control/test/test_pyomo_controllers.py index 1959a6455..c93040311 100644 --- a/h2integrate/control/test/test_pyomo_controllers.py +++ b/h2integrate/control/test/test_pyomo_controllers.py @@ -5,6 +5,7 @@ from h2integrate.storage.battery.pysam_battery import PySAMBatteryPerformanceModel from h2integrate.control.control_strategies.pyomo_controllers import ( HeuristicLoadFollowingController, + OptimizedDispatchController, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( PyomoRuleStorageBaseclass, @@ -399,3 +400,256 @@ def test_heuristic_load_following_battery_dispatch(subtests): pytest.approx(expected_unused_commodity_out, abs=abs_tol, rel=rel_tol) == prob.get_val("battery.unused_electricity_out")[:5] ) + +# The previous subtests seem to cover basic battery dispatch behavior (max and min SOC) +# The following tests will address the optimized dispatch calculation + + +def test_optimized_load_following_battery_dispatch(subtests): + # Fabricate some oscillating power generation data: 0 kW for the first 12 hours, 10000 kW for + # the second twelve hours, and repeat that daily cycle over a year. + n_look_ahead_half = int(24 / 2) + + electricity_in = np.concatenate( + (np.ones(n_look_ahead_half) * 0, np.ones(n_look_ahead_half) * 10000) + ) + electricity_in = np.tile(electricity_in, 365) + + demand_in = np.ones(8760) * 6000.0 + + tech_config["technologies"]["battery"] = { + "dispatch_rule_set": {"model": "pyomo_generic_storage"}, + "control_strategy": {"model": "optimized_dispatch_controller"}, + "performance_model": {"model": "pysam_battery"}, + "model_inputs": { + "shared_parameters": { + "max_charge_rate": 50000, + "max_capacity": 200000, + "n_control_window": 24, + "n_horizon_window": 48, + "init_charge_percent": 0.5, + "max_charge_percent": 0.9, + "min_charge_percent": 0.1, + "commodity_name": "electricity", + "commodity_storage_units": "kW", + "time_weighting_factor": 0.995, + "charge_efficiency": 0.95, + "discharge_efficiency": 0.95, + "cost_per_charge": 0.0027, + "cost_per_discharge": 0.003, + "cost_per_production": 0.001, + "commodity_met_value": 0.01, + }, + "performance_parameters": { + "system_model_source": "pysam", + "chemistry": "LFPGraphite", + "control_variable": "input_power", + }, + "control_parameters": { + "tech_name": "battery", + "system_commodity_interface_limit": 1e12, + }, + }, + } + + # Setup the OpenMDAO problem and add subsystems + prob = om.Problem() + + prob.model.add_subsystem( + "pyomo_generic_storage", + PyomoRuleStorageBaseclass( + plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] + ), + promotes=["*"], + ) + + prob.model.add_subsystem( + "battery_optimized_dispatch_controller", + OptimizedDispatchController( + plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] + ), + promotes=["*"], + ) + + prob.model.add_subsystem( + "battery", + PySAMBatteryPerformanceModel( + plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] + ), + promotes=["*"], + ) + + # Setup the system and required values + prob.setup() + prob.set_val("battery.electricity_in", electricity_in) + prob.set_val("battery.electricity_demand", demand_in) + + # Run the model + prob.run_model() + + # Test the case where the charging/discharging cycle remains within the max and min SOC limits + # Check the expected outputs to actual outputs + expected_electricity_out = [ + 5999.99995059, + 5990.56676743, + 5990.138959, + 5989.64831176, + 5989.08548217, + 5988.44193888, + 5987.70577962, + 5986.86071125, + 5985.88493352, + 5984.7496388, + 5983.41717191, + 5981.839478, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + ] + + expected_battery_electricity_discharge = [ + 5999.99995059, + 5990.56676743, + 5990.138959, + 5989.64831176, + 5989.08548217, + 5988.44193888, + 5987.70577962, + 5986.86071125, + 5985.88493352, + 5984.7496388, + 5983.41717191, + 5981.839478, + -3988.62235554, + -3989.2357847, + -3989.76832626, + -3990.26170521, + -3990.71676106, + -3991.13573086, + -3991.52143699, + -3991.87684905, + -3992.20485715, + -3992.50815603, + -3992.78920148, + -3993.05020268, + ] + + expected_SOC = [ + 49.39724571, + 46.54631833, + 43.69133882, + 40.83119769, + 37.96394628, + 35.08762294, + 32.20015974, + 29.29919751, + 26.38184809, + 23.44436442, + 20.48162855, + 17.48627159, + 19.47067094, + 21.44466462, + 23.40741401, + 25.36052712, + 27.30530573, + 29.24281439, + 31.17393198, + 33.09939078, + 35.01980641, + 36.93570091, + 38.84752069, + 40.75565055, + ] + + expected_unmet_demand_out = np.array( + [ + 4.93562475e-05, + 9.43323257e00, + 9.86104099e00, + 1.03516883e01, + 1.09145178e01, + 1.15580611e01, + 1.22942204e01, + 1.31392889e01, + 1.41150664e01, + 1.52503612e01, + 1.65828282e01, + 1.81605218e01, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + ] + ) + + expected_unused_commodity_out = np.array( + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 11.37764445, + 10.76421514, + 10.23167373, + 9.73829458, + 9.28323883, + 8.86426912, + 8.47856327, + 8.12315078, + 7.79514283, + 7.49184426, + 7.21079852, + 6.94979705, + ] + ) + + with subtests.test("Check electricity_out"): + assert ( + pytest.approx(expected_electricity_out) == prob.get_val("battery.electricity_out")[0:24] + ) + + with subtests.test("Check battery_electricity_discharge"): + assert ( + pytest.approx(expected_battery_electricity_discharge) + == prob.get_val("battery.battery_electricity_discharge")[0:24] + ) + + with subtests.test("Check SOC"): + assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] + + with subtests.test("Check unmet_demand"): + assert ( + pytest.approx(expected_unmet_demand_out, abs=1e-4) + == prob.get_val("battery.unmet_electricity_demand_out")[0:24] + ) + + with subtests.test("Check unused_electricity_out"): + assert ( + pytest.approx(expected_unused_commodity_out) + == prob.get_val("battery.unused_electricity_out")[0:24] + ) From 5d36008eebcd614bbd62c68d45d3370594b7a3b6 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 21 Jan 2026 15:44:24 -0500 Subject: [PATCH 24/37] Update docs and rename example --- docs/control/pyomo_controllers.md | 15 ++++++++++++--- .../18_pyomo_heuristic_dispatch/tech_config.yaml | 7 ++----- .../driver_config.yaml | 0 .../plant_config.yaml | 0 ...yomo_heuristic_dispatch_error_for_testing.yaml | 0 .../pyomo_optimized_dispatch.yaml | 0 .../run_pyomo_optimized_dispatch.py | 0 .../tech_config.yaml | 4 ++-- .../tech_config_error_for_testing.yaml | 0 h2integrate/core/supported_models.py | 4 ++-- 10 files changed, 18 insertions(+), 12 deletions(-) rename examples/{25_pyomo_optimized_dispatch => 27_pyomo_optimized_dispatch}/driver_config.yaml (100%) rename examples/{25_pyomo_optimized_dispatch => 27_pyomo_optimized_dispatch}/plant_config.yaml (100%) rename examples/{25_pyomo_optimized_dispatch => 27_pyomo_optimized_dispatch}/pyomo_heuristic_dispatch_error_for_testing.yaml (100%) rename examples/{25_pyomo_optimized_dispatch => 27_pyomo_optimized_dispatch}/pyomo_optimized_dispatch.yaml (100%) rename examples/{25_pyomo_optimized_dispatch => 27_pyomo_optimized_dispatch}/run_pyomo_optimized_dispatch.py (100%) rename examples/{25_pyomo_optimized_dispatch => 27_pyomo_optimized_dispatch}/tech_config.yaml (96%) rename examples/{25_pyomo_optimized_dispatch => 27_pyomo_optimized_dispatch}/tech_config_error_for_testing.yaml (100%) diff --git a/docs/control/pyomo_controllers.md b/docs/control/pyomo_controllers.md index 103dc8c2e..e78e44435 100644 --- a/docs/control/pyomo_controllers.md +++ b/docs/control/pyomo_controllers.md @@ -2,15 +2,24 @@ # Pyomo control framework [Pyomo](https://www.pyomo.org/about) is an open-source optimization software package. It is used in H2Integrate to facilitate modeling and solving control problems, specifically to determine optimal dispatch strategies for dispatchable technologies. -Pyomo control, allows for the possibility of feedback control at specified intervals, but can also be used for open-loop control if desired. In the pyomo control framework in H2Integrate, each technology can have control rules associated with them that are in turn passed to the pyomo control component, which is owned by the storage technology. The pyomo control component combines the technology rules into a single pyomo model, which is then passed to the storage technology performance model inside a callable dispatch function. The dispatch function also accepts a simulation method from the performance model and iterates between the pyomo model for dispatch commands and the performance simulation function to simulated performance with the specified commands. The dispatch function runs in specified time windows for dispatch and performance until the whole simulation time has been run. +Pyomo control allows for the possibility of feedback control at specified intervals, but can also be used for open-loop control if desired. In the pyomo control framework in H2Integrate, each technology can have control rules associated with them that are in turn passed to the pyomo control component, which is owned by the storage technology. The pyomo control component combines the technology rules into a single pyomo model, which is then passed to the storage technology performance model inside a callable dispatch function. The dispatch function also accepts a simulation method from the performance model and iterates between the pyomo model for dispatch commands and the performance simulation function to simulate performance with the specified commands. The dispatch function runs in specified time windows for dispatch and performance until the whole simulation time has been run. An example of an N2 diagram for a system using the pyomo control framework for hydrogen storage and dispatch is shown below ([click here for an interactive version](./figures/pyomo-n2.html)). Note the control rules being passed to the dispatch component and the dispatch function, containing the full pyomo model, being passed to the performance model for the battery/storage technology. Another important thing to recognize, in contrast to the open-loop control framework, is that the storage technology outputs (commodity out, SOC, unused commodity, etc) are passed out of the performance model when using the Pyomo control framework rather than from the control component. ![](./figures/pyomo-n2.png) +The pyomo control framework currently supports both a simple heuristic method and an optimized dispatch method for load following control. + (heuristic-load-following-controller)= ## Heuristic Load Following Controller -The pyomo control framework currently supports only a simple heuristic method, `heuristic_load_following_controller`, but we plan to extend the framework to be able to run a full dispatch optimization using a pyomo solver. When using the pyomo framework, a `dispatch_rule_set` for each technology connected to the storage technology must also be specified. These will typically be `pyomo_generic_converter` for generating technologies, and `pyomo_generic_storage` for storage technologies. More complex rule sets may be developed as needed. +The simple heuristic method is specified by setting the storage control to `heuristic_load_following_controller`. When using the pyomo framework, a `dispatch_rule_set` for each technology connected to the storage technology must also be specified. These will typically be `pyomo_dispatch_generic_converter` for generating technologies, and `pyomo_dispatch_generic_storage` for storage technologies. More complex rule sets may be developed as needed. -For an example of how to use the pyomo control framework with the `heuristic_load_following_controller`, see +For an example of how to use the heuristic pyomo control framework with the `heuristic_load_following_controller`, see - `examples/18_pyomo_heuristic_wind_battery_dispatch` + +(optimized-load-following-controller)= +## Optimized Load Following Controller +The optmimized dispatch method is specified by setting the storage control to `optimized_dispatch_controller`. The same `dispatch_rule_set` for each technology connected to the storage technology is followed as in the heuristic case. This method maximizes the load met while minimizing the cost of the system (operating cost) over each specified time window. + +For an example of how to use the optimized pyomo control framework with the `optimized_dispatch_controller`, see +- `examples/27_pyomo_optimized_dispatch` \ No newline at end of file diff --git a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml index 7cd06ac8f..3aeae150e 100644 --- a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml +++ b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml @@ -36,7 +36,7 @@ technologies: commodity_storage_units: "kW" battery: dispatch_rule_set: - model: "pyomo_generic_storage" + model: "pyomo_dispatch_generic_storage" control_strategy: model: "heuristic_load_following_controller" performance_model: @@ -65,8 +65,5 @@ technologies: power_capex: 311 # $/kW from 2024 ATB year 2025 opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB control_parameters: - # commodity_storage_units: "kW" tech_name: "battery" - # dispatch_rule_parameters: - # commodity_name: "electricity" - # commodity_storage_units: "kW" + diff --git a/examples/25_pyomo_optimized_dispatch/driver_config.yaml b/examples/27_pyomo_optimized_dispatch/driver_config.yaml similarity index 100% rename from examples/25_pyomo_optimized_dispatch/driver_config.yaml rename to examples/27_pyomo_optimized_dispatch/driver_config.yaml diff --git a/examples/25_pyomo_optimized_dispatch/plant_config.yaml b/examples/27_pyomo_optimized_dispatch/plant_config.yaml similarity index 100% rename from examples/25_pyomo_optimized_dispatch/plant_config.yaml rename to examples/27_pyomo_optimized_dispatch/plant_config.yaml diff --git a/examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml b/examples/27_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml similarity index 100% rename from examples/25_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml rename to examples/27_pyomo_optimized_dispatch/pyomo_heuristic_dispatch_error_for_testing.yaml diff --git a/examples/25_pyomo_optimized_dispatch/pyomo_optimized_dispatch.yaml b/examples/27_pyomo_optimized_dispatch/pyomo_optimized_dispatch.yaml similarity index 100% rename from examples/25_pyomo_optimized_dispatch/pyomo_optimized_dispatch.yaml rename to examples/27_pyomo_optimized_dispatch/pyomo_optimized_dispatch.yaml diff --git a/examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py b/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py similarity index 100% rename from examples/25_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py rename to examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py diff --git a/examples/25_pyomo_optimized_dispatch/tech_config.yaml b/examples/27_pyomo_optimized_dispatch/tech_config.yaml similarity index 96% rename from examples/25_pyomo_optimized_dispatch/tech_config.yaml rename to examples/27_pyomo_optimized_dispatch/tech_config.yaml index 4a66e9d20..8559b1f5a 100644 --- a/examples/25_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/27_pyomo_optimized_dispatch/tech_config.yaml @@ -9,7 +9,7 @@ technologies: cost_model: model: "atb_wind_cost" dispatch_rule_set: - model: "pyomo_generic_converter" + model: "pyomo_dispatch_generic_converter" resource: type: "pysam_wind" wind_speed: 9. @@ -40,7 +40,7 @@ technologies: commodity_storage_units: "kW" battery: dispatch_rule_set: - model: "pyomo_generic_storage" + model: "pyomo_dispatch_generic_storage" control_strategy: model: "optimized_dispatch_controller" performance_model: diff --git a/examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml b/examples/27_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml similarity index 100% rename from examples/25_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml rename to examples/27_pyomo_optimized_dispatch/tech_config_error_for_testing.yaml diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index ba3574aaa..06c956587 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -256,8 +256,8 @@ "demand_open_loop_converter_controller": DemandOpenLoopConverterController, "flexible_demand_open_loop_converter_controller": FlexibleDemandOpenLoopConverterController, # Dispatch - "pyomo_generic_converter": PyomoDispatchGenericConverter, - "pyomo_generic_storage": PyomoRuleStorageBaseclass, + "pyomo_dispatch_generic_converter": PyomoDispatchGenericConverter, + "pyomo_dispatch_generic_storage": PyomoRuleStorageBaseclass, "pyomo_battery_min_operating_cost": PyomoRuleStorageMinOperatingCosts, "pyomo_generic_converter_min_operating_cost": PyomoDispatchGenericConverterMinOperatingCosts, # Feedstock From ba6d65d4d66dc9e88a25ed171d808b3f2adedd49 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 21 Jan 2026 15:49:03 -0500 Subject: [PATCH 25/37] Align naming with develop branch --- .../tech_config_error_for_testing.yaml | 12 +++++------- h2integrate/storage/battery/pysam_battery.py | 6 +++--- .../battery/test_battery/inputs/tech_config.yaml | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml b/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml index 3a5ce9b36..ec8b08b89 100644 --- a/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml +++ b/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml @@ -8,7 +8,7 @@ technologies: cost_model: model: "atb_wind_cost" dispatch_rule_set: - model: "pyomo_generic_converter" + model: "pyomo_dispatch_generic_converter" resource: type: "pysam_wind" wind_speed: 9. @@ -39,7 +39,7 @@ technologies: commodity_storage_units: "kW" battery: dispatch_rule_set: - model: "pyomo_generic_storage" + model: "pyomo_dispatch_generic_storage" control_strategy: model: "heuristic_load_following_controller" performance_model: @@ -48,6 +48,8 @@ technologies: model: "atb_battery_cost" model_inputs: shared_parameters: + commodity_name: "electricity" + commodity_storage_units: "kW" max_charge_rate: 100000 max_capacity: 500000 n_control_window: 24 @@ -65,9 +67,5 @@ technologies: power_capex: 311 # $/kW from 2024 ATB year 2025 opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB control_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" tech_name: "wrong_tech_name" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" + diff --git a/h2integrate/storage/battery/pysam_battery.py b/h2integrate/storage/battery/pysam_battery.py index c7f3f9293..6b1b551fd 100644 --- a/h2integrate/storage/battery/pysam_battery.py +++ b/h2integrate/storage/battery/pysam_battery.py @@ -304,7 +304,7 @@ def setup(self): "tech_to_dispatch_connections" ]: if any(intended_dispatch_tech in name for name in self.tech_group_name): - self.add_discrete_input("pyomo_solver", val=dummy_function) + self.add_discrete_input("pyomo_dispatch_solver", val=dummy_function) break self.unmet_demand = 0.0 @@ -365,9 +365,9 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): self.system_model.value("input_power", 0.0) self.system_model.execute(0) - if "pyomo_solver" in discrete_inputs: + if "pyomo_dispatch_solver" in discrete_inputs: # Simulate the battery with provided dispatch inputs - dispatch = discrete_inputs["pyomo_solver"] + dispatch = discrete_inputs["pyomo_dispatch_solver"] kwargs = { "time_step_duration": self.dt_hr, "control_variable": self.config.control_variable, diff --git a/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml b/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml index a3ef39a34..39114df9d 100644 --- a/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml +++ b/h2integrate/storage/battery/test_battery/inputs/tech_config.yaml @@ -4,7 +4,7 @@ description: "This hybrid plant stores and discharges electricity" technologies: battery: dispatch_rule_set: - model: "pyomo_generic_storage" + model: "pyomo_dispatch_generic_storage" performance_model: model: "pysam_battery" cost_model: From bb8e7d84ddd5784fc8020b7dd460af2d497cf73e Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 21 Jan 2026 16:04:01 -0500 Subject: [PATCH 26/37] Update Ex 02 and update pyomo_controllers with naming in develop --- examples/02_texas_ammonia/tech_config.yaml | 6 ++---- .../control/control_strategies/pyomo_controllers.py | 12 ++++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/examples/02_texas_ammonia/tech_config.yaml b/examples/02_texas_ammonia/tech_config.yaml index 60a838b80..c9a88e9ef 100644 --- a/examples/02_texas_ammonia/tech_config.yaml +++ b/examples/02_texas_ammonia/tech_config.yaml @@ -80,6 +80,7 @@ technologies: model_inputs: shared_parameters: commodity_name: "electricity" + commodity_storage_units: "kW" max_charge_rate: 96.0 #kW max_capacity: 96.0 #kWh n_control_window: 24 @@ -98,11 +99,8 @@ technologies: power_capex: 311 # $/kW from 2024 ATB year 2025 opex_fraction: 0.025 control_parameters: - commodity_storage_units: "kW" tech_name: "battery" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" + electrolyzer: performance_model: model: "eco_pem_electrolyzer_performance" diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index fcd868d15..133916b57 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -110,7 +110,7 @@ def setup(self): Adds discrete inputs named 'dispatch_block_rule_function' (and variants suffixed with source tech names for cross-tech connections) plus a - discrete output 'pyomo_solver' that will hold the assembled + discrete output 'pyomo_dispatch_solver' that will hold the assembled callable after compute(). """ @@ -139,7 +139,7 @@ def setup(self): # create output for the pyomo control model self.add_discrete_output( - "pyomo_solver", + "pyomo_dispatch_solver", val=dummy_function, desc="callable: fully formed pyomo model and execution logic to be run \ by owning technologies performance model", @@ -147,7 +147,7 @@ def setup(self): def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """Build Pyomo model blocks and assign the dispatch solver.""" - discrete_outputs["pyomo_solver"] = self.pyomo_setup(discrete_inputs) + discrete_outputs["pyomo_dispatch_solver"] = self.pyomo_setup(discrete_inputs) def pyomo_setup(self, discrete_inputs): """Create the Pyomo model, attach per-tech Blocks, and return dispatch solver. @@ -188,7 +188,7 @@ def pyomo_setup(self, discrete_inputs): continue # define dispatch solver - def pyomo_solver( + def pyomo_dispatch_solver( performance_model: callable, performance_model_kwargs, inputs, @@ -344,7 +344,7 @@ def pyomo_solver( return total_commodity_out, storage_commodity_out, unmet_demand, unused_commodity, soc - return pyomo_solver + return pyomo_dispatch_solver @staticmethod def dispatch_block_rule(block, t): @@ -1005,7 +1005,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): self.config.max_capacity = inputs["storage_capacity"][0] self.config.max_charge_rate = inputs["max_charge_rate"][0] - discrete_outputs["pyomo_solver"] = self.pyomo_setup(discrete_inputs) + discrete_outputs["pyomo_dispatch_solver"] = self.pyomo_setup(discrete_inputs) @staticmethod def glpk_solve_call( From 6a1d0509389949130c2f078c73b0f3edd4985597 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:25:34 -0700 Subject: [PATCH 27/37] updated other example tech configs --- examples/01_onshore_steel_mn/tech_config.yaml | 5 +---- examples/09_co2/direct_ocean_capture/tech_config.yaml | 6 ++---- .../09_co2/ocean_alkalinity_enhancement/tech_config.yaml | 6 ++---- examples/12_ammonia_synloop/tech_config.yaml | 6 ++---- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/examples/01_onshore_steel_mn/tech_config.yaml b/examples/01_onshore_steel_mn/tech_config.yaml index 58017fca9..8d5bcb565 100644 --- a/examples/01_onshore_steel_mn/tech_config.yaml +++ b/examples/01_onshore_steel_mn/tech_config.yaml @@ -82,6 +82,7 @@ technologies: model_inputs: shared_parameters: commodity_name: "electricity" + commodity_storage_units: "kW" max_charge_rate: 375740.4 #kW max_capacity: 375745.2 #kWh n_control_window: 24 @@ -100,11 +101,7 @@ technologies: power_capex: 311 # $/kW from 2024 ATB year 2025 opex_fraction: 0.024999840573439444 control_parameters: - commodity_storage_units: "kW" tech_name: "battery" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" electrolyzer: performance_model: diff --git a/examples/09_co2/direct_ocean_capture/tech_config.yaml b/examples/09_co2/direct_ocean_capture/tech_config.yaml index 28db428f6..c5b05e118 100644 --- a/examples/09_co2/direct_ocean_capture/tech_config.yaml +++ b/examples/09_co2/direct_ocean_capture/tech_config.yaml @@ -73,6 +73,7 @@ technologies: model_inputs: shared_parameters: commodity_name: "electricity" + commodity_storage_units: "kW" max_charge_rate: 50000 #kW max_capacity: 200000 #kWh n_control_window: 24 @@ -91,11 +92,8 @@ technologies: power_capex: 317 # $/kW from 2024 ATB year 2025 opex_fraction: 0.02536510376633359 control_parameters: - commodity_storage_units: "kW" tech_name: "battery" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" + doc: performance_model: model: "direct_ocean_capture_performance" diff --git a/examples/09_co2/ocean_alkalinity_enhancement/tech_config.yaml b/examples/09_co2/ocean_alkalinity_enhancement/tech_config.yaml index e98836827..4f7957991 100644 --- a/examples/09_co2/ocean_alkalinity_enhancement/tech_config.yaml +++ b/examples/09_co2/ocean_alkalinity_enhancement/tech_config.yaml @@ -50,6 +50,7 @@ technologies: model_inputs: shared_parameters: commodity_name: "electricity" + commodity_storage_units: "kW" max_charge_rate: 50000 #kW max_capacity: 200000 #kWh n_control_window: 24 @@ -68,11 +69,8 @@ technologies: power_capex: 317 # $/kW from 2024 ATB year 2025 opex_fraction: 0.02536510376633359 control_parameters: - commodity_storage_units: "kW" tech_name: "battery" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" + oae: performance_model: model: "ocean_alkalinity_enhancement_performance" diff --git a/examples/12_ammonia_synloop/tech_config.yaml b/examples/12_ammonia_synloop/tech_config.yaml index d239f1e65..441292b9a 100644 --- a/examples/12_ammonia_synloop/tech_config.yaml +++ b/examples/12_ammonia_synloop/tech_config.yaml @@ -80,6 +80,7 @@ technologies: model_inputs: shared_parameters: commodity_name: "electricity" + commodity_storage_units: "kW" max_charge_rate: 96.0 #kW max_capacity: 96.0 #kWh n_control_window: 24 @@ -98,11 +99,8 @@ technologies: power_capex: 311 # $/kW from 2024 ATB year 2025 opex_fraction: 0.025 control_parameters: - commodity_storage_units: "kW" tech_name: "battery" - dispatch_rule_parameters: - commodity_name: "electricity" - commodity_storage_units: "kW" + electrolyzer: performance_model: model: "eco_pem_electrolyzer_performance" From 57bf534bcb609aff448696ad9dfa18dac9493403 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:35:05 -0700 Subject: [PATCH 28/37] ran precommit on some files --- docs/control/pyomo_controllers.md | 2 +- .../tech_config.yaml | 1 - .../tech_config_error_for_testing.yaml | 1 - .../tech_config.yaml | 2 +- .../converters/generic_converter_opt.py | 2 +- .../control/control_rules/hybrid_rule.py | 10 ++- .../pyomo_storage_rule_min_operating_cost.py | 4 +- .../control/test/test_pyomo_controllers.py | 65 ++++++++++--------- 8 files changed, 42 insertions(+), 45 deletions(-) diff --git a/docs/control/pyomo_controllers.md b/docs/control/pyomo_controllers.md index e78e44435..85b259696 100644 --- a/docs/control/pyomo_controllers.md +++ b/docs/control/pyomo_controllers.md @@ -22,4 +22,4 @@ For an example of how to use the heuristic pyomo control framework with the `heu The optmimized dispatch method is specified by setting the storage control to `optimized_dispatch_controller`. The same `dispatch_rule_set` for each technology connected to the storage technology is followed as in the heuristic case. This method maximizes the load met while minimizing the cost of the system (operating cost) over each specified time window. For an example of how to use the optimized pyomo control framework with the `optimized_dispatch_controller`, see -- `examples/27_pyomo_optimized_dispatch` \ No newline at end of file +- `examples/27_pyomo_optimized_dispatch` diff --git a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml index 3aeae150e..ccc46536d 100644 --- a/examples/18_pyomo_heuristic_dispatch/tech_config.yaml +++ b/examples/18_pyomo_heuristic_dispatch/tech_config.yaml @@ -66,4 +66,3 @@ technologies: opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB control_parameters: tech_name: "battery" - diff --git a/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml b/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml index ec8b08b89..ac10473ca 100644 --- a/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml +++ b/examples/18_pyomo_heuristic_dispatch/tech_config_error_for_testing.yaml @@ -68,4 +68,3 @@ technologies: opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB control_parameters: tech_name: "wrong_tech_name" - diff --git a/examples/27_pyomo_optimized_dispatch/tech_config.yaml b/examples/27_pyomo_optimized_dispatch/tech_config.yaml index 8559b1f5a..a91710d67 100644 --- a/examples/27_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/27_pyomo_optimized_dispatch/tech_config.yaml @@ -64,7 +64,7 @@ technologies: commodity_storage_units: "kW" cost_per_charge: 0.0027 cost_per_discharge: 0.003 - commodity_met_value: 0.01 + commodity_met_value: 0.01 cost_per_production: 0.001 performance_parameters: system_model_source: "pysam" diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index 52cf26978..8a0f58409 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -100,7 +100,7 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel): def _create_parameters(self, pyomo_model: pyo.ConcreteModel): """Create generic converter Pyomo parameters to add to the Pyomo model instance. - This method defines converter parameters such as available production and the + This method defines converter parameters such as available production and the cost per generation for the technology Args: diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index bc74fb6f8..8aa6ec426 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -5,6 +5,7 @@ class PyomoDispatchPlantRule: """Class defining Pyomo model and rule for the optimized dispatch for load following for the overal optimization problem describing the system.""" + def __init__( self, pyomo_model: pyo.ConcreteModel, @@ -14,7 +15,6 @@ def __init__( dispatch_options: dict, block_set_name: str = "hybrid", ): - self.source_techs = source_techs # self.pyomo_model self.options = dispatch_options # only using dispatch_options.time_weighting_factor self.power_source_gen_vars = {key: [] for key in index_set} @@ -147,9 +147,7 @@ def arc_rule(m, t): pyo.TransformationFactory("network.expand_arcs").apply_to(self.model) - def update_time_series_parameters( - self, commodity_in=list, commodity_demand=list - ): + def update_time_series_parameters(self, commodity_in=list, commodity_demand=list): """ Updates the pyomo optimization problem with parameters that change with time @@ -163,7 +161,7 @@ def update_time_series_parameters( for tech in self.source_techs: name = tech + "_rule" pyomo_block = self.tech_dispatch_models.__getattribute__(name) - pyomo_block.update_time_series_parameters( commodity_in, commodity_demand) + pyomo_block.update_time_series_parameters(commodity_in, commodity_demand) def create_min_operating_cost_expression(self): """ @@ -206,4 +204,4 @@ def storage_commodity_out(self) -> list: return [ self.blocks[t].discharge_commodity.value - self.blocks[t].charge_commodity.value for t in self.blocks.index_set() - ] \ No newline at end of file + ] 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 cb77ab074..4c6bbe98d 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 @@ -57,7 +57,7 @@ def initialize_parameters( # System parameters self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] - # This should not be set to the commodity_demand. Any way to set it to the production + # This should not be set to the commodity_demand. Any way to set it to the production # capacity of the plant? self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] self._set_initial_soc_constraint() @@ -379,7 +379,7 @@ def soc_inventory_rule(m): def _set_initial_soc_constraint(self): """ - This method links the SOC between the end of one control period and the beginning + This method links the SOC between the end of one control period and the beginning of the next control period. """ ################################## diff --git a/h2integrate/control/test/test_pyomo_controllers.py b/h2integrate/control/test/test_pyomo_controllers.py index c93040311..0f9400b1f 100644 --- a/h2integrate/control/test/test_pyomo_controllers.py +++ b/h2integrate/control/test/test_pyomo_controllers.py @@ -4,8 +4,8 @@ from h2integrate.storage.battery.pysam_battery import PySAMBatteryPerformanceModel from h2integrate.control.control_strategies.pyomo_controllers import ( - HeuristicLoadFollowingController, OptimizedDispatchController, + HeuristicLoadFollowingController, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( PyomoRuleStorageBaseclass, @@ -401,6 +401,7 @@ def test_heuristic_load_following_battery_dispatch(subtests): == prob.get_val("battery.unused_electricity_out")[:5] ) + # The previous subtests seem to cover basic battery dispatch behavior (max and min SOC) # The following tests will address the optimized dispatch calculation @@ -418,38 +419,38 @@ def test_optimized_load_following_battery_dispatch(subtests): demand_in = np.ones(8760) * 6000.0 tech_config["technologies"]["battery"] = { - "dispatch_rule_set": {"model": "pyomo_generic_storage"}, - "control_strategy": {"model": "optimized_dispatch_controller"}, - "performance_model": {"model": "pysam_battery"}, - "model_inputs": { - "shared_parameters": { - "max_charge_rate": 50000, - "max_capacity": 200000, - "n_control_window": 24, - "n_horizon_window": 48, - "init_charge_percent": 0.5, - "max_charge_percent": 0.9, - "min_charge_percent": 0.1, - "commodity_name": "electricity", - "commodity_storage_units": "kW", - "time_weighting_factor": 0.995, - "charge_efficiency": 0.95, - "discharge_efficiency": 0.95, - "cost_per_charge": 0.0027, - "cost_per_discharge": 0.003, - "cost_per_production": 0.001, - "commodity_met_value": 0.01, - }, - "performance_parameters": { - "system_model_source": "pysam", - "chemistry": "LFPGraphite", - "control_variable": "input_power", - }, - "control_parameters": { - "tech_name": "battery", - "system_commodity_interface_limit": 1e12, - }, + "dispatch_rule_set": {"model": "pyomo_generic_storage"}, + "control_strategy": {"model": "optimized_dispatch_controller"}, + "performance_model": {"model": "pysam_battery"}, + "model_inputs": { + "shared_parameters": { + "max_charge_rate": 50000, + "max_capacity": 200000, + "n_control_window": 24, + "n_horizon_window": 48, + "init_charge_percent": 0.5, + "max_charge_percent": 0.9, + "min_charge_percent": 0.1, + "commodity_name": "electricity", + "commodity_storage_units": "kW", + "time_weighting_factor": 0.995, + "charge_efficiency": 0.95, + "discharge_efficiency": 0.95, + "cost_per_charge": 0.0027, + "cost_per_discharge": 0.003, + "cost_per_production": 0.001, + "commodity_met_value": 0.01, }, + "performance_parameters": { + "system_model_source": "pysam", + "chemistry": "LFPGraphite", + "control_variable": "input_power", + }, + "control_parameters": { + "tech_name": "battery", + "system_commodity_interface_limit": 1e12, + }, + }, } # Setup the OpenMDAO problem and add subsystems From 0acaa30d783f4631cc77a41356123140e419ed83 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:39:22 -0700 Subject: [PATCH 29/37] precommit on pyomo_controllers.py --- .../control/control_strategies/pyomo_controllers.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 133916b57..ef6dbf059 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -262,7 +262,7 @@ def pyomo_dispatch_solver( elif "optimized" in control_strategy: # Initialize parameters for optimized dispatch strategy self.initialize_parameters( - inputs[f"{commodity_name}_in"], inputs[f"{commodity_name}_demand"] + inputs[f"{commodity_name}_in"], inputs[f"{commodity_name}_demand"] ) else: @@ -275,7 +275,6 @@ def pyomo_dispatch_solver( # loop over all control windows, where t is the starting index of each window for t in window_start_indices: - # get the inputs over the current control window commodity_in = inputs[self.config.commodity_name + "_in"][ t : t + self.config.n_control_window @@ -937,9 +936,7 @@ def update_time_series_parameters(self, commodity_in=None, commodity_demand=None commodity_demand (list): The demanded commodity for this time slice. """ - self.hybrid_dispatch_rule.update_time_series_parameters( - commodity_in, commodity_demand - ) + self.hybrid_dispatch_rule.update_time_series_parameters(commodity_in, commodity_demand) def solve_dispatch_model( self, From e3d03155c221f2feeed889ceca4802f3e6cad555 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 21 Jan 2026 18:17:53 -0500 Subject: [PATCH 30/37] Update test formatting --- h2integrate/control/test/test_pyomo_controllers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/h2integrate/control/test/test_pyomo_controllers.py b/h2integrate/control/test/test_pyomo_controllers.py index 0f9400b1f..cb664207b 100644 --- a/h2integrate/control/test/test_pyomo_controllers.py +++ b/h2integrate/control/test/test_pyomo_controllers.py @@ -4,8 +4,8 @@ from h2integrate.storage.battery.pysam_battery import PySAMBatteryPerformanceModel from h2integrate.control.control_strategies.pyomo_controllers import ( - OptimizedDispatchController, HeuristicLoadFollowingController, + OptimizedDispatchController, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( PyomoRuleStorageBaseclass, @@ -35,7 +35,7 @@ "description": "...", "technologies": { "battery": { - "dispatch_rule_set": {"model": "pyomo_generic_storage"}, + "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, "control_strategy": {"model": "heuristic_load_following_controller"}, "performance_model": {"model": "pysam_battery"}, "model_inputs": { @@ -419,7 +419,7 @@ def test_optimized_load_following_battery_dispatch(subtests): demand_in = np.ones(8760) * 6000.0 tech_config["technologies"]["battery"] = { - "dispatch_rule_set": {"model": "pyomo_generic_storage"}, + "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, "control_strategy": {"model": "optimized_dispatch_controller"}, "performance_model": {"model": "pysam_battery"}, "model_inputs": { From a509354376e64e9b9c9ceb9d8d7562577880bc65 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Wed, 21 Jan 2026 18:52:46 -0500 Subject: [PATCH 31/37] Update pyomo storage rule for test --- .../storage/pyomo_storage_rule_min_operating_cost.py | 1 + 1 file changed, 1 insertion(+) 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 4c6bbe98d..a4ceb3f5f 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 @@ -18,6 +18,7 @@ def __init__( self.block_set_name = block_set_name self.commodity_name = commodity_info["commodity_name"] self.commodity_storage_units = commodity_info["commodity_storage_units"] + pyo.units.load_definitions_from_strings(["USD = [currency]"]) self.model = pyomo_model self.blocks = pyo.Block(index_set, rule=self.dispatch_block_rule_function) From 2342730ab4833bbfbf0ed12431df2982bfead136 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Fri, 23 Jan 2026 16:04:52 -0500 Subject: [PATCH 32/37] Fix SOC linking bug --- .../tech_config.yaml | 10 +- .../converters/generic_converter_opt.py | 4 +- .../control/control_rules/hybrid_rule.py | 8 +- .../pyomo_storage_rule_min_operating_cost.py | 29 +---- .../control_strategies/pyomo_controllers.py | 23 +++- .../control/test/test_pyomo_controllers.py | 122 ++++++------------ 6 files changed, 72 insertions(+), 124 deletions(-) diff --git a/examples/27_pyomo_optimized_dispatch/tech_config.yaml b/examples/27_pyomo_optimized_dispatch/tech_config.yaml index a91710d67..6c47db2c3 100644 --- a/examples/27_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/27_pyomo_optimized_dispatch/tech_config.yaml @@ -51,7 +51,7 @@ technologies: shared_parameters: commodity_name: "electricity" max_charge_rate: 100000 - max_capacity: 500000 + max_capacity: 400000 n_control_window: 24 n_horizon_window: 48 init_charge_percent: 0.5 @@ -62,10 +62,10 @@ technologies: charge_efficiency: 0.95 discharge_efficiency: 0.95 commodity_storage_units: "kW" - cost_per_charge: 0.0027 - cost_per_discharge: 0.003 - commodity_met_value: 0.01 - cost_per_production: 0.001 + cost_per_charge: 0.04 + cost_per_discharge: 0.05 + commodity_met_value: 0.1 + cost_per_production: 0.0 performance_parameters: system_model_source: "pysam" chemistry: "LFPGraphite" diff --git a/h2integrate/control/control_rules/converters/generic_converter_opt.py b/h2integrate/control/control_rules/converters/generic_converter_opt.py index 8a0f58409..0f760bc63 100644 --- a/h2integrate/control/control_rules/converters/generic_converter_opt.py +++ b/h2integrate/control/control_rules/converters/generic_converter_opt.py @@ -147,9 +147,7 @@ def _create_constraints(self, pyomo_model: pyo.ConcreteModel): # Update time series parameters for next optimization window def update_time_series_parameters( - self, - commodity_in: list, - commodity_demand: list, + self, commodity_in: list, commodity_demand: list, updated_initial_soc: float ): """Updates the pyomo optimization problem with parameters that change with time diff --git a/h2integrate/control/control_rules/hybrid_rule.py b/h2integrate/control/control_rules/hybrid_rule.py index 8aa6ec426..56c9cbc96 100644 --- a/h2integrate/control/control_rules/hybrid_rule.py +++ b/h2integrate/control/control_rules/hybrid_rule.py @@ -147,7 +147,9 @@ def arc_rule(m, t): pyo.TransformationFactory("network.expand_arcs").apply_to(self.model) - def update_time_series_parameters(self, commodity_in=list, commodity_demand=list): + def update_time_series_parameters( + self, commodity_in=list, commodity_demand=list, updated_initial_soc=float + ): """ Updates the pyomo optimization problem with parameters that change with time @@ -161,7 +163,9 @@ def update_time_series_parameters(self, commodity_in=list, commodity_demand=list for tech in self.source_techs: name = tech + "_rule" pyomo_block = self.tech_dispatch_models.__getattribute__(name) - pyomo_block.update_time_series_parameters(commodity_in, commodity_demand) + pyomo_block.update_time_series_parameters( + commodity_in, commodity_demand, updated_initial_soc + ) def create_min_operating_cost_expression(self): """ 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 a4ceb3f5f..23433cca0 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 @@ -58,9 +58,7 @@ def initialize_parameters( # System parameters self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] - # This should not be set to the commodity_demand. Any way to set it to the production - # capacity of the plant? - self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] + self._set_initial_soc_constraint() def dispatch_block_rule_function(self, pyomo_model: pyo.ConcreteModel, tech_name: str): @@ -217,13 +215,6 @@ def _create_parameters(self, pyomo_model: pyo.ConcreteModel, t): mutable=True, units=pyo_commodity_storage_unit, ) - pyomo_model.load_production_limit = pyo.Param( - doc=f"Production limit for load [{self.commodity_storage_units}]", - default=1000.0, - within=pyo.NonNegativeReals, - mutable=True, - units=pyo_commodity_storage_unit, - ) def _create_variables(self, pyomo_model: pyo.ConcreteModel, t): """Create storage-related decision variables in the Pyomo model. @@ -432,9 +423,7 @@ def _create_ports(self, pyomo_model: pyo.ConcreteModel, t): # Update time series parameters for next optimization window def update_time_series_parameters( - self, - commodity_in: list, - commodity_demand: list, + self, commodity_in: list, commodity_demand: list, updated_initial_soc: None ): """Updates the pyomo optimization problem with parameters that change with time @@ -445,7 +434,7 @@ def update_time_series_parameters( """ self.time_duration = [1.0] * len(self.blocks.index_set()) self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] - self.load_production_limit = [commodity_demand[t] for t in self.blocks.index_set()] + self.model.initial_soc = updated_initial_soc # Objective functions def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): @@ -669,18 +658,6 @@ def commodity_load_demand(self, commodity_demand: list): else: raise ValueError("'commodity_demand' list must be the same length as time horizon") - @property - def load_production_limit(self) -> list: - return [self.blocks[t].load_production_limit.value for t in self.blocks.index_set()] - - @load_production_limit.setter - def load_production_limit(self, commodity_demand: list): - if len(commodity_demand) == len(self.blocks): - for t, limit in zip(self.blocks, commodity_demand): - self.blocks[t].load_production_limit.set_value(round(limit, self.round_digits)) - else: - raise ValueError("'commodity_demand' list must be the same length as time horizon") - @property def commodity_met_value(self) -> float: return [self.blocks[t].commodity_met_value.value for t in self.blocks.index_set()] diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index ef6dbf059..33f2c18b4 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -299,7 +299,9 @@ def pyomo_dispatch_solver( print(f"{percentage}% done with dispatch") # Update time series parameters for the optimization method self.update_time_series_parameters( - commodity_in=commodity_in, commodity_demand=demand_in + commodity_in=commodity_in, + commodity_demand=demand_in, + updated_initial_soc=self.updated_initial_soc, ) # Run dispatch optimization to minimize costs while meeting demand self.solve_dispatch_model( @@ -322,7 +324,14 @@ def pyomo_dispatch_solver( **performance_model_kwargs, sim_start_index=t, ) - + # print("Storage commands: ", self.storage_dispatch_commands) + # print("Battery performance: ", storage_commodity_out_control_window) + # print("Battery SOC: ", soc_control_window) + # print("power in: ", commodity_in) + self.updated_initial_soc = soc_control_window[-1] / 100 # turn into ratio + # if "optimized" in control_strategy: + + # jkjk # get a list of all time indices belonging to the current control window window_indices = list(range(t, t + self.config.n_control_window)) @@ -892,6 +901,7 @@ def setup(self): self.discharge_efficiency = self.config.discharge_efficiency self.n_control_window = self.config.n_control_window + self.updated_initial_soc = self.config.init_charge_percent # Is this the best place to put this??? self.commodity_info = { @@ -928,7 +938,9 @@ def initialize_parameters(self, commodity_in, commodity_demand): commodity_in, commodity_demand, self.dispatch_inputs ) - def update_time_series_parameters(self, commodity_in=None, commodity_demand=None): + def update_time_series_parameters( + self, commodity_in=None, commodity_demand=None, updated_initial_soc=None + ): """Updates the pyomo optimization problem with parameters that change with time Args: @@ -936,7 +948,10 @@ def update_time_series_parameters(self, commodity_in=None, commodity_demand=None commodity_demand (list): The demanded commodity for this time slice. """ - self.hybrid_dispatch_rule.update_time_series_parameters(commodity_in, commodity_demand) + print("HIIII", updated_initial_soc) + self.hybrid_dispatch_rule.update_time_series_parameters( + commodity_in, commodity_demand, updated_initial_soc + ) def solve_dispatch_model( self, diff --git a/h2integrate/control/test/test_pyomo_controllers.py b/h2integrate/control/test/test_pyomo_controllers.py index cb664207b..75e92db8f 100644 --- a/h2integrate/control/test/test_pyomo_controllers.py +++ b/h2integrate/control/test/test_pyomo_controllers.py @@ -4,8 +4,8 @@ from h2integrate.storage.battery.pysam_battery import PySAMBatteryPerformanceModel from h2integrate.control.control_strategies.pyomo_controllers import ( - HeuristicLoadFollowingController, OptimizedDispatchController, + HeuristicLoadFollowingController, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( PyomoRuleStorageBaseclass, @@ -412,11 +412,11 @@ def test_optimized_load_following_battery_dispatch(subtests): n_look_ahead_half = int(24 / 2) electricity_in = np.concatenate( - (np.ones(n_look_ahead_half) * 0, np.ones(n_look_ahead_half) * 10000) + (np.ones(n_look_ahead_half) * 1000, np.ones(n_look_ahead_half) * 100000) ) - electricity_in = np.tile(electricity_in, 365) + electricity_in = np.tile(electricity_in, 3) - demand_in = np.ones(8760) * 6000.0 + demand_in = np.ones(72) * 6000.0 tech_config["technologies"]["battery"] = { "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, @@ -436,10 +436,10 @@ def test_optimized_load_following_battery_dispatch(subtests): "time_weighting_factor": 0.995, "charge_efficiency": 0.95, "discharge_efficiency": 0.95, - "cost_per_charge": 0.0027, - "cost_per_discharge": 0.003, - "cost_per_production": 0.001, - "commodity_met_value": 0.01, + "cost_per_charge": 0.04, + "cost_per_discharge": 0.05, + "cost_per_production": 0.0, + "commodity_met_value": 0.1, }, "performance_parameters": { "system_model_source": "pysam", @@ -453,6 +453,9 @@ def test_optimized_load_following_battery_dispatch(subtests): }, } + # Can't find the electricity in because it's not in the tech to tech connections. + plant_config["plant"]["simulation"]["n_timesteps"] = 72 + # Setup the OpenMDAO problem and add subsystems prob = om.Problem() @@ -517,61 +520,7 @@ def test_optimized_load_following_battery_dispatch(subtests): 6000.0, ] - expected_battery_electricity_discharge = [ - 5999.99995059, - 5990.56676743, - 5990.138959, - 5989.64831176, - 5989.08548217, - 5988.44193888, - 5987.70577962, - 5986.86071125, - 5985.88493352, - 5984.7496388, - 5983.41717191, - 5981.839478, - -3988.62235554, - -3989.2357847, - -3989.76832626, - -3990.26170521, - -3990.71676106, - -3991.13573086, - -3991.52143699, - -3991.87684905, - -3992.20485715, - -3992.50815603, - -3992.78920148, - -3993.05020268, - ] - - expected_SOC = [ - 49.39724571, - 46.54631833, - 43.69133882, - 40.83119769, - 37.96394628, - 35.08762294, - 32.20015974, - 29.29919751, - 26.38184809, - 23.44436442, - 20.48162855, - 17.48627159, - 19.47067094, - 21.44466462, - 23.40741401, - 25.36052712, - 27.30530573, - 29.24281439, - 31.17393198, - 33.09939078, - 35.01980641, - 36.93570091, - 38.84752069, - 40.75565055, - ] - - expected_unmet_demand_out = np.array( + np.array( [ 4.93562475e-05, 9.43323257e00, @@ -600,7 +549,7 @@ def test_optimized_load_following_battery_dispatch(subtests): ] ) - expected_unused_commodity_out = np.array( + np.array( [ 0.0, 0.0, @@ -629,28 +578,33 @@ def test_optimized_load_following_battery_dispatch(subtests): ] ) + print("Electricity out", prob.get_val("battery.electricity_out")) + print("Battery out", prob.get_val("battery.battery_electricity_discharge")) + print("unmet demand", prob.get_val("battery.unmet_electricity_demand_out")) + print("Unused electricity", prob.get_val("battery.unused_electricity_out")) + with subtests.test("Check electricity_out"): assert ( pytest.approx(expected_electricity_out) == prob.get_val("battery.electricity_out")[0:24] ) - with subtests.test("Check battery_electricity_discharge"): - assert ( - pytest.approx(expected_battery_electricity_discharge) - == prob.get_val("battery.battery_electricity_discharge")[0:24] - ) - - with subtests.test("Check SOC"): - assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] - - with subtests.test("Check unmet_demand"): - assert ( - pytest.approx(expected_unmet_demand_out, abs=1e-4) - == prob.get_val("battery.unmet_electricity_demand_out")[0:24] - ) - - with subtests.test("Check unused_electricity_out"): - assert ( - pytest.approx(expected_unused_commodity_out) - == prob.get_val("battery.unused_electricity_out")[0:24] - ) + # with subtests.test("Check battery_electricity_discharge"): + # assert ( + # pytest.approx(expected_battery_electricity_discharge) + # == prob.get_val("battery.battery_electricity_discharge")[0:24] + # ) + + # with subtests.test("Check SOC"): + # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] + + # with subtests.test("Check unmet_demand"): + # assert ( + # pytest.approx(expected_unmet_demand_out, abs=1e-4) + # == prob.get_val("battery.unmet_electricity_demand_out")[0:24] + # ) + + # with subtests.test("Check unused_electricity_out"): + # assert ( + # pytest.approx(expected_unused_commodity_out) + # == prob.get_val("battery.unused_electricity_out")[0:24] + # ) From c2604b61954ee1663bb75b7a43f4f2611ad33824 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Mon, 26 Jan 2026 14:03:49 -0500 Subject: [PATCH 33/37] Testing update - partial --- .../control/test/test_pyomo_controllers.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/h2integrate/control/test/test_pyomo_controllers.py b/h2integrate/control/test/test_pyomo_controllers.py index 75e92db8f..662ec1867 100644 --- a/h2integrate/control/test/test_pyomo_controllers.py +++ b/h2integrate/control/test/test_pyomo_controllers.py @@ -452,9 +452,27 @@ def test_optimized_load_following_battery_dispatch(subtests): }, }, } + tech_config["technologies"]["combiner"] = { + "performance_model": {"model": "combiner_performance"}, + "dispatch_rule_set": {"model": "pyomo_dispatch_generic_converter"}, + "model_inputs": { + "performance_parameters": { + "commodity": "electricity", + "commodity_units": "kW", + }, + "dispatch_rule_parameters": { + "commodity_name": "electricity", + "commodity_storage_units": "kW", + }, + }, + } # Can't find the electricity in because it's not in the tech to tech connections. plant_config["plant"]["simulation"]["n_timesteps"] = 72 + # plant_config["tech_to_dispatch_connections"] = [ + # ["combiner", "battery"], + # ["battery", "battery"], + # ], # Setup the OpenMDAO problem and add subsystems prob = om.Problem() @@ -487,6 +505,7 @@ def test_optimized_load_following_battery_dispatch(subtests): prob.setup() prob.set_val("battery.electricity_in", electricity_in) prob.set_val("battery.electricity_demand", demand_in) + prob.set_val("battery_optimized_dispatch_controller.source_techs", ["combiner", "battery"]) # Run the model prob.run_model() From 5f338472df7af3339b28ae07c79efdb8b97eab57 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Mon, 26 Jan 2026 18:29:02 -0500 Subject: [PATCH 34/37] Make new test for optimized pyomo dispatch --- .../control_strategies/pyomo_controllers.py | 4 +- ...llers.py => test_heuristic_controllers.py} | 228 ------- .../control/test/test_optimal_controllers.py | 637 ++++++++++++++++++ 3 files changed, 639 insertions(+), 230 deletions(-) rename h2integrate/control/test/{test_pyomo_controllers.py => test_heuristic_controllers.py} (63%) create mode 100644 h2integrate/control/test/test_optimal_controllers.py diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 33f2c18b4..97b9ab6ac 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -324,11 +324,12 @@ def pyomo_dispatch_solver( **performance_model_kwargs, sim_start_index=t, ) + self.updated_initial_soc = soc_control_window[-1] / 100 # turn into ratio # print("Storage commands: ", self.storage_dispatch_commands) # print("Battery performance: ", storage_commodity_out_control_window) # print("Battery SOC: ", soc_control_window) # print("power in: ", commodity_in) - self.updated_initial_soc = soc_control_window[-1] / 100 # turn into ratio + # if "optimized" in control_strategy: # jkjk @@ -948,7 +949,6 @@ def update_time_series_parameters( commodity_demand (list): The demanded commodity for this time slice. """ - print("HIIII", updated_initial_soc) self.hybrid_dispatch_rule.update_time_series_parameters( commodity_in, commodity_demand, updated_initial_soc ) diff --git a/h2integrate/control/test/test_pyomo_controllers.py b/h2integrate/control/test/test_heuristic_controllers.py similarity index 63% rename from h2integrate/control/test/test_pyomo_controllers.py rename to h2integrate/control/test/test_heuristic_controllers.py index 662ec1867..66915f9c1 100644 --- a/h2integrate/control/test/test_pyomo_controllers.py +++ b/h2integrate/control/test/test_heuristic_controllers.py @@ -4,7 +4,6 @@ from h2integrate.storage.battery.pysam_battery import PySAMBatteryPerformanceModel from h2integrate.control.control_strategies.pyomo_controllers import ( - OptimizedDispatchController, HeuristicLoadFollowingController, ) from h2integrate.control.control_rules.storage.pyomo_storage_rule_baseclass import ( @@ -400,230 +399,3 @@ def test_heuristic_load_following_battery_dispatch(subtests): pytest.approx(expected_unused_commodity_out, abs=abs_tol, rel=rel_tol) == prob.get_val("battery.unused_electricity_out")[:5] ) - - -# The previous subtests seem to cover basic battery dispatch behavior (max and min SOC) -# The following tests will address the optimized dispatch calculation - - -def test_optimized_load_following_battery_dispatch(subtests): - # Fabricate some oscillating power generation data: 0 kW for the first 12 hours, 10000 kW for - # the second twelve hours, and repeat that daily cycle over a year. - n_look_ahead_half = int(24 / 2) - - electricity_in = np.concatenate( - (np.ones(n_look_ahead_half) * 1000, np.ones(n_look_ahead_half) * 100000) - ) - electricity_in = np.tile(electricity_in, 3) - - demand_in = np.ones(72) * 6000.0 - - tech_config["technologies"]["battery"] = { - "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, - "control_strategy": {"model": "optimized_dispatch_controller"}, - "performance_model": {"model": "pysam_battery"}, - "model_inputs": { - "shared_parameters": { - "max_charge_rate": 50000, - "max_capacity": 200000, - "n_control_window": 24, - "n_horizon_window": 48, - "init_charge_percent": 0.5, - "max_charge_percent": 0.9, - "min_charge_percent": 0.1, - "commodity_name": "electricity", - "commodity_storage_units": "kW", - "time_weighting_factor": 0.995, - "charge_efficiency": 0.95, - "discharge_efficiency": 0.95, - "cost_per_charge": 0.04, - "cost_per_discharge": 0.05, - "cost_per_production": 0.0, - "commodity_met_value": 0.1, - }, - "performance_parameters": { - "system_model_source": "pysam", - "chemistry": "LFPGraphite", - "control_variable": "input_power", - }, - "control_parameters": { - "tech_name": "battery", - "system_commodity_interface_limit": 1e12, - }, - }, - } - tech_config["technologies"]["combiner"] = { - "performance_model": {"model": "combiner_performance"}, - "dispatch_rule_set": {"model": "pyomo_dispatch_generic_converter"}, - "model_inputs": { - "performance_parameters": { - "commodity": "electricity", - "commodity_units": "kW", - }, - "dispatch_rule_parameters": { - "commodity_name": "electricity", - "commodity_storage_units": "kW", - }, - }, - } - - # Can't find the electricity in because it's not in the tech to tech connections. - plant_config["plant"]["simulation"]["n_timesteps"] = 72 - # plant_config["tech_to_dispatch_connections"] = [ - # ["combiner", "battery"], - # ["battery", "battery"], - # ], - - # Setup the OpenMDAO problem and add subsystems - prob = om.Problem() - - prob.model.add_subsystem( - "pyomo_generic_storage", - PyomoRuleStorageBaseclass( - plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] - ), - promotes=["*"], - ) - - prob.model.add_subsystem( - "battery_optimized_dispatch_controller", - OptimizedDispatchController( - plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] - ), - promotes=["*"], - ) - - prob.model.add_subsystem( - "battery", - PySAMBatteryPerformanceModel( - plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] - ), - promotes=["*"], - ) - - # 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_optimized_dispatch_controller.source_techs", ["combiner", "battery"]) - - # Run the model - prob.run_model() - - # Test the case where the charging/discharging cycle remains within the max and min SOC limits - # Check the expected outputs to actual outputs - expected_electricity_out = [ - 5999.99995059, - 5990.56676743, - 5990.138959, - 5989.64831176, - 5989.08548217, - 5988.44193888, - 5987.70577962, - 5986.86071125, - 5985.88493352, - 5984.7496388, - 5983.41717191, - 5981.839478, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - 6000.0, - ] - - np.array( - [ - 4.93562475e-05, - 9.43323257e00, - 9.86104099e00, - 1.03516883e01, - 1.09145178e01, - 1.15580611e01, - 1.22942204e01, - 1.31392889e01, - 1.41150664e01, - 1.52503612e01, - 1.65828282e01, - 1.81605218e01, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - ] - ) - - np.array( - [ - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 11.37764445, - 10.76421514, - 10.23167373, - 9.73829458, - 9.28323883, - 8.86426912, - 8.47856327, - 8.12315078, - 7.79514283, - 7.49184426, - 7.21079852, - 6.94979705, - ] - ) - - print("Electricity out", prob.get_val("battery.electricity_out")) - print("Battery out", prob.get_val("battery.battery_electricity_discharge")) - print("unmet demand", prob.get_val("battery.unmet_electricity_demand_out")) - print("Unused electricity", prob.get_val("battery.unused_electricity_out")) - - with subtests.test("Check electricity_out"): - assert ( - pytest.approx(expected_electricity_out) == prob.get_val("battery.electricity_out")[0:24] - ) - - # with subtests.test("Check battery_electricity_discharge"): - # assert ( - # pytest.approx(expected_battery_electricity_discharge) - # == prob.get_val("battery.battery_electricity_discharge")[0:24] - # ) - - # with subtests.test("Check SOC"): - # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] - - # with subtests.test("Check unmet_demand"): - # assert ( - # pytest.approx(expected_unmet_demand_out, abs=1e-4) - # == prob.get_val("battery.unmet_electricity_demand_out")[0:24] - # ) - - # with subtests.test("Check unused_electricity_out"): - # assert ( - # pytest.approx(expected_unused_commodity_out) - # == prob.get_val("battery.unused_electricity_out")[0:24] - # ) diff --git a/h2integrate/control/test/test_optimal_controllers.py b/h2integrate/control/test/test_optimal_controllers.py new file mode 100644 index 000000000..059629766 --- /dev/null +++ b/h2integrate/control/test/test_optimal_controllers.py @@ -0,0 +1,637 @@ +import numpy as np +import pytest + +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +plant_config = { + "name": "plant_config", + "description": "...", + "site": {"latitude": 35.2018863, "longitude": -101.945027}, + "plant": { + "plant_life": 1, + "grid_connection": False, + "ppa_price": 0.025, + "hybrid_electricity_estimated_cf": 0.492, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, + }, + "technology_interconnections": [["combiner", "battery", "electricity", "cable"]], + "tech_to_dispatch_connections": [ + ["combiner", "battery"], + ["battery", "battery"], + ], +} + +driver_config = { + "name": "driver_config", + "description": "Pyomo optimal min operating cost test", + "general": {}, +} + +tech_config = { + "name": "technology_config", + "description": "...", + "technologies": { + "battery": { + "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, + "control_strategy": {"model": "optimized_dispatch_controller"}, + "performance_model": {"model": "pysam_battery"}, + "model_inputs": { + "shared_parameters": { + "max_charge_rate": 50000, + "max_capacity": 200000, + "n_control_window": 24, + "n_horizon_window": 48, + "init_charge_percent": 0.5, + "max_charge_percent": 0.9, + "min_charge_percent": 0.1, + "commodity_name": "electricity", + "commodity_storage_units": "kW", + "time_weighting_factor": 0.995, + "charge_efficiency": 0.95, + "discharge_efficiency": 0.95, + "cost_per_charge": 0.04, + "cost_per_discharge": 0.05, + "cost_per_production": 0.0, + "commodity_met_value": 0.1, + }, + "performance_parameters": { + "system_model_source": "pysam", + "chemistry": "LFPGraphite", + "control_variable": "input_power", + }, + "control_parameters": { + "tech_name": "battery", + "system_commodity_interface_limit": 1e12, + }, + }, + }, + "combiner": { + "performance_model": {"model": "combiner_performance"}, + "dispatch_rule_set": {"model": "pyomo_dispatch_generic_converter"}, + "model_inputs": { + "performance_parameters": { + "commodity": "electricity", + "commodity_units": "kW", + "in_streams": 1, + }, + "dispatch_rule_parameters": { + "commodity_name": "electricity", + "commodity_storage_units": "kW", + }, + }, + }, + }, +} + + +def test_min_operating_cost_load_following_battery_dispatch(subtests): + # Fabricate some oscillating power generation data: 0 kW for the first 12 hours, 10000 kW for + # the second twelve hours, and repeat that daily cycle over a year. + n_look_ahead_half = int(24 / 2) + + electricity_in = np.concatenate( + (np.ones(n_look_ahead_half) * 1000, np.ones(n_look_ahead_half) * 10000) + ) + electricity_in = np.tile(electricity_in, 365) + + demand_in = np.ones(8760) * 6000.0 + + # Create an H2Integrate model + model = H2IntegrateModel( + { + "driver_config": driver_config, + "technology_config": tech_config, + "plant_config": plant_config, + } + ) + + # prob.model.add_subsystem( + # "combiner", + # GenericCombinerPerformanceModel(plant_config=plant_config, + # tech_config=tech_config["technologies"]["combiner"], + # ), + # promotes=["*"] + # ) + + # prob.model.add_subsystem( + # "pyomo_generic_storage", + # PyomoRuleStorageBaseclass( + # plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] + # ), + # promotes=["*"], + # ) + + # prob.model.add_subsystem( + # "battery_optimized_dispatch_controller", + # OptimizedDispatchController( + # plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] + # ), + # promotes=["*"], + # ) + + # prob.model.add_subsystem( + # "battery", + # PySAMBatteryPerformanceModel( + # plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] + # ), + # promotes=["*"], + # ) + + # Setup the system and required values + model.setup() + model.prob.set_val("combiner.electricity_in1", electricity_in) + model.prob.set_val("battery.electricity_demand", demand_in) + + # Run the model + model.prob.run_model() + + # Test the case where the charging/discharging cycle remains within the max and min SOC limits + # Check the expected outputs to actual outputs + expected_electricity_out = [ + 5999.99995059, + 5990.56676743, + 5990.138959, + 5989.64831176, + 5989.08548217, + 5988.44193888, + 5987.70577962, + 5986.86071125, + 5985.88493352, + 5984.7496388, + 5983.41717191, + 5981.839478, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + 6000.0, + ] + + expected_battery_electricity_discharge = [ + 4999.99997732, + 4992.25494845, + 4991.96052468, + 4991.63342842, + 4991.26824325, + 4990.86174194, + 4990.40961477, + 4989.90607785, + 4989.34362595, + 4988.71271658, + 4988.00134229, + 4987.19448473, + -3990.28117686, + -3990.74350731, + -3991.15657455, + -3991.53821244, + -3991.89075932, + -3992.21669822, + -3992.51846124, + -3992.79833559, + -3993.05841677, + -3993.30060036, + -3993.52658465, + -3993.73788536, + ] + + np.array( + [ + 4.93562475e-05, + 9.43323257e00, + 9.86104099e00, + 1.03516883e01, + 1.09145178e01, + 1.15580611e01, + 1.22942204e01, + 1.31392889e01, + 1.41150664e01, + 1.52503612e01, + 1.65828282e01, + 1.81605218e01, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + ] + ) + + np.array( + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 11.37764445, + 10.76421514, + 10.23167373, + 9.73829458, + 9.28323883, + 8.86426912, + 8.47856327, + 8.12315078, + 7.79514283, + 7.49184426, + 7.21079852, + 6.94979705, + ] + ) + + with subtests.test("Check battery.electricity_out"): + assert ( + pytest.approx(expected_electricity_out) + == model.prob.get_val("battery.electricity_out")[0:24] + ) + + with subtests.test("Check battery_electricity_discharge"): + assert ( + pytest.approx(expected_battery_electricity_discharge) + == model.prob.get_val("battery.battery_electricity_discharge")[0:24] + ) + + # with subtests.test("Check SOC"): + # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] + + # with subtests.test("Check unmet_demand"): + # assert ( + # pytest.approx(expected_unmet_demand_out, abs=1e-4) + # == prob.get_val("battery.unmet_electricity_demand_out")[0:24] + # ) + + # with subtests.test("Check unused_electricity_out"): + # assert ( + # pytest.approx(expected_unused_commodity_out) + # == prob.get_val("battery.unused_electricity_out")[0:24] + # ) + + # # Test the case where the battery is discharged to its lower SOC limit + # electricity_in = np.zeros(8760) + # demand_in = np.ones(8760) * 30000 + + # # Setup the system and required values + # prob.setup() + # prob.set_val("battery.electricity_in", electricity_in) + # prob.set_val("battery.electricity_demand", demand_in) + + # # Run the model + # prob.run_model() + + # expected_electricity_out = np.array( + # [3.00000000e04, 2.99305601e04, 2.48145097e04, 4.97901621e00, 3.04065390e01] + # ) + # expected_battery_electricity_discharge = expected_electricity_out + # expected_SOC = np.array([37.69010284, 22.89921133, 10.00249593, 10.01524461, 10.03556385]) + # expected_unmet_demand_out = np.array( + # [ + # 9.43691703e-09, + # 6.94398578e01, + # 5.18549025244965e03, + # 2.999502098378662e04, + # 2.9969593461021406e04, + # ] + # ) + # expected_unused_commodity_out = np.zeros(5) + + # with subtests.test("Check electricity_out for min SOC"): + # assert ( + # pytest.approx(expected_electricity_out) == prob.get_val("battery.electricity_out")[:5] + # ) + + # with subtests.test("Check battery_electricity_discharge for min SOC"): + # assert ( + # pytest.approx(expected_battery_electricity_discharge) + # == prob.get_val("battery.battery_electricity_discharge")[:5] + # ) + + # with subtests.test("Check SOC for min SOC"): + # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[:5] + + # with subtests.test("Check unmet_demand for min SOC"): + # assert ( + # pytest.approx(expected_unmet_demand_out, abs=1e-6) + # == prob.get_val("battery.unmet_electricity_demand_out")[:5] + # ) + + # with subtests.test("Check unused_commodity_out for min SOC"): + # assert ( + # pytest.approx(expected_unused_commodity_out) + # == prob.get_val("battery.unused_electricity_out")[:5] + # ) + + # # Test the case where the battery is charged to its upper SOC limit + # electricity_in = np.ones(8760) * 30000.0 + # demand_in = np.zeros(8760) + + # # Setup the system and required values + # prob.setup() + # prob.set_val("battery.electricity_in", electricity_in) + # prob.set_val("battery.electricity_demand", demand_in) + + # # Run the model + # prob.run_model() + + # expected_electricity_out = [-0.008477085, 0.0, 0.0, 0.0, 0.0] + + # # TODO reevaluate the output here + # expected_battery_electricity_discharge = np.array( + # [-30000.00847709, -29973.58679719, -21109.22734423, 0.0, 0.0] + # ) + + # # expected_SOC = [66.00200558, 79.43840635, 90.0, 90.0, 90.0] + # expected_SOC = np.array([66.00200558, 79.43840635, 89.02326413, 89.02326413, 89.02326413]) + # expected_unmet_demand_out = np.array([0.00847709, 0.0, 0.0, 0.0, 0.0]) + # expected_unused_commodity_out = np.array( + # [0.00000000e00, 2.64132028e01, 8.89077266e03, 3.04088135e04, 3.00564087e04] + # ) + # # I think this is the right expected_electricity_out since the battery won't + # # be discharging in this instance + # # expected_electricity_out = [0.0, 0.0, 0.0, 0.0, 0.0] + # # # expected_electricity_out = [0.0, 0.0, 6150.14483911, 30000.0, 30000.0] + # # expected_battery_electricity_discharge = [-30000.00847705, -29973.58679681, + # # -23310.54620182, 0.0, 0.0] + # # expected_SOC = [66.00200558, 79.43840635, 90.0, 90.0, 90.0] + # # expected_unmet_demand_out = np.zeros(5) + # # expected_unused_commodity_out = [0.0, 0.0, 6150.14483911, 30000.0, 30000.0] + + # abs_tol = 1e-6 + # rel_tol = 1e-1 + # with subtests.test("Check electricity_out for max SOC"): + # assert ( + # pytest.approx(expected_electricity_out, abs=abs_tol, rel=rel_tol) + # == prob.get_val("battery.electricity_out")[:5] + # ) + + # with subtests.test("Check battery_electricity_discharge for max SOC"): + # assert ( + # pytest.approx(expected_battery_electricity_discharge, abs=abs_tol, rel=rel_tol) + # == prob.get_val("battery.battery_electricity_discharge")[:5] + # ) + + # with subtests.test("Check SOC for max SOC"): + # assert pytest.approx(expected_SOC, abs=abs_tol) == prob.get_val("battery.SOC")[:5] + + # with subtests.test("Check unmet_demand for max SOC"): + # assert ( + # pytest.approx(expected_unmet_demand_out, abs=abs_tol) + # == prob.get_val("battery.unmet_electricity_demand_out")[:5] + # ) + + # with subtests.test("Check unused_commodity_out for max SOC"): + # assert ( + # pytest.approx(expected_unused_commodity_out, abs=abs_tol, rel=rel_tol) + # == prob.get_val("battery.unused_electricity_out")[:5] + # ) + + +# The previous subtests seem to cover basic battery dispatch behavior (max and min SOC) +# The following tests will address the optimized dispatch calculation + + +# def test_optimized_load_following_battery_dispatch(subtests): +# # Fabricate some oscillating power generation data: 0 kW for the first 12 hours, 10000 kW for +# # the second twelve hours, and repeat that daily cycle over a year. +# n_look_ahead_half = int(24 / 2) + +# electricity_in = np.concatenate( +# (np.ones(n_look_ahead_half) * 1000, np.ones(n_look_ahead_half) * 100000) +# ) +# electricity_in = np.tile(electricity_in, 3) + +# demand_in = np.ones(72) * 6000.0 + +# tech_config["technologies"]["battery"] = { +# "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, +# "control_strategy": {"model": "optimized_dispatch_controller"}, +# "performance_model": {"model": "pysam_battery"}, +# "model_inputs": { +# "shared_parameters": { +# "max_charge_rate": 50000, +# "max_capacity": 200000, +# "n_control_window": 24, +# "n_horizon_window": 48, +# "init_charge_percent": 0.5, +# "max_charge_percent": 0.9, +# "min_charge_percent": 0.1, +# "commodity_name": "electricity", +# "commodity_storage_units": "kW", +# "time_weighting_factor": 0.995, +# "charge_efficiency": 0.95, +# "discharge_efficiency": 0.95, +# "cost_per_charge": 0.04, +# "cost_per_discharge": 0.05, +# "cost_per_production": 0.0, +# "commodity_met_value": 0.1, +# }, +# "performance_parameters": { +# "system_model_source": "pysam", +# "chemistry": "LFPGraphite", +# "control_variable": "input_power", +# }, +# "control_parameters": { +# "tech_name": "battery", +# "system_commodity_interface_limit": 1e12, +# }, +# }, +# } +# tech_config["technologies"]["combiner"] = { +# "performance_model": {"model": "combiner_performance"}, +# "dispatch_rule_set": {"model": "pyomo_dispatch_generic_converter"}, +# "model_inputs": { +# "performance_parameters": { +# "commodity": "electricity", +# "commodity_units": "kW", +# }, +# "dispatch_rule_parameters": { +# "commodity_name": "electricity", +# "commodity_storage_units": "kW", +# }, +# }, +# } + +# # Can't find the electricity in because it's not in the tech to tech connections. +# plant_config["plant"]["simulation"]["n_timesteps"] = 72 +# plant_config["tech_to_dispatch_connections"] = [ +# ["combiner", "battery"], +# ["battery", "battery"], +# ], + +# # Setup the OpenMDAO problem and add subsystems +# prob = om.Problem() + +# prob.model.add_subsystem( +# "pyomo_generic_storage", +# PyomoRuleStorageBaseclass( +# plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] +# ), +# promotes=["*"], +# ) + +# prob.model.add_subsystem( +# "battery_optimized_dispatch_controller", +# OptimizedDispatchController( +# plant_config=plant_config, tech_config=tech_config["technologies"] +# ), +# promotes=["*"], +# ) + +# prob.model.add_subsystem( +# "battery", +# PySAMBatteryPerformanceModel( +# plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] +# ), +# promotes=["*"], +# ) + +# # 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_optimized_dispatch_controller.source_techs", ["combiner", "battery"]) + +# # Run the model +# prob.run_model() + +# # Test the case where the charging/discharging cycle remains within the max and min SOC limits +# # Check the expected outputs to actual outputs +# expected_electricity_out = [ +# 5999.99995059, +# 5990.56676743, +# 5990.138959, +# 5989.64831176, +# 5989.08548217, +# 5988.44193888, +# 5987.70577962, +# 5986.86071125, +# 5985.88493352, +# 5984.7496388, +# 5983.41717191, +# 5981.839478, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# 6000.0, +# ] + +# np.array( +# [ +# 4.93562475e-05, +# 9.43323257e00, +# 9.86104099e00, +# 1.03516883e01, +# 1.09145178e01, +# 1.15580611e01, +# 1.22942204e01, +# 1.31392889e01, +# 1.41150664e01, +# 1.52503612e01, +# 1.65828282e01, +# 1.81605218e01, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# 0.00000000e00, +# ] +# ) + +# np.array( +# [ +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 0.0, +# 11.37764445, +# 10.76421514, +# 10.23167373, +# 9.73829458, +# 9.28323883, +# 8.86426912, +# 8.47856327, +# 8.12315078, +# 7.79514283, +# 7.49184426, +# 7.21079852, +# 6.94979705, +# ] +# ) + +# print("Electricity out", prob.get_val("battery.electricity_out")) +# print("Battery out", prob.get_val("battery.battery_electricity_discharge")) +# print("unmet demand", prob.get_val("battery.unmet_electricity_demand_out")) +# print("Unused electricity", prob.get_val("battery.unused_electricity_out")) + +# with subtests.test("Check electricity_out"): +# assert ( +# pytest.approx(expected_electricity_out) == +# prob.get_val("battery.electricity_out")[0:24] +# ) + +# # with subtests.test("Check battery_electricity_discharge"): +# # assert ( +# # pytest.approx(expected_battery_electricity_discharge) +# # == prob.get_val("battery.battery_electricity_discharge")[0:24] +# # ) + +# # with subtests.test("Check SOC"): +# # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] + +# # with subtests.test("Check unmet_demand"): +# # assert ( +# # pytest.approx(expected_unmet_demand_out, abs=1e-4) +# # == prob.get_val("battery.unmet_electricity_demand_out")[0:24] +# # ) + +# # with subtests.test("Check unused_electricity_out"): +# # assert ( +# # pytest.approx(expected_unused_commodity_out) +# # == prob.get_val("battery.unused_electricity_out")[0:24] +# # ) From 363bfbf97fab0a7241f90b31701c656f3959af9a Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 27 Jan 2026 12:50:52 -0500 Subject: [PATCH 35/37] Update optimal controller test --- .../run_pyomo_optimized_dispatch.py | 54 ++ .../tech_config.yaml | 2 +- .../pyomo_storage_rule_min_operating_cost.py | 3 +- .../control_strategies/pyomo_controllers.py | 13 +- .../control/test/test_optimal_controllers.py | 558 +++++------------- 5 files changed, 191 insertions(+), 439 deletions(-) diff --git a/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py b/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py index 263a508db..3c8397da0 100644 --- a/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py +++ b/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py @@ -76,3 +76,57 @@ plt.legend(ncol=2, frameon=False) plt.tight_layout() plt.savefig("plot.png", dpi=300) + +fig, ax = plt.subplots(1, 1, sharex=True) + +start_hour = 0 +end_hour = 200 + +ax.plot( + range(start_hour, end_hour), + model.prob.get_val("battery.SOC", units="percent")[start_hour:end_hour] / 100, + label="SOC", +) +ax.plot( + range(start_hour, end_hour), + model.prob.get_val("battery.battery_electricity_discharge", units="MW")[start_hour:end_hour] + / 100, + linestyle="-.", + label="Battery Electricity Out (MW)", +) +ax.set_ylabel("SOC (%)") +# ax.set_ylim([0, 110]) +ax.axhline(y=0.0, linestyle=":", color="k", alpha=0.5, label="Zero") +ax.legend() + +plt.legend(ncol=2, frameon=False) +plt.tight_layout() +plt.savefig("plot_battery_behavior-1.png", dpi=300) +print(model.prob.get_val("battery.SOC", units="percent")[8700:8760] / 100) + +fig, ax = plt.subplots(1, 1, sharex=True) + +start_hour = 8600 +end_hour = 8760 + +ax.plot( + range(start_hour, end_hour), + model.prob.get_val("battery.SOC", units="percent")[start_hour:end_hour] / 100, + label="SOC", +) +ax.plot( + range(start_hour, end_hour), + model.prob.get_val("battery.battery_electricity_discharge", units="MW")[start_hour:end_hour] + / 100, + linestyle="-.", + label="Battery Electricity Out (MW)", +) +ax.set_ylabel("SOC (%)") +# ax.set_ylim([0, 110]) +ax.axhline(y=0.0, linestyle=":", color="k", alpha=0.5, label="Zero") +ax.legend() + +plt.legend(ncol=2, frameon=False) +plt.tight_layout() +plt.savefig("plot_battery_behavior_end-1.png", dpi=300) +print(model.prob.get_val("battery.SOC", units="percent")[8700:8760] / 100) diff --git a/examples/27_pyomo_optimized_dispatch/tech_config.yaml b/examples/27_pyomo_optimized_dispatch/tech_config.yaml index 6c47db2c3..df82f4019 100644 --- a/examples/27_pyomo_optimized_dispatch/tech_config.yaml +++ b/examples/27_pyomo_optimized_dispatch/tech_config.yaml @@ -62,7 +62,7 @@ technologies: charge_efficiency: 0.95 discharge_efficiency: 0.95 commodity_storage_units: "kW" - cost_per_charge: 0.04 + cost_per_charge: 0.03 cost_per_discharge: 0.05 commodity_met_value: 0.1 cost_per_production: 0.0 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 23433cca0..3642f268a 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 @@ -50,7 +50,7 @@ def initialize_parameters( self.initial_soc = dispatch_inputs["initial_soc_percent"] self.charge_efficiency = dispatch_inputs.get("charge_efficiency", 0.94) - self.discharge_commodity_efficiency = dispatch_inputs.get("discharge_efficiency", 0.94) + self.discharge_efficiency = dispatch_inputs.get("discharge_efficiency", 0.94) # Set charge and discharge rate equal to each other for now self.set_timeseries_parameter("max_charge", dispatch_inputs["max_charge_rate"]) @@ -435,6 +435,7 @@ def update_time_series_parameters( self.time_duration = [1.0] * len(self.blocks.index_set()) self.commodity_load_demand = [commodity_demand[t] for t in self.blocks.index_set()] self.model.initial_soc = updated_initial_soc + self.initial_soc = updated_initial_soc # Objective functions def min_operating_cost_objective(self, hybrid_blocks, tech_name: str): diff --git a/h2integrate/control/control_strategies/pyomo_controllers.py b/h2integrate/control/control_strategies/pyomo_controllers.py index 97b9ab6ac..07e8e5d98 100644 --- a/h2integrate/control/control_strategies/pyomo_controllers.py +++ b/h2integrate/control/control_strategies/pyomo_controllers.py @@ -324,15 +324,9 @@ def pyomo_dispatch_solver( **performance_model_kwargs, sim_start_index=t, ) + # update SOC for next time window self.updated_initial_soc = soc_control_window[-1] / 100 # turn into ratio - # print("Storage commands: ", self.storage_dispatch_commands) - # print("Battery performance: ", storage_commodity_out_control_window) - # print("Battery SOC: ", soc_control_window) - # print("power in: ", commodity_in) - # if "optimized" in control_strategy: - - # jkjk # get a list of all time indices belonging to the current control window window_indices = list(range(t, t + self.config.n_control_window)) @@ -896,11 +890,6 @@ def setup(self): super().setup() - if self.config.charge_efficiency is not None: - self.charge_efficiency = self.config.charge_efficiency - if self.config.discharge_efficiency is not None: - self.discharge_efficiency = self.config.discharge_efficiency - self.n_control_window = self.config.n_control_window self.updated_initial_soc = self.config.init_charge_percent diff --git a/h2integrate/control/test/test_optimal_controllers.py b/h2integrate/control/test/test_optimal_controllers.py index 059629766..0d9bb1e73 100644 --- a/h2integrate/control/test/test_optimal_controllers.py +++ b/h2integrate/control/test/test_optimal_controllers.py @@ -53,8 +53,8 @@ "time_weighting_factor": 0.995, "charge_efficiency": 0.95, "discharge_efficiency": 0.95, - "cost_per_charge": 0.04, - "cost_per_discharge": 0.05, + "cost_per_charge": 0.004, + "cost_per_discharge": 0.005, "cost_per_production": 0.0, "commodity_met_value": 0.1, }, @@ -89,7 +89,7 @@ def test_min_operating_cost_load_following_battery_dispatch(subtests): - # Fabricate some oscillating power generation data: 0 kW for the first 12 hours, 10000 kW for + # Fabricate some oscillating power generation data: 1000 kW for the first 12 hours, 10000 kW for # the second twelve hours, and repeat that daily cycle over a year. n_look_ahead_half = int(24 / 2) @@ -109,38 +109,6 @@ def test_min_operating_cost_load_following_battery_dispatch(subtests): } ) - # prob.model.add_subsystem( - # "combiner", - # GenericCombinerPerformanceModel(plant_config=plant_config, - # tech_config=tech_config["technologies"]["combiner"], - # ), - # promotes=["*"] - # ) - - # prob.model.add_subsystem( - # "pyomo_generic_storage", - # PyomoRuleStorageBaseclass( - # plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] - # ), - # promotes=["*"], - # ) - - # prob.model.add_subsystem( - # "battery_optimized_dispatch_controller", - # OptimizedDispatchController( - # plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] - # ), - # promotes=["*"], - # ) - - # prob.model.add_subsystem( - # "battery", - # PySAMBatteryPerformanceModel( - # plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] - # ), - # promotes=["*"], - # ) - # Setup the system and required values model.setup() model.prob.set_val("combiner.electricity_in1", electricity_in) @@ -152,18 +120,18 @@ def test_min_operating_cost_load_following_battery_dispatch(subtests): # Test the case where the charging/discharging cycle remains within the max and min SOC limits # Check the expected outputs to actual outputs expected_electricity_out = [ - 5999.99995059, - 5990.56676743, - 5990.138959, - 5989.64831176, - 5989.08548217, - 5988.44193888, - 5987.70577962, - 5986.86071125, - 5985.88493352, - 5984.7496388, - 5983.41717191, - 5981.839478, + 5999.99997732, + 5992.25494845, + 5991.96052468, + 5991.63342842, + 5991.26824325, + 5990.86174194, + 5990.40961477, + 5989.90607785, + 5989.34362595, + 5988.71271658, + 5988.00134229, + 5987.19448473, 6000.0, 6000.0, 6000.0, @@ -205,20 +173,59 @@ def test_min_operating_cost_load_following_battery_dispatch(subtests): -3993.73788536, ] - np.array( + expected_battery_soc = [ + 49.87479765, + 47.50390223, + 45.12932011, + 42.75108798, + 40.3682277, + 37.9797332, + 35.58459541, + 33.18174418, + 30.76997461, + 28.34786178, + 25.91365422, + 23.4651282, + 25.4168656, + 27.36180226, + 29.29938411, + 31.23051435, + 33.15594392, + 35.07630244, + 36.99212221, + 38.90385706, + 40.81189705, + 42.71658006, + 44.61820098, + 46.517019, + 44.14026872, + 41.75708657, + 39.3692475, + 36.97562686, + 34.57512794, + 32.16658699, + 29.7486839, + 27.31984419, + 24.87811568, + 22.42099596, + 19.94517118, + 17.44609219, + ] + + expected_unmet_demand = np.array( [ - 4.93562475e-05, - 9.43323257e00, - 9.86104099e00, - 1.03516883e01, - 1.09145178e01, - 1.15580611e01, - 1.22942204e01, - 1.31392889e01, - 1.41150664e01, - 1.52503612e01, - 1.65828282e01, - 1.81605218e01, + 2.26821512e-05, + 7.74505155, + 8.03947532, + 8.36657158, + 8.73175675, + 9.13825806, + 9.59038523, + 1.00939222e01, + 1.06563740e01, + 1.12872834e01, + 1.19986577e01, + 1.28055153e01, 0.00000000e00, 0.00000000e00, 0.00000000e00, @@ -234,7 +241,7 @@ def test_min_operating_cost_load_following_battery_dispatch(subtests): ] ) - np.array( + expected_unused_electricity = np.array( [ 0.0, 0.0, @@ -248,18 +255,18 @@ def test_min_operating_cost_load_following_battery_dispatch(subtests): 0.0, 0.0, 0.0, - 11.37764445, - 10.76421514, - 10.23167373, - 9.73829458, - 9.28323883, - 8.86426912, - 8.47856327, - 8.12315078, - 7.79514283, - 7.49184426, - 7.21079852, - 6.94979705, + 9.71882314, + 9.25649269, + 8.84342545, + 8.46178756, + 8.10924068, + 7.78330178, + 7.48153876, + 7.20166441, + 6.94158323, + 6.69939964, + 6.47341535, + 6.26211464, ] ) @@ -275,363 +282,64 @@ def test_min_operating_cost_load_following_battery_dispatch(subtests): == model.prob.get_val("battery.battery_electricity_discharge")[0:24] ) - # with subtests.test("Check SOC"): - # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] - - # with subtests.test("Check unmet_demand"): - # assert ( - # pytest.approx(expected_unmet_demand_out, abs=1e-4) - # == prob.get_val("battery.unmet_electricity_demand_out")[0:24] - # ) - - # with subtests.test("Check unused_electricity_out"): - # assert ( - # pytest.approx(expected_unused_commodity_out) - # == prob.get_val("battery.unused_electricity_out")[0:24] - # ) - - # # Test the case where the battery is discharged to its lower SOC limit - # electricity_in = np.zeros(8760) - # demand_in = np.ones(8760) * 30000 - - # # Setup the system and required values - # prob.setup() - # prob.set_val("battery.electricity_in", electricity_in) - # prob.set_val("battery.electricity_demand", demand_in) - - # # Run the model - # prob.run_model() - - # expected_electricity_out = np.array( - # [3.00000000e04, 2.99305601e04, 2.48145097e04, 4.97901621e00, 3.04065390e01] - # ) - # expected_battery_electricity_discharge = expected_electricity_out - # expected_SOC = np.array([37.69010284, 22.89921133, 10.00249593, 10.01524461, 10.03556385]) - # expected_unmet_demand_out = np.array( - # [ - # 9.43691703e-09, - # 6.94398578e01, - # 5.18549025244965e03, - # 2.999502098378662e04, - # 2.9969593461021406e04, - # ] - # ) - # expected_unused_commodity_out = np.zeros(5) - - # with subtests.test("Check electricity_out for min SOC"): - # assert ( - # pytest.approx(expected_electricity_out) == prob.get_val("battery.electricity_out")[:5] - # ) - - # with subtests.test("Check battery_electricity_discharge for min SOC"): - # assert ( - # pytest.approx(expected_battery_electricity_discharge) - # == prob.get_val("battery.battery_electricity_discharge")[:5] - # ) - - # with subtests.test("Check SOC for min SOC"): - # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[:5] - - # with subtests.test("Check unmet_demand for min SOC"): - # assert ( - # pytest.approx(expected_unmet_demand_out, abs=1e-6) - # == prob.get_val("battery.unmet_electricity_demand_out")[:5] - # ) - - # with subtests.test("Check unused_commodity_out for min SOC"): - # assert ( - # pytest.approx(expected_unused_commodity_out) - # == prob.get_val("battery.unused_electricity_out")[:5] - # ) - - # # Test the case where the battery is charged to its upper SOC limit - # electricity_in = np.ones(8760) * 30000.0 - # demand_in = np.zeros(8760) - - # # Setup the system and required values - # prob.setup() - # prob.set_val("battery.electricity_in", electricity_in) - # prob.set_val("battery.electricity_demand", demand_in) - - # # Run the model - # prob.run_model() - - # expected_electricity_out = [-0.008477085, 0.0, 0.0, 0.0, 0.0] - - # # TODO reevaluate the output here - # expected_battery_electricity_discharge = np.array( - # [-30000.00847709, -29973.58679719, -21109.22734423, 0.0, 0.0] - # ) - - # # expected_SOC = [66.00200558, 79.43840635, 90.0, 90.0, 90.0] - # expected_SOC = np.array([66.00200558, 79.43840635, 89.02326413, 89.02326413, 89.02326413]) - # expected_unmet_demand_out = np.array([0.00847709, 0.0, 0.0, 0.0, 0.0]) - # expected_unused_commodity_out = np.array( - # [0.00000000e00, 2.64132028e01, 8.89077266e03, 3.04088135e04, 3.00564087e04] - # ) - # # I think this is the right expected_electricity_out since the battery won't - # # be discharging in this instance - # # expected_electricity_out = [0.0, 0.0, 0.0, 0.0, 0.0] - # # # expected_electricity_out = [0.0, 0.0, 6150.14483911, 30000.0, 30000.0] - # # expected_battery_electricity_discharge = [-30000.00847705, -29973.58679681, - # # -23310.54620182, 0.0, 0.0] - # # expected_SOC = [66.00200558, 79.43840635, 90.0, 90.0, 90.0] - # # expected_unmet_demand_out = np.zeros(5) - # # expected_unused_commodity_out = [0.0, 0.0, 6150.14483911, 30000.0, 30000.0] - - # abs_tol = 1e-6 - # rel_tol = 1e-1 - # with subtests.test("Check electricity_out for max SOC"): - # assert ( - # pytest.approx(expected_electricity_out, abs=abs_tol, rel=rel_tol) - # == prob.get_val("battery.electricity_out")[:5] - # ) - - # with subtests.test("Check battery_electricity_discharge for max SOC"): - # assert ( - # pytest.approx(expected_battery_electricity_discharge, abs=abs_tol, rel=rel_tol) - # == prob.get_val("battery.battery_electricity_discharge")[:5] - # ) - - # with subtests.test("Check SOC for max SOC"): - # assert pytest.approx(expected_SOC, abs=abs_tol) == prob.get_val("battery.SOC")[:5] - - # with subtests.test("Check unmet_demand for max SOC"): - # assert ( - # pytest.approx(expected_unmet_demand_out, abs=abs_tol) - # == prob.get_val("battery.unmet_electricity_demand_out")[:5] - # ) - - # with subtests.test("Check unused_commodity_out for max SOC"): - # assert ( - # pytest.approx(expected_unused_commodity_out, abs=abs_tol, rel=rel_tol) - # == prob.get_val("battery.unused_electricity_out")[:5] - # ) - - -# The previous subtests seem to cover basic battery dispatch behavior (max and min SOC) -# The following tests will address the optimized dispatch calculation - - -# def test_optimized_load_following_battery_dispatch(subtests): -# # Fabricate some oscillating power generation data: 0 kW for the first 12 hours, 10000 kW for -# # the second twelve hours, and repeat that daily cycle over a year. -# n_look_ahead_half = int(24 / 2) - -# electricity_in = np.concatenate( -# (np.ones(n_look_ahead_half) * 1000, np.ones(n_look_ahead_half) * 100000) -# ) -# electricity_in = np.tile(electricity_in, 3) + # Check a longer portion of SOC to make sure SOC is getting linked between optimization periods + with subtests.test("Check SOC"): + assert pytest.approx(expected_battery_soc) == model.prob.get_val("battery.SOC")[0:36] -# demand_in = np.ones(72) * 6000.0 - -# tech_config["technologies"]["battery"] = { -# "dispatch_rule_set": {"model": "pyomo_dispatch_generic_storage"}, -# "control_strategy": {"model": "optimized_dispatch_controller"}, -# "performance_model": {"model": "pysam_battery"}, -# "model_inputs": { -# "shared_parameters": { -# "max_charge_rate": 50000, -# "max_capacity": 200000, -# "n_control_window": 24, -# "n_horizon_window": 48, -# "init_charge_percent": 0.5, -# "max_charge_percent": 0.9, -# "min_charge_percent": 0.1, -# "commodity_name": "electricity", -# "commodity_storage_units": "kW", -# "time_weighting_factor": 0.995, -# "charge_efficiency": 0.95, -# "discharge_efficiency": 0.95, -# "cost_per_charge": 0.04, -# "cost_per_discharge": 0.05, -# "cost_per_production": 0.0, -# "commodity_met_value": 0.1, -# }, -# "performance_parameters": { -# "system_model_source": "pysam", -# "chemistry": "LFPGraphite", -# "control_variable": "input_power", -# }, -# "control_parameters": { -# "tech_name": "battery", -# "system_commodity_interface_limit": 1e12, -# }, -# }, -# } -# tech_config["technologies"]["combiner"] = { -# "performance_model": {"model": "combiner_performance"}, -# "dispatch_rule_set": {"model": "pyomo_dispatch_generic_converter"}, -# "model_inputs": { -# "performance_parameters": { -# "commodity": "electricity", -# "commodity_units": "kW", -# }, -# "dispatch_rule_parameters": { -# "commodity_name": "electricity", -# "commodity_storage_units": "kW", -# }, -# }, -# } - -# # Can't find the electricity in because it's not in the tech to tech connections. -# plant_config["plant"]["simulation"]["n_timesteps"] = 72 -# plant_config["tech_to_dispatch_connections"] = [ -# ["combiner", "battery"], -# ["battery", "battery"], -# ], - -# # Setup the OpenMDAO problem and add subsystems -# prob = om.Problem() - -# prob.model.add_subsystem( -# "pyomo_generic_storage", -# PyomoRuleStorageBaseclass( -# plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] -# ), -# promotes=["*"], -# ) - -# prob.model.add_subsystem( -# "battery_optimized_dispatch_controller", -# OptimizedDispatchController( -# plant_config=plant_config, tech_config=tech_config["technologies"] -# ), -# promotes=["*"], -# ) - -# prob.model.add_subsystem( -# "battery", -# PySAMBatteryPerformanceModel( -# plant_config=plant_config, tech_config=tech_config["technologies"]["battery"] -# ), -# promotes=["*"], -# ) - -# # 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_optimized_dispatch_controller.source_techs", ["combiner", "battery"]) - -# # Run the model -# prob.run_model() - -# # Test the case where the charging/discharging cycle remains within the max and min SOC limits -# # Check the expected outputs to actual outputs -# expected_electricity_out = [ -# 5999.99995059, -# 5990.56676743, -# 5990.138959, -# 5989.64831176, -# 5989.08548217, -# 5988.44193888, -# 5987.70577962, -# 5986.86071125, -# 5985.88493352, -# 5984.7496388, -# 5983.41717191, -# 5981.839478, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# 6000.0, -# ] - -# np.array( -# [ -# 4.93562475e-05, -# 9.43323257e00, -# 9.86104099e00, -# 1.03516883e01, -# 1.09145178e01, -# 1.15580611e01, -# 1.22942204e01, -# 1.31392889e01, -# 1.41150664e01, -# 1.52503612e01, -# 1.65828282e01, -# 1.81605218e01, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# 0.00000000e00, -# ] -# ) + with subtests.test("Check unmet_demand"): + assert ( + pytest.approx(expected_unmet_demand, abs=1e-4) + == model.prob.get_val("battery.unmet_electricity_demand_out")[0:24] + ) -# np.array( -# [ -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 0.0, -# 11.37764445, -# 10.76421514, -# 10.23167373, -# 9.73829458, -# 9.28323883, -# 8.86426912, -# 8.47856327, -# 8.12315078, -# 7.79514283, -# 7.49184426, -# 7.21079852, -# 6.94979705, -# ] -# ) + with subtests.test("Check unused_electricity_out"): + assert ( + pytest.approx(expected_unused_electricity) + == model.prob.get_val("battery.unused_electricity_out")[0:24] + ) -# print("Electricity out", prob.get_val("battery.electricity_out")) -# print("Battery out", prob.get_val("battery.battery_electricity_discharge")) -# print("unmet demand", prob.get_val("battery.unmet_electricity_demand_out")) -# print("Unused electricity", prob.get_val("battery.unused_electricity_out")) + # Test the case where the battery efficiency is lower + tech_config["technologies"]["battery"]["model_inputs"]["shared_parameters"][ + "charge_efficiency" + ] = 0.632 + tech_config["technologies"]["battery"]["model_inputs"]["shared_parameters"][ + "discharge_efficiency" + ] = 0.632 -# with subtests.test("Check electricity_out"): -# assert ( -# pytest.approx(expected_electricity_out) == -# prob.get_val("battery.electricity_out")[0:24] -# ) + model = H2IntegrateModel( + { + "driver_config": driver_config, + "technology_config": tech_config, + "plant_config": plant_config, + } + ) -# # with subtests.test("Check battery_electricity_discharge"): -# # assert ( -# # pytest.approx(expected_battery_electricity_discharge) -# # == prob.get_val("battery.battery_electricity_discharge")[0:24] -# # ) + # Setup the system and required values + model.setup() + model.prob.set_val("combiner.electricity_in1", electricity_in) + model.prob.set_val("battery.electricity_demand", demand_in) -# # with subtests.test("Check SOC"): -# # assert pytest.approx(expected_SOC) == prob.get_val("battery.SOC")[0:24] + # Run the model + model.prob.run_model() -# # with subtests.test("Check unmet_demand"): -# # assert ( -# # pytest.approx(expected_unmet_demand_out, abs=1e-4) -# # == prob.get_val("battery.unmet_electricity_demand_out")[0:24] -# # ) + expected_electricity_out = [ + 5999.99997732, + 5992.25494845, + 5991.96052468, + 5991.63342842, + 5991.26824325, + 5990.86174194, + 5990.40961477, + 5989.90607785, + 5989.34362595, + 5988.71271658, + 1558.72773849, + 1000.0, + ] -# # with subtests.test("Check unused_electricity_out"): -# # assert ( -# # pytest.approx(expected_unused_commodity_out) -# # == prob.get_val("battery.unused_electricity_out")[0:24] -# # ) + # Make sure output changes if efficiency is changed + with subtests.test("Check electricity_out for different efficiency"): + assert ( + pytest.approx(expected_electricity_out) + == model.prob.get_val("battery.electricity_out")[:12] + ) From f42f935088790dffabbefbe5505fcd45098832ce Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 27 Jan 2026 13:43:23 -0500 Subject: [PATCH 36/37] Update test with new site definition --- h2integrate/control/test/test_optimal_controllers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/h2integrate/control/test/test_optimal_controllers.py b/h2integrate/control/test/test_optimal_controllers.py index 0d9bb1e73..76b2b9e04 100644 --- a/h2integrate/control/test/test_optimal_controllers.py +++ b/h2integrate/control/test/test_optimal_controllers.py @@ -7,7 +7,9 @@ plant_config = { "name": "plant_config", "description": "...", - "site": {"latitude": 35.2018863, "longitude": -101.945027}, + "sites": { + "site": {"latitude": 35.2018863, "longitude": -101.945027}, + }, "plant": { "plant_life": 1, "grid_connection": False, From db32b3a8b6121df5b638ea241076dea8cca22bf5 Mon Sep 17 00:00:00 2001 From: Genevieve Starke Date: Tue, 27 Jan 2026 13:48:26 -0500 Subject: [PATCH 37/37] Update example for merging in develop --- .../plant_config.yaml | 19 ++++++++++--------- .../run_pyomo_optimized_dispatch.py | 6 ++---- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/examples/27_pyomo_optimized_dispatch/plant_config.yaml b/examples/27_pyomo_optimized_dispatch/plant_config.yaml index b7c2897f9..24bf4db59 100644 --- a/examples/27_pyomo_optimized_dispatch/plant_config.yaml +++ b/examples/27_pyomo_optimized_dispatch/plant_config.yaml @@ -1,15 +1,16 @@ name: "plant_config" description: "This plant is located in TX, USA..." -site: - latitude: 35.2018863 - longitude: -101.945027 +sites: + site: + latitude: 35.2018863 + longitude: -101.945027 - resources: - wind_resource: - resource_model: "wind_toolkit_v2_api" - resource_parameters: - resource_year: 2012 + resources: + wind_resource: + resource_model: "wind_toolkit_v2_api" + resource_parameters: + resource_year: 2012 plant: plant_life: 30 @@ -32,7 +33,7 @@ tech_to_dispatch_connections: [ resource_to_tech_connections: [ # connect the wind resource to the wind technology - ['wind_resource', 'wind', 'wind_resource_data'], + ['site.wind_resource', 'wind', 'wind_resource_data'], ] finance_parameters: diff --git a/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py b/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py index 3c8397da0..9ff397ba9 100644 --- a/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py +++ b/examples/27_pyomo_optimized_dispatch/run_pyomo_optimized_dispatch.py @@ -101,8 +101,7 @@ plt.legend(ncol=2, frameon=False) plt.tight_layout() -plt.savefig("plot_battery_behavior-1.png", dpi=300) -print(model.prob.get_val("battery.SOC", units="percent")[8700:8760] / 100) +plt.savefig("plot_battery_behavior.png", dpi=300) fig, ax = plt.subplots(1, 1, sharex=True) @@ -128,5 +127,4 @@ plt.legend(ncol=2, frameon=False) plt.tight_layout() -plt.savefig("plot_battery_behavior_end-1.png", dpi=300) -print(model.prob.get_val("battery.SOC", units="percent")[8700:8760] / 100) +plt.savefig("plot_battery_behavior_end.png", dpi=300)