Skip to content

Commit 8cf246f

Browse files
fix(deye): source energy counters from the Daily registers
DEYE_ENERGY_KEYS mapped Predbat's *_today args onto the lifetime "Total*" accumulators. Those are firmware-derived and drift: on a live capture TotalConsumption read 14332.40 kWh where the other lifetime counters implied 15475.00 (buy 13579.20 + PV 2208.30 - sell 11.80 + discharge 5255.90 - charge 5556.60), a 7.4% shortfall. That fed straight into Predbat's learned load, so load_today climbed slower than the house actually consumed and every charge window came out under-sized. The Daily registers balance exactly on the same payload (DailyConsumption 12.80 = DailyActiveProduction 4.50 + DailyEnergyPurchased 7.00 - DailyGridFeedIn 0.00 + DailyDischargingEnergy 4.10 - DailyChargingEnergy 2.80), so all four counters now read from them. The old comment claimed daily counters were unusable because Predbat needs an incrementing series and a midnight reset would read as a large negative delta. That is not the case: minute_data() smooths the near-midnight drop and clean_incrementing_reverse() re-bases on any reset to <= 0, so the nightly return to 0.00 is absorbed. Comment corrected rather than left to mislead. Tests: energy counters now assert the Daily values, plus a new test pinning the daily energy balance so a drifted key spelling breaks the sum - the same failure that hid the original bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 41ad712 commit 8cf246f

5 files changed

Lines changed: 59 additions & 19 deletions

File tree

apps/predbat/deye.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,9 @@ async def publish_data(self):
812812
if rated_power > 0:
813813
self.dashboard_item(self._sensor_name(sn, "inverter_limit"), state=rated_power, attributes={"unit_of_measurement": "W", "friendly_name": f"DEYE {sn} Inverter Limit"}, app="deye")
814814

