Skip to content

Commit 54dd46e

Browse files
perf(plan): skip disabled windows in remove_intersecting_windows
remove_intersecting_windows runs on every simulation - both Prediction. run_prediction and run_prediction_kernel call it before simulating - so it sits in front of the C++ kernel on the hot path. It scanned every charge window against every export window, testing "is this charge window enabled" and "is this export window enabled" inside the inner loop, so a plan carrying mostly disabled windows still paid the full O(charge x export) scan to do nothing. During optimisation that is the normal case, not an edge case. Instrumenting a benchmark scenario: 266 charge windows per call of which 24.6 enabled, 48.8 export windows of which 16.0 enabled - 139 million pair-iterations scanned per plan, of which 3.1% involved an enabled pair. Enabled export windows are now collected once per call (returning immediately when there are none, since nothing can clip), and a disabled charge window short-circuits instead of scanning every export window to discover it cannot be clipped. Both fast paths rebuild the window dicts exactly as the clipping path does, so the returned windows carry the same keys and are equally freshly owned. Profiled on the worst benchmark scenario, this function was 124.7s of a 152.9s plan (81%). After: that scenario drops from 152.0s to 25.6s. Across the 20 scenario benchmark, mean optimise time falls from 13.674s to 4.359s (3.1x) with plan metric and cost identical on all 20 - the clipping behaviour is unchanged, only the work skipped. Adds characterisation tests first: the fully-covered, disabled-charge, disabled-export, clip-start, clip-end and split cases, plus a randomised equivalence check comparing the implementation against a naive reference over 200 random window layouts. These pass before and after the change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 24fde5a commit 54dd46e

2 files changed

Lines changed: 185 additions & 16 deletions

File tree

