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
75 changes: 49 additions & 26 deletions apps/predbat/energydataservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
rate dictionaries.
"""

from datetime import datetime
from datetime import datetime, timezone
from utils import dp4


Expand Down Expand Up @@ -68,55 +68,76 @@ def fetch_energidataservice_rates(self, entity_id, adjust_key=None):

return rate_data

def minute_data_hourly_rates(self, data, forecast_days, midnight_utc, rate_key, from_key, adjust_key=None, scale=1.0, use_cent=False):
def minute_data_hourly_rates(
self,
data,
forecast_days,
midnight_utc,
rate_key,
from_key,
adjust_key=None,
scale=1.0,
use_cent=False,
):
"""
Convert 15-minute rate data into a per-minute dict keyed by minute offset from midnight_utc.
FIXED: Handles timezone/DST correctly.
Comment on lines 83 to +84
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The docstring says this converts “15-minute rate data”, but the implementation detects the interval dynamically (15–60 min) and is also used for hourly data. Please update the docstring to describe interval-agnostic expansion to per-minute slots to avoid misleading future readers.

Suggested change
Convert 15-minute rate data into a per-minute dict keyed by minute offset from midnight_utc.
FIXED: Handles timezone/DST correctly.
Convert fixed-interval rate data into a per-minute dict keyed by minute offset from midnight_utc.
The interval (typically 1560 minutes) is detected dynamically from consecutive timestamps.
Handles timezone/DST correctly.

Copilot uses AI. Check for mistakes.
"""
rate_data = {}
min_minute = -forecast_days * 24 * 60
max_minute = forecast_days * 24 * 60
interval_minutes = 15 # new feed granularity
interval_minutes = 15 # default granularity

# Find gap between two entries in minutes
if len(data) < 2:
pass
# Normalize midnight to naive (local comparison baseline)
if midnight_utc.tzinfo is not None:
midnight = midnight_utc.astimezone(timezone.utc).replace(tzinfo=None)
else:
midnight = midnight_utc
Comment on lines +91 to +95
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

This comment is inaccurate: the code normalizes midnight_utc to a naive UTC datetime, not a “local comparison baseline”. Please adjust the comment (and/or rename the local variable) so it clearly reflects the UTC normalization to prevent confusion when reasoning about DST behavior.

Copilot uses AI. Check for mistakes.

# Detect interval dynamically
if len(data) >= 2:
t0 = self._parse_iso(data[0].get(from_key))
t1 = self._parse_iso(data[1].get(from_key))
if t0 and t1:
interval_minutes = int((t1 - t0).total_seconds() / 60)
if interval_minutes <= 15 or interval_minutes > 60:
interval_minutes = 15
delta = int((t1 - t0).total_seconds() / 60)
if 15 <= delta <= 60:
interval_minutes = delta

for entry in data:
start_time_str = entry.get(from_key)
rate = entry.get(rate_key, 0) * scale

if not use_cent:
# Keep behavior: convert DKK → øre (or cents) if use_cent is False
rate = rate * 100.0

# Parse time robustly
start_time = self._parse_iso(start_time_str)
if start_time is None:
self.log(f"Warn: Invalid time format '{start_time_str}' in data")
continue

# Support naive/aware midnight_utc gracefully
try:
start_minute = int((start_time - midnight_utc).total_seconds() / 60)
except TypeError:
# If midnight_utc is naive, drop tzinfo from start_time for subtraction
start_minute = int((start_time.replace(tzinfo=None) - midnight_utc).total_seconds() / 60)
# Normalize timezone properly
if start_time.tzinfo is not None:
start_time = start_time.astimezone(timezone.utc).replace(tzinfo=None)

# Compute minute offset safely
start_minute = int((start_time - midnight).total_seconds() / 60)
end_minute = start_minute + interval_minutes

# Fill each minute in the 15-min slot
# Fill each minute in interval
for minute in range(start_minute, end_minute):
if min_minute <= minute < max_minute:
rate_data[minute] = dp4(rate)

# Fill missing minutes at the start of the data set without shifting timestamps
if rate_data and 0 not in rate_data:
min_key = min(rate_data.keys())
if min_key > 0:
first_rate = rate_data[min_key]
for minute in range(0, min_key):
# Use the rate from 24 hours ahead if available, otherwise replicate the first valid rate
rate_data[minute] = rate_data.get(minute + 24 * 60, first_rate)

if adjust_key:
# hook for intelligent adjustments
pass

return rate_data
Expand All @@ -134,11 +155,13 @@ def _parse_iso(self, s):
def _tariff_for(self, tariffs, start_time_str):
if not tariffs or not start_time_str:
return 0
s = str(start_time_str)
dt = self._parse_iso(s)

dt = self._parse_iso(start_time_str)
if not dt:
return tariffs.get(s, 0)
hhmm = f"{dt.hour:02d}:{dt.minute:02d}" # 08:15
h = str(dt.hour) # "8"
hh = f"{dt.hour:02d}" # "08"
return tariffs.get(hhmm, tariffs.get(h, tariffs.get(hh, tariffs.get(s, 0))))
return tariffs.get(str(start_time_str), 0)

hhmm = f"{dt.hour:02d}:{dt.minute:02d}"
h = str(dt.hour)
hh = f"{dt.hour:02d}"

return tariffs.get(hhmm, tariffs.get(h, tariffs.get(hh, tariffs.get(str(start_time_str), 0))))
261 changes: 260 additions & 1 deletion apps/predbat/tests/test_energydataservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# pylint: disable=attribute-defined-outside-init
import json
import yaml
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone


def test_energydataservice(my_predbat):
Expand Down Expand Up @@ -367,3 +367,262 @@ def test_energydataservice(my_predbat):
failed = 1

return failed


def test_energydataservice_dst_spring_forward(my_predbat):
"""
DST regression: Europe/Copenhagen spring forward (2024-03-31).
Clocks jump 02:00 -> 03:00 (CET+01:00 -> CEST+02:00), producing a 23-hour day.
Minute indices must be computed via UTC, not local clock time, so the DST gap
(missing 2 AM) must not cause an offset shift in the rate dictionary.
"""
failed = 0
print("Test energy data service DST spring forward")

# midnight in Copenhagen on spring-forward day: 2024-03-31T00:00:00+01:00
midnight = datetime(2024, 3, 31, 0, 0, 0, tzinfo=timezone(timedelta(hours=1)))

# 23 hourly entries; 2 AM does not exist (spring forward skips it).
# Entries before the DST switch use +01:00; entries from 3 AM onward use +02:00.
prices = [
("2024-03-31T00:00:00+01:00", 10.0),
("2024-03-31T01:00:00+01:00", 11.0),
# 02:00 does not exist
("2024-03-31T03:00:00+02:00", 12.0),
("2024-03-31T04:00:00+02:00", 13.0),
("2024-03-31T05:00:00+02:00", 14.0),
("2024-03-31T06:00:00+02:00", 15.0),
("2024-03-31T07:00:00+02:00", 16.0),
("2024-03-31T08:00:00+02:00", 17.0),
("2024-03-31T09:00:00+02:00", 18.0),
("2024-03-31T10:00:00+02:00", 19.0),
("2024-03-31T11:00:00+02:00", 20.0),
("2024-03-31T12:00:00+02:00", 21.0),
("2024-03-31T13:00:00+02:00", 22.0),
("2024-03-31T14:00:00+02:00", 23.0),
("2024-03-31T15:00:00+02:00", 24.0),
("2024-03-31T16:00:00+02:00", 25.0),
("2024-03-31T17:00:00+02:00", 26.0),
("2024-03-31T18:00:00+02:00", 27.0),
("2024-03-31T19:00:00+02:00", 28.0),
("2024-03-31T20:00:00+02:00", 29.0),
("2024-03-31T21:00:00+02:00", 30.0),
("2024-03-31T22:00:00+02:00", 31.0),
("2024-03-31T23:00:00+02:00", 32.0),
]

data = [{"hour": ts, "price": p} for ts, p in prices]
rates = my_predbat.minute_data_hourly_rates(
data,
my_predbat.forecast_days + 1,
midnight,
rate_key="price",
from_key="hour",
scale=1.0,
use_cent=True,
)

# Expected minute offsets (all computed via UTC relative to midnight+01:00):
# minute 0 = 00:00+01:00 = 23:00 UTC prev day -> rate 10.0
# minute 60 = 01:00+01:00 = 00:00 UTC -> rate 11.0
# minute 120 = 03:00+02:00 = 01:00 UTC (2 AM gap) -> rate 12.0
# minute 180 = 04:00+02:00 = 02:00 UTC -> rate 13.0
# minute 1320 = 23:00+02:00 = 21:00 UTC -> rate 32.0 (last hour of 23-hour day)
expected = {
0: 10.0,
30: 10.0,
59: 10.0,
60: 11.0,
119: 11.0,
120: 12.0,
179: 12.0,
180: 13.0,
1320: 32.0,
1379: 32.0,
}

for minute, rate in expected.items():
if rates.get(minute) != rate:
print(f"ERROR: DST spring forward: minute {minute} expected {rate}, got {rates.get(minute)}")
failed = 1

# Regression: minute 0 must be present (old bug shifted entire dataset to avoid KeyError)
if 0 not in rates:
print("ERROR: DST spring forward: minute 0 missing (offset-shift regression)")
failed = 1

# Slots for the skipped 2 AM (minutes 120-179) must hold the 03:00+02:00 rate, not be absent
for minute in range(120, 180):
if rates.get(minute) != 12.0:
print(f"ERROR: DST spring forward: minute {minute} (DST gap slot) expected 12.0, got {rates.get(minute)}")
failed = 1
break

return failed


def test_energydataservice_dst_fall_back(my_predbat):
"""
DST regression: Europe/Copenhagen fall back (2024-10-27).
Clocks jump 03:00 -> 02:00 (CEST+02:00 -> CET+01:00), producing a 25-hour day.
The 02:00 hour appears twice with different UTC offsets; each occurrence must
map to a distinct minute slot (120 and 180 respectively).
"""
failed = 0
print("Test energy data service DST fall back")

# midnight in Copenhagen on fall-back day: 2024-10-27T00:00:00+02:00
midnight = datetime(2024, 10, 27, 0, 0, 0, tzinfo=timezone(timedelta(hours=2)))

# 25 hourly entries; 02:00 appears twice (once as +02:00 CEST, once as +01:00 CET).
prices = [
("2024-10-27T00:00:00+02:00", 10.0),
("2024-10-27T01:00:00+02:00", 11.0),
("2024-10-27T02:00:00+02:00", 12.0), # first 2 AM (CEST)
("2024-10-27T02:00:00+01:00", 13.0), # second 2 AM (CET, after fallback)
("2024-10-27T03:00:00+01:00", 14.0),
("2024-10-27T04:00:00+01:00", 15.0),
("2024-10-27T05:00:00+01:00", 16.0),
("2024-10-27T06:00:00+01:00", 17.0),
("2024-10-27T07:00:00+01:00", 18.0),
("2024-10-27T08:00:00+01:00", 19.0),
("2024-10-27T09:00:00+01:00", 20.0),
("2024-10-27T10:00:00+01:00", 21.0),
("2024-10-27T11:00:00+01:00", 22.0),
("2024-10-27T12:00:00+01:00", 23.0),
("2024-10-27T13:00:00+01:00", 24.0),
("2024-10-27T14:00:00+01:00", 25.0),
("2024-10-27T15:00:00+01:00", 26.0),
("2024-10-27T16:00:00+01:00", 27.0),
("2024-10-27T17:00:00+01:00", 28.0),
("2024-10-27T18:00:00+01:00", 29.0),
("2024-10-27T19:00:00+01:00", 30.0),
("2024-10-27T20:00:00+01:00", 31.0),
("2024-10-27T21:00:00+01:00", 32.0),
("2024-10-27T22:00:00+01:00", 33.0),
("2024-10-27T23:00:00+01:00", 34.0), # 24 hours after midnight = minute 1440
]

data = [{"hour": ts, "price": p} for ts, p in prices]
rates = my_predbat.minute_data_hourly_rates(
data,
my_predbat.forecast_days + 1,
midnight,
rate_key="price",
from_key="hour",
scale=1.0,
use_cent=True,
)

# Expected minute offsets (all via UTC relative to midnight+02:00 = 22:00 UTC prev day):
# minute 0 = 00:00+02:00 = 22:00 UTC prev -> rate 10.0
# minute 60 = 01:00+02:00 = 23:00 UTC prev -> rate 11.0
# minute 120 = 02:00+02:00 = 00:00 UTC -> rate 12.0 (first 2 AM)
# minute 180 = 02:00+01:00 = 01:00 UTC -> rate 13.0 (second 2 AM)
# minute 240 = 03:00+01:00 = 02:00 UTC -> rate 14.0
# minute 1440 = 23:00+01:00 = 22:00 UTC -> rate 34.0 (last of 25-hour day)
expected = {
0: 10.0,
60: 11.0,
120: 12.0,
179: 12.0,
180: 13.0,
239: 13.0,
240: 14.0,
1440: 34.0,
1499: 34.0,
}

for minute, rate in expected.items():
if rates.get(minute) != rate:
print(f"ERROR: DST fall back: minute {minute} expected {rate}, got {rates.get(minute)}")
failed = 1

# Regression: minute 0 must be present
if 0 not in rates:
print("ERROR: DST fall back: minute 0 missing (offset-shift regression)")
failed = 1

# Regression: duplicate 2 AM hours must map to distinct minute slots
if rates.get(120) == rates.get(180):
print(f"ERROR: DST fall back: both 2 AM occurrences map to same slot (rate {rates.get(120)}) - UTC disambiguation failed")
failed = 1

return failed


def test_energydataservice_dst_fill_forward(my_predbat):
"""
Regression for the fill-forward fix: if minute 0 is absent from rate_data,
gaps at the start must be filled from 24 hours ahead when available, or from
the first valid rate - never by shifting the entire dataset.
"""
failed = 0
print("Test energy data service DST fill-forward (no offset shift)")

midnight = datetime(2024, 6, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=1)))

# Scenario A: data starts at minute 60, but minute 0+1440 exists (24h-ahead rate).
# Minute 0 should be filled from minute 1440, not by shifting minute 60 -> minute 0.
data_with_future = [
{"hour": "2024-06-01T01:00:00+01:00", "price": 50.0}, # minute 60
{"hour": "2024-06-01T02:00:00+01:00", "price": 51.0}, # minute 120
{"hour": "2024-06-02T00:00:00+01:00", "price": 99.0}, # minute 1440 (24h ahead of minute 0)
]
rates_a = my_predbat.minute_data_hourly_rates(
data_with_future,
my_predbat.forecast_days + 1,
midnight,
rate_key="price",
from_key="hour",
scale=1.0,
use_cent=True,
)

# minute 60 must still hold the 01:00 rate (not shifted away)
if rates_a.get(60) != 50.0:
print(f"ERROR: fill-forward A: minute 60 expected 50.0 (no shift), got {rates_a.get(60)}")
failed = 1
# minutes 0-59 filled from minute+1440 (all 99.0 since the 24h-ahead slot covers 1440-1499)
for m in range(0, 60):
if rates_a.get(m) != 99.0:
print(f"ERROR: fill-forward A: minute {m} expected 99.0 (from +24h slot), got {rates_a.get(m)}")
failed = 1
break

# Scenario B: data starts at minute 60, no 24h-ahead entry.
# Minute 0 should be filled with the first valid rate (50.0), not trigger a shift.
data_no_future = [
{"hour": "2024-06-01T01:00:00+01:00", "price": 50.0}, # minute 60
{"hour": "2024-06-01T02:00:00+01:00", "price": 51.0}, # minute 120
]
rates_b = my_predbat.minute_data_hourly_rates(
data_no_future,
my_predbat.forecast_days + 1,
midnight,
rate_key="price",
from_key="hour",
scale=1.0,
use_cent=True,
)

# minute 60 must still hold the 01:00 rate (not shifted away)
if rates_b.get(60) != 50.0:
print(f"ERROR: fill-forward B: minute 60 expected 50.0 (no shift), got {rates_b.get(60)}")
failed = 1
# minute 0 must be filled with first valid rate (50.0)
if rates_b.get(0) != 50.0:
print(f"ERROR: fill-forward B: minute 0 expected 50.0 (first rate fallback), got {rates_b.get(0)}")
failed = 1

return failed


def run_energydataservice_tests(my_predbat):
"""Run all Energi Data Service tests."""
failed = 0
failed += test_energydataservice(my_predbat)
failed += test_energydataservice_dst_spring_forward(my_predbat)
failed += test_energydataservice_dst_fall_back(my_predbat)
failed += test_energydataservice_dst_fill_forward(my_predbat)
return failed
Loading
Loading