Skip to content

Commit eeeae4f

Browse files
fix(car): clamp num_cars to the supported maximum and exclude suspended Octopus devices
An Octopus account with more than 4 registered Intelligent devices (including suspended/decommissioned chargers) auto-bumped num_cars with no upper bound, past what CONFIG_ITEMS and the prediction kernel (KERNEL_MAX_CARS) actually support. Car indices beyond 4 have no car_charging_rate_N config entity, so get_car_charging_planned() crashed every cycle with TypeError: float() argument must be a string or a real number, not 'NoneType'. Clamp num_cars to PREDBAT_MAX_CARS in fetch_config_options() regardless of source, and exclude suspended devices from automatic_config()'s device count and entity lists so a stale/decommissioned charger no longer counts as a car. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a131f12 commit eeeae4f

7 files changed

Lines changed: 111 additions & 7 deletions

File tree

‎apps/predbat/const.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +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
4344

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

‎apps/predbat/fetch.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from datetime import datetime, timedelta
2222
from utils import minutes_to_time, str2time, dp1, dp2, dp3, dp4, time_string_to_stamp, minute_data, get_now_from_cumulative, MinuteArray
23-
from const import MINUTE_WATT, PREDICT_STEP, TIME_FORMAT, PREDBAT_MODE_OPTIONS, PREDBAT_MODE_CONTROL_SOC, PREDBAT_MODE_CONTROL_CHARGEDISCHARGE, PREDBAT_MODE_CONTROL_CHARGE, PREDBAT_MODE_MONITOR, LOAD_FORECAST_HISTORY_MAX_DAYS
23+
from const import MINUTE_WATT, PREDICT_STEP, TIME_FORMAT, PREDBAT_MODE_OPTIONS, PREDBAT_MODE_CONTROL_SOC, PREDBAT_MODE_CONTROL_CHARGEDISCHARGE, PREDBAT_MODE_CONTROL_CHARGE, PREDBAT_MODE_MONITOR, LOAD_FORECAST_HISTORY_MAX_DAYS, PREDBAT_MAX_CARS
2424
from predbat_metrics import metrics
2525
from futurerate import FutureRate
2626
from axle import fetch_axle_sessions, load_axle_slot, fetch_axle_active
@@ -2345,6 +2345,10 @@ def fetch_config_options(self):
23452345
forecast_hours = max(self.get_arg("forecast_hours", 48), 24)
23462346

23472347
self.num_cars = self.get_arg("num_cars", 1)
2348+
if self.num_cars > PREDBAT_MAX_CARS:
2349+
self.log("Warn: num_cars {} exceeds the {} cars Predbat supports - clamping to {}".format(self.num_cars, PREDBAT_MAX_CARS, PREDBAT_MAX_CARS))
2350+
self.record_status("Warn: num_cars {} clamped to the maximum of {} supported cars".format(self.num_cars, PREDBAT_MAX_CARS), had_errors=True)
2351+
self.num_cars = PREDBAT_MAX_CARS
23482352
self.calculate_plan_every = max(self.get_arg("calculate_plan_every"), 5)
23492353
self.calculate_savings_max_charge_slots = self.get_arg("calculate_savings_max_charge_slots", 1)
23502354

‎apps/predbat/octopus.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,21 +1094,26 @@ def automatic_config(self, tariffs):
10941094
self.set_arg("metric_standing_charge", self.get_entity_name("sensor", tariff + "_standing"))
10951095
devices = self.get_intelligent_devices()
10961096
if devices:
1097+
# Suspended devices (e.g. an old/decommissioned charger still linked to the Octopus
1098+
# account) aren't actively charging, so exclude them from the entity lists and from
1099+
# the num_cars count below - otherwise a stale suspended device can silently push
1100+
# num_cars past what Predbat actually supports (see fetch_config_options' clamp).
1101+
active_devices = {device_id: device for device_id, device in devices.items() if not device.get("suspended")}
10971102
slot_list = []
10981103
ready_list = []
10991104
limit_list = []
1100-
for device_id in devices:
1105+
for device_id in active_devices:
11011106
index_suffix = self.device_id_to_index_suffix(device_id)
11021107
slot_list.append(self.get_entity_name("binary_sensor", "intelligent_dispatch", index=index_suffix))
11031108
ready_list.append(self.get_entity_name("select", "intelligent_target_time", index=index_suffix))
11041109
limit_list.append(self.get_entity_name("number", "intelligent_target_soc", index=index_suffix))
11051110
self.set_arg("octopus_intelligent_slot", slot_list)
11061111
self.set_arg("octopus_ready_time", ready_list)
11071112
self.set_arg("octopus_charge_limit", limit_list)
1108-
# Increase number of cars if we have more devices than the current limit to ensure all devices can be configured
1113+
# Increase number of cars if we have more active devices than the current limit to ensure all devices can be configured
11091114
num_cars = self.get_arg("num_cars", 0)
1110-
if num_cars < len(devices):
1111-
self.set_arg("num_cars", len(devices))
1115+
if num_cars < len(active_devices):
1116+
self.set_arg("num_cars", len(active_devices))
11121117

