e-data 2.0.1.dev127__tar.gz → 2.0.1.dev129__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {e_data-2.0.1.dev127/e_data.egg-info → e_data-2.0.1.dev129}/PKG-INFO +1 -1
  2. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129/e_data.egg-info}/PKG-INFO +1 -1
  3. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/e_data.egg-info/SOURCES.txt +2 -1
  4. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/core/utils.py +38 -2
  5. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/database/controller.py +23 -3
  6. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/services/bill_service.py +44 -38
  7. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/services/data_service.py +18 -6
  8. e_data-2.0.1.dev129/edata/tests/test_utils.py +60 -0
  9. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/pyproject.toml +1 -1
  10. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/LICENSE +0 -0
  11. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/MANIFEST.in +0 -0
  12. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/README.md +0 -0
  13. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/e_data.egg-info/dependency_links.txt +0 -0
  14. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/e_data.egg-info/requires.txt +0 -0
  15. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/e_data.egg-info/top_level.txt +0 -0
  16. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/__init__.py +0 -0
  17. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/cli.py +0 -0
  18. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/core/__init__.py +0 -0
  19. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/core/const.py +0 -0
  20. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/database/__init__.py +0 -0
  21. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/database/models.py +0 -0
  22. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/database/queries.py +0 -0
  23. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/database/utils.py +0 -0
  24. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/models/__init__.py +0 -0
  25. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/models/bill.py +0 -0
  26. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/models/data.py +0 -0
  27. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/models/supply.py +0 -0
  28. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/providers/__init__.py +0 -0
  29. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/providers/datadis.py +0 -0
  30. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/providers/redata.py +0 -0
  31. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/tests/__init__.py +0 -0
  32. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/tests/test_datadis_connector.py +0 -0
  33. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/tests/test_redata_connector.py +0 -0
  34. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/edata/tests/test_services.py +0 -0
  35. {e_data-2.0.1.dev127 → e_data-2.0.1.dev129}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e-data
3
- Version: 2.0.1.dev127
3
+ Version: 2.0.1.dev129
4
4
  Summary: Python library for managing spanish energy data from various web providers
5
5
  Author-email: VMG <vmayorg@outlook.es>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e-data
3
- Version: 2.0.1.dev127
3
+ Version: 2.0.1.dev129
4
4
  Summary: Python library for managing spanish energy data from various web providers
5
5
  Author-email: VMG <vmayorg@outlook.es>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -29,4 +29,5 @@ edata/services/data_service.py
29
29
  edata/tests/__init__.py
30
30
  edata/tests/test_datadis_connector.py
31
31
  edata/tests/test_redata_connector.py
32
- edata/tests/test_services.py
32
+ edata/tests/test_services.py
33
+ edata/tests/test_utils.py
@@ -1,9 +1,12 @@
1
1
  """Collection of utilities."""
2
2
 
3
3
  import logging
4
- from datetime import datetime
4
+ import typing
5
+ from datetime import datetime, timedelta
6
+ from functools import lru_cache
5
7
  from pathlib import Path
6
8
 
9
+ from dateutil.relativedelta import relativedelta
7
10
  import holidays
8
11
 
9
12
  from edata.models.supply import Contract
@@ -19,10 +22,22 @@ TARIFF_BY_WEEKDAY = [[], [], [5, 6]]
19
22
  _LOGGER = logging.getLogger(__name__)
20
23
 
21
24
 
25
+ @lru_cache(maxsize=None)
26
+ def _es_holidays(year: int) -> holidays.HolidayBase:
27
+ """Return (and cache) Spanish holidays for a single year.
28
+
29
+ Building the holidays calendar is expensive; get_tariff is called once per
30
+ hourly record, so without this cache a full-history rebuild spends minutes
31
+ reconstructing the same handful of yearly calendars.
32
+ """
33
+
34
+ return holidays.country_holidays("ES", years=year)
35
+
36
+
22
37
  def get_tariff(dt: datetime) -> int:
23
38
  """Return the tariff for the selected datetime."""
24
39
 
25
- hdays = holidays.country_holidays("ES")
40
+ hdays = _es_holidays(dt.year)
26
41
  hour = dt.hour
27
42
  weekday = dt.weekday()
28
43
 
@@ -64,6 +79,27 @@ def get_day(dt: datetime) -> datetime:
64
79
  return dt.replace(hour=0, minute=0, second=0, microsecond=0)
65
80
 
66
81
 
