diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index f37ea4f..64a93c4 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -162,12 +162,42 @@ id ranges, e.g. `Gefaellestrecke 0017 - 0036 (20x)` / `Separator (20): 0037 - in `lib/material_flow.py`) — the underlying graph, `--doc` report and `--connect` output are unaffected and still list every line individually. -Flow direction between conveyor segments is derived from height change -(`Hoehe_Von_mm`/`Hoehe_Bis_mm`) or, failing that, from `Antriebfahrtrichtung` -("Auf"/"Ab"); connections whose direction can't be determined are drawn -bidirectional and reported as a warning. `--doc` additionally cross-checks -stated `Anzahl_Separator`/`Anzahl_Scanner` counts against what was actually -attached. +Flow direction between a Kreisel and a Strecke/Gefällestrecke is set from the +**Ausschleus-/Einschleuselement** at each end (`_apply_transfers`): the +Ausschleuselement is the Kreisel outlet (Kreisel → Strecke, start), the +Einschleuselement the inlet (Strecke → Kreisel, end). A conveyor whose two ends +dock on the *same* Kreisel (a return line — Eingang and Ausgang on one Kreisel) +is drawn as **two** edges — one onto each lane (`0006-L → VF` on the way out, +`VF → 0006-R` on the way back) — so it is never flat/undetermined. The transfer +element names its conveyor via the `Strecke` **or** `Foerderer`/`Förderer` +attribute (both are read). + +Where no transfer element resolves the direction, `drive_uphill` applies the +physics rule: **only a pure Gefällestrecke** takes its direction from the height +(`Hoehe_Von_mm`/`Hoehe_Bis_mm` → flows downhill; a GF with no height runs downhill +by definition). A Förderer (VF) can convey up *or* down and a `Strecke` is often +a mixed GF/VF chain, so **height is not a direction indicator for them** — they +use an unambiguous `Antriebfahrtrichtung` (exactly `"Auf"`/`"Ab"`, not a mixed +list like `"horizontal,horizontal,Auf"`) and otherwise stay undetermined +(direction is expected to come from the AS/ES ends). Only what nothing resolves +is drawn bidirectional and reported as a warning. + +Entry/exit stations are hung on the Kreisel **lane** they belong to +(`_attach_stations`): first the Kreisel whose `TrackIds` umlauf lists the station, +then the entry-/exit-lane from its `Drehrichtung` (UZS → entry lane R / exit lane +L, GUZ reversed). Sources (`Beladung`/`Eingang`) get `Station → lane`, sinks +(`Entladung`/`Ausgang`) `lane → Station`. Neighbours that aren't flow objects +(another station, a separator) are skipped, so two BTMT points no longer get +wired to each other. + +`Eingang`/`Ausgang` are the **generic symbols** for the concrete BTMT stations +`Beladung`/`Entladung` (same source/sink role). When a symbol lies almost on top +of a BTMT station of the same role (≤ `STATION_MERGE_MM` = 600 mm, `x`/`y` from +the `Position` column), only **one** node is drawn: the concrete BTMT station is +kept, the generic symbol is swallowed (`_merge_station_symbols`). A standalone +`Eingang`/`Ausgang` with no nearby BTMT partner is drawn as its own green/red +node. `--doc` additionally cross-checks stated `Anzahl_Separator`/`Anzahl_Scanner` +counts against what was actually attached. `--track` builds a second, finer-grained graph in addition to the one above: Separator, Ausschleus-/Einschleuselement, Weiche and the entry/exit points diff --git a/doc/track.md b/doc/track.md index fda7655..ef2cf7a 100644 --- a/doc/track.md +++ b/doc/track.md @@ -46,7 +46,11 @@ Kreisel 0001 trackIds = [0040(Sep@0001-R), 0079(Weiche), 0036(Sep@0014)] 4. **Systemgrenzen** — BTMT Be-/Entladung werden über den bereits im groben Graphen aufgelösten Anschluss angebunden (`_attach_track_stations`); offene Bahnenden ohne Station bekommen ein generisches Symbol Eingang/Ausgang - (`_attach_track_symbols`). Wichtig: die Offenheit wird am **feinen** Graphen + (`_attach_track_symbols`). Ein generisches Eingang/Ausgang-Symbol, das + deckungsgleich über einer konkreten BTMT-Station liegt (≤ `STATION_MERGE_MM`), + wird auch hier verschluckt (`_merge_station_symbols`) — sowohl aus der + Knotenliste als auch aus den `TrackIds` jedes Containers —, sonst liefe der + Kreisel-Umlauf durch zwei nahezu identische Knoten. Wichtig: die Offenheit wird am **feinen** Graphen gemessen (ein reales Bahnelement ohne Vorgänger/Nachfolger in `track.edges`), **nicht** am groben. Der grobe Graph kann eine Bahn als offenes Ende führen, obwohl ihre AS/ES-Enden im Kreisel-Umlauf längst verkettet sind (die Richtung diff --git a/lib/material_flow.py b/lib/material_flow.py index f7ccf07..4985f9b 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -74,7 +74,7 @@ import re import shutil import subprocess import sys -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime from pathlib import Path @@ -102,24 +102,37 @@ CIRCLE_KINDS = ("Kreisel",) # Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten) DEVICE_KINDS = ("Separator", "Scanner") -# Ein-/Ausgabestationen (BTMT Be-/Entladung) = Materialquelle/-senke, d.h. die -# Ein-/Ausgangspunkte des Materialflusses. Sie werden in sps_skel erkannt; -# dxfmakros exportiert sie derzeit NICHT (Blockmuster fehlt), die Erkennung -# greift also, sobald eine passende Zeile im CSV auftaucht - erkannt an -# TeileArt/Bezeichnung: "Beladung"/"BTMT-Beladung"/"Eingang" -> Eingang, -# "Entladung"/"SC_Entladung"/"Abwurf"/"Ausgang" -> Ausgang (ein anderer Export -# nennt dieselben Ein-/Ausgabepunkte schlicht "Eingang"/"Ausgang" statt der -# BTMT-Namen - gleiche Rolle, andere Schreibweise). -ENTRY_KINDS = ("Beladung",) # BTMT-Beladung/Eingang -> Materialeingang (Quelle) -EXIT_KINDS = ("Entladung",) # SC_Entladung/Ausgang -> Materialausgang (Senke) -STATION_KINDS = ENTRY_KINDS + EXIT_KINDS -# Schluesselwoerter zur Erkennung der Stationen (Kleinschreibung, Teilstring) +# Ein-/Ausgabestationen = Materialquelle/-senke, d.h. die Ein-/Ausgangspunkte +# des Materialflusses. Es gibt sie in zwei Auspraegungen: +# * konkrete BTMT-Station: "Beladung" (Quelle) / "Entladung" (Senke) +# * generisches Symbol: "Eingang" (Quelle) / "Ausgang" (Senke) - der +# allgemeinere Bezeichner fuer dasselbe. Liegt ein Symbol nahezu deckungs- +# gleich ueber der konkreten BTMT-Station (haeufig, gleiche x/y), wird nur +# ein Knoten gezeichnet: die BTMT-Station gewinnt, das Symbol wird +# verschluckt (siehe _merge_station_symbols / STATION_MERGE_MM). +ENTRY_KINDS = ("Beladung", "Eingang") # -> Materialeingang (Quelle) +EXIT_KINDS = ("Entladung", "Ausgang") # -> Materialausgang (Senke) + +STATION_KINDS = ENTRY_KINDS + EXIT_KINDS +# Die generischen Symbole (verschluckbar zugunsten der konkreten BTMT-Station). +STATION_SYMBOL_KINDS = ("Eingang", "Ausgang") + +# Schluesselwoerter zur Erkennung der Stationen (Kleinschreibung, Teilstring). +# "eingang"/"ausgang" MUESSEN nach den spezifischeren Mustern kommen, damit z.B. +# "BTMT-Beladung" als Beladung (nicht Eingang) klassifiziert wird. STATION_PATTERNS = ( - ("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung", "eingang")), - ("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf", "ausgang")), + ("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")), + ("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf")), + ("Eingang", ("eingang",)), + ("Ausgang", ("ausgang",)), ) +# Max. Abstand (mm), ab dem ein generisches Eingang/Ausgang-Symbol als +# "deckungsgleich" mit einer BTMT-Station gilt und verschluckt wird. Die +# beobachteten Paare liegen bei 256-431 mm, echte getrennte Stationen >1000 mm. +STATION_MERGE_MM = 600.0 + # Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen # Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten: # Ausschleuselement = Kreisel -> Strecke (am Streckenanfang, Material verlaesst Kreisel) @@ -178,6 +191,10 @@ NODE_STYLES = { "Strecke": dict(fillcolor="#e2f0d9", fontcolor="#375623", color="#548235"), "Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"), "Entladung": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"), + # Generische Symbole - gleiche Farbe wie die konkreten BTMT-Stationen + # (gruen = Eingang/Quelle, rot = Ausgang/Senke). + "Eingang": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"), + "Ausgang": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"), } DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080") @@ -848,35 +865,43 @@ def drive_uphill(element: Element) -> tuple[bool | None, str]: False = Fluss laeuft vom hoechsten Nachbarn weg (bergab) None = nicht bestimmbar - Bewertet wird in dieser Reihenfolge: - 1. Hoehe_Von_mm / Hoehe_Bis_mm - die Hoehenaenderung des Objekts selbst - 2. Antriebfahrtrichtung "Auf" (nach oben) bzw. "Ab" (nach unten) - 3. Gefaellestrecke ohne Angaben laeuft per Definition bergab + Die Hoehe bestimmt die Richtung NUR bei einer reinen Gefaellestrecke - dort + laeuft der Fluss per Definition bergab (Schwerkraft). Ein Foerderer (VF) + kann bauartbedingt nach oben ODER unten foerdern; eine Strecke ist oft eine + gemischte GF/VF-Kette. Fuer beide ist die Hoehendifferenz KEIN Richtungs- + indikator - ihre Richtung kommt aus den Ein-/Ausschleuselementen + (_apply_transfers, AS -> ES) bzw. einer eindeutigen Antriebfahrtrichtung, + sonst bleibt sie unbestimmt. - Der zweite Rueckgabewert nennt die verwendete Quelle fuer Bericht und - Zeichnung. + Bewertet wird daher: + 1. Gefaellestrecke: Hoehe_Von/Bis (bergab), sonst per Definition bergab + 2. Antriebfahrtrichtung "Auf"/"Ab" (nur wenn eindeutig, nicht bei + gemischten Listen wie "horizontal,horizontal,Auf") + 3. sonst None (unbestimmt) """ - rise = element.rise - if rise is not None and rise != 0: - direction = "Auf" if rise > 0 else "Ab" - return rise > 0, ( - f"Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis " - f"{element.height_to:.0f} mm ({direction}, {rise:+.0f} mm)" - ) - - drive = element.drive_dir.lower() - if drive.startswith("auf"): - return True, f"Antriebfahrtrichtung '{element.drive_dir}' (nach oben)" - if drive.startswith("ab"): - return False, f"Antriebfahrtrichtung '{element.drive_dir}' (nach unten)" - if element.kind == "Gefaellestrecke": + rise = element.rise + if rise is not None and rise != 0: + direction = "Auf" if rise > 0 else "Ab" + return rise > 0, ( + f"Gefaellestrecke Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis " + f"{element.height_to:.0f} mm ({direction}, {rise:+.0f} mm)" + ) return False, "Gefaellestrecke ohne Hoehenangabe - laeuft bergab" + # Foerderer/Strecke: Hoehe sagt NICHTS ueber die Richtung. Nur eine + # eindeutige Antriebfahrtrichtung (genau "Auf" bzw. "Ab", keine gemischte + # Liste) zaehlt. + drive = element.drive_dir.strip().lower() + if drive == "auf": + return True, f"Antriebfahrtrichtung '{element.drive_dir}' (nach oben)" + if drive == "ab": + return False, f"Antriebfahrtrichtung '{element.drive_dir}' (nach unten)" + return None, ( - f"weder Hoehe_Von/Bis noch Antriebfahrtrichtung verwertbar " - f"(Antrieb '{element.drive_dir}', Hoehen " - f"{element.height_from}/{element.height_to})" + f"Foerderer/Strecke - Hoehe ist kein Richtungsindikator und " + f"Antriebfahrtrichtung '{element.drive_dir or '-'}' nicht eindeutig " + f"(Richtung kommt sonst aus AS/ES)" ) @@ -994,24 +1019,35 @@ def _nearest_kreisel(station: Element, elements: list[Element]) -> Element | Non return best +def _kreisel_containing(station_id: str, elements: list[Element]) -> Element | None: + """Kreisel, in dessen TrackIds-Umlauf die Station liegt (oder None).""" + for el in elements: + if el.is_circle and station_id in el.track_ids: + return el + return None + + def _attach_stations( elements: list[Element], by_id: dict[str, Element], nodes: dict[str, Node], owners_of: dict[str, list[str]], warnings: list[str] ) -> list[Edge]: """ - Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden. + Ein-/Ausgabestationen (BTMT Be-/Entladung, Eingang/Ausgang) mit dem Fluss + verbinden. + + Eingang (Beladung) ist Quelle: Kante Station -> Kreisel-Bahn. + Ausgang (Entladung) ist Senke: Kante Kreisel-Bahn -> Station. + + Die Zielbahn kommt in dieser Reihenfolge: + 1. Der Kreisel, in dessen TrackIds-Umlauf die Station liegt (verlaesslich, + siehe Element.track_ids) - Eingangs-/Ausgangsbahn aus der Drehrichtung + (_weiche_lane_roles: UZS -> Eingang R / Ausgang L, GUZ umgekehrt). + 2. Ersatzweise: ein Flussobjekt aus den 'Nachbarn' (Separator/Station als + Nachbar werden bewusst uebersprungen - sonst haengen zwei BTMT-Punkte + aneinander statt an der Bahn). + 3. Ersatzweise: der naechstgelegene Kreisel per Abstand, sonst der + naechste Flussknoten. - Eingang (Beladung) ist Quelle: Kante Station -> Ziel. - Ausgang (Entladung) ist Senke: Kante Ziel -> Station. - Ziel kommt zuerst aus 'TrackIds' des Containers (siehe _track_owner) - ein - Kreisel-Container legt ueber die Eingangs-/Ausgangsbahn fest (siehe - _weiche_lane_roles): UZS -> Eingang Bahn R / Ausgang Bahn L, GUZ -> Eingang - Bahn L / Ausgang Bahn R. Kennt TrackIds keinen Container (aeltere Exports - ohne die Spalte), zaehlen ersatzweise die 'Nachbarn' des Exports (fuer BTMT - inzwischen zwar meist gefuellt, nennen dort aber i. d. R. Separatoren oder - die jeweils andere Station statt eines echten Flussknotens - siehe unten), - dann baulich der naechstgelegene Kreisel, und zuletzt die naechstgelegene - Bahn/Strecke per Abstand. """ edges: list[Edge] = [] for element in elements: @@ -1020,62 +1056,94 @@ def _attach_stations( sid = element.teile_id if sid not in nodes: continue + partners: list[str] = [] - owner = _track_owner(element, owners_of, by_id, warnings) - if owner is not None and owner.is_circle: - roles = _weiche_lane_roles(owner.teile_id, by_id, nodes) - if roles: + # owner = _track_owner(element, owners_of, by_id, warnings) + # if owner is not None and owner.is_circle: + # roles = _weiche_lane_roles(owner.teile_id, by_id, nodes) + # if roles: + # eingang, ausgang = roles + # lane, role = (eingang, "Eingangsbahn") if element.is_entry else (ausgang, "Ausgangsbahn") + # partners = [lane] + # warnings.append( + # f"{element.describe()}: aus TrackIds an Kreisel {owner.teile_id} " + # f"Bahn {lane.split('-')[-1]} angebunden ({role}, Drehrichtung {owner.rotation})" + # ) + # else: + # warnings.append( + # f"{element.describe()}: TrackIds nennt Kreisel {owner.teile_id}, aber dessen " + # f"Drehrichtung ist unbekannt - Bahn nicht bestimmbar" + # ) + # elif owner is not None and owner.teile_id in nodes: + # partners = [owner.teile_id] + # warnings.append(f"{element.describe()}: aus TrackIds an {owner.describe()} angebunden") + # if not partners: + # # 'Nachbarn' verweist bei BTMT-Stationen haeufig auf Separatoren + # # oder die jeweils andere Station (naechste Objekte per Abstand, + # # keine echte Flussverbindung) - eine andere Station wird deshalb + # # nie als Partner akzeptiert, nur echte Flussobjekte. + # for base, lane in element.neighbours: + # if lane and f"{base}-{lane}" in nodes: + # partners.append(f"{base}-{lane}") + # elif base in nodes and not nodes[base].element.is_station: + + # 1. Kreisel aus dem TrackIds-Umlauf + Bahn aus der Drehrichtung. + container = _kreisel_containing(sid, elements) + if container is not None: + roles = _weiche_lane_roles(container.teile_id, by_id, nodes) + if roles is not None: eingang, ausgang = roles - lane, role = (eingang, "Eingangsbahn") if element.is_entry else (ausgang, "Ausgangsbahn") + lane, role = ((eingang, "Eingangsbahn") if element.is_entry + else (ausgang, "Ausgangsbahn")) partners = [lane] warnings.append( - f"{element.describe()}: aus TrackIds an Kreisel {owner.teile_id} " - f"Bahn {lane.split('-')[-1]} angebunden ({role}, Drehrichtung {owner.rotation})" + f"{element.describe()}: im Umlauf von Kreisel " + f"{container.teile_id} - an Bahn {lane.split('-')[-1]} " + f"angebunden ({role}, Drehrichtung {container.rotation})" ) - else: - warnings.append( - f"{element.describe()}: TrackIds nennt Kreisel {owner.teile_id}, aber dessen " - f"Drehrichtung ist unbekannt - Bahn nicht bestimmbar" - ) - elif owner is not None and owner.teile_id in nodes: - partners = [owner.teile_id] - warnings.append(f"{element.describe()}: aus TrackIds an {owner.describe()} angebunden") + + # 2. Nachbarn, aber nur echte Flussobjekte (kein Separator/Station). if not partners: - # 'Nachbarn' verweist bei BTMT-Stationen haeufig auf Separatoren - # oder die jeweils andere Station (naechste Objekte per Abstand, - # keine echte Flussverbindung) - eine andere Station wird deshalb - # nie als Partner akzeptiert, nur echte Flussobjekte. for base, lane in element.neighbours: + partner_el = by_id.get(base) + if partner_el is None or not (partner_el.is_transport or partner_el.is_circle): + continue if lane and f"{base}-{lane}" in nodes: partners.append(f"{base}-{lane}") - elif base in nodes and not nodes[base].element.is_station: + elif base in nodes: partners.append(base) else: partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT) if f"{base}-{L}" in nodes] + + # 3. Naechster Kreisel per Abstand (Bahn aus Drehrichtung), sonst + # naechster Flussknoten. if not partners and (element.is_entry or element.is_exit): kreisel = _nearest_kreisel(element, elements) roles = _weiche_lane_roles(kreisel.teile_id, by_id, nodes) if kreisel else None if roles: eingang, ausgang = roles - lane, role = (eingang, "Eingangsbahn") if element.is_entry else (ausgang, "Ausgangsbahn") + lane, role = ((eingang, "Eingangsbahn") if element.is_entry + else (ausgang, "Ausgangsbahn")) partners = [lane] warnings.append( - f"{element.describe()}: keine Nachbarn im Export - " - f"an Kreisel {kreisel.teile_id} Bahn {lane.split('-')[-1]} " - f"angebunden ({role}, Drehrichtung {kreisel.rotation})" + f"{element.describe()}: nicht in einem TrackIds-Umlauf - " + f"an naechsten Kreisel {kreisel.teile_id} Bahn " + f"{lane.split('-')[-1]} angebunden ({role}, Drehrichtung " + f"{kreisel.rotation})" ) if not partners: nearest = _nearest_node(element, nodes) if nearest: partners = [nearest] warnings.append( - f"{element.describe()}: keine Nachbarn im Export - " + f"{element.describe()}: kein Umlauf/Flussnachbar - " f"naechstes Objekt '{nearest}' per Abstand verbunden" ) else: warnings.append(f"{element.describe()}: kein Ziel gefunden - unverbunden") continue + for partner in partners: if element.is_entry: edges.append(Edge(sid, partner, EDGE_FLOW)) @@ -1144,7 +1212,11 @@ def _apply_transfers( for el in elements: if el.kind in ("Ausschleus", "Einschleus"): k = str(_merkmal(el.merkmale, "Kreisel") or "").strip() - s = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "").strip() + + # Der Export benennt die andockende Strecke je nach Bauart als + # "Strecke" (VF) oder "Foerderer"/"Förderer" (GF/Streckengruppe). + s = str(_merkmal(el.merkmale, "Strecke", "Foerderer", "Förderer") or "").strip() + if k and s: role = "aus" if el.kind == "Ausschleus" else "ein" per_strecke.setdefault(s, {})[role] = k @@ -1248,7 +1320,52 @@ def _apply_transfers( return result +def _merge_station_symbols( + elements: list[Element], warnings: list[str] | None = None +) -> set[str]: + """ + Generische Eingang/Ausgang-Symbole ermitteln, die deckungsgleich ueber einer + konkreten BTMT-Station liegen (gleiche Rolle Quelle/Senke, Abstand + <= STATION_MERGE_MM). Diese werden verschluckt: es bleibt nur die + BTMT-Station als Knoten. Rueckgabe = TeileIds der verschluckten Symbole. + + Eingang gehoert zu Beladung (beide Quelle), Ausgang zu Entladung (beide + Senke). Ein Symbol ohne passende nahe BTMT-Station bleibt ein eigener Knoten. + warnings=None unterdrueckt die Meldungen (zweiter Aufruf im --track-Pfad, + der sonst dieselben Hinweise ein zweites Mal ausgibt). + """ + swallowed: set[str] = set() + btmt = [e for e in elements if e.is_station and e.kind not in STATION_SYMBOL_KINDS] + for sym in elements: + if sym.kind not in STATION_SYMBOL_KINDS or sym.x is None or sym.y is None: + continue + sym_is_entry = sym.kind in ENTRY_KINDS + best, best_d = None, None + for st in btmt: + if st.x is None or st.y is None: + continue + if st.is_entry != sym_is_entry: # nur gleiche Rolle (Quelle/Senke) + continue + d = ((st.x - sym.x) ** 2 + (st.y - sym.y) ** 2) ** 0.5 + if d <= STATION_MERGE_MM and (best_d is None or d < best_d): + best, best_d = st, d + if best is not None: + swallowed.add(sym.teile_id) + if warnings is not None: + warnings.append( + f"{sym.describe()}: deckungsgleich mit {best.kind} {best.teile_id} " + f"({best_d:.0f} mm) - Symbol verschluckt, nur {best.kind} gezeichnet" + ) + return swallowed + + def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Graph: + # Generische Eingang/Ausgang-Symbole, die ueber einer BTMT-Station liegen, + # verschlucken (nur die konkrete Station bleibt Knoten). + swallowed = _merge_station_symbols(elements, warnings) + if swallowed: + elements = [e for e in elements if e.teile_id not in swallowed] + by_id = {element.teile_id: element for element in elements} nodes = _build_nodes(elements, warnings) owners_of = _build_track_owners(elements, warnings) @@ -1470,6 +1587,18 @@ def build_track_graph( warnings wird mit build_graph() geteilt, damit Fein-Hinweise im selben Bericht erscheinen. """ + # Deckungsgleiche Eingang/Ausgang-Symbole (wie im groben Graph) verschlucken: + # aus der Element-Liste UND aus den TrackIds jedes Containers entfernen, sonst + # laeuft der Kreisel-Umlauf durch zwei nahezu identische Knoten (Symbol + + # BTMT-Station). warnings=None: die Meldung stand schon im groben Graph. + swallowed = _merge_station_symbols(elements, warnings=None) + if swallowed: + elements = [ + replace(e, track_ids=[t for t in e.track_ids if t not in swallowed]) + for e in elements if e.teile_id not in swallowed + ] + by_id = {e.teile_id: e for e in elements} + track = TrackGraph(source=graph.source) seen_edges: set[tuple[str, str]] = set()