Skip to content

Commit eb13f9e

Browse files
fix(teslemetry): keep scheduled-export slots out of real-tier quantisation
Follow-up to #4346. _quantise_side splits [min,max] into 3 equal-width bands; a saving-session / scheduled-export spike in rate_export blew up the range so the whole normal day (overnight, midday, evening peak) collapsed into a single bottom tier. The Powerwall then saw a flat price outside the export window and lost its normal charge/export signal. The spike is already priced via the reserved ON_PEAK boost band, so it should not also define the 3 real bands. build_tariff now computes the boost segments before quantising and passes those ranges to _quantise_side, which excludes them from the band definition (they are still assigned a real tier, clamped into range, but that is overwritten by the boost). With no discharge window the behaviour is byte-identical to before. Adds a regression test: an Agile day plus a 175p saving session keeps overnight < midday < evening-peak as distinct increasing sell tiers, with the session in ON_PEAK above them all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 263c5df commit eb13f9e

2 files changed

Lines changed: 101 additions & 19 deletions

File tree

apps/predbat/teslemetry.py

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -831,36 +831,57 @@ def current_rates(self):
831831
return import_rate, export_rate
832832

833833
@staticmethod
834-
def _quantise_side(rate_dict, default_pence):
834+
def _quantise_side(rate_dict, default_pence, exclude_ranges=()):
835835
"""Quantise a per-minute pence rate dict into <=3 named GBP tiers over today+tomorrow.
836836
837837
Samples every 30 minutes for today (minutes 0-1439) and tomorrow (1440-2879), converts to
838838
GBP clamped at 0 and rounded to whole pence, then either maps <=3 distinct values one-to-one
839839
onto the real tiers (cheapest -> SUPER_OFF_PEAK) or splits [min,max] into 3 equal-width bands
840840
priced at each band's mean. Returns (tier_prices, today_tiers, tomorrow_tiers) where the slot
841841
lists only ever name tiers present in tier_prices (matched sets).
842+
843+
exclude_ranges is a list of (start_min, end_min) absolute-minute ranges (today 0-1439,
844+
tomorrow 1440-2879) whose slots are a scheduled export - they are priced via the reserved
845+
ON_PEAK boost band, not the 3 real bands, so they are kept OUT of the band definition here.
846+
Otherwise a saving-session/export spike would blow up the [min,max] range and collapse the
847+
whole normal day into a single tier. Excluded slots are still assigned a real tier (their
848+
value clamped into the excluded-slot-free range) but that tier is later overwritten by the
849+
boost, so the choice is immaterial. With no exclude_ranges this is byte-identical to before.
842850
"""
843851

844852
def slot_price(minute):
845853
"""Convert a per-minute pence rate to GBP, clamped at 0 and rounded to whole pence."""
846854
pence = rate_dict.get(minute, default_pence)
847855
return round(max(0.0, pence) / 100.0, 2)
848856

849-
today = [slot_price(m) for m in range(0, 1440, SLOT_MINUTES)]
850-
tomorrow = [slot_price(m) for m in range(1440, 2880, SLOT_MINUTES)]
851-
combined = today + tomorrow
852-
distinct = sorted(set(combined))
857+
def is_excluded(minute):
858+
"""True if a sample minute falls in a scheduled-export range (priced via ON_PEAK instead)."""
859+
return any(start <= minute < end for start, end in exclude_ranges)
860+
861+
sample_minutes = list(range(0, 2880, SLOT_MINUTES))
862+
combined = [slot_price(minute) for minute in sample_minutes]
863+
today = combined[:SLOTS_PER_DAY]
864+
tomorrow = combined[SLOTS_PER_DAY:]
865+
# Values that DEFINE the bands exclude scheduled-export slots so a spike cannot dominate.
866+
band_values = [price for minute, price in zip(sample_minutes, combined) if not is_excluded(minute)]
867+
if not band_values:
868+
band_values = list(combined)
869+
low, high = min(band_values), max(band_values)
870+
distinct = sorted(set(band_values))
871+
872+
def clamp(value):
873+
"""Clamp a slot value into the (excluded-slot-free) band range before tier assignment."""
874+
return min(max(value, low), high)
853875

854876
if len(distinct) <= len(REAL_TIERS):
855877
value_to_tier = {value: REAL_TIERS[index] for index, value in enumerate(distinct)}
856878
tier_prices = {tier: value for value, tier in value_to_tier.items()}
857879

858880
def band_of(value):
859-
"""Return the tier for an exact value in the small-distinct case."""
860-
return value_to_tier[value]
881+
"""Return the tier for a value, clamped so excluded-slot outliers map to the top real tier."""
882+
return value_to_tier[clamp(value)]
861883

