Skip to content

Don't trust Intelligent slots the car won't need (consider_full) - #5403

Merged
springfall2008 merged 5 commits into
mainfrom
feat/iog-car-need-gate
Oct 5, 2026
Merged

springfall2008 merged 5 commits into
mainfrom
feat/iog-car-need-gate

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Posted by Claude on behalf of @chalfontchubby.

Closes #4482.

Summary

With octopus_intelligent_consider_full On, Predbat already models the car stopping once it's full: load_octopus_slots() ends the car's slots there, so the car load and "Hold for car" stop. But every dispatch minute kept its low rate, so the house battery was still planned around slots the car won't charge in. Octopus only bills a dispatch slot cheap when the car actually charges in it.

This makes the rate agree with what consider_full already decided. Future dispatch minutes that no car still needs are planned at the normal rate, the same way a cancelled car's are (#5229).

It mostly matters for charger-led Intelligent setups (Zappi, Ohme and similar), where Octopus doesn't know the car's state of charge and allocates more slots than the car needs, which is the case consider_full exists for. Car-led integrations size the dispatch from the car's own SoC, so there's little surplus.

Behaviour is unchanged with consider_full Off (the default).

Details

  • octopus_surplus_minutes() collects the future dispatch minutes that no car can use. It decides per car, then shares the result, because rates are shared between cars. A car can use:

    • its needed slots after capping: kWh left, or kwh_cancelled for a cancelled car;
    • every dispatch, if the car isn't modelled from its Octopus slots this cycle (e.g. ignored while unplugged).

    Each car counts only up to its own dynamic_load_car_strip_from(), so cancellation keeps its say. That includes the half hour GH#5316 keeps cheap for a car seen charging in it. Minutes are rounded out to whole half hours, the fixed 23:30-05:30 window is excluded, and the surplus starts from the half hour after the current one, so the current half hour and elapsed minutes always keep their rate.

  • A dispatch in progress is judged from now. With consider_full On, load_octopus_slots() caps a running dispatch's need from its remaining span and energy, the way trim_started_dispatch() already does for the Octopus API component (octopus_intelligent_slot dispatches aren't trimmed). This happens after the daily slot-cap chunking, which still counts the dispatch from its real start. So one long dispatch and the same charge split into half hours get the same answer.

  • A change in the surplus forces a replan, because the rates can move without the slots moving (the car's SoC changes how many half hours it needs). Only the part of the previous surplus still ahead is compared, so time passing alone doesn't trigger it.

  • Both rate paths withhold the surplus:

    • rate_add_io_slots() doesn't stamp it;
    • dynamic_load_car_strip_feed_rates() removes a discount the rate feed already delivered.
  • in_iog_fixed_window() is factored out of dynamic_load_car_strip_feed_rates(), which now shares it.

Effect of the two settings together (with octopus_intelligent_dynamic On)

consider_full trust_slots Daytime dispatch low rate for the house battery
Off On (defaults) every dispatch minute (unchanged)
On On only the half hours the car needs (new)
Off Off none until the car is seen charging in a slot, then those slots (unchanged)
On Off none until confirmed, then only the needed half hours (new)

Possible follow-up

consider_full only affects Intelligent slots, so together with octopus_intelligent_trust_slots it's really one setting: which Intelligent slots to trust. They could be merged into one select (All / Needed / Confirmed). That needs a migration from the two switches, so I've left it out here. Happy to do it if you'd like it.

Background

#4885 proposed a separate limit_future_slots switch for this. This version hangs off the existing consider_full switch instead, because with it On the model already says the car won't charge in those slots.

Test plan

  • New tests/test_iog_car_need_gate.py (iog_car_need_gate):
    • surplus rounding, the fixed window, a dispatch in progress, the current half hour (on and off a boundary), a second car's need, a cancelled car's need only up to its strip_from, and a car that isn't modelled (alone, overlapping a full car, and cancelled);
    • end to end through the real load_octopus_slots() capping, for both Predbat's own overlay and a feed-delivered discount, including an untrimmed dispatch already in progress and a full car sharing a dispatch with a cancelled car;
    • one long dispatch against eight half-hour dispatches, before and after the dispatch starts (from the review);
    • the replan trigger's change detection;
    • consider_full Off unchanged.
  • Each new case fails with the corresponding fix reverted.
  • ./run_all --quick green.
  • ./run_pre_commit green on every commit.

🤖 Generated with Claude Code

chalfontchubby and others added 3 commits October 5, 2026 09:59
With octopus_intelligent_consider_full on, load_octopus_slots() already ends
each car's slots where it reaches its limit, so the car is modelled not to
draw in the rest - but every dispatch minute still kept its cheap rate, and
the house battery planned around slots Octopus only bills cheap when the car
charges in them.

octopus_surplus_minutes() works out the future dispatch minutes no car still
needs (rounded out to whole half hours, shared across cars, outside the fixed
23:30-05:30 window), and both rate paths now withhold them the way they
already do for a cancelled car: rate_add_io_slots() doesn't stamp them, and
dynamic_load_car_strip_feed_rates() removes a discount the rate feed already
delivered. Elapsed minutes keep their rate so today's cost stays true.
Behaviour is unchanged with consider_full off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r-need gate

dynamic_load_car_check() cancels a car by zeroing its future slots and keeping
the kWh in kwh_cancelled. The car-need gate read that zero as "the car won't
need it", so with octopus_intelligent_consider_full on it stripped the cheap
rate from the rest of the half hour #5229 deliberately keeps for a car seen
charging in it. Cancellation decides a cancelled car's rate itself; the gate
now only answers whether a slot is beyond the car's need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The surplus started at minutes_now, which usually falls mid-period, so the rest
of the current half hour lost its low rate once the car was full - even when
the car charged earlier in it, which makes Octopus bill the whole period cheap.
Surplus now starts at the next half-hour boundary. Clamping back to the start
of the current period instead would have marked elapsed minutes as surplus too,
and rate_add_io_slots() applies the skip to past minutes, misreporting today's
cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Boundary handling, active-dispatch capping, and cross-car eligibility can produce incorrect electricity prices.

Review effort: Balanced
Findings: 4 Medium severity

Open (4)
What changed in this PR

Links Intelligent Octopus rate planning to the car’s predicted charging need when consider_full is enabled, addressing #4482.

Changes:

  • Withholds surplus dispatch discounts from both rate paths.
  • Shares fixed-window handling and initializes surplus tracking.
  • Adds regression tests and updates charging documentation.
File Description
docs/​car-charging.md Documents need-based discount handling.
apps/​predbat/​unit_test.py Registers the new tests.
apps/​predbat/​tests/​test_iog_car_need_gate.py Tests surplus detection and rate handling.
apps/​predbat/​predbat.py Initializes surplus-minute state.
apps/​predbat/​octopus.py Calculates surplus minutes and adjusts discounts.
apps/​predbat/​fetch.py Calculates surplus before processing rates.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/octopus.py Outdated
Comment thread apps/predbat/octopus.py Outdated
Comment thread apps/predbat/octopus.py
Comment thread apps/predbat/octopus.py Outdated
…review)

