Skip to content

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

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

fix(octopus): don't rely on unconfirmed or unneeded IOG dispatch slots (#4482, #4516)#4885
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant