From fde9a75a9c536e3cef575880c25a9cd10fec3426 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Sat, 4 Jul 2026 01:33:17 +0100 Subject: [PATCH 1/6] Add characterisation tests for update_pred plan/execute flow Co-Authored-By: Claude Fable 5 --- .cspell/custom-dictionary-workspace.txt | 1 + apps/predbat/tests/test_plan_execute_split.py | 216 ++++++++++++++++++ apps/predbat/unit_test.py | 2 + 3 files changed, 219 insertions(+) create mode 100644 apps/predbat/tests/test_plan_execute_split.py diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index ef4ded9fb..6f8b5a0d3 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -323,6 +323,7 @@ randn rarr recp Redownload +refetched regionid regname remainings diff --git a/apps/predbat/tests/test_plan_execute_split.py b/apps/predbat/tests/test_plan_execute_split.py new file mode 100644 index 000000000..489566f16 --- /dev/null +++ b/apps/predbat/tests/test_plan_execute_split.py @@ -0,0 +1,216 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init +"""Characterisation and unit tests for the plan_once()/execute_once() split of update_pred().""" + +from contextlib import ExitStack +from datetime import timedelta +from unittest.mock import patch + +_STATE_ATTRS = [ + "plugin_system", + "iboost_enable", + "calculate_savings", + "debug_enable", + "num_cars", + "holiday_days_left", + "comparison", + "components", + "rate_min", + "rate_max", + "calculate_plan_every", + "plan_valid", + "plan_last_updated", + "had_errors", + "count_inverter_writes", +] + + +def _save_state(my_predbat): + """Snapshot the shared fixture attributes this module mutates, so they can be restored.""" + saved = {name: getattr(my_predbat, name, None) for name in _STATE_ATTRS} + saved["_template_arg"] = my_predbat.args.get("template", None) + return saved + + +def _restore_state(my_predbat, saved): + """Restore fixture attributes captured by _save_state (the fixture is shared across the whole test run).""" + for name in _STATE_ATTRS: + setattr(my_predbat, name, saved[name]) + if saved["_template_arg"] is None: + my_predbat.args.pop("template", None) + else: + my_predbat.args["template"] = saved["_template_arg"] + + +def _quiet_bookkeeping(my_predbat): + """Steer update_pred away from optional bookkeeping branches so the mock surface stays small.""" + my_predbat.plugin_system = None + my_predbat.iboost_enable = False + my_predbat.calculate_savings = False + my_predbat.debug_enable = False + my_predbat.num_cars = 0 + my_predbat.holiday_days_left = 0 + my_predbat.comparison = None + my_predbat.components = None + my_predbat.rate_min = 5.0 + my_predbat.rate_max = 30.0 + my_predbat.calculate_plan_every = 10 + my_predbat.had_errors = False + my_predbat.count_inverter_writes = {} + my_predbat.args.pop("template", None) + + +def _patch_pipeline(my_predbat, stack, inverter_ok=True, plan_valid_after=True): + """Patch the fetch/plan/execute pipeline on my_predbat, returning a dict of the created mocks.""" + mocks = {} + for name in ("update_time", "save_current_config", "download_predbat_releases", "fetch_config_options", "publish_rate_and_threshold", "save_plan"): + mocks[name] = stack.enter_context(patch.object(my_predbat, name)) + mocks["fetch_sensor_data"] = stack.enter_context(patch.object(my_predbat, "fetch_sensor_data", return_value=False)) + mocks["dynamic_load"] = stack.enter_context(patch.object(my_predbat, "dynamic_load", return_value=False)) + mocks["fetch_inverter_data"] = stack.enter_context(patch.object(my_predbat, "fetch_inverter_data", return_value=inverter_ok)) + + def fake_calculate_plan(recompute=True, debug_mode=False, publish=True): + """Stand-in for calculate_plan that marks the plan valid when a recompute is requested.""" + if recompute and plan_valid_after: + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + return recompute + + mocks["calculate_plan"] = stack.enter_context(patch.object(my_predbat, "calculate_plan", side_effect=fake_calculate_plan)) + mocks["execute_plan"] = stack.enter_context(patch.object(my_predbat, "execute_plan", return_value=("Demand", ""))) + return mocks + + +def _check(failed, condition, message): + """Print OK/ERROR for a single assertion and accumulate the failure count.""" + if condition: + print("OK: {}".format(message)) + return failed + print("ERROR: {}".format(message)) + return failed + 1 + + +def _scenario_fused_fresh_plan(my_predbat): + """Characterise: scheduled run with a fresh valid plan reuses it - no recompute, no save, single execute.""" + failed = 0 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + my_predbat.update_pred(scheduled=True) + failed = _check(failed, mocks["calculate_plan"].call_count == 1, "fresh plan: calculate_plan called once") + failed = _check(failed, mocks["calculate_plan"].call_args.kwargs.get("recompute") is False, "fresh plan: calculate_plan called with recompute=False") + failed = _check(failed, mocks["save_plan"].call_count == 0, "fresh plan: save_plan not called") + failed = _check(failed, mocks["execute_plan"].call_count == 1, "fresh plan: execute_plan called once") + failed = _check(failed, mocks["fetch_inverter_data"].call_count == 1, "fresh plan: inverter data fetched once (no refetch)") + return failed + + +def _scenario_fused_recompute(my_predbat): + """Characterise: invalid plan forces a recompute - save_plan called, inverter refetched before execute.""" + failed = 0 + my_predbat.plan_valid = False + my_predbat.plan_last_updated = None + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + my_predbat.update_pred(scheduled=True) + failed = _check(failed, mocks["calculate_plan"].call_count == 1, "recompute: calculate_plan called once") + failed = _check(failed, mocks["calculate_plan"].call_args.kwargs.get("recompute") is True, "recompute: calculate_plan called with recompute=True") + failed = _check(failed, mocks["save_plan"].call_count == 1, "recompute: save_plan called once") + failed = _check(failed, mocks["execute_plan"].call_count == 1, "recompute: execute_plan called once") + failed = _check(failed, mocks["fetch_inverter_data"].call_count == 2, "recompute: inverter data fetched twice (pre-plan and refetch)") + return failed + + +def _scenario_fused_aged_plan(my_predbat): + """Characterise: an aged valid plan executes first, then recomputes and executes again (and does NOT save - existing quirk).""" + failed = 0 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc - timedelta(minutes=9) + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + my_predbat.update_pred(scheduled=True) + failed = _check(failed, mocks["calculate_plan"].call_count == 2, "aged plan: calculate_plan called twice") + first_kwargs = mocks["calculate_plan"].call_args_list[0].kwargs + second_kwargs = mocks["calculate_plan"].call_args_list[1].kwargs + failed = _check(failed, first_kwargs.get("recompute") is False, "aged plan: first calculate_plan with recompute=False") + failed = _check(failed, second_kwargs.get("recompute") is True, "aged plan: second calculate_plan with recompute=True") + failed = _check(failed, mocks["execute_plan"].call_count == 2, "aged plan: execute_plan called twice") + failed = _check(failed, mocks["fetch_inverter_data"].call_count == 2, "aged plan: inverter data fetched twice") + # Existing upstream quirk: the aged-plan recompute does not persist via save_plan. Preserve, do not fix. + failed = _check(failed, mocks["save_plan"].call_count == 0, "aged plan: save_plan not called (existing quirk preserved)") + return failed + + +def _scenario_fused_inverter_failure(my_predbat): + """Characterise: inverter fetch failure aborts the run before planning or executing.""" + failed = 0 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + my_predbat.had_errors = False + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack, inverter_ok=False) + my_predbat.update_pred(scheduled=True) + failed = _check(failed, mocks["calculate_plan"].call_count == 0, "inverter failure: calculate_plan not called") + failed = _check(failed, mocks["execute_plan"].call_count == 0, "inverter failure: execute_plan not called") + failed = _check(failed, my_predbat.had_errors, "inverter failure: had_errors set") + return failed + + +def _scenario_fused_zero_rates(my_predbat): + """Characterise: all-zero import rates abort the run before planning or executing.""" + failed = 0 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + my_predbat.had_errors = False + my_predbat.rate_min = 0 + my_predbat.rate_max = 0 + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + my_predbat.update_pred(scheduled=True) + my_predbat.rate_min = 5.0 + my_predbat.rate_max = 30.0 + failed = _check(failed, mocks["calculate_plan"].call_count == 0, "zero rates: calculate_plan not called") + failed = _check(failed, mocks["execute_plan"].call_count == 0, "zero rates: execute_plan not called") + failed = _check(failed, my_predbat.had_errors, "zero rates: had_errors set") + return failed + + +def _scenario_fused_template(my_predbat): + """Characterise: template configuration aborts the run before any fetching.""" + failed = 0 + my_predbat.had_errors = False + my_predbat.args["template"] = True + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + my_predbat.update_pred(scheduled=True) + my_predbat.args.pop("template", None) + failed = _check(failed, mocks["fetch_config_options"].call_count == 0, "template: fetch_config_options not called") + failed = _check(failed, mocks["execute_plan"].call_count == 0, "template: execute_plan not called") + failed = _check(failed, my_predbat.had_errors, "template: had_errors set") + return failed + + +def test_plan_execute_split(my_predbat): + """Entry point registered in unit_test.py - characterises update_pred and tests plan_once/execute_once.""" + print("**** Running plan/execute split tests ****") + failed = 0 + saved = _save_state(my_predbat) + try: + _quiet_bookkeeping(my_predbat) + failed += _scenario_fused_fresh_plan(my_predbat) + failed += _scenario_fused_recompute(my_predbat) + failed += _scenario_fused_aged_plan(my_predbat) + failed += _scenario_fused_inverter_failure(my_predbat) + failed += _scenario_fused_zero_rates(my_predbat) + failed += _scenario_fused_template(my_predbat) + finally: + _restore_state(my_predbat, saved) + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 2b083b40a..f6d93c606 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -39,6 +39,7 @@ from tests.test_car_charging_smart import run_car_charging_smart_tests from tests.test_plugin_startup import test_plugin_startup_order from tests.test_active_flag import test_active_flag +from tests.test_plan_execute_split import test_plan_execute_split from tests.test_optimise_levels import run_optimise_levels_tests from tests.test_export_commitment import run_export_commitment_tests from tests.test_energydataservice import run_energydataservice_tests @@ -249,6 +250,7 @@ def main(): ("add_now_to_octopus_slot", test_add_now_to_octopus_slot, "Add now to Octopus slot tests", False), ("plugin_startup", test_plugin_startup_order, "Plugin startup order tests", False), ("active_flag", test_active_flag, "Active flag cleared on exception tests", False), + ("plan_execute_split", test_plan_execute_split, "plan_once/execute_once split and update_pred characterisation tests", False), ("dynamic_load_car", test_dynamic_load_car_slot_cancellation, "Dynamic load car slot cancellation tests", False), ("units", run_test_units, "Unit tests", False), ("manual_api", run_test_manual_api, "Manual API tests", False), From a07f42e642e787cdb424d4b17399fd748efc0af1 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Sat, 4 Jul 2026 09:59:22 +0100 Subject: [PATCH 2/6] Extract plan_once() from update_pred Co-Authored-By: Claude Fable 5 --- apps/predbat/predbat.py | 35 ++++++-- apps/predbat/tests/test_plan_execute_split.py | 80 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 53060f66d..8f96f0f3b 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -741,13 +741,16 @@ def load_plan(self): self.plan_valid = True self.log("Restored saved plan from {:.0f} minutes ago: {} charge windows, {} export windows".format(age_minutes, len(self.charge_window_best), len(self.export_window_best))) - def update_pred(self, scheduled=True): + def plan_once(self, scheduled=True): """ - Update the prediction state, everything is called from here right now + Refresh input data and recompute the plan if required, without executing it. + + Returns None when a pre-condition fails (template configuration, inverter data + unavailable or import rates all zero) - the error will already have been recorded. + Otherwise returns a plan artifact summary dict with keys recomputed, plan_valid + and plan_last_updated. Passing scheduled=False forces a recompute. """ recompute = False - status_extra = "" - self.had_errors = False self.update_time() self.save_current_config() @@ -760,7 +763,7 @@ def update_pred(self, scheduled=True): if self.get_arg("template", False): self.log("Error: You have not completed editing the apps.yaml template, Predbat cannot run. Please comment out 'Template: True' line in apps.yaml to start Predbat running") self.record_status("Error: Template Configuration, remove 'Template: True' line in apps.yaml to start predbat running", had_errors=True) - return + return None self.expose_config("active", True) self.fetch_config_options() @@ -775,13 +778,13 @@ def update_pred(self, scheduled=True): if not self.fetch_inverter_data(): self.log("Error: Failed to fetch inverter data, not able to compute a plan") self.record_status("Error: Failed to fetch inverter data, not able to compute a plan", had_errors=True) - return + return None # Check if we have valid import rates if self.rate_min == self.rate_max == 0: self.log("Error: Import rates are all zero, not able to compute a plan") self.record_status("Error: Import rates are all zero, not able to compute a plan", had_errors=True) - return + return None if self.dynamic_load(): self.log("Dynamic load adjustment changed, will recompute the plan") @@ -805,6 +808,24 @@ def update_pred(self, scheduled=True): # Publish rate data self.publish_rate_and_threshold() + return { + "recomputed": recompute, + "plan_valid": self.plan_valid, + "plan_last_updated": self.plan_last_updated, + } + + def update_pred(self, scheduled=True): + """ + Update the prediction state, everything is called from here right now + """ + status_extra = "" + self.had_errors = False + + plan = self.plan_once(scheduled=scheduled) + if plan is None: + return + recompute = plan["recomputed"] + # Execute the plan, re-read the inverter first if we had to calculate (as time passes during calculations) if recompute: if not self.fetch_inverter_data(): diff --git a/apps/predbat/tests/test_plan_execute_split.py b/apps/predbat/tests/test_plan_execute_split.py index 489566f16..5da9b3b2a 100644 --- a/apps/predbat/tests/test_plan_execute_split.py +++ b/apps/predbat/tests/test_plan_execute_split.py @@ -198,6 +198,82 @@ def _scenario_fused_template(my_predbat): return failed +def _scenario_plan_once_recompute(my_predbat): + """plan_once with an invalid plan recomputes, persists, publishes rates and returns the artifact - without executing.""" + failed = 0 + my_predbat.plan_valid = False + my_predbat.plan_last_updated = None + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=True) + failed = _check(failed, isinstance(artifact, dict), "plan_once recompute: returns a dict artifact") + failed = _check(failed, artifact and artifact.get("recomputed") is True, "plan_once recompute: artifact recomputed True") + failed = _check(failed, artifact and artifact.get("plan_valid") is True, "plan_once recompute: artifact plan_valid True") + failed = _check(failed, artifact and artifact.get("plan_last_updated") == my_predbat.plan_last_updated, "plan_once recompute: artifact timestamp matches instance state") + failed = _check(failed, mocks["save_plan"].call_count == 1, "plan_once recompute: save_plan called once") + failed = _check(failed, mocks["publish_rate_and_threshold"].call_count == 1, "plan_once recompute: rates published") + failed = _check(failed, mocks["execute_plan"].call_count == 0, "plan_once recompute: execute_plan NOT called") + failed = _check(failed, mocks["fetch_inverter_data"].call_count == 1, "plan_once recompute: inverter fetched once only") + return failed + + +def _scenario_plan_once_reuse(my_predbat): + """plan_once with a fresh valid plan reuses it - recomputed False and nothing persisted.""" + failed = 0 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=True) + failed = _check(failed, artifact and artifact.get("recomputed") is False, "plan_once reuse: artifact recomputed False") + failed = _check(failed, mocks["save_plan"].call_count == 0, "plan_once reuse: save_plan not called") + failed = _check(failed, mocks["execute_plan"].call_count == 0, "plan_once reuse: execute_plan NOT called") + return failed + + +def _scenario_plan_once_unscheduled_forces(my_predbat): + """plan_once with scheduled=False forces a recompute even when the plan is fresh and valid.""" + failed = 0 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=False) + failed = _check(failed, artifact and artifact.get("recomputed") is True, "plan_once unscheduled: recompute forced") + failed = _check(failed, mocks["calculate_plan"].call_args.kwargs.get("recompute") is True, "plan_once unscheduled: calculate_plan recompute=True") + return failed + + +def _scenario_plan_once_failures(my_predbat): + """plan_once returns None on inverter-fetch failure, zero rates and template configuration.""" + failed = 0 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack, inverter_ok=False) + artifact = my_predbat.plan_once(scheduled=True) + failed = _check(failed, artifact is None, "plan_once inverter failure: returns None") + failed = _check(failed, mocks["calculate_plan"].call_count == 0, "plan_once inverter failure: calculate_plan not called") + + my_predbat.rate_min = 0 + my_predbat.rate_max = 0 + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=True) + my_predbat.rate_min = 5.0 + my_predbat.rate_max = 30.0 + failed = _check(failed, artifact is None, "plan_once zero rates: returns None") + + my_predbat.args["template"] = True + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=True) + my_predbat.args.pop("template", None) + failed = _check(failed, artifact is None, "plan_once template: returns None") + return failed + + def test_plan_execute_split(my_predbat): """Entry point registered in unit_test.py - characterises update_pred and tests plan_once/execute_once.""" print("**** Running plan/execute split tests ****") @@ -211,6 +287,10 @@ def test_plan_execute_split(my_predbat): failed += _scenario_fused_inverter_failure(my_predbat) failed += _scenario_fused_zero_rates(my_predbat) failed += _scenario_fused_template(my_predbat) + failed += _scenario_plan_once_recompute(my_predbat) + failed += _scenario_plan_once_reuse(my_predbat) + failed += _scenario_plan_once_unscheduled_forces(my_predbat) + failed += _scenario_plan_once_failures(my_predbat) finally: _restore_state(my_predbat, saved) return failed From f6d0156257fa75ac94fbacb03d16cd98001e0288 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Sat, 4 Jul 2026 15:19:24 +0100 Subject: [PATCH 3/6] Extract execute_once() from update_pred Co-Authored-By: Claude Fable 5 --- .cspell/custom-dictionary-workspace.txt | 1 + apps/predbat/predbat.py | 33 +++++++++++++------ apps/predbat/tests/test_plan_execute_split.py | 28 ++++++++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 6f8b5a0d3..b6a3b50fc 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -324,6 +324,7 @@ rarr recp Redownload refetched +refetches regionid regname remainings diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 8f96f0f3b..5989f8a60 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -814,6 +814,22 @@ def plan_once(self, scheduled=True): "plan_last_updated": self.plan_last_updated, } + def execute_once(self, refetch_inverter=True): + """ + Execute the current plan against the inverters. + + When refetch_inverter is True the inverter data is re-read first, as time passes + during plan calculations. Returns None if the inverter data could not be fetched + (the error will already have been recorded), otherwise the (status, status_extra) + tuple from execute_plan(). + """ + if refetch_inverter: + if not self.fetch_inverter_data(): + self.log("Error: Failed to fetch inverter data, not able to execute the plan") + self.record_status("Error: Failed to fetch inverter data, not able to execute the plan", had_errors=True) + return None + return self.execute_plan() + def update_pred(self, scheduled=True): """ Update the prediction state, everything is called from here right now @@ -827,12 +843,10 @@ def update_pred(self, scheduled=True): recompute = plan["recomputed"] # Execute the plan, re-read the inverter first if we had to calculate (as time passes during calculations) - if recompute: - if not self.fetch_inverter_data(): - self.log("Error: Failed to fetch inverter data, not able to execute the plan") - self.record_status("Error: Failed to fetch inverter data, not able to execute the plan", had_errors=True) - return - status, status_extra = self.execute_plan() + executed = self.execute_once(refetch_inverter=recompute) + if executed is None: + return + status, status_extra = executed # If the plan was not updated, and the time has expired lets update it now if not recompute: @@ -849,11 +863,10 @@ def update_pred(self, scheduled=True): time.sleep(delay) # Calculate an updated plan, fetch the inverter data again and execute the plan self.calculate_plan(recompute=True) - if not self.fetch_inverter_data(): - self.log("Error: Failed to fetch inverter data, not able to execute the plan") - self.record_status("Error: Failed to fetch inverter data, not able to execute the plan", had_errors=True) + executed = self.execute_once(refetch_inverter=True) + if executed is None: return - status, status_extra = self.execute_plan() + status, status_extra = executed else: self.log("Will not recompute the plan, it is {} minutes old and max age is {} minutes".format(dp1(plan_age_minutes), self.calculate_plan_every)) diff --git a/apps/predbat/tests/test_plan_execute_split.py b/apps/predbat/tests/test_plan_execute_split.py index 5da9b3b2a..95f656ac5 100644 --- a/apps/predbat/tests/test_plan_execute_split.py +++ b/apps/predbat/tests/test_plan_execute_split.py @@ -274,6 +274,33 @@ def _scenario_plan_once_failures(my_predbat): return failed +def _scenario_execute_once(my_predbat): + """execute_once refetches inverter data when asked, skips when not, and returns None on fetch failure.""" + failed = 0 + + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + result = my_predbat.execute_once(refetch_inverter=True) + failed = _check(failed, result == ("Demand", ""), "execute_once refetch: returns execute_plan result") + failed = _check(failed, mocks["fetch_inverter_data"].call_count == 1, "execute_once refetch: inverter fetched once") + failed = _check(failed, mocks["execute_plan"].call_count == 1, "execute_once refetch: execute_plan called once") + + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack) + result = my_predbat.execute_once(refetch_inverter=False) + failed = _check(failed, result == ("Demand", ""), "execute_once no refetch: returns execute_plan result") + failed = _check(failed, mocks["fetch_inverter_data"].call_count == 0, "execute_once no refetch: inverter not fetched") + + my_predbat.had_errors = False + with ExitStack() as stack: + mocks = _patch_pipeline(my_predbat, stack, inverter_ok=False) + result = my_predbat.execute_once(refetch_inverter=True) + failed = _check(failed, result is None, "execute_once fetch failure: returns None") + failed = _check(failed, mocks["execute_plan"].call_count == 0, "execute_once fetch failure: execute_plan not called") + failed = _check(failed, my_predbat.had_errors, "execute_once fetch failure: had_errors set") + return failed + + def test_plan_execute_split(my_predbat): """Entry point registered in unit_test.py - characterises update_pred and tests plan_once/execute_once.""" print("**** Running plan/execute split tests ****") @@ -291,6 +318,7 @@ def test_plan_execute_split(my_predbat): failed += _scenario_plan_once_reuse(my_predbat) failed += _scenario_plan_once_unscheduled_forces(my_predbat) failed += _scenario_plan_once_failures(my_predbat) + failed += _scenario_execute_once(my_predbat) finally: _restore_state(my_predbat, saved) return failed From 7ceb6608c9c0ce92bc5672a2bc0353f29c9e0802 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Sat, 4 Jul 2026 15:30:40 +0100 Subject: [PATCH 4/6] Extract _post_run_bookkeeping(); update_pred is now pure composition Co-Authored-By: Claude Fable 5 --- apps/predbat/predbat.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 5989f8a60..048876f9d 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -870,6 +870,14 @@ def update_pred(self, scheduled=True): else: self.log("Will not recompute the plan, it is {} minutes old and max age is {} minutes".format(dp1(plan_age_minutes), self.calculate_plan_every)) + self._post_run_bookkeeping(status, status_extra, scheduled, recompute) + + def _post_run_bookkeeping(self, status, status_extra, scheduled, recompute): + """ + Post-run bookkeeping shared by every successful update cycle: plugin hooks, iBoost model + state, register-write counters, savings totals, car SoC, holiday countdown, status + recording, component health, tariff comparison and memory cleanup. + """ # Notify listeners that plan has been executed (consumed by gateway, plugins, etc.) if self.plugin_system: self.plugin_system.call_hooks( From fe0ea14f7a2a98eaf3361f3a44771d27442e9c30 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Sat, 4 Jul 2026 16:43:11 +0100 Subject: [PATCH 5/6] Add monotonic plan_version to the persisted plan artifact Co-Authored-By: Claude Fable 5 --- apps/predbat/predbat.py | 9 +- apps/predbat/tests/test_plan_execute_split.py | 89 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 048876f9d..318baaa8c 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -342,6 +342,7 @@ def reset(self): self.plan_valid = False self.plan_last_updated = None self.plan_last_updated_minutes = 0 + self.plan_version = 0 self.plugin_system = None self.calculate_plan_every = 5 self.prediction_started = False @@ -687,6 +688,7 @@ def save_plan(self): "export_limits_best": self.export_limits_best, "plan_last_updated": self.plan_last_updated.isoformat() if self.plan_last_updated else None, "plan_last_updated_minutes": self.plan_last_updated_minutes, + "plan_version": self.plan_version, } try: expiry = self.now_utc + timedelta(hours=8) @@ -738,6 +740,7 @@ def load_plan(self): self.export_limits_best = plan_data.get("export_limits_best", []) self.plan_last_updated = saved_dt self.plan_last_updated_minutes = plan_data.get("plan_last_updated_minutes", 0) + self.plan_version = plan_data.get("plan_version", 0) self.plan_valid = True self.log("Restored saved plan from {:.0f} minutes ago: {} charge windows, {} export windows".format(age_minutes, len(self.charge_window_best), len(self.export_window_best))) @@ -747,8 +750,8 @@ def plan_once(self, scheduled=True): Returns None when a pre-condition fails (template configuration, inverter data unavailable or import rates all zero) - the error will already have been recorded. - Otherwise returns a plan artifact summary dict with keys recomputed, plan_valid - and plan_last_updated. Passing scheduled=False forces a recompute. + Otherwise returns a plan artifact summary dict with keys recomputed, plan_valid, + plan_version and plan_last_updated. Passing scheduled=False forces a recompute. """ recompute = False @@ -803,6 +806,7 @@ def plan_once(self, scheduled=True): # Persist the plan so it can be restored immediately on next startup if recompute and self.plan_valid: + self.plan_version += 1 self.save_plan() # Publish rate data @@ -812,6 +816,7 @@ def plan_once(self, scheduled=True): "recomputed": recompute, "plan_valid": self.plan_valid, "plan_last_updated": self.plan_last_updated, + "plan_version": self.plan_version, } def execute_once(self, refetch_inverter=True): diff --git a/apps/predbat/tests/test_plan_execute_split.py b/apps/predbat/tests/test_plan_execute_split.py index 95f656ac5..21535392f 100644 --- a/apps/predbat/tests/test_plan_execute_split.py +++ b/apps/predbat/tests/test_plan_execute_split.py @@ -29,6 +29,12 @@ "plan_last_updated", "had_errors", "count_inverter_writes", + "plan_version", + "plan_last_updated_minutes", + "charge_window_best", + "charge_limit_best", + "export_window_best", + "export_limits_best", ] @@ -301,6 +307,87 @@ def _scenario_execute_once(my_predbat): return failed +class _FakeStorage: + """Minimal async stand-in for the storage component, capturing save_plan payloads in memory.""" + + def __init__(self): + """Create the empty in-memory store.""" + self.saved = {} + + async def save(self, namespace, key, data, format="json", expiry=None): + """Record the payload under (namespace, key).""" + self.saved[(namespace, key)] = data + + async def load(self, namespace, key): + """Return the previously saved payload, or None.""" + return self.saved.get((namespace, key)) + + +class _FakeComponents: + """Stub component registry exposing only a storage component.""" + + def __init__(self, storage): + """Wrap the given storage stub.""" + self._storage = storage + + def get_component(self, name): + """Return the storage stub for 'storage', None otherwise.""" + return self._storage if name == "storage" else None + + +def _scenario_plan_version_bump(my_predbat): + """plan_once bumps plan_version only when a recompute produced a valid plan.""" + failed = 0 + my_predbat.plan_version = 0 + + my_predbat.plan_valid = False + my_predbat.plan_last_updated = None + with ExitStack() as stack: + _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=True) + failed = _check(failed, my_predbat.plan_version == 1, "plan version: bumped to 1 on recompute") + failed = _check(failed, artifact and artifact.get("plan_version") == 1, "plan version: artifact carries version 1") + + with ExitStack() as stack: + _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=True) + failed = _check(failed, my_predbat.plan_version == 1, "plan version: unchanged on plan reuse") + + with ExitStack() as stack: + _patch_pipeline(my_predbat, stack) + artifact = my_predbat.plan_once(scheduled=False) + failed = _check(failed, my_predbat.plan_version == 2, "plan version: bumped to 2 on forced recompute") + return failed + + +def _scenario_plan_version_persistence(my_predbat): + """save_plan persists plan_version and load_plan restores it.""" + failed = 0 + storage = _FakeStorage() + my_predbat.components = _FakeComponents(storage) + my_predbat.plan_version = 7 + my_predbat.plan_valid = True + my_predbat.plan_last_updated = my_predbat.now_utc + my_predbat.plan_last_updated_minutes = my_predbat.minutes_now + my_predbat.charge_window_best = [] + my_predbat.charge_limit_best = [] + my_predbat.export_window_best = [] + my_predbat.export_limits_best = [] + + my_predbat.save_plan() + payload = storage.saved.get(("predbat", "plan")) + failed = _check(failed, payload is not None, "plan version persistence: save_plan wrote a payload") + failed = _check(failed, payload and payload.get("plan_version") == 7, "plan version persistence: payload carries version 7") + + my_predbat.plan_version = 0 + my_predbat.plan_valid = False + my_predbat.load_plan() + failed = _check(failed, my_predbat.plan_version == 7, "plan version persistence: load_plan restored version 7") + failed = _check(failed, my_predbat.plan_valid, "plan version persistence: load_plan marked plan valid") + my_predbat.components = None + return failed + + def test_plan_execute_split(my_predbat): """Entry point registered in unit_test.py - characterises update_pred and tests plan_once/execute_once.""" print("**** Running plan/execute split tests ****") @@ -319,6 +406,8 @@ def test_plan_execute_split(my_predbat): failed += _scenario_plan_once_unscheduled_forces(my_predbat) failed += _scenario_plan_once_failures(my_predbat) failed += _scenario_execute_once(my_predbat) + failed += _scenario_plan_version_bump(my_predbat) + failed += _scenario_plan_version_persistence(my_predbat) finally: _restore_state(my_predbat, saved) return failed From 1d89defc5942a458ec2e2cac04b1e7acff473a86 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Sat, 4 Jul 2026 19:59:22 +0100 Subject: [PATCH 6/6] Neutralise plan_random_delay in plan/execute split tests Co-Authored-By: Claude Fable 5 --- apps/predbat/tests/test_plan_execute_split.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/predbat/tests/test_plan_execute_split.py b/apps/predbat/tests/test_plan_execute_split.py index 21535392f..548268555 100644 --- a/apps/predbat/tests/test_plan_execute_split.py +++ b/apps/predbat/tests/test_plan_execute_split.py @@ -42,6 +42,7 @@ def _save_state(my_predbat): """Snapshot the shared fixture attributes this module mutates, so they can be restored.""" saved = {name: getattr(my_predbat, name, None) for name in _STATE_ATTRS} saved["_template_arg"] = my_predbat.args.get("template", None) + saved["_plan_random_delay_arg"] = my_predbat.args.get("plan_random_delay", None) return saved @@ -53,6 +54,10 @@ def _restore_state(my_predbat, saved): my_predbat.args.pop("template", None) else: my_predbat.args["template"] = saved["_template_arg"] + if saved["_plan_random_delay_arg"] is None: + my_predbat.args.pop("plan_random_delay", None) + else: + my_predbat.args["plan_random_delay"] = saved["_plan_random_delay_arg"] def _quiet_bookkeeping(my_predbat): @@ -71,6 +76,7 @@ def _quiet_bookkeeping(my_predbat): my_predbat.had_errors = False my_predbat.count_inverter_writes = {} my_predbat.args.pop("template", None) + my_predbat.args["plan_random_delay"] = 0 # avoids the real random sleep in update_pred's aged-plan path def _patch_pipeline(my_predbat, stack, inverter_ok=True, plan_valid_after=True):