diff --git a/assume/common/base.py b/assume/common/base.py index e5f59d46..3ee60d80 100644 --- a/assume/common/base.py +++ b/assume/common/base.py @@ -7,7 +7,6 @@ from typing import TypedDict import numpy as np -import pandas as pd from assume.common.fast_pandas import FastSeries, TensorFastSeries from assume.common.forecasts import Forecaster @@ -22,22 +21,12 @@ class BaseUnit: """ A base class for a unit. This class is used as a foundation for all units. - Attributes: - id (str): The ID of the unit. - unit_operator (str): The operator of the unit. - technology (str): The technology of the unit. - bidding_strategies (dict[str, BaseStrategy]): The bidding strategies of the unit. - index (pandas.DatetimeIndex): The index of the unit. - node (str, optional): The node of the unit. Defaults to "". - forecaster (Forecaster, optional): The forecast of the unit. Defaults to None. - **kwargs: Additional keyword arguments. - Args: id (str): The ID of the unit. unit_operator (str): The operator of the unit. technology (str): The technology of the unit. bidding_strategies (dict[str, BaseStrategy]): The bidding strategies of the unit. - index (pandas.DatetimeIndex): The index of the unit. + index (FastIndex): The index of the unit. node (str, optional): The node of the unit. Defaults to "". forecaster (Forecaster, optional): The forecast of the unit. Defaults to None. location (tuple[float, float], optional): The location of the unit. Defaults to (0.0, 0.0). @@ -129,10 +118,10 @@ def calculate_bids( def calculate_marginal_cost(self, start: datetime, power: float) -> float: """ - Calculates the marginal cost for the given power. + Calculates the marginal cost for the given power.` Args: - start (pandas.Timestamp): The start time of the dispatch. + start (datetime.datetime): The start time of the dispatch. power (float): The power output of the unit. Returns: @@ -281,7 +270,7 @@ def calculate_cashflow(self, product_type: str, orderbook: Orderbook): cashflow = float( order.get("accepted_price", 0) * order.get("accepted_volume", 0) ) - elapsed_intervals = (end - start) / pd.Timedelta(self.index.freq) + elapsed_intervals = (end - start) / self.index.freq self.outputs[f"{product_type}_cashflow"].loc[start:end_excl] += ( cashflow * elapsed_intervals ) @@ -331,12 +320,12 @@ def calculate_min_max_power( Calculates the min and max power for the given time period. Args: - start (pandas.Timestamp): The start time of the dispatch. - end (pandas.Timestamp): The end time of the dispatch. + start (datetime.datetime): The start time of the dispatch. + end (datetime.datetime): The end time of the dispatch. product_type (str): The product type of the unit. Returns: - tuple[pandas.Series, pandas.Series]: The min and max power for the given time period. + tuple[np.array, np.array]: The min and max power for the given time period. """ def calculate_ramp( @@ -385,26 +374,12 @@ def calculate_ramp( ) return power - def get_clean_spread(self, prices: pd.DataFrame) -> float: - """ - Returns the clean spread for the given prices. - - Args: - prices (pandas.DataFrame): The prices. - - Returns: - float: The clean spread for the given prices. - """ - emission_cost = self.emission_factor * prices["co"].mean() - fuel_cost = prices[self.technology.replace("_combined", "")].mean() - return (fuel_cost + emission_cost) / self.efficiency - def get_operation_time(self, start: datetime) -> int: """ Returns the time the unit is operating (positive) or shut down (negative). Args: - start (datetime): The start time. + start (datetime.datetime): The start time. Returns: int: The operation time as a positive integer if operating, or negative if shut down. @@ -553,27 +528,27 @@ def calculate_min_max_charge( Calculates the min and max charging power for the given time period. Args: - start (pandas.Timestamp): The start time of the dispatch. - end (pandas.Timestamp): The end time of the dispatch. + start (datetime.datetime): The start time of the dispatch. + end (datetime.datetime): The end time of the dispatch. product_type (str, optional): The product type of the unit. Defaults to "energy". Returns: - tuple[pandas.Series, pandas.Series]: The min and max charging power for the given time period. + tuple[np.array, np.array]: The min and max charging power for the given time period. """ def calculate_min_max_discharge( self, start: datetime, end: datetime, product_type="energy" - ) -> tuple[FastSeries, FastSeries]: + ) -> tuple[np.array, np.array]: """ Calculates the min and max discharging power for the given time period. Args: - start (pandas.Timestamp): The start time of the dispatch. - end (pandas.Timestamp): The end time of the dispatch. + start (datetime.datetime): The start time of the dispatch. + end (datetime.datetime): The end time of the dispatch. product_type (str, optional): The product type of the unit. Defaults to "energy". Returns: - tuple[pandas.Series, pandas.Series]: The min and max discharging power for the given time period. + tuple[np.array, np.array]: The min and max discharging power for the given time period. """ def get_soc_before(self, dt: datetime) -> float: @@ -593,20 +568,6 @@ def get_soc_before(self, dt: datetime) -> float: else: return self.outputs["soc"].at[dt - self.index.freq] - def get_clean_spread(self, prices: pd.DataFrame) -> float: - """ - Returns the clean spread for the given prices. - - Args: - prices (pandas.DataFrame): The prices. - - Returns: - float: The clean spread for the given prices. - """ - emission_cost = self.emission_factor * prices["co"].mean() - fuel_cost = prices[self.technology.replace("_combined", "")].mean() - return (fuel_cost + emission_cost) / self.efficiency_charge - def calculate_ramp_discharge( self, previous_power: float, diff --git a/assume/common/fast_pandas.py b/assume/common/fast_pandas.py index b36b9407..935ef43e 100644 --- a/assume/common/fast_pandas.py +++ b/assume/common/fast_pandas.py @@ -143,7 +143,7 @@ def __contains__(self, date: datetime) -> bool: Check if a datetime is within the index range and aligned with the frequency. Parameters: - date (datetime): The datetime to check. + date (datetime.datetime): The datetime to check. Returns: bool: True if the datetime is in the index range and aligned; False otherwise. @@ -224,7 +224,7 @@ def _get_idx_from_date(self, date: datetime) -> int: Convert a datetime to its corresponding index in the range. Parameters: - date (datetime): The datetime to convert. + date (datetime.datetime): The datetime to convert. Returns: int: The index of the datetime in the index range. @@ -306,6 +306,10 @@ def __init__( value (float | np.ndarray, optional): Initial value(s) for the data. Defaults to 0.0. name (str, optional): Name of the series. Defaults to an empty string. """ + # check that the index is a FastIndex + if not isinstance(index, FastIndex): + raise TypeError("In FastSeries, index must be a FastIndex object.") + self._index = index self._name = name self.loc = self # Allow adjusting loc as well @@ -835,7 +839,7 @@ def as_pd_series( return pd.Series(data_slice, index=index, name=name if name else self.name) @staticmethod - def from_series(series: pd.Series): + def from_pandas_series(series: pd.Series): """ Create a FastSeries from a pandas Series. diff --git a/assume/common/forecasts.py b/assume/common/forecasts.py index f79cd788..ddec6f73 100644 --- a/assume/common/forecasts.py +++ b/assume/common/forecasts.py @@ -84,21 +84,6 @@ def get_price(self, fuel_type: str) -> FastSeries: return self[f"fuel_price_{fuel_type}"] - def _to_fast_series(self, value, name: str) -> FastSeries: - """ - Converts a value to a FastSeries based on self.index. - - Args: - value (float | list | pd.Series): The value to convert. - name (str): Name of the series. - - Returns: - FastSeries: The converted FastSeries. - """ - if isinstance(value, pd.Series): - value = value.values # Use the values as an array for consistency - return FastSeries(index=self.index, value=value, name=name) - class CsvForecaster(Forecaster): """ @@ -427,7 +412,7 @@ def convert_forecasts_to_fast_series(self): for column_name in self.forecasts.columns: # Convert each column in self.forecasts to FastSeries forecast_series = self.forecasts[column_name] - fast_forecasts[column_name] = FastSeries.from_series(forecast_series) + fast_forecasts[column_name] = FastSeries.from_pandas_series(forecast_series) # Replace the DataFrame with the dictionary of FastSeries self.forecasts = fast_forecasts @@ -547,11 +532,17 @@ def __init__( self.index = FastIndex(start=index[0], end=index[-1], freq=pd.infer_freq(index)) # Convert attributes to FastSeries if they are not already Series - self.fuel_price = self._to_fast_series(fuel_price, "fuel_price") - self.availability = self._to_fast_series(availability, "availability") - self.co2_price = self._to_fast_series(co2_price, "co2_price") - self.demand = self._to_fast_series(demand, "demand") - self.price_forecast = self._to_fast_series(price_forecast, "price_forecast") + self.fuel_price = FastSeries( + index=self.index, value=fuel_price, name="fuel_price" + ) + self.availability = FastSeries( + index=self.index, value=availability, name="availability" + ) + self.co2_price = FastSeries(index=self.index, value=co2_price, name="co2_price") + self.demand = FastSeries(index=self.index, value=demand, name="demand") + self.price_forecast = FastSeries( + index=self.index, value=price_forecast, name="price_forecast" + ) def __getitem__(self, column: str) -> FastSeries: """ diff --git a/assume/common/units_operator.py b/assume/common/units_operator.py index b130f5d5..0d3a219a 100644 --- a/assume/common/units_operator.py +++ b/assume/common/units_operator.py @@ -301,7 +301,7 @@ def get_actual_dispatch( Args: product_type (str): The product type for which this is done - last (datetime): the last date until which the dispatch was already sent + last (datetime.datetime): the last date until which the dispatch was already sent Returns: tuple[list[tuple[datetime, float, str, str]], list[dict]]: market_dispatch and unit_dispatch dataframes @@ -336,6 +336,7 @@ def get_actual_dispatch( dispatch["time"] = unit.index.get_date_list(start, end) dispatch["unit"] = unit_id unit_dispatch.append(dispatch) + return market_dispatch, unit_dispatch def write_actual_dispatch(self, product_type: str) -> None: diff --git a/assume/common/utils.py b/assume/common/utils.py index 0c224871..134f347a 100644 --- a/assume/common/utils.py +++ b/assume/common/utils.py @@ -333,31 +333,6 @@ def aggregate_step_amount(orderbook: Orderbook, begin=None, end=None, groupby=No return [j for sub in list(aggregation.values()) for j in sub] -def get_test_demand_orders(power: np.ndarray): - """ - Get test demand orders. - - Args: - power (numpy.ndarray): Power array. - - Returns: - pandas.DataFrame: DataFrame of demand orders. - - Examples: - >>> power = np.array([100, 200, 150]) - >>> get_test_demand_orders(power) - """ - - order_book = {} - for t in range(len(power)): - order_book[t] = dict( - type="demand", hour=t, block_id=t, name="DEM", price=3, volume=-power[t] - ) - demand_order = pd.DataFrame.from_dict(order_book, orient="index") - demand_order = demand_order.set_index(["block_id", "hour", "name"]) - return demand_order - - def separate_orders(orderbook: Orderbook): """ Separate orders with several hours into single hour orders. @@ -674,3 +649,21 @@ def suppress_output(): os.close(saved_stdout_fd) os.close(saved_stderr_fd) os.close(devnull) + + +# Function to parse the duration string +def parse_duration(duration_str): + if duration_str.endswith("d"): + days = int(duration_str[:-1]) + return timedelta(days=days) + elif duration_str.endswith("h"): + hours = int(duration_str[:-1]) + return timedelta(hours=hours) + elif duration_str.endswith("m"): + minutes = int(duration_str[:-1]) + return timedelta(minutes=minutes) + elif duration_str.endswith("s"): + seconds = int(duration_str[:-1]) + return timedelta(seconds=seconds) + else: + raise ValueError(f"Unsupported duration format: {duration_str}") diff --git a/assume/strategies/advanced_orders.py b/assume/strategies/advanced_orders.py index e580e3a4..4022ad77 100644 --- a/assume/strategies/advanced_orders.py +++ b/assume/strategies/advanced_orders.py @@ -2,10 +2,10 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -import pandas as pd from assume.common.base import BaseStrategy, 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, @@ -29,7 +29,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # check if kwargs contains eom_foresight argument - self.foresight = pd.Timedelta(kwargs.get("eom_foresight", "12h")) + self.foresight = parse_duration(kwargs.get("eom_foresight", "12h")) def calculate_bids( self, @@ -219,7 +219,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # check if kwargs contains eom_foresight argument - self.foresight = pd.Timedelta(kwargs.get("eom_foresight", "12h")) + self.foresight = parse_duration(kwargs.get("eom_foresight", "12h")) def calculate_bids( self, diff --git a/assume/strategies/flexable.py b/assume/strategies/flexable.py index 82c3b9ac..b6293742 100644 --- a/assume/strategies/flexable.py +++ b/assume/strategies/flexable.py @@ -4,11 +4,11 @@ from datetime import datetime, timedelta -import pandas as pd +import numpy as np from assume.common.base import BaseStrategy, SupportsMinMax from assume.common.market_objects import MarketConfig, Orderbook, Product -from assume.common.utils import get_products_index +from assume.common.utils import get_products_index, parse_duration class flexableEOM(BaseStrategy): @@ -27,7 +27,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # check if kwargs contains eom_foresight argument - self.foresight = pd.Timedelta(kwargs.get("eom_foresight", "12h")) + self.foresight = parse_duration(kwargs.get("eom_foresight", "12h")) def calculate_bids( self, @@ -199,7 +199,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # check if kwargs contains crm_foresight argument - self.foresight = pd.Timedelta(kwargs.get("crm_foresight", "4h")) + self.foresight = parse_duration(kwargs.get("crm_foresight", "4h")) def calculate_bids( self, @@ -307,7 +307,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # check if kwargs contains crm_foresight argument - self.foresight = pd.Timedelta(kwargs.get("crm_foresight", "4h")) + self.foresight = parse_duration(kwargs.get("crm_foresight", "4h")) def calculate_bids( self, @@ -518,7 +518,7 @@ def get_specific_revenue( and marginal costs for the time defined by the foresight. Args: - price_forecast (pandas.Series): The price forecast. + price_forecast (FastSeries): The price forecast. marginal_cost (float): The marginal cost of the unit. t (datetime.datetime): The start time of the product. foresight (datetime.timedelta): The foresight of the unit. @@ -543,12 +543,21 @@ def calculate_reward_EOM( orderbook: Orderbook, ): """ - Calculates and writes reward (costs and profit) for EOM market. + Calculate and write reward, profit and regret to unit outputs. 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. + 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 @@ -558,59 +567,65 @@ def calculate_reward_EOM( unit.forecaster.get_availability(unit.id)[products_index] * unit.max_power ) - profit = pd.Series(0.0, index=products_index) - reward = pd.Series(0.0, index=products_index) - opportunity_cost = pd.Series(0.0, index=products_index) - costs = pd.Series(0.0, index=products_index) + # 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 = order["end_time"] - end_excl = end - unit.index.freq + end_excl = order["end_time"] - unit.index.freq - order_times = pd.date_range(start, end_excl, freq=unit.index.freq) + order_times = unit.index[start:end_excl] + accepted_volume = order["accepted_volume"] + accepted_price = order["accepted_price"] for start, max_pwr in zip(order_times, max_power): + idx = index_map.get(start) + marginal_cost = unit.calculate_marginal_cost( - start, unit.outputs[product_type].loc[start] + start, unit.outputs[product_type].at[start] ) - if isinstance(order["accepted_volume"], dict): - accepted_volume = order["accepted_volume"][start] + if isinstance(accepted_volume, dict): + accepted_volume = accepted_volume.get(start, 0) else: - accepted_volume = order["accepted_volume"] + accepted_volume = accepted_volume - if isinstance(order["accepted_price"], dict): - accepted_price = order["accepted_price"][start] + if isinstance(accepted_price, dict): + accepted_price = accepted_price.get(start, 0) else: - accepted_price = order["accepted_price"] + 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_pwr - unit.outputs[product_type].loc[start] + max_pwr - 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[start] = max(order_opportunity_cost, 0) - profit[start] += accepted_price * accepted_volume + opportunity_cost[idx] = max(order_opportunity_cost, 0) + profit[idx] += accepted_price * accepted_volume # consideration of start-up costs - for start in products_index: + for i, start in enumerate(products_index): op_time = unit.get_operation_time(start) - marginal_cost = unit.calculate_marginal_cost( - start, unit.outputs[product_type].loc[start] - ) - costs[start] += marginal_cost * unit.outputs[product_type].loc[start] + output = unit.outputs[product_type].at[start] + marginal_cost = unit.calculate_marginal_cost(start, output) + costs[i] += marginal_cost * output - if unit.outputs[product_type].loc[start] != 0 and op_time < 0: + if output != 0 and op_time < 0: start_up_cost = unit.get_starting_costs(op_time) - costs[start] += start_up_cost + costs[i] += start_up_cost - profit += -costs + profit -= costs scaling = 0.1 / unit.max_power regret_scale = 0.0 reward = (profit - regret_scale * opportunity_cost) * scaling @@ -620,3 +635,6 @@ def calculate_reward_EOM( unit.outputs["reward"].loc[products_index] = reward unit.outputs["regret"].loc[products_index] = opportunity_cost unit.outputs["total_costs"].loc[products_index] = costs + + if unit.outputs["rl_reward"] is not None: + unit.outputs["rl_reward"].append(reward) diff --git a/assume/strategies/flexable_storage.py b/assume/strategies/flexable_storage.py index a4e9a1fa..e24ff967 100644 --- a/assume/strategies/flexable_storage.py +++ b/assume/strategies/flexable_storage.py @@ -5,10 +5,10 @@ from datetime import timedelta import numpy as np -import pandas as pd from assume.common.base import BaseStrategy, SupportsMinMaxCharge from assume.common.market_objects import MarketConfig, Orderbook, Product +from assume.common.utils import parse_duration class flexableEOMStorage(BaseStrategy): @@ -21,7 +21,7 @@ class flexableEOMStorage(BaseStrategy): Otherwise, the unit will charge with the price defined as the average price multiplied by the charge efficiency of the unit. Attributes: - foresight (pandas.Timedelta): Foresight for the average price calculation. + foresight (datetime.timedelta): Foresight for the average price calculation. Args: *args: Additional arguments. @@ -31,7 +31,7 @@ class flexableEOMStorage(BaseStrategy): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.foresight = pd.Timedelta(kwargs.get("eom_foresight", "12h")) + self.foresight = parse_duration(kwargs.get("eom_foresight", "12h")) def calculate_bids( self, @@ -179,23 +179,20 @@ def calculate_reward( 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(0.0, index=index) - for start in index: - if unit.outputs[product_type][start] != 0: - costs[start] += abs( - unit.outputs[product_type][start] - * unit.calculate_marginal_cost( - start, unit.outputs[product_type][start] - ) - ) - - unit.outputs["profit"][index] = ( - unit.outputs[f"{product_type}_cashflow"][index] - costs + end_excl = order["end_time"] - unit.index.freq + + # Extract outputs and costs in one step + outputs = unit.outputs[product_type].loc[start:end_excl] + costs = outputs.apply( + lambda x: abs(x * unit.calculate_marginal_cost(start, x)) + if x != 0 + else 0 ) - unit.outputs["total_costs"][index] = costs + + unit.outputs["profit"].loc[start:end_excl] = ( + unit.outputs[f"{product_type}_cashflow"].loc[start:end_excl] - costs + ) + unit.outputs["total_costs"].loc[start:end_excl] = costs class flexablePosCRMStorage(BaseStrategy): @@ -206,7 +203,7 @@ class flexablePosCRMStorage(BaseStrategy): Otherwise, the strategy bids the capacity_price for the capacity_pos product. Attributes: - foresight (pandas.Timedelta): Foresight for the average price calculation. + foresight (datetime.timedelta): Foresight for the average price calculation. Args: *args: Additional arguments. @@ -217,7 +214,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # check if kwargs contains crm_foresight argument - self.foresight = pd.Timedelta(kwargs.get("crm_foresight", "4h")) + self.foresight = parse_duration(kwargs.get("crm_foresight", "4h")) def calculate_bids( self, @@ -337,7 +334,7 @@ class flexableNegCRMStorage(BaseStrategy): A strategy that bids the energy_price or the capacity_price of the unit on the negative CRM(reserve market). Attributes: - foresight (pandas.Timedelta): Foresight for the average price calculation. + foresight (datetime.timedelta): Foresight for the average price calculation. Args: *args: Additional arguments. @@ -347,7 +344,7 @@ class flexableNegCRMStorage(BaseStrategy): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.foresight = pd.Timedelta(kwargs.get("crm_foresight", "4h")) + self.foresight = parse_duration(kwargs.get("crm_foresight", "4h")) def calculate_bids( self, @@ -444,9 +441,9 @@ def calculate_price_average(current_time, foresight, price_forecast): Calculates the average price for a given foresight and returns the average price. Args: - current_time (pandas.Timestamp): The current time. - foresight (pandas.Timedelta): The foresight. - price_forecast (pandas.Series): The price forecast. + current_time (datetime.datetime): The current time. + foresight (datetime.timedelta): The foresight. + price_forecast (FastSeries): The price forecast. Returns: float: The average price. @@ -454,7 +451,7 @@ def calculate_price_average(current_time, foresight, price_forecast): start = max(current_time - foresight, price_forecast.index[0]) end = min(current_time + foresight, price_forecast.index[-1]) - average_price = np.mean(price_forecast[start:end]) + average_price = np.mean(price_forecast.loc[start:end]) return average_price @@ -468,8 +465,8 @@ def get_specific_revenue(unit, marginal_cost, t, foresight, price_forecast): unit (SupportsMinMaxCharge): The unit that is dispatched. marginal_cost (float): The marginal cost. t (datetime.datetime): The start time of the product. - foresight (pandas.Timedelta): The foresight. - price_forecast (pandas.Series): The price forecast. + foresight (datetime.timedelta): The foresight. + price_forecast (FastSeries): The price forecast. Returns: float: The specific revenue. diff --git a/assume/strategies/learning_advanced_orders.py b/assume/strategies/learning_advanced_orders.py index ff61aeef..63b01b66 100644 --- a/assume/strategies/learning_advanced_orders.py +++ b/assume/strategies/learning_advanced_orders.py @@ -5,12 +5,11 @@ from datetime import datetime import numpy as np -import pandas as pd import torch as th from assume.common.base import SupportsMinMax from assume.common.market_objects import MarketConfig, Orderbook, Product -from assume.common.utils import get_products_index +from assume.strategies.flexable import calculate_reward_EOM from assume.strategies.learning_strategies import RLStrategy @@ -257,7 +256,7 @@ def create_observation( end_excl = end - unit.index.freq # get the forecast length depending on the time unit considered in the modelled unit - forecast_len = pd.Timedelta((self.foresight - 1) * unit.index.freq) + forecast_len = (self.foresight - 1) * unit.index.freq # ============================================================================= # 1.1 Get the Observations, which are the basis of the action decision @@ -375,89 +374,4 @@ def calculate_reward( """ - # ============================================================================= - # 4. Calculate Reward - # ============================================================================= - # function is called after the market is cleared and we get the market feedback, - # so we can calculate the profit - - product_type = marketconfig.product_type - products_index = get_products_index(orderbook) - - max_power = ( - unit.forecaster.get_availability(unit.id)[products_index] * unit.max_power - ) - - profit = pd.Series(0.0, index=products_index) - reward = pd.Series(0.0, index=products_index) - opportunity_cost = pd.Series(0.0, index=products_index) - costs = pd.Series(0.0, index=products_index) - - # iterate over all orders in the orderbook, to calculate order specific profit - for order in orderbook: - start = order["start_time"] - end = order["end_time"] - end_excl = end - unit.index.freq - - order_times = pd.date_range(start, end_excl, freq=unit.index.freq) - - # calculate profit as income - running_cost from this event - - for start in order_times: - marginal_cost = unit.calculate_marginal_cost( - start, unit.outputs[product_type].loc[start] - ) - if isinstance(order["accepted_volume"], dict): - accepted_volume = order["accepted_volume"][start] - else: - accepted_volume = order["accepted_volume"] - - if isinstance(order["accepted_price"], dict): - accepted_price = order["accepted_price"][start] - else: - accepted_price = order["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[start] - unit.outputs[product_type].loc[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[start] = max(order_opportunity_cost, 0) - profit[start] += accepted_price * accepted_volume - - # consideration of start-up costs, which are evenly divided between the - # upward and downward regulation events - for start in products_index: - op_time = unit.get_operation_time(start) - - marginal_cost = unit.calculate_marginal_cost( - start, unit.outputs[product_type].loc[start] - ) - costs[start] += marginal_cost * unit.outputs[product_type].loc[start] - - if unit.outputs[product_type].loc[start] != 0 and op_time < 0: - start_up_cost = unit.get_starting_costs(op_time) - costs[start] += start_up_cost - - # --------------------------- - # 4.1 Calculate Reward - # The straight forward implementation would be reward = profit, yet we would like to give the agent more guidance - # in the learning process, so we add a regret term to the reward, which is the opportunity cost - # define the reward and scale it - - profit += -costs - scaling = 1 / (unit.max_power * self.max_bid_price) - 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 - - unit.outputs["rl_rewards"].append(reward) + calculate_reward_EOM(unit, marketconfig, orderbook) diff --git a/assume/strategies/learning_strategies.py b/assume/strategies/learning_strategies.py index 393c2ab0..985e4b54 100644 --- a/assume/strategies/learning_strategies.py +++ b/assume/strategies/learning_strategies.py @@ -7,7 +7,6 @@ from pathlib import Path import numpy as np -import pandas as pd import torch as th from assume.common.base import LearningStrategy, SupportsMinMax, SupportsMinMaxCharge @@ -391,7 +390,7 @@ def create_observation( end_excl = end - unit.index.freq # get the forecast length depending on the tme unit considered in the modelled unit - forecast_len = pd.Timedelta((self.foresight - 1) * unit.index.freq) + forecast_len = (self.foresight - 1) * unit.index.freq # ============================================================================= # 1.1 Get the Observations, which are the basis of the action decision @@ -992,7 +991,7 @@ def create_observation( end_excl = end - unit.index.freq # get the forecast length depending on the tme unit considered in the modelled unit - forecast_len = pd.Timedelta((self.foresight - 1) * unit.index.freq) + forecast_len = (self.foresight - 1) * unit.index.freq # ============================================================================= # 1.1 Get the Observations, which are the basis of the action decision diff --git a/assume/units/demand.py b/assume/units/demand.py index 0c5cbe9a..5a50edfa 100644 --- a/assume/units/demand.py +++ b/assume/units/demand.py @@ -2,9 +2,10 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -import numbers from datetime import datetime +import numpy as np + from assume.common.base import SupportsMinMax from assume.common.fast_pandas import FastSeries from assume.common.forecasts import Forecaster @@ -40,7 +41,7 @@ def __init__( min_power: float, forecaster: Forecaster, node: str = "node0", - price: float | FastSeries = 3000.0, + price: float = 3000.0, location: tuple[float, float] = (0.0, 0.0), **kwargs, ): @@ -57,39 +58,39 @@ def __init__( """Create a demand unit.""" self.max_power = max_power self.min_power = min_power + if max_power > 0 and min_power <= 0: self.max_power = min_power self.min_power = -max_power + self.ramp_down = max(abs(min_power), abs(max_power)) self.ramp_up = max(abs(min_power), abs(max_power)) - volume = self.forecaster[self.id] - self.volume = -abs(volume) # demand is negative - if isinstance(price, numbers.Real): - price = FastSeries(index=self.index, value=price) - self.price = price + + self.volume = -abs(self.forecaster[self.id]) # demand is negative + self.price = FastSeries(index=self.index, value=price) def execute_current_dispatch( self, start: datetime, end: datetime, - ): + ) -> np.array: """ Execute the current dispatch of the unit. Returns the volume of the unit within the given time range. Args: - start (pandas.Timestamp): The start time of the dispatch. - end (pandas.Timestamp): The end time of the dispatch. + start (datetime.datetime): The start time of the dispatch. + end (datetime.datetime): The end time of the dispatch. Returns: - FastSeries: The volume of the unit within the gicen time range. + np.array: The volume of the unit for the given time range. """ - return self.volume[start:end] + return self.volume.loc[start:end] def calculate_min_max_power( self, start: datetime, end: datetime, product_type="energy" - ) -> tuple[FastSeries, FastSeries]: + ) -> tuple[np.array, np.array]: """ Calculates the minimum and maximum power output of the unit and returns the bid volume as both the minimum and maximum power output of the unit. @@ -102,6 +103,7 @@ def calculate_min_max_power( """ 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: datetime, power: float) -> float: diff --git a/assume/units/powerplant.py b/assume/units/powerplant.py index 4088561b..fd8e59f8 100644 --- a/assume/units/powerplant.py +++ b/assume/units/powerplant.py @@ -6,8 +6,9 @@ from datetime import datetime, timedelta from functools import lru_cache +import numpy as np + from assume.common.base import SupportsMinMax -from assume.common.fast_pandas import FastSeries from assume.common.forecasts import Forecaster from assume.common.market_objects import MarketConfig, Orderbook from assume.common.utils import get_products_index @@ -28,7 +29,7 @@ class PowerPlant(SupportsMinMax): max_power (float): The maximum power output capacity of the power plant in MW. min_power (float, optional): The minimum power output capacity of the power plant in MW. Defaults to 0.0 MW. efficiency (float, optional): The efficiency of the power plant in converting fuel to electricity. Defaults to 1.0. - additional_cost (Union[float, FastSeries], optional): Additional costs associated with power generation, in EUR/MWh. Defaults to 0. + additional_cost (float, optional): Additional costs associated with power generation, in EUR/MWh. Defaults to 0. partial_load_eff (bool, optional): Does the efficiency vary at part loads? Defaults to False. fuel_type (str, optional): The type of fuel used by the power plant for power generation. Defaults to "others". emission_factor (float, optional): The emission factor associated with the power plant's fuel type (CO2 emissions per unit of energy produced). Defaults to 0.0. @@ -58,7 +59,7 @@ def __init__( max_power: float, min_power: float = 0.0, efficiency: float = 1.0, - additional_cost: float | FastSeries = 0.0, + additional_cost: float = 0.0, partial_load_eff: bool = False, fuel_type: str = "others", emission_factor: float = 0.0, @@ -128,7 +129,7 @@ def execute_current_dispatch( self, start: datetime, end: datetime, - ): + ) -> np.array: """ Executes the current dispatch of the unit based on the provided timestamps. @@ -140,16 +141,16 @@ def execute_current_dispatch( end (pandas.Timestamp): The end time of the dispatch. Returns: - FastSeries: The volume of the unit within the given time range. + np.array: The volume of the unit within the given time range. """ start = max(start, self.index[0]) max_power = ( - self.forecaster.get_availability(self.id)[start:end] * self.max_power + self.forecaster.get_availability(self.id).loc[start:end] * self.max_power ) for t, max_pwr in zip(self.index[start:end], max_power): - current_power = self.outputs["energy"][t] + current_power = self.outputs["energy"].at[t] previous_power = self.get_output_before(t) op_time = self.get_operation_time(t) @@ -159,7 +160,7 @@ def execute_current_dispatch( current_power = min(current_power, max_pwr) current_power = max(current_power, self.min_power) - self.outputs["energy"][t] = current_power + self.outputs["energy"].at[t] = current_power return self.outputs["energy"].loc[start:end] @@ -178,7 +179,8 @@ def set_dispatch_plan( products_index = get_products_index(orderbook) max_power = ( - self.forecaster.get_availability(self.id)[products_index] * self.max_power + self.forecaster.get_availability(self.id).loc[products_index] + * self.max_power ) product_type = marketconfig.product_type @@ -241,18 +243,18 @@ def calc_simple_marginal_cost( def calc_marginal_cost_with_partial_eff( self, power_output: float, - timestep: datetime = None, - ) -> float | FastSeries: + timestep: datetime, + ) -> float: """ Calculates the marginal cost of the unit based on power output and timestamp, considering partial efficiency. Returns the marginal cost of the unit. Args: power_output (float): The power output of the unit. - timestep (datetime, optional): The timestamp of the unit. Defaults to None. + timestep (datetime.datetime): The timestamp of the unit. Returns: - float | FastSeries: The marginal cost of the unit. + float: The marginal cost of the unit at the given timestamp. """ fuel_price = self.forecaster.get_price(self.fuel_type).at[timestep] @@ -291,23 +293,17 @@ def calc_marginal_cost_with_partial_eff( efficiency = self.efficiency - eta_loss co2_price = self.forecaster.get_price("co2").at[timestep] - additional_cost = ( - self.additional_cost.at[timestep] - if isinstance(self.additional_cost, FastSeries) - else self.additional_cost - ) - marginal_cost = ( fuel_price / efficiency + co2_price * self.emission_factor / efficiency - + additional_cost + + self.additional_cost ) return marginal_cost def calculate_min_max_power( self, start: datetime, end: datetime, product_type="energy" - ) -> tuple[FastSeries, FastSeries]: + ) -> tuple[np.array, np.array]: """ Calculates the minimum and maximum power output of the unit and returns it. @@ -324,24 +320,25 @@ def calculate_min_max_power( """ end_excl = end - self.index.freq - base_load = self.outputs["energy"][start:end_excl] - heat_demand = self.outputs["heat"][start:end_excl] + base_load = self.outputs["energy"].loc[start:end_excl] + heat_demand = self.outputs["heat"].loc[start:end_excl] + capacity_neg = self.outputs["capacity_neg"].loc[start:end_excl] - capacity_neg = self.outputs["capacity_neg"][start:end_excl] # needed minimum + capacity_neg - what is already sold is actual minimum min_power = self.min_power + capacity_neg - base_load # min_power should be at least the heat demand at that time min_power = min_power.clip(min=heat_demand) - available_power = self.forecaster.get_availability(self.id)[start:end_excl] + available_power = self.forecaster.get_availability(self.id).loc[start:end_excl] # check if available power is larger than max_power and raise an error if so if (available_power > self.max_power).any(): raise ValueError( f"Available power is larger than max_power for unit {self.id} at time {start}." ) + max_power = available_power * self.max_power # provide reserve for capacity_pos - max_power = max_power - self.outputs["capacity_pos"][start:end_excl] + max_power = max_power - self.outputs["capacity_pos"].loc[start:end_excl] # remove what has already been bid max_power = max_power - base_load # make sure that max_power is > 0 for all timesteps diff --git a/assume/units/steel_plant.py b/assume/units/steel_plant.py index 4a5cd671..0704028b 100644 --- a/assume/units/steel_plant.py +++ b/assume/units/steel_plant.py @@ -5,7 +5,6 @@ import logging from datetime import datetime -import pandas as pd import pyomo.environ as pyo from pyomo.opt import ( SolverFactory, @@ -15,6 +14,7 @@ ) from assume.common.base import SupportsMinMax +from assume.common.fast_pandas import FastSeries from assume.common.forecasts import Forecaster from assume.common.market_objects import MarketConfig, Orderbook from assume.common.utils import get_products_index @@ -38,7 +38,6 @@ class SteelPlant(DSMFlex, SupportsMinMax): bidding_strategies (dict): The bidding strategies of the unit. technology (str): The technology of the unit. node (str): The node of the unit. - index (FastIndex): The index for the data of the unit. location (tuple[float, float]): The location of the unit. components (dict[str, dict]): The components of the unit such as Electrolyser, DRI Plant, DRI Storage, and Electric Arc Furnace. objective (str): The objective of the unit, e.g. minimize variable cost ("min_variable_cost"). @@ -421,18 +420,22 @@ def determine_optimal_operation_without_flex(self): "Termination Condition: ", results.solver.termination_condition ) - self.opt_power_requirement = pd.Series( - data=instance.total_power_input.get_values() - ).set_axis(self.index.as_datetimeindex()) + opt_power_requirement = [ + pyo.value(instance.total_power_input[t]) for t in instance.time_steps + ] + self.opt_power_requirement = FastSeries( + index=self.index, value=opt_power_requirement + ) self.total_cost = sum( instance.variable_cost[t].value for t in instance.time_steps ) # Variable cost series - self.variable_cost_series = pd.Series( - data=instance.variable_cost.get_values() - ).set_axis(self.index.as_datetimeindex()) + variable_cost = [ + pyo.value(instance.variable_cost[t]) for t in instance.time_steps + ] + self.variable_cost_series = FastSeries(index=self.index, value=variable_cost) def determine_optimal_operation_with_flex(self): """ @@ -464,14 +467,20 @@ def determine_optimal_operation_with_flex(self): "Termination Condition: ", results.solver.termination_condition ) - self.flex_power_requirement = pd.Series( - data=instance.total_power_input.get_values() - ).set_axis(self.index.as_datetimeindex()) + flex_power_requirement = [ + pyo.value(instance.total_power_input[t]) for t in instance.time_steps + ] + self.flex_power_requirement = FastSeries( + index=self.index, value=flex_power_requirement + ) # Variable cost series - self.variable_cost_series = pd.Series( - data=instance.variable_cost.get_values() - ).set_axis(self.index.as_datetimeindex()) + flex_variable_cost = [ + instance.variable_cost[t].value for t in instance.time_steps + ] + self.flex_variable_cost_series = FastSeries( + index=self.index, value=flex_variable_cost + ) def switch_to_opt(self, instance): """ @@ -558,7 +567,7 @@ def calculate_marginal_cost(self, start: datetime, power: float) -> float: Calculate the marginal cost of the unit based on the provided time and power. Args: - start (pandas.Timestamp): The start time of the dispatch. + start (datetime.datetime): The start time of the dispatch. power (float): The power output of the unit. Returns: diff --git a/assume/units/storage.py b/assume/units/storage.py index 12529e65..48725118 100644 --- a/assume/units/storage.py +++ b/assume/units/storage.py @@ -7,7 +7,6 @@ from functools import lru_cache import numpy as np -import pandas as pd from assume.common.base import SupportsMinMaxCharge from assume.common.fast_pandas import FastSeries @@ -36,8 +35,8 @@ class Storage(SupportsMinMaxCharge): initial_soc (float): The initial state of charge of the storage unit in MWh. efficiency_charge (float): The efficiency of the storage unit while charging. efficiency_discharge (float): The efficiency of the storage unit while discharging. - additional_cost_charge (Union[float, pd.Series], optional): Additional costs associated with power consumption, in EUR/MWh. Defaults to 0. - additional_cost_discharge (Union[float, pd.Series], optional): Additional costs associated with power generation, in EUR/MWh. Defaults to 0. + additional_cost_charge (float, optional): Additional costs associated with power consumption, in EUR/MWh. Defaults to 0. + additional_cost_discharge (float, optional): Additional costs associated with power generation, in EUR/MWh. Defaults to 0. ramp_up_charge (float): The ramp up rate of charging the storage unit in MW/15 minutes (negative value). ramp_down_charge (float): The ramp down rate of charging the storage unit in MW/15 minutes (negative value). ramp_up_discharge (float): The ramp up rate of discharging the storage unit in MW/15 minutes. @@ -61,18 +60,18 @@ def __init__( technology: str, bidding_strategies: dict, forecaster: Forecaster, - max_power_charge: float | pd.Series, - max_power_discharge: float | pd.Series, + max_power_charge: float, + max_power_discharge: float, max_soc: float, - min_power_charge: float | pd.Series = 0.0, - min_power_discharge: float | pd.Series = 0.0, + min_power_charge: float = 0.0, + min_power_discharge: float = 0.0, min_soc: float = 0.0, initial_soc: float = 0.0, soc_tick: float = 0.01, efficiency_charge: float = 1, efficiency_discharge: float = 1, - additional_cost_charge: float | pd.Series = 0.0, - additional_cost_discharge: float | pd.Series = 0.0, + additional_cost_charge: float = 0.0, + additional_cost_discharge: float = 0.0, ramp_up_charge: float = None, ramp_down_charge: float = None, ramp_up_discharge: float = None, @@ -160,7 +159,7 @@ def __init__( self.warm_start_cost = warm_start_cost * max_power_discharge self.cold_start_cost = cold_start_cost * max_power_discharge - def execute_current_dispatch(self, start: datetime, end: datetime): + def execute_current_dispatch(self, start: datetime, end: datetime) -> np.array: """ Executes the current dispatch of the unit based on the provided timestamps. @@ -168,53 +167,56 @@ def execute_current_dispatch(self, start: datetime, end: datetime): Returns the volume of the unit within the given time range. Args: - start (pandas.Timestamp): The start time of the dispatch. - end (pandas.Timestamp): The end time of the dispatch. + start (datetime.datetime): The start time of the dispatch. + end (datetime.datetime): The end time of the dispatch. Returns: - pd.Series: The volume of the unit within the given time range. + np.array: The volume of the unit within the given time range. """ time_delta = self.index.freq / timedelta(hours=1) for t in self.index[start : end - self.index.freq]: delta_soc = 0 - soc = self.outputs["soc"][t] - if self.outputs["energy"][t] > self.max_power_discharge: - self.outputs["energy"][t] = self.max_power_discharge - elif self.outputs["energy"][t] < self.max_power_charge: - self.outputs["energy"][t] = self.max_power_charge + soc = self.outputs["soc"].at[t] + + if self.outputs["energy"].at[t] > self.max_power_discharge: + self.outputs["energy"].at[t] = self.max_power_discharge + elif self.outputs["energy"].at[t] < self.max_power_charge: + self.outputs["energy"].at[t] = self.max_power_charge 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"].at[t] < self.min_power_discharge + and self.outputs["energy"].at[t] > self.min_power_charge + and self.outputs["energy"].at[t] != 0 ): - self.outputs["energy"][t] = 0 + self.outputs["energy"].at[t] = 0 # discharging - if self.outputs["energy"][t] > 0: + if self.outputs["energy"].at[t] > 0: max_soc_discharge = self.calculate_soc_max_discharge(soc) - if self.outputs["energy"][t] > max_soc_discharge: - self.outputs["energy"][t] = max_soc_discharge + if self.outputs["energy"].at[t] > max_soc_discharge: + self.outputs["energy"].at[t] = max_soc_discharge time_delta = self.index.freq / timedelta(hours=1) delta_soc = ( - -self.outputs["energy"][t] * time_delta / self.efficiency_discharge + -self.outputs["energy"].at[t] + * time_delta + / self.efficiency_discharge ) # charging - elif self.outputs["energy"][t] < 0: + elif self.outputs["energy"].at[t] < 0: max_soc_charge = self.calculate_soc_max_charge(soc) - if self.outputs["energy"][t] < max_soc_charge: - self.outputs["energy"][t] = max_soc_charge + if self.outputs["energy"].at[t] < max_soc_charge: + self.outputs["energy"].at[t] = max_soc_charge time_delta = self.index.freq / timedelta(hours=1) delta_soc = ( - -self.outputs["energy"][t] * time_delta * self.efficiency_charge + -self.outputs["energy"].at[t] * time_delta * self.efficiency_charge ) - self.outputs["soc"].at[t + self.index.freq] = soc + delta_soc + self.outputs["soc"].loc[t + self.index.freq] = soc + delta_soc return self.outputs["energy"].loc[start:end] @@ -302,19 +304,10 @@ def calculate_marginal_cost( """ if power > 0: - additional_cost = ( - self.additional_cost_discharge.at[start] - if isinstance(self.additional_cost_discharge, pd.Series) - else self.additional_cost_discharge - ) + additional_cost = self.additional_cost_discharge efficiency = self.efficiency_discharge - else: - additional_cost = ( - self.additional_cost_charge.at[start] - if isinstance(self.additional_cost_charge, pd.Series) - else self.additional_cost_charge - ) + additional_cost = self.additional_cost_charge efficiency = self.efficiency_charge marginal_cost = additional_cost / efficiency @@ -360,40 +353,30 @@ def calculate_soc_max_charge( def calculate_min_max_charge( self, start: datetime, end: datetime, product_type="energy" - ) -> tuple[FastSeries, FastSeries]: + ) -> tuple[np.array, np.array]: """ Calculates the min and max charging power for the given time period. This is relative to the already sold output on other markets for the same period. It also adheres to reserved positive and negative capacities. Args: - start (pandas.Timestamp): The start of the current dispatch. - end (pandas.Timestamp): The end of the current dispatch. + start (datetime.datetime): The start of the current dispatch. + end (datetime.datetime): The end of the current dispatch. product_type (str): The product type of the storage unit. Returns: - tuple[FastSeries, FastSeries]: The minimum and maximum charge power levels of the storage unit in MW. + tuple[np.array, np.array]: The minimum and maximum charge power levels of the storage unit in MW. """ end_excl = end - self.index.freq - base_load = self.outputs["energy"][start:end_excl] - capacity_pos = self.outputs["capacity_pos"][start:end_excl] - capacity_neg = self.outputs["capacity_neg"][start:end_excl] + base_load = self.outputs["energy"].loc[start:end_excl] + capacity_pos = self.outputs["capacity_pos"].loc[start:end_excl] + capacity_neg = self.outputs["capacity_neg"].loc[start:end_excl] - min_power_charge = ( - self.min_power_charge[start:end_excl] - if isinstance(self.min_power_charge, pd.Series) - else self.min_power_charge - ) - min_power_charge -= base_load + capacity_pos + min_power_charge = self.min_power_charge - (base_load + capacity_pos) min_power_charge = min_power_charge.clip(max=0) - max_power_charge = ( - self.max_power_charge[start:end_excl] - if isinstance(self.max_power_charge, pd.Series) - else self.max_power_charge - ) - max_power_charge -= base_load + capacity_neg + max_power_charge = self.max_power_charge - (base_load + capacity_neg) max_power_charge = np.where( max_power_charge <= min_power_charge, max_power_charge, 0 ) @@ -402,47 +385,37 @@ def calculate_min_max_charge( ) # restrict charging according to max_soc - max_soc_charge = self.calculate_soc_max_charge(self.outputs["soc"][start]) + max_soc_charge = self.calculate_soc_max_charge(self.outputs["soc"].at[start]) max_power_charge = max_power_charge.clip(min=max_soc_charge) return min_power_charge, max_power_charge def calculate_min_max_discharge( self, start: datetime, end: datetime, product_type="energy" - ) -> tuple[FastSeries, FastSeries]: + ) -> tuple[np.array, np.array]: """ Calculates the min and max discharging power for the given time period. This is relative to the already sold output on other markets for the same period. It also adheres to reserved positive and negative capacities. Args: - start (pandas.Timestamp): The start of the current dispatch. - end (pandas.Timestamp): The end of the current dispatch. + start (datetime.datetime): The start of the current dispatch. + end (datetime.datetime): The end of the current dispatch. product_type (str): The product type of the storage unit. Returns: - tuple[FastSeries, FastSeries]: The minimum and maximum discharge power levels of the storage unit in MW. + tuple[np.array, np.array]: The minimum and maximum discharge power levels of the storage unit in MW. """ end_excl = end - self.index.freq - base_load = self.outputs["energy"][start:end_excl] - capacity_pos = self.outputs["capacity_pos"][start:end_excl] - capacity_neg = self.outputs["capacity_neg"][start:end_excl] + base_load = self.outputs["energy"].loc[start:end_excl] + capacity_pos = self.outputs["capacity_pos"].loc[start:end_excl] + capacity_neg = self.outputs["capacity_neg"].loc[start:end_excl] - min_power_discharge = ( - self.min_power_discharge[start:end_excl] - if isinstance(self.min_power_discharge, pd.Series) - else self.min_power_discharge - ) - min_power_discharge -= base_load + capacity_neg + min_power_discharge = self.min_power_discharge - (base_load + capacity_neg) min_power_discharge = min_power_discharge.clip(min=0) - max_power_discharge = ( - self.max_power_discharge[start:end_excl] - if isinstance(self.max_power_discharge, pd.Series) - else self.max_power_discharge - ) - max_power_discharge -= base_load + capacity_pos + max_power_discharge = self.max_power_discharge - (base_load + capacity_pos) # Adjust max_power_discharge using np.where max_power_discharge = np.where( @@ -455,7 +428,9 @@ def calculate_min_max_discharge( ) # restrict according to min_soc - max_soc_discharge = self.calculate_soc_max_discharge(self.outputs["soc"][start]) + max_soc_discharge = self.calculate_soc_max_discharge( + self.outputs["soc"].at[start] + ) max_power_discharge = max_power_discharge.clip(max=max_soc_discharge) return min_power_discharge, max_power_discharge diff --git a/tests/conftest.py b/tests/conftest.py index 3e3b6e71..f2ac9d09 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from datetime import datetime +import numpy as np import pandas as pd import pytest @@ -28,7 +29,7 @@ def __init__(self, forecaster, **kwargs): def calculate_min_max_power( self, start: datetime, end: datetime, product_type="energy" - ) -> tuple[FastSeries, FastSeries]: + ) -> tuple[np.array, np.array]: min = FastSeries(value=100, index=self.index).loc[start:end] max = FastSeries(value=400, index=self.index).loc[start:end] return min, max diff --git a/tests/test_steel_plant.py b/tests/test_steel_plant.py index d3ae5197..6c8bbecf 100644 --- a/tests/test_steel_plant.py +++ b/tests/test_steel_plant.py @@ -5,6 +5,7 @@ import pandas as pd import pytest +from assume.common.fast_pandas import FastSeries from assume.common.forecasts import NaiveForecast from assume.strategies.naive_strategies import ( NaiveDASteelplantStrategy, @@ -89,7 +90,7 @@ def test_initialize_components(steel_plant): def test_determine_optimal_operation_without_flex(steel_plant): steel_plant.determine_optimal_operation_without_flex() assert steel_plant.opt_power_requirement is not None - assert isinstance(steel_plant.opt_power_requirement, pd.Series) + assert isinstance(steel_plant.opt_power_requirement, FastSeries) instance = steel_plant.model.create_instance() instance = steel_plant.switch_to_opt(instance) @@ -134,11 +135,11 @@ def test_determine_optimal_operation_with_flex(steel_plant): # Ensure that the optimal operation without flexibility is determined first steel_plant.determine_optimal_operation_without_flex() assert steel_plant.opt_power_requirement is not None - assert isinstance(steel_plant.opt_power_requirement, pd.Series) + assert isinstance(steel_plant.opt_power_requirement, FastSeries) steel_plant.determine_optimal_operation_with_flex() assert steel_plant.flex_power_requirement is not None - assert isinstance(steel_plant.flex_power_requirement, pd.Series) + assert isinstance(steel_plant.flex_power_requirement, FastSeries) instance = steel_plant.model.create_instance() instance = steel_plant.switch_to_flex(instance)