diff --git a/assume/scenario/entsoe_helper/client.py b/assume/scenario/entsoe_helper/client.py new file mode 100644 index 000000000..68ebed22d --- /dev/null +++ b/assume/scenario/entsoe_helper/client.py @@ -0,0 +1,265 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging +from datetime import datetime +from pathlib import Path + +import pandas as pd + +from assume.common.exceptions import AssumeException +from assume.scenario.entsoe_helper.mappings import PSR_TO_ASSUME + +logger = logging.getLogger(__name__) + + +def _require_entsoe_client(): + try: + from entsoe import EntsoePandasClient + except ImportError as exc: + raise AssumeException( + "entsoe-py is required for the ENTSO-E loader. " + "Install it with: pip install 'assume-framework[entsoe]'" + ) from exc + return EntsoePandasClient + + +def _flatten_columns(data: pd.DataFrame | pd.Series) -> pd.DataFrame | pd.Series: + if not isinstance(data, pd.DataFrame): + return data + if isinstance(data.columns, pd.MultiIndex): + data = data.copy() + data.columns = data.columns.get_level_values(0) + data.columns = data.columns.map(str) + return data + + +class EntsoeInterface: + """Fetch country-level ENTSO-E load, generation and capacity data.""" + + def __init__(self, api_key: str, cache_dir: Path | None = None): + EntsoePandasClient = _require_entsoe_client() + self.client = EntsoePandasClient(api_key=api_key) + self.cache_dir = cache_dir or Path.home() / ".assume" / "entsoe" + + def _cache_path( + self, + country: str, + start: datetime, + end: datetime, + dataset: str, + ) -> Path: + country = country.upper() + if dataset == "capacity": + return self.cache_dir / f"{country}_{start.year}" / f"{dataset}.csv" + period = f"{start:%Y%m%d}_{end:%Y%m%d}" + return self.cache_dir / f"{country}_{period}" / f"{dataset}.csv" + + def _read_cache( + self, path: Path, parse_dates: bool = True + ) -> pd.Series | pd.DataFrame | None: + if not path.is_file(): + return None + logger.info(f"using cached ENTSO-E data from {path}") + data = pd.read_csv(path, index_col=0, parse_dates=parse_dates) + if isinstance(data, pd.DataFrame) and data.shape[1] == 1: + return data.squeeze() + return data + + def _write_cache(self, path: Path, data: pd.Series | pd.DataFrame) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data.to_csv(path) + + @staticmethod + def _ensure_unique_index( + data: pd.Series | pd.DataFrame, + ) -> pd.Series | pd.DataFrame: + if data.index.is_unique: + return data + return data.groupby(level=0).mean() + + @staticmethod + def _to_naive_index( + data: pd.Series | pd.DataFrame, + ) -> pd.Series | pd.DataFrame: + if isinstance(data.index, pd.DatetimeIndex) and data.index.tz is not None: + data = data.copy() + data.index = data.index.tz_localize(None) + return data + + @staticmethod + def _slice_to_period( + data: pd.Series | pd.DataFrame, + start: datetime, + end: datetime, + ) -> pd.Series | pd.DataFrame: + return data.loc[pd.Timestamp(start) : pd.Timestamp(end)] + + @staticmethod + def _to_utc_timestamp(value: datetime) -> pd.Timestamp: + ts = pd.Timestamp(value) + if ts.tzinfo is None: + return ts.tz_localize("UTC") + return ts.tz_convert("UTC") + + def get_country_demand( + self, + start: datetime, + end: datetime, + country: str, + use_cache: bool = True, + ) -> pd.Series: + country = country.upper() + cache_path = self._cache_path(country, start, end, "demand") + if use_cache: + cached = self._read_cache(cache_path) + if cached is not None: + return self._slice_to_period( + self._ensure_unique_index(self._to_naive_index(cached)), + start, + end, + ) + + logger.info(f"querying ENTSO-E load for {country}") + start_ts = self._to_utc_timestamp(start) + end_ts = self._to_utc_timestamp(end) + load = _flatten_columns( + self.client.query_load(country, start=start_ts, end=end_ts) + ) + demand = load["Actual Load"].resample("h").mean() + demand = self._ensure_unique_index(self._to_naive_index(demand)) + if use_cache: + self._write_cache(cache_path, demand) + return self._slice_to_period(demand, start, end) + + def get_country_generation( + self, + start: datetime, + end: datetime, + country: str, + use_cache: bool = True, + ) -> pd.DataFrame: + country = country.upper() + cache_path = self._cache_path(country, start, end, "generation") + if use_cache: + cached = self._read_cache(cache_path) + if cached is not None: + return self._slice_to_period( + self._ensure_unique_index(self._to_naive_index(cached)), + start, + end, + ) + + logger.info(f"querying ENTSO-E generation for {country}") + start_ts = self._to_utc_timestamp(start) + end_ts = self._to_utc_timestamp(end) + generation = self.client.query_generation( + country, start=start_ts, end=end_ts, nett=True + ) + if isinstance(generation, pd.Series): + generation = generation.to_frame() + generation = _flatten_columns(generation) + generation = generation.resample("h").mean() + generation = self._ensure_unique_index(self._to_naive_index(generation)) + if use_cache: + self._write_cache(cache_path, generation) + return self._slice_to_period(generation, start, end) + + def get_installed_capacity( + self, + start: datetime, + end: datetime, + country: str, + use_cache: bool = True, + ) -> pd.Series: + country = country.upper() + cache_path = self._cache_path(country, start, end, "capacity") + if use_cache: + cached = self._read_cache(cache_path, parse_dates=False) + if cached is not None: + return cached + + logger.info(f"querying ENTSO-E installed capacity for {country}") + start_ts = self._to_utc_timestamp(start) + end_ts = self._to_utc_timestamp(end) + capacity = self.client.query_installed_generation_capacity( + country, start=start_ts, end=end_ts + ) + if isinstance(capacity, pd.Series): + capacity = capacity.to_frame().T + capacity = _flatten_columns(capacity) + if capacity.empty: + raise AssumeException( + f"No installed capacity data returned for {country} in {start.year}" + ) + + index_tz = capacity.index.tz + target = pd.Timestamp(start.year, 1, 1, tz=index_tz) + if target not in capacity.index: + capacity_row = capacity.iloc[-1] + else: + capacity_row = capacity.loc[target] + + if isinstance(capacity_row, pd.DataFrame): + capacity_row = capacity_row.iloc[0] + + capacity_row = capacity_row.fillna(0) + capacity_row.index = capacity_row.index.map(str) + if use_cache: + self._write_cache(cache_path, capacity_row) + return capacity_row + + @staticmethod + def aggregate_by_technology( + capacity: pd.Series, + generation: pd.DataFrame, + ) -> dict[str, dict]: + capacity = capacity.fillna(0) + capacity.index = capacity.index.map(str) + generation = generation.fillna(0) + generation.columns = generation.columns.map(str) + + aggregated: dict[str, dict] = {} + all_psr_types = set(capacity.index) | set(generation.columns) + + for psr_name in sorted(all_psr_types): + mapping = PSR_TO_ASSUME.get(psr_name) + if mapping is None: + raise AssumeException( + f"Unmapped ENTSO-E production type '{psr_name}'. " + "Extend PSR_TO_ASSUME in " + "assume/scenario/entsoe_helper/mappings.py" + ) + + cap_mw = float(capacity.get(psr_name, 0.0)) + gen_series = ( + generation[psr_name] + if psr_name in generation.columns + else pd.Series(0.0, index=generation.index) + ) + peak_gen = float(gen_series.max()) + + if cap_mw <= 0 and peak_gen > 0: + cap_mw = peak_gen + logger.info( + f"using peak generation {cap_mw:.1f} MW as capacity for {psr_name}" + ) + + if cap_mw <= 0 and peak_gen <= 0: + continue + + tech = mapping.technology + if tech not in aggregated: + aggregated[tech] = { + "mapping": mapping, + "capacity_mw": cap_mw, + "generation_mw": gen_series, + } + else: + aggregated[tech]["capacity_mw"] += cap_mw + aggregated[tech]["generation_mw"] = ( + aggregated[tech]["generation_mw"] + gen_series + ) + + return aggregated diff --git a/assume/scenario/entsoe_helper/fuel_prices.py b/assume/scenario/entsoe_helper/fuel_prices.py new file mode 100644 index 000000000..c196e2c0a --- /dev/null +++ b/assume/scenario/entsoe_helper/fuel_prices.py @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import io +import logging +from datetime import datetime +from pathlib import Path + +import pandas as pd +import requests + +logger = logging.getLogger(__name__) + +EU_ETS_URL = "https://energy-api.instrat.pl/api/prices/co2" +COAL_URL = "https://energy-api.instrat.pl/api/coal/pscmi_1" +GAS_URL = "https://energy-api.instrat.pl/api/prices/gas_price_rdn_daily" +USER_AGENT = "Mozilla/5.0 (compatible; ASSUME/1.0; +https://assume-project.de/)" + +GJ_TO_KWH = 1e6 / 3600 + +# €/MWh thermal – fallback when instrat returns no usable coal data +_COAL_FALLBACK_EUR_MWH = 18.0 + + +def _sanitize_daily_prices( + series: pd.Series, name: str, fallback: float | None = None +) -> pd.Series: + """Drop invalid values and ensure the series is usable for reindexing.""" + numeric = pd.to_numeric(series, errors="coerce").dropna() + if not numeric.empty: + numeric.name = name + return numeric + + fill = fallback if fallback is not None else _COAL_FALLBACK_EUR_MWH + logger.warning( + "No valid %s prices returned from instrat.pl; using fallback %.1f €/MWh", + name, + fill, + ) + anchor = series.index[0] if len(series.index) else pd.Timestamp("2024-01-01") + return pd.Series(fill, index=[anchor], name=name) + + +def _to_hourly_prices(series: pd.Series, index: pd.DatetimeIndex) -> pd.Series: + """Expand daily prices to the simulation index without leading NaNs.""" + daily = series.resample("D").ffill().bfill() + hourly = daily.reindex(index, method="ffill").bfill().ffill() + if hourly.isna().any(): + fill_value = hourly.dropna().iloc[0] + hourly = hourly.fillna(fill_value) + return hourly + + +class InstratFuelPrices: + """Fetch coal, gas and EU ETS prices from energy.instrat.pl.""" + + def __init__(self, cache_dir: Path | None = None): + self.cache_dir = cache_dir or Path.home() / ".assume" / "instrat_pl" + + def _cache_path(self, start: datetime, end: datetime, dataset: str) -> Path: + period = f"{start:%Y%m%d}_{end:%Y%m%d}" + return self.cache_dir / period / f"{dataset}.csv" + + def _read_cache(self, path: Path) -> pd.Series | None: + if not path.is_file(): + return None + logger.info(f"using cached instrat_pl data from {path}") + data = pd.read_csv(path, index_col=0, parse_dates=True) + if isinstance(data, pd.DataFrame): + return data.iloc[:, 0] + return data + + def _write_cache(self, path: Path, data: pd.Series) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data.to_csv(path) + + @staticmethod + def _download(url: str, start: datetime, end: datetime) -> pd.DataFrame: + params = { + "date_from": start.strftime("%d-%m-%YT%H:%M:%SZ"), + "date_to": end.strftime("%d-%m-%YT%H:%M:%SZ"), + } + headers = {"User-Agent": USER_AGENT} + response = requests.get(url, params=params, headers=headers, timeout=60) + response.raise_for_status() + df = pd.read_json(io.StringIO(response.text)) + df = df.set_index("date") + df.index = df.index.tz_localize(None) + return df + + @staticmethod + def _pln_to_eur(index: pd.DatetimeIndex) -> pd.Series: + try: + import yfinance as yf + except ImportError as exc: + raise ImportError( + "yfinance is required for instrat_pl fuel prices. " + "Install with: pip install 'assume-framework[entsoe]'" + ) from exc + + start = index[0].strftime("%Y-%m-%d") + end = index[-1].strftime("%Y-%m-%d") + pln_eur = yf.download("PLNEUR=X", start=start, end=end, progress=False)["Close"] + if isinstance(pln_eur.columns, pd.MultiIndex): + pln_eur = pln_eur["PLNEUR=X"] + else: + pln_eur = pln_eur.squeeze() + pln_eur = pln_eur.reindex(index).ffill().bfill() + return pln_eur + + def get_co2_price( + self, + start: datetime, + end: datetime, + use_cache: bool = True, + ) -> pd.Series: + """Return EU ETS price in €/tCO2.""" + cache_path = self._cache_path(start, end, "co2") + if use_cache: + cached = self._read_cache(cache_path) + if cached is not None: + return cached + + df = self._download(EU_ETS_URL, start, end) + series = _sanitize_daily_prices(df["price"], "co2", fallback=80.0) + series = series.resample("D").ffill().bfill() + if use_cache: + self._write_cache(cache_path, series) + return series + + def get_coal_price( + self, + start: datetime, + end: datetime, + use_cache: bool = True, + ) -> pd.Series: + """Return steam coal price in €/MWh thermal.""" + cache_path = self._cache_path(start, end, "coal") + if use_cache: + cached = self._read_cache(cache_path) + if cached is not None: + series = _sanitize_daily_prices(cached, "hard coal") + return series.resample("D").ffill().bfill() + + coal_data = self._download(COAL_URL, start, end) + pln_eur = self._pln_to_eur(coal_data.index) + steam_coal_eur_per_gj = coal_data["pscmi1_pln_per_gj"] * pln_eur + series = _sanitize_daily_prices( + steam_coal_eur_per_gj / GJ_TO_KWH * 1e3, "hard coal" + ) + series = series.resample("D").ffill().bfill() + if use_cache and not series.empty: + self._write_cache(cache_path, series) + return series + + def get_gas_price( + self, + start: datetime, + end: datetime, + use_cache: bool = True, + ) -> pd.Series: + """Return gas price in €/MWh thermal.""" + cache_path = self._cache_path(start, end, "gas") + if use_cache: + cached = self._read_cache(cache_path) + if cached is not None: + return cached + + gas_data = self._download(GAS_URL, start, end) + pln_eur = self._pln_to_eur(gas_data.index) + series = _sanitize_daily_prices( + gas_data["price"] * pln_eur, "gas", fallback=30.0 + ) + series = series.resample("D").ffill().bfill() + if use_cache: + self._write_cache(cache_path, series) + return series + + def get_fuel_prices( + self, + start: datetime, + end: datetime, + index: pd.DatetimeIndex, + use_cache: bool = True, + ) -> dict[str, pd.Series]: + """ + Return hourly fuel price series for the simulation index. + + Coal and gas come from instrat_pl; lignite reuses the coal series. + """ + coal = self.get_coal_price(start, end, use_cache=use_cache) + gas = self.get_gas_price(start, end, use_cache=use_cache) + co2 = self.get_co2_price(start, end, use_cache=use_cache) + + coal = _to_hourly_prices(coal, index) + gas = _to_hourly_prices(gas, index) + co2 = _to_hourly_prices(co2, index) + + return { + "hard coal": coal, + "lignite": coal, + "gas": gas, + "co2": co2, + } diff --git a/assume/scenario/entsoe_helper/mappings.py b/assume/scenario/entsoe_helper/mappings.py new file mode 100644 index 000000000..e97f04c67 --- /dev/null +++ b/assume/scenario/entsoe_helper/mappings.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from dataclasses import dataclass + +COUNTRY_LOCATIONS: dict[str, tuple[float, float]] = { + "AT": (47.52, 14.55), + "BE": (50.50, 4.47), + "BG": (42.73, 25.49), + "CH": (46.82, 8.23), + "CZ": (49.82, 15.47), + "DE": (51.16, 10.45), + "DK": (56.26, 9.50), + "EE": (58.60, 25.01), + "ES": (40.46, -3.75), + "FI": (61.92, 25.75), + "FR": (46.22, 2.21), + "GB": (55.38, -3.44), + "GR": (39.07, 21.82), + "HR": (45.10, 15.20), + "HU": (47.16, 19.50), + "IE": (53.41, -8.24), + "IT": (41.87, 12.57), + "LT": (55.17, 23.88), + "LU": (49.82, 6.13), + "LV": (56.88, 24.60), + "NL": (52.13, 5.29), + "NO": (60.47, 8.47), + "PL": (51.92, 19.15), + "PT": (39.40, -8.22), + "RO": (45.94, 24.97), + "SE": (60.13, 18.64), + "SI": (46.15, 14.99), + "SK": (48.67, 19.70), +} + + +@dataclass(frozen=True) +class TechnologyMapping: + technology: str + bidding_key: str + variable: bool + unit_type: str = "power_plant" + + +PSR_TO_ASSUME: dict[str, TechnologyMapping] = { + "Biomass": TechnologyMapping("biomass", "biomass", False), + "Fossil Brown coal/Lignite": TechnologyMapping("lignite", "lignite", False), + "Fossil Coal-derived gas": TechnologyMapping("gas", "gas", False), + "Fossil Gas": TechnologyMapping("gas", "gas", False), + "Fossil Hard coal": TechnologyMapping("hard coal", "hard coal", False), + "Fossil Oil": TechnologyMapping("oil", "oil", False), + "Fossil Oil shale": TechnologyMapping("oil", "oil", False), + "Fossil Peat": TechnologyMapping("lignite", "lignite", False), + "Geothermal": TechnologyMapping("geothermal", "biomass", False), + "Hydro Pumped Storage": TechnologyMapping( + "hydro_storage", "storage", False, unit_type="storage" + ), + "Hydro Run-of-river and poundage": TechnologyMapping("hydro", "hydro", False), + "Hydro Water Reservoir": TechnologyMapping("hydro", "hydro", False), + "Marine": TechnologyMapping("marine", "hydro", False), + "Nuclear": TechnologyMapping("nuclear", "nuclear", False), + "Other": TechnologyMapping("other", "biomass", False), + "Other renewable": TechnologyMapping("other_renewable", "biomass", False), + "Solar": TechnologyMapping("solar", "solar", True), + "Waste": TechnologyMapping("waste", "biomass", False), + "Wind Offshore": TechnologyMapping("wind_offshore", "wind", True), + "Wind Onshore": TechnologyMapping("wind_onshore", "wind", True), + "Energy storage": TechnologyMapping( + "battery_storage", "storage", False, unit_type="storage" + ), +} + +DEFAULT_BLOCK_SIZES_MW: dict[str, float] = { + "hard coal": 500.0, + "lignite": 500.0, + "gas": 400.0, + "oil": 200.0, + "nuclear": 1000.0, + "hydro": 300.0, + "biomass": 200.0, + "geothermal": 100.0, + "marine": 100.0, + "other": 200.0, + "other_renewable": 200.0, + "waste": 200.0, + "hydro_storage": 250.0, + "battery_storage": 50.0, +} + +# spread around the instrat base price for block interpolation +DEFAULT_FUEL_PRICE_RANGES: dict[str, tuple[float, float]] = { + "hard coal": (13.0, 10.0), + "lignite": (3.0, 2.0), + "gas": (32.0, 22.0), + "oil": (25.0, 18.0), + "biomass": (22.0, 18.0), + "hydro": (0.4, 0.1), + "geothermal": (15.0, 10.0), + "marine": (0.4, 0.1), + "other": (25.0, 15.0), + "other_renewable": (15.0, 8.0), + "waste": (22.0, 18.0), + "nuclear": (9.0, 7.0), +} + +DEFAULT_RENEWABLE_FUEL_PRICES: dict[str, float] = { + "solar": 0.1, + "wind_onshore": 0.2, + "wind_offshore": 0.2, +} + +DEFAULT_STORAGE_HOURS = 8.0 +DEFAULT_STORAGE_ADDITIONAL_COST = 0.28 +DEFAULT_CO2_PRICE_EUR_T = 70.0 + +FOSSIL_BIDDING_KEYS = {"hard coal", "lignite", "oil", "gas"} +THERMAL_BIDDING_KEYS = FOSSIL_BIDDING_KEYS | {"nuclear"} + +# tCO2/MWh_el, aligned with examples/inputs/example_03/powerplant_units.csv +DEFAULT_EMISSION_FACTORS: dict[str, float] = { + "hard coal": 0.335, + "lignite": 0.406, + "gas": 0.201, + "oil": 0.776, + "nuclear": 0.0, + "biomass": 0.0, + "hydro": 0.0, + "wind": 0.0, + "solar": 0.0, + "storage": 0.0, +} + + +def split_capacity_blocks(capacity_mw: float, block_size_mw: float) -> list[float]: + if capacity_mw <= 0: + return [] + if block_size_mw <= 0: + return [capacity_mw] + + full_blocks = int(capacity_mw // block_size_mw) + blocks = [block_size_mw] * full_blocks + remainder = capacity_mw % block_size_mw + if remainder > 0: + blocks.append(remainder) + if not blocks: + blocks = [capacity_mw] + return blocks + + +def interpolate_block_prices( + n_blocks: int, + price_high: float, + price_low: float, +) -> list[float]: + if n_blocks <= 0: + return [] + if n_blocks == 1: + return [price_high] + step = (price_high - price_low) / (n_blocks - 1) + return [price_high - i * step for i in range(n_blocks)] + + +def block_price_factors( + n_blocks: int, price_high: float, price_low: float +) -> list[float]: + """Return multipliers for a base price series, highest block first.""" + block_prices = interpolate_block_prices(n_blocks, price_high, price_low) + base = (price_high + price_low) / 2 + if base <= 0: + return [1.0] * n_blocks + return [price / base for price in block_prices] diff --git a/assume/scenario/loader_entsoe.py b/assume/scenario/loader_entsoe.py new file mode 100644 index 000000000..e9056b2f4 --- /dev/null +++ b/assume/scenario/loader_entsoe.py @@ -0,0 +1,474 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +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.exceptions import AssumeException +from assume.common.forecaster import ( + DemandForecaster, + PowerplantForecaster, + UnitForecaster, +) +from assume.common.market_objects import MarketConfig, MarketProduct +from assume.scenario.entsoe_helper.client import EntsoeInterface +from assume.scenario.entsoe_helper.fuel_prices import InstratFuelPrices +from assume.scenario.entsoe_helper.mappings import ( + COUNTRY_LOCATIONS, + DEFAULT_BLOCK_SIZES_MW, + DEFAULT_CO2_PRICE_EUR_T, + DEFAULT_EMISSION_FACTORS, + DEFAULT_FUEL_PRICE_RANGES, + DEFAULT_RENEWABLE_FUEL_PRICES, + DEFAULT_STORAGE_ADDITIONAL_COST, + DEFAULT_STORAGE_HOURS, + FOSSIL_BIDDING_KEYS, + block_price_factors, + interpolate_block_prices, + split_capacity_blocks, +) + +logger = logging.getLogger(__name__) + + +def load_entsoe( + world: World, + scenario: str, + study_case: str, + start: datetime, + end: datetime, + countries: list[str], + marketdesign: list[MarketConfig], + bidding_strategies: dict[str, dict[str, str]], + api_key: str | None = None, + fuel_price_ranges: dict[str, tuple[float, float]] | None = None, + block_sizes_mw: dict[str, float] | None = None, + use_cache: bool = True, + use_instrat_fuel_prices: bool = True, + save_frequency_hours: int = 48, +): + """ + Initialize a country-level scenario from the ENTSO-E Transparency Platform. + + Requires ``pip install 'assume-framework[entsoe]'`` and an API key from + https://transparency.entsoe.eu/ (``ENTSOE_API_KEY`` env var or ``api_key``). + + Args: + world (World): the world to add this scenario to + scenario (str): scenario name + study_case (str): study case name + start (datetime): simulation start + end (datetime): simulation end + countries (list[str]): ISO country codes, e.g. ``["DE", "FR"]`` + marketdesign (list[MarketConfig]): market design for the simulation + bidding_strategies (dict): bidding strategies per fuel or technology key + api_key (str, optional): ENTSO-E API key + fuel_price_ranges (dict, optional): block spread in €/MWh per fuel + block_sizes_mw (dict, optional): block size in MW per technology + use_cache (bool): cache API responses under ``~/.assume/entsoe`` + use_instrat_fuel_prices (bool): fetch coal, gas and CO2 from instrat.pl + save_frequency_hours (int): database save interval + """ + if not countries: + countries = ["DE"] + + countries = [country.upper() for country in countries] + index = pd.date_range(start=start, end=end, freq="h") + simulation_id = f"{scenario}_{study_case}" + logger.info(f"loading ENTSO-E scenario {simulation_id} with {countries}") + + api_key = api_key or os.getenv("ENTSOE_API_KEY") or os.getenv("ENTSOE") + if not api_key: + raise AssumeException( + "ENTSO-E API key missing. Set ENTSOE_API_KEY or pass api_key." + ) + + entsoe = EntsoeInterface(api_key=api_key) + fuel_price_ranges = DEFAULT_FUEL_PRICE_RANGES | (fuel_price_ranges or {}) + block_sizes_mw = DEFAULT_BLOCK_SIZES_MW | (block_sizes_mw or {}) + + api_fuel_prices: dict[str, pd.Series] = {} + if use_instrat_fuel_prices: + api_fuel_prices = InstratFuelPrices().get_fuel_prices( + start, end, index, use_cache=use_cache + ) + co2_prices = _resolve_co2_prices(index, api_fuel_prices) + + world.setup( + start=start, + end=end, + save_frequency_hours=save_frequency_hours, + simulation_id=simulation_id, + ) + + mo_id = "market_operator" + world.add_market_operator(id=mo_id) + for market_config in marketdesign: + world.add_market(mo_id, market_config) + + for country in countries: + logger.info(f"loading ENTSO-E data for {country}") + demand = entsoe.get_country_demand(start, end, country, use_cache=use_cache) + demand = demand.reindex(index).ffill().bfill() + generation = entsoe.get_country_generation( + start, end, country, use_cache=use_cache + ) + capacity = entsoe.get_installed_capacity( + start, end, country, use_cache=use_cache + ) + technologies = entsoe.aggregate_by_technology(capacity, generation) + location = COUNTRY_LOCATIONS.get(country, (0.0, 0.0)) + + world.add_unit_operator(f"demand_{country}") + world.add_unit( + f"demand_{country}", + "demand", + f"demand_{country}", + { + "min_power": 0, + "max_power": -demand.max(), + "bidding_strategies": bidding_strategies["demand"], + "technology": "demand", + "location": location, + "node": country, + "price": 1e3, + }, + DemandForecaster(index, demand=-abs(demand)), + ) + + world.add_unit_operator(f"generation_{country}") + for tech, tech_data in technologies.items(): + mapping = tech_data["mapping"] + total_capacity = tech_data["capacity_mw"] + gen_series = tech_data["generation_mw"].reindex(index, fill_value=0) + + if total_capacity <= 0: + continue + + if mapping.unit_type == "storage": + _add_storage_units( + world, + country, + tech, + mapping, + total_capacity, + index, + location, + bidding_strategies, + block_sizes_mw, + ) + elif mapping.variable: + _add_variable_unit( + world, + country, + tech, + mapping, + total_capacity, + gen_series, + index, + location, + bidding_strategies, + ) + else: + _add_blocked_units( + world, + country, + tech, + mapping, + total_capacity, + gen_series, + index, + location, + bidding_strategies, + block_sizes_mw, + fuel_price_ranges, + api_fuel_prices, + co2_prices, + ) + + world.init_forecasts() + + +def _generation_availability(gen_series, max_power): + if max_power <= 0: + return 0 + return (gen_series / max_power).clip(lower=0, upper=1) + + +def _resolve_price_range(tech, bidding_key, fuel_price_ranges): + if bidding_key in fuel_price_ranges: + return fuel_price_ranges[bidding_key] + if tech in fuel_price_ranges: + return fuel_price_ranges[tech] + return fuel_price_ranges.get("other", (20.0, 15.0)) + + +def _emission_factor(bidding_key: str) -> float: + return DEFAULT_EMISSION_FACTORS.get(bidding_key, 0.0) + + +def _resolve_co2_prices( + index: pd.DatetimeIndex, + api_fuel_prices: dict[str, pd.Series], +) -> pd.Series: + """Return a shared CO2 price series for all fossil units (€/tCO2).""" + if "co2" in api_fuel_prices: + return api_fuel_prices["co2"] + return pd.Series(DEFAULT_CO2_PRICE_EUR_T, index=index, name="co2") + + +def _powerplant_fuel_type(bidding_key: str) -> str: + if bidding_key in FOSSIL_BIDDING_KEYS: + return bidding_key + return "others" + + +def _build_fossil_fuel_prices( + bidding_key: str, + fuel_value: pd.Series | float, + co2_prices: pd.Series, +) -> dict[str, pd.Series | float]: + fuel_type = _powerplant_fuel_type(bidding_key) + fuel_prices: dict[str, pd.Series | float] = {fuel_type: fuel_value} + if bidding_key in FOSSIL_BIDDING_KEYS: + fuel_prices["co2"] = co2_prices + return fuel_prices + + +def _build_api_fuel_prices( + bidding_key: str, + fuel_series: pd.Series, + co2_prices: pd.Series, +) -> dict[str, pd.Series]: + return _build_fossil_fuel_prices(bidding_key, fuel_series, co2_prices) + + +def _add_variable_unit( + world, + country, + tech, + mapping, + total_capacity, + gen_series, + index, + location, + bidding_strategies, +): + if total_capacity <= 0: + return + + fuel_price = DEFAULT_RENEWABLE_FUEL_PRICES.get(tech, 0.2) + world.add_unit( + f"generation_{country}_{tech}", + "power_plant", + f"generation_{country}", + { + "min_power": 0, + "max_power": total_capacity, + "bidding_strategies": bidding_strategies[mapping.bidding_key], + "technology": tech, + "emission_factor": _emission_factor(mapping.bidding_key), + "location": location, + "node": country, + }, + PowerplantForecaster( + index, + availability=_generation_availability(gen_series, total_capacity), + fuel_prices={"others": fuel_price}, + ), + ) + + +def _add_blocked_units( + world, + country, + tech, + mapping, + total_capacity, + gen_series, + index, + location, + bidding_strategies, + block_sizes_mw, + fuel_price_ranges, + api_fuel_prices, + co2_prices, +): + if total_capacity <= 0: + return + + block_size = block_sizes_mw.get( + tech, block_sizes_mw.get(mapping.bidding_key, 500.0) + ) + blocks = split_capacity_blocks(total_capacity, block_size) + price_high, price_low = _resolve_price_range( + tech, mapping.bidding_key, fuel_price_ranges + ) + + if mapping.bidding_key in api_fuel_prices: + factors = block_price_factors(len(blocks), price_high, price_low) + for block_idx, (block_capacity, block_factor) in enumerate( + zip(blocks, factors), start=1 + ): + block_max = block_capacity + fuel_prices = _build_api_fuel_prices( + mapping.bidding_key, + api_fuel_prices[mapping.bidding_key] * block_factor, + co2_prices, + ) + world.add_unit( + f"generation_{country}_{tech}_{block_idx}", + "power_plant", + f"generation_{country}", + { + "min_power": 0, + "max_power": block_max, + "bidding_strategies": bidding_strategies[mapping.bidding_key], + "technology": tech, + "fuel_type": _powerplant_fuel_type(mapping.bidding_key), + "emission_factor": _emission_factor(mapping.bidding_key), + "location": location, + "node": country, + }, + PowerplantForecaster( + index, + availability=1, + fuel_prices=fuel_prices, + ), + ) + return + + block_prices = interpolate_block_prices(len(blocks), price_high, price_low) + for block_idx, (block_capacity, block_price) in enumerate( + zip(blocks, block_prices), start=1 + ): + block_max = block_capacity + fuel_prices = _build_fossil_fuel_prices( + mapping.bidding_key, block_price, co2_prices + ) + + world.add_unit( + f"generation_{country}_{tech}_{block_idx}", + "power_plant", + f"generation_{country}", + { + "min_power": 0, + "max_power": block_max, + "bidding_strategies": bidding_strategies[mapping.bidding_key], + "technology": tech, + "fuel_type": _powerplant_fuel_type(mapping.bidding_key), + "emission_factor": _emission_factor(mapping.bidding_key), + "location": location, + "node": country, + }, + PowerplantForecaster( + index, + availability=1, + fuel_prices=fuel_prices, + ), + ) + + +def _add_storage_units( + world, + country, + tech, + mapping, + total_capacity, + index, + location, + bidding_strategies, + block_sizes_mw, +): + if total_capacity <= 0: + return + + block_size = block_sizes_mw.get(tech, block_sizes_mw.get("hydro_storage", 250.0)) + blocks = split_capacity_blocks(total_capacity, block_size) + + for block_idx, block_capacity in enumerate(blocks, start=1): + world.add_unit( + f"storage_{country}_{tech}_{block_idx}", + "storage", + f"generation_{country}", + { + "max_power_charge": -abs(block_capacity), + "max_power_discharge": block_capacity, + "capacity": block_capacity * DEFAULT_STORAGE_HOURS, + "max_soc": 1.0, + "min_soc": 0.0, + "initial_soc": 0.5, + "efficiency_charge": 0.85, + "efficiency_discharge": 0.9, + "additional_cost_charge": DEFAULT_STORAGE_ADDITIONAL_COST, + "additional_cost_discharge": DEFAULT_STORAGE_ADDITIONAL_COST, + "bidding_strategies": bidding_strategies[mapping.bidding_key], + "technology": tech, + "location": location, + "node": country, + }, + UnitForecaster(index, availability=1), + ) + + +if __name__ == "__main__": + db_uri = "postgresql://assume:assume@localhost:5432/assume" + world = World(database_uri=db_uri) + scenario = "entsoe" + countries = os.getenv("ENTSOE_COUNTRIES", "DE").split(",") + countries = [country.strip() for country in countries if country.strip()] + year = int(os.getenv("ENTSOE_YEAR", "2024")) + study_case = f"{'_'.join(countries)}_{year}" + + start = datetime(year, 1, 1) + end = datetime(year, 12, 31) - timedelta(hours=1) + marketdesign = [ + MarketConfig( + "EOM", + rr.rrule(rr.HOURLY, interval=24, dtstart=start, until=end), + timedelta(hours=1), + "pay_as_clear", + [MarketProduct(timedelta(hours=1), 24, timedelta(hours=1))], + additional_fields=["block_id", "link", "exclusive_id"], + maximum_bid_volume=1e9, + maximum_bid_price=1e9, + ) + ] + + default_strategy = {mc.market_id: "powerplant_energy_naive" for mc in marketdesign} + default_demand_strategy = { + mc.market_id: "demand_energy_naive" for mc in marketdesign + } + bidding_strategies = { + "hard coal": default_strategy, + "lignite": default_strategy, + "oil": default_strategy, + "gas": default_strategy, + "biomass": default_strategy, + "hydro": default_strategy, + "nuclear": default_strategy, + "wind": default_strategy, + "solar": default_strategy, + "storage": { + mc.market_id: "storage_energy_heuristic_flexable" for mc in marketdesign + }, + "demand": default_demand_strategy, + } + + load_entsoe( + world, + scenario, + study_case, + start, + end, + countries, + marketdesign, + bidding_strategies, + ) + world.run() diff --git a/docs/source/assume.scenario.rst b/docs/source/assume.scenario.rst index 0239cf2d5..2ed595e12 100644 --- a/docs/source/assume.scenario.rst +++ b/docs/source/assume.scenario.rst @@ -38,6 +38,21 @@ OEDS Infrastructure :undoc-members: :show-inheritance: +.. _entsoe_loader: + +ENTSO-E Loader +-------------- + +.. automodule:: assume.scenario.loader_entsoe + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: assume.scenario.entsoe_helper.fuel_prices + :members: + :undoc-members: + :show-inheritance: + .. _amiris_loader: AMIRIS Loader diff --git a/docs/source/scenario_loader.rst b/docs/source/scenario_loader.rst index d4ca97ab8..015f92e6a 100644 --- a/docs/source/scenario_loader.rst +++ b/docs/source/scenario_loader.rst @@ -10,6 +10,7 @@ For compatibility with other simulation tools, ASSUME provides a variety of scen - :ref:`csv` - File based scenarios (most flexible) - :ref:`amiris` - used to create comparative studies - :ref:`oeds` - create scenarios with the Open Energy Data Server +- :ref:`entsoe` - create country-level scenarios from the ENTSO-E API - :ref:`pypsa_loader_doc` - create scenarios from imported PyPSA networks @@ -151,6 +152,94 @@ An example configuration of how this can be used is shown here: This creates operators each per NUTS areas and creates a single EOM market, just as the `DMAS simulation `_ from FH Aachen. For more information consult the methods documentation :py:meth:`assume.scenario.loader_oeds.load_oeds`. +.. _entsoe: + +ENTSO-E +------- + +The ENTSO-E loader queries the `ENTSO-E Transparency Platform `_ directly. +It creates country-level scenarios from actual load, actual generation per production type +and installed generation capacity without requiring a local Open-Energy-Data-Server. + +Install the optional dependency with ``pip install 'assume-framework[entsoe]'`` +and register for an API key at the ENTSO-E transparency platform. +Set the environment variable ``ENTSOE_API_KEY`` before running a scenario. + +Coal, gas and EU ETS prices are fetched from `energy.instrat.pl` by default. +The loader creates one node per country without network constraints. + +.. code-block:: python + + from datetime import datetime, timedelta + from dateutil import rrule as rr + + from assume import World + from assume.common.market_objects import MarketConfig, MarketProduct + from assume.scenario.loader_entsoe import load_entsoe + + db_uri = "postgresql://assume:assume@localhost:5432/assume" + world = World(database_uri=db_uri) + + start = datetime(2024, 1, 1) + end = datetime(2024, 12, 31) - timedelta(hours=1) + marketdesign = [ + MarketConfig( + "EOM", + rr.rrule(rr.HOURLY, interval=24, dtstart=start, until=end), + timedelta(hours=1), + "pay_as_clear", + [MarketProduct(timedelta(hours=1), 24, timedelta(hours=1))], + additional_fields=["block_id", "link", "exclusive_id"], + maximum_bid_volume=1e9, + maximum_bid_price=1e9, + ) + ] + + default_strategy = {mc.market_id: "powerplant_energy_naive" for mc in marketdesign} + default_demand_strategy = {mc.market_id: "demand_energy_naive" for mc in marketdesign} + default_storage_strategy = { + mc.market_id: "storage_energy_heuristic_flexable" for mc in marketdesign + } + + bidding_strategies = { + "hard coal": default_strategy, + "lignite": default_strategy, + "oil": default_strategy, + "gas": default_strategy, + "biomass": default_strategy, + "hydro": default_strategy, + "nuclear": default_strategy, + "wind": default_strategy, + "solar": default_strategy, + "storage": default_storage_strategy, + "demand": default_demand_strategy, + } + + load_entsoe( + world, + "entsoe", + "DE_2024", + start, + end, + ["DE"], + marketdesign, + bidding_strategies, + fuel_price_ranges={ + "gas": (32.0, 22.0), + "hard coal": (13.0, 10.0), + }, + block_sizes_mw={ + "gas": 400, + "hard coal": 500, + "nuclear": 1000, + }, + ) + + world.run() + +API responses are cached under ``~/.assume/entsoe`` to avoid repeated downloads. +For more information consult the methods documentation :py:meth:`assume.scenario.loader_entsoe.load_entsoe`. + .. _pypsa_loader_doc: PyPSA diff --git a/pyproject.toml b/pyproject.toml index fba43f297..f0a857b32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,11 @@ oeds = [ "pvlib >=0.10.2", "windpowerlib >=0.2.1", ] +entsoe = [ + "entsoe-py >=0.6.0", + "yfinance", + "requests", +] test = [ "ruff >=0.14.6", "mypy >=1.1.1", @@ -73,7 +78,7 @@ test = [ "pre-commit >=4.0.0" ] all = [ - "assume-framework[oeds, network, learning, test]", + "assume-framework[oeds, entsoe, network, learning, test]", ] docs = [ "sphinx >=8", diff --git a/tests/test_loader_entsoe.py b/tests/test_loader_entsoe.py new file mode 100644 index 000000000..3bca1a82d --- /dev/null +++ b/tests/test_loader_entsoe.py @@ -0,0 +1,396 @@ +# SPDX-FileCopyrightText: ASSUME Developers +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import datetime +from io import StringIO +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest + +from assume.common.exceptions import AssumeException +from assume.common.forecaster import UnitForecaster +from assume.scenario.entsoe_helper.client import EntsoeInterface +from assume.scenario.entsoe_helper.fuel_prices import InstratFuelPrices +from assume.scenario.entsoe_helper.mappings import ( + DEFAULT_CO2_PRICE_EUR_T, + PSR_TO_ASSUME, + block_price_factors, + interpolate_block_prices, + split_capacity_blocks, +) +from assume.scenario.loader_entsoe import ( + _add_blocked_units, + _add_storage_units, + _add_variable_unit, + _resolve_co2_prices, + load_entsoe, +) + + +@pytest.fixture +def hourly_index(): + return pd.date_range("2024-01-01", "2024-01-02 23:00", freq="h") + + +@pytest.fixture +def mock_entsoe_data(hourly_index): + demand = pd.Series(50_000.0, index=hourly_index, name="Actual Load") + generation = pd.DataFrame( + { + "Solar": 5_000.0, + "Wind Onshore": 8_000.0, + "Fossil Gas": 15_000.0, + "Nuclear": 10_000.0, + "Hydro Pumped Storage": 2_000.0, + "Other": 500.0, + }, + index=hourly_index, + ) + capacity = pd.Series( + { + "Solar": 80_000.0, + "Wind Onshore": 60_000.0, + "Fossil Gas": 2_000.0, + "Nuclear": 12_000.0, + "Hydro Pumped Storage": 4_000.0, + "Other": 1_000.0, + } + ) + return demand, generation, capacity + + +def test_load_entsoe_requires_api_key(): + world = MagicMock() + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(AssumeException, match="API key missing"): + load_entsoe( + world, + "entsoe_test", + "DE_2024", + datetime(2024, 1, 1), + datetime(2024, 1, 2), + ["DE"], + [], + {"demand": {}}, + use_instrat_fuel_prices=False, + ) + + +def test_aggregate_raises_for_unmapped_psr(hourly_index): + generation = pd.DataFrame({"Unknown Fuel": 100.0}, index=hourly_index) + capacity = pd.Series({"Unknown Fuel": 100.0}) + with pytest.raises(AssumeException, match="Unmapped ENTSO-E production type"): + EntsoeInterface.aggregate_by_technology(capacity, generation) + + +def test_split_capacity_blocks(): + assert split_capacity_blocks(950, 400) == [400, 400, 150] + assert split_capacity_blocks(400, 400) == [400] + assert split_capacity_blocks(0, 400) == [] + + +def test_interpolate_block_prices(): + assert interpolate_block_prices(1, 30, 20) == [30] + assert interpolate_block_prices(3, 30, 20) == [30, 25, 20] + + +def test_block_price_factors(): + factors = block_price_factors(3, 30, 20) + assert factors[0] > factors[-1] + assert pytest.approx(sum(factors) / len(factors), rel=1e-6) == 1.0 + + +def test_instrat_co2_price_parsing(hourly_index): + payload = StringIO( + '[{"date":"2024-01-01T00:00:00","price":80.0},' + '{"date":"2024-01-02T00:00:00","price":82.0}]' + ) + df = pd.read_json(payload).set_index("date") + df.index = df.index.tz_localize(None) + series = df["price"].resample("D").bfill() + assert series.iloc[0] == 80.0 + + +@patch.object(InstratFuelPrices, "_download") +def test_get_fuel_prices(mock_download, hourly_index): + mock_download.side_effect = [ + pd.DataFrame({"pscmi1_pln_per_gj": [10.0, 11.0]}, index=hourly_index[:2]), + pd.DataFrame({"price": [100.0, 110.0]}, index=hourly_index[:2]), + pd.DataFrame({"price": [80.0, 82.0]}, index=hourly_index[:2]), + ] + + with patch.object( + InstratFuelPrices, + "_pln_to_eur", + return_value=pd.Series(0.23, index=hourly_index[:2]), + ): + prices = InstratFuelPrices().get_fuel_prices( + datetime(2024, 1, 1), + datetime(2024, 1, 2), + hourly_index, + use_cache=False, + ) + + assert set(prices) == {"hard coal", "lignite", "gas", "co2"} + assert len(prices["gas"]) == len(hourly_index) + assert prices["hard coal"].isna().sum() == 0 + + +def test_get_fuel_prices_handles_empty_coal_cache(hourly_index, tmp_path): + cache_dir = tmp_path / "instrat" + period_dir = cache_dir / "20240101_20240102" + period_dir.mkdir(parents=True) + (period_dir / "coal.csv").write_text("date,hard coal\n2024-01-01,\n") + (period_dir / "gas.csv").write_text("date,gas\n2024-01-01,30.0\n2024-01-02,31.0\n") + (period_dir / "co2.csv").write_text("date,co2\n2024-01-01,80.0\n2024-01-02,81.0\n") + + client = InstratFuelPrices(cache_dir=cache_dir) + with patch.object(client, "_download") as mock_download: + prices = client.get_fuel_prices( + datetime(2024, 1, 1), + datetime(2024, 1, 2), + hourly_index, + use_cache=True, + ) + + mock_download.assert_not_called() + assert prices["hard coal"].isna().sum() == 0 + assert prices["lignite"].isna().sum() == 0 + + +def test_aggregate_by_technology(mock_entsoe_data, hourly_index): + _, generation, capacity = mock_entsoe_data + aggregated = EntsoeInterface.aggregate_by_technology(capacity, generation) + + assert "solar" in aggregated + assert "wind_onshore" in aggregated + assert "gas" in aggregated + assert "nuclear" in aggregated + assert "hydro_storage" in aggregated + assert "other" in aggregated + assert aggregated["gas"]["capacity_mw"] == 2_000.0 + assert len(aggregated["solar"]["generation_mw"]) == len(hourly_index) + + +def test_aggregate_uses_generation_peak_when_capacity_missing(hourly_index): + generation = pd.DataFrame({"Other": 100.0}, index=hourly_index) + capacity = pd.Series({"Other": 0.0}) + aggregated = EntsoeInterface.aggregate_by_technology(capacity, generation) + assert aggregated["other"]["capacity_mw"] == 100.0 + + +def test_variable_unit_uses_installed_capacity_and_availability(hourly_index): + world = MagicMock() + gen_series = pd.Series(5_000.0, index=hourly_index) + mapping = PSR_TO_ASSUME["Solar"] + + _add_variable_unit( + world, + "DE", + "solar", + mapping, + total_capacity=80_000.0, + gen_series=gen_series, + index=hourly_index, + location=(51.16, 10.45), + bidding_strategies={"solar": {"EOM": "powerplant_energy_naive"}}, + ) + + unit_params = world.add_unit.call_args[0][3] + forecaster = world.add_unit.call_args[0][4] + assert unit_params["max_power"] == 80_000.0 + assert forecaster.availability.iloc[0] == pytest.approx(5_000.0 / 80_000.0) + + +def test_resolve_co2_prices_uses_api_or_fallback(hourly_index): + fallback = _resolve_co2_prices(hourly_index, {}) + assert (fallback == DEFAULT_CO2_PRICE_EUR_T).all() + + api_co2 = pd.Series(82.5, index=hourly_index, name="co2") + resolved = _resolve_co2_prices(hourly_index, {"co2": api_co2}) + assert resolved.iloc[0] == 82.5 + + +def test_blocked_units_use_installed_capacity_not_generation_peak(hourly_index): + world = MagicMock() + gen_series = pd.Series(3_000.0, index=hourly_index) + mapping = PSR_TO_ASSUME["Hydro Water Reservoir"] + + _add_blocked_units( + world, + "DE", + "hydro", + mapping, + total_capacity=10_000.0, + gen_series=gen_series, + index=hourly_index, + location=(51.16, 10.45), + bidding_strategies={"hydro": {"EOM": "powerplant_energy_naive"}}, + block_sizes_mw={"hydro": 300.0}, + fuel_price_ranges={"hydro": (0.4, 0.1)}, + api_fuel_prices={}, + co2_prices=pd.Series(70.0, index=hourly_index, name="co2"), + ) + + max_powers = [call[0][3]["max_power"] for call in world.add_unit.call_args_list] + assert sum(max_powers) == pytest.approx(10_000.0) + assert max(max_powers) == 300.0 + assert all( + call[0][4].availability.iloc[0] == 1.0 for call in world.add_unit.call_args_list + ) + + +def test_thermal_units_bid_installed_capacity(hourly_index): + world = MagicMock() + gen_series = pd.Series(15_000.0, index=hourly_index) + mapping = PSR_TO_ASSUME["Fossil Gas"] + + co2_prices = pd.Series(75.0, index=hourly_index, name="co2") + + _add_blocked_units( + world, + "DE", + "gas", + mapping, + total_capacity=2_000.0, + gen_series=gen_series, + index=hourly_index, + location=(51.16, 10.45), + bidding_strategies={"gas": {"EOM": "powerplant_energy_naive"}}, + block_sizes_mw={"gas": 400.0}, + fuel_price_ranges={"gas": (32.0, 22.0)}, + api_fuel_prices={}, + co2_prices=co2_prices, + ) + + gas_units = [ + call + for call in world.add_unit.call_args_list + if call[0][0].startswith("generation_DE_gas_") + ] + assert len(gas_units) == 5 + for call in gas_units: + unit_params = call[0][3] + forecaster = call[0][4] + assert unit_params["max_power"] in {400.0, 200.0} + assert unit_params["emission_factor"] == 0.201 + assert unit_params["fuel_type"] == "gas" + assert forecaster.availability.iloc[0] == 1.0 + assert "co2" in forecaster.fuel_prices + assert forecaster.fuel_prices["co2"].iloc[0] == 75.0 + + +def test_oil_units_use_shared_co2_price(hourly_index): + world = MagicMock() + gen_series = pd.Series(500.0, index=hourly_index) + mapping = PSR_TO_ASSUME["Fossil Oil"] + co2_prices = pd.Series(80.0, index=hourly_index, name="co2") + + _add_blocked_units( + world, + "DE", + "oil", + mapping, + total_capacity=400.0, + gen_series=gen_series, + index=hourly_index, + location=(51.16, 10.45), + bidding_strategies={"oil": {"EOM": "powerplant_energy_naive"}}, + block_sizes_mw={"oil": 200.0}, + fuel_price_ranges={"oil": (25.0, 18.0)}, + api_fuel_prices={}, + co2_prices=co2_prices, + ) + + forecaster = world.add_unit.call_args[0][4] + assert forecaster.fuel_prices["co2"].iloc[0] == 80.0 + + +def test_storage_units_use_installed_capacity(hourly_index): + world = MagicMock() + mapping = PSR_TO_ASSUME["Hydro Pumped Storage"] + + _add_storage_units( + world, + "DE", + "hydro_storage", + mapping, + total_capacity=1_000.0, + index=hourly_index, + location=(51.16, 10.45), + bidding_strategies={"storage": {"EOM": "storage_energy_heuristic_flexable"}}, + block_sizes_mw={"hydro_storage": 250.0}, + ) + + storage_units = [ + call + for call in world.add_unit.call_args_list + if call[0][0].startswith("storage_DE_hydro_storage_") + ] + assert len(storage_units) == 4 + unit_params = storage_units[0][0][3] + forecaster = storage_units[0][0][4] + assert unit_params["max_power_discharge"] == 250.0 + assert unit_params["max_power_charge"] == -250.0 + assert unit_params["capacity"] == 250.0 * 8.0 + assert unit_params["initial_soc"] == 0.5 + assert unit_params["additional_cost_charge"] == 0.28 + assert isinstance(forecaster, UnitForecaster) + + +def test_load_entsoe_builds_world(mock_entsoe_data, hourly_index): + demand, generation, capacity = mock_entsoe_data + start = datetime(2024, 1, 1) + end = datetime(2024, 1, 2, 23, 0) + + mock_interface = MagicMock() + mock_interface.get_country_demand.return_value = demand + mock_interface.get_country_generation.return_value = generation + mock_interface.get_installed_capacity.return_value = capacity + mock_interface.aggregate_by_technology.return_value = ( + EntsoeInterface.aggregate_by_technology(capacity, generation) + ) + + world = MagicMock() + marketdesign = [] + + bidding_strategies = { + "demand": {"EOM": "demand_energy_naive"}, + "solar": {"EOM": "powerplant_energy_naive"}, + "wind": {"EOM": "powerplant_energy_naive"}, + "gas": {"EOM": "powerplant_energy_naive"}, + "nuclear": {"EOM": "powerplant_energy_naive"}, + "biomass": {"EOM": "powerplant_energy_naive"}, + "storage": {"EOM": "storage_energy_heuristic_flexable"}, + } + + with ( + patch( + "assume.scenario.loader_entsoe.EntsoeInterface", + return_value=mock_interface, + ), + patch( + "assume.scenario.loader_entsoe.InstratFuelPrices.get_fuel_prices", + return_value={}, + ), + ): + load_entsoe( + world, + "entsoe_test", + "DE_2024", + start, + end, + ["DE"], + marketdesign, + bidding_strategies, + api_key="test-key", + ) + + world.setup.assert_called_once() + world.add_market_operator.assert_called_once() + world.add_unit_operator.assert_any_call("demand_DE") + world.add_unit_operator.assert_any_call("generation_DE") + assert world.add_unit.call_count > 5 + world.init_forecasts.assert_called_once()