Skip to content

feat(octopus): soften the PV10 dispatch hedge once the car is charging in it - #5896

Merged
springfall2008 merged 2 commits into
mainfrom
feat/iog-confirmed-dispatch-hedge
Nov 1, 2026
Merged

springfall2008 merged 2 commits into
mainfrom
feat/iog-confirmed-dispatch-hedge

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Problem

In the PV10 scenario, Predbat assumes every Intelligent dispatch minute more than 30 minutes ahead is gone, and charges it at rate_max (#5304). This still happens after the car has been confirmed charging in the dispatch (bird-in-hand, #5635).

On a real evening (dispatch 19:00–05:30 at 6.6p, firm off-peak from 23:30), a plan that charges the battery with the car from 19:05 scored 56.3p worse than one that waits for 23:30. The 0.1p/kWh bird-in-hand credit can't come close to that. So the credit only ever moved charging into the first half hour, which the hedge already leaves alone.

Change

Once the run is confirmed (io_confirmed_credit is set for the minute, meaning a car is charging in an uncancelled dispatch), a dispatch that disappears is noticed within one Predbat cycle. So for a confirmed minute in PV10:

  • the dispatch is kept: dispatch_gone is false and the battery is still held for the car
  • the non-car import pays a premium of (max(rate_max, rate) - rate) * RUN_EVERY / 30, i.e. 5 minutes of every half hour at the worst-case rate
  • the car's own import pays no premium, because the car stops when its dispatch does

Unconfirmed dispatches keep the full hedge. Nothing changes unless a car is charging in an io_adjusted run.

Both engines are changed. Kernel parity revision is now 19, with no ABI change: the kernel already receives io_credit. The binaries will be rebuilt by the kernel-binaries job.

Effect

The same 19:05 evening, scored by hand (lower is better):

PV10 hedge for the confirmed run Plan: charge this half hour, then 23:30 Charge with the car 19:05–23:30 Gap
Before 1764.85 1821.12 56.3p
After 1763.77 1770.70 6.9p

On that evening the plan itself doesn't change. The car hold already stops the battery discharging during the dispatch, and the firm window fills it, so waiting is still slightly cheaper.

The change matters when the battery would otherwise power the house through the dispatch and refill later. The bird-in-hand lossy test case is one: it now fills and holds the battery through the dispatch instead of cycling it through its losses.

Tests

  • New run_confirmed_hedge_tests in test_iog_bird_in_hand, on both engines, all against exact expected costs:
    • h1: a confirmed slot beyond 30 minutes pays RUN_EVERY/30 of the worst-case premium.
    • h1: an unconfirmed slot still pays the full worst-case rate.
    • h2: the current half hour pays no premium.
    • h3: the car's import pays no premium.
  • h1 (confirmed) and h3 failed on both engines before the change. The other h1/h2 checks passed before and after, so they confirm the test's arithmetic.
  • Changed expectation: planner check p4 ("credit does not outweigh the cost of losses", soc 9 with losses) assumed the old hedge. Under it the plan discharged through the evening and refilled after 23:30. With the run confirmed it now fills and holds the battery through the run, because letting the house import at the dispatch rate beats cycling the battery through its losses. The scores with the rest of the plan unchanged are 211.98 (first slot at 9) vs 211.43 (at 10). p4 now asserts the fill-and-hold. "A cheaper later slot still wins" is still covered by the other p4 check, which is unchanged and passes.
  • kernel_parity passes against a locally rebuilt library with PREDBAT_KERNEL_REQUIRED=1. Its dispatch-car case runs PV10 with a confirmed credit and the car's load reported, so it exercises both the premium and the car exclusion.
  • ./run_all --quick passes. pre-commit passes.

Review notes

  • Risk: this is the prediction hot loop and runs on every PV10 evaluation. It's gated on io_confirmed_credit, so behaviour is unchanged unless a car is charging in a dispatch.
  • Coupling: the credit map now also marks which minutes count as confirmed for the hedge. That's documented in io_confirmed_credit_update(). It avoids a new kernel input, at the cost of tying the two together.

🤖 Generated with Claude Code

…g in it

In PV10, every Intelligent dispatch minute more than 30 minutes ahead was
assumed gone and paid at rate_max, even while the car was charging in the
dispatch. That made any plan that used the rest of the dispatch about 56p
worse on a real evening, far beyond the 0.1p/kWh bird-in-hand credit.

Once the run is confirmed (io_confirmed_credit set, so the car is charging
in it), a dispatch that goes away is seen within one Predbat cycle. So a
confirmed minute keeps its dispatch, with the battery still held for the
car, and the house and battery imports pay RUN_EVERY minutes of each half
hour at the worst-case rate. The car's own import pays nothing extra,
because the car stops when its dispatch does.

Both engines are changed; kernel parity revision 19.

The bird-in-hand planner test p4 ("credit does not outweigh losses")
assumed the old hedge. With the run confirmed, the lossy battery now fills
and holds through the dispatch rather than cycling, which is the intended
result, so p4 now checks that instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the BOT_REVIEW Trigger an autotriage label Oct 31, 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 head 1d5e92a: found no issues — 0 inline, 0 in body.

What I checked:

  • Semantics (prediction.py:767-776, 1430-1432): the softening is gated on dispatch_gone (io_adjusted ∩ PV10 ∩ minute > 30), so nominal/PV90 are untouched; dispatch_risk is reset every loop iteration; the premium correctly excludes the car's share via diff - car_amount_premium in both car_energy_reported_load modes (in bypass mode the car's energy is not in diff at all), and car_gone_kwh stays 0 on confirmed minutes so the old refund path cannot fight the new premium (they never both fire).
  • Kernel parity: prediction_kernel.cpp:845-853 and 1460-1463 mirror prediction.py exactly — same minute mapping (k * PREDICT_STEP + minutes_now, prediction_kernel.py:701-702), same operation order (max(c->rate_max, import_rate) - import_rate) * PK_RUN_EVERY / 30.0, same placement inside the import branch, and dispatch_risk != 0.0 matches if dispatch_risk:. A stale committed binary degrades to the load-time "stale binary" warning plus Python fallback (prediction_kernel.py:359-364) rather than silent wrong numbers, and the kernel-binaries job rebuilds.
  • Credit-map coupling: io_confirmed_credit covers the contiguous io_adjusted run from minutes_now (plan.py:628-635); its values are always strictly positive (divisor 1+n), keys align with the prediction loop's minute_absolute, and a gap in the run falls back to the original full hedge (conservative direction). Confirmed state rides the same dispatch-check decision the existing credit already used, as the docstring documents.
  • Tests: the h1/h2/h3 expected values match the code's arithmetic exactly (stored10 * (LOW_RATE + (rate_max - LOW_RATE) * RUN_EVERY / 30)); h3's pre-change failure story is consistent with the fixture (car_charging_from_battery True, reported load, dispatch slots without an "average" key so the slot rate is 0 — pre-change PV10 met the car from the grid while nominal met it from the battery, post-change both take the normal path); the h-suite runs both engines; everything the new tests mutate is restored (skew _SNAPSHOT_ATTRS + STATE_FIELDS, including prediction_kernel_enable, now_utc_real, minutes_now). Module already registered in TEST_REGISTRY.
  • Docs/goldens: car-charging.md updated in both the PV10 section and the confirmation section; no new setting, so no CONFIG_ITEMS entry is needed; no committed coverage/cases dump contains io_confirmed_credit, so no golden shifts.

One calibration observation I considered and did not raise as a finding: the premium is aggregated as 5/30 of every half hour of the run, while the true exposure after a cancellation is a one-off ~5-minute sliver before the replan, so the modelled premium over the run is larger than a single certain cancellation's cost by roughly the run's half-hour count. That is a deliberate score calibration (uniform across plans, 6x closer than #5304's full hedge, and the PR's Effect table shows it prices the motivating evening within 6.9p), not a defect, so I left it to your judgement.

@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Oct 31, 2026
@springfall2008
springfall2008 merged commit db97964 into main Nov 1, 2026
@springfall2008
springfall2008 deleted the feat/iog-confirmed-dispatch-hedge branch November 1, 2026 16:09
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