Skip to content

fix(plan): run the export swap after the passes that add exports (#4478) - #4513

Merged
springfall2008 merged 6 commits into
mainfrom
fix/4478-export-swap-ordering
Aug 14, 2026
Merged

springfall2008 merged 6 commits into
mainfrom
fix/4478-export-swap-ordering

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Fixes the "excessive SoC ramp down before the cheap rate slot" reported in #4478.

Problem

optimise_swap_export defers an export to a later window when doing so costs no more than metric_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 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.

On the reporter's second debug capture the sequence was:

  1. The swap's own first accepted move was 22:00-22:30 → 23:40-00:00, which vacated the last export slot before the 23:00 cheap rate.
  2. tweak_plan then backfilled that missing export at 15:40 and 16:15 — the only region it can reach — turning 100.0 into 0.0 on windows 0 and 1.
  3. Nothing ran afterwards to defer them.

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_charge was 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_plan sees an intact 22:30 export and scores the front-of-day options as worse (window 0: 100.0_30 = -2102.52 vs 0.0_20 = -2096.67), so they are never enabled.

Effect on the reported case

Before — exports immediately, SoC ramps down through the peak:

15:40  Exp    limit 80 (69.0)   soc  95
16:00  Exp    limit 72 (60.0)   soc  80
16:30  Demand                   soc  72
...
22:30  Exp    limit 4 (0)       soc  37

After — SoC held through the peak, export deferred to just before the cheap rate:

15:40  Demand                   soc  95
16:00  Demand                   soc 100
...
22:00  Demand                   soc  64
22:30  Exp    limit 26 (14)     soc  60
23:00  Chrg                     soc  26

Trade-off worth noting

The new plan scores ~24.5p worse on the plan metric over the ~30h horizon (-2078.71 vs -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 what metric_min_improvement_swap is there to buy.

Note that this reporter also runs best_soc_keep: 4.0 (default 0.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

  • Both golden debug cases (agile1, pre_saving1) are byte-identical.
  • Full ./run_all passes.
  • New test_optimise_swap_export covers 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 the tweak_plan and optimise_full_second_pass branches. Written test-first; the ordering assertion fails on main with the exact diagnostic.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_export to run after all passes that may enable exports, alongside the existing “run swaps last” logic.
  • Add a new unit test suite covering optimise_swap_export behavior and asserting pass ordering in both tweak_plan and optimise_full_second_pass branches.
  • 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.

Comment thread apps/predbat/plan.py
@springfall2008
springfall2008 merged commit 6477e28 into main Aug 14, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/4478-export-swap-ordering branch August 14, 2026 07:16
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>
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.

2 participants