Skip to content

fix(octopus): don't treat dispatches under 5 minutes as low rate unless the car is seen charging - #5657

Merged
springfall2008 merged 4 commits into
mainfrom
fix/octopus-short-dispatch
Oct 20, 2026
Merged

springfall2008 merged 4 commits into
mainfrom
fix/octopus-short-dispatch

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Problem

Octopus has started issuing standalone Intelligent dispatches as short as one minute (e.g. 14:13–14:14). Predbat rounds every dispatch out to whole half hours, so each blip made a full 30 minutes cheap for the house battery and used up part of the daily low-rate cap, although a car rarely starts charging in a dispatch that short. The planner could then charge the battery at what is really the day rate.

Change

A dispatch shorter than OCTOPUS_SLOT_MIN_MINUTES (5) is now off-peak only in a half hour where dynamic load (octopus_intelligent_dynamic) has seen the car charging.

  • One shared test: the check lives in dispatch_billed_off_peak(), so load_octopus_slots(), rate_add_io_slots() and octopus_planned_running() agree, and an unconfirmed blip spends none of the cap.
  • Measured on the whole dispatch: length uses the dispatch's own (raw) boundaries. The few minutes left of a long dispatch in progress, or the part of one that began before midnight, are not a short dispatch. load_octopus_slots() now takes dispatch_start/dispatch_end from the raw decode, the same way rate_add_io_slots() already does.
  • Every confirmed half hour is kept: dynamic_load_car_confirmed only holds the latest one, so a confirmed blip would lose its cheap rate once a later half hour was confirmed, and today's cost would step up (the Octopus IOG: completed dispatches with location AWAY should still be priced as off-peak #4946 shape). The new per-car dynamic_load_car_confirmed_periods keeps every confirmed half hour, pruned after two days.
  • Held half hours are exempt (octopus_held_dispatches(), Octopus API: a just-finished half hour of a running Intelligent dispatch is re-priced at day rate until completedDispatches lists it #5520). They passed the test when they were seen running, and the one in progress only runs to now, so it can be any length.
  • Not applied under window billing (intelligent_dispatch_billing: window, E.ON). That rule bills the whole planned window whether or not the car charges, and dynamic load confirms nothing there.
  • Short dispatches that are filtered get a log line, through the existing change-only slot log gate.

In practice, confirmation needs a car_charging_now sensor: only the 15-second poll is fine-grained enough to catch a one-minute dispatch. Without one, short dispatches stay at the normal rate. The car's own plan still includes them, priced at the normal rate. Documented under "Very short dispatches" in docs/car-charging.md.

Behaviour change: test14_tiny_overlap_issue3328 asserted that a real 1m16s dispatch makes its half hour cheap. That slot came from the same dump as #3328's 45-minute case, which is unaffected. The test now covers both sides: not cheap when unconfirmed, cheap when confirmed.

Known limitations

  • dynamic_load_car_confirmed_periods is in memory only. After a restart, a confirmed short dispatch from earlier today goes back to the day rate in today's cost.
  • A discount the HA Octopus Energy integration's rate feed applies itself (is_intelligent_adjusted) for a short dispatch is not removed. Predbat's own Octopus component path is fully covered.

Tests

  • rate_add_io_slots: unconfirmed/confirmed, exactly 5 minutes, another half hour or another car confirmed, completed blips, cap not spent, window billing.
  • octopus_slots: car-plan pricing; a zero-kWh long-dispatch tail trimmed to 3 minutes keeps the low rate; cap not spent.
  • dynamic_load_car_not_charging t55/t56: the 15s poll confirms a blip, the confirmation survives a later one, and old half hours are pruned.
  • octopus_dispatch_hold t13: a 2-minute held half hour stays cheap.
  • Mutation-checked: disabling the threshold, the held exemption or the window-billing exemption each fails its tests.
  • --quick suite green (355 tests); pre-commit clean on the changed files.

🤖 Generated with Claude Code

…ss the car is seen charging

Octopus has started issuing standalone one-minute Intelligent dispatches. Rounded out to the
half hour, each one made a whole 30 minutes cheap for the house battery, and spent part of the
daily low-rate cap, although a car rarely starts charging in one.

A dispatch shorter than OCTOPUS_SLOT_MIN_MINUTES (5) is now only off-peak in a half hour dynamic
load has seen the car charging in. The test sits in the shared dispatch_billed_off_peak(), so
load_octopus_slots(), rate_add_io_slots() and octopus_planned_running() agree, and it is judged
on the dispatch's own boundaries so the trimmed tail of a long dispatch keeps the low rate.

- dynamic_load_car_target() records every confirmed half hour (dynamic_load_car_confirmed_periods),
  not only the latest, so a confirmed blip stays cheap once a later half hour is confirmed too
- held half hours (octopus_held_dispatches()) are exempt: they passed the test when seen running
- not applied under window billing (E.ON), which bills the whole planned window regardless
- test14_tiny_overlap_issue3328 updated: its 1m16s slot is now cheap only once confirmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the BOT_REVIEW Trigger an autotriage label Oct 20, 2026

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Automated comment from the triage bot.

Reviewed at b668c04 (PR head, based on main d3c2f72). The mechanism itself is sound: the new gate lives in dispatch_billed_off_peak() so load_octopus_slots(), rate_add_io_slots() and octopus_planned_running() all take it; the confirmations flow from dynamic_load_car_target(), which the 15-second poll calls with record=True; the two cap counters still agree (a confirmed blip charges its own ~1 minute to the budget in both); and the tests discriminate for real (test14_tiny_overlap_issue3328_unconfirmed expects a day-rate half hour that main's code would stamp cheap, so it fails without the change). Two findings inline, one in the body.

apps/predbat/octopus.py:4259

A confirmed short dispatch's half hour gets no GH#5520 held fallback, because octopus_held_update() can only hold a half hour it samples while the dispatch is running at a 5-minute cycle, and a dispatch shorter than 5 minutes that does not straddle a 5-minute boundary is never running during one.

Octopus bills a dispatch's half hour off-peak once the car charges in it (the GH#5316 rule this PR builds on), so a confirmed blip's half hour should stay cheap once it is over. But after the blip ends, the planned entry drops out of the planned feed (the HA integration's planned_dispatches sensor drops ended entries; on Predbat's own component the entry stays only while Octopus's flexPlannedDispatches still lists it), and the completed feed lists the dispatch only later - the 15-60 minute lag GH#5520 measured. In that window the half hour is in neither list, so it prices at the day rate, and today's cost steps up, then falls back when the completed dispatch arrives and the confirmation makes dispatch_too_short() pass. That is exactly the #4946/#5520/#5413 today-cost-steps shape, and the held mechanism (added by PR #5533 for precisely this) cannot fire for it: octopus_held_update() samples octopus_planned_running() only at fetch.py:1577 per 5-minute cycle, and the 15-second poll - the tool that does see the blip run - confirms half hours but never records held ones. Example: a 14:13-14:14 dispatch confirmed by a poll at 14:13:40 is gone from planned by the 14:15 cycle, unlisted in completed until roughly 14:30, and prices at the day rate throughout.

The PR body's claim that "Held half hours are exempt" is true of any held half hour that exists, but no held half hour ever gets created for these dispatches, so the exemption is mostly unexercised for the class of dispatch the PR is about: a held entry is injected for the in-progress half hour only when a long dispatch ended part-way through it (the GH#5316 tail), never for a dispatch that finished before any cycle saw it.

If this is judged acceptable as-is, an octopus_held_update() call from dynamic_load_car_poll() (which already knows the dispatch is running, on the exact clock) would close it; the per-car held dict already keys on absolute half-hour starts, so recording the period the poll saw a confirmed dispatch in needs no new state. I could not fully confirm how long the API keeps an ended blip in flexPlannedDispatches on Predbat's own component path, so on that path the window may be shorter than the integration's 15-60 minutes.

Comment thread apps/predbat/plan.py
Comment thread docs/car-charging.md
@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Oct 20, 2026
CI and others added 2 commits October 20, 2026 19:31
…cle cannot see

Review on #5657:
- confirm a dispatch that began in the half hour and is over within DYNAMIC_LOAD_CAR_CONFIRM_MINUTES
  of its start from a reading inside it - the guard otherwise made a 14:00-14:01 blip unconfirmable
- the 15 second poll now runs octopus_held_update() on its exact clock, so a confirmed blip's half hour
  is held until Octopus lists it as completed instead of falling to the day rate in between
- persist dynamic_load_car_confirmed_periods with the rest of the dispatch-check state, so a restart
  does not re-price a confirmed blip's half hour at the day rate
- docs: name the HA integration rate-feed exception

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

Copy link
Copy Markdown
Owner Author

Automated reply from Claude Code (pr-cleanup).

Review body (no GH#5520 hold for a confirmed short dispatch): fixed in 99f7dfc, along the lines suggested. dynamic_load_car_poll() now calls octopus_held_update(now) after its confirmations. A bare call wouldn't have worked: octopus_held_update() took the period from minutes_now, and octopus_planned_running() read now_utc_real, and both only move once per plan cycle. So both now take the poll's time on the exact clock (now / minute_exact, defaulting to the cycle's as before). A 14:13-14:14 blip confirmed at 14:13:20 is held for 14:00-14:30 by that same poll, and stays cheap from the 14:15 cycle until the completed dispatch takes over. For any dispatch a 5-minute cycle does see running, this only adds sampling points under the same rule. The next cycle's octopus_held_update() still drops a half hour if the car is cancelled in it. Covered by t55 and t55b; deleting the poll call fails both.

CI (pre-commit): the failure is str2time Test 3 (str2time('2024-09-07T15:40:09') expected ValueError), not anything in this PR. The runner resolved python-version: 3.x to Python 3.15, and main's own latest run fails the same way. #5653 fixes it. This branch now has main merged (43b8df2), and locally run_pre_commit (pre-commit plus the --quick suite, 355 tests) passes on Python 3.14. I didn't touch the CI failure here; it should go green once #5653 lands and the branch picks it up.

All three new fixes were mutation-checked: removing each one fails its test (t57, t49, t55/t55b).

… too

Undo the over_before_guard exception from 99f7dfc. A dispatch that short is not meant to be counted,
and lifting the guard for it only makes a false confirmation from a lagging sensor more likely. t57
now pins that a 14:00-14:01 dispatch is not confirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit 4f45708 into main Oct 20, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/octopus-short-dispatch branch October 20, 2026 19:02
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.

1 participant