Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ad4fb36
Prune export windows below rate threshold
nickgee31 Jun 12, 2026
92d131e
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jun 12, 2026
3d878b0
Initialize rate_export in test setup
nickgee31 Jun 12, 2026
d77e7e2
Merge branch 'export-planning-changes' of https://github.com/nickgee3…
nickgee31 Jun 12, 2026
4097942
Use original window size for min improvement
nickgee31 Jun 12, 2026
72606b8
Restore export windows trimmed above threshold
nickgee31 Jun 12, 2026
aaf94cc
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 13, 2026
5e79908
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 15, 2026
7da6df4
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 17, 2026
34533ca
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 18, 2026
1e91fa3
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 19, 2026
0e18f03
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 19, 2026
6bf124a
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 21, 2026
9969d3d
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 23, 2026
eb55417
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 24, 2026
65695e2
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 26, 2026
b890f29
Recompute export window average when restoring start
nickgee31 Jun 27, 2026
21374d2
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jun 28, 2026
738a930
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jul 8, 2026
e0d0a2b
Merge branch 'springfall2008:main' into export-planning-changes
nickgee31 Jul 8, 2026
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 @@ -349,9 +349,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 @@ -403,7 +409,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
163 changes: 162 additions & 1 deletion apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,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 @@ -1118,6 +1120,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 @@ -1182,6 +1185,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 @@ -1816,6 +1822,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 @@ -1828,7 +1835,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)

# When already exporting within this window keep the export going across planning cycles by
# relaxing the cost gate (not just the metric above). This only sustains an in-progress forced
Expand Down Expand Up @@ -2356,6 +2363,160 @@ 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
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:
window["average"] = dp2(sum(rate_values) / len(rate_values))

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