#!/usr/bin/env python3 # -*- coding: utf-8 -*- """OSM-Schicht von Stallscout: Pferdebetriebe aus einem Geofabrik-Auszug (.osm.pbf) filtern. Das ist die „Methode“ im Sinn von ODbL 4.6: Wer wissen will, wie aus OpenStreetMap unsere Betriebsliste wurde, findet hier Auswahl, Filter und Typregeln. Die Datei, die dieses Skript schreibt, ist die OSM-Schicht (osm-betriebe.json). Sie steht unter der ODbL 1.0 (© OpenStreetMap-Mitwirkende) und wird auf der Seite „Daten und Quellen“ angeboten. Was übernommen wird: Objekte, die nach Tags oder Namen ein Pensionsstall, ein Reitstall (Reitschule, Reiterhof) oder ein Offenstall (Aktivstall) sind und einen Namen tragen. Reitvereine, Zuchtbetriebe, Rennbahnen und Objekte ohne Namen bleiben draußen. Mehrere Objekte mit gleichem Namen innerhalb von 800 m (Fläche, Gebäude, Platz, Punkt) werden zu einem Eintrag, der Rest steht unter „zusammengefasst“. Von den OSM-Tags bleiben nur die, die für Auswahl, Typ, Anschrift und Website nötig sind (Liste TAG_ERLAUBT). Telefon, E-Mail, Betreiber und alles andere aus OSM wird nicht übernommen. Aufruf (pyosmium nötig, z. B. in einer eigenen venv; Python 3.9 oder neuer): python3 -I scripts/osm/extrahieren.py --pbf niedersachsen-261102.osm.pbf [--pbf bremen-261102.osm.pbf] \\ --stand 2026-11-02 --aus daten/osm-betriebe.json [--grenzen vg250_krs_ni_hb.json] --grenzen (GeoJSON der Landkreise, BKG VG250) schneidet auf Niedersachsen und Bremen zu. Ohne die Datei bleibt alles, was im Auszug liegt (Geofabrik-Auszüge greifen an der Grenze etwas über das Land hinaus). """ from __future__ import annotations import argparse import hashlib import json import math import re import sys import unicodedata from collections import Counter, defaultdict from datetime import datetime, timezone from pathlib import Path try: import osmium except ImportError: # pragma: no cover sys.exit("pyosmium fehlt: pip install osmium") SKRIPT_VERSION = "1" LIZENZ = "ODbL 1.0, © OpenStreetMap-Mitwirkende, https://www.openstreetmap.org/copyright" # Nur diese Tags werden gespeichert und im Download angeboten. TAG_ERLAUBT = { "name", "leisure", "sport", "amenity", "animal", "animal_boarding", "animal_breeding", "building", "club", "addr:street", "addr:housenumber", "addr:postcode", "addr:city", "addr:place", "addr:suburb", "addr:village", "website", "contact:website", "url", } SPORT = re.compile(r"equestrian|horse_riding|horseback") NAME = re.compile( r"(^|[^a-zäöüß])(reit|pferd|pony|ponys|gest[üu]t|hengst|stute|voltigier|fahrverein|islandpferd|isl[äa]nder|pensionsstall|" r"offenstall|aktivstall|einstellbetrieb|zuchtverein|rfv|rufv|zrfv|ranch|stable|horse|equest|hippo|kaltblut|warmblut|" r"vollblut|traber|haflinger|fjord|friesen|quarter|mustang)", re.I) AUSSCHLUSS = ("highway", "route", "railway", "waterway", "barrier", "power", "natural", "boundary") GENERISCH = {"reithalle", "reitplatz", "reitbahn", "reitgelaende", "dressurplatz", "springplatz", "dressurviereck", "paddock", "longierzirkel", "longierplatz", "reitanlage", "roundpen", "round", "pen", "stall", "stallung", "stallungen", "reitsport", "turnierplatz", "abreiteplatz", "abreitplatz", "vorfuehrring", "halle", "platz", "reiterhalle", "fuehranlage", "reitstall", "reiterhof", "reitplaetze", "reithallen", "reitweg", "weide", "koppel", "pferdekoppel", "pferdeweide", "pferdestall", "pferdehof", "pferde", "pferd", "reitstation", "springplaetze", "dressurplaetze", "trainingsplatz", "reitbahnen", "reiterplatz", "sandplatz", "reitgelande", "sport", "sportplatz", "reitbetrieb", "reiten", "ponyhof", "reitsportanlage", "reitzentrum", "pferdepension", "gestuet", "fahrplatz", "fahrbahn", "reit", "fahr", "pensionsstall", "offenstall", "aktivstall"} STOP = {"e", "v", "ev", "gmbh", "co", "kg", "gbr", "ug", "haftungsbeschraenkt", "und", "u", "der", "die", "das", "dem", "den", "zum", "zur", "am", "im", "an", "auf", "in", "von", "vom", "de", "bei", "fuer", "mit", "f"} STARK = re.compile( r"(^|[^a-zäöüß])(reit(er)?(hof|stall|schule|anlage|verein|zentrum|betrieb|sport|park|halle|club|gemeinschaft|" r"station|ring|pension)|reiterhof|reitverein|reiterverein|pferd(e)?(hof|pension|stall|zucht|sport|betrieb|" r"verein|freunde|ranch|zentrum)|pferdehof|pferdepension|pferdestall|pferdezucht|pony(hof|ranch|reit|club|verein)|" r"ponyhof|gest[üu]e?t|hengst|stutenhof|stuteri|voltigier|fahrverein|reit-? ?und fahr|islandpferd|isl[äa]nder|" r"pensionsstall|offenstall|aktivstall|einstellbetrieb|zuchtverein|rfv|rufv|zrfv|ranch|horse|stable|hippo|kaltblut|" r"warmblut|vollblut|traber|haflinger|fjord|friesen|quarter|mustang|reiter ?gruppe|reit ?und ?fahr)", re.I) # Strengeres Muster für Objekte, die nur über den Namen gefunden wurden (ohne Pferde-Tag) STARK_NAME = re.compile( r"(^|[^a-zäöüß])(reit(er)?(hof|stall|schule|anlage|verein|zentrum|betrieb|sport|park|club|gemeinschaft|station|pension|" r"gruppe)|pferd(e)?(hof|pension|stall|zucht|sport|betrieb|verein|freunde|ranch|zentrum)|pony(hof|ranch|reit|club|" r"verein)|gest[üu]e?t|hengststation|hengsthof|stutenhof|voltigier|fahrverein|reit-? ?und fahr|islandpferd|isl[äa]nder|" r"pensionsstall|offenstall|aktivstall|einstellbetrieb|zuchtverein|rfv|rufv|zrfv|ranch)", re.I) NAME_AUSSCHLUSS = re.compile(r"(feuerwehr|schöpfwerk|schoepfwerk|pflegeheim|seniorenheim|krug\b|gaststätte|gasthaus|hotel|apotheke|" r"kirche|friedhof|straße|strasse|weg\b|kindergarten|erholungspark)", re.I) AUSSCHLUSS_AMENITY = {"restaurant", "cafe", "pub", "bar", "fast_food", "biergarten", "parking", "bicycle_parking", "school", "kindergarten", "place_of_worship", "fuel", "post_box", "bench", "recycling", "waste_basket", "shelter", "toilets", "atm", "bank", "doctors", "pharmacy", "veterinary", "charging_station", "bus_station", "drinking_water", "marketplace", "hospital", "clinic", "dentist", "townhall", "library", "theatre", "cinema", "nightclub", "ice_cream", "bicycle_rental", "car_wash", "bureau_de_change"} AUSSCHLUSS_TOURISM = {"viewpoint", "information", "picnic_site", "artwork", "museum", "attraction"} AUSSCHLUSS_LANDUSE = {"forest", "meadow", "grass", "farmland", "residential", "retail", "industrial", "cemetery", "orchard"} # Typregeln: Reihenfolge zählt, der erste Treffer gewinnt. Nur die drei letzten Arten werden übernommen. TYP_REGELN = [ ("reitverein", r"\b(reitverein|reiterverein|rv|rfv|rufv|ruc|rg|rsc|zrfv|fahrverein|reiterring|pferdesportverein|psv|" r"reitclub|reiterclub|reitgemeinschaft|reiterbund|reitsportverein|reiterkreis|turniergemeinschaft|" r"tg|ponyclub|voltigierverein|reit und fahr\w*|reiter und fahr\w*)\b"), ("zucht", r"(gestuet|zucht|aufzucht|hengststation|stutenhof|fohlen|vollblut)"), ("offenstall", r"(offenstall|aktivstall|bewegungsstall|aktiv stall|offen stall|aktivhof)"), ("pensionsstall", r"(pension|einstell|stallgemeinschaft|pferdehaltung)"), ("reitstall", r"(reitschule|reitstall|reiterhof|reitanlage|reitbetrieb|reitzentrum|ponyhof|pony reit|" r"reitunterricht|schulpferde|reitpark|reithof|reitsport|ausbildungsstall|" r"ausbildungszentrum|turnierstall|sportpferde|reitstation|reit und fahrschule|reitverein)"), ] HINWEIS_REGELN = [("sonstiges", r"(rennbahn)"), ("reitverein", r"clubsport"), ("pensionsstall", r"pension"), ("zucht", r"zucht")] ARTEN = ("pensionsstall", "reitstall", "offenstall") UMLAUT = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "ae", "Ö": "oe", "Ü": "ue"}) def norm(s) -> str: s = (s or "").translate(UMLAUT).lower() s = unicodedata.normalize("NFKD", s) s = "".join(c for c in s if not unicodedata.combining(c)) return re.sub(r"[^a-z0-9]+", " ", s).strip() def tokens(name) -> list: return [t for t in norm(name).split() if t not in STOP] def meter(lat1, lon1, lat2, lon2) -> float: dx = (lon2 - lon1) * math.cos(math.radians((lat1 + lat2) / 2)) * 111320.0 dy = (lat2 - lat1) * 110540.0 return math.hypot(dx, dy) def tags_passen(t: dict) -> bool: name = t.get("name") if t.get("leisure") == "horse_riding" or SPORT.search(t.get("sport", "")): return True if t.get("amenity") in ("animal_boarding", "animal_breeding"): return True if re.search(r"horse|pferd", t.get("animal_boarding", ""), re.I): return True if (t.get("building") == "stable" or t.get("animal") == "horse") and name: return True return False def name_passt(t: dict) -> bool: name = t.get("name") return bool(name) and bool(NAME.search(name)) and not any(k in t for k in AUSSCHLUSS) def bezug(t: dict, aus_namenabfrage: bool): """Grund, warum das Objekt als Pferdebetrieb gilt: tag, rennbahn, name oder None.""" name = t.get("name", "") sport = t.get("sport", "") if t.get("leisure") == "horse_riding" or re.search(r"equestrian|horse_riding|horseback", sport): return "tag" if "horse_racing" in sport and name: return "rennbahn" if t.get("amenity") == "animal_boarding" and (re.search(r"horse|pferd", t.get("animal_boarding", ""), re.I) or STARK.search(name)): return "tag" if t.get("amenity") == "animal_breeding" and (re.search(r"horse|pferd", t.get("animal_breeding", "") + t.get("animal", ""), re.I) or STARK.search(name)): return "tag" if t.get("building") == "stable" and name: return "tag" if t.get("animal") == "horse" and name: return "tag" if aus_namenabfrage and name and STARK_NAME.search(name) and not NAME_AUSSCHLUSS.search(name): if (t.get("amenity") in AUSSCHLUSS_AMENITY or t.get("tourism") in AUSSCHLUSS_TOURISM or t.get("landuse") in AUSSCHLUSS_LANDUSE or any(k in t for k in ("shop", "craft", "healthcare", "historic", "man_made", "natural", "power"))): return None return "name" return None def art_bestimmen(name: str, hinweis: str) -> str: text = norm(name) for typ, rx in TYP_REGELN: if re.search(rx, text): return typ h = norm(hinweis) for typ, rx in HINWEIS_REGELN: if re.search(rx, h): return typ return "unklar" def mitte(punkte): lats = [p[0] for p in punkte] lons = [p[1] for p in punkte] return (round((min(lats) + max(lats)) / 2, 7), round((min(lons) + max(lons)) / 2, 7)) def auszug_lesen(pfad: Path, gefunden: dict) -> int: fp = osmium.FileProcessor(str(pfad)).with_locations().with_areas().with_filter(osmium.filter.EmptyTagFilter()) zaehler = 0 for o in fp: if o.is_area() and o.from_way(): continue # geschlossene Wege kommen schon als Weg t = {tag.k: tag.v for tag in o.tags} aus_tags = tags_passen(t) if not aus_tags and not name_passt(t): continue if o.is_node(): typ, oid, lat, lon = "node", o.id, o.location.lat, o.location.lon else: if o.is_way(): typ, oid = "way", o.id punkte = [(n.location.lat, n.location.lon) for n in o.nodes if n.location.valid()] elif o.is_area(): typ, oid = "relation", o.orig_id() punkte = [(n.location.lat, n.location.lon) for ring in o.outer_rings() for n in ring if n.location.valid()] else: continue if not punkte: continue lat, lon = mitte(punkte) if (typ, oid) in gefunden: continue gefunden[(typ, oid)] = {"typ": typ, "id": oid, "lat": lat, "lon": lon, "tags": t, "aus_namen": not aus_tags} zaehler += 1 return zaehler # ----------------------------------------------------------------------------- Landesgrenze def punkt_in_ring(x, y, ring) -> bool: innen = False j = len(ring) - 1 for i in range(len(ring)): xi, yi = ring[i][0], ring[i][1] xj, yj = ring[j][0], ring[j][1] if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi: innen = not innen j = i return innen def grenzen_laden(pfad: Path): flaechen = [] for f in json.loads(pfad.read_text(encoding="utf-8"))["features"]: g = f["geometry"] polys = g["coordinates"] if g["type"] == "MultiPolygon" else [g["coordinates"]] xs = [pt[0] for poly in polys for pt in poly[0]] ys = [pt[1] for poly in polys for pt in poly[0]] flaechen.append(((min(xs), min(ys), max(xs), max(ys)), polys)) return flaechen def im_land(flaechen, lat, lon) -> bool: for (x0, y0, x1, y1), polys in flaechen: if not (x0 <= lon <= x1 and y0 <= lat <= y1): continue for poly in polys: if punkt_in_ring(lon, lat, poly[0]) and not any(punkt_in_ring(lon, lat, h) for h in poly[1:]): return True return False # ----------------------------------------------------------------------------- Betriebe bauen def website_sauber(w): w = (w or "").strip().split(";")[0].strip() if not w: return None if not re.match(r"^https?://", w, re.I): w = "http://" + w return w if re.match(r"^https?://[^\s/]+\.[^\s/]+", w, re.I) else None def satz_aus(e: dict, grund: str) -> dict: t = e["tags"] # Eine Hausnummer allein ist keine Anschrift. Orte ohne Straßennamen führen OSM unter addr:place. strassenname = t.get("addr:street") or t.get("addr:place") strasse = " ".join(x for x in [strassenname, t.get("addr:housenumber")] if x) if strassenname else None ort = t.get("addr:city") or t.get("addr:suburb") or t.get("addr:village") or (t.get("addr:place") if not strassenname else None) hinweis = [] if t.get("amenity") == "animal_boarding": hinweis.append("pension") if t.get("amenity") == "animal_breeding": hinweis.append("zucht") if t.get("club") == "sport": hinweis.append("clubsport") if grund == "rennbahn": hinweis.append("rennbahn") return { "osm_typ": e["typ"], "osm_id": e["id"], "name": (t.get("name") or "").strip(), "lat": e["lat"], "lon": e["lon"], "strasse": strasse, "plz": t.get("addr:postcode"), "ort": ort, "website": website_sauber(t.get("website") or t.get("contact:website") or t.get("url")), "tags": {k: v for k, v in sorted(t.items()) if k in TAG_ERLAUBT}, "zusammengefasst": [], "_hinweis": " ".join(hinweis), "_prim": t.get("leisure") == "horse_riding" or t.get("club") == "sport", } def betriebe_bauen(gefunden: dict, flaechen): st = Counter() st["objekte_mit_pferdebezug_roh"] = len(gefunden) benannt = [] for e in gefunden.values(): grund = bezug(e["tags"], e["aus_namen"]) if not grund: st["kein_pferdebezug"] += 1 continue if flaechen is not None and not im_land(flaechen, e["lat"], e["lon"]): st["ausserhalb_niedersachsen_bremen"] += 1 continue name = (e["tags"].get("name") or "").strip() toks = tokens(name) if not name: st["ohne_namen"] += 1 elif not toks or all(t in GENERISCH for t in toks): st["nur_sachlicher_name"] += 1 else: benannt.append(satz_aus(e, grund)) # gleiche Namen innerhalb von 800 m zusammenfassen gruppen = defaultdict(list) for s in benannt: gruppen[" ".join(sorted(tokens(s["name"])))].append(s) ergebnis = [] for liste in gruppen.values(): liste.sort(key=lambda s: (0 if s["_prim"] else 1, 0 if s["website"] else 1, s["osm_typ"], s["osm_id"])) cluster = [] for s in liste: for c in cluster: r = c[0] if meter(r["lat"], r["lon"], s["lat"], s["lon"]) <= 800: c.append(s) break else: cluster.append([s]) for c in cluster: r = c[0] for s in c[1:]: for feld in ("strasse", "plz", "ort", "website"): if not r[feld] and s[feld]: r[feld] = s[feld] r["_hinweis"] = (r["_hinweis"] + " " + s["_hinweis"]).strip() r["zusammengefasst"].append("%s/%d" % (s["osm_typ"], s["osm_id"])) st["zusammengefasst"] += 1 ergebnis.append(r) behalten = [] for r in ergebnis: r["art"] = art_bestimmen(r["name"], r.pop("_hinweis")) r.pop("_prim") if r["art"] in ARTEN: behalten.append(r) else: st["art_" + r["art"] + "_nicht_uebernommen"] += 1 behalten.sort(key=lambda r: (r["osm_typ"], r["osm_id"])) return behalten, st def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--pbf", action="append", required=True, help="Geofabrik-Auszug, mehrfach angebbar") ap.add_argument("--stand", required=True, help="Datenstand des Auszugs, JJJJ-MM-TT") ap.add_argument("--aus", required=True, help="Zieldatei (JSON)") ap.add_argument("--grenzen", help="GeoJSON der Landkreise (BKG VG250) zum Zuschneiden") args = ap.parse_args() gefunden: dict = {} quellen = [] for p in args.pbf: pfad = Path(p) n = auszug_lesen(pfad, gefunden) print("%s: %d Objekte mit Pferdebezug gelesen" % (pfad.name, n), file=sys.stderr) quellen.append(pfad.name) flaechen = grenzen_laden(Path(args.grenzen)) if args.grenzen else None betriebe, st = betriebe_bauen(gefunden, flaechen) st["betriebe"] = len(betriebe) for a in ARTEN: st["art_" + a] = sum(1 for b in betriebe if b["art"] == a) lauf = hashlib.sha256(json.dumps(betriebe, sort_keys=True).encode()).hexdigest()[:12] ausgabe = { "meta": { "lizenz": LIZENZ, "osm_stand": args.stand, "quellen": quellen, "skript": "scripts/osm/extrahieren.py", "skript_version": SKRIPT_VERSION, "erzeugt": datetime.now(timezone.utc).isoformat(timespec="seconds"), "lauf": "osm-%s-%s" % (args.stand, lauf), "zuschnitt": "BKG VG250 Landkreise Niedersachsen und Bremen" if flaechen else "keiner", "statistik": dict(st), }, "betriebe": betriebe, } ziel = Path(args.aus) ziel.parent.mkdir(parents=True, exist_ok=True) ziel.write_text(json.dumps(ausgabe, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") print(json.dumps(dict(st), ensure_ascii=False), file=sys.stderr) print("%d Betriebe nach %s" % (len(betriebe), ziel), file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())