Skip to content

Export window silently does nothing when best_soc_min lands on 99% and low power export is on #4914

Description

@chalfontchubby

Written by Claude Code on behalf of @chalfontchubby.

Summary

With set_export_low_power on and best_soc_min set to a value that works out at 99% of soc_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. In optimise_export the limit is built by clamping the integer part to the SoC floor and attaching the rung's power fraction:

this_export_limit = max(calc_percent_limit(self.best_soc_min, self.soc_max), int(this_export_limit))
this_export_limit = this_export_limit + loop_limit - int(loop_limit)

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.py holds both readings at once:

  • prediction.py:900 — export_limit_now < EXPORT_LIMIT_FREEZE is False, so it will not force-export.
  • prediction.py:1074 — export_limit_now == EXPORT_LIMIT_FREEZE is False, so it will not freeze.

Neither branch fires:

freeze sentinel        limit=99.0   force_export=False  freeze_export=True   => FREEZES
99% target @70% power  limit=99.3   force_export=False  freeze_export=False  => DOES NOTHING
50% target @70% power  limit=50.3   force_export=True   freeze_export=False  => EXPORTS

execute.py:485/510 splits 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_power on (default off) and best_soc_min at 98.5%–99.4% of soc_max. best_soc_min is in kWh with a 0.1 step, so the triggering values depend on battery size:

soc_max best_soc_min values that trigger it
9.5 kWh 9.4
10 kWh 9.9
13.5 kWh 13.3, 13.4
15 kWh 14.8, 14.9
20 kWh 19.7, 19.8
30 kWh 29.6, 29.7, 29.8

Larger batteries have more triggering values, since more 0.1 kWh steps fall inside the 1% band.

best_soc_min is 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:

  1. While converting call sites, test_gateway.test_fractional_export_limit_not_freeze failed. 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.
  2. Auditing the sentinel comparisons to keep the conversion inert turned up the 11-vs-15 split, and that prediction.py reads it both ways in the same file.
  3. I recorded it as latent, believing [99.0, 100.0) was unreachable.
  4. Rewriting the export ladder as explicit (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:

  1. Clamp the SoC floor below the reserved range — cheapest, keeps the encoding, no plan changes for anyone not currently affected. A target of 99% at reduced power simply becomes unavailable, which is what everyone already assumes.
  2. Make the < EXPORT_LIMIT_FREEZE sites 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.

Activity

  1. springfall2008 commented on Sep 3, 2026

    @springfall2008
    Owner

    Automated first-pass triage (a maintainer will review before any action is taken).

    Classification: bug (labels already reflect this — bug / priority_low / Root Caused all 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 to calc_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 a 0.3 rung this emits 99.3 (also 99.5 / 99.7 from the 0.5 / 0.7 rungs).
    • apps/predbat/utils.py:1499 — calc_percent_limit rounds 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_FREEZE is False at 99.3, so no forced discharge; apps/predbat/prediction.py:1074 — export_limit_now == EXPORT_LIMIT_FREEZE is 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 with adjust_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_power is 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_min floor of exactly 99% with the full-power rung (0.0) emits 99.0 — which is EXPORT_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.

  2. chalfontchubby commented on Sep 3, 2026

    @chalfontchubby
    CollaboratorAuthor

    This is perhaps not worth a fix if I'm able to eliminate the overloading of the Soc target.

  3. chalfontchubby commented on Sep 9, 2026

    @chalfontchubby
    CollaboratorAuthor

    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 needs best_soc_min sitting at 99% of soc_max — an expert setting at an unusual value. But clip_export_slots also 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_power on and a battery that is nearly full when the window runs.

    The last row is a third failure mode not covered above. calc_percent_limit caps the integer part at 100 and the power fraction is added afterwards, so the value can overshoot EXPORT_LIMIT_IDLE rather 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.

  4. chalfontchubby commented on Oct 7, 2026

    @chalfontchubby
    CollaboratorAuthor

    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.py and the legacy pack/unpack helpers in utils.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions