Skip to content

Commit a862f32

Browse files
CIclaude
andcommitted
fix(ohme): take completed Intelligent dispatches from Octopus, planned slots from Ohme (#5413)
Ohme only reports the slots of the charge session in progress, so unplugging the car emptied the car slots and the off-peak rate already charged at was re-priced at day rate in the plan history and today's cost. Add octopus_intelligent_planned_slot: a per-car sensor the planned dispatches are read from, leaving octopus_intelligent_slot as the record of the completed ones. On Octopus Intelligent the Ohme component now sets only that, the ready time and the charge limit when octopus_intelligent_slot is already a sensor of Octopus's (wired by the Octopus component or set in apps.yaml), and falls back to supplying everything when there is no such sensor. The car_slot_owner claim narrows to the ready time and charge limit, so the Octopus component keeps its dispatch sensor wired under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 82bee43 commit a862f32

11 files changed

Lines changed: 509 additions & 39 deletions

‎apps/predbat/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2839,6 +2839,7 @@
28392839
"enphase_automatic": {"type": "boolean"},
28402840
"enphase_automatic_ignore_pv": {"type": "boolean"},
28412841
"octopus_intelligent_slot": {"type": "sensor|sensor_list", "sensor_type": "boolean|action", "entries": "num_cars", "optional_entries": True},
2842+
"octopus_intelligent_planned_slot": {"type": "sensor|sensor_list", "sensor_type": "boolean", "entries": "num_cars", "optional_entries": True},
28422843
"octopus_ready_time": {"type": "sensor|sensor_list", "sensor_type": "string", "entries": "num_cars", "optional_entries": True},
28432844
"octopus_charge_limit": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars", "optional_entries": True},
28442845
"octopus_slot_low_rate": {"type": "boolean"},

‎apps/predbat/fetch.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,21 @@ def fetch_sensor_data_car_planning(self):
14451445
self.log("Car {} charging is exclusive, will not plan other cars".format(car_n))
14461446
break
14471447

1448+
def car_planned_slot_entity(self, planned_entity_id_list, car_n, slot_entity_id):
1449+
"""
1450+
The sensor a car's planned dispatches are read from, where that is not its slot sensor.
1451+
1452+
octopus_intelligent_slot supplies both the completed and the planned dispatches unless
1453+
octopus_intelligent_planned_slot names another sensor for the car. None when it does not, or
1454+
when it names the slot sensor itself - reading that twice would only repeat its dispatches.
1455+
"""
1456+
if car_n >= len(planned_entity_id_list):
1457+
return None
1458+
planned_entity_id = planned_entity_id_list[car_n]
1459+
if not planned_entity_id or not isinstance(planned_entity_id, str) or planned_entity_id.startswith("re:") or planned_entity_id == slot_entity_id:
1460+
return None
1461+
return planned_entity_id
1462+
14481463
def fetch_sensor_data_cars(self, save=True):
14491464
"""
14501465
Fetch car specific data such as Octopus intelligent slots and vehicle data if we can get it, and calculate current SoC and limits based on that
@@ -1478,6 +1493,14 @@ def fetch_sensor_data_cars(self, save=True):
14781493
else:
14791494
entity_id_list = []
14801495

1496+
# A second sensor per car for the planned dispatches, where the one above only has a reliable
1497+
# record of the completed ones - see car_planned_slot_entity()
1498+
planned_entity_id_config = self.get_arg("octopus_intelligent_planned_slot", default=None, indirect=False)
1499+
if planned_entity_id_config and not isinstance(planned_entity_id_config, list):
1500+
planned_entity_id_list = [planned_entity_id_config]
1501+
else:
1502+
planned_entity_id_list = planned_entity_id_config or []
1503+
14811504
# Cars whose charging plan came from Octopus Intelligent dispatch slots this cycle - used
14821505
# below to decide which cars get a model-facing charge limit override (#4967)
14831506
iog_slot_cars = []
@@ -1514,6 +1537,21 @@ def fetch_sensor_data_cars(self, save=True):
15141537
self.log("Warn: Unable to get data from {} for car {} - octopus_intelligent_slot may not be set correctly in apps.yaml".format(entity_id, car_n))
15151538
self.record_status(message="Error: octopus_intelligent_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True)
15161539

1540+
planned_entity_id = self.car_planned_slot_entity(planned_entity_id_list, car_n, entity_id)
1541+
if planned_entity_id:
1542+
# The slot sensor above keeps the record of what was dispatched, and so billed
1543+
# off-peak (#5413); the plan of what is still to come is taken from here instead.
1544+
# What this sensor has already seen finish goes in behind the slot sensor's own
1545+
# completed list: it covers a slot from when it ends until the slot sensor lists
1546+
# it, and load_octopus_slots() drops whatever of it the slot sensor has by then
1547+
try:
1548+
planned = self.get_state_wrapper(entity_id=planned_entity_id, attribute="planned_dispatches") or self.get_state_wrapper(entity_id=planned_entity_id, attribute="plannedDispatches")
1549+
planned_completed = self.get_state_wrapper(entity_id=planned_entity_id, attribute="completed_dispatches") or self.get_state_wrapper(entity_id=planned_entity_id, attribute="completedDispatches")
1550+
completed = (completed or []) + (planned_completed or [])
1551+
except (ValueError, TypeError):
1552+
self.log("Warn: Unable to get data from {} for car {} - octopus_intelligent_planned_slot may not be set correctly in apps.yaml".format(planned_entity_id, car_n))
1553+
self.record_status(message="Error: octopus_intelligent_planned_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True)
1554+
15171555
# Completed and planned slots - merge from all cars
15181556
if completed:
15191557
self.octopus_slots[car_n] += completed

‎apps/predbat/octopus.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,10 +1459,12 @@ def automatic_config(self, tariffs):
14591459
# Another component may have claimed the car slots - the Ohme component does when it is
14601460
# set to take the Intelligent slots from the charger instead. This method re-runs whenever
14611461
# the tariff or device set moves, so without this check it would quietly take them back.
1462+
# The claim is on the ready time and charge limit only. The dispatch sensor is still wired
1463+
# here: it is Octopus's record of what was dispatched, which the other component reads its
1464+
# completed slots from while supplying the planned ones itself (#5413).
14621465
slot_owner = getattr(self.base, "car_slot_owner", None)
1463-
if slot_owner and slot_owner != "octopus":
1464-
self.log("OctopusAPI: Car slots are wired by the {} component, leaving them alone".format(slot_owner))
1465-
elif devices or self.intelligent_config_devices:
1466+
other_owner = bool(slot_owner and slot_owner != "octopus")
1467+
if devices or self.intelligent_config_devices:
14661468
# Suspended devices (e.g. an old/decommissioned charger still linked to the Octopus
14671469
# account) aren't actively charging, so exclude them from the entity lists and from
14681470
# the num_cars count below - otherwise a stale suspended device can silently push
@@ -1490,8 +1492,11 @@ def automatic_config(self, tariffs):
14901492
self.log("OctopusAPI: No active intelligent devices, and the car slot wiring is not from here - leaving it alone")
14911493
else:
14921494
self.set_arg("octopus_intelligent_slot", slot_list)
1493-
self.set_arg("octopus_ready_time", ready_list)
1494-
self.set_arg("octopus_charge_limit", limit_list)
1495+
if other_owner:
1496+
self.log("OctopusAPI: Car ready time and charge limit are wired by the {} component, leaving them alone".format(slot_owner))
1497+
else:
1498+
self.set_arg("octopus_ready_time", ready_list)
1499+
self.set_arg("octopus_charge_limit", limit_list)
14951500
self.intelligent_config_slots = slot_list
14961501
# Increase number of cars if we have more active devices than the current limit to ensure all devices can be configured
14971502
num_cars = self.get_arg("num_cars", 0)

‎apps/predbat/ohme.py‎

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop
247247
# The mode the args were last wired for. Kept apart from slot_mode so that a wiring change
248248
# lost to a failed poll is still owed, and made on the next one
249249
self.slot_mode_applied = None
250+
# On Octopus Intelligent, the sensor Octopus's own record of the dispatches is read from, None
251+
# when there is not one and Ohme's slots are all there is - see octopus_dispatch_sensor()
252+
self.slot_source = None
253+
self.slot_source_applied = None
250254
# Ohme's own schedule is Predbat's car charging plan, as load only - see charger_slots_wanted()
251255
self.charger_slots = False
252256
# The reason charger_slots_wanted() last stood down, so it is only logged when it changes
@@ -313,9 +317,10 @@ async def run(self, seconds, first):
313317
# After the publish, so the slots already read the new way when the args move. Compared
314318
# against what was last wired rather than done on the change alone: if the session fetch or
315319
# the publish above fails, this is skipped, and has to still be owed on the next poll
316-
if poll and self.client.serial and self.slot_mode != self.slot_mode_applied:
320+
if poll and self.client.serial and (self.slot_mode != self.slot_mode_applied or self.slot_source != self.slot_source_applied):
317321
await self.apply_slot_mode()
318322
self.slot_mode_applied = self.slot_mode
323+
self.slot_source_applied = self.slot_source
319324

320325
# Unconditional and outside the "if first and self.client.serial:" block above, so a
321326
# transient failure on that one-shot cycle is retried rather than lost - see
@@ -596,6 +601,38 @@ async def update_slot_mode(self):
596601
slot_mode = None
597602
self.charger_slots = slot_mode == SLOT_MODE_CHARGER
598603
self.slot_mode = slot_mode
604+
# Asked on every poll, like the mode: the Octopus component wires its dispatch sensor when it
605+
# finds a device and clears it when the last one goes, and the Ohme wiring has to follow
606+
slot_source = self.octopus_dispatch_sensor() if slot_mode == SLOT_MODE_INTELLIGENT else None
607+
if slot_mode == SLOT_MODE_INTELLIGENT and (slot_source != self.slot_source or self.slot_mode_applied != SLOT_MODE_INTELLIGENT):
608+
if slot_source:
609+
self.log("Info: Ohme API: Taking the planned car slots from Ohme and the completed dispatches from {}".format(slot_source))
610+
else:
611+
self.log("Info: Ohme API: No Octopus dispatch sensor is set in octopus_intelligent_slot, so the completed car slots come from Ohme too - they are only kept until the car is unplugged")
612+
self.slot_source = slot_source
613+
614+
def octopus_dispatch_sensor(self):
615+
"""
616+
The sensor holding Octopus's own record of the car's dispatches, or None when there is not one.
617+
618+
Ohme knows the charge that is planned sooner and better than Octopus reports it, but only for
619+
the session in progress: unplug the car and every slot it charged in is gone, and with them
620+
the off-peak rate those minutes were billed at (#5413). Octopus keeps its completed dispatches,
621+
so where octopus_intelligent_slot already points at a sensor of Octopus's - wired by the
622+
Octopus component, or set in apps.yaml to the Octopus Energy integration's - that sensor is
623+
left as the record of what has been dispatched and Ohme supplies the plan beside it.
624+
625+
An Ohme sensor is not such a record, whether ours or the Ohme integration's. Nor is an
626+
unmatched regex from the apps.yaml default, still its literal "re:" string at this point.
627+
"""
628+
existing = self.get_arg("octopus_intelligent_slot", default=None, indirect=False)
629+
if isinstance(existing, list):
630+
existing = existing[0] if existing else None
631+
if not existing or not isinstance(existing, str) or existing.startswith("re:"):
632+
return None
633+
if existing == SLOT_ACTIVE_ENTITY or existing.split(".")[-1].startswith("ohme_"):
634+
return None
635+
return existing
599636

600637
async def apply_slot_mode(self):
601638
"""
@@ -604,9 +641,10 @@ async def apply_slot_mode(self):
604641
if self.slot_mode == SLOT_MODE_INTELLIGENT:
605642
await self.automatic_config_octopus_intelligent()
606643
return
607-
# Only the Intelligent wiring holds the car slots against the Octopus component
644+
# Only the Intelligent wiring holds the ready time and charge limit against the Octopus component
608645
if self.base.car_slot_owner == "ohme":
609646
self.base.car_slot_owner = None
647+
self.clear_planned_slots()
610648
if self.slot_mode == SLOT_MODE_CHARGER:
611649
await self.automatic_config_charger_slots()
612650
else:
@@ -634,7 +672,7 @@ def charger_slots_wanted(self, octopus_intelligent):
634672
existing = existing[0]
635673
# An unmatched regex from the apps.yaml default is still its literal "re:" string at this
636674
# point, as it is for car_charging_energy in automatic_config() - that is not a real entity
637-
if existing and existing != SLOT_ACTIVE_ENTITY and not (isinstance(existing, str) and existing.startswith("re:")):
675+
if existing and existing != SLOT_ACTIVE_ENTITY and not (isinstance(existing, str) and existing.startswith("re:")) and not self.dispatch_sensor_spent(existing):
638676
# Asked on every poll, so only said when it changes
639677
if existing != self.charger_slots_blocked:
640678
self.log("Info: Ohme API: Leaving octopus_intelligent_slot set to {} rather than taking the car charging plan from Ohme".format(existing))
@@ -687,13 +725,31 @@ async def automatic_config_octopus_intelligent(self):
687725
"""
688726
Automatically set the predbat entities to take the Intelligent car slots from Ohme.
689727
690-
Claims the car slot args so OctopusAPI.automatic_config() stops re-wiring them to its own
691-
dispatch entities - it re-runs whenever the tariff or intelligent device set moves, which
692-
would otherwise silently undo this part way through a run.
728+
Claims the car slot args so OctopusAPI.automatic_config() stops re-wiring the ready time and
729+
charge limit to its own entities - it re-runs whenever the tariff or intelligent device set
730+
moves, which would otherwise silently undo this part way through a run. The claim does not
731+
cover octopus_intelligent_slot, which the Octopus component goes on wiring to its dispatch
732+
sensor: where there is one it stays as the record of the completed dispatches, and Ohme
733+
supplies only the planned ones - see octopus_dispatch_sensor().
693734
"""
694735
self.log("Info: Ohme API: Setting Predbat to use Ohme")
695736
self.base.car_slot_owner = "ohme"
696-
self.wire_car_slots()
737+
if self.slot_source:
738+
self.wire_planned_slots()
739+
else:
740+
self.wire_car_slots()
741+
742+
def dispatch_sensor_spent(self, entity_id):
743+
"""
744+
Is this the Octopus dispatch sensor Ohme's planned slots were last read beside, now with nothing more to give.
745+
746+
On Octopus Intelligent that sensor is left in octopus_intelligent_slot as the record of the
747+
completed dispatches. Once Intelligent no longer schedules anything - the tariff has gone, or
748+
every device is suspended - it is not a plan of anyone's, and left there it would keep the
749+
car on dispatches that never come. Not so while Octopus drives another device, whose
750+
dispatches that sensor is about to carry.
751+
"""
752+
return bool(entity_id) and entity_id == self.slot_source_applied and not self.octopus_intelligent and not self.octopus_other_device
697753

698754
async def automatic_config_charger_slots(self):
699755
"""
@@ -729,13 +785,51 @@ def clear_car_slots(self):
729785
self.set_arg("octopus_ready_time", [])
730786
self.set_arg("octopus_charge_limit", [])
731787

788+
def clear_planned_slots(self):
789+
"""
790+
Take the planned slot arg, and the ready time and charge limit wired with it, back off the Ohme entities.
791+
792+
For leaving the wiring wire_planned_slots() made, where octopus_intelligent_slot was never
793+
Ohme's and so clear_car_slots() finds nothing to do. Anything since pointed elsewhere is left.
794+
"""
795+
existing = self.get_arg("octopus_intelligent_planned_slot", default=None, indirect=False)
796+
if isinstance(existing, list) and len(existing) == 1:
797+
existing = existing[0]
798+
if existing != SLOT_ACTIVE_ENTITY:
799+
return
800+
self.set_arg("octopus_intelligent_planned_slot", [])
801+
slot = self.get_arg("octopus_intelligent_slot", default=None, indirect=False)
802+
if isinstance(slot, list) and len(slot) == 1:
803+
slot = slot[0]
804+
if self.dispatch_sensor_spent(slot):
805+
self.set_arg("octopus_intelligent_slot", [])
806+
for arg in ("octopus_ready_time", "octopus_charge_limit"):
807+
current = self.get_arg(arg, default=None, indirect=False)
808+
if isinstance(current, list) and len(current) == 1:
809+
current = current[0]
810+
if current == CAR_DISCOVERY_ENTITY_SPEC[arg]["entity_id"]:
811+
self.set_arg(arg, [])
812+
732813
def wire_car_slots(self):
733814
"""
734815
Point the car slot args at the Ohme entities.
735816
"""
736817
self.set_arg("octopus_intelligent_slot", SLOT_ACTIVE_ENTITY)
737-
self.set_arg("octopus_ready_time", "select.predbat_ohme_target_time")
738-
self.set_arg("octopus_charge_limit", "number.predbat_ohme_target_percent")
818+
# The slot sensor now carries the planned slots as well, nothing is read beside it
819+
self.set_arg("octopus_intelligent_planned_slot", [])
820+
self.set_arg("octopus_ready_time", CAR_DISCOVERY_ENTITY_SPEC["octopus_ready_time"]["entity_id"])
821+
self.set_arg("octopus_charge_limit", CAR_DISCOVERY_ENTITY_SPEC["octopus_charge_limit"]["entity_id"])
822+
823+
def wire_planned_slots(self):
824+
"""
825+
Point the planned slot arg at the Ohme slots, leaving octopus_intelligent_slot on Octopus's sensor.
826+
827+
The ready time and charge limit are Ohme's either way: they are set on the charger, which is
828+
what Octopus is scheduling.
829+
"""
830+
self.set_arg("octopus_intelligent_planned_slot", [SLOT_ACTIVE_ENTITY])
831+
self.set_arg("octopus_ready_time", CAR_DISCOVERY_ENTITY_SPEC["octopus_ready_time"]["entity_id"])
832+
self.set_arg("octopus_charge_limit", CAR_DISCOVERY_ENTITY_SPEC["octopus_charge_limit"]["entity_id"])
739833

740834
def _discovery_vehicle(self):
741835
"""

‎apps/predbat/predbat.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,10 +527,12 @@ def reset(self):
527527
self.car_charging_threshold = 99
528528
self.car_charging_energy = {}
529529
self.car_charging_energy_warned = False
530-
# Which component's automatic_config() owns octopus_intelligent_slot/ready_time/charge_limit.
531-
# Both OctopusAPI and OhmeAPI can wire the car slots, and Octopus re-runs its automatic_config
530+
# Which component's automatic_config() owns octopus_ready_time/octopus_charge_limit.
531+
# Both OctopusAPI and OhmeAPI can wire them, and Octopus re-runs its automatic_config
532532
# whenever the tariff or intelligent device set moves - without a claim it silently takes the
533-
# args back off Ohme part way through a run. None means nobody has claimed them.
533+
# args back off Ohme part way through a run. None means nobody has claimed them. The claim
534+
# does not cover octopus_intelligent_slot: Octopus goes on wiring its dispatch sensor there
535+
# as the record of completed dispatches, and Ohme adds octopus_intelligent_planned_slot (#5413).
534536
self.car_slot_owner = None
535537
self.octopus_intelligent_charging = False
536538
self.octopus_intelligent_ignore_unplugged = False

0 commit comments

Comments
 (0)