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)
|
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(
|
def _resolve_time_with_day_offset(
|
||||||
time_value: dt_time,
|
time_value: dt_time,
|
||||||
day_offset: int,
|
day_offset: int,
|
||||||
|
|
@ -451,7 +467,7 @@ def _resolve_scope(
|
||||||
tomorrow_start = today_start + timedelta(days=1)
|
tomorrow_start = today_start + timedelta(days=1)
|
||||||
day_after_start = today_start + timedelta(days=2)
|
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":
|
if scope == "today":
|
||||||
return today_start, tomorrow_start
|
return today_start, tomorrow_start
|
||||||
|
|
@ -608,8 +624,10 @@ def resolve_search_range(
|
||||||
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
|
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
|
||||||
if include_current:
|
if include_current:
|
||||||
search_start = floor_to_quarter_hour(search_start)
|
search_start = floor_to_quarter_hour(search_start)
|
||||||
|
else:
|
||||||
|
search_start = ceil_to_next_quarter_hour(search_start)
|
||||||
else:
|
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 ---
|
# --- Resolve end ---
|
||||||
if "search_end" in call_data:
|
if "search_end" in call_data:
|
||||||
|
|
|
||||||
|
|
@ -150,14 +150,17 @@ class TestResolveSearchRangeNegativeOffsetMinutes:
|
||||||
assert start.hour == 23
|
assert start.hour == 23
|
||||||
|
|
||||||
def test_search_scope_excludes_current_interval_when_disabled(self) -> None:
|
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)
|
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
|
||||||
call_data = {
|
call_data = {
|
||||||
"search_scope": "next_24h",
|
"search_scope": "next_24h",
|
||||||
"include_current_interval": False,
|
"include_current_interval": False,
|
||||||
}
|
}
|
||||||
start, end = resolve_search_range(call_data, now, BERLIN)
|
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)
|
assert end == now + timedelta(hours=24)
|
||||||
|
|
||||||
def test_search_scope_includes_current_interval_when_enabled(self) -> None:
|
def test_search_scope_includes_current_interval_when_enabled(self) -> None:
|
||||||
|
|
@ -172,6 +175,54 @@ class TestResolveSearchRangeNegativeOffsetMinutes:
|
||||||
assert start.minute == 30
|
assert start.minute == 30
|
||||||
assert end == now + timedelta(hours=24)
|
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
|
# Schema validation: day_offset boundaries
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue