Skip to content

Commit 457cf0a

Browse files
springfall2008claudepre-commit-ci-lite[bot]
authored
perf(plan): cache export-to-charge window collisions in the optimiser (1.5x faster planning) (#4507)
* perf(plan): cache export-to-charge window collisions in the optimiser optimise_charge_limit_price_threads asks hit_charge_window which charge window each export window collides with, once per export window per candidate, and hit_charge_window is a linear scan of the whole charge window list. On a benchmark scenario that was 1,741,593 scans of a ~200 entry list for 221 distinct answers - 27% of the plan's runtime, and the largest single cost after the intersect fix in #4505. The collision is purely geometric, and this function only ever turns windows on and off: it never moves a window's start or end. So the answer cannot change for the life of the call and is memoised in a local dict keyed by export window. Only the collision index is cached - the charge limit derived from it depends on charge_mods/best_limits_reset and still varies per trial. Scoping the cache to the call means there is nothing to invalidate. Confirmed by instrumentation before making the change: 1.74M calls, 221 distinct queries, and zero cases of a repeated query returning a different answer later. Random benchmark: mean optimise time 4.302s -> 2.896s (1.5x), worst scenario 25.6s -> 18.1s, with plan metric and cost identical on all 20 scenarios. Adds tests for hit_charge_window's contract - overlap, boundary-touch, and that the first matching window is returned, since the cache stores a specific index - plus a test asserting the invariant the cache rests on: run the optimiser and check the window bounds it was given come back untouched. If a future change starts moving windows, that test fails rather than the cache quietly returning stale collisions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(random): refresh the random benchmark reference for the collision cache cases/random_results.json is what run_random compares against, and it carries the recorded runtime of each scenario as well as its metric and cost. No seed differs on metric or cost - the cache does not change any plan - but the stored runtimes were recorded before it, so run_random reported a permanent ~1.4s of phantom improvement on every run. Regenerated: total recorded runtime 86.0s -> 60.2s, metric and cost unchanged on all 20 scenarios. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 31faa64 commit 457cf0a

3 files changed

Lines changed: 183 additions & 1 deletion

File tree

apps/predbat/plan.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,15 @@ def optimise_charge_limit_price_threads(
392392
reset_contribution = scenario_hash_entry(1, window_n, best_export_limits_reset[window_n])
393393
export_hash_delta[window_n] = {True: scenario_hash_entry(1, window_n, 99.0) - reset_contribution, False: scenario_hash_entry(1, window_n, min_freeze_percent) - reset_contribution}
394394

395+
# Which charge window an export window collides with is a purely geometric question, and this
396+
# function only ever turns windows on and off - it never moves a window's start or end. So the
397+
# answer is fixed for the life of the call and is memoised here, keyed by export window. Without
398+
# it the trial loop below re-scans the whole charge window list for every export window of every
399+
# candidate: on a benchmark scenario that was 1.74 million linear scans of a ~200 entry list,
400+
# for 221 distinct answers. Only the collision itself is cached - the limit that follows from it
401+
# depends on charge_mods/best_limits_reset and changes from trial to trial.
402+
hit_charge_cache = {}
403+
395404
# Start loop of trials
396405
for loop_price in all_prices:
397406
if best_level_score is not None:
@@ -454,7 +463,10 @@ def optimise_charge_limit_price_threads(
454463
# Remove export hitting charge windows if this is disabled
455464
if not self.calculate_export_oncharge:
456465
for window_n in all_d[:]:
457-
hit_charge = self.hit_charge_window(self.charge_window_best, export_window[window_n]["start"], export_window[window_n]["end"])
466+
hit_charge = hit_charge_cache.get(window_n)
467+
if hit_charge is None:
468+
hit_charge = self.hit_charge_window(self.charge_window_best, export_window[window_n]["start"], export_window[window_n]["end"])
469+
hit_charge_cache[window_n] = hit_charge
458470
if hit_charge >= 0:
459471
if hit_charge in charge_mods:
460472
hit_charge_limit = self.reserve if charge_mods[hit_charge] else self.soc_max
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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 export-to-charge window collision cache in optimise_charge_limit_price_threads.
11+
12+
The cache is only sound because window geometry is immutable for the life of that call - the
13+
optimiser turns windows on and off but never moves a start or end. These tests pin both halves:
14+
hit_charge_window's own contract, and the invariant that lets its result be reused.
15+
"""
16+
from prediction import Prediction
17+
from tests.test_infra import reset_inverter, reset_rates
18+
19+
20+
def run_hit_charge_cache_tests(my_predbat):
21+
"""Run the hit_charge_window and collision-cache invariant tests"""
22+
failed = False
23+
failed |= test_hit_charge_window_finds_overlap(my_predbat)
24+
failed |= test_hit_charge_window_boundaries_do_not_overlap(my_predbat)
25+
failed |= test_hit_charge_window_returns_first_match(my_predbat)
26+
failed |= test_optimise_threads_does_not_move_window_bounds(my_predbat)
27+
return failed
28+
29+
30+
def make_window(start, end, average=10.0):
31+
"""Build a minimal window dict"""
32+
return {"start": start, "end": end, "average": average}
33+
34+
35+
def test_hit_charge_window_finds_overlap(my_predbat):
36+
"""hit_charge_window returns the index of the charge window an interval overlaps, else -1"""
37+
print("**** test_hit_charge_window_finds_overlap ****")
38+
failed = False
39+
reset_inverter(my_predbat)
40+
41+
windows = [make_window(0, 30), make_window(60, 90), make_window(120, 180)]
42+
43+
for start, end, expect in ((0, 30, 0), (10, 20, 0), (25, 65, 0), (70, 80, 1), (150, 200, 2), (30, 60, -1), (200, 300, -1)):
44+
got = my_predbat.hit_charge_window(windows, start, end)
45+
if got != expect:
46+
print("ERROR: hit_charge_window({}, {}) expected {} but got {}".format(start, end, expect, got))
47+
failed = True
48+
49+
if not failed:
50+
print("PASS")
51+
return failed
52+
53+
54+
def test_hit_charge_window_boundaries_do_not_overlap(my_predbat):
55+
"""An interval that merely touches a charge window at a boundary is not a collision"""
56+
print("**** test_hit_charge_window_boundaries_do_not_overlap ****")
57+
failed = False
58+
reset_inverter(my_predbat)
59+
60+
windows = [make_window(60, 90)]
61+
62+
if my_predbat.hit_charge_window(windows, 30, 60) != -1:
63+
print("ERROR: interval ending exactly at the window start reported a collision")
64+
failed = True
65+
if my_predbat.hit_charge_window(windows, 90, 120) != -1:
66+
print("ERROR: interval starting exactly at the window end reported a collision")
67+
failed = True
68+
if my_predbat.hit_charge_window(windows, 59, 61) != 0:
69+
print("ERROR: interval straddling the window start did not report a collision")
70+
failed = True
71+
72+
if not failed:
73+
print("PASS")
74+
return failed
75+
76+
77+
def test_hit_charge_window_returns_first_match(my_predbat):
78+
"""When several charge windows overlap the interval, the lowest index is returned - the cache
79+
stores this answer, so it has to be deterministic rather than any-match"""
80+
print("**** test_hit_charge_window_returns_first_match ****")
81+
failed = False
82+
reset_inverter(my_predbat)
83+
84+
windows = [make_window(0, 30), make_window(30, 60), make_window(60, 90)]
85+
got = my_predbat.hit_charge_window(windows, 10, 80)
86+
if got != 0:
87+
print("ERROR: Expected the first overlapping window (0) but got {}".format(got))
88+
failed = True
89+
90+
if not failed:
91+
print("PASS")
92+
return failed
93+
94+
95+
def test_optimise_threads_does_not_move_window_bounds(my_predbat):
96+
"""The collision cache assumes optimise_charge_limit_price_threads never moves a window's start
97+
or end - it only enables and disables windows. Run the optimiser and assert the geometry it was
98+
given comes back untouched; if a future change starts moving windows, this fails rather than the
99+
cache silently returning stale collisions."""
100+
print("**** test_optimise_threads_does_not_move_window_bounds ****")
101+
failed = False
102+
reset_inverter(my_predbat)
103+
reset_rates(my_predbat, 10.0, 5.0)
104+
105+
# Set the metric config this path reads rather than depending on whatever earlier tests left
106+
# behind, and restore it afterwards so this test neither depends on nor leaks ambient state
107+
saved = {name: getattr(my_predbat, name) for name in ("metric_battery_value_export_scaling", "metric_battery_value_scaling", "pv_metric10_weight", "pv_metric90_weight")}
108+
my_predbat.metric_battery_value_export_scaling = 1.0
109+
my_predbat.metric_battery_value_scaling = 1.0
110+
my_predbat.pv_metric10_weight = 0.0
111+
my_predbat.pv_metric90_weight = 0.0
112+
113+
# The optimiser simulates, so it needs a prediction with flat PV/load data behind it
114+
pv_step = {}
115+
load_step = {}
116+
for minute in range(0, my_predbat.forecast_minutes, 5):
117+
pv_step[minute] = 0.0
118+
load_step[minute] = 0.1
119+
my_predbat.pv_forecast_minute_step = pv_step
120+
my_predbat.load_minutes_step = load_step
121+
my_predbat.prediction = Prediction(my_predbat, pv_step, pv_step, load_step, load_step)
122+
my_predbat.args["threads"] = 0
123+
124+
my_predbat.calculate_export_oncharge = False
125+
charge_window = [make_window(my_predbat.minutes_now + 60 * n, my_predbat.minutes_now + 60 * n + 30, 10.0) for n in range(4)]
126+
export_window = [make_window(my_predbat.minutes_now + 60 * n + 30, my_predbat.minutes_now + 60 * n + 60, 15.0) for n in range(4)]
127+
charge_limit = [my_predbat.soc_max] * len(charge_window)
128+
export_limits = [100.0] * len(export_window)
129+
130+
my_predbat.charge_window_best = charge_window
131+
my_predbat.charge_limit_best = charge_limit
132+
my_predbat.export_window_best = export_window
133+
my_predbat.export_limits_best = export_limits
134+
135+
before_charge = [(w["start"], w["end"]) for w in charge_window]
136+
before_export = [(w["start"], w["end"]) for w in export_window]
137+
138+
window_sorted, window_index, price_set, price_links = my_predbat.sort_window_by_price_combined(charge_window, export_window)
139+
try:
140+
my_predbat.optimise_charge_limit_price_threads(
141+
price_set,
142+
price_links,
143+
window_index,
144+
len(charge_window),
145+
len(export_window),
146+
charge_limit,
147+
charge_window,
148+
export_window,
149+
export_limits,
150+
end_record=my_predbat.end_record,
151+
quiet=True,
152+
)
153+
finally:
154+
for name, value in saved.items():
155+
setattr(my_predbat, name, value)
156+
157+
after_charge = [(w["start"], w["end"]) for w in my_predbat.charge_window_best]
158+
after_export = [(w["start"], w["end"]) for w in my_predbat.export_window_best]
159+
if after_charge != before_charge:
160+
print("ERROR: charge window bounds moved during optimisation: {} -> {}".format(before_charge, after_charge))
161+
failed = True
162+
if after_export != before_export:
163+
print("ERROR: export window bounds moved during optimisation: {} -> {}".format(before_export, after_export))
164+
failed = True
165+
166+
if not failed:
167+
print("PASS")
168+
return failed

apps/predbat/unit_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
test_web_annual_validation_error_preserves_input,
9797
)
9898
from tests.test_window import run_window_sort_tests, run_intersect_window_tests
99+
from tests.test_hit_charge_cache import run_hit_charge_cache_tests
99100
from tests.test_find_charge_rate import test_find_charge_rate, test_find_charge_rate_pv_overlap, test_find_charge_rate_string_temperature, test_find_charge_rate_string_charge_curve
100101
from tests.test_manual_api import run_test_manual_api
101102
from tests.test_manual_soc import run_test_manual_soc
@@ -402,6 +403,7 @@ def main():
402403
("iboost_smart", run_iboost_smart_tests, "iBoost smart tests", False),
403404
("car_charging_smart", run_car_charging_smart_tests, "Car charging smart tests", False),
404405
("intersect_window", run_intersect_window_tests, "Intersect window tests", False),
406+
("hit_charge_cache", run_hit_charge_cache_tests, "Hit charge window cache tests", False),
405407
("inverter_multi", run_inverter_multi_tests, "Inverter multi tests", False),
406408
("octopus_free", test_octopus_free, "Octopus free electricity tests", False),
407409
("battery_curve_keys", run_battery_curve_keys_tests, "Battery curve keys tests", False),

0 commit comments

Comments
 (0)