Repository navigation
Metric 10 weighting on IOG Dispatches #5540
Description
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 * metric90So one setting now controls two unrelated things:
- How much weight the plan gives to a pessimistic solar forecast.
- 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_ratesproduces exactly the charge/export cycling I want. Manual rates don't carry theio_adjustedmarker, so PV10 leaves them alone. - Applying a manual rate over a dispatch slot doesn't help either. The price changes but the
io_adjustedmarker 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 torate_max_base. - Even when
octopus_intelligent_dynamicis 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:
- a27403b (16 Nov 2025, Fox fixes, change default battery rate scaling discharge #2926, first in v8.27.22) introduced the PV10 "dispatch goes away" re-pricing for minutes the Octopus Energy integration feed marks
is_intelligent_adjusted. - f29667b (2 Jul 2026, C++ optimized kernel, gives 3-4x speedup on planning #4169) mirrored it into the C++ kernel.
- d7b47cd (11 Aug 2026, feat(plan): add a pv90 upside forecast scenario to balance the one-sided pv10 hedge #4462) replaced the downside-only PV10 clamp with a signed weighted average (
pv_metric10_weight/pv_metric90_weight). - a9af46a (29 Sep 2026, first in v9.3.3) extended the marking to dispatches added by Predbat's own Octopus component (
rate_add_io_slots()). For users on that path, IOG dispatch windows only started being re-priced in PV10 from v9.3.3, so this may look like a regression in 9.3.3+.
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)
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",
},fetch.py: read it alongsideoctopus_intelligent_trust_slots(around line 3776):
self.octopus_intelligent_pv10_dispatch_gone = self.get_arg("octopus_intelligent_pv10_dispatch_gone")prediction.py: copy it frombase(next toself.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 > 30prediction_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_nowisn'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_overrideadjustments - Two EVs (56 kWh and 42.5 kWh)
octopus_intelligent_chargingcurrently off. Dispatch rates arrive via the Octopus Energy integration feed (is_intelligent_adjusted).- Relevant settings:
pv_metric10_weight0.5 (issue reproduced; fixed by setting 0),pv_metric90_weight0.15,inverter_loss0.10,battery_loss/battery_loss_discharge0.04,calculate_export_onchargeon,forecast_plan_hours48
- Tariff: Intelligent Octopus Go, import 6.6p off-peak / 35.61p peak, export 12p flat with
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
springfall2008 commentedon Oct 13, 2026
🤖 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— anio_adjusted-flagged dispatch minute is re-priced atmax(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 fromprediction_kernel.py:662).pv_metric10_weightas the only lever isplan.py:2489-2495: the PV10 simulation cost is weighted bypv_metric10_weight(default 0.15), with no separate weight for the chance an intelligent dispatch is withdrawn. One input, two roles — as reported.octopus_intelligent_*setting is read anywhere inprediction.py, and the markers come from the rate feed unconditionally (fetch.py:1396,adjust_key="is_intelligent_adjusted"), sooctopus_intelligent_chargingbeing off doesn't stop them.apply_manual_rates()(fetch.py:2508-2526) never touchesio_adjusted, so PV10 still worst-cases the minute torate_max. And the only code that deletes a marker (octopus.py:4169-4174) simultaneously resets the price torate_max_baseand only fires for cancelled/surplus dispatches — so there is no way to "trust" a slot either.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_maxin theset_rate_thresholdssuite — 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:
io_adjusteddirectly (prediction.py:740) while the C++ kernel seesio_flag(prediction_kernel.py:662), so the flag must be gated in both places in lockstep ortest_kernel_paritywill break. Gatingio_flagat population is sufficient for the kernel — the gone-dispatch rule isio_flag's only consumer (prediction_kernel.cpp:777and:890) — and leavesself.io_adjusteduntouched, so the charge-skew gradient (plan.py:3217) and dynamic-load stripping continue to work, as the report states.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.pydispatch-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.