Skip to content

Commit 162ead5

Browse files
fix(plan): run the export swap after the passes that add exports (#4478)
optimise_swap_export defers an export to a later window when doing so costs no more than metric_min_improvement_swap, so the battery holds its charge for longer. It can only defer exports that already exist when it runs, and it was running before tweak_plan / optimise_full_second_pass / optimise_solar - all of which turn exports on. tweak_plan makes this concrete: it walks windows in time order and stops after 8, so the only exports it can add are at the front of the plan - exactly the ones the swap exists to push back. In #4478 the swap's own first move (22:00-22:30 -> 23:40-00:00) vacated the last export slot before the cheap rate, tweak_plan backfilled the gap at 15:40 because that is the only region it can reach, and nothing ran afterwards to defer it. The reporter saw the battery ramp down from 95% in the middle of the peak-rate window. Move the export swap to sit with the charge swap after every other pass. The charge swap was already moved there for the mirror-image reason and its comment says so; the export swap simply never got the same treatment. With the swap last, tweak_plan sees an intact 22:30 export and scores the front-of-day options as worse, so they are never enabled. Both golden debug cases are unchanged. Adds test_optimise_swap_export covering the pass itself (defer to an equal-priced later window, leave an already-last export alone, no-op when export optimisation is disabled) and the pass ordering, for both the tweak and second-pass branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c6702d0 commit 162ead5

3 files changed

Lines changed: 234 additions & 6 deletions

File tree

apps/predbat/plan.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4126,10 +4126,6 @@ def optimise_all_windows(self, best_metric, metric_keep, debug_mode=False):
41264126
record_export_windows,
41274127
debug_mode=debug_mode,
41284128
)
4129-
# Swaps
4130-
self.optimise_swap_export(record_charge_windows, record_export_windows, debug_mode=debug_mode)
4131-
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)
4132-
41334129
# Second pass optimisation
41344130
if self.calculate_second_pass:
41354131
# Full second pass (slower)
@@ -4144,8 +4140,14 @@ def optimise_all_windows(self, best_metric, metric_keep, debug_mode=False):
41444140
if self.export_more_solar:
41454141
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)
41464142

4147-
# Charge swap runs last, once all other passes have settled, so a strictly-improving pairwise
4148-
# charge move is not subsequently undone by a non-monotonic pass (tweak/second/solar).
4143+
# 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.
4149+
self.optimise_swap_export(record_charge_windows, record_export_windows, debug_mode=debug_mode)
4150+
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)
41494151
self.optimise_swap_charge(record_charge_windows, debug_mode=debug_mode)
41504152

41514153
self.plan_write_debug(debug_mode, "plan_raw.html", self.pv_forecast_minute_step, self.pv_forecast_minute10_step, self.load_minutes_step, self.load_minutes_step10, self.end_record)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# -----------------------------------------------------------------------------
2+
# Predbat Home Battery System
3+
# Copyright Trefor Southwell 2026 - All Rights Reserved
4+
# This application maybe used for personal use only and not for commercial use
5+
# -----------------------------------------------------------------------------
6+
# fmt off
7+
# pylint: disable=consider-using-f-string
8+
# pylint: disable=line-too-long
9+
# pylint: disable=attribute-defined-outside-init
10+
11+
"""Unit tests for the pairwise export-window swap optimisation pass.
12+
13+
optimise_swap_export moves an export later in the day when doing so costs no more than
14+
metric_min_improvement_swap (default -0.25p, i.e. a small regression is accepted to defer the
15+
export). Holding charge for longer hedges against the forecast being wrong, so a later export of
16+
equal value is always preferred.
17+
18+
The pass can only defer exports that exist when it runs, so these tests also pin its position in
19+
optimise_all_windows: it has to run after every pass that can turn an export on, otherwise the
20+
exports those passes add are never considered for deferral (#4478). tweak_plan in particular only
21+
walks the first few windows of the plan, so it can only ever add exports at the front - exactly the
22+
ones this pass exists to push back.
23+
"""
24+
25+
from tests.test_infra import reset_rates, reset_inverter, update_rates_export
26+
from prediction import Prediction
27+
28+
29+
def setup_swap_export(
30+
my_predbat,
31+
export_window_best,
32+
export_limits_best,
33+
rate_import=30.0,
34+
rate_export=15.0,
35+
battery_size=10.0,
36+
battery_soc=10.0,
37+
export_rate=10.0,
38+
):
39+
"""Configure my_predbat for an export-swap test and return the starting metric and cost."""
40+
end_record = my_predbat.forecast_minutes
41+
my_predbat.end_record = end_record
42+
my_predbat.calculate_best_charge = True
43+
my_predbat.calculate_best_export = True
44+
my_predbat.calculate_export_oncharge = True
45+
my_predbat.set_export_freeze = True
46+
my_predbat.soc_max = battery_size
47+
my_predbat.soc_kw = battery_soc
48+
my_predbat.reserve = 0.0
49+
my_predbat.best_soc_keep = 0.0
50+
my_predbat.manual_all_times = set()
51+
my_predbat.iboost_enable = False
52+
my_predbat.iboost_plan = []
53+
my_predbat.iboost_on_export = False
54+
my_predbat.car_charging_from_battery = True
55+
my_predbat.num_cars = 0
56+
my_predbat.car_charging_slots = [[]]
57+
my_predbat.metric_min_improvement_swap = -0.25
58+
59+
# A generous battery export rate so a 30 minute window can move a meaningful amount of energy
60+
my_predbat.battery_rate_max_discharge = export_rate / 60.0
61+
my_predbat.battery_rate_max_export = export_rate / 60.0
62+
63+
reset_rates(my_predbat, rate_import, rate_export)
64+
update_rates_export(my_predbat, export_window_best)
65+
66+
# No solar and a small flat load
67+
pv_step = {}
68+
load_step = {}
69+
for minute in range(0, my_predbat.forecast_minutes, 5):
70+
pv_step[minute] = 0.0
71+
load_step[minute] = 0.2 / (60 / 5)
72+
my_predbat.load_minutes_step = load_step
73+
my_predbat.load_minutes_step10 = load_step
74+
my_predbat.pv_forecast_minute_step = pv_step
75+
my_predbat.pv_forecast_minute10_step = pv_step
76+
my_predbat.prediction = Prediction(my_predbat, pv_step, pv_step, load_step, load_step)
77+
my_predbat.debug_enable = False
78+
79+
my_predbat.charge_window_best = []
80+
my_predbat.charge_limit_best = []
81+
my_predbat.export_window_best = export_window_best
82+
my_predbat.export_limits_best = list(export_limits_best)
83+
84+
best_metric, _, best_cost, _, _, _, _, _ = my_predbat.run_prediction_metric(
85+
my_predbat.charge_limit_best, my_predbat.charge_window_best, my_predbat.export_window_best, my_predbat.export_limits_best, end_record=end_record
86+
)
87+
return best_metric, best_cost
88+
89+
90+
def _record_pass_order(my_predbat):
91+
"""Stub out every optimisation pass so optimise_all_windows just records the call order.
92+
93+
Returns (order, restore): order is the list the stubs append to, filled in when
94+
optimise_all_windows is called, and restore() puts the real methods back. my_predbat is shared
95+
across the whole test run, so leaving the stubs in place would break every later test.
96+
"""
97+
order = []
98+
stubbed = ("optimise_levels_pass", "optimise_detailed_pass", "optimise_full_second_pass", "tweak_plan", "optimise_solar", "optimise_swap_export", "optimise_swap_charge", "optimise_charge_windows_reset", "optimise_charge_windows_manual", "plan_write_debug")
99+
saved = {name: my_predbat.__dict__.get(name, None) for name in stubbed}
100+
101+
def restore():
102+
"""Remove the stubs so the class methods are visible again."""
103+
for name, value in saved.items():
104+
if value is None:
105+
my_predbat.__dict__.pop(name, None)
106+
else:
107+
my_predbat.__dict__[name] = value
108+
109+
def stub(name, result):
110+
"""Build a recording stub returning a fixed result."""
111+
112+
def _call(*args, **kwargs):
113+
"""Record the call and return the canned result."""
114+
order.append(name)
115+
return result
116+
117+
return _call
118+
119+
zeros12 = tuple([0.0] * 12)
120+
zeros8 = tuple([0.0] * 8)
121+
zeros6 = tuple([0.0] * 6)
122+
123+
my_predbat.optimise_levels_pass = stub("levels", zeros12)
124+
my_predbat.optimise_detailed_pass = stub("detailed", zeros8)
125+
my_predbat.optimise_full_second_pass = stub("second_pass", zeros8)
126+
my_predbat.tweak_plan = stub("tweak", zeros6)
127+
my_predbat.optimise_solar = stub("solar", zeros6)
128+
my_predbat.optimise_swap_export = stub("swap_export", None)
129+
my_predbat.optimise_swap_charge = stub("swap_charge", None)
130+
131+
# Passes that are not part of the ordering under test but would otherwise touch the plan
132+
my_predbat.optimise_charge_windows_reset = lambda reset_all: None
133+
my_predbat.optimise_charge_windows_manual = lambda: None
134+
my_predbat.plan_write_debug = lambda *args, **kwargs: None
135+
136+
return order, restore
137+
138+
139+
def run_optimise_swap_export_tests(my_predbat):
140+
"""Run the pairwise export-window swap optimisation tests and return True on failure."""
141+
print("**** Running Optimise swap export tests ****")
142+
reset_inverter(my_predbat)
143+
failed = False
144+
145+
# ---------------------------------------------------------------------------------------------
146+
# Beneficial swap: the plan exports in the first window while an identically priced later window
147+
# sits idle. Deferring the export holds the charge for longer at no extra cost, so the pass must
148+
# move it to the later window.
149+
# ---------------------------------------------------------------------------------------------
150+
export_window_best = [
151+
{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 15.0},
152+
{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 60, "average": 15.0},
153+
]
154+
setup_swap_export(my_predbat, export_window_best, export_limits_best=[0.0, 100.0])
155+
my_predbat.optimise_swap_export(0, len(export_window_best))
156+
if not (my_predbat.export_limits_best[0] == 100.0 and my_predbat.export_limits_best[1] == 0.0):
157+
print("ERROR: equal priced windows should defer the export to the later window, got limits {}".format(my_predbat.export_limits_best))
158+
failed = True
159+
160+
# ---------------------------------------------------------------------------------------------
161+
# Safety invariant: an export already in the latest window has nowhere better to go and must be
162+
# left alone.
163+
# ---------------------------------------------------------------------------------------------
164+
export_window_best = [
165+
{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 15.0},
166+
{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 60, "average": 15.0},
167+
]
168+
setup_swap_export(my_predbat, export_window_best, export_limits_best=[100.0, 0.0])
169+
my_predbat.optimise_swap_export(0, len(export_window_best))
170+
if not (my_predbat.export_limits_best[0] == 100.0 and my_predbat.export_limits_best[1] == 0.0):
171+
print("ERROR: an export already in the last window should be unchanged, got limits {}".format(my_predbat.export_limits_best))
172+
failed = True
173+
174+
# ---------------------------------------------------------------------------------------------
175+
# Guard: with export optimisation disabled the pass returns immediately without changes.
176+
# ---------------------------------------------------------------------------------------------
177+
export_window_best = [
178+
{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 15.0},
179+
{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 60, "average": 15.0},
180+
]
181+
setup_swap_export(my_predbat, export_window_best, export_limits_best=[0.0, 100.0])
182+
my_predbat.calculate_best_export = False
183+
my_predbat.optimise_swap_export(0, len(export_window_best))
184+
if my_predbat.export_limits_best != [0.0, 100.0]:
185+
print("ERROR: disabled export optimisation should be unchanged, got {}".format(my_predbat.export_limits_best))
186+
failed = True
187+
my_predbat.calculate_best_export = True
188+
189+
# ---------------------------------------------------------------------------------------------
190+
# Ordering regression (#4478): the export swap must run after every pass that can enable an
191+
# export, otherwise the exports tweak_plan / the second pass / optimise_solar add are never
192+
# considered for deferral and stay pinned at the front of the plan.
193+
# ---------------------------------------------------------------------------------------------
194+
for second_pass in (False, True):
195+
my_predbat.calculate_second_pass = second_pass
196+
my_predbat.export_more_solar = True
197+
my_predbat.charge_window_best = []
198+
my_predbat.charge_limit_best = []
199+
my_predbat.export_window_best = []
200+
my_predbat.export_limits_best = []
201+
order, restore = _record_pass_order(my_predbat)
202+
try:
203+
my_predbat.optimise_all_windows(0.0, 0.0)
204+
finally:
205+
restore()
206+
207+
adding_pass = "second_pass" if second_pass else "tweak"
208+
if "swap_export" not in order:
209+
print("ERROR: optimise_swap_export was not called at all, order {}".format(order))
210+
failed = True
211+
elif order.count("swap_export") != 1:
212+
print("ERROR: optimise_swap_export should be called exactly once, order {}".format(order))
213+
failed = True
214+
else:
215+
for earlier in (adding_pass, "solar"):
216+
if earlier in order and order.index("swap_export") < order.index(earlier):
217+
print("ERROR: optimise_swap_export runs before {} so exports it adds are never deferred, order {}".format(earlier, order))
218+
failed = True
219+
220+
if failed:
221+
print("**** Optimise swap export tests FAILED ****")
222+
else:
223+
print("**** Optimise swap export tests passed ****")
224+
return failed

