diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 5aa3946cd..9cda2927b 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -889,8 +889,9 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if self.car_energy_reported_load: # Only add load if the car is reporting it as load, otherwise its outside the CT Clamp + # Each car adds its own energy; car_amount_premium is the running total across cars car_amount_premium += car_load_scale / self.car_charging_loss - load_yesterday += car_amount_premium + load_yesterday += car_load_scale / self.car_charging_loss else: car_load_energy_bypass += car_load_scale / self.car_charging_loss diff --git a/apps/predbat/prediction_kernel.cpp b/apps/predbat/prediction_kernel.cpp index 171bbe354..54e040fd3 100644 --- a/apps/predbat/prediction_kernel.cpp +++ b/apps/predbat/prediction_kernel.cpp @@ -57,7 +57,9 @@ // Parity 15: in PV10, an Intelligent dispatch minute assumed gone (io_flag, beyond 30 minutes) no longer // holds the battery for the car. The car still charges at the rate nominal pays, kept off the battery // by adding it to the grid balance after the battery has acted; the rest of the import pays rate_max. -#define PK_PARITY_REVISION 15 +// Parity 16: each car charging in a step adds its own energy to the house load, not the running total +// across cars, which counted the first car once per car (GH#5313). +#define PK_PARITY_REVISION 16 #define PK_MAX_CARS 8 #define PK_RUN_EVERY 5 // const.py RUN_EVERY #define PK_EXPORT_MODE_TARGET 0 // const.py EXPORT_MODE_TARGET @@ -899,9 +901,9 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu car_rate_premium = std::max(car_rate_premium, std::max(0.0, c->car_rate_flat[car_n * n_steps + k] - import_rate)); if (c->car_energy_reported_load) { - // Note: mirrors the Python engine exactly - the cumulative premium amount is added per car + // Each car adds its own energy; car_amount_premium is the running total across cars car_amount_premium += car_load_scale / c->car_charging_loss; - load_yesterday += car_amount_premium; + load_yesterday += car_load_scale / c->car_charging_loss; } else { car_load_energy_bypass += car_load_scale / c->car_charging_loss; } diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index 525b99cbb..0c74ec08d 100644 --- a/apps/predbat/prediction_kernel.py +++ b/apps/predbat/prediction_kernel.py @@ -33,7 +33,7 @@ # Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp) KERNEL_ABI_VERSION = 7 -KERNEL_PARITY_REVISION = 15 +KERNEL_PARITY_REVISION = 16 # Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp) KERNEL_MAX_CARS = PREDBAT_MAX_CARS diff --git a/apps/predbat/prediction_kernel_lib_aarch64.so b/apps/predbat/prediction_kernel_lib_aarch64.so index 15c491de8..a4b4bc47d 100755 Binary files a/apps/predbat/prediction_kernel_lib_aarch64.so and b/apps/predbat/prediction_kernel_lib_aarch64.so differ diff --git a/apps/predbat/prediction_kernel_lib_armv7l.so b/apps/predbat/prediction_kernel_lib_armv7l.so index a1456b143..82cbb848b 100755 Binary files a/apps/predbat/prediction_kernel_lib_armv7l.so and b/apps/predbat/prediction_kernel_lib_armv7l.so differ diff --git a/apps/predbat/prediction_kernel_lib_darwin_arm64.so b/apps/predbat/prediction_kernel_lib_darwin_arm64.so index 4b1b64d73..6d0ec174f 100755 Binary files a/apps/predbat/prediction_kernel_lib_darwin_arm64.so and b/apps/predbat/prediction_kernel_lib_darwin_arm64.so differ diff --git a/apps/predbat/prediction_kernel_lib_darwin_x86_64.so b/apps/predbat/prediction_kernel_lib_darwin_x86_64.so index 8f32433be..916059db8 100755 Binary files a/apps/predbat/prediction_kernel_lib_darwin_x86_64.so and b/apps/predbat/prediction_kernel_lib_darwin_x86_64.so differ diff --git a/apps/predbat/prediction_kernel_lib_i686.so b/apps/predbat/prediction_kernel_lib_i686.so index a75253bac..5eb43445f 100755 Binary files a/apps/predbat/prediction_kernel_lib_i686.so and b/apps/predbat/prediction_kernel_lib_i686.so differ diff --git a/apps/predbat/prediction_kernel_lib_x86_64.so b/apps/predbat/prediction_kernel_lib_x86_64.so index 73ea724a6..71508f77e 100755 Binary files a/apps/predbat/prediction_kernel_lib_x86_64.so and b/apps/predbat/prediction_kernel_lib_x86_64.so differ diff --git a/apps/predbat/tests/test_multi_car_load.py b/apps/predbat/tests/test_multi_car_load.py new file mode 100644 index 000000000..fa2797fbf --- /dev/null +++ b/apps/predbat/tests/test_multi_car_load.py @@ -0,0 +1,83 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +from const import PV_SCENARIO_NOMINAL +from prediction import Prediction +from tests.test_infra import reset_inverter, reset_rates +from tests.test_kernel_parity import kernel_available + +# Each car charges this much over one hour, starting now +CAR_KW = 7.0 +IMPORT_RATE = 10.0 + + +def _run_cars(my_predbat, cars, kernel, loss): + """Charge `cars` cars at CAR_KW for an hour with no PV, no house load and an empty battery; return (grid import kWh, cost, kernel used).""" + reset_inverter(my_predbat) + reset_rates(my_predbat, IMPORT_RATE, 5.0) + my_predbat.prediction_kernel_enable = kernel + my_predbat.soc_kw = 0.0 + my_predbat.reserve = 0.0 + now = my_predbat.minutes_now + my_predbat.num_cars = cars + my_predbat.car_charging_slots = [[{"start": now, "end": now + 60, "kwh": CAR_KW, "average": IMPORT_RATE, "octopus": False}] if car_n < cars else [] for car_n in range(4)] + my_predbat.car_charging_soc = [0.0] * 4 + my_predbat.car_charging_limit = [100.0] * 4 + my_predbat.car_charging_limit_model = None + my_predbat.car_charging_loss = loss + my_predbat.car_energy_reported_load = True + my_predbat.car_charging_from_battery = True + zero = {minute: 0.0 for minute in range(0, my_predbat.forecast_minutes, 5)} + my_predbat.prediction = Prediction(my_predbat, zero, zero, zero, zero) + result = my_predbat.run_prediction([], [], [], [], PV_SCENARIO_NOMINAL, end_record=my_predbat.forecast_minutes) + return result[1] + result[2], result[0], my_predbat.prediction.kernel_handle != 0 + + +def run_multi_car_load_tests(my_predbat): + """ + Cars charging in the same step each add their own energy to the house load once (GH#5313). + + The per-car loop used to add the running total of car energy to the load for every car, so the + first car was counted once per car: two 7 kWh cars modelled 21 kWh, three modelled 42 kWh. + Checked in both engines, and with a charging loss so it is the grid-side energy that is counted. + Only cars inside the CT clamp (car_energy_reported_load) are modelled as house load; a car outside + it goes through car_load_energy_bypass, which already accumulated each car once. + """ + print("\n**** Multiple cars charging at once ****") + failed = 0 + engines = [False] + available, required_failure = kernel_available() + if required_failure: + failed += 1 + if available: + engines.append(True) + + for kernel in engines: + engine = "kernel" if kernel else "python" + for loss in (1.0, 0.9): + for cars in (1, 2, 3): + expected = CAR_KW * cars + imported, cost, used_kernel = _run_cars(my_predbat, cars, kernel, loss) + label = "{} loss={} cars={}".format(engine, loss, cars) + if used_kernel != kernel: + print(" ERROR: {}: expected the {} engine to run".format(label, engine)) + failed += 1 + elif abs(imported - expected) > 0.01 or abs(cost - expected * IMPORT_RATE) > 0.1: + print(" ERROR: {}: modelled {:.2f} kWh import costing {:.2f}p, expected {:.2f} kWh costing {:.2f}p".format(label, imported, cost, expected, expected * IMPORT_RATE)) + failed += 1 + else: + print(" OK: {}: {:.2f} kWh import".format(label, imported)) + + my_predbat.prediction_kernel_enable = False + if failed: + print("\n**** multi_car_load tests: FAILED ({} failures) ****".format(failed)) + else: + print("\n**** multi_car_load tests: PASSED ****") + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index fefb49ceb..f076cb42d 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -237,6 +237,7 @@ from tests.test_rate_add_io_slots import run_rate_add_io_slots_tests from tests.test_iog_charge_skew import run_iog_charge_skew_tests from tests.test_iog_early_slot_flat_battery import run_iog_early_slot_flat_battery_tests +from tests.test_multi_car_load import run_multi_car_load_tests from tests.test_dispatch_timeline import run_dispatch_timeline_tests from tests.test_log_rotation import run_log_rotation_tests from tests.test_battery_curve_keys import run_battery_curve_keys_tests @@ -571,6 +572,7 @@ def main(): ("rate_add_io_slots", run_rate_add_io_slots_tests, "Rate add IO slots tests", False), ("iog_charge_skew", run_iog_charge_skew_tests, "IOG earlier-charge skew characterisation tests", False), ("iog_early_slot_flat_battery", run_iog_early_slot_flat_battery_tests, "IOG bonus dispatch before the night window with a flat battery", False), + ("multi_car_load", run_multi_car_load_tests, "Cars charging at once each add their own energy to the load", False), ("dispatch_timeline", run_dispatch_timeline_tests, "Dispatch timeline diagnostic tests (#4516 Stage 1)", False), ("log_rotation", run_log_rotation_tests, "Configurable log rotation and two-digit naming (#5076)", False), ("rate_replicate", test_rate_replicate, "Rate replicate comprehensive tests (missing slots, IO, offsets, gas)", False), diff --git a/coverage/cases/random_results.json b/coverage/cases/random_results.json index 0024bef34..c8944e63e 100644 --- a/coverage/cases/random_results.json +++ b/coverage/cases/random_results.json @@ -2,7 +2,7 @@ "run_info": { "template_yaml": "cases/predbat_debug_agile1.yaml", "scenarios_file": "cases/random_scenarios.yaml", - "timestamp": "2026-09-29T08:01:31.271058+00:00" + "timestamp": "2026-09-30T07:25:57.325333+00:00" }, "results": [ { @@ -19,7 +19,7 @@ "soc_final": 5.9262, "battery_cycles": 22.5372, "carbon_g": 20398.29, - "runtime_s": 1.981, + "runtime_s": 2.008, "failed": false, "error": null, "end_record": 1440 @@ -38,7 +38,7 @@ "soc_final": 4.762, "battery_cycles": 16.5804, "carbon_g": 20357.15, - "runtime_s": 1.159, + "runtime_s": 1.161, "failed": false, "error": null, "end_record": 1440 @@ -57,7 +57,7 @@ "soc_final": 2.1289, "battery_cycles": 6.2672, "carbon_g": 10983.08, - "runtime_s": 1.03, + "runtime_s": 1.058, "failed": false, "error": null, "end_record": 1440 @@ -76,7 +76,7 @@ "soc_final": 1.2819, "battery_cycles": 11.1137, "carbon_g": 9719.92, - "runtime_s": 0.374, + "runtime_s": 0.343, "failed": false, "error": null, "end_record": 1440 @@ -95,7 +95,7 @@ "soc_final": 4.6176, "battery_cycles": 11.471, "carbon_g": 18921.81, - "runtime_s": 0.314, + "runtime_s": 0.322, "failed": false, "error": null, "end_record": 1440 @@ -103,18 +103,18 @@ { "id": 5, "seed": 5, - "metric": 1630.7577, - "cost": 1700.8368, - "cost_pv10": 1997.116, - "cost_pv90": 1327.0428, + "metric": 1285.3384, + "cost": 1356.2346, + "cost_pv10": 1647.9744, + "cost_pv90": 993.0577, "import_kwh_battery": 10.1977, - "import_kwh_house": 48.028, - "export_kwh": 9.6928, + "import_kwh_house": 33.688, + "export_kwh": 10.113, "soc_min": 0.38, "soc_final": 2.2769, - "battery_cycles": 21.6064, - "carbon_g": 30670.27, - "runtime_s": 0.209, + "battery_cycles": 22.1943, + "carbon_g": 24896.47, + "runtime_s": 0.24, "failed": false, "error": null, "end_record": 1440 @@ -133,7 +133,7 @@ "soc_final": 0.38, "battery_cycles": 35.4755, "carbon_g": 29386.81, - "runtime_s": 3.312, + "runtime_s": 3.335, "failed": false, "error": null, "end_record": 1440 @@ -152,7 +152,7 @@ "soc_final": 0.3949, "battery_cycles": 14.1532, "carbon_g": 22415.34, - "runtime_s": 1.899, + "runtime_s": 1.909, "failed": false, "error": null, "end_record": 1440 @@ -171,7 +171,7 @@ "soc_final": 0.38, "battery_cycles": 8.1686, "carbon_g": 28892.33, - "runtime_s": 0.268, + "runtime_s": 0.258, "failed": false, "error": null, "end_record": 1440 @@ -190,7 +190,7 @@ "soc_final": 7.7805, "battery_cycles": 29.4133, "carbon_g": 11557.84, - "runtime_s": 0.292, + "runtime_s": 0.295, "failed": false, "error": null, "end_record": 1440 @@ -209,7 +209,7 @@ "soc_final": 1.0081, "battery_cycles": 26.4498, "carbon_g": 20579.27, - "runtime_s": 1.077, + "runtime_s": 1.082, "failed": false, "error": null, "end_record": 1440 @@ -228,7 +228,7 @@ "soc_final": 1.5231, "battery_cycles": 5.0652, "carbon_g": 18871.77, - "runtime_s": 0.404, + "runtime_s": 0.414, "failed": false, "error": null, "end_record": 1440 @@ -247,7 +247,7 @@ "soc_final": 0.38, "battery_cycles": 20.7648, "carbon_g": 36713.81, - "runtime_s": 0.796, + "runtime_s": 0.816, "failed": false, "error": null, "end_record": 1440 @@ -266,7 +266,7 @@ "soc_final": 0.38, "battery_cycles": 12.0194, "carbon_g": 37955.25, - "runtime_s": 0.31, + "runtime_s": 0.359, "failed": false, "error": null, "end_record": 1440 @@ -274,18 +274,18 @@ { "id": 14, "seed": 14, - "metric": 2887.1673, - "cost": 2873.2563, - "cost_pv10": 3003.1288, - "cost_pv90": 2251.1648, - "import_kwh_battery": 31.8142, - "import_kwh_house": 66.4699, + "metric": 2376.0241, + "cost": 2362.1131, + "cost_pv10": 2491.9856, + "cost_pv90": 1740.0216, + "import_kwh_battery": 24.6779, + "import_kwh_house": 53.6319, "export_kwh": 0.0009, "soc_min": 0.38, "soc_final": 0.4023, "battery_cycles": 6.7459, - "carbon_g": 48852.41, - "runtime_s": 0.325, + "carbon_g": 41033.45, + "runtime_s": 0.332, "failed": false, "error": null, "end_record": 1440 @@ -304,7 +304,7 @@ "soc_final": 3.7345, "battery_cycles": 10.5722, "carbon_g": 31438.76, - "runtime_s": 0.791, + "runtime_s": 0.796, "failed": false, "error": null, "end_record": 1440 @@ -323,7 +323,7 @@ "soc_final": 0.38, "battery_cycles": 3.7054, "carbon_g": 23055.97, - "runtime_s": 0.173, + "runtime_s": 0.176, "failed": false, "error": null, "end_record": 1440 @@ -342,7 +342,7 @@ "soc_final": 0.38, "battery_cycles": 23.0298, "carbon_g": 22132.39, - "runtime_s": 0.867, + "runtime_s": 0.885, "failed": false, "error": null, "end_record": 1440 @@ -361,7 +361,7 @@ "soc_final": 0.5188, "battery_cycles": 10.6203, "carbon_g": 15987.78, - "runtime_s": 0.269, + "runtime_s": 0.259, "failed": false, "error": null, "end_record": 1440 @@ -380,7 +380,7 @@ "soc_final": 19.9987, "battery_cycles": 66.0439, "carbon_g": 27244.36, - "runtime_s": 3.408, + "runtime_s": 3.402, "failed": false, "error": null, "end_record": 1440