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.
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/release.yml +1 -1
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/PKG-INFO +2 -2
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/README.md +1 -1
- myelectricaldatapy-2.4.7/myelectricaldatapy/analytics.py +333 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/api.py +6 -6
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/auth.py +2 -2
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/mypdl.py +42 -23
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/types.py +23 -3
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/PKG-INFO +2 -2
- myelectricaldatapy-2.4.7/myelectricaldatapy.egg-info/scm_version.json +8 -0
- myelectricaldatapy-2.4.7/tests/fixtures/tempo.json +1 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/test_analytics.py +145 -17
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/test_load_data.py +1 -1
- 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/tests/fixtures/tempo.json +0 -1
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.devcontainer/devcontainer-lock.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.devcontainer/devcontainer.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/dependabot.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/auto-approve.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/python-lint.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.github/workflows/python-publish.yml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.gitignore +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.pre-commit-config.yaml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/.vscode/settings.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/LICENSE +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/_secrets.yaml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/example.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/__init__.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/const.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/exceptions.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/py.typed +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy/tz.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/SOURCES.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/dependency_links.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/requires.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/scm_file_list.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/myelectricaldatapy.egg-info/top_level.txt +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/pyproject.toml +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/setup.cfg +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/__init__.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/conftest.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/consts.py +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/access.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/address.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/contract.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/daily.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/detail.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/ecowatt.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/identity.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/tests/fixtures/invalid_access.json +0 -0
- {myelectricaldatapy-2.4.4 → myelectricaldatapy-2.4.7}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: myelectricaldatapy
|
|
3
|
-
Version: 2.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
|
-
| `
|
|
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
|
-
| `
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
-
|
|
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 "
|
|
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.
|
|
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
|
-
"""
|
|
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
|
|
153
|
+
def tempo(self) -> str | None:
|
|
154
154
|
"""Tempo day."""
|
|
155
155
|
str_date = local_now().strftime("%Y-%m-%d")
|
|
156
|
-
return
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|