Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1241,6 +1241,7 @@ def fetch_sensor_data(self, save=True):
if import_rates:
self.rate_scan(import_rates, print=False)
self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates)
self.octopus_surplus = self.octopus_surplus_minutes()
import_rates = self.dynamic_load_car_strip_feed_rates(import_rates)
import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True)
self.rate_import_no_io = import_rates.copy()
Expand Down
74 changes: 66 additions & 8 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -3729,6 +3729,9 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
if strip_from is not None and minute >= strip_from:
continue # Car is not charging, its dispatch is not trusted as cheap

if minute in self.octopus_surplus:
continue # No car needs this dispatch minute, see octopus_surplus_minutes()

if minute in saved_slots:
continue # Already applied a low rate slot to this minute, skip
else:
Expand Down Expand Up @@ -3788,6 +3791,62 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):

return rates

def in_iog_fixed_window(self, minute):
"""
Whether minute falls in the fixed 23:30-05:30 Intelligent Go window, cheap by tariff rather than by
dispatch, so no dispatch decision ever changes its rate.
"""
window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"]
window_start = window["start"][0] * 60 + window["start"][1]
window_end = window["end"][0] * 60 + window["end"][1]
minute_of_day = minute % (24 * 60)
return minute_of_day >= window_start or minute_of_day < window_end

def octopus_surplus_minutes(self):
"""
Future Octopus Intelligent dispatch minutes no car can use, when octopus_intelligent_consider_full is on.

load_octopus_slots() already ends each car's slots where the car reaches its limit, so a dispatch
minute no car still needs is one no car will draw in, and Octopus only bills a dispatch cheap when
the car charges in it (#4482). Its cheap rate is withheld from the house battery's plan the same way
a cancelled car's is (see dynamic_load_car_strip_feed_rates()).

Decided per car, then shared, as the rates are shared: a minute stays cheap if any car can still use
it before the point that car's own cheap rate ends (dynamic_load_car_strip_from()). A car can use
- its needed slots - with kWh left, or kWh kept in kwh_cancelled for a cancelled car;
- the whole of a dispatch in progress now. Its capped slots can't be relied on mid-dispatch (one from
octopus_intelligent_slot isn't trimmed to now, so its finish is worked out from its original start),
and dynamic load cancels it in real time once the car stops charging;
- every dispatch, for a car not modelled from its Octopus slots this cycle (e.g. ignored while unplugged).

Rounded out to whole 30 minute rate periods, from the half hour after the current one (the car may have
charged earlier in it, making the whole period cheap) and outside the fixed window.
"""
if not self.octopus_intelligent_consider_full:
return set()
dispatch_minutes = set()
usable_minutes = set()
next_period = (self.minutes_now // 30 + 1) * 30
for car_n in range(min(self.num_cars, len(self.octopus_slots))):
car_slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else []
modelled = any(slot.get("octopus", False) for slot in car_slots)
strip_from = self.dynamic_load_car_strip_from(car_n)
usable_until = strip_from if strip_from is not None else float("inf")
for slot in self.octopus_slots[car_n]:
start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True)
if start_minutes == end_minutes:
continue
in_progress = start_minutes <= self.minutes_now < end_minutes
start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes)
dispatch_minutes.update(range(max(start_minutes, next_period), end_minutes))
if in_progress or not modelled:
usable_minutes.update(range(start_minutes, min(end_minutes, usable_until)))
for slot in car_slots:
if slot.get("kwh", 0) > 0 or slot.get("kwh_cancelled", 0) > 0:
Comment thread
chalfontchubby marked this conversation as resolved.
start_minutes, end_minutes = round_out_to_period(slot["start"], slot["end"])
usable_minutes.update(range(start_minutes, min(end_minutes, usable_until)))
return {minute for minute in dispatch_minutes - usable_minutes if not self.in_iog_fixed_window(minute)}

def dynamic_load_car_strip_feed_rates(self, rates):
"""
Remove an Octopus Intelligent discount the rate feed itself delivered for a dispatch of a car that
Expand All @@ -3805,7 +3864,7 @@ def dynamic_load_car_strip_feed_rates(self, rates):
(see dynamic_load_car_strip_from()), whatever another cancelled car's dispatch covers.
"""
cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_effective.get(car_n, False)]
if not cancelled_cars or not self.io_adjusted:
if (not cancelled_cars and not self.octopus_surplus) or not self.io_adjusted:
self.dynamic_load_car_stripped = 0
return rates

Expand All @@ -3827,23 +3886,22 @@ def dynamic_load_car_strip_feed_rates(self, rates):
trusted_minutes.update(range(start_minutes, min(end_minutes, strip_from)))
cancelled_minutes.update(range(max(start_minutes, strip_from), end_minutes))

window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"]
window_start = window["start"][0] * 60 + window["start"][1]
window_end = window["end"][0] * 60 + window["end"][1]
stripped = 0
for minute in sorted(cancelled_minutes - trusted_minutes):
stripped_surplus = 0
for minute in sorted((cancelled_minutes - trusted_minutes) | self.octopus_surplus):
if not self.io_adjusted.get(minute, False):
continue
minute_of_day = minute % (24 * 60)
if minute_of_day >= window_start or minute_of_day < window_end:
if self.in_iog_fixed_window(minute):
continue
rates[minute] = self.rate_max_base
del self.io_adjusted[minute]
stripped += 1
if minute in self.octopus_surplus:
stripped_surplus += 1
# The feed is re-read every cycle, so the same minutes are stripped again each time - log only
# when that changes, not every 5 minutes for the length of a cancellation
if stripped and stripped != self.dynamic_load_car_stripped:
self.log("Octopus Intelligent: removed the dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars))
self.log("Octopus Intelligent: removed the dispatch rate from {} minutes - {} of cars {} which are not charging, {} no car needs".format(stripped, stripped - stripped_surplus, cancelled_cars, stripped_surplus))
self.dynamic_load_car_stripped = stripped
return rates

Expand Down
1 change: 1 addition & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ def reset(self):
self.dynamic_load_car_sensors = {}
self.dynamic_load_car_effective = {}
self.dynamic_load_car_stripped = 0
self.octopus_surplus = set()
self.battery_capacity_nominal = False
self.battery_scaling_auto = False
self.releases = {}
Expand Down
Loading
Loading