Algoritmo para encontrar un conjunto mínimo de códigos postales que cubran un país (casi) completamente

Esta publicación muestra cómo seleccionar un conjunto mínimo de códigos postales (PLZ) para cubrir un país casi completamente, usando Python y algunas librerías comunes. El ejemplo usa Alemania, pero el enfoque puede adaptarse a otros países con sistemas de códigos postales similares.

El modelo que usamos aquí consiste en dibujar un círculo de radio constante alrededor de cada código postal seleccionado (= centro), y el objetivo es cubrir la mayor cantidad posible del área del país con el menor número de círculos posible. Este es un problema clásico de optimización, a menudo denominado “problema de cobertura de conjuntos” (set cover problem), que es NP-hard. Por lo tanto, usamos un algoritmo de aproximación voraz (greedy) con algunas mejoras para encontrar una buena solución en un tiempo razonable.

Este algoritmo puede adaptarse fácilmente a otros países y también a otros conjuntos de POIs.

Este es principalmente un algoritmo estocástico, por lo que podría no producir el resultado absolutamente óptimo, pero debería estar muy cerca del óptimo en la mayoría de los casos. Además, se ejecuta en segundos incluso en un portátil estándar (con los parámetros como en el ejemplo siguiente).

Dado que este código lee la tabla PLZ como archivo parquet, consulta nuestra publicación anterior Postleitzahlen und Koordinaten von GeoNames parsen para el código y ejecútalo con python parse_de.py -p postleitzahlen.parquet para crear el archivo necesario.

Resultados

Usando el ejemplo de uso siguiente, terminamos seleccionando 429 centros (códigos postales) para cubrir el 99.5% de Alemania con un radio de 35 km alrededor de cada código postal seleccionado.

PLZ Coverage Germany.avif

Código fuente completo

plz-coverage.py
__author__ = "Uli Köhler"
__copyright__ = "Copyright 2025, Uli Köhler"
__license__ = "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication"

import geopandas as gpd
import pandas as pd
import numpy as np
from shapely.geometry import Point
from shapely.ops import unary_union
import matplotlib.pyplot as plt
from tqdm.auto import tqdm
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from cartopy.feature import ShapelyFeature
from sklearn.neighbors import KDTree
import cartopy.io.shapereader as shpreader

# Cargadores de PLZ y país integrados (autocontenidos)
def load_plz_data(plz_file_path: str) -> pd.DataFrame:
    """Cargar datos tabulares de códigos postales desde una ruta de archivo (parquet o CSV)."""
    if plz_file_path.endswith('.parquet') or plz_file_path.endswith('.parq'):
        return pd.read_parquet(plz_file_path)
    if plz_file_path.endswith('.csv') or plz_file_path.endswith('.txt'):
        return pd.read_csv(plz_file_path)
    # Probar lectores comunes como respaldo
    try:
        return pd.read_parquet(plz_file_path)
    except Exception:
        try:
            return pd.read_csv(plz_file_path)
        except Exception as exc:
            raise RuntimeError(f'Could not read PLZ data from {plz_file_path}: {exc}') from exc


def create_plz_points(plz_df: pd.DataFrame) -> gpd.GeoDataFrame:
    """Crear un GeoDataFrame de puntos PLZ a partir de un DataFrame.
    Espera longitud/latitud en columnas llamadas ('lon','lng','longitude') y ('lat','latitude').
    También asegura que exista una columna 'PLZ' copiando columnas comunes de códigos postales o recurriendo al índice.
    """
    df = plz_df.copy()
    # búsqueda tolerante de columnas para coordenadas
    lon_cols = [c for c in df.columns if c.lower() in ('lon', 'lng', 'longitude')]
    lat_cols = [c for c in df.columns if c.lower() in ('lat', 'latitude')]
    if not lon_cols or not lat_cols:
        raise ValueError('PLZ dataframe must contain lon and lat columns (e.g. lon, lat)')
    lon_col = lon_cols[0]
    lat_col = lat_cols[0]
    df = df.dropna(subset=[lon_col, lat_col]).copy()
    geometry = [Point(xy) for xy in zip(df[lon_col].astype(float), df[lat_col].astype(float))]
    gdf = gpd.GeoDataFrame(df, geometry=geometry, crs="EPSG:4326")
    # Mantener nombres de columnas convencionales para el código posterior
    if 'lon' not in gdf.columns:
        gdf['lon'] = gdf[lon_col].astype(float)
    if 'lat' not in gdf.columns:
        gdf['lat'] = gdf[lat_col].astype(float)

    # Asegurar que exista una columna PLZ (código postal). Probar primero nombres comunes, si no, recurrir al índice como cadena
    plz_cols = [c for c in gdf.columns if c.lower() in ('plz', 'postal_code', 'postal', 'postleitzahl')]
    if plz_cols:
        gdf['PLZ'] = gdf[plz_cols[0]].astype(str)
    else:
        # Si hay una columna numérica llamada 'zip' o 'postcode', seleccionarla
        zip_cols = [c for c in gdf.columns if c.lower() in ('zip', 'postcode')]
        if zip_cols:
            gdf['PLZ'] = gdf[zip_cols[0]].astype(str)
        else:
            # respaldo: crear PLZ a partir del índice para tener un identificador estable
            gdf = gdf.reset_index(drop=False)
            gdf['PLZ'] = gdf['index'].astype(str)
            gdf.drop(columns=['index'], inplace=True)

    return gdf


