Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 102 additions & 21 deletions apps/predbat/deye.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,27 +574,84 @@ def derive_control_state(self, schedule, current_soc):
if export.get("enable"):
export_soc = int(export.get("soc", FREEZE_EXPORT_SOC))
if export_soc >= FREEZE_EXPORT_SOC:
# Zero power IS the freeze: selling-first with no slot power holds the
# battery while surplus solar still exports.
return {"behaviour": "freeze_export", "work_mode": DEYE_WORKMODE["selling_first"], "grid_charge": False, "solar_sell": True, "slot_soc": FREEZE_EXPORT_SOC, "power": 0}
return self._freeze_export_state(reserve)
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))}

if charge.get("enable"):
charge_soc = int(charge.get("soc", 0))
if charge_soc > current_soc and charge_soc > reserve:
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))}
if charge_soc == reserve:
# Zero power IS the freeze: the slot is enabled for grid charge but given no
# power, so the battery neither charges nor discharges and simply holds.
# A freeze charge holds via the RESERVE, not this rate: Predbat sets the
# reserve to soc_percent + 1 for the duration (execute.py) and the slot SoC
# follows it, which is what stops the battery discharging below where it
# started. Solar charging above that is allowed and expected - a freeze
# charge only bars discharge.
#
# The zero rate is not what makes this a hold. CONFIRMED live on Sunsynk,
# the same registers behind a different cloud, that a zero slot rate does
# NOT stop the battery charging. What it stops is the battery being SOLD to
# the grid - see _freeze_export_state.
return {"behaviour": "freeze_charge", "work_mode": DEYE_WORKMODE["zero_export_ct"], "grid_charge": True, "solar_sell": False, "slot_soc": reserve, "power": 0}
# The battery is already at or above the requested target, so grid charge stays
# off — the charge is simply not triggered. The slot still carries Predbat's
# charge rate, because that is the rate it chose for this window; zero here would
# mean freeze, which is a different state.
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))}

# No window is active, so the only thing left that can distinguish demand from a
# freeze export is the charge rate. Predbat expresses Freeze Export - "demand mode,
# but with charging disabled" - by turning the forced-export window OFF and calling
# adjust_charge_rate(0), because DeyeCloud declares has_timed_pause False and that
# is the only lever execute.py has left.
#
# Without this the freeze never reached the inverter: charge_rate maps to the
# per-window battery_schedule_charge_power, which this method only reads inside an
# ENABLED charge window - and a freeze export has none. The payload came out
# byte-identical to plain Demand. Diagnosed and fixed on sunsynk.py first, which
# drives the same registers through a different cloud; see _freeze_export_state.
#
# The zero is only read as a freeze while the EXPORT rate is non-zero, which makes
# the signal "charging disabled, discharging still allowed" rather than just "zero",
# and keeps a system whose battery rates were never derived out of a permanent
# freeze: all-zero is an absence of a plan, not a plan.
if int(charge.get("power", 0)) == 0 and int(export.get("power", 0)) > 0:
return self._freeze_export_state(reserve)

