Skip to content

Commit 2ac9d2d

Browse files
fix(web): stop the /entity page reporting a status it never held (#4304)
The /entity page showed two GivEnergy Cloud inverters' status sensors disagreeing with themselves: the timeline chart drew one continuous "Normal" bar while the history table reported "Lost" for the same hours, and a table row could report "Lost" while every 5-minute slot listed under it read "Normal". Both views were wrong, in different ways. History table (build_entity_history_table_data): - A row reported the last sample taken inside its window rather than the state across it, so one momentary "Lost" record at 21:57 made the whole 21:30 row read "Lost". Carry-forward then repeated that value into every later slot with no sample of its own, turning a blip into hours of apparent downtime. Every 30-minute row and 5-minute slot now reports the state as of its own timestamp - the most recent record at or before it - via a new state_as_of_slots() helper. Note this is the rule 7248a75 (#4291) established when it fixed buckets keeping the oldest sample instead of the newest; the bucket-summary idea itself was the problem. - Detail rows were the offsets -5..-25, i.e. the half hour BEFORE the row, so expanding a row described a different window and contradicted the row it sat under. They are now the 5-minute marks inside the row's own window. Timeline chart (render_timeline_chart): - State records were downsampled by array index (every Nth, max 288) before being folded into ranges. For a categorical series that does not lower the resolution, it rewrites history: whenever the step kept landing on one phase of a flapping signal, every occurrence of the other state was erased and the survivors merged into one long run. 8640 records alternating Normal/Lost every minute rendered as a single 8639-minute "Normal" bar. Ranges are now built from every record; only transitions emit a range, so an entity that rarely changes stays cheap. - Records were sorted by raw timestamp string. HA/DB history is UTC while get_history_with_now() appends the current state stamped in local time, so a string sort ordered records by their offset text and could truncate the timeline early. Timestamps are parsed once and sorted by instant. - A rangeBar point's "x" is the y-axis category, and every GivEnergy Cloud inverter publishes a status sensor named "Status", so both inverters collapsed onto a single unlabelled row. Colliding names are now disambiguated with the entity_id. Two consequences of the table change: a blip falling between two 5-minute marks is no longer visible there (the chart shows every transition), and the oldest row can read "-" where nothing had been recorded by that timestamp instead of borrowing a reading taken later in the window. Extends tests/test_web_history_table.py and tests/test_web_charts.py; the new assertions were each confirmed to fail against the unfixed code. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7b5fcdb commit 2ac9d2d

3 files changed

Lines changed: 262 additions & 193 deletions

File tree

apps/predbat/tests/test_web_charts.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
# pylint: disable=line-too-long
99
# pylint: disable=attribute-defined-outside-init
1010

11+
import re
12+
from datetime import datetime, timedelta, timezone
13+
1114
from web import WebInterface
1215

1316

@@ -16,6 +19,83 @@ def make_web(my_predbat):
1619
return WebInterface(my_predbat, web_port=5053)
1720

1821

22+
def parse_timeline_ranges(html):
23+
"""Extract the rendered timeline ranges as a list of (category, label, start_ms, end_ms)."""
24+
pattern = r"x: '([^']*)',\s*\n\s*y: \[(\d+), (\d+)\],\s*\n\s*fillColor: '[^']*',\s*\n\s*label: '([^']*)'"
25+
return [(m.group(1), m.group(4), int(m.group(2)), int(m.group(3))) for m in re.finditer(pattern, html)]
26+
27+
28+
def run_web_timeline_fidelity_tests(web):
29+
"""A flapping state must not be erased from the timeline chart, and same-named entities must not share a row."""
30+
failed = 0
31+
32+
# -------------------------------------------------------------------------
33+
# A status sensor that alternates Normal/Lost every minute for six days. The chart used to
34+
# downsample the raw samples by array index, so whenever the step landed on one phase of the
35+
# flap the other state vanished entirely and the chart claimed one continuous multi-day run -
36+
# contradicting the history table, which samples the same data per 5/30-minute bucket.
37+
print("Test: render_timeline_chart() does not erase a state when downsampling a long flapping history")
38+
now = datetime(2026, 7, 25, 14, 0, 0, tzinfo=timezone.utc)
39+
samples = 8640
40+
flapping = {}
41+
stamp = now - timedelta(minutes=samples)
42+
for index in range(samples):
43+
flapping[stamp.strftime("%Y-%m-%dT%H:%M:%S%z")] = "Lost" if index % 2 else "Normal"
44+
stamp += timedelta(minutes=1)
45+
46+
html = web.render_timeline_chart([{"name": "Status", "entity_id": "sensor.inverter_one_status", "data": flapping}], "chart_status", 7)
47+
ranges = parse_timeline_ranges(html)
48+
covered = {}
49+
for _, label, start, end in ranges:
50+
covered[label] = covered.get(label, 0) + (end - start)
51+
span = sum(covered.values())
52+
lost_share = covered.get("Lost", 0) / span if span else 0
53+
if lost_share < 0.25:
54+
print(f" ERROR: 'Lost' holds half the history but the timeline chart gives it {lost_share:.1%} of the span - the other state was aliased over it")
55+
failed += 1
56+
longest = max(((end - start) / 60000 for _, label, start, end in ranges if label == "Normal"), default=0)
57+
if longest > 60:
58+
print(f" ERROR: timeline chart reports a continuous {longest:.0f} minute 'Normal' run, but the state never stayed Normal for more than a minute")
59+
failed += 1
60+
61+
# -------------------------------------------------------------------------
62+
# Two inverters both publish a status sensor whose friendly name is "Status", so using the
63+
# display name as the rangeBar category collapsed both onto a single y-axis row, making it
64+
# impossible to tell which bar belonged to which inverter.
65+
print("Test: render_timeline_chart() gives same-named entities distinct rows")
66+
both = [
67+
{"name": "Status", "entity_id": "sensor.inverter_one_status", "data": {"2026-07-23T10:00:00+00:00": "Normal"}},
68+
{"name": "Status", "entity_id": "sensor.inverter_two_status", "data": {"2026-07-23T10:00:00+00:00": "Lost"}},
69+
]
70+
html = web.render_timeline_chart(both, "chart_status", 7)
71+
categories = {category for category, _, _, _ in parse_timeline_ranges(html)}
72+
if len(categories) < 2:
73+
print(f" ERROR: two entities sharing the friendly name 'Status' collapsed onto one timeline row: {sorted(categories)}")
74+
failed += 1
75+
76+
# -------------------------------------------------------------------------
77+
# get_history_with_now() appends the current state stamped in local time while HA/DB history
78+
# is UTC, so sorting the raw timestamp strings orders records by their text, not their instant.
79+
print("Test: render_timeline_chart() orders records by instant, not by raw timestamp string")
80+
mixed = {
81+
"2026-07-23T09:00:00+0000": "Normal",
82+
"2026-07-23T11:30:00+0200": "Lost", # 09:30 UTC - sorts after "11:00" as text
83+
"2026-07-23T10:00:00+0000": "Normal",
84+
}
85+
html = web.render_timeline_chart([{"name": "Status", "entity_id": "sensor.inverter_one_status", "data": mixed}], "chart_status", 7)
86+
mixed_ranges = parse_timeline_ranges(html)
87+
latest_ms = int(datetime(2026, 7, 23, 10, 0, 0, tzinfo=timezone.utc).timestamp() * 1000)
88+
covered_to = max((end for _, _, _, end in mixed_ranges), default=0)
89+
if covered_to != latest_ms:
90+
print(f" ERROR: timeline stops at {datetime.fromtimestamp(covered_to / 1000, timezone.utc)} instead of the newest record at 10:00 UTC - the 11:30+0200 record sorted last as text")
91+
failed += 1
92+
if [label for _, label, _, _ in mixed_ranges][:2] != ["Normal", "Lost"]:
93+
print(f" ERROR: expected the 09:30 UTC 'Lost' record to sort between the 09:00 and 10:00 UTC records, got range labels {[label for _, label, _, _ in mixed_ranges]}")
94+
failed += 1
95+
96+
return failed
97+
98+
1999
def run_web_charts_tests(my_predbat):
20100
"""Unit tests for chart rendering - entities with a '%' unit must still be able to chart."""
21101
failed = 0
@@ -69,4 +149,6 @@ def run_web_charts_tests(my_predbat):
69149
print(f" ERROR: expected render_heatmap_chart() to render via a sanitised 'chart_chart__' variable, got: {html}")
70150
failed += 1
71151

152+
failed += run_web_timeline_fidelity_tests(web)
153+
72154
return failed

0 commit comments

Comments
 (0)