diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 9b4def363..28674a73d 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -322,6 +322,58 @@ "enable": "num_cars", "enable_condition": "num_cars > 3", }, + { + "name": "car_charging_rate_4", + "friendly_name": "Car charging rate (Car 4)", + "type": "input_number", + "min": 1, + "max": 24, + "step": 0.10, + "unit": "kW", + "icon": "mdi:ev-station", + "default": 7.4, + "enable": "num_cars", + "enable_condition": "num_cars > 4", + }, + { + "name": "car_charging_rate_5", + "friendly_name": "Car charging rate (Car 5)", + "type": "input_number", + "min": 1, + "max": 24, + "step": 0.10, + "unit": "kW", + "icon": "mdi:ev-station", + "default": 7.4, + "enable": "num_cars", + "enable_condition": "num_cars > 5", + }, + { + "name": "car_charging_rate_6", + "friendly_name": "Car charging rate (Car 6)", + "type": "input_number", + "min": 1, + "max": 24, + "step": 0.10, + "unit": "kW", + "icon": "mdi:ev-station", + "default": 7.4, + "enable": "num_cars", + "enable_condition": "num_cars > 6", + }, + { + "name": "car_charging_rate_7", + "friendly_name": "Car charging rate (Car 7)", + "type": "input_number", + "min": 1, + "max": 24, + "step": 0.10, + "unit": "kW", + "icon": "mdi:ev-station", + "default": 7.4, + "enable": "num_cars", + "enable_condition": "num_cars > 7", + }, { "name": "car_charging_loss", "friendly_name": "Car charging loss", @@ -741,6 +793,90 @@ "default": 0.0, "restore": False, }, + { + "name": "car_charging_manual_soc_4", + "friendly_name": "Car charging manual SOC (Car 4)", + "type": "switch", + "default": False, + "enable": "num_cars", + "enable_condition": "num_cars > 4", + }, + { + "name": "car_charging_manual_soc_kwh_4", + "friendly_name": "Car manual SOC kWh (Car 4)", + "type": "input_number", + "min": 0, + "max": 100, + "step": 0.01, + "unit": "kWh", + "icon": "mdi:ev-station", + "enable": "car_charging_manual_soc_4", + "default": 0.0, + "restore": False, + }, + { + "name": "car_charging_manual_soc_5", + "friendly_name": "Car charging manual SOC (Car 5)", + "type": "switch", + "default": False, + "enable": "num_cars", + "enable_condition": "num_cars > 5", + }, + { + "name": "car_charging_manual_soc_kwh_5", + "friendly_name": "Car manual SOC kWh (Car 5)", + "type": "input_number", + "min": 0, + "max": 100, + "step": 0.01, + "unit": "kWh", + "icon": "mdi:ev-station", + "enable": "car_charging_manual_soc_5", + "default": 0.0, + "restore": False, + }, + { + "name": "car_charging_manual_soc_6", + "friendly_name": "Car charging manual SOC (Car 6)", + "type": "switch", + "default": False, + "enable": "num_cars", + "enable_condition": "num_cars > 6", + }, + { + "name": "car_charging_manual_soc_kwh_6", + "friendly_name": "Car manual SOC kWh (Car 6)", + "type": "input_number", + "min": 0, + "max": 100, + "step": 0.01, + "unit": "kWh", + "icon": "mdi:ev-station", + "enable": "car_charging_manual_soc_6", + "default": 0.0, + "restore": False, + }, + { + "name": "car_charging_manual_soc_7", + "friendly_name": "Car charging manual SOC (Car 7)", + "type": "switch", + "default": False, + "enable": "num_cars", + "enable_condition": "num_cars > 7", + }, + { + "name": "car_charging_manual_soc_kwh_7", + "friendly_name": "Car manual SOC kWh (Car 7)", + "type": "input_number", + "min": 0, + "max": 100, + "step": 0.01, + "unit": "kWh", + "icon": "mdi:ev-station", + "enable": "car_charging_manual_soc_7", + "default": 0.0, + "restore": False, + }, { "name": "octopus_intelligent_charging", "friendly_name": "Octopus Intelligent Charging", diff --git a/apps/predbat/const.py b/apps/predbat/const.py index 196d484bb..da47c062a 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -40,7 +40,7 @@ INVERTER_MAX_RETRY_REST = 5 # Maximum number of retries for inverter REST commands INVERTER_REST_TIMEOUT = 10 # Seconds to wait for a REST response before giving up (local network call, should be fast) INVERTER_QUICK_UPDATE_SECONDS = 120 # Minimum seconds between quick inverter data updates -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 +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 # 240v x 100 amps x 3 phases / 1000 to kW / 60 minutes in an hour is the maximum kWh in a 1 minute period MAX_INCREMENT = 240 * 100 * 3 / 1000 / 60 diff --git a/apps/predbat/prediction_kernel.cpp b/apps/predbat/prediction_kernel.cpp index 0be4dd4f8..ac1a0bc26 100644 --- a/apps/predbat/prediction_kernel.cpp +++ b/apps/predbat/prediction_kernel.cpp @@ -43,8 +43,8 @@ // falling back. Bumping makes the loader reject it and use the Python engine, which is the whole // point of the check. #define PK_ABI_VERSION 4 -#define PK_PARITY_REVISION 5 -#define PK_MAX_CARS 4 +#define PK_PARITY_REVISION 6 +#define PK_MAX_CARS 8 #define PK_RUN_EVERY 5 // const.py RUN_EVERY namespace { diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index eddc3516e..ecab5a622 100644 --- a/apps/predbat/prediction_kernel.py +++ b/apps/predbat/prediction_kernel.py @@ -32,7 +32,7 @@ # Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp) KERNEL_ABI_VERSION = 4 -KERNEL_PARITY_REVISION = 5 +KERNEL_PARITY_REVISION = 6 # Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp) KERNEL_MAX_CARS = PREDBAT_MAX_CARS @@ -97,8 +97,8 @@ class PkContext(ctypes.Structure): ("export_today_now", ctypes.c_double), ("iboost_today", ctypes.c_double), ("car_charging_loss", ctypes.c_double), - ("car_charging_limit", ctypes.c_double * 4), - ("car_charging_soc", ctypes.c_double * 4), + ("car_charging_limit", ctypes.c_double * KERNEL_MAX_CARS), + ("car_charging_soc", ctypes.c_double * KERNEL_MAX_CARS), ("iboost_max_energy", ctypes.c_double), ("iboost_max_power", ctypes.c_double), ("iboost_min_power", ctypes.c_double), @@ -169,7 +169,7 @@ class PkResult(ctypes.Structure): ("metric_keep", ctypes.c_double), ("final_iboost", ctypes.c_double), ("final_carbon_g", ctypes.c_double), - ("car_soc_next", ctypes.c_double * 4), + ("car_soc_next", ctypes.c_double * KERNEL_MAX_CARS), ("iboost_next", ctypes.c_double), ("soc_min_minute", ctypes.c_int32), ("car_soc_next_valid", ctypes.c_int32), diff --git a/apps/predbat/prediction_kernel_lib_aarch64.so b/apps/predbat/prediction_kernel_lib_aarch64.so index eda553983..cf5813d6b 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 b26eda510..1b9588ad9 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 0431deb3d..02e97aee3 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 7300ca9bd..b8be4d8b1 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 05939aabc..63c633502 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 9cb9287e0..ca655ea50 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_fetch_config_options.py b/apps/predbat/tests/test_fetch_config_options.py index 8ae399b6d..a879c4808 100644 --- a/apps/predbat/tests/test_fetch_config_options.py +++ b/apps/predbat/tests/test_fetch_config_options.py @@ -363,11 +363,11 @@ def mock_expose_config(key, value): # misconfiguration) must not be allowed through - car indices beyond the supported maximum # have no HA config entity, which previously crashed get_car_charging_planned() with # TypeError: float() argument must be a string or a real number, not 'NoneType'. - mock_config.config["num_cars"] = 6 + mock_config.config["num_cars"] = 12 my_predbat.fetch_config_options() - assert my_predbat.num_cars == 4, "num_cars should be clamped to 4 (the supported maximum), got {}".format(my_predbat.num_cars) + assert my_predbat.num_cars == 8, "num_cars should be clamped to 8 (the supported maximum), got {}".format(my_predbat.num_cars) print("✓ num_cars clamp test passed") @@ -384,5 +384,25 @@ def mock_expose_config(key, value): my_predbat.expose_config = original_expose_config my_predbat.args = original_args + # Test 15: get_car_charging_planned resolves all 8 supported cars against the real + # CONFIG_ITEMS/config_index (not the MockConfigProvider above) - this is the exact code path + # that crashed with TypeError: float() argument must be a string or a real number, not + # 'NoneType' when num_cars exceeded the number of car_charging_rate_N entities defined. + print("\n*** Test 15: get_car_charging_planned resolves all 8 cars via real config_index ***") + + original_num_cars = my_predbat.num_cars + my_predbat.num_cars = 8 + my_predbat.get_car_charging_planned() + + assert len(my_predbat.car_charging_rate) == 8, "car_charging_rate should have 8 entries, got {}".format(len(my_predbat.car_charging_rate)) + for car_n, rate in enumerate(my_predbat.car_charging_rate): + assert rate == 7.4, "car {} charging rate should default to 7.4, got {}".format(car_n, rate) + + # Restore real config state for any tests that run after this one + my_predbat.num_cars = original_num_cars + my_predbat.fetch_config_options() + + print("✓ 8-car config resolution test passed") + print("\n**** All fetch_config_options tests passed! ****") return False diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py index 1b423fd25..76f592b44 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -9,6 +9,7 @@ # pylint: disable=attribute-defined-outside-init from datetime import datetime, timedelta +from const import PREDBAT_MAX_CARS from prediction import Prediction from matplotlib import pyplot as plt import asyncio @@ -408,6 +409,10 @@ def get_default_config(self): "car_charging_rate_1": 7.4, "car_charging_rate_2": 7.4, "car_charging_rate_3": 7.4, + "car_charging_rate_4": 7.4, + "car_charging_rate_5": 7.4, + "car_charging_rate_6": 7.4, + "car_charging_rate_7": 7.4, "car_charging_battery_size_0": 100.0, "car_charging_battery_size_1": 100.0, "car_charging_limit_0": 100.0, @@ -509,8 +514,8 @@ def reset_inverter(my_predbat): my_predbat.num_cars = 0 my_predbat.car_charging_slots[0] = [] my_predbat.car_charging_from_battery = True - my_predbat.car_charging_limit = [100.0, 100.0, 100.0, 100.0] - my_predbat.car_charging_soc = [0, 0, 0, 0] + my_predbat.car_charging_limit = [100.0] * PREDBAT_MAX_CARS + my_predbat.car_charging_soc = [0] * PREDBAT_MAX_CARS my_predbat.iboost_enable = False my_predbat.iboost_solar = False my_predbat.iboost_gas = False @@ -523,7 +528,7 @@ def reset_inverter(my_predbat): my_predbat.best_soc_keep = 0.0 my_predbat.carbon_enable = 0 my_predbat.inverter_soc_reset = True - my_predbat.car_charging_soc_next = [None for car_n in range(4)] + my_predbat.car_charging_soc_next = [None for car_n in range(PREDBAT_MAX_CARS)] my_predbat.charge_limit_best = [] my_predbat.charge_window_best = [] my_predbat.export_limits_best = [] diff --git a/apps/predbat/tests/test_octopus_misc.py b/apps/predbat/tests/test_octopus_misc.py index e24d9c6d1..354e0ad47 100644 --- a/apps/predbat/tests/test_octopus_misc.py +++ b/apps/predbat/tests/test_octopus_misc.py @@ -1872,10 +1872,11 @@ def test_octopus_automatic_config_num_cars(my_predbat): original_args = dict(my_predbat.args) # Test 1: 5 registered devices, 2 suspended - only the 3 active devices should count towards - # num_cars and appear in the entity lists. Without this filter num_cars would be bumped to 5, - # which exceeds the 4 cars Predbat/the kernel support and previously crashed - # get_car_charging_planned() with TypeError: float() argument must be a string or a real number, - # not 'NoneType' (car_charging_rate_4 has no config entity). + # num_cars and appear in the entity lists. A stale/decommissioned device left in an Octopus + # account should not be treated as a car needing a charging slot (and, before + # fetch_config_options' num_cars clamp existed, an inflated count could push num_cars past + # what Predbat supports and crash get_car_charging_planned() with TypeError: float() argument + # must be a string or a real number, not 'NoneType'). print("\n*** Test 1: Suspended devices are excluded from num_cars and entity lists ***") api = OctopusAPI(my_predbat, key="test-api-key", account_id="test-account", automatic=False) api.intelligent_devices = {