Group parallel Gefaellestrecken in the material-flow diagram

A bank of gravity lanes between the same two Kreisel (same slope) was
drawn as one box per lane, making large storage banks unreadable.
render_dot now collapses such lanes into a single Gefaellestrecke-styled
block with a double border, labelled with the id range and combined
Separator/Scanner ranges. Purely visual - the graph, --doc report and
--connect output still list every lane individually.
This commit is contained in:
2026-08-21 12:08:21 +02:00
parent 9336f18466
commit e165beeaa8
4 changed files with 298 additions and 588 deletions
+116 -1
View File
@@ -138,6 +138,12 @@ NODE_STYLES = {
}
DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080")
# Ab dieser Anzahl paralleler Gefaellestrecken zwischen denselben zwei
# Kreisel-Bahnen (gleicher Zulauf, gleicher Ablauf, gleiche Steigung) werden
# sie im Materialfluss-Diagramm zu einem Block zusammengefasst - sonst wird
# eine grosse Linienbank (Speicher) unlesbar breit.
MIN_GEFAELLE_GROUP = 2
# ---------------------------------------------------------------------------
# Datenmodell
@@ -999,6 +1005,78 @@ def _pos_attr(node: "Node", use_coords: bool) -> str:
return f', pos="{xy[0] * COORD_SCALE:.2f},{xy[1] * COORD_SCALE:.2f}"'
def _flow_neighbours(node_id: str, edges: list[Edge]) -> tuple[str, str] | None:
"""
(Vorgaenger, Nachfolger) eines Knotens im Materialfluss.
None, wenn der Knoten nicht genau einen ankommenden und einen abgehenden
EDGE_FLOW hat - dann ist er kein einfacher Durchlaufknoten zwischen zwei
festen Nachbarn.
"""
incoming = {e.src for e in edges if e.dst == node_id and e.kind == EDGE_FLOW}
outgoing = {e.dst for e in edges if e.src == node_id and e.kind == EDGE_FLOW}
if len(incoming) != 1 or len(outgoing) != 1:
return None
return next(iter(incoming)), next(iter(outgoing))
def _group_parallel_gefaelle(graph: Graph) -> list[list[Node]]:
"""
Parallele Gefaellestrecken zwischen denselben zwei Kreisel-Bahnen zu
Gruppen zusammenfassen (z. B. eine Bank aus Schwerkraftlinien).
Kriterium: gleicher Zulauf- und Ablauf-Knoten (jeweils eine Kreisel-Bahn)
und gleiche Steigung (Hoehe_Von/Hoehe_Bis) - "gleich lang und gleiche
Steigung" laesst sich aus dem Export nur ueber die Hoehenangabe pruefen,
die Laenge ergibt sich daraus, dass beide Enden an denselben zwei Knoten
haengen. Rein fuer die Darstellung; Graph-Knoten/-Kanten bleiben unveraendert.
"""
groups: dict[tuple, list[Node]] = {}
for node_id in sorted(graph.nodes):
node = graph.nodes[node_id]
if node.kind != "Gefaellestrecke":
continue
neighbours = _flow_neighbours(node_id, graph.edges)
if neighbours is None:
continue
before, after = neighbours
if graph.nodes[before].kind != "Kreisel" or graph.nodes[after].kind != "Kreisel":
continue
element = node.element
key = (before, after, element.height_from, element.height_to)
groups.setdefault(key, []).append(node)
return [members for members in groups.values() if len(members) >= MIN_GEFAELLE_GROUP]
def _id_range(ids: list[str]) -> str:
"""Sortierte Ids als Bereich, z. B. ['0037', '0056'] -> '0037 - 0056'."""
ids = sorted(ids)
return ids[0] if len(ids) == 1 else f"{ids[0]} - {ids[-1]}"
def _gefaelle_group_label(members: list[Node]) -> str:
"""Sammel-Label einer Gefaellestrecken-Gruppe: Bereich der Linien-Ids,
gemeinsame Steigung, zusammengefasste Separatoren/Scanner."""
element = members[0].element
ids = sorted(m.node_id for m in members)
lines = [
f"Gefaellestrecke {ids[0]} -> {ids[-1]} ({len(ids)}x)",
"Gruppe paralleler Schwerkraftlinien",
]
if element.height is not None:
lines.append(f"h = {element.height:.3f} m")
if element.rise is not None:
uphill = drive_uphill(element)[0]
arrow = {True: "aufwaerts", False: "abwaerts", None: "Richtung offen"}[uphill]
lines.append(f"{element.height_from:.0f} -> {element.height_to:.0f} mm "
f"({element.rise:+.0f}) = {arrow}")
for kind in DEVICE_KINDS:
ids_dev = sorted({d for m in members for d in m.devices.get(kind, [])})
if ids_dev:
lines.append(f"{kind} ({len(ids_dev)}): {_id_range(ids_dev)}")
return _dot_label(lines)
def render_dot(graph: Graph, use_coords: bool = False) -> str:
out: list[str] = []
add = out.append
@@ -1047,14 +1125,48 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str:
add(" }")
add("")
# Parallele Gefaellestrecken (gleicher Zu-/Ablauf, gleiche Steigung) zu
# je einem Block zusammenfassen, damit eine Linienbank nicht als N
# einzelne Kaesten gezeichnet wird.
gefaelle_groups = _group_parallel_gefaelle(graph)
group_of: dict[str, str] = {}
for members in gefaelle_groups:
group_id = f"grp_{members[0].node_id}"
for member in members:
group_of[member.node_id] = group_id
grouped_ids = set(group_of)
for node in plain:
if node.node_id in grouped_ids:
continue
style = NODE_STYLES.get(node.kind, DEFAULT_NODE_STYLE)
attrs = ", ".join(f'{key}="{value}"' for key, value in style.items())
add(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}'
f'{_pos_attr(node, use_coords)}];')
for members in gefaelle_groups:
group_id = f"grp_{members[0].node_id}"
style = NODE_STYLES.get("Gefaellestrecke", DEFAULT_NODE_STYLE)
attrs = ", ".join(f'{key}="{value}"' for key, value in style.items())
pos = ""
if use_coords:
xs = [xy[0] for m in members if (xy := _node_xy(m))]
ys = [xy[1] for m in members if (xy := _node_xy(m))]
if xs and ys:
pos = (f', pos="{sum(xs) / len(xs) * COORD_SCALE:.2f},'
f'{sum(ys) / len(ys) * COORD_SCALE:.2f}"')
add(f' "{group_id}" [label="{_gefaelle_group_label(members)}", {attrs}, '
f'peripheries=2{pos}];')
add("")
seen_group_edges: set[tuple[str, str]] = set()
for edge in graph.edges:
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:
if src == dst or (src, dst) in seen_group_edges:
continue
seen_group_edges.add((src, dst))
if edge.kind == EDGE_CIRCLE:
# Richtung kommt aus der Drehrichtung und steht als Label an der Kante
label = f', xlabel="{_dot_escape(edge.note)}"' if edge.note else ""
@@ -1070,7 +1182,7 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str:
'fontcolor="#cc8800", tooltip="Weiche (Kreisel-Uebergang)"]')
else:
attrs = "[]"
add(f' "{edge.src}" -> "{edge.dst}" {attrs};')
add(f' "{src}" -> "{dst}" {attrs};')
add("")
legend_pos = ""
@@ -1094,6 +1206,9 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str:
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>'
'<tr><td align="left">doppelte Umrandung</td>'
'<td align="left">Gruppe paralleler Gefaellestrecken (gleicher Zu-/Ablauf, '
"gleiche Steigung)</td></tr>"
"</table>>];")
add(" }")
add("}")