Skip to content

Commit 7f5a751

Browse files
CIclaude
andcommitted
fix(dynamic-load): only cancel Octopus Intelligent slots, never Predbat-led ones
Predbat-led charging is a different mechanism: Predbat itself decides when the car charges, and its car_charging_slot sensor drives the charger. A second check here, for cars Predbat plans itself, ran before publish_car_plan(), so a cancelled slot switched that sensor off. The charger automation then stopped the charge the check was waiting to see, and every later slot in the run stayed cancelled. Detection now applies only to slots load_octopus_slots() built ("octopus": True). The second check is gone, and a single call before the rates decides every car, including one whose slots have gone this cycle, so a stale cancellation cannot outlive them. This changes behaviour from main: the old dynamic_load() also zeroed Predbat-planned slots on low load. It no longer does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 872fd75 commit 7f5a751

6 files changed

Lines changed: 63 additions & 57 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,8 +1184,8 @@ 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 slots of a car that is in one but not charging - before the rates are
1188-
# built, so a cancelled Intelligent dispatch never gets its cheap rate
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
11891189
dynamic_load_car_changed = self.dynamic_load_car_check(save=save)
11901190

11911191
if "rates_export_octopus_url" in self.args:
@@ -1314,8 +1314,6 @@ def fetch_sensor_data(self, save=True):
13141314

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

‎apps/predbat/plan.py‎

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -365,27 +365,36 @@ def dynamic_load_car_in_slot(self, car_n, minute):
365365
return True
366366
return False
367367

368+
def dynamic_load_car_is_octopus(self, car_n):
369+
"""
370+
Whether car_n's slots were built by Octopus Intelligent charging (load_octopus_slots() marks them).
371+
372+
Only those are ever cancelled. Predbat-led charging is a different mechanism: Predbat itself
373+
decides when the car charges and its car_charging_slot sensor drives the charger, so cancelling
374+
a slot would stop the very charge the check is waiting to see.
375+
"""
376+
slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else []
377+
return any(slot.get("octopus", False) for slot in slots)
378+
368379
def dynamic_load_car_trusted_by_default(self, car_n):
369380
"""
370381
Whether car_n's slots are trusted while there is no evidence about them - outside a slot, or
371382
before the car has been seen either way inside one.
372383
373-
Everything is trusted unless octopus_intelligent_trust_slots is Off, which reverses that for a car
374-
carrying Octopus Intelligent dispatch slots: they are assumed not to happen until the car is seen
375-
charging in one. Slots Predbat plans itself are always trusted by default.
384+
Trusted unless octopus_intelligent_trust_slots is Off, which reverses that for an Octopus
385+
Intelligent car: its slots are assumed not to happen until the car is seen charging in one.
376386
"""
377-
if self.octopus_intelligent_trust_slots:
378-
return True
379-
slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else []
380-
return not any(slot.get("octopus", False) for slot in slots)
387+
return self.octopus_intelligent_trust_slots or not self.dynamic_load_car_is_octopus(car_n)
381388

382389
def dynamic_load_car_active(self, car_n):
383390
"""
384-
Whether car-charging detection applies to car_n: dynamic load is on, or the car's Octopus
385-
Intelligent slots are untrusted by default - which means nothing without the detection that
391+
Whether car-charging detection applies to car_n: only an Octopus Intelligent car, and only with
392+
dynamic load on or its slots untrusted by default - which means nothing without the detection that
386393
trusts them again, so it works whether dynamic load is on or not.
387394
"""
388-
return self.metric_dynamic_load_adjust or not self.dynamic_load_car_trusted_by_default(car_n)
395+
if not self.dynamic_load_car_is_octopus(car_n):
396+
return False
397+
return self.metric_dynamic_load_adjust or not self.octopus_intelligent_trust_slots
389398