815-
# Lifetime energy counters feed Predbat's load/import/export history learning.
815+
# Daily energy counters feed Predbat's load/import/export history learning. They
816+
# reset at midnight; minute_data/clean_incrementing_reverse absorb that (see
817+
# DEYE_ENERGY_KEYS for why the Daily registers are used over the Total* ones).
816818
for leaf, value in self.device_energy.get(sn, {}).items():
817819
self.dashboard_item(
818820
self._sensor_name(sn, leaf),

apps/predbat/deye_const.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,19 +182,29 @@
182182
DEYE_TELEMETRY_NEGATE = ("grid_power",)
183183

184184
# Cumulative energy counters Predbat needs for its history-based load/rate learning, from
185-
# the same device/latest dataList. The lifetime "Total*" counters are used rather than the
186-
# "Daily*" ones because Predbat requires an incrementing series and the daily counters
187-
# reset at midnight, which would read as a large negative delta every night.
185+
# the same device/latest dataList.
188186
#
189187
# The mapping is confirmed by DEYE's own daily figures balancing exactly:
190188
# DailyConsumption 12.80 = DailyActiveProduction 4.50 + DailyEnergyPurchased 7.00
191189
# + (DailyDischargingEnergy 4.10 - DailyChargingEnergy 2.80)
192190
# which only holds if ActiveProduction is PV generation alone, excluding battery discharge.
191+
#
192+
# These are the DAILY registers, not the lifetime "Total*" ones. The lifetime accumulators
193+
# are firmware-derived and drift: on a live capture TotalConsumption read 14332.40 kWh where
194+
# the other lifetime counters implied 15475.00 (buy 13579.20 + PV 2208.30 - sell 11.80 +
195+
# discharge 5255.90 - charge 5556.60). That 7.4% shortfall fed straight into Predbat's
196+
# learned load and under-sized every charge window. The Daily registers balance exactly on
197+
# the same payload, so the whole set reads from them.
198+
#
199+
# A daily counter is safe despite resetting at midnight: minute_data() smooths the
200+
# near-midnight drop (utils.py, the near_midnight branch) and clean_incrementing_reverse()
201+
# re-bases on any reset to <= 0, so the nightly return to 0.00 is absorbed rather than read
202+
# as a negative delta. Predbat only ever needs today-so-far plus history from these args.
193203
DEYE_ENERGY_KEYS = {
194-
"import_today": "TotalEnergyBuy",
195-
"export_today": "TotalEnergySell",
196-
"pv_today": "TotalActiveProduction",
197-
"load_today": "TotalConsumption",
204+
"import_today": "DailyEnergyPurchased",
205+
"export_today": "DailyGridFeedIn",
206+
"pv_today": "DailyActiveProduction",
207+
"load_today": "DailyConsumption",
198208
}
199209

200210
# Metrics that must be present in every device/latest response. A key missing here means

apps/predbat/predbat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import pytz
3636
import asyncio
3737

38-
THIS_VERSION = "v8.47.0"
38+
THIS_VERSION = "v8.47.1"
3939

4040
from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
4141
from const import MINUTE_WATT

apps/predbat/tests/test_deye_api.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,16 @@ async def fake_post_never(endpoint_key, body):
390390
assert not failed, "test_rated_power_survives_a_payload_that_omits_it"
391391

392392

393-
def test_fetch_device_data_captures_energy_counters():
394-
"""The lifetime energy counters are captured from device/latest for Predbat's history."""
393+
def test_energy_counters_use_the_daily_registers():
394+
"""The energy counters read the Daily registers, not the drifting lifetime accumulators.
395+
396+
The lifetime "Total*" accumulators are firmware-derived and drift. On a live capture
397+
TotalConsumption read 14332.40 kWh where the other lifetime counters implied 15475.00
398+
(buy 13579.20 + PV 2208.30 - sell 11.80 + discharge 5255.90 - charge 5556.60), a 7.4%
399+
shortfall that made Predbat's learned load too low and under-sized every charge window.
400+
The Daily registers balanced exactly on that same payload - see
401+
test_daily_energy_registers_balance_on_the_live_payload.
402+
"""
395403
failed = False
396404
d = MockDeye()
397405

@@ -403,12 +411,30 @@ async def fake_post(endpoint_key, body):
403411
run_async_local(d.fetch_device_data("INV1"))
404412

405413
energy = d.device_energy.get("INV1", {})
406-
expected = {"import_today": 13579.1, "export_today": 11.7, "pv_today": 2198.1, "load_today": 14326.4}
414+
# The Daily* values from LIVE_DATA_LIST, not the Total* ones (14326.40, 13579.10, ...).
415+
expected = {"import_today": 7.0, "export_today": 0.0, "pv_today": 4.5, "load_today": 12.8}
407416
for name, want in expected.items():
408-
if abs(energy.get(name, 0.0) - want) > 0.01:
409-
print(f"ERROR: {name} expected {want}, got {energy.get(name)}")
417+
got = energy.get(name)
418+
if got is None or abs(got - want) > 0.01:
419+
print(f"ERROR: {name} expected {want} (daily register), got {got}")
410420
failed = True
411-
assert not failed, "test_fetch_device_data_captures_energy_counters"
421+
assert not failed, "test_energy_counters_use_the_daily_registers"
422+
423+
424+
def test_daily_energy_registers_balance_on_the_live_payload():
425+
"""The Daily registers are self-consistent, which is why load_today trusts them.
426+
427+
Guards the key map as a set: if any of these five names drifts to a wrong spelling the
428+
balance breaks, which is the same failure that hid the original TotalConsumption bug.
429+
"""
430+
failed = False
431+
flat = {item["key"]: float(item["value"]) for item in LIVE_DATA_LIST}
432+
433+
derived = flat["DailyActiveProduction"] + flat["DailyEnergyPurchased"] - flat["DailyGridFeedIn"] + flat["DailyDischargingEnergy"] - flat["DailyChargingEnergy"]
434+
if abs(derived - flat["DailyConsumption"]) > 0.01:
435+
print(f"ERROR: daily energy balance {derived} != DailyConsumption {flat['DailyConsumption']}")
436+
failed = True
437+
assert not failed, "test_daily_energy_registers_balance_on_the_live_payload"
412438

413439

414440
def test_energy_counters_absent_are_not_invented():
@@ -586,7 +612,8 @@ def run_deye_api_tests(my_predbat):
586612
("battery_rate_max", test_battery_rate_max_from_charge_current),
587613
("rated_power_captured", test_rated_power_captured_for_inverter_limit),
588614
("rated_power_not_clobbered", test_rated_power_survives_a_payload_that_omits_it),
589-
("energy_counters_captured", test_fetch_device_data_captures_energy_counters),
615+
("energy_counters_daily_registers", test_energy_counters_use_the_daily_registers),
616+
("daily_energy_balance", test_daily_energy_registers_balance_on_the_live_payload),
590617
("energy_counters_absent", test_energy_counters_absent_are_not_invented),
591618
("derive_capacity_no_rating", test_derive_battery_capacity_without_rating),
592619
("fetch_battery_config_success", test_fetch_battery_config_caches_on_success),

apps/predbat/tests/test_deye_publish.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,20 +328,21 @@ def test_automatic_config_skips_missing_ratings():
328328

329329

330330
def test_automatic_config_maps_energy_counters():
331-
"""The lifetime energy counters are published and mapped to Predbat's history args."""
331+
"""The energy counters are published and mapped to Predbat's history args."""
332332
failed = False
333333
d = RecordingDeye()
334334
d.device_list = ["INV1"]
335335
d.device_values = {"INV1": {"soc": 100.0}}
336-
d.device_energy = {"INV1": {"import_today": 13579.1, "export_today": 11.7, "pv_today": 2198.1, "load_today": 14326.4}}
336+
# Daily-register magnitudes, matching DEYE_ENERGY_KEYS' Daily* sources.
337+
d.device_energy = {"INV1": {"import_today": 7.0, "export_today": 0.0, "pv_today": 4.5, "load_today": 12.8}}
337338
d.set_args = {}
338339
d.set_arg = lambda k, v: d.set_args.__setitem__(k, v)
339340
import tests.test_infra as ti
340341

341342
ti.run_async(d.publish_data())
342343
ti.run_async(d.automatic_config())
343344

344-
for leaf, value in (("import_today", 13579.1), ("export_today", 11.7), ("pv_today", 2198.1), ("load_today", 14326.4)):
345+
for leaf, value in (("import_today", 7.0), ("export_today", 0.0), ("pv_today", 4.5), ("load_today", 12.8)):
345346
entity = f"sensor.predbat_deye_inv1_{leaf}"
346347
if d.published.get(entity) != value:
347348
print(f"ERROR: {entity} published as {d.published.get(entity)}, expected {value}")

0 commit comments

Comments
 (0)