From fb9aaf4c0e427ef8c147c7d3377f2a9304dbb264 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 5 Oct 2025 13:23:16 +0100 Subject: [PATCH 1/3] Web config form fix --- apps/predbat/web.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 465c5d465..005cdf62b 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -1477,6 +1477,7 @@ async def html_config_post(self, request): elif pitem.startswith("input_number"): new_value = float(new_value) + self.log("Web interface setting {} to {}".format(pitem, new_value)) await self.base.ha_interface.set_state_external(pitem, new_value) raise web.HTTPFound("./config") @@ -2205,17 +2206,21 @@ async def html_config(self, request): text += f"\n" elif itemtype == "input_number": input_number_with_save = input_number.replace('onchange="javascript: this.form.submit();"', 'onchange="saveFilterValue(); this.form.submit();"') - text += "{}\n".format(input_number_with_save.format(useid, useid, value, item.get("min", 0), item.get("max", 100), item.get("step", 1))) + text += '
' + text += "{}\n".format(input_number_with_save.format(useid, useid, value, item.get("min", 0), item.get("max", 100), item.get("step", 1))) + text += f"
\n" elif itemtype == "select": options = item.get("options", []) if value not in options: options.append(value) - text += ''.format(useid, useid) for option in options: selected = option == value option_label = option if option else "None" text += ''.format(option, option_label, "selected" if selected else "", option) - text += "\n" + text += "\n" + text += f"\n" else: text += "{}\n".format(value) From 3e645b2ef97b105740e2eac76519279b95172199 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 5 Oct 2025 15:36:44 +0100 Subject: [PATCH 2/3] Fox fixes Fix Web UI override issue with now time Data fill issues --- apps/predbat/fetch.py | 15 ++-- apps/predbat/fox.py | 4 +- apps/predbat/unit_test.py | 158 +++++++++++++++++++++++++++++++++++++- apps/predbat/web.py | 6 ++ 4 files changed, 174 insertions(+), 9 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index c716621db..7d31973a8 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -262,10 +262,10 @@ def previous_days_modal_filter(self, data): use_days = max(min(days, self.load_minutes_age), 1) num_gaps = 0 full_days = 24 * 60 * (use_days - 1) - for minute in range(0, 24 * 60, PREDICT_STEP): + for minute in range(0, 24 * 60): minute_previous = 24 * 60 - minute + full_days if data.get(minute_previous, 0) == data.get(minute_previous + gap_size, 0): - num_gaps += PREDICT_STEP + num_gaps += 1 # If we have some gaps if num_gaps > 0: @@ -280,14 +280,17 @@ def previous_days_modal_filter(self, data): # Do the filling per_minute_increment = average_day / (24 * 60) - for minute in range(0, 24 * 60, PREDICT_STEP): + for minute in range(0, 24 * 60): minute_previous = 24 * 60 - minute + full_days if data.get(minute_previous, 0) == data.get(minute_previous + gap_size, 0): - for offset in range(minute_previous, 0, -1): + total_to_add = 0 + for offset in range(minute_previous + gap_size, -1, -1): if offset in data: - data[offset] += per_minute_increment * PREDICT_STEP + data[offset] += total_to_add else: - data[offset] = per_minute_increment * PREDICT_STEP + data[offset] = total_to_add + if offset > minute_previous: + total_to_add += per_minute_increment def get_historical_base(self, data, minute, base_minutes): """ diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 75b8665a4..03280ba88 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -1071,6 +1071,7 @@ async def write_battery_schedule_event(self, entity_id, value): end_hour, end_minute = self.time_string_to_hour_minute(end_time, 0, 0) minSocOnGrid = self.device_settings.get(serial, {}).get("MinSocOnGrid", {}).get("value", 10) + """ start_minutes = start_hour * 60 + start_minute end_minutes = end_hour * 60 + end_minute minutes_now = datetime.now().hour * 60 + datetime.now().minute @@ -1079,6 +1080,7 @@ async def write_battery_schedule_event(self, entity_id, value): start_minutes = minutes_now + 1 start_hour = start_minutes // 60 start_minute = start_minutes % 60 + """ if direction == "charge": new_schedule.append( @@ -1144,7 +1146,7 @@ async def automatic_config(self): self.base.args["load_power"] = [f"sensor.predbat_fox_{device}_loadspower" for device in batteries] self.base.args["soc_percent"] = [f"sensor.predbat_fox_{device}_soc" for device in batteries] self.base.args["soc_max"] = [f"sensor.predbat_fox_{device}_battery_capacity" for device in batteries] - self.base.args["reserve"] = [f"sensor.predbat_fox_{device}_battery_minsocongrid" for device in batteries] + self.base.args["reserve"] = [f"number.predbat_fox_{device}_setting_minsocongrid" for device in batteries] self.base.args["battery_min_soc"] = [f"sensor.predbat_fox_{device}_battery_reserve_min" for device in batteries] self.base.args["charge_start_time"] = [f"select.predbat_fox_{device}_battery_schedule_charge_start_time" for device in batteries] self.base.args["charge_end_time"] = [f"select.predbat_fox_{device}_battery_schedule_charge_end_time" for device in batteries] diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 243b4f928..8d42d0b6f 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -43,7 +43,7 @@ from prediction import wrapped_run_prediction_single from utils import calc_percent_limit, remove_intersecting_windows, find_charge_rate, dp2, dp4 from futurerate import FutureRate -from config import MINUTE_WATT, INVERTER_MAX_RETRY_REST +from config import MINUTE_WATT, INVERTER_MAX_RETRY_REST, PREDICT_STEP from inverter import Inverter from compare import Compare from gecloud import GECloudDirect @@ -9133,6 +9133,159 @@ def run_test_units(my_predbat): return failed +def add_incrementing_sensor_total(data): + max_entry = max(data.keys()) if data else 0 + total = 0 + for minute in range(PREDICT_STEP, max_entry, PREDICT_STEP): + increment = max(data.get(minute, 0) - data.get(minute + PREDICT_STEP, 0), 0) + total += increment + return total + +def test_previous_days_modal_filter(my_predbat): + """ + Test the previous_days_modal_filter function + """ + print("**** Running previous_days_modal_filter tests ****") + failed = False + + # Set up test environment + my_predbat.load_minutes_age = 7 # 7 days of data + my_predbat.days_previous = [1, 2] # Test with 3 days + my_predbat.days_previous_weight = [1.0, 1.0] # Equal weighting + number_of_days = 2 + my_predbat.load_filter_modal = True # Enable modal filtering + my_predbat.car_charging_hold = False + my_predbat.car_charging_energy = None + my_predbat.iboost_energy_subtract = False + my_predbat.iboost_energy_today = None + my_predbat.base_load = 0.0 + + # Mock the get_arg method + original_get_arg = my_predbat.get_arg + def mock_get_arg(key, default=None): + if key == "load_filter_threshold": + return 30 + return original_get_arg(key, default) + my_predbat.get_arg = mock_get_arg + + # Test 1: Empty data set - should be filled with gap filling logic + print("Test 1: Empty data set") + test_data = {} + + # Call the function + my_predbat.previous_days_modal_filter(test_data) + data_length = len(test_data) + data_length_days = data_length / (24 * 60) + print("Data length after processing: {} minutes ({} days)".format(data_length, data_length_days)) + + # After gap filling, data should have been populated for the empty gaps + # Check that data has been filled for at least some minutes + total_filled_data = dp2(add_incrementing_sensor_total(test_data)) + expected_total_per_day = 24.0 # 24 kWh per day as default when no data + + print("Total filled data: {} kWh".format(dp2(total_filled_data))) + + # With 2 days and complete gaps, should use 24kWh default for each day + if total_filled_data != expected_total_per_day * number_of_days: + print("ERROR: Expected gap filling to add approximately {} kWh, got {} kWh".format(expected_total_per_day * number_of_days, total_filled_data)) + for minute in range(0, data_length, 30): + print(" Minute {}: {}".format(minute, test_data.get(minute, 0))) + failed = True + + # Test 2: Data with gaps - should be filled to create 1 kWh per hour pattern + print("Test 2: Data with some gaps") + test_data = {} + + # Create partial data for 1 day (1 kWh per hour = incrementing total) + # PREDICT_STEP is 5 minutes, so 12 steps per hour + # Each step should increment by 1/12 kWh to get 1 kWh per hour + step_increment = 1.0 / 60 + running_total = 0 + + # Fill first half of day with proper incremental data + for minute in range(0, 12 * 60): # 12 hours worth + running_total += step_increment + test_data[24 * 60 - minute] = running_total # Backwards indexing as used in function + + # Leave second half empty to test gap filling + + # Set up days_previous for this test + my_predbat.days_previous = [1] # Only test with 1 day + my_predbat.days_previous_weight = [1.0] + + initial_data_sum = dp2(add_incrementing_sensor_total(test_data)) + print("Initial partial data sum: {} kWh".format(dp2(initial_data_sum))) + + # Call the function + my_predbat.previous_days_modal_filter(test_data) + + final_data_sum = dp2(add_incrementing_sensor_total(test_data)) + print("Final data sum after gap filling: {} kWh".format(dp2(final_data_sum))) + + # Should now have approximately 24 kWh total (1 kWh per hour for 24 hours) + expected_final_total = 24.0 + if abs(final_data_sum - expected_final_total) > 2.0: # Allow 2 kWh tolerance + print("ERROR: Expected final total around {} kWh, got {} kWh".format(expected_final_total, final_data_sum)) + failed = True + else: + print("Gap filling successful: filled from {} kWh to {} kWh".format(dp2(initial_data_sum), dp2(final_data_sum))) + + # Test 3: Modal filtering - remove lowest consumption day + print("Test 3: Modal filtering removes lowest day") + + # Reset for modal filter test + my_predbat.days_previous = [1, 2, 3] + my_predbat.days_previous_weight = [1.0, 1.0, 1.0] + original_days_count = len(my_predbat.days_previous) + + # Create test data with different consumption per day + test_data = {} + + # Day 1: Low consumption (10 kWh total) + day1_total = 10.0 + step_increment_day1 = day1_total / (24 * 60) + running_total = 0 + for minute in range(0, 24 * 60): + running_total += step_increment_day1 + test_data[24 * 60 - minute] = running_total + + # Day 2: Medium consumption (20 kWh total) + day2_total = 20.0 + step_increment_day2 = day2_total / (24 * 60) + running_total = 0 + for minute in range(0, 24 * 60): + running_total += step_increment_day2 + test_data[2 * 24 * 60 - minute] = running_total + + # Day 3: High consumption (30 kWh total) + day3_total = 30.0 + step_increment_day3 = day3_total / (24 * 60) + running_total = 0 + for minute in range(0, 24 * 60): + running_total += step_increment_day3 + test_data[3 * 24 * 60 - minute] = running_total + + print("Created test data with day totals: {} kWh, {} kWh, {} kWh".format(day1_total, day2_total, day3_total)) + + # Call the function - should remove day 1 (lowest consumption) + my_predbat.previous_days_modal_filter(test_data) + + # Check that one day was removed + final_days_count = len(my_predbat.days_previous) + if final_days_count != original_days_count - 1: + print("ERROR: Expected modal filter to remove 1 day, had {} days, now have {} days".format(original_days_count, final_days_count)) + failed = True + elif 1 in my_predbat.days_previous: + print("ERROR: Expected modal filter to remove day 1 (lowest consumption), but it's still present") + failed = True + else: + print("Modal filter correctly removed lowest consumption day") + + # Restore original get_arg method + my_predbat.get_arg = original_get_arg + + return failed + def test_download_octopus_url_wrapper(my_predbat): """ @@ -9223,11 +9376,12 @@ def main(): if args.perf_only: return failed + if not failed: + failed |= test_previous_days_modal_filter(my_predbat) if not failed: failed |= test_download_octopus_url_wrapper(my_predbat) if not failed: failed |= test_plugin_startup_order(my_predbat) - if not failed: failed |= test_minute_data(my_predbat) if not failed: diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 005cdf62b..b646b421b 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -2647,6 +2647,12 @@ def get_override_time_from_string(self, time_str): if add_days < 0: add_days += 7 override_time += timedelta(days=add_days) + + # Ensure minutes are either 0 or 30 + if override_time.minute >= 30: + override_time = override_time.replace(minute=30) + else: + override_time = override_time.replace(minute=0) return override_time async def html_rate_override(self, request): From 52f99d75bd014ac07a3a16d0de3160ba0495a566 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:39:16 +0000 Subject: [PATCH 3/3] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/unit_test.py | 54 +++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 8d42d0b6f..6e2d0ea63 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -9133,6 +9133,7 @@ def run_test_units(my_predbat): return failed + def add_incrementing_sensor_total(data): max_entry = max(data.keys()) if data else 0 total = 0 @@ -9141,13 +9142,14 @@ def add_incrementing_sensor_total(data): total += increment return total + def test_previous_days_modal_filter(my_predbat): """ Test the previous_days_modal_filter function """ print("**** Running previous_days_modal_filter tests ****") failed = False - + # Set up test environment my_predbat.load_minutes_age = 7 # 7 days of data my_predbat.days_previous = [1, 2] # Test with 3 days @@ -9159,19 +9161,21 @@ def test_previous_days_modal_filter(my_predbat): my_predbat.iboost_energy_subtract = False my_predbat.iboost_energy_today = None my_predbat.base_load = 0.0 - + # Mock the get_arg method original_get_arg = my_predbat.get_arg + def mock_get_arg(key, default=None): if key == "load_filter_threshold": return 30 return original_get_arg(key, default) + my_predbat.get_arg = mock_get_arg - + # Test 1: Empty data set - should be filled with gap filling logic print("Test 1: Empty data set") test_data = {} - + # Call the function my_predbat.previous_days_modal_filter(test_data) data_length = len(test_data) @@ -9182,7 +9186,7 @@ def mock_get_arg(key, default=None): # Check that data has been filled for at least some minutes total_filled_data = dp2(add_incrementing_sensor_total(test_data)) expected_total_per_day = 24.0 # 24 kWh per day as default when no data - + print("Total filled data: {} kWh".format(dp2(total_filled_data))) # With 2 days and complete gaps, should use 24kWh default for each day @@ -9195,33 +9199,33 @@ def mock_get_arg(key, default=None): # Test 2: Data with gaps - should be filled to create 1 kWh per hour pattern print("Test 2: Data with some gaps") test_data = {} - + # Create partial data for 1 day (1 kWh per hour = incrementing total) # PREDICT_STEP is 5 minutes, so 12 steps per hour # Each step should increment by 1/12 kWh to get 1 kWh per hour step_increment = 1.0 / 60 running_total = 0 - + # Fill first half of day with proper incremental data for minute in range(0, 12 * 60): # 12 hours worth running_total += step_increment test_data[24 * 60 - minute] = running_total # Backwards indexing as used in function - + # Leave second half empty to test gap filling - + # Set up days_previous for this test my_predbat.days_previous = [1] # Only test with 1 day my_predbat.days_previous_weight = [1.0] - + initial_data_sum = dp2(add_incrementing_sensor_total(test_data)) print("Initial partial data sum: {} kWh".format(dp2(initial_data_sum))) - + # Call the function my_predbat.previous_days_modal_filter(test_data) - + final_data_sum = dp2(add_incrementing_sensor_total(test_data)) print("Final data sum after gap filling: {} kWh".format(dp2(final_data_sum))) - + # Should now have approximately 24 kWh total (1 kWh per hour for 24 hours) expected_final_total = 24.0 if abs(final_data_sum - expected_final_total) > 2.0: # Allow 2 kWh tolerance @@ -9229,18 +9233,18 @@ def mock_get_arg(key, default=None): failed = True else: print("Gap filling successful: filled from {} kWh to {} kWh".format(dp2(initial_data_sum), dp2(final_data_sum))) - + # Test 3: Modal filtering - remove lowest consumption day print("Test 3: Modal filtering removes lowest day") - + # Reset for modal filter test my_predbat.days_previous = [1, 2, 3] my_predbat.days_previous_weight = [1.0, 1.0, 1.0] original_days_count = len(my_predbat.days_previous) - + # Create test data with different consumption per day test_data = {} - + # Day 1: Low consumption (10 kWh total) day1_total = 10.0 step_increment_day1 = day1_total / (24 * 60) @@ -9248,15 +9252,15 @@ def mock_get_arg(key, default=None): for minute in range(0, 24 * 60): running_total += step_increment_day1 test_data[24 * 60 - minute] = running_total - - # Day 2: Medium consumption (20 kWh total) + + # Day 2: Medium consumption (20 kWh total) day2_total = 20.0 step_increment_day2 = day2_total / (24 * 60) running_total = 0 for minute in range(0, 24 * 60): running_total += step_increment_day2 test_data[2 * 24 * 60 - minute] = running_total - + # Day 3: High consumption (30 kWh total) day3_total = 30.0 step_increment_day3 = day3_total / (24 * 60) @@ -9264,12 +9268,12 @@ def mock_get_arg(key, default=None): for minute in range(0, 24 * 60): running_total += step_increment_day3 test_data[3 * 24 * 60 - minute] = running_total - + print("Created test data with day totals: {} kWh, {} kWh, {} kWh".format(day1_total, day2_total, day3_total)) - + # Call the function - should remove day 1 (lowest consumption) my_predbat.previous_days_modal_filter(test_data) - + # Check that one day was removed final_days_count = len(my_predbat.days_previous) if final_days_count != original_days_count - 1: @@ -9280,10 +9284,10 @@ def mock_get_arg(key, default=None): failed = True else: print("Modal filter correctly removed lowest consumption day") - + # Restore original get_arg method my_predbat.get_arg = original_get_arg - + return failed