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.

PLZ Coverage Germany.avif

Código-fonte 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

# 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

example-usage.py
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.

Nearest PLZ.avif

find_nearest_plz.py
# 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

Check out similar posts by category: Algorithms, Geoinformatics