Skip to content
Merged
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
98 changes: 87 additions & 11 deletions apps/predbat/kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -632,36 +650,94 @@ 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
Comment on lines +686 to +693
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async_fetch_standing_charges_graphql() requests validFrom/validTo but then always returns the first entry in applicableStandingCharges. If multiple standing charge periods are returned within the 3-day window (e.g. a rate change boundary), this can pick an arbitrary/non-current value. Consider selecting the entry that applies to now (validFrom <= now < validTo/None), or at least sorting by validFrom to make the choice deterministic.

Copilot uses AI. Check for mistakes.

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.
Comment on lines +698 to +700
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says the REST call falls back to GraphQL for any non-200 status when import_mpan is known, but the implementation only falls back for permanent 404/410 responses. Please update the docstring to reflect the actual fallback conditions (404/410 only) so callers/operators aren’t misled during transient outages (429/5xx).

Suggested change
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.
Falls back to GraphQL applicableStandingCharges only when the REST endpoint returns
HTTP 404 or 410 (for example, TOU tariffs on E.ON Next whose product is not in the
REST API) and self.import_mpan is known. Other non-200 responses, such as transient
429/5xx errors, are treated as failures and do not trigger GraphQL fallback.

Copilot uses AI. Check for mistakes.
"""
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:
async with session.get(url) as response:
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
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This inline comment about unit conversion appears inverted: fetch.py multiplies metric_standing_charge by 100 to convert from pounds/day to pence/day. Here you’re dividing REST/GraphQL pence/day by 100 to return pounds/day, so the comment should describe that fetch.py expects pounds/day (and converts internally) rather than “expects pounds/day” while multiplying.

Suggested change
# API returns pence/day; fetch.py multiplies by 100 expecting pounds/day
# API returns pence/day; convert to pounds/day here because fetch.py expects pounds/day
# from this source and performs its own internal conversion to pence/day later.

Copilot uses AI. Check for mistakes.
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.

Expand Down
153 changes: 153 additions & 0 deletions apps/predbat/tests/test_kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
Loading