Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ startswith
startt
stdlib
stepline
stevekirtley
Storion
Stromligning
strptime
Expand Down
74 changes: 63 additions & 11 deletions apps/predbat/kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ async def handle_oauth_401(self):
# The tariff is queried via the `... on TariffType` interface fragment (all concrete
# tariff types implement it) rather than an enumerated list of concrete fragments —
# so an export/SEG tariff whose type wasn't in the old list is no longer dropped.
#
# standingCharge / preVatStandingCharge are on the same interface (EDF and E.ON alike). Their
# ratio is the VAT multiplier used to bring exc-VAT applicableRates prices up to inc-VAT.
KRAKEN_ACCOUNT_QUERY = """{{
account(accountNumber: "{account_number}") {{
number
Expand All @@ -83,7 +86,7 @@ async def handle_oauth_401(self):
validFrom
validTo
tariff {{
... on TariffType {{ tariffCode displayName productCode }}
... on TariffType {{ tariffCode displayName productCode standingCharge preVatStandingCharge }}
}}
}}
}}
Expand All @@ -99,7 +102,9 @@ async def handle_oauth_401(self):
# product such as an EDF SEG export tariff, or a TOU tariff absent from the REST API such as
# E-TOU-* on E.ON Next) or 400 (the product is in the REST API but has no /standard-unit-rates/
# resource, as day/night-structured EDF tariffs answer — GH#5166).
# Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window.
# Returns value (pence/kWh EXC VAT), validFrom, validTo for the requested window. Unlike the
# REST products API there is no inc-VAT field, so the caller scales by the VAT multiplier taken
# from the account agreement (see KrakenAPI._vat_multiplier).
# applicableRates is a Relay-style connection (ApplicableRateConnectionTypeConnection),
# so the rate fields live under edges { node { ... } }, not directly on the field. The
# connection is mandatory-paginated (KT-CT-1201) — a `first` value is required, and `after`
Expand Down Expand Up @@ -225,6 +230,10 @@ async def handle_oauth_401(self):
KRAKEN_RATES_PAGE_SIZE = 100
KRAKEN_RATES_MAX_PAGES = 20

# Largest inc-VAT / exc-VAT ratio accepted from an agreement's standing charges. VAT only ever
# adds, and never more than a quarter, so anything outside 1.0 - this is bad data, not a VAT rate.
KRAKEN_VAT_MULTIPLIER_MAX = 1.25

# How stale cached data may get before run() re-queries the API (minutes). Tariff/account data
# changes rarely so it is cached for hours; rates are published roughly daily so a 30-minute
# refresh is ample. Cached data is restored on restart, so these also bound post-restart re-fetch.
Expand Down Expand Up @@ -268,6 +277,7 @@ def initialize(
self.account_id = account_id
self.configured_mpan = mpan # From SaaS config — preferred MPAN to match
self.import_mpan = None # Set after first successful tariff discovery
self.import_vat_multiplier = 1.0 # inc-VAT / exc-VAT ratio from the import agreement, for applicableRates
self.current_tariff = None
self.export_tariff = None # Export tariff (discovered dynamically)
self.wired = False
Expand Down Expand Up @@ -387,6 +397,30 @@ async def async_graphql_query(self, query, request_context, _retry_count=0):
self.failures_total += 1
return None

@staticmethod
def _vat_multiplier(tariff):
"""Return the inc-VAT / exc-VAT ratio for a GraphQL tariff, or 1.0 if it cannot be derived.

applicableRates publishes exc-VAT prices only, while everything downstream expects the
inc-VAT basis the REST products API uses. The VAT rate is not hardcoded - GB domestic
electricity is zero-rated from October 2026 to March 2027 and 5% either side - but taken
from the tariff's own standingCharge (inc VAT) / preVatStandingCharge pair, which follows
whatever rate applies now. A missing, zero or implausible pair leaves prices unscaled.
"""
if not isinstance(tariff, dict):
return 1.0
try:
inc_vat = float(tariff.get("standingCharge"))
exc_vat = float(tariff.get("preVatStandingCharge"))
except (TypeError, ValueError):
return 1.0
if exc_vat <= 0:
return 1.0
ratio = inc_vat / exc_vat
if 1.0 <= ratio <= KRAKEN_VAT_MULTIPLIER_MAX:
return ratio
return 1.0

def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False):
"""Find the current active tariff from a list of meter points.

Expand All @@ -396,7 +430,7 @@ def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False
is_export: If True, only match EXPORT tariff codes; if False, skip them

Returns:
dict with tariff_code, product_code, mpan or None
dict with tariff_code, product_code, mpan, vat_multiplier or None
"""
now = datetime.now(timezone.utc)

Expand Down Expand Up @@ -451,6 +485,7 @@ def _find_active_tariff(self, meter_points, preferred_mpan=None, is_export=False
"tariff_code": tariff_code,
"product_code": product_code,
"mpan": mpan,
"vat_multiplier": self._vat_multiplier(tariff),
}

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

# Refreshed on every discovery, not only a tariff change: the VAT rate can move while the
# tariff stays the same. Kept off the tariff dict so it never reads as a tariff change.
vat_multiplier = import_result["vat_multiplier"]
if vat_multiplier != self.import_vat_multiplier:
self.log(f"Kraken: Import VAT multiplier {self.import_vat_multiplier:.4f} -> {vat_multiplier:.4f} (from agreement standing charge)")
self.import_vat_multiplier = vat_multiplier

# Discover export tariff — always re-discover to detect tariff changes
await self._discover_export_tariff(all_meter_points, my_address)

Expand Down Expand Up @@ -694,7 +736,7 @@ def _normalize_rate_timestamps(results):

return results

async def async_fetch_rates_graphql(self, mpan, account_id=None):
async def async_fetch_rates_graphql(self, mpan, account_id=None, vat_multiplier=1.0):
"""Fetch rates via GraphQL applicableRates — fallback when REST returns non-200.

Used when the product code has been removed from the REST API (e.g. product replaced
Expand All @@ -706,6 +748,8 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
mpan: The MPAN (meter point access number) to query rates for.
account_id: Account number that owns the MPAN. Defaults to the configured
import account; pass the export account for E.ON split import/export accounts.
vat_multiplier: inc-VAT / exc-VAT ratio to scale the exc-VAT prices by. Pass the import
agreement's ratio for import; leave at 1.0 for export, which carries no VAT.

Returns list of rate dicts with value_inc_vat, value_exc_vat, valid_from, valid_to, or None.
"""
Expand Down Expand Up @@ -738,7 +782,7 @@ async def async_fetch_rates_graphql(self, mpan, account_id=None):
data = await self.async_graphql_query(query, "applicable-rates-graphql")
if not data:
# Network/auth failure mid-pagination: return what we have if any, else None.
return self._finalize_graphql_rates(raw_rates, mpan) if raw_rates else None
return self._finalize_graphql_rates(raw_rates, mpan, vat_multiplier) if raw_rates else None

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

return self._finalize_graphql_rates(raw_rates, mpan)
return self._finalize_graphql_rates(raw_rates, mpan, vat_multiplier)

def _finalize_graphql_rates(self, raw_rates, mpan):
"""Convert applicableRates connection nodes into rate dicts, or None if none are valid."""
def _finalize_graphql_rates(self, raw_rates, mpan, vat_multiplier=1.0):
"""Convert applicableRates connection nodes into rate dicts, or None if none are valid.

The node value is exc-VAT; vat_multiplier scales it to the inc-VAT basis used downstream.
"""
results = []
for r in raw_rates:
value = r.get("value")
if value is None:
continue
value_inc_vat = float(value)
value_exc_vat = round(value_inc_vat / 1.05, 4)
value_exc_vat = float(value)
value_inc_vat = round(value_exc_vat * vat_multiplier, 4)
results.append(
{
"value_inc_vat": value_inc_vat,
Expand Down Expand Up @@ -862,9 +909,11 @@ async def async_fetch_rates(self, tariff=None):
if is_export:
fallback_mpan = self.export_mpan
fallback_account = self.export_account_id or self.account_id
fallback_vat_multiplier = 1.0 # export payments carry no VAT
else:
fallback_mpan = self.import_mpan
fallback_account = self.account_id
fallback_vat_multiplier = self.import_vat_multiplier
url = self.build_rates_url(tariff["product_code"], tariff["tariff_code"])

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