e-data 2.0.1.dev129__py3-none-any.whl → 2.0.1.dev131__py3-none-any.whl
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.
- {e_data-2.0.1.dev129.dist-info → e_data-2.0.1.dev131.dist-info}/METADATA +1 -1
- {e_data-2.0.1.dev129.dist-info → e_data-2.0.1.dev131.dist-info}/RECORD +13 -10
- edata/core/completion.py +114 -0
- edata/database/controller.py +20 -0
- edata/database/queries.py +26 -0
- edata/services/bill_service.py +64 -77
- edata/services/data_service.py +63 -81
- edata/tests/test_completion.py +104 -0
- edata/tests/test_incremental.py +108 -0
- edata/tests/test_services.py +1 -2
- {e_data-2.0.1.dev129.dist-info → e_data-2.0.1.dev131.dist-info}/WHEEL +0 -0
- {e_data-2.0.1.dev129.dist-info → e_data-2.0.1.dev131.dist-info}/licenses/LICENSE +0 -0
- {e_data-2.0.1.dev129.dist-info → e_data-2.0.1.dev131.dist-info}/top_level.txt +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
e_data-2.0.1.
|
|
1
|
+
e_data-2.0.1.dev131.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
2
2
|
edata/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
edata/cli.py,sha256=0pmlaaXPVfbxPVHMovsuK9qf8PGXl38AV3gtIsk95O4,4010
|
|
4
4
|
edata/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
edata/core/completion.py,sha256=A0ekfu7eCjaLblUYO5DrvaWuDPAO9k1buzy2qdfjdNc,3914
|
|
5
6
|
edata/core/const.py,sha256=X9J2f7lt9jXJnghqz0JFi-J7USa3xlYvbuiki70eHNE,43
|
|
6
7
|
edata/core/utils.py,sha256=uQX8GmNjuhof56jiXXoV-Fk-d590OuIoBJbM9ymZyV8,3194
|
|
7
8
|
edata/database/__init__.py,sha256=HP_pDbGLzeMY9Ufm_Dps5cxzW_3YTonT8w8dstG558c,46
|
|
8
|
-
edata/database/controller.py,sha256=
|
|
9
|
+
edata/database/controller.py,sha256=EW7YKnq1Ti_rslVAuIq5TDjf8THyWBAt-rwMxnsSvdQ,18507
|
|
9
10
|
edata/database/models.py,sha256=4rlRpPErULqYr6QJxZar1NBpJPWTcHftDN1MB3bwGyA,5096
|
|
10
|
-
edata/database/queries.py,sha256=
|
|
11
|
+
edata/database/queries.py,sha256=yfUAwHtEv6JkcGf8aGiQ1Z5ihbgdMtKLGNiwvBikb7Y,8013
|
|
11
12
|
edata/database/utils.py,sha256=V-FJfyAh8YJBGHB85BbWtG3Ilq09pM9Gl-L86TffB4g,1062
|
|
12
13
|
edata/models/__init__.py,sha256=nMPPPkGjUsF51PBFlChgHyb5C3aQiccTRiVHUdJjNj4,153
|
|
13
14
|
edata/models/bill.py,sha256=-qZ4l9f8BnPFlykFRoNYnmRe6-ELWmuNh6usks_k4X0,2674
|
|
@@ -16,14 +17,16 @@ edata/models/supply.py,sha256=qM0Q22-7PfBN93-Tu3ljAE58p5gfob0YWw-o5FccjrI,878
|
|
|
16
17
|
edata/providers/__init__.py,sha256=5xaRKPttYM6EpVE1I_vhUAF25L9pEM_qK_5Ix6xnQe0,104
|
|
17
18
|
edata/providers/datadis.py,sha256=a084ArY83ncvrMQvMSD2BY1rHlhp42dwJJkU8avmleo,20137
|
|
18
19
|
edata/providers/redata.py,sha256=uvxO2TTtSNSfPRWHZifLjbaP7zAr6O7CZ1oRQFNbIhQ,3122
|
|
19
|
-
edata/services/bill_service.py,sha256=
|
|
20
|
-
edata/services/data_service.py,sha256=
|
|
20
|
+
edata/services/bill_service.py,sha256=vukgeQAN0fMjAkyI9cqPqQ4wSRvPdOaswfSR1KheIYc,14947
|
|
21
|
+
edata/services/data_service.py,sha256=tgLjPPPobKSc5txbkpa8mQspcFwxA8zevlRFUC56YPQ,17238
|
|
21
22
|
edata/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
edata/tests/test_completion.py,sha256=-iyVt-juU3U1HqMuLUJXJthil2-n_4ddWiJq2GwnXTo,3551
|
|
22
24
|
edata/tests/test_datadis_connector.py,sha256=O3mjI5uIOgFz3PzQsApcG6J2yXd8rlEtj8By-9dsDbg,9270
|
|
25
|
+
edata/tests/test_incremental.py,sha256=dn1LRgjBi2HuNYELPKDVdZQlS01hk4lsP9L-vyvtEHo,3479
|
|
23
26
|
edata/tests/test_redata_connector.py,sha256=SBvxsma5t-qvz2drxNisNM2P1STTurqEs1TSDgwaPJ8,508
|
|
24
|
-
edata/tests/test_services.py,sha256=
|
|
27
|
+
edata/tests/test_services.py,sha256=BMw-MYXs2L_c1Sg_v4TwAZGOJnx2BoWH8U50M05-ZC8,6816
|
|
25
28
|
edata/tests/test_utils.py,sha256=h6yBzT7j3eA42zbXJ-AK9lXcZkjCMf16wnob1oqGvUE,2307
|
|
26
|
-
e_data-2.0.1.
|
|
27
|
-
e_data-2.0.1.
|
|
28
|
-
e_data-2.0.1.
|
|
29
|
-
e_data-2.0.1.
|
|
29
|
+
e_data-2.0.1.dev131.dist-info/METADATA,sha256=IGNLDaNDJ_S4zqVw0IIoDuIaCp4TrQqtBtKek7NNu08,48084
|
|
30
|
+
e_data-2.0.1.dev131.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
31
|
+
e_data-2.0.1.dev131.dist-info/top_level.txt,sha256=Ez-fReWtUVTMcFuH0dzCnoKIgMBHgjLaBuba8QiKC7g,6
|
|
32
|
+
e_data-2.0.1.dev131.dist-info/RECORD,,
|
edata/core/completion.py
ADDED
|
@@ -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)
|
edata/database/controller.py
CHANGED
|
@@ -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
|
|
edata/database/queries.py
CHANGED
|
@@ -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
|
|
edata/services/bill_service.py
CHANGED
|
@@ -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.
|
|
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
|
-
"""
|
|
182
|
+
"""Compile the daily/monthly bills for a range plus any incomplete backlog."""
|
|
184
183
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
190
|
-
"""
|
|
189
|
+
async def update_statistics_incremental(self) -> None:
|
|
190
|
+
"""Compile only the daily/monthly bills that are new or still incomplete.
|
|
191
191
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
218
|
-
"""
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
213
|
+
last_bill = await self.db.get_last_bill(self._cups)
|
|
214
|
+
if not last_bill:
|
|
215
|
+
return
|
|
223
216
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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],
|
edata/services/data_service.py
CHANGED
|
@@ -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
|
-
#
|
|
247
|
-
await self.
|
|
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
|
-
"""
|
|
321
|
+
"""Compile the statistics for a range plus any incomplete backlog."""
|
|
343
322
|
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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)
|
edata/tests/test_services.py
CHANGED
|
@@ -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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|