myelectricaldatapy 2.4.4__tar.gz → 2.4.5__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.
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/release.yml +1 -1
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/PKG-INFO +1 -1
- myelectricaldatapy-2.4.5/myelectricaldatapy/analytics.py +333 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/api.py +6 -6
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/auth.py +2 -2
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/mypdl.py +5 -6
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/PKG-INFO +1 -1
- myelectricaldatapy-2.4.5/myelectricaldatapy.egg-info/scm_version.json +8 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/test_analytics.py +128 -0
- myelectricaldatapy-2.4.4/myelectricaldatapy/analytics.py +0 -215
- myelectricaldatapy-2.4.4/myelectricaldatapy.egg-info/scm_version.json +0 -8
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.devcontainer/devcontainer-lock.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.devcontainer/devcontainer.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/dependabot.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/auto-approve.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/python-lint.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/python-publish.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.gitignore +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.pre-commit-config.yaml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.vscode/settings.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/LICENSE +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/README.md +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/_secrets.yaml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/example.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/__init__.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/const.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/exceptions.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/py.typed +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/types.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/tz.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/SOURCES.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/dependency_links.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/requires.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/scm_file_list.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/top_level.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/pyproject.toml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/setup.cfg +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/__init__.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/conftest.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/consts.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/access.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/address.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/contract.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/daily.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/detail.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/ecowatt.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/identity.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/invalid_access.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/tempo.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/test_load_data.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/uv.lock +0 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Class for analytics."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime as dt, timedelta, tzinfo as _tzinfo
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .const import ATTR_OFFPEAK, ATTR_STANDARD, TEMPO_DAYS
|
|
11
|
+
from .exceptions import AnalyticsError, EnedisException
|
|
12
|
+
from .types import TempoLabels
|
|
13
|
+
from .tz import get_local_timezone
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EnedisAnalytics:
|
|
19
|
+
"""Data analytics."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, data: Any, timezone: _tzinfo | None = None) -> None:
|
|
22
|
+
"""Initialize Dataframe."""
|
|
23
|
+
# Resolved per instance (not at import/class-definition time) so a
|
|
24
|
+
# timezone set later via tz.set_local_timezone(), or passed here
|
|
25
|
+
# explicitly, is honored.
|
|
26
|
+
self.local_timezone = timezone or get_local_timezone()
|
|
27
|
+
try:
|
|
28
|
+
if isinstance(data, list):
|
|
29
|
+
data = self._shift_timestamps_to_interval_start(data)
|
|
30
|
+
self.df = pd.DataFrame(data)
|
|
31
|
+
except Exception as error: # normalized below
|
|
32
|
+
raise AnalyticsError(
|
|
33
|
+
f"Could not build a dataframe from the provided data: {error}"
|
|
34
|
+
) from error
|
|
35
|
+
|
|
36
|
+
def get_data_analytics(
|
|
37
|
+
self,
|
|
38
|
+
convertKwh: bool = False,
|
|
39
|
+
start_date: dt | None = None,
|
|
40
|
+
intervals: list[tuple[str, str]] | None = None,
|
|
41
|
+
groupby: bool = False,
|
|
42
|
+
summary: bool = False,
|
|
43
|
+
cum_value: dict[str, Any] | None = None,
|
|
44
|
+
cum_price: dict[str, Any] | None = None,
|
|
45
|
+
prices: dict[str, Any] | None = None,
|
|
46
|
+
tempo: dict[str, TempoLabels] | None = None,
|
|
47
|
+
) -> Any:
|
|
48
|
+
"""Convert data to analyze.
|
|
49
|
+
|
|
50
|
+
Any failure while crunching the dataframe is re-raised as
|
|
51
|
+
:class:`AnalyticsError` so callers only have to catch
|
|
52
|
+
:class:`EnedisException`.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return self._get_data_analytics(
|
|
56
|
+
convertKwh=convertKwh,
|
|
57
|
+
start_date=start_date,
|
|
58
|
+
intervals=intervals,
|
|
59
|
+
groupby=groupby,
|
|
60
|
+
summary=summary,
|
|
61
|
+
cum_value=cum_value,
|
|
62
|
+
cum_price=cum_price,
|
|
63
|
+
prices=prices,
|
|
64
|
+
tempo=tempo,
|
|
65
|
+
)
|
|
66
|
+
except EnedisException:
|
|
67
|
+
raise
|
|
68
|
+
except Exception as error: # normalized below
|
|
69
|
+
raise AnalyticsError(
|
|
70
|
+
f"Failed to compute analytics from the provided dataset: {error}"
|
|
71
|
+
) from error
|
|
72
|
+
|
|
73
|
+
def _get_data_analytics(
|
|
74
|
+
self,
|
|
75
|
+
convertKwh: bool = False,
|
|
76
|
+
start_date: dt | None = None,
|
|
77
|
+
intervals: list[tuple[str, str]] | None = None,
|
|
78
|
+
groupby: bool = False,
|
|
79
|
+
summary: bool = False,
|
|
80
|
+
cum_value: dict[str, Any] | None = None,
|
|
81
|
+
cum_price: dict[str, Any] | None = None,
|
|
82
|
+
prices: dict[str, Any] | None = None,
|
|
83
|
+
tempo: dict[str, TempoLabels] | None = None,
|
|
84
|
+
) -> Any:
|
|
85
|
+
"""Convert data to analyze."""
|
|
86
|
+
|
|
87
|
+
cum_value = cum_value or {}
|
|
88
|
+
cum_price = cum_price or {}
|
|
89
|
+
|
|
90
|
+
step_hour = self._prepare_dataframe(start_date)
|
|
91
|
+
if self.df.empty:
|
|
92
|
+
return self.df.to_dict(orient="records")
|
|
93
|
+
|
|
94
|
+
self._apply_value_conversion(convertKwh, step_hour)
|
|
95
|
+
|
|
96
|
+
if intervals:
|
|
97
|
+
self._get_data_interval(intervals)
|
|
98
|
+
|
|
99
|
+
if groupby:
|
|
100
|
+
self._groupby_period(step_hour)
|
|
101
|
+
|
|
102
|
+
if tempo:
|
|
103
|
+
self._set_tempo_days(tempo)
|
|
104
|
+
|
|
105
|
+
notes = list(self.df.notes.drop_duplicates())
|
|
106
|
+
self._apply_pricing(prices, tempo, summary, cum_price, notes)
|
|
107
|
+
|
|
108
|
+
if summary:
|
|
109
|
+
self._apply_value_summary(cum_value, notes)
|
|
110
|
+
|
|
111
|
+
return self.df.to_dict(orient="records")
|
|
112
|
+
|
|
113
|
+
def _prepare_dataframe(self, start_date: dt | None) -> bool:
|
|
114
|
+
"""Normalize dates, localize timezone, filter and mark rows.
|
|
115
|
+
|
|
116
|
+
Timestamps are already shifted from interval-END to interval-START
|
|
117
|
+
by :meth:`_shift_timestamps_to_interval_start` at construction time,
|
|
118
|
+
so no further per-row adjustment is needed here.
|
|
119
|
+
|
|
120
|
+
Returns whether the dataset is stepped (has an interval_length column).
|
|
121
|
+
"""
|
|
122
|
+
step_hour = False
|
|
123
|
+
|
|
124
|
+
if self.df.empty:
|
|
125
|
+
return step_hour
|
|
126
|
+
|
|
127
|
+
# Convert str to datetime
|
|
128
|
+
try:
|
|
129
|
+
self.df["date"] = pd.to_datetime(
|
|
130
|
+
self.df["date"], format="%Y-%m-%d %H:%M:%S"
|
|
131
|
+
)
|
|
132
|
+
except ValueError:
|
|
133
|
+
self.df["date"] = pd.to_datetime(self.df["date"], format="%Y-%m-%d")
|
|
134
|
+
|
|
135
|
+
self.df["date"] = self.df["date"].dt.tz_localize(self.local_timezone)
|
|
136
|
+
|
|
137
|
+
if "interval_length" in self.df:
|
|
138
|
+
step_hour = True
|
|
139
|
+
|
|
140
|
+
if start_date:
|
|
141
|
+
dt_start_date = pd.to_datetime(start_date, format="%Y-%m-%d %H:%M:%S")
|
|
142
|
+
if dt_start_date.tzinfo is None:
|
|
143
|
+
dt_start_date = dt_start_date.tz_localize(self.local_timezone)
|
|
144
|
+
self.df = self.df[(self.df["date"] > dt_start_date)]
|
|
145
|
+
|
|
146
|
+
self.df.index = self.df["date"]
|
|
147
|
+
|
|
148
|
+
# Add mark
|
|
149
|
+
self.df["notes"] = ATTR_STANDARD
|
|
150
|
+
|
|
151
|
+
return step_hour
|
|
152
|
+
|
|
153
|
+
def _apply_value_conversion(self, convertKwh: bool, step_hour: bool) -> None:
|
|
154
|
+
"""Weight values by interval length and optionally convert Wh to kWh."""
|
|
155
|
+
self.df["interval_length"] = (
|
|
156
|
+
self.df["interval_length"].transform(self._weighted_interval)
|
|
157
|
+
if step_hour
|
|
158
|
+
else 1
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if convertKwh:
|
|
162
|
+
self.df["value"] = (
|
|
163
|
+
pd.to_numeric(self.df["value"]) / 1000 * self.df["interval_length"]
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
self.df["value"] = (
|
|
167
|
+
pd.to_numeric(self.df["value"]) * self.df["interval_length"]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _groupby_period(self, step_hour: bool) -> None:
|
|
171
|
+
"""Aggregate values by note and period (hour or day)."""
|
|
172
|
+
freq = "h" if step_hour else "D"
|
|
173
|
+
self.df = (
|
|
174
|
+
self.df.groupby(["notes", pd.Grouper(key="date", freq=freq)])["value"]
|
|
175
|
+
.sum()
|
|
176
|
+
.reset_index()
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _apply_pricing(
|
|
180
|
+
self,
|
|
181
|
+
prices: dict[str, Any] | None,
|
|
182
|
+
tempo: dict[str, TempoLabels] | None,
|
|
183
|
+
summary: bool,
|
|
184
|
+
cum_price: dict[str, Any],
|
|
185
|
+
notes: list[str],
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Compute a price per row and, if requested, a cumulative price per note."""
|
|
188
|
+
if not prices:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
for mode, values in prices.items():
|
|
192
|
+
if isinstance(values, dict):
|
|
193
|
+
for offset, price in values.items():
|
|
194
|
+
if tempo and offset in TEMPO_DAYS:
|
|
195
|
+
self.df.loc[
|
|
196
|
+
(self.df.notes == mode) & (self.df.tempo == offset),
|
|
197
|
+
"price",
|
|
198
|
+
] = self.df.value * price
|
|
199
|
+
elif offset == "price":
|
|
200
|
+
self.df.loc[(self.df.notes == mode), "price"] = (
|
|
201
|
+
self.df.value * price
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
self.df.loc[(self.df.notes == mode), "price"] = None
|
|
205
|
+
|
|
206
|
+
if summary:
|
|
207
|
+
for note in notes:
|
|
208
|
+
self.df.loc[(self.df.notes == note), "sum_price"] = self.df[
|
|
209
|
+
(self.df.notes == note)
|
|
210
|
+
].price.cumsum() + cum_price.get(note, 0)
|
|
211
|
+
|
|
212
|
+
def _apply_value_summary(self, cum_value: dict[str, Any], notes: list[str]) -> None:
|
|
213
|
+
"""Add a cumulative value sum per note."""
|
|
214
|
+
for note in notes:
|
|
215
|
+
self.df.loc[(self.df.notes == note), "sum_value"] = self.df[
|
|
216
|
+
(self.df.notes == note)
|
|
217
|
+
].value.cumsum() + cum_value.get(note, 0)
|
|
218
|
+
|
|
219
|
+
def _weighted_interval(self, interval: str) -> float | int:
|
|
220
|
+
"""Compute weighted."""
|
|
221
|
+
if interval and len(rslt := re.findall("PT([0-9]{1,2})M", interval)) == 1:
|
|
222
|
+
return int(rslt[0]) / 60
|
|
223
|
+
return 1
|
|
224
|
+
|
|
225
|
+
def _get_data_interval(self, intervals: list[tuple[str, str]]) -> pd.DataFrame:
|
|
226
|
+
"""Group date from range time."""
|
|
227
|
+
for interval in intervals:
|
|
228
|
+
# Convert str to datetime
|
|
229
|
+
start = pd.to_datetime(interval[0], format="%H:%M:%S").time()
|
|
230
|
+
end = pd.to_datetime(interval[1], format="%H:%M:%S").time()
|
|
231
|
+
# Mark (handle ranges spanning midnight, e.g. 22:00:00 -> 06:00:00).
|
|
232
|
+
# `date` holds the START of each reading's interval (see
|
|
233
|
+
# _shift_timestamps_to_interval_start), so bounds are
|
|
234
|
+
# start-inclusive / end-exclusive: a reading starting at
|
|
235
|
+
# `end` already belongs to the next (non-offpeak) slot.
|
|
236
|
+
date_time = self.df["date"].dt.time
|
|
237
|
+
if start <= end:
|
|
238
|
+
mask = (date_time >= start) & (date_time < end)
|
|
239
|
+
else:
|
|
240
|
+
mask = (date_time >= start) | (date_time < end)
|
|
241
|
+
self.df.loc[mask, "notes"] = ATTR_OFFPEAK
|
|
242
|
+
|
|
243
|
+
return self.df
|
|
244
|
+
|
|
245
|
+
def _set_tempo_days(self, tempo: dict[str, TempoLabels]) -> None:
|
|
246
|
+
"""Add columns with tempo day."""
|
|
247
|
+
for str_date, value in tempo.items():
|
|
248
|
+
dt_date = pd.to_datetime(str_date, format="%Y-%m-%d")
|
|
249
|
+
self.df.loc[(self.df["date"].dt.date == dt_date.date()), "tempo"] = value
|
|
250
|
+
|
|
251
|
+
def _parse_iso8601_duration_to_minutes(self, duration: str) -> int:
|
|
252
|
+
"""Parse ISO 8601 duration format to minutes."""
|
|
253
|
+
match = re.match(r"PT(\d+)([HM])", duration)
|
|
254
|
+
if not match:
|
|
255
|
+
return 30
|
|
256
|
+
|
|
257
|
+
value = int(match.group(1))
|
|
258
|
+
unit = match.group(2)
|
|
259
|
+
|
|
260
|
+
return value * 60 if unit == "H" else value
|
|
261
|
+
|
|
262
|
+
def _shift_timestamps_to_interval_start(
|
|
263
|
+
self, readings: list[dict[str, Any]]
|
|
264
|
+
) -> list[dict[str, Any]]:
|
|
265
|
+
"""
|
|
266
|
+
Shift timestamps from interval END to interval START.
|
|
267
|
+
|
|
268
|
+
Enedis API returns timestamps representing the END of each measurement interval.
|
|
269
|
+
For better UX and to avoid confusion with midnight timestamps, we shift all timestamps
|
|
270
|
+
backwards by the interval_length to represent the START of each interval.
|
|
271
|
+
|
|
272
|
+
Example with 30min intervals:
|
|
273
|
+
API returns: 2025-11-22 00:30:00 (end of 00:00-00:30 interval)
|
|
274
|
+
We transform: 2025-11-22 00:00:00 (start of 00:00-00:30 interval)
|
|
275
|
+
|
|
276
|
+
This also fixes the midnight edge case where:
|
|
277
|
+
API returns: 2025-11-23 00:00:00 (end of 23:30-00:00 interval of day 22)
|
|
278
|
+
We transform: 2025-11-22 23:30:00 (start of that interval, correctly on day 22)
|
|
279
|
+
|
|
280
|
+
Readings without an interval_length (e.g. daily aggregates) are returned
|
|
281
|
+
unchanged. Input readings are never mutated: callers (e.g. EnedisByPDL.stats,
|
|
282
|
+
a property recomputed on every access from the same stored list) may reuse
|
|
283
|
+
them across multiple EnedisAnalytics calls.
|
|
284
|
+
"""
|
|
285
|
+
shifted_count = 0
|
|
286
|
+
shifted_readings: list[dict[str, Any]] = []
|
|
287
|
+
|
|
288
|
+
for reading in readings:
|
|
289
|
+
interval_length_iso = reading.get("interval_length")
|
|
290
|
+
original_date = reading.get("date")
|
|
291
|
+
if not original_date or not interval_length_iso:
|
|
292
|
+
shifted_readings.append(reading)
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# Parse ISO 8601 duration to minutes
|
|
296
|
+
interval_minutes = self._parse_iso8601_duration_to_minutes(
|
|
297
|
+
interval_length_iso
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Parse datetime (handle both formats: "YYYY-MM-DD HH:MM:SS" and "YYYY-MM-DDTHH:MM:SS")
|
|
301
|
+
try:
|
|
302
|
+
if "T" in original_date:
|
|
303
|
+
dt_orig_date = dt.fromisoformat(original_date)
|
|
304
|
+
else:
|
|
305
|
+
dt_orig_date = dt.strptime(
|
|
306
|
+
original_date, "%Y-%m-%d %H:%M:%S"
|
|
307
|
+
).replace(tzinfo=self.local_timezone)
|
|
308
|
+
|
|
309
|
+
# Shift backwards by interval_length
|
|
310
|
+
shifted_dt = dt_orig_date - timedelta(minutes=interval_minutes)
|
|
311
|
+
|
|
312
|
+
# Format back to original format
|
|
313
|
+
new_date = (
|
|
314
|
+
shifted_dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
315
|
+
if "T" in original_date
|
|
316
|
+
else shifted_dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
shifted_readings.append({**reading, "date": new_date})
|
|
320
|
+
shifted_count += 1
|
|
321
|
+
|
|
322
|
+
except ValueError as e:
|
|
323
|
+
logger.warning(
|
|
324
|
+
f"[ENEDIS] Failed to shift timestamp '{original_date}': {e}"
|
|
325
|
+
)
|
|
326
|
+
shifted_readings.append(reading)
|
|
327
|
+
|
|
328
|
+
if shifted_count > 0:
|
|
329
|
+
logger.info(
|
|
330
|
+
f"[ENEDIS] Shifted {shifted_count} timestamps from interval END → START"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return shifted_readings
|
|
@@ -30,7 +30,7 @@ from .types import (
|
|
|
30
30
|
)
|
|
31
31
|
from .tz import as_local, get_local_timezone, local_now
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def _validate[ModelT: BaseModel](model: type[ModelT], raw: Any) -> ModelT:
|
|
@@ -136,7 +136,7 @@ class Enedis:
|
|
|
136
136
|
try:
|
|
137
137
|
return TempoResponse.validate_python(raw)
|
|
138
138
|
except ValidationError as error:
|
|
139
|
-
|
|
139
|
+
logger.debug("Unexpected tempo payload: %s (%s)", raw, error)
|
|
140
140
|
return {}
|
|
141
141
|
|
|
142
142
|
async def async_get_tempo_days(self) -> TempoDays | None:
|
|
@@ -145,7 +145,7 @@ class Enedis:
|
|
|
145
145
|
try:
|
|
146
146
|
return TempoDays.model_validate(raw)
|
|
147
147
|
except ValidationError as error:
|
|
148
|
-
|
|
148
|
+
logger.debug("Unexpected tempo days payload: %s (%s)", raw, error)
|
|
149
149
|
return None
|
|
150
150
|
|
|
151
151
|
async def async_get_tempo_prices(self) -> Prices | None:
|
|
@@ -165,7 +165,7 @@ class Enedis:
|
|
|
165
165
|
),
|
|
166
166
|
)
|
|
167
167
|
except (AttributeError, ValidationError) as error:
|
|
168
|
-
|
|
168
|
+
logger.debug("Unexpected tempo prices payload: %s (%s)", raw, error)
|
|
169
169
|
return None
|
|
170
170
|
|
|
171
171
|
async def async_get_ecowatt(
|
|
@@ -184,7 +184,7 @@ class Enedis:
|
|
|
184
184
|
try:
|
|
185
185
|
return EcowattResponse.validate_python(raw)
|
|
186
186
|
except ValidationError as error:
|
|
187
|
-
|
|
187
|
+
logger.debug("Unexpected ecowatt payload: %s (%s)", raw, error)
|
|
188
188
|
return {}
|
|
189
189
|
|
|
190
190
|
async def async_has_offpeak(self, pdl: str) -> bool:
|
|
@@ -277,7 +277,7 @@ class Enedis:
|
|
|
277
277
|
)
|
|
278
278
|
except EnedisException as error:
|
|
279
279
|
raise_error = True
|
|
280
|
-
|
|
280
|
+
logger.error(error)
|
|
281
281
|
|
|
282
282
|
if response is None:
|
|
283
283
|
continue
|
|
@@ -17,7 +17,7 @@ from .exceptions import (
|
|
|
17
17
|
TimeoutExceededError,
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
21
|
URL = "https://myelectricaldata.fr"
|
|
22
22
|
|
|
23
23
|
|
|
@@ -40,7 +40,7 @@ class EnedisAuth:
|
|
|
40
40
|
|
|
41
41
|
try:
|
|
42
42
|
async with asyncio.timeout(self.timeout):
|
|
43
|
-
|
|
43
|
+
logger.debug("Request: %s (%s) - %s", path, method, kwargs.get("json"))
|
|
44
44
|
response = await self.session.request(method, f"{URL}{path}", **kwargs)
|
|
45
45
|
contents = await response.read()
|
|
46
46
|
response.raise_for_status()
|
|
@@ -47,7 +47,7 @@ from .types import (
|
|
|
47
47
|
)
|
|
48
48
|
from .tz import as_local, local_now, set_local_timezone
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def _dump(model: BaseModel | None) -> dict[str, Any]:
|
|
@@ -174,7 +174,6 @@ class EnedisByPDL:
|
|
|
174
174
|
analytics = EnedisAnalytics(data, timezone=self._timezone)
|
|
175
175
|
resultat = analytics.get_data_analytics(
|
|
176
176
|
convertKwh=True,
|
|
177
|
-
convertUTC=False,
|
|
178
177
|
intervals=params.get(ATTR_INTERVALS, []),
|
|
179
178
|
groupby=True,
|
|
180
179
|
summary=True,
|
|
@@ -234,13 +233,13 @@ class EnedisByPDL:
|
|
|
234
233
|
self.contract = await self._api.async_get_contract(self.pdl)
|
|
235
234
|
self.offpeak_intervals = self._api.offpeaks
|
|
236
235
|
except EnedisException as error:
|
|
237
|
-
|
|
236
|
+
logger.warning(error)
|
|
238
237
|
|
|
239
238
|
if self.address is None and self.has_collected is False:
|
|
240
239
|
try:
|
|
241
240
|
self.address = await self._api.async_get_address(self.pdl)
|
|
242
241
|
except EnedisException as error:
|
|
243
|
-
|
|
242
|
+
logger.warning(error)
|
|
244
243
|
|
|
245
244
|
if self.ecowatt is None and self.has_ecowatt_subscription:
|
|
246
245
|
self.ecowatt = await self._api.async_get_ecowatt(start, end)
|
|
@@ -286,7 +285,7 @@ class EnedisByPDL:
|
|
|
286
285
|
prices if isinstance(prices, Prices) else Prices.model_validate(prices)
|
|
287
286
|
)
|
|
288
287
|
except ValidationError as error:
|
|
289
|
-
|
|
288
|
+
logger.error("Format is incorrect (%s)", error)
|
|
290
289
|
return
|
|
291
290
|
|
|
292
291
|
self._params[mode].update({ATTR_PRICES: model})
|
|
@@ -301,7 +300,7 @@ class EnedisByPDL:
|
|
|
301
300
|
try:
|
|
302
301
|
model = cum_sum if isinstance(cum_sum, Cum) else Cum.model_validate(cum_sum)
|
|
303
302
|
except ValidationError as error:
|
|
304
|
-
|
|
303
|
+
logger.error("Format is incorrect (%s)", error)
|
|
305
304
|
return
|
|
306
305
|
|
|
307
306
|
self._params[mode].update({f"cum_{form}".lower(): model})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from copy import deepcopy
|
|
5
6
|
from datetime import datetime as dt
|
|
6
7
|
from unittest.mock import Mock, patch
|
|
7
8
|
|
|
@@ -16,6 +17,7 @@ from myelectricaldatapy import (
|
|
|
16
17
|
LimitReached,
|
|
17
18
|
Subscription,
|
|
18
19
|
)
|
|
20
|
+
from myelectricaldatapy.analytics import EnedisAnalytics
|
|
19
21
|
from myelectricaldatapy.tz import LOCAL_TIMEZONE
|
|
20
22
|
|
|
21
23
|
from .consts import PDL, TOKEN
|
|
@@ -331,3 +333,129 @@ async def test_twice_call(
|
|
|
331
333
|
assert api.stats["production"][0]["notes"] == "standard"
|
|
332
334
|
await api.async_update()
|
|
333
335
|
assert api.last_access is not None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --- EnedisAnalytics internals: interval-start timestamp shift ---------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_shift_timestamps_basic() -> None:
|
|
342
|
+
"""A 30min reading is shifted back from interval END to interval START."""
|
|
343
|
+
analytics = EnedisAnalytics([], timezone=LOCAL_TIMEZONE)
|
|
344
|
+
readings = [
|
|
345
|
+
{"date": "2023-03-01 00:30:00", "value": "100", "interval_length": "PT30M"}
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
shifted = analytics._shift_timestamps_to_interval_start(readings)
|
|
349
|
+
|
|
350
|
+
assert shifted[0]["date"] == "2023-03-01 00:00:00"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_shift_timestamps_midnight_rollover() -> None:
|
|
354
|
+
"""The interval ending at midnight must roll back onto the previous day."""
|
|
355
|
+
analytics = EnedisAnalytics([], timezone=LOCAL_TIMEZONE)
|
|
356
|
+
readings = [
|
|
357
|
+
{"date": "2023-03-02 00:00:00", "value": "100", "interval_length": "PT30M"}
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
shifted = analytics._shift_timestamps_to_interval_start(readings)
|
|
361
|
+
|
|
362
|
+
assert shifted[0]["date"] == "2023-03-01 23:30:00"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_shift_timestamps_without_interval_length_is_untouched() -> None:
|
|
366
|
+
"""Daily aggregates (no interval_length) are passed through as-is."""
|
|
367
|
+
analytics = EnedisAnalytics([], timezone=LOCAL_TIMEZONE)
|
|
368
|
+
readings = [{"date": "2023-03-01", "value": "42000"}]
|
|
369
|
+
|
|
370
|
+
shifted = analytics._shift_timestamps_to_interval_start(readings)
|
|
371
|
+
|
|
372
|
+
assert shifted == readings
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_shift_timestamps_does_not_mutate_input() -> None:
|
|
376
|
+
"""EnedisByPDL.stats recomputes analytics from the same stored list on
|
|
377
|
+
every access, so shifting must never mutate the caller's dicts in place.
|
|
378
|
+
"""
|
|
379
|
+
analytics = EnedisAnalytics([], timezone=LOCAL_TIMEZONE)
|
|
380
|
+
readings = [
|
|
381
|
+
{"date": "2023-03-01 00:30:00", "value": "100", "interval_length": "PT30M"}
|
|
382
|
+
]
|
|
383
|
+
original = deepcopy(readings)
|
|
384
|
+
|
|
385
|
+
analytics._shift_timestamps_to_interval_start(readings)
|
|
386
|
+
|
|
387
|
+
assert readings == original
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@pytest.mark.parametrize(
|
|
391
|
+
("duration", "expected_minutes"),
|
|
392
|
+
[("PT30M", 30), ("PT1H", 60), ("PT15M", 15), ("not-a-duration", 30)],
|
|
393
|
+
)
|
|
394
|
+
def test_parse_iso8601_duration_to_minutes(
|
|
395
|
+
duration: str, expected_minutes: int
|
|
396
|
+
) -> None:
|
|
397
|
+
analytics = EnedisAnalytics([], timezone=LOCAL_TIMEZONE)
|
|
398
|
+
assert analytics._parse_iso8601_duration_to_minutes(duration) == expected_minutes
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_offpeak_classification_uses_shifted_start_timestamp() -> None:
|
|
402
|
+
"""Regression test for the boundary fix in _get_data_interval.
|
|
403
|
+
|
|
404
|
+
Once dates represent the interval START (not END), the offpeak mask
|
|
405
|
+
must be start-inclusive / end-exclusive; getting this wrong reclassified
|
|
406
|
+
readings right at the edges of an offpeak window.
|
|
407
|
+
"""
|
|
408
|
+
data = [
|
|
409
|
+
# END of 01:00-01:30 -> shifts to 01:00 (before offpeak starts)
|
|
410
|
+
{"date": "2023-03-01 01:30:00", "value": "1000", "interval_length": "PT30M"},
|
|
411
|
+
# END of 01:30-02:00 -> shifts to 01:30 (first offpeak interval)
|
|
412
|
+
{"date": "2023-03-01 02:00:00", "value": "1000", "interval_length": "PT30M"},
|
|
413
|
+
# END of 07:30-08:00 -> shifts to 07:30 (last offpeak interval)
|
|
414
|
+
{"date": "2023-03-01 08:00:00", "value": "1000", "interval_length": "PT30M"},
|
|
415
|
+
# END of 08:00-08:30 -> shifts to 08:00 (offpeak window just ended)
|
|
416
|
+
{"date": "2023-03-01 08:30:00", "value": "1000", "interval_length": "PT30M"},
|
|
417
|
+
]
|
|
418
|
+
analytics = EnedisAnalytics(data, timezone=LOCAL_TIMEZONE)
|
|
419
|
+
|
|
420
|
+
resultat = analytics.get_data_analytics(intervals=[("01:30:00", "08:00:00")])
|
|
421
|
+
|
|
422
|
+
notes_by_time = {r["date"].strftime("%H:%M:%S"): r["notes"] for r in resultat}
|
|
423
|
+
assert notes_by_time["01:00:00"] == "standard"
|
|
424
|
+
assert notes_by_time["01:30:00"] == "offpeak"
|
|
425
|
+
assert notes_by_time["07:30:00"] == "offpeak"
|
|
426
|
+
assert notes_by_time["08:00:00"] == "standard"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_offpeak_boundaries_snap_to_reading_granularity() -> None:
|
|
430
|
+
"""Documents a known limitation, not a bug: classification cannot be
|
|
431
|
+
more precise than the data's own step (30min for load curve data).
|
|
432
|
+
|
|
433
|
+
A whole reading is either offpeak or standard; it is never split. So an
|
|
434
|
+
offpeak window declared as 00:54-05:54 is not honoured to the minute:
|
|
435
|
+
each reading is classified from its own (interval-START) timestamp, so
|
|
436
|
+
the effective window silently snaps to the surrounding 30min slots
|
|
437
|
+
(here: 01:00-06:00 instead of 00:54-05:54).
|
|
438
|
+
"""
|
|
439
|
+
data = [
|
|
440
|
+
# Represents [00:30, 01:00) - overlaps the declared offpeak window
|
|
441
|
+
# (00:54-01:00) but is entirely "standard": the reading starts
|
|
442
|
+
# before 00:54.
|
|
443
|
+
{"date": "2023-03-01 01:00:00", "value": "1000", "interval_length": "PT30M"},
|
|
444
|
+
# Represents [01:00, 01:30) - fully inside the declared window.
|
|
445
|
+
{"date": "2023-03-01 01:30:00", "value": "1000", "interval_length": "PT30M"},
|
|
446
|
+
# Represents [05:30, 06:00) - overlaps the declared offpeak window
|
|
447
|
+
# (05:30-05:54) but is entirely "offpeak": the reading starts
|
|
448
|
+
# before 05:54.
|
|
449
|
+
{"date": "2023-03-01 06:00:00", "value": "1000", "interval_length": "PT30M"},
|
|
450
|
+
# Represents [06:00, 06:30) - fully outside the declared window.
|
|
451
|
+
{"date": "2023-03-01 06:30:00", "value": "1000", "interval_length": "PT30M"},
|
|
452
|
+
]
|
|
453
|
+
analytics = EnedisAnalytics(data, timezone=LOCAL_TIMEZONE)
|
|
454
|
+
|
|
455
|
+
resultat = analytics.get_data_analytics(intervals=[("00:54:00", "05:54:00")])
|
|
456
|
+
|
|
457
|
+
notes_by_time = {r["date"].strftime("%H:%M:%S"): r["notes"] for r in resultat}
|
|
458
|
+
assert notes_by_time["00:30:00"] == "standard"
|
|
459
|
+
assert notes_by_time["01:00:00"] == "offpeak"
|
|
460
|
+
assert notes_by_time["05:30:00"] == "offpeak"
|
|
461
|
+
assert notes_by_time["06:00:00"] == "standard"
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
"""Class for analytics."""
|
|
2
|
-
|
|
3
|
-
from datetime import datetime as dt, timedelta, tzinfo as _tzinfo
|
|
4
|
-
import re
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
import pandas as pd
|
|
8
|
-
|
|
9
|
-
from .const import ATTR_OFFPEAK, ATTR_STANDARD, TEMPO_DAYS
|
|
10
|
-
from .exceptions import AnalyticsError, EnedisException
|
|
11
|
-
from .types import TempoLabels
|
|
12
|
-
from .tz import get_local_timezone
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class EnedisAnalytics:
|
|
16
|
-
"""Data analytics."""
|
|
17
|
-
|
|
18
|
-
def __init__(self, data: Any, timezone: _tzinfo | None = None) -> None:
|
|
19
|
-
"""Initialize Dataframe."""
|
|
20
|
-
try:
|
|
21
|
-
self.df = pd.DataFrame(data)
|
|
22
|
-
except Exception as error: # normalized below
|
|
23
|
-
raise AnalyticsError(
|
|
24
|
-
f"Could not build a dataframe from the provided data: {error}"
|
|
25
|
-
) from error
|
|
26
|
-
# Resolved per instance (not at import/class-definition time) so a
|
|
27
|
-
# timezone set later via tz.set_local_timezone(), or passed here
|
|
28
|
-
# explicitly, is honored.
|
|
29
|
-
self.local_timezone = timezone or get_local_timezone()
|
|
30
|
-
|
|
31
|
-
def get_data_analytics(
|
|
32
|
-
self,
|
|
33
|
-
convertKwh: bool = False,
|
|
34
|
-
convertUTC: bool = False,
|
|
35
|
-
start_date: dt | None = None,
|
|
36
|
-
intervals: list[tuple[str, str]] | None = None,
|
|
37
|
-
groupby: bool = False,
|
|
38
|
-
summary: bool = False,
|
|
39
|
-
cum_value: dict[str, Any] | None = None,
|
|
40
|
-
cum_price: dict[str, Any] | None = None,
|
|
41
|
-
prices: dict[str, Any] | None = None,
|
|
42
|
-
tempo: dict[str, TempoLabels] | None = None,
|
|
43
|
-
) -> Any:
|
|
44
|
-
"""Convert data to analyze.
|
|
45
|
-
|
|
46
|
-
Any failure while crunching the dataframe is re-raised as
|
|
47
|
-
:class:`AnalyticsError` so callers only have to catch
|
|
48
|
-
:class:`EnedisException`.
|
|
49
|
-
"""
|
|
50
|
-
try:
|
|
51
|
-
return self._get_data_analytics(
|
|
52
|
-
convertKwh=convertKwh,
|
|
53
|
-
convertUTC=convertUTC,
|
|
54
|
-
start_date=start_date,
|
|
55
|
-
intervals=intervals,
|
|
56
|
-
groupby=groupby,
|
|
57
|
-
summary=summary,
|
|
58
|
-
cum_value=cum_value,
|
|
59
|
-
cum_price=cum_price,
|
|
60
|
-
prices=prices,
|
|
61
|
-
tempo=tempo,
|
|
62
|
-
)
|
|
63
|
-
except EnedisException:
|
|
64
|
-
raise
|
|
65
|
-
except Exception as error: # normalized below
|
|
66
|
-
raise AnalyticsError(
|
|
67
|
-
f"Failed to compute analytics from the provided dataset: {error}"
|
|
68
|
-
) from error
|
|
69
|
-
|
|
70
|
-
def _get_data_analytics(
|
|
71
|
-
self,
|
|
72
|
-
convertKwh: bool = False,
|
|
73
|
-
convertUTC: bool = False,
|
|
74
|
-
start_date: dt | None = None,
|
|
75
|
-
intervals: list[tuple[str, str]] | None = None,
|
|
76
|
-
groupby: bool = False,
|
|
77
|
-
summary: bool = False,
|
|
78
|
-
cum_value: dict[str, Any] | None = None,
|
|
79
|
-
cum_price: dict[str, Any] | None = None,
|
|
80
|
-
prices: dict[str, Any] | None = None,
|
|
81
|
-
tempo: dict[str, TempoLabels] | None = None,
|
|
82
|
-
) -> Any:
|
|
83
|
-
"""Convert data to analyze."""
|
|
84
|
-
|
|
85
|
-
cum_value = cum_value or {}
|
|
86
|
-
cum_price = cum_price or {}
|
|
87
|
-
step_hour = False
|
|
88
|
-
|
|
89
|
-
if not self.df.empty:
|
|
90
|
-
# Convert str to datetime
|
|
91
|
-
try:
|
|
92
|
-
self.df["date"] = pd.to_datetime(
|
|
93
|
-
self.df["date"], format="%Y-%m-%d %H:%M:%S"
|
|
94
|
-
)
|
|
95
|
-
except ValueError:
|
|
96
|
-
self.df["date"] = pd.to_datetime(self.df["date"], format="%Y-%m-%d")
|
|
97
|
-
|
|
98
|
-
self.df["date"] = self.df["date"].dt.tz_localize(self.local_timezone)
|
|
99
|
-
|
|
100
|
-
if convertUTC:
|
|
101
|
-
self.df["date"] = pd.to_datetime(
|
|
102
|
-
self.df["date"], utc=True, format="%Y-%m-%d %H:%M:%S"
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
# Subtract 1 minute at hour
|
|
106
|
-
# because Pandas considers hour as the next hour while
|
|
107
|
-
# for Enedis it is the hour before
|
|
108
|
-
if "interval_length" in self.df:
|
|
109
|
-
step_hour = True
|
|
110
|
-
self.df.loc[
|
|
111
|
-
(self.df["date"].dt.minute == 0),
|
|
112
|
-
"date",
|
|
113
|
-
] = self.df["date"] - timedelta(minutes=1)
|
|
114
|
-
|
|
115
|
-
if start_date:
|
|
116
|
-
dt_start_date = pd.to_datetime(start_date, format="%Y-%m-%d %H:%M:%S")
|
|
117
|
-
if dt_start_date.tzinfo is None:
|
|
118
|
-
dt_start_date = dt_start_date.tz_localize(self.local_timezone)
|
|
119
|
-
self.df = self.df[(self.df["date"] > dt_start_date)]
|
|
120
|
-
|
|
121
|
-
self.df.index = self.df["date"]
|
|
122
|
-
|
|
123
|
-
# Add mark
|
|
124
|
-
self.df["notes"] = ATTR_STANDARD
|
|
125
|
-
|
|
126
|
-
if self.df.empty:
|
|
127
|
-
return self.df.to_dict(orient="records")
|
|
128
|
-
|
|
129
|
-
self.df["interval_length"] = (
|
|
130
|
-
self.df["interval_length"].transform(self._weighted_interval)
|
|
131
|
-
if step_hour
|
|
132
|
-
else 1
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
if convertKwh:
|
|
136
|
-
self.df["value"] = (
|
|
137
|
-
pd.to_numeric(self.df["value"]) / 1000 * self.df["interval_length"]
|
|
138
|
-
)
|
|
139
|
-
else:
|
|
140
|
-
self.df["value"] = (
|
|
141
|
-
pd.to_numeric(self.df["value"]) * self.df["interval_length"]
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
if intervals:
|
|
145
|
-
self._get_data_interval(intervals)
|
|
146
|
-
|
|
147
|
-
if groupby:
|
|
148
|
-
freq = "h" if step_hour else "D"
|
|
149
|
-
self.df = (
|
|
150
|
-
self.df.groupby(["notes", pd.Grouper(key="date", freq=freq)])["value"]
|
|
151
|
-
.sum()
|
|
152
|
-
.reset_index()
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
if tempo:
|
|
156
|
-
self._set_tempo_days(tempo)
|
|
157
|
-
|
|
158
|
-
notes = list(self.df.notes.drop_duplicates())
|
|
159
|
-
if prices:
|
|
160
|
-
for mode, values in prices.items():
|
|
161
|
-
if isinstance(values, dict):
|
|
162
|
-
for offset, price in values.items():
|
|
163
|
-
if tempo and offset in TEMPO_DAYS:
|
|
164
|
-
self.df.loc[
|
|
165
|
-
(self.df.notes == mode) & (self.df.tempo == offset),
|
|
166
|
-
"price",
|
|
167
|
-
] = self.df.value * price
|
|
168
|
-
elif offset == "price":
|
|
169
|
-
self.df.loc[(self.df.notes == mode), "price"] = (
|
|
170
|
-
self.df.value * price
|
|
171
|
-
)
|
|
172
|
-
else:
|
|
173
|
-
self.df.loc[(self.df.notes == mode), "price"] = None
|
|
174
|
-
|
|
175
|
-
if summary:
|
|
176
|
-
for note in notes:
|
|
177
|
-
self.df.loc[(self.df.notes == note), "sum_price"] = self.df[
|
|
178
|
-
(self.df.notes == note)
|
|
179
|
-
].price.cumsum() + cum_price.get(note, 0)
|
|
180
|
-
|
|
181
|
-
if summary:
|
|
182
|
-
for note in notes:
|
|
183
|
-
self.df.loc[(self.df.notes == note), "sum_value"] = self.df[
|
|
184
|
-
(self.df.notes == note)
|
|
185
|
-
].value.cumsum() + cum_value.get(note, 0)
|
|
186
|
-
|
|
187
|
-
return self.df.to_dict(orient="records")
|
|
188
|
-
|
|
189
|
-
def _weighted_interval(self, interval: str) -> float | int:
|
|
190
|
-
"""Compute weighted."""
|
|
191
|
-
if interval and len(rslt := re.findall("PT([0-9]{1,2})M", interval)) == 1:
|
|
192
|
-
return int(rslt[0]) / 60
|
|
193
|
-
return 1
|
|
194
|
-
|
|
195
|
-
def _get_data_interval(self, intervals: list[tuple[str, str]]) -> pd.DataFrame:
|
|
196
|
-
"""Group date from range time."""
|
|
197
|
-
for interval in intervals:
|
|
198
|
-
# Convert str to datetime
|
|
199
|
-
start = pd.to_datetime(interval[0], format="%H:%M:%S").time()
|
|
200
|
-
end = pd.to_datetime(interval[1], format="%H:%M:%S").time()
|
|
201
|
-
# Mark (handle ranges spanning midnight, e.g. 22:00:00 -> 06:00:00)
|
|
202
|
-
date_time = self.df["date"].dt.time
|
|
203
|
-
if start <= end:
|
|
204
|
-
mask = (date_time > start) & (date_time <= end)
|
|
205
|
-
else:
|
|
206
|
-
mask = (date_time > start) | (date_time <= end)
|
|
207
|
-
self.df.loc[mask, "notes"] = ATTR_OFFPEAK
|
|
208
|
-
|
|
209
|
-
return self.df
|
|
210
|
-
|
|
211
|
-
def _set_tempo_days(self, tempo: dict[str, TempoLabels]) -> None:
|
|
212
|
-
"""Add columns with tempo day."""
|
|
213
|
-
for str_date, value in tempo.items():
|
|
214
|
-
dt_date = pd.to_datetime(str_date, format="%Y-%m-%d")
|
|
215
|
-
self.df.loc[(self.df["date"].dt.date == dt_date.date()), "tempo"] = value
|
|
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
|
{myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/requires.txt
RENAMED
|
File without changes
|
{myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/scm_file_list.json
RENAMED
|
File without changes
|
{myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/top_level.txt
RENAMED
|
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
|