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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 15:29:08 +02:00
parent 47724fcfe2
commit 90bfbd89b6
2 changed files with 178 additions and 3 deletions
+60 -3
View File
@@ -9,7 +9,8 @@ Aufruf:
Erzeugt neben <basispfad>.json (vollstaendiges Graph-Modell) auch
<basispfad>.dot (GraphViz) und <basispfad>.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"),
}
+118
View File
@@ -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"