Skip to content
Merged
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
13 changes: 13 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,7 @@ def fetch_sensor_data_cars(self):

completed = []
planned = []
started = []

if entity_id and "octopus_intelligent_slot_action_config" in self.args:
config_entry = self.get_arg("octopus_intelligent_slot_action_config", None, indirect=False)
Expand All @@ -1415,10 +1416,22 @@ def fetch_sensor_data_cars(self):
try:
completed = self.get_state_wrapper(entity_id=entity_id, attribute="completed_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="completedDispatches")
planned = self.get_state_wrapper(entity_id=entity_id, attribute="planned_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="plannedDispatches")
# Not merged into octopus_slots or used for any rate/plan decision yet - read
# only for the #4516 Stage 1 diagnostic timeline log below, to observe whether
# this is a trustworthy earlier-than-completed confirmation signal before
# building any gating logic on it (Stage 2, deferred).
started = self.get_state_wrapper(entity_id=entity_id, attribute="started_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="startedDispatches")
except (ValueError, TypeError):
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)

# #4516 Stage 1: diagnostic dispatch-timeline log. Purely observational - see
# build_dispatch_timeline()'s and dispatch_timeline_should_log()'s docstrings.
timeline = self.build_dispatch_timeline(car_n, completed, started, planned)
should_log, marker = self.dispatch_timeline_should_log(car_n, timeline)
if should_log:
self.log("Octopus: Dispatch timeline car {} @ {} [-4h..+24h]: {}{}".format(car_n, self.time_abs_str(self.minutes_now), timeline, marker))

# Completed and planned slots - merge from all cars
if completed:
self.octopus_slots[car_n] += completed
Expand Down
102 changes: 101 additions & 1 deletion apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -2910,9 +2910,14 @@ def load_saving_slot(self, octopus_saving_slots, rate_dict, export=False, rate_r
if not export:
self.load_scaling_dynamic[minute] = self.load_scaling_saving

def decode_octopus_slot(self, car_n, slot, raw=False):
def decode_octopus_slot(self, car_n, slot, raw=False, boundaries_only=False):
"""
Decode IOG slot

boundaries_only skips the kWh/source/location handling below - including the "remove
empty slots" check, which real HA-integration started_dispatches entries (start/end only,
no kWh, source or location) would otherwise trip, decoding them as empty (#4516 Stage 1's
build_dispatch_timeline() review). Only start/end are meaningful there.
"""
if "start" in slot:
start = datetime.strptime(slot["start"], TIME_FORMAT)
Expand All @@ -2936,6 +2941,9 @@ def decode_octopus_slot(self, car_n, slot, raw=False):
if start_minutes == end_minutes:
return 0, 0, 0, source, location

if boundaries_only:
return start_minutes, end_minutes, 0, source, location

cap_minutes = end_minutes - start_minutes

# The load expected is stored in chargeKwh for the period in use
Expand Down Expand Up @@ -2984,6 +2992,98 @@ def get_octopus_slot_max(self):
return OCTOPUS_SLOT_MAX_CAPPED
return OCTOPUS_SLOT_MAX_DEFAULT

def build_dispatch_timeline(self, car_n, completed, started, planned, window_before_hours=4, window_after_hours=24, step=30):
"""
Build a fixed-width, one-character-per-block dispatch status string for the diagnostic
timeline log (#4516 Stage 1 - not yet used for any rate/plan decision, purely observational).

Each character covers `step` minutes, offset from now, spanning
[-window_before_hours, +window_after_hours). '.' = nothing known, 'P' = planned
(provisional), 'S' = started, 'C' = completed. Where lists disagree on the same block, the
most-confirmed status wins (completed > started > planned) - reflects Octopus's own view
having moved on, not a genuine simultaneous claim.

The letter is lowercased where Predbat's own plan charges in that block, so 'p' is a
provisional slot Predbat is relying on and 'P' one it is not. That distinction is the
point: a slot that disappears having never been planned against costs nothing, while one
Predbat committed an import to is a plan that will not happen.

Stacking consecutive lines (a heartbeat one per 30-minute boundary, plus one on any
earlier change - see the caller in fetch.py) in a monospace log viewer reveals dispatch
lifecycle as diagonal stripes: a specific dispatch drifts one column per line as `now`
advances, so a rescinded slot shows as a stripe that stops before reaching the `now`
column, while a genuinely-delivered one runs through into 'C'.
"""
total_before = window_before_hours * 60
total_after = window_after_hours * 60
num_blocks = (total_before + total_after) // step
status = ["."] * num_blocks

def mark(slots, char):
for slot in slots or []:
start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True)
if start_minutes == end_minutes:
continue
start_offset = start_minutes - self.minutes_now
end_offset = end_minutes - self.minutes_now
block_start = max(0, (start_offset + total_before) // step)
block_end = min(num_blocks, -(-(end_offset + total_before) // step)) # ceil division
for block in range(int(block_start), int(block_end)):
status[block] = char

mark(planned, "P")
mark(started, "S")
mark(completed, "C")

# Lowercase where Predbat's own plan plans to import - so a stripe shows not just the
# dispatch's lifecycle but whether Predbat had committed a charge window to it. A slot
# that reads 'p' and then vanishes before reaching the now column is one Predbat planned
# around and lost, which is the case a plain P/S/C timeline cannot distinguish from a
# slot nothing depended on.
#
# This runs in fetch, before the planner, so the windows are the *previous* cycle's plan -
# one 5-minute cycle stale against 30-minute blocks. That is deliberate: what Predbat
# believed while the slot was still live is the more useful thing to record.
for window_n, window in enumerate(self.charge_window_best or []):
if window_n < len(self.charge_limit_best or []) and self.charge_limit_best[window_n] <= 0:
continue
start_offset = window.get("start", 0) - self.minutes_now
end_offset = window.get("end", 0) - self.minutes_now
block_start = max(0, (start_offset + total_before) // step)
block_end = min(num_blocks, -(-(end_offset + total_before) // step))
for block in range(int(block_start), int(block_end)):
if status[block] in ("P", "S", "C"):
status[block] = status[block].lower()

return "".join(status)

def dispatch_timeline_should_log(self, car_n, timeline):
"""
Decide whether fetch.py should log a dispatch-timeline diagnostic line this cycle, and
what marker to append if so.

A 30-minute heartbeat always logs - dispatch decisions are 30-min-granular anyway, so log
volume stays sane at that cadence - but a change from the last logged timeline for this
car also logs immediately outside that boundary, marked with a trailing ' *' (#4948
review). Without this, a provisional slot that appears and disappears entirely within one
half-hour window would never appear in this log at all, however briefly Predbat's own
plan relied on it - exactly the pattern this diagnostic exists to catch.

The marker is a suffix, appended by the caller after build_dispatch_timeline()'s
fixed-width string, rather than a prefix - so heartbeat and change-triggered lines still
stack column-for-column in a monospace viewer. That alignment is the whole point of the
render: consecutive lines are meant to line up into diagonal stripes.

Records the new timeline as "last logged" as a side effect whenever this returns True, so
a caller must log with the returned marker exactly when told to, and no other times.
"""
is_heartbeat = self.minutes_now % 30 == 0
changed = timeline != self.dispatch_timeline_last.get(car_n)
if not (is_heartbeat or changed):
return False, ""
self.dispatch_timeline_last[car_n] = timeline
return True, "" if is_heartbeat else " *"

def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_full):
"""
Turn octopus slots into charging plan
Expand Down
1 change: 1 addition & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ def reset(self):
self.octopus_intelligent_consider_full = False
self.notify_devices = ["notify"]
self.octopus_url_cache = {}
self.dispatch_timeline_last = {}
self.ge_url_cache = {}
self.github_url_cache = {}
self.load_minutes = {}
Expand Down
Loading
Loading