Skip to content

Commit 4773b43

Browse files
CIclaude
andcommitted
fix(gateway): rebase the integration after auto-config, and keep the reserve until a write of 0 is applied
Integration runs before auto-config, so an inverter that gained its slot on a status had its PV baseline taken from the next one, losing that interval. The status is now taken as the baseline for the new slots straight after auto-config, with nothing added. The last good reserve was forgotten before the write of 0% went out. It is now forgotten only once the write has been sent, and put back if the hub refuses it, so a refused or failed write no longer reads back as a reserve of 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fad7af0 commit 4773b43

2 files changed

Lines changed: 130 additions & 9 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ class GatewayMQTT(ComponentBase):
241241
_slots_without_capacity = frozenset()
242242
_last_ratings = {} # replaced, never changed in place
243243
_assumed_ratings = frozenset()
244+
_reserve_before_zero = {} # replaced, never changed in place
244245
gateway_device_id = None
245246

246247
def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqtt_token=None, gateway_inverter_serial=None, gateway_evc_automatic=False, gateway_evc_control=False, gateway_shared_ct=True, gateway_integrate_power=False, **kwargs):
@@ -349,6 +350,7 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt
349350
self._slots_without_capacity = frozenset() # slots bound with no battery size sensor, re-bound once the hub reports a size
350351
self._last_ratings = {} # (entity suffix, rating name) -> the last real value the hub reported
351352
self._assumed_ratings = frozenset() # entity suffixes of inverters whose maximum rate has never been reported
353+
self._reserve_before_zero = {} # entity suffix -> the last real reserve, set aside when Predbat wrote a reserve of 0
352354
self._discovery = {} # upper-case serial -> what the hub last reported it to be (see _update_discovery)
353355
self._discovery_dirty = False
354356
self._discovery_save_retry_at = 0
@@ -881,6 +883,8 @@ def _process_telemetry(self, data):
881883
for inv in status.inverters:
882884
if not inv.primary and self._is_published(inv):
883885
self._publish_inverter(inv)
886+
# The integration above ran on the slots as they were before this status changed them
887+
self._update_integrated_energy(status, rebase=True)
884888
self._inject_unreported_inverters(status)
885889
self._inject_shared_site_metering(status)
886890
self._check_configured_inverters(status)
@@ -1077,7 +1081,7 @@ def _inject_shared_site_metering(self, status):
10771081
for name, wh in today.items():
10781082
self.dashboard_item(f"sensor.{pfx}_{name}_today", round(wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get(f"{name}_today", {}), app="gateway")
10791083

1080-
def _update_integrated_energy(self, status):
1084+
def _update_integrated_energy(self, status, rebase=False):
10811085
"""Integrate grid and battery power into today's import, export and load (gateway_integrate_power).
10821086
10831087
Some inverters do not keep usable grid energy counters. GivEnergy AC inverters sharing one
@@ -1097,6 +1101,12 @@ def _update_integrated_energy(self, status):
10971101
Each interval uses the mean of the readings at its two ends. The figures return to zero at
10981102
local midnight. They are kept in memory only, so they also restart from zero when Predbat
10991103
restarts, which Predbat reads the same way as the midnight reset.
1104+
1105+
Args:
1106+
status: A decoded GatewayStatus protobuf message.
1107+
rebase: True when auto-config has just run on a status that was already integrated.
1108+
Nothing is added: its readings become the baseline for the inverters now in the
1109+
slots, so one that has just joined is counted from this status and not the next.
11001110
"""
11011111
if not self.gateway_integrate_power:
11021112
return
@@ -1143,7 +1153,7 @@ def _update_integrated_energy(self, status):
11431153
state = {"day": day, "time": None, **zeroed}
11441154
self._integrated_energy = state
11451155
previous = state["time"]
1146-
if previous is not None and now <= previous:
1156+
if previous is not None and now <= previous and not rebase:
11471157
# The same status again (a re-inject), or the hub's clock stepping back
11481158
return
11491159

@@ -1158,6 +1168,9 @@ def _update_integrated_energy(self, status):
11581168

11591169
if previous is None:
11601170
state["pv"] = {serial: {"base": wh, "high": None, "high_time": 0} for serial, wh in pv_wh.items()}
1171+
elif rebase:
1172+
for serial, wh in pv_wh.items():
1173+
state["pv"].setdefault(serial, {"base": wh, "high": None, "high_time": 0})
11611174
else:
11621175
elapsed = now - previous
11631176
hours = elapsed / 3600.0
@@ -2676,11 +2689,16 @@ async def number_event(self, entity_id, value):
26762689
elif "_charge_rate" in entity_id:
26772690
await self._send_control(entity_id, "set_charge_rate", power_w=val, serial=serial)
26782691
elif "_reserve" in entity_id:
2692+
await self._send_control(entity_id, "set_reserve", target_soc=val, serial=serial)
26792693
if val == 0:
26802694
# A reserve of 0% asked for here is real, so the next 0 from the hub is not a gap
2681-
# to fill with the last value (see _last_rating)
2682-
self._last_ratings = {key: kept for key, kept in self._last_ratings.items() if key != (_serial_suffix(serial), "reserve")}
2683-
await self._send_control(entity_id, "set_reserve", target_soc=val, serial=serial)
2695+
# to fill with the last value (see _last_rating). Only once the write has gone
2696+
# out, and the value is set aside in case the hub refuses it (see _process_ack).
2697+
key = (_serial_suffix(serial), "reserve")
2698+
self._reserve_before_zero = {suffix: kept for suffix, kept in self._reserve_before_zero.items() if suffix != key[0]}
2699+
if key in self._last_ratings:
2700+
self._reserve_before_zero = {**self._reserve_before_zero, key[0]: self._last_ratings[key]}
2701+
self._last_ratings = {kept_key: kept for kept_key, kept in self._last_ratings.items() if kept_key != key}
26842702
elif "_target_soc" in entity_id:
26852703
await self._send_control(entity_id, "set_target_soc", target_soc=val, serial=serial)
26862704

@@ -2880,6 +2898,7 @@ def _process_ack(self, data):
28802898
apply_cache = ok and entry["cached"] is None
28812899
command = entry["command"]
28822900
serial = entry["kwargs"].get("serial")
2901+
zero_reserve = entry["kwargs"].get("target_soc") == 0
28832902

28842903
if ok:
28852904
self.log(f"Info: GatewayMQTT: {command} for {serial} acknowledged by hub ({command_id}) applied={ack.get('applied')}")
@@ -2893,6 +2912,13 @@ def _process_ack(self, data):
28932912
self.log(f"Warn: GatewayMQTT: {command} for {serial} refused by hub: replay ({command_id} already used), {action}")
28942913
else:
28952914
self.log(f"Warn: GatewayMQTT: {command} for {serial} refused by hub: {error}")
2915+
if command == "set_reserve" and zero_reserve and serial:
2916+
# The reserve of 0 was not applied, so a 0 from the hub is a gap again and the
2917+
# reserve it had before is still the last real one
2918+
key = (_serial_suffix(serial), "reserve")
2919+
if key[0] in self._reserve_before_zero and key not in self._last_ratings:
2920+
self._last_ratings = {**self._last_ratings, key: self._reserve_before_zero[key[0]]}
2921+
restore_status = True
28962922
if restore_status and self._last_status is not None:
28972923
self._inject_entities(self._last_status)
28982924

‎apps/predbat/tests/test_gateway.py‎

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4275,6 +4275,78 @@ def test_reserve_the_hub_stops_reporting_keeps_its_last_good_value(self):
42754275
# The other inverter's reserve is its own
42764276
assert gw._states["number.predbat_gateway_23g800_reserve_soc"] == 4
42774277

4278+
def test_reserve_is_kept_when_the_write_of_zero_fails(self):
4279+
"""A reserve of 0% that never went out is not real: the hub's 0 is still a gap filled with the last value."""
4280+
gw = self._writable(self._listed_gateway([]))
4281+
gw._process_telemetry(self._rated_status().SerializeToString())
4282+
4283+
async def failing_send(entity_id, command, **kwargs):
4284+
raise RuntimeError("publish failed")
4285+
4286+
gw._send_control = failing_send
4287+
try:
4288+
asyncio.run(gw.number_event("number.predbat_gateway_25g400_reserve_soc", 0))
4289+
raise AssertionError("the failed publish should have been raised")
4290+
except RuntimeError:
4291+
pass
4292+
4293+
blank = self._rated_status()
4294+
blank.inverters[1].control.reserve_soc = 0
4295+
gw._process_telemetry(blank.SerializeToString())
4296+
assert gw._states["number.predbat_gateway_25g400_reserve_soc"] == 4
4297+
4298+
def test_reserve_is_put_back_when_the_hub_refuses_the_write_of_zero(self):
4299+
"""A refused reserve of 0% was not applied, so the reserve the inverter had is published again."""
4300+
import threading
4301+
4302+
gw = self._writable(self._listed_gateway([]))
4303+
gw._ack_subscribed = True
4304+
gw._acks_seen = True
4305+
gw._command_id = 0
4306+
gw._pending_commands = {}
4307+
gw._command_lock = threading.Lock()
4308+
gw._process_telemetry(self._rated_status().SerializeToString())
4309+
blank = self._rated_status()
4310+
blank.inverters[1].control.reserve_soc = 0
4311+
4312+
asyncio.run(gw.number_event("number.predbat_gateway_25g400_reserve_soc", 0))
4313+
command, sent = gw._published[-1]
4314+
assert command == "set_reserve" and sent["target_soc"] == 0
4315+
gw._process_telemetry(blank.SerializeToString())
4316+
assert gw._states["number.predbat_gateway_25g400_reserve_soc"] == 0
4317+
4318+
gw._process_ack(json.dumps({"command_id": f"PBAT{sent['command_id']}", "command": "set_reserve", "ok": False, "error": "modbus_write_failed"}).encode("utf-8"))
4319+
assert gw._states["number.predbat_gateway_25g400_reserve_soc"] == 4
4320+
# The other inverter is untouched, and an accepted 0 stays 0
4321+
assert gw._states["number.predbat_gateway_23g800_reserve_soc"] == 4
4322+
4323+
asyncio.run(gw.number_event("number.predbat_gateway_23g800_reserve_soc", 0))
4324+
command, sent = gw._published[-1]
4325+
blank.inverters[0].control.reserve_soc = 0
4326+
gw._process_telemetry(blank.SerializeToString())
4327+
gw._process_ack(json.dumps({"command_id": f"PBAT{sent['command_id']}", "command": "set_reserve", "ok": True}).encode("utf-8"))
4328+
gw._process_telemetry(blank.SerializeToString())
4329+
assert gw._states["number.predbat_gateway_23g800_reserve_soc"] == 0
4330+
4331+
def test_inverter_that_becomes_usable_is_the_integration_baseline_on_that_status(self):
4332+
"""_process_telemetry re-baselines the integration after auto-config changes the slots."""
4333+
import time
4334+
4335+
gw = self._make_handler_gateway()
4336+
gw.gateway_integrate_power = True
4337+
gw.gateway_shared_ct = True
4338+
gw._integrated_energy = None
4339+
4340+
gw._process_telemetry(self._two_ac_status("no_battery").SerializeToString())
4341+
assert sorted(gw._integrated_energy["pv"]) == ["CE2223G800"]
4342+
4343+
joining = self._two_ac_status("ok")
4344+
joining.timestamp = int(time.time()) + 60
4345+
joining.inverters[1].energy.pv_today_wh = 7000
4346+
gw._process_telemetry(joining.SerializeToString())
4347+
assert gw._inverter_slot_serials == ["CE2223G800", "CE2225G400"]
4348+
assert gw._integrated_energy["pv"]["CE2225G400"]["base"] == 7000
4349+
42784350
def test_reserve_never_reported_is_published_as_zero(self):
42794351
"""An inverter that has never reported a reserve still publishes 0, as before."""
42804352
gw = self._listed_gateway([])
@@ -7028,6 +7100,23 @@ def status(timestamp, pv_only_wh, has_battery=False):
70287100
self._feed(gw, status(self.BASE_TIME + 1800, 3500, has_battery=True))
70297101
assert approx_equal(gw._integrated_energy["load_wh"], 500.0)
70307102

7103+
def test_rebase_takes_the_new_fleet_power_and_adds_nothing(self):
7104+
"""A rebase on a status already integrated adds no energy and starts the next interval from the new slots' power."""
7105+
gw = self._make_gateway(shared_ct=False)
7106+
self._feed(gw, self._status(self.BASE_TIME, grid_w=-1000, battery_w=(0,), serials=("CE2225G400",)))
7107+
joining = self._status(self.BASE_TIME + 600, grid_w=-1000, battery_w=(0, 0))
7108+
gw._last_status = joining
7109+
gw._update_integrated_energy(joining)
7110+
before = dict((name, gw._integrated_energy[name]) for name in ("import_wh", "export_wh", "load_wh"))
7111+
assert gw._integrated_energy["power"]["import"] == 1000
7112+
7113+
gw.automatic_config()
7114+
gw._update_integrated_energy(joining, rebase=True)
7115+
7116+
assert dict((name, gw._integrated_energy[name]) for name in before) == before
7117+
assert gw._integrated_energy["power"]["import"] == 2000
7118+
assert gw._integrated_energy["time"] == self.BASE_TIME + 600
7119+
70317120
def test_inverter_given_a_slot_later_has_its_generation_counted_from_then(self):
70327121
"""An inverter that joins the slots after the integration began is counted from its first status, not left out."""
70337122
gw = self._make_gateway()
@@ -7040,13 +7129,19 @@ def both(timestamp, first_wh, second_wh):
70407129
built.inverters[1].energy.pv_today_wh = second_wh
70417130
return built
70427131

7043-
# CE2223G800 appears and takes slot 0; its counter already stands at 7000
7044-
gw._last_status = both(self.BASE_TIME + 1200, 7000, 1200)
7132+
# CE2223G800 appears and takes slot 0; its counter already stands at 7000. As in
7133+
# _process_telemetry, the status is integrated on the old slots, auto-config re-runs,
7134+
# and the status then becomes the baseline for the new ones
7135+
joining = both(self.BASE_TIME + 1200, 7000, 1200)
7136+
gw._last_status = joining
7137+
gw._update_integrated_energy(joining)
70457138
gw.automatic_config()
70467139
assert gw._inverter_slot_serials == ["CE2223G800", "CE2225G400"]
7047-
7048-
self._feed(gw, both(self.BASE_TIME + 1200, 7000, 1200))
7140+
gw._update_integrated_energy(joining, rebase=True)
70497141
assert approx_equal(gw._integrated_energy["load_wh"], 200.0)
7142+
assert gw._integrated_energy["pv"]["CE2223G800"]["base"] == 7000
7143+
7144+
# Its generation since the status it joined on is counted, not only from the one after
70507145
self._feed(gw, both(self.BASE_TIME + 1800, 7300, 1250))
70517146
assert approx_equal(gw._integrated_energy["load_wh"], 550.0)
70527147

0 commit comments

Comments
 (0)