def load_germany_from_natural_earth() -> object:
    """Leer países de Natural Earth 10m y devolver la geometría de Alemania (shapely)"""
    # Usar la ruta shapereader de cartopy y geopandas para cargar el shapefile 10m admin_0_countries
    shp_path = shpreader.natural_earth(resolution='10m', category='cultural', name='admin_0_countries')
    countries = gpd.read_file(shp_path)
    # probar nombres de columnas ISO comunes para identificar Alemania
    iso_cols = [c for c in ('ISO_A3', 'ISO3', 'adm0_a3', 'ADM0_A3', 'iso_a3') if c in countries.columns]
    if iso_cols:
        germany = countries[countries[iso_cols[0]] == 'DEU']
    else:
        # respaldo: intentar coincidir por NAME o SOVEREIGNT
        name_cols = [c for c in ('NAME_EN', 'NAME', 'SOVEREIGNT', 'ADMIN') if c in countries.columns]
        if name_cols:
            germany = countries[countries[name_cols[0]].str.contains('Germany', case=False, na=False)]
        else:
            raise RuntimeError('Could not determine ISO or name column in Natural Earth countries')
    if germany.empty:
        raise RuntimeError('Germany geometry not found in Natural Earth countries')
    return germany.iloc[0].geometry


def filter_plz_in_germany(plz_gdf, germany_geometry):
    """Filtrar puntos PLZ que están dentro de Alemania"""
    plz_gdf = plz_gdf.to_crs("EPSG:3857")  # Web Mercator para cálculos de distancia
    germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]

    # Crear un buffer alrededor de Alemania para incluir PLZ cerca de la frontera
    buffered_germany = germany_geometry.buffer(50000)  # buffer de 50km

    # Filtrar PLZ dentro de la Alemania con buffer
    plz_in_germany = plz_gdf[plz_gdf.intersects(buffered_germany)]
    return plz_in_germany


def calculate_coverage(selected_plz, germany_geometry, radius_km=30):
    """Calcular qué porcentaje de Alemania está cubierto por los PLZ seleccionados"""
    radius_m = radius_km * 1000
    coverage = unary_union(selected_plz.geometry.buffer(radius_m))
    germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]
    covered_area = germany_geometry.intersection(coverage).area
    total_area = germany_geometry.area
    return covered_area / total_area * 100


def _generate_grid_points_in_polygon(polygon_3857, step_m: float) -> np.ndarray:
    """Generar una cuadrícula regular de puntos (x, y) dentro del polígono dado (EPSG:3857)."""
    xmin, ymin, xmax, ymax = polygon_3857.bounds
    xs = np.arange(xmin, xmax + step_m, step_m)
    ys = np.arange(ymin, ymax + step_m, step_m)
    points = []
    # Un bucle simple es aceptable para ~20k-60k puntos
    for x in xs:
        for y in ys:
            p = Point(x, y)
            if polygon_3857.contains(p):
                points.append((x, y))
    if not points:
        return np.empty((0, 2), dtype=float)
    return np.array(points, dtype=float)


def select_plz_max_coverage(
    plz_gdf: gpd.GeoDataFrame,
    germany_geometry,
    radius_km: float = 30,
    coverage_target: float = 0.99,
    sample_size: int = 300,
    grid_step_km: float = 5.0,
    exclusion_factor: float = 0.75,
    max_steps: int = 10000,
    patience: int = 500,
    validate_every: int = 50,
    random_state: int = 42,
    verbose: bool = True
) -> gpd.GeoDataFrame:
    """
    Algoritmo único para seleccionar puntos PLZ maximizando el área cubierta (aprox).

    Usa una cuadrícula regular de puntos dentro de Alemania (EPSG:3857) como proxy de área.
    Voraz aleatorizado: en cada paso toma una muestra de un subconjunto de candidatos restantes y elige
    el que cubre más puntos de la cuadrícula actualmente no cubiertos dentro del radio.

    Mejoras:
    - Comprobaciones periódicas de cobertura exacta (unary_union) para evitar paradas prematuras.
    - Radio de exclusión para suprimir selecciones agrupadas: tras elegir un centro,
      descarta cualquier PLZ restante dentro de (exclusion_factor * radius_km).

    Devuelve: GeoDataFrame (EPSG:3857) de puntos PLZ seleccionados.
    """
    rng = np.random.default_rng(random_state)

    # Asegurar CRS proyectado para distancia/área y preparar geometría
    work_gdf = plz_gdf.to_crs("EPSG:3857").copy()
    germany_3857 = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]

    radius_m = float(radius_km) * 1000.0
    exclusion_m = float(exclusion_factor) * radius_m
    grid_step_m = float(grid_step_km) * 1000.0

    # 1) Construir puntos de cuadrícula dentro de Alemania como proxy de cobertura
    if verbose:
        print(f"Building {grid_step_km:.1f} km grid over Germany for coverage approximation...")
    grid_xy = _generate_grid_points_in_polygon(germany_3857, step_m=grid_step_m)
    if grid_xy.shape[0] == 0:
        raise RuntimeError("Failed to generate grid points inside Germany polygon.")
    if verbose:
        print(f"Grid points: {grid_xy.shape[0]:,}")

    # 2) KDTree sobre la cuadrícula para consultas rápidas por radio
    tree_grid = KDTree(grid_xy, leaf_size=40, metric='euclidean')

    # 3) Precomputar índices de vecinos de la cuadrícula para cada candidato PLZ
    plz_xy = np.column_stack([work_gdf.geometry.x.values, work_gdf.geometry.y.values])
    if verbose:
        print("Precomputing candidate-to-grid coverage neighborhoods...")
    neighbor_lists = tree_grid.query_radius(plz_xy, r=radius_m, return_distance=False)

    # 3b) KDTree sobre candidatos PLZ para filtrado por exclusión
    tree_plz = KDTree(plz_xy, leaf_size=40, metric='euclidean')

    # 4) Bucle de selección voraz aleatorizada sobre la máscara de cuadrícula no cubierta
    uncovered = np.ones(grid_xy.shape[0], dtype=bool)
    selected_mask = np.zeros(len(work_gdf), dtype=bool)
    active_mask = np.ones(len(work_gdf), dtype=bool)  # candidatos todavía disponibles
    no_improve_steps = 0

    pbar = tqdm(total=max_steps, disable=not verbose, desc="Selecting centers (max-coverage)")
    for step in range(max_steps):
        pbar.update(1)

        # Comprobación periódica de cobertura exacta
        if step % validate_every == 0 and selected_mask.any():
            selected_tmp = work_gdf.loc[selected_mask]
            exact_cov = calculate_coverage(selected_tmp, germany_geometry, radius_km) / 100.0
            if step % (validate_every * 2) == 0:
                pbar.set_postfix_str(f"exact~{exact_cov*100:.1f}%")
            if exact_cov >= coverage_target:
                break

        # Fracción de cobertura aproximada en la cuadrícula
        covered_frac = 1.0 - (uncovered.sum() / uncovered.size)
        if covered_frac >= coverage_target and selected_mask.any():
            selected_tmp = work_gdf.loc[selected_mask]
            exact_cov = calculate_coverage(selected_tmp, germany_geometry, radius_km) / 100.0
            if exact_cov >= coverage_target:
                break

        remaining_idx = np.flatnonzero(active_mask)
        if remaining_idx.size == 0:
            break

        # Tomar muestra de candidatos del conjunto activo
        k = int(min(sample_size, remaining_idx.size))
        sample = rng.choice(remaining_idx, size=k, replace=False) if k > 0 else []

        # Evaluar ganancia marginal en la cuadrícula
        best_gain = 0
        best_idx = None
        for idx in sample:
            neigh = neighbor_lists[idx]
            if neigh.size == 0:
                continue
            gain = int(uncovered[neigh].sum())
            if gain > best_gain:
                best_gain = gain
                best_idx = idx

        if best_idx is None or best_gain == 0:
            no_improve_steps += 1
            if no_improve_steps >= patience:
                break
            else:
                continue

        # Aceptar el mejor candidato
        selected_mask[best_idx] = True
        # Marcar puntos de la cuadrícula como cubiertos
        uncovered[neighbor_lists[best_idx]] = False
        # Excluir PLZ cercanos para reducir agrupamiento
        if exclusion_m > 0:
            near = tree_plz.query_radius(plz_xy[[best_idx]], r=exclusion_m, return_distance=False)[0]
            active_mask[near] = False
        # También eliminar el índice elegido
        active_mask[best_idx] = False
        no_improve_steps = 0

    pbar.close()

    selected = work_gdf.loc[selected_mask].copy()
    selected = selected.set_crs("EPSG:3857")
    return selected


