Files
dxfmakros/lib/vf_spec_export.py
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

880 lines
37 KiB
Python

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