82
+ def iter_month_windows(
83
+ start: datetime, end: datetime
84
+ ) -> typing.Iterator[tuple[datetime, datetime]]:
85
+ """Yield contiguous ``(window_start, window_end)`` pairs, one calendar month each.
86
+
87
+ Windows are non-overlapping and cover ``[start, end]`` inclusively; the first
88
+ starts at ``start`` and the last ends at ``end``. Iterating a month at a time
89
+ keeps the working set bounded to a single month regardless of history length.
90
+ """
91
+
92
+ if end < start:
93
+ return
94
+
95
+ win_start = start
96
+ while win_start <= end:
97
+ next_month = get_month(win_start) + relativedelta(months=1)
98
+ win_end = min(next_month - timedelta(microseconds=1), end)
99
+ yield win_start, win_end
100
+ win_start = next_month
101
+
102
+
67
103
  def redacted_cups(cups: str) -> str:
68
104
  """Return an anonymized version of the cups identifier."""
69
105
 
@@ -3,7 +3,7 @@ import os
3
3
  import typing
4
4
  from datetime import datetime
5
5
 
6
- from sqlalchemy import Select, insert
6
+ from sqlalchemy import Select, event, insert
7
7
  from sqlalchemy.exc import IntegrityError
8
8
  from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9
9
  from sqlmodel import SQLModel
@@ -28,6 +28,25 @@ _LOGGER = logging.getLogger(__name__)
28
28
  T = typing.TypeVar("T", bound=SQLModel)
29
29
 
30
30
 
31
+ def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
32
+ """Tune SQLite for this write-heavy, single-writer workload.
33
+
34
+ WAL lets readers proceed while a write is in flight and avoids an fsync per
35
+ statement; synchronous=NORMAL is durable under WAL (only a crash mid-checkpoint
36
+ could lose the last transaction, and the data is re-fetchable from Datadis);
37
+ busy_timeout avoids spurious "database is locked" errors under concurrency.
38
+ journal_mode is persisted in the file; the others are per-connection.
39
+ """
40
+
41
+ cursor = dbapi_connection.cursor()
42
+ try:
43
+ cursor.execute("PRAGMA journal_mode=WAL")
44
+ cursor.execute("PRAGMA synchronous=NORMAL")
45
+ cursor.execute("PRAGMA busy_timeout=5000")
46
+ finally:
47
+ cursor.close()
48
+
49
+
31
50
  class EdataDB:
32
51
 
33
52
  _instance = None
@@ -39,10 +58,11 @@ class EdataDB:
39
58
  if cls._instance is None:
40
59
  cls._instance = super().__new__(cls)
41
60
  cls._db_url = db_url
42
- cls._engine = create_async_engine(db_url, future=True)
43
- # Ensure parent directory exists
61
+ # Ensure parent directory exists before the first connection is opened.
44
62
  dir_path = os.path.dirname(os.path.abspath(sqlite_path))
45
63
  os.makedirs(dir_path, exist_ok=True)
64
+ cls._engine = create_async_engine(db_url, future=True)
65
+ event.listen(cls._engine.sync_engine, "connect", _set_sqlite_pragmas)
46
66
  cls._instance._tables_initialized = False
47
67
  elif db_url != cls._db_url:
48
68
  raise ValueError("EdataDB already initialized with a different db_url")
@@ -16,6 +16,7 @@ from edata.core.utils import (
16
16
  get_day,
17
17
  get_month,
18
18
  get_tariff,
19
+ iter_month_windows,
19
20
  redacted_cups,
20
21
  )
21
22
  from edata.database.controller import EdataDB
@@ -107,31 +108,39 @@ class BillService:
107
108
  # fetch contracts
108
109
  contracts = await self._get_contracts()
109
110
 
110
- # fetch and filter energy items
111
- energy = await self._get_energy()
112
-
113
- _LOGGER.debug("%s compiling missing hourly bills", self._scups)
114
111
  if is_pvpc:
115
- pvpc = await self._get_pvpc()
116
112
  billing_rules = PVPCBillingRules(**billing_rules.model_dump())
117
- bills = await asyncio.to_thread(
118
- self.simulate_pvpc, contracts, energy, pvpc, billing_rules
119
- )
120
113
  confighash = f"pvpc-{hash(billing_rules.model_dump_json())}"
121
114
  else:
122
- bills = await asyncio.to_thread(
123
- self.simulate_custom, contracts, energy, billing_rules
124
- )
125
115
  confighash = f"custom-{hash(billing_rules.model_dump_json())}"
126
116
 
