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
154 changes: 154 additions & 0 deletions examples/power_forecast/plot_day_ahead_timeseries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Plot stitched day-ahead power forecast time series.

For each zone (DE and GB) two examples are produced:

1. A full year of day-ahead generation, stitched from the 09:00 UTC run each
day (``plot_yearly_day_ahead``).
2. The last month of day-ahead generation, comparing the 10:00, 12:00 and
18:00 UTC runs on a single panel (``plot_init_hour_comparison``).

Both rely on ``PowerForecast.get_day_ahead_timeseries`` with ``start_date`` /
``end_date``, which builds one init run per day over the range and stitches the
day-ahead window into a continuous series.
"""

from datetime import datetime, timedelta, timezone

import matplotlib.dates as mdates
import matplotlib.pyplot as plt

from jua import JuaClient

ZONES = ["DE", "GB"]
PSR_COLORS = {
"Solar": "#F59E0B",
"Wind": "#10B981",
"Wind Onshore": "#3B82F6",
"Wind Offshore": "#06B6D4",
"Wind Transmission": "#6366F1",
"Wind Embedded": "#EC4899",
}


def _to_frame(ds):
"""Flatten a day-ahead dataset to a tidy DataFrame."""
if "value" not in ds:
return None
df = ds.to_dataframe().reset_index()
return df.dropna(subset=["value"]).sort_values("time")


def plot_yearly_day_ahead(pf, zone: str) -> None:
"""Plot a year of day-ahead generation from the 09:00 UTC run."""
psr_types = pf.get_psr_types(zone_key=zone)
end = datetime.now(timezone.utc)
start = end - timedelta(days=365)

print(f"Fetching yearly day-ahead series for {zone} (09:00 UTC run)...")
ds = pf.get_day_ahead_timeseries(
zone_keys=[zone],
psr_types=psr_types,
init_hour=9,
time_zone="UTC",
start_date=start,
end_date=end,
)
df = _to_frame(ds)
if df is None or df.empty:
print(" No data returned.")
return

fig, ax = plt.subplots(figsize=(16, 6))
for psr in psr_types:
psr_df = df[df["psr_type"] == psr]
if psr_df.empty:
continue
ax.plot(
psr_df["time"],
psr_df["value"],
label=psr,
color=PSR_COLORS.get(psr),
linewidth=0.8,
alpha=0.85,
)

ax.set_ylabel("Power [MW]", fontsize=12)
ax.set_title(
f"{zone} — Yearly day-ahead generation (09:00 UTC run)",
fontsize=14,
fontweight="bold",
)
ax.legend(loc="upper right", framealpha=0.9)
ax.grid(True, alpha=0.3, linestyle="--")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.xaxis.set_major_locator(mdates.MonthLocator())
fig.autofmt_xdate()

out = f"day_ahead_{zone.lower()}_yearly_09utc.png"
plt.tight_layout()
fig.savefig(out, dpi=150, bbox_inches="tight", facecolor="white")
print(f" Saved {out}")


def plot_init_hour_comparison(pf, zone: str) -> None:
"""Compare the last month of Solar day-ahead across 10/12/18 UTC runs."""
init_hours = [10, 12, 18]
psr = "Solar"
end = datetime.now(timezone.utc)
start = end - timedelta(days=30)

fig, ax = plt.subplots(figsize=(16, 6))
cmap = plt.get_cmap("viridis")

for idx, init_hour in enumerate(init_hours):
print(f"Fetching last-month {zone} {psr} day-ahead ({init_hour:02d}:00 UTC)...")
ds = pf.get_day_ahead_timeseries(
zone_keys=[zone],
psr_types=[psr],
init_hour=init_hour,
time_zone="UTC",
start_date=start,
end_date=end,
)
df = _to_frame(ds)
if df is None or df.empty:
print(f" No data for {init_hour:02d}:00 UTC run.")
continue
ax.plot(
df["time"],
df["value"],
label=f"{init_hour:02d}:00 UTC run",
color=cmap(idx / max(len(init_hours) - 1, 1)),
linewidth=1.0,
alpha=0.85,
)

ax.set_ylabel("Power [MW]", fontsize=12)
ax.set_title(
f"{zone} — {psr} day-ahead, last month by init hour",
fontsize=14,
fontweight="bold",
)
ax.legend(loc="upper right", framealpha=0.9)
ax.grid(True, alpha=0.3, linestyle="--")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=3))
fig.autofmt_xdate()

out = f"day_ahead_{zone.lower()}_last_month_init_hours.png"
plt.tight_layout()
fig.savefig(out, dpi=150, bbox_inches="tight", facecolor="white")
print(f" Saved {out}")


def main():
client = JuaClient()
pf = client.power_forecast

for zone in ZONES:
plot_yearly_day_ahead(pf, zone)
plot_init_hour_comparison(pf, zone)


if __name__ == "__main__":
main()
44 changes: 40 additions & 4 deletions src/jua/power_forecast/power_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,16 +131,34 @@ def get_init_times(
zone_key: str | list[str] | None = None,
psr_type: str | list[str] | None = None,
limit: int = 96,
*,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> list[InitTimeInfo]:
"""Get available forecast init times.

Two selection modes are supported:

**Count mode** (default): returns the most recent ``limit`` init times
(the server caps ``limit`` at 1000).

**Time-window mode** (``start_time`` and/or ``end_time`` given): returns
*all* init times whose ``init_time`` falls in ``[start_time, end_time)``,
regardless of count. ``limit`` is ignored in this mode, so windows
containing more than 1000 runs are returned in full.