Copilot's review of the gate found four ways it could misprice a dispatch:

- The surplus started at the next half-hour boundary computed as a ceiling,
  which is the current boundary when the floored clock sits on one (a fetch at
  14:02 reads 14:00), so the current half hour could still lose its rate. It now
  starts at the strictly next boundary.
- A cancelled car's kwh_cancelled counted as needed for the whole slot, so it
  kept a full car's surplus minutes cheap though neither car would charge then.
  A car's need now counts only before its own cheap rate ends
  (dynamic_load_car_strip_from()), which still keeps the half hour GH#5316
  keeps for a car seen charging in it.
- A car not modelled from its Octopus slots was simply skipped, so a full car's
  overlapping surplus stripped its discount too. Its dispatches are now kept,
  up to its own strip_from.
- An untrimmed dispatch in progress (octopus_intelligent_slot isn't trimmed to
  now, unlike the Octopus API component's) is capped from its original start,
  so the capped slot can end before the car finishes. The running dispatch is
  now left alone; dynamic load cancels it in real time once the car stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things from reviewing this at 380117c. Neither affects anyone with octopus_intelligent_consider_full Off, so they don't block on safety, but the first limits how much of #4482 this closes.

1. The gate switches off when a single long dispatch starts

A dispatch in progress is exempt from the gate for its whole length (in_progress in octopus_surplus_minutes()). So the surplus is withheld right up until the dispatch begins, and comes back the moment it does.

Reproduced against this branch with the real load_octopus_slots(), octopus_surplus_minutes() and rate_add_io_slots(). Car at 55 of 60 kWh (needs 5 kWh), consider_full On, no cancellation:

Dispatches from Octopus Clock Cheap Surplus (withheld)
One, 13:00-17:00, 28 kWh 12:55 13:00-14:00 14:00-17:00
One, 13:00-17:00, 28 kWh 13:05 13:05-17:00 none
Eight half hours, 13:00-17:00, 3.5 kWh each 12:55 13:00-14:00 14:00-17:00
Eight half hours, 13:00-17:00, 3.5 kWh each 13:05 13:05-14:00 14:00-17:00

So the result depends on how Octopus happens to chunk the charge. With one long dispatch, at 13:05 the plan sees three more cheap hours that the car won't charge in, which is the sequence #4482 describes (hold the battery now, plan to charge it later in the dispatch, the slots then go). After that point only octopus_intelligent_dynamic can remove the rate, and only once the car has stopped and there is a car_charging_now sensor or CT reading to see it.

The reply on Copilot's thread says the exemption was chosen to avoid changing load_octopus_slots(). I think the suggestion there is the right fix: work out a running dispatch's need from its remaining span and energy, so the capped slots are reliable mid-dispatch and the exemption isn't needed. The Octopus API component already trims a running dispatch to now (trim_started_dispatch()), so on that path the capped slots are already right and the exemption is only costing accuracy. If you'd rather keep it out of this PR, a narrower step would be to exempt only what the gate can't trust: an untrimmed dispatch from octopus_intelligent_slot. Either way the docs should say that a dispatch already running keeps its low rate for its full length.

2. A change in the surplus doesn't force a replan

fetch_sensor_data() forces a replan when the dispatch signature changes or when dynamic_load_car_check() reports a change, but not when octopus_surplus changes. The rates move without the slots moving in two cases here: the car's SoC changing how many half hours it needs, and the flip in point 1 when a dispatch starts. The plan then stays as it was until the normal calculate_plan_every timer, which can be set as high as 60 minutes.

Suggest comparing the new surplus with the previous cycle's where it is assigned in fetch_sensor_data() and setting force_replan when it differs, as is done for dynamic_load_car_changed. It needs the save guard the other checks have, so a compare.py run doesn't trigger it.

…surplus changes (#5403 review)

Trefor's review of #5403:

1. A dispatch in progress was exempt from the gate for its whole length, so
   one long dispatch kept all its surplus cheap from the moment it started,
   while the same charge chunked into half-hour dispatches didn't. With
   consider_full on, load_octopus_slots() now caps a running dispatch's need
   from its remaining span and energy (as trim_started_dispatch() already does
   for the Octopus API component, but octopus_intelligent_slot doesn't), after
   the slot-cap chunking, which still counts it from its real start. The
   exemption is gone.

2. A change in the surplus didn't force a replan - the rates can move without
   the slots moving, as the car's SoC changes how many half hours it needs.
   fetch_sensor_data() now replans when it changes, guarded by save like
   dynamic_load_car_check(). Only the part of the previous surplus still ahead
   is compared, so time passing alone isn't a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Posted by Claude on behalf of @chalfontchubby.

Thanks. Both are fixed in 22c1a14.

1. The gate switching off when a long dispatch starts. I've taken the fix you preferred and removed the exemption. With consider_full On, load_octopus_slots() now caps a running dispatch's need from its remaining span and energy, the same proportion trim_started_dispatch() applies on the Octopus API path. It's applied per chunk after the daily slot-cap chunking, so the cap counter still counts the dispatch from its real start and stays in step with rate_add_io_slots(). It's also limited to consider_full On, since that capping is the only thing that reads the start time. The car's per-minute charging is the same either way.

Your table is now a regression test, using the real load_octopus_slots(). Car at 55 of 60 kWh; one 13:00-17:00 dispatch against eight half-hour dispatches, at 12:55 and 13:05. All four cases withhold 14:00-17:00. With the exemption put back, the "one dispatch at 13:05" case fails with nothing withheld. There's also a case at 14:10 inside an untrimmed dispatch where the car still needs 5 kWh: its need runs to 14:40, so the 14:30 half hour stays cheap.

2. Replan on a surplus change. fetch_sensor_data() now sets force_replan when the surplus changes, behind the same save guard, so a compare.py run won't trigger it. The comparison ignores the part of the previous surplus that's now in the past or in the current half hour. Otherwise the shrinking front edge would force a replan every half hour. The change detection has unit tests; the wiring in fetch_sensor_data() itself isn't exercised end to end.

@Speshman

Speshman commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Thankyou ❤️

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

4 participants