From f613d7e07547feed2ef13a98ff18460136a6b9e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 07:10:24 +0000 Subject: [PATCH 1/5] Initial plan From 84d816e8f87c9c2e68134d6963654feee4ba1b58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 07:28:01 +0000 Subject: [PATCH 2/5] Fix savings_yesterday instability: limit rate_low to yesterday's rate range only Agent-Logs-Url: https://github.com/springfall2008/batpred/sessions/b0e50bd2-2673-47ec-a8db-839f0584af47 Co-authored-by: springfall2008 <48591903+springfall2008@users.noreply.github.com> --- apps/predbat/output.py | 13 +- apps/predbat/tests/test_savings_stability.py | 150 +++++++++++++++++++ apps/predbat/unit_test.py | 2 + 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 apps/predbat/tests/test_savings_stability.py diff --git a/apps/predbat/output.py b/apps/predbat/output.py index fc496e0c4..01d83802e 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2736,15 +2736,22 @@ 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()) + past_rates_yesterday_values = [v for k, v in past_rates.items() if k < end_record] + rate_low = min(past_rates_yesterday_values) if past_rates_yesterday_values else (min(past_rates.values()) if past_rates else 0.0) 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 diff --git a/apps/predbat/tests/test_savings_stability.py b/apps/predbat/tests/test_savings_stability.py new file mode 100644 index 000000000..87a5ae4c4 --- /dev/null +++ b/apps/predbat/tests/test_savings_stability.py @@ -0,0 +1,150 @@ +# ----------------------------------------------------------------------------- +# 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 the rate_low threshold computed in calculate_yesterday is derived only from + yesterday's rate range (k < end_record = 1440) and not from today's rates. + + Bug: rate_low = min(past_rates.values()) included today's dynamically-added rates + (beyond minute 1440), so as minutes_now increased through the day and cheaper + today-rates entered the dict, the threshold dropped. That caused rate_scan_window to + find fewer (or no) charge windows for yesterday, producing a different metric_baseline + and thus a different savings_yesterday value on each hourly recalculation. + + Fix: compute rate_low only from k < end_record (yesterday's window). + """ + failed = 0 + end_record = 24 * 60 # 1440 + + # Build a set of "yesterday" rates (minutes 0..1439 relative to yesterday midnight) + # using a simple two-band Agile-like tariff: cheap 5p / expensive 25p + cheap_rate = 5.0 + expensive_rate = 25.0 + yesterday_rates = {} + for minute in range(0, end_record): + # Cheap rate 00:00-06:00 (minutes 0..359), expensive otherwise + yesterday_rates[minute] = cheap_rate if minute < 360 else expensive_rate + + # Simulate how history_to_future_rates constructs past_rates: + # past_rates[k] = rate_import.get(k - 1440, 0.0) + # For k < 1440 we need rate_import to have entries at (k - 1440), i.e. negative keys. + # Build a rate_import dict with negative keys for yesterday. + rate_import = {} + for minute in range(0, end_record): + rate_import[minute - end_record] = yesterday_rates[minute] + + # Today has a very cheap slot (0 p, like a negative Agile rate) at minutes 60..120 + today_cheap_rate = 0.0 + for minute in range(0, end_record): + rate = today_cheap_rate if 60 <= minute < 120 else expensive_rate + rate_import[minute] = rate + + # Build past_rates as calculate_yesterday does, for two different values of minutes_now + def build_past_rates(minutes_now_val): + """Build past_rates covering end_record + minutes_now_val entries.""" + fut = {} + for k in range(0, end_record + minutes_now_val): + fut[k] = rate_import.get(k - end_record, 0.0) + return fut + + # ---- Case 1: midnight (minutes_now=0) ---- + past_rates_midnight = build_past_rates(0) + yesterday_vals_midnight = [v for k, v in past_rates_midnight.items() if k < end_record] + rate_low_midnight = min(yesterday_vals_midnight) if yesterday_vals_midnight else 0.0 + + # ---- Case 2: mid-morning (minutes_now=240) – today's 0p slot is now included ---- + past_rates_morning = build_past_rates(240) + yesterday_vals_morning = [v for k, v in past_rates_morning.items() if k < end_record] + rate_low_morning = min(yesterday_vals_morning) if yesterday_vals_morning else 0.0 + + # ---- Case 3: noon (minutes_now=720) ---- + past_rates_noon = build_past_rates(720) + yesterday_vals_noon = [v for k, v in past_rates_noon.items() if k < end_record] + rate_low_noon = min(yesterday_vals_noon) if yesterday_vals_noon else 0.0 + + # The fixed code restricts rate_low to yesterday's range, so it must equal + # yesterday's cheap rate (5p) regardless of minutes_now. + print("rate_low_midnight={}, rate_low_morning={}, rate_low_noon={}".format(rate_low_midnight, rate_low_morning, rate_low_noon)) + + if rate_low_midnight != cheap_rate: + print("ERROR: rate_low at midnight should be {} (yesterday's min), got {}".format(cheap_rate, rate_low_midnight)) + failed = 1 + + if rate_low_morning != cheap_rate: + print("ERROR: rate_low at morning should be {} (yesterday's min), got {}".format(cheap_rate, rate_low_morning)) + failed = 1 + + if rate_low_noon != cheap_rate: + print("ERROR: rate_low at noon should be {} (yesterday's min), got {}".format(cheap_rate, rate_low_noon)) + failed = 1 + + # Demonstrate the OLD (broken) behaviour: using all values instead of yesterday-only + rate_low_broken_midnight = min(past_rates_midnight.values()) if past_rates_midnight else 0.0 + rate_low_broken_morning = min(past_rates_morning.values()) if past_rates_morning else 0.0 + + # At midnight the old code would give cheap_rate (yesterday only, 0p not yet included) + # At morning the old code would give 0p (today's 0p slot entered past_rates) + print("OLD rate_low_midnight={}, OLD rate_low_morning={}".format(rate_low_broken_midnight, rate_low_broken_morning)) + + if rate_low_broken_midnight != cheap_rate: + print("INFO: Old midnight rate_low={} (expected {}, this can vary by setup)".format(rate_low_broken_midnight, cheap_rate)) + + if rate_low_broken_morning != today_cheap_rate: + print("INFO: Old morning rate_low={} (expected {} to demonstrate bug)".format(rate_low_broken_morning, today_cheap_rate)) + else: + # The bug is confirmed: old code gives a different (lower) threshold in the morning + print("Confirmed: old code rate_low drops from {} to {} when today's cheap slot enters past_rates".format(rate_low_broken_midnight, rate_low_broken_morning)) + + # Also verify the variability check (min != max) against yesterday-only values + # Yesterday has cheap_rate and expensive_rate, so min != max should be True + no_io_yesterday_vals_noon = [v for k, v in past_rates_noon.items() if k < end_record] + if not no_io_yesterday_vals_noon: + print("ERROR: no_io_yesterday_vals should not be empty") + failed = 1 + else: + if min(no_io_yesterday_vals_noon) == max(no_io_yesterday_vals_noon): + print("ERROR: variability check should be True for yesterday's Agile rates") + failed = 1 + else: + print("OK: yesterday's rates are variable (min={}, max={})".format(min(no_io_yesterday_vals_noon), max(no_io_yesterday_vals_noon))) + + # Ensure charge-window finding (rate_scan_window) uses the correct stable threshold + # by directly calling it on a past_rates_no_io equivalent using rate_low from the fix + past_rates_no_io = build_past_rates(720) # noon scenario + my_predbat.combine_charge_slots = True + charge_window_best, lowest, highest = my_predbat.rate_scan_window(past_rates_no_io, 5, rate_low_noon, False, return_raw=True) + # Filter to yesterday's window only (start < end_record) + charge_window_best = [c for c in charge_window_best if c["start"] < end_record] + print("Charge windows found with FIXED rate_low={}: {}".format(rate_low_noon, charge_window_best)) + + if not charge_window_best: + print("ERROR: charge windows should be found with fixed rate_low") + failed = 1 + else: + for cw in charge_window_best: + if cw["average"] > rate_low_noon + 0.01: + print("ERROR: charge window average rate {} exceeds threshold {}".format(cw["average"], rate_low_noon)) + failed = 1 + + # Verify that using the OLD broken rate_low (0p) finds NO windows in yesterday's data + charge_window_broken, _, _ = my_predbat.rate_scan_window(past_rates_no_io, 5, today_cheap_rate, False, return_raw=True) + charge_window_broken = [c for c in charge_window_broken if c["start"] < end_record] + print("Charge windows found with BROKEN rate_low={}: {}".format(today_cheap_rate, charge_window_broken)) + + if charge_window_broken: + # Only yesterday entries (k < 1440) have non-zero rates, so finding windows at 0p + # means the scan accidentally hit entries where past_rates_no_io[k] = 0.0 for k<1440 + # This could be an artifact of how the test builds rate_import. + # The important thing is that the fixed code does NOT use 0p as the threshold. + print("INFO: unexpected windows at 0p threshold - may be harmless test setup artifact") + + 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), From 30fb4f8052c6adba1e92a6fb2d2911e6aef19ae3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 07:30:19 +0000 Subject: [PATCH 3/5] Simplify rate_low fallback logic to avoid nested ternary (code review) Agent-Logs-Url: https://github.com/springfall2008/batpred/sessions/b0e50bd2-2673-47ec-a8db-839f0584af47 Co-authored-by: springfall2008 <48591903+springfall2008@users.noreply.github.com> --- apps/predbat/output.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 01d83802e..72591f6bb 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2742,7 +2742,12 @@ def calculate_yesterday(self): # windows on each hourly recalculation and causing savings_yesterday to fluctuate. charge_window_best = [] past_rates_yesterday_values = [v for k, v in past_rates.items() if k < end_record] - rate_low = min(past_rates_yesterday_values) if past_rates_yesterday_values else (min(past_rates.values()) if past_rates else 0.0) + if past_rates_yesterday_values: + rate_low = min(past_rates_yesterday_values) + elif past_rates: + rate_low = min(past_rates.values()) + else: + rate_low = 0.0 combine_charge = self.combine_charge_slots # Find the best charge windows yesterday From 78efe99d400c07311b1a2e1478f01a62413e3ae5 Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Thu, 7 May 2026 09:02:00 +0100 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/tests/test_savings_stability.py | 54 +++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/apps/predbat/tests/test_savings_stability.py b/apps/predbat/tests/test_savings_stability.py index 87a5ae4c4..fba37b75e 100644 --- a/apps/predbat/tests/test_savings_stability.py +++ b/apps/predbat/tests/test_savings_stability.py @@ -120,31 +120,35 @@ def build_past_rates(minutes_now_val): # Ensure charge-window finding (rate_scan_window) uses the correct stable threshold # by directly calling it on a past_rates_no_io equivalent using rate_low from the fix past_rates_no_io = build_past_rates(720) # noon scenario + old_combine_charge_slots = my_predbat.combine_charge_slots my_predbat.combine_charge_slots = True - charge_window_best, lowest, highest = my_predbat.rate_scan_window(past_rates_no_io, 5, rate_low_noon, False, return_raw=True) - # Filter to yesterday's window only (start < end_record) - charge_window_best = [c for c in charge_window_best if c["start"] < end_record] - print("Charge windows found with FIXED rate_low={}: {}".format(rate_low_noon, charge_window_best)) - - if not charge_window_best: - print("ERROR: charge windows should be found with fixed rate_low") - failed = 1 - else: - for cw in charge_window_best: - if cw["average"] > rate_low_noon + 0.01: - print("ERROR: charge window average rate {} exceeds threshold {}".format(cw["average"], rate_low_noon)) - failed = 1 - - # Verify that using the OLD broken rate_low (0p) finds NO windows in yesterday's data - charge_window_broken, _, _ = my_predbat.rate_scan_window(past_rates_no_io, 5, today_cheap_rate, False, return_raw=True) - charge_window_broken = [c for c in charge_window_broken if c["start"] < end_record] - print("Charge windows found with BROKEN rate_low={}: {}".format(today_cheap_rate, charge_window_broken)) - - if charge_window_broken: - # Only yesterday entries (k < 1440) have non-zero rates, so finding windows at 0p - # means the scan accidentally hit entries where past_rates_no_io[k] = 0.0 for k<1440 - # This could be an artifact of how the test builds rate_import. - # The important thing is that the fixed code does NOT use 0p as the threshold. - print("INFO: unexpected windows at 0p threshold - may be harmless test setup artifact") + try: + charge_window_best, lowest, highest = my_predbat.rate_scan_window(past_rates_no_io, 5, rate_low_noon, False, return_raw=True) + # Filter to yesterday's window only (start < end_record) + charge_window_best = [c for c in charge_window_best if c["start"] < end_record] + print("Charge windows found with FIXED rate_low={}: {}".format(rate_low_noon, charge_window_best)) + + if not charge_window_best: + print("ERROR: charge windows should be found with fixed rate_low") + failed = 1 + else: + for cw in charge_window_best: + if cw["average"] > rate_low_noon + 0.01: + print("ERROR: charge window average rate {} exceeds threshold {}".format(cw["average"], rate_low_noon)) + failed = 1 + + # Verify that using the OLD broken rate_low (0p) finds NO windows in yesterday's data + charge_window_broken, _, _ = my_predbat.rate_scan_window(past_rates_no_io, 5, today_cheap_rate, False, return_raw=True) + charge_window_broken = [c for c in charge_window_broken if c["start"] < end_record] + print("Charge windows found with BROKEN rate_low={}: {}".format(today_cheap_rate, charge_window_broken)) + + if charge_window_broken: + # Only yesterday entries (k < 1440) have non-zero rates, so finding windows at 0p + # means the scan accidentally hit entries where past_rates_no_io[k] = 0.0 for k<1440 + # This could be an artifact of how the test builds rate_import. + # The important thing is that the fixed code does NOT use 0p as the threshold. + print("INFO: unexpected windows at 0p threshold - may be harmless test setup artifact") + finally: + my_predbat.combine_charge_slots = old_combine_charge_slots return failed From da44d395ea2f36f75d65880c6fee0af389798a33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 08:05:40 +0000 Subject: [PATCH 5/5] Refactor rate_low into helper method and fix test state management Agent-Logs-Url: https://github.com/springfall2008/batpred/sessions/e7f0a977-1174-4ffe-a0b7-5dc6c67622b7 Co-authored-by: springfall2008 <48591903+springfall2008@users.noreply.github.com> --- apps/predbat/output.py | 24 ++- apps/predbat/tests/test_savings_stability.py | 179 +++++++------------ 2 files changed, 82 insertions(+), 121 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 72591f6bb..d7efa23da 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -2741,13 +2741,7 @@ def calculate_yesterday(self): # (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 = [] - past_rates_yesterday_values = [v for k, v in past_rates.items() if k < end_record] - if past_rates_yesterday_values: - rate_low = min(past_rates_yesterday_values) - elif past_rates: - rate_low = min(past_rates.values()) - else: - rate_low = 0.0 + rate_low = self.compute_rate_low_for_yesterday(past_rates, end_record) combine_charge = self.combine_charge_slots # Find the best charge windows yesterday @@ -3245,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 index fba37b75e..78fcccf33 100644 --- a/apps/predbat/tests/test_savings_stability.py +++ b/apps/predbat/tests/test_savings_stability.py @@ -11,144 +11,95 @@ def test_savings_stability(my_predbat): """ - Test that the rate_low threshold computed in calculate_yesterday is derived only from - yesterday's rate range (k < end_record = 1440) and not from today's rates. + 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: rate_low = min(past_rates.values()) included today's dynamically-added rates - (beyond minute 1440), so as minutes_now increased through the day and cheaper - today-rates entered the dict, the threshold dropped. That caused rate_scan_window to - find fewer (or no) charge windows for yesterday, producing a different metric_baseline - and thus a different savings_yesterday value on each hourly recalculation. + 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 only from k < end_record (yesterday's window). + 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 + end_record = 24 * 60 # 1440 minutes - # Build a set of "yesterday" rates (minutes 0..1439 relative to yesterday midnight) - # using a simple two-band Agile-like tariff: cheap 5p / expensive 25p + # 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 - yesterday_rates = {} - for minute in range(0, end_record): - # Cheap rate 00:00-06:00 (minutes 0..359), expensive otherwise - yesterday_rates[minute] = cheap_rate if minute < 360 else expensive_rate + today_cheap_rate = 0.0 - # Simulate how history_to_future_rates constructs past_rates: - # past_rates[k] = rate_import.get(k - 1440, 0.0) - # For k < 1440 we need rate_import to have entries at (k - 1440), i.e. negative keys. - # Build a rate_import dict with negative keys for yesterday. rate_import = {} + # Yesterday: negative keys -1440 to -1 for minute in range(0, end_record): - rate_import[minute - end_record] = yesterday_rates[minute] - - # Today has a very cheap slot (0 p, like a negative Agile rate) at minutes 60..120 - today_cheap_rate = 0.0 + 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 = today_cheap_rate if 60 <= minute < 120 else expensive_rate - rate_import[minute] = rate - - # Build past_rates as calculate_yesterday does, for two different values of minutes_now - def build_past_rates(minutes_now_val): - """Build past_rates covering end_record + minutes_now_val entries.""" - fut = {} - for k in range(0, end_record + minutes_now_val): - fut[k] = rate_import.get(k - end_record, 0.0) - return fut - - # ---- Case 1: midnight (minutes_now=0) ---- - past_rates_midnight = build_past_rates(0) - yesterday_vals_midnight = [v for k, v in past_rates_midnight.items() if k < end_record] - rate_low_midnight = min(yesterday_vals_midnight) if yesterday_vals_midnight else 0.0 - - # ---- Case 2: mid-morning (minutes_now=240) – today's 0p slot is now included ---- - past_rates_morning = build_past_rates(240) - yesterday_vals_morning = [v for k, v in past_rates_morning.items() if k < end_record] - rate_low_morning = min(yesterday_vals_morning) if yesterday_vals_morning else 0.0 - - # ---- Case 3: noon (minutes_now=720) ---- - past_rates_noon = build_past_rates(720) - yesterday_vals_noon = [v for k, v in past_rates_noon.items() if k < end_record] - rate_low_noon = min(yesterday_vals_noon) if yesterday_vals_noon else 0.0 - - # The fixed code restricts rate_low to yesterday's range, so it must equal - # yesterday's cheap rate (5p) regardless of minutes_now. - print("rate_low_midnight={}, rate_low_morning={}, rate_low_noon={}".format(rate_low_midnight, rate_low_morning, rate_low_noon)) - - if rate_low_midnight != cheap_rate: - print("ERROR: rate_low at midnight should be {} (yesterday's min), got {}".format(cheap_rate, rate_low_midnight)) - failed = 1 - - if rate_low_morning != cheap_rate: - print("ERROR: rate_low at morning should be {} (yesterday's min), got {}".format(cheap_rate, rate_low_morning)) - failed = 1 - - if rate_low_noon != cheap_rate: - print("ERROR: rate_low at noon should be {} (yesterday's min), got {}".format(cheap_rate, rate_low_noon)) - failed = 1 + 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 - # Demonstrate the OLD (broken) behaviour: using all values instead of yesterday-only - rate_low_broken_midnight = min(past_rates_midnight.values()) if past_rates_midnight else 0.0 + # 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 - - # At midnight the old code would give cheap_rate (yesterday only, 0p not yet included) - # At morning the old code would give 0p (today's 0p slot entered past_rates) - print("OLD rate_low_midnight={}, OLD rate_low_morning={}".format(rate_low_broken_midnight, rate_low_broken_morning)) - - if rate_low_broken_midnight != cheap_rate: - print("INFO: Old midnight rate_low={} (expected {}, this can vary by setup)".format(rate_low_broken_midnight, cheap_rate)) - + 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: Old morning rate_low={} (expected {} to demonstrate bug)".format(rate_low_broken_morning, today_cheap_rate)) - else: - # The bug is confirmed: old code gives a different (lower) threshold in the morning - print("Confirmed: old code rate_low drops from {} to {} when today's cheap slot enters past_rates".format(rate_low_broken_midnight, rate_low_broken_morning)) + print("INFO: broken rate_low at morning={} (expected {} to demonstrate original bug)".format(rate_low_broken_morning, today_cheap_rate)) - # Also verify the variability check (min != max) against yesterday-only values - # Yesterday has cheap_rate and expensive_rate, so min != max should be True - no_io_yesterday_vals_noon = [v for k, v in past_rates_noon.items() if k < end_record] - if not no_io_yesterday_vals_noon: - print("ERROR: no_io_yesterday_vals should not be empty") - failed = 1 - else: - if min(no_io_yesterday_vals_noon) == max(no_io_yesterday_vals_noon): - print("ERROR: variability check should be True for yesterday's Agile rates") - failed = 1 - else: - print("OK: yesterday's rates are variable (min={}, max={})".format(min(no_io_yesterday_vals_noon), max(no_io_yesterday_vals_noon))) - - # Ensure charge-window finding (rate_scan_window) uses the correct stable threshold - # by directly calling it on a past_rates_no_io equivalent using rate_low from the fix - past_rates_no_io = build_past_rates(720) # noon scenario + # Test rate_scan_window behaviour with deterministic state, save/restoring all state old_combine_charge_slots = my_predbat.combine_charge_slots - my_predbat.combine_charge_slots = True - try: - charge_window_best, lowest, highest = my_predbat.rate_scan_window(past_rates_no_io, 5, rate_low_noon, False, return_raw=True) - # Filter to yesterday's window only (start < end_record) - charge_window_best = [c for c in charge_window_best if c["start"] < end_record] - print("Charge windows found with FIXED rate_low={}: {}".format(rate_low_noon, charge_window_best)) + old_minutes_now = my_predbat.minutes_now + old_forecast_minutes = my_predbat.forecast_minutes - if not charge_window_best: - print("ERROR: charge windows should be found with fixed rate_low") + 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_window_best: + for cw in charge_windows_yesterday: if cw["average"] > rate_low_noon + 0.01: - print("ERROR: charge window average rate {} exceeds threshold {}".format(cw["average"], rate_low_noon)) + print("ERROR: charge window average {} exceeds fixed rate_low {}".format(cw["average"], rate_low_noon)) failed = 1 - # Verify that using the OLD broken rate_low (0p) finds NO windows in yesterday's data - charge_window_broken, _, _ = my_predbat.rate_scan_window(past_rates_no_io, 5, today_cheap_rate, False, return_raw=True) - charge_window_broken = [c for c in charge_window_broken if c["start"] < end_record] - print("Charge windows found with BROKEN rate_low={}: {}".format(today_cheap_rate, charge_window_broken)) + # 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") - if charge_window_broken: - # Only yesterday entries (k < 1440) have non-zero rates, so finding windows at 0p - # means the scan accidentally hit entries where past_rates_no_io[k] = 0.0 for k<1440 - # This could be an artifact of how the test builds rate_import. - # The important thing is that the fixed code does NOT use 0p as the threshold. - print("INFO: unexpected windows 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