TRO-Fluss: Weichenerkennung ohne Bahn-Sperre, Vario/1Sep-Sonderfaelle je Separator

_find_switch_separators: Regel 2 (naechster freier Separator <= 1500mm) lief
bisher nur auf einer Bahn, die Regel 1 (<=500mm) schon bestaetigt hatte. Bei
HundM05 lagen nach einer CAD-Aktualisierung alle 5 echten Weichen-Separatoren
gleichmaessig bei ca. 1013-1016mm - ausserhalb von Regel 1, wodurch die
Bahn-Sperre fuer jede Bahn zublieb und keine einzige Weiche mehr erkannt
wurde. Die Sperre entfaellt; SWITCH_FALLBACK_MM bleibt die alleinige Schranke
gegen Fehlzuordnungen (der eigentliche Schutz vor dem urspruenglichen
Mubea-Fehlfund, der 5-9m entfernte Separatoren betraf).

derive_tros, angetriebene Strecken (Schritt 2): zwei Faelle stufen einen
Separator trotz Host "Strecke" als 1Sep statt Vario ein:

- 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 (HundM05 VF_3 vor Ort bestaetigt).
- Ein nicht-letzter Separator, dessen vorangehendes Segment "horizontal"
  (keine aktive Antriebsrichtung) gefuehrt wird, dabei aber tatsaechlich an
  Hoehe verliert (> 30mm) - ein unbetriebenes Gefaelle-Teilstueck innerhalb
  einer gemischten VF/GF-Streckengruppe (HundM05 VF_1/0035).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-09-23 17:20:18 +02:00
co-authored by Claude Sonnet 5
parent df6d56090e
commit 97965e8ec7
+383 -62
View File
@@ -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 = "<!-- erzeugt von lib/tro_flow.py -->"
# 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)