diff --git a/apps/predbat/output.py b/apps/predbat/output.py index fc496e0c4..d7efa23da 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2736,15 +2736,21 @@ def calculate_yesterday(self): past_rates_no_io = self.history_to_future_rates(self.rate_import_no_io, 24 * 60, end_record + self.minutes_now) past_rates_export = self.history_to_future_rates(self.rate_export, 24 * 60, end_record + self.minutes_now) - # Assume user might charge at the lowest rate only, for fix tariff + # Assume user might charge at the lowest rate only, for fixed tariff + # Only use yesterday's rate range (k < end_record) for the threshold to prevent today's rates + # (which are added progressively as minutes_now increases) from changing the baseline charge + # windows on each hourly recalculation and causing savings_yesterday to fluctuate. charge_window_best = [] - rate_low = min(past_rates.values()) + rate_low = self.compute_rate_low_for_yesterday(past_rates, end_record) combine_charge = self.combine_charge_slots # Find the best charge windows yesterday if self.calculate_savings_max_charge_slots > 0: self.combine_charge_slots = True - if past_rates_no_io and (min(past_rates_no_io.values()) != max(past_rates_no_io.values())): + # Only check variability in yesterday's rates to avoid today's variable tariff rates + # triggering charge window detection when yesterday had a flat tariff + no_io_yesterday_values = [v for k, v in past_rates_no_io.items() if k < end_record] + if no_io_yesterday_values and (min(no_io_yesterday_values) != max(no_io_yesterday_values)): # Use the Non-IO rates when finding charge windows as hardwired charge wouldn't account for this charge_window_best, lowest, highest = self.rate_scan_window(past_rates_no_io, 5, rate_low, False, return_raw=True) self.combine_charge_slots = combine_charge @@ -3233,6 +3239,22 @@ def log_option_best(self): opts += ", metric_carbon({}{}/kg) ".format(self.carbon_metric, curr) self.log("Calculate Best options: " + opts) + def compute_rate_low_for_yesterday(self, past_rates, end_record): + """ + Compute the lowest rate from yesterday's rate range only (k < end_record). + + Restricts the threshold to yesterday's window so that today's rates, which are + progressively appended to past_rates as minutes_now increases, cannot lower the + threshold and cause different charge windows to be found on each hourly savings + recalculation. + """ + yesterday_values = [v for k, v in past_rates.items() if k < end_record] + if yesterday_values: + return min(yesterday_values) + if past_rates: + return min(past_rates.values()) + return 0.0 + def history_to_future_rates(self, rates, offset, end_record): """ Shift rates from the past into a future array diff --git a/apps/predbat/tests/test_savings_stability.py b/apps/predbat/tests/test_savings_stability.py new file mode 100644 index 000000000..78fcccf33 --- /dev/null +++ b/apps/predbat/tests/test_savings_stability.py @@ -0,0 +1,105 @@ +# ----------------------------------------------------------------------------- +# 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 + + +def test_savings_stability(my_predbat): + """ + Test that compute_rate_low_for_yesterday (used in calculate_yesterday) returns a + stable rate_low regardless of which point in the day the calculation runs. + + Bug: when past_rates was built with end_record + minutes_now entries, today's + cheap/negative tariff slots (e.g. Agile 0p) entered the dict as minutes_now grew, + dropping rate_low and causing rate_scan_window to find no charge windows for + yesterday, making savings_yesterday fluctuate hourly. + + Fix: compute_rate_low_for_yesterday filters to k < end_record (yesterday only). + This test calls the production methods directly (history_to_future_rates and + compute_rate_low_for_yesterday) to verify the fix is exercised. + """ + failed = 0 + end_record = 24 * 60 # 1440 minutes + + # Build rate_import dict representing: + # Yesterday (keys -1440..-1): cheap 00:00-06:00 (5p), expensive otherwise (25p) + # Today (keys 0+): expensive mostly, but a 0p slot at 60-120 min (like Agile) + cheap_rate = 5.0 + expensive_rate = 25.0 + today_cheap_rate = 0.0 + + rate_import = {} + # Yesterday: negative keys -1440 to -1 + for minute in range(0, end_record): + rate_import[minute - end_record] = cheap_rate if minute < 360 else expensive_rate + # Today: keys 0 to 1439 + for minute in range(0, end_record): + rate_import[minute] = today_cheap_rate if 60 <= minute < 120 else expensive_rate + + # Test compute_rate_low_for_yesterday (production helper) at three points in time. + # Uses history_to_future_rates (production code) to build past_rates exactly as + # calculate_yesterday() does. + for minutes_now_val, label in [(0, "midnight"), (240, "mid-morning"), (720, "noon")]: + past_rates = my_predbat.history_to_future_rates(rate_import, end_record, end_record + minutes_now_val) + rate_low = my_predbat.compute_rate_low_for_yesterday(past_rates, end_record) + print("rate_low at {} (minutes_now={}): {}".format(label, minutes_now_val, rate_low)) + if rate_low != cheap_rate: + print("ERROR: rate_low at {} should be {} (yesterday's min), got {}".format(label, cheap_rate, rate_low)) + failed = 1 + + # Confirm the old broken behaviour: min of the full dict drops when today's 0p slot is included + past_rates_morning = my_predbat.history_to_future_rates(rate_import, end_record, end_record + 240) + rate_low_broken_morning = min(past_rates_morning.values()) if past_rates_morning else 0.0 + print("OLD broken rate_low at mid-morning: {} (should be {}, confirms the bug)".format(rate_low_broken_morning, today_cheap_rate)) + if rate_low_broken_morning != today_cheap_rate: + print("INFO: broken rate_low at morning={} (expected {} to demonstrate original bug)".format(rate_low_broken_morning, today_cheap_rate)) + + # Test rate_scan_window behaviour with deterministic state, save/restoring all state + old_combine_charge_slots = my_predbat.combine_charge_slots + old_minutes_now = my_predbat.minutes_now + old_forecast_minutes = my_predbat.forecast_minutes + + try: + # Set deterministic values required by rate_scan_window / find_charge_window + my_predbat.minutes_now = 0 + my_predbat.forecast_minutes = end_record + my_predbat.combine_charge_slots = True + + # Build past_rates at noon (worst case: today's 0p slot is included) + past_rates_noon = my_predbat.history_to_future_rates(rate_import, end_record, end_record + 720) + rate_low_noon = my_predbat.compute_rate_low_for_yesterday(past_rates_noon, end_record) + + # Fixed rate_low (5p) should find yesterday's cheap window (0-360 minutes) + charge_windows, _low, _high = my_predbat.rate_scan_window(past_rates_noon, 5, rate_low_noon, False, return_raw=True) + charge_windows_yesterday = [c for c in charge_windows if c["start"] < end_record] + print("Charge windows with FIXED rate_low={}: {}".format(rate_low_noon, charge_windows_yesterday)) + if not charge_windows_yesterday: + print("ERROR: charge windows should be found in yesterday's data with fixed rate_low") + failed = 1 + else: + for cw in charge_windows_yesterday: + if cw["average"] > rate_low_noon + 0.01: + print("ERROR: charge window average {} exceeds fixed rate_low {}".format(cw["average"], rate_low_noon)) + failed = 1 + + # Old broken rate_low (0p) finds no charge windows in yesterday's data + rate_low_broken = min(past_rates_noon.values()) if past_rates_noon else 0.0 + charge_windows_broken, _, _ = my_predbat.rate_scan_window(past_rates_noon, 5, rate_low_broken, False, return_raw=True) + charge_windows_broken_yesterday = [c for c in charge_windows_broken if c["start"] < end_record] + print("Charge windows with BROKEN rate_low={}: {}".format(rate_low_broken, charge_windows_broken_yesterday)) + # With 0p threshold no yesterday windows should be found (yesterday min is 5p) + if charge_windows_broken_yesterday: + print("INFO: unexpected windows found at 0p threshold - may be harmless test setup artifact") + + finally: + # Always restore shared my_predbat state + my_predbat.combine_charge_slots = old_combine_charge_slots + my_predbat.minutes_now = old_minutes_now + my_predbat.forecast_minutes = old_forecast_minutes + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 80745fca1..5d03b84af 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -118,6 +118,7 @@ from tests.test_discard_unused_charge_slots import run_discard_unused_charge_slots_tests from tests.test_discard_unused_export_slots import run_discard_unused_export_slots_tests from tests.test_marginal_costs import test_marginal_costs +from tests.test_savings_stability import test_savings_stability # Mock the components and plugin system @@ -292,6 +293,7 @@ def main(): ("discard_unused_charge_slots", run_discard_unused_charge_slots_tests, "Discard unused charge slots tests", False), ("discard_unused_export_slots", run_discard_unused_export_slots_tests, "Discard unused export slots tests", False), ("marginal_costs", test_marginal_costs, "Marginal energy cost matrix tests", False), + ("savings_stability", test_savings_stability, "Savings yesterday rate_low stability tests", 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),