apps/predbat/tests/test_window.py

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
# pylint: disable=line-too-long
99
# pylint: disable=attribute-defined-outside-init
1010
# fmt on
11+
import random
12+
1113
from utils import remove_intersecting_windows
1214
from tests.test_infra import reset_rates, reset_inverter
1315
from prediction import Prediction
@@ -59,17 +61,167 @@ def run_window_sort_test(name, my_predbat, charge_window_best, export_window_bes
5961
return failed
6062

6163

62-
def run_intersect_window_tests(my_predbat):
63-
print("**** Running intersect window tests ****")
64+
def reference_remove_intersecting_windows(charge_limit_best, charge_window_best, export_limit_best, export_window_best):
65+
"""Deliberately naive reference: clip each charge window against every enabled export window.
66+
67+
Kept as a plain O(charge x export) scan with no early exits so it can be compared against the
68+
optimised implementation on randomised inputs - if the two ever disagree, the optimisation
69+
changed behaviour rather than just skipping work.
70+
"""
71+
windows = []
72+
limits = []
73+
for limit, window in zip(charge_limit_best, charge_window_best):
74+
segments = [(window["start"], window["end"])]
75+
if limit > 0.0:
76+
for dlimit, dwindow in zip(export_limit_best, export_window_best):
77+
if dlimit >= 100.0:
78+
continue
79+
dstart, dend = dwindow["start"], dwindow["end"]
80+
new_segments = []
81+
for start, end in segments:
82+
if dstart >= end or dend < start:
83+
new_segments.append((start, end))
84+
continue
85+
if dstart > start:
86+
new_segments.append((start, min(dstart, end)))
87+
if dend < end:
88+
new_segments.append((max(dend, start), end))
89+
segments = new_segments
90+
for start, end in segments:
91+
if (end - start) >= 5:
92+
windows.append({"start": start, "end": end, "average": window["average"]})
93+
limits.append(limit)
94+
return limits, windows
95+
96+
97+
def _intersect_case(name, charge_limit_best, charge_window_best, export_limit_best, export_window_best, expect_windows, expect_limits):
98+
"""Run one remove_intersecting_windows case and compare against expected windows/limits"""
6499
failed = False
65-
charge_window_best = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 10}]
66-
export_window_best = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 10}]
67-
charge_limit_best = [4]
68-
export_limit_best = [2]
69100
new_limit_best, new_window_best = remove_intersecting_windows(charge_limit_best, charge_window_best, export_limit_best, export_window_best)
70-
if len(new_window_best) != 0:
71-
print("ERROR: Expected no windows but got {}".format(new_window_best))
101+
got = [(w["start"], w["end"]) for w in new_window_best]
102+
if got != expect_windows:
103+
print("ERROR: {} expected windows {} but got {}".format(name, expect_windows, got))
72104
failed = True
105+
if list(new_limit_best) != list(expect_limits):
106+
print("ERROR: {} expected limits {} but got {}".format(name, expect_limits, list(new_limit_best)))
107+
failed = True
108+
return failed
109+
110+
111+
def run_intersect_window_tests(my_predbat):
112+
"""Characterisation tests for remove_intersecting_windows.
113+
114+
This is a hot path - it runs on every simulation - so it needs enough coverage to be optimised
115+
against. The randomised equivalence check at the end is the real guard: it compares the shipped
116+
implementation against a naive reference over many random window layouts.
117+
"""
118+
print("**** Running intersect window tests ****")
119+
failed = False
120+
now = my_predbat.minutes_now
121+
122+
# A fully-covered charge window disappears
123+
failed |= _intersect_case(
124+
"fully covered",
125+
[4],
126+
[{"start": now, "end": now + 30, "average": 10}],
127+
[2],
128+
[{"start": now, "end": now + 30, "average": 10}],
129+
[],
130+
[],
131+
)
132+
133+
# A disabled charge window (limit 0) is passed through untouched even when an export overlaps it
134+
failed |= _intersect_case(
135+
"disabled charge untouched",
136+
[0],
137+
[{"start": now, "end": now + 30, "average": 10}],
138+
[2],
139+
[{"start": now, "end": now + 30, "average": 10}],
140+
[(now, now + 30)],
141+
[0],
142+
)
143+
144+
# A disabled export window (limit 100) never clips
145+
failed |= _intersect_case(
146+
"disabled export does not clip",
147+
[4],
148+
[{"start": now, "end": now + 30, "average": 10}],
149+
[100.0],
150+
[{"start": now, "end": now + 30, "average": 10}],
151+
[(now, now + 30)],
152+
[4],
153+
)
154+
155+
# Export overlapping the start moves the charge window start forward
156+
failed |= _intersect_case(
157+
"clip start",
158+
[4],
159+
[{"start": now, "end": now + 60, "average": 10}],
160+
[2],
161+
[{"start": now, "end": now + 30, "average": 10}],
162+
[(now + 30, now + 60)],
163+
[4],
164+
)
165+
166+
# Export overlapping the end pulls the charge window end back
167+
failed |= _intersect_case(
168+
"clip end",
169+
[4],
170+
[{"start": now, "end": now + 60, "average": 10}],
171+
[2],
172+
[{"start": now + 30, "end": now + 60, "average": 10}],
173+
[(now, now + 30)],
174+
[4],
175+
)
176+
177+
# Export in the middle splits the charge window into two
178+
failed |= _intersect_case(
179+
"split in two",
180+
[4],
181+
[{"start": now, "end": now + 90, "average": 10}],
182+
[2],
183+
[{"start": now + 30, "end": now + 60, "average": 10}],
184+
[(now, now + 30), (now + 60, now + 90)],
185+
[4, 4],
186+
)
187+
188+
# Randomised equivalence against the naive reference
189+
rng = random.Random(1234)
190+
for case in range(200):
191+
n_charge = rng.randint(0, 6)
192+
n_export = rng.randint(0, 6)
193+
charge_windows = []
194+
charge_limits = []
195+
minute = now
196+
for _ in range(n_charge):
197+
length = rng.choice([5, 10, 30, 60, 90])
198+
charge_windows.append({"start": minute, "end": minute + length, "average": 10})
199+
charge_limits.append(rng.choice([0, 0, 4.0, 8.0]))
200+
minute += length + rng.choice([0, 5, 30])
201+
export_windows = []
202+
export_limits = []
203+
minute = now
204+
for _ in range(n_export):
205+
length = rng.choice([5, 10, 30, 60])
206+
export_windows.append({"start": minute, "end": minute + length, "average": 10})
207+
export_limits.append(rng.choice([100.0, 100.0, 99.0, 0.0, 50.0]))
208+
minute += length + rng.choice([0, 5, 30])
209+
210+
got_limits, got_windows = remove_intersecting_windows([x for x in charge_limits], [dict(w) for w in charge_windows], export_limits, export_windows)
211+
exp_limits, exp_windows = reference_remove_intersecting_windows(charge_limits, charge_windows, export_limits, export_windows)
212+
got_pairs = [(w["start"], w["end"], limit) for w, limit in zip(got_windows, got_limits)]
213+
exp_pairs = [(w["start"], w["end"], limit) for w, limit in zip(exp_windows, exp_limits)]
214+
if got_pairs != exp_pairs:
215+
print("ERROR: randomised case {} disagrees with reference".format(case))
216+
print(" charge {} limits {}".format(charge_windows, charge_limits))
217+
print(" export {} limits {}".format(export_windows, export_limits))
218+
print(" got {}".format(got_pairs))
219+
print(" expected {}".format(exp_pairs))
220+
failed = True
221+
break
222+
223+
if not failed:
224+
print("PASS")
73225
return failed
74226

