Skip to content
7 changes: 7 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,13 @@
"default": False,
"enable": "expert_mode",
},
{
"name": "octopus_intelligent_trust_slots",
"friendly_name": "Trust Intelligent slots before the car is seen charging",
"type": "switch",
"default": True,
"enable": "expert_mode",
},
{
"name": "car_charging_plan_smart",
"friendly_name": "Car Charging Plan Smart",
Expand Down
7 changes: 7 additions & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
TIME_FORMAT_SOLIS = "%Y-%m-%d %H:%M:%S"
PREDICT_STEP = 5

# Dynamic load: how long a car inside one of its charging slots must show "not charging" before its
# slots, and their cheap rate, are cancelled. car_charging_now reports the car directly, so two
# minutes (one charger/integration update after the slot starts) is enough. The load test reads a
# PREDICT_STEP average that the rest of the house also moves, so it needs two full periods.
DYNAMIC_LOAD_CAR_SENSOR_MINUTES = 2
DYNAMIC_LOAD_CAR_LOAD_MINUTES = 10

# Extra cloud divergence applied to the PV10 scenario on top of the computed cloud factor, so the
# downside case diverges harder than the central one
CLOUD_FACTOR_PV10 = 0.2
Expand Down
10 changes: 9 additions & 1 deletion apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,9 @@ def fetch_sensor_data(self, save=True):
# Fetch sensor data for cars, e.g. car plan, car energy, car sessions etc.
self.dispatch_timeline_pending = []
self.fetch_sensor_data_cars(save=save)
# Dynamic load: cancel the slots of a car that is in one but not charging - before the rates are
# built, so a cancelled Intelligent dispatch never gets its cheap rate
dynamic_load_car_changed = self.dynamic_load_car_check(save=save)

if "rates_export_octopus_url" in self.args:
# Fixed URL for rate export
Expand Down Expand Up @@ -1237,6 +1240,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)
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()
for car_n in range(self.num_cars):
Expand Down Expand Up @@ -1310,6 +1314,8 @@ def fetch_sensor_data(self, save=True):

# Work out car plan?
self.fetch_sensor_data_car_planning()
# Dynamic load for the cars Predbat plans itself, whose slots only exist now
dynamic_load_car_changed |= self.dynamic_load_car_check(save=save, late=True)
# Publish the car plan
self.publish_car_plan()

Expand Down Expand Up @@ -1367,7 +1373,7 @@ def fetch_sensor_data(self, save=True):
else:
self.load_inday_adjustment = 1.0

force_replan = False
force_replan = dynamic_load_car_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 Expand Up @@ -3186,8 +3192,10 @@ def fetch_config_options(self):
self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging")
self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged")
self.octopus_intelligent_consider_full = self.get_arg("octopus_intelligent_consider_full")
self.octopus_intelligent_trust_slots = self.get_arg("octopus_intelligent_trust_slots")
self.car_energy_reported_load = self.get_arg("car_energy_reported_load")
self.get_car_charging_planned()
self.dynamic_load_car_check_config()
self.load_inday_adjustment = 1.0

self.combine_rate_threshold = self.get_arg("combine_rate_threshold")
Expand Down
57 changes: 57 additions & 0 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -3601,6 +3601,10 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
slots_added_set = set()
plan_interval_minutes = self.plan_interval_minutes
saved_slots = set() # For logging purposes, track which slots we actually applied as low rate
# Dynamic load has seen this car in its slot but not charging: none of its dispatches from now
# on get the cheap rate (see dynamic_load_car_check()). Elapsed minutes keep theirs - they record
# what the tariff charged, and today's cost is built from them.
car_cancelled = self.dynamic_load_car_cancelled.get(car_n, False)

if octopus_slots:
# Add in IO slots
Expand Down Expand Up @@ -3638,6 +3642,9 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
# is behaviour-preserving there and simply avoids the staleness this PR adds.)
assumed_price = rates.get(start_minutes, self.rate_min)

if car_cancelled and minute >= self.minutes_now:
continue # Car is not charging, its dispatch is not trusted as cheap

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

return rates

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
dynamic load has cancelled (see dynamic_load_car_check()).

The feed marks dispatch minutes as is_intelligent_adjusted (self.io_adjusted) and has already
lowered their price, so unlike rate_add_io_slots()'s own overlay there is nothing to withhold -
the price has to be overwritten. Called before the rates are replicated and snapshotted, so
nothing downstream sees the discount.

A minute is set back to rate_max_base, and loses its io_adjusted marker, only when it is from now
onwards, outside the fixed 23:30-05:30 window (cheap by tariff, not by dispatch), covered by a
cancelled car's dispatch and not covered by a dispatch of any car still trusted - the rates are
shared by every car.
"""
cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_cancelled.get(car_n, False)]
if not cancelled_cars or not self.io_adjusted:
return rates

# Rounded out to whole 30 minute rate periods: the feed marks and prices whole periods, and
# rate_add_io_slots() likewise treats any overlap with a period as the whole period
cancelled_minutes = set()
trusted_minutes = set()
for car_n in range(min(self.num_cars, len(self.octopus_slots))):
covered = cancelled_minutes if car_n in cancelled_cars else trusted_minutes
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 = (start_minutes // 30) * 30
end_minutes = ((end_minutes + 29) // 30) * 30
covered.update(range(max(start_minutes, self.minutes_now), end_minutes))
Comment thread
springfall2008 marked this conversation as resolved.

window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"]
Comment thread
springfall2008 marked this conversation as resolved.
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):
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:
continue
rates[minute] = self.rate_max_base
Comment thread
springfall2008 marked this conversation as resolved.
del self.io_adjusted[minute]
stripped += 1
if stripped:
self.log("Dynamic load: removed the Intelligent dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars))
Comment thread
springfall2008 marked this conversation as resolved.
return rates

def fetch_octopus_rates(self, entity_id, adjust_key=None):
"""
Fetch the Octopus rates from the sensor
Expand Down
14 changes: 12 additions & 2 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,7 @@ def import_rate_color(rate):

# Car charging?
car_rate = None
car_charging_cancelled = 0.0
if self.num_cars > 0:
car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end)
car_total += car_charging_kwh
Expand All @@ -1610,8 +1611,15 @@ def import_rate_color(rate):
car_color = "FFFF00"
car_rate = self.car_charge_slot_rate(minute_start, minute_end)
else:
car_charging_str = "&#9866;"
car_color = "#FFFFFF"
# A slot dynamic load cancelled (the car is not charging) is not planned for, but is
# still shown with a "?" so the car's own plan stays visible
car_charging_cancelled = self.car_charge_slot_kwh_cancelled(minute_start, minute_end)
Comment thread
springfall2008 marked this conversation as resolved.
Outdated
if car_charging_cancelled > 0.0:
car_charging_str = str(car_charging_cancelled) + "?"
car_color = "#FFFFCC"
else:
car_charging_str = "&#9866;"
car_color = "#FFFFFF"

# The car's own rate can diverge from the general household rate once its IOG dispatch
# cap is used up for the day - the car falls back to the peak rate while the house keeps
Expand Down Expand Up @@ -1810,6 +1818,8 @@ def import_rate_color(rate):
json_row["extra_color"] = extra_color
if self.num_cars > 0:
json_row["car_charging"] = car_charging_kwh
if car_charging_cancelled > 0.0:
json_row["car_charging_cancelled"] = car_charging_cancelled
json_row["car_color"] = car_color
json_row["car_rate"] = car_rate
json_row["car_rate_color"] = car_rate_color if rate_split else None
Expand Down
Loading
Loading