From aaa7e53824ef01f44118b91cde41c21f4937dfa6 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:10:31 +0200 Subject: [PATCH 1/2] feat(power): expose opt-in MW debiasing Allow Power Forecast and market-aggregate users to request leakage-safe MW debiasing while retaining byte-compatible raw request bodies by default. Verification: - jua-core PowerForecastQuery.debias and ForecastQuery.mw_walkforward_debias default false - uv run just check-commit passes Co-authored-by: Cursor --- src/jua/market_aggregates/energy_market.py | 8 ++++ src/jua/power_forecast/power_forecast.py | 18 ++++++++ tests/market_aggregates/test_debias.py | 42 +++++++++++++++++++ .../test_day_ahead_timeseries.py | 14 ++++++- tests/power_forecast/test_versioning.py | 30 +++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/market_aggregates/test_debias.py diff --git a/src/jua/market_aggregates/energy_market.py b/src/jua/market_aggregates/energy_market.py index ad200a1..ddc947d 100644 --- a/src/jua/market_aggregates/energy_market.py +++ b/src/jua/market_aggregates/energy_market.py @@ -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. @@ -251,6 +252,11 @@ 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 leakage-safe walk-forward MW debiasing. Wind uses an + eight-week fitting window and solar uses four weeks; both + retain a seven-day exclusion gap. 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``). @@ -317,6 +323,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( diff --git a/src/jua/power_forecast/power_forecast.py b/src/jua/power_forecast/power_forecast.py index 47b92a8..155804f 100644 --- a/src/jua/power_forecast/power_forecast.py +++ b/src/jua/power_forecast/power_forecast.py @@ -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. @@ -437,6 +438,10 @@ 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 leakage-safe walk-forward additive MW debiasing. + Wind uses an eight-week fitting window and solar uses four + weeks; both retain a seven-day exclusion gap. Defaults to + ``False`` so raw predictions remain unchanged. Returns: ``xarray.Dataset`` with dimensions ``(zone_key, psr_type, time)`` @@ -534,6 +539,7 @@ def get_data( time_zone=time_zone, version=version, version_pins=normalized_pins, + debias=debias, ) try: @@ -563,6 +569,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. @@ -613,6 +620,8 @@ 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 leakage-safe walk-forward additive MW debiasing to + every fetched run. Defaults to ``False``. Returns: ``xarray.Dataset`` with dims ``(zone_key, psr_type, time)`` and @@ -656,6 +665,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( @@ -669,6 +679,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( @@ -693,6 +704,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( @@ -725,6 +737,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() @@ -744,6 +757,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. @@ -770,6 +784,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( @@ -1139,6 +1154,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: @@ -1161,6 +1177,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) diff --git a/tests/market_aggregates/test_debias.py b/tests/market_aggregates/test_debias.py new file mode 100644 index 0000000..5eb8b82 --- /dev/null +++ b/tests/market_aggregates/test_debias.py @@ -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 diff --git a/tests/power_forecast/test_day_ahead_timeseries.py b/tests/power_forecast/test_day_ahead_timeseries.py index 15da291..ab97f35 100644 --- a/tests/power_forecast/test_day_ahead_timeseries.py +++ b/tests/power_forecast/test_day_ahead_timeseries.py @@ -56,10 +56,15 @@ 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], @@ -67,6 +72,7 @@ def test_get_day_ahead_timeseries_stitches_across_days(monkeypatch): init_hour=9, time_zone="UTC", max_init_times=10, + debias=True, ) assert "time" in stitched.dims @@ -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: @@ -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. @@ -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" diff --git a/tests/power_forecast/test_versioning.py b/tests/power_forecast/test_versioning.py index 716f79e..3a37cee 100644 --- a/tests/power_forecast/test_versioning.py +++ b/tests/power_forecast/test_versioning.py @@ -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(): From 6f433035ba729953120602e2f9f85818a85ab638 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:58:11 +0200 Subject: [PATCH 2/2] docs(power): describe walk-forward MW debias windows Drop leakage-safe wording and explain that wind uses eight weeks and solar four weeks to estimate the bias, which is then subtracted before the window moves ahead. Co-authored-by: Cursor --- src/jua/market_aggregates/energy_market.py | 9 +++++---- src/jua/power_forecast/power_forecast.py | 17 +++++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/jua/market_aggregates/energy_market.py b/src/jua/market_aggregates/energy_market.py index ddc947d..f3f943a 100644 --- a/src/jua/market_aggregates/energy_market.py +++ b/src/jua/market_aggregates/energy_market.py @@ -252,10 +252,11 @@ 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 leakage-safe walk-forward MW debiasing. Wind uses an - eight-week fitting window and solar uses four weeks; both - retain a seven-day exclusion gap. Defaults to ``False`` so raw - MW remains unchanged. + 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 diff --git a/src/jua/power_forecast/power_forecast.py b/src/jua/power_forecast/power_forecast.py index 155804f..1e5d468 100644 --- a/src/jua/power_forecast/power_forecast.py +++ b/src/jua/power_forecast/power_forecast.py @@ -438,10 +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 leakage-safe walk-forward additive MW debiasing. - Wind uses an eight-week fitting window and solar uses four - weeks; both retain a seven-day exclusion gap. Defaults to - ``False`` so raw predictions remain unchanged. + 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)`` @@ -620,8 +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 leakage-safe walk-forward additive MW debiasing to - every fetched run. Defaults to ``False``. + 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