e-data 2.0.2.dev134__py3-none-any.whl → 2.0.2.dev136__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.2.dev134.dist-info → e_data-2.0.2.dev136.dist-info}/METADATA +2 -2
- {e_data-2.0.2.dev134.dist-info → e_data-2.0.2.dev136.dist-info}/RECORD +15 -14
- {e_data-2.0.2.dev134.dist-info → e_data-2.0.2.dev136.dist-info}/WHEEL +1 -1
- edata/database/controller.py +177 -49
- edata/database/models.py +29 -15
- edata/database/queries.py +12 -0
- edata/providers/datadis.py +25 -5
- edata/providers/redata.py +22 -6
- edata/services/bill_service.py +30 -21
- edata/services/data_service.py +48 -22
- edata/tests/test_controller.py +108 -0
- edata/tests/test_datadis_connector.py +25 -0
- edata/tests/test_services.py +38 -1
- {e_data-2.0.2.dev134.dist-info → e_data-2.0.2.dev136.dist-info}/licenses/LICENSE +0 -0
- {e_data-2.0.2.dev134.dist-info → e_data-2.0.2.dev136.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e-data
|
|
3
|
-
Version: 2.0.2.
|
|
3
|
+
Version: 2.0.2.dev136
|
|
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
|
|
@@ -697,7 +697,7 @@ Requires-Dist: pydantic<3,>=2.10
|
|
|
697
697
|
Requires-Dist: python_dateutil<3,>=2.8
|
|
698
698
|
Requires-Dist: Requests<3,>=2.31
|
|
699
699
|
Requires-Dist: SQLAlchemy[asyncio]<3,>=2.0
|
|
700
|
-
Requires-Dist: sqlmodel<0.
|
|
700
|
+
Requires-Dist: sqlmodel<0.0.45,>=0.0.22
|
|
701
701
|
Requires-Dist: typer<1,>=0.12
|
|
702
702
|
Requires-Dist: aiosqlite<1,>=0.21
|
|
703
703
|
Dynamic: license-file
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
e_data-2.0.2.
|
|
1
|
+
e_data-2.0.2.dev136.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=CsBYDggt0grkLb4cebrckOvJyHr9PaGTnlWqT4yjuFo,5215
|
|
4
4
|
edata/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -6,9 +6,9 @@ edata/core/completion.py,sha256=A0ekfu7eCjaLblUYO5DrvaWuDPAO9k1buzy2qdfjdNc,3914
|
|
|
6
6
|
edata/core/const.py,sha256=X9J2f7lt9jXJnghqz0JFi-J7USa3xlYvbuiki70eHNE,43
|
|
7
7
|
edata/core/utils.py,sha256=uQX8GmNjuhof56jiXXoV-Fk-d590OuIoBJbM9ymZyV8,3194
|
|
8
8
|
edata/database/__init__.py,sha256=HP_pDbGLzeMY9Ufm_Dps5cxzW_3YTonT8w8dstG558c,46
|
|
9
|
-
edata/database/controller.py,sha256=
|
|
10
|
-
edata/database/models.py,sha256=
|
|
11
|
-
edata/database/queries.py,sha256=
|
|
9
|
+
edata/database/controller.py,sha256=bI3cE1Q4SK9U1Eep_61EhlmyVjgYH87F5lfBLhwSpmg,23211
|
|
10
|
+
edata/database/models.py,sha256=tSkxf2OKgT1m2F1td3j3GtYcboUtSuYbhnHWQtpvJuY,5525
|
|
11
|
+
edata/database/queries.py,sha256=XziDSG2V-PVFmvRkj8UrEvgrc9Qc-ShhnxgsPHIsyik,8304
|
|
12
12
|
edata/database/utils.py,sha256=V-FJfyAh8YJBGHB85BbWtG3Ilq09pM9Gl-L86TffB4g,1062
|
|
13
13
|
edata/database/migrations/__init__.py,sha256=P8VXZzfyceOV3GknYXif2HEMB8Bo4B7f7vKdIC02ZO0,1203
|
|
14
14
|
edata/database/migrations/base.py,sha256=V7jfMaTl6XPCwM6Wei4bNxfOKSKanIHF30UgH-_CJNM,444
|
|
@@ -18,19 +18,20 @@ edata/models/bill.py,sha256=-qZ4l9f8BnPFlykFRoNYnmRe6-ELWmuNh6usks_k4X0,2674
|
|
|
18
18
|
edata/models/data.py,sha256=O1aj0H_G92mnJjm6mYhC8fnl8ZOoatGiokRf8b7ziD8,2388
|
|
19
19
|
edata/models/supply.py,sha256=qM0Q22-7PfBN93-Tu3ljAE58p5gfob0YWw-o5FccjrI,878
|
|
20
20
|
edata/providers/__init__.py,sha256=5xaRKPttYM6EpVE1I_vhUAF25L9pEM_qK_5Ix6xnQe0,104
|
|
21
|
-
edata/providers/datadis.py,sha256=
|
|
22
|
-
edata/providers/redata.py,sha256=
|
|
23
|
-
edata/services/bill_service.py,sha256=
|
|
24
|
-
edata/services/data_service.py,sha256=
|
|
21
|
+
edata/providers/datadis.py,sha256=VZ_MhO3x1crSmOH2dn3SWJ5Eq1gWUIDCEvB9oRRMZfQ,21468
|
|
22
|
+
edata/providers/redata.py,sha256=i6aBjunyZMb34SGgAgNQvaNu_GFoe5P2rUIY-_vOElw,3727
|
|
23
|
+
edata/services/bill_service.py,sha256=KAM78u7TpoVF-KTlB7bqj2Kp-rJERiPPoGTJCc-7rM0,15264
|
|
24
|
+
edata/services/data_service.py,sha256=JJCdivjrwNt3aLhz3uMBWv06y-EL7UiK2G5EBr97l_8,19374
|
|
25
25
|
edata/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
26
|
edata/tests/test_completion.py,sha256=-iyVt-juU3U1HqMuLUJXJthil2-n_4ddWiJq2GwnXTo,3551
|
|
27
|
-
edata/tests/
|
|
27
|
+
edata/tests/test_controller.py,sha256=E1Blvel2dtS8CiyxErP4V_wpzWlEcxTYA1gW1f8-Gg4,3449
|
|
28
|
+
edata/tests/test_datadis_connector.py,sha256=8Xet2M2eG-kMhiRDTCSH0R5nYO2oIpQTPOuTZKMKoLo,12538
|
|
28
29
|
edata/tests/test_incremental.py,sha256=dn1LRgjBi2HuNYELPKDVdZQlS01hk4lsP9L-vyvtEHo,3479
|
|
29
30
|
edata/tests/test_migrations.py,sha256=3Io2nokZVdv2xU9fCZCMIvwAwPVtGc0UivClSouP3pw,6418
|
|
30
31
|
edata/tests/test_redata_connector.py,sha256=SBvxsma5t-qvz2drxNisNM2P1STTurqEs1TSDgwaPJ8,508
|
|
31
|
-
edata/tests/test_services.py,sha256=
|
|
32
|
+
edata/tests/test_services.py,sha256=Ya5cbADHj1Qsffb_IuMeDD0BViz8nqrgwazUWK0-hRc,8297
|
|
32
33
|
edata/tests/test_utils.py,sha256=h6yBzT7j3eA42zbXJ-AK9lXcZkjCMf16wnob1oqGvUE,2307
|
|
33
|
-
e_data-2.0.2.
|
|
34
|
-
e_data-2.0.2.
|
|
35
|
-
e_data-2.0.2.
|
|
36
|
-
e_data-2.0.2.
|
|
34
|
+
e_data-2.0.2.dev136.dist-info/METADATA,sha256=YZbWL0ZGXBHvfFBODBeXBwD8CcQHH5VXqYqY_NV42GE,48087
|
|
35
|
+
e_data-2.0.2.dev136.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
36
|
+
e_data-2.0.2.dev136.dist-info/top_level.txt,sha256=Ez-fReWtUVTMcFuH0dzCnoKIgMBHgjLaBuba8QiKC7g,6
|
|
37
|
+
e_data-2.0.2.dev136.dist-info/RECORD,,
|
edata/database/controller.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import logging
|
|
2
3
|
import os
|
|
3
4
|
import typing
|
|
4
5
|
from datetime import datetime
|
|
5
6
|
|
|
6
|
-
from sqlalchemy import Select, event, insert
|
|
7
|
+
from sqlalchemy import Select, Table, event, insert, or_
|
|
8
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
7
9
|
from sqlalchemy.exc import IntegrityError
|
|
8
10
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
9
|
-
from sqlmodel import SQLModel
|
|
11
|
+
from sqlmodel import SQLModel, UniqueConstraint
|
|
10
12
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
11
13
|
from sqlmodel.sql.expression import SelectOfScalar
|
|
12
14
|
|
|
@@ -47,6 +49,34 @@ def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
|
|
|
47
49
|
cursor.close()
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
def _create_missing_indexes(connection) -> None:
|
|
53
|
+
"""Create indexes added after a table was first created.
|
|
54
|
+
|
|
55
|
+
``create_all`` skips tables that already exist, indexes included, so
|
|
56
|
+
databases created by an older version would never get new indexes.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
for table in SQLModel.metadata.sorted_tables:
|
|
60
|
+
for index in table.indexes:
|
|
61
|
+
index.create(connection, checkfirst=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _conflict_columns(table: Table) -> list[str]:
|
|
65
|
+
"""Return the columns that identify a row for upserts on ``table``.
|
|
66
|
+
|
|
67
|
+
That is the table's unique constraint, else its unique index, else its
|
|
68
|
+
primary key.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
for constraint in table.constraints:
|
|
72
|
+
if isinstance(constraint, UniqueConstraint):
|
|
73
|
+
return [c.name for c in constraint.columns]
|
|
74
|
+
for index in table.indexes:
|
|
75
|
+
if index.unique:
|
|
76
|
+
return [c.name for c in index.columns]
|
|
77
|
+
return [c.name for c in table.primary_key.columns]
|
|
78
|
+
|
|
79
|
+
|
|
50
80
|
class EdataDB:
|
|
51
81
|
|
|
52
82
|
_instance = None
|
|
@@ -64,6 +94,7 @@ class EdataDB:
|
|
|
64
94
|
cls._engine = create_async_engine(db_url, future=True)
|
|
65
95
|
event.listen(cls._engine.sync_engine, "connect", _set_sqlite_pragmas)
|
|
66
96
|
cls._instance._tables_initialized = False
|
|
97
|
+
cls._instance._tables_lock = asyncio.Lock()
|
|
67
98
|
elif db_url != cls._db_url:
|
|
68
99
|
raise ValueError("EdataDB already initialized with a different db_url")
|
|
69
100
|
return cls._instance
|
|
@@ -79,9 +110,15 @@ class EdataDB:
|
|
|
79
110
|
|
|
80
111
|
if self._tables_initialized:
|
|
81
112
|
return
|
|
82
|
-
|
|
113
|
+
# concurrent first calls (e.g. several websocket requests right after
|
|
114
|
+
# startup) would otherwise all see the index missing and race to create
|
|
115
|
+
# it, failing with "index ... already exists"
|
|
116
|
+
async with self._tables_lock:
|
|
117
|
+
if self._tables_initialized or not self.engine:
|
|
118
|
+
return
|
|
83
119
|
async with self.engine.begin() as conn:
|
|
84
120
|
await conn.run_sync(SQLModel.metadata.create_all)
|
|
121
|
+
await conn.run_sync(_create_missing_indexes)
|
|
85
122
|
self._tables_initialized = True
|
|
86
123
|
|
|
87
124
|
async def _add_one(
|
|
@@ -158,33 +195,46 @@ class EdataDB:
|
|
|
158
195
|
async def _add_or_update_many(
|
|
159
196
|
self,
|
|
160
197
|
session: AsyncSession,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
batch_size: int = 100,
|
|
198
|
+
model: type[SQLModel],
|
|
199
|
+
rows: list[dict[str, typing.Any]],
|
|
164
200
|
override: list[str] | None = None,
|
|
165
|
-
) ->
|
|
166
|
-
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Insert many rows, updating the existing ones, in a single statement.
|
|
203
|
+
|
|
204
|
+
Uses SQLite's ``INSERT ... ON CONFLICT DO UPDATE`` so re-syncing rows that
|
|
205
|
+
are already stored costs one bulk statement instead of a savepoint, a
|
|
206
|
+
failed insert and a lookup per row. Existing rows are only rewritten (and
|
|
207
|
+
their ``updated_at`` bumped) when ``data`` or an ``override`` column
|
|
208
|
+
actually changed.
|
|
209
|
+
|
|
210
|
+
Rows are plain column dicts: building an ORM instance per row only to
|
|
211
|
+
read it back cost about half of a full-history import. Columns missing
|
|
212
|
+
from a row take the model defaults, resolved once per batch.
|
|
213
|
+
"""
|
|
214
|
+
if not rows:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
table = model.__table__ # type: ignore[attr-defined]
|
|
218
|
+
defaults = {
|
|
219
|
+
c.name: model.model_fields[c.name].get_default(call_default_factory=True)
|
|
220
|
+
for c in table.columns
|
|
221
|
+
if c.name != "id" and c.name not in rows[0]
|
|
222
|
+
}
|
|
223
|
+
rows = [{**defaults, **row} for row in rows]
|
|
224
|
+
|
|
225
|
+
updated = ["data", *(override or [])]
|
|
226
|
+
stmt = sqlite_insert(table)
|
|
227
|
+
stmt = stmt.on_conflict_do_update(
|
|
228
|
+
index_elements=_conflict_columns(table),
|
|
229
|
+
set_={
|
|
230
|
+
**{c: stmt.excluded[c] for c in updated},
|
|
231
|
+
"updated_at": stmt.excluded.updated_at,
|
|
232
|
+
},
|
|
233
|
+
where=or_(*(table.c[c].is_distinct_from(stmt.excluded[c]) for c in updated)),
|
|
234
|
+
)
|
|
235
|
+
connection = await session.connection()
|
|
236
|
+
await connection.execute(stmt, rows)
|
|
186
237
|
await session.commit()
|
|
187
|
-
return records
|
|
188
238
|
|
|
189
239
|
async def get_supply(self, cups: str) -> SupplyModel | None:
|
|
190
240
|
"""Get a supply record by cups."""
|
|
@@ -212,6 +262,14 @@ class EdataDB:
|
|
|
212
262
|
result = await session.exec(q.get_last_energy(cups))
|
|
213
263
|
return result.first()
|
|
214
264
|
|
|
265
|
+
async def get_last_power(self, cups: str) -> PowerModel | None:
|
|
266
|
+
"""Get the most recent power record by cups."""
|
|
267
|
+
|
|
268
|
+
await self._ensure_tables()
|
|
269
|
+
async with AsyncSession(self.engine) as session:
|
|
270
|
+
result = await session.exec(q.get_last_power(cups))
|
|
271
|
+
return result.first()
|
|
272
|
+
|
|
215
273
|
async def get_last_pvpc(self) -> PVPCModel | None:
|
|
216
274
|
"""Get the most recent pvpc."""
|
|
217
275
|
|
|
@@ -287,12 +345,11 @@ class EdataDB:
|
|
|
287
345
|
async with AsyncSession(self.engine) as session:
|
|
288
346
|
unique_map = {item.datetime: item for item in energy}
|
|
289
347
|
unique = list(unique_map.values())
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
EnergyModel(cups=cups, delta_h=x.delta_h, datetime=x.datetime, data=x)
|
|
348
|
+
rows = [
|
|
349
|
+
{"cups": cups, "delta_h": x.delta_h, "datetime": x.datetime, "data": x}
|
|
293
350
|
for x in unique
|
|
294
351
|
]
|
|
295
|
-
await self._add_or_update_many(session,
|
|
352
|
+
await self._add_or_update_many(session, EnergyModel, rows)
|
|
296
353
|
|
|
297
354
|
async def add_power(self, cups: str, power: Power) -> PowerModel | None:
|
|
298
355
|
"""Add or update a power record for a given CUPS and Power instance."""
|
|
@@ -311,9 +368,8 @@ class EdataDB:
|
|
|
311
368
|
async with AsyncSession(self.engine) as session:
|
|
312
369
|
unique_map = {item.datetime: item for item in power}
|
|
313
370
|
unique = list(unique_map.values())
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
await self._add_or_update_many(session, queries, items)
|
|
371
|
+
rows = [{"cups": cups, "datetime": x.datetime, "data": x} for x in unique]
|
|
372
|
+
await self._add_or_update_many(session, PowerModel, rows)
|
|
317
373
|
|
|
318
374
|
async def add_pvpc(self, pvpc: EnergyPrice) -> PVPCModel | None:
|
|
319
375
|
"""Add or update a pvpc record."""
|
|
@@ -332,9 +388,8 @@ class EdataDB:
|
|
|
332
388
|
async with AsyncSession(self.engine) as session:
|
|
333
389
|
unique_map = {item.datetime: item for item in pvpc}
|
|
334
390
|
unique = list(unique_map.values())
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
await self._add_or_update_many(session, queries, items)
|
|
391
|
+
rows = [{"datetime": x.datetime, "data": x} for x in unique]
|
|
392
|
+
await self._add_or_update_many(session, PVPCModel, rows)
|
|
338
393
|
|
|
339
394
|
async def add_statistics(
|
|
340
395
|
self,
|
|
@@ -357,6 +412,32 @@ class EdataDB:
|
|
|
357
412
|
override=["complete"],
|
|
358
413
|
)
|
|
359
414
|
|
|
415
|
+
async def add_statistics_list(
|
|
416
|
+
self,
|
|
417
|
+
cups: str,
|
|
418
|
+
type_: typing.Literal["day", "month"],
|
|
419
|
+
complete: bool,
|
|
420
|
+
statistics: list[Statistics],
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Add or update a list of statistics records."""
|
|
423
|
+
|
|
424
|
+
await self._ensure_tables()
|
|
425
|
+
async with AsyncSession(self.engine) as session:
|
|
426
|
+
unique_map = {item.datetime: item for item in statistics}
|
|
427
|
+
rows = [
|
|
428
|
+
{
|
|
429
|
+
"cups": cups,
|
|
430
|
+
"datetime": x.datetime,
|
|
431
|
+
"type": type_,
|
|
432
|
+
"complete": complete,
|
|
433
|
+
"data": x,
|
|
434
|
+
}
|
|
435
|
+
for x in unique_map.values()
|
|
436
|
+
]
|
|
437
|
+
await self._add_or_update_many(
|
|
438
|
+
session, StatisticsModel, rows, override=["complete"]
|
|
439
|
+
)
|
|
440
|
+
|
|
360
441
|
async def add_bill(
|
|
361
442
|
self,
|
|
362
443
|
cups: str,
|
|
@@ -398,20 +479,19 @@ class EdataDB:
|
|
|
398
479
|
async with AsyncSession(self.engine) as session:
|
|
399
480
|
unique_map = {item.datetime: item for item in bill}
|
|
400
481
|
unique = list(unique_map.values())
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
)
|
|
482
|
+
rows = [
|
|
483
|
+
{
|
|
484
|
+
"cups": cups,
|
|
485
|
+
"datetime": x.datetime,
|
|
486
|
+
"type": type_,
|
|
487
|
+
"confhash": confhash,
|
|
488
|
+
"complete": complete,
|
|
489
|
+
"data": x,
|
|
490
|
+
}
|
|
411
491
|
for x in unique
|
|
412
492
|
]
|
|
413
493
|
await self._add_or_update_many(
|
|
414
|
-
session,
|
|
494
|
+
session, BillModel, rows, override=["complete", "confhash"]
|
|
415
495
|
)
|
|
416
496
|
|
|
417
497
|
async def clear_bills(self, cups: str, since: datetime | None = None) -> None:
|
|
@@ -440,6 +520,54 @@ class EdataDB:
|
|
|
440
520
|
result = await session.exec(q.list_contract(cups))
|
|
441
521
|
return result.all()
|
|
442
522
|
|
|
523
|
+
async def _list_data(self, query: SelectOfScalar, model: type[SQLModel]) -> list:
|
|
524
|
+
"""Return only the ``data`` payload of the rows selected by ``query``.
|
|
525
|
+
|
|
526
|
+
Skips building an ORM instance per row, which nearly halves the cost of
|
|
527
|
+
reading a month of hourly records.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
await self._ensure_tables()
|
|
531
|
+
async with AsyncSession(self.engine) as session:
|
|
532
|
+
result = await session.exec(
|
|
533
|
+
query.with_only_columns(model.data) # type: ignore[attr-defined]
|
|
534
|
+
)
|
|
535
|
+
return list(result.all())
|
|
536
|
+
|
|
537
|
+
async def list_energy_data(
|
|
538
|
+
self,
|
|
539
|
+
cups: str,
|
|
540
|
+
date_from: datetime | None = None,
|
|
541
|
+
date_to: datetime | None = None,
|
|
542
|
+
) -> list[Energy]:
|
|
543
|
+
"""List the energy data (without row metadata)."""
|
|
544
|
+
|
|
545
|
+
return await self._list_data(
|
|
546
|
+
q.list_energy(cups, date_from, date_to), EnergyModel
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
async def list_pvpc_data(
|
|
550
|
+
self,
|
|
551
|
+
date_from: datetime | None = None,
|
|
552
|
+
date_to: datetime | None = None,
|
|
553
|
+
) -> list[EnergyPrice]:
|
|
554
|
+
"""List the pvpc data (without row metadata)."""
|
|
555
|
+
|
|
556
|
+
return await self._list_data(q.list_pvpc(date_from, date_to), PVPCModel)
|
|
557
|
+
|
|
558
|
+
async def list_bill_data(
|
|
559
|
+
self,
|
|
560
|
+
cups: str,
|
|
561
|
+
type_: typing.Literal["hour", "day", "month"],
|
|
562
|
+
date_from: datetime | None = None,
|
|
563
|
+
date_to: datetime | None = None,
|
|
564
|
+
) -> list[Bill]:
|
|
565
|
+
"""List the bill data (without row metadata)."""
|
|
566
|
+
|
|
567
|
+
return await self._list_data(
|
|
568
|
+
q.list_bill(cups, type_, date_from, date_to), BillModel
|
|
569
|
+
)
|
|
570
|
+
|
|
443
571
|
async def list_energy(
|
|
444
572
|
self,
|
|
445
573
|
cups: str,
|
edata/database/models.py
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import typing
|
|
2
2
|
from datetime import datetime as dt
|
|
3
3
|
|
|
4
|
-
from sqlmodel import AutoString, Column, Field, SQLModel, UniqueConstraint
|
|
4
|
+
from sqlmodel import AutoString, Column, Field, Index, SQLModel, UniqueConstraint
|
|
5
5
|
|
|
6
6
|
from edata.database.utils import PydanticJSON
|
|
7
7
|
from edata.models import Bill, Contract, Energy, EnergyPrice, Power, Statistics, Supply
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
def _now() -> dt:
|
|
11
|
+
"""Return the current time.
|
|
12
|
+
|
|
13
|
+
A plain Python function on purpose: with the ``dt.now`` builtin as
|
|
14
|
+
``default_factory`` pydantic re-inspects its signature on every instance,
|
|
15
|
+
which made building each row ~6x slower.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
return dt.now()
|
|
19
|
+
|
|
20
|
+
|
|
10
21
|
class SupplyModel(SQLModel, table=True):
|
|
11
22
|
|
|
12
23
|
__tablename__ = "supply" # type: ignore
|
|
@@ -16,9 +27,9 @@ class SupplyModel(SQLModel, table=True):
|
|
|
16
27
|
cups: str = Field(default=None, primary_key=True)
|
|
17
28
|
data: Supply = Field(sa_column=Column(PydanticJSON(Supply)))
|
|
18
29
|
version: int = Field(default=1)
|
|
19
|
-
created_at: dt = Field(default_factory=
|
|
30
|
+
created_at: dt = Field(default_factory=_now, nullable=False)
|
|
20
31
|
updated_at: dt = Field(
|
|
21
|
-
default_factory=
|
|
32
|
+
default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
|
|
22
33
|
)
|
|
23
34
|
|
|
24
35
|
|
|
@@ -37,9 +48,9 @@ class ContractModel(SQLModel, table=True):
|
|
|
37
48
|
data: Contract = Field(sa_column=Column(PydanticJSON(Contract)))
|
|
38
49
|
|
|
39
50
|
version: int = Field(default=1)
|
|
40
|
-
created_at: dt = Field(default_factory=
|
|
51
|
+
created_at: dt = Field(default_factory=_now, nullable=False)
|
|
41
52
|
updated_at: dt = Field(
|
|
42
|
-
default_factory=
|
|
53
|
+
default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
|
|
43
54
|
)
|
|
44
55
|
|
|
45
56
|
|
|
@@ -51,6 +62,9 @@ class EnergyModel(SQLModel, table=True):
|
|
|
51
62
|
UniqueConstraint(
|
|
52
63
|
"cups", "delta_h", "datetime", name="uq_energy_cups_delta_datetime"
|
|
53
64
|
),
|
|
65
|
+
# serves the per-cups range scans and "latest record" lookups without
|
|
66
|
+
# sorting the whole table
|
|
67
|
+
Index("ix_energy_cups_datetime", "cups", "datetime"),
|
|
54
68
|
{"extend_existing": True},
|
|
55
69
|
)
|
|
56
70
|
|
|
@@ -62,9 +76,9 @@ class EnergyModel(SQLModel, table=True):
|
|
|
62
76
|
data: Energy = Field(sa_column=Column(PydanticJSON(Energy)))
|
|
63
77
|
|
|
64
78
|
version: int = Field(default=1)
|
|
65
|
-
created_at: dt = Field(default_factory=
|
|
79
|
+
created_at: dt = Field(default_factory=_now, nullable=False)
|
|
66
80
|
updated_at: dt = Field(
|
|
67
|
-
default_factory=
|
|
81
|
+
default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
|
|
68
82
|
)
|
|
69
83
|
|
|
70
84
|
|
|
@@ -84,9 +98,9 @@ class PowerModel(SQLModel, table=True):
|
|
|
84
98
|
data: Power = Field(sa_column=Column(PydanticJSON(Power)))
|
|
85
99
|
|
|
86
100
|
version: int = Field(default=1)
|
|
87
|
-
created_at: dt = Field(default_factory=
|
|
101
|
+
created_at: dt = Field(default_factory=_now, nullable=False)
|
|
88
102
|
updated_at: dt = Field(
|
|
89
|
-
default_factory=
|
|
103
|
+
default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
|
|
90
104
|
)
|
|
91
105
|
|
|
92
106
|
|
|
@@ -112,9 +126,9 @@ class StatisticsModel(SQLModel, table=True):
|
|
|
112
126
|
data: Statistics = Field(sa_column=Column(PydanticJSON(Statistics)))
|
|
113
127
|
|
|
114
128
|
version: int = Field(default=1)
|
|
115
|
-
created_at: dt = Field(default_factory=
|
|
129
|
+
created_at: dt = Field(default_factory=_now, nullable=False)
|
|
116
130
|
updated_at: dt = Field(
|
|
117
|
-
default_factory=
|
|
131
|
+
default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
|
|
118
132
|
)
|
|
119
133
|
|
|
120
134
|
|
|
@@ -127,9 +141,9 @@ class PVPCModel(SQLModel, table=True):
|
|
|
127
141
|
data: EnergyPrice = Field(sa_column=Column(PydanticJSON(EnergyPrice)))
|
|
128
142
|
|
|
129
143
|
version: int = Field(default=1)
|
|
130
|
-
created_at: dt = Field(default_factory=
|
|
144
|
+
created_at: dt = Field(default_factory=_now, nullable=False)
|
|
131
145
|
updated_at: dt = Field(
|
|
132
|
-
default_factory=
|
|
146
|
+
default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
|
|
133
147
|
)
|
|
134
148
|
|
|
135
149
|
|
|
@@ -156,7 +170,7 @@ class BillModel(SQLModel, table=True):
|
|
|
156
170
|
data: Bill = Field(sa_column=Column(PydanticJSON(Bill)))
|
|
157
171
|
|
|
158
172
|
version: int = Field(default=1)
|
|
159
|
-
created_at: dt = Field(default_factory=
|
|
173
|
+
created_at: dt = Field(default_factory=_now, nullable=False)
|
|
160
174
|
updated_at: dt = Field(
|
|
161
|
-
default_factory=
|
|
175
|
+
default_factory=_now, nullable=False, sa_column_kwargs={"onupdate": dt.now}
|
|
162
176
|
)
|
edata/database/queries.py
CHANGED
|
@@ -118,6 +118,18 @@ def list_power(
|
|
|
118
118
|
return query
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
def get_last_power(
|
|
122
|
+
cups: str,
|
|
123
|
+
) -> SelectOfScalar[PowerModel]:
|
|
124
|
+
"""Query that selects the most recent power record."""
|
|
125
|
+
|
|
126
|
+
query = select(PowerModel).where(PowerModel.cups == cups)
|
|
127
|
+
query = query.order_by(desc(PowerModel.datetime))
|
|
128
|
+
query = query.limit(1)
|
|
129
|
+
|
|
130
|
+
return query
|
|
131
|
+
|
|
132
|
+
|
|
121
133
|
# Queries for "statistics" table
|
|
122
134
|
def get_statistics(
|
|
123
135
|
cups: str, type_: typing.Literal["day", "month"], datetime_: datetime | None = None
|
edata/providers/datadis.py
CHANGED
|
@@ -78,7 +78,15 @@ class DatadisConnector:
|
|
|
78
78
|
password: str,
|
|
79
79
|
enable_smart_fetch: bool = True,
|
|
80
80
|
storage_path: str | None = None,
|
|
81
|
+
session: aiohttp.ClientSession | None = None,
|
|
81
82
|
) -> None:
|
|
83
|
+
"""Init the connector.
|
|
84
|
+
|
|
85
|
+
``session`` lets the caller share a long-lived aiohttp session (e.g. Home
|
|
86
|
+
Assistant's) so requests reuse pooled TLS connections; without it a
|
|
87
|
+
short-lived session is opened per request.
|
|
88
|
+
"""
|
|
89
|
+
self._session = session
|
|
82
90
|
self._usr = username
|
|
83
91
|
self._pwd = password
|
|
84
92
|
self._token = {}
|
|
@@ -96,6 +104,15 @@ class DatadisConnector:
|
|
|
96
104
|
os.makedirs(self._recent_cache_dir, exist_ok=True)
|
|
97
105
|
self._cache = diskcache.Cache(self._recent_cache_dir)
|
|
98
106
|
|
|
107
|
+
@contextlib.asynccontextmanager
|
|
108
|
+
async def _client(self) -> typing.AsyncIterator[aiohttp.ClientSession]:
|
|
109
|
+
"""Yield the shared session, or a short-lived one when none was given."""
|
|
110
|
+
if self._session is not None:
|
|
111
|
+
yield self._session
|
|
112
|
+
else:
|
|
113
|
+
async with aiohttp.ClientSession() as session:
|
|
114
|
+
yield session
|
|
115
|
+
|
|
99
116
|
def _get_hash(self, item: str) -> str:
|
|
100
117
|
"""Return a hash."""
|
|
101
118
|
return hashlib.md5(item.encode()).hexdigest()
|
|
@@ -127,7 +144,7 @@ class DatadisConnector:
|
|
|
127
144
|
_LOGGER.debug("No token found, fetching a new one")
|
|
128
145
|
is_valid_token = False
|
|
129
146
|
timeout = aiohttp.ClientTimeout(total=TIMEOUT)
|
|
130
|
-
async with
|
|
147
|
+
async with self._client() as session:
|
|
131
148
|
try:
|
|
132
149
|
async with session.post(
|
|
133
150
|
URL_TOKEN,
|
|
@@ -135,6 +152,7 @@ class DatadisConnector:
|
|
|
135
152
|
TOKEN_USERNAME: self._usr,
|
|
136
153
|
TOKEN_PASSWD: self._pwd,
|
|
137
154
|
},
|
|
155
|
+
timeout=timeout,
|
|
138
156
|
) as response:
|
|
139
157
|
text = await response.text()
|
|
140
158
|
if response.status == 200:
|
|
@@ -209,12 +227,14 @@ class DatadisConnector:
|
|
|
209
227
|
if self._token.get("headers"):
|
|
210
228
|
headers.update(self._token["headers"])
|
|
211
229
|
timeout = aiohttp.ClientTimeout(total=TIMEOUT)
|
|
212
|
-
async with
|
|
230
|
+
async with self._client() as session:
|
|
213
231
|
async with session.get(
|
|
214
232
|
url + params,
|
|
215
233
|
headers=headers,
|
|
234
|
+
timeout=timeout,
|
|
216
235
|
) as reply:
|
|
217
|
-
text
|
|
236
|
+
# the body is only needed as text to log errors; decoding
|
|
237
|
+
# it here as well would decode every (large) payload twice
|
|
218
238
|
if reply.status == 200:
|
|
219
239
|
try:
|
|
220
240
|
json_data = await reply.json(content_type=None)
|
|
@@ -247,7 +267,7 @@ class DatadisConnector:
|
|
|
247
267
|
_LOGGER.warning(
|
|
248
268
|
"%s with message '%s'",
|
|
249
269
|
reply.status,
|
|
250
|
-
text,
|
|
270
|
+
await reply.text(),
|
|
251
271
|
)
|
|
252
272
|
if not ignore_cache:
|
|
253
273
|
await asyncio.to_thread(self._set_cache, url + params)
|
|
@@ -256,7 +276,7 @@ class DatadisConnector:
|
|
|
256
276
|
_LOGGER.warning(
|
|
257
277
|
"%s with message '%s'. %s. %s",
|
|
258
278
|
reply.status,
|
|
259
|
-
text,
|
|
279
|
+
await reply.text(),
|
|
260
280
|
"Query temporary disabled",
|
|
261
281
|
"Future 500 code errors for this query will be silenced until restart",
|
|
262
282
|
)
|
edata/providers/redata.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""A REData API connector"""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
import datetime as dt
|
|
5
6
|
import logging
|
|
7
|
+
import typing
|
|
6
8
|
|
|
7
9
|
import aiohttp
|
|
8
10
|
from dateutil import parser
|
|
@@ -26,8 +28,23 @@ class REDataConnector:
|
|
|
26
28
|
|
|
27
29
|
def __init__(
|
|
28
30
|
self,
|
|
31
|
+
session: aiohttp.ClientSession | None = None,
|
|
29
32
|
) -> None:
|
|
30
|
-
"""Init method for REDataConnector
|
|
33
|
+
"""Init method for REDataConnector
|
|
34
|
+
|
|
35
|
+
``session`` lets the caller share a long-lived aiohttp session; without
|
|
36
|
+
it a short-lived session is opened per request.
|
|
37
|
+
"""
|
|
38
|
+
self._session = session
|
|
39
|
+
|
|
40
|
+
@contextlib.asynccontextmanager
|
|
41
|
+
async def _client(self) -> typing.AsyncIterator[aiohttp.ClientSession]:
|
|
42
|
+
"""Yield the shared session, or a short-lived one when none was given."""
|
|
43
|
+
if self._session is not None:
|
|
44
|
+
yield self._session
|
|
45
|
+
else:
|
|
46
|
+
async with aiohttp.ClientSession() as session:
|
|
47
|
+
yield session
|
|
31
48
|
|
|
32
49
|
async def async_get_realtime_prices(
|
|
33
50
|
self, dt_from: dt.datetime, dt_to: dt.datetime, is_ceuta_melilla: bool = False
|
|
@@ -41,10 +58,9 @@ class REDataConnector:
|
|
|
41
58
|
data = []
|
|
42
59
|
_LOGGER.info("GET %s", url)
|
|
43
60
|
timeout = aiohttp.ClientTimeout(total=REQUESTS_TIMEOUT)
|
|
44
|
-
async with
|
|
61
|
+
async with self._client() as session:
|
|
45
62
|
try:
|
|
46
|
-
async with session.get(url) as res:
|
|
47
|
-
text = await res.text()
|
|
63
|
+
async with session.get(url, timeout=timeout) as res:
|
|
48
64
|
if res.status == 200:
|
|
49
65
|
try:
|
|
50
66
|
res_json = await res.json()
|
|
@@ -53,7 +69,7 @@ class REDataConnector:
|
|
|
53
69
|
_LOGGER.error(
|
|
54
70
|
"%s returned a malformed response: %s ",
|
|
55
71
|
url,
|
|
56
|
-
text,
|
|
72
|
+
await res.text(),
|
|
57
73
|
)
|
|
58
74
|
return data
|
|
59
75
|
for element in res_list:
|
|
@@ -70,7 +86,7 @@ class REDataConnector:
|
|
|
70
86
|
_LOGGER.error(
|
|
71
87
|
"%s returned %s with code %s",
|
|
72
88
|
url,
|
|
73
|
-
text,
|
|
89
|
+
await res.text(),
|
|
74
90
|
res.status,
|
|
75
91
|
)
|
|
76
92
|
except Exception as e:
|
edata/services/bill_service.py
CHANGED
|
@@ -44,8 +44,7 @@ class BillService:
|
|
|
44
44
|
) -> list[Bill]:
|
|
45
45
|
"""Return the list of bills."""
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
return [x.data for x in res]
|
|
47
|
+
return await self.db.list_bill_data(self._cups, type_, start, end)
|
|
49
48
|
|
|
50
49
|
async def update(
|
|
51
50
|
self,
|
|
@@ -234,27 +233,39 @@ class BillService:
|
|
|
234
233
|
)
|
|
235
234
|
data = await self.get_bills(month, month_end, "hour")
|
|
236
235
|
|
|
236
|
+
# aggregate the whole month in a single pass (and a single thread hop)
|
|
237
|
+
day_bills, month_bills = await asyncio.to_thread(
|
|
238
|
+
self._compile_day_and_month, data
|
|
239
|
+
)
|
|
240
|
+
by_day = {x.datetime: x for x in day_bills}
|
|
241
|
+
|
|
242
|
+
done: dict[bool, list[Bill]] = {True: [], False: []}
|
|
237
243
|
for day in ledger.days_in(month):
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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)
|
|
244
|
+
bill = by_day.get(day)
|
|
245
|
+
final = is_day_final(day, bill.delta_h if bill else 0.0, now)
|
|
246
|
+
if bill:
|
|
247
|
+
done[final].append(bill)
|
|
247
248
|
ledger.resolve_day(day, final=final)
|
|
249
|
+
for complete, bills in done.items():
|
|
250
|
+
if bills:
|
|
251
|
+
await self.db.add_bill_list(
|
|
252
|
+
self._cups, "day", "mix", complete, bills
|
|
253
|
+
)
|
|
248
254
|
|
|
249
|
-
|
|
250
|
-
self._compile_statistics, data, get_month
|
|
251
|
-
)
|
|
252
|
-
delta_h = month_stat[0].delta_h if month_stat else 0.0
|
|
255
|
+
delta_h = month_bills[0].delta_h if month_bills else 0.0
|
|
253
256
|
final = is_month_final(month, delta_h, now)
|
|
254
|
-
if
|
|
255
|
-
await self.db.add_bill(self._cups, "month",
|
|
257
|
+
if month_bills:
|
|
258
|
+
await self.db.add_bill(self._cups, "month", month_bills[0], "mix", final)
|
|
256
259
|
ledger.resolve_month(month, final=final)
|
|
257
260
|
|
|
261
|
+
def _compile_day_and_month(self, data: list[Bill]) -> tuple[list[Bill], list[Bill]]:
|
|
262
|
+
"""Return the daily and monthly aggregates of a month of hourly bills."""
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
self._compile_statistics(data, get_day),
|
|
266
|
+
self._compile_statistics(data, get_month),
|
|
267
|
+
)
|
|
268
|
+
|
|
258
269
|
def _compile_statistics(
|
|
259
270
|
self,
|
|
260
271
|
data: list[Bill],
|
|
@@ -422,15 +433,13 @@ class BillService:
|
|
|
422
433
|
self, start: datetime | None = None, end: datetime | None = None
|
|
423
434
|
) -> list[Energy]:
|
|
424
435
|
"""Get energy."""
|
|
425
|
-
|
|
426
|
-
return [x.data for x in res]
|
|
436
|
+
return await self.db.list_energy_data(self._cups, start, end)
|
|
427
437
|
|
|
428
438
|
async def _get_pvpc(
|
|
429
439
|
self, start: datetime | None = None, end: datetime | None = None
|
|
430
440
|
) -> list[EnergyPrice]:
|
|
431
441
|
"""Get PVPC."""
|
|
432
|
-
|
|
433
|
-
return [x.data for x in res]
|
|
442
|
+
return await self.db.list_pvpc_data(start, end)
|
|
434
443
|
|
|
435
444
|
async def _get_last_bill_dt(self) -> datetime | None:
|
|
436
445
|
"""Return the timestamp of the latest bill record."""
|
edata/services/data_service.py
CHANGED
|
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from tempfile import gettempdir
|
|
10
10
|
|
|
11
|
+
import aiohttp
|
|
11
12
|
from dateutil import relativedelta
|
|
12
13
|
|
|
13
14
|
from edata.core.completion import PendingLedger, is_day_final, is_month_final
|
|
@@ -37,12 +38,13 @@ class DataService:
|
|
|
37
38
|
datadis_pwd: str,
|
|
38
39
|
storage_path: str,
|
|
39
40
|
datadis_authorized_nif: str | None = None,
|
|
41
|
+
session: aiohttp.ClientSession | None = None,
|
|
40
42
|
) -> None:
|
|
41
43
|
|
|
42
44
|
self.datadis = DatadisConnector(
|
|
43
|
-
datadis_user, datadis_pwd, storage_path=storage_path
|
|
45
|
+
datadis_user, datadis_pwd, storage_path=storage_path, session=session
|
|
44
46
|
)
|
|
45
|
-
self.redata = REDataConnector()
|
|
47
|
+
self.redata = REDataConnector(session=session)
|
|
46
48
|
|
|
47
49
|
# params
|
|
48
50
|
self._cups = cups
|
|
@@ -86,8 +88,7 @@ class DataService:
|
|
|
86
88
|
) -> list[Energy]:
|
|
87
89
|
"""Return a list of energy records for the selected cups."""
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
return [x.data for x in res]
|
|
91
|
+
return await self.db.list_energy_data(self._cups, start, end)
|
|
91
92
|
|
|
92
93
|
async def get_power(
|
|
93
94
|
self, start: datetime | None = None, end: datetime | None = None
|
|
@@ -102,8 +103,7 @@ class DataService:
|
|
|
102
103
|
) -> list[EnergyPrice]:
|
|
103
104
|
"""Return a list of pvpc records (energy prices) for the selected cups."""
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
return [x.data for x in res]
|
|
106
|
+
return await self.db.list_pvpc_data(start, end)
|
|
107
107
|
|
|
108
108
|
async def get_statistics(
|
|
109
109
|
self,
|
|
@@ -164,6 +164,7 @@ class DataService:
|
|
|
164
164
|
supply.date_end,
|
|
165
165
|
)
|
|
166
166
|
|
|
167
|
+
explicit_start = start_date is not None
|
|
167
168
|
if not start_date:
|
|
168
169
|
start_date = supply.date_start
|
|
169
170
|
_LOGGER.debug(
|
|
@@ -218,8 +219,12 @@ class DataService:
|
|
|
218
219
|
# we have no data yet, fetch from start
|
|
219
220
|
await self.update_energy(start_date, end_date)
|
|
220
221
|
|
|
221
|
-
# update power records
|
|
222
|
-
|
|
222
|
+
# update power records; unless a start is forced, only refetch from the
|
|
223
|
+
# month of the latest stored peak instead of the whole supply history
|
|
224
|
+
power_start = start_date
|
|
225
|
+
if not explicit_start and (last_power_dt := await self._get_last_power_dt()):
|
|
226
|
+
power_start = max(start_date, get_month(last_power_dt))
|
|
227
|
+
await self.update_power(power_start, end_date)
|
|
223
228
|
|
|
224
229
|
# fetch pvpc data
|
|
225
230
|
await self.update_pvpc(start_date, end_date)
|
|
@@ -394,27 +399,41 @@ class DataService:
|
|
|
394
399
|
)
|
|
395
400
|
data = await self.get_energy(month, month_end)
|
|
396
401
|
|
|
402
|
+
# aggregate the whole month in a single pass (and a single thread hop)
|
|
403
|
+
day_stats, month_stats = await asyncio.to_thread(
|
|
404
|
+
self._compile_day_and_month, data
|
|
405
|
+
)
|
|
406
|
+
by_day = {x.datetime: x for x in day_stats}
|
|
407
|
+
|
|
408
|
+
done: dict[bool, list[Statistics]] = {True: [], False: []}
|
|
397
409
|
for day in ledger.days_in(month):
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
stat = await asyncio.to_thread(
|
|
401
|
-
self._compile_statistics, day_data, get_day
|
|
402
|
-
)
|
|
403
|
-
delta_h = stat[0].delta_h if stat else 0.0
|
|
404
|
-
final = is_day_final(day, delta_h, now)
|
|
410
|
+
stat = by_day.get(day)
|
|
411
|
+
final = is_day_final(day, stat.delta_h if stat else 0.0, now)
|
|
405
412
|
if stat:
|
|
406
|
-
|
|
413
|
+
done[final].append(stat)
|
|
407
414
|
ledger.resolve_day(day, final=final)
|
|
415
|
+
for complete, stats in done.items():
|
|
416
|
+
if stats:
|
|
417
|
+
await self.db.add_statistics_list(
|
|
418
|
+
self._cups, "day", complete, stats
|
|
419
|
+
)
|
|
408
420
|
|
|
409
|
-
|
|
410
|
-
self._compile_statistics, data, get_month
|
|
411
|
-
)
|
|
412
|
-
delta_h = month_stat[0].delta_h if month_stat else 0.0
|
|
421
|
+
delta_h = month_stats[0].delta_h if month_stats else 0.0
|
|
413
422
|
final = is_month_final(month, delta_h, now)
|
|
414
|
-
if
|
|
415
|
-
await self.db.add_statistics(self._cups, "month",
|
|
423
|
+
if month_stats:
|
|
424
|
+
await self.db.add_statistics(self._cups, "month", month_stats[0], final)
|
|
416
425
|
ledger.resolve_month(month, final=final)
|
|
417
426
|
|
|
427
|
+
def _compile_day_and_month(
|
|
428
|
+
self, data: list[Energy]
|
|
429
|
+
) -> tuple[list[Statistics], list[Statistics]]:
|
|
430
|
+
"""Return the daily and monthly aggregates of a month of energy data."""
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
self._compile_statistics(data, get_day),
|
|
434
|
+
self._compile_statistics(data, get_month),
|
|
435
|
+
)
|
|
436
|
+
|
|
418
437
|
def _compile_statistics(
|
|
419
438
|
self,
|
|
420
439
|
data: list[Energy],
|
|
@@ -491,6 +510,13 @@ class DataService:
|
|
|
491
510
|
if last_record:
|
|
492
511
|
return last_record.datetime
|
|
493
512
|
|
|
513
|
+
async def _get_last_power_dt(self) -> datetime | None:
|
|
514
|
+
"""Return the timestamp of the latest power record."""
|
|
515
|
+
|
|
516
|
+
last_record = await self.db.get_last_power(self._cups)
|
|
517
|
+
if last_record:
|
|
518
|
+
return last_record.datetime
|
|
519
|
+
|
|
494
520
|
async def _get_last_pvpc_dt(self) -> datetime | None:
|
|
495
521
|
"""Return the timestamp of the latest pvpc record."""
|
|
496
522
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Tests for the database controller."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import pytest_asyncio
|
|
9
|
+
|
|
10
|
+
from edata.database.controller import EdataDB
|
|
11
|
+
from edata.models import Bill, Energy, Supply
|
|
12
|
+
|
|
13
|
+
CUPS = "ESXXXXXXXXXXXXXXXXTEST"
|
|
14
|
+
START = datetime(2024, 1, 1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _reset_singleton() -> None:
|
|
18
|
+
EdataDB._instance = None
|
|
19
|
+
EdataDB._engine = None
|
|
20
|
+
EdataDB._db_url = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest_asyncio.fixture
|
|
24
|
+
async def db(tmp_path) -> AsyncIterator[EdataDB]:
|
|
25
|
+
"""An EdataDB on an isolated on-disk database with one supply."""
|
|
26
|
+
_reset_singleton()
|
|
27
|
+
database = EdataDB(str(tmp_path / "edata.db"))
|
|
28
|
+
await database.add_supply(
|
|
29
|
+
Supply(
|
|
30
|
+
cups=CUPS,
|
|
31
|
+
date_start=START,
|
|
32
|
+
date_end=START + timedelta(days=30),
|
|
33
|
+
address=None,
|
|
34
|
+
postal_code=None,
|
|
35
|
+
province=None,
|
|
36
|
+
municipality=None,
|
|
37
|
+
distributor=None,
|
|
38
|
+
point_type=5,
|
|
39
|
+
distributor_code="2",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
yield database
|
|
43
|
+
if EdataDB._engine is not None:
|
|
44
|
+
await EdataDB._engine.dispose()
|
|
45
|
+
_reset_singleton()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _energy(hours: int, kwh: float) -> list[Energy]:
|
|
49
|
+
return [
|
|
50
|
+
Energy(
|
|
51
|
+
datetime=START + timedelta(hours=h),
|
|
52
|
+
delta_h=1.0,
|
|
53
|
+
consumption_kwh=kwh,
|
|
54
|
+
real=True,
|
|
55
|
+
)
|
|
56
|
+
for h in range(hours)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_add_energy_list_upserts(db: EdataDB) -> None:
|
|
62
|
+
await db.add_energy_list(CUPS, _energy(24, 0.1))
|
|
63
|
+
before = {x.datetime: x for x in await db.list_energy(CUPS)}
|
|
64
|
+
|
|
65
|
+
# overlapping batch: first 12 hours unchanged, last 12 changed, 12 new
|
|
66
|
+
await db.add_energy_list(CUPS, _energy(12, 0.1) + _energy(36, 0.5)[12:])
|
|
67
|
+
after = {x.datetime: x for x in await db.list_energy(CUPS)}
|
|
68
|
+
|
|
69
|
+
assert len(after) == 36
|
|
70
|
+
for h in range(36):
|
|
71
|
+
dt = START + timedelta(hours=h)
|
|
72
|
+
assert after[dt].data.consumption_kwh == (0.1 if h < 12 else 0.5)
|
|
73
|
+
if h < 12:
|
|
74
|
+
# unchanged rows are not rewritten
|
|
75
|
+
assert after[dt].id == before[dt].id
|
|
76
|
+
assert after[dt].updated_at == before[dt].updated_at
|
|
77
|
+
elif h < 24:
|
|
78
|
+
assert after[dt].id == before[dt].id
|
|
79
|
+
assert after[dt].updated_at > before[dt].updated_at
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_add_bill_list_applies_overrides(db: EdataDB) -> None:
|
|
84
|
+
bills = [Bill(datetime=START + timedelta(hours=h), delta_h=1) for h in range(3)]
|
|
85
|
+
await db.add_bill_list(CUPS, "hour", "hash-a", False, bills)
|
|
86
|
+
|
|
87
|
+
# same data, only the override columns change
|
|
88
|
+
await db.add_bill_list(CUPS, "hour", "hash-b", True, bills)
|
|
89
|
+
|
|
90
|
+
stored = await db.list_bill(CUPS, "hour")
|
|
91
|
+
assert len(stored) == 3
|
|
92
|
+
assert all(x.complete and x.confhash == "hash-b" for x in stored)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_concurrent_first_calls_do_not_race_index_creation(db: EdataDB) -> None:
|
|
97
|
+
# simulate a database created before the index existed, opened fresh
|
|
98
|
+
async with db.engine.begin() as conn:
|
|
99
|
+
await conn.exec_driver_sql("DROP INDEX ix_energy_cups_datetime")
|
|
100
|
+
db._tables_initialized = False
|
|
101
|
+
|
|
102
|
+
await asyncio.gather(*(db.get_last_energy(CUPS) for _ in range(10)))
|
|
103
|
+
|
|
104
|
+
async with db.engine.connect() as conn:
|
|
105
|
+
result = await conn.exec_driver_sql(
|
|
106
|
+
"SELECT name FROM sqlite_master WHERE name='ix_energy_cups_datetime'"
|
|
107
|
+
)
|
|
108
|
+
assert result.first() is not None
|
|
@@ -4,6 +4,8 @@ import datetime
|
|
|
4
4
|
import os
|
|
5
5
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
7
9
|
from edata.providers.datadis import DatadisConnector
|
|
8
10
|
|
|
9
11
|
MOCK_USERNAME = "USERNAME"
|
|
@@ -347,3 +349,26 @@ def test_get_supplies_optional_fields_none(mock_token, mock_get, snapshot):
|
|
|
347
349
|
mock_get.return_value.__aenter__.return_value = mock_response
|
|
348
350
|
connector = DatadisConnector(MOCK_USERNAME, MOCK_PASSWORD)
|
|
349
351
|
assert connector.get_supplies() == snapshot
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@pytest.mark.asyncio
|
|
355
|
+
@patch.object(
|
|
356
|
+
DatadisConnector, "_async_get_token", new_callable=AsyncMock, return_value=True
|
|
357
|
+
)
|
|
358
|
+
async def test_shared_session_is_reused(mock_token, tmp_path, snapshot):
|
|
359
|
+
"""A caller-provided session serves the requests and is left open."""
|
|
360
|
+
mock_response = MagicMock()
|
|
361
|
+
mock_response.status = 200
|
|
362
|
+
mock_response.json = AsyncMock(return_value=SUPPLIES_RESPONSE)
|
|
363
|
+
session = MagicMock()
|
|
364
|
+
session.get.return_value.__aenter__.return_value = mock_response
|
|
365
|
+
session.close = AsyncMock()
|
|
366
|
+
|
|
367
|
+
connector = DatadisConnector(
|
|
368
|
+
MOCK_USERNAME, MOCK_PASSWORD, storage_path=str(tmp_path), session=session
|
|
369
|
+
)
|
|
370
|
+
supplies = await connector.async_get_supplies()
|
|
371
|
+
|
|
372
|
+
assert supplies == snapshot
|
|
373
|
+
session.get.assert_called_once()
|
|
374
|
+
session.close.assert_not_awaited()
|
edata/tests/test_services.py
CHANGED
|
@@ -8,7 +8,7 @@ import pytest
|
|
|
8
8
|
import pytest_asyncio
|
|
9
9
|
from syrupy.assertion import SnapshotAssertion
|
|
10
10
|
|
|
11
|
-
from edata.core.utils import get_day
|
|
11
|
+
from edata.core.utils import get_day, get_month
|
|
12
12
|
from edata.models.bill import BillingRules
|
|
13
13
|
from edata.models.data import Energy, Power
|
|
14
14
|
from edata.models.supply import Contract, Supply
|
|
@@ -231,3 +231,40 @@ async def test_update_pvpc_clamps_range_to_min_date(populated_data_service):
|
|
|
231
231
|
mock_fetch.assert_awaited_once()
|
|
232
232
|
called_start = mock_fetch.await_args.args[0]
|
|
233
233
|
assert called_start >= get_day(now) - timedelta(days=28)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.asyncio
|
|
237
|
+
async def test_update_power_is_incremental(populated_data_service, power):
|
|
238
|
+
ds = populated_data_service
|
|
239
|
+
last_power_dt = max(x.datetime for x in power)
|
|
240
|
+
|
|
241
|
+
with (
|
|
242
|
+
patch.object(ds, "update_energy", AsyncMock(return_value=True)),
|
|
243
|
+
patch.object(ds, "update_pvpc", AsyncMock(return_value=True)),
|
|
244
|
+
patch.object(ds, "update_statistics_incremental", AsyncMock()),
|
|
245
|
+
patch.object(ds, "update_power", AsyncMock(return_value=True)) as mock_power,
|
|
246
|
+
):
|
|
247
|
+
await ds.update()
|
|
248
|
+
# only refetch from the month of the latest stored peak
|
|
249
|
+
assert mock_power.await_args.args[0] == get_month(last_power_dt)
|
|
250
|
+
|
|
251
|
+
# an explicit start date still forces the full range
|
|
252
|
+
forced_start = datetime(2020, 1, 1)
|
|
253
|
+
await ds.update(start_date=forced_start)
|
|
254
|
+
assert mock_power.await_args.args[0] == forced_start
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_missing_indexes_are_created_on_existing_db(populated_data_service):
|
|
259
|
+
db = populated_data_service.db
|
|
260
|
+
|
|
261
|
+
async with db.engine.begin() as conn:
|
|
262
|
+
await conn.exec_driver_sql("DROP INDEX IF EXISTS ix_energy_cups_datetime")
|
|
263
|
+
db._tables_initialized = False
|
|
264
|
+
await db._ensure_tables()
|
|
265
|
+
|
|
266
|
+
async with db.engine.connect() as conn:
|
|
267
|
+
result = await conn.exec_driver_sql(
|
|
268
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='energy'"
|
|
269
|
+
)
|
|
270
|
+
assert "ix_energy_cups_datetime" in {row[0] for row in result}
|
|
File without changes
|
|
File without changes
|