@@ -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 })
0 commit comments