From ce049e48b157c2f46d10f5a8d5cec4a6b284ee62 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Mon, 6 Apr 2026 13:57:03 +0000 Subject: [PATCH] fix(interval_pool): use UTC-aware gap detection to prevent spring-forward false positives _get_sensor_interval_stats() computed expected_count via UTC time arithmetic ((end - start).total_seconds() / 900 = 480 for 5 days), then iterated through fixed-offset local timestamps adding timedelta(minutes=15). On DST spring-forward days (e.g. last Sunday March in EU), clocks skip from 02:00 to 03:00. The 4 local quarter-hour slots 02:00-02:45 never exist, so the Tibber API never returns intervals for them. The iteration still visits those 4 keys, finds them absent from the index, and reports has_gaps=True (expected=480, actual=476). Since no API call can ever fill those non-existent slots, the pool triggers an unnecessary gap-fill fetch every 15 minutes for the entire spring-forward day. Fix: keep the nominal expected_count for diagnostics, but determine has_gaps via the new _has_real_gaps_in_range() helper that sorts cached intervals by UTC time and checks consecutive UTC differences. The 01:45+01:00 -> 03:00+02:00 transition is exactly 15 minutes in UTC, so no gap is reported. Start/end boundary comparisons use naive 19-char local timestamps to stay consistent with the fixed-offset arithmetic used by get_protected_range(). Impact: No spurious API fetches on DST spring-forward Sunday. Gap detection for real missing data (API failures, first startup) remains fully functional. --- .../tibber_prices/interval_pool/manager.py | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/custom_components/tibber_prices/interval_pool/manager.py b/custom_components/tibber_prices/interval_pool/manager.py index 7d2a75f..4c124a9 100644 --- a/custom_components/tibber_prices/interval_pool/manager.py +++ b/custom_components/tibber_prices/interval_pool/manager.py @@ -382,20 +382,28 @@ class TibberPricesIntervalPool: Get statistics for sensor intervals (protected range). Protected range: day-before-yesterday 00:00 to day-after-tomorrow 00:00. - Expected: 4 days * 24 hours * 4 intervals = 384 intervals. + Expected: ~480 intervals (5 days x 96 quarter-hourly slots). Returns: Dict with count, expected, and has_gaps. + Note on DST: + expected_count is derived from UTC-duration arithmetic and will be + off by ±4 on spring-forward/fall-back days. has_gaps uses a + separate UTC-aware consecutive-interval check that is immune to this. + """ start_iso, end_iso = self._cache.get_protected_range() start_dt = datetime.fromisoformat(start_iso) end_dt = datetime.fromisoformat(end_iso) - # Count expected intervals (15-min resolution) + # Nominal expected count for diagnostics. On spring-forward days this + # over-estimates by 4 (the 4 non-existent 02:xx local slots); on + # fall-back days it under-estimates by 4. The has_gaps flag is + # determined independently via UTC-aware logic below. expected_count = int((end_dt - start_dt).total_seconds() / (15 * 60)) - # Count actual intervals in range + # Count actual intervals by naive-key iteration (matches index format) actual_count = 0 current_dt = start_dt @@ -408,9 +416,84 @@ class TibberPricesIntervalPool: return { "count": actual_count, "expected": expected_count, - "has_gaps": actual_count < expected_count, + "has_gaps": self._has_real_gaps_in_range(start_iso, end_iso), } + def _has_real_gaps_in_range(self, start_iso: str, end_iso: str) -> bool: + """ + Check for coverage gaps using UTC-aware consecutive-interval comparison. + + The naive key-counting approach in _get_sensor_interval_stats() visits + 'phantom' timestamps that don't exist on DST spring-forward days + (e.g. 02:00-02:45 when clocks jump 02:00→03:00). Those slots are + permanently absent from the index, producing a false positive every + spring-forward day until the next HA restart. + + This method avoids the problem by comparing consecutive cached intervals + via their UTC times: the jump from 01:45+01:00 (CET) to 03:00+02:00 + (CEST) is exactly 15 minutes in UTC, so no gap is reported. + + Boundary comparisons (first/last interval vs start/end of protected + range) use the naive 19-char local time representation to stay + consistent with the fixed-offset arithmetic used by get_protected_range(). + + Args: + start_iso: ISO start of the protected range (inclusive). + end_iso: ISO end of the protected range (exclusive). + + Returns: + True if a real gap exists, False if the range is fully covered. + + """ + from datetime import UTC # noqa: PLC0415 - UTC constant needed here only + + cached_intervals = self._get_cached_intervals(start_iso, end_iso) + + if not cached_intervals: + return True + + resolution_change_utc = datetime(2025, 10, 1, tzinfo=UTC) + # 1-minute tolerance for scheduling jitter / minor timestamp variations + tolerance_s = 60 + + # Sort by actual UTC time so ordering is correct across DST boundaries + sorted_intervals = sorted( + cached_intervals, + key=lambda x: datetime.fromisoformat(x["startsAt"]), + ) + + # --- Boundary check: gap before first interval --- + # Compare naive local times so we don't confuse a legitimate +01:00/+02:00 + # offset mismatch (caused by fixed-offset arithmetic in get_protected_range) + # with a real missing hour. + first_naive_str = sorted_intervals[0]["startsAt"][:19] + start_naive_str = start_iso[:19] + # datetime.fromisoformat on a naive string → naive datetime (intentional) + first_naive_dt = datetime.fromisoformat(first_naive_str) + start_naive_dt = datetime.fromisoformat(start_naive_str) + if (first_naive_dt - start_naive_dt).total_seconds() > 15 * 60 + tolerance_s: + return True + + # --- Interior check: gap between consecutive intervals (UTC-based) --- + for i in range(len(sorted_intervals) - 1): + current_dt = datetime.fromisoformat(sorted_intervals[i]["startsAt"]) + next_dt = datetime.fromisoformat(sorted_intervals[i + 1]["startsAt"]) + diff_s = (next_dt - current_dt).total_seconds() + expected_s = 900 if current_dt.astimezone(UTC) >= resolution_change_utc else 3600 + if diff_s > expected_s + tolerance_s: + return True # Real gap between consecutive intervals + + # --- Boundary check: gap after last interval --- + # Same naive-time logic as the start boundary. + last_naive_str = sorted_intervals[-1]["startsAt"][:19] + end_naive_str = end_iso[:19] + last_naive_dt = datetime.fromisoformat(last_naive_str) + end_naive_dt = datetime.fromisoformat(end_naive_str) + last_dt_utc = datetime.fromisoformat(sorted_intervals[-1]["startsAt"]) + expected_last_s = 900 if last_dt_utc.astimezone(UTC) >= resolution_change_utc else 3600 + last_end_naive_dt = last_naive_dt + timedelta(seconds=expected_last_s) + return (end_naive_dt - last_end_naive_dt).total_seconds() >= expected_last_s + def _has_gaps_in_protected_range(self) -> bool: """ Check if there are gaps in the protected date range.