Skip to content

Commit 40f5dfe

Browse files
chalfontchubbyclaude
authored andcommitted
fix: compute rate_min_base/rate_max_base from the gap-filled rate curve, not the raw sparse fetch (#4544)
rate_min_base/rate_max_base were captured from a scan of the raw import rate dict before rate_replicate() ran. For a tariff whose forward-fetch window is short (e.g. Kraken/E.ON Next Drive Smart, a fixed day/night product where the raw fetch only exposes the currently-active segment), the scan only ever saw the known segment and locked in the wrong "true" min/max for the day - rate_add_io_slots() then stamped IOG/SmartFlex dispatch slots with that stale value instead of the tariff's real off-peak price, showing as "Predbat charges the car at the high import rate". New rate_base_min_max() helper replicates first (mirroring how rate_export_base is already built downstream of rate_replicate() on the export side) and scans the result, so a segment that hasn't started yet but is recoverable from history is reflected correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e70314f commit 40f5dfe

2 files changed

Lines changed: 98 additions & 3 deletions

File tree

apps/predbat/fetch.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -976,9 +976,7 @@ def fetch_sensor_data(self, save=True):
976976
# Replicate and scan import rates
977977
if import_rates:
978978
self.rate_scan(import_rates, print=False)
979-
self.rate_max_base = self.rate_max # True peak rate before saving sessions / overrides inflate it
980-
self.rate_min_base = self.rate_min # True off-peak rate before free sessions / overrides deflate it
981-
self.rate_import_base, _ = self.rate_replicate(import_rates.copy(), {}, is_import=True) # True import rates, gap-filled but without IO/saving/override distortion
979+
self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates)
982980
import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True)
983981
self.rate_import_no_io = import_rates.copy()
984982
for car_n in range(self.num_cars):
@@ -1837,6 +1835,23 @@ def rate_minmax(self, rates):
18371835

18381836
return dp2(rate_min), dp2(rate_max), dp2(rate_average), rate_min_minute, rate_max_minute
18391837

