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.
Files changed (51) hide show
  1. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/release.yml +1 -1
  2. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/PKG-INFO +1 -1
  3. myelectricaldatapy-2.4.5/myelectricaldatapy/analytics.py +333 -0
  4. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/api.py +6 -6
  5. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/auth.py +2 -2
  6. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/mypdl.py +5 -6
  7. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/PKG-INFO +1 -1
  8. myelectricaldatapy-2.4.5/myelectricaldatapy.egg-info/scm_version.json +8 -0
  9. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/test_analytics.py +128 -0
  10. myelectricaldatapy-2.4.4/myelectricaldatapy/analytics.py +0 -215
  11. myelectricaldatapy-2.4.4/myelectricaldatapy.egg-info/scm_version.json +0 -8
  12. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.devcontainer/devcontainer-lock.json +0 -0
  13. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.devcontainer/devcontainer.json +0 -0
  14. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/dependabot.yml +0 -0
  15. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/auto-approve.yml +0 -0
  16. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/python-lint.yml +0 -0
  17. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.github/workflows/python-publish.yml +0 -0
  18. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.gitignore +0 -0
  19. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.pre-commit-config.yaml +0 -0
  20. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/.vscode/settings.json +0 -0
  21. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/LICENSE +0 -0
  22. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/README.md +0 -0
  23. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/_secrets.yaml +0 -0
  24. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/example.py +0 -0
  25. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/__init__.py +0 -0
  26. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/const.py +0 -0
  27. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/exceptions.py +0 -0
  28. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/py.typed +0 -0
  29. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/types.py +0 -0
  30. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy/tz.py +0 -0
  31. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/SOURCES.txt +0 -0
  32. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/dependency_links.txt +0 -0
  33. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/requires.txt +0 -0
  34. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/scm_file_list.json +0 -0
  35. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/myelectricaldatapy.egg-info/top_level.txt +0 -0
  36. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/pyproject.toml +0 -0
  37. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/setup.cfg +0 -0
  38. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/__init__.py +0 -0
  39. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/conftest.py +0 -0
  40. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/consts.py +0 -0
  41. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/access.json +0 -0
  42. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/address.json +0 -0
  43. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/contract.json +0 -0
  44. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/daily.json +0 -0
  45. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/detail.json +0 -0
  46. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/ecowatt.json +0 -0
  47. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/identity.json +0 -0
  48. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/invalid_access.json +0 -0
  49. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/fixtures/tempo.json +0 -0
  50. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/tests/test_load_data.py +0 -0
  51. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.5}/uv.lock +0 -0
@@ -13,7 +13,7 @@ jobs:
13
13
 
14
14
  - name: Generate changelog
15
15
  id: changelog
16
- uses: metcalfc/changelog-generator@v4.8.0
16
+ uses: metcalfc/changelog-generator@v5.0.1
17
17
  with:
18
18
  myToken: ${{ secrets.GITHUB_TOKEN }}
19
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: myelectricaldatapy
3
- Version: 2.4.4
3
+ Version: 2.4.5
4
4
  Summary: Fetch Linky data from myelectricaldata.fr
5
5
  Author-email: Cyr-ius <cyr-ius@ipocus.net>
6
6
  License-Expression: GPL-3.0-or-later
@@ -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
- _LOGGER = logging.getLogger(__name__)
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
- _LOGGER.debug("Unexpected tempo payload: %s (%s)", raw, error)
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
- _LOGGER.debug("Unexpected tempo days payload: %s (%s)", raw, error)
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
- _LOGGER.debug("Unexpected tempo prices payload: %s (%s)", raw, error)
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
- _LOGGER.debug("Unexpected ecowatt payload: %s (%s)", raw, error)
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
- _LOGGER.error(error)
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
- _LOGGER = logging.getLogger(__name__)
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
- _LOGGER.debug("Request: %s (%s) - %s", path, method, kwargs.get("json"))
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
- _LOGGER = logging.getLogger(__name__)
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
- _LOGGER.warning(error)
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
- _LOGGER.warning(error)
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
- _LOGGER.error("Format is incorrect (%s)", error)
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
- _LOGGER.error("Format is incorrect (%s)", error)
303
+ logger.error("Format is incorrect (%s)", error)
305
304
  return
306
305
 
307
306
  self._params[mode].update({f"cum_{form}".lower(): model})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: myelectricaldatapy
3
- Version: 2.4.4
3
+ Version: 2.4.5
4
4
  Summary: Fetch Linky data from myelectricaldata.fr
5
5
  Author-email: Cyr-ius <cyr-ius@ipocus.net>
6
6
  License-Expression: GPL-3.0-or-later
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.4.5",
3
+ "distance": 0,
4
+ "node": "gf020e899389b76b7cf905171890380ed977aad11",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-05"
8
+ }
@@ -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
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.4.4",
3
- "distance": 0,
4
- "node": "g9a87ed2706824a82c1926e6396d5602827511a4a",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-31"
8
- }