Skip to content

Commit 31faa64

Browse files
springfall2008claudegithub-actions[bot]
authored
fix(prediction): collision-safe simulation cache key + clip windows in the C++ kernel (#4508)
* fix(prediction): make the simulation cache key collision-safe, clip windows in the kernel Two changes to the per-simulation hot path, one of which turns out to be a correctness fix rather than the optimisation it was meant to be. The prediction cache key XORed a hash of every window bound together: window_hash ^= hash(window["start"]) ^ hash(window["end"]) XOR is commutative and self-cancelling, and hash(n) == n for the small integers involved, so two windows sharing bounds cancel out entirely, window order is invisible, and start/end values fold into each other. Different scenarios therefore share a key and the cache hands back a result simulated for a different plan. Instrumenting the benchmark, that is not theoretical: 186 collisions on one scenario and 828 on another, each one a wrong result fed back into the optimiser. The key is now a single tuple hash, which is order-sensitive and does not cancel. It is also cheaper, because the per-element hashing happens in C rather than a Python loop, and it is only computed when the cache is actually in play - a saving run always simulates, so the key was previously built and discarded. This changes plans: 3 of 20 benchmark scenarios move, 2 better and 1 worse. That spread is what a cache no longer returning the wrong scenario's answer looks like - the movement is the bug being removed, not a regression. Separately, remove_intersecting_windows is now done inside the kernel (clip_intersecting_charge_windows in prediction_kernel.cpp) instead of in Python before every kernel call, where it cost more than the simulation it preceded. The Python engine keeps its own copy, so the two must agree; both parity revisions are bumped and all platform binaries rebuilt, since a stale binary is refused and would silently drop users back to the Python engine. The clipping port is behaviour-neutral, verified separately from the cache fix: with the new key in place, adding the port leaves all 20 benchmark scenarios bit-identical on metric and cost. Parity is pinned by a new sweep. The existing random sweep places a few windows at random, so an export window landing strictly inside a charge window - the split case, and the subtlest part of clipping - almost never arises. The new sweep positions export windows relative to charge windows on purpose (covering, overlapping either end, inside, touching exactly at a boundary, leaving a sub-5-minute remnant) and fails if it stops generating splits. 250 layouts, 33 of which split a charge window, all matching. Benchmark: worst scenario 25.6s -> 18.2s, total optimise time across the 20 scenarios 87.1s -> 74.3s for the clipping port alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: update prediction kernel binaries for all platforms --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent a10b5c3 commit 31faa64

11 files changed

Lines changed: 284 additions & 56 deletions

apps/predbat/prediction.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from datetime import timedelta
2121
from const import PREDICT_STEP, PV_SCENARIO_PV10, PV_SCENARIO_PV90, RUN_EVERY, TIME_FORMAT
22+
from operator import itemgetter
23+
2224
from utils import remove_intersecting_windows, get_charge_rate_curve_cached, get_discharge_rate_curve_cached, find_charge_rate, calc_percent_limit, in_iboost_slot, in_car_slot, charge_curve_to_tuple
2325
from prediction_kernel import create_kernel_context, kernel_supported, run_prediction_kernel
2426

@@ -92,6 +94,11 @@ def get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybri
9294
return total_inverted
9395

9496

97+
# Pulls (start, end) from a window dict; used to build the prediction cache key without a Python
98+
# level loop over every window
99+
window_bounds = itemgetter("start", "end")
100+
101+
95102
class Prediction:
96103
"""
97104
Class to hold prediction input and output data and the run function
@@ -409,24 +416,34 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
409416
KERNEL_PARITY_REVISION (prediction_kernel.py) and PK_PARITY_REVISION (prediction_kernel.cpp)
410417
must both be bumped, and the kernel_parity test must pass (cd coverage && ./run_all --test kernel_parity).
411418
"""
412-
window_hash = 0
413-
for window in charge_window:
414-
window_hash ^= hash(window["start"]) ^ hash(window["end"])
415-
for window in export_window:
416-
window_hash ^= hash(window["start"]) ^ hash(window["end"])
417-
418-
sim_hash = hash(tuple(charge_limit)) ^ window_hash ^ hash(tuple(export_limits)) ^ hash(pv_scenario) ^ hash(end_record) ^ hash(step)
419-
420-
if not save and cache and sim_hash in self.prediction_cache:
421-
# Return cached result
422-
return self.prediction_cache[sim_hash]
419+
# The cache key is only wanted when the cache is actually in play - a saving run always
420+
# simulates - so it is not computed otherwise. Building it as one tuple hash keeps the
421+
# per-window hashing in C rather than looping in Python, which matters because this runs on
422+
# every simulation with a few hundred windows.
423+
sim_hash = None
424+
if cache and not save:
425+
sim_hash = hash(
426+
(
427+
tuple(charge_limit),
428+
tuple(map(window_bounds, charge_window)),
429+
tuple(export_limits),
430+
tuple(map(window_bounds, export_window)),
431+
pv_scenario,
432+
end_record,
433+
step,
434+
)
435+
)
436+
cached_result = self.prediction_cache.get(sim_hash)
437+
if cached_result is not None:
438+
# Return cached result
439+
return cached_result
423440

424441
# Try the C++ prediction kernel first; unsupported scenarios fall through to the Python engine.
425442
# The kernel understands all three pv_scenario values (see PkScenario.pv_scenario, ABI 3).
426443
if kernel_supported(self, save, step):
427444
kernel_result = run_prediction_kernel(self, charge_limit, charge_window, export_window, export_limits, pv_scenario, end_record, step, cache)
428445
if kernel_result is not None:
429-
if not save and cache:
446+
if sim_hash is not None:
430447
# Store in cache without the SoC/car data to save memory, mirroring the Python engine
431448
self.prediction_cache[sim_hash] = kernel_result[:11] + ([], []) + kernel_result[13:]
432449
return kernel_result
@@ -1301,7 +1318,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
13011318
self.import_kwh_time = import_kwh_time
13021319
self.export_kwh_time = export_kwh_time
13031320

1304-
if not save and cache:
1321+
if sim_hash is not None:
13051322
self.prediction_cache[sim_hash] = (
13061323
round(final_metric, 4),
13071324
round(final_import_kwh_battery, 4),

apps/predbat/prediction_kernel.cpp

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
#include <vector>
2828

2929
#define PK_ABI_VERSION 3
30-
#define PK_PARITY_REVISION 4
30+
#define PK_PARITY_REVISION 5
3131
#define PK_MAX_CARS 4
3232
#define PK_RUN_EVERY 5 // const.py RUN_EVERY
3333

@@ -234,6 +234,92 @@ inline double rate_curve(double soc_key, double rate_setting, double rate_max, d
234234
// Build per-step window membership, mirroring Prediction.find_charge_window_optimised():
235235
// dict keyed by absolute minute stepping 5 from each window start, last window wins,
236236
// looked up at minute_absolute = minutes_now + k*5 (so misaligned windows never match).
237+
// Mirrors remove_intersecting_windows() in utils.py, which the Python engine applies before
238+
// simulating. Charge windows that collide with an enabled export window are trimmed, and a window
239+
// with an export landing inside it is split in two. Two rules are easy to get wrong and are pinned
240+
// by tests: a window that was never clipped survives whatever its length, while a remnant clipping
241+
// itself created is dropped below 5 minutes; and windows that merely touch at a boundary overlap
242+
// arithmetically but remove nothing, so they must not count as clipped.
243+
//
244+
// PARITY: any change here must be mirrored in utils.remove_intersecting_windows and vice versa.
245+
static void clip_intersecting_charge_windows(std::vector<int32_t> &out_start, std::vector<int32_t> &out_end, std::vector<double> &out_limit, int32_t n_charge, const int32_t *charge_start, const int32_t *charge_end, const double *charge_limit, int32_t n_export, const int32_t *export_start,
246+
const int32_t *export_end, const double *export_limits)
247+
{
248+
// Enabled export windows only - the sole candidates for clipping anything - in start order
249+
std::vector<std::pair<int32_t, int32_t>> export_active;
250+
export_active.reserve(n_export);
251+
for (int32_t n = 0; n < n_export; n++) {
252+
if (export_limits[n] < 100.0) {
253+
export_active.emplace_back(export_start[n], export_end[n]);
254+
}
255+
}
256+
std::sort(export_active.begin(), export_active.end());
257+
258+
out_start.clear();
259+
out_end.clear();
260+
out_limit.clear();
261+
out_start.reserve(n_charge);
262+
out_end.reserve(n_charge);
263+
out_limit.reserve(n_charge);
264+
265+
if (export_active.empty()) {
266+
for (int32_t n = 0; n < n_charge; n++) {
267+
out_start.push_back(charge_start[n]);
268+
out_end.push_back(charge_end[n]);
269+
out_limit.push_back(charge_limit[n]);
270+
}
271+
return;
272+
}
273+
274+
for (int32_t n = 0; n < n_charge; n++) {
275+
int32_t start = charge_start[n];
276+
int32_t end = charge_end[n];
277+
const double limit = charge_limit[n];
278+
279+
if (!(limit > 0.0)) {
280+
// A disabled charge window can never be clipped
281+
out_start.push_back(start);
282+
out_end.push_back(end);
283+
out_limit.push_back(limit);
284+
continue;
285+
}
286+
287+
bool clipped = false;
288+
for (const auto &dw : export_active) {
289+
const int32_t dstart = dw.first;
290+
const int32_t dend = dw.second;
291+
if ((dstart < end) && (dend >= start)) {
292+
if (dstart <= start) {
293+
if (start != dend) {
294+
start = dend;
295+
clipped = true;
296+
}
297+
} else if (dend >= end) {
298+
if (end != dstart) {
299+
end = dstart;
300+
clipped = true;
301+
}
302+
} else {
303+
// Two segments - emit the head now, carry on clipping the tail
304+
if ((dstart - start) >= 5) {
305+
out_start.push_back(start);
306+
out_end.push_back(dstart);
307+
out_limit.push_back(limit);
308+
}
309+
start = dend;
310+
clipped = true;
311+
}
312+
}
313+
}
314+
315+
if (!clipped || ((end - start) >= 5)) {
316+
out_start.push_back(start);
317+
out_end.push_back(end);
318+
out_limit.push_back(limit);
319+
}
320+
}
321+
}
322+
237323
void build_window_membership(std::vector<int32_t> &member, int32_t n_windows, const int32_t *starts, const int32_t *ends, const double *limits, bool is_export, int32_t minutes_now, int32_t n_steps)
238324
{
239325
member.assign(n_steps, -1);
@@ -360,7 +446,15 @@ int32_t pk_run(int64_t handle, const PkScenario *s, PkResult *out)
360446

361447
// Window membership - prediction.py:494-495 / find_charge_window_optimised
362448
std::vector<int32_t> charge_window_optimised, export_window_optimised;
363-
build_window_membership(charge_window_optimised, s->n_charge, s->charge_start, s->charge_end, s->charge_limit, false, c->minutes_now, n_steps);
449+
450+
// The caller hands over the raw charge windows; clipping them against the export windows used to
451+
// be done in Python on every simulation, which cost more than the simulation itself
452+
std::vector<int32_t> clipped_start, clipped_end;
453+
std::vector<double> clipped_limit;
454+
clip_intersecting_charge_windows(clipped_start, clipped_end, clipped_limit, s->n_charge, s->charge_start, s->charge_end, s->charge_limit, s->n_export, s->export_start, s->export_end, s->export_limits);
455+
const int32_t n_charge_clipped = static_cast<int32_t>(clipped_start.size());
456+
457+
build_window_membership(charge_window_optimised, n_charge_clipped, clipped_start.data(), clipped_end.data(), clipped_limit.data(), false, c->minutes_now, n_steps);
364458
build_window_membership(export_window_optimised, s->n_export, s->export_start, s->export_end, s->export_limits, true, c->minutes_now, n_steps);
365459

366460
// Initial state - prediction.py:435-490
@@ -461,7 +555,7 @@ int32_t pk_run(int64_t handle, const PkScenario *s, PkResult *out)
461555
// Find charge limit - prediction.py:609-620
462556
double charge_limit_n = 0;
463557
if (charge_window_active) {
464-
charge_limit_n = s->charge_limit[charge_window_n];
558+
charge_limit_n = clipped_limit[charge_window_n];
465559
if (c->set_charge_freeze && (calc_percent_limit(charge_limit_n, soc_max) == reserve_percent)) {
466560
// Charge freeze via reserve
467561
charge_limit_n = std::max(soc, reserve);

apps/predbat/prediction_kernel.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@
2727
import weakref
2828

2929
from const import PREDICT_STEP
30-
from utils import remove_intersecting_windows, get_curve_value, find_battery_temperature_cap, in_car_slot, in_iboost_slot
30+
from utils import get_curve_value, find_battery_temperature_cap, in_car_slot, in_iboost_slot
3131

3232
# Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp)
3333
KERNEL_ABI_VERSION = 3
34-
KERNEL_PARITY_REVISION = 4
34+
KERNEL_PARITY_REVISION = 5
3535

3636
# Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp)
3737
KERNEL_MAX_CARS = 4
@@ -466,8 +466,9 @@ def run_prediction_kernel(pred, charge_limit, charge_window, export_window, expo
466466
if not lib:
467467
return None
468468

469-
# Remove intersecting windows, mirroring the Python engine - prediction.py:492-493
470-
charge_limit, charge_window = remove_intersecting_windows(charge_limit, charge_window, export_limits, export_window)
469+
# The kernel clips intersecting windows itself (clip_intersecting_charge_windows in
470+
# prediction_kernel.cpp), mirroring what the Python engine does before simulating. Doing it in
471+
# Python here cost more per simulation than the simulation, so the raw windows are handed over.
471472

472473
n_steps = pred.forecast_minutes // PREDICT_STEP
473474
scenario = PkScenario()
10.8 KB
Binary file not shown.
10.5 KB
Binary file not shown.
17.8 KB
Binary file not shown.
9.73 KB
Binary file not shown.
11.5 KB
Binary file not shown.
11.3 KB
Binary file not shown.

apps/predbat/tests/test_kernel_parity.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from const import PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90
3030
from prediction import Prediction
3131
from prediction_kernel import create_kernel_context, run_prediction_kernel, load_kernel
32+
from utils import remove_intersecting_windows
3233
from tests.test_infra import reset_inverter, reset_rates
3334
from tests.test_model import run_model_tests
3435

@@ -687,6 +688,119 @@ def run_random_sweep_tests(my_predbat, count=150):
687688
return failed
688689

689690

691+
def make_intersecting_windows(rng, minutes_now, forecast_minutes):
692+
"""Build a charge/export layout that deliberately exercises window clipping.
693+
694+
The generic sweep places a handful of windows at random, so an export window landing strictly
695+
inside a charge window - the split case, and the subtlest part of the clipping - almost never
696+
comes up. Here export windows are positioned relative to the charge windows on purpose: covering
697+
them entirely, overlapping either end, sitting inside them, and touching exactly at a boundary.
698+
Short windows and short remnants are included because the 5 minute minimum only applies to
699+
remnants clipping itself created, never to a window that was left alone.
700+
"""
701+
charge_window = []
702+
minute = minutes_now
703+
for _ in range(rng.randint(1, 5)):
704+
length = rng.choice([5, 10, 30, 60, 120, 240])
705+
end = min(minute + length, minutes_now + forecast_minutes)
706+
if end <= minute:
707+
break
708+
charge_window.append({"start": minute, "end": end, "average": round(rng.uniform(0, 40), 2)})
709+
minute = end + rng.choice([0, 0, 5, 30])
710+
if minute >= minutes_now + forecast_minutes:
711+
break
712+
713+
export_window = []
714+
for window in charge_window:
715+
mode = rng.choice(["inside", "inside", "overlap_start", "overlap_end", "cover", "touch_start", "touch_end", "clear", "tiny_remnant"])
716+
start, end = window["start"], window["end"]
717+
span = end - start
718+
if mode == "inside" and span >= 20:
719+
dstart = start + rng.randrange(5, max(span - 10, 6), 5)
720+
dend = min(dstart + rng.choice([5, 10, 30]), end - 1)
721+
if dend <= dstart:
722+
continue
723+
elif mode == "overlap_start":
724+
dstart, dend = max(start - rng.choice([5, 30]), minutes_now), start + max(span // 3, 5)
725+
elif mode == "overlap_end":
726+
dstart, dend = end - max(span // 3, 5), end + rng.choice([5, 30])
727+
elif mode == "cover":
728+
dstart, dend = start, end
729+
elif mode == "touch_start":
730+
dstart, dend = max(start - 30, minutes_now), start
731+
elif mode == "touch_end":
732+
dstart, dend = end, end + 30
733+
elif mode == "tiny_remnant" and span >= 10:
734+
# Leave only a couple of minutes at the end, below the 5 minute minimum
735+
dstart, dend = start, end - rng.choice([1, 2, 3])
736+
else:
737+
dstart, dend = end + 60, end + 90
738+
dstart = max(min(dstart, minutes_now + forecast_minutes), minutes_now)
739+
dend = max(min(dend, minutes_now + forecast_minutes), dstart)
740+
if dend > dstart:
741+
export_window.append({"start": dstart, "end": dend, "average": round(rng.uniform(0, 40), 2)})
742+
743+
export_window.sort(key=lambda w: w["start"])
744+
return charge_window, export_window
745+
746+
747+
def run_clipping_parity_tests(my_predbat, count=250):
748+
"""Compare both engines on layouts built to exercise window clipping, returns True on failure.
749+
750+
The kernel clips intersecting charge windows itself (clip_intersecting_charge_windows in
751+
prediction_kernel.cpp) rather than having Python do it first, so this is the sweep that pins
752+
those two implementations together.
753+
"""
754+
failed = False
755+
split_layouts = 0
756+
for seed in range(count):
757+
rng = random.Random(500000 + seed)
758+
reset_inverter(my_predbat)
759+
reset_rates(my_predbat, 10.0, 5.0)
760+
my_predbat.battery_rate_max_export = my_predbat.battery_rate_max_discharge
761+
apply_random_scenario(my_predbat, rng)
762+
pv_step, pv10_step, load_step, load10_step = make_step_data(my_predbat, rng=rng)
763+
764+
charge_window, export_window = make_intersecting_windows(rng, my_predbat.minutes_now, my_predbat.forecast_minutes)
765+
charge_limit = [rng.choice([0.0, my_predbat.reserve, my_predbat.soc_max, round(rng.uniform(0, my_predbat.soc_max), 2)]) for _ in charge_window]
766+
export_limits = [rng.choice([100.0, 99.0, 0.0, round(rng.uniform(0, 100), 1)]) for _ in export_window]
767+
end_record = rng.choice([my_predbat.forecast_minutes, my_predbat.forecast_minutes - 30])
768+
769+
# Count the layouts that actually split a charge window, so a generator that stopped
770+
# producing them would show up rather than silently weakening this sweep
771+
clipped_limits, clipped_windows = remove_intersecting_windows(charge_limit, charge_window, export_limits, export_window)
772+
if len(clipped_windows) > len(charge_window):
773+
split_layouts += 1
774+
775+
for pv_scenario in (PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10):
776+
failed |= dual_run(
777+
"clip_{}_s{}".format(seed, pv_scenario),
778+
my_predbat,
779+
pv_step,
780+
pv10_step,
781+
load_step,
782+
load10_step,
783+
charge_limit,
784+
charge_window,
785+
export_window,
786+
export_limits,
787+
pv_scenario,
788+
end_record,
789+
)
790+
if failed:
791+
print("Clipping sweep failed at seed {} scenario {}".format(seed, pv_scenario))
792+
print(" charge {} limits {}".format([(w["start"], w["end"]) for w in charge_window], charge_limit))
793+
print(" export {} limits {}".format([(w["start"], w["end"]) for w in export_window], export_limits))
794+
break
795+
if failed:
796+
break
797+
print("Clipping sweep ran {} layouts, {} of which split a charge window".format(count, split_layouts))
798+
if split_layouts == 0:
799+
print("ERROR: clipping sweep generated no window splits - the sweep is not exercising the split path")
800+
failed = True
801+
return failed
802+
803+
690804
def kernel_available():
691805
"""Ensure the kernel library is built and loaded, returns (available, required_failure)"""
692806
if not ensure_kernel_built():
@@ -729,6 +843,8 @@ def run_kernel_parity_tests(my_predbat):
729843
failed = run_edge_case_tests(my_predbat)
730844
if not failed:
731845
failed |= run_random_sweep_tests(my_predbat)
846+
if not failed:
847+
failed |= run_clipping_parity_tests(my_predbat)
732848
finally:
733849
restore_scenario_state(my_predbat, state)
734850

0 commit comments

Comments
 (0)