Args:
zone_key: Optional zone code(s) to filter by. When multiple
are given, only init times available for all of them are
returned (intersection semantics).
psr_type: Optional PSR type(s) to filter by. When multiple are
given, only init times available for all of them are returned.
limit: Maximum number of init times to return (default 96).
limit: Maximum number of init times to return in count mode
(default 96). Ignored when ``start_time``/``end_time`` is set.
start_time: Inclusive lower bound on ``init_time``. Enables
time-window mode.
end_time: Exclusive upper bound on ``init_time``. Enables
time-window mode.

Returns:
List of :class:`InitTimeInfo` objects sorted newest-first.
Expand All @@ -149,13 +167,31 @@ def get_init_times(
RuntimeError: If the API request fails.

Examples:
>>> # Count mode: 10 most recent runs
>>> init_times = client.power_forecast.get_init_times(
... zone_key="DE", limit=10
... )
>>> for it in init_times:
... print(it.init_time, it.max_prediction_timedelta)
>>>
>>> # Time-window mode: every run in a date range (can exceed 1000)
>>> from datetime import datetime, timezone
>>> init_times = client.power_forecast.get_init_times(
... zone_key="DE",
... start_time=datetime(2025, 1, 1, tzinfo=timezone.utc),
... end_time=datetime(2025, 2, 1, tzinfo=timezone.utc),
... )
"""
params: dict = {"limit": limit}
params: dict = {}
# Time-window mode: filter by init_time range and let the server return
# the full window. We omit ``limit`` because the endpoint caps it at
# 1000, which would silently truncate long ranges.
if start_time is not None or end_time is not None:
if start_time is not None:
params["start_time"] = start_time.isoformat()
if end_time is not None:
params["end_time"] = end_time.isoformat()
else:
params["limit"] = limit

if zone_key is not None:
params["zone_key"] = zone_key
if psr_type is not None:
Expand Down
23 changes: 23 additions & 0 deletions tests/functional/test_power_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,29 @@ def test_get_init_times_multiple_psr_types(self, pf):
"Intersection of multiple PSR types should not exceed a single type"
)

def test_get_init_times_time_window(self, pf):
"""Time-window mode returns init times bounded by start/end and is not
capped at the 1000-item count limit."""
from datetime import datetime, timedelta, timezone

end = datetime.now(timezone.utc)
start = end - timedelta(days=30)

init_times = pf.get_init_times(
zone_key="DE", psr_type="Solar", start_time=start, end_time=end
)

assert isinstance(init_times, list)
assert len(init_times) > 0

def _as_utc(dt):
# The endpoint may return naive datetimes; treat them as UTC.
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt

assert all(start <= _as_utc(it.init_time) < end for it in init_times)
# A 30-day window exceeds the legacy 1000-item count cap.
assert len(init_times) > 1000


class TestGetData:
"""Tests for power forecast data retrieval."""
Expand Down
78 changes: 78 additions & 0 deletions tests/power_forecast/test_get_init_times.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from datetime import datetime, timezone

from jua import JuaClient
from jua.power_forecast.power_forecast import InitTimeInfo


class _FakeResponse:
def __init__(self, payload):
self._payload = payload

def json(self):
return self._payload


def _patch_api(monkeypatch, pf, payload):
"""Capture the params passed to the init-times endpoint."""
captured: dict = {}

def fake_get(path, params=None, requires_auth=True):
captured["path"] = path
captured["params"] = params
return _FakeResponse(payload)

monkeypatch.setattr(pf._api, "get", fake_get)
return captured


_PAYLOAD = {
"init_times": [
{"init_time": "2025-01-02T00:00:00Z", "max_prediction_timedelta": 2400},
{"init_time": "2025-01-01T00:00:00Z", "max_prediction_timedelta": 2400},
]
}


def test_count_mode_sends_limit(monkeypatch):
client = JuaClient()
pf = client.power_forecast
captured = _patch_api(monkeypatch, pf, _PAYLOAD)

result = pf.get_init_times(zone_key="DE", psr_type="Solar", limit=42)

assert captured["params"]["limit"] == 42
assert "start_time" not in captured["params"]
assert "end_time" not in captured["params"]
assert captured["params"]["zone_key"] == "DE"
assert captured["params"]["psr_type"] == ["Solar"]
assert all(isinstance(it, InitTimeInfo) for it in result)


def test_time_window_mode_omits_limit_and_sends_range(monkeypatch):
client = JuaClient()
pf = client.power_forecast
captured = _patch_api(monkeypatch, pf, _PAYLOAD)

start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = datetime(2025, 2, 1, tzinfo=timezone.utc)
pf.get_init_times(zone_key="DE", start_time=start, end_time=end)

params = captured["params"]
# limit must be omitted so the server returns the full window (>1000 allowed)
assert "limit" not in params
assert params["start_time"] == start.isoformat()
assert params["end_time"] == end.isoformat()


def test_time_window_mode_accepts_only_start(monkeypatch):
client = JuaClient()
pf = client.power_forecast
captured = _patch_api(monkeypatch, pf, _PAYLOAD)

start = datetime(2025, 1, 1, tzinfo=timezone.utc)
pf.get_init_times(zone_key="DE", start_time=start)

params = captured["params"]
assert "limit" not in params
assert params["start_time"] == start.isoformat()
assert "end_time" not in params
Loading