11131118
async def async_get_saving_sessions(self, account_id):
11141119
"""

‎apps/predbat/prediction_kernel.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@
2727
import sys
2828
import weakref
2929

30-
from const import PREDICT_STEP
30+
from const import PREDICT_STEP, PREDBAT_MAX_CARS
3131
from utils import get_curve_value, find_battery_temperature_cap, in_car_slot, in_iboost_slot
3232

3333
# Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp)
3434
KERNEL_ABI_VERSION = 3
3535
KERNEL_PARITY_REVISION = 5
3636

3737
# Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp)
38-
KERNEL_MAX_CARS = 4
38+
KERNEL_MAX_CARS = PREDBAT_MAX_CARS
3939

4040
# Module-level loader state, inherited by forked worker processes
4141
KERNEL_LIB = None

‎apps/predbat/tests/test_fetch_config_options.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,24 @@ def mock_expose_config(key, value):
356356

357357
print("✓ Case-insensitivity test passed")
358358

359+
# Test 14: num_cars is clamped to the maximum Predbat/kernel supports
360+
print("\n*** Test 14: num_cars is clamped to the supported maximum ***")
361+
362+
# An Octopus account with more registered devices than Predbat supports (or any other
363+
# misconfiguration) must not be allowed through - car indices beyond the supported maximum
364+
# have no HA config entity, which previously crashed get_car_charging_planned() with
365+
# TypeError: float() argument must be a string or a real number, not 'NoneType'.
366+
mock_config.config["num_cars"] = 6
367+
368+
my_predbat.fetch_config_options()
369+
370+
assert my_predbat.num_cars == 4, "num_cars should be clamped to 4 (the supported maximum), got {}".format(my_predbat.num_cars)
371+
372+
print("✓ num_cars clamp test passed")
373+
374+
# Restore num_cars for any tests appended after this one
375+
mock_config.config["num_cars"] = 2
376+
359377
# Restore original methods
360378
my_predbat.get_arg = original_get_arg
361379
my_predbat.manual_times = original_manual_times

‎apps/predbat/tests/test_infra.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,8 @@ def get_default_config(self):
406406
# Car charging config for each car (postfix _0, _1, etc.)
407407
"car_charging_rate_0": 7400,
408408
"car_charging_rate_1": 7400,
409+
"car_charging_rate_2": 7400,
410+
"car_charging_rate_3": 7400,
409411
"car_charging_battery_size_0": 100.0,
410412
"car_charging_battery_size_1": 100.0,
411413
"car_charging_limit_0": 100.0,

‎apps/predbat/tests/test_octopus_misc.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def test_octopus_misc(my_predbat):
2626
failed += test_octopus_get_intelligent_target_time(my_predbat)
2727
failed += test_octopus_get_intelligent_battery_size(my_predbat)
2828
failed += test_octopus_get_intelligent_vehicle(my_predbat)
29+
failed += test_octopus_automatic_config_num_cars(my_predbat)
2930
failed += await test_octopus_run(my_predbat)
3031

3132
if failed == 0:
@@ -1852,6 +1853,79 @@ def test_octopus_get_intelligent_vehicle(my_predbat):
18521853
return 0
18531854

18541855

1856+
def test_octopus_automatic_config_num_cars(my_predbat):
1857+
"""
1858+
Test OctopusAPI automatic_config method's num_cars auto-discovery from Octopus Intelligent devices.
1859+
1860+
Tests:
1861+
- Test 1: Suspended devices (e.g. an old/decommissioned charger still linked to the account) are
1862+
excluded from the entity lists and don't count towards num_cars
1863+
- Test 2: num_cars auto-discovery only ever raises num_cars, never lowers an existing higher value
1864+
"""
1865+
print("\n**** Running Octopus automatic_config num_cars tests ****")
1866+
failed = False
1867+
1868+
# automatic_config() writes several keys (num_cars, octopus_saving_session_join,
1869+
# octopus_intelligent_slot, metric_octopus_*, ...) directly into my_predbat.args, and
1870+
# my_predbat is a single shared instance reused across every test in the run - so without
1871+
# saving/restoring, those writes would leak into unrelated tests that run afterwards.
1872+
original_args = dict(my_predbat.args)
1873+
1874+
# 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).
1879+
print("\n*** Test 1: Suspended devices are excluded from num_cars and entity lists ***")
1880+
api = OctopusAPI(my_predbat, key="test-api-key", account_id="test-account", automatic=False)
1881+
api.intelligent_devices = {
1882+
"device-aaa1": {"suspended": False},
1883+
"device-bbb2": {"suspended": True},
1884+
"device-ccc3": {"suspended": False},
1885+
"device-ddd4": {"suspended": True},
1886+
"device-eee5": {"suspended": False},
1887+
}
1888+
my_predbat.args["num_cars"] = 0
1889+
1890+
api.automatic_config(["import", "export"])
1891+
1892+
if my_predbat.args.get("num_cars") != 3:
1893+
print(f"ERROR: Expected num_cars to be raised to 3 active devices, got {my_predbat.args.get('num_cars')}")
1894+
failed = True
1895+
else:
1896+
print("PASS: num_cars raised to the count of active (non-suspended) devices only")
1897+
1898+
slot_list = my_predbat.args.get("octopus_intelligent_slot", [])
1899+
if len(slot_list) != 3:
1900+
print(f"ERROR: Expected 3 entities in octopus_intelligent_slot, got {len(slot_list)}")
1901+
failed = True
1902+
else:
1903+
print("PASS: octopus_intelligent_slot only contains active devices")
1904+
1905+
# Test 2: num_cars auto-discovery never lowers an already-higher value
1906+
print("\n*** Test 2: num_cars is not lowered below an existing higher value ***")
1907+
my_predbat.args["num_cars"] = 4
1908+
1909+
api.automatic_config(["import", "export"])
1910+
1911+
if my_predbat.args.get("num_cars") != 4:
1912+
print(f"ERROR: Expected num_cars to remain at 4, got {my_predbat.args.get('num_cars')}")
1913+
failed = True
1914+
else:
1915+
print("PASS: num_cars left unchanged when already >= the active device count")
1916+
1917+
# Restore my_predbat.args so this test's auto-discovery writes don't leak into later tests
1918+
my_predbat.args.clear()
1919+
my_predbat.args.update(original_args)
1920+
1921+
if failed:
1922+
print("\n**** ❌ Octopus automatic_config num_cars tests FAILED ****")
1923+
return 1
1924+
else:
1925+
print("\n**** ✅ Octopus automatic_config num_cars tests PASSED ****")
1926+
return 0
1927+
1928+
18551929
def _mock_run_api(my_predbat, key, account_id, automatic=False):
18561930
"""Create an OctopusAPI instance with all run() methods mocked."""
18571931
api = OctopusAPI(my_predbat, key=key, account_id=account_id, automatic=automatic)

0 commit comments

Comments
 (0)