Skip to content

Metric 10 weighting on IOG Dispatches #5540

Open

Description

@thewhale21

Describe the bug

Intelligent Octopus dispatch slots can only be treated as at risk through the PV10 scenario, and there is no setting to change that. As a result, a high pv_metric10_weight stops Predbat from planning charge/export cycles in IOG dispatch windows, even though it plans the same cycles happily when the same 6.6p rate comes from the tariff or a manual rate.

In prediction.py (around line 740), and the same rule in the compiled kernel (prediction_kernel.cpp line 777, fed by io_flag in prediction_kernel.py line 662):

dispatch_gone = io_adjusted.get(minute_absolute, 0) and pv_scenario == PV_SCENARIO_PV10 and minute > 30
if dispatch_gone:
    import_rate = max(self.rate_max, import_rate)

In the PV10 run, every dispatch minute more than 30 minutes ahead is priced at the tariff's peak rate. pv_metric10_weight then blends PV10 into the plan metric:

metric = (1.0 - weight10 - weight90) * metric + weight10 * metric10 + weight90 * metric90

So one setting now controls two unrelated things:

  1. How much weight the plan gives to a pessimistic solar forecast.
  2. How much weight it gives to the chance that an IOG dispatch is withdrawn.

For a cycle in a dispatch window (import 6.6p, export 12p, roughly 75% round trip), the normal case gains about +2.4p/kWh. The PV10 case recharges at 35.6p, a loss of about −26.6p/kWh. The weighted result only goes positive when pv_metric10_weight is below about 0.08. With my setting of 0.5 (and even the default of 0.15), no cycle in a dispatch window is ever planned.

Why it's confusing as a user:

  • Setting the same times to 6.6p with select.predbat_manual_import_rates produces exactly the charge/export cycling I want. Manual rates don't carry the io_adjusted marker, so PV10 leaves them alone.
  • Applying a manual rate over a dispatch slot doesn't help either. The price changes but the io_adjusted marker stays, so the slot is still assumed gone in PV10.
  • None of the Intelligent switches affect this (octopus_intelligent_charging, _ignore_unplugged, _consider_full, _dynamic, _trust_slots). The only code that removes the marker (octopus.py, around line 4170) also resets the price to rate_max_base.
  • Even when octopus_intelligent_dynamic is on and the car has been seen charging in the current dispatch, the rest of that dispatch is still assumed gone in PV10.
  • The only workaround is pv_metric10_weight = 0. That fixes the behaviour (confirmed on my system), but it removes the PV10 solar hedge entirely.

History, from git log on the repo:

Confirmed regression: with identical settings (pv_metric10_weight 0.5), downgrading to v9.1.0 restores the expected behaviour. Evening IOG dispatch windows get charge → export → recharge cycles again. On v9.3.8 they don't.

Expected behaviour

There should be a way to keep the PV10 solar scenario while trusting IOG dispatch rates. Ideally:

  • a switch to turn off the "dispatch assumed gone in PV10" rule, and/or
  • dispatches the dynamic check has confirmed (car seen charging in the current dispatch) are no longer assumed gone for the rest of that dispatch.

With either, a 6.6p dispatch window should plan the same as a 6.6p tariff or manual window (for example evening charge → export → recharge cycles), given the same pv_metric10_weight.

Suggested fix

Option A: a new expert-mode switch (minimal change, default keeps current behaviour)

  1. config.py: add a switch next to the other Intelligent switches:
{
    "name": "octopus_intelligent_pv10_dispatch_gone",
    "friendly_name": "Assume Intelligent dispatches can vanish in the PV10 scenario",
    "type": "switch",
    "default": True,
    "enable": "expert_mode",
},
  1. fetch.py: read it alongside octopus_intelligent_trust_slots (around line 3776):
self.octopus_intelligent_pv10_dispatch_gone = self.get_arg("octopus_intelligent_pv10_dispatch_gone")
  1. prediction.py: copy it from base (next to self.io_adjusted = base.io_adjusted, around line 197) and gate the rule:
