Skip to content

Commit 4752507

Browse files
perf(ha): fetch long history windows in chunks to bound peak memory
Predbat fetches up to 22 days of history for several sensors on its first plan cycle. requests computes .json() as loads(self.text), and .text decodes .content, so during the parse the same payload is resident three times over - raw bytes, decoded string and parsed objects. For load_power that is 16.3MB of JSON and 54,000 states, and profiling showed those three representations dominating the peak memory of a run. Split windows longer than HISTORY_CHUNK_DAYS across several requests so only one chunk's three representations are live at a time. get_history_window() is extracted for the single request and get_history() assembles the chunks. Home Assistant opens every history window with the state in effect at start_time, synthesised rather than recorded. For chunks after the first that instant is already covered by the previous chunk, so the record is a duplicate. It cannot simply be ignored as harmless: when a boundary lands inside a gap in the recording it adds a data point a single request never returns, and minute_data then interpolates across that gap differently. Against 21 days of real history one boundary in six did exactly that, moving 12 minutes of load data. Dropping records at or before each chunk's start makes the state count match a single fetch exactly and the resulting minute data byte-identical, at every chunk size tested. Measured over three plan cycles against a live Home Assistant, 22 day window: chunk size peak RSS settled RSS cold cycle none 221.3MB 186.8MB 9.84s 3 days 188.7MB 173.1MB 9.43s 1 day 176.4MB 172.3MB 11.61s Three days is the default: it takes essentially all the steady-state saving and three quarters of the peak saving at no measurable time cost, where one day adds 1.8s of startup for 7 requests per sensor rather than 21. Warm cycles are unaffected either way - the history cache refreshes with a small delta fetch that stays under the chunk threshold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e86772e commit 4752507

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)