Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2839,6 +2839,7 @@
"enphase_automatic": {"type": "boolean"},
"enphase_automatic_ignore_pv": {"type": "boolean"},
"octopus_intelligent_slot": {"type": "sensor|sensor_list", "sensor_type": "boolean|action", "entries": "num_cars", "optional_entries": True},
"octopus_intelligent_planned_slot": {"type": "sensor|sensor_list", "sensor_type": "boolean", "entries": "num_cars", "optional_entries": True},
"octopus_ready_time": {"type": "sensor|sensor_list", "sensor_type": "string", "entries": "num_cars", "optional_entries": True},
"octopus_charge_limit": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars", "optional_entries": True},
"octopus_slot_low_rate": {"type": "boolean"},
Expand Down
38 changes: 38 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1445,6 +1445,21 @@ def fetch_sensor_data_car_planning(self):
self.log("Car {} charging is exclusive, will not plan other cars".format(car_n))
break

def car_planned_slot_entity(self, planned_entity_id_list, car_n, slot_entity_id):
"""
The sensor a car's planned dispatches are read from, where that is not its slot sensor.

octopus_intelligent_slot supplies both the completed and the planned dispatches unless
octopus_intelligent_planned_slot names another sensor for the car. None when it does not, or
when it names the slot sensor itself - reading that twice would only repeat its dispatches.
"""
if car_n >= len(planned_entity_id_list):
return None
planned_entity_id = planned_entity_id_list[car_n]
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:
return None
return planned_entity_id

