Skip to content

Commit 6bdc14f

Browse files
springfall2008CIclaude
authored
fix(kraken): scale exc-VAT applicableRates prices to inc-VAT from the agreement (#5418)
The GraphQL applicableRates fallback stored its single `value` as value_inc_vat. The BottlecapDave-derived EDF integration treats that value as exc-VAT, which would leave import rates 5% low on tariffs the REST products API cannot serve (EDF day/night tariffs, E.ON TOU and retired products). Take the VAT multiplier from the import agreement's standingCharge / preVatStandingCharge and scale import applicableRates values by it. It follows the rate in force - 1.0 during the Oct 2026 - Mar 2027 zero rate, 1.05 after - without date logic. Export is left unscaled. The multiplier is refreshed on every tariff discovery and cached across restarts. Fixes #5417 Co-authored-by: CI <ci@example.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 42a4df5 commit 6bdc14f

4 files changed

Lines changed: 172 additions & 23 deletions

File tree

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,7 @@ startswith
634634
startt
635635
stdlib
636636
stepline
637+
stevekirtley
637638
Storion
638639
Stromligning
639640
strptime

‎apps/predbat/kraken.py‎

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ async def handle_oauth_401(self):
7171
# The tariff is queried via the `... on TariffType` interface fragment (all concrete
7272
# tariff types implement it) rather than an enumerated list of concrete fragments —
7373
# so an export/SEG tariff whose type wasn't in the old list is no longer dropped.
74+
#
75+
# standingCharge / preVatStandingCharge are on the same interface (EDF and E.ON alike). Their
76+
# ratio is the VAT multiplier used to bring exc-VAT applicableRates prices up to inc-VAT.
7477
KRAKEN_ACCOUNT_QUERY = """{{
7578
account(accountNumber: "{account_number}") {{
7679
number
@@ -83,7 +86,7 @@ async def handle_oauth_401(self):
8386
validFrom
8487
validTo
8588
tariff {{
86-
... on TariffType {{ tariffCode displayName productCode }}
89+
... on TariffType {{ tariffCode displayName productCode standingCharge preVatStandingCharge }}
8790
}}
8891
}}
8992
}}
@@ -99,7 +102,9 @@ async def handle_oauth_401(self):
99102
# product such as an EDF SEG export tariff, or a TOU tariff absent from the REST API such as
100103
# E-TOU-* on E.ON Next) or 400 (the product is in the REST API but has no /standard-unit-rates/
101104
# resource, as day/night-structured EDF tariffs answer — GH#5166).
102-
# Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window.
105+
# Returns value (pence/kWh EXC VAT), validFrom, validTo for the requested window. Unlike the
106+
# REST products API there is no inc-VAT field, so the caller scales by the VAT multiplier taken
107+
# from the account agreement (see KrakenAPI._vat_multiplier).
103108
# applicableRates is a Relay-style connection (ApplicableRateConnectionTypeConnection),
104109
# so the rate fields live under edges { node { ... } }, not directly on the field. The
105110
# connection is mandatory-paginated (KT-CT-1201) — a `first` value is required, and `after`
@@ -225,6 +230,10 @@ async def handle_oauth_401(self):
225230
KRAKEN_RATES_PAGE_SIZE = 100
226231
KRAKEN_RATES_MAX_PAGES = 20
227232

233+
# Largest inc-VAT / exc-VAT ratio accepted from an agreement's standing charges. VAT only ever
234+
# adds, and never more than a quarter, so anything outside 1.0 - this is bad data, not a VAT rate.
235+
KRAKEN_VAT_MULTIPLIER_MAX = 1.25
236+
228237
# How stale cached data may get before run() re-queries the API (minutes). Tariff/account data
229238
# changes rarely so it is cached for hours; rates are published roughly daily so a 30-minute
230239
# refresh is ample. Cached data is restored on restart, so these also bound post-restart re-fetch.
@@ -268,6 +277,7 @@ def initialize(
268277
self.account_id = account_id
269278
self.configured_mpan = mpan # From SaaS config — preferred MPAN to match
270279
self.import_mpan = None # Set after first successful tariff discovery
280+
self.import_vat_multiplier = 1.0 # inc-VAT / exc-VAT ratio from the import agreement, for applicableRates
271281
self.current_tariff = None
272282
self.export_tariff = None # Export tariff (discovered dynamically)
273283
self.wired = False
@@ -387,6 +397,30 @@ async def async_graphql_query(self, query, request_context, _retry_count=0):
387397
self.failures_total += 1
388398
return None
389399

400+
@staticmethod
401+
def _vat_multiplier(tariff):
402+
"""Return the inc-VAT / exc-VAT ratio for a GraphQL tariff, or 1.0 if it cannot be derived.
403+
404+
applicableRates publishes exc-VAT prices only, while everything downstream expects the
405+
inc-VAT basis the REST products API uses. The VAT rate is not hardcoded - GB domestic
406+
electricity is zero-rated from October 2026 to March 2027 and 5% either side - but taken
407+
from the tariff's own standingCharge (inc VAT) / preVatStandingCharge pair, which follows
408+
whatever rate applies now. A missing, zero or implausible pair leaves prices unscaled.
409+
"""
410+
if not isinstance(tariff, dict):
411+
return 1.0
412+
try:
413+
inc_vat = float(tariff.get("standingCharge"))
414+
exc_vat = float(tariff.get("preVatStandingCharge"))
415+
except (TypeError, ValueError):
416+
return 1.0
417+
if exc_vat <= 0:
418+
return 1.0
419+
ratio = inc_vat / exc_vat
420+
if 1.0 <= ratio <= KRAKEN_VAT_MULTIPLIER_MAX:
421+
return ratio
422+
return 1.0
423+
390424
def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False):
391425
"""Find the current active tariff from a list of meter points.
392426
@@ -396,7 +430,7 @@ def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False
396430
is_export: If True, only match EXPORT tariff codes; if False, skip them
397431
398432
Returns:
399-
dict with tariff_code, product_code, mpan or None
433+
dict with tariff_code, product_code, mpan, vat_multiplier or None
400434
"""
401435
now = datetime.now(timezone.utc)
402436

@@ -451,6 +485,7 @@ def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False
451485
"tariff_code": tariff_code,
452486
"product_code": product_code,
453487
"mpan": mpan,
488+
"vat_multiplier": self._vat_multiplier(tariff),
454489
}
455490

456491
return None
@@ -614,6 +649,13 @@ async def async_find_tariffs(self):
614649
# Store MPAN for GraphQL fallback in async_fetch_rates_graphql()
615650
self.import_mpan = import_result["mpan"]
616651

652+
# Refreshed on every discovery, not only a tariff change: the VAT rate can move while the
653+
# tariff stays the same. Kept off the tariff dict so it never reads as a tariff change.
654+
vat_multiplier = import_result["vat_multiplier"]
655+
if vat_multiplier != self.import_vat_multiplier:
656+
self.log(f"Kraken: Import VAT multiplier {self.import_vat_multiplier:.4f} -> {vat_multiplier:.4f} (from agreement standing charge)")
657+
self.import_vat_multiplier = vat_multiplier
658+
617659
# Discover export tariff — always re-discover to detect tariff changes
618660
await self._discover_export_tariff(all_meter_points, my_address)
619661

@@ -694,7 +736,7 @@ def _normalize_rate_timestamps(results):
694736

695737
return results
696738

697-
async def async_fetch_rates_graphql(self, mpan, account_id=None):
739+
async def async_fetch_rates_graphql(self, mpan, account_id=None, vat_multiplier=1.0):
698740
"""Fetch rates via GraphQL applicableRates — fallback when REST returns non-200.
699741
700742
Used when the product code has been removed from the REST API (e.g. product replaced
@@ -706,6 +748,8 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
706748
mpan: The MPAN (meter point access number) to query rates for.
707749
account_id: Account number that owns the MPAN. Defaults to the configured
708750
import account; pass the export account for E.ON split import/export accounts.
751+
vat_multiplier: inc-VAT / exc-VAT ratio to scale the exc-VAT prices by. Pass the import
752+
agreement's ratio for import; leave at 1.0 for export, which carries no VAT.
709753
710754
Returns list of rate dicts with value_inc_vat, value_exc_vat, valid_from, valid_to, or None.
711755
"""
@@ -738,7 +782,7 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
738782
data = await self.async_graphql_query(query, "applicable-rates-graphql")
739783
if not data:
740784
# Network/auth failure mid-pagination: return what we have if any, else None.
741-
return self._finalize_graphql_rates(raw_rates, mpan) if raw_rates else None
785+
return self._finalize_graphql_rates(raw_rates, mpan, vat_multiplier) if raw_rates else None
742786

743787
connection = data.get("applicableRates") or {}
744788
raw_rates.extend(self._connection_nodes(connection))
@@ -757,17 +801,20 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
757801
self.log("Warn: Kraken: applicableRates GraphQL returned no rate periods")
758802
return None
759803

760-
return self._finalize_graphql_rates(raw_rates, mpan)
804+
return self._finalize_graphql_rates(raw_rates, mpan, vat_multiplier)
761805

762-
def _finalize_graphql_rates(self, raw_rates, mpan):
763-
"""Convert applicableRates connection nodes into rate dicts, or None if none are valid."""
806+
def _finalize_graphql_rates(self, raw_rates, mpan, vat_multiplier=1.0):
807+
"""Convert applicableRates connection nodes into rate dicts, or None if none are valid.
808+
809+
The node value is exc-VAT; vat_multiplier scales it to the inc-VAT basis used downstream.
810+
"""
764811
results = []
765812
for r in raw_rates:
766813
value = r.get("value")
767814
if value is None:
768815
continue
769-
value_inc_vat = float(value)
770-
value_exc_vat = round(value_inc_vat / 1.05, 4)
816+
value_exc_vat = float(value)
817+
value_inc_vat = round(value_exc_vat * vat_multiplier, 4)
771818
results.append(
772819
{
773820
"value_inc_vat": value_inc_vat,
@@ -862,9 +909,11 @@ async def async_fetch_rates(self, tariff=None):
862909
if is_export:
863910
fallback_mpan = self.export_mpan
864911
fallback_account = self.export_account_id or self.account_id
912+
fallback_vat_multiplier = 1.0 # export payments carry no VAT
865913
else:
866914
fallback_mpan = self.import_mpan
867915
fallback_account = self.account_id
916+
fallback_vat_multiplier = self.import_vat_multiplier
868917
url = self.build_rates_url(tariff["product_code"], tariff["tariff_code"])
869918

870919
# 1) Public (unauthenticated) attempt.
@@ -898,7 +947,7 @@ async def async_fetch_rates(self, tariff=None):
898947
reason = "product not in the REST API" if err in KRAKEN_REST_PRODUCT_NOT_FOUND_STATUSES else "tariff has no standard-unit-rates endpoint"
899948
self.log(f"Kraken: REST rates HTTP {err} for {tariff['tariff_code']} ({reason}), using GraphQL applicableRates for {kind} MPAN {fallback_mpan}")
900949
failures_before = self.failures_total
901-
rates = await self.async_fetch_rates_graphql(fallback_mpan, account_id=fallback_account)
950+
rates = await self.async_fetch_rates_graphql(fallback_mpan, account_id=fallback_account, vat_multiplier=fallback_vat_multiplier)
902951
if rates is None and self.failures_total == failures_before:
903952
# async_graphql_query counts its own failures (auth, non-200, GraphQL errors,
904953
# network), so only add a count when the fallback came back empty without
@@ -1051,6 +1100,8 @@ async def load_kraken_cache(self):
10511100
self.current_tariff = data.get("current_tariff")
10521101
self.export_tariff = data.get("export_tariff")
10531102
self.import_mpan = data.get("import_mpan")
1103+
# Absent from a cache written before the multiplier existed; the next discovery sets it.
1104+
self.import_vat_multiplier = data.get("import_vat_multiplier") or 1.0
10541105
# Prefer cached discovered values but never clobber a configured value with None.
10551106
self.export_mpan = data.get("export_mpan") or self.export_mpan
10561107
self.export_account_id = data.get("export_account_id") or self.export_account_id
@@ -1082,6 +1133,7 @@ async def save_kraken_cache(self):
10821133
"current_tariff": self.current_tariff,
10831134
"export_tariff": self.export_tariff,
10841135
"import_mpan": self.import_mpan,
1136+
"import_vat_multiplier": self.import_vat_multiplier,
10851137
"export_mpan": self.export_mpan,
10861138
"export_account_id": self.export_account_id,
10871139
"import_rates": self.import_rates,

0 commit comments

Comments
 (0)