--track: Bahnzusammenhang aus der TrackIds-Spalte statt aus Nachbarn

build_track_graph baut die Fein-Adjazenz jetzt direkt aus der geordneten
TrackIds-Spalte je Container (Element.track_ids): Kreisel als zirkulaerer
Umlauf in Drehrichtung (Export-Sortierung), Strecke/Gefaelle als lineare
Kette AS -> SEP.. -> ES. Gemeinsame AS/ES-/Weiche-Knoten verketten die
Container automatisch - die Rueckmeldungs-/Rollen-Heuristik aus der
Nachbarn-Spalte (_transfer_kreisel_lane, _wire_weiche,
_orient_separator_links) entfaellt komplett.

_attach_track_symbols misst offene Bahnenden jetzt am feinen Graphen statt
am groben; das behebt die vereinzelten Falsch-Eingaenge an voll verketteten
Bahnen (HundM05: Bahn 0001-0004, 0006-R).

Doku in doc/Python_Scripts.md und doc/track.md nachgezogen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-09-17 09:59:09 +02:00
co-authored by Claude Opus 4.8
parent db4e975adf
commit 516f30278b
3 changed files with 228 additions and 387 deletions
+190 -369
View File
@@ -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('<table border="0" cellborder="0" cellspacing="2" cellpadding="2">')
add('<tr><td align="left">Raute</td><td align="left">Weiche (Kreuzung zweier Kreisel)</td></tr>')
add('<tr><td align="left"><font color="#e69138">orange</font></td>'
'<td align="left">Zweig an Ausschleus-/Einschleuselement</td></tr>')
add('<tr><td align="left"><font color="#2f5597">blau</font></td>'
'<td align="left">Weiterfahrt auf derselben Bahn (Strecke/Gefaelle-Kette)</td></tr>')
add('<tr><td align="left"><font color="#8ea9db">hellblau</font></td>'
'<td align="left">Kreisel-Umlauf (Reihenfolge in Drehrichtung)</td></tr>')
add('<tr><td align="left"><font color="#338833">gruen</font></td>'
'<td align="left">Anbindung BTMT Be-/Entladung</td></tr>')
add('<tr><td align="left">gestrichelter Rand</td>'
'<td align="left">Bahn-Platzhalter - Reihenfolge/Position offen (Teil B)</td></tr>')
add('<tr><td align="left"><font color="#548235">gestrichelt gruen</font></td>'
'<td align="left">Richtung ueber Hoehe angenommen (kein Anker erreichbar)</td></tr>')
add('<tr><td align="left"><font color="#bf8f00">gestrichelt gelb</font></td>'
'<td align="left">Richtung unbestimmt</td></tr>')
'<td align="left">Richtung unbestimmt (Element nicht in einer TrackIds-Kette)</td></tr>')
add("</table>>];")
add(" }")
add("}")