def fetch_sensor_data_cars(self, save=True):
"""
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
Expand Down Expand Up @@ -1478,6 +1493,14 @@ def fetch_sensor_data_cars(self, save=True):
else:
entity_id_list = []

# A second sensor per car for the planned dispatches, where the one above only has a reliable
# record of the completed ones - see car_planned_slot_entity()
planned_entity_id_config = self.get_arg("octopus_intelligent_planned_slot", default=None, indirect=False)
if planned_entity_id_config and not isinstance(planned_entity_id_config, list):
planned_entity_id_list = [planned_entity_id_config]
else:
planned_entity_id_list = planned_entity_id_config or []

# Cars whose charging plan came from Octopus Intelligent dispatch slots this cycle - used
# below to decide which cars get a model-facing charge limit override (#4967)
iog_slot_cars = []
Expand Down Expand Up @@ -1514,6 +1537,21 @@ def fetch_sensor_data_cars(self, save=True):
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))
self.record_status(message="Error: octopus_intelligent_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True)

planned_entity_id = self.car_planned_slot_entity(planned_entity_id_list, car_n, entity_id)
if planned_entity_id:
# The slot sensor above keeps the record of what was dispatched, and so billed
# off-peak (#5413); the plan of what is still to come is taken from here instead.
# What this sensor has already seen finish goes in behind the slot sensor's own
# completed list: it covers a slot from when it ends until the slot sensor lists
# it, and load_octopus_slots() drops whatever of it the slot sensor has by then
try:
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")
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")
completed = (completed or []) + (planned_completed or [])
except (ValueError, TypeError):
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))
self.record_status(message="Error: octopus_intelligent_planned_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True)

# Completed and planned slots - merge from all cars
if completed:
self.octopus_slots[car_n] += completed
Expand Down
15 changes: 10 additions & 5 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -1459,10 +1459,12 @@ def automatic_config(self, tariffs):
# Another component may have claimed the car slots - the Ohme component does when it is
# set to take the Intelligent slots from the charger instead. This method re-runs whenever
# the tariff or device set moves, so without this check it would quietly take them back.
# The claim is on the ready time and charge limit only. The dispatch sensor is still wired
# here: it is Octopus's record of what was dispatched, which the other component reads its
# completed slots from while supplying the planned ones itself (#5413).
slot_owner = getattr(self.base, "car_slot_owner", None)
if slot_owner and slot_owner != "octopus":
self.log("OctopusAPI: Car slots are wired by the {} component, leaving them alone".format(slot_owner))
elif devices or self.intelligent_config_devices:
other_owner = bool(slot_owner and slot_owner != "octopus")
if devices or self.intelligent_config_devices:
# Suspended devices (e.g. an old/decommissioned charger still linked to the Octopus
# account) aren't actively charging, so exclude them from the entity lists and from
# the num_cars count below - otherwise a stale suspended device can silently push
Expand Down Expand Up @@ -1490,8 +1492,11 @@ def automatic_config(self, tariffs):
self.log("OctopusAPI: No active intelligent devices, and the car slot wiring is not from here - leaving it alone")
else:
self.set_arg("octopus_intelligent_slot", slot_list)
self.set_arg("octopus_ready_time", ready_list)
self.set_arg("octopus_charge_limit", limit_list)
if other_owner:
self.log("OctopusAPI: Car ready time and charge limit are wired by the {} component, leaving them alone".format(slot_owner))
else:
self.set_arg("octopus_ready_time", ready_list)
self.set_arg("octopus_charge_limit", limit_list)
self.intelligent_config_slots = slot_list
# Increase number of cars if we have more active devices than the current limit to ensure all devices can be configured
num_cars = self.get_arg("num_cars", 0)
Expand Down
112 changes: 103 additions & 9 deletions apps/predbat/ohme.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop
# The mode the args were last wired for. Kept apart from slot_mode so that a wiring change
# lost to a failed poll is still owed, and made on the next one
self.slot_mode_applied = None
# On Octopus Intelligent, the sensor Octopus's own record of the dispatches is read from, None
# when there is not one and Ohme's slots are all there is - see octopus_dispatch_sensor()
self.slot_source = None
self.slot_source_applied = None
# Ohme's own schedule is Predbat's car charging plan, as load only - see charger_slots_wanted()
self.charger_slots = False
# The reason charger_slots_wanted() last stood down, so it is only logged when it changes
Expand Down Expand Up @@ -313,9 +317,10 @@ async def run(self, seconds, first):
# After the publish, so the slots already read the new way when the args move. Compared
# against what was last wired rather than done on the change alone: if the session fetch or
# the publish above fails, this is skipped, and has to still be owed on the next poll
if poll and self.client.serial and self.slot_mode != self.slot_mode_applied:
if poll and self.client.serial and (self.slot_mode != self.slot_mode_applied or self.slot_source != self.slot_source_applied):
await self.apply_slot_mode()
self.slot_mode_applied = self.slot_mode
self.slot_source_applied = self.slot_source

# Unconditional and outside the "if first and self.client.serial:" block above, so a
# transient failure on that one-shot cycle is retried rather than lost - see
Expand Down Expand Up @@ -596,6 +601,38 @@ async def update_slot_mode(self):
slot_mode = None
self.charger_slots = slot_mode == SLOT_MODE_CHARGER
self.slot_mode = slot_mode
# Asked on every poll, like the mode: the Octopus component wires its dispatch sensor when it
# finds a device and clears it when the last one goes, and the Ohme wiring has to follow
slot_source = self.octopus_dispatch_sensor() if slot_mode == SLOT_MODE_INTELLIGENT else None
if slot_mode == SLOT_MODE_INTELLIGENT and (slot_source != self.slot_source or self.slot_mode_applied != SLOT_MODE_INTELLIGENT):
if slot_source:
self.log("Info: Ohme API: Taking the planned car slots from Ohme and the completed dispatches from {}".format(slot_source))
else:
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")
self.slot_source = slot_source

def octopus_dispatch_sensor(self):
"""
The sensor holding Octopus's own record of the car's dispatches, or None when there is not one.

Ohme knows the charge that is planned sooner and better than Octopus reports it, but only for
the session in progress: unplug the car and every slot it charged in is gone, and with them
the off-peak rate those minutes were billed at (#5413). Octopus keeps its completed dispatches,
so where octopus_intelligent_slot already points at a sensor of Octopus's - wired by the
Octopus component, or set in apps.yaml to the Octopus Energy integration's - that sensor is
left as the record of what has been dispatched and Ohme supplies the plan beside it.

An Ohme sensor is not such a record, whether ours or the Ohme integration's. Nor is an
unmatched regex from the apps.yaml default, still its literal "re:" string at this point.
"""
existing = self.get_arg("octopus_intelligent_slot", default=None, indirect=False)
if isinstance(existing, list):
existing = existing[0] if existing else None
if not existing or not isinstance(existing, str) or existing.startswith("re:"):
return None
if existing == SLOT_ACTIVE_ENTITY or existing.split(".")[-1].startswith("ohme_"):
return None
return existing

async def apply_slot_mode(self):
"""
Expand All @@ -604,9 +641,10 @@ async def apply_slot_mode(self):
if self.slot_mode == SLOT_MODE_INTELLIGENT:
await self.automatic_config_octopus_intelligent()
return
# Only the Intelligent wiring holds the car slots against the Octopus component
# Only the Intelligent wiring holds the ready time and charge limit against the Octopus component
if self.base.car_slot_owner == "ohme":
self.base.car_slot_owner = None
self.clear_planned_slots()
if self.slot_mode == SLOT_MODE_CHARGER:
await self.automatic_config_charger_slots()
else:
Expand Down Expand Up @@ -634,7 +672,7 @@ def charger_slots_wanted(self, octopus_intelligent):
existing = existing[0]
# An unmatched regex from the apps.yaml default is still its literal "re:" string at this
# point, as it is for car_charging_energy in automatic_config() - that is not a real entity
if existing and existing != SLOT_ACTIVE_ENTITY and not (isinstance(existing, str) and existing.startswith("re:")):
if existing and existing != SLOT_ACTIVE_ENTITY and not (isinstance(existing, str) and existing.startswith("re:")) and not self.dispatch_sensor_spent(existing):
# Asked on every poll, so only said when it changes
if existing != self.charger_slots_blocked:
self.log("Info: Ohme API: Leaving octopus_intelligent_slot set to {} rather than taking the car charging plan from Ohme".format(existing))
Expand Down Expand Up @@ -687,13 +725,31 @@ async def automatic_config_octopus_intelligent(self):
"""
Automatically set the predbat entities to take the Intelligent car slots from Ohme.

Claims the car slot args so OctopusAPI.automatic_config() stops re-wiring them to its own
dispatch entities - it re-runs whenever the tariff or intelligent device set moves, which
would otherwise silently undo this part way through a run.
Claims the car slot args so OctopusAPI.automatic_config() stops re-wiring the ready time and
charge limit to its own entities - it re-runs whenever the tariff or intelligent device set
moves, which would otherwise silently undo this part way through a run. The claim does not
cover octopus_intelligent_slot, which the Octopus component goes on wiring to its dispatch
sensor: where there is one it stays as the record of the completed dispatches, and Ohme
supplies only the planned ones - see octopus_dispatch_sensor().
"""
self.log("Info: Ohme API: Setting Predbat to use Ohme")
self.base.car_slot_owner = "ohme"
self.wire_car_slots()
if self.slot_source:
self.wire_planned_slots()
else:
self.wire_car_slots()

def dispatch_sensor_spent(self, entity_id):
"""
Is this the Octopus dispatch sensor Ohme's planned slots were last read beside, now with nothing more to give.

On Octopus Intelligent that sensor is left in octopus_intelligent_slot as the record of the
completed dispatches. Once Intelligent no longer schedules anything - the tariff has gone, or
every device is suspended - it is not a plan of anyone's, and left there it would keep the
car on dispatches that never come. Not so while Octopus drives another device, whose
dispatches that sensor is about to carry.
"""
return bool(entity_id) and entity_id == self.slot_source_applied and not self.octopus_intelligent and not self.octopus_other_device

async def automatic_config_charger_slots(self):
"""
Expand Down Expand Up @@ -729,13 +785,51 @@ def clear_car_slots(self):
self.set_arg("octopus_ready_time", [])
self.set_arg("octopus_charge_limit", [])

def clear_planned_slots(self):
"""
Take the planned slot arg, and the ready time and charge limit wired with it, back off the Ohme entities.

For leaving the wiring wire_planned_slots() made, where octopus_intelligent_slot was never
Ohme's and so clear_car_slots() finds nothing to do. Anything since pointed elsewhere is left.
"""
existing = self.get_arg("octopus_intelligent_planned_slot", default=None, indirect=False)
if isinstance(existing, list) and len(existing) == 1:
existing = existing[0]
if existing != SLOT_ACTIVE_ENTITY:
return
self.set_arg("octopus_intelligent_planned_slot", [])
slot = self.get_arg("octopus_intelligent_slot", default=None, indirect=False)
if isinstance(slot, list) and len(slot) == 1:
slot = slot[0]
if self.dispatch_sensor_spent(slot):
self.set_arg("octopus_intelligent_slot", [])
for arg in ("octopus_ready_time", "octopus_charge_limit"):
current = self.get_arg(arg, default=None, indirect=False)
if isinstance(current, list) and len(current) == 1:
current = current[0]
if current == CAR_DISCOVERY_ENTITY_SPEC[arg]["entity_id"]:
self.set_arg(arg, [])

def wire_car_slots(self):
"""
Point the car slot args at the Ohme entities.
"""
self.set_arg("octopus_intelligent_slot", SLOT_ACTIVE_ENTITY)
self.set_arg("octopus_ready_time", "select.predbat_ohme_target_time")
self.set_arg("octopus_charge_limit", "number.predbat_ohme_target_percent")
# The slot sensor now carries the planned slots as well, nothing is read beside it
self.set_arg("octopus_intelligent_planned_slot", [])
self.set_arg("octopus_ready_time", CAR_DISCOVERY_ENTITY_SPEC["octopus_ready_time"]["entity_id"])
self.set_arg("octopus_charge_limit", CAR_DISCOVERY_ENTITY_SPEC["octopus_charge_limit"]["entity_id"])

def wire_planned_slots(self):
"""
Point the planned slot arg at the Ohme slots, leaving octopus_intelligent_slot on Octopus's sensor.

The ready time and charge limit are Ohme's either way: they are set on the charger, which is
what Octopus is scheduling.
"""
self.set_arg("octopus_intelligent_planned_slot", [SLOT_ACTIVE_ENTITY])
self.set_arg("octopus_ready_time", CAR_DISCOVERY_ENTITY_SPEC["octopus_ready_time"]["entity_id"])
self.set_arg("octopus_charge_limit", CAR_DISCOVERY_ENTITY_SPEC["octopus_charge_limit"]["entity_id"])

def _discovery_vehicle(self):
"""
Expand Down
8 changes: 5 additions & 3 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,10 +527,12 @@ def reset(self):
self.car_charging_threshold = 99
self.car_charging_energy = {}
self.car_charging_energy_warned = False
# Which component's automatic_config() owns octopus_intelligent_slot/ready_time/charge_limit.
# Both OctopusAPI and OhmeAPI can wire the car slots, and Octopus re-runs its automatic_config
# Which component's automatic_config() owns octopus_ready_time/octopus_charge_limit.
# Both OctopusAPI and OhmeAPI can wire them, and Octopus re-runs its automatic_config
# whenever the tariff or intelligent device set moves - without a claim it silently takes the
# args back off Ohme part way through a run. None means nobody has claimed them.
# args back off Ohme part way through a run. None means nobody has claimed them. The claim
# does not cover octopus_intelligent_slot: Octopus goes on wiring its dispatch sensor there
# as the record of completed dispatches, and Ohme adds octopus_intelligent_planned_slot (#5413).
self.car_slot_owner = None
self.octopus_intelligent_charging = False
self.octopus_intelligent_ignore_unplugged = False
Expand Down
Loading
Loading