diff --git a/idaes/apps/grid_integration/bidder.py b/idaes/apps/grid_integration/bidder.py index 369248cc8c..72889b04ce 100644 --- a/idaes/apps/grid_integration/bidder.py +++ b/idaes/apps/grid_integration/bidder.py @@ -256,6 +256,7 @@ def __init__( n_scenario, solver, forecaster, + real_time_underbid_penalty, ): """ @@ -284,6 +285,7 @@ def __init__( self.n_scenario = n_scenario self.solver = solver self.forecaster = forecaster + self.real_time_underbid_penalty = real_time_underbid_penalty self._check_inputs() @@ -337,6 +339,10 @@ def formulate_DA_bidding_problem(self): model = self._set_up_bidding_problem(self.day_ahead_horizon) self._add_DA_bidding_constraints(model) + # do not relax the DA offering UB + for i in model.SCENARIOS: + model.fs[i].real_time_underbid_power.fix(0) + return model def formulate_RT_bidding_problem(self): @@ -352,6 +358,10 @@ def formulate_RT_bidding_problem(self): model = self._set_up_bidding_problem(self.real_time_horizon) self._add_RT_bidding_constraints(model) + # relax the DA offering UB + for i in model.SCENARIOS: + model.fs[i].real_time_underbid_power.unfix() + return model def _save_power_outputs(self, model): @@ -396,6 +406,9 @@ def _add_bidding_params(self, model): model.fs[i].real_time_energy_price = pyo.Param( time_index, initialize=0, mutable=True ) + model.fs[i].real_time_underbid_penalty = pyo.Param( + initialize=self.real_time_underbid_penalty, mutable=True + ) return @@ -411,8 +424,11 @@ def _add_bidding_vars(self, model): None """ - def day_ahead_power_ub_rule(fs, t): - return fs.power_output_ref[t] >= fs.day_ahead_power[t] + def relaxed_day_ahead_power_ub_rule(fs, t): + return ( + fs.power_output_ref[t] + fs.real_time_underbid_power[t] + >= fs.day_ahead_power[t] + ) for i in model.SCENARIOS: time_index = model.fs[i].power_output_ref.index_set() @@ -420,8 +436,12 @@ def day_ahead_power_ub_rule(fs, t): time_index, initialize=0, within=pyo.NonNegativeReals ) + model.fs[i].real_time_underbid_power = pyo.Var( + time_index, initialize=0, within=pyo.NonNegativeReals + ) + model.fs[i].day_ahead_power_ub = pyo.Constraint( - time_index, rule=day_ahead_power_ub_rule + time_index, rule=relaxed_day_ahead_power_ub_rule ) return @@ -459,6 +479,8 @@ def _add_bidding_objective(self, model): + model.fs[k].real_time_energy_price[t] * (model.fs[k].power_output_ref[t] - model.fs[k].day_ahead_power[t]) - weight * cost[t] + - model.fs[k].real_time_underbid_penalty + * model.fs[k].real_time_underbid_power[t] ) return @@ -654,8 +676,12 @@ def _pass_realized_day_ahead_dispatches(self, realized_day_ahead_dispatches, hou dispatch = realized_day_ahead_dispatches[t + hour] except IndexError as ex: self.real_time_model.fs[s].day_ahead_power[t].unfix() + # unrelax the DA offering UB + self.real_time_model.fs[s].real_time_underbid_power[t].fix(0) else: self.real_time_model.fs[s].day_ahead_power[t].fix(dispatch) + # relax the DA offering UB + self.real_time_model.fs[s].real_time_underbid_power[t].unfix() def update_day_ahead_model(self, **kwargs): @@ -808,6 +834,7 @@ def __init__( n_scenario, solver, forecaster, + real_time_underbid_penalty=10000, fixed_to_schedule=False, ): """ @@ -839,6 +866,7 @@ def __init__( n_scenario, solver, forecaster, + real_time_underbid_penalty, ) self.fixed_to_schedule = fixed_to_schedule @@ -922,6 +950,7 @@ def _assemble_bids(self, model, power_var_name, energy_price_param_name, hour): """ bids = {} + is_thermal = self.bidding_model_object.model_data.generator_type == "thermal" power_output_var = getattr(model.fs[0], power_var_name) time_index = power_output_var.index_set() @@ -939,29 +968,34 @@ def _assemble_bids(self, model, power_var_name, energy_price_param_name, hour): if self.fixed_to_schedule: bids[t][self.generator]["p_min"] = bids[t][self.generator]["p_max"] - bids[t][self.generator]["min_up_time"] = 0 - bids[t][self.generator]["min_down_time"] = 0 bids[t][self.generator]["fixed_commitment"] = ( 1 if bids[t][self.generator]["p_min"] > 0 else 0 ) - bids[t][self.generator]["startup_fuel"] = [ - (bids[t][self.generator]["min_down_time"], 0) - ] - bids[t][self.generator]["startup_cost"] = [ - (bids[t][self.generator]["min_down_time"], 0) - ] - bids[t][self.generator]["p_cost"] = [ - (bids[t][self.generator]["p_min"], 0), - (bids[t][self.generator]["p_max"], 0), - ] + if is_thermal: + bids[t][self.generator]["min_up_time"] = 0 + bids[t][self.generator]["min_down_time"] = 0 + + bids[t][self.generator]["startup_fuel"] = [ + (bids[t][self.generator]["min_down_time"], 0) + ] + bids[t][self.generator]["startup_cost"] = [ + (bids[t][self.generator]["min_down_time"], 0) + ] + + if is_thermal: - bids[t][self.generator]["startup_capacity"] = bids[t][self.generator][ - "p_min" - ] - bids[t][self.generator]["shutdown_capacity"] = bids[t][self.generator][ - "p_min" - ] + bids[t][self.generator]["p_cost"] = [ + (bids[t][self.generator]["p_min"], 0), + (bids[t][self.generator]["p_max"], 0), + ] + + bids[t][self.generator]["startup_capacity"] = bids[t][self.generator][ + "p_min" + ] + bids[t][self.generator]["shutdown_capacity"] = bids[t][self.generator][ + "p_min" + ] return bids @@ -1023,6 +1057,7 @@ def __init__( n_scenario, solver, forecaster, + real_time_underbid_penalty=10000, ): """ @@ -1052,6 +1087,7 @@ def __init__( n_scenario, solver, forecaster, + real_time_underbid_penalty, ) def _add_DA_bidding_constraints(self, model): @@ -1234,7 +1270,6 @@ def _assemble_bids(self, model, power_var_name, energy_price_param_name, hour): full_bids[t][gen]["p_cost"] = bids[t_idx][gen] full_bids[t][gen]["p_min"] = min([p[0] for p in bids[t_idx][gen]]) full_bids[t][gen]["p_max"] = max([p[0] for p in bids[t_idx][gen]]) - full_bids[t][gen]["startup_capacity"] = full_bids[t][gen]["p_min"] full_bids[t][gen]["shutdown_capacity"] = full_bids[t][gen]["p_min"] diff --git a/idaes/apps/grid_integration/coordinator.py b/idaes/apps/grid_integration/coordinator.py index fc05478018..99aa0362f5 100644 --- a/idaes/apps/grid_integration/coordinator.py +++ b/idaes/apps/grid_integration/coordinator.py @@ -35,10 +35,10 @@ def __init__(self, bidder, tracker, projection_tracker): Arguments: bidder: an initialized bidder object - tracker: an initialized bidder object + tracker: an initialized tracker object - projection_tracker: an initialized bidder object, this object is - mimicking the behaviro of the projection SCED in + projection_tracker: an initialized tracker object, this object is + mimicking the behaviror of the projection SCED in Prescient and to projecting the system states and updating bidder model. @@ -285,7 +285,7 @@ def assemble_project_tracking_signal(self, options, simulator, hour): """ This function assembles the signals for the tracking model to estimate the - state of the bidding model at the begining of next RUC. + state of the bidding model at the beginning of next RUC. Arguments: options: Prescient options from prescient.simulator.config. @@ -398,18 +398,32 @@ def _update_static_params(self, gen_dict): None """ + is_thermal = ( + self.bidder.bidding_model_object.model_data.generator_type == "thermal" + ) + is_renewable = ( + self.bidder.bidding_model_object.model_data.generator_type == "renewable" + ) + for param, value in self.bidder.bidding_model_object.model_data: if param == "gen_name" or value is None: continue elif param == "p_cost": - curve_value = convert_marginal_costs_to_actual_costs(value) - gen_dict[param] = { - "data_type": "cost_curve", - "cost_curve_type": "piecewise", - "values": curve_value, - } + if is_thermal: + curve_value = convert_marginal_costs_to_actual_costs(value) + gen_dict[param] = { + "data_type": "cost_curve", + "cost_curve_type": "piecewise", + "values": curve_value, + } + elif is_renewable: + gen_dict[param] = value + if "p_fuel" in gen_dict: gen_dict.pop("p_fuel") + elif param == "initial_status" or param == "initial_p_output": + if param not in gen_dict: + gen_dict[param] = value else: gen_dict[param] = value diff --git a/idaes/apps/grid_integration/examples/thermal_generator.py b/idaes/apps/grid_integration/examples/thermal_generator.py index 5c4b3c924f..bed43d5bf5 100644 --- a/idaes/apps/grid_integration/examples/thermal_generator.py +++ b/idaes/apps/grid_integration/examples/thermal_generator.py @@ -16,7 +16,7 @@ from idaes.apps.grid_integration import Tracker from idaes.apps.grid_integration import Bidder from idaes.apps.grid_integration import PlaceHolderForecaster -from idaes.apps.grid_integration.model_data import GeneratorModelData +from idaes.apps.grid_integration.model_data import ThermalGeneratorModelData from pyomo.common.dependencies import attempt_import @@ -138,10 +138,9 @@ def assemble_model_data(self, generator_name, gen_params): model_data["Power Segments"][l] ] = model_data["Marginal Costs"][l] - self._model_data = GeneratorModelData( + self._model_data = ThermalGeneratorModelData( gen_name=generator_name, bus=model_data["Bus Name"], - generator_type="thermal", p_min=model_data["PMin MW"], p_max=model_data["PMax MW"], min_down_time=model_data["Min Down Time Hr"], @@ -150,6 +149,9 @@ def assemble_model_data(self, generator_name, gen_params): ramp_down_60min=model_data["RD"], shutdown_capacity=model_data["SD"], startup_capacity=model_data["SU"], + initial_status=-1, + initial_p_output=0, + fixed_commitment=None, production_cost_bid_pairs=[ ( model_data["PMin MW"], @@ -163,7 +165,6 @@ def assemble_model_data(self, generator_name, gen_params): startup_cost_pairs=[ (model_data["Min Down Time Hr"], model_data["SU Cost"]) ], - fixed_commitment=None, ) return model_data diff --git a/idaes/apps/grid_integration/forecaster.py b/idaes/apps/grid_integration/forecaster.py index ec3217fe38..e48ca90dee 100644 --- a/idaes/apps/grid_integration/forecaster.py +++ b/idaes/apps/grid_integration/forecaster.py @@ -336,10 +336,7 @@ def fetch_day_ahead_stats_from_prescient(self, uc_date, uc_hour, day_ahead_resul None """ - forecasts_arr = np.random.normal( - loc=corresponding_means, scale=corresponding_stds, size=(n_samples, horizon) - ) - forecasts_arr[forecasts_arr < 0] = 0 + return class Backcaster(AbstractPrescientPriceForecaster): diff --git a/idaes/apps/grid_integration/model_data.py b/idaes/apps/grid_integration/model_data.py index 2008b087fe..4eaf6f7cb7 100644 --- a/idaes/apps/grid_integration/model_data.py +++ b/idaes/apps/grid_integration/model_data.py @@ -141,49 +141,16 @@ class GeneratorModelData: gen_name = StrValidator() bus = StrValidator() - p_min = RealValueValidator(min_val=0) - p_min_agc = RealValueValidator(min_val=0) - min_down_time = RealValueValidator(min_val=0) - min_up_time = RealValueValidator(min_val=0) - ramp_up_60min = RealValueValidator(min_val=0) - ramp_down_60min = RealValueValidator(min_val=0) + p_min = RealValueValidator(min_val=0) p_max = AtLeastPminValidator() - p_max_agc = AtLeastPminValidator() - shutdown_capacity = AtLeastPminValidator() - startup_capacity = AtLeastPminValidator() - def __init__( - self, - gen_name, - bus, - generator_type, - p_min, - p_max, - min_down_time, - min_up_time, - ramp_up_60min, - ramp_down_60min, - shutdown_capacity, - startup_capacity, - production_cost_bid_pairs=None, - startup_cost_pairs=None, - fixed_commitment=None, - ): + def __init__(self, gen_name, bus, p_min, p_max, fixed_commitment=None): self.gen_name = gen_name self.bus = bus - self.generator_type = generator_type self.p_min = p_min self.p_max = p_max - self.p_min_agc = p_min - self.p_max_agc = p_max - self.min_down_time = min_down_time - self.min_up_time = min_up_time - self.ramp_up_60min = ramp_up_60min - self.ramp_down_60min = ramp_down_60min - self.shutdown_capacity = shutdown_capacity - self.startup_capacity = startup_capacity fixed_commitment_allowed_values = [0, 1, None] if fixed_commitment not in fixed_commitment_allowed_values: @@ -192,24 +159,21 @@ def __init__( ) self.fixed_commitment = fixed_commitment - self.p_cost = self._assemble_default_cost_bids(production_cost_bid_pairs) - self.startup_cost = self._assemble_default_startup_cost_bids(startup_cost_pairs) - - # initialization for iterator - # get the collection of the params - self._collection = [ - name - for name in dir(self) - if not name.startswith("__") - and not name.startswith("_") - and not callable(getattr(self, name)) - ] - self._index = -1 - def __iter__(self): """ Make it iteratble. """ + + if not hasattr(self, "_collection"): + self._collection = [ + name + for name in dir(self) + if not name.startswith("__") + and not name.startswith("_") + and not callable(getattr(self, name)) + ] + self._index = -1 + return self def __next__(self): @@ -225,37 +189,55 @@ def __next__(self): name = self._collection[self._index] return name, getattr(self, name) - @property - def generator_type(self): - - """ - Property getter for generator's type. - Returns: - str: generator's type - """ +class ThermalGeneratorModelData(GeneratorModelData): - return self._generator_type + min_down_time = RealValueValidator(min_val=0) + min_up_time = RealValueValidator(min_val=0) + ramp_up_60min = RealValueValidator(min_val=0) + ramp_down_60min = RealValueValidator(min_val=0) - @generator_type.setter - def generator_type(self, value): + shutdown_capacity = AtLeastPminValidator() + startup_capacity = AtLeastPminValidator() - """ - Property setter for generator's type. + def __init__( + self, + gen_name, + bus, + p_min, + p_max, + min_down_time, + min_up_time, + ramp_up_60min, + ramp_down_60min, + shutdown_capacity, + startup_capacity, + initial_status, + initial_p_output, + fixed_commitment=None, + production_cost_bid_pairs=None, + startup_cost_pairs=None, + ): - Args: - value: generator's type in str + super().__init__( + gen_name=gen_name, + bus=bus, + p_min=p_min, + p_max=p_max, + fixed_commitment=fixed_commitment, + ) - Returns: - None - """ + self.min_down_time = min_down_time + self.min_up_time = min_up_time + self.ramp_up_60min = ramp_up_60min + self.ramp_down_60min = ramp_down_60min + self.shutdown_capacity = shutdown_capacity + self.startup_capacity = startup_capacity + self.initial_status = initial_status + self.initial_p_output = initial_p_output - allowed_types = ["thermal", "renewable"] - if value not in allowed_types: - raise ValueError( - f"Value for generator types must be one of {allowed_types}, but {value} is provided." - ) - self._generator_type = value + self.p_cost = self._assemble_default_cost_bids(production_cost_bid_pairs) + self.startup_cost = self._assemble_default_startup_cost_bids(startup_cost_pairs) def _check_empty_and_sort_cost_pairs(self, pair_description, pairs): @@ -332,3 +314,93 @@ def _assemble_default_startup_cost_bids(self, startup_cost_pairs): ) return startup_cost_pairs + + @property + def generator_type(self): + """ + Generator type property: fixed to thermal. + """ + return "thermal" + + @property + def initial_status(self): + + """ + Generator initial status proptery. If positive, the number of hours prior + to (and including) t=0 that the unit has been on. If negative, the number + of hours prior to (and including) t=0 that the unit has been off. The value + cannot be 0, by definition. + """ + + return self._initial_status + + @initial_status.setter + def initial_status(self, value): + + """ + Generator initial status proptery setter. Validate the value before setting. + """ + + if not isinstance(value, Real): + raise TypeError("Value for initial_status shoulde be real numbers.") + + if isclose(value, 0): + raise ValueError("Value for initial_status cannot be zero.") + + self._initial_status = value + + @property + def initial_p_output(self): + """ + Generator initial power output proptery. + """ + return self._initial_p_output + + @initial_p_output.setter + def initial_p_output(self, value): + + """ + Generator initial power output proptery setter. + """ + + if not isinstance(value, Real): + raise TypeError("Value for initial_p_output shoulde be real numbers.") + + if ( + self.initial_status > 0 + and value < self.p_min + and not isclose(value, self.p_min) + ): + raise ValueError( + f"The initial status of the generator was on before T0, so the initial power output should at least be p_min {self.p_min}, but {value} is provided." + ) + + if self.initial_status < 0 and (value > 0 and not isclose(value, 0)): + raise ValueError( + f"The initial status of the generator was off before T0, so the initial power output should at 0, but {value} is provided." + ) + + self._initial_p_output = value + + +class RenewableGeneratorModelData(GeneratorModelData): + + p_cost = RealValueValidator(min_val=0) + + def __init__(self, gen_name, bus, p_min, p_max, p_cost, fixed_commitment=None): + + super().__init__( + gen_name=gen_name, + bus=bus, + p_min=p_min, + p_max=p_max, + fixed_commitment=fixed_commitment, + ) + self.p_cost = p_cost + + @property + def generator_type(self): + """ + Generator type property: fixed to renewable. + """ + return "renewable" diff --git a/idaes/apps/grid_integration/tests/test_model_data.py b/idaes/apps/grid_integration/tests/test_model_data.py index 32380e166a..1c092a98fe 100644 --- a/idaes/apps/grid_integration/tests/test_model_data.py +++ b/idaes/apps/grid_integration/tests/test_model_data.py @@ -13,7 +13,10 @@ import pytest from pyomo.common import unittest as pyo_unittest -from idaes.apps.grid_integration.model_data import GeneratorModelData +from idaes.apps.grid_integration.model_data import ( + ThermalGeneratorModelData, + RenewableGeneratorModelData, +) @pytest.fixture @@ -21,7 +24,6 @@ def generator_params(): return { "gen_name": "Testing_Generator", "bus": "bus5", - "generator_type": "thermal", "p_min": 30, "p_max": 76, "min_down_time": 2, @@ -30,19 +32,38 @@ def generator_params(): "ramp_down_60min": 100, "shutdown_capacity": 30, "startup_capacity": 30, + "initial_status": 4, + "initial_p_output": 30, "production_cost_bid_pairs": [(30, 25), (45, 23), (60, 27), (76, 35)], "startup_cost_pairs": [(2, 1000), (6, 1500), (10, 2000)], "fixed_commitment": None, } +@pytest.fixture +def renewable_generator_params(): + return { + "gen_name": "Testing_Renewable_Generator", + "bus": "bus5", + "p_min": 0, + "p_max": 200, + "p_cost": 0, + "fixed_commitment": None, + } + + @pytest.fixture def generator_data_object(generator_params): - return GeneratorModelData(**generator_params) + return ThermalGeneratorModelData(**generator_params) + + +@pytest.fixture +def renewable_generator_data_object(renewable_generator_params): + return RenewableGeneratorModelData(**renewable_generator_params) @pytest.mark.unit -def test_create_model_data_object(generator_params, generator_data_object): +def test_create_thermal_model_data_object(generator_params, generator_data_object): # test scalar values for param_name in generator_params: @@ -60,6 +81,18 @@ def test_create_model_data_object(generator_params, generator_data_object): pyo_unittest.assertStructuredAlmostEqual(first=bids, second=expected_bid) +@pytest.mark.unit +def test_create_renewable_model_data_object( + renewable_generator_params, renewable_generator_data_object +): + + for name, value in renewable_generator_data_object: + if name == "generator_type": + assert value == "renewable" + else: + assert renewable_generator_params[name] == value + + @pytest.mark.unit @pytest.mark.parametrize( "param_name, value", @@ -79,7 +112,7 @@ def test_create_model_data_with_non_real_numbers(param_name, value, generator_pa with pytest.raises( TypeError, match=f"Value for {param_name} shoulde be real numbers." ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit @@ -96,7 +129,7 @@ def test_create_model_data_with_non_real_numbers(param_name, value, generator_pa def test_create_model_data_with_negative_values(param_name, value, generator_params): generator_params[param_name] = value with pytest.raises(ValueError, match="Value should be greater than or equal to 0."): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit @@ -111,7 +144,7 @@ def test_create_model_data_with_less_than_pmin_data( with pytest.raises( ValueError, match=f"Value for {param_name} shoulde be greater or equal to Pmin." ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit @@ -120,30 +153,21 @@ def test_invalid_fixed_commitment_value(generator_params): with pytest.raises( ValueError, match=r"^(Value for generator fixed commitment must be one of)" ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit def test_invalid_generator_name(generator_params): generator_params["gen_name"] = 102_111 with pytest.raises(TypeError, match=r".*gen_name must be str.*"): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit def test_invalid_bus_name(generator_params): generator_params["bus"] = 102 with pytest.raises(TypeError, match=r".*bus must be str.*"): - GeneratorModelData(**generator_params) - - -@pytest.mark.unit -def test_invalid_generator_type(generator_params): - generator_params["generator_type"] = "nuclear" - with pytest.raises( - ValueError, match=r"^(Value for generator types must be one of)" - ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit @@ -155,7 +179,7 @@ def test_empty_bid_pairs(param_name, generator_params): with pytest.raises( ValueError, match=r"Empty production|startup cost pairs are provided." ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit @@ -166,7 +190,7 @@ def test_bid_missing_pmin(generator_params): with pytest.raises( ValueError, match=r"^(The first power output in the bid should be the Pmin)" ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit @@ -175,7 +199,7 @@ def test_bid_missing_pmax(generator_params): with pytest.raises( ValueError, match=r"^(The last power output in the bid should be the Pmax)" ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) @pytest.mark.unit @@ -185,4 +209,74 @@ def test_invalid_start_up_bid(generator_params): ValueError, match=r"^(The first startup lag should be the same as minimum down time)", ): - GeneratorModelData(**generator_params) + ThermalGeneratorModelData(**generator_params) + + +@pytest.mark.unit +def test_model_data_iterator(generator_data_object): + + expected_param_names = [ + "gen_name", + "bus", + "p_min", + "p_max", + "min_down_time", + "min_up_time", + "ramp_up_60min", + "ramp_down_60min", + "shutdown_capacity", + "startup_capacity", + "p_cost", + "startup_cost", + "fixed_commitment", + "generator_type", + "initial_status", + "initial_p_output", + ] + iter_result = [name for name, value in generator_data_object] + + expected_param_names.sort() + iter_result.sort() + + pyo_unittest.assertStructuredAlmostEqual( + first=expected_param_names, second=iter_result + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "value, error, msg", + [ + ("1", TypeError, "Value for initial_status shoulde be real numbers"), + (0, ValueError, "Value for initial_status cannot be zero"), + ], +) +def test_invalid_initial_status(value, error, msg, generator_params): + + generator_params["initial_status"] = value + with pytest.raises(error, match=msg): + ThermalGeneratorModelData(**generator_params) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "initial_status, initial_p_output, error, msg", + [ + (9, "1", TypeError, "Value for initial_p_output shoulde be real numbers"), + ( + 9, + 29, + ValueError, + r".*so the initial power output should at least be p_min.*", + ), + (-9, 30, ValueError, r".*so the initial power output should at 0.*"), + ], +) +def test_invalid_initial_p_output( + initial_status, initial_p_output, error, msg, generator_params +): + + generator_params["initial_p_output"] = initial_p_output + generator_params["initial_status"] = initial_status + with pytest.raises(error, match=msg): + ThermalGeneratorModelData(**generator_params) diff --git a/idaes/apps/grid_integration/tests/test_tracker.py b/idaes/apps/grid_integration/tests/test_tracker.py index ac925fd054..58254b44b8 100644 --- a/idaes/apps/grid_integration/tests/test_tracker.py +++ b/idaes/apps/grid_integration/tests/test_tracker.py @@ -182,3 +182,18 @@ def test_track_market_dispatch(tracker_object): pytest.approx(tracker_object.get_last_delivered_power(), abs=1e-3) == last_delivered_power ) + + +@pytest.mark.component +def test_track_deviation_penalty(tracker_object): + + large_penalty = 10000 + + assert pytest.approx(large_penalty) == pyo.value( + tracker_object.model.deviation_penalty[0] + ) + + for t in range(1, horizon): + assert pytest.approx( + large_penalty / (horizon - tracker_object.n_tracking_hour) + ) == pyo.value(tracker_object.model.deviation_penalty[t]) diff --git a/idaes/apps/grid_integration/tests/util.py b/idaes/apps/grid_integration/tests/util.py index 69e0463203..f760500c26 100644 --- a/idaes/apps/grid_integration/tests/util.py +++ b/idaes/apps/grid_integration/tests/util.py @@ -17,7 +17,10 @@ from idaes.apps.grid_integration import Bidder, SelfScheduler from idaes.apps.grid_integration import DoubleLoopCoordinator from idaes.apps.grid_integration.forecaster import AbstractPrescientPriceForecaster -from idaes.apps.grid_integration.model_data import GeneratorModelData +from idaes.apps.grid_integration.model_data import ( + GeneratorModelData, + ThermalGeneratorModelData, +) class TestingModel: @@ -42,8 +45,10 @@ def __init__(self, model_data): None """ - if not isinstance(model_data, GeneratorModelData): - raise TypeError(f"model_data must be an instance of GeneratorModelData.") + if not isinstance(model_data, ThermalGeneratorModelData): + raise TypeError( + f"model_data must be an instance of ThermalGeneratorModelData." + ) self._model_data = model_data self.generator = self.model_data.gen_name @@ -299,7 +304,6 @@ def fetch_day_ahead_stats_from_prescient(self, uc_date, uc_hour, day_ahead_resul testing_generator_params = { "gen_name": "10_STEAM", "bus": "bus5", - "generator_type": "thermal", "p_min": 30, "p_max": 76, "min_down_time": 4, @@ -308,6 +312,8 @@ def fetch_day_ahead_stats_from_prescient(self, uc_date, uc_hour, day_ahead_resul "ramp_down_60min": 120, "shutdown_capacity": 30, "startup_capacity": 30, + "initial_status": 9, + "initial_p_output": 30, "production_cost_bid_pairs": [ (30, 30), (45.3, 30), @@ -318,7 +324,7 @@ def fetch_day_ahead_stats_from_prescient(self, uc_date, uc_hour, day_ahead_resul "fixed_commitment": None, } -testing_model_data = GeneratorModelData(**testing_generator_params) +testing_model_data = ThermalGeneratorModelData(**testing_generator_params) tracking_horizon = 4 day_ahead_bidding_horizon = 48 real_time_bidding_horizon = 4 diff --git a/idaes/apps/grid_integration/tracker.py b/idaes/apps/grid_integration/tracker.py index 68b259f386..abf32bd88e 100644 --- a/idaes/apps/grid_integration/tracker.py +++ b/idaes/apps/grid_integration/tracker.py @@ -210,7 +210,19 @@ def _add_tracking_params(self): self.time_set, initialize=0, within=pyo.Reals, mutable=True ) - self.model.deviation_penalty = pyo.Param(initialize=10000, mutable=False) + large_penalty = 10000 + penalty_init = {} + for t in self.time_set: + if t < self.n_tracking_hour: + penalty_init[t] = large_penalty + else: + penalty_init[t] = large_penalty / ( + self.tracking_horizon - self.n_tracking_hour + ) + self.model.deviation_penalty = pyo.Param( + self.time_set, initialize=penalty_init, mutable=False + ) + return def _add_tracking_constraints(self): @@ -260,9 +272,9 @@ def _add_tracking_objective(self): weight = self.tracking_model_object.total_cost[1] for t in self.time_set: - self.model.obj.expr += weight * cost[t] + self.model.deviation_penalty * ( - self.model.power_underdelivered[t] + self.model.power_overdelivered[t] - ) + self.model.obj.expr += weight * cost[t] + self.model.deviation_penalty[ + t + ] * (self.model.power_underdelivered[t] + self.model.power_overdelivered[t]) return