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()