Files
sps_skel/lib/tro_flow.py
T
2026-09-04 14:57:30 +02:00

1386 lines
53 KiB
Python

# -*- coding: utf-8 -*-
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
"""
tro_flow.py - TROs aus einem CSV-Export ermitteln und ihren Fluss zeichnen.
Schwesterprogramm zu material_flow.py:
material_flow.py Materialfluss der *mechanischen Objekte*
(Kreisel, Gefaellestrecke, Strecke)
tro_flow.py Fluss der *Steuerungsobjekte* (TROs)
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
Uebergabestelle**. Ausgangspunkt ist der Separator (Vereinzeler/Sperre) und das,
was an ihm haengt (Host-Strecke, Scanner). Ableitungsregeln nach
doc/500573_Mubea/TRO_Identifikation_500573.md:
Host des Separators -> TRO-Typ
-----------------------------------------------------------------------
Gefaellestrecke, >= 2 Linien -> PinStore_Auto (ein Block je Gruppe)
Gefaellestrecke, einzelne Linie -> 1Sep
Strecke (angetrieben) -> Vario (je Streckensegment)
Kreisel-Bahn -> 1Sep
Ein Scanner am Separator aendert den Typ NICHT: in connect.ini der
Referenzanlage sind Separatoren mit Scanner ueberwiegend 1Sep (z. B.
"TRO104 = 1Sep | TRO 104 (1Sep)+Scanner"). 1Sep_SSCC ist dem Sonderfall mit
SSCC-Scanner, Endmessung und WCS-Telegramm vorbehalten (nur TRO105/TRO110) und
laesst sich aus dem mechanischen Layout nicht erkennen - es wird nur ein
Hinweis ausgegeben.
Zusaetzlich: hat ein Host 2 bzw. 3 abgehende Flusskanten, wird 1Sep zu
1Sep1Swi bzw. 1Sep2Swi aufgewertet (Weiche).
Typen, FB-Bausteine, Bauteile und Farben kommen ausschliesslich aus
lib/tro_catalog.py.
Ausgabe (alles in SKEL_RESULTS)
-------------------------------
<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
Aufruf ueber bin/tro_flow.bat bzw. bin/tro_flow.sh.
"""
from __future__ import annotations
import argparse
import csv
import math
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from material_flow import (
EDGE_CIRCLE,
EDGE_FLOW,
EDGE_UNRESOLVED,
LANE_LEFT,
LANE_RIGHT,
ROTATION_CCW,
ROTATION_CW,
UNASSIGNED,
Element,
Graph,
_as_float,
_dot_escape,
_dot_label,
_merkmal,
build_graph,
env_dir,
graph_to_svg,
read_elements,
resolve_input,
)
from tro_catalog import (
CATALOG_AS_OF,
ITEM_FB,
STYLES,
TRO_CATALOG,
TroDefinition,
get_tro,
mermaid_classdefs,
)
from tro_overrides import (
Overrides,
apply_connections,
apply_merges,
apply_overrides,
apply_splits,
load_overrides,
)
# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------
# Ab dieser Anzahl zusammenhaengender Schwerkraftlinien (Gefaellestrecke mit je
# einem Separator) wird ein Speicherblock (PinStore_Auto) angenommen.
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
# ---------------------------------------------------------------------------
@dataclass
class Tro:
"""Eine ermittelte Uebergabestelle."""
tro_id: str
type_name: str
label: str
hosts: list[str]
separators: list[str]
scanners: list[str]
reason: str
confidence: str
fb_block: str = ""
items: dict[str, int] = field(default_factory=dict)
successors: list[str] = field(default_factory=list)
predecessors: list[str] = field(default_factory=list)
colocated: list[str] = field(default_factory=list)
# Mittelpunkt der Bauteile, aus denen dieser TRO gebildet wurde
position: tuple[float, float, float] | None = None
position_from: str = ""
position_points: int = 0
@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
def describe_position(self) -> str:
"""Koordinate als Text, z. B. '1030, 4700'."""
if not self.position:
return "-"
return f"{self.position[0]:.0f}, {self.position[1]:.0f}"
@property
def connected(self) -> bool:
return bool(self.successors or self.predecessors or self.colocated)
def definition(self) -> TroDefinition | None:
"""Typdefinition aus tro_catalog (None wenn Typ unbekannt)."""
return get_tro(self.type_name)
@dataclass
class Analysis:
tros: list[Tro] = field(default_factory=list)
edges: list[tuple[str, str]] = field(default_factory=list)
# (von, nach) -> Kantenart (transfer / umlauf / weiche / unbestimmt / normal)
edge_kinds: dict[tuple[str, str], str] = field(default_factory=dict)
isolated: list[Tro] = field(default_factory=list)
unknown_types: list[str] = field(default_factory=list)
findings: list[str] = field(default_factory=list)
# Manuelle Korrekturen aus cfg/tro_overrides.ini (Typkorrekturen, offene Punkte)
overrides: Overrides = field(default_factory=Overrides)
def by_id(self, tro_id: str) -> Tro | None:
for tro in self.tros:
if tro.tro_id == tro_id:
return tro
return None
# ---------------------------------------------------------------------------
# TRO-Ermittlung
# ---------------------------------------------------------------------------
def _group_lanes(lanes: list[str], graph: Graph) -> list[list[str]]:
"""
Schwerkraftlinien zu Speicherbloecken gruppieren.
Linien, die sich einen Zu- oder Ablaufknoten teilen, gehoeren zum selben
Speicher. So entstehen bei zwei getrennten Speichern auch zwei TROs.
"""
adjacency: dict[str, set[str]] = {}
for lane in lanes:
neighbours: set[str] = set()
for edge in graph.edges:
if edge.src == lane:
neighbours.add(edge.dst)
elif edge.dst == lane:
neighbours.add(edge.src)
adjacency[lane] = neighbours
groups: list[dict] = []
for lane in sorted(lanes):
current = {"lanes": [lane], "nodes": set(adjacency[lane]) | {lane}}
for group in [g for g in groups if current["nodes"] & g["nodes"]]:
current["lanes"].extend(group["lanes"])
current["nodes"] |= group["nodes"]
groups.remove(group)
groups.append(current)
for group in groups:
group["lanes"].sort()
groups.sort(key=lambda g: g["lanes"][0])
return [group["lanes"] for group in groups]
def _switch_exits(hosts: list[str], graph: Graph) -> list[str]:
"""Abgehende Flusskanten eines TRO-Hosts (ohne Kreisel-Umlauf)."""
return sorted(
{
edge.dst
for edge in graph.edges
if edge.kind == EDGE_FLOW and edge.src in hosts and edge.dst not in hosts
}
)
def derive_tros(
graph: Graph,
elements: list[Element],
findings: list[str],
overrides: Overrides | None = None,
) -> list[Tro]:
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden."""
separators = sorted(
(e for e in elements if e.kind == "Separator"), key=lambda e: e.teile_id
)
scanners = sorted(
(e for e in elements if e.kind == "Scanner"), key=lambda e: e.teile_id
)
# Separator -> Host-Knoten (Spalte "Zuordnung")
sep_host: dict[str, str] = {}
for sep in separators:
host = sep.assignment
if host and host != UNASSIGNED and host in graph.nodes:
sep_host[sep.teile_id] = host
else:
findings.append(
f"Separator {sep.teile_id} '{sep.name}': Zuordnung '{host or '-'}' "
f"zeigt auf keinen Flussknoten - keinem TRO zugeordnet"
)
# Scanner -> Separator; "Naechster Separator" ist die funktionale Zuordnung,
# "Zuordnung" nur der Montageort.
sep_scanners: dict[str, list[str]] = {}
for scanner in scanners:
target = str(_merkmal(scanner.merkmale, "Naechster Separator") or "").strip()
if target in sep_host:
sep_scanners.setdefault(target, []).append(scanner.teile_id)
mounted = scanner.assignment
if mounted and mounted != UNASSIGNED and mounted != sep_host[target]:
findings.append(
f"Scanner {scanner.teile_id} '{scanner.name}': montiert an {mounted}, "
f"liest aber an Separator {target} auf {sep_host[target]}"
)
else:
findings.append(
f"Scanner {scanner.teile_id} '{scanner.name}': 'Naechster Separator' "
f"'{target or '-'}' unbekannt - keinem TRO zugeordnet"
)
def seps_on(hosts: list[str]) -> list[str]:
return sorted(s for s, h in sep_host.items() if h in hosts)
def scanners_for(seps: list[str]) -> list[str]:
found: list[str] = []
for sep in seps:
found.extend(sep_scanners.get(sep, []))
return sorted(found)
tros: list[Tro] = []
# 1) Schwerkraftlinien (Gefaellestrecke mit Separator) -> Speicherblock
lanes = sorted(
{h for h in sep_host.values() if graph.nodes[h].kind == "Gefaellestrecke"}
)
for group in _group_lanes(lanes, graph):
seps = seps_on(group)
if len(group) >= MIN_STORE_LANES:
tros.append(
Tro(
tro_id="",
type_name="PinStore_Auto",
label=f"Speicher ({len(group)} Schwerkraftlinien)",
hosts=group,
separators=seps,
scanners=scanners_for(seps),
reason=(
f"{len(group)} Gefaellestrecken mit je eigenem Separator, "
f"gemeinsamer Zu-/Ablauf - Muster eines Linienspeichers"
),
confidence="hoch",
)
)
else:
for lane in group:
lane_seps = seps_on([lane])
tros.append(
Tro(
tro_id="",
type_name="1Sep",
label=graph.nodes[lane].element.name,
hosts=[lane],
separators=lane_seps,
scanners=scanners_for(lane_seps),
reason="einzelne Gefaellestrecke mit Separator (kein Linienspeicher)",
confidence="mittel",
)
)
# 2) Angetriebene Strecken -> je Segment ein Vario
for node_id in sorted(n for n, node in graph.nodes.items() if node.kind == "Strecke"):
seps = seps_on([node_id])
element = graph.nodes[node_id].element
drive = element.drive_dir or "unbekannt"
tros.append(
Tro(
tro_id="",
type_name="Vario",
label=element.name,
hosts=[node_id],
separators=seps,
scanners=scanners_for(seps),
reason=f"angetriebenes Streckensegment (Antriebfahrtrichtung: {drive})",
confidence="hoch",
)
)
# 3) Separatoren auf Kreisel-Bahnen -> je Separator eine Uebergabestelle
for sep_id in sorted(
s for s, h in sep_host.items() if graph.nodes[h].kind == "Kreisel"
):
host = sep_host[sep_id]
own_scanners = sorted(sep_scanners.get(sep_id, []))
node = graph.nodes[host]
lane_name = _merkmal(node.element.merkmale, "Name") or node.element.name
tros.append(
Tro(
tro_id="",
type_name="1Sep",
label=f"Separator {sep_id} auf {lane_name} Bahn {node.lane}",
hosts=[host],
separators=[sep_id],
scanners=own_scanners,
reason=(
"Separator mit Scanner auf angetriebener Kreisel-Bahn"
if own_scanners
else "einzelner Separator auf angetriebener Kreisel-Bahn"
),
confidence="mittel",
)
)
# 4) Weichen-Aufwertung: 2 bzw. 3 abgehende Flusskanten
for tro in tros:
if tro.type_name != "1Sep":
continue
exits = _switch_exits(tro.hosts, graph)
if not 2 <= len(exits) <= 3:
continue
upgraded = "1Sep1Swi" if len(exits) == 2 else "1Sep2Swi"
findings.append(
f"{tro.label}: {len(exits)} abgehende Wege ({', '.join(exits)}) - "
f"als Weiche {upgraded} statt {tro.type_name} eingestuft"
)
tro.type_name = upgraded
tro.reason = f"{tro.reason}; {len(exits)} abgehende Wege -> Weiche"
tro.confidence = "niedrig"
# 4b) Manuell ergaenzte Uebergabestellen (cfg/tro_overrides.ini, [.split.]).
# Vor der Nummernvergabe, damit die neuen TROs in derselben Reihenfolge
# eine Nummer bekommen wie die abgeleiteten.
if overrides is not None and overrides.splits:
def _neuer_tro(spec, hosts, label):
return Tro(
tro_id="",
type_name=spec.type_name,
label=label,
hosts=hosts,
separators=list(spec.separators),
scanners=list(spec.scanners),
reason=spec.reason or "manuell ergaenzte Uebergabestelle",
confidence=spec.confidence,
)
apply_splits(tros, overrides, _neuer_tro)
# 4c) Zusammenfassungen - nach den Splits, damit auch ein frisch
# herausgeloester TRO Teil einer Zusammenfassung sein kann.
if overrides is not None and overrides.merges:
apply_merges(tros, overrides)
# 5) Stabile, laufende Nummern in Reihenfolge der Host-Knoten
tros.sort(key=lambda t: (t.hosts[0], t.separators[0] if t.separators else ""))
width = max(2, len(str(len(tros))))
for index, tro in enumerate(tros, start=1):
tro.tro_id = f"TRO{index:0{width}d}"
# 5b) Manuelle Korrekturen aus cfg/tro_overrides.ini. Sie greifen erst hier,
# weil sie ueber die TRO-ID adressiert sind, und noch vor Schritt 6, damit
# ein manuell gesetzter Typ den SSCC-Hinweis nicht mehr ausloest.
if overrides is not None:
apply_overrides(tros, overrides)
# 6) Scanner am Separator: 1Sep_SSCC laesst sich mechanisch nicht erkennen
for tro in tros:
if tro.type_name == "1Sep" and tro.scanners:
findings.append(
f"{tro.tro_id} ({tro.label}): Scanner {', '.join(tro.scanners)} am "
f"Separator - als 1Sep eingestuft. Pruefen, ob 1Sep_SSCC "
f"(SSCC-Scanner, Endmessung, WCS-Telegramm) erforderlich ist; das "
f"ist aus dem mechanischen Layout nicht erkennbar."
)
return tros
def _tro_edge_kind(edge: Edge) -> str:
"""Materialkante -> Kantenart des TRO-Flusses (fuer Einfaerbung)."""
if edge.note == "Weiche":
return "weiche"
if edge.kind == EDGE_CIRCLE:
return "umlauf"
if edge.kind == EDGE_UNRESOLVED:
return "unbestimmt"
if edge.note in ("Einschleusung", "Ausschleusung"):
return "transfer"
return "normal"
def _bahn_richtung(node) -> tuple[float, float] | None:
"""Foerderrichtung einer Kreisel-Bahn als Einheitsvektor.
Der Kreisel ist eine geschlossene Schleife: auf der einen Bahn laeuft das
Material hin, auf der anderen zurueck. Welche Richtung das ist, ergibt sich
aus der Laengsachse ("Drehung") und dem Umlaufsinn ("Drehrichtung"):
UZS (im Uhrzeigersinn) Bahn R entgegen der Achse, Bahn L mit ihr
GUZ (gegen den UZS) umgekehrt
Rueckgabe None, wenn eines der beiden Merkmale fehlt - dann bleibt es bei
der ungerichteten "gleiche Bahn"-Notiz.
"""
element = node.element
drehung = _as_float(_merkmal(element.merkmale, "Drehung"))
umlauf = element.rotation
if drehung is None or umlauf not in (ROTATION_CW, ROTATION_CCW):
return None
rad = math.radians(drehung)
achse = (math.cos(rad), math.sin(rad))
mit_achse = (node.lane == LANE_LEFT) == (umlauf == ROTATION_CW)
return achse if mit_achse else (-achse[0], -achse[1])
def link_tros(
graph: Graph, tros: list[Tro]
) -> tuple[list[tuple[str, str]], dict[tuple[str, str], str]]:
"""
TROs verknuepfen.
Der Materialfluss laeuft von Uebergabestelle zu Uebergabestelle, ggf. durch
passive Zwischenknoten (Strecken/Bahnen ohne eigenen TRO). Solche Knoten
werden daher durchlaufen ("kontrahiert"), belegte Knoten beenden den Pfad.
Jede TRO-Kante bekommt zusaetzlich die Art der *ankommenden* Materialkante
(transfer / umlauf / weiche / unbestimmt / normal).
"""
owners: dict[str, list[str]] = {}
for tro in tros:
for host in tro.hosts:
owners.setdefault(host, []).append(tro.tro_id)
forward: dict[str, list[tuple[str, str]]] = {}
for edge in graph.edges:
kind = _tro_edge_kind(edge)
forward.setdefault(edge.src, []).append((edge.dst, kind))
if edge.kind == EDGE_UNRESOLVED:
# unbestimmte Richtung: in beide Richtungen begehbar
forward.setdefault(edge.dst, []).append((edge.src, kind))
edges: set[tuple[str, str]] = set()
edge_kinds: dict[tuple[str, str], str] = {}
for tro in tros:
seen = set(tro.hosts)
queue = [(nxt, k) for host in tro.hosts for (nxt, k) in forward.get(host, [])]
while queue:
node, arriving = queue.pop()
if node in seen:
continue
seen.add(node)
if node in owners:
for other in owners[node]:
if other != tro.tro_id:
pair = (tro.tro_id, other)
edges.add(pair)
edge_kinds.setdefault(pair, arriving)
continue
queue.extend(forward.get(node, []))
# Mehrere TROs auf derselben Kreisel-Bahn liegen HINTEREINANDER, nicht
# nebeneinander: das Material laeuft die Bahn in Foerderrichtung ab. Sie
# werden daher der Reihe nach verkettet statt nur als "gleiche Bahn"
# markiert - sonst fehlt z. B. die Kante von der einen Sperre zur naechsten.
verkettet: set[str] = set()
for host, ids in owners.items():
if len(ids) < 2:
continue
node = graph.nodes.get(host)
if node is None or node.kind != "Kreisel" or not node.lane:
continue
richtung = _bahn_richtung(node)
if richtung is None:
continue
auf_bahn = [t for t in tros if t.tro_id in ids and t.position]
if len(auf_bahn) < 2:
continue
# Projektion auf die Foerderrichtung: kleinster Wert kommt zuerst
auf_bahn.sort(key=lambda t: t.position[0] * richtung[0]
+ t.position[1] * richtung[1])
for vorher, nachher in zip(auf_bahn, auf_bahn[1:]):
pair = (vorher.tro_id, nachher.tro_id)
edges.add(pair)
edge_kinds.setdefault(pair, "bahn")
verkettet.update(pair)
# gleicher Host = physisch dieselbe Strecke/Bahn. Verkettete Bahnen sind
# oben schon gerichtet verbunden und brauchen die ungerichtete Notiz nicht.
for host, ids in owners.items():
for tro_id in ids:
tro = next(t for t in tros if t.tro_id == tro_id)
if tro_id in verkettet:
continue
tro.colocated = sorted(
set(tro.colocated) | {o for o in ids if o != tro_id and o not in verkettet}
)
for tro in tros:
tro.successors = sorted(dst for src, dst in edges if src == tro.tro_id)
tro.predecessors = sorted(src for src, dst in edges if dst == tro.tro_id)
return sorted(edges), edge_kinds
def _centroid(
points: list[tuple[float, float, float]]
) -> tuple[float, float, float] | None:
"""Mittelpunkt mehrerer Positionen."""
if not points:
return None
count = len(points)
return (
sum(p[0] for p in points) / count,
sum(p[1] for p in points) / count,
sum(p[2] for p in points) / count,
)
def locate_tros(tros: list[Tro], elements: list[Element], graph: Graph) -> None:
"""
Jedem TRO eine Koordinate geben.
Die Koordinate ist der **Mittelpunkt der Bauteile**, aus denen der TRO
gebildet wurde - bei einem 1Sep1Swi also der Mittelwert aus Separator und
Weiche, beim Linienspeicher der Mittelwert aller Linien-Separatoren.
Hat ein TRO keine Bauteile mit Position (z. B. ein Vario ohne Separator),
wird auf die Position seiner Host-Objekte zurueckgegriffen; das steht dann
in `position_from`.
"""
by_id = {element.teile_id: element for element in elements}
for tro in tros:
# 1) Bauteile des TRO (Separatoren, Scanner)
parts = [
by_id[i].position
for i in list(tro.separators) + list(tro.scanners)
if i in by_id and by_id[i].position
]
source = "Bauteile"
# 2) Ersatzweise die Host-Objekte
if not parts:
parts = [
graph.nodes[h].element.position
for h in tro.hosts
if h in graph.nodes and graph.nodes[h].element.position
]
source = "Host-Objekte"
tro.position = _centroid(parts)
tro.position_points = len(parts)
tro.position_from = source if tro.position else "keine Position"
def analyse_tros(
graph: Graph, elements: list[Element], overrides: Overrides | None = None
) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen."""
analysis = Analysis()
analysis.overrides = overrides if overrides is not None else Overrides()
analysis.tros = derive_tros(graph, elements, analysis.findings, analysis.overrides)
# Verorten vor dem Verknuepfen: die Reihenfolge mehrerer TROs auf einer
# Kreisel-Bahn wird ueber ihre Koordinate bestimmt.
locate_tros(analysis.tros, elements, graph)
analysis.edges, analysis.edge_kinds = link_tros(graph, analysis.tros)
# Vor Ort aufgenommene Topologie schlaegt die abgeleitete. Die Differenz
# wird protokolliert, damit sichtbar bleibt, was die Ableitung falsch hatte.
if analysis.overrides.connections:
abgeleitet = set(analysis.edges)
analysis.edges, analysis.edge_kinds = apply_connections(
analysis.tros, analysis.overrides
)
gesetzt = set(analysis.edges)
for src, dst in sorted(abgeleitet - gesetzt):
analysis.findings.append(
f"abgeleitete Verbindung {src} -> {dst} entfaellt - steht nicht "
f"in der gesetzten Topologie"
)
for src, dst in sorted(gesetzt - abgeleitet):
analysis.findings.append(
f"Verbindung {src} -> {dst} nur gesetzt, aus dem Layout nicht "
f"ableitbar"
)
for tro in analysis.tros:
definition = tro.definition()
if definition:
tro.fb_block = definition.get_fb_block()
tro.items = definition.get_items()
else:
analysis.unknown_types.append(tro.type_name)
analysis.unknown_types = sorted(set(analysis.unknown_types))
analysis.isolated = [tro for tro in analysis.tros if not tro.connected]
analysis.findings.sort()
return analysis
# ---------------------------------------------------------------------------
# DOT erzeugen - Flussdiagramm der TROs
# ---------------------------------------------------------------------------
def _tro_label(tro: Tro) -> str:
lines = [f"{tro.tro_id} ({tro.type_name})", tro.label]
if tro.fb_block:
lines.append(tro.fb_block)
if tro.items:
lines.append(", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())))
if tro.separators:
seps = ", ".join(tro.separators)
if len(tro.separators) > 6:
seps = f"{tro.separators[0]}{tro.separators[-1]} ({len(tro.separators)})"
lines.append(f"Sep: {seps}")
if tro.scanners:
lines.append(f"Scan: {', '.join(tro.scanners)}")
if tro.position:
lines.append(f"x/y: {tro.describe_position()}")
return _dot_label(lines)
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)
# Kantenstil je Art (an material_flow.py angelehnt: Umlauf hellblau gestrichelt,
# Weiche orange). "transfer"/"normal" bleiben der durchgezogene Standardfluss.
TRO_EDGE_STYLE = {
"umlauf": 'color="#8ea9db", style=dashed, tooltip="Kreisel-Umlauf"',
"weiche": 'color="#cc8800", penwidth=1.8, xlabel="Weiche", fontcolor="#cc8800", '
'tooltip="Weiche (Kreisel-Uebergang)"',
"unbestimmt": 'color="#bf8f00", style=dashed, dir=both, tooltip="Richtung unbestimmt"',
"transfer": 'tooltip="Uebergabe (Ein-/Ausschleusung)"',
}
def _tro_edge_attrs(kind: str | None) -> str:
return TRO_EDGE_STYLE.get(kind or "normal", "")
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 {")
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];")
add("")
for tro in analysis.tros:
definition = tro.definition()
attrs = definition.get_dot_attrs() if definition else {
"fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000"
}
rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items())
# Anlagenkoordinate mitgeben. Ohne --use-cords ignoriert dot das pos,
# die Angabe bleibt aber als Information im DOT stehen.
pos = ""
if tro.position:
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("")
for src, dst in analysis.edges:
add(f' "{src}" -> "{dst}" [{_tro_edge_attrs(analysis.edge_kinds.get((src, dst)))}];')
seen: set[frozenset] = set()
for tro in analysis.tros:
for other in tro.colocated:
key = frozenset((tro.tro_id, other))
if key in seen:
continue
seen.add(key)
add(f' "{tro.tro_id}" -> "{other}" [dir=none, style=dotted, '
f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];')
add("")
# 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
)
legend_label = ('<<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
+ rows
+ '<tr><td align="left"><font color="#2f5597">durchgezogen</font></td>'
'<td align="left">Uebergabe / Ein-Ausschleusung</td></tr>'
'<tr><td align="left"><font color="#8ea9db">blau gestrichelt</font></td>'
'<td align="left">Kreisel-Umlauf</td></tr>'
'<tr><td align="left"><font color="#cc8800">orange</font></td>'
'<td align="left">Weiche (Kreisel-Uebergang)</td></tr>'
'<tr><td align="left">grau 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"
# ---------------------------------------------------------------------------
# Fehlerdatei
# ---------------------------------------------------------------------------
def render_errors(analysis: Analysis, csv_file: Path) -> str:
out = [GENERATED_MARKER, f"# TRO-Fehler - {csv_file.name}", ""]
out.append(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} ")
out.append(f"**Quelle:** `{csv_file}` ")
out.append(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})")
out.append("")
if analysis.isolated:
out.append("## Nicht verbundene TROs")
out.append("")
out.append(
"Jeder TRO muss mit mindestens einem anderen TRO verbunden sein. "
"Fuer die folgenden Uebergabestellen liess sich im Materialfluss kein "
"Nachbar-TRO finden - weder ueber die Spalte `Nachbarn` noch ueber einen "
"gemeinsamen Host."
)
out.append("")
out.append("| TRO | Typ | Host | Separatoren | Bezeichnung |")
out.append("|---|---|---|---|---|")
for tro in analysis.isolated:
out.append(
f"| `{tro.tro_id}` | `{tro.type_name}` | "
f"{', '.join(f'`{h}`' for h in tro.hosts)} | "
f"{', '.join(tro.separators) or '-'} | {tro.label} |"
)
out.append("")
out.append("**Moegliche Ursachen**")
out.append("")
out.append("- Spalte `Nachbarn` des Host-Objekts ist leer oder unvollstaendig")
out.append("- `Nachbarn` verweist auf eine unbekannte `TeileId`")
out.append("- Die Bahnangabe (`-L` / `-R`) eines Kreisels fehlt auf beiden Seiten")
out.append("")
if analysis.unknown_types:
out.append("## Typen ohne Katalogeintrag")
out.append("")
out.append(
"Die folgenden Typen wurden ermittelt, stehen aber nicht in "
"`TRO_CATALOG` in `lib/tro_catalog.py`:"
)
out.append("")
for name in analysis.unknown_types:
holders = [t.tro_id for t in analysis.tros if t.type_name == name]
out.append(f"- `{name}` ({', '.join(holders)})")
out.append("")
return "\n".join(out) + "\n"
def write_error_file(analysis: Analysis, csv_file: Path, path: Path) -> bool:
"""Fehlerdatei schreiben; veraltete eigene Datei bei fehlerfreiem Lauf entfernen."""
if analysis.isolated or analysis.unknown_types:
path.write_text(render_errors(analysis, csv_file), encoding="utf-8")
return True
if path.is_file():
try:
first = path.read_text(encoding="utf-8").splitlines()[:1]
except OSError:
first = []
if first and first[0].strip() == GENERATED_MARKER:
path.unlink()
return False
# ---------------------------------------------------------------------------
# Dokumentation (Markdown)
# ---------------------------------------------------------------------------
def _mermaid_text(text: str) -> str:
return text.replace('"', "'").replace("[", "(").replace("]", ")")
def _tro_mermaid(analysis: Analysis) -> list[str]:
out = ["```mermaid", "flowchart LR"]
out.extend(f" {line}" for line in mermaid_classdefs())
for tro in analysis.tros:
definition = tro.definition()
group = definition.get_group() if definition else "ext"
label = f"{tro.tro_id}<br/>{tro.type_name}<br/>{_mermaid_text(tro.label)}"
out.append(f' {tro.tro_id}["{label}"]:::{group}')
for src, dst in analysis.edges:
out.append(f" {src} --> {dst}")
seen: set[frozenset] = set()
for tro in analysis.tros:
for other in tro.colocated:
key = frozenset((tro.tro_id, other))
if key in seen:
continue
seen.add(key)
out.append(f" {tro.tro_id} -. gleiche Bahn .- {other}")
out.append("```")
return out
def render_doc(
analysis: Analysis,
graph: Graph,
csv_file: Path,
dot_file: Path,
svg_file: Path | None,
error_file: Path | None,
) -> str:
out: list[str] = [GENERATED_MARKER]
add = out.append
add(f"# TRO-Liste und TRO-Fluss - {csv_file.name}")
add("")
add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von "
f"`lib/tro_flow.py` ")
add(f"**Quelle:** `{csv_file}` ")
add(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})")
add("")
add("> Automatisch aus dem CSV-Export erzeugt. Die TRO-Typen sind aus dem "
"mechanischen Layout **abgeleitet** - ohne E-Planung (I/O-Liste) und ohne "
"`FB_Main`-Aufrufstellen gibt es noch keine verbindliche TRO-Nummerierung. "
"Die IDs unten sind laufende Nummern dieses Laufs.")
add("")
types_used: dict[str, int] = {}
for tro in analysis.tros:
types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1
add("## 1. Kurzfassung")
add("")
add("| | |")
add("|---|---|")
add(f"| Ermittelte TROs | {len(analysis.tros)} |")
add(f"| TRO-Verbindungen | {len(analysis.edges)} |")
add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |")
add(f"| Hinweise | {len(analysis.findings)} |")
if analysis.overrides.applied:
add(f"| Manuelle Typkorrekturen | {len(analysis.overrides.applied)} |")
if analysis.overrides.open_points:
add(f"| Offene Punkte (noch kein TRO) | "
f"{len(analysis.overrides.open_points)} |")
add("")
add("| Typ | FB-Baustein | Anzahl hier | Bauteile je TRO | Farbgruppe |")
add("|---|---|---:|---|---|")
for name, count in sorted(types_used.items()):
definition = get_tro(name)
add(f"| `{name}` | "
f"{f'`{definition.get_fb_block()}`' if definition else '_kein Katalogeintrag_'} "
f"| {count} | {definition.describe_items() if definition else '-'} "
f"| {definition.get_group() if definition else '-'} |")
add("")
add("## 2. TRO-Flussdiagramm")
add("")
out.extend(_tro_mermaid(analysis))
add("")
add(f"Gerendert: [`{dot_file.name}`]({dot_file.name})"
+ (f" - [`{svg_file.name}`]({svg_file.name})" if svg_file else ""))
add("")
add("## 3. TRO-Liste")
add("")
add("| TRO | Typ | FB-Baustein | Bauteile laut Typ | X [mm] | Y [mm] | Z [mm] "
"| Host-Knoten | Separatoren | Scanner | Begruendung | Vertrauen |")
add("|---|---|---|---|---:|---:|---:|---|---|---|---|---|")
for tro in analysis.tros:
hosts = ", ".join(f"`{h}`" for h in tro.hosts)
if len(tro.hosts) > 4:
hosts = f"`{tro.hosts[0]}` … `{tro.hosts[-1]}` ({len(tro.hosts)} Knoten)"
seps = ", ".join(tro.separators) or "-"
if len(tro.separators) > 6:
seps = f"{tro.separators[0]}{tro.separators[-1]} ({len(tro.separators)})"
items = ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())) or "-"
cx = f"{tro.x:.0f}" if tro.x is not None else "-"
cy = f"{tro.y:.0f}" if tro.y is not None else "-"
cz = f"{tro.z:.0f}" if tro.z is not None else "-"
add(f"| `{tro.tro_id}` | `{tro.type_name}` | "
f"{f'`{tro.fb_block}`' if tro.fb_block else '-'} | {items} "
f"| {cx} | {cy} | {cz} | {hosts} | {seps} | "
f"{', '.join(tro.scanners) or '-'} | {tro.reason} | {tro.confidence} |")
add("")
add("**X/Y/Z** ist der Mittelpunkt der Bauteile, aus denen der TRO gebildet "
"wurde (Separatoren und Scanner) - beim Linienspeicher also der "
"Mittelwert aller Linien-Separatoren. Koordinaten in mm im "
"Anlagen-Koordinatensystem des CSV-Exports.")
add("")
add("| TRO | Koordinate aus | Anzahl Punkte |")
add("|---|---|---:|")
for tro in analysis.tros:
add(f"| `{tro.tro_id}` | {tro.position_from} | {tro.position_points} |")
add("")
add("## 4. TRO-Verbindungen")
add("")
if analysis.edges:
add("Richtung des Materialflusses zwischen den Uebergabestellen. "
"Passive Zwischenknoten ohne eigenen TRO sind zusammengefasst.")
add("")
add("| von | nach | Typ von -> Typ nach |")
add("|---|---|---|")
for src, dst in analysis.edges:
a, b = analysis.by_id(src), analysis.by_id(dst)
add(f"| `{src}` | `{dst}` | "
f"`{a.type_name if a else '?'}` -> `{b.type_name if b else '?'}` |")
else:
add("_Keine TRO-Verbindungen ermittelt._")
add("")
if analysis.isolated:
add("### Nicht verbundene TROs")
add("")
for tro in analysis.isolated:
add(f"- `{tro.tro_id}` (`{tro.type_name}`) - {tro.label}")
add("")
if error_file:
add(f"Details: [`{error_file.name}`]({error_file.name})")
add("")
add("## 5. Hinweise")
add("")
if analysis.findings:
for finding in analysis.findings:
add(f"- {finding}")
else:
add("_Keine._")
add("")
add("## 6. Manuelle Korrekturen und offene Punkte")
add("")
overrides = analysis.overrides
if not overrides and not overrides.warnings:
add("_Keine._ Alles unten Stehende ist rein aus dem Layout abgeleitet.")
add("")
else:
add(f"Quelle: `{overrides.source}`" if overrides.source
else "_Keine Override-Datei gefunden._")
add("")
if overrides.applied:
add("### 6.1 Angewandte Typkorrekturen")
add("")
add("Diese Typen stammen **nicht** aus dem Layout, sondern aus der "
"Begehung bzw. dem Review - im Layout sind sie nicht erkennbar.")
add("")
add("| Korrektur | Begruendung |")
add("|---|---|")
for entry in overrides.applied:
head, _, tail = entry.partition(" (")
add(f"| `{head}` | {tail[:-1] if tail.endswith(')') else tail} |")
add("")
if overrides.open_points:
add("### 6.2 Offene Punkte - noch kein TRO")
add("")
add("Im Review gemeldete Stellen. Die TRO-Struktur ist hier "
"**bewusst unveraendert**; die Punkte sind nur markiert (auch in der "
"annotierten Zeichnung, Layer `TRO_OPENPOINT`), damit sie in BricsCAD "
"geprueft werden koennen.")
add("")
add("| Punkt | X [mm] | Y [mm] | Z [mm] | erwartet | heute Teil von | Anmerkung |")
add("|---|---:|---:|---:|---|---|---|")
for point in overrides.open_points:
z_text = f"{point.z:.0f}" if point.z is not None else "-"
expect = f"`{point.expect}`" if point.expect else "-"
owner = f"`{point.belongs_to}`" if point.belongs_to else "-"
add(f"| {point.name} | {point.x:.0f} | {point.y:.0f} | {z_text} "
f"| {expect} | {owner} | {point.note or '-'} |")
add("")
if overrides.warnings:
add("### 6.3 Probleme mit der Override-Datei")
add("")
for warning in overrides.warnings:
add(f"- {warning}")
add("")
add("## 7. Ableitungsregeln und Typdefinition")
add("")
add("Ein TRO ist die Steuerungslogik **einer Uebergabestelle**. Ausgangspunkt "
"ist der Separator und das, was an ihm haengt (Host-Objekt, Scanner).")
add("")
add("| Host des Separators | TRO-Typ |")
add("|---|---|")
add(f"| Gefaellestrecke, >= {MIN_STORE_LANES} Linien mit gemeinsamem Zu-/Ablauf "
"| `PinStore_Auto` (ein Block je Liniengruppe) |")
add("| Gefaellestrecke, einzelne Linie | `1Sep` |")
add("| Strecke (angetrieben) | `Vario` (je Streckensegment) |")
add("| Kreisel-Bahn | `1Sep` |")
add("| zusaetzlich: 2 bzw. 3 abgehende Wege | Aufwertung zu `1Sep1Swi` / `1Sep2Swi` |")
add("")
add("Ein **Scanner** am Separator aendert den Typ nicht. In der Referenzanlage "
"sind Separatoren mit Scanner ueberwiegend `1Sep`. `1Sep_SSCC` ist dem "
"Sonderfall mit SSCC-Scanner, Endmessung und WCS-Telegramm vorbehalten und "
"aus einem mechanischen Layout nicht erkennbar - betroffene Stellen stehen "
"als Hinweis in Abschnitt 5.")
add("")
add("Was aus dem CSV **nicht** ableitbar ist: TRO-Nummerierung, Sensor- und "
"Aktor-Tags, JamAreas, Zielsteuerung und Timing. Das kommt aus der "
"E-Planung (I/O-Liste) bzw. aus `FB_Main`.")
add("")
add(f"Typen, FB-Bausteine, Bauteile und Farben stammen vollstaendig aus "
f"`TRO_CATALOG` in `lib/tro_catalog.py` (Stand {CATALOG_AS_OF}):")
add("")
add("| # | Typ | FB-Baustein | Bauteile je TRO | Farbgruppe |")
add("|---:|---|---|---|---|")
for index, definition in enumerate(TRO_CATALOG, start=1):
add(f"| {index} | `{definition.get_name()}` | `{definition.get_fb_block()}` "
f"| {definition.describe_items()} | {definition.get_group()} |")
add("")
add("| Bauteil | Sub-FB |")
add("|---|---|")
for item in sorted({i for d in TRO_CATALOG for i in d.get_item_names()}):
fb = ITEM_FB.get(item, "")
add(f"| {item} | {f'`{fb}`' if fb else '_kein eigener FB_'} |")
add("")
add("| Farbgruppe | Bedeutung | Fuellfarbe |")
add("|---|---|---|")
for style in STYLES:
add(f"| {style.group} | {style.label} | `{style.fill}` |")
add("")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# Bericht
# ---------------------------------------------------------------------------
def report(
analysis: Analysis,
csv_file: Path,
dot_file: Path,
svg_file: Path | None,
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:
types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1
print("")
print("================================================================")
print("TRO-FLUSS")
print("================================================================")
print(f"Eingabe = {csv_file}")
print(f"TROs = {len(analysis.tros)}"
+ (f" ({', '.join(f'{k}: {v}' for k, v in sorted(types_used.items()))})"
if types_used else ""))
print(f"TRO-Verbindungen = {len(analysis.edges)}, "
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)'}")
print(f"Fehlerdatei = {error_file if error_file else '- (keine Fehler)'}")
print("================================================================")
if analysis.isolated:
print("")
print(f"FEHLER: {len(analysis.isolated)} TRO(s) ohne Verbindung zu einem "
f"anderen TRO:")
for tro in analysis.isolated:
print(f" x {tro.tro_id} ({tro.type_name}) - {tro.label}")
if analysis.unknown_types:
print("")
print("FEHLER: TRO-Typen ohne Eintrag in TRO_CATALOG: "
+ ", ".join(analysis.unknown_types))
overrides = analysis.overrides
if overrides.applied:
print("")
print(f"Manuelle Typkorrekturen ({len(overrides.applied)}) aus "
f"{overrides.source}:")
for entry in overrides.applied:
print(f" * {entry}")
if overrides.open_points:
print("")
print(f"Offene Punkte ({len(overrides.open_points)}) - nur markiert, "
f"TRO-Struktur unveraendert:")
for point in overrides.open_points:
expect = f" erwartet {point.expect}" if point.expect else ""
owner = f", heute Teil von {point.belongs_to}" if point.belongs_to else ""
print(f" o {point.name} bei {point.describe_position()}{expect}{owner}")
if overrides.warnings:
print("")
print(f"Probleme mit {overrides.source} ({len(overrides.warnings)}):")
for warning in overrides.warnings:
print(f" ! {warning}")
if analysis.findings:
print("")
print(f"Hinweise ({len(analysis.findings)}):")
for finding in analysis.findings:
print(f" ? {finding}")
if warnings:
print("")
print(f"Warnungen aus dem Materialfluss ({len(warnings)}):")
for warning in warnings:
print(f" ! {warning}")
print("")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="tro_flow",
description="Ermittelt aus einem CSV-Export der Anlagenobjekte die "
"benoetigten TROs und zeichnet ihren Fluss (DOT, optional SVG).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n"
" 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"
" 1 Eingabe- oder Aufruffehler\n"
" 2 SVG konnte nicht erzeugt werden (Graphviz)\n"
" 3 TRO-Fehler gefunden, Fehlerdatei geschrieben\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 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",
help="Dokumentation der TROs als Markdown in %%SKEL_RESULTS%% erzeugen "
"(TRO-Liste, Verbindungen, Typdefinition).",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
csv_file = resolve_input(args.file)
if csv_file.suffix.lower() != ".csv":
print(f"FEHLER: --file erwartet eine CSV-Datei, keine '{csv_file.suffix}'-Datei: "
f"{csv_file}", file=sys.stderr)
return 1
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.", file=sys.stderr)
return 1
overrides = load_overrides(csv_file.name, env_dir("SKEL_CFG", "cfg"))
analysis = analyse_tros(graph, elements, overrides)
if not analysis.tros:
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
f"ableitbar.", file=sys.stderr)
return 1
stem = csv_file.stem
dot_file = results / f"{stem}_tro_flow.dot"
svg_file = results / f"{stem}_tro_flow.svg"
doc_target = results / f"{stem}_tro_doc.md"
error_target = results / f"{stem}_tro_errors.md"
# 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:
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
doc_file = None
if args.doc:
doc_target.write_text(
render_doc(analysis, graph, csv_file, dot_file, created_svg, error_file),
encoding="utf-8",
)
doc_file = doc_target
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)
return 2
if has_errors:
print(f"FEHLER: TRO-Pruefung fehlgeschlagen - siehe {error_target}",
file=sys.stderr)
return 3
return 0
if __name__ == "__main__":
sys.exit(main())