862884
else:
863-
low, high = distinct[0], distinct[-1]
864885
width = (high - low) / len(REAL_TIERS)
865886
buckets = {index: [] for index in range(len(REAL_TIERS))}
866887

@@ -870,13 +891,13 @@ def band_index(value):
870891
return 0
871892
return min(len(REAL_TIERS) - 1, int((value - low) / width))
872893

873-
for value in combined:
874-
buckets[band_index(value)].append(value)
894+
for price in band_values:
895+
buckets[band_index(price)].append(price)
875896
tier_prices = {REAL_TIERS[index]: round(sum(values) / len(values), 2) for index, values in buckets.items() if values}
876897

877898
def band_of(value):
878-
"""Return the tier name for the band a value falls in."""
879-
return REAL_TIERS[band_index(value)]
899+
"""Return the tier name for the band a value falls in (clamped into the band range)."""
900+
return REAL_TIERS[band_index(clamp(value))]
880901

881902
today_tiers = [band_of(value) for value in today]
882903
tomorrow_tiers = [band_of(value) for value in tomorrow]
@@ -1012,17 +1033,18 @@ def _apply_boost(buy_layout, sell_layout, segments, today_dow):
10121033
day = (today_dow + offset) % 7
10131034
layout[day] = TeslemetryAPI._carve_interval(layout[day], seg_start, seg_end, BOOST_TIER)
10141035

1015-
def _rate_side(self, rate_dict, default_gbp):
1036+
def _rate_side(self, rate_dict, default_gbp, exclude_ranges=()):
10161037
"""Return the 4-tuple (energy_charges_side, tou_periods, tier_prices, layout) for one side.
10171038
10181039
layout is None in the flat/fallback branch (no rates), signalling build_tariff to skip the boost;
1019-
otherwise it is the per-DOW interval layout the boost carves into.
1040+
otherwise it is the per-DOW interval layout the boost carves into. exclude_ranges names the
1041+
scheduled-export slots to keep out of the real-tier band definition (see _quantise_side).
10201042
"""
10211043
if not rate_dict:
10221044
flat = round(max(0.0, default_gbp), 2)
10231045
return {"ALL": {"rates": {"ALL": flat}}}, {}, {"SUPER_OFF_PEAK": flat}, None
10241046
today_dow = self._tesla_dow(self._local_today_weekday())
1025-
tier_prices, today_tiers, tomorrow_tiers = self._quantise_side(rate_dict, default_gbp * 100.0)
1047+
tier_prices, today_tiers, tomorrow_tiers = self._quantise_side(rate_dict, default_gbp * 100.0, exclude_ranges)
10261048
layout = self._side_layout(today_tiers, tomorrow_tiers, today_dow)
10271049
return (*self._render_side(layout, tier_prices), tier_prices, layout)
10281050

@@ -1062,15 +1084,19 @@ def build_tariff(self, discharge_window=None, now_min=None):
10621084
now_min = self.get_minutes_now()
10631085
import_gbp, export_gbp = self.current_rates()
10641086
today_dow = self._tesla_dow(self._local_today_weekday())
1065-
buy_charges, buy_periods, buy_prices, buy_layout = self._rate_side(self._side_rates("import"), import_gbp)
1066-
sell_charges, sell_periods, sell_prices, sell_layout = self._rate_side(self._side_rates("export"), export_gbp)
1087+
# Compute the boost segments BEFORE quantising so the scheduled-export slots can be kept out of
1088+
# the real-tier band definition - they are priced via the reserved ON_PEAK band, and leaving a
1089+
# saving-session/export spike in would blow up the [min,max] range and flatten the normal day.
1090+
segments = self._boost_segments(discharge_window, now_min) if discharge_window is not None else []
1091+
exclude_ranges = [(offset * 1440 + seg_start, offset * 1440 + seg_end) for offset, seg_start, seg_end in segments]
1092+
buy_charges, buy_periods, buy_prices, buy_layout = self._rate_side(self._side_rates("import"), import_gbp, exclude_ranges)
1093+
sell_charges, sell_periods, sell_prices, sell_layout = self._rate_side(self._side_rates("export"), export_gbp, exclude_ranges)
10671094
code = "PREDBAT"
10681095
# buy_layout/sell_layout are None only in the flat-fallback branch (no rate data, priced via the
10691096
# ALL field) - there are no per-day bands to carve a boost into, and that degenerate zero-rate-data
10701097
# case is not a normal production state, so the boost is intentionally skipped there.
1071-
if discharge_window is not None and buy_layout is not None and sell_layout is not None:
1098+
if segments and buy_layout is not None and sell_layout is not None:
10721099
boost = self._boost_price(buy_prices, sell_prices)
1073-
segments = self._boost_segments(discharge_window, now_min)
10741100
self._apply_boost(buy_layout, sell_layout, segments, today_dow)
10751101
buy_charges, buy_periods = self._render_side(buy_layout, {**buy_prices, BOOST_TIER: boost})
10761102
sell_charges, sell_periods = self._render_side(sell_layout, {**sell_prices, BOOST_TIER: boost})

