From 90bfbd89b6e04e65a325da8f128905ae48b38947 Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Thu, 10 Sep 2026 15:29:08 +0200 Subject: [PATCH] BTMT Beladung/Entladung als Ein-/Ausgang des Materialfluss-Graphen erkannt export_graph.py behandelte BTMT-Stationen bisher wie ein generisches Durchlaufelement mit zwei Achs-Enden - dadurch entstanden mehrere, teils widerspruechliche Kanten und die Rolle blieb "Element" statt "Eingang"/"Ausgang". BTMT hat aber physisch nur EINEN Anschluss zum Foerdersystem (sitzt als dritter Zweig einer Weiche auf der Kreisel- Durchgangsbahn, der Kreisel bleibt dabei ein einzelner Verteiler-Knoten): Beladung speist fest Material ein (kein Vorgaenger im Fluss), Entladung nimmt es fest raus (kein Nachfolger). Co-Authored-By: Claude Sonnet 5 --- lib/export_graph.py | 63 +++++++++++++++++++- tests/test_export_graph.py | 118 +++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 tests/test_export_graph.py diff --git a/lib/export_graph.py b/lib/export_graph.py index e97c5ba..f16ad04 100644 --- a/lib/export_graph.py +++ b/lib/export_graph.py @@ -9,7 +9,8 @@ Aufruf: Erzeugt neben .json (vollstaendiges Graph-Modell) auch .dot (GraphViz) und .mmd (Mermaid) zur Visualisierung. -KNOTEN = ein Anlagenelement je CSV-Zeile (Kreisel, Gefaellestrecke, Strecke). +KNOTEN = ein Anlagenelement je CSV-Zeile (Kreisel, Gefaellestrecke, Strecke, + BTMT Beladung/Entladung als Ein-/Ausgang des Graphen). KANTEN = eine Materialuebergabe zwischen zwei Elementen. Warum nicht die CSV-Spalte "Nachbarn"? @@ -42,9 +43,25 @@ Lisp/export.lsp), wird die Richtung heuristisch abgeleitet: "Ab" = abwaerts) gegen die Partnerhoehen. * Kreisel: Verteilknoten. Richtung ergibt sich aus den angeschlossenen Elementen, nicht aus dem Kreisel selbst. + * BTMT Beladung/Entladung: FEST gerichtet (Beladung -> Partner, Partner -> + Entladung), unabhaengig von Hoehe/Antrieb - siehe naechster Abschnitt. Jede Kante traegt daher ein Feld "richtung_quelle": "ks" (exakt, aus K1/K2), -"gefaelle", "antrieb" oder "unbestimmt". +"gefaelle", "antrieb", "btmt" oder "unbestimmt". + +Ein-/Ausgaenge des Graphen: BTMT Beladung/Entladung +---------------------------------------------------- +BTMT Beladung/Entladung sind die Ein- bzw. Ausgaenge des gesamten +Materialfluss-Graphen - eine Beladestation speist NEUES Material ein (kein +Vorgaenger im Fluss), eine Entladestation nimmt es endgueltig raus (kein +Nachfolger). Physisch sitzen sie AUF der Durchgangsbahn eines Kreisels +(der Kreisel laeuft an dieser Stelle durch UND die BTMT-Station zweigt dort +zusaetzlich ab/ein - wie eine Weiche mit einem fixen dritten Zweig). Der +Kreisel bleibt im Graphmodell trotzdem EIN Knoten (Rolle "Verteiler", keine +Aufteilung in Segmente); BTMT wird als eigener Knoten mit GENAU EINEM +Anschlusspunkt (Elementmitte statt zwei Achs-Enden, siehe _enden) und einer +fest gerichteten Kante zum Kreisel/Partner dargestellt (Rolle "Eingang" +bzw. "Ausgang", siehe _rolle/BTMT_ARTEN). """ import csv @@ -68,6 +85,15 @@ KREISEL_ARTEN = {"ILS 2.0 Kreisel", "ILS 2.0 Eckrad"} GEFAELLE_ARTEN = {"ILS 2.0 Gefaellestrecke"} STRECKEN_ARTEN = {"ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"} +# Ein-/Ausgaenge des Materialfluss-Graphen: BTMT Beladung speist Material +# EIN (Graph-Quelle, kein Vorgaenger im Fluss), BTMT Entladung nimmt es +# AUS (Graph-Senke, kein Nachfolger). Anders als Kreisel/Strecken haben +# beide nur EINEN Anschluss zum Foerdersystem, keine zwei Enden einer +# Durchlaufachse - siehe _enden(). +BTMT_BELADUNG_ARTEN = {"ILS 2.0 BTMT Beladung"} +BTMT_ENTLADUNG_ARTEN = {"ILS 2.0 BTMT Entladung"} +BTMT_ARTEN = BTMT_BELADUNG_ARTEN | BTMT_ENTLADUNG_ARTEN + # ------------------------------------------------------------------ # Dekodierung der Base64-Koordinatenstrings @@ -193,7 +219,17 @@ def _footprint(element): def _enden(element): - """Die beiden Enden der Laengsachse als Anschlusspunkte im Grundriss.""" + """Anschlusspunkte eines Elements im Grundriss. + + BTMT Beladung/Entladung haben nur EINEN Anschluss zum Foerdersystem + (Elementmitte) - anders als eine Durchlaufstrecke keine zwei Enden + einer Laengsachse. Mit der generischen Zwei-Enden-Logik wuerden sie + faelschlich als Durchlaufelement mit zwei Uebergabestellen behandelt + (siehe derive_flow/BTMT_ARTEN). + """ + if element["teileart"] in BTMT_ARTEN: + m = element["mitte"] + return {"a": (m["x"], m["y"])} minx, maxx, miny, maxy = element["footprint"] m = element["mitte"] if element["achse"] == "x": @@ -262,6 +298,12 @@ def derive_flow(elemente, anschluesse): * Foerderer mit Antriebfahrtrichtung "Auf" -> vom tieferen zum hoeheren, "Ab" -> umgekehrt. Liegen K1/K2 vor, gewinnt diese exakte Angabe. + + BTMT Beladung/Entladung sind die Ein-/Ausgaenge des gesamten Graphen + (siehe BTMT_ARTEN/_enden): ihr einziger Anschluss wird FEST gerichtet + (Beladung -> Partner, Partner -> Entladung), unabhaengig von Hoehe/ + Antriebfahrtrichtung - eine Beladestation speist immer ein, eine + Entladestation nimmt immer aus, nie umgekehrt. """ # Anschluesse je Streckenelement sammeln je_element = {} @@ -273,6 +315,15 @@ def derive_flow(elemente, anschluesse): el = elemente[idx] art = el["teileart"] + if art in BTMT_BELADUNG_ARTEN: + for anschluss in liste: + kanten.append(_kante(idx, anschluss["b"], "btmt", anschluss["abstand"])) + continue + if art in BTMT_ENTLADUNG_ARTEN: + for anschluss in liste: + kanten.append(_kante(anschluss["b"], idx, "btmt", anschluss["abstand"])) + continue + # Exakte Richtung, sobald der Export K1/K2 liefert if el["ks_ein"] and el["ks_aus"]: kanten.extend(_kanten_aus_ks(elemente, idx, liste)) @@ -433,6 +484,10 @@ def _rolle(el): return "Schwerkraftstrecke" if el["teileart"] in STRECKEN_ARTEN: return "Foerderer" + if el["teileart"] in BTMT_BELADUNG_ARTEN: + return "Eingang" + if el["teileart"] in BTMT_ENTLADUNG_ARTEN: + return "Ausgang" return "Element" @@ -440,6 +495,8 @@ STIL = { "Verteiler": ("box3d", "#1f6feb"), "Schwerkraftstrecke": ("box", "#8250df"), "Foerderer": ("box", "#1a7f37"), + "Eingang": ("invhouse", "#bf8700"), + "Ausgang": ("house", "#bf8700"), "Element": ("ellipse", "#57606a"), } diff --git a/tests/test_export_graph.py b/tests/test_export_graph.py new file mode 100644 index 0000000..c6b3c6c --- /dev/null +++ b/tests/test_export_graph.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_export_graph.py - Materialfluss-Graph (lib/export_graph.py). +Laeuft komplett ohne BricsCAD (reine Python-Logik). + +Regressionstest fuer einen realen Befund: BTMT Beladung/Entladung sind die +Ein-/Ausgaenge des gesamten Materialfluss-Graphen (Beladung speist neu ein, +Entladung nimmt endgueltig raus), wurden aber bisher wie ein generisches +Durchlaufelement mit ZWEI Enden behandelt (_enden nahm die laengere +Boundingbox-Seite als Achse) - dadurch bekamen sie mehrere, teils +widerspruechliche Kanten und die Rolle "Element" statt "Eingang"/"Ausgang". +""" + +import csv +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) + +from export_graph import load_elements, build_graph # noqa: E402 + + +def _write_csv(path, rows): + header = ["Elementnummer", "TeileArt", "TeileId", "Bezeichnung", "Planquadrat", + "Anzahl", "Position", "Boundingbox", "Insertpoint", "K1", "K2", "K3", + "K4", "Nachbarn", "TrackIds", "Fehler", "Warnungen", "Merkmale"] + with open(path, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=header, delimiter=";") + writer.writeheader() + for row in rows: + full = {k: "" for k in header} + full.update(row) + writer.writerow(full) + + +def _zeile(teileart, teileid, x, y, z, dx, dy, dz, bezeichnung=""): + return { + "TeileArt": teileart, + "TeileId": teileid, + "Bezeichnung": bezeichnung or teileart, + "Position": f"{x:.2f}, {y:.2f}, {z:.2f}", + "Boundingbox": f"{dx:.2f}, {dy:.2f}, {dz:.2f}", + "Merkmale": "{}", + } + + +class TestBtmtEinAusgangDesGraphen: + """Kreisel (5000x800mm, Achse entlang X) mit einer BTMT-Beladung UND + einer BTMT-Entladung, beide mittig auf der Kreiselflaeche platziert + (wie physisch: sie sitzen AUF der Kreiselbahn, keine eigenstaendige + Durchlaufstrecke).""" + + def _csv_pfad(self, tmp_path): + rows = [ + _zeile("ILS 2.0 Kreisel", "0001", 2500.0, 0.0, 1500.0, 5000.0, 800.0, 1.0), + _zeile("ILS 2.0 BTMT Beladung", "0002", 1000.0, 0.0, 1500.0, 400.0, 650.0, 300.0), + _zeile("ILS 2.0 BTMT Entladung", "0003", 4000.0, 0.0, 1500.0, 900.0, 1450.0, 450.0), + ] + pfad = os.path.join(tmp_path, "graph_test.csv") + _write_csv(pfad, rows) + return pfad + + def test_beladung_hat_nur_einen_anschluss(self, tmp_path): + elemente = load_elements(self._csv_pfad(tmp_path)) + beladung = next(e for e in elemente if e["teileart"] == "ILS 2.0 BTMT Beladung") + assert list(beladung["enden"].keys()) == ["a"], ( + "BTMT Beladung darf nur EINEN Anschlusspunkt haben (Elementmitte), " + f"hat aber {list(beladung['enden'].keys())}") + + def test_entladung_hat_nur_einen_anschluss(self, tmp_path): + elemente = load_elements(self._csv_pfad(tmp_path)) + entladung = next(e for e in elemente if e["teileart"] == "ILS 2.0 BTMT Entladung") + assert list(entladung["enden"].keys()) == ["a"] + + def test_beladung_ist_reiner_eingang(self, tmp_path): + """Beladung speist Material EIN - kein Vorgaenger im Fluss (grad_ein=0), + genau ein Nachfolger (grad_aus=1), Rolle "Eingang".""" + elemente = load_elements(self._csv_pfad(tmp_path)) + graph = build_graph(elemente) + knoten = next(n for n in graph["knoten"] if n["teileart"] == "ILS 2.0 BTMT Beladung") + assert knoten["rolle"] == "Eingang" + assert knoten["grad_ein"] == 0, "Beladung darf keinen Vorgaenger im Fluss haben" + assert knoten["grad_aus"] == 1, "Beladung muss genau einen Nachfolger haben" + + def test_entladung_ist_reiner_ausgang(self, tmp_path): + """Entladung nimmt Material AUS - kein Nachfolger im Fluss (grad_aus=0), + genau ein Vorgaenger (grad_ein=1), Rolle "Ausgang".""" + elemente = load_elements(self._csv_pfad(tmp_path)) + graph = build_graph(elemente) + knoten = next(n for n in graph["knoten"] if n["teileart"] == "ILS 2.0 BTMT Entladung") + assert knoten["rolle"] == "Ausgang" + assert knoten["grad_ein"] == 1, "Entladung muss genau einen Vorgaenger haben" + assert knoten["grad_aus"] == 0, "Entladung darf keinen Nachfolger im Fluss haben" + + def test_kante_beladung_zeigt_zum_kreisel(self, tmp_path): + elemente = load_elements(self._csv_pfad(tmp_path)) + graph = build_graph(elemente) + beladung_idx = next(i for i, e in enumerate(elemente) + if e["teileart"] == "ILS 2.0 BTMT Beladung") + kreisel_idx = next(i for i, e in enumerate(elemente) + if e["teileart"] == "ILS 2.0 Kreisel") + kante = next(k for k in graph["kanten"] if beladung_idx in (k["von"], k["nach"])) + assert kante["von"] == beladung_idx and kante["nach"] == kreisel_idx + assert kante["gerichtet"] is True + assert kante["richtung_quelle"] == "btmt" + + def test_kante_entladung_kommt_vom_kreisel(self, tmp_path): + elemente = load_elements(self._csv_pfad(tmp_path)) + graph = build_graph(elemente) + entladung_idx = next(i for i, e in enumerate(elemente) + if e["teileart"] == "ILS 2.0 BTMT Entladung") + kreisel_idx = next(i for i, e in enumerate(elemente) + if e["teileart"] == "ILS 2.0 Kreisel") + kante = next(k for k in graph["kanten"] if entladung_idx in (k["von"], k["nach"])) + assert kante["von"] == kreisel_idx and kante["nach"] == entladung_idx + assert kante["gerichtet"] is True + assert kante["richtung_quelle"] == "btmt"