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.

Código fuente completo
__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
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.

# 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