e-data 2.0.1.dev128__tar.gz → 2.0.1.dev130__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.1.dev128/e_data.egg-info → e_data-2.0.1.dev130}/PKG-INFO +1 -1
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130/e_data.egg-info}/PKG-INFO +1 -1
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/e_data.egg-info/SOURCES.txt +2 -1
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/core/utils.py +24 -1
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/services/bill_service.py +43 -37
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/services/data_service.py +16 -4
- e_data-2.0.1.dev130/edata/tests/test_utils.py +60 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/pyproject.toml +1 -1
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/LICENSE +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/MANIFEST.in +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/README.md +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/e_data.egg-info/dependency_links.txt +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/e_data.egg-info/requires.txt +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/e_data.egg-info/top_level.txt +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/__init__.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/cli.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/core/__init__.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/core/const.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/database/__init__.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/database/controller.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/database/models.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/database/queries.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/database/utils.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/models/__init__.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/models/bill.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/models/data.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/models/supply.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/providers/__init__.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/providers/datadis.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/providers/redata.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/tests/__init__.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/tests/test_datadis_connector.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/tests/test_redata_connector.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/edata/tests/test_services.py +0 -0
- {e_data-2.0.1.dev128 → e_data-2.0.1.dev130}/setup.cfg +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Collection of utilities."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
|
|
4
|
+
import typing
|
|
5
|
+
from datetime import datetime, timedelta
|
|
5
6
|
from functools import lru_cache
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
9
|
+
from dateutil.relativedelta import relativedelta
|
|
8
10
|
import holidays
|
|
9
11
|
|
|
10
12
|
from edata.models.supply import Contract
|
|
@@ -77,6 +79,27 @@ def get_day(dt: datetime) -> datetime:
|
|
|
77
79
|
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
78
80
|
|
|
79
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
|
+
|
|
80
103
|
def redacted_cups(cups: str) -> str:
|
|
81
104
|
"""Return an anonymized version of the cups identifier."""
|
|
82
105
|
|
|
@@ -16,6 +16,7 @@ from edata.core.utils import (
|
|
|
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,31 +108,39 @@ 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
146
|
await self.update_statistics(start, end)
|
|
@@ -171,10 +180,11 @@ class BillService:
|
|
|
171
180
|
)
|
|
172
181
|
|
|
173
182
|
async def update_statistics(self, start: datetime, end: datetime):
|
|
174
|
-
"""Update the statistics during a period."""
|
|
183
|
+
"""Update the statistics during a period, one month at a time."""
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
for win_start, win_end in iter_month_windows(start, end):
|
|
186
|
+
await self._update_daily_statistics(win_start, win_end)
|
|
187
|
+
await self._update_monthly_statistics(win_start, win_end)
|
|
178
188
|
|
|
179
189
|
async def _update_daily_statistics(self, start: datetime, end: datetime) -> None:
|
|
180
190
|
"""Update daily statistics within a date range."""
|
|
@@ -306,21 +316,17 @@ class BillService:
|
|
|
306
316
|
) -> list[Bill]:
|
|
307
317
|
"""Compile bills assuming PVPC billing."""
|
|
308
318
|
|
|
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}
|
|
319
|
+
# reduce computation to timestamps present in both series and covered
|
|
320
|
+
# by a contract (set membership instead of nested list scans)
|
|
321
|
+
common_dt = {x.datetime for x in energy} & {x.datetime for x in pvpc}
|
|
322
|
+
valid_dt = {
|
|
323
|
+
dt
|
|
324
|
+
for dt in common_dt
|
|
325
|
+
if any(c.date_start <= dt <= c.date_end for c in contracts)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
e = {x.datetime: x for x in energy if x.datetime in valid_dt}
|
|
329
|
+
p = {x.datetime: x for x in pvpc if x.datetime in valid_dt}
|
|
324
330
|
b: dict[datetime, Bill] = {}
|
|
325
331
|
|
|
326
332
|
for dt in e.keys():
|
|
@@ -11,7 +11,14 @@ from tempfile import gettempdir
|
|
|
11
11
|
|
|
12
12
|
from dateutil import relativedelta
|
|
13
13
|
|
|
14
|
-
from edata.core.utils import
|
|
14
|
+
from edata.core.utils import (
|
|
15
|
+
get_db_path,
|
|
16
|
+
get_day,
|
|
17
|
+
get_month,
|
|
18
|
+
get_tariff,
|
|
19
|
+
iter_month_windows,
|
|
20
|
+
redacted_cups,
|
|
21
|
+
)
|
|
15
22
|
from edata.database.controller import EdataDB
|
|
16
23
|
from edata.models import Contract, Energy, Power, Statistics, Supply
|
|
17
24
|
from edata.models.bill import EnergyPrice
|
|
@@ -332,10 +339,15 @@ class DataService:
|
|
|
332
339
|
return False
|
|
333
340
|
|
|
334
341
|
async def update_statistics(self, start: datetime, end: datetime) -> None:
|
|
335
|
-
"""Update the statistics during a period.
|
|
342
|
+
"""Update the statistics during a period, one month at a time.
|
|
336
343
|
|
|
337
|
-
|
|
338
|
-
|
|
344
|
+
Iterating monthly windows keeps the energy loaded for compilation bounded
|
|
345
|
+
to a single month instead of the whole history.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
for win_start, win_end in iter_month_windows(start, end):
|
|
349
|
+
await self._update_daily_statistics(win_start, win_end)
|
|
350
|
+
await self._update_monthly_statistics(win_start, win_end)
|
|
339
351
|
|
|
340
352
|
async def _update_daily_statistics(self, start: datetime, end: datetime) -> None:
|
|
341
353
|
"""Update daily statistics within a date range."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tests for edata.core.utils helpers."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from edata.core.utils import get_month, iter_month_windows
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _assert_windows_valid(
|
|
11
|
+
windows: list[tuple[datetime, datetime]],
|
|
12
|
+
start: datetime,
|
|
13
|
+
end: datetime,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Assert windows tile [start, end] contiguously, one calendar month each."""
|
|
16
|
+
assert windows[0][0] == start
|
|
17
|
+
assert windows[-1][1] == end
|
|
18
|
+
for win_start, win_end in windows:
|
|
19
|
+
assert win_start <= win_end
|
|
20
|
+
# a window never straddles a calendar-month boundary
|
|
21
|
+
assert get_month(win_start) == get_month(win_end)
|
|
22
|
+
for (_, prev_end), (next_start, _) in zip(windows, windows[1:], strict=False):
|
|
23
|
+
# contiguous and non-overlapping
|
|
24
|
+
assert next_start == prev_end + timedelta(microseconds=1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize(
|
|
28
|
+
("start", "end", "expected_len"),
|
|
29
|
+
[
|
|
30
|
+
# sub-month range collapses to a single window
|
|
31
|
+
(datetime(2023, 1, 5), datetime(2023, 1, 20), 1),
|
|
32
|
+
# spans three calendar months
|
|
33
|
+
(datetime(2023, 1, 15), datetime(2023, 3, 10), 3),
|
|
34
|
+
# exact month boundaries
|
|
35
|
+
(datetime(2023, 1, 1), datetime(2023, 2, 28, 23, 59, 59), 2),
|
|
36
|
+
# crosses a year boundary
|
|
37
|
+
(datetime(2022, 12, 20), datetime(2023, 2, 1), 3),
|
|
38
|
+
# spring-forward DST month is handled like any other (naive datetimes)
|
|
39
|
+
(datetime(2023, 3, 1), datetime(2023, 4, 15), 2),
|
|
40
|
+
],
|
|
41
|
+
ids=["sub-month", "three-months", "exact-boundaries", "year-boundary", "dst-month"],
|
|
42
|
+
)
|
|
43
|
+
def test_iter_month_windows_tiles_range(
|
|
44
|
+
start: datetime, end: datetime, expected_len: int
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Windows cover the range contiguously, one calendar month at a time."""
|
|
47
|
+
windows = list(iter_month_windows(start, end))
|
|
48
|
+
assert len(windows) == expected_len
|
|
49
|
+
_assert_windows_valid(windows, start, end)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_iter_month_windows_single_instant() -> None:
|
|
53
|
+
"""A zero-length range yields exactly one window covering that instant."""
|
|
54
|
+
moment = datetime(2023, 6, 15, 10, 30)
|
|
55
|
+
assert list(iter_month_windows(moment, moment)) == [(moment, moment)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_iter_month_windows_empty_when_end_before_start() -> None:
|
|
59
|
+
"""An inverted range yields no windows."""
|
|
60
|
+
assert list(iter_month_windows(datetime(2023, 2, 1), datetime(2023, 1, 1))) == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|