From c4122b4497fe2b38d6397fc24aa6d0fa0dcf1a77 Mon Sep 17 00:00:00 2001 From: Samer Ayadi Date: Thu, 15 Oct 2026 15:40:53 +0200 Subject: [PATCH] [FIX] Materialfluss: Kreisel als geschlossener Umlauf Jeder Kreisel bekam nur EINE Bahnkante aus der Drehrichtung (UZS R->L, GUZ L->R). Die Gegenbahn hatte damit keinen Abgang bzw. Zugang, und der Sackgassen-Check meldete an fast jedem Kreisel "Sackgasse"/"nicht erreichbar", obwohl die Anlage durchlaufbar ist. - build_graph: immer L->R UND R->L; die Gegenkante traegt CIRCLE_RETURN_NOTE und wird in render_dot nicht gezeichnet - Sackgassen-Pruefung ersetzt durch Erreichbarkeit im Gesamtgraphen (von Beladung erreichbar / Entladung erreichbar); unbestimmte Kanten zaehlen in beide Richtungen - Weiche: Merkmal "Beruehrung" (Kreis/Kreis bzw. Tangente) hat Vorrang vor der Geometrie, die am Kappenuebergang per Rundung entschied - Test test_kreisel_umlauf_graph.py mit Fixture test_system_export.csv TRO-Graph (nach Hosts) fuer dematic/hundm_05/mubea/showroom unveraendert, HundM05 komplett unveraendert. Co-Authored-By: Claude Opus 5.5 --- lib/material_flow.py | 123 ++++++++++++++++++++------ tests/fixtures/test_system_export.csv | 30 +++++++ tests/test_kreisel_umlauf_graph.py | 118 ++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/test_system_export.csv create mode 100644 tests/test_kreisel_umlauf_graph.py 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))