75227

apps/predbat/utils.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,22 @@ def calc_percent_limit(charge_limit, soc_max):
992992
def remove_intersecting_windows(charge_limit_best, charge_window_best, export_limit_best, export_window_best):
993993
"""
994994
Filters and removes intersecting charge windows
995+
996+
This runs on every simulation (see Prediction.run_prediction and run_prediction_kernel), so the
997+
scan is restricted to the pairs that can actually clip: only export windows that are enabled
998+
(limit < 100) can clip anything, and only charge windows that are enabled (limit > 0) can be
999+
clipped. Both were previously tested inside the inner loop, so a plan carrying hundreds of
1000+
disabled windows - the normal case during optimisation - scanned every pair to do nothing. The
1001+
clipping behaviour itself is unchanged; see run_intersect_window_tests, which compares this
1002+
against a naive reference implementation over randomised window layouts.
9951003
"""
1004+
# Enabled export windows only - the sole candidates for clipping anything
1005+
export_active = [(export_window_best[n]["start"], export_window_best[n]["end"]) for n in range(len(export_limit_best)) if export_limit_best[n] < 100.0]
1006+
if not export_active:
1007+
# Rebuild the windows rather than passing the caller's dicts back, so the returned windows
1008+
# carry exactly the same keys (and are as freshly owned) as on the clipping path below
1009+
return list(charge_limit_best), [{"start": w["start"], "end": w["end"], "average": w["average"]} for w in charge_window_best]
1010+
9961011
clip_again = True
9971012

9981013
# For each charge window
@@ -1008,15 +1023,17 @@ def remove_intersecting_windows(charge_limit_best, charge_window_best, export_li
10081023
limit = charge_limit_best[window_n]
10091024
clipped = False
10101025

1011-
# For each discharge window
1012-
for dwindow_n in range(len(export_limit_best)):
1013-
dwindow = export_window_best[dwindow_n]
1014-
dlimit = export_limit_best[dwindow_n]
1015-
dstart = dwindow["start"]
1016-
dend = dwindow["end"]
1026+
if limit <= 0.0:
1027+
# A disabled charge window can never be clipped; rebuild it exactly as the clipping
1028+
# path below would have done, so the returned dicts are equivalent either way
1029+
new_window_best.append({"start": start, "end": end, "average": average})
1030+
new_limit_best.append(limit)
1031+
continue
10171032

1018-
# Overlapping window with enabled discharge?
1019-
if (limit > 0.0) and (dlimit < 100.0) and (dstart < end) and (dend >= start):
1033+
# For each enabled discharge window
1034+
for dstart, dend in export_active:
1035+
# Overlapping window?
1036+
if (dstart < end) and (dend >= start):
10201037
if dstart <= start:
10211038
if start != dend:
10221039
start = dend

0 commit comments

Comments
 (0)