Skip to content

Commit 87ece1a

Browse files
feat(elecprice): serve native 15-minute Tibber prices for the quarter-hour grid
Request QUARTER_HOURLY exchange prices from Tibber and store them at their native resolution instead of pre-averaging to hourly values. EOS resamples the stored records onto the optimization grid on demand (key_to_array), so keeping the native step size lets both the hourly (interval=3600) and the 15-minute (interval=900) optimizer be fed the correct grid automatically. - GraphQL: priceInfoRange resolution HOURLY -> QUARTER_HOURLY (last 960). - _hourly_series -> _normalize_series: dedupe by timestamp (mean) + sort, no 1h aggregation; add _resolution_seconds (median of timestamp diffs, fallback 3600s). - Resolution-agnostic ETS extrapolation: seasonal windows and history thresholds are scaled by slots_per_hour, needed forecast length and the prediction index step are computed in slots. Hourly behaviour is unchanged (slots_per_hour=1 -> 168/24 seasonal periods, hourly steps). - Tests: replace the 1h-averaging test with resolution-preserving + dedup tests, assert QUARTER_HOURLY in the query, add a 15-min end-to-end test (native storage stays 15min, slot-based seasonal periods = 96, 15-min forecast index). Hourly backward-compat tests stay green unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181Cp11xQ2VesG1HtuPJKMe
1 parent 323ad4c commit 87ece1a

3 files changed

Lines changed: 176 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
2020
default 3600 s interval keeps the previous hourly behaviour. The new sub-hourly PV
2121
providers (pvnode, Forecast.Solar, Solcast) feed their native resolution straight
2222
into the quarter-hour grid.
23+
- The Tibber electricity price provider now requests native 15-minute exchange prices
24+
(`priceInfoRange(resolution: QUARTER_HOURLY)`) and stores them at their native
25+
resolution, so both the hourly and the 15-minute optimizer are fed the correct
26+
grid. The seasonal price extrapolation is resolution-agnostic and stays identical
27+
at the default hourly resolution.
2328

2429
## 0.3.0 (2026-03-17)
2530