def visualize_coverage(selected_plz, germany_geometry, radius_km=30):
    """Visualizar la cobertura usando Cartopy"""
    # Convertir a WGS84 para visualización
    selected_plz = selected_plz.to_crs("EPSG:4326")
    germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:4326")[0]

    # Crear figura con proyección de Cartopy
    fig = plt.figure(figsize=(12, 12))
    ax = fig.add_subplot(1, 1, 1, projection=ccrs.EqualEarth())

    # Añadir elementos del mapa
    ax.add_feature(cfeature.LAND, facecolor='lightgray')
    ax.add_feature(cfeature.OCEAN, facecolor='lightblue')
    ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
    ax.add_feature(cfeature.BORDERS, linestyle=':', linewidth=0.5)

    # Crear ShapelyFeature para Alemania
    germany_feature = ShapelyFeature([germany_geometry], ccrs.PlateCarree(), facecolor='none', edgecolor='red', linewidth=1)
    ax.add_feature(germany_feature)

    # Crear y dibujar círculos (todos los PLZ ahora)
    radius_m = radius_km * 1000
    buffers_3857 = gpd.GeoSeries(selected_plz.geometry).to_crs("EPSG:3857").buffer(radius_m)
    buffers_4326 = gpd.GeoSeries(buffers_3857, crs="EPSG:3857").to_crs("EPSG:4326")

    # Dibujar cada buffer individualmente para que las superposiciones se rendericen como transparencias superpuestas
    for geom in buffers_4326:
        ax.add_geometries([geom], crs=ccrs.PlateCarree(), facecolor='blue', edgecolor='none', alpha=0.2, zorder=2)

    # Dibujar puntos PLZ seleccionados encima
    ax.scatter(selected_plz.geometry.x, selected_plz.geometry.y, color='red', s=5, transform=ccrs.PlateCarree(), label='Selected PLZ', zorder=3)

    # Establecer extensión a Alemania con algo de margen
    ax.set_extent([4, 16, 47, 56], crs=ccrs.PlateCarree())

    # Añadir líneas de cuadrícula
    gl = ax.gridlines(draw_labels=True, linestyle='--')
    gl.top_labels = False
    gl.right_labels = False

    plt.title(f"PLZ Coverage of Germany with {len(selected_plz)} centers ({radius_km}km radius)")
    plt.legend(loc='upper right')
    plt.show()


def run_analysis(plz_file_path, radius_km=30, coverage_target=0.99):
    """Función principal para el análisis usando un único algoritmo de cobertura máxima"""
    # Cargar geometría de Alemania desde Natural Earth
    print("Loading Germany geometry from Natural Earth...")
    germany_geometry = load_germany_from_natural_earth()

    # Cargar y procesar datos PLZ
    print("Loading PLZ data...")
    plz_df = load_plz_data(plz_file_path)
    print(f"Loaded {len(plz_df)} German postal codes")

    plz_gdf = create_plz_points(plz_df)

    print("Filtering PLZ within Germany...")
    plz_in_germany = filter_plz_in_germany(plz_gdf, germany_geometry)
    print(f"Found {len(plz_in_germany)} PLZ within or near Germany")

    # Seleccionar centros PLZ usando un único algoritmo
    print("Selecting PLZ centers with max-coverage algorithm...")
    selected_plz = select_plz_max_coverage(
        plz_in_germany, germany_geometry, radius_km=radius_km, coverage_target=coverage_target, sample_size=300, grid_step_km=2.0, exclusion_factor=0.75, max_steps=10000, patience=500, validate_every=50, random_state=42, verbose=True
    )

    # Calcular cobertura (exacta, unión única)
    coverage_percent = calculate_coverage(selected_plz, germany_geometry, radius_km)
    print(f"\nSelected {len(selected_plz)} PLZ centers covering {coverage_percent:.1f}% of Germany")

    # Visualizar cobertura
    visualize_coverage(selected_plz, germany_geometry=germany_geometry, radius_km=radius_km)

    # Devolver resultados de forma defensiva: incluir solo columnas que existan
    sel = selected_plz.to_crs("EPSG:4326")
    desired = ['PLZ', 'lat', 'lon', 'geometry']
    available = [c for c in desired if c in sel.columns or c == 'geometry']
    # Asegurar que geometry esté presente
    if 'geometry' not in available:
        available.append('geometry')

    return {
        'selected_plz': sel[available],
        'coverage_percent': coverage_percent,
        'total_plz_used': len(selected_plz)
    }

