Skip to content

fix(prediction): model battery discharge for a genuine Freeze Export shortfall - #4679

Open
chalfontchubby wants to merge 5 commits into
mainfrom
fix/freeze-export-shortfall-discharge
Open

fix(prediction): model battery discharge for a genuine Freeze Export shortfall#4679
chalfontchubby wants to merge 5 commits into
mainfrom
fix/freeze-export-shortfall-discharge

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Summary

Freeze Export is documented as allowing battery discharge to cover a load shortfall (customisation.md "Freeze Export during Demand": "allows battery discharge, but not battery charging") - but the simulation left battery_draw at 0 whenever load exceeded PV, silently modelling the same outcome as Freeze Charge (grid covers it, SoC held flat) instead. So a plan slot labelled FreezeExp showed the wrong behaviour, regardless of whether Freeze Export was even the right mode to pick for that slot in the first place (a separate, bigger issue - #4677).

Root-caused while investigating #4257 - full trace and the reporter's own debug.yaml replay in that thread.

Changes

  • prediction.py: added the missing shortfall-discharge branch to the freeze-export elif, mirroring the existing ECO-mode discharge logic a few lines away.
  • prediction_kernel.cpp: same fix, kept in parity with the Python engine (kernel rebuilt, binaries will be picked up by the cross-build CI job).
  • Scoped the existing AlphaESS residual-discharge special case (inverter_freeze_export_discharge_rate) to only fire when nothing else has already moved the battery (battery_draw == 0 rather than >= 0), so it can't clobber a genuine shortfall discharge.
  • New test freeze_export_ac_flow_shortfall_discharges in test_model.py, passing on both the Python engine and the C++ kernel.
  • Two golden-snapshot regressions regenerated as an expected consequence of the behaviour actually changing: debug_cases/predbat_debug_pre_saving1.yaml and the random-scenario baseline (20 scenarios, several metrics shifted - checked each shift is directionally explained by the fix, not a red flag).

Test plan

  • ./run_all --quick passes
  • ./run_all --test model --test model_kernel - new scenario passes on both engines
  • ./run_all --test kernel_parity - Python/C++ kernel stay in sync
  • ./run_pre_commit passes (ruff, black, cspell, markdownlint, full test suite incl. random regression)

Fixes #4676. Root-caused via #4257.

🤖 Generated with Claude Code

…shortfall

Freeze Export is documented as allowing battery discharge to cover a load
shortfall (customisation.md "Freeze Export during Demand"), but the
simulation left battery_draw at 0 whenever load exceeded PV - silently
modelling the same behaviour as Freeze Charge (grid covers it, SoC held
flat) instead. So a plan slot labelled FreezeExp showed the wrong outcome
regardless of whether FreezeExp was the right mode to pick in the first
place.

Adds the missing shortfall-discharge branch (mirrors the existing ECO-mode
logic a few lines away) to both the Python engine and the C++ kernel, and
scopes the AlphaESS residual-discharge special case to only apply when
nothing else has already moved the battery, so it can't clobber a genuine
shortfall discharge.

Two golden-snapshot regressions regenerated as an expected consequence
(plans now correctly show battery movement during a FreezeExp shortfall):
debug_cases/predbat_debug_pre_saving1.yaml and the random-scenario
baseline.

Fixes #4676. Root-caused via #4257.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Reopening to retrigger CI - the bot's kernel-binaries auto-fix push doesn't trigger a new check run on its own.

Comment thread apps/predbat/prediction.py Outdated
# limit rather than capping the discharge at house demand. Only applies when
# nothing else has already decided a movement - a genuine shortfall discharge
# (above) already covers the real load more precisely than this residual estimate.
if inverter_freeze_export_discharge_rate > 0 and battery_draw == 0:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Isn't ==0 on a floating point number quite unlikely?

springfall2008 and others added 3 commits August 23, 2026 22:43
…l precedence

Two follow-ups to the freeze export shortfall discharge added in this branch.

battery_draw is a DC quantity but the shortfall coming out of get_diff is AC, so
it has to be grossed up through the inverter the way the ECO branch already does
(diff * inverter_loss_recp). Without that the battery only covers inverter_loss
of the load and the remainder is billed as a phantom grid import - measured at
1kW load, no PV, inverter_loss 0.9: ECO discharged 1.111kWh for £0.00 while
Freeze Export discharged 1.000kWh and imported the balance for £0.01. The
existing tests could not see this because simple_scenario defaults
inverter_loss=1.0, which collapses the two cases.

inverter_freeze_export_discharge_rate is set by users whose inverter does not
cover house load during Freeze Export - it only leaks a small fixed rate. The
shortfall discharge was overriding it, so the setting stopped doing anything in
exactly the overnight case it was added for. It now takes precedence again. The
existing 240W tests passed only because they set battery_rate_max_charge=0.0,
which also zeroes battery_rate_max_discharge, so none of them exercised the
interaction; with a realistic 1kW rate the configured 240W leak was replaced by
a full 1kW load-covering discharge.

Adds a test per fix, both watched failing first. Docs updated: the default
Freeze Export behaviour is now load-covering, so the apps-yaml text describing
the battery as held flat was no longer true.

Golden baselines regenerated. Both sides of the random comparison were generated
on the same machine - the runtime figures in a cross-machine comparison are
meaningless (that run showed a spurious +42.6%; same-machine it is -2.2%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Freeze Export is not a distinct inverter mode. execute.py implements it by
calling adjust_charge_rate(0) (or adjust_pause_mode(pause_charge=True)) and
otherwise leaving the inverter in Demand/ECO mode - it never touches the
discharge rate. Modelling it as a parallel branch that re-derives the same AC
balance is what allowed the two to drift apart: the branch pinned battery_draw
at 0, which is Freeze Charge behaviour, and that is #4676.

The branch is gone. Freeze Export now runs the shared ECO flow with the charge
rate scaled to zero, then applies the three things that are genuinely specific
to it: the #4207 recapture of surplus the export limit cannot absorb, the
AlphaESS residual discharge rate, and the fz+/fz-/fz~ plan labels. The residual
block's existing "battery_draw >= 0" guard gives it precedence over the ECO
shortfall discharge for free, so no extra gate is needed.

The charge rate is scaled at the point of use rather than zeroed upstream
because the non-hybrid #4207 recapture reads the same charge_rate_now_curve_step
and needs it unscaled.

Same change in prediction_kernel.cpp; kernel parity holds.

Behaviour-neutral: this commit changes no baseline file, and the golden plan
snapshot plus all 20 random scenarios were generated against the previous commit
and still pass here unchanged. Verified independently by generating the random
sweep from both states on the same machine - 0 of 20 scenarios differ in any
recorded field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008

Copy link
Copy Markdown
Owner

Reviewed this and pushed two commits onto the branch (708e9a1, f063890) rather than leaving the findings as comments — happy to drop either if you disagree with the call.

Two problems with the fix as written

1. The shortfall discharge wasn't grossed up for inverter loss. The PR describes the new branch as mirroring the ECO discharge logic, but it mirrors the min() clamp and not the diff feeding it. ECO deliberately converts the AC shortfall to a DC draw (required_for_load += (load - pv_ac) * inverter_loss_recp - (load - pv_ac), giving diff = shortfall / inverter_loss). The freeze branch passed the raw get_diff result — an AC figure — straight into battery_draw, which is DC. The battery therefore covered only inverter_loss of the load and the remainder became a phantom grid import of shortfall × (1 - inverter_loss).

Measured at 1 kW load, no PV, one hour, inverter_loss=0.9, with the discharge rate and inverter limit both non-binding:

final SoC metric
ECO (discharge=100) 8.89 £0.00
FreezeExp (discharge=99) 9.00 £0.01

Identical in both engines. The existing tests couldn't catch it because simple_scenario defaults inverter_loss=1.0, which collapses the two cases.

2. The AlphaESS residual rate was silently neutered. Changing the guard to battery_draw == 0 plus the new elif meant inverter_freeze_export_discharge_rate only fired when there was no load shortfall — i.e. never, in the overnight case it exists for. Every existing 240W test passes only because it sets battery_rate_max_charge=0.0, which also zeroes battery_rate_max_discharge, so none of them exercised the interaction:

battery_rate_max_charge main this PR (before)
0.0 (as tested) SoC 9.76, £0.08 SoC 9.76, £0.08
1.0 (realistic) SoC 9.76, £0.08 SoC 9.00, £0.00

The premise of that setting is that those inverters don't cover house load during Freeze Export, so it now takes precedence again. docs/apps-yaml.md said the battery is "held flat" by default, which is no longer true either way — updated.

Both fixes have a test, each watched failing first.

Why the branch shouldn't exist

Freeze Export is not a distinct inverter mode. execute.py:486-496 and execute.py:527-539 implement it by calling adjust_charge_rate(0) (or adjust_pause_mode(pause_charge=True)) and otherwise leaving the inverter in Demand/ECO mode — the discharge rate is never touched. So it is ECO with the charge rate at zero, literally, not by analogy.

Modelling it as a parallel branch that re-derives the same AC balance is what let the two drift apart, and #4676 is that drift: the branch pinned battery_draw at 0, which is Freeze Charge behaviour. Finding 1 above is the same failure mode recurring inside the fix — a hand-copied line that diverged from its original within the same PR.

The second commit removes the branch. Freeze Export runs the shared ECO flow with charge_rate_scale = 0, then applies the three things genuinely specific to it: the #4207 recapture, the residual rate, and the fz* labels. That commit changes no baseline file, which is itself the neutrality evidence — the golden snapshot and all 20 random scenarios were generated against the previous commit and pass here unchanged.

On whether the residual rate could just be folded into the rate used by the ECO flow, removing that block too: it can't. freeze_export_ac_flow_no_load_exports_residual is the counterexample — load 0, PV 0, residual 240W, metric -1.2 because it earns money exporting the leak. A rate limit can only shrink a demand-driven discharge; it can't create one when diff == 0. It's a floor as well as a cap, so it stays a post-step.

Why the scenarios moved

Worth being precise here, because the PR notes checked the shifts were "directionally explained" and I think the actual mechanism is more interesting than that.

First, attribution. The fold is provably inert: generating the random sweep from the fixes-only state and the folded state on the same machine gives 0 of 20 scenarios differing in any recorded field. inverter_freeze_export_discharge_rate is not set in any scenario, so fix 2 is inert too. Every delta therefore comes from one line — the gross-up — with inverter_loss: 0.96 in the template, so a 4.2% change in freeze-export shortfall discharge.

16 of 20 scenarios are bit-identical. Of the four that move:

scenario metric delta plan changed?
0 +1.82 yes — export and charge windows both
9 +0.26 yes — export windows
17 -0.01 no — identical plan
19 +6.56 yes — export and charge windows both

Scenario 17 is the only pure re-pricing, and it moves in the better direction: same plan, phantom import removed.

The other three are the optimiser choosing a different plan, so their metric deltas are not a re-pricing and shouldn't be read as "the plan got 6.5p worse". Scenario 19 is the clearest case — it plans zero freeze-export slots under both versions, yet has the largest change. Freeze-export candidates are evaluated at every candidate window even when none survives into the final plan, so a 4% shift in their cost flips tie-breaks against the metric_min_improvement_export_freeze thresholds and the divergence cascades through the greedy search. Scenarios 1, 2 and 10 conversely do plan freeze-export slots and are unchanged — presumably those slots sit in solar surplus where the shortfall branch never fires.

The honest caveat: once the model changes underneath the optimiser, comparing metrics across baselines isn't apples-to-apples, and I have not shown the newly chosen plans are physically better or worse. Doing that properly means scoring the old plan under the new model. Say the word and I'll build it.

One thing that is worth a look regardless: in the pre_saving1 golden case, freeze-export slots went 2 (main) → 1 (this PR) → 0 (after these commits). Pricing the mode correctly makes it much less attractive, which is the right outcome but a big behavioural shift, and it bears on #4677.

Verification

./run_all full suite passes (232s), ./run_pre_commit clean, --test kernel_parity green. Golden snapshot and random baseline regenerated with both sides generated on this machine — worth noting the cross-machine runtime comparison in the original PR is meaningless, it showed +42.6% where the same-machine figure is -2.2%.

Caveat on the binaries: I could only rebuild prediction_kernel_lib_darwin_arm64.so (no zig available), and with Apple clang rather than the cross-toolchain, hence the size change. The other five are stale against the new .cpp until the cross-build job regenerates them — verify_kernel_binary.py in code-quality.yml should fail on x86_64 first.

Unrelated, spotted in passing

When set_export_freeze_only is set with export_limit_now below 99, the model takes the freeze path (prediction.py:1065 before these commits) but execute.py:506 takes "Hold exporting", which leaves charging enabled. Pre-existing, out of scope here, but it looks like a real mismatch.

🤖 Generated with Claude Code

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.

Freeze Export simulation doesn't model battery discharge for a load shortfall

2 participants