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
+20 -1
View File
@@ -153,6 +153,24 @@ 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:
- **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).
| Switch | Argument | Default | Meaning |
|---|---|---|---|
| `--file` | `NAME` | `export.csv` | CSV input file, resolved against `%SKEL_DATA%`, or a full path. |
@@ -162,11 +180,12 @@ attached.
| `--connect` | — | off | Also write the topology as `<name>_connect.ini` (item/flow level): `[…nodes]` (Kreisel lanes, Strecke, Gefällestrecke), `[…connections]` with a `kind` per edge (`normal`/`umlauf`/`weiche`/`einschleusung`/`ausschleusung`), and `[…externals]` — the BTMT entry/exit if present, otherwise the open lane-ends listed as entry candidates. A hand-editable bridge into the TRO step, styled after `doc/TRO_Katalog/connect.ini`. |
| `--show-bbs` | — | off | Also write `<name>_bbs.svg`: a plain 2-D top-down view of every CSV row (not just flow nodes — Separator/Scanner/Ein-/Ausschleuselement too), one axis-aligned rectangle per row from the `Position` column (its center, X/Y) and the `Boundingbox` column (its width/depth, X/Y — the Z extent is ignored), with the row's `Bezeichnung` centered in it. No rotation, no Graphviz — hand-built SVG; the `viewBox` stays in plant millimetres (Y flipped so plant "up" renders as screen "up") but the printed page size is scaled to DIN A3 (landscape or portrait, whichever matches the layout's aspect ratio) instead of the plant's real-world extent. Larger objects are drawn first so small ones (Separator, Scanner, …) stay visible on top. |
| `--show-kx` | — | off | Only valid together with `--show-bbs` (else exit 1). Additionally draw each object's connection coordinate systems K1K4 into the `_bbs.svg`: the K1K4 columns are 12-char base64 strings (three 24-bit fixed-point values `x,y,z`, factor 10, `csv:trans-encode` in `dxfmakros/Lisp/export.lsp`) decoded to a plant-mm X/Y. Each present K-point is drawn as a small red cross (two lines) with a small `K1`…`K4` label, and consecutive points are joined `K1→K2→K3→K4` by a blue line. Empty K columns are skipped, so an object with only K1/K2 gets two crosses and one link. |
| `--track` | — | off | Additionally write the finer Separator/Ausschleus/Einschleus/Weiche graph described above (`<name>_track.dot`, and `<name>_track.svg` with `--tosvg`). Does not change the normal `_material_flow.*` output. See `doc/track.md`. |
**Output** (in `%SKEL_RESULTS%`): `<name>_material_flow.dot` (always, with `pos`
attributes when `--use-cords` was used), `<name>_material_flow.svg` (with `--tosvg`),
`<name>_material_flow.md` (with `--doc`), `<name>_connect.ini` (with `--connect`),
`<name>_bbs.svg` (with `--show-bbs`).
`<name>_bbs.svg` (with `--show-bbs`), `<name>_track.dot` / `<name>_track.svg` (with `--track`).
**Exit codes:** `0` ok · `1` input/CLI error · `2` SVG rendering failed
(Graphviz).
+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: