diff --git a/examples/market_aggregates/market_aggregates_example.py b/examples/market_aggregates/market_aggregates_example.py index c0bd1fe..5170af6 100644 --- a/examples/market_aggregates/market_aggregates_example.py +++ b/examples/market_aggregates/market_aggregates_example.py @@ -140,11 +140,25 @@ def main(): ) print(f"Solar MW dataset:\n{solar_mw}") - # Example: Discover MW-capable zones + # Example: predicted electricity demand (population weighting -> load_mw). + # Demand is available well beyond Germany, so query a few zones at once. + print("\nExample: predicted demand (load) for DE, FR, GB") + eu = client.market_aggregates.get_market( + market_zone=[MarketZones.DE, MarketZones.FR, MarketZones.GB] + ) + load_mw = eu.compare_runs_mw( + weighting="population", + model_runs=[ModelRuns(Models.EPT2, 0)], + max_lead_time=48, + ) + print(f"Load (demand) MW dataset:\n{load_mw}") + + # Example: Discover MW-capable zones (generation and demand) print("\nExample: MW-capable market zones") mw_zones = client.market_aggregates.get_mw_zones() print(f"Wind MW zones: {mw_zones['wind']}") print(f"Solar MW zones: {mw_zones['solar']}") + print(f"Load (demand) MW zones: {mw_zones['load']}") if __name__ == "__main__": diff --git a/examples/market_data/market_data_example.py b/examples/market_data/market_data_example.py index bc7f7f5..abb8b88 100644 --- a/examples/market_data/market_data_example.py +++ b/examples/market_data/market_data_example.py @@ -42,16 +42,33 @@ def main(): print(de.head()) print() - # --- Great Britain: renewables (served from the UK power feed) --- - print("GB solar/wind:") + # --- Germany: actual demand vs day-ahead load forecast --- + # load_forecast is served for ENTSO-E zones (DE/FR/NL/BE) but not GB. + print("DE load vs day-ahead load forecast:") + de_load = md.get_data( + market_zone="DE", + variables=["load", "load_forecast"], + start_time=start, + end_time=end, + time_zone="Europe/Berlin", + ) + print(de_load.groupby("variable")["value"].mean().round(1)) + print() + + # --- Great Britain: renewables + the GB-only wind split --- + # GB additionally exposes wind broken into transmission-connected and + # distribution-embedded generation (actuals and day-ahead forecasts); the + # SDK fetches the total and its components in one call even though the + # backend cannot return them together. + print("GB solar/wind + transmission/embedded split:") gb = md.get_data( market_zone="GB", - variables=["solar", "wind"], + variables=["solar", "wind", "wind_transmission", "wind_embedded"], start_time=start, end_time=end, time_zone="Europe/London", ) - print(gb.head()) + print(gb.groupby("variable")["value"].mean().round(1)) print() # --- Combined request across zones in one call --- diff --git a/examples/market_data/plot_market_data.py b/examples/market_data/plot_market_data.py new file mode 100644 index 0000000..51db0ea --- /dev/null +++ b/examples/market_data/plot_market_data.py @@ -0,0 +1,106 @@ +"""Plot zone-addressed market data: GB wind split and DE load vs forecast. + +Produces a single figure with two panels from the unified ``market_data`` API: + +1. GB wind generation as total, transmission-connected, and distribution- + embedded (the components sum to the total). +2. Germany actual demand against the day-ahead load forecast. + +Saves the figure to ``market_data_overview.png``. +""" + +from datetime import datetime, timedelta, timezone + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt + +from jua import JuaClient + +COLORS = { + "wind": "#10B981", + "wind_transmission": "#6366F1", + "wind_embedded": "#EC4899", + "load": "#1F2937", + "load_forecast": "#EF4444", +} + + +def _series(df, variable): + """Return (times, values) for one variable, sorted by time.""" + sub = df[df["variable"] == variable].sort_values("time") + return sub["time"], sub["value"] + + +def main(): + md = JuaClient().market_data + + end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) + start = end - timedelta(days=7) + + gb = md.get_data( + market_zone="GB", + variables=["wind", "wind_transmission", "wind_embedded"], + start_time=start, + end_time=end, + time_zone="Europe/London", + ) + de = md.get_data( + market_zone="DE", + variables=["load", "load_forecast"], + start_time=start, + end_time=end, + time_zone="Europe/Berlin", + ) + if gb.empty and de.empty: + print("No data returned.") + return + + fig, (ax_wind, ax_load) = plt.subplots(2, 1, figsize=(15, 9)) + + # Panel 1: GB wind total + components. + for variable in ["wind", "wind_transmission", "wind_embedded"]: + times, values = _series(gb, variable) + if times.empty: + continue + ax_wind.plot( + times, + values, + label=variable, + color=COLORS[variable], + linewidth=1.4 if variable == "wind" else 1.0, + alpha=0.9, + ) + ax_wind.set_title("GB wind — total vs transmission + embedded", fontweight="bold") + ax_wind.set_ylabel("Power [MW]") + + # Panel 2: DE load vs day-ahead forecast. + for variable in ["load", "load_forecast"]: + times, values = _series(de, variable) + if times.empty: + continue + ax_load.plot( + times, + values, + label=variable, + color=COLORS[variable], + linewidth=1.2, + alpha=0.9, + linestyle="--" if variable == "load_forecast" else "-", + ) + ax_load.set_title("DE load — actual vs day-ahead forecast", fontweight="bold") + ax_load.set_ylabel("Demand [MW]") + + for ax in (ax_wind, ax_load): + ax.legend(loc="upper right", framealpha=0.9) + ax.grid(True, alpha=0.3, linestyle="--") + ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d")) + + fig.autofmt_xdate() + fig.tight_layout() + out = "market_data_overview.png" + fig.savefig(out, dpi=150, bbox_inches="tight", facecolor="white") + print(f"Saved {out}") + + +if __name__ == "__main__": + main() diff --git a/src/jua/market_aggregates/energy_market.py b/src/jua/market_aggregates/energy_market.py index 2e60c8c..ad200a1 100644 --- a/src/jua/market_aggregates/energy_market.py +++ b/src/jua/market_aggregates/energy_market.py @@ -221,17 +221,22 @@ def compare_runs_mw( ) -> xr.Dataset: """Compare multiple model runs with output in MW. - Like :meth:`compare_runs`, but applies power curves and returns - predicted megawatt (MW) values instead of raw weather variables. + Like :meth:`compare_runs`, but applies power curves (generation) or a + demand model (load) and returns predicted megawatt (MW) values instead + of raw weather variables. The response columns depend on the weighting: - ``"wind_capacity"`` -> ``wind_onshore_mw``, ``wind_offshore_mw`` - ``"solar_capacity"`` -> ``solar_mw`` + - ``"population"`` -> ``load_mw`` (predicted electricity demand) Args: - weighting: Capacity weighting scheme. Must be - ``"wind_capacity"`` or ``"solar_capacity"``. + weighting: MW output scheme. ``"wind_capacity"`` / + ``"solar_capacity"`` apply generation power curves; + ``"population"`` applies a demand model and returns predicted + load. Use :meth:`MarketAggregates.get_mw_zones` to see which + zones support each output. model_runs: List of ModelRuns instances specifying which model forecasts to query. @@ -274,6 +279,13 @@ def compare_runs_mw( ... max_lead_time=48, ... ) >>> + >>> # Predicted electricity demand (load_mw) + >>> ds_load = germany.compare_runs_mw( + ... weighting="population", + ... model_runs=[ModelRuns(Models.EPT2, 0)], + ... max_lead_time=48, + ... ) + >>> >>> # Daily mean MW data >>> ds_daily = germany.compare_runs_mw( ... weighting="wind_capacity", @@ -427,7 +439,16 @@ def _build_dataset( init_time_per_run = df.groupby("model_run")["init_time"].first() df_for_ds = df.drop(columns=["model", "init_time"]) - ds = xr.Dataset.from_dataframe(df_for_ds.set_index(["model_run", "time"])) + # MW responses are per-zone (a ``market_zone`` column with one row per + # zone and timestamp), so it must be part of the index to stay unique + # when several zones are requested. Weather responses are a single + # combined series with no ``market_zone`` column, so the index falls + # back to (model_run, time). + index_cols = ["model_run", "time"] + if "market_zone" in df_for_ds.columns: + index_cols = ["model_run", "market_zone", "time"] + + ds = xr.Dataset.from_dataframe(df_for_ds.set_index(index_cols)) ds = ds.assign_attrs(**attrs) ds.coords["model"] = ("model_run", model_per_run.values) ds.coords["init_time"] = ("model_run", init_time_per_run.values) diff --git a/src/jua/market_aggregates/market_aggregates.py b/src/jua/market_aggregates/market_aggregates.py index 887b90c..3d3cd30 100644 --- a/src/jua/market_aggregates/market_aggregates.py +++ b/src/jua/market_aggregates/market_aggregates.py @@ -71,14 +71,18 @@ def get_market( return EnergyMarket(client=self._client, market_zone=market_zone) def get_mw_zones(self) -> dict[str, list[str]]: - """Get market zones capable of MW output. + """Get market zones capable of MW output, by output type. - Returns zones that have both installed capacity data and fitted - power curves, broken down by energy type (wind and solar). + Returns the zones that can produce predicted MW for each output, i.e. + the zones that have the data and fitted models required. Generation + outputs (``"wind"`` / ``"solar"``) need installed capacity and power + curves; demand (``"load"``) needs a fitted demand model. Returns: - Dictionary with ``"wind"`` and ``"solar"`` keys, each mapping - to a list of zone codes. + Dictionary mapping each output type to a list of zone codes. Always + includes ``"wind"``, ``"solar"`` and ``"load"``; the API may also + return finer-grained wind keys (e.g. ``"wind_combined"``, + ``"wind_onshore_only"``), which are passed through when present. Raises: RuntimeError: If the API request fails. @@ -87,6 +91,7 @@ def get_mw_zones(self) -> dict[str, list[str]]: >>> mw_zones = client.market_aggregates.get_mw_zones() >>> print(mw_zones["wind"]) # ["AT", "BE", "DE", "FR", ...] >>> print(mw_zones["solar"]) # ["AT", "BE", "DE", "FR", ...] + >>> print(mw_zones["load"]) # ["AL", "AT", "BE", "DE", "FR", ...] """ try: response = self._query_engine_api.get( @@ -94,6 +99,12 @@ def get_mw_zones(self) -> dict[str, list[str]]: requires_auth=False, ) data = response.json() - return {"wind": data["wind"], "solar": data["solar"]} except Exception as e: raise RuntimeError(f"Failed to fetch MW-capable market zones: {e}") from e + + # Pass through every output type the API reports, guaranteeing the + # documented keys exist even if a future response omits one. + result = {key: list(values) for key, values in data.items()} + for key in ("wind", "solar", "load"): + result.setdefault(key, []) + return result diff --git a/src/jua/market_aggregates/variables.py b/src/jua/market_aggregates/variables.py index 3634c29..e0243b7 100644 --- a/src/jua/market_aggregates/variables.py +++ b/src/jua/market_aggregates/variables.py @@ -4,7 +4,10 @@ from jua.weather.variables import Variables -MWWeighting = Literal["wind_capacity", "solar_capacity"] +# MW-output weighting schemes. "population" applies a demand model and returns +# predicted load (``load_mw``); the capacity schemes apply generation power +# curves (wind -> wind_onshore_mw/wind_offshore_mw, solar -> solar_mw). +MWWeighting = Literal["wind_capacity", "solar_capacity", "population"] class Weighting(StrEnum): diff --git a/src/jua/market_data/_mapping.py b/src/jua/market_data/_mapping.py index ca738b0..2f6b1af 100644 --- a/src/jua/market_data/_mapping.py +++ b/src/jua/market_data/_mapping.py @@ -37,6 +37,14 @@ class MarketVariable(StrEnum): SOLAR_FORECAST = "solar_forecast" WIND_FORECAST = "wind_forecast" LOAD_FORECAST = "load_forecast" + # GB-only wind sub-types. The GB grid distinguishes transmission-connected + # wind (metered by Elexon) from distribution-embedded wind (estimated by + # NESO); ``wind`` is their total. ENTSO-E zones split wind by onshore / + # offshore instead, so these resolve only for GB. + WIND_EMBEDDED = "wind_embedded" + WIND_TRANSMISSION = "wind_transmission" + WIND_EMBEDDED_FORECAST = "wind_embedded_forecast" + WIND_TRANSMISSION_FORECAST = "wind_transmission_forecast" DAY_AHEAD_PRICES = "day_ahead_prices" # ENTSO-E publishes imbalance prices per direction ("Long" = surplus, # "Short" = shortfall). They are equal in single-price markets (e.g. DE, BE) @@ -127,21 +135,31 @@ def _entsoe_capability(variable: MarketVariable) -> Capability: return Capability(backend=MarketBackend.ENTSOE, entsoe=_ENTSOE_BINDINGS[variable]) -# Every unified variable is available for EU zones via ENTSOE. +# Every ENTSOE-backed variable is available for EU zones. Variables without an +# ENTSOE binding (e.g. the GB-only wind sub-types) are intentionally absent and +# resolve only where their backend serves them. _EU_CAPABILITIES: dict[MarketVariable, Capability] = { - variable: _entsoe_capability(variable) for variable in MarketVariable + variable: _entsoe_capability(variable) for variable in _ENTSOE_BINDINGS } # GB serves renewables + load actual + renewable day-ahead forecasts from the -# richer UK-power feed (Elexon / PV_Live / NESO). GB prices and load forecast -# are intentionally not advertised: the /v1/uk-power endpoint exposes neither, -# and ENTSOE's GB zone has no usable price/load-forecast feed, so requesting -# them raises a clear "not supported" error instead of returning empty data. -# (GB day-ahead/imbalance prices will be re-added once exposed by the Query -# Engine.) +# richer UK-power feed (Elexon / PV_Live / NESO), including the GB-specific +# split of wind into transmission-connected (Elexon FUELHH) and +# distribution-embedded (NESO Gen Mix) generation, for both actuals and +# day-ahead forecasts. GB prices and load forecast are intentionally not +# advertised: the /v1/uk-power endpoint exposes neither, and ENTSOE's GB zone +# has no usable price/load-forecast feed, so requesting them raises a clear +# "not supported" error instead of returning empty data. (GB day-ahead/imbalance +# prices will be re-added once exposed by the Query Engine.) _GB_CAPABILITIES: dict[MarketVariable, Capability] = { MarketVariable.SOLAR: Capability(MarketBackend.UK_POWER, uk_power_variable="solar"), MarketVariable.WIND: Capability(MarketBackend.UK_POWER, uk_power_variable="wind"), + MarketVariable.WIND_EMBEDDED: Capability( + MarketBackend.UK_POWER, uk_power_variable="wind_embedded" + ), + MarketVariable.WIND_TRANSMISSION: Capability( + MarketBackend.UK_POWER, uk_power_variable="wind_transmission" + ), MarketVariable.LOAD: Capability(MarketBackend.UK_POWER, uk_power_variable="load"), MarketVariable.SOLAR_FORECAST: Capability( MarketBackend.UK_POWER, uk_power_variable="solar_forecast" @@ -149,6 +167,12 @@ def _entsoe_capability(variable: MarketVariable) -> Capability: MarketVariable.WIND_FORECAST: Capability( MarketBackend.UK_POWER, uk_power_variable="wind_forecast" ), + MarketVariable.WIND_EMBEDDED_FORECAST: Capability( + MarketBackend.UK_POWER, uk_power_variable="wind_embedded_forecast" + ), + MarketVariable.WIND_TRANSMISSION_FORECAST: Capability( + MarketBackend.UK_POWER, uk_power_variable="wind_transmission_forecast" + ), } # DE mirrors the EU defaults except for imbalance prices, which ENTSO-E diff --git a/src/jua/market_data/_uk_power.py b/src/jua/market_data/_uk_power.py index a5fe260..b504c20 100644 --- a/src/jua/market_data/_uk_power.py +++ b/src/jua/market_data/_uk_power.py @@ -23,6 +23,21 @@ #: UK power is GB only. _MARKET_ZONE = "GB" +#: Composite uk-power variables that cannot share a request with their own +#: components. Each "total" (e.g. ``wind`` = transmission + embedded) is drawn +#: from the same underlying columns as its parts, and the Query Engine returns a +#: 500 when a total and any of its components are requested together. The two +#: groups are independent: a total only collides with its *own* components +#: (e.g. ``wind`` + ``wind_embedded`` fails, but ``wind`` + ``wind_forecast`` or +#: ``wind_forecast`` + ``wind_embedded`` are fine). We split such requests so +#: callers can ask for a total and its parts in a single SDK call. +_WIND_TOTAL_COMPONENTS: dict[str, frozenset[str]] = { + "wind": frozenset({"wind_embedded", "wind_transmission"}), + "wind_forecast": frozenset( + {"wind_embedded_forecast", "wind_transmission_forecast"} + ), +} + class _UkPowerBackend: """Fetches and normalizes UK power timeseries (GB only).""" @@ -53,17 +68,13 @@ def fetch( assert native is not None # routed here, so always set native_by_unified[variable] = native - body = remove_none_from_dict( - { - "variables": sorted(set(native_by_unified.values())), - "start_time": start_time.isoformat(), - "end_time": end_time.isoformat() if end_time else None, - "temporal_resolution_minutes": temporal_resolution_minutes, - "time_zone": time_zone, - } + raw = self._fetch_native( + sorted(set(native_by_unified.values())), + start_time=start_time, + end_time=end_time, + time_zone=time_zone, + temporal_resolution_minutes=temporal_resolution_minutes, ) - response = self._api.post("uk-power/data", data=body, requires_auth=True) - raw = parse_time(decode_columnar(response.json()), time_zone) if raw.empty: return pd.DataFrame(columns=UNIFIED_COLUMNS) @@ -77,6 +88,73 @@ def fetch( return pd.DataFrame(columns=UNIFIED_COLUMNS) return pd.concat(frames, ignore_index=True) + def _fetch_native( + self, + natives: list[str], + *, + start_time: datetime, + end_time: datetime | None, + time_zone: str | None, + temporal_resolution_minutes: int | None, + ) -> pd.DataFrame: + """Request native uk-power variables, splitting incompatible groups. + + Composite totals cannot be queried alongside their own components (see + :data:`_WIND_TOTAL_COMPONENTS`), so each conflicting total is sent in its + own request and the columnar responses are concatenated. Everything else + is fetched together in a single request. + """ + frames: list[pd.DataFrame] = [] + for batch in self._split_into_compatible_batches(natives): + body = remove_none_from_dict( + { + "variables": batch, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat() if end_time else None, + "temporal_resolution_minutes": temporal_resolution_minutes, + "time_zone": time_zone, + } + ) + response = self._api.post("uk-power/data", data=body, requires_auth=True) + frame = parse_time(decode_columnar(response.json()), time_zone) + if not frame.empty: + frames.append(frame) + + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) + + @staticmethod + def _split_into_compatible_batches(natives: list[str]) -> list[list[str]]: + """Split native variables so no total shares a request with its own + components. + + Each total that is requested together with at least one of its + components is isolated into a singleton batch; everything else (other + components, unrelated variables, and totals whose components were not + requested) is grouped into one final batch. A total left in the grouped + batch is safe because its components are absent, and components never + collide with a *different* total. Order within each batch is sorted for + deterministic requests. + """ + unique = sorted(set(natives)) + present = set(unique) + + isolated = [ + total + for total, components in _WIND_TOTAL_COMPONENTS.items() + if total in present and components & present + ] + if not isolated: + return [unique] + + isolated_set = set(isolated) + rest = [n for n in unique if n not in isolated_set] + batches = [[total] for total in sorted(isolated)] + if rest: + batches.append(rest) + return batches + @staticmethod def _normalize_variable( raw: pd.DataFrame, diff --git a/src/jua/market_data/market_data.py b/src/jua/market_data/market_data.py index 70796cc..5b8034c 100644 --- a/src/jua/market_data/market_data.py +++ b/src/jua/market_data/market_data.py @@ -38,14 +38,21 @@ class MarketData: - ``imbalance_price_long`` / ``imbalance_price_short`` - imbalance settlement price per direction (equal in single-price markets like DE/BE; different in dual-pricing markets like FR/NL) - - ``wind`` is the total of all wind sub-types (onshore + offshore). The - underlying data sources differ by zone and variable, but that is an - implementation detail - the same call works everywhere. Not every variable - is served in every zone; use :meth:`get_variables` to see what a zone - supports. Requesting an unsupported ``(zone, variable)`` raises a clear - error (e.g. GB currently serves renewables and load only, not prices or - load forecast). + - ``wind_embedded`` / ``wind_transmission`` - GB-only split of wind into + distribution-embedded and transmission-connected generation (MW) + - ``wind_embedded_forecast`` / ``wind_transmission_forecast`` - GB-only + day-ahead forecast of the embedded / transmission wind split (MW) + + ``wind`` is the total of all wind sub-types. For ENTSO-E zones that means + onshore + offshore; for GB it means transmission + embedded, and GB + additionally exposes those two components (and their day-ahead forecasts) + as ``wind_transmission`` / ``wind_embedded``. The underlying data sources + differ by zone and variable, but that is an implementation detail - the + same call works everywhere. Not every variable is served in every zone; + use :meth:`get_variables` to see what a zone supports. Requesting an + unsupported ``(zone, variable)`` raises a clear error (e.g. GB serves + renewables, the wind split, and load, but not prices or load forecast; + the wind split is GB-only and is not available for ENTSO-E zones). Examples: >>> from datetime import datetime, timezone @@ -57,7 +64,9 @@ class MarketData: >>> md.get_zones() ['BE', 'DE', 'FR', 'GB', 'NL'] >>> md.get_variables(market_zone="GB") - ['load', 'solar', 'solar_forecast', 'wind', 'wind_forecast'] + ['load', 'solar', 'solar_forecast', 'wind', 'wind_embedded', + 'wind_embedded_forecast', 'wind_forecast', 'wind_transmission', + 'wind_transmission_forecast'] >>> >>> df = md.get_data( ... market_zone="DE", diff --git a/src/jua/power_forecast/power_forecast.py b/src/jua/power_forecast/power_forecast.py index 0026258..aadf038 100644 --- a/src/jua/power_forecast/power_forecast.py +++ b/src/jua/power_forecast/power_forecast.py @@ -73,6 +73,10 @@ class PowerForecast: def __init__(self, client: JuaClient) -> None: self._client = client self._api = QueryEngineAPI(jua_client=self._client) + # Per-zone PSR-type cache, used to validate requests and produce + # actionable errors without repeating the (cheap, unauthenticated) + # psr-types lookup on every call. + self._psr_types_cache: dict[str, list[str]] = {} def get_zones(self) -> list[str]: """Get the list of available power forecast zones. @@ -126,6 +130,48 @@ def get_psr_types(self, zone_key: str | None = None) -> list[str]: except Exception as e: raise RuntimeError(f"Failed to fetch power forecast PSR types: {e}") from e + def _available_psr_types(self, zone_key: str) -> list[str]: + """Return (and cache) the PSR types served for a zone.""" + if zone_key not in self._psr_types_cache: + self._psr_types_cache[zone_key] = self.get_psr_types(zone_key=zone_key) + return self._psr_types_cache[zone_key] + + def _validate_psr_types(self, zone_keys: list[str], psr_types: list[str]) -> None: + """Validate requested PSR types per zone, with actionable hints. + + ``power_forecast`` serves Jua-model generation by PSR type (and load + for the zones that have a fitted demand model). When a requested type + isn't served for a zone, raise a clear error that lists what *is* + available and, for demand (``"Load"``), points to the complementary + Jua product: ``market_aggregates`` exposes predicted demand as + ``load_mw`` (population weighting) for many more zones. + + Raises: + ValueError: If any requested PSR type is not available for a zone. + """ + for zone in zone_keys: + try: + available = self._available_psr_types(zone) + except RuntimeError: + # Don't block on a metadata lookup failure; let the data call + # surface the underlying error instead. + continue + missing = [p for p in psr_types if p not in available] + if not missing: + continue + message = ( + f"PSR type(s) {missing} not available from power_forecast for " + f"zone '{zone}'. Available: {available}." + ) + if "Load" in missing: + message += ( + " For predicted demand, market_aggregates exposes load_mw " + "via the population weighting: " + f"client.market_aggregates.get_market('{zone}')" + ".compare_runs_mw(weighting='population')." + ) + raise ValueError(message) + def get_init_times( self, zone_key: str | list[str] | None = None, @@ -302,6 +348,9 @@ def get_data( "or time range mode (start_time/end_time)." ) + if psr_types is not None and zone_keys: + self._validate_psr_types(zone_keys, psr_types) + resolved_init_time = init_time if init_time is not None: resolved_init_time = self._resolve_init_time( diff --git a/tests/functional/test_market_aggregates.py b/tests/functional/test_market_aggregates.py index 9400ca6..17e867c 100644 --- a/tests/functional/test_market_aggregates.py +++ b/tests/functional/test_market_aggregates.py @@ -417,6 +417,39 @@ def test_compare_runs_mw_solar(client: JuaClient): pytest.fail(f"Failed to retrieve solar MW data: {e}") +def test_compare_runs_mw_load(client: JuaClient): + """Test predicted electricity demand (population weighting -> load_mw). + + Demand is available for many zones beyond Germany, so this also checks a + couple of other zones return load. + + Args: + client: JuaClient instance + """ + try: + for zone in (MarketZones.DE, MarketZones.FR, MarketZones.GB): + market = client.market_aggregates.get_market(market_zone=zone) + model_runs = [ModelRuns(Models.EPT2, 0)] + + ds = market.compare_runs_mw( + weighting="population", + model_runs=model_runs, + max_lead_time=48, + ) + + assert ds is not None, f"No data returned for load MW ({zone.zone_name})" + assert "model_run" in ds.dims, "Missing model_run dimension" + assert "time" in ds.dims, "Missing time dimension" + assert "load_mw" in ds.data_vars, "Missing load_mw variable" + assert ds.attrs.get("unit") == "MW" + assert ds.attrs.get("weighting") == "population" + + print(f"✓ Load MW ({zone.zone_name}): retrieved predicted demand") + + except Exception as e: + pytest.fail(f"Failed to retrieve load MW data: {e}") + + def test_get_mw_zones(client: JuaClient): """Test discovering which market zones support MW output. @@ -428,6 +461,11 @@ def test_get_mw_zones(client: JuaClient): assert isinstance(zones, dict), "Expected dict from get_mw_zones()" assert len(zones) > 0, "No MW zones returned" + # Generation and demand outputs are all reported. + for key in ("wind", "solar", "load"): + assert key in zones, f"Missing '{key}' in mw-zones" + # Demand is broadly available, including the core EU zones + GB. + assert {"DE", "FR", "NL", "BE", "GB"} <= set(zones["load"]) print("✓ MW zones: Successfully retrieved supported zones") print(f" Categories: {list(zones.keys())}") diff --git a/tests/functional/test_market_data.py b/tests/functional/test_market_data.py index 9050a53..0f5035e 100644 --- a/tests/functional/test_market_data.py +++ b/tests/functional/test_market_data.py @@ -36,6 +36,11 @@ def test_get_variables(self, md): def test_get_variables_for_zone(self, md): gb_vars = md.get_variables(market_zone="GB") assert "solar" in gb_vars + # GB exposes the transmission/embedded wind split (actual + forecast). + assert "wind_transmission" in gb_vars + assert "wind_embedded" in gb_vars + assert "wind_transmission_forecast" in gb_vars + assert "wind_embedded_forecast" in gb_vars # GB prices/load_forecast are not served and must not be advertised. assert "day_ahead_prices" not in gb_vars assert "load_forecast" not in gb_vars @@ -82,6 +87,51 @@ def test_gb_renewables_via_uk_power(self, md): assert not df.empty assert set(df["market_zone"]) == {"GB"} + def test_gb_wind_split_totals_match_components(self, md): + """The wind total and its components are served in one SDK call (the + backend can't return them together, so the SDK splits the request) and + the components must sum to the total at every timestamp.""" + start, end = self._window() + df = md.get_data( + market_zone="GB", + variables=["wind", "wind_transmission", "wind_embedded"], + start_time=start, + end_time=end, + ) + assert not df.empty + assert set(df["variable"]) == {"wind", "wind_transmission", "wind_embedded"} + + wide = df.pivot_table(index="time", columns="variable", values="value") + wide = wide.dropna(subset=["wind", "wind_transmission", "wind_embedded"]) + if wide.empty: + pytest.skip("No overlapping wind data for this window") + residual = ( + wide["wind"] - wide["wind_transmission"] - wide["wind_embedded"] + ).abs() + # Allow a small tolerance for rounding in the published feeds. + assert residual.max() <= 1.0 + + def test_gb_wind_forecast_split(self, md): + """The day-ahead wind total and its forecast components also come back + from a single SDK call despite the same backend batching limit.""" + start, end = self._window() + df = md.get_data( + market_zone="GB", + variables=[ + "wind_forecast", + "wind_transmission_forecast", + "wind_embedded_forecast", + ], + start_time=start, + end_time=end, + ) + assert not df.empty + assert set(df["variable"]) == { + "wind_forecast", + "wind_transmission_forecast", + "wind_embedded_forecast", + } + def test_gb_prices_not_supported(self, md): """GB prices/load forecast are not served: raise a clear error.""" start, end = self._window() diff --git a/tests/functional/test_power_forecast.py b/tests/functional/test_power_forecast.py index ecdfb45..147a520 100644 --- a/tests/functional/test_power_forecast.py +++ b/tests/functional/test_power_forecast.py @@ -213,3 +213,31 @@ def test_get_data_mutually_exclusive_modes(self, pf): max_prediction_timedelta=120, start_time=datetime.now(timezone.utc), ) + + def test_get_data_unavailable_load_hints_market_aggregates(self, pf): + """Requesting Load for a zone without a demand model raises a clear + error that points to market_aggregates' population-weighted load_mw. + + Only DE exposes a Load PSR type today; other zones (e.g. FR) should + get an actionable hint instead of a cryptic init-time error. + """ + if "Load" in pf.get_psr_types(zone_key="FR"): + pytest.skip("FR unexpectedly exposes a Load PSR type") + + with pytest.raises(ValueError, match="market_aggregates"): + pf.get_data( + zone_keys=["FR"], + psr_types=["Load"], + init_time="latest", + max_prediction_timedelta=120, + ) + + def test_get_data_unavailable_psr_type_lists_available(self, pf): + """An unavailable PSR type error lists what the zone does serve.""" + with pytest.raises(ValueError, match="not available from power_forecast"): + pf.get_data( + zone_keys=["FR"], + psr_types=["Not A Real Type"], + init_time="latest", + max_prediction_timedelta=120, + ) diff --git a/tests/market_data/test_mapping.py b/tests/market_data/test_mapping.py index d266bb3..aad0f14 100644 --- a/tests/market_data/test_mapping.py +++ b/tests/market_data/test_mapping.py @@ -18,14 +18,19 @@ def test_supported_variables_full_vocabulary(): def test_supported_variables_for_zone(): gb_vars = _mapping.supported_variables(market_zone="GB") - # GB exposes renewables + load actual + renewable day-ahead forecasts. + # GB exposes renewables + load actual + renewable day-ahead forecasts, plus + # the GB-only wind transmission/embedded split (actuals and forecasts). # Prices and load_forecast are intentionally not advertised (not served). assert set(gb_vars) == { "solar", "wind", + "wind_embedded", + "wind_transmission", "load", "solar_forecast", "wind_forecast", + "wind_embedded_forecast", + "wind_transmission_forecast", } @@ -59,10 +64,36 @@ def test_eu_wind_sums_onshore_and_offshore(): def test_gb_renewables_route_to_uk_power(): - for variable in ["solar", "wind", "load", "solar_forecast", "wind_forecast"]: + for variable in [ + "solar", + "wind", + "wind_embedded", + "wind_transmission", + "load", + "solar_forecast", + "wind_forecast", + "wind_embedded_forecast", + "wind_transmission_forecast", + ]: cap = _mapping.resolve("GB", variable) assert cap.backend == MarketBackend.UK_POWER, variable assert cap.uk_power_variable is not None + # The native name matches the unified name for GB wind sub-types. + assert cap.uk_power_variable == variable + + +def test_wind_split_is_gb_only(): + # The transmission/embedded split is a GB grid concept; ENTSO-E zones split + # wind by onshore/offshore instead, so these must not resolve for DE/FR/etc. + for variable in [ + "wind_embedded", + "wind_transmission", + "wind_embedded_forecast", + "wind_transmission_forecast", + ]: + for zone in ["DE", "FR", "NL", "BE"]: + with pytest.raises(ValueError, match=f"not supported for zone '{zone}'"): + _mapping.resolve(zone, variable) def test_gb_prices_and_load_forecast_not_supported(): diff --git a/tests/market_data/test_market_data.py b/tests/market_data/test_market_data.py index 14387a4..82a80b7 100644 --- a/tests/market_data/test_market_data.py +++ b/tests/market_data/test_market_data.py @@ -90,6 +90,41 @@ def _uk_payload(): } +# Canned per-variable values for the uk-power feed, used to build payloads that +# reflect exactly which native variables a given request asked for. +_UK_VALUES = { + "solar": [5.0, 8.0], + "wind": [200.0, 210.0], + "wind_embedded": [40.0, 45.0], + "wind_transmission": [160.0, 165.0], + "load": [30000.0, 31000.0], + "wind_forecast": [205.0, 212.0], + "wind_embedded_forecast": [42.0, 46.0], + "wind_transmission_forecast": [158.0, 167.0], +} + + +def _uk_payload_for(body): + """Build a uk-power payload echoing only the variables in ``body``. + + This lets tests assert how the backend batches requests: each call's + response contains rows solely for that request's native variables. + """ + times = ["2025-12-01T00:00:00Z", "2025-12-01T01:00:00Z"] + rows_time, rows_var, rows_val = [], [], [] + for native in body["variables"]: + for i, t in enumerate(times): + rows_time.append(t) + rows_var.append(native) + rows_val.append(_UK_VALUES[native][i]) + return { + "time": rows_time, + "variable_name": rows_var, + "value": rows_val, + "unit": ["MW"] * len(rows_time), + } + + class _Recorder: """Captures posts and returns canned payloads keyed by native variable.""" @@ -187,6 +222,139 @@ def test_gb_renewables_route_to_uk_power(monkeypatch): assert set(df["market_zone"]) == {"GB"} +def test_gb_wind_split_actuals_and_forecasts(monkeypatch): + md = JuaClient().market_data + uk_rec = _patch_uk(monkeypatch, md, _uk_payload_for) + + df = md.get_data( + market_zone="GB", + variables=[ + "wind_embedded", + "wind_transmission", + "wind_embedded_forecast", + "wind_transmission_forecast", + ], + start_time=_START, + end_time=_END, + ) + + # No wind total requested -> a single batched uk-power request. + assert len(uk_rec.calls) == 1 + assert set(df["variable"]) == { + "wind_embedded", + "wind_transmission", + "wind_embedded_forecast", + "wind_transmission_forecast", + } + embedded = df[df["variable"] == "wind_embedded"].sort_values("time") + assert embedded["value"].tolist() == [40.0, 45.0] + + +def test_gb_wind_total_split_from_components(monkeypatch): + md = JuaClient().market_data + uk_rec = _patch_uk(monkeypatch, md, _uk_payload_for) + + df = md.get_data( + market_zone="GB", + variables=["wind", "wind_embedded", "wind_transmission", "load"], + start_time=_START, + end_time=_END, + ) + + # The composite "wind" actual cannot share a request with its components + # (server 500s), so the backend issues two requests: wind alone, rest + # together. + assert len(uk_rec.calls) == 2 + batches = sorted(tuple(sorted(c["body"]["variables"])) for c in uk_rec.calls) + assert batches == [ + ("load", "wind_embedded", "wind_transmission"), + ("wind",), + ] + + # All four series still come back, correctly assembled across the two calls. + assert set(df["variable"]) == {"wind", "wind_embedded", "wind_transmission", "load"} + total = df[df["variable"] == "wind"].sort_values("time") + assert total["value"].tolist() == [200.0, 210.0] + + +def test_gb_wind_total_with_forecast_components_single_request(monkeypatch): + md = JuaClient().market_data + uk_rec = _patch_uk(monkeypatch, md, _uk_payload_for) + + # The actual wind total only collides with the *actual* components, not the + # forecast ones, so these can share one request. + md.get_data( + market_zone="GB", + variables=["wind", "wind_embedded_forecast", "wind_transmission_forecast"], + start_time=_START, + end_time=_END, + ) + assert len(uk_rec.calls) == 1 + + +def test_gb_wind_forecast_total_split_from_forecast_components(monkeypatch): + md = JuaClient().market_data + uk_rec = _patch_uk(monkeypatch, md, _uk_payload_for) + + # wind_forecast (total day-ahead) collides with its forecast components the + # same way the actual total does, so it is isolated into its own request. + md.get_data( + market_zone="GB", + variables=["wind_forecast", "wind_embedded_forecast"], + start_time=_START, + end_time=_END, + ) + assert len(uk_rec.calls) == 2 + batches = sorted(tuple(sorted(c["body"]["variables"])) for c in uk_rec.calls) + assert batches == [("wind_embedded_forecast",), ("wind_forecast",)] + + +def test_gb_both_totals_split_independently(monkeypatch): + md = JuaClient().market_data + uk_rec = _patch_uk(monkeypatch, md, _uk_payload_for) + + # Both totals requested with both component sets: each total is isolated and + # the remaining components/extras share one batch (3 requests total). + df = md.get_data( + market_zone="GB", + variables=[ + "wind", + "wind_forecast", + "wind_embedded", + "wind_transmission", + "wind_embedded_forecast", + "wind_transmission_forecast", + "load", + ], + start_time=_START, + end_time=_END, + ) + + assert len(uk_rec.calls) == 3 + batches = sorted(tuple(sorted(c["body"]["variables"])) for c in uk_rec.calls) + assert batches == [ + ( + "load", + "wind_embedded", + "wind_embedded_forecast", + "wind_transmission", + "wind_transmission_forecast", + ), + ("wind",), + ("wind_forecast",), + ] + # Every requested variable comes back, assembled across the three calls. + assert set(df["variable"]) == { + "wind", + "wind_forecast", + "wind_embedded", + "wind_transmission", + "wind_embedded_forecast", + "wind_transmission_forecast", + "load", + } + + def test_gb_prices_not_supported_raises(monkeypatch): md = JuaClient().market_data entsoe_rec = _patch_entsoe(monkeypatch, md, lambda body: _prices_payload("GB"))