Skip to content

Commit 2329f54

Browse files
Merge pull request #3776 from springfall2008/fix/kraken
Fix kraken issue with missing tariff data
2 parents 08f86c2 + 5c38431 commit 2329f54

2 files changed

Lines changed: 432 additions & 9 deletions

File tree

‎apps/predbat/kraken.py‎

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ async def handle_oauth_401(self):
8787
# Viewer query to discover all account numbers under the authenticated user
8888
KRAKEN_VIEWER_QUERY = """{ viewer { accounts { number } } }"""
8989

90+
# GraphQL applicableRates query — fallback when REST product endpoint returns 404
91+
# (product code removed/replaced while customer is still on the tariff).
92+
# Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window.
93+
KRAKEN_APPLICABLE_RATES_QUERY = """{{
94+
applicableRates(
95+
accountNumber: "{account_number}"
96+
mpxn: "{mpan}"
97+
startAt: "{start_at}"
98+
endAt: "{end_at}"
99+
) {{
100+
value
101+
validFrom
102+
validTo
103+
}}
104+
}}"""
105+
90106
KRAKEN_BASE_URLS = {
91107
"edf": "https://api.edfgb-kraken.energy",
92108
"eon": "https://api.eonnext-kraken.energy",
@@ -123,6 +139,7 @@ def initialize(
123139

124140
self.account_id = account_id
125141
self.configured_mpan = mpan # From SaaS config — preferred MPAN to match
142+
self.import_mpan = None # Set after first successful tariff discovery
126143
self.current_tariff = None
127144
self.export_tariff = None # Export tariff (discovered dynamically)
128145
self.wired = False
@@ -430,6 +447,9 @@ async def async_find_tariffs(self):
430447

431448
new_tariff = {"tariff_code": import_result["tariff_code"], "product_code": import_result["product_code"]}
432449

450+
# Store MPAN for GraphQL fallback in async_fetch_rates_graphql()
451+
self.import_mpan = import_result["mpan"]
452+
433453
# Discover export tariff — always re-discover to detect tariff changes
434454
await self._discover_export_tariff(all_meter_points, my_address)
435455

@@ -493,16 +513,83 @@ def _normalize_rate_timestamps(results):
493513

494514
return results
495515

516+
async def async_fetch_rates_graphql(self, mpan):
517+
"""Fetch import rates via GraphQL applicableRates — fallback when REST returns non-200.
518+
519+
Used when the product code has been removed from the REST API (e.g. product replaced
520+
mid-agreement). The applicableRates query returns the rates currently applicable to
521+
the customer regardless of product lifecycle.
522+
523+
Args:
524+
mpan: The import MPAN (meter point access number) for the account.
525+
526+
Returns list of rate dicts with value_inc_vat, value_exc_vat, valid_from, valid_to, or None.
527+
"""
528+
now = datetime.now(timezone.utc)
529+
midnight_utc = now.replace(hour=0, minute=0, second=0, microsecond=0)
530+
531+
# Mirror the window used by fetch_octopus_rates → minute_data(forecast_days + 1, midnight_utc).
532+
# Start one day before midnight so any rate period that began earlier today is included.
533+
# End at midnight + (forecast_days + 1) to cover the full planning horizon.
534+
forecast_hours = self.get_arg("forecast_hours", 48)
535+
forecast_days = int((forecast_hours + 23) / 24)
536+
start_at = (midnight_utc - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
537+
end_at = (midnight_utc + timedelta(days=forecast_days + 1)).strftime("%Y-%m-%dT%H:%M:%SZ")
538+
query = KRAKEN_APPLICABLE_RATES_QUERY.format(
539+
account_number=self.account_id,
540+
mpan=mpan,
541+
start_at=start_at,
542+
end_at=end_at,
543+
)
544+
data = await self.async_graphql_query(query, "applicable-rates-graphql")
545+
if not data:
546+
return None
547+
548+
raw_rates = data.get("applicableRates", [])
549+
if not raw_rates:
550+
self.log("Warn: Kraken: applicableRates GraphQL returned no rate periods")
551+
return None
552+
553+
results = []
554+
for r in raw_rates:
555+
value = r.get("value")
556+
if value is None:
557+
continue
558+
value_inc_vat = float(value)
559+
value_exc_vat = round(value_inc_vat / 1.05, 4)
560+
results.append(
561+
{
562+
"value_inc_vat": value_inc_vat,
563+
"value_exc_vat": value_exc_vat,
564+
"valid_from": r.get("validFrom"),
565+
"valid_to": r.get("validTo"),
566+
}
567+
)
568+
569+
if not results:
570+
return None
571+
572+
results = self._normalize_rate_timestamps(results)
573+
self.log(f"Kraken: Fetched {len(results)} rate periods via GraphQL applicableRates for MPAN {mpan}")
574+
return results
575+
496576
async def async_fetch_rates(self, tariff=None):
497-
"""Fetch rates from public REST endpoint. No auth needed. Returns list of rate objects or None."""
577+
"""Fetch rates from public REST endpoint. No auth needed. Returns list of rate objects or None.
578+
579+
Falls back to GraphQL applicableRates if the REST endpoint returns a non-200 status
580+
(e.g. 404 when the product code has been removed from the API) and self.import_mpan
581+
is known. Only applied to import tariff fetches — export rates have no GraphQL fallback.
582+
"""
498583
tariff = tariff or self.current_tariff
499584
if not tariff:
500585
return None
501586

587+
is_import = tariff == self.current_tariff
502588
url = self.build_rates_url(tariff["product_code"], tariff["tariff_code"])
503589

504590
all_results = []
505591
pages = 0
592+
http_error_status = None
506593
try:
507594
timeout = aiohttp.ClientTimeout(total=30)
508595
async with aiohttp.ClientSession(timeout=timeout) as session:
@@ -511,25 +598,35 @@ async def async_fetch_rates(self, tariff=None):
511598
if response.status != 200:
512599
self.log(f"Warn: Kraken: Rates HTTP {response.status} for {url}")
513600
self.failures_total += 1
514-
return None
601+
http_error_status = response.status
602+
break
515603
data = await response.json()
516604

517605
all_results.extend(data.get("results", []))
518606
url = data.get("next") # Pagination
519607
pages += 1
520608

521-
if url:
522-
self.log(f"Warn: Kraken: Rate pagination capped at {pages} pages, more data available")
523-
524-
all_results = self._normalize_rate_timestamps(all_results)
525-
self.log(f"Kraken: Fetched {len(all_results)} rate periods for {tariff['tariff_code']}")
526-
return all_results
527-
528609
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
529610
self.log(f"Warn: Kraken: Network error fetching rates: {e}")
530611
self.failures_total += 1
531612
return None
532613

614+
if http_error_status is not None:
615+
# Only fall back to GraphQL for permanent "product not found" responses (404/410).
616+
# Transient errors (429, 500, 503, …) should surface as failures, not trigger
617+
# an extra GraphQL request that would mask the outage.
618+
if http_error_status in (404, 410) and is_import and self.import_mpan:
619+
self.log(f"Kraken: REST rates returned HTTP {http_error_status}, falling back to GraphQL applicableRates for MPAN {self.import_mpan}")
620+
return await self.async_fetch_rates_graphql(self.import_mpan)
621+
return None
622+
623+
if url:
624+
self.log(f"Warn: Kraken: Rate pagination capped at {pages} pages, more data available")
625+
626+
all_results = self._normalize_rate_timestamps(all_results)
627+
self.log(f"Kraken: Fetched {len(all_results)} rate periods for {tariff['tariff_code']}")
628+
return all_results
629+
533630
def get_entity_name(self, root, suffix):
534631
"""Construct entity name. Same pattern as OctopusAPI.get_entity_name."""
535632
entity_name = root + "." + self.prefix + "_kraken_" + self.account_id.replace("-", "_") + "_" + suffix

0 commit comments

Comments
 (0)