diff --git a/lib/tro_flow.py b/lib/tro_flow.py index 067edd3..15fdf78 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -28,9 +28,32 @@ doc/500573_Mubea/TRO_Identifikation_500573.md: ----------------------------------------------------------------------- Gefaellestrecke, >= 2 Linien -> PinStore_Auto (ein Block je Gruppe) Gefaellestrecke, einzelne Linie -> 1Sep - Strecke (angetrieben) -> Vario (je Streckensegment) + 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, HundM05): + +1. Letzter Separator vor dem Wiedereinschleusen auf die Kreisel-Bahn (in + TrackIds-Reihenfolge) - reiner Vereinzeler ohne eigene VarioDrive-Strecke, + die Kreisel-Bahn treibt ab dort selbst an. 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). +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 (nicht letzter) 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). 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). + 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 @@ -73,6 +96,7 @@ from material_flow import ( LANE_RIGHT, ROTATION_CCW, ROTATION_CW, + TRANSPORT_KINDS, UNASSIGNED, Element, Graph, @@ -93,6 +117,7 @@ from tro_catalog import ( STYLES, TRO_CATALOG, TroDefinition, + TroItem, get_tro, mermaid_classdefs, ) @@ -131,6 +156,15 @@ SWITCH_PROXIMITY_MM = 500.0 # zugeordnet, auch wenn das 5-9 Meter entfernt lag. SWITCH_FALLBACK_MM = 1500.0 +# Mindest-Hoehenverlust (mm) auf dem der Reihe nach vorangehenden TrackIds- +# Segment, damit ein Separator auf einer "Strecke" trotzdem als 1Sep statt +# Vario gilt (siehe _incoming_drop). Muss nur "horizontal" gefuehrte, aber +# tatsaechlich Gefaelle-Segmente erwischen (HundM05 Separator 0035: -104mm auf +# 3,8m), nicht das Rauschen eines echten flachen VF-Laufs (HundM05 Separator +# 0036: +26mm auf 0,5m). 30mm liegt bewusst weit unter dem echten Befund und +# weit ueber dem Rauschen. +GEFAELLE_DROP_MM = 30.0 + GENERATED_MARKER = "" # Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in @@ -309,18 +343,29 @@ def _find_switch_separators( die Strecke zeigt (in den Daten kommt beides vor, siehe derive_tros). 2. Naechstgelegener freier Separator je noch unbelegtem Ausschleuselement, jedes Paar nur einmal vergeben (kleinste Distanz zuerst, hoechstens - SWITCH_FALLBACK_MM), und nur auf einer Bahn, die bereits durch Regel 1 - einen bestaetigten Treffer hat (eine Bahn ohne jeden nahen Treffer hat - vermutlich gar keine Weiche, z. B. Kreisel1 Bahn R bei HundM05). Grund - fuer den zweiten Durchlauf (HundM05, Review 2026-09-18): an einem - Kreisel mit 5 Abzweigen lagen die tatsaechlichen Schalt-Separatoren pro - Abzweig unterschiedlich weit vom Uebergabeelement entfernt (418-1139mm - in der damaligen Registrierung) - eine einzelne mm-Schwelle (Regel 1) - trifft nicht alle. SWITCH_FALLBACK_MM begrenzt das trotzdem: ohne - Obergrenze hat Regel 2 an einer groesseren Anlage (Mubea) uebrig - gebliebene, tatsaechlich unbeteiligte Separatoren dem naechsten noch - freien Ausschleuselement zugeordnet, auch wenn das 5-9 Meter entfernt - lag - siehe SWITCH_FALLBACK_MM. + SWITCH_FALLBACK_MM) - unabhaengig davon, ob Regel 1 auf derselben Bahn + bereits einen Treffer hatte. Grund fuer den zweiten Durchlauf (HundM05, + Review 2026-09-18): an einem Kreisel mit 5 Abzweigen lagen die + tatsaechlichen Schalt-Separatoren pro Abzweig unterschiedlich weit vom + Uebergabeelement entfernt (418-1139mm in der damaligen Registrierung) + - eine einzelne mm-Schwelle (Regel 1) trifft nicht alle. + SWITCH_FALLBACK_MM begrenzt das trotzdem: ohne Obergrenze hat Regel 2 + an einer groesseren Anlage (Mubea) uebrig gebliebene, tatsaechlich + unbeteiligte Separatoren dem naechsten noch freien Ausschleuselement + zugeordnet, auch wenn das 5-9 Meter entfernt lag - siehe + SWITCH_FALLBACK_MM. + + Frueher lief Regel 2 nur auf einer Bahn, die Regel 1 bereits bestaetigt + hatte (eine Bahn ohne jeden nahen Treffer hat vermutlich gar keine + Weiche). Diese Bahn-Sperre wurde entfernt (Review 2026-09-23): bei + einer neueren HundM05-Revision lagen ALLE 5 echten Schalt-Separatoren + gleichmaessig bei ca. 1013-1016mm - ausserhalb von Regel 1 (500mm), aber + innerhalb von SWITCH_FALLBACK_MM. Ohne einen einzigen Regel-1-Treffer + blieb die Bahn-Sperre fuer jede Bahn zu, Regel 2 kam nie zum Zug, und + keine der 5 Weichen wurde erkannt. SWITCH_FALLBACK_MM bleibt die + einzige Schranke gegen Fehlzuordnungen (siehe Mubea-Fall oben) - sollte + sich das an einer weiteren Anlage als zu grosszuegig erweisen, braucht + es dort eine gezieltere Loesung statt der Bahn-Sperre. """ transfers = [e for e in elements if e.kind == "Ausschleus"] result: dict[str, tuple[Element, float]] = {} @@ -338,15 +383,9 @@ def _find_switch_separators( if best is not None: result[sep.teile_id] = (best[1], best[0]) - # Bahnen mit mindestens einem durch Regel 1 bestaetigten Treffer - nur - # dort darf Regel 2 ueberhaupt suchen (siehe Docstring). - switch_bahnen = {sep.assignment for sep in separators - if sep.teile_id in result and sep.assignment} - used_transfers = {hit[0].teile_id for hit in result.values()} remaining_seps = [s for s in separators if s.teile_id not in result - and s.x is not None and s.y is not None - and s.assignment in switch_bahnen] + and s.x is not None and s.y is not None] candidates: list[tuple[float, Element, Element]] = [] for sep in remaining_seps: @@ -369,6 +408,39 @@ def _find_switch_separators( 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. + """ + 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 derive_tros( graph: Graph, elements: list[Element], @@ -396,6 +468,7 @@ def derive_tros( 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 an einem Ausschleuselement (siehe _find_switch_separators) # werden VOR der normalen Host-Aufloesung herausgenommen: sie bilden gleich @@ -486,23 +559,78 @@ def derive_tros( ) ) - # 2) Angetriebene Strecken -> je Segment ein Vario + # 2) Angetriebene Strecken -> je SEPARATOR ein eigener Vario-TRO. Katalog + # (TRO_CATALOG in tro_catalog.py) definiert Vario als 1x Separator + 1x + # VarioDrive - mehrere Separatoren auf demselben Streckensegment sind + # mehrere eigenstaendige Uebergabestellen, keine gemeinsame (Review + # 2026-09-21). Ein Segment ganz ohne Separator behaelt weiterhin einen + # einzelnen TRO (leere Separatorliste), damit das Antriebssegment selbst + # nicht verschwindet. for node_id in sorted(n for n, node in graph.nodes.items() if node.kind == "Strecke"): seps = seps_on([node_id]) element = graph.nodes[node_id].element drive = element.drive_dir or "unbekannt" - tros.append( - Tro( - tro_id="", - type_name="Vario", - label=element.name, - hosts=[node_id], - separators=seps, - scanners=scanners_for(seps), - reason=f"angetriebenes Streckensegment (Antriebfahrtrichtung: {drive})", - confidence="hoch", + reason = f"angetriebenes Streckensegment (Antriebfahrtrichtung: {drive})" + + # Letzter Separator vor dem Wiedereinschleusen auf die Kreisel-Bahn + # (in echter TrackIds-Reihenfolge, nicht sortierte Teile-Id) ist ein + # reiner Vereinzeler ohne eigene VarioDrive-Strecke: die Kreisel-Bahn + # uebernimmt den Antrieb ab dort selbst - 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. Gilt unabhaengig von der + # Separatoranzahl - siehe auch VF_1 (0034, letzter von vieren). Nur + # bei genau einem Separator gibt es kein "erster vs. letzter", der + # bleibt Vario (er ist der einzige Antriebspunkt der Strecke). + seps_set = set(seps) + track_seps = [tid for tid in element.track_ids if tid in seps_set] + last_sep_id = track_seps[-1] if len(track_seps) > 1 else None + + for sep_id in seps or [None]: + sep_list = [sep_id] if sep_id else [] + type_name = "Vario" + sep_reason = reason + confidence = "hoch" + if sep_id is not None and sep_id == last_sep_id: + type_name = "1Sep" + confidence = "mittel" + sep_reason = ( + f"letzter Separator vor dem Wiedereinschleusen von " + f"{element.name} auf die Kreisel-Bahn - reiner Vereinzeler, " + f"kein eigenes VarioDrive mehr noetig (Review 2026-09-23)" + ) + findings.append( + f"{element.name} Separator {sep_id}: letzter Separator vor dem " + f"Wiedereinschleusen - als 1Sep statt Vario eingestuft" + ) + elif sep_id is not None: + 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'" + ) + 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" + ) + tros.append( + Tro( + tro_id="", + type_name=type_name, + label=element.name, + hosts=[node_id], + separators=sep_list, + scanners=scanners_for(sep_list), + reason=sep_reason, + confidence=confidence, + ) ) - ) # 3) Separatoren auf Kreisel-Bahnen -> je Separator eine Uebergabestelle for sep_id in sorted( @@ -624,26 +752,35 @@ def derive_tros( # Typnamen fuer Systemgrenz-Knoten (siehe derive_boundaries) - bewusst NICHT -# in TRO_CATALOG: eine BTMT Be-/Entladestation ist kein Steuerobjekt mit -# eigenem FB-Baustein, nur der tatsaechliche Materialein-/ausgang. +# 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[Tro]: - """BTMT Be-/Entladestationen als sichtbare Systemgrenz-Knoten. + """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 BTMT-Station ist nie - ein TRO-Host (sie hat keinen Separator), also wird sie beim Durchlaufen - als passiver Zwischenknoten behandelt - und da eine Entladung (Senke) - keine ausgehende Kante mehr hat, bricht die Suche dort einfach ab, ohne - eine Kante einzutragen. Eine Beladung (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. + 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 @@ -653,7 +790,7 @@ def derive_boundaries(elements: list[Element], graph: Graph) -> list[Tro]: """ boundaries: list[Tro] = [] for element in elements: - if element.kind not in ("Beladung", "Entladung"): + if not element.is_station: continue if element.teile_id not in graph.nodes: continue @@ -665,7 +802,7 @@ def derive_boundaries(elements: list[Element], graph: Graph) -> list[Tro]: hosts=[element.teile_id], separators=[], scanners=[], - reason="Systemgrenze (BTMT Be-/Entladestation) - kein TRO, " + reason="Systemgrenze (Be-/Entladestation) - kein TRO, " "nur Materialein-/ausgang", confidence="hoch", ) @@ -754,6 +891,7 @@ def link_tros( 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: @@ -782,26 +920,169 @@ def link_tros( continue queue.extend(forward.get(node, [])) - # Mehrere TROs auf derselben Kreisel-Bahn liegen HINTEREINANDER, nicht - # nebeneinander: das Material laeuft die Bahn in Foerderrichtung ab. Sie - # werden daher der Reihe nach verkettet statt nur als "gleiche Bahn" - # markiert - sonst fehlt z. B. die Kante von der einen Sperre zur naechsten. - # Gruppiert wird ueber _tro_bahn() (Separator-Zuordnung), NICHT ueber - # owners/host: ein Weichen-TRO liegt zwar physisch auf der Bahn, sein Host - # wurde in derive_tros Schritt 3b aber auf die gefuetterte Strecke gelegt - # (review 2026-09-21 - sonst fehlt bei HundM05 die Reihenfolge 0008->0010-> - # 0012->0014->0020 auf Kreisel1 Bahn L komplett, sobald deren Weichen einen - # eigenen Host haben und nur noch ein einzelner Separator auf der Bahn - # selbst uebrig bleibt). + # 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: + if a == b: + return + # Eine Systemgrenze ist nur in EINE Richtung gueltig (siehe + # derive_boundaries: Beladung ist Quelle, nie Ziel; Entladung ist + # Senke, nie Quelle) - unabhaengig davon, wie TrackIds sie in der + # geschlossenen Umlaufliste nacheinander auffuehrt. + if by_id[a].type_name == BOUNDARY_EXIT or by_id[b].type_name == BOUNDARY_ENTRY: + return + 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). + 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 + + for el in elements: + if not el.is_circle or len(el.track_ids) < 2: + continue + chain = list(el.track_ids) + if el.rotation in (ROTATION_CW, ROTATION_CCW): + chain.append(chain[0]) # Umlauf schliessen + + # Hauptkette: Separatoren/Systemgrenzen in Reihenfolge, Aus-/ + # Einschleuselemente bewusst uebersprungen (unowned) - das beschreibt + # den BYPASS fuer Verkehr, der NICHT abzweigt (z. B. TRO01 -> TRO03, + # wenn dazwischen niemand abbiegt). + owned = [(i, elem_owner[tid]) for i, tid in enumerate(chain) if tid in elem_owner] + for (_, a), (_, b) in zip(owned, owned[1:]): + _add_chain_pair(a, b) + if owned: + covered_kreisel_bases.add(el.teile_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[Tro]] = {} for tro in tros: bahn = _tro_bahn(tro, graph, sep_assignment) - if bahn: + if bahn and bahn.split("-")[0] not in covered_kreisel_bases: bahn_gruppen.setdefault(bahn, []).append(tro) - verkettete_paare: set[frozenset[str]] = set() for bahn, gruppe in bahn_gruppen.items(): if len(gruppe) < 2: continue @@ -822,12 +1103,52 @@ def link_tros( 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. + for tro in tros: + definition = tro.definition() + if not (definition and definition.has_item(TroItem.SWITCH)) or len(tro.hosts) != 1: + continue + ziel_id = strecke_first_owned.get(tro.hosts[0]) + 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) + + # 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 (nicht: irgendein verketteter TRO - ein Weichen-TRO kann zugleich - # auf der Bahn verkettet UND mit seinem Vario-TRO auf derselben Strecke - # colocated sein, das sind zwei verschiedene Beziehungen) braucht die - # ungerichtete Notiz nicht zusaetzlich. + # 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. for host, ids in owners.items(): for tro_id in ids: tro = next(t for t in tros if t.tro_id == tro_id)