e-data 2.0.1.dev129__tar.gz → 2.0.1.dev131__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 (38) hide show
  1. {e_data-2.0.1.dev129/e_data.egg-info → e_data-2.0.1.dev131}/PKG-INFO +1 -1
  2. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131/e_data.egg-info}/PKG-INFO +1 -1
  3. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/e_data.egg-info/SOURCES.txt +3 -0
  4. e_data-2.0.1.dev131/edata/core/completion.py +114 -0
  5. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/database/controller.py +20 -0
  6. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/database/queries.py +26 -0
  7. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/services/bill_service.py +64 -77
  8. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/services/data_service.py +63 -81
  9. e_data-2.0.1.dev131/edata/tests/test_completion.py +104 -0
  10. e_data-2.0.1.dev131/edata/tests/test_incremental.py +108 -0
  11. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/tests/test_services.py +1 -2
  12. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/pyproject.toml +1 -1
  13. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/LICENSE +0 -0
  14. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/MANIFEST.in +0 -0
  15. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/README.md +0 -0
  16. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/e_data.egg-info/dependency_links.txt +0 -0
  17. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/e_data.egg-info/requires.txt +0 -0
  18. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/e_data.egg-info/top_level.txt +0 -0
  19. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/__init__.py +0 -0
  20. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/cli.py +0 -0
  21. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/core/__init__.py +0 -0
  22. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/core/const.py +0 -0
  23. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/core/utils.py +0 -0
  24. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/database/__init__.py +0 -0
  25. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/database/models.py +0 -0
  26. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/database/utils.py +0 -0
  27. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/models/__init__.py +0 -0
  28. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/models/bill.py +0 -0
  29. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/models/data.py +0 -0
  30. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/models/supply.py +0 -0
  31. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/providers/__init__.py +0 -0
  32. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/providers/datadis.py +0 -0
  33. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/providers/redata.py +0 -0
  34. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/tests/__init__.py +0 -0
  35. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/tests/test_datadis_connector.py +0 -0
  36. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/tests/test_redata_connector.py +0 -0
  37. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/edata/tests/test_utils.py +0 -0
  38. {e_data-2.0.1.dev129 → e_data-2.0.1.dev131}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e-data
3
- Version: 2.0.1.dev129
3
+ Version: 2.0.1.dev131
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.dev129
3
+ Version: 2.0.1.dev131
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
@@ -10,6 +10,7 @@ e_data.egg-info/top_level.txt
10
10
  edata/__init__.py
11
11
  edata/cli.py
12
12
  edata/core/__init__.py
13
+ edata/core/completion.py
13
14
  edata/core/const.py
14
15
  edata/core/utils.py
15
16
  edata/database/__init__.py
@@ -27,7 +28,9 @@ edata/providers/redata.py
27
28
  edata/services/bill_service.py
28
29
  edata/services/data_service.py
29
30
  edata/tests/__init__.py
31
+ edata/tests/test_completion.py
30
32
  edata/tests/test_datadis_connector.py
33
+ edata/tests/test_incremental.py
31
34
  edata/tests/test_redata_connector.py
32
35
  edata/tests/test_services.py
33
36
  edata/tests/test_utils.py
