Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions apps/predbat/car_charger_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,8 @@ def charger_control_slots_claimed(self):
"""Has another charger component (Ohme) claimed car 0's Intelligent car wiring.

The claim is for car 0 alone, the car that component wires. Since #5443 it covers only the
ready time and charge limit: the Ohme supplies the planned slots, and the Octopus component
goes on wiring its own dispatch sensors into octopus_intelligent_slot, one per device. So a
ready time and charge limit: the Octopus component goes on wiring its own dispatch sensors
into octopus_intelligent_slot, one per device, and they supply the car slots. So a
second car has a sensor of its own and is judged on what that sensor says.
"""
owner = getattr(self.base, "car_slot_owner", None)
Expand Down
10 changes: 10 additions & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@
# planned dispatch was in force off-peak and never lists the ones the car did not charge in, so the hold is the
# record - kept for a day, so today's cost and the hours either side of midnight are priced from it
OCTOPUS_DISPATCH_WINDOW_HOLD_MINUTES = 24 * 60
# Octopus Intelligent device currentState while it is running a smart charge, and the states in which it dispatches
# nothing - the car unplugged or out of reach, or smart control turned off - whatever planned dispatches it still lists
OCTOPUS_STATE_SMART_CONTROL_IN_PROGRESS = "SMART_CONTROL_IN_PROGRESS"
OCTOPUS_STATES_NOT_DISPATCHING = ("SMART_CONTROL_NOT_AVAILABLE", "SMART_CONTROL_OFF")
# How long the last known planned dispatches are kept through empty answers while a smart charge is in progress:
# Octopus answers an empty planned list for a few minutes while it re-plans a running session (seen 2 to 8 minutes)
OCTOPUS_PLANNED_EMPTY_HOLD_MINUTES = 10
# The settings of intelligent_dispatch_billing in apps.yaml
DISPATCH_BILLING_CHOICES = ("auto", "octopus", "window")

Expand Down Expand Up @@ -79,6 +86,9 @@
# Dispatch source for a charger's own schedule on a tariff with no dispatch rate (e.g. Ohme off Octopus
# Intelligent): the slots are when the car will charge, but they are billed at the normal tariff rate
DISPATCH_SOURCE_CHARGER_SCHEDULE = "charger-schedule"
# Source for a half hour held because the car was charging in a smart charge Octopus had not listed a dispatch for:
# Octopus starts charging when the car is plugged in, and lists that half hour only once it has completed
DISPATCH_SOURCE_CHARGING_UNLISTED = "charging-unlisted"
TIMEOUT = 60 * 5
CONFIG_REFRESH_PERIOD = 60 * 8
INVERTER_MAX_RETRY = 10 # Maximum number of retries for inverter commands
Expand Down
129 changes: 118 additions & 11 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,19 @@
import re
from datetime import datetime, timedelta, timezone
from predbat_metrics import record_api_call
from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS, DISPATCH_SOURCE_CHARGER_SCHEDULE, REPEAT_FULL_LOG_SECONDS, OCTOPUS_DISPATCH_HOLD_MINUTES, OCTOPUS_DISPATCH_WINDOW_HOLD_MINUTES, DISPATCH_BILLING_CHOICES
from const import (
TIME_FORMAT,
TIME_FORMAT_OCTOPUS,
DISPATCH_SOURCE_CHARGER_SCHEDULE,
DISPATCH_SOURCE_CHARGING_UNLISTED,
REPEAT_FULL_LOG_SECONDS,
OCTOPUS_DISPATCH_HOLD_MINUTES,
OCTOPUS_DISPATCH_WINDOW_HOLD_MINUTES,
DISPATCH_BILLING_CHOICES,
OCTOPUS_STATE_SMART_CONTROL_IN_PROGRESS,
OCTOPUS_STATES_NOT_DISPATCHING,
OCTOPUS_PLANNED_EMPTY_HOLD_MINUTES,
)
from utils import RepeatLogGate, str2time, minutes_to_time, dp1, dp2, dp4, minute_data, round_out_to_period, filter_payment_method, is_edge_block_body, token_mint_backoff_seconds, TOKEN_MINT_BACKOFF_LOG_INTERVAL_SECONDS, strptime_aware
from component_base import ComponentBase
from mock_base import MockBase as SharedMockBase
Expand Down Expand Up @@ -706,6 +718,9 @@ def initialize(self, key, account_id, automatic):
self.saving_sessions = {}
self.saving_sessions_to_join = []
self.intelligent_devices = {}
# Per device, when Octopus first answered an empty planned list while a smart charge was in progress -
# the last known plan is kept through those for OCTOPUS_PLANNED_EMPTY_HOLD_MINUTES (see planned_dispatches_for_state())
self.intelligent_planned_empty_since = {}
# (message, make, model) the catalogue gave conflicting values for and that have been logged, so the
# warning is given once rather than on every device poll (see catalogue_value())
self.catalogue_ambiguity_logged = set()
Expand Down Expand Up @@ -2766,6 +2781,7 @@ async def async_get_intelligent_devices(self, account_id, device_id):
break
if not found:
completed.append(dispatch)
current_state = self.dispatch_current_state(dispatch_result, IntelligentdeviceID)
if not dispatch_result or dispatch_result.get("flexPlannedDispatches") is None:
# The dispatch query failed (e.g. rate limited), or Octopus answered it without the planned
# dispatches: an error on that field (KT-CT-4340, "Unable to fetch planned dispatches") comes
Expand All @@ -2775,17 +2791,11 @@ async def async_get_intelligent_devices(self, account_id, device_id):
# slots and dispatch rates until the next poll, and the change requested a replan without them.
# Planned dispatches are never pruned, so drop any that have ended since, and re-trim the one
# in progress so the energy delivered since the last poll is not counted again.
now = self.now_utc_exact
planned = []
for cached in self.intelligent_devices.get(IntelligentdeviceID, {}).get("planned_dispatches", []):
cached_end = parse_date_time(cached.get("end"))
if cached_end and cached_end <= now:
continue
dispatch = dict(cached)
self.trim_started_dispatch(dispatch, now)
planned.append(dispatch)
planned = self.cached_planned_dispatches(IntelligentdeviceID)
if planned:
self.log("Warn: OctopusAPI: Dispatch fetch failed for intelligent device {}, reusing the last known planned dispatches".format(IntelligentdeviceID))
else:
planned = self.planned_dispatches_for_state(IntelligentdeviceID, planned, current_state)

