Repository navigation
fix(octopus): don't rely on future IOG dispatch slots the car no longer needs - #4483
chalfontchubby wants to merge 5 commits into
Conversation
|
Can you review this against: #4237 |
|
Checked against #4237 - no overlap or conflict, they're on different pathways:
No file overlap either - #4237 only touches Functionally they're complementary rather than duplicative: this PR answers "should a future out-of-window slot be relied on for the house battery at all" (hard exclusion, gated on the car's real remaining SoC need), #4237 answers "given the slots that are being relied on, which within a run should be preferred first" (soft risk-decay ranking). A slot this PR excludes ( One thing I can't verify from code alone: whether Octopus's |
|
One possible interaction with #4237/the Octopus rate input path may need testing. When metric_octopus_import is used, fetch_octopus_rates() can receive a dispatch period whose value_inc_vat is already the Intelligent low rate and whose is_intelligent_adjusted flag is true. By the time rate_add_io_slots() runs, rates[minute] may therefore already be 3.99p. In that case, needed=False only prevents another low-rate assignment; it does not restore the already-adjusted rate to the normal out-of-window tariff. The block may consequently remain available to the optimiser. I observed this shape in the live Octopus integration: value_inc_vat: 0.03993 compared with the normal peak rate of 0.293222. Could a test be added where the input rate is already low and io_adjusted is true, asserting that an unneeded future out-of-window block returns to the firm tariff? The implementation may also need to clear or ignore the corresponding io_adjusted marker so #4237 does not continue treating the rejected block as an Intelligent window. The existing tests initialise all rates at 10p and rely on rate_add_io_slots() to reduce accepted blocks to 4p, so they do not currently cover this input path. |
|
Excellent catch, thank you - traced it through and you're right.
Proposed fix: when
Will add a test with rates pre-seeded low (mirroring your example) + |
|
I now have a live example confirming this input path and answering the restoration-target question. For tonight, Octopus currently advertises these dispatches:
The car's actual SoC is 2%. With However, the Octopus rate event already supplies that surplus block as: start: 2026-08-13T05:30:00+01:00 |
|
Pushed the restoration fix discussed above. A rejected slot (not needed, or the daily cap already reached) now restores New regression test simulates the exact scenario (rejected slot with Full suite + pre-commit clean. |
|
Thanks, this now addresses the live failure mode I reported: restoring every minute to rate_max_base and clearing every corresponding io_adjusted marker is exactly what the pre-adjusted 3.99p input requires. I noticed one possible side effect in the revised rejection path. The same destructive restoration also runs when the slot is rejected only because octopus_slot_max has been reached: if needed and slots_per_day[day_offset] < octopus_slot_max: That changes the previous cap behavior even when octopus_intelligent_limit_future_slots is Off. It could also overwrite a legitimate tariff rate, including a guaranteed fixed-window rate, despite the PR intending 23:30–05:30 to remain unaffected. Would it be safer to perform the active restore and io_adjusted clear only when needed is false? A slot rejected solely by the existing daily cap could retain the previous behavior unless its undiscounted source rate is known. A regression test with the feature switch Off and octopus_slot_max already reached would confirm whether this behavior is intentional. A capped slot within 23:30–05:30 may also be worth covering. I very much appreciate your work on this. |
|
Pushed 3b1fedac addressing both points. The restore scope: agreed, and confirmed the exact mechanism you flagged - Second thing, flagged separately by Rik while reviewing this: a zero-kWh dispatch entry (a plug-independent SMART grid-flex event Octopus schedules that delivers no energy to the car) was still consuming a slot of the 12-slot daily budget under the old counting, which could crowd out real charging dispatches later in the day even on a day the car never got close to 12 real slots. Added Thanks for reading the actual diff rather than just eyeballing the PR description - the cap-rejection path especially wasn't something we'd have caught without it. |
|
If you need anything else from me please just ask. I'm still quite new to all this so on a vertical learning curve 😁 |
|
Thanks @Speshman - I'm pretty new here myself. Subject to review from @springfall2008 now I think |
|
The main fix for #4482 still looks useful, but the branch is now well behind current main and newer changes affect the same Octopus slot code. Could this be rebased and retested? I think the zero-kWh exemption should be removed from this PR or handled separately. At present, any slot decoded as zero kWh can bypass both the EV-need check and the daily slot cap. That can include malformed or incomplete input, not only genuine SMART grid-flex events. The safer version would keep zero-kWh slots under the existing cap behaviour unless there is confirmed evidence that this exact event type always guarantees the cheap tariff. Once rebased, it would also be useful to test against the newer IOG cap-splitting code and the io_adjusted planning changes. |
…er needs For springfall2008#4482: Octopus grants daytime Intelligent Go dispatch slots based on its own assumption of what the car needs, since it can't see the car's real SoC - only Predbat can, via car_charging_soc/car_charging_limit. load_octopus_slots() already caps car_charging_slots[car_n] at the car's real remaining requirement when octopus_intelligent_consider_full is on, zeroing the kwh of any slot beyond that. But rate_add_io_slots() (which decides which dispatch slots the house battery can rely on as a cheap rate) never looked at that - it still treated every granted slot as low rate regardless of whether the car's own plan says it's still needed. If Predbat had already deferred house battery charging into one of those "surplus" slots, the slot could disappear once Octopus notices the car has stopped drawing, leaving the battery undercharged with no cheap window left to make it up. Adds octopus_intelligent_limit_future_slots (expert mode, default Off, requires octopus_intelligent_consider_full to have any effect - warns at startup otherwise): a *future* out-of-window dispatch slot only counts as low rate for the house battery while it falls within a 30-min block car_charging_slots still lists a positive kwh for. A slot already underway or completed is trusted regardless (only future need is gated), and the fixed 23:30-05:30 window is never affected, since it's guaranteed cheap by the tariff itself rather than the dispatch mechanism. Test coverage: 6 new rate_add_io_slots scenarios (partial-need-vs-full settlement period, car already full, current/completed dispatch exempted, switch off restores old behaviour, fixed window unaffected) plus a fetch_config_options warning test for the consider_full dependency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ing a new one Review follow-up on this PR (springfall2008#4483): for a genuine Octopus Intelligent tariff, fetch_octopus_rates() can receive a dispatch period whose rate is already the Intelligent low rate before rate_add_io_slots() ever runs - rate_replicate() only gap-fills minutes with no real fetched value, so it never touches one that's already there. When rate_add_io_slots() then decides a slot isn't needed (or the daily cap is already reached), `needed=False`/cap-exceeded only prevented adding a *new* low rate; it left whatever was already in rates[] untouched, so an already-discounted fetched rate stayed live even though the slot was just rejected. Confirmed live: a 05:30-06:00 dispatch Predbat correctly determined the car no longer needs, where Octopus's own event data already carried 3.99p (matching the dispatch event's own max_rate) instead of the tariff's ordinary 29.3p out-of-window rate. Now restores rates[minute] to rate_max_base for every minute of a rejected 30-min block (both branches of the existing minute%30==0 split, not just the slot-start minute), and clears self.io_adjusted for the same range - minute_data() (utils.py) sets it for every minute in an adjusted block, not just the first, so a partial clear would leave the plan believing an already-restored minute was still IOG-adjusted. New regression test simulates the exact scenario (a rejected slot with its rates[] pre-seeded as fetch_octopus_rates() would leave it), confirmed to fail without this fix and pass with it. Existing 23 rate_add_io_slots scenarios pass unchanged - their reject-path expectations already happened to equal rate_max_base in that fixture, so this only changes behaviour when rates[] holds something other than the baseline.
…mpt zero-kWh dispatches from the daily cap Two follow-ups from PR review on springfall2008#4483: 1. The active rate/io_adjusted restore added in the previous commit was reached by *any* rejection in rate_add_io_slots(), including a slot rejected purely because octopus_slot_max was already reached (needed stays True) - a pre-existing, unrelated mechanism that fires even with octopus_intelligent_limit_future_slots Off. That could overwrite a legitimate live rate Predbat was simply choosing not to count against its own daily budget, not one Octopus is known to have rescinded. The restore now only fires when needed is False (Speshman, PR springfall2008#4483 review). 2. A zero-kWh dispatch entry (e.g. a plug-independent SMART grid-flex event that delivers no energy to the car) is a real tariff discount, but not a car-charging dispatch, so by default it no longer competes for the octopus_slot_max budget or the springfall2008#4482 "does the car still need this" check - both model car-dispatch behaviour specifically. Gated behind a new switch, octopus_slot_count_zero_kwh (default Off = don't count them), so anyone who wants the old counting behaviour back can restore it. Adds 5 regression tests (25-29) covering both, including the two review specifically asked for (cap-only rejection with the feature Off, and a capped slot inside the fixed 23:30-05:30 window). All fail on the pre-fix code, pass with it. Full suite + pre-commit clean.
f140d75 to
79e1819
Compare
|
Rebased onto current Addressed the zero-kWh exemption concern: scoped Also checked against the newer IOG cap-splitting work (#4624/#4644, Not stacking on #4528 for now - going to chase that one to land separately, and nothing that's merged since duplicates it. Full |
decode_octopus_slot() silently coerces malformed/unparseable charge_in_kwh input to 0.0 - indistinguishable by value alone from a genuine zero-kWh SMART grid-flex event. Without this, a garbage entry with any source could bypass both the octopus_slot_max cap and the springfall2008#4482 need-check. springfall2008#4483 review follow-up, Speshman.
|
Correction: the zero-kWh scoping fix I described above wasn't actually committed when I posted that - it was still sitting in the working tree. Pushed now as d0100fb. |
|
Thank you for taking the time to rebase this and revisit the zero-kWh concern. I had another look at the current head ( I found three possible edge cases. I may be missing some context, so please treat these as review questions rather than firm conclusions:
There is also some overlap with #4528's dispatch-trust work, so it may be worth ensuring the eventual combined design does not let the zero-kWh path bypass that trust policy. None of this detracts from the main fix, which still looks useful, and I appreciate the care taken to address the earlier feedback and add the restoration tests. I would be happy to help test any follow-up if useful. |
|
I'm concerned that if we tweak this too much more it will get stale again - If it is better than what is there then I'd try to get it merged and fix issues in follow up rather than going round in circles again. I'm aware of the pending conflicts with 4528. I have the car connected to IOG rather than the charger so the planning has better visibility of the needed slots anyway - but the plan being based around slots when I might have had to go out in the car certainly bothers me. |
|
That sounds reasonable to me. I agree there is a risk of the branch becoming stale again, and I would not want the edge cases I raised to prevent a useful core improvement from landing. I would be comfortable with the partial-current-block and multi-car cases being recorded and handled as follow-ups, particularly as the latter does not affect my installation. My only remaining hesitation is the zero-kWh exemption because it changes existing behaviour by default, independently of Other than that, I agree that merging the improvement and iterating is preferable to allowing it to go stale again. Thank you again for engaging with all the review points. |
|
If I'm switching context back to this I might as well look at the other issues in stacked pr as well - starting with raising issues for the follow on points. |
Defaulting the zero-kWh exemption off (i.e. "exempt by default") silently loosened the octopus_slot_max cap for every household whose tariff sends zero-kWh SMART grid-flex events, not just those who wanted the exemption. It also meant a malformed/unparseable dispatch entry coerced to 0.0 by decode_octopus_slot() got the same free pass by default. Defaulting the switch on keeps prior cap-counting behaviour out of the box and makes the exemption strictly opt-in. springfall2008#4483 review follow-up, Speshman. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed 369cbfb: Agreed this is the minimal fix to land now. I'll raise separate issues for the three remaining points (malformed-SMART-input scoping, the rounded "already underway" boundary, and multi-car ordering) and pick them up as follow-up PRs stacked on this one - not holding this PR open for them. Thanks again both for the thorough review. |
|
Moving this to draft. This PR and #4528 address two halves of the same underlying problem, and both rewrite the same Parking this as draft so it's clear it isn't waiting on review in its current form. The review points 🤖 Written by Claude Code on @chalfontchubby's behalf. |
|
Closing in favour of #4885, which combines this with its sibling PR into a single squashed commit against current main. Both this and its counterpart rewrote the same decision point in Nothing from this branch is lost; it is superseded rather than abandoned. Posted by Claude on behalf of chalfontchubby. |
…ed input (#4810) * fix(octopus): scope the zero-kWh dispatch exemption to genuinely parsed input decode_octopus_slot() previously coerced any unparseable charge_in_kwh value to 0.0, indistinguishable by value alone from a real zero-kWh SMART grid-flex event. rate_add_io_slots()'s exemption from the octopus_slot_max cap and the #4482 need-check was scoped to source == "SMART" as a first pass, but a malformed entry can carry that source too. Threads a kwh_valid flag through decode_octopus_slot()'s return - True unless a present kwh value failed to parse as a number - and requires it in the exemption condition, closing the gap regardless of source. #4807, review follow-up on #4483 from Speshman. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(octopus): compare a dispatch's real start, not its rounded settlement block, for "already underway" rate_add_io_slots()'s exemption from the #4482 future-need check compared the 30-min-rounded slot_start against a similarly rounded current_block, so a dispatch starting partway through the current settlement period (e.g. 10:20, checked at 10:05) read as already-underway from the top of that period (10:00), before it had actually started. Captures the dispatch's real, unrounded start separately and compares that against minutes_now instead - current_block is no longer needed and is removed. #4808, review follow-up on #4483 from Speshman. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Trefor Southwell <48591903+springfall2008@users.noreply.github.com>
The trust gating added by this PR decided a 30-minute block from whichever dispatch entry the Octopus feed happened to list first, and whichever car fetch.py happened to loop over first. Both are arbitrary, and both could price a genuine dispatch at the day rate. Found by review on #5110 and by successive cold reviews of the fix itself. Entry order. Several entries can cover one block - Octopus lists a planned dispatch and, later, a completed record for it - and `_confirmed` is the only per-ENTRY input to `trusted`, since car_charging_now_confirmed_slot_starts is keyed on slot_start and therefore shared. slots_decided made the first entry visited decide the block permanently, and saved_slots then skipped the later entry's minutes before it could be consulted. So an unconfirmed planned entry rejected a block its own completed record proved had happened. Confirmation is now resolved across all entries before any is applied. Car order. rate_add_io_slots() runs once per car into one shared rates dict, and its reject path rewrites the rate AND drops the io_adjusted marker. The guard against undoing another car's dispatch read trusted_dynamic_minutes, which that same loop builds as it goes - so a rejecting car running first saw it empty, popped the marker, and nothing anywhere restores one. The rate was later restored but the marker was not, dropping the block out of plan.py's IOG skew and prediction.py's PV10 hedge. resolve_protected_dispatch_minutes() now resolves the shield across every car before the loop begins. One decision, one implementation. car_trusted_dispatch_blocks() is the single per-car answer to "which blocks does this car have a genuine dispatch for", used by both the shield and the loop's own gates. An earlier version of the shield re-derived a looser variant alongside the loop - it dropped the `needed` gate at "planned" and skipped the per-car degrade to "none" for a car with no real car_charging_now sensor - shielding blocks the loop itself rejects, which would have kept a rescindable slot cheap. That is the #4516 failure this PR exists to prevent. assumed_price reads rate_import_no_io, the snapshot fetch takes before the per-car loop, rather than the working rates dict this function mutates. On main nothing ever wrote rate_max_base into rates so the read-back was safe; this PR's restore path made it reachable, letting one car's rejection be read back by the next car as though it were the tariff price. trusted_dynamic_minutes records the whole block when `needed and trusted` held, regardless of the octopus_slot_max cap. The cap budgets what Predbat PLANS - it is documented per-car while the rates dict is install-wide - not what it believes the price to be. Gating the record on the cap meant exclude_dynamic_io_slots() stripped the very minutes the #4483 cap-only exemption had just protected, on any install whose feed delivers the discount. Test 39 asserted that bookkeeping directly and is rewritten to assert the user-visible outcome, with io_adjusted markers it previously lacked. Also corrects an end_minutes clamp that used forecast_minutes where its three siblings use forecast_minutes + minutes_now. forecast_minutes is a duration, so the clamp truncated roughly minutes_now of the horizon; pre-existing, but this PR added the consumer that makes it strip a real dispatch's discount. Tests 45-51 cover entry order, cap accounting under duplication, the tariff read-back, car-order equivalence including the io_adjusted marker, shield breadth in both directions, and the reject path against a discount distinguishable from rate_max_base - most existing tests run on a baseline equal to rate_max_base, so the restore was invisible to them. Each was confirmed to fail against the behaviour it pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #4482.
Summary
car_charging_soc/car_charging_limit.load_octopus_slots()already capscar_charging_slots[car_n]at the car's real remaining requirement whenoctopus_intelligent_consider_fullis on, zeroing the kwh of any slot beyond that - butrate_add_io_slots()(which decides which dispatch slots the house battery can rely on as a cheap rate) never looked at that, so it kept treating every granted slot as low rate regardless of whether the car's own plan still needed it.octopus_intelligent_limit_future_slots(expert mode, default Off, requiresoctopus_intelligent_consider_fullto have any effect - warns at startup otherwise): a future out-of-window dispatch slot only counts as low rate for the house battery while it falls within a 30-min blockcar_charging_slotsstill lists a positive kwh for. A slot already underway or completed is trusted regardless (only future need is gated), and the fixed 23:30-05:30 window is never affected, since it's guaranteed cheap by the tariff itself.Test plan
rate_add_io_slotsscenarios: partial-need-vs-full settlement period, car already full, current/completed dispatch exempted, switch off restores old behaviour, fixed window unaffectedfetch_config_optionswarning test for theconsider_fulldependency./run_all --quick./run_pre_commit