fix(plan): stop tweak and second pass regressing the plan - #4402
Merged
Conversation
Records the root cause analysis for tweak_plan and optimise_full_second_pass writing back window changes that make the whole plan worse, and the design for guarding both passes. Diagnosis, field logs and performance measurements originate from PR #4398 by @mbuhansen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tweak_plan and optimise_full_second_pass call optimise_charge_limit / optimise_export one window at a time and write the result straight back with no check that the whole plan improved. Neither optimiser ranks candidates on the metric the plan is finally judged by: both score against a fixed default (charge to max, or export off) rather than the setting the plan already holds, and both rank on a score carrying adjustments the plan metric does not. The reported symptom was a force export at the best price of the horizon being cancelled mid-flight, then flapping Exporting -> Demand for 16 minutes. should_replace_plan could not catch it because it only compares against the previous cycle's plan, not against the value tweak_plan had just destroyed. Both passes now snapshot the single window they are about to change, and revert it unless the whole-plan metric did not worsen. The metric comes from the optimiser rather than a fresh simulation: it already simulates the entire plan for every option it tries, so the unadjusted metric of the option it selected describes the plan we now hold. Both optimisers therefore return that unadjusted metric alongside the adjusted one they rank on, leaving optimise_detailed_pass - which gates on the adjusted score - untouched. Ties are kept, matching optimise_swap_export. Reverting them was tried first and measured worse: a metric-neutral revert leaves a differently shaped plan of equal value, and the passes after tweak amplify that downstream. Also scopes the in-progress export commitment bonus to candidates that cover the current minute. Applying it to later-starting candidates too cancels it out, which is what let "stop now, restart later" win. Removes the dead best_soc_margin field, which applied after selection and would otherwise mean the SoC written back was not the one simulated. It is assigned 0.0 in fetch.py and 0 in predbat.py and set nowhere else. Tests: the shared harness built its Prediction before soc_kw/soc_max and the rates were set, so it simulated an empty battery, every plan cost 0.0 and any metric assertion was silently a no-op. Fixed, and guarded by a test. Adds coverage for both passes reverting a regression, the in-progress export not being restarted later in its window, and the reported metric matching a fresh whole-plan simulation for both branches. Random benchmark over 20 scenarios against main: 19 unchanged, 1 better, 0 worse. Diagnosis, field logs and the original fix are from PR #4398 by @mbuhansen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens Predbat’s plan “tweak” and “full second pass” optimisation paths to be monotonic on whole-plan metric, preventing per-window optimisations from silently regressing an already-good plan (notably mid-flight forced exports).
Changes:
- Make
tweak_planandoptimise_full_second_passrevert any single-window mutation that worsens the whole-plan metric (ties kept). - Extend
optimise_export/optimise_charge_limitto also return the unadjusted plan metric used for monotonicity gating, while retaining adjusted metrics for internal ranking. - Fix and expand
test_export_commitmentso the harness simulates a non-empty battery and adds regression coverage for the monotonic guards and export commitment scoping.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| docs/superpowers/specs/2026-07-31-monotonic-plan-passes-design.md | Design write-up documenting root cause and the monotonic-guard approach. |
| coverage/cases/random_results.json | Re-baselines random scenario golden results to reflect the new monotonic behaviour. |
| apps/predbat/tests/test_optimise_all_windows.py | Updates charge-limit optimiser call sites for the new return value. |
| apps/predbat/tests/test_export_commitment.py | Fixes the test harness ordering bug and adds regression tests for monotonicity and export commitment scoping. |
| apps/predbat/predbat.py | Removes dead best_soc_margin state from reset. |
| apps/predbat/plan.py | Implements monotonic window guards, returns plan-metric alongside adjusted metrics, scopes export commitment bonus, removes best_soc_margin behaviour. |
| apps/predbat/fetch.py | Removes dead best_soc_margin assignment from config fetch. |
Comment on lines
1725
to
+1726
| # Add margin last | ||
| best_soc = min(best_soc + self.best_soc_margin, self.soc_max) | ||
| best_soc = min(best_soc, self.soc_max) |
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.
Supersedes #4398 — same bug, same diagnosis, smaller and better-measured fix. The field logs, the root-cause analysis and the original implementation are @mbuhansen's work.
The bug
tweak_planandoptimise_full_second_passcalloptimise_charge_limit/optimise_exportone window at a time and write the result straight back, with no check that the whole plan improved.Reported symptom: a force export at the best price of the horizon was cancelled mid-flight and the inverter switched to Demand, then flapped
Exporting -> Demand -> Exporting -> Demandfor 16 minutes.should_replace_plancould not catch it — it compares the new plan against the previous cycle's plan, so the valuetweak_planhad just destroyed was never visible to it. Two days of production logs showed 25 of 104tweak_plancalls regressing the plan (24%).Why it happens
Neither optimiser is blind to the status quo —
optimise_charge_limitexplicitly forces the current setting into its candidate list, andoptimise_exportregenerates the full start grid. The current setting is always scored. It just isn't privileged, and it isn't scored on the yardstick the plan is finally judged by:optimise_exportapplies the in-progress commitment bonus and -0.002/-0.001 tie-break weightings;optimise_charge_limithas its own plus an isCharging bonus. Maximising that adjusted score can move the plan metric the wrong way. This is the direct cause of the reported symptom: the Sustain in-progress forced export across plan cycles (anti-flapping on flat peaks) #4118 commitment bonus was applied to every candidate including ones starting afterminutes_now, so "stop now, restart later" got the same bonus as "keep exporting" and the two cancelled out.metric_min_improvementdefaults to 0.0 andmetric_min_improvement_exportto 0.1, so hysteresis is a minor contributor. The ranking-score divergence is doing the damage.The fix
Both passes snapshot the single window they are about to change and revert it unless the whole-plan metric did not worsen. Every mutation in both passes is at index
window_nand neither optimiser touchesself.*_best, so a one-window restore is sufficient.No extra simulation. The optimisers already simulate the entire plan for every option they try, so the guard reuses that number rather than re-simulating. Both now return the unadjusted metric alongside the adjusted one they rank on —
optimise_detailed_passgates on the adjusted score and is deliberately untouched, since replacing it would strip the isCharging and export-commitment stickiness from the fresh-plan path. Verified on a real plan run: the reported metric matched a fresh whole-plan simulation on all 16 guard invocations, delta 0.0 on every one.Ties are kept (
<=), matchingoptimise_swap_export. Reverting them was tried first, on the reasoning that it reduces churn, and measured worse — see below.Also scopes the commitment bonus to candidates covering the current minute, and removes the dead
best_soc_marginfield (assigned0.0infetch.py,0inpredbat.py, set nowhere else; it applied after selection, so it would otherwise mean the SoC written back was not the one simulated).Measurements
Random benchmark, 20 scenarios, against
main:<=(this PR)<(tried, rejected)In the one scenario that regressed under strict
<,tweak_planitself finished at metric 256.76 / cost 384.81 under both rules — identical. The pass is monotonic either way; reverting a metric-neutral tie just left a differently shaped plan of equal value, and the passes after tweak amplified it. Keeping ties also meanspredbat_debug_pre_saving1's expected plan needs no regeneration.cases/random_results.jsonis re-baselined: it differs frommainin exactly one scenario, the improvement above.Tests
The shared harness in
test_export_commitment.pybuilt itsPredictionbeforesoc_kw/soc_maxand the rates were assigned.Predictionsnapshots those at construction, so it simulated an empty battery — every plan cost 0.0 and every metric assertion was silently a no-op. Fixed, and pinned by a test so it cannot regress.New coverage:
Each was verified to fail with its production change reverted. Mutation battery — guard disabled, bonus scoping reverted, export reports adjusted metric, charge reports adjusted metric,
Predictionbuilt before config — 5/5 caught.Full
./run_allgreen (305s, 0 failures) and./run_pre_commitclean.Not fixed here
The root causes above are treated, not cured: the passes will keep proposing regressions, we just stop accepting them. Fixing the ranking-score divergence would make them monotonic by construction and collapse the two metrics this PR has to carry back into one. Worth a follow-up issue.
🤖 Generated with Claude Code