Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 8 additions & 1 deletion apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,7 @@ def fetch_sensor_data(self, save=True):
# Dynamic load: cancel the Octopus Intelligent slots of a car that is in one but not charging -
# before the rates are built, so a cancelled dispatch never gets its cheap rate
dynamic_load_car_changed = self.dynamic_load_car_check(save=save)
octopus_surplus_changed = False

if "rates_export_octopus_url" in self.args:
# Fixed URL for rate export
Expand Down Expand Up @@ -1241,6 +1242,12 @@ 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)
previous_surplus = self.octopus_surplus
self.octopus_surplus = self.octopus_surplus_minutes()
# The rates can move without the slots moving (the car's SoC changes how many half hours it needs)
if save and self.octopus_surplus_changed(previous_surplus):
self.log("Octopus Intelligent: dispatch minutes no car needs changed to {}".format(len(self.octopus_surplus)))
octopus_surplus_changed = True
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 Expand Up @@ -1372,7 +1379,7 @@ def fetch_sensor_data(self, save=True):
else:
self.load_inday_adjustment = 1.0

force_replan = dynamic_load_car_changed
force_replan = dynamic_load_car_changed or octopus_surplus_changed
# Compare on the change-detection signature, not the raw slots, so the per-cycle re-clocking
# of an in-progress dispatch (start advanced to now, energy scaled to remaining time) does not
# force a replan every cycle while a slot is active - only genuine slot changes do
Expand Down
94 changes: 86 additions & 8 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -3623,6 +3623,14 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_
# Emission is future-only, so dispatch_billed_off_peak()'s completed-dispatch exemption
# (#4946) can never apply here - a plain location test is equivalent and is kept as-is.
if (end_minutes > start_minutes) and (end_minutes > self.minutes_now) and (not location or location == "AT_HOME"):
if octopus_intelligent_consider_full and start_minutes < self.minutes_now:
# A dispatch in progress: only its remaining span and energy are still to come, so cap the
# car's need from now, not from the dispatch's original start. The Octopus API component
# already trims a running dispatch (trim_started_dispatch()); octopus_intelligent_slot doesn't.
# Done after the slot-cap chunking above, which counts the dispatch from its real start.
kwh = kwh * (end_minutes - self.minutes_now) / (end_minutes - start_minutes)
kwh_original = kwh
start_minutes = self.minutes_now
kwh_expected = kwh * self.car_charging_loss
if octopus_intelligent_consider_full:
kwh_expected = max(min(kwh_expected, limit - car_soc), 0)
Expand Down Expand Up @@ -3729,6 +3737,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 +3799,74 @@ 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_from(self):
"""
The first minute the car-need gate can withhold: the half hour after the current one, as the car may
already have charged in the current one, making the whole period cheap.
"""
return (self.minutes_now // 30 + 1) * 30

def octopus_surplus_changed(self, previous):
"""
Whether this cycle's surplus differs from the previous cycle's, so the plan must be recomputed. Only the
part of the previous surplus still ahead is compared, so time passing alone isn't a change.
"""
start = self.octopus_surplus_from()
return {minute for minute in previous if minute >= start} != self.octopus_surplus

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. A dispatch in
progress is capped from now (see load_octopus_slots()), so this holds mid-dispatch too;
- 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.octopus_surplus_from()
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
start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes)
dispatch_minutes.update(range(max(start_minutes, next_period), end_minutes))
if 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 +3884,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 +3906,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