e-data 1.3.1__py3-none-any.whl

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.
processors/base.py ADDED
@@ -0,0 +1,27 @@
1
+ """Base definitions for processors."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from copy import deepcopy
5
+ from typing import Any
6
+
7
+
8
+ class Processor(ABC):
9
+ """A base class for data processors."""
10
+
11
+ _LABEL = "Processor"
12
+
13
+ def __init__(self, input_data: Any, auto: bool = True) -> None:
14
+ """Init method."""
15
+ self._input = deepcopy(input_data)
16
+ self._output = None
17
+ if auto:
18
+ self.do_process()
19
+
20
+ @abstractmethod
21
+ def do_process(self):
22
+ """Process method."""
23
+
24
+ @property
25
+ def output(self):
26
+ """An output property."""
27
+ return deepcopy(self._output)
processors/billing.py ADDED
@@ -0,0 +1,215 @@
1
+ """Billing data processors."""
2
+
3
+ import contextlib
4
+ from datetime import datetime, timedelta
5
+ import logging
6
+ from typing import Optional, TypedDict
7
+
8
+ from jinja2 import Environment
9
+ import voluptuous
10
+
11
+ from ..definitions import (
12
+ ConsumptionData,
13
+ ConsumptionSchema,
14
+ ContractData,
15
+ ContractSchema,
16
+ PricingAggData,
17
+ PricingData,
18
+ PricingRules,
19
+ PricingRulesSchema,
20
+ PricingSchema,
21
+ )
22
+ from ..processors import utils
23
+ from ..processors.base import Processor
24
+
25
+ _LOGGER = logging.getLogger(__name__)
26
+
27
+
28
+ class BillingOutput(TypedDict):
29
+ """A dict holding BillingProcessor output property."""
30
+
31
+ hourly: list[PricingAggData]
32
+ daily: list[PricingAggData]
33
+ monthly: list[PricingAggData]
34
+
35
+
36
+ class BillingInput(TypedDict):
37
+ """A dict holding BillingProcessor input data."""
38
+
39
+ contracts: list[ContractData]
40
+ consumptions: list[ConsumptionData]
41
+ prices: Optional[list[PricingData]]
42
+ rules: PricingRules
43
+
44
+
45
+ class BillingProcessor(Processor):
46
+ """A billing processor for edata."""
47
+
48
+ def do_process(self):
49
+ """Process billing and get hourly/daily/monthly metrics."""
50
+ self._output = BillingOutput(hourly=[], daily=[], monthly=[])
51
+
52
+ _schema = voluptuous.Schema(
53
+ {
54
+ voluptuous.Required("contracts"): [ContractSchema],
55
+ voluptuous.Required("consumptions"): [ConsumptionSchema],
56
+ voluptuous.Optional("prices", default=None): voluptuous.Union(
57
+ [voluptuous.Union(PricingSchema)], None
58
+ ),
59
+ voluptuous.Required("rules"): PricingRulesSchema,
60
+ }
61
+ )
62
+ self._input = _schema(self._input)
63
+
64
+ self._cycle_offset = self._input["rules"]["cycle_start_day"] - 1
65
+
66
+ # joint data by datetime
67
+ _data = {
68
+ x["datetime"]: {
69
+ "datetime": x["datetime"],
70
+ "kwh": x["value_kWh"],
71
+ "surplus_kwh": x["surplus_kWh"] if x["surplus_kWh"] is not None else 0,
72
+ }
73
+ for x in self._input["consumptions"]
74
+ }
75
+
76
+ for contract in self._input["contracts"]:
77
+ start = contract["date_start"]
78
+ end = contract["date_end"]
79
+ finish = False
80
+ while not finish:
81
+ if start in _data:
82
+ _data[start]["p1_kw"] = contract["power_p1"]
83
+ _data[start]["p2_kw"] = contract["power_p2"]
84
+ start = start + timedelta(hours=1)
85
+ finish = not (end > start)
86
+
87
+ if self._input["prices"]:
88
+ for x in self._input["prices"]:
89
+ start = x["datetime"]
90
+ if start in _data:
91
+ _data[start]["kwh_eur"] = x["value_eur_kWh"]
92
+
93
+ env = Environment()
94
+ energy_expr = env.compile_expression(
95
+ f'({self._input["rules"]["energy_formula"]})|float'
96
+ )
97
+ power_expr = env.compile_expression(
98
+ f'({self._input["rules"]["power_formula"]})|float'
99
+ )
100
+ others_expr = env.compile_expression(
101
+ f'({self._input["rules"]["others_formula"]})|float'
102
+ )
103
+ surplus_expr = env.compile_expression(
104
+ f'({self._input["rules"]["surplus_formula"]})|float'
105
+ )
106
+ main_expr = env.compile_expression(
107
+ f'({self._input["rules"]["main_formula"]})|float'
108
+ )
109
+
110
+ _data = sorted([_data[x] for x in _data], key=lambda x: x["datetime"])
111
+ hourly = []
112
+ for x in _data:
113
+ x.update(self._input["rules"])
114
+ tariff = utils.get_pvpc_tariff(x["datetime"])
115
+ if "kwh_eur" not in x:
116
+ if tariff == "p1":
117
+ x["kwh_eur"] = x["p1_kwh_eur"]
118
+ elif tariff == "p2":
119
+ x["kwh_eur"] = x["p2_kwh_eur"]
120
+ elif tariff == "p3":
121
+ x["kwh_eur"] = x["p3_kwh_eur"]
122
+
123
+ if x["kwh_eur"] is None:
124
+ continue
125
+
126
+ if tariff == "p1":
127
+ x["surplus_kwh_eur"] = x["surplus_p1_kwh_eur"]
128
+ elif tariff == "p2":
129
+ x["surplus_kwh_eur"] = x["surplus_p2_kwh_eur"]
130
+ elif tariff == "p3":
131
+ x["surplus_kwh_eur"] = x["surplus_p3_kwh_eur"]
132
+
133
+ _energy_term = 0
134
+ _power_term = 0
135
+ _others_term = 0
136
+ _surplus_term = 0
137
+
138
+ with contextlib.suppress(Exception):
139
+ _energy_term = round(energy_expr(**x), 6)
140
+ _power_term = round(power_expr(**x), 6)
141
+ _others_term = round(others_expr(**x), 6)
142
+ _surplus_term = round(surplus_expr(**x), 6)
143
+
144
+ new_item = PricingAggData(
145
+ datetime=x["datetime"],
146
+ energy_term=_energy_term,
147
+ power_term=_power_term,
148
+ others_term=_others_term,
149
+ surplus_term=_surplus_term,
150
+ value_eur=0,
151
+ delta_h=1,
152
+ )
153
+ hourly.append(new_item)
154
+
155
+ self._output["hourly"] = hourly
156
+
157
+ last_day_dt = None
158
+ last_month_dt = None
159
+ for hour in hourly:
160
+ curr_hour_dt: datetime = hour["datetime"]
161
+ curr_day_dt = curr_hour_dt.replace(hour=0, minute=0, second=0)
162
+ curr_month_dt = (curr_day_dt - timedelta(days=self._cycle_offset)).replace(
163
+ day=1
164
+ )
165
+
166
+ if last_day_dt is None or curr_day_dt != last_day_dt:
167
+ self._output["daily"].append(
168
+ PricingAggData(
169
+ datetime=curr_day_dt,
170
+ energy_term=hour["energy_term"],
171
+ power_term=hour["power_term"],
172
+ others_term=hour["others_term"],
173
+ surplus_term=hour["surplus_term"],
174
+ value_eur=hour["value_eur"],
175
+ delta_h=hour["delta_h"],
176
+ )
177
+ )
178
+ else:
179
+ self._output["daily"][-1]["energy_term"] += hour["energy_term"]
180
+ self._output["daily"][-1]["power_term"] += hour["power_term"]
181
+ self._output["daily"][-1]["others_term"] += hour["others_term"]
182
+ self._output["daily"][-1]["surplus_term"] += hour["surplus_term"]
183
+ self._output["daily"][-1]["delta_h"] += hour["delta_h"]
184
+ self._output["daily"][-1]["value_eur"] += hour["value_eur"]
185
+
186
+ if last_month_dt is None or curr_month_dt != last_month_dt:
187
+ self._output["monthly"].append(
188
+ PricingAggData(
189
+ datetime=curr_month_dt,
190
+ energy_term=hour["energy_term"],
191
+ power_term=hour["power_term"],
192
+ others_term=hour["others_term"],
193
+ surplus_term=hour["surplus_term"],
194
+ value_eur=hour["value_eur"],
195
+ delta_h=hour["delta_h"],
196
+ )
197
+ )
198
+ else:
199
+ self._output["monthly"][-1]["energy_term"] += hour["energy_term"]
200
+ self._output["monthly"][-1]["power_term"] += hour["power_term"]
201
+ self._output["monthly"][-1]["others_term"] += hour["others_term"]
202
+ self._output["monthly"][-1]["surplus_term"] += hour["surplus_term"]
203
+ self._output["monthly"][-1]["value_eur"] += hour["value_eur"]
204
+ self._output["monthly"][-1]["delta_h"] += hour["delta_h"]
205
+
206
+ last_day_dt = curr_day_dt
207
+ last_month_dt = curr_month_dt
208
+
209
+ for item in self._output:
210
+ for cost in self._output[item]:
211
+ cost["value_eur"] = round(main_expr(**cost, **self._input["rules"]), 6)
212
+ cost["energy_term"] = round(cost["energy_term"], 6)
213
+ cost["power_term"] = round(cost["power_term"], 6)
214
+ cost["others_term"] = round(cost["others_term"], 6)
215
+ cost["surplus_term"] = round(cost["surplus_term"], 6)
@@ -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)
@@ -0,0 +1,56 @@
1
+ """Maximeter data processors."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import TypedDict
6
+
7
+ from dateparser import parse
8
+ import voluptuous
9
+
10
+ from edata.definitions import MaxPowerSchema
11
+ from edata.processors import utils
12
+
13
+ from .base import Processor
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ class MaximeterStats(TypedDict):
19
+ """A dict holding MaximeterProcessor stats."""
20
+
21
+ value_max_kW: float
22
+ date_max: datetime
23
+ value_mean_kW: float
24
+ value_tile90_kW: float
25
+
26
+
27
+ class MaximeterOutput(TypedDict):
28
+ """A dict holding MaximeterProcessor output property."""
29
+
30
+ stats: MaximeterStats
31
+
32
+
33
+ class MaximeterProcessor(Processor):
34
+ """A processor for Maximeter data."""
35
+
36
+ def do_process(self):
37
+ """Calculate maximeter stats."""
38
+
39
+ self._output = MaximeterOutput(stats={})
40
+
41
+ _schema = voluptuous.Schema([MaxPowerSchema])
42
+ self._input = _schema(self._input)
43
+
44
+ _values = [x["value_kW"] for x in self._input]
45
+
46
+ _max_kW = max(_values)
47
+ _dt_max_kW = parse(str(self._input[_values.index(_max_kW)]["datetime"]))
48
+ _mean_kW = sum(_values) / len(_values)
49
+ _tile90_kW = utils.percentile(_values, 0.9)
50
+
51
+ self._output["stats"] = MaximeterOutput(
52
+ value_max_kW=round(_max_kW, 2),
53
+ date_max=_dt_max_kW,
54
+ value_mean_kW=round(_mean_kW, 2),
55
+ value_tile90_kW=round(_tile90_kW, 2),
56
+ )
processors/utils.py ADDED
@@ -0,0 +1,148 @@
1
+ """Generic utilities for processing data."""
2
+
3
+ import json
4
+ import logging
5
+ from copy import deepcopy
6
+ from datetime import date, datetime, timedelta
7
+ from json import JSONEncoder
8
+
9
+ import holidays
10
+
11
+ import math
12
+ import functools
13
+
14
+ import contextlib
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+ logging.basicConfig(level=logging.INFO)
18
+
19
+ HOURS_P1 = [10, 11, 12, 13, 18, 19, 20, 21]
20
+ HOURS_P2 = [8, 9, 14, 15, 16, 17, 22, 23]
21
+ WEEKDAYS_P3 = [5, 6]
22
+
23
+
24
+ def is_empty(lst):
25
+ """Check if a list is empty."""
26
+ return len(lst) == 0
27
+
28
+
29
+ def extract_dt_ranges(lst, dt_from, dt_to, gap_interval=timedelta(hours=1)):
30
+ """Filter a list of dicts between two datetimes."""
31
+ new_lst = []
32
+ missing = []
33
+ oldest_dt = None
34
+ newest_dt = None
35
+ last_dt = None
36
+ if len(lst) > 0:
37
+ sorted_lst = sorted(lst, key=lambda i: i["datetime"])
38
+ last_dt = dt_from
39
+ for i in sorted_lst:
40
+ if dt_from <= i["datetime"] <= dt_to:
41
+ if (i["datetime"] - last_dt) > gap_interval:
42
+ missing.append({"from": last_dt, "to": i["datetime"]})
43
+ if i.get("value_kWh", 1) > 0:
44
+ if oldest_dt is None or i["datetime"] < oldest_dt:
45
+ oldest_dt = i["datetime"]
46
+ if newest_dt is None or i["datetime"] > newest_dt:
47
+ newest_dt = i["datetime"]
48
+ if i["datetime"] != last_dt: # remove duplicates
49
+ new_lst.append(i)
50
+ last_dt = i["datetime"]
51
+ if dt_to > last_dt:
52
+ missing.append({"from": last_dt, "to": dt_to})
53
+ _LOGGER.debug("found data from %s to %s", oldest_dt, newest_dt)
54
+ else:
55
+ missing.append({"from": dt_from, "to": dt_to})
56
+ return new_lst, missing
57
+
58
+
59
+ def extend_by_key(old_lst, new_lst, key):
60
+ """Extend a list of dicts by key."""
61
+ lst = deepcopy(old_lst)
62
+ temp_list = []
63
+ for new_element in new_lst:
64
+ for old_element in lst:
65
+ if new_element[key] == old_element[key]:
66
+ for i in old_element:
67
+ old_element[i] = new_element[i]
68
+ break
69
+ else:
70
+ temp_list.append(new_element)
71
+ lst.extend(temp_list)
72
+ return lst
73
+
74
+
75
+ def extend_and_filter(old_lst, new_lst, key, dt_from, dt_to):
76
+ data = extend_by_key(old_lst, new_lst, key)
77
+ data, _ = extract_dt_ranges(
78
+ data,
79
+ dt_from,
80
+ dt_to,
81
+ gap_interval=timedelta(days=365), # trick
82
+ )
83
+
84
+ return data
85
+
86
+
87
+ def get_by_key(lst, key, value):
88
+ """Obtain an element of a list of dicts by key=value."""
89
+ for i in lst:
90
+ if i[key] == value:
91
+ return i
92
+ return None
93
+
94
+
95
+ def get_pvpc_tariff(a_datetime):
96
+ """Evals the PVPC tariff for a given datetime."""
97
+ hdays = holidays.country_holidays("ES")
98
+ hour = a_datetime.hour
99
+ weekday = a_datetime.weekday()
100
+ if weekday in WEEKDAYS_P3 or a_datetime.date() in hdays:
101
+ return "p3"
102
+ elif hour in HOURS_P1:
103
+ return "p1"
104
+ elif hour in HOURS_P2:
105
+ return "p2"
106
+ else:
107
+ return "p3"
108
+
109
+
110
+ def serialize_dict(data: dict) -> dict:
111
+ """Serialize dicts as json."""
112
+
113
+ class DateTimeEncoder(JSONEncoder):
114
+ """Replace datetime objects with ISO strings."""
115
+
116
+ def default(self, o):
117
+ if isinstance(o, (date, datetime)):
118
+ return o.isoformat()
119
+
120
+ return json.loads(json.dumps(data, cls=DateTimeEncoder))
121
+
122
+
123
+ def deserialize_dict(serialized_dict: dict) -> dict:
124
+ """Deserializes a json replacing ISOTIME strings into datetime."""
125
+
126
+ def datetime_parser(json_dict):
127
+ """Parse JSON while converting ISO strings into datetime objects."""
128
+ for key, value in json_dict.items():
129
+ if "date" in key:
130
+ with contextlib.suppress(Exception):
131
+ json_dict[key] = datetime.fromisoformat(value)
132
+ return json_dict
133
+
134
+ return json.loads(json.dumps(serialized_dict), object_hook=datetime_parser)
135
+
136
+
137
+ def percentile(N, percent, key=lambda x: x):
138
+ """Find the percentile of a list of values."""
139
+ if not N:
140
+ return None
141
+ k = (len(N) - 1) * percent
142
+ f = math.floor(k)
143
+ c = math.ceil(k)
144
+ if f == c:
145
+ return key(N[int(k)])
146
+ d0 = key(N[int(f)]) * (c - k)
147
+ d1 = key(N[int(c)]) * (k - f)
148
+ return d0 + d1
tests/__init__.py ADDED
File without changes