From 691e917c0a39974e0fcc5478a81541acd37f0062 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:14:20 +0000 Subject: [PATCH] fix(services): ceil search_start to next quarter when include_current_interval=false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When include_current_interval=False, the search_start was set to the raw now timestamp (e.g. 14:47:00.167996). The interval pool index iterates in 15-minute steps from that exact value, so keys like 14:47:00, 15:02:00, … never matched any cached interval (which are always on :00/:15/:30/:45 boundaries), causing no data to be found and returning no_data_in_range. Fix: replace `now` with `ceil_to_next_quarter_hour(now)` which is always `floor(now) + 15min`. This skips the interval that is currently in progress (14:30–14:45 interval) and starts at the first interval that hasn't begun yet (15:00), consistent with the intent of include_current_interval=False. Fixes #190 User-Impact: find_cheapest_block/find_most_expensive_block/find_cheapest_hours with include_current_interval=false and search_scope=next_24h (or no scope) no longer returns no_data_in_range when data is available. Agent-Logs-Url: https://github.com/jpawlowski/hass.tibber_prices/sessions/0ea804f9-7c98-4e5a-b40f-469954fa9daa Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com> --- .../tibber_prices/services/helpers.py | 22 +++++++- tests/services/test_search_range.py | 55 ++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/custom_components/tibber_prices/services/helpers.py b/custom_components/tibber_prices/services/helpers.py index d6036e7..07901ff 100644 --- a/custom_components/tibber_prices/services/helpers.py +++ b/custom_components/tibber_prices/services/helpers.py @@ -408,6 +408,22 @@ def floor_to_quarter_hour(dt_value: datetime) -> datetime: return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0) +def ceil_to_next_quarter_hour(dt_value: datetime) -> datetime: + """Return the start of the next quarter-hour interval after dt_value. + + Always advances past the interval that contains dt_value, even when + dt_value falls exactly on a boundary (in which case that interval is + considered "current" and is skipped). + + This is used when include_current_interval=False to align the search start + with the first interval boundary in the pool index that has not yet started. + Without this, a raw ``now`` timestamp like ``14:47:00.167996`` would not + match any cached key (which are always on :00/:15/:30/:45) and would return + no intervals. + """ + return floor_to_quarter_hour(dt_value) + timedelta(minutes=INTERVAL_MINUTES) + + def _resolve_time_with_day_offset( time_value: dt_time, day_offset: int, @@ -451,7 +467,7 @@ def _resolve_scope( tomorrow_start = today_start + timedelta(days=1) day_after_start = today_start + timedelta(days=2) - rolling_start = floor_to_quarter_hour(now) if include_current else now + rolling_start = floor_to_quarter_hour(now) if include_current else ceil_to_next_quarter_hour(now) if scope == "today": return today_start, tomorrow_start @@ -608,8 +624,10 @@ def resolve_search_range( search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"]) if include_current: search_start = floor_to_quarter_hour(search_start) + else: + search_start = ceil_to_next_quarter_hour(search_start) else: - search_start = floor_to_quarter_hour(now) if include_current else now + search_start = floor_to_quarter_hour(now) if include_current else ceil_to_next_quarter_hour(now) # --- Resolve end --- if "search_end" in call_data: diff --git a/tests/services/test_search_range.py b/tests/services/test_search_range.py index 3a1c3f3..0f5abcb 100644 --- a/tests/services/test_search_range.py +++ b/tests/services/test_search_range.py @@ -150,14 +150,17 @@ class TestResolveSearchRangeNegativeOffsetMinutes: assert start.hour == 23 def test_search_scope_excludes_current_interval_when_disabled(self) -> None: - """Relative search scopes honor include_current_interval=false.""" + """Relative search scopes ceil to next quarter boundary when include_current_interval=False.""" now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN) call_data = { "search_scope": "next_24h", "include_current_interval": False, } start, end = resolve_search_range(call_data, now, BERLIN) - assert start == now + # 14:37 is not on a quarter boundary → should ceil to 14:45 + assert start.hour == 14 + assert start.minute == 45 + assert start.second == 0 assert end == now + timedelta(hours=24) def test_search_scope_includes_current_interval_when_enabled(self) -> None: @@ -172,6 +175,54 @@ class TestResolveSearchRangeNegativeOffsetMinutes: assert start.minute == 30 assert end == now + timedelta(hours=24) + def test_exclude_current_interval_with_sub_second_now(self) -> None: + """Regression: microseconds in now caused no intervals to be returned. + + When include_current_interval=False and now has sub-second precision + (e.g. 14:47:00.167996), the search_start must be ceiled to the next + quarter boundary (15:00) so it aligns with actual interval timestamps + in the pool index. Previously, raw now was used, which matched no + cached interval and returned no data. + """ + # Reproduce the exact scenario from the bug report: 14:47:00.167996+02:00 + now = datetime(2026, 7, 27, 14, 47, 0, 167996, tzinfo=ZoneInfo("Europe/Berlin")) + call_data = { + "search_scope": "next_24h", + "include_current_interval": False, + } + start, end = resolve_search_range(call_data, now, ZoneInfo("Europe/Berlin")) + # 14:47:00.167996 → floor → 14:45 + 15min → 15:00 + assert start.hour == 15 + assert start.minute == 0 + assert start.second == 0 + assert start.microsecond == 0 + + def test_exclude_current_interval_already_on_boundary(self) -> None: + """When now is exactly on a quarter boundary and include_current_interval=False, + the start is advanced to the NEXT boundary (the interval that hasn't started yet). + """ + # 14:45:00 exactly → the 14:45 interval is currently in progress + # → exclude it → ceil to 15:00 + now = datetime(2026, 4, 11, 14, 45, 0, tzinfo=BERLIN) + call_data = { + "search_scope": "next_24h", + "include_current_interval": False, + } + start, end = resolve_search_range(call_data, now, BERLIN) + # 14:45:00 is exactly on a boundary → but the 14:45 interval is "current" + # The correct behaviour: include_current=False should start at 15:00 + assert start.hour == 15 + assert start.minute == 0 + + def test_exclude_current_default_no_scope_ceils_to_next_quarter(self) -> None: + """Default path (no scope) with include_current_interval=False also ceils.""" + now = datetime(2026, 4, 11, 14, 47, 30, tzinfo=BERLIN) + call_data = {"include_current_interval": False} + start, _end = resolve_search_range(call_data, now, BERLIN) + assert start.hour == 15 + assert start.minute == 0 + assert start.second == 0 + # ============================================================================= # Schema validation: day_offset boundaries