Files
dxfmakros/lib/vf_linienzug_grammatik.py
T
Samer AyadiandClaude Sonnet 5 4a057862a8 fix: VF-Linienzug Journal-Reset, Spec-Grammatik (auf-ab/Linie-GF) und hz-Bug in HundM05
- 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>
2026-09-15 15:13:07 +02:00

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()