dispatch_gone = self.octopus_intelligent_pv10_dispatch_gone and io_adjusted.get(minute_absolute, 0) and pv_scenario == PV_SCENARIO_PV10 and minute > 30
  1. prediction_kernel.py (around line 662): gate the flag passed to the C++ kernel, so no kernel rebuild is needed:
io_flag.append(1 if (pred.octopus_intelligent_pv10_dispatch_gone and pred.io_adjusted.get(minute_absolute, 0)) else 0)

self.io_adjusted itself is left alone, so the charge-skew gradient in plan.py (_io_rate_adjustment) and the dynamic-load stripping in octopus.py work as before.

Option B: don't assume a confirmed dispatch is gone (smarter default)

When octopus_intelligent_dynamic is on and the car has been seen charging in the current dispatch (that is, it isn't in dynamic_load_car_effective), clear the PV10 "gone" flag for the remaining minutes of that dispatch. Future, unconfirmed dispatches keep the current behaviour. This targets the real risk (a dispatch for a car that isn't charging) without penalising one that is clearly happening.

Tests: add cases to the Octopus Intelligent / prediction tests covering:

  • switch off → a PV10 dispatch minute keeps its dispatch rate (Python and kernel paths match)
  • switch on → current behaviour unchanged
  • (Option B) a dispatch confirmed by car_charging_now isn't assumed gone in PV10

Predbat version

v9.3.8

Environment details

  • Inverter and battery setup: 2 × Sigenergy SigenStor (Predbat inverter type SIG). Inverter 0: 9.04 kWh, 4.5 kW. Inverter 1: 25.07 kWh, 8 kW. 34.1 kWh total. Export limit 4.25 kW per inverter.
  • Standard HAOS installer (Predbat add-on)
  • Anything else?
    • Tariff: Intelligent Octopus Go, import 6.6p off-peak / 35.61p peak, export 12p flat with rates_export_override adjustments
    • Two EVs (56 kWh and 42.5 kWh)
    • octopus_intelligent_charging currently off. Dispatch rates arrive via the Octopus Energy integration feed (is_intelligent_adjusted).
    • Relevant settings: pv_metric10_weight 0.5 (issue reproduced; fixed by setting 0), pv_metric90_weight 0.15, inverter_loss 0.10, battery_loss / battery_loss_discharge 0.04, calculate_export_oncharge on, forecast_plan_hours 48

Screenshots

Plan for Tue 13 Oct: 16:00–21:30 is a 6.6p IOG dispatch window. The battery only charges until 17:00, then discharges to the house (↘) with no charge/export cycling. Overnight (23:30–05:30 tariff off-peak), charge and export alternate as expected. Wed 14 Oct 16:30–21:30, set to 6.6p via manual import rates, cycles as expected.

(attach plan screenshot)

Log file

(attach predbat.log)

Relevant lines from the 16:22 run:

Octopus: Dispatch timeline car 0 @ 10-13 16:20:00 [-4h..+24h]: .......c|SPppppppppp....IIIIIIIII-II..................... soc 28.0/56.0kWh unplugged *
Raw charge windows [ 13-10 16:00:00 - 13-10 16:30:00 @ 6.6p 100%, 13-10 16:30:00 - 13-10 17:00:00 @ 6.6p 75%, 13-10 17:00:00 - 13-10 17:30:00 @ 6.6p 0%, ... 13-10 21:00:00 - 13-10 21:30:00 @ 6.6p 0%, ...
Filtered charge windows [ ... 14-10 17:00:00 - 14-10 19:50:00 @ 6.6p 100%, 14-10 20:30:00 - 14-10 21:30:00 @ 6.6p 100% ... ]
Export windows filtered [ ... 14-10 19:50:00 - 14-10 20:30:00 @ 12.0p 70.0% ... ]

Predbat debug yaml file

(attach predbat_debug.yaml.txt captured during a dispatch window)

Activity

  1. springfall2008 commented on Oct 13, 2026

    @springfall2008
    Owner

    🤖 Automated first-pass triage — this is a bot's initial assessment; a maintainer will review it before any action is taken.

    Classification: enhancement — a control over existing, deliberate behaviour, i.e. a feature request, so no priority is set.

    I verified the mechanism claims against current main (v9.3.7-26-g1faff228) by reading the code; every one checks out:

    • prediction.py:740-743 — an io_adjusted-flagged dispatch minute is re-priced at max(self.rate_max, import_rate) in the PV10 scenario for every simulated minute beyond the first 30, exactly as quoted. The same rule lives in the C++ kernel (prediction_kernel.cpp:777, flag fed from prediction_kernel.py:662).
    • The blend that leaves pv_metric10_weight as the only lever is plan.py:2489-2495: the PV10 simulation cost is weighted by pv_metric10_weight (default 0.15), with no separate weight for the chance an intelligent dispatch is withdrawn. One input, two roles — as reported.
    • The Intelligent switches genuinely cannot reach this: no octopus_intelligent_* setting is read anywhere in prediction.py, and the markers come from the rate feed unconditionally (fetch.py:1396, adjust_key="is_intelligent_adjusted"), so octopus_intelligent_charging being off doesn't stop them.
    • A manual import rate over a flagged slot changes the price but not the flag: apply_manual_rates() (fetch.py:2508-2526) never touches io_adjusted, so PV10 still worst-cases the minute to rate_max. And the only code that deletes a marker (octopus.py:4169-4174) simultaneously resets the price to rate_max_base and only fires for cancelled/surplus dispatches — so there is no way to "trust" a slot either.
    • Nothing clears the PV10 assumption for a dispatch the car is actively charging in: dynamic_load_car_strip_feed_rates() serves only the opposite case (a cancelled car's discount is removed), so Option B's target case exists as described.

    Context behind the behaviour: the re-pricing is deliberate and recent. PR #5304 (merged 2026-09-29) introduced it to model a dispatch that Octopus withdraws, fixing #5392 (the plan held a flat battery for an evening dispatch that never ran), with pricing pinned by test_dispatch_gone_priced_at_tariff_max in the set_rate_thresholds suite — I ran that test via the wrapper and it passes on main, so current behaviour matches this report exactly. Related: #4885 (closed, not merged) similarly argued for relying less on unconfirmed IOG slots. So this is a fair design follow-up to #5392: the hedge is sound for users bitten by that bug, but it is bundled into the solar-pessimism weight with no independent control — which is what this issue asks for.

    On the suggested fixes, both look feasible where they claim to be, with one implementation trap:

    • Option A's file paths all match current main. The trap: the Python engine reads io_adjusted directly (prediction.py:740) while the C++ kernel sees io_flag (prediction_kernel.py:662), so the flag must be gated in both places in lockstep or test_kernel_parity will break. Gating io_flag at population is sufficient for the kernel — the gone-dispatch rule is io_flag's only consumer (prediction_kernel.cpp:777 and :890) — and leaves self.io_adjusted untouched, so the charge-skew gradient (plan.py:3217) and dynamic-load stripping continue to work, as the report states.
    • Option B targets the genuinely over-conservative case: a dispatch confirmed by the car charging now is still worst-cased beyond its first 30 minutes.

    I did not independently recompute the ±2.4p/−26.6p figures or the break-even weight of ~0.08 — those depend on this install's exact losses and tariff; I verified the mechanism instead. Note the current PV10/IOG semantics are test-pinned (test_kernel_parity.py dispatch-gone cases), so either option should update those tests alongside, including a case where the switch is off and Python and kernel paths agree.

    No duplicate found, and no further files are needed — the reported cause was fully visible in the text and is confirmed in code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions