Skip to content

fix(octopus): treat a joined zero-reward session as free electricity - #4852

Merged
springfall2008 merged 1 commit into
mainfrom
fix/octopus-zero-reward-power-up-free-slot
Aug 30, 2026
Merged

fix(octopus): treat a joined zero-reward session as free electricity#4852
springfall2008 merged 1 commit into
mainfrom
fix/octopus-zero-reward-power-up-free-slot

Conversation

@mgazza

@mgazza mgazza commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4851.

Problem

Octopus puts Power Up (free electricity) events into the Power Down / saving-session data set at 0 p/kWh — the mislabelling described in #4548 point 5. The auto-join side already handles this: #4593/#4595 added a rate guard so Predbat stops trying to join events the integration will reject.

Nothing was done on the side that turns those same events into rates. The joined_events loop drops them:

if start and end and (octopoints_kwh is not None or default_rate_pence > 0) and saving_rate > 0:

0 / 8 == 0.0, so a joined, zero-reward session fails the test and is discarded. The import rate for that period stays at the standard tariff rate and no charge window is planned.

So Predbat recognises the event well enough to avoid join-spamming it, then throws it away. Observed on Octopus Intelligent Go via Octopus Energy Direct: the only way to get the free hour into the plan was to type it in by hand —

select.predbat_manual_import_rates = "+Sun 11:00=0.0,Sun 11:30=0.0"

— which is exactly what the automatic path should have produced. The plan row confirmed the source, showing import_rate_adjust_type: "manual" against a published tariff rate of 28.56p.

The free-electricity feed does not cover for this either: it returned nothing for the day (also #4548 point 5, "Nothing populated in the free electricity/power up event data"), so load_free_slot() had no current events to apply.

Change

Route a joined session reporting exactly 0 into octopus_free_slots at rate 0 instead of discarding it.

Zero reward on a Power Down event means there is nothing to earn by exporting. On a Power Up event it means the opposite of "ignore this" — importing is free.

Two deliberate choices:

  • Keyed on == 0, not on octopus_saving_session_min_octopoints_per_kwh. That threshold exists so a user can decline low-value Power Down sessions; raising it must not also cost them free Power Up hours. The two loops are testing different things on purpose.
  • A null rate keeps its existing meaning — rate not reported, octopus_saving_session_rate applies if configured — and is not treated as free.

This does not attempt to distinguish Power Up from Power Down by event type (#4548 point 7); that needs the upstream API change described there. It only stops a joined, zero-reward, known-duration session being silently discarded.

Tests

New test_saving_session_zero_octopoints_joined_is_free_slot (registered in unit_test.py), covering:

  • a joined 0 p/kWh session becomes a free slot, while a genuine 500-octopoint session alongside it still becomes a saving slot
  • load_free_slot() zeroes exactly one contiguous hour and leaves every other minute untouched, with the replicate reason marked
  • a null reward rate still produces no free slot

Confirmed to fail against the unfixed code (got [], expecting 60 minutes at rate 0, got 0).

unit_test.py -k saving and -k octopus both pass in full.

@mgazza

mgazza commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #4835 / #4837 — same user-visible symptom, different feed, and #4837 does not cover this path.

What the events are. Weekday Power Down participation earns a Weekend Happy Hour: Octopus offers several candidate hours at the weekend and the customer books one. All the offered hours appear in the saving-session available_events at octopoints_per_kwh: 0; the booked one moves to joined_events, also at 0. The lists are disjoint, so joined_events is a reliable signal of what the customer actually has — and it carries genuine weekday Power Down sessions (68 octopoints/kWh observed) in the same list, which is why the reward value is the right discriminator and this change keys on it.

Why #4837 does not cover it:

feed guard that dropped it
#4835 / #4837 (fixed) BottleCapDave octopus_free_sessionevent.*_octoplus_free_electricity_session_events if start and end and code: — booked Happy Hours publish code: null
this PR Octopus Direct saving-session joined_events saving_rate > 0 — booked Happy Hours report octopoints_per_kwh: 0

On Octopus Direct the free-electricity feed carries nothing for the day (sensor.*_free_electricity is off, its events historical), so the booked hour only ever appears in joined_events. The two reports are of the same weekend: #4835's reporter booked event ids 5798 and 5800, two of the hours left unbooked in the case behind this PR.

Observed dropped on at least two consecutive weekends, with a hand-typed manual import rate override as the workaround both times.

@springfall2008

Copy link
Copy Markdown
Owner

How does this code know its a power up and not a power down with zero reward?

@springfall2008 springfall2008 added the BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push label Aug 30, 2026
@mgazza

mgazza commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

It doesn't, and you're right to push on it — the change infers it from the reward being zero, and that inference isn't sound.

What I actually have is correlation, not a discriminator. On the account this came from, joined_events carried weekday Power Down sessions at 68 octopoints/kWh and weekend Happy Hours at 0, consistently, across two weekends. Combined with #4548 point 5 ("all national power up event times are populated in the power down/saving session 'available events' data set ... all with 0p/kWh") I took zero to mean Power Up. But that's a description of a known upstream mislabelling, not a guarantee, and I can't rule out a genuine Power Down arriving at 0 — an unknown-yet reward published as 0 rather than null would do it.

And the failure mode is bad, not neutral. If a real Power Down did report 0, this change would zero the import rate for it, so Predbat would grid-charge through a session where the user is supposed to be reducing import — paying full price believing it's free, and losing the participation. That's worse than the bug it fixes. It shouldn't merge on an inference.

There is an authoritative source already wired up. async_get_flexibility_campaign_events() queries customerFlexibilityCampaignEvents per campaign slug, which separates the two properly:

campaign_slug="octoplus-saving-sessions"   # Power Down
campaign_slug="free_electricity"           # Power Up

No inference needed — the API says which campaign an event belongs to.

The reason it isn't already solving this is that free_electricity returns nothing current. On the account here it returns 15 events, last: 50, newest 2025-10-25 — i.e. the old free-electricity campaign, which appears to have ended. Weekend Happy Hours are evidently a different campaign, so they fall through to the legacy savingSessions query, which is exactly where the two kinds get mixed at 0 p/kWh.

So the correct fix looks like: find the campaign slug Happy Hours are published under, query it alongside the other two, and feed the results into free_electricity_events — which already flows into octopus_free_slots through the existing path. That needs no guessing about reward values at all, and would also cover the case where a Power Down legitimately reports 0.

I don't know the slug — I only have the two above, and the inconsistent naming (octoplus-saving-sessions hyphenated vs free_electricity underscored) suggests they came from different places, so I'd rather not guess. Do you know it, or is there someone with a booked Happy Hour who could dump customerFlexibilityCampaignEvents for a few candidate slugs?

Happy to rework this PR that way, or close it if you'd rather fold it into #4548 point 7. Either way I don't think the current version should go in as-is.

@mgazza
mgazza marked this pull request as draft August 30, 2026 08:09
@springfall2008

Copy link
Copy Markdown
Owner

Ran pr-cleanup on this PR: CI is green (kernel-binaries, pre-commit) and there are no unaddressed inline review comments — the only outstanding item is the top-level exchange between @springfall2008 and @mgazza above, which already reaches a conclusion.

I checked whether the open question in that exchange (is there a way to identify the correct campaign slug for Happy Hour events without guessing) could be resolved from the codebase. async_get_flexibility_events() (octopus.py:1371-1420) queries customerFlexibilityCampaignEvents with two hardcoded slug literals, octoplus-saving-sessions and free_electricity — there's no query anywhere that enumerates a customer's actual campaigns, so the codebase itself has no way to surface the slug Happy Hour events are published under. I don't have a live account to dump customerFlexibilityCampaignEvents against candidate slugs either.

That leaves mgazza's assessment as the correct read: the == 0 inference this PR currently relies on is unverified, and its failure mode — silently zeroing the import rate for a genuine Power Down that happens to report 0 — is worse than the bug it fixes (#4851). There's no safe code change to make without the campaign slug or account data mgazza asked for, so I'm not implementing anything further here rather than guessing. Leaving this open for @springfall2008 to weigh in on the slug, or for a user with a booked Happy Hour to supply the data.

@springfall2008 springfall2008 removed the BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push label Aug 30, 2026
Fixes #4851.

Octopus publishes Power Up and Power Down through the same savingSessions feed
and distinguishes them with `eventType`:

  TURN_DOWN           reduce consumption, rewarded in octopoints
  WEEKEND_HAPPY_HOUR  an earned free import hour, reward 0
  TURN_UP             increase consumption, rewarded in octopoints

Predbat's query never asked for that field, so a booked Weekend Happy Hour
arrived indistinguishable from a saving session at 0 p/kWh and was dropped by
`saving_rate > 0`. The free hour never reached the plan: the import rate stayed
at the standard tariff rate, no charge window was planned, and the only way to
use it was to enter it by hand as a manual import rate override.

Request `eventType`, carry it through to the joined and available event lists,
and route a joined WEEKEND_HAPPY_HOUR into octopus_free_slots at rate 0.

Deliberately NOT keyed on the reward value. A zero reward looks like a free hour
but a TURN_DOWN reporting 0 - an unknown reward published as 0 rather than null -
would then be zeroed on IMPORT, making Predbat grid-charge through a session
where the user is meant to be reducing import: full price, believing it free, and
the participation lost. eventType is the only sound discriminator.

TURN_UP stays on the saving path. It means import more, but it is rewarded in
octopoints rather than free, so folding it in here would misprice it.

An absent eventType falls through to the existing behaviour rather than guessing,
so the BottleCapDave feed and older API responses are unaffected.

Tests cover all four: a joined WEEKEND_HAPPY_HOUR becomes a free slot while a
rewarded TURN_DOWN in the same joined_events list stays a saving slot, a
TURN_DOWN reporting 0 does not become free, TURN_UP does not become free, and a
missing eventType does not become free. Reverting to the reward-value test fails
two of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
@mgazza
mgazza force-pushed the fix/octopus-zero-reward-power-up-free-slot branch from 62db9c1 to 328bf1b Compare August 30, 2026 08:14
@mgazza

mgazza commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

Answered — and thank you, my original approach was wrong.

eventType is in the API and Predbat simply wasn't asking for it. From @sammort's payload dumps on #4531:

{ "id": 4809, "code": "EVENT_41_050826", "rewardPerKwhInOctoPoints": 93, "eventType": "TURN_DOWN",          "status": "DONE" }
{ "id": 5144, "code": "EVENT_45_160826", "rewardPerKwhInOctoPoints": 0,  "eventType": "WEEKEND_HAPPY_HOUR", "status": "UPCOMING" }

with the schema description for the field being "Whether the event is a reduction event (TURN_DOWN) or an increase event (TURN_UP)."

Our savingSessions query requests id, code, rewardPerKwhInOctoPoints, startAt, endAt, devEvent, targetRegion — no eventType, so both kinds arrived indistinguishable and the reward value was the only thing left to look at. That was the whole problem.

Reworked (force-pushed, single commit):

  • request eventType in the query
  • carry it through to the joined and available event lists
  • route a joined WEEKEND_HAPPY_HOUR into octopus_free_slots

TURN_UP deliberately stays on the saving path — it means import more, but it is rewarded in octopoints rather than free, so folding it in here would misprice it. An absent eventType falls through to existing behaviour, so the BottleCapDave feed and older API responses are unaffected.

Your specific case is now a test: a TURN_DOWN reporting 0 must not become free. Worth spelling out why it matters, since it is the failure mode I had built in — a zero-reward TURN_DOWN treated as free zeroes the import rate, so Predbat would grid-charge through a session where the user is meant to be reducing import: full price, believing it free, and the participation lost. Strictly worse than the bug being fixed.

Four cases covered, and reverting to the reward-value test fails two of them:

  • joined WEEKEND_HAPPY_HOUR becomes a free slot, while a rewarded TURN_DOWN in the same joined_events list stays a saving slot
  • TURN_DOWN at 0 reward does not become free
  • TURN_UP does not become free
  • missing eventType does not become free

One thing I could use a second opinion on: I've left available_events alone, so an offered but unbooked Happy Hour is not treated as free. That matches what I can observe — Octopus offers several candidate hours and the customer books one, and only the booked one moves to joined_events. But if Octopus ever auto-enrols rather than requiring a booking, the joined list would be the wrong place to look. Do you know if that varies?

unit_test.py -k saving and -k octopus both pass in full.

@mgazza
mgazza marked this pull request as ready for review August 30, 2026 08:15
@springfall2008
springfall2008 merged commit dc0793d into main Aug 30, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/octopus-zero-reward-power-up-free-slot branch August 30, 2026 08:46
@gcoan

gcoan commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

One thing I could use a second opinion on: I've left available_events alone, so an offered but unbooked Happy Hour is not treated as free. That matches what I can observe — Octopus offers several candidate hours and the customer books one, and only the booked one moves to joined_events. But if Octopus ever auto-enrols rather than requiring a booking, the joined list would be the wrong place to look. Do you know if that varies?

@mgazza The changes made look to match what I understand of what the BCD integration does

Answering your last point though, Octopus can and DID auto-join people to a free saving session, the one after the Eclipse power down, people were told what hour they were allocated. The events that were available that they were not allocated to still appeared in available events, the Octopus allocated event appeared in joined events.

I'm not sure what the issue is you are suggesting , the Octopus selected joined events appears the same as the user selected power up free hour

@mgazza

mgazza commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

@gcoan thank you — that settles it, and it means my worry was unfounded. If Octopus allocates rather than the user booking, the allocated event still lands in joined_events and the ones they weren't allocated stay in available_events. So joined_events is the right place to look either way, and there's no case to handle differently. Nothing to change there.

I've taken your other point from #4851 and pushed it as a second commit: Weekend Happy Hours are no longer offered in available_events, matching BCD v19.0.1. They can't be joined through the API, so listing them only produces the rejected join attempts from #4593/#4595 and fills the join selector with unselectable options. Now that eventType is being requested they're identifiable, so this can be done precisely rather than by reward value.

One ordering trap worth flagging for review, because it's invisible in the diff: the reward/code/type lookup maps are built from the same events list, and a joined Happy Hour reads its type out of those maps. Skipping before they're populated leaves the joined event with event_type: None and the injected default reward — so a free hour gets priced as an 80p/kWh saving session, which is worse than the original bug. The skip therefore sits after the maps are filled, and there's a test that fails if it's moved above them.

Also fixed the Copilot nits on #4849 (test list numbering, docstring phrasing) and #4850 (log prefix — the new warnings now use Warn: Kraken: … like the rest of the component).

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.

Octopus: joined Power Up (free electricity) sessions reported at 0 p/kWh are dropped from the plan

3 participants