Note
You can download this example as a Jupyter notebook or try it out directly in Google Colab.
3. Implementation of a Demand Side Unit and Bidding Strategy#
This tutorial provides a step-by-step guide for implementing a custom Demand Side Unit with a Rule-Based Bidding Strategy in the ASSUME framework. By the end of this guide, you will be familiar with the process of creating and integrating a Demand Side Agent within the electricity market simulation environment provided by ASSUME.
We will cover the following topics:
Essential concepts and terminology in electricity market modeling
Setting up the ASSUME framework
Developing a new Demand Side Unit
Formulating a rule-based bidding strategy
Integrating the new unit and strategy into the ASSUME simulation
1. Introduction to Unit Agents and Bidding Strategy#
The ASSUME framework is a versatile tool for simulating electricity markets, allowing researchers and industry professionals to analyze market dynamics and strategies.
A Unit in ASSUME refers to an entity that participates in the market, either buying or selling electricity. Each unit operates based on a Bidding Strategy, which dictates its market behavior. For Demand Side Management (DSM) Units, this includes adjusting electricity demand in response to market conditions.
In this tutorial, we will create a DSM Unit that represents an Electrolyser, capable of varying its demand to optimize for energy prices.
Understanding Demand Side Management (DSM)
Before we start coding, it’s essential to understand what DSM is and why it’s important in electricity market modeling. DSM allows for the dynamic adjustment of electricity demand, contributing to balanced grid operations.
Understanding the Model
The image below illustrates the concept of a simple Electrolyser unit model:
[12]:
# this cell is used to display the image in the notebook when using colab
# or running the notebook locally
import os
from IPython.display import Image, display
image_path = "assume-repo/docs/source/img/Electrolyzer.png"
alt_image_path = "../../docs/source/img/Electrolyzer.png"
if os.path.exists(image_path):
display(Image(image_path))
elif os.path.exists(alt_image_path):
display(Image(alt_image_path))
The image provides a visual representation of how dynamic efficiency varies based on different factors:
X-Axis: Represents the varying power input to the Electrolyser unit.
Y-Axis: Indicates the efficiency levels that correspond to different power inputs.
Curve: Shows that the efficiency is not constant and varies depending on the current power input to the unit.
Significance of this model
Understanding this model is crucial for several reasons:
Adaptability: The curve suggests that the unit can operate at different efficiency levels, allowing it to adapt to market conditions.
Optimization: Knowing the efficiency levels at various power inputs allows the unit to operate at an optimal point, which is especially crucial in Demand Side Management (DSM) strategies.
Complexity: The non-linear nature of the curve indicates that simple linear models may not be sufficient for capturing the unit’s behavior, highlighting the need for a more complex model.
By understanding this model, you’ll gain valuable insights into how to calculate and utilize dynamic efficiency in the Electrolyser unit, a crucial aspect of DSM in the ASSUME framework.
2. Setting Up ASSUME#
Before we create our custom unit, let’s set up the ASSUME framework. We’ll install the ASSUME core package and clone the repository containing predefined scenarios.
You don’t need to execute the following code cells if you already have the ASSUME framework installed and/or the repository cloned.
[ ]:
import importlib.util
# Check if 'google.colab' is available
IN_COLAB = importlib.util.find_spec("google.colab") is not None
if IN_COLAB:
!pip install assume-framework
[ ]:
if IN_COLAB:
!git clone --depth=1 https://github.com/assume-framework/assume.git assume-repo
Note that Google Colab does not support Docker functionalities, so features dependent on Docker will not be available here.
Select input files path:
We also need to differentiate between the input file paths when using this tutorial in Google Colab and a local environment. The code snippets will include both options for your convenience.
[ ]:
colab_inputs_path = "assume-repo/examples/inputs"
local_inputs_path = "../inputs"
inputs_path = colab_inputs_path if IN_COLAB else local_inputs_path
3. Developing a New Demand Side Unit#
We will now develop a new unit that models an Electrolyser. This unit will be capable of adjusting its electricity consumption based on the market conditions, showcasing DSM capabilities.
3.1 Initializing Core Attributes#
We’ll start by defining the core attributes of our Electrolyser class, such as its power capacity and operational parameters.
ID: A unique identifier for the unit.
Technology: The type of technology used, which in this case is electrolysis for hydrogen production.
Unit Operator: The entity responsible for operating the unit.
Bidding Strategies: The strategies used by the unit for bidding in the electricity market.
Max Power and Min Power: The maximum and minimum electrical power that the unit can handle.
Max Hydrogen and Min Hydrogen: The maximum and minimum hydrogen production levels.
Additional Cost: The fixed operational cost for the unit.
[ ]:
# Initialize the Electrolyser class with core attributes
from datetime import datetime
import pandas as pd
from assume.common.base import BaseStrategy, SupportsMinMax
from assume.common.forecasts import Forecaster
from assume.common.market_objects import MarketConfig, Order, Orderbook, Product
class Electrolyser(SupportsMinMax):
def __init__(
self,
id: str,
technology: str,
unit_operator: str,
bidding_strategies: str,
max_power: float,
min_power: float,
max_hydrogen: float,
min_hydrogen: float,
forecaster: Forecaster,
additional_cost: float,
**kwargs,
):
super().__init__(
id=id,
unit_operator=unit_operator,
technology=technology,
bidding_strategies=bidding_strategies,
forecaster=forecaster,
**kwargs,
)
self.min_hydrogen = min_hydrogen
self.max_hydrogen = max_hydrogen
self.max_power = max_power
self.min_power = min_power
self.additional_cost = additional_cost
# Conversion factor functions will be introduced in section 3.3
self.conversion_factors = self.get_conversion_factors()
# this function is a must be part of any unit class
# as it controls how the unit is dispatched after market clearings
# and is executed after each market clearing
def execute_current_dispatch(
self,
start: datetime,
end: datetime,
):
# Calculate mean power for this time period
avg_power = abs(self.outputs["energy"].loc[start:end]).mean()
# Decide which efficiency point to use
if avg_power < self.min_power:
self.outputs["energy"].loc[start:end] = 0
self.outputs["hydrogen"].loc[start:end] = 0
else:
if avg_power <= 0.35 * self.max_power:
dynamic_conversion_factor = self.conversion_factors[0]
else:
dynamic_conversion_factor = self.conversion_factors[1]
self.outputs["energy"].loc[start:end] = avg_power
self.outputs["hydrogen"].loc[start:end] = (
avg_power / dynamic_conversion_factor
)
return self.outputs["energy"].loc[start:end]
# this function is a must be part of each unit class
# as it dictates which parameters of the unit we would like to save to the database
# or csv files
def as_dict(self) -> dict:
unit_dict = super().as_dict()
unit_dict.update(
{
"max_power": self.max_power,
"min_power": self.min_power,
"min_hydrogen": self.min_hydrogen,
"max_hydrogen": self.max_hydrogen,
"unit_type": "electrolyzer",
}
)
return unit_dict
Why Are These Attributes Important?
Understanding these attributes is crucial for the following reasons:
They define the range of actions the Electrolyser unit can perform in the electricity market.
They are used to calculate dynamic efficiency and power input, as we’ll see in the later sections.
They can be crucial for implementing demand-side management strategies, where the unit adjusts its operations based on market signals.
3.2 Power Calculation Function#
Next, we’ll implement a function to calculate the power input for the Electrolyser based on its demand profile. This function calculates the amount of electrical power that should be supplied to the unit, taking into consideration several attributes like maximum capacity, demand profile, and dynamic efficiency.
This function is integral for:
Optimizing Resource Utilization: Ensuring that the Electrolyser operates within its optimal efficiency range.
Demand-Side Management (DSM): Allowing the unit to adapt its power consumption in response to market signals and constraints, thereby contributing to grid stability.
Key Parameters Involved
Maximum and Minimum Capacity: The upper and lower bounds for power input to the Electrolyser.
Demand Profile: The expected hydrogen production rates, which influence the power requirements.
Dynamic Efficiency: The efficiency of converting power to hydrogen at different levels of power input, as discussed in the previous section.
[ ]:
# we define the class again and inherit from the initial class just to add the additional method to the original class
# this is a workaround to have different methods of the class in different cells
# which is good for the purpose of this tutorial
# however, you should have all functions in a single class when using this example in .py files
class Electrolyser(Electrolyser):
def calculate_min_max_power(
self,
start: datetime,
end: datetime,
):
# check if hydrogen_demand is within min and max hydrogen production
# and adjust accordingly
hydrogen_demand = self.forecaster["hydrogen_demand"][start]
if hydrogen_demand < self.min_hydrogen:
hydrogen_production = self.min_hydrogen
elif hydrogen_demand > self.max_hydrogen:
hydrogen_production = self.max_hydrogen
else:
hydrogen_production = hydrogen_demand
# Conversion factor functions will be introduced in section 3.3
dynamic_conversion_factor = self.get_dynamic_conversion_factor(
hydrogen_production
)
power = hydrogen_production * dynamic_conversion_factor
return power, hydrogen_production
def calculate_marginal_cost(self, start: datetime, power: float = 0) -> float:
"""
Calculate the marginal cost of the unit returns the marginal cost of the unit based on the provided time and power.
Args:
start (pandas.Timestamp): The start time of the dispatch.
power (float): The power output of the unit.
Returns:
float: the marginal cost of the unit for the given power.
"""
return self.forecaster["fuel_price"].at[start]
3.3 Developing Advanced Attributes#
We will enhance our Electrolyser class by adding advanced attributes like dynamic efficiency, which varies with power input.
Dynamic efficiency refers to the Electrolyser unit’s ability to convert electrical power into hydrogen gas at varying rates of efficiency, depending on its current power input. This attribute is crucial for the following reasons:
It allows the unit to adapt to fluctuating market conditions, optimizing its operation for price signals.
It provides a quantitative measure for decision-making, particularly in DSM where the unit may need to adjust its demand profile.
Key Parameters Involved
Average Power: The mean power consumed during a specific time period.
Conversion Factors: These are factors used to convert the average power into hydrogen production, and they can vary based on the power level.
[ ]:
# we define the class again and inherit from the initial class just to add the additional method to the original class
# this is a workaround to have different methods of the class in different cells
# which is good for the purpose of this tutorial
# however, you should have all functions in a single class when using this example in .py files
class Electrolyser(Electrolyser):
def get_dynamic_conversion_factor(self, hydrogen_demand=None):
# Adjust efficiency based on power
if hydrogen_demand <= 0.35 * self.max_hydrogen:
return self.conversion_factors[0]
else:
return self.conversion_factors[1]
def get_conversion_factors(self):
# Calculate the conversion factor for the two efficiency points
conversion_point_1 = (0.3 * self.max_power) / (
0.35 * self.max_hydrogen
) # MWh / Tonne
conversion_point_2 = self.max_power / self.max_hydrogen # MWh / Tonne
return [conversion_point_1, conversion_point_2]
4. Rule-Based Bidding Strategy#
Now, we’ll define a rule-based bidding strategy for our Electrolyser unit. This strategy will use market information to place bids.
Key components of a Rule-Based Bidding Strategy
Product Type: This sets the stage for the kind of products (e.g., energy, ancillary services) that the unit will bid for in the electricity market.
Bid Volume: The quantity of the product that the unit offers in its bid.
Marginal Revenue: Used to determine the price at which the unit should make its bid. This involves calculating the unit’s incremental revenue for additional units of electricity sold or consumed.
The NaiveStrategyElectrolyser
class inherits from the BaseStrategy
class and implements the calculate_bids
method, which is responsible for formulating the market bids:
The calculate_bids
method takes several arguments, including the unit to be dispatched (unit
), the market configuration (market_config
), and a list of products (product_tuples
). It returns an Orderbook
containing the bids.
In this case, we use Marginal Revenue to determine the price at which the unit should make its bid. The equation used in the code is as follows:
where:
Hydrogen Price: The price of hydrogen at the specific time frame, fetched from the unit’s forecaster.
Fixed Cost: The constant cost associated with the unit, not varying with the amount of power or hydrogen produced.
Hydrogen Production: The amount of hydrogen produced during the given time frame, calculated based on the hydrogen demand.
Power: The electrical power consumed by the Electrolyser unit to produce the given amount of hydrogen.
This mathematical equation provides just a mere example for understanding and implementing an effective rule-based bidding strategy for the Electrolyser unit.
[ ]:
class NaiveStrategyElectrolyser(BaseStrategy):
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
"""
start = product_tuples[0][0] # start time of the first product
bids = []
# iterate over all products
# to create a bid for each product
# in this case it would be 24 bids for each hour of the day-ahead market
for product in product_tuples:
"""
for each product, calculate the marginal revenue of the unit at the start time of the product
and the volume of the product. Dispatch the order to the market.
"""
# get the start and end time of the product
# for example 1 AM to 2 AM
start = product[0]
end = product[1]
# Get hydrogen demand and price for the product start time
# in this case for the start hour of the product
hydrogen_price = unit.calculate_marginal_cost(start=start)
# Calculate the required power and the actual possible hydrogen production
# given the hydrogen demand
power, hydrogen_production = unit.calculate_min_max_power(
start=start,
end=end,
)
# Calculate the marginal revenue of producing hydrogen
# as described in the equation above
marginal_revenue = (
(hydrogen_price - unit.additional_cost) * hydrogen_production / power
)
# set the bid price to the marginal revenue
bid_price = marginal_revenue
# formulate the order
# start, end, price, and volume are required
order: Order = {
"start_time": product[0],
"end_time": product[1],
"price": bid_price,
"volume": -power,
}
# append the order to the list of bids
bids.append(order)
bids = self.remove_empty_bids(bids)
return bids
5. Integrating the New Unit and Strategy into ASSUME#
Finally, we’ll integrate our new unit and bidding strategy into the ASSUME simulation environment.
[ ]:
# import packages
import logging
import os
from datetime import datetime, timedelta
import pandas as pd
from dateutil import rrule as rr
from assume import World
from assume.common.forecasts import NaiveForecast
from assume.common.market_objects import MarketConfig, MarketProduct
logger = logging.getLogger(__name__)
# define output path
csv_path = "outputs"
os.makedirs("local_db", exist_ok=True)
# create world instance
world = World(export_csv_path=csv_path)
# add new unit type to world
world.unit_types["electrolyser"] = Electrolyser
# add new bidding strategy to world
world.bidding_strategies["electrolyser_naive"] = NaiveStrategyElectrolyser
# define simulation period and ID
start = datetime(2019, 1, 1)
end = datetime(2019, 1, 30)
index = pd.date_range(
start=start,
end=end + timedelta(hours=24),
freq="h",
)
sim_id = "electrolyser_demo"
# run world setup to create simulation and different roles
# this creates the clock and the outputs role
world.setup(
start=start,
end=end,
save_frequency_hours=None,
simulation_id=sim_id,
index=index,
)
# define market design and add it to a market
marketdesign = [
MarketConfig(
market_id="EOM",
opening_hours=rr.rrule(rr.HOURLY, interval=1, dtstart=start, until=end),
opening_duration=timedelta(hours=1),
market_mechanism="pay_as_clear",
market_products=[
MarketProduct(
duration=timedelta(hours=1),
count=1,
first_delivery=timedelta(hours=1),
)
],
)
]
mo_id = "market_operator"
world.add_market_operator(id=mo_id)
for market_config in marketdesign:
world.add_market(market_operator_id=mo_id, market_config=market_config)
# add unit operator
world.add_unit_operator(id="power_plant_operator")
# define a simple forecaster
simple_forecaster = NaiveForecast(index, availability=1, fuel_price=0, co2_price=50)
# add a unit to the world
world.add_unit(
id="power_plant_01",
unit_type="power_plant",
unit_operator_id="power_plant_operator",
unit_params={
"min_power": 0,
"max_power": 100,
"bidding_strategies": {"EOM": "naive_eom"},
"additional_cost": 5,
"technology": "wind turbine",
},
forecaster=simple_forecaster,
)
# repeat for demand unit
world.add_unit_operator("demand_operator")
world.add_unit(
id="demand_unit_1",
unit_type="demand",
unit_operator_id="demand_operator",
unit_params={
"min_power": 0,
"max_power": 1000,
"bidding_strategies": {"EOM": "naive_eom"},
"technology": "demand",
},
forecaster=NaiveForecast(index, demand=50),
)
# load forecasts for hydrogen demand and hydrogen price
hydrogen_forecasts = pd.read_csv(
f"{inputs_path}/example_01e/forecasts_df.csv",
index_col=0,
parse_dates=True,
)
# add the electrolyser unit to the world
world.add_unit_operator(id="electrolyser_operator")
hydrogen_plant_forecaster = NaiveForecast(
index=index,
demand=hydrogen_forecasts["electrolyser_01_h2demand"],
fuel_cost=hydrogen_forecasts["electrolyser_01_h2price"],
)
world.add_unit(
id="electrolyser_01",
unit_type="electrolyser",
unit_operator_id="electrolyser_operator",
unit_params={
"min_power": 7,
"max_power": 52.25,
"min_hydrogen": 0.15,
"max_hydrogen": 0.95,
"bidding_strategies": {"EOM": "electrolyser_naive"},
"technology": "electrolyser",
"additional_cost": 10,
},
forecaster=hydrogen_plant_forecaster,
)
[ ]:
# run the simulation
world.run()
The above code block is more in line with Tutorial 1, where we manually defined the simulation and executed it. It shows all inner working and serves better understanding.
For more practical applications you should use the construct from Tutorial 2 to have an automated loading and execution of the scenario, where all parameters are defined in a configuration file, as in the following code block.
In the following cell, we automatically load the scenario, add the newly defined custom unit, it’s bidding strategy, and load these into the simulation.
[ ]:
# import the main World class and the load_scenario_folder functions from assume
# import the function to load custom units
from assume import World, load_custom_units
from assume.scenario.loader_csv import load_scenario_folder
# Set up logging
log = logging.getLogger(__name__)
# Define paths for input and output data
csv_path = "outputs"
# Create directories if they don't exist
os.makedirs("local_db", exist_ok=True)
# make sure that you have a database server up and running - preferabely in docker
# DB_URI = "postgresql://assume:assume@localhost:5432/assume"
# but you can use a file-based sqlite database too:
data_format = "local_db" # "local_db" or "timescale"
if data_format == "local_db":
db_uri = "sqlite:///local_db/assume_db.db"
elif data_format == "timescale":
db_uri = "postgresql://assume:assume@localhost:5432/assume"
# create world
world = World(database_uri=db_uri, export_csv_path=csv_path)
# load scenario by providing the world instance
# the path to the inputs folder and the scenario name (subfolder in inputs)
# and the study case name (which config to use for the simulation)
load_scenario_folder(
world,
inputs_path=inputs_path,
scenario="example_01e",
study_case="base",
)
# add custom unit types and bidding strategies into the world
world.unit_types["electrolyser"] = Electrolyser
# add new bidding strategy to world
world.bidding_strategies["electrolyser_naive"] = NaiveStrategyElectrolyser
# load the custom units
load_custom_units(
world,
inputs_path=inputs_path,
scenario="example_01e",
file_name="electrolyser_units",
unit_type="electrolyser",
)
# run the simulation
world.run()
After the simulation you can now check out the simulation results in the output folder. For example, take a look at the market_dispatch.csv file to see the dispatch of the units in the market.
This concludes our tutorial. By following these steps, you have successfully created a Demand Side Unit with a Rule-Based Bidding Strategy and integrated it into the ASSUME framework.