diff --git a/assume/common/outputs.py b/assume/common/outputs.py index d17d84d49..a235650ed 100644 --- a/assume/common/outputs.py +++ b/assume/common/outputs.py @@ -299,6 +299,12 @@ def write_market_orders(self, market_orders, market_id): market_orders = separate_orders(market_orders) df = pd.DataFrame.from_records(market_orders, index="start_time") + if "eligible_lambda" in df.columns: + df["eligible_lambda"] = df["eligible_lambda"].apply(lambda x: x.__name__) + if "evaluation_frequency" in df.columns: + df["evaluation_frequency"] = df["evaluation_frequency"].apply( + lambda x: repr(x) + ) del df["only_hours"] del df["agent_id"] diff --git a/assume/markets/clearing_algorithms_extended.py b/assume/markets/clearing_algorithms_extended.py new file mode 100644 index 000000000..2a0234dd6 --- /dev/null +++ b/assume/markets/clearing_algorithms_extended.py @@ -0,0 +1,746 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import asyncio +import logging +from datetime import datetime, timedelta +from itertools import groupby +from operator import itemgetter +from typing import Callable + +from dateutil import rrule as rr +from dateutil.relativedelta import relativedelta as rd + +from assume.common.market_objects import ( + ClearingMessage, + MarketConfig, + MarketProduct, + MetaDict, + Order, + Orderbook, +) +from assume.markets.base_market import MarketRole + +log = logging.getLogger(__name__) + + +class NodalPricingInflexDemandRole(MarketRole): + required_fields = ["node_id"] + + def __init__(self, marketconfig: MarketConfig): + super().__init__(marketconfig) + + def clear( + self, orderbook: Orderbook, market_products + ) -> (Orderbook, Orderbook, list[dict]): + import pypsa + + n = pypsa.Network() + for i in range(3): + n.add("Bus", f"{i}") + n.add("Link", "1 - 0", bus0="1", bus1="0", p_nom=300, p_min_pu=-1) + n.add("Link", "2 - 0", bus0="2", bus1="0", p_nom=300, p_min_pu=-1) + n.add("Link", "0 - 1", bus0="2", bus1="1", p_nom=300, p_min_pu=-1) + + market_getter = itemgetter("start_time", "end_time", "only_hours") + accepted_orders: Orderbook = [] + rejected_orders: Orderbook = [] + meta = [] + orderbook.sort(key=market_getter) + for product, product_orders in groupby(orderbook, market_getter): + # don't compare node_id too + if product[0:3] not in market_products: + rejected_orders.extend(product_orders) + # log.debug(f'found unwanted bids for {product} should be {market_products}') + continue + + network = n.copy() + # solve_time = product['start_time'] + # network.snapshots = [solve_time] + product_orders = list(product_orders) + demand_orders = filter(lambda x: x["volume"] < 0, product_orders) + supply_orders = filter(lambda x: x["volume"] > 0, product_orders) + # volume 0 is ignored/invalid + # generation + sorted_supply_orders = sorted(supply_orders, key=lambda i: i["price"]) + # demand + sorted_demand_orders = sorted( + demand_orders, key=lambda i: i["price"], reverse=True + ) + + unique_idxs = list(range(len(sorted_supply_orders))) + names = [] + for idx in unique_idxs: + names.append(f"{sorted_supply_orders[idx]['agent_id']}_{idx}") + network.madd( + "Generator", + names, + bus=map(itemgetter("node_id"), sorted_supply_orders), + p_nom=map(itemgetter("volume"), sorted_supply_orders), + marginal_cost=map(itemgetter("price"), sorted_supply_orders), + ) + # make sure enough generation exists + # should be magic source + network.generators.iloc[0, 1] = 100 + + unique_idxs = list(range(len(sorted_demand_orders))) + names = [] + for idx in unique_idxs: + names.append(f"{sorted_demand_orders[idx]['agent_id']}_{idx}") + network.madd( + "Load", + names, + bus=map(itemgetter("node_id"), sorted_demand_orders), + p_set=map(lambda o: -o["volume"], sorted_demand_orders) + # XXX: does not respect cost of load + # marginal_cost=map(itemgetter("price"), sorted_demand_orders), + ) + + status, solution = network.lopf() + + if status != "ok" or solution != "optimal": + print(f"Demand to match: {network.loads.p_set.sum()}") + print(f"Generation to match: {network.generators.p_nom.sum()}") + raise Exception("could not solve") + + price_dict = network.buses_t.marginal_price.to_dict() + load_dict = network.loads_t.p.to_dict() + + checker = lambda o: o["agent_id"] == agent_id + price = sum([o["now"] for o in price_dict.values()]) / len(price_dict) + for key in load_dict.keys(): + agent_id = "_".join(key.split("_")[:-1]) + nr = int(key.split("_")[-1]) - 1 + + def check_agent_id(o): + return o["agent_id"] == agent_id and o["node_id"] == nr + + orders = list(filter(check_agent_id, sorted_demand_orders)) + for o in orders: + o["price"] = price_dict[str(o["node_id"])]["now"] + accepted_orders.extend(orders) + # can only accept all orders + + gen_dict = network.generators_t.p.to_dict() + for order_key, order_val in gen_dict.items(): + agent_id = "_".join(key.split("_")[:-1]) + nr = int(key.split("_")[-1]) - 1 + + def check_agent_id(o): + return o["agent_id"] == agent_id and o["node_id"] == nr + + orders = list(filter(check_agent_id, sorted_supply_orders)) + for o in orders: + o["volume"] = order_val["now"] + + if o["volume"] > 0: + accepted_orders.append(o) + else: + rejected_orders.append(o) + + # links are not needed as congestion can't occur: network.links_t.p0 + # link shadow prices: network.links_t.mu_lower + + # TODO + meta.append( + { + "supply_volume": network.generators.p_nom.sum(), + "demand_volume": network.loads.p_set.sum(), + "price": price, + "node_id": 1, + "product_start": product[0], + "product_end": product[1], + "only_hours": product[2], + } + ) + + return accepted_orders, rejected_orders, meta + + +logger = logging.getLogger(__name__) + + +class PayAsBidContractRole(MarketRole): + required_fields = [ + "sender_id", + "contract", + "eligible_lambda", + "evaluation_frequency", + ] + + def __init__( + self, + marketconfig: MarketConfig, + limitation: str = "only_co2emissionless", + market_indices: dict[str, str] = {}, + ): + super().__init__(marketconfig) + self.limitation = limitation + self.market_indices = market_indices + self.futures = {} + + def setup(self): + super().setup() + + def accept_data_response(content: dict, meta: MetaDict): + return content.get("context") == "data_response" + + self.context.subscribe_message( + self, self.handle_data_response, accept_data_response + ) + + def handle_data_response(self, content: dict, meta: MetaDict): + if meta["in_reply_to"] not in self.futures: + logger.error(f'data response {meta["in_reply_to"]} not in awaited futures') + else: + self.futures[meta["in_reply_to"]].set_result(content["data"]) + + def validate_registration(self, content, meta): + if self.limitation: + if self.limitation == "only_co2emissionless": + requirement = lambda x: x in [ + "demand", + "nuclear", + "wind", + "solar", + "biomass", + ] + elif self.limitation == "only_renewables": + requirement = lambda x: x in ["demand", "wind", "solar", "biomass"] + return all( + [requirement(info["technology"]) for info in content["information"]] + ) + else: + return True + + def check_working(self, supply_order, demand_order): + s_information = self.registered_agents[supply_order["agent_id"]] + d_information = self.registered_agents[demand_order["agent_id"]] + return supply_order["eligible_lambda"](d_information) and demand_order[ + "eligible_lambda" + ](s_information) + + def clear( + self, orderbook: Orderbook, market_products + ) -> (Orderbook, Orderbook, list[dict]): + accepted_contracts: Orderbook = self.context.data_dict.get("contracts") + + market_getter = itemgetter( + "start_time", "end_time", "only_hours", "contract", "evaluation_frequency" + ) + accepted_orders: Orderbook = [] + rejected_orders: Orderbook = [] + meta = [] + orderbook.sort(key=market_getter) + for product, product_orders in groupby(orderbook, market_getter): + accepted_demand_orders: Orderbook = [] + accepted_supply_orders: Orderbook = [] + if product[0:3] not in market_products: + rejected_orders.extend(product_orders) + # log.debug(f'found unwanted bids for {product} should be {market_products}') + continue + + accepted_product_orders = [] + + product_orders = list(product_orders) + demand_orders = list(filter(lambda x: x["volume"] < 0, product_orders)) + supply_orders = list(filter(lambda x: x["volume"] > 0, product_orders)) + + # generation + supply_orders.sort(key=lambda i: i["price"]) + # demand + demand_orders.sort(key=lambda i: i["price"], reverse=True) + dem_vol, gen_vol = 0, 0 + # the following algorithm is inspired by one bar for generation and one for demand + # add generation for currents demand price, until it matches demand + # generation above it has to be sold for the lower price (or not at all) + for demand_order in demand_orders: + if not supply_orders: + # if no more generation - reject left over demand + rejected_orders.append(demand_order) + continue + + dem_vol += -demand_order["volume"] + to_commit: Orderbook = [] + + while supply_orders and gen_vol < dem_vol: + supply_order = supply_orders.pop(0) + if supply_order["price"] <= demand_order[ + "price" + ] and self.check_working(supply_order, demand_order): + # TODO this might match a different demand, if different policies are available + # so we should not reject only if check_working is false but check compatibility beforehand? + supply_order["accepted_volume"] = supply_order["volume"] + to_commit.append(supply_order) + gen_vol += supply_order["volume"] + else: + rejected_orders.append(supply_order) + # now we know which orders we need + # we only need to see how to arrange it. + + diff = gen_vol - dem_vol + + if diff < 0: + # gen < dem + # generation is not enough - split demand + split_demand_order = demand_order.copy() + split_demand_order["accepted_volume"] = diff + demand_order["accepted_volume"] = demand_order["volume"] - diff + rejected_orders.append(split_demand_order) + elif diff > 0: + # generation left over - split generation + supply_order = to_commit[-1] + split_supply_order = supply_order.copy() + split_supply_order["volume"] = diff + supply_order["accepted_volume"] = supply_order["volume"] - diff + # only volume-diff can be sold for current price + # add left over to supply_orders again + gen_vol -= diff + + supply_orders.insert(0, split_supply_order) + demand_order["accepted_volume"] = demand_order["volume"] + else: + # diff == 0 perfect match + demand_order["accepted_volume"] = demand_order["volume"] + + accepted_demand_orders.append(demand_order) + # pay as bid + for supply_order in to_commit: + supply_order["accepted_price"] = supply_order["price"] + demand_order["accepted_price"] = supply_order["price"] + supply_order["contractor_unit_id"] = demand_order["sender_id"] + supply_order["contractor_id"] = demand_order["agent_id"] + demand_order["contractor_unit_id"] = supply_order["sender_id"] + demand_order["contractor_id"] = supply_order["agent_id"] + accepted_supply_orders.extend(to_commit) + + for order in supply_orders: + rejected_orders.append(order) + + accepted_product_orders = accepted_demand_orders + accepted_supply_orders + + supply_volume = sum(map(itemgetter("volume"), accepted_supply_orders)) + demand_volume = sum(map(itemgetter("volume"), accepted_demand_orders)) + accepted_orders.extend(accepted_product_orders) + prices = list(map(itemgetter("price"), accepted_supply_orders)) + if not prices: + prices = [self.marketconfig.maximum_bid_price] + + meta.append( + { + "supply_volume": supply_volume, + "demand_volume": demand_volume, + "price": sum(prices) / len(prices), + "max_price": max(prices), + "min_price": min(prices), + "node_id": None, + "product_start": product[0], + "product_end": product[1], + "only_hours": product[2], + } + ) + # demand for contracts is maximum generation capacity of the buyer + # this is needed so that the seller of the contract can lower the volume + + from functools import partial + + for order in accepted_supply_orders: + recurrency_task = rr.rrule( + freq=order["evaluation_frequency"], + dtstart=order["start_time"], + until=order["end_time"], + cache=True, + ) + self.context.schedule_recurrent_task( + partial(self.execute_contract, contract=order), recurrency_task + ) + + # contract clearing (pay_as_bid) takes place + return accepted_orders, rejected_orders, meta + + async def execute_contract(self, contract: Order): + # contract must be executed + # contract from supply is given + buyer, seller = contract["contractor_unit_id"], contract["unit_id"] + buyer_agent = contract["contractor_id"] + seller_agent = contract["agent_id"] + c_function: Callable[str, tuple[Orderbook, Orderbook]] = available_contracts[ + contract["contract"] + ] + + end = datetime.utcfromtimestamp(self.context.current_timestamp) + begin = end - rd(weeks=1) + begin = max(contract["start_time"], begin) + + reply_with = f'{buyer}_{contract["start_time"]}' + self.futures[reply_with] = asyncio.Future() + self.context.schedule_instant_acl_message( + { + "context": "data_request", + "unit": seller, + "metric": "energy", + "start_time": begin, + "end_time": end, + }, + receiver_addr=seller_agent[0], + receiver_id=seller_agent[1], + acl_metadata={ + "sender_addr": self.context.addr, + "sender_id": self.context.aid, + "reply_with": reply_with, + }, + ) + + if contract["contract"] in ["CfD", "MPFIX", "MPVAR"]: + reply_with_market = f'market_eom_{contract["start_time"]}' + self.futures[reply_with_market] = asyncio.Future() + self.context.schedule_instant_acl_message( + { + "context": "data_request", + # ID3 would be average price of orders cleared in last 3 hours before delivery + # monthly averages are used for EEG + # https://www.netztransparenz.de/de-de/Erneuerbare-Energien-und-Umlagen/EEG/Transparenzanforderungen/Marktpr%C3%A4mie/Marktwert%C3%BCbersicht + "market_id": "EOM", + "metric": "eom_price", + "start_time": begin, + "end_time": end, + }, + receiver_addr=self.context.addr, + receiver_id=self.context.aid, # TODO + acl_metadata={ + "sender_addr": self.context.addr, + "sender_id": self.context.aid, + "reply_with": reply_with_market, + }, + ) + market_series = await self.futures[reply_with_market] + else: + market_series = None + + client_series = await self.futures[reply_with] + buyer, seller = c_function(contract, market_series, client_series, begin, end) + + in_reply_to = f'{contract["contract"]}_{contract["start_time"]}' + await self.send_contract_result(contract["contractor_id"], buyer, in_reply_to) + await self.send_contract_result(contract["agent_id"], seller, in_reply_to) + + async def send_contract_result( + self, receiver: tuple, orderbook: Orderbook, in_reply_to + ): + content: ClearingMessage = { + # this is a way of giving payments to the participants + "context": "clearing", + "market_id": self.marketconfig.name, + "accepted_orders": orderbook, + "rejected_orders": [], + } + await self.context.send_acl_message( + content=content, + receiver_addr=receiver[0], + receiver_id=receiver[1], + acl_metadata={ + "sender_addr": self.context.addr, + "sender_id": self.context.aid, + "in_reply_to": in_reply_to, + }, + ) + + +# 1. multi-stage market -> clears locally, rejected_bids are pushed up a layer +# 2. nodal pricing -> centralized market which handles different node_ids different - can also be used for country coupling +# 3. nodal limited market -> clear by node_id, select cheapest generation orders from surrounding area up to max_capacity, clear market +# 4. one sided market? - fixed demand as special case of two sided market +# 5. + + +available_clearing_strategies: dict[str, Callable] = { + "nodal_pricing_pypsa_unflexible_demand": NodalPricingInflexDemandRole, + "pay_as_bid_contract": PayAsBidContractRole, +} + +import pandas as pd +from mango import Agent + + +def ppa( + contract: dict, + market_index: pd.Series, + future_generation_series: pd.Series, + start: datetime, + end: datetime, +): + buyer, seller = contract["buyer_id"], contract["seller_id"] + volume = sum(future_generation_series[start:end]) + return { + "buyer": { + "start_time": start, + "end_time": end, + "volume": volume, + "price": contract["price"], + "agent_id": buyer, + }, + "seller": { + "start_time": start, + "end_time": end, + "volume": -volume, + "price": contract["price"], + "agent_id": seller, + }, + } + + +def swingcontract( + contract: dict, + market_index: pd.Series, + demand_series: pd.Series, + start: datetime, + end: datetime, +): + buyer, seller = contract["buyer_id"], contract["seller_id"] + + minDCQ = 80 # daily constraint quantity + maxDCQ = 100 + set_price = contract["price"] # ct/kWh + outer_price = contract["price"] * 1.5 # ct/kwh + # TODO does not work with multiple markets with differing time scales.. + # this only works for whole trading hours (as x MW*1h == x MWh) + demand = -demand_series[seller][start:end] + normal = demand[minDCQ < demand and demand < maxDCQ] * set_price + expensive = ~demand[minDCQ < demand and demand < maxDCQ] * outer_price + price = sum(normal) + sum(expensive) + # volume is hard to calculate with differing units? + # unit conversion is quite hard regarding the different intervals + return { + "buyer": { + "start_time": start, + "end_time": end, + "volume": 1, + "price": price, + "agent_id": buyer, + }, + "seller": { + "start_time": start, + "end_time": end, + "volume": -1, + "price": price, + "agent_id": seller, + }, + } + + +def cfd( + contract: dict, + market_index: pd.Series, + gen_series: pd.Series, + start: datetime, + end: datetime, +): + buyer, seller = contract["buyer_id"], contract["seller_id"] + + # TODO does not work with multiple markets with differing time scales.. + # this only works for whole trading hours (as x MW*1h == x MWh) + price_series = (contract["price"] - market_index[start:end]) * gen_series[seller][ + start:end + ] + price = sum(price_series) + # volume is hard to calculate with differing units? + # unit conversion is quite hard regarding the different intervals + return { + "buyer": { + "start_time": start, + "end_time": end, + "volume": 1, + "price": price, + "agent_id": buyer, + }, + "seller": { + "start_time": start, + "end_time": end, + "volume": -1, + "price": price, + "agent_id": seller, + }, + } + + +def market_premium( + contract: dict, + market_index: pd.Series, + gen_series: pd.Series, + start: datetime, + end: datetime, +): + buyer_agent, seller_agent = contract["contractor_id"], contract["agent_id"] + # TODO does not work with multiple markets with differing time scales.. + # this only works for whole trading hours (as x MW*1h == x MWh) + price_series = (market_index[start:end] - contract["price"]) * gen_series[start:end] + price_series = price_series.dropna() + # sum only where market price is below contract price + price = sum(price_series[price_series < 0]) + volume = sum(gen_series[start:end]) + # volume is hard to calculate with differing units? + # unit conversion is quite hard regarding the different intervals + buyer: Orderbook = [ + { + "bid_id": contract["contractor_unit_id"], + "unit_id": contract["contractor_unit_id"], + "start_time": start, + "end_time": end, + "volume": volume, + "price": price, + "accepted_volume": volume, + "accepted_price": price, + "only_hours": None, + "agent_id": buyer_agent, + } + ] + seller: Orderbook = [ + { + "bid_id": contract["unit_id"], + "unit_id": contract["unit_id"], + "start_time": start, + "end_time": end, + "volume": -volume, + "price": price, + "accepted_volume": -volume, + "accepted_price": price, + "only_hours": None, + "agent_id": seller_agent, + } + ] + return buyer, seller + + +def feed_in_tariff( + contract: dict, + market_index: pd.Series, + client_series: pd.Series, + start: datetime, + end: datetime, +): + buyer_agent, seller_agent = contract["contractor_id"], contract["agent_id"] + # TODO does not work with multiple markets with differing time scales.. + # this only works for whole trading hours (as x MW*1h == x MWh) + price_series = contract["price"] * client_series[start:end] + price = sum(price_series) + # volume is hard to calculate with differing units? + # unit conversion is quite hard regarding the different intervals + volume = sum(client_series) + buyer: Orderbook = [ + { + "bid_id": contract["contractor_unit_id"], + "unit_id": contract["contractor_unit_id"], + "start_time": start, + "end_time": end, + "volume": volume, + "price": price, + "accepted_volume": volume, + "accepted_price": price, + "only_hours": None, + "agent_id": buyer_agent, + } + ] + seller: Orderbook = [ + { + "bid_id": contract["unit_id"], + "unit_id": contract["unit_id"], + "start_time": start, + "end_time": end, + "volume": -volume, + "price": price, + "accepted_volume": -volume, + "accepted_price": price, + "only_hours": None, + "agent_id": seller_agent, + } + ] + return buyer, seller + + +available_contracts: dict[str, Callable] = { + "ppa": ppa, + "swingcontract": swingcontract, + "cfg": cfd, + "market_premium": market_premium, + "FIT": feed_in_tariff, + "MPFIX": market_premium, +} + + +if __name__ == "__main__": + from dateutil import rrule as rr + from dateutil.relativedelta import relativedelta as rd + + from assume.common.utils import get_available_products + + simple_dayahead_auction_config = MarketConfig( + "simple_dayahead_auction", + market_products=[MarketProduct(rd(hours=+1), 1, rd(hours=1))], + opening_hours=rr.rrule( + rr.HOURLY, + dtstart=datetime(2005, 6, 1), + cache=True, + ), + opening_duration=timedelta(hours=1), + amount_unit="MW", + amount_tick=0.1, + price_unit="€/MW", + market_mechanism="pay_as_clear", + ) + mr_class = available_clearing_strategies[ + simple_dayahead_auction_config.market_mechanism + ] + mr: MarketRole = mr_class(simple_dayahead_auction_config) + next_opening = simple_dayahead_auction_config.opening_hours.after(datetime.now()) + products = get_available_products( + simple_dayahead_auction_config.market_products, next_opening + ) + assert len(products) == 1 + + print(products) + start = products[0][0] + end = products[0][1] + only_hours = products[0][2] + + orderbook: Orderbook = [ + { + "start_time": start, + "end_time": end, + "volume": 120, + "price": 120, + "agent_id": "gen1", + "only_hours": None, + }, + { + "start_time": start, + "end_time": end, + "volume": 80, + "price": 58, + "agent_id": "gen1", + "only_hours": None, + }, + { + "start_time": start, + "end_time": end, + "volume": 100, + "price": 53, + "agent_id": "gen1", + "only_hours": None, + }, + { + "start_time": start, + "end_time": end, + "volume": -180, + "price": 70, + "agent_id": "dem1", + "only_hours": None, + }, + ] + + accepted, rejected, meta = mr.clear(orderbook, products) + import pandas as pd + + print(pd.DataFrame.from_dict(rejected)) + print(pd.DataFrame.from_dict(clearing_result)) + print(meta) diff --git a/assume/strategies/extended.py b/assume/strategies/extended.py index c070ce462..e585341e4 100644 --- a/assume/strategies/extended.py +++ b/assume/strategies/extended.py @@ -2,8 +2,12 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later +import dateutil.rrule as rr +from dateutil.relativedelta import relativedelta as rd + from assume.common.base import BaseStrategy, SupportsMinMax from assume.common.market_objects import MarketConfig, Orderbook, Product +from assume.strategies.naive_strategies import NaiveStrategy class OTCStrategy(BaseStrategy): @@ -64,3 +68,69 @@ def calculate_bids( } ) return bids + + +def is_co2emissionless(units): + requirement = lambda x: x in ["demand", "nuclear", "wind", "solar", "biomass"] + return all([requirement(info["technology"]) for info in units]) + + +class SupportStrategy(NaiveStrategy): + """ + Strategy for OTC (over the counter trading) markets + """ + + def __init__(self, contract_type: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.contract_type = contract_type + + 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 + + Returns a list of bids that the unit operator will submit to the market + :param unit: unit to dispatch + :type unit: SupportsMinMax + :param market_config: market configuration + :type market_config: MarketConfig + :param product_tuples: list of products to dispatch + :type product_tuples: list[Product] + :param kwargs: additional arguments + :type kwargs: dict + :return: orderbook + :rtype: Orderbook + """ + if "evaluation_frequency" not in market_config.additional_fields: + return super().calculate_bids(unit, market_config, product_tuples, **kwargs) + + bids = [] + power = ( + unit.min_power if unit.__class__.__name__ == "Demand" else unit.max_power + ) + power *= 0.4 + for product in product_tuples: + start = product[0] + end = product[1] + bids.append( + { + "start_time": product[0], + "end_time": product[1], + "only_hours": product[2], + "price": 70, + "volume": power, + "sender_id": unit.id, + "contract": "FIT", + "eligible_lambda": is_co2emissionless, + # lambda u: u.technology in ["nuclear"], + "evaluation_frequency": rr.WEEKLY, # every monday + } + ) + + return bids diff --git a/docs/source/support_policies.rst b/docs/source/support_policies.rst new file mode 100644 index 000000000..395428f92 --- /dev/null +++ b/docs/source/support_policies.rst @@ -0,0 +1,72 @@ +.. SPDX-FileCopyrightText: ASSUME Developers +.. +.. SPDX-License-Identifier: AGPL-3.0-or-later + +###################### +Support Policies +###################### + +Support Policies are a very important feature when considering different energy market designs. +A support policy allows to influence the cash flow of unit, making decisions more profitable. + +One can differentiate between support policies which influence the available market capacity (product_type=`energy``) and those which do not. + +If the product_type is `energy`, the volume used for the contract can not be additionally bid on the EOM. + + +Example Policies +===================================== + + +Feed-In-Tariff - FIT +-------------------- + +To create a Feed-In-Tariff (Einspeisevergütung) one has a contract which sets a fixed price for all produced energy. +The energy can not be additionally sold somewhere else (product_type=`energy`). + +The Tariff is contracted at the beginning of the simulation and is valid for X days (1 year). + +The payout is executed on a different repetition schedule (monthly). +For this, the outputs_agent is asked how much energy an agent produced in the timeframe. + +This is essentially the same as a Power Purchase Agreement (PPA), except that the payment of FIT is continuous and not monthly or yearly. + + +Fixed Market Premium - MPFIX +-------------------- + +A market premium is paid on top of the market results, based on the results. +As the volume does not influcence the market bidding, the product_type is `financial_support` +So a Market premium is contracted at the beginning of the simulation and is valid for X days (1 year). + +The payout is executed on a different repetition schedule (monthly). +For this, the outputs_agent is asked how much energy an agent produced in the timeframe and what the clearing price of the market with name "EOM" was. +The differences are then calculated and paid out on a monthly base. + +This mechanism is also known as One-Sided market premium + +Variable Market Premium - MPVAR +------------------------------- + +The Idea of the variable market premium is to be based on some kind of market index (like ID3) received from the output agent. + + +Capacity Premium - CP +--------------------- + +A capacity premium is paid on a yearly basis for a technology. +This is done in € per installed MW of capacity. +It allows to influence the financial flow of plants which would not be profitable. + +Contract for Differences - CfD +------------------------------ + +A fixed LCoE is set as a price, if an Agent accepts the CfD contract, +it has to bid at the hourly EOM - the difference of the market result is paid/received to/from the contractor. + + +Swing Contract +-------------- + +Actor +^^^^^ diff --git a/examples/world_script_policy.py b/examples/world_script_policy.py new file mode 100644 index 000000000..88437e480 --- /dev/null +++ b/examples/world_script_policy.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging +from datetime import datetime, timedelta + +import pandas as pd +from dateutil import rrule as rr +from dateutil.relativedelta import relativedelta as rd + +from assume import World +from assume.common.forecasts import NaiveForecast +from assume.common.market_objects import MarketConfig, MarketProduct +from assume.markets.clearing_algorithms_extended import PayAsBidContractRole + +log = logging.getLogger(__name__) + +db_uri = "postgresql://assume:assume@localhost:5432/assume" + +world = World(database_uri=db_uri) + + +async def init(): + start = datetime(2023, 10, 1) + end = datetime(2023, 12, 1) + index = pd.date_range( + start=start, + end=end + timedelta(hours=24), + freq="H", + ) + sim_id = "world_script_policy" + + world.clearing_mechanisms["pay_as_bid_contract"] = PayAsBidContractRole + from assume.strategies.extended import SupportStrategy + + world.bidding_strategies["support"] = SupportStrategy + bidding_params = {"contract_volume": 500, "contract_type": "FIT"} # Feed-In-Tariff + + await world.setup( + start=start, + end=end, + save_frequency_hours=48, + simulation_id=sim_id, + index=index, + bidding_params=bidding_params, + ) + + marketdesign = [ + MarketConfig( + "EOM", + rr.rrule( + rr.HOURLY, interval=24, dtstart=start + timedelta(hours=2), until=end + ), + timedelta(hours=1), + "pay_as_clear", + [MarketProduct(timedelta(hours=1), 24, timedelta(hours=1))], + product_type="energy", + ), + MarketConfig( + "SupportEnergy", + rr.rrule(rr.MONTHLY, dtstart=start, until=end), + timedelta(hours=1), + "pay_as_bid_contract", + [MarketProduct(rd(months=1), 1, timedelta(days=0))], + additional_fields=[ + "sender_id", + "contract", # one of FIT, MPVAR, MPFIX, CFD + "eligible_lambda", + "evaluation_frequency", # monthly + ], + product_type="financial_support", + supports_get_unmatched=True, + ), + # MarketConfig( + # "Support", + # rr.rrule(rr.MONTHLY, dtstart=start, until=end), + # timedelta(hours=1), + # "pay_as_bid_contract", + # [MarketProduct(rd(months=1), 1, timedelta(hours=1))], + # additional_fields=[ + # "sender_id", + # "contract", # one of FIT, MPVAR, MPFIX, CFD + # "eligible_lambda", + # "evaluation_frequency", # monthly + # ], + # product_type="financial_support", + # supports_get_unmatched=True, + # ), + ] + + mo_id = "market_operator" + world.add_market_operator(id=mo_id) + for market_config in marketdesign: + world.add_market(mo_id, market_config) + + world.add_unit_operator("my_operator") + world.add_unit_operator("brd") + world.add_unit( + "demand1", + "demand", + "brd", + # the unit_params have no hints + { + "min_power": 0, + "max_power": 1000, + "bidding_strategies": {"energy": "support", "financial_support": "support"}, + "technology": "demand", + }, + NaiveForecast(index, demand=1000), + ) + + nuclear_forecast = NaiveForecast(index, availability=1, fuel_price=3, co2_price=0.1) + world.add_unit( + "nuclear1", + "power_plant", + "my_operator", + { + "min_power": 200, + "max_power": 1000, + "bidding_strategies": {"energy": "support", "financial_support": "support"}, + "technology": "nuclear", + }, + nuclear_forecast, + ) + + +world.loop.run_until_complete(init()) +world.run()