#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Ortsbeschrieb Stufe 1 - Erzeuger der Punktliste nach Regel R0.2 (D0, Geralt, 14.10.2026; R0.1 = R0 mit erweiterten Typwort-Listen, R0.2 = R0.1 mit weiteren Bindewoertern und drei Mehrzahlwoertern, Nachtraege im Regelblatt). Rezept fuer die Liste aller Leuchttuerme, Inseln, Haefen, Marinas, Kaps, Staedte und Doerfer am Wasser. Setzt das Regelblatt R0 (Werkstatt/Ortsbeschrieb R0, eingefroren 14.10.2026 11:06) Wert fuer Wert um: Klasse, Name und Einzelfilter je Objekt (Regelblatt 1.1 und 1.4), dann Dubletten, Leuchtturm-Haeufung und Filter am Wasser (1.5). Jede Konstante steht in referenz.py mit demselben Wert; abgleich_r0.py prueft das, Wert fuer Wert. Nur Standardbibliothek, kein Netz. Laeuft mit Python 3.9 (macOS /usr/bin/python3) und neuer. Eingang (erstes Argument): geojsonseq Ausgabe von `osmium export -f geojsonseq -u type_id` (ein Objekt je Zeile, mit oder ohne Satztrenner RS). Lage: Knoten mit seiner Koordinate, Weg und Relation mit der Mitte des umschliessenden Rechtecks der Geometrie (wie Overpass `out center`), auf 1e-7 Grad gerundet wie die Overpass-Ausgabe; ein Rechteck ueber 180 Grad Laenge wird ueber die Datumsgrenze gerechnet (Entscheid Oscar 14.10.2026). overpass Overpass-JSON (`out center tags`), Adapter fuer die R0-Rohdaten. Elemente mit `geometry` (Kueste und Seeflaeche fuer den Eichlauf) werden uebersprungen. Ausgang: punkte.json {"lizenz", "regel", "punkte": [...]}, Form und Reihenfolge wie punkte-r0.json. Die Datei NOB1 schreibt Klaas' Schreiber (scripts/ortsbeschrieb/nob1.py, Paket A) aus genau dieser Liste; dieses Skript schreibt kein Binaerformat. lauf-log.json OSM-Stand, gelesen je Klasse, ausgeschieden je Klasse und Grund, geschrieben je Klasse, Probe "geht auf", Eingaenge mit Byte und SHA-256, SHA-256 dieses Rezepts, Python- und Unicode-Fassung. Aufrufe: python3 -I -B bauen.py geojsonseq --osm-stand python3 -I -B bauen.py overpass [ ...] Daten: OpenStreetMap, (c) OpenStreetMap-Mitwirkende, ODbL 1.0. Nicht zur Navigation. """ import argparse import hashlib import json import math import os import platform import sys import time import unicodedata LIZENZ = "(c) OpenStreetMap-Mitwirkende, ODbL 1.0" REGEL = "R0.2 2026-10-14" # --------------------------------------------------------------------------------------------- # Konstanten R0 - gleiche Namen und Werte wie referenz.py (abgleich_r0.py vergleicht sie) # --------------------------------------------------------------------------------------------- R_SM = 3440.065 # Erdradius der App in Seemeilen (WaypointNavigator.swift:275) METER_JE_SM = 1852.0 # Klassen in der Reihenfolge der Vorfahrt (Regelblatt 1.1, Spalte Vorfahrt) KLASSEN = ("leuchtturm", "insel", "hafen", "marina", "kap", "stadt", "dorf") RANG = {k: i for i, k in enumerate(KLASSEN)} SEE_KLASSEN = ("leuchtturm", "insel", "hafen", "marina", "kap") # "seemaennischer Punkt" (1.5) DUBLETTE_M = 500.0 # gleicher Name, Abstand KLEINER 500 m: ein Punkt bleibt WASSER_D_SM = 2.0 # Ortschaft bleibt nur mit seemaennischem Punkt <= 2.0 sm NAME_MAX_BYTE = 255 TYPWORT_ANFANG = { "leuchtturm": ("svjetionik", "svetilnik", "leuchtturm", "leuchtfeuer", "seefeuer", "lighthouse", "phare", "faro", "farol", "fyr", "fyrtårn", "fyrtorn", "vuurtoren"), "insel": ("otok", "otočić", "otocic", "hrid", "školj", "školjić", "insel", "isola", "île", "ile", "isla", "ilha", "island", "isle", "eiland"), } TYPWORT_ENDE = { "leuchtturm": ("lighthouse", "fyr", "fyrtårn", "fyrtorn", "vuurtoren"), "insel": ("island", "islet", "ø", "eiland"), } TYPWORT_NUR_ERKENNEN = ("leuchtturm", "insel", "otok", "otočić", "otocic", "hrid", "hridi", "školj", "školjić", "škoj", "škojić", "island", "isle", "islet", "isola", "île", "ile", "isla", "ilha", "eiland", "ø", "holm", "latarnia", "majakka", "fener", "lighthouse", "svjetionik", "phare", "faro", "farol", "fyr", "fyrtårn", "fyrtorn", "vuurtoren", "mole", "pier", "bridge", "brücke", "schleuse", "schleusen", "havn", "hafen", "luka", "lučica", "port", "harbour", "mast") # --- R0.1 (Nachtrag im Regelblatt, 14.10.2026): Typwoerter weiterer Sprachen, gemessen an der Weltdatei R0 --- # Anfang/Ende: wird an dieser Stelle abgestreift, Merkmal an (wie R0). Nur fuer Sprachen, in denen das # Typwort vor bzw. nach einem Eigennamen steht und kein Artikel ausserhalb der Bindewoerter folgt. # Nur erkennen: Name bleibt ganz, Merkmal aus (wie R0 "Isle of Wight"), fuer alle uebrigen. # Woerter in Kleinschreibung nach casefold und NFC ("φάροσ" ist casefold von "Φάρος"). R01_ANFANG = { "leuchtturm": ("mercusuar", "маяк", "фар", "φάροσ", "منارة", "منار", "farolete"), "insel": ("pulau", "остров", "о.", "островок", "острів", "востраў", "ostrov", "ostrvo", "islote", "cayo", "motu", "wyspa", "جزيرة", "ilhéu", "nosy", "nusa", "meos", "hòn", "gili", "đảo", "ko"), } R01_ENDE = { "leuchtturm": ("švyturys", "등대", "灯塔", "燈塔"), "insel": ("adası",), } R01_NUR_ERKENNEN = ("mercusuar", "маяк", "фар", "φάροσ", "منارة", "منار", "farolete", "švyturys", "등대", "灯塔", "燈塔", "light", "lights", "lt", "beacon", "tower", "menara", "api", "suar", "đăng", "feneri", "tuletorn", "tulepaak", "bāka", "lykt", "nisis", "dao", "chau", "farul", "far", "taigh-solais", "pulau", "остров", "о.", "островок", "острів", "востраў", "ostrov", "ostrvo", "islote", "cayo", "motu", "wyspa", "جزيرة", "ilhéu", "nosy", "nusa", "meos", "hòn", "gili", "đảo", "ko", "adası", "cay", "caye", "key", "îlot", "ilot", "îlet", "ilet", "illa", "eilean", "oileán", "νησίδα", "νησί", "νήσοσ", "sziget", "ada", "sala", "ö", "øya", "holmen", "skär", "skäret", "kari", "saari", "luoto", "ören", "луда", "sgeir", "skerry", "scoglio", "shima", "jima", "qeqertaa") TYPWORT_ANFANG = {k: v + R01_ANFANG[k] for k, v in TYPWORT_ANFANG.items()} TYPWORT_ENDE = {k: v + R01_ENDE[k] for k, v in TYPWORT_ENDE.items()} TYPWORT_NUR_ERKENNEN = TYPWORT_NUR_ERKENNEN + R01_NUR_ERKENNEN WORTENDE_NUR_ERKENNEN = ("feuer", "leuchte", "turm", "mole") BINDEWOERTER = ("of", "the", "de", "du", "des", "del", "della", "dello", "delle", "dei", "degli", "di", "da", "do", "dos", "das", "la", "le", "les", "lo", "el", "los", "las", "van", "von", "vom", "zu", "zum", "zur", "der", "die", "dem", "den", "na", "u", "od") ELISIONEN = ("d'", "l'", "d’", "l’") MEHRZAHL_WORT = ("otoci", "otočići", "školji", "škoji", "hridi", "grebeni", "islands", "isles", "islets", "inseln", "øer", "øerne", "öar", "öarna", "holme", "holmene", "isole", "îles", "iles", "islas", "ilhas", "eilanden", "scogli") MEHRZAHL_ENDUNG = ("öarna", "øerne", "inseln") # --- R0.2 (Nachtrag im Regelblatt, 14.10.2026): fehlende Bindewoerter und drei Mehrzahlwoerter --- # Bindewoerter: Woerter, die in der Weltdatei R0.1 nach einem abgestreiften Typwort am Namensanfang # standen ("Île aux Papillons" -> "aux Papillons") oder vor einem abgestreiften Typwort am Ende; dazu # "an" (Gaelisch, Deutsch, Englisch). Nicht aufgenommen: "a", "o", "y", "sa" (dort meist Buchstabe # oder Namensteil, R0.1 gab schon den besseren Namen). In casefold und NFC ("τησ" ist casefold von "της"). R02_BINDEWOERTER = ("à", "au", "aux", "en", "am", "im", "in", "an", "τησ", "του", "των", "al", "ar", "and") R02_MEHRZAHL_WORT = ("острова", "örarna", "qeqertaat") BINDEWOERTER = BINDEWOERTER + R02_BINDEWOERTER MEHRZAHL_WORT = MEHRZAHL_WORT + R02_MEHRZAHL_WORT KLAMMER_WEG = ("bodensee", "obersee", "untersee", "überlinger see", "zürichsee", "walensee", "vierwaldstättersee", "thunersee", "brienzersee", "bielersee", "neuenburgersee", "genfersee", "lac léman", "lac de neuchâtel", "lago maggiore", "lago di lugano", "luganersee", "gardasee", "lago di garda", "comer see", "lago di como", "chiemsee", "ammersee", "starnberger see", "tegernsee", "müritz", "attersee", "traunsee", "wolfgangsee", "mondsee", "neusiedler see", "ostsee", "nordsee", "mittelmeer", "adria", "recepcija", "reception", "rezeption", "réception") VEREIN_TEIL = ("club", "klub", "verein", "vereniging", "gemeinschaft", "sportgruppe", "circolo", "cercle", "sällskap", "förening", "forening", "selskab") GRUPPENWORT_TEIL = ("segel", "segler", "sejl", "segl", "zeil", "jedrili", "sail", "voile", "motorboot", "motorbåd", "motorbåt", "motoryacht") NAME_VERBOTENE_ZEICHEN = ("+", ";") GATTUNG_ALLEIN = { "kap": ("rat", "ratac", "rt", "rtić", "punta", "glava", "nos", "cape", "kap", "capo", "cabo", "pointe", "point", "huk", "hoved", "odde", "nakke", "udde"), "marina": ("marina", "hafen", "yachthafen", "jachthafen", "sportboothafen", "bootshafen", "fischerhafen", "fischereihafen", "gemeindehafen", "stadthafen", "privathafen", "gondelhafen", "schutzhafen", "anlegestelle", "bojenfeld", "steg", "luka", "lučica", "porat", "mandrač", "port", "porto", "harbour", "havn", "hamn", "haven"), } GATTUNG_ALLEIN["hafen"] = GATTUNG_ALLEIN["marina"] SPERRLISTE = ("node/7427678821",) # "S Glöggli", Romanshorn: oertlicher Name einer Glocke, kein Kartenname TYPRANG = {"node": 0, "way": 1, "relation": 2} # Gruende im Lauf-Log (K-D0-1) und ihr Name im Protokoll von referenz.py / den Namensvektoren. # "nur Gattungswort" und "ohne Namen" sind in R0 ein Grund ("ohne Namen"); das Lauf-Log trennt sie. GRUENDE = ("Sperrliste", "keine Lage", "ohne Namen", "verbotene Zeichen", "nur Gattungswort", "Inselgruppe", "Verein", "Wort nur einer Gruppe", "Name zu lang", "Dublette", "Leuchtturm-Häufung", "fern vom Wasser") GRUND_R0 = {"keine Klasse": "keine Klasse", "Sperrliste": "Sperrliste", "keine Lage": "keine Lage", "ohne Namen": "ohne Namen", "nur Gattungswort": "ohne Namen", "verbotene Zeichen": "Name mit + oder ;", "Inselgruppe": "Inselgruppe", "Verein": "Verein", "Wort nur einer Gruppe": "Gruppenwort", "Name zu lang": "Name zu lang", "Dublette": "Dublette", "Leuchtturm-Häufung": "Leuchtturm-Haeufung", "fern vom Wasser": "fern vom Wasser"} # --------------------------------------------------------------------------------------------- # Geometrie - dieselbe Kugelformel wie die App (WaypointNavigator.distanceNM, 15e42d31) # --------------------------------------------------------------------------------------------- def distanz_sm(lat1, lon1, lat2, lon2): """Haversine, R = 3440.065 sm; Rechenschritte in derselben Reihenfolge wie referenz.py.""" R = R_SM phi1 = lat1 * math.pi / 180 phi2 = lat2 * math.pi / 180 dlat = (lat2 - lat1) * math.pi / 180 dlon = (lon2 - lon1) * math.pi / 180 a = math.sin(dlat / 2) * math.sin(dlat / 2) + math.cos(phi1) * math.cos(phi2) * math.sin(dlon / 2) * math.sin(dlon / 2) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) class Gitter(object): """Raeumlicher Vorfilter fuer "Abstand unter X": Zellen auf der Einheitskugel (x, y, z). Liegt die Kugeldistanz unter X, liegt auch die Sehne darunter und damit jede Koordinate des Einheitsvektors um weniger als die Zellkante auseinander; die Punkte liegen dann in Nachbarzellen (27 je Abfrage). Ohne Laengengrad im Schluessel gibt es keine Sonderfaelle an der Datumsgrenze und an den Polen. Entschieden wird jeder Treffer mit distanz_sm wie in referenz.py; das Gitter waehlt nur aus, wen es fragt. 5 % Rand gegen Rundung. """ def __init__(self, abstand_m): self.kante = abstand_m / METER_JE_SM / R_SM * 1.05 self.zellen = {} def zelle(self, lat, lon): p = math.radians(lat) l = math.radians(lon) c = math.cos(p) k = self.kante return (int(math.floor(c * math.cos(l) / k)), int(math.floor(c * math.sin(l) / k)), int(math.floor(math.sin(p) / k))) def dazu(self, schluessel, zelle, punkt): self.zellen.setdefault((schluessel, zelle[0], zelle[1], zelle[2]), []).append(punkt) def nachbarn(self, schluessel, zelle): cx, cy, cz = zelle get = self.zellen.get for dx in (-1, 0, 1): for dy in (-1, 0, 1): for dz in (-1, 0, 1): liste = get((schluessel, cx + dx, cy + dy, cz + dz)) if liste: for q in liste: yield q # --------------------------------------------------------------------------------------------- # Je Objekt: Klasse, Name, Einzelfilter (Regelblatt 1.1, 1.4) # --------------------------------------------------------------------------------------------- def klasse(tags, osm_typ): """Pruefreihenfolge: Leuchtturm, Marina, Hafen, Kap, Stadt, Dorf, Insel (Regelblatt 1.1).""" st = tags.get("seamark:type", "") if tags.get("man_made") == "lighthouse" or st in ("lighthouse", "light_major"): return "leuchtturm" if tags.get("leisure") == "marina": return "marina" if ("harbour" in tags and tags["harbour"] != "no") or st == "harbour": return "hafen" if tags.get("natural") == "cape": return "kap" p = tags.get("place") if osm_typ == "node" and p in ("city", "town"): return "stadt" if osm_typ == "node" and p == "village": return "dorf" if p == "islet": return "insel" return None def rohname(tags): """`name`; nur wenn `name` fehlt oder leer ist, `seamark:name`. NFC, aussen getrimmt.""" for schluessel in ("name", "seamark:name"): n = tags.get(schluessel) if n is not None: n = unicodedata.normalize("NFC", n).strip() if n: return n, schluessel return None, None def _ist_bindewort(wort): w = wort.casefold() return w in BINDEWOERTER or any(w.startswith(e) for e in ELISIONEN) def _erkennt_typ(woerter): for w in woerter: wc = w.casefold() teile = [wc] + wc.split("-") if any(t in TYPWORT_NUR_ERKENNEN for t in teile): return True if any(wc.endswith(e) for e in WORTENDE_NUR_ERKENNEN): return True return False def klammer_weg(name): """'Lindau (Bodensee)' -> 'Lindau'; ein Zusatz mit Ort bleibt.""" if name.endswith(")") and "(" in name: i = name.rfind("(") innen = name[i + 1:-1].strip().casefold() aussen = name[:i].rstrip() if aussen and innen in KLAMMER_WEG: return aussen return name def namensregel(kl, name): """(anzeigename, gattungswort). Nur Leuchtturm und Insel koennen das Merkmal tragen (1.4, Ablauf).""" if kl not in ("leuchtturm", "insel"): return name, False woerter = name.split(" ") if len(woerter) >= 2: erstes, rest = woerter[0], woerter[1:] if erstes.casefold() in TYPWORT_ANFANG[kl] and not _ist_bindewort(rest[0]): return " ".join(rest), True letztes, rest = woerter[-1], woerter[:-1] if letztes.casefold() in TYPWORT_ENDE[kl] and not _ist_bindewort(rest[-1]): return " ".join(rest), True if _erkennt_typ(woerter): return name, False return name, True def _ist_gruppe(name): for w in name.split(" "): wc = w.casefold() if wc in MEHRZAHL_WORT or any(wc.endswith(e) for e in MEHRZAHL_ENDUNG): return True return False def _verein_oder_gruppenwort(name): nc = name.casefold() if any(t in nc for t in VEREIN_TEIL): return "Verein" if any(t in nc for t in GRUPPENWORT_TEIL): return "Wort nur einer Gruppe" return None def _feuerhoehe(tags): """seamark:light:height als Zahl (fuehrende Ziffern, Punkt als Trenner); fehlt oder unlesbar = 0.""" v = (tags.get("seamark:light:height") or "").strip() zahl = "" for ch in v: if ch.isdigit() or (ch == "." and "." not in zahl): zahl += ch else: break try: return float(zahl) if zahl else 0.0 except ValueError: return 0.0 class Punkt(object): __slots__ = ("klasse", "name", "gattungswort", "lat", "lon", "typ", "oid", "rohname", "quelle", "feuerhoehe", "seezeichen", "seamark_name") def osm(self): return "%s/%d" % (self.typ, self.oid) def als_dict(self): """Feldnamen und Reihenfolge wie punkte-r0.json.""" return {"klasse": self.klasse, "name": self.name, "gattungswort": self.gattungswort, "lat": self.lat, "lon": self.lon, "osm": self.osm(), "rohname": self.rohname, "quelle": self.quelle, "feuerhoehe": self.feuerhoehe, "seezeichen": self.seezeichen, "seamark_name": self.seamark_name} def pruefe_objekt(typ, oid, tags, lage): """Ein OSM-Objekt -> (Punkt, None) oder (None, Grund). lage = (lat, lon) oder None. Reihenfolge der Pruefungen wie referenz.punkt_aus_osm: Klasse, Sperrliste, Lage, Name.""" kl = klasse(tags, typ) if kl is None: return None, "keine Klasse" if "%s/%d" % (typ, oid) in SPERRLISTE: return None, "Sperrliste" if lage is None: return None, "keine Lage" n, quelle = rohname(tags) if n is None: return None, "ohne Namen" if any(z in n for z in NAME_VERBOTENE_ZEICHEN): return None, "verbotene Zeichen" n = klammer_weg(n) anzeige, gw = namensregel(kl, n) allein = (TYPWORT_ANFANG[kl] + TYPWORT_ENDE[kl]) if kl in TYPWORT_ANFANG else GATTUNG_ALLEIN.get(kl, ()) if not anzeige.strip(): return None, "ohne Namen" if anzeige.casefold() in allein: return None, "nur Gattungswort" if kl == "insel" and _ist_gruppe(anzeige): return None, "Inselgruppe" if kl in ("marina", "hafen"): g = _verein_oder_gruppenwort(anzeige) if g: return None, g if len(anzeige.encode("utf-8")) > NAME_MAX_BYTE: return None, "Name zu lang" p = Punkt() p.klasse, p.name, p.gattungswort = kl, anzeige, gw p.lat, p.lon = lage p.typ, p.oid = typ, oid p.rohname, p.quelle = n, quelle p.feuerhoehe = _feuerhoehe(tags) p.seezeichen = tags.get("seamark:type") in ("lighthouse", "light_major") p.seamark_name = bool(tags.get("seamark:name")) return p, None # --------------------------------------------------------------------------------------------- # Menge: Dubletten, Leuchtturm-Haeufung, Filter am Wasser (Regelblatt 1.5) # --------------------------------------------------------------------------------------------- def neues_log(): return {"objekte_gelesen": 0, "doppelt_im_eingang": 0, "ohne_klasse": 0, "klassen": {k: {"gelesen": 0, "weg": {g: 0 for g in GRUENDE}, "geschrieben": 0} for k in KLASSEN}} def erzeuge(objekte, log=None): """objekte: Folge von (typ, oid, tags, lage). Rueckgabe (punkte, log); punkte in der Reihenfolge von referenz.erzeuge (Klasse, Name vor seamark:name, OSM-Typ, OSM-Nummer).""" if log is None: log = neues_log() kl_log = log["klassen"] gesehen = set() roh = [] for typ, oid, tags, lage in objekte: schluessel = (typ, oid) if schluessel in gesehen: log["doppelt_im_eingang"] += 1 continue gesehen.add(schluessel) log["objekte_gelesen"] += 1 p, grund = pruefe_objekt(typ, oid, tags, lage) if grund == "keine Klasse": log["ohne_klasse"] += 1 continue kl = p.klasse if p is not None else klasse(tags, typ) kl_log[kl]["gelesen"] += 1 if p is None: kl_log[kl]["weg"][grund] += 1 continue roh.append(p) gesehen = None # Dubletten: gleicher gezeigter Name (casefold) und Abstand unter 500 m. Vorfahrt: Klasse, # dann `name` vor `seamark:name`, dann OSM-Typ (Knoten, Weg, Relation), dann OSM-Nummer. roh.sort(key=lambda p: (RANG[p.klasse], 0 if p.quelle == "name" else 1, TYPRANG[p.typ], p.oid)) gitter = Gitter(DUBLETTE_M) behalten = [] for p in roh: nk = p.name.casefold() z = gitter.zelle(p.lat, p.lon) dup = False for q in gitter.nachbarn(nk, z): if distanz_sm(q.lat, q.lon, p.lat, p.lon) * METER_JE_SM < DUBLETTE_M: dup = True break if dup: kl_log[p.klasse]["weg"]["Dublette"] += 1 continue gitter.dazu(nk, z, p) behalten.append(p) roh = None # Leuchtturm-Haeufung: Leuchttuerme mit Abstand unter 500 m, auch mit verschiedenen Namen; es # bleibt einer. Vorfahrt: groesste Feuerhoehe, `name` vor `seamark:name`, Seezeichen # (lighthouse/light_major), mit seamark:name, OSM-Typ, OSM-Nummer. lt = [p for p in behalten if p.klasse == "leuchtturm"] lt.sort(key=lambda p: (-p.feuerhoehe, 0 if p.quelle == "name" else 1, 0 if p.seezeichen else 1, 0 if p.seamark_name else 1, TYPRANG[p.typ], p.oid)) gitter = Gitter(DUBLETTE_M) weg_lt = set() for p in lt: z = gitter.zelle(p.lat, p.lon) if any(distanz_sm(q.lat, q.lon, p.lat, p.lon) * METER_JE_SM < DUBLETTE_M for q in gitter.nachbarn(None, z)): kl_log["leuchtturm"]["weg"]["Leuchtturm-Häufung"] += 1 weg_lt.add(id(p)) continue gitter.dazu(None, z, p) behalten = [p for p in behalten if id(p) not in weg_lt] # Filter am Wasser: Stadt und Dorf nur mit Leuchtturm, Insel, Hafen, Marina oder Kap aus der # bereinigten Menge hoechstens 2.0 sm entfernt. gitter = Gitter(WASSER_D_SM * METER_JE_SM) for q in behalten: if q.klasse in SEE_KLASSEN: gitter.dazu(None, gitter.zelle(q.lat, q.lon), q) end = [] for p in behalten: if p.klasse in ("stadt", "dorf"): z = gitter.zelle(p.lat, p.lon) if not any(distanz_sm(q.lat, q.lon, p.lat, p.lon) <= WASSER_D_SM for q in gitter.nachbarn(None, z)): kl_log[p.klasse]["weg"]["fern vom Wasser"] += 1 continue end.append(p) for p in end: kl_log[p.klasse]["geschrieben"] += 1 return end, log def geht_auf(log): """K-D0-1: je Klasse gelesen = ausgeschieden + geschrieben. Rueckgabe {klasse: bool}.""" return {k: v["gelesen"] == sum(v["weg"].values()) + v["geschrieben"] for k, v in log["klassen"].items()} def protokoll_r0(log): """Dasselbe Protokoll wie referenz.erzeuge: {'weg: ': n, 'geschrieben: ': n}, nur Werte > 0.""" out = {} for k, v in log["klassen"].items(): for g, n in v["weg"].items(): if n: schl = "weg: " + GRUND_R0[g] out[schl] = out.get(schl, 0) + n if v["geschrieben"]: out["geschrieben: " + k] = v["geschrieben"] return out def auffaellige_muster(punkte, anzahl=20): """Nur Protokoll, aendert keinen Punkt ("Auffaellige Namen, die keine Regel faengt"): die haeufigsten Woerter der geschriebenen Namen (casefold, Ziffern als 9, ohne Bindewoerter, ab drei Zeichen) mit Zahl und drei Beispielen, dazu Zeichenmuster mit Zahl und drei Beispielen.""" woerter = {} for p in punkte: for w in set(p.name.casefold().split(" ")): w = "".join("9" if ch.isdigit() else ch for ch in w) if len(w) < 3 or w in BINDEWOERTER: continue e = woerter.setdefault(w, [0, []]) e[0] += 1 if len(e[1]) < 3: e[1].append("%s (%s, %s)" % (p.name, p.klasse, p.osm())) top = sorted(woerter.items(), key=lambda kv: (-kv[1][0], kv[0]))[:anzahl] pruef = (("Ziffer", lambda n: any(ch.isdigit() for ch in n)), ("Schraegstrich", lambda n: "/" in n), ("Klammer", lambda n: "(" in n or ")" in n), ("Anfuehrungszeichen", lambda n: any(ch in n for ch in "\"„“”«»")), ("nur Grossbuchstaben", lambda n: len(n) > 3 and n.isupper()), ("mehr als sechs Woerter", lambda n: len(n.split(" ")) > 6), ("Steuer- oder Formatzeichen", lambda n: any(unicodedata.category(ch) in ("Cc", "Cf") for ch in n))) zeichen = {} for name, f in pruef: treffer = [p for p in punkte if f(p.name)] zeichen[name] = {"anzahl": len(treffer), "beispiele": ["%s (%s, %s)" % (p.name, p.klasse, p.osm()) for p in treffer[:3]]} return {"woerter": [{"wort": w, "anzahl": e[0], "beispiele": e[1]} for w, e in top], "zeichen": zeichen} # --------------------------------------------------------------------------------------------- # Eingaenge # --------------------------------------------------------------------------------------------- def objekt_aus_overpass(e): """Ein Overpass-Element -> (typ, oid, tags, lage). Lage: lat/lon, sonst center, sonst None.""" if "lat" in e: lage = (e["lat"], e["lon"]) elif "center" in e: lage = (e["center"]["lat"], e["center"]["lon"]) else: lage = None return e["type"], e["id"], e.get("tags", {}), lage def lies_overpass(pfade, merk): """Overpass-JSON in der Reihenfolge der Dateien (bei gleicher OSM-Kennung gilt die erste).""" for pfad in pfade: d = json.load(open(pfad, encoding="utf-8")) merk["eingaenge"].append({"datei": os.path.basename(pfad), "byte": os.path.getsize(pfad), "sha256": sha256_datei(pfad), "osm_stand": (d.get("osm3s") or {}).get("timestamp_osm_base")}) for e in d["elements"]: if "geometry" in e: # Kueste und Seeflaeche gehoeren nicht zu den Punkten merk["uebersprungen_geometrie"] += 1 continue yield objekt_aus_overpass(e) def osm_kennung(fid): """osmium `-u type_id`: n, w, r; Flaeche a<2*Weg> oder a<2*Relation+1>.""" t, n = fid[0], int(fid[1:]) if t == "n": return "node", n if t == "w": return "way", n if t == "r": return "relation", n if t == "a": return ("way", n // 2) if n % 2 == 0 else ("relation", (n - 1) // 2) raise ValueError("unbekannte Kennung %r" % fid) def mitte(geometrie): """Knoten: seine Koordinate. Sonst Mitte des umschliessenden Rechtecks aller Koordinaten (Overpass `out center`), gerundet auf 1e-7 Grad. Datumsgrenze (Entscheid Oscar 14.10.2026, D0 Teil 2): Spannt das Rechteck mehr als 180 Grad Laenge, wird es auf den verschobenen Laengen (0 bis 360, negative Laenge + 360) gerechnet und die Mitte zurueck auf -180 bis unter 180 normiert. Sonst wuerde ein Objekt mit Knoten auf +179.9 und -179.9 Grad seine Mitte nahe 0 Grad bekommen, auf der anderen Seite der Erde. Rueckgabe (lage, breite, breite_verschoben): lage = (lat, lon) oder None; breite = Breite des Rechtecks in Grad Laenge; breite_verschoben = Breite auf den verschobenen Laengen, nur wenn umgerechnet wurde, sonst None.""" if not geometrie: return None, 0.0, None c = geometrie.get("coordinates") if geometrie.get("type") == "Point": return (c[1], c[0]), 0.0, None lo_min = la_min = float("inf") lo_max = la_max = float("-inf") laengen = [] stapel = [c] while stapel: x = stapel.pop() if not x: continue if isinstance(x[0], (int, float)): lo, la = x[0], x[1] laengen.append(lo) if lo < lo_min: lo_min = lo if lo > lo_max: lo_max = lo if la < la_min: la_min = la if la > la_max: la_max = la else: stapel.extend(x) if lo_min == float("inf"): return None, 0.0, None breite = lo_max - lo_min lat = round((la_min + la_max) / 2, 7) if breite <= 180.0: return (lat, round((lo_min + lo_max) / 2, 7)), breite, None v_min = float("inf") v_max = float("-inf") for lo in laengen: v = lo + 360.0 if lo < 0 else lo if v < v_min: v_min = v if v > v_max: v_max = v m = (v_min + v_max) / 2 if m >= 180.0: m -= 360.0 return (lat, round(m, 7)), breite, v_max - v_min def lies_geojsonseq(pfad, merk): """Ausgabe von `osmium export -f geojsonseq -u type_id`, Zeile fuer Zeile (wenig Speicher).""" merk["eingaenge"].append({"datei": os.path.basename(pfad), "byte": os.path.getsize(pfad), "sha256": sha256_datei(pfad), "osm_stand": merk.get("osm_stand")}) with open(pfad, "rb") as f: for roh in f: roh = roh.strip(b"\x1e \t\r\n") if not roh: continue merk["zeilen"] += 1 ft = json.loads(roh.decode("utf-8")) fid = ft.get("id") if not isinstance(fid, str) or len(fid) < 2: merk["ohne_kennung"] += 1 continue typ, oid = osm_kennung(fid) lage, breite, breite_v = mitte(ft.get("geometry")) if breite_v is not None and (typ, oid) not in merk["datumsgrenze_gesehen"]: merk["datumsgrenze_gesehen"].add((typ, oid)) # Linie und Flaeche desselben Wegs: einmal # Ueber die Datumsgrenze gerechnet (siehe mitte); jeder Fall steht im Lauf-Log. merk["breit_ueber_180"].append({"osm": "%s/%d" % (typ, oid), "breite_grad": breite, "breite_verschoben_grad": breite_v, "lat": lage[0], "lon": lage[1], "name": (ft.get("properties") or {}).get("name")}) yield typ, oid, ft.get("properties") or {}, lage # --------------------------------------------------------------------------------------------- # Ausgabe # --------------------------------------------------------------------------------------------- def sha256_datei(pfad): h = hashlib.sha256() with open(pfad, "rb") as f: while True: b = f.read(1 << 20) if not b: break h.update(b) return h.hexdigest() def schreibe_punkte(pfad, punkte): with open(pfad, "w", encoding="utf-8", newline="\n") as f: json.dump({"lizenz": LIZENZ, "regel": REGEL, "punkte": [p.als_dict() for p in punkte]}, f, ensure_ascii=False, indent=0) def drucke_log(log): kurz = {"Sperrliste": "Sperr", "keine Lage": "Lage", "ohne Namen": "o.Name", "verbotene Zeichen": "+;", "nur Gattungswort": "Gattung", "Inselgruppe": "Gruppe", "Verein": "Verein", "Wort nur einer Gruppe": "1Gruppe", "Name zu lang": "lang", "Dublette": "Dubl", "Leuchtturm-Häufung": "Haeuf", "fern vom Wasser": "fern"} kopf = "%-11s %8s " % ("Klasse", "gelesen") + " ".join("%7s" % kurz[g] for g in GRUENDE) + " %8s %s" % ("geschr.", "geht auf") print(kopf) auf = geht_auf(log) summe = {"gelesen": 0, "geschrieben": 0} summe_weg = {g: 0 for g in GRUENDE} for k in KLASSEN: v = log["klassen"][k] summe["gelesen"] += v["gelesen"] summe["geschrieben"] += v["geschrieben"] for g in GRUENDE: summe_weg[g] += v["weg"][g] print("%-11s %8d " % (k, v["gelesen"]) + " ".join("%7d" % v["weg"][g] for g in GRUENDE) + " %8d %s" % (v["geschrieben"], "ja" if auf[k] else "NEIN")) print("%-11s %8d " % ("Summe", summe["gelesen"]) + " ".join("%7d" % summe_weg[g] for g in GRUENDE) + " %8d %s" % (summe["geschrieben"], "ja" if all(auf.values()) else "NEIN")) print("Objekte gelesen %d (davon ohne Klasse %d), doppelt im Eingang %d" % (log["objekte_gelesen"], log["ohne_klasse"], log["doppelt_im_eingang"])) def main(argv): ap = argparse.ArgumentParser(description="Ortsbeschrieb: Punktliste nach Regel R0") ap.add_argument("eingang", choices=("geojsonseq", "overpass")) ap.add_argument("punkte") ap.add_argument("lauflog") ap.add_argument("dateien", nargs="+") ap.add_argument("--osm-stand", default=None, help="OSM-Stand des Planeten (osmium fileinfo), ISO") a = ap.parse_args(argv[1:]) t0 = time.time() merk = {"eingaenge": [], "uebersprungen_geometrie": 0, "zeilen": 0, "ohne_kennung": 0, "breit_ueber_180": [], "datumsgrenze_gesehen": set(), "osm_stand": a.osm_stand} if a.eingang == "overpass": objekte = lies_overpass(a.dateien, merk) else: if len(a.dateien) != 1: ap.error("geojsonseq: genau eine Datei") objekte = lies_geojsonseq(a.dateien[0], merk) punkte, log = erzeuge(objekte) schreibe_punkte(a.punkte, punkte) auf = geht_auf(log) rezept = os.path.abspath(__file__) osm_staende = sorted(set(e["osm_stand"] for e in merk["eingaenge"] if e["osm_stand"])) lauf = { "regel": REGEL, "lizenz": LIZENZ, "osm_stand": osm_staende, "eingang": a.eingang, "eingaenge": merk["eingaenge"], "rezept": {"datei": os.path.basename(rezept), "sha256": sha256_datei(rezept)}, "python": platform.python_version(), "unicode": unicodedata.unidata_version, "rechner": platform.node(), "system": platform.platform(), "zeilen_geojsonseq": merk["zeilen"], "ohne_kennung": merk["ohne_kennung"], "uebersprungen_geometrie_overpass": merk["uebersprungen_geometrie"], "breit_ueber_180": merk["breit_ueber_180"], "objekte_gelesen": log["objekte_gelesen"], "ohne_klasse": log["ohne_klasse"], "doppelt_im_eingang": log["doppelt_im_eingang"], "klassen": log["klassen"], "geht_auf": auf, "protokoll_r0": protokoll_r0(log), "muster": auffaellige_muster(punkte), "punkte": {"datei": os.path.basename(a.punkte), "anzahl": len(punkte), "byte": os.path.getsize(a.punkte), "sha256": sha256_datei(a.punkte)}, "sekunden": round(time.time() - t0, 1), } with open(a.lauflog, "w", encoding="utf-8", newline="\n") as f: json.dump(lauf, f, ensure_ascii=False, indent=1) print("Ortsbeschrieb bauen.py · Regel %s · Python %s · Unicode %s · Rezept SHA-256 %s" % (REGEL, lauf["python"], lauf["unicode"], lauf["rezept"]["sha256"])) for e in merk["eingaenge"]: print("Eingang %-24s %11d Byte OSM-Stand %s SHA-256 %s" % (e["datei"], e["byte"], e["osm_stand"], e["sha256"])) if a.eingang == "overpass": print("Overpass-Elemente mit Geometrie (Kueste, See) uebersprungen: %d" % merk["uebersprungen_geometrie"]) else: print("Zeilen %d, ohne Kennung %d, Rechteck breiter als 180 Grad (ueber die Datumsgrenze gerechnet): %d" % (merk["zeilen"], merk["ohne_kennung"], len(merk["breit_ueber_180"]))) for x in merk["breit_ueber_180"][:20]: print(" Datumsgrenze: %s breit %.4f, verschoben %.4f Grad, Mitte %.7f %.7f, Name %r" % (x["osm"], x["breite_grad"], x["breite_verschoben_grad"], x["lat"], x["lon"], x["name"])) drucke_log(log) m = lauf["muster"] print("Haeufigste Woerter (nur Protokoll): " + ", ".join("%s %d" % (x["wort"], x["anzahl"]) for x in m["woerter"])) print("Zeichenmuster (nur Protokoll): " + ", ".join("%s %d" % (k, v["anzahl"]) for k, v in m["zeichen"].items())) print("Punkte geschrieben %d -> %s (%d Byte, SHA-256 %s) · %.1f s" % (len(punkte), a.punkte, lauf["punkte"]["byte"], lauf["punkte"]["sha256"], lauf["sekunden"])) if not all(auf.values()): print("FEHLER: Zahlen gehen nicht auf:", [k for k, v in auf.items() if not v]) return 3 if merk["ohne_kennung"]: print("FEHLER: %d Zeilen ohne osmium-Kennung (Export mit -u type_id?), diese Objekte fehlen" % merk["ohne_kennung"]) return 4 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))