Repository navigation
fix(octopus): don't rely on unconfirmed or unneeded IOG dispatch slots (#4482, #4516) - #4885
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>
|
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 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 Drafted with assistance from OpenAI GPT-5.6 Sol. |
|
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. |
|
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. |
|
Checking recent bill, I accept I can't see an instance of high usage during peak pricing. |
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 I can believe that some genuine charging sessions don’t always “graduate” cleanly into But I think there’s still a distinction between:
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 |
|
My concern is narrower than the wider 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. |
|
(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 The false-positive concern (a kettle being mistaken for the car) is handled by requiring a real 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. |
…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 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.