#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ vf_spec_export.py - Eingabe-Journale eines VF-Linienzugs in eine SPEC uebersetzen und zurueck. Eine Spec beschreibt eine Kette in Domaenenwerten ("links", "winkel", "aussen", ja/nein) statt in Menue-Codes und Journal-Tokens: lesbar, schreibbar und damit die Eingabeform fuer den nicht-interaktiven Bau (Lisp/vf_spec.lsp, siehe doc/TODO-plan-vf-interactive.md Stufe 1). Zwei Richtungen, die einander pruefen: spec_aus_journal(tokens) -> spec (Leser) journal_aus_spec(spec) -> tokens (Schreiber) Fuer jede echte Kette muss gelten: journal_aus_spec(spec_aus_journal(tok)) == tok Genau dieser Rundlauf laeuft in tests/test_vf_spec.py gegen die 5 echten HundM-Ketten. Er beweist, dass die Spec das Journal VERLUSTFREI abbildet - sonst waere jeder spec-gebaute Nachbau eine andere Kette als das Original. Der Schreiber ist das Vorbild fuer vfl-spec->journal in Lisp/vf_spec.lsp; beide muessen dieselbe Tokenfolge liefern. Die Grammatik selbst (welche Frage in welcher Reihenfolge) steht EINMAL in lib/vf_linienzug_grammatik.py und wird von hier (Spec) wie von lib/vf_journal_export.py (Protokoll) ueber denselben Parser-Kern benutzt - nur mit unterschiedlicher Sink. Aufruf (erzeugt die Spec-Testdaten aus dem Protokoll der echten Ketten): python lib/vf_spec_export.py tests/testdata/hm_recformat.json \ tests/testdata/hundm05.json """ import argparse import collections import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from vf_journal_export import arrays_einzeilig # noqa: E402 # Die Code-Tabellen, die Grammatik-Auspraegungen und der Parser-Kern stehen # EINMAL im gemeinsamen Modul - hier nur noch importiert, nicht nachgebaut. # Ein Teil davon wird hier selbst nicht mehr benutzt, sondern nur # weitergereicht (vse.MENU_START, vse.GLIEDER, vse.GRAMMATIK_AKTUELL ... in # tests/ und in Aufrufern) - die oeffentliche Flaeche bleibt unveraendert. from vf_linienzug_grammatik import ( # noqa: E402,F401 ENDE2, ENDE3, ENDPUNKT, GEFAELLE, GF_LAENGE_MODUS, GLIEDER, GRAMMATIK_AKTUELL, GRAMMATIK_AUFZEICHNUNG, Grammatik, IM_VF, MENU_START, MENU_START_AKTUELL, MENU_WEITER, RICHTUNG, SEITE, VARIANTE, VERTEILUNG, VF_GLIEDER, Sink, code_aus_ja, code_aus_wert, lies_journal, pruefe_flachen_freitext) PREFIX = "[vf_spec_export]" # ============================================================ # Tokens # ============================================================ # Token = (Art, Wert-String) genau wie im Journal auf der XDATA. Arten: # PT Startpunkt "x,y,z" DL Segmentlaenge # REAL Realzahl INT Ganzzahl # STR Menue-/Wertantwort STEP Glied-Marker (Label = Segmenttyp) def tokens_aus_protokoll(eintraege): """Das Frage-Antwort-Protokoll (Schema tests/testdata/hm_recformat.json) zurueck in Journal-Tokens uebersetzen - die Umkehrung von vf_journal_export.Journal.add.""" tok = [] for e in eintraege: typ = e["typ"] if typ == "point_abs": tok.append(("PT", ",".join(_zahl(v) for v in e["wert"]))) elif typ == "point_rel": tok.append(("DL", _zahl(e["dL"]))) if "hz" in e: tok.append(("REAL", _zahl(e["hz"]))) elif typ == "real": tok.append(("REAL", _zahl(e["wert"]))) elif typ == "int": tok.append(("INT", str(int(e["wert"])))) elif typ == "string": tok.append(("STR", str(e["wert"]))) elif typ == "step": tok.append(("STEP", str(e["wert"]))) else: raise ValueError("unbekannter Eintragstyp: %r" % typ) return tok def _zahl(v): """Zahl so schreiben, wie sie im Journal steht (float ohne Exponent).""" if isinstance(v, bool): raise ValueError("Wahrheitswert als Zahl: %r" % v) f = float(v) if f == int(f) and abs(f) < 1e15: return "%.1f" % f return repr(f) def gleich(a, b): """Zwei Tokenfolgen vergleichen - Zahlen numerisch, nicht als Text (0.0 und -0.0, 3 und 3.0 sind dasselbe Journal).""" if len(a) != len(b): return False for (art1, w1), (art2, w2) in zip(a, b): if art1 != art2: return False if art1 in ("REAL", "DL", "INT"): if abs(float(w1) - float(w2)) > 1e-6: return False elif art1 == "PT": p1 = [float(x) for x in w1.split(",")] p2 = [float(x) for x in w2.split(",")] if len(p1) != len(p2) or any(abs(x - y) > 1e-6 for x, y in zip(p1, p2)): return False elif w1 != w2: return False return True def _tok_gleich(a, b, toleranz=1e-6): """Zwei EINZELNE Tokens vergleichen wie gleich() - Zahlen numerisch. `toleranz` ist Standard 1e-6 (praktisch exakt) fuer den Vergleich gegen volle Praezision. Wer eine GERUNDETE Seite vergleicht (z.B. hundm05.json nach runde_mm_felder, auf 1 Nachkommastelle), muss die Toleranz auf die Rundungsbreite anheben - sonst schlaegt der Vergleich an genau der Rundung fehl, nicht an einer echten Abweichung.""" art1, w1 = a art2, w2 = b if art1 != art2: return False if art1 in ("REAL", "DL", "INT"): return abs(float(w1) - float(w2)) <= toleranz if art1 == "PT": p1 = [float(x) for x in w1.split(",")] p2 = [float(x) for x in w2.split(",")] return len(p1) == len(p2) and all(abs(x - y) <= toleranz for x, y in zip(p1, p2)) return w1 == w2 def ist_teilfolge(original, rekonstruiert, toleranz=1e-6): """True, wenn jedes Token aus `original` in derselben Reihenfolge in `rekonstruiert` vorkommt - dazwischen darf `rekonstruiert` zusaetzliche Tokens enthalten. Grund: journal_aus_spec haengt bei "Linie-GF" IMMER die drei neuen Pflichtfragen (laenge_modus, sep_vor, sep_nach) des heutigen vf-linienzug-modus an (siehe Kommentar in SpecSchreiber.sektion). Die 5 echten HundM-Ketten (hm_recformat.json) wurden VOR dieser Erweiterung aufgezeichnet und enthalten sie darum nicht - ein exakter Rundlauf ist fuer sie seitdem prinzipiell unerreichbar, weil eine Replay durch den HEUTIGEN Bau-Ablauf diese Fragen real stellen wuerde. Das ist kein Datenverlust der Spec, sondern eine neue Frage des lebenden Ablaufs - darum genuegt hier: nichts vom Original fehlt oder weicht ab, zusaetzliche Tokens sind erlaubt.""" i = 0 for tok in rekonstruiert: if i < len(original) and _tok_gleich(original[i], tok, toleranz): i += 1 return i == len(original) def erste_abweichung(a, b): """Index und Beschreibung der ersten Abweichung (fuer Fehlermeldungen).""" for i in range(max(len(a), len(b))): x = a[i] if i < len(a) else None y = b[i] if i < len(b) else None if not gleich([x] if x else [], [y] if y else []): return i, "ist %r, erwartet %r" % (y, x) return None, "" # ============================================================ # Leser: Journal -> Spec # ============================================================ # Den Ablauf (welche Frage wann) laeuft der gemeinsame Kern in # lib/vf_linienzug_grammatik.py. Hier steht nur noch, WOHIN die gemeldeten # Werte wandern: in eine verschachtelte Spec aus OrderedDicts. class SpecSink(Sink): """Baut aus den Meldungen des Parser-Kerns die Spec. Die Reihenfolge der Schluessel ist bewusst die des Frageablaufs - das flache Spec-JSON wird in genau dieser Reihenfolge geschrieben und ist so neben der Aufzeichnung lesbar. """ def __init__(self): self.spec = collections.OrderedDict() self.start = None self.sektionen = [] self.sek = None self.vf = None self.sub = None # Wohin die naechsten Felder gehoeren (Kopf / Sektion / VF-Einheit / # Sub-Knoten). Der Kern sagt mit den Struktur-Meldungen an, wann sich # das Ziel aendert - hier wird nichts geraten. self.ziel = None def ergebnis(self): self.spec["start"] = self.start self.spec["sektionen"] = self.sektionen return self.spec # --- Struktur --- def praeambel_start(self): self.start = collections.OrderedDict() self.ziel = self.start def praeambel_ende(self): self.ziel = None def sektion_start(self, label): self.sek = collections.OrderedDict([("glied", label)]) self.ziel = self.sek def sektion_ende(self): self.sektionen.append(self.sek) self.sek = None self.ziel = None def vf_start(self): self.vf = collections.OrderedDict() self.ziel = self.vf def vf_ende(self): # "subs" wird erst beim ersten Sub-Knoten angelegt, damit es in der # Reihenfolge hinter gf_verteilung/sep_vor/sep_nach steht. self.vf.setdefault("subs", []) self.sek["vf"] = self.vf self.vf = None self.ziel = self.sek def sub_start(self, sub): self.sub = collections.OrderedDict([("sub", sub)]) self.vf.setdefault("subs", []) self.ziel = self.sub def sub_ende(self): self.vf["subs"].append(self.sub) self.sub = None self.ziel = self.vf # --- Werte --- def punkt_abs(self, punkt): self.ziel["punkt"] = punkt def linie(self, dl, hz, frage): self.ziel["dl"] = dl if hz is not None: self.ziel["hz"] = hz def real_feld(self, name, wert, frage): self.ziel[name] = wert def int_feld(self, name, wert, frage): self.ziel[name] = wert def code_feld(self, name, code, wert, frage): self.ziel[name] = wert def roh_feld(self, name, wert, frage): self.ziel[name] = wert def ja_nein(self, name, code, wert, frage): self.ziel[name] = wert def implizit(self, name, wert): self.ziel[name] = wert # wahl()/glied_gewaehlt()/marke() schlagen sich in der STRUKTUR nieder # (Sub-Knoten, Glied-Name) - die Spec speichert sie nicht noch einmal als # Feld. Die Erbversionen aus Sink tun darum genau das Richtige: nichts. def spec_aus_journal(tok, grammatik=GRAMMATIK_AUFZEICHNUNG): return lies_journal(tok, SpecSink(), grammatik) # ============================================================ # Schreiber: Spec -> Journal # ============================================================ # Vorbild fuer vfl-spec->journal in Lisp/vf_spec.lsp. Bewusst als reiner # Emitter geschrieben: keine Geometrie, keine Rueckfrage, nur Felder in # Tokens. Was die Spec nicht hergibt, ist ein Spec-Fehler - nichts wird # geraten (siehe Invariante I3 im Fahrplan). class SpecSchreiber(object): def __init__(self, spec, grammatik=GRAMMATIK_AUFZEICHNUNG): self.spec = spec self.grammatik = grammatik self.tok = [] self.erster_dl = True self.wo = "Spec" # Siehe sektion()/schreib(): bei "Linie-GF" mit ende="ja-mit-es" # zieht vf-linienzug-modus die ES-Winkel/Seite-Frage direkt in # dieselbe Antwortrunde (Fussabdruck muss vor der Laengenberechnung # feststehen) - die naechste Sektion (glied="ES") ist dann bereits # vollstaendig verbraucht und muss beim Weiterzaehlen uebersprungen # werden. Analog zu *vsp-es-verbraucht* in Lisp/vf_spec.lsp. self.es_verbraucht = False # --- Ausgabe --- def raus(self, art, wert): self.tok.append((art, wert)) def zahl(self, art, wert): self.raus(art, _zahl(wert)) def feld(self, quelle, name, pflicht=True): if name in quelle: return quelle[name] if pflicht: raise ValueError("%s: Feld %r fehlt" % (self.wo, name)) return None def richtung(self, quelle): # Reine Richtungswahl OHNE Laenge (vfl-in-richtung: nur REAL, kein # DL-Token) - fuer den Kettenanfang-Fall von Linie-GF modus="hoehe"/ # "steigend"+"hoehe", wo die Laenge erst aus Hoehe/Winkel berechnet # wird. Nur relevant, wenn dies das erste Segment der Kette ist. self.zahl("REAL", self.feld(quelle, "hz")) self.erster_dl = False def linie(self, quelle): self.zahl("DL", self.feld(quelle, "dl")) # Feldpraesenz entscheidet, nicht "erster_dl": die eingefrorene # Aufzeichnung (hm_recformat.json) journalisiert die Richtung nur # beim allerersten Segment der ganzen Kette, der heutige Ablauf # (linienzug_tests.json/-stufe2.json, 2026-09-12) dagegen bei JEDEM # Segment neu, auch nach einer Vario-Kurve oder einem neuen Glied - # siehe Kern._linie in vf_linienzug_grammatik.py (Gegenstueck beim # Lesen, dort per Lookahead statt Feldpraesenz, weil die Spec beim # Lesen noch nicht existiert). Beim ALLERERSTEN Segment MUSS die # Richtung dabeistehen (die Kette hat noch keine geerbte). if self.erster_dl and "hz" not in quelle: raise ValueError("%s: Feld \"hz\" fehlt (Pflicht beim ersten " "Segment der Kette)" % self.wo) if "hz" in quelle: self.zahl("REAL", self.feld(quelle, "hz")) self.erster_dl = False def winkel_idx(self, quelle): if "winkel_idx" in quelle: self.raus("INT", str(int(quelle["winkel_idx"]))) def gf2_bestaetigung(self, quelle): # Stufe-2-Fallback (vfl-frage-gf2-minimum) - siehe # Kern._opt_gf2_bestaetigung in vf_linienzug_grammatik.py fuer die # volle Erklaerung. Nur vorhanden, wenn die berechnete GF2-Laenge # am Kettenende unter der Mindestlaenge lag. if "gf2_exakt_bauen" in quelle: self.raus("STR", code_aus_ja(quelle["gf2_exakt_bauen"])) # --- Ablauf --- def schreib(self): self.praeambel() sektionen = self.spec.get("sektionen") or [] if not sektionen: raise ValueError("Spec hat keine Sektionen") n = len(sektionen) i = 0 while i < n: sek = sektionen[i] naechste = sektionen[i + 1] if i + 1 < n else None self.wo = "Sektion %d (%s)" % (i + 1, sek.get("glied")) self.es_verbraucht = False self.sektion(sek, erster=(i == 0), naechste=naechste) i += 2 if self.es_verbraucht else 1 return self.tok def praeambel(self): start = self.spec.get("start") if not start: raise ValueError("Spec: Abschnitt \"start\" fehlt") self.wo = "start" punkt = self.feld(start, "punkt") if len(punkt) != 3: raise ValueError("start: punkt braucht x, y, z") self.raus("PT", ",".join(_zahl(v) for v in punkt)) self.zahl("REAL", self.feld(start, "hoehe")) gesetzt = bool(self.feld(start, "as")) self.raus("STR", code_aus_ja(gesetzt)) if gesetzt: self.raus("STR", str(self.feld(start, "as_winkel"))) self.raus("STR", code_aus_wert(SEITE, self.feld(start, "as_seite"), "as_seite")) def sektion(self, sek, erster, naechste=None): label = self.feld(sek, "glied") if label == "ES": self.es_glied(sek) return tabelle = self.grammatik.menu_start if erster else MENU_WEITER if label not in tabelle: raise ValueError("%s: Glied %r ist an dieser Stelle nicht " "waehlbar (erlaubt: %s)" % (self.wo, label, sorted(tabelle))) self.raus("STEP", label) self.raus("STR", tabelle[label]) if label == "GF-Bogen": self.raus("INT", str(int(self.feld(sek, "winkel")))) self.raus("STR", code_aus_wert(SEITE, self.feld(sek, "seite"), "seite")) elif label == "Linie-GF": # Vorwahl "laenge_modus" (endpunkt/hoehe/steigend, Default # "endpunkt" - deckt alte Specs ohne dieses Feld ab). "steigend" # ist der Sonderfall (GF bis 3 Grad steigend, KEIN Separator, # eigene innere Endpunkt/Hoehe-Wahl "steigend_modus"). Die # DL(+hz)-Ausgabe (self.linie) faellt bei "hoehe"/"steigend"+ # "hoehe" NUR beim allerersten Segment der Kette an - siehe # self.linie/self.erster_dl. modus = self.feld(sek, "laenge_modus", pflicht=False) or "endpunkt" self.raus("STR", code_aus_wert(GF_LAENGE_MODUS, modus, "laenge_modus")) # "Ist das Kettenende?" wird SOFORT nach der Laenge-Modus-Wahl # gefragt (vor Separator/Gefaelle/Laenge) - Gegenstueck zu # vsp-sektion in Lisp/vf_spec.lsp (Kommentar dort: "NEU # 2026-09-12"). Bei "ja-mit-es" fragt vf-linienzug-modus DIREKT im # Anschluss auch ES-Winkel/-Seite (deren Fussabdruck muss schon in # die Laengenberechnung eingehen) - die Spec beschreibt das # ES-Element trotzdem als EIGENE, nachfolgende Sektion # (glied="ES") fuer bessere Lesbarkeit. Die beiden Tokengruppen # werden hier zusammengezogen: "ende" + (bei ja-mit-es) die # Winkel/Seite-Tokens der naechsten Sektion, vorgezogen - die # eigentliche ES-Sektion ist dadurch schon vollstaendig # verbraucht (self.es_verbraucht, von schreib() zum Ueberspringen # ausgewertet), sonst kaemen ihre Tokens ein zweites Mal. ende_wert = self.feld(sek, "ende") self.raus("STR", code_aus_wert(ENDE3, ende_wert, "ende")) if ende_wert == "ja-mit-es": if naechste is None: raise ValueError("%s: ende=\"ja-mit-es\", aber keine " "folgende ES-Sektion vorhanden" % self.wo) if naechste.get("glied") != "ES": raise ValueError("%s: ende=\"ja-mit-es\" erwartet eine " "direkt folgende Sektion mit " "glied=\"ES\"" % self.wo) self.raus("STR", str(self.feld(naechste, "winkel"))) self.raus("STR", code_aus_wert(SEITE, self.feld(naechste, "seite"), "seite (ES, vorgezogen)")) self.es_verbraucht = True if modus == "steigend": smodus = (self.feld(sek, "steigend_modus", pflicht=False) or "endpunkt") self.raus("STR", code_aus_wert(GF_LAENGE_MODUS, smodus, "steigend_modus")) if smodus == "endpunkt": self.linie(sek) elif self.erster_dl: self.richtung(sek) if smodus == "hoehe": self.zahl("REAL", self.feld(sek, "hoehe")) self.zahl("REAL", self.feld(sek, "winkel")) elif modus == "hoehe": if self.erster_dl: self.richtung(sek) self.raus("STR", code_aus_ja(self.feld(sek, "sep_vor", pflicht=False))) self.raus("STR", code_aus_ja(self.feld(sek, "sep_nach", pflicht=False))) self.zahl("REAL", self.feld(sek, "hoehe")) self.zahl("REAL", self.feld(sek, "winkel")) else: # "endpunkt" self.linie(sek) self.raus("STR", code_aus_ja(self.feld(sek, "sep_vor", pflicht=False))) self.raus("STR", code_aus_ja(self.feld(sek, "sep_nach", pflicht=False))) art = self.feld(sek, "gefaelle") self.raus("STR", code_aus_wert(GEFAELLE, art, "gefaelle")) self.zahl("REAL", self.feld(sek, "winkel" if art == "winkel" else "hoehe")) elif label == "Linie-VF": self.linie(sek) self.zahl("REAL", self.feld(sek, "hoehe")) self.winkel_idx(sek) self.vf_einheit(sek, erst_hor=False) elif label == "Horizontal-VF": self.linie(sek) # Optional: Ziel-Hoehe des GF1-Einlaufs - nur wenn der Eingang # komplett neu gebaut wird (siehe Kern._opt_eingang_hoehe). # "auto" (Gegenstueck zu vsp-eingang-hoehe in Lisp/vf_spec.lsp): # die Frage steht zwar an, aber es wird bewusst KEINE Zahl # vorgegeben - ein NIL-Token statt REAL, der Bauablauf berechnet # dann seinen eigenen Vorschlag (wie bei leerer Konsoleneingabe). if "eingang_hoehe" in sek: v = sek["eingang_hoehe"] if isinstance(v, str) and v.strip().upper() == "AUTO": self.raus("NIL", "") else: self.zahl("REAL", v) self.vf_einheit(sek, erst_hor=True) elif label == "Linie": self.linie(sek) self.zahl("REAL", self.feld(sek, "hoehe")) self.winkel_idx(sek) if self.feld(sek, "segment_typ") == "VF": self.vf_einheit(sek, erst_hor=False) else: raise ValueError("%s: unbekanntes Glied %r" % (self.wo, label)) def es_glied(self, sek): self.raus("STEP", "ES") self.raus("STR", str(self.feld(sek, "winkel"))) self.raus("STR", code_aus_wert(SEITE, self.feld(sek, "seite"), "seite")) def vf_einheit(self, sek, erst_hor): vf = self.feld(sek, "vf") # GF-Verteilung: bei horizontalem Erstkoerper stellt der heutige # Ablauf die Frage nicht mehr (fest "2" = alles am Einlauf, siehe # Grammatik.horizontal_vf_fragt_gf_verteilung). Anders als beim # LESEN (Kern.vf_einheit in vf_linienzug_grammatik.py, wo die # Grammatik-Auspraegung ueber die naechste Tokenart entscheiden # muss) reicht hier die Feldpraesenz: die Spec liegt schon in # Domaenenwerten vor, das Fehlen von "gf_verteilung" IST die Aussage # "diese Frage kam nicht" - selbstbeschreibend statt an ein # externes Grammatik-Flag gebunden. Das erlaubt Specs mit und ohne # diese Frage nebeneinander (aeltere, aus der Aufzeichnung gelesene # Ketten haben sie; neu von Hand geschriebene fuer den heutigen # Ablauf lassen sie weg), ohne dass der Aufrufer die Grammatik kennen # muss. if not erst_hor or "gf_verteilung" in vf: self.raus("STR", code_aus_wert(VERTEILUNG, self.feld(vf, "gf_verteilung"), "gf_verteilung")) if erst_hor: self.raus("STR", code_aus_ja(self.feld(vf, "sep_vor"))) self.raus("STR", code_aus_ja(self.feld(vf, "sep_nach"))) subs = vf.get("subs") or [] if not subs: raise ValueError("%s: VF-Einheit ohne subs - sie muss mit " "motorstation oder kettenende enden" % self.wo) for i, sub in enumerate(subs, 1): wahl = self.feld(sub, "sub") letzter = (i == len(subs)) if wahl in ("motorstation", "kettenende") and not letzter: raise ValueError("%s: sub %r beendet die VF-Einheit, es " "folgen aber noch %d weitere" % (self.wo, wahl, len(subs) - i)) if wahl == "motorstation": self.raus("STR", code_aus_wert(ENDPUNKT, "motorstation", "sub")) elif wahl == "kettenende": self.raus("STR", code_aus_wert(ENDPUNKT, "kettenende", "sub")) gesetzt = bool(self.feld(sub, "es")) self.raus("STR", code_aus_ja(gesetzt)) if gesetzt: # Winkel+Seite des ES-Elements stehen HIER im Journal # (vfl-frage-es-seite, inkl. eigenem Marker) - der # ES-Fussabdruck muss vor der Zerlegung feststehen. self.raus("STEP", "ES") self.raus("STR", str(self.feld(sub, "es_winkel"))) self.raus("STR", code_aus_wert(SEITE, self.feld(sub, "es_seite"), "es_seite")) self.linie(sub) self.zahl("REAL", self.feld(sub, "hoehe")) self.winkel_idx(sub) self.gf2_bestaetigung(sub) else: if letzter: raise ValueError("%s: VF-Einheit endet mit sub %r - der " "letzte sub muss motorstation oder " "kettenende sein" % (self.wo, wahl)) self.raus("STR", code_aus_wert(ENDPUNKT, "weiter", "sub")) self.raus("STR", code_aus_wert(IM_VF, wahl, "sub")) if wahl == "horizontal": self.linie(sub) self.raus("STR", code_aus_ja(self.feld(sub, "sep_vor"))) self.raus("STR", code_aus_ja(self.feld(sub, "sep_nach"))) elif wahl == "vario-kurve": self.raus("STEP", "Vario-Kurve") self.raus("INT", str(int(self.feld(sub, "winkel")))) self.raus("STR", code_aus_wert(SEITE, self.feld(sub, "seite"), "seite")) self.raus("STR", code_aus_wert(VARIANTE, self.feld(sub, "variante"), "variante")) else: # auf-ab self.linie(sub) # Zwei Auspraegungen, per Feldpraesenz unterschieden (kein # separates Modus-Flag - "richtung" IST die Aussage, siehe # vsp-vf-einheit in Lisp/vf_spec.lsp): kommt die Kette # bereits FLACH an, fragt vf-linienzug-modus die Richtung # (Auf/Ab) + waehlt den Winkel aus einer Kandidatentabelle # statt einer direkten Zielhoehe. if "richtung" in sub: self.raus("STR", code_aus_wert(RICHTUNG, self.feld(sub, "richtung"), "richtung")) self.winkel_idx(sub) else: self.zahl("REAL", self.feld(sub, "hoehe")) self.winkel_idx(sub) ende = self.feld(vf, "ende") if ende in ("zielpunkt-ohne-es", "automatisch"): return # keine Frage self.raus("STR", code_aus_wert(ENDE2, ende, "vf.ende")) if ende != "ja-mit-es": self.raus("STR", code_aus_ja(self.feld(vf, "separator"))) def journal_aus_spec(spec, grammatik=GRAMMATIK_AUFZEICHNUNG): return SpecSchreiber(spec, grammatik).schreib() # ============================================================ # Flaches JSON (vom LISP-Parser lesbar) # ============================================================ # ssg-load-json/ssg-parse-json-array liest ZEILENWEISE und FLACH: jedes "{" # beginnt ein Objekt, Verschachtelung ist unmoeglich. Also dieselbe flache # Form wie die Aufzeichnung: ein Kopf-Objekt ("spec_id"), dann Sektionen # ("glied") und deren Sub-Knoten ("sub") in Reihenfolge. def flache_objekte(spec_id, spec, kopf_extra=None): objekte = [] kopf = collections.OrderedDict() kopf["spec_id"] = spec_id kopf["modus"] = spec.get("modus", 1) start = spec["start"] kopf["start_punkt"] = start["punkt"] kopf["start_hoehe"] = start["hoehe"] kopf["as"] = 1 if start["as"] else 0 if start["as"]: kopf["as_winkel"] = start["as_winkel"] kopf["as_seite"] = start["as_seite"] for k, v in (kopf_extra or collections.OrderedDict()).items(): kopf[k] = v objekte.append(kopf) for nr, sek in enumerate(spec["sektionen"], 1): obj = collections.OrderedDict() obj["glied"] = sek["glied"] obj["nr"] = nr for k, v in sek.items(): if k in ("glied", "vf"): continue obj[k] = 1 if v is True else (0 if v is False else v) vf = sek.get("vf") if vf: for k in ("gf_verteilung", "sep_vor", "sep_nach", "ende", "separator"): if k in vf: v = vf[k] obj["vf_" + k if k != "gf_verteilung" else k] = ( 1 if v is True else (0 if v is False else v)) objekte.append(obj) for sub in (vf or {}).get("subs", []): s = collections.OrderedDict() s["sub"] = sub["sub"] for k, v in sub.items(): if k == "sub": continue s[k] = 1 if v is True else (0 if v is False else v) objekte.append(s) # Freitext (beschreibung/quelle/...) darf kein " enthalten - der # LISP-Leser kennt keine Escapes (siehe pruefe_flachen_freitext). pruefe_flachen_freitext(objekte, "Spec %s" % spec_id) return objekte # Felder, die eine Laenge/Koordinate in mm tragen (Winkel/Indizes/Flags # bleiben unberuehrt). Gerundet wird erst HIER, ganz am Ende - der # Rundlauf-Vergleich (gleich()) laeuft vorher gegen die volle Praezision aus # hm_recformat.json, sonst wuerde das Runden selbst als Abweichung auffallen. MM_FELDER = ("dl", "hoehe", "start_hoehe", "eingang_hoehe", "x", "y", "z", "abstand") def _gerundet(v, stellen=1): return round(v, stellen) if isinstance(v, float) else v def runde_mm_felder(objekte, stellen=1): """dl/Punkte/Hoehen/Koordinaten auf `stellen` Nachkommastellen runden - sowohl in den generierten Sektionen/Subs als auch in den von Hand ergaenzten Objekten (fremde_objekte), die unveraendert durchgereicht werden.""" for obj in objekte: for k, v in obj.items(): if k == "start_punkt" and isinstance(v, list): obj[k] = [_gerundet(c, stellen) for c in v] elif k in MM_FELDER: obj[k] = _gerundet(v, stellen) return objekte def fremde_objekte(pfad): """Objekte einer bestehenden Ziel-Datei, die KEINE Spec beschreiben. Die Spec-Testdaten werden erzeugt, die Datei ist aber zugleich die Stelle, an der weitere Objekte der Anlage von Hand nachgetragen werden (Kreisel, Foerderer, ...). Alles ohne spec_id/glied/sub gehoert nicht zum Generator und wird beim Neuschreiben uebernommen - sonst waere ein handgeschriebener Eintrag beim naechsten Lauf weg. """ if not pfad or not os.path.isfile(pfad): return [] try: with open(pfad, encoding="utf-8") as fh: daten = json.load(fh) except ValueError: return [] return [o for o in daten if not ("spec_id" in o or "glied" in o or "sub" in o)] def spec_aus_flachen_objekten(objekte): """Umkehrung von flache_objekte - dieselbe Gruppierregel wie hmrec:gruppiere in LISP (Kopf, dann glied/sub in Reihenfolge).""" specs = [] for obj in objekte: if "spec_id" in obj: start = collections.OrderedDict() start["punkt"] = obj["start_punkt"] start["hoehe"] = obj["start_hoehe"] start["as"] = bool(obj.get("as")) if start["as"]: start["as_winkel"] = obj["as_winkel"] start["as_seite"] = obj["as_seite"] specs.append((obj, collections.OrderedDict( [("modus", obj.get("modus", 1)), ("start", start), ("sektionen", [])]))) elif "glied" in obj: sek = collections.OrderedDict([("glied", obj["glied"])]) vf = collections.OrderedDict() for k, v in obj.items(): if k in ("glied", "nr"): continue if k == "gf_verteilung": vf["gf_verteilung"] = v elif k.startswith("vf_"): name = k[3:] vf[name] = bool(v) if name in ("sep_vor", "sep_nach", "separator") else v elif k in ("as", "es", "sep_vor", "sep_nach"): sek[k] = bool(v) else: sek[k] = v if vf: vf["subs"] = [] sek["vf"] = vf specs[-1][1]["sektionen"].append(sek) elif "sub" in obj: sub = collections.OrderedDict([("sub", obj["sub"])]) for k, v in obj.items(): if k == "sub": continue sub[k] = bool(v) if k in ("es", "sep_vor", "sep_nach") else v sek = specs[-1][1]["sektionen"][-1] sek.setdefault("vf", collections.OrderedDict()) sek["vf"].setdefault("subs", []).append(sub) else: # Von Hand ergaenzte Objekte der Anlage (Kreisel u.a.) gehoeren # nicht zur Spec und werden uebersprungen - genauso wie # vsp-gruppieren in Lisp/vf_spec.lsp es tut. Sie stehen in # derselben Datei, damit eine Anlage EINE Beschreibung hat. continue return specs # ============================================================ # CLI # ============================================================ def main(): p = argparse.ArgumentParser( description="Frage-Antwort-Protokoll (hm_recformat.json) in Specs " "uebersetzen und den Rundlauf pruefen.") p.add_argument("protokoll", help="Eingabe (Schema hm_recformat.json)") p.add_argument("ziel", help="Ausgabe (flaches Spec-JSON)") args = p.parse_args() with open(args.protokoll, encoding="utf-8") as fh: daten = json.load(fh) ketten = [] for obj in daten: if "test_id" in obj: ketten.append((obj, [])) elif "typ" in obj and ketten: ketten[-1][1].append(obj) print("%s %d Ketten in %s" % (PREFIX, len(ketten), args.protokoll)) alle = [] fehler = 0 for kopf, eintraege in ketten: tid = kopf["test_id"] tok = tokens_aus_protokoll(eintraege) try: spec = spec_aus_journal(tok) zurueck = journal_aus_spec(spec) except ValueError as e: print("%s %-20s FEHLER: %s" % (PREFIX, tid, e)) fehler += 1 continue if not ist_teilfolge(tok, zurueck): print("%s %-20s RUNDLAUF WEICHT AB: die Spec bildet die " "Aufzeichnung nicht mehr verlustfrei ab (nicht nur um " "neue Pflichtfragen erweitert)" % (PREFIX, tid)) fehler += 1 continue # Nur "quelle" landet in der Spec - sie ist die einzige der # Protokoll-Kopf-Angaben, die keine Routine abfragt (Lisp-Seite: # vsp-gruppieren/vsp-praeambel lesen nur spec_id/modus/start_punkt/ # start_hoehe/as[_winkel/_seite]). beschreibung/sivas_id/ # sivas_bezeichnung/planquadrat/block/dxf_handle waren reine # Dokumentation (Planquadrat wird beim Export ohnehin neu bestimmt, # Block/Handle sind nach der Ableitung uninteressant) - "quelle" # bleibt als einzige Herkunftsangabe stehen. extra = collections.OrderedDict() if "quelle" in kopf: extra["quelle"] = kopf["quelle"] # Die spec_id bleibt die Kettenkennung aus dem Protokoll: dieselbe # Kette soll in beiden Testlaeufen (Spec-Pfad und Aufzeichnungs- # Pfad) denselben Namen tragen, sonst laesst sich das Ergebnis # nicht paaren. try: alle.extend(flache_objekte(tid, spec, extra)) except ValueError as e: print("%s %-20s FEHLER: %s" % (PREFIX, tid, e)) fehler += 1 continue print("%s %-20s %2d Sektionen, %3d Tokens - Rundlauf ok" % (PREFIX, tid, len(spec["sektionen"]), len(tok))) if fehler: print("%s %d Kette(n) fehlerhaft - nichts geschrieben" % (PREFIX, fehler)) return 1 # Von Hand ergaenzte Objekte der Ziel-Datei ans Ende uebernehmen. fremde = fremde_objekte(args.ziel) if fremde: print("%s %d von Hand ergaenzte(s) Objekt(e) uebernommen: %s" % (PREFIX, len(fremde), ", ".join(str(o.get("id", "?")) for o in fremde))) alle.extend(fremde) runde_mm_felder(alle) # Letzte Kontrolle vor dem Schreiben - sie deckt auch die von Hand # ergaenzten Objekte ab, die nicht durch flache_objekte gelaufen sind. # Gueltiges JSON reicht hier NICHT (siehe pruefe_flachen_freitext): der # LISP-Leser schneidet einen Stringwert am ersten rohen " ab und verliert # damit den Rest der Datei. try: pruefe_flachen_freitext(alle, args.ziel) except ValueError as e: print("%s FEHLER: %s" % (PREFIX, e)) print("%s nichts geschrieben" % PREFIX) return 1 text = json.dumps(alle, indent=2, ensure_ascii=False) with open(args.ziel, "w", encoding="utf-8", newline="\r\n") as fh: fh.write(arrays_einzeilig(text)) fh.write("\n") print("%s %d Objekte geschrieben -> %s" % (PREFIX, len(alle), args.ziel)) return 0 if __name__ == "__main__": sys.exit(main())