Skip to content

feat(octopus): prefer charging in the Intelligent dispatch run in hand - #5635

Merged
springfall2008 merged 2 commits into
mainfrom
feat/iog-bird-in-hand
Oct 19, 2026
Merged

springfall2008 merged 2 commits into
mainfrom
feat/iog-bird-in-hand

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Summary

On Octopus Intelligent, the house's cheap rate during a dispatch only holds while the car really charges in it, and the car can be unplugged at any time. When topping the battery up in a running dispatch scored exactly the same as waiting for the fixed off-peak window, the planner waited.

Example: a dispatch from 19:00 into the night, off-peak from 23:30, and the battery holding about enough for the evening. The plan waits for 23:30. The car is unplugged at 20:00, the dispatch disappears, and the house runs out of battery and imports at peak rate before 23:30.

This change prefers charging in the dispatch run that is in hand ("bird in hand"), earliest half hours first, whenever doing so costs no more.

What changes

  • Which slots count (Plan.io_confirmed_credit_update()): a car is in one of its Octopus dispatches now, and the dispatch check has not cancelled it. This is the same decision that shows a ? on a cancelled slot.
    • With octopus_intelligent_trust_slots Off, that means the car has been seen charging.
    • With octopus_intelligent_dynamic Off, or under the window billing rule, it means the dispatch has started.
    • Only minutes the dispatch itself made cheap (io_adjusted) count. A dispatch inside the fixed off-peak window carries no risk, so it gets nothing.
  • The credit: score only, taken off metric_keep for energy a charge window stores in those minutes. It is 0.1p/kWh for the current half hour, divided by 1 + n for the nth half hour after it, so the charge lands where it is least likely to vanish. Cost is never touched, so a genuinely cheaper slot still wins.
  • Both engines: prediction.py and prediction_kernel.cpp have the same change. The kernel gets a per-step io_credit array, copied into ContextStore. ABI 7→8, parity 17→18.
  • Plan replacement: the incumbent plan was optimised without the credit, which is far smaller than metric_min_improvement_plan (2p). So, on the first replan after a run becomes boosted, should_replace_plan() also adopts a plan that scores better and costs no more (to 0.01p).
    • The tie-break is one-shot (io_confirmed_tiebreak, cleared at the end of that recompute), so the plan is not up for replacement every cycle of the run.
    • A newly boosted run forces a replan, because with trust On a trusted dispatch starting changes no rates and nothing else triggers one.
  • compare.py restores the live credit after tariff runs, and the tie-break flag is in COMPARE_PLAN_STATE.
  • Docs: new "Charging the battery while the car is charging" section in car-charging.md.
  • Debug journal: a trap entry. A new kernel context array that isn't copied into ContextStore reads freed memory, and gives plausible but wrong numbers rather than crashing.

Why the credit, and not the sort order

The reproduction (default PV10/PV90 weights, the old load-scaling defaults, 1 kW evening load) showed an exact tie between "charge at 19:00" and "wait for 23:30" once the battery covers even the PV10 evening.

A 20p sort discount on the current slot changed nothing. The optimiser only accepts changes that improve the score, so a tie can only be broken in the score.

Testing

  • New tests/test_iog_bird_in_hand.py (./run_all --test iog_bird_in_hand):
    • Credit map:
      • running dispatch;
      • stepping across 19:30, 19:35, 20:00 and 20:01 with no re-armed tie-break;
      • cancelled, then resumed;
      • ended;
      • check off;
      • not started;
      • Predbat-led slot;
      • dispatch inside off-peak;
      • save=False.
    • Score only: cost unchanged, metric_keep lowered by stored kWh × credit, a hold earns nothing.
    • Planner:
      • charges in the current slot from 3–7 kWh lossless and 4–7 kWh with default losses;
      • the run's charge is front-loaded;
      • a cheaper later slot still wins;
      • at 9 kWh with losses it holds, because topping up costs about 0.2p more than the credit is worth;
      • a full battery is unchanged;
      • with the car itself modelled it still charges in the current slot.
  • test_plan_tiebreak.py: boosted and unboosted same-cost cases, plus a costlier plan, an equal score, and a call without costs.
  • test_kernel_parity.py: a random credit over random dispatch blocks, and an edge case charging inside a credited run.
  • Local runs:
    • ./run_pre_commit: all hooks pass, and the full ./run_all --quick passes.
    • kernel_parity, model_kernel and optimise_windows_kernel pass on a fresh local kernel build.

