From 9675afa6dfc52a197441ca7bc8aa0ac53f71687c Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Tue, 5 Aug 2025 17:54:06 -0400 Subject: [PATCH 01/19] add multiple startup types --- .../pricetaker/unit_commitment.py | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 605ac26699..bc03ef2ab2 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -17,7 +17,7 @@ capacity limit constraints, and ramping constraints. """ -from typing import Union +from typing import Union, Optional from pyomo.common.config import ConfigDict, ConfigValue from pyomo.environ import Block, Constraint, Var, RangeSet from idaes.core.util.config import ConfigurationError, is_in_range @@ -141,10 +141,28 @@ def startup_shutdown_constraints( minimum_up_time: int, minimum_down_time: int, set_time: RangeSet, + startup_transition_time: Optional[dict], ): """ Appends startup and shutdown constraints for a given unit/process + + Supports multiples types of startup. + + Args: + startup_transition_time (dict): A dictionary with keys as startup types and values are the time of startup transition. """ + # Default one startup type. If provided + if startup_transition_time is not None: + # at least two types of startup, assume the default one should be at least min_down_time + startup_types = {"default": minimum_down_time} + for key in startup_transition_time.keys(): + startup_types[key] = startup_transition_time[key] + + startup_names = list(startup_types.keys()) + + # define the startup type as a binary variable + blk.startup_type = pyo.Var(set_time, startup_names, within=pyo.Binary) + blk.startup_duration = pyo.Param(startup_names, initialize=startup_types) @blk.Constraint(set_time) def binary_relationship_con(_, t): @@ -176,6 +194,44 @@ def minimum_down_time_con(_, t): <= install_unit - op_blocks[t].op_mode ) + @blk.Constraint(set_time) + def startup_type_rule(_, t): + ''' + Eq 55 in Ben's paper + ''' + if startup_transition_time is None: + return Constraint.Skip + + return ( + sum(blk.startup_type[t, k] for k in startup_names) == op_blocks[t].startup + ) + + if startup_transition_time is not None: + # add the startup type constraints for each type of startup + for key in startup_types.keys(): + if key == "default": + prev_key = "default" + continue + + def startup_type_rule(_, t, key=key): + ''' + Eq 54 in Ben's paper + ''' + if t < blk.startup_duration[key]: + return Constraint.Skip + return ( + blk.startup_type[t, key] <= sum( + op_blocks[t - i].shutdown for i in range( + blk.startup_duration[prev_key], blk.startup_duration[key] + ) + ) + ) + + setattr(blk, + f"startup_type_{key}", + Constraint(rule=startup_type_rule) + ) + def capacity_limits( blk: Block, From 646ef8c3a43c9960091901d11071c1f2f06762d9 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Wed, 6 Aug 2025 15:59:27 -0400 Subject: [PATCH 02/19] complete multistage startup --- .../pricetaker/price_taker_model.py | 1 + .../pricetaker/unit_commitment.py | 25 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index fb08f84f41..b73f54d9d8 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -684,6 +684,7 @@ def add_startup_shutdown( minimum_up_time=minimum_up_time, minimum_down_time=minimum_down_time, set_time=self.set_time, + startup_transition_time={"type1": 6, "type2": 10}, ) # Save the uptime and downtime data for reference diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index bc03ef2ab2..1c1fc53218 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -19,7 +19,7 @@ from typing import Union, Optional from pyomo.common.config import ConfigDict, ConfigValue -from pyomo.environ import Block, Constraint, Var, RangeSet +from pyomo.environ import Block, Constraint, Var, Param, RangeSet, Binary from idaes.core.util.config import ConfigurationError, is_in_range @@ -154,15 +154,15 @@ def startup_shutdown_constraints( # Default one startup type. If provided if startup_transition_time is not None: # at least two types of startup, assume the default one should be at least min_down_time - startup_types = {"default": minimum_down_time} + startup_type_dict = {"default": minimum_down_time} for key in startup_transition_time.keys(): - startup_types[key] = startup_transition_time[key] - - startup_names = list(startup_types.keys()) + startup_type_dict[key] = startup_transition_time[key] + + startup_names = list(startup_type_dict.keys()) # define the startup type as a binary variable - blk.startup_type = pyo.Var(set_time, startup_names, within=pyo.Binary) - blk.startup_duration = pyo.Param(startup_names, initialize=startup_types) + blk.startup_type = Var(set_time, startup_names, within=Binary) + blk.startup_duration = Param(startup_names, initialize=startup_type_dict) @blk.Constraint(set_time) def binary_relationship_con(_, t): @@ -208,12 +208,12 @@ def startup_type_rule(_, t): if startup_transition_time is not None: # add the startup type constraints for each type of startup - for key in startup_types.keys(): + for key in startup_type_dict.keys(): if key == "default": - prev_key = "default" + prev_key = key continue - def startup_type_rule(_, t, key=key): + def startup_type_rule(_, t, key=key, prev_key=prev_key): ''' Eq 54 in Ben's paper ''' @@ -228,9 +228,10 @@ def startup_type_rule(_, t, key=key): ) setattr(blk, - f"startup_type_{key}", - Constraint(rule=startup_type_rule) + f"Startup_Type_Constraint_{key}", + Constraint(set_time, rule=startup_type_rule) ) + prev_key = key def capacity_limits( From 03da2abb85aa55e4082f98ff0ab0239fa7b29c30 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Wed, 6 Aug 2025 16:33:24 -0400 Subject: [PATCH 03/19] update startup_type variables to the op_blocks --- .../grid_integration/pricetaker/price_taker_model.py | 4 +++- .../grid_integration/pricetaker/unit_commitment.py | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index b73f54d9d8..73032a1ad5 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -676,6 +676,8 @@ def add_startup_shutdown( start_shut_blk = getattr(self, start_shut_blk_name) # pylint: disable=not-an-iterable + startup_transition_time = None + startup_transition_time = {"type1": 6, "type2": 10} for d in self.set_days: startup_shutdown_constraints( blk=start_shut_blk[d], @@ -684,7 +686,7 @@ def add_startup_shutdown( minimum_up_time=minimum_up_time, minimum_down_time=minimum_down_time, set_time=self.set_time, - startup_transition_time={"type1": 6, "type2": 10}, + startup_transition_time=startup_transition_time, ) # Save the uptime and downtime data for reference diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 1c1fc53218..f7b26729c0 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -159,9 +159,11 @@ def startup_shutdown_constraints( startup_type_dict[key] = startup_transition_time[key] startup_names = list(startup_type_dict.keys()) + + # define the startup type as a binary variable (in the op_block) + for t in set_time: + setattr(op_blocks[t], "startup_type", Var(startup_names, within=Binary)) - # define the startup type as a binary variable - blk.startup_type = Var(set_time, startup_names, within=Binary) blk.startup_duration = Param(startup_names, initialize=startup_type_dict) @blk.Constraint(set_time) @@ -203,7 +205,7 @@ def startup_type_rule(_, t): return Constraint.Skip return ( - sum(blk.startup_type[t, k] for k in startup_names) == op_blocks[t].startup + sum(op_blocks[t].startup_type[k] for k in startup_names) == op_blocks[t].startup ) if startup_transition_time is not None: @@ -220,7 +222,7 @@ def startup_type_rule(_, t, key=key, prev_key=prev_key): if t < blk.startup_duration[key]: return Constraint.Skip return ( - blk.startup_type[t, key] <= sum( + op_blocks[t].startup_type[key] <= sum( op_blocks[t - i].shutdown for i in range( blk.startup_duration[prev_key], blk.startup_duration[key] ) From 4b88d2db1db376537348dd01d373f7c5a2a1dd88 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Fri, 8 Aug 2025 17:05:05 -0400 Subject: [PATCH 04/19] update --- .../grid_integration/pricetaker/price_taker_model.py | 8 +++++++- .../apps/grid_integration/pricetaker/unit_commitment.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index 73032a1ad5..0337ca324f 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -622,6 +622,7 @@ def add_startup_shutdown( des_block_name: Optional[str] = None, minimum_up_time: int = 1, minimum_down_time: int = 1, + startup_transition_time: Optional[dict] = None, ): """ Adds minimum uptime/downtime constraints for a given unit/process @@ -675,9 +676,14 @@ def add_startup_shutdown( setattr(self, start_shut_blk_name, Block(self.set_days)) start_shut_blk = getattr(self, start_shut_blk_name) - # pylint: disable=not-an-iterable + # startup_transition_time = None startup_transition_time = {"type1": 6, "type2": 10} + + if startup_transition_time is not None: + self._multiple_startup_types = True + + # pylint: disable=not-an-iterable for d in self.set_days: startup_shutdown_constraints( blk=start_shut_blk[d], diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index f7b26729c0..075fb0165c 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -235,6 +235,15 @@ def startup_type_rule(_, t, key=key, prev_key=prev_key): ) prev_key = key + # @op_blocks.Expression(set_time) + # def multiple_startup_type_cost(_, t): + # """ + # Calculate the startup cost based on the startup type. + # """ + # return sum( + # op_blocks[t].startup_type[k] * blk.startup_costs[k] + # for k in startup_names + # ) def capacity_limits( blk: Block, From 09107e373827eb3e7ad860c47f7b401c0165f3a5 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Thu, 14 Aug 2025 12:43:24 -0400 Subject: [PATCH 05/19] fix variable name bug --- idaes/apps/grid_integration/pricetaker/unit_commitment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 075fb0165c..dd4a54d4de 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -197,7 +197,7 @@ def minimum_down_time_con(_, t): ) @blk.Constraint(set_time) - def startup_type_rule(_, t): + def tot_startup_type_rule(_, t): ''' Eq 55 in Ben's paper ''' @@ -222,7 +222,7 @@ def startup_type_rule(_, t, key=key, prev_key=prev_key): if t < blk.startup_duration[key]: return Constraint.Skip return ( - op_blocks[t].startup_type[key] <= sum( + op_blocks[t].startup_type[prev_key] <= sum( op_blocks[t - i].shutdown for i in range( blk.startup_duration[prev_key], blk.startup_duration[key] ) From 94006ff828011799813b6a2d2fa618f24cd52731 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Thu, 14 Aug 2025 15:34:03 -0400 Subject: [PATCH 06/19] update the transition time format --- .../pricetaker/design_and_operation_models.py | 15 +++ .../pricetaker/price_taker_model.py | 13 ++- .../pricetaker/unit_commitment.py | 97 +++++++++---------- 3 files changed, 69 insertions(+), 56 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index 1d015f379b..9d4d8d33c1 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -185,6 +185,14 @@ def my_operation_model(m, design_blk): ), ) + CONFIG.declare( + "startup_types", + ConfigValue( + domain=dict, + doc="Dictionary of startup types for the unit/process", + ) + ) + # noinspection PyAttributeOutsideInit def build(self): super().build() @@ -206,6 +214,13 @@ def build(self): doc="Binary: 1 if the shutdown is initiated, 0 otherwise", ) + if self.config.startup_types is not None: + self.startup_type_vars = Var( + list(self.config.startup_types.keys()), + within=Binary, + doc="Binary: 1 if the startup type is active, 0 otherwise", + ) + if self.config.declare_lmp_param: self.LMP = Param( initialize=1, diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index 0337ca324f..0014dfe249 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -150,6 +150,13 @@ ), ) +CONFIG.declare( + "startup_types", + ConfigValue( + domain=dict, + doc="Dictionary of startup types and their transition times for the unit/process", + ) +) # pylint: disable = too-many-ancestors, too-many-instance-attributes class PriceTakerModel(ConcreteModel): @@ -649,6 +656,8 @@ def add_startup_shutdown( # Check minimum_up_time and minimum_down_time for validity self.config.minimum_up_time = minimum_up_time self.config.minimum_down_time = minimum_down_time + # Set startup transition times + self.config.startup_types = startup_transition_time op_blocks = self._get_operation_blocks( blk_name=op_block_name, @@ -676,10 +685,6 @@ def add_startup_shutdown( setattr(self, start_shut_blk_name, Block(self.set_days)) start_shut_blk = getattr(self, start_shut_blk_name) - # - startup_transition_time = None - startup_transition_time = {"type1": 6, "type2": 10} - if startup_transition_time is not None: self._multiple_startup_types = True diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index dd4a54d4de..8c717785fc 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -151,21 +151,6 @@ def startup_shutdown_constraints( Args: startup_transition_time (dict): A dictionary with keys as startup types and values are the time of startup transition. """ - # Default one startup type. If provided - if startup_transition_time is not None: - # at least two types of startup, assume the default one should be at least min_down_time - startup_type_dict = {"default": minimum_down_time} - for key in startup_transition_time.keys(): - startup_type_dict[key] = startup_transition_time[key] - - startup_names = list(startup_type_dict.keys()) - - # define the startup type as a binary variable (in the op_block) - for t in set_time: - setattr(op_blocks[t], "startup_type", Var(startup_names, within=Binary)) - - blk.startup_duration = Param(startup_names, initialize=startup_type_dict) - @blk.Constraint(set_time) def binary_relationship_con(_, t): if t == 1: @@ -195,55 +180,63 @@ def minimum_down_time_con(_, t): sum(op_blocks[i].shutdown for i in range(t - minimum_down_time + 1, t + 1)) <= install_unit - op_blocks[t].op_mode ) + + if startup_transition_time is None: + # if there is only one startup type, return + return + + # multiple startup types + if startup_transition_time is not None: + # at least two types of startup + startup_names = list(startup_transition_time.keys()) + # assume the first should be max(min_down_time, startup_transition_time["default"]) + startup_transition_time[startup_names[0]] = max(minimum_down_time, startup_transition_time[startup_names[0]]) + # add a check to ensure the startup time is monotonically increasing + for i in range(1, len(startup_names)): + startup_transition_time[startup_names[i]] = max( + startup_transition_time[startup_names[i]], + startup_transition_time[startup_names[i - 1]] + ) + + blk.startup_duration = Param(startup_names, initialize=startup_transition_time) @blk.Constraint(set_time) def tot_startup_type_rule(_, t): ''' Eq 55 in Ben's paper ''' - if startup_transition_time is None: - return Constraint.Skip - + return ( - sum(op_blocks[t].startup_type[k] for k in startup_names) == op_blocks[t].startup + sum(op_blocks[t].startup_type_vars[k] for k in startup_names) == op_blocks[t].startup ) - if startup_transition_time is not None: - # add the startup type constraints for each type of startup - for key in startup_type_dict.keys(): - if key == "default": - prev_key = key - continue - - def startup_type_rule(_, t, key=key, prev_key=prev_key): - ''' - Eq 54 in Ben's paper - ''' - if t < blk.startup_duration[key]: - return Constraint.Skip - return ( - op_blocks[t].startup_type[prev_key] <= sum( - op_blocks[t - i].shutdown for i in range( - blk.startup_duration[prev_key], blk.startup_duration[key] - ) + + # add the startup type constraints for each type of startup + for idx, key in enumerate(startup_names): + if idx == 0: + prev_key = key + continue + + def startup_type_rule(_, t, key=key, prev_key=prev_key): + ''' + Eq 54 in Ben's paper + ''' + if t < blk.startup_duration[key]: + return Constraint.Skip + return ( + op_blocks[t].startup_type_vars[prev_key] <= sum( + op_blocks[t - i].shutdown for i in range( + blk.startup_duration[prev_key], blk.startup_duration[key] ) ) - - setattr(blk, - f"Startup_Type_Constraint_{key}", - Constraint(set_time, rule=startup_type_rule) ) - prev_key = key - - # @op_blocks.Expression(set_time) - # def multiple_startup_type_cost(_, t): - # """ - # Calculate the startup cost based on the startup type. - # """ - # return sum( - # op_blocks[t].startup_type[k] * blk.startup_costs[k] - # for k in startup_names - # ) + + setattr(blk, + f"Startup_Type_Constraint_{key}", + Constraint(set_time, rule=startup_type_rule) + ) + prev_key = key + def capacity_limits( blk: Block, From 67f307f261d9cd6ad0a1f406091597c463a431b4 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Thu, 14 Aug 2025 15:45:06 -0400 Subject: [PATCH 07/19] update docstrings --- .../pricetaker/design_and_operation_models.py | 2 +- .../pricetaker/unit_commitment.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index 9d4d8d33c1..50424d65f4 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -189,7 +189,7 @@ def my_operation_model(m, design_blk): "startup_types", ConfigValue( domain=dict, - doc="Dictionary of startup types for the unit/process", + doc="Dictionary of startup types and transition times for the unit/process", ) ) diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 8c717785fc..ca850e0a86 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -144,12 +144,12 @@ def startup_shutdown_constraints( startup_transition_time: Optional[dict], ): """ - Appends startup and shutdown constraints for a given unit/process - + Appends startup and shutdown constraints for a given unit/process. Supports multiples types of startup. Args: startup_transition_time (dict): A dictionary with keys as startup types and values are the time of startup transition. + """ @blk.Constraint(set_time) def binary_relationship_con(_, t): @@ -187,17 +187,20 @@ def minimum_down_time_con(_, t): # multiple startup types if startup_transition_time is not None: - # at least two types of startup + # there will be at least two types of startup startup_names = list(startup_transition_time.keys()) - # assume the first should be max(min_down_time, startup_transition_time["default"]) + + # assume the first should be max(min_down_time, startup_transition_time["hot"]) startup_transition_time[startup_names[0]] = max(minimum_down_time, startup_transition_time[startup_names[0]]) - # add a check to ensure the startup time is monotonically increasing + + # add a check to ensure the startup time is monotonically increasing. for i in range(1, len(startup_names)): startup_transition_time[startup_names[i]] = max( startup_transition_time[startup_names[i]], startup_transition_time[startup_names[i - 1]] ) + # this is necessary, because we have updated the startup_transition_time. blk.startup_duration = Param(startup_names, initialize=startup_transition_time) @blk.Constraint(set_time) @@ -236,7 +239,7 @@ def startup_type_rule(_, t, key=key, prev_key=prev_key): Constraint(set_time, rule=startup_type_rule) ) prev_key = key - + def capacity_limits( blk: Block, From 94f77290f5a3b936334c052c549a5540339ff604 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Mon, 1 Dec 2025 17:51:26 -0500 Subject: [PATCH 08/19] update various startup codes and add tests --- .../pricetaker/design_and_operation_models.py | 3 ++- .../apps/grid_integration/pricetaker/price_taker_model.py | 2 +- .../pricetaker/tests/test_design_and_operation_models.py | 8 ++++++++ idaes/apps/grid_integration/pricetaker/unit_commitment.py | 5 +++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index bd4edb64b5..ae84aa685c 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -424,7 +424,8 @@ def build(self): doc="Binary: 1 if the shutdown is initiated, 0 otherwise", ) - if self.config.startup_types is not None: + if not self.config.startup_types: + # self.config.startup_types can be None or an empty dict self.startup_type_vars = Var( list(self.config.startup_types.keys()), within=Binary, diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index bb83863431..274a856490 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -881,7 +881,7 @@ def add_startup_shutdown( setattr(self, start_shut_blk_name, Block(self.set_days)) start_shut_blk = getattr(self, start_shut_blk_name) - if startup_transition_time is not None: + if not startup_transition_time: self._multiple_startup_types = True # pylint: disable=not-an-iterable diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py index d6487b6518..2e8f6772b9 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py @@ -270,6 +270,14 @@ def op_model(m, des_blk): for attr in ["op_mode", "startup", "shutdown", "power", "LMP"]: assert not hasattr(blk.unit_2_op, attr) + # test the multiple startup types + blk.unit_3_op = OperationModel( + model_func=op_model, + model_args={"des_blk": blk.unit_1_design}, + startup_types={'hot': 4, 'warm': 8, 'cold': 12} + ) + assert hasattr(blk.unit_3_op, "startup_type_vars") + @pytest.mark.unit def test_operation_model_class_logger_message1(caplog): diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index ca850e0a86..190646d32e 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -181,12 +181,13 @@ def minimum_down_time_con(_, t): <= install_unit - op_blocks[t].op_mode ) - if startup_transition_time is None: + if not startup_transition_time: # if there is only one startup type, return + # startup_transition_time can be None or empty dict return # multiple startup types - if startup_transition_time is not None: + if startup_transition_time: # there will be at least two types of startup startup_names = list(startup_transition_time.keys()) From 4448f130ee3bda98368c898020f00f8c570cb4f9 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Tue, 2 Dec 2025 14:37:03 -0500 Subject: [PATCH 09/19] fix bugs and add more tests --- .../pricetaker/design_and_operation_models.py | 2 +- .../tests/test_design_and_operation_models.py | 8 + .../pricetaker/tests/test_unit_commitment.py | 255 ++++++++++++++++++ .../pricetaker/unit_commitment.py | 2 +- 4 files changed, 265 insertions(+), 2 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index ae84aa685c..f16370be6d 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -424,7 +424,7 @@ def build(self): doc="Binary: 1 if the shutdown is initiated, 0 otherwise", ) - if not self.config.startup_types: + if self.config.startup_types: # self.config.startup_types can be None or an empty dict self.startup_type_vars = Var( list(self.config.startup_types.keys()), diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py index 2e8f6772b9..7ca9519517 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py @@ -278,6 +278,14 @@ def op_model(m, des_blk): ) assert hasattr(blk.unit_3_op, "startup_type_vars") + # test the multiple startup types + blk.unit_3_op = OperationModel( + model_func=op_model, + model_args={"des_blk": blk.unit_1_design}, + startup_types={} + ) + assert not hasattr(blk.unit_3_op, "startup_type_vars") + @pytest.mark.unit def test_operation_model_class_logger_message1(caplog): diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py b/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py index eccabe6d01..6c58992332 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py @@ -306,3 +306,258 @@ def op_blk(b, _): ) assert con2_2 == str(rl.ramp_down_con[2].expr) assert con2_10 == str(rl.ramp_down_con[10].expr) + + +@pytest.mark.unit +def test_startup_shutdown_constraints_with_multiple_startup_types(): + """ + Tests the startup_shutdown_constraints function with multiple startup types + covering lines 184-242 which handle startup transition times and constraints + """ + m = pyo.ConcreteModel() + m.set_time = pyo.RangeSet(10) + startup_transition_time = {"hot": 2, "warm": 5, "cold": 8} + + @m.Block(m.set_time) + def op_blk(b, _): + b.op_mode = pyo.Var(within=pyo.Binary) + b.startup = pyo.Var(within=pyo.Binary) + b.shutdown = pyo.Var(within=pyo.Binary) + # Add startup_type_vars for multiple startup types + b.startup_type_vars = pyo.Var(list(startup_transition_time.keys()), within=pyo.Binary) + + m.startup_shutdown = pyo.Block() + + # Test with multiple startup types + uc.startup_shutdown_constraints( + blk=m.startup_shutdown, + op_blocks=m.op_blk, + install_unit=1, + minimum_up_time=3, + minimum_down_time=4, + set_time=m.set_time, + startup_transition_time=startup_transition_time, + ) + + ss = m.startup_shutdown + + # Test that startup_duration parameter is created with correct values + # Line 204: blk.startup_duration = Param(startup_names, initialize=startup_transition_time) + assert hasattr(ss, 'startup_duration') + assert pyo.value(ss.startup_duration["hot"]) == 4 # max(minimum_down_time=4, original=2) + assert pyo.value(ss.startup_duration["warm"]) == 5 # max(5, 4) + assert pyo.value(ss.startup_duration["cold"]) == 8 # max(8, 5) + + # Test monotonic increasing property (lines 196-201) + startup_names = list(startup_transition_time.keys()) + for i in range(1, len(startup_names)): + current_duration = pyo.value(ss.startup_duration[startup_names[i]]) + prev_duration = pyo.value(ss.startup_duration[startup_names[i-1]]) + assert current_duration >= prev_duration + + # Test total startup type constraint exists (lines 206-212) + # Eq 55 in Ben's paper + assert hasattr(ss, 'tot_startup_type_rule') + assert len(ss.tot_startup_type_rule) == 10 + + # Check the constraint expression for tot_startup_type_rule + expected_expr = ( + "op_blk[1].startup_type_vars[hot] + " + "op_blk[1].startup_type_vars[warm] + " + "op_blk[1].startup_type_vars[cold] == op_blk[1].startup" + ) + # print(str(ss.tot_startup_type_rule[1].expr)) + assert str(ss.tot_startup_type_rule[1].expr) == expected_expr + + # Test individual startup type constraints exist (lines 215-242) + # Eq 54 in Ben's paper - should have constraints for warm and cold (not hot since idx=0) + assert hasattr(ss, 'Startup_Type_Constraint_warm') + assert hasattr(ss, 'Startup_Type_Constraint_cold') + + # Check that constraints are created for correct indices + # For warm startup (duration=5): constraints should exist for t >= 5 + warm_constraint = getattr(ss, 'Startup_Type_Constraint_warm') + assert 1 not in warm_constraint + assert 2 not in warm_constraint + assert 3 not in warm_constraint + assert 4 not in warm_constraint + assert 5 in warm_constraint + assert 10 in warm_constraint + + # For cold startup (duration=8): constraints should exist for t >= 8 + cold_constraint = getattr(ss, 'Startup_Type_Constraint_cold') + assert 1 not in cold_constraint + assert 7 not in cold_constraint + assert 8 in cold_constraint + assert 10 in cold_constraint + + +@pytest.mark.unit +def test_startup_shutdown_constraints_no_startup_transition_time(): + """ + Tests early return when startup_transition_time is None or empty + covering lines 184-188 + """ + m = pyo.ConcreteModel() + m.set_time = pyo.RangeSet(5) + + @m.Block(m.set_time) + def op_blk(b, _): + b.op_mode = pyo.Var(within=pyo.Binary) + b.startup = pyo.Var(within=pyo.Binary) + b.shutdown = pyo.Var(within=pyo.Binary) + + m.startup_shutdown = pyo.Block() + + # Test with None startup_transition_time + uc.startup_shutdown_constraints( + blk=m.startup_shutdown, + op_blocks=m.op_blk, + install_unit=1, + minimum_up_time=2, + minimum_down_time=2, + set_time=m.set_time, + startup_transition_time=None, + ) + + ss = m.startup_shutdown + + # Should have basic constraints but no startup type specific constraints + assert hasattr(ss, 'binary_relationship_con') + assert hasattr(ss, 'minimum_up_time_con') + assert hasattr(ss, 'minimum_down_time_con') + + # Should NOT have startup type specific attributes + assert not hasattr(ss, 'startup_duration') + assert not hasattr(ss, 'tot_startup_type_rule') + assert not hasattr(ss, 'Startup_Type_Constraint_warm') + + # Test with empty dict startup_transition_time + m2 = pyo.ConcreteModel() + m2.set_time = pyo.RangeSet(5) + + @m2.Block(m2.set_time) + def op_blk2(b, _): + b.op_mode = pyo.Var(within=pyo.Binary) + b.startup = pyo.Var(within=pyo.Binary) + b.shutdown = pyo.Var(within=pyo.Binary) + + m2.startup_shutdown = pyo.Block() + + uc.startup_shutdown_constraints( + blk=m2.startup_shutdown, + op_blocks=m2.op_blk2, + install_unit=1, + minimum_up_time=2, + minimum_down_time=2, + set_time=m2.set_time, + startup_transition_time={}, + ) + + ss2 = m2.startup_shutdown + + # Should have basic constraints but no startup type specific constraints + assert hasattr(ss2, 'binary_relationship_con') + assert not hasattr(ss2, 'startup_duration') + assert not hasattr(ss2, 'tot_startup_type_rule') + + +@pytest.mark.unit +def test_startup_shutdown_constraints_single_startup_type(): + """ + Tests the case with single startup type in startup_transition_time dict + This should still create startup duration parameter and constraints + """ + m = pyo.ConcreteModel() + m.set_time = pyo.RangeSet(8) + + @m.Block(m.set_time) + def op_blk(b, _): + b.op_mode = pyo.Var(within=pyo.Binary) + b.startup = pyo.Var(within=pyo.Binary) + b.shutdown = pyo.Var(within=pyo.Binary) + # Add startup_type_vars for single startup type + b.startup_type_vars = {} + b.startup_type_vars["hot"] = pyo.Var(within=pyo.Binary) + + m.startup_shutdown = pyo.Block() + + # Test with single startup type - should respect minimum_down_time + startup_transition_time = {"hot": 2} # Less than minimum_down_time=3 + + uc.startup_shutdown_constraints( + blk=m.startup_shutdown, + op_blocks=m.op_blk, + install_unit=1, + minimum_up_time=2, + minimum_down_time=3, + set_time=m.set_time, + startup_transition_time=startup_transition_time, + ) + + ss = m.startup_shutdown + + # Should create startup_duration parameter + assert hasattr(ss, 'startup_duration') + # Hot startup should be adjusted to minimum_down_time (line 195) + assert pyo.value(ss.startup_duration["hot"]) == 3 # max(minimum_down_time=3, original=2) + + # Should create tot_startup_type_rule constraint + assert hasattr(ss, 'tot_startup_type_rule') + assert len(ss.tot_startup_type_rule) == 8 + + # Should NOT create individual startup type constraints since there's only one type (idx=0 case) + assert not hasattr(ss, 'Startup_Type_Constraint_hot') + + +@pytest.mark.unit +def test_startup_shutdown_constraints_constraint_expressions(): + """ + Tests the specific constraint expressions created for multiple startup types + Verifies the mathematical formulation from lines 225-241 + """ + m = pyo.ConcreteModel() + m.set_time = pyo.RangeSet(10) + + @m.Block(m.set_time) + def op_blk(b, _): + b.op_mode = pyo.Var(within=pyo.Binary) + b.startup = pyo.Var(within=pyo.Binary) + b.shutdown = pyo.Var(within=pyo.Binary) + b.startup_type_vars = {} + b.startup_type_vars["hot"] = pyo.Var(within=pyo.Binary) + b.startup_type_vars["warm"] = pyo.Var(within=pyo.Binary) + + m.startup_shutdown = pyo.Block() + + startup_transition_time = {"hot": 3, "warm": 6} + + uc.startup_shutdown_constraints( + blk=m.startup_shutdown, + op_blocks=m.op_blk, + install_unit=1, + minimum_up_time=2, + minimum_down_time=4, + set_time=m.set_time, + startup_transition_time=startup_transition_time, + ) + + ss = m.startup_shutdown + + # Test tot_startup_type_rule constraint expression (Eq 55) + # Verify the constraint structure (exact string may vary due to variable representation) + tot_expr_str = str(ss.tot_startup_type_rule[5].expr) + assert "==" in tot_expr_str + assert "op_blk[5].startup" in tot_expr_str + + # Test individual startup type constraint (Eq 54) + # For warm startup (duration=6): constraint should exist for t=6 and above + warm_constraint = getattr(ss, 'Startup_Type_Constraint_warm') + assert 6 in warm_constraint + + # The constraint should be: hot_startup <= sum of shutdowns in range [hot_duration, warm_duration) + # Range should be [4, 6) = [4, 5] but Pyomo uses t-i indexing, so it's [2, 1] + warm_expr_str = str(warm_constraint[6].expr) + assert "<=" in warm_expr_str + assert "op_blk[2].shutdown" in warm_expr_str + assert "op_blk[1].shutdown" in warm_expr_str diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 190646d32e..e75dce50c2 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -141,7 +141,7 @@ def startup_shutdown_constraints( minimum_up_time: int, minimum_down_time: int, set_time: RangeSet, - startup_transition_time: Optional[dict], + startup_transition_time: Optional[dict] = None, ): """ Appends startup and shutdown constraints for a given unit/process. From 43e5c1b0bf8caf063444ca33a3ebd3084af08f07 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Tue, 2 Dec 2025 14:52:22 -0500 Subject: [PATCH 10/19] use black to reformulate --- .../pricetaker/design_and_operation_models.py | 2 +- .../pricetaker/price_taker_model.py | 3 +- .../tests/test_design_and_operation_models.py | 6 +- .../pricetaker/tests/test_unit_commitment.py | 76 ++++++++++--------- 4 files changed, 46 insertions(+), 41 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index f16370be6d..bec24dbc2d 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -400,7 +400,7 @@ def my_operation_model(m, design_blk): ConfigValue( domain=dict, doc="Dictionary of startup types and transition times for the unit/process", - ) + ), ) # noinspection PyAttributeOutsideInit diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index 274a856490..da64ab062b 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -161,9 +161,10 @@ ConfigValue( domain=dict, doc="Dictionary of startup types and their transition times for the unit/process", - ) + ), ) + # pylint: disable = too-many-ancestors, too-many-instance-attributes class PriceTakerModel(ConcreteModel): """Builds a price-taker model for a given system""" diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py index 7ca9519517..f33f4c26a6 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py @@ -274,15 +274,13 @@ def op_model(m, des_blk): blk.unit_3_op = OperationModel( model_func=op_model, model_args={"des_blk": blk.unit_1_design}, - startup_types={'hot': 4, 'warm': 8, 'cold': 12} + startup_types={"hot": 4, "warm": 8, "cold": 12}, ) assert hasattr(blk.unit_3_op, "startup_type_vars") # test the multiple startup types blk.unit_3_op = OperationModel( - model_func=op_model, - model_args={"des_blk": blk.unit_1_design}, - startup_types={} + model_func=op_model, model_args={"des_blk": blk.unit_1_design}, startup_types={} ) assert not hasattr(blk.unit_3_op, "startup_type_vars") diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py b/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py index 6c58992332..081a87a9d3 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_unit_commitment.py @@ -324,7 +324,9 @@ def op_blk(b, _): b.startup = pyo.Var(within=pyo.Binary) b.shutdown = pyo.Var(within=pyo.Binary) # Add startup_type_vars for multiple startup types - b.startup_type_vars = pyo.Var(list(startup_transition_time.keys()), within=pyo.Binary) + b.startup_type_vars = pyo.Var( + list(startup_transition_time.keys()), within=pyo.Binary + ) m.startup_shutdown = pyo.Block() @@ -343,21 +345,23 @@ def op_blk(b, _): # Test that startup_duration parameter is created with correct values # Line 204: blk.startup_duration = Param(startup_names, initialize=startup_transition_time) - assert hasattr(ss, 'startup_duration') - assert pyo.value(ss.startup_duration["hot"]) == 4 # max(minimum_down_time=4, original=2) + assert hasattr(ss, "startup_duration") + assert ( + pyo.value(ss.startup_duration["hot"]) == 4 + ) # max(minimum_down_time=4, original=2) assert pyo.value(ss.startup_duration["warm"]) == 5 # max(5, 4) assert pyo.value(ss.startup_duration["cold"]) == 8 # max(8, 5) - + # Test monotonic increasing property (lines 196-201) startup_names = list(startup_transition_time.keys()) for i in range(1, len(startup_names)): current_duration = pyo.value(ss.startup_duration[startup_names[i]]) - prev_duration = pyo.value(ss.startup_duration[startup_names[i-1]]) + prev_duration = pyo.value(ss.startup_duration[startup_names[i - 1]]) assert current_duration >= prev_duration # Test total startup type constraint exists (lines 206-212) # Eq 55 in Ben's paper - assert hasattr(ss, 'tot_startup_type_rule') + assert hasattr(ss, "tot_startup_type_rule") assert len(ss.tot_startup_type_rule) == 10 # Check the constraint expression for tot_startup_type_rule @@ -371,28 +375,28 @@ def op_blk(b, _): # Test individual startup type constraints exist (lines 215-242) # Eq 54 in Ben's paper - should have constraints for warm and cold (not hot since idx=0) - assert hasattr(ss, 'Startup_Type_Constraint_warm') - assert hasattr(ss, 'Startup_Type_Constraint_cold') - + assert hasattr(ss, "Startup_Type_Constraint_warm") + assert hasattr(ss, "Startup_Type_Constraint_cold") + # Check that constraints are created for correct indices # For warm startup (duration=5): constraints should exist for t >= 5 - warm_constraint = getattr(ss, 'Startup_Type_Constraint_warm') + warm_constraint = getattr(ss, "Startup_Type_Constraint_warm") assert 1 not in warm_constraint assert 2 not in warm_constraint assert 3 not in warm_constraint assert 4 not in warm_constraint assert 5 in warm_constraint assert 10 in warm_constraint - + # For cold startup (duration=8): constraints should exist for t >= 8 - cold_constraint = getattr(ss, 'Startup_Type_Constraint_cold') + cold_constraint = getattr(ss, "Startup_Type_Constraint_cold") assert 1 not in cold_constraint assert 7 not in cold_constraint assert 8 in cold_constraint assert 10 in cold_constraint -@pytest.mark.unit +@pytest.mark.unit def test_startup_shutdown_constraints_no_startup_transition_time(): """ Tests early return when startup_transition_time is None or empty @@ -421,16 +425,16 @@ def op_blk(b, _): ) ss = m.startup_shutdown - + # Should have basic constraints but no startup type specific constraints - assert hasattr(ss, 'binary_relationship_con') - assert hasattr(ss, 'minimum_up_time_con') - assert hasattr(ss, 'minimum_down_time_con') - + assert hasattr(ss, "binary_relationship_con") + assert hasattr(ss, "minimum_up_time_con") + assert hasattr(ss, "minimum_down_time_con") + # Should NOT have startup type specific attributes - assert not hasattr(ss, 'startup_duration') - assert not hasattr(ss, 'tot_startup_type_rule') - assert not hasattr(ss, 'Startup_Type_Constraint_warm') + assert not hasattr(ss, "startup_duration") + assert not hasattr(ss, "tot_startup_type_rule") + assert not hasattr(ss, "Startup_Type_Constraint_warm") # Test with empty dict startup_transition_time m2 = pyo.ConcreteModel() @@ -455,11 +459,11 @@ def op_blk2(b, _): ) ss2 = m2.startup_shutdown - + # Should have basic constraints but no startup type specific constraints - assert hasattr(ss2, 'binary_relationship_con') - assert not hasattr(ss2, 'startup_duration') - assert not hasattr(ss2, 'tot_startup_type_rule') + assert hasattr(ss2, "binary_relationship_con") + assert not hasattr(ss2, "startup_duration") + assert not hasattr(ss2, "tot_startup_type_rule") @pytest.mark.unit @@ -484,7 +488,7 @@ def op_blk(b, _): # Test with single startup type - should respect minimum_down_time startup_transition_time = {"hot": 2} # Less than minimum_down_time=3 - + uc.startup_shutdown_constraints( blk=m.startup_shutdown, op_blocks=m.op_blk, @@ -498,16 +502,18 @@ def op_blk(b, _): ss = m.startup_shutdown # Should create startup_duration parameter - assert hasattr(ss, 'startup_duration') + assert hasattr(ss, "startup_duration") # Hot startup should be adjusted to minimum_down_time (line 195) - assert pyo.value(ss.startup_duration["hot"]) == 3 # max(minimum_down_time=3, original=2) - + assert ( + pyo.value(ss.startup_duration["hot"]) == 3 + ) # max(minimum_down_time=3, original=2) + # Should create tot_startup_type_rule constraint - assert hasattr(ss, 'tot_startup_type_rule') + assert hasattr(ss, "tot_startup_type_rule") assert len(ss.tot_startup_type_rule) == 8 - + # Should NOT create individual startup type constraints since there's only one type (idx=0 case) - assert not hasattr(ss, 'Startup_Type_Constraint_hot') + assert not hasattr(ss, "Startup_Type_Constraint_hot") @pytest.mark.unit @@ -531,7 +537,7 @@ def op_blk(b, _): m.startup_shutdown = pyo.Block() startup_transition_time = {"hot": 3, "warm": 6} - + uc.startup_shutdown_constraints( blk=m.startup_shutdown, op_blocks=m.op_blk, @@ -552,9 +558,9 @@ def op_blk(b, _): # Test individual startup type constraint (Eq 54) # For warm startup (duration=6): constraint should exist for t=6 and above - warm_constraint = getattr(ss, 'Startup_Type_Constraint_warm') + warm_constraint = getattr(ss, "Startup_Type_Constraint_warm") assert 6 in warm_constraint - + # The constraint should be: hot_startup <= sum of shutdowns in range [hot_duration, warm_duration) # Range should be [4, 6) = [4, 5] but Pyomo uses t-i indexing, so it's [2, 1] warm_expr_str = str(warm_constraint[6].expr) From e65d9805744527b04a8037fc92b8c948722ab912 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Tue, 2 Dec 2025 15:16:39 -0500 Subject: [PATCH 11/19] further reformat using black --- .../pricetaker/unit_commitment.py | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index e75dce50c2..5a08d70734 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -151,6 +151,7 @@ def startup_shutdown_constraints( startup_transition_time (dict): A dictionary with keys as startup types and values are the time of startup transition. """ + @blk.Constraint(set_time) def binary_relationship_con(_, t): if t == 1: @@ -180,7 +181,7 @@ def minimum_down_time_con(_, t): sum(op_blocks[i].shutdown for i in range(t - minimum_down_time + 1, t + 1)) <= install_unit - op_blocks[t].op_mode ) - + if not startup_transition_time: # if there is only one startup type, return # startup_transition_time can be None or empty dict @@ -190,15 +191,17 @@ def minimum_down_time_con(_, t): if startup_transition_time: # there will be at least two types of startup startup_names = list(startup_transition_time.keys()) - + # assume the first should be max(min_down_time, startup_transition_time["hot"]) - startup_transition_time[startup_names[0]] = max(minimum_down_time, startup_transition_time[startup_names[0]]) - + startup_transition_time[startup_names[0]] = max( + minimum_down_time, startup_transition_time[startup_names[0]] + ) + # add a check to ensure the startup time is monotonically increasing. for i in range(1, len(startup_names)): startup_transition_time[startup_names[i]] = max( startup_transition_time[startup_names[i]], - startup_transition_time[startup_names[i - 1]] + startup_transition_time[startup_names[i - 1]], ) # this is necessary, because we have updated the startup_transition_time. @@ -206,38 +209,38 @@ def minimum_down_time_con(_, t): @blk.Constraint(set_time) def tot_startup_type_rule(_, t): - ''' + """ Eq 55 in Ben's paper - ''' - + """ + return ( - sum(op_blocks[t].startup_type_vars[k] for k in startup_names) == op_blocks[t].startup + sum(op_blocks[t].startup_type_vars[k] for k in startup_names) + == op_blocks[t].startup ) - # add the startup type constraints for each type of startup for idx, key in enumerate(startup_names): if idx == 0: prev_key = key continue - + def startup_type_rule(_, t, key=key, prev_key=prev_key): - ''' + """ Eq 54 in Ben's paper - ''' + """ if t < blk.startup_duration[key]: return Constraint.Skip - return ( - op_blocks[t].startup_type_vars[prev_key] <= sum( - op_blocks[t - i].shutdown for i in range( - blk.startup_duration[prev_key], blk.startup_duration[key] - ) + return op_blocks[t].startup_type_vars[prev_key] <= sum( + op_blocks[t - i].shutdown + for i in range( + blk.startup_duration[prev_key], blk.startup_duration[key] ) ) - - setattr(blk, - f"Startup_Type_Constraint_{key}", - Constraint(set_time, rule=startup_type_rule) + + setattr( + blk, + f"Startup_Type_Constraint_{key}", + Constraint(set_time, rule=startup_type_rule), ) prev_key = key From 5948e7574e36f5139f3019879b00c6b9386dcf17 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Fri, 19 Dec 2025 14:15:13 -0500 Subject: [PATCH 12/19] revome an unsed variable --- idaes/apps/grid_integration/pricetaker/price_taker_model.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index 2ff57793d7..9edd3bb081 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -882,9 +882,6 @@ def add_startup_shutdown( setattr(self, start_shut_blk_name, Block(self.set_days)) start_shut_blk = getattr(self, start_shut_blk_name) - if not startup_transition_time: - self._multiple_startup_types = True - # pylint: disable=not-an-iterable for d in self.set_days: startup_shutdown_constraints( From 8bac610d0440fb2aba2494cedb98b70ca75909d8 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Mon, 12 Jan 2026 16:14:41 -0500 Subject: [PATCH 13/19] update to address comments from PR --- .../pricetaker/design_and_operation_models.py | 40 ++++++++++++++++++- .../pricetaker/price_taker_model.py | 3 +- .../pricetaker/unit_commitment.py | 14 +++++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index bc134cb5ba..e06031e1ae 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -85,6 +85,44 @@ def is_valid_polynomial_surrogate_data(data: dict): return new_data +def is_valid_startup_types(data): + """Validate if the startup_types received is valid""" + + if data is None: + # if data is None, it is okay and return None + return None + + if not isinstance(data, dict): + raise TypeError("Data must be a dictionary.") + + if len(data) == 0: + # set to None instead of an empty dict. + raise ConfigurationError("Received an empty dictionary for startup types") + + # check the key names and make sure space is handled correctly. + # use a method to check if a string is a valid variable name. string.isidentifier() works for now. + + for key, value in data.items(): + if not isinstance(key, str): + raise TypeError("key must be a valid string.") + + if not key.isidentifier(): + raise ConfigurationError( + f"Key '{key}' is not a valid Python variable name. " + "Keys must be valid identifiers." + ) + + if not isinstance(value, int): + raise TypeError("value must be an int") + + # Values must be unique, as they correspond to different startup types + if len(data.values()) > len(set(data.values())): + raise ConfigurationError("Startup time for two or more startup types is the same.") + + # Return a dictionary after sorting based on values + return dict(sorted(data.items(), key=lambda item: item[1])) + + # pylint: disable = attribute-defined-outside-init, too-many-ancestors # pylint: disable = invalid-name, logging-fstring-interpolation @declare_process_block_class("DesignModel") @@ -398,7 +436,7 @@ def my_operation_model(m, design_blk): CONFIG.declare( "startup_types", ConfigValue( - domain=dict, + domain=is_valid_startup_types, doc="Dictionary of startup types and transition times for the unit/process", ), ) diff --git a/idaes/apps/grid_integration/pricetaker/price_taker_model.py b/idaes/apps/grid_integration/pricetaker/price_taker_model.py index 9edd3bb081..6f866194e0 100644 --- a/idaes/apps/grid_integration/pricetaker/price_taker_model.py +++ b/idaes/apps/grid_integration/pricetaker/price_taker_model.py @@ -42,6 +42,7 @@ DesignModelData, OperationModelData, StorageModelData, + is_valid_startup_types, ) from idaes.apps.grid_integration.pricetaker.clustering import ( generate_daily_data, @@ -159,7 +160,7 @@ CONFIG.declare( "startup_types", ConfigValue( - domain=dict, + domain=is_valid_startup_types, doc="Dictionary of startup types and their transition times for the unit/process", ), ) diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 3bcabff973..5df313c5ac 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -15,6 +15,11 @@ This module contains functions that build unit commitment-type constraints: startup/shutdown, uptime/downtime constraints, capacity limit constraints, and ramping constraints. + +The unit commitment model is taken from: +Knueven, Bernard, James Ostrowski, and Jean-Paul Watson. +"On mixed-integer programming formulations for the unit commitment problem." +INFORMS Journal on Computing 32, no. 4 (2020): 857-876. """ from typing import Union, Optional @@ -148,7 +153,7 @@ def startup_shutdown_constraints( Supports multiples types of startup. Args: - startup_transition_time (dict): A dictionary with keys as startup types and values are the time of startup transition. + startup_transition_time (dict or None): A dictionary with keys as startup types and values are the time of startup transition. """ @@ -182,9 +187,10 @@ def minimum_down_time_con(_, t): <= install_unit - op_blocks[t].op_mode ) - if not startup_transition_time: + if startup_transition_time is None or len(startup_transition_time) == 0: # if there is only one startup type, return # startup_transition_time can be None or empty dict + # This is a double insurance check, that empty dict or None is not passed return # multiple startup types @@ -210,7 +216,7 @@ def minimum_down_time_con(_, t): @blk.Constraint(set_time) def tot_startup_type_rule(_, t): """ - Eq 55 in Ben's paper + Eq 55 in Knueven et.al. """ return ( @@ -226,7 +232,7 @@ def tot_startup_type_rule(_, t): def startup_type_rule(_, t, key=key, prev_key=prev_key): """ - Eq 54 in Ben's paper + Eq 54 in Knueven et.al. """ if t < blk.startup_duration[key]: return Constraint.Skip From c32e950e6b45f4e238e38b3bb464a8c613bc9ab1 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Mon, 12 Jan 2026 16:20:07 -0500 Subject: [PATCH 14/19] use black to format codes --- .../pricetaker/design_and_operation_models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index e06031e1ae..fe0772ba48 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -117,7 +117,9 @@ def is_valid_startup_types(data): # Values must be unique, as they correspond to different startup types if len(data.values()) > len(set(data.values())): - raise ConfigurationError("Startup time for two or more startup types is the same.") + raise ConfigurationError( + "Startup time for two or more startup types is the same." + ) # Return a dictionary after sorting based on values return dict(sorted(data.items(), key=lambda item: item[1])) From 3938e1ff3534db19ecc4c3cb0420aa0b910ac938 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Mon, 12 Jan 2026 17:19:55 -0500 Subject: [PATCH 15/19] update and fix errors in the tests --- .../tests/test_design_and_operation_models.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py index 867e63c3d1..b48eee8d0d 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py @@ -23,10 +23,20 @@ _is_valid_data_type_for_storage_model, is_valid_variable_design_data, is_valid_polynomial_surrogate_data, + is_valid_startup_types, ) from idaes.core.util.config import ConfigurationError +@pytest.mark.unit +def test_is_valid_startup_types_empty_dict(): + """Tests that is_valid_startup_types raises error for empty dictionary""" + with pytest.raises( + ConfigurationError, match="Received an empty dictionary for startup types" + ): + is_valid_startup_types({}) + + @pytest.mark.unit def test_format_data(): """Tests the _format_data function""" @@ -278,9 +288,11 @@ def op_model(m, des_blk): ) assert hasattr(blk.unit_3_op, "startup_type_vars") - # test the multiple startup types + # test the startup types = None blk.unit_3_op = OperationModel( - model_func=op_model, model_args={"des_blk": blk.unit_1_design}, startup_types={} + model_func=op_model, + model_args={"des_blk": blk.unit_1_design}, + startup_types=None, ) assert not hasattr(blk.unit_3_op, "startup_type_vars") From 4ec1457e9acd296f01763ab177bd6e5dc8019b67 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Wed, 14 Jan 2026 19:44:42 -0500 Subject: [PATCH 16/19] update the functions and remove unnecessary lines --- .../pricetaker/design_and_operation_models.py | 4 ---- .../grid_integration/pricetaker/unit_commitment.py | 11 ++--------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index fe0772ba48..af897db137 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -88,10 +88,6 @@ def is_valid_polynomial_surrogate_data(data: dict): def is_valid_startup_types(data): """Validate if the startup_types received is valid""" - if data is None: - # if data is None, it is okay and return None - return None - if not isinstance(data, dict): raise TypeError("Data must be a dictionary.") diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 5df313c5ac..34b521ecdc 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -189,8 +189,8 @@ def minimum_down_time_con(_, t): if startup_transition_time is None or len(startup_transition_time) == 0: # if there is only one startup type, return - # startup_transition_time can be None or empty dict - # This is a double insurance check, that empty dict or None is not passed + # startup_transition_time can be None. + # This is a double insurance check, that empty dict or None will skip the following. return # multiple startup types @@ -203,13 +203,6 @@ def minimum_down_time_con(_, t): minimum_down_time, startup_transition_time[startup_names[0]] ) - # add a check to ensure the startup time is monotonically increasing. - for i in range(1, len(startup_names)): - startup_transition_time[startup_names[i]] = max( - startup_transition_time[startup_names[i]], - startup_transition_time[startup_names[i - 1]], - ) - # this is necessary, because we have updated the startup_transition_time. blk.startup_duration = Param(startup_names, initialize=startup_transition_time) From 50b68c11213b92d77baeeac2895666c699eba7c4 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Fri, 16 Jan 2026 10:29:47 -0500 Subject: [PATCH 17/19] update tests --- .../tests/test_design_and_operation_models.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py index b48eee8d0d..2c48d96eaa 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py @@ -37,6 +37,35 @@ def test_is_valid_startup_types_empty_dict(): is_valid_startup_types({}) +@pytest.mark.unit +def test_is_valid_startup_types_invalid_inputs(): + """Tests that is_valid_startup_types rejects invalid inputs""" + + with pytest.raises(TypeError, match="Data must be a dictionary."): + is_valid_startup_types([("hot", 4)]) + + with pytest.raises(TypeError, match="Data must be a dictionary."): + is_valid_startup_types(3.14) + + with pytest.raises(TypeError, match="key must be a valid string."): + is_valid_startup_types({1: 4}) + + with pytest.raises( + ConfigurationError, + match="Key 'hot-start' is not a valid Python variable name. Keys must be valid identifiers.", + ): + is_valid_startup_types({"hot-start": 4}) + + with pytest.raises(TypeError, match="value must be an int"): + is_valid_startup_types({"hot": 4.2}) + + with pytest.raises( + ConfigurationError, + match="Startup time for two or more startup types is the same.", + ): + is_valid_startup_types({"hot": 4, "warm": 4}) + + @pytest.mark.unit def test_format_data(): """Tests the _format_data function""" @@ -423,6 +452,15 @@ def test_is_valid_data_type_for_storage_model(): ) +@pytest.mark.unit +def test_is_valid_startup_types(): + """Tests the is_valid_startup_types function""" + # Test the correct data structure is returned + data = is_valid_startup_types({"warm": 8, "hot": 4, "cold": 12}) + assert list(data.keys()) == ["hot", "warm", "cold"] + assert data == {"hot": 4, "warm": 8, "cold": 12} + + @pytest.mark.unit def test_storage_model_fixed_design(): """Tests the StorageModel class with int/float-type arguments""" From ac54a1284dc121750fa6dc3d72362b5ac32ae892 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Wed, 4 Feb 2026 22:17:02 -0500 Subject: [PATCH 18/19] update tests --- .../pricetaker/design_and_operation_models.py | 12 +++++++++-- .../tests/test_design_and_operation_models.py | 14 +++++++++---- .../pricetaker/unit_commitment.py | 20 +++++++++---------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index af897db137..bf4305ffe3 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -94,6 +94,15 @@ def is_valid_startup_types(data): if len(data) == 0: # set to None instead of an empty dict. raise ConfigurationError("Received an empty dictionary for startup types") + + if len(data) == 1: + raise ConfigurationError( + "At least two startup types must be defined for the unit/process. \n" + "if startup_types = {\"hot\": 4, \"warm\": 8, \"cold\": 12}, \n" + "then when downtime is between 4 to 8 -> hot startup; \n" + "when downtime is between 8 to 12 -> warm startup; \n" + "when downtime is greater than 12 -> cold startup. \n" + ) # check the key names and make sure space is handled correctly. # use a method to check if a string is a valid variable name. string.isidentifier() works for now. @@ -460,8 +469,7 @@ def build(self): doc="Binary: 1 if the shutdown is initiated, 0 otherwise", ) - if self.config.startup_types: - # self.config.startup_types can be None or an empty dict + if self.config.startup_types is not None: self.startup_type_vars = Var( list(self.config.startup_types.keys()), within=Binary, diff --git a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py index 2c48d96eaa..349ec556c9 100644 --- a/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/tests/test_design_and_operation_models.py @@ -42,22 +42,28 @@ def test_is_valid_startup_types_invalid_inputs(): """Tests that is_valid_startup_types rejects invalid inputs""" with pytest.raises(TypeError, match="Data must be a dictionary."): - is_valid_startup_types([("hot", 4)]) + is_valid_startup_types([("hot", 4), ("warm", 8)]) with pytest.raises(TypeError, match="Data must be a dictionary."): is_valid_startup_types(3.14) + with pytest.raises( + ConfigurationError, + match="At least two startup types must be defined for the unit/process.", + ): + is_valid_startup_types({"hot": 4}) + with pytest.raises(TypeError, match="key must be a valid string."): - is_valid_startup_types({1: 4}) + is_valid_startup_types({1: 4, 2: 8}) with pytest.raises( ConfigurationError, match="Key 'hot-start' is not a valid Python variable name. Keys must be valid identifiers.", ): - is_valid_startup_types({"hot-start": 4}) + is_valid_startup_types({"hot-start": 4, "warm-start": 8}) with pytest.raises(TypeError, match="value must be an int"): - is_valid_startup_types({"hot": 4.2}) + is_valid_startup_types({"hot": 4.2, "warm": 8.1}) with pytest.raises( ConfigurationError, diff --git a/idaes/apps/grid_integration/pricetaker/unit_commitment.py b/idaes/apps/grid_integration/pricetaker/unit_commitment.py index 34b521ecdc..55694e0453 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -193,18 +193,16 @@ def minimum_down_time_con(_, t): # This is a double insurance check, that empty dict or None will skip the following. return - # multiple startup types - if startup_transition_time: - # there will be at least two types of startup - startup_names = list(startup_transition_time.keys()) - - # assume the first should be max(min_down_time, startup_transition_time["hot"]) - startup_transition_time[startup_names[0]] = max( - minimum_down_time, startup_transition_time[startup_names[0]] - ) + # there will be at least two types of startup + startup_names = list(startup_transition_time.keys()) + + # assume the first should be max(min_down_time, startup_transition_time["hot"]) + startup_transition_time[startup_names[0]] = max( + minimum_down_time, startup_transition_time[startup_names[0]] + ) - # this is necessary, because we have updated the startup_transition_time. - blk.startup_duration = Param(startup_names, initialize=startup_transition_time) + # this is necessary, because we have updated the startup_transition_time. + blk.startup_duration = Param(startup_names, initialize=startup_transition_time) @blk.Constraint(set_time) def tot_startup_type_rule(_, t): From cbe6eb9dfc12c8c1ddedcea41418dc0f3e52dad3 Mon Sep 17 00:00:00 2001 From: Xinhe Chen Date: Wed, 4 Feb 2026 22:18:20 -0500 Subject: [PATCH 19/19] foramt --- .../pricetaker/design_and_operation_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py index bf4305ffe3..f47eb0cf3d 100644 --- a/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py +++ b/idaes/apps/grid_integration/pricetaker/design_and_operation_models.py @@ -94,11 +94,11 @@ def is_valid_startup_types(data): if len(data) == 0: # set to None instead of an empty dict. raise ConfigurationError("Received an empty dictionary for startup types") - + if len(data) == 1: raise ConfigurationError( "At least two startup types must be defined for the unit/process. \n" - "if startup_types = {\"hot\": 4, \"warm\": 8, \"cold\": 12}, \n" + 'if startup_types = {"hot": 4, "warm": 8, "cold": 12}, \n' "then when downtime is between 4 to 8 -> hot startup; \n" "when downtime is between 8 to 12 -> warm startup; \n" "when downtime is greater than 12 -> cold startup. \n"