# -*- coding: utf-8 -*- # Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" # ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. """ tro_flow.py - TROs aus einem CSV-Export ermitteln und ihren Fluss zeichnen. Schwesterprogramm zu material_flow.py: material_flow.py Materialfluss der *mechanischen Objekte* (Kreisel, Gefaellestrecke, Strecke) tro_flow.py Fluss der *Steuerungsobjekte* (TROs) Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das Flussdiagramm der TROs, nicht das der Einzelobjekte. Zusaetzlich --use-cords: jeder TRO traegt die Koordinate seiner Bauteile, und das SVG wird mit "neato -n" lagerichtig gezeichnet statt von "dot" angeordnet. Der Graph sieht dann aus wie das Anlagenlayout. Was ein TRO ist --------------- Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer Uebergabestelle**. Ausgangspunkt ist der Separator (Vereinzeler/Sperre) und das, was an ihm haengt (Host-Strecke, Scanner). Ableitungsregeln nach doc/500573_Mubea/TRO_Identifikation_500573.md: Host des Separators -> TRO-Typ ----------------------------------------------------------------------- Gefaellestrecke, >= 2 Linien -> PinStore_Auto (ein Block je Gruppe) Gefaellestrecke, einzelne Linie -> 1Sep Strecke (angetrieben) -> Vario (je Separator, siehe unten) Kreisel-Bahn -> 1Sep Zwei Ausnahmen stufen einen Separator auf einer "Strecke" trotzdem als 1Sep statt Vario ein (Review 2026-09-23/25, HundM05): 1. Alternierendes Muster vom Wiedereinschleusen auf die Kreisel-Bahn rueckwaerts (in TrackIds-Reihenfolge, nur echte Kettenmitglieder - siehe "eigenstaendige Vereinzeler" unten): der letzte Separator vor dem Wiedereinschleusen ist ein reiner Vereinzeler ohne eigene VarioDrive- Strecke (die Kreisel-Bahn treibt ab dort selbst an), der vorletzte wieder ein eigener Vario-Antriebspunkt, der drittletzte wieder 1Sep usw. - eine Streckengruppe ist baulich meist eine alternierende VF/GF-Kette (angetriebener Abschnitt, Vereinzeler, unbetriebener Abschnitt, Vereinzeler, ...), nicht nur am Ende. Gilt fuer jede Separatoranzahl >= 2 (z. B. VF_3: erster Separator 0041 = Vario, letzter 0040 = 1Sep); bei genau einem Separator bleibt dieser Vario (einziger Antriebspunkt der Strecke). 2. Eine "Strecke" (Streckengruppe) ist selbst oft eine gemischte VF/GF-Kette: sie enthaelt sowohl angetriebene (VF, jede Antriebfahrtrichtung - horizontal, Auf oder Ab zaehlt gleichermassen als VF) als auch unbetriebene Gefaelle-Teilstuecke (GF) - siehe Merkmal "Antriebfahrtrichtung"/"VF_Winkel" je TrackIds-Segment. Ein laut Regel 1 als Vario erwarteter Separator, dessen unmittelbar vorangehendes Segment als "horizontal" (=keine aktive Antriebsrichtung) gefuehrt wird, dabei aber tatsaechlich an Hoehe verliert (Merkmal "Hoehe" der Anbauteile, > 30mm Gefaelle), sitzt auf einem GF-Teilstueck und wird ebenfalls 1Sep (siehe _incoming_drop) - eine unabhaengige Zusatzpruefung neben der Positions-Regel, kein Ersatz dafuer; ein laut Regel 1 bereits 1Sep bleibt 1Sep. Achtung: diese Hoehen-/ Antriebsangaben sind bei mehreren Streckengruppen desselben Typs (z. B. VF_2/VF_3/VF_4 bei HundM05) identisch und damit vermutlich unkorrigierte CAD-Blockvorgaben, nicht echte Einzelmessungen - Regel 1 (Position in der Kette) ist deshalb der robustere Nachweis, Regel 2 nur ein Zusatzfund fuer Faelle mit echten, individuellen Messwerten (wie VF_1). Separatoren, deren Zuordnung zwar auf eine Streckengruppe zeigt, die aber nicht Teil von deren eigener TrackIds-Kette sind, zaehlen bei beiden Regeln nicht mit - sie sind eigenstaendige Vereinzeler (plain 1Sep, siehe derive_tros Schritt 2) und treiben das alternierende Muster nicht mit. Ein Scanner am Separator aendert den Typ NICHT: in connect.ini der Referenzanlage sind Separatoren mit Scanner ueberwiegend 1Sep (z. B. "TRO104 = 1Sep | TRO 104 (1Sep)+Scanner"). 1Sep_SSCC ist dem Sonderfall mit SSCC-Scanner, Endmessung und WCS-Telegramm vorbehalten (nur TRO105/TRO110) und laesst sich aus dem mechanischen Layout nicht erkennen - es wird nur ein Hinweis ausgegeben. Zusaetzlich: hat ein Host 2 bzw. 3 abgehende Flusskanten, wird 1Sep zu 1Sep1Swi bzw. 1Sep2Swi aufgewertet (Weiche). Wo im Code der Typ entsteht --------------------------- Einstieg ist analyse_tros(); den TRO-Typ (DerivedTro.type_name) setzt aber ausschliesslich derive_tros(), Schritt fuer Schritt: Schritt Bauteil-Muster im CSV -> TRO-Typ ----------------------------------------------------------------------- 1 >= 2 Gefaellestrecken mit Separator, PinStore_Auto gemeinsamer Zu-/Ablauf (_group_lanes, _speicher_bloecke) 1 einzelne Gefaellestrecke mit Separator 1Sep 2 Separator in der TrackIds-Kette einer Vario / 1Sep Strecke (alternierendes Muster, (abwechselnd) _incoming_drop) 2 Separator mit Zuordnung auf eine Strecke, 1Sep aber nicht in deren TrackIds-Kette 3 Separator auf einer Kreisel-Bahn 1Sep 3b Separator vor einem Ausschleuselement 1Sep1Swi (_find_switch_separators, _weichen_*) 3b' derselbe Separator vor einem zweiten 1Sep2Swi Ausschleuselement ohne Separator dazwischen 3c Weichenknoten zwischen Kreiseln: n Separa- 1Sep1Swi, 2Sep1Swi, toren in Laufrichtung unmittelbar vor m 2Sep2Swi, 3Sep2Swi Beruehrpunkten derselben Umlenkung (_kreisel_knoten, lib/kreisel_junctions.py) 3c' Separator an einer ILS Weiche, die kein 1Sep1Swi Weichenknoten aus 3c ist (_find_weiche_separators) 3d 3b-TRO und 3c'-TRO direkt hintereinander 2Sep2Swi in der TrackIds-Kette eines Kreisels 4 1Sep mit 2 bzw. 3 abgehenden Flusskanten 1Sep1Swi / 1Sep2Swi Alle anderen Funktionen (link_tros, locate_tros, derive_boundaries, ...) aendern den Typ nicht mehr. cfg/tro_overrides.ini wird hier seit 2026-10-05 nicht mehr angewendet (siehe tro_overrides.py) - ein falsch erkannter Typ wird in derive_tros() korrigiert, nicht per INI. Nicht abgeleitet werden bisher: 1Sep_SSCC (nur Hinweis, Schritt 6), EmptyCarrBuffer und LoadingBoom. Schritt 3c braucht eine stimmige Drehrichtung beider Kreisel: laufen sie am Beruehrpunkt gegeneinander (Mubea, Dematic - vermutlich Exportfehler), bleibt es fuer diese ILS Weiche bei 3c' und 3d. Typen, FB-Bausteine, Bauteile und Farben kommen ausschliesslich aus lib/tro_catalog.py. Ausgabe (alles in SKEL_RESULTS) ------------------------------- _tro_flow.dot immer (mit --use-cords lagerichtige pos-Attribute) _tro_flow.svg mit --tosvg (dot, mit --use-cords neato -n) _tro_doc.md mit --doc _tro.json mit --json: TRO-JSON (model.TroDocument), Eingabe fuer scl_skeleton.py _tro_errors.md nur wenn TRO-Fehler gefunden wurden TRO-IDs (ID-Datei in SKEL_CFG) ------------------------------ Die IDs kommen aus %SKEL_CFG%/_tro_ids.ini (lib/tro_ids.py): jeder TRO wird ueber die Lage seiner Separatoren wiedergefunden und behaelt seine ID, auch wenn die SPS-Programmierer sie umbenannt haben. Fehlt die Datei, werden die TROs entlang des Materialflusses durchnummeriert und die Datei angelegt; neue TROs werden angehaengt. --renumber nummeriert alles neu. Aufruf ueber bin/tro_flow.bat bzw. bin/tro_flow.sh. """ from __future__ import annotations import argparse import csv import json import math import sys from collections.abc import Callable from datetime import datetime from pathlib import Path from material_flow import ( EDGE_CIRCLE, EDGE_FLOW, EDGE_UNRESOLVED, LANE_LEFT, LANE_RIGHT, ROTATION_CCW, ROTATION_CW, TRANSPORT_KINDS, UNASSIGNED, Element, Graph, Node, _as_float, _dot_escape, _dot_label, _merkmal, build_graph, env_dir, graph_to_svg, read_elements, resolve_input, ) from kreisel_junctions import Junction, find_junctions from model import Analysis, DerivedTro, TroDocument from parameter import get_float, get_int from tro_ids import ( DEFAULT_TOLERANCE, TroIds, assign_ids, ids_path, insert_entries, other_sections, render_ids, render_new_entries, ) from tro_catalog import ( CATALOG_AS_OF, ITEM_FB, STYLE_EXT, STYLES, TRO_CATALOG, TroItem, get_tro, mermaid_classdefs, ) # --------------------------------------------------------------------------- # Konstanten # --------------------------------------------------------------------------- # Zahlenwerte aus cfg/parameter.ini, Abschnitt [tro_flow] - Bedeutung und # Einheit stehen dort (lib/parameter.py). _P = "tro_flow" MIN_STORE_LANES = get_int(_P, "min_store_lanes") GEFAELLE_DROP_MM = get_float(_P, "gefaelle_drop_mm") COORD_SCALE = get_float(_P, "coord_scale") COORD_PFEIL_LAENGE = get_int(_P, "coord_pfeil_laenge") COORD_MAX_LUECKE = get_int(_P, "coord_max_luecke") LEGEND_MARGIN = get_int(_P, "legend_margin") LEGEND_DROP = get_int(_P, "legend_drop") GENERATED_MARKER = "" # --------------------------------------------------------------------------- # TRO-Ermittlung # --------------------------------------------------------------------------- def _group_lanes(lanes: list[str], graph: Graph) -> list[list[str]]: """ Schwerkraftlinien zu Speicherbloecken gruppieren. Linien, die sich einen Zu- oder Ablaufknoten teilen, gehoeren zum selben Speicher. So entstehen bei zwei getrennten Speichern auch zwei TROs. Parameter: lanes: Knoten-Ids der Gefaellestrecken, die einen Separator tragen (derive_tros Schritt 1). graph: Materialflussgraph aus material_flow.build_graph; nur graph.edges wird gelesen (Nachbarschaft der Linien). Rueckgabe: Liste von Gruppen, jede eine sortierte Liste von Linien-Ids; die Gruppen sind nach ihrer ersten Linie sortiert. Eine Gruppe mit >= MIN_STORE_LANES Linien wird in derive_tros zu PinStore_Auto. """ adjacency: dict[str, set[str]] = {} for lane in lanes: neighbours: set[str] = set() for edge in graph.edges: if edge.src == lane: neighbours.add(edge.dst) elif edge.dst == lane: neighbours.add(edge.src) adjacency[lane] = neighbours groups: list[dict] = [] for lane in sorted(lanes): current = {"lanes": [lane], "nodes": set(adjacency[lane]) | {lane}} for group in [g for g in groups if current["nodes"] & g["nodes"]]: current["lanes"].extend(group["lanes"]) current["nodes"] |= group["nodes"] groups.remove(group) groups.append(current) for group in groups: group["lanes"].sort() groups.sort(key=lambda g: g["lanes"][0]) return [group["lanes"] for group in groups] def _kreisel_bahn(sep: Element, elements: list[Element], graph: Graph) -> str | None: """Kreisel-Bahn-Knoten eines Separators, der in der Umlaufkette (Element.track_ids) eines Kreisels steht - sonst None. dxfmakros traegt bei Kreisel-Separatoren als "Zuordnung" oft die naechste Strecke statt der Bahn ein (Dematic/Arvato: 0043 -> VF_5, 0048 -> VF_4, 0046 -> Gefaellestrecke 0037; nur 0038/0047 stehen auf "0002-L"/"0003-R"). Bei einer Gefaellestrecke zaehlt er dann faelschlich als Speicherlinien- Separator (Aufrufer in derive_tros wendet es nur dort an). Die Bahn ergibt sich aus der Seite der Kreisel-Achse ("Drehung"): links der Achsrichtung L, rechts R - dieselbe Konvention wie die Zuordnungen, die der Export richtig setzt (0038 unterhalb von Kreisel2 mit Drehung 180 -> L, 0047 oberhalb von Kreisel3 -> R). Parameter: sep: der Separator (Element.kind "Separator"). elements: alle Elemente des CSV-Exports; durchsucht werden die Kreisel und ihre track_ids. graph: Materialflussgraph; die gefundene Bahn muss dort ein Knoten sein (z. B. "0002-L"). Rueckgabe: Knoten-Id der Bahn ("-L" bzw. "-R") oder None, wenn der Separator in keiner Kreiselkette steht, Position bzw. "Drehung" fehlen oder die Bahn kein Graphknoten ist. """ for k in elements: if k.kind != "Kreisel" or sep.teile_id not in k.track_ids: continue drehung = _as_float(_merkmal(k.merkmale, "Drehung")) if drehung is None or None in (sep.x, sep.y, k.x, k.y): return None rad = math.radians(drehung) kreuz = math.cos(rad) * (sep.y - k.y) - math.sin(rad) * (sep.x - k.x) bahn = f"{k.teile_id}-{LANE_LEFT if kreuz > 0 else LANE_RIGHT}" return bahn if bahn in graph.nodes else None return None def _weichen_nach_umlauf( switch_hits: dict[str, tuple[Element, float]], elements: list[Element], findings: list[str], ) -> dict[str, tuple[Element, float]]: """Weichen-Separatoren verwerfen, die in Laufrichtung NACH ihrem Ausschleuselement liegen. _find_switch_separators paart ueber die CSV-Nachbarn - die kennen aber keine Laufrichtung, und am Ring ist auch das erste mit dem letzten Element der TrackIds-Liste benachbart. Ein Separator steuert ein Ausschleuselement nur, wenn er der LETZTE Separator davor ist (Element. track_ids des Kreisels ist in Laufrichtung sortiert, bei gesetzter Drehrichtung ein geschlossener Ring). Dematic/Arvato (Review 2026-10-06): 0047 sitzt auf Kreisel3 am Kopf der Gegenbahn und war ueber den Ring- Schluss mit dem Ausschleuselement 0057 von VF_1 "benachbart" (TRO03 als Weiche in VF_1), 0046 liegt hinter dem letzten Ausschleuselement von Speicherblock C. Beide sind gewoehnliche Kreisel-Separatoren (Schritt 3). Parameter: switch_hits: Ergebnis von _find_switch_separators (Separator-Id -> (Ausschleuselement, Abstand mm)). elements: alle Elemente des CSV-Exports (Kreisel mit track_ids). findings: Fundliste; je verworfenem Paar wird ein Eintrag angehaengt. Rueckgabe: Gefiltertes switch_hits. Paare ausserhalb einer Kreiselkette bleiben ungeprueft erhalten. """ by_id = {e.teile_id: e for e in elements} kreisel = [e for e in elements if e.kind == "Kreisel" and e.track_ids] result: dict[str, tuple[Element, float]] = {} for sep_id, (transfer, distance) in switch_hits.items(): k = next((k for k in kreisel if transfer.teile_id in k.track_ids and sep_id in k.track_ids), None) if k is None: result[sep_id] = (transfer, distance) continue ids = list(k.track_ids) i = ids.index(transfer.teile_id) ring = k.rotation in (ROTATION_CW, ROTATION_CCW) davor = ids[:i][::-1] + (ids[i + 1:][::-1] if ring else []) vorgaenger = next((t for t in davor if (el := by_id.get(t)) and el.kind == "Separator"), None) if vorgaenger == sep_id: result[sep_id] = (transfer, distance) else: findings.append( f"Separator {sep_id}: CSV-Nachbar von Ausschleuselement " f"{transfer.teile_id}, liegt in Laufrichtung aber nicht davor " f"(letzter Separator davor: {vorgaenger or '-'}) - keine Weiche, " f"gewoehnlicher Kreisel-Separator") return result def _weichen_aus_umlauf( switch_hits: dict[str, tuple[Element, float]], kandidaten: list[Element], elements: list[Element], findings: list[str], ) -> dict[str, tuple[Element, float]]: """Weichen-Separatoren ergaenzen, die die CSV-Nachbarn nicht zeigen. Ein Separator, auf den in Laufrichtung ein Ausschleuselement folgt, ohne Separator dazwischen, steuert diese Weiche - auch wenn das Element nicht in seinen "Nachbarn" steht (dort stehen nur die unmittelbaren Ring- Nachbarn, oft ein Einschleuselement). Dematic/Arvato Kreisel1 (Review 2026-10-08): 0043 -> Ausschleuselement 0071 (VF_5), Nachbarn von 0043 sind nur die Einschleuselemente von VF_4/VF_6. Weitere Elemente bis zum naechsten Separator ergaenzt derive_tros Schritt 3b' (1Sep2Swi). Ausschleuselemente einer Gefaellestrecke bleiben aussen vor: das sind Speicherlinien, ihr Steuer-Separator entsteht in _speicher_bloecke nur aus der CSV-Nachbarschaft (Mubea 0044 bleibt eigener 1Sep vor dem Block). Parameter: switch_hits: bisherige Paare (Separator-Id -> (Ausschleuselement, Abstand mm)), meist aus _weichen_nach_umlauf. kandidaten: Separatoren, die ueberhaupt Weiche sein duerfen (in derive_tros: ohne die eigenen VF-Kettenseparatoren). elements: alle Elemente des CSV-Exports. findings: Fundliste; je ergaenztem Paar wird ein Eintrag angehaengt. Rueckgabe: Neues dict: switch_hits plus die ergaenzten Paare. Jedes Ausschleuselement gehoert hoechstens einem Separator. """ by_id = {e.teile_id: e for e in elements} erlaubt = {s.teile_id for s in kandidaten} belegt = {t.teile_id for t, _d in switch_hits.values()} result = dict(switch_hits) for kreisel in (e for e in elements if e.kind == "Kreisel" and e.track_ids): ids = list(kreisel.track_ids) ring = kreisel.rotation in (ROTATION_CW, ROTATION_CCW) for i, as_id in enumerate(ids): transfer = by_id.get(as_id) if transfer is None or transfer.kind != "Ausschleus" or as_id in belegt: continue strecke = by_id.get(str(_merkmal(transfer.merkmale, "Strecke", "Förderer") or "")) if strecke is not None and strecke.kind == "Gefaellestrecke": continue davor = ids[:i][::-1] + (ids[i + 1:][::-1] if ring else []) sep_id = next((t for t in davor if (el := by_id.get(t)) and el.kind == "Separator"), None) if sep_id is None or sep_id in result or sep_id not in erlaubt: continue sep = by_id[sep_id] d = (math.hypot(transfer.x - sep.x, transfer.y - sep.y) if None not in (sep.x, sep.y, transfer.x, transfer.y) else 0.0) result[sep_id] = (transfer, d) belegt.add(as_id) findings.append( f"Separator {sep_id}: steuert Ausschleuselement {as_id} (folgt in " f"Laufrichtung ohne Separator dazwischen, kein CSV-Nachbar) - Weiche") return result def _speicher_bloecke( lanes: list[str], elements: list[Element], switch_hits: dict[str, tuple[Element, float]], ) -> tuple[dict[str, tuple[str, int]], dict[str, tuple[str, int]]]: """Schwerkraftlinien je Kreisel in Speicherbloecke schneiden. _group_lanes fasst alle Linien mit gemeinsamem Zu-/Ablauf zusammen - an Dematic/Arvato haengen aber alle 27 Linien an denselben zwei Kreiseln und bilden doch DREI Bloecke (10/7/10), je von einem eigenen Separator auf der Kreisel-Bahn gesteuert, dazwischen kehren VF_1..VF_3 zurueck (Review 2026-10-06). Grenze ist darum die Umlaufreihenfolge (Element.track_ids): die Ausschleuselemente der Linien folgen dort lueckenlos aufeinander, ein Separator dazwischen beginnt einen neuen Block. Mubea Kreisel3 (20 Ausschleuselemente ohne Separator dazwischen) bleibt ein Block. Steuer-Separator eines Blocks ist der Weichen-Separator (switch_hits, _find_switch_separators), dessen Ausschleuselement zu einer Linie des Blocks gehoert: er steht auf der Kreisel-Bahn direkt davor und hat das erste Ausschleuselement des Blocks als CSV-Nachbarn (Dematic 0040 -> 0083). Er gehoert in den PinStore_Auto-TRO (Katalog: 1x Separator + Scanner + Encoder + Speicherlinien), nicht in einen eigenen 1Sep1Swi - das Ausschleuselement einer Speicherlinie ist keine Weiche einer Strecke. Mubea 0044 (TRO05) ist durch ein Einschleuselement vom Block getrennt, kein Nachbar eines Ausschleuselements und bleibt deshalb eigener 1Sep. Parameter: lanes: Knoten-Ids der Gefaellestrecken mit Separator. elements: alle Elemente des CSV-Exports (Ausschleuselemente mit Merkmal "Strecke"/"Foerderer", Kreisel mit track_ids). switch_hits: Weichen-Separatoren (Separator-Id -> (Ausschleuselement, Abstand mm)), siehe _find_switch_separators. Rueckgabe: (Linie -> Block, Steuer-Separator -> Block). Ein Block ist (Kreisel-Id, laufende Nummer ab 1). Linien ohne Ausschleuselement in einer Kreisel-Kette fehlen in der ersten Zuordnung. """ lane_set = set(lanes) by_id = {e.teile_id: e for e in elements} as_lane: dict[str, str] = {} for e in elements: if e.kind != "Ausschleus": continue strecke = str(_merkmal(e.merkmale, "Strecke", "Förderer") or "") if strecke in lane_set: as_lane[e.teile_id] = strecke lane_block: dict[str, tuple[str, int]] = {} as_block: dict[str, tuple[str, int]] = {} for kreisel in (e for e in elements if e.kind == "Kreisel"): ids = list(kreisel.track_ids) if not any(t in as_lane for t in ids): continue # Am ersten Separator beginnen, damit ein Block nicht ueber das # Listenende hinweg in zwei Teile zerfaellt (die Kette ist ein Ring). start = next((i for i, t in enumerate(ids) if (el := by_id.get(t)) and el.kind == "Separator"), 0) ids = ids[start:] + ids[:start] nummer, offen = 0, False for t in ids: el = by_id.get(t) if el is not None and el.kind == "Separator": offen = False elif t in as_lane: if not offen: nummer += 1 offen = True block = (kreisel.teile_id, nummer) as_block[t] = block lane_block.setdefault(as_lane[t], block) steuer: dict[str, tuple[str, int]] = {} for sep_id, (transfer, _d) in switch_hits.items(): block = as_block.get(transfer.teile_id) if block is not None: steuer[sep_id] = block return lane_block, steuer def _switch_exits(hosts: list[str], graph: Graph) -> list[str]: """Abgehende Flusskanten eines TRO-Hosts (ohne Kreisel-Umlauf). Parameter: hosts: Knoten-Ids, die zusammen den TRO tragen (DerivedTro.hosts). graph: Materialflussgraph; gezaehlt werden nur Kanten der Art EDGE_FLOW, die aus hosts hinaus fuehren. Rueckgabe: Sortierte, eindeutige Ziel-Knoten-Ids. 2 bzw. 3 Ziele werten einen 1Sep in derive_tros Schritt 4 zu 1Sep1Swi bzw. 1Sep2Swi auf. """ return sorted( { edge.dst for edge in graph.edges if edge.kind == EDGE_FLOW and edge.src in hosts and edge.dst not in hosts } ) def _default_resolve_host(sep: Element, findings: list[str], graph: Graph) -> str | None: """Host eines Separators aus der CSV-Spalte 'Zuordnung' (Default-Resolver). Parameter: sep: der Separator; gelesen wird sep.assignment (Merkmal "Zuordnung", z. B. "0002-L" oder die Id einer Strecke). findings: Fundliste; ist die Zuordnung leer oder kein Graphknoten, wird ein Eintrag angehaengt. graph: Materialflussgraph, gegen dessen Knoten geprueft wird. Rueckgabe: Knoten-Id des Hosts oder None (Separator bekommt dann keinen TRO). Die Art dieses Knotens (Kreisel/Strecke/Gefaellestrecke) entscheidet in derive_tros, in welchem Schritt der Separator landet. """ host = sep.assignment if host and host != UNASSIGNED and host in graph.nodes: return host findings.append( f"Separator {sep.teile_id} '{sep.name}': Zuordnung '{host or '-'}' " f"zeigt auf keinen Flussknoten - keinem TRO zugeordnet" ) return None def _find_switch_separators( separators: list[Element], elements: list[Element] ) -> dict[str, tuple[Element, float]]: """Separator-Id -> (Ausschleuselement der Weiche, Abstand mm). Ein Separator ist eine Weiche, wenn ihm ein ILS Ausschleuselement zugeordnet werden kann - das Element ist die Weiche (siehe unten), der Separator nur der Steuerpunkt davor. NICHT das Einschleuselement: das Wiedereinschleusen ist ein passiver Merge ohne Richtungsentscheidung, nur das Ausschleusen (Verlassen der Kreisel-Bahn) ist eine Weichenstelle - vor Ort bestaetigt (HundM05, Review 2026-09-21): ein Separator an einem Einschleuselement ist keine eigene Uebergabestelle. Ein eventueller Scanner am selben Separator ist ebenfalls kein Kriterium, sondern nur ein zusaetzliches Bauteil des TROs (siehe derive_tros/scanners_for) - viele Separatoren tragen einen Scanner, ohne eine Weiche zu sein. Frueher ueber eine reine Distanzschwelle ermittelt (<=500mm, mit einer grosszuegigeren Fallback-Schwelle bis 1500mm fuer weiter entfernte Faelle). Das erwies sich als nicht plausibel genug in beide Richtungen (Review 2026-09-25): die Fallback-Schwelle paarte an Mubea wiederholt voellig unbeteiligte Separatoren mit dem naechstgelegenen noch freien Ausschleuselement, ohne echten Bezug zwischen beiden - vor Ort geprueft, keine Weiche an der Stelle. Eine reine Nahdistanz ohne Fallback haette umgekehrt an HundM05 alle 5 echten Weichen verfehlt, die dort gleichmaessig bei 1013-1016mm liegen (Review 2026-09-18) - Distanz allein ist also weder an Mubea noch an HundM05 ein verlaessliches Kriterium. Stattdessen wird die im CSV-Export bereits vorhandene topologische Nachbarschaft verwendet (Spalte "Nachbarn", Element.neighbours): dxfmakros traegt dort den tatsaechlich anliegenden Nachbarn ein (CAD-Topologie), nicht nur den geometrisch naechsten - an HundM05 steht das echte Ausschleuselement trotz 1013-1016mm Abstand direkt in den Nachbarn des Schalt-Separators, an Mubea NICHT in den Nachbarn der zu Unrecht per Fallback getroffenen Separatoren. Ein Separator ist eine Weiche, wenn: 1. ein Ausschleuselement unter seinen Nachbarn steht, UND 2. dieses Ausschleuselement zur selben Kreisel-Bahn gehoert wie der Separator (gleiche "Zuordnung", z. B. beide auf Kreisel "0002" - Element.track_ids des betreffenden Kreisels enthaelt das Ausschleuselement). Das ist nicht nur eine Absicherung gegen einen ungenau exportierten "Nachbarn"-Eintrag, sondern noetig: ein Ausschleuselement markiert nicht nur den Kreisel->Strecke-Uebergang, sondern an einem PinStore_Auto-Speicherblock auch den Ablauf jeder einzelnen Schwerkraftlinie zum gemeinsamen Sammler - jeder Linien-Separator hat dort ein Ausschleuselement als CSV-Nachbarn, ohne damit eine echte Weiche zu sein (Review 2026-09-25, Mubea: 20 Speicherlinien faelschlich als 1Sep1Swi einsortiert, weil ihre Kreisel-Zuordnung fehlt und Kriterium 2 dafuer faelschlich nicht griff). Ist die Kreisel-Zuordnung des Separators nicht aufloesbar (er sitzt gar nicht auf einer Kreisel-Bahn), ist er deshalb KEIN Weichenkandidat - Kriterium 1 allein reicht nicht. Die Distanz im Rueckgabewert ist nur noch Beiwerk fuer die Doku ("XXXmm von Ausschleuselement YYYY entfernt"), kein Kriterium mehr. Parameter: separators: zu pruefende Separatoren (in derive_tros ohne die eigenen VF-Kettenseparatoren). elements: alle Elemente des CSV-Exports (Ausschleuselemente und Kreisel mit track_ids). Rueckgabe: Separator-Id -> (Ausschleuselement, Abstand mm); je Separator das erste passende Element aus seinen Nachbarn. Wird in derive_tros Schritt 3b zu 1Sep1Swi (oder zum Steuer-Separator eines PinStore_Auto, siehe _speicher_bloecke). """ transfers = {e.teile_id: e for e in elements if e.kind == "Ausschleus"} kreisel_by_id = {e.teile_id: e for e in elements if e.kind == "Kreisel"} result: dict[str, tuple[Element, float]] = {} for sep in separators: sep_kreisel_id = sep.assignment.split("-", 1)[0] if sep.assignment else "" kreisel = kreisel_by_id.get(sep_kreisel_id) # Zuordnung zeigt auf die Strecke statt auf den Kreisel, der Separator # steht aber in der TrackIds-Kette eines Kreisels (Mubea 0005: Zuordnung # VF_2, liegt auf Kreisel2 Bahn R direkt vor Ausschleuselement 0057) - # dann gilt dieser Kreisel. Die Gegenprobe "Ausschleuselement in # derselben Kette" (Kriterium 2) bleibt unveraendert. bahn_kreisel = [k for k in kreisel_by_id.values() if sep.teile_id in k.track_ids] if kreisel is not None: bahn_kreisel = [kreisel] if not bahn_kreisel: continue for base, _lane in sep.neighbours: transfer = transfers.get(base) if transfer is None: continue if not any(transfer.teile_id in k.track_ids for k in bahn_kreisel): continue d = ( math.hypot(transfer.x - sep.x, transfer.y - sep.y) if None not in (sep.x, sep.y, transfer.x, transfer.y) else 0.0 ) result[sep.teile_id] = (transfer, d) break return result def _find_weiche_separators( separators: list[Element], elements: list[Element], findings: list[str] ) -> dict[str, Element]: """Separator-Id -> ILS Weiche, die er steuert. Eine "ILS Weiche" (TeileArt, Element.kind "Weiche") ist ein ECHTES Weichen-Bauteil - anders als das Ausschleuselement in _find_switch_separators, das die Weichenrolle nur uebernimmt. dxfmakros erzeugt sie z. B. am Beruehrpunkt zweier Kreisel (Merkmal "Erzeugt aus": "Kreisel-Beruehrpunkt"; Mubea 0125 zwischen Kreisel2 und Kreisel1). Steuernder Separator ist der Separator unter den CSV-Nachbarn der Weiche, der einen Scanner traegt (Scanner-Merkmal "Naechster Separator"). Mubea 0125: Nachbarn 0040 und 0036, nur 0036 hat einen Scanner (0041) - vor Ort bestaetigt (Review 2026-08-12 und 2026-10-01). Ist nur ein Separator benachbart, gilt dieser. Bleibt es mehrdeutig, wird nichts geraten, sondern ein Finding geschrieben. Grenze: einer Weiche wird hoechstens EIN Separator zugeordnet. Weichen mit mehreren Separatoren (2Sep1Swi, 3Sep2Swi, ...) ermittelt vorher _kreisel_knoten aus Geometrie und Laufrichtung; hier kommen nur die ILS Weichen an, die dort keinen Knoten bilden. Parameter: separators: zu pruefende Separatoren (in derive_tros ohne die Ausschleus-Weichen-Separatoren aus switch_hits). elements: alle Elemente des CSV-Exports (ILS Weichen und Scanner). findings: Fundliste; je mehrdeutiger Weiche wird ein Eintrag angehaengt. Rueckgabe: Separator-Id -> ILS Weiche (Element.kind "Weiche"). Wird in derive_tros Schritt 3c' zu 1Sep1Swi. """ by_id = {s.teile_id: s for s in separators} mit_scanner = { str(_merkmal(e.merkmale, "Naechster Separator") or "").strip() for e in elements if e.kind == "Scanner" } result: dict[str, Element] = {} for weiche in (e for e in elements if e.kind == "Weiche"): kandidaten = sorted({base for base, _lane in weiche.neighbours if base in by_id}) gewaehlt = [s for s in kandidaten if s in mit_scanner] if len(gewaehlt) != 1: gewaehlt = kandidaten if len(kandidaten) == 1 else [] if not gewaehlt: findings.append( f"ILS Weiche {weiche.teile_id}: steuernder Separator nicht " f"eindeutig (benachbart: {', '.join(kandidaten) or '-'}) - " f"keinem TRO zugeordnet" ) continue result[gewaehlt[0]] = weiche return result def _kreisel_knoten( elements: list[Element], findings: list[str], frei: set[str] ) -> list[Junction]: """Weichenknoten zwischen Kreiseln, die einen eigenen TRO bilden. Geometrie und Steuerregel siehe lib/kreisel_junctions.py: ein Separator steuert einen Knoten, wenn er auf einem beteiligten Kreisel in Laufrichtung unmittelbar davor liegt (keine andere Weiche dazwischen). Parameter: elements: alle Elemente des CSV-Exports. findings: Fundliste; je verworfenem Knoten ein Eintrag. frei: Separator-Ids, die einen Knoten steuern duerfen. Steuert ein anderer Separator (Ausschleus-Weiche aus 3b, eigener VF- Kettenseparator) mit, bleibt der Knoten den bisherigen Regeln (3b, 3c', 3d) ueberlassen. Rueckgabe: Die Knoten, deren Typ (j.type_name) im Katalog steht und die nur von freien Separatoren gesteuert werden. """ result: list[Junction] = [] for j in find_junctions(elements, findings): weichen = ", ".join(c.weiche_id for c in j.contacts) if not j.separators: if any(not c.exported for c in j.contacts): findings.append( f"Weichenknoten Kreisel {'/'.join(j.kreisel)} ({weichen}): kein " f"Separator in Laufrichtung davor - kein TRO") continue if not set(j.separators) <= frei: continue if get_tro(j.type_name) is None: findings.append( f"Weichenknoten Kreisel {'/'.join(j.kreisel)} ({weichen}): " f"{len(j.separators)} Separatoren und {len(j.contacts)} Weichen - " f"Typ {j.type_name} fehlt im Katalog, kein TRO") continue result.append(j) return result def _incoming_drop( host: Element, sep_id: str, elements_by_id: dict[str, Element] ) -> tuple[str | None, float | None]: """Antriebfahrtrichtung und Hoehendifferenz (mm) des TrackIds-Segments, das unmittelbar vor sep_id in host.track_ids liegt. host ist eine "Strecke" (Streckengruppe); ihr Merkmal "Antriebfahrtrichtung" ist eine Kommaliste, ein Eintrag je Segment zwischen zwei aufeinander- folgenden TrackIds-Elementen (siehe derive_tros/Docstring-Tabelle). Fehlt sep_id in der Kette, ist es das erste Element (kein vorangehendes Segment) oder fehlt einem der beiden Enden das Merkmal "Hoehe" (z. B. Aus-/ Einschleuselemente tragen keins), liefert die Funktion (None, None) - der Aufrufer behandelt das als "kein Gefaelle-Nachweis", bleibt also bei Vario. Parameter: host: die Streckengruppe (Element.kind "Strecke") mit track_ids und drive_dir. sep_id: TeileId des Separators in host.track_ids. elements_by_id: TeileId -> Element, fuer die Hoehen der beiden Segmentenden. Rueckgabe: (Antriebfahrtrichtung des Segments, Hoehendifferenz in mm; negativ = Gefaelle). "horizontal" mit <= -GEFAELLE_DROP_MM stuft in derive_tros Schritt 2 einen Vario auf 1Sep herab. """ chain = host.track_ids if sep_id not in chain: return None, None idx = chain.index(sep_id) if idx == 0: return None, None prev = elements_by_id.get(chain[idx - 1]) cur = elements_by_id.get(sep_id) if prev is None or cur is None: return None, None prev_h = _as_float(_merkmal(prev.merkmale, "Höhe", "Hoehe")) cur_h = _as_float(_merkmal(cur.merkmale, "Höhe", "Hoehe")) if prev_h is None or cur_h is None: return None, None drive_list = [d.strip() for d in (host.drive_dir or "").split(",")] drive = drive_list[idx - 1] if idx - 1 < len(drive_list) else None return drive, cur_h - prev_h def find_sibling_raw_json(csv_path: Path) -> Path | None: """_raw.json neben der CSV, falls vorhanden. Optionale Roh-Attributsammlung des DXF-Exports (dxfmakros): eine flache Liste von Bloecken mit 'attribs' (u. a. 'ID') und bei Strecke/ Gefaellestrecke zusaetzlich einer 'sepliste' (siehe load_sepliste_counts). Kein Pflichtbestandteil des Exports - existiert die Datei nicht, liefert diese Funktion None und die Gegenprobe entfaellt einfach. """ candidate = csv_path.with_name(f"{csv_path.stem.lower()}_raw.json") return candidate if candidate.is_file() else None def load_sepliste_counts(raw_json_path: Path, warnings: list[str]) -> dict[str, int]: """Container-Id (Roh-JSON 'attribs.ID') -> Anzahl 'SEP'-Eintraege in dessen 'sepliste'. Reine Gegenprobe gegen die aus der CSV abgeleitete Separatorenzahl je Host (siehe derive_tros) - NICHT zur Rekonstruktion fehlender Separatoren gedacht. Zwei Merkmale des Roh-JSON erwiesen sich als unzuverlaessig und werden deshalb bewusst NICHT verwendet (Review 2026-09-24, HundM05): - Der jeweils ERSTE 'SEP'-Eintrag jedes VF-Blocks traegt durchgehend eine kaputte Position (x = -Block-x, y ein Fantasiewert) - ein Bug im DXF-Export, keine echte Platzierung. Die sepliste-IDs stammen ausserdem aus einem anderen Nummerierungslauf als die CSV und lassen sich nicht darauf abbilden - nur die ANZAHL der 'SEP'-Eintraege ist verwertbar, nicht ihre Position oder ihre Id. - Das Attribut "ANZAHL_SEPARATOR" auf dem Block selbst weicht in beiden bisher geprueften Anlagen sowohl von der TrackIds-Zaehlung als auch von der sepliste-Zaehlung ab (z. B. HundM05 VF_1: TrackIds/sepliste sagen 4, ANZAHL_SEPARATOR sagt 9) - vermutlich ein veraltetes/doppelt gezaehltes Katalog-Attribut, keine verlaessliche Quelle. Bei fehlender oder kaputter Datei (z. B. ein noch unvollstaendig geschriebenes *_raw.json) wird ein Warnhinweis angehaengt und ein leeres dict geliefert - die Gegenprobe entfaellt dann einfach, ohne den Lauf abzubrechen. """ try: raw = json.loads(raw_json_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: warnings.append( f"{raw_json_path.name}: nicht lesbar/kein gueltiges JSON ({exc}) - " f"sepliste-Gegenprobe uebersprungen" ) return {} counts: dict[str, int] = {} for item in raw if isinstance(raw, list) else []: container_id = str((item.get("attribs") or {}).get("ID") or "") sepliste = item.get("sepliste") if not container_id or not sepliste: continue counts[container_id] = sum(1 for s in sepliste if s.get("typ") == "SEP") return counts def derive_tros( graph: Graph, elements: list[Element], findings: list[str], *, sepliste_counts: dict[str, int] | None = None, tro_ids: TroIds | None = None, ) -> list[DerivedTro]: """TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden. **Hier - und nur hier - wird der TRO-Typ bestimmt** (DerivedTro.type_name, Uebersicht im Modul-Docstring "Wo im Code der Typ entsteht"). Vorbereitung (vor den Schritten): a) Weichen-Separatoren: _find_switch_separators -> _weichen_nach_umlauf -> _weichen_aus_umlauf (Ausschleuselemente), _kreisel_knoten (Weichenknoten zwischen Kreiseln) und _find_weiche_separators (uebrige ILS Weichen). Diese Separatoren bekommen keinen Host aus der Zuordnung. b) Uebrige Separatoren -> Host-Knoten (_default_resolve_host, bei Gefaellestrecken ggf. korrigiert durch _kreisel_bahn). c) Scanner -> Separator ueber das Merkmal "Naechster Separator". Schritte (setzen type_name): 1 Gefaellestrecken: PinStore_Auto (>= MIN_STORE_LANES Linien) bzw. 1Sep (einzelne Linie). 2 Strecken: je Separator Vario oder 1Sep (alternierendes Muster, Gefaelle-Pruefung ueber _incoming_drop). 3 Kreisel-Bahnen: je Separator 1Sep. 3b Separator vor Ausschleuselement: 1Sep1Swi. 3b' zweites Ausschleuselement desselben Separators: 1Sep2Swi. 3c Weichenknoten zwischen Kreiseln (_kreisel_knoten): Typ aus der Zahl der Separatoren unmittelbar davor und der Weichen, z. B. 2Sep1Swi, 3Sep2Swi. 3c' Separator an einer uebrigen ILS Weiche: 1Sep1Swi. 3d 3b- und 3c'-TRO direkt hintereinander am Kreisel: 2Sep2Swi. 4 1Sep mit 2/3 abgehenden Flusskanten: 1Sep1Swi/1Sep2Swi. 5 Sortieren entlang des Materialflusses, IDs vergeben. 6 Hinweis bei 1Sep mit Scanner (evtl. 1Sep_SSCC, Typ bleibt). Parameter: graph: Materialflussgraph aus material_flow.build_graph (Knoten = Kreisel-Bahnen "-L/-R", Strecken, Gefaellestrecken, Stationen; Kanten = Materialfluss). elements: alle Elemente des CSV-Exports (material_flow.read_elements); Separatoren, Scanner, Aus-/Einschleuselemente, ILS Weichen und Kreisel werden daraus gelesen. findings: Fundliste, an die jeder Schritt Hinweise anhaengt (wird veraendert, nicht ersetzt). sepliste_counts: optional, siehe load_sepliste_counts: Container-Id -> Anzahl 'SEP'-Eintraege aus dem Roh-JSON. Reine Gegenprobe - stimmt die aus der CSV abgeleitete Separatorenzahl eines Strecke/Gefaellestrecke-Hosts nicht mit dieser Zahl ueberein, wird ein Finding angehaengt; die TRO-Daten selbst bleiben unangetastet (CSV bleibt die massgebliche Quelle). tro_ids: optional, ID-Datei der Anlage. Ohne sie werden die TROs entlang des Materialflusses neu durchnummeriert; mit ihr behalten wiedergefundene TROs ihre ID (Ergebnis in tro_ids.result). Rueckgabe: Die TROs in Materialfluss-Reihenfolge, mit tro_id, type_name, label, hosts, separators, scanners, reason und confidence. Position, Vorgaenger/Nachfolger fuellt erst analyse_tros (locate_tros, link_tros). """ def resolve_host(sep: Element, findings: list[str]) -> str | None: """Host-Knoten eines Separators (siehe _default_resolve_host).""" return _default_resolve_host(sep, findings, graph) def count_exits(hosts: list[str]) -> list[str]: """Abgehende Flussziele der Hosts (siehe _switch_exits).""" return _switch_exits(hosts, graph) separators = sorted( (e for e in elements if e.kind == "Separator"), key=lambda e: e.teile_id ) scanners = sorted( (e for e in elements if e.kind == "Scanner"), key=lambda e: e.teile_id ) elements_by_id = {e.teile_id: e for e in elements} # Separatoren, die bereits ein echtes VF-Kettenmitglied ihrer EIGENEN # Zuordnung-Streckengruppe sind (siehe Schritt 2/_incoming_drop unten), # werden von der Weichenerkennung ausgenommen: Naehe zum eigenen Aus-/ # Einschleuselement ist bei ihnen der NORMALE, erwartete Aufbau jeder # angetriebenen Streckengruppe (TrackIds = [Aus-, 1x Separator, Ein- # schleuselement], siehe Mubea VF_1-VF_3), keine Weichenevidenz. Ohne # diesen Ausschluss faengt Regel 1 faelschlich genau diesen Separator ein # (Mubea 0005, 163mm - der engste, staerkste Treffer ueberhaupt, aber kein # Weichen-Indiz - Review 2026-09-24). own_vf_separators = { sep.teile_id for sep in separators if sep.assignment and (host := elements_by_id.get(sep.assignment)) and host.kind == "Strecke" and sep.teile_id in host.track_ids } switch_candidates = [s for s in separators if s.teile_id not in own_vf_separators] # Separatoren an einem Ausschleuselement (siehe _find_switch_separators) # werden VOR der normalen Host-Aufloesung herausgenommen: sie bilden gleich # unten (3b) ihre eigene 1Sep1Swi-Uebergabestelle und duerfen nicht zugleich # als gewoehnlicher Separator ihres Zuordnung-Hosts (Kreisel-Bahn oder # Strecke - beides kommt vor) mitgezaehlt werden. switch_hits = _weichen_aus_umlauf( _weichen_nach_umlauf( _find_switch_separators(switch_candidates, elements), elements, findings), switch_candidates, elements, findings) # Weichenknoten zwischen Kreiseln aus Geometrie und Laufrichtung (siehe # _kreisel_knoten, Schritt 3c) - ihre Separatoren und Weichen sind damit # vergeben und laufen nicht mehr durch _find_weiche_separators. knoten = _kreisel_knoten( elements, findings, frei={s.teile_id for s in switch_candidates} - set(switch_hits)) knoten_seps = {s for j in knoten for s in j.separators} knoten_weichen = {c.weiche_id for j in knoten for c in j.contacts} # Separatoren an einer echten ILS Weiche (siehe _find_weiche_separators) - # wie switch_hits vor der Host-Aufloesung herausgenommen, Schritt 3c'. weiche_hits = _find_weiche_separators( [s for s in switch_candidates if s.teile_id not in switch_hits and s.teile_id not in knoten_seps], [e for e in elements if e.teile_id not in knoten_weichen], findings, ) # Separator -> Host-Knoten sep_host: dict[str, str] = {} for sep in separators: if (sep.teile_id in switch_hits or sep.teile_id in weiche_hits or sep.teile_id in knoten_seps): continue host = resolve_host(sep, findings) # Steht er in der Umlaufkette eines Kreisels, zeigt die Zuordnung aber # auf eine Gefaellestrecke, ist das nur die naechstgelegene Linie # (siehe _kreisel_bahn) - sonst zaehlte er als Speicherlinien- # Separator (Dematic 0046 in Speicherblock C). Bewusst NUR fuer # Gefaellestrecken: Kreisel-Separatoren mit einer VF-Strecke als # Zuordnung bleiben wie bisher (HundM05 0018 laut Review 2026-09-21 # Teil seiner Strecke; ein Umhaengen nummerierte HundM05/Mubea um). if host is not None and graph.nodes[host].kind == "Gefaellestrecke": bahn = _kreisel_bahn(sep, elements, graph) if bahn is not None: host = bahn if host is not None: sep_host[sep.teile_id] = host # Scanner -> Separator; "Naechster Separator" ist die funktionale Zuordnung, # "Zuordnung" nur der Montageort. sep_scanners: dict[str, list[str]] = {} for scanner in scanners: target = str(_merkmal(scanner.merkmale, "Naechster Separator") or "").strip() if (target in sep_host or target in switch_hits or target in weiche_hits or target in knoten_seps): sep_scanners.setdefault(target, []).append(scanner.teile_id) mounted = scanner.assignment target_host = sep_host.get(target) # Fuer Switch-Separatoren (3b) gibt es keinen einzelnen Host zum # Vergleichen (sie haengen zwischen zwei Ketten) - der Abgleich # entfaellt dort, ohne den Scanner deshalb als "unbekannt" zu werten. if (mounted and mounted != UNASSIGNED and target_host is not None and mounted != target_host): findings.append( f"Scanner {scanner.teile_id} '{scanner.name}': montiert an {mounted}, " f"liest aber an Separator {target} auf {target_host}" ) else: findings.append( f"Scanner {scanner.teile_id} '{scanner.name}': 'Naechster Separator' " f"'{target or '-'}' unbekannt - keinem TRO zugeordnet" ) def seps_on(hosts: list[str]) -> list[str]: """Sortierte Separator-Ids, deren Host in hosts liegt.""" return sorted(s for s, h in sep_host.items() if h in hosts) def scanners_for(seps: list[str]) -> list[str]: """Sortierte Scanner-Ids, die an einem der Separatoren lesen.""" found: list[str] = [] for sep in seps: found.extend(sep_scanners.get(sep, [])) return sorted(found) # Sepliste-Gegenprobe (siehe load_sepliste_counts/Docstring oben): reine # Beobachtung, veraendert weder sep_host noch die spaeter abgeleiteten # TROs - die CSV bleibt die massgebliche Quelle. if sepliste_counts: for node_id, node in sorted(graph.nodes.items()): if node.kind not in ("Strecke", "Gefaellestrecke"): continue expected = sepliste_counts.get(node_id) if expected is None: continue actual = len(seps_on([node_id])) if actual != expected: findings.append( f"{node.element.name} ({node_id}): {actual} Separator(en) aus " f"der CSV zugeordnet, aber {expected} 'SEP'-Eintraege in " f"sepliste (Roh-JSON) - Gegenprobe weicht ab, bitte pruefen" ) tros: list[DerivedTro] = [] # 1) Schwerkraftlinien (Gefaellestrecke mit Separator) -> Speicherblock lanes = sorted( {h for h in sep_host.values() if graph.nodes[h].kind == "Gefaellestrecke"} ) # Speicherbloecke nach Umlaufreihenfolge und Steuer-Separator schneiden # (siehe _speicher_bloecke). Steuer-Separatoren eines gebildeten Speichers # bekommen in 3b keinen eigenen 1Sep1Swi mehr. lane_block, steuer_block = _speicher_bloecke(lanes, elements, switch_hits) speicher_steuer: set[str] = set() teilgruppen: list[list[str]] = [] for group in _group_lanes(lanes, graph): nach_block: dict = {} for lane in group: nach_block.setdefault(lane_block.get(lane), []).append(lane) teilgruppen.extend(sorted(nach_block.values(), key=lambda g: g[0])) for group in teilgruppen: seps = seps_on(group) if len(group) >= MIN_STORE_LANES: bloecke = {lane_block.get(lane) for lane in group} - {None} steuer = sorted(s for s, b in steuer_block.items() if b in bloecke) speicher_steuer.update(steuer) alle_seps = steuer + seps tros.append( DerivedTro( tro_id="", type_name="PinStore_Auto", label=f"Speicher ({len(group)} Schwerkraftlinien)", hosts=group, separators=alle_seps, scanners=scanners_for(alle_seps), reason=( f"{len(group)} Gefaellestrecken mit je eigenem Separator, " f"gemeinsamer Zu-/Ablauf - Muster eines Linienspeichers" + (f"; Steuer-Separator {', '.join(steuer)} auf der " f"Kreisel-Bahn davor" if steuer else "") ), confidence="hoch", ) ) else: for lane in group: lane_seps = seps_on([lane]) tros.append( DerivedTro( tro_id="", type_name="1Sep", label=graph.nodes[lane].element.name, hosts=[lane], separators=lane_seps, scanners=scanners_for(lane_seps), reason="einzelne Gefaellestrecke mit Separator (kein Linienspeicher)", confidence="mittel", ) ) # 2) Angetriebene Strecken -> je SEPARATOR ein eigener Vario-TRO. Katalog # (TRO_CATALOG in tro_catalog.py) definiert Vario als genau 1x Separator + # 1x VarioDrive - ein Vario-TRO ist die Paarung eines einzelnen VF- # Teilstuecks mit dem einen Separator, den es antreibt, kein Sammel-TRO # fuer die ganze Streckengruppe (Review 2026-09-21/24). Mehrere # Separatoren auf demselben Streckensegment sind deshalb mehrere # eigenstaendige Uebergabestellen. Ein Segment ganz ohne Separator bildet # folgerichtig KEIN eigenes TRO (kein Separator zum Paaren) - anders als # bisher wird es nicht mehr als Platzhalter-Vario mit leerer # Separatorliste gefuehrt. for node_id in sorted(n for n, node in graph.nodes.items() if node.kind == "Strecke"): seps = seps_on([node_id]) if not seps: continue element = graph.nodes[node_id].element drive = element.drive_dir or "unbekannt" reason = f"angetriebenes Streckensegment (Antriebfahrtrichtung: {drive})" # Alternierendes Muster vom Wiedereinschleusen auf die Kreisel-Bahn # rueckwaerts (in echter TrackIds-Reihenfolge, nicht sortierte # Teile-Id): eine Streckengruppe ist baulich meist eine alternierende # VF/GF-Kette, deshalb wechselt auch der TRO-Typ ab dem letzten # Separator (naechster Nachbar der Kreisel-Bahn, reiner Vereinzeler # ohne eigene VarioDrive-Strecke - die Kreisel-Bahn treibt ab dort # selbst an) rueckwaerts durch: letzter = 1Sep, davor Vario, davor # wieder 1Sep usw. Bestaetigt am Beispiel VF_3 (Review 2026-09-23): # TRO11 (erster Separator 0041, direkt nach dem Ausschleusen) ist # Vario, TRO10 (zweiter/letzter Separator 0040, vor dem # Wiedereinschleusen) ist 1Sep; siehe auch VF_1 (0034, letzter von # vieren). Fuer genau 2 Separatoren identisch zur frueheren "nur der # letzte wird umgestuft"-Regel; ab 3 weicht es ab (Review 2026-09-25) # - vorher blieben alle bis auf den letzten pauschal Vario, obwohl die # Kette baulich mehrfach zwischen VF und GF wechselt. Bei genau einem # Separator gibt es kein "erster vs. letzter", der bleibt Vario # (einziger Antriebspunkt der Strecke). seps_set = set(seps) track_seps = [tid for tid in element.track_ids if tid in seps_set] track_seps_set = set(track_seps) alt_type: dict[str, str] = {} alt_offset: dict[str, int] = {} if len(track_seps) > 1: for offset, tid in enumerate(reversed(track_seps)): alt_offset[tid] = offset alt_type[tid] = "1Sep" if offset % 2 == 0 else "Vario" for sep_id in seps: if sep_id not in track_seps_set: # Zuordnung zeigt zwar auf diese Streckengruppe, der Separator # ist aber nicht Teil von deren eigener TrackIds-Kette - kein # VF-Teilstueck treibt ihn an, also plain 1Sep statt Vario # (Mubea VF_1-VF_3 vor Ort bestaetigt, Review 2026-09-24: pro # Streckengruppe genau 1 echter Kettenseparator, alle # uebrigen Zuordnung-Treffer sind eigenstaendige Vereinzeler). tros.append( DerivedTro( tro_id="", type_name="1Sep", label=element.name, hosts=[node_id], separators=[sep_id], scanners=scanners_for([sep_id]), reason=( f"Separator {sep_id}: Zuordnung zeigt auf " f"{element.name}, ist aber nicht Teil von dessen " f"TrackIds-Kette - kein eigenes VF treibt ihn an" ), confidence="mittel", ) ) findings.append( f"{element.name} Separator {sep_id}: Zuordnung ohne " f"TrackIds-Mitgliedschaft - als 1Sep statt Vario eingestuft" ) continue type_name = alt_type.get(sep_id, "Vario") sep_reason = reason confidence = "hoch" if type_name == "1Sep": confidence = "mittel" sep_reason = ( f"alternierendes VF/GF-Muster vom Wiedereinschleusen von " f"{element.name} rueckwaerts ({alt_offset[sep_id] + 1}. " f"Separator von hinten) - unbetriebener Abschnitt ohne " f"eigenes VarioDrive (Review 2026-09-23/25)" ) findings.append( f"{element.name} Separator {sep_id}: alternierendes Muster " f"({alt_offset[sep_id] + 1}. von hinten) - als 1Sep statt " f"Vario eingestuft" ) # Hoehen-/Antriebsdaten des vorangehenden Segments (Strecke/ # Gefaellestrecke) bleiben eine unabhaengige Zusatzpruefung (siehe # Docstring oben, Regel 2): sie kann eine laut Muster als Vario # erwartete Position auf 1Sep umstufen, wenn dort tatsaechlich ein # unbetriebenes Gefaelle gemessen wurde - nicht umgekehrt, ein # bereits per Muster bestimmtes 1Sep bleibt 1Sep. if type_name == "Vario": seg_drive, seg_drop = _incoming_drop(element, sep_id, elements_by_id) if seg_drive == "horizontal" and seg_drop is not None and seg_drop <= -GEFAELLE_DROP_MM: type_name = "1Sep" confidence = "mittel" sep_reason = ( f"Separator {sep_id} liegt auf einem unbetriebenen " f"Gefaelle-Teilstueck der Streckengruppe {element.name} " f"(Hoehe faellt um {-seg_drop:.0f}mm, keine aktive " f"Antriebfahrtrichtung) - kein Vario trotz Host 'Strecke' " f"und trotz alternierendem Muster" ) findings.append( f"{element.name} Separator {sep_id}: {-seg_drop:.0f}mm Gefaelle " f"ohne Antrieb auf dem vorangehenden Segment - als 1Sep statt " f"Vario eingestuft (widerspricht alternierendem Muster)" ) tros.append( DerivedTro( tro_id="", type_name=type_name, label=element.name, hosts=[node_id], separators=[sep_id], scanners=scanners_for([sep_id]), reason=sep_reason, confidence=confidence, ) ) # 3) Separatoren auf Kreisel-Bahnen -> je Separator eine Uebergabestelle for sep_id in sorted( s for s, h in sep_host.items() if graph.nodes[h].kind == "Kreisel" ): host = sep_host[sep_id] own_scanners = sorted(sep_scanners.get(sep_id, [])) node = graph.nodes[host] lane_name = _merkmal(node.element.merkmale, "Name") or node.element.name tros.append( DerivedTro( tro_id="", type_name="1Sep", label=f"Separator {sep_id} auf {lane_name} Bahn {node.lane}", hosts=[host], separators=[sep_id], scanners=own_scanners, reason=( "Separator mit Scanner auf angetriebener Kreisel-Bahn" if own_scanners else "einzelner Separator auf angetriebener Kreisel-Bahn" ), confidence="mittel", ) ) # 3b) Separatoren an einem Ausschleuselement -> eigene 1Sep1Swi- # Uebergabestelle. Das Element uebernimmt die Rolle der (hier fehlenden) # Weiche; siehe _find_switch_separators. Host bewusst die Strecke (nicht # die Kreisel-Bahn): beide Elementarten tragen "Strecke" oder "Foerderer" # im Merkmal (beide Schreibweisen kommen nebeneinander im selben Export # vor), und die Strecke hat bereits reale, gerichtete Kanten zum Kreisel # (_apply_transfers) - darueber haengt sich die Verknuepfung (link_tros) # von selbst ein, ohne einen weiteren synthetischen Host zu erfinden. as_switch_tro: dict[str, DerivedTro] = {} # Ausschleuselement-Id -> 1Sep1Swi for sep_id, (transfer, distance) in sorted(switch_hits.items()): if sep_id in speicher_steuer: continue # Teil seines Speichers (Schritt 1) strecke_id = str(_merkmal(transfer.merkmale, "Strecke", "Förderer") or "") ende = str(_merkmal(transfer.merkmale, "Streckenende") or "") own_scanners = sorted(sep_scanners.get(sep_id, [])) art = "Ausschleuselement" as_switch_tro[transfer.teile_id] = tro = ( DerivedTro( tro_id="", type_name="1Sep1Swi", label=f"Separator {sep_id} an {art} {transfer.teile_id} " f"(Strecke {strecke_id}, {ende})", hosts=[strecke_id] if strecke_id in graph.nodes else [transfer.teile_id], separators=[sep_id], scanners=own_scanners, reason=( f"{distance:.0f}mm von {art} {transfer.teile_id} entfernt - " f"kein eigenes Weichen-Bauteil im Layout, das {art} " f"uebernimmt diese Rolle" ), confidence="mittel", ) ) tros.append(tro) # 3b') Weitere Weichen desselben Separators. Ein Separator steuert ALLE # Ausschleuselemente, die in Laufrichtung bis zum naechsten Separator auf # ihn folgen - nicht nur das eine, das in seinen CSV-Nachbarn steht # (_find_switch_separators). Dematic/Arvato Kreisel4 (GUZ): 0048 -> # Ausschleuselement 0069 (VF_4) -> Ausschleuselement 0077 (VF_6); 0077 ist # kein CSV-Nachbar von 0048, wird aber von ihm gesteuert (Review # 2026-10-07). Mit zwei Weichen wird der TRO zum 1Sep2Swi (Katalog: 1x # Separator, 2x Switch, 1x Scanner). Ausschleuselemente von Speicherlinien # gehoeren dem PinStore_Auto (Schritt 1) und zaehlen hier nicht. speicher_hosts = {h for t in tros if t.type_name == "PinStore_Auto" for h in t.hosts} sep_switch_tro = {t.separators[0]: t for t in as_switch_tro.values() if t.separators} weichen_je_tro: dict[int, list[str]] = { id(t): [a for a, tt in as_switch_tro.items() if tt is t] for t in as_switch_tro.values()} for kreisel in (e for e in elements if e.kind == "Kreisel" and e.track_ids): ids = list(kreisel.track_ids) ring = kreisel.rotation in (ROTATION_CW, ROTATION_CCW) for i, as_id in enumerate(ids): transfer = elements_by_id.get(as_id) if transfer is None or transfer.kind != "Ausschleus" or as_id in as_switch_tro: continue strecke_id = str(_merkmal(transfer.merkmale, "Strecke", "Förderer") or "") if strecke_id in speicher_hosts: continue davor = ids[:i][::-1] + (ids[i + 1:][::-1] if ring else []) steuer = next((t for t in davor if (el := elements_by_id.get(t)) and el.kind == "Separator"), None) tro = sep_switch_tro.get(steuer or "") if tro is None: continue as_switch_tro[as_id] = tro weichen = weichen_je_tro[id(tro)] weichen.append(as_id) if strecke_id in graph.nodes and strecke_id not in tro.hosts: tro.hosts.append(strecke_id) tro.label = (f"Separator {steuer} an Ausschleuselementen " f"{', '.join(weichen)} (Strecken {', '.join(tro.hosts)})") tro.reason += (f"; steuert auch Ausschleuselement {as_id} - folgt in " f"Laufrichtung ohne Separator dazwischen") if len(weichen) == 2: tro.type_name = "1Sep2Swi" else: findings.append( f"Separator {steuer}: {len(weichen)} Ausschleuselemente " f"({', '.join(weichen)}) ohne Separator dazwischen - der " f"Katalog kennt hoechstens 1Sep2Swi, bitte pruefen") for tro in sep_switch_tro.values(): if tro.type_name == "1Sep2Swi" and not tro.scanners: findings.append( f"{tro.label}: 1Sep2Swi ohne Scanner - der Katalog verlangt einen " f"Barcode-Scanner am Eingang, im Layout fehlt einer vor Separator " f"{tro.separators[0]}") # 3c) Weichenknoten zwischen Kreiseln -> nSepmSwi (siehe _kreisel_knoten): # n = Separatoren, die in Laufrichtung unmittelbar vor dem Knoten liegen, # m = Weichen (Beruehrpunkte) des Knotens. Host je Separator ist die Bahn, # auf der er physisch liegt - nicht die Zuordnung (export.csv 0015: # Zuordnung 0003-L, liegt aber auf Kreisel 0002 Bahn R). for j in knoten: weichen = [c.weiche_id for c in j.contacts] vor = ", ".join(f"{s} ({j.sep_lane[s]})" for s in j.separators) hosts = list(dict.fromkeys(j.sep_lane[s] for s in j.separators if j.sep_lane[s] in graph.nodes)) tros.append(DerivedTro( tro_id="", type_name=j.type_name, label=(f"Weichenknoten Kreisel {'/'.join(j.kreisel)}: " f"Weiche{'n' if len(weichen) > 1 else ''} {', '.join(weichen)}"), hosts=hosts or [j.sep_kreisel[s] for s in j.separators], separators=list(j.separators), scanners=scanners_for(j.separators), reason=(f"Separator{'en' if len(j.separators) > 1 else ''} {vor} " f"in Laufrichtung unmittelbar vor " f"{'den Weichen' if len(weichen) > 1 else 'der Weiche'} " f"{', '.join(weichen)} (Beruehrpunkt" f"{'e' if len(weichen) > 1 else ''} der Kreisel " f"{'/'.join(j.kreisel)})"), confidence="hoch" if all(c.exported for c in j.contacts) else "mittel", )) # 3c') Separatoren an einer ILS Weiche, die kein Weichenknoten aus 3c ist # (z. B. gegenlaeufige Drehrichtung im Export) -> 1Sep1Swi (siehe # _find_weiche_separators). Host wie bei 3b: der aufgeloeste Zuordnung-Host # des Separators, sonst die Weiche selbst. weiche_tro: dict[str, DerivedTro] = {} # Weichen-Id -> 1Sep1Swi for sep_id, weiche in sorted(weiche_hits.items()): host = resolve_host(elements_by_id[sep_id], findings) weiche_tro[weiche.teile_id] = tro = DerivedTro( tro_id="", type_name="1Sep1Swi", label=f"Separator {sep_id} an ILS Weiche {weiche.teile_id}", hosts=[host] if host is not None else [weiche.teile_id], separators=[sep_id], scanners=sorted(sep_scanners.get(sep_id, [])), reason=f"steuert die ILS Weiche {weiche.teile_id} (echtes Weichen-Bauteil)", confidence="hoch", ) tros.append(tro) # 3d) Ausschleuselement-Weiche und ILS Weiche direkt hintereinander in der # TrackIds-Kette eines Kreisels -> EIN 2Sep2Swi (2 Separatoren, 2 Weichen, # 2 Scanner). Mubea: Kreisel2 ... 0005 -> 0057 (AS VF_2) -> 0125 (ILS # Weiche zu Kreisel1) -> 0012 ...; Separator 0005 steuert das Ausschleusen, # 0036 die Weiche - steuerungstechnisch eine Uebergabestelle (Review # 2026-08-12, im Code seit 2026-10-01). Host ist der des AS-Teils (die # Strecke), damit link_tros die Weichenkante in deren ersten Vario-TRO zieht. for kreisel in (e for e in elements if e.kind == "Kreisel"): chain = list(kreisel.track_ids) if len(chain) < 2: continue for a, b in zip(chain, chain[1:] + chain[:1]): for as_id, w_id in ((a, b), (b, a)): as_tro, w_tro = as_switch_tro.get(as_id), weiche_tro.get(w_id) if not (as_tro and w_tro and as_tro in tros and w_tro in tros): continue tros.remove(as_tro) tros.remove(w_tro) tros.append(DerivedTro( tro_id="", type_name="2Sep2Swi", label=(f"Uebergabestelle {kreisel.name}: Ausschleuselement " f"{as_id} + ILS Weiche {w_id}"), hosts=list(as_tro.hosts), separators=as_tro.separators + w_tro.separators, scanners=sorted(as_tro.scanners + w_tro.scanners), reason=(f"Ausschleuselement {as_id} (Separator " f"{', '.join(as_tro.separators)}) und ILS Weiche {w_id} " f"(Separator {', '.join(w_tro.separators)}) liegen in " f"der TrackIds-Kette von {kreisel.name} direkt " f"hintereinander - eine Uebergabestelle"), confidence="hoch", )) findings.append( f"{kreisel.name}: Ausschleuselement {as_id} und ILS Weiche " f"{w_id} benachbart - als 2Sep2Swi zusammengefasst " f"(FB_ILS_MTRO_2Sep2Swi fehlt noch in der ILSLib)" ) # 4) Weichen-Aufwertung: 2 bzw. 3 abgehende Flusskanten for tro in tros: if tro.type_name != "1Sep": continue exits = count_exits(tro.hosts) if not 2 <= len(exits) <= 3: continue upgraded = "1Sep1Swi" if len(exits) == 2 else "1Sep2Swi" findings.append( f"{tro.label}: {len(exits)} abgehende Wege ({', '.join(exits)}) - " f"als Weiche {upgraded} statt {tro.type_name} eingestuft" ) tro.type_name = upgraded tro.reason = f"{tro.reason}; {len(exits)} abgehende Wege -> Weiche" tro.confidence = "niedrig" # 5) IDs: Reihenfolge entlang des Materialflusses, wiedergefundene TROs # behalten ihre ID aus der ID-Datei (siehe lib/tro_ids.py) tros.sort(key=_flow_key(graph, elements_by_id)) anchors = separator_anchors(elements) if tro_ids is None: assign_ids(tros, anchors, [], renumber=True) else: tro_ids.result = assign_ids( tros, anchors, tro_ids.entries, tolerance=tro_ids.tolerance, renumber=tro_ids.renumber) # 6) Scanner am Separator: 1Sep_SSCC laesst sich mechanisch nicht erkennen for tro in tros: if tro.type_name == "1Sep" and tro.scanners: findings.append( f"{tro.tro_id} ({tro.label}): Scanner {', '.join(tro.scanners)} am " f"Separator - als 1Sep eingestuft. Pruefen, ob 1Sep_SSCC " f"(SSCC-Scanner, Endmessung, WCS-Telegramm) erforderlich ist; das " f"ist aus dem mechanischen Layout nicht erkennbar." ) return tros def separator_anchors(elements: list[Element]) -> dict[str, tuple[float, float]]: """Separator-TeileId -> (x, y) in mm, Bezugslage fuer die ID-Datei.""" return {e.teile_id: (e.position[0], e.position[1]) for e in elements if e.kind == "Separator" and e.position} def _flow_rank(graph: Graph) -> dict[str, int]: """Knoten -> Rang entlang des Materialflusses (Tiefensuche). Start an den Materialeingaengen (Beladung/Eingang), danach an jedem noch nicht erreichten Knoten - eine geschlossene Anlage ohne Station wird so trotzdem vollstaendig erfasst. Gleichrangige Nachfolger nach Knotennamen. """ succ: dict[str, list[str]] = {} for edge in graph.edges: succ.setdefault(edge.src, []).append(edge.dst) starts = sorted(n for n, node in graph.nodes.items() if node.element.is_entry) rank: dict[str, int] = {} for start in starts + sorted(graph.nodes): stack = [start] while stack: node = stack.pop() if node in rank: continue rank[node] = len(rank) stack.extend(n for n in sorted(succ.get(node, []), reverse=True) if n not in rank) return rank def _flow_key( graph: Graph, elements_by_id: dict[str, Element] ) -> Callable[[DerivedTro], tuple[int, int, str]]: """Sortierschluessel fuer TROs entlang des Materialflusses. Erst der Rang des Host-Knotens, dann die Lage des Separators in der TrackIds-Kette des Hosts (Kreisel: Umlaufrichtung, Strecke: Baureihenfolge). Parameter: graph: Materialflussgraph (fuer _flow_rank). elements_by_id: TeileId -> Element, fuer die track_ids des Hosts. Rueckgabe: Funktion fuer sorted()/list.sort(key=...), die je TRO (Host-Rang, Index in der TrackIds-Kette, erster Separator) liefert. """ rank = _flow_rank(graph) def key(tro: DerivedTro) -> tuple[int, int, str]: """(Host-Rang, Index in der TrackIds-Kette, erster Separator).""" host_rank = min((rank.get(h, len(rank)) for h in tro.hosts), default=len(rank)) sep = tro.separators[0] if tro.separators else "" host = elements_by_id.get(tro.hosts[0]) if tro.hosts else None track = host.track_ids if host else [] in_track = track.index(sep) if sep in track else len(track) return (host_rank, in_track, sep) return key # Typnamen fuer Systemgrenz-Knoten (siehe derive_boundaries) - bewusst NICHT # in TRO_CATALOG: eine Be-/Entladestation (BTMT oder generisches Eingang/ # Ausgang-Symbol) ist kein Steuerobjekt mit eigenem FB-Baustein, nur der # tatsaechliche Materialein-/ausgang. BOUNDARY_ENTRY = "Materialeingang" BOUNDARY_EXIT = "Materialausgang" BOUNDARY_TYPES = (BOUNDARY_ENTRY, BOUNDARY_EXIT) def derive_boundaries(elements: list[Element], graph: Graph) -> list[DerivedTro]: """Be-/Entladestationen als sichtbare Systemgrenz-Knoten. Ohne diese Knoten "verschwindet" der Materialfluss stillschweigend an der Station: link_tros() registriert eine Kante nur, wenn die Traversierung einen Knoten erreicht, der ein TRO-Host ist. Eine Station ist nie ein TRO-Host (sie hat keinen Separator), also wird sie beim Durchlaufen als passiver Zwischenknoten behandelt - und da eine Entladung/ein Ausgang (Senke) keine ausgehende Kante mehr hat, bricht die Suche dort einfach ab, ohne eine Kante einzutragen. Eine Beladung/ein Eingang (Quelle) wird umgekehrt nie als Ausgangspunkt einer Traversierung betrachtet, weil kein TRO dort beginnt. Damit fehlte bislang genau der Teil des Flusses, den man am Anfang oder Ende eines Durchlaufs durch die Anlage sieht - die Anlage laeuft als Kreislauf, diese Stationen sind ihre einzigen Materialquellen/-senken (siehe auch material_flow.py --chain-graph). Erfasst sowohl die konkrete BTMT-Station (Beladung/Entladung) als auch das generische Eingang/Ausgang-Symbol (element.is_station, STATION_KINDS in material_flow.py) - ein durch eine deckungsgleiche BTMT-Station verschlucktes Symbol taucht dank des graph.nodes-Filters unten trotzdem nicht doppelt auf (_merge_station_symbols(), siehe build_graph()). Kein eigener TRO-Typ (keine FB_ILS_MTRO_*-Zuordnung, kein Eintrag in TRO_CATALOG) - nur ein sichtbarer Marker fuer den echten Anfang/das echte Ende des Materialflusses. Eigene Nummerierung ("SYS1", "SYS2", ...) getrennt von den TRO-IDs, damit auf den ersten Blick klar bleibt: das sind keine Steuerobjekte. """ boundaries: list[DerivedTro] = [] for element in elements: if not element.is_station: continue if element.teile_id not in graph.nodes: continue boundaries.append( DerivedTro( tro_id="", type_name=BOUNDARY_ENTRY if element.is_entry else BOUNDARY_EXIT, label=element.name, hosts=[element.teile_id], separators=[], scanners=[], reason="Systemgrenze (Be-/Entladestation) - kein TRO, " "nur Materialein-/ausgang", confidence="hoch", ) ) boundaries.sort(key=lambda t: t.hosts[0]) width = max(1, len(str(len(boundaries)))) for index, boundary in enumerate(boundaries, start=1): boundary.tro_id = f"SYS{index:0{width}d}" return boundaries def _tro_edge_kind(edge: Edge) -> str: """Materialkante -> Kantenart des TRO-Flusses (fuer Einfaerbung).""" if edge.note == "Weiche": return "weiche" if edge.kind == EDGE_CIRCLE: return "umlauf" if edge.kind == EDGE_UNRESOLVED: return "unbestimmt" if edge.note in ("Einschleusung", "Ausschleusung"): return "transfer" return "normal" def _bahn_richtung(node: Node) -> tuple[float, float] | None: """Foerderrichtung einer Kreisel-Bahn als Einheitsvektor. Der Kreisel ist eine geschlossene Schleife: auf der einen Bahn laeuft das Material hin, auf der anderen zurueck. Welche Richtung das ist, ergibt sich aus der Laengsachse ("Drehung") und dem Umlaufsinn ("Drehrichtung"): UZS (im Uhrzeigersinn) Bahn R entgegen der Achse, Bahn L mit ihr GUZ (gegen den UZS) umgekehrt Rueckgabe None, wenn eines der beiden Merkmale fehlt - dann bleibt es bei der ungerichteten "gleiche Bahn"-Notiz. """ element = node.element drehung = _as_float(_merkmal(element.merkmale, "Drehung")) umlauf = element.rotation if drehung is None or umlauf not in (ROTATION_CW, ROTATION_CCW): return None rad = math.radians(drehung) achse = (math.cos(rad), math.sin(rad)) mit_achse = (node.lane == LANE_LEFT) == (umlauf == ROTATION_CW) return achse if mit_achse else (-achse[0], -achse[1]) def _tro_bahn(tro: DerivedTro, graph: Graph, sep_assignment: dict[str, str]) -> str | None: """Kreisel-Bahn, auf der ein TRO physisch sitzt - fuer die Positions- Verkettung in link_tros(). Meist einfach der TRO-Host (wenn er direkt auf der Bahn liegt, z. B. ein gewoehnlicher Kreisel-Separator). Bei einem Weichen-TRO zeigt der Host dagegen auf die gefuetterte Strecke, nicht auf die Bahn (siehe derive_tros Schritt 3b) - dort zaehlt stattdessen die Zuordnung des eigenen Separators, falls die auf eine Kreisel-Bahn zeigt. Zeigt sie stattdessen auf eine Strecke (z. B. HundM05 Separator 0016/0018, die auf ihrer eigenen Strecke montiert sind, nicht auf der Kreisel-Bahn), gibt es keine Bahn-Zugehoerigkeit - der TRO gehoert dann nicht in die Verkettung. """ for host in tro.hosts: node = graph.nodes.get(host) if node and node.kind == "Kreisel": return host for sep_id in tro.separators: bahn = sep_assignment.get(sep_id) node = graph.nodes.get(bahn or "") if node and node.kind == "Kreisel": return bahn return None def _chain_mit_ungelisteten_separatoren( strecke: Element, chain: list[str], elements: list[Element], elements_by_id: dict[str, Element], ) -> list[str]: """Separatoren der Strecke, die in KEINER TrackIds-Liste stehen, lagerichtig in die (bereits AS -> ES orientierte) Kette einsortieren. Hintergrund (Mubea, VF_3): Separatoren 0037/0038 sind per Zuordnung der Strecke 0015 zugeordnet und liegen physisch zwischen 0062 und 0063, fehlen aber in deren TrackIds. Ohne Platz in der Kette bekommen ihre TROs keinen praezisen Nachfolger, behalten die groben Contraction-Walk-Kanten und zeigen auf jeden TRO hinter Kreisel2 Bahn R (je 9 Fehlkanten). Einsortiert wird ueber die Projektion auf die Gerade erstes -> letztes Kettenelement. Steht ein Separator in irgendeiner TrackIds-Liste (z. B. eines Kreisels), gehoert er dorthin und bleibt hier draussen. """ if len(chain) < 2: return chain gelistet = {tid for e in elements for tid in e.track_ids} fehlend = [ e for e in elements if e.kind == "Separator" and e.assignment == strecke.teile_id and e.teile_id not in gelistet and e.position ] if not fehlend: return chain anfang = elements_by_id.get(chain[0]) ende = elements_by_id.get(chain[-1]) if not (anfang and ende and anfang.position and ende.position): return chain dx = ende.position[0] - anfang.position[0] dy = ende.position[1] - anfang.position[1] if dx == 0 and dy == 0: return chain def _t(pos: tuple[float, float, float]) -> float: """Projektion von pos auf die Richtung anfang -> ende (unskaliert).""" return (pos[0] - anfang.position[0]) * dx + (pos[1] - anfang.position[1]) * dy # Kettenelemente ohne Position behalten ihren Platz: sie erben den Wert des # Vorgaengers, damit die Sortierung stabil bleibt. werte: list[tuple[float, int, str]] = [] letzter = float("-inf") for i, tid in enumerate(chain): el = elements_by_id.get(tid) if el is not None and el.position: letzter = _t(el.position) werte.append((letzter, i, tid)) for e in fehlend: werte.append((_t(e.position), len(chain), e.teile_id)) werte.sort(key=lambda w: (w[0], w[1])) return [tid for _, _, tid in werte] def link_tros( graph: Graph, tros: list[DerivedTro], elements: list[Element] ) -> tuple[list[tuple[str, str]], dict[tuple[str, str], str]]: """ TROs verknuepfen. Der Materialfluss laeuft von Uebergabestelle zu Uebergabestelle, ggf. durch passive Zwischenknoten (Strecken/Bahnen ohne eigenen TRO). Solche Knoten werden daher durchlaufen ("kontrahiert"), belegte Knoten beenden den Pfad. Jede TRO-Kante bekommt zusaetzlich die Art der *ankommenden* Materialkante (transfer / umlauf / weiche / unbestimmt / normal). """ owners: dict[str, list[str]] = {} for tro in tros: for host in tro.hosts: owners.setdefault(host, []).append(tro.tro_id) by_id = {t.tro_id: t for t in tros} forward: dict[str, list[tuple[str, str]]] = {} for edge in graph.edges: kind = _tro_edge_kind(edge) forward.setdefault(edge.src, []).append((edge.dst, kind)) if edge.kind == EDGE_UNRESOLVED: # unbestimmte Richtung: in beide Richtungen begehbar forward.setdefault(edge.dst, []).append((edge.src, kind)) edges: set[tuple[str, str]] = set() edge_kinds: dict[tuple[str, str], str] = {} for tro in tros: seen = set(tro.hosts) queue = [(nxt, k) for host in tro.hosts for (nxt, k) in forward.get(host, [])] while queue: node, arriving = queue.pop() if node in seen: continue seen.add(node) if node in owners: for other in owners[node]: if other != tro.tro_id: pair = (tro.tro_id, other) edges.add(pair) edge_kinds.setdefault(pair, arriving) continue queue.extend(forward.get(node, [])) # verkettete_paare (ungerichtet) unterdrueckt die "gleiche Bahn"-Punktlinie # weiter unten - dafuer reicht Ortsgleichheit unabhaengig von der Richtung. # directed_paare (gerichtet) markiert dagegen genau die Kanten, die durch # eine dieser Regeln bewusst gesetzt wurden - fuer den Weichen- # Redundanzfilter weiter unten muss das richtungsscharf sein: TRO12->TRO01 # (echte Umlaufkette) darf die entgegengesetzte TRO01->TRO12 (grober, # redundanter Contraction-Walk-Fund) NICHT mitdecken, nur weil es dieselben # zwei TROs sind (Review 2026-09-21, Bug beim ersten Versuch dieses Filters). verkettete_paare: set[frozenset[str]] = set() directed_paare: set[tuple[str, str]] = set() # 1. Ground-Truth-Umlaufreihenfolge je Kreisel aus Element.track_ids # (dieselbe nach Drehrichtung-Winkel sortierte Liste, die auch --track/ # build_track_graph verwendet - siehe doc/track.md). Laeuft ZUERST, damit # der Positions-Fallback weiter unten weiss, welche Kreisel damit schon # praezise abgedeckt sind. Ersetzt fuer diese Kreisel die aus dem groben # Materialfluss-Graph kontrahierten "Nabe -> jeder erreichbare Zweig"- # Kanten weiter unten: die kennen keine Reihenfolge und zeigen daher u. U. # rueckwaerts (Review 2026-09-21 - TRO11 zeigte auf alle 5 Abzweige # gleichrangig, obwohl es in der echten Umlaufreihenfolge der LETZTE # Separator vor der Entladung ist). elem_owner: dict[str, str] = {} for tro in tros: if tro.type_name in BOUNDARY_TYPES: elem_owner.setdefault(tro.hosts[0], tro.tro_id) else: for sep_id in tro.separators: elem_owner.setdefault(sep_id, tro.tro_id) def _add_chain_pair(a: str, b: str) -> None: """Gerichtete Kante TRO a -> TRO b aus der TrackIds-Kette eintragen.""" if a == b: return # Keine Sperre fuer Systemgrenzen noetig: sie sind kein Kettenglied # (siehe station_hosts unten) und kommen hier nur als Zu-/Ablaufpfeil # in der richtigen Richtung an. pair = (a, b) edges.add(pair) edge_kinds.setdefault(pair, "bahn") verkettete_paare.add(frozenset(pair)) directed_paare.add(pair) trackids_pairs.add(pair) trackids_pairs: set[tuple[str, str]] = set() covered_kreisel_bases: set[str] = set() # 0. Reihenfolge der Vario-TROs INNERHALB einer Strecke, aus derselben # Element.track_ids-Quelle wie der Kreisel-Umlauf unten, hier aber linear # (AS -> SEP.. -> ES, keine geschlossene Schleife) - siehe build_track_graph() # in material_flow.py, dessen Richtungskorrektur hier repliziert wird. Seit # derive_tros Schritt 2 einen eigenen Vario-TRO je Separator anlegt (Review # 2026-09-21: "jedes Vario-TRO soll genau 1 Separator haben"), muessen # mehrere Varios auf derselben Strecke in echter Reihenfolge verkettet # werden - strecke_first_owned/strecke_last_owned merken sich den jeweils # ersten/letzten fuer die Anschluss-Kanten an Weiche bzw. Wiedereinschleusung. elements_by_id = {e.teile_id: e for e in elements} strecke_first_owned: dict[str, str] = {} strecke_last_owned: dict[str, str] = {} for el in elements: if el.kind not in TRANSPORT_KINDS: continue chain = list(el.track_ids) if len(chain) >= 2: first_el, last_el = elements_by_id.get(chain[0]), elements_by_id.get(chain[-1]) if ((first_el and first_el.kind == "Einschleus") or (last_el and last_el.kind == "Ausschleus")): chain = list(reversed(chain)) # Ein Separator, der (Zuordnung) zwar auf dieser Strecke steht, aber # selbst eine Weiche ist (z. B. HundM05 Separator 0016 auf VF_5 - siehe # derive_tros Schritt 3b), gehoert NICHT in die Vario-Kette der Strecke: # er ist der Kreisel-Bahn zugehoerig und dort schon (Schritt 1 unten) # verkettet. Ohne diesen Ausschluss wuerde er faelschlich als erster # oder letzter "Vario" gelten (Review 2026-09-21). chain = _chain_mit_ungelisteten_separatoren(el, chain, elements, elements_by_id) owned = [ elem_owner[tid] for tid in chain if tid in elem_owner and not ((d := by_id[elem_owner[tid]].definition()) and d.has_item(TroItem.SWITCH)) ] if not owned: # Segment ganz ohne Separator (derive_tros Schritt 2: leere # Separatorliste) - der einzelne Vario-TRO dieses Hosts bleibt # trotzdem Anschlusspunkt fuer Weiche/Wiedereinschleusung. solo = [tid for tid in owners.get(el.teile_id, []) if by_id[tid].type_name == "Vario"] if len(solo) == 1: owned = solo for a, b in zip(owned, owned[1:]): _add_chain_pair(a, b) if owned: strecke_first_owned[el.teile_id] = owned[0] strecke_last_owned[el.teile_id] = owned[-1] # Einschleuselement-Id -> das Vario-TRO, das dort real wieder einmuendet # (der LETZTE Vario-TRO der Strecke in echter Reihenfolge, nicht irgendein # beliebiger - Review 2026-09-21). einschleus_vario: dict[str, str] = {} for el in elements: if el.kind != "Einschleus": continue strecke_id = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "") vario_id = strecke_last_owned.get(strecke_id) if vario_id: einschleus_vario[el.teile_id] = vario_id # Linienspeicher in der Umlaufkette. Seine Schwerkraftlinien beginnen als # Folge von Ausschleuselementen auf dem einen Kreisel (Mubea Kreisel3: # TRO05 -> 0122, 0119, ... 0065 -> TRO07) und enden als Folge von # Einschleuselementen auf dem anderen (Kreisel2: 0067 ... 0124 -> TRO10). # Die Ausschleuselemente zaehlen in der Kette als Glied des PinStore-TROs: # der Speicher liegt IM Umlauf, TRO05 -> TRO18 -> TRO07 (TRO05 bleibt 1Sep, # kein ILS Ausschleuselement einer Strecke - Review 2026-10-01). Die # Einschleuselemente liefern - wie bei einer Vario-Strecke - den Ablauf # zum naechsten Halt (TRO18 -> TRO10). store_by_host = {h: t.tro_id for t in tros if t.type_name == "PinStore_Auto" for h in t.hosts} speicher_ausschleus: dict[str, str] = {} for el in elements: if el.kind not in ("Ausschleus", "Einschleus"): continue strecke_id = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "") if strecke_id not in store_by_host: continue if el.kind == "Ausschleus": speicher_ausschleus[el.teile_id] = store_by_host[strecke_id] else: einschleus_vario[el.teile_id] = store_by_host[strecke_id] # Systemgrenzen (Be-/Entladung, Eingang/Ausgang) sind KEIN Kettenglied: # die Umlaufkette verbindet die TROs direkt an ihnen vorbei, die Station # haengt nur als Zu- bzw. Ablaufpfeil am benachbarten TRO (Review # 2026-10-02). Vorher liefen sie als Glied mit (TRO19 -> SYS3 -> SYS1 -> # SYS2 -> TRO20), jetzt TRO19 -> TRO20 plus TRO19 -> SYS3, SYS1/SYS2 -> TRO20. station_hosts = {t.hosts[0]: t for t in tros if t.type_name in BOUNDARY_TYPES} ketten_owner = { **{k: v for k, v in elem_owner.items() if k not in station_hosts}, **speicher_ausschleus, } for el in elements: if not el.is_circle or len(el.track_ids) < 2: continue chain = list(el.track_ids) laenge = len(chain) geschlossen = el.rotation in (ROTATION_CW, ROTATION_CCW) if geschlossen: # Umlauf schliessen: den Listenanfang hinten anhaengen, und zwar # bis einschliesslich des ersten Kettenglieds (Element mit TRO). # Alles davor - Stationen, aber auch Ein-/Ausschleuselemente ohne # eigenen TRO - ist kein Glied; endete die Verlaengerung dort, fehlte # der Schluss (HundM05 Kreisel1 beginnt mit den Beladungen 0021/ # 0022: ohne das fehlte TRO19 -> TRO20; Dematic Kreisel2 beginnt # mit dem Einschleuselement 0060: es fehlte TRO01 -> Speicher, # Review 2026-10-06). k = 0 while k < laenge - 1 and chain[k] not in ketten_owner: k += 1 chain.extend(chain[:k + 1]) # Hauptkette: Separatoren in Reihenfolge, Aus-/Einschleuselemente und # Stationen bewusst uebersprungen (unowned) - das beschreibt den BYPASS # fuer Verkehr, der NICHT abzweigt (z. B. TRO01 -> TRO03, wenn # dazwischen niemand abbiegt). owned = [(i, ketten_owner[tid]) for i, tid in enumerate(chain) if tid in ketten_owner] for (_, a), (_, b) in zip(owned, owned[1:]): _add_chain_pair(a, b) if owned: covered_kreisel_bases.add(el.teile_id) # Stationen als Pfeile: Entladung/Ausgang verlaesst den TRO direkt # DAVOR, Beladung/Eingang muendet in den TRO direkt DANACH. for i, tid in enumerate(chain[:laenge]): station = station_hosts.get(tid) if station is None or not owned: continue if station.type_name == BOUNDARY_EXIT: tro_id = next((o for j, o in reversed(owned) if j < i), owned[-1][1] if geschlossen else None) if tro_id is not None: _add_chain_pair(tro_id, station.tro_id) else: tro_id = next((o for j, o in owned if j > i), owned[0][1] if geschlossen else None) if tro_id is not None: _add_chain_pair(station.tro_id, tro_id) # Zusaetzlich der reale Wiedereintritt eines abgezweigten Vario- # Segments: vom Einschleuselement zum naechsten Halt DANACH in # derselben Kette - ersetzt fuer dieses Vario-TRO den groben "Strecke # -> Bahn"-Kontraktionsfund (der pauschal auf DEN EINEN TRO zeigt, der # zufaellig noch die Kreisel-Bahn als Host hat, unabhaengig davon, # welche Strecke tatsaechlich zurueckkehrt - Review 2026-09-21: "warum # zeigen alle Varios auf TRO12"). Unabhaengig von der Hauptkette oben, # die an dieser Stelle bewusst durchlaeuft (Bypass, siehe dort). for i, tid in enumerate(chain): vario_id = einschleus_vario.get(tid) if vario_id is None: continue nxt = next((o for j, o in owned if j > i), None) if nxt is None and owned: nxt = owned[0][1] # Umlauf geschlossen - wickelt um if nxt is not None and nxt != vario_id: _add_chain_pair(vario_id, nxt) # Nur TROs, fuer die die Umlaufkette tatsaechlich einen praezisen # Nachfolger gefunden hat (precise_sources) - ein TRO ohne brauchbare # TrackIds-Abdeckung behaelt seine bisherigen (ungenaueren, aber nicht # leeren) Kanten aus dem Contraction-Walk als Rueckfall. Angewendet wird # der Filter erst ganz unten, nachdem auch die Weichen-Kanten feststehen. precise_sources = {a for a, _ in trackids_pairs} # 2. Positions-Fallback: mehrere TROs auf derselben Kreisel-Bahn liegen # HINTEREINANDER, nicht nebeneinander - der Reihe nach verkettet statt nur # als "gleiche Bahn" markiert. NUR fuer Kreisel, die Schritt 1 NICHT schon # praezise abgedeckt hat (covered_kreisel_bases) - sonst gruppiert dieser # Fallback rein nach CSV-Spalte "Zuordnung" und uebersieht z. B. bei # HundM05 die Separatoren 0016/0018 (auf "0005" zugeordnet, nicht auf der # Bahn) und zieht faelschlich eine direkte Kante 0014->0020 an ihnen # vorbei (Review 2026-09-21). sep_assignment = {e.teile_id: e.assignment for e in elements if e.kind == "Separator" and e.assignment} bahn_gruppen: dict[str, list[DerivedTro]] = {} for tro in tros: bahn = _tro_bahn(tro, graph, sep_assignment) if bahn and bahn.split("-")[0] not in covered_kreisel_bases: bahn_gruppen.setdefault(bahn, []).append(tro) for bahn, gruppe in bahn_gruppen.items(): if len(gruppe) < 2: continue node = graph.nodes[bahn] if not node.lane: continue richtung = _bahn_richtung(node) if richtung is None: continue auf_bahn = [t for t in gruppe if t.position] if len(auf_bahn) < 2: continue # Projektion auf die Foerderrichtung: kleinster Wert kommt zuerst auf_bahn.sort(key=lambda t: t.position[0] * richtung[0] + t.position[1] * richtung[1]) for vorher, nachher in zip(auf_bahn, auf_bahn[1:]): pair = (vorher.tro_id, nachher.tro_id) edges.add(pair) edge_kinds.setdefault(pair, "bahn") verkettete_paare.add(frozenset(pair)) directed_paare.add(pair) # Ein Weichen-TRO auf derselben Strecke wie ein Vario-TRO ist keine blosse # Ortsgleichheit, sondern der zweite der beiden moeglichen Wege an der # Weiche: entweder weiter auf der Kreisel-Bahn zum naechsten Separator # (verkettete_paare oben) oder durch die Weiche hindurch in dieses Segment # - konkret in dessen ERSTEN Vario-TRO (strecke_first_owned oben; bei # mehreren Separatoren auf derselben Strecke sind die weiteren bereits # ueber die Strecken-Kette oben an den ersten angeschlossen). Gerichtete # Kante statt "gleiche Bahn"-Punktlinie, mit dem bereits vorhandenen # "weiche"-Kantenstil (orange, Label "Weiche"). Review 2026-09-21. # Je Host eine Weichenkante: ein 1Sep2Swi hat zwei Strecken (Schritt 3b' # in derive_tros). Hat ein Weichen-TRO keinen Nachfolger auf der Bahn # (einziger Separator seines Kreisels - Dematic Kreisel1/Kreisel4: ohne # Abzweig laeuft das Material auf dem Kreisel zu ihm selbst zurueck), # beschreiben die Weichenkanten seine Weiterfuehrung trotzdem vollstaendig; # er zaehlt dann wie ein Bahn-TRO als praezise Quelle, die groben # Contraction-Walk-Kanten entfallen (sie fuehrten an VF_5 vorbei direkt # auf Kreisel2, Review 2026-10-08). for tro in tros: definition = tro.definition() if not (definition and definition.has_item(TroItem.SWITCH)): continue weichen_kanten = 0 for host in tro.hosts: ziel_id = strecke_first_owned.get(host) if ziel_id is None or ziel_id == tro.tro_id: continue pair = (tro.tro_id, ziel_id) edges.add(pair) edge_kinds.setdefault(pair, "weiche") verkettete_paare.add(frozenset(pair)) directed_paare.add(pair) weichen_kanten += 1 if tro.hosts and weichen_kanten == len(tro.hosts): precise_sources.add(tro.tro_id) # Jedes TRO, fuer das oben ein praeziser Nachfolger in der Umlaufkette # gefunden wurde (precise_sources), hat damit seine gesamte reale # Weiterfuehrung bereits vollstaendig beschrieben (Bahn-Fortsetzung und/ # oder Weiche). Jede WEITERE von ihm ausgehende Kante stammt aus dem # groben Contraction-Walk ganz oben (ueber denselben Host wie ein anderes # TRO - z. B. TRO01 -> TRO12 zusaetzlich zu TRO01 -> TRO02, oder TRO02 -> # TRO12 zusaetzlich zu TRO02 -> TRO03) und ist redundant bzw. schlicht # falsch: das jeweilige Ziel-TRO berichtet dieselbe Weiterfuehrung bereits # selbst, praeziser. Review 2026-09-21: erst "jede Weiche hat 3 statt 2 # ausgehende Kanten", dann "warum zeigen alle Varios auf TRO12" - beides # derselbe Fehler, hier fuer jedes TRO vereinheitlicht statt nur fuer # Weichen. directed_paare (NICHT das ungerichtete verkettete_paare!) - # sonst deckt eine TRO12->TRO01-Umlaufkante faelschlich auch die # Gegenrichtung TRO01->TRO12 ab, nur weil dasselbe TRO-Paar beteiligt ist. edges = {e for e in edges if not (e[0] in precise_sources and e not in directed_paare)} # gleicher Host = physisch dieselbe Strecke/Bahn. Ein bereits verkettetes # PAAR (Bahn-Reihenfolge oben oder Weiche gerade eben) braucht die # ungerichtete "gleiche Bahn"-Notiz nicht zusaetzlich - ein Weichen-TRO # kann durchaus auf der Bahn verkettet UND mit seinem Vario-TRO ueber die # Weiche verbunden sein, das sind zwei unabhaengige Beziehungen. # Weichen-TROs bekommen keine "gleiche Bahn"-Notiz: ihr Host ist die # Strecke, IN die sie abzweigen - sie selbst sitzen auf der Kreisel-Bahn, # nicht auf dieser Strecke. Die Notiz verband sonst einen 1Sep2Swi mit # jedem TRO beider Strecken (Dematic TRO13 mit VF_4 und VF_6, Review # 2026-10-08); die Verbindung zeigt bereits die Weichenkante. weichen_tros = {t.tro_id for t in tros if (d := t.definition()) and d.has_item(TroItem.SWITCH)} for host, ids in owners.items(): for tro_id in ids: tro = next(t for t in tros if t.tro_id == tro_id) others = {o for o in ids if o != tro_id and frozenset((tro_id, o)) not in verkettete_paare and not {tro_id, o} & weichen_tros} if others: tro.colocated = sorted(set(tro.colocated) | others) for tro in tros: tro.successors = sorted(dst for src, dst in edges if src == tro.tro_id) tro.predecessors = sorted(src for src, dst in edges if dst == tro.tro_id) return sorted(edges), edge_kinds def _centroid( points: list[tuple[float, float, float]] ) -> tuple[float, float, float] | None: """Mittelpunkt mehrerer Positionen.""" if not points: return None count = len(points) return ( sum(p[0] for p in points) / count, sum(p[1] for p in points) / count, sum(p[2] for p in points) / count, ) def locate_tros(tros: list[DerivedTro], elements: list[Element], graph: Graph) -> None: """ Jedem TRO eine Koordinate geben. Die Koordinate ist der **Mittelpunkt der Bauteile**, aus denen der TRO gebildet wurde - bei einem 1Sep1Swi also der Mittelwert aus Separator und Weiche, beim Linienspeicher der Mittelwert aller Linien-Separatoren. Hat ein TRO keine Bauteile mit Position (z. B. ein Vario ohne Separator), wird auf die Position seiner Host-Objekte zurueckgegriffen; das steht dann in `position_from`. """ by_id = {element.teile_id: element for element in elements} for tro in tros: # 1) Bauteile des TRO (Separatoren, Scanner) parts = [ by_id[i].position for i in list(tro.separators) + list(tro.scanners) if i in by_id and by_id[i].position ] source = "Bauteile" # 2) Ersatzweise die Host-Objekte if not parts: parts = [ graph.nodes[h].element.position for h in tro.hosts if h in graph.nodes and graph.nodes[h].element.position ] source = "Host-Objekte" tro.position = _centroid(parts) tro.position_points = len(parts) tro.position_from = source if tro.position else "keine Position" def analyse_tros( graph: Graph, elements: list[Element], *, sepliste_counts: dict[str, int] | None = None, tro_ids: TroIds | None = None, ) -> Analysis: """TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen. Gemeinsamer Einstieg fuer tro_flow.py und tro_annotate.py. Der TRO-Typ wird NICHT hier bestimmt, sondern in derive_tros (erster Aufruf unten); danach kommen nur noch Systemgrenzen (derive_boundaries), Koordinaten (locate_tros) und Verbindungen (link_tros) dazu. Parameter: graph: Materialflussgraph aus material_flow.build_graph. elements: alle Elemente des CSV-Exports (material_flow.read_elements). sepliste_counts: optional, Separatorenzahl je Container aus dem Roh-JSON (load_sepliste_counts) - nur Gegenprobe. tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu nummerieren. Rueckgabe: Analysis mit tros, boundaries, edges/edge_kinds, unknown_types (Typnamen ohne Katalogeintrag), isolated (TROs ohne Verbindung) und den sortierten findings. """ analysis = Analysis() analysis.tros = derive_tros(graph, elements, analysis.findings, sepliste_counts=sepliste_counts, tro_ids=tro_ids) analysis.boundaries = derive_boundaries(elements, graph) # Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen: # link_tros() registriert eine Kante nur zu einem Knoten, der Host eines # Eintrags in der uebergebenen Liste ist - eine BTMT-Station muss also mit # in dieser Liste stehen, sonst bleibt sie unsichtbar (siehe derive_boundaries). linked = analysis.tros + analysis.boundaries # Verorten vor dem Verknuepfen: die Reihenfolge mehrerer TROs auf einer # Kreisel-Bahn wird ueber ihre Koordinate bestimmt. locate_tros(linked, elements, graph) analysis.edges, analysis.edge_kinds = link_tros(graph, linked, elements) analysis.unknown_types = sorted( {tro.type_name for tro in analysis.tros if tro.definition() is None}) analysis.isolated = [tro for tro in analysis.tros if not tro.connected] analysis.findings.sort() return analysis # --------------------------------------------------------------------------- # DOT erzeugen - Flussdiagramm der TROs # --------------------------------------------------------------------------- def _tro_label(tro: DerivedTro) -> str: """Mehrzeiliges DOT-Label eines TRO-Knotens. Zeilen: ID und Typ, Bezeichnung, FB-Baustein, Bauteile laut Katalog, Separatoren (ab 7 gekuerzt), Scanner, Koordinate - jeweils nur, wenn vorhanden. """ lines = [f"{tro.tro_id} ({tro.type_name})", tro.label] if tro.fb_block: lines.append(tro.fb_block) if tro.items: lines.append(", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items()))) if tro.separators: seps = ", ".join(tro.separators) if len(tro.separators) > 6: seps = f"{tro.separators[0]} … {tro.separators[-1]} ({len(tro.separators)})" lines.append(f"Sep: {seps}") if tro.scanners: lines.append(f"Scan: {', '.join(tro.scanners)}") if tro.position: lines.append(f"x/y: {tro.describe_position()}") return _dot_label(lines) def missing_positions(analysis: Analysis) -> list[str]: """TROs/Systemgrenzen ohne Koordinate - mit ihnen ist kein lagerichtiges Layout moeglich (auch eine Systemgrenze wird als Knoten gezeichnet).""" return [t.tro_id for t in analysis.tros + analysis.boundaries if not t.position] # Lagerichtige Darstellung (--use-cords): feste Symbolgroesse je Form in Zoll # (Breite, Hoehe). Ohne fixedsize waechst jede Form mit ihrem Label - Dreiecke # und Doppel-Dreiecke wurden dadurch um ein Vielfaches groesser als die Kreise # (Review 2026-10-01: "manche Symbole sind riesig"). COORD_SYMBOL_SIZE: dict[str, tuple[float, float]] = { "circle": (0.45, 0.45), "triangle": (0.62, 0.52), "invtriangle": (0.62, 0.52), "box": (0.7, 0.34), "square": (0.55, 0.55), "diamond": (0.62, 0.5), "pentagon": (0.55, 0.52), "hexagon": (0.65, 0.45), "rarrow": (0.7, 0.36), "ellipse": (0.7, 0.34), } def _achse_kompakt(werte: list[float], max_luecke: float) -> dict[float, float]: """Koordinaten einer Achse -> gekuerzte Koordinaten: Abstaende bis max_luecke bleiben, groessere werden auf max_luecke gekuerzt. Monoton, die Reihenfolge der Knoten bleibt also unveraendert.""" sortiert = sorted(set(werte)) if not sortiert: return {} abbildung = {sortiert[0]: sortiert[0]} for vorher, wert in zip(sortiert, sortiert[1:]): abbildung[wert] = abbildung[vorher] + min(wert - vorher, max_luecke) return abbildung def _kompakte_positionen( positionen: dict[str, tuple[float, float]], max_luecke: float ) -> dict[str, tuple[float, float]]: """Knoten-Id -> Zeichenposition (mm) mit gekuerzten Leerstrecken.""" ax = _achse_kompakt([p[0] for p in positionen.values()], max_luecke) ay = _achse_kompakt([p[1] for p in positionen.values()], max_luecke) return {k: (ax[x], ay[y]) for k, (x, y) in positionen.items()} def _station_positionen( analysis: Analysis, tro_zeichen: dict[str, tuple[float, float]] ) -> dict[str, tuple[float, float]]: """Zeichenposition der Stationsbeschriftung (Ende des Zu-/Ablaufpfeils). Feste Pfeillaenge COORD_PFEIL_LAENGE ab dem angehaengten TRO (Beladung: der TRO danach, Entladung: der TRO davor) - so bleibt der Pfeil sichtbar, auch wenn die Station praktisch auf dem TRO sitzt (Mubea SYS1 186 mm neben TRO04). Die Richtung haelt den groessten Winkelabstand zu allen Kanten des Anker-TROs und zu bereits gesetzten Stationspfeilen; die Richtung zur echten Station entscheidet nur bei Gleichstand. Station ohne angehaengten TRO: echte Koordinate.""" tro_by_id = {t.tro_id: t for t in analysis.tros} je_tro: dict[str, list[DerivedTro]] = {} ergebnis: dict[str, tuple[float, float]] = {} for station in analysis.boundaries: nachbarn = (station.successors if station.type_name == BOUNDARY_ENTRY else station.predecessors) anker = next((n for n in nachbarn if n in tro_zeichen), None) if anker is None: if station.position: ergebnis[station.tro_id] = (station.position[0], station.position[1]) continue je_tro.setdefault(anker, []).append(station) def _abstand(a: float, b: float) -> float: """Kleinster Winkelabstand (rad) zweier Richtungen.""" d = abs(a - b) % (2 * math.pi) return min(d, 2 * math.pi - d) for anker, stationen in je_tro.items(): ax, ay = tro_zeichen[anker] echt = tro_by_id[anker].position # Richtungen der Kettenkanten am Anker-TRO - der Pfeil soll von der # Kette WEG zeigen, nicht auf ihr liegen (Review 2026-10-02: Mubea # TRO02 -> SYS2 lief fast deckungsgleich mit TRO02 -> TRO13). belegt: list[float] = [] for src, dst in analysis.edges: anderer = dst if src == anker else src if dst == anker else None if anderer is None or anderer not in tro_zeichen: continue ox, oy = tro_zeichen[anderer] if (ox, oy) != (ax, ay): belegt.append(math.atan2(oy - ay, ox - ax)) for station in sorted(stationen, key=lambda s: s.tro_id): wunsch = -math.pi / 2 if echt and station.position: dx, dy = station.position[0] - echt[0], station.position[1] - echt[1] if math.hypot(dx, dy) >= 1.0: wunsch = math.atan2(dy, dx) # Kandidaten im 15-Grad-Raster: groesster Mindestabstand zu allen # belegten Richtungen gewinnt, bei Gleichstand die zur echten # Station naechste Richtung. kandidaten = [math.radians(g) for g in range(0, 360, 15)] winkel = max(kandidaten, key=lambda w: ( round(min((_abstand(w, b) for b in belegt), default=math.pi), 3), -_abstand(w, wunsch))) belegt.append(winkel) ergebnis[station.tro_id] = (ax + math.cos(winkel) * COORD_PFEIL_LAENGE, ay + math.sin(winkel) * COORD_PFEIL_LAENGE) return ergebnis def _tro_kurzinfo(tro: DerivedTro) -> str: """Zweizeilige Beschriftung neben dem Symbol: Typ und Bauteile.""" if tro.type_name in BOUNDARY_TYPES: return _dot_label([tro.type_name, tro.label]) if len(tro.separators) > 3: teile = f"{len(tro.separators)} Separatoren" elif tro.separators: teile = "Sep " + ", ".join(tro.separators) else: teile = tro.label return _dot_label([tro.type_name, teile]) def _tro_tooltip(tro: DerivedTro) -> str: """Vollstaendige Angaben als SVG-Tooltip (Zeilenumbruch per ).""" return _tro_label(tro).replace("\\n", " ") def _coord_node_attrs(tro: DerivedTro, base: dict[str, str]) -> str: """Knotenattribute fuer --use-cords: feste Groesse, kurzes Label im Symbol, Typ/Bauteile als xlabel daneben, alle Details als Tooltip.""" attrs = dict(base) shape = attrs.get("shape", "box") width, height = COORD_SYMBOL_SIZE.get(shape, (0.7, 0.5)) attrs.update({ "fixedsize": "true", "width": f"{width}", "height": f"{height}", "fontsize": "8", "label": tro.tro_id, "xlabel": _tro_kurzinfo(tro), "tooltip": _tro_tooltip(tro), }) return ", ".join(f'{k}="{v}"' for k, v in attrs.items()) def _legend_pos_rechts(zeichen: dict[str, tuple[float, float]]) -> tuple[float, float]: """Legende rechts neben der Anlage, obere Kante etwa auf Anlagenoberkante (mm, Zeichenpositionen; pos ist bei neato -n der Knotenmittelpunkt).""" if not zeichen: return (0.0, 0.0) xs = [p[0] for p in zeichen.values()] ys = [p[1] for p in zeichen.values()] return (max(xs) + LEGEND_MARGIN, max(ys) - LEGEND_DROP) def _zeichen_positionen( analysis: Analysis, compact: bool ) -> dict[str, tuple[float, float]]: """Zeichenposition (mm) je TRO/Systemgrenze fuer --use-cords: TROs auf ihrer Anlagenkoordinate, mit compact die Leerstrecken gekuerzt (_kompakte_positionen); die Stationsbeschriftungen danach relativ zum angehaengten TRO (_station_positionen). Reine Darstellung - DerivedTro.position und die Tooltip-Koordinaten bleiben echt.""" roh: dict[str, tuple[float, float]] = {} for tro in analysis.tros: if tro.position: roh[tro.tro_id] = (tro.position[0], tro.position[1]) zeichen = _kompakte_positionen(roh, COORD_MAX_LUECKE) if compact else roh zeichen.update(_station_positionen(analysis, zeichen)) return zeichen # Kantenstil je Art (an material_flow.py angelehnt: Umlauf hellblau gestrichelt, # Weiche orange). "transfer"/"normal" bleiben der durchgezogene Standardfluss. TRO_EDGE_STYLE = { "umlauf": 'color="#8ea9db", style=dashed, tooltip="Kreisel-Umlauf"', "weiche": 'color="#cc8800", penwidth=1.8, xlabel="Weiche", fontcolor="#cc8800", ' 'tooltip="Weiche (Kreisel-Uebergang)"', "unbestimmt": 'color="#bf8f00", style=dashed, dir=both, tooltip="Richtung unbestimmt"', "transfer": 'tooltip="Uebergabe (Ein-/Ausschleusung)"', } # Farbe der Zu-/Ablaufpfeile und ihrer Stationsbeschriftung STATION_FARBE = { BOUNDARY_ENTRY: "#2e8b57", # gruen: Material kommt herein BOUNDARY_EXIT: "#c0392b", # rot: Material geht hinaus } def _tro_edge_attrs(kind: str | None) -> str: """DOT-Kantenattribute zur Kantenart (TRO_EDGE_STYLE); None = "normal".""" return TRO_EDGE_STYLE.get(kind or "normal", "") def render_dot(analysis: Analysis, source: str, use_coords: bool = False, compact: bool = True) -> str: """ Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle. use_coords=False Anordnung berechnet 'dot' (Fluss von links nach rechts) use_coords=True jeder Knoten wird auf seine Anlagenkoordinate gesetzt und mit "neato -n" lagerichtig gezeichnet compact nur mit use_coords: Leerstrecken ueber COORD_MAX_LUECKE kuerzen (Reihenfolge der Knoten bleibt); False = masstaeblich """ out: list[str] = [] add = out.append zeichen = _zeichen_positionen(analysis, compact) if use_coords else {} def _pos(node: DerivedTro) -> str: """DOT-Attribut ', pos="x,y"' des Knotens oder ''.""" if use_coords: p = zeichen.get(node.tro_id) return (f', pos="{p[0] * COORD_SCALE:.2f},{p[1] * COORD_SCALE:.2f}"' if p else "") # Ohne --use-cords ignoriert dot das pos, die Angabe bleibt aber als # Information im DOT stehen. if node.position: return f', pos="{node.position[0] * 0.01:.2f},{node.position[1] * 0.01:.2f}"' return "" add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.") add(f"// Quelle: {source}") if use_coords: add(f"// Lagerichtig: Knoten auf Anlagenkoordinate, {COORD_SCALE} pt/mm.") add("// Rendern mit: neato -n -Tsvg .dot -o .svg") add("digraph TROFluss {") if use_coords: # Bei -n bestimmen die pos-Attribute die Lage; rankdir/ranksep entfallen. add(" graph [splines=true, outputorder=edgesfirst, forcelabels=true,") add(' bgcolor="white", pad=0.6,') add(' fontname="Segoe UI", fontsize=16, labelloc="t",') add(f' label="TRO-Fluss - {_dot_escape(source)}"];') else: add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,") add(' fontname="Segoe UI", fontsize=11, labelloc="t",') add(f' label="TRO-Fluss - {_dot_escape(source)}"];') if use_coords: add(' node [shape=box, style="filled", penwidth=1.6, fontname="Segoe UI Semibold",' ' fontsize=9];') add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.3,' " arrowsize=0.7];") else: add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];') add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,' " arrowsize=0.8];") add("") for tro in analysis.tros: definition = tro.definition() attrs = definition.get_dot_attrs() if definition else { "fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000" } rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items()) pos = _pos(tro) if use_coords: add(f' "{tro.tro_id}" [{_coord_node_attrs(tro, attrs)}{pos}];') else: add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];') add("") # Systemgrenzen (BTMT Be-/Entladestationen): kein Katalogtyp, eigene # Farbe/Form (ext-Stil, Ellipse statt Box) - deutlich als "kein TRO" # erkennbar, aber sichtbar als tatsaechlicher Anfang/Ende des Flusses. # Systemgrenzen (Be-/Entladung, Eingang/Ausgang) sind kein TRO und kein # Kettenglied: sie erscheinen nur als Beschriftung am Ende eines farbigen # Zu- bzw. Ablaufpfeils (Review 2026-10-02). boundary_ids = {b.tro_id for b in analysis.boundaries} for boundary in analysis.boundaries: farbe = STATION_FARBE[boundary.type_name] add(f' "{boundary.tro_id}" [shape=plaintext, style="", fontsize=9, ' f'fontcolor="{farbe}", label="{_dot_escape(boundary.label)}", ' f'tooltip="{_tro_tooltip(boundary)}"{_pos(boundary)}];') add("") for src, dst in analysis.edges: if src in boundary_ids or dst in boundary_ids: farbe = STATION_FARBE[BOUNDARY_ENTRY if src in boundary_ids else BOUNDARY_EXIT] add(f' "{src}" -> "{dst}" [color="{farbe}", penwidth=2.0, arrowsize=0.9, ' f'tooltip="{"Materialeingang" if src in boundary_ids else "Materialausgang"}"];') continue add(f' "{src}" -> "{dst}" [{_tro_edge_attrs(analysis.edge_kinds.get((src, dst)))}];') # "gleiche Bahn"-Punktlinien nur in der berechneten Anordnung: lagerichtig # liegen TROs derselben Bahn/Strecke ohnehin sichtbar nebeneinander, die # Linien waren dort nur Unruhe (Review 2026-10-01). seen: set[frozenset] = set() for tro in analysis.tros if not use_coords else []: for other in tro.colocated: key = frozenset((tro.tro_id, other)) if key in seen: continue seen.add(key) add(f' "{tro.tro_id}" -> "{other}" [dir=none, style=dotted, ' f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];') add("") # Legende: eine Zeile je TRO-Typ (Farbe der Gruppe + eigene Form, siehe # tro_catalog.TRO_CATALOG), plus eine Zeile fuer die Systemgrenzen und die # Kantenarten. Bei -n braucht auch sie ein pos, sonst landet sie auf 0,0 # mitten in der Anlage - daher unterhalb links vom Anlagenrand. # Lagerichtig nur die tatsaechlich vorkommenden Typen - die volle # Katalogliste macht die Legende dort laenger als die Anlage breit ist. used_types = {t.type_name for t in analysis.tros} legend_types = [d for d in TRO_CATALOG if not use_coords or d.get_name() in used_types] rows = "".join( f' {d.get_name()} ' f'{d.describe_symbol()}' for d in legend_types ) rows += (f'' f'gruener PfeilMaterialeingang (Beladung / Eingang)' f'' f'roter PfeilMaterialausgang (Entladung / Ausgang)') legend_label = ('<' + rows + '' '' '' '' + ('' if use_coords else '' '') + "
durchgezogenUebergabe / Ein-Ausschleusung
orangeWeiche (Kreisel-Uebergang)
grau gepunktetgleiche Bahn / Strecke
>") if use_coords: lx, ly = _legend_pos_rechts(zeichen) add(f' legende [shape=plaintext, style="", fillcolor="none", fontsize=11, ' f'label={legend_label}, pos="{lx * COORD_SCALE:.2f},' f'{ly * COORD_SCALE:.2f}"];') else: add(" subgraph cluster_legende {") add(' label="Legende"; style="rounded"; color="#a6a6a6";') add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";') add(f' legende [shape=plaintext, style="", fillcolor="none", ' f'label={legend_label}];') add(" }") add("}") return "\n".join(out) + "\n" # --------------------------------------------------------------------------- # connect.ini (TRO-Ebene) - --connect schreibt # --------------------------------------------------------------------------- def render_connect_ini(analysis: Analysis, section: str, source: str) -> str: """ TRO-Topologie als connect.ini, angelehnt an doc/TRO_Katalog/connect.ini (hmf-connect-v1). Reine Ausgabe - wird von keinem Lauf zurueckgelesen. Abweichung vom Referenzformat: Systemgrenzen stehen nicht als EXTERN, sondern mit ihrer ID (SYSn). [.tros] ist eine reine Uebersicht (Vorgaenger/Nachfolger je TRO). """ out: list[str] = [] add = out.append add("; ============================================================") add("; connect.ini - Routing / Zusammenhalt der TROs (TRO-Ebene)") add(f"; Automatisch erzeugt von lib/tro_flow.py aus '{source}'.") add("; Wird bei jedem Lauf mit --connect neu geschrieben - nicht von Hand") add("; aendern, Korrekturen gehoeren in die Ableitung (lib/tro_flow.py).") add(";") add("; [.nodes] TRO = | | ") add("; SYSn = Materialeingang/-ausgang (kein TRO)") add("; [.connections] cNNN = -> | | ") add("; kind: normal | transfer | bahn | weiche | umlauf | unbestimmt | gesetzt") add("; [.externals] eNNN = SYSn -> | | (Zulauf)") add("; eNNN = -> SYSn | | (Ablauf)") add("; [.tros] TRO = | prev: ... | next: ...") add("; nur Uebersicht - beim Einlesen ignoriert") add("; ============================================================") add("") add(f"[{section}.nodes]") for tro in analysis.tros: teile = [] if tro.separators: teile.append(f"Sep {', '.join(tro.separators)}") if tro.scanners: teile.append(f"Scan {', '.join(tro.scanners)}") add(f"{tro.tro_id} = {tro.type_name} | {tro.label}" + (f" | {'; '.join(teile)}" if teile else "")) for boundary in analysis.boundaries: add(f"{boundary.tro_id} = {boundary.type_name} | {boundary.label}") add("") boundary_ids = {b.tro_id for b in analysis.boundaries} intern = [e for e in analysis.edges if e[0] not in boundary_ids and e[1] not in boundary_ids] extern = [e for e in analysis.edges if e[0] in boundary_ids or e[1] in boundary_ids] add(f"[{section}.connections]") for i, (src, dst) in enumerate(intern, start=1): kind = analysis.edge_kinds.get((src, dst)) or "normal" add(f"c{i:03d} = {src} -> {dst} | | {kind}") add("") add(f"[{section}.externals]") if not extern and boundary_ids: add(f"; Systemgrenzen ohne Verbindung: {', '.join(sorted(boundary_ids))}") elif not extern: add("; keine Be-/Entladestation im Layout - Materialein-/ausgang offen") for i, (src, dst) in enumerate(extern, start=1): kind = analysis.edge_kinds.get((src, dst)) or "normal" add(f"e{i:03d} = {src} -> {dst} | | {kind}") add("") add(f"[{section}.tros]") for tro in analysis.tros: prev = ", ".join(tro.predecessors) or "-" nxt = ", ".join(tro.successors) or "-" line = f"{tro.tro_id} = {tro.type_name} | prev: {prev} | next: {nxt}" if tro.colocated: line += f" | gleiche Bahn: {', '.join(tro.colocated)}" add(line) add("") return "\n".join(out) + "\n" # --------------------------------------------------------------------------- # Fehlerdatei # --------------------------------------------------------------------------- def render_errors(analysis: Analysis, csv_file: Path) -> str: """Fehlerbericht _tro_errors.md als Markdown-Text. Parameter: analysis: Ergebnis von analyse_tros (isolated, unknown_types). csv_file: Eingabe-CSV, nur fuer Titel und Quellenangabe. Rueckgabe: Der Markdown-Text. Ob er geschrieben wird (nur bei isolierten TROs oder unbekannten Typen), entscheidet write_error_file. """ out = [GENERATED_MARKER, f"# TRO-Fehler - {csv_file.name}", ""] out.append(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} ") out.append(f"**Quelle:** `{csv_file}` ") out.append(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})") out.append("") if analysis.isolated: out.append("## Nicht verbundene TROs") out.append("") out.append( "Jeder TRO muss mit mindestens einem anderen TRO verbunden sein. " "Fuer die folgenden Uebergabestellen liess sich im Materialfluss kein " "Nachbar-TRO finden - weder ueber die Spalte `Nachbarn` noch ueber einen " "gemeinsamen Host." ) out.append("") out.append("| TRO | Typ | Host | Separatoren | Bezeichnung |") out.append("|---|---|---|---|---|") for tro in analysis.isolated: out.append( f"| `{tro.tro_id}` | `{tro.type_name}` | " f"{', '.join(f'`{h}`' for h in tro.hosts)} | " f"{', '.join(tro.separators) or '-'} | {tro.label} |" ) out.append("") out.append("**Moegliche Ursachen**") out.append("") out.append("- Spalte `Nachbarn` des Host-Objekts ist leer oder unvollstaendig") out.append("- `Nachbarn` verweist auf eine unbekannte `TeileId`") out.append("- Die Bahnangabe (`-L` / `-R`) eines Kreisels fehlt auf beiden Seiten") out.append("") if analysis.unknown_types: out.append("## Typen ohne Katalogeintrag") out.append("") out.append( "Die folgenden Typen wurden ermittelt, stehen aber nicht in " "`TRO_CATALOG` in `lib/tro_catalog.py`:" ) out.append("") for name in analysis.unknown_types: holders = [t.tro_id for t in analysis.tros if t.type_name == name] out.append(f"- `{name}` ({', '.join(holders)})") out.append("") return "\n".join(out) + "\n" def write_error_file(analysis: Analysis, csv_file: Path, path: Path) -> bool: """Fehlerdatei schreiben; veraltete eigene Datei bei fehlerfreiem Lauf entfernen.""" if analysis.isolated or analysis.unknown_types: path.write_text(render_errors(analysis, csv_file), encoding="utf-8") return True if path.is_file(): try: first = path.read_text(encoding="utf-8").splitlines()[:1] except OSError: first = [] if first and first[0].strip() == GENERATED_MARKER: path.unlink() return False # --------------------------------------------------------------------------- # Dokumentation (Markdown) # --------------------------------------------------------------------------- def _mermaid_text(text: str) -> str: """Text fuer ein Mermaid-Knotenlabel entschaerfen (\" und [] ersetzen).""" return text.replace('"', "'").replace("[", "(").replace("]", ")") def _tro_mermaid(analysis: Analysis) -> list[str]: """TRO-Fluss als Mermaid-Flowchart (Zeilen inklusive ```-Zaun). Knoten je TRO (Farbe nach Katalogtyp) und Systemgrenze, Kanten aus analysis.edges, gestrichelt TROs auf derselben Bahn (colocated). """ out = ["```mermaid", "flowchart LR"] out.extend(f" {line}" for line in mermaid_classdefs()) for tro in analysis.tros: definition = tro.definition() css_class = definition.mermaid_class() if definition else "ext" label = f"{tro.tro_id}
{tro.type_name}
{_mermaid_text(tro.label)}" out.append(f' {tro.tro_id}["{label}"]:::{css_class}') for boundary in analysis.boundaries: label = f"{boundary.tro_id}
{boundary.type_name}
{_mermaid_text(boundary.label)}" out.append(f' {boundary.tro_id}(["{label}"]):::ext') for src, dst in analysis.edges: out.append(f" {src} --> {dst}") seen: set[frozenset] = set() for tro in analysis.tros: for other in tro.colocated: key = frozenset((tro.tro_id, other)) if key in seen: continue seen.add(key) out.append(f" {tro.tro_id} -. gleiche Bahn .- {other}") out.append("```") return out def render_doc( analysis: Analysis, graph: Graph, csv_file: Path, dot_file: Path, svg_file: Path | None, error_file: Path | None, ) -> str: """TRO-Dokumentation _tro_doc.md (--doc) als Markdown-Text. Parameter: analysis: Ergebnis von analyse_tros. graph: Materialflussgraph (Host-Namen und -Arten fuer die Tabellen). csv_file: Eingabe-CSV (Titel, Quellenangabe). dot_file: erzeugte DOT-Datei, wird verlinkt. svg_file: erzeugtes SVG oder None (ohne --tosvg). error_file: Fehlerdatei oder None (fehlerfreier Lauf). Rueckgabe: Der Markdown-Text: TRO-Liste mit Typ und Begruendung, Mermaid-Fluss, Verbindungen, Typdefinition aus dem Katalog, Findings. """ out: list[str] = [GENERATED_MARKER] add = out.append add(f"# TRO-Liste und TRO-Fluss - {csv_file.name}") add("") add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von " f"`lib/tro_flow.py` ") add(f"**Quelle:** `{csv_file}` ") add(f"**Methode:** {analysis.method} ") add(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})") add("") add("> Automatisch aus dem CSV-Export erzeugt. Die TRO-Typen sind aus dem " "mechanischen Layout **abgeleitet** - ohne E-Planung (I/O-Liste) und ohne " "`FB_Main`-Aufrufstellen gibt es noch keine verbindliche TRO-Nummerierung. " "Die IDs unten sind laufende Nummern dieses Laufs.") add("") types_used: dict[str, int] = {} for tro in analysis.tros: types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1 add("## 1. Kurzfassung") add("") add("| | |") add("|---|---|") add(f"| Ermittelte TROs | {len(analysis.tros)} |") add(f"| Systemgrenzen (Materialein-/ausgang) | {len(analysis.boundaries)} |") add(f"| TRO-Verbindungen | {len(analysis.edges)} |") add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |") add(f"| Hinweise | {len(analysis.findings)} |") add("") add("| Typ | FB-Baustein | Anzahl hier | Bauteile je TRO | Farbgruppe | Symbol |") add("|---|---|---:|---|---|---|") for name, count in sorted(types_used.items()): definition = get_tro(name) add(f"| `{name}` | " f"{f'`{definition.get_fb_block()}`' if definition else '_kein Katalogeintrag_'} " f"| {count} | {definition.describe_items() if definition else '-'} " f"| {definition.get_group() if definition else '-'} " f"| {definition.describe_symbol() if definition else '-'} |") add("") add("## 2. TRO-Flussdiagramm") add("") out.extend(_tro_mermaid(analysis)) add("") add(f"Gerendert: [`{dot_file.name}`]({dot_file.name})" + (f" - [`{svg_file.name}`]({svg_file.name})" if svg_file else "")) add("") add("## 3. TRO-Liste") add("") add("| TRO | Typ | FB-Baustein | Bauteile laut Typ | X [mm] | Y [mm] | Z [mm] " "| Host-Knoten | Separatoren | Scanner | Begruendung | Vertrauen |") add("|---|---|---|---|---:|---:|---:|---|---|---|---|---|") for tro in analysis.tros: hosts = ", ".join(f"`{h}`" for h in tro.hosts) if len(tro.hosts) > 4: hosts = f"`{tro.hosts[0]}` … `{tro.hosts[-1]}` ({len(tro.hosts)} Knoten)" seps = ", ".join(tro.separators) or "-" if len(tro.separators) > 6: seps = f"{tro.separators[0]} … {tro.separators[-1]} ({len(tro.separators)})" items = ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())) or "-" cx = f"{tro.x:.0f}" if tro.x is not None else "-" cy = f"{tro.y:.0f}" if tro.y is not None else "-" cz = f"{tro.z:.0f}" if tro.z is not None else "-" add(f"| `{tro.tro_id}` | `{tro.type_name}` | " f"{f'`{tro.fb_block}`' if tro.fb_block else '-'} | {items} " f"| {cx} | {cy} | {cz} | {hosts} | {seps} | " f"{', '.join(tro.scanners) or '-'} | {tro.reason} | {tro.confidence} |") add("") add("**X/Y/Z** ist der Mittelpunkt der Bauteile, aus denen der TRO gebildet " "wurde (Separatoren und Scanner) - beim Linienspeicher also der " "Mittelwert aller Linien-Separatoren. Koordinaten in mm im " "Anlagen-Koordinatensystem des CSV-Exports.") add("") add("| TRO | Koordinate aus | Anzahl Punkte |") add("|---|---|---:|") for tro in analysis.tros: add(f"| `{tro.tro_id}` | {tro.position_from} | {tro.position_points} |") add("") add("## 4. TRO-Verbindungen") add("") if analysis.edges: add("Richtung des Materialflusses zwischen den Uebergabestellen. " "Passive Zwischenknoten ohne eigenen TRO sind zusammengefasst.") add("") add("| von | nach | Typ von -> Typ nach |") add("|---|---|---|") for src, dst in analysis.edges: a, b = analysis.by_id(src), analysis.by_id(dst) add(f"| `{src}` | `{dst}` | " f"`{a.type_name if a else '?'}` -> `{b.type_name if b else '?'}` |") else: add("_Keine TRO-Verbindungen ermittelt._") add("") if analysis.isolated: add("### Nicht verbundene TROs") add("") for tro in analysis.isolated: add(f"- `{tro.tro_id}` (`{tro.type_name}`) - {tro.label}") add("") if error_file: add(f"Details: [`{error_file.name}`]({error_file.name})") add("") if analysis.boundaries: add("### Systemgrenzen (Materialein-/ausgang)") add("") add("BTMT Be-/Entladestationen - kein TRO (kein Separator, kein " "FB-Baustein), aber der tatsaechliche Anfang bzw. das Ende des " "Materialflusses. Ohne diese Zeile wuerde die Verbindung an der " "Station stillschweigend enden (siehe `derive_boundaries` in " "`lib/tro_flow.py`).") add("") add("| Knoten | Typ | Bezeichnung | verbunden mit |") add("|---|---|---|---|") for boundary in analysis.boundaries: other = ", ".join(f"`{x}`" for x in sorted(set(boundary.successors) | set(boundary.predecessors))) or "-" add(f"| `{boundary.tro_id}` | `{boundary.type_name}` | " f"{boundary.label} | {other} |") add("") add("## 5. Hinweise") add("") if analysis.findings: for finding in analysis.findings: add(f"- {finding}") else: add("_Keine._") add("") add("## 6. Ableitungsregeln und Typdefinition") add("") add("Ein TRO ist die Steuerungslogik **einer Uebergabestelle**. Ausgangspunkt " "ist der Separator und das, was an ihm haengt (Host-Objekt, Scanner).") add("") add("| Host des Separators | TRO-Typ |") add("|---|---|") add(f"| Gefaellestrecke, >= {MIN_STORE_LANES} Linien mit gemeinsamem Zu-/Ablauf " "| `PinStore_Auto` (ein Block je Liniengruppe) |") add("| Gefaellestrecke, einzelne Linie | `1Sep` |") add("| Strecke (angetrieben) | `Vario` (je Streckensegment) |") add("| Kreisel-Bahn | `1Sep` |") add("| Separator mit Ausschleuselement unter seinen CSV-Nachbarn (gleiche " "Kreisel-Bahn) | `1Sep1Swi` (Element uebernimmt die fehlende Weiche; " "ein Einschleuselement zaehlt nicht - passiver Merge ohne " "Richtungsentscheidung) |") add("| zusaetzlich: 2 bzw. 3 abgehende Wege | Aufwertung zu `1Sep1Swi` / `1Sep2Swi` |") add("") add("Ein **Scanner** am Separator aendert den Typ nicht. In der Referenzanlage " "sind Separatoren mit Scanner ueberwiegend `1Sep`. `1Sep_SSCC` ist dem " "Sonderfall mit SSCC-Scanner, Endmessung und WCS-Telegramm vorbehalten und " "aus einem mechanischen Layout nicht erkennbar - betroffene Stellen stehen " "als Hinweis in Abschnitt 5.") add("") add("Was aus dem CSV **nicht** ableitbar ist: TRO-Nummerierung, Sensor- und " "Aktor-Tags, JamAreas, Zielsteuerung und Timing. Das kommt aus der " "E-Planung (I/O-Liste) bzw. aus `FB_Main`.") add("") add(f"Typen, FB-Bausteine, Bauteile und Farben stammen vollstaendig aus " f"`TRO_CATALOG` in `lib/tro_catalog.py` (Stand {CATALOG_AS_OF}):") add("") add("| # | Typ | FB-Baustein | Bauteile je TRO | Farbgruppe | Symbol | Fuellfarbe |") add("|---:|---|---|---|---|---|---|") for index, definition in enumerate(TRO_CATALOG, start=1): add(f"| {index} | `{definition.get_name()}` | `{definition.get_fb_block()}` " f"| {definition.describe_items()} | {definition.get_group()} " f"| {definition.describe_symbol()} | `{definition.get_fill()}` |") add("") add("| Bauteil | Sub-FB |") add("|---|---|") for item in sorted({i for d in TRO_CATALOG for i in d.get_item_names()}): fb = ITEM_FB.get(item, "") add(f"| {item} | {f'`{fb}`' if fb else '_kein eigener FB_'} |") add("") add("| Farbgruppe | Bedeutung | Fuellfarbe |") add("|---|---|---|") for style in STYLES: add(f"| {style.group} | {style.label} | `{style.fill}` |") add("") return "\n".join(out) + "\n" # --------------------------------------------------------------------------- # Bericht # --------------------------------------------------------------------------- def report( analysis: Analysis, csv_file: Path, dot_file: Path, svg_file: Path | None, error_file: Path | None, doc_file: Path | None, warnings: list[str], engine: str = "dot", ) -> None: """Konsolenbericht eines Laufs: TRO-Zahl je Typ, Findings, Warnungen, erzeugte Dateien. Parameter: analysis: Ergebnis von analyse_tros. csv_file: Eingabe-CSV. dot_file: erzeugte DOT-Datei. svg_file / error_file / doc_file: erzeugte Dateien oder None. warnings: Warnungen aus material_flow (Einlesen, Graphaufbau). engine: Graphviz-Programm des SVG ("dot" oder "neato", mit -n). """ types_used: dict[str, int] = {} for tro in analysis.tros: types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1 print("") print("================================================================") print("TRO-FLUSS") print("================================================================") print(f"Eingabe = {csv_file}") print(f"Methode = {analysis.method}") print(f"TROs = {len(analysis.tros)}" + (f" ({', '.join(f'{k}: {v}' for k, v in sorted(types_used.items()))})" if types_used else "")) print(f"TRO-Verbindungen = {len(analysis.edges)}, " f"nicht verbunden: {len(analysis.isolated)}") print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), " f"{len(TRO_CATALOG)} Typen") print(f"Layout = {engine}" + (" (lagerichtig, neato -n auf Anlagenkoordinaten, " f"{COORD_SCALE} pt/mm)" if engine == "neato" else " (berechnete Anordnung; lagerichtig mit --use-cords)")) print(f"DOT = {dot_file}") print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}") print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}") print(f"Fehlerdatei = {error_file if error_file else '- (keine Fehler)'}") print("================================================================") if analysis.isolated: print("") print(f"FEHLER: {len(analysis.isolated)} TRO(s) ohne Verbindung zu einem " f"anderen TRO:") for tro in analysis.isolated: print(f" x {tro.tro_id} ({tro.type_name}) - {tro.label}") if analysis.unknown_types: print("") print("FEHLER: TRO-Typen ohne Eintrag in TRO_CATALOG: " + ", ".join(analysis.unknown_types)) if analysis.findings: print("") print(f"Hinweise ({len(analysis.findings)}):") for finding in analysis.findings: print(f" ? {finding}") if warnings: print("") print(f"Warnungen aus dem Materialfluss ({len(warnings)}):") for warning in warnings: print(f" ! {warning}") print("") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv: list[str] | None = None) -> argparse.Namespace: """Kommandozeile auswerten; argv=None liest sys.argv[1:].""" parser = argparse.ArgumentParser( prog="tro_flow", description="Ermittelt aus einem CSV-Export der Anlagenobjekte die " "benoetigten TROs und zeichnet ihren Fluss (DOT, optional SVG).", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Beispiele:\n" " tro_flow.bat --file mubea.csv\n" " tro_flow.bat --file mubea.csv --tosvg\n" " tro_flow.bat --file mubea.csv --tosvg --doc\n" " tro_flow.bat --file mubea.csv --tosvg --use-cords\n" " tro_flow.bat --file mubea.csv --json\n" " tro_flow.bat --file mubea_export.csv --ids mubea_tro_ids.ini --json\n" " tro_flow.bat --file mubea.csv --renumber\n" "\n" "Exit-Codes:\n" " 0 ok\n" " 1 Eingabe- oder Aufruffehler\n" " 2 SVG konnte nicht erzeugt werden (Graphviz)\n" " 3 TRO-Fehler gefunden, Fehlerdatei geschrieben\n", ) parser.add_argument( "--file", default="export.csv", metavar="NAME", help="Name der CSV-Eingabedatei in %%SKEL_DATA%% " "(oder ein vollstaendiger Pfad). Standard: %(default)s", ) parser.add_argument( "--tosvg", action="store_true", help="Aus der erzeugten DOT-Datei zusaetzlich das TRO-Flussdiagramm als " "SVG erzeugen (benoetigt Graphviz 'dot').", ) parser.add_argument( "--use-cords", "--use-coords", dest="use_cords", action="store_true", help="Lagerichtig zeichnen: jeder TRO wird auf seine Anlagenkoordinate " "gesetzt und das SVG von Graphviz 'neato -n' erzeugt statt von " f"'dot'. Massstab {COORD_SCALE} pt/mm. Nur mit --tosvg wirksam.", ) parser.add_argument( "--doc", action="store_true", help="Dokumentation der TROs als Markdown in %%SKEL_RESULTS%% erzeugen " "(TRO-Liste, Verbindungen, Typdefinition).", ) parser.add_argument( "--connect", action="store_true", help="TRO-Topologie als _tro_connect.ini nach %%SKEL_RESULTS%% " "schreiben: Knoten (TRO = Typ), Verbindungen, Systemgrenzen und je " "TRO Vorgaenger/Nachfolger.", ) parser.add_argument( "--json", action="store_true", help="TRO-JSON als _tro.json nach %%SKEL_RESULTS%% schreiben " "(Modell lib/model.py, TroDocument) - Eingabe fuer scl_skeleton.py.", ) parser.add_argument( "--true-scale", dest="true_scale", action="store_true", help="Nur mit --use-cords: masstaeblich zeichnen. Standard kuerzt " f"Leerstrecken ueber {COORD_MAX_LUECKE} mm zwischen den Knoten " "(Reihenfolge und Nachbarschaft bleiben erhalten).", ) parser.add_argument( "--ids", metavar="DATEI", help="ID-Datei der Anlage (Name in %%SKEL_CFG%% oder vollstaendiger " "Pfad). Standard: _tro_ids.ini in %%SKEL_CFG%%. Fehlt sie, " "wird sie angelegt.", ) parser.add_argument( "--renumber", action="store_true", help="Alle TRO-IDs neu entlang des Materialflusses vergeben und die " "Abschnitt [tro] der ID-Datei neu schreiben (fruehe Projektphase - " "umbenannte IDs gehen verloren, andere Abschnitte bleiben).", ) parser.add_argument( "--id-tolerance", dest="id_tolerance", type=float, default=DEFAULT_TOLERANCE, metavar="MM", help="Hoechster Abstand (mm) zwischen Eintrag der ID-Datei und " "Separator, bis zu dem ein TRO als wiedergefunden gilt. " "Standard: %(default)s", ) return parser.parse_args(argv) def resolve_ids_file(arg: str | None, csv_file: Path) -> Path: """Pfad der ID-Datei: --ids (Name in SKEL_CFG oder Pfad), sonst Standard.""" cfg_dir = env_dir("SKEL_CFG", "cfg") if not arg: return ids_path(csv_file.stem, cfg_dir) path = Path(arg) if path.is_absolute() or path.parent != Path("."): return path return cfg_dir / path def write_ids_file(tro_ids: TroIds, analysis: Analysis, elements: list[Element], source: str) -> str: """ID-Datei anlegen, neu schreiben oder neue TROs anhaengen. Rueckgabe: kurze Beschreibung fuer den Bericht. """ result = tro_ids.result if result is None: return "unveraendert" anchors = separator_anchors(elements) if result.renumbered: tro_ids.path.parent.mkdir(parents=True, exist_ok=True) rest = other_sections(tro_ids.path) text = render_ids(analysis.tros, anchors, source) tro_ids.path.write_text(text + (f"\n{rest}\n" if rest else ""), encoding="utf-8") return "neu geschrieben" if tro_ids.exists else "angelegt" if result.new: insert_entries(tro_ids.path, render_new_entries(analysis.tros, anchors, result.new, source)) return f"{len(result.new)} neue(r) TRO(s) angehaengt" return "unveraendert" def report_ids(tro_ids: TroIds, status: str) -> None: """Abschnitt TRO-IDs im Konsolenbericht.""" result = tro_ids.result if result is None: return print(f"TRO-IDs = {tro_ids.path} ({status})") if result.renumbered: grund = "--renumber" if tro_ids.renumber else "keine ID-Datei" print(f" neu entlang des Materialflusses nummeriert " f"({grund})") return print(f" wiedergefunden: {len(result.kept)}, " f"neu: {len(result.new)}, weggefallen: {len(result.missing)} " f"(Toleranz {tro_ids.tolerance:g} mm)") if result.new: print(f" + neu (vorlaeufige ID, in der ID-Datei umbenennbar): " f"{', '.join(result.new)}") for entry in result.missing: merged = result.merged_into.get(entry.tro_id) grund = (f"Separator gehoert jetzt zu {merged}" if merged else f"kein Separator innerhalb {tro_ids.tolerance:g} mm") print(f" - weggefallen: {entry.tro_id} bei {entry.x:.1f}, {entry.y:.1f} " f"({grund}; Eintrag bleibt stehen, ID wird nicht wiederverwendet)") def main(argv: list[str] | None = None) -> int: """CLI-Einstieg: CSV lesen, Graph bauen, analyse_tros, Ausgaben schreiben. Parameter: argv: Argumente ohne Programmnamen; None = sys.argv[1:]. Rueckgabe: Exitcode: 0 = Erfolg, 1 = Eingabe- oder Ausgabefehler, 2 = SVG nicht erzeugt, 3 = TRO-Pruefung fehlgeschlagen (isolierte TROs oder unbekannte Typen, siehe Fehlerdatei). """ args = parse_args(argv) csv_file = resolve_input(args.file) if csv_file.suffix.lower() != ".csv": print(f"FEHLER: --file erwartet eine CSV-Datei, keine '{csv_file.suffix}'-Datei: " f"{csv_file}", file=sys.stderr) return 1 if not csv_file.is_file(): print(f"FEHLER: Eingabedatei nicht gefunden: {csv_file}", file=sys.stderr) print(f" Suchpfad fuer Dateinamen: {env_dir('SKEL_DATA', 'data')}", file=sys.stderr) return 1 results = env_dir("SKEL_RESULTS", "results") results.mkdir(parents=True, exist_ok=True) warnings: list[str] = [] try: elements = read_elements(csv_file, warnings) except (OSError, ValueError, csv.Error) as exc: print(f"FEHLER: {csv_file} konnte nicht gelesen werden: {exc}", file=sys.stderr) return 1 if not elements: print(f"FEHLER: {csv_file} enthaelt keine Objekte.", file=sys.stderr) return 1 graph = build_graph(elements, csv_file.name, warnings) if not graph.nodes: print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte.", file=sys.stderr) return 1 sepliste_counts: dict[str, int] = {} raw_json_file = find_sibling_raw_json(csv_file) if raw_json_file is not None: sepliste_counts = load_sepliste_counts(raw_json_file, warnings) stem = csv_file.stem connect_target = results / f"{stem}_tro_connect.ini" json_target = results / f"{stem}_tro.json" try: tro_ids = TroIds.load(resolve_ids_file(args.ids, csv_file), tolerance=args.id_tolerance, renumber=args.renumber) except (OSError, ValueError) as exc: print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr) return 1 analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts, tro_ids=tro_ids) if not analysis.tros: print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs " f"ableitbar.", file=sys.stderr) return 1 ids_status = write_ids_file(tro_ids, analysis, elements, csv_file.name) dot_file = results / f"{stem}_tro_flow.dot" svg_file = results / f"{stem}_tro_flow.svg" doc_target = results / f"{stem}_tro_doc.md" error_target = results / f"{stem}_tro_errors.md" # Ohne Koordinaten ist kein lagerichtiges Layout moeglich without = missing_positions(analysis) if args.use_cords and without: print(f"FEHLER: --use-cords nicht moeglich, {len(without)} TRO(s) ohne " f"Koordinate: {', '.join(without)}", file=sys.stderr) print(" Ohne pos an jedem Knoten kann 'neato -n' nicht zeichnen.", file=sys.stderr) return 1 dot_file.write_text( render_dot(analysis, csv_file.name, use_coords=args.use_cords, compact=not args.true_scale), encoding="utf-8", ) connect_file = None if args.connect: connect_target.write_text( render_connect_ini(analysis, section=stem, source=csv_file.name), encoding="utf-8", ) connect_file = connect_target json_file = None if args.json: document = TroDocument.from_analysis( analysis, source=csv_file.name, note="Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die " "Zeichnung ist nur Ansicht, keine Datenquelle.", ) json_target.write_text(document.to_json(), encoding="utf-8") json_file = json_target has_errors = write_error_file(analysis, csv_file, error_target) error_file = error_target if has_errors else None engine = "neato" if args.use_cords else "dot" svg_error: str | None = None if args.tosvg: try: graph_to_svg(dot_file, svg_file, engine=engine, no_op=1 if args.use_cords else 0) except RuntimeError as exc: svg_error = str(exc) created_svg = svg_file if args.tosvg and svg_error is None else None doc_file = None if args.doc: doc_target.write_text( render_doc(analysis, graph, csv_file, dot_file, created_svg, error_file), encoding="utf-8", ) doc_file = doc_target report(analysis, csv_file, dot_file, created_svg, error_file, doc_file, warnings, engine=engine) report_ids(tro_ids, ids_status) if connect_file: print(f"connect.ini = {connect_file}") if json_file: print(f"TRO-JSON = {json_file}") if svg_error: print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr) return 2 if has_errors: print(f"FEHLER: TRO-Pruefung fehlgeschlagen - siehe {error_target}", file=sys.stderr) return 3 return 0 if __name__ == "__main__": sys.exit(main())