From 5f87123fe32025bd5f8702b42842c029c94423e8 Mon Sep 17 00:00:00 2001 From: Johanna Adams Date: Mon, 25 Sep 2023 17:05:09 +0200 Subject: [PATCH] Correct cost calculations and storage operation --- assume/common/base.py | 82 ++++++- assume/common/forecasts.py | 2 + assume/common/units_operator.py | 9 +- assume/strategies/flexable.py | 151 +++++++++--- assume/strategies/flexable_storage.py | 116 ++++++--- assume/strategies/learning_strategies.py | 11 +- assume/strategies/storage_strategies.py | 286 ----------------------- assume/units/demand.py | 3 +- assume/units/powerplant.py | 57 +++-- assume/units/storage.py | 87 +++---- tests/test_powerplant.py | 8 +- tests/test_storage.py | 28 ++- 12 files changed, 386 insertions(+), 454 deletions(-) delete mode 100644 assume/strategies/storage_strategies.py diff --git a/assume/common/base.py b/assume/common/base.py index 5627eb66..55d5a2e6 100644 --- a/assume/common/base.py +++ b/assume/common/base.py @@ -225,6 +225,17 @@ def calculate_cashflow(self, product_type: str, orderbook: Orderbook): cashflow * hours ) + def get_starting_costs(self, op_time: int): + """ + op_time is hours running from get_operation_time + returns the costs if start_up is planned + :param op_time: operation time + :type op_time: int + :return: start_costs + :rtype: float + """ + return 0 + class SupportsMinMax(BaseUnit): """ @@ -368,6 +379,75 @@ def get_operation_time(self, start: datetime): runn += 1 return (-1) ** is_off * runn + def get_average_operation_times(self, start: datetime): + """ + calculates the average uninterupted operation time + :param start: the current time + :type start: datetime + :return: avg_op_time + :rtype: float + :return: avg_down_time + :rtype: float + """ + op_series = [] + + before = start - self.index.freq + arr = self.outputs["energy"][self.index[0] : before][::-1] > 0 + + if len(arr) < 1: + # before start of index + return self.min_operating_time, self.min_down_time + + op_series = [] + status = arr.iloc[0] + runn = 0 + for val in arr: + if val == status: + runn += 1 + else: + op_series.append(-((-1) ** status) * runn) + runn = 0 + status = val + op_series.append(-((-1) ** status) * runn) + + op_times = [operation for operation in op_series if operation > 0] + if op_times == []: + avg_op_time = self.min_operating_time + else: + avg_op_time = sum(op_times) / len(op_times) + + down_times = [operation for operation in op_series if operation < 0] + if down_times == []: + avg_down_time = self.min_down_time + else: + avg_down_time = abs(sum(down_times) / len(down_times)) + + return max(1, avg_op_time), max(1, avg_down_time) + + def get_starting_costs(self, op_time: int): + """ + op_time is hours running from get_operation_time + returns the costs if start_up is planned + :param op_time: operation time + :type op_time: int + :return: start_costs + :rtype: float + """ + if op_time > 0: + # unit is running + return 0 + + if self.downtime_hot_start is not None and self.hot_start_cost is not None: + if -op_time <= self.downtime_hot_start: + return self.hot_start_cost + if self.downtime_warm_start is not None and self.warm_start_cost is not None: + if -op_time <= self.downtime_warm_start: + return self.warm_start_cost + if self.cold_start_cost is not None: + return self.cold_start_cost + + return 0 + class SupportsMinMaxCharge(BaseUnit): """ @@ -473,7 +553,7 @@ def get_soc_before(self, dt: datetime) -> float: :return: the SoC before the given datetime :rtype: float """ - if dt - self.index.freq < self.index[0]: + if dt - self.index.freq <= self.index[0]: return self.initial_soc else: return self.outputs["soc"].at[dt - self.index.freq] diff --git a/assume/common/forecasts.py b/assume/common/forecasts.py index 496c8199..fd19f416 100644 --- a/assume/common/forecasts.py +++ b/assume/common/forecasts.py @@ -330,6 +330,8 @@ def __getitem__(self, column: str) -> pd.Series: value = self.demand elif column == "price_forecast": value = self.price_forecast + elif column == "price_EOM": + value = self.price_forecast else: value = 0 if isinstance(value, pd.Series): diff --git a/assume/common/units_operator.py b/assume/common/units_operator.py index 98ac0fd0..8e2cc6f8 100644 --- a/assume/common/units_operator.py +++ b/assume/common/units_operator.py @@ -220,12 +220,13 @@ def write_actual_dispatch(self): current_dispatch.name = "power" data = pd.DataFrame(current_dispatch) data["soc"] = unit.outputs["soc"][start:end] - # TODO make that right for all products + for key in unit.outputs.keys(): - if "energy_cashflow" in key: + if "cashflow" in key: data[key] = unit.outputs[key][start:end] - - if "energy_marginal_costs" in key: + if "marginal_costs" in key: + data[key] = unit.outputs[key][start:end] + if "total_costs" in key: data[key] = unit.outputs[key][start:end] data["unit"] = unit_id diff --git a/assume/strategies/flexable.py b/assume/strategies/flexable.py index 472770d7..ddd3f593 100644 --- a/assume/strategies/flexable.py +++ b/assume/strategies/flexable.py @@ -48,6 +48,9 @@ def calculate_bids( min_power, max_power = unit.calculate_min_max_power(start, end) bids = [] + op_time = unit.get_operation_time(start) + avg_op_time, avg_down_time = unit.get_average_operation_times(start) + for product in product_tuples: bid_quantity_inflex, bid_price_inflex = 0, 0 bid_quantity_flex, bid_price_flex = 0, 0 @@ -83,17 +86,23 @@ def calculate_bids( # ============================================================================= # Calculating possible price # ============================================================================= - if unit.get_operation_time(start) > 0: + if op_time > 0: bid_price_inflex = calculate_EOM_price_if_on( unit, start, - marginal_cost_inflex, + marginal_cost_flex, bid_quantity_inflex, self.foresight, + avg_down_time, ) else: bid_price_inflex = calculate_EOM_price_if_off( - unit, start, marginal_cost_flex, bid_quantity_inflex + unit, + start, + marginal_cost_flex, + bid_quantity_inflex, + op_time, + avg_op_time, ) if unit.outputs["heat"][start] > 0: @@ -104,10 +113,7 @@ def calculate_bids( power_loss_ratio = 0.0 # Flex-bid price formulation - if ( - unit.get_operation_time(start) <= -unit.min_down_time - or unit.get_operation_time(start) > 0 - ): + if op_time <= -unit.min_down_time or op_time > 0: bid_quantity_flex = max_power[start] - bid_quantity_inflex bid_price_flex = (1 - power_loss_ratio) * marginal_cost_flex @@ -131,9 +137,27 @@ def calculate_bids( ) # calculate previous power with planned dispatch (bid_quantity) previous_power = bid_quantity_inflex + bid_quantity_flex + current_power + if previous_power > 0: + op_time = max(op_time, 0) + 1 + else: + op_time = min(op_time, 0) - 1 return bids + def calculate_reward( + self, + unit, + marketconfig: MarketConfig, + orderbook: Orderbook, + ): + # TODO: Calculate profits over all markets + + calculate_reward_EOM( + unit=unit, + marketconfig=marketconfig, + orderbook=orderbook, + ) + class flexableEOMBlock(BaseStrategy): """ @@ -178,6 +202,8 @@ def calculate_bids( bids = [] bid_quantity_block = {} bid_price_block = [] + op_time = unit.get_operation_time(start) + avg_op_time, avg_down_time = unit.get_average_operation_times(start) for product in product_tuples: bid_quantity_flex, bid_price_flex = 0, 0 @@ -188,11 +214,11 @@ def calculate_bids( current_power = unit.outputs["energy"].at[start] - # adjust for ramp down speed + # adjust for ramp speed max_power[start] = unit.calculate_ramp( previous_power, max_power[start], current_power ) - # adjust for ramp up speed + # adjust for ramp speed min_power[start] = unit.calculate_ramp( previous_power, min_power[start], current_power ) @@ -214,17 +240,23 @@ def calculate_bids( # ============================================================================= # Calculating possible price # ============================================================================= - if unit.get_operation_time(start) > 0: + if op_time > 0: bid_price_inflex = calculate_EOM_price_if_on( unit, start, - marginal_cost_inflex, + marginal_cost_flex, bid_quantity_inflex, self.foresight, + avg_down_time, ) else: bid_price_inflex = calculate_EOM_price_if_off( - unit, start, marginal_cost_flex, bid_quantity_inflex + unit, + start, + marginal_cost_inflex, + bid_quantity_inflex, + op_time, + avg_op_time, ) if unit.outputs["heat"][start] > 0: @@ -240,9 +272,7 @@ def calculate_bids( or unit.get_operation_time(start) > 0 ): bid_quantity_flex = max_power[start] - bid_quantity_inflex - bid_price_flex = ( - 1 - power_loss_ratio - ) * marginal_cost_flex + bid_price_inflex # hihger price than inflex + bid_price_flex = (1 - power_loss_ratio) * marginal_cost_flex bid_quantity_block[product[0]] = bid_quantity_inflex bid_price_block.append(bid_price_inflex) @@ -259,6 +289,10 @@ def calculate_bids( ) # calculate previous power with planned dispatch (bid_quantity) previous_power = bid_quantity_inflex + bid_quantity_flex + current_power + if previous_power > 0: + op_time = max(op_time, 0) + 1 + else: + op_time = min(op_time, 0) - 1 bids.append( { @@ -275,6 +309,20 @@ def calculate_bids( return bids + def calculate_reward( + self, + unit, + marketconfig: MarketConfig, + orderbook: Orderbook, + ): + # TODO: Calculate profits over all markets + + calculate_reward_EOM( + unit=unit, + marketconfig=marketconfig, + orderbook=orderbook, + ) + class flexablePosCRM(BaseStrategy): """ @@ -477,6 +525,8 @@ def calculate_EOM_price_if_off( start, marginal_cost_inflex, bid_quantity_inflex, + op_time, + avg_op_time=1, ): """ The powerplant is currently off and calculates a startup markup as an extra @@ -492,17 +542,14 @@ def calculate_EOM_price_if_off( :return: The bid price of the unit :rtype: float """ - av_operating_time = max((unit.outputs["energy"][:start] > 0).mean(), 1) - # 1 prevents division by 0 - op_time = unit.get_operation_time(start) starting_cost = unit.get_starting_costs(op_time) # if we split starting_cost across av_operating_time # we are never adding the other parts of the cost to the following hours if bid_quantity_inflex == 0: - markup = starting_cost / av_operating_time + markup = starting_cost / avg_op_time else: - markup = starting_cost / av_operating_time / bid_quantity_inflex + markup = starting_cost / avg_op_time / bid_quantity_inflex bid_price_inflex = min(marginal_cost_inflex + markup, 3000.0) @@ -512,12 +559,13 @@ def calculate_EOM_price_if_off( def calculate_EOM_price_if_on( unit: SupportsMinMax, start, - marginal_cost_inflex, + marginal_cost_flex, bid_quantity_inflex, foresight, + avg_down_time=1, ): """ - Check the description provided by Thomas in last version, the average downtime is not available + Check the description provided by Thomas in last version, the average downtime is available here The powerplant is currently on :param unit: A unit that the unit operator manages @@ -535,11 +583,11 @@ def calculate_EOM_price_if_on( return 0 t = start - op_time = unit.get_operation_time(start) + # TODO is it correct to bill for cold, hot and warm starts in one start? - starting_cost = unit.get_starting_costs(op_time) + starting_cost = unit.get_starting_costs(-max(avg_down_time, 1)) - price_reduction_restart = starting_cost / unit.min_down_time / bid_quantity_inflex + price_reduction_restart = starting_cost / avg_down_time / bid_quantity_inflex if unit.outputs["heat"][t] > 0: heat_gen_cost = ( @@ -551,18 +599,15 @@ def calculate_EOM_price_if_on( possible_revenue = get_specific_revenue( unit=unit, - marginal_cost=marginal_cost_inflex, + marginal_cost=marginal_cost_flex, t=start, foresight=foresight, ) - if ( - possible_revenue >= 0 - and unit.forecaster["price_forecast"][t] < marginal_cost_inflex - ): - marginal_cost_inflex = 0 + if possible_revenue >= 0 and unit.forecaster["price_EOM"][t] < marginal_cost_flex: + marginal_cost_flex = 0 bid_price_inflex = max( - -price_reduction_restart - heat_gen_cost + marginal_cost_inflex, + -price_reduction_restart - heat_gen_cost + marginal_cost_flex, -499.00, ) @@ -608,11 +653,47 @@ def get_specific_revenue( """ price_forecast = [] - if t + foresight > unit.forecaster["price_forecast"].index[-1]: - price_forecast = unit.forecaster["price_forecast"][t:] + if t + foresight > unit.forecaster["price_EOM"].index[-1]: + price_forecast = unit.forecaster["price_EOM"][t:] else: - price_forecast = unit.forecaster["price_forecast"][t : t + foresight] + price_forecast = unit.forecaster["price_EOM"][t : t + foresight] possible_revenue = (price_forecast - marginal_cost).sum() return possible_revenue + + +def calculate_reward_EOM( + unit, + marketconfig: MarketConfig, + orderbook: Orderbook, +): + """ + Calculate reward (costs and profit) + :param unit: Unit to calculate reward for + :type unit: SupportsMinMax + :param marketconfig: Market configuration + :type marketconfig: MarketConfig + :param orderbook: Orderbook + :type orderbook: Orderbook + """ + # TODO: Calculate profits over all markets + product_type = marketconfig.product_type + + for order in orderbook: + start = order["start_time"] + end = order["end_time"] + end_excl = end - unit.index.freq + index = pd.date_range(start, end_excl, freq=unit.index.freq) + costs = pd.Series(unit.fixed_cost, index=index) + for start in index: + if unit.outputs[product_type][start] != 0: + op_time = unit.get_operation_time(start - unit.index.freq) + costs[start] += unit.get_starting_costs(op_time) + costs[start] += unit.outputs[product_type][ + start + ] * unit.calculate_marginal_cost( + start, unit.outputs[product_type][start] + ) + + unit.outputs["total_costs"][index] = costs diff --git a/assume/strategies/flexable_storage.py b/assume/strategies/flexable_storage.py index ba8e45f1..0931b995 100644 --- a/assume/strategies/flexable_storage.py +++ b/assume/strategies/flexable_storage.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import numpy as np import pandas as pd @@ -44,14 +46,7 @@ def calculate_bids( previous_power = unit.get_output_before(start) - # get the dispatched power from last day - dispatch = np.where( - unit.outputs["energy"] > 0, - (unit.outputs["energy"] / unit.efficiency_discharge), - (unit.outputs["energy"] * unit.efficiency_charge), - ) - soc = unit.get_soc_before(start) - theoretic_SOC = soc - ((dispatch / unit.max_volume)).sum() + theoretic_SOC = unit.outputs["soc"][start] min_power_charge, max_power_charge = unit.calculate_min_max_charge( start, end_all @@ -75,7 +70,7 @@ def calculate_bids( previous_power, max_power_discharge[start], current_power_discharge, - min_power_discharge.iloc[0], + min_power_discharge[start], ) min_power_discharge[start] = unit.calculate_ramp_discharge( theoretic_SOC, @@ -99,7 +94,7 @@ def calculate_bids( min_power_charge[start], ) - price_forecast = unit.forecaster["price_forecast"] + price_forecast = unit.forecaster["price_EOM"] average_price = calculate_price_average( unit=unit, @@ -113,6 +108,7 @@ def calculate_bids( elif price_forecast[start] <= average_price * unit.efficiency_charge: bid_quantity = max_power_charge[start] else: + previous_power = current_power continue bids.append( @@ -124,17 +120,64 @@ def calculate_bids( "volume": bid_quantity, } ) - previous_power = bid_quantity + current_power - if previous_power > 0: - theoretic_SOC -= ( - bid_quantity + current_power - ) / unit.efficiency_discharge - elif previous_power < 0: - theoretic_SOC -= (bid_quantity + current_power) * unit.efficiency_charge + time_delta = (end - start) / timedelta(hours=1) + if bid_quantity + current_power > 0: + delta_soc = -( + (bid_quantity + current_power) + * time_delta + / unit.efficiency_discharge + / unit.max_volume + ) + elif bid_quantity + current_power < 0: + delta_soc = -( + (bid_quantity + current_power) + * time_delta + * unit.efficiency_charge + / unit.max_volume + ) + else: + delta_soc = 0 + + theoretic_SOC += delta_soc + previous_power = bid_quantity + current_power return bids + def calculate_reward( + self, + unit, + marketconfig: MarketConfig, + orderbook: Orderbook, + ): + """ + Calculate reward (costs) + :param unit: Unit to calculate reward for + :type unit: SupportsMinMax + :param marketconfig: Market configuration + :type marketconfig: MarketConfig + :param orderbook: Orderbook + :type orderbook: Orderbook + """ + # TODO: Calculate profits over all markets + product_type = marketconfig.product_type + + for order in orderbook: + start = order["start_time"] + end = order["end_time"] + end_excl = end - unit.index.freq + index = pd.date_range(start, end_excl, freq=unit.index.freq) + costs = pd.Series(unit.fixed_cost, index=index) + for start in index: + if unit.outputs[product_type][start] != 0: + costs[start] += unit.outputs[product_type][ + start + ] * unit.calculate_marginal_cost( + start, unit.outputs[product_type][start] + ) + + unit.outputs["total_costs"][index] = costs + class flexablePosCRMStorage(BaseStrategy): def __init__(self, *args, **kwargs): @@ -175,13 +218,13 @@ def calculate_bids( start, end ) bids = [] + theoretic_SOC = unit.outputs["soc"][start] for product in product_tuples: start = product[0] current_power = unit.outputs["energy"].at[start] - soc = unit.get_soc_before(start) bid_quantity = unit.calculate_ramp_discharge( - soc, + theoretic_SOC, previous_power, max_power_discharge[start], current_power, @@ -199,7 +242,7 @@ def calculate_bids( marginal_cost=marginal_cost, current_time=start, foresight=self.foresight, - price_forecast=unit.forecaster["price_forecast"], + price_forecast=unit.forecaster["price_EOM"], ) if specific_revenue >= 0: @@ -208,8 +251,8 @@ def calculate_bids( capacity_price = ( abs(specific_revenue) * unit.min_power_discharge / bid_quantity ) - soc = unit.get_soc_before(start) - energy_price = capacity_price / (soc * unit.max_volume) + + energy_price = capacity_price / (theoretic_SOC * unit.max_volume) if market_config.product_type == "capacity_pos": bids.append( @@ -221,6 +264,7 @@ def calculate_bids( "volume": bid_quantity, } ) + previous_power = current_power elif market_config.product_type == "energy_pos": bids.append( { @@ -231,11 +275,20 @@ def calculate_bids( "volume": bid_quantity, } ) + time_delta = (end - start) / timedelta(hours=1) + delta_soc = -( + (bid_quantity + current_power) + * time_delta + / unit.efficiency_discharge + / unit.max_volume + ) + theoretic_SOC += delta_soc + previous_power = bid_quantity + current_power else: + previous_power = current_power raise ValueError( f"Product {market_config.product_type} is not supported by this strategy." ) - previous_power = bid_quantity + current_power return bids @@ -272,7 +325,7 @@ def calculate_bids( end = product_tuples[-1][1] previous_power = unit.get_output_before(start) - soc = unit.get_soc_before(start) + theoretic_SOC = unit.outputs["soc"][start] min_power_charge, max_power_charge = unit.calculate_min_max_charge(start, end) @@ -281,7 +334,7 @@ def calculate_bids( start = product[0] current_power = unit.outputs["energy"].at[start] bid_quantity = unit.calculate_ramp_charge( - soc, + theoretic_SOC, previous_power, max_power_charge[start], current_power, @@ -289,6 +342,7 @@ def calculate_bids( # if bid_quantity >= min_bid_volume --> not checked here if bid_quantity == 0: + previous_power = current_power continue if market_config.product_type == "capacity_neg": @@ -301,6 +355,7 @@ def calculate_bids( "volume": bid_quantity, } ) + previous_power = current_power elif market_config.product_type == "energy_neg": bids.append( { @@ -311,11 +366,20 @@ def calculate_bids( "volume": bid_quantity, } ) + time_delta = (end - start) / timedelta(hours=1) + delta_soc = ( + (bid_quantity + current_power) + * time_delta + * unit.efficiency_charge + / unit.max_volume + ) + theoretic_SOC += delta_soc + previous_power = bid_quantity + current_power else: + previous_power = current_power raise ValueError( f"Product {market_config.product_type} is not supported by this strategy." ) - previous_power = current_power + bid_quantity return bids diff --git a/assume/strategies/learning_strategies.py b/assume/strategies/learning_strategies.py index f32a0939..d1f52187 100644 --- a/assume/strategies/learning_strategies.py +++ b/assume/strategies/learning_strategies.py @@ -271,15 +271,14 @@ def create_observation( / scaling_factor_res_load ) - if end_excl + forecast_len > unit.forecaster["price_forecast"].index[-1]: + if end_excl + forecast_len > unit.forecaster["price_EOM"].index[-1]: scaled_price_forecast = ( - unit.forecaster["price_forecast"].loc[start:].values - / scaling_factor_price + unit.forecaster["price_EOM"].loc[start:].values / scaling_factor_price ) scaled_price_forecast = np.concatenate( [ scaled_price_forecast, - unit.forecaster["price_forecast"].iloc[ + unit.forecaster["price_EOM"].iloc[ : self.foresight - len(scaled_price_forecast) ], ] @@ -287,9 +286,7 @@ def create_observation( else: scaled_price_forecast = ( - unit.forecaster["price_forecast"] - .loc[start : end_excl + forecast_len] - .values + unit.forecaster["price_EOM"].loc[start : end_excl + forecast_len].values / scaling_factor_price ) diff --git a/assume/strategies/storage_strategies.py b/assume/strategies/storage_strategies.py deleted file mode 100644 index 1d3be600..00000000 --- a/assume/strategies/storage_strategies.py +++ /dev/null @@ -1,286 +0,0 @@ -from datetime import datetime - -import numpy as np -import pandas as pd - -from assume.common.base import BaseStrategy, SupportsMinMaxCharge -from assume.common.market_objects import MarketConfig, Orderbook, Product - - -class complexEOMStorage(BaseStrategy): - """ - complexEOMStorage is a strategy for storage units that are able to charge and discharge (Energy Only Market) - """ - - def __init__(self): - """ - :param foresight: [description], defaults to pd.Timedelta("12h") - :type foresight: [type], optional - """ - super().__init__() - - self.foresight = pd.Timedelta("12h") - - def calculate_bids( - self, - unit: SupportsMinMaxCharge, - market_config: MarketConfig, - product_tuples: list[Product], - **kwargs, - ) -> Orderbook: - """ - Takes information from a unit that the unit operator manages and - defines how it is dispatched to the market - - :param unit: the unit to be dispatched - :type unit: SupportsMinMax - :param market_config: the market configuration - :type market_config: MarketConfig - :param product_tuples: list of all products the unit can offer - :type product_tuples: list[Product] - :return: the bids consisting of the start time, end time, only hours, price and volume. - :rtype: Orderbook - - Strategy analogue to flexABLE - """ - product = product_tuples[0] - start = product[0] - end = product[1] - - min_power_charge, max_power_charge = unit.calculate_min_max_charge(start, end) - min_power_discharge, max_power_discharge = unit.calculate_min_max_discharge( - start, end - ) - - # ============================================================================= - # Storage Unit is either charging, discharging, or off - # ============================================================================= - bid_quantity_mr_discharge = min_power_discharge - bid_quantity_flex_discharge = max_power_discharge - min_power_discharge - bid_quantity_mr_charge = min_power_charge - bid_quantity_flex_charge = max_power_charge - min_power_charge - - cost_mr_discharge = unit.calculate_marginal_cost(start, min_power_discharge) - cost_flex_discharge = unit.calculate_marginal_cost(start, max_power_discharge) - cost_mr_charge = unit.calculate_marginal_cost(start, min_power_charge) - cost_flex_charge = unit.calculate_marginal_cost(start, max_power_charge) - - average_price = self.calculate_price_average(unit) - - previous_power = unit.get_output_before(start) - - price_forecast = unit.forecaster["price_EOM"][t : t + self.foresight] - - if price_forecast[start] >= average_price / unit.efficiency_discharge: - # place bid to discharge - if previous_power > 0: - # was discharging before - bid_price_mr = self.calculate_EOM_price_continue_discharging( - start, unit, cost_mr_discharge, bid_quantity_mr_discharge - ) - bid_quantity_mr = bid_quantity_mr_discharge - bid_price_flex = cost_flex_discharge - bid_quantity_flex = bid_quantity_flex_discharge - - elif previous_power < 0: - # was charging before - if unit.min_down_time > 0: - bid_quantity_mr = 0 - bid_price_mr = 0 - - else: - bid_price_mr = self.calculate_EOM_price_if_off( - unit, - cost_flex_discharge, - bid_quantity_mr_discharge, - ) - bid_quantity_mr = bid_quantity_mr_discharge - bid_price_flex = cost_flex_discharge - bid_quantity_flex = bid_quantity_flex_discharge - else: - bid_price_mr = 0 - bid_quantity_mr = 0 - bid_price_flex = 0 - bid_quantity_flex = 0 - - elif price_forecast[start] <= average_price * unit.efficiency_charge: - # place bid to charge - if previous_power > 0: - # was discharging before - if unit.min_down_time > 0: - bid_quantity_mr = 0 - bid_price_mr = 0 - else: - bid_price_mr = self.calculate_EOM_price_if_off( - unit, cost_mr_charge, bid_quantity_mr_charge - ) - bid_quantity_mr = bid_quantity_mr_charge - bid_price_flex = cost_flex_charge - bid_quantity_flex = bid_quantity_flex_charge - - elif previous_power < 0: - # was charging before - bid_price_mr = bid_quantity_mr_charge - bid_quantity_mr = cost_mr_charge - bid_price_flex = cost_flex_charge - bid_quantity_flex = bid_quantity_flex_charge - else: - bid_price_mr = 0 - bid_quantity_mr = 0 - bid_price_flex = 0 - bid_quantity_flex = 0 - - else: - bid_price_mr = 0 - bid_quantity_mr = 0 - bid_price_flex = 0 - bid_quantity_flex = 0 - - bids = [ - { - "start_time": product[0], - "end_time": product[1], - "only_hours": product[2], - "price": bid_price_mr, - "volume": bid_quantity_mr, - }, - { - "start_time": product[0], - "end_time": product[1], - "only_hours": product[2], - "price": bid_price_flex, - "volume": bid_quantity_flex, - }, - ] - return bids - - def calculate_price_average(self, unit: SupportsMinMaxCharge, t: datetime): - """ - Calculates the average price for the next 12 hours - Returns the average price - - :param unit: the unit to be dispatched - :type unit: SupportsMinMax - :param t: the current time - :type t: datetime - :return: the average price - :rtype: float - """ - average_price = np.mean( - unit.forecaster["price_EOM"][t - self.foresight : t + self.foresight] - ) - - return average_price - - def calculate_EOM_price_if_off(self, unit, marginal_cost_mr, bid_quantity_mr): - """ - Calculates the bid price if the unit is off - Returns the bid price - - :param unit: the unit to be dispatched - :type unit: SupportsMinMax - :param marginal_cost_mr: the marginal cost of the unit - :type marginal_cost_mr: float - :param bid_quantity_mr: the bid quantity of the unit - :type bid_quantity_mr: float - :return: the bid price - :rtype: float - """ - av_operating_time = max((unit.outputs["energy"][:start] > 0).mean(), 1) - # 1 prevents division by 0 - - op_time = unit.get_operation_time(start) - starting_cost = unit.get_starting_costs(op_time) - markup = starting_cost / av_operating_time / bid_quantity_mr - - bid_price_mr = min(marginal_cost_mr + markup, 3000.0) - - return bid_price_mr - - def calculate_EOM_price_continue_discharging( - self, start, unit, marginal_cost_flex, bid_quantity_mr - ): - """ - Calculates the bid price if the unit is discharging - Returns the bid price - - :param start: the start time of the product - :type start: datetime - :param unit: the unit to be dispatched - :type unit: SupportsMinMax - :param marginal_cost_flex: the marginal cost of the unit - :type marginal_cost_flex: float - :param bid_quantity_mr: the bid quantity of the unit - :type bid_quantity_mr: float - :return: the bid price - :rtype: float - """ - if bid_quantity_mr == 0: - return 0 - - t = start - op_time = unit.get_operation_time(start) - starting_cost = unit.get_starting_costs(op_time) - - price_reduction_restart = starting_cost / unit.min_down_time / bid_quantity_mr - - possible_revenue = self.get_possible_revenues( - marginal_cost=marginal_cost_flex, - unit=unit, - t=start, - ) - if ( - possible_revenue >= 0 - and unit.forecaster["price_EOM"][t] < marginal_cost_flex - ): - marginal_cost_flex = 0 - - bid_price_mr = max( - -price_reduction_restart + marginal_cost_flex, - -2999.00, - ) - - return bid_price_mr - - def get_starting_costs(self, time, unit): - """ - get the starting costs of the unit - Returns the starting costs - - :param time: the time the unit is off - :type time: float - :param unit: the unit to be dispatched - :type unit: SupportsMinMax - :return: the starting costs - :rtype: float - """ - if time < unit.downtime_hot_start: - return unit.hot_start_cost - - elif time < unit.downtime_warm_start: - return unit.warm_start_cost - - else: - return unit.cold_start_cost - - def get_possible_revenues(self, marginal_cost, unit, t): - """ - get the possible revenues of the unit - Returns the possible revenues - - :param marginal_cost: the marginal cost of the unit - :type marginal_cost: float - :param unit: the unit to be dispatched - :type unit: SupportsMinMax - :param t: the current time - :type t: datetime - :return: the possible revenues - :rtype: float - """ - price_forecast = unit.forecaster["price_EOM"][t : t + self.foresight] - - possible_revenue = sum( - marketPrice - marginal_cost for marketPrice in price_forecast - ) - - return possible_revenue diff --git a/assume/units/demand.py b/assume/units/demand.py index 20e301dd..1cc83989 100644 --- a/assume/units/demand.py +++ b/assume/units/demand.py @@ -109,7 +109,8 @@ def calculate_min_max_power( :return: the bid volume as both the minimum and maximum power output of the unit :rtype: tuple[pd.Series, pd.Series] """ - bid_volume = (self.volume - self.outputs[product_type]).loc[start:end] + end_excl = end - self.index.freq + bid_volume = (self.volume - self.outputs[product_type]).loc[start:end_excl] return bid_volume, bid_volume def calculate_marginal_cost(self, start: pd.Timestamp, power: float) -> float: diff --git a/assume/units/powerplant.py b/assume/units/powerplant.py index df18ebb2..11f35126 100644 --- a/assume/units/powerplant.py +++ b/assume/units/powerplant.py @@ -77,6 +77,7 @@ def __init__( min_power: float = 0.0, efficiency: float = 1.0, fixed_cost: float = 0.0, + variable_cost: float | pd.Series = 0.0, partial_load_eff: bool = False, fuel_type: str = "others", emission_factor: float = 0.0, @@ -120,9 +121,12 @@ def __init__( self.downtime_hot_start = downtime_hot_start / ( self.index.freq / timedelta(hours=1) ) - self.downtime_warm_start = downtime_warm_start + self.downtime_warm_start = downtime_warm_start / ( + self.index.freq / timedelta(hours=1) + ) self.fixed_cost = fixed_cost + self.variable_cost = variable_cost self.hot_start_cost = hot_start_cost * max_power self.warm_start_cost = warm_start_cost * max_power self.cold_start_cost = cold_start_cost * max_power @@ -147,6 +151,7 @@ def execute_current_dispatch( ): """ Executes the current dispatch of the unit based on the provided timestamps. + The dispatch is only executed, if it is in the constraints given by the unit. Returns the volume of the unit within the given time range. :param start: the start time of the dispatch @@ -158,14 +163,24 @@ def execute_current_dispatch( """ end_excl = end - self.index.freq - # TODO ramp down and turn off only for relevant timesteps - if self.outputs["energy"][start:end_excl].mean() < self.min_power: - self.outputs["energy"].loc[start:end_excl] = 0 - - # TODO check if resulting power is < max_power - # if self.outputs["energy"][start:end_excl].max() > self.max_power: - # max_pow = self.outputs["energy"][start:end_excl].max() - # logger.error(f"{max_pow} greater than {self.max_power} - bidding twice?") + + max_power = ( + self.forecaster.get_availability(self.id)[start:end_excl] * self.max_power + ) + + for t in self.outputs["energy"][start:end_excl].index: + current_power = self.outputs["energy"][t] + previous_power = self.get_output_before(t) + + max_power_t = self.calculate_ramp(previous_power, max_power[t]) + min_power_t = self.calculate_ramp(previous_power, self.min_power) + + if current_power > max_power_t: + self.outputs["energy"][t] = max_power_t + + elif current_power < min_power_t and current_power > 0: + self.outputs["energy"][t] = 0 + return self.outputs["energy"].loc[start:end_excl] def calc_simple_marginal_cost( @@ -182,7 +197,7 @@ def calc_simple_marginal_cost( marginal_cost = ( fuel_price / self.efficiency + self.forecaster.get_price("co2") * self.emission_factor / self.efficiency - + self.fixed_cost + + self.variable_cost ) return marginal_cost @@ -241,10 +256,16 @@ def calc_marginal_cost_with_partial_eff( efficiency = self.efficiency - eta_loss co2_price = self.forecaster.get_price("co2").at[timestep] + variable_cost = ( + self.variable_cost + if isinstance(self.variable_cost, float) + else self.variable_cost[timestep] + ) + marginal_cost = ( fuel_price / efficiency + co2_price * self.emission_factor / efficiency - + self.fixed_cost + + variable_cost ) return marginal_cost @@ -332,17 +353,3 @@ def as_dict(self) -> dict: ) return unit_dict - - def get_starting_costs(self, op_time): - """ - op_time is hours running - """ - if op_time > 0: - # unit is running - return 0 - if -op_time < self.downtime_hot_start: - return self.hot_start_cost - elif -op_time < self.downtime_warm_start: - return self.warm_start_cost - else: - return self.cold_start_cost diff --git a/assume/units/storage.py b/assume/units/storage.py index 3d4c306c..df9cd32b 100644 --- a/assume/units/storage.py +++ b/assume/units/storage.py @@ -133,6 +133,7 @@ def __init__( self.max_power_discharge = max_power_discharge self.min_power_discharge = min_power_discharge self.initial_soc = initial_soc + self.outputs["soc"] = pd.Series(self.initial_soc, index=self.index, dtype=float) self.soc_tick = soc_tick @@ -179,7 +180,7 @@ def __init__( # The downtime before hot start of the storage unit. self.downtime_hot_start = downtime_hot_start # The downtime before warm start of the storage unit. - self.warm_start_cost = downtime_warm_start + self.downtime_warm_start = downtime_warm_start self.fixed_cost = fixed_cost @@ -200,45 +201,32 @@ def execute_current_dispatch(self, start: pd.Timestamp, end: pd.Timestamp): :rtype: pd.Series """ end_excl = end - self.index.freq + time_delta = self.index.freq / timedelta(hours=1) for t in self.outputs["energy"][start:end_excl].index: delta_soc = 0 - soc = self.get_soc_before(t) + soc = self.outputs["soc"][t] if self.outputs["energy"][t] > self.max_power_discharge: self.outputs["energy"][t] = self.max_power_discharge - logger.error( - f"The energy dispatched is greater the maximum power to discharge, dispatched amount is adjusted." - ) elif self.outputs["energy"][t] < self.max_power_charge: self.outputs["energy"][t] = self.max_power_charge - logger.error( - f"The energy dispatched is greater than the maximum power to charge, dispatched amount is adjusted." - ) elif ( self.outputs["energy"][t] < self.min_power_discharge and self.outputs["energy"][t] > self.min_power_charge and self.outputs["energy"][t] != 0 ): self.outputs["energy"][t] = 0 - logger.error( - f"The energy dispatched is between min_power_charge and min_power_discharge, no energy is dispatched" - ) # discharging if self.outputs["energy"][t] > 0: max_soc_discharge = self.calculate_soc_max_discharge(soc) if self.outputs["energy"][t] > max_soc_discharge: - if abs(self.outputs["energy"][t] - max_soc_discharge) > EPS: - logger.error( - f"The energy dispatched exceeds the minimum SOC significantly, the dispatched amount is adjusted." - ) self.outputs["energy"][t] = max_soc_discharge delta_soc = ( -self.outputs["energy"][t] - * self.index.freq - / timedelta(hours=1) + * time_delta / self.efficiency_discharge / self.max_volume ) @@ -248,21 +236,16 @@ def execute_current_dispatch(self, start: pd.Timestamp, end: pd.Timestamp): max_soc_charge = self.calculate_soc_max_charge(soc) if self.outputs["energy"][t] < max_soc_charge: - if abs(self.outputs["energy"][t] - max_soc_charge) > EPS: - logger.error( - f"The energy dispatched exceeds the maximum SOC, the dispatched amount is adjusted." - ) self.outputs["energy"][t] = max_soc_charge delta_soc = ( -self.outputs["energy"][t] - * self.index.freq - / timedelta(hours=1) + * time_delta * self.efficiency_charge / self.max_volume ) - self.outputs["soc"][t] = soc + delta_soc + self.outputs["soc"][t + self.index.freq :] = soc + delta_soc return self.outputs["energy"].loc[start:end_excl] @@ -288,32 +271,10 @@ def calculate_marginal_cost( ) efficiency = self.efficiency_charge - marginal_cost = variable_cost / efficiency + self.fixed_cost + marginal_cost = variable_cost / efficiency return marginal_cost - def as_dict(self) -> dict: - """ - Return the storage unit's attributes as a dictionary, including specific attributes. - - :return: the storage unit's attributes as a dictionary - :rtype: dict - """ - unit_dict = super().as_dict() - unit_dict.update( - { - "max_power_charge": self.max_power_charge, - "max_power_discharge": self.max_power_discharge, - "min_power_charge": self.min_power_charge, - "min_power_discharge": self.min_power_discharge, - "efficiency_charge": self.efficiency_discharge, - "efficiency_discharge": self.efficiency_charge, - "unit_type": "storage", - } - ) - - return unit_dict - def calculate_soc_max_discharge(self, soc) -> float: duration = self.index.freq / timedelta(hours=1) power = max( @@ -383,7 +344,7 @@ def calculate_min_max_charge( ) # restrict charging according to max_volume - max_soc_charge = self.calculate_soc_max_charge(self.get_soc_before(start)) + max_soc_charge = self.calculate_soc_max_charge(self.outputs["soc"][start]) max_power_charge = max_power_charge.clip(lower=max_soc_charge) return min_power_charge, max_power_charge @@ -430,7 +391,7 @@ def calculate_min_max_discharge( ) # restrict according to min_volume - max_soc_discharge = self.calculate_soc_max_discharge(self.get_soc_before(start)) + max_soc_discharge = self.calculate_soc_max_discharge(self.outputs["soc"][start]) max_power_discharge = max_power_discharge.clip(upper=max_soc_discharge) return min_power_discharge, max_power_discharge @@ -453,8 +414,7 @@ def calculate_ramp_discharge( # restrict according to min_SOC max_soc_discharge = self.calculate_soc_max_discharge(previous_soc) - if power_discharge > max_soc_discharge: - power_discharge = max_soc_discharge + power_discharge = min(power_discharge, max_soc_discharge) if power_discharge < min_power_discharge: power_discharge = 0 @@ -478,8 +438,7 @@ def calculate_ramp_charge( # restrict charging according to max_SOC max_soc_charge = self.calculate_soc_max_charge(previous_soc) - if power_charge < max_soc_charge: - power_charge = max_soc_charge + power_charge = max(power_charge, max_soc_charge) if power_charge > min_power_charge: power_charge = 0 @@ -498,3 +457,25 @@ def get_starting_costs(self, op_time): return self.warm_start_cost else: return self.cold_start_cost + + def as_dict(self) -> dict: + """ + Return the storage unit's attributes as a dictionary, including specific attributes. + + :return: the storage unit's attributes as a dictionary + :rtype: dict + """ + unit_dict = super().as_dict() + unit_dict.update( + { + "max_power_charge": self.max_power_charge, + "max_power_discharge": self.max_power_discharge, + "min_power_charge": self.min_power_charge, + "min_power_discharge": self.min_power_discharge, + "efficiency_charge": self.efficiency_discharge, + "efficiency_discharge": self.efficiency_charge, + "unit_type": "storage", + } + ) + + return unit_dict diff --git a/tests/test_powerplant.py b/tests/test_powerplant.py index 8e91585e..6e3e7ca8 100644 --- a/tests/test_powerplant.py +++ b/tests/test_powerplant.py @@ -24,7 +24,7 @@ def power_plant_1() -> PowerPlant: max_power=1000, min_power=200, efficiency=0.5, - fixed_cost=10, + variable_cost=10, fuel_type="lignite", emission_factor=0.5, forecaster=ff, @@ -45,7 +45,7 @@ def power_plant_2() -> PowerPlant: max_power=1000, min_power=0, efficiency=0.5, - fixed_cost=10, + variable_cost=10, fuel_type="lignite", forecaster=ff, emission_factor=0.5, @@ -66,7 +66,7 @@ def power_plant_3() -> PowerPlant: max_power=1000, min_power=0, efficiency=0.5, - fixed_cost=10, + variable_cost=10, fuel_type="lignite", emission_factor=0.5, forecaster=ff, @@ -81,7 +81,7 @@ def test_init_function(power_plant_1, power_plant_2, power_plant_3): assert power_plant_1.max_power == 1000 assert power_plant_1.min_power == 200 assert power_plant_1.efficiency == 0.5 - assert power_plant_1.fixed_cost == 10 + assert power_plant_1.variable_cost == 10 assert power_plant_1.fuel_type == "lignite" assert power_plant_1.emission_factor == 0.5 assert power_plant_1.ramp_up == 1000 diff --git a/tests/test_storage.py b/tests/test_storage.py index 3a0be473..bf339ccf 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -76,7 +76,7 @@ def test_calculate_operational_window(storage_unit): assert min_power_discharge[start] == 0 assert max_power_discharge[start] == 100 - assert cost_discharge == 4 / 0.95 + 1 + assert cost_discharge == 4 / 0.95 min_power_charge, max_power_charge = storage_unit.calculate_min_max_charge( start, end, product_type="energy" @@ -85,7 +85,7 @@ def test_calculate_operational_window(storage_unit): assert min_power_charge[start] == 0 assert max_power_charge[start] == -100 - assert math.isclose(cost_charge, 3 / 0.9 + 1) + assert math.isclose(cost_charge, 3 / 0.9) assert storage_unit.outputs["energy"].at[start] == 0 @@ -122,7 +122,7 @@ def test_soc_constraint(storage_unit): storage_unit.outputs["capacity_neg"][start] = -50 storage_unit.outputs["capacity_pos"][start] = 30 - storage_unit.outputs["soc"][start - storage_unit.index.freq] = 0.05 + storage_unit.outputs["soc"][start] = 0.05 min_power_discharge, max_power_discharge = storage_unit.calculate_min_max_discharge( start, end ) @@ -131,7 +131,7 @@ def test_soc_constraint(storage_unit): max_power_discharge.iloc[0], (50 * storage_unit.efficiency_discharge) ) - storage_unit.outputs["soc"][start - storage_unit.index.freq] = 0.95 + storage_unit.outputs["soc"][start] = 0.95 min_power_charge, max_power_charge = storage_unit.calculate_min_max_charge( start, end ) @@ -302,45 +302,49 @@ def test_execute_dispatch(storage_unit): end = product_tuple[1] storage_unit.outputs["energy"][start] = 100 - storage_unit.outputs["soc"][start - storage_unit.index.freq] = 0.5 + storage_unit.outputs["soc"][start] = 0.5 # dispatch full discharge dispatched_energy = storage_unit.execute_current_dispatch(start, end) assert dispatched_energy.iloc[0] == 100 assert math.isclose( - storage_unit.outputs["soc"][start], + storage_unit.outputs["soc"][start + storage_unit.index.freq], 0.5 - 100 / storage_unit.efficiency_discharge / storage_unit.max_volume, ) # dispatch full charging storage_unit.outputs["energy"][start] = -100 - storage_unit.outputs["soc"][start - storage_unit.index.freq] = 0.5 + storage_unit.outputs["soc"][start] = 0.5 dispatched_energy = storage_unit.execute_current_dispatch(start, end) assert dispatched_energy.iloc[0] == -100 assert math.isclose( - storage_unit.outputs["soc"][start], + storage_unit.outputs["soc"][start + storage_unit.index.freq], 0.5 + 100 * storage_unit.efficiency_charge / storage_unit.max_volume, ) storage_unit.outputs["energy"][start] = 100 - storage_unit.outputs["soc"][start - storage_unit.index.freq] = 0.05 + storage_unit.outputs["soc"][start] = 0.05 dispatched_energy = storage_unit.execute_current_dispatch(start, end) assert math.isclose( dispatched_energy.iloc[0], 50 * storage_unit.efficiency_discharge, abs_tol=0.1 ) storage_unit.outputs["energy"][start] = -100 - storage_unit.outputs["soc"][start - storage_unit.index.freq] = 0.95 + storage_unit.outputs["soc"][start] = 0.95 dispatched_energy = storage_unit.execute_current_dispatch(start, end) assert math.isclose( dispatched_energy.iloc[0], -50 / storage_unit.efficiency_charge, abs_tol=0.1 ) - storage_unit.outputs["soc"][start] = 1 + assert math.isclose( + storage_unit.outputs["soc"][start + storage_unit.index.freq], 1, abs_tol=0.001 + ) start = start + storage_unit.index.freq end = end + storage_unit.index.freq storage_unit.outputs["energy"][start] = -100 dispatched_energy = storage_unit.execute_current_dispatch(start, end) assert dispatched_energy.iloc[0] == 0 - storage_unit.outputs["soc"][start] = 1 + assert math.isclose( + storage_unit.outputs["soc"][start + storage_unit.index.freq], 1, abs_tol=0.001 + ) if __name__ == "__main__":