leneda-client 0.1.0__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.
@@ -0,0 +1,6 @@
1
+ include README.md
2
+ include LICENSE
3
+ include requirements.txt
4
+ recursive-include src *
5
+ recursive-include examples *.py
6
+ recursive-include tests *.py
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: leneda-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the Leneda energy data platform
5
+ Home-page: https://github.com/fedus/leneda-client
6
+ Author: fedus
7
+ Author-email: fedus@dillendapp.eu
8
+ Project-URL: Bug Reports, https://github.com/yourusername/leneda-client/issues
9
+ Project-URL: Source, https://github.com/yourusername/leneda-client
10
+ Project-URL: Documentation, https://github.com/yourusername/leneda-client#readme
11
+ Keywords: leneda,energy,api,client
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: requests>=2.25.0
25
+ Requires-Dist: python-dateutil>=2.8.2
26
+ Dynamic: author
27
+ Dynamic: author-email
28
+ Dynamic: classifier
29
+ Dynamic: description
30
+ Dynamic: description-content-type
31
+ Dynamic: home-page
32
+ Dynamic: keywords
33
+ Dynamic: project-url
34
+ Dynamic: requires-dist
35
+ Dynamic: requires-python
36
+ Dynamic: summary
37
+
38
+ # Leneda API Client
39
+
40
+ [![PyPI version]](https://pypi.org/project/leneda-client/)
41
+ [![Python versions]](https://pypi.org/project/leneda-client/)
42
+ [![License]](https://github.com/fedus/leneda-client/blob/main/LICENSE)
43
+
44
+ A Python client for interacting with the Leneda energy data platform API.
45
+
46
+ ## Overview
47
+
48
+ This client provides a simple interface to the Leneda API, which allows users to:
49
+
50
+ - Retrieve metering data for specific time ranges
51
+ - Get aggregated metering data (hourly, daily, weekly, monthly, or total)
52
+ - Create metering data access requests
53
+ - Use predefined OBIS code constants for easy reference
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install leneda-client
@@ -0,0 +1,21 @@
1
+ # Leneda API Client
2
+
3
+ [![PyPI version]](https://pypi.org/project/leneda-client/)
4
+ [![Python versions]](https://pypi.org/project/leneda-client/)
5
+ [![License]](https://github.com/fedus/leneda-client/blob/main/LICENSE)
6
+
7
+ A Python client for interacting with the Leneda energy data platform API.
8
+
9
+ ## Overview
10
+
11
+ This client provides a simple interface to the Leneda API, which allows users to:
12
+
13
+ - Retrieve metering data for specific time ranges
14
+ - Get aggregated metering data (hourly, daily, weekly, monthly, or total)
15
+ - Create metering data access requests
16
+ - Use predefined OBIS code constants for easy reference
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install leneda-client
@@ -0,0 +1,543 @@
1
+ """
2
+ Advanced usage examples for the Leneda API client.
3
+
4
+ This script demonstrates more complex use cases of the Leneda API client,
5
+ including data analysis, visualization, and error handling.
6
+ It accepts API credentials via command-line arguments or environment variables.
7
+
8
+ Environment variables:
9
+ LENEDA_API_KEY: Your Leneda API key
10
+ LENEDA_ENERGY_ID: Your Energy ID
11
+
12
+ Usage:
13
+ python advanced_usage.py --api-key YOUR_API_KEY --energy-id YOUR_ENERGY_ID
14
+ python advanced_usage.py --metering-point LU-METERING_POINT1 --example 2
15
+ """
16
+
17
+ import argparse
18
+ import logging
19
+ import os
20
+ import sys
21
+ from datetime import datetime, timedelta
22
+ from typing import Optional
23
+
24
+ import matplotlib.pyplot as plt
25
+ import pandas as pd
26
+
27
+ from leneda import (
28
+ AggregatedMeteringData,
29
+ ElectricityConsumption,
30
+ LenedaClient,
31
+ MeteringData,
32
+ )
33
+
34
+ # Set up logging
35
+ logging.basicConfig(
36
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
37
+ )
38
+ logger = logging.getLogger("leneda_advanced_example")
39
+
40
+
41
+ def parse_arguments():
42
+ """Parse command-line arguments."""
43
+ parser = argparse.ArgumentParser(description="Leneda API Client Advanced Usage Example")
44
+
45
+ # API credentials
46
+ parser.add_argument(
47
+ "--api-key",
48
+ help="Your Leneda API key (or set LENEDA_API_KEY environment variable)",
49
+ )
50
+ parser.add_argument(
51
+ "--energy-id",
52
+ help="Your Energy ID (or set LENEDA_ENERGY_ID environment variable)",
53
+ )
54
+
55
+ # Other parameters
56
+ parser.add_argument(
57
+ "--metering-point",
58
+ default="LU-METERING_POINT1",
59
+ help="Metering point code (default: LU-METERING_POINT1)",
60
+ )
61
+ parser.add_argument(
62
+ "--example",
63
+ type=int,
64
+ choices=[1, 2, 3, 4],
65
+ default=0,
66
+ help="Run a specific example (1-4) or all if not specified",
67
+ )
68
+ parser.add_argument(
69
+ "--days",
70
+ type=int,
71
+ default=7,
72
+ help="Number of days to retrieve data for (default: 7)",
73
+ )
74
+ parser.add_argument(
75
+ "--year",
76
+ type=int,
77
+ default=datetime.now().year,
78
+ help=f"Year for analysis (default: {datetime.now().year})",
79
+ )
80
+ parser.add_argument(
81
+ "--threshold",
82
+ type=float,
83
+ default=50.0,
84
+ help="Anomaly detection threshold percentage (default: 50.0)",
85
+ )
86
+ parser.add_argument(
87
+ "--save-plots",
88
+ action="store_true",
89
+ help="Save plots to files instead of displaying them",
90
+ )
91
+ parser.add_argument(
92
+ "--output-dir",
93
+ default="./plots",
94
+ help="Directory to save plots (default: ./plots)",
95
+ )
96
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging")
97
+
98
+ return parser.parse_args()
99
+
100
+
101
+ def get_credentials(args):
102
+ """Get API credentials from arguments or environment variables."""
103
+ api_key = args.api_key or os.environ.get("LENEDA_API_KEY")
104
+ energy_id = args.energy_id or os.environ.get("LENEDA_ENERGY_ID")
105
+
106
+ if not api_key:
107
+ logger.error(
108
+ "API key not provided. Use --api-key or set LENEDA_API_KEY environment variable."
109
+ )
110
+ sys.exit(1)
111
+
112
+ if not energy_id:
113
+ logger.error(
114
+ "Energy ID not provided. Use --energy-id or set LENEDA_ENERGY_ID environment variable."
115
+ )
116
+ sys.exit(1)
117
+
118
+ return api_key, energy_id
119
+
120
+
121
+ def convert_to_dataframe(metering_data: MeteringData) -> pd.DataFrame:
122
+ """Convert MeteringData to a pandas DataFrame for analysis."""
123
+ data = [
124
+ {
125
+ "timestamp": item.started_at,
126
+ "value": item.value,
127
+ "unit": metering_data.unit,
128
+ "metering_point": metering_data.metering_point_code,
129
+ "obis_code": metering_data.obis_code,
130
+ "type": item.type,
131
+ "version": item.version,
132
+ "calculated": item.calculated,
133
+ }
134
+ for item in metering_data.items
135
+ ]
136
+
137
+ df = pd.DataFrame(data)
138
+ df.set_index("timestamp", inplace=True)
139
+ return df
140
+
141
+
142
+ def convert_aggregated_to_dataframe(
143
+ aggregated_data: AggregatedMeteringData,
144
+ ) -> pd.DataFrame:
145
+ """Convert AggregatedMeteringData to a pandas DataFrame for analysis."""
146
+ data = [
147
+ {
148
+ "start_date": item.started_at,
149
+ "end_date": item.ended_at,
150
+ "value": item.value,
151
+ "unit": aggregated_data.unit,
152
+ "calculated": item.calculated,
153
+ }
154
+ for item in aggregated_data.aggregated_time_series
155
+ ]
156
+
157
+ df = pd.DataFrame(data)
158
+ df.set_index("start_date", inplace=True)
159
+ return df
160
+
161
+
162
+ def plot_consumption_data(df: pd.DataFrame, title: str, save_path: Optional[str] = None) -> None:
163
+ """Plot consumption data from a DataFrame."""
164
+ plt.figure(figsize=(12, 6))
165
+ plt.plot(df.index, df["value"], marker="o", linestyle="-", markersize=4)
166
+ plt.title(title)
167
+ plt.xlabel("Time")
168
+ plt.ylabel(f"Consumption ({df['unit'].iloc[0]})")
169
+ plt.grid(True)
170
+ plt.tight_layout()
171
+
172
+ if save_path:
173
+ # Create directory if it doesn't exist
174
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
175
+ plt.savefig(save_path)
176
+ logger.info(f"Plot saved to {save_path}")
177
+ else:
178
+ plt.show()
179
+
180
+ plt.close()
181
+
182
+
183
+ def compare_consumption_periods(
184
+ client: LenedaClient,
185
+ metering_point: str,
186
+ period1_start: datetime,
187
+ period1_end: datetime,
188
+ period2_start: datetime,
189
+ period2_end: datetime,
190
+ save_path: Optional[str] = None,
191
+ ) -> None:
192
+ """Compare consumption data between two time periods."""
193
+ # Get data for period 1
194
+ period1_data = client.get_metering_data(
195
+ metering_point_code=metering_point,
196
+ obis_code=ElectricityConsumption.ACTIVE,
197
+ start_date_time=period1_start,
198
+ end_date_time=period1_end,
199
+ )
200
+
201
+ # Get data for period 2
202
+ period2_data = client.get_metering_data(
203
+ metering_point_code=metering_point,
204
+ obis_code=ElectricityConsumption.ACTIVE,
205
+ start_date_time=period2_start,
206
+ end_date_time=period2_end,
207
+ )
208
+
209
+ # Convert to DataFrames
210
+ df1 = convert_to_dataframe(period1_data)
211
+ df2 = convert_to_dataframe(period2_data)
212
+
213
+ # Resample to daily data for better comparison
214
+ daily1 = df1.resample("D").sum()
215
+ daily2 = df2.resample("D").sum()
216
+
217
+ # Calculate total consumption
218
+ total1 = df1["value"].sum()
219
+ total2 = df2["value"].sum()
220
+
221
+ # Calculate average consumption
222
+ avg1 = df1["value"].mean()
223
+ avg2 = df2["value"].mean()
224
+
225
+ # Calculate percentage difference
226
+ pct_diff = ((total2 - total1) / total1) * 100 if total1 > 0 else 0
227
+
228
+ # Print comparison
229
+ period1_str = f"{period1_start.strftime('%Y-%m-%d')} to {period1_end.strftime('%Y-%m-%d')}"
230
+ period2_str = f"{period2_start.strftime('%Y-%m-%d')} to {period2_end.strftime('%Y-%m-%d')}"
231
+
232
+ print("\nConsumption Comparison:")
233
+ print(f"Period 1 ({period1_str}):")
234
+ print(f" - Total: {total1:.2f} {df1['unit'].iloc[0]}")
235
+ print(f" - Average: {avg1:.2f} {df1['unit'].iloc[0]}")
236
+
237
+ print(f"\nPeriod 2 ({period2_str}):")
238
+ print(f" - Total: {total2:.2f} {df2['unit'].iloc[0]}")
239
+ print(f" - Average: {avg2:.2f} {df2['unit'].iloc[0]}")
240
+
241
+ print("\nComparison:")
242
+ print(f" - Absolute difference: {total2 - total1:.2f} {df1['unit'].iloc[0]}")
243
+ print(f" - Percentage difference: {pct_diff:.2f}%")
244
+
245
+ # Plot comparison
246
+ plt.figure(figsize=(12, 6))
247
+
248
+ plt.subplot(2, 1, 1)
249
+ plt.bar(range(len(daily1)), daily1["value"], label=f"Period 1 ({period1_str})")
250
+ plt.xticks(range(len(daily1)), [d.strftime("%a %d") for d in daily1.index], rotation=45)
251
+ plt.ylabel(f"Daily Consumption ({df1['unit'].iloc[0]})")
252
+ plt.legend()
253
+ plt.grid(True, alpha=0.3)
254
+
255
+ plt.subplot(2, 1, 2)
256
+ plt.bar(
257
+ range(len(daily2)),
258
+ daily2["value"],
259
+ label=f"Period 2 ({period2_str})",
260
+ color="orange",
261
+ )
262
+ plt.xticks(range(len(daily2)), [d.strftime("%a %d") for d in daily2.index], rotation=45)
263
+ plt.ylabel(f"Daily Consumption ({df2['unit'].iloc[0]})")
264
+ plt.legend()
265
+ plt.grid(True, alpha=0.3)
266
+
267
+ plt.tight_layout()
268
+
269
+ if save_path:
270
+ # Create directory if it doesn't exist
271
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
272
+ plt.savefig(save_path)
273
+ logger.info(f"Plot saved to {save_path}")
274
+ else:
275
+ plt.show()
276
+
277
+ plt.close()
278
+
279
+
280
+ def analyze_monthly_trends(
281
+ client: LenedaClient,
282
+ metering_point: str,
283
+ year: int,
284
+ save_path: Optional[str] = None,
285
+ ) -> None:
286
+ """Analyze monthly consumption trends for a specific year."""
287
+ # Get monthly aggregated data for the year
288
+ start_date = datetime(year, 1, 1)
289
+ end_date = datetime(year, 12, 31)
290
+
291
+ monthly_data = client.get_aggregated_metering_data(
292
+ metering_point_code=metering_point,
293
+ obis_code=ElectricityConsumption.ACTIVE,
294
+ start_date=start_date,
295
+ end_date=end_date,
296
+ aggregation_level="Month",
297
+ transformation_mode="Accumulation",
298
+ )
299
+
300
+ # Convert to DataFrame
301
+ df = convert_aggregated_to_dataframe(monthly_data)
302
+
303
+ # Print monthly consumption
304
+ print(f"\nMonthly Consumption for {year}:")
305
+ for idx, row in df.iterrows():
306
+ month_name = idx.strftime("%B")
307
+ print(f" - {month_name}: {row['value']:.2f} {row['unit']}")
308
+
309
+ # Calculate statistics
310
+ total = df["value"].sum()
311
+ average = df["value"].mean()
312
+ max_month = df["value"].idxmax().strftime("%B")
313
+ min_month = df["value"].idxmin().strftime("%B")
314
+
315
+ print("\nYearly Statistics:")
316
+ print(f" - Total consumption: {total:.2f} {df['unit'].iloc[0]}")
317
+ print(f" - Average monthly consumption: {average:.2f} {df['unit'].iloc[0]}")
318
+ print(
319
+ f" - Highest consumption month: {max_month} ({df['value'].max():.2f} {df['unit'].iloc[0]})"
320
+ )
321
+ print(
322
+ f" - Lowest consumption month: {min_month} ({df['value'].min():.2f} {df['unit'].iloc[0]})"
323
+ )
324
+
325
+ # Plot monthly consumption
326
+ plt.figure(figsize=(12, 6))
327
+ plt.bar(range(len(df)), df["value"], color="green")
328
+ plt.xticks(range(len(df)), [d.strftime("%b") for d in df.index], rotation=0)
329
+ plt.title(f"Monthly Electricity Consumption for {year}")
330
+ plt.xlabel("Month")
331
+ plt.ylabel(f"Consumption ({df['unit'].iloc[0]})")
332
+ plt.grid(True, axis="y", alpha=0.3)
333
+ plt.tight_layout()
334
+
335
+ if save_path:
336
+ # Create directory if it doesn't exist
337
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
338
+ plt.savefig(save_path)
339
+ logger.info(f"Plot saved to {save_path}")
340
+ else:
341
+ plt.show()
342
+
343
+ plt.close()
344
+
345
+
346
+ def detect_consumption_anomalies(
347
+ client: LenedaClient,
348
+ metering_point: str,
349
+ start_date: datetime,
350
+ end_date: datetime,
351
+ threshold_pct: float = 50.0,
352
+ save_path: Optional[str] = None,
353
+ ) -> None:
354
+ """Detect anomalies in consumption data based on percentage deviation from the mean."""
355
+ # Get hourly consumption data
356
+ consumption_data = client.get_metering_data(
357
+ metering_point_code=metering_point,
358
+ obis_code=ElectricityConsumption.ACTIVE,
359
+ start_date_time=start_date,
360
+ end_date_time=end_date,
361
+ )
362
+
363
+ # Convert to DataFrame
364
+ df = convert_to_dataframe(consumption_data)
365
+
366
+ # Calculate statistics
367
+ mean = df["value"].mean()
368
+ std = df["value"].std()
369
+ threshold = mean * (threshold_pct / 100)
370
+
371
+ # Detect anomalies
372
+ anomalies = df[abs(df["value"] - mean) > threshold].copy()
373
+
374
+ print("\nAnomaly Detection:")
375
+ print(f" - Period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
376
+ print(f" - Average consumption: {mean:.2f} {df['unit'].iloc[0]}")
377
+ print(f" - Standard deviation: {std:.2f} {df['unit'].iloc[0]}")
378
+ print(
379
+ f" - Threshold for anomaly: {threshold:.2f} {df['unit'].iloc[0]} ({threshold_pct}% of mean)"
380
+ )
381
+ print(f" - Number of anomalies detected: {len(anomalies)}")
382
+
383
+ if not anomalies.empty:
384
+ print("\nTop 5 Anomalies:")
385
+ # Sort by absolute deviation from mean
386
+ anomalies["deviation"] = abs(anomalies["value"] - mean)
387
+ anomalies = anomalies.sort_values("deviation", ascending=False)
388
+
389
+ for idx, row in anomalies.head(5).iterrows():
390
+ deviation_pct = (row["deviation"] / mean) * 100
391
+ print(
392
+ f" - {idx.strftime('%Y-%m-%d %H:%M')}: {row['value']:.2f} {row['unit']} "
393
+ f"(Deviation: {deviation_pct:.2f}%)"
394
+ )
395
+
396
+ # Plot the data with anomalies highlighted
397
+ plt.figure(figsize=(12, 6))
398
+ plt.plot(
399
+ df.index,
400
+ df["value"],
401
+ marker=".",
402
+ linestyle="-",
403
+ markersize=2,
404
+ label="Normal",
405
+ )
406
+ plt.scatter(anomalies.index, anomalies["value"], color="red", s=50, label="Anomaly")
407
+ plt.axhline(y=mean, color="green", linestyle="--", label=f"Mean ({mean:.2f})")
408
+ plt.axhline(
409
+ y=mean + threshold,
410
+ color="orange",
411
+ linestyle="--",
412
+ label=f"Upper Threshold ({mean + threshold:.2f})",
413
+ )
414
+ plt.axhline(
415
+ y=mean - threshold,
416
+ color="orange",
417
+ linestyle="--",
418
+ label=f"Lower Threshold ({mean - threshold:.2f})",
419
+ )
420
+
421
+ plt.title("Electricity Consumption with Anomalies")
422
+ plt.xlabel("Time")
423
+ plt.ylabel(f"Consumption ({df['unit'].iloc[0]})")
424
+ plt.legend()
425
+ plt.grid(True, alpha=0.3)
426
+ plt.tight_layout()
427
+
428
+ if save_path:
429
+ # Create directory if it doesn't exist
430
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
431
+ plt.savefig(save_path)
432
+ logger.info(f"Plot saved to {save_path}")
433
+ else:
434
+ plt.show()
435
+
436
+ plt.close()
437
+
438
+
439
+ def main():
440
+ # Parse command-line arguments
441
+ args = parse_arguments()
442
+
443
+ # Set up debug logging if requested
444
+ if args.debug:
445
+ logging.getLogger("leneda").setLevel(logging.DEBUG)
446
+ logger.setLevel(logging.DEBUG)
447
+ logger.debug("Debug logging enabled")
448
+
449
+ # Get API credentials
450
+ api_key, energy_id = get_credentials(args)
451
+
452
+ # Get other parameters
453
+ metering_point = args.metering_point
454
+ example_num = args.example
455
+ days = args.days
456
+ year = args.year
457
+ threshold = args.threshold
458
+ save_plots = args.save_plots
459
+ output_dir = args.output_dir
460
+
461
+ # Initialize the client
462
+ client = LenedaClient(api_key, energy_id, debug=args.debug)
463
+
464
+ try:
465
+ # Run all examples or a specific one based on the command-line argument
466
+ if example_num == 0 or example_num == 1:
467
+ # Example 1: Get and visualize hourly electricity consumption for the last week
468
+ end_date = datetime.now()
469
+ start_date = end_date - timedelta(days=days)
470
+
471
+ print(
472
+ f"\nExample 1: Visualizing hourly electricity consumption for the last {days} days"
473
+ )
474
+ consumption_data = client.get_metering_data(
475
+ metering_point_code=metering_point,
476
+ obis_code=ElectricityConsumption.ACTIVE,
477
+ start_date_time=start_date,
478
+ end_date_time=end_date,
479
+ )
480
+
481
+ # Convert to DataFrame and plot
482
+ df = convert_to_dataframe(consumption_data)
483
+ plot_consumption_data(
484
+ df,
485
+ f"Hourly Electricity Consumption ({start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')})",
486
+ save_path=(
487
+ os.path.join(output_dir, "hourly_consumption.png") if save_plots else None
488
+ ),
489
+ )
490
+
491
+ if example_num == 0 or example_num == 2:
492
+ # Example 2: Compare consumption between two weeks
493
+ current_week_end = datetime.now()
494
+ current_week_start = current_week_end - timedelta(days=7)
495
+ previous_week_end = current_week_start
496
+ previous_week_start = previous_week_end - timedelta(days=7)
497
+
498
+ print("\nExample 2: Comparing consumption between two weeks")
499
+ compare_consumption_periods(
500
+ client,
501
+ metering_point,
502
+ previous_week_start,
503
+ previous_week_end,
504
+ current_week_start,
505
+ current_week_end,
506
+ save_path=(
507
+ os.path.join(output_dir, "weekly_comparison.png") if save_plots else None
508
+ ),
509
+ )
510
+
511
+ if example_num == 0 or example_num == 3:
512
+ # Example 3: Analyze monthly trends for the specified year
513
+ print(f"\nExample 3: Analyzing monthly trends for {year}")
514
+ analyze_monthly_trends(
515
+ client,
516
+ metering_point,
517
+ year,
518
+ save_path=(
519
+ os.path.join(output_dir, f"monthly_trends_{year}.png") if save_plots else None
520
+ ),
521
+ )
522
+
523
+ if example_num == 0 or example_num == 4:
524
+ # Example 4: Detect consumption anomalies for the last 30 days
525
+ end_date = datetime.now()
526
+ start_date = end_date - timedelta(days=30)
527
+
528
+ print("\nExample 4: Detecting consumption anomalies for the last 30 days")
529
+ detect_consumption_anomalies(
530
+ client,
531
+ metering_point,
532
+ start_date,
533
+ end_date,
534
+ threshold_pct=threshold,
535
+ save_path=(os.path.join(output_dir, "anomalies.png") if save_plots else None),
536
+ )
537
+
538
+ except Exception as e:
539
+ logger.error(f"Error: {e}", exc_info=True)
540
+
541
+
542
+ if __name__ == "__main__":
543
+ main()