Skip to content

Commit a787689

Browse files
Fix for fox inverter, data gap filling and for web override at current time (#2725)
* Web config form fix * Fox fixes Fix Web UI override issue with now time Data fill issues * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 2d862f8 commit a787689

4 files changed

Lines changed: 186 additions & 12 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,10 @@ def previous_days_modal_filter(self, data):
262262
use_days = max(min(days, self.load_minutes_age), 1)
263263
num_gaps = 0
264264
full_days = 24 * 60 * (use_days - 1)
265-
for minute in range(0, 24 * 60, PREDICT_STEP):
265+
for minute in range(0, 24 * 60):
266266
minute_previous = 24 * 60 - minute + full_days
267267
if data.get(minute_previous, 0) == data.get(minute_previous + gap_size, 0):
268-
num_gaps += PREDICT_STEP
268+
num_gaps += 1
269269

270270
# If we have some gaps
271271
if num_gaps > 0:
@@ -280,14 +280,17 @@ def previous_days_modal_filter(self, data):
280280

281281
# Do the filling
282282
per_minute_increment = average_day / (24 * 60)
283-
for minute in range(0, 24 * 60, PREDICT_STEP):
283+
for minute in range(0, 24 * 60):
284284
minute_previous = 24 * 60 - minute + full_days
285285
if data.get(minute_previous, 0) == data.get(minute_previous + gap_size, 0):
286-
for offset in range(minute_previous, 0, -1):
286+
total_to_add = 0
287+
for offset in range(minute_previous + gap_size, -1, -1):
287288
if offset in data:
288-
data[offset] += per_minute_increment * PREDICT_STEP
289+
data[offset] += total_to_add
289290
else:
290-
data[offset] = per_minute_increment * PREDICT_STEP
291+
data[offset] = total_to_add
292+
if offset > minute_previous:
293+
total_to_add += per_minute_increment
291294

292295
def get_historical_base(self, data, minute, base_minutes):
293296
"""

‎apps/predbat/fox.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,7 @@ async def write_battery_schedule_event(self, entity_id, value):
10711071
end_hour, end_minute = self.time_string_to_hour_minute(end_time, 0, 0)
10721072
minSocOnGrid = self.device_settings.get(serial, {}).get("MinSocOnGrid", {}).get("value", 10)
10731073

1074+
"""
10741075
start_minutes = start_hour * 60 + start_minute
10751076
end_minutes = end_hour * 60 + end_minute
10761077
minutes_now = datetime.now().hour * 60 + datetime.now().minute
@@ -1079,6 +1080,7 @@ async def write_battery_schedule_event(self, entity_id, value):
10791080
start_minutes = minutes_now + 1
10801081
start_hour = start_minutes // 60
10811082
start_minute = start_minutes % 60
1083+
"""
10821084

10831085
if direction == "charge":
10841086
new_schedule.append(
@@ -1144,7 +1146,7 @@ async def automatic_config(self):
11441146
self.base.args["load_power"] = [f"sensor.predbat_fox_{device}_loadspower" for device in batteries]
11451147
self.base.args["soc_percent"] = [f"sensor.predbat_fox_{device}_soc" for device in batteries]
11461148
self.base.args["soc_max"] = [f"sensor.predbat_fox_{device}_battery_capacity" for device in batteries]
1147-
self.base.args["reserve"] = [f"sensor.predbat_fox_{device}_battery_minsocongrid" for device in batteries]
1149+
self.base.args["reserve"] = [f"number.predbat_fox_{device}_setting_minsocongrid" for device in batteries]
11481150
self.base.args["battery_min_soc"] = [f"sensor.predbat_fox_{device}_battery_reserve_min" for device in batteries]
11491151
self.base.args["charge_start_time"] = [f"select.predbat_fox_{device}_battery_schedule_charge_start_time" for device in batteries]
11501152
self.base.args["charge_end_time"] = [f"select.predbat_fox_{device}_battery_schedule_charge_end_time" for device in batteries]

‎apps/predbat/unit_test.py‎

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from prediction import wrapped_run_prediction_single
4444
from utils import calc_percent_limit, remove_intersecting_windows, find_charge_rate, dp2, dp4
4545
from futurerate import FutureRate
46-
from config import MINUTE_WATT, INVERTER_MAX_RETRY_REST
46+
from config import MINUTE_WATT, INVERTER_MAX_RETRY_REST, PREDICT_STEP
4747
from inverter import Inverter
4848
from compare import Compare
4949
from gecloud import GECloudDirect
@@ -9134,6 +9134,163 @@ def run_test_units(my_predbat):
91349134
return failed
91359135

91369136

9137+
def add_incrementing_sensor_total(data):
9138+
max_entry = max(data.keys()) if data else 0
9139+
total = 0
9140+
for minute in range(PREDICT_STEP, max_entry, PREDICT_STEP):
9141+
increment = max(data.get(minute, 0) - data.get(minute + PREDICT_STEP, 0), 0)
9142+
total += increment
9143+
return total
9144+
9145+
9146+
def test_previous_days_modal_filter(my_predbat):
9147+
"""
9148+
Test the previous_days_modal_filter function
9149+
"""
9150+
print("**** Running previous_days_modal_filter tests ****")
9151+
failed = False
9152+
9153+
# Set up test environment
9154+
my_predbat.load_minutes_age = 7 # 7 days of data
9155+
my_predbat.days_previous = [1, 2] # Test with 3 days
9156+
my_predbat.days_previous_weight = [1.0, 1.0] # Equal weighting
9157+
number_of_days = 2
9158+
my_predbat.load_filter_modal = True # Enable modal filtering
9159+
my_predbat.car_charging_hold = False
9160+
my_predbat.car_charging_energy = None
9161+
my_predbat.iboost_energy_subtract = False
9162+
my_predbat.iboost_energy_today = None
9163+
my_predbat.base_load = 0.0
9164+
9165+
# Mock the get_arg method
9166+
original_get_arg = my_predbat.get_arg
9167+
9168+
def mock_get_arg(key, default=None):
9169+
if key == "load_filter_threshold":
9170+
return 30
9171+
return original_get_arg(key, default)
9172+
9173+
my_predbat.get_arg = mock_get_arg
9174+
9175+
# Test 1: Empty data set - should be filled with gap filling logic
9176+
print("Test 1: Empty data set")
9177+
test_data = {}
9178+
9179+
# Call the function
9180+
my_predbat.previous_days_modal_filter(test_data)
9181+
data_length = len(test_data)
9182+
data_length_days = data_length / (24 * 60)
9183+
print("Data length after processing: {} minutes ({} days)".format(data_length, data_length_days))
9184+
9185+
# After gap filling, data should have been populated for the empty gaps
9186+
# Check that data has been filled for at least some minutes
9187+
total_filled_data = dp2(add_incrementing_sensor_total(test_data))
9188+
expected_total_per_day = 24.0 # 24 kWh per day as default when no data
9189+
9190+
print("Total filled data: {} kWh".format(dp2(total_filled_data)))
9191+
9192+
# With 2 days and complete gaps, should use 24kWh default for each day
9193+
if total_filled_data != expected_total_per_day * number_of_days:
9194+
print("ERROR: Expected gap filling to add approximately {} kWh, got {} kWh".format(expected_total_per_day * number_of_days, total_filled_data))
9195+
for minute in range(0, data_length, 30):
9196+
print(" Minute {}: {}".format(minute, test_data.get(minute, 0)))
9197+
failed = True
9198+
9199+
# Test 2: Data with gaps - should be filled to create 1 kWh per hour pattern
9200+
print("Test 2: Data with some gaps")
9201+
test_data = {}
9202+
9203+
# Create partial data for 1 day (1 kWh per hour = incrementing total)
9204+
# PREDICT_STEP is 5 minutes, so 12 steps per hour
9205+
# Each step should increment by 1/12 kWh to get 1 kWh per hour
9206+
step_increment = 1.0 / 60
9207+
running_total = 0
9208+
9209+
# Fill first half of day with proper incremental data
9210+
for minute in range(0, 12 * 60): # 12 hours worth
9211+
running_total += step_increment
9212+
test_data[24 * 60 - minute] = running_total # Backwards indexing as used in function
9213+
9214+
# Leave second half empty to test gap filling
9215+
9216+
# Set up days_previous for this test
9217+
my_predbat.days_previous = [1] # Only test with 1 day
9218+
my_predbat.days_previous_weight = [1.0]
9219+
9220+
initial_data_sum = dp2(add_incrementing_sensor_total(test_data))
9221+
print("Initial partial data sum: {} kWh".format(dp2(initial_data_sum)))
9222+
9223+
# Call the function
9224+
my_predbat.previous_days_modal_filter(test_data)
9225+
9226+
final_data_sum = dp2(add_incrementing_sensor_total(test_data))
9227+
print("Final data sum after gap filling: {} kWh".format(dp2(final_data_sum)))
9228+
9229+
# Should now have approximately 24 kWh total (1 kWh per hour for 24 hours)
9230+
expected_final_total = 24.0
9231+
if abs(final_data_sum - expected_final_total) > 2.0: # Allow 2 kWh tolerance
9232+
print("ERROR: Expected final total around {} kWh, got {} kWh".format(expected_final_total, final_data_sum))
9233+
failed = True
9234+
else:
9235+
print("Gap filling successful: filled from {} kWh to {} kWh".format(dp2(initial_data_sum), dp2(final_data_sum)))
9236+
9237+
# Test 3: Modal filtering - remove lowest consumption day
9238+
print("Test 3: Modal filtering removes lowest day")
9239+
9240+
# Reset for modal filter test
9241+
my_predbat.days_previous = [1, 2, 3]
9242+
my_predbat.days_previous_weight = [1.0, 1.0, 1.0]
9243+
original_days_count = len(my_predbat.days_previous)
9244+
9245+
# Create test data with different consumption per day
9246+
test_data = {}
9247+
9248+
# Day 1: Low consumption (10 kWh total)
9249+
day1_total = 10.0
9250+
step_increment_day1 = day1_total / (24 * 60)
9251+
running_total = 0
9252+
for minute in range(0, 24 * 60):
9253+
running_total += step_increment_day1
9254+
test_data[24 * 60 - minute] = running_total
9255+
9256+
# Day 2: Medium consumption (20 kWh total)
9257+
day2_total = 20.0
9258+
step_increment_day2 = day2_total / (24 * 60)
9259+
running_total = 0
9260+
for minute in range(0, 24 * 60):
9261+
running_total += step_increment_day2
9262+
test_data[2 * 24 * 60 - minute] = running_total
9263+
9264+
# Day 3: High consumption (30 kWh total)
9265+
day3_total = 30.0
9266+
step_increment_day3 = day3_total / (24 * 60)
9267+
running_total = 0
9268+
for minute in range(0, 24 * 60):
9269+
running_total += step_increment_day3
9270+
test_data[3 * 24 * 60 - minute] = running_total
9271+
9272+
print("Created test data with day totals: {} kWh, {} kWh, {} kWh".format(day1_total, day2_total, day3_total))
9273+
9274+
# Call the function - should remove day 1 (lowest consumption)
9275+
my_predbat.previous_days_modal_filter(test_data)
9276+
9277+
# Check that one day was removed
9278+
final_days_count = len(my_predbat.days_previous)
9279+
if final_days_count != original_days_count - 1:
9280+
print("ERROR: Expected modal filter to remove 1 day, had {} days, now have {} days".format(original_days_count, final_days_count))
9281+
failed = True
9282+
elif 1 in my_predbat.days_previous:
9283+
print("ERROR: Expected modal filter to remove day 1 (lowest consumption), but it's still present")
9284+
failed = True
9285+
else:
9286+
print("Modal filter correctly removed lowest consumption day")
9287+
9288+
# Restore original get_arg method
9289+
my_predbat.get_arg = original_get_arg
9290+
9291+
return failed
9292+
9293+
91379294
def test_download_octopus_url_wrapper(my_predbat):
91389295
"""
91399296
Wrapper to run the async test function
@@ -9223,11 +9380,12 @@ def main():
92239380
if args.perf_only:
92249381
return failed
92259382

9383+
if not failed:
9384+
failed |= test_previous_days_modal_filter(my_predbat)
92269385
if not failed:
92279386
failed |= test_download_octopus_url_wrapper(my_predbat)
92289387
if not failed:
92299388
failed |= test_plugin_startup_order(my_predbat)
9230-
92319389
if not failed:
92329390
failed |= test_minute_data(my_predbat)
92339391
if not failed:

‎apps/predbat/web.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,7 @@ async def html_config_post(self, request):
14771477
elif pitem.startswith("input_number"):
14781478
new_value = float(new_value)
14791479

1480+
self.log("Web interface setting {} to {}".format(pitem, new_value))
14801481
await self.base.ha_interface.set_state_external(pitem, new_value)
14811482

14821483
raise web.HTTPFound("./config")
@@ -2205,17 +2206,21 @@ async def html_config(self, request):
22052206
text += f"</form></td>\n"
22062207
elif itemtype == "input_number":
22072208
input_number_with_save = input_number.replace('onchange="javascript: this.form.submit();"', 'onchange="saveFilterValue(); this.form.submit();"')
2208-
text += "<td>{}</td>\n".format(input_number_with_save.format(useid, useid, value, item.get("min", 0), item.get("max", 100), item.get("step", 1)))
2209+
text += '<td><form style="display: inline;" method="post" action="./config">'
2210+
text += "{}\n".format(input_number_with_save.format(useid, useid, value, item.get("min", 0), item.get("max", 100), item.get("step", 1)))
2211+
text += f"</form></td>\n"
22092212
elif itemtype == "select":
22102213
options = item.get("options", [])
22112214
if value not in options:
22122215
options.append(value)
2213-
text += '<td><select name="{}" id="{}" onchange="saveFilterValue(); this.form.submit();">'.format(useid, useid)
2216+
text += f'<td><form style="display: inline;" method="post" action="./config">'
2217+
text += '<select name="{}" id="{}" onchange="saveFilterValue(); this.form.submit();">'.format(useid, useid)
22142218
for option in options:
22152219
selected = option == value
22162220
option_label = option if option else "None"
22172221
text += '<option value="{}" label="{}" {}>{}</option>'.format(option, option_label, "selected" if selected else "", option)
2218-
text += "</select></td>\n"
2222+
text += "</select>\n"
2223+
text += f"</form></td>\n"
22192224
else:
22202225
text += "<td>{}</td>\n".format(value)
22212226

@@ -2642,6 +2647,12 @@ def get_override_time_from_string(self, time_str):
26422647
if add_days < 0:
26432648
add_days += 7
26442649
override_time += timedelta(days=add_days)
2650+
2651+
# Ensure minutes are either 0 or 30
2652+
if override_time.minute >= 30:
2653+
override_time = override_time.replace(minute=30)
2654+
else:
2655+
override_time = override_time.replace(minute=0)
26452656
return override_time
26462657

26472658
async def html_rate_override(self, request):

0 commit comments

Comments
 (0)