Algoritmo para encontrar um conjunto mínimo de códigos postais para cobrir um país (quase) completamente
Esta publicação mostra como selecionar um conjunto mínimo de códigos postais (PLZ) para cobrir um país quase completamente, usando Python e algumas bibliotecas comuns. O exemplo usa a Alemanha, mas a abordagem pode ser adaptada para outros países com sistemas de códigos postais semelhantes.
O modelo que usamos aqui é o de um círculo de raio constante desenhado em torno de cada código postal selecionado (= centro), e o objetivo é cobrir o máximo possível da área do país com o menor número de círculos possível. Este é um problema clássico de otimização, frequentemente referido como “problema de cobertura de conjuntos” (set cover problem), que é NP-difícil. Por isso, usamos um algoritmo de aproximação guloso com alguns aprimoramentos para encontrar uma boa solução num tempo razoável.
Este algoritmo pode ser facilmente adaptado para outros países e também para outros conjuntos de POIs.
Este é principalmente um algoritmo estocástico, por isso pode não produzir o resultado absolutamente ótimo, mas deve estar muito próximo do ótimo na maioria dos casos. Além disso, executa em segundos mesmo num portátil padrão (com parâmetros como no exemplo abaixo).
Como este código lê a tabela de PLZ como ficheiro parquet, consulte a nossa publicação anterior Postleitzahlen und Koordinaten von GeoNames parsen
para o código e execute-o com python parse_de.py -p postleitzahlen.parquet para criar o ficheiro necessário.
Resultados
Usando o exemplo de utilização abaixo, acabamos por selecionar 429 centros (códigos postais) para cobrir 99,5% da Alemanha com um raio de 35 km em torno de cada código postal selecionado.

