- vfl-journal-reset-lauf ans Funktionsanfang verschoben (Modus 1/2/3,
Lisp/vf_linienzug.lsp): stand bisher nach der Startpunkt/Hoehe/AS-
Praeambel und loeschte diese beim Reset selbst wieder - Journal begann
mit dem ersten STEP-Marker statt PT, betraf jede Kette.
- Spec-Grammatik erweitert (Lisp/vf_spec.lsp, lib/vf_linienzug_grammatik.py,
lib/vf_spec_export.py): "auf-ab" bei bereits flach ankommender Kette
(Richtungswahl statt Zielhoehe) und neue Linie-GF-Fragereihenfolge
(Kettenende direkt nach laenge_modus, inkl. vorgezogenem ES). Tests in
tests/test_vf_spec.lsp/.py angepasst.
- lib/vf_linienzug_grammatik.py: _linie("kettenende_ziel") erwartete
faelschlich ein hz-REAL nach der Distanz - der reale Bauablauf
(vfl-body-abschluss) leitet die Richtung dort aus dem Frame ab und
fragt sie nie. Fuehrte zu einem Token-Desync beim Dekodieren echter
Kettenende-Journale.
- tests/testdata/hundm05.json (VF_hundm05_LZ_01): 5 ueberfluessige
hz-Felder entfernt. vsp-linie injiziert bei JEDEM vorhandenen hz-Feld
einen Richtungs-Token, unabhaengig vom tatsaechlichen Bauablauf - die
Felder desyncten den Replay und liessen die Kette stumm mit der
falschen (verkuerzten) Geometrie fertig bauen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
746 lines
35 KiB
Python
746 lines
35 KiB
Python
#!/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()
|