Skip to content

Commit 1ee3c6e

Browse files
feat(plan): run the region descent twice and keep the better plan
The levels-stage region optimiser is a block coordinate descent, so where the tile boundaries fall decides which charge/export pairs can be discovered together: a pair split across a boundary looks unprofitable from either side, and the descent cannot climb out. Offsetting the tiles by half a region fixes some of those splits and creates others, so swapping one tiling for the other is a coin flip - measured over 320 random scenarios it was 76 better / 55 regressed, with a bootstrap CI straddling zero. Running both descents from the same entry state and keeping whichever scores better turns that into a one-directional gain, because the worse branch is simply never kept. Over 320 random scenarios: 36 scenarios improved, 6 regressed (five of those by under 2p), total metric -159.85 (-0.50 per scenario), bootstrap 95% CI [-319.0, -55.2], sign test p=1.4e-06. Planning time goes to 1.62x, concentrated in the levels stage which roughly doubles. Each branch gets its own copy of the tried/score memos. Sharing them lets the first branch's rejections suppress configurations the second would have selected from its own incumbent, and measured 7.5% worse for only 3% less runtime - the overlap is small precisely because the branches explore differently, which is what makes the portfolio work in the first place. Branches are compared on the levels-stage metric, which predicts the final plan's ranking about 72% of the time, so a challenger must clear a 0.5 margin before it displaces the incumbent. Switching on any improvement measured both a lower total gain and three times the regressions. Also considered and dropped: cutting regions at the incumbent plan's SOC minima (not significant on its own, p=0.16) and widening the price-level prune (net negative - the search is boundary-limited, not coverage-limited). The pre_saving1 case plan is restructured at an identical metric (471.1906), and the random benchmark reference is regenerated (-18.65 over its 20 scenarios, 4 better / 1 worse). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9d2a3f9 commit 1ee3c6e

8 files changed

Lines changed: 457 additions & 121 deletions

File tree

.cspell/custom-dictionary-workspace.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ misdetected
297297
misdetecting
298298
misdetection
299299
mispairing
300+
misrank
300301
Mixergy
301302
mkdocs
302303
mlugg
@@ -518,6 +519,7 @@ unnormalised
518519
unparseable
519520
unsmoothed
520521
unstaged
522+
unstaggered
521523
urlsafe
522524
useid
523525
userinterface

apps/predbat/fetch.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2558,6 +2558,7 @@ def fetch_config_options(self):
25582558
self.prediction_kernel_enable = self.get_arg("prediction_kernel_enable", True)
25592559
self.calculate_inday_adjustment = self.get_arg("calculate_inday_adjustment")
25602560
self.calculate_regions = True
2561+
self.calculate_regions_portfolio = True
25612562
self.calculate_import_low_export = self.get_arg("calculate_import_low_export")
25622563
self.calculate_export_high_import = self.get_arg("calculate_export_high_import")
25632564

apps/predbat/plan.py

Lines changed: 227 additions & 64 deletions
Large diffs are not rendered by default.

