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>
This commit is contained in:
2026-07-30 13:24:14 +02:00
parent 19259a0251
commit 035c86ced9
2 changed files with 149 additions and 37 deletions
+39 -12
View File
@@ -692,26 +692,53 @@ def find_dot() -> str | None:
return shutil.which("dot") return shutil.which("dot")
def dot_to_svg(dot_file: Path, svg_file: Path) -> None: def find_engine(engine: str) -> str | None:
"""SVG aus der .dot-Datei erzeugen (Graphviz).""" """Layout-Programm von Graphviz suchen ('dot', 'neato', ...)."""
executable = find_dot() 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: if not executable:
raise RuntimeError( raise RuntimeError(
"Graphviz 'dot' nicht gefunden. Bitte Graphviz installieren " f"Graphviz '{engine}' nicht gefunden. Bitte Graphviz installieren "
"(https://graphviz.org/download/) oder GRAPHVIZ_DOT auf die " f"(https://graphviz.org/download/)"
"dot-Programmdatei setzen." + (" oder GRAPHVIZ_DOT auf die dot-Programmdatei setzen."
if engine == "dot" else ".")
) )
result = subprocess.run( command = [executable]
[executable, "-Tsvg", str(dot_file), "-o", str(svg_file)], if no_op:
capture_output=True, command.append(f"-n{no_op}")
text=True, command += ["-Tsvg", str(dot_file), "-o", str(svg_file)]
)
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError( raise RuntimeError(
f"'dot' ist mit Code {result.returncode} fehlgeschlagen:\n" f"'{engine}' ist mit Code {result.returncode} fehlgeschlagen:\n"
f"{(result.stderr or result.stdout).strip()}" 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")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+110 -25
View File
@@ -13,6 +13,10 @@ Schwesterprogramm zu material_flow.py:
Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das
Flussdiagramm der TROs, nicht das der Einzelobjekte. Flussdiagramm der TROs, nicht das der Einzelobjekte.
Zusaetzlich --use-cords: jeder TRO traegt die Koordinate seiner Bauteile, und
das SVG wird mit "neato -n" lagerichtig gezeichnet statt von "dot" angeordnet.
Der Graph sieht dann aus wie das Anlagenlayout.
Was ein TRO ist Was ein TRO ist
--------------- ---------------
Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer
@@ -42,8 +46,8 @@ lib/tro_catalog.py.
Ausgabe (alles in SKEL_RESULTS) Ausgabe (alles in SKEL_RESULTS)
------------------------------- -------------------------------
<name>_tro_flow.dot immer <name>_tro_flow.dot immer (mit --use-cords lagerichtige pos-Attribute)
<name>_tro_flow.svg mit --tosvg (Graphviz) <name>_tro_flow.svg mit --tosvg (dot, mit --use-cords neato -n)
<name>_tro_doc.md mit --doc <name>_tro_doc.md mit --doc
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden <name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
@@ -73,8 +77,8 @@ from material_flow import (
_dot_label, _dot_label,
_merkmal, _merkmal,
build_graph, build_graph,
dot_to_svg,
env_dir, env_dir,
graph_to_svg,
read_elements, read_elements,
resolve_input, resolve_input,
) )
@@ -98,6 +102,14 @@ MIN_STORE_LANES = 2
GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->" GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->"
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
# Millimeter. 0.25 pt/mm entspricht 1:4 - bei ~11 x 20 m Anlage ergibt das eine
# Zeichnung von etwa 2800 x 5000 pt, in der sich die TRO-Kaesten nicht ueberdecken.
COORD_SCALE = 0.25
# Abstand der Legende zum Anlagenrand in mm
LEGEND_MARGIN = 3000
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Datenmodell # Datenmodell
@@ -526,17 +538,47 @@ def _tro_label(tro: Tro) -> str:
return _dot_label(lines) return _dot_label(lines)
def render_dot(analysis: Analysis, source: str) -> str: def missing_positions(analysis: Analysis) -> list[str]:
"""Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle.""" """TROs ohne Koordinate - mit ihnen ist kein lagerichtiges Layout moeglich."""
return [t.tro_id for t in analysis.tros if not t.position]
def _legend_pos(analysis: Analysis) -> tuple[float, float]:
"""Legende links unterhalb der Anlage platzieren (mm)."""
xs = [t.position[0] for t in analysis.tros if t.position]
ys = [t.position[1] for t in analysis.tros if t.position]
if not xs:
return (0.0, 0.0)
return (min(xs) - LEGEND_MARGIN, min(ys) - LEGEND_MARGIN)
def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str:
"""
Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle.
use_coords=False Anordnung berechnet 'dot' (Fluss von links nach rechts)
use_coords=True jeder Knoten wird auf seine Anlagenkoordinate gesetzt und
mit "neato -n" lagerichtig gezeichnet
"""
out: list[str] = [] out: list[str] = []
add = out.append add = out.append
add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.") add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.")
add(f"// Quelle: {source}") add(f"// Quelle: {source}")
if use_coords:
add(f"// Lagerichtig: Knoten auf Anlagenkoordinate, {COORD_SCALE} pt/mm.")
add("// Rendern mit: neato -n -Tsvg <datei>.dot -o <datei>.svg")
add("digraph TROFluss {") add("digraph TROFluss {")
add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,") if use_coords:
add(' fontname="Segoe UI", fontsize=11, labelloc="t",') # Bei -n bestimmen die pos-Attribute die Lage; rankdir/ranksep entfallen.
add(f' label="TRO-Fluss - {_dot_escape(source)}"];') add(" graph [splines=true, outputorder=edgesfirst,")
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
add(f' label="TRO-Fluss lagerichtig ({COORD_SCALE} pt/mm) - '
f'{_dot_escape(source)}"];')
else:
add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,")
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
add(f' label="TRO-Fluss - {_dot_escape(source)}"];')
add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];') add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];')
add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,' add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,'
" arrowsize=0.8];") " arrowsize=0.8];")
@@ -548,11 +590,13 @@ def render_dot(analysis: Analysis, source: str) -> str:
"fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000" "fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000"
} }
rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items()) rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items())
# Anlagenkoordinate mitgeben: dot ignoriert pos, "neato -n" zeichnet # Anlagenkoordinate mitgeben. Ohne --use-cords ignoriert dot das pos,
# damit den Graphen lagerichtig (Millimeter -> Punkt, 1:100). # die Angabe bleibt aber als Information im DOT stehen.
pos = "" pos = ""
if tro.position: if tro.position:
pos = f', pos="{tro.position[0] / 100:.2f},{tro.position[1] / 100:.2f}"' scale = COORD_SCALE if use_coords else 0.01
pos = (f', pos="{tro.position[0] * scale:.2f},'
f'{tro.position[1] * scale:.2f}"')
add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];') add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];')
add("") add("")
@@ -570,22 +614,33 @@ def render_dot(analysis: Analysis, source: str) -> str:
f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];') f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];')
add("") add("")
# Legende: eine Zeile je Farbgruppe, plus die Kantenarten # Legende: eine Zeile je Farbgruppe, plus die Kantenarten.
add(" subgraph cluster_legende {") # Bei -n braucht auch sie ein pos, sonst landet sie auf 0,0 mitten in der
add(' label="Legende"; style="rounded"; color="#a6a6a6";') # Anlage - daher unterhalb links vom Anlagenrand.
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
rows = "".join( rows = "".join(
f'<tr><td bgcolor="{s.fill}" align="left"> {s.group} </td>' f'<tr><td bgcolor="{s.fill}" align="left"> {s.group} </td>'
f'<td align="left">{s.label}</td></tr>' f'<td align="left">{s.label}</td></tr>'
for s in STYLES for s in STYLES
) )
add(' legende [shape=plaintext, style="", fillcolor="none", label=<' legend_label = ('<<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
'<table border="0" cellborder="1" cellspacing="0" cellpadding="3">' + rows
+ rows + '<tr><td align="left">durchgezogen</td>'
+ '<tr><td align="left">durchgezogen</td><td align="left">Materialfluss</td></tr>' '<td align="left">Materialfluss</td></tr>'
'<tr><td align="left">gepunktet</td><td align="left">gleiche Bahn / Strecke</td></tr>' '<tr><td align="left">gepunktet</td>'
"</table>>];") '<td align="left">gleiche Bahn / Strecke</td></tr>'
add(" }") "</table>>")
if use_coords:
lx, ly = _legend_pos(analysis)
add(f' legende [shape=plaintext, style="", fillcolor="none", '
f'label={legend_label}, pos="{lx * COORD_SCALE:.2f},'
f'{ly * COORD_SCALE:.2f}"];')
else:
add(" subgraph cluster_legende {")
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
add(f' legende [shape=plaintext, style="", fillcolor="none", '
f'label={legend_label}];')
add(" }")
add("}") add("}")
return "\n".join(out) + "\n" return "\n".join(out) + "\n"
@@ -879,6 +934,7 @@ def report(
error_file: Path | None, error_file: Path | None,
doc_file: Path | None, doc_file: Path | None,
warnings: list[str], warnings: list[str],
engine: str = "dot",
) -> None: ) -> None:
types_used: dict[str, int] = {} types_used: dict[str, int] = {}
for tro in analysis.tros: for tro in analysis.tros:
@@ -896,6 +952,10 @@ def report(
f"nicht verbunden: {len(analysis.isolated)}") f"nicht verbunden: {len(analysis.isolated)}")
print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), " print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), "
f"{len(TRO_CATALOG)} Typen") f"{len(TRO_CATALOG)} Typen")
print(f"Layout = {engine}"
+ (" (lagerichtig, neato -n auf Anlagenkoordinaten, "
f"{COORD_SCALE} pt/mm)" if engine == "neato"
else " (berechnete Anordnung; lagerichtig mit --use-cords)"))
print(f"DOT = {dot_file}") print(f"DOT = {dot_file}")
print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}") print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}") print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}")
@@ -943,6 +1003,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" tro_flow.bat --file mubea.csv\n" " tro_flow.bat --file mubea.csv\n"
" tro_flow.bat --file mubea.csv --tosvg\n" " tro_flow.bat --file mubea.csv --tosvg\n"
" tro_flow.bat --file mubea.csv --tosvg --doc\n" " tro_flow.bat --file mubea.csv --tosvg --doc\n"
" tro_flow.bat --file mubea.csv --tosvg --use-cords\n"
"\n" "\n"
"Exit-Codes:\n" "Exit-Codes:\n"
" 0 ok\n" " 0 ok\n"
@@ -963,6 +1024,15 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="Aus der erzeugten DOT-Datei zusaetzlich das TRO-Flussdiagramm als " help="Aus der erzeugten DOT-Datei zusaetzlich das TRO-Flussdiagramm als "
"SVG erzeugen (benoetigt Graphviz 'dot').", "SVG erzeugen (benoetigt Graphviz 'dot').",
) )
parser.add_argument(
"--use-cords",
"--use-coords",
dest="use_cords",
action="store_true",
help="Lagerichtig zeichnen: jeder TRO wird auf seine Anlagenkoordinate "
"gesetzt und das SVG von Graphviz 'neato -n' erzeugt statt von "
f"'dot'. Massstab {COORD_SCALE} pt/mm. Nur mit --tosvg wirksam.",
)
parser.add_argument( parser.add_argument(
"--doc", "--doc",
action="store_true", action="store_true",
@@ -1013,15 +1083,29 @@ def main(argv: list[str] | None = None) -> int:
doc_target = results / f"{stem}_tro_doc.md" doc_target = results / f"{stem}_tro_doc.md"
error_target = results / f"{stem}_tro_errors.md" error_target = results / f"{stem}_tro_errors.md"
dot_file.write_text(render_dot(analysis, csv_file.name), encoding="utf-8") # Ohne Koordinaten ist kein lagerichtiges Layout moeglich
without = missing_positions(analysis)
if args.use_cords and without:
print(f"FEHLER: --use-cords nicht moeglich, {len(without)} TRO(s) ohne "
f"Koordinate: {', '.join(without)}", file=sys.stderr)
print(" Ohne pos an jedem Knoten kann 'neato -n' nicht zeichnen.",
file=sys.stderr)
return 1
dot_file.write_text(
render_dot(analysis, csv_file.name, use_coords=args.use_cords),
encoding="utf-8",
)
has_errors = write_error_file(analysis, csv_file, error_target) has_errors = write_error_file(analysis, csv_file, error_target)
error_file = error_target if has_errors else None error_file = error_target if has_errors else None
engine = "neato" if args.use_cords else "dot"
svg_error: str | None = None svg_error: str | None = None
if args.tosvg: if args.tosvg:
try: try:
dot_to_svg(dot_file, svg_file) graph_to_svg(dot_file, svg_file, engine=engine,
no_op=1 if args.use_cords else 0)
except RuntimeError as exc: except RuntimeError as exc:
svg_error = str(exc) svg_error = str(exc)
created_svg = svg_file if args.tosvg and svg_error is None else None created_svg = svg_file if args.tosvg and svg_error is None else None
@@ -1034,7 +1118,8 @@ def main(argv: list[str] | None = None) -> int:
) )
doc_file = doc_target doc_file = doc_target
report(analysis, csv_file, dot_file, created_svg, error_file, doc_file, warnings) report(analysis, csv_file, dot_file, created_svg, error_file, doc_file,
warnings, engine=engine)
if svg_error: if svg_error:
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr) print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)