|
19 | 19 | import re |
20 | 20 | from datetime import datetime, timedelta, timezone |
21 | 21 | 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 |
23 | 23 | 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 |
24 | 24 | from component_base import ComponentBase |
25 | 25 | from mock_base import MockBase as SharedMockBase |
@@ -3258,8 +3258,11 @@ def octopus_slots_signature(self, octopus_slots): |
3258 | 3258 | and force a needless replan each time. For a currently active slot the signature keeps only |
3259 | 3259 | the stable fields (window end, source, location); genuine changes - new/removed slots, a |
3260 | 3260 | 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. |
3261 | 3264 | """ |
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) |
3263 | 3266 |
|
3264 | 3267 | def load_free_slot(self, octopus_free_slots, rate_dict, export=False, rate_replicate=None): |
3265 | 3268 | """ |
@@ -4056,6 +4059,90 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): |
4056 | 4059 |
|
4057 | 4060 | return rates |
4058 | 4061 |
|
| 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 | + |
4059 | 4146 | def in_iog_fixed_window(self, minute): |
4060 | 4147 | """ |
4061 | 4148 | Whether minute falls in the fixed 23:30-05:30 Intelligent Go window, cheap by tariff rather than by |
|
0 commit comments