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
9 changes: 9 additions & 0 deletions src/jua/market_aggregates/energy_market.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def compare_runs_mw(
min_lead_time: int = 0,
max_lead_time: int | None = None,
temporal_aggregation: TemporalAggregation | None = None,
debias: bool = False,
) -> xr.Dataset:
"""Compare multiple model runs with output in MW.

Expand Down Expand Up @@ -251,6 +252,12 @@ def compare_runs_mw(
specified frequency (e.g. daily) using the chosen method
(e.g. mean, sum). Applied client-side after fetching data.

debias: Apply walk-forward MW debiasing. Wind uses eight weeks of
history and solar uses four weeks to estimate the bias for
each init time and lead; that bias is subtracted from the
current forecast, then the window moves ahead. Defaults to
``False`` so raw MW remains unchanged.

Returns:
``xarray.Dataset`` with ``model_run`` and ``time`` dimensions
and MW data variables (e.g. ``wind_onshore_mw``).
Expand Down Expand Up @@ -317,6 +324,8 @@ def _build_params(models: list[Models], init_time: datetime) -> dict:
params["min_prediction_timedelta"] = min_lead_time
if max_lead_time is not None:
params["max_prediction_timedelta"] = max_lead_time
if debias:
params["debias"] = True
return params

all_dataframes = self._fetch_dataframes(
Expand Down
23 changes: 23 additions & 0 deletions src/jua/power_forecast/power_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ def get_data(
time_zone: str | None = None,
version: VersionSpec | None = None,
version_pins: Sequence[VersionPinSpec] | None = None,
debias: bool = False,
) -> xr.Dataset:
"""Query power forecast data in MW.

