fix(prediction): model battery discharge for a genuine Freeze Export shortfall - #4679
fix(prediction): model battery discharge for a genuine Freeze Export shortfall#4679chalfontchubby wants to merge 5 commits into
Conversation
…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>
|
Reopening to retrigger CI - the bot's kernel-binaries auto-fix push doesn't trigger a new check run on its own. |
| # 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: |
There was a problem hiding this comment.
Isn't ==0 on a floating point number quite unlikely?
…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>
|
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 written1. 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 Measured at 1 kW load, no PV, one hour,
Identical in both engines. The existing tests couldn't catch it because 2. The AlphaESS residual rate was silently neutered. Changing the guard to
The premise of that setting is that those inverters don't cover house load during Freeze Export, so it now takes precedence again. Both fixes have a test, each watched failing first. Why the branch shouldn't existFreeze Export is not a distinct inverter mode. 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 The second commit removes the branch. Freeze Export runs the shared ECO flow with On whether the residual rate could just be folded into the rate used by the ECO flow, removing that block too: it can't. Why the scenarios movedWorth 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. 16 of 20 scenarios are bit-identical. Of the four that move:
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 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 Verification
Caveat on the binaries: I could only rebuild Unrelated, spotted in passingWhen 🤖 Generated with Claude Code |
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 leftbattery_drawat0whenever load exceeded PV, silently modelling the same outcome as Freeze Charge (grid covers it, SoC held flat) instead. So a plan slot labelledFreezeExpshowed 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).inverter_freeze_export_discharge_rate) to only fire when nothing else has already moved the battery (battery_draw == 0rather than>= 0), so it can't clobber a genuine shortfall discharge.freeze_export_ac_flow_shortfall_dischargesintest_model.py, passing on both the Python engine and the C++ kernel.debug_cases/predbat_debug_pre_saving1.yamland 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 --quickpasses./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_commitpasses (ruff, black, cspell, markdownlint, full test suite incl.randomregression)Fixes #4676. Root-caused via #4257.
🤖 Generated with Claude Code