Código-fonte 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
# Carregadores de PLZ e país inline (autossuficiente)
def load_plz_data(plz_file_path: str) -> pd.DataFrame:
"""Carrega dados tabulares de códigos postais a partir de um caminho de ficheiro (parquet ou 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)
# Tentar leitores comuns como alternativa
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'Não foi possível ler dados de PLZ de {plz_file_path}: {exc}') from exc
def create_plz_points(plz_df: pd.DataFrame) -> gpd.GeoDataFrame:
"""Cria um GeoDataFrame de pontos PLZ a partir de um DataFrame.
Espera longitude/latitude em colunas chamadas uma de ('lon','lng','longitude') e ('lat','latitude').
Também garante que existe uma coluna 'PLZ' copiando colunas comuns de códigos postais ou recorrendo ao índice.
"""
df = plz_df.copy()
# procura tolerante de colunas 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('O DataFrame de PLZ deve conter colunas lon e lat (ex. 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")
# Manter nomes de colunas convencionais para código downstream
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)
# Garantir que existe uma coluna PLZ (código postal). Tentar nomes comuns primeiro, senão recorrer ao índice como string
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:
# Se houver uma coluna numérica chamada 'zip' ou 'postcode', escolhê-la
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:
# alternativa: criar PLZ a partir do índice para ter um identificador estável
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:
"""Lê países do Natural Earth 10m e retorna a geometria da Alemanha (shapely)"""
# Usar o caminho shapereader do cartopy e o geopandas para carregar o shapefile 10m admin_0_countries
shp_path = shpreader.natural_earth(resolution='10m', category='cultural', name='admin_0_countries')
countries = gpd.read_file(shp_path)
# tentar nomes de colunas ISO comuns para corresponder à Alemanha
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:
# alternativa: tentar corresponder por NAME ou 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('Não foi possível determinar coluna ISO ou de nome nos países do Natural Earth')
if germany.empty:
raise RuntimeError('Geometria da Alemanha não encontrada nos países do Natural Earth')
return germany.iloc[0].geometry
def filter_plz_in_germany(plz_gdf, germany_geometry):
"""Filtra pontos PLZ que estão dentro da Alemanha"""
plz_gdf = plz_gdf.to_crs("EPSG:3857") # Web Mercator para cálculos de distância
germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]
# Criar um buffer em torno da Alemanha para incluir PLZ perto da fronteira
buffered_germany = germany_geometry.buffer(50000) # buffer de 50km
# Filtrar PLZ dentro da Alemanha com 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):
"""Calcula que percentagem da Alemanha está coberta pelos PLZ selecionados"""
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:
"""Gera uma grelha regular de pontos (x, y) dentro do 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 = []
# Ciclo simples é aceitável para ~20k-60k pontos
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 selecionar pontos PLZ maximizando a área coberta (aprox.).
Usa uma grelha regular de pontos dentro da Alemanha (EPSG:3857) como proxy de área.
Guloso randomizado: em cada passo amostra um subconjunto dos candidatos restantes e escolhe
o que cobre mais pontos da grelha atualmente não cobertos dentro do raio.
Aprimoramentos:
- Verificações periódicas de cobertura exata (unary_union) para evitar paragem prematura.
- Raio de exclusão para suprimir seleções agrupadas: após escolher um centro,
descartar qualquer PLZ restante dentro de (exclusion_factor * radius_km).
Retorna: GeoDataFrame (EPSG:3857) dos pontos PLZ selecionados.
"""
rng = np.random.default_rng(random_state)
# Garantir CRS projetado para distância/área e preparar geometria
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 pontos de grelha dentro da Alemanha como proxy de cobertura
if verbose:
print(f"Construindo grelha de {grid_step_km:.1f} km sobre a Alemanha para aproximação de cobertura...")
grid_xy = _generate_grid_points_in_polygon(germany_3857, step_m=grid_step_m)
if grid_xy.shape[0] == 0:
raise RuntimeError("Falha ao gerar pontos de grelha dentro do polígono da Alemanha.")
if verbose:
print(f"Pontos de grelha: {grid_xy.shape[0]:,}")
# 2) KDTree na grelha para consultas rápidas por raio
tree_grid = KDTree(grid_xy, leaf_size=40, metric='euclidean')
# 3) Pré-calcular índices de vizinhos da grelha para cada candidato PLZ
plz_xy = np.column_stack([work_gdf.geometry.x.values, work_gdf.geometry.y.values])
if verbose:
print("Pré-calculando vizinhanças de cobertura candidato-para-grelha...")
neighbor_lists = tree_grid.query_radius(plz_xy, r=radius_m, return_distance=False)
# 3b) KDTree nos candidatos PLZ para filtragem por exclusão
tree_plz = KDTree(plz_xy, leaf_size=40, metric='euclidean')
# 4) Ciclo de seleção guloso randomizado sobre a máscara de grelha não coberta
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 ainda disponíveis
no_improve_steps = 0
pbar = tqdm(total=max_steps, disable=not verbose, desc="Selecionando centros (cobertura máxima)")
for step in range(max_steps):
pbar.update(1)
# Verificação periódica de cobertura exata
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"exato~{exact_cov*100:.1f}%")
if exact_cov >= coverage_target:
break
# Fração de cobertura aproximada na grelha
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
# Amostrar candidatos do conjunto ativo
k = int(min(sample_size, remaining_idx.size))
sample = rng.choice(remaining_idx, size=k, replace=False) if k > 0 else []
# Avaliar ganho marginal na grelha
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
# Aceitar o melhor candidato
selected_mask[best_idx] = True
# Marcar pontos da grelha como cobertos
uncovered[neighbor_lists[best_idx]] = False
# Excluir PLZ próximos para reduzir agrupamento
if exclusion_m > 0:
near = tree_plz.query_radius(plz_xy[[best_idx]], r=exclusion_m, return_distance=False)[0]
active_mask[near] = False
# Também remover o índice escolhido
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):
"""Visualiza a cobertura usando Cartopy"""
# Converter para WGS84 para visualização
selected_plz = selected_plz.to_crs("EPSG:4326")
germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:4326")[0]
# Criar figura com projeção Cartopy
fig = plt.figure(figsize=(12, 12))
ax = fig.add_subplot(1, 1, 1, projection=ccrs.EqualEarth())
# Adicionar feições do 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)
# Criar ShapelyFeature para a Alemanha
germany_feature = ShapelyFeature([germany_geometry], ccrs.PlateCarree(), facecolor='none', edgecolor='red', linewidth=1)
ax.add_feature(germany_feature)
# Criar e desenhar círculos (todos os PLZ agora)
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")
# Desenhar cada buffer individualmente para que sobreposições sejam renderizadas como transparências em camadas
for geom in buffers_4326:
ax.add_geometries([geom], crs=ccrs.PlateCarree(), facecolor='blue', edgecolor='none', alpha=0.2, zorder=2)
# Desenhar pontos PLZ selecionados por cima
ax.scatter(selected_plz.geometry.x, selected_plz.geometry.y, color='red', s=5, transform=ccrs.PlateCarree(), label='PLZ selecionados', zorder=3)
# Definir extensão para a Alemanha com alguma margem
ax.set_extent([4, 16, 47, 56], crs=ccrs.PlateCarree())
# Adicionar linhas de grelha
gl = ax.gridlines(draw_labels=True, linestyle='--')
gl.top_labels = False
gl.right_labels = False
plt.title(f"Cobertura PLZ da Alemanha com {len(selected_plz)} centros (raio de {radius_km}km)")
plt.legend(loc='upper right')
plt.show()
def run_analysis(plz_file_path, radius_km=30, coverage_target=0.99):
"""Função principal para a análise usando um único algoritmo de cobertura máxima"""
# Carregar geometria da Alemanha do Natural Earth
print("Carregando geometria da Alemanha do Natural Earth...")
germany_geometry = load_germany_from_natural_earth()
# Carregar e processar dados PLZ
print("Carregando dados PLZ...")
plz_df = load_plz_data(plz_file_path)
print(f"Carregados {len(plz_df)} códigos postais alemães")
plz_gdf = create_plz_points(plz_df)
print("Filtrando PLZ dentro da Alemanha...")
plz_in_germany = filter_plz_in_germany(plz_gdf, germany_geometry)
print(f"Encontrados {len(plz_in_germany)} PLZ dentro ou perto da Alemanha")
# Selecionar centros PLZ usando algoritmo único
print("Selecionando centros PLZ com algoritmo de cobertura máxima...")
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 (exata, união única)
coverage_percent = calculate_coverage(selected_plz, germany_geometry, radius_km)
print(f"\nSelecionados {len(selected_plz)} centros PLZ cobrindo {coverage_percent:.1f}% da Alemanha")
# Visualizar cobertura
visualize_coverage(selected_plz, germany_geometry=germany_geometry, radius_km=radius_km)
# Retornar resultados defensivamente: apenas incluir colunas que existem
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']
# Garantir que a geometria 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 é adequado para Jupyter Notebooks.
Exemplo de utilização
results = run_analysis(
plz_file_path="postleitzahlen.parquet",
radius_km=35,
coverage_target=0.995
)Como encontrar os códigos PLZ resultantes mais próximos de um dado código
Este complemento opcional recebe um código PLZ de referência de algum lugar e gera um DataFrame dos 50 PLZ selecionados mais próximos. Isto assume que usou um Jupyter Notebook para executar o código anterior.

# Célula: encontrar os 50 PLZ selecionados mais próximos de um dado PLZ (exemplo: 63110)
plz_code = '10176'
# Garantir que temos os resultados selecionados disponíveis; se não, executar a análise uma vez (proteção leve)
if 'results' not in globals():
print('`results` não encontrado no espaço de nomes do notebook — executando `run_analysis` (isto pode demorar)')
results = run_analysis(plz_file_path="postleitzahlen.parquet", radius_km=35, coverage_target=0.995)
selected = results['selected_plz']
# Carregar tabela PLZ completa para localizar as coordenadas do PLZ de referência
plz_df = load_plz_data("postleitzahlen.parquet")
plz_gdf = create_plz_points(plz_df)
# Tentar correspondência exata em PLZ, senão correspondência por prefixo
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"PLZ de referência {plz_code} não encontrado no conjunto de dados PLZ")
ref_point = ref.iloc[0].geometry
# Reprojetar ambos para um CRS projetado para distâncias 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 distâncias (metros)
selected_3857['distance_m'] = selected_3857.geometry.distance(ref_3857)
# Se o próprio PLZ de referência estiver no conjunto selecionado, excluí-lo para obtermos os centros mais próximos diferentes
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
# Obter os 50 mais próximos
nearest = selected_for_sort.nsmallest(50, 'distance_m').copy()
# Converter de volta para coordenadas geográficas e preparar um DataFrame amigável
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)
# Escolher colunas a mostrar: preferir campos legíveis se existirem
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]
# se nenhuma das colunas de nome preferidas existir, mostrar todas as colunas não-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"{len(nearest_50_df)} PLZ selecionados mais próximos de {plz_code} (top {min(50, len(nearest_50_df))}):")
nearest_50_df.head(50) # exibir o DataFrame