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
28 changes: 25 additions & 3 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions apps/predbat/tests/test_savings_stability.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading