#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ NOB1 — Schreiber und Leser der Ortsbeschrieb-Datei (Format, sonst nichts). Klaas, 14.10.2026, Paket A (NP-ATP). Das Byte-Layout steht vollstaendig im Bericht `Projekte/Nautly/Plan & Status/2026-10-14 Ortsbeschrieb Paket A — Bericht (Klaas).md`, Abschnitt "Format NOB1", und als Kurzfassung hier unten. Der Swift-Leser (`Nautly/Services/OrtsbeschriebDatei.swift`) liest genau dieses Layout; Android liest es spaeter ebenso. Geralts Erzeuger (D0) schreibt die Weltdatei ausschliesslich ueber `schreibe()`. Was hier NICHT steht: Klasse, Name, Typwort, Dubletten, Filter am Wasser. Das entscheidet der Erzeuger (Regel R0) VOR dem Schreiben; dieser Schreiber prueft nur, dass die Eingabe sich schreiben laesst, und verweigert alles andere laut (ValueError), statt still zu reparieren. Format, Fassung 1 (alle Ganzzahlen little-endian, ohne Vorzeichen, keine Fuellbytes): Kopf, 24 Byte 0 4 Kennung ASCII "NOB1" 4 2 Formatfassung u16 = 1 6 2 reserviert u16 = 0 8 4 OSM-Stand u32, JJJJMMTT als Dezimalzahl (20261014) 12 4 Punktzahl u32 16 4 Kachelzahl u32 20 4 Laenge des Punktteils in Byte u32 Dateilaenge = 24 + 12 * Kachelzahl + Laenge des Punktteils, genau. Kachelindex, ab Byte 24, 12 Byte je Kachel, Schluessel streng aufsteigend 0 4 Schluessel u32 = (Breitenkachel + 90) * 360 + (Laengenkachel + 180), 0..64799 4 4 Versatz u32 des ersten Punkts, gezaehlt ab Beginn des Punktteils 8 4 Anzahl u32, >= 1 Breitenkachel = floor(Breite) in -90..89 (Breite 90 -> 89), Laengenkachel = floor(Laenge) in -180..179 (Laenge 180 -> 179). Die erste Kachel beginnt bei Versatz 0, jede weitere genau am Ende der vorigen, die letzte endet genau am Ende des Punktteils. Punktsatz, 6 + n Byte, innerhalb einer Kachel nach (qBreite, qLaenge, Klassenbyte, Namensbytes) aufsteigend 0 2 qBreite u16 = round((Breite - Breitenkachel) * 65535), 0..65535 2 2 qLaenge u16 = round((Laenge - Laengenkachel) * 65535), 0..65535 4 1 Klassenbyte u8: Bits 0-6 Klassencode 1..7, Bit 7 (0x80) Gattungswort 5 1 Namenlaenge n u8, 1..255 6 n Name, UTF-8, NFC Klassencode = Vorfahrt aus Regelblatt R0 1.1: 1 Leuchtturm, 2 Insel, 3 Hafen, 4 Marina, 5 Kap, 6 Stadt, 7 Dorf. Gattungswort nur bei 1 und 2. round() ist Pythons round: genau x.5 zur geraden Zahl. Rueckrechnung: Kachel + q / 65535 (erst teilen, dann addieren, doppelte Genauigkeit) — auf jeder Plattform bitgleich. Aufrufe (Python 3.9 auf dem Mac, 3.14 am PC; nur Standardbibliothek, laeuft mit `python -I`): python3 -I nob1.py schreibe --osm-stand JJJJMMTT python3 -I nob1.py lese # gibt Kopf und Punkte als JSON aus python3 -I nob1.py base64 # eine Zeile Base64, fuer die Vektoren `punkte.json` ist eine Liste von Punkten oder ein Objekt mit Feld "punkte" (so schreibt Geralts referenz.py `erzeuge`): je Punkt klasse, name, gattungswort, lat, lon. Datenquelle der Punkte: OpenStreetMap, (c) OpenStreetMap-Mitwirkende, ODbL 1.0. """ import base64 import datetime import json import math import struct import sys import unicodedata KENNUNG = b"NOB1" FASSUNG = 1 KOPF_LAENGE = 24 INDEX_LAENGE = 12 SATZ_KOPF = 6 TEILER = 65535 SCHLUESSEL_ANZAHL = 180 * 360 # 64800 moegliche 1-Grad-Kacheln GATTUNGSWORT_BIT = 0x80 KLASSENCODE = {"leuchtturm": 1, "insel": 2, "hafen": 3, "marina": 4, "kap": 5, "stadt": 6, "dorf": 7} KLASSE_AUS_CODE = {v: k for k, v in KLASSENCODE.items()} MIT_GATTUNGSWORT = ("leuchtturm", "insel") _KOPF = struct.Struct("<4sHHIIII") _INDEX = struct.Struct(" oben: # Breite 90 bzw. Laenge 180: obere Kante der letzten Kachel k = oben if k < unten: raise ValueError("Koordinate %r ausserhalb des Bereichs" % (wert,)) q = round((wert - k) * TEILER) # Python round: genau x.5 zur geraden Zahl if not 0 <= q <= TEILER: raise ValueError("Koordinate %r ergibt q=%d" % (wert, q)) return k, q def zurueck(k, q): """Rueckrechnung, wie der Swift-Leser: Kachel + q / 65535.""" return k + q / float(TEILER) def schluessel(breitenkachel, laengenkachel): return (breitenkachel + 90) * 360 + (laengenkachel + 180) def kachel_aus_schluessel(s): return s // 360 - 90, s % 360 - 180 # --------------------------------------------------------------------------------------------- # Schreiben # --------------------------------------------------------------------------------------------- def _pruefe_osm_stand(osm_stand): if not isinstance(osm_stand, int) or isinstance(osm_stand, bool): raise ValueError("OSM-Stand muss eine ganze Zahl JJJJMMTT sein") jahr, rest = divmod(osm_stand, 10000) monat, tag = divmod(rest, 100) if not 2000 <= jahr <= 9999: raise ValueError("OSM-Stand %r ist kein JJJJMMTT" % (osm_stand,)) datetime.date(jahr, monat, tag) # wirft bei 20261332 def _satz(p): kl = p["klasse"] if kl not in KLASSENCODE: raise ValueError("unbekannte Klasse %r" % (kl,)) gw = p["gattungswort"] if not isinstance(gw, bool): raise ValueError("gattungswort muss true oder false sein: %r" % (p,)) if gw and kl not in MIT_GATTUNGSWORT: raise ValueError("Gattungswort nur bei Leuchtturm und Insel: %r" % (p,)) name = p["name"] if not isinstance(name, str) or not name: raise ValueError("Name fehlt: %r" % (p,)) if not unicodedata.is_normalized("NFC", name): raise ValueError("Name nicht in NFC: %r" % (name,)) nb = name.encode("utf-8") if len(nb) > 255: raise ValueError("Name ueber 255 Byte UTF-8: %r" % (name,)) lat, lon = p["lat"], p["lon"] for w in (lat, lon): if isinstance(w, bool) or not isinstance(w, (int, float)) or not math.isfinite(w): raise ValueError("Koordinate ist keine endliche Zahl: %r" % (p,)) if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0): raise ValueError("Koordinate ausserhalb des Bereichs: %r" % (p,)) kb, qb = kachel_und_q(lat, -90, 89) kl_, ql = kachel_und_q(lon, -180, 179) byte = KLASSENCODE[kl] | (GATTUNGSWORT_BIT if gw else 0) return schluessel(kb, kl_), (qb, ql, byte, nb) def schreibe(punkte, osm_stand): """Liste von Punkten (klasse, name, gattungswort, lat, lon) -> Bytes der Datei.""" _pruefe_osm_stand(osm_stand) kacheln = {} for p in punkte: s, satz = _satz(p) kacheln.setdefault(s, []).append(satz) index = [] teil = bytearray() for s in sorted(kacheln): saetze = sorted(kacheln[s]) index.append(_INDEX.pack(s, len(teil), len(saetze))) for qb, ql, byte, nb in saetze: teil += _SATZ.pack(qb, ql, byte, len(nb)) teil += nb if len(teil) > 0xFFFFFFFF: raise ValueError("Punktteil groesser als 4 GiB") punktzahl = sum(len(v) for v in kacheln.values()) kopf = _KOPF.pack(KENNUNG, FASSUNG, 0, osm_stand, punktzahl, len(index), len(teil)) return kopf + b"".join(index) + bytes(teil) # --------------------------------------------------------------------------------------------- # Lesen — dieselben Pruefungen wie der Swift-Leser, damit D0 seine Datei vor dem Einchecken # genau so prueft, wie die App sie liest. # --------------------------------------------------------------------------------------------- def lese_kopf(daten): """Kopf und Index pruefen. Rueckgabe (kopf, index) oder Nob1Fehler.""" if len(daten) < KOPF_LAENGE: raise Nob1Fehler("kuerzer als der Kopf (%d Byte)" % len(daten)) kennung, fassung, reserviert, osm_stand, punktzahl, kachelzahl, teil = _KOPF.unpack_from(daten, 0) if kennung != KENNUNG: raise Nob1Fehler("falsche Kennung %r" % (kennung,)) if fassung != FASSUNG: raise Nob1Fehler("Formatfassung %d, gelesen wird nur %d" % (fassung, FASSUNG)) if reserviert != 0: raise Nob1Fehler("reserviertes Feld ist %d statt 0" % reserviert) if kachelzahl > SCHLUESSEL_ANZAHL: raise Nob1Fehler("Kachelzahl %d ueber %d" % (kachelzahl, SCHLUESSEL_ANZAHL)) soll = KOPF_LAENGE + INDEX_LAENGE * kachelzahl + teil if len(daten) != soll: raise Nob1Fehler("Dateilaenge %d statt %d" % (len(daten), soll)) if punktzahl * (SATZ_KOPF + 1) > teil: raise Nob1Fehler("Punktteil %d Byte zu klein fuer %d Punkte" % (teil, punktzahl)) index = [] summe = 0 for i in range(kachelzahl): s, versatz, anzahl = _INDEX.unpack_from(daten, KOPF_LAENGE + INDEX_LAENGE * i) if s >= SCHLUESSEL_ANZAHL: raise Nob1Fehler("Kachel %d: Schluessel %d ausserhalb" % (i, s)) if anzahl == 0: raise Nob1Fehler("Kachel %d: leer" % i) if i == 0 and versatz != 0: raise Nob1Fehler("erste Kachel beginnt bei %d statt 0" % versatz) if index: vs, vv, va = index[-1] if s <= vs: raise Nob1Fehler("Kachel %d: Schluessel nicht aufsteigend" % i) if versatz < vv + va * (SATZ_KOPF + 1): raise Nob1Fehler("Kachel %d: Versatz %d ueberlappt die vorige" % (i, versatz)) if versatz >= teil: raise Nob1Fehler("Kachel %d: Versatz %d hinter dem Ende (%d)" % (i, versatz, teil)) index.append((s, versatz, anzahl)) summe += anzahl if index: vs, vv, va = index[-1] if teil - vv < va * (SATZ_KOPF + 1): raise Nob1Fehler("letzte Kachel reicht ueber das Ende") if summe != punktzahl: raise Nob1Fehler("Summe der Kacheln %d statt Punktzahl %d" % (summe, punktzahl)) kopf = {"fassung": fassung, "osmStand": osm_stand, "punktzahl": punktzahl, "kachelzahl": kachelzahl, "punktteil": teil} return kopf, index def lese_kachel(daten, index, i): """Alle Punkte der Kachel i. Nob1Fehler, wenn ein Satz nicht passt.""" teil_anfang = KOPF_LAENGE + INDEX_LAENGE * len(index) s, versatz, anzahl = index[i] ende = index[i + 1][1] if i + 1 < len(index) else len(daten) - teil_anfang kb, kl = kachel_aus_schluessel(s) pos = teil_anfang + versatz stop = teil_anfang + ende punkte = [] for _ in range(anzahl): if pos + SATZ_KOPF > stop: raise Nob1Fehler("Kachel %d: Satzkopf reicht ueber das Kachelende" % i) qb, ql, byte, n = _SATZ.unpack_from(daten, pos) code = byte & 0x7F if code not in KLASSE_AUS_CODE: raise Nob1Fehler("Kachel %d: Klassencode %d" % (i, code)) if n == 0: raise Nob1Fehler("Kachel %d: leerer Name" % i) if pos + SATZ_KOPF + n > stop: raise Nob1Fehler("Kachel %d: Name reicht ueber das Kachelende" % i) try: name = daten[pos + SATZ_KOPF:pos + SATZ_KOPF + n].decode("utf-8") except UnicodeDecodeError: raise Nob1Fehler("Kachel %d: Name ist kein UTF-8" % i) punkte.append({"klasse": KLASSE_AUS_CODE[code], "name": name, "gattungswort": bool(byte & GATTUNGSWORT_BIT), "kachel": [kb, kl], "lat": zurueck(kb, qb), "lon": zurueck(kl, ql)}) pos += SATZ_KOPF + n if pos != stop: raise Nob1Fehler("Kachel %d endet bei %d statt %d" % (i, pos - teil_anfang, ende)) return punkte def lese(daten): """Ganze Datei -> {"kopf": ..., "punkte": [...]}; Nob1Fehler bei jedem Schaden.""" kopf, index = lese_kopf(daten) punkte = [] for i in range(len(index)): punkte.extend(lese_kachel(daten, index, i)) return {"kopf": kopf, "punkte": punkte} # --------------------------------------------------------------------------------------------- # Aufruf # --------------------------------------------------------------------------------------------- def _lade_punkte(pfad): with open(pfad, encoding="utf-8") as f: d = json.load(f) return d["punkte"] if isinstance(d, dict) else d def main(argv): if len(argv) == 6 and argv[1] == "schreibe" and argv[4] == "--osm-stand": daten = schreibe(_lade_punkte(argv[2]), int(argv[5])) with open(argv[3], "wb") as f: f.write(daten) kopf, _ = lese_kopf(daten) print("geschrieben %s: %d Byte, %d Punkte, %d Kacheln, OSM-Stand %d" % (argv[3], len(daten), kopf["punktzahl"], kopf["kachelzahl"], kopf["osmStand"])) return 0 if len(argv) == 3 and argv[1] == "lese": with open(argv[2], "rb") as f: print(json.dumps(lese(f.read()), ensure_ascii=False, indent=1)) return 0 if len(argv) == 3 and argv[1] == "base64": with open(argv[2], "rb") as f: print(base64.b64encode(f.read()).decode("ascii")) return 0 print(__doc__) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))