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
1 change: 1 addition & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +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

# 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
Expand Down
6 changes: 5 additions & 1 deletion apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from datetime import datetime, timedelta
from utils import minutes_to_time, str2time, dp1, dp2, dp3, dp4, time_string_to_stamp, minute_data, get_now_from_cumulative, MinuteArray
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
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
from predbat_metrics import metrics
from futurerate import FutureRate
from axle import fetch_axle_sessions, load_axle_slot, fetch_axle_active
Expand Down Expand Up @@ -2345,6 +2345,10 @@ def fetch_config_options(self):
forecast_hours = max(self.get_arg("forecast_hours", 48), 24)

self.num_cars = self.get_arg("num_cars", 1)
if self.num_cars > PREDBAT_MAX_CARS:
self.log("Warn: num_cars {} exceeds the {} cars Predbat supports - clamping to {}".format(self.num_cars, PREDBAT_MAX_CARS, PREDBAT_MAX_CARS))
self.record_status("Warn: num_cars {} clamped to the maximum of {} supported cars".format(self.num_cars, PREDBAT_MAX_CARS), had_errors=True)
self.num_cars = PREDBAT_MAX_CARS
Comment thread
springfall2008 marked this conversation as resolved.
self.calculate_plan_every = max(self.get_arg("calculate_plan_every"), 5)
self.calculate_savings_max_charge_slots = self.get_arg("calculate_savings_max_charge_slots", 1)

Expand Down
13 changes: 9 additions & 4 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -1094,21 +1094,26 @@ def automatic_config(self, tariffs):
self.set_arg("metric_standing_charge", self.get_entity_name("sensor", tariff + "_standing"))
devices = self.get_intelligent_devices()
if devices:
# Suspended devices (e.g. an old/decommissioned charger still linked to the Octopus
# account) aren't actively charging, so exclude them from the entity lists and from
# the num_cars count below - otherwise a stale suspended device can silently push
# num_cars past what Predbat actually supports (see fetch_config_options' clamp).
active_devices = {device_id: device for device_id, device in devices.items() if not device.get("suspended")}
slot_list = []
ready_list = []
limit_list = []
for device_id in devices:
for device_id in active_devices:
index_suffix = self.device_id_to_index_suffix(device_id)
slot_list.append(self.get_entity_name("binary_sensor", "intelligent_dispatch", index=index_suffix))
ready_list.append(self.get_entity_name("select", "intelligent_target_time", index=index_suffix))
limit_list.append(self.get_entity_name("number", "intelligent_target_soc", index=index_suffix))
self.set_arg("octopus_intelligent_slot", slot_list)
self.set_arg("octopus_ready_time", ready_list)
self.set_arg("octopus_charge_limit", limit_list)
# Increase number of cars if we have more devices than the current limit to ensure all devices can be configured
# Increase number of cars if we have more active devices than the current limit to ensure all devices can be configured
num_cars = self.get_arg("num_cars", 0)
if num_cars < len(devices):
self.set_arg("num_cars", len(devices))
if num_cars < len(active_devices):
self.set_arg("num_cars", len(active_devices))

