diff --git a/apps/predbat/tests/test_window.py b/apps/predbat/tests/test_window.py index 4cb271e61..7aac0e6e9 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,271 @@ 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"])] + clipped = False + 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 + pieces = [] + if dstart > start: + pieces.append((start, min(dstart, end))) + if dend < 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: + # 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 + + +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], + ) + + # 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(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([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, 0.0, 2.0, 4.0, 8.0])) + minute += length + rng.choice([0, 0, 1, 5, 30]) + export_windows = [] + export_limits = [] + minute = now + rng.choice([0, 5, 10]) + for _ in range(n_export): + 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, 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) + 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..e85db3eef 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -992,64 +992,73 @@ 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 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. """ - clip_again = True + # Enabled export windows only - the sole candidates for clipping anything + 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] + + 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 - - # 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"] - - # Overlapping window with enabled discharge? - if (limit > 0.0) and (dlimit < 100.0) and (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 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, 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..e653d620d 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,16 +331,16 @@ { "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