Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/predbat/prediction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions apps/predbat/prediction_kernel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/prediction_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified apps/predbat/prediction_kernel_lib_aarch64.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_armv7l.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_darwin_arm64.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_darwin_x86_64.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_i686.so
Binary file not shown.
Binary file modified apps/predbat/prediction_kernel_lib_x86_64.so
Binary file not shown.
83 changes: 83 additions & 0 deletions apps/predbat/tests/test_multi_car_load.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
72 changes: 36 additions & 36 deletions coverage/cases/random_results.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -95,26 +95,26 @@
"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
},
{
"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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -266,26 +266,26 @@
"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
},
{
"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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading