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 04c494aa40..f47eb0cf3d 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,51 @@ 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 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") + + 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. + + 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") @@ -395,6 +440,14 @@ def my_operation_model(m, design_blk): ), ) + CONFIG.declare( + "startup_types", + ConfigValue( + domain=is_valid_startup_types, + doc="Dictionary of startup types and transition times for the unit/process", + ), + ) + # noinspection PyAttributeOutsideInit def build(self): super().build() @@ -416,6 +469,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 32ad9224d0..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, @@ -156,6 +157,14 @@ ), ) +CONFIG.declare( + "startup_types", + ConfigValue( + domain=is_valid_startup_types, + 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): @@ -816,6 +825,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 @@ -842,6 +852,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, @@ -880,6 +892,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=startup_transition_time, ) # Save the uptime and downtime data for reference 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 0eb339fbf0..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 @@ -23,10 +23,55 @@ _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_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), ("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, 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, "warm-start": 8}) + + with pytest.raises(TypeError, match="value must be an int"): + is_valid_startup_types({"hot": 4.2, "warm": 8.1}) + + 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""" @@ -270,6 +315,22 @@ 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") + + # test the startup types = None + blk.unit_3_op = OperationModel( + 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") + @pytest.mark.unit def test_operation_model_class_logger_message1(caplog): @@ -397,6 +458,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""" 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 20d6eb4aa6..36ff269b25 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,264 @@ 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 1211f89de8..55694e0453 100644 --- a/idaes/apps/grid_integration/pricetaker/unit_commitment.py +++ b/idaes/apps/grid_integration/pricetaker/unit_commitment.py @@ -15,11 +15,16 @@ 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 +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 @@ -141,9 +146,15 @@ def startup_shutdown_constraints( minimum_up_time: int, minimum_down_time: int, set_time: RangeSet, + startup_transition_time: Optional[dict] = None, ): """ - 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 or None): A dictionary with keys as startup types and values are the time of startup transition. + """ @blk.Constraint(set_time) @@ -176,6 +187,60 @@ def minimum_down_time_con(_, t): <= install_unit - op_blocks[t].op_mode ) + 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. + # This is a double insurance check, that empty dict or None will skip the following. + return + + # 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) + + @blk.Constraint(set_time) + def tot_startup_type_rule(_, t): + """ + Eq 55 in Knueven et.al. + """ + + return ( + 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 Knueven et.al. + """ + 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 + def capacity_limits( blk: Block,