Repository navigation
feat(octopus): log a diagnostic IOG dispatch timeline (#4516 Stage 1) - #4948
Conversation
…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>
|
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:
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. |
|
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. 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. |
There was a problem hiding this comment.
🟡 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.
…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>
|
Reconsidered point 1 after checking the code more closely — it's cheaper than I gave it credit for. Implemented in 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. |
There was a problem hiding this comment.
🟢 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
|
@Speshman |
…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>
…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>
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]:.nothing known,Pplanned (provisional),Sstarted,Ccompleted. 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
nowadvances. So:Cis a slot that was deliveredpthat stops before reaching the now column is a slot Predbat planned around and lostThat 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
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.Testing
test_dispatch_timelinecovers 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.