Skip to content

Commit 9891606

Browse files
CIclaude
andcommitted
fix(octopus): keep the dispatch rate on a half hour a planned dispatch ran in until it is listed as completed (#5520)
A planned Octopus Intelligent dispatch only covers what is still to come: the Octopus API component advances the start of a running one to now, and an ended one is dropped, while Octopus lists it as completed 15 to 60 minutes later. In between the half hour went back to the day rate, and as today's cost is priced from the current rates the day's cost stepped up after each half hour of car charging and fell back when the completed dispatch arrived. Each cycle, after the slot confirmation check, remember the half hour a started planned dispatch is running in unless the car was cancelled in it, and put it back into the slots as a finished dispatch until a completed dispatch overlaps it or OCTOPUS_DISPATCH_HOLD_MINUTES (90) have passed. A dispatch still to start is never held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1faff22 commit 9891606

8 files changed

Lines changed: 388 additions & 3 deletions

File tree

‎apps/predbat/const.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
# A car_charging_now reading only confirms the half hour it is in (keeping that half hour cheap once the car
4040
# stops, GH#5316) from this many minutes into it: the sensor can lag a car that stopped just before the boundary
4141
DYNAMIC_LOAD_CAR_CONFIRM_MINUTES = 2
42+
# How long a half hour an Octopus Intelligent planned dispatch ran in keeps the dispatch rate after it ends,
43+
# while waiting for Octopus to list it as a completed dispatch - seen to take 15 to 60 minutes (GH#5520)
44+
OCTOPUS_DISPATCH_HOLD_MINUTES = 90
4245

4346
# Extra cloud divergence applied to the PV10 scenario on top of the computed cloud factor, so the
4447
# downside case diverges harder than the central one

‎apps/predbat/fetch.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,9 @@ def fetch_sensor_data(self, save=True):
14641464
# Dynamic load: cancel the Octopus Intelligent slots of a car that is in one but not charging -
14651465
# before the rates are built, so a cancelled dispatch never gets its cheap rate
14661466
dynamic_load_car_changed = self.dynamic_load_car_check(save=save)
1467+
if save:
1468+
# Remember the half hour a dispatch is running in, now it is known whether the car was cancelled
1469+
self.octopus_held_update()
14671470
octopus_surplus_changed = False
14681471

14691472
if "rates_export_octopus_url" in self.args:
@@ -1806,6 +1809,8 @@ def fetch_sensor_data_cars(self, save=True):
18061809
# Cars whose charging plan came from Octopus Intelligent dispatch slots this cycle - used
18071810
# below to decide which cars get a model-facing charge limit override (#4967)
18081811
iog_slot_cars = []
1812+
# The planned dispatches each car's plan used this cycle, for octopus_held_update()
1813+
self.octopus_slots_planned = {}
18091814

18101815
if entity_id_list:
18111816
# Process each car's intelligent slot configuration
@@ -1857,10 +1862,16 @@ def fetch_sensor_data_cars(self, save=True):
18571862
# Completed and planned slots - merge from all cars
18581863
if completed:
18591864
self.octopus_slots[car_n] += completed
1865+
planned_used = []
18601866
if planned and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n] or self.car_charging_now[car_n]):
18611867
# We only count planned slots if the car is plugged in or we are ignoring unplugged cars. A car
18621868
# charging now is plugged in, even before car_charging_planned catches up with an ad-hoc dispatch
1863-
self.octopus_slots[car_n] += planned
1869+
planned_used = planned
1870+
# The half hours a planned dispatch has already run in, until the completed list has them (#5520).
1871+
# After the completed list, which is the record once it arrives, and ahead of the planned one
1872+
self.octopus_slots[car_n] += self.octopus_held_dispatches(car_n, completed or [], planned_used)
1873+
self.octopus_slots[car_n] += planned_used
1874+
self.octopus_slots_planned[car_n] = planned_used
18641875