async def async_get_saving_sessions(self, account_id):
"""
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/prediction_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@
import sys
import weakref

from const import PREDICT_STEP
from const import PREDICT_STEP, PREDBAT_MAX_CARS
from utils import get_curve_value, find_battery_temperature_cap, in_car_slot, in_iboost_slot

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

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

# Module-level loader state, inherited by forked worker processes
KERNEL_LIB = None
Expand Down
18 changes: 18 additions & 0 deletions apps/predbat/tests/test_fetch_config_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,24 @@ def mock_expose_config(key, value):

print("✓ Case-insensitivity test passed")

# Test 14: num_cars is clamped to the maximum Predbat/kernel supports
print("\n*** Test 14: num_cars is clamped to the supported maximum ***")

# An Octopus account with more registered devices than Predbat supports (or any other
# 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

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)

print("✓ num_cars clamp test passed")

# Restore num_cars for any tests appended after this one
mock_config.config["num_cars"] = 2

# Restore original methods
my_predbat.get_arg = original_get_arg
my_predbat.manual_times = original_manual_times
Expand Down
6 changes: 4 additions & 2 deletions apps/predbat/tests/test_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,10 @@ def get_default_config(self):
"inverter_clock_skew_discharge_end": 0,
"set_window_minutes": 0,
# Car charging config for each car (postfix _0, _1, etc.)
"car_charging_rate_0": 7400,
"car_charging_rate_1": 7400,
"car_charging_rate_0": 7.4,
"car_charging_rate_1": 7.4,
"car_charging_rate_2": 7.4,
"car_charging_rate_3": 7.4,
"car_charging_battery_size_0": 100.0,
"car_charging_battery_size_1": 100.0,
"car_charging_limit_0": 100.0,
Expand Down
74 changes: 74 additions & 0 deletions apps/predbat/tests/test_octopus_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ async def test_octopus_misc(my_predbat):
failed += test_octopus_get_intelligent_target_time(my_predbat)
failed += test_octopus_get_intelligent_battery_size(my_predbat)
failed += test_octopus_get_intelligent_vehicle(my_predbat)
failed += test_octopus_automatic_config_num_cars(my_predbat)
failed += await test_octopus_run(my_predbat)

if failed == 0:
Expand Down Expand Up @@ -1852,6 +1853,79 @@ def test_octopus_get_intelligent_vehicle(my_predbat):
return 0


def test_octopus_automatic_config_num_cars(my_predbat):
"""
Test OctopusAPI automatic_config method's num_cars auto-discovery from Octopus Intelligent devices.

Tests:
- Test 1: Suspended devices (e.g. an old/decommissioned charger still linked to the account) are
excluded from the entity lists and don't count towards num_cars
- Test 2: num_cars auto-discovery only ever raises num_cars, never lowers an existing higher value
"""
print("\n**** Running Octopus automatic_config num_cars tests ****")
failed = False

# automatic_config() writes several keys (num_cars, octopus_saving_session_join,
# octopus_intelligent_slot, metric_octopus_*, ...) directly into my_predbat.args, and
# my_predbat is a single shared instance reused across every test in the run - so without
# saving/restoring, those writes would leak into unrelated tests that run afterwards.
original_args = dict(my_predbat.args)

Comment thread
springfall2008 marked this conversation as resolved.
# 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).
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 = {
"device-aaa1": {"suspended": False},
"device-bbb2": {"suspended": True},
"device-ccc3": {"suspended": False},
"device-ddd4": {"suspended": True},
"device-eee5": {"suspended": False},
}
my_predbat.args["num_cars"] = 0

api.automatic_config(["import", "export"])

if my_predbat.args.get("num_cars") != 3:
print(f"ERROR: Expected num_cars to be raised to 3 active devices, got {my_predbat.args.get('num_cars')}")
failed = True
else:
print("PASS: num_cars raised to the count of active (non-suspended) devices only")

slot_list = my_predbat.args.get("octopus_intelligent_slot", [])
if len(slot_list) != 3:
print(f"ERROR: Expected 3 entities in octopus_intelligent_slot, got {len(slot_list)}")
failed = True
else:
print("PASS: octopus_intelligent_slot only contains active devices")

# Test 2: num_cars auto-discovery never lowers an already-higher value
print("\n*** Test 2: num_cars is not lowered below an existing higher value ***")
my_predbat.args["num_cars"] = 4

api.automatic_config(["import", "export"])

if my_predbat.args.get("num_cars") != 4:
print(f"ERROR: Expected num_cars to remain at 4, got {my_predbat.args.get('num_cars')}")
failed = True
else:
print("PASS: num_cars left unchanged when already >= the active device count")

# Restore my_predbat.args so this test's auto-discovery writes don't leak into later tests
my_predbat.args.clear()
my_predbat.args.update(original_args)

if failed:
print("\n**** ❌ Octopus automatic_config num_cars tests FAILED ****")
return 1
else:
print("\n**** ✅ Octopus automatic_config num_cars tests PASSED ****")
return 0


def _mock_run_api(my_predbat, key, account_id, automatic=False):
"""Create an OctopusAPI instance with all run() methods mocked."""
api = OctopusAPI(my_predbat, key=key, account_id=account_id, automatic=automatic)
Expand Down
Loading