neuer Schalter Switch hinzugefügt

This commit is contained in:
2026-09-10 13:17:09 +02:00
parent 4bab45705d
commit 04f9ba456c
2 changed files with 641 additions and 1 deletions
+621
View File
@@ -115,6 +115,12 @@ TRANSFER_PATTERNS = (
("Weiche", ("weiche",)),
)
# Objektarten des feinen Bahnzusammenhang-Graphen (--track, siehe doc/track.md):
# Separator plus die drei Transferarten bilden dort je einen eigenen Knoten -
# anders als im normalen Materialfluss-Graphen, wo sie nur Kanten/Zuordnungen
# liefern (siehe TRANSPORT_KINDS/CIRCLE_KINDS oben).
TRACK_KINDS = ("Separator",) + TRANSFER_KINDS
LANE_LEFT = "L"
LANE_RIGHT = "R"
LANE_NAMES = {LANE_LEFT: "links", LANE_RIGHT: "rechts"}
@@ -311,6 +317,38 @@ class Graph:
source: str = ""
@dataclass
class TrackNode:
"""Ein Knoten des feinen Bahnzusammenhang-Graphen (--track, doc/track.md).
element ist None fuer die beiden Hilfsknotenarten: einen Bahn-Platzhalter
(placeholder=True - eine Kreisel-Bahn oder Strecke, an der sich kein
konkretes Bahnelement auflösen liess, siehe build_track_graph) oder ein
generisches Eingang/Ausgang-Symbol (SYMBOL_ENTRY/SYMBOL_EXIT) an einem
offenen Bahnende ohne BTMT-Station.
"""
node_id: str
kind: str
element: Element | None = None
placeholder: bool = False
note: str = ""
@dataclass
class TrackEdge:
src: str
dst: str
note: str = ""
@dataclass
class TrackGraph:
nodes: dict[str, TrackNode] = field(default_factory=dict)
edges: list[TrackEdge] = field(default_factory=list)
source: str = ""
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
@@ -1081,6 +1119,459 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
return Graph(nodes=nodes, edges=edges, warnings=warnings, unassigned=unassigned, source=source)
# ---------------------------------------------------------------------------
# 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).
#
# 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.
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:
"""
BTMT Be-/Entladung an den Bahn-Platzhalter anbinden.
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.
"""
for element in elements:
if not element.is_station:
continue
sid = element.teile_id
if sid not in graph.nodes:
continue
track.nodes.setdefault(sid, TrackNode(sid, element.kind, element))
for edge in graph.edges:
if edge.src == sid:
add_edge(sid, placeholder(edge.dst), "Station")
elif edge.dst == sid:
add_edge(placeholder(edge.src), sid, "Station")
def _attach_track_symbols(
graph: Graph, track: TrackGraph, placeholder, 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.
"""
incoming = {e.dst for e in graph.edges}
outgoing = {e.src for e in graph.edges}
symbol_no = 0
for node_id in sorted(graph.nodes):
node = graph.nodes[node_id]
if 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")
warnings.append(
f"{node_id}: keine ankommende Verbindung und keine BTMT-Station - "
f"im --track-Graph als Symbol {SYMBOL_ENTRY} markiert"
)
if node_id not in outgoing:
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")
warnings.append(
f"{node_id}: keine abgehende Verbindung und keine BTMT-Station - "
f"im --track-Graph als Symbol {SYMBOL_EXIT} markiert"
)
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.
"""
track = TrackGraph(source=graph.source)
fine = {e.teile_id: e for e in elements if e.kind in TRACK_KINDS}
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))
def placeholder(host_id: str | None, reason: str = "") -> str | None:
if not host_id:
return None
if host_id not in track.nodes:
coarse = graph.nodes.get(host_id)
track.nodes[host_id] = TrackNode(
host_id, "Bahn", coarse.element if coarse else None, placeholder=True, note=reason
)
return host_id
for el in fine.values():
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")
if ph:
add_edge(ph, el.teile_id, "unbestimmt")
warnings.append(
f"{el.describe()}: keine Nachbarn zu anderen Bahnelementen im Export - "
f"Reihenfolge auf {host} ungeklaert"
)
else:
warnings.append(f"{el.describe()}: keine Nachbarn und keine Zuordnung - im --track-Graph isoliert")
# 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)
# 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.
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")
return track
# ---------------------------------------------------------------------------
# DOT erzeugen
# ---------------------------------------------------------------------------
@@ -1372,6 +1863,102 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str:
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# DOT des Bahnzusammenhang-Graphen erzeugen (--track)
# ---------------------------------------------------------------------------
TRACK_NODE_STYLES: dict[str, dict[str, str]] = {
"Separator": dict(fillcolor="#fff2cc", fontcolor="#7f6000", color="#bf8f00"),
"Ausschleus": dict(fillcolor="#f9cb9c", fontcolor="#b45f06", color="#e69138"),
"Einschleus": dict(fillcolor="#cfe2f3", fontcolor="#1155cc", color="#3d85c6"),
"Weiche": dict(fillcolor="#ead1dc", fontcolor="#741b47", color="#a64d79"),
"Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"),
"Entladung": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"),
SYMBOL_ENTRY: dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"),
SYMBOL_EXIT: dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"),
"Bahn": dict(fillcolor="#f2f2f2", fontcolor="#666666", color="#999999"),
}
TRACK_EDGE_ATTRS: dict[str, str] = {
"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"]',
}
def _track_node_label(node: TrackNode) -> str:
if node.placeholder:
lines = [f"Bahn {node.node_id}", "Reihenfolge/Position offen (Teil B)"]
if node.note:
lines.append(node.note)
return _dot_label(lines)
if node.element is None:
return _dot_label([node.kind])
el = node.element
lines = [f"{el.name or node.kind} [{node.node_id}]", node.kind]
if node.note:
lines.append(node.note)
return _dot_label(lines)
def render_track_dot(track: TrackGraph) -> str:
"""DOT des Bahnzusammenhang-Graphen (--track); siehe build_track_graph()
und doc/track.md. Gerendert ueber dieselben graph_to_svg/dot_to_svg wie
render_dot()."""
out: list[str] = []
add = out.append
add("// Automatisch erzeugt von lib/material_flow.py (--track) - nicht manuell aendern.")
add(f"// Quelle: {track.source}")
add("digraph Bahnzusammenhang {")
add(' graph [rankdir=LR, splines=spline, nodesep=0.2, ranksep=1.1,')
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
add(f' label="Bahnzusammenhang - {_dot_escape(track.source)}"];')
add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=8];')
add(' edge [fontname="Segoe UI", fontsize=7, color="#2f5597", penwidth=1.1, arrowsize=0.7];')
add("")
for node_id in sorted(track.nodes):
node = track.nodes[node_id]
style = TRACK_NODE_STYLES.get(node.kind, DEFAULT_NODE_STYLE)
attrs = ", ".join(f'{key}="{value}"' for key, value in style.items())
extra = ', style="rounded,dashed,filled"' if node.placeholder else ""
if node.kind == "Weiche":
extra += ", shape=diamond"
elif node.kind in (SYMBOL_ENTRY, SYMBOL_EXIT, "Beladung", "Entladung"):
extra += ", shape=ellipse"
add(f' "{node_id}" [label="{_track_node_label(node)}", {attrs}{extra}];')
add("")
for edge in track.edges:
attrs = TRACK_EDGE_ATTRS.get(edge.note, "[]")
add(f' "{edge.src}" -> "{edge.dst}" {attrs};')
add("")
add(" subgraph cluster_legende {")
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
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">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>')
add("</table>>];")
add(" }")
add("}")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# connect.ini (Item-/Flussebene) erzeugen
# ---------------------------------------------------------------------------
@@ -1908,6 +2495,8 @@ def report(
doc_file: Path | None,
stale_svg: Path | None = None,
bbs_file: Path | None = None,
track_dot_file: Path | None = None,
track_svg_file: Path | None = None,
) -> None:
kinds: dict[str, int] = {}
for node in graph.nodes.values():
@@ -1937,6 +2526,9 @@ def report(
print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}")
print(f"BB-Draufsicht = {bbs_file if bbs_file else '- (ohne --show-bbs)'}")
print(f"Bahnzusammenhang = {track_dot_file if track_dot_file else '- (ohne --track)'}")
if track_dot_file:
print(f" SVG = {track_svg_file if track_svg_file else '- (ohne --tosvg)'}")
print("================================================================")
if stale_svg is not None:
@@ -1986,6 +2578,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" material_flow.bat --file mubea.csv --tosvg\n"
" material_flow.bat --file mubea.csv --tosvg --doc\n"
" material_flow.bat --file HundM05.csv --show-bbs\n"
" material_flow.bat --file mubea.csv --track --tosvg\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
@@ -2044,6 +2637,17 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"kleines Kreuz mit Beschriftung einzeichnen und in der Reihenfolge "
"K1->K2->K3->K4 mit einer blauen Linie verbinden.",
)
parser.add_argument(
"--track",
action="store_true",
help="Zusaetzlich einen feineren Bahnzusammenhang-Graph erzeugen "
"(<name>_track.dot, mit --tosvg auch <name>_track.svg): Separator, "
"Ausschleus-/Einschleuselement und Weiche werden zu eigenen Knoten "
"(Verzweigung/Zusammenfuehrung/Kreuzung), statt nur wie im normalen "
"Materialfluss Kanten/Zuordnungen an Kreisel/Strecke/Gefaellestrecke "
"zu liefern. Siehe doc/track.md. Aendert den normalen Materialfluss-"
"Output nicht.",
)
return parser.parse_args(argv)
@@ -2153,6 +2757,21 @@ def main(argv: list[str] | None = None) -> int:
)
connect_file = connect_target
track_dot_file = track_svg_file = None
if args.track:
by_id = {element.teile_id: element for element in elements}
track = build_track_graph(elements, by_id, graph, warnings)
track_dot_target = results / f"{stem}_track.dot"
track_dot_target.write_text(render_track_dot(track), encoding="utf-8")
track_dot_file = track_dot_target
if args.tosvg:
track_svg_target = results / f"{stem}_track.svg"
try:
dot_to_svg(track_dot_file, track_svg_target)
track_svg_file = track_svg_target
except RuntimeError as exc:
print(f"FEHLER: Bahnzusammenhang-SVG nicht erzeugt: {exc}", file=sys.stderr)
report(
graph,
findings,
@@ -2162,6 +2781,8 @@ def main(argv: list[str] | None = None) -> int:
doc_file,
stale_svg=None if args.tosvg and svg_error is None else existing_svg,
bbs_file=bbs_file,
track_dot_file=track_dot_file,
track_svg_file=track_svg_file,
)
if svg_error: