Skip to content

Commit 1a23d41

Browse files
fix(deye): correct the TOU slot payload found by the Sunsynk comparison
Four corrections to the control payload, all surfaced by diffing this component against the Sunsynk port built from it and against a real inverter's live settings. **A zero-length window became a multi-hour full-power grid charge (#4560).** build_tou_slots() added an action segment at the window start and then called setdefault() for the return-to-self-use segment at the window end - a no-op when the two times are equal, so the action slot ran on until the next boundary, up to four hours. The schedule default is start = end = "00:00:00" and the control entities are written one at a time, so an enable event landing before the times do produced exactly this: a grid charge to the window's target at full power while _window_active() - which has always guarded start == end - reported the window inactive. The same guard now runs in build_tou_slots(), on the normalised times so "02:00:00" against "02:00" is caught too. **Solar Sell was switched off outside export windows (#4580).** solarSellAction governs whether surplus PV reaches the grid at all, not what the battery does, and it was derived from the window active right now - on inside an export window, off everywhere else. A user with an ordinary overnight charge window therefore had spare solar curtailed all through the following day, invisibly: nothing in Predbat's model represents PV curtailment, so the lost export revenue never showed up. It is now written on unconditionally. Fixed in build_dynamic_payload() rather than derive_control_state(), whose solar_sell field also classifies action-vs-self-use slots. **Non-export states now use ZERO_EXPORT_TO_CT (#4580).** The CT variant measures at the grid CT, so the battery serves the whole house without exporting; ZERO_EXPORT_TO_LOAD measures at the inverter's own output and on a CT-clamp install would stop the battery serving anything not wired to it, with the shortfall drawn from the grid. Confirmed on Sunsynk hardware, which sits behind the same registers. **Zero slot power is a freeze, and it was on the wrong slots (#4581).** TimeUseSettingItem.power is the slot's charge/export power and zero is how the inverter is told to hold. Self-use slots - every interval Predbat is not actively charging or exporting - carried zero, freezing the battery as its default state and putting the house on the grid, while the two freeze states carried the requested power, the opposite of a freeze. Self-use slots now carry the inverter's rated power, falling back to the battery's own maximum charge rate for a model whose device/latest has no RatedPower, and failing closed if neither is known: no payload is built and nothing is written, because a zero there cannot be justified. That warning is throttled to once per serial, since _reconcile_control() rebuilds the payload every cycle. freeze_charge and freeze_export now carry zero and still classify as action slots, keeping grid_charge and solar_sell respectively. hold_charge is deliberately unchanged: the battery is already at target, so the slot keeps Predbat's chosen charge rate and only the grid-charge flag differs. Zero there would mean freeze, which is a different state. Closes #4560, #4580, #4581 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 29226fa commit 1a23d41

6 files changed

Lines changed: 377 additions & 53 deletions

File tree

apps/predbat/deye.py

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def initialize(
130130
self._cache_restored = False
131131
self._saved_ratings = None # signature of the ratings last written, to skip no-op saves
132132
self._soc_floor_warned = set()
133+
self._self_use_power_warned = set()
133134
self.battery_nominal_voltage = self._as_float(battery_nominal_voltage, 0.0)
134135
# auth_method defaults to app_credentials, but an injected access token with no
135136
# developer app credentials can only be oauth — and in app_credentials mode
@@ -512,58 +513,107 @@ def _battery_config_value(self, sn, key, default=0.0):
512513
return self._as_float(raw, default)
513514

514515
def derive_control_state(self, schedule, current_soc):
515-
"""Map Predbat's schedule intent to a DEYE control state (see design spec table)."""
516+
"""Map Predbat's schedule intent to a DEYE control state (see design spec table).
517+
518+
The work mode governs whether the BATTERY may export; it is orthogonal to
519+
solarSellAction, which governs whether surplus PV may (see build_dynamic_payload).
520+
521+
Non-export states use ZERO_EXPORT_TO_CT: the battery serves the whole house,
522+
measured at the grid CT, without exporting. The stricter ZERO_EXPORT_TO_LOAD
523+
measures at the inverter's own output instead, so on a CT-clamp install it would
524+
stop the battery serving anything not wired to the inverter and the shortfall would
525+
come from the grid. Confirmed on Sunsynk hardware, which sits behind the same
526+
registers.
527+
"""
516528
reserve = int(schedule.get("reserve", 0))
517529
charge = schedule.get("charge", {})
518530
export = schedule.get("export", {})
519531

520532
if export.get("enable"):
521533
export_soc = int(export.get("soc", FREEZE_EXPORT_SOC))
522534
if export_soc >= FREEZE_EXPORT_SOC:
523-
return {"behaviour": "freeze_export", "work_mode": DEYE_WORKMODE["selling_first"], "grid_charge": False, "solar_sell": True, "slot_soc": FREEZE_EXPORT_SOC, "power": int(export.get("power", 0))}
535+
# Zero power IS the freeze: selling-first with no slot power holds the
536+
# battery while surplus solar still exports.
537+
return {"behaviour": "freeze_export", "work_mode": DEYE_WORKMODE["selling_first"], "grid_charge": False, "solar_sell": True, "slot_soc": FREEZE_EXPORT_SOC, "power": 0}
524538
return {"behaviour": "export", "work_mode": DEYE_WORKMODE["selling_first"], "grid_charge": False, "solar_sell": True, "slot_soc": export_soc, "power": int(export.get("power", 0))}
525539

526540
if charge.get("enable"):
527541
charge_soc = int(charge.get("soc", 0))
528542
if charge_soc > current_soc and charge_soc > reserve:
529-
return {"behaviour": "charge", "work_mode": DEYE_WORKMODE["zero_export_load"], "grid_charge": True, "solar_sell": False, "slot_soc": charge_soc, "power": int(charge.get("power", 0))}
543+
return {"behaviour": "charge", "work_mode": DEYE_WORKMODE["zero_export_ct"], "grid_charge": True, "solar_sell": False, "slot_soc": charge_soc, "power": int(charge.get("power", 0))}
530544
if charge_soc == reserve:
531-
return {"behaviour": "freeze_charge", "work_mode": DEYE_WORKMODE["zero_export_load"], "grid_charge": True, "solar_sell": False, "slot_soc": reserve, "power": int(charge.get("power", 0))}
532-
return {"behaviour": "hold_charge", "work_mode": DEYE_WORKMODE["zero_export_load"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": int(charge.get("power", 0))}
545+
# Zero power IS the freeze: the slot is enabled for grid charge but given no
546+
# power, so the battery neither charges nor discharges and simply holds.
547+
return {"behaviour": "freeze_charge", "work_mode": DEYE_WORKMODE["zero_export_ct"], "grid_charge": True, "solar_sell": False, "slot_soc": reserve, "power": 0}
548+
# The battery is already at or above the requested target, so grid charge stays
549+
# off — the charge is simply not triggered. The slot still carries Predbat's
550+
# charge rate, because that is the rate it chose for this window; zero here would
551+
# mean freeze, which is a different state.
552+
return {"behaviour": "hold_charge", "work_mode": DEYE_WORKMODE["zero_export_ct"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": int(charge.get("power", 0))}
553+
554+
return {"behaviour": "idle", "work_mode": DEYE_WORKMODE["zero_export_ct"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": 0}
555+
556+
def _self_use_power(self, sn):
557+
"""Return the power to write on self-use slots for one inverter, or 0 when unknown.
558+
559+
The inverter's AC rating first, so the battery is free to serve whatever the house
560+
draws. Failing that, the battery's own maximum charge rate — a DC-side limit the
561+
inverter clamps to its own capability anyway, and far better than a freeze on a
562+
model whose device/latest carries no RatedPower. Zero only when neither is known,
563+
which build_dynamic_payload turns into no write at all (see _self_use_slot).
564+
"""
565+
return int(self.device_rated_power.get(sn, 0.0)) or int(self.battery_rate_max(sn))
533566

534-
return {"behaviour": "idle", "work_mode": DEYE_WORKMODE["zero_export_load"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": 0}
567+
def _self_use_slot(self, start_time, reserve, self_use_power):
568+
"""Build a self-use TOU slot holding at the reserve SoC.
535569
536-
def _self_use_slot(self, start_time, reserve):
537-
"""Build a self-use TOU slot holding at the reserve SoC."""
538-
return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: 0, TOU_FIELD["soc"]: int(reserve), TOU_FIELD["grid_charge"]: False, TOU_FIELD["generate"]: True}
570+
self_use_power must NOT be zero. Zero power is how a slot expresses a freeze — the
571+
battery neither charges nor discharges — so a zero-power self-use slot would stop
572+
the battery serving the house for the whole interval and push the load onto the
573+
grid. Self-use slots cover every interval Predbat is not actively charging or
574+
exporting, so that would be the battery's default state.
575+
"""
576+
return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: int(self_use_power), TOU_FIELD["soc"]: int(reserve), TOU_FIELD["grid_charge"]: False, TOU_FIELD["generate"]: True}
539577

540578
def _action_slot(self, start_time, state):
541579
"""Build a TOU slot realising a derived control state."""
542580
return {TOU_FIELD["time"]: start_time, TOU_FIELD["power"]: int(state["power"]), TOU_FIELD["soc"]: int(state["slot_soc"]), TOU_FIELD["grid_charge"]: bool(state["grid_charge"]), TOU_FIELD["generate"]: True}
543581

544-
def build_tou_slots(self, schedule, current_soc):
582+
def build_tou_slots(self, schedule, current_soc, self_use_power):
545583
"""Build exactly TOU_SLOT_COUNT ordered slots covering 24h from the schedule windows."""
546584
reserve = int(schedule.get("reserve", 0))
547585
# Collect (start_time, state) segment boundaries. Baseline self-use at 00:00.
548-
segments = {"00:00": {"behaviour": "idle", "power": 0, "slot_soc": reserve, "grid_charge": False, "solar_sell": False, "work_mode": None}}
586+
idle = {"behaviour": "idle", "power": 0, "slot_soc": reserve, "grid_charge": False, "solar_sell": False, "work_mode": None}
587+
segments = {"00:00": dict(idle)}
549588
for direction in ("charge", "export"):
550589
window = schedule.get(direction, {})
551590
if window.get("enable") and window.get("start") and window.get("end"):
552-
intent = {"reserve": reserve, "charge": {"enable": False}, "export": {"enable": False}}
553-
intent[direction] = {"enable": True, "soc": window.get("soc", 0), "power": window.get("power", 0)}
554-
state = self.derive_control_state(intent, current_soc)
555591
# Normalised to HH:MM here: these strings become DEYE slot times, and the
556592
# entities they came from carry seconds.
557-
segments[self._to_slot_time(window["start"])] = state
593+
start_time = self._to_slot_time(window["start"])
594+
end_time = self._to_slot_time(window["end"])
595+
if start_time == end_time:
596+
# Mirrors the guard in _window_active: a zero-length window has no
597+
# interval to act over. Compared on the NORMALISED times, so "02:00:00"
598+
# against "02:00" is caught too. Without this, an enable event arriving
599+
# before the time fields (both still the "00:00:00" default) would add an
600+
# action segment whose matching return-to-self-use segment cannot be
601+
# added at the same key — an unterminated, multi-hour full-power
602+
# grid-charge/export slot, even though _active_state correctly reports
603+
# the window inactive.
604+
continue
605+
intent = {"reserve": reserve, "charge": {"enable": False}, "export": {"enable": False}}
606+
intent[direction] = {"enable": True, "soc": window.get("soc", 0), "power": window.get("power", 0)}
607+
segments[start_time] = self.derive_control_state(intent, current_soc)
558608
# After the window, return to self-use at reserve.
559-
segments.setdefault(self._to_slot_time(window["end"]), {"behaviour": "idle", "power": 0, "slot_soc": reserve, "grid_charge": False, "solar_sell": False, "work_mode": None})
609+
segments.setdefault(end_time, dict(idle))
560610
ordered = sorted(segments.items(), key=lambda kv: kv[0])
561611
slots = []
562612
for start_time, state in ordered:
563613
if state.get("grid_charge") or state.get("solar_sell") or state.get("power"):
564614
slots.append(self._action_slot(start_time, state))
565615
else:
566-
slots.append(self._self_use_slot(start_time, reserve))
616+
slots.append(self._self_use_slot(start_time, reserve, self_use_power))
567617
# Normalise to exactly TOU_SLOT_COUNT slots, each with a DISTINCT ascending
568618
# start time (DEYE rejects/mis-applies duplicate slot times). Pad with
569619
# self-use slots at filler times not already used by a window boundary,
@@ -573,7 +623,7 @@ def build_tou_slots(self, schedule, current_soc):
573623
if len(slots) >= TOU_SLOT_COUNT:
574624
break
575625
if filler_time not in used:
576-
slots.append(self._self_use_slot(filler_time, reserve))
626+
slots.append(self._self_use_slot(filler_time, reserve, self_use_power))
577627
used.add(filler_time)
578628
slots = sorted(slots, key=lambda slot: slot[TOU_FIELD["time"]])[:TOU_SLOT_COUNT]
579629
return slots
@@ -647,10 +697,25 @@ def build_dynamic_payload(self, sn, schedule, current_soc, now_minutes=None):
647697
The top-level work mode / on-off flags follow the window active at
648698
now_minutes (defaults to the current local time); the 6 TOU slots still
649699
encode every window's per-slot config.
700+
701+
Returns {} when there is no honest self-use slot power for this inverter, which
702+
the caller treats as "do not write": the alternative is a zero-power slot, and
703+
zero power freezes the battery (see _self_use_slot).
650704
"""
651705
if now_minutes is None:
652706
now_minutes = self._now_minutes()
653-
slots = self.build_tou_slots(schedule, current_soc)
707+
self_use_power = self._self_use_power(sn)
708+
if not self_use_power:
709+
# Once per serial: _reconcile_control rebuilds this payload every cycle, so an
710+
# unconditional warning would fill the log for as long as the rating is missing.
711+
# Cleared again below, so a serial that recovers and then regresses is reported
712+
# afresh rather than staying quiet.
713+
if sn not in self._self_use_power_warned:
714+
self._self_use_power_warned.add(sn)
715+
self.log(f"Warn: DEYE {sn} has no inverter rating and no battery config, so self-use slots would be written with zero power (a freeze); skipping the control write")
716+
return {}
717+
self._self_use_power_warned.discard(sn)
718+
slots = self.build_tou_slots(schedule, current_soc, self_use_power)
654719
active = self._active_state(schedule, current_soc, now_minutes)
655720
# Final guard: never ask the battery to go below the floor its own installer
656721
# settings declare (config/battery battLowCapacity). Predbat's control entities
@@ -674,7 +739,22 @@ def build_dynamic_payload(self, sn, schedule, current_soc, now_minutes=None):
674739
"deviceSn": sn,
675740
"workMode": active["work_mode"],
676741
"gridChargeAction": "on" if active["grid_charge"] else "off",
677-
"solarSellAction": "on" if active["solar_sell"] else "off",
742+
# Solar Sell is always left ON, whatever the active state derives.
743+
#
744+
# It does not govern the BATTERY — it governs whether surplus PV reaches the grid
745+
# at all. Predbat plans when the battery charges and exports; it assumes, as it
746+
# does for every other inverter it drives, that spare solar exports passively in
747+
# the background. Deriving this from the active window (on only inside an export
748+
# window, off otherwise) curtailed surplus PV for most daylight hours, silently
749+
# costing export revenue on a sunny day once the battery was full — and Predbat
750+
# has no notion it is doing so, because nothing in its model represents PV
751+
# curtailment.
752+
#
753+
# An export window still exports: that comes from the selling-first work mode and
754+
# the slot SoC targets, not from this flag. Turning it off is never useful here,
755+
# only harmful, so it is not derived at all. derive_control_state still carries
756+
# solar_sell because build_tou_slots uses it to classify action-vs-self-use slots.
757+
"solarSellAction": "on",
678758
"touAction": "on",
679759
"timeUseSettingItems": slots,
680760
}
@@ -735,6 +815,11 @@ def is_busy_response(data):
735815
async def apply_dynamic_control(self, sn, schedule, current_soc, force=False):
736816
"""Write the combined control payload, suppressing no-op writes via the applied-payload cache. Returns True if written."""
737817
desired = self.build_dynamic_payload(sn, schedule, current_soc)
818+
if not desired:
819+
# No honest self-use power for this inverter, so there is nothing safe to send —
820+
# build_dynamic_payload has already said why. force does not override this: the
821+
# payload it would write does not exist.
822+
return False
738823
if not force and self.payloads_equal(desired, self.applied_payload.get(sn)):
739824
self.log(f"Info: DEYE {sn} control unchanged, skipping write")
740825
return False

apps/predbat/tests/test_deye_api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
from deye_const import DEYE_BASE_URLS, DEYE_TELEMETRY_KEYS, CONFIG_BATTERY_KEYS
1717
from tests.test_infra import run_async as run_async_local
1818

19+
# Inverter AC rating handed to test doubles. Self-use TOU slots are written at the
20+
# inverter's rating and a serial with no rating fails closed (see _self_use_power), so any
21+
# test that builds a control payload has to give its serials one.
22+
MOCK_RATED_POWER = 5000.0
23+
1924

2025
class MockDeye(DeyeAPI):
2126
"""Test double: build a DeyeAPI without the full component lifecycle."""
@@ -51,6 +56,7 @@ def __init__(self, auth_method="app_credentials", data_center="eu", inverter_sn=
5156
self._tier_refreshed = {}
5257
self._cache_restored = False
5358
self._soc_floor_warned = set()
59+
self._self_use_power_warned = set()
5460
self.log_messages = []
5561
self.local_tz = pytz.timezone("Europe/London")
5662
self.base = MagicMock()
@@ -59,6 +65,12 @@ def __init__(self, auth_method="app_credentials", data_center="eu", inverter_sn=
5965
self.base.minutes_now = 0 # local minutes-since-midnight; tests set this for time-aware control
6066
self._init_oauth(auth_method, "test-token", None, "deye")
6167

68+
def with_rating(self, *serials, watts=MOCK_RATED_POWER):
69+
"""Record an inverter AC rating for each serial and return self, for chaining."""
70+
for sn in serials:
71+
self.device_rated_power[sn] = float(watts)
72+
return self
73+
6274
def log(self, message):
6375
"""Capture logs."""
6476
self.log_messages.append(message)

0 commit comments

Comments
 (0)