18651876
# Extract vehicle data if we can get it
18661877
size = self.get_state_wrapper(entity_id=entity_id, attribute="vehicle_battery_size_in_kwh")

‎apps/predbat/octopus.py‎

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import re
2020
from datetime import datetime, timedelta, timezone
2121
from predbat_metrics import record_api_call
22-
from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS, DISPATCH_SOURCE_CHARGER_SCHEDULE, REPEAT_FULL_LOG_SECONDS
22+
from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS, DISPATCH_SOURCE_CHARGER_SCHEDULE, REPEAT_FULL_LOG_SECONDS, OCTOPUS_DISPATCH_HOLD_MINUTES
2323
from utils import RepeatLogGate, str2time, minutes_to_time, dp1, dp2, dp4, minute_data, round_out_to_period, filter_payment_method, is_edge_block_body, token_mint_backoff_seconds, TOKEN_MINT_BACKOFF_LOG_INTERVAL_SECONDS
2424
from component_base import ComponentBase
2525
from mock_base import MockBase as SharedMockBase
@@ -3258,8 +3258,11 @@ def octopus_slots_signature(self, octopus_slots):
32583258
and force a needless replan each time. For a currently active slot the signature keeps only
32593259
the stable fields (window end, source, location); genuine changes - new/removed slots, a
32603260
moved window end, a revised future slot, a future slot becoming active - still alter it.
3261+
3262+
A held slot (octopus_held_dispatches()) is left out: it only re-states a half hour a planned dispatch
3263+
already priced, and the one for the half hour in progress ends at now, so moves every cycle.
32613264
"""
3262-
return dispatch_slots_signature(octopus_slots, self.now_utc)
3265+
return dispatch_slots_signature([[slot for slot in car_slots if not slot.get("held", False)] for car_slots in octopus_slots], self.now_utc)
32633266

32643267
def load_free_slot(self, octopus_free_slots, rate_dict, export=False, rate_replicate=None):
32653268
"""
@@ -4056,6 +4059,90 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
40564059

40574060
return rates
40584061

