Skip to content
Merged
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
192 changes: 149 additions & 43 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import os
import yaml

THIS_VERSION = "v7.14.45"
THIS_VERSION = "v7.15.0"
TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
TIME_FORMAT_SECONDS = "%Y-%m-%dT%H:%M:%S.%f%z"
TIME_FORMAT_OCTOPUS = "%Y-%m-%d %H:%M:%S%z"
Expand Down Expand Up @@ -475,6 +475,26 @@
"default": None,
"restore": False,
},
{
"name": "manual_charge",
"friendly_name": "Manual charge",
"type": "select",
"options": [],
"icon": "mdi:state-machine",
"default": "",
"restore": False,
"manual": True,
},
{
"name": "manual_discharge",
"friendly_name": "Manual discharge",
"type": "select",
"options": [],
"icon": "mdi:state-machine",
"default": "",
"restore": False,
"manual": True,
},
{
"name": "saverestore",
"friendly_name": "Save/restore settings",
Expand Down Expand Up @@ -4791,7 +4811,14 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high):

if minute in rates:
rate = rates[minute]
if ((not find_high) and (rate <= threshold_rate)) or (find_high and (rate >= threshold_rate) and (rate > 0)):
if (
((not find_high) and (rate <= threshold_rate))
or (find_high and (rate >= threshold_rate) and (rate > 0))
or (not find_high and (minute in self.manual_charge_times))
or (not find_high and (rate_low_start in self.manual_charge_times))
or (find_high and (minute in self.manual_discharge_times))
or (find_high and (rate_low_start in self.manual_discharge_times))
):
if (not self.combine_mixed_rates) and (rate_low_start >= 0) and (int(self.dp2(rate) + 0.5) != int(self.dp2(rate_low_rate) + 0.5)):
# Refuse mixed rates that are different by more than 0.5p
rate_low_end = minute
Expand All @@ -4804,6 +4831,14 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high):
# If combine is disabled, for import slots make them all N minutes so we can select some not all
rate_low_end = minute
break
if (not find_high) and (rate_low_start in self.manual_charge_times) and (rate_low_start >= 0) and ((minute - rate_low_start) >= 30):
# Manual import slot
rate_low_end = minute
break
if find_high and (rate_low_start in self.manual_discharge_times) and (rate_low_start >= 0) and ((minute - rate_low_start) >= 30):
# Manual export slot
rate_low_end = minute
break
if find_high and (rate_low_start >= 0) and ((minute - rate_low_start) >= 60 * 4):
# Export slot can never be bigger than 4 hours
rate_low_end = minute
Expand Down Expand Up @@ -5846,6 +5881,8 @@ def publish_html_plan(self, pv_forecast_minute_step, load_minutes_step, end_reco
else:
state = "Charge&nearr;"
state_color = "#3AEE85"
if self.charge_window_best[charge_window_n]["start"] in self.manual_charge_times:
state += " &#8526;"
show_limit = str(limit_percent)

if discharge_window_n >= 0:
Expand All @@ -5869,6 +5906,8 @@ def publish_html_plan(self, pv_forecast_minute_step, load_minutes_step, end_reco
state_color = "#FFFF00"
state += "Discharge&searr;"
show_limit = str(int(limit))
if self.discharge_window_best[discharge_window_n]["start"] in self.manual_discharge_times:
state += " &#8526;"

# Import and export rates -> to string
if self.rate_import_replicated.get(minute, False):
Expand Down Expand Up @@ -6410,6 +6449,8 @@ def reset(self):
"""
self.inverter_needs_reset = False
self.inverter_needs_reset_force = ""
self.manual_charge_times = []
self.manual_discharge_times = []
self.config_index = {}
self.dashboard_index = []
self.prefix = self.args.get("prefix", "predbat")
Expand Down Expand Up @@ -7655,7 +7696,8 @@ def tweak_plan(self, end_record, load_minutes_step, load_minutes_step10, pv_fore
typ = window_index[key]["type"]
window_n = window_index[key]["id"]
if typ == "c":
if self.calculate_best_charge:
window_start = self.charge_window_best[window_n]["start"]
if self.calculate_best_charge and (window_start not in self.manual_charge_times):
best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep = self.optimise_charge_limit(
window_n,
record_charge_windows,
Expand All @@ -7671,7 +7713,8 @@ def tweak_plan(self, end_record, load_minutes_step, load_minutes_step10, pv_fore
)
self.charge_limit_best[window_n] = best_soc
else:
if self.calculate_best_discharge:
window_start = self.discharge_window_best[window_n]["start"]
if self.calculate_best_discharge and (window_start not in self.manual_discharge_times):
if not self.calculate_discharge_oncharge:
hit_charge = self.hit_charge_window(self.charge_window_best, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"])
if hit_charge >= 0 and self.charge_limit_best[hit_charge] > 0.0:
Expand Down Expand Up @@ -7717,6 +7760,7 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
if price_set and self.calculate_best_charge and self.charge_window_best:
self.log("Optimise all windows, total charge {} discharge {}".format(record_charge_windows, record_discharge_windows))
self.optimise_charge_windows_reset(reset_all=True)
self.optimise_charge_windows_manual()
self.charge_limit_best, ignore_discharge_limits, best_price, best_price_discharge = self.optimise_charge_limit_price(
price_set,
price_links,
Expand Down Expand Up @@ -7757,6 +7801,7 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
# Set the new end record and blackout period based on the levelling
self.end_record = self.record_length(self.charge_window_best, self.charge_limit_best, best_price)
self.optimise_charge_windows_reset(reset_all=False)
self.optimise_charge_windows_manual()
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
record_discharge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.discharge_window_best), 1)
window_sorted, window_index, price_set, price_links = self.sort_window_by_price_combined(
Expand Down Expand Up @@ -7802,14 +7847,15 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
if typ == "c":
# Store price set with window
self.charge_window_best[window_n]["set"] = price
window_start = self.charge_window_best[window_n]["start"]

# For start at high only tune down excess high slots
if (not start_at_low) and (price > best_price) and (self.charge_limit_best[window_n] != self.soc_max):
if self.debug_enable:
self.log("Skip start at high window {} best limit {}".format(window_n, (self.charge_limit_best[window_n])))
continue

if self.calculate_best_charge:
if self.calculate_best_charge and (window_start not in self.manual_charge_times):
if not printed_set:
self.log("Optimise price set {} start_at_low {} best_price {}".format(price, start_at_low, best_price))
printed_set = True
Expand All @@ -7830,41 +7876,6 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
)
self.charge_limit_best[window_n] = best_soc

if 0:
# Find all adjacent windows in price range
all_n = [window_n]
end_n = self.charge_window_best[window_n]["end"]
loop_n = window_n + 1
while (loop_n < len(self.charge_limit_best)) and (len(all_n) < 4):
if end_n == self.charge_window_best[loop_n]["start"] and (abs(self.charge_window_best[loop_n]["average"] - average) <= 1.0):
all_n.append(loop_n)
end_n = self.charge_window_best[loop_n]["end"]
else:
break
loop_n += 1

if len(all_n) > 1:
self.log("Optimise charge group {}".format(all_n))
prev_best_metric = best_metric
best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep = self.optimise_charge_limit(
window_n,
record_charge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
load_minutes_step,
load_minutes_step10,
pv_forecast_minute_step,
pv_forecast_minute10_step,
end_record=self.end_record,
all_n=all_n,
)
self.log("Optimised charge group metric {} previous {} soc {}".format(best_metric, prev_best_metric, best_soc))
if best_metric < prev_best_metric:
for loop_n in all_n:
self.charge_limit_best[loop_n] = best_soc

if self.debug_enable:
self.log(
"Best charge limit window {} time {} - {} cost {} charge_limit {} (adjusted) min {} @ {} (margin added {} and min {} max {}) with metric {} cost {} windows {}".format(
Expand All @@ -7886,13 +7897,14 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
else:
# Store price set with window
self.discharge_window_best[window_n]["set"] = price
window_start = self.discharge_window_best[window_n]["start"]

# Do highest price first
# Second pass to tune down any excess exports only
if start_at_low and ((price > best_price) or (self.discharge_limits_best[window_n] == 100.0)):
continue

if self.calculate_best_discharge:
if self.calculate_best_discharge and (window_start not in self.manual_discharge_times):
if not self.calculate_discharge_oncharge:
hit_charge = self.hit_charge_window(
self.charge_window_best, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]
Expand Down Expand Up @@ -7978,7 +7990,8 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
typ = window_index[key]["type"]
window_n = window_index[key]["id"]
if typ == "c":
if self.calculate_best_charge:
window_start = self.charge_window_best[window_n]["start"]
if self.calculate_best_charge and (window_start not in self.manual_charge_times):
best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep = self.optimise_charge_limit(
window_n,
record_charge_windows,
Expand All @@ -7994,7 +8007,8 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
)
self.charge_limit_best[window_n] = best_soc
else:
if self.calculate_best_discharge:
window_start = self.discharge_window_best[window_n]["start"]
if self.calculate_best_discharge and (window_start not in self.manual_discharge_times):
if not self.calculate_discharge_oncharge:
hit_charge = self.hit_charge_window(self.charge_window_best, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"])
if hit_charge >= 0 and self.charge_limit_best[hit_charge] > 0.0:
Expand All @@ -8020,6 +8034,20 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca
count += 1
self.log("Second pass optimisation finished metric {} cost {} metric_keep {}".format(best_metric, self.dp2(best_cost), self.dp2(best_keep)))

def optimise_charge_windows_manual(self):
"""
Manual window overrides
"""
if self.charge_window_best and self.calculate_best_charge:
for window_n in range(0, len(self.charge_window_best)):
if self.charge_window_best[window_n]["start"] in self.manual_charge_times:
self.charge_limit_best[window_n] = self.soc_max

if self.discharge_window_best and self.calculate_best_discharge:
for window_n in range(0, len(self.discharge_window_best)):
if self.discharge_window_best[window_n]["start"] in self.manual_discharge_times:
self.discharge_limits_best[window_n] = 0

def optimise_charge_windows_reset(self, reset_all):
"""
Reset the charge windows to min
Expand Down Expand Up @@ -9676,6 +9704,79 @@ def fetch_inverter_data(self):
self.log("Base charge window {}".format(self.window_as_text(self.charge_window, self.charge_limit)))
self.log("Base discharge window {}".format(self.window_as_text(self.discharge_window, self.discharge_limits)))

def manual_select(self, config_item, value):
"""
Selection on manual times dropdown
"""
item = self.config_index.get(config_item)
values = item.get("value", "")
values_list = []
if values:
values_list = values.split(",")
if "[" in value:
value = value.replace("[", "")
value = value.replace("]", "")
if value in values_list:
values_list.remove(value)
else:
values_list.append(value)
self.expose_config(config_item, ",".join(values_list))
self.manual_times(config_item)

def manual_times(self, config_item):
"""
Update manual times sensor
"""
time_overrides = []
minutes_now = int(self.minutes_now / 30) * 30

# Deconstruct the value into a list of minutes
item = self.config_index.get(config_item)
values = item.get("value", "")
values_list = []
if values:
values_list = values.split(",")
for value in values_list:
try:
start_time = datetime.strptime(value, "%H:%M:%S")
except ValueError:
start_time = None
if start_time:
minutes = start_time.hour * 60 + start_time.minute
if minutes < minutes_now:
minutes += 24 * 60
if (minutes - minutes_now) < 12 * 60:
time_overrides.append(minutes)

# Reconstruct the list in order based on minutes
values_list = []
for minute in time_overrides:
minute_str = (self.midnight + timedelta(minutes=minute)).strftime("%H:%M:%S")
values_list.append(minute_str)
values = ",".join(values_list)

# Create the new dropdown
time_values = []
for minute in range(minutes_now, minutes_now + 12 * 60, 30):
minute_str = (self.midnight + timedelta(minutes=minute)).strftime("%H:%M:%S")
if minute in time_overrides:
minute_str = "[" + minute_str + "]"
time_values.append(minute_str)
if values not in time_values:
time_values.append(values)
item["options"] = time_values
if not values:
values = None
item["value"] = None # Force update to expose
self.expose_config(config_item, values)

if time_overrides:
time_txt = []
for minute in time_overrides:
time_txt.append(self.time_abs_str(minute))
self.log("Manual override: {} times now {}".format(config_item, time_txt))
return time_overrides

def fetch_config_options(self):
"""
Fetch all the configuration options
Expand Down Expand Up @@ -9873,6 +9974,9 @@ def fetch_config_options(self):
self.car_charging_threshold = float(self.get_arg("car_charging_threshold")) / 60.0
self.car_charging_energy_scale = self.get_arg("car_charging_energy_scale")

# Update list of slot times
self.manual_charge_times = self.manual_times("manual_charge")
self.manual_discharge_times = self.manual_times("manual_discharge")
# Update list of config options to save/restore to
self.update_save_restore_list()

Expand Down Expand Up @@ -10056,6 +10160,8 @@ def select_event(self, event, data, kwargs):
self.restore_settings_yaml(None)
else:
self.restore_settings_yaml(value)
elif item.get("manual"):
self.manual_select(item["name"], value)
else:
self.expose_config(item["name"], value, event=True)
self.update_pending = True
Expand Down