e-data 2.0.0.dev125__tar.gz → 2.0.1__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.
- {e_data-2.0.0.dev125/e_data.egg-info → e_data-2.0.1}/PKG-INFO +2 -2
- {e_data-2.0.0.dev125 → e_data-2.0.1/e_data.egg-info}/PKG-INFO +2 -2
- {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/SOURCES.txt +5 -1
- {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/requires.txt +1 -1
- e_data-2.0.1/edata/core/completion.py +114 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/core/utils.py +38 -2
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/controller.py +43 -3
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/queries.py +26 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/providers/datadis.py +10 -3
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/services/bill_service.py +103 -110
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/services/data_service.py +73 -77
- e_data-2.0.1/edata/tests/test_completion.py +104 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/test_datadis_connector.py +62 -0
- e_data-2.0.1/edata/tests/test_incremental.py +108 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/test_services.py +22 -2
- e_data-2.0.1/edata/tests/test_utils.py +60 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/pyproject.toml +2 -2
- {e_data-2.0.0.dev125 → e_data-2.0.1}/LICENSE +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/MANIFEST.in +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/README.md +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/dependency_links.txt +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/top_level.txt +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/__init__.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/cli.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/core/__init__.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/core/const.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/__init__.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/models.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/utils.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/__init__.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/bill.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/data.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/supply.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/providers/__init__.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/providers/redata.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/__init__.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/test_redata_connector.py +0 -0
- {e_data-2.0.0.dev125 → e_data-2.0.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e-data
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
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
|
|
@@ -696,7 +696,7 @@ Requires-Dist: Jinja2<4,>=3.1
|
|
|
696
696
|
Requires-Dist: pydantic<3,>=2.10
|
|
697
697
|
Requires-Dist: python_dateutil<3,>=2.8
|
|
698
698
|
Requires-Dist: Requests<3,>=2.31
|
|
699
|
-
Requires-Dist: SQLAlchemy<3,>=2.0
|
|
699
|
+
Requires-Dist: SQLAlchemy[asyncio]<3,>=2.0
|
|
700
700
|
Requires-Dist: sqlmodel<0.1,>=0.0.22
|
|
701
701
|
Requires-Dist: typer<1,>=0.12
|
|
702
702
|
Requires-Dist: aiosqlite<1,>=0.21
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e-data
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
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
|
|
@@ -696,7 +696,7 @@ Requires-Dist: Jinja2<4,>=3.1
|
|
|
696
696
|
Requires-Dist: pydantic<3,>=2.10
|
|
697
697
|
Requires-Dist: python_dateutil<3,>=2.8
|
|
698
698
|
Requires-Dist: Requests<3,>=2.31
|
|
699
|
-
Requires-Dist: SQLAlchemy<3,>=2.0
|
|
699
|
+
Requires-Dist: SQLAlchemy[asyncio]<3,>=2.0
|
|
700
700
|
Requires-Dist: sqlmodel<0.1,>=0.0.22
|
|
701
701
|
Requires-Dist: typer<1,>=0.12
|
|
702
702
|
Requires-Dist: aiosqlite<1,>=0.21
|
|
@@ -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,6 +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
|
-
edata/tests/test_services.py
|
|
35
|
+
edata/tests/test_services.py
|
|
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)
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Collection of utilities."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
|
|
4
|
+
import typing
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from functools import lru_cache
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
9
|
+
from dateutil.relativedelta import relativedelta
|
|
7
10
|
import holidays
|
|
8
11
|
|
|
9
12
|
from edata.models.supply import Contract
|
|
@@ -19,10 +22,22 @@ TARIFF_BY_WEEKDAY = [[], [], [5, 6]]
|
|
|
19
22
|
_LOGGER = logging.getLogger(__name__)
|
|
20
23
|
|
|
21
24
|
|
|
25
|
+
@lru_cache(maxsize=None)
|
|
26
|
+
def _es_holidays(year: int) -> holidays.HolidayBase:
|
|
27
|
+
"""Return (and cache) Spanish holidays for a single year.
|
|
28
|
+
|
|
29
|
+
Building the holidays calendar is expensive; get_tariff is called once per
|
|
30
|
+
hourly record, so without this cache a full-history rebuild spends minutes
|
|
31
|
+
reconstructing the same handful of yearly calendars.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
return holidays.country_holidays("ES", years=year)
|
|
35
|
+
|
|
36
|
+
|
|
22
37
|
def get_tariff(dt: datetime) -> int:
|
|
23
38
|
"""Return the tariff for the selected datetime."""
|
|
24
39
|
|
|
25
|
-
hdays =
|
|
40
|
+
hdays = _es_holidays(dt.year)
|
|
26
41
|
hour = dt.hour
|
|
27
42
|
weekday = dt.weekday()
|
|
28
43
|
|
|
@@ -64,6 +79,27 @@ def get_day(dt: datetime) -> datetime:
|
|
|
64
79
|
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
65
80
|
|
|
66
81
|
|
|
82
|
+
def iter_month_windows(
|
|
83
|
+
start: datetime, end: datetime
|
|
84
|
+
) -> typing.Iterator[tuple[datetime, datetime]]:
|
|
85
|
+
"""Yield contiguous ``(window_start, window_end)`` pairs, one calendar month each.
|
|
86
|
+
|
|
87
|
+
Windows are non-overlapping and cover ``[start, end]`` inclusively; the first
|
|
88
|
+
starts at ``start`` and the last ends at ``end``. Iterating a month at a time
|
|
89
|
+
keeps the working set bounded to a single month regardless of history length.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
if end < start:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
win_start = start
|
|
96
|
+
while win_start <= end:
|
|
97
|
+
next_month = get_month(win_start) + relativedelta(months=1)
|
|
98
|
+
win_end = min(next_month - timedelta(microseconds=1), end)
|
|
99
|
+
yield win_start, win_end
|
|
100
|
+
win_start = next_month
|
|
101
|
+
|
|
102
|
+
|
|
67
103
|
def redacted_cups(cups: str) -> str:
|
|
68
104
|
"""Return an anonymized version of the cups identifier."""
|
|
69
105
|
|
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import typing
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
|
|
6
|
-
from sqlalchemy import Select, insert
|
|
6
|
+
from sqlalchemy import Select, event, insert
|
|
7
7
|
from sqlalchemy.exc import IntegrityError
|
|
8
8
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
9
9
|
from sqlmodel import SQLModel
|
|
@@ -28,6 +28,25 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
28
28
|
T = typing.TypeVar("T", bound=SQLModel)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
|
|
32
|
+
"""Tune SQLite for this write-heavy, single-writer workload.
|
|
33
|
+
|
|
34
|
+
WAL lets readers proceed while a write is in flight and avoids an fsync per
|
|
35
|
+
statement; synchronous=NORMAL is durable under WAL (only a crash mid-checkpoint
|
|
36
|
+
could lose the last transaction, and the data is re-fetchable from Datadis);
|
|
37
|
+
busy_timeout avoids spurious "database is locked" errors under concurrency.
|
|
38
|
+
journal_mode is persisted in the file; the others are per-connection.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
cursor = dbapi_connection.cursor()
|
|
42
|
+
try:
|
|
43
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
44
|
+
cursor.execute("PRAGMA synchronous=NORMAL")
|
|
45
|
+
cursor.execute("PRAGMA busy_timeout=5000")
|
|
46
|
+
finally:
|
|
47
|
+
cursor.close()
|
|
48
|
+
|
|
49
|
+
|
|
31
50
|
class EdataDB:
|
|
32
51
|
|
|
33
52
|
_instance = None
|
|
@@ -39,10 +58,11 @@ class EdataDB:
|
|
|
39
58
|
if cls._instance is None:
|
|
40
59
|
cls._instance = super().__new__(cls)
|
|
41
60
|
cls._db_url = db_url
|
|
42
|
-
|
|
43
|
-
# Ensure parent directory exists
|
|
61
|
+
# Ensure parent directory exists before the first connection is opened.
|
|
44
62
|
dir_path = os.path.dirname(os.path.abspath(sqlite_path))
|
|
45
63
|
os.makedirs(dir_path, exist_ok=True)
|
|
64
|
+
cls._engine = create_async_engine(db_url, future=True)
|
|
65
|
+
event.listen(cls._engine.sync_engine, "connect", _set_sqlite_pragmas)
|
|
46
66
|
cls._instance._tables_initialized = False
|
|
47
67
|
elif db_url != cls._db_url:
|
|
48
68
|
raise ValueError("EdataDB already initialized with a different db_url")
|
|
@@ -208,6 +228,26 @@ class EdataDB:
|
|
|
208
228
|
result = await session.exec(q.get_last_bill(cups))
|
|
209
229
|
return result.first()
|
|
210
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
|
+
|
|
211
251
|
async def add_contract(self, cups: str, contract: Contract) -> ContractModel | None:
|
|
212
252
|
"""Add or update a contract record."""
|
|
213
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
|
|
|
@@ -403,10 +403,17 @@ class DatadisConnector:
|
|
|
403
403
|
for i in response.get("timeCurve", []):
|
|
404
404
|
if "consumptionKWh" in i:
|
|
405
405
|
if all(k in i for k in GET_CONSUMPTION_DATA_MANDATORY_FIELDS):
|
|
406
|
-
|
|
406
|
+
raw_hour = int(i["time"].split(":")[0])
|
|
407
|
+
# Datadis delivers hours 1..24 (end-of-interval). A sporadic
|
|
408
|
+
# i-DE glitch emits an extra "00:00" row on a day already
|
|
409
|
+
# carrying its full 24 hours, so drop it -- 01:00..24:00
|
|
410
|
+
# already covers the day -- instead of remapping onto 23:00
|
|
411
|
+
# and overlapping that day's 24:00 slot.
|
|
412
|
+
if raw_hour == 0:
|
|
413
|
+
continue
|
|
407
414
|
date_as_dt = datetime.strptime(
|
|
408
|
-
|
|
409
|
-
)
|
|
415
|
+
i["date"], "%Y/%m/%d"
|
|
416
|
+
) + timedelta(hours=raw_hour - 1)
|
|
410
417
|
if not (start_date <= date_as_dt <= end_date):
|
|
411
418
|
continue # skip element if dt is out of range
|
|
412
419
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import calendar
|
|
3
2
|
import logging
|
|
4
3
|
import os
|
|
5
4
|
import typing
|
|
@@ -10,12 +9,14 @@ 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,
|
|
16
16
|
get_day,
|
|
17
17
|
get_month,
|
|
18
18
|
get_tariff,
|
|
19
|
+
iter_month_windows,
|
|
19
20
|
redacted_cups,
|
|
20
21
|
)
|
|
21
22
|
from edata.database.controller import EdataDB
|
|
@@ -107,35 +108,42 @@ class BillService:
|
|
|
107
108
|
# fetch contracts
|
|
108
109
|
contracts = await self._get_contracts()
|
|
109
110
|
|
|
110
|
-
# fetch and filter energy items
|
|
111
|
-
energy = await self._get_energy()
|
|
112
|
-
|
|
113
|
-
_LOGGER.debug("%s compiling missing hourly bills", self._scups)
|
|
114
111
|
if is_pvpc:
|
|
115
|
-
pvpc = await self._get_pvpc()
|
|
116
112
|
billing_rules = PVPCBillingRules(**billing_rules.model_dump())
|
|
117
|
-
bills = await asyncio.to_thread(
|
|
118
|
-
self.simulate_pvpc, contracts, energy, pvpc, billing_rules
|
|
119
|
-
)
|
|
120
113
|
confighash = f"pvpc-{hash(billing_rules.model_dump_json())}"
|
|
121
114
|
else:
|
|
122
|
-
bills = await asyncio.to_thread(
|
|
123
|
-
self.simulate_custom, contracts, energy, billing_rules
|
|
124
|
-
)
|
|
125
115
|
confighash = f"custom-{hash(billing_rules.model_dump_json())}"
|
|
126
116
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
117
|
+
# compile and persist hourly bills one month at a time so the energy
|
|
118
|
+
# (and simulated bills) held in memory stay bounded to a single month
|
|
119
|
+
for win_start, win_end in iter_month_windows(start, end):
|
|
120
|
+
energy = await self._get_energy(win_start, win_end)
|
|
121
|
+
if not energy:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
_LOGGER.debug(
|
|
125
|
+
"%s compiling hourly bills for %s..%s", self._scups, win_start, win_end
|
|
126
|
+
)
|
|
127
|
+
if is_pvpc:
|
|
128
|
+
pvpc = await self._get_pvpc(win_start, win_end)
|
|
129
|
+
bills = await asyncio.to_thread(
|
|
130
|
+
self.simulate_pvpc, contracts, energy, pvpc, billing_rules
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
bills = await asyncio.to_thread(
|
|
134
|
+
self.simulate_custom, contracts, energy, billing_rules
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
await self.db.add_bill_list(
|
|
138
|
+
cups=self._cups,
|
|
139
|
+
type_="hour",
|
|
140
|
+
confhash=confighash,
|
|
141
|
+
complete=True,
|
|
142
|
+
bill=bills,
|
|
143
|
+
)
|
|
135
144
|
|
|
136
145
|
_LOGGER.debug("%s updating daily and monthly bills", self._scups)
|
|
137
|
-
await self.
|
|
138
|
-
await self.fix_missing_statistics()
|
|
146
|
+
await self.update_statistics_incremental()
|
|
139
147
|
|
|
140
148
|
async def clear_bills(self, since: datetime | None = None) -> None:
|
|
141
149
|
"""Delete stored bills for this cups, optionally only from a datetime onwards."""
|
|
@@ -171,75 +179,81 @@ class BillService:
|
|
|
171
179
|
)
|
|
172
180
|
|
|
173
181
|
async def update_statistics(self, start: datetime, end: datetime):
|
|
174
|
-
"""
|
|
182
|
+
"""Compile the daily/monthly bills for a range plus any incomplete backlog."""
|
|
175
183
|
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
ledger = PendingLedger()
|
|
185
|
+
ledger.mark_range(start, end)
|
|
186
|
+
await self._seed_pending_backlog(ledger)
|
|
187
|
+
await self._compile_pending(ledger)
|
|
178
188
|
|
|
179
|
-
async def
|
|
180
|
-
"""
|
|
189
|
+
async def update_statistics_incremental(self) -> None:
|
|
190
|
+
"""Compile only the daily/monthly bills that are new or still incomplete.
|
|
181
191
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
"""
|
|
185
196
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
continue
|
|
197
|
+
ledger = PendingLedger()
|
|
198
|
+
await self._seed_pending_backlog(ledger)
|
|
199
|
+
await self._seed_pending_recent(ledger)
|
|
200
|
+
await self._compile_pending(ledger)
|
|
191
201
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
self._compile_statistics, data, get_day, skip=complete
|
|
195
|
-
)
|
|
202
|
+
async def _seed_pending_backlog(self, ledger: PendingLedger) -> None:
|
|
203
|
+
"""Seed the ledger with the buckets whose stored bills are incomplete."""
|
|
196
204
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
_LOGGER.info(
|
|
202
|
-
"%s daily statistics for %s are incomplete",
|
|
203
|
-
self._scups,
|
|
204
|
-
stat.datetime.date(),
|
|
205
|
-
)
|
|
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)
|
|
206
209
|
|
|
207
|
-
async def
|
|
208
|
-
"""
|
|
210
|
+
async def _seed_pending_recent(self, ledger: PendingLedger) -> None:
|
|
211
|
+
"""Seed the ledger with the range past the last complete daily bill."""
|
|
209
212
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
+
last_bill = await self.db.get_last_bill(self._cups)
|
|
214
|
+
if not last_bill:
|
|
215
|
+
return
|
|
213
216
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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)
|
|
219
226
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
self._compile_statistics, data, get_month, skip=complete
|
|
223
|
-
)
|
|
227
|
+
async def _compile_pending(self, ledger: PendingLedger) -> None:
|
|
228
|
+
"""Roll up the pending buckets, one month of hourly bills loaded at a time."""
|
|
224
229
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
230
|
+
now = datetime.now()
|
|
231
|
+
for month in ledger.sorted_months():
|
|
232
|
+
month_end = (
|
|
233
|
+
month + relativedelta.relativedelta(months=1) - timedelta(microseconds=1)
|
|
228
234
|
)
|
|
229
|
-
|
|
230
|
-
await self.db.add_bill(self._cups, "month", stat, "mix", is_complete)
|
|
231
|
-
if not is_complete:
|
|
232
|
-
_LOGGER.info(
|
|
233
|
-
"%s monthly statistics for %s are incomplete",
|
|
234
|
-
self._scups,
|
|
235
|
-
stat.datetime.date(),
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
async def _find_missing_stats(self) -> list[datetime]:
|
|
239
|
-
"""Return the list of days that are missing billing data."""
|
|
235
|
+
data = await self.get_bills(month, month_end, "hour")
|
|
240
236
|
|
|
241
|
-
|
|
242
|
-
|
|
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)
|
|
243
257
|
|
|
244
258
|
def _compile_statistics(
|
|
245
259
|
self,
|
|
@@ -280,23 +294,6 @@ class BillService:
|
|
|
280
294
|
|
|
281
295
|
return [agg_data[x] for x in agg_data]
|
|
282
296
|
|
|
283
|
-
async def fix_missing_statistics(self) -> None:
|
|
284
|
-
"""Recompile statistics to fix missing data."""
|
|
285
|
-
|
|
286
|
-
missing = await self._find_missing_stats()
|
|
287
|
-
for day in missing:
|
|
288
|
-
_LOGGER.debug("%s updating daily statistics for date %s", self._scups, day)
|
|
289
|
-
end = day + relativedelta.relativedelta(day=1) - timedelta(minutes=1)
|
|
290
|
-
await self._update_daily_statistics(day, end)
|
|
291
|
-
|
|
292
|
-
missing_months = list(set([get_month(x) for x in missing]))
|
|
293
|
-
for month in missing_months:
|
|
294
|
-
_LOGGER.debug(
|
|
295
|
-
"%s updating monthly statistics for date %s", self._scups, month
|
|
296
|
-
)
|
|
297
|
-
end = month + relativedelta.relativedelta(months=1) - timedelta(minutes=1)
|
|
298
|
-
await self._update_monthly_statistics(month, end)
|
|
299
|
-
|
|
300
297
|
def simulate_pvpc(
|
|
301
298
|
self,
|
|
302
299
|
contracts: list[Contract],
|
|
@@ -306,21 +303,17 @@ class BillService:
|
|
|
306
303
|
) -> list[Bill]:
|
|
307
304
|
"""Compile bills assuming PVPC billing."""
|
|
308
305
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
pvpc_valid_dt = [x for x in pvpc_valid_dt if x in energy_dt]
|
|
321
|
-
|
|
322
|
-
e = {x.datetime: x for x in energy if x.datetime in pvpc_valid_dt}
|
|
323
|
-
p = {x.datetime: x for x in pvpc if x.datetime in pvpc_valid_dt}
|
|
306
|
+
# reduce computation to timestamps present in both series and covered
|
|
307
|
+
# by a contract (set membership instead of nested list scans)
|
|
308
|
+
common_dt = {x.datetime for x in energy} & {x.datetime for x in pvpc}
|
|
309
|
+
valid_dt = {
|
|
310
|
+
dt
|
|
311
|
+
for dt in common_dt
|
|
312
|
+
if any(c.date_start <= dt <= c.date_end for c in contracts)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
e = {x.datetime: x for x in energy if x.datetime in valid_dt}
|
|
316
|
+
p = {x.datetime: x for x in pvpc if x.datetime in valid_dt}
|
|
324
317
|
b: dict[datetime, Bill] = {}
|
|
325
318
|
|
|
326
319
|
for dt in e.keys():
|