mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
fix(services): ceil search_start to next quarter when include_current_interval=false
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>
This commit is contained in:
parent
fe656499fb
commit
691e917c0a
2 changed files with 73 additions and 4 deletions
|
|
@ -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
|
||||
|
|
@ -609,7 +625,9 @@ def resolve_search_range(
|
|||
if include_current:
|
||||
search_start = floor_to_quarter_hour(search_start)
|
||||
else:
|
||||
search_start = floor_to_quarter_hour(now) if include_current else now
|
||||
search_start = ceil_to_next_quarter_hour(search_start)
|
||||
else:
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue