Algorithmus zum Finden einer minimalen Menge von Postleitzahlen, um ein Land (fast) vollständig abzudecken

Dieser Beitrag zeigt, wie man eine minimale Menge von Postleitzahlen (PLZ) auswählt, um ein Land fast vollständig abzudecken, mit Python und einigen gängigen Bibliotheken. Das Beispiel verwendet Deutschland, aber der Ansatz kann auf andere Länder mit ähnlichen Postleitzahlensystemen angepasst werden.

Das Modell, das wir hier verwenden, ist, dass ein Kreis mit konstantem Radius um jede ausgewählte Postleitzahl (= Zentrum) gezogen wird, und das Ziel ist es, so viel wie möglich der Landesfläche mit so wenigen Kreisen wie möglich abzudecken. Dies ist ein klassisches Optimierungsproblem, oft als “Set-Cover-Problem” bezeichnet, das NP-schwer ist. Daher verwenden wir einen Greedy-Approximationsalgorithmus mit einigen Erweiterungen, um in angemessener Zeit eine gute Lösung zu finden.

Dieser Algorithmus kann leicht für andere Länder und auch für andere POI-Mengen angepasst werden.

Dies ist in erster Linie ein stochastischer Algorithmus, sodass er möglicherweise nicht das absolut optimale Ergebnis liefert, aber in den meisten Fällen sehr nahe am Optimum sein sollte. Zusätzlich läuft er in Sekunden sogar auf einem Standard-Laptop (mit den Parametern wie im folgenden Beispiel).

Da dieser Code die PLZ-Tabelle als parquet-Datei einliest, siehe unseren vorherigen Beitrag Postleitzahlen und Koordinaten von GeoNames parsen für den Code und führe ihn mit python parse_de.py -p postleitzahlen.parquet aus, um die erforderliche Datei zu erstellen.

Ergebnisse

Mit der folgenden Beispielverwendung wählen wir am Ende 429 Zentren (Postleitzahlen) aus, um 99,5% Deutschlands mit einem Radius von 35 km um jede ausgewählte Postleitzahl abzudecken.

PLZ Coverage Germany.avif

Vollständiger Quellcode

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

