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()
tipo_vía_urbana = ['CL', 'CR', 'AV', 'CIR', 'DG', 'TV']
keywords = ['CL', 'CR', 'AV', 'CIR', 'DG', 'TV', 'BIS', 'KM']
equivalente = {'CALLE': 'CL', 'CARRERA': 'CR', 'CRR': 'CR', 'CRA': 'CR', 'CLL': 'CL', 'NÚMERO': '#', 'NUMERO': '#', 'NO': '#', 'DIAGONAL': 'DG', 'CIRCULAR': 'CIR', 'TRANSVERSAL': 'TV', 'AVENIDA': 'AV', 'NO.': '#', 'NORTE': 'N', 'SUR': 'S', 'ESTE': 'E', 'ORIENTE': 'E', 'OESTE': 'O', 'OCCIDENTE': 'O'}
área_metropolitana = ['MEDELLIN - ANTIOQUIA', 'ITAGUI - ANTIOQUIA', 'BELLO - ANTIOQUIA', 'GIRARDOTA - ANTIOQUIA', 'BARBOSA - ANTIOQUIA', 'COPACABANA - ANTIOQUIA', 'ENVIGADO - ANTIOQUIA', 'SABANETA - ANTIOQUIA', 'CALDAS - ANTIOQUIA', 'LA ESTRELLA - ANTIOQUIA']
barrios_medellin = ['ALDEA PABLO VI', 'ALEJANDRIA', 'ALEJANDRO ECHEVARRIA', 'ALFONSO LOPEZ', 'ALTAMIRA', 'ALTAVISTA', 'POBLADO', 'ANDALUCIA', 'ANTONIO NARINO', 'ARANJUEZ', 'ASOMADERA N1', 'ASOMADERA N2', 'ASOMADERA N3', 'ASTORGA', 'AURES N1', 'AURES N2', 'BARRIO CAICEDO', 'BARRIO COLOMBIA', 'BARRIO CRISTOBAL', 'BARRIOS DE JESUS', 'BELEN', 'BELLO HORIZONTE', 'BICENTENARIO', 'BOSTON', 'BOSQUES DE SAN PABLO', 'BOMBONA N1', 'BOMBONA N2', 'BRASILIA', 'BOSQUE', 'BOLIVARIANA', 'CALASANZ', 'CALLE NUEVA', 'CAMPO ALEGRE', 'CAMPO AMOR', 'CAMPO VALDES', 'CANTV', 'CANTAR', 'CASTILLA', 'CASTROPOL', 'CATALUÑA', 'CASTILLA', 'CAICEDO', 'CATEDRAL', 'CARIBE', 'CARLOS E. RESTREPO', 'CARLOS E RESTREPO', 'CENTRAL GARC', 'CERRO EL VOLADOR', 'CERRO NUTIBARA', 'CHARCO MORALES', 'CIELO', 'COLON', 'COLONIA LIBRE', 'COLONIAL', 'COMUNA 1', 'COMUNA 13', 'COMUNA 4', 'COMUNA 7', 'CONDINA', 'CONQUISTADORES', 'CORAZON DE JESUS', 'CORAZON DE JESUS – BARRIO TRISTE', 'CÓRDOBA', 'CORTEXTO', 'CRISTO REY', 'DARE', 'DIEGO ECHEVARRIA', 'DOS QUETAZALES', 'EDUCACION', 'ESPINAL', 'ESTACION VILLA', 'ESTANCIA DEL MAR', 'ESTADIO', 'EUFEMIA CRUZ', 'EXPOSICION', 'FONTIBON', 'FERRINI', 'FLORENCIA', 'FLORIDA', 'FLORIDA NUEVA', 'FLORIDA VILLA', 'FUSIBLES', 'GENERAL MOTOBIEN', 'GENERAL PERON', 'GIRARDOT', 'GRANADA', 'GRANIZAL', 'GRANIZAL', 'GUAYABAL', 'HABSAÑA', 'HATILLO', 'HORIZONTES', 'HUNTER', 'HUMBERTO', 'JARDINES', 'JUAN PABLO II', 'JOSE JIMENEZ', 'LA AVANZADA', 'LA BARCALERA', 'LA BATERIA', 'LA CABAÑA', 'LA CASCADA', 'LA COLINA', 'LA COMUNA 1', 'LA COMUNA 2', 'LA ESPERANZA', 'LA FLORIDA', 'LA FRONTERA', 'LA GLORIA', 'LA HONDONADA', 'LA ISLA', 'LOMA DE LOS BERNAL', 'LA LOMA DE LOS ORIENTALES', 'LA MERCED', 'LA MOTA', 'LA PALMA', 'LA PARRANDA', 'LA PILARICA', 'LA PLAYA']
def 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:

  1. Lectura y estandarización de direcciones.
  2. Conversión de coordenadas usando reverse geocoding.
  3. Mejora de coordenadas basadas en barrios.
  4. Generación de mapas interactivos.
def seleccionar_funcionalidad():
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').

def verificar_y_copiar_archivo_original():
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.

def estructura_programa():
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.

def leer_archivo_original() -> pandas.core.frame.DataFrame:
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.

def obtener_llave():
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.

def filtrar_área_metropolitana(df: pandas.core.frame.DataFrame) -> pandas.core.frame.DataFrame:
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]

def reemplazar_equivalentes(s: pandas.core.series.Series) -> pandas.core.series.Series:
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.

def filtrar_vías_principales(df: pandas.core.frame.DataFrame):
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]

def identificar_vías_principales(s: pandas.core.series.Series) -> pandas.core.series.Series:
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.

def estandarización(df: pandas.core.frame.DataFrame):
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'.

def añadir_columna_complemento(df: pandas.core.frame.DataFrame):
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'.

def dividir_complemento(s: str):
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]

def format_dir(s):
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.

def reverse_geocoding( df: pandas.core.frame.DataFrame, api_key: str, n=300) -> pandas.core.frame.DataFrame:
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).

def visualizar(df: pandas.core.frame.DataFrame):
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'.

def rellenar_por_barrio( df: pandas.core.frame.DataFrame, api_key: str, n=400) -> pandas.core.frame.DataFrame:
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.