Skip to content

Commit 7b2ea4f

Browse files
Merge pull request #4558 from springfall2008/fix/history-fetch-chunking
2 parents 40f5dfe + 4752507 commit 7b2ea4f

3 files changed

Lines changed: 227 additions & 7 deletions

File tree

apps/predbat/ha.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
from const import TIME_FORMAT_HA, TIMEOUT, TIME_FORMAT_HA_TZ
3333
from component_base import ComponentBase
3434

35+
# Maximum days of history fetched per request. Long windows are split so only one chunk's
36+
# response body, decoded string and parsed objects are resident at a time.
37+
HISTORY_CHUNK_DAYS = 3
38+
3539

3640
class RunThread(threading.Thread):
3741
def __init__(self, coro):
@@ -869,12 +873,36 @@ def update_states(self):
869873
else:
870874
self.log("Warn: Failed to update state data from HA")
871875

872-
def get_history(self, sensor, now, days=30, from_time=None, force_db=False):
876+
def get_history_window(self, sensor, start, end):
877+
"""
878+
Fetch a single window of history for a sensor.
879+
880+
:param sensor: The sensor to get the history for.
881+
:param start: Start of the window.
882+
:param end: End of the window.
883+
:return: The raw API response, or None.
884+
"""
885+
res = self.api_call("/api/history/period/{}".format(start.strftime(TIME_FORMAT_HA)), {"filter_entity_id": sensor, "end_time": end.strftime(TIME_FORMAT_HA)})
886+
if isinstance(res, list) and len(res) > 0:
887+
return res
888+
return None
889+
890+
def get_history(self, sensor, now, days=30, from_time=None, force_db=False, chunk_days=HISTORY_CHUNK_DAYS):
873891
"""
874892
Get the history for a sensor from Home Assistant.
875893
894+
Long windows are fetched in chunks so only one chunk's response body, decoded string
895+
and parsed objects are resident at a time rather than the whole window's. A 21 day
896+
window of a power sensor is tens of megabytes of JSON, and holding all three
897+
representations of it at once dominated the peak memory of a plan cycle.
898+
876899
:param sensor: The sensor to get the history for.
877-
:return: The history for the sensor.
900+
:param now: Current time, the end of the window.
901+
:param days: How many days of history to fetch.
902+
:param from_time: Explicit window start, overriding days.
903+
:param force_db: Read from the database rather than Home Assistant.
904+
:param chunk_days: Maximum days per request; 0 or None fetches the window in one request.
905+
:return: The history for the sensor, oldest first, or None.
878906
"""
879907
if not sensor:
880908
return None
@@ -889,11 +917,28 @@ def get_history(self, sensor, now, days=30, from_time=None, force_db=False):
889917
else:
890918
start = now - timedelta(days=days)
891919
end = now
892-
res = self.api_call("/api/history/period/{}".format(start.strftime(TIME_FORMAT_HA)), {"filter_entity_id": sensor, "end_time": end.strftime(TIME_FORMAT_HA)})
893-
if isinstance(res, list) and len(res) > 0:
894-
return res
895-
else:
896-
return None
920+
921+
if not chunk_days or (end - start) <= timedelta(days=chunk_days):
922+
return self.get_history_window(sensor, start, end)
923+
924+
history = []
925+
cursor = start
926+
while cursor < end:
927+
window_end = min(cursor + timedelta(days=chunk_days), end)
928+
res = self.get_history_window(sensor, cursor, window_end)
929+
if res:
930+
for item in res[0]:
931+
# Home Assistant opens every window with the state in effect at start_time.
932+
# For chunks after the first that instant is already covered by the previous
933+
# chunk, and the synthesised record can land inside a gap in the recording
934+
# where it would add a data point a single request never returns, changing
935+
# how minute_data interpolates across that gap.
936+
if cursor > start and item.get("last_updated") and str2time(item["last_updated"]) <= cursor:
937+
continue
938+
history.append(item)
939+
cursor = window_end
940+
941+
return [history] if history else None
897942

