Skip to content

Commit f4a51f9

Browse files
fix(plan): score plan selection on the plan as optimised, not as clipped
clip_export_slots and clip_charge_slots set the charge/export percentage shown on the plan and sent to the inverter. Measured across the random benchmark, export clipping changes the mid-case cost by exactly 0.00 on every call - it is a no-op in the expected world - but it still moves the metric, entirely through the PV10 branch, by an amount that depends on plan shape: 7 of 12 scenarios taxed, 0 improved, up to +4.25. That tax lands between optimisation and should_replace_plan, so plan selection was partly deciding on a difference clipping invented rather than one the plans really have. A plan taxed 4.25 could lose to one taxed 0.0 on that alone. By then the slot is usually hours out and will be re-planned many times before it executes, so constraining its PV10 branch today buys nothing. Clipping is unchanged - the executed and displayed percentages are exactly as before. calculate_plan now keeps a snapshot of the plan taken before clipping and scores selection on that. Both sides fall back to the clipped plans together when either snapshot is missing, so a fresh plan is never scored untaxed against a taxed incumbent. The snapshot is persisted with the plan so a restart does not lose it. An earlier attempt made clipping itself span both traces. That fixed the metric but changed the executed percentages, which is the wrong trade: the percentage should reflect the expected case. Tests: plan_scoring_pair is unit tested including the never-mix fallback, the save/load round trip covers the snapshot, and a new plan_preclip test drives two real recomputes on a scenario where clipping demonstrably taxes the metric (3.49) and asserts the snapshot scores no worse than the executed plan. Taking the snapshot after clipping instead makes that test fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9e4df48 commit f4a51f9

7 files changed

Lines changed: 162 additions & 5 deletions

File tree

.cspell/custom-dictionary-workspace.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ powerline
354354
Powerwall
355355
ppdetails
356356
ppkwh
357+
preclip
357358
pred
358359
predai
359360
predbat

