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.
- connectors/__init__.py +0 -0
- connectors/datadis.py +533 -0
- connectors/redata.py +81 -0
- e_data-1.3.1.dist-info/METADATA +808 -0
- e_data-1.3.1.dist-info/RECORD +19 -0
- e_data-1.3.1.dist-info/WHEEL +5 -0
- e_data-1.3.1.dist-info/licenses/LICENSE +674 -0
- e_data-1.3.1.dist-info/top_level.txt +3 -0
- processors/__init__.py +0 -0
- processors/base.py +27 -0
- processors/billing.py +215 -0
- processors/consumption.py +139 -0
- processors/maximeter.py +56 -0
- processors/utils.py +148 -0
- tests/__init__.py +0 -0
- tests/test_datadis_connector.py +242 -0
- tests/test_helpers.py +52 -0
- tests/test_processors.py +106 -0
- tests/test_redata_connector.py +14 -0
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)
|
processors/maximeter.py
ADDED
|
@@ -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
|