diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index 4c2dd66..9bb8ca9 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -153,23 +153,35 @@ bidirectional and reported as a warning. `--doc` additionally cross-checks stated `Anzahl_Separator`/`Anzahl_Scanner` counts against what was actually attached. -`--track` builds a second, finer-grained graph instead of (in addition to) the -one above: Separator, Ausschleus-/Einschleuselement and Weiche each become -their own node (they supply no node in the graph described above), showing -the actual branch/merge/crossing topology between Kreisel rather than just the -mechanical objects. See `doc/track.md` for the derivation rules and its -tested-against-`mubea_export.csv` findings; short version: +`--track` builds a second, finer-grained graph in addition to the one above: +Separator, Ausschleus-/Einschleuselement, Weiche and the entry/exit points +(BTMT Be-/Entladung, Eingang/Ausgang) each become their own node (they supply +no node in the graph described above), showing the actual branch/merge/crossing +topology between Kreisel rather than just the mechanical objects. -- **Ausschleuselement** → 1 incoming (Kreisel-side) → 2 outgoing (continues - on the Kreisel-Bahn *and* branches onto the Strecke) — a fork. -- **Einschleuselement** → 2 incoming (from the Strecke *and* continuing on - the Kreisel-Bahn) → 1 outgoing — a merge. -- **Weiche** → 4-way crossing between two Kreisel (in/out on each side, from - `Drehrichtung` via the existing `_weiche_lane_roles`). -- A Kreisel-Bahn with no separator resolvable to a specific position (or with - several that aren't chained to each other in the export) is drawn as a - dashed **placeholder** node rather than guessed — the intended hand-off - point for a later geometry-based ordering pass (not implemented yet). +The ordering comes **directly from the `TrackIds` CSV column** (`Element.track_ids`): +every container — Kreisel, Strecke, Gefällestrecke — carries the ordered list of +the track elements lying on it, computed by the dxfmakros export +(`dxfmakros/doc/csv-export.md`). `build_track_graph` draws one directed chain per +container from that list: + +- **Kreisel** → the circular umlauf in `Drehrichtung` order (the list is already + angle-sorted); consecutive entries are linked and the last closes back onto the + first (a real, closed loop). Edges are drawn light-blue ("Umlauf"). +- **Strecke / Gefällestrecke** → the linear chain `AS → SEP… → ES` in build + order, oriented by its ends (Ausschleuselement = start, Einschleuselement = + end). Edges are drawn blue ("Bahn"). +- An element listed in **two** containers (an AS/ES sits in its Strecke *and* in + the docked Kreisel's umlauf; a Kreisel-Kreisel Weiche sits in both Kreisel) is + the **same node** and stitches the containers together automatically — no + `Nachbarn`-based role/feedback heuristic any more. +- A track element that appears in **no** `TrackIds` list (e.g. a free Separator + the export didn't chain) is hung off a dashed **placeholder** for its + `Zuordnung` with an "unbestimmt" edge and a warning — order on that Bahn left + open. If the CSV has no `TrackIds` column at all (older export), every track + element falls into this case. + +See `doc/track.md` for the fuller derivation notes. | Switch | Argument | Default | Meaning | |---|---|---|---| diff --git a/doc/track.md b/doc/track.md index 06d9490..fda7655 100644 --- a/doc/track.md +++ b/doc/track.md @@ -46,7 +46,13 @@ 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`). + (`_attach_track_symbols`). 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 + blieb dort nur „unbestimmt") — würde man ihn als Maßstab nehmen, entstünden + vereinzelte Falsch-Eingänge an voll verketteten Bahnen (beobachtet an + `HundM05.csv`: Bahn 0001–0004, 0006-R). ## Warum das die `Nachbarn`-Heuristik ersetzt @@ -76,4 +82,6 @@ komplett — mit ihr die zahlreichen zugehörigen Warnungen. | grün (`"Station"`) | Anbindung BTMT Be-/Entladung | | gepunktet (`"Symbol"`) | offenes Bahnende ohne BTMT-Station | | gestrichelt gelb (`"unbestimmt"`) | Element nicht in einer `TrackIds`-Kette | -| Raute | Weiche | Ellipse | Ein-/Ausgang, BTMT | gestrichelter Rand | Bahn-Platzhalter | +| Raute (Knoten) | Weiche | +| Ellipse (Knoten) | Ein-/Ausgang, BTMT Be-/Entladung | +| gestrichelter Rand (Knoten) | Bahn-Platzhalter (Reihenfolge/Position offen) | diff --git a/lib/material_flow.py b/lib/material_flow.py index b95c5cd..6ac379a 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -121,6 +121,13 @@ TRANSFER_PATTERNS = ( # liefern (siehe TRANSPORT_KINDS/CIRCLE_KINDS oben). TRACK_KINDS = ("Separator",) + TRANSFER_KINDS +# Container, die in der CSV-Spalte "TrackIds" eine geordnete Liste ihrer +# enthaltenen Bahnelemente fuehren (siehe Element.track_ids): Kreisel bringt den +# zirkulaeren Umlauf in Drehrichtung mit, Strecke/Gefaellestrecke die lineare +# Kette AS -> SEP.. -> ES in Baureihenfolge. Aus diesen Listen wird der +# --track-Graph direkt aufgebaut (build_track_graph). +TRACK_CONTAINER_KINDS = CIRCLE_KINDS + TRANSPORT_KINDS + LANE_LEFT = "L" LANE_RIGHT = "R" LANE_NAMES = {LANE_LEFT: "links", LANE_RIGHT: "rechts"} @@ -176,6 +183,12 @@ class Element: name: str plan_square: str neighbours: list[tuple[str, str | None]] + # Geordnete Element-Liste eines Containers (Kreisel/VF/GF) aus der CSV-Spalte + # "TrackIds": bei VF/GF die Kette AS -> SEP.. -> ES in Baureihenfolge, bei + # Kreisel der zirkulaere Umlauf aller auf ihm liegenden Punkte in + # Drehrichtung (Separator/AS/ES/Weiche/BTMT/Eingang/Ausgang). Leer fuer + # Nicht-Container. Erzeugt von dxfmakros/export_csv.py, siehe doc/track.md. + track_ids: list[str] merkmale: dict row: int position: tuple[float, float, float] | None = None @@ -466,6 +479,11 @@ def parse_neighbours(raw: str) -> list[tuple[str, str | None]]: return result +def parse_track_ids(raw: str) -> list[str]: + """'0118, 0059, 0119' -> ['0118', '0059', '0119'] (Reihenfolge erhalten).""" + return [token.strip() for token in (raw or "").split(",") if token.strip()] + + def _match_kind(patterns, *texts: str) -> str | None: """Erste Musterklasse, deren Schluesselwort in den Texten vorkommt.""" haystack = " ".join(t.lower() for t in texts if t) @@ -534,6 +552,7 @@ def read_elements(path: Path, warnings: list[str]) -> list[Element]: name=(row.get("Bezeichnung") or "").strip(), plan_square=(row.get("Planquadrat") or "").strip(), neighbours=parse_neighbours(row.get("Nachbarn") or ""), + track_ids=parse_track_ids(row.get("TrackIds") or ""), merkmale=merkmale, row=offset, position=parse_position(row.get("Position") or ""), @@ -1123,244 +1142,26 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr # Bahnzusammenhang-Graph (--track, siehe doc/track.md) # --------------------------------------------------------------------------- # -# Feinerer Graph als build_graph(): Separator/Ausschleus/Einschleus/Weiche -# werden hier zu eigenen Knoten (statt nur Kanten/Zuordnungen zu liefern wie -# im normalen Materialfluss). Baut auf dem fertigen groben Graph auf (fuer -# Kreisel-Drehrichtung ueber _weiche_lane_roles und die bereits geloeste -# Stations-Anbindung aus _attach_stations) und auf der CSV-Spalte "Nachbarn" -# der vier TRACK_KINDS selbst - die die bestehende Pipeline bisher komplett -# ignoriert (_collect_links ueberspringt sie, siehe dort). +# Feinerer Graph als build_graph(): Separator/Ausschleus/Einschleus/Weiche und +# die Ein-/Ausgangspunkte (BTMT, Eingang/Ausgang) werden hier zu eigenen Knoten +# (statt nur Kanten/Zuordnungen zu liefern wie im normalen Materialfluss). # -# Kernbefund (doc/track.md Abschnitt 1): ein Ausschleus-/Einschleuselement -# nennt in seiner eigenen Nachbarn-Spalte konsequent nur die Foerderer-/ -# Streckenseite; die Kreisel-Seite ist nur ueber die Rueckmeldung des dortigen -# Separators auffindbar (der das Transferelement seinerseits als Nachbarn -# nennt). Die Fein-Adjazenz ist daher die Vereinigung aller Nennungen, nicht -# nur die eigene Nachbarn-Liste eines Elements. +# Quelle der Fein-Adjazenz ist die CSV-Spalte "TrackIds" (Element.track_ids): +# jeder Container (Kreisel/Strecke/Gefaellestrecke) fuehrt die geordnete Liste +# der auf ihm liegenden Bahnelemente. Der dxfmakros-Export berechnet diese +# Reihenfolge bereits (Kreisel: Umlauf nach Winkel in Drehrichtung; Strecke/ +# Gefaelle: Kette AS -> SEP.. -> ES in Baureihenfolge) - siehe +# dxfmakros/doc/csv-export.md. build_track_graph zieht daraus je Container eine +# gerichtete Kette; ein Element, das in zwei Listen steht (ein AS/ES in seiner +# Strecke UND im Kreisel-Umlauf, eine Weiche in beiden Kreiseln), ist derselbe +# Knoten und verkettet die Container automatisch. Die frueher noetige +# Rekonstruktion der Reihenfolge aus der "Nachbarn"-Spalte (Rueckmeldungs-/ +# Rollen-Heuristik) entfaellt damit. SYMBOL_ENTRY = "Eingang" SYMBOL_EXIT = "Ausgang" -def _transfer_kreisel_lane(el: Element, by_id: dict[str, Element], nodes: dict[str, Node]) -> str | None: - """ - Kreisel-Bahn (…-L/…-R) eines Ausschleus-/Einschleuselements. - - Die Merkmale von AS/ES nennen nur die blanke Kreisel-TeileId (Merkmal - "Kreisel"), nie die Bahn. Welche der beiden Bahnen gemeint ist, steht - stattdessen in den Nachbarn der zugehoerigen Strecke/Gefaellestrecke - (Merkmal "Strecke" bzw. "Förderer") - dieselbe Aufloesung, die - _apply_transfers fuer die groben Kreisel<->Strecke-Kanten schon nutzt. - """ - kreisel_id = str(_merkmal(el.merkmale, "Kreisel") or "").strip() - if not kreisel_id: - return None - strecke_id = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "").strip() - strecke_el = by_id.get(strecke_id) - if strecke_el is not None: - lane = _kreisel_lane_node(kreisel_id, strecke_el, nodes) - if lane: - return lane - for lane_suffix in (LANE_LEFT, LANE_RIGHT): # Fallback: irgendeine Bahn - if f"{kreisel_id}-{lane_suffix}" in nodes: - return f"{kreisel_id}-{lane_suffix}" - return kreisel_id if kreisel_id in nodes else None - - -def _wire_weiche( - el: Element, - by_id: dict[str, Element], - graph: Graph, - own: dict[str, list[str]], - track: TrackGraph, - warnings: list[str], - placeholder, - add_edge, -) -> None: - """ - Weiche als 4-Wege-Kreuzung verdrahten (Kreisel A <-> Kreisel B, in beide - Richtungen; siehe doc/track.md Abschnitt 2). - - Pro Kreisel liefert das bereits vorhandene _weiche_lane_roles() die - Eingangs-/Ausgangsbahn aus der Drehrichtung. Die eigenen Nachbarn der - Weiche (bis zu 2) werden per Zuordnung auf diese vier Rollen verteilt; - ein Nachbar, dessen Zuordnung zu keiner der vier Bahnen passt (z. B. weil - er - wie in den Mubea-Testdaten - foelschlich einer Strecke statt der - schaltenden Kreisel-Bahn zugeordnet ist, vgl. tro_overrides.py-Fall - "split"), bekommt per Ausschlussverfahren die letzte freie Rolle, - zusammen mit einem Hinweis. Rollen ohne passenden Nachbarn haengen am - Bahn-Platzhalter der jeweiligen Bahn. - """ - kreisel_a = str(_merkmal(el.merkmale, "Kreisel A") or "").strip() - kreisel_b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip() - roles_a = _weiche_lane_roles(kreisel_a, by_id, graph.nodes) if kreisel_a else None - roles_b = _weiche_lane_roles(kreisel_b, by_id, graph.nodes) if kreisel_b else None - if roles_a is None and kreisel_a: - warnings.append( - f"{el.describe()}: Drehrichtung von Kreisel {kreisel_a} unbekannt - " - f"Bahnrollen auf dieser Seite nicht bestimmbar" - ) - if roles_b is None and kreisel_b: - warnings.append( - f"{el.describe()}: Drehrichtung von Kreisel {kreisel_b} unbekannt - " - f"Bahnrollen auf dieser Seite nicht bestimmbar" - ) - lanes: dict[str, str | None] = { - "eingang_a": roles_a[0] if roles_a else None, - "ausgang_a": roles_a[1] if roles_a else None, - "eingang_b": roles_b[0] if roles_b else None, - "ausgang_b": roles_b[1] if roles_b else None, - } - - candidates = list(own.get(el.teile_id, [])) - matched: dict[str, str] = {} - unmatched: list[str] = [] - for cand in candidates: - cand_el = by_id.get(cand) - lane = cand_el.assignment if cand_el else None - role = next((r for r, lane_id in lanes.items() if lane_id and lane_id == lane), None) - if role: - matched[role] = cand - else: - unmatched.append(cand) - - remaining_roles = [r for r in lanes if r not in matched and lanes[r] is not None] - if len(unmatched) == 1 and len(remaining_roles) == 1: - role = remaining_roles[0] - matched[role] = unmatched[0] - cand_el = by_id.get(unmatched[0]) - warnings.append( - f"{el.describe()}: Nachbar {unmatched[0]} (Zuordnung " - f"'{cand_el.assignment if cand_el else '-'}') passt zu keiner der " - f"erwarteten Bahnen - durch Ausschlussverfahren als {role} angenommen" - ) - elif unmatched: - warnings.append( - f"{el.describe()}: Nachbar(n) {unmatched} nicht eindeutig einer Bahn " - f"zuzuordnen (offene Rollen: {remaining_roles or '-'}) - nicht verbunden" - ) - - def endpoint(role: str) -> str | None: - if role in matched: - return matched[role] - lane_id = lanes[role] - return placeholder(lane_id, "Weiche-Seite ohne passenden Fein-Nachbarn") if lane_id else None - - add_edge(endpoint("ausgang_a"), el.teile_id, "Weiche") - add_edge(el.teile_id, endpoint("eingang_b"), "Weiche") - add_edge(endpoint("ausgang_b"), el.teile_id, "Weiche") - add_edge(el.teile_id, endpoint("eingang_a"), "Weiche") - - -def _orient_separator_links( - fine: dict[str, Element], - own: dict[str, list[str]], - mentioned_by: dict[str, set[str]], - track: TrackGraph, - by_id: dict[str, Element], - warnings: list[str], - add_edge, - suppressed_pairs: set[frozenset] = frozenset(), -) -> None: - """ - Reine Separator-Separator-Kanten richten (doc/track.md Abschnitt 3). - - Jede AS-/ES-/Weiche-Kante ist zu diesem Zeitpunkt bereits gerichtet - (siehe build_track_graph) und "verankert" die anliegenden Separatoren. - Von dort aus wird die Kette wie eine verkettete Liste abgearbeitet: hat - ein Separator genau eine noch offene Separator-Separator-Verbindung und - schon eine bekannte (Anker- oder bereits geloeste) Seite, bekommt die - offene Seite zwangslaeufig die jeweils andere Rolle. Was am Ende keinen - Anker erreicht, faellt auf Hoehe/Antriebsrichtung des zugeordneten - Objekts zurueck (wie im groben Graph), sonst bleibt es unbestimmt. - - suppressed_pairs schliesst Paare aus, die zwar gegenseitig als Nachbarn - gelistet sind, aber beide zugleich eigener Nachbar DESSELBEN AS/ES/Weiche- - Ankers sind (siehe build_track_graph) - das sind zwei unabhaengige Speichen - desselben Uebergabepunkts (beide nur deshalb nah beieinander, weil sie am - selben Knoten haengen), keine direkte Foerderverbindung zueinander. Ohne - diesen Ausschluss wuerde z. B. in mubea_export.csv faelschlich eine Kante - zwischen den beiden Weiche-Nachbarn 0040 (Kreisel 0001-R) und 0036 (an - Strecke 0014, siehe _wire_weiche) gezeichnet. - """ - separators = {tid for tid, el in fine.items() if el.kind == "Separator"} - adjacency: dict[str, set[str]] = { - tid: { - n - for n in (set(own.get(tid, [])) | mentioned_by.get(tid, set())) - if n in separators and frozenset((tid, n)) not in suppressed_pairs - } - for tid in separators - } - - # touches[sep]: bereits bekannte Nachbarn dieses Separators und ihre Rolle - # ("upstream" = Material kommt von dort, "downstream" = Material geht dorthin). - touches: dict[str, dict[str, str]] = {tid: {} for tid in separators} - for e in track.edges: - if e.dst in separators: - touches[e.dst][e.src] = "upstream" - if e.src in separators: - touches[e.src][e.dst] = "downstream" - - pending: dict[str, set[str]] = { - tid: {n for n in adjacency[tid] if n not in touches[tid]} for tid in separators - } - - changed = True - while changed: - changed = False - for tid in separators: - if len(pending[tid]) != 1 or len(touches[tid]) == 0: - continue - other = next(iter(pending[tid])) - resolved_dir = next(iter(touches[tid].values())) - other_dir = "upstream" if resolved_dir == "downstream" else "downstream" - if other_dir == "downstream": - add_edge(tid, other, "Bahn") - else: - add_edge(other, tid, "Bahn") - touches[tid][other] = other_dir - touches[other][tid] = "upstream" if other_dir == "downstream" else "downstream" - pending[tid].discard(other) - pending[other].discard(tid) - changed = True - - seen: set[frozenset] = set() - for tid in separators: - for other in pending[tid]: - pair = frozenset((tid, other)) - if pair in seen: - continue - seen.add(pair) - # Hoehenvergleich nur, wenn beide Separatoren DERSELBEN Zuordnung - # angehoeren (echtes Kettenmittelstueck, z. B. 0050<->0051 auf - # Strecke 0013). Ein Nachbar-Paar mit UNTERSCHIEDLICHER Zuordnung - # und ganz ohne erreichbaren Anker (z. B. 0043 auf Kreisel 0010-R - # <-> 0045 auf Strecke 0013) ist derselbe Fall wie 0036/0040 bzw. - # 0005/0078 - ein Uebergang, der eigentlich ueber ein AS/ES/Weiche - # laufen sollte, hier aber keins hat; ohne dessen Rollenaufloesung - # waere ein Hoehenvergleich (der hier zudem gleiche Z liefert) - # geraten statt abgeleitet. - same_host = bool(fine[tid].assignment) and fine[tid].assignment == fine[other].assignment - host = by_id.get(fine[tid].assignment.split("-")[0]) if same_host and fine[tid].assignment else None - uphill, reason = drive_uphill(host) if host is not None else (None, "kein zugeordnetes Objekt") - za, zb = fine[tid].z, fine[other].z - if uphill is not None and za is not None and zb is not None and za != zb: - src, dst = (tid, other) if (za < zb) == uphill else (other, tid) - add_edge(src, dst, "Hoehe") - warnings.append( - f"Separator {tid} <-> {other}: kein Anker (AS/ES/Weiche) in der " - f"Kette erreichbar - Richtung ueber Hoehe des zugeordneten Objekts " - f"({reason}) angenommen" - ) - else: - add_edge(tid, other, "unbestimmt") - warnings.append( - f"Separator {tid} <-> {other}: kein Anker und keine Hoehe - " - f"Richtung unbestimmt, beidseitig markiert" - ) - - def _attach_track_stations( elements: list[Element], graph: Graph, track: TrackGraph, placeholder, add_edge ) -> None: @@ -1369,7 +1170,12 @@ def _attach_track_stations( Die Zielbahn wurde bereits von _attach_stations() im groben Graph bestimmt (naechster Kreisel + Eingangs-/Ausgangsbahn aus der Drehrichtung, - siehe dort) - hier nur wiederverwendet statt neu berechnet. + siehe dort) - hier nur wiederverwendet statt neu berechnet. Eine Station, + die bereits als Knoten in einer TrackIds-Kette liegt, wird nur noch an ihre + Kreisel-*Bahn* (den groben Lane-/Transportknoten) gebrueckt; grobe + Station<->Station-Kanten (ein Artefakt der Abstands-Anbindung im groben + Graph) werden hier NICHT uebernommen, sonst entstuenden verwirrende + Rueckkanten zwischen zwei BTMT-Punkten, die im Umlauf schon geordnet sind. """ for element in elements: if not element.is_station: @@ -1379,33 +1185,50 @@ def _attach_track_stations( continue track.nodes.setdefault(sid, TrackNode(sid, element.kind, element)) for edge in graph.edges: + partner = edge.dst if edge.src == sid else edge.src if edge.dst == sid else None + if partner is None: + continue + partner_node = graph.nodes.get(partner) + if partner_node is not None and partner_node.element.is_station: + continue # Station<->Station-Kante des groben Graphen ueberspringen if edge.src == sid: - add_edge(sid, placeholder(edge.dst), "Station") - elif edge.dst == sid: - add_edge(placeholder(edge.src), sid, "Station") + add_edge(sid, placeholder(partner), "Station") + else: + add_edge(placeholder(partner), sid, "Station") def _attach_track_symbols( - graph: Graph, track: TrackGraph, placeholder, add_edge, warnings: list[str] + track: TrackGraph, add_edge, warnings: list[str] ) -> None: """ Offene Bahnenden ohne BTMT-Station als generisches Symbol Eingang/Ausgang - markieren - dieselben Kandidaten, die render_connect_ini() heute nur als - Kommentarzeile auflistet (kein Transportobjekt liefert eine ankommende - bzw. abgehende Kante), hier aber als echter Graph-Knoten. + markieren - aber anhand des FEINEN Graphen (track), nicht des groben. + + Ein echter System-Ein-/Ausgang ist ein reales Bahnelement (Separator, AS/ES, + Station), das im --track-Graphen keinen Vorgaenger (Eingang) bzw. keinen + Nachfolger (Ausgang) hat. Muss darum NACH allen Ketten- und Stations-Kanten + laufen. Der grobe Graph taugt hier NICHT als Massstab: er kann eine Bahn als + offenes Ende fuehren, obwohl ihre AS/ES-Enden im Kreisel-Umlauf laengst + verkettet sind (die Richtung im groben Graph blieb nur 'unbestimmt') - genau + das erzeugte frueher die vereinzelten Falsch-Eingaenge (z. B. Bahn 0001-0004 + in HundM05). Platzhalter- und Symbol-Knoten selbst zaehlen nicht als Bahn. """ - incoming = {e.dst for e in graph.edges} - outgoing = {e.src for e in graph.edges} + incoming = {e.dst for e in track.edges} + outgoing = {e.src for e in track.edges} symbol_no = 0 - for node_id in sorted(graph.nodes): - node = graph.nodes[node_id] - if node.element.is_station: + for node_id in sorted(track.nodes): + node = track.nodes[node_id] + # Nur echte Bahnelemente betrachten: keine Platzhalter, keine schon + # gesetzten Symbole, keine BTMT-Stationen (die sind selbst die Grenze). + if node.placeholder or node.kind in (SYMBOL_ENTRY, SYMBOL_EXIT): + continue + if node.element is not None and node.element.is_station: continue if node_id not in incoming: symbol_no += 1 sym_id = f"SYM_EIN_{symbol_no:02d}" track.nodes[sym_id] = TrackNode(sym_id, SYMBOL_ENTRY) - add_edge(sym_id, placeholder(node_id), "Symbol") + add_edge(sym_id, node_id, "Symbol") warnings.append( f"{node_id}: keine ankommende Verbindung und keine BTMT-Station - " f"im --track-Graph als Symbol {SYMBOL_ENTRY} markiert" @@ -1414,32 +1237,59 @@ def _attach_track_symbols( symbol_no += 1 sym_id = f"SYM_AUS_{symbol_no:02d}" track.nodes[sym_id] = TrackNode(sym_id, SYMBOL_EXIT) - add_edge(placeholder(node_id), sym_id, "Symbol") + add_edge(node_id, sym_id, "Symbol") warnings.append( f"{node_id}: keine abgehende Verbindung und keine BTMT-Station - " f"im --track-Graph als Symbol {SYMBOL_EXIT} markiert" ) +# Kanten-Notiz je Container-Art: eine Kette innerhalb einer Strecke/ +# Gefaellestrecke ist eine Weiterfahrt auf derselben Bahn, ein Schritt im +# Kreisel-Umlauf gehoert zur zirkulaeren Kreiselbahn. +TRACK_NOTE_STRECKE = "Bahn" +TRACK_NOTE_KREISEL = "Umlauf" + + def build_track_graph( elements: list[Element], by_id: dict[str, Element], graph: Graph, warnings: list[str] ) -> TrackGraph: """ Feiner Bahnzusammenhang-Graph fuer --track (siehe doc/track.md). - Nutzt den bereits fertig aufgeloesten groben Graph fuer Kreisel- - Drehrichtung (_weiche_lane_roles) und die bereits geloeste Stations- - Anbindung (graph.edges); die Fein-Adjazenz selbst kommt aus der CSV-Spalte - "Nachbarn" der Separator-/Ausschleus-/Einschleus-/Weiche-Zeilen (siehe - Modul-Docstring oben). warnings wird mit dem Aufruf von build_graph() - geteilt, damit Fein-Hinweise im selben Bericht erscheinen. + Baut die Fein-Adjazenz direkt aus der CSV-Spalte "TrackIds" auf: jeder + Container (Kreisel, Strecke, Gefaellestrecke) fuehrt dort die geordnete + Liste der auf ihm liegenden Bahnelemente (Element.track_ids). Daraus wird + je Container eine gerichtete Kette gezogen: + + * Kreisel -> zirkulaerer Umlauf in Drehrichtung (die Liste ist bereits + nach Winkel in DREHRICHTUNG sortiert); aufeinanderfolgende + Eintraege werden verbunden, das Ende schliesst an den Anfang + zurueck (der Umlauf ist tatsaechlich geschlossen). + * Strecke/ -> lineare Kette AS -> SEP.. -> ES; die Richtung kommt aus den + Gefaelle Kettenenden (Ausschleus = Anfang, Einschleus = Ende), nicht + aus der Listenreihenfolge. + + Elemente, die in zwei Containern gelistet sind (ein Ausschleus-/ + Einschleuselement steht in seiner Strecke UND im Umlauf des angedockten + Kreisels; eine Kreisel-Kreisel-Weiche steht in beiden Kreiseln), sind + derselbe Knoten und verketten die Container automatisch - ohne die frueher + noetige Rueckmeldungs-/Rollen-Heuristik aus der "Nachbarn"-Spalte. + + warnings wird mit build_graph() geteilt, damit Fein-Hinweise im selben + Bericht erscheinen. """ track = TrackGraph(source=graph.source) - fine = {e.teile_id: e for e in elements if e.kind in TRACK_KINDS} + seen_edges: set[tuple[str, str]] = set() def add_edge(src: str | None, dst: str | None, note: str = "") -> None: - if src is not None and dst is not None: - track.edges.append(TrackEdge(src, dst, note)) + # Dieselbe gerichtete Kante kann aus zwei Containern kommen (ein Paar, das + # z. B. im Kreisel-Umlauf UND am Ende einer andockenden Strecke direkt + # aufeinanderfolgt) - nur einmal ziehen, mit der zuerst gesehenen Notiz. + if src is None or dst is None or src == dst or (src, dst) in seen_edges: + return + seen_edges.add((src, dst)) + track.edges.append(TrackEdge(src, dst, note)) def placeholder(host_id: str | None, reason: str = "") -> str | None: if not host_id: @@ -1451,123 +1301,92 @@ def build_track_graph( ) return host_id - for el in fine.values(): + def ensure_node(tid: str) -> str | None: + """Knoten fuer ein in TrackIds gelistetes Element sicherstellen.""" + if tid in track.nodes: + return tid + el = by_id.get(tid) + if el is None: + warnings.append(f"TrackIds nennt unbekanntes Element {tid} - uebersprungen") + return None + track.nodes[tid] = TrackNode(tid, el.kind, el) + return tid + + # 1. Strecke/Gefaellestrecke: lineare Kette AS -> SEP.. -> ES. + for el in elements: + if el.kind not in TRANSPORT_KINDS or not el.track_ids: + continue + chain = list(el.track_ids) + # Richtung an den Kettenenden ausrichten: Einschleus vorne bedeutet, die + # Liste laeuft entgegen dem Fluss - umdrehen. Ausschleus am Ende ebenso. + first_el, last_el = by_id.get(chain[0]), by_id.get(chain[-1]) + first_kind = first_el.kind if first_el else "" + last_kind = last_el.kind if last_el else "" + if first_kind == "Einschleus" or last_kind == "Ausschleus": + chain = list(reversed(chain)) + prev = None + for tid in chain: + node = ensure_node(tid) + if node is None: + continue + if prev is not None: + add_edge(prev, node, TRACK_NOTE_STRECKE) + prev = node + + # 2. Kreisel: zirkulaerer Umlauf in Drehrichtung (Liste bereits sortiert). + # Ohne bekannte Drehrichtung ist die Umlaufrichtung nicht gesichert - dann + # wird die Kette offen (ohne Rueckschluss) gezogen und gemeldet. + for el in elements: + if not el.is_circle or not el.track_ids: + continue + chain = [n for tid in el.track_ids if (n := ensure_node(tid)) is not None] + if len(chain) < 2: + continue + for a, b in zip(chain, chain[1:]): + add_edge(a, b, TRACK_NOTE_KREISEL) + if el.rotation in (ROTATION_CW, ROTATION_CCW): + add_edge(chain[-1], chain[0], TRACK_NOTE_KREISEL) # Umlauf schliessen + else: + warnings.append( + f"{el.describe()}: Drehrichtung '{el.rotation or '-'}' unbekannt - " + f"Kreisel-Umlauf im --track-Graph nicht geschlossen" + ) + + # 3. Verwaiste Bahnelemente (in keiner TrackIds-Liste eines Containers, z. B. + # ein frei platzierter Separator ohne Kette): an ihren Zuordnung- + # Platzhalter haengen statt sie unverbunden zu lassen. + listed = {tid for el in elements for tid in el.track_ids} + for el in elements: + if el.kind not in TRACK_KINDS or el.teile_id in listed: + continue track.nodes.setdefault(el.teile_id, TrackNode(el.teile_id, el.kind, el)) - - # Eigene Nachbarn je Feinelement (nur Feinelemente behalten, siehe - # TRACK_KINDS) plus die Umkehr-Sicht (wer nennt mich als Nachbar) - - # zusammen die vereinigte Fein-Adjazenz (doc/track.md Abschnitt 1). - own: dict[str, list[str]] = { - tid: [b for b, _lane in el.neighbours if b in fine] for tid, el in fine.items() - } - mentioned_by: dict[str, set[str]] = {tid: set() for tid in fine} - for tid, targets in own.items(): - for target in targets: - mentioned_by[target].add(tid) - - # Ausschleus/Einschleus: eigener Nachbar = Foerderer-/Streckenseite, - # Rueckmeldung = Kreisel-Seite (doc/track.md Abschnitt 1-2). Eine - # Rueckmeldung wird nur akzeptiert, wenn ihre eigene Zuordnung auch - # tatsaechlich zur erwarteten Bahn (_transfer_kreisel_lane) passt - sonst - # ist es wie beim Weiche-Fall 0036/0040 nur eine raeumlich nahe, aber - # fachlich andere Kette (z. B. 0005 auf Strecke 0014, das per Rueckmeldung - # faelschlich als Kreisel-Seite mehrerer Storage-Bank-Einschleuselemente - # auf 0002-R erscheint), keine echte Weiterfahrt auf derselben Bahn. - for el in fine.values(): - if el.kind not in ("Ausschleus", "Einschleus"): - continue - strecke_side = list(own.get(el.teile_id, [])) - if not strecke_side: - warnings.append(f"{el.describe()}: keine Foerderer-/Streckenseite in eigenen Nachbarn gefunden") - elif len(strecke_side) > 1: - warnings.append(f"{el.describe()}: mehrere Streckenseiten-Nachbarn {strecke_side} - alle verbunden") - - expected_lane = _transfer_kreisel_lane(el, by_id, graph.nodes) - raw_kreisel_side = sorted(mentioned_by.get(el.teile_id, set()) - set(strecke_side)) - kreisel_side: list[str] = [] - for cand in raw_kreisel_side: - cand_el = by_id.get(cand) - if expected_lane and cand_el is not None and cand_el.assignment == expected_lane: - kreisel_side.append(cand) - else: - warnings.append( - f"{el.describe()}: Rueckmeldung von {cand} (Zuordnung " - f"'{cand_el.assignment if cand_el else '-'}') passt nicht zur erwarteten " - f"Bahn {expected_lane or '?'} - als Kreisel-seitiger Nachbar verworfen" - ) - if not kreisel_side: - if expected_lane: - kreisel_side = [placeholder(expected_lane, "keine passende Rueckmeldung eines Fein-Elements gefunden")] - else: - warnings.append(f"{el.describe()}: Kreisel-Bahn nicht aufloesbar - keine Kreisel-seitige Kante") - elif len(kreisel_side) > 1: - warnings.append(f"{el.describe()}: mehrere Kreisel-seitige Nachbarn {kreisel_side} - alle verbunden") - - if el.kind == "Ausschleus": - for k in kreisel_side: - add_edge(k, el.teile_id, "Bahn") - for s in strecke_side: - add_edge(el.teile_id, s, "Zweig") - else: # Einschleus - for s in strecke_side: - add_edge(s, el.teile_id, "Zweig") - for k in kreisel_side: - add_edge(el.teile_id, k, "Bahn") - - # Weiche: 4-Wege-Kreuzung zwischen zwei Kreiseln (doc/track.md Abschnitt 2). - for el in fine.values(): - if el.kind == "Weiche": - _wire_weiche(el, by_id, graph, own, track, warnings, placeholder, add_edge) - - # Zwei eigene Nachbarn DESSELBEN AS/ES/Weiche-Ankers sind zwei unabhaengige - # Speichen desselben Uebergabepunkts, keine direkte Kette zueinander (siehe - # _orient_separator_links-Docstring) - von der Separator-Separator- - # Orientierung ausschliessen, bevor sie dort faelschlich verkettet werden. - suppressed_pairs: set[frozenset] = set() - for tid, el in fine.items(): - if el.kind not in ("Ausschleus", "Einschleus", "Weiche"): - continue - candidates = own.get(tid, []) - for i, a in enumerate(candidates): - for b in candidates[i + 1:]: - suppressed_pairs.add(frozenset((a, b))) - - # Reine Separator-Separator-Verbindungen orientieren (doc/track.md Abschnitt 3). - _orient_separator_links( - fine, own, mentioned_by, track, by_id, warnings, add_edge, suppressed_pairs - ) - - # Verwaiste Separatoren (keine Nachbarn im Export, z. B. 0037/0038 in - # mubea_export.csv) an ihren Zuordnung-Platzhalter haengen statt sie - # unverbunden zu lassen - Reihenfolge dort bewusst offen. - for el in fine.values(): - if el.kind != "Separator" or own.get(el.teile_id) or mentioned_by.get(el.teile_id): - continue host = el.assignment if el.assignment and el.assignment != UNASSIGNED else None - ph = placeholder(host, "Separator ohne Nachbarn im Export") + ph = placeholder(host, "nicht in einer TrackIds-Kette enthalten") if ph: add_edge(ph, el.teile_id, "unbestimmt") warnings.append( - f"{el.describe()}: keine Nachbarn zu anderen Bahnelementen im Export - " + f"{el.describe()}: in keiner TrackIds-Kette enthalten - " f"Reihenfolge auf {host} ungeklaert" ) else: - warnings.append(f"{el.describe()}: keine Nachbarn und keine Zuordnung - im --track-Graph isoliert") + warnings.append( + f"{el.describe()}: in keiner TrackIds-Kette und ohne Zuordnung - " + f"im --track-Graph isoliert" + ) - # Systemgrenzen: BTMT-Stationen (wiederverwendet) bzw. generisches - # Eingang/Ausgang-Symbol an offenen Bahnenden ohne Station. + # 4. Systemgrenzen: BTMT-Stationen (wiederverwendet) bzw. generisches + # Eingang/Ausgang-Symbol an offenen Bahnenden ohne Station. _attach_track_stations(elements, graph, track, placeholder, add_edge) - _attach_track_symbols(graph, track, placeholder, add_edge, warnings) + _attach_track_symbols(track, add_edge, warnings) - # Letzte Sicherung: ein Fein-Element, das nach alledem keine einzige Kante - # hat (z. B. weil ein Weiche-Nachbar zu keiner Rolle passte, siehe - # _wire_weiche, UND seine einzige sonstige Nachbarschaft als Speiche - # desselben Ankers ausgeschlossen wurde), sichtbar melden statt es - # stillschweigend unverbunden im Graph stehen zu lassen. + # Letzte Sicherung: ein gelistetes Element ohne jede Kante sichtbar melden. touched = {e.src for e in track.edges} | {e.dst for e in track.edges} - for tid, el in fine.items(): - if tid not in touched: - warnings.append(f"{el.describe()}: im --track-Graph ohne jede Kante - Zuordnung/Nachbarn widerspruechlich") + for tid in sorted(listed): + el = by_id.get(tid) + if el is not None and el.kind in TRACK_KINDS and tid not in touched: + warnings.append( + f"{el.describe()}: im --track-Graph ohne jede Kante - TrackIds widerspruechlich" + ) return track @@ -1880,12 +1699,12 @@ TRACK_NODE_STYLES: dict[str, dict[str, str]] = { } TRACK_EDGE_ATTRS: dict[str, str] = { + # Schritte innerhalb der TrackIds-Kette eines Containers (build_track_graph): + "Bahn": '[color="#2f5597", tooltip="Weiterfahrt auf derselben Bahn (Strecke/Gefaelle)"]', + "Umlauf": '[color="#8ea9db", tooltip="Kreisel-Umlauf (Reihenfolge in Drehrichtung)"]', "unbestimmt": '[color="#bf8f00", style=dashed, dir=both, tooltip="Richtung unbestimmt"]', - "Zweig": '[color="#e69138", tooltip="Verzweigung/Zusammenfuehrung an AS/ES"]', - "Weiche": '[color="#a64d79", penwidth=1.6, tooltip="Weiche (Kreuzung zweier Kreisel)"]', "Symbol": '[color="#999999", style=dotted, tooltip="offenes Bahnende ohne BTMT-Station"]', "Station": '[color="#338833", tooltip="Anbindung BTMT Be-/Entladung"]', - "Hoehe": '[color="#548235", style=dashed, tooltip="Richtung ueber Hoehe angenommen"]', } @@ -1944,14 +1763,16 @@ def render_track_dot(track: TrackGraph) -> str: add(' legende [shape=plaintext, style="", fillcolor="none", label=<') add('
| Raute | Weiche (Kreuzung zweier Kreisel) |
| orange | ' - 'Zweig an Ausschleus-/Einschleuselement |
| blau | ' + 'Weiterfahrt auf derselben Bahn (Strecke/Gefaelle-Kette) |
| hellblau | ' + 'Kreisel-Umlauf (Reihenfolge in Drehrichtung) |
| gruen | ' + 'Anbindung BTMT Be-/Entladung |
| gestrichelter Rand | ' 'Bahn-Platzhalter - Reihenfolge/Position offen (Teil B) |
| gestrichelt gruen | ' - 'Richtung ueber Hoehe angenommen (kein Anker erreichbar) |
| gestrichelt gelb | ' - 'Richtung unbestimmt | Richtung unbestimmt (Element nicht in einer TrackIds-Kette) | ') add("