Skip to content

Commit a2f65e5

Browse files
0xAHAclaude
andcommitted
test: pin the SPH 112-115 mapping and the off-grid-only AC discharge rule (#390)
Companion to the previous commit, which staged without the new file because git add -u only touches tracked paths. Covers both halves and both directions: that SPH reads the block as energy, that the total is a pair rather than a lone low word, that the reporter's raw values decode to the portal figure, that out-of-scope families were NOT swept up, and that removing the sensor from the shared group did not strip SPF and SPE which genuinely map registers 66/67. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 93c2a2a commit a2f65e5

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""AC charge/discharge energy on storage models (#390).
2+
3+
Protocol V1.39 gives input registers 112-115 two meanings, selected by device class:
4+
5+
| Reg | MAX-class string inverter | Storage Power (SPH, SPA) |
6+
|-----|---------------------------|--------------------------|
7+
| 112 | Warn Maincode | EACharge_Today_H |
8+
| 113 | real Power Percent | EACharge_Today_L |
9+
| 114 | inv start delay time | EACharge_Total_H |
10+
| 115 | bINVAllFaultCode | EACharge_Total_L |
11+
12+
The SPH profiles used to read **both** interpretations at once — 112 as `warning_code` and
13+
115 alone as `ac_charge_energy_total` — which cannot both be right on one device. Reading
14+
115 without its high word also capped the lifetime total at 6553.5 kWh.
15+
16+
A reporter's scan read 114=1, 115=5462, i.e. (1<<16)|5462 = 70998 → 7099.8 kWh, where 115
17+
alone said 546.2. His entity meanwhile showed 13820.7, which is registers 1058/1059 —
18+
battery charge total — copied over the top by the decode path.
19+
20+
And there is no AC-*discharge* counter anywhere in V1.39. It exists only in the off-grid
21+
protocol, so it belongs to SPF and SPE alone.
22+
"""
23+
from __future__ import annotations
24+
25+
import importlib
26+
27+
import pytest
28+
29+
_const = importlib.import_module("growatt_under_test.const")
30+
_dp = importlib.import_module("growatt_under_test.device_profiles")
31+
32+
REGISTER_MAPS = _const.REGISTER_MAPS
33+
34+
# Every profile in this file's scope: SPH is unambiguously a "Storage Power" model.
35+
SPH_MAPS = [k for k in REGISTER_MAPS if k.startswith("SPH_")]
36+
37+
# Deliberately untouched. The MAX-class ones legitimately read warn/fault codes there, and
38+
# for the XH hybrids there is no evidence either way — guessing is how wrong-but-plausible
39+
# values get shipped.
40+
NOT_IN_SCOPE = ("MIN_", "MID_", "MOD_", "MIC_", "TL3_S", "TL_XH")
41+
42+
43+
def _names(map_key):
44+
regs = REGISTER_MAPS[map_key]["input_registers"]
45+
return {a: regs[a]["name"] for a in (112, 113, 114, 115) if a in regs}
46+
47+
48+
def test_every_sph_profile_is_in_scope():
49+
"""Guards the parametrisation itself — an empty list would make this file vacuous."""
50+
assert len(SPH_MAPS) >= 6, f"expected the SPH family, found {SPH_MAPS}"
51+
52+
53+
@pytest.mark.parametrize("map_key", SPH_MAPS)
54+
def test_sph_reads_112_to_115_as_energy(map_key):
55+
assert _names(map_key) == {
56+
112: "ac_charge_energy_today_high",
57+
113: "ac_charge_energy_today_low",
58+
114: "ac_charge_energy_total_high",
59+
115: "ac_charge_energy_total_low",
60+
}, f"{map_key} does not read 112-115 under the Storage Power interpretation"
61+
62+
63+
@pytest.mark.parametrize("map_key", SPH_MAPS)
64+
def test_sph_no_longer_reads_a_warning_code_from_an_energy_register(map_key):
65+
"""The specific contradiction: one block, two interpretations."""
66+
assert "warning_code" not in _names(map_key).values()
67+
68+
69+
@pytest.mark.parametrize("map_key", SPH_MAPS)
70+
def test_the_total_is_a_pair_not_a_lone_low_word(map_key):
71+
"""115 alone at scale 0.1 wraps above 6553.5 kWh. The reporter was at 7099.8."""
72+
regs = REGISTER_MAPS[map_key]["input_registers"]
73+
assert regs[114]["pair"] == 115
74+
assert regs[115]["pair"] == 114
75+
assert regs[115]["combined_scale"] == 0.1
76+
assert regs[115]["combined_unit"] == "kWh"
77+
assert "scale" not in regs[115] or regs[115]["scale"] == 1, (
78+
"the low word still carries a 0.1 scale of its own, which would be applied "
79+
"before combining"
80+
)
81+
82+
83+
def test_the_reporters_values_decode_to_the_portal_figure():
84+
"""Arithmetic check against the real scan: 114=1, 115=5462."""
85+
high, low = 1, 5462
86+
assert ((high << 16) | low) * 0.1 == pytest.approx(7099.8)
87+
# ...and what the old mapping would have reported instead.
88+
assert low * 0.1 == pytest.approx(546.2)
89+
90+
91+
@pytest.mark.parametrize("prefix", NOT_IN_SCOPE)
92+
def test_out_of_scope_families_are_untouched(prefix):
93+
"""These were not part of the change and must not have been swept up by it."""
94+
affected = [
95+
k for k in REGISTER_MAPS
96+
if k.startswith(prefix)
97+
and REGISTER_MAPS[k]["input_registers"].get(112, {}).get("name")
98+
== "ac_charge_energy_today_high"
99+
]
100+
assert not affected, (
101+
f"{prefix}* profiles were changed to the Storage Power reading without evidence "
102+
f"that they use it: {affected}"
103+
)
104+
105+
106+
# --------------------------------------------------------------------------
107+
# ac_discharge_energy_total — off-grid only
108+
# --------------------------------------------------------------------------
109+
110+
def _profile_keys():
111+
keys = set()
112+
for value in _dp.PROFILE_DISPLAY_NAMES.values():
113+
if isinstance(value, dict):
114+
keys.update(v for v in value.values() if isinstance(v, str))
115+
else:
116+
keys.add(value)
117+
return sorted(keys)
118+
119+
120+
def test_no_profile_claims_ac_discharge_total_without_a_register():
121+
"""It sat in the shared BATTERY_SENSORS group, so 21 grid-tied profiles created the
122+
sensor with nothing to populate it. It read 0.0, and the coordinator's lifetime-total
123+
retention latched one garbage frame and restored it forever — 21,069,824 kWh on a 12 kWh
124+
battery, unclearable."""
125+
offenders = []
126+
for key in _profile_keys():
127+
profile = _dp.get_profile(key)
128+
regs = REGISTER_MAPS.get(profile.get("register_map", ""), {}).get(
129+
"input_registers", {}
130+
)
131+
has_register = any(
132+
r.get("name") == "ac_discharge_energy_total_low" for r in regs.values()
133+
)
134+
if "ac_discharge_energy_total" in profile.get("sensors", ()) and not has_register:
135+
offenders.append(key)
136+
137+
assert not offenders, (
138+
"these profiles expose AC Discharge Energy Total with no register behind it: "
139+
f"{offenders}"
140+
)
141+
142+
143+
def test_the_off_grid_profiles_that_do_have_it_keep_it():
144+
"""The other half — removing it from the shared group must not strip SPF and SPE, which
145+
genuinely map registers 66/67."""
146+
for key in _profile_keys():
147+
profile = _dp.get_profile(key)
148+
regs = REGISTER_MAPS.get(profile.get("register_map", ""), {}).get(
149+
"input_registers", {}
150+
)
151+
if any(r.get("name") == "ac_discharge_energy_total_low" for r in regs.values()):
152+
assert "ac_discharge_energy_total" in profile.get("sensors", ()), (
153+
f"{key} maps the register but no longer exposes the sensor"
154+
)

0 commit comments

Comments
 (0)