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.
Files changed (38) hide show
  1. {e_data-2.0.0.dev125/e_data.egg-info → e_data-2.0.1}/PKG-INFO +2 -2
  2. {e_data-2.0.0.dev125 → e_data-2.0.1/e_data.egg-info}/PKG-INFO +2 -2
  3. {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/SOURCES.txt +5 -1
  4. {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/requires.txt +1 -1
  5. e_data-2.0.1/edata/core/completion.py +114 -0
  6. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/core/utils.py +38 -2
  7. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/controller.py +43 -3
  8. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/queries.py +26 -0
  9. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/providers/datadis.py +10 -3
  10. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/services/bill_service.py +103 -110
  11. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/services/data_service.py +73 -77
  12. e_data-2.0.1/edata/tests/test_completion.py +104 -0
  13. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/test_datadis_connector.py +62 -0
  14. e_data-2.0.1/edata/tests/test_incremental.py +108 -0
  15. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/test_services.py +22 -2
  16. e_data-2.0.1/edata/tests/test_utils.py +60 -0
  17. {e_data-2.0.0.dev125 → e_data-2.0.1}/pyproject.toml +2 -2
  18. {e_data-2.0.0.dev125 → e_data-2.0.1}/LICENSE +0 -0
  19. {e_data-2.0.0.dev125 → e_data-2.0.1}/MANIFEST.in +0 -0
  20. {e_data-2.0.0.dev125 → e_data-2.0.1}/README.md +0 -0
  21. {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/dependency_links.txt +0 -0
  22. {e_data-2.0.0.dev125 → e_data-2.0.1}/e_data.egg-info/top_level.txt +0 -0
  23. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/__init__.py +0 -0
  24. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/cli.py +0 -0
  25. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/core/__init__.py +0 -0
  26. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/core/const.py +0 -0
  27. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/__init__.py +0 -0
  28. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/models.py +0 -0
  29. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/database/utils.py +0 -0
  30. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/__init__.py +0 -0
  31. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/bill.py +0 -0
  32. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/data.py +0 -0
  33. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/models/supply.py +0 -0
  34. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/providers/__init__.py +0 -0
  35. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/providers/redata.py +0 -0
  36. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/__init__.py +0 -0
  37. {e_data-2.0.0.dev125 → e_data-2.0.1}/edata/tests/test_redata_connector.py +0 -0
  38. {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.0.dev125
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.0.dev125
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
@@ -7,7 +7,7 @@ Jinja2<4,>=3.1
7
7
  pydantic<3,>=2.10
8
8
  python_dateutil<3,>=2.8
9
9
  Requests<3,>=2.31
10
- SQLAlchemy<3,>=2.0
10
+ SQLAlchemy[asyncio]<3,>=2.0
11
11
  sqlmodel<0.1,>=0.0.22
12
12
  typer<1,>=0.12
13
13
  aiosqlite<1,>=0.21
@@ -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
- from datetime import datetime
4
+ import typing
5
+ from datetime import datetime, timedelta
6
+ from functools import lru_cache
5
7
  from pathlib import Path
6
8
 
9
+ from dateutil.relativedelta import relativedelta
7
10
  import holidays
8
11
 
9
12
  from edata.models.supply import Contract
@@ -19,10 +22,22 @@ TARIFF_BY_WEEKDAY = [[], [], [5, 6]]
19
22
  _LOGGER = logging.getLogger(__name__)
20
23
 
21
24
 
25
+ @lru_cache(maxsize=None)
26
+ def _es_holidays(year: int) -> holidays.HolidayBase:
27
+ """Return (and cache) Spanish holidays for a single year.
28
+
29
+ Building the holidays calendar is expensive; get_tariff is called once per
30
+ hourly record, so without this cache a full-history rebuild spends minutes
31
+ reconstructing the same handful of yearly calendars.
32
+ """
33
+
34
+ return holidays.country_holidays("ES", years=year)
35
+
36
+
22
37
  def get_tariff(dt: datetime) -> int:
23
38
  """Return the tariff for the selected datetime."""
24
39
 
25
- hdays = holidays.country_holidays("ES")
40
+ hdays = _es_holidays(dt.year)
26
41
  hour = dt.hour
27
42
  weekday = dt.weekday()
28
43
 
@@ -64,6 +79,27 @@ def get_day(dt: datetime) -> datetime:
64
79
  return dt.replace(hour=0, minute=0, second=0, microsecond=0)
65
80
 
66
81
 
82
+ def iter_month_windows(
83
+ start: datetime, end: datetime
84
+ ) -> typing.Iterator[tuple[datetime, datetime]]:
85
+ """Yield contiguous ``(window_start, window_end)`` pairs, one calendar month each.
86
+
87
+ Windows are non-overlapping and cover ``[start, end]`` inclusively; the first
88
+ starts at ``start`` and the last ends at ``end``. Iterating a month at a time
89
+ keeps the working set bounded to a single month regardless of history length.
90
+ """
91
+
92
+ if end < start:
93
+ return
94
+
95
+ win_start = start
96
+ while win_start <= end:
97
+ next_month = get_month(win_start) + relativedelta(months=1)
98
+ win_end = min(next_month - timedelta(microseconds=1), end)
99
+ yield win_start, win_end
100
+ win_start = next_month
101
+
102
+
67
103
  def redacted_cups(cups: str) -> str:
68
104
  """Return an anonymized version of the cups identifier."""
69
105
 
@@ -3,7 +3,7 @@ import os
3
3
  import typing
4
4
  from datetime import datetime
5
5
 
6
- from sqlalchemy import Select, insert
6
+ from sqlalchemy import Select, event, insert
7
7
  from sqlalchemy.exc import IntegrityError
8
8
  from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9
9
  from sqlmodel import SQLModel
@@ -28,6 +28,25 @@ _LOGGER = logging.getLogger(__name__)
28
28
  T = typing.TypeVar("T", bound=SQLModel)
29
29
 
30
30
 
31
+ def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
32
+ """Tune SQLite for this write-heavy, single-writer workload.
33
+
34
+ WAL lets readers proceed while a write is in flight and avoids an fsync per
35
+ statement; synchronous=NORMAL is durable under WAL (only a crash mid-checkpoint
36
+ could lose the last transaction, and the data is re-fetchable from Datadis);
37
+ busy_timeout avoids spurious "database is locked" errors under concurrency.
38
+ journal_mode is persisted in the file; the others are per-connection.
39
+ """
40
+
41
+ cursor = dbapi_connection.cursor()
42
+ try:
43
+ cursor.execute("PRAGMA journal_mode=WAL")
44
+ cursor.execute("PRAGMA synchronous=NORMAL")
45
+ cursor.execute("PRAGMA busy_timeout=5000")
46
+ finally:
47
+ cursor.close()
48
+
49
+
31
50
  class EdataDB:
32
51
 
33
52
  _instance = None
@@ -39,10 +58,11 @@ class EdataDB:
39
58
  if cls._instance is None:
40
59
  cls._instance = super().__new__(cls)
41
60
  cls._db_url = db_url
42
- cls._engine = create_async_engine(db_url, future=True)
43
- # Ensure parent directory exists
61
+ # Ensure parent directory exists before the first connection is opened.
44
62
  dir_path = os.path.dirname(os.path.abspath(sqlite_path))
45
63
  os.makedirs(dir_path, exist_ok=True)
64
+ cls._engine = create_async_engine(db_url, future=True)
65
+ event.listen(cls._engine.sync_engine, "connect", _set_sqlite_pragmas)
46
66
  cls._instance._tables_initialized = False
47
67
  elif db_url != cls._db_url:
48
68
  raise ValueError("EdataDB already initialized with a different db_url")
@@ -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
- hour = str(int(i["time"].split(":")[0]) - 1)
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
- f"{i['date']} {hour.zfill(2)}:00", "%Y/%m/%d %H:%M"
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
- _LOGGER.debug("%s pushing hourly bills", self._scups)
128
- await self.db.add_bill_list(
129
- cups=self._cups,
130
- type_="hour",
131
- confhash=confighash,
132
- complete=True,
133
- bill=bills,
134
- )
117
+ # compile and persist hourly bills one month at a time so the energy
118
+ # (and simulated bills) held in memory stay bounded to a single month
119
+ for win_start, win_end in iter_month_windows(start, end):
120
+ energy = await self._get_energy(win_start, win_end)
121
+ if not energy:
122
+ continue
123
+
124
+ _LOGGER.debug(
125
+ "%s compiling hourly bills for %s..%s", self._scups, win_start, win_end
126
+ )
127
+ if is_pvpc:
128
+ pvpc = await self._get_pvpc(win_start, win_end)
129
+ bills = await asyncio.to_thread(
130
+ self.simulate_pvpc, contracts, energy, pvpc, billing_rules
131
+ )
132
+ else:
133
+ bills = await asyncio.to_thread(
134
+ self.simulate_custom, contracts, energy, billing_rules
135
+ )
136
+
137
+ await self.db.add_bill_list(
138
+ cups=self._cups,
139
+ type_="hour",
140
+ confhash=confighash,
141
+ complete=True,
142
+ bill=bills,
143
+ )
135
144
 
136
145
  _LOGGER.debug("%s updating daily and monthly bills", self._scups)
137
- await self.update_statistics(start, end)
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
- """Update the statistics during a period."""
182
+ """Compile the daily/monthly bills for a range plus any incomplete backlog."""
175
183
 
176
- await self._update_daily_statistics(start, end)
177
- await self._update_monthly_statistics(start, end)
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 _update_daily_statistics(self, start: datetime, end: datetime) -> None:
180
- """Update daily statistics within a date range."""
189
+ async def update_statistics_incremental(self) -> None:
190
+ """Compile only the daily/monthly bills that are new or still incomplete.
181
191
 
182
- day_start = get_day(start)
183
- day_end = end
184
- daily = await self.db.list_bill(self._cups, "day", day_start, day_end)
192
+ The pending buckets come from cheap indexed queries (incomplete rows plus
193
+ the recent range past the last complete bucket), so a sync never rolls up
194
+ the whole hourly-bill history.
195
+ """
185
196
 
186
- complete = []
187
- for stat in daily:
188
- if stat.complete:
189
- complete.append(stat.datetime)
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
- data = await self.get_bills(day_start, day_end)
193
- stats = await asyncio.to_thread(
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
- for stat in stats:
198
- is_complete = stat.delta_h == 24
199
- await self.db.add_bill(self._cups, "day", stat, "mix", is_complete)
200
- if not is_complete:
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 _update_monthly_statistics(self, start: datetime, end: datetime) -> None:
208
- """Update monthly statistics within a date range."""
210
+ async def _seed_pending_recent(self, ledger: PendingLedger) -> None:
211
+ """Seed the ledger with the range past the last complete daily bill."""
209
212
 
210
- month_start = get_month(start)
211
- month_end = end
212
- monthly = await self.db.list_bill(self._cups, "month", month_start, month_end)
213
+ last_bill = await self.db.get_last_bill(self._cups)
214
+ if not last_bill:
215
+ return
213
216
 
214
- complete = []
215
- for stat in monthly:
216
- if stat.complete:
217
- complete.append(stat.datetime)
218
- continue
217
+ last_complete = await self.db.get_last_complete_bill(self._cups, "day")
218
+ if last_complete:
219
+ start = get_day(last_complete.datetime) + timedelta(days=1)
220
+ else:
221
+ supply = await self.db.get_supply(self._cups)
222
+ start = get_day(
223
+ supply.data.date_start if supply else last_bill.datetime
224
+ )
225
+ ledger.mark_range(start, last_bill.datetime)
219
226
 
220
- data = await self.get_bills(month_start, month_end)
221
- stats = await asyncio.to_thread(
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
- for stat in stats:
226
- target_hours = (
227
- calendar.monthrange(stat.datetime.year, stat.datetime.month)[1] * 24
230
+ now = datetime.now()
231
+ for month in ledger.sorted_months():
232
+ month_end = (
233
+ month + relativedelta.relativedelta(months=1) - timedelta(microseconds=1)
228
234
  )
229
- is_complete = stat.delta_h == target_hours
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
- stats = await self.db.list_bill(self._cups, "day", complete=False)
242
- return [x.datetime for x in stats]
237
+ for day in ledger.days_in(month):
238
+ day_end = day + timedelta(days=1) - timedelta(microseconds=1)
239
+ day_data = [x for x in data if day <= x.datetime <= day_end]
240
+ stat = await asyncio.to_thread(
241
+ self._compile_statistics, day_data, get_day
242
+ )
243
+ delta_h = stat[0].delta_h if stat else 0.0
244
+ final = is_day_final(day, delta_h, now)
245
+ if stat:
246
+ await self.db.add_bill(self._cups, "day", stat[0], "mix", final)
247
+ ledger.resolve_day(day, final=final)
248
+
249
+ month_stat = await asyncio.to_thread(
250
+ self._compile_statistics, data, get_month
251
+ )
252
+ delta_h = month_stat[0].delta_h if month_stat else 0.0
253
+ final = is_month_final(month, delta_h, now)
254
+ if month_stat:
255
+ await self.db.add_bill(self._cups, "month", month_stat[0], "mix", final)
256
+ ledger.resolve_month(month, final=final)
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
- energy_dt = [x.datetime for x in energy]
310
- pvpc_dt = [x.datetime for x in pvpc]
311
-
312
- # reduce computation range to valid periods
313
- pvpc_valid_dt = []
314
- for contract in contracts:
315
- for dt in pvpc_dt:
316
- if dt in pvpc_valid_dt:
317
- continue
318
- if contract.date_start <= dt <= contract.date_end:
319
- pvpc_valid_dt.append(dt)
320
- pvpc_valid_dt = [x for x in pvpc_valid_dt if x in energy_dt]
321
-
322
- e = {x.datetime: x for x in energy if x.datetime in pvpc_valid_dt}
323
- p = {x.datetime: x for x in pvpc if x.datetime in pvpc_valid_dt}
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():