Skip to content

feat(octopus): log a diagnostic IOG dispatch timeline (#4516 Stage 1) - #4948

Merged
springfall2008 merged 4 commits into
mainfrom
feat/iog-dispatch-timeline
Sep 6, 2026
Merged

springfall2008 merged 4 commits into
mainfrom
feat/iog-dispatch-timeline

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Written by Claude Code on behalf of @chalfontchubby.

Why this instead of #4885

You pushed back on #4885 and I think the objection holds: distrusting IOG slots has a concrete cost — you end up discharging the house battery into the car, and it takes 5-10 minutes of sensor evidence to know whether the car is really charging — while the benefit rests on slots being rescinded often enough to matter. I went looking for evidence of that in my own billing and couldn't find an instance, and @Speshman's example on #4483 turned out to be a different case (provisional daytime slots the car doesn't need, not slots Octopus withdrew).

So rather than argue the premise, this makes it measurable. No behaviour change at all — no rate is altered, no plan decision reads this. It is a log line.

The reason this is needed is that the data does not currently exist. Predbat merges and retains completed dispatches for 5 days, but planned dispatches are replaced wholesale on every poll, so a slot that disappears leaves no trace. Nothing logs the planned set either. The absence of evidence here is an absence of instrumentation, not of the phenomenon — and that cuts both ways: if the timeline shows slots are stable, #4885 should be closed on the strength of it.

What it does

Once per 30-minute boundary, per car, one character per 30-minute block across [-4h, +24h]:

Octopus: Dispatch timeline car 0 @ 14:30 [-4h..+24h]: ....CCcc..SS..pp....PP..................................

. nothing known, P planned (provisional), S started, C completed. Most-confirmed status wins where the lists disagree.

Lowercase means Predbat's own plan charges in that block. That distinction is the point of the second commit: a slot that vanishes having never been planned against costs nothing, while one Predbat committed an import to is a plan that will not happen. A plain P/S/C render cannot tell those apart.

Stacked in a monospace viewer, consecutive lines make dispatch lifecycles read as diagonal stripes — a specific dispatch drifts one column per line as now advances. So:

  • a stripe that runs through into C is a slot that was delivered
  • a stripe of p that stops before reaching the now column is a slot Predbat planned around and lost

That second pattern is the thing worth finding, and it is exactly the failure I suspect but cannot currently prove: I had one late-day shortfall I believe followed a cheap import slot moving, and no way to confirm it.

Notes

  • The timeline is built in fetch, before the planner runs, so the charge windows are the previous cycle's plan — one 5-minute cycle stale against 30-minute blocks. That is deliberate and commented: what Predbat believed while the slot was still live is the more useful record, and the error is well inside a block.
  • Windows with a zero charge limit are not counted as charging.
  • Logged only on 30-minute boundaries so log volume stays sane; dispatch decisions are 30-minute-granular anyway.
  • The first commit is my own work from August (IOG future planned dispatches treated as cheap home rate before EV charging earns the slot #4516 Stage 1), cherry-picked onto current main. One conflict from a method added adjacently since, resolved by keeping both.

Testing

test_dispatch_timeline covers the render, precedence, boundaries, multi-block slots, and the new lowercase annotation including the zero-limit and no-slot cases. The existing tests did not set plan state, so they would have inherited ambient charge windows in a full-suite run — they now set it explicitly and save/restore around the whole test. Full quick suite and pre-commit clean.

What I'd suggest happens next

Land this, run it for a few weeks on a couple of IOG systems, and let the stripes settle whether #4885 has a case. If slots turn out to be stable, I'll close #4885 myself.

chalfontchubby and others added 2 commits September 5, 2026 20:07
…Stage 1)

Issue #4516: a future daytime IOG dispatch slot is still Octopus's own
provisional/revisable plan, but Predbat currently treats it the same as a
confirmed one. Before building any gating/replan logic on a specific
confirmation signal, first observe real dispatch lifecycles - two signals
investigated for this (intelligent_charge_cap_hours, then the
intelligent_dispatching binary_sensor's default mode) both looked promising
and both turned out unreliable on inspection.

Adds build_dispatch_timeline() (octopus.py): a small render helper producing
a fixed-width, one-character-per-30-min-block status string ('.' nothing,
'P' planned, 'S' started, 'C' completed) across a -4h..+24h window around
now. Logged once per car per 30-minute boundary from fetch_sensor_data_cars()
- purely observational, not merged into octopus_slots or used for any
rate/plan decision. Stacking consecutive lines 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.

Also reads started_dispatches/startedDispatches from the octopus_intelligent_slot
entity for the first time - present on the entity (confirmed against the
actual Octopus Energy HA integration source) but not previously read anywhere
in Predbat.

8 new unit tests for the render helper (empty/planned/started/completed
rendering, confirmed-status priority ordering, out-of-window slots,
multi-block spans). Sanity-checked against a real captured debug.yaml's
octopus_slots data - handles real-world irregularities (negative
charge_in_kwh, non-standard location values) without crashing.

Stage 2 (the actual confirmed-slot gating + immediate-replan trigger) is
deferred until dispatch timelines have been observed for a while.
…harge

The Stage 1 timeline shows a dispatch's lifecycle (planned -> started -> completed)
but not whether Predbat depended on it. A slot that disappears having never been
planned against costs nothing; one Predbat committed an import to is a plan that
will not happen. Those look identical in a P/S/C render.

Lowercase the letter where Predbat's plan charges in that block, so 'p' is a
provisional slot being relied on and 'P' one that is not. Read as stripes, a run of
'p' that stops before reaching the now column is exactly the case worth finding:
Predbat planned around a slot and lost it.

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. Deliberate: what Predbat
believed while the slot was still live is the more useful record, and the error is
well inside a block.

Windows with a zero charge limit are not treated as charging.

Still purely observational - no rate or plan decision reads this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Speshman

Speshman commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Thanks for separating the instrumentation from the behavioural change. This looks like a useful and low-risk way to gather evidence.

Two details may affect what can be concluded from the timeline:

  1. Logging only on 30-minute boundaries could miss a provisional slot that appears after one boundary, influences Predbat’s plan, and disappears before the next. Would it be practical to log whenever the canonical timeline changes, while retaining the half-hour entry as a heartbeat?
  2. Lowercase currently shows that a dispatch overlaps Predbat’s charge plan, but not necessarily that the dispatch caused that plan. Predbat might have charged in that block anyway because of the fixed off-peak window or another rate source. Describing lowercase as “overlaps planned house-battery charging” rather than “relied upon” may avoid overstating what the evidence proves.

Neither point argues against landing the observability work; they would just make it less likely to miss the original issue or draw a stronger conclusion than the data supports.

Drafted with assistance from OpenAI GPT-5.6 Sol.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

I a slot only lives for a short period and disappears before the next log heartbeat it won't have had much time to do damage to the soc. non-uniform time, logging on every issue, would be possible, but limited in utility.
This is indicative- pinning down whether a slot is responsible for what is problematic

I think patch stands as is. Agile mentality mezns ship it if it ads value, don't redo repeatedly and find you never get merged. We can add more later if we need it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Real started dispatches are discarded, preventing S or s from appearing.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds diagnostic logging for Octopus Intelligent dispatch lifecycles without changing planning behavior.

Changes:

  • Renders planned, started, and completed dispatch timelines.
  • Marks blocks overlapping Predbat charge plans in lowercase.
  • Adds and registers timeline unit tests.
File summaries
File Description
apps/predbat/octopus.py Builds dispatch timelines.
apps/predbat/fetch.py Reads started dispatches and logs timelines.
apps/predbat/tests/test_dispatch_timeline.py Tests timeline rendering.
apps/predbat/unit_test.py Registers the new tests.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/octopus.py Outdated
chalfontchubby and others added 2 commits September 6, 2026 12:04
…tadata reject

Copilot review on #4948: decode_octopus_slot()'s "remove empty slots" check
rejects a slot with no kWh, source or location as empty. A real
started_dispatches entry from the HA Octopus Energy integration carries only
start/end - no metadata at all - so build_dispatch_timeline()'s mark() call
decoded every real started dispatch as empty and 'S' could never render for
real data. The existing tests all use a _slot() helper that adds
source/location, which masked this.

The timeline only needs start/end; it never reads the kWh/source/location
this rejection is guarding. Added decode_octopus_slot(boundaries_only=True),
returning right after the start/end decode and before the metadata handling,
and pointed build_dispatch_timeline() at it. Every other caller is
unaffected - the parameter defaults False.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…in heartbeat

PR #4948 review: a heartbeat every 30 minutes alone can miss a provisional
slot that appears, influences Predbat's plan, and disappears again entirely
within one half-hour window - exactly the "stripe that never reaches now"
pattern this diagnostic exists to catch, invisible regardless of how little
time it had to affect SoC. The underlying dispatch lists were already being
re-read every 5-minute cycle; only the log line itself was gated to the
30-minute boundary.

Added dispatch_timeline_should_log(), called every cycle: the 30-minute
heartbeat still always logs unmarked, and a change from the last logged
timeline for that car now also logs immediately, marked with a trailing
' *' so the two kinds of line can be told apart (or grepped separately).
The marker is a suffix, appended after build_dispatch_timeline()'s
fixed-width string rather than before it, so heartbeat and change-triggered
lines keep stacking column-for-column in a monospace viewer - the whole
point of the render is that consecutive lines line up into diagonal
stripes, and a prefix of differing length between the two line kinds would
break that.

5 new tests for the logging decision itself (heartbeat, no-change,
change, no-repeat, independent per-car state), confirmed to fail with an
AttributeError against the pre-change code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Reconsidered point 1 after checking the code more closely — it's cheaper than I gave it credit for. completed/planned/started were already being re-read from HA every 5-minute cycle; only the log line itself was gated to the 30-minute boundary. So a heartbeat-only log genuinely can miss a provisional slot that appears and disappears entirely within one half-hour window, which is exactly the "stripe that never reaches now" pattern this diagnostic exists to catch — regardless of how little time it had to affect SoC, which was the wrong axis to argue on.

Implemented in 45dd2576: the 30-min heartbeat still always logs unmarked, and a change from the last-logged timeline for that car now also logs immediately, marked with a trailing ' *' so the two kinds of line can be told apart (or grepped separately) without disturbing the fixed-width alignment the diagonal-stripe reading depends on — the marker is a suffix, not a prefix.

Point 2 stands as discussed — the lowercase mark is indicative of overlap, not proof of causation, and I think that's already clear enough in context without a wording change.

Posted by Claude on behalf of @chalfontchubby.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The diagnostic-only implementation is consistent with its stated scope, handles real started-dispatch data, preserves shared test state, and has focused coverage.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@springfall2008
springfall2008 merged commit 8b67d5e into main Sep 6, 2026
3 checks passed
@springfall2008
springfall2008 deleted the feat/iog-dispatch-timeline branch September 6, 2026 17:06
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

@Speshman
If you filter the predbat log by all with a string "Timeline" now you should see an illustration of how slots come and go. Might be useful if confusing.

chalfontchubby added a commit that referenced this pull request Sep 16, 2026
…w sensor (#4482, #4516)

Octopus Intelligent daytime dispatch slots are provisional: Octopus can move or withdraw
one before it happens, and allocates them from its own assumption of what the car needs,
since it cannot see the car's real SoC. Predbat treats every granted slot as guaranteed
cheap for the house battery, so it can make an irreversible decision - an early force
export, or deferring a charge - in anticipation of a window that then disappears, or
import during a slot the car never uses, which Octopus bills at the full peak rate.

Two independent gates, which a future out-of-window slot must both clear:

- switch.predbat_octopus_intelligent_limit_future_slots (default Off) - does the car's
  own remaining SoC requirement still want this slot? Requires
  octopus_intelligent_consider_full, and warns at startup if enabled without it.
- select.predbat_trust_future_dynamic_iog_slots (default "planned") - will Octopus
  honour it? "none" trusts no dynamic slot; "completed" requires Octopus's own metered
  record; "started" also accepts a slot corroborated by car_charging_now; "planned" is
  the long-standing trust-everything behaviour. The fixed 23:30-05:30 window is never
  gated - it is cheap by tariff structure, not by dispatch.

Both defaults preserve today's behaviour, so an upgrade changes no plan until a user
opts in.

This reworks the design of the closed PR #4885 onto current main. That PR was rejected
on the grounds that distrusting a slot costs a house-battery discharge into the car
while waiting on a sensor, and that "it takes 5-10 minutes based on a sensor to figure
out if the car is really charging". Measured on a live system since: a watched
car_charging_now change reaches a register write in 17-40s (update_time_loop polls every
15s, so it does not wait for the 5-minute scheduled cycle), and the inverter acts on it
36-65s later - so under two minutes end to end from a locally reported charging state,
not 5-10. The second objection was false positives: inferring "the car is charging" from
a load signature could take a kettle for the car. Hence the sensor gate here - "started"
requires a real charger/car-reported car_charging_now for that car, and degrades to
"completed" per car without one, rather than guessing. #4948 has since landed the
dispatch-timeline diagnostic that PR also carried, so only the trust logic is new.

Two details worth knowing:

car_charging_now confirms a 30-minute settlement slot as a "trusted streak" rather than
per reading. get_arg() re-samples live state on each replan instead of consuming the edge
that woke it, so a charging pulse shorter than the wakeup-to-read latency can be missed
entirely rather than merely seen late. A guard-passing positive read therefore starts a
streak that rolls forward through following slots until an explicit False reading, which
also tolerates a charger toggling at a settlement boundary - observed live as a readiness
sensor flipping Charging->Ready->Charging within two minutes, negotiation noise rather
than a real stop. The guard applies only to streak starts, so a reading in a slot's last
few minutes cannot confirm the slot after it; a cap bounds how far a streak rolls without
fresh evidence, so a missed negative edge degrades to untrusted rather than silently
behaving as "planned". Both constants are judgement calls, not measured.

Rejecting a slot actively restores rate_max_base rather than merely declining to discount
it, because on a real Intelligent tariff fetch_octopus_rates() can deliver the discounted
rate directly from the feed. exclude_dynamic_io_slots() does the same for a feed-side
discount with no matching dispatch entry, consuming the trust decisions rate_add_io_slots()
just made so the two cannot disagree. Both touch only minutes from now onwards: an elapsed
minute records what the tariff actually charged, and rewriting it would inflate
today_cost() without changing a plan that never looks behind minutes_now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 23, 2026
…w sensor (#4482, #4516)

Octopus Intelligent daytime dispatch slots are provisional: Octopus can move or withdraw
one before it happens, and allocates them from its own assumption of what the car needs,
since it cannot see the car's real SoC. Predbat treats every granted slot as guaranteed
cheap for the house battery, so it can make an irreversible decision - an early force
export, or deferring a charge - in anticipation of a window that then disappears, or
import during a slot the car never uses, which Octopus bills at the full peak rate.

Two independent gates, which a future out-of-window slot must both clear:

- switch.predbat_octopus_intelligent_limit_future_slots (default Off) - does the car's
  own remaining SoC requirement still want this slot? Requires
  octopus_intelligent_consider_full, and warns at startup if enabled without it.
- select.predbat_trust_future_dynamic_iog_slots (default "planned") - will Octopus
  honour it? "none" trusts no dynamic slot; "completed" requires Octopus's own metered
  record; "started" also accepts a slot corroborated by car_charging_now; "planned" is
  the long-standing trust-everything behaviour. The fixed 23:30-05:30 window is never
  gated - it is cheap by tariff structure, not by dispatch.

Both defaults preserve today's behaviour, so an upgrade changes no plan until a user
opts in.

This reworks the design of the closed PR #4885 onto current main. That PR was rejected
on the grounds that distrusting a slot costs a house-battery discharge into the car
while waiting on a sensor, and that "it takes 5-10 minutes based on a sensor to figure
out if the car is really charging". Measured on a live system since: a watched
car_charging_now change reaches a register write in 17-40s (update_time_loop polls every
15s, so it does not wait for the 5-minute scheduled cycle), and the inverter acts on it
36-65s later - so under two minutes end to end from a locally reported charging state,
not 5-10. The second objection was false positives: inferring "the car is charging" from
a load signature could take a kettle for the car. Hence the sensor gate here - "started"
requires a real charger/car-reported car_charging_now for that car, and degrades to
"completed" per car without one, rather than guessing. #4948 has since landed the
dispatch-timeline diagnostic that PR also carried, so only the trust logic is new.

Two details worth knowing:

car_charging_now confirms a 30-minute settlement slot as a "trusted streak" rather than
per reading. get_arg() re-samples live state on each replan instead of consuming the edge
that woke it, so a charging pulse shorter than the wakeup-to-read latency can be missed
entirely rather than merely seen late. A guard-passing positive read therefore starts a
streak that rolls forward through following slots until an explicit False reading, which
also tolerates a charger toggling at a settlement boundary - observed live as a readiness
sensor flipping Charging->Ready->Charging within two minutes, negotiation noise rather
than a real stop. The guard applies only to streak starts, so a reading in a slot's last
few minutes cannot confirm the slot after it; a cap bounds how far a streak rolls without
fresh evidence, so a missed negative edge degrades to untrusted rather than silently
behaving as "planned". Both constants are judgement calls, not measured.

Rejecting a slot actively restores rate_max_base rather than merely declining to discount
it, because on a real Intelligent tariff fetch_octopus_rates() can deliver the discounted
rate directly from the feed. exclude_dynamic_io_slots() does the same for a feed-side
discount with no matching dispatch entry, consuming the trust decisions rate_add_io_slots()
just made so the two cannot disagree. Both touch only minutes from now onwards: an elapsed
minute records what the tariff actually charged, and rewriting it would inflate
today_cost() without changing a plan that never looks behind minutes_now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants