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
+110 -25
View File
@@ -13,6 +13,10 @@ Schwesterprogramm zu material_flow.py:
Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das
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
---------------
Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer
@@ -42,8 +46,8 @@ lib/tro_catalog.py.
Ausgabe (alles in SKEL_RESULTS)
-------------------------------
<name>_tro_flow.dot immer
<name>_tro_flow.svg mit --tosvg (Graphviz)
<name>_tro_flow.dot immer (mit --use-cords lagerichtige pos-Attribute)
<name>_tro_flow.svg mit --tosvg (dot, mit --use-cords neato -n)
<name>_tro_doc.md mit --doc
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
@@ -73,8 +77,8 @@ from material_flow import (
_dot_label,
_merkmal,
build_graph,
dot_to_svg,
env_dir,
graph_to_svg,
read_elements,
resolve_input,
)
@@ -98,6 +102,14 @@ MIN_STORE_LANES = 2
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
@@ -526,17 +538,47 @@ def _tro_label(tro: Tro) -> str:
return _dot_label(lines)
def render_dot(analysis: Analysis, source: str) -> str:
"""Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle."""
def missing_positions(analysis: Analysis) -> list[str]:
"""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] = []
add = out.append
add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.")
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(" 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)}"];')
if use_coords:
# Bei -n bestimmen die pos-Attribute die Lage; rankdir/ranksep entfallen.
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(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,'
" arrowsize=0.8];")
@@ -548,11 +590,13 @@ def render_dot(analysis: Analysis, source: str) -> str:
"fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000"
}
rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items())
# Anlagenkoordinate mitgeben: dot ignoriert pos, "neato -n" zeichnet
# damit den Graphen lagerichtig (Millimeter -> Punkt, 1:100).
# Anlagenkoordinate mitgeben. Ohne --use-cords ignoriert dot das pos,
# die Angabe bleibt aber als Information im DOT stehen.
pos = ""
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("")
@@ -570,22 +614,33 @@ def render_dot(analysis: Analysis, source: str) -> str:
f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];')
add("")
# Legende: eine Zeile je Farbgruppe, plus die Kantenarten
add(" subgraph cluster_legende {")
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
# Legende: eine Zeile je Farbgruppe, plus die Kantenarten.
# Bei -n braucht auch sie ein pos, sonst landet sie auf 0,0 mitten in der
# Anlage - daher unterhalb links vom Anlagenrand.
rows = "".join(
f'<tr><td bgcolor="{s.fill}" align="left"> {s.group} </td>'
f'<td align="left">{s.label}</td></tr>'
for s in STYLES
)
add(' legende [shape=plaintext, style="", fillcolor="none", label=<'
'<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
+ rows
+ '<tr><td align="left">durchgezogen</td><td align="left">Materialfluss</td></tr>'
'<tr><td align="left">gepunktet</td><td align="left">gleiche Bahn / Strecke</td></tr>'
"</table>>];")
add(" }")
legend_label = ('<<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
+ rows
+ '<tr><td align="left">durchgezogen</td>'
'<td align="left">Materialfluss</td></tr>'
'<tr><td align="left">gepunktet</td>'
'<td align="left">gleiche Bahn / Strecke</td></tr>'
"</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("}")
return "\n".join(out) + "\n"
@@ -879,6 +934,7 @@ def report(
error_file: Path | None,
doc_file: Path | None,
warnings: list[str],
engine: str = "dot",
) -> None:
types_used: dict[str, int] = {}
for tro in analysis.tros:
@@ -896,6 +952,10 @@ def report(
f"nicht verbunden: {len(analysis.isolated)}")
print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), "
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"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
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 --tosvg\n"
" tro_flow.bat --file mubea.csv --tosvg --doc\n"
" tro_flow.bat --file mubea.csv --tosvg --use-cords\n"
"\n"
"Exit-Codes:\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 "
"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(
"--doc",
action="store_true",
@@ -1013,15 +1083,29 @@ def main(argv: list[str] | None = None) -> int:
doc_target = results / f"{stem}_tro_doc.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)
error_file = error_target if has_errors else None
engine = "neato" if args.use_cords else "dot"
svg_error: str | None = None
if args.tosvg:
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:
svg_error = str(exc)
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
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:
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)