# -*- coding: utf-8 -*- # Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" # ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. # Der Interpreter wird von bin/material_flow.bat bzw. .sh gesetzt. """ material_flow.py - Materialfluss-Graph aus einem CSV-Export erzeugen. Liest einen CSV-Export der Anlagenobjekte (ILS 2.0) aus dem data-Ordner und erzeugt daraus einen gerichteten Graphen des Materialflusses. Knotenmodell ------------ * Gefaellestrecke / Strecke (Foerderer) -> je ein Knoten * Kreisel -> zwei Knoten (-L / -R); die Richtung zwischen ihnen kommt aus dem Merkmal "Drehrichtung": UZS = rechts nach links, GUZ = links nach rechts * Separator / Scanner -> kein eigener Knoten; sie werden ueber "Zuordnung" am Label des uebergeordneten Knotens angezeigt Kantenmodell ------------ Die Spalte "Nachbarn" liefert die Verbindungen (symmetrisch, ohne Richtung). Die Flussrichtung wird aus Hoehe und Antriebsrichtung abgeleitet: * Gefaellestrecke -> bergab: vom hoechsten Nachbarn weg * Strecke, Antriebfahrtrichtung -> "Auf": bergauf, zum hoechsten Nachbarn hin "Ab": bergab, vom hoechsten Nachbarn weg Verbindungen, deren Richtung sich so nicht bestimmen laesst (z. B. zwischen zwei Kreiseln auf gleicher Hoehe), werden als beidseitige Kante gezeichnet und im Bericht als Warnung ausgegeben. Abgrenzung ---------- Dieses Programm behandelt ausschliesslich den Materialfluss der *mechanischen Objekte*. Die Ableitung der Steuerungsobjekte (TROs) und ihr Flussdiagramm liegen im Schwesterprogramm lib/tro_flow.py, das die hier definierten Bausteine (read_elements, build_graph) weiterverwendet. Ausgabe (alles in SKEL_RESULTS) ------------------------------- _material_flow.dot immer _material_flow.svg mit --tosvg (Graphviz) _material_flow.md mit --doc Aufruf ueber bin/material_flow.bat bzw. bin/material_flow.sh. """ from __future__ import annotations import argparse import csv import json import os import re import shutil import subprocess import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path # --------------------------------------------------------------------------- # Konstanten # --------------------------------------------------------------------------- CSV_DELIMITER = ";" CSV_ENCODING = "utf-8-sig" TEILEART_PREFIX = "ILS 2.0 " # Dekodierung der CSV-Spalten K1-K4 (Anschluss-Koordinatensysteme eines Blocks): # 12-Zeichen-Base64-Strings mit je drei 24-Bit-Fixed-Point-Werten (x, y, z), # nur Position ohne Rotation. Kodierung siehe csv:trans-encode in # dxfmakros/Lisp/export.lsp (Faktor 10, gleiches Base64-Alphabet wie der # Insertpoint). Rueckgabe in mm im selben Koordinatensystem wie 'Position'. KX_B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" KX_B64_INDEX = {c: i for i, c in enumerate(KX_B64_CHARS)} KX_TRANS_FAKTOR = 10.0 # Objektarten, die je einen Materialfluss-Knoten bilden TRANSPORT_KINDS = ("Gefaellestrecke", "Strecke") # Objektarten, die als Kreis aus zwei Bahnen (links/rechts) modelliert werden CIRCLE_KINDS = ("Kreisel",) # Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten) DEVICE_KINDS = ("Separator", "Scanner") # 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",)), ) # Objektarten des feinen Bahnzusammenhang-Graphen (--track, siehe doc/track.md): # Separator plus die drei Transferarten bilden dort je einen eigenen Knoten - # anders als im normalen Materialfluss-Graphen, wo sie nur Kanten/Zuordnungen # liefern (siehe TRANSPORT_KINDS/CIRCLE_KINDS oben). TRACK_KINDS = ("Separator",) + TRANSFER_KINDS LANE_LEFT = "L" LANE_RIGHT = "R" LANE_NAMES = {LANE_LEFT: "links", LANE_RIGHT: "rechts"} UNASSIGNED = "nicht zugeordnet" # Drehrichtung eines Kreisels (Merkmal "Drehrichtung"). Sie bestimmt, in welche # Richtung das Material innerhalb des Kreisels zwischen den beiden Bahnen laeuft: # UZS im Uhrzeigersinn -> von rechts nach links (R -> L) # GUZ gegen den Uhrzeigersinn -> von links nach rechts (L -> R) ROTATION_CW = "UZS" ROTATION_CCW = "GUZ" ROTATION_FLOW = {ROTATION_CW: "rechts nach links", ROTATION_CCW: "links nach rechts"} # "0009-L" -> base 0009, lane L ; "0034" -> base 0034, lane None NEIGHBOUR_RE = re.compile(r"^(?P[^\s-]+)(?:-(?P[LR]))?$") # Kantenarten EDGE_FLOW = "flow" EDGE_CIRCLE = "circle" EDGE_UNRESOLVED = "unresolved" # Darstellung je Objektart NODE_STYLES = { "Kreisel": dict(fillcolor="#2f5597", fontcolor="#ffffff", color="#1f3864"), "Gefaellestrecke": dict(fillcolor="#fbe5d6", fontcolor="#833c00", color="#c55a11"), "Strecke": dict(fillcolor="#e2f0d9", fontcolor="#375623", color="#548235"), "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") # Ab dieser Anzahl paralleler Gefaellestrecken zwischen denselben zwei # Kreisel-Bahnen (gleicher Zulauf, gleicher Ablauf, gleiche Steigung) werden # sie im Materialfluss-Diagramm zu einem Block zusammengefasst - sonst wird # eine grosse Linienbank (Speicher) unlesbar breit. MIN_GEFAELLE_GROUP = 2 # --------------------------------------------------------------------------- # Datenmodell # --------------------------------------------------------------------------- @dataclass class Element: """Eine Zeile des CSV-Exports.""" number: str kind: str teile_art: str teile_id: str name: str plan_square: str neighbours: list[tuple[str, str | None]] merkmale: dict row: int position: tuple[float, float, float] | None = None bbox: tuple[float, float, float] | None = None # Anschluss-Koordinatensysteme K1..K4 aus den gleichnamigen CSV-Spalten, # als (label, (x, y)) in mm; leere Spalten fehlen. Nur fuer --show-kx. kpoints: list[tuple[str, tuple[float, float]]] = field(default_factory=list) @property def x(self) -> float | None: return self.position[0] if self.position else None @property def y(self) -> float | None: return self.position[1] if self.position else None @property def z(self) -> float | None: return self.position[2] if self.position else None @property def bbox_x(self) -> float | None: """Boundingbox-Ausdehnung in X (mm), aus der Spalte 'Boundingbox'.""" return self.bbox[0] if self.bbox else None @property def bbox_y(self) -> float | None: """Boundingbox-Ausdehnung in Y (mm).""" return self.bbox[1] if self.bbox else None @property def is_transport(self) -> bool: return self.kind in TRANSPORT_KINDS @property def is_circle(self) -> bool: return self.kind in CIRCLE_KINDS @property def is_device(self) -> bool: return self.kind in DEVICE_KINDS @property def 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.""" if self.is_circle: return _as_float(_merkmal(self.merkmale, "Höhe in m", "Hoehe in m")) return _as_float(_merkmal(self.merkmale, "Montagehoehe_m", "Montagehöhe_m")) @property def drive_dir(self) -> str: return str(_merkmal(self.merkmale, "Antriebfahrtrichtung") or "").strip() @property def rotation(self) -> str: """Drehrichtung eines Kreisels: UZS (im Uhrzeigersinn) oder GUZ.""" return str(_merkmal(self.merkmale, "Drehrichtung") or "").strip().upper() @property def height_from(self) -> float | None: """Hoehe am Anfang des Objekts in mm (Merkmal Hoehe_Von_mm).""" return _as_float(_merkmal(self.merkmale, "Hoehe_Von_mm", "Höhe_Von_mm")) @property def height_to(self) -> float | None: """Hoehe am Ende des Objekts in mm (Merkmal Hoehe_Bis_mm).""" return _as_float(_merkmal(self.merkmale, "Hoehe_Bis_mm", "Höhe_Bis_mm")) @property def rise(self) -> float | None: """ Hoehenaenderung in Foerderrichtung in mm. Positiv = das Objekt foerdert nach oben, negativ = nach unten. Die Werte koennen absolut sein (Gefaellestrecke: 1941 -> 1466) oder relativ zum Objekt (Strecke: 0 -> 474); nur das Vorzeichen wird ausgewertet. """ start, end = self.height_from, self.height_to if start is None or end is None: return None return end - start @property def assignment(self) -> str: return str(_merkmal(self.merkmale, "Zuordnung") or "").strip() def describe(self) -> str: return f"Zeile {self.row} ({self.kind} {self.teile_id} '{self.name}')" @dataclass class Node: """Ein Knoten des Materialfluss-Graphen.""" node_id: str kind: str element: Element lane: str | None = None devices: dict[str, list[str]] = field(default_factory=dict) @property def height(self) -> float | None: return self.element.height @dataclass class Edge: src: str dst: str kind: str note: str = "" @dataclass class Graph: nodes: dict[str, Node] = field(default_factory=dict) edges: list[Edge] = field(default_factory=list) warnings: list[str] = field(default_factory=list) unassigned: list[Element] = field(default_factory=list) source: str = "" @dataclass class TrackNode: """Ein Knoten des feinen Bahnzusammenhang-Graphen (--track, doc/track.md). element ist None fuer die beiden Hilfsknotenarten: einen Bahn-Platzhalter (placeholder=True - eine Kreisel-Bahn oder Strecke, an der sich kein konkretes Bahnelement auflösen liess, siehe build_track_graph) oder ein generisches Eingang/Ausgang-Symbol (SYMBOL_ENTRY/SYMBOL_EXIT) an einem offenen Bahnende ohne BTMT-Station. """ node_id: str kind: str element: Element | None = None placeholder: bool = False note: str = "" @dataclass class TrackEdge: src: str dst: str note: str = "" @dataclass class TrackGraph: nodes: dict[str, TrackNode] = field(default_factory=dict) edges: list[TrackEdge] = field(default_factory=list) source: str = "" # --------------------------------------------------------------------------- # Hilfsfunktionen # --------------------------------------------------------------------------- def _merkmal(merkmale: dict, *keys: str): """Merkmal ueber mehrere Schreibweisen hinweg lesen.""" for key in keys: if key in merkmale: return merkmale[key] return None def _as_float(value) -> float | None: """Zahl tolerant lesen ('1.94' und '1,94'). Listen wie '2.298,1' -> None.""" if value is None: return None text = str(value).strip() if not text: return None if text.count(",") == 1 and "." not in text: text = text.replace(",", ".") try: return float(text) except ValueError: return None def _dot_escape(text: str) -> str: return text.replace("\\", "\\\\").replace('"', '\\"') def _dot_label(lines: list[str]) -> str: return "\\n".join(_dot_escape(line) for line in lines if line) # --------------------------------------------------------------------------- # Pfade / Umgebung # --------------------------------------------------------------------------- def project_root() -> Path: env = os.environ.get("SPS_SKEL") if env: return Path(env) return Path(__file__).resolve().parent.parent def env_dir(var: str, fallback: str) -> Path: env = os.environ.get(var) return Path(env) if env else project_root() / fallback def resolve_input(name: str) -> Path: """Dateinamen gegen SKEL_DATA aufloesen; Pfadangaben bleiben unveraendert.""" candidate = Path(name) if candidate.is_absolute() or candidate.parent != Path("."): return candidate return env_dir("SKEL_DATA", "data") / name # --------------------------------------------------------------------------- # CSV einlesen # --------------------------------------------------------------------------- def decode_kpoint(raw: str) -> tuple[float, float] | None: """ K1-K4-Base64-String -> (x, y) in mm, oder None bei leer/ungueltig. Die Spalten K1..K4 enthalten je 12 Zeichen (drei 24-Bit-Werte x, y, z); fuer die Draufsicht interessiert nur die xy-Lage. Kodierung siehe csv:trans-encode in dxfmakros/Lisp/export.lsp (vgl. KX_* oben). """ text = (raw or "").strip() if len(text) < 8: # mindestens x und y (2 * 4 Zeichen) return None values: list[int] = [] for start in range(0, len(text) - 3, 4): word = 0 for char in text[start:start + 4]: if char not in KX_B64_INDEX: return None word = (word << 6) | KX_B64_INDEX[char] if word >= 8388608: # Zweierkomplement (24 Bit) word -= 16777216 values.append(word) if len(values) < 2: return None return (values[0] / KX_TRANS_FAKTOR, values[1] / KX_TRANS_FAKTOR) def parse_position(raw: str) -> tuple[float, float, float] | None: """'9510.00, -4882.95, 1942.50' -> (9510.0, -4882.95, 1942.5)""" parts = [p.strip() for p in (raw or "").split(",")] if len(parts) != 3: return None values = [_as_float(p) for p in parts] if any(v is None for v in values): return None return (values[0], values[1], values[2]) def parse_neighbours(raw: str) -> list[tuple[str, str | None]]: """'0009-L, 0002-R' -> [('0009', 'L'), ('0002', 'R')]""" result: list[tuple[str, str | None]] = [] for token in (raw or "").split(","): token = token.strip() if not token: continue match = NEIGHBOUR_RE.match(token) if not match: continue result.append((match.group("base"), match.group("lane"))) return result def _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: reader = csv.DictReader(handle, delimiter=CSV_DELIMITER) if not reader.fieldnames or "TeileId" not in reader.fieldnames: raise ValueError( f"Unerwartete Kopfzeile in {path.name}: 'TeileId' nicht gefunden. " f"Gefunden: {reader.fieldnames}" ) elements: list[Element] = [] seen: dict[str, Element] = {} for offset, row in enumerate(reader, start=2): teile_id = (row.get("TeileId") or "").strip() if not teile_id: continue teile_art = (row.get("TeileArt") or "").strip() kind = teile_art[len(TEILEART_PREFIX):] if teile_art.startswith(TEILEART_PREFIX) else teile_art # 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 = {} if raw_merkmale: try: parsed = json.loads(raw_merkmale) if isinstance(parsed, dict): merkmale = parsed else: warnings.append(f"Zeile {offset}: Merkmale ist kein Objekt - ignoriert") except json.JSONDecodeError as exc: warnings.append(f"Zeile {offset}: Merkmale nicht lesbar ({exc.msg}) - ignoriert") element = Element( number=(row.get("Elementnummer") or "").strip(), kind=kind, teile_art=teile_art, teile_id=teile_id, name=(row.get("Bezeichnung") or "").strip(), plan_square=(row.get("Planquadrat") or "").strip(), neighbours=parse_neighbours(row.get("Nachbarn") or ""), merkmale=merkmale, row=offset, position=parse_position(row.get("Position") or ""), bbox=parse_position(row.get("Boundingbox") or ""), kpoints=[ (label, xy) for label in ("K1", "K2", "K3", "K4") if (xy := decode_kpoint(row.get(label) or "")) is not None ], ) if teile_id in seen: warnings.append( f"{element.describe()}: TeileId doppelt (bereits in Zeile {seen[teile_id].row}) - ignoriert" ) continue seen[teile_id] = element elements.append(element) return elements # --------------------------------------------------------------------------- # Graph aufbauen # --------------------------------------------------------------------------- def _build_nodes(elements: list[Element], warnings: list[str]) -> dict[str, Node]: nodes: dict[str, Node] = {} for element in elements: if element.is_transport: nodes[element.teile_id] = Node(element.teile_id, element.kind, element) elif element.is_circle: for lane in (LANE_LEFT, LANE_RIGHT): node_id = f"{element.teile_id}-{lane}" nodes[node_id] = Node(node_id, element.kind, element, lane=lane) elif 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" ) return nodes def _attach_devices( elements: list[Element], nodes: dict[str, Node], warnings: list[str] ) -> list[Element]: """Separatoren/Scanner ueber 'Zuordnung' an ihren Knoten haengen.""" unassigned: list[Element] = [] for element in elements: if not element.is_device: continue target = element.assignment if not target or target == UNASSIGNED: unassigned.append(element) continue if target not in nodes: warnings.append( f"{element.describe()}: Zuordnung '{target}' zeigt auf keinen Knoten - nicht dargestellt" ) unassigned.append(element) continue nodes[target].devices.setdefault(element.kind, []).append(element.teile_id) return unassigned def _collect_links( elements: list[Element], by_id: dict[str, Element], warnings: list[str] ) -> dict[frozenset, dict[str, set[str]]]: """ Verbindungen aus der Spalte 'Nachbarn' sammeln. Ergebnis: {frozenset({idA, idB}): {idA: {Bahnen}, idB: {Bahnen}}} Die Bahn (-L/-R) eines Kreisels steht immer in der Zeile des *Partners*, daher werden beide Blickrichtungen zusammengefuehrt. """ observed: dict[frozenset, dict[str, set[str]]] = {} for element in elements: if not element.neighbours: continue if element.is_device: warnings.append( f"{element.describe()}: Nachbarn an einem Anbauteil werden nicht ausgewertet" ) continue if not (element.is_transport or element.is_circle): continue for base, lane in element.neighbours: if base == element.teile_id: warnings.append(f"{element.describe()}: Nachbar verweist auf sich selbst - ignoriert") continue partner = by_id.get(base) if partner is None: warnings.append(f"{element.describe()}: unbekannter Nachbar '{base}' - ignoriert") continue if partner.is_device or not (partner.is_transport or partner.is_circle): warnings.append( f"{element.describe()}: Nachbar '{base}' ({partner.kind}) ist kein Flussobjekt - ignoriert" ) continue key = frozenset((element.teile_id, base)) entry = observed.setdefault(key, {element.teile_id: set(), base: set()}) if lane: entry[base].add(lane) return observed def _endpoints( base: str, lanes: set[str], by_id: dict[str, Element], warnings: list[str], partner: str ) -> list[str]: """Basis-Id auf konkrete Knoten aufloesen (Kreisel: eine Bahn je Knoten).""" element = by_id[base] if not element.is_circle: return [base] if lanes: return [f"{base}-{lane}" for lane in sorted(lanes)] warnings.append( f"Verbindung {base} <-> {partner}: keine Bahn (-L/-R) angegeben - " f"beide Bahnen des Kreisels {base} werden verbunden" ) return [f"{base}-{LANE_LEFT}", f"{base}-{LANE_RIGHT}"] def drive_uphill(element: Element) -> tuple[bool | None, str]: """ Foerdert das Objekt nach oben? True = Fluss laeuft zum hoechsten Nachbarn hin (bergauf) False = Fluss laeuft vom hoechsten Nachbarn weg (bergab) None = nicht bestimmbar Bewertet wird in dieser Reihenfolge: 1. Hoehe_Von_mm / Hoehe_Bis_mm - die Hoehenaenderung des Objekts selbst 2. Antriebfahrtrichtung "Auf" (nach oben) bzw. "Ab" (nach unten) 3. Gefaellestrecke ohne Angaben laeuft per Definition bergab Der zweite Rueckgabewert nennt die verwendete Quelle fuer Bericht und Zeichnung. """ rise = element.rise if rise is not None and rise != 0: direction = "Auf" if rise > 0 else "Ab" return rise > 0, ( f"Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis " f"{element.height_to:.0f} mm ({direction}, {rise:+.0f} mm)" ) drive = element.drive_dir.lower() if drive.startswith("auf"): return True, f"Antriebfahrtrichtung '{element.drive_dir}' (nach oben)" if drive.startswith("ab"): return False, f"Antriebfahrtrichtung '{element.drive_dir}' (nach unten)" if element.kind == "Gefaellestrecke": return False, "Gefaellestrecke ohne Hoehenangabe - laeuft bergab" return None, ( f"weder Hoehe_Von/Bis noch Antriebfahrtrichtung verwertbar " f"(Antrieb '{element.drive_dir}', Hoehen " f"{element.height_from}/{element.height_to})" ) def _flow_uphill(element: Element, warnings: list[str]) -> bool | None: """Wie drive_uphill, meldet aber unbestimmbare Faelle als Warnung.""" uphill, reason = drive_uphill(element) if uphill is None: warnings.append(f"{element.describe()}: {reason} - Richtung nicht bestimmbar") return uphill def _orient( pairs: list[tuple[str, str]], nodes: dict[str, Node], warnings: list[str] ) -> list[Edge]: """Flussrichtung je Verbindung aus Hoehe und Antriebsrichtung ableiten.""" incident: dict[str, list[int]] = {} for index, (left, right) in enumerate(pairs): incident.setdefault(left, []).append(index) incident.setdefault(right, []).append(index) # Vorschlaege je Verbindung, gesammelt aus der Sicht der Transportobjekte proposals: dict[int, set[tuple[str, str]]] = {index: set() for index in range(len(pairs))} for node_id, node in sorted(nodes.items()): if node.kind not in TRANSPORT_KINDS: continue indices = incident.get(node_id, []) if not indices: warnings.append( f"{node.element.describe()}: keine Nachbarn - Knoten bleibt unverbunden" ) continue partners = {} for index in indices: left, right = pairs[index] partners[index] = right if left == node_id else left heights = {index: nodes[partner].height for index, partner in partners.items()} missing = [partners[i] for i, h in heights.items() if h is None] if missing: warnings.append( f"{node.element.describe()}: Hoehe von {', '.join(sorted(missing))} unbekannt - " f"Richtung nicht bestimmbar" ) continue uphill = _flow_uphill(node.element, warnings) if uphill is None: continue highest = max(heights.values()) if highest == min(heights.values()): warnings.append( f"{node.element.describe()}: alle Nachbarn auf gleicher Hoehe " f"({highest:.3f} m) - Richtung nicht bestimmbar" ) continue for index, partner in partners.items(): at_top = heights[index] == highest if uphill == at_top: proposals[index].add((node_id, partner)) else: proposals[index].add((partner, node_id)) edges: list[Edge] = [] for index, (left, right) in enumerate(pairs): found = proposals[index] if len(found) == 1: src, dst = next(iter(found)) edges.append(Edge(src, dst, EDGE_FLOW)) elif len(found) > 1: warnings.append( f"Verbindung {left} <-> {right}: widerspruechliche Richtungen " f"({', '.join(f'{s} -> {d}' for s, d in sorted(found))}) - beidseitig gezeichnet" ) edges.append(Edge(left, right, EDGE_UNRESOLVED, "widerspruechlich")) else: warnings.append( f"Verbindung {left} <-> {right}: Richtung nicht bestimmbar " f"(kein Transportobjekt beteiligt) - beidseitig gezeichnet" ) edges.append(Edge(left, right, EDGE_UNRESOLVED, "unbestimmt")) return edges def _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 _nearest_kreisel(station: Element, elements: list[Element]) -> Element | None: """Naechstgelegener Kreisel zur Station (2D-Abstand zur Kreisel-Achsposition).""" if station.x is None or station.y is None: return None best, best_d = None, None for el in elements: if not el.is_circle or el.x is None or el.y is None: continue d = (el.x - station.x) ** 2 + (el.y - station.y) ** 2 if best_d is None or d < best_d: best, best_d = el, d return best def _attach_stations( elements: list[Element], by_id: dict[str, 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), haengen beide baulich direkt am naechstgelegenen Kreisel, auf dessen Eingangs- bzw. Ausgangsbahn (siehe _weiche_lane_roles): UZS -> Eingang Bahn R / Ausgang Bahn L, GUZ -> Eingang Bahn L / Ausgang Bahn R. Nur wenn kein Kreisel mit bekannter Drehrichtung gefunden wird, greift ersatzweise die naechstgelegene Bahn/Strecke per Abstand. """ 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 and (element.is_entry or element.is_exit): kreisel = _nearest_kreisel(element, elements) roles = _weiche_lane_roles(kreisel.teile_id, by_id, nodes) if kreisel else None if roles: eingang, ausgang = roles lane, role = (eingang, "Eingangsbahn") if element.is_entry else (ausgang, "Ausgangsbahn") partners = [lane] warnings.append( f"{element.describe()}: keine Nachbarn im Export - " f"an Kreisel {kreisel.teile_id} Bahn {lane.split('-')[-1]} " f"angebunden ({role}, Drehrichtung {kreisel.rotation})" ) 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 _weiche_lane_roles(kreisel_base: str, by_id: dict[str, Element], nodes: dict[str, Node]) -> tuple[str, str] | None: """Eingangs-/Ausgangsbahn eines Kreisels an einer Kreisel<->Kreisel-Weiche. Ein Kreisel ist intern ein Umlauf zwischen seinen zwei Bahnen (Merkmal "Drehrichtung"); am Beruehrpunkt mit dem anderen Kreisel liegt daher zwingend die eine Bahn als Eingang (freies Ende vor dem internen Umlauf) und die andere als Ausgang (freies Ende danach): UZS (Umlauf R -> L) -> Eingang R, Ausgang L GUZ (Umlauf L -> R) -> Eingang L, Ausgang R None, wenn die Drehrichtung fehlt/unbekannt ist oder eine Bahn im Graph fehlt. """ element = by_id.get(kreisel_base) if element is None or not element.is_circle: return None left, right = f"{kreisel_base}-{LANE_LEFT}", f"{kreisel_base}-{LANE_RIGHT}" if left not in nodes or right not in nodes: return None if element.rotation == ROTATION_CW: return right, left if element.rotation == ROTATION_CCW: return left, right return 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, Bahn und Richtung aus der Drehrichtung beider Kreisel (siehe _weiche_lane_roles); ohne bekannte Drehrichtung bleibt die Verbindung unbestimmt, wird aber weiterhin als Weiche markiert. 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]] = [] weiche_ab: list[tuple[str, 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}) weiche_ab.append((a, b)) # Weiche zwischen zwei Kreiseln: Bahn und Richtung deterministisch aus der # Drehrichtung beider Kreisel ableiten (Ausgang der einen Bahn -> Eingang # der anderen, in beiden Richtungen) statt aus dem rohen Nachbarn-Kreuzprodukt, # das links/rechts nicht unterscheiden kann. weiche_edges: list[Edge] = [] weiche_resolved: set[frozenset] = set() for a, b in weiche_ab: roles_a = _weiche_lane_roles(a, by_id, nodes) roles_b = _weiche_lane_roles(b, by_id, nodes) if roles_a is None or roles_b is None: warnings.append( f"Weiche {a}<->{b}: Drehrichtung mindestens eines Kreisels unbekannt - " f"Bahnzuordnung nicht bestimmbar, Verbindung bleibt unbestimmt" ) continue in_a, out_a = roles_a in_b, out_b = roles_b weiche_edges.append(Edge(out_a, in_b, EDGE_FLOW, "Weiche")) weiche_edges.append(Edge(out_b, in_a, EDGE_FLOW, "Weiche")) weiche_resolved.add(frozenset({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] = [] superseded: set[tuple[str, str]] = set() for e in edges: if frozenset({e.src, e.dst}) in directed: continue # wird gerichtet neu gesetzt if frozenset({e.src.split("-")[0], e.dst.split("-")[0]}) in weiche_resolved: superseded.add((e.src, e.dst)) continue # wird durch die Weiche-Kanten ersetzt 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)) result.extend(weiche_edges) # Die anfaengliche Hoehen-basierte Ausrichtung (_orient) kann eine # Kreisel<->Kreisel-Verbindung nie bestimmen und meldet sie immer als # "beidseitig gezeichnet" - das stimmt nicht mehr, sobald die Weiche sie # gerade durch obenstehende Kanten ersetzt hat. Die dadurch ueberholte # Meldung wird entfernt. if superseded: stale = { f"Verbindung {a} <-> {b}: Richtung nicht bestimmbar " f"(kein Transportobjekt beteiligt) - beidseitig gezeichnet" for src, dst in superseded for a, b in ((src, dst), (dst, src)) } warnings[:] = [w for w in warnings if w not in stale] # Kreisel<->Kreisel-Kanten, die eine Weiche verbinden, markieren (deckt vor # allem die Faelle ab, die mangels Drehrichtung nicht gerichtet aufgeloest # werden konnten - die aufgeloesten Weiche-Kanten tragen die Markierung # bereits). 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) unassigned = _attach_devices(elements, nodes, warnings) observed = _collect_links(elements, by_id, warnings) pairs: set[tuple[str, str]] = set() for key, lanes in sorted(observed.items(), key=lambda item: sorted(item[0])): base_a, base_b = sorted(key) for node_a in _endpoints(base_a, lanes[base_a], by_id, warnings, base_b): for node_b in _endpoints(base_b, lanes[base_b], by_id, warnings, base_a): pairs.add((node_a, node_b)) edges = _orient(sorted(pairs), nodes, warnings) # Kreisel: Richtung zwischen den beiden Bahnen aus der Drehrichtung # UZS (im Uhrzeigersinn) -> von rechts nach links, R -> L # GUZ (gegen den Uhrzeigersinn) -> von links nach rechts, L -> R # Fehlt die Angabe, bleibt der Umlauf beidseitig und wird gemeldet. for element in elements: if not element.is_circle: continue left = f"{element.teile_id}-{LANE_LEFT}" right = f"{element.teile_id}-{LANE_RIGHT}" rotation = element.rotation if rotation == ROTATION_CW: edges.append(Edge(right, left, EDGE_CIRCLE, ROTATION_CW)) elif rotation == ROTATION_CCW: edges.append(Edge(left, right, EDGE_CIRCLE, ROTATION_CCW)) else: warnings.append( f"{element.describe()}: Drehrichtung '{element.rotation or '-'}' " f"unbekannt (erwartet {ROTATION_CW} oder {ROTATION_CCW}) - " f"Umlauf beidseitig gezeichnet" ) 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, by_id, 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. outgoing = {edge.src for edge in edges} incoming = {edge.dst for edge in edges} for node_id in sorted(nodes): node = nodes[node_id] if node.lane is None: continue devices = node.devices.get("Separator", []) detail = f" (Separatoren {', '.join(sorted(devices))})" if devices else "" if node_id not in outgoing: warnings.append( f"Bahn {node_id}{detail}: keine abgehende Verbindung - Sackgasse. " f"Drehrichtung {node.element.rotation or '?'} laesst das Material " f"nur in eine Richtung laufen" ) if node_id not in incoming: warnings.append( f"Bahn {node_id}{detail}: keine ankommende Verbindung - fuer das " f"Material nicht erreichbar" ) return Graph(nodes=nodes, edges=edges, warnings=warnings, unassigned=unassigned, source=source) # --------------------------------------------------------------------------- # Bahnzusammenhang-Graph (--track, siehe doc/track.md) # --------------------------------------------------------------------------- # # Feinerer Graph als build_graph(): Separator/Ausschleus/Einschleus/Weiche # werden hier zu eigenen Knoten (statt nur Kanten/Zuordnungen zu liefern wie # im normalen Materialfluss). Baut auf dem fertigen groben Graph auf (fuer # Kreisel-Drehrichtung ueber _weiche_lane_roles und die bereits geloeste # Stations-Anbindung aus _attach_stations) und auf der CSV-Spalte "Nachbarn" # der vier TRACK_KINDS selbst - die die bestehende Pipeline bisher komplett # ignoriert (_collect_links ueberspringt sie, siehe dort). # # Kernbefund (doc/track.md Abschnitt 1): ein Ausschleus-/Einschleuselement # nennt in seiner eigenen Nachbarn-Spalte konsequent nur die Foerderer-/ # Streckenseite; die Kreisel-Seite ist nur ueber die Rueckmeldung des dortigen # Separators auffindbar (der das Transferelement seinerseits als Nachbarn # nennt). Die Fein-Adjazenz ist daher die Vereinigung aller Nennungen, nicht # nur die eigene Nachbarn-Liste eines Elements. SYMBOL_ENTRY = "Eingang" SYMBOL_EXIT = "Ausgang" def _transfer_kreisel_lane(el: Element, by_id: dict[str, Element], nodes: dict[str, Node]) -> str | None: """ Kreisel-Bahn (…-L/…-R) eines Ausschleus-/Einschleuselements. Die Merkmale von AS/ES nennen nur die blanke Kreisel-TeileId (Merkmal "Kreisel"), nie die Bahn. Welche der beiden Bahnen gemeint ist, steht stattdessen in den Nachbarn der zugehoerigen Strecke/Gefaellestrecke (Merkmal "Strecke" bzw. "Förderer") - dieselbe Aufloesung, die _apply_transfers fuer die groben Kreisel<->Strecke-Kanten schon nutzt. """ kreisel_id = str(_merkmal(el.merkmale, "Kreisel") or "").strip() if not kreisel_id: return None strecke_id = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "").strip() strecke_el = by_id.get(strecke_id) if strecke_el is not None: lane = _kreisel_lane_node(kreisel_id, strecke_el, nodes) if lane: return lane for lane_suffix in (LANE_LEFT, LANE_RIGHT): # Fallback: irgendeine Bahn if f"{kreisel_id}-{lane_suffix}" in nodes: return f"{kreisel_id}-{lane_suffix}" return kreisel_id if kreisel_id in nodes else None def _wire_weiche( el: Element, by_id: dict[str, Element], graph: Graph, own: dict[str, list[str]], track: TrackGraph, warnings: list[str], placeholder, add_edge, ) -> None: """ Weiche als 4-Wege-Kreuzung verdrahten (Kreisel A <-> Kreisel B, in beide Richtungen; siehe doc/track.md Abschnitt 2). Pro Kreisel liefert das bereits vorhandene _weiche_lane_roles() die Eingangs-/Ausgangsbahn aus der Drehrichtung. Die eigenen Nachbarn der Weiche (bis zu 2) werden per Zuordnung auf diese vier Rollen verteilt; ein Nachbar, dessen Zuordnung zu keiner der vier Bahnen passt (z. B. weil er - wie in den Mubea-Testdaten - foelschlich einer Strecke statt der schaltenden Kreisel-Bahn zugeordnet ist, vgl. tro_overrides.py-Fall "split"), bekommt per Ausschlussverfahren die letzte freie Rolle, zusammen mit einem Hinweis. Rollen ohne passenden Nachbarn haengen am Bahn-Platzhalter der jeweiligen Bahn. """ kreisel_a = str(_merkmal(el.merkmale, "Kreisel A") or "").strip() kreisel_b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip() roles_a = _weiche_lane_roles(kreisel_a, by_id, graph.nodes) if kreisel_a else None roles_b = _weiche_lane_roles(kreisel_b, by_id, graph.nodes) if kreisel_b else None if roles_a is None and kreisel_a: warnings.append( f"{el.describe()}: Drehrichtung von Kreisel {kreisel_a} unbekannt - " f"Bahnrollen auf dieser Seite nicht bestimmbar" ) if roles_b is None and kreisel_b: warnings.append( f"{el.describe()}: Drehrichtung von Kreisel {kreisel_b} unbekannt - " f"Bahnrollen auf dieser Seite nicht bestimmbar" ) lanes: dict[str, str | None] = { "eingang_a": roles_a[0] if roles_a else None, "ausgang_a": roles_a[1] if roles_a else None, "eingang_b": roles_b[0] if roles_b else None, "ausgang_b": roles_b[1] if roles_b else None, } candidates = list(own.get(el.teile_id, [])) matched: dict[str, str] = {} unmatched: list[str] = [] for cand in candidates: cand_el = by_id.get(cand) lane = cand_el.assignment if cand_el else None role = next((r for r, lane_id in lanes.items() if lane_id and lane_id == lane), None) if role: matched[role] = cand else: unmatched.append(cand) remaining_roles = [r for r in lanes if r not in matched and lanes[r] is not None] if len(unmatched) == 1 and len(remaining_roles) == 1: role = remaining_roles[0] matched[role] = unmatched[0] cand_el = by_id.get(unmatched[0]) warnings.append( f"{el.describe()}: Nachbar {unmatched[0]} (Zuordnung " f"'{cand_el.assignment if cand_el else '-'}') passt zu keiner der " f"erwarteten Bahnen - durch Ausschlussverfahren als {role} angenommen" ) elif unmatched: warnings.append( f"{el.describe()}: Nachbar(n) {unmatched} nicht eindeutig einer Bahn " f"zuzuordnen (offene Rollen: {remaining_roles or '-'}) - nicht verbunden" ) def endpoint(role: str) -> str | None: if role in matched: return matched[role] lane_id = lanes[role] return placeholder(lane_id, "Weiche-Seite ohne passenden Fein-Nachbarn") if lane_id else None add_edge(endpoint("ausgang_a"), el.teile_id, "Weiche") add_edge(el.teile_id, endpoint("eingang_b"), "Weiche") add_edge(endpoint("ausgang_b"), el.teile_id, "Weiche") add_edge(el.teile_id, endpoint("eingang_a"), "Weiche") def _orient_separator_links( fine: dict[str, Element], own: dict[str, list[str]], mentioned_by: dict[str, set[str]], track: TrackGraph, by_id: dict[str, Element], warnings: list[str], add_edge, suppressed_pairs: set[frozenset] = frozenset(), ) -> None: """ Reine Separator-Separator-Kanten richten (doc/track.md Abschnitt 3). Jede AS-/ES-/Weiche-Kante ist zu diesem Zeitpunkt bereits gerichtet (siehe build_track_graph) und "verankert" die anliegenden Separatoren. Von dort aus wird die Kette wie eine verkettete Liste abgearbeitet: hat ein Separator genau eine noch offene Separator-Separator-Verbindung und schon eine bekannte (Anker- oder bereits geloeste) Seite, bekommt die offene Seite zwangslaeufig die jeweils andere Rolle. Was am Ende keinen Anker erreicht, faellt auf Hoehe/Antriebsrichtung des zugeordneten Objekts zurueck (wie im groben Graph), sonst bleibt es unbestimmt. suppressed_pairs schliesst Paare aus, die zwar gegenseitig als Nachbarn gelistet sind, aber beide zugleich eigener Nachbar DESSELBEN AS/ES/Weiche- Ankers sind (siehe build_track_graph) - das sind zwei unabhaengige Speichen desselben Uebergabepunkts (beide nur deshalb nah beieinander, weil sie am selben Knoten haengen), keine direkte Foerderverbindung zueinander. Ohne diesen Ausschluss wuerde z. B. in mubea_export.csv faelschlich eine Kante zwischen den beiden Weiche-Nachbarn 0040 (Kreisel 0001-R) und 0036 (an Strecke 0014, siehe _wire_weiche) gezeichnet. """ separators = {tid for tid, el in fine.items() if el.kind == "Separator"} adjacency: dict[str, set[str]] = { tid: { n for n in (set(own.get(tid, [])) | mentioned_by.get(tid, set())) if n in separators and frozenset((tid, n)) not in suppressed_pairs } for tid in separators } # touches[sep]: bereits bekannte Nachbarn dieses Separators und ihre Rolle # ("upstream" = Material kommt von dort, "downstream" = Material geht dorthin). touches: dict[str, dict[str, str]] = {tid: {} for tid in separators} for e in track.edges: if e.dst in separators: touches[e.dst][e.src] = "upstream" if e.src in separators: touches[e.src][e.dst] = "downstream" pending: dict[str, set[str]] = { tid: {n for n in adjacency[tid] if n not in touches[tid]} for tid in separators } changed = True while changed: changed = False for tid in separators: if len(pending[tid]) != 1 or len(touches[tid]) == 0: continue other = next(iter(pending[tid])) resolved_dir = next(iter(touches[tid].values())) other_dir = "upstream" if resolved_dir == "downstream" else "downstream" if other_dir == "downstream": add_edge(tid, other, "Bahn") else: add_edge(other, tid, "Bahn") touches[tid][other] = other_dir touches[other][tid] = "upstream" if other_dir == "downstream" else "downstream" pending[tid].discard(other) pending[other].discard(tid) changed = True seen: set[frozenset] = set() for tid in separators: for other in pending[tid]: pair = frozenset((tid, other)) if pair in seen: continue seen.add(pair) # Hoehenvergleich nur, wenn beide Separatoren DERSELBEN Zuordnung # angehoeren (echtes Kettenmittelstueck, z. B. 0050<->0051 auf # Strecke 0013). Ein Nachbar-Paar mit UNTERSCHIEDLICHER Zuordnung # und ganz ohne erreichbaren Anker (z. B. 0043 auf Kreisel 0010-R # <-> 0045 auf Strecke 0013) ist derselbe Fall wie 0036/0040 bzw. # 0005/0078 - ein Uebergang, der eigentlich ueber ein AS/ES/Weiche # laufen sollte, hier aber keins hat; ohne dessen Rollenaufloesung # waere ein Hoehenvergleich (der hier zudem gleiche Z liefert) # geraten statt abgeleitet. same_host = bool(fine[tid].assignment) and fine[tid].assignment == fine[other].assignment host = by_id.get(fine[tid].assignment.split("-")[0]) if same_host and fine[tid].assignment else None uphill, reason = drive_uphill(host) if host is not None else (None, "kein zugeordnetes Objekt") za, zb = fine[tid].z, fine[other].z if uphill is not None and za is not None and zb is not None and za != zb: src, dst = (tid, other) if (za < zb) == uphill else (other, tid) add_edge(src, dst, "Hoehe") warnings.append( f"Separator {tid} <-> {other}: kein Anker (AS/ES/Weiche) in der " f"Kette erreichbar - Richtung ueber Hoehe des zugeordneten Objekts " f"({reason}) angenommen" ) else: add_edge(tid, other, "unbestimmt") warnings.append( f"Separator {tid} <-> {other}: kein Anker und keine Hoehe - " f"Richtung unbestimmt, beidseitig markiert" ) def _attach_track_stations( elements: list[Element], graph: Graph, track: TrackGraph, placeholder, add_edge ) -> None: """ BTMT Be-/Entladung an den Bahn-Platzhalter anbinden. Die Zielbahn wurde bereits von _attach_stations() im groben Graph bestimmt (naechster Kreisel + Eingangs-/Ausgangsbahn aus der Drehrichtung, siehe dort) - hier nur wiederverwendet statt neu berechnet. """ for element in elements: if not element.is_station: continue sid = element.teile_id if sid not in graph.nodes: continue track.nodes.setdefault(sid, TrackNode(sid, element.kind, element)) for edge in graph.edges: if edge.src == sid: add_edge(sid, placeholder(edge.dst), "Station") elif edge.dst == sid: add_edge(placeholder(edge.src), sid, "Station") def _attach_track_symbols( graph: Graph, track: TrackGraph, placeholder, add_edge, warnings: list[str] ) -> None: """ Offene Bahnenden ohne BTMT-Station als generisches Symbol Eingang/Ausgang markieren - dieselben Kandidaten, die render_connect_ini() heute nur als Kommentarzeile auflistet (kein Transportobjekt liefert eine ankommende bzw. abgehende Kante), hier aber als echter Graph-Knoten. """ incoming = {e.dst for e in graph.edges} outgoing = {e.src for e in graph.edges} symbol_no = 0 for node_id in sorted(graph.nodes): node = graph.nodes[node_id] if node.element.is_station: continue if node_id not in incoming: symbol_no += 1 sym_id = f"SYM_EIN_{symbol_no:02d}" track.nodes[sym_id] = TrackNode(sym_id, SYMBOL_ENTRY) add_edge(sym_id, placeholder(node_id), "Symbol") warnings.append( f"{node_id}: keine ankommende Verbindung und keine BTMT-Station - " f"im --track-Graph als Symbol {SYMBOL_ENTRY} markiert" ) if node_id not in outgoing: symbol_no += 1 sym_id = f"SYM_AUS_{symbol_no:02d}" track.nodes[sym_id] = TrackNode(sym_id, SYMBOL_EXIT) add_edge(placeholder(node_id), sym_id, "Symbol") warnings.append( f"{node_id}: keine abgehende Verbindung und keine BTMT-Station - " f"im --track-Graph als Symbol {SYMBOL_EXIT} markiert" ) def build_track_graph( elements: list[Element], by_id: dict[str, Element], graph: Graph, warnings: list[str] ) -> TrackGraph: """ Feiner Bahnzusammenhang-Graph fuer --track (siehe doc/track.md). Nutzt den bereits fertig aufgeloesten groben Graph fuer Kreisel- Drehrichtung (_weiche_lane_roles) und die bereits geloeste Stations- Anbindung (graph.edges); die Fein-Adjazenz selbst kommt aus der CSV-Spalte "Nachbarn" der Separator-/Ausschleus-/Einschleus-/Weiche-Zeilen (siehe Modul-Docstring oben). warnings wird mit dem Aufruf von build_graph() geteilt, damit Fein-Hinweise im selben Bericht erscheinen. """ track = TrackGraph(source=graph.source) fine = {e.teile_id: e for e in elements if e.kind in TRACK_KINDS} def add_edge(src: str | None, dst: str | None, note: str = "") -> None: if src is not None and dst is not None: track.edges.append(TrackEdge(src, dst, note)) def placeholder(host_id: str | None, reason: str = "") -> str | None: if not host_id: return None if host_id not in track.nodes: coarse = graph.nodes.get(host_id) track.nodes[host_id] = TrackNode( host_id, "Bahn", coarse.element if coarse else None, placeholder=True, note=reason ) return host_id for el in fine.values(): track.nodes.setdefault(el.teile_id, TrackNode(el.teile_id, el.kind, el)) # Eigene Nachbarn je Feinelement (nur Feinelemente behalten, siehe # TRACK_KINDS) plus die Umkehr-Sicht (wer nennt mich als Nachbar) - # zusammen die vereinigte Fein-Adjazenz (doc/track.md Abschnitt 1). own: dict[str, list[str]] = { tid: [b for b, _lane in el.neighbours if b in fine] for tid, el in fine.items() } mentioned_by: dict[str, set[str]] = {tid: set() for tid in fine} for tid, targets in own.items(): for target in targets: mentioned_by[target].add(tid) # Ausschleus/Einschleus: eigener Nachbar = Foerderer-/Streckenseite, # Rueckmeldung = Kreisel-Seite (doc/track.md Abschnitt 1-2). Eine # Rueckmeldung wird nur akzeptiert, wenn ihre eigene Zuordnung auch # tatsaechlich zur erwarteten Bahn (_transfer_kreisel_lane) passt - sonst # ist es wie beim Weiche-Fall 0036/0040 nur eine raeumlich nahe, aber # fachlich andere Kette (z. B. 0005 auf Strecke 0014, das per Rueckmeldung # faelschlich als Kreisel-Seite mehrerer Storage-Bank-Einschleuselemente # auf 0002-R erscheint), keine echte Weiterfahrt auf derselben Bahn. for el in fine.values(): if el.kind not in ("Ausschleus", "Einschleus"): continue strecke_side = list(own.get(el.teile_id, [])) if not strecke_side: warnings.append(f"{el.describe()}: keine Foerderer-/Streckenseite in eigenen Nachbarn gefunden") elif len(strecke_side) > 1: warnings.append(f"{el.describe()}: mehrere Streckenseiten-Nachbarn {strecke_side} - alle verbunden") expected_lane = _transfer_kreisel_lane(el, by_id, graph.nodes) raw_kreisel_side = sorted(mentioned_by.get(el.teile_id, set()) - set(strecke_side)) kreisel_side: list[str] = [] for cand in raw_kreisel_side: cand_el = by_id.get(cand) if expected_lane and cand_el is not None and cand_el.assignment == expected_lane: kreisel_side.append(cand) else: warnings.append( f"{el.describe()}: Rueckmeldung von {cand} (Zuordnung " f"'{cand_el.assignment if cand_el else '-'}') passt nicht zur erwarteten " f"Bahn {expected_lane or '?'} - als Kreisel-seitiger Nachbar verworfen" ) if not kreisel_side: if expected_lane: kreisel_side = [placeholder(expected_lane, "keine passende Rueckmeldung eines Fein-Elements gefunden")] else: warnings.append(f"{el.describe()}: Kreisel-Bahn nicht aufloesbar - keine Kreisel-seitige Kante") elif len(kreisel_side) > 1: warnings.append(f"{el.describe()}: mehrere Kreisel-seitige Nachbarn {kreisel_side} - alle verbunden") if el.kind == "Ausschleus": for k in kreisel_side: add_edge(k, el.teile_id, "Bahn") for s in strecke_side: add_edge(el.teile_id, s, "Zweig") else: # Einschleus for s in strecke_side: add_edge(s, el.teile_id, "Zweig") for k in kreisel_side: add_edge(el.teile_id, k, "Bahn") # Weiche: 4-Wege-Kreuzung zwischen zwei Kreiseln (doc/track.md Abschnitt 2). for el in fine.values(): if el.kind == "Weiche": _wire_weiche(el, by_id, graph, own, track, warnings, placeholder, add_edge) # Zwei eigene Nachbarn DESSELBEN AS/ES/Weiche-Ankers sind zwei unabhaengige # Speichen desselben Uebergabepunkts, keine direkte Kette zueinander (siehe # _orient_separator_links-Docstring) - von der Separator-Separator- # Orientierung ausschliessen, bevor sie dort faelschlich verkettet werden. suppressed_pairs: set[frozenset] = set() for tid, el in fine.items(): if el.kind not in ("Ausschleus", "Einschleus", "Weiche"): continue candidates = own.get(tid, []) for i, a in enumerate(candidates): for b in candidates[i + 1:]: suppressed_pairs.add(frozenset((a, b))) # Reine Separator-Separator-Verbindungen orientieren (doc/track.md Abschnitt 3). _orient_separator_links( fine, own, mentioned_by, track, by_id, warnings, add_edge, suppressed_pairs ) # Verwaiste Separatoren (keine Nachbarn im Export, z. B. 0037/0038 in # mubea_export.csv) an ihren Zuordnung-Platzhalter haengen statt sie # unverbunden zu lassen - Reihenfolge dort bewusst offen. for el in fine.values(): if el.kind != "Separator" or own.get(el.teile_id) or mentioned_by.get(el.teile_id): continue host = el.assignment if el.assignment and el.assignment != UNASSIGNED else None ph = placeholder(host, "Separator ohne Nachbarn im Export") if ph: add_edge(ph, el.teile_id, "unbestimmt") warnings.append( f"{el.describe()}: keine Nachbarn zu anderen Bahnelementen im Export - " f"Reihenfolge auf {host} ungeklaert" ) else: warnings.append(f"{el.describe()}: keine Nachbarn und keine Zuordnung - im --track-Graph isoliert") # Systemgrenzen: BTMT-Stationen (wiederverwendet) bzw. generisches # Eingang/Ausgang-Symbol an offenen Bahnenden ohne Station. _attach_track_stations(elements, graph, track, placeholder, add_edge) _attach_track_symbols(graph, track, placeholder, add_edge, warnings) # Letzte Sicherung: ein Fein-Element, das nach alledem keine einzige Kante # hat (z. B. weil ein Weiche-Nachbar zu keiner Rolle passte, siehe # _wire_weiche, UND seine einzige sonstige Nachbarschaft als Speiche # desselben Ankers ausgeschlossen wurde), sichtbar melden statt es # stillschweigend unverbunden im Graph stehen zu lassen. touched = {e.src for e in track.edges} | {e.dst for e in track.edges} for tid, el in fine.items(): if tid not in touched: warnings.append(f"{el.describe()}: im --track-Graph ohne jede Kante - Zuordnung/Nachbarn widerspruechlich") return track # --------------------------------------------------------------------------- # DOT erzeugen # --------------------------------------------------------------------------- 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}]") lines.append(f"Bahn {node.lane} ({LANE_NAMES[node.lane]})") else: lines.append(f"{element.name} [{element.teile_id}]") lines.append(element.kind) height = element.height if height is not None: lines.append(f"h = {height:.3f} m") if element.is_transport: uphill = drive_uphill(element)[0] arrow = {True: "aufwaerts", False: "abwaerts", None: "Richtung offen"}[uphill] if element.rise is not None: lines.append(f"{element.height_from:.0f} -> {element.height_to:.0f} mm " f"({element.rise:+.0f}) = {arrow}") elif element.drive_dir: lines.append(f"Antrieb: {element.drive_dir} = {arrow}") else: lines.append(arrow) for kind in DEVICE_KINDS: ids = node.devices.get(kind) if ids: lines.append(f"{kind} ({len(ids)}): {', '.join(sorted(ids))}") return _dot_label(lines) # 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 _flow_neighbours(node_id: str, edges: list[Edge]) -> tuple[str, str] | None: """ (Vorgaenger, Nachfolger) eines Knotens im Materialfluss. None, wenn der Knoten nicht genau einen ankommenden und einen abgehenden EDGE_FLOW hat - dann ist er kein einfacher Durchlaufknoten zwischen zwei festen Nachbarn. """ incoming = {e.src for e in edges if e.dst == node_id and e.kind == EDGE_FLOW} outgoing = {e.dst for e in edges if e.src == node_id and e.kind == EDGE_FLOW} if len(incoming) != 1 or len(outgoing) != 1: return None return next(iter(incoming)), next(iter(outgoing)) def _group_parallel_gefaelle(graph: Graph) -> list[list[Node]]: """ Parallele Gefaellestrecken zwischen denselben zwei Kreisel-Bahnen zu Gruppen zusammenfassen (z. B. eine Bank aus Schwerkraftlinien). Kriterium: gleicher Zulauf- und Ablauf-Knoten (jeweils eine Kreisel-Bahn) und gleiche Steigung (Hoehe_Von/Hoehe_Bis) - "gleich lang und gleiche Steigung" laesst sich aus dem Export nur ueber die Hoehenangabe pruefen, die Laenge ergibt sich daraus, dass beide Enden an denselben zwei Knoten haengen. Rein fuer die Darstellung; Graph-Knoten/-Kanten bleiben unveraendert. """ groups: dict[tuple, list[Node]] = {} for node_id in sorted(graph.nodes): node = graph.nodes[node_id] if node.kind != "Gefaellestrecke": continue neighbours = _flow_neighbours(node_id, graph.edges) if neighbours is None: continue before, after = neighbours if graph.nodes[before].kind != "Kreisel" or graph.nodes[after].kind != "Kreisel": continue element = node.element key = (before, after, element.height_from, element.height_to) groups.setdefault(key, []).append(node) return [members for members in groups.values() if len(members) >= MIN_GEFAELLE_GROUP] def _id_range(ids: list[str]) -> str: """Sortierte Ids als Bereich, z. B. ['0037', '0056'] -> '0037 - 0056'.""" ids = sorted(ids) return ids[0] if len(ids) == 1 else f"{ids[0]} - {ids[-1]}" def _gefaelle_group_label(members: list[Node]) -> str: """Sammel-Label einer Gefaellestrecken-Gruppe: Bereich der Linien-Ids, gemeinsame Steigung, zusammengefasste Separatoren/Scanner.""" element = members[0].element ids = sorted(m.node_id for m in members) lines = [ f"Gefaellestrecke {ids[0]} -> {ids[-1]} ({len(ids)}x)", "Gruppe paralleler Schwerkraftlinien", ] if element.height is not None: lines.append(f"h = {element.height:.3f} m") if element.rise is not None: uphill = drive_uphill(element)[0] arrow = {True: "aufwaerts", False: "abwaerts", None: "Richtung offen"}[uphill] lines.append(f"{element.height_from:.0f} -> {element.height_to:.0f} mm " f"({element.rise:+.0f}) = {arrow}") for kind in DEVICE_KINDS: ids_dev = sorted({d for m in members for d in m.devices.get(kind, [])}) if ids_dev: lines.append(f"{kind} ({len(ids_dev)}): {_id_range(ids_dev)}") return _dot_label(lines) 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 .dot -o .svg") add("digraph Materialfluss {") add(' graph [rankdir=LR, splines=spline, nodesep=0.25, ranksep=1.6,') add(' fontname="Segoe UI", fontsize=11, labelloc="t",') add(f' label="Materialfluss - {_dot_escape(graph.source)}"];') add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];') add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,' ' arrowsize=0.8];') add("") # Kreisel als Cluster mit den beiden Bahnen circles: dict[str, list[Node]] = {} plain: list[Node] = [] for node_id in sorted(graph.nodes): node = graph.nodes[node_id] if node.lane: circles.setdefault(node.element.teile_id, []).append(node) else: plain.append(node) for index, (teile_id, lanes) in enumerate(sorted(circles.items())): element = lanes[0].element name = _merkmal(element.merkmale, "Name") or element.name rot = element.rotation rot_text = (f" {rot} ({ROTATION_FLOW[rot]})" if rot in ROTATION_FLOW else f" Drehrichtung {rot or '?'}") add(f" subgraph cluster_kreisel_{index} {{") add(f' label="{_dot_escape(element.kind)} {_dot_escape(str(name))} ' f'[{teile_id}]{_dot_escape(rot_text)}";') add(' style="rounded,filled"; fillcolor="#eef2fa"; color="#2f5597";') add(' fontname="Segoe UI"; fontsize=10; fontcolor="#1f3864"; margin=12;') style = NODE_STYLES.get(element.kind, DEFAULT_NODE_STYLE) for node in sorted(lanes, key=lambda n: n.lane or ""): attrs = ", ".join(f'{key}="{value}"' for key, value in style.items()) add(f' "{node.node_id}" [label="{_node_label(node)}", {attrs}' f'{_pos_attr(node, use_coords)}];') add(" { rank=same; " + " ".join(f'"{n.node_id}";' for n in lanes) + " }") add(" }") add("") # Parallele Gefaellestrecken (gleicher Zu-/Ablauf, gleiche Steigung) zu # je einem Block zusammenfassen, damit eine Linienbank nicht als N # einzelne Kaesten gezeichnet wird. gefaelle_groups = _group_parallel_gefaelle(graph) group_of: dict[str, str] = {} for members in gefaelle_groups: group_id = f"grp_{members[0].node_id}" for member in members: group_of[member.node_id] = group_id grouped_ids = set(group_of) for node in plain: if node.node_id in grouped_ids: continue 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}' f'{_pos_attr(node, use_coords)}];') for members in gefaelle_groups: group_id = f"grp_{members[0].node_id}" style = NODE_STYLES.get("Gefaellestrecke", DEFAULT_NODE_STYLE) attrs = ", ".join(f'{key}="{value}"' for key, value in style.items()) pos = "" if use_coords: xs = [xy[0] for m in members if (xy := _node_xy(m))] ys = [xy[1] for m in members if (xy := _node_xy(m))] if xs and ys: pos = (f', pos="{sum(xs) / len(xs) * COORD_SCALE:.2f},' f'{sum(ys) / len(ys) * COORD_SCALE:.2f}"') add(f' "{group_id}" [label="{_gefaelle_group_label(members)}", {attrs}, ' f'peripheries=2{pos}];') add("") seen_group_edges: set[tuple[str, str]] = set() for edge in graph.edges: src = group_of.get(edge.src, edge.src) dst = group_of.get(edge.dst, edge.dst) if edge.src in grouped_ids or edge.dst in grouped_ids: if src == dst or (src, dst) in seen_group_edges: continue seen_group_edges.add((src, dst)) if edge.kind == EDGE_CIRCLE: # Richtung kommt aus der Drehrichtung und steht als Label an der Kante label = f', xlabel="{_dot_escape(edge.note)}"' if edge.note else "" attrs = ('[color="#8ea9db", style=dashed, penwidth=1.2, constraint=false, ' '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}"{weiche}]') elif edge.note == "Weiche": attrs = ('[color="#cc8800", penwidth=1.6, xlabel="Weiche", ' 'fontcolor="#cc8800", tooltip="Weiche (Kreisel-Uebergang)"]') else: attrs = "[]" add(f' "{src}" -> "{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(f' legende [shape=plaintext, style="", fillcolor="none",{legend_pos} label=<' '' '' '' '' '' '' '' '" "
durchgezogenMaterialfluss
gestrichelt blauKreisel-Umlauf, Richtung aus Drehrichtung ' f'({ROTATION_CW} = {ROTATION_FLOW[ROTATION_CW]}, ' f'{ROTATION_CCW} = {ROTATION_FLOW[ROTATION_CCW]})
gestrichelt gelbRichtung nicht bestimmbar
doppelte UmrandungGruppe paralleler Gefaellestrecken (gleicher Zu-/Ablauf, ' "gleiche Steigung)
>];") add(" }") add("}") return "\n".join(out) + "\n" # --------------------------------------------------------------------------- # DOT des Bahnzusammenhang-Graphen erzeugen (--track) # --------------------------------------------------------------------------- TRACK_NODE_STYLES: dict[str, dict[str, str]] = { "Separator": dict(fillcolor="#fff2cc", fontcolor="#7f6000", color="#bf8f00"), "Ausschleus": dict(fillcolor="#f9cb9c", fontcolor="#b45f06", color="#e69138"), "Einschleus": dict(fillcolor="#cfe2f3", fontcolor="#1155cc", color="#3d85c6"), "Weiche": dict(fillcolor="#ead1dc", fontcolor="#741b47", color="#a64d79"), "Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"), "Entladung": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"), SYMBOL_ENTRY: dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"), SYMBOL_EXIT: dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"), "Bahn": dict(fillcolor="#f2f2f2", fontcolor="#666666", color="#999999"), } TRACK_EDGE_ATTRS: dict[str, str] = { "unbestimmt": '[color="#bf8f00", style=dashed, dir=both, tooltip="Richtung unbestimmt"]', "Zweig": '[color="#e69138", tooltip="Verzweigung/Zusammenfuehrung an AS/ES"]', "Weiche": '[color="#a64d79", penwidth=1.6, tooltip="Weiche (Kreuzung zweier Kreisel)"]', "Symbol": '[color="#999999", style=dotted, tooltip="offenes Bahnende ohne BTMT-Station"]', "Station": '[color="#338833", tooltip="Anbindung BTMT Be-/Entladung"]', "Hoehe": '[color="#548235", style=dashed, tooltip="Richtung ueber Hoehe angenommen"]', } def _track_node_label(node: TrackNode) -> str: if node.placeholder: lines = [f"Bahn {node.node_id}", "Reihenfolge/Position offen (Teil B)"] if node.note: lines.append(node.note) return _dot_label(lines) if node.element is None: return _dot_label([node.kind]) el = node.element lines = [f"{el.name or node.kind} [{node.node_id}]", node.kind] if node.note: lines.append(node.note) return _dot_label(lines) def render_track_dot(track: TrackGraph) -> str: """DOT des Bahnzusammenhang-Graphen (--track); siehe build_track_graph() und doc/track.md. Gerendert ueber dieselben graph_to_svg/dot_to_svg wie render_dot().""" out: list[str] = [] add = out.append add("// Automatisch erzeugt von lib/material_flow.py (--track) - nicht manuell aendern.") add(f"// Quelle: {track.source}") add("digraph Bahnzusammenhang {") add(' graph [rankdir=LR, splines=spline, nodesep=0.2, ranksep=1.1,') add(' fontname="Segoe UI", fontsize=11, labelloc="t",') add(f' label="Bahnzusammenhang - {_dot_escape(track.source)}"];') add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=8];') add(' edge [fontname="Segoe UI", fontsize=7, color="#2f5597", penwidth=1.1, arrowsize=0.7];') add("") for node_id in sorted(track.nodes): node = track.nodes[node_id] style = TRACK_NODE_STYLES.get(node.kind, DEFAULT_NODE_STYLE) attrs = ", ".join(f'{key}="{value}"' for key, value in style.items()) extra = ', style="rounded,dashed,filled"' if node.placeholder else "" if node.kind == "Weiche": extra += ", shape=diamond" elif node.kind in (SYMBOL_ENTRY, SYMBOL_EXIT, "Beladung", "Entladung"): extra += ", shape=ellipse" add(f' "{node_id}" [label="{_track_node_label(node)}", {attrs}{extra}];') add("") for edge in track.edges: attrs = TRACK_EDGE_ATTRS.get(edge.note, "[]") add(f' "{edge.src}" -> "{edge.dst}" {attrs};') add("") add(" subgraph cluster_legende {") add(' label="Legende"; style="rounded"; color="#a6a6a6";') add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";') add(' legende [shape=plaintext, style="", fillcolor="none", label=<') add('') add('') add('' '') add('' '') add('' '') add('' '') add("
RauteWeiche (Kreuzung zweier Kreisel)
orangeZweig an Ausschleus-/Einschleuselement
gestrichelter RandBahn-Platzhalter - Reihenfolge/Position offen (Teil B)
gestrichelt gruenRichtung ueber Hoehe angenommen (kein Anker erreichbar)
gestrichelt gelbRichtung unbestimmt
>];") add(" }") add("}") 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 `[.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("; [.nodes] = | ") add("; [.connections] cNNN = -> | | ") add("; kind: normal | umlauf | weiche | einschleusung | ausschleusung | unbestimmt") add("; [.externals] eNNN = EXTERN -> | (Zulauf/Beladung)") add("; eNNN = -> EXTERN | (Ablauf/Entladung)") add("; [.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" # --------------------------------------------------------------------------- # 2D-Draufsicht der Boundingboxen (--show-bbs) # --------------------------------------------------------------------------- # Farben je Objektart fuer die Boundingbox-Draufsicht (Fuellung, Rahmen, Text). # Eigene Palette statt NODE_STYLES, weil hier auch die Anbauteile (Separator, # Scanner, Ein-/Ausschleuselement, Weiche) eine eigene Farbe brauchen. BBS_STYLES: dict[str, dict[str, str]] = { "Kreisel": dict(fill="#2f5597", stroke="#1f3864", text="#ffffff"), "Gefaellestrecke": dict(fill="#fbe5d6", stroke="#c55a11", text="#833c00"), "Strecke": dict(fill="#e2f0d9", stroke="#548235", text="#375623"), "Beladung": dict(fill="#d4f0d4", stroke="#338833", text="#1e5b1e"), "Entladung": dict(fill="#f4d4d4", stroke="#cc3333", text="#7a1f1f"), "Separator": dict(fill="#fff2cc", stroke="#bf8f00", text="#7f6000"), "Scanner": dict(fill="#d9d2e9", stroke="#674ea7", text="#351c75"), "Einschleus": dict(fill="#cfe2f3", stroke="#3d85c6", text="#1155cc"), "Ausschleus": dict(fill="#f9cb9c", stroke="#e69138", text="#b45f06"), "Weiche": dict(fill="#ead1dc", stroke="#a64d79", text="#741b47"), } BBS_DEFAULT_STYLE = dict(fill="#f2f2f2", stroke="#808080", text="#404040") # Rand um die Anlage herum und minimale Schriftgroesse, jeweils in mm. BBS_MARGIN_MM = 500.0 BBS_MIN_FONT_MM = 40.0 # Darstellung der Anschlusspunkte K1-K4 (--show-kx): Groesse des Kreuzes und # der Beschriftung in mm, Verbindungslinie K1->K2->K3->K4 in Blau. KX_CROSS_MM = 120.0 KX_FONT_MM = 90.0 KX_CROSS_COLOR = "#c00000" KX_LINK_COLOR = "#1f5fd0" # Physisches Papierformat der SVG-Ausgabe: DIN A3, Quer- oder Hochformat je # nach Seitenverhaeltnis der Anlage. Die viewBox bleibt in Anlage-mm - nur # width/height (das gedruckte Format) werden auf A3 skaliert. DINA3_LANDSCAPE_MM = (420.0, 297.0) DINA3_PORTRAIT_MM = (297.0, 420.0) def _xml_escape(text: str) -> str: return ( text.replace("&", "&").replace("<", "<").replace(">", ">") .replace('"', """) ) def render_bbs_svg( elements: list[Element], source: str, warnings: list[str], show_kx: bool = False, ) -> str: """ 2D-Draufsicht der ganzen Szene: ein achsparalleles Rechteck je CSV-Zeile, aus 'Position' (Mittelpunkt) und 'Boundingbox' (Breite/Tiefe in X/Y - die Z-Ausdehnung ist fuer eine Draufsicht ohne Bedeutung), mit der Bezeichnung im Zentrum. Keine Rotation - reine Lageuebersicht auf Basis der Rohdaten, kein Ersatz fuer die CAD-Zeichnung. Anlage-Y waechst nach oben (wie im CSV/DXF), SVG-Y nach unten - deshalb wird Y beim Zeichnen gespiegelt, damit die Draufsicht nicht auf dem Kopf steht. Die viewBox bleibt in Anlage-mm (Koordinaten, Schrift, Strichstaerke bleiben so zueinander proportional); das gedruckte Format (width/height) wird auf DIN A3 (Quer- oder Hochformat je nach Seitenverhaeltnis) skaliert - sonst waere die SVG-Datei bei einer 25 m breiten Anlage 25 m breit. show_kx zeichnet zusaetzlich je Objekt die Anschlusspunkte K1-K4 (rotes Kreuz + kleine Beschriftung) und verbindet sie in der Reihenfolge K1->K2->K3->K4 mit einer blauen Linie. """ items: list[tuple[Element, float, float, float, float]] = [] for element in elements: if element.position is None or element.bbox is None: warnings.append( f"{element.describe()}: keine Position/Boundingbox - in --show-bbs nicht gezeichnet" ) continue px, py = element.x, element.y bw, bd = element.bbox_x, element.bbox_y if not bw or not bd or bw <= 0 or bd <= 0: warnings.append( f"{element.describe()}: Boundingbox {bw}, {bd} mm ungueltig - in --show-bbs nicht gezeichnet" ) continue items.append((element, px, py, bw, bd)) if not items: raise ValueError("keine Objekte mit Position und Boundingbox gefunden") min_x = min(item[1] - item[3] / 2 for item in items) - BBS_MARGIN_MM max_x = max(item[1] + item[3] / 2 for item in items) + BBS_MARGIN_MM min_y = min(item[2] - item[4] / 2 for item in items) - BBS_MARGIN_MM max_y = max(item[2] + item[4] / 2 for item in items) + BBS_MARGIN_MM width, height = max_x - min_x, max_y - min_y # Groesste Objekte zuerst zeichnen, damit kleine Anbauteile (Separator, # Scanner, ...) sichtbar obenauf liegen statt unter einer Strecke/einem # Kreisel zu verschwinden. items.sort(key=lambda item: item[3] * item[4], reverse=True) page_w, page_h = DINA3_LANDSCAPE_MM if width >= height else DINA3_PORTRAIT_MM out: list[str] = [] add = out.append add('') add(f'') add(" ") add(f" ") add(f' ') for element, px, py, bw, bd in items: style = BBS_STYLES.get(element.kind, BBS_DEFAULT_STYLE) x = (px - bw / 2) - min_x y = max_y - (py + bd / 2) name = element.name or element.teile_id font_size = max(BBS_MIN_FONT_MM, min(bw, bd) * 0.28) add(f' ') add(f' {_xml_escape(name)}') # Anschlusspunkte K1-K4 zuletzt, damit sie ueber den Rechtecken liegen. if show_kx: half = KX_CROSS_MM / 2 for element, *_rest in items: # Anlage- in viewBox-Koordinaten (X verschoben, Y gespiegelt). pts = [(label, kx - min_x, max_y - ky) for label, (kx, ky) in element.kpoints] # Verbindungslinie K1->K2->K3->K4 (nur bei mindestens zwei Punkten). for (_, x1, y1), (_, x2, y2) in zip(pts, pts[1:]): add(f' ') for label, sx, sy in pts: add(f' ') add(f' ') add(f' {label}') add("") return "\n".join(out) + "\n" # --------------------------------------------------------------------------- # SVG erzeugen # --------------------------------------------------------------------------- def find_dot() -> str | None: env = os.environ.get("GRAPHVIZ_DOT") if env: return env if Path(env).is_file() else shutil.which(env) return shutil.which("dot") def find_engine(engine: str) -> str | None: """Layout-Programm von Graphviz suchen ('dot', 'neato', ...).""" if engine == "dot": return find_dot() return shutil.which(engine) def graph_to_svg( dot_file: Path, svg_file: Path, engine: str = "dot", no_op: int = 0 ) -> None: """ SVG aus der .dot-Datei erzeugen. engine Layout-Programm ('dot' fuer die berechnete Anordnung, 'neato' fuer vorgegebene Koordinaten) no_op nur fuer neato: 1 oder 2 setzt den No-op-Schalter (-n / -n2), dann werden die pos-Attribute der Knoten als Position uebernommen und nur die Kanten berechnet. Alle Knoten brauchen dann ein pos. """ executable = find_engine(engine) if not executable: raise RuntimeError( f"Graphviz '{engine}' nicht gefunden. Bitte Graphviz installieren " f"(https://graphviz.org/download/)" + (" oder GRAPHVIZ_DOT auf die dot-Programmdatei setzen." if engine == "dot" else ".") ) command = [executable] if no_op: command.append(f"-n{no_op}") command += ["-Tsvg", str(dot_file), "-o", str(svg_file)] result = subprocess.run(command, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError( f"'{engine}' ist mit Code {result.returncode} fehlgeschlagen:\n" f"{(result.stderr or result.stdout).strip()}" ) # 'neato -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: """SVG mit der berechneten Anordnung von 'dot' erzeugen.""" graph_to_svg(dot_file, svg_file, engine="dot") # --------------------------------------------------------------------------- # Plausibilitaet # --------------------------------------------------------------------------- def check_elements(graph: Graph, elements: list[Element]) -> list[str]: """ Merkmal-Stueckzahlen gegen die tatsaechlich vorhandenen Anbauteile pruefen. Rein objektbezogen - alles, was TROs betrifft, liegt in lib/tro_flow.py. """ findings: list[str] = [] counted: dict[str, dict[str, int]] = {} for node_id, node in graph.nodes.items(): for kind, ids in node.devices.items(): counted.setdefault(node_id, {})[kind] = len(ids) # Widerspruch zwischen Hoehenangabe und Antriebfahrtrichtung for element in elements: if not element.is_transport: continue rise, drive = element.rise, element.drive_dir.lower() if rise is None or rise == 0 or not drive: continue stated_up = drive.startswith("auf") if stated_up != (rise > 0): findings.append( f"{element.kind} {element.teile_id} '{element.name}': " f"Antriebfahrtrichtung '{element.drive_dir}' passt nicht zu " f"Hoehe_Von {element.height_from:.0f} -> Hoehe_Bis " f"{element.height_to:.0f} mm ({rise:+.0f}) - die Hoehenangabe gilt" ) for element in elements: if not (element.is_transport or element.is_circle): continue node_ids = ( [element.teile_id] if element.is_transport else [f"{element.teile_id}-{LANE_LEFT}", f"{element.teile_id}-{LANE_RIGHT}"] ) for label, keys in ( ("Separator", ("Anzahl_Separator", "Anzahl der Separatoren")), ("Scanner", ("Anzahl_Scanner", "Anzahl der Scanner")), ): expected = _as_float(_merkmal(element.merkmale, *keys)) if expected is None: continue actual = sum(counted.get(n, {}).get(label, 0) for n in node_ids) if int(expected) != actual: findings.append( f"{element.kind} {element.teile_id} '{element.name}': Merkmal " f"'{keys[0] if keys[0] in element.merkmale else keys[1]}' = " f"{int(expected)}, tatsaechlich {actual} {label}" ) findings.sort() return findings # --------------------------------------------------------------------------- # Dokumentation (Markdown) # --------------------------------------------------------------------------- GENERATED_MARKER = "" def render_doc( graph: Graph, findings: list[str], csv_file: Path, dot_file: Path, svg_file: Path | None, ) -> str: """Dokumentation des Materialflusses (nur Objekte, keine TROs).""" out: list[str] = [GENERATED_MARKER] add = out.append add(f"# Materialfluss - {csv_file.name}") add("") add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von " f"`lib/material_flow.py` ") add(f"**Quelle:** `{csv_file}`") add("") add("> Materialfluss der mechanischen Objekte. Die daraus abgeleiteten " "Steuerungsobjekte (TROs) dokumentiert `lib/tro_flow.py --doc`.") add("") kinds: dict[str, int] = {} for node in graph.nodes.values(): kinds[node.kind] = kinds.get(node.kind, 0) + 1 devices: dict[str, int] = {} for node in graph.nodes.values(): for kind, ids in node.devices.items(): devices[kind] = devices.get(kind, 0) + len(ids) flow = sum(1 for e in graph.edges if e.kind == EDGE_FLOW) circle = sum(1 for e in graph.edges if e.kind == EDGE_CIRCLE) open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED) add("## 1. Kurzfassung") add("") add("| | |") add("|---|---|") add(f"| Flussknoten | {len(graph.nodes)} " f"({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))}) |") add(f"| Flusskanten | {flow} gerichtet, {circle} Kreisel-Umlauf, " f"{open_dir} Richtung unbestimmt |") add(f"| Anbauteile | " f"{', '.join(f'{k}: {v}' for k, v in sorted(devices.items())) or '-'} |") add(f"| Plausibilitaetshinweise | {len(findings)} |") add("") add(f"Graph: [`{dot_file.name}`]({dot_file.name})" + (f" - [`{svg_file.name}`]({svg_file.name})" if svg_file else "")) add("") add("Ein **Kreisel** ist ein Umlauf und wird als zwei Knoten modelliert - eine " "Bahn je Fahrtrichtung (`-L` links, `-R` rechts), die zu einem Kreis " "verbunden sind.") add("") add("## 2. Anlagenobjekte") add("") add("| Knoten | Art | Bezeichnung | Planquadrat | Hoehe [m] | Separatoren | Scanner |") add("|---|---|---|---|---|---|---|") for node_id in sorted(graph.nodes): node = graph.nodes[node_id] height = f"{node.height:.3f}" if node.height is not None else "-" name = node.element.name logical = _merkmal(node.element.merkmale, "Name") if logical and str(logical).strip() and str(logical).strip() != name: name = f"{name} / {str(logical).strip()}" add(f"| `{node_id}` | {node.kind} | {name}" f"{f' (Bahn {node.lane})' if node.lane else ''} | " f"{node.element.plan_square or '-'} | {height} | " f"{', '.join(sorted(node.devices.get('Separator', []))) or '-'} | " f"{', '.join(sorted(node.devices.get('Scanner', []))) or '-'} |") add("") add("Bezeichnung: `Bezeichnung` aus dem CSV; wo das Merkmal `Name` abweicht, " "steht es dahinter (die beiden Nummerierungen laufen nicht zwangslaeufig " "gleich).") add("") add("Hoehe: bei Kreiseln aus dem Merkmal `Höhe in m`, bei Gefaellestrecke und " "Strecke aus `Montagehoehe_m` - unveraendert aus dem CSV uebernommen. Fuer " "die Richtungsableitung werden nur die Hoehen der **Nachbarn** verglichen, " "nie die des Transportobjekts selbst.") add("") if graph.unassigned: add("**Anbauteile ohne Zuordnung**") add("") for element in graph.unassigned: add(f"- {element.kind} {element.teile_id} '{element.name}' " f"(Zuordnung: `{element.assignment or '-'}`)") add("") add("## 3. Verbindungen") add("") add("| von | nach | Art |") add("|---|---|---|") kind_text = {EDGE_FLOW: "Materialfluss", EDGE_CIRCLE: "Kreisel-Umlauf", EDGE_UNRESOLVED: "Richtung unbestimmt"} for edge in graph.edges: note = f" ({edge.note})" if edge.note else "" add(f"| `{edge.src}` | `{edge.dst}` | " f"{kind_text.get(edge.kind, edge.kind)}{note} |") add("") add(f"Die Richtung innerhalb eines Kreisels kommt aus dem Merkmal " f"`Drehrichtung`: **{ROTATION_CW}** (im Uhrzeigersinn) laesst das Material " f"{ROTATION_FLOW[ROTATION_CW]} laufen, **{ROTATION_CCW}** (gegen den " f"Uhrzeigersinn) {ROTATION_FLOW[ROTATION_CCW]}.") add("") add("## 4. Plausibilitaetshinweise") add("") if findings: add("Abweichungen zwischen den Merkmalen des CSV-Exports und den " "tatsaechlich vorhandenen Objekten.") add("") for finding in findings: add(f"- {finding}") else: add("_Keine Abweichungen gefunden._") add("") add("## 5. Warnungen des Laufs") add("") if graph.warnings: for warning in graph.warnings: add(f"- {warning}") else: add("_Keine._") add("") return "\n".join(out) + "\n" # --------------------------------------------------------------------------- # Bericht # --------------------------------------------------------------------------- def report( graph: Graph, findings: list[str], csv_file: Path, dot_file: Path, svg_file: Path | None, doc_file: Path | None, stale_svg: Path | None = None, bbs_file: Path | None = None, track_dot_file: Path | None = None, track_svg_file: Path | None = None, ) -> None: kinds: dict[str, int] = {} for node in graph.nodes.values(): kinds[node.kind] = kinds.get(node.kind, 0) + 1 flow = sum(1 for e in graph.edges if e.kind == EDGE_FLOW) circle = sum(1 for e in graph.edges if e.kind == EDGE_CIRCLE) open_dir = sum(1 for e in graph.edges if e.kind == EDGE_UNRESOLVED) devices = 0 for node in graph.nodes.values(): devices += sum(len(ids) for ids in node.devices.values()) print("") print("================================================================") print("MATERIALFLUSS") print("================================================================") print(f"Eingabe = {csv_file}") print(f"Knoten = {len(graph.nodes)}" + (f" ({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))})" if kinds else "")) print(f"Kanten = {len(graph.edges)} (Fluss: {flow}, " f"Kreisel-Umlauf: {circle}, unbestimmt: {open_dir})") print(f"Anbauteile = {devices} an Knoten, " f"{len(graph.unassigned)} ohne Zuordnung") print(f"DOT = {dot_file}") print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}") print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}") print(f"BB-Draufsicht = {bbs_file if bbs_file else '- (ohne --show-bbs)'}") print(f"Bahnzusammenhang = {track_dot_file if track_dot_file else '- (ohne --track)'}") if track_dot_file: print(f" SVG = {track_svg_file if track_svg_file else '- (ohne --tosvg)'}") print("================================================================") if stale_svg is not None: print("") print(f"HINWEIS: {stale_svg}") print(" stammt aus einem frueheren Lauf und passt nicht zur neuen " "DOT-Datei.") print(" Mit --tosvg neu erzeugen.") if graph.unassigned: print("") print(f"Ohne Zuordnung ({len(graph.unassigned)}):") for element in graph.unassigned: print(f" - {element.kind} {element.teile_id} '{element.name}'") if findings: print("") print(f"Plausibilitaetshinweise ({len(findings)}):") for finding in findings: print(f" ? {finding}") if graph.warnings: print("") print(f"Warnungen ({len(graph.warnings)}):") for warning in graph.warnings: print(f" ! {warning}") print("") print("TRO-Liste und TRO-Flussdiagramm: bin/tro_flow.bat " f"--file {csv_file.name} --tosvg --doc") print("") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="material_flow", description="Erzeugt aus einem CSV-Export der Anlagenobjekte einen " "gerichteten Materialfluss-Graphen der mechanischen Objekte " "(DOT, optional SVG). Die TROs ermittelt tro_flow.py.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Beispiele:\n" " material_flow.bat --file mubea.csv\n" " material_flow.bat --file mubea.csv --tosvg\n" " material_flow.bat --file mubea.csv --tosvg --doc\n" " material_flow.bat --file HundM05.csv --show-bbs\n" " material_flow.bat --file mubea.csv --track --tosvg\n" "\n" "Exit-Codes:\n" " 0 ok\n" " 1 Eingabe- oder Aufruffehler\n" " 2 SVG konnte nicht erzeugt werden (Graphviz)\n", ) parser.add_argument( "--file", default="export.csv", metavar="NAME", help="Name der CSV-Eingabedatei in %%SKEL_DATA%% " "(oder ein vollstaendiger Pfad). Standard: %(default)s", ) parser.add_argument( "--tosvg", action="store_true", help="Aus der erzeugten DOT-Datei zusaetzlich eine SVG-Datei erzeugen " "(benoetigt Graphviz 'dot').", ) parser.add_argument( "--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.", ) parser.add_argument( "--show-bbs", dest="show_bbs", action="store_true", help="2D-Draufsicht der ganzen Szene als _bbs.svg nach %%SKEL_RESULTS%% " "schreiben: ein achsparalleles Rechteck je CSV-Zeile aus 'Position' " "(Mittelpunkt) und 'Boundingbox' (Breite/Tiefe), mit der Bezeichnung " "im Zentrum, skaliert auf DIN A3. Braucht kein Graphviz.", ) parser.add_argument( "--show-kx", dest="show_kx", action="store_true", help="Nur zusammen mit --show-bbs: je Objekt die Anschlusspunkte K1-K4 als " "kleines Kreuz mit Beschriftung einzeichnen und in der Reihenfolge " "K1->K2->K3->K4 mit einer blauen Linie verbinden.", ) parser.add_argument( "--track", action="store_true", help="Zusaetzlich einen feineren Bahnzusammenhang-Graph erzeugen " "(_track.dot, mit --tosvg auch _track.svg): Separator, " "Ausschleus-/Einschleuselement und Weiche werden zu eigenen Knoten " "(Verzweigung/Zusammenfuehrung/Kreuzung), statt nur wie im normalen " "Materialfluss Kanten/Zuordnungen an Kreisel/Strecke/Gefaellestrecke " "zu liefern. Siehe doc/track.md. Aendert den normalen Materialfluss-" "Output nicht.", ) 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 stem = csv_file.stem if args.show_kx and not args.show_bbs: print("FEHLER: --show-kx ist nur zusammen mit --show-bbs moeglich.", file=sys.stderr) return 1 bbs_file = None if args.show_bbs: bbs_target = results / f"{stem}_bbs.svg" try: bbs_target.write_text( render_bbs_svg(elements, csv_file.name, warnings, show_kx=args.show_kx), encoding="utf-8", ) except ValueError as exc: print(f"FEHLER: --show-bbs: {exc}", file=sys.stderr) return 1 bbs_file = bbs_target graph = build_graph(elements, csv_file.name, warnings) if not graph.nodes: print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte " f"({', '.join(TRANSPORT_KINDS + CIRCLE_KINDS)}).", file=sys.stderr) return 1 findings = check_elements(graph, elements) dot_file = results / f"{stem}_material_flow.dot" 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, use_coords=args.use_cords), encoding="utf-8") svg_error: str | None = None if args.tosvg: try: 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 doc_file = None if args.doc: doc_target.write_text( render_doc(graph, findings, csv_file, dot_file, created_svg), encoding="utf-8", ) doc_file = doc_target 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 track_dot_file = track_svg_file = None if args.track: by_id = {element.teile_id: element for element in elements} track = build_track_graph(elements, by_id, graph, warnings) track_dot_target = results / f"{stem}_track.dot" track_dot_target.write_text(render_track_dot(track), encoding="utf-8") track_dot_file = track_dot_target if args.tosvg: track_svg_target = results / f"{stem}_track.svg" try: dot_to_svg(track_dot_file, track_svg_target) track_svg_file = track_svg_target except RuntimeError as exc: print(f"FEHLER: Bahnzusammenhang-SVG nicht erzeugt: {exc}", file=sys.stderr) report( graph, findings, csv_file, dot_file, created_svg, doc_file, stale_svg=None if args.tosvg and svg_error is None else existing_svg, bbs_file=bbs_file, track_dot_file=track_dot_file, track_svg_file=track_svg_file, ) if svg_error: print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": sys.exit(main())