Skip to content

Commit bbb0f81

Browse files
authored
Merge pull request #18 from enerBit/fix-query-meters
Update .gitignore, add timeout option in CLI, and enhance token handl…
2 parents c184a06 + d13abb3 commit bbb0f81

File tree

6 files changed

+301
-25
lines changed

6 files changed

+301
-25
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Local config files
22
*.env
33
.vscode
4+
main.py
5+
frt_prueba.txt
46

57
# Byte-compiled / optimized / DLL files
68
__pycache__/

README.md

Lines changed: 128 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,22 @@ También tiene opción `--help` que muestra la ayuda particular de este sub-coma
126126
127127
Usage: enerbitdso usages fetch [OPTIONS] [FRTS]...
128128
129-
╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────────╮
130-
│ frts [FRTS]... List of frt codes separated by ' ' [default: None] │
131-
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
132-
╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────╮
133-
│ * --api-base-url TEXT [env var: ENERBIT_API_BASE_URL] [default: None] [required] │
134-
│ * --api-username TEXT [env var: ENERBIT_API_USERNAME] [default: None] [required] │
135-
│ * --api-password TEXT [env var: ENERBIT_API_PASSWORD] [default: None] [required] │
136-
│ --since [%Y-%m-%d|%Y%m%d] [default: (yesterday)] │
137-
│ --until [%Y-%m-%d|%Y%m%d] [default: (today)] │
138-
│ --timezone TEXT [default: America/Bogota] │
139-
│ --out-format [csv|jsonl] Output file format [default: jsonl] │
140-
│ --frt-file PATH Path file with one frt code per line [default: None] │
141-
│ --help Show this message and exit. │
142-
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
129+
╭─ Arguments ───────────────────────────────────────────────────────────────────────────────────────────────────────╮
130+
│ frts [FRTS]... List of frt codes separated by ' ' [default: None] │
131+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
132+
╭─ Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
133+
│ * --api-base-url TEXT [env var: ENERBIT_API_BASE_URL] [default: None] [required] │
134+
│ * --api-username TEXT [env var: ENERBIT_API_USERNAME] [default: None] [required] │
135+
│ * --api-password TEXT [env var: ENERBIT_API_PASSWORD] [default: None] [required] │
136+
│ --since [%Y-%m-%d|%Y%m%d] [default: (yesterday)] │
137+
│ --until [%Y-%m-%d|%Y%m%d] [default: (today)] │
138+
│ --timezone TEXT [default: America/Bogota] │
139+
│ --out-format [csv|jsonl] Output file format [default: jsonl] │
140+
│ --frt-file PATH Path file with one frt code per line [default: None] │
141+
│ --connection_timeout INTEGER RANGE The timeout used for HTTP connection in seconds[0<=x<=20][default: 10]│
142+
│ --read_timeout INTEGER RANGE The timeout used for HTTP requests in seconds[60<=x<=120][default: 60]│
143+
│ --help Show this message and exit. │
144+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
143145
```
144146

145147
# Librería DSO
@@ -150,14 +152,14 @@ Para poder hacer uso de la librería DSO se debe hacer lo siguiente
150152

151153
Para ello se debe importar el constructor de la siguiente forma:
152154

153-
```txt
155+
```python
154156
from enerbitdso.enerbit import DSOClient
155157
```
156158

157159
La inicialización se debe hacer asi:
158160

