diff --git a/apps/predbat/kraken.py b/apps/predbat/kraken.py index 9526fc2f0..99b3c4ee5 100644 --- a/apps/predbat/kraken.py +++ b/apps/predbat/kraken.py @@ -88,7 +88,8 @@ async def handle_oauth_401(self): KRAKEN_VIEWER_QUERY = """{ viewer { accounts { number } } }""" # GraphQL applicableRates query — fallback when REST product endpoint returns 404 -# (product code removed/replaced while customer is still on the tariff). +# (product code removed/replaced while customer is still on the tariff, or TOU tariff +# with no /standard-unit-rates/ REST endpoint e.g. E-TOU-* tariffs on E.ON Next). # Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window. KRAKEN_APPLICABLE_RATES_QUERY = """{{ applicableRates( @@ -103,6 +104,23 @@ async def handle_oauth_401(self): }} }}""" +# GraphQL applicableStandingCharges query — fallback when REST /standing-charges/ returns 404 +# (same scenarios as KRAKEN_APPLICABLE_RATES_QUERY above — product removed from REST API, +# or TOU tariffs whose /standing-charges/ endpoint is unavailable on the provider API). +# Returns value (pence/day inc VAT) for the requested window. +KRAKEN_STANDING_CHARGES_QUERY = """{{ + applicableStandingCharges( + accountNumber: "{account_number}" + mpxn: "{mpan}" + startAt: "{start_at}" + endAt: "{end_at}" + ) {{ + value + validFrom + validTo + }} +}}""" + KRAKEN_BASE_URLS = { "edf": "https://api.edfgb-kraken.energy", "eon": "https://api.eonnext-kraken.energy", @@ -632,14 +650,62 @@ def get_entity_name(self, root, suffix): entity_name = root + "." + self.prefix + "_kraken_" + self.account_id.replace("-", "_") + "_" + suffix return entity_name.lower() + async def async_fetch_standing_charges_graphql(self, mpan): + """Fetch standing charge via GraphQL applicableStandingCharges — fallback when REST returns non-200. + + Used when the product code has been removed from the REST API (e.g. TOU tariffs on E.ON Next + whose /standing-charges/ REST endpoint returns 404). Returns the standing charge in pounds/day + (pence/day divided by 100), or None on failure. + + Args: + mpan: The import MPAN (meter point access number) for the account. + + Returns the standing charge as pounds/day (float), or None. + """ + now = datetime.now(timezone.utc) + midnight_utc = now.replace(hour=0, minute=0, second=0, microsecond=0) + # Use a 3-day window (yesterday → tomorrow+1) to ensure the current standing charge + # is captured regardless of when the agreement period started or ends. + start_at = (midnight_utc - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") + end_at = (midnight_utc + timedelta(days=2)).strftime("%Y-%m-%dT%H:%M:%SZ") + query = KRAKEN_STANDING_CHARGES_QUERY.format( + account_number=self.account_id, + mpan=mpan, + start_at=start_at, + end_at=end_at, + ) + data = await self.async_graphql_query(query, "applicable-standing-charges-graphql") + if not data: + return None + + applicable_charges = data.get("applicableStandingCharges", []) + if not applicable_charges: + self.log("Warn: Kraken: applicableStandingCharges GraphQL returned no results") + return None + + # Take the first (most applicable) standing charge entry; value is pence/day inc VAT. + # Divide by 100 to match the units expected by the caller (pounds/day). + charge = applicable_charges[0] + value = charge.get("value") + if value is None: + return None + self.log(f"Kraken: Fetched standing charge via GraphQL applicableStandingCharges for MPAN {mpan}: {value}p/day") + return float(value) / 100.0 + async def async_fetch_standing_charges(self, tariff=None): - """Fetch standing charges from public REST endpoint. No auth needed.""" + """Fetch standing charges from public REST endpoint. No auth needed. + + Falls back to GraphQL applicableStandingCharges if the REST endpoint returns a non-200 + status (e.g. 404 for TOU tariffs on E.ON Next whose product is not in the REST API) + and self.import_mpan is known. + """ tariff = tariff or self.current_tariff if not tariff: return None url = self.build_standing_charge_url(tariff["product_code"], tariff["tariff_code"]) + http_error_status = None try: timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: @@ -647,21 +713,31 @@ async def async_fetch_standing_charges(self, tariff=None): if response.status != 200: self.log(f"Warn: Kraken: Standing charges HTTP {response.status}") self.failures_total += 1 - return None - data = await response.json() - - results = data.get("results", []) - if results: - # API returns pence/day; fetch.py multiplies by 100 expecting pounds/day - value = results[0].get("value_inc_vat") - return value / 100.0 if value is not None else None - return None + http_error_status = response.status + else: + data = await response.json() except (aiohttp.ClientError, asyncio.TimeoutError) as e: self.log(f"Warn: Kraken: Network error fetching standing charges: {e}") self.failures_total += 1 return None + if http_error_status is not None: + # Only fall back to GraphQL for permanent "product not found" responses (404/410). + # Transient errors (429, 500, 503, …) should surface as failures, not trigger + # an extra GraphQL request that would mask the outage. + if http_error_status in (404, 410) and self.import_mpan: + self.log(f"Kraken: REST standing charges returned HTTP {http_error_status}, falling back to GraphQL applicableStandingCharges for MPAN {self.import_mpan}") + return await self.async_fetch_standing_charges_graphql(self.import_mpan) + return None + + results = data.get("results", []) + if results: + # API returns pence/day; fetch.py multiplies by 100 expecting pounds/day + value = results[0].get("value_inc_vat") + return value / 100.0 if value is not None else None + return None + async def run(self, seconds, first): """Component run method — called by ComponentBase.start() every 60s. diff --git a/apps/predbat/tests/test_kraken.py b/apps/predbat/tests/test_kraken.py index 0157712e3..cf4c90e9b 100644 --- a/apps/predbat/tests/test_kraken.py +++ b/apps/predbat/tests/test_kraken.py @@ -447,6 +447,152 @@ def test_standing_charge_converts_pence_to_pounds(): assert result == 0.53 +def test_fetch_standing_charges_rest_404_falls_back_to_graphql(): + """async_fetch_standing_charges() falls back to GraphQL when REST returns 404. + + Reproduces the E.ON Next TOU tariff scenario: NEXT_SMART_SAVER_FIXED_12M_V8 has no + /standing-charges/ REST endpoint, returns HTTP 404, GraphQL fallback must be used. + """ + api = make_kraken_api(provider="eon", account_id="A-AA8A473C") + api.current_tariff = {"tariff_code": "E-TOU-NEXT_SMART_SAVER_FIXED_12M_V8-M", "product_code": "NEXT_SMART_SAVER_FIXED_12M_V8"} + api.import_mpan = "1900000000456" + api.async_fetch_standing_charges_graphql = AsyncMock(return_value=0.6195) + + mock_response = AsyncMock() + mock_response.status = 404 + mock_session = AsyncMock() + mock_session.get = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_response), + __aexit__=AsyncMock(return_value=None), + ) + ) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(api.async_fetch_standing_charges()) + + assert result == 0.6195 + api.async_fetch_standing_charges_graphql.assert_called_once_with("1900000000456") + + +def test_fetch_standing_charges_rest_404_no_fallback_without_import_mpan(): + """async_fetch_standing_charges() returns None (no fallback) when REST 404 and import_mpan not set.""" + api = make_kraken_api() + api.current_tariff = {"tariff_code": "E-TOU-OLD-M", "product_code": "OLD"} + api.import_mpan = None + + mock_response = AsyncMock() + mock_response.status = 404 + mock_session = AsyncMock() + mock_session.get = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_response), + __aexit__=AsyncMock(return_value=None), + ) + ) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(api.async_fetch_standing_charges()) + + assert result is None + + +def test_fetch_standing_charges_rest_410_falls_back_to_graphql(): + """async_fetch_standing_charges() also falls back on HTTP 410 Gone.""" + api = make_kraken_api(provider="eon", account_id="A-AA8A473C") + api.current_tariff = {"tariff_code": "E-TOU-GONE-V1-M", "product_code": "GONE-V1"} + api.import_mpan = "1900000000456" + api.async_fetch_standing_charges_graphql = AsyncMock(return_value=0.53) + + mock_response = AsyncMock() + mock_response.status = 410 + mock_session = AsyncMock() + mock_session.get = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_response), + __aexit__=AsyncMock(return_value=None), + ) + ) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(api.async_fetch_standing_charges()) + + assert result == 0.53 + api.async_fetch_standing_charges_graphql.assert_called_once_with("1900000000456") + + +def test_fetch_standing_charges_graphql_returns_value(): + """async_fetch_standing_charges_graphql() converts GraphQL value to pounds/day.""" + api = make_kraken_api() + api.account_id = "A-AA8A473C" + api.async_graphql_query = AsyncMock( + return_value={ + "applicableStandingCharges": [ + {"value": 61.95, "validFrom": "2026-04-10T00:00:00Z", "validTo": None}, + ] + } + ) + + result = asyncio.run(api.async_fetch_standing_charges_graphql("1900000000456")) + + # 61.95 pence/day → 0.6195 pounds/day + assert result is not None + assert abs(result - 0.6195) < 1e-6 + + +def test_fetch_standing_charges_graphql_returns_none_on_empty(): + """async_fetch_standing_charges_graphql() returns None when applicableStandingCharges is empty.""" + api = make_kraken_api() + api.account_id = "A-AA8A473C" + api.async_graphql_query = AsyncMock(return_value={"applicableStandingCharges": []}) + + result = asyncio.run(api.async_fetch_standing_charges_graphql("1900000000456")) + assert result is None + + +def test_fetch_standing_charges_graphql_returns_none_on_graphql_failure(): + """async_fetch_standing_charges_graphql() returns None when GraphQL query fails.""" + api = make_kraken_api() + api.account_id = "A-AA8A473C" + api.async_graphql_query = AsyncMock(return_value=None) + + result = asyncio.run(api.async_fetch_standing_charges_graphql("1900000000456")) + assert result is None + + +def test_fetch_standing_charges_transient_error_does_not_fall_back_to_graphql(): + """async_fetch_standing_charges() returns None (no fallback) for transient errors like 500/429.""" + for status_code in (429, 500, 503): + api = make_kraken_api() + api.current_tariff = {"tariff_code": "E-1R-VAR-01-J", "product_code": "VAR-01"} + api.import_mpan = "1900000000456" + api.async_fetch_standing_charges_graphql = AsyncMock() + + mock_response = AsyncMock() + mock_response.status = status_code + mock_session = AsyncMock() + mock_session.get = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_response), + __aexit__=AsyncMock(return_value=None), + ) + ) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run(api.async_fetch_standing_charges()) + + assert result is None, f"Expected None for HTTP {status_code}, got {result}" + api.async_fetch_standing_charges_graphql.assert_not_called() + + def test_export_discovery_clears_stale_when_not_found(): """Export tariff is cleared if all strategies fail (prevents stale rates).""" api = make_kraken_api() @@ -977,6 +1123,13 @@ def run_kraken_tests(my_predbat=None): test_fetch_rates_rest_404_no_fallback_for_export_tariff, test_fetch_rates_rest_410_falls_back_to_graphql_for_import, test_fetch_rates_transient_error_does_not_fall_back_to_graphql, + test_fetch_standing_charges_rest_404_falls_back_to_graphql, + test_fetch_standing_charges_rest_404_no_fallback_without_import_mpan, + test_fetch_standing_charges_rest_410_falls_back_to_graphql, + test_fetch_standing_charges_graphql_returns_value, + test_fetch_standing_charges_graphql_returns_none_on_empty, + test_fetch_standing_charges_graphql_returns_none_on_graphql_failure, + test_fetch_standing_charges_transient_error_does_not_fall_back_to_graphql, ] for test_func in tests: try: