diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index fcda1290f..0a898f8fb 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -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" @@ -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", @@ -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 @@ -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 @@ -5846,6 +5881,8 @@ def publish_html_plan(self, pv_forecast_minute_step, load_minutes_step, end_reco else: state = "Charge↗" state_color = "#3AEE85" + if self.charge_window_best[charge_window_n]["start"] in self.manual_charge_times: + state += " ⅎ" show_limit = str(limit_percent) if discharge_window_n >= 0: @@ -5869,6 +5906,8 @@ def publish_html_plan(self, pv_forecast_minute_step, load_minutes_step, end_reco state_color = "#FFFF00" state += "Discharge↘" show_limit = str(int(limit)) + if self.discharge_window_best[discharge_window_n]["start"] in self.manual_discharge_times: + state += " ⅎ" # Import and export rates -> to string if self.rate_import_replicated.get(minute, False): @@ -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") @@ -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, @@ -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: @@ -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, @@ -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( @@ -7802,6 +7847,7 @@ 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): @@ -7809,7 +7855,7 @@ def optimise_all_windows(self, load_minutes_step, load_minutes_step10, pv_foreca 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 @@ -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( @@ -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"] @@ -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, @@ -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: @@ -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 @@ -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 @@ -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() @@ -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