Skip to content

fix(octopus): don't rely on future IOG dispatch slots the car no longer needs - #4483

Closed
chalfontchubby wants to merge 5 commits into
springfall2008:mainfrom
chalfontchubby:fix/iog-limit-future-slots-by-ev-soc
Closed

chalfontchubby wants to merge 5 commits into
springfall2008:mainfrom
chalfontchubby:fix/iog-limit-future-slots-by-ev-soc

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Fixes #4482.

Summary

  • 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, so it kept treating every granted slot as low rate regardless of whether the car's own plan still needed it.
  • 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.

Test plan

  • 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
  • fetch_config_options warning test for the consider_full dependency
  • ./run_all --quick
  • ./run_pre_commit

@springfall2008

Copy link
Copy Markdown
Owner

Can you review this against: #4237

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Checked against #4237 - no overlap or conflict, they're on different pathways:

  • feat: strengthen Octopus Intelligent earlier-charge skew #4237 operates on self.io_adjusted, which is populated by fetch_octopus_rates() from Octopus's own rate-API is_intelligent_adjusted attribute across the whole import/export rate curve (octopus.py:2825, unrelated to any specific car). It's consumed downstream in plan.py's sort_window_by_price_combined() as a soft pence-level pricing gradient across a contiguous run of already-built charge/export windows.
  • This PR (fix(octopus): don't rely on future IOG dispatch slots the car no longer needs #4483) operates on rate_add_io_slots(), which works from the explicit per-car dispatch slot list (octopus_slots/car_charging_slots) and decides, upstream, whether a future slot's low assumed_price gets written into rates[] at all. Nothing in it touches self.io_adjusted.

No file overlap either - #4237 only touches plan.py + its own new test module; this PR touches config.py/fetch.py/octopus.py/predbat.py + existing tests. Safe to merge in either order.

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 (needed=False) just carries its normal, non-discounted rate into #4237's sort stage, so #4237's few-pence gradient has negligible effect on it either way - it isn't priced as cheap in the first place.

One thing I can't verify from code alone: whether Octopus's is_intelligent_adjusted rate-curve flag (#4237's input) always lines up 1:1 with the specific per-car dispatch slots this PR reads (octopus_slots), or whether they can diverge in practice. Not something either PR depends on for correctness, but worth knowing if there's a real-world case where they disagree.

@Speshman

Copy link
Copy Markdown
Contributor

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
is_intelligent_adjusted: true

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.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Excellent catch, thank you - traced it through and you're right.

fetch.py:978 runs rate_replicate(import_rates, self.io_adjusted, ...) before rate_add_io_slots() ever sees the data (fetch.py:981), and rate_replicate() only gap-fills minutes that are missing - it doesn't touch a minute that already has a real fetched value. So for a genuine Octopus Intelligent tariff, value_inc_vat can arrive already at the dispatch-discounted rate (your 3.99p example) for a period flagged is_intelligent_adjusted, before rate_add_io_slots() runs at all.

needed=False in this PR only skips the write (rates[minute] = assumed_price) - it was never built to undo an already-low rate, because on main today rate_add_io_slots() has no "reject" concept at all: it only ever adds a low rate for a slot it decides is valid, it never had to make one not low. This PR is the first thing that introduces that need, and I only tested it against fixtures that start every rate flat at 10p (so rate_add_io_slots() itself is always the one making a block cheap) - exactly the gap you spotted, not something the existing tests could have caught.

Proposed fix: when needed=False and the incoming rate at that slot is already below the normal tariff, restore it to self.rate_max_base (fetch.py:975 - the day's true peak rate, captured before any IO/saving-session/override distortion) rather than leaving it untouched. Also clear self.io_adjusted[slot_start] for the rejected block at the same time, so a future consumer of that flag (e.g. #4237's pricing gradient, once merged) doesn't keep treating a now-firm-tariff block as an Intelligent window.

rate_max_base is the closest existing signal I can find, but it's an approximation - the day's overall max, not necessarily that specific half-hour's true undiscounted rate. Since you've clearly got real API responses to hand: is there a better signal I should be restoring to instead (e.g. a standard/non-dispatch rate reported alongside the adjusted one), or is rate_max_base close enough in practice for how Octopus structures IOG tariffs?

Will add a test with rates pre-seeded low (mirroring your example) + io_adjusted=True, asserting an unneeded future block returns to the firm tariff, once the restore target is confirmed.

@Speshman

Speshman commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

I now have a live example confirming this input path and answering the restoration-target question.

For tonight, Octopus currently advertises these dispatches:

  • 23:00-23:30: 1.753 kWh
  • 00:30-04:30: 14.024 kWh
  • 05:30-06:00: 0.5965 kWh

The car's actual SoC is 2%. With octopus_intelligent_consider_full enabled, Predbat forecasts it reaching 100% at approximately 03:15. Accordingly, Predbat assigns zero EV demand to the future 05:30-06:00 dispatch.

However, the Octopus rate event already supplies that surplus block as:

start: 2026-08-13T05:30:00+01:00
end: 2026-08-13T06:00:00+01:00
value_inc_vat: 0.03993
is_intelligent_adjusted: true
Predbat therefore still publishes 05:30-06:00 at 3.99p despite forecasting no EV demand in it. This demonstrates that needed=False must actively undo the adjusted input rate rather than merely skip writing another low rate.
Regarding the restoration value: the Octopus event does not provide an undiscounted value alongside an adjusted row. On this fixed IOG tariff, every ordinary out-of-window row is 0.293222 GBP/kWh and the event's max_rate is also 0.293222. Therefore, rate_max_base appears to be the appropriate conservative restoration target here.
One implementation detail: minute_data() populates self.io_adjusted for every minute covered by the adjusted period, not just slot_start (utils.py:598-603). The rejection path should therefore restore and clear every minute in the rejected block, for example while iterating it:
rates[minute] = self.rate_max_base
self.io_adjusted.pop(minute, None)
Clearing only self.io_adjusted[slot_start] would leave the other 29 minutes marked as Intelligent-adjusted.
This installation is currently running unpatched code, so this is evidence of the real input shape and current failure mode rather than a test of the revised PR implementation. I can also report whether Octopus later withdraws the surplus 05:30-06:00 dispatch after the car finishes charging.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Pushed the restoration fix discussed above.

A rejected slot (not needed, or the daily cap already reached) now restores rates[minute] to rate_max_base for every minute of the block, rather than just skipping the addition of a new low rate - previously it left whatever fetch_octopus_rates() had already put there untouched, which for a genuine Intelligent tariff can already be the dispatch-discounted rate. Also clears self.io_adjusted across the same range, not just slot_start - minute_data() sets it for every minute in an adjusted block, so a partial clear would leave the plan believing an already-restored minute was still IOG-adjusted.

New regression test simulates the exact scenario (rejected slot with rates[] pre-seeded as fetch_octopus_rates() would leave it) - confirmed it fails without this commit and passes with it. All 23 existing scenarios pass unchanged.

Full suite + pre-commit clean.

@Speshman

Speshman commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

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:
...
else:
rates[minute] = self.rate_max_base
self.io_adjusted.pop(minute, None)

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.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Pushed 3b1fedac addressing both points.

The restore scope: agreed, and confirmed the exact mechanism you flagged - needed stays True even with octopus_intelligent_limit_future_slots Off, since the daily octopus_slot_max cap is a pre-existing, unrelated mechanism. The destructive restore now only fires when needed is False; a slot rejected purely because the cap is already spent is left exactly as fetch_octopus_rates() set it, same as before this PR. Added the two tests you asked for - cap-only rejection with the feature Off, and one inside the fixed 23:30-05:30 window - both fail on the pre-fix code, pass now.

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 octopus_slot_count_zero_kwh (default Off) so these are now exempt from both the cap and the needed check entirely - a real tariff discount, but not something the car-dispatch cap should be modelling in the first place. On (legacy) restores the old counting.

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.

@Speshman

Copy link
Copy Markdown
Contributor

If you need anything else from me please just ask. I'm still quite new to all this so on a vertical learning curve 😁

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Thanks @Speshman - I'm pretty new here myself. Subject to review from @springfall2008 now I think

@Speshman

Copy link
Copy Markdown
Contributor

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.

chalfontchubby and others added 3 commits August 28, 2026 09:46
…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.
@chalfontchubby
chalfontchubby force-pushed the fix/iog-limit-future-slots-by-ev-soc branch from f140d75 to 79e1819 Compare August 28, 2026 09:16
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (force-pushed) - 3 clean commits, no more merge noise. Verified rate_add_io_slots() itself was untouched on main since this branch diverged, so the rebase was purely mechanical (renamed a test-numbering collision with #4545's own new "Test 14" in test_fetch_config_options.py to avoid duplicate numbering, nothing functional).

Addressed the zero-kWh exemption concern: scoped zero_kwh_exempt to also require source == "SMART", not just kwh <= 0. decode_octopus_slot() silently coerces malformed/unparseable charge_in_kwh to 0.0 - indistinguishable by value alone from a genuine zero-kWh SMART event - so a garbage entry with any other source can no longer slip past both the cap and the #4482 need-check. Added test30_zero_kwh_non_smart_source_not_exempt, confirmed it fails without the scoping and passes with it.

Also checked against the newer IOG cap-splitting work (#4624/#4644, load_octopus_slots()) and the io_adjusted planning changes (#4237) - neither touches rate_add_io_slots(), confirmed by a clean auto-merge with no overlap, and test_octopus_slots.py's cap-split tests still pass unchanged.

Not stacking on #4528 for now - going to chase that one to land separately, and nothing that's merged since duplicates it.

Full rate_add_io_slots/fetch_config_options/octopus_slots/octopus_misc/octopus_free test modules pass; run_pre_commit clean.

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.
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

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.

@Speshman

Copy link
Copy Markdown
Contributor

Thank you for taking the time to rebase this and revisit the zero-kWh concern. I had another look at the current head (d0100fb6). The rebase and compatibility concerns appear resolved, and the core restoration behaviour still looks valuable.

I found three possible edge cases. I may be missing some context, so please treat these as review questions rather than firm conclusions:

  1. decode_octopus_slot() converts an invalid or unparseable charge_in_kwh value to 0.0. The new kwh <= 0 and source == "SMART" condition would therefore still exempt malformed input when its source is also SMART. Test 30 covers numeric zero with a non-SMART source, but not malformed SMART input. Would it be safer to preserve whether parsing succeeded, require an exact genuine zero, or separate this exemption from this PR?

  2. The "already underway" check appears to use the rounded settlement-period start. For example, at 10:05 a dispatch beginning at 10:20 is rounded to 10:00, making slot_start <= current_block true even though the dispatch has not started. If the car no longer needs it, that future dispatch would still be trusted. Could the original dispatch start time be used for this check, with a regression test for a mid-period future start?

  3. Rates and io_adjusted are shared while rate_add_io_slots() is called separately for each car. With overlapping multi-car dispatches, a later car that does not need the block could restore rate_max_base and clear io_adjusted after another car accepted the same block. This probably does not affect most single-car installations, but the result appears dependent on car processing order.

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.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

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.

@Speshman

Copy link
Copy Markdown
Contributor

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 octopus_intelligent_limit_future_slots. As a minimal way to keep this PR tightly scoped, perhaps octopus_slot_count_zero_kwh could default to True, preserving the previous cap behaviour, or the exemption could be deferred to a separate follow-up.

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.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

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.
Note I can't merge here - needs @springfall2008's blessing.

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>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Pushed 369cbfb: octopus_slot_count_zero_kwh now defaults to True, so the zero-kWh exemption is opt-in rather than a silent change to everyone's cap behaviour on upgrade. Tests 27-30 updated to explicitly set the switch state they're exercising rather than relying on which value happened to be the default; test 29 now documents the actual default path.

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.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Moving this to draft.

This PR and #4528 address two halves of the same underlying problem, and both rewrite the same
decision point in rate_add_io_slots(). They conflict with each other, so whichever landed first
would have forced the other into a rebase that changed its behaviour — and in the meantime both have
drifted well behind main. Rather than keep rebasing them against each other, I'm reworking the
approach so the interaction between the two is decided deliberately instead of by merge order.

Parking this as draft so it's clear it isn't waiting on review in its current form. The review points
raised here are not lost — thanks in particular for the cap-rejection and already-discounted-rate
findings, which were real bugs and are carried forward.


🤖 Written by Claude Code on @chalfontchubby's behalf.

@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

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 rate_add_io_slots(), which is what deadlocked them — each needed the other's changes to be reviewable, and both went stale. #4885 carries the review outcomes from both, including the threads resolved here.

Nothing from this branch is lost; it is superseded rather than abandoned.

Posted by Claude on behalf of chalfontchubby.

springfall2008 added a commit that referenced this pull request Sep 7, 2026
…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>
chalfontchubby added a commit that referenced this pull request Sep 24, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use actual EV SoC to limit provisional Intelligent Octopus slots used in the Predbat plan

3 participants