Skip to content

Commit e96ecd9

Browse files
Merge pull request #5229 from springfall2008/feat/dynamic-load-car-not-charging
feat(dynamic-load): cancel a car's slots and IOG cheap rate while it is not charging, with an option to distrust Intelligent slots until seen charging
2 parents e122d59 + accbf99 commit e96ecd9

14 files changed

Lines changed: 1236 additions & 114 deletions

‎apps/predbat/config.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,13 @@
915915
"default": False,
916916
"enable": "expert_mode",
917917
},
918+
{
919+
"name": "octopus_intelligent_trust_slots",
920+
"friendly_name": "Trust Intelligent slots before the car is seen charging",
921+
"type": "switch",
922+
"default": True,
923+
"enable": "expert_mode",
924+
},
918925
{
919926
"name": "car_charging_plan_smart",
920927
"friendly_name": "Car Charging Plan Smart",

‎apps/predbat/const.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
TIME_FORMAT_SOLIS = "%Y-%m-%d %H:%M:%S"
2525
PREDICT_STEP = 5
2626

27+
# Dynamic load: how long a car inside one of its charging slots must show "not charging" before its
28+
# slots, and their cheap rate, are cancelled. car_charging_now reports the car directly, so two
29+
# minutes (one charger/integration update after the slot starts) is enough. The load test reads a
30+
# PREDICT_STEP average that the rest of the house also moves, so it needs two full periods.
31+
DYNAMIC_LOAD_CAR_SENSOR_MINUTES = 2
32+
DYNAMIC_LOAD_CAR_LOAD_MINUTES = 10
33+
2734
# Extra cloud divergence applied to the PV10 scenario on top of the computed cloud factor, so the
2835
# downside case diverges harder than the central one
2936
CLOUD_FACTOR_PV10 = 0.2

‎apps/predbat/fetch.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,9 @@ def fetch_sensor_data(self, save=True):
11841184
# Fetch sensor data for cars, e.g. car plan, car energy, car sessions etc.
11851185
self.dispatch_timeline_pending = []
11861186
self.fetch_sensor_data_cars(save=save)
1187+
# Dynamic load: cancel the Octopus Intelligent slots of a car that is in one but not charging -
1188+
# before the rates are built, so a cancelled dispatch never gets its cheap rate
1189+
dynamic_load_car_changed = self.dynamic_load_car_check(save=save)
11871190

11881191
if "rates_export_octopus_url" in self.args:
11891192
# Fixed URL for rate export
@@ -1237,6 +1240,7 @@ def fetch_sensor_data(self, save=True):
12371240
if import_rates:
12381241
self.rate_scan(import_rates, print=False)
12391242
self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates)
1243+
import_rates = self.dynamic_load_car_strip_feed_rates(import_rates)
12401244
import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True)
12411245
self.rate_import_no_io = import_rates.copy()
12421246
for car_n in range(self.num_cars):
@@ -1367,7 +1371,7 @@ def fetch_sensor_data(self, save=True):
13671371
else:
13681372
self.load_inday_adjustment = 1.0
13691373

1370-
force_replan = False
1374+
force_replan = dynamic_load_car_changed
13711375
# Compare on the change-detection signature, not the raw slots, so the per-cycle re-clocking
13721376
# of an in-progress dispatch (start advanced to now, energy scaled to remaining time) does not
13731377
# force a replan every cycle while a slot is active - only genuine slot changes do
@@ -3216,8 +3220,10 @@ def fetch_config_options(self):
32163220
self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging")
32173221
self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged")
32183222
self.octopus_intelligent_consider_full = self.get_arg("octopus_intelligent_consider_full")
3223+
self.octopus_intelligent_trust_slots = self.get_arg("octopus_intelligent_trust_slots")
32193224
self.car_energy_reported_load = self.get_arg("car_energy_reported_load")
32203225
self.get_car_charging_planned()
3226+
self.dynamic_load_car_check_config()
32213227
self.load_inday_adjustment = 1.0
32223228

32233229
self.combine_rate_threshold = self.get_arg("combine_rate_threshold")

‎apps/predbat/octopus.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3601,6 +3601,10 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
36013601
slots_added_set = set()
36023602
plan_interval_minutes = self.plan_interval_minutes
36033603
saved_slots = set() # For logging purposes, track which slots we actually applied as low rate
3604+
# Dynamic load has seen this car in its slot but not charging: none of its dispatches from now
3605+
# on get the cheap rate (see dynamic_load_car_check()). Elapsed minutes keep theirs - they record
3606+
# what the tariff charged, and today's cost is built from them.
3607+
car_cancelled = self.dynamic_load_car_effective.get(car_n, False)
36043608

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

3645+
if car_cancelled and minute >= self.minutes_now:
3646+
continue # Car is not charging, its dispatch is not trusted as cheap
3647+
36413648
if minute in saved_slots:
36423649
continue # Already applied a low rate slot to this minute, skip
36433650
else:
@@ -3688,6 +3695,60 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
36883695

36893696
return rates
36903697

