myelectricaldatapy 2.4.4__tar.gz → 2.4.7__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 (52) hide show
  1. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/release.yml +1 -1
  2. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/PKG-INFO +2 -2
  3. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/README.md +1 -1
  4. myelectricaldatapy-2.4.7/myelectricaldatapy/analytics.py +333 -0
  5. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/api.py +6 -6
  6. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/auth.py +2 -2
  7. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/mypdl.py +42 -23
  8. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/types.py +23 -3
  9. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/PKG-INFO +2 -2
  10. myelectricaldatapy-2.4.7/myelectricaldatapy.egg-info/scm_version.json +8 -0
  11. myelectricaldatapy-2.4.7/tests/fixtures/tempo.json +1 -0
  12. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/test_analytics.py +145 -17
  13. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/test_load_data.py +1 -1
  14. myelectricaldatapy-2.4.4/myelectricaldatapy/analytics.py +0 -215
  15. myelectricaldatapy-2.4.4/myelectricaldatapy.egg-info/scm_version.json +0 -8
  16. myelectricaldatapy-2.4.4/tests/fixtures/tempo.json +0 -1
  17. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.devcontainer/devcontainer-lock.json +0 -0
  18. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.devcontainer/devcontainer.json +0 -0
  19. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/dependabot.yml +0 -0
  20. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/auto-approve.yml +0 -0
  21. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/python-lint.yml +0 -0
  22. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/python-publish.yml +0 -0
  23. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.gitignore +0 -0
  24. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.pre-commit-config.yaml +0 -0
  25. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.vscode/settings.json +0 -0
  26. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/LICENSE +0 -0
  27. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/_secrets.yaml +0 -0
  28. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/example.py +0 -0
  29. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/__init__.py +0 -0
  30. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/const.py +0 -0
  31. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/exceptions.py +0 -0
  32. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/py.typed +0 -0
  33. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/tz.py +0 -0
  34. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/SOURCES.txt +0 -0
  35. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/dependency_links.txt +0 -0
  36. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/requires.txt +0 -0
  37. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/scm_file_list.json +0 -0
  38. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/top_level.txt +0 -0
  39. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/pyproject.toml +0 -0
  40. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/setup.cfg +0 -0
  41. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/__init__.py +0 -0
  42. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/conftest.py +0 -0
  43. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/consts.py +0 -0
  44. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/access.json +0 -0
  45. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/address.json +0 -0
  46. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/contract.json +0 -0
  47. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/daily.json +0 -0
  48. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/detail.json +0 -0
  49. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/ecowatt.json +0 -0
  50. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/identity.json +0 -0
  51. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/invalid_access.json +0 -0
  52. {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/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.7
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
@@ -120,7 +120,7 @@ Workflow:
120
120
  | `set_ecowatt_subscription(activate=False)` | Enable Ecowatt retrieval |
121
121
  | `set_maxpower_subscription(activate=False)` | Enable max power retrieval |
122
122
  | `async_update(force_refresh=False)` | Refresh access, contract, address, tempo, ecowatt, collects |
123
- | `async_update_collects()` | Refresh only the registered collects |
123
+ | `_async_fetch_data()` | Refresh only the registered collects |
124
124
  | `stats` | `dict` keyed by mode (`"consumption"` / `"production"`) with the computed analytics |
125
125
  | `access`, `contract`, `address`, `ecowatt`, `max_power`, `tempo`, `tempo_days`, `tempo_prices` | Last fetched models |
126
126
  | `is_connected`, `has_intervals`, `has_tempo_subscription`, `has_offpeak_hours_subscription`, `has_standard_subscription`, `has_ecowatt_subscription`, `has_maxpower_subscription` | Boolean state |
@@ -85,7 +85,7 @@ Workflow:
85
85
  | `set_ecowatt_subscription(activate=False)` | Enable Ecowatt retrieval |
86
86
  | `set_maxpower_subscription(activate=False)` | Enable max power retrieval |
87
87
  | `async_update(force_refresh=False)` | Refresh access, contract, address, tempo, ecowatt, collects |
88
- | `async_update_collects()` | Refresh only the registered collects |
88
+ | `_async_fetch_data()` | Refresh only the registered collects |
89
89
  | `stats` | `dict` keyed by mode (`"consumption"` / `"production"`) with the computed analytics |
90
90
  | `access`, `contract`, `address`, `ecowatt`, `max_power`, `tempo`, `tempo_days`, `tempo_prices` | Last fetched models |
91
91
  | `is_connected`, `has_intervals`, `has_tempo_subscription`, `has_offpeak_hours_subscription`, `has_standard_subscription`, `has_ecowatt_subscription`, `has_maxpower_subscription` | Boolean state |
@@ -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()
@@ -41,13 +41,12 @@ from .types import (
41
41
  Prices,
42
42
  Subscription,
43
43
  TempoDays,
44
- TempoLabels,
45
44
  TempoMapping,
46
45
  UsagePoint,
47
46
  )
48
47
  from .tz import as_local, local_now, set_local_timezone
49
48
 
50
- _LOGGER = logging.getLogger(__name__)
49
+ logger = logging.getLogger(__name__)
51
50
 
52
51
 
53
52
  def _dump(model: BaseModel | None) -> dict[str, Any]:
@@ -61,7 +60,7 @@ class EnedisByPDL:
61
60
  This class allows you to obtain information specific to a connection point.
62
61
 
63
62
  The "set_collect" function allows you to specify the collection parameters from Enedis
64
- The "async_update_collects" function allows you to perform the calculations
63
+ The "_async_fetch_data" function allows you to perform the calculations
65
64
  The result is displayed in the property: stats
66
65
  y"""
67
66
 
@@ -88,6 +87,7 @@ class EnedisByPDL:
88
87
  self._connected: bool = False
89
88
  self._ecowatt_subs: bool = False
90
89
  self._maxpower_subs: bool = False
90
+ self._convert_kwh: bool = True
91
91
  self._params: dict[Mode, dict[str, Any]] = {}
92
92
  self.subscription: Subscription = subscription
93
93
  self.access: AccessResponse | None = None
@@ -101,7 +101,7 @@ class EnedisByPDL:
101
101
  self._update_lock = asyncio.Lock()
102
102
  self.last_refresh: date | None = None
103
103
  self.max_power: DataCollect | None = None
104
- self.tempo: TempoMapping | None = None
104
+ self.tempo_historics: TempoMapping | None = None
105
105
  self.tempo_days: TempoDays | None = None
106
106
  self.tempo_prices: Prices | None = None
107
107
 
@@ -130,7 +130,7 @@ class EnedisByPDL:
130
130
 
131
131
  @property
132
132
  def has_standard_subscription(self) -> bool:
133
- """Offpeak hours subscription status."""
133
+ """Standard subscription status."""
134
134
  return self.subscription == Subscription.STANDARD
135
135
 
136
136
  @property
@@ -150,10 +150,24 @@ class EnedisByPDL:
150
150
  return self.ecowatt.get(str_date) if self.ecowatt is not None else None
151
151
 
152
152
  @property
153
- def tempo_day(self) -> TempoLabels | None:
153
+ def tempo(self) -> str | None:
154
154
  """Tempo day."""
155
155
  str_date = local_now().strftime("%Y-%m-%d")
156
- return self.tempo.get(str_date) if self.tempo is not None else None
156
+ return (
157
+ self.tempo_historics.get(str_date)
158
+ if self.tempo_historics is not None
159
+ else None
160
+ )
161
+
162
+ @property
163
+ def tempo_next(self) -> str | None:
164
+ """Tempo next days."""
165
+ str_date = (local_now() + timedelta(days=1)).strftime("%Y-%m-%d")
166
+ return (
167
+ self.tempo_historics.get(str_date)
168
+ if self.tempo_historics is not None
169
+ else None
170
+ )
157
171
 
158
172
  @property
159
173
  def prod_prices(self) -> Prices | None:
@@ -173,8 +187,7 @@ class EnedisByPDL:
173
187
  data = params.get("data", [])
174
188
  analytics = EnedisAnalytics(data, timezone=self._timezone)
175
189
  resultat = analytics.get_data_analytics(
176
- convertKwh=True,
177
- convertUTC=False,
190
+ convertKwh=self._convert_kwh,
178
191
  intervals=params.get(ATTR_INTERVALS, []),
179
192
  groupby=True,
180
193
  summary=True,
@@ -182,7 +195,7 @@ class EnedisByPDL:
182
195
  cum_value=_dump(params.get(ATTR_CUM_VALUE)),
183
196
  cum_price=_dump(params.get(ATTR_CUM_PRICE)),
184
197
  start_date=params.get(ATTR_START),
185
- tempo=self.tempo,
198
+ tempo=self.tempo_historics,
186
199
  )
187
200
  stats.update({mode: resultat})
188
201
  return stats
@@ -203,6 +216,7 @@ class EnedisByPDL:
203
216
  self.ecowatt = None
204
217
  self.max_power = None
205
218
  self.has_collected = False
219
+ self.tempo_historics = None
206
220
  self.tempo_days = None
207
221
  self.tempo_prices = None
208
222
 
@@ -234,13 +248,13 @@ class EnedisByPDL:
234
248
  self.contract = await self._api.async_get_contract(self.pdl)
235
249
  self.offpeak_intervals = self._api.offpeaks
236
250
  except EnedisException as error:
237
- _LOGGER.warning(error)
251
+ logger.warning(error)
238
252
 
239
253
  if self.address is None and self.has_collected is False:
240
254
  try:
241
255
  self.address = await self._api.async_get_address(self.pdl)
242
256
  except EnedisException as error:
243
- _LOGGER.warning(error)
257
+ logger.warning(error)
244
258
 
245
259
  if self.ecowatt is None and self.has_ecowatt_subscription:
246
260
  self.ecowatt = await self._api.async_get_ecowatt(start, end)
@@ -250,9 +264,10 @@ class EnedisByPDL:
250
264
  self.pdl, start, end
251
265
  )
252
266
 
253
- if self.has_parameters and self.has_collected is False:
254
- await self.async_update_collects()
255
- self.last_refresh = local_now()
267
+ if self.tempo_historics is None and self.has_tempo_subscription:
268
+ start = local_now() - timedelta(days=1095)
269
+ end = local_now() + timedelta(days=1)
270
+ self.tempo_historics = await self._api.async_get_tempo(start, end)
256
271
 
257
272
  if self.tempo_prices is None and self.has_tempo_subscription:
258
273
  self.tempo_prices = await self._api.async_get_tempo_prices()
@@ -260,6 +275,10 @@ class EnedisByPDL:
260
275
  if self.tempo_days is None and self.has_tempo_subscription:
261
276
  self.tempo_days = await self._api.async_get_tempo_days()
262
277
 
278
+ if self.has_parameters and self.has_collected is False:
279
+ await self._async_fetch_data()
280
+ self.last_refresh = local_now()
281
+
263
282
  except EnedisException as error:
264
283
  raise error from error
265
284
  finally:
@@ -286,7 +305,7 @@ class EnedisByPDL:
286
305
  prices if isinstance(prices, Prices) else Prices.model_validate(prices)
287
306
  )
288
307
  except ValidationError as error:
289
- _LOGGER.error("Format is incorrect (%s)", error)
308
+ logger.error("Format is incorrect (%s)", error)
290
309
  return
291
310
 
292
311
  self._params[mode].update({ATTR_PRICES: model})
@@ -301,7 +320,7 @@ class EnedisByPDL:
301
320
  try:
302
321
  model = cum_sum if isinstance(cum_sum, Cum) else Cum.model_validate(cum_sum)
303
322
  except ValidationError as error:
304
- _LOGGER.error("Format is incorrect (%s)", error)
323
+ logger.error("Format is incorrect (%s)", error)
305
324
  return
306
325
 
307
326
  self._params[mode].update({f"cum_{form}".lower(): model})
@@ -315,6 +334,7 @@ class EnedisByPDL:
315
334
  prices: Prices | Mapping[str, Any] | None = None,
316
335
  cum_value: Cum | Mapping[str, Any] | None = None,
317
336
  cum_price: Cum | Mapping[str, Any] | None = None,
337
+ convert_kwh: bool = True,
318
338
  ) -> None:
319
339
  """Set parameters for data fetching.
320
340
 
@@ -323,8 +343,9 @@ class EnedisByPDL:
323
343
  end: date of end to collect data
324
344
  intervals: offpeak hours range - ex: [("01:00","05:00"),("12:00","14:00")]
325
345
  prices: price for standard interval and offpeak interval
326
- cum_sum: price of start
327
- cum_price:
346
+ cum_sum: Cumulative kwh value of start
347
+ cum_price: Cumulative price of start
348
+ convert_kwh: whether to convert kWh to Wh
328
349
  """
329
350
  funcs: dict[str, Callable[..., Any]] = {
330
351
  DAILY_PROD: self._api.async_get_daily_production,
@@ -350,9 +371,10 @@ class EnedisByPDL:
350
371
  if cum_price:
351
372
  self._set_cumsum(mode, ATTR_PRICE, cum_price)
352
373
 
374
+ self._convert_kwh = convert_kwh
353
375
  self.has_parameters = True
354
376
 
355
- async def async_update_collects(self) -> None:
377
+ async def _async_fetch_data(self) -> None:
356
378
  """Fetch data.
357
379
 
358
380
  It is necessary to value the initial data via the method: set_data_fetch.
@@ -381,9 +403,6 @@ class EnedisByPDL:
381
403
  }
382
404
  )
383
405
 
384
- if mode == ATTR_CONSUM and self.has_tempo_subscription:
385
- self.tempo = await self._api.async_get_tempo(start, end)
386
-
387
406
  self.has_collected = True
388
407
 
389
408
  async def async_close(self) -> None: