Files
sps_skel/lib/material_flow.py
T
s.ayadi 035c86ced9 Draw the TRO flow to scale with neato via --use-cords
Adds a --use-cords switch to tro_flow.py (--use-coords accepted too). With it,
every TRO node carries its plant coordinate as a pos attribute and the SVG is
produced by "neato -n" instead of dot, so the diagram looks like the plant
layout rather than a computed left-to-right flow.

Scale is 0.25 pt/mm (COORD_SCALE), which turns the ~11 x 20 m plant into roughly
2800 x 5000 pt - large enough that the TRO boxes do not overlap. Without the
switch the pos attributes stay in the DOT as information only, at 1:100, and dot
ignores them; the default layout is unchanged.

Two details neato -n needs: the legend gets a position of its own, placed below
left of the plant bounding box, because with -n a node without pos would land at
0,0 in the middle of the drawing. And the switch is refused with a clear message
if any TRO has no coordinate, since -n cannot lay out a graph with missing
positions.

Verified that neato -n reproduces the given coordinates: the positions it reports
back differ from the input by a constant offset only (spread 0.08 pt, from
Graphviz moving the drawing into the positive quadrant), and no node distance
changes by more than 0.08 pt over spans of up to 3600 pt.

graph_to_svg() in material_flow.py replaces dot_to_svg() to take an engine and
the no-op flag; dot_to_svg() stays as a thin wrapper. It now also treats output
on stderr as an error, so neato's "node has no position" warnings do not pass
silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:24:14 +02:00

1107 lines
39 KiB
Python

# -*- coding: utf-8 -*-
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
# Der Interpreter wird von bin/material_flow.bat bzw. .sh gesetzt.
"""
material_flow.py - Materialfluss-Graph aus einem CSV-Export erzeugen.
Liest einen CSV-Export der Anlagenobjekte (ILS 2.0) aus dem data-Ordner und
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)
* Separator / Scanner -> kein eigener Knoten; sie werden
ueber "Zuordnung" am Label des
uebergeordneten Knotens angezeigt
Kantenmodell
------------
Die Spalte "Nachbarn" liefert die Verbindungen (symmetrisch, ohne Richtung).
Die Flussrichtung wird aus Hoehe und Antriebsrichtung abgeleitet:
* Gefaellestrecke -> bergab: vom hoechsten Nachbarn weg
* Strecke, Antriebfahrtrichtung -> "Auf": bergauf, zum hoechsten Nachbarn hin
"Ab": bergab, vom hoechsten Nachbarn weg
Verbindungen, deren Richtung sich so nicht bestimmen laesst (z. B. zwischen
zwei Kreiseln auf gleicher Hoehe), werden als beidseitige Kante gezeichnet und
im Bericht als Warnung ausgegeben.
Abgrenzung
----------
Dieses Programm behandelt ausschliesslich den Materialfluss der *mechanischen
Objekte*. Die Ableitung der Steuerungsobjekte (TROs) und ihr Flussdiagramm
liegen im Schwesterprogramm lib/tro_flow.py, das die hier definierten
Bausteine (read_elements, build_graph) weiterverwendet.
Ausgabe (alles in SKEL_RESULTS)
-------------------------------
<name>_material_flow.dot immer
<name>_material_flow.svg mit --tosvg (Graphviz)
<name>_material_flow.md mit --doc
Aufruf ueber bin/material_flow.bat bzw. bin/material_flow.sh.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------
CSV_DELIMITER = ";"
CSV_ENCODING = "utf-8-sig"
TEILEART_PREFIX = "ILS 2.0 "
# Objektarten, die je einen Materialfluss-Knoten bilden
TRANSPORT_KINDS = ("Gefaellestrecke", "Strecke")
# Objektarten, die als Kreis aus zwei Bahnen (links/rechts) modelliert werden
CIRCLE_KINDS = ("Kreisel",)
# Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten)
DEVICE_KINDS = ("Separator", "Scanner")
LANE_LEFT = "L"
LANE_RIGHT = "R"
LANE_NAMES = {LANE_LEFT: "links", LANE_RIGHT: "rechts"}
UNASSIGNED = "nicht zugeordnet"
# "0009-L" -> base 0009, lane L ; "0034" -> base 0034, lane None
NEIGHBOUR_RE = re.compile(r"^(?P<base>[^\s-]+)(?:-(?P<lane>[LR]))?$")
# Kantenarten
EDGE_FLOW = "flow"
EDGE_CIRCLE = "circle"
EDGE_UNRESOLVED = "unresolved"
# Darstellung je Objektart
NODE_STYLES = {
"Kreisel": dict(fillcolor="#2f5597", fontcolor="#ffffff", color="#1f3864"),
"Gefaellestrecke": dict(fillcolor="#fbe5d6", fontcolor="#833c00", color="#c55a11"),
"Strecke": dict(fillcolor="#e2f0d9", fontcolor="#375623", color="#548235"),
}
DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080")
# ---------------------------------------------------------------------------
# Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class Element:
"""Eine Zeile des CSV-Exports."""
number: str
kind: str
teile_art: str
teile_id: str
name: str
plan_square: str
neighbours: list[tuple[str, str | None]]
merkmale: dict
row: int
position: tuple[float, float, float] | None = None
@property
def x(self) -> float | None:
return self.position[0] if self.position else None
@property
def y(self) -> float | None:
return self.position[1] if self.position else None
@property
def z(self) -> float | None:
return self.position[2] if self.position else None
@property
def is_transport(self) -> bool:
return self.kind in TRANSPORT_KINDS
@property
def is_circle(self) -> bool:
return self.kind in CIRCLE_KINDS
@property
def is_device(self) -> bool:
return self.kind in DEVICE_KINDS
@property
def height(self) -> float | None:
"""Montage-/Einbauhoehe in Meter, falls ableitbar."""
if self.is_circle:
return _as_float(_merkmal(self.merkmale, "Höhe in m", "Hoehe in m"))
return _as_float(_merkmal(self.merkmale, "Montagehoehe_m", "Montagehöhe_m"))
@property
def drive_dir(self) -> str:
return str(_merkmal(self.merkmale, "Antriebfahrtrichtung") or "").strip()
@property
def assignment(self) -> str:
return str(_merkmal(self.merkmale, "Zuordnung") or "").strip()
def describe(self) -> str:
return f"Zeile {self.row} ({self.kind} {self.teile_id} '{self.name}')"
@dataclass
class Node:
"""Ein Knoten des Materialfluss-Graphen."""
node_id: str
kind: str
element: Element
lane: str | None = None
devices: dict[str, list[str]] = field(default_factory=dict)
@property
def height(self) -> float | None:
return self.element.height
@dataclass
class Edge:
src: str
dst: str
kind: str
note: str = ""
@dataclass
class Graph:
nodes: dict[str, Node] = field(default_factory=dict)
edges: list[Edge] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
unassigned: list[Element] = field(default_factory=list)
source: str = ""
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _merkmal(merkmale: dict, *keys: str):
"""Merkmal ueber mehrere Schreibweisen hinweg lesen."""
for key in keys:
if key in merkmale:
return merkmale[key]
return None
def _as_float(value) -> float | None:
"""Zahl tolerant lesen ('1.94' und '1,94'). Listen wie '2.298,1' -> None."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.count(",") == 1 and "." not in text:
text = text.replace(",", ".")
try:
return float(text)
except ValueError:
return None
def _dot_escape(text: str) -> str:
return text.replace("\\", "\\\\").replace('"', '\\"')
def _dot_label(lines: list[str]) -> str:
return "\\n".join(_dot_escape(line) for line in lines if line)
# ---------------------------------------------------------------------------
# Pfade / Umgebung
# ---------------------------------------------------------------------------
def project_root() -> Path:
env = os.environ.get("SPS_SKEL")
if env:
return Path(env)
return Path(__file__).resolve().parent.parent
def env_dir(var: str, fallback: str) -> Path:
env = os.environ.get(var)
return Path(env) if env else project_root() / fallback
def resolve_input(name: str) -> Path:
"""Dateinamen gegen SKEL_DATA aufloesen; Pfadangaben bleiben unveraendert."""
candidate = Path(name)
if candidate.is_absolute() or candidate.parent != Path("."):
return candidate
return env_dir("SKEL_DATA", "data") / name
# ---------------------------------------------------------------------------
# CSV einlesen
# ---------------------------------------------------------------------------
def parse_position(raw: str) -> tuple[float, float, float] | None:
"""'9510.00, -4882.95, 1942.50' -> (9510.0, -4882.95, 1942.5)"""
parts = [p.strip() for p in (raw or "").split(",")]
if len(parts) != 3:
return None
values = [_as_float(p) for p in parts]
if any(v is None for v in values):
return None
return (values[0], values[1], values[2])
def parse_neighbours(raw: str) -> list[tuple[str, str | None]]:
"""'0009-L, 0002-R' -> [('0009', 'L'), ('0002', 'R')]"""
result: list[tuple[str, str | None]] = []
for token in (raw or "").split(","):
token = token.strip()
if not token:
continue
match = NEIGHBOUR_RE.match(token)
if not match:
continue
result.append((match.group("base"), match.group("lane")))
return result
def read_elements(path: Path, warnings: list[str]) -> list[Element]:
"""CSV-Export einlesen."""
with path.open("r", encoding=CSV_ENCODING, newline="") as handle:
reader = csv.DictReader(handle, delimiter=CSV_DELIMITER)
if not reader.fieldnames or "TeileId" not in reader.fieldnames:
raise ValueError(
f"Unerwartete Kopfzeile in {path.name}: 'TeileId' nicht gefunden. "
f"Gefunden: {reader.fieldnames}"
)
elements: list[Element] = []
seen: dict[str, Element] = {}
for offset, row in enumerate(reader, start=2):
teile_id = (row.get("TeileId") or "").strip()
if not teile_id:
continue
teile_art = (row.get("TeileArt") or "").strip()
kind = teile_art[len(TEILEART_PREFIX):] if teile_art.startswith(TEILEART_PREFIX) else teile_art
raw_merkmale = (row.get("Merkmale") or "").strip()
merkmale: dict = {}
if raw_merkmale:
try:
parsed = json.loads(raw_merkmale)
if isinstance(parsed, dict):
merkmale = parsed
else:
warnings.append(f"Zeile {offset}: Merkmale ist kein Objekt - ignoriert")
except json.JSONDecodeError as exc:
warnings.append(f"Zeile {offset}: Merkmale nicht lesbar ({exc.msg}) - ignoriert")
element = Element(
number=(row.get("Elementnummer") or "").strip(),
kind=kind,
teile_art=teile_art,
teile_id=teile_id,
name=(row.get("Bezeichnung") or "").strip(),
plan_square=(row.get("Planquadrat") or "").strip(),
neighbours=parse_neighbours(row.get("Nachbarn") or ""),
merkmale=merkmale,
row=offset,
position=parse_position(row.get("Position") or ""),
)
if teile_id in seen:
warnings.append(
f"{element.describe()}: TeileId doppelt (bereits in Zeile {seen[teile_id].row}) - ignoriert"
)
continue
seen[teile_id] = element
elements.append(element)
return elements
# ---------------------------------------------------------------------------
# Graph aufbauen
# ---------------------------------------------------------------------------
def _build_nodes(elements: list[Element], warnings: list[str]) -> dict[str, Node]:
nodes: dict[str, Node] = {}
for element in elements:
if element.is_transport:
nodes[element.teile_id] = Node(element.teile_id, element.kind, element)
elif element.is_circle:
for lane in (LANE_LEFT, LANE_RIGHT):
node_id = f"{element.teile_id}-{lane}"
nodes[node_id] = Node(node_id, element.kind, element, lane=lane)
elif not element.is_device:
warnings.append(
f"{element.describe()}: unbekannte TeileArt '{element.teile_art}' - kein Knoten erzeugt"
)
return nodes
def _attach_devices(
elements: list[Element], nodes: dict[str, Node], warnings: list[str]
) -> list[Element]:
"""Separatoren/Scanner ueber 'Zuordnung' an ihren Knoten haengen."""
unassigned: list[Element] = []
for element in elements:
if not element.is_device:
continue
target = element.assignment
if not target or target == UNASSIGNED:
unassigned.append(element)
continue
if target not in nodes:
warnings.append(
f"{element.describe()}: Zuordnung '{target}' zeigt auf keinen Knoten - nicht dargestellt"
)
unassigned.append(element)
continue
nodes[target].devices.setdefault(element.kind, []).append(element.teile_id)
return unassigned
def _collect_links(
elements: list[Element], by_id: dict[str, Element], warnings: list[str]
) -> dict[frozenset, dict[str, set[str]]]:
"""
Verbindungen aus der Spalte 'Nachbarn' sammeln.
Ergebnis: {frozenset({idA, idB}): {idA: {Bahnen}, idB: {Bahnen}}}
Die Bahn (-L/-R) eines Kreisels steht immer in der Zeile des *Partners*,
daher werden beide Blickrichtungen zusammengefuehrt.
"""
observed: dict[frozenset, dict[str, set[str]]] = {}
for element in elements:
if not element.neighbours:
continue
if element.is_device:
warnings.append(
f"{element.describe()}: Nachbarn an einem Anbauteil werden nicht ausgewertet"
)
continue
if not (element.is_transport or element.is_circle):
continue
for base, lane in element.neighbours:
if base == element.teile_id:
warnings.append(f"{element.describe()}: Nachbar verweist auf sich selbst - ignoriert")
continue
partner = by_id.get(base)
if partner is None:
warnings.append(f"{element.describe()}: unbekannter Nachbar '{base}' - ignoriert")
continue
if partner.is_device or not (partner.is_transport or partner.is_circle):
warnings.append(
f"{element.describe()}: Nachbar '{base}' ({partner.kind}) ist kein Flussobjekt - ignoriert"
)
continue
key = frozenset((element.teile_id, base))
entry = observed.setdefault(key, {element.teile_id: set(), base: set()})
if lane:
entry[base].add(lane)
return observed
def _endpoints(
base: str, lanes: set[str], by_id: dict[str, Element], warnings: list[str], partner: str
) -> list[str]:
"""Basis-Id auf konkrete Knoten aufloesen (Kreisel: eine Bahn je Knoten)."""
element = by_id[base]
if not element.is_circle:
return [base]
if lanes:
return [f"{base}-{lane}" for lane in sorted(lanes)]
warnings.append(
f"Verbindung {base} <-> {partner}: keine Bahn (-L/-R) angegeben - "
f"beide Bahnen des Kreisels {base} werden verbunden"
)
return [f"{base}-{LANE_LEFT}", f"{base}-{LANE_RIGHT}"]
def _flow_uphill(element: Element, warnings: list[str]) -> bool | None:
"""
True = Fluss laeuft zum hoechsten Nachbarn hin (bergauf)
False = Fluss laeuft vom hoechsten Nachbarn weg (bergab)
None = nicht bestimmbar
"""
if element.kind == "Gefaellestrecke":
return False
direction = element.drive_dir.lower()
if direction.startswith("auf"):
return True
if direction.startswith("ab"):
return False
warnings.append(
f"{element.describe()}: Antriebfahrtrichtung fehlt oder unbekannt "
f"('{element.drive_dir}') - Richtung nicht bestimmbar"
)
return None
def _orient(
pairs: list[tuple[str, str]], nodes: dict[str, Node], warnings: list[str]
) -> list[Edge]:
"""Flussrichtung je Verbindung aus Hoehe und Antriebsrichtung ableiten."""
incident: dict[str, list[int]] = {}
for index, (left, right) in enumerate(pairs):
incident.setdefault(left, []).append(index)
incident.setdefault(right, []).append(index)
# Vorschlaege je Verbindung, gesammelt aus der Sicht der Transportobjekte
proposals: dict[int, set[tuple[str, str]]] = {index: set() for index in range(len(pairs))}
for node_id, node in sorted(nodes.items()):
if node.kind not in TRANSPORT_KINDS:
continue
indices = incident.get(node_id, [])
if not indices:
warnings.append(
f"{node.element.describe()}: keine Nachbarn - Knoten bleibt unverbunden"
)
continue
partners = {}
for index in indices:
left, right = pairs[index]
partners[index] = right if left == node_id else left
heights = {index: nodes[partner].height for index, partner in partners.items()}
missing = [partners[i] for i, h in heights.items() if h is None]
if missing:
warnings.append(
f"{node.element.describe()}: Hoehe von {', '.join(sorted(missing))} unbekannt - "
f"Richtung nicht bestimmbar"
)
continue
uphill = _flow_uphill(node.element, warnings)
if uphill is None:
continue
highest = max(heights.values())
if highest == min(heights.values()):
warnings.append(
f"{node.element.describe()}: alle Nachbarn auf gleicher Hoehe "
f"({highest:.3f} m) - Richtung nicht bestimmbar"
)
continue
for index, partner in partners.items():
at_top = heights[index] == highest
if uphill == at_top:
proposals[index].add((node_id, partner))
else:
proposals[index].add((partner, node_id))
edges: list[Edge] = []
for index, (left, right) in enumerate(pairs):
found = proposals[index]
if len(found) == 1:
src, dst = next(iter(found))
edges.append(Edge(src, dst, EDGE_FLOW))
elif len(found) > 1:
warnings.append(
f"Verbindung {left} <-> {right}: widerspruechliche Richtungen "
f"({', '.join(f'{s} -> {d}' for s, d in sorted(found))}) - beidseitig gezeichnet"
)
edges.append(Edge(left, right, EDGE_UNRESOLVED, "widerspruechlich"))
else:
warnings.append(
f"Verbindung {left} <-> {right}: Richtung nicht bestimmbar "
f"(kein Transportobjekt beteiligt) - beidseitig gezeichnet"
)
edges.append(Edge(left, right, EDGE_UNRESOLVED, "unbestimmt"))
return edges
def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Graph:
by_id = {element.teile_id: element for element in elements}
nodes = _build_nodes(elements, warnings)
unassigned = _attach_devices(elements, nodes, warnings)
observed = _collect_links(elements, by_id, warnings)
pairs: set[tuple[str, str]] = set()
for key, lanes in sorted(observed.items(), key=lambda item: sorted(item[0])):
base_a, base_b = sorted(key)
for node_a in _endpoints(base_a, lanes[base_a], by_id, warnings, base_b):
for node_b in _endpoints(base_b, lanes[base_b], by_id, warnings, base_a):
pairs.add((node_a, node_b))
edges = _orient(sorted(pairs), nodes, warnings)
# Kreisel: die beiden Bahnen bilden den Kreis
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))
return Graph(nodes=nodes, edges=edges, warnings=warnings, unassigned=unassigned, source=source)
# ---------------------------------------------------------------------------
# DOT erzeugen
# ---------------------------------------------------------------------------
def _node_label(node: Node) -> str:
element = node.element
lines: list[str] = []
if node.lane:
name = _merkmal(element.merkmale, "Name") or element.name
lines.append(f"{name} [{node.node_id}]")
lines.append(f"Bahn {node.lane} ({LANE_NAMES[node.lane]})")
else:
lines.append(f"{element.name} [{element.teile_id}]")
lines.append(element.kind)
height = element.height
if height is not None:
lines.append(f"h = {height:.3f} m")
if element.kind == "Strecke" and element.drive_dir:
lines.append(f"Antrieb: {element.drive_dir}")
for kind in DEVICE_KINDS:
ids = node.devices.get(kind)
if ids:
lines.append(f"{kind} ({len(ids)}): {', '.join(sorted(ids))}")
return _dot_label(lines)
def render_dot(graph: Graph) -> str:
out: list[str] = []
add = out.append
add("// Automatisch erzeugt von lib/material_flow.py - nicht manuell aendern.")
add(f"// Quelle: {graph.source}")
add("digraph Materialfluss {")
add(' graph [rankdir=LR, splines=spline, nodesep=0.25, ranksep=1.6,')
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
add(f' label="Materialfluss - {_dot_escape(graph.source)}"];')
add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];')
add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,'
' arrowsize=0.8];')
add("")
# Kreisel als Cluster mit den beiden Bahnen
circles: dict[str, list[Node]] = {}
plain: list[Node] = []
for node_id in sorted(graph.nodes):
node = graph.nodes[node_id]
if node.lane:
circles.setdefault(node.element.teile_id, []).append(node)
else:
plain.append(node)
for index, (teile_id, lanes) in enumerate(sorted(circles.items())):
element = lanes[0].element
name = _merkmal(element.merkmale, "Name") or element.name
add(f" subgraph cluster_kreisel_{index} {{")
add(f' label="{_dot_escape(element.kind)} {_dot_escape(str(name))} [{teile_id}]";')
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)
for node in sorted(lanes, key=lambda n: n.lane or ""):
attrs = ", ".join(f'{key}="{value}"' for key, value in style.items())
add(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}];')
add(" { rank=same; " + " ".join(f'"{n.node_id}";' for n in lanes) + " }")
add(" }")
add("")
for node in plain:
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}];')
add("")
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"]')
elif edge.kind == EDGE_UNRESOLVED:
attrs = ('[color="#bf8f00", style=dashed, dir=both, '
f'tooltip="Richtung {edge.note}"]')
else:
attrs = "[]"
add(f' "{edge.src}" -> "{edge.dst}" {attrs};')
add("")
add(" subgraph cluster_legende {")
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
add(' legende [shape=plaintext, style="", fillcolor="none", label=<'
'<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>'
'<tr><td align="left"><font color="#bf8f00">gestrichelt gelb</font></td>'
'<td align="left">Richtung nicht bestimmbar</td></tr>'
"</table>>];")
add(" }")
add("}")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# SVG erzeugen
# ---------------------------------------------------------------------------
def find_dot() -> str | None:
env = os.environ.get("GRAPHVIZ_DOT")
if env:
return env if Path(env).is_file() else shutil.which(env)
return shutil.which("dot")
def find_engine(engine: str) -> str | None:
"""Layout-Programm von Graphviz suchen ('dot', 'neato', ...)."""
if engine == "dot":
return find_dot()
return shutil.which(engine)
def graph_to_svg(
dot_file: Path, svg_file: Path, engine: str = "dot", no_op: int = 0
) -> None:
"""
SVG aus der .dot-Datei erzeugen.
engine Layout-Programm ('dot' fuer die berechnete Anordnung, 'neato' fuer
vorgegebene Koordinaten)
no_op nur fuer neato: 1 oder 2 setzt den No-op-Schalter (-n / -n2), dann
werden die pos-Attribute der Knoten als Position uebernommen und
nur die Kanten berechnet. Alle Knoten brauchen dann ein pos.
"""
executable = find_engine(engine)
if not executable:
raise RuntimeError(
f"Graphviz '{engine}' nicht gefunden. Bitte Graphviz installieren "
f"(https://graphviz.org/download/)"
+ (" oder GRAPHVIZ_DOT auf die dot-Programmdatei setzen."
if engine == "dot" else ".")
)
command = [executable]
if no_op:
command.append(f"-n{no_op}")
command += ["-Tsvg", str(dot_file), "-o", str(svg_file)]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"'{engine}' ist mit Code {result.returncode} fehlgeschlagen:\n"
f"{(result.stderr or result.stdout).strip()}"
)
# neato meldet fehlende Positionen nur als Warnung auf stderr
if result.stderr.strip():
raise RuntimeError(f"'{engine}' meldet: {result.stderr.strip()}")
def dot_to_svg(dot_file: Path, svg_file: Path) -> None:
"""SVG mit der berechneten Anordnung von 'dot' erzeugen."""
graph_to_svg(dot_file, svg_file, engine="dot")
# ---------------------------------------------------------------------------
# Plausibilitaet
# ---------------------------------------------------------------------------
def check_elements(graph: Graph, elements: list[Element]) -> list[str]:
"""
Merkmal-Stueckzahlen gegen die tatsaechlich vorhandenen Anbauteile pruefen.
Rein objektbezogen - alles, was TROs betrifft, liegt in lib/tro_flow.py.
"""
findings: list[str] = []
counted: dict[str, dict[str, int]] = {}
for node_id, node in graph.nodes.items():
for kind, ids in node.devices.items():
counted.setdefault(node_id, {})[kind] = len(ids)
for element in elements:
if not (element.is_transport or element.is_circle):
continue
node_ids = (
[element.teile_id]
if element.is_transport
else [f"{element.teile_id}-{LANE_LEFT}", f"{element.teile_id}-{LANE_RIGHT}"]
)
for label, keys in (
("Separator", ("Anzahl_Separator", "Anzahl der Separatoren")),
("Scanner", ("Anzahl_Scanner", "Anzahl der Scanner")),
):
expected = _as_float(_merkmal(element.merkmale, *keys))
if expected is None:
continue
actual = sum(counted.get(n, {}).get(label, 0) for n in node_ids)
if int(expected) != actual:
findings.append(
f"{element.kind} {element.teile_id} '{element.name}': Merkmal "
f"'{keys[0] if keys[0] in element.merkmale else keys[1]}' = "
f"{int(expected)}, tatsaechlich {actual} {label}"
)
findings.sort()
return findings
# ---------------------------------------------------------------------------
# Dokumentation (Markdown)
# ---------------------------------------------------------------------------
GENERATED_MARKER = "<!-- erzeugt von lib/material_flow.py -->"
def render_doc(
graph: Graph,
findings: list[str],
csv_file: Path,
dot_file: Path,
svg_file: Path | None,
) -> str:
"""Dokumentation des Materialflusses (nur Objekte, keine TROs)."""
out: list[str] = [GENERATED_MARKER]
add = out.append
add(f"# Materialfluss - {csv_file.name}")
add("")
add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von "
f"`lib/material_flow.py` ")
add(f"**Quelle:** `{csv_file}`")
add("")
add("> Materialfluss der mechanischen Objekte. Die daraus abgeleiteten "
"Steuerungsobjekte (TROs) dokumentiert `lib/tro_flow.py --doc`.")
add("")
kinds: dict[str, int] = {}
for node in graph.nodes.values():
kinds[node.kind] = kinds.get(node.kind, 0) + 1
devices: dict[str, int] = {}
for node in graph.nodes.values():
for kind, ids in node.devices.items():
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)
open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED)
add("## 1. Kurzfassung")
add("")
add("| | |")
add("|---|---|")
add(f"| Flussknoten | {len(graph.nodes)} "
f"({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))}) |")
add(f"| Flusskanten | {flow} gerichtet, {circle} Kreisel-Umlauf, "
f"{open_dir} Richtung unbestimmt |")
add(f"| Anbauteile | "
f"{', '.join(f'{k}: {v}' for k, v in sorted(devices.items())) or '-'} |")
add(f"| Plausibilitaetshinweise | {len(findings)} |")
add("")
add(f"Graph: [`{dot_file.name}`]({dot_file.name})"
+ (f" - [`{svg_file.name}`]({svg_file.name})" if svg_file else ""))
add("")
add("Ein **Kreisel** ist ein Umlauf und wird als zwei Knoten modelliert - eine "
"Bahn je Fahrtrichtung (`-L` links, `-R` rechts), die zu einem Kreis "
"verbunden sind.")
add("")
add("## 2. Anlagenobjekte")
add("")
add("| Knoten | Art | Bezeichnung | Planquadrat | Hoehe [m] | Separatoren | Scanner |")
add("|---|---|---|---|---|---|---|")
for node_id in sorted(graph.nodes):
node = graph.nodes[node_id]
height = f"{node.height:.3f}" if node.height is not None else "-"
name = node.element.name
logical = _merkmal(node.element.merkmale, "Name")
if logical and str(logical).strip() and str(logical).strip() != name:
name = f"{name} / {str(logical).strip()}"
add(f"| `{node_id}` | {node.kind} | {name}"
f"{f' (Bahn {node.lane})' if node.lane else ''} | "
f"{node.element.plan_square or '-'} | {height} | "
f"{', '.join(sorted(node.devices.get('Separator', []))) or '-'} | "
f"{', '.join(sorted(node.devices.get('Scanner', []))) or '-'} |")
add("")
add("Bezeichnung: `Bezeichnung` aus dem CSV; wo das Merkmal `Name` abweicht, "
"steht es dahinter (die beiden Nummerierungen laufen nicht zwangslaeufig "
"gleich).")
add("")
add("Hoehe: bei Kreiseln aus dem Merkmal `Höhe in m`, bei Gefaellestrecke und "
"Strecke aus `Montagehoehe_m` - unveraendert aus dem CSV uebernommen. Fuer "
"die Richtungsableitung werden nur die Hoehen der **Nachbarn** verglichen, "
"nie die des Transportobjekts selbst.")
add("")
if graph.unassigned:
add("**Anbauteile ohne Zuordnung**")
add("")
for element in graph.unassigned:
add(f"- {element.kind} {element.teile_id} '{element.name}' "
f"(Zuordnung: `{element.assignment or '-'}`)")
add("")
add("## 3. Verbindungen")
add("")
add("| von | nach | Art |")
add("|---|---|---|")
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)} |")
add("")
add("## 4. Plausibilitaetshinweise")
add("")
if findings:
add("Abweichungen zwischen den Merkmalen des CSV-Exports und den "
"tatsaechlich vorhandenen Objekten.")
add("")
for finding in findings:
add(f"- {finding}")
else:
add("_Keine Abweichungen gefunden._")
add("")
add("## 5. Warnungen des Laufs")
add("")
if graph.warnings:
for warning in graph.warnings:
add(f"- {warning}")
else:
add("_Keine._")
add("")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# Bericht
# ---------------------------------------------------------------------------
def report(
graph: Graph,
findings: list[str],
csv_file: Path,
dot_file: Path,
svg_file: Path | None,
doc_file: Path | None,
stale_svg: Path | None = None,
) -> None:
kinds: dict[str, int] = {}
for node in graph.nodes.values():
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)
open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED)
devices = 0
for node in graph.nodes.values():
devices += sum(len(ids) for ids in node.devices.values())
print("")
print("================================================================")
print("MATERIALFLUSS")
print("================================================================")
print(f"Eingabe = {csv_file}")
print(f"Knoten = {len(graph.nodes)}"
+ (f" ({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))})"
if kinds else ""))
print(f"Kanten = {len(graph.edges)} (Fluss: {flow}, "
f"Kreisel-Umlauf: {circle}, unbestimmt: {open_dir})")
print(f"Anbauteile = {devices} an Knoten, "
f"{len(graph.unassigned)} ohne Zuordnung")
print(f"DOT = {dot_file}")
print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}")
print("================================================================")
if stale_svg is not None:
print("")
print(f"HINWEIS: {stale_svg}")
print(" stammt aus einem frueheren Lauf und passt nicht zur neuen "
"DOT-Datei.")
print(" Mit --tosvg neu erzeugen.")
if graph.unassigned:
print("")
print(f"Ohne Zuordnung ({len(graph.unassigned)}):")
for element in graph.unassigned:
print(f" - {element.kind} {element.teile_id} '{element.name}'")
if findings:
print("")
print(f"Plausibilitaetshinweise ({len(findings)}):")
for finding in findings:
print(f" ? {finding}")
if graph.warnings:
print("")
print(f"Warnungen ({len(graph.warnings)}):")
for warning in graph.warnings:
print(f" ! {warning}")
print("")
print("TRO-Liste und TRO-Flussdiagramm: bin/tro_flow.bat "
f"--file {csv_file.name} --tosvg --doc")
print("")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="material_flow",
description="Erzeugt aus einem CSV-Export der Anlagenobjekte einen "
"gerichteten Materialfluss-Graphen der mechanischen Objekte "
"(DOT, optional SVG). Die TROs ermittelt tro_flow.py.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n"
" material_flow.bat --file mubea.csv\n"
" material_flow.bat --file mubea.csv --tosvg\n"
" material_flow.bat --file mubea.csv --tosvg --doc\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
" 1 Eingabe- oder Aufruffehler\n"
" 2 SVG konnte nicht erzeugt werden (Graphviz)\n",
)
parser.add_argument(
"--file",
default="export.csv",
metavar="NAME",
help="Name der CSV-Eingabedatei in %%SKEL_DATA%% "
"(oder ein vollstaendiger Pfad). Standard: %(default)s",
)
parser.add_argument(
"--tosvg",
action="store_true",
help="Aus der erzeugten DOT-Datei zusaetzlich eine SVG-Datei erzeugen "
"(benoetigt Graphviz 'dot').",
)
parser.add_argument(
"--doc",
action="store_true",
help="Dokumentation des Materialflusses als Markdown in %%SKEL_RESULTS%% "
"erzeugen (Anlagenobjekte, Verbindungen, Pruefungen).",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
csv_file = resolve_input(args.file)
if not csv_file.is_file():
print(f"FEHLER: Eingabedatei nicht gefunden: {csv_file}", file=sys.stderr)
print(f" Suchpfad fuer Dateinamen: {env_dir('SKEL_DATA', 'data')}",
file=sys.stderr)
return 1
results = env_dir("SKEL_RESULTS", "results")
results.mkdir(parents=True, exist_ok=True)
warnings: list[str] = []
try:
elements = read_elements(csv_file, warnings)
except (OSError, ValueError, csv.Error) as exc:
print(f"FEHLER: {csv_file} konnte nicht gelesen werden: {exc}", file=sys.stderr)
return 1
if not elements:
print(f"FEHLER: {csv_file} enthaelt keine Objekte.", file=sys.stderr)
return 1
graph = build_graph(elements, csv_file.name, warnings)
if not graph.nodes:
print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte "
f"({', '.join(TRANSPORT_KINDS + CIRCLE_KINDS)}).", file=sys.stderr)
return 1
findings = check_elements(graph, elements)
stem = csv_file.stem
dot_file = results / f"{stem}_material_flow.dot"
svg_file = results / f"{stem}_material_flow.svg"
doc_target = results / f"{stem}_material_flow.md"
existing_svg = svg_file if svg_file.is_file() else None
dot_file.write_text(render_dot(graph), encoding="utf-8")
svg_error: str | None = None
if args.tosvg:
try:
dot_to_svg(dot_file, svg_file)
except RuntimeError as exc:
svg_error = str(exc)
created_svg = svg_file if args.tosvg and svg_error is None else None
doc_file = None
if args.doc:
doc_target.write_text(
render_doc(graph, findings, csv_file, dot_file, created_svg),
encoding="utf-8",
)
doc_file = doc_target
report(
graph,
findings,
csv_file,
dot_file,
created_svg,
doc_file,
stale_svg=None if args.tosvg and svg_error is None else existing_svg,
)
if svg_error:
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())