main
1import pandas as pd, re, time, folium, os, shutil, tkinter as tk, sys, unicodedata 2from opencage.geocoder import OpenCageGeocode 3from folium.plugins import FastMarkerCluster 4from pathlib import Path 5from tkinter import filedialog 6 7 8 9# Declaración de variables globales 10tipo_vía_urbana = ['CL', 'CR', 'AV', 'CIR', 'DG', 'TV'] 11keywords = tipo_vía_urbana.copy() + ['BIS', 'KM'] 12equivalente = { 13 'CALLE' : 'CL', 14 'CARRERA' : 'CR', 15 'CRR' : 'CR', 16 'CRA' : 'CR', 17 'CLL' : 'CL', 18 'NÚMERO' : '#', 19 'NUMERO' : '#', 20 'NO' : '#', 21 'DIAGONAL' : 'DG', 22 'CIRCULAR' : 'CIR', 23 'TRANSVERSAL' : 'TV', 24 'AVENIDA' : 'AV', 25 'NO.' : '#', 26 'NORTE' : 'N', 27 'SUR' : 'S', 28 'ESTE' : 'E', 29 'ORIENTE' : 'E', 30 'OESTE' : 'O', 31 'OCCIDENTE' : 'O' 32} 33 34área_metropolitana = [ 35 "MEDELLIN - ANTIOQUIA", 36 "ITAGUI - ANTIOQUIA", 37 "BELLO - ANTIOQUIA", 38 "GIRARDOTA - ANTIOQUIA", 39 "BARBOSA - ANTIOQUIA", 40 "COPACABANA - ANTIOQUIA", 41 "ENVIGADO - ANTIOQUIA", 42 "SABANETA - ANTIOQUIA", 43 "CALDAS - ANTIOQUIA", 44 "LA ESTRELLA - ANTIOQUIA" 45 ] 46 47barrios_medellin = [ 48 "ALDEA PABLO VI", 49 "ALEJANDRIA", 50 "ALEJANDRO ECHEVARRIA", 51 "ALFONSO LOPEZ", 52 "ALTAMIRA", 53 "ALTAVISTA", 54 "POBLADO", 55 "ANDALUCIA", 56 "ANTONIO NARINO", 57 "ARANJUEZ", 58 "ASOMADERA N1", 59 "ASOMADERA N2", 60 "ASOMADERA N3", 61 "ASTORGA", 62 "AURES N1", 63 "AURES N2", 64 "BARRIO CAICEDO", 65 "BARRIO COLOMBIA", 66 "BARRIO CRISTOBAL", 67 "BARRIOS DE JESUS", 68 "BELEN", 69 "BELLO HORIZONTE", 70 "BICENTENARIO", 71 "BOSTON", 72 "BOSQUES DE SAN PABLO", 73 "BOMBONA N1", 74 "BOMBONA N2", 75 "BRASILIA", 76 "BOSQUE", 77 "BOLIVARIANA", 78 "CALASANZ", 79 "CALLE NUEVA", 80 "CAMPO ALEGRE", 81 "CAMPO AMOR", 82 "CAMPO VALDES", 83 "CANTV", 84 "CANTAR", 85 "CASTILLA", 86 "CASTROPOL", 87 "CATALUÑA", 88 "CASTILLA", 89 "CAICEDO", 90 "CATEDRAL", 91 "CARIBE", 92 "CARLOS E. RESTREPO", 93 "CARLOS E RESTREPO", 94 "CENTRAL GARC", 95 "CERRO EL VOLADOR", 96 "CERRO NUTIBARA", 97 "CHARCO MORALES", 98 "CIELO", 99 "COLON", 100 "COLONIA LIBRE", 101 "COLONIAL", 102 "COMUNA 1", 103 "COMUNA 13", 104 "COMUNA 4", 105 "COMUNA 7", 106 "CONDINA", 107 "CONQUISTADORES", 108 "CORAZON DE JESUS", 109 "CORAZON DE JESUS – BARRIO TRISTE", 110 "CÓRDOBA", 111 "CORTEXTO", 112 "CRISTO REY", 113 "DARE", 114 "DIEGO ECHEVARRIA", 115 "DOS QUETAZALES", 116 "EDUCACION", 117 "ESPINAL", 118 "ESTACION VILLA", 119 "ESTANCIA DEL MAR", 120 "ESTADIO", 121 "EUFEMIA CRUZ", 122 "EXPOSICION", 123 "FONTIBON", 124 "FERRINI", 125 "FLORENCIA", 126 "FLORIDA", 127 "FLORIDA NUEVA", 128 "FLORIDA VILLA", 129 "FUSIBLES", 130 "GENERAL MOTOBIEN", 131 "GENERAL PERON", 132 "GIRARDOT", 133 "GRANADA", 134 "GRANIZAL", 135 "GRANIZAL", 136 "GUAYABAL", 137 "HABSAÑA", 138 "HATILLO", 139 "HORIZONTES", 140 "HUNTER", 141 "HUMBERTO", 142 "JARDINES", 143 "JUAN PABLO II", 144 "JOSE JIMENEZ", 145 "LA AVANZADA", 146 "LA BARCALERA", 147 "LA BATERIA", 148 "LA CABAÑA", 149 "LA CASCADA", 150 "LA COLINA", 151 "LA COMUNA 1", 152 "LA COMUNA 2", 153 "LA ESPERANZA", 154 "LA FLORIDA", 155 "LA FRONTERA", 156 "LA GLORIA", 157 "LA HONDONADA", 158 "LA ISLA", 159 "LOMA DE LOS BERNAL", 160 "LA LOMA DE LOS ORIENTALES", 161 "LA MERCED", 162 "LA MOTA", 163 "LA PALMA", 164 "LA PARRANDA", 165 "LA PILARICA", 166 "LA PLAYA" 167] 168 169 170 171 172def main(): 173 """ 174 Función principal que maneja la interfaz de línea de comandos y coordina las funcionalidades disponibles: 175 1. Lectura y estandarización de direcciones. 176 2. Conversión de coordenadas usando reverse geocoding. 177 3. Mejora de coordenadas basadas en barrios. 178 4. Generación de mapas interactivos. 179 """ 180 if len(sys.argv) == 2: 181 if sys.argv[1] == "c": 182 ruta_archivo = Path("llave") / "llave.txt" 183 184 llave = "" 185 186 with open(ruta_archivo, "r", encoding="utf-8") as archivo: 187 primera_linea = archivo.readline().strip() # strip() elimina espacios al principio y al final 188 189 llave = primera_linea 190 191 ruta_archivo = Path("coordenadas") / "coordenadas.xlsx" 192 193 if os.path.exists(ruta_archivo): 194 df = pd.read_excel(ruta_archivo) 195 else: 196 df = pd.read_excel(Path("estandarizado") / "principales_área_metropolitana.xlsx") 197 am_vías_principales_coordenadas = reverse_geocoding(df.copy(), api_key=llave) 198 am_vías_principales_coordenadas.to_excel(Path("coordenadas") / "coordenadas.xlsx") 199 200 sys.exit() 201 202 estructura_programa() 203 verificar_y_copiar_archivo_original() 204 # Lectura de la base de datos 205 df = leer_archivo_original() 206 seleccion = int(seleccionar_funcionalidad()) 207 match seleccion: 208 case 1: 209 # am -> área metropolitana 210 # nam -> no área metropolitana 211 a, b = filtrar_área_metropolitana(df) 212 am = pd.DataFrame(a) 213 nam = pd.DataFrame(b) 214 # Acondicionamiento básico de ambos DF 215 am.loc[:, "Direccion de residencia"] = reemplazar_equivalentes(am.loc[:, "Direccion de residencia"]) 216 nam.loc[:, "Direccion de residencia"] = reemplazar_equivalentes(nam.loc[:, "Direccion de residencia"]) 217 218 # Filtro de los tipos de vía principales en el DF am 219 am_vías_principales, am_vías_especiales = filtrar_vías_principales(am.copy()) 220 221 # Estandarización direcciones 222 am_vías_principales = estandarización(am_vías_principales) 223 224 am.to_excel(Path("estandarizado") / "total_área_metropolitana.xlsx") 225 am_vías_principales.to_excel(Path("estandarizado") / "principales_área_metropolitana.xlsx") 226 am_vías_especiales.to_excel(Path("estandarizado") / "especiales_área_metropolitana.xlsx") 227 nam.to_excel(Path("estandarizado") / "fuera_área_metropolitana.xlsx") 228 229 case 2: 230 ruta_archivo = Path("coordenadas") / "coordenadas.xlsx" 231 llave = obtener_llave() 232 233 if os.path.exists(ruta_archivo): 234 df = pd.read_excel(ruta_archivo) 235 else: 236 df = pd.read_excel(Path("estandarizado") / "principales_área_metropolitana.xlsx") 237 am_vías_principales_coordenadas = reverse_geocoding(df.copy(), api_key=llave) 238 am_vías_principales_coordenadas.to_excel(Path("coordenadas") / "coordenadas.xlsx") 239 240 case 3: 241 llave = obtener_llave() 242 ruta_archivo = Path("coordenadas") / "coordenadas.xlsx" 243 if os.path.exists(ruta_archivo): 244 df = pd.read_excel(ruta_archivo, index_col=0) 245 else: 246 sys.exit('Error: Necesitas primero procesar las coordenadas.') 247 248 coordenadas = rellenar_por_barrio(api_key=llave,df=df.copy()) 249 coordenadas.to_excel(Path("coordenadas") / "coordenadas.xlsx") 250 251 case 4: 252 visualizar(pd.read_excel(Path('coordenadas') / 'coordenadas.xlsx')) 253 254 255def seleccionar_funcionalidad(): 256 """ 257 Muestra un menú interactivo para que el usuario seleccione la funcionalidad deseada. 258 259 Returns: 260 str: Número de la opción seleccionada ('1'-'4'). 261 """ 262 opciones = { 263 "1": "estandarización de direcciones", 264 "2": "conversión de coordenadas", 265 "3": "mejora de coordenadas (sólo si ya se realizó la conversión)", 266 "4": "generación de mapas" 267 } 268 269 print("Selecciona una funcionalidad:") 270 for clave, descripcion in opciones.items(): 271 print(f"{clave}. {descripcion.capitalize()}") 272 273 while True: 274 seleccion = input("Ingresa el número de la opción deseada (1-4): ").strip() 275 if seleccion in opciones: 276 print(f"Has seleccionado: {opciones[seleccion].capitalize()}") 277 return seleccion 278 else: 279 print("Opción inválida. Por favor elige 1, 2, 3 o 4.") 280 281 282def verificar_y_copiar_archivo_original(): 283 """ 284 Verifica si la carpeta 'original' está vacía y, de ser así, permite al usuario seleccionar un archivo via diálogo 285 gráfico para copiarlo a dicha carpeta. 286 """ 287 carpeta_original = Path("original") 288 289 # Verifica si está vacía 290 if not any(carpeta_original.iterdir()): 291 print("La carpeta 'original' está vacía. Selecciona un archivo para copiar allí.") 292 293 # Oculta la ventana principal de tkinter 294 root = tk.Tk() 295 root.withdraw() 296 297 # Abre el diálogo para seleccionar archivo 298 archivo_origen = filedialog.askopenfilename(title="Selecciona un archivo para copiar a 'original'") 299 300 if archivo_origen: 301 # Copia el archivo a la carpeta 'original' 302 archivo_destino = carpeta_original / Path(archivo_origen).name 303 shutil.copy(archivo_origen, archivo_destino) 304 print(f"Archivo copiado a: {archivo_destino}") 305 else: 306 print("No se seleccionó ningún archivo.") 307 308def estructura_programa(): 309 """ 310 Crea la estructura de directorios requerida por el programa si no existen. 311 """ 312 rutas = [ 313 Path("original"), 314 Path("estandarizado"), 315 Path("coordenadas"), 316 Path("mapas") / "filtrados", 317 Path("llave") / "llave.txt" 318 ] 319 320 for ruta in rutas: 321 # Si es un archivo (tiene sufijo como .txt), crea su carpeta padre 322 if ruta.suffix: 323 ruta.parent.mkdir(parents=True, exist_ok=True) 324 else: 325 ruta.mkdir(parents=True, exist_ok=True) 326 327def leer_archivo_original() -> pd.DataFrame: 328 """ 329 Lee el único archivo presente en la carpeta 'original' y lo carga en un DataFrame. 330 331 Returns: 332 pd.DataFrame: Datos cargados desde el archivo Excel. 333 """ 334 carpeta = Path("original") 335 archivos = list(carpeta.glob("*")) 336 337 # Filtrar solo archivos (ignora subdirectorios) 338 archivos = [f for f in archivos if f.is_file()] 339 340 if len(archivos) > 1: 341 print("Error: Hay más de un archivo en la carpeta 'original'. Asegúrate de que solo haya uno.") 342 sys.exit(1) 343 344 archivo = archivos[0] 345 346 # Leer el archivo Excel y devolver el DataFrame 347 df = pd.read_excel(archivo) 348 return df 349 350def obtener_llave(): 351 """ 352 Obtiene la llave API almacenada en 'llave/llave.txt', permite al usuario decidir usarla o ingresar una nueva, 353 y opcionalmente guarda la nueva llave para futuras ejecuciones. 354 355 Returns: 356 str: La llave API seleccionada o ingresada. 357 """ 358 359 # Ruta del archivo 360 ruta_archivo = Path("llave") / "llave.txt" 361 362 # Asegura que el directorio existe 363 os.makedirs(os.path.dirname(ruta_archivo), exist_ok=True) 364 365 llave = "" 366 367 368 # Paso 1: Verificar si el archivo existe y leer la primera línea 369 if os.path.exists(ruta_archivo): 370 with open(ruta_archivo, "r", encoding="utf-8") as archivo: 371 primera_linea = archivo.readline().strip() # strip() elimina espacios al principio y al final 372 373 if primera_linea: 374 previa = input("Se encontró una llave en el sistema de una ejecución previa, ¿Desea usarla? (s/n): ").strip().lower() 375 if previa == "s": 376 llave = primera_linea 377 else: 378 llave = input("Ingresa una llave: ").strip() 379 guardar = input("¿Deseas guardar esta llave para la próxima ejecución? (s/n): ").strip().lower() 380 if guardar == "s": 381 with open(ruta_archivo, "w", encoding="utf-8") as archivo: 382 archivo.write(llave + "\n") 383 else: 384 # No hay contenido válido en la primera línea 385 llave = input("No se encontró una llave válida. Ingresa una llave: ").strip() 386 guardar = input("¿Deseas guardar esta llave para la próxima ejecución? (s/n): ").strip().lower() 387 if guardar == "s": 388 with open(ruta_archivo, "w", encoding="utf-8") as archivo: 389 archivo.write(llave + "\n") 390 else: 391 # El archivo no existe 392 llave = input("No se encontró el archivo de llave. Ingresa una llave: ").strip() 393 guardar = input("¿Deseas guardar esta llave para la próxima ejecución? (s/n): ").strip().lower() 394 if guardar == "s": 395 with open(ruta_archivo, "w", encoding="utf-8") as archivo: 396 archivo.write(llave + "\n") 397 398 # Mostrar la llave obtenida 399 return llave 400 401 402def filtrar_área_metropolitana(df : pd.DataFrame) -> pd.DataFrame: 403 """ 404 Separa el DataFrame en dos listas: 405 - Registros cuya 'Ciudad Residencia' pertenece al área metropolitana. 406 - Registros fuera de dicha área. 407 408 Args: 409 df (pd.DataFrame): DataFrame original con columna 'Ciudad Residencia'. 410 411 Returns: 412 list: [DataFrame_area_metropolitana, DataFrame_fuera_area] 413 """ 414 a = df.loc[df["Ciudad Residencia"].isin(área_metropolitana)] 415 return [a, df[~df["Ciudad Residencia"].isin(a["Ciudad Residencia"])]] 416 417 418def reemplazar_equivalentes(s : pd.Series) -> pd.Series: 419 """ 420 Reemplaza en una Serie de direcciones las palabras clave por sus equivalentes abreviados. 421 422 Args: 423 s (pd.Series): Serie de strings con direcciones. 424 425 Returns: 426 pd.Series: Serie con direcciones normalizadas en mayúsculas y abreviadas. 427 """ 428 s = s.apply(str.upper) 429 for i in equivalente: 430 s = s.str.replace(i, equivalente[i]) 431 return s 432 433 434def filtrar_vías_principales(df : pd.DataFrame): 435 """ 436 Filtra el DataFrame para separar las direcciones que comienzan con un tipo de vía principal. 437 438 Args: 439 df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'. 440 441 Returns: 442 list: [DataFrame_principales, DataFrame_especiales] 443 """ 444 s = identificar_vías_principales(pd.Series(df.loc[:, "Direccion de residencia"].copy())) 445 s.dropna(inplace=True) 446 447 index_drop = list((set(df.index.copy()) - set(s.index.copy()))) 448 new_df = df.drop(index_drop, inplace=False) 449 450 return [new_df, df[~df["Direccion de residencia"].isin(new_df["Direccion de residencia"])]] 451 452 453def identificar_vías_principales(s : pd.Series) -> pd.Series: 454 """ 455 Identifica si cada dirección en la Serie comienza con un tipo de vía definido en 'tipo_vía_urbana'. 456 457 Args: 458 s (pd.Series): Serie de strings con direcciones. 459 460 Returns: 461 pd.Series: Serie con el código de vía o None si no coincide. 462 """ 463 s = s.copy() 464 for i in s.index: 465 for vía in tipo_vía_urbana: 466 if str(s.loc[i]).startswith(vía): 467 s.loc[i] = vía 468 break 469 if s.loc[i] not in tipo_vía_urbana: 470 s.loc[i] = None 471 472 #Esta función retorna una serie que tiene el tipo de vía de una dirección o un valor nulo en 473 # caso de que no cumpla esta condición 474 return s 475 476def estandarización(df : pd.DataFrame): 477 """ 478 Aplica procesamiento de estandarización de direcciones y añade la columna 'Complemento'. 479 480 Args: 481 df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'. 482 483 Returns: 484 pd.DataFrame: DataFrame estandarizado con nueva columna 'Complemento'. 485 """ 486 añadir_columna_complemento(df) 487 df.loc[:, "Direccion de residencia"] = df.loc[:, "Direccion de residencia"].apply(format_dir) 488 489 # Reordenar columnas: mover "Complemento" al lado de "Direccion de residencia" 490 columnas = df.columns.tolist() 491 if "Complemento" in columnas and "Direccion de residencia" in columnas: 492 columnas.remove("Complemento") 493 idx = columnas.index("Direccion de residencia") + 1 494 columnas.insert(idx, "Complemento") 495 df = df[columnas] 496 return df 497 498def añadir_columna_complemento(df : pd.DataFrame): 499 """ 500 Separa la dirección en base al complemento y añade la columna 'Complemento' al DataFrame. 501 502 Args: 503 df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'. 504 """ 505 df.loc[:, "Direccion de residencia"] =df.loc[:, "Direccion de residencia"].apply(dividir_complemento) 506 buffer = [] 507 for i in df.index: 508 buffer.append(df.loc[i, "Direccion de residencia"][1]) 509 df.loc[i, "Direccion de residencia"] = df.loc[i, "Direccion de residencia"][0] 510 df["Complemento"] = buffer 511 512 513def dividir_complemento(s : str): 514 """ 515 Divide una dirección en parte principal y complemento basado en palabras clave. 516 517 Args: 518 s (str): Dirección completa. 519 520 Returns: 521 list: [parte_principal, complemento] 522 """ 523 delay = 0 524 try: 525 for i in range(len(s)): 526 if delay == 0: 527 if ((s[i]+s[i + 1]) in keywords and not s[i + 2].isalpha()): 528 delay = 2 529 elif (s[i]+s[i + 1]+s[i + 2]) in keywords and not s[i + 3].isalpha(): 530 delay = 3 531 else: 532 if s[i].isalpha() and s[i + 1].isalpha(): 533 if s[i] != s[i + 1]: 534 new_str = s.split(s[i] + s[i + 1], 1) 535 new_str[1] = (s[i] + s[i + 1] + new_str[1]).strip() 536 new_str[0] = new_str[0].strip() 537 return new_str 538 else: 539 delay -= 1 540 except IndexError: 541 return [s, ''] 542 543def format_dir(s): 544 """ 545 Formatea una dirección separando componentes de vía, números y complementos. 546 547 Args: 548 s (str): Dirección sin formato. 549 550 Returns: 551 str: Dirección formateada. 552 """ 553 matches = re.findall(r'([A-ZÑ]+|\d+)', s) 554 555 temp = '' 556 part = 0 557 num_aldready = False 558 letter_aldready = False 559 via_aldready = False 560 561 for match in matches: 562 if match in tipo_vía_urbana: 563 if not via_aldready: 564 temp = temp + f'{match} ' 565 via_aldready = True 566 elif match in keywords: 567 temp = temp + f'{match} ' if temp.endswith(' ') or temp == '' else temp + f' {match} ' 568 elif match.isnumeric(): 569 if num_aldready: 570 part += 1 571 match part: 572 case 1: 573 temp = temp = temp + '#' if temp.endswith(' ') else temp + ' #' 574 case 2: 575 temp = temp = temp + '-' 576 num_aldready = False 577 letter_aldready = False 578 temp = temp + f'{match}' 579 num_aldready = True 580 else: 581 temp = temp + f'{match}' if not letter_aldready else temp + f' {match}' 582 letter_aldready = True 583 584 return temp 585 586 587 588def reverse_geocoding(df: pd.DataFrame, api_key: str, n = 300) -> pd.DataFrame: 589 """ 590 Realiza reverse geocoding sobre un número exacto de registros (n), partiendo desde: 591 - El primer registro si no existen columnas 'Latitud' y 'Longitud'. 592 - El primer registro donde 'Latitud' o 'Longitud' están vacíos, si ya existen. 593 594 Se realiza una consulta por segundo (como exige la API de OpenCage). 595 """ 596 geocoder = OpenCageGeocode(api_key) 597 598 # Crear columnas si no existen 599 if 'Latitud' not in df.columns: 600 df['Latitud'] = None 601 if 'Longitud' not in df.columns: 602 df['Longitud'] = None 603 604 # Encontrar primer índice donde falta alguna coordenada 605 start_idx = None 606 for idx in df.index: 607 if pd.isna(df.at[idx, 'Latitud']) or pd.isna(df.at[idx, 'Longitud']): 608 start_idx = idx 609 break 610 611 if start_idx is None: 612 print("Todas las coordenadas ya están completas. No se necesita geocodificación.") 613 return df 614 615 # Geocodificar exactamente n registros desde start_idx 616 processed = 0 617 for idx in df.loc[start_idx:].index: 618 if processed >= n: 619 break 620 621 direccion = f"{df.at[idx,'Direccion de residencia']}, {df.at[idx,'Ciudad Residencia'].replace(' -',',')}, Antioquia, Colombia" 622 623 try: 624 resultados = geocoder.geocode(direccion, no_annotations=1, countrycode='co') 625 except Exception as e: 626 print(f"‼ Error al consultar idx={idx}: {e}") 627 continue 628 629 if resultados and len(resultados): 630 df.at[idx, 'Longitud'] = resultados[0]['geometry']['lng'] 631 df.at[idx, 'Latitud'] = resultados[0]['geometry']['lat'] 632 else: 633 df.at[idx, 'Longitud'] = geocoder.geocode(df.at[idx,'Ciudad Residencia'].replace(' -',',') 634 , no_annotations=1, countrycode='co')[0]['geometry']['lng'] 635 df.at[idx, 'Latitud'] = geocoder.geocode(df.at[idx,'Ciudad Residencia'].replace(' -',',') 636 , no_annotations=1, countrycode='co')[0]['geometry']['lat'] 637 638 processed += 1 639 print(f"✓ Coordenadas asignadas en fila {idx}\nRegistros procesados: {processed}") 640 time.sleep(1) 641 642 # Reordenar columnas si existe 'Complemento' 643 cols = df.columns.tolist() 644 if 'Complemento' in cols: 645 idx_comp = cols.index('Complemento') 646 geo_cols = ['Latitud', 'Longitud'] 647 for col in geo_cols: 648 if col in cols: 649 cols.remove(col) 650 cols = cols[:idx_comp+1] + geo_cols + cols[idx_comp+1:] 651 df = df[cols] 652 653 return df 654 655 656def visualizar(df : pd.DataFrame): 657 """ 658 Genera y guarda mapas HTML con marcadores y clusters basados en coordenadas del DataFrame. 659 660 Args: 661 df (pd.DataFrame): DataFrame con columnas 'Latitud' y 'Longitud'. 662 """ 663 df.dropna(subset=['Latitud', 'Longitud'], inplace=True) 664 latitudes = [a['Latitud'] for a in df.to_dict('records')] 665 longitudes = [a['Longitud'] for a in df.to_dict('records')] 666 data = list(zip(latitudes, longitudes)) 667 668 map = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 669 for a in df.index: 670 coordenada = (df['Latitud'][a], df['Longitud'][a]) 671 folium.Marker(coordenada).add_to(map) 672 673 map2 = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 674 675 FastMarkerCluster(data=data).add_to(map2) 676 677 map.save(Path('mapas') / 'individual.html') 678 map2.save(Path('mapas') / 'clusters.html') 679 680 df_filtrado = df[ 681 ~((df["Latitud"] == 6.25184) & (df["Longitud"] == -75.56359)) 682 ] 683 latitudes = [a['Latitud'] for a in df_filtrado.to_dict('records')] 684 longitudes = [a['Longitud'] for a in df_filtrado.to_dict('records')] 685 data = list(zip(latitudes, longitudes)) 686 687 map = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 688 for a in df_filtrado.index: 689 coordenada = (df_filtrado['Latitud'][a], df_filtrado['Longitud'][a]) 690 folium.Marker(coordenada).add_to(map) 691 692 map2 = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 693 694 FastMarkerCluster(data=data).add_to(map2) 695 696 map.save(Path('mapas') / "filtrados" / 'individual_filtrados.html') 697 map2.save(Path('mapas') / "filtrados" / 'clusters_filtrados.html') 698 699 700def rellenar_por_barrio(df: pd.DataFrame, 701 api_key: str, 702 n = 400) -> pd.DataFrame: 703 """ 704 Para registros con lat=6.25184 y lon=-75.56359 y Complemento conteniendo 705 un barrio de barrios_medellin, re-geocodifica usando Ciudad + Barrio. 706 No hace más de n llamadas al API. 707 """ 708 geocoder = OpenCageGeocode(api_key) 709 llamadas = 0 710 711 latitud = df['Latitud'].copy() 712 longitud = df["Longitud"].copy() 713 714 # Preprocesar lista de barrios: sin tildes y en mayúsculas 715 def normaliza(texto: str) -> str: 716 # Quita tildes 717 s = unicodedata.normalize('NFD', texto) 718 s = ''.join(ch for ch in s if unicodedata.category(ch) != 'Mn') 719 return s.upper() 720 721 722 for idx, row in df.iterrows(): 723 if llamadas >= n: 724 break 725 726 lat = row.get('Latitud', None) 727 lon = row.get('Longitud', None) 728 complemento = row.get('Complemento', '') 729 if lat == 6.25184 and lon == -75.56359: 730 comp_norm = normaliza(str(complemento)) 731 # ¿Algún barrio está contenido en el texto normalizado? 732 barrio_encontrado = next((b for b in barrios_medellin if b in comp_norm), None) 733 if barrio_encontrado: 734 # Construir dirección: ciudad + barrio 735 direccion = f"{barrio_encontrado}, MEDELLIN" 736 737 try: 738 resultados = geocoder.geocode(direccion, no_annotations=1, countrycode='co') 739 except Exception as e: 740 print(f"‼ Error al geocodificar idx={idx}: {e}") 741 continue 742 743 if resultados and len(resultados): 744 longitud.loc[idx] = resultados[0]['geometry']['lng'] 745 latitud.loc[idx] = resultados[0]['geometry']['lat'] 746 print(f"✓ Re-geocodificado idx={idx} → {barrio_encontrado}") 747 else: 748 print(f"– Sin resultados para idx={idx}, barrio={barrio_encontrado}") 749 750 llamadas += 1 751 time.sleep(1) # pausa mínima entre llamadas 752 753 df["Latitud"] = latitud 754 df["Longitud"] = longitud 755 if llamadas == 0: 756 print("Los registros han sido optimizados") 757 else: 758 print(f"🗸 Total de llamadas realizadas: {llamadas}") 759 return df 760 761 762if __name__ == "__main__": 763 main()
173def main(): 174 """ 175 Función principal que maneja la interfaz de línea de comandos y coordina las funcionalidades disponibles: 176 1. Lectura y estandarización de direcciones. 177 2. Conversión de coordenadas usando reverse geocoding. 178 3. Mejora de coordenadas basadas en barrios. 179 4. Generación de mapas interactivos. 180 """ 181 if len(sys.argv) == 2: 182 if sys.argv[1] == "c": 183 ruta_archivo = Path("llave") / "llave.txt" 184 185 llave = "" 186 187 with open(ruta_archivo, "r", encoding="utf-8") as archivo: 188 primera_linea = archivo.readline().strip() # strip() elimina espacios al principio y al final 189 190 llave = primera_linea 191 192 ruta_archivo = Path("coordenadas") / "coordenadas.xlsx" 193 194 if os.path.exists(ruta_archivo): 195 df = pd.read_excel(ruta_archivo) 196 else: 197 df = pd.read_excel(Path("estandarizado") / "principales_área_metropolitana.xlsx") 198 am_vías_principales_coordenadas = reverse_geocoding(df.copy(), api_key=llave) 199 am_vías_principales_coordenadas.to_excel(Path("coordenadas") / "coordenadas.xlsx") 200 201 sys.exit() 202 203 estructura_programa() 204 verificar_y_copiar_archivo_original() 205 # Lectura de la base de datos 206 df = leer_archivo_original() 207 seleccion = int(seleccionar_funcionalidad()) 208 match seleccion: 209 case 1: 210 # am -> área metropolitana 211 # nam -> no área metropolitana 212 a, b = filtrar_área_metropolitana(df) 213 am = pd.DataFrame(a) 214 nam = pd.DataFrame(b) 215 # Acondicionamiento básico de ambos DF 216 am.loc[:, "Direccion de residencia"] = reemplazar_equivalentes(am.loc[:, "Direccion de residencia"]) 217 nam.loc[:, "Direccion de residencia"] = reemplazar_equivalentes(nam.loc[:, "Direccion de residencia"]) 218 219 # Filtro de los tipos de vía principales en el DF am 220 am_vías_principales, am_vías_especiales = filtrar_vías_principales(am.copy()) 221 222 # Estandarización direcciones 223 am_vías_principales = estandarización(am_vías_principales) 224 225 am.to_excel(Path("estandarizado") / "total_área_metropolitana.xlsx") 226 am_vías_principales.to_excel(Path("estandarizado") / "principales_área_metropolitana.xlsx") 227 am_vías_especiales.to_excel(Path("estandarizado") / "especiales_área_metropolitana.xlsx") 228 nam.to_excel(Path("estandarizado") / "fuera_área_metropolitana.xlsx") 229 230 case 2: 231 ruta_archivo = Path("coordenadas") / "coordenadas.xlsx" 232 llave = obtener_llave() 233 234 if os.path.exists(ruta_archivo): 235 df = pd.read_excel(ruta_archivo) 236 else: 237 df = pd.read_excel(Path("estandarizado") / "principales_área_metropolitana.xlsx") 238 am_vías_principales_coordenadas = reverse_geocoding(df.copy(), api_key=llave) 239 am_vías_principales_coordenadas.to_excel(Path("coordenadas") / "coordenadas.xlsx") 240 241 case 3: 242 llave = obtener_llave() 243 ruta_archivo = Path("coordenadas") / "coordenadas.xlsx" 244 if os.path.exists(ruta_archivo): 245 df = pd.read_excel(ruta_archivo, index_col=0) 246 else: 247 sys.exit('Error: Necesitas primero procesar las coordenadas.') 248 249 coordenadas = rellenar_por_barrio(api_key=llave,df=df.copy()) 250 coordenadas.to_excel(Path("coordenadas") / "coordenadas.xlsx") 251 252 case 4: 253 visualizar(pd.read_excel(Path('coordenadas') / 'coordenadas.xlsx'))
Función principal que maneja la interfaz de línea de comandos y coordina las funcionalidades disponibles:
- Lectura y estandarización de direcciones.
- Conversión de coordenadas usando reverse geocoding.
- Mejora de coordenadas basadas en barrios.
- Generación de mapas interactivos.
256def seleccionar_funcionalidad(): 257 """ 258 Muestra un menú interactivo para que el usuario seleccione la funcionalidad deseada. 259 260 Returns: 261 str: Número de la opción seleccionada ('1'-'4'). 262 """ 263 opciones = { 264 "1": "estandarización de direcciones", 265 "2": "conversión de coordenadas", 266 "3": "mejora de coordenadas (sólo si ya se realizó la conversión)", 267 "4": "generación de mapas" 268 } 269 270 print("Selecciona una funcionalidad:") 271 for clave, descripcion in opciones.items(): 272 print(f"{clave}. {descripcion.capitalize()}") 273 274 while True: 275 seleccion = input("Ingresa el número de la opción deseada (1-4): ").strip() 276 if seleccion in opciones: 277 print(f"Has seleccionado: {opciones[seleccion].capitalize()}") 278 return seleccion 279 else: 280 print("Opción inválida. Por favor elige 1, 2, 3 o 4.")
Muestra un menú interactivo para que el usuario seleccione la funcionalidad deseada.
Returns: str: Número de la opción seleccionada ('1'-'4').
283def verificar_y_copiar_archivo_original(): 284 """ 285 Verifica si la carpeta 'original' está vacía y, de ser así, permite al usuario seleccionar un archivo via diálogo 286 gráfico para copiarlo a dicha carpeta. 287 """ 288 carpeta_original = Path("original") 289 290 # Verifica si está vacía 291 if not any(carpeta_original.iterdir()): 292 print("La carpeta 'original' está vacía. Selecciona un archivo para copiar allí.") 293 294 # Oculta la ventana principal de tkinter 295 root = tk.Tk() 296 root.withdraw() 297 298 # Abre el diálogo para seleccionar archivo 299 archivo_origen = filedialog.askopenfilename(title="Selecciona un archivo para copiar a 'original'") 300 301 if archivo_origen: 302 # Copia el archivo a la carpeta 'original' 303 archivo_destino = carpeta_original / Path(archivo_origen).name 304 shutil.copy(archivo_origen, archivo_destino) 305 print(f"Archivo copiado a: {archivo_destino}") 306 else: 307 print("No se seleccionó ningún archivo.")
Verifica si la carpeta 'original' está vacía y, de ser así, permite al usuario seleccionar un archivo via diálogo gráfico para copiarlo a dicha carpeta.
309def estructura_programa(): 310 """ 311 Crea la estructura de directorios requerida por el programa si no existen. 312 """ 313 rutas = [ 314 Path("original"), 315 Path("estandarizado"), 316 Path("coordenadas"), 317 Path("mapas") / "filtrados", 318 Path("llave") / "llave.txt" 319 ] 320 321 for ruta in rutas: 322 # Si es un archivo (tiene sufijo como .txt), crea su carpeta padre 323 if ruta.suffix: 324 ruta.parent.mkdir(parents=True, exist_ok=True) 325 else: 326 ruta.mkdir(parents=True, exist_ok=True)
Crea la estructura de directorios requerida por el programa si no existen.
328def leer_archivo_original() -> pd.DataFrame: 329 """ 330 Lee el único archivo presente en la carpeta 'original' y lo carga en un DataFrame. 331 332 Returns: 333 pd.DataFrame: Datos cargados desde el archivo Excel. 334 """ 335 carpeta = Path("original") 336 archivos = list(carpeta.glob("*")) 337 338 # Filtrar solo archivos (ignora subdirectorios) 339 archivos = [f for f in archivos if f.is_file()] 340 341 if len(archivos) > 1: 342 print("Error: Hay más de un archivo en la carpeta 'original'. Asegúrate de que solo haya uno.") 343 sys.exit(1) 344 345 archivo = archivos[0] 346 347 # Leer el archivo Excel y devolver el DataFrame 348 df = pd.read_excel(archivo) 349 return df
Lee el único archivo presente en la carpeta 'original' y lo carga en un DataFrame.
Returns: pd.DataFrame: Datos cargados desde el archivo Excel.
351def obtener_llave(): 352 """ 353 Obtiene la llave API almacenada en 'llave/llave.txt', permite al usuario decidir usarla o ingresar una nueva, 354 y opcionalmente guarda la nueva llave para futuras ejecuciones. 355 356 Returns: 357 str: La llave API seleccionada o ingresada. 358 """ 359 360 # Ruta del archivo 361 ruta_archivo = Path("llave") / "llave.txt" 362 363 # Asegura que el directorio existe 364 os.makedirs(os.path.dirname(ruta_archivo), exist_ok=True) 365 366 llave = "" 367 368 369 # Paso 1: Verificar si el archivo existe y leer la primera línea 370 if os.path.exists(ruta_archivo): 371 with open(ruta_archivo, "r", encoding="utf-8") as archivo: 372 primera_linea = archivo.readline().strip() # strip() elimina espacios al principio y al final 373 374 if primera_linea: 375 previa = input("Se encontró una llave en el sistema de una ejecución previa, ¿Desea usarla? (s/n): ").strip().lower() 376 if previa == "s": 377 llave = primera_linea 378 else: 379 llave = input("Ingresa una llave: ").strip() 380 guardar = input("¿Deseas guardar esta llave para la próxima ejecución? (s/n): ").strip().lower() 381 if guardar == "s": 382 with open(ruta_archivo, "w", encoding="utf-8") as archivo: 383 archivo.write(llave + "\n") 384 else: 385 # No hay contenido válido en la primera línea 386 llave = input("No se encontró una llave válida. Ingresa una llave: ").strip() 387 guardar = input("¿Deseas guardar esta llave para la próxima ejecución? (s/n): ").strip().lower() 388 if guardar == "s": 389 with open(ruta_archivo, "w", encoding="utf-8") as archivo: 390 archivo.write(llave + "\n") 391 else: 392 # El archivo no existe 393 llave = input("No se encontró el archivo de llave. Ingresa una llave: ").strip() 394 guardar = input("¿Deseas guardar esta llave para la próxima ejecución? (s/n): ").strip().lower() 395 if guardar == "s": 396 with open(ruta_archivo, "w", encoding="utf-8") as archivo: 397 archivo.write(llave + "\n") 398 399 # Mostrar la llave obtenida 400 return llave
Obtiene la llave API almacenada en 'llave/llave.txt', permite al usuario decidir usarla o ingresar una nueva, y opcionalmente guarda la nueva llave para futuras ejecuciones.
Returns: str: La llave API seleccionada o ingresada.
403def filtrar_área_metropolitana(df : pd.DataFrame) -> pd.DataFrame: 404 """ 405 Separa el DataFrame en dos listas: 406 - Registros cuya 'Ciudad Residencia' pertenece al área metropolitana. 407 - Registros fuera de dicha área. 408 409 Args: 410 df (pd.DataFrame): DataFrame original con columna 'Ciudad Residencia'. 411 412 Returns: 413 list: [DataFrame_area_metropolitana, DataFrame_fuera_area] 414 """ 415 a = df.loc[df["Ciudad Residencia"].isin(área_metropolitana)] 416 return [a, df[~df["Ciudad Residencia"].isin(a["Ciudad Residencia"])]]
Separa el DataFrame en dos listas:
- Registros cuya 'Ciudad Residencia' pertenece al área metropolitana.
- Registros fuera de dicha área.
Args: df (pd.DataFrame): DataFrame original con columna 'Ciudad Residencia'.
Returns: list: [DataFrame_area_metropolitana, DataFrame_fuera_area]
419def reemplazar_equivalentes(s : pd.Series) -> pd.Series: 420 """ 421 Reemplaza en una Serie de direcciones las palabras clave por sus equivalentes abreviados. 422 423 Args: 424 s (pd.Series): Serie de strings con direcciones. 425 426 Returns: 427 pd.Series: Serie con direcciones normalizadas en mayúsculas y abreviadas. 428 """ 429 s = s.apply(str.upper) 430 for i in equivalente: 431 s = s.str.replace(i, equivalente[i]) 432 return s
Reemplaza en una Serie de direcciones las palabras clave por sus equivalentes abreviados.
Args: s (pd.Series): Serie de strings con direcciones.
Returns: pd.Series: Serie con direcciones normalizadas en mayúsculas y abreviadas.
435def filtrar_vías_principales(df : pd.DataFrame): 436 """ 437 Filtra el DataFrame para separar las direcciones que comienzan con un tipo de vía principal. 438 439 Args: 440 df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'. 441 442 Returns: 443 list: [DataFrame_principales, DataFrame_especiales] 444 """ 445 s = identificar_vías_principales(pd.Series(df.loc[:, "Direccion de residencia"].copy())) 446 s.dropna(inplace=True) 447 448 index_drop = list((set(df.index.copy()) - set(s.index.copy()))) 449 new_df = df.drop(index_drop, inplace=False) 450 451 return [new_df, df[~df["Direccion de residencia"].isin(new_df["Direccion de residencia"])]]
Filtra el DataFrame para separar las direcciones que comienzan con un tipo de vía principal.
Args: df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'.
Returns: list: [DataFrame_principales, DataFrame_especiales]
454def identificar_vías_principales(s : pd.Series) -> pd.Series: 455 """ 456 Identifica si cada dirección en la Serie comienza con un tipo de vía definido en 'tipo_vía_urbana'. 457 458 Args: 459 s (pd.Series): Serie de strings con direcciones. 460 461 Returns: 462 pd.Series: Serie con el código de vía o None si no coincide. 463 """ 464 s = s.copy() 465 for i in s.index: 466 for vía in tipo_vía_urbana: 467 if str(s.loc[i]).startswith(vía): 468 s.loc[i] = vía 469 break 470 if s.loc[i] not in tipo_vía_urbana: 471 s.loc[i] = None 472 473 #Esta función retorna una serie que tiene el tipo de vía de una dirección o un valor nulo en 474 # caso de que no cumpla esta condición 475 return s
Identifica si cada dirección en la Serie comienza con un tipo de vía definido en 'tipo_vía_urbana'.
Args: s (pd.Series): Serie de strings con direcciones.
Returns: pd.Series: Serie con el código de vía o None si no coincide.
477def estandarización(df : pd.DataFrame): 478 """ 479 Aplica procesamiento de estandarización de direcciones y añade la columna 'Complemento'. 480 481 Args: 482 df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'. 483 484 Returns: 485 pd.DataFrame: DataFrame estandarizado con nueva columna 'Complemento'. 486 """ 487 añadir_columna_complemento(df) 488 df.loc[:, "Direccion de residencia"] = df.loc[:, "Direccion de residencia"].apply(format_dir) 489 490 # Reordenar columnas: mover "Complemento" al lado de "Direccion de residencia" 491 columnas = df.columns.tolist() 492 if "Complemento" in columnas and "Direccion de residencia" in columnas: 493 columnas.remove("Complemento") 494 idx = columnas.index("Direccion de residencia") + 1 495 columnas.insert(idx, "Complemento") 496 df = df[columnas] 497 return df
Aplica procesamiento de estandarización de direcciones y añade la columna 'Complemento'.
Args: df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'.
Returns: pd.DataFrame: DataFrame estandarizado con nueva columna 'Complemento'.
499def añadir_columna_complemento(df : pd.DataFrame): 500 """ 501 Separa la dirección en base al complemento y añade la columna 'Complemento' al DataFrame. 502 503 Args: 504 df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'. 505 """ 506 df.loc[:, "Direccion de residencia"] =df.loc[:, "Direccion de residencia"].apply(dividir_complemento) 507 buffer = [] 508 for i in df.index: 509 buffer.append(df.loc[i, "Direccion de residencia"][1]) 510 df.loc[i, "Direccion de residencia"] = df.loc[i, "Direccion de residencia"][0] 511 df["Complemento"] = buffer
Separa la dirección en base al complemento y añade la columna 'Complemento' al DataFrame.
Args: df (pd.DataFrame): DataFrame con columna 'Direccion de residencia'.
514def dividir_complemento(s : str): 515 """ 516 Divide una dirección en parte principal y complemento basado en palabras clave. 517 518 Args: 519 s (str): Dirección completa. 520 521 Returns: 522 list: [parte_principal, complemento] 523 """ 524 delay = 0 525 try: 526 for i in range(len(s)): 527 if delay == 0: 528 if ((s[i]+s[i + 1]) in keywords and not s[i + 2].isalpha()): 529 delay = 2 530 elif (s[i]+s[i + 1]+s[i + 2]) in keywords and not s[i + 3].isalpha(): 531 delay = 3 532 else: 533 if s[i].isalpha() and s[i + 1].isalpha(): 534 if s[i] != s[i + 1]: 535 new_str = s.split(s[i] + s[i + 1], 1) 536 new_str[1] = (s[i] + s[i + 1] + new_str[1]).strip() 537 new_str[0] = new_str[0].strip() 538 return new_str 539 else: 540 delay -= 1 541 except IndexError: 542 return [s, '']
Divide una dirección en parte principal y complemento basado en palabras clave.
Args: s (str): Dirección completa.
Returns: list: [parte_principal, complemento]
544def format_dir(s): 545 """ 546 Formatea una dirección separando componentes de vía, números y complementos. 547 548 Args: 549 s (str): Dirección sin formato. 550 551 Returns: 552 str: Dirección formateada. 553 """ 554 matches = re.findall(r'([A-ZÑ]+|\d+)', s) 555 556 temp = '' 557 part = 0 558 num_aldready = False 559 letter_aldready = False 560 via_aldready = False 561 562 for match in matches: 563 if match in tipo_vía_urbana: 564 if not via_aldready: 565 temp = temp + f'{match} ' 566 via_aldready = True 567 elif match in keywords: 568 temp = temp + f'{match} ' if temp.endswith(' ') or temp == '' else temp + f' {match} ' 569 elif match.isnumeric(): 570 if num_aldready: 571 part += 1 572 match part: 573 case 1: 574 temp = temp = temp + '#' if temp.endswith(' ') else temp + ' #' 575 case 2: 576 temp = temp = temp + '-' 577 num_aldready = False 578 letter_aldready = False 579 temp = temp + f'{match}' 580 num_aldready = True 581 else: 582 temp = temp + f'{match}' if not letter_aldready else temp + f' {match}' 583 letter_aldready = True 584 585 return temp
Formatea una dirección separando componentes de vía, números y complementos.
Args: s (str): Dirección sin formato.
Returns: str: Dirección formateada.
589def reverse_geocoding(df: pd.DataFrame, api_key: str, n = 300) -> pd.DataFrame: 590 """ 591 Realiza reverse geocoding sobre un número exacto de registros (n), partiendo desde: 592 - El primer registro si no existen columnas 'Latitud' y 'Longitud'. 593 - El primer registro donde 'Latitud' o 'Longitud' están vacíos, si ya existen. 594 595 Se realiza una consulta por segundo (como exige la API de OpenCage). 596 """ 597 geocoder = OpenCageGeocode(api_key) 598 599 # Crear columnas si no existen 600 if 'Latitud' not in df.columns: 601 df['Latitud'] = None 602 if 'Longitud' not in df.columns: 603 df['Longitud'] = None 604 605 # Encontrar primer índice donde falta alguna coordenada 606 start_idx = None 607 for idx in df.index: 608 if pd.isna(df.at[idx, 'Latitud']) or pd.isna(df.at[idx, 'Longitud']): 609 start_idx = idx 610 break 611 612 if start_idx is None: 613 print("Todas las coordenadas ya están completas. No se necesita geocodificación.") 614 return df 615 616 # Geocodificar exactamente n registros desde start_idx 617 processed = 0 618 for idx in df.loc[start_idx:].index: 619 if processed >= n: 620 break 621 622 direccion = f"{df.at[idx,'Direccion de residencia']}, {df.at[idx,'Ciudad Residencia'].replace(' -',',')}, Antioquia, Colombia" 623 624 try: 625 resultados = geocoder.geocode(direccion, no_annotations=1, countrycode='co') 626 except Exception as e: 627 print(f"‼ Error al consultar idx={idx}: {e}") 628 continue 629 630 if resultados and len(resultados): 631 df.at[idx, 'Longitud'] = resultados[0]['geometry']['lng'] 632 df.at[idx, 'Latitud'] = resultados[0]['geometry']['lat'] 633 else: 634 df.at[idx, 'Longitud'] = geocoder.geocode(df.at[idx,'Ciudad Residencia'].replace(' -',',') 635 , no_annotations=1, countrycode='co')[0]['geometry']['lng'] 636 df.at[idx, 'Latitud'] = geocoder.geocode(df.at[idx,'Ciudad Residencia'].replace(' -',',') 637 , no_annotations=1, countrycode='co')[0]['geometry']['lat'] 638 639 processed += 1 640 print(f"✓ Coordenadas asignadas en fila {idx}\nRegistros procesados: {processed}") 641 time.sleep(1) 642 643 # Reordenar columnas si existe 'Complemento' 644 cols = df.columns.tolist() 645 if 'Complemento' in cols: 646 idx_comp = cols.index('Complemento') 647 geo_cols = ['Latitud', 'Longitud'] 648 for col in geo_cols: 649 if col in cols: 650 cols.remove(col) 651 cols = cols[:idx_comp+1] + geo_cols + cols[idx_comp+1:] 652 df = df[cols] 653 654 return df
Realiza reverse geocoding sobre un número exacto de registros (n), partiendo desde:
- El primer registro si no existen columnas 'Latitud' y 'Longitud'.
- El primer registro donde 'Latitud' o 'Longitud' están vacíos, si ya existen.
Se realiza una consulta por segundo (como exige la API de OpenCage).
657def visualizar(df : pd.DataFrame): 658 """ 659 Genera y guarda mapas HTML con marcadores y clusters basados en coordenadas del DataFrame. 660 661 Args: 662 df (pd.DataFrame): DataFrame con columnas 'Latitud' y 'Longitud'. 663 """ 664 df.dropna(subset=['Latitud', 'Longitud'], inplace=True) 665 latitudes = [a['Latitud'] for a in df.to_dict('records')] 666 longitudes = [a['Longitud'] for a in df.to_dict('records')] 667 data = list(zip(latitudes, longitudes)) 668 669 map = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 670 for a in df.index: 671 coordenada = (df['Latitud'][a], df['Longitud'][a]) 672 folium.Marker(coordenada).add_to(map) 673 674 map2 = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 675 676 FastMarkerCluster(data=data).add_to(map2) 677 678 map.save(Path('mapas') / 'individual.html') 679 map2.save(Path('mapas') / 'clusters.html') 680 681 df_filtrado = df[ 682 ~((df["Latitud"] == 6.25184) & (df["Longitud"] == -75.56359)) 683 ] 684 latitudes = [a['Latitud'] for a in df_filtrado.to_dict('records')] 685 longitudes = [a['Longitud'] for a in df_filtrado.to_dict('records')] 686 data = list(zip(latitudes, longitudes)) 687 688 map = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 689 for a in df_filtrado.index: 690 coordenada = (df_filtrado['Latitud'][a], df_filtrado['Longitud'][a]) 691 folium.Marker(coordenada).add_to(map) 692 693 map2 = folium.Map(location=[6.2464186, -75.5942503], zoom_start=12) 694 695 FastMarkerCluster(data=data).add_to(map2) 696 697 map.save(Path('mapas') / "filtrados" / 'individual_filtrados.html') 698 map2.save(Path('mapas') / "filtrados" / 'clusters_filtrados.html')
Genera y guarda mapas HTML con marcadores y clusters basados en coordenadas del DataFrame.
Args: df (pd.DataFrame): DataFrame con columnas 'Latitud' y 'Longitud'.
701def rellenar_por_barrio(df: pd.DataFrame, 702 api_key: str, 703 n = 400) -> pd.DataFrame: 704 """ 705 Para registros con lat=6.25184 y lon=-75.56359 y Complemento conteniendo 706 un barrio de barrios_medellin, re-geocodifica usando Ciudad + Barrio. 707 No hace más de n llamadas al API. 708 """ 709 geocoder = OpenCageGeocode(api_key) 710 llamadas = 0 711 712 latitud = df['Latitud'].copy() 713 longitud = df["Longitud"].copy() 714 715 # Preprocesar lista de barrios: sin tildes y en mayúsculas 716 def normaliza(texto: str) -> str: 717 # Quita tildes 718 s = unicodedata.normalize('NFD', texto) 719 s = ''.join(ch for ch in s if unicodedata.category(ch) != 'Mn') 720 return s.upper() 721 722 723 for idx, row in df.iterrows(): 724 if llamadas >= n: 725 break 726 727 lat = row.get('Latitud', None) 728 lon = row.get('Longitud', None) 729 complemento = row.get('Complemento', '') 730 if lat == 6.25184 and lon == -75.56359: 731 comp_norm = normaliza(str(complemento)) 732 # ¿Algún barrio está contenido en el texto normalizado? 733 barrio_encontrado = next((b for b in barrios_medellin if b in comp_norm), None) 734 if barrio_encontrado: 735 # Construir dirección: ciudad + barrio 736 direccion = f"{barrio_encontrado}, MEDELLIN" 737 738 try: 739 resultados = geocoder.geocode(direccion, no_annotations=1, countrycode='co') 740 except Exception as e: 741 print(f"‼ Error al geocodificar idx={idx}: {e}") 742 continue 743 744 if resultados and len(resultados): 745 longitud.loc[idx] = resultados[0]['geometry']['lng'] 746 latitud.loc[idx] = resultados[0]['geometry']['lat'] 747 print(f"✓ Re-geocodificado idx={idx} → {barrio_encontrado}") 748 else: 749 print(f"– Sin resultados para idx={idx}, barrio={barrio_encontrado}") 750 751 llamadas += 1 752 time.sleep(1) # pausa mínima entre llamadas 753 754 df["Latitud"] = latitud 755 df["Longitud"] = longitud 756 if llamadas == 0: 757 print("Los registros han sido optimizados") 758 else: 759 print(f"🗸 Total de llamadas realizadas: {llamadas}") 760 return df
Para registros con lat=6.25184 y lon=-75.56359 y Complemento conteniendo un barrio de barrios_medellin, re-geocodifica usando Ciudad + Barrio. No hace más de n llamadas al API.