refactor: VF-Linienzug-Journal-Grammatik in lib/vf_linienzug_grammatik.py gebuendelt

lib/vf_journal_export.py (Journal.dekodiere) und lib/vf_spec_export.py
(SpecLeser) implementierten denselben Tokenwalk (Praeambel, Sektions-
schleife, Glied-Dispatch, VF-Einheit-Unterschleife) sowie dieselben
Code-Tabellen (SEITE/VARIANTE/VERTEILUNG/IM_VF/ENDPUNKT/ENDE2/ENDE3/
GEFAELLE/GF_LAENGE_MODUS, MENU_START/MENU_START_AKTUELL/MENU_WEITER,
Grammatik) unabhaengig voneinander - Aenderungen an der Grammatik (wie
im vorigen Commit) mussten von Hand synchron gehalten werden und sind
bereits einmal auseinandergelaufen.

Neuer gemeinsamer Parser-Kern (Kern/lies_journal) plus Sink-Schnittstelle
(SpecSink in vf_spec_export.py, ProtokollSink in vf_journal_export.py)
ersetzt die doppelte Implementierung; Code-Tabellen/Grammatik-Objekte
leben jetzt nur noch in lib/vf_linienzug_grammatik.py. Journal behaelt
nur noch die reine XDATA/DXF-Zerlegung, SpecSchreiber (Spec -> Journal,
Gegenstueck zu Lisp/vf_spec.lsp) bleibt unveraendert in vf_spec_export.py.

Zusaetzlich: pruefe_flachen_freitext schlaegt jetzt hart fehl, wenn
generierter Freitext (quelle/beschreibung) ein rohes Anfuehrungszeichen
enthaelt - der Lisp-seitige Flach-JSON-Leser (ssg-cfg-parse-value) kennt
keine Escapes und wuerde daran die Zeichenkette (und damit die gesamte
restliche Datei) falsch zerlegen.

