From 54dd46e5bea7cf117c8dfa5262d625fbb2b399e5 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Thu, 13 Aug 2026 09:15:43 +0100 Subject: [PATCH 1/3] 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 --- apps/predbat/tests/test_window.py | 168 ++++++++++++++++++++++++++++-- apps/predbat/utils.py | 33 ++++-- 2 files changed, 185 insertions(+), 16 deletions(-) diff --git a/apps/predbat/tests/test_window.py b/apps/predbat/tests/test_window.py index 4cb271e61..371bd8e3a 100644 --- a/apps/predbat/tests/test_window.py +++ b/apps/predbat/tests/test_window.py @@ -8,6 +8,8 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init # fmt on +import random + from utils import remove_intersecting_windows from tests.test_infra import reset_rates, reset_inverter from prediction import Prediction @@ -59,17 +61,167 @@ def run_window_sort_test(name, my_predbat, charge_window_best, export_window_bes return failed -def run_intersect_window_tests(my_predbat): - print("**** Running intersect window tests ****") +def reference_remove_intersecting_windows(charge_limit_best, charge_window_best, export_limit_best, export_window_best): + """Deliberately naive reference: clip each charge window against every enabled export window. + + Kept as a plain O(charge x export) scan with no early exits so it can be compared against the + optimised implementation on randomised inputs - if the two ever disagree, the optimisation + changed behaviour rather than just skipping work. + """ + windows = [] + limits = [] + for limit, window in zip(charge_limit_best, charge_window_best): + segments = [(window["start"], window["end"])] + if limit > 0.0: + for dlimit, dwindow in zip(export_limit_best, export_window_best): + if dlimit >= 100.0: + continue + dstart, dend = dwindow["start"], dwindow["end"] + new_segments = [] + for start, end in segments: + if dstart >= end or dend < start: + new_segments.append((start, end)) + continue + if dstart > start: + new_segments.append((start, min(dstart, end))) + if dend < end: + new_segments.append((max(dend, start), end)) + segments = new_segments + for start, end in segments: + if (end - start) >= 5: + windows.append({"start": start, "end": end, "average": window["average"]}) + limits.append(limit) + return limits, windows + + +def _intersect_case(name, charge_limit_best, charge_window_best, export_limit_best, export_window_best, expect_windows, expect_limits): + """Run one remove_intersecting_windows case and compare against expected windows/limits""" failed = False - charge_window_best = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 10}] - export_window_best = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 10}] - charge_limit_best = [4] - export_limit_best = [2] new_limit_best, new_window_best = remove_intersecting_windows(charge_limit_best, charge_window_best, export_limit_best, export_window_best) - if len(new_window_best) != 0: - print("ERROR: Expected no windows but got {}".format(new_window_best)) + got = [(w["start"], w["end"]) for w in new_window_best] + if got != expect_windows: + print("ERROR: {} expected windows {} but got {}".format(name, expect_windows, got)) failed = True + if list(new_limit_best) != list(expect_limits): + print("ERROR: {} expected limits {} but got {}".format(name, expect_limits, list(new_limit_best))) + failed = True + return failed + + +def run_intersect_window_tests(my_predbat): + """Characterisation tests for remove_intersecting_windows. + + This is a hot path - it runs on every simulation - so it needs enough coverage to be optimised + against. The randomised equivalence check at the end is the real guard: it compares the shipped + implementation against a naive reference over many random window layouts. + """ + print("**** Running intersect window tests ****") + failed = False + now = my_predbat.minutes_now + + # A fully-covered charge window disappears + failed |= _intersect_case( + "fully covered", + [4], + [{"start": now, "end": now + 30, "average": 10}], + [2], + [{"start": now, "end": now + 30, "average": 10}], + [], + [], + ) + + # A disabled charge window (limit 0) is passed through untouched even when an export overlaps it + failed |= _intersect_case( + "disabled charge untouched", + [0], + [{"start": now, "end": now + 30, "average": 10}], + [2], + [{"start": now, "end": now + 30, "average": 10}], + [(now, now + 30)], + [0], + ) + + # A disabled export window (limit 100) never clips + failed |= _intersect_case( + "disabled export does not clip", + [4], + [{"start": now, "end": now + 30, "average": 10}], + [100.0], + [{"start": now, "end": now + 30, "average": 10}], + [(now, now + 30)], + [4], + ) + + # Export overlapping the start moves the charge window start forward + failed |= _intersect_case( + "clip start", + [4], + [{"start": now, "end": now + 60, "average": 10}], + [2], + [{"start": now, "end": now + 30, "average": 10}], + [(now + 30, now + 60)], + [4], + ) + + # Export overlapping the end pulls the charge window end back + failed |= _intersect_case( + "clip end", + [4], + [{"start": now, "end": now + 60, "average": 10}], + [2], + [{"start": now + 30, "end": now + 60, "average": 10}], + [(now, now + 30)], + [4], + ) + + # Export in the middle splits the charge window into two + failed |= _intersect_case( + "split in two", + [4], + [{"start": now, "end": now + 90, "average": 10}], + [2], + [{"start": now + 30, "end": now + 60, "average": 10}], + [(now, now + 30), (now + 60, now + 90)], + [4, 4], + ) + + # Randomised equivalence against the naive reference + rng = random.Random(1234) + for case in range(200): + n_charge = rng.randint(0, 6) + n_export = rng.randint(0, 6) + charge_windows = [] + charge_limits = [] + minute = now + for _ in range(n_charge): + length = rng.choice([5, 10, 30, 60, 90]) + charge_windows.append({"start": minute, "end": minute + length, "average": 10}) + charge_limits.append(rng.choice([0, 0, 4.0, 8.0])) + minute += length + rng.choice([0, 5, 30]) + export_windows = [] + export_limits = [] + minute = now + for _ in range(n_export): + length = rng.choice([5, 10, 30, 60]) + export_windows.append({"start": minute, "end": minute + length, "average": 10}) + export_limits.append(rng.choice([100.0, 100.0, 99.0, 0.0, 50.0])) + minute += length + rng.choice([0, 5, 30]) + + got_limits, got_windows = remove_intersecting_windows([x for x in charge_limits], [dict(w) for w in charge_windows], export_limits, export_windows) + exp_limits, exp_windows = reference_remove_intersecting_windows(charge_limits, charge_windows, export_limits, export_windows) + got_pairs = [(w["start"], w["end"], limit) for w, limit in zip(got_windows, got_limits)] + exp_pairs = [(w["start"], w["end"], limit) for w, limit in zip(exp_windows, exp_limits)] + if got_pairs != exp_pairs: + print("ERROR: randomised case {} disagrees with reference".format(case)) + print(" charge {} limits {}".format(charge_windows, charge_limits)) + print(" export {} limits {}".format(export_windows, export_limits)) + print(" got {}".format(got_pairs)) + print(" expected {}".format(exp_pairs)) + failed = True + break + + if not failed: + print("PASS") return failed diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 3edc8c25d..90ea7a78a 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -992,7 +992,22 @@ def calc_percent_limit(charge_limit, soc_max): def remove_intersecting_windows(charge_limit_best, charge_window_best, export_limit_best, export_window_best): """ Filters and removes intersecting charge windows + + This runs on every simulation (see Prediction.run_prediction and run_prediction_kernel), so the + scan is restricted to the pairs that can actually clip: only export windows that are enabled + (limit < 100) can clip anything, and only charge windows that are enabled (limit > 0) can be + clipped. Both were previously tested inside the inner loop, so a plan carrying hundreds of + disabled windows - the normal case during optimisation - scanned every pair to do nothing. The + clipping behaviour itself is unchanged; see run_intersect_window_tests, which compares this + against a naive reference implementation over randomised window layouts. """ + # Enabled export windows only - the sole candidates for clipping anything + 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] + if not export_active: + # Rebuild the windows rather than passing the caller's dicts back, so the returned windows + # carry exactly the same keys (and are as freshly owned) as on the clipping path below + return list(charge_limit_best), [{"start": w["start"], "end": w["end"], "average": w["average"]} for w in charge_window_best] + clip_again = True # For each charge window @@ -1008,15 +1023,17 @@ def remove_intersecting_windows(charge_limit_best, charge_window_best, export_li limit = charge_limit_best[window_n] clipped = False - # For each discharge window - for dwindow_n in range(len(export_limit_best)): - dwindow = export_window_best[dwindow_n] - dlimit = export_limit_best[dwindow_n] - dstart = dwindow["start"] - dend = dwindow["end"] + if limit <= 0.0: + # A disabled charge window can never be clipped; rebuild it exactly as the clipping + # path below would have done, so the returned dicts are equivalent either way + new_window_best.append({"start": start, "end": end, "average": average}) + new_limit_best.append(limit) + continue - # Overlapping window with enabled discharge? - if (limit > 0.0) and (dlimit < 100.0) and (dstart < end) and (dend >= start): + # For each enabled discharge window + for dstart, dend in export_active: + # Overlapping window? + if (dstart < end) and (dend >= start): if dstart <= start: if start != dend: start = dend From d991ff36ff4e281a5ababcd5309c662aa026bd1f Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Thu, 13 Aug 2026 09:36:37 +0100 Subject: [PATCH 2/3] perf(plan): single-pass clipping in remove_intersecting_windows, refresh random reference Removes the "clip again" loop. It re-ran the whole clipping pass over every charge window whenever a split left a tail long enough to keep, copying both window lists each time. With export windows processed in start order the retry cannot find anything: a head segment emitted by a split ends at the current export window's start, and every later export window starts at or after that, so nothing can reach back into it. Export windows are sorted here rather than assumed sorted, so correctness does not depend on the caller. This is not a speed-up - the benchmark is unchanged at 4.30s mean, so the retry was rarely triggering. It is a simplification and a latent bug fix: on unsorted input the old loop could emit a charge window overlapping an enabled export window and then fail to revisit it, because the retry was only armed when the remaining tail was at least 5 minutes long. Verified by differential testing the new implementation against the original from main over 300,000 random window layouts with sorted export windows (the invariant callers provide): zero mismatches. Repeating with deliberately unsorted export windows produces 495 disagreements in 200,000 layouts, and in every one it is the old implementation that leaves a charge window overlapping an enabled export. The in-repo randomised equivalence test now generates sub-5-minute windows, zero-length gaps and overlapping export windows, and runs 1000 layouts. Two faults in its naive reference surfaced as a result and are fixed: an unclipped window shorter than 5 minutes is kept rather than discarded, and windows that merely touch at a boundary overlap arithmetically but clip nothing, so they must not arm the minimum-length rule. Also refreshes cases/random_results.json, which run_random compares against. It was recorded on 2026-08-09 against the previous scenario set and was left stale when the scenarios were regenerated in #4491, so run_random reported large differences that were purely the scenario mismatch. Regenerated from the current scenarios; the plans are identical with and without this change, so the new reference is equally valid for main. Co-Authored-By: Claude Fable 5 --- apps/predbat/tests/test_window.py | 132 +++++++++-- apps/predbat/utils.py | 116 +++++---- coverage/cases/random_results.json | 362 ++++++++++++++--------------- 3 files changed, 353 insertions(+), 257 deletions(-) diff --git a/apps/predbat/tests/test_window.py b/apps/predbat/tests/test_window.py index 371bd8e3a..7aac0e6e9 100644 --- a/apps/predbat/tests/test_window.py +++ b/apps/predbat/tests/test_window.py @@ -72,6 +72,7 @@ def reference_remove_intersecting_windows(charge_limit_best, charge_window_best, limits = [] for limit, window in zip(charge_limit_best, charge_window_best): segments = [(window["start"], window["end"])] + clipped = False if limit > 0.0: for dlimit, dwindow in zip(export_limit_best, export_window_best): if dlimit >= 100.0: @@ -82,13 +83,21 @@ def reference_remove_intersecting_windows(charge_limit_best, charge_window_best, if dstart >= end or dend < start: new_segments.append((start, end)) continue + pieces = [] if dstart > start: - new_segments.append((start, min(dstart, end))) + pieces.append((start, min(dstart, end))) if dend < end: - new_segments.append((max(dend, start), end)) + pieces.append((max(dend, start), end)) + # Windows that merely touch at a boundary overlap arithmetically but remove + # nothing, and must not count as clipped or the 5 minute rule would discard them + if pieces != [(start, end)]: + clipped = True + new_segments.extend(pieces) segments = new_segments for start, end in segments: - if (end - start) >= 5: + # A window that was never clipped passes through whatever its length; the 5 minute + # minimum only discards the remnants clipping itself created + if not clipped or (end - start) >= 5: windows.append({"start": start, "end": end, "average": window["average"]}) limits.append(limit) return limits, windows @@ -185,27 +194,122 @@ def run_intersect_window_tests(my_predbat): [4, 4], ) - # Randomised equivalence against the naive reference + # Two exports inside one charge window produce three segments - this is the path that used to + # need a second pass over the whole window list + failed |= _intersect_case( + "two splits in one window", + [4], + [{"start": now, "end": now + 120, "average": 10}], + [2, 2], + [{"start": now + 20, "end": now + 40, "average": 10}, {"start": now + 60, "end": now + 80, "average": 10}], + [(now, now + 20), (now + 40, now + 60), (now + 80, now + 120)], + [4, 4, 4], + ) + + # Overlapping export windows collapse into one clipped region + failed |= _intersect_case( + "overlapping exports", + [4], + [{"start": now, "end": now + 120, "average": 10}], + [2, 2], + [{"start": now + 20, "end": now + 60, "average": 10}, {"start": now + 40, "end": now + 80, "average": 10}], + [(now, now + 20), (now + 80, now + 120)], + [4, 4], + ) + + # A head segment shorter than 5 minutes is dropped rather than emitted + failed |= _intersect_case( + "short head segment dropped", + [4], + [{"start": now, "end": now + 60, "average": 10}], + [2], + [{"start": now + 2, "end": now + 30, "average": 10}], + [(now + 30, now + 60)], + [4], + ) + + # A clipped remainder shorter than 5 minutes is dropped + failed |= _intersect_case( + "short remainder dropped", + [4], + [{"start": now, "end": now + 32, "average": 10}], + [2], + [{"start": now, "end": now + 30, "average": 10}], + [], + [], + ) + + # An unclipped window shorter than 5 minutes is still kept + failed |= _intersect_case( + "short unclipped window kept", + [4], + [{"start": now, "end": now + 2, "average": 10}], + [2], + [{"start": now + 60, "end": now + 90, "average": 10}], + [(now, now + 2)], + [4], + ) + + # Windows that merely touch at the boundary do not clip + failed |= _intersect_case( + "touching boundaries do not clip", + [4], + [{"start": now + 30, "end": now + 60, "average": 10}], + [2], + [{"start": now, "end": now + 30, "average": 10}], + [(now + 30, now + 60)], + [4], + ) + + # Export windows presented out of order must give the same answer as sorted ones + failed |= _intersect_case( + "unsorted export windows", + [4], + [{"start": now, "end": now + 120, "average": 10}], + [2, 2], + [{"start": now + 60, "end": now + 80, "average": 10}, {"start": now + 20, "end": now + 40, "average": 10}], + [(now, now + 20), (now + 40, now + 60), (now + 80, now + 120)], + [4, 4, 4], + ) + + # Several charge windows, only some intersecting - the others must pass through untouched + failed |= _intersect_case( + "mixed windows", + [4, 0, 8], + [{"start": now, "end": now + 60, "average": 10}, {"start": now + 60, "end": now + 120, "average": 10}, {"start": now + 120, "end": now + 180, "average": 10}], + [2], + [{"start": now + 30, "end": now + 90, "average": 10}], + [(now, now + 30), (now + 60, now + 120), (now + 120, now + 180)], + [4, 0, 8], + ) + + # Randomised equivalence against the naive reference. Generates short (sub-5-minute) windows, + # zero-length gaps and overlapping export windows, since those drive the segment-length rules and + # the clipping order. Export windows are sorted, which is the invariant callers provide and which + # the single-pass clipping relies on. rng = random.Random(1234) - for case in range(200): - n_charge = rng.randint(0, 6) - n_export = rng.randint(0, 6) + for case in range(1000): + n_charge = rng.randint(0, 8) + n_export = rng.randint(0, 8) charge_windows = [] charge_limits = [] minute = now for _ in range(n_charge): - length = rng.choice([5, 10, 30, 60, 90]) + length = rng.choice([1, 2, 5, 5, 10, 30, 60, 90, 120]) charge_windows.append({"start": minute, "end": minute + length, "average": 10}) - charge_limits.append(rng.choice([0, 0, 4.0, 8.0])) - minute += length + rng.choice([0, 5, 30]) + charge_limits.append(rng.choice([0, 0, 0.0, 2.0, 4.0, 8.0])) + minute += length + rng.choice([0, 0, 1, 5, 30]) export_windows = [] export_limits = [] - minute = now + minute = now + rng.choice([0, 5, 10]) for _ in range(n_export): - length = rng.choice([5, 10, 30, 60]) + length = rng.choice([1, 2, 5, 10, 30, 60]) export_windows.append({"start": minute, "end": minute + length, "average": 10}) - export_limits.append(rng.choice([100.0, 100.0, 99.0, 0.0, 50.0])) - minute += length + rng.choice([0, 5, 30]) + export_limits.append(rng.choice([100.0, 100.0, 99.0, 0.0, 50.0, 4.0])) + minute += length + rng.choice([-5, 0, 0, 5, 30]) + order = sorted(range(len(export_windows)), key=lambda i: export_windows[i]["start"]) + export_windows = [export_windows[i] for i in order] + export_limits = [export_limits[i] for i in order] got_limits, got_windows = remove_intersecting_windows([x for x in charge_limits], [dict(w) for w in charge_windows], export_limits, export_windows) exp_limits, exp_windows = reference_remove_intersecting_windows(charge_limits, charge_windows, export_limits, export_windows) diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 90ea7a78a..e85db3eef 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -993,80 +993,72 @@ def remove_intersecting_windows(charge_limit_best, charge_window_best, export_li """ Filters and removes intersecting charge windows - This runs on every simulation (see Prediction.run_prediction and run_prediction_kernel), so the - scan is restricted to the pairs that can actually clip: only export windows that are enabled - (limit < 100) can clip anything, and only charge windows that are enabled (limit > 0) can be - clipped. Both were previously tested inside the inner loop, so a plan carrying hundreds of - disabled windows - the normal case during optimisation - scanned every pair to do nothing. The - clipping behaviour itself is unchanged; see run_intersect_window_tests, which compares this - against a naive reference implementation over randomised window layouts. + This runs on every simulation (see Prediction.run_prediction and run_prediction_kernel), so it + sits in front of the C++ kernel on the hot path and only does the work that can change something: + + - only export windows that are enabled (limit < 100) can clip anything, so they are collected + once and the function returns immediately when there are none + - only charge windows that are enabled (limit > 0) can be clipped, so a disabled one + short-circuits instead of being scanned against every export window + + Clipping is a single pass. Export windows are processed in start order, so when a charge window + is split the head segment it emits ends at the current export window's start and no later export + window can reach back into it - which is what the previous "clip again" pass over the whole + window list existed to catch. The sort is kept even though callers already provide sorted + windows, so correctness does not depend on that. + + See run_intersect_window_tests, which pins this behaviour with hand-written cases and compares + the result against a naive reference implementation over randomised window layouts. """ # Enabled export windows only - the sole candidates for clipping anything - 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] + export_active = sorted((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) if not export_active: # Rebuild the windows rather than passing the caller's dicts back, so the returned windows # carry exactly the same keys (and are as freshly owned) as on the clipping path below return list(charge_limit_best), [{"start": w["start"], "end": w["end"], "average": w["average"]} for w in charge_window_best] - clip_again = True + new_limit_best = [] + new_window_best = [] # For each charge window - while clip_again: - clip_again = False - new_limit_best = [] - new_window_best = [] - for window_n in range(len(charge_limit_best)): - window = charge_window_best[window_n] - start = window["start"] - end = window["end"] - average = window["average"] - limit = charge_limit_best[window_n] - clipped = False - - if limit <= 0.0: - # A disabled charge window can never be clipped; rebuild it exactly as the clipping - # path below would have done, so the returned dicts are equivalent either way - new_window_best.append({"start": start, "end": end, "average": average}) - new_limit_best.append(limit) - continue + for window_n in range(len(charge_limit_best)): + window = charge_window_best[window_n] + start = window["start"] + end = window["end"] + average = window["average"] + limit = charge_limit_best[window_n] + clipped = False + + if limit <= 0.0: + # A disabled charge window can never be clipped; rebuild it exactly as the clipping + # path below would have done, so the returned dicts are equivalent either way + new_window_best.append({"start": start, "end": end, "average": average}) + new_limit_best.append(limit) + continue - # For each enabled discharge window - for dstart, dend in export_active: - # Overlapping window? - if (dstart < end) and (dend >= start): - if dstart <= start: - if start != dend: - start = dend - clipped = True - elif dend >= end: - if end != dstart: - end = dstart - clipped = True - else: - # Two segments - if (dstart - start) >= 5: - new_window = {} - new_window["start"] = start - new_window["end"] = dstart - new_window["average"] = average - new_window_best.append(new_window) - new_limit_best.append(limit) + # For each enabled discharge window, in start order + for dstart, dend in export_active: + # Overlapping window? + if (dstart < end) and (dend >= start): + if dstart <= start: + if start != dend: start = dend clipped = True - if (end - start) >= 5: - clip_again = True - - if not clipped or ((end - start) >= 5): - new_window = {} - new_window["start"] = start - new_window["end"] = end - new_window["average"] = average - new_window_best.append(new_window) - new_limit_best.append(limit) - - if clip_again: - charge_window_best = new_window_best.copy() - charge_limit_best = new_limit_best.copy() + elif dend >= end: + if end != dstart: + end = dstart + clipped = True + else: + # Two segments - emit the head now, carry on clipping the tail + if (dstart - start) >= 5: + new_window_best.append({"start": start, "end": dstart, "average": average}) + new_limit_best.append(limit) + start = dend + clipped = True + + if not clipped or ((end - start) >= 5): + new_window_best.append({"start": start, "end": end, "average": average}) + new_limit_best.append(limit) return new_limit_best, new_window_best diff --git a/coverage/cases/random_results.json b/coverage/cases/random_results.json index 680e61726..13c0bebe8 100644 --- a/coverage/cases/random_results.json +++ b/coverage/cases/random_results.json @@ -2,22 +2,22 @@ "run_info": { "template_yaml": "cases/predbat_debug_agile1.yaml", "scenarios_file": "cases/random_scenarios.yaml", - "timestamp": "2026-08-09T18:22:33.967760+00:00" + "timestamp": "2026-08-13T08:31:36.598732+00:00" }, "results": [ { "id": 0, "seed": 0, - "metric": 592.1414, - "cost": 675.9979, - "import_kwh_battery": 16.3303, - "import_kwh_house": 0.0298, - "export_kwh": 11.4672, - "soc_min": 0.8066, - "soc_final": 9.5, - "battery_cycles": 21.3845, - "carbon_g": 13451.58, - "runtime_s": 0.532, + "metric": 576.3461, + "cost": 686.033, + "import_kwh_battery": 9.2998, + "import_kwh_house": 0.0635, + "export_kwh": 7.8364, + "soc_min": 0.38, + "soc_final": 6.8616, + "battery_cycles": 21.0198, + "carbon_g": 12959.33, + "runtime_s": 4.583, "failed": false, "error": null, "end_record": 1440 @@ -25,16 +25,16 @@ { "id": 1, "seed": 1, - "metric": 525.7475, - "cost": 513.0503, - "import_kwh_battery": 5.0795, - "import_kwh_house": 0.0054, - "export_kwh": 30.3442, - "soc_min": 0.4031, - "soc_final": 0.637, - "battery_cycles": 18.2722, - "carbon_g": 2551.96, - "runtime_s": 1.912, + "metric": 601.8769, + "cost": 643.4444, + "import_kwh_battery": 0.2946, + "import_kwh_house": 0.0493, + "export_kwh": 24.5842, + "soc_min": 0.38, + "soc_final": 4.8, + "battery_cycles": 10.5692, + "carbon_g": 4609.57, + "runtime_s": 3.537, "failed": false, "error": null, "end_record": 1440 @@ -42,16 +42,16 @@ { "id": 2, "seed": 2, - "metric": 447.0206, - "cost": 464.1716, - "import_kwh_battery": 2.6083, - "import_kwh_house": 0.4961, - "export_kwh": 12.3478, - "soc_min": 0.266, - "soc_final": 0.38, - "battery_cycles": 7.0761, - "carbon_g": 8971.43, - "runtime_s": 1.903, + "metric": 548.3406, + "cost": 610.7432, + "import_kwh_battery": 0.0, + "import_kwh_house": 0.0271, + "export_kwh": 9.61, + "soc_min": 0.3723, + "soc_final": 2.2382, + "battery_cycles": 5.8265, + "carbon_g": 9017.53, + "runtime_s": 3.137, "failed": false, "error": null, "end_record": 1440 @@ -59,16 +59,16 @@ { "id": 3, "seed": 3, - "metric": 657.4629, - "cost": 709.3799, - "import_kwh_battery": 5.028, - "import_kwh_house": 0.03, - "export_kwh": 7.9539, - "soc_min": 0.38, - "soc_final": 3.4029, - "battery_cycles": 11.4364, - "carbon_g": 11256.77, - "runtime_s": 0.388, + "metric": 610.0153, + "cost": 645.3918, + "import_kwh_battery": 0.0, + "import_kwh_house": 0.0, + "export_kwh": 8.3817, + "soc_min": 0.6554, + "soc_final": 2.0559, + "battery_cycles": 9.7908, + "carbon_g": 9673.45, + "runtime_s": 0.22, "failed": false, "error": null, "end_record": 1440 @@ -76,16 +76,16 @@ { "id": 4, "seed": 4, - "metric": 807.0631, - "cost": 930.2553, - "import_kwh_battery": 9.2907, - "import_kwh_house": 1.7369, - "export_kwh": 0.4596, + "metric": 891.3254, + "cost": 1013.0651, + "import_kwh_battery": 0.7446, + "import_kwh_house": 8.1608, + "export_kwh": 0.4742, "soc_min": 0.38, "soc_final": 4.8, - "battery_cycles": 11.9705, - "carbon_g": 16185.87, - "runtime_s": 0.964, + "battery_cycles": 7.3257, + "carbon_g": 15944.91, + "runtime_s": 0.446, "failed": false, "error": null, "end_record": 1440 @@ -93,16 +93,16 @@ { "id": 5, "seed": 5, - "metric": 363.6515, - "cost": 418.4037, - "import_kwh_battery": 17.1136, - "import_kwh_house": 0.1686, - "export_kwh": 38.2601, + "metric": 329.3409, + "cost": 321.974, + "import_kwh_battery": 10.2525, + "import_kwh_house": 1.0116, + "export_kwh": 41.3924, "soc_min": 0.38, - "soc_final": 8.3505, - "battery_cycles": 25.3073, - "carbon_g": 3624.35, - "runtime_s": 0.475, + "soc_final": 2.3066, + "battery_cycles": 34.749, + "carbon_g": 884.71, + "runtime_s": 0.435, "failed": false, "error": null, "end_record": 1440 @@ -110,16 +110,16 @@ { "id": 6, "seed": 6, - "metric": 342.5312, - "cost": 263.0798, - "import_kwh_battery": 10.0741, - "import_kwh_house": 3.4149, - "export_kwh": 23.9588, + "metric": 498.4142, + "cost": 478.7357, + "import_kwh_battery": 9.8907, + "import_kwh_house": 0.5393, + "export_kwh": 22.4051, "soc_min": 0.38, "soc_final": 0.38, - "battery_cycles": 39.1222, - "carbon_g": 8191.07, - "runtime_s": 3.249, + "battery_cycles": 38.4449, + "carbon_g": 8065.01, + "runtime_s": 25.266, "failed": false, "error": null, "end_record": 1440 @@ -127,16 +127,16 @@ { "id": 7, "seed": 7, - "metric": 1177.6668, - "cost": 1183.3559, - "import_kwh_battery": 0.0, - "import_kwh_house": 14.3575, + "metric": 998.0546, + "cost": 996.4757, + "import_kwh_battery": 14.0493, + "import_kwh_house": 0.6067, "export_kwh": 0.0, "soc_min": 0.38, "soc_final": 0.38, - "battery_cycles": 0.9215, - "carbon_g": 17836.83, - "runtime_s": 0.462, + "battery_cycles": 14.2039, + "carbon_g": 18350.09, + "runtime_s": 5.202, "failed": false, "error": null, "end_record": 1440 @@ -144,16 +144,16 @@ { "id": 8, "seed": 8, - "metric": 984.0332, - "cost": 976.0045, + "metric": 945.3878, + "cost": 931.5713, "import_kwh_battery": 0.0, - "import_kwh_house": 10.4647, - "export_kwh": 7.1858, + "import_kwh_house": 8.9365, + "export_kwh": 7.4282, "soc_min": 0.38, "soc_final": 0.38, - "battery_cycles": 6.9101, - "carbon_g": 13187.23, - "runtime_s": 0.6, + "battery_cycles": 10.6638, + "carbon_g": 12867.91, + "runtime_s": 0.462, "failed": false, "error": null, "end_record": 1440 @@ -161,16 +161,16 @@ { "id": 9, "seed": 9, - "metric": 117.2071, - "cost": 233.0853, - "import_kwh_battery": 13.0071, - "import_kwh_house": 0.0, - "export_kwh": 33.3159, - "soc_min": 0.3893, - "soc_final": 7.3332, - "battery_cycles": 26.1783, - "carbon_g": 3997.87, - "runtime_s": 0.438, + "metric": 111.1819, + "cost": 261.4367, + "import_kwh_battery": 13.0372, + "import_kwh_house": 0.0301, + "export_kwh": 31.8507, + "soc_min": 0.38, + "soc_final": 9.152, + "battery_cycles": 25.215, + "carbon_g": 4663.11, + "runtime_s": 0.598, "failed": false, "error": null, "end_record": 1440 @@ -178,16 +178,16 @@ { "id": 10, "seed": 10, - "metric": 359.0812, - "cost": 324.2384, - "import_kwh_battery": 10.0691, - "import_kwh_house": 0.0, - "export_kwh": 25.9194, - "soc_min": 0.3816, - "soc_final": 0.3816, - "battery_cycles": 33.9378, - "carbon_g": 6467.63, - "runtime_s": 2.986, + "metric": 502.4424, + "cost": 493.1507, + "import_kwh_battery": 8.4304, + "import_kwh_house": 2.0718, + "export_kwh": 26.2002, + "soc_min": 0.38, + "soc_final": 1.0713, + "battery_cycles": 25.7368, + "carbon_g": 7751.35, + "runtime_s": 2.426, "failed": false, "error": null, "end_record": 1440 @@ -195,16 +195,16 @@ { "id": 11, "seed": 11, - "metric": 726.8783, - "cost": 741.0181, - "import_kwh_battery": 11.9932, - "import_kwh_house": 0.0278, - "export_kwh": 9.8501, + "metric": 805.2294, + "cost": 818.8538, + "import_kwh_battery": 0.0, + "import_kwh_house": 1.624, + "export_kwh": 0.0, "soc_min": 0.38, "soc_final": 0.38, - "battery_cycles": 16.9138, - "carbon_g": 13478.98, - "runtime_s": 1.245, + "battery_cycles": 6.174, + "carbon_g": 13156.97, + "runtime_s": 1.208, "failed": false, "error": null, "end_record": 1440 @@ -212,16 +212,16 @@ { "id": 12, "seed": 12, - "metric": 655.6314, - "cost": 740.7557, - "import_kwh_battery": 27.8705, - "import_kwh_house": 6.9489, - "export_kwh": 20.6943, + "metric": 758.8372, + "cost": 750.5256, + "import_kwh_battery": 12.3986, + "import_kwh_house": 7.5495, + "export_kwh": 12.1614, "soc_min": 0.38, - "soc_final": 5.1469, - "battery_cycles": 50.8062, - "carbon_g": 17337.46, - "runtime_s": 2.338, + "soc_final": 0.38, + "battery_cycles": 28.7593, + "carbon_g": 15144.61, + "runtime_s": 3.737, "failed": false, "error": null, "end_record": 1440 @@ -229,16 +229,16 @@ { "id": 13, "seed": 13, - "metric": 858.8054, - "cost": 812.4557, + "metric": 778.1413, + "cost": 903.2063, "import_kwh_battery": 0.0, - "import_kwh_house": 6.9481, - "export_kwh": 11.1695, + "import_kwh_house": 9.1378, + "export_kwh": 10.3753, "soc_min": 0.38, - "soc_final": 0.38, - "battery_cycles": 10.9678, - "carbon_g": 10462.19, - "runtime_s": 1.674, + "soc_final": 6.2665, + "battery_cycles": 9.68, + "carbon_g": 12838.7, + "runtime_s": 0.538, "failed": false, "error": null, "end_record": 1440 @@ -246,16 +246,16 @@ { "id": 14, "seed": 14, - "metric": 910.0124, - "cost": 956.7149, - "import_kwh_battery": 12.2904, - "import_kwh_house": 3.043, - "export_kwh": 0.8904, - "soc_min": 0.38, - "soc_final": 2.4, - "battery_cycles": 7.519, - "carbon_g": 17378.97, - "runtime_s": 0.868, + "metric": 903.095, + "cost": 933.5963, + "import_kwh_battery": 14.8812, + "import_kwh_house": 0.0, + "export_kwh": 2.2733, + "soc_min": 0.4301, + "soc_final": 1.5389, + "battery_cycles": 5.6825, + "carbon_g": 16611.75, + "runtime_s": 0.504, "failed": false, "error": null, "end_record": 1440 @@ -263,16 +263,16 @@ { "id": 15, "seed": 15, - "metric": 799.7785, - "cost": 795.6586, - "import_kwh_battery": 21.219, - "import_kwh_house": 0.9251, - "export_kwh": 11.2256, - "soc_min": 0.2978, - "soc_final": 1.9303, - "battery_cycles": 31.8178, - "carbon_g": 16532.2, - "runtime_s": 2.566, + "metric": 807.431, + "cost": 880.907, + "import_kwh_battery": 12.7199, + "import_kwh_house": 3.6126, + "export_kwh": 5.8263, + "soc_min": 0.048, + "soc_final": 4.3726, + "battery_cycles": 19.543, + "carbon_g": 16613.71, + "runtime_s": 4.959, "failed": false, "error": null, "end_record": 1440 @@ -280,16 +280,16 @@ { "id": 16, "seed": 16, - "metric": 1101.5746, - "cost": 1084.9635, + "metric": 1056.5398, + "cost": 1041.3293, "import_kwh_battery": 0.0, - "import_kwh_house": 9.8333, + "import_kwh_house": 8.4629, "export_kwh": 0.0, "soc_min": 0.38, "soc_final": 0.38, - "battery_cycles": 4.1652, - "carbon_g": 15925.89, - "runtime_s": 0.502, + "battery_cycles": 4.9586, + "carbon_g": 15848.43, + "runtime_s": 0.32, "failed": false, "error": null, "end_record": 1440 @@ -297,16 +297,16 @@ { "id": 17, "seed": 17, - "metric": 228.3891, - "cost": 191.5137, - "import_kwh_battery": 6.1902, - "import_kwh_house": 0.0, - "export_kwh": 36.2329, - "soc_min": 0.6519, - "soc_final": 0.6896, - "battery_cycles": 30.6878, - "carbon_g": 821.48, - "runtime_s": 2.333, + "metric": 294.1232, + "cost": 250.3757, + "import_kwh_battery": 2.4861, + "import_kwh_house": 0.4872, + "export_kwh": 35.8662, + "soc_min": 0.38, + "soc_final": 0.38, + "battery_cycles": 25.8549, + "carbon_g": 153.34, + "runtime_s": 5.528, "failed": false, "error": null, "end_record": 1440 @@ -314,16 +314,16 @@ { "id": 18, "seed": 18, - "metric": 691.4178, - "cost": 723.029, - "import_kwh_battery": 7.8375, - "import_kwh_house": 0.0, - "export_kwh": 9.0615, - "soc_min": 0.4249, - "soc_final": 4.5266, - "battery_cycles": 13.2257, - "carbon_g": 11673.86, - "runtime_s": 0.374, + "metric": 692.2002, + "cost": 694.0709, + "import_kwh_battery": 5.2475, + "import_kwh_house": 4.1962, + "export_kwh": 15.108, + "soc_min": 0.38, + "soc_final": 0.5251, + "battery_cycles": 9.8445, + "carbon_g": 11504.26, + "runtime_s": 0.219, "failed": false, "error": null, "end_record": 1440 @@ -331,19 +331,19 @@ { "id": 19, "seed": 19, - "metric": 685.9778, - "cost": 681.0472, - "import_kwh_battery": 14.2917, - "import_kwh_house": 0.0, - "export_kwh": 16.886, + "metric": -288.7326, + "cost": -2.5999, + "import_kwh_battery": 43.4427, + "import_kwh_house": 0.0357, + "export_kwh": 24.5863, "soc_min": 0.38, - "soc_final": 0.6268, - "battery_cycles": 27.113, - "carbon_g": 11478.32, - "runtime_s": 0.462, + "soc_final": 20.0, + "battery_cycles": 57.1834, + "carbon_g": 18756.69, + "runtime_s": 22.708, "failed": false, "error": null, "end_record": 1440 } ] -} +} \ No newline at end of file From 30c04828db8b881de687cc10a2b7f256b679e24d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:38:42 +0000 Subject: [PATCH 3/3] [pre-commit.ci lite] apply automatic fixes --- coverage/cases/random_results.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coverage/cases/random_results.json b/coverage/cases/random_results.json index 13c0bebe8..e653d620d 100644 --- a/coverage/cases/random_results.json +++ b/coverage/cases/random_results.json @@ -346,4 +346,4 @@ "end_record": 1440 } ] -} \ No newline at end of file +}