Skip to content

Commit 19cf246

Browse files
Merge pull request #3881 from springfall2008/copilot/fix-predbat-savings-values
Fix savings_yesterday_predbat fluctuating throughout the day on dynamic tariffs
2 parents d09a877 + da44d39 commit 19cf246

3 files changed

Lines changed: 132 additions & 3 deletions

File tree

apps/predbat/output.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2736,15 +2736,21 @@ def calculate_yesterday(self):
27362736
past_rates_no_io = self.history_to_future_rates(self.rate_import_no_io, 24 * 60, end_record + self.minutes_now)
27372737
past_rates_export = self.history_to_future_rates(self.rate_export, 24 * 60, end_record + self.minutes_now)
27382738

2739-
# Assume user might charge at the lowest rate only, for fix tariff
2739+
# Assume user might charge at the lowest rate only, for fixed tariff
2740+
# Only use yesterday's rate range (k < end_record) for the threshold to prevent today's rates
2741+
# (which are added progressively as minutes_now increases) from changing the baseline charge
2742+
# windows on each hourly recalculation and causing savings_yesterday to fluctuate.
27402743
charge_window_best = []
2741-
rate_low = min(past_rates.values())
2744+
rate_low = self.compute_rate_low_for_yesterday(past_rates, end_record)
27422745
combine_charge = self.combine_charge_slots
27432746

27442747
# Find the best charge windows yesterday
27452748
if self.calculate_savings_max_charge_slots > 0:
27462749
self.combine_charge_slots = True
2747-
if past_rates_no_io and (min(past_rates_no_io.values()) != max(past_rates_no_io.values())):
2750+
# Only check variability in yesterday's rates to avoid today's variable tariff rates
2751+
# triggering charge window detection when yesterday had a flat tariff
2752+
no_io_yesterday_values = [v for k, v in past_rates_no_io.items() if k < end_record]
2753+
if no_io_yesterday_values and (min(no_io_yesterday_values) != max(no_io_yesterday_values)):
27482754
# Use the Non-IO rates when finding charge windows as hardwired charge wouldn't account for this
27492755
charge_window_best, lowest, highest = self.rate_scan_window(past_rates_no_io, 5, rate_low, False, return_raw=True)
27502756
self.combine_charge_slots = combine_charge
@@ -3233,6 +3239,22 @@ def log_option_best(self):
32333239
opts += ", metric_carbon({}{}/kg) ".format(self.carbon_metric, curr)
32343240
self.log("Calculate Best options: " + opts)
32353241

3242+
def compute_rate_low_for_yesterday(self, past_rates, end_record):
3243+
"""
3244+
Compute the lowest rate from yesterday's rate range only (k < end_record).
3245+
3246+
Restricts the threshold to yesterday's window so that today's rates, which are
3247+
progressively appended to past_rates as minutes_now increases, cannot lower the
3248+
threshold and cause different charge windows to be found on each hourly savings
3249+
recalculation.
3250+
"""
3251+
yesterday_values = [v for k, v in past_rates.items() if k < end_record]
3252+
if yesterday_values:
3253+
return min(yesterday_values)
3254+
if past_rates:
3255+
return min(past_rates.values())
3256+
return 0.0
3257+
32363258
def history_to_future_rates(self, rates, offset, end_record):
32373259
"""
32383260
Shift rates from the past into a future array
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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

apps/predbat/unit_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
from tests.test_discard_unused_charge_slots import run_discard_unused_charge_slots_tests
119119
from tests.test_discard_unused_export_slots import run_discard_unused_export_slots_tests
120120
from tests.test_marginal_costs import test_marginal_costs
121+
from tests.test_savings_stability import test_savings_stability
121122

122123

123124
# Mock the components and plugin system
@@ -292,6 +293,7 @@ def main():
292293
("discard_unused_charge_slots", run_discard_unused_charge_slots_tests, "Discard unused charge slots tests", False),
293294
("discard_unused_export_slots", run_discard_unused_export_slots_tests, "Discard unused export slots tests", False),
294295
("marginal_costs", test_marginal_costs, "Marginal energy cost matrix tests", False),
296+
("savings_stability", test_savings_stability, "Savings yesterday rate_low stability tests", False),
295297
("compare", test_compare, "Compare tariff engine tests (hardware overrides, bleed isolation)", False),
296298
("gateway", run_gateway_tests, "GatewayMQTT component tests (protobuf, plan serialization, commands, telemetry)", False),
297299
("optimise_levels", run_optimise_levels_tests, "Optimise levels tests", False),

0 commit comments

Comments
 (0)