@@ -0,0 +1,114 @@
1
+ """Incremental completion tracking for statistics/bill compilation.
2
+
3
+ The durable source of truth is the per-bucket ``complete`` flag stored on the
4
+ statistics/bill rows. A ``PendingLedger`` is the cheap, in-memory work set of the
5
+ day/month buckets a single sync still needs to (re)compile -- seeded from the
6
+ incomplete rows plus the small recent range past the last complete bucket -- so a
7
+ sync never has to scan the whole energy/bill history.
8
+
9
+ A bucket is *final* once it reaches its nominal hour count, or once its period has
10
+ fully elapsed and is older than ``SETTLE_DAYS`` (so DST days, partial months, and
11
+ hours Datadis never delivered leave the ledger instead of being recompiled
12
+ forever).
13
+ """
14
+
15
+ import calendar
16
+ import typing
17
+ from datetime import datetime, timedelta
18
+
19
+ from dateutil.relativedelta import relativedelta
20
+
21
+ from edata.core.utils import get_day, get_month
22
+
23
+ # Past periods older than this settle window are treated as final regardless of
24
+ # their hour count. Matches the ~monthly window Datadis uses to revise data.
25
+ SETTLE_DAYS = 30
26
+
27
+ HOURS_PER_DAY = 24
28
+
29
+
30
+ def day_buckets(start: datetime, end: datetime) -> typing.Iterator[datetime]:
31
+ """Yield each day-start in ``[start, end]``."""
32
+
33
+ day = get_day(start)
34
+ while day <= end:
35
+ yield day
36
+ day += timedelta(days=1)
37
+
38
+
39
+ def _elapsed_beyond_settle(period_end: datetime, now: datetime, settle_days: int) -> bool:
40
+ """Return whether a period ended more than ``settle_days`` before ``now``."""
41
+
42
+ return period_end < now - timedelta(days=settle_days)
43
+
44
+
45
+ def is_day_final(
46
+ day: datetime, delta_h: float, now: datetime, settle_days: int = SETTLE_DAYS
47
+ ) -> bool:
48
+ """Return whether a daily bucket needs no further recompilation."""
49
+
50
+ if delta_h >= HOURS_PER_DAY:
51
+ return True
52
+ return _elapsed_beyond_settle(day + timedelta(days=1), now, settle_days)
53
+
54
+
55
+ def is_month_final(
56
+ month: datetime, delta_h: float, now: datetime, settle_days: int = SETTLE_DAYS
57
+ ) -> bool:
58
+ """Return whether a monthly bucket needs no further recompilation."""
59
+
60
+ nominal = calendar.monthrange(month.year, month.month)[1] * HOURS_PER_DAY
61
+ if delta_h >= nominal:
62
+ return True
63
+ return _elapsed_beyond_settle(month + relativedelta(months=1), now, settle_days)
64
+
65
+
66
+ class PendingLedger:
67
+ """In-memory set of day/month buckets still needing (re)compilation."""
68
+
69
+ def __init__(self) -> None:
70
+ self.days: set[datetime] = set()
71
+ self.months: set[datetime] = set()
72
+
73
+ def mark_range(self, start: datetime, end: datetime) -> None:
74
+ """Mark every day (and its month) spanning ``[start, end]`` as pending."""
75
+
76
+ for day in day_buckets(start, end):
77
+ self.days.add(day)
78
+ self.months.add(get_month(day))
79
+
80
+ def add_days(self, days: typing.Iterable[datetime]) -> None:
81
+ """Mark specific days (and their months) as pending."""
82
+
83
+ for value in days:
84
+ day = get_day(value)
85
+ self.days.add(day)
86
+ self.months.add(get_month(day))
87
+
88
+ def add_months(self, months: typing.Iterable[datetime]) -> None:
89
+ """Mark specific months as pending."""
90
+
91
+ for value in months:
92
+ self.months.add(get_month(value))
93
+
94
+ def days_in(self, month: datetime) -> list[datetime]:
95
+ """Return the sorted pending days that belong to ``month``."""
96
+
97
+ return sorted(day for day in self.days if get_month(day) == month)
98
+
99
+ def sorted_months(self) -> list[datetime]:
100
+ """Return the pending months in chronological order."""
101
+
102
+ return sorted(self.months)
103
+
104
+ def resolve_day(self, day: datetime, *, final: bool) -> None:
105
+ """Drop a day from the ledger once it is final."""
106
+
107
+ if final:
108
+ self.days.discard(day)
109
+
110
+ def resolve_month(self, month: datetime, *, final: bool) -> None:
111
+ """Drop a month from the ledger once it is final."""
112
+
113
+ if final:
114
+ self.months.discard(month)
@@ -228,6 +228,26 @@ class EdataDB:
228
228
  result = await session.exec(q.get_last_bill(cups))
229
229
  return result.first()
230
230
 
231
+ async def get_last_complete_statistic(
232
+ self, cups: str, type_: typing.Literal["day", "month"]
233
+ ) -> StatisticsModel | None:
234
+ """Get the most recent complete statistics record by cups and type."""
235
+
236
+ await self._ensure_tables()
237
+ async with AsyncSession(self.engine) as session:
238
+ result = await session.exec(q.get_last_complete_statistic(cups, type_))
239
+ return result.first()
240
+
241
+ async def get_last_complete_bill(
242
+ self, cups: str, type_: typing.Literal["hour", "day", "month"]
243
+ ) -> BillModel | None:
244
+ """Get the most recent complete bill record by cups and type."""
245
+
246
+ await self._ensure_tables()
247
+ async with AsyncSession(self.engine) as session:
248
+ result = await session.exec(q.get_last_complete_bill(cups, type_))
249
+ return result.first()
250
+
231
251
  async def add_contract(self, cups: str, contract: Contract) -> ContractModel | None:
232
252
  """Add or update a contract record."""
233
253
 
