#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ vf_linienzug_grammatik.py - die EINE Grammatik des VF-Linienzug-Journals und der EINE Parser-Kern, der sie laeuft. Warum es diese Datei gibt ------------------------- Die Frage-Reihenfolge des Linienzug-Bau-Ablaufs (Lisp/vf_linienzug.lsp, Modus 1) wurde frueher in ZWEI Python-Dateien unabhaengig nachgebaut: - lib/vf_journal_export.py (Klasse Journal) - erzeugt daraus ein annotiertes Frage-Antwort-PROTOKOLL, - lib/vf_spec_export.py (Klasse SpecLeser) - erzeugt daraus eine SPEC in Domaenenwerten. Beide liefen denselben Ablauf ab und hielten dieselben Code-Tabellen doppelt. Eine Grammatik-Aenderung wurde darum regelmaessig nur in einer der beiden Dateien nachgezogen (realer Fall: das optionale Feld eingang_hoehe am Horizontal-VF und die Position der ES-Untermarke im kettenende-Zweig landeten nur in vf_spec_export.py - vf_journal_export.py konnte eine Aufzeichnung der aktuellen Grammatik danach nicht mehr lesen). Hier steht der Ablauf jetzt EINMAL. Was die beiden Ausgaben unterscheidet, steckt allein in ihrer Sink-Implementierung: der Kern meldet an jeder Entscheidungsstelle einen selbstbeschreibenden Wert, die Sink legt ihn ab (Spec) oder schreibt eine Protokollzeile dazu (Aufzeichnung). lib/vf_spec_export.py SpecSink -> Spec (OrderedDict) lib/vf_journal_export.py ProtokollSink -> annotiertes Protokoll (Liste) Die Gegenstuecke in AutoLISP (vsp-journal/vsp-sektion/vsp-vf-einheit in Lisp/vf_spec.lsp, der lebende Ablauf in Lisp/vf_linienzug.lsp) lassen sich nicht mitbenutzen (andere Sprache) - sie bleiben die zweite, unabhaengige Niederschrift und werden gegen diese hier geprueft (tests/test_vf_spec.py, tests/test_vf_spec.lsp). Token-Form ---------- Token = (Art, Wert-String) genau wie im Journal auf der XDATA: PT Startpunkt "x,y,z" DL Segmentlaenge REAL Realzahl INT Ganzzahl STR Menue-/Wertantwort STEP Glied-Marker (Label = Segmenttyp) NIL bewusst leere Antwort (z.B. eingang_hoehe="auto", siehe _opt_eingang_hoehe) - der Bauablauf berechnet dann seinen eigenen Vorschlag, wie bei einer leeren Konsoleneingabe """ # ============================================================ # Code-Tabellen: Menue-Antwort <-> Domaenenwert # ============================================================ # Links der Journal-Code, rechts der Wert in der Spec. Die Spec kennt KEINE # Codes - sie sind eine Eigenschaft des Dialogs, nicht der Anlage. Nur die # AS-/ES-Winkel und die Bogen-/Kurvenwinkel stehen im Journal als Wert und # bleiben darum Werte. # # Die Werte sind bewusst die DOMAENEN-Kurzformen ("links", "haelfte", ...), # nicht die Klartext-Antworten des Dialogs: sie sind maschinenlesbar und # stehen so auch in der Spec-Datei. Den Annotationstext leitet # lib/vf_journal_export.py aus dem Domaenenwert ab (Tabelle _CODE_FRAGEN # dort) - eine zweite Code-Tabelle braucht es dafuer nicht. SEITE = {"1": "links", "2": "rechts"} VARIANTE = {"1": "aussen", "2": "innen"} GEFAELLE = {"1": "hoehe", "2": "winkel"} # Vorwahl "Wie soll die Laenge der Gefaellestrecke bestimmt werden?" # (vfl-menu in vf-linienzug-modus, wahl="Linie-GF"). Die innere Wahl im # Steigend-Sonderfall (Endpunkt/Hoehe) nutzt dieselbe Tabelle - dort sind # nur "1"/"2" gueltig, "steigend" kommt dort nie vor. GF_LAENGE_MODUS = {"1": "endpunkt", "2": "hoehe", "3": "steigend"} VERTEILUNG = {"1": "haelfte", "2": "einlauf"} IM_VF = {"1": "horizontal", "2": "vario-kurve", "3": "auf-ab"} ENDPUNKT = {"1": "motorstation", "2": "weiter", "3": "kettenende"} # Richtungswahl fuer "auf-ab", wenn die Kette bereits FLACH ankommt # (vfl-frage-auf-ab-richtung: "1"=Auf, "2"=Ab) - siehe Kern.vf_einheit. RICHTUNG = {"1": "auf", "2": "ab"} ENDE3 = {"1": "ja-mit-es", "2": "ja-ohne-es", "3": "nein"} ENDE2 = {"1": "ja-mit-es", "2": "nein"} # Ja/Nein-Antworten des Dialogs. Als Tabelle gefuehrt (und nicht als # "code == 1"), damit ein unbekannter Code denselben Fehler liefert wie bei # jeder anderen Frage, statt still als "Nein" durchzugehen. JA_NEIN = {"1": True, "2": False} # Glied-Typ -> Menue-Code, getrennt nach Position in der Kette. Der Code # haengt am Frame: die ERSTE Sektion laeuft ohne Frame und bekommt 4 Optionen # (kein GF-Bogen - es gibt noch keine Richtung, an die er anschliessen # koennte), jede spaetere 5. Siehe vf-linienzug-modus. # # WICHTIG (2026-09-12): vf-linienzug-modus wurde SPAETER um "GF-Bogen"/ # "Vario-Kurve" auch am Kettenanfang erweitert (6 statt 4 Optionen dort, # "Fortsetzung einer bestehenden Teilkette" - Referenzrichtung kommt dann aus # einem KS_AUS am Startpunkt). Lisp/vf_spec.lsp's *vsp-menu-start* wurde # entsprechend auf die NEUE 6er-Zuordnung umgestellt, WEIL vsp-journal seine # Tokens durch den AKTUELLEN (lebenden) vf-linienzug-modus REPLAYT - der # Code muss dort zur JETZT gueltigen Menue-Reihenfolge passen. # # Diese Python-Tabelle bleibt bewusst auf der ALTEN 4er-Zuordnung: sie # decodiert tests/testdata/hm_recformat.json - eine EINGEFRORENE Aufzeichnung # einer echten Kundenzeichnung, die zu einem Zeitpunkt VOR dieser Erweiterung # entstand (ihr roher Code fuer "Linie-GF" am Kettenanfang ist "1", nicht # "3"). Wuerde diese Tabelle auf die neue Zuordnung umgestellt, koennte der # Parser-Kern die vorhandene Aufzeichnung nicht mehr korrekt lesen (siehe # Testfall test_verlustfrei in tests/test_vf_spec.py). Die Tokenfolgen von # LISP (vsp-journal, GEGEN den lebenden Bau-Ablauf) und Python (hier, GEGEN # die eingefrorene Aufzeichnung) sind fuer "Linie-GF" am Kettenanfang darum # ab dieser Erweiterung bewusst NICHT mehr identisch - eine unvermeidbare # Folge davon, dass sich die Menue-Struktur nach der Aufzeichnung geaendert # hat. Wird hm_recformat.json je aus einer NEUEN Aufzeichnung (mit dem # aktuellen 6-Optionen-Ablauf) neu erzeugt, muesste diese Tabelle mitziehen. # # Fuer eine NEUE Uebersetzung, die durch den lebenden Ablauf replayt werden # soll, gibt es die aktuelle Zuordnung daneben (MENU_START_AKTUELL): beide # Auspraegungen sind ueber den Parameter "grammatik" waehlbar, diese hier # bleibt die Vorgabe. MENU_START = {"Linie-GF": "1", "Linie-VF": "2", "Horizontal-VF": "3", "Linie": "4"} # Die HEUTE gueltige Zuordnung am Kettenanfang (6 Optionen), identisch zu # *vsp-menu-start* in Lisp/vf_spec.lsp. Sie gilt fuer JEDE neue Uebersetzung, # die spaeter durch den lebenden vf-linienzug-modus replayt werden soll - die # alte Tabelle daneben bleibt nur fuer die eingefrorene Aufzeichnung stehen. MENU_START_AKTUELL = {"GF-Bogen": "1", "Vario-Kurve": "2", "Linie-GF": "3", "Linie-VF": "4", "Horizontal-VF": "5", "Linie": "6"} MENU_WEITER = {"GF-Bogen": "1", "Linie-GF": "2", "Linie-VF": "3", "Horizontal-VF": "4", "Linie": "5"} GLIEDER = sorted(set(MENU_WEITER) | {"ES"}) # Glieder, die eine VF-Einheit eroeffnen, und ob ihr erster Koerper # horizontal ist (winkel1 = 0 in vfl-vf-einheit: nur dann laufen die # Separator-/Endpunkt-Fragen VOR der Laengenberechnung). VF_GLIEDER = {"Horizontal-VF": True, "Linie-VF": False, "Linie": False} class Grammatik(object): """Eine Auspraegung der Dialog-Grammatik (welche Frage an welcher Stelle). Die Grammatik ist KEINE Eigenschaft der Anlage, sondern des Dialogs - und der hat sich seit der Aufzeichnung tests/testdata/hm_recformat.json geaendert. Beide Auspraegungen muessen darum NEBENEINANDER benutzbar sein: die eingefrorene, um die alte Aufzeichnung weiter lesen zu koennen, und die aktuelle fuer jede neue Uebersetzung, die spaeter durch den lebenden vf-linienzug-modus replayt wird (Lisp/vf_spec.lsp folgt immer der aktuellen). menu_start Glied -> Menue-Code der ERSTEN Sektion (ohne Frame). horizontal_vf_fragt_gf_verteilung Ob eine mit "Horizontal-VF" eroeffnete VF-Einheit die GF-Verteilung fragt. Der heutige Ablauf fragt sie dort NICHT mehr: die Aufrufstelle in vf-linienzug-modus gibt "2" (alles am Einlauf) fest vor (vfl-vf-einheit-abschluss, Argument gf-verteilung-fest). linie_gf_laenge_modus Ob eine "Linie-GF"-Sektion die Vorwahl "laenge_modus" (endpunkt/ hoehe/steigend) kennt und "Ist das Kettenende?" SOFORT danach fragt (NEU 2026-09-12, siehe vsp-sektion in Lisp/vf_spec.lsp). Die eingefrorene Aufzeichnung stammt von VOR dieser Erweiterung: dort folgen auf Laenge/Gefaelle direkt Winkel/Hoehe, und "Ist das Kettenende?" steht ganz am Schluss der Sektion. linie_hz_je_segment Ob vfl-in-abstand die gesnappte Fahrtrichtung (hz) bei JEDEM Segment journalisiert (linienzug_tests.json/-stufe2.json, 2026-09-12: auch nach einer Vario-Kurve oder einem neuen Glied) oder nur beim ALLERERSTEN Segment der ganzen Kette (eingefrorene Aufzeichnung, hm_recformat.json). Muss grammatik-gebunden sein, nicht per Lookahead entschieden: ein direkt auf DL folgendes REAL kann auch "eingang_hoehe" einer Horizontal-VF-Sektion sein (siehe Kern._opt_eingang_hoehe) - blinder Lookahead wuerde das faelschlich als hz konsumieren und die Queue verschieben. """ def __init__(self, name, menu_start, horizontal_vf_fragt_gf_verteilung, linie_gf_laenge_modus=True, linie_hz_je_segment=True): self.name = name self.menu_start = menu_start self.horizontal_vf_fragt_gf_verteilung = ( horizontal_vf_fragt_gf_verteilung) self.linie_gf_laenge_modus = linie_gf_laenge_modus self.linie_hz_je_segment = linie_hz_je_segment def __repr__(self): return "Grammatik(%r)" % self.name # Stand der EINGEFRORENEN Aufzeichnung (hm_recformat.json) - Vorgabe, damit # alle bestehenden Aufrufer unveraendert weiterlaufen. GRAMMATIK_AUFZEICHNUNG = Grammatik("aufzeichnung", MENU_START, True, linie_gf_laenge_modus=False, linie_hz_je_segment=False) # Stand des LEBENDEN Bau-Ablaufs (vf_linienzug.lsp) - fuer neue Uebersetzungen. GRAMMATIK_AKTUELL = Grammatik("aktuell", MENU_START_AKTUELL, False, linie_gf_laenge_modus=True, linie_hz_je_segment=True) # ============================================================ # Code <-> Wert # ============================================================ def wert_aus_code(tabelle, code, was): """Journal-Code in den Domaenenwert uebersetzen (fail fast).""" if code not in tabelle: raise ValueError("%s: unbekannter Code %r (erlaubt: %s)" % (was, code, sorted(tabelle))) return tabelle[code] def code_aus_wert(tabelle, wert, was): """Domaenenwert in den Journal-Code uebersetzen (fail fast).""" fuer = dict((v, k) for k, v in tabelle.items()) if wert not in fuer: raise ValueError("%s: unbekannter Wert %r (erlaubt: %s)" % (was, wert, sorted(fuer))) return fuer[wert] def ja_aus_code(code, was): return wert_aus_code(JA_NEIN, code, was) def code_aus_ja(wert): return "1" if wert else "2" # ============================================================ # Schutz fuer das flache, LISP-lesbare JSON # ============================================================ def pruefe_flachen_freitext(objekte, wo="Spec-JSON"): """Jeden Stringwert der flachen Objekte auf rohe Anfuehrungszeichen pruefen - und bei einem Treffer hart abbrechen. Warum das kein Kosmetik-Check ist: der LISP-seitige Leser (ssg-load-json -> ssg-cfg-parse-value, benutzt von vsp-json-laden/ vsp-gruppieren in Lisp/vf_spec.lsp) ist ein ZEILENWEISER Minimalparser OHNE Escape-Behandlung. Er schneidet einen Stringwert am ersten rohen " ab. Ein Anfuehrungszeichen mitten in einem Freitextfeld (quelle, beschreibung, ...) verschiebt damit alles Folgende um eine Anfuehrung - und zerlegt nicht nur dieses Feld, sondern das Parsen der GESAMTEN restlichen Datei. Realer Fehlerfall: ein maskiertes \\"sub\\":\\"auf-ab\\" in einem quelle-Feld liess auf LISP-Seite ALLE 5 Ketten ausfallen. Python's json-Modul liest so eine Datei anstandslos wieder ein - genau darum faellt der Fehler ohne diese Pruefung nicht auf. Gueltiges JSON reicht hier NICHT; die strengere Bedingung des LISP-Lesers muss auch gelten. Ein Kommentar allein hat das schon einmal nicht verhindert, darum jetzt eine harte Pruefung im Schreibpfad. """ for nr, obj in enumerate(objekte, 1): kennung = (obj.get("spec_id") or obj.get("glied") or obj.get("sub") or obj.get("id") or "?") for schluessel, wert in obj.items(): if isinstance(wert, str) and '"' in wert: raise ValueError( '%s, Objekt %d (%s): Feld %r enthaelt ein ' 'Anfuehrungszeichen. Der LISP-Leser (ssg-cfg-parse-value) ' 'kennt keine Escapes und schneidet den Wert dort ab - das ' 'zerlegt das Parsen der GESAMTEN restlichen Datei. Text ' 'ohne " formulieren. Wert: %s' % (wo, nr, kennung, schluessel, wert)) # ============================================================ # Sink: was der Parser-Kern an jeder Entscheidungsstelle meldet # ============================================================ class Sink(object): """Aufnehmer fuer den Parser-Kern. Der Kern kennt nur die Grammatik, nicht das Ziel-Format. An jeder Stelle, an der ein Wert verbraucht wird oder sich die Struktur aendert, ruft er genau eine dieser Methoden. Die Schnittstelle ist bewusst FEINGRANULAR (ein Aufruf je Wert, nicht je Glied): ein groeberer Schnitt ("bau mir das Glied und gib mir das Ergebnis") waere wieder je Ausgabe eine eigene Implementierung der Grammatik - genau das, was hier abgeschafft wird. Jede Methode bekommt den Wert als DOMAENENWERT (bereits uebersetzt), dazu name - Feldname in der Spec (selbstbeschreibend, kein Index) frage - Schluessel der Frage im Dialog. Er unterscheidet gleichnamige Felder an verschiedenen Stellen ("hoehe" ist am Kettenende eine andere Frage als am Auf/Ab-Segment) und ist damit der Anker fuer die Annotationstexte des Protokolls. """ # --- Struktur --- def praeambel_start(self): """Kettenkopf beginnt (Startpunkt/Starthoehe/AS-Element).""" def praeambel_ende(self): """Kettenkopf fertig.""" def sektion_start(self, label): """STEP-Marker eines Gliedes verbraucht; label = Segmenttyp.""" def glied_gewaehlt(self, label, code, frage): """Menue-Antwort zum Glied verbraucht (STR, bereits geprueft).""" def sektion_ende(self): """Glied fertig.""" def marke(self, label): """STEP-Marker OHNE eigene Menue-Antwort (Vario-Kurve, ES-Untermarke im kettenende-Zweig).""" def vf_start(self): """VF-Einheit beginnt; die folgenden Felder gehoeren zu ihr.""" def vf_ende(self): """VF-Einheit fertig.""" def sub_start(self, sub): """Sub-Knoten der VF-Einheit beginnt (motorstation/kettenende/ horizontal/vario-kurve/auf-ab).""" def sub_ende(self): """Sub-Knoten fertig.""" # --- Werte --- def punkt_abs(self, punkt): """PT verbraucht - der absolute Startpunkt der Kette.""" def linie(self, dl, hz, frage): """DL (+ REAL beim ersten Segment der Kette) verbraucht.""" def real_feld(self, name, wert, frage): """REAL verbraucht (Hoehe, Winkel, ...).""" def int_feld(self, name, wert, frage): """INT verbraucht (Bogen-/Kurvenwinkel, Winkelwahl-Index).""" def code_feld(self, name, code, wert, frage): """STR ueber eine Code-Tabelle verbraucht; wert ist der Domaenenwert, code die rohe Menue-Antwort aus dem Journal.""" def roh_feld(self, name, wert, frage): """STR, dessen Inhalt schon der Wert ist (AS-/ES-Winkel).""" def ja_nein(self, name, code, wert, frage): """STR ueber die Ja/Nein-Tabelle verbraucht; wert ist True/False.""" def wahl(self, name, code, wert, frage): """Eine Antwort, die sich in der STRUKTUR niederschlaegt statt in einem Feld (Endpunkt-Frage, Auswahl in der VF-Einheit). Die Spec speichert sie nicht - sie steckt im Sub-Knoten -, das Protokoll muss sie trotzdem zeigen.""" def implizit(self, name, wert): """Ein Feld, das der Ablauf ERGIBT, ohne dass ein Token dafuer gelesen wurde (segment_typ, das automatische VF-Ende). Umgekehrter Fall zu wahl(): die Spec braucht es, das Protokoll nicht.""" def ergebnis(self): """Das fertige Ergebnis nach dem letzten Token.""" raise NotImplementedError # ============================================================ # Parser-Kern # ============================================================ class Kern(object): """Laeuft den Frageablauf von vf-linienzug-modus (Modus 1) EINMAL ab: Praeambel: Startpunkt, Starthoehe, AS-Element (Ja/Nein, Winkel, Seite) Sektionsschleife je Glied: STEP-Marker, Menuewahl, dann die Fragen des gewaehlten Segmenttyps VF-Einheit (Horizontal-VF / Linie-VF / Linie): GF-Verteilung, ggf. Separator-Fragen des horizontalen Erstkoerpers, dann die Fortsetzungsschleife (Horizontal / Vario-Kurve / Auf-Ab) bis Motorstation oder Kettenende, danach der Abschluss Kettenende: ES-Glied (Winkel, Seite) Was dabei herauskommt, bestimmt allein die Sink. """ def __init__(self, tok, sink, grammatik=GRAMMATIK_AUFZEICHNUNG): self.tok = list(tok) self.sink = sink self.grammatik = grammatik self.pos = 0 self.erster_step = True self.erster_dl = True # --- Tokenzugriff --- def art(self): return self.tok[self.pos][0] if self.pos < len(self.tok) else None def label(self): return self.tok[self.pos][1] if self.pos < len(self.tok) else None def nimm(self, erwartet): if self.pos >= len(self.tok): raise ValueError("Journal endet vor %s" % erwartet) art, wert = self.tok[self.pos] if art != erwartet: raise ValueError("Token %d: %s erwartet, %s:%s gefunden" % (self.pos, erwartet, art, wert)) self.pos += 1 return wert def real(self): return float(self.nimm("REAL")) def ganz(self): return int(self.nimm("INT")) # --- Werte lesen und melden --- def _code(self, name, tabelle, was, frage): code = self.nimm("STR") wert = wert_aus_code(tabelle, code, was) self.sink.code_feld(name, code, wert, frage) return wert def _wahl(self, name, tabelle, was, frage): code = self.nimm("STR") wert = wert_aus_code(tabelle, code, was) self.sink.wahl(name, code, wert, frage) return wert def _ja(self, name, was, frage): code = self.nimm("STR") wert = ja_aus_code(code, was) self.sink.ja_nein(name, code, wert, frage) return wert def _linie(self, frage): """Segmentlaenge, und optional die gesnappte Fahrtrichtung (vfl-in-abstand journalisiert sie bei freier Richtungswahl; ein Segment, dessen Richtung schon feststeht, erbt sie vom Vorgaenger und hat keinen eigenen Journal-Eintrag). Grammatik-gebunden (linie_hz_je_segment), NICHT per blindem Lookahead: ein direkt auf DL folgendes REAL kann auch "eingang_hoehe" einer Horizontal-VF-Sektion sein (siehe _opt_eingang_hoehe, dort IMMER ein zweiter, eigener Lookahead direkt danach) - ein Lookahead hier ohne Grammatik-Wissen wuerde das faelschlich als hz konsumieren und die Queue verschieben. 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. Ausnahme "kettenende_ziel" (vf_einheit, Option 3 "Kettenende"): Lisp/vf_linienzug.lsp:7102 uebergibt vfl-body-abschluss dort (car (frame->hz-winkel frame)) - die Richtung wird aus dem bestehenden Frame ABGELEITET, nie erfragt. vfl-neue-linie-messen (Zeile 4829) misst danach nur noch die Distanz. Ein REAL direkt nach dem DL ist an dieser Stelle darum IMMER die Zielhoehe, nie ein hz - wurde bisher fälschlich als hz verschluckt (Token-Desync am Kettenende, siehe hundm05.json LZ_01/VF_0-Rekonstruktion).""" dl = float(self.nimm("DL")) if frage == "kettenende_ziel": erwartet = False else: erwartet = self.erster_dl or self.grammatik.linie_hz_je_segment hz = self.real() if (erwartet and self.art() == "REAL") else None self.erster_dl = False self.sink.linie(dl, hz, frage) def _opt_winkel_idx(self): """Die Winkelwahl (vfl-waehle-winkel) steht nur im Journal, wenn mehrere Kandidatenwinkel gueltig waren - sonst faellt die Frage weg.""" if self.art() == "INT": self.sink.int_feld("winkel_idx", self.ganz(), "winkel_idx") def _opt_gf2_bestaetigung(self): """Stufe-2-Fallback (vfl-frage-gf2-minimum, Lisp/vf_linienzug.lsp): steht nur im Journal, wenn die am Kettenende automatisch berechnete GF2-Laenge UNTER der ueblichen Mindestlaenge liegt (Stufe 1 - Winkeltabelle - hat dann keinen gueltigen Kandidaten geliefert). "ja" = mit der exakt berechneten (kuerzeren) Laenge bauen (trifft den Zielpunkt exakt), "nein" = auf die Mindestlaenge auffuellen (Zielpunkt verschiebt sich entsprechend). Faellt die Frage nicht an (GF2 lang genug oder Stufe 1 erfolgreich), gibt es keinen Journal-Eintrag - genau wie bei _opt_winkel_idx.""" if self.art() == "STR": self._ja("gf2_exakt_bauen", "GF2 unter Mindestlaenge - exakt bauen", "gf2_minimum") def _opt_eingang_hoehe(self): """Ziel-Hoehe fuer den GF1-Einlauf einer mit "Horizontal-VF" eroeffneten VF-Einheit (vfl-prompt-hoehe-eingang-gf1). Sie wird nur gefragt, wenn der Eingang KOMPLETT neu gebaut wird (vfl-eingang-modus-bestimmen liefert nil) - also am echten Kettenanfang und nach einer fertigen VF-Einheit, nicht dagegen im Anschluss an GF/Separator/Vario-Kurve. Ob sie ansteht, haengt am Zustand der Zeichnung und laesst sich hier nicht ausrechnen; am Journal ist sie eindeutig (ein REAL/NIL vor der ersten Menue-Antwort der VF-Einheit). "auto" (statt einer Zahl, Gegenstueck zu vsp-eingang-hoehe in Lisp/vf_spec.lsp): die Frage kommt, aber es wird bewusst KEINE Zahl vorgegeben - wie eine leere Konsoleneingabe laesst das den Bauablauf seinen eigenen, aus dem Fussabdruck der festen Elemente berechneten Vorschlag nehmen. Dafuer steht ein NIL-Token (statt REAL) im Journal.""" if self.art() == "REAL": self.sink.real_feld("eingang_hoehe", self.real(), "eingang_hoehe") elif self.art() == "NIL": self.nimm("NIL") self.sink.roh_feld("eingang_hoehe", "auto", "eingang_hoehe") # --- Ablauf --- def lies(self): self.praeambel() self.sektionen() if self.pos != len(self.tok): raise ValueError("nicht alle Tokens verbraucht (%d von %d)" % (self.pos, len(self.tok))) return self.sink.ergebnis() def praeambel(self): self.sink.praeambel_start() self.sink.punkt_abs([float(v) for v in self.nimm("PT").split(",")]) self.sink.real_feld("hoehe", self.real(), "start_hoehe") if self._ja("as", "AS-Element setzen", "as"): self.sink.roh_feld("as_winkel", self.nimm("STR"), "as_winkel") self._code("as_seite", SEITE, "AS-Seite", "as_seite") self.sink.praeambel_ende() def sektionen(self): while self.pos < len(self.tok): label = self.nimm("STEP") if label == "ES": self.sink.sektion_start("ES") self.es_glied() self.sink.sektion_ende() continue if label == "Vario-Kurve": raise ValueError("Vario-Kurve-Marker ausserhalb einer " "VF-Einheit (Token %d)" % (self.pos - 1)) self.sink.sektion_start(label) tabelle = (self.grammatik.menu_start if self.erster_step else MENU_WEITER) frage = "menu_start" if self.erster_step else "menu_weiter" code = self.nimm("STR") soll = tabelle.get(label) if soll is None: raise ValueError("Glied %r ist an dieser Stelle nicht " "waehlbar (Token %d)" % (label, self.pos - 1)) if code != soll: raise ValueError("Glied %r erwartet Menue-Code %r, Journal " "hat %r (Token %d)" % (label, soll, code, self.pos - 1)) self.erster_step = False self.sink.glied_gewaehlt(label, code, frage) self.glied(label) self.sink.sektion_ende() def es_glied(self): self.sink.roh_feld("winkel", self.nimm("STR"), "es_winkel") self._code("seite", SEITE, "ES-Seite", "es_seite") def glied(self, label): if label == "GF-Bogen": self.sink.int_feld("winkel", self.ganz(), "gf_bogen_winkel") self._code("seite", SEITE, "GF-Bogen-Seite", "gf_bogen_seite") elif label == "Linie-GF" and not self.grammatik.linie_gf_laenge_modus: # Eingefrorene Aufzeichnung (GRAMMATIK_AUFZEICHNUNG): stammt von # VOR der "laenge_modus"-Erweiterung - Laenge/Gefaelle direkt, # "Ist das Kettenende?" ganz am Schluss. Siehe Grammatik-Doku. self._linie("gf_linie") if self._code("gefaelle", GEFAELLE, "Gefaelle", "gefaelle") == "winkel": self.sink.real_feld("winkel", self.real(), "gf_winkel") else: self.sink.real_feld("hoehe", self.real(), "gf_hoehe") self._code("ende", ENDE3, "Kettenende", "ende3") elif label == "Linie-GF": # Reihenfolge seit 2026-09-12 (siehe Lisp/vf_spec.lsp # vsp-sektion, dortiger Kommentar): erst die Vorwahl # "laenge_modus" (endpunkt/hoehe/steigend), DANN sofort "Ist # das Kettenende?" - beide muessen VOR den modus-spezifischen # Feldern gelesen werden, der alte Ablauf (Laenge/Gefaelle # zuerst, Kettenende-Frage zuletzt) ist veraltet. modus = self._code("laenge_modus", GF_LAENGE_MODUS, "GF-Laengenmodus", "gf_laenge_modus") ende = self._code("ende", ENDE3, "Kettenende", "ende3") if ende == "ja-mit-es": # Das ES-Element wird HIER inline erfragt (Winkel/Seite, # OHNE eigenen STEP-Marker) - noch nicht decodierbar, bisher # in keiner Aufzeichnung vorgekommen. Fail fast statt falsch # zu raten (siehe Modul-Kopf, Invariante I3). raise ValueError( "Linie-GF mit ende=\"ja-mit-es\" wird von diesem Parser " "noch nicht unterstuetzt (Token %d)" % (self.pos - 1)) if modus == "steigend": smodus = self._code("steigend_modus", GF_LAENGE_MODUS, "Steigend-Modus", "gf_laenge_modus") if smodus == "endpunkt": self._linie("gf_linie") elif self.erster_dl: raise ValueError( "Linie-GF steigend/%s als allererstes Segment der " "Kette wird von diesem Parser noch nicht " "unterstuetzt (Token %d)" % (smodus, self.pos - 1)) if smodus == "hoehe": self.sink.real_feld("hoehe", self.real(), "gf_hoehe") self.sink.real_feld("winkel", self.real(), "gf_winkel") elif modus == "hoehe": if self.erster_dl: raise ValueError( "Linie-GF hoehe als allererstes Segment der Kette " "wird von diesem Parser noch nicht unterstuetzt " "(Token %d)" % (self.pos - 1)) self._ja("sep_vor", "Separator vor", "sep_vor") self._ja("sep_nach", "Separator nach", "sep_nach") self.sink.real_feld("hoehe", self.real(), "gf_hoehe") self.sink.real_feld("winkel", self.real(), "gf_winkel") else: # "endpunkt" self._linie("gf_linie") self._ja("sep_vor", "Separator vor", "sep_vor") self._ja("sep_nach", "Separator nach", "sep_nach") if self._code("gefaelle", GEFAELLE, "Gefaelle", "gefaelle") == "winkel": self.sink.real_feld("winkel", self.real(), "gf_winkel") else: self.sink.real_feld("hoehe", self.real(), "gf_hoehe") elif label == "Linie-VF": self._linie("vf_linie") self.sink.real_feld("hoehe", self.real(), "vf_zielhoehe") self._opt_winkel_idx() self.vf_einheit(auto_ende=False, erst_hor=False) elif label == "Horizontal-VF": self._linie("horizontal_vf") self._opt_eingang_hoehe() self.vf_einheit(auto_ende=False, erst_hor=True) elif label == "Linie": self._linie("linie_kettenende") self.sink.real_feld("hoehe", self.real(), "kettenende_hoehe") self._opt_winkel_idx() # vfl-segment-entscheidung waehlt selbst zwischen reiner # Gefaellestrecke und VF-Einheit. Am Journal erkennbar: eine # VF-Einheit beginnt mit der GF-Verteilungsfrage (STR), eine # Gefaellestrecke geht direkt zum ES-Glied (STEP) oder endet. if self.art() == "STR": self.sink.implizit("segment_typ", "VF") self.vf_einheit(auto_ende=True, erst_hor=False) else: self.sink.implizit("segment_typ", "GF") else: raise ValueError("unbekanntes Glied: %r" % label) def vf_einheit(self, auto_ende, erst_hor): self.sink.vf_start() # GF-Verteilung: bei einem horizontalen Erstkoerper stellt der heutige # Ablauf die Frage nicht mehr (fest "2" = alles am Einlauf) - siehe # Grammatik.horizontal_vf_fragt_gf_verteilung. if not erst_hor or self.grammatik.horizontal_vf_fragt_gf_verteilung: self._code("gf_verteilung", VERTEILUNG, "GF-Verteilung", "gf_verteilung") if erst_hor: # Erster Koerper horizontal: vfl-baue-horizontal-koerper stellt # die Separator-Fragen VOR der Laengenberechnung. Die # Endpunkt-Antwort danach gehoert schon zum naechsten Knoten und # wird darum unten in der Schleife gelesen. self._ja("sep_vor", "Separator vor", "sep_vor") self._ja("sep_nach", "Separator nach", "sep_nach") ziel_ende = False es_gewuenscht = True while True: was = self._wahl("endpunkt", ENDPUNKT, "Endpunkt des Foerderers", "endpunkt") if was == "motorstation": self.sink.sub_start("motorstation") self.sink.sub_ende() break if was == "kettenende": self.sink.sub_start("kettenende") es_gewuenscht = self._ja("es", "ES-Element setzen", "es") if es_gewuenscht: # Winkel+Seite des ES-Elements werden HIER erfragt (nicht # erst in vfl-vf-einheit-abschluss): sie bestimmen den # ES-Fussabdruck, der schon in die Streckenzerlegung # eingeht. Der Marker kommt aus vfl-frage-es-seite. marke = self.nimm("STEP") if marke != "ES": raise ValueError("ES-Marker erwartet, %r gefunden" % marke) self.sink.marke("ES") self.sink.roh_feld("es_winkel", self.nimm("STR"), "es_winkel") self._code("es_seite", SEITE, "ES-Seite", "es_seite") self._linie("kettenende_ziel") self.sink.real_feld("hoehe", self.real(), "kettenende_hoehe") self._opt_winkel_idx() self._opt_gf2_bestaetigung() self.sink.sub_ende() ziel_ende = True break wahl = self._wahl("im_vf", IM_VF, "Naechstes in der VF-Einheit", "im_vf") self.sink.sub_start(wahl) if wahl == "horizontal": self._linie("horizontal_sub") self._ja("sep_vor", "Separator vor", "sep_vor") self._ja("sep_nach", "Separator nach", "sep_nach") elif wahl == "vario-kurve": marke = self.nimm("STEP") if marke != "Vario-Kurve": raise ValueError("Vario-Kurve-Marker erwartet, %r " "gefunden" % marke) self.sink.marke("Vario-Kurve") self.sink.int_feld("winkel", self.ganz(), "vario_winkel") self._code("seite", SEITE, "Vario-Kurve-Seite", "vario_seite") self._code("variante", VARIANTE, "Vario-Kurve-Variante", "vario_variante") else: # auf-ab self._linie("aufab_sub") # Zwei Auspraegungen, am naechsten Token erkennbar (Kern kennt # keinen eigenen "Modus"-Schalter, vf-linienzug-modus # entscheidet selbst anhand des Zeichnungszustands): kommt die # Kette bereits FLACH an, folgt eine STR-Richtungswahl (Auf/ # Ab) + Winkeltabelle statt einer direkten Zielhoehe (REAL). if self.art() == "STR": self._code("richtung", RICHTUNG, "Auf/Ab-Richtung", "aufab_richtung") self._opt_winkel_idx() else: self.sink.real_feld("hoehe", self.real(), "aufab_hoehe") self._opt_winkel_idx() self.sink.sub_ende() # --- Abschluss (vfl-vf-einheit-abschluss) --- # Ohne ES-Wunsch am Zielpunkt endet die Kette dort direkt; bei # auto_ende (Glied "Linie") oder erreichtem Zielpunkt folgt das # ES-Glied ohne Frage. Sonst wird gefragt. if ziel_ende and not es_gewuenscht: self.sink.implizit("ende", "zielpunkt-ohne-es") elif auto_ende or ziel_ende: self.sink.implizit("ende", "automatisch") else: if self._code("ende", ENDE2, "Ist das Kettenende", "ende2") != "ja-mit-es": self._ja("separator", "Separator an dieser Stelle", "separator") self.sink.vf_ende() def lies_journal(tok, sink, grammatik=GRAMMATIK_AUFZEICHNUNG): """Tokenfolge durch den gemeinsamen Kern laufen lassen und das Ergebnis der Sink zurueckgeben.""" return Kern(tok, sink, grammatik).lies()