Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion examples/market_aggregates/market_aggregates_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
25 changes: 21 additions & 4 deletions examples/market_data/market_data_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
106 changes: 106 additions & 0 deletions examples/market_data/plot_market_data.py
Original file line number Diff line number Diff line change
@@ -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()
31 changes: 26 additions & 5 deletions src/jua/market_aggregates/energy_market.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions src/jua/market_aggregates/market_aggregates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -87,13 +91,20 @@ 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(
"forecast/market-aggregate/mw-zones",
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
5 changes: 4 additions & 1 deletion src/jua/market_aggregates/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
40 changes: 32 additions & 8 deletions src/jua/market_data/_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -127,28 +135,44 @@ 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"
),
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
Expand Down
Loading
Loading