898943
async def set_state_external(self, entity_id, state, attributes={}):
899944
"""
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# fmt: off
2+
"""
3+
History Chunking Tests
4+
5+
Tests for fetching long history windows from Home Assistant in time-ordered chunks
6+
rather than one request, so only one chunk's response is resident at a time:
7+
- get_history() splits long windows and covers them exactly
8+
- the synthesised record Home Assistant returns at each chunk start is dropped
9+
"""
10+
11+
from datetime import datetime, timedelta, timezone
12+
13+
from tests.test_hainterface_common import MockBase, create_ha_interface
14+
15+
16+
NOW = datetime(2026, 8, 16, 12, 0, 0, tzinfo=timezone.utc)
17+
18+
19+
def _interface():
20+
"""Build an HAInterface against the shared mock base."""
21+
return create_ha_interface(MockBase(), ha_key="test_key", db_enable=False, db_mirror_ha=False, db_primary=False)
22+
23+
24+
def _record(stamp, state):
25+
"""Build one history record."""
26+
return {"state": state, "last_updated": stamp.isoformat(), "attributes": {"unit_of_measurement": "W"}}
27+
28+
29+
def _capture(interface, responder):
30+
"""Point api_call at a responder and record the windows requested."""
31+
calls = []
32+
33+
def api_call(endpoint, data_in=None, **kwargs):
34+
"""Stand in for the REST call, recording the requested window."""
35+
start = endpoint.rsplit("/", 1)[1]
36+
calls.append((start, data_in.get("end_time")))
37+
return responder(start, data_in.get("end_time"))
38+
39+
interface.api_call = api_call
40+
return calls
41+
42+
43+
def test_get_history_drops_synthesised_boundary_record(my_predbat=None):
44+
"""Home Assistant's state-at-window-start record must not be added twice"""
45+
print("\n=== Testing get_history() boundary record handling ===")
46+
failed = 0
47+
48+
interface = _interface()
49+
50+
def responder(start, end):
51+
"""Return a synthesised record at the window start plus one real record."""
52+
start_dt = datetime.fromisoformat(start)
53+
# Home Assistant reports the state in effect at start_time, exactly on the boundary
54+
# and with no sub-second precision, then any real changes within the window
55+
return [[_record(start_dt, "500"), _record(start_dt + timedelta(hours=1), "600")]]
56+
57+
_capture(interface, responder)
58+
59+
result = interface.get_history("sensor.load_power", NOW, days=9, chunk_days=3)
60+
61+
if not result or not result[0]:
62+
print("ERROR: expected history back, got {}".format(result))
63+
return failed + 1
64+
65+
items = result[0]
66+
stamps = [item["last_updated"] for item in items]
67+
if len(stamps) != len(set(stamps)):
68+
duplicates = sorted({s for s in stamps if stamps.count(s) > 1})
69+
print("ERROR: chunk boundaries duplicated records: {}".format(duplicates))
70+
failed += 1
71+
else:
72+
print("✓ no duplicate timestamps across chunk boundaries")
73+
74+
# 3 chunks: chunk 1 contributes both records, chunks 2 and 3 contribute only their real one
75+
if len(items) != 4:
76+
print("ERROR: expected 4 records (2 from the first chunk, 1 from each later chunk), got {}".format(len(items)))
77+
print(" records: {}".format(stamps))
78+
failed += 1
79+
else:
80+
print("✓ synthesised boundary record dropped for chunks after the first")
81+
82+
if stamps != sorted(stamps):
83+
print("ERROR: records not in oldest-first order: {}".format(stamps))
84+
failed += 1
85+
else:
86+
print("✓ records stay oldest-first across chunks")
87+
88+
return failed
89+
90+
91+
def test_get_history_short_window_makes_one_request(my_predbat=None):
92+
"""A window inside the chunk size is fetched in a single request as before"""
93+
print("\n=== Testing get_history() short window ===")
94+
failed = 0
95+
96+
interface = _interface()
97+
calls = _capture(interface, lambda start, end: [[_record(NOW - timedelta(hours=1), "500")]])
98+
99+
interface.get_history("sensor.load_power", NOW, days=2, chunk_days=7)
100+
101+
if len(calls) != 1:
102+
print("ERROR: expected 1 request for a 2 day window, got {}".format(len(calls)))
103+
failed += 1
104+
else:
105+
print("✓ short window still fetched in one request")
106+
107+
return failed
108+
109+
110+
def test_get_history_chunks_cover_the_window_exactly(my_predbat=None):
111+
"""Chunks tile the requested window end to end with no gaps or overlap"""
112+
print("\n=== Testing get_history() window coverage ===")
113+
failed = 0
114+
115+
interface = _interface()
116+
calls = _capture(interface, lambda start, end: [[_record(datetime.fromisoformat(start) + timedelta(hours=1), "500")]])
117+
118+
interface.get_history("sensor.load_power", NOW, days=10, chunk_days=3)
119+
120+
if len(calls) != 4:
121+
print("ERROR: expected 4 chunks for 10 days at 3 days each, got {}".format(len(calls)))
122+
return failed + 1
123+
print("✓ 10 day window split into 4 chunks of at most 3 days")
124+
125+
starts = [datetime.fromisoformat(start) for start, _ in calls]
126+
ends = [datetime.fromisoformat(end) for _, end in calls]
127+
128+
if starts[0] != NOW - timedelta(days=10):
129+
print("ERROR: first chunk starts at {}, expected {}".format(starts[0], NOW - timedelta(days=10)))
130+
failed += 1
131+
elif ends[-1] != NOW:
132+
print("ERROR: last chunk ends at {}, expected {}".format(ends[-1], NOW))
133+
failed += 1
134+
else:
135+
print("✓ chunks span exactly the requested window")
136+
137+
for index in range(1, len(starts)):
138+
if starts[index] != ends[index - 1]:
139+
print("ERROR: chunk {} starts at {} but previous ended at {}".format(index, starts[index], ends[index - 1]))
140+
failed += 1
141+
break
142+
else:
143+
print("✓ chunks are contiguous with no gaps or overlap")
144+
145+
if max((ends[i] - starts[i]) for i in range(len(starts))) > timedelta(days=3):
146+
print("ERROR: a chunk exceeded the 3 day limit")
147+
failed += 1
148+
else:
149+
print("✓ no chunk exceeds the requested chunk size")
150+
151+
return failed
152+
153+
154+
def run_history_chunking_tests(my_predbat=None):
155+
"""Run all history chunking tests"""
156+
print("\n" + "=" * 80)
157+
print("History Chunking Tests")
158+
print("=" * 80)
159+
160+
failed = 0
161+
failed += test_get_history_drops_synthesised_boundary_record(my_predbat)
162+
failed += test_get_history_short_window_makes_one_request(my_predbat)
163+
failed += test_get_history_chunks_cover_the_window_exactly(my_predbat)
164+
165+
print("\n" + "=" * 80)
166+
if failed == 0:
167+
print("✅ All history chunking tests passed!")
168+
else:
169+
print(f"❌ {failed} history chunking test(s) failed")
170+
print("=" * 80)
171+
172+
return failed

apps/predbat/unit_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
from tests.test_hainterface_service import run_hainterface_service_tests
7979
from tests.test_hainterface_lifecycle import run_hainterface_lifecycle_tests
8080
from tests.test_hainterface_websocket import run_hainterface_websocket_tests
81+
from tests.test_history_chunking import run_history_chunking_tests
8182
from tests.test_web_if import run_test_web_if
8283
from tests.test_web_chart_currency import test_rates_chart_series_names_use_currency_symbol
8384
from tests.test_metrics_dashboard_soc_refresh import test_soc_chart_center_text_reads_live_data
@@ -489,6 +490,8 @@ def main():
489490
("hainterface_lifecycle", run_hainterface_lifecycle_tests, "HAInterface lifecycle tests", False),
490491
# HAInterface websocket tests
491492
("hainterface_websocket", run_hainterface_websocket_tests, "HAInterface websocket tests", False),
493+
# History chunking (long windows fetched in pieces) tests
494+
("history_chunking", run_history_chunking_tests, "History chunking tests", False),
492495
# Carbon Intensity API unit tests
493496
("carbon", test_carbon, "Carbon Intensity API comprehensive tests (fetch, cache, publish, config)", False),
494497
# Storage component unit tests

0 commit comments

Comments
 (0)