mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-04-07 08:03:40 +00:00
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:
parent
ce049e48b1
commit
8975aef900
1 changed files with 76 additions and 0 deletions
|
|
@ -34,6 +34,11 @@ INTERVAL_QUARTER_HOURLY = 15
|
||||||
# Debounce delay for auto-save (seconds)
|
# Debounce delay for auto-save (seconds)
|
||||||
DEBOUNCE_DELAY_SECONDS = 3.0
|
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:
|
def _normalize_starts_at(starts_at: datetime | str) -> str:
|
||||||
"""Normalize startsAt to consistent format (YYYY-MM-DDTHH:MM:SS)."""
|
"""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_debounce_task: asyncio.Task | None = None
|
||||||
self._save_lock = asyncio.Lock()
|
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(
|
async def get_intervals(
|
||||||
self,
|
self,
|
||||||
api_client: TibberPricesApiClient,
|
api_client: TibberPricesApiClient,
|
||||||
|
|
@ -582,6 +596,12 @@ class TibberPricesIntervalPool:
|
||||||
# (e.g., parse_all_timestamps() converts startsAt to datetime in-place)
|
# (e.g., parse_all_timestamps() converts startsAt to datetime in-place)
|
||||||
result.append(dict(interval))
|
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
|
# Move to next expected interval
|
||||||
current_naive += timedelta(minutes=interval_minutes)
|
current_naive += timedelta(minutes=interval_minutes)
|
||||||
|
|
||||||
|
|
@ -599,6 +619,47 @@ class TibberPricesIntervalPool:
|
||||||
|
|
||||||
return result
|
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(
|
def _add_intervals(
|
||||||
self,
|
self,
|
||||||
intervals: list[dict[str, Any]],
|
intervals: list[dict[str, Any]],
|
||||||
|
|
@ -633,6 +694,9 @@ class TibberPricesIntervalPool:
|
||||||
starts_at_normalized = _normalize_starts_at(interval["startsAt"])
|
starts_at_normalized = _normalize_starts_at(interval["startsAt"])
|
||||||
if not self._index.contains(starts_at_normalized):
|
if not self._index.contains(starts_at_normalized):
|
||||||
new_intervals.append(interval)
|
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:
|
else:
|
||||||
intervals_to_touch.append((starts_at_normalized, interval))
|
intervals_to_touch.append((starts_at_normalized, interval))
|
||||||
_LOGGER_DETAILS.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
|
|
@ -676,6 +740,11 @@ class TibberPricesIntervalPool:
|
||||||
# Run GC to evict old fetch groups if needed
|
# Run GC to evict old fetch groups if needed
|
||||||
gc_changed_data = self._gc.run_gc()
|
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
|
# Schedule debounced auto-save if data changed
|
||||||
data_changed = len(new_intervals) > 0 or len(intervals_to_touch) > 0 or gc_changed_data
|
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:
|
if data_changed and self._hass is not None and self._entry_id is not None:
|
||||||
|
|
@ -848,6 +917,9 @@ class TibberPricesIntervalPool:
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"home_id": self._home_id,
|
"home_id": self._home_id,
|
||||||
"fetch_groups": serialized_fetch_groups,
|
"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
|
@classmethod
|
||||||
|
|
@ -910,4 +982,8 @@ class TibberPricesIntervalPool:
|
||||||
total_intervals,
|
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
|
return manager
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue