Skip to content

Commit eb5195b

Browse files
Merge pull request #4068 from springfall2008/fix/misc
Reset GECloud defaults after 24 hours, Add 100w base load default, catch crash with MinuteData array
2 parents e73c506 + f15003e commit eb5195b

9 files changed

Lines changed: 114 additions & 20 deletions

File tree

apps/predbat/fetch.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ def previous_days_modal_filter(self, data):
457457
self.log("Gap starting at {} ({} minutes) for {} minutes".format(gap_start_timestamp.strftime(TIME_FORMAT), gap_start, gap_length))
458458

459459
# Do the filling
460+
len_data = len(data) if isinstance(data, MinuteArray) else 99999999
460461
for gap in gap_list:
461462
gap_start_minute_previous = gap[0]
462463
gap_minutes = gap[1]
@@ -467,7 +468,7 @@ def previous_days_modal_filter(self, data):
467468
# gap_start_minute_previous is the highest index (earliest in gap)
468469
# We fill from there down to the end of the gap
469470

470-
minute_previous = gap_end_minute_previous
471+
minute_previous = min(gap_end_minute_previous, len_data - 1)
471472
gap_day = None
472473
while minute_previous > gap_start_minute_previous and minute_previous >= 0:
473474
# Change of day?
@@ -2184,7 +2185,7 @@ def fetch_config_options(self):
21842185
self.inverter_loss = 1.0 - self.get_arg("inverter_loss")
21852186
self.inverter_hybrid = self.get_arg("inverter_hybrid")
21862187
self.pv_ac_limit = self.get_arg("pv_ac_limit", 0.0) / MINUTE_WATT
2187-
self.base_load = self.get_arg("base_load", 0) / 1000.0
2188+
self.base_load = self.get_arg("base_load", 100) / 1000.0
21882189

21892190
# Charge curve
21902191
if self.args.get("battery_charge_power_curve", "") == "auto":

apps/predbat/gecloud.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ def initialize(self, ge_cloud_direct, api_key, automatic):
248248
self.evc_devices_dict = {}
249249
self.evc_device_list = []
250250
self.settings_from_cache = False
251-
self.default_options_done = False
251+
self.default_options_stamp = None
252252

253253
# API request metrics for monitoring
254254
self.requests_total = 0
@@ -1093,8 +1093,10 @@ async def run(self, seconds, first):
10931093
if self.automatic:
10941094
await self.async_automatic_config(self.devices_dict)
10951095

1096-
if not self.default_options_done and self.get_state_wrapper(f"switch.{self.prefix}_set_read_only", default="off") != "on":
1097-
self.default_options_done = True
1096+
now_utc = self.now_utc_exact
1097+
options_due = self.default_options_stamp is None or (now_utc - self.default_options_stamp) >= timedelta(hours=24)
1098+
if options_due and self.get_state_wrapper(f"switch.{self.prefix}_set_read_only", default="off") != "on":
1099+
self.default_options_stamp = now_utc
10981100
for device in self.device_list:
10991101
await self.enable_default_options(device, self.settings[device])
11001102

apps/predbat/predbat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import requests
3737
import asyncio
3838

39-
THIS_VERSION = "v8.40.8"
39+
THIS_VERSION = "v8.40.9"
4040

4141
from download import predbat_update_move, predbat_update_download, check_install, resolve_predbat_repository, DEFAULT_PREDBAT_REPOSITORY
4242
from const import MINUTE_WATT

apps/predbat/tests/test_ge_cloud.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def __init__(self):
5858
self.gateway_device = None
5959
self._now_utc_exact = datetime.now(timezone.utc)
6060
self.settings_from_cache = False
61-
self.default_options_done = False
61+
self.default_options_stamp = None
6262
self._read_only = False
6363

6464
class MockHAInterface:
@@ -239,6 +239,7 @@ def test_ge_cloud(my_predbat=None):
239239
("enable_defaults", _test_enable_default_options, "Enable default options"),
240240
("enable_defaults_read_only", _test_run_read_only_skips_reset, "Enable defaults skipped in read-only mode"),
241241
("enable_defaults_after_read_only", _test_run_enables_reset_after_read_only, "Enable defaults on first non-read-only run"),
242+
("enable_defaults_24h", _test_run_enables_reset_after_24h, "Enable defaults re-runs after 24 hours"),
242243
("download_single", _test_download_ge_data_single_day, "Download single day"),
243244
("download_multi", _test_download_ge_data_multi_day, "Download multi-day"),
244245
("download_pagination", _test_download_ge_data_pagination, "Download pagination"),
@@ -3646,8 +3647,8 @@ async def test():
36463647
print("ERROR: enable_default_options should NOT be called in read-only mode, got calls for: {}".format(enable_default_calls))
36473648
return 1
36483649

3649-
if ge_cloud.default_options_done:
3650-
print("ERROR: default_options_done should remain False when skipped due to read-only mode")
3650+
if ge_cloud.default_options_stamp is not None:
3651+
print("ERROR: default_options_stamp should remain None when skipped due to read-only mode")
36513652
return 1
36523653

36533654
return 0
@@ -3675,8 +3676,8 @@ async def test():
36753676
if enable_default_calls:
36763677
print("ERROR: enable_default_options should NOT be called in read-only mode, got: {}".format(enable_default_calls))
36773678
return 1
3678-
if ge_cloud.default_options_done:
3679-
print("ERROR: default_options_done should be False after read-only first run")
3679+
if ge_cloud.default_options_stamp is not None:
3680+
print("ERROR: default_options_stamp should be None after read-only first run")
36803681
return 1
36813682

36823683
# Disable read-only — next 10-minute settings tick should trigger the reset
@@ -3692,15 +3693,62 @@ async def test():
36923693
if enable_default_calls != ["inv001"]:
36933694
print("ERROR: Expected enable_default_options called for inv001, got: {}".format(enable_default_calls))
36943695
return 1
3695-
if not ge_cloud.default_options_done:
3696-
print("ERROR: default_options_done should be True after reset ran")
3696+
if ge_cloud.default_options_stamp is None:
3697+
print("ERROR: default_options_stamp should be set after reset ran")
36973698
return 1
36983699

3699-
# Verify the reset does not run again on subsequent ticks
3700+
# Verify the reset does not run again on subsequent ticks within 24 hours
37003701
enable_default_calls.clear()
37013702
await ge_cloud.run(seconds=1200, first=False)
37023703
if enable_default_calls:
3703-
print("ERROR: enable_default_options should not be called again after default_options_done=True")
3704+
print("ERROR: enable_default_options should not be called again within 24 hours")
3705+
return 1
3706+
3707+
return 0
3708+
3709+
return run_async(test())
3710+
3711+
3712+
def _test_run_enables_reset_after_24h(my_predbat):
3713+
"""enable_default_options re-runs after 24 hours have elapsed"""
3714+
3715+
async def test():
3716+
ge_cloud = MockGECloudDirect()
3717+
ge_cloud.automatic = False
3718+
ge_cloud._read_only = False
3719+
3720+
enable_default_calls = []
3721+
_make_run_mocks(ge_cloud, enable_default_calls)
3722+
3723+
# First run — should call enable_default_options
3724+
result = await ge_cloud.run(seconds=0, first=True)
3725+
if not result:
3726+
print("ERROR: run() should return True on first run")
3727+
return 1
3728+
if not enable_default_calls:
3729+
print("ERROR: enable_default_options should be called on first run")
3730+
return 1
3731+
3732+
# Subsequent run within 24 hours — should NOT call again
3733+
enable_default_calls.clear()
3734+
ge_cloud._now_utc_exact = ge_cloud.default_options_stamp + timedelta(hours=23, minutes=59)
3735+
await ge_cloud.run(seconds=600, first=False)
3736+
if enable_default_calls:
3737+
print("ERROR: enable_default_options should not be called again within 24 hours")
3738+
return 1
3739+
3740+
# Run after 24 hours have elapsed — should call again
3741+
enable_default_calls.clear()
3742+
ge_cloud._now_utc_exact = ge_cloud.default_options_stamp + timedelta(hours=24)
3743+
result = await ge_cloud.run(seconds=1200, first=False)
3744+
if not result:
3745+
print("ERROR: run() should return True on 24h run")
3746+
return 1
3747+
if not enable_default_calls:
3748+
print("ERROR: enable_default_options should be called again after 24 hours")
3749+
return 1
3750+
if enable_default_calls != ["inv001"]:
3751+
print("ERROR: Expected enable_default_options called for inv001, got: {}".format(enable_default_calls))
37043752
return 1
37053753

37063754
return 0

apps/predbat/tests/test_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ def run_model_tests(my_predbat):
561561
failed |= simple_scenario("battery_charge", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10)
562562

563563
failed |= simple_scenario("battery_charge_low_off", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=False, keep=5, assert_keep=24.59)
564-
failed |= simple_scenario("battery_charge_low_on", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=True, keep=5, assert_keep=88.89)
564+
failed |= simple_scenario("battery_charge_low_on", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=True, keep=5, assert_keep=88.8947)
565565
failed |= simple_scenario(
566566
"battery_charge_low_on_monitor", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=True, keep=5, assert_keep=24.59, set_charge_window=False
567567
)
@@ -573,7 +573,7 @@ def run_model_tests(my_predbat):
573573
"battery_charge_low_temp2", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=False, keep=5, assert_keep=80.00, battery_temperature=1
574574
)
575575
failed |= simple_scenario(
576-
"battery_charge_low_temp3", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=True, keep=5, assert_keep=88.89, battery_temperature=1
576+
"battery_charge_low_temp3", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=True, keep=5, assert_keep=88.8947, battery_temperature=1
577577
)
578578

579579
if failed:

apps/predbat/tests/test_previous_days_modal.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# pylint: disable=line-too-long
99
# pylint: disable=attribute-defined-outside-init
1010

11-
from utils import dp2, dp4
11+
from utils import dp2, dp4, MinuteArray
1212
from const import PREDICT_STEP
1313

1414

@@ -331,6 +331,47 @@ def mock_get_arg(key, default=None):
331331
else:
332332
print("Partial day (<16h gaps) correctly filled from {} kWh to {} kWh (proportional)".format(dp2(initial_day2_total), dp2(day2_filled_total)))
333333

334+
# Test 6: MinuteArray with a gap whose end falls beyond the array boundary
335+
# Regression test for IndexError: array assignment index out of range.
336+
# The gap-detection loop checks minute_previous + PREDICT_STEP beyond max_minute,
337+
# so a gap near the end of the array gets an end index that exceeds len(array).
338+
print("Test 6: MinuteArray gap whose end overshoots array boundary (regression)")
339+
340+
my_predbat.days_previous = [1, 2]
341+
my_predbat.days_previous_weight = [1.0, 1.0]
342+
my_predbat.load_minutes_age = 2
343+
my_predbat.load_filter_modal = False
344+
345+
# Build 2 days of data as a plain dict, with a gap at the very end of day 2
346+
# (i.e. near index max(data.keys())) so the gap-detection look-ahead lands
347+
# one PREDICT_STEP beyond the array boundary.
348+
dict_data = {}
349+
day_kwh = 20.0
350+
step_inc = day_kwh / (24 * 60)
351+
for day in range(1, 3):
352+
running_total = 0
353+
for minute in range(0, 24 * 60):
354+
running_total += step_inc
355+
dict_data[day * 24 * 60 - minute] = dp4(running_total)
356+
357+
# Erase the last PREDICT_STEP worth of entries in day 2 to create a trailing gap
358+
# that the look-ahead will extend one step beyond the array end.
359+
max_key = max(dict_data.keys())
360+
for k in range(max_key - PREDICT_STEP * 10, max_key + 1):
361+
dict_data.pop(k, None)
362+
363+
# Convert to MinuteArray sized exactly to the remaining max key + 2,
364+
# mirroring what minute_data_load does (pad=False path).
365+
size = max(dict_data.keys()) + 2
366+
minute_array = MinuteArray(dict_data, size)
367+
368+
try:
369+
my_predbat.previous_days_modal_filter(minute_array)
370+
print("MinuteArray boundary gap filled without error")
371+
except IndexError as exc:
372+
print("ERROR: IndexError raised during MinuteArray gap filling: {}".format(exc))
373+
failed = True
374+
334375
# Restore original get_arg method
335376
my_predbat.get_arg = original_get_arg
336377

apps/predbat/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,7 @@ def find_charge_rate(
11441144
# Real achieved max rate
11451145
max_rate_real = get_charge_rate_curve_cached(round(soc, 1), max_rate, soc_max, max_rate, battery_charge_power_curve_tuple, battery_rate_min, battery_temperature, battery_temperature_curve_tuple) * battery_rate_max_scaling
11461146

1147+
min_battery_rate = max(400, int(round(battery_rate_min * MINUTE_WATT)))
11471148
if set_charge_low_power:
11481149
minutes_left = window["end"] - minutes_now - margin
11491150
abs_minutes_left = window["end"] - minutes_now
@@ -1190,7 +1191,7 @@ def find_charge_rate(
11901191
)
11911192
)
11921193

1193-
while rate_w >= 400:
1194+
while rate_w >= min_battery_rate:
11941195
rate = rate_w / MINUTE_WATT
11951196
if rate_w >= min_rate_w:
11961197
charge_now = soc

docs/apps-yaml.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1698,7 +1698,7 @@ weirdness you may have from your inverter and battery setup.
16981698
Sometimes the load predictions can yield near zero data due to inaccuracy of data (e.g. a second PV system not tracked, car data being unreliable, poor sensors).
16991699
In order to not get unrealistically low values you can set a base load value (in watts) which Predbat will use as a minimum load for a slot duration.
17001700

1701-
To set a base load set **base_load** as an integer value in watts.
1701+
To set a base load set **base_load** as an integer value in watts. The default is 100 watts if not specified.
17021702

17031703
```yaml
17041704
base_load: 300

docs/customisation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ Set the list of [devices to notify](apps-yaml.md#notify_devices) in `apps.yaml`.
369369
lowest possible rate to meet the charge target. This is only really effective for charge windows longer than a single slot.
370370
If this setting is turned on, it is strongly recommended that you create a [battery_power_charge_curve in apps.yaml](apps-yaml.md#battery-chargedischarge-curves)
371371
as otherwise the low power charge may not reach the charge target in time.
372+
The minimum requested charge rate used in this mode is 400 watts (subject to inverter/battery minimum rate limits).
372373
This setting is off by default.
373374

374375
The YouTube video [low power charging and charging curve](https://youtu.be/L2vY_Vj6pQg?si=0ZiIVrDLHkeDCx7h)

0 commit comments

Comments
 (0)