Skip to content

fix(octopus): don't rely on unconfirmed or unneeded IOG dispatch slots (#4482, #4516) - #4885

Closed
chalfontchubby wants to merge 1 commit into
mainfrom
fix/iog-dispatch-slot-trust-combined
Closed

chalfontchubby wants to merge 1 commit into
mainfrom
fix/iog-dispatch-slot-trust-combined

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Why this exists

This is an attempt to unblock a deadlock between two stale, interdependent PRs.

#4482 and #4516 are two halves of the same underlying problem, and the two PRs written for them both
rewrote the same decision point in rate_add_io_slots(). Each was reviewed and iterated separately,
each drifted further behind main, and they conflicted with each other — so whichever landed first
would have forced the other into a rebase that changed its behaviour. Neither could move, and the
open review threads on each were increasingly about merge mechanics rather than the fix.

This branch consolidates both onto current main as a single coherent change, with the interaction
between them actually decided rather than left to whichever merged last.

The problem

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 can't see the
car's real SoC. Predbat treated every granted slot as a guaranteed cheap rate for the house battery,
so it could make an irreversible decision — an early force-export, or deferring a charge — in
anticipation of a cheap window that then disappeared, leaving the battery undercharged with no cheap
window left to make it up.

What it does

Two independent gates gate a future out-of-window slot, and it must clear both:

  • switch.predbat_octopus_intelligent_limit_future_slots (default Off) — does the car still
    need this?
    load_octopus_slots() already caps car_charging_slots at the car's real remaining
    requirement when octopus_intelligent_consider_full is on, so a future block outside that set is
    one Octopus will reclaim once it notices the car stopped drawing. Requires consider_full to have
    any effect, and warns at startup if enabled without it.

  • select.predbat_trust_future_dynamic_iog_slots (default planned) — will Octopus honour
    it?
    planned trusts every slot (the long-standing behaviour, so no plan changes on upgrade);
    completed requires Octopus's own metered record; started additionally accepts the current
    settlement period when car_charging_now shows real draw; none trusts nothing outside the fixed
    window. Trust is source-based, never clock-based — a slot's start time passing is not evidence the
    car drew power, only that it was due to.

The fixed 23:30-05:30 window is never gated by either; it's guaranteed cheap by the tariff itself.

Rejecting a slot actively restores rate_max_base rather than merely declining to add a discount,
because for a genuine Intelligent tariff fetch_octopus_rates() can deliver the dispatch-discounted
rate directly before rate_add_io_slots() runs. 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 can never disagree. Both only touch minutes from now
onwards — an elapsed minute records what the tariff actually charged, and rewriting it would inflate
today_cost()'s reported spend without changing the plan. A slot rejected only by the
octopus_slot_max cap is left alone: Predbat is declining to count it, not asserting Octopus
withdrew it.

Also adds a per-car dispatch timeline logged once per 30-minute boundary, which makes slots being
moved or revoked visible as diagonal stripes in the log or an attached debug.yaml — the
observability that was missing when diagnosing this in the first place.

Both defaults preserve current behaviour, so nobody's plan changes unless they opt in.

Car charging forecasting is unaffected: future dispatch slots are still used in full to predict when
the EV will charge. Only whether they're trusted as cheap for the house battery changes.

Notable decisions

  • The two gates compose as AND. They answer genuinely different questions and are independently
    useful, so either can be enabled alone.
  • A zero-kWh dispatch exemption that had been developed on one of the two PRs is not included.
    Its premise was that source: SMART identifies a plug-independent grid-flex event crowding out
    real dispatches under the daily cap. On review that didn't hold: SMART pairs with BOOST exactly
    as smart-charge pairs with bump-charge (two API vocabularies for the same ordinary dispatch);
    OCTOPUS_SLOT_MAX_DEFAULT is 48, so for a domestic IOG account there is no binding cap to crowd
    out; and Octopus free hours arrive on a separate path entirely (octoplus_power_up_events →
    load_free_slot() at rate 0), never reaching rate_add_io_slots(). It can be revisited on its own
    merits if real dispatch data shows genuine zero-kWh entries carrying a cheap rate with no car draw.

Test plan

  • ./run_all --quick — full suite passes
  • ./run_pre_commit — clean
  • New/updated unit tests: test_dispatch_timeline.py, test_exclude_dynamic_io_slots.py,
    test_fetch_config_options.py, and extensive new cases in test_rate_add_io_slots.py covering
    all four trust levels, the car-need gate, their interaction, cap-only rejection, fixed-window
    exemption, and the elapsed-minute guarantee
  • Real-world dogfooding on a live IOG setup — I'm on IOG with the car connected directly rather
    than via the charger, so the limit_future_slots path in particular would benefit from testing
    by someone on an EVSE-controlled setup

Closes #4482, closes #4516.


🤖 This PR was written by Claude Code on @chalfontchubby's behalf.

…ts (#4482, #4516)

Octopus Intelligent daytime dispatch slots are provisional. They can be moved or
withdrawn before they happen, and Octopus allocates them from its own assumption
of what the car needs, since it can't see the car's real SoC. Predbat treated
every granted slot as a guaranteed cheap rate for the house battery, so it could
make an irreversible decision - an early force-export, or deferring a charge -
in anticipation of a cheap window that then disappeared, leaving the battery
undercharged with no way to make it up.

Supersedes PRs #4483 and #4528, which addressed the two halves separately and
conflicted with each other in rate_add_io_slots().

Two independent gates now guard a future out-of-window slot, and it must clear
both to be treated as cheap for the house battery:

- switch.predbat_octopus_intelligent_limit_future_slots (default Off) - "does
  the car still need this?" load_octopus_slots() already caps car_charging_slots
  at the car's real remaining requirement when octopus_intelligent_consider_full
  is on, so a future block outside that set is one Octopus will reclaim once it
  notices the car has stopped drawing. Requires consider_full to do anything, and
  warns at startup if enabled without it.

- select.predbat_trust_future_dynamic_iog_slots (default planned) - "will
  Octopus honour this?" Graduated: planned trusts every slot (the long-standing
  behaviour, so nobody's plan changes on upgrade), completed requires Octopus's
  own metered record, started additionally accepts the current settlement period
  when car_charging_now shows real draw, none trusts nothing outside the fixed
  window. Trust is source-based, never clock-based: a slot's start time passing
  is not evidence the car drew power, only that it was due to.

The fixed 23:30-05:30 window is never gated by either - it's guaranteed cheap by
the tariff itself, not the dispatch mechanism.

Rejecting a slot actively restores rate_max_base rather than merely declining to
add a discount, because for a genuine Intelligent tariff fetch_octopus_rates()
can deliver the dispatch-discounted rate directly, before rate_add_io_slots()
runs. 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 can never disagree. Both only ever touch minutes from now
onwards: an elapsed minute records what the tariff actually charged, and
rewriting it would inflate today_cost()'s reported spend without changing the
plan. A slot rejected only by the octopus_slot_max cap is left alone - Predbat is
declining to count it, not asserting Octopus withdrew it.

Also adds a per-car dispatch timeline logged once per 30-minute boundary, which
makes slots being moved or revoked visible as diagonal stripes in the log or an
attached debug.yaml - the observability that was missing when diagnosing this.

Car charging forecasting is unaffected: future dispatch slots are still used in
full to predict when the EV will charge. Only whether they are trusted as cheap
for the house battery changes.

Closes #4482, #4516.

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

Speshman commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Thanks for consolidating this. I have an EVSE-controlled IOG setup: Octopus Intelligent controls a Zappi V2 charging a single BMW 225xe. Predbat receives the car’s actual SoC, and I have octopus_intelligent_consider_full enabled, so my setup exercises the limit_future_slots path described here.

My live example on #4483 was not a slot remaining after Octopus withdrew it. It showed future provisional daytime slots that the car clearly did not need: the actual car SoC meant Predbat forecast zero EV demand in those slots, yet it still used their discounted rates for house-battery planning even though they were likely to be withdrawn.

I’m unlikely to have time to reproduce and test this again for about a week.

The remaining areas worth checking before this change is relied upon are the partial-current-block started case, DST handling of the fixed window, and safe restoration of source-provided rates.

Drafted with assistance from OpenAI GPT-5.6 Sol.

@springfall2008

Copy link
Copy Markdown
Owner

I've been around in circles on this and I think its the wrong move.

Most IOG planned dispatches become low rate slots, some don't graduate to completed but are still billed as low rate.

If you don't trust the slots you end up discharging your home battery into the car, and it takes 5-10 minutes based on a sensor to figure out if the car is really charging.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Ok. I'm pretty sure I've seen the house charge in an IOG slot that didn't work out due to the car deciding it wasn't plugged in, and my bill seemed to show full price on the bill. Not sure I'll be able to find that again, but I'll be keeping a really close eye on it now.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Checking recent bill, I accept I can't see an instance of high usage during peak pricing.

@PlainSeer

Copy link
Copy Markdown
Contributor

I've been around in circles on this and I think its the wrong move.

Most IOG planned dispatches become low rate slots, some don't graduate to completed but are still billed as low rate.

If you don't trust the slots you end up discharging your home battery into the car, and it takes 5-10 minutes based on a sensor to figure out if the car is really charging.

I’m not sure that’s always true.

Octopus’ current wording seems to say the household only gets the cheap rate outside 23:30–05:30 when the EV actually smart-charges in that half-hour. If a planned slot is withdrawn, or the car never actually starts charging, the house shouldn’t automatically get the cheap rate just because it was previously in planned_dispatches.

I can believe that some genuine charging sessions don’t always “graduate” cleanly into completed_dispatches in the API, so I agree completed_dispatches alone may not be a reliable test.

But I think there’s still a distinction between:

  • a slot where the EV really started charging but the API didn’t record completion properly, and
  • a provisional planned slot where the EV never actually charged.

The second case matters because PredBat can plan around those future cheap slots before they are actually earned.

For example, if Octopus shows a provisional 14:00–15:00 cheap slot, PredBat may decide not to charge the house battery earlier because it expects cheap import at 14:00. If that slot is later withdrawn or the EV never starts charging, the expected cheap period never happens and the earlier plan was based on a rate that was never actually available.

That’s the bit I’m worried about — not genuine charging sessions missing from completed_dispatches, but future provisional slots being treated as guaranteed cheap before they’ve actually been earned.

@Speshman

Speshman commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

My concern is narrower than the wider planned_dispatches versus completed_dispatches discussion.

In my EVSE-controlled setup, Octopus can publish future daytime slots that the car will not need according to its actual SoC. Predbat may then use those provisional cheap slots when planning the house battery. If those slots are subsequently withdrawn, the house-battery plan has relied on cheap periods that are no longer available.

Avoiding irreversible house-battery decisions based on provisional future slots is the issue I was trying to raise. I don’t have the expertise or billing evidence to suggest how every planned or completed dispatch should be classified, so I’ll leave the implementation details to those who understand that area better.

Drafted with assistance from OpenAI GPT-5.6 Sol.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, working with @chalfontchubby.)

Reworked and reopened as #5110, on current main.

The reason this one is worth another look rather than a repeat of the same argument: the latency figure this was closed on turned out to be wrong, and it's now been measured rather than estimated. On a live system, a car_charging_now change reaches a register write in 17-40s and the inverter acts on it 36-65s later — under two minutes end to end, not 5-10. The 5-minute assumption comes from the scheduled run_time_loop cycle, but a watched entity doesn't wait for it: update_time_loop polls update_pending every 15 seconds and replans out of cycle. I had that wrong too until I went and measured it.

The false-positive concern (a kettle being mistaken for the car) is handled by requiring a real car_charging_now sensor for the started level — a charger/car-reported signal, never inferred from load — and degrading to completed per car without one.

Defaults are unchanged in #5110, so nobody's plan moves unless they opt in.

Leaving this closed for the history — the design discussion here is still the best record of how the two-gate shape was arrived at.

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

4 participants