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
47 changes: 46 additions & 1 deletion apps/predbat/kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import aiohttp
import asyncio
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone

from component_base import ComponentBase

Expand Down Expand Up @@ -431,6 +431,49 @@ def build_standing_charge_url(self, product_code, tariff_code):
"""Construct public REST standing charge URL."""
return f"{self.base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/standing-charges/"

@staticmethod
def _normalize_rate_timestamps(results):
"""Normalize Kraken rate results so downstream minute_data() can parse them.

Kraken's REST API returns null valid_from/valid_to for flat-rate tariffs
(e.g. fixed export rates). The Octopus API always provides real timestamps,
so minute_data() expects valid_from to be a parsable ISO timestamp.

Normalization rules:
- valid_from=null: set to the earliest valid_to across all results, or
48h in the past if no valid_to exists (covers the full forecast window).
- valid_to=null: already handled by minute_data() (extends to end of forecast).

Note: We have only observed the single-entry flat-rate case from Kraken
(1 result, both timestamps null). If Kraken later returns multiple entries
where some have null valid_from (e.g. an open-ended latest rate alongside
historical rates with real timestamps), this logic should still work -
but the actual API response shape for that scenario is unverified.
"""
if not results:
return results

has_null_from = any(r.get("valid_from") is None for r in results)
if not has_null_from:
return results

# Find the earliest valid_to to use as a reference point for null valid_from
earliest_to = None
for r in results:
vt = r.get("valid_to")
if vt:
if earliest_to is None or vt < earliest_to:
earliest_to = vt

# Default: 48h in the past covers history + forecast window
fallback_from = (datetime.now(timezone.utc) - timedelta(hours=48)).strftime("%Y-%m-%dT%H:%M:%SZ")

for r in results:
if r.get("valid_from") is None:
r["valid_from"] = earliest_to or fallback_from

return results

async def async_fetch_rates(self, tariff=None):
"""Fetch rates from public REST endpoint. No auth needed. Returns list of rate objects or None."""
tariff = tariff or self.current_tariff
Expand Down Expand Up @@ -458,6 +501,8 @@ async def async_fetch_rates(self, tariff=None):

if url:
self.log(f"Warn: Kraken: Rate pagination capped at {pages} pages, more data available")

all_results = self._normalize_rate_timestamps(all_results)
self.log(f"Kraken: Fetched {len(all_results)} rate periods for {tariff['tariff_code']}")
return all_results

Expand Down
68 changes: 68 additions & 0 deletions apps/predbat/tests/test_kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,70 @@ def test_export_discovery_strategy1_no_fallthrough_on_network_failure():
assert api.export_tariff == {"tariff_code": "E-1R-OLD-EXPORT", "product_code": "OLD-EXPORT"}


def test_normalize_rate_timestamps_flat_rate_both_null():
"""Flat-rate tariff with valid_from=null and valid_to=null (observed from Kraken export API).

Without normalization, minute_data() skips these entries because it can't
parse null as a timestamp, resulting in 'metric_octopus_export not set correctly'.
"""
from kraken import KrakenAPI

results = [{"value_inc_vat": 16.5, "valid_from": None, "valid_to": None, "payment_method": None}]
normalized = KrakenAPI._normalize_rate_timestamps(results)

assert len(normalized) == 1
assert normalized[0]["value_inc_vat"] == 16.5
assert normalized[0]["valid_from"] is not None # Must be a real timestamp
assert normalized[0]["valid_to"] is None # Left as-is (minute_data handles this)
# Should be parsable as ISO datetime
from datetime import datetime

datetime.fromisoformat(normalized[0]["valid_from"].replace("Z", "+00:00"))


def test_normalize_rate_timestamps_normal_rates_unchanged():
"""Rates with real timestamps should pass through unmodified."""
from kraken import KrakenAPI

results = [
{"value_inc_vat": 24.5, "valid_from": "2026-03-23T00:00:00Z", "valid_to": "2026-03-24T00:00:00Z"},
{"value_inc_vat": 28.3, "valid_from": "2026-03-24T00:00:00Z", "valid_to": None},
]
normalized = KrakenAPI._normalize_rate_timestamps(results)

assert normalized[0]["valid_from"] == "2026-03-23T00:00:00Z"
assert normalized[1]["valid_from"] == "2026-03-24T00:00:00Z"


def test_normalize_rate_timestamps_empty_list():
"""Empty results should return empty."""
from kraken import KrakenAPI

assert KrakenAPI._normalize_rate_timestamps([]) == []
assert KrakenAPI._normalize_rate_timestamps(None) is None


def test_normalize_rate_timestamps_mixed_null_and_real():
"""Mixed results where some have null valid_from (hypothetical future Kraken response).

We have NOT observed this from Kraken's API - only the single-entry flat-rate case
has been seen. This test documents expected behaviour if Kraken later returns
multiple rate entries where the latest has valid_from=null (open-ended) alongside
historical entries with real timestamps, similar to Octopus's pattern.
"""
from kraken import KrakenAPI

results = [
{"value_inc_vat": 15.0, "valid_from": "2026-01-01T00:00:00Z", "valid_to": "2026-04-01T00:00:00Z"},
{"value_inc_vat": 16.5, "valid_from": None, "valid_to": None},
]
normalized = KrakenAPI._normalize_rate_timestamps(results)

# The null valid_from should get the earliest valid_to as its start
assert normalized[0]["valid_from"] == "2026-01-01T00:00:00Z" # Unchanged
assert normalized[1]["valid_from"] == "2026-04-01T00:00:00Z" # Set from earliest valid_to


def run_kraken_tests(my_predbat=None):
"""Run all KrakenAPI tests. Returns True on failure, False on success."""
tests = [
Expand Down Expand Up @@ -509,6 +573,10 @@ def run_kraken_tests(my_predbat=None):
test_standing_charge_converts_pence_to_pounds,
test_export_discovery_clears_stale_when_not_found,
test_export_discovery_strategy1_no_fallthrough_on_network_failure,
test_normalize_rate_timestamps_flat_rate_both_null,
test_normalize_rate_timestamps_normal_rates_unchanged,
test_normalize_rate_timestamps_empty_list,
test_normalize_rate_timestamps_mixed_null_and_real,
]
for test_func in tests:
try:
Expand Down
Loading