latest changes

This commit is contained in:
2026-08-20 11:38:51 +02:00
parent 8bd0739653
commit d7edc55116
23 changed files with 4769 additions and 44 deletions
+472 -10
View File
@@ -77,6 +77,35 @@ CIRCLE_KINDS = ("Kreisel",)
# Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten)
DEVICE_KINDS = ("Separator", "Scanner")
# Ein-/Ausgabestationen (BTMT Be-/Entladung) = Materialquelle/-senke, d.h. die
# Ein-/Ausgangspunkte des Materialflusses. Sie werden in sps_skel erkannt;
# dxfmakros exportiert sie derzeit NICHT (Blockmuster fehlt), die Erkennung
# greift also, sobald eine passende Zeile im CSV auftaucht - erkannt an
# TeileArt/Bezeichnung: "Beladung"/"BTMT-Beladung" -> Eingang, "Entladung"/
# "SC_Entladung"/"Abwurf" -> Ausgang.
ENTRY_KINDS = ("Beladung",) # BTMT-Beladung -> Materialeingang (Quelle)
EXIT_KINDS = ("Entladung",) # SC_Entladung -> Materialausgang (Senke)
STATION_KINDS = ENTRY_KINDS + EXIT_KINDS
# Schluesselwoerter zur Erkennung der Stationen (Kleinschreibung, Teilstring)
STATION_PATTERNS = (
("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")),
("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf")),
)
# Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen
# Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten:
# Ausschleuselement = Kreisel -> Strecke (am Streckenanfang, Material verlaesst Kreisel)
# Einschleuselement = Strecke -> Kreisel (am Streckenende, Material laeuft in Kreisel)
# Weiche = Uebergang zwischen zwei Kreiseln
# Merkmale: {"Kreisel","Strecke","Streckenende"} bzw. {"Kreisel A","Kreisel B"}.
TRANSFER_KINDS = ("Einschleus", "Ausschleus", "Weiche")
TRANSFER_PATTERNS = (
("Ausschleus", ("ausschleus",)),
("Einschleus", ("einschleus",)),
("Weiche", ("weiche",)),
)
LANE_LEFT = "L"
LANE_RIGHT = "R"
LANE_NAMES = {LANE_LEFT: "links", LANE_RIGHT: "rechts"}
@@ -104,6 +133,8 @@ 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"),
"Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"),
"Entladung": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"),
}
DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080")
@@ -152,6 +183,25 @@ class Element:
def is_device(self) -> bool:
return self.kind in DEVICE_KINDS
@property
def is_entry(self) -> bool:
"""BTMT-Beladung: Materialeingang (Quelle)."""
return self.kind in ENTRY_KINDS
@property
def is_exit(self) -> bool:
"""SC_Entladung: Materialausgang (Senke)."""
return self.kind in EXIT_KINDS
@property
def is_station(self) -> bool:
return self.kind in STATION_KINDS
@property
def is_transfer(self) -> bool:
"""Ein-/Ausschleuselement oder Weiche (liefert Richtung, kein Knoten)."""
return self.kind in TRANSFER_KINDS
@property
def height(self) -> float | None:
"""Montage-/Einbauhoehe in Meter, falls ableitbar."""
@@ -323,6 +373,25 @@ def parse_neighbours(raw: str) -> list[tuple[str, str | None]]:
return result
def _match_kind(patterns, *texts: str) -> str | None:
"""Erste Musterklasse, deren Schluesselwort in den Texten vorkommt."""
haystack = " ".join(t.lower() for t in texts if t)
for kind, keys in patterns:
if any(key in haystack for key in keys):
return kind
return None
def classify_station(*texts: str) -> str | None:
"""BTMT Be-/Entladestation an TeileArt/Bezeichnung erkennen (sonst None)."""
return _match_kind(STATION_PATTERNS, *texts)
def classify_transfer(*texts: str) -> str | None:
"""Ein-/Ausschleuselement bzw. Weiche erkennen (sonst None)."""
return _match_kind(TRANSFER_PATTERNS, *texts)
def read_elements(path: Path, warnings: list[str]) -> list[Element]:
"""CSV-Export einlesen."""
with path.open("r", encoding=CSV_ENCODING, newline="") as handle:
@@ -343,6 +412,14 @@ def read_elements(path: Path, warnings: list[str]) -> list[Element]:
teile_art = (row.get("TeileArt") or "").strip()
kind = teile_art[len(TEILEART_PREFIX):] if teile_art.startswith(TEILEART_PREFIX) else teile_art
# BTMT Be-/Entladestation als Ein-/Ausgang erkennen (sps_skel-seitig)
station_kind = classify_station(teile_art, row.get("Bezeichnung") or "")
if station_kind:
kind = station_kind
# Ein-/Ausschleuselement bzw. Weiche erkennen (liefern Richtung)
transfer_kind = classify_transfer(teile_art, row.get("Bezeichnung") or "")
if transfer_kind:
kind = transfer_kind
raw_merkmale = (row.get("Merkmale") or "").strip()
merkmale: dict = {}
@@ -395,6 +472,12 @@ def _build_nodes(elements: list[Element], warnings: list[str]) -> dict[str, Node
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 element.is_station:
nodes[element.teile_id] = Node(element.teile_id, element.kind, element)
elif element.is_transfer:
# Ein-/Ausschleuselement / Weiche liefern nur die (gerichtete)
# Verbindung, siehe _apply_transfers - kein eigener Knoten.
continue
elif not element.is_device:
warnings.append(
f"{element.describe()}: unbekannte TeileArt '{element.teile_art}' - kein Knoten erzeugt"
@@ -611,6 +694,160 @@ def _orient(
return edges
def _nearest_node(station: Element, nodes: dict[str, Node]) -> str | None:
"""Naechster Nicht-Stations-Knoten zur Station (2D-Abstand)."""
if station.x is None or station.y is None:
return None
best, best_d = None, None
for node_id, node in nodes.items():
if node.element.is_station or node.element.x is None or node.element.y is None:
continue
d = (node.element.x - station.x) ** 2 + (node.element.y - station.y) ** 2
if best_d is None or d < best_d:
best, best_d = node_id, d
return best
def _attach_stations(
elements: list[Element], nodes: dict[str, Node], warnings: list[str]
) -> list[Edge]:
"""
Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden.
Eingang (Beladung) ist Quelle: Kante Station -> Ziel.
Ausgang (Entladung) ist Senke: Kante Ziel -> Station.
Ziel kommt aus den 'Nachbarn' des Exports; fehlen sie (dxfmakros exportiert
fuer BTMT derzeit keine), wird das naechstgelegene Objekt per Abstand genommen.
"""
edges: list[Edge] = []
for element in elements:
if not element.is_station:
continue
sid = element.teile_id
if sid not in nodes:
continue
partners: list[str] = []
for base, lane in element.neighbours:
if lane and f"{base}-{lane}" in nodes:
partners.append(f"{base}-{lane}")
elif base in nodes:
partners.append(base)
else:
partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT)
if f"{base}-{L}" in nodes]
if not partners:
nearest = _nearest_node(element, nodes)
if nearest:
partners = [nearest]
warnings.append(
f"{element.describe()}: keine Nachbarn im Export - "
f"naechstes Objekt '{nearest}' per Abstand verbunden"
)
else:
warnings.append(f"{element.describe()}: kein Ziel gefunden - unverbunden")
continue
for partner in partners:
if element.is_entry:
edges.append(Edge(sid, partner, EDGE_FLOW))
else:
edges.append(Edge(partner, sid, EDGE_FLOW))
return edges
def _kreisel_lane_node(kreisel_base: str, strecke: Element, nodes: dict[str, Node]) -> str | None:
"""Die konkrete Kreisel-Bahn (…-L/…-R), an der die Strecke haengt."""
for base, lane in strecke.neighbours:
if base == kreisel_base:
node_id = f"{base}-{lane}" if lane else base
return node_id if node_id in nodes else None
for lane in (LANE_LEFT, LANE_RIGHT): # Fallback: irgendeine Bahn
if f"{kreisel_base}-{lane}" in nodes:
return f"{kreisel_base}-{lane}"
return kreisel_base if kreisel_base in nodes else None
def _apply_transfers(
elements: list[Element], by_id: dict[str, Element],
nodes: dict[str, Node], edges: list[Edge], warnings: list[str]
) -> list[Edge]:
"""
Kreisel<->Strecke-Kanten anhand der Transferelemente gerichtet setzen.
Ausschleuselement: Kreisel -> Strecke (Streckenanfang).
Einschleuselement: Strecke -> Kreisel (Streckenende).
Weiche: Kreisel<->Kreisel als Weiche markieren.
Die so definierten Kanten sind massgeblich und ersetzen die aus Hoehe/
Nachbarn abgeleitete Richtung fuer genau diese Knotenpaare.
"""
# Transfer je Strecke sammeln: aus = Einlauf (Kreisel->Strecke, Anfang),
# ein = Auslauf (Strecke->Kreisel, Ende)
per_strecke: dict[str, dict[str, str]] = {}
weiche_pairs: list[set[str]] = []
for el in elements:
if el.kind in ("Ausschleus", "Einschleus"):
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
s = str(_merkmal(el.merkmale, "Strecke") or "").strip()
if k and s:
role = "aus" if el.kind == "Ausschleus" else "ein"
per_strecke.setdefault(s, {})[role] = k
elif el.kind == "Weiche":
a = str(_merkmal(el.merkmale, "Kreisel A") or "").strip()
b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip()
if a and b:
weiche_pairs.append({a, b})
directed: dict[frozenset, tuple[str, str]] = {}
for s, roles in per_strecke.items():
if s not in nodes or nodes[s].element.is_circle:
continue
strecke_el = by_id.get(s)
aus_lane = _kreisel_lane_node(roles["aus"], strecke_el, nodes) if roles.get("aus") else None
ein_lane = _kreisel_lane_node(roles["ein"], strecke_el, nodes) if roles.get("ein") else None
# Ruecklauf-Strecke (beide Enden am selben Kreisel): auf die zwei Bahnen verteilen
if aus_lane and aus_lane == ein_lane:
base = aus_lane.split("-")[0]
lanes = [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT) if f"{base}-{L}" in nodes]
if len(lanes) == 2:
aus_lane, ein_lane = lanes
warnings.append(
f"Strecke {s}: Ein- und Ausschleusung am selben Kreisel {base} "
f"(Ruecklauf) - Bahnen {lanes[0]}/{lanes[1]} angenommen"
)
if aus_lane: # Kreisel -> Strecke
directed[frozenset({aus_lane, s})] = (aus_lane, s, "Ausschleusung")
if ein_lane: # Strecke -> Kreisel
directed[frozenset({ein_lane, s})] = (s, ein_lane, "Einschleusung")
# Fuer Strecken mit Transferelementen sind ALLEIN diese massgeblich fuer die
# Kreisel<->Strecke-Verbindung. Jede weitere Kreisel<->Strecke-Kante aus den
# rohen Nachbarn (Bounding-Box-Ueberlappung, z. B. ein VF, der zufaellig neben
# einem dritten Kreisel liegt) ist ein Falschtreffer und wird verworfen.
covered = set(per_strecke)
result: list[Edge] = []
for e in edges:
if frozenset({e.src, e.dst}) in directed:
continue # wird gerichtet neu gesetzt
ends = {e.src, e.dst}
strecke_end = ends & covered
others = ends - strecke_end
if strecke_end and len(others) == 1:
other = next(iter(others))
if other in nodes and nodes[other].kind in CIRCLE_KINDS:
warnings.append(
f"Kante {other} <-> {next(iter(strecke_end))}: Kreisel<->Strecke "
f"ohne Transferelement - als Bounding-Box-Falschtreffer verworfen"
)
continue
result.append(e)
for (src, dst, note) in directed.values():
result.append(Edge(src, dst, EDGE_FLOW, note))
# Kreisel<->Kreisel-Kanten, die eine Weiche verbinden, markieren
for e in result:
if {e.src.split("-")[0], e.dst.split("-")[0]} in weiche_pairs:
e.note = "Weiche"
return result
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)
@@ -650,6 +887,12 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
edges.append(Edge(left, right, EDGE_CIRCLE, "unbestimmt"))
edges.append(Edge(right, left, EDGE_CIRCLE, "unbestimmt"))
# Ein-/Ausgabestationen (BTMT Be-/Entladung) als Quelle/Senke anbinden
edges.extend(_attach_stations(elements, nodes, warnings))
# Kreisel<->Strecke gerichtet aus den Transferelementen (massgeblich)
edges = _apply_transfers(elements, by_id, nodes, edges, warnings)
# Sackgassen und unerreichbare Bahnen melden. Sie entstehen, wenn ein
# Kreisel nur in einer Richtung durchlaufen wird (Drehrichtung) und die
# Gegenrichtung nicht ueber aeussere Verbindungen geschlossen ist.
@@ -685,6 +928,14 @@ def _node_label(node: Node) -> str:
element = node.element
lines: list[str] = []
if element.is_station:
role = "Materialeingang" if element.is_entry else "Materialausgang"
lines.append(f"{element.name or element.kind} [{element.teile_id}]")
lines.append(f"{element.kind} - {role}")
if element.height is not None:
lines.append(f"h = {element.height:.3f} m")
return _dot_label(lines)
if node.lane:
name = _merkmal(element.merkmale, "Name") or element.name
lines.append(f"{name} [{node.node_id}]")
@@ -716,12 +967,47 @@ def _node_label(node: Node) -> str:
return _dot_label(lines)
def render_dot(graph: Graph) -> str:
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
# Millimeter. 0.25 pt/mm (1:4) - wie in tro_flow.py, damit Material- und TRO-Graph
# im selben Massstab lagerichtig sind.
COORD_SCALE = 0.25
# Die beiden Bahnen (L/R) eines Kreisels teilen sich eine Position; im
# lagerichtigen Layout leicht versetzen, damit sie nicht uebereinander liegen.
LANE_NUDGE_MM = 350.0
def _node_xy(node: "Node") -> tuple[float, float] | None:
"""Anlagenkoordinate eines Knotens (Kreisel-Bahnen L/R leicht versetzt)."""
el = node.element
if el.x is None or el.y is None:
return None
x, y = el.x, el.y
if node.lane == "L":
y += LANE_NUDGE_MM
elif node.lane == "R":
y -= LANE_NUDGE_MM
return (x, y)
def _pos_attr(node: "Node", use_coords: bool) -> str:
"""pos-Attribut fuer 'neato -n', oder leer ohne --use-cords."""
if not use_coords:
return ""
xy = _node_xy(node)
if xy is None:
return ""
return f', pos="{xy[0] * COORD_SCALE:.2f},{xy[1] * COORD_SCALE:.2f}"'
def render_dot(graph: Graph, use_coords: bool = False) -> str:
out: list[str] = []
add = out.append
add("// Automatisch erzeugt von lib/material_flow.py - nicht manuell aendern.")
add(f"// Quelle: {graph.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 Materialfluss {")
add(' graph [rankdir=LR, splines=spline, nodesep=0.25, ranksep=1.6,')
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
@@ -755,7 +1041,8 @@ def render_dot(graph: Graph) -> str:
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(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}'
f'{_pos_attr(node, use_coords)}];')
add(" { rank=same; " + " ".join(f'"{n.node_id}";' for n in lanes) + " }")
add(" }")
add("")
@@ -763,7 +1050,8 @@ def render_dot(graph: Graph) -> str:
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(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}'
f'{_pos_attr(node, use_coords)}];')
add("")
for edge in graph.edges:
@@ -774,17 +1062,30 @@ def render_dot(graph: Graph) -> str:
'arrowsize=0.9, fontcolor="#5b7fc7", '
f'tooltip="Kreisel-Umlauf {edge.note}"{label}]')
elif edge.kind == EDGE_UNRESOLVED:
weiche = ', xlabel="Weiche", fontcolor="#cc8800"' if edge.note == "Weiche" else ""
attrs = ('[color="#bf8f00", style=dashed, dir=both, '
f'tooltip="Richtung {edge.note}"]')
f'tooltip="Richtung {edge.note}"{weiche}]')
elif edge.note == "Weiche":
attrs = ('[color="#cc8800", penwidth=1.6, xlabel="Weiche", '
'fontcolor="#cc8800", tooltip="Weiche (Kreisel-Uebergang)"]')
else:
attrs = "[]"
add(f' "{edge.src}" -> "{edge.dst}" {attrs};')
add("")
legend_pos = ""
if use_coords:
xs = [xy[0] for n in graph.nodes.values() if (xy := _node_xy(n))]
ys = [xy[1] for n in graph.nodes.values() if (xy := _node_xy(n))]
if xs and ys:
lx, ly = min(xs), min(ys) - 3000.0 # etwas unterhalb der Anlage
legend_pos = (f' pos="{lx * COORD_SCALE:.2f},'
f'{ly * COORD_SCALE:.2f}",')
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=<'
add(f' legende [shape=plaintext, style="", fillcolor="none",{legend_pos} 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>'
@@ -800,6 +1101,120 @@ def render_dot(graph: Graph) -> str:
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# connect.ini (Item-/Flussebene) erzeugen
# ---------------------------------------------------------------------------
def _connect_kind(edge: Edge) -> str:
"""Kantenart fuer connect.ini."""
if edge.kind == EDGE_CIRCLE:
return "umlauf"
if edge.note == "Weiche":
return "weiche"
if edge.kind == EDGE_UNRESOLVED:
return "unbestimmt"
if edge.note in ("Einschleusung", "Ausschleusung"):
return edge.note.lower()
return "normal"
def render_connect_ini(graph: Graph, section: str = "Anlage", overrides=None) -> str:
"""
Materialfluss-Topologie als connect.ini (Item-/Flussebene).
Zwischenstand fuer die TRO-Ableitung: Knoten = Flussobjekte (Kreisel-Bahnen,
Strecke, Gefaellestrecke), Kanten = gerichteter Materialfluss. Von Hand
editierbar. Aufbau angelehnt an doc/TRO_Katalog/connect.ini.
`overrides` (aus cfg/tro_overrides.ini) wird als eigener Abschnitt
`[<Anlage>.manual]` angehaengt. Die Datei hier wird bei jedem Lauf neu
geschrieben - von Hand eingetragene Weichen waeren sonst weg. Der Abschnitt
beschreibt bewusst nur, WAS vor Ort bestaetigt wurde; er erfindet keine
Flusskanten, denn welche Kante zur Weiche gehoert, ist aus dem Layout nicht
ableitbar.
"""
out: list[str] = []
add = out.append
add("; ============================================================")
add("; connect.ini - Materialfluss-Topologie (Item-/Flussebene)")
add(f"; Automatisch erzeugt von lib/material_flow.py aus '{graph.source}'.")
add("; Zwischenstand fuer die TRO-Ableitung; von Hand editierbar.")
add(";")
add("; [<Anlage>.nodes] <Knoten> = <Art> | <Bezeichnung>")
add("; [<Anlage>.connections] cNNN = <von> -> <nach> | <JamArea> | <kind>")
add("; kind: normal | umlauf | weiche | einschleusung | ausschleusung | unbestimmt")
add("; [<Anlage>.externals] eNNN = EXTERN -> <Knoten> | <Info> (Zulauf/Beladung)")
add("; eNNN = <Knoten> -> EXTERN | <Info> (Ablauf/Entladung)")
add("; [<Anlage>.manual] vor Ort bestaetigte Angaben aus cfg/tro_overrides.ini")
add("; ============================================================")
add("")
add(f"[{section}.nodes]")
for node_id in sorted(graph.nodes):
node = graph.nodes[node_id]
el = node.element
if node.lane:
name = _merkmal(el.merkmale, "Name") or el.name
comment = f"{name} (Bahn {node.lane})"
else:
comment = el.name or el.kind
add(f"{node_id} = {node.kind} | {comment}")
add("")
add(f"[{section}.connections]")
for i, edge in enumerate(sorted(graph.edges, key=lambda e: (e.src, e.dst)), start=1):
add(f"c{i:03d} = {edge.src} -> {edge.dst} | | {_connect_kind(edge)}")
add("")
add(f"[{section}.externals]")
stations = [n for n in graph.nodes.values() if n.element.is_station]
if stations:
for j, node in enumerate(sorted(stations, key=lambda n: n.node_id), start=1):
if node.element.is_entry:
add(f"e{j:03d} = EXTERN -> {node.node_id} | {node.element.name}")
else:
add(f"e{j:03d} = {node.node_id} -> EXTERN | {node.element.name}")
else:
add("; Noch keine BTMT Be-/Entladestation im Layout - Ein-/Ausgang offen.")
add("; Kandidaten (offene Enden aus dem Materialfluss):")
incoming = {e.dst for e in graph.edges}
outgoing = {e.src for e in graph.edges}
for node_id in sorted(graph.nodes):
node = graph.nodes[node_id]
if node.element.is_station:
continue
if node_id not in incoming:
add(f"; Beladung? EXTERN -> {node_id} ({node.kind}, keine Zufuhr)")
if node_id not in outgoing:
add(f"; Entladung? {node_id} -> EXTERN ({node.kind}, kein Abfluss)")
add("")
if overrides is not None and (overrides.tros or overrides.splits
or overrides.open_points):
add(f"[{section}.manual]")
add("; Vor Ort bestaetigt, im CSV-Export nicht enthalten.")
add(f"; Quelle: {overrides.source}")
add("; Diese Zeilen werden aus der Override-Datei erzeugt - nicht hier")
add("; editieren, sonst sind sie beim naechsten Lauf wieder weg.")
for section_key, override in sorted(overrides.tros.items()):
if not override.type_name:
continue
wie = f"Separator {override.key}" if override.by_separator else override.key
add(f"{section_key} = {override.type_name} | {wie} | {override.reason}")
for spec in overrides.splits:
add(f"{spec.name} = NEU {spec.type_name} "
f"| Separator {', '.join(spec.separators)}"
+ (f" + Scanner {', '.join(spec.scanners)}" if spec.scanners else "")
+ (f" | Bahn {spec.host}" if spec.host else "")
+ f" | {spec.reason}")
for point in overrides.open_points:
add(f"{point.name} = OFFEN {point.expect or '?'} "
f"| x={point.x:.2f} y={point.y:.2f} "
f"| heute Teil von {point.belongs_to or '-'} "
f"| {point.note}")
add("")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# SVG erzeugen
# ---------------------------------------------------------------------------
@@ -851,9 +1266,13 @@ def graph_to_svg(
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()}")
# 'neato -n' gibt harmlose Warnungen auf stderr aus (z. B. sich beruehrende
# Knoten -> gerade Kanten). Nur dann scheitern, wenn wirklich keine SVG
# entstanden ist; sonst ist die Datei gueltig und die Warnung unkritisch.
if not svg_file.is_file() or svg_file.stat().st_size == 0:
raise RuntimeError(
f"'{engine}' hat keine SVG erzeugt: {result.stderr.strip() or '(keine Meldung)'}"
)
def dot_to_svg(dot_file: Path, svg_file: Path) -> None:
@@ -1164,12 +1583,28 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="Aus der erzeugten DOT-Datei zusaetzlich eine SVG-Datei erzeugen "
"(benoetigt Graphviz 'dot').",
)
parser.add_argument(
"--use-cords",
"--use-coords",
dest="use_cords",
action="store_true",
help="Jeden Knoten auf seine Anlagenkoordinate setzen und das SVG von "
f"Graphviz 'neato -n' erzeugen statt von 'dot' (lagerichtig, "
f"{COORD_SCALE} pt/mm). Nur mit --tosvg wirksam.",
)
parser.add_argument(
"--doc",
action="store_true",
help="Dokumentation des Materialflusses als Markdown in %%SKEL_RESULTS%% "
"erzeugen (Anlagenobjekte, Verbindungen, Pruefungen).",
)
parser.add_argument(
"--connect",
action="store_true",
help="Materialfluss-Topologie zusaetzlich als connect.ini (Item-/Flussebene) "
"nach %%SKEL_RESULTS%% schreiben (Knoten, gerichtete Verbindungen, "
"Ein-/Ausgang-Kandidaten) - Zwischenstand fuer die TRO-Ableitung.",
)
return parser.parse_args(argv)
@@ -1210,13 +1645,26 @@ def main(argv: list[str] | None = None) -> int:
svg_file = results / f"{stem}_material_flow.svg"
doc_target = results / f"{stem}_material_flow.md"
# --use-cords braucht an jedem Knoten eine Koordinate
if args.use_cords:
without = [n.node_id for n in graph.nodes.values() if _node_xy(n) is None]
if without:
print(f"FEHLER: --use-cords nicht moeglich, {len(without)} Knoten ohne "
f"Koordinate: {', '.join(without)}", file=sys.stderr)
print(" Ohne pos an jedem Knoten kann 'neato -n' nicht zeichnen.",
file=sys.stderr)
return 1
existing_svg = svg_file if svg_file.is_file() else None
dot_file.write_text(render_dot(graph), encoding="utf-8")
dot_file.write_text(render_dot(graph, use_coords=args.use_cords), encoding="utf-8")
svg_error: str | None = None
if args.tosvg:
try:
dot_to_svg(dot_file, svg_file)
if args.use_cords:
graph_to_svg(dot_file, svg_file, engine="neato", no_op=1)
else:
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
@@ -1229,6 +1677,20 @@ def main(argv: list[str] | None = None) -> int:
)
doc_file = doc_target
connect_file = None
if args.connect:
# Spaet importiert: tro_overrides zieht tro_catalog nach, das
# material_flow sonst nicht braucht.
from tro_overrides import load_overrides
overrides = load_overrides(csv_file.name, env_dir("SKEL_CFG", "cfg"))
connect_target = results / f"{stem}_connect.ini"
connect_target.write_text(
render_connect_ini(graph, section=stem, overrides=overrides),
encoding="utf-8",
)
connect_file = connect_target
report(
graph,
findings,