|
| 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 |
0 commit comments