fix(sunsynk,deye): make Freeze Export actually reach the inverter - #4616
Merged
Conversation
Freeze Export was a silent no-op on Sunsynk. Predbat expresses it by turning
the forced-export window OFF and calling adjust_charge_rate(0), because
SunsynkCloud declares has_timed_pause False and that is the only lever
execute.py has left. But charge_rate maps to the per-WINDOW
battery_schedule_charge_power, which derive_control_state only reads inside an
ENABLED charge window - and a freeze export has none. The zero was dropped and
the component wrote a settings object byte-identical to plain Demand.
The freeze_export branch that was meant to catch this keyed on the export SoC
entity reaching FREEZE_EXPORT_SOC, which Predbat never writes: discharge_target_soc
is only written during a real force export, always as reserve_percent. So the
branch was unreachable, and had it fired it wrote the 99 SENTINEL as cap{n} -
telling a Selling First inverter to drive the battery to 99%.
Confirmed live throughout on inverter 2405116013 (2026-08-20), which is how the
mapping was settled rather than inferred:
* Limited to Home fills the battery from the surplus - a 99% battery still
took 540 W of PV while the rest exported. So the mode has to change.
* The slot rate is the SELL-RATE cap. Two runs differing only in that field:
at 8000 W with the cap 3% under the SoC the battery drained to the grid at
up to 4715 W; at 0 W with the cap 5% under the SoC it held at 1-28 W across
five polls while the export still tracked PV minus load exactly.
* Zero slot rate does NOT stop charging, so it cannot stand in for the mode
swap: with rate 0 applied and read back, the battery kept charging at 554 W
until it simply reached 100%.
* The production payload (m0 p0 c20 s1) was then written live with the cap at
the real 20% reserve - 80% of headroom below it - and the battery held at
-1/0/3/6/3 W with the export tracking PV minus load.
So a freeze export is Selling First, Sell on, rate 0, cap at the RESERVE. The
original code had three of those four right; only the cap was wrong.
Not confirmed live: that the battery still covers the house under this mode.
That needs the load to exceed the solar and every run was midday sun against a
250 W house. Recorded as such in the docstring.
Also resolves the VERIFY@SPIKE on the grid_power sign. It is correct as it was -
Sunsynk reports pac NEGATIVE when exporting, so the negation stands - but it was
an unverified DEYE-parity guess and is now pinned to a live sample: pac -1939 W
against a computed 2047 W surplus, with the day's counters at 0.0 kWh imported
and 2.8 kWh exported. The comment warns off re-deciding it from a low-power
sample, which points the opposite way and is pure noise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEYE carries the identical bug, in identical code: the freeze_export branch keys on the export SoC entity reaching FREEZE_EXPORT_SOC, which Predbat never writes (discharge_target_soc is only written during a real force export, always as reserve_percent), so the branch was unreachable. DeyeCloud also declares has_timed_pause False, so Predbat signals Freeze Export with adjust_charge_rate(0) - which maps to the per-WINDOW battery_schedule_charge_power that a disabled window never reads. The freeze was dropped and the payload came out identical to plain Demand, leaving the battery to charge from the solar the freeze existed to export. And had the branch fired, it wrote the 99 SENTINEL as the slot SoC. So the same three changes as sunsynk.py: a zero charge rate against a non-zero export rate derives the freeze, the cap is the RESERVE rather than the sentinel, and the slot baseline is derived so the fillers carry the freeze too. The mapping itself - Selling First, Sell on, rate 0, cap at the reserve - was settled on live Sunsynk hardware, including the controlled pair that identifies the rate as the sell-rate cap: 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. Marked VERIFY@SPIKE for DEYE specifically. The parity argument is stronger here 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. It is still inherited rather than measured. If DEYE's rate is not the sell-rate cap, a freeze export would drain the battery to the reserve, so one live run watching battery power against the solar surplus should confirm it. DEYE's grid sign needs no change: it is already confirmed live by the counter cross-check (TotalGridPower +25 W with DailyGridFeedIn 0.00 kWh, so no feed-in that day at all), and correctly negated. The test fixtures needed a sweep: a disabled window in the real control entities still carries Predbat's rate, so fixtures that left the charge rate at zero were silently describing a freeze. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both files asserted "Zero power IS the freeze - the battery neither charges nor discharges". The first half is now disproven: CONFIRMED live on 2026-08-20 that a zero slot rate does NOT stop the battery charging - rate 0 was written and read back from the inverter and the battery carried on at 554 W until it simply reached 100%. What a zero rate does do is stop the battery being SOLD to the grid under Selling First, which is the mechanism _freeze_export_state depends on. Its effect on discharge to the HOUSE remains unmeasured - every live run was midday sun against a 250 W load, so the battery never needed to cover a shortfall. The _self_use_slot guard therefore stays exactly as it was: that is precisely the direction still untested, and a self-use slot covers most of the day. Also corrects the freeze_charge comment in both. A freeze charge holds via the RESERVE - Predbat sets it to soc_percent + 1 for the duration and the slot SoC follows - which bars DISCHARGE below the starting point. Solar charging above that is allowed and expected; the zero rate is not what makes it a hold. No behaviour change, the mapping was already right. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Freeze Export was a silent no-op on both Sunsynk and DEYE. Predbat expresses it by turning the forced-export window off and calling
adjust_charge_rate(0), because bothSunsynkCloudandDeyeClouddeclarehas_timed_pause: Falseand that is the only leverexecute.pyhas left. Butcharge_ratemaps to the per-windowbattery_schedule_charge_power, whichderive_control_stateonly reads inside an enabled charge window — and a freeze export has none. The zero was dropped and the component wrote a settings object byte-identical to plain Demand.The
freeze_exportbranch meant to catch this keyed on the export SoC entity reachingFREEZE_EXPORT_SOC, which Predbat never writes:discharge_target_socis only written during a real force export, always asreserve_percent. So the branch was unreachable — and had it fired, it wrote the 99 sentinel ascap{n}, telling a Selling First inverter to drive the battery to 99%.Net effect on a live system: Predbat says "Freeze exporting", the inverter sits in self-use, and surplus PV charges the battery instead of being exported.
The fix
A freeze export is Selling First, Sell on, slot rate 0, cap at the RESERVE. The original code had three of those four right; only the cap was wrong.
Evidence
Settled on live hardware (Sunsynk inverter 2405116013, 2026-08-20) rather than inferred. The decisive result is a controlled pair differing in one field:
So the slot rate is the sell-rate cap, which is what makes a cap below the SoC safe, which is what lets the cap sit at the reserve so the battery keeps the room to cover the house.
Supporting runs:
m0 p0 c20 s1) was then written live with the cap at the real 20% reserve — 80% of headroom below it — and the battery held at −1/0/3/6/3 W while the export tracked PV minus load (5148 − 257 = 4891 W vspac−4711 W).Also resolves the
VERIFY@SPIKEon the Sunsynkgrid_powersign. No behaviour change — it is correct as it was,pacis negative when exporting so the negation stands — but it was an unverified DEYE-parity guess and is now pinned to a live sample and a regression test:pac −1939 Wagainst a computed 2047 W surplus, with the day's counters at 0.0 kWh imported against 2.8 kWh exported. The comment warns off re-deciding it from a low-power sample, which points the opposite way and is pure noise.DEYE
Identical bug in identical code, so the same three changes. Its grid sign needs nothing — already confirmed live by the counter cross-check (
TotalGridPower +25 WwithDailyGridFeedIn 0.00 kWh).Marked
VERIFY@SPIKEfor DEYE specifically. The parity argument is stronger here than it was for the grid sign — a sign convention is a cloud presentation choice and so had to be measured per cloud, whereas slot-rate and work-mode behaviour is inverter firmware, and both clouds drive the same Deye registers. But it is still inherited rather than measured. If DEYE's rate is not the sell-rate cap, a freeze export would drain the battery to the reserve, so one live DEYE run watching battery power against the solar surplus should confirm it.Not verified
Also
Corrects a stale comment in both files: they asserted "Zero power IS the freeze — the battery neither charges nor discharges", and the charging half is now disproven. They record what is actually known, including that the effect on discharge to the house was never measured — which is why the
_self_use_slotguard stays exactly as it was.The test fixtures needed a sweep in both components: a disabled window in the real control entities still carries Predbat's rate, so fixtures that left the charge rate at zero were silently describing a freeze rather than the demand state they read as.
Testing
New regression tests for the freeze-export payload and the sentinel in both components, plus the Sunsynk grid-sign sample. Full
--quicksuite green, pre-commit clean.🤖 Generated with Claude Code