Skip to content

Commit a9d226e

Browse files
CIclaude
andcommitted
fix(octopus): count the Intelligent daily cap in dispatch minutes, not half-hour blocks (#5510)
rate_add_io_slots() charged one 30-minute block per half hour a dispatch touched and load_octopus_slots() charged max(1, blocks spanned) per dispatch, so a 10-minute dispatch used 30 minutes of the cap and the two counters disagreed. Both now charge the dispatch's own minutes, as Octopus sends them for a completed dispatch and as planned for one still to come, through one shared helper. octopus_slot_max stays in 30-minute slots in apps.yaml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9247831 commit a9d226e

5 files changed

Lines changed: 192 additions & 90 deletions

File tree

‎apps/predbat/octopus.py‎

Lines changed: 98 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
)
7272
OCTOPUS_SLOT_MAX_DEFAULT = 48 # 24 hours with 30-minute slots
7373
OCTOPUS_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)
7475
IO_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

Comments
 (0)