e-data 2.0.0b1__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.
edata/const.py ADDED
@@ -0,0 +1,56 @@
1
+ """Constants file."""
2
+
3
+ PROG_NAME = "edata"
4
+ DEFAULT_STORAGE_DIR = "edata.storage"
5
+
6
+ # Attributes definition for backward compatibility
7
+ ATTRIBUTES = {
8
+ "cups": None,
9
+ "contract_p1_kW": "kW",
10
+ "contract_p2_kW": "kW",
11
+ "yesterday_kWh": "kWh",
12
+ "yesterday_hours": "h",
13
+ "yesterday_p1_kWh": "kWh",
14
+ "yesterday_p2_kWh": "kWh",
15
+ "yesterday_p3_kWh": "kWh",
16
+ "yesterday_surplus_kWh": "kWh",
17
+ "yesterday_surplus_p1_kWh": "kWh",
18
+ "yesterday_surplus_p2_kWh": "kWh",
19
+ "yesterday_surplus_p3_kWh": "kWh",
20
+ "last_registered_date": None,
21
+ "last_registered_day_kWh": "kWh",
22
+ "last_registered_day_hours": "h",
23
+ "last_registered_day_p1_kWh": "kWh",
24
+ "last_registered_day_p2_kWh": "kWh",
25
+ "last_registered_day_p3_kWh": "kWh",
26
+ "last_registered_day_surplus_kWh": "kWh",
27
+ "last_registered_day_surplus_p1_kWh": "kWh",
28
+ "last_registered_day_surplus_p2_kWh": "kWh",
29
+ "last_registered_day_surplus_p3_kWh": "kWh",
30
+ "month_kWh": "kWh",
31
+ "month_daily_kWh": "kWh",
32
+ "month_days": "d",
33
+ "month_p1_kWh": "kWh",
34
+ "month_p2_kWh": "kWh",
35
+ "month_p3_kWh": "kWh",
36
+ "month_surplus_kWh": "kWh",
37
+ "month_surplus_p1_kWh": "kWh",
38
+ "month_surplus_p2_kWh": "kWh",
39
+ "month_surplus_p3_kWh": "kWh",
40
+ "month_€": "€",
41
+ "last_month_kWh": "kWh",
42
+ "last_month_daily_kWh": "kWh",
43
+ "last_month_days": "d",
44
+ "last_month_p1_kWh": "kWh",
45
+ "last_month_p2_kWh": "kWh",
46
+ "last_month_p3_kWh": "kWh",
47
+ "last_month_surplus_kWh": "kWh",
48
+ "last_month_surplus_p1_kWh": "kWh",
49
+ "last_month_surplus_p2_kWh": "kWh",
50
+ "last_month_surplus_p3_kWh": "kWh",
51
+ "last_month_€": "€",
52
+ "max_power_kW": "kW",
53
+ "max_power_date": None,
54
+ "max_power_mean_kW": "kW",
55
+ "max_power_90perc_kW": "kW",
56
+ }
edata/helpers.py ADDED
@@ -0,0 +1,312 @@
1
+ """A module for edata helpers."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Any, Dict
6
+
7
+ from edata.connectors.datadis import DatadisConnector
8
+ from edata.const import ATTRIBUTES
9
+ from edata.models.pricing import PricingRules
10
+ from edata.services.billing import BillingService
11
+ from edata.services.consumption import ConsumptionService
12
+ from edata.services.contract import ContractService
13
+ from edata.services.maximeter import MaximeterService
14
+ from edata.services.supply import SupplyService
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ def acups(cups):
20
+ """Print an abbreviated and anonymized CUPS."""
21
+ return cups[-5:]
22
+
23
+
24
+ class EdataHelper:
25
+ """Main EdataHelper class using service-based architecture."""
26
+
27
+ def __init__(
28
+ self,
29
+ datadis_username: str,
30
+ datadis_password: str,
31
+ cups: str,
32
+ datadis_authorized_nif: str | None = None,
33
+ pricing_rules: PricingRules | None = None,
34
+ storage_dir_path: str | None = None,
35
+ enable_smart_fetch: bool = True,
36
+ ) -> None:
37
+ """Initialize EdataHelper with service-based architecture.
38
+
39
+ Args:
40
+ datadis_username: Datadis username
41
+ datadis_password: Datadis password
42
+ cups: CUPS identifier
43
+ datadis_authorized_nif: Optional authorized NIF
44
+ pricing_rules: Pricing configuration
45
+ storage_dir_path: Directory for database and cache storage
46
+ enable_smart_fetch: Enable smart fetching in datadis connector
47
+ """
48
+ self._cups = cups
49
+ self._scups = acups(cups)
50
+ self._authorized_nif = datadis_authorized_nif
51
+ self._storage_dir = storage_dir_path
52
+ self.pricing_rules = pricing_rules
53
+
54
+ # Initialize summary attributes
55
+ self.summary: Dict[str, Any] = {}
56
+ for attr in ATTRIBUTES:
57
+ self.summary[attr] = None
58
+
59
+ # For backward compatibility, alias 'attributes' to 'summary'
60
+ self.attributes = self.summary
61
+
62
+ # Determine if using PVPC pricing
63
+ self.enable_billing = pricing_rules is not None
64
+ if self.enable_billing:
65
+ self.is_pvpc = not all(
66
+ getattr(pricing_rules, x, None) is not None
67
+ for x in ("p1_kwh_eur", "p2_kwh_eur", "p3_kwh_eur")
68
+ )
69
+ else:
70
+ self.is_pvpc = False
71
+
72
+ # Create shared Datadis connector
73
+ self._datadis_connector = DatadisConnector(
74
+ username=datadis_username,
75
+ password=datadis_password,
76
+ enable_smart_fetch=enable_smart_fetch,
77
+ storage_path=storage_dir_path,
78
+ )
79
+
80
+ # Initialize services with dependency injection
81
+ self._supply_service = SupplyService(
82
+ datadis_connector=self._datadis_connector,
83
+ storage_dir=storage_dir_path,
84
+ )
85
+
86
+ self._contract_service = ContractService(
87
+ datadis_connector=self._datadis_connector,
88
+ storage_dir=storage_dir_path,
89
+ )
90
+
91
+ self._consumption_service = ConsumptionService(
92
+ datadis_connector=self._datadis_connector,
93
+ storage_dir=storage_dir_path,
94
+ )
95
+
96
+ self._maximeter_service = MaximeterService(
97
+ datadis_connector=self._datadis_connector,
98
+ storage_dir=storage_dir_path,
99
+ )
100
+
101
+ if self.enable_billing:
102
+ self._billing_service = BillingService(storage_dir=storage_dir_path)
103
+
104
+ _LOGGER.info(f"EdataHelper initialized for CUPS {self._scups}")
105
+
106
+ @property
107
+ def datadis_connector(self) -> DatadisConnector:
108
+ """Get the shared Datadis connector instance."""
109
+ return self._datadis_connector
110
+
111
+ async def update(
112
+ self,
113
+ date_from: datetime = datetime(1970, 1, 1),
114
+ date_to: datetime = datetime.today(),
115
+ ):
116
+ """Update all data and calculate summary attributes.
117
+
118
+ Args:
119
+ date_from: Start date for data updates
120
+ date_to: End date for data updates
121
+ incremental_update: Whether to update incrementally (deprecated, ignored)
122
+ """
123
+ _LOGGER.info(
124
+ f"{self._scups}: Starting update from {date_from.date()} to {date_to.date()}"
125
+ )
126
+
127
+ try:
128
+ # Step 1: Update supplies
129
+ _LOGGER.info(f"{self._scups}: Updating supplies")
130
+ supply_result = await self._supply_service.update_supplies(
131
+ authorized_nif=self._authorized_nif
132
+ )
133
+
134
+ if not supply_result["success"]:
135
+ _LOGGER.error(
136
+ f"{self._scups}: Failed to update supplies: {supply_result.get('error', 'Unknown error')}"
137
+ )
138
+ return False
139
+
140
+ # Validate that our CUPS exists
141
+ if not await self._supply_service.validate_cups(self._cups):
142
+ _LOGGER.error(f"{self._scups}: CUPS not found in account")
143
+ return False
144
+
145
+ _LOGGER.info(f"{self._scups}: CUPS validated successfully")
146
+
147
+ # Get supply information
148
+ supply = await self._supply_service.get_supply_by_cups(self._cups)
149
+ if not supply:
150
+ _LOGGER.error(f"{self._scups}: Could not retrieve supply details")
151
+ return False
152
+
153
+ distributor_code = supply.distributor_code
154
+ point_type = supply.point_type
155
+
156
+ _LOGGER.info(
157
+ f"{self._scups}: Supply dates from {supply.date_start.date()} to {supply.date_end.date()}"
158
+ )
159
+
160
+ # Adjust date range to supply validity period
161
+ effective_start = max(date_from, supply.date_start)
162
+ effective_end = min(date_to, supply.date_end)
163
+
164
+ # Step 2: Update contracts
165
+ _LOGGER.info(f"{self._scups}: Updating contracts")
166
+ contract_result = await self._contract_service.update_contracts(
167
+ cups=self._cups,
168
+ distributor_code=distributor_code,
169
+ authorized_nif=self._authorized_nif,
170
+ )
171
+
172
+ if not contract_result["success"]:
173
+ _LOGGER.warning(
174
+ f"{self._scups}: Contract update failed: {contract_result.get('error', 'Unknown error')}"
175
+ )
176
+
177
+ # Step 3: Update consumptions in monthly chunks
178
+ _LOGGER.info(f"{self._scups}: Updating consumptions")
179
+ consumption_result = (
180
+ await self._consumption_service.update_consumption_range_by_months(
181
+ cups=self._cups,
182
+ distributor_code=distributor_code,
183
+ start_date=effective_start,
184
+ end_date=effective_end,
185
+ measurement_type="0",
186
+ point_type=point_type,
187
+ authorized_nif=self._authorized_nif,
188
+ )
189
+ )
190
+
191
+ if not consumption_result["success"]:
192
+ _LOGGER.warning(f"{self._scups}: Consumption update failed")
193
+
194
+ # Step 4: Update maximeter data
195
+ _LOGGER.info(f"{self._scups}: Updating maximeter")
196
+ maximeter_result = (
197
+ await self._maximeter_service.update_maxpower_range_by_months(
198
+ cups=self._cups,
199
+ distributor_code=distributor_code,
200
+ start_date=effective_start,
201
+ end_date=effective_end,
202
+ authorized_nif=self._authorized_nif,
203
+ )
204
+ )
205
+
206
+ if not maximeter_result["success"]:
207
+ _LOGGER.warning(f"{self._scups}: Maximeter update failed")
208
+
209
+ # Step 5: Update PVPC prices if needed
210
+ if self.enable_billing and self.is_pvpc:
211
+ _LOGGER.info(f"{self._scups}: Updating PVPC prices")
212
+ try:
213
+ pvpc_result = await self._billing_service.update_pvpc_prices(
214
+ start_date=effective_start,
215
+ end_date=effective_end,
216
+ is_ceuta_melilla=False, # Default to Peninsula
217
+ )
218
+
219
+ if not pvpc_result["success"]:
220
+ _LOGGER.warning(
221
+ f"{self._scups}: PVPC price update failed: {pvpc_result.get('error', 'Unknown error')}"
222
+ )
223
+
224
+ except Exception as e:
225
+ _LOGGER.warning(
226
+ f"{self._scups}: PVPC price update failed with exception: {str(e)}"
227
+ )
228
+
229
+ # Step 6: Update billing costs if pricing rules are defined
230
+ if self.enable_billing and self.pricing_rules:
231
+ _LOGGER.info(f"{self._scups}: Updating billing costs")
232
+ try:
233
+ billing_result = await self._billing_service.update_missing_costs(
234
+ cups=self._cups,
235
+ pricing_rules=self.pricing_rules,
236
+ start_date=effective_start,
237
+ end_date=effective_end,
238
+ is_ceuta_melilla=False,
239
+ force_recalculate=False,
240
+ )
241
+
242
+ if not billing_result["success"]:
243
+ _LOGGER.warning(
244
+ f"{self._scups}: Billing cost update failed: {billing_result.get('error', 'Unknown error')}"
245
+ )
246
+
247
+ except Exception as e:
248
+ _LOGGER.warning(
249
+ f"{self._scups}: Billing cost update failed with exception: {str(e)}"
250
+ )
251
+
252
+ # Step 7: Calculate summary attributes
253
+ _LOGGER.info(f"{self._scups}: Calculating summary attributes")
254
+ await self._calculate_summary_attributes()
255
+
256
+ _LOGGER.info(f"{self._scups}: Update completed successfully")
257
+ return True
258
+
259
+ except Exception as e:
260
+ _LOGGER.error(f"{self._scups}: Update failed with exception: {str(e)}")
261
+ return False
262
+
263
+ async def _calculate_summary_attributes(self):
264
+ """Calculate summary attributes from all services."""
265
+
266
+ # Reset all attributes
267
+ for attr in ATTRIBUTES:
268
+ self.summary[attr] = None
269
+
270
+ try:
271
+ # Get supply summary
272
+ supply_summary = await self._supply_service.get_supply_summary(self._cups)
273
+ self.summary.update(supply_summary)
274
+
275
+ # Get contract summary
276
+ contract_summary = await self._contract_service.get_contract_summary(
277
+ self._cups
278
+ )
279
+ self.summary.update(contract_summary)
280
+
281
+ # Get consumption summary
282
+ consumption_summary = (
283
+ await self._consumption_service.get_consumption_summary(self._cups)
284
+ )
285
+ self.summary.update(consumption_summary)
286
+
287
+ # Get maximeter summary
288
+ maximeter_summary = await self._maximeter_service.get_maximeter_summary(
289
+ self._cups
290
+ )
291
+ self.summary.update(maximeter_summary)
292
+
293
+ # Get billing summary if enabled
294
+ if self.enable_billing and self.pricing_rules and self._billing_service:
295
+ billing_summary = await self._billing_service.get_billing_summary(
296
+ cups=self._cups,
297
+ pricing_rules=self.pricing_rules,
298
+ is_ceuta_melilla=False,
299
+ )
300
+ self.summary.update(billing_summary)
301
+
302
+ # Round numeric values to 2 decimal places for consistency
303
+ for key, value in self.summary.items():
304
+ if isinstance(value, float):
305
+ self.summary[key] = round(value, 2)
306
+
307
+ _LOGGER.debug(f"{self._scups}: Summary attributes calculated successfully")
308
+
309
+ except Exception as e:
310
+ _LOGGER.error(
311
+ f"{self._scups}: Error calculating summary attributes: {str(e)}"
312
+ )
File without changes
File without changes
@@ -0,0 +1,178 @@
1
+ """Datadis connector module testing."""
2
+
3
+ import datetime
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ from edata.connectors.datadis import DatadisConnector
9
+
10
+ MOCK_USERNAME = "fake_user"
11
+ MOCK_PASSWORD = "fake_password"
12
+
13
+ SUPPLIES_RESPONSE = [
14
+ {
15
+ "cups": "ESXXXXXXXXXXXXXXXXTEST",
16
+ "validDateFrom": "2022/01/01",
17
+ "validDateTo": "",
18
+ "pointType": 5,
19
+ "distributorCode": "2",
20
+ "address": "fake address, fake 12345",
21
+ "postalCode": "12345",
22
+ "province": "FAKE PROVINCE",
23
+ "municipality": "FAKE MUNICIPALITY",
24
+ "distributor": "FAKE DISTRIBUTOR",
25
+ }
26
+ ]
27
+
28
+ SUPPLIES_EXPECTATIONS = [
29
+ {
30
+ "cups": "ESXXXXXXXXXXXXXXXXTEST",
31
+ "date_start": datetime.datetime(2022, 1, 1, 0, 0),
32
+ "date_end": datetime.datetime.now().replace(
33
+ hour=0, minute=0, second=0, microsecond=0
34
+ )
35
+ + datetime.timedelta(days=1),
36
+ "point_type": 5,
37
+ "distributor_code": "2",
38
+ "address": "fake address, fake 12345",
39
+ "postal_code": "12345",
40
+ "province": "FAKE PROVINCE",
41
+ "municipality": "FAKE MUNICIPALITY",
42
+ "distributor": "FAKE DISTRIBUTOR",
43
+ }
44
+ ]
45
+
46
+ CONTRACTS_RESPONSE = [
47
+ {
48
+ "startDate": "2022/10/22",
49
+ "endDate": "2022/10/22",
50
+ "marketer": "fake_marketer",
51
+ "contractedPowerkW": [1.5, 1.5],
52
+ }
53
+ ]
54
+
55
+ CONTRACTS_EXPECTATIONS = [
56
+ {
57
+ "date_start": datetime.datetime(2022, 10, 22, 0, 0),
58
+ "date_end": datetime.datetime(2022, 10, 22, 0, 0),
59
+ "marketer": "fake_marketer",
60
+ "distributor_code": "2",
61
+ "power_p1": 1.5,
62
+ "power_p2": 1.5,
63
+ }
64
+ ]
65
+
66
+ CONSUMPTIONS_RESPONSE = [
67
+ {
68
+ "cups": "ESXXXXXXXXXXXXXXXXTEST",
69
+ "date": "2022/10/22",
70
+ "time": "01:00",
71
+ "consumptionKWh": 1.0,
72
+ "obtainMethod": "Real",
73
+ },
74
+ {
75
+ "cups": "ESXXXXXXXXXXXXXXXXTEST",
76
+ "date": "2022/10/22",
77
+ "time": "02:00",
78
+ "consumptionKWh": 1.0,
79
+ "obtainMethod": "Real",
80
+ },
81
+ ]
82
+
83
+ CONSUMPTIONS_EXPECTATIONS = [
84
+ {
85
+ "datetime": datetime.datetime(2022, 10, 22, 0, 0),
86
+ "delta_h": 1,
87
+ "value_kwh": 1.0,
88
+ "surplus_kwh": 0,
89
+ "real": True,
90
+ },
91
+ {
92
+ "datetime": datetime.datetime(2022, 10, 22, 1, 0),
93
+ "delta_h": 1,
94
+ "value_kwh": 1.0,
95
+ "surplus_kwh": 0,
96
+ "real": True,
97
+ },
98
+ ]
99
+
100
+ MAXIMETER_RESPONSE = [
101
+ {
102
+ "cups": "ESXXXXXXXXXXXXXXXXTEST",
103
+ "date": "2022/03/01",
104
+ "time": "12:00",
105
+ "maxPower": 1.0,
106
+ }
107
+ ]
108
+
109
+ MAXIMETER_EXPECTATIONS = [
110
+ {
111
+ "datetime": datetime.datetime(2022, 3, 1, 12, 0),
112
+ "value_kw": 1.0,
113
+ }
114
+ ]
115
+
116
+
117
+ # Tests for async methods (now the only methods available)
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ @patch.object(DatadisConnector, "_get_token", AsyncMock(return_value=True))
122
+ @patch.object(DatadisConnector, "_get", AsyncMock(return_value=SUPPLIES_RESPONSE))
123
+ async def test_get_supplies():
124
+ """Test a successful 'get_supplies' query."""
125
+ connector = DatadisConnector(MOCK_USERNAME, MOCK_PASSWORD)
126
+ result = await connector.get_supplies()
127
+ # Note: Now returns Pydantic models instead of dicts
128
+ # Convert to dicts for comparison with expectations
129
+ result_dicts = [supply.model_dump() for supply in result]
130
+ assert result_dicts == SUPPLIES_EXPECTATIONS
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ @patch.object(DatadisConnector, "_get_token", AsyncMock(return_value=True))
135
+ @patch.object(DatadisConnector, "_get", AsyncMock(return_value=CONTRACTS_RESPONSE))
136
+ async def test_get_contract_detail():
137
+ """Test a successful 'get_contract_detail' query."""
138
+ connector = DatadisConnector(MOCK_USERNAME, MOCK_PASSWORD)
139
+ result = await connector.get_contract_detail("ESXXXXXXXXXXXXXXXXTEST", "2")
140
+ # Note: Now returns Pydantic models instead of dicts
141
+ result_dicts = [contract.model_dump() for contract in result]
142
+ assert result_dicts == CONTRACTS_EXPECTATIONS
143
+
144
+
145
+ @pytest.mark.asyncio
146
+ @patch.object(DatadisConnector, "_get_token", AsyncMock(return_value=True))
147
+ @patch.object(DatadisConnector, "_get", AsyncMock(return_value=CONSUMPTIONS_RESPONSE))
148
+ async def test_get_consumption_data():
149
+ """Test a successful 'get_consumption_data' query."""
150
+ connector = DatadisConnector(MOCK_USERNAME, MOCK_PASSWORD)
151
+ result = await connector.get_consumption_data(
152
+ "ESXXXXXXXXXXXXXXXXTEST",
153
+ "2",
154
+ datetime.datetime(2022, 10, 22, 0, 0, 0),
155
+ datetime.datetime(2022, 10, 22, 2, 0, 0),
156
+ "0", # measurement_type as string
157
+ 5,
158
+ )
159
+ # Note: Now returns Pydantic models instead of dicts
160
+ result_dicts = [consumption.model_dump() for consumption in result]
161
+ assert result_dicts == CONSUMPTIONS_EXPECTATIONS
162
+
163
+
164
+ @pytest.mark.asyncio
165
+ @patch.object(DatadisConnector, "_get_token", AsyncMock(return_value=True))
166
+ @patch.object(DatadisConnector, "_get", AsyncMock(return_value=MAXIMETER_RESPONSE))
167
+ async def test_get_max_power():
168
+ """Test a successful 'get_max_power' query."""
169
+ connector = DatadisConnector(MOCK_USERNAME, MOCK_PASSWORD)
170
+ result = await connector.get_max_power(
171
+ "ESXXXXXXXXXXXXXXXXTEST",
172
+ "2",
173
+ datetime.datetime(2022, 3, 1, 0, 0, 0),
174
+ datetime.datetime(2022, 4, 1, 0, 0, 0),
175
+ )
176
+ # Note: Now returns Pydantic models instead of dicts
177
+ result_dicts = [maxpower.model_dump() for maxpower in result]
178
+ assert result_dicts == MAXIMETER_EXPECTATIONS
@@ -0,0 +1,29 @@
1
+ """Tests for REData (online)"""
2
+
3
+ from datetime import datetime, timedelta
4
+
5
+ import pytest
6
+
7
+ from edata.connectors.redata import REDataConnector
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_get_realtime_prices():
12
+ """Test a successful 'get_realtime_prices' query"""
13
+ connector = REDataConnector()
14
+ yesterday = datetime.now().replace(hour=0, minute=0, second=0) - timedelta(days=1)
15
+ response = await connector.get_realtime_prices(
16
+ yesterday, yesterday + timedelta(days=1) - timedelta(minutes=1), False
17
+ )
18
+ assert len(response) == 24
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_async_get_realtime_prices():
23
+ """Test a successful 'get_realtime_prices' query (legacy test name)"""
24
+ connector = REDataConnector()
25
+ yesterday = datetime.now().replace(hour=0, minute=0, second=0) - timedelta(days=1)
26
+ response = await connector.get_realtime_prices(
27
+ yesterday, yesterday + timedelta(days=1) - timedelta(minutes=1), False
28
+ )
29
+ assert len(response) == 24
File without changes