src/akkudoktoreos/prediction/elecpricetibber.py

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
total
3535
}
3636
}
37-
priceInfoRange(resolution: HOURLY, last: 840) {
37+
priceInfoRange(resolution: QUARTER_HOURLY, last: 960) {
3838
nodes {
3939
startsAt
4040
total
@@ -245,13 +245,36 @@ def _parse_data(self, response: TibberGraphQLResponse) -> pd.Series:
245245

246246
return series_data.sort_index()
247247

248-
def _hourly_series(self, series: pd.Series) -> pd.Series:
249-
"""Normalize Tibber prices to hourly values for EOS optimization."""
248+
def _normalize_series(self, series: pd.Series) -> pd.Series:
249+
"""Normalize Tibber prices while preserving their native resolution.
250+
251+
The Tibber API delivers either hourly or quarter-hourly prices. EOS resamples
252+
the stored records onto the optimization grid on demand (``key_to_array``), so
253+
the provider must keep the native step size (e.g. 15 minutes) instead of
254+
pre-aggregating to hourly values. Duplicate timestamps are collapsed (mean) and
255+
the series is sorted, but the resolution is left untouched.
256+
"""
250257
if series.empty:
251258
return series
252259
series = series.sort_index()
253260
series.index = pd.to_datetime([to_datetime(index).isoformat() for index in series.index])
254-
return series.resample("1h").mean().dropna()
261+
series = series.groupby(level=0).mean().sort_index()
262+
return series.dropna()
263+
264+
def _resolution_seconds(self, series: pd.Series) -> int:
265+
"""Infer the native slot size in seconds from the series timestamps.
266+
267+
Uses the median of the timestamp differences so that a single outlier gap does
268+
not distort the result. Falls back to hourly (3600 s) when fewer than two
269+
timestamps are available.
270+
"""
271+
if len(series) < 2:
272+
return 3600
273+
deltas = pd.DatetimeIndex(series.index).to_series().diff().dropna()
274+
if deltas.empty:
275+
return 3600
276+
resolution = int(round(deltas.dt.total_seconds().median()))
277+
return resolution if resolution > 0 else 3600
255278

256279
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
257280
mean = data.mean()
@@ -272,33 +295,48 @@ def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
272295
clean_history = self._cap_outliers(history)
273296
return np.full(hours, np.median(clean_history))
274297

275-
def _predict_missing_prices(self, history: np.ndarray, hours: int) -> np.ndarray:
276-
"""Forecast missing future prices from the available hourly history."""
298+
def _predict_missing_prices(
299+
self, history: np.ndarray, slots: int, slots_per_hour: int
300+
) -> np.ndarray:
301+
"""Forecast missing future prices from the available history.
302+
303+
Works on the native resolution of the series: ``slots_per_hour`` scales the
304+
hour-based seasonal windows into slot counts, so the seasonal periods and
305+
history thresholds stay correct at both hourly (``slots_per_hour == 1``) and
306+
quarter-hourly (``slots_per_hour == 4``) resolution.
307+
"""
277308
numeric_history = np.asarray(history, dtype=float)
278309
numeric_history = numeric_history[np.isfinite(numeric_history)]
279-
history_hours = len(numeric_history)
310+
history_slots = len(numeric_history)
280311

281-
if history_hours > TIBBER_WEEKLY_SEASONAL_HOURS:
312+
weekly_seasonal_slots = TIBBER_WEEKLY_SEASONAL_HOURS * slots_per_hour
313+
daily_seasonal_slots = TIBBER_DAILY_SEASONAL_HOURS * slots_per_hour
314+
315+
if history_slots > weekly_seasonal_slots:
282316
logger.info(
283317
"Using weekly seasonal ETS forecast for Tibber electricity prices "
284-
"with {} historical hourly values.",
285-
history_hours,
318+
"with {} historical values.",
319+
history_slots,
320+
)
321+
return self._predict_ets(
322+
numeric_history, seasonal_periods=168 * slots_per_hour, hours=slots
286323
)
287-
return self._predict_ets(numeric_history, seasonal_periods=168, hours=hours)
288-
if history_hours > TIBBER_DAILY_SEASONAL_HOURS:
324+
if history_slots > daily_seasonal_slots:
289325
logger.info(
290326
"Using daily seasonal ETS forecast for Tibber electricity prices "
291-
"with {} historical hourly values.",
292-
history_hours,
327+
"with {} historical values.",
328+
history_slots,
293329
)
294-
return self._predict_ets(numeric_history, seasonal_periods=24, hours=hours)
295-
if history_hours > 0:
330+
return self._predict_ets(
331+
numeric_history, seasonal_periods=24 * slots_per_hour, hours=slots
332+
)
333+
if history_slots > 0:
296334
logger.warning(
297335
"Using median fallback for Tibber electricity prices because only {} "
298-
"historical hourly values are available.",
299-
history_hours,
336+
"historical values are available.",
337+
history_slots,
300338
)
301-
return self._predict_median(numeric_history, hours=hours)
339+
return self._predict_median(numeric_history, hours=slots)
302340

303341
logger.error("No data available for prediction")
304342
raise ValueError("No data available")
@@ -310,16 +348,20 @@ def _update_data(self, force_update: Optional[bool] = False) -> None:
310348
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
311349

312350
api_history_count, api_today_count, api_tomorrow_count = self._api_price_counts(tibber_data)
313-
series_data = self._hourly_series(self._parse_data(tibber_data))
351+
series_data = self._normalize_series(self._parse_data(tibber_data))
314352
if series_data.empty:
315-
raise ValueError("Tibber response contains no usable hourly price points")
353+
raise ValueError("Tibber response contains no usable price points")
354+
355+
resolution_seconds = self._resolution_seconds(series_data)
356+
slots_per_hour = round(3600 / resolution_seconds)
316357

317358
highest_orig_datetime = to_datetime(series_data.index.max())
318359
self.key_from_series("elecprice_marketprice_wh", series_data)
319360

320361
history = self.key_to_array(
321362
key="elecprice_marketprice_wh",
322363
end_datetime=highest_orig_datetime,
364+
interval=to_duration(f"{resolution_seconds} seconds"),
323365
fill_method="linear",
324366
)
325367

@@ -328,36 +370,41 @@ def _update_data(self, force_update: Optional[bool] = False) -> None:
328370
logger.error(error_msg)
329371
raise ValueError(error_msg)
330372

331-
needed_hours = int(
332-
self.config.prediction.hours
333-
- ((highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
334-
)
373+
covered_slots = (
374+
highest_orig_datetime - self.ems_start_datetime
375+
).total_seconds() // resolution_seconds
376+
needed_slots = int(self.config.prediction.hours * slots_per_hour - covered_slots)
335377

336-
if needed_hours <= 0:
378+
if needed_slots <= 0:
337379
logger.warning(
338380
"No prediction needed. "
339-
f"needed_hours={needed_hours}, "
381+
f"needed_slots={needed_slots}, "
340382
f"hours={self.config.prediction.hours}, "
383+
f"slots_per_hour={slots_per_hour}, "
341384
f"highest_orig_datetime={highest_orig_datetime}, "
342385
f"start_datetime={self.ems_start_datetime}"
343386
)
344387
return
345388

346389
logger.info(
347-
"Tibber electricity price input: api_history_hours={}, api_today_hours={}, "
348-
"api_tomorrow_hours={}, combined_history_hours={}, needed_forecast_hours={}.",
390+
"Tibber electricity price input: api_history={}, api_today={}, "
391+
"api_tomorrow={}, resolution_seconds={}, combined_history_slots={}, "
392+
"needed_forecast_slots={}.",
349393
api_history_count,
350394
api_today_count,
351395
api_tomorrow_count,
396+
resolution_seconds,
352397
len(history),
353-
needed_hours,
398+
needed_slots,
399+
)
400+
prediction = self._predict_missing_prices(
401+
history, slots=needed_slots, slots_per_hour=slots_per_hour
354402
)
355-
prediction = self._predict_missing_prices(history, hours=needed_hours)
356403

357404
prediction_series = pd.Series(
358405
data=prediction,
359406
index=[
360-
highest_orig_datetime + to_duration(f"{i + 1} hours")
407+
highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
361408
for i in range(len(prediction))
362409
],
363410
)

tests/test_elecpricetibber.py

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,41 @@ def test_parse_data_combines_sorts_and_converts_total(provider, tibber_response)
175175
assert series.iloc[2] == pytest.approx(0.00030468)
176176

177177

178-
def test_tibber_hourly_series_averages_quarter_hour_prices(provider):
179-
"""Quarter-hour Tibber prices are averaged to hourly EOS prices."""
178+
def test_tibber_normalize_series_preserves_quarter_hour_resolution(provider):
179+
"""Quarter-hour Tibber prices keep their native 15-min resolution (no averaging).
180+
181+
EOS resamples onto the optimization grid on demand, so the provider must store the
182+
native step size instead of pre-aggregating quarter-hour prices to hourly values.
183+
"""
180184
index = pd.date_range("2026-07-09T00:00:00+00:00", periods=8, freq="15min")
181-
series = pd.Series([0.10, 0.30, 0.50, 0.70, 1.0, 1.4, 1.8, 2.2], index=index)
185+
values = [0.10, 0.30, 0.50, 0.70, 1.0, 1.4, 1.8, 2.2]
186+
series = pd.Series(values, index=index)
187+
188+
normalized = provider._normalize_series(series)
189+
190+
# Every 15-min point survives, values untouched, still on a 15-min grid.
191+
assert normalized.tolist() == pytest.approx(values)
192+
deltas = normalized.index.to_series().diff().dropna().dt.total_seconds().unique().tolist()
193+
assert deltas == [900.0]
194+
assert provider._resolution_seconds(normalized) == 900
195+
196+
197+
def test_tibber_normalize_series_deduplicates_timestamps(provider):
198+
"""Duplicate timestamps are collapsed (mean) without changing the resolution."""
199+
index = pd.DatetimeIndex(
200+
[
201+
"2026-07-09T00:00:00+00:00",
202+
"2026-07-09T00:00:00+00:00",
203+
"2026-07-09T01:00:00+00:00",
204+
]
205+
)
206+
series = pd.Series([0.10, 0.30, 0.50], index=index)
182207

183-
hourly = provider._hourly_series(series)
208+
normalized = provider._normalize_series(series)
184209

185-
assert hourly.tolist() == pytest.approx([0.40, 1.60])
210+
assert len(normalized) == 2
211+
assert normalized.iloc[0] == pytest.approx(0.20)
212+
assert normalized.iloc[1] == pytest.approx(0.50)
186213

187214

188215
def test_empty_tomorrow_stores_only_today_and_warns(provider):
@@ -223,6 +250,7 @@ def test_request_forecast_uses_tibber_graphql_api(
223250
assert "query" in kwargs["json"]
224251
assert "TibberPriceInfo" in kwargs["json"]["query"]
225252
assert "priceInfoRange" in kwargs["json"]["query"]
253+
assert "QUARTER_HOURLY" in kwargs["json"]["query"]
226254
assert "total" in kwargs["json"]["query"]
227255
assert kwargs["timeout"] == 30
228256

@@ -299,3 +327,62 @@ def fake_predict_ets(history, seasonal_periods, hours):
299327

300328
assert forecast_call["seasonal_periods"] == 168
301329
assert forecast_call["history_hours"] > 840
330+
331+
332+
def test_tibber_update_preserves_quarter_hour_resolution_and_slots(
333+
tibber_provider, monkeypatch
334+
):
335+
"""15-minute Tibber prices are stored natively and extrapolated on the slot grid.
336+
337+
Proves the resolution-agnostic path: (a) the native 15-min resolution survives
338+
storage, (b) the ETS extrapolation scales the seasonal window into slots
339+
(daily-only history -> 24*4 = 96 seasonal periods), and (c) the forecast index is
340+
spaced at 15-minute steps.
341+
"""
342+
data = TibberGraphQLResponse.model_validate(
343+
_tibber_payload(
344+
[
345+
_price("2026-07-09T00:00:00+00:00", 0.30),
346+
_price("2026-07-09T00:15:00+00:00", 0.42),
347+
_price("2026-07-09T00:30:00+00:00", 0.36),
348+
],
349+
include_history_range=False,
350+
)
351+
)
352+
monkeypatch.setattr(tibber_provider, "_request_forecast", lambda **_: data)
353+
forecast_call = {}
354+
355+
def fake_predict_ets(history, seasonal_periods, hours):
356+
forecast_call["seasonal_periods"] = seasonal_periods
357+
forecast_call["history_slots"] = len(history)
358+
forecast_call["forecast_slots"] = hours
359+
return np.full(hours, 0.0009)
360+
361+
monkeypatch.setattr(tibber_provider, "_predict_ets", fake_predict_ets)
362+
363+
# A bit more than one week of quarter-hour history: enough for the daily seasonal
364+
# window (> 24*7*4 = 672 slots) but below the weekly one (<= 24*35*4 = 3360 slots).
365+
stored_history = pd.Series(
366+
data=np.linspace(0.0002, 0.0004, 800),
367+
index=pd.date_range("2026-07-01T00:00:00+00:00", periods=800, freq="15min"),
368+
)
369+
tibber_provider.key_from_series("elecprice_marketprice_wh", stored_history)
370+
371+
tibber_provider._update_data(force_update=True)
372+
373+
# (b) Daily seasonal window scaled into 15-min slots.
374+
assert forecast_call["seasonal_periods"] == 96
375+
assert 672 < forecast_call["history_slots"] <= 3360
376+
# prediction.hours (6) * slots_per_hour (4) - covered slots (2 -> 00:00..00:30) = 22
377+
assert forecast_call["forecast_slots"] == 22
378+
379+
# (a)+(c) Stored records keep the native 15-min grid across today and the forecast.
380+
stored = tibber_provider.key_to_series(
381+
"elecprice_marketprice_wh",
382+
start_datetime=to_datetime("2026-07-09T00:00:00+00:00"),
383+
end_datetime=to_datetime("2026-07-09T06:15:00+00:00"),
384+
)
385+
steps = stored.index.to_series().diff().dropna().dt.total_seconds().unique().tolist()
386+
assert steps == [900.0]
387+
# 00:00..06:00 inclusive at 15-min steps = 25 points (3 API + 22 forecast).
388+
assert len(stored) == 25

0 commit comments

Comments
 (0)