[MERGE] Lager branch in main

This commit is contained in:
2026-10-30 11:44:19 +01:00
96 changed files with 9707 additions and 727 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Edit-Befehl (Doppelklick) zum Neuaufbau liest. Typischer Ablauf: Scanner-
Vorlage nachbauen, in BricsCAD von Hand korrigieren/ergaenzen, Zeichnung als
DXF speichern und mit diesem Skript die Testdaten aktualisieren.
Je Wrapper (Felder wie in tests/testdata/object_data.md bzw. mubea.json):
Je Wrapper (Felder wie in doc/json-input-lisp.md bzw. mubea.json):
KREISEL_n -> Kreisel ("function": "insert", x/y/z = Einfuegepunkt,
abstand = Mittenabstand AN8-SP8 aus der Blockdefinition,
+3 -3
View File
@@ -6,7 +6,7 @@ dxf_scan_components.py - Erkennt ILS-/Omniflo-Komponenten in einem FREMDEN DXF.
Gedacht fuer Projekt-Zeichnungen, die NICHT von unseren Makros erzeugt wurden
(also ohne die Wrapper-Bloecke KREISEL_*, VF_*, GF_*). Das Skript sucht die
Bauteile ueber Blocknamen-Muster, rechnet ihre Position in Weltkoordinaten um
und schreibt das Ergebnis im JSON-Schema aus tests/testdata/object_data.md -
und schreibt das Ergebnis im JSON-Schema aus doc/json-input-lisp.md -
also so, dass die Anlage per JSON-Treiber mit den Produktionsmakros
nachgebaut werden kann (Vorbild: tests/testdata/mubea.json, das bisher von
Hand aus einem DXF abgeleitet wurde).
@@ -1697,7 +1697,7 @@ def gf_eintraege_aus_linien(linien, as_liste, kreisel=None, sep_liste=None,
def baue_eintraege(treffer, defaults, boegen, weichen,
bekannte_bloecke=None, linien=None, kreisel_hoehen=None,
kreisel_achsen=None, flach=False):
"""Erkannte Vorkommen auf die Schemata aus object_data.md abbilden.
"""Erkannte Vorkommen auf die Schemata aus json-input-lisp.md abbilden.
kreisel_hoehen: Liste (x, y, z) vom Anwender - z gilt fuer den Kreisel,
dessen Einfuegepunkt (Eintrag x/y) hoechstens KREISEL_HOEHE_TOL entfernt
@@ -2082,7 +2082,7 @@ def main():
parser = argparse.ArgumentParser(
description="Erkennt ILS-/Omniflo-Komponenten in einem fremden DXF "
"und schreibt sie im JSON-Schema aus "
"tests/testdata/object_data.md.")
"doc/json-input-lisp.md.")
parser.add_argument("dxf", help="Quellzeichnung (binaeres oder ASCII-DXF)")
parser.add_argument("data_dir", help="Datenverzeichnis mit json/")
parser.add_argument("output", help="Ziel-JSON")
+7
View File
@@ -42,6 +42,7 @@ from export_neighbors import (
compute_kreisel_touch_switches,
lisp_touch_switches,
load_lisp_kreisel_weichen,
einsortieren_freie_separatoren,
compute_sep_kette,
compute_kreisel_umlauf,
SENSOR_TEILEARTEN,
@@ -1543,6 +1544,12 @@ def process_blocks(blocks, lookup, dbg=None, lisp_weichen=None):
if item.get("teileart", "") == "ILS 2.0 Scanner" and separator_id:
item["merkmale"]["Naechster Separator"] = separator_id
# Freie Separatoren, deren Zuordnung eine VF_n/GF_n nennt, die aber nicht
# beim Bau der Strecke entstanden sind (fehlen darum in ihrer sepliste),
# nach Lage in die Kette aufnehmen - nur fuer diesen Lauf, die XDATA am
# Block bleibt unveraendert (siehe einsortieren_freie_separatoren).
einsortieren_freie_separatoren(items, NEIGHBOR_TOLERANCE_MM, dbg=dbg)
# Ketteninterne Separator-/AS-/ES-Reihenfolge aus der neuen "sepliste"-
# XDATA (siehe ssg-sepliste-xdata-schreiben) - leeres dict, wenn keine
# Strecke eine sepliste traegt (reiner Altbestand ohne neue XDATA).
+193
View File
@@ -1255,6 +1255,194 @@ def _asel_collision_boxes(item, half_tol, box_dims):
# Ersetzt das bisherige Raten der Ketten-internen Reihenfolge (die Linie
# selbst wird nicht exportiert) durch die tatsaechliche Bau-Reihenfolge.
def sepliste_einsortier_positionen(kette_xy, kandidaten):
"""Reiner Rechenkern fuer einsortieren_freie_separatoren: wohin gehoert
jeder Kandidat in eine vorhandene Kette?
kette_xy = [(x, y), ...] Lage der Kettenglieder in Kettenfolge (AS ...
ES), mindestens zwei Punkte. Sie bilden einen Polygonzug - so
folgt die Zuordnung auch einer Kette, die um eine Kurve laeuft,
statt nur einer Geraden AS->ES.
kandidaten = [(key, x, y), ...]
Jeder Kandidat wird dem naechstgelegenen Abschnitt (Glied i -> i+1)
zugeordnet. Liegt er dabei VOR dem ersten oder HINTER dem letzten Glied,
wird er verworfen - eingefuegt wird nur zwischen AS und ES.
Rueckgabe (einfuegen, verworfen): einfuegen = {i: [key, ...]} - nach
Glied i einzufuegen, untereinander in Kettenrichtung sortiert;
verworfen = [key, ...].
"""
abschnitte = []
for i in range(len(kette_xy) - 1):
(ax, ay), (bx, by) = kette_xy[i], kette_xy[i + 1]
dx, dy = bx - ax, by - ay
l2 = dx * dx + dy * dy
if l2 > 1e-12:
abschnitte.append((i, ax, ay, dx, dy, l2))
letzter = len(kette_xy) - 2
einfuegen = {}
verworfen = []
for key, x, y in kandidaten:
best = None
for i, ax, ay, dx, dy, l2 in abschnitte:
t = ((x - ax) * dx + (y - ay) * dy) / l2
tc = min(max(t, 0.0), 1.0)
d = math.hypot(x - (ax + tc * dx), y - (ay + tc * dy))
if best is None or d < best[0] - 1e-9:
best = (d, i, t)
if best is None:
verworfen.append(key)
continue
_d, i, t = best
if (i == 0 and t <= 0.0) or (i == letzter and t >= 1.0):
verworfen.append(key)
continue
einfuegen.setdefault(i, []).append((min(max(t, 0.0), 1.0), key))
return ({i: [key for _t, key in sorted(liste)] for i, liste in einfuegen.items()},
verworfen)
def _kettenglied_xy(entry, item, strecke):
"""Lage eines sepliste-Glieds fuer den Polygonzug: AS/ES ueber K1/K2 der
Strecke (Ein-/Auslauf - der ZZ_ASES_MARKER sitzt auf dem Einfuegepunkt
des Elements und kann bis ~600 mm seitlich davon liegen), sonst bzw.
ohne K1/K2 der Bounding-Box-Mittelpunkt des vertretenden Items."""
typ = entry.get("typ", "")
if typ in ("AS", "ES"):
k1_xy, k2_xy = _asel_kos_xy(strecke)
pt = k1_xy if typ == "AS" else k2_xy
if pt is not None:
return pt
bbox = (item or {}).get("_bbox")
if not bbox:
return None
return (bbox.get("cx", 0.0), bbox.get("cy", 0.0))
def einsortieren_freie_separatoren(items, tolerance_mm, dbg=None):
"""Nimmt freie Separatoren in die sepliste ihrer VF_n/GF_n-Strecke auf.
Die sepliste entsteht beim BAU der Strecke (ssg-sepliste-sammeln) und
kennt nur die Separatoren, die dabei erzeugt wurden. Ein eigenstaendiger
Separator_SP-Block, der spaeter (oder aus einer frueheren Fassung der
Strecke) auf ihr liegt, fehlt darin - obwohl seine ZUORDNUNG
(cs-zuordnung-lauf) die Strecke nennt. Ohne diesen Schritt fehlt er in
TrackIds und Nachbarn (realer Fall Mubea VF_3: 0037/0038 zwischen 0062
und 0063).
Aufgenommen wird ein Separator nur, wenn
- seine Zuordnung die Strecke nennt,
- er in keiner sepliste steht,
- sein Mittelpunkt in der Bounding-Box der Strecke liegt (+ tolerance_mm/2),
- er laengs der Kette zwischen AS und ES liegt
(sepliste_einsortier_positionen).
Bewusst NUR im Speicher dieses Exportlaufs: die sepliste-XDATA am Block
bleibt das Bauprotokoll. Lisp ordnet ihre Eintraege den eingebauten
Bausteinen ueber die Anzahl je Typ zu (csv:sepliste-kette-paare) und
fuehrt reservierte AS/ES-IDs unter der lfdnr - ein in die XDATA
eingefuegter fremder Separator verschoebe beides. Ausserdem schreibt jeder
Neuaufbau der Strecke die XDATA neu; hier wird bei jedem Export frisch
aus dem aktuellen Zeichnungsstand gerechnet.
Aendert items in-place: item["sepliste"] der Strecke bekommt neue
SEP-Eintraege (lfdnr "frei<TeileId>", "id" = TeileId des Separators,
"frei": True); der Separator bekommt "_in_strecke_kette" (Strecken-
TeileId, damit compute_kreisel_umlauf ihn nicht zusaetzlich einem
Kreisel zuschlaegt), das Merkmal "Einsortiert in" (Strecken-TeileId,
in der CSV-Spalte Merkmale) und einen Hinweis in "warnung".
Muss NACH der Separator-Zuordnung und VOR compute_sep_kette laufen.
"""
half_tol = tolerance_mm / 2.0
by_teileid = {it.get("teileid", ""): it for it in items if it.get("teileid", "")}
in_ketten = set()
for it in items:
if it.get("teileart", "") in GF_VF_TEILEARTEN:
for e in it.get("sepliste") or []:
eid = str(e.get("id", "")).strip()
if eid:
in_ketten.add(eid)
if dbg:
dbg("=== einsortieren_freie_separatoren ===")
for strecke in items:
if strecke.get("teileart", "") not in GF_VF_TEILEARTEN:
continue
sepliste = strecke.get("sepliste")
sbbox = strecke.get("_bbox")
if not sepliste or not sbbox:
continue
sid = strecke.get("teileid", "")
sbounds = _bounds(sbbox, half_tol)
kandidaten = []
for it in items:
if it.get("teileart", "") != "ILS 2.0 Separator":
continue
tid = it.get("teileid", "")
if not tid or tid in in_ketten:
continue
zuordnung = ((it.get("merkmale") or {}).get("Zuordnung")
or it.get("_zuordnung_attr") or "").strip()
if zuordnung != sid:
continue
bbox = it.get("_bbox")
if not bbox:
continue
cx, cy = bbox.get("cx", 0.0), bbox.get("cy", 0.0)
if not (sbounds[0] <= cx <= sbounds[1] and sbounds[2] <= cy <= sbounds[3]):
if dbg:
dbg(f" Strecke {sid}: Separator {tid} ausserhalb der Strecken-BBox "
f"- nicht einsortiert")
continue
kandidaten.append((tid, cx, cy))
if not kandidaten:
continue
kette_xy = []
for e in sepliste:
pt = _kettenglied_xy(e, by_teileid.get(str(e.get("id", "")).strip()), strecke)
if pt is None:
break
kette_xy.append(pt)
if len(kette_xy) != len(sepliste) or len(kette_xy) < 2:
if dbg:
dbg(f" Strecke {sid}: Kettenglieder nicht vollstaendig lokalisierbar "
f"- freie Separatoren {[k[0] for k in kandidaten]} nicht einsortiert")
continue
einfuegen, verworfen = sepliste_einsortier_positionen(kette_xy, kandidaten)
if dbg and verworfen:
dbg(f" Strecke {sid}: {verworfen} liegen nicht zwischen AS und ES "
f"- nicht einsortiert")
if not einfuegen:
continue
neu = []
for i, entry in enumerate(sepliste):
neu.append(entry)
for tid in einfuegen.get(i, []):
neu.append({"typ": "SEP", "lfdnr": f"frei{tid}", "id": tid, "frei": True})
sep = by_teileid[tid]
sep["_in_strecke_kette"] = sid
# Fuer Folgewerkzeuge (sps_skel: sepliste-Gegenprobe): dieser
# Separator steht in TrackIds, aber nicht im Bauprotokoll.
sep.setdefault("merkmale", {})["Einsortiert in"] = sid
hinweis = (f"nicht im Bau von {sid} erzeugt - per Lage in dessen "
f"Kette einsortiert")
bisherige = sep.get("warnung", "")
sep["warnung"] = f"{bisherige}; {hinweis}" if bisherige else hinweis
in_ketten.add(tid)
strecke["sepliste"] = neu
if dbg:
dbg(f" Strecke {sid}: Kette jetzt "
f"{' -> '.join(str(e.get('id', '')) for e in neu)}")
def compute_sep_kette(items, dbg=None):
"""Baut je VF_n/GF_n-Item MIT "sepliste" die ketteninterne Vorgaenger-/
Nachfolger-Beziehung AS -> Sep1 -> ... -> SepN -> ES.
@@ -1392,6 +1580,11 @@ def compute_kreisel_umlauf(items, tolerance_mm, durchmesser_mm=800.0,
for item in items:
if item.get("teileart", "") not in KREISEL_UMLAUF_TEILEARTEN:
continue
# Freier Separator, den einsortieren_freie_separatoren in die Kette
# einer Strecke aufgenommen hat - gehoert nicht zusaetzlich auf einen
# Kreisel, auch wenn seine Box einen beruehrt.
if item.get("_in_strecke_kette"):
continue
teileid = item.get("teileid", "")
bbox = item.get("_bbox")
if not bbox:
+243 -3
View File
@@ -58,6 +58,7 @@ items_zusammenfassen. output.csv selbst bleibt unveraendert.
import json
import sys
import os
import re
from export_blockpatterns import (load_patterns, load_export_cfg, cfg_path_from_env,
matches_any, csv_quote, safe_float, safe_int)
@@ -101,14 +102,36 @@ def get_id(block):
# Sprachdatei, da rein Sivas-seitig.
GERUEST_TYP_ANZEIGE = sivas_mapping.load_flat_mapping("geruest_typ_anzeige")
# Kettenfoerderer: Attribut WINKEL fuehrt den Neigungswinkel als nackte Zahl
# ("26"/"30"), Sivas-Merkmal 359 erwartet den Optionstext mit Gradzeichen.
# Gleiche Bauart und derselbe Grund wie GERUEST_TYP_ANZEIGE.
KF_TYP_ANZEIGE = sivas_mapping.load_flat_mapping("kf_typ_anzeige")
# TeileArt-Spalte der Kettenfoerderer-Zeilen. Fester Wert statt Block-Attribut:
# er ist fuer jeden Kettenfoerderer gleich, stuende also an jedem Block nur
# redundant herum. Die Erkennung laeuft ueber den Blocknamen
# (pattern_kettenfoerderer), nicht ueber TEILEART. Ein Block, der das Attribut
# doch mitbringt (Altbestand), hat weiterhin Vorrang.
KF_TEILEART = "Schraegfoerderer Stahl"
SEPARATOR_BEARBEITUNG_ANZEIGE = "ohne HARTCOAT HRC 50 ± 5"
# Sivas-Merkmale "Streckengruppe - Variofoerderer 001..005 - Hoehe in Meter".
# Reihenfolge = Bau-Reihenfolge der Foerdereinheiten in VF_HOEHE_m.
VF_HOEHE_MERKMALE = ("4433", "4434", "4435", "4436", "4437")
def geruest_typ_anzeige(wert):
"""Wandelt den ASCII-Attributwert GERUEST_TYP in die Sivas-Anzeige um."""
return GERUEST_TYP_ANZEIGE.get(wert, wert)
def kf_typ_anzeige(wert):
"""Wandelt den Kettenfoerderer-Winkel ("26"/"30") in die Sivas-Anzeige
zu Merkmal 359 um ("Schrägförderer Stahl 26°")."""
return KF_TYP_ANZEIGE.get(str(wert).strip(), wert)
def ja_nein(flag_1_0):
"""Wandelt ein "0"/"1"-Attribut in die Sivas-Anzeige "Ja"/"Nein" um."""
return "Ja" if flag_1_0 == "1" else "Nein"
@@ -120,14 +143,95 @@ def mm_zu_m(wert_mm):
return str(round(safe_float(wert_mm, 0.0) / 1000.0, 3))
# --- AS-/ES-Element (Merkmale 4427-4432) ---------------------------------
# Zuordnung komplett in cfg/sivas.cfg ([gefaellestrecke]/[variofoerderer]),
# hier stehen nur die Transforms. Die Sivas-Hierarchie ist: 4431/4432
# ("AS-Element"/"ES-Element", Ja/Nein) schalten die Unterknoten Winkel
# (4427/4429) und Seite (4428/4430) frei.
#
# Ob ein Element vorhanden ist, entscheidet AUSSCHLIESSLICH WINKEL_AS/
# WINKEL_ES: das gemeinsame Strecken-Schema (*strecke-attr-front* in
# ssg_core.lsp) laesst den Winkel leer, wenn kein Element gewaehlt wurde.
# SEITE_AS/SEITE_ES taugen dafuer NICHT - sie tragen mit "rechts" immer einen
# Schema-Vorgabewert. Ohne Element bleiben Winkel UND Seite leer, damit in
# Sivas keine Seite unter einem "Nein"-Knoten haengt.
# Attributwert -> Sivas-Optionswert der Auswahlliste ("30"/"90" -> "30 °"/
# "90 °", siehe Merkmalstamm). Wie [geruest_typ_anzeige] in der Config, damit
# die exakte Schreibweise ohne Code-Aenderung nachgezogen werden kann.
AS_ES_WINKEL_ANZEIGE = sivas_mapping.load_flat_mapping("as_es_winkel_anzeige")
def winkel_text(wert):
"""Elementwinkel ganzzahlig normiert ("90.0" -> "90"), ohne Sivas-
Anzeigeform. Basis fuer as_es_winkel UND fuer die Gruppierung (dort darf
die Anzeigeform keine Rolle spielen). Leerer Wert bleibt leer."""
wert = str(wert or "").strip()
if not wert:
return ""
zahl = safe_float(wert, None)
return wert if zahl is None else str(int(zahl))
def as_es_vorhanden(winkel):
"""Merkmal 4431/4432: "Ja", sobald ein Elementwinkel gesetzt ist."""
return "Ja" if winkel_text(winkel) else "Nein"
def as_es_winkel(winkel):
"""Merkmal 4427/4429: Elementwinkel in der Sivas-Schreibweise der
Auswahlliste (cfg/sivas.cfg [as_es_winkel_anzeige]). Ein Winkel ohne
Eintrag dort wird unveraendert (nur ganzzahlig normiert) uebernommen."""
winkel = winkel_text(winkel)
return AS_ES_WINKEL_ANZEIGE.get(winkel, winkel)
def _seite_text(seite, winkel):
"""Gemeinsamer Kern von as_seite/es_seite: Seite nur, wenn es das Element
ueberhaupt gibt."""
if not winkel_text(winkel):
return ""
return str(seite or "").strip().lower()
@sivas_mapping.braucht_attribs
def as_seite(wert, attribs):
"""Merkmal 4428: AS-Seite, aber nur bei vorhandenem AS-Element."""
return _seite_text(wert, attribs.get("WINKEL_AS", ""))
@sivas_mapping.braucht_attribs
def es_seite(wert, attribs):
"""Merkmal 4430: ES-Seite, aber nur bei vorhandenem ES-Element."""
return _seite_text(wert, attribs.get("WINKEL_ES", ""))
# --- Transform-Namen, wie sie in cfg/sivas.cfg hinter "|" stehen koennen ---
TRANSFORMS = {
"ja_nein": ja_nein,
"geruest_typ_anzeige": geruest_typ_anzeige,
"kf_typ_anzeige": kf_typ_anzeige,
"mm_zu_m": mm_zu_m,
"as_es_vorhanden": as_es_vorhanden,
"as_es_winkel": as_es_winkel,
"as_seite": as_seite,
"es_seite": es_seite,
}
def as_es_signatur(attribs):
"""AS-/ES-Anteil des Gruppierungsschluessels (siehe
gefaellestrecke_signature). Nutzt dieselben Transforms wie der Export,
damit Schluessel und Zeileninhalt nicht auseinanderlaufen: ohne Element
ist die Seite leer und darf dann auch nicht trennen."""
return (as_es_vorhanden(attribs.get("WINKEL_AS", "")),
winkel_text(attribs.get("WINKEL_AS", "")),
as_seite(attribs.get("SEITE_AS", ""), attribs),
as_es_vorhanden(attribs.get("WINKEL_ES", "")),
winkel_text(attribs.get("WINKEL_ES", "")),
es_seite(attribs.get("SEITE_ES", ""), attribs))
def streckengruppe_objekt_text(attribs):
"""Merkmal 4300 "Streckengruppe": "mehrere Objekte", wenn der Block ein
kombinierter Streckengruppe-Block (Gefaelle+Vario, TYP-Attribut
@@ -191,12 +295,54 @@ def eckrad_signature(block):
)
def kettenfoerderer_signature(block):
"""
Schluessel zur Gruppierung gleichartiger Kettenfoerderer (KF_n): baugleiche
Bloecke landen in EINER Sivas-Zeile, deren Anzahl das Merkmal 353 ("Anzahl
der gleichen Schraegfoerderer") traegt.
In den Schluessel gehoert JEDES Attribut, aus dem ein Merkmal der Zeile
entsteht - sonst verschmelzen zwei unterschiedlich ausgefuehrte Foerderer
zu einer Zeile, und die Merkmale des ERSTEN Blocks gelten fuer beide
(gleiche Ueberlegung wie bei gefaellestrecke_signature/as_es_signatur).
Die Kommentare nennen darum je Attribut die Merkmalnummer; einzige
Ausnahme ist 353, das erst aus der Gruppengroesse entsteht.
Das ist zugleich die Voraussetzung dafuer, dass items_zusammenfassen (fuer
die _zusammengefasst.csv) zwei KF-Zeilen NIE verschmelzen kann: gleiche
Merkmale wuerden schon hier dieselbe Gruppe ergeben. Sonst wuerde dort
"anzahl" addiert, waehrend 353 unveraendert stehen bliebe.
Die Laenge des Zwischenstuecks (L_KF_mm) ist eine FOLGE der Einbauhoehe und
steht darum nicht eigens im Schluessel.
"""
attribs = block.get("attribs", {})
return (
attribs.get("WINKEL", "26"), # 359
attribs.get("STANDARD_STEUERUNG", "0"), # 361
attribs.get("FOERDERERTYP", ""), # 350
attribs.get("H_EINBAUHOEHE_mm", "0"), # 360
attribs.get("FARBE", ""), # 354
attribs.get("ANTRIEBSMOTOR", ""), # 355
attribs.get("ANTRIEBSART", ""), # 357
attribs.get("MOVIMOT", "0"), # 356
attribs.get("VERBINDUNGSELEMENTE", "Nein"), # 391
attribs.get("ARTINR", ""),
)
def gefaellestrecke_signature(block):
"""
Schluessel zur Gruppierung gleichartiger Gefaellestrecken: Bloecke mit
identischen Massen (Hoehe, Laenge, Winkel, Separatoren, Scanner, Typ,
Geruest fuer Einzelmodul/Geruestoption) landen in derselben Sivas-Zeile -
nur IDs/Bezeichnung unterscheiden sich.
Geruest fuer Einzelmodul/Geruestoption, AS-/ES-Element) landen in
derselben Sivas-Zeile - nur IDs/Bezeichnung unterscheiden sich.
AS-/ES-Element (Merkmale 4427-4432) gehoeren mit in den Schluessel: die
Merkmale der Zeile entstehen aus dem ERSTEN Block der Gruppe, zwei sonst
baugleiche Gefaellestrecken mit AS links bzw. AS rechts wuerden sonst zu
einer Zeile mit nur einer der beiden Seiten verschmelzen (siehe
as_es_signatur).
"""
attribs = block.get("attribs", {})
return (
@@ -209,6 +355,7 @@ def gefaellestrecke_signature(block):
attribs.get("TYP", "Gefaellestrecke"),
attribs.get("GERUEST_EINZELMODUL", "0"),
attribs.get("GERUEST_TYP", "Schoenenberger Geruest"),
as_es_signatur(attribs),
)
@@ -292,6 +439,20 @@ def build_variofoerderer_details(block):
details["4354"] = vf_richtung[0].lower() if vf_richtung else ""
details["4386"] = str(int(vf_winkel[0])) if vf_winkel else ""
# Hoehe je Foerdereinheit (4433-4437, "Variofoerderer 001-005 - Hoehe in
# Meter"). Quelle ist das Attribut VF_HOEHE_m: eine kommagetrennte Liste
# in Bau-Reihenfolge, je Eintrag der Mittelwert aus KS_EIN der
# Umlenkstation und KS_AUS der Motorstation (siehe vf-vfhoehe-anfang/-ende
# in Lisp/vf_core.lsp). Einheit Meter, 3 Nachkommastellen - wie L_VF_m.
#
# Mehr als fuenf Foerdereinheiten kann Sivas hier nicht abbilden; die
# ueberzaehligen werden bewusst weggelassen statt zusammengefasst, damit
# kein Merkmal einen erfundenen Wert traegt. Fehlende Eintraege bleiben
# ganz weg (kein leeres Merkmal).
vf_hoehen = parse_float_list(attribs.get("VF_HOEHE_m", ""))
for nummer, hoehe in zip(VF_HOEHE_MERKMALE, vf_hoehen):
details[nummer] = "%.3f" % hoehe
return {k: str(v) for k, v in details.items()}
@@ -299,6 +460,7 @@ SIVAS_MAPPING_KREISEL = sivas_mapping.load_section_mapping("kreisel")
SIVAS_MAPPING_ECKRAD = sivas_mapping.load_section_mapping("eckrad")
SIVAS_MAPPING_GERUEST = sivas_mapping.load_section_mapping("geruest")
SIVAS_MAPPING_WEICHEN = sivas_mapping.load_section_mapping("weichen")
SIVAS_MAPPING_KETTENFOERDERER = sivas_mapping.load_section_mapping("kettenfoerderer")
def build_kreisel_details(block):
@@ -326,6 +488,21 @@ def build_eckrad_details(block):
return sivas_mapping.anwenden(SIVAS_MAPPING_ECKRAD, attribs, TRANSFORMS)
def build_kettenfoerderer_details(block, anzahl=1):
"""Merkmale-Dict fuer eine Gruppe baugleicher Kettenfoerderer (KF_n,
Lisp/KettenFoerderer.lsp).
350/359/354/356/360/391 kommen flach aus cfg/sivas.cfg [kettenfoerderer].
353 ("Anzahl der gleichen Schraegfoerderer") entsteht erst hier aus der
Gruppengroesse und kann darum nicht im Mapping stehen - gleiche Bauart wie
3510/3512 beim Kreisel.
"""
attribs = block.get("attribs", {})
details = sivas_mapping.anwenden(SIVAS_MAPPING_KETTENFOERDERER, attribs, TRANSFORMS)
details["353"] = str(anzahl)
return details
# def build_geruest_details(block):
#"""Merkmale-Dict fuer eine GR_-Geruest-Baugruppe (siehe pattern_geruest in
#cfg/export.cfg, GeruestInsert.lsp *geruest-attrib-defs*). ARTINR/
@@ -444,6 +621,8 @@ def process_blocks(blocks, anzahl_weichen_ils=0):
btmt_beladung_ids = [] # IDs aller BTMT-Beladung*-Bloecke (eine gruppierte Zeile)
btmt_entladung_ids = [] # IDs aller SC_Entladung*-Bloecke (eine gruppierte Zeile)
geruest_list = [] # Liste von GR_-Geruest-Baugruppenbloecken, je einer Zeile
# Kettenfoerderer: kettenfoerderer_signature(block) -> {"block", "ids", "anzahl"}
kettenfoerderer_groups = {}
counters = {
"anzahl_kreisel": 0,
@@ -466,6 +645,7 @@ def process_blocks(blocks, anzahl_weichen_ils=0):
"anzahl_btbeladung": 0,
"anzahl_abwurfstationen": 0,
"anzahl_geruest": 0,
"anzahl_kettenfoerderer": 0,
}
for block in blocks:
@@ -515,6 +695,22 @@ def process_blocks(blocks, anzahl_weichen_ils=0):
counters["anzahl_geruest"] += 1
continue
# --- Kettenfoerderer / Schraegfoerderer Stahl (KF_n) ---
# MUSS vor dem allgemeinen Omniflo-Zweig stehen: die KF-Baugruppe
# traegt TEILEART "Omniflo Kettenfoerderer ..." und wuerde dort sonst
# ueber ARTINR aufgeloest - ihre eigenen Merkmale (350/353/354/356/
# 359/360/391) gingen dabei verloren. Anders als das Geruest werden
# BAUGLEICHE Foerderer zu einer Zeile zusammengefasst, weil Merkmal
# 353 genau diese Stueckzahl verlangt.
if matches_any(bname, BLOCKPATTERNS.get("pattern_kettenfoerderer", [])):
sig = kettenfoerderer_signature(block)
if sig not in kettenfoerderer_groups:
kettenfoerderer_groups[sig] = {"block": block, "ids": [], "anzahl": 0}
kettenfoerderer_groups[sig]["anzahl"] += 1
kettenfoerderer_groups[sig]["ids"].append(get_id(block))
counters["anzahl_kettenfoerderer"] += 1
continue
# --- Omniflo Gerade (AP110) ---
# VOR dem allgemeinen Omniflo-Zweig: Geraden tragen ggf. ebenfalls
# TEILEART "Omniflo ...", brauchen aber die Laengensumme.
@@ -773,7 +969,7 @@ def process_blocks(blocks, anzahl_weichen_ils=0):
"sivasnr": artinr,
"sivasnr_quoted": True,
"bezeichnung": attribs.get("BEZEICHNUNG", ""),
"dispgruppe": 20,
"dispgruppe": 30,
"anzahl": anzahl,
"laenge_mm": attribs.get("LAENGE", ""),
"ids": [get_id(block)],
@@ -781,6 +977,31 @@ def process_blocks(blocks, anzahl_weichen_ils=0):
})
nr += 1
# Kettenfoerderer / Schraegfoerderer Stahl (KF_n) - eine Zeile je Gruppe
# BAUGLEICHER Foerderer, deren Groesse als Merkmal 353 mitgefuehrt wird.
# Die Sivasnr kommt - wie beim Geruest - aus dem ARTINR-Attribut (gepflegt
# als "sivas_id" je Variante in data/json/kettenfoerderer.json); ist dort
# nichts hinterlegt, bleibt die Spalte leer und die Zeile traegt allein
# ihre Merkmale. Die drei Bausteine des Foerderers (Umlenkstation,
# skaliertes Zwischenstueck, Antriebsstation) bekommen KEINE eigenen
# Zeilen - der Schraegfoerderer ist in Sivas EIN Artikel.
for gruppe in kettenfoerderer_groups.values():
block = gruppe["block"]
attribs = block.get("attribs", {})
items.append({
"nr": nr,
"teileart": attribs.get("TEILEART") or attribs.get("FOERDERERTYP") or KF_TEILEART,
"sivasnr": attribs.get("ARTINR", ""),
"sivasnr_quoted": True,
"bezeichnung": attribs.get("Bezeichnung", ""),
"dispgruppe": 20,
"anzahl": gruppe["anzahl"],
"laenge_mm": attribs.get("L_KF_mm", ""),
"ids": gruppe["ids"],
"details": build_kettenfoerderer_details(block, gruppe["anzahl"]),
})
nr += 1
# VarioFoerderer-Compound-Bloecke (VF_N) - je Block eine eigene Zeile mit
# vollstaendigen Gefaelle-/Angetrieben-Teilgruppen (immer TYP "Streckengruppe").
for block in vario_list:
@@ -964,6 +1185,13 @@ def zusammengefasster_dateiname(output_csv):
return f"{stamm}_zusammengefasst{endung}"
def dispogruppen_dateiname(output_csv, dispo):
"""z.B. "500651_sivas.csv", "10" -> "500651_sivas_zusammengefasst_dispo10.csv"."""
stamm, endung = os.path.splitext(output_csv)
sauber = re.sub(r"[^0-9A-Za-z_-]", "_", str(dispo)) or "leer"
return f"{stamm}_zusammengefasst_dispo{sauber}{endung}"
def items_zusammenfassen(items):
"""Fasst Zeilen zusammen, die sich nur in Nr, Anzahl und IDs
unterscheiden: Anzahl wird addiert, die IDs werden (ohne Dubletten, in
@@ -1041,6 +1269,18 @@ def main():
print(f"[export_sivas] Zusammengefasste CSV geschrieben: {output_csv_zus} "
f"({len(items_zus)} Zeilen)")
# Je Dispogruppe eine eigene zusammengefasste Datei, z.B.
# "<stamm>_zusammengefasst_dispo10.csv" (Nr je Datei neu gezaehlt).
for dispo in sorted({str(i["dispgruppe"]) for i in items_zus}):
teil = [i for i in items_zus if str(i["dispgruppe"]) == dispo]
teil = [dict(i, nr=n) for n, i in enumerate(teil, 1)]
pfad = dispogruppen_dateiname(output_csv, dispo)
with open(pfad, "w", encoding="utf-8") as f:
f.write(header + "\n")
for item in teil:
f.write(format_csv_line(item) + "\n")
print(f"[export_sivas] Dispogruppe {dispo}: {pfad} ({len(teil)} Zeilen)")
# Zweite, lesbare Kopie fuer die manuelle Pruefung (Merkmalnummer +
# Bezeichnung aus cfg/Merkmalsstamm_in_Sivas.xlsx) - wird von Sivas
# selbst NICHT eingelesen, nur output_csv ist der eigentliche Import.
+1 -1
View File
@@ -9,7 +9,7 @@ Hintergrund: data/polylines.dxf enthaelt 5 LWPOLYLINE-Objekte auf Layer
"Z-6" - Streckenzuege, die reale Foerderer-Pfade der HundM05-Zeichnung
nachbilden (siehe data/polylines.md, von Hand ausgewertet). Dieses Skript
macht dieselbe Auswertung reproduzierbar und maschinenlesbar, als Grundlage
fuer einen neuen Testfall (siehe tests/testdata/object_data.md fuer das
fuer einen neuen Testfall (siehe doc/json-input-lisp.md fuer das
allgemeine JSON-Schema).
Eine Polylinie kann auf einer gekippten Ebene liegen (Gruppencode
+25 -5
View File
@@ -97,14 +97,31 @@ def load_flat_mapping(section, cfg_path=None):
return {}
def braucht_attribs(fn):
"""Markiert einen Transform, der NEBEN seinem Rohwert auch das komplette
Attribut-Dict des Blocks braucht - er wird dann als fn(wert, attribs)
aufgerufen statt als fn(wert).
Noetig fuer Merkmale, deren Wert von einem ZWEITEN Attribut abhaengt: die
AS-/ES-Seite (4428/4430) darf nur gefuellt werden, wenn der zugehoerige
WINKEL_AS/WINKEL_ES ueberhaupt ein Element ausweist. Ohne diese Klammer
muessten solche Merkmale wieder aus der Config heraus in Python-Code
wandern - genau das soll sivas.cfg vermeiden.
"""
fn.braucht_attribs = True
return fn
def anwenden(section_mapping, attribs, transforms):
"""Wendet eine per load_section_mapping() gelesene Zuordnung auf ein
Block-Attribut-Dict an.
transforms: dict transform_name -> Funktion(wert) -> wert. Ein in der
Config genannter, aber hier unbekannter Transform-Name wird ignoriert
(Rohwert bleibt unveraendert) statt abzubrechen - ein Tippfehler in
sivas.cfg darf den Export nicht zum Stillstand bringen.
transforms: dict transform_name -> Funktion(wert) -> wert. Ein mit
@braucht_attribs markierter Transform wird stattdessen als
fn(wert, attribs) aufgerufen und sieht damit die uebrigen Attribute des
Blocks. Ein in der Config genannter, aber hier unbekannter Transform-Name
wird ignoriert (Rohwert bleibt unveraendert) statt abzubrechen - ein
Tippfehler in sivas.cfg darf den Export nicht zum Stillstand bringen.
Rueckgabe: dict merkmal -> wert. Ohne Transform wird der Rohwert zu str()
gewandelt (bisheriges Verhalten der build_*_details-Funktionen: alles
@@ -124,7 +141,10 @@ def anwenden(section_mapping, attribs, transforms):
if transform:
fn = transforms.get(transform)
if fn is not None:
ergebnis[merkmal] = fn(wert)
if getattr(fn, "braucht_attribs", False):
ergebnis[merkmal] = fn(wert, attribs)
else:
ergebnis[merkmal] = fn(wert)
continue
ergebnis[merkmal] = str(wert)
return ergebnis
+84 -3
View File
@@ -65,6 +65,11 @@ PREFIX = "[vf_journal_export]"
XDATA_APP = "SSG_VF_EDIT"
MARKER = "linienzug"
# Marker -> Modus-Nummer. "linienzug" (Modus 1) hat ein Frage-Antwort-
# Protokoll (Grammatik in vf_linienzug_grammatik.py); "linienzug2"/"3" werden
# als Rohtokens ausgegeben - fuer sie gibt es bewusst keine Grammatik, ihre
# SEGS-Eintraege machen sie ohnehin selbst-enthalten.
MARKER_MODUS = {"linienzug": 1, "linienzug2": 2, "linienzug3": 3}
# Steuerzeichen-Notation des Eintrags-Trenners im ASCII-DXF (siehe Kopf).
TRENNER = "^J"
@@ -322,6 +327,19 @@ class ProtokollSink(Sink):
self.add("point_abs", "getpoint Startpunkt der Kette (absoluter "
"Weltpunkt, einziger Anker der ganzen Kette)", wert=punkt)
def as_achse(self, punkt):
# Zweiter absoluter Punkt - gibt NUR die Richtung der Zufuehrung,
# der Startpunkt bleibt der Sitz des AS-Elements. "nil" heisst: die
# Frage wurde gestellt und bewusst uebergangen (die Kettenrichtung
# kommt dann aus dem naechsten Klick).
if punkt is None:
self.add("nil", "getpoint Zufuehrungsachse - 2. Punkt: keiner "
"gewaehlt, Richtung kommt aus dem naechsten Klick")
else:
self.add("point_abs", "getpoint Zufuehrungsachse - 2. Punkt "
"(gibt nur die Richtung, der Startpunkt bleibt)",
wert=punkt)
def linie(self, dl, hz, frage):
was = _vorlage(_LINIE_TEXT, frage)
if hz is None:
@@ -541,6 +559,58 @@ def baue_testfaelle(inserts, strecken, startpunkt_fix, quelle_datei,
return faelle
def baue_tokenfaelle(inserts, quelle_datei):
"""Modus-2/3-Ketten als TOKEN-Records (flache Liste, Kopf + Eintraege).
Warum nicht wie Modus 1 als Frage-Antwort-Protokoll: dafuer braeuchte es
eine Grammatik des Modus-2/3-Fragenflusses, die es (bewusst, siehe
doc/todo/spec-modus2-3.md) nicht gibt. Die Tokens selbst sind aber bereits
aussagekraeftig - vor allem traegt SEGS die komplette Segment-Essenz, womit
die Kette OHNE Zeichnung abspielbar ist.
Ausgabeformat ist genau das, was tests/test_linienzug.lsp
(c:TEST_LINIENZUG_MODUS2) einliest:
{"art": "PT", "x": .., "y": .., "z": ..}
{"art": "REAL", "wert": ..} {"art": "INT", "wert": ..}
{"art": "STR", "wert": ".."} {"art": "SEGS", "wert": ".."}
Die Metadaten-Zeile (beschreibung/erwartung_*) ist bewusst NICHT dabei -
die wird je Testfall von Hand gepflegt und soll beim Neuerzeugen nicht
stillschweigend ueberschrieben werden.
"""
faelle = []
for nr, e in enumerate(sorted(inserts, key=lambda i: i["x"]), start=1):
toks = tokens_aus_journal(e["journal_roh"])
modus = MARKER_MODUS.get(e["marker"], 0)
kopf = {
"test_id": "VF_%s_M%d_%02d" % (TEST_ID_ANLAGE, modus, nr),
"anlagetyp": e["marker"],
"modus": modus,
"anzahl_eingaben": len(toks),
"block": e["block"],
"dxf_handle": e["handle"],
"beschreibung": ("Linienzug Modus %d, %d Journal-Eintraege - "
"Rohtokens, siehe doc/todo/spec-modus2-3.md"
% (modus, len(toks))),
"quelle": ("INSERT %s (Block %s) in %s, XDATA-App %s, Marker %s"
% (e["handle"], e["block"],
os.path.basename(quelle_datei), XDATA_APP,
e["marker"])),
}
faelle.append(kopf)
for art, wert in toks:
if art == "PT":
x, y, z = [float(v) for v in wert.split(",")]
faelle.append({"art": "PT", "x": round(x, RUND),
"y": round(y, RUND), "z": round(z, RUND)})
elif art == "REAL":
faelle.append({"art": "REAL", "wert": float(wert)})
elif art == "INT":
faelle.append({"art": "INT", "wert": int(wert)})
else:
faelle.append({"art": art, "wert": wert})
return faelle
def arrays_einzeilig(text):
"""Rein numerische JSON-Arrays auf eine Zeile ziehen (wie
dxf_scan_components.py) - ssg-parse-json-array (Lisp/ssg_core.lsp) liest
@@ -585,9 +655,20 @@ def main():
print("%s %d verbundene Strecken im Sivas-Export"
% (PREFIX, len(strecken)))
faelle = baue_testfaelle(inserts, strecken,
not args.kein_startpunkt_fix, args.dxf,
args.output)
# Nach Modus trennen: nur Modus 1 hat eine Grammatik. Frueher lief die
# Modus-1-Grammatik auch ueber "linienzug2"/"3" (der startswith-Filter
# laesst sie durch) und brach mit "Token 6: STEP erwartet" ab.
m1 = [e for e in inserts if MARKER_MODUS.get(e["marker"]) == 1]
m23 = [e for e in inserts if MARKER_MODUS.get(e["marker"]) in (2, 3)]
faelle = []
if m1:
faelle.extend(baue_testfaelle(m1, strecken,
not args.kein_startpunkt_fix, args.dxf,
args.output))
if m23:
print("%s %d Modus-2/3-Kette(n) als Rohtokens (keine Grammatik, "
"siehe doc/todo/spec-modus2-3.md)" % (PREFIX, len(m23)))
faelle.extend(baue_tokenfaelle(m23, args.dxf))
koepfe = [f for f in faelle if "test_id" in f]
for f in koepfe:
print("%s %s %s (%s) %d Eingaben"
+29 -1
View File
@@ -287,6 +287,12 @@ class Sink(object):
def praeambel_start(self):
"""Kettenkopf beginnt (Startpunkt/Starthoehe/AS-Element)."""
def as_achse(self, punkt):
"""Zufuehrungsachse des AS-Elements verbraucht (2. Punkt, gibt nur
die Richtung). punkt = [x, y, z] oder None, wenn die Kette bewusst
OHNE Achse gebaut wurde. Wird gar nicht gerufen, wenn das Journal
diese Frage noch nicht kennt (vor 2026-10-21)."""
def praeambel_ende(self):
"""Kettenkopf fertig."""
@@ -518,9 +524,31 @@ class Kern(object):
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")
# Drei Journal-Formate, unterscheidbar an der Eintragsart (siehe
# vfl-frage-as-winkel-seite-oder-achse):
# PT direkt nach dem Winkel -> Alt-Achse ohne Seitenfrage
# (30-Grad-Element, 2026-10-14
# bis 10-20); die Seite wurde
# damals aus der Lage des
# naechsten Punkts ermittelt.
# STR, dann PT bzw. NIL -> Format ab 2026-10-21: Seite UND
# (optionale) Zufuehrungsachse.
# STR, dann etwas anderes -> Altbestand ohne Achsenfrage.
if self.art() == "PT":
self._as_achse()
else:
self._code("as_seite", SEITE, "AS-Seite", "as_seite")
if self.art() == "PT":
self._as_achse()
elif self.art() == "NIL":
self.nimm("NIL")
self.sink.as_achse(None)
self.sink.praeambel_ende()
def _as_achse(self):
"""Zufuehrungsachse: zweiter Punkt, der NUR die Richtung gibt."""
self.sink.as_achse([float(v) for v in self.nimm("PT").split(",")])
def sektionen(self):
while self.pos < len(self.tok):
label = self.nimm("STEP")
+26
View File
@@ -87,6 +87,11 @@ def tokens_aus_protokoll(eintraege):
tok.append(("STR", str(e["wert"])))
elif typ == "step":
tok.append(("STEP", str(e["wert"])))
elif typ == "nil":
# Eine Frage, die gestellt und bewusst uebergangen wurde (bisher
# nur die Zufuehrungsachse des AS-Elements) - im Journal ein
# "NIL"-Eintrag ohne Wert.
tok.append(("NIL", None))
else:
raise ValueError("unbekannter Eintragstyp: %r" % typ)
return tok
@@ -255,6 +260,14 @@ class SpecSink(Sink):
def punkt_abs(self, punkt):
self.ziel["punkt"] = punkt
def as_achse(self, punkt):
# Zufuehrungsachse des AS-Elements. "ohne" ist NICHT dasselbe wie ein
# fehlendes Feld: "ohne" heisst "die Frage wurde gestellt und bewusst
# uebergangen" (NIL im Journal), ein fehlendes Feld heisst "das
# Journal kennt die Frage gar nicht" (vor 2026-10-21). Nur so bleibt
# der Rundlauf Journal -> Spec -> Journal tokengleich.
self.ziel["as_achse"] = punkt if punkt is not None else "ohne"
def linie(self, dl, hz, frage):
self.ziel["dl"] = dl
if hz is not None:
@@ -398,6 +411,14 @@ class SpecSchreiber(object):
self.raus("STR", str(self.feld(start, "as_winkel")))
self.raus("STR", code_aus_wert(SEITE, self.feld(start, "as_seite"),
"as_seite"))
# Optional, Gegenstueck zu SpecSink.as_achse und zu
# vsp-praeambel in Lisp/vf_spec.lsp: Liste -> PT, "ohne" -> NIL,
# fehlt -> gar kein Token (Journalformat vor 2026-10-21).
achse = start.get("as_achse")
if isinstance(achse, (list, tuple)):
self.raus("PT", ",".join(_zahl(v) for v in achse))
elif achse == "ohne":
self.raus("NIL", None)
def sektion(self, sek, erster, naechste=None):
label = self.feld(sek, "glied")
@@ -641,6 +662,11 @@ def flache_objekte(spec_id, spec, kopf_extra=None):
if start["as"]:
kopf["as_winkel"] = start["as_winkel"]
kopf["as_seite"] = start["as_seite"]
# Nur schreiben, wenn vorhanden - ein fehlendes Feld ist die
# bewusste Aussage "Journalformat ohne Achsenfrage" (siehe
# SpecSink.as_achse), kein Datenverlust.
if "as_achse" in start:
kopf["as_achse"] = start["as_achse"]
for k, v in (kopf_extra or collections.OrderedDict()).items():
kopf[k] = v
objekte.append(kopf)