33from __future__ import annotations
44
55from dataclasses import dataclass
6- from datetime import datetime , timedelta
6+ from datetime import datetime , timedelta , timezone
77from typing import TYPE_CHECKING , Any
88
99if 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 )
0 commit comments