1838+
def rate_base_min_max(self, rates):
1839+
"""
1840+
Gap-fill `rates` into a "base" import curve (replicated, but without IO-slot/saving-session/
1841+
override distortion) and work out its true min/max.
1842+
1843+
Must scan the gap-filled curve, not the raw input: some tariffs (e.g. a fixed day/night
1844+
product fetched with a short forward window - #4544) only have the currently-active segment
1845+
in the raw data at fetch time, so a scan taken before replication can miss a cheaper/dearer
1846+
segment that hasn't started yet and lock in the wrong "true" min/max for the rest of the day.
1847+
rate_add_io_slots() then stamps IOG/SmartFlex dispatch slots with that stale value instead of
1848+
the tariff's real off-peak price. Mirrors how rate_export_base is already built downstream of
1849+
rate_replicate() on the export side.
1850+
"""
1851+
rate_base, _ = self.rate_replicate(rates.copy(), {}, is_import=True)
1852+
rate_min_base, rate_max_base, _, _, _ = self.rate_minmax(rate_base)
1853+
return rate_base, rate_min_base, rate_max_base
1854+
18401855
def rate_min_forward_calc(self, rates):
18411856
"""
18421857
Work out lowest rate from time forwards

apps/predbat/tests/test_rate_replicate_missing_slots.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def test_rate_replicate(my_predbat):
3838
("import_offset", _test_import_offset, "Import rate offset"),
3939
("export_offset", _test_export_offset_negative, "Export rate offset with negative clamping"),
4040
("gas_rates", _test_gas_rates, "Gas rates (is_gas=True)"),
41+
("rate_base_min_max", _test_rate_base_min_max, "rate_base_min_max reflects the gap-filled curve, not the raw sparse fetch (#4544)"),
4142
]
4243

4344
print("\n" + "="*70)
@@ -698,3 +699,82 @@ def _test_gas_rates(my_predbat):
698699
my_predbat.forecast_minutes = 24*60
699700

700701
return failed
702+
703+
704+
def _test_rate_base_min_max(my_predbat):
705+
"""
706+
Test for rate_base_min_max() reflecting the gap-filled forward curve, not a raw sparse fetch (#4544).
707+
708+
Reproduces a fixed day/night tariff (e.g. Kraken/E.ON Next Drive Smart) fetched with a short
709+
forward window: at fetch time, only the currently-active (expensive, daytime) segment is known
710+
forward of "now" - the cheaper night segment a few hours away hasn't been published yet. History
711+
has the full repeating day/night pattern, so rate_replicate() can correctly project the true
712+
night rate forward from it, but a scan of the raw, not-yet-replicated dict only ever sees the
713+
known expensive segment.
714+
"""
715+
failed = 0
716+
717+
print("*** Test: rate_base_min_max reflects the gap-filled curve, not the raw sparse fetch ***")
718+
719+
my_predbat.midnight = datetime.strptime("2025-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S")
720+
my_predbat.forecast_minutes = 2880
721+
my_predbat.minutes_now = 550 # 09:10 - inside the day segment, matching the reported scenario
722+
my_predbat.metric_future_rate_offset_import = 0
723+
my_predbat.metric_future_rate_offset_export = 0
724+
my_predbat.future_energy_rates_import = {}
725+
my_predbat.future_energy_rates_export = {}
726+
my_predbat.rate_max = 99.0
727+
728+
day_rate = 31.18
729+
night_rate = 8.0
730+
day_start, night_start = 300, 1380 # 05:00, 23:00
731+
732+
rates = {}
733+
# Full repeating day/night pattern for yesterday (minutes -1440..-1), so rate_replicate() has a
734+
# real forward projection to draw on.
735+
for minute in range(-1440, 0):
736+
minute_of_day = minute % 1440
737+
rates[minute] = night_rate if (minute_of_day >= night_start or minute_of_day < day_start) else day_rate
738+
739+
# Raw fetch only knows about the current segment (the whole day segment, since the tariff
740+
# publishes each segment as a single valid_from/valid_to block) - nothing beyond it is known yet
741+
# (mimicking a short forward-fetch window, e.g. Kraken's SmartFlex tariff data).
742+
for minute in range(day_start, night_start):
743+
rates[minute] = day_rate
744+
745+
assert night_start not in rates, "Test setup error: tonight's night segment should not be fetched yet"
746+
747+
# The raw, pre-replicate scan only sees the known expensive segment - this is the bug: taken at
748+
# face value, it looks like the tariff never goes below day_rate.
749+
raw_min, raw_max, _, _, _ = my_predbat.rate_minmax(rates)
750+
if raw_min != day_rate or raw_max != day_rate:
751+
print(f" ✗ ERROR: Test setup error - raw scan should see only {day_rate}, got min={raw_min} max={raw_max}")
752+
failed |= 1
753+
754+
rate_base, rate_min_base, rate_max_base = my_predbat.rate_base_min_max(rates)
755+
756+
if rate_min_base != night_rate:
757+
print(f" ✗ ERROR: rate_min_base should be {night_rate} (from the gap-filled forward night segment), got {rate_min_base} - this is the #4544 bug")
758+
failed |= 1
759+
else:
760+
print(f" ✓ rate_min_base correctly reflects the gap-filled night rate: {rate_min_base}")
761+
762+
if rate_max_base != day_rate:
763+
print(f" ✗ ERROR: rate_max_base should be {day_rate}, got {rate_max_base}")
764+
failed |= 1
765+
else:
766+
print(f" ✓ rate_max_base correctly reflects the day rate: {rate_max_base}")
767+
768+
if night_start not in rate_base or rate_base.get(night_start) != night_rate:
769+
print(f" ✗ ERROR: rate_base should have tonight's night segment gap-filled to {night_rate}, got {rate_base.get(night_start)}")
770+
failed |= 1
771+
772+
# Restore context
773+
my_predbat.now_utc = datetime.now(my_predbat.local_tz)
774+
my_predbat.midnight_utc = my_predbat.now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
775+
my_predbat.midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
776+
my_predbat.minutes_now = int((my_predbat.now_utc - my_predbat.midnight_utc).total_seconds() / 60)
777+
my_predbat.rate_max = 0
778+
my_predbat.forecast_minutes = 24 * 60
779+
780+
return failed

0 commit comments

Comments
 (0)