apps/predbat/unit_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from tests.test_optimise_all_windows import run_optimise_all_windows_kernel_tests
4242
from tests.test_optimise_solar import run_optimise_solar_tests
4343
from tests.test_optimise_swap_charge import run_optimise_swap_charge_tests
44+
from tests.test_optimise_swap_export import run_optimise_swap_export_tests
4445
from tests.test_nordpool import run_nordpool_test
4546
from tests.test_futurerate_auto import test_futurerate_auto
4647
from tests.test_car_charging_smart import run_car_charging_smart_tests
@@ -484,6 +485,7 @@ def main():
484485
("optimise_windows_kernel", run_optimise_all_windows_kernel_tests, "Optimise all windows tests with/without the C++ kernel", True),
485486
("optimise_solar", run_optimise_solar_tests, "Optimise export more solar tests", False),
486487
("optimise_swap_charge", run_optimise_swap_charge_tests, "Optimise pairwise charge-window swap tests", False),
488+
("optimise_swap_export", run_optimise_swap_export_tests, "Optimise pairwise export-window swap tests", False),
487489
("debug_cases", run_debug_cases, "Debug case file tests", True),
488490
("annual_config", test_annual_config, "Annual prediction config validation tests", False),
489491
("annual_bootstrap", test_annual_bootstrap, "Annual prediction bootstrap and state reset tests", False),

0 commit comments

Comments
 (0)