159-
```txt
160-
ebconnector = enerbit.DSOClient(
161+
```python
162+
ebconnector = DSOClient(
161163
api_base_url="https://dso.enerbit.me/",
162164
api_username="usuario_del_DSO",
163165
api_password="contraseña_del_DSO",
@@ -166,16 +168,122 @@ ebconnector = enerbit.DSOClient(
166168

167169
Al tener el objeto ya se pueden realizar consultas de la siguiente forma:
168170

169-
```txt
171+
```python
170172
usage_records = ebconnector.fetch_schedule_usage_records_large_interval(
171173
frt_code=frt_code, since=since, until=until
172174
)
173175
```
174176

175177
Tambien se puede hacer una consulta de perfiles de la siguiente forma:
176178

177-
```txt
179+
```python
178180
schedule_records = ebconnector.fetch_schedule_measurements_records_large_interval(
179181
frt_code=frt_code, since=since, until=until
180182
)
181-
```
183+
```
184+
185+
## Configuración del Cliente DSO
186+
187+
### Parámetros Básicos
188+
189+
```python
190+
ebconnector = DSOClient(
191+
api_base_url="https://dso.enerbit.me/",
192+
api_username="tu_usuario@empresa.com",
193+
api_password="tu_contraseña"
194+
)
195+
```
196+
197+
### Configuración Avanzada con Timeouts
198+
199+
Para mejorar la estabilidad en consultas masivas, especialmente cuando se procesan muchas fronteras, se recomienda configurar timeouts personalizados:
200+
201+
```python
202+
ebconnector = DSOClient(
203+
api_base_url="https://dso.enerbit.me/",
204+
api_username="tu_usuario@empresa.com",
205+
api_password="tu_contraseña",
206+
connection_timeout=20, # Timeout de conexión en segundos (1-60)
207+
read_timeout=120 # Timeout de lectura en segundos (60-300)
208+
)
209+
```
210+
211+
### Parámetros de Timeout
212+
213+
- **connection_timeout**: Tiempo máximo para establecer conexión con el servidor (recomendado: 10-30 segundos)
214+
- **read_timeout**: Tiempo máximo para recibir respuesta del servidor (recomendado: 60-180 segundos)
215+
216+
### Configuración con Variables de Entorno
217+
218+
Una práctica recomendada es usar variables de entorno para las credenciales:
219+
220+
```python
221+
import os
222+
223+
ebconnector = DSOClient(
224+
api_base_url=os.getenv("DSO_HOST", "https://dso.enerbit.me/"),
225+
api_username=os.getenv("DSO_USERNAME"),
226+
api_password=os.getenv("DSO_PASSWORD"),
227+
connection_timeout=20,
228+
read_timeout=120
229+
)
230+
```
231+
232+
Configurar las variables de entorno:
233+
234+
**Linux/macOS:**
235+
```bash
236+
export DSO_HOST="https://dso.enerbit.me/"
237+
export DSO_USERNAME="tu_usuario@empresa.com"
238+
export DSO_PASSWORD="tu_contraseña"
239+
```
240+
241+
**Windows:**
242+
```cmd
243+
set DSO_HOST=https://dso.enerbit.me/
244+
set DSO_USERNAME=tu_usuario@empresa.com
245+
set DSO_PASSWORD=tu_contraseña
246+
```
247+
248+
# Ejemplo de Uso Masivo
249+
250+
## Archivo `example.py`
251+
252+
El repositorio incluye un archivo `example.py` que demuestra cómo procesar múltiples fronteras de manera eficiente usando concurrencia. Este ejemplo es útil para:
253+
254+
- **Procesamiento masivo de fronteras**: Consulta múltiples fronteras en paralelo
255+
- **Manejo de errores**: Implementa reintentos automáticos y reportes de fronteras fallidas
256+
- **Generación de reportes**: Crea archivos Excel con matrices horarias de consumo
257+
- **Monitoreo de progreso**: Muestra el avance del procesamiento cada 500 fronteras
258+
259+
### Características del ejemplo:
260+
261+
- 🚀 **Concurrencia**: Usa ThreadPoolExecutor para procesar múltiples fronteras simultáneamente
262+
- 🔄 **Reintentos**: Implementa backoff exponencial para manejar errores de red
263+
- 📊 **Progreso visual**: Muestra estadísticas de avance durante la ejecución
264+
- 📈 **Salida estructurada**: Genera matrices Excel organizadas por hora, día, mes y año
265+
- ⚠️ **Manejo de errores**: Reporta fronteras fallidas para análisis posterior
266+
267+
### Uso del ejemplo:
268+
269+
1. **Configurar variables de entorno** (como se describe arriba)
270+
2. **Crear archivo de fronteras**: `frt_prueba.txt` con una frontera por línea
271+
3. **Ejecutar el script**:
272+
```bash
273+
python example.py
274+
```
275+
276+
### Archivos generados:
277+
278+
- `Matrices_YYYYMMDD_HHMM.xlsx` - Datos principales organizados por matrices horarias
279+
- `fronteras_fallidas_YYYYMMDD_HHMM.txt` - Lista de fronteras que no se pudieron procesar
280+
281+
### Configuración recomendada para producción:
282+
283+
Para ambientes de producción o consultas masivas, ajusta estos parámetros en el ejemplo:
284+
285+
- **max_workers**: Reduce de 30 a 5-10 para evitar saturar el servidor
286+
- **Timeouts**: Usa connection_timeout=20 y read_timeout=120 como mínimo
287+
- **Intervalos de tiempo**: Limita los rangos de fechas para consultas más eficientes
288+
289+
El archivo `example.py` sirve como base para desarrollar tus propios scripts de procesamiento masivo de datos de enerBit DSO.

example.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from enerbitdso.enerbit import DSOClient
2+
from datetime import datetime as dt
3+
import pandas as pd
4+
import pytz
5+
import os
6+
from concurrent.futures import ThreadPoolExecutor, as_completed
7+
import time
8+
import random
9+
10+
colombia_tz = pytz.timezone('America/Bogota')
11+
12+
ebconnector = DSOClient(
13+
api_base_url=os.getenv("DSO_HOST"),
14+
api_username=os.getenv("DSO_USERNAME"),
15+
api_password=os.getenv("DSO_PASSWORD"),
16+
connection_timeout=20,
17+
read_timeout=120
18+
)
19+
since = dt.strptime("2025-09-04T00:00-05:00", "%Y-%m-%dT%H:%M%z")
20+
until = dt.strptime("2025-09-08T00:00-05:00", "%Y-%m-%dT%H:%M%z")
21+
22+
with open("frt_prueba.txt", "r") as f1:
23+
frontiers = [line.strip() for line in f1 if line.strip()]
24+
25+
usage_records_dict = []
26+
fronteras_fallidas = []
27+
28+
print("Generando archivo...")
29+
30+
def fetch_usage_records(frontier, max_retries=3):
31+
for attempt in range(max_retries):
32+
try:
33+
usage_records = ebconnector.fetch_schedule_usage_records_large_interval(
34+
frt_code=frontier, since=since, until=until
35+
)
36+
37+
if not usage_records:
38+
print(f"[INFO] No se encontraron datos para la frontera {frontier}.")
39+
return []
40+
41+
return [{
42+
"Frontera": usage_record.frt_code if usage_record.frt_code is not None else "SIN_FRONTERA",
43+
"Serial": usage_record.meter_serial,
44+
"time_start": str(usage_record.time_start.astimezone(colombia_tz).strftime('%Y-%m-%d %H:%M:%S%z')),
45+
"time_end": str(usage_record.time_end.astimezone(colombia_tz).strftime('%Y-%m-%d %H:%M:%S%z')),
46+
"kWhD": usage_record.active_energy_imported,
47+
"kWhR": usage_record.active_energy_exported,
48+
"kVarhD": usage_record.reactive_energy_imported,
49+
"kVarhR": usage_record.reactive_energy_exported
50+
} for usage_record in usage_records]
51+
52+
except Exception as e:
53+
if attempt < max_retries - 1:
54+
# Backoff exponencial con jitter
55+
wait_time = (2 ** attempt) + random.uniform(0, 1)
56+
print(f"[RETRY] Frontera {frontier}, intento {attempt + 1}/{max_retries}. Esperando {wait_time:.1f}s...")
57+
time.sleep(wait_time)
58+
continue
59+
else:
60+
print(f"[ERROR] Error procesando la frontera {frontier} después de {max_retries} intentos: {e}")
61+
fronteras_fallidas.append(frontier)
62+
return []
63+
64+
with ThreadPoolExecutor(max_workers=30) as executor:
65+
future_to_frontier = {executor.submit(fetch_usage_records, frontier): frontier for frontier in frontiers}
66+
67+
processed_count = 0
68+
total_frontiers = len(frontiers)
69+
70+
for future in as_completed(future_to_frontier):
71+
usage_records_dict.extend(future.result())
72+
processed_count += 1
73+
74+
# Mostrar progreso cada 500 fronteras o al final
75+
if processed_count % 500 == 0 or processed_count == total_frontiers:
76+
print(f"📊 Progreso: {processed_count}/{total_frontiers} fronteras procesadas ({processed_count/total_frontiers*100:.1f}%)")
77+
78+
# Generar reporte de fronteras fallidas
79+
if fronteras_fallidas:
80+
timestamp_failed = dt.now().strftime("%Y%m%d_%H%M")
81+
failed_filename = f"fronteras_fallidas_{timestamp_failed}.txt"
82+
83+
with open(failed_filename, "w") as out:
84+
out.write("\n".join(fronteras_fallidas))
85+
86+
print(f"\n{len(fronteras_fallidas)} fronteras fallaron y se guardaron en: {failed_filename}")
87+
print(f"Fronteras exitosas: {total_frontiers - len(fronteras_fallidas)}/{total_frontiers}")
88+
else:
89+
print(f"\n✅ Todas las {total_frontiers} fronteras se procesaron exitosamente.")
90+
91+
if not usage_records_dict:
92+
print("⚠️ No se encontraron registros para ninguna frontera. Terminando script.")
93+
exit()
94+
95+
print("\n🔄 Procesando datos y generando Excel...")
96+
97+
df = pd.DataFrame(usage_records_dict)
98+
df['time_start'] = pd.to_datetime(df['time_start'])
99+
100+
df['Año'] = df['time_start'].dt.year
101+
df['Mes'] = df['time_start'].dt.month
102+
df['Día'] = df['time_start'].dt.day
103+
df['hora_en_punto'] = df['time_start'].dt.hour
104+
105+
cuadrante = ["kWhD", "kWhR", "kVarhD", "kVarhR"]
106+
df_long = df.melt(
107+
id_vars=["Frontera", "Serial", "Año", "Mes", "Día", "hora_en_punto"],
108+
value_vars=cuadrante,
109+
var_name="Tipo",
110+
value_name="valor_cuadrante"
111+
)
112+
113+
horas = list(range(24))
114+
resultado = (
115+
df_long.pivot_table(
116+
index=["Serial", "Frontera", "Tipo", "Año", "Mes", "Día"],
117+
columns="hora_en_punto",
118+
values="valor_cuadrante",
119+
aggfunc="first"
120+
)
121+
.reindex(columns=horas, fill_value=0)
122+
.reset_index()
123+
)
124+
resultado.columns.name = None
125+
resultado = resultado.rename(columns={col: f"Hora {col}" for col in resultado.columns if isinstance(col, int)})
126+
127+
timestamp = dt.now().strftime("%Y%m%d_%H%M")
128+
filename = f"Matrices_{timestamp}.xlsx"
129+
resultado.to_excel(filename, index=False)
130+
131+
print(f"\n✅ Archivo generado correctamente: {filename}")
132+
133+
# Resumen final
134+
print(f"\n📋 RESUMEN FINAL:")
135+
print(f" • Total fronteras: {total_frontiers}")
136+
print(f" • Exitosas: {total_frontiers - len(fronteras_fallidas)}")
137+
print(f" • Fallidas: {len(fronteras_fallidas)}")
138+
print(f" • Registros procesados: {len(usage_records_dict)}")

src/enerbitdso/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.19
1+
0.1.20

src/enerbitdso/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ def fetch(
8080
frt_file: pathlib.Path = typer.Option(
8181
None, help="Path file with one frt code per line"
8282
),
83+
connection_timeout: int = typer.Option(
84+
10,
85+
min=0,
86+
max=20,
87+
help="Config the timeout for HTTP connection (in seconds)",
88+
),
89+
read_timeout: int = typer.Option(
90+
10,
91+
min=0,
92+
max=20,
93+
help="Config the timeout for HTTP requests (in seconds)",
94+
),
8395
meter_serial: str = typer.Option(
8496
None, help="Filter by specific meter serial number"
8597
),
@@ -89,6 +101,8 @@ def fetch(
89101
api_base_url=api_base_url,
90102
api_username=api_username,
91103
api_password=api_password.get_secret_value(),
104+
connection_timeout=connection_timeout,
105+
read_timeout=read_timeout,
92106
)
93107

94108
today = dt.datetime.now(TZ_INFO).replace(**DATE_PARTS_TO_START_DAY)

0 commit comments

Comments
 (0)