From c15e68e790ac47b69d49f758ead1c606485fa622 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:18:14 +0200 Subject: [PATCH 1/4] feat(power_forecast): add time-window mode to get_init_times Add start_time/end_time params to get_init_times. When set, the endpoint is queried by init_time range and limit is omitted so the full window is returned (bypassing the 1000-item count cap). Includes unit and functional tests. Co-authored-by: Cursor --- src/jua/power_forecast/power_forecast.py | 44 ++++++++++-- tests/functional/test_power_forecast.py | 18 +++++ tests/power_forecast/test_get_init_times.py | 78 +++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/power_forecast/test_get_init_times.py diff --git a/src/jua/power_forecast/power_forecast.py b/src/jua/power_forecast/power_forecast.py index a4b0418..627924a 100644 --- a/src/jua/power_forecast/power_forecast.py +++ b/src/jua/power_forecast/power_forecast.py @@ -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. @@ -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: diff --git a/tests/functional/test_power_forecast.py b/tests/functional/test_power_forecast.py index 04f3045..06cc3d3 100644 --- a/tests/functional/test_power_forecast.py +++ b/tests/functional/test_power_forecast.py @@ -76,6 +76,24 @@ 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 + assert all(start <= 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.""" diff --git a/tests/power_forecast/test_get_init_times.py b/tests/power_forecast/test_get_init_times.py new file mode 100644 index 0000000..bf06e56 --- /dev/null +++ b/tests/power_forecast/test_get_init_times.py @@ -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 From bbd81f8f7cc2faeccb62bed5dc6aac63dd467a7a Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:18:19 +0200 Subject: [PATCH 2/4] docs(examples): add DE day-ahead time-series plots Add an example that plots a year of DE day-ahead generation from the 09:00 UTC run, and the last month of DE Solar day-ahead comparing the 10/12/18 UTC init hours. Co-authored-by: Cursor --- .../plot_day_ahead_timeseries.py | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 examples/power_forecast/plot_day_ahead_timeseries.py diff --git a/examples/power_forecast/plot_day_ahead_timeseries.py b/examples/power_forecast/plot_day_ahead_timeseries.py new file mode 100644 index 0000000..5b3c5c7 --- /dev/null +++ b/examples/power_forecast/plot_day_ahead_timeseries.py @@ -0,0 +1,150 @@ +"""Plot stitched day-ahead power forecast time series for Germany (DE). + +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 + +ZONE = "DE" +PSR_COLORS = { + "Solar": "#F59E0B", + "Wind Onshore": "#3B82F6", + "Wind Offshore": "#06B6D4", +} + + +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) -> None: + """Plot a year of DE day-ahead generation from the 09:00 UTC run.""" + psr_types = ["Solar", "Wind Onshore", "Wind Offshore"] + 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 = "day_ahead_de_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) -> None: + """Compare the last month of DE 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 = "day_ahead_de_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 + + plot_yearly_day_ahead(pf) + plot_init_hour_comparison(pf) + + +if __name__ == "__main__": + main() From 3df4225c2684a15b39ead3f4b3b3bc38923b4df7 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:21:43 +0200 Subject: [PATCH 3/4] docs(examples): add GB day-ahead plots alongside DE Generalize the day-ahead example to run for both DE and GB, pulling each zone's available PSR types for the yearly plot. Co-authored-by: Cursor --- .../plot_day_ahead_timeseries.py | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/examples/power_forecast/plot_day_ahead_timeseries.py b/examples/power_forecast/plot_day_ahead_timeseries.py index 5b3c5c7..09676c5 100644 --- a/examples/power_forecast/plot_day_ahead_timeseries.py +++ b/examples/power_forecast/plot_day_ahead_timeseries.py @@ -1,6 +1,6 @@ -"""Plot stitched day-ahead power forecast time series for Germany (DE). +"""Plot stitched day-ahead power forecast time series. -Two examples are produced: +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``). @@ -19,11 +19,14 @@ from jua import JuaClient -ZONE = "DE" +ZONES = ["DE", "GB"] PSR_COLORS = { "Solar": "#F59E0B", + "Wind": "#10B981", "Wind Onshore": "#3B82F6", "Wind Offshore": "#06B6D4", + "Wind Transmission": "#6366F1", + "Wind Embedded": "#EC4899", } @@ -35,15 +38,15 @@ def _to_frame(ds): return df.dropna(subset=["value"]).sort_values("time") -def plot_yearly_day_ahead(pf) -> None: - """Plot a year of DE day-ahead generation from the 09:00 UTC run.""" - psr_types = ["Solar", "Wind Onshore", "Wind Offshore"] +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)...") + print(f"Fetching yearly day-ahead series for {zone} (09:00 UTC run)...") ds = pf.get_day_ahead_timeseries( - zone_keys=[ZONE], + zone_keys=[zone], psr_types=psr_types, init_hour=9, time_zone="UTC", @@ -71,7 +74,7 @@ def plot_yearly_day_ahead(pf) -> None: ax.set_ylabel("Power [MW]", fontsize=12) ax.set_title( - f"{ZONE} — Yearly day-ahead generation (09:00 UTC run)", + f"{zone} — Yearly day-ahead generation (09:00 UTC run)", fontsize=14, fontweight="bold", ) @@ -81,14 +84,14 @@ def plot_yearly_day_ahead(pf) -> None: ax.xaxis.set_major_locator(mdates.MonthLocator()) fig.autofmt_xdate() - out = "day_ahead_de_yearly_09utc.png" + 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) -> None: - """Compare the last month of DE Solar day-ahead across 10/12/18 UTC runs.""" +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) @@ -98,9 +101,9 @@ def plot_init_hour_comparison(pf) -> None: 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)...") + print(f"Fetching last-month {zone} {psr} day-ahead ({init_hour:02d}:00 UTC)...") ds = pf.get_day_ahead_timeseries( - zone_keys=[ZONE], + zone_keys=[zone], psr_types=[psr], init_hour=init_hour, time_zone="UTC", @@ -122,7 +125,7 @@ def plot_init_hour_comparison(pf) -> None: ax.set_ylabel("Power [MW]", fontsize=12) ax.set_title( - f"{ZONE} — {psr} day-ahead, last month by init hour", + f"{zone} — {psr} day-ahead, last month by init hour", fontsize=14, fontweight="bold", ) @@ -132,7 +135,7 @@ def plot_init_hour_comparison(pf) -> None: ax.xaxis.set_major_locator(mdates.DayLocator(interval=3)) fig.autofmt_xdate() - out = "day_ahead_de_last_month_init_hours.png" + 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}") @@ -142,8 +145,9 @@ def main(): client = JuaClient() pf = client.power_forecast - plot_yearly_day_ahead(pf) - plot_init_hour_comparison(pf) + for zone in ZONES: + plot_yearly_day_ahead(pf, zone) + plot_init_hour_comparison(pf, zone) if __name__ == "__main__": From e97c276963f31280e49ebf3fa8b7b043309489c6 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:11:22 +0200 Subject: [PATCH 4/4] test(power_forecast): handle naive init_time in time-window test The init-times endpoint may return naive datetimes; normalize to UTC before comparing against the tz-aware window bounds. Co-authored-by: Cursor --- tests/functional/test_power_forecast.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_power_forecast.py b/tests/functional/test_power_forecast.py index 06cc3d3..ecdfb45 100644 --- a/tests/functional/test_power_forecast.py +++ b/tests/functional/test_power_forecast.py @@ -90,7 +90,12 @@ def test_get_init_times_time_window(self, pf): assert isinstance(init_times, list) assert len(init_times) > 0 - assert all(start <= it.init_time < end for it in init_times) + + 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