apps/predbat/tests/test_teslemetry.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,61 @@ def test_teslemetry_build_tariff_consolidates_replicated_days():
630630
assert len(block["periods"]) <= 3, "tier {} has {} period entries, expected consolidated ranges (<=3): {}".format(tier, len(block["periods"]), block["periods"])
631631

632632

633+
def test_teslemetry_saving_session_spike_keeps_daily_shape():
634+
"""A scheduled-export/saving-session spike (issue #4346 follow-up) must not flatten the rest of the
635+
day's rate shape when quantised into Tesla tiers.
636+
637+
The spike lives in the reserved ON_PEAK boost band, so it is excluded from the 3 real-tier band
638+
definition. Before the fix, the outlier blew up the [min,max] range and collapsed overnight AND
639+
the evening peak into one bottom tier - the Powerwall then saw a flat price outside the session
640+
and lost its normal charge/export signal. Here overnight, midday and the evening peak must each
641+
resolve to a distinct, monotonically increasing sell tier, while the boosted session sits in
642+
ON_PEAK above them all.
643+
"""
644+
from types import SimpleNamespace
645+
from datetime import datetime
646+
647+
def agile(offset):
648+
"""An Agile-like export day: overnight 5p, midday 12p, evening peak 25p, half-hourly."""
649+
day = {}
650+
for slot in range(48):
651+
hour = slot // 2
652+
price = 5.0 if hour < 6 else (25.0 if 16 <= hour < 19 else 12.0)
653+
for minute in range(slot * 30, slot * 30 + 30):
654+
day[offset + minute] = price
655+
return day
656+
657+
rate_export = {**agile(0), **agile(1440)}
658+
for minute in range(17 * 60, 18 * 60 + 30): # 17:00-18:30 saving session already folded into rate_export
659+
rate_export[minute] = 175.0
660+
661+
api = MockTeslemetryAPI()
662+
api.base = SimpleNamespace(rate_import={m: 15.0 for m in range(2880)}, rate_export=rate_export, minutes_now=0, now=datetime(2026, 7, 20, 12, 0), local_tz=None, get_arg=lambda a, d=None, **k: d)
663+
# Predbat scheduled an export over the session, so build_tariff is called with that window.
664+
tariff = api.build_tariff((17 * 60, 18 * 60 + 30), now_min=12 * 60)
665+
sell = tariff["sell_tariff"]["energy_charges"]["AllYear"]["rates"]
666+
periods = tariff["sell_tariff"]["seasons"]["AllYear"]["tou_periods"]
667+
today = api._tesla_dow(api._local_today_weekday())
668+
669+
def sell_price_at(minute):
670+
"""Return the sell tier price applying on today's day at the given minute-of-day."""
671+
for tier, block in periods.items():
672+
for period in block["periods"]:
673+
if period["fromDayOfWeek"] <= today <= period["toDayOfWeek"]:
674+
start = period["fromHour"] * 60 + period["fromMinute"]
675+
end = (period["toHour"] * 60 + period["toMinute"]) or 1440
676+
if start <= minute < end:
677+
return sell[tier]
678+
return None
679+
680+
overnight = sell_price_at(3 * 60) # real 5p
681+
midday = sell_price_at(12 * 60) # real 12p
682+
eve_peak = sell_price_at(16 * 60) # real 25p, outside the boosted session
683+
session = sell_price_at(17 * 60 + 30) # boosted session
684+
assert overnight < midday < eve_peak, "daily shape lost: overnight={} midday={} peak={}".format(overnight, midday, eve_peak)
685+
assert session > eve_peak, "boosted session {} must price above the real peak {}".format(session, eve_peak)
686+
687+
633688
def test_teslemetry_day_runs_groups_replicated_days():
634689
"""_day_runs() itself: 6 identical days plus 1 different day collapse to 2 runs, not 7 singletons."""
635690
api = MockTeslemetryAPI()
@@ -1831,6 +1886,7 @@ def test_teslemetry(my_predbat=None):
18311886
test_teslemetry_build_tariff_boost_clamps_above_high_rates()
18321887
test_teslemetry_build_tariff_periods_partition_each_day()
18331888
test_teslemetry_build_tariff_consolidates_replicated_days()
1889+
test_teslemetry_saving_session_spike_keeps_daily_shape()
18341890
test_teslemetry_day_runs_groups_replicated_days()
18351891
test_teslemetry_day_runs_all_identical_single_run()
18361892
test_teslemetry_set_tariff_posts_tou_settings()

0 commit comments

Comments
 (0)