fix(octopus): prefer the direct debit rate when both payment methods overlap - #5145
Conversation
…overlap The REST tariff endpoints return a DIRECT_DEBIT row and a NON_DIRECT_DEBIT row over the same validity window. Both went unfiltered into minute_data(), which writes each row over its range, so the row appearing last in the response won. Measured against the live public API for E-1R-VAR-22-11-01-A: predbat used 27.8488 where the direct debit rate is 26.381355, and 5 of the 17 windows returned put DIRECT_DEBIT last, so the displayed variant was not even stable between periods. filter_payment_method() now runs at both parse points, the OctopusAPI component path and the legacy import_octopus_url path. Rows with payment_method None (Agile, day/night), rows without the key, and responses that never mention DIRECT_DEBIT are all left untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The change is small, localized to the intended parse points, and includes targeted regression coverage for the reported order-dependence bug and its edge cases.
Review effort: Lite
Findings: None
What changed in this PR
This PR fixes an Octopus REST tariff parsing bug where overlapping DIRECT_DEBIT and NON_DIRECT_DEBIT rows for the same validity window could overwrite each other in minute_data() depending on API response order, causing direct-debit customers to see the higher non-direct-debit rate. It adds a conservative parse-time filter that prefers DIRECT_DEBIT when present, while preserving existing behaviour for tariffs that don’t provide (or don’t include) that payment-method variant.
Changes:
- Add
filter_payment_method()inoctopus.pyand apply it at both Octopus REST parse points (get_octopus_rates_direct()anddownload_octopus_rates_func()). - Add a new
payment_methodsub-test to verify correct selection for unit rates and standing charges across multiple overlap/edge cases.
| File | Description |
|---|---|
| apps/predbat/octopus.py | Introduces filter_payment_method() and applies it before minute_data() to make payment-method overlaps deterministic (prefer direct debit). |
| apps/predbat/tests/test_octopus_url.py | Adds a new sub-test covering overlapping payment-method rows and edge cases (None / single-variant). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…nual paths The filter added at the two parse points does not reach two other consumers of the same overlapping DIRECT_DEBIT / NON_DIRECT_DEBIT rows: - async_get_day_night_rates builds its own schedule rows, without a payment_method, before get_octopus_rates_direct ever runs, so a Flexible dual-register tariff (E-2R-*, which has no standard-unit-rates endpoint and so always takes this path) still used whichever variant _get_rate_for_time saw first. Against the live E-2R-VAR-22-11-01-A feed, 4 of its 17 windows return NON_DIRECT_DEBIT first. - annual_tariff._download_rows feeds the same raw rows to minute_data and to _rows_to_local_pattern. Live January 2025 rates for E-1R-VAR-22-11-01-A resolved to 26.3156p (non-direct-debit) rather than 25.5749p. filter_payment_method moves to utils.py, next to the minute_data whose last-row-wins behaviour causes this, so annual_tariff does not have to import the Octopus component module - components.py loads those lazily. The annual rows are filtered on the way out of _download_rows rather than before the cache write, so the cache keeps Octopus's own rows and a year of cached history would not need re-downloading if the preferred payment method ever became configurable; the cached branch is filtered on read. Tests cover both response orders on each path, because the three consumers resolve an overlap in opposite directions: minute_data keeps the last row of an overlap, while _get_rate_for_time and _rows_to_local_pattern keep the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #5144
The bug
The REST tariff endpoints return two overlapping rows per validity window, one
DIRECT_DEBITand oneNON_DIRECT_DEBIT. Both go straight intominute_data(), which writes each row over its range, so whichever row comes last in the response wins.Reproduced against the live public API (
E-1R-VAR-22-11-01-A, no auth), using the repository's ownminute_data:So a direct debit customer is shown the higher rate — and the last line is the part that is easy to miss: 5 of the 17 windows the API returns put
DIRECT_DEBITlast, so the displayed rate silently flips variant from one period to the next rather than always being the higher one.payment_methodappeared nowhere in the production code before this change — only in test fixtures.The fix
A module-level
filter_payment_method()inoctopus.py, applied at both parse points, as suggested in the triage on the issue:get_octopus_rates_direct()— the OctopusAPI component path, for unit rates and standing charges;download_octopus_rates_func()— the legacyimport_octopus_url/ compare path.It is deliberately conservative:
payment_method: None(Agile, day/night) pass through untouched;DIRECT_DEBITis returned unchanged, so a non-direct-debit-only feed keeps its rate rather than becoming empty.Filtering at the parse point rather than at download also covers data already sitting in the URL and storage caches.
Tests
A seventh sub-test in
tests/test_octopus_url.py(payment_method), covering the five cases:payment_method: NoneNON_DIRECT_DEBITrowsRun with the fix:
RESULTS: 7 passed, 0 failed out of 7 tests.Run with
octopus.pyreverted tomain, to show the test would have caught this:Note that the "direct debit row last" case passes even without the fix — that is the order-dependence itself.
Other checks
./run_all --quick: the only failure ispredheat("Predheat did not run promptly after being re-enabled"), which fails the same way on a clean checkout ofmainon this machine (Windows), so it is unrelated to this change.black --checkon both files: unchanged.flake8: same number of findings asmainonoctopus.py(all pre-existing).interrogate: 90.4% onmain→ 90.6% with this change, for these two files.🤖 Generated with Claude Code