3698+
def dynamic_load_car_strip_feed_rates(self, rates):
3699+
"""
3700+
Remove an Octopus Intelligent discount the rate feed itself delivered for a dispatch of a car that
3701+
dynamic load has cancelled (see dynamic_load_car_check()).
3702+
3703+
The feed marks dispatch minutes as is_intelligent_adjusted (self.io_adjusted) and has already
3704+
lowered their price, so unlike rate_add_io_slots()'s own overlay there is nothing to withhold -
3705+
the price has to be overwritten. Called before the rates are replicated and snapshotted, so
3706+
nothing downstream sees the discount.
3707+
3708+
A minute is set back to rate_max_base, and loses its io_adjusted marker, only when it is from now
3709+
onwards, outside the fixed 23:30-05:30 window (cheap by tariff, not by dispatch), covered by a
3710+
cancelled car's dispatch and not covered by a dispatch of any car still trusted - the rates are
3711+
shared by every car.
3712+
"""
3713+
cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_effective.get(car_n, False)]
3714+
if not cancelled_cars or not self.io_adjusted:
3715+
self.dynamic_load_car_stripped = 0
3716+
return rates
3717+
3718+
# Rounded out to whole 30 minute rate periods: the feed marks and prices whole periods, and
3719+
# rate_add_io_slots() likewise treats any overlap with a period as the whole period
3720+
cancelled_minutes = set()
3721+
trusted_minutes = set()
3722+
for car_n in range(min(self.num_cars, len(self.octopus_slots))):
3723+
covered = cancelled_minutes if car_n in cancelled_cars else trusted_minutes
3724+
for slot in self.octopus_slots[car_n]:
3725+
start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True)
3726+
if start_minutes == end_minutes:
3727+
continue
3728+
start_minutes = (start_minutes // 30) * 30
3729+
end_minutes = ((end_minutes + 29) // 30) * 30
3730+
covered.update(range(max(start_minutes, self.minutes_now), end_minutes))
3731+
3732+
window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"]
3733+
window_start = window["start"][0] * 60 + window["start"][1]
3734+
window_end = window["end"][0] * 60 + window["end"][1]
3735+
stripped = 0
3736+
for minute in sorted(cancelled_minutes - trusted_minutes):
3737+
if not self.io_adjusted.get(minute, False):
3738+
continue
3739+
minute_of_day = minute % (24 * 60)
3740+
if minute_of_day >= window_start or minute_of_day < window_end:
3741+
continue
3742+
rates[minute] = self.rate_max_base
3743+
del self.io_adjusted[minute]
3744+
stripped += 1
3745+
# The feed is re-read every cycle, so the same minutes are stripped again each time - log only
3746+
# when that changes, not every 5 minutes for the length of a cancellation
3747+
if stripped and stripped != self.dynamic_load_car_stripped:
3748+
self.log("Dynamic load: removed the Intelligent dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars))
3749+
self.dynamic_load_car_stripped = stripped
3750+
return rates
3751+
36913752
def fetch_octopus_rates(self, entity_id, adjust_key=None):
36923753
"""
36933754
Fetch the Octopus rates from the sensor

‎apps/predbat/output.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ def publish_car_plan(self):
193193
show["kwh"] = kwh
194194
show["average"] = average
195195
show["cost"] = cost
196+
if window.get("kwh_cancelled"):
197+
show["kwh_cancelled"] = dp2(window["kwh_cancelled"])
196198
total_cost += cost
197199
total_kwh += kwh
198200
plan.append(show)
@@ -1602,13 +1604,23 @@ def import_rate_color(rate):
16021604

16031605
# Car charging?
16041606
car_rate = None
1607+
car_charging_cancelled = 0.0
16051608
if self.num_cars > 0:
16061609
car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end)
16071610
car_total += car_charging_kwh
1611+
# A slot dynamic load cancelled (the car is not charging) is not planned for, but is still
1612+
# shown with a "?" so the car's own plan stays visible - alongside another car's live
1613+
# charging in the same step, not only when no car is charging
1614+
car_charging_cancelled = self.car_charge_slot_kwh_cancelled(minute_start, minute_end)
16081615
if car_charging_kwh > 0.0:
16091616
car_charging_str = str(car_charging_kwh)
1617+
if car_charging_cancelled > 0.0:
1618+
car_charging_str += " +" + str(car_charging_cancelled) + "?"
16101619
car_color = "FFFF00"
16111620
car_rate = self.car_charge_slot_rate(minute_start, minute_end)
1621+
elif car_charging_cancelled > 0.0:
1622+
car_charging_str = str(car_charging_cancelled) + "?"
1623+
car_color = "#FFFFCC"
16121624
else:
16131625
car_charging_str = "&#9866;"
16141626
car_color = "#FFFFFF"
@@ -1810,6 +1822,8 @@ def import_rate_color(rate):
18101822
json_row["extra_color"] = extra_color
18111823
if self.num_cars > 0:
18121824
json_row["car_charging"] = car_charging_kwh
1825+
if car_charging_cancelled > 0.0:
1826+
json_row["car_charging_cancelled"] = car_charging_cancelled
18131827
json_row["car_color"] = car_color
18141828
json_row["car_rate"] = car_rate
18151829
json_row["car_rate_color"] = car_rate_color if rate_split else None

0 commit comments

Comments
 (0)