apps/predbat/plan.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,25 @@ def keep_window_change_if_improved(self, baseline, candidate, typ, window_n, sna
999999
self.plan_window_restore(typ, window_n, snapshot)
10001000
return baseline
10011001

1002+
def plan_scoring_pair(self, plan_new, plan_prev, preclip_new, preclip_prev):
1003+
"""Return the (new, previous) plans that selection should be scored on.
1004+
1005+
Plans are (charge_limit, charge_window, export_window, export_limits) tuples.
1006+
1007+
Clipping sets the charge/export percentage actually shown on the plan and sent to the inverter, so it
1008+
has to stay. It is a no-op in the expected case - the mid-case cost is unchanged - but it moves the
1009+
metric through the PV10 branch by an amount that depends on plan shape. Scoring the clipped plans
1010+
therefore decides between them partly on a difference clipping invented rather than one the plans
1011+
really have, and by then the slot is usually hours away and will be re-planned many times before it
1012+
executes.
1013+
1014+
Both sides fall back together when either snapshot is missing (the first recompute after a restart):
1015+
scoring an un-taxed new plan against a taxed incumbent would favour the new plan on the tax alone.
1016+
"""
1017+
if preclip_new is not None and preclip_prev is not None:
1018+
return preclip_new, preclip_prev
1019+
return plan_new, plan_prev
1020+
10021021
def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
10031022
"""
10041023
Calculate the new plan (best)
@@ -1047,12 +1066,14 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
10471066
charge_window_best_prev = copy.deepcopy(self.charge_window_best)
10481067
export_window_best_prev = copy.deepcopy(self.export_window_best)
10491068
export_limits_best_prev = copy.deepcopy(self.export_limits_best)
1069+
preclip_prev = self.plan_preclip
10501070
self.log("Recompute is saving previous plan...")
10511071
else:
10521072
charge_limit_best_prev = None
10531073
charge_window_best_prev = None
10541074
export_window_best_prev = None
10551075
export_limits_best_prev = None
1076+
preclip_prev = None
10561077
self.log("Recompute, previous plan is invalid...")
10571078

10581079
self.plan_valid = False # In case of crash, plan is now invalid
@@ -1179,6 +1200,9 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
11791200
# Remove charge windows that overlap with export windows
11801201
self.charge_limit_best, self.charge_window_best = remove_intersecting_windows(self.charge_limit_best, self.charge_window_best, self.export_limits_best, self.export_window_best)
11811202

1203+
# Snapshot the plan as optimised, before clipping adjusts the percentages for execution
1204+
preclip_new = (copy.deepcopy(self.charge_limit_best), copy.deepcopy(self.charge_window_best), copy.deepcopy(self.export_window_best), copy.deepcopy(self.export_limits_best))
1205+
11821206
# Filter out any unused export windows
11831207
if self.calculate_best_export and self.export_window_best:
11841208
# Filter out the windows we disabled
@@ -1263,27 +1287,38 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
12631287

12641288
# Plan comparison
12651289
if charge_window_best_prev is not None and not debug_mode:
1266-
metric, battery_value, cost, metric_keep, battery_cycle, final_carbon_g, import_kwh, export_kwh = self.run_prediction_metric(
1267-
self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, end_record=self.end_record
1290+
# Score the plans as optimised rather than as clipped - see plan_scoring_pair()
1291+
score_new, score_prev = self.plan_scoring_pair(
1292+
(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best),
1293+
(charge_limit_best_prev, charge_window_best_prev, export_window_best_prev, export_limits_best_prev),
1294+
preclip_new,
1295+
preclip_prev,
12681296
)
1297+
metric, battery_value, cost, metric_keep, battery_cycle, final_carbon_g, import_kwh, export_kwh = self.run_prediction_metric(score_new[0], score_new[1], score_new[2], score_new[3], end_record=self.end_record)
12691298
metric_prev, battery_value_prev, cost_prev, metric_keep_prev, battery_cycle_prev, final_carbon_g_prev, import_kwh_prev, export_kwh_prev = self.run_prediction_metric(
1270-
charge_limit_best_prev, charge_window_best_prev, export_window_best_prev, export_limits_best_prev, end_record=self.end_record
1299+
score_prev[0], score_prev[1], score_prev[2], score_prev[3], end_record=self.end_record
12711300
)
12721301

12731302
self.log("Previous plan best metric is {} (cost {}) and new plan best metric is {} (cost {})".format(dp2(metric_prev), dp2(cost_prev), dp2(metric), dp2(cost)))
1274-
fragmentation_prev = self.plan_fragmentation(charge_window_best_prev, charge_limit_best_prev, export_window_best_prev, export_limits_best_prev)
1275-
fragmentation_new = self.plan_fragmentation(self.charge_window_best, self.charge_limit_best, self.export_window_best, self.export_limits_best)
1303+
fragmentation_prev = self.plan_fragmentation(score_prev[1], score_prev[0], score_prev[2], score_prev[3])
1304+
fragmentation_new = self.plan_fragmentation(score_new[1], score_new[0], score_new[2], score_new[3])
12761305
if not self.should_replace_plan(metric_prev, metric, fragmentation_prev, fragmentation_new):
12771306
self.log("New plan metric is not significantly better (metric_min_improvement_plan {}) than previous plan, using previous plan".format(self.metric_min_improvement_plan))
12781307
self.charge_window_best = copy.deepcopy(charge_window_best_prev)
12791308
self.charge_limit_best = copy.deepcopy(charge_limit_best_prev)
12801309
self.export_window_best = copy.deepcopy(export_window_best_prev)
12811310
self.export_limits_best = copy.deepcopy(export_limits_best_prev)
1311+
# Keeping the incumbent keeps its pre-clip snapshot too, so the next cycle still compares
1312+
# like for like
1313+
preclip_new = preclip_prev
12821314
elif (metric_prev - metric) >= self.metric_min_improvement_plan:
12831315
self.log("New plan metric is significantly better from previous plan, using new plan")
12841316
else:
12851317
self.log("New plan is a cost-neutral improvement but less fragmented ({} vs {} segments), using new plan".format(fragmentation_new, fragmentation_prev))
12861318

1319+
# Carry the pre-clip snapshot of whichever plan we kept into the next cycle
1320+
self.plan_preclip = preclip_new
1321+
12871322
# Plan is now valid
12881323
self.log("Plan valid is now true after recompute was {}".format(self.plan_valid))
12891324
if not self.update_pending:

apps/predbat/predbat.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ def reset(self):
340340
self.previous_status = None
341341
self.had_errors = False
342342
self.plan_valid = False
343+
self.plan_preclip = None
343344
self.plan_last_updated = None
344345
self.plan_last_updated_minutes = 0
345346
self.plugin_system = None
@@ -684,6 +685,7 @@ def save_plan(self):
684685
"charge_limit_best": self.charge_limit_best,
685686
"export_window_best": self.export_window_best,
686687
"export_limits_best": self.export_limits_best,
688+
"plan_preclip": self.plan_preclip,
687689
"plan_last_updated": self.plan_last_updated.isoformat() if self.plan_last_updated else None,
688690
"plan_last_updated_minutes": self.plan_last_updated_minutes,
689691
}
@@ -735,6 +737,10 @@ def load_plan(self):
735737
self.charge_limit_best = plan_data.get("charge_limit_best", [])
736738
self.export_window_best = plan_data.get("export_window_best", [])
737739
self.export_limits_best = plan_data.get("export_limits_best", [])
740+
# The pre-clip snapshot plan selection scores against. Older saves predate it, and it is only ever a
741+
# four part plan, so anything else is discarded and the comparison falls back to the clipped plans.
742+
preclip = plan_data.get("plan_preclip")
743+
self.plan_preclip = tuple(preclip) if isinstance(preclip, (list, tuple)) and len(preclip) == 4 else None
738744
self.plan_last_updated = saved_dt
739745
self.plan_last_updated_minutes = plan_data.get("plan_last_updated_minutes", 0)
740746
self.plan_valid = True

apps/predbat/tests/test_plan_persistence.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ def test_plan_persistence(my_predbat):
6262
my_predbat.plan_last_updated_minutes = saved_minutes
6363
my_predbat.plan_valid = True
6464

65+
# The pre-clip snapshot is what plan selection scores against next cycle, so it has to survive a
66+
# restart too - without it the first recompute back compares a clipped incumbent and falls back
67+
preclip = ([9.0], charge_windows, export_windows, [0.0])
68+
my_predbat.plan_preclip = preclip
69+
6570
# 1. save_plan() must not raise and must write something loadable
6671
print(" Test 1: save_plan() round-trip")
6772
my_predbat.save_plan()
@@ -74,6 +79,7 @@ def test_plan_persistence(my_predbat):
7479
my_predbat.plan_last_updated = None
7580
my_predbat.plan_last_updated_minutes = 0
7681
my_predbat.plan_valid = False
82+
my_predbat.plan_preclip = None
7783

7884
my_predbat.load_plan()
7985

@@ -95,6 +101,12 @@ def test_plan_persistence(my_predbat):
95101
if my_predbat.plan_last_updated_minutes != saved_minutes:
96102
print(" FAILED: plan_last_updated_minutes mismatch: {}".format(my_predbat.plan_last_updated_minutes))
97103
failed += 1
104+
if my_predbat.plan_preclip is None:
105+
print(" FAILED: plan_preclip was not restored")
106+
failed += 1
107+
elif [list(x) for x in my_predbat.plan_preclip] != [list(x) for x in preclip]:
108+
print(" FAILED: plan_preclip mismatch: {}".format(my_predbat.plan_preclip))
109+
failed += 1
98110

99111
# 2. load_plan() with empty storage leaves plan_valid False
100112
print(" Test 2: load_plan() with no saved plan")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# -----------------------------------------------------------------------------
2+
# Predbat Home Battery System
3+
# Copyright Trefor Southwell 2026 - All Rights Reserved
4+
# This application maybe used for personal use only and not for commercial use
5+
# -----------------------------------------------------------------------------
6+
# fmt off
7+
# pylint: disable=consider-using-f-string
8+
# pylint: disable=line-too-long
9+
# pylint: disable=attribute-defined-outside-init
10+
"""Tests that plan selection scores the plan as optimised rather than as clipped.
11+
12+
clip_export_slots and clip_charge_slots set the percentage shown on the plan and sent to the inverter. They
13+
are a no-op in the expected case but move the metric through the PV10 branch, so calculate_plan keeps a
14+
snapshot of the plan taken before clipping and compares on that instead - see plan_scoring_pair().
15+
"""
16+
from tests.test_infra import reset_inverter
17+
from tests.test_random_scenarios import load_scenarios, run_scenario
18+
19+
20+
def run_plan_preclip_tests(my_predbat):
21+
"""Run the pre-clip plan selection tests. Returns True on failure."""
22+
print("**** Running plan pre-clip selection tests ****")
23+
failed = False
24+
25+
# read_debug_yaml reconfigures the instance wholesale, so work on a throwaway one rather than leaving the
26+
# shared fixture holding this debug case for every test that runs after
27+
from unit_test import create_predbat
28+
29+
my_predbat = create_predbat()
30+
31+
reset_inverter(my_predbat)
32+
my_predbat.read_debug_yaml("cases/predbat_debug_agile1.yaml")
33+
my_predbat.config_root = "./"
34+
my_predbat.save_restore_dir = "./"
35+
my_predbat.load_user_config()
36+
37+
# Reuse a benchmark scenario rather than generating one: these are known to plan in a couple of seconds
38+
# Scenario 10 is one where clipping measurably taxes the metric, so the comparison below has teeth: on an
39+
# untaxed scenario pre-clip and clipped score the same and the assertion would pass either way
40+
scenario = [s for s in load_scenarios("cases/random_scenarios.yaml") if s["id"] == 10][0]
41+
42+
# First plan establishes an incumbent; the second recompute is the one that runs the comparison
43+
run_scenario(my_predbat, scenario)
44+
if my_predbat.plan_preclip is None:
45+
print("ERROR: plan_preclip was not captured by the first recompute")
46+
return True
47+
48+
run_scenario(my_predbat, scenario)
49+
50+
if my_predbat.plan_preclip is None:
51+
print("ERROR: plan_preclip was not carried through the second recompute")
52+
return True
53+
if len(my_predbat.plan_preclip) != 4:
54+
print("ERROR: plan_preclip should be a four part plan, got {}".format(len(my_predbat.plan_preclip)))
55+
return True
56+
57+
# Clipping never improves a plan, so the snapshot selection scores must be no worse than the plan that
58+
# actually executes. Taking the snapshot after clipping instead would make these equal at best and hand
59+
# the taxed metric to should_replace_plan.
60+
preclip = my_predbat.plan_preclip
61+
metric_preclip = my_predbat.run_prediction_metric(preclip[0], preclip[1], preclip[2], preclip[3], end_record=my_predbat.end_record)[0]
62+
metric_final = my_predbat.run_prediction_metric(my_predbat.charge_limit_best, my_predbat.charge_window_best, my_predbat.export_window_best, my_predbat.export_limits_best, end_record=my_predbat.end_record)[0]
63+
64+
if metric_final - metric_preclip < 0.01:
65+
print("ERROR: clipping did not tax this scenario ({} vs {}), so the comparison proves nothing".format(metric_preclip, metric_final))
66+
failed = True
67+
elif metric_preclip > metric_final + 0.0001:
68+
print("ERROR: pre-clip plan scored {} which is worse than the clipped plan {} - snapshot is not pre-clip".format(metric_preclip, metric_final))
69+
failed = True
70+
else:
71+
print("pre-clip metric {} vs clipped {} (clipping tax {})".format(round(metric_preclip, 4), round(metric_final, 4), round(metric_final - metric_preclip, 4)))
72+
73+
if not failed:
74+
print("**** Plan pre-clip selection tests passed ****")
75+
return failed

apps/predbat/tests/test_plan_tiebreak.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,36 @@ def _tiebreak_decision_tests(my_predbat, failures):
7979
_check(my_predbat.should_replace_plan(-200.0, -200.0, 4, 2) is True, "cost-neutral cleaner plan adopts new", failures)
8080

8181

82+
def _scoring_pair_tests(my_predbat, failures):
83+
"""plan_scoring_pair: selection scores the pre-clip plans, and never mixes a clipped side with a pre-clip one.
84+
85+
Clipping sets the percentage actually sent to the inverter. It is a no-op in the expected case but moves the
86+
metric through the PV10 branch by an amount that depends on plan shape, so comparing post-clip decides
87+
between plans on a difference clipping invented rather than one the plans really have.
88+
"""
89+
clipped_new = ([1.0], [{"start": 0, "end": 30}], [{"start": 30, "end": 60}], [100.0])
90+
clipped_prev = ([2.0], [{"start": 0, "end": 30}], [{"start": 30, "end": 60}], [100.0])
91+
preclip_new = ([3.0], [{"start": 0, "end": 30}], [{"start": 30, "end": 60}], [0.0])
92+
preclip_prev = ([4.0], [{"start": 0, "end": 30}], [{"start": 30, "end": 60}], [0.0])
93+
94+
# Both pre-clip snapshots available: score the plans as optimised
95+
pair = my_predbat.plan_scoring_pair(clipped_new, clipped_prev, preclip_new, preclip_prev)
96+
_check(pair == (preclip_new, preclip_prev), "with both snapshots the pre-clip plans are scored", failures)
97+
98+
# No incumbent snapshot (first recompute after a restart): fall back to clipped on BOTH sides, never a mix,
99+
# otherwise the new plan is scored untaxed against a taxed incumbent and wins on the difference
100+
pair = my_predbat.plan_scoring_pair(clipped_new, clipped_prev, preclip_new, None)
101+
_check(pair == (clipped_new, clipped_prev), "without an incumbent snapshot both sides fall back to clipped", failures)
102+
103+
pair = my_predbat.plan_scoring_pair(clipped_new, clipped_prev, None, preclip_prev)
104+
_check(pair == (clipped_new, clipped_prev), "without a new snapshot both sides fall back to clipped", failures)
105+
106+
82107
def run_plan_tiebreak_tests(my_predbat):
83108
"""Run the plan fragmentation tie-break tests. Returns True on failure."""
84109
print("**** Running plan tie-break tests ****")
85110
failures = []
86111
_fragmentation_tests(my_predbat, failures)
87112
_tiebreak_decision_tests(my_predbat, failures)
113+
_scoring_pair_tests(my_predbat, failures)
88114
return len(failures) > 0

apps/predbat/unit_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from tests.test_optimise_levels import run_optimise_levels_tests
4747
from tests.test_trim_export import run_trim_export_tests
4848
from tests.test_plan_tiebreak import run_plan_tiebreak_tests
49+
from tests.test_plan_preclip import run_plan_preclip_tests
4950
from tests.test_export_commitment import run_export_commitment_tests
5051
from tests.test_energydataservice import run_energydataservice_tests
5152
from tests.test_iboost import run_iboost_smart_tests
@@ -454,6 +455,7 @@ def main():
454455
("optimise_levels", run_optimise_levels_tests, "Optimise levels tests", False),
455456
("trim_export", run_trim_export_tests, "Export trim ordering (buffer from cheapest slot) tests", False),
456457
("plan_tiebreak", run_plan_tiebreak_tests, "Plan fragmentation near-tie tie-break tests", False),
458+
("plan_preclip", run_plan_preclip_tests, "Plan selection scores the pre-clip plan", True),
457459
("export_commitment", run_export_commitment_tests, "Forced-export commitment / anti-flapping tests", False),
458460
("load_ml", test_load_ml, "ML Load Forecaster tests (MLP, training, persistence, validation)", True),
459461
# ("optimise_windows", run_optimise_all_windows_tests, "Optimise all windows tests", True),

0 commit comments

Comments
 (0)