Skip to content

Commit be635ec

Browse files
feat(config): add a performance_tweaks toggle and merge the two plan passes
Two related changes to the optimiser's final pass and how it is configured. Merge tweak_plan and optimise_full_second_pass into optimise_plan_pass(end_record, budget). They were the same loop - same window sort, same optimise_charge_limit/optimise_export calls, same keep_window_change_if_improved - differing only in tweak_plan's count >= 8 cap and in the export-window start_orig reset, which only tweak_plan performed. The reset now applies on both paths: without it a window trimmed by an earlier pass can only ever be trimmed further, so the pass cannot recover a window it narrowed on a plan that has since changed underneath it. calculate_second_pass now selects budget=0 (every window in the record) rather than a second implementation. Add switch.predbat_performance_tweaks, gating the options that trade planning time for plan quality. It works like expert_mode, but everything behind it defaults to On, so a user who never touches it gets the best plan Predbat can produce; the toggle exists so someone on slow hardware can reveal the switches and turn features off. calculate_second_pass and calculate_pv90_plan move behind it and both now default to On. Note the sharp edge this inherits from the config layer: get_ha_config substitutes an item's default when the item is disabled rather than returning None, so a hidden option is pinned to its default and cannot be held Off from apps.yaml without revealing the toggle. Covered by tests. predbat_debug_pre_saving1's expected plan is regenerated: those captures predate PV90 and do not pin pv_metric90_weight, so the new default switches PV90 on for them. agile1 is unchanged. Adds test_performance_tweaks covering the toggle, its gated items' defaults, hide/reveal behaviour and the fetch_config_options read. test_pv90's default-off assertions are rewritten to force the Off case now that On is the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6477e28 commit be635ec

9 files changed

Lines changed: 338 additions & 156 deletions