Este código es adecuado para Jupyter Notebooks.

Ejemplo de uso

example-usage.py
results = run_analysis(
    plz_file_path="postleitzahlen.parquet",
    radius_km=35,
    coverage_target=0.995
)

Cómo encontrar los códigos PLZ resultantes más cercanos a un código dado

Esta adición opcional toma un código PLZ de referencia de algún lugar y genera un DataFrame de los 50 PLZ seleccionados más cercanos. Esto asume que has usado un Jupyter Notebook para ejecutar el código anterior.

Nearest PLZ.avif

find_nearest_plz.py
# Celda: encontrar los 50 PLZ seleccionados más cercanos a un PLZ dado (ejemplo: 63110)
plz_code = '10176'

# Asegurar que tenemos los resultados seleccionados disponibles; si no, ejecutar el análisis una vez (guarda ligero)
if 'results' not in globals():
    print('`results` not found in the notebook namespace — running `run_analysis` (this may take time)')
    results = run_analysis(plz_file_path="postleitzahlen.parquet", radius_km=35, coverage_target=0.995)

selected = results['selected_plz']

# Cargar la tabla PLZ completa para localizar las coordenadas del PLZ de referencia
plz_df = load_plz_data("postleitzahlen.parquet")
plz_gdf = create_plz_points(plz_df)

# Intentar coincidencia exacta con PLZ, si no, coincidencia por prefijo
ref = plz_gdf[plz_gdf['PLZ'].astype(str) == str(plz_code)]
if ref.empty:
    ref = plz_gdf[plz_gdf['PLZ'].astype(str).str.startswith(str(plz_code))]

if ref.empty:
    raise ValueError(f"Reference PLZ {plz_code} not found in PLZ dataset")

ref_point = ref.iloc[0].geometry

# Reproyectar ambos a un CRS proyectado para distancias métricas (EPSG:3857)
selected_3857 = selected.to_crs("EPSG:3857").copy()
ref_3857 = gpd.GeoSeries([ref_point], crs="EPSG:4326").to_crs("EPSG:3857")[0]

# Calcular distancias (metros)
selected_3857['distance_m'] = selected_3857.geometry.distance(ref_3857)

# Si el propio PLZ de referencia está en el conjunto seleccionado, excluirlo para obtener los otros centros más cercanos
if 'PLZ' in selected_3857.columns:
    selected_for_sort = selected_3857[selected_3857['PLZ'].astype(str) != str(plz_code)]
else:
    selected_for_sort = selected_3857

# Tomar los 50 más cercanos
nearest = selected_for_sort.nsmallest(50, 'distance_m').copy()

# Convertir de vuelta a coordenadas geográficas y preparar un DataFrame amigable
nearest_geo = nearest.to_crs("EPSG:4326")
nearest_geo['lat'] = nearest_geo.geometry.y
nearest_geo['lon'] = nearest_geo.geometry.x
nearest_geo['distance_km'] = (nearest_geo['distance_m'] / 1000.0).round(3)

# Elegir columnas a mostrar: preferir campos legibles si están presentes
prefer_columns = ['PLZ', 'name', 'place', 'Ortsteil', 'stadtteil', 'place_name', 'lat', 'lon', 'distance_km']
available = [c for c in prefer_columns if c in nearest_geo.columns]
# si ninguna de las columnas de nombre preferidas existe, mostrar todas las columnas no geométricas
if not available:
    available = [c for c in nearest_geo.columns if c != 'geometry']

nearest_50_df = nearest_geo[available].reset_index(drop=True)

print(f"Nearest {len(nearest_50_df)} selected PLZs to {plz_code} (top {min(50, len(nearest_50_df))}):")
nearest_50_df.head(50)  # mostrar el DataFrame

Echa un vistazo a artículos similares por categoría: Algorithms, Geoinformatics