7171)
7272OCTOPUS_SLOT_MAX_DEFAULT = 48 # 24 hours with 30-minute slots
7373OCTOPUS_SLOT_MAX_CAPPED = 12 # 6 hours with 30-minute slots
74+ OCTOPUS_SLOT_MAX_MINUTES = 30 # octopus_slot_max is set in 30-minute slots, the cap itself is counted in dispatch minutes (#5510)
7475IO_RATE_TOLERANCE = 0.01 # A dispatch lowers a rate by pence; anything closer is rate_min_base's dp2 rounding against the unrounded tariff feed (#5392)
7576
7677# Per-device settings read from the Octopus intelligent settings query. Kept as a list so a poll
@@ -3464,6 +3465,30 @@ def get_octopus_slot_max(self):
34643465 return OCTOPUS_SLOT_MAX_CAPPED
34653466 return OCTOPUS_SLOT_MAX_DEFAULT
34663467
3468+ def get_octopus_cap_minutes (self ):
3469+ """
3470+ The Octopus Intelligent daily low-rate cap in minutes of dispatch.
3471+
3472+ The cap is on minutes of charging, so minutes are what is counted against it: the start and
3473+ end Octopus sends for a completed dispatch and the planned start and end of one still to
3474+ come, as they are, with no rounding to half hours (#5510). How long the car really drew power
3475+ inside a dispatch is left to Octopus and not worked out here. octopus_slot_max stays in
3476+ 30-minute slots, as it is set in apps.yaml.
3477+ """
3478+ return self .get_octopus_slot_max () * OCTOPUS_SLOT_MAX_MINUTES
3479+
3480+ @staticmethod
3481+ def take_cap_minutes (minutes_per_day , day_offset , wanted_minutes , cap_minutes ):
3482+ """
3483+ Charge dispatch minutes to a midday-to-midday period's budget, returning how many of them fitted.
3484+
3485+ Shared by load_octopus_slots() and rate_add_io_slots() so the two count the cap the same way.
3486+ """
3487+ used_minutes = minutes_per_day .get (day_offset , 0 )
3488+ granted_minutes = max (0 , min (wanted_minutes , cap_minutes - used_minutes ))
3489+ minutes_per_day [day_offset ] = used_minutes + granted_minutes
3490+ return granted_minutes
3491+
34673492 def build_dispatch_timeline (self , car_n , completed , started , planned , window_before_hours = 4 , window_after_hours = 24 , step = 30 ):
34683493 """
34693494 Build a fixed-width, one-character-per-block dispatch status string for the diagnostic
@@ -3762,8 +3787,8 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_
37623787 # Car not configured, just return the slots as they are (for export or other non-car use)
37633788 return new_slots
37643789 octopus_slot_low_rate = self .get_arg ("octopus_slot_low_rate" , True )
3765- octopus_slot_max = self .get_octopus_slot_max ()
3766- slots_per_day = {} # Track 30-min blocks used per midday-to-midday period
3790+ cap_minutes = self .get_octopus_cap_minutes ()
3791+ minutes_per_day = {} # Track dispatch minutes used per midday-to-midday period
37673792 car_soc = self .car_charging_soc [car_n ]
37683793 limit = self .car_charging_limit [car_n ]
37693794 slots_decoded = []
@@ -3823,37 +3848,33 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_
38233848
38243849 # Determine rate for this slot, applying the midday-to-midday cap. A slot that only
38253850 # partly fits the remaining daily budget is split at the point the budget runs out -
3826- # e.g. a single 8-hour overnight IOG dispatch with only 4 of its 12 daily blocks left
3827- # gets 4 blocks at the low rate and the other 12 at the max rate, not the whole 16
3828- # blocks flipped to max rate the way an all-or-nothing check would (batpred#4624).
3851+ # e.g. a single 8-hour overnight IOG dispatch with only 2 of its 6 daily hours left
3852+ # gets 2 hours at the low rate and the other 6 at the max rate, not the whole 8
3853+ # hours flipped to max rate the way an all-or-nothing check would (batpred#4624).
3854+ # The budget is counted in the slot's own minutes, not in half hours it touches (#5510);
3855+ # the slots are already cut clear of each other above, so no minute is counted twice.
38293856 # kWh is apportioned to each chunk by its share of the slot's duration - an
3830- # approximation (real draw isn't perfectly uniform across the slot) but matches how
3831- # rate_add_io_slots() below treats rate as uniform per 30-min block too.
3857+ # approximation (real draw isn't perfectly uniform across the slot).
38323858 chunks = [(start_minutes , end_minutes , kwh , self .rate_import .get (start_minutes , self .rate_min_base ))]
38333859 # Marked so reprice_charger_schedule_slots() can find them again once this cycle's rates are built
38343860 charger_schedule = source == DISPATCH_SOURCE_CHARGER_SCHEDULE
38353861 if charger_schedule :
38363862 chunks = self .split_slot_by_rate (start_minutes , end_minutes , kwh )
38373863 elif octopus_slot_low_rate and self .dispatch_billed_off_peak (source , location , end_minutes ):
3838- slot_block_start = (start_minutes // 30 ) * 30
3839- num_blocks = max (1 , (end_minutes - slot_block_start + 29 ) // 30 )
38403864 day_offset = (start_minutes - 720 ) // (24 * 60 )
3841- if day_offset not in slots_per_day :
3842- slots_per_day [day_offset ] = 0
3843- available_blocks = max (0 , octopus_slot_max - slots_per_day [day_offset ])
3844- slots_per_day [day_offset ] += min (num_blocks , available_blocks )
3865+ slot_minutes = end_minutes - start_minutes
3866+ low_minutes = self .take_cap_minutes (minutes_per_day , day_offset , slot_minutes , cap_minutes )
38453867
3846- if available_blocks >= num_blocks :
3868+ if low_minutes >= slot_minutes :
38473869 chunks = [(start_minutes , end_minutes , kwh , self .rate_min_base )]
3848- elif available_blocks <= 0 :
3870+ elif low_minutes <= 0 :
38493871 chunks = [(start_minutes , end_minutes , kwh , self .rate_max_base )]
38503872 else :
38513873 # Full precision here - rounding to dp2() on both sides can make low_kwh + high_kwh
38523874 # drift from the original kwh, and this function's non-split chunks already carry
38533875 # kwh at full precision too (dp2() is only applied downstream, to cost/soc).
3854- split_minute = min (slot_block_start + available_blocks * 30 , end_minutes )
3855- span = end_minutes - start_minutes
3856- low_kwh = kwh * (split_minute - start_minutes ) / span if span > 0 else 0.0
3876+ split_minute = start_minutes + low_minutes
3877+ low_kwh = kwh * low_minutes / slot_minutes
38573878 chunks = [
38583879 (start_minutes , split_minute , low_kwh , self .rate_min_base ),
38593880 (split_minute , end_minutes , kwh - low_kwh , self .rate_max_base ),
@@ -3930,18 +3951,25 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_
39303951
39313952 def rate_add_io_slots (self , car_n , rates , octopus_slots ):
39323953 """
3933- # Add in any planned octopus slots
3934- # Octopus limits cheap slots to 6 hours (12 x 30-min slots) per 24-hour period
3954+ Add in any planned octopus slots
3955+
3956+ Octopus limits cheap dispatches to 6 hours per midday-to-midday period on some tariffs. That
3957+ budget is counted in the minutes of each dispatch as Octopus sends them, see
3958+ get_octopus_cap_minutes(). The cheap rate is still stamped by whole plan periods: a period
3959+ with any dispatch minute inside the budget is cheap throughout, including the one the budget
3960+ runs out in.
39353961 """
39363962 octopus_slot_low_rate = self .get_arg ("octopus_slot_low_rate" , True )
3937- octopus_slot_max = self .get_octopus_slot_max ()
3963+ cap_minutes = self .get_octopus_cap_minutes ()
39383964
3939- # Track slots per 24-hour period (keyed by day offset from midday)
3965+ # Dispatch minutes charged to each 24-hour period (keyed by day offset from midday)
39403966 # Period 0 = noon today to 11:59 tomorrow, Period -1 = noon yesterday to 11:59 today, etc.
3941- slots_per_day = {}
3967+ minutes_per_day = {}
39423968
3943- # Track which 30-min slot starts were actually added (for filling in the rest of the slot)
3944- slots_added_set = set ()
3969+ # The dispatch minutes that fitted the budget, so a minute in two dispatches is charged once,
3970+ # and the plan periods they fall in, which are the ones that get the cheap rate
3971+ budget_minutes = set ()
3972+ budget_periods = set ()
39453973 plan_interval_minutes = self .plan_interval_minutes
39463974 saved_slots = set () # For logging purposes, track which slots we actually applied as low rate
39473975 slot_lines = [] # One log line per half hour of each slot, logged after the loop only when the list changes
@@ -3951,30 +3979,50 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
39513979 strip_from = self .dynamic_load_car_strip_from (car_n )
39523980
39533981 if octopus_slots :
3954- # Add in IO slots
3955- for slot in octopus_slots :
3956- start_minutes , end_minutes , kwh , source , location = self .decode_octopus_slot (car_n , slot , raw = True )
3982+ # Earliest first, as load_octopus_slots() takes them, so the budget goes to the same dispatches in both
3983+ decoded_slots = sorted ((self .decode_octopus_slot (car_n , slot , raw = True ) for slot in octopus_slots ), key = lambda decoded : decoded [0 ])
39573984
3985+ # Add in IO slots
3986+ for dispatch_start , dispatch_end , kwh , source , location in decoded_slots :
39583987 # A dispatch that's already fully in the past and delivered zero kWh either never
39593988 # actually happened (a withdrawn planned slot Octopus hasn't dropped yet) or genuinely
3960- # delivered nothing, so it shouldn't consume one of the day's capped low-rate slots .
3989+ # delivered nothing, so it shouldn't use up any of the day's capped low-rate minutes .
39613990 # A future/still-active slot is left alone even at zero kWh - its kWh is usually
39623991 # synthesised rather than genuinely zero, and an in-progress dispatch is still real.
39633992 # This runs ahead of the completed-dispatch exemption below on purpose: a zero-kWh past
39643993 # dispatch is treated as never having happened, so it is dropped whatever its location.
3965- if end_minutes <= self .minutes_now and kwh <= 0 :
3994+ if dispatch_end <= self .minutes_now and kwh <= 0 :
39663995 continue
39673996
39683997 # Off-peak eligibility (source, location and the completed-dispatch exemption for
39693998 # issue #4946) is shared with load_octopus_slots() so the two cap counters agree.
3970- if self .dispatch_billed_off_peak (source , location , end_minutes ):
3999+ if self .dispatch_billed_off_peak (source , location , dispatch_end ):
39714000 # Round slots to 30 minute boundary
39724001 # Floor the start (round down) and ceiling the end (round up)
39734002 # This ensures any partial overlap with a 30-min slot marks the entire slot as off-peak
3974- start_minutes , end_minutes = round_out_to_period (start_minutes , end_minutes , plan_interval_minutes )
4003+ start_minutes , end_minutes = round_out_to_period (dispatch_start , dispatch_end , plan_interval_minutes )
39754004 start_minutes = max (start_minutes , - 96 * 60 ) # Allow for previous 2 days
39764005 end_minutes = min (end_minutes , self .forecast_minutes )
39774006
4007+ # Calculate which day this slot belongs to (day boundary at midday)
4008+ # Period 0 = noon today (720) to 11:59 tomorrow (2159), etc.
4009+ # Python's floor division handles negative numbers correctly
4010+ #
4011+ # Key this on the slot's START, not the current minute: a window that opens in
4012+ # the evening and runs past noon the next day would otherwise cross into the
4013+ # next period part-way through and be handed a second budget, re-stamping its
4014+ # tail at rate_min_base in the middle of the day-rate block. load_octopus_slots
4015+ # already charges a window to its start period the same way.
4016+ day_offset = (start_minutes - 720 ) // (24 * 60 )
4017+
4018+ # Charge the dispatch's own minutes to the budget, earliest first. A minute the car
4019+ # is not trusted to charge in, or that no car needs, is never cheap and uses none of it
4020+ dispatch_minutes = [minute for minute in range (max (dispatch_start , start_minutes ), min (dispatch_end , end_minutes )) if not self .io_slot_minute_withheld (minute , strip_from )]
4021+ wanted_minutes = [minute for minute in dispatch_minutes if minute not in budget_minutes ]
4022+ granted_minutes = self .take_cap_minutes (minutes_per_day , day_offset , len (wanted_minutes ), cap_minutes )
4023+ budget_minutes .update (wanted_minutes [:granted_minutes ])
4024+ budget_periods .update ((minute // plan_interval_minutes ) * plan_interval_minutes for minute in dispatch_minutes if minute in budget_minutes )
4025+
39784026 for minute in range (start_minutes , end_minutes ):
39794027 if octopus_slot_low_rate :
39804028 assumed_price = self .rate_min_base
@@ -3985,49 +4033,15 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
39854033 # is behaviour-preserving there and simply avoids the staleness this PR adds.)
39864034 assumed_price = rates .get (start_minutes , self .rate_min )
39874035
3988- if strip_from is not None and minute >= strip_from :
3989- continue # Car is not charging, its dispatch is not trusted as cheap
3990-
3991- if minute in self .octopus_surplus :
3992- continue # No car needs this dispatch minute, see octopus_surplus_minutes()
4036+ if self .io_slot_minute_withheld (minute , strip_from ):
4037+ continue # Car is not charging or no car needs this minute, the dispatch is not cheap here
39934038
39944039 if minute in saved_slots :
39954040 continue # Already applied a low rate slot to this minute, skip
39964041 else :
39974042 saved_slots .add (minute )
39984043
3999- # Calculate which day this slot belongs to (day boundary at midday)
4000- # Period 0 = noon today (720) to 11:59 tomorrow (2159), etc.
4001- # Python's floor division handles negative numbers correctly
4002- #
4003- # Key this on the slot's START, not the current minute: a window that opens in
4004- # the evening and runs past noon the next day would otherwise cross into the
4005- # next period part-way through and be handed a second budget, re-stamping its
4006- # tail at rate_min_base in the middle of the day-rate block. load_octopus_slots
4007- # already charges a window to its start period the same way.
4008- day_offset = (start_minutes - 720 ) // (24 * 60 )
4009-
4010- # Initialise counter for this day if needed
4011- if day_offset not in slots_per_day :
4012- slots_per_day [day_offset ] = 0
4013-
4014- # Calculate the 30-min slot start for this minute
4015- slot_start = (minute // 30 ) * 30
4016-
4017- # At the start of each 30-min slot, decide if we can add it
4018- apply_rate = False
4019- if minute % 30 == 0 :
4020- if slots_per_day [day_offset ] < octopus_slot_max :
4021- slots_per_day [day_offset ] += 1
4022- slots_added_set .add (slot_start )
4023- apply_rate = True
4024- else :
4025- assumed_price = self .rate_max_base
4026- else :
4027- # For minutes within a 30-min slot, only apply if the slot was added
4028- apply_rate = slot_start in slots_added_set
4029-
4030- if apply_rate :
4044+ if (minute // plan_interval_minutes ) * plan_interval_minutes in budget_periods :
40314045 # Mark the minutes this dispatch made cheaper in io_adjusted, as the Octopus Energy
40324046 # integration's feed does with is_intelligent_adjusted, so the plan treats them as a
40334047 # dispatch that may still move or vanish. A minute already off-peak by tariff is
@@ -4037,6 +4051,8 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
40374051 if assumed_price < rates .get (minute , assumed_price ) - IO_RATE_TOLERANCE :
40384052 self .io_adjusted [minute ] = True
40394053 rates [minute ] = assumed_price
4054+ else :
4055+ assumed_price = self .rate_max_base # Beyond the day's budget
40404056
40414057 if minute % 30 == 0 and start_minutes > - 24 * 60 :
40424058 slot_lines .append (
@@ -4049,13 +4065,22 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
40494065 if slot_lines :
40504066 self .io_slot_log_gate .log (self .log , ("slots" , car_n ), "\n " .join (slot_lines ), slot_lines , lambda when : "Octopus: Intelligent slots for car {} same as logged in full at {}, {} half-hour slot lines" .format (car_n , when , len (slot_lines )))
40514067
4052- # Log daily slot counts for debugging
4053- for day_offset in sorted (slots_per_day .keys ()):
4054- if slots_per_day [day_offset ] > 0 :
4055- self .log ("Octopus: Intelligent slots for day {}: {} of {} max" .format (day_offset , slots_per_day [day_offset ], octopus_slot_max ))
4068+ # Log the dispatch minutes charged to each day's budget for debugging
4069+ for day_offset in sorted (minutes_per_day .keys ()):
4070+ if minutes_per_day [day_offset ] > 0 :
4071+ self .log ("Octopus: Intelligent slot minutes for day {}: {} of {} max" .format (day_offset , minutes_per_day [day_offset ], cap_minutes ))
40564072
40574073 return rates
40584074
4075+ def io_slot_minute_withheld (self , minute , strip_from ):
4076+ """
4077+ Whether a dispatch minute is denied the cheap rate whatever the budget says.
4078+
4079+ True from strip_from on, when dynamic load has seen the car in its slot but not charging (None
4080+ when it is not cancelled), and for a minute no car needs - see octopus_surplus_minutes().
4081+ """
4082+ return (strip_from is not None and minute >= strip_from ) or minute in self .octopus_surplus
4083+
40594084 def in_iog_fixed_window (self , minute ):
40604085 """
40614086 Whether minute falls in the fixed 23:30-05:30 Intelligent Go window, cheap by tariff rather than by
0 commit comments