Repository navigation
feat(octopus): prefer charging in the Intelligent dispatch run in hand - #5635
Conversation
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
left a comment
There was a problem hiding this comment.
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).
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
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.octopus_intelligent_trust_slotsOff, that means the car has been seen charging.octopus_intelligent_dynamicOff, or under the window billing rule, it means the dispatch has started.io_adjusted) count. A dispatch inside the fixed off-peak window carries no risk, so it gets nothing.metric_keepfor 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.prediction.pyandprediction_kernel.cpphave the same change. The kernel gets a per-stepio_creditarray, copied intoContextStore. ABI 7→8, parity 17→18.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).io_confirmed_tiebreak, cleared at the end of that recompute), so the plan is not up for replacement every cycle of the run.compare.pyrestores the live credit after tariff runs, and the tie-break flag is inCOMPARE_PLAN_STATE.car-charging.md.ContextStorereads 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
tests/test_iog_bird_in_hand.py(./run_all --test iog_bird_in_hand):save=False.metric_keeplowered by stored kWh × credit, a hold earns nothing.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../run_pre_commit: all hooks pass, and the full./run_all --quickpasses.kernel_parity,model_kernelandoptimise_windows_kernelpass on a fresh local kernel build.Before merging
prediction_kernel_lib_*.sobinaries are stale after the ABI/parity bump. Thekernel-binariesjob 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.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.🤖 Generated with Claude Code