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
15 changes: 9 additions & 6 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
"""
Expand Down
4 changes: 3 additions & 1 deletion apps/predbat/fox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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]
Expand Down
162 changes: 160 additions & 2 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -9134,6 +9134,163 @@ 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):
"""
Wrapper to run the async test function
Expand Down Expand Up @@ -9223,11 +9380,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:
Expand Down
17 changes: 14 additions & 3 deletions apps/predbat/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -2205,17 +2206,21 @@ async def html_config(self, request):
text += f"</form></td>\n"
elif itemtype == "input_number":
input_number_with_save = input_number.replace('onchange="javascript: this.form.submit();"', 'onchange="saveFilterValue(); this.form.submit();"')
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)))
text += '<td><form style="display: inline;" method="post" action="./config">'
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"</form></td>\n"
elif itemtype == "select":
options = item.get("options", [])
if value not in options:
options.append(value)
text += '<td><select name="{}" id="{}" onchange="saveFilterValue(); this.form.submit();">'.format(useid, useid)
text += f'<td><form style="display: inline;" method="post" action="./config">'
text += '<select name="{}" id="{}" onchange="saveFilterValue(); this.form.submit();">'.format(useid, useid)
for option in options:
selected = option == value
option_label = option if option else "None"
text += '<option value="{}" label="{}" {}>{}</option>'.format(option, option_label, "selected" if selected else "", option)
text += "</select></td>\n"
text += "</select>\n"
text += f"</form></td>\n"
else:
text += "<td>{}</td>\n".format(value)

Expand Down Expand Up @@ -2642,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):
Expand Down