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
4 changes: 2 additions & 2 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
import requests
import asyncio

THIS_VERSION = "v8.30.8"
THIS_VERSION = "v8.30.9"

# fmt: off
PREDBAT_FILES = ["predbat.py", "hass.py", "config.py", "prediction.py", "gecloud.py", "utils.py", "inverter.py", "ha.py", "download.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py", "execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py", "fox.py", "carbon.py", "web_mcp.py", "component_base.py", "axle.py"]
PREDBAT_FILES = ["predbat.py", "hass.py", "config.py", "prediction.py", "gecloud.py", "utils.py", "inverter.py", "ha.py", "download.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py", "execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py", "fox.py", "carbon.py", "web_mcp.py", "component_base.py", "axle.py", "unit_test.py"]
# fmt: on

from download import predbat_update_move, predbat_update_download, check_install
Expand Down
115 changes: 115 additions & 0 deletions apps/predbat/tests/test_find_charge_rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,118 @@ def test_find_charge_rate(my_predbat):
print("**** ERROR: Best real rate should be 1225 ****")
failed = 1
return failed


def test_find_charge_rate_string_temperature(my_predbat):
"""
Test find_charge_rate with string temperature indices in the curve
This tests backward compatibility with configs that may have string keys
Uses extreme temperature curve values to ensure they actually affect the result
"""
failed = 0

current_charge_rate = 952
soc = 5.0 # Lower SOC so more charge is needed
soc_max = 9.52
log_to = print # my_predbat.log
minutes_now = my_predbat.minutes_now
window = {"start": minutes_now - 60, "end": minutes_now + 50}
target_soc = soc_max
# Use a flat charge curve so temperature becomes the only limiting factor
battery_charge_power_curve = {100: 1.0, 99: 1.0, 98: 1.0, 97: 1.0, 96: 1.0, 95: 1.0, 94: 1.0, 93: 1.0, 92: 1.0, 91: 1.0, 90: 1.0}
set_charge_low_power = True
charge_low_power_margin = my_predbat.charge_low_power_margin
battery_rate_min = 0
battery_rate_max_scaling = 1
battery_loss = 0.96
battery_temperature = 17.0
# Temperature curve with STRING keys - these are scale factors that reduce charging rate
# Using 1.0 (no reduction) for most temps, and 0.5 (50% reduction) at 17 degrees
battery_temperature_curve = {"19": 1.0, "18": 1.0, "17": 0.5, "16": 1.0, "15": 1.0, "14": 1.0, "13": 1.0, "12": 1.0, "11": 1.0, "10": 1.0, "9": 1.0, "8": 1.0, "7": 1.0, "6": 1.0, "5": 1.0, "4": 1.0, "3": 1.0, "2": 1.0, "1": 1.0, "0": 1.0}
max_rate = 6000 # High enough that temperature becomes the limiting factor

best_rate, best_rate_real = find_charge_rate(
minutes_now,
soc,
window,
target_soc,
max_rate / MINUTE_WATT,
soc_max,
battery_charge_power_curve,
set_charge_low_power,
charge_low_power_margin,
battery_rate_min / MINUTE_WATT,
battery_rate_max_scaling,
battery_loss,
log_to,
battery_temperature=battery_temperature,
battery_temperature_curve=battery_temperature_curve,
current_charge_rate=current_charge_rate / MINUTE_WATT,
)
print("String temp test - Best_rate {} Best_rate_real {}".format(best_rate * MINUTE_WATT, best_rate_real * MINUTE_WATT))
# With temp scale factor 0.5 at 17 degrees and high max_rate, temperature should be the limiting factor
# Temperature cap: 9.52 * 0.5 / 60 = 0.0793 kW/min = 4760W
# Best rate should be capped by temperature to ~4760W
if best_rate * MINUTE_WATT != 6000:
print("**** ERROR: Best rate should be 6000 (with string temp curve) ****")
failed = 1
# Best_rate_real should be temperature-limited to ~4760W
expected_temp_limited = int(9.52 * 0.5 / 60 * MINUTE_WATT) # Should be ~4760W
if abs(best_rate_real * MINUTE_WATT - expected_temp_limited) > 100: # Allow 100W tolerance
print("**** ERROR: Best real rate {} should be temp-limited to ~{}W ****".format(best_rate_real * MINUTE_WATT, expected_temp_limited))
failed = 1
return failed


def test_find_charge_rate_string_charge_curve(my_predbat):
"""
Test find_charge_rate with string charge power curve indices
This tests backward compatibility with configs that may have string keys in charge curve
"""
failed = 0

