Orient the carousel arrows from Drehrichtung (UZS / GUZ)

Until now the two lanes of a Kreisel were joined by a pair of edges in both
directions, because the travel direction was unknown. The CSV does carry it in
the Drehrichtung merkmal, so the arrow inside a Kreisel is now directed:

  UZS  clockwise         -> material runs right to left, R -> L
  GUZ  counter-clockwise -> material runs left to right, L -> R

If the merkmal is missing or holds anything else, the circuit stays bidirectional
and that is reported as a warning, so an unknown value cannot silently invent a
direction.

The direction is visible in the drawing: the edge carries the rotation as its
label and the Kreisel cluster header states it in words, e.g.
"Kreisel Kreisel3 [0009] UZS (rechts nach links)".

Since a Kreisel is now traversed one way only, lanes can end up without an
outgoing or without an incoming connection. build_graph reports both cases,
naming the separators that sit on the affected lane, because such a lane means
carriers can either not leave or not reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 13:38:46 +02:00
parent 035c86ced9
commit 0392114e10
+78 -11
View File
@@ -11,9 +11,10 @@ erzeugt daraus einen gerichteten Graphen des Materialflusses.
Knotenmodell
------------
* Gefaellestrecke / Strecke (Foerderer) -> je ein Knoten
* Kreisel -> zwei Knoten (-L / -R), die als
Kreis miteinander verbunden sind
(L -> R und R -> L)
* Kreisel -> zwei Knoten (-L / -R); die Richtung
zwischen ihnen kommt aus dem Merkmal
"Drehrichtung": UZS = rechts nach
links, GUZ = links nach rechts
* Separator / Scanner -> kein eigener Knoten; sie werden
ueber "Zuordnung" am Label des
uebergeordneten Knotens angezeigt
@@ -82,6 +83,14 @@ LANE_NAMES = {LANE_LEFT: "links", LANE_RIGHT: "rechts"}
UNASSIGNED = "nicht zugeordnet"
# Drehrichtung eines Kreisels (Merkmal "Drehrichtung"). Sie bestimmt, in welche
# Richtung das Material innerhalb des Kreisels zwischen den beiden Bahnen laeuft:
# UZS im Uhrzeigersinn -> von rechts nach links (R -> L)
# GUZ gegen den Uhrzeigersinn -> von links nach rechts (L -> R)
ROTATION_CW = "UZS"
ROTATION_CCW = "GUZ"
ROTATION_FLOW = {ROTATION_CW: "rechts nach links", ROTATION_CCW: "links nach rechts"}
# "0009-L" -> base 0009, lane L ; "0034" -> base 0034, lane None
NEIGHBOUR_RE = re.compile(r"^(?P<base>[^\s-]+)(?:-(?P<lane>[LR]))?$")
@@ -154,6 +163,11 @@ class Element:
def drive_dir(self) -> str:
return str(_merkmal(self.merkmale, "Antriebfahrtrichtung") or "").strip()
@property
def rotation(self) -> str:
"""Drehrichtung eines Kreisels: UZS (im Uhrzeigersinn) oder GUZ."""
return str(_merkmal(self.merkmale, "Drehrichtung") or "").strip().upper()
@property
def assignment(self) -> str:
return str(_merkmal(self.merkmale, "Zuordnung") or "").strip()
@@ -561,14 +575,51 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
edges = _orient(sorted(pairs), nodes, warnings)
# Kreisel: die beiden Bahnen bilden den Kreis
# 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
# Fehlt die Angabe, bleibt der Umlauf beidseitig und wird gemeldet.
for element in elements:
if not element.is_circle:
continue
left = f"{element.teile_id}-{LANE_LEFT}"
right = f"{element.teile_id}-{LANE_RIGHT}"
edges.append(Edge(left, right, EDGE_CIRCLE))
edges.append(Edge(right, left, EDGE_CIRCLE))
rotation = element.rotation
if rotation == ROTATION_CW:
edges.append(Edge(right, left, EDGE_CIRCLE, ROTATION_CW))
elif rotation == ROTATION_CCW:
edges.append(Edge(left, right, EDGE_CIRCLE, ROTATION_CCW))
else:
warnings.append(
f"{element.describe()}: Drehrichtung '{element.rotation or '-'}' "
f"unbekannt (erwartet {ROTATION_CW} oder {ROTATION_CCW}) - "
f"Umlauf beidseitig gezeichnet"
)
edges.append(Edge(left, right, EDGE_CIRCLE, "unbestimmt"))
edges.append(Edge(right, left, EDGE_CIRCLE, "unbestimmt"))
# 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"
)
return Graph(nodes=nodes, edges=edges, warnings=warnings, unassigned=unassigned, source=source)
@@ -633,8 +684,12 @@ def render_dot(graph: Graph) -> str:
for index, (teile_id, lanes) in enumerate(sorted(circles.items())):
element = lanes[0].element
name = _merkmal(element.merkmale, "Name") or element.name
rot = element.rotation
rot_text = (f" {rot} ({ROTATION_FLOW[rot]})" if rot in ROTATION_FLOW
else f" Drehrichtung {rot or '?'}")
add(f" subgraph cluster_kreisel_{index} {{")
add(f' label="{_dot_escape(element.kind)} {_dot_escape(str(name))} [{teile_id}]";')
add(f' label="{_dot_escape(element.kind)} {_dot_escape(str(name))} '
f'[{teile_id}]{_dot_escape(rot_text)}";')
add(' style="rounded,filled"; fillcolor="#eef2fa"; color="#2f5597";')
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#1f3864"; margin=12;')
style = NODE_STYLES.get(element.kind, DEFAULT_NODE_STYLE)
@@ -653,8 +708,11 @@ def render_dot(graph: Graph) -> str:
for edge in graph.edges:
if edge.kind == EDGE_CIRCLE:
attrs = ('[color="#8ea9db", style=dashed, penwidth=1.0, constraint=false, '
'arrowsize=0.6, tooltip="Kreisel-Umlauf"]')
# Richtung kommt aus der Drehrichtung und steht als Label an der Kante
label = f', xlabel="{_dot_escape(edge.note)}"' if edge.note else ""
attrs = ('[color="#8ea9db", style=dashed, penwidth=1.2, constraint=false, '
'arrowsize=0.9, fontcolor="#5b7fc7", '
f'tooltip="Kreisel-Umlauf {edge.note}"{label}]')
elif edge.kind == EDGE_UNRESOLVED:
attrs = ('[color="#bf8f00", style=dashed, dir=both, '
f'tooltip="Richtung {edge.note}"]')
@@ -670,7 +728,9 @@ def render_dot(graph: Graph) -> str:
'<table border="0" cellborder="0" cellspacing="2" cellpadding="2">'
'<tr><td align="left">durchgezogen</td><td align="left">Materialfluss</td></tr>'
'<tr><td align="left"><font color="#8ea9db">gestrichelt blau</font></td>'
'<td align="left">Kreisel-Umlauf (L / R)</td></tr>'
'<td align="left">Kreisel-Umlauf, Richtung aus Drehrichtung '
f'({ROTATION_CW} = {ROTATION_FLOW[ROTATION_CW]}, '
f'{ROTATION_CCW} = {ROTATION_FLOW[ROTATION_CCW]})</td></tr>'
'<tr><td align="left"><font color="#bf8f00">gestrichelt gelb</font></td>'
'<td align="left">Richtung nicht bestimmbar</td></tr>'
"</table>>];")
@@ -886,7 +946,14 @@ def render_doc(
kind_text = {EDGE_FLOW: "Materialfluss", EDGE_CIRCLE: "Kreisel-Umlauf",
EDGE_UNRESOLVED: "Richtung unbestimmt"}
for edge in graph.edges:
add(f"| `{edge.src}` | `{edge.dst}` | {kind_text.get(edge.kind, edge.kind)} |")
note = f" ({edge.note})" if edge.note else ""
add(f"| `{edge.src}` | `{edge.dst}` | "
f"{kind_text.get(edge.kind, edge.kind)}{note} |")
add("")
add(f"Die Richtung innerhalb eines Kreisels kommt aus dem Merkmal "
f"`Drehrichtung`: **{ROTATION_CW}** (im Uhrzeigersinn) laesst das Material "
f"{ROTATION_FLOW[ROTATION_CW]} laufen, **{ROTATION_CCW}** (gegen den "
f"Uhrzeigersinn) {ROTATION_FLOW[ROTATION_CCW]}.")
add("")
add("## 4. Plausibilitaetshinweise")