apps/predbat/config.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@
4444
"type": "switch",
4545
"default": False,
4646
},
47+
{
48+
"name": "performance_tweaks",
49+
"friendly_name": "Performance Tweaks",
50+
"type": "switch",
51+
"default": False,
52+
},
4753
{
4854
"name": "active",
4955
"friendly_name": "Predbat Active",
@@ -810,14 +816,15 @@
810816
"name": "calculate_second_pass",
811817
"friendly_name": "Calculate full second pass (slower)",
812818
"type": "switch",
813-
"enable": "expert_mode",
814-
"default": False,
819+
"enable": "performance_tweaks",
820+
"default": True,
815821
},
816822
{
817823
"name": "calculate_pv90_plan",
818824
"friendly_name": "Calculate PV90 upside plan",
819825
"type": "switch",
820-
"default": False,
826+
"enable": "performance_tweaks",
827+
"default": True,
821828
},
822829
{
823830
"name": "calculate_import_low_export",

apps/predbat/plan.py

Lines changed: 37 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
from datetime import datetime, timedelta
2323
from multiprocessing import Pool, cpu_count
2424
from const import PREDICT_STEP, PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90, TIME_FORMAT, MINUTE_WATT
25+
26+
# How many windows the post-settle plan pass revisits when calculate_second_pass is off. The near-term
27+
# windows are the ones about to be executed, so a small budget keeps the common path cheap; raising it
28+
# picks up value further out at a proportional cost in planning time.
29+
PLAN_PASS_WINDOW_BUDGET = 8
2530
from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, calc_percent_limit, in_car_slot
2631
from prediction import Prediction, wrapped_run_prediction_single, wrapped_run_prediction_charge, wrapped_run_prediction_charge_min_max, wrapped_run_prediction_export, wrapped_run_prediction_charge_min_max
2732
from prediction_kernel import kernel_status_summary
@@ -2874,19 +2879,30 @@ def update_target_values(self):
28742879
for window_n in range(len(self.charge_limit_best)):
28752880
self.charge_window_best[window_n]["target"] = self.charge_limit_best[window_n]
28762881

2877-
def tweak_plan(self, end_record):
2878-
"""
2879-
Tweak existing plan only
2882+
def optimise_plan_pass(self, end_record, budget=0, debug_mode=False):
2883+
"""Re-optimise each charge and export window of the settled plan, in time order.
2884+
2885+
This is the pass that runs after the levels and detailed passes have chosen the plan's shape.
2886+
Each window is re-optimised against the whole plan and the change is kept only when it improves
2887+
on the plan we were handed, so the pass is monotonic.
2888+
2889+
budget caps how many windows are visited; 0 visits every window in the record. The cheap default
2890+
exists because the near-term windows are the ones about to be executed, but the cap is what makes
2891+
the fast path miss value further out - see calculate_second_pass, which runs unbudgeted.
2892+
2893+
The metric is measured from the plan we were handed rather than taken from the caller:
2894+
optimise_swap_export runs before this and mutates the plan without its return value being used,
2895+
so the caller's metric is already stale.
28802896
2881-
The metric is measured from the plan we were handed rather than taken from the caller: optimise_swap_export
2882-
runs immediately before this and mutates the plan without its return value being used, so the caller's
2883-
metric is already stale. Each window change below is then only kept when it improves on that baseline.
2897+
Each export window's start is reset to start_orig before it is re-optimised. Without that reset a
2898+
window trimmed by an earlier pass can only ever be trimmed further, so the pass cannot recover a
2899+
window it narrowed on a plan that has since changed underneath it.
28842900
"""
28852901
record_charge_windows = max(self.max_charge_windows(end_record + self.minutes_now, self.charge_window_best), 1)
28862902
record_export_windows = max(self.max_charge_windows(end_record + self.minutes_now, self.export_window_best), 1)
28872903
selected = self.plan_metric_now(end_record)
28882904
curr = self.currency_symbols[1]
2889-
self.log("Tweak plan optimisation started metric {}{}, cost {}{}".format(dp2(selected[0]), curr, dp2(selected[1]), curr))
2905+
self.log("Plan pass optimisation started metric {}{}, cost {}{}, budget {}".format(dp2(selected[0]), curr, dp2(selected[1]), curr, budget if budget else "unlimited"))
28902906
count = 0
28912907
window_sorted, window_index = self.sort_window_by_time_combined(self.charge_window_best[:record_charge_windows], self.export_window_best[:record_export_windows])
28922908
for key in window_sorted:
@@ -2930,16 +2946,20 @@ def tweak_plan(self, end_record):
29302946
self.export_window_best[window_n]["start"] = best_start
29312947
candidate = (best_metric_plan, best_cost, best_keep, best_cycle, best_carbon, best_import)
29322948
selected = self.keep_window_change_if_improved(selected, candidate, typ, window_n, snapshot)
2949+
if (count % 16) == 0 and self.debug_enable:
2950+
log_metric, log_cost, log_keep, log_cycle, log_carbon, log_import = selected
2951+
self.log("Plan pass type {} window {} metric {} metric_keep {} carbon {} import {} cost {}".format(typ, window_n, log_metric, dp2(log_keep), dp0(log_carbon), dp2(log_import), dp2(log_cost)))
29332952
count += 1
2934-
if count >= 8:
2953+
if budget and count >= budget:
29352954
break
29362955

29372956
best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = selected
29382957
self.log(
2939-
"Tweak optimisation finished metric {}{}, cost {}{}, metric_keep {}kWh, cycle {}kWh, carbon {}kg, import {}kWh, changed {} window(s)".format(
2958+
"Plan pass optimisation finished metric {}{}, cost {}{}, metric_keep {}kWh, cycle {}kWh, carbon {}kg, import {}kWh, visited {} window(s)".format(
29402959
dp2(best_metric), curr, dp2(best_cost), curr, dp2(best_keep), dp2(best_cycle), dp0(best_carbon), dp2(best_import), count
29412960
)
29422961
)
2962+
self.plan_write_debug(debug_mode, "plan_pass.html", self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, end_record)
29432963
return best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import
29442964

29452965
def plan_write_debug(self, debug_mode, name, pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10, end_record, test=False, prediction=None):
@@ -3503,66 +3523,6 @@ def allow_this_export_window(self, export_window_n):
35033523
return True
35043524
return False
35053525

3506-
def optimise_full_second_pass(self, best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value, record_charge_windows, record_export_windows, debug_mode=False):
3507-
"""
3508-
Second pass optimisation of the charge and export windows
3509-
"""
3510-
self.log("Second pass optimisation started")
3511-
3512-
# Baseline the plan we were handed, for the same reason as tweak_plan: optimise_swap_export has already
3513-
# mutated it since the caller's metric was measured.
3514-
selected = self.plan_metric_now(self.end_record)
3515-
3516-
count = 0
3517-
window_sorted, window_index = self.sort_window_by_time_combined(self.charge_window_best[:record_charge_windows], self.export_window_best[:record_export_windows])
3518-
for key in window_sorted:
3519-
typ = window_index[key]["type"]
3520-
window_n = window_index[key]["id"]
3521-
if typ == "c":
3522-
if self.allow_this_charge_window(window_n):
3523-
snapshot = self.plan_window_snapshot(typ, window_n)
3524-
best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import, best_metric_plan = self.optimise_charge_limit(
3525-
window_n,
3526-
record_charge_windows,
3527-
self.charge_limit_best,
3528-
self.charge_window_best,
3529-
self.export_window_best,
3530-
self.export_limits_best,
3531-
end_record=self.end_record,
3532-
)
3533-
self.charge_limit_best[window_n] = best_soc
3534-
candidate = (best_metric_plan, best_cost, best_keep, best_cycle, best_carbon, best_import)
3535-
selected = self.keep_window_change_if_improved(selected, candidate, typ, window_n, snapshot)
3536-
elif typ == "d":
3537-
if self.allow_this_export_window(window_n):
3538-
snapshot = self.plan_window_snapshot(typ, window_n)
3539-
best_soc, best_start, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import, best_metric_plan = self.optimise_export(
3540-
window_n,
3541-
record_export_windows,
3542-
self.charge_limit_best,
3543-
self.charge_window_best,
3544-
self.export_window_best,
3545-
self.export_limits_best,
3546-
end_record=self.end_record,
3547-
)
3548-
self.export_limits_best[window_n] = best_soc
3549-
self.export_window_best[window_n]["start_orig"] = self.export_window_best[window_n].get("start_orig", self.export_window_best[window_n]["start"])
3550-
self.export_window_best[window_n]["start"] = best_start
3551-
candidate = (best_metric_plan, best_cost, best_keep, best_cycle, best_carbon, best_import)
3552-
selected = self.keep_window_change_if_improved(selected, candidate, typ, window_n, snapshot)
3553-
if (count % 16) == 0:
3554-
log_metric, log_cost, log_keep, log_cycle, log_carbon, log_import = selected
3555-
self.log("Final optimisation type {} window {} metric {} metric_keep {} best_carbon {} best_import {} cost {}".format(typ, window_n, log_metric, dp2(log_keep), dp0(log_carbon), dp2(log_import), dp2(log_cost)))
3556-
count += 1
3557-
3558-
# best_battery_value and best_soc_min stay as the caller passed them in, as they did before this pass
3559-
# measured its own metric
3560-
best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = selected
3561-
self.log("Second pass optimisation finished metric {} cost {} metric_keep {} cycle {} carbon {} import {}".format(best_metric, dp2(best_cost), dp2(best_keep), dp2(best_cycle), dp0(best_carbon), dp2(best_import)))
3562-
3563-
self.plan_write_debug(debug_mode, "plan_pass2.html", self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, self.end_record)
3564-
return best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value
3565-
35663526
def optimise_detailed_pass(
35673527
self,
35683528
best_price_charge,
@@ -4126,26 +4086,20 @@ def optimise_all_windows(self, best_metric, metric_keep, debug_mode=False):
41264086
record_export_windows,
41274087
debug_mode=debug_mode,
41284088
)
4129-
# Second pass optimisation
4130-
if self.calculate_second_pass:
4131-
# Full second pass (slower)
4132-
best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value = self.optimise_full_second_pass(
4133-
best_metric, best_cost, best_keep, best_soc_min, best_cycle, best_carbon, best_import, best_battery_value, record_charge_windows, record_export_windows, debug_mode=debug_mode
4134-
)
4135-
else:
4136-
# Tweak plan (faster)
4137-
best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = self.tweak_plan(self.end_record)
4089+
# Re-optimise each window of the settled plan. calculate_second_pass lifts the window budget so
4090+
# every window in the record is revisited rather than just the near-term ones (slower).
4091+
best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = self.optimise_plan_pass(self.end_record, budget=0 if self.calculate_second_pass else PLAN_PASS_WINDOW_BUDGET, debug_mode=debug_mode)
41384092

41394093
# Export more solar - enable freeze export on idle solar windows if it doesn't cost too much
41404094
if self.export_more_solar:
41414095
best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import = self.optimise_solar(best_metric, best_cost, best_keep, best_cycle, best_carbon, best_import, record_export_windows, debug_mode=debug_mode)
41424096

41434097
# Swaps run once all other passes have settled. The export swap can only defer an export that
4144-
# already exists when it runs, and tweak/second/solar all turn exports on - tweak_plan only walks
4145-
# the first few windows of the plan, so the exports it adds are always at the front, exactly the
4146-
# ones the swap exists to push back. Running the swap before them left those pinned in place
4147-
# (#4478). The charge swap follows for the mirror-image reason: a strictly-improving pairwise
4148-
# charge move must not be subsequently undone by a non-monotonic pass.
4098+
# already exists when it runs, and the plan pass and solar pass both turn exports on - on the
4099+
# budgeted plan pass the windows it reaches are the near-term ones, so the exports it adds are at
4100+
# the front, exactly the ones the swap exists to push back. Running the swap before them left
4101+
# those pinned in place (#4478). The charge swap follows for the mirror-image reason: a
4102+
# strictly-improving pairwise charge move must not be subsequently undone by a non-monotonic pass.
41494103
self.optimise_swap_export(record_charge_windows, record_export_windows, debug_mode=debug_mode)
41504104
self.plan_write_debug(debug_mode, "plan_swap_final.html", self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, self.end_record)
41514105
self.optimise_swap_charge(record_charge_windows, debug_mode=debug_mode)

0 commit comments

Comments
 (0)