apps/predbat/predbat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,8 @@ def reset(self):
606606
self.inverter_can_charge_during_export = True
607607
self.octopus_last_joined_try = None
608608
self.calculate_savings_max_charge_slots = 1
609+
self.calculate_regions = True
610+
self.calculate_regions_portfolio = True
609611
self.inverter_data_last_fetch = None
610612
self.octopus_url_cache_loaded = False
611613
self.github_url_cache_loaded = False
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 for the region tiling geometry and the region portfolio branch selection."""
11+
12+
from plan import REGION_SIZE_MIN, REGION_SIZE_START, REGION_SWITCH_THRESHOLD
13+
14+
15+
def _fail(name, message):
16+
"""Print a test failure and return True"""
17+
print("ERROR: {} - {}".format(name, message))
18+
return True
19+
20+
21+
def test_region_passes_uniform(my_predbat):
22+
"""Uniform tiling halves the region width each pass and tiles the record end to start"""
23+
failed = False
24+
passes = my_predbat.compute_region_passes(0, 48 * 60, stagger=False)
25+
26+
sizes = [size for size, _ in passes]
27+
if sizes != [960, 480, 240, 120]:
28+
failed |= _fail("region_passes_uniform", "expected widths [960, 480, 240, 120] got {}".format(sizes))
29+
30+
counts = [len(regions) for _, regions in passes]
31+
if counts != [3, 6, 12, 24]:
32+
failed |= _fail("region_passes_uniform", "expected counts [3, 6, 12, 24] got {}".format(counts))
33+
34+
# Tiles must not overlap within a pass and must be exactly one region wide (bar the clipped first tile)
35+
for size, regions in passes:
36+
ordered = sorted(regions)
37+
for index in range(1, len(ordered)):
38+
if ordered[index][0] < ordered[index - 1][1]:
39+
failed |= _fail("region_passes_uniform", "width {} tiles {} and {} overlap".format(size, ordered[index - 1], ordered[index]))
40+
return failed
41+
42+
43+
def test_region_passes_stagger(my_predbat):
44+
"""Staggered tiling advances by half a region so each boundary is interior to the next tile"""
45+
failed = False
46+
uniform = my_predbat.compute_region_passes(0, 48 * 60, stagger=False)
47+
stagger = my_predbat.compute_region_passes(0, 48 * 60, stagger=True)
48+
49+
if [size for size, _ in uniform] != [size for size, _ in stagger]:
50+
failed |= _fail("region_passes_stagger", "stagger changed the pass widths")
51+
52+
for (size, uniform_regions), (_, stagger_regions) in zip(uniform, stagger):
53+
if size == REGION_SIZE_MIN:
54+
# The half-region step is clamped to the minimum width, so the finest pass is identical
55+
# in both layouts. There is nothing narrower to offset into, and the fine passes refine
56+
# an already-good plan rather than discovering pairs, so the offset buys nothing there.
57+
if sorted(stagger_regions) != sorted(uniform_regions):
58+
failed |= _fail("region_passes_stagger", "the finest pass should not be staggered")
59+
continue
60+
61+
if len(stagger_regions) <= len(uniform_regions):
62+
failed |= _fail("region_passes_stagger", "width {} produced {} tiles, expected more than uniform's {}".format(size, len(stagger_regions), len(uniform_regions)))
63+
64+
# Every interior boundary of the uniform layout must fall strictly inside some staggered
65+
# tile - that is the whole point, so a window pair the uniform tiling splits stays together.
66+
interior = sorted({start for start, _ in uniform_regions})[1:]
67+
for boundary in interior:
68+
if not any(start < boundary < end for start, end in stagger_regions):
69+
failed |= _fail("region_passes_stagger", "width {} boundary {} is not interior to any staggered tile".format(size, boundary))
70+
return failed
71+
72+
73+
def test_region_passes_bounds(my_predbat):
74+
"""Regions stay inside the record and drop tiles that end before the current time"""
75+
failed = False
76+
minutes_now = 600
77+
end_max = 600 + 36 * 60
78+
for stagger in (False, True):
79+
for size, regions in my_predbat.compute_region_passes(minutes_now, end_max, stagger=stagger):
80+
for start, end in regions:
81+
if start < 0 or end > end_max:
82+
failed |= _fail("region_passes_bounds", "width {} tile {} escapes [0, {}]".format(size, (start, end), end_max))
83+
if end < minutes_now:
84+
failed |= _fail("region_passes_bounds", "width {} tile {} ends before minutes_now {}".format(size, (start, end), minutes_now))
85+
if start >= end:
86+
failed |= _fail("region_passes_bounds", "width {} tile {} is empty".format(size, (start, end)))
87+
return failed
88+
89+
90+
def test_region_passes_cover_record(my_predbat):
91+
"""Each pass covers the whole live part of the record, so no window is left unoptimised"""
92+
failed = False
93+
minutes_now = 300
94+
end_max = 300 + 48 * 60
95+
for stagger in (False, True):
96+
for size, regions in my_predbat.compute_region_passes(minutes_now, end_max, stagger=stagger):
97+
covered = sorted(regions)
98+
reach = covered[0][0]
99+
if reach > minutes_now:
100+
failed |= _fail("region_passes_cover", "width {} starts at {} leaving {} uncovered".format(size, reach, minutes_now))
101+
for start, end in covered:
102+
if start > reach:
103+
failed |= _fail("region_passes_cover", "width {} has a gap at {}".format(size, reach))
104+
reach = max(reach, end)
105+
if reach < end_max:
106+
failed |= _fail("region_passes_cover", "width {} reaches {} not {}".format(size, reach, end_max))
107+
return failed
108+
109+
110+
def test_region_passes_min_size(my_predbat):
111+
"""The descent stops at the minimum region width and never emits a narrower pass"""
112+
failed = False
113+
for stagger in (False, True):
114+
passes = my_predbat.compute_region_passes(0, 48 * 60, min_region_size=240, stagger=stagger)
115+
sizes = [size for size, _ in passes]
116+
if min(sizes) < 240:
117+
failed |= _fail("region_passes_min_size", "emitted a pass narrower than the minimum: {}".format(sizes))
118+
if sizes[0] != REGION_SIZE_START:
119+
failed |= _fail("region_passes_min_size", "first pass should be the widest ({}) got {}".format(REGION_SIZE_START, sizes[0]))
120+
return failed
121+
122+
123+
def test_select_region_branch(my_predbat):
124+
"""Branch B is kept only when it clears the switching threshold, otherwise the incumbent wins"""
125+
failed = False
126+
cases = [
127+
([100.0], 0, "a lone branch is always the winner"),
128+
([100.0, 100.0 - REGION_SWITCH_THRESHOLD - 0.01], 1, "clearing the threshold switches"),
129+
([100.0, 100.0 - REGION_SWITCH_THRESHOLD + 0.01], 0, "a gain inside the threshold is noise and must not switch"),
130+
([100.0, 100.0], 0, "a tie keeps the incumbent"),
131+
([100.0, 101.0], 0, "a worse branch never wins"),
132+
([100.0, 95.0, 90.0], 2, "the best branch clearing the threshold wins"),
133+
([100.0, 90.0, 99.9], 1, "a marginal third branch does not displace a clear winner"),
134+
]
135+
for metrics, expected, reason in cases:
136+
chosen = my_predbat.select_region_branch(metrics)
137+
if chosen != expected:
138+
failed |= _fail("select_region_branch", "{}: {} chose branch {} expected {}".format(reason, metrics, chosen, expected))
139+
return failed
140+
141+
142+
def test_region_defaults(my_predbat):
143+
"""The tiling constants match the geometry the optimiser was tuned against"""
144+
failed = False
145+
if REGION_SIZE_START != 16 * 60:
146+
failed |= _fail("region_defaults", "REGION_SIZE_START changed to {}".format(REGION_SIZE_START))
147+
if REGION_SIZE_MIN != 120:
148+
failed |= _fail("region_defaults", "REGION_SIZE_MIN changed to {}".format(REGION_SIZE_MIN))
149+
if REGION_SWITCH_THRESHOLD <= 0:
150+
failed |= _fail("region_defaults", "REGION_SWITCH_THRESHOLD must be positive, got {}".format(REGION_SWITCH_THRESHOLD))
151+
return failed
152+
153+
154+
def run_region_portfolio_tests(my_predbat):
155+
"""Run all region tiling and portfolio selection tests"""
156+
failed = False
157+
failed |= test_region_passes_uniform(my_predbat)
158+
failed |= test_region_passes_stagger(my_predbat)
159+
failed |= test_region_passes_bounds(my_predbat)
160+
failed |= test_region_passes_cover_record(my_predbat)
161+
failed |= test_region_passes_min_size(my_predbat)
162+
failed |= test_select_region_branch(my_predbat)
163+
failed |= test_region_defaults(my_predbat)
164+
if not failed:
165+
print("**** Region portfolio tests passed ****")
166+
return failed

apps/predbat/unit_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from tests.test_active_flag import test_active_flag
4949
from tests.test_component_health_status import test_component_health_status
5050
from tests.test_optimise_levels import run_optimise_levels_tests
51+
from tests.test_region_portfolio import run_region_portfolio_tests
5152
from tests.test_trim_export import run_trim_export_tests
5253
from tests.test_plan_tiebreak import run_plan_tiebreak_tests
5354
from tests.test_plan_preclip import run_plan_preclip_tests
@@ -475,6 +476,7 @@ def main():
475476
("compare", test_compare, "Compare tariff engine tests (hardware overrides, bleed isolation)", False),
476477
("gateway", run_gateway_tests, "GatewayMQTT component tests (protobuf, plan serialization, commands, telemetry)", False),
477478
("optimise_levels", run_optimise_levels_tests, "Optimise levels tests", False),
479+
("region_portfolio", run_region_portfolio_tests, "Region tiling geometry and portfolio branch selection tests", False),
478480
("trim_export", run_trim_export_tests, "Export trim ordering (buffer from cheapest slot) tests", False),
479481
("plan_tiebreak", run_plan_tiebreak_tests, "Plan fragmentation near-tie tie-break tests", False),
480482
("plan_preclip", run_plan_preclip_tests, "Plan selection scores the pre-clip plan", True),
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"charge_limit_best": [3.02, 0.38, 0.38, 9.52, 9.52, 9.52, 8.27, 0.38, 9.52, 9.52], "charge_window_best": [{"start": 1020, "end": 1050, "average": 25.58, "target": 3.02}, {"start": 1050, "end": 1080, "average": 25.58, "target": 0.38}, {"start": 1140, "end": 1260, "average": 25.58, "target": 0.38}, {"start": 1410, "end": 1680, "average": 7.0, "target": 9.52}, {"start": 1710, "end": 1770, "average": 7.0, "target": 9.509}, {"start": 1770, "end": 1980, "average": 25.58, "target": 9.52}, {"start": 2100, "end": 2220, "average": 25.58, "target": 8.27}, {"start": 2700, "end": 2820, "average": 25.58, "target": 0.38}, {"start": 2850, "end": 3210, "average": 7.0, "target": 9.52}, {"start": 3210, "end": 3900, "average": 25.58, "target": 9.52}], "export_window_best": [{"average": 75.0, "end": 1140, "start": 1080, "set": 69.8, "start_orig": 1080, "target": 12}, {"average": 15.0, "end": 1710, "start": 1680, "set": 14.0, "target": 89}], "export_limits_best": [7.0, 85]}
1+
{"charge_limit_best": [3.02, 0.38, 0.38, 0.38, 9.52, 9.52, 9.52, 9.02, 0.38, 9.52, 9.52], "charge_window_best": [{"start": 1020, "end": 1050, "average": 25.58, "target": 3.02}, {"start": 1050, "end": 1080, "average": 25.58, "target": 0.38}, {"start": 1140, "end": 1200, "average": 25.58, "target": 0.38}, {"start": 1230, "end": 1380, "average": 25.58, "target": 0.38}, {"start": 1410, "end": 1680, "average": 7.0, "target": 9.52}, {"start": 1710, "end": 1770, "average": 7.0, "target": 9.509}, {"start": 1770, "end": 2220, "average": 25.58, "target": 9.52}, {"start": 2250, "end": 2280, "average": 25.58, "target": 9.02}, {"start": 2280, "end": 2310, "average": 25.58, "target": 0.38}, {"start": 2850, "end": 3210, "average": 7.0, "target": 9.52}, {"start": 3210, "end": 3900, "average": 25.58, "target": 9.52}], "export_window_best": [{"average": 75.0, "end": 1140, "start": 1080, "set": 69.8, "start_orig": 1080, "target": 12}, {"average": 15.0, "end": 1710, "start": 1680, "set": 14.0, "target": 89}], "export_limits_best": [7.0, 85]}

0 commit comments

Comments
 (0)