Before merging

  • The committed prediction_kernel_lib_*.so binaries are stale after the ABI/parity bump. The kernel-binaries job should rebuild them and commit them back to this branch. Until it does, installs fall back to the Python engine, which is correct but slower.
  • This touches the main-loop planner path (fetch_sensor_data, Prediction.run_prediction, calculate_plan / should_replace_plan). Nothing has been run against a live install or a real debug dump yet.
  • Choices worth a second look:
    • the 0.1p/kWh size and 1/(1+n) taper;
    • the boost starting at dispatch start when trust is On;
    • no credit for dispatches inside the fixed off-peak window.

🤖 Generated with Claude Code

A dispatch only makes the house cheap while the car really charges in it,
and the car can be unplugged at any time. When topping the battery up in a
running dispatch scored the same as waiting for the fixed off-peak window,
the planner waited - so a car unplugged at 20:00 could leave the house on a
battery that was never charged until 23:30.

While a car is in one of its Octopus dispatches and the dispatch check has
not cancelled it (the same decision that shows a "?" on a cancelled slot),
io_confirmed_credit_update() gives the dispatch-made-cheap run from now a
small score-only credit per kWh stored: 0.1p for the current half hour,
divided by 1 + n for the nth after it. It is taken off metric_keep in both
the Python engine and the C++ kernel, so cost is never touched and a
genuinely cheaper slot still wins.

The incumbent plan was optimised without the credit, which is far smaller
than metric_min_improvement_plan, so on the first replan after a run becomes
boosted should_replace_plan() also adopts a plan that scores better and
costs no more. The tie-break is one-shot, so the plan is not up for
replacement every cycle of the run. A newly boosted run forces a replan, as
with octopus_intelligent_trust_slots On nothing else does.

Kernel ABI 8 / parity 18: io_credit per step, copied into ContextStore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@springfall2008
springfall2008 marked this pull request as ready for review October 19, 2026 10:34
@github-actions github-actions Bot added the BOT_REVIEW Trigger an autotriage label Oct 19, 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 f84a326 (18 files). I read the bird-in-hand credit end to end against the PR's own code, and found 1 finding, posted as an inline comment.

Checked and sound, for the record: the credit block is mirrored bit-for-bit in the kernel (both engines set force_charging in the charge block and subtract min(-battery_draw * battery_loss, soc_max - soc) * credit from metric_keep in the battery_draw <= 0 branch); ABI 7→8 and parity 17→18 are bumped on both sides and the ctypes PkContext field order matches the C++ struct; ContextStore copies io_credit (the stale-pointer trap the PR's own journal entry warns about is handled); build_static_context_arrays/create_kernel_context wire the array through, and the static-cache shape still covers the only shared-cache caller (marginal.py's 28 same-input builds); io_adjusted is final before the new fetch call (after dynamic_load_car_check and rate_add_io_slots), so the credit reads this cycle's dispatch decision; compare.py restores both new pieces of plan state (io_confirmed_tiebreak in COMPARE_PLAN_STATE, io_confirmed_credit in COMPARE_RESTORED_STATE); predbat.py reset() initialises the three new fields; the docs section went onto the existing car-charging.md page (no mkdocs.yml change needed); there is no new CONFIG_ITEMS entry to make because the credit size is a constant, not a setting; the new test module is registered in TEST_REGISTRY; and the head commit rebuilt all six .so binaries after the ABI bump (the stub ABI handshake still protects a stale one).

Comment thread apps/predbat/tests/test_iog_bird_in_hand.py
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