Skip to content

Commit e55356f

Browse files
Merge pull request #3834 from springfall2008/copilot/fix-eon-import-tariff-lookup
kraken: add GraphQL applicableStandingCharges fallback for TOU tariff 404s
2 parents e778e41 + f2f062e commit e55356f

2 files changed

Lines changed: 240 additions & 11 deletions

File tree

‎apps/predbat/kraken.py‎

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ async def handle_oauth_401(self):
8888
KRAKEN_VIEWER_QUERY = """{ viewer { accounts { number } } }"""
8989

9090
# GraphQL applicableRates query — fallback when REST product endpoint returns 404
91-
# (product code removed/replaced while customer is still on the tariff).
91+
# (product code removed/replaced while customer is still on the tariff, or TOU tariff
92+
# with no /standard-unit-rates/ REST endpoint e.g. E-TOU-* tariffs on E.ON Next).
9293
# Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window.
9394
KRAKEN_APPLICABLE_RATES_QUERY = """{{
9495
applicableRates(
@@ -103,6 +104,23 @@ async def handle_oauth_401(self):
103104
}}
104105
}}"""
105106

107+
# GraphQL applicableStandingCharges query — fallback when REST /standing-charges/ returns 404
108+
# (same scenarios as KRAKEN_APPLICABLE_RATES_QUERY above — product removed from REST API,
109+
# or TOU tariffs whose /standing-charges/ endpoint is unavailable on the provider API).
110+
# Returns value (pence/day inc VAT) for the requested window.
111+
KRAKEN_STANDING_CHARGES_QUERY = """{{
112+
applicableStandingCharges(
113+
accountNumber: "{account_number}"
114+
mpxn: "{mpan}"
115+
startAt: "{start_at}"
116+
endAt: "{end_at}"
117+
) {{
118+
value
119+
validFrom
120+
validTo
121+
}}
122+
}}"""
123+
106124
KRAKEN_BASE_URLS = {
107125
"edf": "https://api.edfgb-kraken.energy",
108126
"eon": "https://api.eonnext-kraken.energy",
@@ -632,36 +650,94 @@ def get_entity_name(self, root, suffix):
632650
entity_name = root + "." + self.prefix + "_kraken_" + self.account_id.replace("-", "_") + "_" + suffix
633651
return entity_name.lower()
634652

653+
async def async_fetch_standing_charges_graphql(self, mpan):
654+
"""Fetch standing charge via GraphQL applicableStandingCharges — fallback when REST returns non-200.
655+
656+
Used when the product code has been removed from the REST API (e.g. TOU tariffs on E.ON Next
657+
whose /standing-charges/ REST endpoint returns 404). Returns the standing charge in pounds/day
658+
(pence/day divided by 100), or None on failure.
659+
660+
Args:
661+
mpan: The import MPAN (meter point access number) for the account.
662+
663+
Returns the standing charge as pounds/day (float), or None.
664+
"""
665+
now = datetime.now(timezone.utc)
666+
midnight_utc = now.replace(hour=0, minute=0, second=0, microsecond=0)
667+
# Use a 3-day window (yesterday → tomorrow+1) to ensure the current standing charge
668+
# is captured regardless of when the agreement period started or ends.
669+
start_at = (midnight_utc - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
670+
end_at = (midnight_utc + timedelta(days=2)).strftime("%Y-%m-%dT%H:%M:%SZ")
671+
query = KRAKEN_STANDING_CHARGES_QUERY.format(
672+
account_number=self.account_id,
673+
mpan=mpan,
674+
start_at=start_at,
675+
end_at=end_at,
676+
)
677+
data = await self.async_graphql_query(query, "applicable-standing-charges-graphql")
678+
if not data:
679+
return None
680+
681+
applicable_charges = data.get("applicableStandingCharges", [])
682+
if not applicable_charges:
683+
self.log("Warn: Kraken: applicableStandingCharges GraphQL returned no results")
684+
return None
685+
686+
# Take the first (most applicable) standing charge entry; value is pence/day inc VAT.
687+
# Divide by 100 to match the units expected by the caller (pounds/day).
688+
charge = applicable_charges[0]
689+
value = charge.get("value")
690+
if value is None:
691+
return None
692+
self.log(f"Kraken: Fetched standing charge via GraphQL applicableStandingCharges for MPAN {mpan}: {value}p/day")
693+
return float(value) / 100.0
694+
635695
async def async_fetch_standing_charges(self, tariff=None):
636-
"""Fetch standing charges from public REST endpoint. No auth needed."""
696+
"""Fetch standing charges from public REST endpoint. No auth needed.
697+
698+
Falls back to GraphQL applicableStandingCharges if the REST endpoint returns a non-200
699+
status (e.g. 404 for TOU tariffs on E.ON Next whose product is not in the REST API)
700+
and self.import_mpan is known.
701+
"""
637702
tariff = tariff or self.current_tariff
638703
if not tariff:
639704
return None
640705

641706
url = self.build_standing_charge_url(tariff["product_code"], tariff["tariff_code"])
642707

708+
http_error_status = None
643709
try:
644710
timeout = aiohttp.ClientTimeout(total=30)
645711
async with aiohttp.ClientSession(timeout=timeout) as session:
646712
async with session.get(url) as response:
647713
if response.status != 200:
648714
self.log(f"Warn: Kraken: Standing charges HTTP {response.status}")
649715
self.failures_total += 1
650-
return None
651-
data = await response.json()
652-
653-
results = data.get("results", [])
654-
if results:
655-
# API returns pence/day; fetch.py multiplies by 100 expecting pounds/day
656-
value = results[0].get("value_inc_vat")
657-
return value / 100.0 if value is not None else None
658-
return None
716+
http_error_status = response.status
717+
else:
718+
data = await response.json()
659719

660720
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
661721
self.log(f"Warn: Kraken: Network error fetching standing charges: {e}")
662722
self.failures_total += 1
663723
return None
664724

725+
if http_error_status is not None:
726+
# Only fall back to GraphQL for permanent "product not found" responses (404/410).
727+
# Transient errors (429, 500, 503, …) should surface as failures, not trigger
728+
# an extra GraphQL request that would mask the outage.
729+
if http_error_status in (404, 410) and self.import_mpan:
730+
self.log(f"Kraken: REST standing charges returned HTTP {http_error_status}, falling back to GraphQL applicableStandingCharges for MPAN {self.import_mpan}")
731+
return await self.async_fetch_standing_charges_graphql(self.import_mpan)
732+
return None
733+
734+
results = data.get("results", [])
735+
if results:
736+
# API returns pence/day; fetch.py multiplies by 100 expecting pounds/day
737+
value = results[0].get("value_inc_vat")
738+
return value / 100.0 if value is not None else None
739+
return None
740+
665741
async def run(self, seconds, first):
666742
"""Component run method — called by ComponentBase.start() every 60s.
667743

‎apps/predbat/tests/test_kraken.py‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,152 @@ def test_standing_charge_converts_pence_to_pounds():
447447
assert result == 0.53
448448

449449

450+
def test_fetch_standing_charges_rest_404_falls_back_to_graphql():
451+
"""async_fetch_standing_charges() falls back to GraphQL when REST returns 404.
452+
453+
Reproduces the E.ON Next TOU tariff scenario: NEXT_SMART_SAVER_FIXED_12M_V8 has no
454+
/standing-charges/ REST endpoint, returns HTTP 404, GraphQL fallback must be used.
455+
"""
456+
api = make_kraken_api(provider="eon", account_id="A-AA8A473C")
457+
api.current_tariff = {"tariff_code": "E-TOU-NEXT_SMART_SAVER_FIXED_12M_V8-M", "product_code": "NEXT_SMART_SAVER_FIXED_12M_V8"}
458+
api.import_mpan = "1900000000456"
459+
api.async_fetch_standing_charges_graphql = AsyncMock(return_value=0.6195)
460+
461+
mock_response = AsyncMock()
462+
mock_response.status = 404
463+
mock_session = AsyncMock()
464+
mock_session.get = MagicMock(
465+
return_value=AsyncMock(
466+
__aenter__=AsyncMock(return_value=mock_response),
467+
__aexit__=AsyncMock(return_value=None),
468+
)
469+
)
470+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
471+
mock_session.__aexit__ = AsyncMock(return_value=None)
472+
473+
with patch("aiohttp.ClientSession", return_value=mock_session):
474+
result = asyncio.run(api.async_fetch_standing_charges())
475+
476+
assert result == 0.6195
477+
api.async_fetch_standing_charges_graphql.assert_called_once_with("1900000000456")
478+
479+
480+
def test_fetch_standing_charges_rest_404_no_fallback_without_import_mpan():
481+
"""async_fetch_standing_charges() returns None (no fallback) when REST 404 and import_mpan not set."""
482+
api = make_kraken_api()
483+
api.current_tariff = {"tariff_code": "E-TOU-OLD-M", "product_code": "OLD"}
484+
api.import_mpan = None
485+
486+
mock_response = AsyncMock()
487+
mock_response.status = 404
488+
mock_session = AsyncMock()
489+
mock_session.get = MagicMock(
490+
return_value=AsyncMock(
491+
__aenter__=AsyncMock(return_value=mock_response),
492+
__aexit__=AsyncMock(return_value=None),
493+
)
494+
)
495+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
496+
mock_session.__aexit__ = AsyncMock(return_value=None)
497+
498+
with patch("aiohttp.ClientSession", return_value=mock_session):
499+
result = asyncio.run(api.async_fetch_standing_charges())
500+
501+
assert result is None
502+
503+
504+
def test_fetch_standing_charges_rest_410_falls_back_to_graphql():
505+
"""async_fetch_standing_charges() also falls back on HTTP 410 Gone."""
506+
api = make_kraken_api(provider="eon", account_id="A-AA8A473C")
507+
api.current_tariff = {"tariff_code": "E-TOU-GONE-V1-M", "product_code": "GONE-V1"}
508+
api.import_mpan = "1900000000456"
509+
api.async_fetch_standing_charges_graphql = AsyncMock(return_value=0.53)
510+
511+
mock_response = AsyncMock()
512+
mock_response.status = 410
513+
mock_session = AsyncMock()
514+
mock_session.get = MagicMock(
515+
return_value=AsyncMock(
516+
__aenter__=AsyncMock(return_value=mock_response),
517+
__aexit__=AsyncMock(return_value=None),
518+
)
519+
)
520+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
521+
mock_session.__aexit__ = AsyncMock(return_value=None)
522+
523+
with patch("aiohttp.ClientSession", return_value=mock_session):
524+
result = asyncio.run(api.async_fetch_standing_charges())
525+
526+
assert result == 0.53
527+
api.async_fetch_standing_charges_graphql.assert_called_once_with("1900000000456")
528+
529+
530+
def test_fetch_standing_charges_graphql_returns_value():
531+
"""async_fetch_standing_charges_graphql() converts GraphQL value to pounds/day."""
532+
api = make_kraken_api()
533+
api.account_id = "A-AA8A473C"
534+
api.async_graphql_query = AsyncMock(
535+
return_value={
536+
"applicableStandingCharges": [
537+
{"value": 61.95, "validFrom": "2026-04-10T00:00:00Z", "validTo": None},
538+
]
539+
}
540+
)
541+
542+
result = asyncio.run(api.async_fetch_standing_charges_graphql("1900000000456"))
543+
544+
# 61.95 pence/day → 0.6195 pounds/day
545+
assert result is not None
546+
assert abs(result - 0.6195) < 1e-6
547+
548+
549+
def test_fetch_standing_charges_graphql_returns_none_on_empty():
550+
"""async_fetch_standing_charges_graphql() returns None when applicableStandingCharges is empty."""
551+
api = make_kraken_api()
552+
api.account_id = "A-AA8A473C"
553+
api.async_graphql_query = AsyncMock(return_value={"applicableStandingCharges": []})
554+
555+
result = asyncio.run(api.async_fetch_standing_charges_graphql("1900000000456"))
556+
assert result is None
557+
558+
559+
def test_fetch_standing_charges_graphql_returns_none_on_graphql_failure():
560+
"""async_fetch_standing_charges_graphql() returns None when GraphQL query fails."""
561+
api = make_kraken_api()
562+
api.account_id = "A-AA8A473C"
563+
api.async_graphql_query = AsyncMock(return_value=None)
564+
565+
result = asyncio.run(api.async_fetch_standing_charges_graphql("1900000000456"))
566+
assert result is None
567+
568+
569+
def test_fetch_standing_charges_transient_error_does_not_fall_back_to_graphql():
570+
"""async_fetch_standing_charges() returns None (no fallback) for transient errors like 500/429."""
571+
for status_code in (429, 500, 503):
572+
api = make_kraken_api()
573+
api.current_tariff = {"tariff_code": "E-1R-VAR-01-J", "product_code": "VAR-01"}
574+
api.import_mpan = "1900000000456"
575+
api.async_fetch_standing_charges_graphql = AsyncMock()
576+
577+
mock_response = AsyncMock()
578+
mock_response.status = status_code
579+
mock_session = AsyncMock()
580+
mock_session.get = MagicMock(
581+
return_value=AsyncMock(
582+
__aenter__=AsyncMock(return_value=mock_response),
583+
__aexit__=AsyncMock(return_value=None),
584+
)
585+
)
586+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
587+
mock_session.__aexit__ = AsyncMock(return_value=None)
588+
589+
with patch("aiohttp.ClientSession", return_value=mock_session):
590+
result = asyncio.run(api.async_fetch_standing_charges())
591+
592+
assert result is None, f"Expected None for HTTP {status_code}, got {result}"
593+
api.async_fetch_standing_charges_graphql.assert_not_called()
594+
595+
450596
def test_export_discovery_clears_stale_when_not_found():
451597
"""Export tariff is cleared if all strategies fail (prevents stale rates)."""
452598
api = make_kraken_api()
@@ -977,6 +1123,13 @@ def run_kraken_tests(my_predbat=None):
9771123
test_fetch_rates_rest_404_no_fallback_for_export_tariff,
9781124
test_fetch_rates_rest_410_falls_back_to_graphql_for_import,
9791125
test_fetch_rates_transient_error_does_not_fall_back_to_graphql,
1126+
test_fetch_standing_charges_rest_404_falls_back_to_graphql,
1127+
test_fetch_standing_charges_rest_404_no_fallback_without_import_mpan,
1128+
test_fetch_standing_charges_rest_410_falls_back_to_graphql,
1129+
test_fetch_standing_charges_graphql_returns_value,
1130+
test_fetch_standing_charges_graphql_returns_none_on_empty,
1131+
test_fetch_standing_charges_graphql_returns_none_on_graphql_failure,
1132+
test_fetch_standing_charges_transient_error_does_not_fall_back_to_graphql,
9801133
]
9811134
for test_func in tests:
9821135
try:

0 commit comments

Comments
 (0)