390399
def dynamic_load_car_target(self, car_n, minute, now, record=True):
391400
"""
@@ -455,16 +464,16 @@ def dynamic_load_car_check_config(self):
455464
self.dynamic_load_car_warned = cars
456465
return cars
457466

458-
def dynamic_load_car_check(self, save=True, late=False):
467+
def dynamic_load_car_check(self, save=True):
459468
"""
460-
Cancel the slots of a car that is inside one of its charging slots but not charging - or, with
461-
octopus_intelligent_trust_slots Off, of an Octopus Intelligent car until it is seen charging.
469+
Cancel the slots of an Octopus Intelligent car that is inside one of its charging slots but not
470+
charging - or, with octopus_intelligent_trust_slots Off, until it is seen charging in one. Slots
471+
Predbat plans itself are never cancelled (see dynamic_load_car_is_octopus()).
462472
463-
Runs in fetch_sensor_data() before the rates are built, so that a cancelled Octopus Intelligent
464-
dispatch never gets its cheap rate (rate_add_io_slots() and dynamic_load_car_strip_feed_rates()
465-
consult dynamic_load_car_cancelled). Cars that Predbat plans itself only get their slots after the
466-
rates, from the low-rate scan, so a second call with late=True decides those once they exist -
467-
they have no dispatch discount to withhold.
473+
Runs in fetch_sensor_data() after the Octopus slots are built and before the rates, so that a
474+
cancelled dispatch never gets its cheap rate (rate_add_io_slots() and
475+
dynamic_load_car_strip_feed_rates() consult dynamic_load_car_cancelled). Every car is decided,
476+
including one whose slots have gone, so a stale cancellation cannot outlive them.
468477
469478
A cancelled car has every slot ending after now set to 0 kWh, which releases "Hold for car" and the
470479
predicted car load; the kWh it had is kept in the slot's kwh_cancelled so the plan can still show
@@ -477,16 +486,7 @@ def dynamic_load_car_check(self, save=True, late=False):
477486
Returns True when a car's cancellation changed, so the plan is recomputed.
478487
"""
479488
changed = False
480-
if not late:
481-
self.dynamic_load_car_decided = set()
482-
483489
for car_n in range(self.num_cars):
484-
if car_n in self.dynamic_load_car_decided:
485-
continue
486-
if not late and not (car_n < len(self.car_charging_slots) and self.car_charging_slots[car_n]):
487-
continue
488-
self.dynamic_load_car_decided.add(car_n)
489-
490490
cancelled = self.dynamic_load_car_target(car_n, self.minutes_now, self.now_utc_real, record=save)
491491
if save:
492492
was_cancelled = self.dynamic_load_car_cancelled.get(car_n, False)

‎apps/predbat/predbat.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,6 @@ def reset(self):
547547
self.load_last_status = "baseline"
548548
self.dynamic_load_car_since = {}
549549
self.dynamic_load_car_cancelled = {}
550-
self.dynamic_load_car_decided = set()
551550
self.dynamic_load_car_warned = []
552551
self.dynamic_load_car_warned_iog_off = False
553552
self.dynamic_load_car_sensors = {}

‎apps/predbat/tests/test_dynamic_load_car.py‎

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
re-derived every cycle and ends as soon as the car leaves its slots or starts charging again.
1818
1919
Evidence is car_charging_now when it is a real entity (2 minute grace), otherwise the low-load test
20-
when the car is inside the CT clamp (10 minute grace), otherwise nothing.
20+
when the car is inside the CT clamp (10 minute grace), otherwise nothing. Only slots Octopus
21+
Intelligent charging built are affected - Predbat-led charging is never cancelled.
2122
"""
2223
import copy
2324
from datetime import timedelta
@@ -43,7 +44,6 @@
4344
"update_pending",
4445
"dynamic_load_car_since",
4546
"dynamic_load_car_cancelled",
46-
"dynamic_load_car_decided",
4747
"dynamic_load_car_sensors",
4848
"dynamic_load_car_warned",
4949
"dynamic_load_car_warned_iog_off",
@@ -103,7 +103,6 @@ def _reset(my_predbat):
103103
my_predbat.load_last_period = 3.0
104104
my_predbat.dynamic_load_car_since = {}
105105
my_predbat.dynamic_load_car_cancelled = {}
106-
my_predbat.dynamic_load_car_decided = set()
107106
my_predbat.update_pending = False
108107
my_predbat.octopus_intelligent_trust_slots = True
109108

@@ -218,7 +217,7 @@ def _run(my_predbat):
218217
_sensor(my_predbat, None)
219218
my_predbat.car_energy_reported_load = True
220219
my_predbat.load_last_period = 0.5
221-
early_slots = [{"start": 0, "end": 30, "kwh": 3.5}]
220+
early_slots = [{"start": 0, "end": 30, "kwh": 3.5, "octopus": True}]
222221
_cycle(my_predbat, 0, slots=early_slots)
223222
_cycle(my_predbat, 5, slots=early_slots)
224223
failed |= _check("t6b no clock before 00:05", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0)))
@@ -269,22 +268,31 @@ def _run(my_predbat):
269268
failed |= _check("t10 not applied outside the slot", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat)))
270269
failed |= _check("t10 saved state untouched", my_predbat.dynamic_load_car_cancelled.get(0, False), "cancelled {}".format(my_predbat.dynamic_load_car_cancelled))
271270

272-
# Cars planned by Predbat itself get their slots after the rates are built: the late call
273-
# decides the cars the early call could not
274-
print("Test 11: the late call decides cars that had no slots at the early call")
271+
# Predbat-led charging is a different mechanism: Predbat itself decides when the car charges, and
272+
# its slot sensor drives the charger - cancelling it would stop the very charge it is waiting to
273+
# see. Only slots Octopus Intelligent charging built are ever cancelled.
274+
print("Test 11: a car Predbat plans itself is never cancelled, by the sensor or the load")
275275
_reset(my_predbat)
276276
_sensor(my_predbat, "off")
277-
_at(my_predbat, 840)
278-
my_predbat.car_charging_slots = [[]]
279-
my_predbat.dynamic_load_car_check()
280-
my_predbat.car_charging_slots = [_slots()]
281-
my_predbat.dynamic_load_car_check(late=True)
282-
_at(my_predbat, 845)
283-
my_predbat.car_charging_slots = [[]]
284-
my_predbat.dynamic_load_car_check()
285-
my_predbat.car_charging_slots = [_slots()]
286-
changed = my_predbat.dynamic_load_car_check(late=True)
287-
failed |= _check("t11 late cancel", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat)))
277+
own = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 3.5}]
278+
_cycle(my_predbat, 840, slots=own)
279+
changed = _cycle(my_predbat, 850, slots=own)
280+
failed |= _check("t11 sensor", (not changed) and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat)))
281+
_sensor(my_predbat, None)
282+
my_predbat.car_energy_reported_load = True
283+
my_predbat.load_last_period = 0.5
284+
_cycle(my_predbat, 840, slots=own)
285+
_cycle(my_predbat, 855, slots=own)
286+
failed |= _check("t11 load", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat)))
287+
288+
# Every car is decided in the one call, including one whose slots have gone this cycle
289+
print("Test 11a: a cancelled car with no slots this cycle is cleared")
290+
_reset(my_predbat)
291+
_sensor(my_predbat, "off")
292+
_cycle(my_predbat, 840)
293+
_cycle(my_predbat, 845)
294+
changed = _cycle(my_predbat, 850, slots=[])
295+
failed |= _check("t11a cleared", changed and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} cancelled {}".format(changed, my_predbat.dynamic_load_car_cancelled))
288296

289297
# car_charging_slots shorter than num_cars must not raise out of the fetch (#5229 review)
290298
print("Test 11b: fewer car slot lists than cars")
@@ -295,10 +303,9 @@ def _run(my_predbat):
295303
my_predbat.dynamic_load_car_cancelled = {1: True}
296304
my_predbat.car_charging_slots = [_slots()]
297305
try:
298-
my_predbat.dynamic_load_car_check(save=False, late=True)
306+
my_predbat.dynamic_load_car_check(save=False)
299307
my_predbat.dynamic_load_car_cancelled = {1: True}
300308
my_predbat.dynamic_load_car_check()
301-
my_predbat.dynamic_load_car_check(late=True)
302309
except IndexError as exc:
303310
failed |= _check("t11b no IndexError", False, str(exc))
304311

