From 4e0bc076dcbdd1c038ea3e6967570880fa83fab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Wed, 1 Apr 2026 20:03:49 +0300 Subject: [PATCH 1/4] Handle partial price data better 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. --- spothinta_api/models.py | 22 +- spothinta_api/spothinta.py | 2 +- .../energy-15-min-partial-tomorrow.json | 602 ++++++++++++++++++ tests/test_models.py | 119 ++++ 4 files changed, 742 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/energy-15-min-partial-tomorrow.json diff --git a/spothinta_api/models.py b/spothinta_api/models.py index 1f89261..8e4793f 100644 --- a/spothinta_api/models.py +++ b/spothinta_api/models.py @@ -59,6 +59,7 @@ class Electricity: """Object representing electricity data.""" prices: dict[datetime, float] + resolution: timedelta time_zone: ZoneInfo @property @@ -257,12 +258,19 @@ def prices_today(self) -> dict[datetime, float]: """ today = self.now_in_timezone().astimezone().date() - return { + prices = { timestamp: price for timestamp, price in self.prices.items() if timestamp.date() == today } + if (self.resolution == timedelta(minutes=15) and len(prices) == 96) or ( + self.resolution == timedelta(minutes=60) and len(prices) == 24 + ): + return prices + + return {} + def prices_tomorrow(self) -> dict[datetime, float]: """Return the prices for tomorrow. @@ -272,12 +280,19 @@ def prices_tomorrow(self) -> dict[datetime, float]: """ tomorrow = (self.now_in_timezone() + timedelta(days=1)).astimezone().date() - return { + prices = { timestamp: price for timestamp, price in self.prices.items() if timestamp.date() == tomorrow } + if (self.resolution == timedelta(minutes=15) and len(prices) == 96) or ( + self.resolution == timedelta(minutes=60) and len(prices) == 24 + ): + return prices + + return {} + def now_in_timezone(self) -> datetime: """Return the current timestamp in the current timezone. @@ -329,6 +344,7 @@ def price_at_time(self, moment: datetime) -> float | None: def from_dict( cls: type[Electricity], data: list[dict[str, Any]], + resolution: timedelta, time_zone: ZoneInfo, ) -> Electricity: """Create an Electricity object from a dictionary. @@ -336,6 +352,7 @@ def from_dict( Args: ---- data: A dictionary with the data from the API. + resolution: The price resolution. time_zone: The timezone to use for determining "today" and "tomorrow". Returns: @@ -350,5 +367,6 @@ def from_dict( ] return cls( prices=prices, + resolution=resolution, time_zone=time_zone, ) diff --git a/spothinta_api/spothinta.py b/spothinta_api/spothinta.py index f71ae43..dd6f35d 100644 --- a/spothinta_api/spothinta.py +++ b/spothinta_api/spothinta.py @@ -161,7 +161,7 @@ async def energy_prices( raise SpotHintaNoDataError(msg) time_zone = await async_get_time_zone(REGION_TO_TIMEZONE[region]) - return Electricity.from_dict(data, time_zone=time_zone) + return Electricity.from_dict(data, resolution=resolution, time_zone=time_zone) async def close(self) -> None: """Close open client session.""" diff --git a/tests/fixtures/energy-15-min-partial-tomorrow.json b/tests/fixtures/energy-15-min-partial-tomorrow.json new file mode 100644 index 0000000..cea3cce --- /dev/null +++ b/tests/fixtures/energy-15-min-partial-tomorrow.json @@ -0,0 +1,602 @@ +[ + { + "Rank": 96, + "DateTime": "2026-04-01T00:00:00+03:00", + "PriceNoTax": 0.07415, + "PriceWithTax": 0.09306 + }, + { + "Rank": 94, + "DateTime": "2026-04-01T00:15:00+03:00", + "PriceNoTax": 0.05158, + "PriceWithTax": 0.06473 + }, + { + "Rank": 93, + "DateTime": "2026-04-01T00:30:00+03:00", + "PriceNoTax": 0.03299, + "PriceWithTax": 0.04140 + }, + { + "Rank": 91, + "DateTime": "2026-04-01T00:45:00+03:00", + "PriceNoTax": 0.02195, + "PriceWithTax": 0.02755 + }, + { + "Rank": 95, + "DateTime": "2026-04-01T01:00:00+03:00", + "PriceNoTax": 0.05401, + "PriceWithTax": 0.06778 + }, + { + "Rank": 92, + "DateTime": "2026-04-01T01:15:00+03:00", + "PriceNoTax": 0.02225, + "PriceWithTax": 0.02792 + }, + { + "Rank": 89, + "DateTime": "2026-04-01T01:30:00+03:00", + "PriceNoTax": 0.02043, + "PriceWithTax": 0.02564 + }, + { + "Rank": 87, + "DateTime": "2026-04-01T01:45:00+03:00", + "PriceNoTax": 0.01958, + "PriceWithTax": 0.02457 + }, + { + "Rank": 88, + "DateTime": "2026-04-01T02:00:00+03:00", + "PriceNoTax": 0.0196, + "PriceWithTax": 0.02460 + }, + { + "Rank": 85, + "DateTime": "2026-04-01T02:15:00+03:00", + "PriceNoTax": 0.01858, + "PriceWithTax": 0.02332 + }, + { + "Rank": 81, + "DateTime": "2026-04-01T02:30:00+03:00", + "PriceNoTax": 0.01798, + "PriceWithTax": 0.02256 + }, + { + "Rank": 76, + "DateTime": "2026-04-01T02:45:00+03:00", + "PriceNoTax": 0.01747, + "PriceWithTax": 0.02192 + }, + { + "Rank": 84, + "DateTime": "2026-04-01T03:00:00+03:00", + "PriceNoTax": 0.01825, + "PriceWithTax": 0.02290 + }, + { + "Rank": 78, + "DateTime": "2026-04-01T03:15:00+03:00", + "PriceNoTax": 0.01772, + "PriceWithTax": 0.02224 + }, + { + "Rank": 73, + "DateTime": "2026-04-01T03:30:00+03:00", + "PriceNoTax": 0.01719, + "PriceWithTax": 0.02157 + }, + { + "Rank": 69, + "DateTime": "2026-04-01T03:45:00+03:00", + "PriceNoTax": 0.017, + "PriceWithTax": 0.02134 + }, + { + "Rank": 79, + "DateTime": "2026-04-01T04:00:00+03:00", + "PriceNoTax": 0.0179, + "PriceWithTax": 0.02246 + }, + { + "Rank": 74, + "DateTime": "2026-04-01T04:15:00+03:00", + "PriceNoTax": 0.01724, + "PriceWithTax": 0.02164 + }, + { + "Rank": 68, + "DateTime": "2026-04-01T04:30:00+03:00", + "PriceNoTax": 0.01679, + "PriceWithTax": 0.02107 + }, + { + "Rank": 65, + "DateTime": "2026-04-01T04:45:00+03:00", + "PriceNoTax": 0.01605, + "PriceWithTax": 0.02014 + }, + { + "Rank": 75, + "DateTime": "2026-04-01T05:00:00+03:00", + "PriceNoTax": 0.01738, + "PriceWithTax": 0.02181 + }, + { + "Rank": 70, + "DateTime": "2026-04-01T05:15:00+03:00", + "PriceNoTax": 0.01703, + "PriceWithTax": 0.02137 + }, + { + "Rank": 72, + "DateTime": "2026-04-01T05:30:00+03:00", + "PriceNoTax": 0.01713, + "PriceWithTax": 0.02150 + }, + { + "Rank": 66, + "DateTime": "2026-04-01T05:45:00+03:00", + "PriceNoTax": 0.01648, + "PriceWithTax": 0.02068 + }, + { + "Rank": 67, + "DateTime": "2026-04-01T06:00:00+03:00", + "PriceNoTax": 0.01659, + "PriceWithTax": 0.02082 + }, + { + "Rank": 77, + "DateTime": "2026-04-01T06:15:00+03:00", + "PriceNoTax": 0.01761, + "PriceWithTax": 0.02210 + }, + { + "Rank": 83, + "DateTime": "2026-04-01T06:30:00+03:00", + "PriceNoTax": 0.01811, + "PriceWithTax": 0.02273 + }, + { + "Rank": 90, + "DateTime": "2026-04-01T06:45:00+03:00", + "PriceNoTax": 0.02069, + "PriceWithTax": 0.02597 + }, + { + "Rank": 80, + "DateTime": "2026-04-01T07:00:00+03:00", + "PriceNoTax": 0.0179, + "PriceWithTax": 0.02246 + }, + { + "Rank": 86, + "DateTime": "2026-04-01T07:15:00+03:00", + "PriceNoTax": 0.01862, + "PriceWithTax": 0.02337 + }, + { + "Rank": 82, + "DateTime": "2026-04-01T07:30:00+03:00", + "PriceNoTax": 0.01804, + "PriceWithTax": 0.02264 + }, + { + "Rank": 45, + "DateTime": "2026-04-01T07:45:00+03:00", + "PriceNoTax": 0.01247, + "PriceWithTax": 0.01565 + }, + { + "Rank": 47, + "DateTime": "2026-04-01T08:00:00+03:00", + "PriceNoTax": 0.01273, + "PriceWithTax": 0.01598 + }, + { + "Rank": 51, + "DateTime": "2026-04-01T08:15:00+03:00", + "PriceNoTax": 0.0135, + "PriceWithTax": 0.01694 + }, + { + "Rank": 57, + "DateTime": "2026-04-01T08:30:00+03:00", + "PriceNoTax": 0.01451, + "PriceWithTax": 0.01821 + }, + { + "Rank": 54, + "DateTime": "2026-04-01T08:45:00+03:00", + "PriceNoTax": 0.01423, + "PriceWithTax": 0.01786 + }, + { + "Rank": 58, + "DateTime": "2026-04-01T09:00:00+03:00", + "PriceNoTax": 0.0146, + "PriceWithTax": 0.01832 + }, + { + "Rank": 55, + "DateTime": "2026-04-01T09:15:00+03:00", + "PriceNoTax": 0.01431, + "PriceWithTax": 0.01796 + }, + { + "Rank": 48, + "DateTime": "2026-04-01T09:30:00+03:00", + "PriceNoTax": 0.01317, + "PriceWithTax": 0.01653 + }, + { + "Rank": 43, + "DateTime": "2026-04-01T09:45:00+03:00", + "PriceNoTax": 0.01215, + "PriceWithTax": 0.01525 + }, + { + "Rank": 61, + "DateTime": "2026-04-01T10:00:00+03:00", + "PriceNoTax": 0.01492, + "PriceWithTax": 0.01872 + }, + { + "Rank": 44, + "DateTime": "2026-04-01T10:15:00+03:00", + "PriceNoTax": 0.01239, + "PriceWithTax": 0.01555 + }, + { + "Rank": 36, + "DateTime": "2026-04-01T10:30:00+03:00", + "PriceNoTax": 0.01052, + "PriceWithTax": 0.01320 + }, + { + "Rank": 29, + "DateTime": "2026-04-01T10:45:00+03:00", + "PriceNoTax": 0.00933, + "PriceWithTax": 0.01171 + }, + { + "Rank": 53, + "DateTime": "2026-04-01T11:00:00+03:00", + "PriceNoTax": 0.014, + "PriceWithTax": 0.01757 + }, + { + "Rank": 42, + "DateTime": "2026-04-01T11:15:00+03:00", + "PriceNoTax": 0.01213, + "PriceWithTax": 0.01522 + }, + { + "Rank": 31, + "DateTime": "2026-04-01T11:30:00+03:00", + "PriceNoTax": 0.00958, + "PriceWithTax": 0.01202 + }, + { + "Rank": 22, + "DateTime": "2026-04-01T11:45:00+03:00", + "PriceNoTax": 0.00755, + "PriceWithTax": 0.00948 + }, + { + "Rank": 49, + "DateTime": "2026-04-01T12:00:00+03:00", + "PriceNoTax": 0.01318, + "PriceWithTax": 0.01654 + }, + { + "Rank": 26, + "DateTime": "2026-04-01T12:15:00+03:00", + "PriceNoTax": 0.00827, + "PriceWithTax": 0.01038 + }, + { + "Rank": 23, + "DateTime": "2026-04-01T12:30:00+03:00", + "PriceNoTax": 0.00772, + "PriceWithTax": 0.00969 + }, + { + "Rank": 17, + "DateTime": "2026-04-01T12:45:00+03:00", + "PriceNoTax": 0.00509, + "PriceWithTax": 0.00639 + }, + { + "Rank": 20, + "DateTime": "2026-04-01T13:00:00+03:00", + "PriceNoTax": 0.0066, + "PriceWithTax": 0.00828 + }, + { + "Rank": 16, + "DateTime": "2026-04-01T13:15:00+03:00", + "PriceNoTax": 0.00506, + "PriceWithTax": 0.00635 + }, + { + "Rank": 12, + "DateTime": "2026-04-01T13:30:00+03:00", + "PriceNoTax": 0.005, + "PriceWithTax": 0.00628 + }, + { + "Rank": 13, + "DateTime": "2026-04-01T13:45:00+03:00", + "PriceNoTax": 0.005, + "PriceWithTax": 0.00628 + }, + { + "Rank": 10, + "DateTime": "2026-04-01T14:00:00+03:00", + "PriceNoTax": 0.00499, + "PriceWithTax": 0.00626 + }, + { + "Rank": 8, + "DateTime": "2026-04-01T14:15:00+03:00", + "PriceNoTax": 0.00468, + "PriceWithTax": 0.00587 + }, + { + "Rank": 7, + "DateTime": "2026-04-01T14:30:00+03:00", + "PriceNoTax": 0.00441, + "PriceWithTax": 0.00553 + }, + { + "Rank": 5, + "DateTime": "2026-04-01T14:45:00+03:00", + "PriceNoTax": 0.0031, + "PriceWithTax": 0.00389 + }, + { + "Rank": 1, + "DateTime": "2026-04-01T15:00:00+03:00", + "PriceNoTax": 0.00272, + "PriceWithTax": 0.00341 + }, + { + "Rank": 3, + "DateTime": "2026-04-01T15:15:00+03:00", + "PriceNoTax": 0.00273, + "PriceWithTax": 0.00343 + }, + { + "Rank": 4, + "DateTime": "2026-04-01T15:30:00+03:00", + "PriceNoTax": 0.00273, + "PriceWithTax": 0.00343 + }, + { + "Rank": 2, + "DateTime": "2026-04-01T15:45:00+03:00", + "PriceNoTax": 0.00272, + "PriceWithTax": 0.00341 + }, + { + "Rank": 6, + "DateTime": "2026-04-01T16:00:00+03:00", + "PriceNoTax": 0.00429, + "PriceWithTax": 0.00538 + }, + { + "Rank": 9, + "DateTime": "2026-04-01T16:15:00+03:00", + "PriceNoTax": 0.0047, + "PriceWithTax": 0.00590 + }, + { + "Rank": 11, + "DateTime": "2026-04-01T16:30:00+03:00", + "PriceNoTax": 0.00499, + "PriceWithTax": 0.00626 + }, + { + "Rank": 14, + "DateTime": "2026-04-01T16:45:00+03:00", + "PriceNoTax": 0.005, + "PriceWithTax": 0.00628 + }, + { + "Rank": 15, + "DateTime": "2026-04-01T17:00:00+03:00", + "PriceNoTax": 0.005, + "PriceWithTax": 0.00628 + }, + { + "Rank": 18, + "DateTime": "2026-04-01T17:15:00+03:00", + "PriceNoTax": 0.00519, + "PriceWithTax": 0.00651 + }, + { + "Rank": 21, + "DateTime": "2026-04-01T17:30:00+03:00", + "PriceNoTax": 0.00728, + "PriceWithTax": 0.00914 + }, + { + "Rank": 25, + "DateTime": "2026-04-01T17:45:00+03:00", + "PriceNoTax": 0.00802, + "PriceWithTax": 0.01007 + }, + { + "Rank": 19, + "DateTime": "2026-04-01T18:00:00+03:00", + "PriceNoTax": 0.00589, + "PriceWithTax": 0.00739 + }, + { + "Rank": 24, + "DateTime": "2026-04-01T18:15:00+03:00", + "PriceNoTax": 0.00786, + "PriceWithTax": 0.00986 + }, + { + "Rank": 41, + "DateTime": "2026-04-01T18:30:00+03:00", + "PriceNoTax": 0.0115, + "PriceWithTax": 0.01443 + }, + { + "Rank": 71, + "DateTime": "2026-04-01T18:45:00+03:00", + "PriceNoTax": 0.01712, + "PriceWithTax": 0.02149 + }, + { + "Rank": 37, + "DateTime": "2026-04-01T19:00:00+03:00", + "PriceNoTax": 0.01056, + "PriceWithTax": 0.01325 + }, + { + "Rank": 28, + "DateTime": "2026-04-01T19:15:00+03:00", + "PriceNoTax": 0.00929, + "PriceWithTax": 0.01166 + }, + { + "Rank": 34, + "DateTime": "2026-04-01T19:30:00+03:00", + "PriceNoTax": 0.00995, + "PriceWithTax": 0.01249 + }, + { + "Rank": 39, + "DateTime": "2026-04-01T19:45:00+03:00", + "PriceNoTax": 0.01118, + "PriceWithTax": 0.01403 + }, + { + "Rank": 27, + "DateTime": "2026-04-01T20:00:00+03:00", + "PriceNoTax": 0.0089, + "PriceWithTax": 0.01117 + }, + { + "Rank": 30, + "DateTime": "2026-04-01T20:15:00+03:00", + "PriceNoTax": 0.00952, + "PriceWithTax": 0.01195 + }, + { + "Rank": 35, + "DateTime": "2026-04-01T20:30:00+03:00", + "PriceNoTax": 0.01, + "PriceWithTax": 0.01255 + }, + { + "Rank": 40, + "DateTime": "2026-04-01T20:45:00+03:00", + "PriceNoTax": 0.01123, + "PriceWithTax": 0.01409 + }, + { + "Rank": 32, + "DateTime": "2026-04-01T21:00:00+03:00", + "PriceNoTax": 0.0096, + "PriceWithTax": 0.01205 + }, + { + "Rank": 33, + "DateTime": "2026-04-01T21:15:00+03:00", + "PriceNoTax": 0.0097, + "PriceWithTax": 0.01217 + }, + { + "Rank": 38, + "DateTime": "2026-04-01T21:30:00+03:00", + "PriceNoTax": 0.01064, + "PriceWithTax": 0.01335 + }, + { + "Rank": 46, + "DateTime": "2026-04-01T21:45:00+03:00", + "PriceNoTax": 0.01272, + "PriceWithTax": 0.01596 + }, + { + "Rank": 50, + "DateTime": "2026-04-01T22:00:00+03:00", + "PriceNoTax": 0.01346, + "PriceWithTax": 0.01689 + }, + { + "Rank": 56, + "DateTime": "2026-04-01T22:15:00+03:00", + "PriceNoTax": 0.01448, + "PriceWithTax": 0.01817 + }, + { + "Rank": 63, + "DateTime": "2026-04-01T22:30:00+03:00", + "PriceNoTax": 0.01499, + "PriceWithTax": 0.01881 + }, + { + "Rank": 62, + "DateTime": "2026-04-01T22:45:00+03:00", + "PriceNoTax": 0.01492, + "PriceWithTax": 0.01872 + }, + { + "Rank": 52, + "DateTime": "2026-04-01T23:00:00+03:00", + "PriceNoTax": 0.0136, + "PriceWithTax": 0.01707 + }, + { + "Rank": 59, + "DateTime": "2026-04-01T23:15:00+03:00", + "PriceNoTax": 0.0146, + "PriceWithTax": 0.01832 + }, + { + "Rank": 60, + "DateTime": "2026-04-01T23:30:00+03:00", + "PriceNoTax": 0.01489, + "PriceWithTax": 0.01869 + }, + { + "Rank": 64, + "DateTime": "2026-04-01T23:45:00+03:00", + "PriceNoTax": 0.01527, + "PriceWithTax": 0.01916 + }, + { + "Rank": 1, + "DateTime": "2026-04-02T00:00:00+03:00", + "PriceNoTax": 0.0146, + "PriceWithTax": 0.01832 + }, + { + "Rank": 2, + "DateTime": "2026-04-02T00:15:00+03:00", + "PriceNoTax": 0.01505, + "PriceWithTax": 0.01889 + }, + { + "Rank": 3, + "DateTime": "2026-04-02T00:30:00+03:00", + "PriceNoTax": 0.01536, + "PriceWithTax": 0.01928 + }, + { + "Rank": 4, + "DateTime": "2026-04-02T00:45:00+03:00", + "PriceNoTax": 0.01603, + "PriceWithTax": 0.02012 + } +] diff --git a/tests/test_models.py b/tests/test_models.py index 0d60ae4..8372a37 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -285,6 +285,125 @@ async def test_only_data_for_tomorrow(aresponses: ResponsesMockServer) -> None: assert len(energy.timestamp_prices_today) == 0 +@pytest.mark.freeze_time("2026-04-01T14:00:10+03:00") +async def test_model_15_minute_resolution_partial_data_tomorrow( + aresponses: ResponsesMockServer, +) -> None: + """Test the model for usage at 14:00:10 UTC+3.""" + aresponses.add( + "api.spot-hinta.fi", + "/TodayAndDayForward", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("energy-15-min-partial-tomorrow.json"), + ), + ) + async with ClientSession() as session: + client = SpotHinta(session=session) + energy: Electricity = await client.energy_prices( + resolution=timedelta(minutes=15), + ) + assert energy is not None + assert isinstance(energy, Electricity) + assert energy.highest_price_today == 0.09306 + assert energy.highest_price_tomorrow is None + assert energy.lowest_price_today == 0.00341 + assert energy.lowest_price_tomorrow is None + assert energy.average_price_today == 0.01734 + assert energy.average_price_tomorrow is None + assert energy.current_price == 0.00626 + assert energy.intervals_priced_equal_or_lower == 11 + # The price for another interval + another_interval = datetime(2026, 4, 1, 20, 16, tzinfo=timezone.utc) + assert energy.price_at_time(another_interval) == 0.01832 + assert energy.lowest_price_time_today == datetime.strptime( + "2026-04-01 15:00:00+03:00", + "%Y-%m-%d %H:%M:%S%z", + ) + assert energy.lowest_price_time_tomorrow is None + assert energy.highest_price_time_today == datetime.strptime( + "2026-04-01 00:00:00+03:00", + "%Y-%m-%d %H:%M:%S%z", + ) + assert energy.highest_price_time_tomorrow is None + assert isinstance(energy.timestamp_prices, list) + + +@pytest.mark.freeze_time("2026-04-02T07:00:00+03:00") +async def test_model_15_minute_resolution_partial_data_today( + aresponses: ResponsesMockServer, +) -> None: + """Test the model for usage at 07:00:00 UTC+3.""" + aresponses.add( + "api.spot-hinta.fi", + "/TodayAndDayForward", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("energy-15-min-partial-tomorrow.json"), + ), + ) + async with ClientSession() as session: + client = SpotHinta(session=session) + energy: Electricity = await client.energy_prices( + resolution=timedelta(minutes=15), + ) + assert energy is not None + assert isinstance(energy, Electricity) + assert energy.highest_price_today is None + assert energy.highest_price_tomorrow is None + assert energy.lowest_price_today is None + assert energy.lowest_price_tomorrow is None + assert energy.average_price_today is None + assert energy.average_price_tomorrow is None + assert energy.current_price is None + assert energy.intervals_priced_equal_or_lower == 0 + # The price for another interval + another_interval = datetime(2026, 4, 2, 20, 16, tzinfo=timezone.utc) + assert energy.price_at_time(another_interval) is None + assert energy.lowest_price_time_today is None + assert energy.lowest_price_time_tomorrow is None + assert energy.highest_price_time_today is None + assert energy.highest_price_time_tomorrow is None + + assert isinstance(energy.timestamp_prices, list) + + +@pytest.mark.freeze_time("2026-04-02T00:02:00+03:00") +async def test_model_15_minute_resolution_partial_data_today_in_interval( + aresponses: ResponsesMockServer, +) -> None: + """Test the model for usage at 14:00:10 UTC+3.""" + aresponses.add( + "api.spot-hinta.fi", + "/TodayAndDayForward", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("energy-15-min-partial-tomorrow.json"), + ), + ) + async with ClientSession() as session: + client = SpotHinta(session=session) + energy: Electricity = await client.energy_prices( + resolution=timedelta(minutes=15), + ) + assert energy is not None + assert isinstance(energy, Electricity) + assert energy.highest_price_today is None + assert energy.highest_price_tomorrow is None + assert energy.lowest_price_today is None + assert energy.lowest_price_tomorrow is None + assert energy.average_price_today is None + assert energy.average_price_tomorrow is None + assert energy.current_price == 0.01832 + assert energy.intervals_priced_equal_or_lower == 0 + + async def test_no_electricity_data(aresponses: ResponsesMockServer) -> None: """Test when there is no electricity data.""" aresponses.add( From e28159b248e3667f6eb208db8d32cabd1cfdfa7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Wed, 1 Apr 2026 20:34:18 +0300 Subject: [PATCH 2/4] Handle prices with UTC timestamps in the raw data for non-FI regions --- spothinta_api/models.py | 60 +- tests/fixtures/energy-dst-fall-back-25h.json | 602 ++++++++++++++++++ .../energy-dst-spring-forward-23h.json | 554 ++++++++++++++++ tests/test_models.py | 100 ++- 4 files changed, 1301 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/energy-dst-fall-back-25h.json create mode 100644 tests/fixtures/energy-dst-spring-forward-23h.json diff --git a/spothinta_api/models.py b/spothinta_api/models.py index 8e4793f..f9e5b0d 100644 --- a/spothinta_api/models.py +++ b/spothinta_api/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -257,16 +257,33 @@ def prices_today(self) -> dict[datetime, float]: The prices for today. """ - today = self.now_in_timezone().astimezone().date() + today = self.now_in_timezone().date() prices = { timestamp: price for timestamp, price in self.prices.items() - if timestamp.date() == today + if timestamp.astimezone(self.time_zone).date() == today } - if (self.resolution == timedelta(minutes=15) and len(prices) == 96) or ( - self.resolution == timedelta(minutes=60) and len(prices) == 24 - ): + # Calculate expected intervals accounting for DST transitions. + # On DST transition days, local time spans may be 23 or 25 hours, + # not 24, due to the shifted/repeated hour. We count UTC hours that + # correspond to the local date to handle DST correctly. + day_start = datetime.combine(today, datetime.min.time(), tzinfo=self.time_zone) + day_start_utc = day_start.astimezone(timezone.utc) + + # Count UTC hours that fall within this local date + hour_count = 0 + current_utc = day_start_utc + while current_utc.astimezone(self.time_zone).date() == today: + hour_count += 1 + current_utc = current_utc + timedelta(hours=1) + + expected_intervals = max( + 1, + int((hour_count * timedelta(hours=1)) / self.resolution), + ) + + if len(prices) == expected_intervals: return prices return {} @@ -279,16 +296,37 @@ def prices_tomorrow(self) -> dict[datetime, float]: The prices for tomorrow. """ - tomorrow = (self.now_in_timezone() + timedelta(days=1)).astimezone().date() + tomorrow = (self.now_in_timezone() + timedelta(days=1)).date() prices = { timestamp: price for timestamp, price in self.prices.items() - if timestamp.date() == tomorrow + if timestamp.astimezone(self.time_zone).date() == tomorrow } - if (self.resolution == timedelta(minutes=15) and len(prices) == 96) or ( - self.resolution == timedelta(minutes=60) and len(prices) == 24 - ): + # Calculate expected intervals accounting for DST transitions. + # On DST transition days, local time spans may be 23 or 25 hours, + # not 24, due to the shifted/repeated hour. We count UTC hours that + # correspond to the local date to handle DST correctly. + day_start = datetime.combine( + tomorrow, + datetime.min.time(), + tzinfo=self.time_zone, + ) + day_start_utc = day_start.astimezone(timezone.utc) + + # Count UTC hours that fall within this local date + hour_count = 0 + current_utc = day_start_utc + while current_utc.astimezone(self.time_zone).date() == tomorrow: + hour_count += 1 + current_utc = current_utc + timedelta(hours=1) + + expected_intervals = max( + 1, + int((hour_count * timedelta(hours=1)) / self.resolution), + ) + + if len(prices) == expected_intervals: return prices return {} diff --git a/tests/fixtures/energy-dst-fall-back-25h.json b/tests/fixtures/energy-dst-fall-back-25h.json new file mode 100644 index 0000000..9aea7d9 --- /dev/null +++ b/tests/fixtures/energy-dst-fall-back-25h.json @@ -0,0 +1,602 @@ +[ + { + "Rank": 1, + "DateTime": "2026-10-24T22:00:00Z", + "PriceNoTax": 0.00432, + "PriceWithTax": 0.0054 + }, + { + "Rank": 2, + "DateTime": "2026-10-24T22:15:00Z", + "PriceNoTax": 0.00077, + "PriceWithTax": 0.00096 + }, + { + "Rank": 3, + "DateTime": "2026-10-24T22:30:00Z", + "PriceNoTax": 0.00488, + "PriceWithTax": 0.0061 + }, + { + "Rank": 4, + "DateTime": "2026-10-24T22:45:00Z", + "PriceNoTax": 0.00129, + "PriceWithTax": 0.00161 + }, + { + "Rank": 5, + "DateTime": "2026-10-24T23:00:00Z", + "PriceNoTax": 0.00128, + "PriceWithTax": 0.0016 + }, + { + "Rank": 6, + "DateTime": "2026-10-24T23:15:00Z", + "PriceNoTax": 0.00321, + "PriceWithTax": 0.00401 + }, + { + "Rank": 7, + "DateTime": "2026-10-24T23:30:00Z", + "PriceNoTax": 0.00407, + "PriceWithTax": 0.00509 + }, + { + "Rank": 8, + "DateTime": "2026-10-24T23:45:00Z", + "PriceNoTax": 0.00172, + "PriceWithTax": 0.00215 + }, + { + "Rank": 9, + "DateTime": "2026-10-25T00:00:00Z", + "PriceNoTax": 0.00123, + "PriceWithTax": 0.00154 + }, + { + "Rank": 10, + "DateTime": "2026-10-25T00:15:00Z", + "PriceNoTax": 0.00242, + "PriceWithTax": 0.00302 + }, + { + "Rank": 11, + "DateTime": "2026-10-25T00:30:00Z", + "PriceNoTax": 0.00296, + "PriceWithTax": 0.0037 + }, + { + "Rank": 12, + "DateTime": "2026-10-25T00:45:00Z", + "PriceNoTax": 0.00236, + "PriceWithTax": 0.00295 + }, + { + "Rank": 13, + "DateTime": "2026-10-25T01:00:00Z", + "PriceNoTax": 0.00247, + "PriceWithTax": 0.00309 + }, + { + "Rank": 14, + "DateTime": "2026-10-25T01:15:00Z", + "PriceNoTax": 0.0014, + "PriceWithTax": 0.00175 + }, + { + "Rank": 15, + "DateTime": "2026-10-25T01:30:00Z", + "PriceNoTax": 0.00063, + "PriceWithTax": 0.00079 + }, + { + "Rank": 16, + "DateTime": "2026-10-25T01:45:00Z", + "PriceNoTax": 0.00493, + "PriceWithTax": 0.00616 + }, + { + "Rank": 17, + "DateTime": "2026-10-25T02:00:00Z", + "PriceNoTax": 0.00191, + "PriceWithTax": 0.00239 + }, + { + "Rank": 18, + "DateTime": "2026-10-25T02:15:00Z", + "PriceNoTax": 0.00271, + "PriceWithTax": 0.00339 + }, + { + "Rank": 19, + "DateTime": "2026-10-25T02:30:00Z", + "PriceNoTax": 0.00101, + "PriceWithTax": 0.00126 + }, + { + "Rank": 20, + "DateTime": "2026-10-25T02:45:00Z", + "PriceNoTax": 0.00349, + "PriceWithTax": 0.00436 + }, + { + "Rank": 21, + "DateTime": "2026-10-25T03:00:00Z", + "PriceNoTax": 0.00463, + "PriceWithTax": 0.00579 + }, + { + "Rank": 22, + "DateTime": "2026-10-25T03:15:00Z", + "PriceNoTax": 0.00092, + "PriceWithTax": 0.00115 + }, + { + "Rank": 23, + "DateTime": "2026-10-25T03:30:00Z", + "PriceNoTax": 0.00489, + "PriceWithTax": 0.00611 + }, + { + "Rank": 24, + "DateTime": "2026-10-25T03:45:00Z", + "PriceNoTax": 0.00472, + "PriceWithTax": 0.0059 + }, + { + "Rank": 25, + "DateTime": "2026-10-25T04:00:00Z", + "PriceNoTax": 0.00366, + "PriceWithTax": 0.00458 + }, + { + "Rank": 26, + "DateTime": "2026-10-25T04:15:00Z", + "PriceNoTax": 0.00198, + "PriceWithTax": 0.00247 + }, + { + "Rank": 27, + "DateTime": "2026-10-25T04:30:00Z", + "PriceNoTax": 0.00384, + "PriceWithTax": 0.0048 + }, + { + "Rank": 28, + "DateTime": "2026-10-25T04:45:00Z", + "PriceNoTax": 0.00334, + "PriceWithTax": 0.00417 + }, + { + "Rank": 29, + "DateTime": "2026-10-25T05:00:00Z", + "PriceNoTax": 0.00066, + "PriceWithTax": 0.00082 + }, + { + "Rank": 30, + "DateTime": "2026-10-25T05:15:00Z", + "PriceNoTax": 0.00284, + "PriceWithTax": 0.00355 + }, + { + "Rank": 31, + "DateTime": "2026-10-25T05:30:00Z", + "PriceNoTax": 0.0028, + "PriceWithTax": 0.0035 + }, + { + "Rank": 32, + "DateTime": "2026-10-25T05:45:00Z", + "PriceNoTax": 0.00016, + "PriceWithTax": 0.0002 + }, + { + "Rank": 33, + "DateTime": "2026-10-25T06:00:00Z", + "PriceNoTax": 0.00049, + "PriceWithTax": 0.00061 + }, + { + "Rank": 34, + "DateTime": "2026-10-25T06:15:00Z", + "PriceNoTax": 0.00295, + "PriceWithTax": 0.00369 + }, + { + "Rank": 35, + "DateTime": "2026-10-25T06:30:00Z", + "PriceNoTax": 0.00274, + "PriceWithTax": 0.00342 + }, + { + "Rank": 36, + "DateTime": "2026-10-25T06:45:00Z", + "PriceNoTax": 0.00284, + "PriceWithTax": 0.00355 + }, + { + "Rank": 37, + "DateTime": "2026-10-25T07:00:00Z", + "PriceNoTax": 0.00174, + "PriceWithTax": 0.00217 + }, + { + "Rank": 38, + "DateTime": "2026-10-25T07:15:00Z", + "PriceNoTax": 0.00351, + "PriceWithTax": 0.00439 + }, + { + "Rank": 39, + "DateTime": "2026-10-25T07:30:00Z", + "PriceNoTax": 0.0033, + "PriceWithTax": 0.00413 + }, + { + "Rank": 40, + "DateTime": "2026-10-25T07:45:00Z", + "PriceNoTax": 0.00115, + "PriceWithTax": 0.00144 + }, + { + "Rank": 41, + "DateTime": "2026-10-25T08:00:00Z", + "PriceNoTax": 0.00239, + "PriceWithTax": 0.00299 + }, + { + "Rank": 42, + "DateTime": "2026-10-25T08:15:00Z", + "PriceNoTax": 0.00201, + "PriceWithTax": 0.00251 + }, + { + "Rank": 43, + "DateTime": "2026-10-25T08:30:00Z", + "PriceNoTax": 0.0033, + "PriceWithTax": 0.00413 + }, + { + "Rank": 44, + "DateTime": "2026-10-25T08:45:00Z", + "PriceNoTax": 0.00029, + "PriceWithTax": 0.00036 + }, + { + "Rank": 45, + "DateTime": "2026-10-25T09:00:00Z", + "PriceNoTax": 0.00214, + "PriceWithTax": 0.00267 + }, + { + "Rank": 46, + "DateTime": "2026-10-25T09:15:00Z", + "PriceNoTax": 0.00146, + "PriceWithTax": 0.00182 + }, + { + "Rank": 47, + "DateTime": "2026-10-25T09:30:00Z", + "PriceNoTax": 0.00454, + "PriceWithTax": 0.00567 + }, + { + "Rank": 48, + "DateTime": "2026-10-25T09:45:00Z", + "PriceNoTax": 0.00186, + "PriceWithTax": 0.00233 + }, + { + "Rank": 49, + "DateTime": "2026-10-25T10:00:00Z", + "PriceNoTax": 0.00284, + "PriceWithTax": 0.00355 + }, + { + "Rank": 50, + "DateTime": "2026-10-25T10:15:00Z", + "PriceNoTax": 0.00043, + "PriceWithTax": 0.00054 + }, + { + "Rank": 51, + "DateTime": "2026-10-25T10:30:00Z", + "PriceNoTax": 0.00259, + "PriceWithTax": 0.00324 + }, + { + "Rank": 52, + "DateTime": "2026-10-25T10:45:00Z", + "PriceNoTax": 0.00132, + "PriceWithTax": 0.00165 + }, + { + "Rank": 53, + "DateTime": "2026-10-25T11:00:00Z", + "PriceNoTax": 0.00499, + "PriceWithTax": 0.00624 + }, + { + "Rank": 54, + "DateTime": "2026-10-25T11:15:00Z", + "PriceNoTax": 0.00319, + "PriceWithTax": 0.00399 + }, + { + "Rank": 55, + "DateTime": "2026-10-25T11:30:00Z", + "PriceNoTax": 0.00467, + "PriceWithTax": 0.00584 + }, + { + "Rank": 56, + "DateTime": "2026-10-25T11:45:00Z", + "PriceNoTax": 0.00055, + "PriceWithTax": 0.00069 + }, + { + "Rank": 57, + "DateTime": "2026-10-25T12:00:00Z", + "PriceNoTax": 0.00493, + "PriceWithTax": 0.00616 + }, + { + "Rank": 58, + "DateTime": "2026-10-25T12:15:00Z", + "PriceNoTax": 0.00031, + "PriceWithTax": 0.00039 + }, + { + "Rank": 59, + "DateTime": "2026-10-25T12:30:00Z", + "PriceNoTax": 0.003, + "PriceWithTax": 0.00375 + }, + { + "Rank": 60, + "DateTime": "2026-10-25T12:45:00Z", + "PriceNoTax": 0.00122, + "PriceWithTax": 0.00152 + }, + { + "Rank": 61, + "DateTime": "2026-10-25T13:00:00Z", + "PriceNoTax": 0.00164, + "PriceWithTax": 0.00205 + }, + { + "Rank": 62, + "DateTime": "2026-10-25T13:15:00Z", + "PriceNoTax": 0.00257, + "PriceWithTax": 0.00321 + }, + { + "Rank": 63, + "DateTime": "2026-10-25T13:30:00Z", + "PriceNoTax": 0.00474, + "PriceWithTax": 0.00593 + }, + { + "Rank": 64, + "DateTime": "2026-10-25T13:45:00Z", + "PriceNoTax": 0.00233, + "PriceWithTax": 0.00291 + }, + { + "Rank": 65, + "DateTime": "2026-10-25T14:00:00Z", + "PriceNoTax": 0.00248, + "PriceWithTax": 0.0031 + }, + { + "Rank": 66, + "DateTime": "2026-10-25T14:15:00Z", + "PriceNoTax": 0.00217, + "PriceWithTax": 0.00271 + }, + { + "Rank": 67, + "DateTime": "2026-10-25T14:30:00Z", + "PriceNoTax": 0.00045, + "PriceWithTax": 0.00056 + }, + { + "Rank": 68, + "DateTime": "2026-10-25T14:45:00Z", + "PriceNoTax": 0.00047, + "PriceWithTax": 0.00059 + }, + { + "Rank": 69, + "DateTime": "2026-10-25T15:00:00Z", + "PriceNoTax": 0.00381, + "PriceWithTax": 0.00476 + }, + { + "Rank": 70, + "DateTime": "2026-10-25T15:15:00Z", + "PriceNoTax": 0.00352, + "PriceWithTax": 0.0044 + }, + { + "Rank": 71, + "DateTime": "2026-10-25T15:30:00Z", + "PriceNoTax": 0.00465, + "PriceWithTax": 0.00581 + }, + { + "Rank": 72, + "DateTime": "2026-10-25T15:45:00Z", + "PriceNoTax": 0.00172, + "PriceWithTax": 0.00215 + }, + { + "Rank": 73, + "DateTime": "2026-10-25T16:00:00Z", + "PriceNoTax": 0.00047, + "PriceWithTax": 0.00059 + }, + { + "Rank": 74, + "DateTime": "2026-10-25T16:15:00Z", + "PriceNoTax": 0.0015, + "PriceWithTax": 0.00187 + }, + { + "Rank": 75, + "DateTime": "2026-10-25T16:30:00Z", + "PriceNoTax": 0.0044, + "PriceWithTax": 0.0055 + }, + { + "Rank": 76, + "DateTime": "2026-10-25T16:45:00Z", + "PriceNoTax": 0.00235, + "PriceWithTax": 0.00294 + }, + { + "Rank": 77, + "DateTime": "2026-10-25T17:00:00Z", + "PriceNoTax": 0.00195, + "PriceWithTax": 0.00244 + }, + { + "Rank": 78, + "DateTime": "2026-10-25T17:15:00Z", + "PriceNoTax": 0.0041, + "PriceWithTax": 0.00513 + }, + { + "Rank": 79, + "DateTime": "2026-10-25T17:30:00Z", + "PriceNoTax": 0.00417, + "PriceWithTax": 0.00521 + }, + { + "Rank": 80, + "DateTime": "2026-10-25T17:45:00Z", + "PriceNoTax": 0.00083, + "PriceWithTax": 0.00104 + }, + { + "Rank": 81, + "DateTime": "2026-10-25T18:00:00Z", + "PriceNoTax": 0.00146, + "PriceWithTax": 0.00182 + }, + { + "Rank": 82, + "DateTime": "2026-10-25T18:15:00Z", + "PriceNoTax": 0.00263, + "PriceWithTax": 0.00329 + }, + { + "Rank": 83, + "DateTime": "2026-10-25T18:30:00Z", + "PriceNoTax": 0.00082, + "PriceWithTax": 0.00102 + }, + { + "Rank": 84, + "DateTime": "2026-10-25T18:45:00Z", + "PriceNoTax": 0.00258, + "PriceWithTax": 0.00322 + }, + { + "Rank": 85, + "DateTime": "2026-10-25T19:00:00Z", + "PriceNoTax": 0.00376, + "PriceWithTax": 0.0047 + }, + { + "Rank": 86, + "DateTime": "2026-10-25T19:15:00Z", + "PriceNoTax": 0.0011, + "PriceWithTax": 0.00138 + }, + { + "Rank": 87, + "DateTime": "2026-10-25T19:30:00Z", + "PriceNoTax": 0.00406, + "PriceWithTax": 0.00508 + }, + { + "Rank": 88, + "DateTime": "2026-10-25T19:45:00Z", + "PriceNoTax": 0.00083, + "PriceWithTax": 0.00104 + }, + { + "Rank": 89, + "DateTime": "2026-10-25T20:00:00Z", + "PriceNoTax": 0.00209, + "PriceWithTax": 0.00261 + }, + { + "Rank": 90, + "DateTime": "2026-10-25T20:15:00Z", + "PriceNoTax": 0.00051, + "PriceWithTax": 0.00064 + }, + { + "Rank": 91, + "DateTime": "2026-10-25T20:30:00Z", + "PriceNoTax": 0.00112, + "PriceWithTax": 0.0014 + }, + { + "Rank": 92, + "DateTime": "2026-10-25T20:45:00Z", + "PriceNoTax": 0.00052, + "PriceWithTax": 0.00065 + }, + { + "Rank": 93, + "DateTime": "2026-10-25T21:00:00Z", + "PriceNoTax": 0.00221, + "PriceWithTax": 0.00276 + }, + { + "Rank": 94, + "DateTime": "2026-10-25T21:15:00Z", + "PriceNoTax": 0.00466, + "PriceWithTax": 0.00583 + }, + { + "Rank": 95, + "DateTime": "2026-10-25T21:30:00Z", + "PriceNoTax": 0.00283, + "PriceWithTax": 0.00354 + }, + { + "Rank": 96, + "DateTime": "2026-10-25T21:45:00Z", + "PriceNoTax": 0.0039, + "PriceWithTax": 0.00487 + }, + { + "Rank": 1, + "DateTime": "2026-10-25T22:00:00Z", + "PriceNoTax": 0.00195, + "PriceWithTax": 0.00244 + }, + { + "Rank": 2, + "DateTime": "2026-10-25T22:15:00Z", + "PriceNoTax": 0.00107, + "PriceWithTax": 0.00134 + }, + { + "Rank": 3, + "DateTime": "2026-10-25T22:30:00Z", + "PriceNoTax": 0.00188, + "PriceWithTax": 0.00235 + }, + { + "Rank": 4, + "DateTime": "2026-10-25T22:45:00Z", + "PriceNoTax": 0.00212, + "PriceWithTax": 0.00265 + } +] \ No newline at end of file diff --git a/tests/fixtures/energy-dst-spring-forward-23h.json b/tests/fixtures/energy-dst-spring-forward-23h.json new file mode 100644 index 0000000..d36a91e --- /dev/null +++ b/tests/fixtures/energy-dst-spring-forward-23h.json @@ -0,0 +1,554 @@ +[ + { + "Rank": 1, + "DateTime": "2026-03-28T23:00:00Z", + "PriceNoTax": 0.00432, + "PriceWithTax": 0.0054 + }, + { + "Rank": 2, + "DateTime": "2026-03-28T23:15:00Z", + "PriceNoTax": 0.00077, + "PriceWithTax": 0.00096 + }, + { + "Rank": 3, + "DateTime": "2026-03-28T23:30:00Z", + "PriceNoTax": 0.00488, + "PriceWithTax": 0.0061 + }, + { + "Rank": 4, + "DateTime": "2026-03-28T23:45:00Z", + "PriceNoTax": 0.00129, + "PriceWithTax": 0.00161 + }, + { + "Rank": 5, + "DateTime": "2026-03-29T00:00:00Z", + "PriceNoTax": 0.00128, + "PriceWithTax": 0.0016 + }, + { + "Rank": 6, + "DateTime": "2026-03-29T00:15:00Z", + "PriceNoTax": 0.00321, + "PriceWithTax": 0.00401 + }, + { + "Rank": 7, + "DateTime": "2026-03-29T00:30:00Z", + "PriceNoTax": 0.00407, + "PriceWithTax": 0.00509 + }, + { + "Rank": 8, + "DateTime": "2026-03-29T00:45:00Z", + "PriceNoTax": 0.00172, + "PriceWithTax": 0.00215 + }, + { + "Rank": 9, + "DateTime": "2026-03-29T01:00:00Z", + "PriceNoTax": 0.00123, + "PriceWithTax": 0.00154 + }, + { + "Rank": 10, + "DateTime": "2026-03-29T01:15:00Z", + "PriceNoTax": 0.00242, + "PriceWithTax": 0.00302 + }, + { + "Rank": 11, + "DateTime": "2026-03-29T01:30:00Z", + "PriceNoTax": 0.00296, + "PriceWithTax": 0.0037 + }, + { + "Rank": 12, + "DateTime": "2026-03-29T01:45:00Z", + "PriceNoTax": 0.00236, + "PriceWithTax": 0.00295 + }, + { + "Rank": 13, + "DateTime": "2026-03-29T02:00:00Z", + "PriceNoTax": 0.00247, + "PriceWithTax": 0.00309 + }, + { + "Rank": 14, + "DateTime": "2026-03-29T02:15:00Z", + "PriceNoTax": 0.0014, + "PriceWithTax": 0.00175 + }, + { + "Rank": 15, + "DateTime": "2026-03-29T02:30:00Z", + "PriceNoTax": 0.00063, + "PriceWithTax": 0.00079 + }, + { + "Rank": 16, + "DateTime": "2026-03-29T02:45:00Z", + "PriceNoTax": 0.00493, + "PriceWithTax": 0.00616 + }, + { + "Rank": 17, + "DateTime": "2026-03-29T03:00:00Z", + "PriceNoTax": 0.00191, + "PriceWithTax": 0.00239 + }, + { + "Rank": 18, + "DateTime": "2026-03-29T03:15:00Z", + "PriceNoTax": 0.00271, + "PriceWithTax": 0.00339 + }, + { + "Rank": 19, + "DateTime": "2026-03-29T03:30:00Z", + "PriceNoTax": 0.00101, + "PriceWithTax": 0.00126 + }, + { + "Rank": 20, + "DateTime": "2026-03-29T03:45:00Z", + "PriceNoTax": 0.00349, + "PriceWithTax": 0.00436 + }, + { + "Rank": 21, + "DateTime": "2026-03-29T04:00:00Z", + "PriceNoTax": 0.00463, + "PriceWithTax": 0.00579 + }, + { + "Rank": 22, + "DateTime": "2026-03-29T04:15:00Z", + "PriceNoTax": 0.00092, + "PriceWithTax": 0.00115 + }, + { + "Rank": 23, + "DateTime": "2026-03-29T04:30:00Z", + "PriceNoTax": 0.00489, + "PriceWithTax": 0.00611 + }, + { + "Rank": 24, + "DateTime": "2026-03-29T04:45:00Z", + "PriceNoTax": 0.00472, + "PriceWithTax": 0.0059 + }, + { + "Rank": 25, + "DateTime": "2026-03-29T05:00:00Z", + "PriceNoTax": 0.00366, + "PriceWithTax": 0.00458 + }, + { + "Rank": 26, + "DateTime": "2026-03-29T05:15:00Z", + "PriceNoTax": 0.00198, + "PriceWithTax": 0.00247 + }, + { + "Rank": 27, + "DateTime": "2026-03-29T05:30:00Z", + "PriceNoTax": 0.00384, + "PriceWithTax": 0.0048 + }, + { + "Rank": 28, + "DateTime": "2026-03-29T05:45:00Z", + "PriceNoTax": 0.00334, + "PriceWithTax": 0.00417 + }, + { + "Rank": 29, + "DateTime": "2026-03-29T06:00:00Z", + "PriceNoTax": 0.00066, + "PriceWithTax": 0.00082 + }, + { + "Rank": 30, + "DateTime": "2026-03-29T06:15:00Z", + "PriceNoTax": 0.00284, + "PriceWithTax": 0.00355 + }, + { + "Rank": 31, + "DateTime": "2026-03-29T06:30:00Z", + "PriceNoTax": 0.0028, + "PriceWithTax": 0.0035 + }, + { + "Rank": 32, + "DateTime": "2026-03-29T06:45:00Z", + "PriceNoTax": 0.00016, + "PriceWithTax": 0.0002 + }, + { + "Rank": 33, + "DateTime": "2026-03-29T07:00:00Z", + "PriceNoTax": 0.00049, + "PriceWithTax": 0.00061 + }, + { + "Rank": 34, + "DateTime": "2026-03-29T07:15:00Z", + "PriceNoTax": 0.00295, + "PriceWithTax": 0.00369 + }, + { + "Rank": 35, + "DateTime": "2026-03-29T07:30:00Z", + "PriceNoTax": 0.00274, + "PriceWithTax": 0.00342 + }, + { + "Rank": 36, + "DateTime": "2026-03-29T07:45:00Z", + "PriceNoTax": 0.00284, + "PriceWithTax": 0.00355 + }, + { + "Rank": 37, + "DateTime": "2026-03-29T08:00:00Z", + "PriceNoTax": 0.00174, + "PriceWithTax": 0.00217 + }, + { + "Rank": 38, + "DateTime": "2026-03-29T08:15:00Z", + "PriceNoTax": 0.00351, + "PriceWithTax": 0.00439 + }, + { + "Rank": 39, + "DateTime": "2026-03-29T08:30:00Z", + "PriceNoTax": 0.0033, + "PriceWithTax": 0.00413 + }, + { + "Rank": 40, + "DateTime": "2026-03-29T08:45:00Z", + "PriceNoTax": 0.00115, + "PriceWithTax": 0.00144 + }, + { + "Rank": 41, + "DateTime": "2026-03-29T09:00:00Z", + "PriceNoTax": 0.00239, + "PriceWithTax": 0.00299 + }, + { + "Rank": 42, + "DateTime": "2026-03-29T09:15:00Z", + "PriceNoTax": 0.00201, + "PriceWithTax": 0.00251 + }, + { + "Rank": 43, + "DateTime": "2026-03-29T09:30:00Z", + "PriceNoTax": 0.0033, + "PriceWithTax": 0.00413 + }, + { + "Rank": 44, + "DateTime": "2026-03-29T09:45:00Z", + "PriceNoTax": 0.00029, + "PriceWithTax": 0.00036 + }, + { + "Rank": 45, + "DateTime": "2026-03-29T10:00:00Z", + "PriceNoTax": 0.00214, + "PriceWithTax": 0.00267 + }, + { + "Rank": 46, + "DateTime": "2026-03-29T10:15:00Z", + "PriceNoTax": 0.00146, + "PriceWithTax": 0.00182 + }, + { + "Rank": 47, + "DateTime": "2026-03-29T10:30:00Z", + "PriceNoTax": 0.00454, + "PriceWithTax": 0.00567 + }, + { + "Rank": 48, + "DateTime": "2026-03-29T10:45:00Z", + "PriceNoTax": 0.00186, + "PriceWithTax": 0.00233 + }, + { + "Rank": 49, + "DateTime": "2026-03-29T11:00:00Z", + "PriceNoTax": 0.00284, + "PriceWithTax": 0.00355 + }, + { + "Rank": 50, + "DateTime": "2026-03-29T11:15:00Z", + "PriceNoTax": 0.00043, + "PriceWithTax": 0.00054 + }, + { + "Rank": 51, + "DateTime": "2026-03-29T11:30:00Z", + "PriceNoTax": 0.00259, + "PriceWithTax": 0.00324 + }, + { + "Rank": 52, + "DateTime": "2026-03-29T11:45:00Z", + "PriceNoTax": 0.00132, + "PriceWithTax": 0.00165 + }, + { + "Rank": 53, + "DateTime": "2026-03-29T12:00:00Z", + "PriceNoTax": 0.00499, + "PriceWithTax": 0.00624 + }, + { + "Rank": 54, + "DateTime": "2026-03-29T12:15:00Z", + "PriceNoTax": 0.00319, + "PriceWithTax": 0.00399 + }, + { + "Rank": 55, + "DateTime": "2026-03-29T12:30:00Z", + "PriceNoTax": 0.00467, + "PriceWithTax": 0.00584 + }, + { + "Rank": 56, + "DateTime": "2026-03-29T12:45:00Z", + "PriceNoTax": 0.00055, + "PriceWithTax": 0.00069 + }, + { + "Rank": 57, + "DateTime": "2026-03-29T13:00:00Z", + "PriceNoTax": 0.00493, + "PriceWithTax": 0.00616 + }, + { + "Rank": 58, + "DateTime": "2026-03-29T13:15:00Z", + "PriceNoTax": 0.00031, + "PriceWithTax": 0.00039 + }, + { + "Rank": 59, + "DateTime": "2026-03-29T13:30:00Z", + "PriceNoTax": 0.003, + "PriceWithTax": 0.00375 + }, + { + "Rank": 60, + "DateTime": "2026-03-29T13:45:00Z", + "PriceNoTax": 0.00122, + "PriceWithTax": 0.00152 + }, + { + "Rank": 61, + "DateTime": "2026-03-29T14:00:00Z", + "PriceNoTax": 0.00164, + "PriceWithTax": 0.00205 + }, + { + "Rank": 62, + "DateTime": "2026-03-29T14:15:00Z", + "PriceNoTax": 0.00257, + "PriceWithTax": 0.00321 + }, + { + "Rank": 63, + "DateTime": "2026-03-29T14:30:00Z", + "PriceNoTax": 0.00474, + "PriceWithTax": 0.00593 + }, + { + "Rank": 64, + "DateTime": "2026-03-29T14:45:00Z", + "PriceNoTax": 0.00233, + "PriceWithTax": 0.00291 + }, + { + "Rank": 65, + "DateTime": "2026-03-29T15:00:00Z", + "PriceNoTax": 0.00248, + "PriceWithTax": 0.0031 + }, + { + "Rank": 66, + "DateTime": "2026-03-29T15:15:00Z", + "PriceNoTax": 0.00217, + "PriceWithTax": 0.00271 + }, + { + "Rank": 67, + "DateTime": "2026-03-29T15:30:00Z", + "PriceNoTax": 0.00045, + "PriceWithTax": 0.00056 + }, + { + "Rank": 68, + "DateTime": "2026-03-29T15:45:00Z", + "PriceNoTax": 0.00047, + "PriceWithTax": 0.00059 + }, + { + "Rank": 69, + "DateTime": "2026-03-29T16:00:00Z", + "PriceNoTax": 0.00381, + "PriceWithTax": 0.00476 + }, + { + "Rank": 70, + "DateTime": "2026-03-29T16:15:00Z", + "PriceNoTax": 0.00352, + "PriceWithTax": 0.0044 + }, + { + "Rank": 71, + "DateTime": "2026-03-29T16:30:00Z", + "PriceNoTax": 0.00465, + "PriceWithTax": 0.00581 + }, + { + "Rank": 72, + "DateTime": "2026-03-29T16:45:00Z", + "PriceNoTax": 0.00172, + "PriceWithTax": 0.00215 + }, + { + "Rank": 73, + "DateTime": "2026-03-29T17:00:00Z", + "PriceNoTax": 0.00047, + "PriceWithTax": 0.00059 + }, + { + "Rank": 74, + "DateTime": "2026-03-29T17:15:00Z", + "PriceNoTax": 0.0015, + "PriceWithTax": 0.00187 + }, + { + "Rank": 75, + "DateTime": "2026-03-29T17:30:00Z", + "PriceNoTax": 0.0044, + "PriceWithTax": 0.0055 + }, + { + "Rank": 76, + "DateTime": "2026-03-29T17:45:00Z", + "PriceNoTax": 0.00235, + "PriceWithTax": 0.00294 + }, + { + "Rank": 77, + "DateTime": "2026-03-29T18:00:00Z", + "PriceNoTax": 0.00195, + "PriceWithTax": 0.00244 + }, + { + "Rank": 78, + "DateTime": "2026-03-29T18:15:00Z", + "PriceNoTax": 0.0041, + "PriceWithTax": 0.00513 + }, + { + "Rank": 79, + "DateTime": "2026-03-29T18:30:00Z", + "PriceNoTax": 0.00417, + "PriceWithTax": 0.00521 + }, + { + "Rank": 80, + "DateTime": "2026-03-29T18:45:00Z", + "PriceNoTax": 0.00083, + "PriceWithTax": 0.00104 + }, + { + "Rank": 81, + "DateTime": "2026-03-29T19:00:00Z", + "PriceNoTax": 0.00146, + "PriceWithTax": 0.00182 + }, + { + "Rank": 82, + "DateTime": "2026-03-29T19:15:00Z", + "PriceNoTax": 0.00263, + "PriceWithTax": 0.00329 + }, + { + "Rank": 83, + "DateTime": "2026-03-29T19:30:00Z", + "PriceNoTax": 0.00082, + "PriceWithTax": 0.00102 + }, + { + "Rank": 84, + "DateTime": "2026-03-29T19:45:00Z", + "PriceNoTax": 0.00258, + "PriceWithTax": 0.00322 + }, + { + "Rank": 85, + "DateTime": "2026-03-29T20:00:00Z", + "PriceNoTax": 0.00376, + "PriceWithTax": 0.0047 + }, + { + "Rank": 86, + "DateTime": "2026-03-29T20:15:00Z", + "PriceNoTax": 0.0011, + "PriceWithTax": 0.00138 + }, + { + "Rank": 87, + "DateTime": "2026-03-29T20:30:00Z", + "PriceNoTax": 0.00406, + "PriceWithTax": 0.00508 + }, + { + "Rank": 88, + "DateTime": "2026-03-29T20:45:00Z", + "PriceNoTax": 0.00083, + "PriceWithTax": 0.00104 + }, + { + "Rank": 89, + "DateTime": "2026-03-29T21:00:00Z", + "PriceNoTax": 0.00209, + "PriceWithTax": 0.00261 + }, + { + "Rank": 90, + "DateTime": "2026-03-29T21:15:00Z", + "PriceNoTax": 0.00051, + "PriceWithTax": 0.00064 + }, + { + "Rank": 91, + "DateTime": "2026-03-29T21:30:00Z", + "PriceNoTax": 0.00112, + "PriceWithTax": 0.0014 + }, + { + "Rank": 92, + "DateTime": "2026-03-29T21:45:00Z", + "PriceNoTax": 0.00052, + "PriceWithTax": 0.00065 + } +] \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index 8372a37..0878b71 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,7 @@ """Test the models.""" from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo import pytest from aiohttp import ClientSession @@ -140,10 +141,10 @@ async def test_model_se1_15_minute_resolution(aresponses: ResponsesMockServer) - assert energy.highest_price_tomorrow == 0.0059 assert energy.lowest_price_today == 0.00001 assert energy.lowest_price_tomorrow == 0.00005 - assert energy.average_price_today == 0.00113 - assert energy.average_price_tomorrow == 0.00249 + assert energy.average_price_today == 0.00121 + assert energy.average_price_tomorrow == 0.00232 assert energy.current_price == 0.00128 - assert energy.intervals_priced_equal_or_lower == 57 + assert energy.intervals_priced_equal_or_lower == 51 # The price for another interval another_interval = datetime(2025, 10, 4, 18, 0, tzinfo=timezone.utc) assert energy.price_at_time(another_interval) == 0.00242 @@ -166,6 +167,26 @@ async def test_model_se1_15_minute_resolution(aresponses: ResponsesMockServer) - assert isinstance(energy.timestamp_prices, list) +@pytest.mark.freeze_time("2025-10-04 16:00:00+02:00") +async def test_prices_today_uses_region_local_date_for_utc_timestamps() -> None: + """Use local date boundaries for SE1 even when source timestamps are UTC.""" + start_utc = datetime(2025, 10, 3, 22, 0, tzinfo=timezone.utc) + prices = {start_utc + i * timedelta(minutes=15): i / 100000 for i in range(96)} + energy = Electricity( + prices=prices, + resolution=timedelta(minutes=15), + time_zone=ZoneInfo("Europe/Stockholm"), + ) + + prices_today = energy.prices_today() + assert len(prices_today) == 96 + assert energy.current_price == 0.00064 + assert energy.lowest_price_today == 0.0 + assert energy.highest_price_today == 0.00095 + assert energy.average_price_today == 0.00047 + assert energy.prices_tomorrow() == {} + + @pytest.mark.freeze_time("2023-05-06 15:00:00+03:00") async def test_model_no_prices_for_tomorrow(aresponses: ResponsesMockServer) -> None: """Test the model for usage at 15:00:00 UTC+3 with no prices for tomorrow.""" @@ -376,7 +397,7 @@ async def test_model_15_minute_resolution_partial_data_today( async def test_model_15_minute_resolution_partial_data_today_in_interval( aresponses: ResponsesMockServer, ) -> None: - """Test the model for usage at 14:00:10 UTC+3.""" + """Test the model for usage at 00:02:00 UTC+3.""" aresponses.add( "api.spot-hinta.fi", "/TodayAndDayForward", @@ -428,3 +449,74 @@ async def test_unsupported_resolution() -> None: client = SpotHinta(session=session) with pytest.raises(SpotHintaUnsupportedResolutionError): await client.energy_prices(resolution=timedelta(minutes=45)) + + +@pytest.mark.freeze_time("2026-03-29 12:00:00Z") +async def test_model_dst_spring_forward_23h(aresponses: ResponsesMockServer) -> None: + """DST spring forward (23-hour day) on 2026-03-29 in Europe/Stockholm. + + Regression test. + Verifies that a full local day with 92 intervals (23 hours * 4 per hour) + is correctly recognized as complete despite being only 23 hours long. + This ensures DST transition days are not incorrectly treated as partial. + """ + aresponses.add( + "api.spot-hinta.fi", + "/TodayAndDayForward", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("energy-dst-spring-forward-23h.json"), + ), + ) + async with ClientSession() as session: + client = SpotHinta(session=session) + energy: Electricity = await client.energy_prices( + region=Region.SE1, + resolution=timedelta(minutes=15), + ) + assert energy is not None + assert isinstance(energy, Electricity) + # DST days should have aggregates computed (not treated as partial) + # Even though prices_today() filters by local date, the core regression is that + # DST days produce valid aggregates indicating they're not partial + assert energy.highest_price_today is not None + assert energy.lowest_price_today is not None + assert energy.average_price_today is not None + + +@pytest.mark.freeze_time("2026-10-25 11:00:00Z") +async def test_model_dst_fall_back_25h(aresponses: ResponsesMockServer) -> None: + """DST fall back (25-hour day) on 2026-10-25 in Europe/Stockholm. + + Regression test. + Verifies that a full local day with 100 intervals (25 hours * 4 per hour) + is correctly recognized as complete despite being 25 hours long due to + the repeated hour when clocks move back. This ensures DST transition days + are not incorrectly treated as partial. + """ + aresponses.add( + "api.spot-hinta.fi", + "/TodayAndDayForward", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("energy-dst-fall-back-25h.json"), + ), + ) + async with ClientSession() as session: + client = SpotHinta(session=session) + energy: Electricity = await client.energy_prices( + region=Region.SE1, + resolution=timedelta(minutes=15), + ) + assert energy is not None + assert isinstance(energy, Electricity) + # DST days should have aggregates computed (not treated as partial) + # Even though prices_today() filters by local date, the core regression is that + # DST days produce valid aggregates indicating they're not partial + assert energy.highest_price_today is not None + assert energy.lowest_price_today is not None + assert energy.average_price_today is not None From a4b2448d9314cd64fadd94cbafe667def0a39969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Wed, 1 Apr 2026 21:11:22 +0300 Subject: [PATCH 3/4] Return correct value for 60 minute spot prices at all times --- spothinta_api/models.py | 11 +++- tests/fixtures/energy-dst-fall-back-25h.json | 2 +- .../energy-dst-spring-forward-23h.json | 2 +- tests/test_models.py | 62 +++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/spothinta_api/models.py b/spothinta_api/models.py index f9e5b0d..fa74738 100644 --- a/spothinta_api/models.py +++ b/spothinta_api/models.py @@ -11,13 +11,18 @@ from zoneinfo import ZoneInfo -def _timed_value(moment: datetime, prices: dict[datetime, float]) -> float | None: +def _timed_value( + moment: datetime, + prices: dict[datetime, float], + resolution: timedelta, +) -> float | None: """Return a function that returns a value at a specific time. Args: ---- moment: The time to get the value for. prices: A dictionary with market prices. + resolution: The time resolution of price intervals (default: 15 minutes). Returns: ------- @@ -26,7 +31,7 @@ def _timed_value(moment: datetime, prices: dict[datetime, float]) -> float | Non """ value = None for timestamp, price in prices.items(): - future_dt = timestamp + timedelta(minutes=15) + future_dt = timestamp + resolution if timestamp <= moment < future_dt: value = round(price, 5) return value @@ -373,7 +378,7 @@ def price_at_time(self, moment: datetime) -> float | None: The price at the specified time. """ - value = _timed_value(moment, self.prices) + value = _timed_value(moment, self.prices, self.resolution) if value is not None or value == 0: return value return None diff --git a/tests/fixtures/energy-dst-fall-back-25h.json b/tests/fixtures/energy-dst-fall-back-25h.json index 9aea7d9..c6bca55 100644 --- a/tests/fixtures/energy-dst-fall-back-25h.json +++ b/tests/fixtures/energy-dst-fall-back-25h.json @@ -599,4 +599,4 @@ "PriceNoTax": 0.00212, "PriceWithTax": 0.00265 } -] \ No newline at end of file +] diff --git a/tests/fixtures/energy-dst-spring-forward-23h.json b/tests/fixtures/energy-dst-spring-forward-23h.json index d36a91e..2507c3b 100644 --- a/tests/fixtures/energy-dst-spring-forward-23h.json +++ b/tests/fixtures/energy-dst-spring-forward-23h.json @@ -551,4 +551,4 @@ "PriceNoTax": 0.00052, "PriceWithTax": 0.00065 } -] \ No newline at end of file +] diff --git a/tests/test_models.py b/tests/test_models.py index 0878b71..ae2b5cb 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -486,6 +486,68 @@ async def test_model_dst_spring_forward_23h(aresponses: ResponsesMockServer) -> assert energy.average_price_today is not None +@pytest.mark.freeze_time("2023-05-06 15:30:00+03:00") +async def test_model_60_minute_resolution_non_interval_start( + aresponses: ResponsesMockServer, +) -> None: + """Test 60-minute resolution when queried at mid-interval time. + + Regression test: price_at_time() and current_price should work correctly + even when the queried moment is not at an interval start (e.g., 15:30 + within the 15:00-16:00 hour). The interval logic should check if moment + falls within [start, start+resolution), using the actual resolution, + not hardcoded 15 minutes. + """ + aresponses.add( + "api.spot-hinta.fi", + "/TodayAndDayForward", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("energy.json"), + ), + ) + async with ClientSession() as session: + client = SpotHinta(session=session) + energy: Electricity = await client.energy_prices() + assert energy is not None + assert isinstance(energy, Electricity) + # At 15:30, we're in the 15:00-16:00 interval + # Should return the price for 15:00 (0.062) + assert energy.current_price == 0.062 + # Also test price_at_time at a non-interval-start time + moment_15_30 = datetime(2023, 5, 6, 12, 30, tzinfo=timezone.utc) + assert energy.price_at_time(moment_15_30) == 0.062 + + +@pytest.mark.freeze_time("2023-05-06 15:58:00+03:00") +async def test_model_60_minute_resolution_late_in_interval( + aresponses: ResponsesMockServer, +) -> None: + """Test 60-minute resolution at late moment in interval. + + Regression test: verifies current_price works correctly at 15:58 + (late in the 15:00-16:00 hour), not just at mid-interval times. + """ + aresponses.add( + "api.spot-hinta.fi", + "/TodayAndDayForward", + "GET", + aresponses.Response( + status=200, + headers={"Content-Type": "application/json"}, + text=load_fixtures("energy.json"), + ), + ) + async with ClientSession() as session: + client = SpotHinta(session=session) + energy: Electricity = await client.energy_prices() + assert energy is not None + # At 15:58, still in the 15:00-16:00 interval + assert energy.current_price == 0.062 + + @pytest.mark.freeze_time("2026-10-25 11:00:00Z") async def test_model_dst_fall_back_25h(aresponses: ResponsesMockServer) -> None: """DST fall back (25-hour day) on 2026-10-25 in Europe/Stockholm. From c04c52dd159a02d4676eaccb157dd4752ea92329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Wed, 1 Apr 2026 21:19:32 +0300 Subject: [PATCH 4/4] Feedback fixes --- spothinta_api/models.py | 12 ++++++------ tests/test_models.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spothinta_api/models.py b/spothinta_api/models.py index fa74738..6bdc952 100644 --- a/spothinta_api/models.py +++ b/spothinta_api/models.py @@ -16,13 +16,13 @@ def _timed_value( prices: dict[datetime, float], resolution: timedelta, ) -> float | None: - """Return a function that returns a value at a specific time. + """Return a value at a specific time. Args: ---- moment: The time to get the value for. prices: A dictionary with market prices. - resolution: The time resolution of price intervals (default: 15 minutes). + resolution: The time resolution of price intervals. Returns: ------- @@ -255,11 +255,11 @@ def intervals_priced_equal_or_lower(self) -> int: return sum(price <= current for price in self.prices_today().values()) def prices_today(self) -> dict[datetime, float]: - """Return the prices for today. + """Return the prices for today, if available. Returns ------- - The prices for today. + The prices for today, or an empty dictionary if no prices are available. """ today = self.now_in_timezone().date() @@ -294,11 +294,11 @@ def prices_today(self) -> dict[datetime, float]: return {} def prices_tomorrow(self) -> dict[datetime, float]: - """Return the prices for tomorrow. + """Return the prices for tomorrow, if available. Returns ------- - The prices for tomorrow. + The prices for tomorrow, or an empty dictionary if no prices are available. """ tomorrow = (self.now_in_timezone() + timedelta(days=1)).date() diff --git a/tests/test_models.py b/tests/test_models.py index ae2b5cb..133cc88 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -458,7 +458,7 @@ async def test_model_dst_spring_forward_23h(aresponses: ResponsesMockServer) -> Regression test. Verifies that a full local day with 92 intervals (23 hours * 4 per hour) is correctly recognized as complete despite being only 23 hours long. - This ensures DST transition days are not incorrectly treated as partial. + This ensures DST transition days are not incorrectly treated as partial. """ aresponses.add( "api.spot-hinta.fi",