Skip to content

fix(sunsynk,deye): make Freeze Export actually reach the inverter - #4616

Merged
springfall2008 merged 4 commits into
mainfrom
fix/sunsynk-freeze-export
Aug 20, 2026
Merged

fix(sunsynk,deye): make Freeze Export actually reach the inverter#4616
springfall2008 merged 4 commits into
mainfrom
fix/sunsynk-freeze-export

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

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 both SunsynkCloud and DeyeCloud declare 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 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%.

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.

  • a zero charge rate against a non-zero export rate derives the freeze (the signal Predbat actually sends)
  • the cap is the reserve, not the sentinel
  • the slot baseline is derived rather than assumed idle, so the fillers carry the freeze too — Predbat never says when a freeze ends, so a self-use filler would otherwise defeat it the moment the clock reached it

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:

mode sell rate cap vs SoC battery
Selling First 0 8000 3% below drained to grid at 4283–4715 W
Selling First 1 0 5% below held at 7, 1, 28, 1, 10 W

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:

  • Zero-export-to-CT 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.
  • A zero slot rate does not stop charging, so it cannot stand in for the mode swap: rate 0 written and read back, battery carried on 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 while the export tracked PV minus load (5148 − 257 = 4891 W vs pac −4711 W).

Also resolves the VERIFY@SPIKE on the Sunsynk grid_power sign. No behaviour change — it is correct as it was, pac is 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 W against 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 W with DailyGridFeedIn 0.00 kWh).

Marked VERIFY@SPIKE for 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

  • The discharge half. That the battery still covers the house under Selling First when the load exceeds the solar. Every run was midday sun against a ~250 W house, so the battery never needed to cover a shortfall. The cap at the reserve is what should allow it and nothing observed contradicts it, but it rests on reasoning — recorded as such in the docstring. An evening run settles it.

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_slot guard 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 --quick suite green, pre-commit clean.

🤖 Generated with Claude Code

springfall2008 and others added 3 commits August 20, 2026 13:51
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>
Copilot AI lite review requested due to automatic review settings August 20, 2026 13:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@springfall2008
springfall2008 requested a lite review from Copilot August 20, 2026 13:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@springfall2008
springfall2008 requested a lite review from Copilot August 20, 2026 13:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit 9012dd9 into main Aug 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants