feat(octopus): don't trust future IOG dispatch slots as cheap until confirmed (#4516) - #4528
feat(octopus): don't trust future IOG dispatch slots as cheap until confirmed (#4516)#4528chalfontchubby wants to merge 9 commits into
Conversation
…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>
|
I'm testing this branch as a IOG user with a Zappi charger integrated, and ideally I'd like to use
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:
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. |
|
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. |
|
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. |
|
It can’t be moved later but it might not happen - I would find myself unplugging before the slot happens
To me, it adds more complexity and risk.
Sent from Yahoo Mail for iPhone
On Sunday, August 16, 2026, 10:20 am, Sam Mort ***@***.***> wrote:
sammort left a comment (springfall2008/batpred#4528)
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.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you authored the thread.Message ID: ***@***.***>
|
|
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. |
|
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. |
…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>
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-unusedstarted_dispatchesattribute alongsideplanned/completed.Stage 2 (trust gating): new
select.predbat_trust_future_dynamic_iog_slots(expert mode, defaultnone):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 tocompletedbehaviour with a warning ifcar_charging_nowisn't configured).Trust is source-based (a
_confirmedtag threaded throughdecode_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 withrate_add_io_slots().car_charging_from_batteryis 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
startedlevel and its dependence oncar_charging_now.Test plan
./run_all --quick- full suite passes./run_pre_commit- cleantest_dispatch_timeline.py(Stage 1 render helper),test_exclude_dynamic_io_slots.py, and 7 new cases intest_rate_add_io_slots.pycovering all three trust levels🤖 Generated with Claude Code