fix(interval_pool): preserve DST fall-back duplicate intervals

On DST fall-back nights the clocks repeat an hour (e.g. 02:00 CET/CEST).
Tibber delivers quarter-hourly intervals for both the CEST (+02:00) and
CET (+01:00) copies of that hour.  Both share the same 19-char naive local
key 'YYYY-MM-DDTHH:MM:SS', so _add_intervals treated the CET arrivals as
unwanted duplicates and sent them to _touch_intervals, which kept the CEST
data and silently discarded the CET price data.  The fall-back hour's prices
were permanently lost from the pool.

Fix:
- Add module constant _DST_COLLISION_MAX_SAME_UTC_S (60 s) to distinguish
  true duplicate arrivals (same UTC, ≤60 s apart) from DST collision pairs
  (~3600 s apart).
- Add _handle_index_collision() helper that compares the UTC datetimes of
  the existing and incoming interval.  If they differ by more than the
  threshold it stores the new interval in self._dst_extras keyed by the
  normalised local timestamp and returns True.
- _add_intervals delegates every collision to _handle_index_collision and
  only routes to touch when it returns False (true duplicate).
- _get_cached_intervals yields the saved extras after the main interval.
- After each GC run, stale entries are pruned from _dst_extras.
- to_dict / from_dict persist and restore _dst_extras across HA restarts.

Impact: The full fall-back hour (e.g. 02:00-02:45 CET) now appears in the
interval pool alongside the CEST copies, so sensors that query that hour
return correct prices instead of stale or missing data.
This commit is contained in:
Julian Pawlowski 2026-04-06 14:05:10 +00:00
parent ce049e48b1
commit 8975aef900

View file

@ -34,6 +34,11 @@ INTERVAL_QUARTER_HOURLY = 15
# Debounce delay for auto-save (seconds)
DEBOUNCE_DELAY_SECONDS = 3.0
# Maximum UTC difference (seconds) between two intervals that share the same naive
# local timestamp to still be considered a true duplicate (not a DST fall-back pair).
# True duplicates differ by 0 s; DST fall-back pairs differ by ~3600 s.
_DST_COLLISION_MAX_SAME_UTC_S = 60
def _normalize_starts_at(starts_at: datetime | str) -> str:
"""Normalize startsAt to consistent format (YYYY-MM-DDTHH:MM:SS)."""
@ -112,6 +117,15 @@ class TibberPricesIntervalPool:
self._save_debounce_task: asyncio.Task | None = None
self._save_lock = asyncio.Lock()
# DST fall-back extra intervals.
# On DST fall-back nights (e.g. last Sunday October in EU), wall-clock
# 02:00-02:45 occurs twice: once in CEST (+02:00) and once in CET (+01:00).
# The main index uses naive 19-char keys, so the second batch collides.
# To avoid discarding the CET hour's price data, we store the colliding
# entries here keyed by their normalized timestamp.
# Structure: {"2026-10-25T02:00:00": [{"startsAt": "...+01:00", ...}], ...}
self._dst_extras: dict[str, list[dict[str, Any]]] = {}
async def get_intervals(
self,
api_client: TibberPricesApiClient,
@ -582,6 +596,12 @@ class TibberPricesIntervalPool:
# (e.g., parse_all_timestamps() converts startsAt to datetime in-place)
result.append(dict(interval))
# Also yield DST fall-back extras for this naive timestamp.
# On fall-back day, 02:xx occurs in both CEST and CET; the CET
# version was stored in _dst_extras instead of being discarded.
if current_dt_key in self._dst_extras:
result.extend(dict(extra) for extra in self._dst_extras[current_dt_key])
# Move to next expected interval
current_naive += timedelta(minutes=interval_minutes)
@ -599,6 +619,47 @@ class TibberPricesIntervalPool:
return result
def _handle_index_collision(
self,
starts_at_normalized: str,
interval: dict[str, Any],
) -> bool:
"""
Handle a key collision when adding an interval.
A collision occurs when a new interval shares the same naive local key as one
already in the index. Two cases:
* **True duplicate**: the UTC times are ``_DST_COLLISION_MAX_SAME_UTC_S``
apart normal re-fetch; caller should *touch* the existing entry.
* **DST fall-back collision**: the UTC times differ by ~3600 s the same
local clock time occurs twice (CEST then CET). Store the new interval in
``_dst_extras`` so both are preserved.
Returns:
``True`` if this was a DST fall-back collision (extra stored internally).
``False`` if this was a true duplicate (caller should touch existing entry).
"""
location = self._index.get(starts_at_normalized)
if location is None:
return False
fetch_groups = self._cache.get_fetch_groups()
existing_interval = fetch_groups[location["fetch_group_index"]]["intervals"][location["interval_index"]]
existing_dt = datetime.fromisoformat(existing_interval["startsAt"])
new_dt = datetime.fromisoformat(interval["startsAt"])
if abs((new_dt - existing_dt).total_seconds()) > _DST_COLLISION_MAX_SAME_UTC_S:
# Different UTC time → DST fall-back collision: preserve both
self._dst_extras.setdefault(starts_at_normalized, []).append(dict(interval))
_LOGGER.debug(
"DST fall-back: stored extra interval %s alongside %s for home %s",
interval["startsAt"],
existing_interval["startsAt"],
self._home_id,
)
return True
return False
def _add_intervals(
self,
intervals: list[dict[str, Any]],
@ -633,6 +694,9 @@ class TibberPricesIntervalPool:
starts_at_normalized = _normalize_starts_at(interval["startsAt"])
if not self._index.contains(starts_at_normalized):
new_intervals.append(interval)
elif self._handle_index_collision(starts_at_normalized, interval):
# DST fall-back: extra stored inside _handle_index_collision, skip touch
pass
else:
intervals_to_touch.append((starts_at_normalized, interval))
_LOGGER_DETAILS.debug(
@ -676,6 +740,11 @@ class TibberPricesIntervalPool:
# Run GC to evict old fetch groups if needed
gc_changed_data = self._gc.run_gc()
# After GC, prune DST extras whose main index entry was evicted.
# (Extras are only meaningful while their CEST counterpart is still indexed.)
if gc_changed_data and self._dst_extras:
self._dst_extras = {key: extras for key, extras in self._dst_extras.items() if self._index.contains(key)}
# Schedule debounced auto-save if data changed
data_changed = len(new_intervals) > 0 or len(intervals_to_touch) > 0 or gc_changed_data
if data_changed and self._hass is not None and self._entry_id is not None:
@ -848,6 +917,9 @@ class TibberPricesIntervalPool:
"version": 1,
"home_id": self._home_id,
"fetch_groups": serialized_fetch_groups,
# DST fall-back extras: CET duplicates of fall-back 02:xx intervals.
# Only non-empty on/after fall-back nights; typically {} all year.
"dst_extras": self._dst_extras,
}
@classmethod
@ -910,4 +982,8 @@ class TibberPricesIntervalPool:
total_intervals,
)
# Restore DST fall-back extras (CET duplicates of fall-back 02:xx intervals).
# Typically empty ({}) except in the days following a fall-back night.
manager._dst_extras = data.get("dst_extras", {})
return manager