Byte-identischer Output gegen die Baseline geprueft (beide CLIs, echte
Eingaben); pytest tests/test_vf_spec.py + test_vf_journal_grammatik.py
unveraendert: 2 bekannte, vorbestehende Fehlschlaege, 38 bestanden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-09-14 11:59:22 +02:00
co-authored by Claude Sonnet 5
parent 1bdecf0678
commit 6d15c6be13
3 changed files with 1023 additions and 610 deletions
+237 -294
View File
@@ -14,6 +14,12 @@ ist die vollstaendige, wiederabspielbare Bau-Anleitung der Kette
vfl-entry->string wieder zurueck und annotiert jeden Wert mit der Frage, die
er beantwortet - abgeleitet aus dem Frageablauf von vf-linienzug-modus.
Den Frageablauf selbst laeuft der GEMEINSAME Parser-Kern
(lib/vf_linienzug_grammatik.py), den sich dieses Skript mit
lib/vf_spec_export.py teilt: dort steht die Grammatik EINMAL, hier nur noch
die ProtokollSink mit den Annotationstexten. Frueher war der Ablauf in beiden
Dateien von Hand nachgebaut und lief regelmaessig auseinander.
Bewusst OHNE ezdxf: gebraucht werden nur INSERT-Kopf (Blockname, Einfuegepunkt,
Drehung) und die 1000er-Gruppen der XDATA - das ist reine Gruppencode-Paar-
Lesung und funktioniert auch bei ASCII-DXF im 100-MB-Bereich.
@@ -47,6 +53,14 @@ import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Grammatik (Code-Tabellen, Menue-Zuordnung) UND Frageablauf kommen aus dem
# gemeinsamen Modul - hier steht nur noch die Annotation dazu. SEITE/VARIANTE
# braucht zusaetzlich die Kurzbeschreibung (kurzbeschreibung): sie liest die
# fertigen Protokolleintraege nach und hat dort nur den rohen Menue-Code.
from vf_linienzug_grammatik import ( # noqa: E402
GRAMMATIK_AUFZEICHNUNG, SEITE, Sink, VARIANTE, lies_journal)
PREFIX = "[vf_journal_export]"
XDATA_APP = "SSG_VF_EDIT"
@@ -60,25 +74,6 @@ RUND = 6
# Anlagenkuerzel in den test_ids (VF_<kuerzel>_LZ_01 ...).
TEST_ID_ANLAGE = "hundm05"
# Antwort-Klartexte der Menues aus vf_linienzug.lsp (nur zur Annotation).
JA_NEIN = {"1": "Ja", "2": "Nein"}
SEITE = {"1": "links", "2": "rechts"}
VARIANTE = {"1": "aussen", "2": "innen"}
GF_MODUS = {"1": "ueber Zielhoehe", "2": "ueber Neigungswinkel"}
ENDPUNKT3 = {"1": "Ja, nur Motorstation", "2": "Nein, weiterbauen",
"3": "Ja, Motorstation + Kettenende am Zielpunkt"}
KETTENENDE3 = {"1": "Ja, mit ES-Element", "2": "Ja, ohne ES-Element",
"3": "Nein, weiterbauen"}
KETTENENDE2 = {"1": "Ja, Separator + ES-Element", "2": "Nein, weiterbauen"}
VERTEILUNG = {"1": "halbe Staustrecke am Ausgang", "2": "alles am Einlauf"}
IM_VF = {"1": "Horizontaler Foerderer", "2": "Vario-Kurve",
"3": "Auf/Ab-Foerderer"}
MENU_START = {"1": "Neue Linie: GF", "2": "Neue Linie: Ab/Auf VF",
"3": "Horizontal-VF", "4": "Neue Linie bis Kettenende"}
MENU_WEITER = {"1": "GF-Bogen", "2": "Neue Linie: GF",
"3": "Neue Linie: Ab/Auf VF", "4": "Horizontal-VF",
"5": "Neue Linie bis Kettenende"}
STEP_KOMMENTAR = ("Glied-Marker (vfl-journal-mark) - beim Replay "
"uebersprungen, Label = gewaehlter Segmenttyp")
@@ -148,50 +143,155 @@ def lese_vf_inserts(dxf_pfad):
# ============================================================
# Journal dekodieren
# ============================================================
# Den Frageablauf laeuft der gemeinsame Kern in lib/vf_linienzug_grammatik.py
# (dieselbe Routine, die lib/vf_spec_export.py fuer die Spec benutzt). Hier
# steht nur noch die ANNOTATION: zu jeder Frage die Konsolen-Vorlage aus
# vf_linienzug.lsp und - wo die Domaenen-Kurzform nicht schon der
# Antworttext ist - der Klartext der Antwort.
class Journal(object):
"""Ein Journal (kind:payload je Zeile) in das Frage-Antwort-Protokoll
uebersetzen. Der Ablauf folgt vf-linienzug-modus (Modus 1):
# Prompt-Vorlagen je Frage-Schluessel (siehe Sink-Doku im Grammatik-Modul).
# Ein Schluessel, nicht der Feldname: dasselbe Feld ("hoehe") beantwortet an
# verschiedenen Stellen verschiedene Fragen.
Praeambel: Startpunkt, Starthoehe, AS-Element (Ja/Nein, Winkel, Seite)
Hauptschleife je Glied: STEP-Marker, Menuewahl, dann die Fragen des
gewaehlten Segmenttyps
Innerhalb einer VF-Einheit (Horizontal-VF / Linie): Separator vor/nach,
"Ist der Endpunkt der Foerderer?", danach Horizontal / Vario-Kurve /
Auf-Ab bis die Einheit schliesst
Kettenende: ES-Glied (Winkel, Seite)
_REAL_FRAGEN = {
"start_hoehe": "getreal Hoehe (Z) Startpunkt -> %.1f mm",
"gf_winkel": "getreal Neigungswinkel -> %.1f Grad",
"gf_hoehe": "getreal Zielhoehe (Z) Linienendpunkt -> %.1f mm",
"vf_zielhoehe": "getreal Zielhoehe (Z) Linienendpunkt -> %.1f mm",
"kettenende_hoehe": "getreal Zielhoehe (Z) am Kettenende -> %.1f mm",
"aufab_hoehe": "getreal Zielhoehe (Z) am Segmentende -> %.1f mm",
"eingang_hoehe": "getreal Ziel-Hoehe fuer den GF1-Einlauf der "
"VF-Einheit (nur bei komplett neu gebautem Eingang) "
"-> %.1f mm",
}
_INT_FRAGEN = {
"gf_bogen_winkel": "getint GF-Bogen Winkel (30/60/90 Grad) -> %d",
"vario_winkel": "getint Vario-Kurve Winkel (30/60/90 Grad, Wert "
"direkt) -> %d Grad",
"winkel_idx": "getint Winkelwahl aus den gueltigen Kandidaten "
"(vfl-waehle-winkel) -> Option %d",
}
# STR-Antworten, deren Inhalt schon der Wert ist (kein Code).
_ROH_FRAGEN = {
"as_winkel": "getstring AS-Element Winkel (Wert direkt, 90 oder 30 "
"Grad) -> %s Grad",
"es_winkel": "getstring ES-Element Winkel (Wert direkt, 90 oder 30 "
"Grad) -> %s Grad",
}
_JA_NEIN_FRAGEN = {
"as": "getstring AS-Element setzen? (1=Ja, 2=Nein) -> %s",
"sep_vor": "getstring Separator VOR dem horizontalen Stueck? (1=Ja, "
"2=Nein) -> %s",
"sep_nach": "getstring Separator NACH dem horizontalen Stueck? (1=Ja, "
"2=Nein) -> %s",
"es": "getstring ES-Element setzen? (1=Ja, 2=Nein - Kette endet direkt "
"am Zielpunkt) -> %s",
"separator": "getstring Separator an dieser Stelle einfuegen? (1=Ja, "
"2=Nein) -> %s",
}
# Die Menue-Antwort auf "Naechstes Element" ist der Glied-Name; sein
# Klartext ist derselbe am Kettenanfang wie spaeter, nur die aufgezaehlten
# Optionen im Fragetext unterscheiden sich. Darum EINE Antworttabelle fuer
# beide Fragen (frueher zwei code-indizierte Tabellen nebeneinander).
_GLIED_TEXT = {"GF-Bogen": "GF-Bogen", "Linie-GF": "Neue Linie: GF",
"Linie-VF": "Neue Linie: Ab/Auf VF",
"Horizontal-VF": "Horizontal-VF",
"Linie": "Neue Linie bis Kettenende"}
# STR-Antworten ueber eine Code-Tabelle: (Vorlage, Klartext je Domaenenwert).
# Steht statt der Klartexte None, ist der Domaenenwert selbst schon der
# Antworttext ("links"/"rechts", "aussen"/"innen").
#
# ACHTUNG: die Fragetexte zaehlen die Menue-Optionen der AUFZEICHNUNG auf
# (4 Optionen am Kettenanfang). Wird das Protokoll je gegen die aktuelle
# Grammatik erzeugt (GRAMMATIK_AKTUELL, 6 Optionen), muss "menu_start" hier
# mitgezogen werden - die Antworten selbst bleiben richtig.
_CODE_FRAGEN = {
"as_seite": ("getstring AS-Seite (1=links, 2=rechts) -> %s", None),
"es_seite": ("getstring ES-Seite (1=links, 2=rechts) -> %s", None),
"gf_bogen_seite": ("getstring GF-Bogen Seite (1=links, 2=rechts) -> %s",
None),
"vario_seite": ("getstring Vario-Kurve Seite (1=links, 2=rechts) -> %s",
None),
"vario_variante": ("getstring Vario-Kurve Variante (1=aussen, 2=innen) "
"-> %s", None),
"gefaelle": ("getstring Gefaelle festlegen (1=ueber Zielhoehe, 2=ueber "
"Neigungswinkel) -> %s",
{"hoehe": "ueber Zielhoehe",
"winkel": "ueber Neigungswinkel"}),
"ende3": ("getstring Ist das Kettenende? (1=Ja mit ES, 2=Ja ohne ES, "
"3=Nein weiterbauen) -> %s",
{"ja-mit-es": "Ja, mit ES-Element",
"ja-ohne-es": "Ja, ohne ES-Element",
"nein": "Nein, weiterbauen"}),
"ende2": ("getstring Ist das Kettenende? (1=Ja, Separator + ES-Element, "
"2=Nein, weiterbauen) -> %s",
{"ja-mit-es": "Ja, Separator + ES-Element",
"nein": "Nein, weiterbauen"}),
"gf_verteilung": ("getstring GF-Verteilung (1=halbe Staustrecke am "
"Ausgang, 2=alles am Einlauf) -> %s",
{"haelfte": "halbe Staustrecke am Ausgang",
"einlauf": "alles am Einlauf"}),
"endpunkt": ("getstring Ist der Endpunkt der Foerderer? (1=Ja nur "
"Motorstation, 2=Nein weiterbauen, 3=Ja Motorstation + "
"Kettenende) -> %s",
{"motorstation": "Ja, nur Motorstation",
"weiter": "Nein, weiterbauen",
"kettenende": "Ja, Motorstation + Kettenende am "
"Zielpunkt"}),
"im_vf": ("getstring Naechstes in der VF-Einheit (1=Horizontaler "
"Foerderer, 2=Vario-Kurve, 3=Auf/Ab-Foerderer) -> %s",
{"horizontal": "Horizontaler Foerderer",
"vario-kurve": "Vario-Kurve",
"auf-ab": "Auf/Ab-Foerderer"}),
"menu_start": ("getstring Naechstes Element am Kettenanfang (1=Neue "
"Linie GF, 2=Neue Linie Ab/Auf VF, 3=Horizontal-VF, "
"4=Neue Linie bis Kettenende) -> %s", _GLIED_TEXT),
"menu_weiter": ("getstring Naechstes Element (1=GF-Bogen, 2=Neue Linie "
"GF, 3=Neue Linie Ab/Auf VF, 4=Horizontal-VF, 5=Neue "
"Linie bis Kettenende) -> %s", _GLIED_TEXT),
}
# Kontext der Laengeneingabe je Frage-Schluessel (die "%s"-Stelle im
# getpoint-Kommentar).
_LINIE_TEXT = {
"gf_linie": "Endpunkt der GF-Linie",
"vf_linie": "Endpunkt des Ab/Auf-VF-Segments",
"horizontal_vf": "Endpunkt des horizontalen VF-Stuecks",
"horizontal_sub": "Endpunkt des horizontalen VF-Stuecks",
"aufab_sub": "Endpunkt des Auf/Ab-VF-Segments",
"linie_kettenende": "Endpunkt der Linie bis Kettenende (Ziel-XY der "
"Kette)",
"kettenende_ziel": "Zielpunkt des Kettenendes",
}
def _vorlage(tabelle, frage):
if frage not in tabelle:
raise ValueError("keine Annotation fuer die Frage %r hinterlegt "
"(lib/vf_journal_export.py)" % frage)
return tabelle[frage]
class ProtokollSink(Sink):
"""Schreibt aus den Meldungen des Parser-Kerns das annotierte
Frage-Antwort-Protokoll (Schema tests/testdata/hm_recformat.json):
je verbrauchtem Wert ein Eintrag {"typ", Wertfeld(er), "kommentar"}.
Die Reihenfolge der Schluessel im Eintrag ist fest (typ, Wert,
kommentar) - das erzeugte JSON wird von Hand gelesen und mit frueheren
Laeufen verglichen.
"""
def __init__(self, roh):
self.tok = []
for zeile in roh.split(TRENNER):
if zeile:
art, _, wert = zeile.partition(":")
self.tok.append((art, wert))
self.pos = 0
def __init__(self):
self.out = []
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
# --- Ausgabe ---
def ergebnis(self):
return self.out
def add(self, typ, kommentar, **felder):
eintrag = {"typ": typ}
@@ -199,253 +299,96 @@ class Journal(object):
eintrag["kommentar"] = kommentar
self.out.append(eintrag)
def frage_str(self, kommentar_fmt, tabelle=None):
w = self.nimm("STR")
self.add("string", kommentar_fmt % (tabelle.get(w, "?") if tabelle
else w), wert=w)
return w
# --- Struktur: nur die Glied-Marker stehen im Protokoll ---
def frage_real(self, kommentar_fmt):
w = float(self.nimm("REAL"))
self.add("real", kommentar_fmt % w, wert=w)
return w
def frage_int(self, kommentar_fmt):
w = int(self.nimm("INT"))
self.add("int", kommentar_fmt % w, wert=w)
return w
def marker(self, label):
def sektion_start(self, label):
self.add("step", STEP_KOMMENTAR, wert=label)
def frage_linie(self, was):
"""Segmentlaenge; die gesnappte Fahrtrichtung steht nur beim ersten
Segment der Kette im Journal (vfl-in-abstand), danach erbt jedes
Segment die Richtung des Vorgaengers."""
dl = float(self.nimm("DL"))
if self.erster_dl and self.art() == "REAL":
hz = float(self.nimm("REAL"))
self.erster_dl = False
def marke(self, label):
self.add("step", STEP_KOMMENTAR, wert=label)
def glied_gewaehlt(self, label, code, frage):
vorlage, texte = _vorlage(_CODE_FRAGEN, frage)
self.add("string", vorlage % texte[label], wert=code)
# --- Werte ---
def punkt_abs(self, punkt):
self.add("point_abs", "getpoint Startpunkt der Kette (absoluter "
"Weltpunkt, einziger Anker der ganzen Kette)", wert=punkt)
def linie(self, dl, hz, frage):
was = _vorlage(_LINIE_TEXT, frage)
if hz is None:
self.add("point_rel",
"getpoint %s: dL=%.1f mm (Fahrtrichtung vom "
"Vorgaenger geerbt)" % (was, dl), dL=dl)
else:
self.add("point_rel",
"getpoint %s: dL=%.1f mm, gesnappte Fahrtrichtung "
"hz=%.0f Grad (nur beim ersten Segment der Kette "
"journalisiert)" % (was, dl, hz), dL=dl, hz=hz)
else:
self.erster_dl = False
self.add("point_rel",
"getpoint %s: dL=%.1f mm (Fahrtrichtung vom "
"Vorgaenger geerbt)" % (was, dl), dL=dl)
return dl
# --- Ablauf ---
def real_feld(self, name, wert, frage):
self.add("real", _vorlage(_REAL_FRAGEN, frage) % wert, wert=wert)
def int_feld(self, name, wert, frage):
self.add("int", _vorlage(_INT_FRAGEN, frage) % wert, wert=wert)
def roh_feld(self, name, wert, frage):
self.add("string", _vorlage(_ROH_FRAGEN, frage) % wert, wert=wert)
def ja_nein(self, name, code, wert, frage):
self.add("string",
_vorlage(_JA_NEIN_FRAGEN, frage) % ("Ja" if wert else "Nein"),
wert=code)
def code_feld(self, name, code, wert, frage):
self._code(code, wert, frage)
def wahl(self, name, code, wert, frage):
self._code(code, wert, frage)
def _code(self, code, wert, frage):
"""Eine ueber eine Code-Tabelle beantwortete Frage. Der Domaenenwert
ist entweder selbst schon der Antworttext (links/rechts,
aussen/innen) oder wird ueber die Klartexttabelle der Frage
uebersetzt; im Protokoll steht als "wert" der rohe Journal-Code, so
wie er in der XDATA stand."""
vorlage, texte = _vorlage(_CODE_FRAGEN, frage)
self.add("string", vorlage % (texte[wert] if texte else wert),
wert=code)
# implizit() schreibt nichts: segment_typ und das automatische VF-Ende
# ergeben sich aus dem Ablauf, es wurde dafuer kein Token gelesen.
def tokens_aus_journal(roh):
"""Die Rohform der XDATA ("ART:wert", getrennt durch TRENNER) in
Tokenpaare zerlegen."""
tok = []
for zeile in roh.split(TRENNER):
if zeile:
art, _, wert = zeile.partition(":")
tok.append((art, wert))
return tok
class Journal(object):
"""Ein Journal (kind:payload je Zeile) in das Frage-Antwort-Protokoll
uebersetzen.
Der Ablauf selbst steckt im gemeinsamen Parser-Kern
(lib/vf_linienzug_grammatik.py) - diese Klasse haelt nur noch die
Rohform-Zerlegung und die Sink zusammen.
"""
def __init__(self, roh, grammatik=GRAMMATIK_AUFZEICHNUNG):
self.tok = tokens_aus_journal(roh)
self.grammatik = grammatik
def dekodiere(self):
self.praeambel()
while self.pos < len(self.tok):
label = self.nimm("STEP")
self.marker(label)
if label == "ES":
self.glied_es(marker_gelesen=True)
continue
if self.erster_step:
tabelle, kopf = MENU_START, (
"Naechstes Element am Kettenanfang (1=Neue Linie GF, "
"2=Neue Linie Ab/Auf VF, 3=Horizontal-VF, 4=Neue Linie "
"bis Kettenende) -> %s")
else:
tabelle, kopf = MENU_WEITER, (
"Naechstes Element (1=GF-Bogen, 2=Neue Linie GF, "
"3=Neue Linie Ab/Auf VF, 4=Horizontal-VF, 5=Neue Linie "
"bis Kettenende) -> %s")
self.frage_str("getstring " + kopf, tabelle)
self.erster_step = False
if label == "Linie-GF":
self.glied_linie_gf()
elif label == "Linie-VF":
self.glied_linie_vf()
elif label == "Horizontal-VF":
self.glied_horizontal_vf()
elif label == "Linie":
self.glied_linie_kettenende()
elif label == "GF-Bogen":
self.frage_int("getint GF-Bogen Winkel (30/60/90 Grad) "
"-> %d")
self.frage_str("getstring GF-Bogen Seite (1=links, "
"2=rechts) -> %s", SEITE)
else:
raise ValueError("unbekanntes Glied: %s" % label)
if self.pos != len(self.tok):
raise ValueError("nicht alle Tokens verbraucht (%d von %d)"
% (self.pos, len(self.tok)))
return self.out
def praeambel(self):
punkt = [float(v) for v in self.nimm("PT").split(",")]
self.add("point_abs", "getpoint Startpunkt der Kette (absoluter "
"Weltpunkt, einziger Anker der ganzen Kette)", wert=punkt)
self.frage_real("getreal Hoehe (Z) Startpunkt -> %.1f mm")
if self.frage_str("getstring AS-Element setzen? (1=Ja, 2=Nein) "
"-> %s", JA_NEIN) == "1":
w = self.nimm("STR")
self.add("string", "getstring AS-Element Winkel (Wert direkt, "
"90 oder 30 Grad) -> %s Grad" % w, wert=w)
self.frage_str("getstring AS-Seite (1=links, 2=rechts) -> %s",
SEITE)
def glied_linie_gf(self):
self.frage_linie("Endpunkt der GF-Linie")
if self.frage_str("getstring Gefaelle festlegen (1=ueber Zielhoehe, "
"2=ueber Neigungswinkel) -> %s", GF_MODUS) == "2":
self.frage_real("getreal Neigungswinkel -> %.1f Grad")
else:
self.frage_real("getreal Zielhoehe (Z) Linienendpunkt "
"-> %.1f mm")
if self.frage_str("getstring Ist das Kettenende? (1=Ja mit ES, "
"2=Ja ohne ES, 3=Nein weiterbauen) -> %s",
KETTENENDE3) == "1":
self.glied_es()
def glied_horizontal_vf(self):
"""VF-Einheit mit HORIZONTALEM Anfangskoerper (winkel1=0 in
vfl-vf-einheit): dort stellt vfl-baue-horizontal-koerper im
Ziel-Modus die Separator-/Endpunkt-Fragen VOR der
Laengenberechnung."""
self.frage_linie("Endpunkt des horizontalen VF-Stuecks")
self.vf_einheit(auto_ende=False, erster_horizontal=True)
def glied_linie_vf(self):
""""Neue Linie: Ab/Auf VF" - Laenge, Zielhoehe, ggf. Winkelwahl
(vfl-vf-entscheidung), dann eine VF-Einheit mit GEWINKELTEM
Anfangskoerper. Zu dem Koerper wird nichts gefragt (vfl-vf-einheit
baut ihn direkt), die erste Frage der Einheit ist also die
Endpunkt-Frage der Fortsetzungsschleife."""
self.frage_linie("Endpunkt des Ab/Auf-VF-Segments")
self.frage_real("getreal Zielhoehe (Z) Linienendpunkt -> %.1f mm")
self.frage_winkelwahl()
self.vf_einheit(auto_ende=False, erster_horizontal=False)
def glied_linie_kettenende(self):
""""Neue Linie bis Kettenende": Laenge + Zielhoehe, danach entscheidet
vfl-segment-entscheidung selbst. Reine Gefaellestrecke -> ES-Element
ohne weitere Frage; VF-Einheit -> vf_einheit mit auto-ende."""
self.frage_linie("Endpunkt der Linie bis Kettenende (Ziel-XY der "
"Kette)")
self.frage_real("getreal Zielhoehe (Z) am Kettenende -> %.1f mm")
self.frage_winkelwahl()
if self.art() == "STEP" and self.label() == "ES":
self.glied_es()
elif self.art() == "STR":
# vfl-segment-entscheidung liefert fuer typ="VF" immer einen
# Winkel > 0 (die VF-Winkelstufen beginnen bei 3 Grad), der erste
# Koerper ist hier also nie horizontal.
self.vf_einheit(auto_ende=True, erster_horizontal=False)
def glied_es(self, marker_gelesen=False):
if not marker_gelesen:
label = self.nimm("STEP")
if label != "ES":
raise ValueError("ES-Marker erwartet, %s gefunden" % label)
self.marker(label)
w = self.nimm("STR")
self.add("string", "getstring ES-Element Winkel (Wert direkt, 90 "
"oder 30 Grad) -> %s Grad" % w, wert=w)
self.frage_str("getstring ES-Seite (1=links, 2=rechts) -> %s", SEITE)
def frage_winkelwahl(self):
"""Winkelwahl (vfl-waehle-winkel) steht nur im Journal, wenn mehrere
Kandidatenwinkel gueltig waren - bei genau einem faellt die Frage
weg."""
if self.art() == "INT":
self.frage_int("getint Winkelwahl aus den gueltigen "
"Kandidaten (vfl-waehle-winkel) -> Option %d")
def horizontal_koerper(self):
"""vfl-baue-horizontal-koerper im Ziel-Modus: Separator vor/nach und
die Endpunkt-Frage stehen VOR der Laengenberechnung. Rueckgabe: die
Endpunkt-Antwort, die die Fortsetzungsschleife weiterverwendet."""
self.frage_str("getstring Separator VOR dem horizontalen Stueck? "
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
self.frage_str("getstring Separator NACH dem horizontalen Stueck? "
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
return self.frage_str("getstring Ist der Endpunkt der Foerderer? "
"(1=Ja nur Motorstation, 2=Nein weiterbauen, "
"3=Ja Motorstation + Kettenende) -> %s",
ENDPUNKT3)
def vario_kurve(self):
label = self.nimm("STEP")
if label != "Vario-Kurve":
raise ValueError("Vario-Kurve-Marker erwartet, %s" % label)
self.marker(label)
self.frage_int("getint Vario-Kurve Winkel (30/60/90 Grad, Wert "
"direkt) -> %d Grad")
self.frage_str("getstring Vario-Kurve Seite (1=links, 2=rechts) "
"-> %s", SEITE)
self.frage_str("getstring Vario-Kurve Variante (1=aussen, 2=innen) "
"-> %s", VARIANTE)
def vf_einheit(self, auto_ende, erster_horizontal):
"""Eine VF-Einheit: GF-Verteilung, erster Koerper, dann die
Fortsetzungsschleife (vfl-vf-einheit) bis Motorstation/Kettenende,
danach der Abschluss (vfl-vf-einheit-abschluss).
erster_horizontal spiegelt die Verzweigung ueber winkel1 in
vfl-vf-einheit: nur der horizontale Erstkoerper laeuft durch
vfl-baue-horizontal-koerper und fragt darum Separator vor/nach und
die Endpunkt-Frage schon vorab (deren Antwort die Schleife dann
uebernimmt, statt erneut zu fragen). Ein gewinkelter Erstkoerper
wird ohne jede Frage gebaut."""
self.frage_str("getstring GF-Verteilung (1=halbe Staustrecke am "
"Ausgang, 2=alles am Einlauf) -> %s", VERTEILUNG)
vorantwort = self.horizontal_koerper() if erster_horizontal else None
ziel_ende = False
es_gewuenscht = True
while True:
if vorantwort is None:
antwort = self.frage_str(
"getstring Ist der Endpunkt der Foerderer? (1=Ja nur "
"Motorstation, 2=Nein weiterbauen, 3=Ja Motorstation + "
"Kettenende) -> %s", ENDPUNKT3)
else:
antwort, vorantwort = vorantwort, None
if antwort == "1":
break
if antwort == "3":
es_gewuenscht = self.frage_str(
"getstring ES-Element setzen? (1=Ja, 2=Nein - Kette "
"endet direkt am Zielpunkt) -> %s", JA_NEIN) != "2"
self.frage_linie("Zielpunkt des Kettenendes")
self.frage_real("getreal Zielhoehe (Z) am Kettenende "
"-> %.1f mm")
self.frage_winkelwahl()
ziel_ende = True
break
wahl = self.frage_str("getstring Naechstes in der VF-Einheit "
"(1=Horizontaler Foerderer, "
"2=Vario-Kurve, 3=Auf/Ab-Foerderer) "
"-> %s", IM_VF)
if wahl == "1":
self.frage_linie("Endpunkt des horizontalen VF-Stuecks")
vorantwort = self.horizontal_koerper()
elif wahl == "2":
self.vario_kurve()
else:
self.frage_linie("Endpunkt des Auf/Ab-VF-Segments")
self.frage_real("getreal Zielhoehe (Z) am Segmentende "
"-> %.1f mm")
self.frage_winkelwahl()
if ziel_ende and not es_gewuenscht:
return
if auto_ende or ziel_ende:
self.glied_es()
return
if self.frage_str("getstring Ist das Kettenende? (1=Ja, Separator + "
"ES-Element, 2=Nein, weiterbauen) -> %s",
KETTENENDE2) == "1":
self.glied_es()
else:
self.frage_str("getstring Separator an dieser Stelle einfuegen? "
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
return lies_journal(self.tok, ProtokollSink(), self.grammatik)
# ============================================================
+604
View File
@@ -0,0 +1,604 @@
#!/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)
"""
# ============================================================
# 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"}
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).
"""
def __init__(self, name, menu_start, horizontal_vf_fragt_gf_verteilung):
self.name = name
self.menu_start = menu_start
self.horizontal_vf_fragt_gf_verteilung = (
horizontal_vf_fragt_gf_verteilung)
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)
# Stand des LEBENDEN Bau-Ablaufs (vf_linienzug.lsp) - fuer neue Uebersetzungen.
GRAMMATIK_AKTUELL = Grammatik("aktuell", MENU_START_AKTUELL, False)
# ============================================================
# 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 NUR beim allerersten Segment der Kette die
gesnappte Fahrtrichtung (vfl-in-abstand journalisiert sie nur bei
freier Richtungswahl; jedes spaetere Segment erbt die Richtung des
Vorgaengers)."""
dl = float(self.nimm("DL"))
hz = self.real() if (self.erster_dl 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_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 vor der ersten Menue-Antwort der
VF-Einheit)."""
if self.art() == "REAL":
self.sink.real_feld("eingang_hoehe", self.real(), "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":
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-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.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")
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()
+174 -308
View File
@@ -24,8 +24,10 @@ sonst waere jeder spec-gebaute Nachbau eine andere Kette als das Original.
Der Schreiber ist das Vorbild fuer vfl-spec->journal in Lisp/vf_spec.lsp;
beide muessen dieselbe Tokenfolge liefern.
Die Grammatik selbst (welche Frage in welcher Reihenfolge) steckt in
lib/vf_journal_export.py (Journal.dekodiere) und ist dort kommentiert.
Die Grammatik selbst (welche Frage in welcher Reihenfolge) steht EINMAL in
lib/vf_linienzug_grammatik.py und wird von hier (Spec) wie von
lib/vf_journal_export.py (Protokoll) ueber denselben Parser-Kern benutzt -
nur mit unterschiedlicher Sink.
Aufruf (erzeugt die Spec-Testdaten aus dem Protokoll der echten Ketten):
@@ -41,94 +43,20 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from vf_journal_export import arrays_einzeilig # noqa: E402
# Die Code-Tabellen, die Grammatik-Auspraegungen und der Parser-Kern stehen
# EINMAL im gemeinsamen Modul - hier nur noch importiert, nicht nachgebaut.
# Ein Teil davon wird hier selbst nicht mehr benutzt, sondern nur
# weitergereicht (vse.MENU_START, vse.GLIEDER, vse.GRAMMATIK_AKTUELL ... in
# tests/ und in Aufrufern) - die oeffentliche Flaeche bleibt unveraendert.
from vf_linienzug_grammatik import ( # noqa: E402,F401
ENDE2, ENDE3, ENDPUNKT, GEFAELLE, GF_LAENGE_MODUS, GLIEDER,
GRAMMATIK_AKTUELL, GRAMMATIK_AUFZEICHNUNG, Grammatik, IM_VF, MENU_START,
MENU_START_AKTUELL, MENU_WEITER, SEITE, VARIANTE, VERTEILUNG, VF_GLIEDER,
Sink, code_aus_ja, code_aus_wert, lies_journal, pruefe_flachen_freitext)
PREFIX = "[vf_spec_export]"
# ============================================================
# 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.
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"}
ENDE3 = {"1": "ja-mit-es", "2": "ja-ohne-es", "3": "nein"}
ENDE2 = {"1": "ja-mit-es", "2": "nein"}
# 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
# SpecReader 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.
MENU_START = {"Linie-GF": "1", "Linie-VF": "2", "Horizontal-VF": "3",
"Linie": "4"}
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}
def _um(tabelle, code, was):
if code not in tabelle:
raise ValueError("%s: unbekannter Code %r (erlaubt: %s)"
% (was, code, sorted(tabelle)))
return tabelle[code]
def _code(tabelle, wert, was):
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(code, was):
return _um({"1": True, "2": False}, code, was)
def _ja_code(wert):
return "1" if wert else "2"
# ============================================================
# Tokens
# ============================================================
@@ -208,217 +136,110 @@ def erste_abweichung(a, b):
# ============================================================
# Leser: Journal -> Spec
# ============================================================
# Den Ablauf (welche Frage wann) laeuft der gemeinsame Kern in
# lib/vf_linienzug_grammatik.py. Hier steht nur noch, WOHIN die gemeldeten
# Werte wandern: in eine verschachtelte Spec aus OrderedDicts.
class SpecLeser(object):
"""Tokens in eine Spec uebersetzen. Folgt derselben Frage-Reihenfolge wie
Journal.dekodiere in lib/vf_journal_export.py, legt aber Domaenenwerte ab
statt Protokollzeilen."""
class SpecSink(Sink):
"""Baut aus den Meldungen des Parser-Kerns die Spec.
def __init__(self, tok):
self.tok = list(tok)
self.pos = 0
self.erster_step = True
self.erster_dl = True
Die Reihenfolge der Schluessel ist bewusst die des Frageablaufs - das
flache Spec-JSON wird in genau dieser Reihenfolge geschrieben und ist so
neben der Aufzeichnung lesbar.
"""
# --- Tokenzugriff ---
def __init__(self):
self.spec = collections.OrderedDict()
self.start = None
self.sektionen = []
self.sek = None
self.vf = None
self.sub = None
# Wohin die naechsten Felder gehoeren (Kopf / Sektion / VF-Einheit /
# Sub-Knoten). Der Kern sagt mit den Struktur-Meldungen an, wann sich
# das Ziel aendert - hier wird nichts geraten.
self.ziel = None
def art(self):
return self.tok[self.pos][0] if self.pos < len(self.tok) else None
def ergebnis(self):
self.spec["start"] = self.start
self.spec["sektionen"] = self.sektionen
return self.spec
def label(self):
return self.tok[self.pos][1] if self.pos < len(self.tok) else None
# --- Struktur ---
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 praeambel_start(self):
self.start = collections.OrderedDict()
self.ziel = self.start
def real(self):
return float(self.nimm("REAL"))
def praeambel_ende(self):
self.ziel = None
def ganz(self):
return int(self.nimm("INT"))
def sektion_start(self, label):
self.sek = collections.OrderedDict([("glied", label)])
self.ziel = self.sek
def opt_winkel_idx(self, ziel):
"""Die Winkelwahl (vfl-waehle-winkel) steht nur im Journal, wenn
mehrere Kandidatenwinkel gueltig waren - sonst faellt die Frage weg."""
if self.art() == "INT":
ziel["winkel_idx"] = self.ganz()
def sektion_ende(self):
self.sektionen.append(self.sek)
self.sek = None
self.ziel = None
def linie(self, ziel):
"""Segmentlaenge, und NUR beim allerersten Segment der Kette die
gesnappte Fahrtrichtung (vfl-in-abstand journalisiert sie nur bei
freier Richtungswahl)."""
ziel["dl"] = float(self.nimm("DL"))
if self.erster_dl and self.art() == "REAL":
ziel["hz"] = self.real()
self.erster_dl = False
def vf_start(self):
self.vf = collections.OrderedDict()
self.ziel = self.vf
# --- Ablauf ---
def vf_ende(self):
# "subs" wird erst beim ersten Sub-Knoten angelegt, damit es in der
# Reihenfolge hinter gf_verteilung/sep_vor/sep_nach steht.
self.vf.setdefault("subs", [])
self.sek["vf"] = self.vf
self.vf = None
self.ziel = self.sek
def lies(self):
spec = collections.OrderedDict()
spec["start"] = self.praeambel()
spec["sektionen"] = self.sektionen()
if self.pos != len(self.tok):
raise ValueError("nicht alle Tokens verbraucht (%d von %d)"
% (self.pos, len(self.tok)))
return spec
def sub_start(self, sub):
self.sub = collections.OrderedDict([("sub", sub)])
self.vf.setdefault("subs", [])
self.ziel = self.sub
def praeambel(self):
start = collections.OrderedDict()
start["punkt"] = [float(v) for v in self.nimm("PT").split(",")]
start["hoehe"] = self.real()
start["as"] = _ja(self.nimm("STR"), "AS-Element setzen")
if start["as"]:
start["as_winkel"] = self.nimm("STR") # Wert, kein Code
start["as_seite"] = _um(SEITE, self.nimm("STR"), "AS-Seite")
return start
def sub_ende(self):
self.vf["subs"].append(self.sub)
self.sub = None
self.ziel = self.vf
def sektionen(self):
raus = []
while self.pos < len(self.tok):
label = self.nimm("STEP")
if label == "ES":
raus.append(self.es_glied())
continue
if label == "Vario-Kurve":
raise ValueError("Vario-Kurve-Marker ausserhalb einer "
"VF-Einheit (Token %d)" % (self.pos - 1))
sek = collections.OrderedDict()
sek["glied"] = label
tabelle = 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.glied(label, sek)
raus.append(sek)
return raus
# --- Werte ---
def es_glied(self):
sek = collections.OrderedDict()
sek["glied"] = "ES"
sek["winkel"] = self.nimm("STR") # Wert, kein Code
sek["seite"] = _um(SEITE, self.nimm("STR"), "ES-Seite")
return sek
def punkt_abs(self, punkt):
self.ziel["punkt"] = punkt
def glied(self, label, sek):
if label == "GF-Bogen":
sek["winkel"] = self.ganz()
sek["seite"] = _um(SEITE, self.nimm("STR"), "GF-Bogen-Seite")
elif label == "Linie-GF":
self.linie(sek)
sek["gefaelle"] = _um(GEFAELLE, self.nimm("STR"), "Gefaelle")
if sek["gefaelle"] == "winkel":
sek["winkel"] = self.real()
else:
sek["hoehe"] = self.real()
sek["ende"] = _um(ENDE3, self.nimm("STR"), "Kettenende")
elif label == "Linie-VF":
self.linie(sek)
sek["hoehe"] = self.real()
self.opt_winkel_idx(sek)
sek["vf"] = self.vf_einheit(auto_ende=False, erst_hor=False)
elif label == "Horizontal-VF":
self.linie(sek)
sek["vf"] = self.vf_einheit(auto_ende=False, erst_hor=True)
elif label == "Linie":
self.linie(sek)
sek["hoehe"] = self.real()
self.opt_winkel_idx(sek)
# 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":
sek["segment_typ"] = "VF"
sek["vf"] = self.vf_einheit(auto_ende=True, erst_hor=False)
else:
sek["segment_typ"] = "GF"
else:
raise ValueError("unbekanntes Glied: %r" % label)
def linie(self, dl, hz, frage):
self.ziel["dl"] = dl
if hz is not None:
self.ziel["hz"] = hz
def vf_einheit(self, auto_ende, erst_hor):
vf = collections.OrderedDict()
vf["gf_verteilung"] = _um(VERTEILUNG, self.nimm("STR"),
"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.
vf["sep_vor"] = _ja(self.nimm("STR"), "Separator vor")
vf["sep_nach"] = _ja(self.nimm("STR"), "Separator nach")
vf["subs"] = []
ziel_ende = False
es_gewuenscht = True
while True:
was = _um(ENDPUNKT, self.nimm("STR"), "Endpunkt des Foerderers")
if was == "motorstation":
vf["subs"].append(collections.OrderedDict(
[("sub", "motorstation")]))
break
if was == "kettenende":
sub = collections.OrderedDict([("sub", "kettenende")])
sub["es"] = _ja(self.nimm("STR"), "ES-Element setzen")
self.linie(sub)
sub["hoehe"] = self.real()
self.opt_winkel_idx(sub)
vf["subs"].append(sub)
ziel_ende = True
es_gewuenscht = sub["es"]
break
wahl = _um(IM_VF, self.nimm("STR"), "Naechstes in der VF-Einheit")
sub = collections.OrderedDict([("sub", wahl)])
if wahl == "horizontal":
self.linie(sub)
sub["sep_vor"] = _ja(self.nimm("STR"), "Separator vor")
sub["sep_nach"] = _ja(self.nimm("STR"), "Separator nach")
elif wahl == "vario-kurve":
marke = self.nimm("STEP")
if marke != "Vario-Kurve":
raise ValueError("Vario-Kurve-Marker erwartet, %r "
"gefunden" % marke)
sub["winkel"] = self.ganz()
sub["seite"] = _um(SEITE, self.nimm("STR"),
"Vario-Kurve-Seite")
sub["variante"] = _um(VARIANTE, self.nimm("STR"),
"Vario-Kurve-Variante")
else: # auf-ab
self.linie(sub)
sub["hoehe"] = self.real()
self.opt_winkel_idx(sub)
vf["subs"].append(sub)
# --- 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:
vf["ende"] = "zielpunkt-ohne-es"
elif auto_ende or ziel_ende:
vf["ende"] = "automatisch"
else:
vf["ende"] = _um(ENDE2, self.nimm("STR"), "Ist das Kettenende")
if vf["ende"] != "ja-mit-es":
vf["separator"] = _ja(self.nimm("STR"),
"Separator an dieser Stelle")
return vf
def real_feld(self, name, wert, frage):
self.ziel[name] = wert
def int_feld(self, name, wert, frage):
self.ziel[name] = wert
def code_feld(self, name, code, wert, frage):
self.ziel[name] = wert
def roh_feld(self, name, wert, frage):
self.ziel[name] = wert
def ja_nein(self, name, code, wert, frage):
self.ziel[name] = wert
def implizit(self, name, wert):
self.ziel[name] = wert
# wahl()/glied_gewaehlt()/marke() schlagen sich in der STRUKTUR nieder
# (Sub-Knoten, Glied-Name) - die Spec speichert sie nicht noch einmal als
# Feld. Die Erbversionen aus Sink tun darum genau das Richtige: nichts.
def spec_aus_journal(tok):
return SpecLeser(tok).lies()
def spec_aus_journal(tok, grammatik=GRAMMATIK_AUFZEICHNUNG):
return lies_journal(tok, SpecSink(), grammatik)
# ============================================================
@@ -431,8 +252,9 @@ def spec_aus_journal(tok):
class SpecSchreiber(object):
def __init__(self, spec):
def __init__(self, spec, grammatik=GRAMMATIK_AUFZEICHNUNG):
self.spec = spec
self.grammatik = grammatik
self.tok = []
self.erster_dl = True
self.wo = "Spec"
@@ -497,10 +319,10 @@ class SpecSchreiber(object):
self.raus("PT", ",".join(_zahl(v) for v in punkt))
self.zahl("REAL", self.feld(start, "hoehe"))
gesetzt = bool(self.feld(start, "as"))
self.raus("STR", _ja_code(gesetzt))
self.raus("STR", code_aus_ja(gesetzt))
if gesetzt:
self.raus("STR", str(self.feld(start, "as_winkel")))
self.raus("STR", _code(SEITE, self.feld(start, "as_seite"),
self.raus("STR", code_aus_wert(SEITE, self.feld(start, "as_seite"),
"as_seite"))
def sektion(self, sek, erster):
@@ -508,7 +330,7 @@ class SpecSchreiber(object):
if label == "ES":
self.es_glied(sek)
return
tabelle = MENU_START if erster else MENU_WEITER
tabelle = self.grammatik.menu_start if erster else MENU_WEITER
if label not in tabelle:
raise ValueError("%s: Glied %r ist an dieser Stelle nicht "
"waehlbar (erlaubt: %s)"
@@ -517,7 +339,8 @@ class SpecSchreiber(object):
self.raus("STR", tabelle[label])
if label == "GF-Bogen":
self.raus("INT", str(int(self.feld(sek, "winkel"))))
self.raus("STR", _code(SEITE, self.feld(sek, "seite"), "seite"))
self.raus("STR", code_aus_wert(SEITE, self.feld(sek, "seite"),
"seite"))
elif label == "Linie-GF":
# Vorwahl "laenge_modus" (endpunkt/hoehe/steigend, Default
# "endpunkt" - deckt alte Specs ohne dieses Feld ab). "steigend"
@@ -540,11 +363,12 @@ class SpecSchreiber(object):
# (vf-linienzug-modus), kein Fehler in diesem Schreiber. Siehe
# doc/vf_linienzug_modus1_anleitung.md / Commit-Historie.
modus = self.feld(sek, "laenge_modus", pflicht=False) or "endpunkt"
self.raus("STR", _code(GF_LAENGE_MODUS, modus, "laenge_modus"))
self.raus("STR", code_aus_wert(GF_LAENGE_MODUS, modus,
"laenge_modus"))
if modus == "steigend":
smodus = (self.feld(sek, "steigend_modus", pflicht=False)
or "endpunkt")
self.raus("STR", _code(GF_LAENGE_MODUS, smodus,
self.raus("STR", code_aus_wert(GF_LAENGE_MODUS, smodus,
"steigend_modus"))
if smodus == "endpunkt":
self.linie(sek)
@@ -556,23 +380,24 @@ class SpecSchreiber(object):
elif modus == "hoehe":
if self.erster_dl:
self.richtung(sek)
self.raus("STR", _ja_code(self.feld(sek, "sep_vor",
self.raus("STR", code_aus_ja(self.feld(sek, "sep_vor",
pflicht=False)))
self.raus("STR", _ja_code(self.feld(sek, "sep_nach",
self.raus("STR", code_aus_ja(self.feld(sek, "sep_nach",
pflicht=False)))
self.zahl("REAL", self.feld(sek, "hoehe"))
self.zahl("REAL", self.feld(sek, "winkel"))
else: # "endpunkt"
self.linie(sek)
self.raus("STR", _ja_code(self.feld(sek, "sep_vor",
self.raus("STR", code_aus_ja(self.feld(sek, "sep_vor",
pflicht=False)))
self.raus("STR", _ja_code(self.feld(sek, "sep_nach",
self.raus("STR", code_aus_ja(self.feld(sek, "sep_nach",
pflicht=False)))
art = self.feld(sek, "gefaelle")
self.raus("STR", _code(GEFAELLE, art, "gefaelle"))
self.raus("STR", code_aus_wert(GEFAELLE, art, "gefaelle"))
self.zahl("REAL", self.feld(sek, "winkel" if art == "winkel"
else "hoehe"))
self.raus("STR", _code(ENDE3, self.feld(sek, "ende"), "ende"))
self.raus("STR", code_aus_wert(ENDE3, self.feld(sek, "ende"),
"ende"))
elif label == "Linie-VF":
self.linie(sek)
self.zahl("REAL", self.feld(sek, "hoehe"))
@@ -580,6 +405,10 @@ class SpecSchreiber(object):
self.vf_einheit(sek, erst_hor=False)
elif label == "Horizontal-VF":
self.linie(sek)
# Optional: Ziel-Hoehe des GF1-Einlaufs - nur wenn der Eingang
# komplett neu gebaut wird (siehe SpecLeser.opt_eingang_hoehe).
if "eingang_hoehe" in sek:
self.zahl("REAL", sek["eingang_hoehe"])
self.vf_einheit(sek, erst_hor=True)
elif label == "Linie":
self.linie(sek)
@@ -593,15 +422,20 @@ class SpecSchreiber(object):
def es_glied(self, sek):
self.raus("STEP", "ES")
self.raus("STR", str(self.feld(sek, "winkel")))
self.raus("STR", _code(SEITE, self.feld(sek, "seite"), "seite"))
self.raus("STR", code_aus_wert(SEITE, self.feld(sek, "seite"),
"seite"))
def vf_einheit(self, sek, erst_hor):
vf = self.feld(sek, "vf")
self.raus("STR", _code(VERTEILUNG, self.feld(vf, "gf_verteilung"),
# GF-Verteilung: bei horizontalem Erstkoerper stellt der heutige
# Ablauf die Frage nicht mehr (fest "2") - siehe Grammatik.
if not erst_hor or self.grammatik.horizontal_vf_fragt_gf_verteilung:
self.raus("STR", code_aus_wert(VERTEILUNG,
self.feld(vf, "gf_verteilung"),
"gf_verteilung"))
if erst_hor:
self.raus("STR", _ja_code(self.feld(vf, "sep_vor")))
self.raus("STR", _ja_code(self.feld(vf, "sep_nach")))
self.raus("STR", code_aus_ja(self.feld(vf, "sep_vor")))
self.raus("STR", code_aus_ja(self.feld(vf, "sep_nach")))
subs = vf.get("subs") or []
if not subs:
raise ValueError("%s: VF-Einheit ohne subs - sie muss mit "
@@ -614,10 +448,21 @@ class SpecSchreiber(object):
"folgen aber noch %d weitere"
% (self.wo, wahl, len(subs) - i))
if wahl == "motorstation":
self.raus("STR", _code(ENDPUNKT, "motorstation", "sub"))
self.raus("STR", code_aus_wert(ENDPUNKT, "motorstation",
"sub"))
elif wahl == "kettenende":
self.raus("STR", _code(ENDPUNKT, "kettenende", "sub"))
self.raus("STR", _ja_code(self.feld(sub, "es")))
self.raus("STR", code_aus_wert(ENDPUNKT, "kettenende", "sub"))
gesetzt = bool(self.feld(sub, "es"))
self.raus("STR", code_aus_ja(gesetzt))
if gesetzt:
# Winkel+Seite des ES-Elements stehen HIER im Journal
# (vfl-frage-es-seite, inkl. eigenem Marker) - der
# ES-Fussabdruck muss vor der Zerlegung feststehen.
self.raus("STEP", "ES")
self.raus("STR", str(self.feld(sub, "es_winkel")))
self.raus("STR", code_aus_wert(SEITE,
self.feld(sub, "es_seite"),
"es_seite"))
self.linie(sub)
self.zahl("REAL", self.feld(sub, "hoehe"))
self.winkel_idx(sub)
@@ -626,18 +471,19 @@ class SpecSchreiber(object):
raise ValueError("%s: VF-Einheit endet mit sub %r - der "
"letzte sub muss motorstation oder "
"kettenende sein" % (self.wo, wahl))
self.raus("STR", _code(ENDPUNKT, "weiter", "sub"))
self.raus("STR", _code(IM_VF, wahl, "sub"))
self.raus("STR", code_aus_wert(ENDPUNKT, "weiter", "sub"))
self.raus("STR", code_aus_wert(IM_VF, wahl, "sub"))
if wahl == "horizontal":
self.linie(sub)
self.raus("STR", _ja_code(self.feld(sub, "sep_vor")))
self.raus("STR", _ja_code(self.feld(sub, "sep_nach")))
self.raus("STR", code_aus_ja(self.feld(sub, "sep_vor")))
self.raus("STR", code_aus_ja(self.feld(sub, "sep_nach")))
elif wahl == "vario-kurve":
self.raus("STEP", "Vario-Kurve")
self.raus("INT", str(int(self.feld(sub, "winkel"))))
self.raus("STR", _code(SEITE, self.feld(sub, "seite"),
self.raus("STR", code_aus_wert(SEITE,
self.feld(sub, "seite"),
"seite"))
self.raus("STR", _code(VARIANTE,
self.raus("STR", code_aus_wert(VARIANTE,
self.feld(sub, "variante"),
"variante"))
else: # auf-ab
@@ -647,13 +493,13 @@ class SpecSchreiber(object):
ende = self.feld(vf, "ende")
if ende in ("zielpunkt-ohne-es", "automatisch"):
return # keine Frage
self.raus("STR", _code(ENDE2, ende, "vf.ende"))
self.raus("STR", code_aus_wert(ENDE2, ende, "vf.ende"))
if ende != "ja-mit-es":
self.raus("STR", _ja_code(self.feld(vf, "separator")))
self.raus("STR", code_aus_ja(self.feld(vf, "separator")))
def journal_aus_spec(spec):
return SpecSchreiber(spec).schreib()
def journal_aus_spec(spec, grammatik=GRAMMATIK_AUFZEICHNUNG):
return SpecSchreiber(spec, grammatik).schreib()
# ============================================================
@@ -704,6 +550,9 @@ def flache_objekte(spec_id, spec, kopf_extra=None):
continue
s[k] = 1 if v is True else (0 if v is False else v)
objekte.append(s)
# Freitext (beschreibung/quelle/...) darf kein " enthalten - der
# LISP-Leser kennt keine Escapes (siehe pruefe_flachen_freitext).
pruefe_flachen_freitext(objekte, "Spec %s" % spec_id)
return objekte
@@ -831,7 +680,12 @@ def main():
# Kette soll in beiden Testlaeufen (Spec-Pfad und Aufzeichnungs-
# Pfad) denselben Namen tragen, sonst laesst sich das Ergebnis
# nicht paaren.
try:
alle.extend(flache_objekte(tid, spec, extra))
except ValueError as e:
print("%s %-20s FEHLER: %s" % (PREFIX, tid, e))
fehler += 1
continue
print("%s %-20s %2d Sektionen, %3d Tokens - Rundlauf ok"
% (PREFIX, tid, len(spec["sektionen"]), len(tok)))
@@ -848,6 +702,18 @@ def main():
", ".join(str(o.get("id", "?")) for o in fremde)))
alle.extend(fremde)
# Letzte Kontrolle vor dem Schreiben - sie deckt auch die von Hand
# ergaenzten Objekte ab, die nicht durch flache_objekte gelaufen sind.
# Gueltiges JSON reicht hier NICHT (siehe pruefe_flachen_freitext): der
# LISP-Leser schneidet einen Stringwert am ersten rohen " ab und verliert
# damit den Rest der Datei.
try:
pruefe_flachen_freitext(alle, args.ziel)
except ValueError as e:
print("%s FEHLER: %s" % (PREFIX, e))
print("%s nichts geschrieben" % PREFIX)
return 1
text = json.dumps(alle, indent=2, ensure_ascii=False)
with open(args.ziel, "w", encoding="utf-8", newline="\r\n") as fh:
fh.write(arrays_einzeilig(text))