127
- _LOGGER.debug("%s pushing hourly bills", self._scups)
128
- await self.db.add_bill_list(
129
- cups=self._cups,
130
- type_="hour",
131
- confhash=confighash,
132
- complete=True,
133
- bill=bills,
134
- )
117
+ # compile and persist hourly bills one month at a time so the energy
118
+ # (and simulated bills) held in memory stay bounded to a single month
119
+ for win_start, win_end in iter_month_windows(start, end):
120
+ energy = await self._get_energy(win_start, win_end)
121
+ if not energy:
122
+ continue
123
+
124
+ _LOGGER.debug(
125
+ "%s compiling hourly bills for %s..%s", self._scups, win_start, win_end
126
+ )
127
+ if is_pvpc:
128
+ pvpc = await self._get_pvpc(win_start, win_end)
129
+ bills = await asyncio.to_thread(
130
+ self.simulate_pvpc, contracts, energy, pvpc, billing_rules
131
+ )
132
+ else:
133
+ bills = await asyncio.to_thread(
134
+ self.simulate_custom, contracts, energy, billing_rules
135
+ )
136
+
137
+ await self.db.add_bill_list(
138
+ cups=self._cups,
139
+ type_="hour",
140
+ confhash=confighash,
141
+ complete=True,
142
+ bill=bills,
143
+ )
135
144
 
136
145
  _LOGGER.debug("%s updating daily and monthly bills", self._scups)
137
146
  await self.update_statistics(start, end)
@@ -171,10 +180,11 @@ class BillService:
171
180
  )
172
181
 
173
182
  async def update_statistics(self, start: datetime, end: datetime):
174
- """Update the statistics during a period."""
183
+ """Update the statistics during a period, one month at a time."""
175
184
 
176
- await self._update_daily_statistics(start, end)
177
- await self._update_monthly_statistics(start, end)
185
+ for win_start, win_end in iter_month_windows(start, end):
186
+ await self._update_daily_statistics(win_start, win_end)
187
+ await self._update_monthly_statistics(win_start, win_end)
178
188
 
179
189
  async def _update_daily_statistics(self, start: datetime, end: datetime) -> None:
180
190
  """Update daily statistics within a date range."""
@@ -286,7 +296,7 @@ class BillService:
286
296
  missing = await self._find_missing_stats()
287
297
  for day in missing:
288
298
  _LOGGER.debug("%s updating daily statistics for date %s", self._scups, day)
289
- end = day + relativedelta.relativedelta(day=1) - timedelta(minutes=1)
299
+ end = day + relativedelta.relativedelta(days=1) - timedelta(minutes=1)
290
300
  await self._update_daily_statistics(day, end)
291
301
 
292
302
  missing_months = list(set([get_month(x) for x in missing]))
@@ -306,21 +316,17 @@ class BillService:
306
316
  ) -> list[Bill]:
307
317
  """Compile bills assuming PVPC billing."""
308
318
 
309
- energy_dt = [x.datetime for x in energy]
310
- pvpc_dt = [x.datetime for x in pvpc]
311
-
312
- # reduce computation range to valid periods
313
- pvpc_valid_dt = []
314
- for contract in contracts:
315
- for dt in pvpc_dt:
316
- if dt in pvpc_valid_dt:
317
- continue
318
- if contract.date_start <= dt <= contract.date_end:
319
- pvpc_valid_dt.append(dt)
320
- pvpc_valid_dt = [x for x in pvpc_valid_dt if x in energy_dt]
321
-
322
- e = {x.datetime: x for x in energy if x.datetime in pvpc_valid_dt}
323
- p = {x.datetime: x for x in pvpc if x.datetime in pvpc_valid_dt}
319
+ # reduce computation to timestamps present in both series and covered
320
+ # by a contract (set membership instead of nested list scans)
321
+ common_dt = {x.datetime for x in energy} & {x.datetime for x in pvpc}
322
+ valid_dt = {
323
+ dt
324
+ for dt in common_dt
325
+ if any(c.date_start <= dt <= c.date_end for c in contracts)
326
+ }
327
+
328
+ e = {x.datetime: x for x in energy if x.datetime in valid_dt}
329
+ p = {x.datetime: x for x in pvpc if x.datetime in valid_dt}
324
330
  b: dict[datetime, Bill] = {}
325
331
 
326
332
  for dt in e.keys():
@@ -11,7 +11,14 @@ from tempfile import gettempdir
11
11
 
12
12
  from dateutil import relativedelta
13
13
 
14
- from edata.core.utils import get_db_path, get_day, get_month, get_tariff, redacted_cups
14
+ from edata.core.utils import (
15
+ get_db_path,
16
+ get_day,
17
+ get_month,
18
+ get_tariff,
19
+ iter_month_windows,
20
+ redacted_cups,
21
+ )
15
22
  from edata.database.controller import EdataDB
16
23
  from edata.models import Contract, Energy, Power, Statistics, Supply
17
24
  from edata.models.bill import EnergyPrice
@@ -120,7 +127,7 @@ class DataService:
120
127
  missing = await self._find_missing_stats()
121
128
  for day in missing:
122
129
  _LOGGER.info("%s updating daily statistics for date %s", self._scups, day)
