Skip to content

feat(config): add a performance_tweaks toggle and merge the two plan passes - #4524

Merged
springfall2008 merged 9 commits into
mainfrom
feat/performance-tweaks-toggle
Aug 15, 2026
Merged

springfall2008 merged 9 commits into
mainfrom
feat/performance-tweaks-toggle

Conversation

@springfall2008

@springfall2008 springfall2008 commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

Two related changes to the optimiser's final pass and how it is configured.

1. Merge tweak_plan and optimise_full_second_pass

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
  • the export-window start_orig reset, which only tweak_plan performed

Now one function, optimise_plan_pass(end_record, budget). calculate_second_pass selects budget=0 (every window in the record) instead of a second implementation. Net −48 lines.

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.

This part is provably behaviour-preserving. With PV90 held off, the merged pass reproduces main exactly across the 20-scenario random benchmark: all 20 identical on metric and cost, total runtime 38.21s vs 38.21s.

2. switch.predbat_performance_tweaks

A second gating toggle alongside expert_mode, defaulting Off. Behind it sit the options that trade planning time for plan quality — and unlike expert mode, everything behind it defaults to On. 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.

Moved behind it, both now defaulting to On:

  • switch.predbat_calculate_second_pass (was expert-gated, default Off)
  • switch.predbat_calculate_pv90_plan (was ungated, default Off)

Sharp edge worth knowing

get_ha_config substitutes an item's default when the item is disabled rather than returning None, so get_arg never reaches its self.args lookup. A hidden option is pinned to its default and cannot be held Off from apps.yaml without revealing the toggle. This is pre-existing behaviour for every expert-gated item, but it matters more now that hidden means "on". Documented and covered by tests.

Evidence for the default flips

PV90

The random benchmark scored every plan only on the central forecast, which cannot judge a hedge: a plan that weights PV90 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. This PR extends the harness to record each plan's cost in all three simulated futures, so the effect is reproducible from the repo rather than from a one-off script.

Main vs this branch, 20 scenarios (− = branch cheaper in that future):

future avg min max better worse unchanged
PV10 (cloudy, heavier load) −4.66 −68.86 +14.98 7 6 7
nominal −3.41 −33.91 +1.69 7 5 8
PV90 (sunny, lighter load) −10.56 −53.38 +1.79 11 2 7

Cheaper on average in all three futures, and strongly so in the one PV90 exists for (11–2). Isolating PV90 alone — second pass off, both plans built from identical data — gives the same shape at smaller magnitude: −1.00 / −1.17 / −5.24, winning 10–2 in the sunny future.

Caveat on magnitude: the benchmark's P90 series is synthetic (pv90 = pv × 1.25, PV90_FACTOR fixed in the harness) and the sunny case also applies load_scaling90 = 0.7, so the size of the win is an artefact of those factors even though the direction is not.

Second pass

apply_scenario_to_predbat pinned calculate_second_pass to a literal False, so the random benchmark could never measure this switch — and silently ignored a template that set it, which made an earlier second-pass run look like a null result rather than an experiment that never happened. This PR changes the harness to read the CONFIG_ITEMS default instead, so the benchmark measures the shipped planning path.

With that fixed, main vs this branch on the real config read: metric average −3.1704, cost average −3.4125, 6 better / 7 worse / 7 unchanged. Those match the earlier stand-in measurement (forcing PLAN_PASS_WINDOW_BUDGET = 0, which is exactly what the switch selects) to within run-to-run noise, so both measurements agree.

Raising the budget also never regressed a scenario in isolation: at budget 40, 2 better / 0 worse / 18 unchanged; unbudgeted, 5 better / 0 worse / 15 unchanged.

Runtime cost

20-scenario random benchmark, total wall-clock across the suite:

config total mean median slowest vs main
main 38.21s 1.910s 0.869s 8.87s —
merged pass only 38.21s 1.910s 0.872s 8.85s +0.0%
+ PV90 on 47.39s 2.369s 1.165s 11.19s +24.0%
as shipped (both defaults on) 50.94s 2.547s 1.275s 11.62s +33.3%

The final row is a real config read via the fixed harness, not a stand-in. Worst single scenario: +2.75s (2.07x). The cost scales with horizon length — a 48-hour plan pays more than these 24-hour scenarios (the #4478 capture went 11.0s → 15.6s for the second pass alone).

This is the whole reason for the toggle: users on a Pi can turn either feature off and get the old planning time back.

Golden cases

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. The change is modest — exports slightly more (export_limits_best [7.0, 85] → [6.0, 85], target 12 → 10) with a mildly restructured charge plan, at the same nominal metric of 471.1906. agile1 is unchanged.

Testing

  • Full ./run_all passes; pre-commit clean.
  • New test_performance_tweaks covers the toggle, its gated items' defaults, hide/reveal behaviour, and the fetch_config_options read.
  • test_export_commitment's two monotonicity tests now exercise the merged pass at budget=8 and budget=0.
  • test_pv90's default-off assertions rewritten to force the Off case now that On is the default, plus a new test pinning the hidden-item-resolves-to-default mechanism.

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings August 14, 2026 20:22

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 refactors Predbat’s optimiser “final pass” by merging the previous tweak/second-pass implementations into a single optimise_plan_pass(...) with a window budget, and introduces a new performance_tweaks gating switch that hides (but defaults-on) the more CPU-intensive plan-quality features.

Changes:

  • Merge the old tweak/second-pass loops into optimise_plan_pass(end_record, budget=...), and drive the pass via PLAN_PASS_WINDOW_BUDGET vs “unlimited” for the full second pass.
  • Add switch.predbat_performance_tweaks and move calculate_second_pass + calculate_pv90_plan behind it, flipping both defaults to On while hidden.
  • Update docs and tests to reflect the new toggle, new defaults, and the merged pass behavior.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/customisation.md Documents the new performance tweaks toggle and updated defaults/visibility rules.
coverage/cases/predbat_debug_pre_saving1.yaml.expected.json Updates golden output to match new default planning behavior.
apps/predbat/unit_test.py Registers the new performance tweaks test suite.
apps/predbat/tests/test_pv90.py Updates PV90 tests for new gating/defaults and adds coverage for hidden-default behavior.
apps/predbat/tests/test_performance_tweaks.py Adds new unit tests covering the performance_tweaks gate, defaults, and hide/reveal semantics.
apps/predbat/tests/test_optimise_swap_export.py Updates pass-order assertions for the merged plan pass.
apps/predbat/tests/test_export_commitment.py Updates monotonicity tests to exercise optimise_plan_pass in budgeted and unbudgeted modes.
apps/predbat/plan.py Implements optimise_plan_pass, introduces PLAN_PASS_WINDOW_BUDGET, and removes the duplicate second-pass implementation.
apps/predbat/config.py Adds performance_tweaks config item and gates/updates defaults for second pass and PV90 switches.
Suppressed comments (1)

apps/predbat/plan.py:2895

  • The optimise_plan_pass docstring says optimise_swap_export runs before this, but optimise_all_windows calls optimise_plan_pass before optimise_swap_export. This makes the rationale for re-baselining the metric misleading.
        The metric is measured from the plan we were handed rather than taken from the caller:
        optimise_swap_export runs before this and mutates the plan without its return value being used,
        so the caller's metric is already stale.

💡 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 Outdated
Comment on lines 30 to 31
from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, calc_percent_limit, in_car_slot
from prediction import Prediction, wrapped_run_prediction_single, wrapped_run_prediction_charge, wrapped_run_prediction_charge_min_max, wrapped_run_prediction_export, wrapped_run_prediction_charge_min_max
Comment thread apps/predbat/tests/test_pv90.py Outdated
Comment on lines +998 to +1000
The switch defaults On now, so the Off state is produced the way the product produces it - by switching it
off and letting fetch_config_options' override drive pv_metric90_weight to 0.0 - rather than by setting the
weight by hand. A regression that broke the switch-to-weight wiring is still caught here.
springfall2008 and others added 8 commits August 14, 2026 21:31
…ss 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>
…bled

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>
… 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>
…aults

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>
…seded 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>
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
springfall2008 merged commit 884d78f into main Aug 15, 2026
2 checks passed
@chalfontchubby
chalfontchubby deleted the feat/performance-tweaks-toggle branch August 21, 2026 09:43
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