e-data 1.2.6__tar.gz → 1.2.8__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 (26) hide show
  1. {e-data-1.2.6/e_data.egg-info → e-data-1.2.8}/PKG-INFO +1 -1
  2. {e-data-1.2.6 → e-data-1.2.8/e_data.egg-info}/PKG-INFO +1 -1
  3. {e-data-1.2.6 → e-data-1.2.8}/edata/connectors/datadis.py +124 -47
  4. {e-data-1.2.6 → e-data-1.2.8}/edata/definitions.py +33 -10
  5. {e-data-1.2.6 → e-data-1.2.8}/edata/helpers.py +61 -39
  6. {e-data-1.2.6 → e-data-1.2.8}/edata/processors/base.py +5 -5
  7. {e-data-1.2.6 → e-data-1.2.8}/edata/processors/billing.py +26 -10
  8. e-data-1.2.8/edata/processors/consumption.py +139 -0
  9. {e-data-1.2.6 → e-data-1.2.8}/setup.py +1 -1
  10. e-data-1.2.6/edata/processors/consumption.py +0 -103
  11. {e-data-1.2.6 → e-data-1.2.8}/LICENSE +0 -0
  12. {e-data-1.2.6 → e-data-1.2.8}/MANIFEST.in +0 -0
  13. {e-data-1.2.6 → e-data-1.2.8}/README.md +0 -0
  14. {e-data-1.2.6 → e-data-1.2.8}/e_data.egg-info/SOURCES.txt +0 -0
  15. {e-data-1.2.6 → e-data-1.2.8}/e_data.egg-info/dependency_links.txt +0 -0
  16. {e-data-1.2.6 → e-data-1.2.8}/e_data.egg-info/requires.txt +0 -0
  17. {e-data-1.2.6 → e-data-1.2.8}/e_data.egg-info/top_level.txt +0 -0
  18. {e-data-1.2.6 → e-data-1.2.8}/edata/__init__.py +0 -0
  19. {e-data-1.2.6 → e-data-1.2.8}/edata/connectors/__init__.py +0 -0
  20. {e-data-1.2.6 → e-data-1.2.8}/edata/connectors/redata.py +0 -0
  21. {e-data-1.2.6 → e-data-1.2.8}/edata/const.py +0 -0
  22. {e-data-1.2.6 → e-data-1.2.8}/edata/processors/__init__.py +0 -0
  23. {e-data-1.2.6 → e-data-1.2.8}/edata/processors/maximeter.py +0 -0
  24. {e-data-1.2.6 → e-data-1.2.8}/edata/processors/utils.py +0 -0
  25. {e-data-1.2.6 → e-data-1.2.8}/edata/storage.py +0 -0
  26. {e-data-1.2.6 → e-data-1.2.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: e-data
3
- Version: 1.2.6
3
+ Version: 1.2.8
4
4
  Summary: Python library for managing spanish energy data from various web providers
5
5
  Home-page: https://github.com/uvejota/python-edata
6
6
  Author: VMG
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: e-data
3
- Version: 1.2.6
3
+ Version: 1.2.8
4
4
  Summary: Python library for managing spanish energy data from various web providers
5
5
  Home-page: https://github.com/uvejota/python-edata
6
6
  Author: VMG
@@ -1,5 +1,13 @@
1
- """Definitions for API connectors."""
1
+ """Datadis API connector.
2
2
 
3
+ To fetch data from datadis.es private API.
4
+ There a few issues that are workarounded:
5
+ - You have to wait 24h between two identical requests.
6
+ - Datadis server does not like ranges greater than 1 month.
7
+ - TODO: gzip header checksum errors under weird circumstances
8
+ """
9
+
10
+ import contextlib
3
11
  import hashlib
4
12
  import json
5
13
  import logging
@@ -14,9 +22,12 @@ from ..processors import utils
14
22
 
15
23
  _LOGGER = logging.getLogger(__name__)
16
24
 
25
+ # Token-related constants
17
26
  URL_TOKEN = "https://datadis.es/nikola-auth/tokens/login"
18
27
  TOKEN_USERNAME = "username"
19
28
  TOKEN_PASSWD = "password"
29
+
30
+ # Supplies-related constants
20
31
  URL_GET_SUPPLIES = "https://datadis.es/api-private/api/get-supplies"
21
32
  GET_SUPPLIES_MANDATORY_FIELDS = [
22
33
  "cups",
@@ -25,6 +36,8 @@ GET_SUPPLIES_MANDATORY_FIELDS = [
25
36
  "pointType",
26
37
  "distributorCode",
27
38
  ]
39
+
40
+ # Contracts-related constants
28
41
  URL_GET_CONTRACT_DETAIL = "https://datadis.es/api-private/api/get-contract-detail"
29
42
  GET_CONTRACT_DETAIL_MANDATORY_FIELDS = [
30
43
  "startDate",
@@ -32,6 +45,8 @@ GET_CONTRACT_DETAIL_MANDATORY_FIELDS = [
32
45
  "marketer",
33
46
  "contractedPowerkW",
34
47
  ]
48
+
49
+ # Consumption-related constants
35
50
  URL_GET_CONSUMPTION_DATA = "https://datadis.es/api-private/api/get-consumption-data"
36
51
  GET_CONSUMPTION_DATA_MANDATORY_FIELDS = [
37
52
  "time",
@@ -39,17 +54,23 @@ GET_CONSUMPTION_DATA_MANDATORY_FIELDS = [
39
54
  "consumptionKWh",
40
55
  "obtainMethod",
41
56
  ]
57
+ MAX_CONSUMPTIONS_MONTHS = (
58
+ 1 # max consumptions in a single request (fixed to 1 due to datadis limitations)
59
+ )
60
+
61
+ # Maximeter-related constants
42
62
  URL_GET_MAX_POWER = "https://datadis.es/api-private/api/get-max-power"
43
63
  GET_MAX_POWER_MANDATORY_FIELDS = ["time", "date", "maxPower"]
44
64
 
45
- TIMEOUT = 3 * 60
46
-
47
- MAX_CONSUMPTIONS_MONTHS = 1
48
-
49
- QUERY_LIMIT = timedelta(hours=24)
65
+ # Timing constants
66
+ TIMEOUT = 3 * 60 # requests timeout
67
+ QUERY_LIMIT = timedelta(hours=24) # a datadis limitation, again...
50
68
 
69
+ # Cache-related constants
51
70
  RECENT_QUERIES_FILENAME = "edata_recent_queries.json"
52
- RECENT_QUERIES_FILE = f"/tmp/{RECENT_QUERIES_FILENAME}"
71
+ RECENT_QUERIES_CACHE_FILENAME = "edata_recent_queries_cache.json"
72
+ DEFAULT_RECENT_QUERIES_FILE = f"/tmp/{RECENT_QUERIES_FILENAME}"
73
+ DEFAULT_RECENT_QUERIES_CACHE = f"/tmp/{RECENT_QUERIES_CACHE_FILENAME}"
53
74
 
54
75
 
55
76
  class DatadisConnector:
@@ -62,54 +83,71 @@ class DatadisConnector:
62
83
  enable_smart_fetch: bool = True,
63
84
  storage_path: str | None = None,
64
85
  ) -> None:
65
- """Init method."""
86
+ """DatadisConnector constructor."""
66
87
 
88
+ # initialize some things
67
89
  self._usr = username
68
90
  self._pwd = password
69
91
  self._session = requests.Session()
70
92
  self._token = {}
71
93
  self._smart_fetch = enable_smart_fetch
72
-
94
+ self._recent_queries = {}
95
+ self._recent_cache = {}
73
96
  if storage_path is not None:
74
97
  self._recent_queries_file = os.path.join(
75
98
  storage_path, RECENT_QUERIES_FILENAME
76
99
  )
77
- # TODO FIX this is not working after migration
100
+ self._recent_queries_cache_file = os.path.join(
101
+ storage_path, RECENT_QUERIES_CACHE_FILENAME
102
+ )
78
103
  else:
79
- self._recent_queries_file = RECENT_QUERIES_FILE
80
-
104
+ self._recent_queries_file = DEFAULT_RECENT_QUERIES_FILE
105
+ self._recent_queries_cache_file = DEFAULT_RECENT_QUERIES_CACHE
81
106
  self._warned_queries = []
82
107
 
83
- try:
108
+ # load caches (to avoid query spam if the program restarts)
109
+ with contextlib.suppress(FileNotFoundError):
84
110
  with open(self._recent_queries_file, encoding="utf8") as dst_file:
85
111
  self._recent_queries = json.load(dst_file)
86
112
  for query in self._recent_queries:
87
113
  self._recent_queries[query] = datetime.fromisoformat(
88
114
  self._recent_queries[query]
89
115
  )
90
- except FileNotFoundError:
91
- self._recent_queries = {}
116
+ with open(self._recent_queries_cache_file, encoding="utf8") as dst_file:
117
+ self._recent_cache = json.load(dst_file)
92
118
 
93
- def _update_recent_queries(self, query: str) -> None:
94
- """Record a recent successful query to avoid exceeding query limits."""
119
+ def _update_recent_queries(self, query: str, data: dict | None = None) -> None:
120
+ """Cache a successful query to avoid exceeding query limits."""
95
121
 
122
+ # identify the query by a md5 hash
96
123
  hash_query = hashlib.md5(query.encode()).hexdigest()
124
+ # prepare key (hash) and values (timestamp and response)
97
125
  self._recent_queries[hash_query] = datetime.now()
126
+ if data is not None:
127
+ self._recent_cache[hash_query] = data
98
128
 
99
- # purge old queries
129
+ # purge old cache
100
130
  to_delete = []
101
131
  for _query in self._recent_queries:
102
132
  if (datetime.now() - self._recent_queries[_query]) > QUERY_LIMIT:
103
133
  to_delete.append(_query)
104
134
 
105
135
  for key in to_delete:
106
- self._recent_queries.pop(key, None)
136
+ with contextlib.suppress(KeyError):
137
+ self._recent_queries.pop(key, None)
138
+ self._recent_cache.pop(key, None)
107
139
 
140
+ # dump current cache to disk
108
141
  try:
109
142
  with open(self._recent_queries_file, "w", encoding="utf8") as dst_file:
110
143
  json.dump(utils.serialize_dict(self._recent_queries), dst_file)
111
- except Exception:
112
- pass
144
+ if data is not None:
145
+ with open(
146
+ self._recent_queries_cache_file, "w", encoding="utf8"
147
+ ) as dst_file:
148
+ json.dump(self._recent_cache, dst_file)
149
+ except Exception as e:
150
+ _LOGGER.warning("Unknown error while updating cache: %s", e)
113
151
 
114
152
  def _is_recent_query(self, query: str) -> bool:
115
153
  """Check if a query has been done recently to avoid exceeding query limits."""
@@ -119,6 +157,12 @@ class DatadisConnector:
119
157
  return (datetime.now() - self._recent_queries[hash_query]) < QUERY_LIMIT
120
158
  return False
121
159
 
160
+ def _get_cache_for_query(self, query: str) -> dict:
161
+ """Return cached response for a query."""
162
+ hash_query = hashlib.md5(query.encode()).hexdigest()
163
+
164
+ return self._recent_cache.get(hash_query, None)
165
+
122
166
  def _get_token(self):
123
167
  """Private method that fetches a new token if needed."""
124
168
 
@@ -137,7 +181,6 @@ class DatadisConnector:
137
181
  self._token["encoded"] = response.text
138
182
  # prepare session authorization bearer
139
183
  self._session.headers["Authorization"] = "Bearer " + self._token["encoded"]
140
- _LOGGER.debug("token received")
141
184
  is_valid_token = True
142
185
  else:
143
186
  _LOGGER.error("Unknown error while retrieving token, got %s", response.text)
@@ -147,7 +190,7 @@ class DatadisConnector:
147
190
  """Test to login with provided credentials."""
148
191
  return self._get_token()
149
192
 
150
- def _send_cmd(
193
+ def _get(
151
194
  self,
152
195
  url: str,
153
196
  request_data: dict | None = None,
@@ -155,7 +198,7 @@ class DatadisConnector:
155
198
  is_retry: bool = False,
156
199
  ignore_recent_queries: bool = False,
157
200
  ):
158
- """Common method for GET requests."""
201
+ """Get request for Datadis API."""
159
202
 
160
203
  if request_data is None:
161
204
  data = {}
@@ -174,31 +217,49 @@ class DatadisConnector:
174
217
  key = param
175
218
  value = data[param]
176
219
  params = params + f"{key}={value}&"
177
- # query
178
220
 
221
+ # check if query is already in cache
179
222
  if not ignore_recent_queries and self._is_recent_query(url + params):
180
- return response
223
+ _cache = self._get_cache_for_query(url + params)
224
+ if _cache is not None:
225
+ return _cache
226
+ return []
181
227
 
228
+ # run the query
182
229
  try:
183
- _LOGGER.info("GET %s", url + params)
184
- reply = self._session.get(url + params, timeout=TIMEOUT)
230
+ _LOGGER.debug("GET %s", url + params)
231
+ reply = self._session.get(
232
+ url + params,
233
+ headers={"Accept-Encoding": "identity"},
234
+ timeout=TIMEOUT,
235
+ )
185
236
  except requests.exceptions.Timeout:
186
237
  _LOGGER.warning("Timeout at %s", url + params)
187
- return response
238
+ return []
188
239
 
189
240
  # eval response
190
- if reply.status_code == 200 and reply.json():
241
+ if reply.status_code == 200:
242
+ # we're here if reply seems valid
191
243
  _LOGGER.info("Got 200 OK at %s", url + params)
192
- response = reply.json()
193
- self._update_recent_queries(url + params)
244
+ if reply.json():
245
+ response = reply.json()
246
+ self._update_recent_queries(url + params, response)
247
+ else:
248
+ # this mostly happens when datadis provides an empty response
249
+ _LOGGER.info(
250
+ "Datadis returned an empty response at %s", url + params
251
+ )
252
+ self._update_recent_queries(url + params)
194
253
  elif reply.status_code == 401 and not refresh_token:
195
- response = self._send_cmd(
254
+ # we're here if we were unauthorized so we will refresh the token
255
+ response = self._get(
196
256
  url,
197
257
  request_data=data,
198
258
  refresh_token=True,
199
259
  ignore_recent_queries=ignore_recent_queries,
200
260
  )
201
261
  elif reply.status_code == 429:
262
+ # we're here if we exceeded datadis API rates (24h)
202
263
  _LOGGER.warning(
203
264
  "%s %s at %s",
204
265
  reply.status_code,
@@ -206,12 +267,8 @@ class DatadisConnector:
206
267
  url + params,
207
268
  )
208
269
  self._update_recent_queries(url + params)
209
- elif reply.status_code == 200:
210
- _LOGGER.info(
211
- "%s returned an empty response, try again later", url + params
212
- )
213
- self._update_recent_queries(url + params)
214
270
  elif is_retry:
271
+ # otherwise, if this was a retried request... warn the user
215
272
  if (url + params) not in self._warned_queries:
216
273
  _LOGGER.warning(
217
274
  "%s %s at %s. %s. %s",
@@ -224,41 +281,61 @@ class DatadisConnector:
224
281
  self._update_recent_queries(url + params)
225
282
  self._warned_queries.append(url + params)
226
283
  else:
227
- self._send_cmd(url, request_data, is_retry=True)
284
+ # finally, retry since an unexpected error took place (mostly 500 errors - server fault)
285
+ response = self._get(
286
+ url,
287
+ request_data,
288
+ is_retry=True,
289
+ ignore_recent_queries=ignore_recent_queries,
290
+ )
291
+
228
292
  return response
229
293
 
230
294
  def get_supplies(self, authorized_nif: str | None = None):
231
- """Datadis get_supplies query."""
295
+ """Datadis 'get_supplies' query."""
296
+
232
297
  data = {}
298
+
299
+ # If authorized_nif is provided, we have to include it as parameter
233
300
  if authorized_nif is not None:
234
301
  data["authorizedNif"] = authorized_nif
235
- response = self._send_cmd(
302
+
303
+ # Request the resource
304
+ response = self._get(
236
305
  URL_GET_SUPPLIES, request_data=data, ignore_recent_queries=False
237
306
  )
307
+
308
+ # Response is a list of serialized supplies.
309
+ # We will iter through them to transform them into SupplyData objects
238
310
  supplies = []
311
+ # Build tomorrow Y/m/d string since we will use it as the 'date_end' of
312
+ # active supplies
239
313
  tomorrow_str = (datetime.today() + timedelta(days=1)).strftime("%Y/%m/%d")
240
314
  for i in response:
315
+ # check data integrity (maybe this can be supressed if datadis proves to be reliable)
241
316
  if all(k in i for k in GET_SUPPLIES_MANDATORY_FIELDS):
242
317
  supplies.append(
243
318
  SupplyData(
244
- cups=i["cups"],
319
+ cups=i["cups"], # the supply identifier
245
320
  date_start=datetime.strptime(
246
321
  i["validDateFrom"]
247
322
  if i["validDateFrom"] != ""
248
323
  else "1970/01/01",
249
324
  "%Y/%m/%d",
250
- ),
325
+ ), # start date of the supply. 1970/01/01 if unset.
251
326
  date_end=datetime.strptime(
252
327
  i["validDateTo"]
253
328
  if i["validDateTo"] != ""
254
329
  else tomorrow_str,
255
330
  "%Y/%m/%d",
256
- ),
331
+ ), # end date of the supply, tomorrow if unset
332
+ # the following parameters are not crucial, so they can be none
257
333
  address=i["address"] if "address" in i else None,
258
334
  postal_code=i["postalCode"] if "postalCode" in i else None,
259
335
  province=i["province"] if "province" in i else None,
260
336
  municipality=i["municipality"] if "municipality" in i else None,
261
337
  distributor=i["distributor"] if "distributor" in i else None,
338
+ # these two are mandatory, we will use them to fetch contracts data
262
339
  pointType=i["pointType"],
263
340
  distributorCode=i["distributorCode"],
264
341
  )
@@ -277,7 +354,7 @@ class DatadisConnector:
277
354
  data = {"cups": cups, "distributorCode": distributor_code}
278
355
  if authorized_nif is not None:
279
356
  data["authorizedNif"] = authorized_nif
280
- response = self._send_cmd(
357
+ response = self._get(
281
358
  URL_GET_CONTRACT_DETAIL, request_data=data, ignore_recent_queries=False
282
359
  )
283
360
  contracts = []
@@ -359,7 +436,7 @@ class DatadisConnector:
359
436
  if authorized_nif is not None:
360
437
  data["authorizedNif"] = authorized_nif
361
438
 
362
- response = self._send_cmd(URL_GET_CONSUMPTION_DATA, request_data=data)
439
+ response = self._get(URL_GET_CONSUMPTION_DATA, request_data=data)
363
440
 
364
441
  consumptions = []
365
442
  for i in response:
@@ -405,7 +482,7 @@ class DatadisConnector:
405
482
  }
406
483
  if authorized_nif is not None:
407
484
  data["authorizedNif"] = authorized_nif
408
- response = self._send_cmd(URL_GET_MAX_POWER, request_data=data)
485
+ response = self._get(URL_GET_MAX_POWER, request_data=data)
409
486
  maxpower_values = []
410
487
  for i in response:
411
488
  if all(k in i for k in GET_MAX_POWER_MANDATORY_FIELDS):
@@ -14,18 +14,30 @@ ATTRIBUTES = {
14
14
  "yesterday_p1_kWh": "kWh",
15
15
  "yesterday_p2_kWh": "kWh",
16
16
  "yesterday_p3_kWh": "kWh",
17
+ "yesterday_surplus_kWh": "kWh",
18
+ "yesterday_surplus_p1_kWh": "kWh",
19
+ "yesterday_surplus_p2_kWh": "kWh",
20
+ "yesterday_surplus_p3_kWh": "kWh",
17
21
  "last_registered_date": None,
18
22
  "last_registered_day_kWh": "kWh",
19
23
  "last_registered_day_hours": "h",
20
24
  "last_registered_day_p1_kWh": "kWh",
21
25
  "last_registered_day_p2_kWh": "kWh",
22
26
  "last_registered_day_p3_kWh": "kWh",
27
+ "last_registered_day_surplus_kWh": "kWh",
28
+ "last_registered_day_surplus_p1_kWh": "kWh",
29
+ "last_registered_day_surplus_p2_kWh": "kWh",
30
+ "last_registered_day_surplus_p3_kWh": "kWh",
23
31
  "month_kWh": "kWh",
24
32
  "month_daily_kWh": "kWh",
25
33
  "month_days": "d",
26
34
  "month_p1_kWh": "kWh",
27
35
  "month_p2_kWh": "kWh",
28
36
  "month_p3_kWh": "kWh",
37
+ "month_surplus_kWh": "kWh",
38
+ "month_surplus_p1_kWh": "kWh",
39
+ "month_surplus_p2_kWh": "kWh",
40
+ "month_surplus_p3_kWh": "kWh",
29
41
  "month_€": "€",
30
42
  "last_month_kWh": "kWh",
31
43
  "last_month_daily_kWh": "kWh",
@@ -33,6 +45,10 @@ ATTRIBUTES = {
33
45
  "last_month_p1_kWh": "kWh",
34
46
  "last_month_p2_kWh": "kWh",
35
47
  "last_month_p3_kWh": "kWh",
48
+ "last_month_surplus_kWh": "kWh",
49
+ "last_month_surplus_p1_kWh": "kWh",
50
+ "last_month_surplus_p2_kWh": "kWh",
51
+ "last_month_surplus_p3_kWh": "kWh",
36
52
  "last_month_€": "€",
37
53
  "max_power_kW": "kW",
38
54
  "max_power_date": None,
@@ -118,7 +134,7 @@ ConsumptionSchema = vol.Schema(
118
134
  vol.Required("datetime"): dt.datetime,
119
135
  vol.Required("delta_h"): vol.Coerce(float),
120
136
  vol.Required("value_kWh"): vol.Coerce(float),
121
- vol.Optional("surplus_kWh", default=0): vol.Union(vol.Coerce(float), None),
137
+ vol.Optional("surplus_kWh", default=0): vol.Coerce(float),
122
138
  vol.Required("real"): bool,
123
139
  }
124
140
  )
@@ -175,22 +191,23 @@ class PricingRules(TypedDict):
175
191
  power_formula: str | None
176
192
  others_formula: str | None
177
193
  surplus_formula: str | None
194
+ cycle_start_day: int | None
178
195
 
179
196
 
180
197
  PricingRulesSchema = vol.Schema(
181
198
  {
182
199
  vol.Required("p1_kw_year_eur"): vol.Coerce(float),
183
200
  vol.Required("p2_kw_year_eur"): vol.Coerce(float),
184
- vol.Required("p1_kwh_eur"): vol.Union(vol.Coerce(float), None),
185
- vol.Required("p2_kwh_eur"): vol.Union(vol.Coerce(float), None),
186
- vol.Required("p3_kwh_eur"): vol.Union(vol.Coerce(float), None),
187
- vol.Optional("surplus_p1_kwh_eur", default=0): vol.Union(
201
+ vol.Optional("p1_kwh_eur", default=None): vol.Union(vol.Coerce(float), None),
202
+ vol.Optional("p2_kwh_eur", default=None): vol.Union(vol.Coerce(float), None),
203
+ vol.Optional("p3_kwh_eur", default=None): vol.Union(vol.Coerce(float), None),
204
+ vol.Optional("surplus_p1_kwh_eur", default=None): vol.Union(
188
205
  vol.Coerce(float), None
189
206
  ),
190
- vol.Optional("surplus_p2_kwh_eur", default=0): vol.Union(
207
+ vol.Optional("surplus_p2_kwh_eur", default=None): vol.Union(
191
208
  vol.Coerce(float), None
192
209
  ),
193
- vol.Optional("surplus_p3_kwh_eur", default=0): vol.Union(
210
+ vol.Optional("surplus_p3_kwh_eur", default=None): vol.Union(
194
211
  vol.Coerce(float), None
195
212
  ),
196
213
  vol.Required("meter_month_eur"): vol.Coerce(float),
@@ -201,6 +218,7 @@ PricingRulesSchema = vol.Schema(
201
218
  vol.Optional("power_formula", default=DEFAULT_BILLING_POWER_FORMULA): str,
202
219
  vol.Optional("others_formula", default=DEFAULT_BILLING_OTHERS_FORMULA): str,
203
220
  vol.Optional("surplus_formula", default=DEFAULT_BILLING_SURPLUS_FORMULA): str,
221
+ vol.Optional("cycle_start_day", default=1): vol.Range(1, 30),
204
222
  }
205
223
  )
206
224
 
@@ -211,9 +229,6 @@ DEFAULT_PVPC_RULES = PricingRules(
211
229
  market_kw_year_eur=3.113,
212
230
  electricity_tax=1.0511300560,
213
231
  iva_tax=1.05,
214
- p1_kwh_eur=None,
215
- p2_kwh_eur=None,
216
- p3_kwh_eur=None,
217
232
  )
218
233
 
219
234
 
@@ -225,6 +240,10 @@ class ConsumptionAggData(TypedDict):
225
240
  value_p1_kWh: float
226
241
  value_p2_kWh: float
227
242
  value_p3_kWh: float
243
+ surplus_kWh: float
244
+ surplus_p1_kWh: float
245
+ surplus_p2_kWh: float
246
+ surplus_p3_kWh: float
228
247
  delta_h: float
229
248
 
230
249
 
@@ -235,6 +254,10 @@ ConsumptionAggSchema = vol.Schema(
235
254
  vol.Required("value_p1_kWh"): vol.Coerce(float),
236
255
  vol.Required("value_p2_kWh"): vol.Coerce(float),
237
256
  vol.Required("value_p3_kWh"): vol.Coerce(float),
257
+ vol.Optional("surplus_kWh", default=0): vol.Coerce(float),
258
+ vol.Optional("surplus_p1_kWh", default=0): vol.Coerce(float),
259
+ vol.Optional("surplus_p2_kWh", default=0): vol.Coerce(float),
260
+ vol.Optional("surplus_p3_kWh", default=0): vol.Coerce(float),
238
261
  vol.Required("delta_h"): vol.Coerce(float),
239
262
  }
240
263
  )
@@ -433,7 +433,14 @@ class EdataHelper:
433
433
  new_data_from = self._date_from
434
434
 
435
435
  proc = ConsumptionProcessor(
436
- [x for x in self.data["consumptions"] if x["datetime"] >= new_data_from]
436
+ {
437
+ "consumptions": [
438
+ x
439
+ for x in self.data["consumptions"]
440
+ if x["datetime"] >= new_data_from
441
+ ],
442
+ "cycle_start_day": self.pricing_rules.get("cycle_start_day", 1),
443
+ }
437
444
  )
438
445
  today_starts = datetime(
439
446
  datetime.today().year,
@@ -472,15 +479,23 @@ class EdataHelper:
472
479
  self.attributes["yesterday_kWh"] = (
473
480
  yday.get("value_kWh", None) if yday is not None else None
474
481
  )
475
- self.attributes["yesterday_p1_kWh"] = (
476
- yday.get("value_p1_kWh", None) if yday is not None else None
477
- )
478
- self.attributes["yesterday_p2_kWh"] = (
479
- yday.get("value_p2_kWh", None) if yday is not None else None
480
- )
481
- self.attributes["yesterday_p3_kWh"] = (
482
- yday.get("value_p3_kWh", None) if yday is not None else None
482
+
483
+ for tariff in (1, 2, 3):
484
+ self.attributes[f"yesterday_p{tariff}_kWh"] = (
485
+ yday.get(f"value_p{tariff}_kWh", None) if yday is not None else None
486
+ )
487
+
488
+ self.attributes["yesterday_surplus_kWh"] = (
489
+ yday.get("surplus_kWh", None) if yday is not None else None
483
490
  )
491
+
492
+ for tariff in (1, 2, 3):
493
+ self.attributes[f"yesterday_surplus_p{tariff}_kWh"] = (
494
+ yday.get(f"surplus_p{tariff}_kWh", None)
495
+ if yday is not None
496
+ else None
497
+ )
498
+
484
499
  self.attributes["yesterday_hours"] = (
485
500
  yday.get("delta_h", None) if yday is not None else None
486
501
  )
@@ -491,6 +506,9 @@ class EdataHelper:
491
506
  self.attributes["month_kWh"] = (
492
507
  month.get("value_kWh", None) if month is not None else None
493
508
  )
509
+ self.attributes["month_surplus_kWh"] = (
510
+ month.get("surplus_kWh", None) if month is not None else None
511
+ )
494
512
  self.attributes["month_days"] = (
495
513
  month.get("delta_h", 0) / 24 if month is not None else None
496
514
  )
@@ -503,15 +521,13 @@ class EdataHelper:
503
521
  if month is not None
504
522
  else None
505
523
  )
506
- self.attributes["month_p1_kWh"] = (
507
- month.get("value_p1_kWh", None) if month is not None else None
508
- )
509
- self.attributes["month_p2_kWh"] = (
510
- month.get("value_p2_kWh", None) if month is not None else None
511
- )
512
- self.attributes["month_p3_kWh"] = (
513
- month.get("value_p3_kWh", None) if month is not None else None
514
- )
524
+
525
+ for tariff in (1, 2, 3):
526
+ self.attributes[f"month_surplus_p{tariff}_kWh"] = (
527
+ month.get(f"surplus_p{tariff}_kWh", None)
528
+ if month is not None
529
+ else None
530
+ )
515
531
 
516
532
  last_month = utils.get_by_key(
517
533
  self.data["consumptions_monthly_sum"],
@@ -521,6 +537,9 @@ class EdataHelper:
521
537
  self.attributes["last_month_kWh"] = (
522
538
  last_month.get("value_kWh", None) if last_month is not None else None
523
539
  )
540
+ self.attributes["last_month_surplus_kWh"] = (
541
+ last_month.get("surplus_kWh", None) if last_month is not None else None
542
+ )
524
543
  self.attributes["last_month_days"] = (
525
544
  last_month.get("delta_h", 0) / 24 if last_month is not None else None
526
545
  )
@@ -536,15 +555,18 @@ class EdataHelper:
536
555
  if last_month is not None
537
556
  else None
538
557
  )
539
- self.attributes["last_month_p1_kWh"] = (
540
- last_month.get("value_p1_kWh", None) if last_month is not None else None
541
- )
542
- self.attributes["last_month_p2_kWh"] = (
543
- last_month.get("value_p2_kWh", None) if last_month is not None else None
544
- )
545
- self.attributes["last_month_p3_kWh"] = (
546
- last_month.get("value_p3_kWh", None) if last_month is not None else None
547
- )
558
+ for tariff in (1, 2, 3):
559
+ self.attributes[f"last_month_p{tariff}_kWh"] = (
560
+ last_month.get(f"value_p{tariff}_kWh", None)
561
+ if last_month is not None
562
+ else None
563
+ )
564
+ for tariff in (1, 2, 3):
565
+ self.attributes[f"last_month_surplus_p{tariff}_kWh"] = (
566
+ last_month.get(f"surplus_p{tariff}_kWh", None)
567
+ if last_month is not None
568
+ else None
569
+ )
548
570
 
549
571
  if len(self.data["consumptions"]) > 0:
550
572
  self.attributes["last_registered_date"] = self.data["consumptions"][-1][
@@ -558,21 +580,21 @@ class EdataHelper:
558
580
  if last_day is not None
559
581
  else None
560
582
  )
561
- self.attributes["last_registered_day_p1_kWh"] = (
562
- last_day.get("value_p1_kWh", None)
563
- if last_day is not None
564
- else None
565
- )
566
- self.attributes["last_registered_day_p2_kWh"] = (
567
- last_day.get("value_p2_kWh", None)
568
- if last_day is not None
569
- else None
570
- )
571
- self.attributes["last_registered_day_p3_kWh"] = (
572
- last_day.get("value_p3_kWh", None)
583
+ self.attributes["last_registered_day_surplus_kWh"] = (
584
+ last_day.get("surplus_kWh", None)
573
585
  if last_day is not None
574
586
  else None
575
587
  )
588
+
589
+ for tariff in (1, 2, 3):
590
+ self.attributes[
591
+ f"last_registered_day_surplus_p{tariff}_kWh"
592
+ ] = (
593
+ last_day.get(f"surplus_p{tariff}_kWh", None)
594
+ if last_day is not None
595
+ else None
596
+ )
597
+
576
598
  self.attributes["last_registered_day_hours"] = (
577
599
  last_day.get("delta_h", None) if last_day is not None else None
578
600
  )
@@ -2,7 +2,7 @@
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
  from copy import deepcopy
5
- from typing import Any, Iterable
5
+ from typing import Any
6
6
 
7
7
 
8
8
  class Processor(ABC):
@@ -10,8 +10,8 @@ class Processor(ABC):
10
10
 
11
11
  _LABEL = "Processor"
12
12
 
13
- def __init__(self, input_data: Iterable[dict[str, Any]], auto: bool = True):
14
- """Init method"""
13
+ def __init__(self, input_data: Any, auto: bool = True):
14
+ """Init method."""
15
15
  self._input = deepcopy(input_data)
16
16
  self._output = None
17
17
  if auto:
@@ -19,9 +19,9 @@ class Processor(ABC):
19
19
 
20
20
  @abstractmethod
21
21
  def do_process(self):
22
- """The processing method"""
22
+ """The processing method."""
23
23
 
24
24
  @property
25
25
  def output(self):
26
- """An output property"""
26
+ """An output property."""
27
27
  return deepcopy(self._output)
@@ -1,4 +1,4 @@
1
- """Billing data processors"""
1
+ """Billing data processors."""
2
2
 
3
3
  import logging
4
4
  from datetime import datetime, timedelta
@@ -20,12 +20,13 @@ from ..definitions import (
20
20
  )
21
21
  from ..processors import utils
22
22
  from ..processors.base import Processor
23
+ import contextlib
23
24
 
24
25
  _LOGGER = logging.getLogger(__name__)
25
26
 
26
27
 
27
28
  class BillingOutput(TypedDict):
28
- """A dict holding BillingProcessor output property"""
29
+ """A dict holding BillingProcessor output property."""
29
30
 
30
31
  hourly: list[PricingAggData]
31
32
  daily: list[PricingAggData]
@@ -33,7 +34,7 @@ class BillingOutput(TypedDict):
33
34
 
34
35
 
35
36
  class BillingInput(TypedDict):
36
- """A dict holding BillingProcessor input data"""
37
+ """A dict holding BillingProcessor input data."""
37
38
 
38
39
  contracts: list[ContractData]
39
40
  consumptions: list[ConsumptionData]
@@ -42,10 +43,10 @@ class BillingInput(TypedDict):
42
43
 
43
44
 
44
45
  class BillingProcessor(Processor):
45
- """A billing processor for edata"""
46
+ """A billing processor for edata."""
46
47
 
47
48
  def do_process(self):
48
- """Main method for the BillingProcessor"""
49
+ """Main method for the BillingProcessor."""
49
50
  self._output = BillingOutput(hourly=[], daily=[], monthly=[])
50
51
 
51
52
  _schema = voluptuous.Schema(
@@ -60,6 +61,8 @@ class BillingProcessor(Processor):
60
61
  )
61
62
  self._input = _schema(self._input)
62
63
 
64
+ self._cycle_offset = self._input["rules"]["cycle_start_day"] - 1
65
+
63
66
  # joint data by datetime
64
67
  _data = {
65
68
  x["datetime"]: {
@@ -124,12 +127,23 @@ class BillingProcessor(Processor):
124
127
  elif tariff == "p3":
125
128
  x["surplus_kwh_eur"] = x["surplus_p3_kwh_eur"]
126
129
 
130
+ _energy_term = 0
131
+ _power_term = 0
132
+ _others_term = 0
133
+ _surplus_term = 0
134
+
135
+ with contextlib.suppress(Exception):
136
+ _energy_term = round(energy_expr(**x), 3)
137
+ _power_term = round(power_expr(**x), 3)
138
+ _others_term = round(others_expr(**x), 3)
139
+ _surplus_term = round(surplus_expr(**x), 3)
140
+
127
141
  new_item = PricingAggData(
128
142
  datetime=x["datetime"],
129
- energy_term=round(energy_expr(**x), 3),
130
- power_term=round(power_expr(**x), 3),
131
- others_term=round(others_expr(**x), 3),
132
- surplus_term=round(surplus_expr(**x), 3),
143
+ energy_term=_energy_term,
144
+ power_term=_power_term,
145
+ others_term=_others_term,
146
+ surplus_term=_surplus_term,
133
147
  value_eur=0,
134
148
  delta_h=1,
135
149
  )
@@ -151,7 +165,9 @@ class BillingProcessor(Processor):
151
165
  for hour in hourly:
152
166
  curr_hour_dt: datetime = hour["datetime"]
153
167
  curr_day_dt = curr_hour_dt.replace(hour=0, minute=0, second=0)
154
- curr_month_dt = curr_day_dt.replace(day=1)
168
+ curr_month_dt = (curr_day_dt - timedelta(days=self._cycle_offset)).replace(
169
+ day=1
170
+ )
155
171
 
156
172
  if last_day_dt is None or curr_day_dt != last_day_dt:
157
173
  self._output["daily"].append(
@@ -0,0 +1,139 @@
1
+ """Consumption data processors."""
2
+
3
+ import logging
4
+ from collections.abc import Iterable
5
+ from typing import TypedDict
6
+ from datetime import datetime, timedelta
7
+
8
+ import voluptuous
9
+
10
+ from ..definitions import ConsumptionAggData, ConsumptionSchema
11
+ from . import utils
12
+ from .base import Processor
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class ConsumptionOutput(TypedDict):
18
+ """A dict holding ConsumptionProcessor output property."""
19
+
20
+ daily: Iterable[ConsumptionAggData]
21
+ monthly: Iterable[ConsumptionAggData]
22
+
23
+
24
+ class ConsumptionProcessor(Processor):
25
+ """A consumptions processor."""
26
+
27
+ def do_process(self):
28
+ """Calculate daily and monthly consumption stats."""
29
+
30
+ self._output = ConsumptionOutput(daily=[], monthly=[])
31
+
32
+ last_day_dt = None
33
+ last_month_dt = None
34
+
35
+ _schema = voluptuous.Schema(
36
+ {
37
+ voluptuous.Required("consumptions"): [ConsumptionSchema],
38
+ voluptuous.Optional("cycle_start_day", default=1): voluptuous.Range(
39
+ 1, 30
40
+ ),
41
+ }
42
+ )
43
+ self._input = _schema(self._input)
44
+
45
+ self._cycle_offset = self._input["cycle_start_day"] - 1
46
+
47
+ for consumption in self._input["consumptions"]:
48
+ curr_hour_dt: datetime = consumption["datetime"]
49
+ curr_day_dt = curr_hour_dt.replace(hour=0, minute=0, second=0)
50
+ curr_month_dt = (curr_day_dt - timedelta(days=self._cycle_offset)).replace(
51
+ day=1
52
+ )
53
+
54
+ tariff = utils.get_pvpc_tariff(curr_hour_dt)
55
+ kwh = consumption["value_kWh"]
56
+ surplus_kwh = consumption["surplus_kWh"]
57
+ delta_h = consumption["delta_h"]
58
+
59
+ kwh_by_tariff = [0, 0, 0]
60
+ surplus_kwh_by_tariff = [0, 0, 0]
61
+
62
+ match tariff:
63
+ case "p1":
64
+ kwh_by_tariff[0] = kwh
65
+ surplus_kwh_by_tariff[0] = surplus_kwh
66
+ case "p2":
67
+ kwh_by_tariff[1] = kwh
68
+ surplus_kwh_by_tariff[1] = surplus_kwh
69
+ case "p3":
70
+ kwh_by_tariff[2] = kwh
71
+ surplus_kwh_by_tariff[2] = surplus_kwh
72
+
73
+ if last_day_dt is None or curr_day_dt != last_day_dt:
74
+ self._output["daily"].append(
75
+ ConsumptionAggData(
76
+ datetime=curr_day_dt,
77
+ value_kWh=kwh,
78
+ delta_h=delta_h,
79
+ value_p1_kWh=kwh_by_tariff[0],
80
+ value_p2_kWh=kwh_by_tariff[1],
81
+ value_p3_kWh=kwh_by_tariff[2],
82
+ surplus_kWh=surplus_kwh,
83
+ surplus_p1_kWh=surplus_kwh_by_tariff[0],
84
+ surplus_p2_kWh=surplus_kwh_by_tariff[1],
85
+ surplus_p3_kWh=surplus_kwh_by_tariff[2],
86
+ )
87
+ )
88
+ else:
89
+ self._output["daily"][-1]["value_kWh"] += kwh
90
+ self._output["daily"][-1]["value_p1_kWh"] += kwh_by_tariff[0]
91
+ self._output["daily"][-1]["value_p2_kWh"] += kwh_by_tariff[1]
92
+ self._output["daily"][-1]["value_p3_kWh"] += kwh_by_tariff[2]
93
+ self._output["daily"][-1]["surplus_kWh"] += surplus_kwh
94
+ self._output["daily"][-1]["surplus_p1_kWh"] += surplus_kwh_by_tariff[0]
95
+ self._output["daily"][-1]["surplus_p2_kWh"] += surplus_kwh_by_tariff[1]
96
+ self._output["daily"][-1]["surplus_p3_kWh"] += surplus_kwh_by_tariff[2]
97
+ self._output["daily"][-1]["delta_h"] += delta_h
98
+
99
+ if last_month_dt is None or curr_month_dt != last_month_dt:
100
+ self._output["monthly"].append(
101
+ ConsumptionAggData(
102
+ datetime=curr_month_dt,
103
+ value_kWh=kwh,
104
+ delta_h=delta_h,
105
+ value_p1_kWh=kwh_by_tariff[0],
106
+ value_p2_kWh=kwh_by_tariff[1],
107
+ value_p3_kWh=kwh_by_tariff[2],
108
+ surplus_kWh=surplus_kwh,
109
+ surplus_p1_kWh=surplus_kwh_by_tariff[0],
110
+ surplus_p2_kWh=surplus_kwh_by_tariff[1],
111
+ surplus_p3_kWh=surplus_kwh_by_tariff[2],
112
+ )
113
+ )
114
+ else:
115
+ self._output["monthly"][-1]["value_kWh"] += kwh
116
+ self._output["monthly"][-1]["value_p1_kWh"] += kwh_by_tariff[0]
117
+ self._output["monthly"][-1]["value_p2_kWh"] += kwh_by_tariff[1]
118
+ self._output["monthly"][-1]["value_p3_kWh"] += kwh_by_tariff[2]
119
+ self._output["monthly"][-1]["surplus_kWh"] += surplus_kwh
120
+ self._output["monthly"][-1]["surplus_p1_kWh"] += surplus_kwh_by_tariff[
121
+ 0
122
+ ]
123
+ self._output["monthly"][-1]["surplus_p2_kWh"] += surplus_kwh_by_tariff[
124
+ 1
125
+ ]
126
+ self._output["monthly"][-1]["surplus_p3_kWh"] += surplus_kwh_by_tariff[
127
+ 2
128
+ ]
129
+ self._output["monthly"][-1]["delta_h"] += delta_h
130
+
131
+ last_day_dt = curr_day_dt
132
+ last_month_dt = curr_month_dt
133
+
134
+ # Round to two decimals
135
+ for item in self._output:
136
+ for cons in self._output[item]:
137
+ for key in cons:
138
+ if isinstance(cons[key], float):
139
+ cons[key] = round(cons[key], 2)
@@ -20,7 +20,7 @@ URL = "https://github.com/uvejota/python-edata"
20
20
  EMAIL = "vmayorg@outlook.es"
21
21
  AUTHOR = "VMG"
22
22
  REQUIRES_PYTHON = ">=3.6.0"
23
- VERSION = "1.2.6"
23
+ VERSION = "1.2.8"
24
24
 
25
25
  # What packages are required for this module to be executed?
26
26
  REQUIRED = [
@@ -1,103 +0,0 @@
1
- """Consumption data processors."""
2
-
3
- import logging
4
- from collections.abc import Iterable
5
- from typing import TypedDict
6
- from datetime import datetime
7
-
8
- import voluptuous
9
-
10
- from ..definitions import ConsumptionAggData, ConsumptionSchema
11
- from . import utils
12
- from .base import Processor
13
-
14
- _LOGGER = logging.getLogger(__name__)
15
-
16
-
17
- class ConsumptionOutput(TypedDict):
18
- """A dict holding ConsumptionProcessor output property."""
19
-
20
- daily: Iterable[ConsumptionAggData]
21
- monthly: Iterable[ConsumptionAggData]
22
-
23
-
24
- class ConsumptionProcessor(Processor):
25
- """A consumptions processor."""
26
-
27
- def do_process(self):
28
- """Calculate daily and monthly consumption stats."""
29
-
30
- self._output = ConsumptionOutput(daily=[], monthly=[])
31
-
32
- last_day_dt = None
33
- last_month_dt = None
34
-
35
- _schema = voluptuous.Schema([ConsumptionSchema])
36
- self._input = _schema(self._input)
37
-
38
- for consumption in self._input:
39
- curr_hour_dt: datetime = consumption["datetime"]
40
- curr_day_dt = curr_hour_dt.replace(hour=0, minute=0, second=0)
41
- curr_month_dt = curr_day_dt.replace(day=1)
42
-
43
- tariff = utils.get_pvpc_tariff(curr_hour_dt)
44
- kwh = consumption["value_kWh"]
45
- delta_h = consumption["delta_h"]
46
-
47
- kwh_p1 = 0
48
- kwh_p2 = 0
49
- kwh_p3 = 0
50
-
51
- match tariff:
52
- case "p1":
53
- kwh_p1 = kwh
54
- case "p2":
55
- kwh_p2 = kwh
56
- case "p3":
57
- kwh_p3 = kwh
58
-
59
- if last_day_dt is None or curr_day_dt != last_day_dt:
60
- self._output["daily"].append(
61
- ConsumptionAggData(
62
- datetime=curr_day_dt,
63
- value_kWh=kwh,
64
- delta_h=delta_h,
65
- value_p1_kWh=kwh_p1,
66
- value_p2_kWh=kwh_p2,
67
- value_p3_kWh=kwh_p3,
68
- )
69
- )
70
- else:
71
- self._output["daily"][-1]["value_kWh"] += kwh
72
- self._output["daily"][-1]["value_p1_kWh"] += kwh_p1
73
- self._output["daily"][-1]["value_p2_kWh"] += kwh_p2
74
- self._output["daily"][-1]["value_p3_kWh"] += kwh_p3
75
- self._output["daily"][-1]["delta_h"] += delta_h
76
-
77
- if last_month_dt is None or curr_month_dt != last_month_dt:
78
- self._output["monthly"].append(
79
- ConsumptionAggData(
80
- datetime=curr_month_dt,
81
- value_kWh=kwh,
82
- delta_h=delta_h,
83
- value_p1_kWh=kwh_p1,
84
- value_p2_kWh=kwh_p2,
85
- value_p3_kWh=kwh_p3,
86
- )
87
- )
88
- else:
89
- self._output["monthly"][-1]["value_kWh"] += kwh
90
- self._output["monthly"][-1]["value_p1_kWh"] += kwh_p1
91
- self._output["monthly"][-1]["value_p2_kWh"] += kwh_p2
92
- self._output["monthly"][-1]["value_p3_kWh"] += kwh_p3
93
- self._output["monthly"][-1]["delta_h"] += delta_h
94
-
95
- last_day_dt = curr_day_dt
96
- last_month_dt = curr_month_dt
97
-
98
- for item in self._output:
99
- for cons in self._output[item]:
100
- cons["value_kWh"] = round(cons["value_kWh"], 2)
101
- cons["value_p1_kWh"] = round(cons["value_p1_kWh"], 2)
102
- cons["value_p2_kWh"] = round(cons["value_p2_kWh"], 2)
103
- cons["value_p3_kWh"] = round(cons["value_p3_kWh"], 2)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes