From a03503741a35cbf69844b29cad537e4aa8de2895 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 10:40:44 +0100 Subject: [PATCH 1/4] Fix load so far crash --- apps/predbat/output.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index b9c18af2b..20f69fc1d 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2635,9 +2635,9 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import state=dp3(load_total_pred), attributes={ "results": self.filtered_times(load_predict_stamp), - "today": dp2(load_today), - "today_so_far": dp2(load_so_far), - "today_remaining": dp2(load_today_remaining), + "today": dp2(load_today) if load_today is not None else 0, + "today_so_far": dp2(load_so_far) if load_so_far is not None else 0, + "today_remaining": dp2(load_today_remaining) if load_today_remaining is not None else 0, "friendly_name": "Load energy predicted (filtered)", "state_class": "measurement", "unit_of_measurement": "kWh", @@ -2657,9 +2657,9 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import state=dp3(load_adjusted), attributes={ "results": self.filtered_times(load_adjusted_stamp), - "today": dp2(load_today), - "today_so_far": dp2(load_so_far), - "today_remaining": dp2(load_today_remaining), + "today": dp2(load_today) if load_today is not None else 0, + "today_so_far": dp2(load_so_far) if load_so_far is not None else 0, + "today_remaining": dp2(load_today_remaining) if load_today_remaining is not None else 0, "friendly_name": "Load energy prediction adjusted", "state_class": "measurement", "unit_of_measurement": "kWh", From 56b33be1fc5e28b5e72096c8e3afec58eb0e6503 Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Mon, 25 May 2026 10:49:12 +0100 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/output.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 20f69fc1d..d4dd813a9 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2635,9 +2635,9 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import state=dp3(load_total_pred), attributes={ "results": self.filtered_times(load_predict_stamp), - "today": dp2(load_today) if load_today is not None else 0, - "today_so_far": dp2(load_so_far) if load_so_far is not None else 0, - "today_remaining": dp2(load_today_remaining) if load_today_remaining is not None else 0, + "today": dp2(load_today) if load_today is not None else 0.0, + "today_so_far": dp2(load_so_far) if load_so_far is not None else 0.0, + "today_remaining": dp2(load_today_remaining) if load_today_remaining is not None else 0.0, "friendly_name": "Load energy predicted (filtered)", "state_class": "measurement", "unit_of_measurement": "kWh", From 45a047ab3206d3d178c0e6dc9b124b5f52796e19 Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Mon, 25 May 2026 10:49:20 +0100 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/output.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index d4dd813a9..166227ab9 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2657,9 +2657,9 @@ def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import state=dp3(load_adjusted), attributes={ "results": self.filtered_times(load_adjusted_stamp), - "today": dp2(load_today) if load_today is not None else 0, - "today_so_far": dp2(load_so_far) if load_so_far is not None else 0, - "today_remaining": dp2(load_today_remaining) if load_today_remaining is not None else 0, + "today": dp2(load_today) if load_today is not None else 0.0, + "today_so_far": dp2(load_so_far) if load_so_far is not None else 0.0, + "today_remaining": dp2(load_today_remaining) if load_today_remaining is not None else 0.0, "friendly_name": "Load energy prediction adjusted", "state_class": "measurement", "unit_of_measurement": "kWh", From c26ee0d398592c6bc64b996e5ac901da8d898539 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 10:49:26 +0100 Subject: [PATCH 4/4] Basic test --- .../tests/test_load_today_comparison.py | 114 ++++++++++++++++++ apps/predbat/unit_test.py | 2 + 2 files changed, 116 insertions(+) create mode 100644 apps/predbat/tests/test_load_today_comparison.py diff --git a/apps/predbat/tests/test_load_today_comparison.py b/apps/predbat/tests/test_load_today_comparison.py new file mode 100644 index 000000000..d30eafd3d --- /dev/null +++ b/apps/predbat/tests/test_load_today_comparison.py @@ -0,0 +1,114 @@ +# ----------------------------------------------------------------------------- +# 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 + +""" +Unit tests for Output.load_today_comparison(). + +Covered scenarios +----------------- +1. None-guard regression: when filtered_today() cannot find a matching + timestamp in load_adjusted_stamp or load_predict_stamp (because now_utc + is not on a 5-minute boundary), it returns None. Without the guard, + dp2(None) raises: + TypeError: type NoneType doesn't define __round__ method + This test verifies the guard prevents the crash and the dashboard + attributes are published as numeric values (0) rather than None. +""" + +from datetime import datetime, timedelta + +import pytz + +UTC = pytz.UTC + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _test_none_guard_no_crash(my_predbat, failed): + """ + Verify load_today_comparison does not crash with TypeError when + filtered_today returns None for load_so_far/load_today. + + Trigger: set now_utc to a non-5-minute-boundary timestamp. + load_adjusted_stamp and load_predict_stamp only contain entries at + exact 5-minute boundaries, so filtered_today(stamp=now_utc) returns None + for load_so_far in both the load_energy_predicted and load_energy_adjusted + dashboard items. + """ + print(" test: None guard prevents crash when filtered_today returns None") + + # Save state that will be mutated + saved_now_utc = my_predbat.now_utc + saved_midnight_utc = my_predbat.midnight_utc + saved_minutes_now = my_predbat.minutes_now + + # Use a fixed midnight so the test is deterministic + midnight_utc = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) + + # Set now_utc to 1 minute past midnight - NOT a 5-minute boundary. + # load_adjusted_stamp / load_predict_stamp only have keys at 0, 5, 10, ... + # minutes past midnight, so filtered_today(stamp=now_utc) returns None + # for load_so_far, exercising the None guard in the dashboard_item call. + my_predbat.midnight_utc = midnight_utc + my_predbat.now_utc = midnight_utc + timedelta(minutes=1) + my_predbat.minutes_now = 0 # 5-min aligned start of day + + # Call with empty dicts - no load/import history; save=True triggers + # the dashboard_item calls that formerly crashed on dp2(None). + try: + my_predbat.load_today_comparison({}, {}, {}, {}, minutes_now=0, save=True) + except TypeError as e: + print(" ERROR: load_today_comparison raised TypeError: {}".format(e)) + failed = True + my_predbat.now_utc = saved_now_utc + my_predbat.midnight_utc = saved_midnight_utc + my_predbat.minutes_now = saved_minutes_now + return failed + + # Verify that the attributes published are numeric (not None/crashing) + for entity_suffix in [".load_energy_adjusted", ".load_energy_predicted"]: + entity_id = my_predbat.prefix + entity_suffix + attrs = my_predbat.dashboard_values.get(entity_id, {}).get("attributes", {}) + for attr_name in ["today_so_far", "today", "today_remaining"]: + attr_val = attrs.get(attr_name, None) + if not isinstance(attr_val, (int, float)): + print(" ERROR: {}.{} = {} (expected numeric, got {})".format(entity_suffix, attr_name, attr_val, type(attr_val))) + failed = True + + if not failed: + print(" PASS: No TypeError raised and all load_energy attributes are numeric") + + # Restore state + my_predbat.now_utc = saved_now_utc + my_predbat.midnight_utc = saved_midnight_utc + my_predbat.minutes_now = saved_minutes_now + + return failed + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def test_load_today_comparison(my_predbat): + """ + Unit tests for load_today_comparison() covering the None-guard fix + for dp2() calls when filtered_today() returns None. + """ + failed = False + print("**** Running load_today_comparison tests ****") + + failed = _test_none_guard_no_crash(my_predbat, failed) + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index b7e1a77f9..d64fd8b18 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -122,6 +122,7 @@ from tests.test_marginal_costs import test_marginal_costs from tests.test_savings_stability import test_savings_stability from tests.test_calculate_yesterday import test_calculate_yesterday +from tests.test_load_today_comparison import test_load_today_comparison # Mock the components and plugin system @@ -301,6 +302,7 @@ def main(): ("marginal_costs", test_marginal_costs, "Marginal energy cost matrix tests", False), ("savings_stability", test_savings_stability, "Savings yesterday rate_low stability tests", False), ("calculate_yesterday", test_calculate_yesterday, "Calculate yesterday savings and IOG car-slot subtraction tests", False), + ("load_today_comparison", test_load_today_comparison, "load_today_comparison None-guard regression test", False), ("compare", test_compare, "Compare tariff engine tests (hardware overrides, bleed isolation)", False), ("gateway", run_gateway_tests, "GatewayMQTT component tests (protobuf, plan serialization, commands, telemetry)", False), ("optimise_levels", run_optimise_levels_tests, "Optimise levels tests", False),