return {"behaviour": "idle", "work_mode": DEYE_WORKMODE["zero_export_ct"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": 0}

@staticmethod
def _freeze_export_state(reserve):
"""Return the control state for a freeze export: sell the surplus, keep serving the house.

Selling First, the slot Sell flag ON, the slot rate at ZERO, and the cap at the
RESERVE. The cap is the fix: FREEZE_EXPORT_SOC is Predbat's SENTINEL for "this
export window is a freeze" (export_limits_best of 99), not a battery level, and
writing it through as the slot SoC told a Selling First inverter to drive the
battery to 99% - charging it from the very solar the freeze exists to export.

Every leg of this was settled on live SUNSYNK hardware (inverter 2405116013,
2026-08-20) rather than inferred, and is inherited here on the register-parity rule:

* Zero-export-to-CT fills the battery from the surplus, so the mode must change -
a 99% battery still took 540 W of PV in that mode while the rest exported.
* The slot rate is the SELL-RATE cap, and zero is what stops the battery being
pushed to the grid. Two runs differing only in that field: at 8000 W with the
cap below the SoC the battery drained to the grid at up to 4715 W; at 0 W it
held at 1-28 W while the export still tracked PV minus load exactly.
* The cap can therefore sit at the reserve, leaving the battery the room it needs
to cover the house, because the zero rate is what makes that safe.

VERIFY@SPIKE on DEYE specifically. The parity argument is stronger than it was for
the grid sign - which is a CLOUD presentation choice, and so had to be measured per
cloud - because slot-rate and work-mode behaviour is a property of the inverter
FIRMWARE, and both clouds drive the same Deye registers. But it is still inherited
rather than measured here. The failure mode if DEYE's rate is not the sell-rate cap
is that a freeze export drains the battery to the reserve, so one live DEYE run
watching battery power against the solar surplus should confirm it.
"""
return {"behaviour": "freeze_export", "work_mode": DEYE_WORKMODE["selling_first"], "grid_charge": False, "solar_sell": True, "slot_soc": int(reserve), "power": 0}

def _self_use_power(self, sn):
"""Return the power to write on self-use slots for one inverter, or 0 when unknown.

Expand All @@ -609,11 +666,17 @@ def _self_use_power(self, sn):
def _self_use_slot(self, start_time, reserve, self_use_power):
"""Build a self-use TOU slot holding at the reserve SoC.

self_use_power must NOT be zero. Zero power is how a slot expresses a freeze — the
battery neither charges nor discharges — so a zero-power self-use slot would stop
the battery serving the house for the whole interval and push the load onto the
grid. Self-use slots cover every interval Predbat is not actively charging or
exporting, so that would be the battery's default state.
self_use_power must NOT be zero. The rate is the battery's power limit for the
slot, so a zero-rate self-use slot risks stopping the battery serving the house for
the whole interval and pushing the load onto the grid. Self-use slots cover every
interval Predbat is not actively charging or exporting, so that would be the
battery's default state and not somewhere to take the risk.

Precisely what a zero rate does is only half known. CONFIRMED live on Sunsynk (the
same registers behind a different cloud, 2026-08-20) that it does NOT stop CHARGING,
and that it DOES stop the battery being sold to the grid under Selling First - which
is what _freeze_export_state relies on. Its effect on discharge to the HOUSE was
never measured. This guard stays because that is the untested direction.
"""
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"]: False, TOU_FIELD["sell"]: False}

Expand All @@ -633,12 +696,30 @@ def _action_slot(self, start_time, state):
TOU_FIELD["sell"]: bool(state.get("solar_sell")),
}

def _slot_for(self, start_time, state, reserve, self_use_power):
"""Build the slot for one derived state: an action slot, or self-use when idle.

A state that asks for nothing - no grid charge, no sell, no power - is demand, and
demand needs the inverter's full rating so the battery can serve the house (see
_self_use_slot). Anything else is written verbatim, which is what lets a freeze
export, whose only non-default slot field is the sell flag, survive as a zero-rate
slot rather than being rewritten as self-use.
"""
if state.get("grid_charge") or state.get("solar_sell") or state.get("power"):
return self._action_slot(start_time, state)
return self._self_use_slot(start_time, reserve, self_use_power)

def build_tou_slots(self, schedule, current_soc, self_use_power):
"""Build exactly TOU_SLOT_COUNT ordered slots covering 24h from the schedule windows."""
reserve = int(schedule.get("reserve", 0))
# Collect (start_time, state) segment boundaries. Baseline self-use at 00:00.
idle = {"behaviour": "idle", "power": 0, "slot_soc": reserve, "grid_charge": False, "solar_sell": False, "work_mode": None}
segments = {"00:00": dict(idle)}
# Collect (start_time, state) segment boundaries, from a DERIVED baseline at 00:00.
# "No window is active" is not always demand - a freeze export is exactly that state
# plus a zero charge rate (see derive_control_state) - so every slot the schedule
# does not otherwise claim carries the baseline, and a freeze covers the whole
# programme instead of being defeated by the first filler that covers the current
# time. Predbat never says when a freeze ends, so there is no boundary to write.
baseline = self.derive_control_state({"reserve": reserve, "charge": {"enable": False, "power": int(schedule.get("charge", {}).get("power", 0))}, "export": {"enable": False, "power": int(schedule.get("export", {}).get("power", 0))}}, current_soc)
segments = {"00:00": dict(baseline)}
for direction in ("charge", "export"):
window = schedule.get(direction, {})
if window.get("enable") and window.get("start") and window.get("end"):
Expand All @@ -659,15 +740,12 @@ def build_tou_slots(self, schedule, current_soc, self_use_power):
intent = {"reserve": reserve, "charge": {"enable": False}, "export": {"enable": False}}
intent[direction] = {"enable": True, "soc": window.get("soc", 0), "power": window.get("power", 0)}
segments[start_time] = self.derive_control_state(intent, current_soc)
# After the window, return to self-use at reserve.
segments.setdefault(end_time, dict(idle))
# After the window, return to the baseline.
segments.setdefault(end_time, dict(baseline))
ordered = sorted(segments.items(), key=lambda kv: kv[0])
slots = []
for start_time, state in ordered:
if state.get("grid_charge") or state.get("solar_sell") or state.get("power"):
slots.append(self._action_slot(start_time, state))
else:
slots.append(self._self_use_slot(start_time, reserve, self_use_power))
slots.append(self._slot_for(start_time, state, reserve, self_use_power))
# Normalise to exactly TOU_SLOT_COUNT slots, each with a DISTINCT ascending
# start time (DEYE rejects/mis-applies duplicate slot times). Pad with
# self-use slots at filler times not already used by a window boundary,
Expand All @@ -677,7 +755,7 @@ def build_tou_slots(self, schedule, current_soc, self_use_power):
if len(slots) >= TOU_SLOT_COUNT:
break
if filler_time not in used:
slots.append(self._self_use_slot(filler_time, reserve, self_use_power))
slots.append(self._slot_for(filler_time, baseline, reserve, self_use_power))
used.add(filler_time)
slots = sorted(slots, key=lambda slot: slot[TOU_FIELD["time"]])[:TOU_SLOT_COUNT]
return slots
Expand Down Expand Up @@ -738,7 +816,10 @@ def _active_state(self, schedule, current_soc, now_minutes):
reserve = int(schedule.get("reserve", 0))
charge = schedule.get("charge", {})
export = schedule.get("export", {})
intent = {"reserve": reserve, "charge": {"enable": False}, "export": {"enable": False}}
# The rates are carried even when no window is active: with both windows shut a zero
# charge rate is Predbat's Freeze Export, and the mode has to follow it rather than
# sit in demand (see derive_control_state).
intent = {"reserve": reserve, "charge": {"enable": False, "power": int(charge.get("power", 0))}, "export": {"enable": False, "power": int(export.get("power", 0))}}
if self._window_active(export, now_minutes):
intent["export"] = {"enable": True, "soc": export.get("soc", 0), "power": export.get("power", 0)}
elif self._window_active(charge, now_minutes):
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import pytz
import asyncio

THIS_VERSION = "v8.49.1"
THIS_VERSION = "v8.49.2"

from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
from const import MINUTE_WATT
Expand Down
Loading
Loading