# Eingebettete PLZ- und Länder-Lader (selbstständig)
def load_plz_data(plz_file_path: str) -> pd.DataFrame:
    """Lade tabellarische PLZ-Daten aus einem Dateipfad (parquet oder 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)
    # Versuche gängige Reader als Fallback
    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:
    """Erstelle ein GeoDataFrame mit PLZ-Punkten aus einem DataFrame.
    Erwartet Längengrad/Breitengrad in Spalten mit einem der Namen ('lon','lng','longitude') und ('lat','latitude').
    Stellt außerdem sicher, dass eine 'PLZ'-Spalte existiert, indem gängige Postleitzahl-Spalten kopiert werden oder als Fallback der Index verwendet wird.
    """
    df = plz_df.copy()
    # Tolerante Spaltensuche für Koordinaten
    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")
    # Behalte konventionelle Spaltennamen für nachfolgenden Code bei
    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)

    # Stelle sicher, dass eine PLZ-Spalte existiert (Postleitzahl). Versuche zuerst gängige Namen, sonst Fallback auf Index als 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:
        # Falls es eine numerische Spalte namens 'zip' oder 'postcode' gibt, wähle diese
        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:
            # Fallback: Erstelle PLZ aus dem Index, um einen stabilen Bezeichner zu haben
            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:
    """Lese 10m Natural Earth Länder und gib die Deutschland-Geometrie zurück (shapely)"""
    # Verwende cartopys shapereader-Pfad und geopandas, um die 10m admin_0_countries-Shapefile zu laden
    shp_path = shpreader.natural_earth(resolution='10m', category='cultural', name='admin_0_countries')
    countries = gpd.read_file(shp_path)
    # Versuche gängige ISO-Spaltennamen, um Deutschland zu finden
    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:
        # Fallback: Versuche Übereinstimmung über NAME oder 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):
    """Filtere PLZ-Punkte, die innerhalb Deutschlands liegen"""
    plz_gdf = plz_gdf.to_crs("EPSG:3857")  # Web Mercator für Distanzberechnungen
    germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]

    # Erstelle einen Puffer um Deutschland, um PLZ nahe der Grenze einzubeziehen
    buffered_germany = germany_geometry.buffer(50000)  # 50km Puffer

    # Filtere PLZ innerhalb des gepufferten Deutschlands
    plz_in_germany = plz_gdf[plz_gdf.intersects(buffered_germany)]
    return plz_in_germany


def calculate_coverage(selected_plz, germany_geometry, radius_km=30):
    """Berechne, wie viel Prozent Deutschlands von den ausgewählten PLZ abgedeckt werden"""
    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:
    """Erstelle ein regelmäßiges Gitter von Punkten (x, y) innerhalb des gegebenen Polygons (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 = []
    # Einfache Schleife ist OK für ~20k-60k Punkte
    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:
    """
    Einziger Algorithmus zur Auswahl von PLZ-Punkten, die die abgedeckte Fläche maximieren (approximativ).

    Verwendet ein regelmäßiges Gitter von Punkten innerhalb Deutschlands (EPSG:3857) als Flächenproxy.
    Gierig-randomisiert: wähle bei jedem Schritt eine Teilmenge der verbleibenden Kandidaten und wähle
    denjenigen, der die meisten aktuell nicht abgedeckten Gitterpunkte innerhalb des Radius abdeckt.

    Erweiterungen:
    - Periodische exakte Abdeckungsprüfungen (unary_union), um vorzeitiges Abbrechen zu vermeiden.
    - Ausschlussradius, um gehäufte Auswahlen zu unterdrücken: nach Auswahl eines Zentrums,
      verwerfe alle verbleibenden PLZ innerhalb (exclusion_factor * radius_km).

    Gibt zurück: GeoDataFrame (EPSG:3857) der ausgewählten PLZ-Punkte.
    """
    rng = np.random.default_rng(random_state)

    # Stelle projiziertes CRS für Distanz/Fläche sicher und bereite Geometrie vor
    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) Erstelle Gitterpunkte innerhalb Deutschlands als Abdeckungsproxy
    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 auf Gitter für schnelle Radius-Abfragen
    tree_grid = KDTree(grid_xy, leaf_size=40, metric='euclidean')

    # 3) Berechne Nachbar-Gitterindizes für jeden PLZ-Kandidaten vorab
    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 auf PLZ-Kandidaten für Ausschlussfilterung
    tree_plz = KDTree(plz_xy, leaf_size=40, metric='euclidean')

    # 4) Gierig-randomisierte Auswahl-Schleife über die nicht-abgedeckte Gittermaske
    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)  # noch verfügbare Kandidaten
    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)

        # Periodische exakte Abdeckungsprüfung
        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

        # Approximiere Abdeckungsanteil auf dem Gitter
        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

        # Wähle Kandidaten aus der aktiven Menge
        k = int(min(sample_size, remaining_idx.size))
        sample = rng.choice(remaining_idx, size=k, replace=False) if k > 0 else []

        # Werte den marginalen Gewinn auf dem Gitter aus
        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

        # Akzeptiere besten Kandidaten
        selected_mask[best_idx] = True
        # Markiere Gitterpunkte als abgedeckt
        uncovered[neighbor_lists[best_idx]] = False
        # Schließe nahegelegene PLZ aus, um Häufung zu reduzieren
        if exclusion_m > 0:
            near = tree_plz.query_radius(plz_xy[[best_idx]], r=exclusion_m, return_distance=False)[0]
            active_mask[near] = False
        # Entferne auch den gewählten Index
        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):
    """Visualisiere die Abdeckung mit Cartopy"""
    # Konvertiere zu WGS84 für die Visualisierung
    selected_plz = selected_plz.to_crs("EPSG:4326")
    germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:4326")[0]

    # Erstelle Abbildung mit Cartopy-Projektion
    fig = plt.figure(figsize=(12, 12))
    ax = fig.add_subplot(1, 1, 1, projection=ccrs.EqualEarth())

    # Füge Karten-Features hinzu
    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)

    # Erstelle ShapelyFeature für Deutschland
    germany_feature = ShapelyFeature([germany_geometry], ccrs.PlateCarree(), facecolor='none', edgecolor='red', linewidth=1)
    ax.add_feature(germany_feature)

    # Erstelle und zeichne Kreise (jetzt ALLE 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")

    # Zeichne jeden Puffer einzeln, damit Überlappungen als geschichtete Transparenzen gerendert werden
    for geom in buffers_4326:
        ax.add_geometries([geom], crs=ccrs.PlateCarree(), facecolor='blue', edgecolor='none', alpha=0.2, zorder=2)

    # Zeichne ausgewählte PLZ-Punkte obendrauf
    ax.scatter(selected_plz.geometry.x, selected_plz.geometry.y, color='red', s=5, transform=ccrs.PlateCarree(), label='Selected PLZ', zorder=3)

    # Setze Ausdehnung auf Deutschland mit etwas Rand
    ax.set_extent([4, 16, 47, 56], crs=ccrs.PlateCarree())

    # Füge Gitterlinien hinzu
    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):
    """Hauptfunktion für die Analyse mit einem einzigen Max-Coverage-Algorithmus"""
    # Lade Deutschland-Geometrie von Natural Earth
    print("Loading Germany geometry from Natural Earth...")
    germany_geometry = load_germany_from_natural_earth()

    # Lade und verarbeite PLZ-Daten
    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")

    # Wähle PLZ-Zentren mit einem einzigen Algorithmus aus
    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
    )

    # Berechne Abdeckung (exakt, einzelne Vereinigung)
    coverage_percent = calculate_coverage(selected_plz, germany_geometry, radius_km)
    print(f"\nSelected {len(selected_plz)} PLZ centers covering {coverage_percent:.1f}% of Germany")

    # Visualisiere Abdeckung
    visualize_coverage(selected_plz, germany_geometry=germany_geometry, radius_km=radius_km)

    # Gib Ergebnisse defensiv zurück: nur Spalten einbeziehen, die existieren
    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']
    # Stelle sicher, dass geometry vorhanden ist
    if 'geometry' not in available:
        available.append('geometry')

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

Dieser Code ist für Jupyter-Notebooks geeignet.

Beispielverwendung

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

Wie man die nächstgelegenen resultierenden PLZ-Codes zu einem gegebenen Code findet

Diese optionale Ergänzung nimmt einen Referenz-PLZ-Code eines Ortes und generiert einen DataFrame der nächsten 50 ausgewählten PLZ. Dies setzt voraus, dass du ein Jupyter-Notebook verwendet hast, um den vorherigen Code auszuführen.

Nearest PLZ.avif

find_nearest_plz.py
# Zelle: finde die 50 ausgewählten PLZ, die einer gegebenen PLZ am nächsten liegen (Beispiel: 63110)
plz_code = '10176'

# Stelle sicher, dass die ausgewählten Ergebnisse verfügbar sind; falls nicht, führe die Analyse einmal aus (leichtgewichtiger Schutz)
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']

# Lade vollständige PLZ-Tabelle, um die Koordinaten der Referenz-PLZ zu finden
plz_df = load_plz_data("postleitzahlen.parquet")
plz_gdf = create_plz_points(plz_df)

# Versuche exakte Übereinstimmung mit PLZ, sonst Präfix-Übereinstimmung
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

# Projiziere beide in ein projiziertes CRS für metrische Distanzen (EPSG:3857)
selected_3857 = selected.to_crs("EPSG:3857").copy()
ref_3857 = gpd.GeoSeries([ref_point], crs="EPSG:4326").to_crs("EPSG:3857")[0]

# Berechne Distanzen (Meter)
selected_3857['distance_m'] = selected_3857.geometry.distance(ref_3857)

# Falls die Referenz-PLZ selbst in der ausgewählten Menge ist, schließe sie aus, damit wir die nächsten anderen Zentren erhalten
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

# Nimm die 50 nächsten
nearest = selected_for_sort.nsmallest(50, 'distance_m').copy()

# Konvertiere zurück zu geografischen Koordinaten und bereite ein nutzerfreundliches DataFrame vor
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)

# Wähle Spalten zur Anzeige: bevorzuge lesbare Felder, falls vorhanden
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]
# falls keine der bevorzugten Namensspalten existiert, zeige alle Nicht-Geometry-Spalten
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)  # zeige das DataFrame an

Ähnliche Beiträge nach Kategorie: Algorithms, Geoinformatics