From a88540ac0e726cea972d3922afb875a9c247a768 Mon Sep 17 00:00:00 2001 From: graf-wronski Date: Fri, 14 Nov 2025 15:27:33 +0100 Subject: [PATCH 1/3] Added World._validate_setup(). --- assume/world.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/assume/world.py b/assume/world.py index 4766197b6..c3def815c 100644 --- a/assume/world.py +++ b/assume/world.py @@ -8,6 +8,7 @@ import time from datetime import datetime from pathlib import Path +import warnings from mango import ( RoleAgent, @@ -698,6 +699,73 @@ def add_market(self, market_operator_id: str, market_config: MarketConfig) -> No market_operator.markets.append(market_config) self.markets[f"{market_config.market_id}"] = market_config + def _validate_setup(self): + """ Pre-empt invalid/unclear states, by detecting defective setups. """ + + # Integrity of schedule. + for market_id, market_config in self.markets.items(): + market_start = market_config.opening_hours.dtstart, + market_end = market_config.opening_hours.until, + + if market_start < self.start or market_end > self.end: + msg = (f"Market {market_id} violates world schedule. \n" + f"Market start: {market_start}, end: {market_end}. \n)" + f"World start: {market_start}, end: {market_end}.)") + raise ValueError(msg) + + # Integrity of reference. + # For each UnitOperator: strategies must reference existing markets. + unit_operators = list(self.unit_operators.values()) + for operator in unit_operators.keys(): + for market_id in operator.portfolio_strategies.keys(): + if market_id not in list(self.markets.keys()): + msg = (f"Strategies of unit operator {operator} references" + f"market {market_id} which is not known in world." + f"Known markets are:\n{list(self.markets.keys())}.") + raise ValueError(msg) + + # Each market should be referenced by a market strategy. + referenced_markets = {market + for market in operator.portfolio_strategies.keys() + for operator in unit_operators} + for market_id in self.markets.keys(): + if not market_id in referenced_markets: + msg = f"Added market {market_id}, has no participants." + warnings.warn(msg) + + # Real-world integrity. + # A Re-Dispatch market can only open if an earlier market closed. + for market_config in self.markets.values(): + earliest_redispatch_opening = min( + {config["start_date"] for config in self.markets.values() + if config["market_mechanism"] == "redispatch"}) + + earliest_dispatch_closing = min( + {config["end_date"] for config in self.markets.values() + if config["market_mechanism"] != "redispatch"}) + + if earliest_redispatch_opening < earliest_dispatch_closing: + msg = (f"First re-dispatch market opens before first dispatch " + f"market has closed.") + raise ValueError(msg) + + # Existence of demand implies existence of generation and vice versa. + demand_exists, generation_exists = False, False + for operator in unit_operators.keys(): + for unit in operator.units: + # ToDo: Are the defintions of demand and generation exhaustive? + if type(unit) in [self.unit_types["Demand"]]: + demand_exists = True + elif type(unit) in [self.unit_types["PowerPlant"], + self.unit_types["HydrogenPlant"]]: + generation_exists = True + if demand_exists and not generation_exists: + msg = "Demand units but no generation units were created. " + warnings.warn(msg) + elif generation_exists and not demand_exists: + msg = "Generation units but no demand units were created. " + warnings.warn(msg) + async def _step(self, container): """ Executes a simulation step for the container. @@ -737,6 +805,8 @@ async def async_run(self, start_ts: datetime, end_ts: datetime): start_ts (datetime.datetime): The start timestamp for the simulation run. end_ts (datetime.datetime): The end timestamp for the simulation run. """ + self._validate_setup() + logger.debug("activating container") # agent is implicit added to self.container._agents async with activate(self.container) as c: @@ -784,6 +854,8 @@ def run(self): container. """ + self._validate_setup() + start_ts = datetime2timestamp(self.start) end_ts = datetime2timestamp(self.end) From 9db67a42f7982597da0da504c54d36a54f4594e8 Mon Sep 17 00:00:00 2001 From: graf-wronski Date: Tue, 18 Nov 2025 16:13:18 +0100 Subject: [PATCH 2/3] Finished draft for world validation and added tests. --- assume/world.py | 40 +++--- tests/test_world_validation.py | 229 +++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 tests/test_world_validation.py diff --git a/assume/world.py b/assume/world.py index c3def815c..74827d878 100644 --- a/assume/world.py +++ b/assume/world.py @@ -704,8 +704,8 @@ def _validate_setup(self): # Integrity of schedule. for market_id, market_config in self.markets.items(): - market_start = market_config.opening_hours.dtstart, - market_end = market_config.opening_hours.until, + market_start = market_config.opening_hours[0] + market_end = market_config.opening_hours[-1] if market_start < self.start or market_end > self.end: msg = (f"Market {market_id} violates world schedule. \n" @@ -716,7 +716,7 @@ def _validate_setup(self): # Integrity of reference. # For each UnitOperator: strategies must reference existing markets. unit_operators = list(self.unit_operators.values()) - for operator in unit_operators.keys(): + for operator in unit_operators: for market_id in operator.portfolio_strategies.keys(): if market_id not in list(self.markets.keys()): msg = (f"Strategies of unit operator {operator} references" @@ -735,29 +735,35 @@ def _validate_setup(self): # Real-world integrity. # A Re-Dispatch market can only open if an earlier market closed. - for market_config in self.markets.values(): - earliest_redispatch_opening = min( - {config["start_date"] for config in self.markets.values() - if config["market_mechanism"] == "redispatch"}) + dispatch_markets = [config for config in self.markets.values() + if config.market_mechanism != "redispatch"] + redispatch_markets = [config for config in self.markets.values() + if config.market_mechanism == "redispatch"] + + if len(redispatch_markets) > 0: + if len(dispatch_markets) == 0: + msg = "Redispatch market but no dispatch market was defined." + raise ValueError(msg) - earliest_dispatch_closing = min( - {config["end_date"] for config in self.markets.values() - if config["market_mechanism"] != "redispatch"}) + earliest_dispatch_closing = min(x.opening_hours[-1] + for x in dispatch_markets) + earliest_redispatch_opening = min(x.opening_hours[0] + for x in redispatch_markets) - if earliest_redispatch_opening < earliest_dispatch_closing: - msg = (f"First re-dispatch market opens before first dispatch " + if earliest_redispatch_opening <= earliest_dispatch_closing: + msg = (f"First redispatch market opens before first dispatch " f"market has closed.") raise ValueError(msg) # Existence of demand implies existence of generation and vice versa. demand_exists, generation_exists = False, False - for operator in unit_operators.keys(): - for unit in operator.units: + for operator in unit_operators: + for unit in operator.units.values(): # ToDo: Are the defintions of demand and generation exhaustive? - if type(unit) in [self.unit_types["Demand"]]: + if type(unit) in [self.unit_types["demand"]]: demand_exists = True - elif type(unit) in [self.unit_types["PowerPlant"], - self.unit_types["HydrogenPlant"]]: + elif type(unit) in [self.unit_types["power_plant"], + self.unit_types["hydrogen_plant"]]: generation_exists = True if demand_exists and not generation_exists: msg = "Demand units but no generation units were created. " diff --git a/tests/test_world_validation.py b/tests/test_world_validation.py new file mode 100644 index 000000000..c2d905d54 --- /dev/null +++ b/tests/test_world_validation.py @@ -0,0 +1,229 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import pandas as pd +import pytest + +import datetime + +from assume.common.market_objects import MarketConfig, MarketProduct +from assume.strategies.naive_strategies import NaiveSingleBidStrategy +from assume.units.powerplant import PowerPlant + +from assume.common.forecaster import DemandForecaster, PowerplantForecaster +from assume.units.demand import Demand +from tests.utils import index, setup_simple_world + +import dateutil.rrule as rr + +import warnings + + +world = setup_simple_world() + + +@pytest.fixture +def demand(): + return Demand( + id="test_unit", + unit_operator="test_operator", + min_power=0, + max_power=-1000, + technology="demand", + bidding_strategies={}, + forecaster=DemandForecaster(index, demand=-100), + ) + + +@pytest.fixture +def power_plant(): + params_dict = { + "bidding_strategies": {"EOM": NaiveSingleBidStrategy()}, + "technology": "energy", + "unit_operator": "test_operator", + "max_power": 10, + "min_power": 0, + "forecaster": PowerplantForecaster(index), + } + return PowerPlant("testdemand", **params_dict) + + +@pytest.fixture +def grid_data(): + """ A simple mock grid. """ + + bus_data = {"name": ["node1"], "v_nom": [1.0]} + buses = pd.DataFrame(bus_data).set_index("name") + line_data = {} + lines = pd.DataFrame(line_data) + generator_data = {"name": "gen1", "node": ["node1"], "max_power": [5.0]} + generators = pd.DataFrame(generator_data).set_index("name") + load_data = {"name": "load1", "node": ["node1"], "max_power": [5.0]} + loads = pd.DataFrame(load_data).set_index("name") + + return {"buses": buses, + "lines": lines, + "generators": generators, + "loads": loads} + +def test_warning_no_generation(demand): + """ Running a Wworld with demand but no generation raises a Warning. """ + world.add_unit_operator("test_operator") + world.add_unit_instance(operator_id="test_operator", unit=demand) + with pytest.warns(UserWarning) as record: + world.run() + assert len(record) > 0 + + +def test_warning_no_demand(power_plant): + """ Running a World with generation but no demand raises a Warning. """ + world.add_unit_operator("test_operator") + world.add_unit_instance(operator_id="test_operator", unit=power_plant) + with pytest.warns(UserWarning) as record: + world.run() + assert len(record) > 0 + + +def test_market_too_early(): + """ A market that opens before simulation time, raises an Error. """ + world.add_market_operator("test_operator") + market_start = world.start - datetime.timedelta(hours=1) + market_end = world.end + market_opening = rr.rrule(rr.HOURLY, dtstart=market_start, until=market_end) + market_products= [MarketProduct(datetime.timedelta(hours=1), + 1, + datetime.timedelta(hours=1))] + market_config = MarketConfig( + "test_EOM", + opening_hours=market_opening, + market_products=market_products) + + world.add_market( + market_operator_id="test_operator", + market_config=market_config) + + with pytest.raises(ValueError): + world.run() + + +def test_market_too_late(): + """ A market that closes after simulation time, raises an Error. """ + world.add_market_operator("test_operator") + market_start = world.start + market_end = world.end + datetime.timedelta(hours=1) + market_opening = rr.rrule(rr.HOURLY, dtstart=market_start, until=market_end) + market_products= [MarketProduct( + duration=datetime.timedelta(hours=1), + count=1, + first_delivery=datetime.timedelta(hours=1))] + market_config = MarketConfig( + "test_EOM", + opening_hours=market_opening, + market_products=market_products) + + world.add_market( + market_operator_id="test_operator", + market_config=market_config) + + with pytest.raises(ValueError): + world.run() + + +def test_refering_non_existant_market(): + """A UnitOperator referencing a non-existant Market, raises an Error. """ + + strategies = {"none_existant_market": "naive_eom"} + world.add_unit_operator("unit_operator", strategies) + + with pytest.raises(ValueError): + world.run() + + +def test_non_referred_market(): + """ A Market referred by no UnitOperator raises a Warning. """ + world.add_market_operator("market_operator") + world.add_unit_operator("unit_operator") + market_opening = rr.rrule(rr.HOURLY, dtstart=world.start, until=world.end) + market_products= [MarketProduct( + duration=datetime.timedelta(hours=1), + count=1, + first_delivery=datetime.timedelta(hours=1))] + market_config = MarketConfig( + "test_EOM", + opening_hours=market_opening, + market_products=market_products) + + world.add_market( + market_operator_id="market_operator", + market_config=market_config) + + with pytest.warns(UserWarning) as record: + world.run() + assert len(record) > 0 + + +def test_redispatch_no_dispatch(grid_data): + """ A redispatch without a dispatch market raises an Error. """ + + world.add_market_operator("market_operator") + world.add_unit_operator("unit_operator") + market_opening = rr.rrule(rr.HOURLY, dtstart=world.start, until=world.end) + market_products= [MarketProduct( + duration=datetime.timedelta(hours=1), + count=1, + first_delivery=datetime.timedelta(hours=1))] + + redispatch_config = MarketConfig( + "redispatch", + market_mechanism="redispatch", + opening_hours=market_opening, + market_products=market_products, + param_dict={"grid_data": grid_data}, + additional_fields=["node", "max_power", "min_power"]) + + world.add_market( + market_operator_id="market_operator", + market_config=redispatch_config) + + with pytest.raises(ValueError): + world.run() + + +def test_redispatch_too_early(grid_data): + """ A redispatch market opening before dispatch closure raises an Error. """ + + world.add_market_operator("market_operator") + world.add_unit_operator("unit_operator") + opening_dispatch = rr.rrule(rr.HOURLY, dtstart=world.start, until=world.end) + opening_redispatch = opening_dispatch + + market_products= [MarketProduct( + duration=datetime.timedelta(hours=1), + count=1, + first_delivery=datetime.timedelta(hours=1))] + + redispatch_config = MarketConfig( + "dispatch", + opening_hours=opening_dispatch, + market_products=market_products) + + dispatch_config = MarketConfig( + "redispatch", + market_mechanism="redispatch", + opening_hours=opening_redispatch, + market_products=market_products, + param_dict={"grid_data": grid_data}, + additional_fields=["node", "max_power", "min_power"]) + + world.add_market( + market_operator_id="market_operator", + market_config=dispatch_config) + + world.add_market( + market_operator_id="market_operator", + market_config=redispatch_config) + + with pytest.raises(ValueError): + world.run() + \ No newline at end of file From 2a2a49902163cf9ffb4bfb092627f7583a52a8a1 Mon Sep 17 00:00:00 2001 From: Carl Wanninger <93084036+Graf-Wronski@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:10:19 +0100 Subject: [PATCH 3/3] Fixed typo in error message. --- assume/world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assume/world.py b/assume/world.py index 74827d878..af3c49cc3 100644 --- a/assume/world.py +++ b/assume/world.py @@ -710,7 +710,7 @@ def _validate_setup(self): if market_start < self.start or market_end > self.end: msg = (f"Market {market_id} violates world schedule. \n" f"Market start: {market_start}, end: {market_end}. \n)" - f"World start: {market_start}, end: {market_end}.)") + f"World start: {self.start}, end: {self.end}.)") raise ValueError(msg) # Integrity of reference.