Expand Down Expand Up @@ -437,6 +438,12 @@ def get_data(
version_pins: Optional per-(zone_key, psr_type) overrides. Each
mapping must include ``zone_key``, ``psr_type``, and
``version`` (alias or run id).
debias: Apply walk-forward additive MW debiasing. Wind uses
eight weeks of history and solar uses four weeks to
estimate the bias for each init time and lead; that bias
is subtracted from the current forecast, then the window
moves ahead. Defaults to ``False`` so raw predictions
remain unchanged.

Returns:
``xarray.Dataset`` with dimensions ``(zone_key, psr_type, time)``
Expand Down Expand Up @@ -534,6 +541,7 @@ def get_data(
time_zone=time_zone,
version=version,
version_pins=normalized_pins,
debias=debias,
)

try:
Expand Down Expand Up @@ -563,6 +571,7 @@ def get_day_ahead_timeseries(
max_init_times: int = 365,
version: VersionSpec | None = None,
version_pins: Sequence[VersionPinSpec] | None = None,
debias: bool = False,
) -> xr.Dataset:
"""Return a continuous day-ahead time series stitched across runs.

Expand Down Expand Up @@ -613,6 +622,11 @@ def get_day_ahead_timeseries(
:meth:`get_init_times` (``stable`` / ``latest`` / run id).
version_pins: Optional per-(zone, psr) overrides forwarded to
:meth:`get_data`.
debias: Apply walk-forward additive MW debiasing to every
fetched run. Wind uses eight weeks of history and solar
uses four weeks to estimate the bias; that bias is
subtracted from the current forecast, then the window
moves ahead. Defaults to ``False``.

Returns:
``xarray.Dataset`` with dims ``(zone_key, psr_type, time)`` and
Expand Down Expand Up @@ -656,6 +670,7 @@ def get_day_ahead_timeseries(
end_lead_minutes=end_lead_minutes,
version=version,
version_pins=version_pins,
debias=debias,
)
else:
df = self._fetch_day_ahead_latest(
Expand All @@ -669,6 +684,7 @@ def get_day_ahead_timeseries(
max_init_times=max_init_times,
version=version,
version_pins=version_pins,
debias=debias,
)

return self._stitch_day_ahead(
Expand All @@ -693,6 +709,7 @@ def _fetch_day_ahead_latest(
max_init_times: int,
version: VersionSpec | None = None,
version_pins: Sequence[VersionPinSpec] | None = None,
debias: bool = False,
) -> pd.DataFrame:
"""Fetch day-ahead data for the most recent matching runs."""
init_infos = self.get_init_times(
Expand Down Expand Up @@ -725,6 +742,7 @@ def _fetch_day_ahead_latest(
time_zone=time_zone,
version=version,
version_pins=version_pins,
debias=debias,
)
if "value" not in ds:
return pd.DataFrame()
Expand All @@ -744,6 +762,7 @@ def _fetch_day_ahead_by_date_range(
end_lead_minutes: int,
version: VersionSpec | None = None,
version_pins: Sequence[VersionPinSpec] | None = None,
debias: bool = False,
) -> pd.DataFrame:
"""Fetch day-ahead data by constructing daily init runs over a range.

Expand All @@ -770,6 +789,7 @@ def _fetch_day_ahead_by_date_range(
time_zone=time_zone,
version=version,
version_pins=version_pins,
debias=debias,
)
if "value" not in ds:
raise ValueError(
Expand Down Expand Up @@ -1139,6 +1159,7 @@ def _build_query_body(
time_zone: str | None,
version: VersionSpec | None = None,
version_pins: list[dict[str, str]] | None = None,
debias: bool = False,
) -> dict:
body: dict = {}
if zone_keys is not None:
Expand All @@ -1161,6 +1182,8 @@ def _build_query_body(
body["version"] = version
if version_pins is not None:
body["version_pins"] = version_pins
if debias:
body["debias"] = True

return remove_none_from_dict(body)

Expand Down
42 changes: 42 additions & 0 deletions tests/market_aggregates/test_debias.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from datetime import datetime, timezone

import pytest

from jua import JuaClient
from jua.market_aggregates import ModelRuns
from jua.weather import Models


class _FakeResponse:
def json(self):
return {}


@pytest.mark.parametrize(("debias", "expected"), [(False, None), (True, True)])
def test_compare_runs_mw_forwards_opt_in_debias(monkeypatch, debias, expected):
market = JuaClient().market_aggregates.get_market("DE")
captured: dict = {}

monkeypatch.setattr(
market,
"_resolve_init_times_for_model",
lambda model, init_times: [
datetime(2026, 8, 1, tzinfo=timezone.utc),
],
)

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

monkeypatch.setattr(market._query_engine_api, "get", fake_get)

market.compare_runs_mw(
weighting="wind_capacity",
model_runs=[ModelRuns(Models.EPT2, 0)],
debias=debias,
)

assert captured["path"] == "forecast/market-aggregate"
assert captured["params"].get("debias") is expected
14 changes: 12 additions & 2 deletions tests/power_forecast/test_day_ahead_timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,23 @@ def test_get_day_ahead_timeseries_stitches_across_days(monkeypatch):
InitTimeInfo(init_time=t1, max_prediction_timedelta=40 * 60),
InitTimeInfo(init_time=t2, max_prediction_timedelta=40 * 60),
]
captured: dict = {}

def fake_get_data(**kwargs):
captured.update(kwargs)
return _make_ds(zone, psr, [t1, t2])

# Patch network methods
monkeypatch.setattr(pf, "get_init_times", _stub_init_times(init_infos))
monkeypatch.setattr(pf, "get_data", lambda **kwargs: _make_ds(zone, psr, [t1, t2]))
monkeypatch.setattr(pf, "get_data", fake_get_data)

stitched = pf.get_day_ahead_timeseries(
zone_keys=[zone],
psr_types=[psr],
init_hour=9,
time_zone="UTC",
max_init_times=10,
debias=True,
)

assert "time" in stitched.dims
Expand All @@ -76,6 +82,7 @@ def test_get_day_ahead_timeseries_stitches_across_days(monkeypatch):
last_time = pd.Timestamp(datetime(2025, 1, 3, 23, 0)).tz_localize(None)
assert pd.Timestamp(stitched.time.values[0]) == first_time
assert pd.Timestamp(stitched.time.values[-1]) == last_time
assert captured["debias"] is True


def _make_ds_15min(zone: str, psr: str, init_times: list[datetime]) -> xr.Dataset:
Expand Down Expand Up @@ -145,11 +152,12 @@ def test_get_day_ahead_timeseries_date_range_builds_inits_and_clips(monkeypatch)
zone, psr = "DE", "Solar"
init_hour = 9

calls = {"n_inits": []}
calls = {"n_inits": [], "debias": []}

def fake_get_data(**kwargs):
inits = kwargs["init_time"]
calls["n_inits"].append(len(inits))
calls["debias"].append(kwargs["debias"])
return _make_ds_15min(zone, psr, list(inits))

# get_init_times must NOT be used in date-range mode.
Expand All @@ -169,10 +177,12 @@ def fail_init_times(*a, **k):
time_zone="UTC",
start_date=start,
end_date=end,
debias=True,
)

# One request containing exactly one init for each requested valid day.
assert calls["n_inits"] == [10]
assert calls["debias"] == [True]

times = pd.to_datetime(ds.time.values)
assert len(times) == len(set(times)), "time index must be unique"
Expand Down
30 changes: 30 additions & 0 deletions tests/power_forecast/test_versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,36 @@ def fake_post(path, data=None, requires_auth=True):
assert captured["data"]["version_pins"] == [
{"zone_key": "DE", "psr_type": "Solar", "version": "rv7orbtm"}
]
assert "debias" not in captured["data"]


def test_get_data_body_includes_debias_when_enabled(monkeypatch):
client = JuaClient()
pf = client.power_forecast
captured: dict = {}

def fake_post(path, data=None, requires_auth=True):
captured["data"] = data
return _FakeResponse(
{
"zone_key": [],
"psr_type": [],
"init_time": [],
"time": [],
"value": [],
}
)

monkeypatch.setattr(pf._api, "post", fake_post)

pf.get_data(
zone_keys=["DE"],
psr_types=["Solar"],
init_time="2026-07-16T00:00:00+00:00",
debias=True,
)

assert captured["data"]["debias"] is True


def test_get_data_rejects_internal_channel_names():
Expand Down
Loading