4062+
def octopus_planned_running(self, car_n, planned):
4063+
"""
4064+
The source of the planned dispatch that is pricing the current half hour off-peak for car_n, or None
4065+
when none is.
4066+
4067+
The dispatch must have started - on the exact clock, as the Octopus API component advances the start
4068+
of a running dispatch to the time of its last poll, which is after minutes_now - and reach into the
4069+
current half hour, which rate_add_io_slots() prices whole. One still to start is not counted, though
4070+
its half hour is priced too: Octopus can withdraw it before it runs.
4071+
"""
4072+
minute_exact = self.dynamic_load_car_minute(self.now_utc_real)
4073+
for slot in planned:
4074+
start_minutes, end_minutes, _, source, location = self.decode_octopus_slot(car_n, slot, raw=True)
4075+
if start_minutes == end_minutes or not self.dispatch_billed_off_peak(source, location, end_minutes):
4076+
continue
4077+
if start_minutes <= minute_exact and round_out_to_period(start_minutes, end_minutes)[1] > self.minutes_now:
4078+
return source
4079+
return None
4080+
4081+
def octopus_held_update(self):
4082+
"""
4083+
Remember the half hour a planned Octopus Intelligent dispatch is running in, per car, so it keeps the
4084+
dispatch rate once it has passed (see octopus_held_dispatches()).
4085+
4086+
Called once per cycle after dynamic_load_car_check(), so that a half hour in which the car was seen
4087+
in its dispatch but not charging is not kept - unless it charged earlier in the same half hour,
4088+
which Octopus bills off-peak whole (see dynamic_load_car_strip_from()). Half hours are kept by their
4089+
absolute start, as minutes since midnight_utc mean something else after midnight, and dropped
4090+
OCTOPUS_DISPATCH_HOLD_MINUTES after they end.
4091+
"""
4092+
period_minute = (self.minutes_now // 30) * 30
4093+
period_start = self.midnight_utc + timedelta(minutes=period_minute)
4094+
for car_n in range(self.num_cars):
4095+
held = self.octopus_slots_held.setdefault(car_n, {})
4096+
for start in list(held):
4097+
if start + timedelta(minutes=30 + OCTOPUS_DISPATCH_HOLD_MINUTES) <= self.now_utc:
4098+
del held[start]
4099+
strip_from = self.dynamic_load_car_strip_from(car_n)
4100+
if strip_from is not None and strip_from < period_minute + 30:
4101+
held.pop(period_start, None)
4102+
continue
4103+
source = self.octopus_planned_running(car_n, self.octopus_slots_planned.get(car_n, []))
4104+
if source is not None:
4105+
held[period_start] = source
4106+
4107+
def octopus_held_dispatches(self, car_n, completed, planned):
4108+
"""
4109+
The half hours a planned dispatch of car_n ran in that nothing else covers any more, as dispatches
4110+
that have finished, to go into octopus_slots with the completed ones.
4111+
4112+
A planned dispatch only covers what is still to come: the Octopus API component advances the start
4113+
of a running one to now (trim_started_dispatch()) and any source drops one that has ended, while
4114+
Octopus lists it as completed 15 to 60 minutes later. In between the half hour went back to the
4115+
day rate, and as today's cost is priced from the current rates the day's cost jumped up after each
4116+
half hour of charging and fell back when the completed dispatch arrived (#5520).
4117+
4118+
A half hour is covered from when it was seen running (octopus_held_update()) until a completed
4119+
dispatch overlaps it - which is then the record, including one of no energy, which is treated as
4120+
never having happened - or the hold runs out. It is left out while a planned dispatch still reaches
4121+
it, so it is not counted twice against the daily slot cap. The one in progress ends at now: it has
4122+
finished, so load_octopus_slots() plans no car charging in it, and rate_add_io_slots() prices the
4123+
rest of the half hour as Octopus bills it once a dispatch has started in it (GH#5316).
4124+
"""
4125+
held = self.octopus_slots_held.get(car_n, {})
4126+
if not held:
4127+
return []
4128+
covered = []
4129+
for slot in list(completed) + list(planned):
4130+
start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True)
4131+
if start_minutes != end_minutes:
4132+
covered.append(round_out_to_period(start_minutes, end_minutes))
4133+
4134+
dispatches = []
4135+
for period_start in sorted(held):
4136+
period_end = period_start + timedelta(minutes=30)
4137+
end = min(period_end, self.now_utc)
4138+
if end <= period_start or period_end + timedelta(minutes=OCTOPUS_DISPATCH_HOLD_MINUTES) <= self.now_utc:
4139+
continue
4140+
period_minute = minutes_to_time(period_start, self.midnight_utc)
4141+
if any(start_minutes < period_minute + 30 and end_minutes > period_minute for start_minutes, end_minutes in covered):
4142+
continue
4143+
dispatches.append({"start": period_start.strftime(TIME_FORMAT), "end": end.strftime(TIME_FORMAT), "source": held[period_start], "location": "AT_HOME", "held": True})
4144+
return dispatches
4145+
40594146
def in_iog_fixed_window(self, minute):
40604147
"""
40614148
Whether minute falls in the fixed 23:30-05:30 Intelligent Go window, cheap by tariff rather than by

‎apps/predbat/predbat.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,8 @@ def reset(self):
571571
self.dynamic_load_car_effective = {}
572572
self.dynamic_load_car_stripped = 0
573573
self.octopus_surplus = set()
574+
self.octopus_slots_planned = {}
575+
self.octopus_slots_held = {}
574576
self.battery_capacity_nominal = False
575577
self.battery_scaling_auto = False
576578
self.releases = {}

0 commit comments

Comments
 (0)