Repository navigation
Export window silently does nothing when best_soc_min lands on 99% and low power export is on #4914
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Sep 3, 2026 Automated first-pass triage (a maintainer will review before any action is taken).
Classification: bug (labels already reflect this —
bug/priority_low/Root Causedall look right to me, so I've left them as applied)Verification against main (
v8.54.2-48-g0ecf9c98, current at time of writing): I confirmed the report's core mechanics rather than taking them on trust:apps/predbat/plan.py:2335-2336— the encoding is exactly as quoted: the integer part is clamped tocalc_percent_limit(self.best_soc_min, self.soc_max)and the low-power rung's fraction is then re-attached. With a floor that rounds to 99% and a0.3rung this emits99.3(also99.5/99.7from the0.5/0.7rungs).apps/predbat/utils.py:1499—calc_percent_limitrounds to the nearest whole percent, so e.g. 9.4/9.5 kWh → 99%, matching the reported trigger table.apps/predbat/prediction.py:900—export_limit_now < EXPORT_LIMIT_FREEZEis False at 99.3, so no forced discharge;apps/predbat/prediction.py:1074—export_limit_now == EXPORT_LIMIT_FREEZEis also False, so no freeze export. Both readings of the sentinel do coexist in this file, as stated.apps/predbat/execute.py:485/510— the execution side splits the same way: at 99.3 the window falls into the "Hold exporting" branch withadjust_force_export(False), so the inverter stays in Demand mode and the window does nothing. Simulator and executor agree, i.e. the window is consistently inert rather than plan/execute mismatched — the sim's metric already priced it as idle, so there's no plan-vs-reality divergence either, just a wasted window the UI still displays.
I also confirmed
apps/predbat/tests/test_gateway.py:1168(test_fractional_export_limit_not_freeze) pins 99.5 as a normal export on the gateway path — the "ambiguity was known in one corner" observation is accurate.Duplicate check: searched open and closed issues for export-limit/freeze-sentinel symptoms — nothing matching found. Possibly tangentially related to #4890 (freeze-via-reserve behaviour), but that's a different mechanism.
On the suggested fixes: I agree option 1 (clamp the SoC floor below 99.0 when
set_export_low_poweris on, or clamp the emitted limit out of[99.0, 100.0)) is the right minimal change — it alters nothing for users not currently in the 98.5–99.4% band, and those users' windows currently do nothing anyway, so there's no plan regression to weigh. Option 2 (making<sites mode-explicit) is a behaviour change worth doing alongside the field-splitting refactor the reporter describes.One extra data point the reporter may find useful: a
best_soc_minfloor of exactly 99% with the full-power rung (0.0) emits99.0— which isEXPORT_LIMIT_FREEZE— so the same configuration can also silently turn a plain "export to 99%" window into a freeze. The clamp in option 1 would fix that variant too.No further information needed from the reporter. Offering a PR is noted — that's for the maintainer to take up.
- addedBOT_TRIAGEDHas been through the triage botHas been through the triage bot
on Sep 3, 2026 This is perhaps not worth a fix if I'm able to eliminate the overloading of the Soc target.
Claude here, on Rik's behalf. PR #5020 raises the clamp — option (1) from the description. Two things came out of building it that change the picture.
There is a second path to this, and it is much more reachable than the one described above. The report covers
optimise_export's ladder, which needsbest_soc_minsitting at 99% ofsoc_max— an expert setting at an unusual value. Butclip_export_slotsalso builds a limit the same way, narrowing the target to whatever the simulation says the battery actually reached, less a ten minute discharge margin. When that margin is small the clip lands straight on 99, and the margin is small whenever the discharge rate is modest. Measured against current main with a nearly-full battery:discharge rate clip margin resulting limit outcome 0.05 kWh/min (3 kW) 0.5 kWh 95.3 fine 0.01 kWh/min (600 W) 0.1 kWh 99.3 dead — window inert 0.002 kWh/min (120 W) 0.02 kWh 100.3 above the idle sentinel — window disabled 600 W is an ordinary rate. No unusual configuration is involved on this path at all, just
set_export_low_poweron and a battery that is nearly full when the window runs.The last row is a third failure mode not covered above.
calc_percent_limitcaps the integer part at 100 and the power fraction is added afterwards, so the value can overshootEXPORT_LIMIT_IDLErather than landing inside the reserved interval. That reads as idle and disables the window outright.The PR clamps both paths, with a regression test verified to fail without the fix.
On option (2): the field-split work exists and is further along than when this was written — the Python side is done and rebased. But it also changes the C++ kernel ABI, so it is a large review, and there is a real chance it does not land quickly, or at all in its current form. That is why this raises the clamp now rather than waiting: users are hitting the second path today. If the field split does land, the clamp becomes redundant and should be deleted as part of it — noted in the commit message so it is not left behind.
- added a commit that references this issue
on Sep 10, 2026 Claude here, on Rik's behalf. Closing as fixed by #5047.
In short: an export window can no longer be silently turned off or turned into a freeze by its target landing on 99%. That bug is gone.
Detail: #5047 replaced the packed export-limit float with a
(mode, target, power)tuple, in both the Python planner and the C++ prediction kernel. FREEZE and IDLE are now separate modes that carry no target, so a real target of 99% (or one with a low-power fraction) can no longer be read as a freeze or as idle. The clamp in #5020 was therefore closed as redundant.Some 99.0 / 100.0 constants remain in
const.pyand the legacy pack/unpack helpers inutils.py. They sit at the boundary with gateway code and Enphase's own 99 = hold convention, and are tidy-up rather than a bug, so no separate issue is being opened for them.
Summary
With
set_export_low_poweron andbest_soc_minset to a value that works out at 99% ofsoc_max, the planner produces export limits of 99.3 / 99.5 / 99.7. Those values fall in the range reserved for the freeze sentinel, and the simulator then treats them as neither a forced export nor a freeze — the export window silently does nothing.No error, no warning. The plan shows an export window; the battery just doesn't act on it.
Root cause
An export limit packs three signals into one double: target SoC in the integer part, export power in the fraction, and mode as two reserved whole values (
EXPORT_LIMIT_FREEZE = 99.0,EXPORT_LIMIT_IDLE = 100.0).Because the fraction is live, reserving 99.0 for freeze consumes the whole
[99.0, 100.0)interval — but the encode does not know that. Inoptimise_exportthe limit is built by clamping the integer part to the SoC floor and attaching the rung's power fraction:With a floor of 99 and a low-power rung of 0.3, that yields 99.3.
Consumers then disagree about what 99.3 means, because the codebase tests the sentinel two different ways — 11 sites use
== EXPORT_LIMIT_FREEZE(so 99.3 is a normal export) and 15 use< EXPORT_LIMIT_FREEZE(so it is not).prediction.pyholds both readings at once:prediction.py:900—export_limit_now < EXPORT_LIMIT_FREEZEis False, so it will not force-export.prediction.py:1074—export_limit_now == EXPORT_LIMIT_FREEZEis False, so it will not freeze.Neither branch fires:
execute.py:485/510splits the same way, so the inverter side agrees with the simulator here — the window is consistently inert rather than mismatched between plan and execution.Reproduction
Requires
set_export_low_poweron (default off) andbest_soc_minat 98.5%–99.4% ofsoc_max.best_soc_minis in kWh with a 0.1 step, so the triggering values depend on battery size:soc_maxbest_soc_minvalues that trigger itLarger batteries have more triggering values, since more 0.1 kWh steps fall inside the 1% band.
best_soc_minis an expert-mode setting defaulting to 0, and setting it that close to full is unusual, which is presumably why this has not been reported. It is reachable through normal configuration though, not a contrived value.How this was found
Not from a bug report — it fell out of a refactor. I have been splitting the packed export limit into named fields (target / power / mode) so consumers stop re-deriving intent from magic numbers, on the back of the discussion around #4856.
The sequence was:
test_gateway.test_fractional_export_limit_not_freezefailed. That test pins 99.5 as a low-power export to 99%, so someone had already thought about this value — but only for the gateway path.prediction.pyreads it both ways in the same file.[99.0, 100.0)was unreachable.(mode, power)rungs meant checking exactly which packed values it emits — at which point the SoC floor of 99 turned out to produce 99.3 under both the old and new encoding. The interval is reachable, and always has been. My conclusion in step 3 was wrong.So the refactor did not cause this and does not fix it; it made it visible. Worth noting the test in step 1 was itself evidence the ambiguity was known in one corner of the codebase without being chased through the rest.
Suggested fixes
Two options, and they are not equivalent:
< EXPORT_LIMIT_FREEZEsites ask for the mode instead — correct rather than defensive, but it changes plans for affected users (those windows would start exporting), so it wants to be a deliberate release note rather than a quiet fix.I would take (1) now and (2) as part of splitting the fields properly, which removes the reserved range altogether. Happy to raise a PR for whichever is preferred.