current_charge_rate = 952
soc = 9.04
soc_max = 9.52
log_to = print # my_predbat.log
minutes_now = my_predbat.minutes_now
window = {"start": minutes_now - 60, "end": minutes_now + 50}
target_soc = soc_max
# Battery charge power curve with STRING keys instead of integers
battery_charge_power_curve = {"100": 0.15, "99": 0.15, "98": 0.23, "97": 0.3, "96": 0.42, "95": 0.49, "94": 0.55, "93": 0.69, "92": 0.79, "91": 0.89, "90": 0.96}
set_charge_low_power = True
charge_low_power_margin = my_predbat.charge_low_power_margin
battery_rate_min = 0
battery_rate_max_scaling = 1
battery_loss = 0.96
battery_temperature = 17.0
battery_temperature_curve = {19: 0.33, 18: 0.33, 17: 0.33, 16: 0.33, 15: 0.33, 14: 0.33, 13: 0.33, 12: 0.33, 11: 0.33, 10: 0.25, 9: 0.25, 8: 0.25, 7: 0.25, 6: 0.25, 5: 0.25, 4: 0.25, 3: 0.25, 2: 0.25, 1: 0.15, 0: 0.00}
max_rate = 2500

best_rate, best_rate_real = find_charge_rate(
minutes_now,
soc,
window,
target_soc,
max_rate / MINUTE_WATT,
soc_max,
battery_charge_power_curve,
set_charge_low_power,
charge_low_power_margin,
battery_rate_min / MINUTE_WATT,
battery_rate_max_scaling,
battery_loss,
log_to,
battery_temperature=battery_temperature,
battery_temperature_curve=battery_temperature_curve,
current_charge_rate=current_charge_rate / MINUTE_WATT,
)
print("String charge curve test - Best_rate {} Best_rate_real {}".format(best_rate * MINUTE_WATT, best_rate_real * MINUTE_WATT))
# Should get the same results as with integer keys
if best_rate * MINUTE_WATT != 2500:
print("**** ERROR: Best rate should be 2500 (with string charge curve) ****")
failed = 1
if best_rate_real * MINUTE_WATT != 1225:
print("**** ERROR: Best real rate should be 1225 (with string charge curve) ****")
failed = 1
return failed
8 changes: 5 additions & 3 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from tests.test_nordpool import run_nordpool_test
from tests.test_car_charging_smart import run_car_charging_smart_tests
from tests.test_plugin_startup import test_plugin_startup_order
from tests.test_optimise_levels import run_optimise_levels
from tests.test_optimise_levels import run_optimise_levels_tests
from tests.test_energydataservice import test_energydataservice
from tests.test_iboost import run_iboost_smart_tests
from tests.test_alert_feed import test_alert_feed
Expand Down Expand Up @@ -123,7 +123,7 @@
from tests.test_hainterface_websocket import run_hainterface_websocket_tests
from tests.test_web_if import run_test_web_if
from tests.test_window import run_window_sort_tests, run_intersect_window_tests
from tests.test_find_charge_rate import test_find_charge_rate
from tests.test_find_charge_rate import test_find_charge_rate, test_find_charge_rate_string_temperature, test_find_charge_rate_string_charge_curve
from tests.test_manual_api import run_test_manual_api
from tests.test_manual_soc import run_test_manual_soc
from tests.test_manual_times import run_test_manual_times
Expand Down Expand Up @@ -405,6 +405,8 @@ def main():
("octopus_slots", run_load_octopus_slots_tests, "Load Octopus slots tests", False),
("rate_add_io_slots", run_rate_add_io_slots_tests, "Rate add IO slots tests", False),
("find_charge_rate", test_find_charge_rate, "Find charge rate tests", False),
("find_charge_rate_string_temp", test_find_charge_rate_string_temperature, "Find charge rate string temperature", False),
("find_charge_rate_string_curve", test_find_charge_rate_string_charge_curve, "Find charge rate string charge curve", False),
("find_charge_curve", run_find_charge_curve_tests, "Find charge curve tests", False),
("energydataservice", test_energydataservice, "Energy data service tests", False),
("saving_session", test_saving_session, "Saving session tests", False),
Expand Down Expand Up @@ -617,7 +619,7 @@ def main():
("ohme_switch_max_charge_off", test_ohme_switch_event_handler_max_charge_off, "Ohme switch_event_handler max_charge off", False),
("ohme_switch_approve_charge", test_ohme_switch_event_handler_approve_charge, "Ohme switch_event_handler approve_charge", False),
("ohme_switch_approve_wrong_status", test_ohme_switch_event_handler_approve_charge_wrong_status, "Ohme switch_event_handler approve wrong status", False),
("optimise_levels", run_optimise_levels, "Optimise levels tests", True),
("optimise_levels", run_optimise_levels_tests, "Optimise levels tests", True),
("optimise_windows", run_optimise_all_windows_tests, "Optimise all windows tests", True),
("debug_cases", run_debug_cases, "Debug case file tests", True),
("download_octopus_rates", test_octopus_download_rates_wrapper, "Test download octopus rates", False),
Expand Down
59 changes: 23 additions & 36 deletions apps/predbat/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,36 +831,6 @@ def str2time(str):
return tdata


def get_curve_value(curve, key, default=1.0):
"""
Get a value from a battery power curve dictionary.
Supports both integer and string keys for compatibility with YAML configurations.

Args:
curve: Dictionary containing the power curve (e.g., battery_charge_power_curve)
key: Integer SOC percentage to look up
default: Default value if key not found (default: 1.0)

Returns:
The curve value at the given SOC percentage, or default if not found

Note:
This function handles both integer keys (100, 99, 98) and string keys ("100", "99", "98")
to support YAML configurations that require string keys for encryption (e.g., SOPS).
"""
# Try integer key first (most common case)
if key in curve:
return curve[key]

# Try string key for YAML configs with string-based keys
str_key = str(key)
if str_key in curve:
return curve[str_key]

# Return default if neither found
return default


def calc_percent_limit(charge_limit, soc_max):
"""
Calculate a charge limit in percent
Expand Down Expand Up @@ -974,18 +944,35 @@ def get_discharge_rate_curve(soc, discharge_rate_setting, soc_max, battery_rate_
return max(min(discharge_rate_setting, max_discharge_rate), battery_rate_min)


"""
Get value from curve with integer or string index
"""


def get_curve_value(curve, index, default=1.0):
Comment on lines +947 to +952
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for get_curve_value should use a proper docstring format (triple-quoted string inside the function) instead of a comment above the function. This is inconsistent with the documentation style used throughout the rest of utils.py (e.g., find_battery_temperature_cap, get_charge_rate_curve, calc_percent_limit). The docstring should include Args and Returns sections explaining the parameters and return value.

Suggested change
"""
Get value from curve with integer or string index
"""
def get_curve_value(curve, index, default=1.0):
def get_curve_value(curve, index, default=1.0):
"""
Get a value from a curve mapping using either an integer or string index.
Args:
curve: Mapping of curve points keyed by index (typically int or str).
index: Index to look up in the curve. Both the raw value and its string
representation will be tried as keys.
default: Value to return if neither the index nor its string form exist
in the curve.
Returns:
The value from the curve corresponding to the given index, or ``default``
if no matching key is found.
"""

Copilot uses AI. Check for mistakes.
if index in curve:
return curve[index]
Comment on lines +948 to +954
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_curve_value function is defined after it is used. Lines 919 and 939 call get_curve_value, but the function is not defined until line 948. This will cause a NameError at runtime when get_charge_rate_curve or get_discharge_rate_curve is called. The function should be defined before these calling functions, not after them.

Copilot uses AI. Check for mistakes.
elif str(index) in curve:
Comment on lines +953 to +955
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This instance of list is unhashable.
This instance of list is unhashable.

Suggested change
if index in curve:
return curve[index]
elif str(index) in curve:
try:
if index in curve:
return curve[index]
except TypeError:
# Unhashable index (e.g. list); fall back to string/default handling
pass
if str(index) in curve:

Copilot uses AI. Check for mistakes.
return curve[str(index)]
else:
return default


def find_battery_temperature_cap(battery_temperature, battery_temperature_curve, soc_max, max_rate):
"""
Find the battery temperature cap
"""
battery_temperature_idx = min(battery_temperature, 20)
battery_temperature_idx = max(battery_temperature_idx, -20)
if battery_temperature_idx in battery_temperature_curve:
battery_temperature_adjust = battery_temperature_curve[battery_temperature_idx]
elif battery_temperature_idx > 0:
battery_temperature_adjust = battery_temperature_curve.get(20, 1.0)
else:
battery_temperature_adjust = battery_temperature_curve.get(0, 1.0)
battery_temperature_idx = int(battery_temperature_idx) # Convert to int for proper key matching
# Try to get the temperature adjustment from the curve (handles both int and string keys)
battery_temperature_adjust = get_curve_value(battery_temperature_curve, battery_temperature_idx, None)
if battery_temperature_adjust is None:
# If not found, try fallback values
if battery_temperature_idx > 0:
battery_temperature_adjust = get_curve_value(battery_temperature_curve, 20, 1.0)
else:
battery_temperature_adjust = get_curve_value(battery_temperature_curve, 0, 1.0)
battery_temperature_rate_cap = soc_max * battery_temperature_adjust / 60.0

return min(battery_temperature_rate_cap, max_rate)
Expand Down