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 )
0 commit comments