#!/usr/bin/env python3 """Deutschlandweiten Adressindex aus einem Geofabrik-PBF erzeugen (pip install osmium shapely). Nur Adressen und öffentlich kartierte Punkte; ODbL 1.0, © OpenStreetMap-Mitwirkende. """ import argparse import re import sqlite3 import unicodedata import json import time from pathlib import Path import osmium from shapely import STRtree, wkb from shapely.geometry import Point def normal(text): text = text.lower().translate(str.maketrans({"ä":"ae","ö":"oe","ü":"ue","ß":"ss"})) text = ''.join(x for x in unicodedata.normalize("NFKD", text) if not unicodedata.combining(x)) return ' '.join(re.sub(r"[^a-z0-9 ]+", " ", text).split()) def strasse(text): return re.sub(r"str$", "strasse", normal(text)) parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pbf") parser.add_argument("ausgabe", type=Path) parser.add_argument("--stand", required=True, help="OSM-Datenstand aus dem PBF-Header (ISO-Datum/Zeit)") args = parser.parse_args() start = time.monotonic() args.ausgabe.parent.mkdir(parents=True, exist_ok=True) temp = args.ausgabe.with_suffix(".tmp") if temp.exists(): raise SystemExit(f"Temporäre Datei existiert bereits: {temp}") db = sqlite3.connect(temp) db.executescript(""" CREATE TABLE adressen (strasse TEXT NOT NULL, haus TEXT NOT NULL, plz TEXT NOT NULL, ort TEXT NOT NULL, ortsteil TEXT NOT NULL, ags TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL); """) db.execute("PRAGMA cache_size = -65536") factory = osmium.geom.WKBFactory() gemeinden = [] postgebiete = [] batch = [] def schreiben(tags, p): hn = re.sub(r"\s+", "", tags["addr:housenumber"].lower()) if not re.fullmatch(r"[0-9]{1,4}[a-z]?", hn): return batch.append((strasse(tags["addr:street"]), hn, tags.get("addr:postcode", ""), normal(tags.get("addr:city", "")), normal(tags.get("addr:suburb") or tags.get("addr:village") or tags.get("addr:place", "")), "", round(p.y, 6), round(p.x, 6))) if len(batch) == 10000: db.executemany("INSERT INTO adressen VALUES (?,?,?,?,?,?,?,?)", batch) batch.clear() class Daten(osmium.SimpleHandler): def node(self, n): if "addr:street" in n.tags and "addr:housenumber" in n.tags: schreiben(n.tags, Point(n.location.lon, n.location.lat)) def area(self, a): t = a.tags ags = t.get("de:amtlicher_gemeindeschluessel", t.get("ref:ags", "")) plz = t.get("postal_code", "") if t.get("boundary") == "postal_code" else "" gemeinde = t.get("boundary") == "administrative" and re.fullmatch(r"(?:0[1-9]|1[0-6])[0-9]{6}", ags) and t.get("admin_level") in ("4", "6", "7", "8", "9") if not (gemeinde or re.fullmatch(r"[0-9]{5}", plz) or ("addr:street" in t and "addr:housenumber" in t)): return try: geom = wkb.loads(factory.create_multipolygon(a), hex=True) except (RuntimeError, ValueError): return if gemeinde: gemeinden.append((geom, ags, normal(t.get("name", "")))) if plz: postgebiete.append((geom, plz)) if "addr:street" in t and "addr:housenumber" in t and not geom.is_empty: schreiben(t, geom.representative_point()) Daten().apply_file(args.pbf, locations=True, idx="sparse_file_array," + str(args.ausgabe.with_suffix(".locations"))) db.executemany("INSERT INTO adressen VALUES (?,?,?,?,?,?,?,?)", batch) db.commit() args.ausgabe.with_suffix(".locations").unlink(missing_ok=True) print(f"{len(gemeinden)} Gemeinden, {len(postgebiete)} Postgebiete", flush=True) if not gemeinden or not postgebiete: raise SystemExit("Gebietsgrenzen fehlen im PBF") gemtree = STRtree([x[0] for x in gemeinden]) posttree = STRtree([x[0] for x in postgebiete]) # Stapelweise räumlich zuordnen, fehlende PLZ aus dem Postgebiet ergänzen. letzte = 0 while rows := db.execute("SELECT rowid, lat, lon, plz, ort FROM adressen WHERE rowid>? ORDER BY rowid LIMIT 20000", (letzte,)).fetchall(): letzte = rows[-1][0] punkte = [Point(lon, lat) for _, lat, lon, _, _ in rows] gem_paare = gemtree.query(punkte, predicate="within") post_paare = posttree.query(punkte, predicate="within") gems = {i: gemeinden[j][1:] for i,j in zip(*gem_paare)} plz = {i: postgebiete[j][1] for i,j in zip(*post_paare)} aendern = [] ausserhalb = [] for i, (rowid, _, _, alte_plz, ort) in enumerate(rows): if i not in gems: ausserhalb.append((rowid,)) continue ags, gemeinde = gems[i] aendern.append((ags, ort or gemeinde, alte_plz or plz.get(i, ""), rowid)) db.executemany("UPDATE adressen SET ags=?, ort=?, plz=? WHERE rowid=?", aendern) db.executemany("DELETE FROM adressen WHERE rowid=?", ausserhalb) # Wörterbücher statt Millionen wiederholter Straßen-/Ortsnamen; einzige Suchstruktur ist der Primärschlüssel. # Koordinaten als Millionstel Grad (weniger als 0,1 m Rundungsfehler). db.executescript(""" CREATE TABLE strassen (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE); INSERT INTO strassen(name) SELECT DISTINCT strasse FROM adressen; CREATE TABLE orte (id INTEGER PRIMARY KEY, ort TEXT NOT NULL, ortsteil TEXT NOT NULL, ags TEXT NOT NULL); CREATE INDEX orte_aufbau ON orte(ort, ortsteil, ags); INSERT INTO orte(ort, ortsteil, ags) SELECT DISTINCT ort, ortsteil, ags FROM adressen; CREATE TABLE punkte (strasse_id INTEGER NOT NULL, haus TEXT NOT NULL, plz TEXT NOT NULL, ort_id INTEGER NOT NULL, lat INTEGER NOT NULL, lon INTEGER NOT NULL, PRIMARY KEY(strasse_id, haus, plz, ort_id, lat, lon)) WITHOUT ROWID; INSERT OR IGNORE INTO punkte SELECT s.id, a.haus, a.plz, o.id, round(a.lat*1000000), round(a.lon*1000000) FROM adressen a JOIN strassen s ON s.name=a.strasse JOIN orte o ON o.ort=a.ort AND o.ortsteil=a.ortsteil AND o.ags=a.ags; DROP TABLE adressen; DROP INDEX orte_aufbau; CREATE VIEW adressen AS SELECT s.name AS strasse, p.haus, p.plz, o.ort, o.ortsteil, o.ags, p.lat/1000000.0 AS lat, p.lon/1000000.0 AS lon FROM punkte p JOIN strassen s ON s.id=p.strasse_id JOIN orte o ON o.id=p.ort_id; CREATE TABLE metadata (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL) WITHOUT ROWID; """) db.executemany("INSERT INTO metadata VALUES (?,?)", [ ("stand", args.stand), ("quelle", "https://download.geofabrik.de/europe/germany.html"), ("lizenz", "ODbL-1.0"), ("gebiet", "DE")]) db.commit() print(f"{db.execute('SELECT COUNT(*) FROM adressen').fetchone()[0]} Adresspunkte", flush=True) db.execute("VACUUM") db.close() temp.replace(args.ausgabe) print(json.dumps({"datei": str(args.ausgabe), "bytes": args.ausgabe.stat().st_size, "bauSekunden": round(time.monotonic()-start, 1), "stand": args.stand}), flush=True)