Lisp (vf_linienzug.lsp/vf_spec.lsp): - vsp-eingang-hoehe unterstuetzt jetzt "auto" (NIL-Token statt REAL) - bewusst keine Zahl vorgeben, der Bauablauf berechnet seinen eigenen Vorschlag, wie bei leerer Konsoleneingabe - vfl-journal-reset-lauf: internes Reset innerhalb von vf-linienzug-modus/ -modus2 loescht *vfl-replay-queue*/*vflw-pending* nicht mehr mit (Bug: REPLAY-getriebener Bau (vsp-bau-aus-spec) brach mitten in der Kette ab, weil eine noch unverbrauchte Queue verworfen wurde) Python-Gegenstueck nachgezogen (lib/vf_linienzug_grammatik.py, lib/vf_spec_export.py, lib/vf_journal_export.py): - NIL-Token-Unterstuetzung fuer eingang_hoehe="auto" im gemeinsamen Parser-Kern, im Schreiber (SpecSchreiber) und in der Protokoll-Annotation - gf_verteilung ist im Schreiber jetzt an der Feldpraesenz in der Spec festgemacht statt an einem Grammatik-Flag (selbstbeschreibend, deckt Specs mit und ohne diese Frage ab, ohne dass der Aufrufer die Grammatik kennen muss) - Rundlauf-Pruefung (main(), test_verlustfrei) toleriert jetzt die bekannte, schon dokumentierte Erweiterung um drei neue Linie-GF-Pflichtfragen (ist_teilfolge statt exakter Gleichheit) - test_gleiche_journale_wie_protokoll: alle 5 Ketten in hundm05.json weichen inzwischen vom Rohprotokoll ab (Kette 1 bewusst wegen eingang_hoehe, Ketten 2-5 schon laenger wegen Handkorrektur gegen echte DXF-Positionen) - expliziter Skip mit Begruendung statt leerem Loop tests/testdata/hundm05.json: - mm-Werte (dl, Start-/Stuetzpunkte, Koordinaten der Nachtrag-Objekte) auf eine Nachkommastelle gerundet - nicht abgefragte Kopf-Metadaten entfernt (beschreibung, sivas_id, sivas_bezeichnung, planquadrat, block, dxf_handle) - nur quelle bleibt Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
824 lines
34 KiB
Python
824 lines
34 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, 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"
|
|
|
|
# --- 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"))
|
|
if self.erster_dl:
|
|
# Beim ersten Segment MUSS die Richtung dabeistehen (die Kette hat
|
|
# noch keine geerbte), bei jedem spaeteren darf sie es NICHT -
|
|
# sonst verschiebt sich die ganze Queue um einen Eintrag.
|
|
self.zahl("REAL", self.feld(quelle, "hz"))
|
|
self.erster_dl = False
|
|
elif "hz" in quelle:
|
|
raise ValueError("%s: hz steht nur beim ERSTEN Segment der Kette "
|
|
"im Journal (vfl-in-abstand)" % self.wo)
|
|
|
|
def winkel_idx(self, quelle):
|
|
if "winkel_idx" in quelle:
|
|
self.raus("INT", str(int(quelle["winkel_idx"])))
|
|
|
|
# --- Ablauf ---
|
|
|
|
def schreib(self):
|
|
self.praeambel()
|
|
sektionen = self.spec.get("sektionen") or []
|
|
for nr, sek in enumerate(sektionen, 1):
|
|
self.wo = "Sektion %d (%s)" % (nr, sek.get("glied"))
|
|
self.sektion(sek, erster=(nr == 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):
|
|
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.
|
|
#
|
|
# ACHTUNG Rundlauf-Test (TestRundlauf.test_verlustfrei): die 5
|
|
# echten HundM-Ketten (hm_recformat.json) wurden VOR dieser
|
|
# Erweiterung aufgezeichnet und enthalten daher fuer ihre
|
|
# Linie-GF-Sektionen KEINE Separator-Antworten. spec_aus_journal
|
|
# (Leser, unveraendert) liefert fuer sie weiterhin die alte,
|
|
# separator-lose Spec-Form; journal_aus_spec (dieser Schreiber)
|
|
# haengt bei modus="endpunkt" aber IMMER die beiden neuen
|
|
# sep_vor/sep_nach-Tokens an. Der Rundlauf ist fuer Ketten mit
|
|
# Linie-GF damit bewusst NICHT mehr verlustfrei - das ist eine
|
|
# inhaerente Folge der neuen Pflichtfragen im interaktiven Ablauf
|
|
# (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_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_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"))
|
|
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"))
|
|
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)
|
|
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)
|
|
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())
|