[FEAT] Werkzeuge zum Auslesen fremder Zeichnungen und der VF-Eingabe-Journale
Grundlage fuer reproduzierbare Nachbau-Testfaelle aus echten Projekt- zeichnungen (HundM05). - lib/vf_journal_export.py (neu): liest die Eingabe-Journale der VF_n-Bloecke aus der XDATA (App SSG_VF_EDIT, Marker "linienzug") und schreibt sie als Frage-Antwort-Protokoll im Schema von tests/testdata/linienzug_tests.json - Gegenstueck zu vfl-entry->string, ohne ezdxf (reine Gruppencode-Lesung). Optional --csv fuer Strecken-ID und Erwartungswerte je Kette. - lib/extract_polylines.py (neu): LWPOLYLINE-Objekte als JSON, inkl. OCS->Welt-Umrechnung fuer gekippte Ebenen (Handles, Segment-/Gesamtlaengen). - lib/dxf_scan_components.py: erkennt jetzt zusaetzlich, ob ein Block ein 2D-Schema oder ein 3D-Modell ist (TYPEN_3D, ist_3d_block/darstellung_von) - eine Anlagenzeichnung enthaelt beides. Dazu Sensor-Erfassung, Zwillings-Zusammenfuehrung und Hoehenschaetzung aus der Umgebung. - lib/dxf_abbild.py: gibt die erkannte Darstellung je Element mit aus. - tests/test_vf_journal_grammatik.py (neu): prueft den Journal-Dekoder gegen handgebaute Journale - ohne CAD und ohne die grosse DXF. Deckt die zwei Zweige ab, die in den echten Ketten nicht vorkommen: Glied "Linie-VF" und eine VF-Einheit mit gewinkeltem Erstkoerper (dort fragt vfl-vf-einheit KEINE Separator-/Endpunkt-Fragen vorab, anders als beim horizontalen). - data/polylines.md, tests/testdata/hundm05_linienzug_polylines.json: ausgewertete Streckenzuege der Quellzeichnung. - .gitignore: die Quellzeichnungen selbst (data/polylines.dxf 124 MB, data/polylines.dwg, tests/HM5.dwg) bleiben draussen - eingecheckt ist die Auswertung, nicht die Zeichnung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,3 +193,9 @@ data/block_libraries/ils_library.dxf
|
||||
# Grosse Test-Zeichnungen (Arbeitsstaende, gehoeren nicht ins Repo)
|
||||
/tests/test.dwg
|
||||
/tests/test.dxf
|
||||
/tests/HM5.dwg
|
||||
# Quellzeichnungen der HundM05-Analyse (124 MB ASCII-DXF bzw. 24 MB DWG).
|
||||
# Ausgewertet in data/polylines.md und tests/testdata/hundm05.json - die
|
||||
# Auswertung ist eingecheckt, die Zeichnung selbst nicht.
|
||||
/data/polylines.dwg
|
||||
/data/polylines.dxf
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Polylinien aus `data/polylines.dxf`
|
||||
|
||||
Quelle: `data/polylines.dxf` (ASCII-DXF, AC1032 / AutoCAD 2018, ca. 143 MB).
|
||||
Die Datei enthaelt genau **5 LWPOLYLINE**-Objekte, alle auf Layer `Z-6`,
|
||||
alle direkt am Anfang der `ENTITIES`-Sektion (ab Zeile 159094). In der
|
||||
`BLOCKS`-Sektion liegen keine Polylinien.
|
||||
|
||||
Alle Polylinien sind **offen** (Gruppencode `70 = 0`) und haben **keine Bulges**
|
||||
(kein Gruppencode `42`), sind also reine Streckenzuege.
|
||||
|
||||
Die Spalte **Laenge** gibt jeweils die Strecke vom vorhergehenden zum aktuellen
|
||||
Punkt an (Segmentlaenge). Da OCS ein orthonormales System ist, entspricht die
|
||||
in der Polylinien-Ebene gemessene Laenge der echten 3D-Laenge im Weltsystem.
|
||||
|
||||
---
|
||||
|
||||
## Polylinie 1 - Handle `CA`
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Handle (5) | `CA` |
|
||||
| Layer (8) | `Z-6` |
|
||||
| Stuetzpunkte (90) | 5 |
|
||||
| Geschlossen (70) | 0 (offen) |
|
||||
| Elevation (38) | -271.758876 |
|
||||
| Extrusion (210/220/230) | -1.3674923e-07 / -0.043577812 / -0.999050036 |
|
||||
|
||||
Achtung: Die Extrusionsrichtung ist **nicht** (0,0,1). Die Gruppencodes
|
||||
`10`/`20` sind daher **OCS**-Koordinaten, keine Weltkoordinaten. Beide
|
||||
Darstellungen sind unten aufgefuehrt.
|
||||
|
||||
### OCS-Koordinaten (Rohwerte 10/20)
|
||||
|
||||
| Nr. | X | Y | Laenge zum Vorgaenger |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 | 8610.178314 | -1646.791925 | - |
|
||||
| 2 | 8608.179544 | -6230.251177 | 4583.4597 |
|
||||
| 3 | 9758.166544 | -6230.250709 | 1149.9870 |
|
||||
| 4 | 9758.180524 | -5392.918048 | 837.3327 |
|
||||
| 5 | 9758.945042 | -1661.656105 | 3731.2620 |
|
||||
| | | **Gesamtlaenge** | **10302.0414** |
|
||||
|
||||
### Weltkoordinaten (aus OCS umgerechnet, Arbitrary-Axis-Algorithmus)
|
||||
|
||||
| Nr. | X | Y | Z | Laenge zum Vorgaenger |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 1 | 8610.1835 | 1657.0432 | 199.7371 | - |
|
||||
| 2 | 8608.1991 | 6236.1483 | 0.0000 | 4583.4597 |
|
||||
| 3 | 9758.1861 | 6236.1442 | 0.0000 | 1149.9870 |
|
||||
| 4 | 9758.1975 | 5399.6070 | 36.4891 | 837.3327 |
|
||||
| 5 | 9758.9503 | 1671.8896 | 199.0894 | 3731.2620 |
|
||||
| | | | **Gesamtlaenge** | **10302.0414** |
|
||||
|
||||
---
|
||||
|
||||
## Polylinie 2 - Handle `CD`
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Handle (5) | `CD` |
|
||||
| Layer (8) | `Z-6` |
|
||||
| Stuetzpunkte (90) | 6 |
|
||||
| Geschlossen (70) | 0 (offen) |
|
||||
| Elevation (38) | 199.737126 |
|
||||
| Extrusion (210/220/230) | Standard (0/0/1) |
|
||||
|
||||
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
|
||||
|
||||
| Nr. | X | Y | Laenge zum Vorgaenger |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 | 4927.055794 | 1205.360995 | - |
|
||||
| 2 | 4932.562971 | 7060.201678 | 5854.8433 |
|
||||
| 3 | 7106.355038 | 7060.201678 | 2173.7921 |
|
||||
| 4 | 7106.138574 | 4645.514574 | 2414.6871 |
|
||||
| 5 | 6201.192989 | 4645.514574 | 904.9456 |
|
||||
| 6 | 6176.269578 | 1205.360995 | 3440.2439 |
|
||||
| | | **Gesamtlaenge** | **14788.5119** |
|
||||
|
||||
---
|
||||
|
||||
## Polylinie 3 - Handle `FB`
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Handle (5) | `FB` |
|
||||
| Layer (8) | `Z-6` |
|
||||
| Stuetzpunkte (90) | 5 |
|
||||
| Geschlossen (70) | 0 (offen) |
|
||||
| Elevation (38) | 2366.737126 |
|
||||
| Extrusion (210/220/230) | Standard (0/0/1) |
|
||||
|
||||
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
|
||||
|
||||
| Nr. | X | Y | Laenge zum Vorgaenger |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 | 12891.792045 | 1205.360995 | - |
|
||||
| 2 | 12908.891320 | 6244.648400 | 5039.3164 |
|
||||
| 3 | 12908.891320 | 6244.648400 | 0.0000 |
|
||||
| 4 | 13904.931447 | 6244.648400 | 996.0401 |
|
||||
| 5 | 13898.258832 | 1695.891177 | 4548.7621 |
|
||||
| | | **Gesamtlaenge** | **10584.1187** |
|
||||
|
||||
---
|
||||
|
||||
## Polylinie 4 - Handle `12B`
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Handle (5) | `12B` |
|
||||
| Layer (8) | `Z-6` |
|
||||
| Stuetzpunkte (90) | 5 |
|
||||
| Geschlossen (70) | 0 (offen) |
|
||||
| Elevation (38) | 2266.737126 |
|
||||
| Extrusion (210/220/230) | Standard (0/0/1) |
|
||||
|
||||
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
|
||||
|
||||
| Nr. | X | Y | Laenge zum Vorgaenger |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 | 17198.353387 | 1205.360995 | - |
|
||||
| 2 | 17215.452662 | 6244.648400 | 5039.3164 |
|
||||
| 3 | 17215.452662 | 6244.648400 | 0.0000 |
|
||||
| 4 | 18211.492790 | 6244.648400 | 996.0401 |
|
||||
| 5 | 18204.820174 | 1695.891177 | 4548.7621 |
|
||||
| | | **Gesamtlaenge** | **10584.1187** |
|
||||
|
||||
---
|
||||
|
||||
## Polylinie 5 - Handle `15A`
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Handle (5) | `15A` |
|
||||
| Layer (8) | `Z-6` |
|
||||
| Stuetzpunkte (90) | 5 |
|
||||
| Geschlossen (70) | 0 (offen) |
|
||||
| Elevation (38) | 2266.737126 |
|
||||
| Extrusion (210/220/230) | Standard (0/0/1) |
|
||||
|
||||
### Koordinaten (10/20, entsprechen den Weltkoordinaten X/Y)
|
||||
|
||||
| Nr. | X | Y | Laenge zum Vorgaenger |
|
||||
|---:|---:|---:|---:|
|
||||
| 1 | 21480.091053 | 1205.360995 | - |
|
||||
| 2 | 21497.190328 | 6244.648400 | 5039.3164 |
|
||||
| 3 | 21497.190328 | 6244.648400 | 0.0000 |
|
||||
| 4 | 22493.230455 | 6244.648400 | 996.0401 |
|
||||
| 5 | 22486.557840 | 1695.891177 | 4548.7621 |
|
||||
| | | **Gesamtlaenge** | **10584.1187** |
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Nr. | Handle | Stuetzpunkte | Gesamtlaenge | kuerzestes Segment | laengstes Segment |
|
||||
|---:|---|---:|---:|---:|---:|
|
||||
| 1 | `CA` | 5 | 10302.0414 | 837.3327 | 4583.4597 |
|
||||
| 2 | `CD` | 6 | 14788.5119 | 904.9456 | 5854.8433 |
|
||||
| 3 | `FB` | 5 | 10584.1187 | 0.0000 | 5039.3164 |
|
||||
| 4 | `12B` | 5 | 10584.1187 | 0.0000 | 5039.3164 |
|
||||
| 5 | `15A` | 5 | 10584.1187 | 0.0000 | 5039.3164 |
|
||||
|
||||
## Auffaelligkeiten
|
||||
|
||||
- Polylinie 1 (`CA`) liegt in einer gekippten Ebene (Extrusion ca. 2.5 Grad
|
||||
gegen die Z-Achse) und ist die einzige mit OCS-Koordinaten.
|
||||
- Die Polylinien 3 (`FB`), 4 (`12B`) und 5 (`15A`) sind formgleich und nur in
|
||||
X versetzt (Versatz ca. 4306.56 bzw. 4281.74).
|
||||
- In den Polylinien 3, 4 und 5 ist jeweils Punkt 3 ein **Duplikat** von Punkt 2
|
||||
(Segmentlaenge 0.0000). Falls diese Daten weiterverarbeitet werden, sollten
|
||||
die Nullsegmente vorher entfernt werden.
|
||||
+18
-7
@@ -46,7 +46,8 @@ from ezdxf.math import Matrix44
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from dxf_scan_components import ( # noqa: E402
|
||||
RUND, familie_von, insert_matrix, lese_inserts, im_fenster)
|
||||
RUND, darstellung_von, familie_von, insert_matrix, lese_inserts,
|
||||
im_fenster)
|
||||
|
||||
PREFIX = "[dxf_abbild]"
|
||||
|
||||
@@ -146,13 +147,19 @@ def welt_daten(rec, mutter, block_base):
|
||||
return mat, ursprung, rot
|
||||
|
||||
|
||||
def element_dict(rec, ursprung, rot, tiefe):
|
||||
def element_dict(rec, ursprung, rot, tiefe, block_info=None,
|
||||
dim_memo=None):
|
||||
"""Einen INSERT als Abbild-Eintrag beschreiben."""
|
||||
attribs = dict(rec["attribs"])
|
||||
typ, typ_quelle = typ_von(attribs, rec["name"])
|
||||
eintrag = collections.OrderedDict()
|
||||
eintrag["block"] = rec["name"]
|
||||
eintrag["layer"] = rec["layer"] or ""
|
||||
if block_info is not None:
|
||||
# 2D-Schema oder 3D-Modell - die Zeichnung enthaelt beides fuer
|
||||
# dieselbe Anlage (siehe dxf_scan_components.TYPEN_3D).
|
||||
eintrag["darstellung"] = darstellung_von(rec["name"], block_info,
|
||||
dim_memo)
|
||||
eintrag["typ"] = typ
|
||||
eintrag["typ_quelle"] = typ_quelle
|
||||
eintrag["position"] = [round(ursprung.x, RUND) + 0.0,
|
||||
@@ -183,13 +190,14 @@ def element_dict(rec, ursprung, rot, tiefe):
|
||||
|
||||
|
||||
def baue_abbild(ms_inserts, block_inserts, block_base, fenster=None,
|
||||
max_tiefe=12, ohne_container=False):
|
||||
max_tiefe=12, ohne_container=False, block_info=None):
|
||||
"""Alle Modelspace-INSERTs als Abbild-Baum beschreiben.
|
||||
|
||||
Rueckgabe: (elemente, statistik). Jedes Element der obersten Ebene enthaelt
|
||||
seine aufgeloesten Unterkomponenten unter "unterkomponenten".
|
||||
"""
|
||||
stat = collections.Counter()
|
||||
dim_memo = {}
|
||||
|
||||
def kinder_von(name, mat, tiefe, pfad):
|
||||
"""Verschachtelte INSERTs eines Blocks rekursiv beschreiben."""
|
||||
@@ -203,7 +211,8 @@ def baue_abbild(ms_inserts, block_inserts, block_base, fenster=None,
|
||||
stat["container_uebersprungen"] += 1
|
||||
continue
|
||||
kind_mat, ursprung, rot = welt_daten(rec, mat, block_base)
|
||||
eintrag = element_dict(rec, ursprung, rot, tiefe)
|
||||
eintrag = element_dict(rec, ursprung, rot, tiefe, block_info,
|
||||
dim_memo)
|
||||
stat["unterkomponenten"] += 1
|
||||
if rec["name"] not in pfad:
|
||||
enkel = kinder_von(rec["name"], kind_mat, tiefe + 1,
|
||||
@@ -224,7 +233,7 @@ def baue_abbild(ms_inserts, block_inserts, block_base, fenster=None,
|
||||
if ohne_container and GEOMETRIE_CONTAINER.match(rec["name"]):
|
||||
stat["container_uebersprungen"] += 1
|
||||
continue
|
||||
eintrag = element_dict(rec, ursprung, rot, 0)
|
||||
eintrag = element_dict(rec, ursprung, rot, 0, block_info, dim_memo)
|
||||
eintrag["nr"] = nr
|
||||
eintrag.move_to_end("nr", last=False)
|
||||
kinder = kinder_von(rec["name"], mat, 1, frozenset({rec["name"]}))
|
||||
@@ -264,14 +273,16 @@ def main():
|
||||
|
||||
groesse = os.path.getsize(args.dxf) / (1024.0 * 1024.0)
|
||||
print("%s lese %s (%.1f MB) ..." % (PREFIX, args.dxf, groesse))
|
||||
ms_inserts, block_inserts, block_base, statistik = lese_inserts(args.dxf)
|
||||
(ms_inserts, block_inserts, block_base, statistik,
|
||||
block_info) = lese_inserts(args.dxf)
|
||||
print("%s %d INSERTs im Modelspace, %d INSERTs gesamt, %d mit gekippter OCS"
|
||||
% (PREFIX, len(ms_inserts), statistik["inserts"],
|
||||
statistik["ocs_gekippt"]))
|
||||
|
||||
elemente, stat = baue_abbild(ms_inserts, block_inserts, block_base,
|
||||
fenster=fenster, max_tiefe=args.max_tiefe,
|
||||
ohne_container=args.ohne_geometrie_container)
|
||||
ohne_container=args.ohne_geometrie_container,
|
||||
block_info=block_info)
|
||||
|
||||
# Kennzahlen, die belegen, wie belastbar die Typangaben sind
|
||||
quellen = collections.Counter()
|
||||
|
||||
+386
-32
@@ -76,6 +76,53 @@ Z_PLAUSIBEL = (-1000.0, 20000.0)
|
||||
# Groesste plausible XY-Ausdehnung einer Anlage (mm).
|
||||
XY_MAX_AUSDEHNUNG = 500000.0
|
||||
|
||||
# --- 2D-Symbol oder 3D-Modell? --------------------------------------------
|
||||
# ST500592_05.dxf enthaelt DIESELBE Anlage zweimal: als flaches Schema (70,6 %
|
||||
# der Modelspace-INSERTs, alle Z=0) und als 3D-Modell (29,4 %, echte Hoehen).
|
||||
# Nachgewiesen: alle 12 flachen Antriebsstationen haben eine 3D-Antriebsstation
|
||||
# in unter 1,5 m Abstand (Versatz ~340 mm, unterschiedliche Bezugspunkte).
|
||||
# Wer die beiden Darstellungen mischt, erfindet Geometrie: eine Paarung
|
||||
# "flaches Symbol bei Z=0" mit "3D-Modell bei Z=2338" ergibt eine
|
||||
# Hoehendifferenz, die es in der Anlage nicht gibt.
|
||||
# Die Einordnung erfolgt ueber die GEOMETRIE der Blockdefinition, nicht ueber
|
||||
# den Namen: nur 37 von 2099 Bloecken tragen "3D" im Namen, und die Layer-Regel
|
||||
# (_3D*/3d*) liegt bei 98,2 % - sie verfehlt u.a. "tef90i" auf Layer F-1.
|
||||
TYPEN_3D = frozenset([
|
||||
"3DSOLID", "3DFACE", "SURFACE", "PLANESURFACE", "SWEPTSURFACE",
|
||||
"EXTRUDEDSURFACE", "REVOLVEDSURFACE", "LOFTEDSURFACE", "MESH", "REGION",
|
||||
"BODY", "POLYFACEMESH"])
|
||||
|
||||
# Gruppencodes mit Z-Anteil, die die Z-Ausdehnung eines Blocks aufspannen.
|
||||
Z_CODES_BLOCK = frozenset([30, 31, 32, 33, 38])
|
||||
|
||||
# Ab dieser Z-Ausdehnung (mm) gilt eine Blockdefinition als 3D.
|
||||
Z_TOLERANZ_3D = 0.001
|
||||
|
||||
# --- Achsentreue der Foerderer ---------------------------------------------
|
||||
# Die Anlage ist rechtwinklig aufgebaut: 89 von 91 Bauteil-Rotationen liegen
|
||||
# exakt auf einem Vielfachen von 90 Grad. Die Richtung eines Foerderers darf
|
||||
# darum NICHT einfach aus der Verbindung der beiden Einfuegepunkte kommen -
|
||||
# Antriebs- und Umlenkstation haben unterschiedliche Bezugspunkte. Gemessen:
|
||||
# vier baugleiche Fingerfoerderer haben alle denselben Querversatz von ~1000 mm
|
||||
# zwischen Antriebs- und Umlenk-Einfuegepunkt. Aus dem Verbindungsvektor ergaben
|
||||
# sich dadurch 105,34 Grad statt 90 und 3775 mm statt 3641 mm Laenge.
|
||||
# Vorgehen: Richtung auf die naechste Achse einrasten, Laenge darauf projizieren,
|
||||
# und den Querversatz als Pruefgroesse verwenden.
|
||||
ACHSE_RASTER = 90.0
|
||||
|
||||
# Querversatz (mm), bis zu dem er als Bezugspunkt-Unterschied gilt. Darueber
|
||||
# passen die beiden Stationen nicht zu einem geraden Foerderer und die Paarung
|
||||
# ist falsch (Beispiel: 6694 mm Querversatz bei einer angeblichen Laenge von
|
||||
# 13009 mm - das waeren zwei Stationen aus verschiedenen Foerderern).
|
||||
ACHSE_MAX_QUER = 1500.0
|
||||
|
||||
# Maximaler XY-Abstand (mm), bis zu dem ein 2D-Symbol und ein 3D-Modell als
|
||||
# DASSELBE Bauteil gelten. Gemessen an dieser Zeichnung: alle 12 flachen
|
||||
# Antriebsstationen haben ihren 3D-Zwilling in 337..353 mm Abstand, die
|
||||
# Umlenkstationen in 184..493 mm. 1500 mm laesst Luft und traf im Test 100 %
|
||||
# der Antriebsstationen, ohne benachbarte Bauteile zu verwechseln.
|
||||
ZWILLING_MAX_ABSTAND = 1500.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsfunktionen
|
||||
@@ -329,15 +376,18 @@ def system_von(bname):
|
||||
def lese_inserts(dxf_path):
|
||||
"""Liest alle INSERTs (Modelspace und Blockdefinitionen) plus Basispunkte.
|
||||
|
||||
Rueckgabe: (ms_inserts, block_inserts, block_base, statistik)
|
||||
Rueckgabe: (ms_inserts, block_inserts, block_base, statistik, block_info)
|
||||
ms_inserts - Liste der INSERTs der ENTITIES-Sektion
|
||||
block_inserts- dict blockname -> Liste der darin verschachtelten INSERTs
|
||||
block_base - dict blockname -> Basispunkt [x, y, z]
|
||||
statistik - dict mit Zaehlern fuer die Diagnoseausgabe
|
||||
block_info - dict blockname -> {"typen", "zmin", "zmax", "kinder"};
|
||||
Grundlage fuer ist_3d_block (2D-Symbol oder 3D-Modell)
|
||||
"""
|
||||
ms_inserts = []
|
||||
block_inserts = {}
|
||||
block_base = {}
|
||||
block_info = {}
|
||||
statistik = {"inserts": 0, "ocs_gekippt": 0, "papierbereich": 0}
|
||||
|
||||
section = None
|
||||
@@ -354,6 +404,24 @@ def lese_inserts(dxf_path):
|
||||
"ext": [0.0, 0.0, 1.0], "_ms": im_modelspace,
|
||||
"_parent": parent}
|
||||
|
||||
def block_merken(name):
|
||||
return block_info.setdefault(name, {"typen": set(), "zmin": 0.0,
|
||||
"zmax": 0.0, "kinder": set()})
|
||||
|
||||
def z_merken(name, wert):
|
||||
"""Z-Ausdehnung einer Blockdefinition mitschreiben.
|
||||
|
||||
Grundlage der 2D/3D-Einordnung: ein flaches Symbol hat alle Z auf 0,
|
||||
ein 3D-Modell hat eine Z-Ausdehnung. Die Einfuegepunkt-Z verschachtelter
|
||||
INSERTs zaehlen mit - ein Block, der seine Teile auf verschiedenen
|
||||
Hoehen zusammensetzt, ist ebenfalls 3D.
|
||||
"""
|
||||
info = block_merken(name)
|
||||
if wert < info["zmin"]:
|
||||
info["zmin"] = wert
|
||||
if wert > info["zmax"]:
|
||||
info["zmax"] = wert
|
||||
|
||||
def ablegen(datensatz):
|
||||
if datensatz["name"] is None:
|
||||
return
|
||||
@@ -410,6 +478,10 @@ def lese_inserts(dxf_path):
|
||||
elif value == "INSERT":
|
||||
rec = neu_insert(section == "ENTITIES", aktueller_block)
|
||||
statistik["inserts"] += 1
|
||||
# Entity-Typen der Blockdefinition mitschreiben - daraus
|
||||
# ergibt sich, ob der Block ein 3D-Modell ist.
|
||||
if aktueller_block and value != "SEQEND":
|
||||
block_merken(aktueller_block)["typen"].add(value)
|
||||
elif code == 2:
|
||||
if warte_auf_sectionname:
|
||||
section = value
|
||||
@@ -417,8 +489,11 @@ def lese_inserts(dxf_path):
|
||||
elif aktuell == "BLOCK":
|
||||
aktueller_block = value
|
||||
block_base.setdefault(value, [0.0, 0.0, 0.0])
|
||||
block_merken(value)
|
||||
elif aktuell == "INSERT" and rec is not None:
|
||||
rec["name"] = value
|
||||
if aktueller_block:
|
||||
block_merken(aktueller_block)["kinder"].add(value)
|
||||
elif aktuell == "ATTRIB":
|
||||
attrib_tag = value
|
||||
elif aktuell == "BLOCK" and aktueller_block and code in (10, 20, 30):
|
||||
@@ -444,12 +519,18 @@ def lese_inserts(dxf_path):
|
||||
# Nur den ersten Code 1 nehmen; die Zuordnung zum Tag erfolgt
|
||||
# am Ende des ATTRIB (siehe oben beim 0-Tag).
|
||||
attrib_wert = value
|
||||
# Bewusst NACH der Kette und ohne elif: die Z-Ausdehnung einer
|
||||
# Blockdefinition wird aus ALLEN Entities darin gespeist, auch aus
|
||||
# denen, die oben schon behandelt wurden (z.B. Einfuegepunkt-Z
|
||||
# verschachtelter INSERTs).
|
||||
if aktueller_block and code in Z_CODES_BLOCK:
|
||||
z_merken(aktueller_block, value)
|
||||
if rec is not None:
|
||||
ablegen(rec)
|
||||
finally:
|
||||
mm.close()
|
||||
fh.close()
|
||||
return ms_inserts, block_inserts, block_base, statistik
|
||||
return ms_inserts, block_inserts, block_base, statistik, block_info
|
||||
|
||||
|
||||
def insert_matrix(rec, block_base):
|
||||
@@ -479,6 +560,39 @@ def insert_matrix(rec, block_base):
|
||||
return Matrix44.translate(-base[0], -base[1], -base[2]) @ lokal
|
||||
|
||||
|
||||
def ist_3d_block(name, block_info, memo=None, pfad=frozenset()):
|
||||
"""Ist die Blockdefinition ein 3D-Modell (statt eines flachen Symbols)?
|
||||
|
||||
3D, wenn sie echte Volumen-/Flaechentypen enthaelt oder ihre Geometrie eine
|
||||
Z-Ausdehnung hat. Die Einordnung wird transitiv weitergegeben: ein Block,
|
||||
der einen 3D-Block enthaelt, ist selbst 3D. "memo" cached das Ergebnis je
|
||||
Blockname (der Aufrufer sollte ein dict mitgeben und wiederverwenden).
|
||||
"""
|
||||
if memo is None:
|
||||
memo = {}
|
||||
if name in memo:
|
||||
return memo[name]
|
||||
info = block_info.get(name)
|
||||
if info is None or name in pfad:
|
||||
return False
|
||||
memo[name] = False # Zyklenschutz waehrend der Rekursion
|
||||
ergebnis = bool(info["typen"] & TYPEN_3D)
|
||||
if not ergebnis:
|
||||
ergebnis = (info["zmax"] - info["zmin"]) > Z_TOLERANZ_3D
|
||||
if not ergebnis:
|
||||
for kind in info["kinder"]:
|
||||
if ist_3d_block(kind, block_info, memo, pfad | {name}):
|
||||
ergebnis = True
|
||||
break
|
||||
memo[name] = ergebnis
|
||||
return ergebnis
|
||||
|
||||
|
||||
def darstellung_von(name, block_info, memo=None):
|
||||
""""3D" oder "2D" fuer einen Blocknamen."""
|
||||
return "3D" if ist_3d_block(name, block_info, memo) else "2D"
|
||||
|
||||
|
||||
def im_fenster(x, y, fenster):
|
||||
"""Liegt (x, y) im XY-Fenster (x0, y0, x1, y1)? Ohne Fenster: immer ja."""
|
||||
if not fenster:
|
||||
@@ -487,7 +601,8 @@ def im_fenster(x, y, fenster):
|
||||
return x0 <= x <= x1 and y0 <= y <= y1
|
||||
|
||||
|
||||
def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12):
|
||||
def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12,
|
||||
block_info=None):
|
||||
"""Loest alle INSERTs rekursiv in Weltkoordinaten auf.
|
||||
|
||||
Aequivalent zu ezdxf virtual_entities(), aber ohne DOM: die Matrizen
|
||||
@@ -504,6 +619,7 @@ def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12):
|
||||
"""
|
||||
treffer = []
|
||||
unbekannt = {}
|
||||
dim_memo = {}
|
||||
|
||||
def lauf(recs, mutter, tiefe, pfad):
|
||||
if tiefe > max_tiefe:
|
||||
@@ -528,6 +644,11 @@ def sammle_vorkommen(ms_inserts, block_inserts, block_base, max_tiefe=12):
|
||||
"rot": round(rot, RUND) + 0.0,
|
||||
"tiefe": tiefe,
|
||||
"attribs": rec["attribs"],
|
||||
# 2D-Symbol oder 3D-Modell - entscheidet, mit wem dieses
|
||||
# Vorkommen gepaart werden darf (siehe paare_bilden).
|
||||
"darstellung": (darstellung_von(rec["name"], block_info,
|
||||
dim_memo)
|
||||
if block_info else "?"),
|
||||
})
|
||||
elif rec["name"] and not rec["name"].startswith("*"):
|
||||
unbekannt[rec["name"]] = unbekannt.get(rec["name"], 0) + 1
|
||||
@@ -630,25 +751,155 @@ def sivasnr_weiche(bname, katalog):
|
||||
"Katalogtreffer Winkel=%g/%gx%g" % (winkel, breite, laenge))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zusammenfuehrung der beiden Darstellungen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def hoehe_aus_umgebung(punkt, treffer, radius=3000.0, mindestens=2):
|
||||
"""Einbauhoehe eines 2D-Bauteils aus 3D-Nachbarn schaetzen.
|
||||
|
||||
Fuer Bauteile, die es NUR als flaches Symbol gibt (in dieser Zeichnung z.B.
|
||||
der Kreisel: AN8/SP8 sind Linien/Kreis/Text auf Z=0), steht im DXF keine
|
||||
Hoehe. Die 3D-Modelle ringsum stehen aber auf der echten Foerderhoehe -
|
||||
daraus laesst sich die Hoehe belegen statt raten.
|
||||
|
||||
Rueckgabe: (hoehe, begruendung) oder (None, begruendung), wenn zu wenige
|
||||
3D-Nachbarn in Reichweite sind.
|
||||
"""
|
||||
nah = []
|
||||
for t in treffer:
|
||||
if t.get("darstellung") != "3D":
|
||||
continue
|
||||
d = math.hypot(t["x"] - punkt[0], t["y"] - punkt[1])
|
||||
if d <= radius and Z_PLAUSIBEL[0] <= t["z"] <= Z_PLAUSIBEL[1]:
|
||||
nah.append((d, t))
|
||||
if len(nah) < mindestens:
|
||||
return None, ("nur %d 3D-Nachbarn in %.0f mm - zu wenige fuer eine "
|
||||
"belegbare Hoehe" % (len(nah), radius))
|
||||
# Haeufigster gerundeter Z-Wert; bei Gleichstand der naechstgelegene.
|
||||
haeufig = {}
|
||||
for d, t in nah:
|
||||
z = round(t["z"])
|
||||
haeufig.setdefault(z, []).append(d)
|
||||
z_best = max(haeufig, key=lambda z: (len(haeufig[z]), -min(haeufig[z])))
|
||||
return float(z_best), (
|
||||
"aus %d 3D-Nachbarn in %.0f mm abgeleitet; haeufigster Z-Wert %d mm "
|
||||
"(%dx, naechster in %.0f mm)"
|
||||
% (len(nah), radius, z_best, len(haeufig[z_best]),
|
||||
min(haeufig[z_best])))
|
||||
|
||||
|
||||
def zwillinge_zusammenfuehren(treffer):
|
||||
"""2D-Symbol und 3D-Modell desselben Bauteils zu EINEM Vorkommen vereinen.
|
||||
|
||||
Die Zeichnung enthaelt dieselbe Anlage zweimal. Ohne diesen Schritt liefert
|
||||
jede Anlage doppelt: einmal als 3D-Foerderer mit echter Hoehe und einmal als
|
||||
2D-Strecke mit unbekannter Hoehe. Beides ist dasselbe Bauteil.
|
||||
|
||||
Zusammengefuehrt wird nur bei gegenseitig naechstem Nachbarn derselben
|
||||
Familie innerhalb von ZWILLING_MAX_ABSTAND. Das 3D-Vorkommen bleibt der
|
||||
Traeger (es hat die belastbare Geometrie samt Hoehe), uebernimmt aber die
|
||||
Attribute des 2D-Zwillings - denn die Identitaet (BESCHR, ARTINR,
|
||||
KENNZEICHNUNG) steht im 2D-Schema. Die XY-Lage des 2D-Symbols wird NICHT
|
||||
uebernommen: der Versatz von ~340 mm ist ein anderer Bezugspunkt, und
|
||||
welcher davon zu unseren Bloecken passt, ist nicht belegt.
|
||||
|
||||
Rueckgabe: (vorkommen, statistik)
|
||||
"""
|
||||
zwei_d = [t for t in treffer if t.get("darstellung") == "2D"]
|
||||
drei_d = [t for t in treffer if t.get("darstellung") == "3D"]
|
||||
rest = [t for t in treffer if t.get("darstellung") not in ("2D", "3D")]
|
||||
stat = {"zusammengefuehrt": 0, "nur_2d": 0, "nur_3d": 0,
|
||||
"attribute_uebernommen": 0}
|
||||
|
||||
def dist(a, b):
|
||||
return math.hypot(b["x"] - a["x"], b["y"] - a["y"])
|
||||
|
||||
offen_2d = list(zwei_d)
|
||||
offen_3d = list(drei_d)
|
||||
paare = []
|
||||
while True:
|
||||
bester = None
|
||||
for d3 in offen_3d:
|
||||
kand = [d2 for d2 in offen_2d if d2["familie"] == d3["familie"]]
|
||||
if not kand:
|
||||
continue
|
||||
d2 = min(kand, key=lambda k: dist(d3, k))
|
||||
d = dist(d3, d2)
|
||||
if d > ZWILLING_MAX_ABSTAND:
|
||||
continue
|
||||
# Gegenprobe: ist d3 auch fuer d2 das naechste 3D-Vorkommen?
|
||||
gegen = [x for x in offen_3d if x["familie"] == d2["familie"]]
|
||||
if min(gegen, key=lambda k: dist(k, d2)) is not d3:
|
||||
continue
|
||||
if bester is None or d < bester[2]:
|
||||
bester = (d3, d2, d)
|
||||
if bester is None:
|
||||
break
|
||||
d3, d2, d = bester
|
||||
offen_3d.remove(d3)
|
||||
offen_2d.remove(d2)
|
||||
paare.append((d3, d2, d))
|
||||
|
||||
for d3, d2, d in paare:
|
||||
# Attribute des 2D-Zwillings uebernehmen, ohne vorhandene zu ueber-
|
||||
# schreiben - das 2D-Schema traegt die Identitaet, das 3D-Modell die
|
||||
# Geometrie.
|
||||
neu = dict(d2["attribs"])
|
||||
neu.update(d3["attribs"])
|
||||
if neu != d3["attribs"]:
|
||||
stat["attribute_uebernommen"] += 1
|
||||
d3["attribs"] = neu
|
||||
d3["zwilling_2d"] = {"block": d2["block"], "layer": d2["layer"],
|
||||
"abstand": round(d, RUND)}
|
||||
stat["zusammengefuehrt"] += 1
|
||||
|
||||
stat["nur_2d"] = len(offen_2d)
|
||||
stat["nur_3d"] = len(offen_3d)
|
||||
# Zusammengefuehrte 2D-Vorkommen fallen weg (sie sind im 3D-Traeger drin),
|
||||
# eigenstaendige 2D-Vorkommen bleiben - z.B. der Kreisel, den es nur als
|
||||
# flaches Symbol gibt.
|
||||
return drei_d + offen_2d + rest, stat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paarbildung und Schema-Abbildung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def paare_bilden(antriebe, umlenkungen):
|
||||
"""Gegenseitig naechste Nachbarn paaren (nur innerhalb desselben Systems).
|
||||
"""Gegenseitig naechste Nachbarn paaren.
|
||||
|
||||
Gepaart wird nur innerhalb desselben Foerdersystems (TEF/FF/SCHRAEG) UND
|
||||
innerhalb derselben Darstellung (2D-Schema oder 3D-Modell). Letzteres ist
|
||||
entscheidend: die Zeichnung enthaelt dieselbe Anlage zweimal, und eine
|
||||
Paarung ueber die Darstellungsgrenze erfindet eine Hoehendifferenz, die es
|
||||
nicht gibt (flaches Symbol bei Z=0 gegen 3D-Modell bei Z=2338 ergab
|
||||
Foerderer mit deltaH=5827 mm und Startpunkten unter dem Hallenboden).
|
||||
|
||||
Rueckgabe: (paare, rest_antriebe, rest_umlenkungen)
|
||||
"""
|
||||
def dist(a, b):
|
||||
return math.hypot(b["x"] - a["x"], b["y"] - a["y"])
|
||||
|
||||
def passt(a, b):
|
||||
if system_von(a["block"]) != system_von(b["block"]):
|
||||
return False
|
||||
if a.get("darstellung") != b.get("darstellung"):
|
||||
return False
|
||||
# Achstreue: die beiden Stationen muessen auf einer Achse liegen. Der
|
||||
# zulaessige Querversatz deckt den Bezugspunkt-Unterschied der
|
||||
# Blocktypen ab (~1000 mm gemessen), nicht mehr. Ohne diese Bedingung
|
||||
# paart der naechste-Nachbar-Ansatz Stationen aus verschiedenen
|
||||
# Foerderern und erzeugt Diagonalen quer durch die Anlage.
|
||||
return achse_einrasten(a, b)[2] <= ACHSE_MAX_QUER
|
||||
|
||||
paare = []
|
||||
offen_a = list(antriebe)
|
||||
offen_u = list(umlenkungen)
|
||||
while True:
|
||||
bester = None
|
||||
for a in offen_a:
|
||||
kand = [u for u in offen_u if system_von(u["block"]) == system_von(a["block"])]
|
||||
kand = [u for u in offen_u if passt(a, u)]
|
||||
if not kand:
|
||||
continue
|
||||
u = min(kand, key=lambda k: dist(a, k))
|
||||
@@ -656,7 +907,7 @@ def paare_bilden(antriebe, umlenkungen):
|
||||
if d > MAX_PAAR_ABSTAND:
|
||||
continue
|
||||
# Gegenprobe: ist a auch fuer u der naechste Antrieb?
|
||||
kand_a = [x for x in offen_a if system_von(x["block"]) == system_von(u["block"])]
|
||||
kand_a = [x for x in offen_a if passt(x, u)]
|
||||
if min(kand_a, key=lambda k: dist(k, u)) is not a:
|
||||
continue
|
||||
if bester is None or d < bester[2]:
|
||||
@@ -670,6 +921,25 @@ def paare_bilden(antriebe, umlenkungen):
|
||||
return paare, offen_a, offen_u
|
||||
|
||||
|
||||
def achse_einrasten(a, u):
|
||||
"""Foerderer-Achse aus einem Stationspaar bestimmen.
|
||||
|
||||
Rueckgabe: (hz, laenge_axial, querversatz). "hz" ist auf das
|
||||
ACHSE_RASTER eingerastet, "laenge_axial" die Projektion des
|
||||
Verbindungsvektors auf diese Achse, "querversatz" der Rest senkrecht dazu
|
||||
(= Bezugspunkt-Unterschied der beiden Blocktypen, siehe ACHSE_MAX_QUER).
|
||||
"""
|
||||
dx = u["x"] - a["x"]
|
||||
dy = u["y"] - a["y"]
|
||||
roh = math.degrees(math.atan2(dy, dx)) % 360.0
|
||||
hz = (round(roh / ACHSE_RASTER) * ACHSE_RASTER) % 360.0
|
||||
rad = math.radians(hz)
|
||||
# Projektion auf die Achse und den dazu senkrechten Anteil
|
||||
axial = dx * math.cos(rad) + dy * math.sin(rad)
|
||||
quer = -dx * math.sin(rad) + dy * math.cos(rad)
|
||||
return hz, abs(axial), abs(quer)
|
||||
|
||||
|
||||
def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
|
||||
"""Ein Antrieb/Umlenk-Paar auf Gerade, VarioFoerderer (3.1) oder
|
||||
Gefaellestrecke (2.1) abbilden.
|
||||
@@ -687,16 +957,45 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
|
||||
werden als eigener Typ "gerade" ausgewiesen.
|
||||
"""
|
||||
dz = u["z"] - a["z"]
|
||||
hz = round(math.degrees(math.atan2(u["y"] - a["y"], u["x"] - a["x"])) % 360.0, RUND)
|
||||
neigung = round(math.degrees(math.atan2(abs(dz), dist_xy)), RUND) if dist_xy else 0.0
|
||||
herkunft = ("aus DXF: %s -> %s (Layer %s / %s), XY-Abstand %.2f, "
|
||||
"Neigung %.2f Grad" % (a["block"], u["block"], a["layer"],
|
||||
u["layer"], dist_xy, neigung))
|
||||
# Richtung und Laenge auf der Anlagenachse, NICHT aus dem Verbindungsvektor
|
||||
# der Einfuegepunkte (siehe achse_einrasten / ACHSE_MAX_QUER).
|
||||
hz, laenge, quer = achse_einrasten(a, u)
|
||||
laenge = round(laenge, RUND)
|
||||
neigung = round(math.degrees(math.atan2(abs(dz), laenge)), RUND) if laenge else 0.0
|
||||
herkunft = ("aus DXF: %s -> %s (Layer %s / %s), Achse %.0f Grad, "
|
||||
"Laenge %.2f, Querversatz %.2f (Bezugspunkt-Unterschied), "
|
||||
"Neigung %.2f Grad"
|
||||
% (a["block"], u["block"], a["layer"], u["layer"], hz, laenge,
|
||||
quer, neigung))
|
||||
# dist_xy war der reine Punktabstand; ab hier zaehlt die axiale Laenge.
|
||||
dist_xy = laenge
|
||||
|
||||
# Aus dem 2D-Schema ist KEINE Hoehe ableitbar: dort liegt alles auf Z=0
|
||||
# (die wenigen abweichenden Z-Werte - z.B. -3941 und +21677 auf Layer z-3 -
|
||||
# sind Ablage-Artefakte, keine Einbauhoehen). Ein Hoehenunterschied aus
|
||||
# solchen Werten waere erfunden, darum wird ein 2D-Paar immer als
|
||||
# waagrechte Strecke ausgewiesen und die Hoehe als unbekannt markiert.
|
||||
# Die echte Hoehe liefert der 3D-Zwilling (siehe Zusammenfuehrung).
|
||||
if a.get("darstellung") == "2D":
|
||||
return {
|
||||
"test_id": "GERADE_HundM05_%02d" % nummer,
|
||||
"typ": "gerade",
|
||||
"darstellung": "2D",
|
||||
"laenge": round(dist_xy, RUND),
|
||||
"hz": hz,
|
||||
"startpunkt": [a["x"], a["y"], a["z"]],
|
||||
"endpunkt": [u["x"], u["y"], u["z"]],
|
||||
"hoehe_unbekannt": True,
|
||||
"comment": herkunft + "; 2D-Schema - Z ist dort keine Einbauhoehe, "
|
||||
"darum waagrechte Strecke und hoehe_unbekannt=true. "
|
||||
"Einbauhoehe nur ueber den 3D-Zwilling zu bekommen.",
|
||||
}
|
||||
|
||||
if abs(dz) < GERADE_MAX_DELTA_H:
|
||||
return {
|
||||
"test_id": "GERADE_HundM05_%02d" % nummer,
|
||||
"typ": "gerade",
|
||||
"darstellung": a.get("darstellung", "?"),
|
||||
"laenge": round(dist_xy, RUND),
|
||||
"hz": hz,
|
||||
"startpunkt": [a["x"], a["y"], a["z"]],
|
||||
@@ -711,6 +1010,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
|
||||
return {
|
||||
"test_id": "GF_HundM05_%02d" % nummer,
|
||||
"modus": 1,
|
||||
"darstellung": a.get("darstellung", "?"),
|
||||
"deltaL_mm": round(dist_xy, RUND),
|
||||
"winkel_grad": neigung,
|
||||
"hz_grad": hz,
|
||||
@@ -727,6 +1027,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
|
||||
return {
|
||||
"test_id": "VF_HundM05_%02d" % nummer,
|
||||
"typ": "standard",
|
||||
"darstellung": a.get("darstellung", "?"),
|
||||
"richtung": "Auf" if dz > 0 else "Ab",
|
||||
"deltaL": round(dist_xy, RUND),
|
||||
"deltaH": round(abs(dz), RUND),
|
||||
@@ -739,7 +1040,7 @@ def foerderer_eintrag(a, u, dist_xy, nummer, gf_winkel_min):
|
||||
}
|
||||
|
||||
|
||||
def sensor_eintrag(t, bekannte_bloecke=None, hinweise=None):
|
||||
def sensor_eintrag(t, bekannte_bloecke=None, hinweise=None, treffer=None):
|
||||
"""Beliebiges Bauteil auf das generische Insert-Schema (5.) abbilden.
|
||||
|
||||
"bekannte_bloecke" (siehe lade_bekannte_bloecke) dient der Kontrolle, ob
|
||||
@@ -762,13 +1063,43 @@ def sensor_eintrag(t, bekannte_bloecke=None, hinweise=None):
|
||||
% (block, t["block"]))
|
||||
if t["attribs"]:
|
||||
kommentar += ", Attribute %s" % json.dumps(t["attribs"], ensure_ascii=False)
|
||||
zw = t.get("zwilling_2d")
|
||||
if zw:
|
||||
kommentar += ("; 3D-Modell, zusammengefuehrt mit dem 2D-Symbol '%s' "
|
||||
"(Layer %s, %.0f mm entfernt) - Hoehe aus dem 3D-Modell, "
|
||||
"Identitaet aus dem 2D-Symbol"
|
||||
% (zw["block"], zw["layer"], zw["abstand"]))
|
||||
elif t.get("darstellung") == "2D":
|
||||
# Gleiche Begruendung wie bei den Foerderer-Paaren: im 2D-Schema ist Z
|
||||
# keine Einbauhoehe. Der Wert wird uebernommen (er steht so im DXF),
|
||||
# aber als nicht belastbar markiert.
|
||||
kommentar += ("; 2D-Schema ohne 3D-Zwilling - z ist keine Einbauhoehe "
|
||||
"(hoehe_unbekannt)")
|
||||
# Ein 2D-Symbol mit unmoeglichem Z (z.B. -3941 oder +21677 auf Layer z-3)
|
||||
# wuerde sonst 4 m unter bzw. 21 m ueber der Anlage eingebaut. Der Wert ist
|
||||
# dort kein Hoehenmass, also wird - wie beim Kreisel - die Hoehe aus den
|
||||
# 3D-Nachbarn abgeleitet, statt einen unmoeglichen Wert zu uebernehmen.
|
||||
z = t["z"]
|
||||
if (t.get("darstellung") == "2D" and treffer
|
||||
and not (Z_PLAUSIBEL[0] <= z <= Z_PLAUSIBEL[1])):
|
||||
ersatz, grund = hoehe_aus_umgebung((t["x"], t["y"]), treffer)
|
||||
if ersatz is not None:
|
||||
kommentar += ("; z=%.1f liegt ausserhalb des plausiblen Fensters "
|
||||
"und ist im 2D-Schema kein Hoehenmass - ersetzt "
|
||||
"durch %s" % (z, grund))
|
||||
z = ersatz
|
||||
else:
|
||||
kommentar += ("; z=%.1f liegt ausserhalb des plausiblen Fensters, "
|
||||
"aber %s" % (z, grund))
|
||||
return {
|
||||
"function": "insert",
|
||||
"block": block,
|
||||
"x": t["x"],
|
||||
"y": t["y"],
|
||||
"z": t["z"],
|
||||
"z": z,
|
||||
"rotation": t["rot"],
|
||||
"darstellung": t.get("darstellung", "?"),
|
||||
"hoehe_unbekannt": t.get("darstellung") == "2D",
|
||||
"comment": kommentar,
|
||||
}
|
||||
|
||||
@@ -790,33 +1121,46 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
|
||||
for i, an in enumerate(an_liste, start=1):
|
||||
if not sp_liste:
|
||||
hinweise.append("AN8 bei (%.2f, %.2f) ohne SP8-Partner" % (an["x"], an["y"]))
|
||||
eintraege.append(sensor_eintrag(an, bekannte_bloecke, hinweise))
|
||||
eintraege.append(sensor_eintrag(an, bekannte_bloecke, hinweise, treffer))
|
||||
continue
|
||||
sp = min(sp_liste, key=lambda s: math.hypot(s["x"] - an["x"], s["y"] - an["y"]))
|
||||
sp_liste.remove(sp)
|
||||
dist = math.hypot(sp["x"] - an["x"], sp["y"] - an["y"])
|
||||
rotation = math.degrees(math.atan2(sp["y"] - an["y"], sp["x"] - an["x"])) % 360.0
|
||||
# AN8/SP8 sind flache 2D-Symbole auf Z=0 - die Einbauhoehe steht nicht
|
||||
# im DXF. Sie wird aus den 3D-Modellen in der Umgebung abgeleitet
|
||||
# (Separatoren, Boegen: die stehen auf der echten Foerderhoehe).
|
||||
hoehe = an["z"]
|
||||
hoehe_hinweis = ""
|
||||
if an.get("darstellung") == "2D":
|
||||
geschaetzt, grund = hoehe_aus_umgebung((an["x"], an["y"]), treffer)
|
||||
if geschaetzt is not None:
|
||||
hoehe = geschaetzt
|
||||
hoehe_hinweis = (" ACHTUNG hoehe NICHT aus dem Kreisel selbst: "
|
||||
"AN8/SP8 sind flache 2D-Symbole auf Z=0, die "
|
||||
"Einbauhoehe steht nicht im DXF. Wert %s"
|
||||
% grund) + "."
|
||||
else:
|
||||
hoehe_hinweis = (" ACHTUNG hoehe=%.1f ist NICHT belegt: "
|
||||
"AN8/SP8 sind flache 2D-Symbole auf Z=0 und "
|
||||
"%s." % (hoehe, grund))
|
||||
eintraege.append({
|
||||
"id": "Kreisel_HundM05_%02d" % i,
|
||||
"function": "connect",
|
||||
"start_x": an["x"], "start_y": an["y"], "start_z": an["z"],
|
||||
"end_x": sp["x"], "end_y": sp["y"], "end_z": sp["z"],
|
||||
"typ": "STANDARD",
|
||||
"hoehe": an["z"],
|
||||
"comment": "aus DXF: AN8 (Layer %s) -> SP8 (Layer %s), Abstand "
|
||||
"%.2f; daraus folgt produktionsseitig abstand = %.2f - "
|
||||
"%.1f = %.2f und rotation = %.2f Grad. ACHTUNG hoehe: "
|
||||
"AN8 und SP8 sind in dieser Zeichnung flache 2D-Symbole "
|
||||
"(nur Linien/Kreis/Text auf Z=0) und auf Z=0 eingefuegt "
|
||||
"- die Einfuegehoehe des Kreisels steht NICHT im DXF "
|
||||
"und muss hier von Hand gesetzt werden (vgl. "
|
||||
"mubea.json, wo z ebenfalls Handarbeit ist)."
|
||||
% (an["layer"], sp["layer"], dist, dist, durchmesser,
|
||||
dist - durchmesser, rotation),
|
||||
"darstellung": an.get("darstellung", "?"),
|
||||
"hoehe": hoehe,
|
||||
"comment": ("aus DXF: AN8 (Layer %s) -> SP8 (Layer %s), Abstand "
|
||||
"%.2f; daraus folgt produktionsseitig abstand = %.2f - "
|
||||
"%.1f = %.2f und rotation = %.2f Grad."
|
||||
% (an["layer"], sp["layer"], dist, dist, durchmesser,
|
||||
dist - durchmesser, rotation)) + hoehe_hinweis,
|
||||
})
|
||||
for sp in sp_liste:
|
||||
hinweise.append("SP8 bei (%.2f, %.2f) ohne AN8-Partner" % (sp["x"], sp["y"]))
|
||||
eintraege.append(sensor_eintrag(sp, bekannte_bloecke, hinweise))
|
||||
eintraege.append(sensor_eintrag(sp, bekannte_bloecke, hinweise, treffer))
|
||||
|
||||
# --- Foerderer: Antriebs- und Umlenkstation paaren ---------------------
|
||||
gf_winkel_min = defaults.get("gefaelle", {}).get("default_winkel", 3.0)
|
||||
@@ -828,7 +1172,8 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
|
||||
for t in rest_a + rest_u:
|
||||
hinweise.append("Foerderer-Station ohne Partner: %s bei (%.2f, %.2f)"
|
||||
% (t["block"], t["x"], t["y"]))
|
||||
eintraege.append(sensor_eintrag(t, bekannte_bloecke, hinweise))
|
||||
eintraege.append(sensor_eintrag(t, bekannte_bloecke, hinweise,
|
||||
treffer))
|
||||
|
||||
# --- Omniflo Boegen und Weichen ---------------------------------------
|
||||
for familie, katalog, typ in (("omniflo_bogen", boegen, "bogen"),
|
||||
@@ -868,14 +1213,14 @@ def baue_eintraege(treffer, defaults, boegen, weichen,
|
||||
"(Modulweiche_RG_links/rechts bzw. Modulweiche_RR, je Passiv/"
|
||||
"Pneumatik/Zylinder) muss im JSON von Hand gesetzt werden"
|
||||
% len(ils_weichen))
|
||||
eintraege.extend(sensor_eintrag(t, bekannte_bloecke, hinweise)
|
||||
eintraege.extend(sensor_eintrag(t, bekannte_bloecke, hinweise, treffer)
|
||||
for t in ils_weichen)
|
||||
|
||||
# --- Rest: Sensoren, Boegen, Schleuselemente --------------------------
|
||||
for familie in ("bogen", "schleuselement", "sensor"):
|
||||
posten = sorted(nach_familie.get(familie, []),
|
||||
key=lambda t: (t["block"], t["x"], t["y"]))
|
||||
eintraege.extend(sensor_eintrag(t, bekannte_bloecke, hinweise)
|
||||
eintraege.extend(sensor_eintrag(t, bekannte_bloecke, hinweise, treffer)
|
||||
for t in posten)
|
||||
|
||||
return eintraege, hinweise
|
||||
@@ -1031,7 +1376,8 @@ def main():
|
||||
|
||||
groesse = os.path.getsize(dxf_path) / (1024.0 * 1024.0)
|
||||
print("%s lese %s (%.1f MB) ..." % (PREFIX, dxf_path, groesse))
|
||||
ms_inserts, block_inserts, block_base, statistik = lese_inserts(dxf_path)
|
||||
(ms_inserts, block_inserts, block_base, statistik,
|
||||
block_info) = lese_inserts(dxf_path)
|
||||
print("%s %d INSERTs im Modelspace, %d Blockdefinitionen mit Inhalt"
|
||||
% (PREFIX, len(ms_inserts), len(block_inserts)))
|
||||
print("%s %d INSERTs gesamt, davon %d mit gekippter OCS (%.1f%%), "
|
||||
@@ -1040,8 +1386,8 @@ def main():
|
||||
100.0 * statistik["ocs_gekippt"] / max(statistik["inserts"], 1),
|
||||
statistik["papierbereich"]))
|
||||
|
||||
treffer, unbekannt, verworfen = sammle_vorkommen(ms_inserts, block_inserts,
|
||||
block_base)
|
||||
treffer, unbekannt, verworfen = sammle_vorkommen(
|
||||
ms_inserts, block_inserts, block_base, block_info=block_info)
|
||||
print("%s %d erkannte Bauteil-Vorkommen (%d deckungsgleiche Duplikate "
|
||||
"verworfen)" % (PREFIX, len(treffer), verworfen))
|
||||
mit_attrib = sum(1 for t in treffer if t["attribs"])
|
||||
@@ -1053,6 +1399,14 @@ def main():
|
||||
for familie in sorted(zaehler):
|
||||
print("%s %-20s %d" % (PREFIX, familie, zaehler[familie]))
|
||||
|
||||
# Beide Darstellungen derselben Anlage zu einem Bauteil vereinen
|
||||
treffer, dim_stat = zwillinge_zusammenfuehren(treffer)
|
||||
print("%s Darstellungen zusammengefuehrt: %d Zwillingspaare (davon %d mit "
|
||||
"uebernommenen Attributen), %d nur 2D, %d nur 3D -> %d Vorkommen"
|
||||
% (PREFIX, dim_stat["zusammengefuehrt"],
|
||||
dim_stat["attribute_uebernommen"], dim_stat["nur_2d"],
|
||||
dim_stat["nur_3d"], len(treffer)))
|
||||
|
||||
eintraege, hinweise = baue_eintraege(treffer, defaults, boegen, weichen,
|
||||
bekannte_bloecke)
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
extract_polylines.py - Liest LWPOLYLINE-Objekte aus einem DXF und schreibt
|
||||
sie als JSON (Handle, Layer, Stuetzpunkte in OCS und Weltkoordinaten,
|
||||
Segment-/Gesamtlaengen).
|
||||
|
||||
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
|
||||
allgemeine JSON-Schema).
|
||||
|
||||
Eine Polylinie kann auf einer gekippten Ebene liegen (Gruppencode
|
||||
210/220/230 != (0,0,1)); die Gruppencodes 10/20 sind dann OCS-, keine
|
||||
Weltkoordinaten. Die Umrechnung nutzt ezdxf (Arbitrary-Axis-Algorithmus),
|
||||
darum reicht hier ezdxf.readfile() statt eines eigenen Streaming-Parsers -
|
||||
polylines.dxf ist mit ca. 124 MB ASCII-DXF deutlich kleiner als die
|
||||
binaeren GB-Zeichnungen, fuer die dxf_scan_components.py den mmap-Weg
|
||||
braucht.
|
||||
|
||||
Aufruf:
|
||||
python extract_polylines.py <dxf> <output.json> [--layer Z-6]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import ezdxf
|
||||
|
||||
PREFIX = "[extract_polylines]"
|
||||
|
||||
RUND = 4
|
||||
|
||||
|
||||
def arrays_einzeilig(text):
|
||||
"""Rein numerische JSON-Arrays auf eine Zeile ziehen (wie in
|
||||
dxf_scan_components.py) - noetig, falls die Datei spaeter ueber
|
||||
ssg-parse-json-array (zeilenweiser LISP-Parser) gelesen wird."""
|
||||
def zusammen(m):
|
||||
return "[" + " ".join(m.group(1).split()) + "]"
|
||||
return re.sub(r"\[([\s\d.,+\-eE]*?)\]", zusammen, text)
|
||||
|
||||
|
||||
def lese_polylinien(dxf_path, layer):
|
||||
doc = ezdxf.readfile(dxf_path)
|
||||
msp = doc.modelspace()
|
||||
ergebnis = []
|
||||
for pl in msp.query("LWPOLYLINE"):
|
||||
if layer and pl.dxf.layer != layer:
|
||||
continue
|
||||
elevation = pl.dxf.elevation
|
||||
ocs = pl.ocs()
|
||||
punkte_ocs = [(p[0], p[1]) for p in pl.get_points("xy")]
|
||||
punkte_welt = [ocs.to_wcs((x, y, elevation)) for x, y in punkte_ocs]
|
||||
|
||||
laengen = [0.0]
|
||||
for i in range(1, len(punkte_welt)):
|
||||
a, b = punkte_welt[i - 1], punkte_welt[i]
|
||||
laengen.append(math.dist((a.x, a.y, a.z), (b.x, b.y, b.z)))
|
||||
|
||||
ergebnis.append({
|
||||
"handle": pl.dxf.handle,
|
||||
"layer": pl.dxf.layer,
|
||||
"geschlossen": bool(pl.closed),
|
||||
"elevation": round(elevation, RUND),
|
||||
"extrusion": [round(v, RUND) for v in tuple(pl.dxf.extrusion)],
|
||||
"anzahl_punkte": len(punkte_welt),
|
||||
"punkte_ocs": [[round(x, RUND), round(y, RUND)]
|
||||
for x, y in punkte_ocs],
|
||||
"punkte_welt": [[round(p.x, RUND), round(p.y, RUND),
|
||||
round(p.z, RUND)] for p in punkte_welt],
|
||||
"segment_laengen": [round(l, RUND) for l in laengen],
|
||||
"gesamtlaenge": round(sum(laengen), RUND),
|
||||
})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Liest LWPOLYLINE-Objekte aus einem DXF und schreibt "
|
||||
"sie (inkl. OCS->Welt-Umrechnung) als JSON.")
|
||||
parser.add_argument("dxf", help="Quellzeichnung (ASCII- oder "
|
||||
"binaeres DXF)")
|
||||
parser.add_argument("output", help="Ziel-JSON")
|
||||
parser.add_argument("--layer", default="Z-6",
|
||||
help="Nur Polylinien auf diesem Layer (Default: "
|
||||
"Z-6, leer = alle Layer)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isfile(args.dxf):
|
||||
print("%s FEHLER: DXF nicht gefunden: %s" % (PREFIX, args.dxf))
|
||||
sys.exit(1)
|
||||
|
||||
groesse = os.path.getsize(args.dxf) / (1024.0 * 1024.0)
|
||||
print("%s lese %s (%.1f MB) ..." % (PREFIX, args.dxf, groesse))
|
||||
|
||||
ergebnis = lese_polylinien(args.dxf, args.layer)
|
||||
print("%s %d LWPOLYLINE-Objekte auf Layer '%s' gefunden"
|
||||
% (PREFIX, len(ergebnis), args.layer or "*"))
|
||||
for e in ergebnis:
|
||||
print("%s Handle %-4s %d Punkte Gesamtlaenge %.2f mm%s"
|
||||
% (PREFIX, e["handle"], e["anzahl_punkte"], e["gesamtlaenge"],
|
||||
" (gekippte Ebene)" if e["extrusion"] != [0.0, 0.0, 1.0]
|
||||
else ""))
|
||||
|
||||
ziel_dir = os.path.dirname(os.path.abspath(args.output))
|
||||
if ziel_dir and not os.path.isdir(ziel_dir):
|
||||
os.makedirs(ziel_dir)
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
fh.write(arrays_einzeilig(json.dumps(ergebnis, ensure_ascii=False,
|
||||
indent=2)))
|
||||
fh.write("\n")
|
||||
print("%s %d Eintraege geschrieben -> %s"
|
||||
% (PREFIX, len(ergebnis), args.output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,654 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
vf_journal_export.py - Liest die SSG_VF_EDIT-Eingabe-Journale der VF_n-Bloecke
|
||||
aus einer Zeichnung und schreibt sie als Frage-Antwort-Protokoll (JSON) im
|
||||
Schema von tests/testdata/linienzug_tests.json.
|
||||
|
||||
Hintergrund: Der Linienzug-Modus (Lisp/vf_linienzug.lsp) ist nicht scriptbar -
|
||||
er fragt jede Eingabe einzeln ab (getpoint/getstring/getint/getreal) und
|
||||
zeichnet sie in einem Journal auf, das nach dem Bau als XDATA (App
|
||||
SSG_VF_EDIT, Marker "linienzug") am fertigen VF_n-Block landet. Dieses Journal
|
||||
ist die vollstaendige, wiederabspielbare Bau-Anleitung der Kette
|
||||
(vfl-journal-replay-start). Dieses Skript dreht die Serialisierung aus
|
||||
vfl-entry->string wieder zurueck und annotiert jeden Wert mit der Frage, die
|
||||
er beantwortet - abgeleitet aus dem Frageablauf von vf-linienzug-modus.
|
||||
|
||||
Bewusst OHNE ezdxf: gebraucht werden nur INSERT-Kopf (Blockname, Einfuegepunkt,
|
||||
Drehung) und die 1000er-Gruppen der XDATA - das ist reine Gruppencode-Paar-
|
||||
Lesung und funktioniert auch bei ASCII-DXF im 100-MB-Bereich.
|
||||
|
||||
Zwei Eigenheiten der Quelldaten, die hier aufgeloest werden:
|
||||
|
||||
1. Das Newline zwischen zwei Journal-Eintraegen steht im ASCII-DXF als
|
||||
Steuerzeichen-Notation "^J" (zwei Zeichen) - ein echtes 0x0A wuerde die
|
||||
zeilenweise DXF-Struktur zerreissen. Getrennt wird darum an "^J".
|
||||
2. Kopierte Ketten teilen ihr Journal: die vier VF_46-Bloecke in
|
||||
data/polylines.dxf sind Kopien EINER gebauten Kette und tragen deshalb
|
||||
Wort fuer Wort dasselbe Journal - inklusive des Startpunkts der zuerst
|
||||
gebauten Kette. Alle uebrigen Eintraege sind relativ (Distanz, Winkel,
|
||||
Zielhoehen), nur dieser eine Punkt ist absolut. Er wird darum je Instanz
|
||||
auf deren echten Blockeinfuegepunkt gezogen (--kein-startpunkt-fix
|
||||
schaltet das ab), damit jeder Eintrag fuer sich abspielbar ist.
|
||||
|
||||
Aufruf:
|
||||
python vf_journal_export.py <dxf> <output.json> [--csv <sivas_export.csv>]
|
||||
[--kein-startpunkt-fix]
|
||||
|
||||
Beispiel (erzeugt tests/testdata/hundm05.json):
|
||||
python lib/vf_journal_export.py data/polylines.dxf \
|
||||
tests/testdata/hundm05.json --csv results/HundM_export.csv
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
PREFIX = "[vf_journal_export]"
|
||||
|
||||
XDATA_APP = "SSG_VF_EDIT"
|
||||
MARKER = "linienzug"
|
||||
|
||||
# Steuerzeichen-Notation des Eintrags-Trenners im ASCII-DXF (siehe Kopf).
|
||||
TRENNER = "^J"
|
||||
|
||||
RUND = 6
|
||||
|
||||
# Antwort-Klartexte der Menues aus vf_linienzug.lsp (nur zur Annotation).
|
||||
JA_NEIN = {"1": "Ja", "2": "Nein"}
|
||||
SEITE = {"1": "links", "2": "rechts"}
|
||||
VARIANTE = {"1": "aussen", "2": "innen"}
|
||||
GF_MODUS = {"1": "ueber Zielhoehe", "2": "ueber Neigungswinkel"}
|
||||
ENDPUNKT3 = {"1": "Ja, nur Motorstation", "2": "Nein, weiterbauen",
|
||||
"3": "Ja, Motorstation + Kettenende am Zielpunkt"}
|
||||
KETTENENDE3 = {"1": "Ja, mit ES-Element", "2": "Ja, ohne ES-Element",
|
||||
"3": "Nein, weiterbauen"}
|
||||
KETTENENDE2 = {"1": "Ja, Separator + ES-Element", "2": "Nein, weiterbauen"}
|
||||
VERTEILUNG = {"1": "halbe Staustrecke am Ausgang", "2": "alles am Einlauf"}
|
||||
IM_VF = {"1": "Horizontaler Foerderer", "2": "Vario-Kurve",
|
||||
"3": "Auf/Ab-Foerderer"}
|
||||
MENU_START = {"1": "Neue Linie: GF", "2": "Neue Linie: Ab/Auf VF",
|
||||
"3": "Horizontal-VF", "4": "Neue Linie bis Kettenende"}
|
||||
MENU_WEITER = {"1": "GF-Bogen", "2": "Neue Linie: GF",
|
||||
"3": "Neue Linie: Ab/Auf VF", "4": "Horizontal-VF",
|
||||
"5": "Neue Linie bis Kettenende"}
|
||||
|
||||
STEP_KOMMENTAR = ("Glied-Marker (vfl-journal-mark) - beim Replay "
|
||||
"uebersprungen, Label = gewaehlter Segmenttyp")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DXF lesen
|
||||
# ============================================================
|
||||
|
||||
def gruppen(dxf_pfad):
|
||||
"""Alle (Gruppencode, Wert)-Paare der Datei."""
|
||||
with open(dxf_pfad, "r", encoding="utf-8", errors="replace") as fh:
|
||||
zeilen = fh.read().splitlines()
|
||||
return [(zeilen[i].strip(), zeilen[i + 1])
|
||||
for i in range(0, len(zeilen) - 1, 2)]
|
||||
|
||||
|
||||
def lese_vf_inserts(dxf_pfad):
|
||||
"""INSERTs mit SSG_VF_EDIT-XDATA einsammeln.
|
||||
|
||||
Rueckgabe: Liste von Dicts (handle, block, punkt, rotation, marker,
|
||||
journal_roh). Das Journal steckt in den 1000er-Gruppen NACH dem Marker
|
||||
und ist auf 250 Zeichen je Gruppe gestueckelt (DXF-Limit 255).
|
||||
"""
|
||||
paare = gruppen(dxf_pfad)
|
||||
treffer = []
|
||||
i = 0
|
||||
while i < len(paare):
|
||||
code, wert = paare[i]
|
||||
if code != "0" or wert != "INSERT":
|
||||
i += 1
|
||||
continue
|
||||
e = {"handle": None, "block": None, "x": 0.0, "y": 0.0, "z": 0.0,
|
||||
"rotation": 0.0, "marker": None, "chunks": []}
|
||||
in_app = False
|
||||
j = i + 1
|
||||
while j < len(paare) and paare[j][0] != "0":
|
||||
c, v = paare[j]
|
||||
if c == "5" and e["handle"] is None:
|
||||
e["handle"] = v
|
||||
elif c == "2" and e["block"] is None:
|
||||
e["block"] = v
|
||||
elif c == "10":
|
||||
e["x"] = float(v)
|
||||
elif c == "20":
|
||||
e["y"] = float(v)
|
||||
elif c == "30":
|
||||
e["z"] = float(v)
|
||||
elif c == "50":
|
||||
e["rotation"] = float(v)
|
||||
elif c == "1001":
|
||||
in_app = (v == XDATA_APP)
|
||||
elif c == "1000" and in_app:
|
||||
if e["marker"] is None:
|
||||
e["marker"] = v
|
||||
else:
|
||||
e["chunks"].append(v)
|
||||
j += 1
|
||||
if e["marker"] is not None:
|
||||
e["punkt"] = [e["x"], e["y"], e["z"]]
|
||||
e["journal_roh"] = "".join(e["chunks"])
|
||||
del e["chunks"]
|
||||
treffer.append(e)
|
||||
i = j
|
||||
return treffer
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Journal dekodieren
|
||||
# ============================================================
|
||||
|
||||
class Journal(object):
|
||||
"""Ein Journal (kind:payload je Zeile) in das Frage-Antwort-Protokoll
|
||||
uebersetzen. Der Ablauf folgt vf-linienzug-modus (Modus 1):
|
||||
|
||||
Praeambel: Startpunkt, Starthoehe, AS-Element (Ja/Nein, Winkel, Seite)
|
||||
Hauptschleife je Glied: STEP-Marker, Menuewahl, dann die Fragen des
|
||||
gewaehlten Segmenttyps
|
||||
Innerhalb einer VF-Einheit (Horizontal-VF / Linie): Separator vor/nach,
|
||||
"Ist der Endpunkt der Foerderer?", danach Horizontal / Vario-Kurve /
|
||||
Auf-Ab bis die Einheit schliesst
|
||||
Kettenende: ES-Glied (Winkel, Seite)
|
||||
"""
|
||||
|
||||
def __init__(self, roh):
|
||||
self.tok = []
|
||||
for zeile in roh.split(TRENNER):
|
||||
if zeile:
|
||||
art, _, wert = zeile.partition(":")
|
||||
self.tok.append((art, wert))
|
||||
self.pos = 0
|
||||
self.out = []
|
||||
self.erster_step = True
|
||||
self.erster_dl = True
|
||||
|
||||
# --- Tokenzugriff ---
|
||||
|
||||
def art(self):
|
||||
return self.tok[self.pos][0] if self.pos < len(self.tok) else None
|
||||
|
||||
def label(self):
|
||||
return self.tok[self.pos][1] if self.pos < len(self.tok) else None
|
||||
|
||||
def nimm(self, erwartet):
|
||||
if self.pos >= len(self.tok):
|
||||
raise ValueError("Journal endet vor %s" % erwartet)
|
||||
art, wert = self.tok[self.pos]
|
||||
if art != erwartet:
|
||||
raise ValueError("Token %d: %s erwartet, %s:%s gefunden"
|
||||
% (self.pos, erwartet, art, wert))
|
||||
self.pos += 1
|
||||
return wert
|
||||
|
||||
# --- Ausgabe ---
|
||||
|
||||
def add(self, typ, kommentar, **felder):
|
||||
eintrag = {"typ": typ}
|
||||
eintrag.update(felder)
|
||||
eintrag["kommentar"] = kommentar
|
||||
self.out.append(eintrag)
|
||||
|
||||
def frage_str(self, kommentar_fmt, tabelle=None):
|
||||
w = self.nimm("STR")
|
||||
self.add("string", kommentar_fmt % (tabelle.get(w, "?") if tabelle
|
||||
else w), wert=w)
|
||||
return w
|
||||
|
||||
def frage_real(self, kommentar_fmt):
|
||||
w = float(self.nimm("REAL"))
|
||||
self.add("real", kommentar_fmt % w, wert=w)
|
||||
return w
|
||||
|
||||
def frage_int(self, kommentar_fmt):
|
||||
w = int(self.nimm("INT"))
|
||||
self.add("int", kommentar_fmt % w, wert=w)
|
||||
return w
|
||||
|
||||
def marker(self, label):
|
||||
self.add("step", STEP_KOMMENTAR, wert=label)
|
||||
|
||||
def frage_linie(self, was):
|
||||
"""Segmentlaenge; die gesnappte Fahrtrichtung steht nur beim ersten
|
||||
Segment der Kette im Journal (vfl-in-abstand), danach erbt jedes
|
||||
Segment die Richtung des Vorgaengers."""
|
||||
dl = float(self.nimm("DL"))
|
||||
if self.erster_dl and self.art() == "REAL":
|
||||
hz = float(self.nimm("REAL"))
|
||||
self.erster_dl = False
|
||||
self.add("point_rel",
|
||||
"getpoint %s: dL=%.1f mm, gesnappte Fahrtrichtung "
|
||||
"hz=%.0f Grad (nur beim ersten Segment der Kette "
|
||||
"journalisiert)" % (was, dl, hz), dL=dl, hz=hz)
|
||||
else:
|
||||
self.erster_dl = False
|
||||
self.add("point_rel",
|
||||
"getpoint %s: dL=%.1f mm (Fahrtrichtung vom "
|
||||
"Vorgaenger geerbt)" % (was, dl), dL=dl)
|
||||
return dl
|
||||
|
||||
# --- Ablauf ---
|
||||
|
||||
def dekodiere(self):
|
||||
self.praeambel()
|
||||
while self.pos < len(self.tok):
|
||||
label = self.nimm("STEP")
|
||||
self.marker(label)
|
||||
if label == "ES":
|
||||
self.glied_es(marker_gelesen=True)
|
||||
continue
|
||||
if self.erster_step:
|
||||
tabelle, kopf = MENU_START, (
|
||||
"Naechstes Element am Kettenanfang (1=Neue Linie GF, "
|
||||
"2=Neue Linie Ab/Auf VF, 3=Horizontal-VF, 4=Neue Linie "
|
||||
"bis Kettenende) -> %s")
|
||||
else:
|
||||
tabelle, kopf = MENU_WEITER, (
|
||||
"Naechstes Element (1=GF-Bogen, 2=Neue Linie GF, "
|
||||
"3=Neue Linie Ab/Auf VF, 4=Horizontal-VF, 5=Neue Linie "
|
||||
"bis Kettenende) -> %s")
|
||||
self.frage_str("getstring " + kopf, tabelle)
|
||||
self.erster_step = False
|
||||
if label == "Linie-GF":
|
||||
self.glied_linie_gf()
|
||||
elif label == "Linie-VF":
|
||||
self.glied_linie_vf()
|
||||
elif label == "Horizontal-VF":
|
||||
self.glied_horizontal_vf()
|
||||
elif label == "Linie":
|
||||
self.glied_linie_kettenende()
|
||||
elif label == "GF-Bogen":
|
||||
self.frage_int("getint GF-Bogen Winkel (30/60/90 Grad) "
|
||||
"-> %d")
|
||||
self.frage_str("getstring GF-Bogen Seite (1=links, "
|
||||
"2=rechts) -> %s", SEITE)
|
||||
else:
|
||||
raise ValueError("unbekanntes Glied: %s" % label)
|
||||
if self.pos != len(self.tok):
|
||||
raise ValueError("nicht alle Tokens verbraucht (%d von %d)"
|
||||
% (self.pos, len(self.tok)))
|
||||
return self.out
|
||||
|
||||
def praeambel(self):
|
||||
punkt = [float(v) for v in self.nimm("PT").split(",")]
|
||||
self.add("point_abs", "getpoint Startpunkt der Kette (absoluter "
|
||||
"Weltpunkt, einziger Anker der ganzen Kette)", wert=punkt)
|
||||
self.frage_real("getreal Hoehe (Z) Startpunkt -> %.1f mm")
|
||||
if self.frage_str("getstring AS-Element setzen? (1=Ja, 2=Nein) "
|
||||
"-> %s", JA_NEIN) == "1":
|
||||
w = self.nimm("STR")
|
||||
self.add("string", "getstring AS-Element Winkel (Wert direkt, "
|
||||
"90 oder 30 Grad) -> %s Grad" % w, wert=w)
|
||||
self.frage_str("getstring AS-Seite (1=links, 2=rechts) -> %s",
|
||||
SEITE)
|
||||
|
||||
def glied_linie_gf(self):
|
||||
self.frage_linie("Endpunkt der GF-Linie")
|
||||
if self.frage_str("getstring Gefaelle festlegen (1=ueber Zielhoehe, "
|
||||
"2=ueber Neigungswinkel) -> %s", GF_MODUS) == "2":
|
||||
self.frage_real("getreal Neigungswinkel -> %.1f Grad")
|
||||
else:
|
||||
self.frage_real("getreal Zielhoehe (Z) Linienendpunkt "
|
||||
"-> %.1f mm")
|
||||
if self.frage_str("getstring Ist das Kettenende? (1=Ja mit ES, "
|
||||
"2=Ja ohne ES, 3=Nein weiterbauen) -> %s",
|
||||
KETTENENDE3) == "1":
|
||||
self.glied_es()
|
||||
|
||||
def glied_horizontal_vf(self):
|
||||
"""VF-Einheit mit HORIZONTALEM Anfangskoerper (winkel1=0 in
|
||||
vfl-vf-einheit): dort stellt vfl-baue-horizontal-koerper im
|
||||
Ziel-Modus die Separator-/Endpunkt-Fragen VOR der
|
||||
Laengenberechnung."""
|
||||
self.frage_linie("Endpunkt des horizontalen VF-Stuecks")
|
||||
self.vf_einheit(auto_ende=False, erster_horizontal=True)
|
||||
|
||||
def glied_linie_vf(self):
|
||||
""""Neue Linie: Ab/Auf VF" - Laenge, Zielhoehe, ggf. Winkelwahl
|
||||
(vfl-vf-entscheidung), dann eine VF-Einheit mit GEWINKELTEM
|
||||
Anfangskoerper. Zu dem Koerper wird nichts gefragt (vfl-vf-einheit
|
||||
baut ihn direkt), die erste Frage der Einheit ist also die
|
||||
Endpunkt-Frage der Fortsetzungsschleife."""
|
||||
self.frage_linie("Endpunkt des Ab/Auf-VF-Segments")
|
||||
self.frage_real("getreal Zielhoehe (Z) Linienendpunkt -> %.1f mm")
|
||||
self.frage_winkelwahl()
|
||||
self.vf_einheit(auto_ende=False, erster_horizontal=False)
|
||||
|
||||
def glied_linie_kettenende(self):
|
||||
""""Neue Linie bis Kettenende": Laenge + Zielhoehe, danach entscheidet
|
||||
vfl-segment-entscheidung selbst. Reine Gefaellestrecke -> ES-Element
|
||||
ohne weitere Frage; VF-Einheit -> vf_einheit mit auto-ende."""
|
||||
self.frage_linie("Endpunkt der Linie bis Kettenende (Ziel-XY der "
|
||||
"Kette)")
|
||||
self.frage_real("getreal Zielhoehe (Z) am Kettenende -> %.1f mm")
|
||||
self.frage_winkelwahl()
|
||||
if self.art() == "STEP" and self.label() == "ES":
|
||||
self.glied_es()
|
||||
elif self.art() == "STR":
|
||||
# vfl-segment-entscheidung liefert fuer typ="VF" immer einen
|
||||
# Winkel > 0 (die VF-Winkelstufen beginnen bei 3 Grad), der erste
|
||||
# Koerper ist hier also nie horizontal.
|
||||
self.vf_einheit(auto_ende=True, erster_horizontal=False)
|
||||
|
||||
def glied_es(self, marker_gelesen=False):
|
||||
if not marker_gelesen:
|
||||
label = self.nimm("STEP")
|
||||
if label != "ES":
|
||||
raise ValueError("ES-Marker erwartet, %s gefunden" % label)
|
||||
self.marker(label)
|
||||
w = self.nimm("STR")
|
||||
self.add("string", "getstring ES-Element Winkel (Wert direkt, 90 "
|
||||
"oder 30 Grad) -> %s Grad" % w, wert=w)
|
||||
self.frage_str("getstring ES-Seite (1=links, 2=rechts) -> %s", SEITE)
|
||||
|
||||
def frage_winkelwahl(self):
|
||||
"""Winkelwahl (vfl-waehle-winkel) steht nur im Journal, wenn mehrere
|
||||
Kandidatenwinkel gueltig waren - bei genau einem faellt die Frage
|
||||
weg."""
|
||||
if self.art() == "INT":
|
||||
self.frage_int("getint Winkelwahl aus den gueltigen "
|
||||
"Kandidaten (vfl-waehle-winkel) -> Option %d")
|
||||
|
||||
def horizontal_koerper(self):
|
||||
"""vfl-baue-horizontal-koerper im Ziel-Modus: Separator vor/nach und
|
||||
die Endpunkt-Frage stehen VOR der Laengenberechnung. Rueckgabe: die
|
||||
Endpunkt-Antwort, die die Fortsetzungsschleife weiterverwendet."""
|
||||
self.frage_str("getstring Separator VOR dem horizontalen Stueck? "
|
||||
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
|
||||
self.frage_str("getstring Separator NACH dem horizontalen Stueck? "
|
||||
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
|
||||
return self.frage_str("getstring Ist der Endpunkt der Foerderer? "
|
||||
"(1=Ja nur Motorstation, 2=Nein weiterbauen, "
|
||||
"3=Ja Motorstation + Kettenende) -> %s",
|
||||
ENDPUNKT3)
|
||||
|
||||
def vario_kurve(self):
|
||||
label = self.nimm("STEP")
|
||||
if label != "Vario-Kurve":
|
||||
raise ValueError("Vario-Kurve-Marker erwartet, %s" % label)
|
||||
self.marker(label)
|
||||
self.frage_int("getint Vario-Kurve Winkel (30/60/90 Grad, Wert "
|
||||
"direkt) -> %d Grad")
|
||||
self.frage_str("getstring Vario-Kurve Seite (1=links, 2=rechts) "
|
||||
"-> %s", SEITE)
|
||||
self.frage_str("getstring Vario-Kurve Variante (1=aussen, 2=innen) "
|
||||
"-> %s", VARIANTE)
|
||||
|
||||
def vf_einheit(self, auto_ende, erster_horizontal):
|
||||
"""Eine VF-Einheit: GF-Verteilung, erster Koerper, dann die
|
||||
Fortsetzungsschleife (vfl-vf-einheit) bis Motorstation/Kettenende,
|
||||
danach der Abschluss (vfl-vf-einheit-abschluss).
|
||||
|
||||
erster_horizontal spiegelt die Verzweigung ueber winkel1 in
|
||||
vfl-vf-einheit: nur der horizontale Erstkoerper laeuft durch
|
||||
vfl-baue-horizontal-koerper und fragt darum Separator vor/nach und
|
||||
die Endpunkt-Frage schon vorab (deren Antwort die Schleife dann
|
||||
uebernimmt, statt erneut zu fragen). Ein gewinkelter Erstkoerper
|
||||
wird ohne jede Frage gebaut."""
|
||||
self.frage_str("getstring GF-Verteilung (1=halbe Staustrecke am "
|
||||
"Ausgang, 2=alles am Einlauf) -> %s", VERTEILUNG)
|
||||
vorantwort = self.horizontal_koerper() if erster_horizontal else None
|
||||
ziel_ende = False
|
||||
es_gewuenscht = True
|
||||
while True:
|
||||
if vorantwort is None:
|
||||
antwort = self.frage_str(
|
||||
"getstring Ist der Endpunkt der Foerderer? (1=Ja nur "
|
||||
"Motorstation, 2=Nein weiterbauen, 3=Ja Motorstation + "
|
||||
"Kettenende) -> %s", ENDPUNKT3)
|
||||
else:
|
||||
antwort, vorantwort = vorantwort, None
|
||||
if antwort == "1":
|
||||
break
|
||||
if antwort == "3":
|
||||
es_gewuenscht = self.frage_str(
|
||||
"getstring ES-Element setzen? (1=Ja, 2=Nein - Kette "
|
||||
"endet direkt am Zielpunkt) -> %s", JA_NEIN) != "2"
|
||||
self.frage_linie("Zielpunkt des Kettenendes")
|
||||
self.frage_real("getreal Zielhoehe (Z) am Kettenende "
|
||||
"-> %.1f mm")
|
||||
self.frage_winkelwahl()
|
||||
ziel_ende = True
|
||||
break
|
||||
wahl = self.frage_str("getstring Naechstes in der VF-Einheit "
|
||||
"(1=Horizontaler Foerderer, "
|
||||
"2=Vario-Kurve, 3=Auf/Ab-Foerderer) "
|
||||
"-> %s", IM_VF)
|
||||
if wahl == "1":
|
||||
self.frage_linie("Endpunkt des horizontalen VF-Stuecks")
|
||||
vorantwort = self.horizontal_koerper()
|
||||
elif wahl == "2":
|
||||
self.vario_kurve()
|
||||
else:
|
||||
self.frage_linie("Endpunkt des Auf/Ab-VF-Segments")
|
||||
self.frage_real("getreal Zielhoehe (Z) am Segmentende "
|
||||
"-> %.1f mm")
|
||||
self.frage_winkelwahl()
|
||||
if ziel_ende and not es_gewuenscht:
|
||||
return
|
||||
if auto_ende or ziel_ende:
|
||||
self.glied_es()
|
||||
return
|
||||
if self.frage_str("getstring Ist das Kettenende? (1=Ja, Separator + "
|
||||
"ES-Element, 2=Nein, weiterbauen) -> %s",
|
||||
KETTENENDE2) == "1":
|
||||
self.glied_es()
|
||||
else:
|
||||
self.frage_str("getstring Separator an dieser Stelle einfuegen? "
|
||||
"(1=Ja, 2=Nein) -> %s", JA_NEIN)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Sivas-Export als Quervergleich
|
||||
# ============================================================
|
||||
|
||||
def lese_strecken(csv_pfad):
|
||||
"""Die "ILS 2.0 Strecke"-Zeilen eines Sivas-Exports (EXPORTSIVAS).
|
||||
|
||||
Dient nur der Zuordnung/Dokumentation: die Merkmale (DeltaL, DeltaH,
|
||||
L_GF_m, L_VF_m, ...) sind die Attribute des fertigen VF_n-Blocks und
|
||||
damit der Erwartungswert zu einem nachgebauten Foerderer.
|
||||
"""
|
||||
strecken = []
|
||||
with open(csv_pfad, encoding="utf-8") as fh:
|
||||
for zeile in csv.DictReader(fh, delimiter=";"):
|
||||
if zeile.get("TeileArt") != "ILS 2.0 Strecke":
|
||||
continue
|
||||
if not zeile.get("Nachbarn"):
|
||||
continue # unverbunden - gehoert zu keinem Kreisel
|
||||
strecken.append({
|
||||
"id": zeile["TeileId"],
|
||||
"bezeichnung": zeile["Bezeichnung"],
|
||||
"planquadrat": zeile["Planquadrat"],
|
||||
"x": float(zeile["Position"].split(",")[0]),
|
||||
"merkmale": json.loads(zeile["Merkmale"]),
|
||||
})
|
||||
return strecken
|
||||
|
||||
|
||||
def erwartung_hinweis(strecke):
|
||||
m = strecke["merkmale"]
|
||||
felder = ["DeltaL_mm", "DeltaH_mm", "Hoehe_Von_mm", "Hoehe_Bis_mm",
|
||||
"L_GF_m", "L_VF_m", "VF_Winkel", "Anzahl_Separator"]
|
||||
werte = ", ".join("%s=%s" % (f, m[f]) for f in felder if f in m)
|
||||
return ("Sivas-Export (EXPORTSIVAS), Strecke %s: %s"
|
||||
% (strecke["id"], werte))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Kurzbeschreibung der Kette
|
||||
# ============================================================
|
||||
|
||||
def kurzbeschreibung(eintraege):
|
||||
"""Kette in einer Zeile: die Glied-Marker mit ihren Kernwerten."""
|
||||
teile = []
|
||||
for i, e in enumerate(eintraege):
|
||||
if e["typ"] != "step":
|
||||
continue
|
||||
folge = eintraege[i + 1:i + 8]
|
||||
strings = [x["wert"] for x in folge if x["typ"] == "string"]
|
||||
ints = [x["wert"] for x in folge if x["typ"] == "int"]
|
||||
reals = [x["wert"] for x in folge if x["typ"] == "real"]
|
||||
laengen = [x["dL"] for x in folge if x["typ"] == "point_rel"]
|
||||
label = e["wert"]
|
||||
if label == "Vario-Kurve":
|
||||
teile.append("Vario-Kurve %d %s %s"
|
||||
% (ints[0], SEITE.get(strings[0], "?"),
|
||||
VARIANTE.get(strings[1], "?")))
|
||||
elif label == "ES":
|
||||
teile.append("ES %s %s" % (strings[0], SEITE.get(strings[1], "?")))
|
||||
elif label == "Linie-GF":
|
||||
teile.append("GF-Linie %.0f mm / %.0f Grad"
|
||||
% (laengen[0], reals[0]))
|
||||
elif label == "Horizontal-VF":
|
||||
teile.append("Horizontal-VF %.0f mm" % laengen[0])
|
||||
elif label == "Linie":
|
||||
teile.append("Linie bis Kettenende %.0f mm auf Z%.0f"
|
||||
% (laengen[0], reals[0]))
|
||||
elif label == "GF-Bogen":
|
||||
teile.append("GF-Bogen %d %s" % (ints[0],
|
||||
SEITE.get(strings[0], "?")))
|
||||
return " -> ".join(teile)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Testfaelle bauen
|
||||
# ============================================================
|
||||
|
||||
def baue_testfaelle(inserts, strecken, startpunkt_fix, quelle_datei,
|
||||
ziel_datei):
|
||||
"""FLACHE Testfall-Liste: je Kette ein Kopf-Objekt (traegt "test_id"),
|
||||
danach ihre Eingaben (tragen "typ") - Struktur wie linienzug_tests.json.
|
||||
|
||||
Bewusst NICHT verschachtelt ("eingaben": [...]): ssg-load-json /
|
||||
ssg-parse-json-array (Lisp/ssg_core.lsp) liest die Datei ZEILENWEISE und
|
||||
behandelt jedes "{" als Objektanfang. Ein verschachteltes Array wuerde die
|
||||
Kopf-Objekte verschlucken. Der Treiber gruppiert wieder ueber "test_id"
|
||||
(siehe hundm05:gruppiere in tests/test_hundm05.lsp).
|
||||
"""
|
||||
# Anlagenkuerzel der test_id kommt aus dem Namen der Ziel-JSON (die
|
||||
# Testfall-Sammlung), nicht aus dem Zeichnungsnamen: tests/testdata/
|
||||
# hundm05.json -> VF_hundm05_LZ_01 .. _nn.
|
||||
praefix = os.path.splitext(os.path.basename(ziel_datei))[0]
|
||||
faelle = []
|
||||
for nr, e in enumerate(sorted(inserts, key=lambda i: i["x"]), start=1):
|
||||
eintraege = Journal(e["journal_roh"]).dekodiere()
|
||||
journal_start = list(eintraege[0]["wert"])
|
||||
punkt = [round(v, RUND) for v in e["punkt"]]
|
||||
if startpunkt_fix:
|
||||
eintraege[0]["wert"] = punkt
|
||||
eintraege[1]["wert"] = punkt[2]
|
||||
eintraege[1]["kommentar"] = ("getreal Hoehe (Z) Startpunkt "
|
||||
"-> %.1f mm" % punkt[2])
|
||||
else:
|
||||
punkt = [round(v, RUND) for v in journal_start]
|
||||
strecke = None
|
||||
if strecken:
|
||||
strecke = min(strecken, key=lambda s: abs(s["x"] - e["x"]))
|
||||
kopf = {
|
||||
"test_id": "VF_%s_LZ_%02d" % (praefix, nr),
|
||||
# "anlagetyp", nicht "typ": "typ" markiert im flachen Array einen
|
||||
# EINGABE-Eintrag (point_abs/real/string/int/step) - ein Kopf mit
|
||||
# "typ" waere davon nicht zu unterscheiden.
|
||||
"anlagetyp": "linienzug",
|
||||
"modus": 1,
|
||||
"anzahl_eingaben": len(eintraege),
|
||||
"block": e["block"],
|
||||
"dxf_handle": e["handle"],
|
||||
"startpunkt_mm": punkt,
|
||||
"rotation_grad": round(e["rotation"], 4),
|
||||
"beschreibung": "Linienzug (Modus 1), Start Z%.0f: %s"
|
||||
% (punkt[2], kurzbeschreibung(eintraege)),
|
||||
}
|
||||
if strecke:
|
||||
kopf["sivas_id"] = strecke["id"]
|
||||
kopf["sivas_bezeichnung"] = strecke["bezeichnung"]
|
||||
kopf["planquadrat"] = strecke["planquadrat"]
|
||||
kopf["erwartung_hinweis"] = erwartung_hinweis(strecke)
|
||||
# Ohne Anfuehrungszeichen im Text: ssg-cfg-parse-value schneidet einen
|
||||
# String am ersten/letzten Zeichen ab und kennt keine Escapes.
|
||||
kopf["quelle"] = ("INSERT %s (Block %s) in %s, XDATA-App %s, Marker "
|
||||
"%s; im Journal aufgezeichneter Kettenstart: [%s]"
|
||||
% (e["handle"], e["block"],
|
||||
os.path.basename(quelle_datei), XDATA_APP,
|
||||
e["marker"],
|
||||
", ".join("%.6f" % v for v in journal_start)))
|
||||
faelle.append(kopf)
|
||||
faelle.extend(eintraege)
|
||||
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
|
||||
zeilenweise und kann ein umgebrochenes Zahlen-Array nicht lesen."""
|
||||
def zusammen(m):
|
||||
return "[" + " ".join(m.group(1).split()) + "]"
|
||||
return re.sub(r"\[([\s\d.,+\-eE]*?)\]", zusammen, text)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(
|
||||
description="Liest die SSG_VF_EDIT-Eingabe-Journale der VF_n-Bloecke "
|
||||
"einer Zeichnung und schreibt sie als Frage-Antwort-"
|
||||
"Protokoll (Schema linienzug_tests.json).")
|
||||
p.add_argument("dxf", help="Quellzeichnung (ASCII-DXF)")
|
||||
p.add_argument("output", help="Ziel-JSON")
|
||||
p.add_argument("--csv", help="Sivas-Export (EXPORTSIVAS) der gleichen "
|
||||
"Zeichnung - liefert Strecken-ID und "
|
||||
"Erwartungswerte je Kette")
|
||||
p.add_argument("--kein-startpunkt-fix", action="store_true",
|
||||
help="Kettenstart NICHT auf den Blockeinfuegepunkt "
|
||||
"ziehen (Journal unveraendert uebernehmen)")
|
||||
args = p.parse_args()
|
||||
|
||||
if not os.path.isfile(args.dxf):
|
||||
print("%s FEHLER: DXF nicht gefunden: %s" % (PREFIX, args.dxf))
|
||||
sys.exit(1)
|
||||
|
||||
mb = os.path.getsize(args.dxf) / (1024.0 * 1024.0)
|
||||
print("%s lese %s (%.1f MB) ..." % (PREFIX, args.dxf, mb))
|
||||
inserts = lese_vf_inserts(args.dxf)
|
||||
print("%s %d INSERT(s) mit %s-XDATA gefunden"
|
||||
% (PREFIX, len(inserts), XDATA_APP))
|
||||
inserts = [e for e in inserts if e["marker"].startswith(MARKER)]
|
||||
if not inserts:
|
||||
print("%s FEHLER: kein Linienzug-Journal (Marker \"%s\") in der "
|
||||
"Zeichnung" % (PREFIX, MARKER))
|
||||
sys.exit(1)
|
||||
|
||||
strecken = lese_strecken(args.csv) if args.csv else []
|
||||
if args.csv:
|
||||
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)
|
||||
koepfe = [f for f in faelle if "test_id" in f]
|
||||
for f in koepfe:
|
||||
print("%s %s %s (%s) %d Eingaben"
|
||||
% (PREFIX, f["test_id"], f["block"],
|
||||
f.get("sivas_bezeichnung", "ohne Sivas-Zuordnung"),
|
||||
f["anzahl_eingaben"]))
|
||||
|
||||
ziel_dir = os.path.dirname(os.path.abspath(args.output))
|
||||
if ziel_dir and not os.path.isdir(ziel_dir):
|
||||
os.makedirs(ziel_dir)
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
fh.write(arrays_einzeilig(json.dumps(faelle, ensure_ascii=False,
|
||||
indent=2)))
|
||||
fh.write("\n")
|
||||
print("%s %d Ketten / %d JSON-Objekte geschrieben -> %s"
|
||||
% (PREFIX, len(koepfe), len(faelle), args.output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
test_vf_journal_grammatik.py - Prueft den Journal-Dekoder in
|
||||
lib/vf_journal_export.py gegen handgebaute Journale.
|
||||
|
||||
Warum: der Dekoder ist die ZWEITE Implementierung der Linienzug-Grammatik
|
||||
(erste ist der Bau-Ablauf in Lisp/vf_linienzug.lsp selbst). Nur wenn beide
|
||||
dieselbe Frage-Reihenfolge kennen, laesst sich ein Journal aus einer echten
|
||||
Zeichnung verlustfrei in das Testdatenschema zuruecklesen
|
||||
(tests/testdata/hundm05.json).
|
||||
|
||||
Die 5 echten HundM-Ketten deckten zwei Zweige NICHT ab, weil ihre Daten sie
|
||||
nicht enthalten:
|
||||
- Glied "Linie-VF" ("Neue Linie: Ab/Auf VF")
|
||||
- eine VF-Einheit mit GEWINKELTEM Erstkoerper - dort stellt
|
||||
vfl-vf-einheit KEINE Separator-/Endpunkt-Frage vorab (das tut nur
|
||||
vfl-baue-horizontal-koerper beim horizontalen Erstkoerper, winkel1=0)
|
||||
|
||||
Genau diese beiden Faelle sichern die Tests hier ab. Laeuft ohne CAD und ohne
|
||||
die grosse DXF - die Journale stehen als Token-Listen im Test.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
|
||||
|
||||
import vf_journal_export as vje
|
||||
|
||||
|
||||
def _journal(*tokens):
|
||||
"""Token-Liste in die Rohform bringen, in der das Journal in der XDATA
|
||||
steht: "ART:wert", verbunden mit dem Trenner (die zwei Zeichen "^J" -
|
||||
ein echter Zeilenumbruch wuerde die zeilenweise DXF-Lesung brechen)."""
|
||||
return vje.TRENNER.join(tokens)
|
||||
|
||||
|
||||
PRAEAMBEL_OHNE_AS = ("PT:0.0,0.0,5000.0", "REAL:5000.0", "STR:2")
|
||||
ES_GLIED = ("STEP:ES", "STR:90", "STR:1")
|
||||
|
||||
|
||||
def _dekodiere(*tokens):
|
||||
j = vje.Journal(_journal(*tokens))
|
||||
out = j.dekodiere() # wirft, wenn Tokens uebrig bleiben
|
||||
return out
|
||||
|
||||
|
||||
def _typen(eintraege):
|
||||
return [e["typ"] for e in eintraege]
|
||||
|
||||
|
||||
def _labels(eintraege):
|
||||
return [e["wert"] for e in eintraege if e["typ"] == "step"]
|
||||
|
||||
|
||||
class TestHorizontalVF:
|
||||
"""Kontrollfall: VF-Einheit mit horizontalem Erstkoerper. Dieser Zweig
|
||||
kommt in den echten HundM-Ketten vor und muss unveraendert bleiben."""
|
||||
|
||||
TOKENS = PRAEAMBEL_OHNE_AS + (
|
||||
"STEP:Horizontal-VF",
|
||||
"STR:3", # erste Sektion, 4 Optionen: 3 = Horizontal-VF
|
||||
"DL:8000.0",
|
||||
"REAL:0.0", # gesnappte Fahrtrichtung (nur erstes Segment)
|
||||
"STR:2", # GF-Verteilung: alles am Einlauf
|
||||
"STR:2", # Separator VOR dem horizontalen Stueck? Nein
|
||||
"STR:2", # Separator NACH dem horizontalen Stueck? Nein
|
||||
"STR:1", # Endpunkt des Foerderers? Ja, nur Motorstation
|
||||
"STR:1", # Kettenende? Ja, Separator + ES
|
||||
) + ES_GLIED
|
||||
|
||||
def test_dekodiert_vollstaendig(self):
|
||||
eintraege = _dekodiere(*self.TOKENS)
|
||||
assert _labels(eintraege) == ["Horizontal-VF", "ES"]
|
||||
assert _typen(eintraege)[:3] == ["point_abs", "real", "string"]
|
||||
|
||||
def test_separatorfragen_vorhanden(self):
|
||||
"""Der horizontale Erstkoerper fragt Separator vor/nach - das ist der
|
||||
Unterschied zum gewinkelten Erstkoerper."""
|
||||
kommentare = " | ".join(e["kommentar"] for e in
|
||||
_dekodiere(*self.TOKENS))
|
||||
assert "Separator VOR" in kommentare
|
||||
assert "Separator NACH" in kommentare
|
||||
|
||||
|
||||
class TestLinieVF:
|
||||
"""Der bis dahin fehlende Zweig: Glied "Linie-VF" mit gewinkeltem
|
||||
Erstkoerper der VF-Einheit."""
|
||||
|
||||
TOKENS = PRAEAMBEL_OHNE_AS + (
|
||||
"STEP:Linie-VF",
|
||||
"STR:2", # erste Sektion, 4 Optionen: 2 = Neue Linie Ab/Auf VF
|
||||
"DL:12000.0",
|
||||
"REAL:0.0", # gesnappte Fahrtrichtung
|
||||
"REAL:3000.0", # Zielhoehe des Linienendpunkts
|
||||
# keine Winkelwahl: nur ein Kandidat war gueltig
|
||||
"STR:2", # GF-Verteilung: alles am Einlauf
|
||||
# KEINE Separator-/Endpunktfrage vorab
|
||||
"STR:1", # Endpunkt des Foerderers? Ja, nur Motorstation
|
||||
"STR:1", # Kettenende? Ja, Separator + ES
|
||||
) + ES_GLIED
|
||||
|
||||
def test_dekodiert_vollstaendig(self):
|
||||
eintraege = _dekodiere(*self.TOKENS)
|
||||
assert _labels(eintraege) == ["Linie-VF", "ES"]
|
||||
|
||||
def test_keine_separatorfragen(self):
|
||||
"""Ein gewinkelter Erstkoerper wird ohne Frage gebaut. Wuerde der
|
||||
Dekoder hier - wie vor dem Fix - horizontal_koerper() aufrufen,
|
||||
verschluckte er die Endpunkt-Antwort als Separator-Antwort und liefe
|
||||
aus den Tokens heraus."""
|
||||
kommentare = " | ".join(e["kommentar"] for e in
|
||||
_dekodiere(*self.TOKENS))
|
||||
assert "Separator VOR" not in kommentare
|
||||
assert "Zielhoehe" in kommentare
|
||||
|
||||
def test_winkelwahl_optional(self):
|
||||
"""vfl-waehle-winkel fragt nur bei mehreren gueltigen Kandidaten -
|
||||
dann steht ein INT im Journal, sonst nicht."""
|
||||
mit_wahl = list(self.TOKENS)
|
||||
mit_wahl.insert(mit_wahl.index("REAL:3000.0") + 1, "INT:2")
|
||||
eintraege = _dekodiere(*mit_wahl)
|
||||
assert [e["typ"] for e in eintraege].count("int") == 1
|
||||
|
||||
def test_vario_kurve_in_der_einheit(self):
|
||||
tokens = PRAEAMBEL_OHNE_AS + (
|
||||
"STEP:Linie-VF",
|
||||
"STR:2",
|
||||
"DL:12000.0",
|
||||
"REAL:0.0",
|
||||
"REAL:3000.0",
|
||||
"STR:2", # GF-Verteilung
|
||||
"STR:2", # Endpunkt? Nein, weiterbauen
|
||||
"STR:2", # Naechstes in der VF-Einheit: Vario-Kurve
|
||||
"STEP:Vario-Kurve",
|
||||
"INT:90",
|
||||
"STR:1", # Seite links
|
||||
"STR:1", # Variante aussen
|
||||
"STR:1", # Endpunkt? Ja, nur Motorstation
|
||||
"STR:2", # Kettenende? Nein, weiterbauen
|
||||
"STR:2", # Separator an dieser Stelle? Nein
|
||||
"STEP:Linie",
|
||||
"STR:5", # Folge-Sektion, 5 Optionen: 5 = Linie bis Ende
|
||||
"DL:4000.0",
|
||||
"REAL:3000.0", # Zielhoehe am Kettenende
|
||||
) + ES_GLIED
|
||||
eintraege = _dekodiere(*tokens)
|
||||
assert _labels(eintraege) == ["Linie-VF", "Vario-Kurve", "Linie", "ES"]
|
||||
|
||||
|
||||
class TestFehlerfaelle:
|
||||
"""Der Dekoder darf nicht raten: fehlende oder ueberzaehlige Tokens sind
|
||||
ein Fehler, kein stiller Teilerfolg."""
|
||||
|
||||
def test_unbekanntes_glied(self):
|
||||
with pytest.raises(ValueError):
|
||||
_dekodiere(*(PRAEAMBEL_OHNE_AS + ("STEP:Gibtsnicht", "STR:1")))
|
||||
|
||||
def test_tokens_uebrig(self):
|
||||
with pytest.raises(ValueError):
|
||||
_dekodiere(*(TestLinieVF.TOKENS + ("STR:1",)))
|
||||
|
||||
def test_journal_zu_kurz(self):
|
||||
with pytest.raises(ValueError):
|
||||
_dekodiere(*TestLinieVF.TOKENS[:-1])
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
[
|
||||
{
|
||||
"handle": "CA",
|
||||
"layer": "Z-6",
|
||||
"geschlossen": false,
|
||||
"elevation": -271.7589,
|
||||
"extrusion": [-0.0, -0.0436, -0.9991],
|
||||
"anzahl_punkte": 5,
|
||||
"punkte_ocs": [
|
||||
[8608.0283, -5622.4633],
|
||||
[8608.1795, -6230.2512],
|
||||
[9758.1665, -6230.2507],
|
||||
[9758.1805, -5392.918],
|
||||
[9752.5609, -1913.0356]
|
||||
],
|
||||
"punkte_welt": [
|
||||
[8608.046, 5628.9379, 26.4861],
|
||||
[8608.1991, 6236.1483, 0.0],
|
||||
[9758.1861, 6236.1442, 0.0],
|
||||
[9758.1975, 5399.607, 36.4891],
|
||||
[9752.5669, 1923.0303, 188.1348]
|
||||
],
|
||||
"segment_laengen": [0.0, 607.7879, 1149.987, 837.3327, 3479.887],
|
||||
"gesamtlaenge": 6074.9945
|
||||
},
|
||||
{
|
||||
"handle": "CD",
|
||||
"layer": "Z-6",
|
||||
"geschlossen": false,
|
||||
"elevation": 199.7371,
|
||||
"extrusion": [0.0, 0.0, 1.0],
|
||||
"anzahl_punkte": 6,
|
||||
"punkte_ocs": [
|
||||
[4927.0558, 1205.361],
|
||||
[4932.563, 7060.2017],
|
||||
[7106.355, 7060.2017],
|
||||
[7106.1386, 4645.5146],
|
||||
[6189.1673, 2991.5761],
|
||||
[6176.2696, 1205.361]
|
||||
],
|
||||
"punkte_welt": [
|
||||
[4927.0558, 1205.361, 199.7371],
|
||||
[4932.563, 7060.2017, 199.7371],
|
||||
[7106.355, 7060.2017, 199.7371],
|
||||
[7106.1386, 4645.5146, 199.7371],
|
||||
[6189.1673, 2991.5761, 199.7371],
|
||||
[6176.2696, 1205.361, 199.7371]
|
||||
],
|
||||
"segment_laengen": [0.0, 5854.8433, 2173.7921, 2414.6871, 1891.1237, 1786.2617],
|
||||
"gesamtlaenge": 14120.7078
|
||||
},
|
||||
{
|
||||
"handle": "8BA1",
|
||||
"layer": "Z-6",
|
||||
"geschlossen": false,
|
||||
"elevation": -2636.2483,
|
||||
"extrusion": [-0.0, -0.0436, -0.9991],
|
||||
"anzahl_punkte": 5,
|
||||
"punkte_ocs": [
|
||||
[12892.9127, -5519.3396],
|
||||
[12893.0639, -6127.1274],
|
||||
[14043.0509, -6127.1269],
|
||||
[14043.0649, -5289.7943],
|
||||
[14037.4453, -1809.9118]
|
||||
],
|
||||
"punkte_welt": [
|
||||
[12892.9303, 5628.9379, 2393.2232],
|
||||
[12893.0835, 6236.1483, 2366.7371],
|
||||
[14043.0705, 6236.1442, 2366.7371],
|
||||
[14043.0818, 5399.607, 2403.2263],
|
||||
[14037.4513, 1923.0303, 2554.8719]
|
||||
],
|
||||
"segment_laengen": [0.0, 607.7879, 1149.987, 837.3327, 3479.887],
|
||||
"gesamtlaenge": 6074.9945
|
||||
},
|
||||
{
|
||||
"handle": "8BCF",
|
||||
"layer": "Z-6",
|
||||
"geschlossen": false,
|
||||
"elevation": -2636.2489,
|
||||
"extrusion": [-0.0, -0.0436, -0.9991],
|
||||
"anzahl_punkte": 5,
|
||||
"punkte_ocs": [
|
||||
[17202.5751, -4259.1639],
|
||||
[17203.0502, -6127.1409],
|
||||
[18353.0372, -6127.1404],
|
||||
[18353.0512, -5289.8078],
|
||||
[18347.4315, -1809.9253]
|
||||
],
|
||||
"punkte_welt": [
|
||||
[17202.5888, 4369.9458, 2448.1395],
|
||||
[17203.0698, 6236.1483, 2366.7371],
|
||||
[18353.0568, 6236.1442, 2366.7371],
|
||||
[18353.0681, 5399.607, 2403.2263],
|
||||
[18347.4376, 1923.0303, 2554.8719]
|
||||
],
|
||||
"segment_laengen": [0.0, 1867.9771, 1149.987, 837.3327, 3479.887],
|
||||
"gesamtlaenge": 7335.1837
|
||||
},
|
||||
{
|
||||
"handle": "8BFD",
|
||||
"layer": "Z-6",
|
||||
"geschlossen": false,
|
||||
"elevation": -2536.3444,
|
||||
"extrusion": [-0.0, -0.0436, -0.9991],
|
||||
"anzahl_punkte": 5,
|
||||
"punkte_ocs": [
|
||||
[21481.2117, -5523.7243],
|
||||
[21481.3629, -6131.5121],
|
||||
[22631.3499, -6131.5116],
|
||||
[22631.3639, -5294.179],
|
||||
[22625.7443, -1814.2965]
|
||||
],
|
||||
"punkte_welt": [
|
||||
[21481.2293, 5628.9379, 2293.2232],
|
||||
[21481.3825, 6236.1483, 2266.7371],
|
||||
[22631.3695, 6236.1442, 2266.7371],
|
||||
[22631.3808, 5399.607, 2303.2263],
|
||||
[22625.7503, 1923.0303, 2454.8719]
|
||||
],
|
||||
"segment_laengen": [0.0, 607.7879, 1149.987, 837.3327, 3479.887],
|
||||
"gesamtlaenge": 6074.9945
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user