# Sort by start time
planned = sorted([x for x in planned if x.get("start")], key=lambda x: parse_date_time(x.get("start")))
Expand All @@ -2794,7 +2804,7 @@ async def async_get_intelligent_devices(self, account_id, device_id):
# Prune completed dispatches for results older than 5 days
completed = [x for x in completed if x.get("start") and parse_date_time(x.get("start")) > self.now_utc_exact - timedelta(days=5)]
# Store results
result = {**intelligent_device, **device_setting_result, "planned_dispatches": planned, "completed_dispatches": completed}
result = {**intelligent_device, **device_setting_result, "current_state": current_state, "planned_dispatches": planned, "completed_dispatches": completed}
results[IntelligentdeviceID] = result
else:
# The devices query failed. Returning {} here would be indistinguishable from an
Expand All @@ -2803,6 +2813,72 @@ async def async_get_intelligent_devices(self, account_id, device_id):
return None
return results

def dispatch_current_state(self, dispatch_result, device_id):
"""
The device's currentState from a dispatch query answer, e.g. SMART_CONTROL_IN_PROGRESS.

A failed query, or an answer without the device, keeps the last known state, as a failed
query keeps the last known planned dispatches.
"""
if dispatch_result:
for device in dispatch_result.get("devices") or []:
if device.get("id") == device_id:
state = (device.get("status") or {}).get("currentState")
if state:
return state
return self.intelligent_devices.get(device_id, {}).get("current_state")

def cached_planned_dispatches(self, device_id):
"""
The device's last known planned dispatches, still to come, for a poll that did not get a usable planned list.

Planned dispatches are never pruned, so any that have ended since are dropped, and the one in
progress is re-trimmed so the energy delivered since the last poll is not counted again. Copies,
so the cache itself is left as it was.
"""
now = self.now_utc_exact
planned = []
for cached in self.intelligent_devices.get(device_id, {}).get("planned_dispatches", []):
cached_end = parse_date_time(cached.get("end"))
if cached_end and cached_end <= now:
continue
dispatch = dict(cached)
self.trim_started_dispatch(dispatch, now)
planned.append(dispatch)
return planned

def planned_dispatches_for_state(self, device_id, planned, state):
"""
The planned dispatches to keep from a dispatch query answer, given the device's currentState.

Unplugged or out of reach (SMART_CONTROL_NOT_AVAILABLE), or with smart control off, the device
is dispatched nothing, yet Octopus can go on listing the plan it had - seen for 12 hours after a
car was unplugged, none of it ever completed. Priced as dispatches, that plan has the house
charge at peak rate on the strength of slots that never come.

While a smart charge is in progress Octopus re-plans it, and answers an empty planned list
for a few minutes as it does (seen 2 to 8 minutes, a dozen times a night). The session is still
running, so the last known plan is kept, for OCTOPUS_PLANNED_EMPTY_HOLD_MINUTES from the first
empty answer - long enough to bridge a re-plan, short enough that a plan Octopus really has
ended does not linger. In any other state an empty list means there are no dispatches.
"""
if state in OCTOPUS_STATES_NOT_DISPATCHING:
self.intelligent_planned_empty_since.pop(device_id, None)
if planned and self.intelligent_devices.get(device_id, {}).get("planned_dispatches"):
self.log("Info: OctopusAPI: Intelligent device {} is {}, ignoring the planned dispatches Octopus still lists for it".format(device_id, state))
return []
if planned or state != OCTOPUS_STATE_SMART_CONTROL_IN_PROGRESS:
self.intelligent_planned_empty_since.pop(device_id, None)
return planned
now = self.now_utc_exact
empty_since = self.intelligent_planned_empty_since.setdefault(device_id, now)
if now - empty_since >= timedelta(minutes=OCTOPUS_PLANNED_EMPTY_HOLD_MINUTES):
return []
kept = self.cached_planned_dispatches(device_id)
if kept and empty_since == now:
self.log("Info: OctopusAPI: Intelligent device {} has no planned dispatches while its smart charge is in progress, keeping the last known ones while Octopus re-plans".format(device_id))
return kept

def trim_started_dispatch(self, dispatch, now):
"""
Trim the elapsed portion of a planned dispatch that is already in progress at now: advance its start
Expand Down Expand Up @@ -4249,6 +4325,9 @@ def octopus_held_update(self, now=None):

Also called from the 15 second poll with its time as now, judged on that exact clock: a dispatch
shorter than the 5 minute cycle is never running at one (see dispatch_too_short()).

A half hour the car is charging in during a smart charge, with no planned dispatch listed for it,
is kept as well (see octopus_charging_unlisted()).
"""
hold_minutes = self.octopus_hold_minutes()
minute_exact = self.dynamic_load_car_minute(now) if now is not None else None
Expand All @@ -4264,10 +4343,38 @@ def octopus_held_update(self, now=None):
held.pop(period_start, None)
continue
source = self.octopus_planned_running(car_n, self.octopus_slots_planned.get(car_n, []), minute_exact)
if source is None and self.octopus_charging_unlisted(car_n):
source = DISPATCH_SOURCE_CHARGING_UNLISTED
if source is not None:
held[period_start] = source
self.octopus_held_save()

def octopus_charging_unlisted(self, car_n):
"""
Whether car_n is charging in a smart charge Octopus has not listed a planned dispatch for.

Octopus starts charging as soon as the car is plugged in, but never lists that first half hour
as a planned dispatch - only as a completed one, an hour or so later, billed off-peak. Seen on
three of six Ohme instances, where Ohme's own schedule had been standing in for it. Until the
completed dispatch arrives the half hour is priced at the day rate, and the plan can export
the battery through a charge the house is getting at the dispatch rate.

The device's currentState comes from the Octopus component's dispatch sensor for the car, so a
car wired to any other slot sensor - the Octopus Energy integration's, or a charger's - is never
taken as charging unlisted. Only SMART_CONTROL_IN_PROGRESS counts: a boost is BOOSTING and
billed at the day rate, and in any other state Octopus is not driving the charge.

Kept as a held half hour rather than a car slot, so it prices the half hour in progress and
no later: a slot from car_charging_now would turn on a charger Predbat drives and keep the
charge going (see execute_plan()'s "Hold for car").
"""
if car_n >= len(self.car_charging_now) or not self.car_charging_now[car_n]:
return False
entity_id = self.get_arg("octopus_intelligent_slot", None, index=car_n, indirect=False)
Comment thread
springfall2008 marked this conversation as resolved.
if not entity_id or not isinstance(entity_id, str) or entity_id.startswith("re:"):
return False
return self.get_state_wrapper(entity_id=entity_id, attribute="current_state") == OCTOPUS_STATE_SMART_CONTROL_IN_PROGRESS

def octopus_held_state(self):
"""
The held half hours in a form storage can hold: per car, the absolute start of each half hour and the
Expand Down
Loading
Loading