fix(plan): retain self-consumption buffer from cheapest export slot, not the peak#4294
Merged
Conversation
…not the peak Split the export/import trim sub-pass of optimise_detailed_pass into trim_export and trim_import so each sheds its least valuable slot first. Coming out of the levels pass every export window above the price threshold is switched fully on, which over-exports and forces expensive house import before the next charge. The old single "trim" pass then clawed the excess back highest-price first, so it clipped the most valuable peak export window (e.g. an Agile 19.67p slot) while leaving cheaper 12-16p slots fully exported. trim_export now runs first, walks price bands low to high, is reduce-only and only touches export windows, so the cheapest slots absorb the over-export and the peak is left fully exported. trim_import runs after it, high to low, reduce-only, charge only (the correct direction for charge - drop the priciest charging first). Both passes derive their iteration order locally instead of mutating price_set in place. On the reporter's data (issue #4230) the 19.67p peak is now exported for its full duration instead of being clipped, improving the plan metric by ~3.2p. Adds test_trim_export covering the buffer-from-cheapest-slot behaviour. The two debug regression cases that shift are metric-identical (benign charge-window representation changes) and have been re-baselined. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adjusts the detailed optimisation phase so that when export must be clawed back to preserve a self-consumption buffer, the reduction is taken from the cheapest export slots first (keeping the most valuable peak fully exported). It introduces distinct export vs import trim passes and adds a regression test plus updated debug-case baselines.
Changes:
- Split the prior trim logic into
trim_export(low→high, export-only, reduce-only) andtrim_import(high→low, charge-only, reduce-only), each deriving its own iteration order without mutatingprice_set. - Add and register
test_trim_exportto validate that the peak export slot is not clipped when trimming is needed. - Update expected outputs for impacted debug cases (
agile1,pre_saving1).
Reviewed changes
Copilot reviewed 3 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/predbat/plan.py | Splits trim passes and changes price-band iteration order to trim export from cheapest first. |
| apps/predbat/tests/test_trim_export.py | Adds regression test scenario for export-trim ordering / buffer retention. |
| apps/predbat/unit_test.py | Registers the new trim_export test in the test runner. |
| coverage/cases/predbat_debug_agile1.yaml.expected.json | Updated debug-case expected output after optimiser behaviour change. |
| coverage/cases/predbat_debug_pre_saving1.yaml.expected.json | Updated debug-case expected output after optimiser behaviour change. |
Comments suppressed due to low confidence (1)
apps/predbat/tests/test_trim_export.py:109
- The test only considers a cheaper window “reduced” if it is disabled (100) or start-clipped. The optimiser can also retain buffer by raising the export SoC limit (shallower discharge) or selecting freeze (99) without clipping the start, which would incorrectly fail this test.
window = my_predbat.export_window_best[n]
if my_predbat.export_limits_best[n] >= 100 or window["start"] > (mn + 30 * n):
cheaper_reduced = True
break
…ering The debug regression cases (agile1, pre_saving1) passed standalone but failed in the full suite. Two derived attributes are neither saved in the debug yaml nor reset by reset_inverter, so they leaked from an earlier test and changed the plan: - dynamic_load_baseline: feeds the load model calculate_plan rebuilds. It is normally (re)computed by dynamic_load(), which only runs from update_pred, not the debug harness, so a leaked value flowed straight into the load rebuild. - battery_rate_max_export: the discharge power cap used in prediction. A leaked full-precision value (vs the 0.0333 default) flipped pre_saving1's plan at a decision boundary (10 -> 11 charge windows). Reset both to their standalone-run defaults in run_single_debug so each case is deterministic regardless of test ordering. Confirmed by state-diffing the pre-plan snapshot between a standalone and full-suite run; the committed baselines already match the deterministic plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trim_export acceptance guard accepted any option with a higher SoC limit regardless of its start. Because optimise_export is called after the window start is reset to start_orig, a later trim_export pass on an already-trimmed window could return a higher limit together with an earlier start (a bigger window), which exports more - contradicting the reduce-only intent. Require a reduction in both dimensions: a shallower discharge (higher SoC limit) and no earlier start. Off/freeze (limit >= 99) export no battery and force the start back to the full window, so they stay exempt from the earlier-start check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The peak assertion only flagged limit >= 100 (off), so a peak returned as 99 (export freeze) would keep the battery from exporting yet still pass. Fail when the peak is off or frozen (>= 99). Likewise count a cheaper window as reduced when it is frozen, not only when disabled or clipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Reported in #4230 (and the same shape as the now-closed #4272): on Agile-style export tariffs Predbat stops exporting during the most profitable slot and resumes later on a cheaper one.
Root cause traced to
optimise_detailed_pass:optimise_levels_passswitches every export window above the price threshold fully on. When the battery is power limited and a cheap charge is several hours away, this over-exports and forces expensive house import before the next charge, so the detailed pass must claw some export back.trimsub-pass walked price bands highest-first, so the first window it handed tooptimise_exportwas the highest-priced peak — and shrinking it relieved the over-export. Result: the peak (e.g. a 19.67p slot) was clipped while cheaper 12–16p slots stayed fully exported. The buffer was carved out of the most valuable slot instead of the least valuable one.Fix
Split
triminto two directional, reduce-only passes:trim_export— runs first, price bands low→high, export windows only, reduce-only. The cheapest slots absorb the levels over-export, leaving the high-priced peak fully exported.trim_import— after it, high→low, charge windows only, reduce-only (the correct direction for charge: drop the priciest charging first; matches prior behaviour).Both passes derive their iteration order locally rather than mutating
price_setin place.Result
On the #4230 debug data, the 19.67p peak is now exported for its full duration (previously clipped 5 min at the start), improving the plan metric by ~3.2p. #4272 is unchanged (that one was a
metric_min_improvement_planstickiness/config issue, not the optimiser).Tests
test_trim_export— synthetic buffer scenario; verified it fails before the change (peak clipped) and passes after.optimise_windows_kernel(Python + C++ kernel), full--quicksuite, anddebug_casesall pass.debug_casesbaselines that shifted (agile1,pre_saving1) are metric-identical — benign charge-window representation changes — and have been re-baselined.Notes / caveats
trim_importhalf is symmetry/formalisation — the bug is entirely export-side. It is metric-neutral on every case tested but does introduce minor charge-window churn; happy to drop it and keep onlytrim_exportif preferred.🤖 Generated with Claude Code