|
| 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 | + |
| 12 | +def test_savings_stability(my_predbat): |
| 13 | + """ |
| 14 | + Test that compute_rate_low_for_yesterday (used in calculate_yesterday) returns a |
| 15 | + stable rate_low regardless of which point in the day the calculation runs. |
| 16 | +
|
| 17 | + Bug: when past_rates was built with end_record + minutes_now entries, today's |
| 18 | + cheap/negative tariff slots (e.g. Agile 0p) entered the dict as minutes_now grew, |
| 19 | + dropping rate_low and causing rate_scan_window to find no charge windows for |
| 20 | + yesterday, making savings_yesterday fluctuate hourly. |
| 21 | +
|
| 22 | + Fix: compute_rate_low_for_yesterday filters to k < end_record (yesterday only). |
| 23 | + This test calls the production methods directly (history_to_future_rates and |
| 24 | + compute_rate_low_for_yesterday) to verify the fix is exercised. |
| 25 | + """ |
| 26 | + failed = 0 |
| 27 | + end_record = 24 * 60 # 1440 minutes |
| 28 | + |
| 29 | + # Build rate_import dict representing: |
| 30 | + # Yesterday (keys -1440..-1): cheap 00:00-06:00 (5p), expensive otherwise (25p) |
| 31 | + # Today (keys 0+): expensive mostly, but a 0p slot at 60-120 min (like Agile) |
| 32 | + cheap_rate = 5.0 |
| 33 | + expensive_rate = 25.0 |
| 34 | + today_cheap_rate = 0.0 |
| 35 | + |
| 36 | + rate_import = {} |
| 37 | + # Yesterday: negative keys -1440 to -1 |
| 38 | + for minute in range(0, end_record): |
| 39 | + rate_import[minute - end_record] = cheap_rate if minute < 360 else expensive_rate |
| 40 | + # Today: keys 0 to 1439 |
| 41 | + for minute in range(0, end_record): |
| 42 | + rate_import[minute] = today_cheap_rate if 60 <= minute < 120 else expensive_rate |
| 43 | + |
| 44 | + # Test compute_rate_low_for_yesterday (production helper) at three points in time. |
| 45 | + # Uses history_to_future_rates (production code) to build past_rates exactly as |
| 46 | + # calculate_yesterday() does. |
| 47 | + for minutes_now_val, label in [(0, "midnight"), (240, "mid-morning"), (720, "noon")]: |
| 48 | + past_rates = my_predbat.history_to_future_rates(rate_import, end_record, end_record + minutes_now_val) |
| 49 | + rate_low = my_predbat.compute_rate_low_for_yesterday(past_rates, end_record) |
| 50 | + print("rate_low at {} (minutes_now={}): {}".format(label, minutes_now_val, rate_low)) |
| 51 | + if rate_low != cheap_rate: |
| 52 | + print("ERROR: rate_low at {} should be {} (yesterday's min), got {}".format(label, cheap_rate, rate_low)) |
| 53 | + failed = 1 |
| 54 | + |
| 55 | + # Confirm the old broken behaviour: min of the full dict drops when today's 0p slot is included |
| 56 | + past_rates_morning = my_predbat.history_to_future_rates(rate_import, end_record, end_record + 240) |
| 57 | + rate_low_broken_morning = min(past_rates_morning.values()) if past_rates_morning else 0.0 |
| 58 | + print("OLD broken rate_low at mid-morning: {} (should be {}, confirms the bug)".format(rate_low_broken_morning, today_cheap_rate)) |
| 59 | + if rate_low_broken_morning != today_cheap_rate: |
| 60 | + print("INFO: broken rate_low at morning={} (expected {} to demonstrate original bug)".format(rate_low_broken_morning, today_cheap_rate)) |
| 61 | + |
| 62 | + # Test rate_scan_window behaviour with deterministic state, save/restoring all state |
| 63 | + old_combine_charge_slots = my_predbat.combine_charge_slots |
| 64 | + old_minutes_now = my_predbat.minutes_now |
| 65 | + old_forecast_minutes = my_predbat.forecast_minutes |
| 66 | + |
| 67 | + try: |
| 68 | + # Set deterministic values required by rate_scan_window / find_charge_window |
| 69 | + my_predbat.minutes_now = 0 |
| 70 | + my_predbat.forecast_minutes = end_record |
| 71 | + my_predbat.combine_charge_slots = True |
| 72 | + |
| 73 | + # Build past_rates at noon (worst case: today's 0p slot is included) |
| 74 | + past_rates_noon = my_predbat.history_to_future_rates(rate_import, end_record, end_record + 720) |
| 75 | + rate_low_noon = my_predbat.compute_rate_low_for_yesterday(past_rates_noon, end_record) |
| 76 | + |
| 77 | + # Fixed rate_low (5p) should find yesterday's cheap window (0-360 minutes) |
| 78 | + charge_windows, _low, _high = my_predbat.rate_scan_window(past_rates_noon, 5, rate_low_noon, False, return_raw=True) |
| 79 | + charge_windows_yesterday = [c for c in charge_windows if c["start"] < end_record] |
| 80 | + print("Charge windows with FIXED rate_low={}: {}".format(rate_low_noon, charge_windows_yesterday)) |
| 81 | + if not charge_windows_yesterday: |
| 82 | + print("ERROR: charge windows should be found in yesterday's data with fixed rate_low") |
| 83 | + failed = 1 |
| 84 | + else: |
| 85 | + for cw in charge_windows_yesterday: |
| 86 | + if cw["average"] > rate_low_noon + 0.01: |
| 87 | + print("ERROR: charge window average {} exceeds fixed rate_low {}".format(cw["average"], rate_low_noon)) |
| 88 | + failed = 1 |
| 89 | + |
| 90 | + # Old broken rate_low (0p) finds no charge windows in yesterday's data |
| 91 | + rate_low_broken = min(past_rates_noon.values()) if past_rates_noon else 0.0 |
| 92 | + charge_windows_broken, _, _ = my_predbat.rate_scan_window(past_rates_noon, 5, rate_low_broken, False, return_raw=True) |
| 93 | + charge_windows_broken_yesterday = [c for c in charge_windows_broken if c["start"] < end_record] |
| 94 | + print("Charge windows with BROKEN rate_low={}: {}".format(rate_low_broken, charge_windows_broken_yesterday)) |
| 95 | + # With 0p threshold no yesterday windows should be found (yesterday min is 5p) |
| 96 | + if charge_windows_broken_yesterday: |
| 97 | + print("INFO: unexpected windows found at 0p threshold - may be harmless test setup artifact") |
| 98 | + |
| 99 | + finally: |
| 100 | + # Always restore shared my_predbat state |
| 101 | + my_predbat.combine_charge_slots = old_combine_charge_slots |
| 102 | + my_predbat.minutes_now = old_minutes_now |
| 103 | + my_predbat.forecast_minutes = old_forecast_minutes |
| 104 | + |
| 105 | + return failed |
0 commit comments