Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.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",
Expand All @@ -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.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",
Expand Down
114 changes: 114 additions & 0 deletions apps/predbat/tests/test_load_today_comparison.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading