diff --git a/lib/material_flow.py b/lib/material_flow.py
index 25b5d87..7db9b4c 100644
--- a/lib/material_flow.py
+++ b/lib/material_flow.py
@@ -186,6 +186,10 @@ NEIGHBOUR_RE = re.compile(r"^(?P[^\s-]+)(?:-(?P[LR]))?$")
EDGE_FLOW = "flow"
EDGE_CIRCLE = "circle"
EDGE_UNRESOLVED = "unresolved"
+# Notiz der Gegenkante eines Kreisel-Umlaufs (EDGE_CIRCLE entgegen der
+# Hauptkante, siehe build_graph) - fuer den Fluss gleichwertig, wird aber
+# nicht gezeichnet.
+CIRCLE_RETURN_NOTE = "Rueckfuehrung"
# Darstellung je Objektart
NODE_STYLES = {
@@ -1330,7 +1334,21 @@ def _apply_transfers(
touch_a = _kreisel_touch_lane(el_a, point, elements) if el_a and point else None
touch_b = _kreisel_touch_lane(el_b, point, elements) if el_b and point else None
- if touch_a is not None and touch_b is not None and (not touch_a[1] or not touch_b[1]):
+ # Beruehrart aus dem Export (dxfmakros, Merkmal "Beruehrung") hat
+ # Vorrang vor der Geometrie: ein Kreis/Kreis-Punkt liegt genau auf dem
+ # Uebergang Endkappe -> Gerade, dort entschied in _kreisel_touch_lane
+ # die Rundung der CSV-Werte (test_system: 4891.79 gegen Schwelle
+ # 4891.79) und machte die Weiche faelschlich "unbestimmt".
+ beruehrung = str(_merkmal(weiche_el.merkmale, "Beruehrung") or "").strip()
+ if beruehrung == "Kreis/Kreis":
+ laengsseite = False
+ elif "Tangente" in beruehrung:
+ laengsseite = True
+ else:
+ laengsseite = (touch_a is not None and touch_b is not None
+ and (not touch_a[1] or not touch_b[1]))
+
+ if laengsseite and touch_a is not None and touch_b is not None:
# Mindestens eine Seite liegt an der Laengsseite: Bahn direkt aus
# der Geometrie, Richtung bleibt unbestimmt (ohne Schaltzustand
# der Weiche ist dort kein Eingang/Ausgang ableitbar).
@@ -1473,6 +1491,63 @@ def _merge_station_symbols(
return swallowed
+def _reachability_warnings(nodes: dict[str, Node], edges: list[Edge]) -> list[str]:
+ """Bahnen, die von keiner Beladung aus erreichbar sind bzw. von denen
+ aus keine Entladung erreichbar ist.
+
+ Ein Kreisel ist ein geschlossener Umlauf (siehe build_graph), eine Bahn
+ hat darum lokal immer Zu- und Abgang - ob das Material sie erreicht bzw.
+ wieder verlassen kann, ist eine Frage des ganzen Graphen. Kanten mit
+ unbestimmter Richtung (EDGE_UNRESOLVED, z. B. eine Weiche an der
+ Laengsseite) gelten in beide Richtungen als begehbar. Ohne Be- bzw.
+ Entladestation im Layout entfaellt die jeweilige Pruefung.
+ """
+ succ: dict[str, set[str]] = {}
+ pred: dict[str, set[str]] = {}
+ for edge in edges:
+ succ.setdefault(edge.src, set()).add(edge.dst)
+ pred.setdefault(edge.dst, set()).add(edge.src)
+ if edge.kind == EDGE_UNRESOLVED:
+ succ.setdefault(edge.dst, set()).add(edge.src)
+ pred.setdefault(edge.src, set()).add(edge.dst)
+
+ def reach(starts: list[str], step: dict[str, set[str]]) -> set[str]:
+ seen = set(starts)
+ stack = list(starts)
+ while stack:
+ for nxt in step.get(stack.pop(), ()):
+ if nxt not in seen:
+ seen.add(nxt)
+ stack.append(nxt)
+ return seen
+
+ entries = sorted(n for n, node in nodes.items()
+ if node.element.is_station and node.element.is_entry)
+ exits = sorted(n for n, node in nodes.items()
+ if node.element.is_station and node.element.is_exit)
+ from_entry = reach(entries, succ) if entries else None
+ to_exit = reach(exits, pred) if exits else None
+
+ result: list[str] = []
+ for node_id in sorted(nodes):
+ node = nodes[node_id]
+ if node.lane is None:
+ continue
+ devices = node.devices.get("Separator", [])
+ detail = f" (Separatoren {', '.join(sorted(devices))})" if devices else ""
+ if from_entry is not None and node_id not in from_entry:
+ result.append(
+ f"Bahn {node_id}{detail}: von keiner Beladung ({', '.join(entries)}) "
+ f"aus erreichbar"
+ )
+ if to_exit is not None and node_id not in to_exit:
+ result.append(
+ f"Bahn {node_id}{detail}: keine Entladung ({', '.join(exits)}) "
+ f"erreichbar - Sackgasse"
+ )
+ return result
+
+
def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Graph:
# Generische Eingang/Ausgang-Symbole, die ueber einer BTMT-Station liegen,
# verschlucken (nur die konkrete Station bleibt Knoten).
@@ -1496,9 +1571,16 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
edges = _orient(sorted(pairs), nodes, warnings)
- # Kreisel: Richtung zwischen den beiden Bahnen aus der Drehrichtung
- # UZS (im Uhrzeigersinn) -> von rechts nach links, R -> L
- # GUZ (gegen den Uhrzeigersinn) -> von links nach rechts, L -> R
+ # Kreisel: geschlossener Umlauf - das Ende von L laeuft an der einen
+ # Endkappe in den Anfang von R, das Ende von R an der anderen in den
+ # Anfang von L. Darum IMMER zwei Kanten L -> R und R -> L. Die Drehrichtung
+ # legt nur fest, welche davon die "Hauptkante" mit dem Kuerzel ist:
+ # UZS (im Uhrzeigersinn) -> Hauptkante R -> L
+ # GUZ (gegen den Uhrzeigersinn) -> Hauptkante L -> R
+ # Die Gegenkante traegt CIRCLE_RETURN_NOTE und wird in render_dot nicht
+ # gezeichnet (der Umlauf-Pfeil steht fuer beide). Frueher fehlte sie: jeder
+ # Kreisel hatte dann eine Bahn ohne Abgang und eine ohne Zugang, und die
+ # Pruefung unten meldete an fast jedem Kreisel Sackgasse + unerreichbar.
# Fehlt die Angabe, bleibt der Umlauf beidseitig und wird gemeldet.
for element in elements:
if not element.is_circle:
@@ -1508,8 +1590,10 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
rotation = element.rotation
if rotation == ROTATION_CW:
edges.append(Edge(right, left, EDGE_CIRCLE, ROTATION_CW))
+ edges.append(Edge(left, right, EDGE_CIRCLE, CIRCLE_RETURN_NOTE))
elif rotation == ROTATION_CCW:
edges.append(Edge(left, right, EDGE_CIRCLE, ROTATION_CCW))
+ edges.append(Edge(right, left, EDGE_CIRCLE, CIRCLE_RETURN_NOTE))
else:
warnings.append(
f"{element.describe()}: Drehrichtung '{element.rotation or '-'}' "
@@ -1525,28 +1609,7 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
# Kreisel<->Strecke gerichtet aus den Transferelementen (massgeblich)
edges = _apply_transfers(elements, by_id, nodes, edges, warnings)
- # Sackgassen und unerreichbare Bahnen melden. Sie entstehen, wenn ein
- # Kreisel nur in einer Richtung durchlaufen wird (Drehrichtung) und die
- # Gegenrichtung nicht ueber aeussere Verbindungen geschlossen ist.
- outgoing = {edge.src for edge in edges}
- incoming = {edge.dst for edge in edges}
- for node_id in sorted(nodes):
- node = nodes[node_id]
- if node.lane is None:
- continue
- devices = node.devices.get("Separator", [])
- detail = f" (Separatoren {', '.join(sorted(devices))})" if devices else ""
- if node_id not in outgoing:
- warnings.append(
- f"Bahn {node_id}{detail}: keine abgehende Verbindung - Sackgasse. "
- f"Drehrichtung {node.element.rotation or '?'} laesst das Material "
- f"nur in eine Richtung laufen"
- )
- if node_id not in incoming:
- warnings.append(
- f"Bahn {node_id}{detail}: keine ankommende Verbindung - fuer das "
- f"Material nicht erreichbar"
- )
+ warnings.extend(_reachability_warnings(nodes, edges))
return Graph(nodes=nodes, edges=edges, warnings=warnings, unassigned=unassigned, source=source)
@@ -2078,6 +2141,8 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str:
seen_group_edges: set[tuple[str, str]] = set()
for edge in graph.edges:
+ if edge.kind == EDGE_CIRCLE and edge.note == CIRCLE_RETURN_NOTE:
+ continue # Umlauf-Pfeil der Hauptkante steht fuer beide
src = group_of.get(edge.src, edge.src)
dst = group_of.get(edge.dst, edge.dst)
if edge.src in grouped_ids or edge.dst in grouped_ids:
@@ -3499,7 +3564,8 @@ def render_doc(
devices[kind] = devices.get(kind, 0) + len(ids)
flow = sum(1 for e in graph.edges if e.kind == EDGE_FLOW)
- circle = sum(1 for e in graph.edges if e.kind == EDGE_CIRCLE)
+ circle = sum(1 for e in graph.edges
+ if e.kind == EDGE_CIRCLE and e.note != CIRCLE_RETURN_NOTE)
open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED)
add("## 1. Kurzfassung")
@@ -3622,7 +3688,8 @@ def report(
kinds[node.kind] = kinds.get(node.kind, 0) + 1
flow = sum(1 for e in graph.edges if e.kind == EDGE_FLOW)
- circle = sum(1 for e in graph.edges if e.kind == EDGE_CIRCLE)
+ circle = sum(1 for e in graph.edges
+ if e.kind == EDGE_CIRCLE and e.note != CIRCLE_RETURN_NOTE)
open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED)
devices = 0
diff --git a/tests/fixtures/test_system_export.csv b/tests/fixtures/test_system_export.csv
new file mode 100644
index 0000000..ea5c226
--- /dev/null
+++ b/tests/fixtures/test_system_export.csv
@@ -0,0 +1,30 @@
+Elementnummer;TeileArt;TeileId;Bezeichnung;Planquadrat;Anzahl;Position;Boundingbox;Insertpoint;K1;K2;K3;K4;Nachbarn;TrackIds;Fehler;Warnungen;Merkmale
+1;"ILS 2.0 Kreisel";"0001";"Kreisel1";"A/7-B/8";1;"-2011.74, 994.84, 2000.00";"3100.00, 800.00, 1.00";"yab0Dy4YHoSAAAAAAAAAAAAA";"";"";"";"";"0009-L";"0023, 0021";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "2.3", "Anzahl der Separatoren": "0", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "UZS", "Drehung": 0, "Name": "Kreisel1", "Geruest fuer Einzelmodul": true}
+2;"ILS 2.0 Kreisel";"0002";"Kreisel2";"E/3-E/6";1;"4938.26, -4953.25, 2000.00";"800.00, 5300.00, 1.00";"S1oUi/vOHoSAAAAAAAAAWoJ4";"";"";"";"";"0007-L, 0007-R, 0003-L";"0015, 0029, 0014, 0025";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "4.5", "Anzahl der Separatoren": "2", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "UZS", "Drehung": 90, "Name": "Kreisel2", "Geruest fuer Einzelmodul": true}
+3;"ILS 2.0 Kreisel";"0003";"Kreisel3";"E/1-E/4";1;"5738.26, -9850.80, 2000.00";"800.00, 6095.71, 1.00";"V48UgAABHoSAAAAAAAAAWoJ4";"";"";"";"";"0004-L, 0002-R";"0029, 0028";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "5.3", "Anzahl der Separatoren": "0", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "GUZ", "Drehung": 90, "Name": "Kreisel3", "Geruest fuer Einzelmodul": true}
+4;"ILS 2.0 Kreisel";"0004";"Kreisel4";"E/3-F/7";1;"6538.26, -3258.73, 2000.00";"800.00, 8689.42, 1.00";"Y8QUi/sQHoSAAAAAAAAAWoJ4";"";"";"";"";"0005-R, 0003-R";"0016, 0028, 0017, 0027";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "7.89", "Anzahl der Separatoren": "2", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "UZS", "Drehung": 90, "Name": "Kreisel4", "Geruest fuer Einzelmodul": true}
+5;"ILS 2.0 Kreisel";"0005";"Kreisel5";"E/7-K/8";1;"11430.05, 1485.99, 2000.00";"10583.58, 800.00, 1.00";"XamUFqymHoSAAAAAAAAAAAAA";"";"";"";"";"0006-L, 0006-R, 0004-L, 0004-R";"0018, 0026, 0027, 0020";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "9.78", "Anzahl der Separatoren": "1", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "GUZ", "Drehung": 0, "Name": "Kreisel5", "Geruest fuer Einzelmodul": true}
+6;"ILS 2.0 Kreisel";"0006";"Kreisel6";"E/8-F/10";1;"6538.26, 4145.31, 2000.00";"800.00, 4518.63, 1.00";"Y8QUHMcmHoSAAAAAAAAAWoJ4";"";"";"";"";"0005-L";"0019, 0026";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "3.72", "Anzahl der Separatoren": "1", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "UZS", "Drehung": 90, "Name": "Kreisel6", "Geruest fuer Einzelmodul": true}
+7;"ILS 2.0 Kreisel";"0007";"Kreisel7";"E/6-E/8";1;"4938.26, -453.25, 2000.00";"800.00, 3700.00, 1.00";"S1oU3NruHoSAAAAAAAAAWoJ4";"";"";"";"";"0008-L, 0008-R, 0002-L, 0002-R";"0024, 0013, 0025";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "2.9", "Anzahl der Separatoren": "1", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "GUZ", "Drehung": 90, "Name": "Kreisel7", "Geruest fuer Einzelmodul": true}
+8;"ILS 2.0 Kreisel";"0008";"Kreisel8";"B/7-E/7";1;"2438.26, 505.79, 2000.00";"4200.00, 800.00, 1.00";"BSlUB7e+HoSAAAAAAAAAAAAA";"";"";"";"";"0009-R, 0007-L";"0024, 0011, 0022, 0012";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "3.4", "Anzahl der Separatoren": "2", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "UZS", "Drehung": 0, "Name": "Kreisel8", "Geruest fuer Einzelmodul": true}
+9;"ILS 2.0 Kreisel";"0009";"Kreisel9";"B/6-B/9";1;"-61.74, 1425.92, 2000.00";"800.00, 4800.00, 1.00";"/w7U8SMAHoSAAAAAAAAAWoJ4";"";"";"";"";"0001-L, 0001-R, 0008-L, 0008-R";"0010, 0023, 0022";"";"";{"Abstand (Kreiselachse A - Kreiselachse) in Meter": "4.0", "Anzahl der Separatoren": "1", "Kreiselart": "STANDARD", "Anzahl der Scanner": "0", "Anzahl der Rampen": "0", "Höhe in m": "2.0", "Drehrichtung": "GUZ", "Drehung": 90, "Name": "Kreisel9", "Geruest fuer Einzelmodul": true}
+10;"ILS 2.0 Separator";"0010";"Separator :10";"B/8";1;"-461.74, 1526.13, 2001.50";"150.00, 209.90, 3.00";"+PRUFl9EHoSAAAAAAAAAWoJ4";"/+33ADlFAE4g";"";"";"";"0022, 0023";"";"";"";{"Zuordnung": "0009-L", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 90, "Typ": ""}
+11;"ILS 2.0 Separator";"0011";"Separator :9";"C/7";1;"1241.80, 105.79, 1.50";"209.90, 150.00, 3.00";"EgiaAZ0+AAAAAAAAAAAAAAAA";"AC4qAAQhAAAA";"";"";"";"0024, 0022";"";"";"";{"Zuordnung": "0008-R", "ARTINR": "200000077", "Höhe": "0", "Drehung": 0, "Typ": ""}
+12;"ILS 2.0 Separator";"0012";"Separator :8";"D/7";1;"3696.84, 905.79, 2001.50";"209.90, 150.00, 3.00";"OVL2DdI+HoSAAAAAAAAAf///";"AJK/ACNhAE4g";"";"";"";"0022, 0024";"";"";"";{"Zuordnung": "0008-L", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 180, "Typ": ""}
+13;"ILS 2.0 Separator";"0013";"Separator :7";"E/6";1;"4538.26, -1493.47, 2001.50";"150.00, 209.90, 3.00";"RT+U6Ev0HoSAAAAAAAAAWoJ4";"ALFG/8NSAE4g";"";"";"";"0024, 0025";"";"";"";{"Zuordnung": "0007-L", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 90, "Typ": ""}
+14;"ILS 2.0 Separator";"0014";"Separator :6";"E/5";1;"4538.26, -3169.65, 2001.50";"150.00, 209.90, 3.00";"RT+U0Iy8HoSAAAAAAAAApX2I";"ALFG/4aHAE4g";"";"";"";"0029, 0025";"";"";"";{"Zuordnung": "0002-L", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 270, "Typ": ""}
+15;"ILS 2.0 Separator";"0015";"Separator :5";"E/4";1;"5338.26, -6534.21, 2001.50";"150.00, 209.90, 3.00";"UXSUm2GQHoSAAAAAAAAAWoJ4";"ANCG/v5rAE4g";"";"";"";"0025, 0029";"";"";"";{"Zuordnung": "0002-R", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 90, "Typ": ""}
+16;"ILS 2.0 Separator";"0016";"Separator :4";"F/4";1;"6938.26, -6618.23, 2001.50";"150.00, 209.90, 3.00";"ad6UmhlcHoSAAAAAAAAAWoJ4";"AQ8G/vsjAE4g";"";"";"";"0027, 0028";"";"";"";{"Zuordnung": "0004-R", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 90, "Typ": ""}
+17;"ILS 2.0 Separator";"0017";"Separator :3";"E/7";1;"6138.26, 302.58, 2001.50";"150.00, 209.90, 3.00";"XamUBYgiHoSAAAAAAAAApX2I";"AO/GAA4pAE4g";"";"";"";"0028, 0027";"";"";"";{"Zuordnung": "0004-L", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 270, "Typ": ""}
+18;"ILS 2.0 Separator";"0018";"Separator :2";"F/8";1;"7153.84, 1885.99, 2001.50";"209.90, 150.00, 3.00";"bD6CHMcmHoSAAAAAAAAAAAAA";"ARUaAEmrAE4g";"";"";"";"0020, 0026";"";"";"";{"Zuordnung": "0005-L", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 0, "Typ": ""}
+19;"ILS 2.0 Separator";"0019";"Separator :1";"F/8";1;"6938.26, 2908.49, 2001.50";"150.00, 209.90, 3.00";"ad6UK3ccHoSAAAAAAAAAWoJ4";"AQ8GAG9FAE4g";"";"";"";"0026, 0026";"";"";"";{"Zuordnung": "0006-R", "ARTINR": "200000077", "Höhe": "2000", "Drehung": 90, "Typ": ""}
+20;"ILS 2.0 BTMT Beladung";"0020";"BTMT Beladung :1";"I/7-J/8";1;"14378.91, 1186.94, 1955.31";"645.71, 397.50, 316.02";"f///EJImHoSAAAAAAAAAAAAA";"AiYwADU7AE6r";"Aj4eACprAE4g";"";"";"0027, 0018";"";"";"";{"ARTINR": "829662012", "Höhe": "2000", "Drehung": 0}
+21;"ILS 2.0 BTMT Entladung";"0021";"BTMT Entladung :1";"A/7";1;"-2501.71, 212.89, 1847.09";"1468.14, 950.36, 467.01";"3bjiCN/+HoSAAAAAAAAAf/+r";"/7UPABabAE4f";"/4Hu//aMAD/1";"";"";"0023, 0023";"";"";"";{"ARTINR": "400100156", "Höhe": "2000", "Drehung": 179}
+22;"ILS Weiche";"0022";"ILS Weiche :1";"B/7";1;"338.26, 505.79, 2000.00";"100.00, 250.00, 30.00";"";"";"";"";"";"0011, 0012";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0009", "Kreisel B": "0008", "Achse von Kreisel": "0008", "Beruehrung": "Kreis/Tangente"}
+23;"ILS Weiche";"0023";"ILS Weiche :2";"B/7";1;"-461.74, 994.84, 2000.00";"100.00, 250.00, 30.00";"";"";"";"";"";"0021, 0021";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0009", "Kreisel B": "0001", "Achse von Kreisel": "0001", "Beruehrung": "Kreis/Tangente"}
+24;"ILS Weiche";"0024";"ILS Weiche :3";"E/7";1;"4538.26, 505.79, 2000.00";"100.00, 250.00, 30.00";"";"";"";"";"";"0025, 0013";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0008", "Kreisel B": "0007", "Achse von Kreisel": "0008", "Beruehrung": "Kreis/Tangente"}
+25;"ILS Weiche";"0025";"ILS Weiche :4";"E/6";1;"4938.26, -2303.25, 2000.00";"250.00, 100.00, 30.00";"";"";"";"";"";"0014, 0015";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0007", "Kreisel B": "0002", "Achse von Kreisel": "0007", "Beruehrung": "Kreis/Kreis"}
+26;"ILS Weiche";"0026";"ILS Weiche :5";"F/8";1;"6538.26, 1885.99, 2000.00";"250.00, 100.00, 30.00";"";"";"";"";"";"0018, 0027";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0006", "Kreisel B": "0005", "Achse von Kreisel": "0006", "Beruehrung": "Kreis/Kreis"}
+27;"ILS Weiche";"0027";"ILS Weiche :6";"F/7";1;"6538.26, 1085.78, 2000.00";"250.00, 100.00, 30.00";"";"";"";"";"";"0017, 0016";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0005", "Kreisel B": "0004", "Achse von Kreisel": "0005", "Beruehrung": "Kreis/Kreis"}
+28;"ILS Weiche";"0028";"ILS Weiche :7";"E/3";1;"6138.26, -7203.05, 2000.00";"100.25, 250.10, 30.00";"";"";"";"";"";"0029, 0029";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0004", "Kreisel B": "0003", "Achse von Kreisel": "0004", "Beruehrung": "Kreis/Kreis"}
+29;"ILS Weiche";"0029";"ILS Weiche :8";"E/3";1;"5338.26, -7202.95, 2000.00";"100.19, 250.07, 30.00";"";"";"";"";"";"0015, 0014";"";"";"";{"Erzeugt aus": "Kreisel-Beruehrpunkt", "Kreisel A": "0003", "Kreisel B": "0002", "Achse von Kreisel": "0003", "Beruehrung": "Kreis/Kreis"}
diff --git a/tests/test_kreisel_umlauf_graph.py b/tests/test_kreisel_umlauf_graph.py
new file mode 100644
index 0000000..64e03fd
--- /dev/null
+++ b/tests/test_kreisel_umlauf_graph.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+"""Tests fuer den Kreisel-Umlauf im Materialflussgraphen (lib/material_flow.py).
+
+Ein Kreisel ist ein geschlossener Umlauf: das Ende von L laeuft in den Anfang
+von R und das Ende von R in den Anfang von L. Frueher bekam jeder Kreisel nur
+EINE Bahnkante (aus der Drehrichtung) - jede Bahn ohne Gegenkante galt dann
+als Sackgasse bzw. unerreichbar, obwohl die Anlage vollstaendig durchlaufbar
+ist. Fixture: Testanlage test_system (dxfmakros results/export.csv, 9 Kreisel,
+8 Kreisel-Kreisel-Weichen, 1 Beladung, 1 Entladung).
+"""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from pathlib import Path
+
+import pytest
+
+from material_flow import (
+ CIRCLE_RETURN_NOTE,
+ EDGE_CIRCLE,
+ EDGE_FLOW,
+ EDGE_UNRESOLVED,
+ Edge,
+ Element,
+ Node,
+ _reachability_warnings,
+ build_graph,
+ read_elements,
+)
+
+FIXTURE = Path(__file__).parent / "fixtures" / "test_system_export.csv"
+
+
+@pytest.fixture(scope="module")
+def elements() -> list[Element]:
+ return read_elements(FIXTURE, [])
+
+
+def _graph(elements: list[Element]):
+ warnings: list[str] = []
+ return build_graph(elements, FIXTURE.name, warnings), warnings
+
+
+def test_jeder_kreisel_hat_beide_bahnkanten(elements):
+ graph, _ = _graph(elements)
+ for el in (e for e in elements if e.is_circle):
+ left, right = f"{el.teile_id}-L", f"{el.teile_id}-R"
+ circle = [e for e in graph.edges if e.kind == EDGE_CIRCLE
+ and {e.src, e.dst} == {left, right}]
+ assert {(e.src, e.dst) for e in circle} == {(left, right), (right, left)}
+ # genau eine Hauptkante mit dem Drehrichtungs-Kuerzel
+ assert sorted(e.note for e in circle) == sorted([el.rotation, CIRCLE_RETURN_NOTE])
+
+
+def test_keine_sackgassen_in_durchlaufbarer_anlage(elements):
+ _, warnings = _graph(elements)
+ bahn = [w for w in warnings if w.startswith("Bahn ")]
+ assert bahn == []
+
+
+def test_kreis_kreis_weiche_ist_gerichtet(elements):
+ """Weiche 0006<->0005 ist "Kreis/Kreis" - liegt genau auf dem Uebergang
+ Endkappe/Gerade, die Geometrie allein entschied dort per Rundung auf
+ "Laengsseite, unbestimmt". Das Merkmal "Beruehrung" hat Vorrang."""
+ graph, _ = _graph(elements)
+ weiche = [e for e in graph.edges if e.note == "Weiche"
+ and {e.src.split("-")[0], e.dst.split("-")[0]} == {"0005", "0006"}]
+ assert weiche
+ assert all(e.kind == EDGE_FLOW for e in weiche)
+
+
+def test_ohne_weichen_meldet_unerreichbare_bahnen(elements):
+ """Gegenprobe: ohne die Kreisel-Kreisel-Weichen (und ohne die rohen
+ Kreisel-Nachbarn, die sonst als unbestimmte Kanten verbinden) ist nur der
+ Kreisel der Beladung erreichbar - die Pruefung muss das melden."""
+ ohne = [replace(e, neighbours=[]) if e.is_circle else e
+ for e in elements if e.kind != "Weiche"]
+ _, warnings = _graph(ohne)
+ assert any("von keiner Beladung" in w for w in warnings)
+ assert any("keine Entladung" in w for w in warnings)
+
+
+def _el(teile_id: str, kind: str) -> Element:
+ return Element(
+ number=teile_id, kind=kind, teile_art=kind, teile_id=teile_id,
+ name=f"{kind} {teile_id}", plan_square="", neighbours=[], track_ids=[],
+ merkmale={}, row=0, position=(0.0, 0.0, 0.0), bbox=None,
+ )
+
+
+def test_unbestimmte_kante_in_beide_richtungen_begehbar():
+ """Eine Weiche mit unbestimmter Richtung (dir=both) darf nicht nur in
+ src->dst-Richtung zaehlen."""
+ k1, k2 = _el("0001", "Kreisel"), _el("0002", "Kreisel")
+ nodes = {
+ "0001-L": Node("0001-L", "Kreisel", k1, lane="L"),
+ "0001-R": Node("0001-R", "Kreisel", k1, lane="R"),
+ "0002-L": Node("0002-L", "Kreisel", k2, lane="L"),
+ "0002-R": Node("0002-R", "Kreisel", k2, lane="R"),
+ "B": Node("B", "Beladung", _el("B", "Beladung")),
+ "E": Node("E", "Entladung", _el("E", "Entladung")),
+ }
+ edges = [
+ Edge("0001-L", "0001-R", EDGE_CIRCLE, "GUZ"),
+ Edge("0001-R", "0001-L", EDGE_CIRCLE, CIRCLE_RETURN_NOTE),
+ Edge("0002-L", "0002-R", EDGE_CIRCLE, "GUZ"),
+ Edge("0002-R", "0002-L", EDGE_CIRCLE, CIRCLE_RETURN_NOTE),
+ # Beladung an Kreisel 2, Entladung an Kreisel 1, Weiche zeigt "falsch herum"
+ Edge("B", "0002-L", EDGE_FLOW),
+ Edge("0001-L", "E", EDGE_FLOW),
+ Edge("0001-R", "0002-R", EDGE_UNRESOLVED, "Weiche"),
+ ]
+ assert _reachability_warnings(nodes, edges) == []
+ # gerichtet waere Kreisel 1 von der Beladung aus nicht erreichbar
+ edges[-1] = Edge("0001-R", "0002-R", EDGE_FLOW, "Weiche")
+ assert any("0001-L" in w and "von keiner Beladung" in w
+ for w in _reachability_warnings(nodes, edges))