@@ -152,6 +152,19 @@ def list_statistics(
152
152
  return query
153
153
 
154
154
 
155
+ def get_last_complete_statistic(
156
+ cups: str, type_: typing.Literal["day", "month"]
157
+ ) -> SelectOfScalar[StatisticsModel]:
158
+ """Query that selects the most recent complete statistics record."""
159
+
160
+ query = select(StatisticsModel).where(StatisticsModel.cups == cups)
161
+ query = query.where(StatisticsModel.type == type_)
162
+ query = query.where(StatisticsModel.complete == True) # noqa: E712
163
+ query = query.order_by(desc(StatisticsModel.datetime))
164
+ query = query.limit(1)
165
+ return query
166
+
167
+
155
168
  def list_bill(
156
169
  cups: str,
157
170
  type_: typing.Literal["hour", "day", "month"],
@@ -185,6 +198,19 @@ def get_last_bill(
185
198
  return query
186
199
 
187
200
 
201
+ def get_last_complete_bill(
202
+ cups: str, type_: typing.Literal["hour", "day", "month"]
203
+ ) -> SelectOfScalar[BillModel]:
204
+ """Query that selects the most recent complete bill record."""
205
+
206
+ query = select(BillModel).where(BillModel.cups == cups)
207
+ query = query.where(BillModel.type == type_)
208
+ query = query.where(BillModel.complete == True) # noqa: E712
209
+ query = query.order_by(desc(BillModel.datetime))
210
+ query = query.limit(1)
211
+ return query
212
+
213
+
188
214
  def delete_bill(cups: str, date_from: datetime | None = None) -> Delete:
189
215
  """Query that deletes bill records, optionally from a datetime onwards."""
190
216
 
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- import calendar
3
2
  import logging
4
3
  import os
5
4
  import typing
@@ -10,6 +9,7 @@ from tempfile import gettempdir
10
9
  from dateutil import relativedelta
11
10
  from jinja2 import Environment
12
11
 
12
+ from edata.core.completion import PendingLedger, is_day_final, is_month_final
13
13
  from edata.core.utils import (
14
14
  get_db_path,
15
15
  get_contract_for_dt,
@@ -143,8 +143,7 @@ class BillService:
143
143
  )
144
144
 
145
145
  _LOGGER.debug("%s updating daily and monthly bills", self._scups)
146
- await self.update_statistics(start, end)
147
- await self.fix_missing_statistics()
146
+ await self.update_statistics_incremental()
148
147
 
149
148
  async def clear_bills(self, since: datetime | None = None) -> None:
150
149
  """Delete stored bills for this cups, optionally only from a datetime onwards."""
@@ -180,76 +179,81 @@ class BillService:
180
179
  )
181
180
 
182
181
  async def update_statistics(self, start: datetime, end: datetime):
183
- """Update the statistics during a period, one month at a time."""
182
+ """Compile the daily/monthly bills for a range plus any incomplete backlog."""
184
183
 
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)
184
+ ledger = PendingLedger()
185
+ ledger.mark_range(start, end)
186
+ await self._seed_pending_backlog(ledger)
187
+ await self._compile_pending(ledger)
188
188
 
189
- async def _update_daily_statistics(self, start: datetime, end: datetime) -> None:
190
- """Update daily statistics within a date range."""
189
+ async def update_statistics_incremental(self) -> None:
190
+ """Compile only the daily/monthly bills that are new or still incomplete.
191
191
 
192
- day_start = get_day(start)
193
- day_end = end
194
- daily = await self.db.list_bill(self._cups, "day", day_start, day_end)
192
+ The pending buckets come from cheap indexed queries (incomplete rows plus
193
+ the recent range past the last complete bucket), so a sync never rolls up
194
+ the whole hourly-bill history.
195
+ """
195
196
 
196
- complete = []
197
- for stat in daily:
198
- if stat.complete:
199
- complete.append(stat.datetime)
200
- continue
197
+ ledger = PendingLedger()
198
+ await self._seed_pending_backlog(ledger)
199
+ await self._seed_pending_recent(ledger)
200
+ await self._compile_pending(ledger)
201
201
 
202
- data = await self.get_bills(day_start, day_end)
203
- stats = await asyncio.to_thread(
204
- self._compile_statistics, data, get_day, skip=complete
205
- )
202
+ async def _seed_pending_backlog(self, ledger: PendingLedger) -> None:
203
+ """Seed the ledger with the buckets whose stored bills are incomplete."""
206
204
 
207
- for stat in stats:
208
- is_complete = stat.delta_h == 24
209
- await self.db.add_bill(self._cups, "day", stat, "mix", is_complete)
210
- if not is_complete:
211
- _LOGGER.info(
212
- "%s daily statistics for %s are incomplete",
213
- self._scups,
214
- stat.datetime.date(),
215
- )
205
+ day_incomplete = await self.db.list_bill(self._cups, "day", complete=False)
206
+ month_incomplete = await self.db.list_bill(self._cups, "month", complete=False)
207
+ ledger.add_days(x.datetime for x in day_incomplete)
208
+ ledger.add_months(x.datetime for x in month_incomplete)
216
209
 
217
- async def _update_monthly_statistics(self, start: datetime, end: datetime) -> None:
218
- """Update monthly statistics within a date range."""
210
+ async def _seed_pending_recent(self, ledger: PendingLedger) -> None:
211
+ """Seed the ledger with the range past the last complete daily bill."""
219
212
 
220
- month_start = get_month(start)
221
- month_end = end
222
- monthly = await self.db.list_bill(self._cups, "month", month_start, month_end)
213
+ last_bill = await self.db.get_last_bill(self._cups)
214
+ if not last_bill:
215
+ return
223
216
 
224
- complete = []
225
- for stat in monthly:
226
- if stat.complete:
227
- complete.append(stat.datetime)
228
- continue
217
+ last_complete = await self.db.get_last_complete_bill(self._cups, "day")
218
+ if last_complete:
219
+ start = get_day(last_complete.datetime) + timedelta(days=1)
220
+ else:
221
+ supply = await self.db.get_supply(self._cups)
222
+ start = get_day(
223
+ supply.data.date_start if supply else last_bill.datetime
224
+ )
225
+ ledger.mark_range(start, last_bill.datetime)
229
226
 
230
- data = await self.get_bills(month_start, month_end)
231
- stats = await asyncio.to_thread(
232
- self._compile_statistics, data, get_month, skip=complete
233
- )
227
+ async def _compile_pending(self, ledger: PendingLedger) -> None:
228
+ """Roll up the pending buckets, one month of hourly bills loaded at a time."""
234
229
 
235
- for stat in stats:
236
- target_hours = (
237
- calendar.monthrange(stat.datetime.year, stat.datetime.month)[1] * 24
230
+ now = datetime.now()
231
+ for month in ledger.sorted_months():
232
+ month_end = (
233
+ month + relativedelta.relativedelta(months=1) - timedelta(microseconds=1)
238
234
  )
239
- is_complete = stat.delta_h == target_hours
240
- await self.db.add_bill(self._cups, "month", stat, "mix", is_complete)
241
- if not is_complete:
242
- _LOGGER.info(
243
- "%s monthly statistics for %s are incomplete",
244
- self._scups,
245
- stat.datetime.date(),
246
- )
247
-
248
- async def _find_missing_stats(self) -> list[datetime]:
249
- """Return the list of days that are missing billing data."""
235
+ data = await self.get_bills(month, month_end, "hour")
250
236
 
251
- stats = await self.db.list_bill(self._cups, "day", complete=False)
252
- return [x.datetime for x in stats]
237
+ for day in ledger.days_in(month):
238
+ day_end = day + timedelta(days=1) - timedelta(microseconds=1)
239
+ day_data = [x for x in data if day <= x.datetime <= day_end]
240
+ stat = await asyncio.to_thread(
241
+ self._compile_statistics, day_data, get_day
242
+ )
243
+ delta_h = stat[0].delta_h if stat else 0.0
244
+ final = is_day_final(day, delta_h, now)
245
+ if stat:
246
+ await self.db.add_bill(self._cups, "day", stat[0], "mix", final)
247
+ ledger.resolve_day(day, final=final)
248
+
249
+ month_stat = await asyncio.to_thread(
250
+ self._compile_statistics, data, get_month
251
+ )
252
+ delta_h = month_stat[0].delta_h if month_stat else 0.0
253
+ final = is_month_final(month, delta_h, now)
254
+ if month_stat:
255
+ await self.db.add_bill(self._cups, "month", month_stat[0], "mix", final)
256
+ ledger.resolve_month(month, final=final)
253
257
 
254
258
  def _compile_statistics(
255
259
  self,
@@ -290,23 +294,6 @@ class BillService:
290
294
 
291
295
  return [agg_data[x] for x in agg_data]
292
296
 
293
- async def fix_missing_statistics(self) -> None:
294
- """Recompile statistics to fix missing data."""
295
-
296
- missing = await self._find_missing_stats()
297
- for day in missing:
298
- _LOGGER.debug("%s updating daily statistics for date %s", self._scups, day)
299
- end = day + relativedelta.relativedelta(days=1) - timedelta(minutes=1)
300
- await self._update_daily_statistics(day, end)
301
-
302
- missing_months = list(set([get_month(x) for x in missing]))
303
- for month in missing_months:
304
- _LOGGER.debug(
305
- "%s updating monthly statistics for date %s", self._scups, month
306
- )
307
- end = month + relativedelta.relativedelta(months=1) - timedelta(minutes=1)
308
- await self._update_monthly_statistics(month, end)
309
-
310
297
  def simulate_pvpc(
311
298
  self,
312
299
  contracts: list[Contract],
@@ -1,7 +1,6 @@
1
1
  """Definition of a service for telemetry data handling."""
2
2
 
3
3
  import asyncio
4
- import calendar
5
4
  import logging
6
5
  import os
7
6
  import typing
@@ -11,12 +10,12 @@ from tempfile import gettempdir
11
10
 
12
11
  from dateutil import relativedelta
13
12
 
13
+ from edata.core.completion import PendingLedger, is_day_final, is_month_final
14
14
  from edata.core.utils import (
15
15
  get_db_path,
16
16
  get_day,
17
17
  get_month,
18
18
  get_tariff,
19
- iter_month_windows,
20
19
  redacted_cups,
21
20
  )
22
21
  from edata.database.controller import EdataDB
@@ -121,23 +120,6 @@ class DataService:
121
120
 
122
121
  return await self._get_last_energy_dt()
123
122
 
124
- async def fix_missing_statistics(self) -> None:
125
- """Recompile statistics to fix missing data."""
126
-
127
- missing = await self._find_missing_stats()
128
- for day in missing:
129
- _LOGGER.info("%s updating daily statistics for date %s", self._scups, day)
130
- end = day + relativedelta.relativedelta(days=1) - timedelta(hours=1)
131
- await self._update_daily_statistics(day, end)
132
-
133
- missing_months = await self._find_missing_stats("month")
134
- for month in missing_months:
135
- _LOGGER.info(
136
- "%s updating monthly statistics for date %s", self._scups, month
137
- )
138
- end = month + relativedelta.relativedelta(months=1) - timedelta(hours=1)
139
- await self._update_monthly_statistics(month, end)
140
-
141
123
  async def update(
142
124
  self,
143
125
  start_date: datetime | None = None,
@@ -228,9 +210,6 @@ class DataService:
228
210
  _LOGGER.info("%s trying to update energy for %s", scups, day)
229
211
  await self.update_energy(day, day + timedelta(days=1))
230
212
 
231
- # and recreate statistics
232
- await self.fix_missing_statistics()
233
-
234
213
  # fetch upstream records
235
214
  await self.update_energy(last_energy_dt + timedelta(hours=1), end_date)
236
215
  else:
@@ -243,8 +222,8 @@ class DataService:
243
222
  # fetch pvpc data
244
223
  await self.update_pvpc(start_date, end_date)
245
224
 
246
- # update new statistics
247
- await self.update_statistics(start_date, end_date)
225
+ # (re)compile only the statistics buckets that are new or still incomplete
226
+ await self.update_statistics_incremental()
248
227
 
249
228
  return True
250
229
 
@@ -339,76 +318,79 @@ class DataService:
339
318
  return False
340
319
 
341
320
  async def update_statistics(self, start: datetime, end: datetime) -> None:
342
- """Update the statistics during a period, one month at a time.
321
+ """Compile the statistics for a range plus any incomplete backlog."""
343
322
 
344
- Iterating monthly windows keeps the energy loaded for compilation bounded
345
- to a single month instead of the whole history.
346
- """
323
+ ledger = PendingLedger()
324
+ ledger.mark_range(start, end)
325
+ await self._seed_pending_backlog(ledger)
326
+ await self._compile_pending(ledger)
347
327
 
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)
328
+ async def update_statistics_incremental(self) -> None:
329
+ """Compile only the statistics buckets that are new or still incomplete.
351
330
 
352
- async def _update_daily_statistics(self, start: datetime, end: datetime) -> None:
353
- """Update daily statistics within a date range."""
331
+ The pending buckets are derived from cheap indexed queries (incomplete
332
+ rows plus the recent range past the last complete bucket), so a sync never
333
+ scans the whole energy history.
334
+ """
354
335
 
355
- day_start = get_day(start)
356
- day_end = end
357
- daily = await self.db.list_statistics(self._cups, "day", day_start, day_end)
336
+ ledger = PendingLedger()
337
+ await self._seed_pending_backlog(ledger)
338
+ await self._seed_pending_recent(ledger)
339
+ await self._compile_pending(ledger)
358
340
 
359
- complete = []
360
- for stat in daily:
361
- if stat.complete:
362
- complete.append(stat.datetime)
363
- continue
341
+ async def _seed_pending_backlog(self, ledger: PendingLedger) -> None:
342
+ """Seed the ledger with the buckets whose stored stats are incomplete."""
364
343
 
365
- data = await self.get_energy(day_start, day_end)
366
- stats = await asyncio.to_thread(
367
- self._compile_statistics, data, get_day, skip=complete
368
- )
344
+ ledger.add_days(await self._find_missing_stats("day"))
345
+ ledger.add_months(await self._find_missing_stats("month"))
369
346
 
370
- for stat in stats:
371
- is_complete = stat.delta_h == 24
372
- await self.db.add_statistics(self._cups, "day", stat, is_complete)
373
- if not is_complete:
374
- _LOGGER.info(
375
- "%s daily statistics for %s are incomplete",
376
- self._scups,
377
- stat.datetime.date(),
378
- )
379
-
380
- async def _update_monthly_statistics(self, start: datetime, end: datetime) -> None:
381
- """Update monthly statistics within a date range."""
347
+ async def _seed_pending_recent(self, ledger: PendingLedger) -> None:
348
+ """Seed the ledger with the range past the last complete daily stat."""
382
349
 
383
- month_start = get_month(start)
384
- month_end = end
385
- monthly = await self.db.list_statistics(
386
- self._cups, "month", month_start, month_end
387
- )
350
+ last_energy = await self.db.get_last_energy(self._cups)
351
+ if not last_energy:
352
+ return
388
353
 
389
- complete = []
390
- for stat in monthly:
391
- if stat.complete:
392
- complete.append(stat.datetime)
393
- continue
354
+ last_complete = await self.db.get_last_complete_statistic(self._cups, "day")
355
+ if last_complete:
356
+ start = get_day(last_complete.datetime) + timedelta(days=1)
357
+ else:
358
+ supply = await self.db.get_supply(self._cups)
359
+ start = get_day(
360
+ supply.data.date_start if supply else last_energy.datetime
361
+ )
362
+ ledger.mark_range(start, last_energy.datetime)
394
363
 
395
- data = await self.get_energy(month_start, month_end)
396
- stats = await asyncio.to_thread(
397
- self._compile_statistics, data, get_month, skip=complete
398
- )
364
+ async def _compile_pending(self, ledger: PendingLedger) -> None:
365
+ """Compile the pending buckets, one month of energy loaded at a time."""
399
366
 
400
- for stat in stats:
401
- target_hours = (
402
- calendar.monthrange(stat.datetime.year, stat.datetime.month)[1] * 24
367
+ now = datetime.now()
368
+ for month in ledger.sorted_months():
369
+ month_end = (
370
+ month + relativedelta.relativedelta(months=1) - timedelta(microseconds=1)
403
371
  )
404
- is_complete = stat.delta_h == target_hours
405
- await self.db.add_statistics(self._cups, "month", stat, is_complete)
406
- if not is_complete:
407
- _LOGGER.info(
408
- "%s monthly statistics for %s are incomplete",
409
- self._scups,
410
- stat.datetime.date(),
372
+ data = await self.get_energy(month, month_end)
373
+
374
+ for day in ledger.days_in(month):
375
+ day_end = day + timedelta(days=1) - timedelta(microseconds=1)
376
+ day_data = [x for x in data if day <= x.datetime <= day_end]
377
+ stat = await asyncio.to_thread(
378
+ self._compile_statistics, day_data, get_day
411
379
  )
380
+ delta_h = stat[0].delta_h if stat else 0.0
381
+ final = is_day_final(day, delta_h, now)
382
+ if stat:
383
+ await self.db.add_statistics(self._cups, "day", stat[0], final)
384
+ ledger.resolve_day(day, final=final)
385
+
386
+ month_stat = await asyncio.to_thread(
387
+ self._compile_statistics, data, get_month
388
+ )
389
+ delta_h = month_stat[0].delta_h if month_stat else 0.0
390
+ final = is_month_final(month, delta_h, now)
391
+ if month_stat:
392
+ await self.db.add_statistics(self._cups, "month", month_stat[0], final)
393
+ ledger.resolve_month(month, final=final)
412
394
 
413
395
  def _compile_statistics(
414
396
  self,
@@ -0,0 +1,104 @@
1
+ """Tests for the incremental completion ledger."""
2
+
3
+ from datetime import datetime
4
+
5
+ import pytest
6
+
7
+ from edata.core.completion import (
8
+ PendingLedger,
9
+ day_buckets,
10
+ is_day_final,
11
+ is_month_final,
12
+ )
13
+
14
+ NOW = datetime(2026, 7, 26, 12, 0)
15
+
16
+
17
+ def test_day_buckets_covers_range_inclusively() -> None:
18
+ """Every day-start in the range is yielded once."""
19
+ buckets = list(day_buckets(datetime(2023, 1, 30, 5), datetime(2023, 2, 2)))
20
+ assert buckets == [
21
+ datetime(2023, 1, 30),
22
+ datetime(2023, 1, 31),
23
+ datetime(2023, 2, 1),
24
+ datetime(2023, 2, 2),
25
+ ]
26
+
27
+
28
+ @pytest.mark.parametrize(
29
+ ("day", "delta_h", "expected"),
30
+ [
31
+ (datetime(2026, 7, 25), 24, True), # nominal reached
32
+ (datetime(2026, 7, 25), 25, True), # fall-back DST day (25h)
33
+ (datetime(2026, 7, 25), 23, False), # recent + short -> keep
34
+ (datetime(2026, 5, 1), 23, True), # short but settled (>30d old)
35
+ ],
36
+ ids=["nominal", "dst-25h", "recent-short", "settled-short"],
37
+ )
38
+ def test_is_day_final(day: datetime, delta_h: float, expected: bool) -> None:
39
+ """A day is final at nominal hours or once settled past the window."""
40
+ assert is_day_final(day, delta_h, NOW) is expected
41
+
42
+
43
+ @pytest.mark.parametrize(
44
+ ("month", "delta_h", "expected"),
45
+ [
46
+ (datetime(2026, 7, 1), 31 * 24, True), # nominal (July has 31 days)
47
+ (datetime(2026, 7, 1), 700, False), # current month, short -> keep
48
+ (datetime(2026, 5, 1), 700, True), # short but settled
49
+ ],
50
+ ids=["nominal", "current-short", "settled-short"],
51
+ )
52
+ def test_is_month_final(month: datetime, delta_h: float, expected: bool) -> None:
53
+ """A month is final at nominal hours or once settled past the window."""
54
+ assert is_month_final(month, delta_h, NOW) is expected
55
+
56
+
57
+ def test_mark_range_adds_days_and_their_months() -> None:
58
+ """mark_range records each day and the month it belongs to."""
59
+ ledger = PendingLedger()
60
+ ledger.mark_range(datetime(2023, 1, 30), datetime(2023, 2, 1))
61
+
62
+ assert ledger.days == {
63
+ datetime(2023, 1, 30),
64
+ datetime(2023, 1, 31),
65
+ datetime(2023, 2, 1),
66
+ }
67
+ assert ledger.months == {datetime(2023, 1, 1), datetime(2023, 2, 1)}
68
+
69
+
70
+ def test_add_days_and_months_normalize_and_pair() -> None:
71
+ """add_days aligns to day-start and also marks the month; add_months aligns."""
72
+ ledger = PendingLedger()
73
+ ledger.add_days([datetime(2023, 3, 15, 8, 30)])
74
+ ledger.add_months([datetime(2023, 4, 20, 0, 0)])
75
+
76
+ assert ledger.days == {datetime(2023, 3, 15)}
77
+ assert ledger.months == {datetime(2023, 3, 1), datetime(2023, 4, 1)}
78
+
79
+
80
+ def test_days_in_filters_and_sorts_by_month() -> None:
81
+ """days_in returns only the sorted days belonging to the given month."""
82
+ ledger = PendingLedger()
83
+ ledger.add_days(
84
+ [datetime(2023, 1, 31), datetime(2023, 1, 5), datetime(2023, 2, 3)]
85
+ )
86
+ assert ledger.days_in(datetime(2023, 1, 1)) == [
87
+ datetime(2023, 1, 5),
88
+ datetime(2023, 1, 31),
89
+ ]
90
+
91
+
92
+ def test_resolve_only_drops_when_final() -> None:
93
+ """resolve_* removes a bucket when final and keeps it otherwise."""
94
+ ledger = PendingLedger()
95
+ ledger.mark_range(datetime(2023, 1, 1), datetime(2023, 1, 1))
96
+
97
+ ledger.resolve_day(datetime(2023, 1, 1), final=False)
98
+ assert datetime(2023, 1, 1) in ledger.days
99
+
100
+ ledger.resolve_day(datetime(2023, 1, 1), final=True)
101
+ assert datetime(2023, 1, 1) not in ledger.days
102
+
103
+ ledger.resolve_month(datetime(2023, 1, 1), final=True)
104
+ assert datetime(2023, 1, 1) not in ledger.months
@@ -0,0 +1,108 @@
1
+ """Incrementality tests: a steady-state sync only touches recent buckets."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from datetime import datetime, timedelta
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+ import pytest_asyncio
9
+
10
+ from edata.core.utils import get_month
11
+ from edata.database.controller import EdataDB
12
+ from edata.models import Energy, Supply
13
+ from edata.services.data_service import DataService
14
+
15
+ CUPS = "ESXXXXXXXXXXXXXXXXTEST"
16
+
17
+
18
+ def _reset_singleton() -> None:
19
+ EdataDB._instance = None
20
+ EdataDB._engine = None
21
+ EdataDB._db_url = None
22
+
23
+
24
+ @pytest_asyncio.fixture
25
+ async def data_service(tmp_path) -> AsyncIterator[DataService]:
26
+ """A DataService on an isolated on-disk database, singleton reset around it."""
27
+ _reset_singleton()
28
+ with (
29
+ patch("edata.services.data_service.DatadisConnector"),
30
+ patch("edata.services.data_service.REDataConnector"),
31
+ ):
32
+ service = DataService(CUPS, "user", "pwd", storage_path=str(tmp_path))
33
+ yield service
34
+ if EdataDB._engine is not None:
35
+ await EdataDB._engine.dispose()
36
+ _reset_singleton()
37
+
38
+
39
+ def _hourly_energy(start: datetime, end: datetime) -> list[Energy]:
40
+ """Build deterministic hourly energy records over [start, end)."""
41
+ records = []
42
+ cursor = start
43
+ while cursor < end:
44
+ records.append(
45
+ Energy(
46
+ datetime=cursor,
47
+ delta_h=1.0,
48
+ consumption_kwh=0.1,
49
+ surplus_kwh=0.0,
50
+ generation_kwh=0.0,
51
+ selfconsumption_kwh=0.0,
52
+ real=True,
53
+ )
54
+ )
55
+ cursor += timedelta(hours=1)
56
+ return records
57
+
58
+
59
+ async def _seed(service: DataService, start: datetime, end: datetime) -> None:
60
+ await service.db.add_supply(
61
+ Supply(
62
+ cups=CUPS,
63
+ date_start=start,
64
+ date_end=end,
65
+ address=None,
66
+ postal_code=None,
67
+ province=None,
68
+ municipality=None,
69
+ distributor=None,
70
+ point_type=5,
71
+ distributor_code="2",
72
+ )
73
+ )
74
+ await service.db.add_energy_list(CUPS, _hourly_energy(start, end))
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_incremental_sync_only_loads_recent_month(
79
+ data_service: DataService,
80
+ ) -> None:
81
+ """After a full build, an incremental sync must not reload complete months."""
82
+ now = datetime.now().replace(minute=0, second=0, microsecond=0)
83
+ start = now - timedelta(days=70)
84
+ await _seed(data_service, start, now)
85
+
86
+ # Full build establishes completeness for every past month.
87
+ await data_service.update_statistics(start, now)
88
+ daily_before = await data_service.get_statistics("day")
89
+
90
+ # Spy on the energy loads issued by a subsequent incremental sync.
91
+ loaded_ranges: list[tuple[datetime, datetime]] = []
92
+ original = data_service.get_energy
93
+
94
+ async def _spy(start=None, end=None):
95
+ loaded_ranges.append((start, end))
96
+ return await original(start=start, end=end)
97
+
98
+ with patch.object(data_service, "get_energy", side_effect=_spy):
99
+ await data_service.update_statistics_incremental()
100
+
101
+ # Something was loaded, but never anything before the current month.
102
+ current_month = get_month(now)
103
+ assert loaded_ranges
104
+ assert all(rng[0] >= current_month for rng in loaded_ranges)
105
+
106
+ # Stats are unchanged by the idempotent re-run.
107
+ daily_after = await data_service.get_statistics("day")
108
+ assert len(daily_after) == len(daily_before)
@@ -83,8 +83,7 @@ async def populated_data_service(mock_connector, energy, power, storage_dir):
83
83
  await ds.update_energy(start_date, end_date)
84
84
  await ds.update_power(start_date, end_date)
85
85
 
86
- await ds._update_daily_statistics(start_date, end_date)
87
- await ds._update_monthly_statistics(start_date, end_date)
86
+ await ds.update_statistics(start_date, end_date)
88
87
 
89
88
  return ds
90
89
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "e-data"
7
- version = "2.0.1.dev129"
7
+ version = "2.0.1.dev131"
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