@@ -462,7 +469,7 @@ def _run_poll(my_predbat):
462469
print("Test 18: the poll agrees with the check about a slot ending between 5 minute steps")
463470
_reset(my_predbat)
464471
_sensor(my_predbat, "off")
465-
odd_slots = [{"start": 840, "end": 872, "kwh": 3.5}]
472+
odd_slots = [{"start": 840, "end": 872, "kwh": 3.5, "octopus": True}]
466473
_cycle(my_predbat, 840, slots=odd_slots)
467474
_cycle(my_predbat, 845, slots=odd_slots)
468475
my_predbat.update_pending = False
@@ -657,7 +664,7 @@ def _run_kwh_cancelled(my_predbat):
657664
print("Test 26: a cancelled slot records its kWh in kwh_cancelled")
658665
_reset(my_predbat)
659666
_sensor(my_predbat, "off")
660-
slots = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 0.0}]
667+
slots = [{"start": 840, "end": 870, "kwh": 3.5, "octopus": True}, {"start": 900, "end": 930, "kwh": 0.0, "octopus": True}]
661668
_cycle(my_predbat, 840, slots=slots)
662669
_cycle(my_predbat, 845, slots=slots)
663670
failed |= _check("t26 recorded", _kwh(my_predbat) == [0, 0] and _cancelled(my_predbat) == [3.5, None], "kwh {} cancelled {}".format(_kwh(my_predbat), _cancelled(my_predbat)))

‎docs/car-charging.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,8 @@ e.g. you are using Intelligent Octopus or you use the car slots in Predbat to co
627627
- **input_number.predbat_car_charging_loss** gives the percentage amount of energy lost when charging the car (load in the home vs energy added to the battery).
628628
A good setting is 0.08 which is 8%.
629629

630-
- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - When On, Predbat cancels a car's charging slots, including the cheap rate of an Octopus Intelligent dispatch, while the car is inside a slot but not actually charging, and restores them as soon as it starts charging again or the slot ends.
630+
- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - When On, Predbat cancels a car's Octopus Intelligent charging slots, including the dispatch's cheap rate, while the car is inside a slot but not actually charging, and restores them as soon as it starts charging again or the slot ends.
631+
Slots Predbat plans itself (Predbat-led charging) are never cancelled.
631632
This releases the battery hold set by **switch.predbat_car_charging_from_battery**, and stops the house battery being planned around a dispatch Octopus may bill at the full rate.
632633
It uses **car_charging_now** when that is a real sensor (reacting within about 2 minutes), otherwise the house load if the car is inside the CT clamp (about 10 minutes). If turned Off then Predbat won't export during times the car is planned to charge even if the car is not charging.
633634

‎docs/customisation.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,9 @@ This setting will not impact the real calculated costs and is only used for plan
189189
**switch.predbat_metric_dynamic_load_adjust** (default False) is a toggle that when enabled allows Predbat to take into account your energy consumption within the last 5 minutes.
190190
If the load is above what your battery can deliver the plan is updated to predict this load will continue during the current slot, thus preventing forced export in the plan.
191191
If the load remains high for two checks in a row, this prediction is extended into the following slot too, so the plan stays up to date across the slot boundary.
192-
If the current time is inside one of your car's charging slots but the car is not charging, Predbat cancels that slot and every later one, so the battery is no longer held for the car and the plan can include potential export again.
193-
For an Octopus Intelligent dispatch the slot's cheap rate is withdrawn too, as Octopus may bill a dispatch the car does not use at the full rate.
192+
If your car is charged by Octopus Intelligent and the current time is inside one of its charging slots but the car is not charging, Predbat cancels that slot and every later one, so the battery is no longer held for the car and the plan can include potential export again.
193+
The dispatch's cheap rate is withdrawn too, as Octopus may bill a dispatch the car does not use at the full rate.
194+
Slots Predbat plans itself (Predbat-led charging) are never cancelled, as Predbat is the one deciding when the car charges.
194195
Whether the car is charging is taken from **car_charging_now** when it is set to a real Home Assistant entity, after 2 minutes of it reporting not charging.
195196
Without that sensor, if your car is inside the CT clamp (**switch.predbat_car_energy_reported_load** On), the house load is used instead, after 10 minutes of load too low for a car to be charging.
196197
The slots come back as soon as the car starts charging again or the current slot ends.

0 commit comments

Comments
 (0)