Skip to content

Commit 6477e28

Browse files
springfall2008claudepre-commit-ci-lite[bot]
authored
fix(plan): run the export swap after the passes that add exports (#4478) (#4513)
* 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> * [pre-commit.ci lite] apply automatic fixes * Rebase random * Remove supurious warning * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent c6702d0 commit 6477e28

5 files changed

Lines changed: 277 additions & 42 deletions

File tree

apps/predbat/fetch.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2434,8 +2434,6 @@ def fetch_config_options(self):
24342434
# paths in plan.py, and the pv90 term collapsing out of compute_metric) stays inert until
24352435
# the user explicitly turns the switch on - no separate gating on calculate_pv90_plan is
24362436
# added anywhere else, this is the single choke point.
2437-
if self.pv_metric90_weight:
2438-
self.log("Warn: calculate_pv90_plan is Off so forcing pv_metric90_weight from {} to 0.0 - turn on switch.predbat_calculate_pv90_plan (expert mode) to enable the PV90 upside scenario".format(self.pv_metric90_weight))
24392437
self.pv_metric90_weight = 0.0
24402438

24412439
self.charge_scaling10 = self.get_arg("charge_scaling10")

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: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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(my_predbat.charge_limit_best, my_predbat.charge_window_best, my_predbat.export_window_best, my_predbat.export_limits_best, end_record=end_record)
85+
return best_metric, best_cost
86+
87+
88+
def _record_pass_order(my_predbat):
89+
"""Stub out every optimisation pass so optimise_all_windows just records the call order.
90+
91+
Returns (order, restore): order is the list the stubs append to, filled in when
92+
optimise_all_windows is called, and restore() puts the real methods back. my_predbat is shared
93+
across the whole test run, so leaving the stubs in place would break every later test.
94+
"""
95+
order = []
96+
stubbed = (
97+
"optimise_levels_pass",
98+
"optimise_detailed_pass",
99+
"optimise_full_second_pass",
100+
"tweak_plan",
101+
"optimise_solar",
102+
"optimise_swap_export",
103+
"optimise_swap_charge",
104+
"optimise_charge_windows_reset",
105+
"optimise_charge_windows_manual",
106+
"plan_write_debug",
107+
)
108+
saved = {name: my_predbat.__dict__.get(name, None) for name in stubbed}
109+
110+
def restore():
111+
"""Remove the stubs so the class methods are visible again."""
112+
for name, value in saved.items():
113+
if value is None:
114+
my_predbat.__dict__.pop(name, None)
115+
else:
116+
my_predbat.__dict__[name] = value
117+
118+
def stub(name, result):
119+
"""Build a recording stub returning a fixed result."""
120+
121+
def _call(*args, **kwargs):
122+
"""Record the call and return the canned result."""
123+
order.append(name)
124+
return result
125+
126+
return _call
127+
128+
zeros12 = tuple([0.0] * 12)
129+
zeros8 = tuple([0.0] * 8)
130+
zeros6 = tuple([0.0] * 6)
131+
132+
my_predbat.optimise_levels_pass = stub("levels", zeros12)
133+
my_predbat.optimise_detailed_pass = stub("detailed", zeros8)
134+
my_predbat.optimise_full_second_pass = stub("second_pass", zeros8)
135+
my_predbat.tweak_plan = stub("tweak", zeros6)
136+
my_predbat.optimise_solar = stub("solar", zeros6)
137+
my_predbat.optimise_swap_export = stub("swap_export", None)
138+
my_predbat.optimise_swap_charge = stub("swap_charge", None)
139+
140+
# Passes that are not part of the ordering under test but would otherwise touch the plan
141+
my_predbat.optimise_charge_windows_reset = lambda reset_all: None
142+
my_predbat.optimise_charge_windows_manual = lambda: None
143+
my_predbat.plan_write_debug = lambda *args, **kwargs: None
144+
145+
return order, restore
146+
147+
148+
def run_optimise_swap_export_tests(my_predbat):
149+
"""Run the pairwise export-window swap optimisation tests and return True on failure."""
150+
print("**** Running Optimise swap export tests ****")
151+
reset_inverter(my_predbat)
152+
failed = False
153+
154+
# ---------------------------------------------------------------------------------------------
155+
# Beneficial swap: the plan exports in the first window while an identically priced later window
156+
# sits idle. Deferring the export holds the charge for longer at no extra cost, so the pass must
157+
# move it to the later window.
158+
# ---------------------------------------------------------------------------------------------
159+
export_window_best = [
160+
{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 15.0},
161+
{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 60, "average": 15.0},
162+
]
163+
setup_swap_export(my_predbat, export_window_best, export_limits_best=[0.0, 100.0])
164+
my_predbat.optimise_swap_export(0, len(export_window_best))
165+
if not (my_predbat.export_limits_best[0] == 100.0 and my_predbat.export_limits_best[1] == 0.0):
166+
print("ERROR: equal priced windows should defer the export to the later window, got limits {}".format(my_predbat.export_limits_best))
167+
failed = True
168+
169+
# ---------------------------------------------------------------------------------------------
170+
# Safety invariant: an export already in the latest window has nowhere better to go and must be
171+
# left alone.
172+
# ---------------------------------------------------------------------------------------------
173+
export_window_best = [
174+
{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 15.0},
175+
{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 60, "average": 15.0},
176+
]
177+
setup_swap_export(my_predbat, export_window_best, export_limits_best=[100.0, 0.0])
178+
my_predbat.optimise_swap_export(0, len(export_window_best))
179+
if not (my_predbat.export_limits_best[0] == 100.0 and my_predbat.export_limits_best[1] == 0.0):
180+
print("ERROR: an export already in the last window should be unchanged, got limits {}".format(my_predbat.export_limits_best))
181+
failed = True
182+
183+
# ---------------------------------------------------------------------------------------------
184+
# Guard: with export optimisation disabled the pass returns immediately without changes.
185+
# ---------------------------------------------------------------------------------------------
186+
export_window_best = [
187+
{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 30, "average": 15.0},
188+
{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 60, "average": 15.0},
189+
]
190+
setup_swap_export(my_predbat, export_window_best, export_limits_best=[0.0, 100.0])
191+
my_predbat.calculate_best_export = False
192+
my_predbat.optimise_swap_export(0, len(export_window_best))
193+
if my_predbat.export_limits_best != [0.0, 100.0]:
194+
print("ERROR: disabled export optimisation should be unchanged, got {}".format(my_predbat.export_limits_best))
195+
failed = True
196+
my_predbat.calculate_best_export = True
197+
198+
# ---------------------------------------------------------------------------------------------
199+
# Ordering regression (#4478): the export swap must run after every pass that can enable an
200+
# export, otherwise the exports tweak_plan / the second pass / optimise_solar add are never
201+
# considered for deferral and stay pinned at the front of the plan.
202+
# ---------------------------------------------------------------------------------------------
203+
for second_pass in (False, True):
204+
my_predbat.calculate_second_pass = second_pass
205+
my_predbat.export_more_solar = True
206+
my_predbat.charge_window_best = []
207+
my_predbat.charge_limit_best = []
208+
my_predbat.export_window_best = []
209+
my_predbat.export_limits_best = []
210+
order, restore = _record_pass_order(my_predbat)
211+
try:
212+
my_predbat.optimise_all_windows(0.0, 0.0)
213+
finally:
214+
restore()
215+
216+
adding_pass = "second_pass" if second_pass else "tweak"
217+
if "swap_export" not in order:
218+
print("ERROR: optimise_swap_export was not called at all, order {}".format(order))
219+
failed = True
220+
elif order.count("swap_export") != 1:
221+
print("ERROR: optimise_swap_export should be called exactly once, order {}".format(order))
222+
failed = True
223+
else:
224+
for earlier in (adding_pass, "solar"):
225+
if earlier in order and order.index("swap_export") < order.index(earlier):
226+
print("ERROR: optimise_swap_export runs before {} so exports it adds are never deferred, order {}".format(earlier, order))
227+
failed = True
228+
229+
if failed:
230+
print("**** Optimise swap export tests FAILED ****")
231+
else:
232+
print("**** Optimise swap export tests passed ****")
233+
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)