fix: detect recent Energy-Charts source cadence (#1315)

Infer interval coverage from consistent recent original price spacings while preserving history and forecasting resolution policies. Reuse source data until a successful refresh and cover cadence transitions, fallback, and history repair.

Fixes #1279
This commit is contained in:
dr-dimitri
2026-09-16 12:22:39 +02:00
committed by GitHub
parent afb7bcb8bc
commit 7ebe6d714b
2 changed files with 237 additions and 25 deletions
@@ -98,6 +98,35 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
"""Return the unique identifier for the Energy-Charts provider."""
return "ElecPriceEnergyCharts"
def _coverage_resolution_seconds(self, source_series: pd.Series) -> int:
"""Infer interval coverage from original prices without changing forecast resolution.
The caller excludes timestamps beyond ``highest_orig_datetime``. Within the last
24 hours, four equal spacings among the final five differences establish a recent
cadence. This recognizes five consecutive points at a new resolution while tolerating
one exceptional gap. Only positive intervals dividing one hour are supported, as in
the shared resolution helper.
Ambiguous or insufficient agreement falls back to the median resolution of these
24 hours; fewer than two distinct timestamps fall back to hourly coverage.
"""
if source_series.empty:
return 3600
recent_series = source_series.sort_index()
recent_series = recent_series[
recent_series.index >= recent_series.index[-1] - pd.Timedelta(hours=24)
]
index = pd.DatetimeIndex(recent_series.index).drop_duplicates()
deltas = index.to_series().diff().dropna().dt.total_seconds().tail(5)
counts = deltas.value_counts()
if not counts.empty and counts.iloc[0] >= 4:
resolution = float(counts.index[0])
if resolution > 0 and 3600 % resolution == 0:
return int(resolution)
return self._resolution_seconds(recent_series)
def _has_complete_published_horizon(
self, *, now: pd.Timestamp, resolution_seconds: int
) -> bool:
@@ -247,12 +276,21 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
# Determine if update is needed and what start date is really necessary
needs_update = False
raw_series: Optional[pd.Series] = None
if self.highest_orig_datetime:
raw_history = await self.key_to_raw_series(
source_end = to_datetime(self.highest_orig_datetime).add(seconds=1)
raw_series = await self.key_to_raw_series(
key="elecprice_marketprice_raw_wh",
start_datetime=start_datetime,
end_datetime=gross_start_datetime,
end_datetime=max(gross_start_datetime, source_end),
)
# Preserve the history window even during an outage when the latest original
# timestamp precedes EMS start. Reuse this read for coverage and forecasting,
# but exclude the extrapolated tail from both resolution estimates.
raw_history = raw_series[
(raw_series.index >= pd.Timestamp(start_datetime))
& (raw_series.index < pd.Timestamp(gross_start_datetime))
]
raw_series = raw_series[raw_series.index <= pd.Timestamp(self.highest_orig_datetime)]
if raw_history.empty:
# We need the default start date (35 days in past)
@@ -271,16 +309,7 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
# Use default start date in case of forced update
needs_update = True
else:
# The latest source data may have a different resolution than
# the history before ems_start_datetime. Use its final 24 hours
# so older, finer intervals cannot dominate the median, and
# exclude the predicted tail.
source_series = await self.key_to_raw_series(
key="elecprice_marketprice_raw_wh",
start_datetime=to_datetime(self.highest_orig_datetime).subtract(hours=24),
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
source_resolution_seconds = self._resolution_seconds(source_series)
source_resolution_seconds = self._coverage_resolution_seconds(raw_series)
if not self._has_complete_published_horizon(
now=now, resolution_seconds=source_resolution_seconds
):
@@ -311,6 +340,8 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
raise ValueError("No Energy-Charts electricity price data available")
self.highest_orig_datetime = to_datetime(series_data.index.max())
await self.key_from_series("elecprice_marketprice_raw_wh", series_data)
# Reload after a successful fetch so prediction sees the new source data.
raw_series = None
# Newly fetched data widens the window that needs its gross
# (fee-inclusive) values recomputed.
gross_start_datetime = to_datetime(series_data.index.min())
@@ -335,10 +366,11 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
logger.error(error_msg)
raise ValueError(error_msg)
raw_series = await self.key_to_raw_series(
key="elecprice_marketprice_raw_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
if raw_series is None:
raw_series = await self.key_to_raw_series(
key="elecprice_marketprice_raw_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = self._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds