Skip to content
Open
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
10 changes: 8 additions & 2 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,15 @@ def execute_plan(self):
discharge_start_time = self.midnight_utc + timedelta(minutes=minutes_start)
discharge_end_time = self.midnight_utc + timedelta(minutes=(minutes_end + export_adjust)) # Add in 1 minute margin to allow Predbat to restore demand mode
discharge_soc = max((int(self.export_limits_best[0]) * self.soc_max) / 100.0, self.reserve, self.best_soc_min)
export_window_above_threshold = self.export_window_above_threshold(window, self.export_limits_best[0])
self.log("Next export window will be: {} - {} at reserve {}".format(discharge_start_time, discharge_end_time, self.export_limits_best[0]))
if (self.minutes_now >= minutes_start) and (self.minutes_now < minutes_end) and (self.export_limits_best[0] < 100.0):
if not self.set_export_freeze_only and self.export_limits_best[0] < 99.0 and (self.soc_kw > discharge_soc):
if not export_window_above_threshold:
export_rate = window.get("average", self.rate_export.get(window["start"], 0))
self.log("Not exporting now as export rate {}{} is below optimisation threshold {}{}".format(dp2(export_rate), self.currency_symbols[1], dp2(self.export_threshold_for_window()), self.currency_symbols[1]))
inverter.adjust_force_export(False)
disabled_export = True
elif not self.set_export_freeze_only and self.export_limits_best[0] < 99.0 and (self.soc_kw > discharge_soc):
if self.set_export_low_power:
export_rate_adjust = 1 - (self.export_limits_best[0] - int(self.export_limits_best[0]))
else:
Expand Down Expand Up @@ -399,7 +405,7 @@ def execute_plan(self):
self.isExporting_Target = inverter.soc_percent
self.log("Export Hold (Demand mode) as export is now at/below target or freeze only is set - current SoC {}kWh and target {}kWh".format(self.soc_kw, discharge_soc))
else:
if (self.minutes_now < minutes_end) and ((minutes_start - self.minutes_now) <= self.set_window_minutes) and (self.export_limits_best[0] < 99.0):
if (self.minutes_now < minutes_end) and ((minutes_start - self.minutes_now) <= self.set_window_minutes) and (self.export_limits_best[0] < 99.0) and export_window_above_threshold:
# We can't schedule freeze export only full export
# Don't turn off ECO mode for GE inverters except when we are within the export window as it will stop the battery being used
ge_inverters = inverter.inv_has_ge_eco_toggle or inverter.inv_has_ge_inverter_mode
Expand Down
160 changes: 159 additions & 1 deletion apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,8 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):

# Full plan
self.optimise_all_windows(metric, metric_keep, debug_mode)
if self.calculate_best_export and self.export_window_best:
self.restore_export_windows_above_threshold()

# Update target values, will be refined via clipping
self.update_target_values()
Expand Down Expand Up @@ -1059,6 +1061,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):

# Filter out the windows we disabled during clipping
self.export_limits_best, self.export_window_best = self.discard_unused_export_slots(self.export_limits_best, self.export_window_best)
self.export_limits_best, self.export_window_best = self.prune_export_windows_below_threshold(self.export_limits_best, self.export_window_best)
self.log("Export windows filtered {}".format(self.window_as_text(self.export_window_best, self.export_limits_best)))

# Filter out any unused charge slots
Expand Down Expand Up @@ -1123,6 +1126,9 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
else:
self.log("New plan metric is significantly better from previous plan, using new plan")

if self.calculate_best_export and self.export_window_best:
self.export_limits_best, self.export_window_best = self.prune_export_windows_below_threshold(self.export_limits_best, self.export_window_best)

# Plan is now valid
self.log("Plan valid is now true after recompute was {}".format(self.plan_valid))
if not self.update_pending:
Expand Down Expand Up @@ -1751,6 +1757,7 @@ def optimise_export(self, window_n, record_charge_windows, try_charge_limit, cha
)

window_size = try_export_window[window_n]["end"] - start
original_window_size = try_export_window[window_n]["end"] - window["start"]
window_key = str(dp2(this_export_limit)) + "_" + str(window_size)
window_results[window_key] = [metric, cost]

Expand All @@ -1763,7 +1770,7 @@ def optimise_export(self, window_n, record_charge_windows, try_charge_limit, cha
elif all_n:
min_improvement_scaled = self.metric_min_improvement_export * rate_scale * len(all_n)
else:
min_improvement_scaled = self.metric_min_improvement_export * window_size * rate_scale / float(self.plan_interval_minutes)
min_improvement_scaled = self.metric_min_improvement_export * original_window_size * rate_scale / float(self.plan_interval_minutes)

# Only select an export if it makes a notable improvement has defined by min_improvement (divided in M windows)
# Also require cost improvement to prevent exports that only game metric_keep without actual savings (issue #2984)
Expand Down Expand Up @@ -2283,6 +2290,157 @@ def discard_unused_export_slots(self, export_limits_best, export_window_best):

return new_enable, new_best

def export_threshold_for_window(self):
"""
Return the final export threshold used to decide if optimiser-selected
export windows are still eligible.
"""
if self.rate_best_cost_threshold_export is not None:
return self.rate_best_cost_threshold_export
if self.rate_export_cost_threshold < 99:
return self.rate_export_cost_threshold
return None

def export_window_is_manual(self, window):
"""
Return true when the export window is an explicit manual export/freeze override.
"""
window_start = window["start"]
window_start_orig = window.get("start_orig", window_start)
return (window_start in self.manual_export_times) or (window_start_orig in self.manual_export_times) or (window_start in self.manual_freeze_export_times) or (window_start_orig in self.manual_freeze_export_times)

def export_window_above_threshold(self, window, export_limit):
"""
Check if an export window is allowed by the final export rate threshold.
"""
if export_limit >= 100.0:
return True
if self.export_window_is_manual(window):
return True

threshold = self.export_threshold_for_window()
if threshold is None:
return True

export_rate = window.get("average", self.rate_export.get(window["start"], 0))
return export_rate >= threshold

def trim_export_window_to_threshold(self, window, threshold):
"""
Trim below-threshold rate periods from the start/end of an export window.
"""
new_window = copy.deepcopy(window)
window_start = new_window["start"]
window_end = new_window["end"]
rate_minutes = list(range(window_start, window_end, PREDICT_STEP))
has_rate_data = any(minute in self.rate_export for minute in rate_minutes)

if not has_rate_data:
export_rate = new_window.get("average", self.rate_export.get(window_start, 0))
return new_window if export_rate >= threshold else None

while window_start < window_end and self.rate_export.get(window_start, 0) < threshold:
window_start += PREDICT_STEP

while window_end > window_start and self.rate_export.get(window_end - PREDICT_STEP, 0) < threshold:
window_end -= PREDICT_STEP

if window_start >= window_end:
return None

if (window_start != new_window["start"]) or (window_end != new_window["end"]):
self.log(
"Trim export window {} - {} to {} - {} using optimisation threshold {}{}".format(
self.time_abs_str(new_window["start"]),
self.time_abs_str(new_window["end"]),
self.time_abs_str(window_start),
self.time_abs_str(window_end),
dp2(threshold),
self.currency_symbols[1],
)
)

new_window["start"] = window_start
new_window["end"] = window_end
rate_values = [self.rate_export[minute] for minute in range(window_start, window_end, PREDICT_STEP) if minute in self.rate_export]
if rate_values:
new_window["average"] = dp2(sum(rate_values) / len(rate_values))
return new_window

def restore_export_windows_above_threshold(self):
"""
Undo optimiser tail-trimming for selected export windows when the skipped
start of the original window is still above the final export threshold.
"""
threshold = self.export_threshold_for_window()
if threshold is None:
return

for window_n in range(len(self.export_window_best)):
window = self.export_window_best[window_n]
if self.export_limits_best[window_n] >= 100.0:
continue

window_start = window["start"]
window_start_orig = window.get("start_orig", window_start)
if window_start_orig >= window_start:
continue

rate_minutes = list(range(window_start_orig, window_start, PREDICT_STEP))
has_rate_data = any(minute in self.rate_export for minute in rate_minutes)
if has_rate_data:
skipped_above_threshold = all(self.rate_export.get(minute, 0) >= threshold for minute in rate_minutes)
else:
skipped_above_threshold = window.get("average", self.rate_export.get(window_start_orig, 0)) >= threshold

if skipped_above_threshold:
self.log(
"Restore export window start {} - {} to {} using optimisation threshold {}{}".format(
self.time_abs_str(window_start),
self.time_abs_str(window["end"]),
self.time_abs_str(window_start_orig),
dp2(threshold),
self.currency_symbols[1],
)
)
window["start"] = window_start_orig

def prune_export_windows_below_threshold(self, export_limits_best, export_window_best):
"""
Remove optimiser-selected export windows below the final export threshold.
"""
new_best = []
new_enable = []
threshold = self.export_threshold_for_window()

for window_n in range(len(export_window_best)):
window = export_window_best[window_n]
export_limit = export_limits_best[window_n]
if threshold is not None and (not self.export_window_is_manual(window)) and export_limit < 100.0:
window = self.trim_export_window_to_threshold(window, threshold)

if (window is None) or (not self.export_window_above_threshold(window, export_limit)):
original_window = export_window_best[window_n]
if window is None:
export_rate = original_window.get("average", self.rate_export.get(original_window["start"], 0))
else:
export_rate = window.get("average", self.rate_export.get(window["start"], 0))
self.log(
"Discard export window {} - {} at {}{} below optimisation threshold {}{}".format(
self.time_abs_str(original_window["start"]),
self.time_abs_str(original_window["end"]),
dp2(export_rate),
self.currency_symbols[1],
dp2(threshold),
self.currency_symbols[1],
)
)
continue
new_best.append(window)
new_enable.append(export_limit)

return new_enable, new_best

def update_target_values(self):
"""
Update target values for HTML plan
Expand Down
Loading
Loading