diff --git a/assume/common/base.py b/assume/common/base.py index 78e0ef44a..671422bb9 100644 --- a/assume/common/base.py +++ b/assume/common/base.py @@ -58,6 +58,8 @@ def __init__( self.outputs = defaultdict(lambda: FastSeries(value=0.0, index=self.index)) # series does not like to convert from tensor to float otherwise + self.avg_op_time = 0 + # some data is stored as series to allow to store it in the outputs # check if any bidding strategy is using the RL strategy if any( @@ -70,6 +72,7 @@ def __init__( index=self.index, ) self.outputs["reward"] = FastSeries(value=0.0, index=self.index) + self.outputs["regret"] = FastSeries(value=0.0, index=self.index) # RL data stored as lists to simplify storing to the buffer self.outputs["rl_observations"] = [] @@ -139,25 +142,51 @@ def set_dispatch_plan( Iterates through the orderbook, adding the accepted volumes to the corresponding time slots in the dispatch plan. It then calculates the cashflow and the reward for the bidding strategies. + Additionally, updates the average operation and downtime dynamically. + Args: marketconfig (MarketConfig): The market configuration. orderbook (Orderbook): The orderbook. - """ - product_type = marketconfig.product_type + + # Initialize counters for operation and downtime updates + total_op_time = self.avg_op_time * len(self.outputs[product_type]) + total_periods = len(self.outputs[product_type]) + for order in orderbook: start = order["start_time"] end = order["end_time"] # end includes the end of the last product, to get the last products' start time we deduct the frequency once end_excl = end - self.index.freq + + # Determine the added volume if isinstance(order["accepted_volume"], dict): added_volume = list(order["accepted_volume"].values()) else: added_volume = order["accepted_volume"] + + # Update outputs and track changes + current_slice = self.outputs[product_type].loc[start:end_excl] self.outputs[product_type].loc[start:end_excl] += added_volume - self.calculate_cashflow(product_type, orderbook) + # Detect changes in operation/downtime + for idx, volume in enumerate( + self.outputs[product_type].loc[start:end_excl] + ): + was_operating = current_slice[idx] > 0 + now_operating = volume > 0 + + if was_operating and not now_operating: # Transition to downtime + total_op_time -= 1 + elif not was_operating and now_operating: # Transition to operating + total_op_time += 1 + + # Recalculate averages + self.avg_op_time = total_op_time / total_periods + + # Calculate cashflow and reward + self.calculate_cashflow(product_type, orderbook) self.bidding_strategies[marketconfig.market_id].calculate_reward( unit=self, marketconfig=marketconfig, @@ -411,56 +440,6 @@ def get_operation_time(self, start: datetime) -> int: # Return positive time if operating, negative if shut down return -run if is_off else run - def get_average_operation_times(self, start: datetime) -> tuple[float, float]: - """ - Calculates the average uninterrupted operation and down time. - - Args: - start (datetime.datetime): The current time. - - Returns: - tuple[float, float]: Tuple of the average operation time avg_op_time and average down time avg_down_time. - - Note: - down_time in general is indicated with negative values - """ - op_series = [] - - before = start - self.index.freq - arr = self.outputs["energy"].loc[self.index[0] : before][::-1] > 0 - - if len(arr) < 1: - # before start of index - return max(self.min_operating_time, 1), min(-self.min_down_time, -1) - - op_series = [] - status = arr[0] - run = 0 - for val in arr: - if val == status: - run += 1 - else: - op_series.append(-((-1) ** status) * run) - run = 1 - status = val - op_series.append(-((-1) ** status) * run) - - 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 = sum(down_times) / len(down_times) - - return max(1, avg_op_time, self.min_operating_time), min( - -1, avg_down_time, -self.min_down_time - ) - def get_starting_costs(self, op_time: int) -> float: """ Returns the start-up cost for the given operation time. @@ -475,19 +454,20 @@ def get_starting_costs(self, op_time: int) -> float: float: The start-up costs depending on the down time. """ if op_time > 0: - # unit is running + # The unit is running, no start-up cost is needed 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 + downtime = abs(op_time) - return 0 + # Check and return the appropriate start-up cost + if downtime <= self.downtime_hot_start: + return self.hot_start_cost + + if downtime <= self.downtime_warm_start: + return self.warm_start_cost + + # If it exceeds warm start threshold, return cold start cost + return self.cold_start_cost class SupportsMinMaxCharge(BaseUnit): diff --git a/assume/common/fast_pandas.py b/assume/common/fast_pandas.py index 8071a37d7..26f3e432a 100644 --- a/assume/common/fast_pandas.py +++ b/assume/common/fast_pandas.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from datetime import datetime, timedelta -from functools import lru_cache import numpy as np import pandas as pd @@ -56,7 +55,11 @@ def __init__( total_seconds = (self._end - self._start).total_seconds() self._count = int(np.floor(total_seconds / self._freq_seconds)) + 1 - self._tolerance_seconds = 1 + # Precompute the mapping + self._date_to_index = { + self._start + i * self._freq: i for i in range(self._count) + } + self._date_list = None # Lazy-loaded @property @@ -79,11 +82,6 @@ def freq_seconds(self) -> float: """Get the frequency of the index in total seconds.""" return self._freq_seconds - @property - def tolerance_seconds(self) -> int: - """Get the tolerance in seconds for date alignment.""" - return self._tolerance_seconds - def __getitem__(self, item: int | slice): """ Retrieve datetime(s) based on the specified index or slice. @@ -131,9 +129,7 @@ def __getitem__(self, item: int | slice): if not sliced_dates: return [] - return FastIndex( - start=sliced_dates[0], end=sliced_dates[-1], freq=self._freq - ) + return sliced_dates else: raise TypeError("Index must be an integer or a slice") @@ -184,7 +180,6 @@ def __str__(self) -> str: """Return an informal string representation of the FastIndex.""" return self.__repr__() - @lru_cache(maxsize=100) def get_date_list( self, start: datetime | None = None, end: datetime | None = None ) -> list[datetime]: @@ -218,7 +213,6 @@ def as_datetimeindex(self) -> pd.DatetimeIndex: # Convert to pandas DatetimeIndex return pd.DatetimeIndex(pd.to_datetime(datetimes), name="FastIndex") - @lru_cache(maxsize=1000) def _get_idx_from_date(self, date: datetime) -> int: """ Convert a datetime to its corresponding index in the range. @@ -233,21 +227,11 @@ def _get_idx_from_date(self, date: datetime) -> int: KeyError: If the input `date` is None. ValueError: If the `date` is not aligned with the frequency within tolerance. """ - if date is None: - raise KeyError("Date cannot be None. Please provide a valid datetime.") - - delta_seconds = (date - self.start).total_seconds() - remainder = delta_seconds % self.freq_seconds - - if remainder > self.tolerance_seconds and remainder < ( - self.freq_seconds - self.tolerance_seconds - ): + if date not in self._date_to_index: raise ValueError( - f"Date {date} is not aligned with frequency {self.freq_seconds} seconds. " - f"Allowed tolerance: {self.tolerance_seconds} seconds." + f"Date {date} is not aligned with the frequency or out of range." ) - - return round(delta_seconds / self.freq_seconds) + return self._date_to_index[date] @staticmethod def _convert_to_datetime(value: datetime | str) -> datetime: @@ -312,8 +296,6 @@ def __init__( self._index = index self._name = name - self.loc = self # Allow adjusting loc as well - self.at = self count = len(self.index) # Use index length directly self._data = ( @@ -384,6 +366,16 @@ def dtype(self) -> np.dtype: """ return self.data.dtype + @property + def loc(self): + """ + Label-based indexing property. + + Returns: + FastSeriesLocIndexer: Indexer for label-based access. + """ + return FastSeriesLocIndexer(self) + @property def iloc(self): """ @@ -394,6 +386,16 @@ def iloc(self): """ return FastSeriesILocIndexer(self) + @property + def at(self): + """ + Label-based single-item access property. + + Returns: + FastSeriesAtIndexer: Indexer for label-based single-element access. + """ + return FastSeriesAtIndexer(self) + @property def iat(self): """ @@ -444,12 +446,6 @@ def __getitem__( [(d - self.index.start).total_seconds() for d in dates] ) indices = (delta_seconds / self.index.freq_seconds).round().astype(int) - remainders = delta_seconds % self.index.freq_seconds - - if not np.all(remainders <= self.index.tolerance_seconds): - raise ValueError( - "One or more dates are not aligned with the index frequency." - ) return self.data[indices] elif isinstance(item, str): @@ -976,6 +972,39 @@ def _arithmetic_operation(self, other: int | float | np.ndarray, op: str): return result +class FastSeriesLocIndexer: + def __init__(self, series: FastSeries): + self._series = series + + def __getitem__( + self, item: datetime | slice | list | pd.Index | pd.Series | np.ndarray | str + ): + """ + Retrieve item(s) using label-based indexing. + + Parameters: + item (datetime | slice | list | pd.Index | pd.Series | np.ndarray | str): The label(s) to retrieve. + + Returns: + float | np.ndarray: The retrieved value(s). + """ + return self._series.__getitem__(item) + + def __setitem__( + self, + item: datetime | slice | list | pd.Index | pd.Series | np.ndarray | str, + value: float | np.ndarray, + ): + """ + Assign value(s) using label-based indexing. + + Parameters: + item (datetime | slice | list | pd.Index | pd.Series | np.ndarray | str): The label(s) to set. + value (float | np.ndarray): The value(s) to assign. + """ + self._series.__setitem__(item, value) + + class FastSeriesILocIndexer: def __init__(self, series: FastSeries): self._series = series @@ -1070,6 +1099,37 @@ def __setitem__(self, item: int | slice, value: float | np.ndarray): ) +class FastSeriesAtIndexer: + def __init__(self, series: FastSeries): + self._series = series + + def __getitem__(self, item: datetime | str): + """ + Retrieve a single item using label-based indexing. + + Parameters: + item (datetime | str): The label. + + Returns: + float: The retrieved value. + """ + if isinstance(item, str): + item = pd.to_datetime(item).to_pydatetime() + return self._series[item] + + def __setitem__(self, item: datetime | str, value: float): + """ + Assign a value using label-based indexing. + + Parameters: + item (datetime | str): The label. + value (float): The value to assign. + """ + if isinstance(item, str): + item = pd.to_datetime(item).to_pydatetime() + self._series[item] = value + + class FastSeriesIatIndexer: def __init__(self, series: FastSeries): self._series = series diff --git a/assume/common/forecasts.py b/assume/common/forecasts.py index 11859c230..b81666aae 100644 --- a/assume/common/forecasts.py +++ b/assume/common/forecasts.py @@ -141,8 +141,13 @@ def __getitem__(self, column: str) -> FastSeries: if column not in self.forecasts.keys(): if "availability" in column: - return FastSeries(value=1.0, index=self.index) - return FastSeries(value=0.0, index=self.index) + self.forecasts[column] = FastSeries( + value=1.0, index=self.index, name=column + ) + else: + self.forecasts[column] = FastSeries( + value=0.0, index=self.index, name=column + ) return self.forecasts[column] diff --git a/assume/common/units_operator.py b/assume/common/units_operator.py index e85b421e8..051d7510d 100644 --- a/assume/common/units_operator.py +++ b/assume/common/units_operator.py @@ -292,7 +292,7 @@ def set_unit_dispatch( ) def get_actual_dispatch( - self, product_type: str, last: datetime + self, product_type: str, start: datetime, end: datetime ) -> tuple[list[tuple[datetime, float, str, str]], list[dict]]: """ Retrieves the actual dispatch and commits it in the unit. @@ -301,27 +301,26 @@ def get_actual_dispatch( Args: product_type (str): The product type for which this is done - last (datetime.datetime): the last date until which the dispatch was already sent + start (datetime): The start time of the dispatch. + end (datetime): The end time of the dispatch. Returns: tuple[list[tuple[datetime, float, str, str]], list[dict]]: market_dispatch and unit_dispatch dataframes """ - now = timestamp2datetime(self.context.current_timestamp) - start = timestamp2datetime(last + 1) - market_dispatch = aggregate_step_amount( orderbook=self.valid_orders[product_type], - begin=timestamp2datetime(last), - end=now, + begin=start, + end=end, groupby=["market_id", "unit_id"], ) unit_dispatch = [] for unit_id, unit in self.units.items(): - current_dispatch = unit.execute_current_dispatch(start, now) - end = now + if start < unit.index.start: + start = unit.index.start + current_dispatch = unit.execute_current_dispatch(start, end) dispatch = {"power": current_dispatch} - unit.calculate_generation_cost(start, now, "energy") + unit.calculate_generation_cost(start, end, "energy") valid_outputs = [ "soc", "cashflow", @@ -346,23 +345,26 @@ def write_actual_dispatch(self, product_type: str) -> None: Args: product_type (str): The type of the product. """ + current_time = timestamp2datetime(self.context.current_timestamp) + last_dispatch_time = timestamp2datetime(self.last_sent_dispatch[product_type]) - last = self.last_sent_dispatch[product_type] - if self.context.current_timestamp == last: + if current_time == last_dispatch_time: # stop if we exported at this time already return + # Update the last dispatch timestamp for this product self.last_sent_dispatch[product_type] = self.context.current_timestamp - market_dispatch, unit_dispatch = self.get_actual_dispatch(product_type, last) - - now = timestamp2datetime(self.context.current_timestamp) - self.valid_orders[product_type] = list( - filter( - lambda x: x["end_time"] > now, - self.valid_orders[product_type], - ) + market_dispatch, unit_dispatch = self.get_actual_dispatch( + product_type=product_type, start=last_dispatch_time, end=current_time ) + # Filter valid orders to remove expired ones + self.valid_orders[product_type] = [ + order + for order in self.valid_orders[product_type] + if order["end_time"] > current_time + ] + db_addr = self.context.data.get("output_agent_addr") if db_addr: self.context.schedule_instant_message( diff --git a/assume/common/utils.py b/assume/common/utils.py index 165129b23..954b53f2c 100644 --- a/assume/common/utils.py +++ b/assume/common/utils.py @@ -346,39 +346,36 @@ def separate_orders(orderbook: Orderbook): Notes: This function separates orders with several hours into single hour orders and modifies the orderbook in place. """ + new_orders = [] - # separate orders with several hours into single hour orders - delete_orders = [] for order in orderbook: - if any([isinstance(value, dict) for value in order.values()]): - start_hour = order["start_time"] - end_hour = order["end_time"] - order_len = max( - len(value) for value in order.values() if isinstance(value, dict) + # Skip orders that don't need separation + if not any(isinstance(value, dict) for value in order.values()): + new_orders.append(order) + continue + + # Calculate duration and generate single-hour orders + start_hour = order["start_time"] + end_hour = order["end_time"] + order_len = max( + len(value) for value in order.values() if isinstance(value, dict) + ) + duration = (end_hour - start_hour) / order_len + + # Generate new single-hour orders + for start in pd.date_range(start_hour, end_hour - duration, freq=duration): + single_order = { + k: (v[start] if isinstance(v, dict) else v) for k, v in order.items() + } + single_order.update( + { + "start_time": start, + "end_time": start + duration, + } ) - duration = (end_hour - start_hour) / order_len - - for start in pd.date_range(start_hour, end_hour - duration, freq=duration): - single_order = order.copy() - for key in order.keys(): - if isinstance(order[key], dict): - single_order.update({key: order[key][start]}) - if single_order != order: - single_order.update( - { - "start_time": start, - "end_time": start + duration, - } - ) - - orderbook.append(single_order) - - delete_orders.append(order) - - for order in delete_orders: - orderbook.remove(order) + new_orders.append(single_order) - return orderbook + return new_orders def get_products_index(orderbook: Orderbook) -> pd.DatetimeIndex: diff --git a/assume/strategies/__init__.py b/assume/strategies/__init__.py index b651e3846..de440a8ce 100644 --- a/assume/strategies/__init__.py +++ b/assume/strategies/__init__.py @@ -13,8 +13,6 @@ ) from assume.strategies.naive_strategies import ( NaiveDASteelplantStrategy, - NaiveNegReserveStrategy, - NaivePosReserveStrategy, NaiveProfileStrategy, NaiveRedispatchSteelplantStrategy, NaiveRedispatchStrategy, @@ -28,8 +26,8 @@ bidding_strategies: dict[str, BaseStrategy] = { "naive_eom": NaiveSingleBidStrategy, "naive_dam": NaiveProfileStrategy, - "naive_pos_reserve": NaivePosReserveStrategy, - "naive_neg_reserve": NaiveNegReserveStrategy, + "naive_pos_reserve": NaiveSingleBidStrategy, + "naive_neg_reserve": NaiveSingleBidStrategy, "otc_strategy": OTCStrategy, "flexable_eom": flexableEOM, "flexable_eom_block": flexableEOMBlock, diff --git a/assume/strategies/advanced_orders.py b/assume/strategies/advanced_orders.py index ad740ae48..fdd1dc0e1 100644 --- a/assume/strategies/advanced_orders.py +++ b/assume/strategies/advanced_orders.py @@ -3,17 +3,17 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -from assume.common.base import BaseStrategy, SupportsMinMax +from assume.common.base import SupportsMinMax from assume.common.market_objects import MarketConfig, Orderbook, Product from assume.common.utils import parse_duration from assume.strategies.flexable import ( calculate_EOM_price_if_off, calculate_EOM_price_if_on, - calculate_reward_EOM, + flexableEOM, ) -class flexableEOMBlock(BaseStrategy): +class flexableEOMBlock(flexableEOM): """ A strategy that bids on the EOM-market with block bids. @@ -69,7 +69,6 @@ def calculate_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, min_power, max_power in zip( product_tuples, min_power_values, max_power_values @@ -120,7 +119,6 @@ def calculate_bids( marginal_cost_flex=marginal_cost_flex, bid_quantity_inflex=bid_quantity_inflex, foresight=self.foresight, - avg_down_time=avg_down_time, ) else: bid_price_inflex = calculate_EOM_price_if_off( @@ -128,7 +126,6 @@ def calculate_bids( marginal_cost_inflex=marginal_cost_inflex, bid_quantity_inflex=bid_quantity_inflex, op_time=op_time, - avg_op_time=avg_op_time, ) if unit.outputs["heat"].at[start] > 0: @@ -190,30 +187,8 @@ def calculate_bids( return bids - def calculate_reward( - self, - unit, - marketconfig: MarketConfig, - orderbook: Orderbook, - ): - """ - Calculates and writes the reward (costs and profit). - Args: - unit (SupportsMinMax): A unit that the unit operator manages. - marketconfig (MarketConfig): A market configuration. - orderbook (Orderbook): An orderbook with accepted and rejected orders for the unit. - """ - # TODO: Calculate profits over all markets - - calculate_reward_EOM( - unit=unit, - marketconfig=marketconfig, - orderbook=orderbook, - ) - - -class flexableEOMLinked(BaseStrategy): +class flexableEOMLinked(flexableEOM): """ A strategy that bids on the EOM-market with block and linked bids. """ @@ -261,7 +236,6 @@ def calculate_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) block_id = unit.id + "_block" @@ -314,7 +288,6 @@ def calculate_bids( marginal_cost_flex=marginal_cost_flex, bid_quantity_inflex=bid_quantity_inflex, foresight=self.foresight, - avg_down_time=avg_down_time, ) else: bid_price_inflex = calculate_EOM_price_if_off( @@ -322,7 +295,6 @@ def calculate_bids( marginal_cost_inflex=marginal_cost_inflex, bid_quantity_inflex=bid_quantity_inflex, op_time=op_time, - avg_op_time=avg_op_time, ) if unit.outputs["heat"].at[start] > 0: @@ -388,26 +360,3 @@ def calculate_bids( bids = self.remove_empty_bids(bids) return bids - - def calculate_reward( - self, - unit, - marketconfig: MarketConfig, - orderbook: Orderbook, - ): - """ - Calculates and writes the reward (costs and profit). - - Args: - unit (SupportsMinMax): A unit that the unit operator manages. - marketconfig (MarketConfig): A market configuration. - orderbook (Orderbook): An orderbook with accepted and rejected orders for the unit. - """ - - # TODO: Calculate profits over all markets - - calculate_reward_EOM( - unit=unit, - marketconfig=marketconfig, - orderbook=orderbook, - ) diff --git a/assume/strategies/flexable.py b/assume/strategies/flexable.py index 10ba7fcdc..5931106a6 100644 --- a/assume/strategies/flexable.py +++ b/assume/strategies/flexable.py @@ -63,7 +63,6 @@ def calculate_bids( min_power_values, max_power_values = unit.calculate_min_max_power(start, end) op_time = unit.get_operation_time(start) - avg_op_time, avg_down_time = unit.get_average_operation_times(start) bids = [] for product, min_power, max_power in zip( @@ -111,7 +110,6 @@ def calculate_bids( marginal_cost_flex=marginal_cost_flex, bid_quantity_inflex=bid_quantity_inflex, foresight=self.foresight, - avg_down_time=avg_down_time, ) else: bid_price_inflex = calculate_EOM_price_if_off( @@ -119,7 +117,6 @@ def calculate_bids( marginal_cost_inflex=marginal_cost_inflex, bid_quantity_inflex=bid_quantity_inflex, op_time=op_time, - avg_op_time=avg_op_time, ) if unit.outputs["heat"].at[start] > 0: @@ -176,14 +173,70 @@ def calculate_reward( unit (SupportsMinMax): A unit that the unit operator manages. marketconfig (MarketConfig): A market configuration. orderbook (Orderbook): An orderbook with accepted and rejected orders for the unit. + + Note: + The reward is calculated as the profit minus the opportunity cost, + which is the loss of income we have because we are not running at full power. + The regret is the opportunity cost. + Because the regret_scale is set to 0 the reward equals the profit. + The profit is the income we have from the accepted bids. + The total costs are the running costs and the start-up costs. + """ - # TODO: Calculate profits over all markets + product_type = marketconfig.product_type + products_index = get_products_index(orderbook) + + # Initialize intermediate results as numpy arrays for better performance + profit = np.zeros(len(products_index)) + costs = np.zeros(len(products_index)) + + # Map products_index to their positions for faster updates + index_map = {time: i for i, time in enumerate(products_index)} + + for order in orderbook: + start = order["start_time"] + end_excl = order["end_time"] - unit.index.freq + + order_times = unit.index[start:end_excl] + accepted_volume = order.get("accepted_volume", 0) + accepted_price = order.get("accepted_price", 0) + + for start in order_times: + idx = index_map.get(start) + + marginal_cost = unit.calculate_marginal_cost( + start, unit.outputs[product_type].at[start] + ) + + if isinstance(accepted_volume, dict): + accepted_volume = accepted_volume.get(start, 0) + else: + accepted_volume = accepted_volume + + if isinstance(accepted_price, dict): + accepted_price = accepted_price.get(start, 0) + else: + accepted_price = accepted_price + + profit[idx] += accepted_price * accepted_volume + + # consideration of start-up costs + for i, start in enumerate(products_index): + op_time = unit.get_operation_time(start) - calculate_reward_EOM( - unit=unit, - marketconfig=marketconfig, - orderbook=orderbook, - ) + output = unit.outputs[product_type].at[start] + marginal_cost = unit.calculate_marginal_cost(start, output) + costs[i] += marginal_cost * output + + if output != 0 and op_time < 0: + start_up_cost = unit.get_starting_costs(op_time) + costs[i] += start_up_cost + + profit -= costs + + # store results in unit outputs which are written to database by unit operator + unit.outputs["profit"].loc[products_index] = profit + unit.outputs["total_costs"].loc[products_index] = costs class flexablePosCRM(BaseStrategy): @@ -421,7 +474,6 @@ def calculate_EOM_price_if_off( 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 @@ -435,20 +487,20 @@ def calculate_EOM_price_if_off( marginal_cost_inflex (float): The marginal cost of the unit. bid_quantity_inflex (float): The bid quantity of the unit. op_time (int): The operation time of the unit. - avg_op_time (int): The average operation time of the unit. Returns: float: The inflexible bid price of the unit. """ + if bid_quantity_inflex == 0: + return 0 + + avg_operating_time = max(unit.avg_op_time, unit.min_operating_time) 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 / avg_op_time - else: - markup = starting_cost / avg_op_time / bid_quantity_inflex + markup = starting_cost / avg_operating_time / bid_quantity_inflex bid_price_inflex = min(marginal_cost_inflex + markup, 3000.0) @@ -462,13 +514,12 @@ def calculate_EOM_price_if_on( marginal_cost_flex, bid_quantity_inflex, foresight, - avg_down_time=-1, ): """ The powerplant is currently on and calculates a price reduction to prevent shutdowns. The price reduction is calculated as follows: - starting_cost / -avg_down_time / bid_quantity_inflex + starting_cost / min_down_time / bid_quantity_inflex If the unit is a CHP, the heat generation costs are added to the price reduction with the following formula: heat_gen_cost = (heat_output * (natural_gas_price / 0.9)) / bid_quantity_inflex If the estimated revenue for the time defined in foresight is positive, @@ -481,25 +532,23 @@ def calculate_EOM_price_if_on( marginal_cost_flex (float): The marginal cost of the unit. bid_quantity_inflex (float): The bid quantity of the unit. foresight (datetime.timedelta): The foresight of the unit. - avg_down_time (int): The average down time of the unit. Returns: float: The inflexible bid price of the unit. """ + if bid_quantity_inflex == 0: return 0 - t = start + # check the starting cost if the unit were turned off for min_down_time + starting_cost = unit.get_starting_costs(-unit.min_down_time) - # TODO is it correct to bill for cold, hot and warm starts in one start? - starting_cost = unit.get_starting_costs(avg_down_time) + 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: + if unit.outputs["heat"].at[start] > 0: heat_gen_cost = ( - unit.outputs["heat"][t] - * (unit.forecaster.get_price("natural gas")[t] / 0.9) + unit.outputs["heat"].at[start] + * (unit.forecaster.get_price("natural gas").at[start] / 0.9) ) / bid_quantity_inflex else: heat_gen_cost = 0.0 @@ -512,7 +561,7 @@ def calculate_EOM_price_if_on( ) if ( possible_revenue >= 0 - and unit.forecaster[f"price_{market_id}"][t] < marginal_cost_flex + and unit.forecaster[f"price_{market_id}"].at[start] < marginal_cost_flex ): marginal_cost_flex = 0 @@ -552,107 +601,3 @@ def get_specific_revenue( possible_revenue = (price_forecast - marginal_cost).sum() return possible_revenue - - -def calculate_reward_EOM( - unit, - marketconfig: MarketConfig, - orderbook: Orderbook, -): - """ - Calculate and write reward, profit and regret to unit outputs. - - Args: - unit (SupportsMinMax): The unit to calculate reward for. - marketconfig (MarketConfig): The market configuration. - orderbook (Orderbook): The Orderbook. - - Note: - The reward is calculated as the profit minus the opportunity cost, - which is the loss of income we have because we are not running at full power. - The regret is the opportunity cost. - Because the regret_scale is set to 0 the reward equals the profit. - The profit is the income we have from the accepted bids. - The total costs are the running costs and the start-up costs. - - """ - # TODO: Calculate profits over all markets - product_type = marketconfig.product_type - products_index = get_products_index(orderbook) - - max_power_values = ( - unit.forecaster.get_availability(unit.id)[products_index] * unit.max_power - ) - - # Initialize intermediate results as numpy arrays for better performance - profit = np.zeros(len(products_index)) - reward = np.zeros(len(products_index)) - opportunity_cost = np.zeros(len(products_index)) - costs = np.zeros(len(products_index)) - - # Map products_index to their positions for faster updates - index_map = {time: i for i, time in enumerate(products_index)} - - for order in orderbook: - start = order["start_time"] - # end includes the end of the last product, to get the last products' start time we deduct the frequency once - end_excl = order["end_time"] - unit.index.freq - - order_times = unit.index[start:end_excl] - accepted_volume = order["accepted_volume"] - accepted_price = order["accepted_price"] - - for start, max_power in zip(order_times, max_power_values): - idx = index_map.get(start) - - marginal_cost = unit.calculate_marginal_cost( - start, unit.outputs[product_type].at[start] - ) - - if isinstance(accepted_volume, dict): - accepted_volume = accepted_volume.get(start, 0) - else: - accepted_volume = accepted_volume - - if isinstance(accepted_price, dict): - accepted_price = accepted_price.get(start, 0) - else: - accepted_price = accepted_price - - price_difference = accepted_price - marginal_cost - - # calculate opportunity cost - # as the loss of income we have because we are not running at full power - order_opportunity_cost = price_difference * ( - max_power - unit.outputs[product_type].at[start] - ) - # if our opportunity costs are negative, we did not miss an opportunity to earn money and we set them to 0 - # don't consider opportunity_cost more than once! Always the same for one timestep and one market - opportunity_cost[idx] = max(order_opportunity_cost, 0) - profit[idx] += accepted_price * accepted_volume - - # consideration of start-up costs - for i, start in enumerate(products_index): - op_time = unit.get_operation_time(start) - - output = unit.outputs[product_type].at[start] - marginal_cost = unit.calculate_marginal_cost(start, output) - costs[i] += marginal_cost * output - - if output != 0 and op_time < 0: - start_up_cost = unit.get_starting_costs(op_time) - costs[i] += start_up_cost - - profit -= costs - scaling = 0.1 / unit.max_power - regret_scale = 0.0 - reward = (profit - regret_scale * opportunity_cost) * scaling - - # store results in unit outputs which are written to database by unit operator - unit.outputs["profit"].loc[products_index] = profit - unit.outputs["reward"].loc[products_index] = reward - unit.outputs["regret"].loc[products_index] = opportunity_cost - unit.outputs["total_costs"].loc[products_index] = costs - - if "rl_reward" in unit.outputs.keys(): - unit.outputs["rl_reward"].append(reward) diff --git a/assume/strategies/learning_advanced_orders.py b/assume/strategies/learning_advanced_orders.py index a66da20bc..e894e8424 100644 --- a/assume/strategies/learning_advanced_orders.py +++ b/assume/strategies/learning_advanced_orders.py @@ -9,7 +9,7 @@ from assume.common.base import SupportsMinMax from assume.common.market_objects import MarketConfig, Orderbook, Product -from assume.strategies.flexable import calculate_reward_EOM +from assume.common.utils import get_products_index from assume.strategies.learning_strategies import RLStrategy @@ -377,4 +377,80 @@ def calculate_reward( """ - calculate_reward_EOM(unit, marketconfig, orderbook) + product_type = marketconfig.product_type + products_index = get_products_index(orderbook) + + max_power_values = ( + unit.forecaster.get_availability(unit.id)[products_index] * unit.max_power + ) + + # Initialize intermediate results as numpy arrays for better performance + profit = np.zeros(len(products_index)) + reward = np.zeros(len(products_index)) + opportunity_cost = np.zeros(len(products_index)) + costs = np.zeros(len(products_index)) + + # Map products_index to their positions for faster updates + index_map = {time: i for i, time in enumerate(products_index)} + + for order in orderbook: + start = order["start_time"] + end_excl = order["end_time"] - unit.index.freq + + order_times = unit.index[start:end_excl] + accepted_volume = order.get("accepted_volume", 0) + accepted_price = order.get("accepted_price", 0) + + for start, max_power in zip(order_times, max_power_values): + idx = index_map.get(start) + + marginal_cost = unit.calculate_marginal_cost( + start, unit.outputs[product_type].at[start] + ) + + if isinstance(accepted_volume, dict): + accepted_volume = accepted_volume.get(start, 0) + else: + accepted_volume = accepted_volume + + if isinstance(accepted_price, dict): + accepted_price = accepted_price.get(start, 0) + else: + accepted_price = accepted_price + + price_difference = accepted_price - marginal_cost + + # calculate opportunity cost + # as the loss of income we have because we are not running at full power + order_opportunity_cost = price_difference * ( + max_power - unit.outputs[product_type].at[start] + ) + # if our opportunity costs are negative, we did not miss an opportunity to earn money and we set them to 0 + # don't consider opportunity_cost more than once! Always the same for one timestep and one market + opportunity_cost[idx] = max(order_opportunity_cost, 0) + profit[idx] += accepted_price * accepted_volume + + # consideration of start-up costs + for i, start in enumerate(products_index): + op_time = unit.get_operation_time(start) + + output = unit.outputs[product_type].at[start] + marginal_cost = unit.calculate_marginal_cost(start, output) + costs[i] += marginal_cost * output + + if output != 0 and op_time < 0: + start_up_cost = unit.get_starting_costs(op_time) + costs[i] += start_up_cost + + profit -= costs + scaling = 0.1 / unit.max_power + regret_scale = 0.2 + reward = (profit - regret_scale * opportunity_cost) * scaling + + # store results in unit outputs which are written to database by unit operator + unit.outputs["profit"].loc[products_index] = profit + unit.outputs["reward"].loc[products_index] = reward + unit.outputs["regret"].loc[products_index] = opportunity_cost + unit.outputs["total_costs"].loc[products_index] = costs + + unit.outputs["rl_reward"].append(reward) diff --git a/assume/strategies/learning_strategies.py b/assume/strategies/learning_strategies.py index d41dd28b9..279539fb3 100644 --- a/assume/strategies/learning_strategies.py +++ b/assume/strategies/learning_strategies.py @@ -532,9 +532,12 @@ def calculate_reward( duration = (end - start) / timedelta(hours=1) + accepted_volume = order.get("accepted_volume", 0) + accepted_price = order.get("accepted_price", 0) + # calculate profit as income - running_cost from this event - order_profit = order["accepted_price"] * order["accepted_volume"] * duration - order_cost = marginal_cost * order["accepted_volume"] * duration + order_profit = accepted_price * accepted_volume * duration + order_cost = marginal_cost * accepted_volume * duration # collect profit and opportunity cost for all orders profit += order_profit @@ -543,7 +546,7 @@ def calculate_reward( # calculate opportunity cost # as the loss of income we have because we are not running at full power opportunity_cost = ( - (order["accepted_price"] - marginal_cost) + (accepted_price - marginal_cost) * (unit.max_power - unit.outputs[product_type].loc[start:end_excl]).sum() * duration ) @@ -929,13 +932,13 @@ def calculate_reward( ) marginal_cost += unit.get_starting_costs(int(duration_hours)) + accepted_volume = order.get("accepted_volume", 0) # ignore very small volumes due to calculations - accepted_volume = ( - order["accepted_volume"] if abs(order["accepted_volume"]) > 1 else 0 - ) + accepted_volume = accepted_volume if abs(accepted_volume) > 1 else 0 + accepted_price = order.get("accepted_price", 0) # Calculate profit and cost for the order - order_profit = order["accepted_price"] * accepted_volume * duration_hours + order_profit = accepted_price * accepted_volume * duration_hours order_cost = abs(marginal_cost * accepted_volume * duration_hours) current_soc = unit.outputs["soc"].at[start_time] diff --git a/assume/strategies/naive_strategies.py b/assume/strategies/naive_strategies.py index b0023e387..95944c649 100644 --- a/assume/strategies/naive_strategies.py +++ b/assume/strategies/naive_strategies.py @@ -209,135 +209,6 @@ def calculate_bids( return bids -class NaivePosReserveStrategy(BaseStrategy): - """ - A naive strategy that bids the ramp up volume on the positive reserve market (price = 0). - - Args: - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def calculate_bids( - self, - unit: SupportsMinMax, - 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. - - Args: - unit (SupportsMinMax): The unit to be dispatched. - market_config (MarketConfig): The market configuration. - product_tuples (list[Product]): The list of all products the unit can offer. - - Returns: - Orderbook: The bids consisting of the start time, end time, only hours, price and volume. - """ - start = product_tuples[0][0] - end_all = product_tuples[-1][1] - previous_power = unit.get_output_before(start) - _, max_power_values = unit.calculate_min_max_power( - start, end_all, market_config.product_type - ) - - bids = [] - for product, max_power in zip(product_tuples, max_power_values): - start = product[0] - op_time = unit.get_operation_time(start) - current_power = unit.outputs["energy"].at[start] - volume = unit.calculate_ramp( - op_time, previous_power, max_power, current_power - ) - price = 0 - bids.append( - { - "start_time": product[0], - "end_time": product[1], - "only_hours": product[2], - "price": price, - "volume": volume, - "node": unit.node, - } - ) - previous_power = volume + current_power - - bids = self.remove_empty_bids(bids) - - return bids - - -class NaiveNegReserveStrategy(BaseStrategy): - """ - A naive strategy that bids the ramp down volume on the negative reserve market (price = 0). - - Args: - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def calculate_bids( - self, - unit: SupportsMinMax, - 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. - - Args: - unit (SupportsMinMax): The unit to be dispatched. - market_config (MarketConfig): The market configuration. - product_tuples (list[Product]): The list of all products the unit can offer. - - Returns: - Orderbook: The bids consisting of the start time, end time, only hours, price and volume. - """ - start = product_tuples[0][0] - end_all = product_tuples[-1][1] - previous_power = unit.get_output_before(start) - min_power_values, _ = unit.calculate_min_max_power( - start, end_all, market_config.product_type - ) - - bids = [] - for product, min_power in zip(product_tuples, min_power_values): - start = product[0] - op_time = unit.get_operation_time(start) - previous_power = unit.get_output_before(start) - current_power = unit.outputs["energy"].at[start] - volume = unit.calculate_ramp( - op_time, previous_power, min_power, current_power - ) - price = 0 - bids.append( - { - "start_time": start, - "end_time": product[1], - "only_hours": product[2], - "price": price, - "volume": volume, - "node": unit.node, - } - ) - previous_power = volume + current_power - - bids = self.remove_empty_bids(bids) - - return bids - - class NaiveRedispatchStrategy(BaseStrategy): """ A naive strategy that simply submits all information about the unit and diff --git a/assume/units/demand.py b/assume/units/demand.py index a435de140..8bfe36b02 100644 --- a/assume/units/demand.py +++ b/assume/units/demand.py @@ -104,7 +104,10 @@ def calculate_min_max_power( # end includes the end of the last product, to get the last products' start time we deduct the frequency once end_excl = end - self.index.freq - bid_volume = (self.volume - self.outputs[product_type]).loc[start:end_excl] + bid_volume = ( + self.volume.loc[start:end_excl] + - self.outputs[product_type].loc[start:end_excl] + ) return bid_volume, bid_volume diff --git a/assume/units/powerplant.py b/assume/units/powerplant.py index 55580e2c9..13b4f3468 100644 --- a/assume/units/powerplant.py +++ b/assume/units/powerplant.py @@ -68,8 +68,8 @@ def __init__( cold_start_cost: float = 0, min_operating_time: float = 0, min_down_time: float = 0, - downtime_hot_start: int = 8, # hours - downtime_warm_start: int = 48, # hours + downtime_hot_start: int = 0, # hours + downtime_warm_start: int = 0, # hours heat_extraction: bool = False, max_heat_extraction: float = 0, location: tuple[float, float] = (0.0, 0.0), diff --git a/examples/inputs/example_01a/config.yaml b/examples/inputs/example_01a/config.yaml index 6eec3e895..85997b217 100644 --- a/examples/inputs/example_01a/config.yaml +++ b/examples/inputs/example_01a/config.yaml @@ -6,7 +6,7 @@ base: start_date: 2019-01-01 00:00 end_date: 2019-01-31 00:00 time_step: 1h - save_frequency_hours: 24 + save_frequency_hours: 720 markets_config: EOM: diff --git a/examples/inputs/example_01f/config.yaml b/examples/inputs/example_01f/config.yaml index 5670bff63..51dbfffb4 100644 --- a/examples/inputs/example_01f/config.yaml +++ b/examples/inputs/example_01f/config.yaml @@ -4,7 +4,7 @@ eom_case: start_date: 2019-01-01 00:00 - end_date: 2019-01-21 00:00 + end_date: 2019-01-22 00:00 time_step: 1h markets_config: @@ -25,7 +25,7 @@ eom_case: ltm_case: start_date: 2019-01-01 00:00 - end_date: 2019-01-21 00:00 + end_date: 2019-01-22 00:00 time_step: 1h markets_config: diff --git a/examples/inputs/example_02a/config.yaml b/examples/inputs/example_02a/config.yaml index f6e39adbb..efb2ee291 100644 --- a/examples/inputs/example_02a/config.yaml +++ b/examples/inputs/example_02a/config.yaml @@ -7,7 +7,7 @@ base: end_date: 2019-03-31 00:00 time_step: 1h learning_mode: true - save_frequency_hours: null + save_frequency_hours: 48 learning_config: continue_learning: false @@ -50,7 +50,7 @@ base_lstm: end_date: 2019-03-31 00:00 time_step: 1h learning_mode: true - save_frequency_hours: null + save_frequency_hours: 720 learning_config: continue_learning: false @@ -96,7 +96,7 @@ tiny: end_date: 2019-01-05 00:00 time_step: 1h learning_mode: true - save_frequency_hours: null + save_frequency_hours: 720 learning_config: continue_learning: false diff --git a/examples/inputs/example_02b/config.yaml b/examples/inputs/example_02b/config.yaml index d50d8a170..d867ea296 100644 --- a/examples/inputs/example_02b/config.yaml +++ b/examples/inputs/example_02b/config.yaml @@ -6,7 +6,7 @@ base: start_date: 2019-03-01 00:00 end_date: 2019-04-01 00:00 time_step: 1h - save_frequency_hours: null + save_frequency_hours: 720 learning_mode: True learning_config: @@ -51,7 +51,7 @@ base_lstm: start_date: 2019-03-01 00:00 end_date: 2019-04-01 00:00 time_step: 1h - save_frequency_hours: null + save_frequency_hours: 720 learning_mode: True learning_config: diff --git a/examples/inputs/example_02c/config.yaml b/examples/inputs/example_02c/config.yaml index 0cd3f4091..c29aef563 100644 --- a/examples/inputs/example_02c/config.yaml +++ b/examples/inputs/example_02c/config.yaml @@ -6,7 +6,7 @@ base: start_date: 2019-03-01 00:00 end_date: 2019-04-01 00:00 time_step: 1h - save_frequency_hours: null + save_frequency_hours: 720 learning_mode: True learning_config: diff --git a/examples/inputs/example_02d/config.yaml b/examples/inputs/example_02d/config.yaml index e52c39c78..0b856943d 100644 --- a/examples/inputs/example_02d/config.yaml +++ b/examples/inputs/example_02d/config.yaml @@ -6,7 +6,7 @@ dam: start_date: 2019-01-01 00:00 end_date: 2019-01-15 23:00 time_step: 1h - save_frequency_hours: null + save_frequency_hours: 720 learning_mode: True learning_config: diff --git a/examples/inputs/example_02e/config.yaml b/examples/inputs/example_02e/config.yaml index 5011950cf..09dbdb5d7 100644 --- a/examples/inputs/example_02e/config.yaml +++ b/examples/inputs/example_02e/config.yaml @@ -6,7 +6,7 @@ tiny: start_date: 2019-01-01 00:00 end_date: 2019-01-05 00:00 time_step: 1h - save_frequency_hours: null + save_frequency_hours: 720 learning_mode: True learning_config: @@ -50,7 +50,7 @@ base: start_date: 2019-03-01 00:00 end_date: 2019-04-30 00:00 time_step: 1h - save_frequency_hours: null + save_frequency_hours: 720 learning_mode: True learning_config: diff --git a/examples/inputs/example_03/config.yaml b/examples/inputs/example_03/config.yaml index 4fa68ae59..898c203dd 100644 --- a/examples/inputs/example_03/config.yaml +++ b/examples/inputs/example_03/config.yaml @@ -7,7 +7,7 @@ base_case_2019: end_date: 2019-02-01 00:00 time_step: 1h industrial_dsm_units: null - save_frequency_hours: null + save_frequency_hours: 720 markets_config: EOM: diff --git a/examples/inputs/example_03/demand_units.csv b/examples/inputs/example_03/demand_units.csv index 8eb1d4c19..bb2bdb4b6 100644 --- a/examples/inputs/example_03/demand_units.csv +++ b/examples/inputs/example_03/demand_units.csv @@ -1,4 +1,4 @@ name,technology,bidding_EOM,bidding_CRM_pos,bidding_CRM_neg,max_power,min_power,unit_operator demand_EOM,inflex_demand,naive_eom,,,1000000.0,0.0,eom_de -demand_CRM_pos,reserve_capacity,,naive_eom,,1000000.0,0.0,crm_de -demand_CRM_neg,reserve_capacity,,,naive_eom,1000000.0,0.0,crm_de +demand_CRM_pos,reserve_capacity,,naive_pos_reserve,,1000000.0,0.0,crm_de +demand_CRM_neg,reserve_capacity,,,naive_neg_reserve,1000000.0,0.0,crm_de diff --git a/examples/inputs/example_03a/config.yaml b/examples/inputs/example_03a/config.yaml index 9acc8f80a..362fd26bd 100644 --- a/examples/inputs/example_03a/config.yaml +++ b/examples/inputs/example_03a/config.yaml @@ -7,7 +7,7 @@ base_case_2019: end_date: 2019-01-31 00:00 time_step: 1h learning_mode: True - save_frequency_hours: Null + save_frequency_hours: 720 learning_config: continue_learning: False diff --git a/examples/inputs/example_03b/config.yaml b/examples/inputs/example_03b/config.yaml index 176397bd3..25d228151 100644 --- a/examples/inputs/example_03b/config.yaml +++ b/examples/inputs/example_03b/config.yaml @@ -7,7 +7,7 @@ base_case_2021: end_date: 2021-03-31 00:00 time_step: 1h learning_mode: True - save_frequency_hours: Null + save_frequency_hours: 720 learning_config: continue_learning: False diff --git a/examples/notebooks/06_advanced_orders_example.ipynb b/examples/notebooks/06_advanced_orders_example.ipynb index 69fbeaadb..f20117d42 100644 --- a/examples/notebooks/06_advanced_orders_example.ipynb +++ b/examples/notebooks/06_advanced_orders_example.ipynb @@ -783,15 +783,12 @@ "metadata": {}, "outputs": [], "source": [ - "from assume.common.base import (\n", - " BaseStrategy,\n", - " SupportsMinMax,\n", - ")\n", + "from assume.common.base import SupportsMinMax\n", "from assume.common.market_objects import Orderbook, Product\n", "from assume.strategies.flexable import (\n", " calculate_EOM_price_if_off,\n", " calculate_EOM_price_if_on,\n", - " calculate_reward_EOM,\n", + " flexableEOM,\n", ")" ] }, @@ -801,7 +798,7 @@ "metadata": {}, "outputs": [], "source": [ - "class blockStrategy(BaseStrategy):\n", + "class BlockStrategy(flexableEOM):\n", " \"\"\"\n", " A strategy that bids on the EOM-market with block bids.\n", " \"\"\"\n", @@ -848,7 +845,6 @@ "\n", " bids = []\n", " op_time = unit.get_operation_time(start)\n", - " avg_op_time, avg_down_time = unit.get_average_operation_times(start)\n", "\n", " # we need to store the bid quantity for each hour\n", " # and the bid price to calculate the weighted average\n", @@ -904,7 +900,6 @@ " marginal_cost_flex=marginal_cost_flex,\n", " bid_quantity_inflex=bid_quantity_inflex,\n", " foresight=self.foresight,\n", - " avg_down_time=avg_down_time,\n", " )\n", " else:\n", " bid_price_inflex = calculate_EOM_price_if_off(\n", @@ -912,7 +907,6 @@ " marginal_cost_inflex=marginal_cost_inflex,\n", " bid_quantity_inflex=bid_quantity_inflex,\n", " op_time=op_time,\n", - " avg_op_time=avg_op_time,\n", " )\n", "\n", " if unit.outputs[\"heat\"].at[start] > 0:\n", @@ -981,28 +975,7 @@ " # delete bids with zero volume\n", " bids = self.remove_empty_bids(bids)\n", "\n", - " return bids\n", - "\n", - " def calculate_reward(\n", - " self,\n", - " unit,\n", - " marketconfig: MarketConfig,\n", - " orderbook: Orderbook,\n", - " ):\n", - " \"\"\"\n", - " Calculates and writes the reward (costs and profit).\n", - "\n", - " Args:\n", - " unit (SupportsMinMax): A unit that the unit operator manages.\n", - " marketconfig (MarketConfig): A market configuration.\n", - " orderbook (Orderbook): An orderbook with accepted and rejected orders for the unit.\n", - " \"\"\"\n", - "\n", - " calculate_reward_EOM(\n", - " unit=unit,\n", - " marketconfig=marketconfig,\n", - " orderbook=orderbook,\n", - " )" + " return bids" ] }, { @@ -1011,7 +984,7 @@ "source": [ "With this the strategy is ready to test.\n", "As before, we add the new class to our world and load the scenario.\n", - "Additionally, we now have to change the set bidding strategy for one example unit. Here, we choose the combined cycle gas turbine and set its strategy to our modified class 'blockStrategy'.\n", + "Additionally, we now have to change the set bidding strategy for one example unit. Here, we choose the combined cycle gas turbine and set its strategy to our modified class 'BlockStrategy'.\n", "\n", "Don't forget to also add the defined advanced clearing mechanism to the newly generated world." ] @@ -1028,7 +1001,7 @@ "world.clearing_mechanisms[\"pay_as_clear_advanced\"] = AdvancedClearingRole\n", "\n", "# overwrite the bidding strategy for all units\n", - "world.bidding_strategies[\"new_advanced_strategy\"] = blockStrategy\n", + "world.bidding_strategies[\"new_advanced_strategy\"] = BlockStrategy\n", "\n", "load_scenario_folder(\n", " world,\n", @@ -1063,7 +1036,7 @@ "source": [ "## 5. Linked orders\n", "\n", - "In the same way, we can further adjust the block bid strategy to integrate the flexible bids as linked bids. Deviations to blockStrategy are marked with # ====== new:" + "In the same way, we can further adjust the block bid strategy to integrate the flexible bids as linked bids. Deviations to BlockStrategy are marked with # ====== new:" ] }, { @@ -1072,7 +1045,7 @@ "metadata": {}, "outputs": [], "source": [ - "class linkedStrategy(BaseStrategy):\n", + "class LinkedStrategy(flexableEOM):\n", " \"\"\"\n", " A strategy that bids on the EOM-market with block and linked bids.\n", " \"\"\"\n", @@ -1118,7 +1091,6 @@ "\n", " bids = []\n", " op_time = unit.get_operation_time(start)\n", - " avg_op_time, avg_down_time = unit.get_average_operation_times(start)\n", "\n", " # we need to store the bid quantity for each hour\n", " # and the bid price to calculate the weighted average\n", @@ -1171,21 +1143,19 @@ " # =============================================================================\n", " if op_time > 0:\n", " bid_price_inflex = calculate_EOM_price_if_on(\n", - " unit,\n", - " market_config.market_id,\n", - " start,\n", - " marginal_cost_flex,\n", - " bid_quantity_inflex,\n", - " self.foresight,\n", - " avg_down_time,\n", + " unit=unit,\n", + " market_id=market_config.market_id,\n", + " start=start,\n", + " marginal_cost_flex=marginal_cost_flex,\n", + " bid_quantity_inflex=bid_quantity_inflex,\n", + " foresight=self.foresight,\n", " )\n", " else:\n", " bid_price_inflex = calculate_EOM_price_if_off(\n", - " unit,\n", - " marginal_cost_inflex,\n", - " bid_quantity_inflex,\n", - " op_time,\n", - " avg_op_time,\n", + " unit=unit,\n", + " marginal_cost_inflex=marginal_cost_inflex,\n", + " bid_quantity_inflex=bid_quantity_inflex,\n", + " op_time=op_time,\n", " )\n", "\n", " if unit.outputs[\"heat\"].at[start] > 0:\n", @@ -1255,34 +1225,14 @@ " # delete bids with zero volume\n", " bids = self.remove_empty_bids(bids)\n", "\n", - " return bids\n", - "\n", - " def calculate_reward(\n", - " self,\n", - " unit,\n", - " marketconfig: MarketConfig,\n", - " orderbook: Orderbook,\n", - " ):\n", - " \"\"\"\n", - " Calculates and writes the reward (costs and profit).\n", - "\n", - " Args:\n", - " unit (SupportsMinMax): A unit that the unit operator manages.\n", - " marketconfig (MarketConfig): A market configuration.\n", - " orderbook (Orderbook): An orderbook with accepted and rejected orders for the unit.\n", - " \"\"\"\n", - " calculate_reward_EOM(\n", - " unit=unit,\n", - " marketconfig=marketconfig,\n", - " orderbook=orderbook,\n", - " )" + " return bids" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Here, we now add the new class linkedStrategy to our available bidding_strategies, overwrite the bidding strategies to linkedStrategy and then load our scenario." + "Here, we now add the new class LinkedStrategy to our available bidding_strategies, overwrite the bidding strategies to LinkedStrategy and then load our scenario." ] }, { @@ -1297,7 +1247,7 @@ "world.clearing_mechanisms[\"pay_as_clear_advanced\"] = AdvancedClearingRole\n", "\n", "# overwrite the bidding strategy for all units\n", - "world.bidding_strategies[\"new_advanced_strategy\"] = linkedStrategy\n", + "world.bidding_strategies[\"new_advanced_strategy\"] = LinkedStrategy\n", "\n", "load_scenario_folder(\n", " world,\n", @@ -1528,7 +1478,7 @@ "toc_visible": true }, "kernelspec": { - "display_name": "assume", + "display_name": "assume-framework", "language": "python", "name": "python3" }, diff --git a/tests/conftest.py b/tests/conftest.py index 1ae381cf3..6bea8009b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,7 +48,7 @@ def mock_market_config(): @pytest.fixture def mock_supports_minmax(): index = pd.date_range( - start=datetime(2023, 7, 1), end=datetime(2023, 7, 2), freq="1h" + start=datetime(2023, 7, 1), end=datetime(2023, 7, 3), freq="1h" ) forecaster = NaiveForecast(index, demand=150) return MockMinMaxUnit(forecaster=forecaster) diff --git a/tests/test_naive_strategies.py b/tests/test_naive_strategies.py index 18f68a6c8..5e83f5e71 100644 --- a/tests/test_naive_strategies.py +++ b/tests/test_naive_strategies.py @@ -9,8 +9,6 @@ from assume.common.market_objects import MarketProduct from assume.common.utils import get_available_products from assume.strategies import ( - NaiveNegReserveStrategy, - NaivePosReserveStrategy, NaiveProfileStrategy, NaiveSingleBidStrategy, ) @@ -30,32 +28,6 @@ def test_naive_strategy(mock_market_config, mock_supports_minmax): assert bids[0]["volume"] == 400 -def test_naive_pos_strategy(mock_market_config, mock_supports_minmax): - """ - calculates bids for - """ - strategy = NaivePosReserveStrategy() - mc = mock_market_config - product_tuples = [(start, end, None)] - bids = strategy.calculate_bids( - mock_supports_minmax, mc, product_tuples=product_tuples - ) - assert bids[0]["price"] == 0 - assert bids[0]["volume"] == 400 - assert len(bids) == 1 - - -def test_naive_neg_strategy(mock_market_config, mock_supports_minmax): - strategy = NaiveNegReserveStrategy() - mc = mock_market_config - unit = mock_supports_minmax - product_tuples = [(start, end, None)] - bids = strategy.calculate_bids(unit, mc, product_tuples=product_tuples) - assert bids[0]["price"] == 0 - assert bids[0]["volume"] == 100 - assert len(bids) == 1 - - def test_naive_da_strategy(mock_market_config, mock_supports_minmax): # test with mock market strategy = NaiveProfileStrategy() diff --git a/tests/test_units_operator.py b/tests/test_units_operator.py index 0f939ec15..2f46d2c9e 100644 --- a/tests/test_units_operator.py +++ b/tests/test_units_operator.py @@ -16,7 +16,7 @@ from assume.common.forecasts import NaiveForecast from assume.common.market_objects import MarketConfig, MarketProduct from assume.common.units_operator import UnitsOperator -from assume.common.utils import datetime2timestamp +from assume.common.utils import datetime2timestamp, timestamp2datetime from assume.strategies.naive_strategies import NaiveSingleBidStrategy from assume.units.demand import Demand from assume.units.powerplant import PowerPlant @@ -240,8 +240,11 @@ async def test_get_actual_dispatch(units_operator: UnitsOperator): last = clock.time clock.set_time(clock.time + 3600) + # WHEN actual_dispatch is called - market_dispatch, unit_dfs = units_operator.get_actual_dispatch("energy", last) + market_dispatch, unit_dfs = units_operator.get_actual_dispatch( + "energy", timestamp2datetime(last), timestamp2datetime(clock.time) + ) # THEN resulting unit dispatch dataframe contains one row # which is for the current time - as we must know our current dispatch assert datetime2timestamp(unit_dfs[0]["time"][0]) == last @@ -255,7 +258,9 @@ async def test_get_actual_dispatch(units_operator: UnitsOperator): clock.set_time(clock.time + 3600) # THEN resulting unit dispatch dataframe contains only one row with current dispatch - market_dispatch, unit_dfs = units_operator.get_actual_dispatch("energy", last) + market_dispatch, unit_dfs = units_operator.get_actual_dispatch( + "energy", timestamp2datetime(last), timestamp2datetime(clock.time) + ) assert datetime2timestamp(unit_dfs[0]["time"][0]) == last assert datetime2timestamp(unit_dfs[0]["time"][1]) == clock.time assert len(unit_dfs[0]["time"]) == 2 @@ -264,7 +269,9 @@ async def test_get_actual_dispatch(units_operator: UnitsOperator): last = clock.time clock.set_time(clock.time + 3600) - market_dispatch, unit_dfs = units_operator.get_actual_dispatch("energy", last) + market_dispatch, unit_dfs = units_operator.get_actual_dispatch( + "energy", timestamp2datetime(last), timestamp2datetime(clock.time) + ) assert datetime2timestamp(unit_dfs[0]["time"][0]) == last assert datetime2timestamp(unit_dfs[0]["time"][1]) == clock.time assert len(unit_dfs[0]["time"]) == 2 diff --git a/tests/test_utils.py b/tests/test_utils.py index cda27d344..2588b4e67 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -485,8 +485,8 @@ def test_create_date_range(): for i in range(n): q_pd_slice = series[start:new_end] res_slice_pd = time.time() - t - # more than at least factor 5 - assert res_slice < res_slice_pd / 5 + # more than at least factor 2 + assert res_slice < res_slice_pd / 2 # check that setting items is faster: t = time.time() @@ -500,8 +500,8 @@ def test_create_date_range(): for i in range(n): series[start] = 1 res_slice_pd = time.time() - t - # more than at least factor 5 - assert res_slice < res_slice_pd / 5 + # more than at least factor 2 + assert res_slice < res_slice_pd / 2 # check that setting slices is faster t = time.time() @@ -515,8 +515,8 @@ def test_create_date_range(): for i in range(n): series[start:new_end] = 17 res_slice_pd = time.time() - t - # more than at least factor 5 - assert res_slice < res_slice_pd / 5 + # more than at least factor 2 + assert res_slice < res_slice_pd / 2 se = pd.Series(0.0, index=fs.index.get_date_list()) se.loc[start] @@ -570,8 +570,6 @@ def test_set_list(): for i in range(n): result = series[dr] res_pd = time.time() - t - print(res_fds) - print(res_pd) assert res_fds < res_pd # check setting list or series with single value @@ -584,8 +582,6 @@ def test_set_list(): for i in range(n): series[dr] = 3 res_pd = time.time() - t - print(res_fds) - print(res_pd) assert res_fds < res_pd # check setting list or series with a series @@ -600,8 +596,6 @@ def test_set_list(): for i in range(n): series[dr] = d_new res_pd = time.time() - t - print(res_fds) - print(res_pd) assert res_fds < res_pd # check setting list or series with a list @@ -616,8 +610,6 @@ def test_set_list(): for i in range(n): series[dr] = d_new res_pd = time.time() - t - print(res_fds) - print(res_pd) assert res_fds < res_pd