Алгоритм пошуку мінімального набору поштових індексів для (майже) повного покриття країни

Ця публікація показує, як обрати мінімальний набір поштових індексів (PLZ) для майже повного покриття країни, використовуючи Python та деякі поширені бібліотеки. Приклад використовує Німеччину, але підхід можна адаптувати для інших країн із подібними системами поштових індексів.

Модель, яку ми тут використовуємо, полягає в тому, що навколо кожного обраного поштового індексу (= центру) малюється коло постійного радіуса, а метою є покрити якомога більшу частину площі країни якомога меншою кількістю кіл. Це класична задача оптимізації, яку часто називають «задачей покриття множини» (set cover problem), і вона є NP-складною. Тому ми використовуємо жадібний алгоритм апроксимації з певними вдосконаленнями, щоб знайти хороший розв’язок за розумний час.

Цей алгоритм можна легко адаптувати для інших країн, а також для інших наборів точок інтересу (POI).

Це переважно стохастичний алгоритм, тому він може не дати абсолютно оптимального результату, але в більшості випадків він має бути дуже близьким до оптимального. Крім того, він виконується за кілька секунд навіть на стандартному ноутбуці (з параметрами, як у прикладі нижче).

Оскільки цей код зчитує таблицю PLZ як файл parquet, дивіться нашу попередню публікацію Postleitzahlen und Koordinaten von GeoNames parsen із кодом і запустіть його командою python parse_de.py -p postleitzahlen.parquet, щоб створити потрібний файл.

Результати

З використанням прикладу нижче ми в підсумку обираємо 429 центрів (поштових індексів), щоб покрити 99.5% Німеччини з радіусом 35 км навколо кожного обраного поштового індексу.

PLZ Coverage Germany.avif

Повний вихідний код

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

# Вбудовані завантажувачі PLZ та країн (автономний модуль)
def load_plz_data(plz_file_path: str) -> pd.DataFrame:
    """Завантажити табличні дані поштових індексів із шляху до файлу (parquet або 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)
    # Спробувати поширені читачі як запасний варіант
    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'Не вдалося прочитати дані PLZ з {plz_file_path}: {exc}') from exc


def create_plz_points(plz_df: pd.DataFrame) -> gpd.GeoDataFrame:
    """Створити GeoDataFrame точок PLZ із DataFrame.
    Очікує довготу/широту у стовпцях з іменами одного з ('lon','lng','longitude') та ('lat','latitude').
    Також гарантує наявність стовпця 'PLZ', копіюючи поширені стовпці поштових індексів або використовуючи індекс як запасний варіант.
    """
    df = plz_df.copy()
    # Толерантний пошук стовпців для координат
    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")
    # Зберегти стандартні імена стовпців для подальшого коду
    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)

    # Гарантувати наявність стовпця PLZ (поштовий індекс). Спочатку спробувати поширені імена, інакше використати індекс як рядок
    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:
        # Якщо є числовий стовпець з іменем на зразок 'zip' або 'postcode', обрати його
        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:
            # запасний варіант: створити PLZ з індексу, щоб мати стабільний ідентифікатор
            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:
    """Прочитати країни Natural Earth 10m і повернути геометрію Німеччини (shapely)"""
    # Використати шлях shapereader cartopy та geopandas для завантаження 10m admin_0_countries shapefile
    shp_path = shpreader.natural_earth(resolution='10m', category='cultural', name='admin_0_countries')
    countries = gpd.read_file(shp_path)
    # спробувати поширені імена стовпців ISO для пошуку Німеччини
    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:
        # запасний варіант: спробувати пошук за NAME або 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):
    """Відфільтрувати точки PLZ, що знаходяться в межах Німеччини"""
    plz_gdf = plz_gdf.to_crs("EPSG:3857")  # Web Mercator для обчислень відстаней
    germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]

    # Створити буфер навколо Німеччини, щоб включити PLZ поблизу кордону
    buffered_germany = germany_geometry.buffer(50000)  # буфер 50 км

    # Відфільтрувати PLZ у межах буферизованої Німеччини
    plz_in_germany = plz_gdf[plz_gdf.intersects(buffered_germany)]
    return plz_in_germany


def calculate_coverage(selected_plz, germany_geometry, radius_km=30):
    """Обчислити, який відсоток Німеччини покрито обраними PLZ"""
    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:
    """Згенерувати регулярну сітку точок (x, y) у межах заданого полігону (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 = []
    # Простий цикл підходить для ~20k-60k точок
    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:
    """
    Єдиний алгоритм для вибору точок PLZ, що максимізують покриту площу (апроксимація).

    Використовує регулярну сітку точок усередині Німеччини (EPSG:3857) як проксі площі.
    Жадібний рандомізований: на кожному кроці вибирає підмножину кандидатів, що залишилися, і обирає
    той, що покриває найбільше ще непокритих точок сітки в межах радіуса.

    Вдосконалення:
    - Періодичні точні перевірки покриття (unary_union), щоб уникнути передчасної зупинки.
    - Радіус виключення для придушення кластерного вибору: після обрання центру
      відкидає всі PLZ, що залишилися, у межах (exclusion_factor * radius_km).

    Повертає: GeoDataFrame (EPSG:3857) обраних точок PLZ.
    """
    rng = np.random.default_rng(random_state)

    # Забезпечити проєктовану CRS для відстаней/площі та підготувати геометрію
    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) Побудувати точки сітки всередині Німеччини як проксі покриття
    if verbose:
        print(f"Будую сітку {grid_step_km:.1f} км над Німеччиною для апроксимації покриття...")
    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_xy.shape[0]:,}")

    # 2) KDTree на сітці для швидких запитів за радіусом
    tree_grid = KDTree(grid_xy, leaf_size=40, metric='euclidean')

    # 3) Попередньо обчислити індекси сусідніх точок сітки для кожного кандидата PLZ
    plz_xy = np.column_stack([work_gdf.geometry.x.values, work_gdf.geometry.y.values])
    if verbose:
        print("Попередньо обчислюю околи покриття кандидат-сітка...")
    neighbor_lists = tree_grid.query_radius(plz_xy, r=radius_m, return_distance=False)

    # 3b) KDTree на кандидатах PLZ для фільтрації виключення
    tree_plz = KDTree(plz_xy, leaf_size=40, metric='euclidean')

    # 4) Жадібний рандомізований цикл вибору за маскою непокритих точок сітки
    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)  # кандидати, що ще доступні
    no_improve_steps = 0

    pbar = tqdm(total=max_steps, disable=not verbose, desc="Обираю центри (макс-покриття)")
    for step in range(max_steps):
        pbar.update(1)

        # Періодична точна перевірка покриття
        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_cov*100:.1f}%")
            if exact_cov >= coverage_target:
                break

        # Апроксимована частка покриття на сітці
        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

        # Вибрати кандидатів з активної множини
        k = int(min(sample_size, remaining_idx.size))
        sample = rng.choice(remaining_idx, size=k, replace=False) if k > 0 else []

        # Оцінити граничний приріст на сітці
        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

        # Прийняти найкращого кандидата
        selected_mask[best_idx] = True
        # Позначити точки сітки як покриті
        uncovered[neighbor_lists[best_idx]] = False
        # Виключити сусідні PLZ, щоб зменшити кластеризацію
        if exclusion_m > 0:
            near = tree_plz.query_radius(plz_xy[[best_idx]], r=exclusion_m, return_distance=False)[0]
            active_mask[near] = False
        # Також видалити обраний індекс
        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):
    """Візуалізувати покриття за допомогою Cartopy"""
    # Перетворити у WGS84 для візуалізації
    selected_plz = selected_plz.to_crs("EPSG:4326")
    germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:4326")[0]

    # Створити фігуру з проєкцією Cartopy
    fig = plt.figure(figsize=(12, 12))
    ax = fig.add_subplot(1, 1, 1, projection=ccrs.EqualEarth())

    # Додати елементи карти
    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)

    # Створити ShapelyFeature для Німеччини
    germany_feature = ShapelyFeature([germany_geometry], ccrs.PlateCarree(), facecolor='none', edgecolor='red', linewidth=1)
    ax.add_feature(germany_feature)

    # Створити та побудувати кола (тепер усі PLZ)
    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")

    # Малювати кожен буфер окремо, щоб перекриття рендерилися як шаруваті прозорості
    for geom in buffers_4326:
        ax.add_geometries([geom], crs=ccrs.PlateCarree(), facecolor='blue', edgecolor='none', alpha=0.2, zorder=2)

    # Побудувати обрані точки PLZ поверх
    ax.scatter(selected_plz.geometry.x, selected_plz.geometry.y, color='red', s=5, transform=ccrs.PlateCarree(), label='Обрані PLZ', zorder=3)

    # Встановити екстент для Німеччини з певним відступом
    ax.set_extent([4, 16, 47, 56], crs=ccrs.PlateCarree())

    # Додати лінії сітки
    gl = ax.gridlines(draw_labels=True, linestyle='--')
    gl.top_labels = False
    gl.right_labels = False

    plt.title(f"Покриття PLZ Німеччини з {len(selected_plz)} центрами (радіус {radius_km} км)")
    plt.legend(loc='upper right')
    plt.show()


def run_analysis(plz_file_path, radius_km=30, coverage_target=0.99):
    """Головна функція для аналізу з використанням єдиного алгоритму макс-покриття"""
    # Завантажити геометрію Німеччини з Natural Earth
    print("Завантажую геометрію Німеччини з Natural Earth...")
    germany_geometry = load_germany_from_natural_earth()

    # Завантажити та обробити дані PLZ
    print("Завантажую дані PLZ...")
    plz_df = load_plz_data(plz_file_path)
    print(f"Завантажено {len(plz_df)} німецьких поштових індексів")

    plz_gdf = create_plz_points(plz_df)

    print("Фільтрую PLZ у межах Німеччини...")
    plz_in_germany = filter_plz_in_germany(plz_gdf, germany_geometry)
    print(f"Знайдено {len(plz_in_germany)} PLZ у межах або поблизу Німеччини")

    # Обрати центри PLZ за єдиним алгоритмом
    print("Обираю центри PLZ алгоритмом макс-покриття...")
    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
    )

    # Обчислити покриття (точне, єдине об'єднання)
    coverage_percent = calculate_coverage(selected_plz, germany_geometry, radius_km)
    print(f"\nОбрано {len(selected_plz)} центрів PLZ, що покривають {coverage_percent:.1f}% Німеччини")

    # Візуалізувати покриття
    visualize_coverage(selected_plz, germany_geometry=germany_geometry, radius_km=radius_km)

    # Повернути результати захисно: включити лише наявні стовпці
    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']
    # Забезпечити наявність geometry
    if 'geometry' not in available:
        available.append('geometry')

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

Цей код підходить для Jupyter Notebook.

Приклад використання

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

Як знайти найближчі результуючі коди PLZ до заданого коду

Це необов’язкове доповнення бере еталонний код PLZ певного місця і генерує DataFrame із 50 найближчих обраних PLZ. Передбачається, що ви використали Jupyter Notebook для запуску попереднього коду.

Nearest PLZ.avif

find_nearest_plz.py
# Комірка: знайти 50 обраних PLZ, найближчих до заданого PLZ (приклад: 63110)
plz_code = '10176'

# Забезпечити наявність обраних результатів; якщо ні, запустити аналіз один раз (легкий захист)
if 'results' not in globals():
    print('`results` не знайдено в просторі імен ноутбука — запускаю `run_analysis` (це може зайняти час)')
    results = run_analysis(plz_file_path="postleitzahlen.parquet", radius_km=35, coverage_target=0.995)

selected = results['selected_plz']

# Завантажити повну таблицю PLZ, щоб знайти координати еталонного PLZ
plz_df = load_plz_data("postleitzahlen.parquet")
plz_gdf = create_plz_points(plz_df)

# Спробувати точний збіг за PLZ, інакше збіг за префіксом
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

# Перепроєктувати обидва у проєктовану CRS для метричних відстаней (EPSG:3857)
selected_3857 = selected.to_crs("EPSG:3857").copy()
ref_3857 = gpd.GeoSeries([ref_point], crs="EPSG:4326").to_crs("EPSG:3857")[0]

# Обчислити відстані (метри)
selected_3857['distance_m'] = selected_3857.geometry.distance(ref_3857)

# Якщо сам еталонний PLZ є в обраній множині, виключити його, щоб отримати найближчі інші центри
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

# Взяти 50 найближчих
nearest = selected_for_sort.nsmallest(50, 'distance_m').copy()

# Перетворити назад у географічні координати та підготувати зручний DataFrame
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)

# Обрати стовпці для показу: надавати перевагу людяним полям, якщо вони є
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]
# якщо жоден з бажаних стовпців імені не існує, показати всі стовпці, крім geometry
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 до {plz_code} (перші {min(50, len(nearest_50_df))}):")
nearest_50_df.head(50)  # відобразити DataFrame

Дивіться схожі статті за категоріями: Algorithms, Geoinformatics