Repository navigation
fix(plan): run the export swap after the passes that add exports (#4478) - #4513
Merged
Merged
Conversation
optimise_swap_export defers an export to a later window when doing so costs no more than metric_min_improvement_swap, so the battery holds its charge for longer. It can only defer exports that already exist when it runs, and it was running before tweak_plan / optimise_full_second_pass / optimise_solar - all of which turn exports on. tweak_plan makes this concrete: it walks windows in time order and stops after 8, so the only exports it can add are at the front of the plan - exactly the ones the swap exists to push back. In #4478 the swap's own first move (22:00-22:30 -> 23:40-00:00) vacated the last export slot before the cheap rate, tweak_plan backfilled the gap at 15:40 because that is the only region it can reach, and nothing ran afterwards to defer it. The reporter saw the battery ramp down from 95% in the middle of the peak-rate window. Move the export swap to sit with the charge swap after every other pass. The charge swap was already moved there for the mirror-image reason and its comment says so; the export swap simply never got the same treatment. With the swap last, tweak_plan sees an intact 22:30 export and scores the front-of-day options as worse, so they are never enabled. Both golden debug cases are unchanged. Adds test_optimise_swap_export covering the pass itself (defer to an equal-priced later window, leave an already-last export alone, no-op when export optimisation is disabled) and the pass ordering, for both the tweak and second-pass branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a planning-ordering bug in optimise_all_windows where optimise_swap_export previously ran too early to defer exports added by later passes (e.g., tweak_plan, full second pass, and optimise_solar), which could cause premature SoC ramp-down before cheap-rate slots (issue #4478).
Changes:
- Move
optimise_swap_exportto run after all passes that may enable exports, alongside the existing “run swaps last” logic. - Add a new unit test suite covering
optimise_swap_exportbehavior and asserting pass ordering in bothtweak_planandoptimise_full_second_passbranches. - Register the new test suite in the project’s unit test runner.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| apps/predbat/plan.py | Reorders optimise_swap_export to run after other export-enabling passes, addressing the observed SoC ramp-down behavior. |
| apps/predbat/tests/test_optimise_swap_export.py | Adds focused unit tests for export swap behavior plus a regression test that pins the pass ordering. |
| apps/predbat/unit_test.py | Registers the new export-swap test module in the test runner. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…2008/batpred into fix/4478-export-swap-ordering
springfall2008
added a commit
that referenced
this pull request
Aug 15, 2026
Three findings from the Copilot review, all accurate: optimise_plan_pass' docstring claimed "optimise_swap_export runs before this", which was true of tweak_plan when that sentence was written but stopped being true when #4513 moved the swap to run last. Carried over unchanged into the merged pass. Reworded to say what actually holds: the metric is measured from the plan in hand rather than accepted from the caller, so it cannot be stale. test_pv90_switch_off_skips_all_launch_paths' docstring said the Off state was produced by letting fetch_config_options drive pv_metric90_weight to 0.0, but the test sets both the switch and the weight directly and never calls fetch_config_options. Reworded to describe what it does test, and to point at test_pv90_switch_off_forces_weight_zero, which does exercise that wiring. PLAN_PASS_WINDOW_BUDGET was left sitting between two import lines. Moved below the import block. Also removes two duplicated import symbols (calc_percent_limit, wrapped_run_prediction_charge_min_max) flagged in the same review. Those pre-date this PR rather than coming from it, but they are a one-word fix and the review surfaced them here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
springfall2008
added a commit
that referenced
this pull request
Aug 15, 2026
…passes (#4524) * feat(config): add a performance_tweaks toggle and merge the two plan passes Two related changes to the optimiser's final pass and how it is configured. Merge tweak_plan and optimise_full_second_pass into optimise_plan_pass(end_record, budget). They were the same loop - same window sort, same optimise_charge_limit/optimise_export calls, same keep_window_change_if_improved - differing only in tweak_plan's count >= 8 cap and in the export-window start_orig reset, which only tweak_plan performed. The reset now applies on both paths: without it a window trimmed by an earlier pass can only ever be trimmed further, so the pass cannot recover a window it narrowed on a plan that has since changed underneath it. calculate_second_pass now selects budget=0 (every window in the record) rather than a second implementation. Add switch.predbat_performance_tweaks, gating the options that trade planning time for plan quality. It works like expert_mode, but everything behind it defaults to On, so a user who never touches it gets the best plan Predbat can produce; the toggle exists so someone on slow hardware can reveal the switches and turn features off. calculate_second_pass and calculate_pv90_plan move behind it and both now default to On. Note the sharp edge this inherits from the config layer: get_ha_config substitutes an item's default when the item is disabled rather than returning None, so a hidden option is pinned to its default and cannot be held Off from apps.yaml without revealing the toggle. Covered by tests. predbat_debug_pre_saving1's expected plan is regenerated: those captures predate PV90 and do not pin pv_metric90_weight, so the new default switches PV90 on for them. agile1 is unchanged. Adds test_performance_tweaks covering the toggle, its gated items' defaults, hide/reveal behaviour and the fetch_config_options read. test_pv90's default-off assertions are rewritten to force the Off case now that On is the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(random): plan the benchmark scenarios with the shipped second-pass default apply_scenario_to_predbat pinned calculate_second_pass to a literal False, so the random benchmark always measured the budgeted planning path regardless of what users actually run - and silently ignored the switch even when a template set it, which made a second-pass benchmark run look like a null result rather than an experiment that never happened. Read the CONFIG_ITEMS default instead. That keeps the point of pinning it here - every scenario is planned the same way regardless of which template it was run against - while tracking the product default rather than drifting from it. With calculate_second_pass now defaulting On, the 20-scenario benchmark measures the real shipped path: 50.94s total vs 38.21s on main (+33.3%), metric average -3.1704, cost average -3.4125. Those match the earlier stand-in measurement (via PLAN_PASS_WINDOW_BUDGET) to within run-to-run noise. Also rewords one docstring to avoid a cspell miss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cases): plan the debug regression cases with the second pass enabled Both captures pinned calculate_second_pass false in their flat dump, so the golden regression cases exercised only the budgeted planning path. With the switch now defaulting On, that left the shipped path - optimise_plan_pass at budget 0, every window in the record revisited - with no golden coverage at all: a regression there would pass CI untouched. agile1's plan is unchanged by the second pass, so only pre_saving1's expected plan is regenerated. There it is worth having: metric 471.1906 -> 469.2904, found by adding a 15 minute export at 23:45-00:00 (window trimmed from start_orig 1410 to 1425) and pulling the neighbouring export 30 minutes earlier - the same short-late-export shape the #4478 work showed the window budget was missing. Debug cases planning time 5.02s -> 5.91s in the full suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(random): score every benchmark plan in the PV10 and PV90 futures too The benchmark scored each plan only on the central forecast, which cannot judge a hedge. A plan that weights the PV90 scenario deliberately spends money the central forecast says is wasted, so scoring it there marks it down however well it does on the day it was hedging for - the reason the PV90 default looked like a wash on this suite while being a clear win out of sample. run_scenario now records the same plan's cost in all three simulated futures. The PV10 run already existed for compute_metric and was simply discarded; only the PV90 run is new, and two extra predictions per scenario are noise beside calculate_plan (suite mean runtime is unchanged). compare_results grows a per-future summary. Result files written before this contribute nothing to those columns rather than breaking, so older baselines stay comparable on what they do carry. Main vs this branch, 20 scenarios (- = branch cheaper in that future): PV10 (cloudy, heavier load) avg -4.66 7 better, 6 worse nominal avg -3.41 7 better, 5 worse PV90 (sunny, lighter load) avg -10.56 11 better, 2 worse Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Rebase random * [pre-commit.ci lite] apply automatic fixes * docs: describe the performance_tweaks toggle and correct the PV90 defaults apps-yaml.md still said the 90% estimate is "not used by default", that the switch is "Off by default" and that it sits outside expert mode "as we would like this tested widely". All three are now wrong: PV90 is on by default and the switch lives behind performance_tweaks. The performance section only told users to turn off the second pass. PV90 is the larger of the two costs, so name both, say what each buys, and give a sense of the scale - including that the second pass gets relatively more expensive as the plan horizon lengthens, which a single percentage would hide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(spec): note that the monotonic plan passes design has been superseded in part tweak_plan and optimise_full_second_pass were merged into optimise_plan_pass, so the two function names this spec is written around no longer exist. Left as the dated record it is, with a note saying what changed and confirming the monotonicity guarantee it specifies still holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address review feedback on the plan pass and PV90 tests Three findings from the Copilot review, all accurate: optimise_plan_pass' docstring claimed "optimise_swap_export runs before this", which was true of tweak_plan when that sentence was written but stopped being true when #4513 moved the swap to run last. Carried over unchanged into the merged pass. Reworded to say what actually holds: the metric is measured from the plan in hand rather than accepted from the caller, so it cannot be stale. test_pv90_switch_off_skips_all_launch_paths' docstring said the Off state was produced by letting fetch_config_options drive pv_metric90_weight to 0.0, but the test sets both the switch and the weight directly and never calls fetch_config_options. Reworded to describe what it does test, and to point at test_pv90_switch_off_forces_weight_zero, which does exercise that wiring. PLAN_PASS_WINDOW_BUDGET was left sitting between two import lines. Moved below the import block. Also removes two duplicated import symbols (calc_percent_limit, wrapped_run_prediction_charge_min_max) flagged in the same review. Those pre-date this PR rather than coming from it, but they are a one-word fix and the review surfaced them here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.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.
Fixes the "excessive SoC ramp down before the cheap rate slot" reported in #4478.
Problem
optimise_swap_exportdefers an export to a later window when doing so costs no more thanmetric_min_improvement_swap(default-0.25p), so the battery holds its charge for longer. It can only defer exports that already exist when it runs, and it was running beforetweak_plan/optimise_full_second_pass/optimise_solar— all of which turn exports on.tweak_planmakes this concrete: it walks windows in time order and stops after 8, so the only exports it can add are at the front of the plan — exactly the ones the swap exists to push back.On the reporter's second debug capture the sequence was:
22:00-22:30 → 23:40-00:00, which vacated the last export slot before the 23:00 cheap rate.tweak_planthen backfilled that missing export at15:40and16:15— the only region it can reach — turning100.0into0.0on windows 0 and 1.Result: the battery ramps down from 95% in the middle of the peak-rate window, which is what the reporter saw.
Fix
Move the export swap to sit with the charge swap after every other pass.
optimise_swap_chargewas already moved there for the mirror-image reason and its comment says so; the export swap never got the same treatment.With the swap running last,
tweak_plansees an intact 22:30 export and scores the front-of-day options as worse (window 0:100.0_30=-2102.52vs0.0_20=-2096.67), so they are never enabled.Effect on the reported case
Before — exports immediately, SoC ramps down through the peak:
After — SoC held through the peak, export deferred to just before the cheap rate:
Trade-off worth noting
The new plan scores ~24.5p worse on the plan metric over the ~30h horizon (
-2078.71vs-2103.21). That gap is an extra arbitrage cycle the old plan took — roughly 3.6 kWh exported at 13.92p in the afternoon and bought back as 4.2 kWh at 6.99p overnight. Real money if the forecast holds; the objection to it is robustness, which is precisely whatmetric_min_improvement_swapis there to buy.Note that this reporter also runs
best_soc_keep: 4.0(default0.0), which is the larger driver of the early placement being preferred at all — see the issue thread for the measurements. This PR does not touch the keep term.Plan time is unaffected (single call, just relocated): 11.0s vs 12.9s on the reported case.
Testing
agile1,pre_saving1) are byte-identical../run_allpasses.test_optimise_swap_exportcovers the pass itself — defer to an equal-priced later window, leave an already-last export alone, no-op when export optimisation is disabled — plus the pass ordering, asserted for both thetweak_planandoptimise_full_second_passbranches. Written test-first; the ordering assertion fails onmainwith the exact diagnostic.🤖 Generated with Claude Code