Skip to content

feat(octopus): don't trust future IOG dispatch slots as cheap until confirmed (#4516) - #4528

Draft
chalfontchubby wants to merge 9 commits into
mainfrom
feat/iog-dispatch-timeline-diagnostic
Draft

feat(octopus): don't trust future IOG dispatch slots as cheap until confirmed (#4516)#4528
chalfontchubby wants to merge 9 commits into
mainfrom
feat/iog-dispatch-timeline-diagnostic

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Summary

Addresses #4516: Octopus Intelligent Go daytime dispatch slots are still Octopus's own provisional plan until they actually happen - they can be moved or revoked, which could otherwise lead Predbat to make an irreversible battery decision (e.g. an early force-export) in anticipation of a cheap recharge that never occurs.

  • Stage 1 (diagnostic): logs a compact per-car IOG dispatch timeline once per 30-minute boundary (build_dispatch_timeline()), riding along in the same log/debug.yaml users already attach to bug reports. Reads the previously-unused started_dispatches attribute alongside planned/completed.

  • Stage 2 (trust gating): new select.predbat_trust_future_dynamic_iog_slots (expert mode, default none):

    • none - only the fixed 23:30-05:30 window is trusted as cheap for house battery planning.
    • completed - also trusts a daytime dispatch once Octopus reports it as completed (metered).
    • started - also trusts the current 30-minute block of a still-provisional dispatch when car_charging_now confirms the car is actually charging right now (falls back to completed behaviour with a warning if car_charging_now isn't configured).

    Trust is source-based (a _confirmed tag threaded through decode_octopus_slot()), not clock-time-based - a slot's start time passing is not itself confirmation, since Octopus can revoke a dispatch that's technically started but where the car never drew power.

  • Also closes a couple of related gaps found along the way:

    • exclude_dynamic_io_slots() now consults the same trust decision (self.trusted_dynamic_minutes) so the rate-feed-driven discount (io_adjusted) can't disagree with rate_add_io_slots().
    • Removed a stale docs claim that car_charging_from_battery is auto-set On (the code was already fixed for this in an earlier PR; only the docs hadn't caught up).

Doesn't touch car charging forecasting itself - future dispatch slots are still used in full to predict EV charging, regardless of this setting; only whether they're trusted as cheap for the house battery plan is affected. Doesn't rewrite historical cost reporting either - today_cost()'s "actual cost so far" figures reflect what genuinely happened, unaffected by this setting.

Posting as a draft for visibility/feedback given the interest on the issue - not claiming this is the final design, especially around the started level and its dependence on car_charging_now.

Test plan

  • ./run_all --quick - full suite passes
  • ./run_pre_commit - clean
  • New unit tests: test_dispatch_timeline.py (Stage 1 render helper), test_exclude_dynamic_io_slots.py, and 7 new cases in test_rate_add_io_slots.py covering all three trust levels
  • Real-world dogfooding on my own Octopus IOG setup (ongoing)

🤖 Generated with Claude Code

chalfontchubby and others added 7 commits August 14, 2026 21:13
…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.
…nal IOG slots from battery planning (#4516)

A future daytime Octopus Intelligent dispatch slot (outside the fixed
23:30-05:30 off-peak window) is still Octopus's own provisional plan - it can
be moved or withdrawn before it happens. Predbat previously treated it exactly
the same as a confirmed/completed one when setting the house import rate, so
it could commit the battery to a decision (e.g. an early force-export) in
anticipation of a cheap recharge that never occurs.

Adds a new expert-mode switch, trust_future_dynamic_iog_slots (default Off):
with it off, rate_add_io_slots() only trusts a dispatch slot as cheap for
house battery planning if it's inside the fixed IOG window
(minute_in_iog_fixed_window(), reintroduced from OCTOPUS_NIGHT_RATE_WINDOWS -
already used elsewhere for the same tariff's day/night rate selection, not
new "magic" numbers). Car charging forecasting is unaffected - octopus_slots
still flows into load_octopus_slots()/car_charging_slots exactly as before;
only whether a slot is trusted as a cheap rate for the *battery* plan changes.

Also adds exclude_dynamic_io_slots(), a separate post-pass called once after
the per-car rate_add_io_slots() loop: rate_add_io_slots() only stops itself
adding a new dynamic-slot discount, but for a genuine Intelligent tariff
fetch_octopus_rates() can already receive the dispatch-discounted rate
directly from the rate feed (marked via self.io_adjusted), independently of
the octopus_slots dispatch-list mechanism. Without this second pass the
switch would be silently defeated for exactly the installs it matters most
for. Restores rate_max_base and clears the io_adjusted marker so plan.py's
existing future-slot risk penalty doesn't still treat the minute as adjusted.

14 new tests (3 in test_rate_add_io_slots.py, 6 in the new
test_exclude_dynamic_io_slots.py) plus the 17 pre-existing rate_add_io_slots
tests updated to explicitly trust dynamic slots where that's what they're
actually testing. All new tests confirmed to fail on the pre-fix code and
pass with it. Full suite + pre-commit clean.
…rdless of the switch

Follow-up to the trust_future_dynamic_iog_slots switch: rate_add_io_slots()
and exclude_dynamic_io_slots() gated every out-of-window dynamic slot
identically, past or future. But today_cost() (output.py) computes today's
actual spend - both house and per-car breakdown - by multiplying real
historical import/car energy against self.rate_import for every already-
elapsed minute today. With the switch off, a genuine dynamic dispatch that
had already happened today would get excluded from the rate calculation,
making the actual-spend figures wrongly report it at full price - #4516's
rescission risk only applies to a slot that hasn't happened yet.

Both functions now also trust any slot at or before the current 30-min
settlement period (current_block = (minutes_now // 30) * 30), regardless of
the switch or fixed-window check - once a slot has started or completed, the
risk it existed to guard against has already resolved one way or the other.

2 new tests (one per function), each confirmed to fail on the pre-fix code
and pass with it. Full suite + pre-commit clean.
…t touch past cost reporting

Spell out that the switch only ever withholds trust from a slot that hasn't
happened yet - a started/completed dispatch is always trusted regardless -
and why: today's actual-spend figures (house and car) are calculated from
the same rates, so this never rewrites what genuinely already happened.
… auto-set On

f3ddd50 (#4246/#4264) already fixed the code so battery-discharge-into-car
protection applies regardless of car_energy_reported_load - its own default
(Off) already prevents this unconditionally. This paragraph was never
updated to match and still claimed the opposite (that car_energy_reported_load
Off would auto-set car_charging_from_battery On because the circuits were
assumed separate). Confirmed no such auto-set exists anywhere in the code
(execute.py and prediction.py both gate discharge-hold on car_charging_from_battery
alone, explicitly documented as independent of car_energy_reported_load).
…#4516)

Clock time alone isn't proof a dynamic IOG slot actually happened - Octopus
can still revoke it right up until it's confirmed, so 237a03c's "trust it
once its start time has passed" carve-out was wrong and is superseded here.

Trust is now source-based: decode_octopus_slot() threads a _confirmed tag
(set on completed_dispatches merge, or on the car_charging_now synthetic
slot) through to rate_add_io_slots(), which now supports three levels:
- none: never trust a dynamic (outside the fixed 23:30-05:30) slot
- completed: trust once Octopus reports it as completed_dispatches
- started: also trust the current 30-min block when car_charging_now is
  true, since waiting for "completed" can be too slow to act on

exclude_dynamic_io_slots() now consults self.trusted_dynamic_minutes (built
by rate_add_io_slots()) instead of re-deriving trust from clock time, so the
two functions can't disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ay select (#4516)

The switch became a none/completed/started select. Documents the started
level's dependence on car_charging_now and the fallback-to-completed
warning behaviour, and clarifies that none of the levels trust a slot on
clock time alone - only the fixed window is unconditionally trusted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sammort

sammort commented Aug 15, 2026

Copy link
Copy Markdown

I'm testing this branch as a IOG user with a Zappi charger integrated, and ideally I'd like to use car_charging_now as the trust gate. I found something that may be problematic with car_charging_now mentioned in the docs (this is not to do with the change made here but with the option in general):

If Octopus Intelligent Charging is enabled and car_charging_now indicates the car is charging then Predbat will also assume that this is a low rate slot for the car/house (and might therefore start charging the battery), otherwise electricity import rates are taken from the normal rate data.

A problem I can see with this logic, is the phased implementation of the IOG 6hour charging cap. Once this cap is in-place for a user, it is possible that they:

  • Have Intelligent Charging Enabled
  • Have setup car_charging_now as a dispatch trust gate
  • Want to charge over the 6 hour cap
    • e.g. they have a big drive and need to reach 100% battery capacity from a very low starting SoC of the car

In this scenario, Predbat should not assume that this is a low rate for the house, as that previous connection between car charging and low rates has been removed.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Agreed, car_charging_now is perhaps required but not sufficient to indicate cheap power for the house if in the case of boost charging, manual intervention, or ghosts in the machine.

@sammort

sammort commented Aug 16, 2026

Copy link
Copy Markdown

I ran this overnight with some planned chargiles which moved around.

One quick thought, I had a slot planned at the last half-hour that takes me up until my target charge time, which seems to be the case quite regularly.

I think if there is a planned dispatch at the end of the charge window, it should be trusted for planning, as it cannot move later.

@chalfontchubby

chalfontchubby commented Aug 16, 2026 via email

Copy link
Copy Markdown
Collaborator Author

@nickgee31

Copy link
Copy Markdown
Contributor

Is there going to be an option to keep it as it is now? As I quite like that my battery will export before a daytime slot to be honest.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

I'll make sure there is an unconditional trust mode - either for planning (export in advance on faith) and for import (import in planned slots whether we have indications they are confirmed or not).

You've maybe been bitten less often than some of us.

chalfontchubby and others added 2 commits August 16, 2026 17:57
…med dispatch (#4516)

add_now_to_octopus_slot() tagged its synthetic slot _confirmed=True
unconditionally, on the reasoning that a live "car is charging now" read is
a real-time confirmed draw. In practice car_charging_now is a single sensor
read with no debounce - a charger's readiness sensor can blip on/off for
reasons unrelated to a genuine charging session - so this let one noisy
reading get trusted as a cheap house import rate under the "completed" and
"none" trust levels too, both of which exist specifically to require more
than a live guess before trusting a dynamic slot.

Confirmed live while dogfooding: a flapping car_charging_now signal caused
repeated synthetic slots, each briefly making rate_add_io_slots() write an
assumed-cheap rate into the house's own import rate array, independent of
any genuine Octopus dispatch - not just the discharge-hold oscillation this
same flapping also causes downstream in execute.py, but the house battery
opportunistically charging on a fabricated cheap-rate signal.

Leave the tag False and let rate_add_io_slots()'s existing "started" level
re-check car_charging_now live for the current settlement block only -
that's the level that deliberately accepts a live reading as confirmation,
not every level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iour as an opt-in (#4516)

Requested on #4528: some users prefer the house battery to plan ahead of a
still-provisional daytime dispatch (e.g. exporting in anticipation of it)
over waiting for any confirmation, and are comfortable with the plan
occasionally acting on a slot Octopus later moves or withdraws - exactly
the behaviour Predbat had before trust_future_dynamic_iog_slots existed.

Adds it as a fourth, explicit option alongside none/completed/started
rather than reverting to unconditional trust for everyone: trusts every
dynamic slot the moment it's planned, no confirmation required. Still
excluded from car_charging_now-sourced synthetic slots, which stay
_confirmed=False regardless of this setting (fixed separately) - "planned"
widens trust for genuine Octopus-reported dispatches, not for a single live
sensor reading.

Co-Authored-By: Claude Sonnet 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.

3 participants