123
- end = day + relativedelta.relativedelta(day=1) - timedelta(hours=1)
130
+ end = day + relativedelta.relativedelta(days=1) - timedelta(hours=1)
124
131
  await self._update_daily_statistics(day, end)
125
132
 
126
133
  missing_months = await self._find_missing_stats("month")
@@ -128,7 +135,7 @@ class DataService:
128
135
  _LOGGER.info(
129
136
  "%s updating monthly statistics for date %s", self._scups, month
130
137
  )
131
- end = month + relativedelta.relativedelta(month=1) - timedelta(hours=1)
138
+ end = month + relativedelta.relativedelta(months=1) - timedelta(hours=1)
132
139
  await self._update_monthly_statistics(month, end)
133
140
 
134
141
  async def update(
@@ -332,10 +339,15 @@ class DataService:
332
339
  return False
333
340
 
334
341
  async def update_statistics(self, start: datetime, end: datetime) -> None:
335
- """Update the statistics during a period."""
342
+ """Update the statistics during a period, one month at a time.
336
343
 
337
- await self._update_daily_statistics(start, end)
338
- await self._update_monthly_statistics(start, end)
344
+ Iterating monthly windows keeps the energy loaded for compilation bounded
345
+ to a single month instead of the whole history.
346
+ """
347
+
348
+ for win_start, win_end in iter_month_windows(start, end):
349
+ await self._update_daily_statistics(win_start, win_end)
350
+ await self._update_monthly_statistics(win_start, win_end)
339
351
 
340
352
  async def _update_daily_statistics(self, start: datetime, end: datetime) -> None:
341
353
  """Update daily statistics within a date range."""
@@ -0,0 +1,60 @@
1
+ """Tests for edata.core.utils helpers."""
2
+
3
+ from datetime import datetime, timedelta
4
+
5
+ import pytest
6
+
7
+ from edata.core.utils import get_month, iter_month_windows
8
+
9
+
10
+ def _assert_windows_valid(
11
+ windows: list[tuple[datetime, datetime]],
12
+ start: datetime,
13
+ end: datetime,
14
+ ) -> None:
15
+ """Assert windows tile [start, end] contiguously, one calendar month each."""
16
+ assert windows[0][0] == start
17
+ assert windows[-1][1] == end
18
+ for win_start, win_end in windows:
19
+ assert win_start <= win_end
20
+ # a window never straddles a calendar-month boundary
21
+ assert get_month(win_start) == get_month(win_end)
22
+ for (_, prev_end), (next_start, _) in zip(windows, windows[1:], strict=False):
23
+ # contiguous and non-overlapping
24
+ assert next_start == prev_end + timedelta(microseconds=1)
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ ("start", "end", "expected_len"),
29
+ [
30
+ # sub-month range collapses to a single window
31
+ (datetime(2023, 1, 5), datetime(2023, 1, 20), 1),
32
+ # spans three calendar months
33
+ (datetime(2023, 1, 15), datetime(2023, 3, 10), 3),
34
+ # exact month boundaries
35
+ (datetime(2023, 1, 1), datetime(2023, 2, 28, 23, 59, 59), 2),
36
+ # crosses a year boundary
37
+ (datetime(2022, 12, 20), datetime(2023, 2, 1), 3),
38
+ # spring-forward DST month is handled like any other (naive datetimes)
39
+ (datetime(2023, 3, 1), datetime(2023, 4, 15), 2),
40
+ ],
41
+ ids=["sub-month", "three-months", "exact-boundaries", "year-boundary", "dst-month"],
42
+ )
43
+ def test_iter_month_windows_tiles_range(
44
+ start: datetime, end: datetime, expected_len: int
45
+ ) -> None:
46
+ """Windows cover the range contiguously, one calendar month at a time."""
47
+ windows = list(iter_month_windows(start, end))
48
+ assert len(windows) == expected_len
49
+ _assert_windows_valid(windows, start, end)
50
+
51
+
52
+ def test_iter_month_windows_single_instant() -> None:
53
+ """A zero-length range yields exactly one window covering that instant."""
54
+ moment = datetime(2023, 6, 15, 10, 30)
55
+ assert list(iter_month_windows(moment, moment)) == [(moment, moment)]
56
+
57
+
58
+ def test_iter_month_windows_empty_when_end_before_start() -> None:
59
+ """An inverted range yields no windows."""
60
+ assert list(iter_month_windows(datetime(2023, 2, 1), datetime(2023, 1, 1))) == []
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "e-data"
7
- version = "2.0.1.dev127"
7
+ version = "2.0.1.dev129"
8
8
  description = "Python library for managing spanish energy data from various web providers"
9
9
  readme = "README.md"
10
10
  authors = [
File without changes
File without changes
File without changes
File without changes