Skip to content

Commit 82b599f

Browse files
feat(car): raise the maximum supported cars from 4 to 8
Some users have more than 4 EVs/chargers registered (e.g. multiple Octopus Intelligent devices), which the previous 4-car ceiling (CONFIG_ITEMS entities, KERNEL_MAX_CARS) rejected outright by clamping num_cars down. Doubling the limit to 8 needs three things kept in lockstep: the shared PREDBAT_MAX_CARS constant, the C++ kernel's PK_MAX_CARS array size (with PK_PARITY_REVISION/ KERNEL_PARITY_REVISION bumped 5 -> 6, on top of the ABI 4 already on main, so a stale 4-car binary is safely rejected rather than corrupting the ctypes struct layout), and the matching ctypes.Structure array sizes on the Python side (previously hardcoded to 4 independently of KERNEL_MAX_CARS - this mismatch caused a segfault before the fix). - const.py: PREDBAT_MAX_CARS 4 -> 8 - prediction_kernel.cpp / .py: PK_MAX_CARS/KERNEL_MAX_CARS -> 8, parity revision bumped 5 -> 6, ctypes array fields now reference KERNEL_MAX_CARS instead of a hardcoded 4 (car_charging_limit, car_charging_soc, car_soc_next) - config.py: added car_charging_rate_4..7 and car_charging_manual_soc(_kwh)_4..7 CONFIG_ITEMS entries so cars 4-7 have HA entities to configure - Regression tests for the new ceiling: kernel parity, 8-car config resolution via the real config_index, MockConfigProvider car rate defaults, and reset_inverter's car_charging_limit/soc arrays sized from PREDBAT_MAX_CARS instead of a hardcoded 4 (Copilot review catch) Kernel binaries are intentionally not included - CI's kernel-binaries job cross-builds and auto-commits all 6 platform binaries once this merges. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 87cb016 commit 82b599f

7 files changed

Lines changed: 178 additions & 16 deletions

File tree

‎apps/predbat/config.py‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,58 @@
322322
"enable": "num_cars",
323323
"enable_condition": "num_cars > 3",
324324
},
325+
{
326+
"name": "car_charging_rate_4",
327+
"friendly_name": "Car charging rate (Car 4)",
328+
"type": "input_number",
329+
"min": 1,
330+
"max": 24,
331+
"step": 0.10,
332+
"unit": "kW",
333+
"icon": "mdi:ev-station",
334+
"default": 7.4,
335+
"enable": "num_cars",
336+
"enable_condition": "num_cars > 4",
337+
},
338+
{
339+
"name": "car_charging_rate_5",
340+
"friendly_name": "Car charging rate (Car 5)",
341+
"type": "input_number",
342+
"min": 1,
343+
"max": 24,
344+
"step": 0.10,
345+
"unit": "kW",
346+
"icon": "mdi:ev-station",
347+
"default": 7.4,
348+
"enable": "num_cars",
349+
"enable_condition": "num_cars > 5",
350+
},
351+
{
352+
"name": "car_charging_rate_6",
353+
"friendly_name": "Car charging rate (Car 6)",
354+
"type": "input_number",
355+
"min": 1,
356+
"max": 24,
357+
"step": 0.10,
358+
"unit": "kW",
359+
"icon": "mdi:ev-station",
360+
"default": 7.4,
361+
"enable": "num_cars",
362+
"enable_condition": "num_cars > 6",
363+
},
364+
{
365+
"name": "car_charging_rate_7",
366+
"friendly_name": "Car charging rate (Car 7)",
367+
"type": "input_number",
368+
"min": 1,
369+
"max": 24,
370+
"step": 0.10,
371+
"unit": "kW",
372+
"icon": "mdi:ev-station",
373+
"default": 7.4,
374+
"enable": "num_cars",
375+
"enable_condition": "num_cars > 7",
376+
},
325377
{
326378
"name": "car_charging_loss",
327379
"friendly_name": "Car charging loss",
@@ -741,6 +793,90 @@
741793
"default": 0.0,
742794
"restore": False,
743795
},
796+
{
797+
"name": "car_charging_manual_soc_4",
798+
"friendly_name": "Car charging manual SOC (Car 4)",
799+
"type": "switch",
800+
"default": False,
801+
"enable": "num_cars",
802+
"enable_condition": "num_cars > 4",
803+
},
804+
{
805+
"name": "car_charging_manual_soc_kwh_4",
806+
"friendly_name": "Car manual SOC kWh (Car 4)",
807+
"type": "input_number",
808+
"min": 0,
809+
"max": 100,
810+
"step": 0.01,
811+
"unit": "kWh",
812+
"icon": "mdi:ev-station",
813+
"enable": "car_charging_manual_soc_4",
814+
"default": 0.0,
815+
"restore": False,
816+
},
817+
{
818+
"name": "car_charging_manual_soc_5",
819+
"friendly_name": "Car charging manual SOC (Car 5)",
820+
"type": "switch",
821+
"default": False,
822+
"enable": "num_cars",
823+
"enable_condition": "num_cars > 5",
824+
},
825+
{
826+
"name": "car_charging_manual_soc_kwh_5",
827+
"friendly_name": "Car manual SOC kWh (Car 5)",
828+
"type": "input_number",
829+
"min": 0,
830+
"max": 100,
831+
"step": 0.01,
832+
"unit": "kWh",
833+
"icon": "mdi:ev-station",
834+
"enable": "car_charging_manual_soc_5",
835+
"default": 0.0,
836+
"restore": False,
837+
},
838+
{
839+
"name": "car_charging_manual_soc_6",
840+
"friendly_name": "Car charging manual SOC (Car 6)",
841+
"type": "switch",
842+
"default": False,
843+
"enable": "num_cars",
844+
"enable_condition": "num_cars > 6",
845+
},
846+
{
847+
"name": "car_charging_manual_soc_kwh_6",
848+
"friendly_name": "Car manual SOC kWh (Car 6)",
849+
"type": "input_number",
850+
"min": 0,
851+
"max": 100,
852+
"step": 0.01,
853+
"unit": "kWh",
854+
"icon": "mdi:ev-station",
855+
"enable": "car_charging_manual_soc_6",
856+
"default": 0.0,
857+
"restore": False,
858+
},
859+
{
860+
"name": "car_charging_manual_soc_7",
861+
"friendly_name": "Car charging manual SOC (Car 7)",
862+
"type": "switch",
863+
"default": False,
864+
"enable": "num_cars",
865+
"enable_condition": "num_cars > 7",
866+
},
867+
{
868+
"name": "car_charging_manual_soc_kwh_7",
869+
"friendly_name": "Car manual SOC kWh (Car 7)",
870+
"type": "input_number",
871+
"min": 0,
872+
"max": 100,
873+
"step": 0.01,
874+
"unit": "kWh",
875+
"icon": "mdi:ev-station",
876+
"enable": "car_charging_manual_soc_7",
877+
"default": 0.0,
878+
"restore": False,
879+
},
744880
{
745881
"name": "octopus_intelligent_charging",
746882
"friendly_name": "Octopus Intelligent Charging",

‎apps/predbat/const.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
INVERTER_MAX_RETRY_REST = 5 # Maximum number of retries for inverter REST commands
4141
INVERTER_REST_TIMEOUT = 10 # Seconds to wait for a REST response before giving up (local network call, should be fast)
4242
INVERTER_QUICK_UPDATE_SECONDS = 120 # Minimum seconds between quick inverter data updates
43-
PREDBAT_MAX_CARS = 4 # Matches PK_MAX_CARS in prediction_kernel.cpp and the car_charging_rate_0..3 config items - the hard ceiling on num_cars
43+
PREDBAT_MAX_CARS = 8 # Matches PK_MAX_CARS in prediction_kernel.cpp and the car_charging_rate/_1../_7 config items - the hard ceiling on num_cars
4444

4545
# 240v x 100 amps x 3 phases / 1000 to kW / 60 minutes in an hour is the maximum kWh in a 1 minute period
4646
MAX_INCREMENT = 240 * 100 * 3 / 1000 / 60

‎apps/predbat/prediction_kernel.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
// falling back. Bumping makes the loader reject it and use the Python engine, which is the whole
4444
// point of the check.
4545
#define PK_ABI_VERSION 4
46-
#define PK_PARITY_REVISION 5
47-
#define PK_MAX_CARS 4
46+
#define PK_PARITY_REVISION 6
47+
#define PK_MAX_CARS 8
4848
#define PK_RUN_EVERY 5 // const.py RUN_EVERY
4949

5050
namespace {

‎apps/predbat/prediction_kernel.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
# Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp)
3434
KERNEL_ABI_VERSION = 4
35-
KERNEL_PARITY_REVISION = 5
35+
KERNEL_PARITY_REVISION = 6
3636

3737
# Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp)
3838
KERNEL_MAX_CARS = PREDBAT_MAX_CARS
@@ -97,8 +97,8 @@ class PkContext(ctypes.Structure):
9797
("export_today_now", ctypes.c_double),
9898
("iboost_today", ctypes.c_double),
9999
("car_charging_loss", ctypes.c_double),
100-
("car_charging_limit", ctypes.c_double * 4),
101-
("car_charging_soc", ctypes.c_double * 4),
100+
("car_charging_limit", ctypes.c_double * KERNEL_MAX_CARS),
101+
("car_charging_soc", ctypes.c_double * KERNEL_MAX_CARS),
102102
("iboost_max_energy", ctypes.c_double),
103103
("iboost_max_power", ctypes.c_double),
104104
("iboost_min_power", ctypes.c_double),
@@ -169,7 +169,7 @@ class PkResult(ctypes.Structure):
169169
("metric_keep", ctypes.c_double),
170170
("final_iboost", ctypes.c_double),
171171
("final_carbon_g", ctypes.c_double),
172-
("car_soc_next", ctypes.c_double * 4),
172+
("car_soc_next", ctypes.c_double * KERNEL_MAX_CARS),
173173
("iboost_next", ctypes.c_double),
174174
("soc_min_minute", ctypes.c_int32),
175175
("car_soc_next_valid", ctypes.c_int32),

‎apps/predbat/tests/test_fetch_config_options.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,11 @@ def mock_expose_config(key, value):
363363
# misconfiguration) must not be allowed through - car indices beyond the supported maximum
364364
# have no HA config entity, which previously crashed get_car_charging_planned() with
365365
# TypeError: float() argument must be a string or a real number, not 'NoneType'.
366-
mock_config.config["num_cars"] = 6
366+
mock_config.config["num_cars"] = 12
367367

368368
my_predbat.fetch_config_options()
369369

370-
assert my_predbat.num_cars == 4, "num_cars should be clamped to 4 (the supported maximum), got {}".format(my_predbat.num_cars)
370+
assert my_predbat.num_cars == 8, "num_cars should be clamped to 8 (the supported maximum), got {}".format(my_predbat.num_cars)
371371

372372
print("✓ num_cars clamp test passed")
373373

@@ -384,5 +384,25 @@ def mock_expose_config(key, value):
384384
my_predbat.expose_config = original_expose_config
385385
my_predbat.args = original_args
386386

387+
# Test 15: get_car_charging_planned resolves all 8 supported cars against the real
388+
# CONFIG_ITEMS/config_index (not the MockConfigProvider above) - this is the exact code path
389+
# that crashed with TypeError: float() argument must be a string or a real number, not
390+
# 'NoneType' when num_cars exceeded the number of car_charging_rate_N entities defined.
391+
print("\n*** Test 15: get_car_charging_planned resolves all 8 cars via real config_index ***")
392+
393+
original_num_cars = my_predbat.num_cars
394+
my_predbat.num_cars = 8
395+
my_predbat.get_car_charging_planned()
396+
397+
assert len(my_predbat.car_charging_rate) == 8, "car_charging_rate should have 8 entries, got {}".format(len(my_predbat.car_charging_rate))
398+
for car_n, rate in enumerate(my_predbat.car_charging_rate):
399+
assert rate == 7.4, "car {} charging rate should default to 7.4, got {}".format(car_n, rate)
400+
401+
# Restore real config state for any tests that run after this one
402+
my_predbat.num_cars = original_num_cars
403+
my_predbat.fetch_config_options()
404+
405+
print("✓ 8-car config resolution test passed")
406+
387407
print("\n**** All fetch_config_options tests passed! ****")
388408
return False

‎apps/predbat/tests/test_infra.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# pylint: disable=attribute-defined-outside-init
1010

1111
from datetime import datetime, timedelta
12+
from const import PREDBAT_MAX_CARS
1213
from prediction import Prediction
1314
from matplotlib import pyplot as plt
1415
import asyncio
@@ -408,6 +409,10 @@ def get_default_config(self):
408409
"car_charging_rate_1": 7.4,
409410
"car_charging_rate_2": 7.4,
410411
"car_charging_rate_3": 7.4,
412+
"car_charging_rate_4": 7.4,
413+
"car_charging_rate_5": 7.4,
414+
"car_charging_rate_6": 7.4,
415+
"car_charging_rate_7": 7.4,
411416
"car_charging_battery_size_0": 100.0,
412417
"car_charging_battery_size_1": 100.0,
413418
"car_charging_limit_0": 100.0,
@@ -509,8 +514,8 @@ def reset_inverter(my_predbat):
509514
my_predbat.num_cars = 0
510515
my_predbat.car_charging_slots[0] = []
511516
my_predbat.car_charging_from_battery = True
512-
my_predbat.car_charging_limit = [100.0, 100.0, 100.0, 100.0]
513-
my_predbat.car_charging_soc = [0, 0, 0, 0]
517+
my_predbat.car_charging_limit = [100.0] * PREDBAT_MAX_CARS
518+
my_predbat.car_charging_soc = [0] * PREDBAT_MAX_CARS
514519
my_predbat.iboost_enable = False
515520
my_predbat.iboost_solar = False
516521
my_predbat.iboost_gas = False
@@ -523,7 +528,7 @@ def reset_inverter(my_predbat):
523528
my_predbat.best_soc_keep = 0.0
524529
my_predbat.carbon_enable = 0
525530
my_predbat.inverter_soc_reset = True
526-
my_predbat.car_charging_soc_next = [None for car_n in range(4)]
531+
my_predbat.car_charging_soc_next = [None for car_n in range(PREDBAT_MAX_CARS)]
527532
my_predbat.charge_limit_best = []
528533
my_predbat.charge_window_best = []
529534
my_predbat.export_limits_best = []

‎apps/predbat/tests/test_octopus_misc.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,10 +1872,11 @@ def test_octopus_automatic_config_num_cars(my_predbat):
18721872
original_args = dict(my_predbat.args)
18731873

18741874
# Test 1: 5 registered devices, 2 suspended - only the 3 active devices should count towards
1875-
# num_cars and appear in the entity lists. Without this filter num_cars would be bumped to 5,
1876-
# which exceeds the 4 cars Predbat/the kernel support and previously crashed
1877-
# get_car_charging_planned() with TypeError: float() argument must be a string or a real number,
1878-
# not 'NoneType' (car_charging_rate_4 has no config entity).
1875+
# num_cars and appear in the entity lists. A stale/decommissioned device left in an Octopus
1876+
# account should not be treated as a car needing a charging slot (and, before
1877+
# fetch_config_options' num_cars clamp existed, an inflated count could push num_cars past
1878+
# what Predbat supports and crash get_car_charging_planned() with TypeError: float() argument
1879+
# must be a string or a real number, not 'NoneType').
18791880
print("\n*** Test 1: Suspended devices are excluded from num_cars and entity lists ***")
18801881
api = OctopusAPI(my_predbat, key="test-api-key", account_id="test-account", automatic=False)
18811882
api.intelligent_devices = {

0 commit comments

Comments
 (0)