Skip to content

Commit af942ea

Browse files
authored
Handle partial price data better (#550)
If today or tomorrow has only partial price data, don't return any averages or highest/lowest price, because it's not possible to return something that makes sense if we don't have prices for the whole day. Other bug fixes: - Handle prices with UTC timestamps in the raw data for non-FI regions. - Return correct value for 60 minute spot prices at all times
1 parent 5f5310d commit af942ea

6 files changed

Lines changed: 2111 additions & 19 deletions

File tree

spothinta_api/models.py

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,26 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from datetime import datetime, timedelta
6+
from datetime import datetime, timedelta, timezone
77
from typing import TYPE_CHECKING, Any
88

99
if TYPE_CHECKING:
1010
from collections.abc import Callable
1111
from zoneinfo import ZoneInfo
1212

1313

14-
def _timed_value(moment: datetime, prices: dict[datetime, float]) -> float | None:
15-
"""Return a function that returns a value at a specific time.
14+
def _timed_value(
15+
moment: datetime,
16+
prices: dict[datetime, float],
17+
resolution: timedelta,
18+
) -> float | None:
19+
"""Return a value at a specific time.
1620
1721
Args:
1822
----
1923
moment: The time to get the value for.
2024
prices: A dictionary with market prices.
25+
resolution: The time resolution of price intervals.
2126
2227
Returns:
2328
-------
@@ -26,7 +31,7 @@ def _timed_value(moment: datetime, prices: dict[datetime, float]) -> float | Non
2631
"""
2732
value = None
2833
for timestamp, price in prices.items():
29-
future_dt = timestamp + timedelta(minutes=15)
34+
future_dt = timestamp + resolution
3035
if timestamp <= moment < future_dt:
3136
value = round(price, 5)
3237
return value
@@ -59,6 +64,7 @@ class Electricity:
5964
"""Object representing electricity data."""
6065

6166
prices: dict[datetime, float]
67+
resolution: timedelta
6268
time_zone: ZoneInfo
6369

6470
@property
@@ -249,35 +255,87 @@ def intervals_priced_equal_or_lower(self) -> int:
249255
return sum(price <= current for price in self.prices_today().values())
250256

251257
def prices_today(self) -> dict[datetime, float]:
252-
"""Return the prices for today.
258+
"""Return the prices for today, if available.
253259
254260
Returns
255261
-------
256-
The prices for today.
262+
The prices for today, or an empty dictionary if no prices are available.
257263
258264
"""
259-
today = self.now_in_timezone().astimezone().date()
260-
return {
265+
today = self.now_in_timezone().date()
266+
prices = {
261267
timestamp: price
262268
for timestamp, price in self.prices.items()
263-
if timestamp.date() == today
269+
if timestamp.astimezone(self.time_zone).date() == today
264270
}
265271

272+
# Calculate expected intervals accounting for DST transitions.
273+
# On DST transition days, local time spans may be 23 or 25 hours,
274+
# not 24, due to the shifted/repeated hour. We count UTC hours that
275+
# correspond to the local date to handle DST correctly.
276+
day_start = datetime.combine(today, datetime.min.time(), tzinfo=self.time_zone)
277+
day_start_utc = day_start.astimezone(timezone.utc)
278+
279+
# Count UTC hours that fall within this local date
280+
hour_count = 0
281+
current_utc = day_start_utc
282+
while current_utc.astimezone(self.time_zone).date() == today:
283+
hour_count += 1
284+
current_utc = current_utc + timedelta(hours=1)
285+
286+
expected_intervals = max(
287+
1,
288+
int((hour_count * timedelta(hours=1)) / self.resolution),
289+
)
290+
291+
if len(prices) == expected_intervals:
292+
return prices
293+
294+
return {}
295+
266296
def prices_tomorrow(self) -> dict[datetime, float]:
267-
"""Return the prices for tomorrow.
297+
"""Return the prices for tomorrow, if available.
268298
269299
Returns
270300
-------
271-
The prices for tomorrow.
301+
The prices for tomorrow, or an empty dictionary if no prices are available.
272302
273303
"""
274-
tomorrow = (self.now_in_timezone() + timedelta(days=1)).astimezone().date()
275-
return {
304+
tomorrow = (self.now_in_timezone() + timedelta(days=1)).date()
305+
prices = {
276306
timestamp: price
277307
for timestamp, price in self.prices.items()
278-
if timestamp.date() == tomorrow
308+
if timestamp.astimezone(self.time_zone).date() == tomorrow
279309
}
280310

311+
# Calculate expected intervals accounting for DST transitions.
312+
# On DST transition days, local time spans may be 23 or 25 hours,
313+
# not 24, due to the shifted/repeated hour. We count UTC hours that
314+
# correspond to the local date to handle DST correctly.
315+
day_start = datetime.combine(
316+
tomorrow,
317+
datetime.min.time(),
318+
tzinfo=self.time_zone,
319+
)
320+
day_start_utc = day_start.astimezone(timezone.utc)
321+
322+
# Count UTC hours that fall within this local date
323+
hour_count = 0
324+
current_utc = day_start_utc
325+
while current_utc.astimezone(self.time_zone).date() == tomorrow:
326+
hour_count += 1
327+
current_utc = current_utc + timedelta(hours=1)
328+
329+
expected_intervals = max(
330+
1,
331+
int((hour_count * timedelta(hours=1)) / self.resolution),
332+
)
333+
334+
if len(prices) == expected_intervals:
335+
return prices
336+
337+
return {}
338+
281339
def now_in_timezone(self) -> datetime:
282340
"""Return the current timestamp in the current timezone.
283341
@@ -320,7 +378,7 @@ def price_at_time(self, moment: datetime) -> float | None:
320378
The price at the specified time.
321379
322380
"""
323-
value = _timed_value(moment, self.prices)
381+
value = _timed_value(moment, self.prices, self.resolution)
324382
if value is not None or value == 0:
325383
return value
326384
return None
@@ -329,13 +387,15 @@ def price_at_time(self, moment: datetime) -> float | None:
329387
def from_dict(
330388
cls: type[Electricity],
331389
data: list[dict[str, Any]],
390+
resolution: timedelta,
332391
time_zone: ZoneInfo,
333392
) -> Electricity:
334393
"""Create an Electricity object from a dictionary.
335394
336395
Args:
337396
----
338397
data: A dictionary with the data from the API.
398+
resolution: The price resolution.
339399
time_zone: The timezone to use for determining "today" and "tomorrow".
340400
341401
Returns:
@@ -350,5 +410,6 @@ def from_dict(
350410
]
351411
return cls(
352412
prices=prices,
413+
resolution=resolution,
353414
time_zone=time_zone,
354415
)

spothinta_api/spothinta.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ async def energy_prices(
161161
raise SpotHintaNoDataError(msg)
162162

163163
time_zone = await async_get_time_zone(REGION_TO_TIMEZONE[region])
164-
return Electricity.from_dict(data, time_zone=time_zone)
164+
return Electricity.from_dict(data, resolution=resolution, time_zone=time_zone)
165165

166166
async def close(self) -> None:
167167
"""Close open client session."""

0 commit comments

Comments
 (0)