diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index ed93046..4c2dd66 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -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 `_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 `_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 K1–K4 into the `_bbs.svg`: the K1–K4 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 (`_track.dot`, and `_track.svg` with `--tosvg`). Does not change the normal `_material_flow.*` output. See `doc/track.md`. | **Output** (in `%SKEL_RESULTS%`): `_material_flow.dot` (always, with `pos` attributes when `--use-cords` was used), `_material_flow.svg` (with `--tosvg`), `_material_flow.md` (with `--doc`), `_connect.ini` (with `--connect`), -`_bbs.svg` (with `--show-bbs`). +`_bbs.svg` (with `--show-bbs`), `_track.dot` / `_track.svg` (with `--track`). **Exit codes:** `0` ok · `1` input/CLI error · `2` SVG rendering failed (Graphviz). diff --git a/lib/material_flow.py b/lib/material_flow.py index 51eea38..b95c5cd 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -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('') + add('') + add('' + '') + add('' + '') + add('' + '') + add('' + '') + add("
RauteWeiche (Kreuzung zweier Kreisel)
orangeZweig an Ausschleus-/Einschleuselement
gestrichelter RandBahn-Platzhalter - Reihenfolge/Position offen (Teil B)
gestrichelt gruenRichtung ueber Hoehe angenommen (kein Anker erreichbar)
gestrichelt gelbRichtung unbestimmt
>];") + 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 " + "(_track.dot, mit --tosvg auch _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: