fix(octopus): don't rely on unconfirmed or unneeded IOG dispatch slots (#4482, #4516) - #4885
Open
chalfontchubby wants to merge 1 commit into
Open
fix(octopus): don't rely on unconfirmed or unneeded IOG dispatch slots (#4482, #4516)#4885chalfontchubby wants to merge 1 commit into
chalfontchubby wants to merge 1 commit into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 firstwould 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
mainas a single coherent change, with the interactionbetween 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 stillneed this?
load_octopus_slots()already capscar_charging_slotsat the car's real remainingrequirement when
octopus_intelligent_consider_fullis on, so a future block outside that set isone Octopus will reclaim once it notices the car stopped drawing. Requires
consider_fullto haveany effect, and warns at startup if enabled without it.
select.predbat_trust_future_dynamic_iog_slots(defaultplanned) — will Octopus honourit?
plannedtrusts every slot (the long-standing behaviour, so no plan changes on upgrade);completedrequires Octopus's own metered record;startedadditionally accepts the currentsettlement period when
car_charging_nowshows real draw;nonetrusts nothing outside the fixedwindow. 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_baserather than merely declining to add a discount,because for a genuine Intelligent tariff
fetch_octopus_rates()can deliver the dispatch-discountedrate directly before
rate_add_io_slots()runs.exclude_dynamic_io_slots()does the same for afeed-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 nowonwards — 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 theoctopus_slot_maxcap is left alone: Predbat is declining to count it, not asserting Octopuswithdrew 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— theobservability 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
useful, so either can be enabled alone.
Its premise was that
source: SMARTidentifies a plug-independent grid-flex event crowding outreal dispatches under the daily cap. On review that didn't hold:
SMARTpairs withBOOSTexactlyas
smart-chargepairs withbump-charge(two API vocabularies for the same ordinary dispatch);OCTOPUS_SLOT_MAX_DEFAULTis 48, so for a domestic IOG account there is no binding cap to crowdout; and Octopus free hours arrive on a separate path entirely (
octoplus_power_up_events→load_free_slot()at rate 0), never reachingrate_add_io_slots(). It can be revisited on its ownmerits 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— cleantest_dispatch_timeline.py,test_exclude_dynamic_io_slots.py,test_fetch_config_options.py, and extensive new cases intest_rate_add_io_slots.pycoveringall four trust levels, the car-need gate, their interaction, cap-only rejection, fixed-window
exemption, and the elapsed-minute guarantee
than via the charger, so the
limit_future_slotspath in particular would benefit from testingby someone on an EVSE-controlled setup
Closes #4482, closes #4516.
🤖 This PR was written by Claude Code on @chalfontchubby's behalf.