diff --git a/bin/material_flow.bat b/bin/material_flow.bat index fb606ed..2dc7a5e 100644 --- a/bin/material_flow.bat +++ b/bin/material_flow.bat @@ -5,9 +5,11 @@ REM ================================================================ REM Aufruf: REM bin\material_flow.bat --file export.csv REM bin\material_flow.bat --file export.csv --tosvg +REM bin\material_flow.bat --file export.csv --track --tosvg +REM bin\material_flow.bat --file export.csv --chain-graph REM REM Die CSV-Datei wird in %SKEL_DATA% gesucht, DOT und SVG werden -REM in %SKEL_RESULTS% abgelegt. +REM in %SKEL_RESULTS% abgelegt. Alle Optionen: --help. REM ================================================================ setlocal diff --git a/bin/material_flow.sh b/bin/material_flow.sh index 90c26c4..dc087cb 100644 --- a/bin/material_flow.sh +++ b/bin/material_flow.sh @@ -5,9 +5,11 @@ # Aufruf: # bash bin/material_flow.sh --file export.csv # bash bin/material_flow.sh --file export.csv --tosvg +# bash bin/material_flow.sh --file export.csv --track --tosvg +# bash bin/material_flow.sh --file export.csv --chain-graph # # Die CSV-Datei wird in $SKEL_DATA gesucht, DOT und SVG werden -# in $SKEL_RESULTS abgelegt. +# in $SKEL_RESULTS abgelegt. Alle Optionen: --help. # ================================================================ set -eu diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index 9bb8ca9..f37ea4f 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -114,6 +114,17 @@ Reads the CSV export and builds a directed graph of the mechanical objects: counter-clockwise = left→right). - **Separator** / **Scanner** → no node of their own; shown via `Zuordnung` (assignment) on the label of their owning node. +- **`TrackIds`** (added Sept. 2026): a Kreisel/Strecke/Gefällestrecke row lists the + `TeileId`s of every fine element (Separator, Scanner, Weiche, Ein-/Ausschleuselement, + BTMT Be-/Entladung) that belongs to it. `Zuordnung` on the fine element itself stays + authoritative whenever it resolves to a real node — it's more precise than `TrackIds` + for a separator sitting right at a Kreisel↔Strecke boundary, which `TrackIds` lists + under *both* neighbouring containers (normal, not a conflict). `TrackIds` only fills + gaps `Zuordnung` can't resolve: a bare Kreisel id with no lane suffix (attached to + *both* lanes then), a missing/empty `Zuordnung`, or (for BTMT stations, which never had + a reliable `Zuordnung`) the nearest-neighbour distance guess this replaces. An older + export without the `TrackIds` column behaves exactly as before. See `_build_track_owners`/ + `_track_owner`/`_attach_devices`/`_attach_stations` in `lib/material_flow.py`. - **Einschleuselement** / **Ausschleuselement** / **Weiche** (from the richer export) → no node of their own; they supply the **direction** of the Kreisel↔Strecke edges (`Ausschleusung` = Kreisel→Strecke at the strecke's *Anfang*, `Einschleusung` = @@ -122,13 +133,18 @@ Reads the CSV export and builds a directed graph of the mechanical objects: ends touch the *same* Kreisel (return loop) is split across that Kreisel's two lanes. This makes Kreisel↔Strecke direction deterministic instead of height-inferred. - **BTMT Be-/Entladestation** (entry/exit points) → one node each, recognised by - `TeileArt`/`Bezeichnung` containing `Beladung`/`BTMT-Beladung` (→ **entry**, a - material *source*) or `Entladung`/`SC_Entladung`/`Abwurf` (→ **exit**, a *sink*). - An entry gets an outgoing edge, an exit an incoming edge, to the object named in - its `Nachbarn`; if the export lists no neighbour (dxfmakros does not export BTMT - yet — see below), the nearest object by 2-D distance is used and a note is - printed. Recognition is **sps_skel-side only** — nothing in dxfmakros changes; - it simply activates when such a row appears in the CSV. + `TeileArt`/`Bezeichnung` containing `Beladung`/`BTMT-Beladung`/`Eingang` (→ **entry**, a + material *source*) or `Entladung`/`SC_Entladung`/`Abwurf`/`Ausgang` (→ **exit**, a *sink*). + Some exports name these same entry/exit points plainly `Eingang`/`Ausgang` instead of the + BTMT-specific terms — same role, recognised the same way. + An entry gets an outgoing edge, an exit an incoming edge, to the object its owning + Kreisel/Strecke names it under in `TrackIds` (a Kreisel picks the Eingangs-/ + Ausgangsbahn from `Drehrichtung`, same as `_weiche_lane_roles`); failing that, to + the object named in its own `Nachbarn` (ignoring another station — BTMT `Nachbarn` + often just names nearby separators or the paired station, not a real flow target); + failing that, the nearest Kreisel, then the nearest object by 2-D distance, each + with a note printed. Recognition is **sps_skel-side only** — nothing in dxfmakros + changes; it simply activates when such a row appears in the CSV. > **Note on BTMT / entry-exit data.** dxfmakros' `EXPORTCSV` currently does **not** > emit the BTMT stations (their block names match no export pattern; documented as @@ -190,14 +206,16 @@ See `doc/track.md` for the fuller derivation notes. | `--use-cords` (alias `--use-coords`) | — | off | Place every node at its plant coordinate and render with `neato -n` (fixed positions) instead of `dot`, so the material graph matches the plant layout — the same option `tro_flow.py` has. The two Kreisel lanes (`-L`/`-R`) are nudged apart by `LANE_NUDGE_MM`; scale is `COORD_SCALE` (0.25 pt/mm, identical to `tro_flow.py`). Only has an effect together with `--tosvg`; fails (exit 1) if any flow node has no coordinate. | | `--doc` | — | off | Also write a Markdown report (`_material_flow.md`) with object list, connections and plausibility findings. | | `--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-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. A Kreisel is the one exception: instead of one rectangle for the whole ring, it's split into its two Bahn L/R halves (`_kreisel_lane_rects()` — short axis divided in half, `Zuordnung`-majority-vote decides which half is L vs R), each drawn and labelled separately; its K1–K4 points (with `--show-kx`) still belong to the Kreisel as a whole and are drawn once, not per half. 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`. | +| `--chain-graph` | — | off | Write **one** to-scale "chain-/Lageplan" SVG of the **grobe Objekte** (big items) as `_chain.svg`: every Kreisel split into its Bahn L/R halves (`_kreisel_lane_rects()`, same to-scale split as `--show-bbs`) and every Strecke/Gefällestrecke ("VF"/"GF") drawn as rectangles at their real `Position`/`Boundingbox` coordinates, connected by the already-resolved top-level `graph.edges` (material flow, Kreisel-Umlauf, unresolved). Step 1 of the new chain-graph build-up — no Separator/Scanner/Weiche/Ausschleus-/Einschleuselement and no BTMT Ein-/Ausgang yet; an edge touching a node that isn't drawn (e.g. a station) is simply skipped. Hand-built SVG, no Graphviz. | **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`), `_track.dot` / `_track.svg` (with `--track`). +`_bbs.svg` (with `--show-bbs`), `_track.dot` / `_track.svg` (with `--track`), +`_chain.svg` (with `--chain-graph`). **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 6ac379a..f7ccf07 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -16,8 +16,24 @@ Knotenmodell "Drehrichtung": UZS = rechts nach links, GUZ = links nach rechts * Separator / Scanner -> kein eigener Knoten; sie werden - ueber "Zuordnung" am Label des - uebergeordneten Knotens angezeigt + ueber "Zuordnung" (bevorzugt) oder + "TrackIds" des Containers (siehe + unten) am Label des uebergeordneten + Knotens angezeigt + +Container und ihre Anbauteile (Spalte "TrackIds") +-------------------------------------------------- +Seit Sept. 2026 listet ein Kreisel/Strecke/Gefaellestrecke in seiner eigenen +Spalte "TrackIds" die TeileIds aller Anbauteile (Separator, Scanner, Weiche, +Ein-/Ausschleuselement, BTMT Be-/Entladung), die zu ihm gehoeren. Fuer +Separator/Scanner/Beladung/Entladung bleibt die praezisere "Zuordnung" des +Anbauteils selbst massgeblich, solange sie sich aufloesen laesst; TrackIds +schliesst nur die Luecken (fehlende/unaufloesbare Zuordnung, siehe +_attach_devices/_attach_stations). Transferelemente (Weiche/Ein-/ +Ausschleuselement) werden dagegen weiterhin ausschliesslich ueber ihre +eigenen Merkmale ("Kreisel"/"Strecke"/"Kreisel A"/"Kreisel B") verdrahtet - +dass sie in TrackIds zweier Container auftauchen (sie verbinden ja genau +diese zwei), ist normal, kein Datenfehler. Kantenmodell ------------ @@ -90,16 +106,18 @@ DEVICE_KINDS = ("Separator", "Scanner") # Ein-/Ausgangspunkte des Materialflusses. Sie werden in sps_skel erkannt; # dxfmakros exportiert sie derzeit NICHT (Blockmuster fehlt), die Erkennung # greift also, sobald eine passende Zeile im CSV auftaucht - erkannt an -# TeileArt/Bezeichnung: "Beladung"/"BTMT-Beladung" -> Eingang, "Entladung"/ -# "SC_Entladung"/"Abwurf" -> Ausgang. -ENTRY_KINDS = ("Beladung",) # BTMT-Beladung -> Materialeingang (Quelle) -EXIT_KINDS = ("Entladung",) # SC_Entladung -> Materialausgang (Senke) +# TeileArt/Bezeichnung: "Beladung"/"BTMT-Beladung"/"Eingang" -> Eingang, +# "Entladung"/"SC_Entladung"/"Abwurf"/"Ausgang" -> Ausgang (ein anderer Export +# nennt dieselben Ein-/Ausgabepunkte schlicht "Eingang"/"Ausgang" statt der +# BTMT-Namen - gleiche Rolle, andere Schreibweise). +ENTRY_KINDS = ("Beladung",) # BTMT-Beladung/Eingang -> Materialeingang (Quelle) +EXIT_KINDS = ("Entladung",) # SC_Entladung/Ausgang -> Materialausgang (Senke) STATION_KINDS = ENTRY_KINDS + EXIT_KINDS # Schluesselwoerter zur Erkennung der Stationen (Kleinschreibung, Teilstring) STATION_PATTERNS = ( - ("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")), - ("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf")), + ("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung", "eingang")), + ("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf", "ausgang")), ) # Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen @@ -107,7 +125,10 @@ STATION_PATTERNS = ( # Ausschleuselement = Kreisel -> Strecke (am Streckenanfang, Material verlaesst Kreisel) # Einschleuselement = Strecke -> Kreisel (am Streckenende, Material laeuft in Kreisel) # Weiche = Uebergang zwischen zwei Kreiseln -# Merkmale: {"Kreisel","Strecke","Streckenende"} bzw. {"Kreisel A","Kreisel B"}. +# Merkmale: {"Kreisel","Strecke"/"Foerderer","Streckenende"} bzw. {"Kreisel A","Kreisel B"}. +# "Strecke" und "Foerderer" sind zwei Schreibweisen desselben Merkmals, die +# nebeneinander in ein und demselben Export vorkommen (nicht projektabhaengig +# aufteilbar) - siehe _apply_transfers. TRANSFER_KINDS = ("Einschleus", "Ausschleus", "Weiche") TRANSFER_PATTERNS = ( ("Ausschleus", ("ausschleus",)), @@ -346,6 +367,11 @@ class TrackNode: element: Element | None = None placeholder: bool = False note: str = "" + # Zuordnung des naechstgelegenen Scanners (siehe build_track_graph, Schritt 5) + # - Scanner bilden weiterhin keinen eigenen Knoten (TRACK_KINDS), aber + # render_track_dot() markiert den betroffenen Separator damit per rotem + # Punkt statt einen zusaetzlichen Scanner-Knoten einzufuehren. + scanner_zuordnung: str | None = None @dataclass @@ -479,9 +505,36 @@ 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 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 parse_track_ids(raw: str, *, context: str = "", warnings: list[str] | None = None) -> list[str]: + """'0040, 0076, 0036' -> ['0040', '0076', '0036'] + + Spalte 'TrackIds' (siehe Element.track_ids): anders als 'Nachbarn' ohne + Bahn-Suffix (-L/-R) - TrackIds nennt nur die TeileId, nie eine Kreiselbahn. + + Der ILS-2.0-Export nennt eine TeileId in dieser Spalte gelegentlich doppelt + (Datenfehler im CAD-Export, kein sps_skel-Artefakt) - ohne Entduplizierung + wuerde das in _build_track_owners wie zwei verschiedene Container aussehen + und faelschlich eine "mehrere Container"-Mehrdeutigkeit ausloesen. + """ + seen: set[str] = set() + result: list[str] = [] + for token in (raw or "").split(","): + token = token.strip() + if not token: + continue + if token in seen: + if warnings is not None: + warnings.append(f"{context}: TrackIds nennt '{token}' mehrfach - Duplikat ignoriert") + continue + seen.add(token) + result.append(token) + return result + def _match_kind(patterns, *texts: str) -> str | None: @@ -552,7 +605,11 @@ 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 ""), + track_ids=parse_track_ids( + row.get("TrackIds") or "", + context=f"Zeile {offset} ({teile_art} {teile_id})", + warnings=warnings + ), merkmale=merkmale, row=offset, position=parse_position(row.get("Position") or ""), @@ -603,25 +660,122 @@ def _build_nodes(elements: list[Element], warnings: list[str]) -> dict[str, Node return nodes +def _build_track_owners(elements: list[Element], warnings: list[str]) -> dict[str, list[str]]: + """ + Rueckindex der Spalte 'TrackIds': welche(r) Container (Kreisel/Strecke/ + Gefaellestrecke) nennt ein Anbauteil (Separator/Scanner/Weiche/Ein-/ + Ausschleuselement/Beladung/Entladung) in seiner eigenen 'TrackIds'-Liste. + + Transferelemente (Weiche/Einschleus/Ausschleus, siehe TRANSFER_KINDS) + verbinden zwei Container und werden folgerichtig von beiden genannt - + mehrere Nennungen sind dort normal (ihre Verbindung wird ohnehin ueber die + eigenen Merkmale "Kreisel"/"Strecke"/"Kreisel A"/"Kreisel B" bestimmt, nicht + hierueber). Fuer alle anderen Anbauteile (genau ein Besitzer erwartet) + macht das keinen Sinn - siehe _track_owner. + """ + by_id = {element.teile_id: element for element in elements} + owners: dict[str, list[str]] = {} + for element in elements: + if not (element.is_transport or element.is_circle): + continue + for fine_id in element.track_ids: + if fine_id == element.teile_id: + warnings.append(f"{element.describe()}: TrackIds verweist auf sich selbst - ignoriert") + continue + if fine_id not in by_id: + warnings.append( + f"{element.describe()}: TrackIds verweist auf unbekanntes Element '{fine_id}' - ignoriert" + ) + continue + owners.setdefault(fine_id, []).append(element.teile_id) + return owners + + +def _track_owner( + element: Element, owners_of: dict[str, list[str]], by_id: dict[str, Element], + warnings: list[str] +) -> Element | None: + """ + Container-Element zu einem Anbauteil ueber 'TrackIds' (Rueckindex), oder + None, wenn TrackIds dazu nichts sagt oder (fuer ein Anbauteil mit genau + einem erwarteten Besitzer) mehrdeutig ist. Der Aufrufer faellt dann auf die + bisherige Zuordnung/Nachbarn-Ableitung zurueck. + """ + owners = owners_of.get(element.teile_id) or [] + if not owners: + return None + if len(owners) > 1: + warnings.append( + f"{element.describe()}: TrackIds nennt mehrere Container ({', '.join(sorted(owners))}) - " + f"mehrdeutig, bisherige Zuordnung/Nachbarn-Ableitung verwendet" + ) + return None + return by_id.get(owners[0]) + + +def _owner_node_ids(owner: Element, element: Element, nodes: dict[str, Node]) -> list[str]: + """ + Knoten-Id(s) eines per TrackIds gefundenen Container-Elements - nur wenn + die eigene 'Zuordnung' des Anbauteils schon gescheitert ist (siehe + _attach_devices: leer, oder ohne passenden Knoten). + + TrackIds kennt nur den Kreisel als Ganzes, nicht die Bahn (-L/-R); da eine + aufloesbare Zuordnung bereits Vorrang hatte, bleibt hier nur der Fall + "Bahn unbekannt" - wie mit E-Planung abgestimmt, wird dann an beide Bahnen + angehaengt. + """ + if owner.is_circle: + return [node_id for lane in (LANE_LEFT, LANE_RIGHT) + if (node_id := f"{owner.teile_id}-{lane}") in nodes] + return [owner.teile_id] if owner.teile_id in nodes else [] + + def _attach_devices( - elements: list[Element], nodes: dict[str, Node], warnings: list[str] + elements: list[Element], nodes: dict[str, Node], by_id: dict[str, Element], + owners_of: dict[str, list[str]], warnings: list[str] ) -> list[Element]: - """Separatoren/Scanner ueber 'Zuordnung' an ihren Knoten haengen.""" + """ + Separatoren/Scanner an ihren Knoten haengen. + + Die eigene 'Zuordnung' des Anbauteils bleibt massgeblich, solange sie auf + einen existierenden Knoten zeigt - sie ist praeziser als 'TrackIds', das + bei einem Anbauteil direkt an einer Kreisel-Strecke-Grenze (Ausschleus-/ + Einschleuselement) auch den benachbarten Kreisel nennt, obwohl das + Anbauteil logisch zur Strecke gehoert (siehe _build_track_owners). Erst + wenn die Zuordnung fehlt oder ins Leere zeigt (aeltere Exports ohne + TrackIds-Spalte eingeschlossen), uebernimmt 'TrackIds' auf dem Container + (siehe _track_owner) - das deckt u. a. Anbauteile ab, deren Zuordnung nur + den Kreisel ohne Bahn (-L/-R) nennt. + """ unassigned: list[Element] = [] for element in elements: if not element.is_device: continue + target = element.assignment + if target and target != UNASSIGNED and target in nodes: + nodes[target].devices.setdefault(element.kind, []).append(element.teile_id) + continue + + owner = _track_owner(element, owners_of, by_id, warnings) + if owner is not None: + targets = _owner_node_ids(owner, element, nodes) + if targets: + for node_id in targets: + nodes[node_id].devices.setdefault(element.kind, []).append(element.teile_id) + continue + warnings.append( + f"{element.describe()}: TrackIds verweist auf Container {owner.teile_id}, " + f"aber kein passender Knoten - nicht dargestellt" + ) + if not target or target == UNASSIGNED: unassigned.append(element) continue - if target not in nodes: - warnings.append( - f"{element.describe()}: Zuordnung '{target}' zeigt auf keinen Knoten - nicht dargestellt" - ) - unassigned.append(element) - continue - nodes[target].devices.setdefault(element.kind, []).append(element.teile_id) + warnings.append( + f"{element.describe()}: Zuordnung '{target}' zeigt auf keinen Knoten - nicht dargestellt" + ) + unassigned.append(element) return unassigned @@ -842,19 +996,22 @@ def _nearest_kreisel(station: Element, elements: list[Element]) -> Element | Non def _attach_stations( elements: list[Element], by_id: dict[str, Element], nodes: dict[str, Node], - warnings: list[str] + owners_of: dict[str, list[str]], warnings: list[str] ) -> list[Edge]: """ Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden. Eingang (Beladung) ist Quelle: Kante Station -> Ziel. Ausgang (Entladung) ist Senke: Kante Ziel -> Station. - Ziel kommt aus den 'Nachbarn' des Exports; fehlen sie (dxfmakros exportiert - fuer BTMT derzeit keine), haengen beide baulich direkt am naechstgelegenen - Kreisel, auf dessen Eingangs- bzw. Ausgangsbahn (siehe _weiche_lane_roles): - UZS -> Eingang Bahn R / Ausgang Bahn L, GUZ -> Eingang Bahn L / Ausgang - Bahn R. Nur wenn kein Kreisel mit bekannter Drehrichtung gefunden wird, - greift ersatzweise die naechstgelegene Bahn/Strecke per Abstand. + Ziel kommt zuerst aus 'TrackIds' des Containers (siehe _track_owner) - ein + Kreisel-Container legt ueber die Eingangs-/Ausgangsbahn fest (siehe + _weiche_lane_roles): UZS -> Eingang Bahn R / Ausgang Bahn L, GUZ -> Eingang + Bahn L / Ausgang Bahn R. Kennt TrackIds keinen Container (aeltere Exports + ohne die Spalte), zaehlen ersatzweise die 'Nachbarn' des Exports (fuer BTMT + inzwischen zwar meist gefuellt, nennen dort aber i. d. R. Separatoren oder + die jeweils andere Station statt eines echten Flussknotens - siehe unten), + dann baulich der naechstgelegene Kreisel, und zuletzt die naechstgelegene + Bahn/Strecke per Abstand. """ edges: list[Edge] = [] for element in elements: @@ -864,14 +1021,38 @@ def _attach_stations( if sid not in nodes: continue partners: list[str] = [] - for base, lane in element.neighbours: - if lane and f"{base}-{lane}" in nodes: - partners.append(f"{base}-{lane}") - elif base in nodes: - partners.append(base) + owner = _track_owner(element, owners_of, by_id, warnings) + if owner is not None and owner.is_circle: + roles = _weiche_lane_roles(owner.teile_id, by_id, nodes) + if roles: + eingang, ausgang = roles + lane, role = (eingang, "Eingangsbahn") if element.is_entry else (ausgang, "Ausgangsbahn") + partners = [lane] + warnings.append( + f"{element.describe()}: aus TrackIds an Kreisel {owner.teile_id} " + f"Bahn {lane.split('-')[-1]} angebunden ({role}, Drehrichtung {owner.rotation})" + ) else: - partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT) - if f"{base}-{L}" in nodes] + warnings.append( + f"{element.describe()}: TrackIds nennt Kreisel {owner.teile_id}, aber dessen " + f"Drehrichtung ist unbekannt - Bahn nicht bestimmbar" + ) + elif owner is not None and owner.teile_id in nodes: + partners = [owner.teile_id] + warnings.append(f"{element.describe()}: aus TrackIds an {owner.describe()} angebunden") + if not partners: + # 'Nachbarn' verweist bei BTMT-Stationen haeufig auf Separatoren + # oder die jeweils andere Station (naechste Objekte per Abstand, + # keine echte Flussverbindung) - eine andere Station wird deshalb + # nie als Partner akzeptiert, nur echte Flussobjekte. + for base, lane in element.neighbours: + if lane and f"{base}-{lane}" in nodes: + partners.append(f"{base}-{lane}") + elif base in nodes and not nodes[base].element.is_station: + partners.append(base) + else: + partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT) + if f"{base}-{L}" in nodes] if not partners and (element.is_entry or element.is_exit): kreisel = _nearest_kreisel(element, elements) roles = _weiche_lane_roles(kreisel.teile_id, by_id, nodes) if kreisel else None @@ -963,7 +1144,7 @@ def _apply_transfers( for el in elements: if el.kind in ("Ausschleus", "Einschleus"): k = str(_merkmal(el.merkmale, "Kreisel") or "").strip() - s = str(_merkmal(el.merkmale, "Strecke") or "").strip() + s = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "").strip() if k and s: role = "aus" if el.kind == "Ausschleus" else "ein" per_strecke.setdefault(s, {})[role] = k @@ -1070,7 +1251,8 @@ def _apply_transfers( def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Graph: by_id = {element.teile_id: element for element in elements} nodes = _build_nodes(elements, warnings) - unassigned = _attach_devices(elements, nodes, warnings) + owners_of = _build_track_owners(elements, warnings) + unassigned = _attach_devices(elements, nodes, by_id, owners_of, warnings) observed = _collect_links(elements, by_id, warnings) @@ -1107,7 +1289,7 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr edges.append(Edge(right, left, EDGE_CIRCLE, "unbestimmt")) # Ein-/Ausgabestationen (BTMT Be-/Entladung) als Quelle/Senke anbinden - edges.extend(_attach_stations(elements, by_id, nodes, warnings)) + edges.extend(_attach_stations(elements, by_id, nodes, owners_of, warnings)) # Kreisel<->Strecke gerichtet aus den Transferelementen (massgeblich) edges = _apply_transfers(elements, by_id, nodes, edges, warnings) @@ -1171,12 +1353,19 @@ 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. 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. + die schon ueber eine TrackIds-Kette (Kreisel-Umlauf/Strecke, Schritte 1+2 + in build_track_graph) real mit einem anderen Bahnelement verbunden ist, + braucht keine zusaetzliche Bruecke zur groben Kreisel-*Bahn* mehr - sonst + haengt am sichtbar schon einsortierten Ein-/Ausgang ein ueberfluessiger + "Bahn X"-Platzhalterknoten (Reihenfolge/Position offen) mit dran, obwohl + die Reihenfolge laengst geklaert ist. Nur eine Station OHNE echte Kante + (nicht Teil einer TrackIds-Kette) wird ueberhaupt an den Platzhalter + gebrueckt; grobe Station<->Station-Kanten (ein Artefakt der Abstands- + Anbindung im groben Graph) werden hier so oder so NICHT uebernommen, + sonst entstuenden verwirrende Rueckkanten zwischen zwei BTMT-Punkten, die + im Umlauf schon geordnet sind. """ + already_linked = {edge.src for edge in track.edges} | {edge.dst for edge in track.edges} for element in elements: if not element.is_station: continue @@ -1184,6 +1373,8 @@ def _attach_track_stations( if sid not in graph.nodes: continue track.nodes.setdefault(sid, TrackNode(sid, element.kind, element)) + if sid in already_linked: + continue 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: @@ -1388,6 +1579,25 @@ def build_track_graph( f"{el.describe()}: im --track-Graph ohne jede Kante - TrackIds widerspruechlich" ) + # 5. Scanner bilden weiterhin keinen eigenen Knoten (siehe TRACK_KINDS), aber + # ihre "Naechster Separator"-Angabe wird aufgeloest und die eigene + # Zuordnung des Scanners auf dem betroffenen Separator vermerkt - + # render_track_dot() zeichnet dafuer einen roten Punkt unten mittig ans + # Separator-Rechteck, statt einen zusaetzlichen Knoten einzufuehren. + for el in elements: + if el.kind != "Scanner": + continue + sep_id = str(_merkmal(el.merkmale, "Naechster Separator") or "").strip() + sep_node = track.nodes.get(sep_id) + if sep_node is None or sep_node.kind != "Separator": + if sep_id: + warnings.append( + f"{el.describe()}: 'Naechster Separator' {sep_id} ist im --track-Graph " + f"kein Separator-Knoten - Scanner-Zuordnung nicht dargestellt" + ) + continue + sep_node.scanner_zuordnung = el.assignment or sep_node.scanner_zuordnung + return track @@ -1688,6 +1898,11 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str: TRACK_NODE_STYLES: dict[str, dict[str, str]] = { "Separator": dict(fillcolor="#fff2cc", fontcolor="#7f6000", color="#bf8f00"), + # Scanner ist keine eigene TrackNode-Art (siehe TRACK_KINDS) und taucht im + # --track-DOT nie unter diesem Schluessel auf - der Eintrag existiert nur, + # damit er sich farblich vom DEFAULT_NODE_STYLE-Grau abheben kann, sobald + # ein Konsument ihn braucht. + "Scanner": dict(fillcolor="#d9d2e9", fontcolor="#351c75", color="#674ea7"), "Ausschleus": dict(fillcolor="#f9cb9c", fontcolor="#b45f06", color="#e69138"), "Einschleus": dict(fillcolor="#cfe2f3", fontcolor="#1155cc", color="#3d85c6"), "Weiche": dict(fillcolor="#ead1dc", fontcolor="#741b47", color="#a64d79"), @@ -1723,6 +1938,22 @@ def _track_node_label(node: TrackNode) -> str: return _dot_label(lines) +def _track_node_html_label(node: TrackNode) -> str: + """HTML-like Label mit rotem Punkt unten mittig fuer einen Separator mit + Scanner-Zuordnung (node.scanner_zuordnung, siehe build_track_graph Schritt + 5) - der Scanner selbst bekommt weiterhin keinen eigenen Knoten.""" + el = node.element + lines = [f"{el.name or node.kind} [{node.node_id}]" if el else node.kind, node.kind] + if node.note: + lines.append(node.note) + rows = "".join(f"{_xml_escape(line)}" for line in lines if line) + dot_row = '●' + return ( + f'<' + f'{rows}{dot_row}
>' + ) + + 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 @@ -1749,7 +1980,11 @@ def render_track_dot(track: TrackGraph) -> str: 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}];') + if node.kind == "Separator" and node.scanner_zuordnung: + label_attr = f"label={_track_node_html_label(node)}" + else: + label_attr = f'label="{_track_node_label(node)}"' + add(f' "{node_id}" [{label_attr}, {attrs}{extra}];') add("") for edge in track.edges: @@ -1773,6 +2008,8 @@ def render_track_dot(track: TrackGraph) -> str: 'Bahn-Platzhalter - Reihenfolge/Position offen (Teil B)') add('gestrichelt gelb' 'Richtung unbestimmt (Element nicht in einer TrackIds-Kette)') + add('●' + 'Separator hat einen zugeordneten Scanner (kein eigener Knoten)') add(">];") add(" }") add("}") @@ -1780,6 +2017,7 @@ def render_track_dot(track: TrackGraph) -> str: return "\n".join(out) + "\n" + # --------------------------------------------------------------------------- # connect.ini (Item-/Flussebene) erzeugen # --------------------------------------------------------------------------- @@ -1965,8 +2203,15 @@ def render_bbs_svg( show_kx zeichnet zusaetzlich je Objekt die Anschlusspunkte K1-K4 (rotes Kreuz + kleine Beschriftung) und verbindet sie in der Reihenfolge K1->K2->K3->K4 mit einer blauen Linie. + + Ein Kreisel wird - anders als jedes andere Objekt - nicht als ein + Rechteck, sondern als seine beiden Bahnen L/R gezeichnet: dieselbe + massstabsgetreue Aufteilung (kurze Achse hälftig, Zuordnung-Mehrheits- + entscheid fuer L/R), siehe _kreisel_lane_rects(). + Die K1-K4-Anschlusspunkte (real am Kreisel als Ganzem haengend) werden + trotzdem nur einmal gezeichnet, nicht je Bahn doppelt. """ - items: list[tuple[Element, float, float, float, float]] = [] + items: list[tuple[Element, float, float, float, float, str | None]] = [] for element in elements: if element.position is None or element.bbox is None: warnings.append( @@ -1980,7 +2225,11 @@ def render_bbs_svg( f"{element.describe()}: Boundingbox {bw}, {bd} mm ungueltig - in --show-bbs nicht gezeichnet" ) continue - items.append((element, px, py, bw, bd)) + if element.is_circle: + for lane, (x0, y0, x1, y1) in sorted(_kreisel_lane_rects(element, elements).items()): + items.append((element, (x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0, lane)) + continue + items.append((element, px, py, bw, bd, None)) if not items: raise ValueError("keine Objekte mit Position und Boundingbox gefunden") @@ -2007,11 +2256,11 @@ def render_bbs_svg( add(f" ") add(f' ') - for element, px, py, bw, bd in items: + for element, px, py, bw, bd, lane in items: style = BBS_STYLES.get(element.kind, BBS_DEFAULT_STYLE) x = (px - bw / 2) - min_x y = max_y - (py + bd / 2) - name = element.name or element.teile_id + name = f"{element.name or element.teile_id} Bahn {lane}" if lane else (element.name or element.teile_id) font_size = max(BBS_MIN_FONT_MM, min(bw, bd) * 0.28) add(f' ') @@ -2022,7 +2271,13 @@ def render_bbs_svg( # Anschlusspunkte K1-K4 zuletzt, damit sie ueber den Rechtecken liegen. if show_kx: half = KX_CROSS_MM / 2 + seen_kx: set[str] = set() for element, *_rest in items: + # Ein per Bahn L/R aufgeteilter Kreisel steht zweimal in items, + # seine K1-K4 haengen aber am Kreisel als Ganzem - nur einmal zeichnen. + if element.teile_id in seen_kx: + continue + seen_kx.add(element.teile_id) # Anlage- in viewBox-Koordinaten (X verschoben, Y gespiegelt). pts = [(label, kx - min_x, max_y - ky) for label, (kx, ky) in element.kpoints] # Verbindungslinie K1->K2->K3->K4 (nur bei mindestens zwei Punkten). @@ -2042,6 +2297,239 @@ def render_bbs_svg( return "\n".join(out) + "\n" +# --------------------------------------------------------------------------- +# Kreisel-Geometrie: Aufteilung in Bahn L/R +# --------------------------------------------------------------------------- + + +def _kreisel_lane_rects( + kreisel: Element, fine: list[Element] +) -> dict[str, tuple[float, float, float, float]]: + """ + (x0, y0, x1, y1) je Bahn (LANE_LEFT/LANE_RIGHT) in Anlage-mm: die Kreisel- + Boundingbox wird entlang ihrer kurzen Achse haelftig geteilt (die beiden + Bahnen laufen parallel zur langen Achse). Welche Haelfte L bzw. R ist, wird + per Mehrheitsentscheid aus der 'Zuordnung' (…-L/…-R) der uebergebenen + Feinelemente abgeleitet; ohne verwertbare Zuordnung faellt die Haelfte mit + der kleineren Koordinate auf LANE_RIGHT zurueck (willkuerliche, aber + stabile Konvention, wie zuvor in data/gen_chain_svg.py). + """ + x, y, bw, bh = kreisel.x, kreisel.y, kreisel.bbox_x, kreisel.bbox_y + x0, x1 = x - bw / 2, x + bw / 2 + y0, y1 = y - bh / 2, y + bh / 2 + horizontal = bw >= bh # Bahnen laufen entlang X, Haelften entlang Y gestapelt + + if horizontal: + mid = (y0 + y1) / 2 + half_a, half_b = (x0, y0, x1, mid), (x0, mid, x1, y1) + else: + mid = (x0 + x1) / 2 + half_a, half_b = (x0, y0, mid, y1), (mid, y0, x1, y1) + + votes: dict[str, list[float]] = {LANE_LEFT: [], LANE_RIGHT: []} + for el in fine: + base, _, lane = el.assignment.partition("-") + if base != kreisel.teile_id or lane not in (LANE_LEFT, LANE_RIGHT): + continue + coord = el.y if horizontal else el.x + if coord is not None: + votes[lane].append(coord) + + def avg(values: list[float]) -> float | None: + return sum(values) / len(values) if values else None + + avg_l, avg_r = avg(votes[LANE_LEFT]), avg(votes[LANE_RIGHT]) + a_mid = (half_a[1] + half_a[3]) / 2 if horizontal else (half_a[0] + half_a[2]) / 2 + if avg_l is None and avg_r is None: + lane_a = LANE_RIGHT + else: + dist_l = abs(avg_l - a_mid) if avg_l is not None else float("inf") + dist_r = abs(avg_r - a_mid) if avg_r is not None else float("inf") + lane_a = LANE_LEFT if dist_l < dist_r else LANE_RIGHT + lane_b = LANE_RIGHT if lane_a == LANE_LEFT else LANE_LEFT + return {lane_a: half_a, lane_b: half_b} + + +# --------------------------------------------------------------------------- +# Chain-/Lageplan-SVG der groben Objekte erzeugen (--chain-graph) +# --------------------------------------------------------------------------- +# +# Schritt 1 des neu aufgebauten Materialfluss-Bildes: nur die groben Objekte - +# Kreisel (je Bahn L/R) und Strecke/Gefaellestrecke (VF/GF) - als massstabs- +# getreue Rechtecke an ihrer echten Position/Boundingbox, verbunden durch die +# bereits von build_graph() aufgeloesten Materialfluss-Kanten (graph.edges). +# Keine Anbauteile (Separator, Scanner, Weiche, ...) und keine Ein-/ +# Ausgabestationen - die kommen in einem spaeteren Schritt dazu. + +CHAIN_MAX_W = 1400.0 +CHAIN_MAX_H = 850.0 +CHAIN_MARGIN = 70.0 +CHAIN_TITLE_H = 60.0 + +CHAIN_EDGE_COLOR = { + EDGE_FLOW: "#2f5597", + EDGE_CIRCLE: "#8ea9db", + EDGE_UNRESOLVED: "#bf8f00", +} + + +def _chain_marker_id(color: str) -> str: + return "chainArrow_" + color.lstrip("#") + + +def render_chain_svg(elements: list[Element], graph: Graph, source: str) -> str: + """ + Massstabsgetreuer Chain-/Lageplan der groben Objekte (--chain-graph): jeder + Kreisel als seine beiden Bahnen L/R (_kreisel_lane_rects(), Zuordnung aus + allen Elementen fuer den Mehrheitsentscheid - wie --show-bbs) und jede + Strecke/Gefaellestrecke als ein Rechteck an ihrer echten Position/ + Boundingbox. Kanten kommen direkt aus dem schon aufgeloesten groben + Materialfluss-Graphen (graph.edges) - eine Kante, deren Knoten hier nicht + gezeichnet wird (z. B. eine BTMT-Station), wird schlicht uebersprungen. + """ + rects: dict[str, tuple[float, float, float, float]] = {} + lane_info: dict[str, tuple[Element, str]] = {} + strecke_labels: dict[str, Element] = {} + + for element in elements: + if element.position is None or element.bbox is None: + continue + if element.is_circle: + for lane, rect in _kreisel_lane_rects(element, elements).items(): + node_id = f"{element.teile_id}-{lane}" + rects[node_id] = rect + lane_info[node_id] = (element, lane) + elif element.is_transport: + rects[element.teile_id] = ( + element.x - element.bbox_x / 2, element.y - element.bbox_y / 2, + element.x + element.bbox_x / 2, element.y + element.bbox_y / 2, + ) + strecke_labels[element.teile_id] = element + + if not rects: + raise ValueError("keine Kreisel/Strecken mit Position/Boundingbox gefunden") + + all_x = [v for r in rects.values() for v in (r[0], r[2])] + all_y = [v for r in rects.values() for v in (r[1], r[3])] + min_x, max_x = min(all_x), max(all_x) + min_y, max_y = min(all_y), max(all_y) + real_w, real_h = max(max_x - min_x, 1.0), max(max_y - min_y, 1.0) + scale = min(CHAIN_MAX_W / real_w, CHAIN_MAX_H / real_h) + + def sx(rx: float) -> float: + return CHAIN_MARGIN + (rx - min_x) * scale + + def sy(ry: float) -> float: + return CHAIN_TITLE_H + (max_y - ry) * scale + + draw_w, draw_h = real_w * scale, real_h * scale + canvas_w = CHAIN_MARGIN * 2 + draw_w + canvas_h = CHAIN_TITLE_H + draw_h + CHAIN_MARGIN + + def center(node_id: str) -> tuple[float, float]: + x0, y0, x1, y1 = rects[node_id] + return ((x0 + x1) / 2, (y0 + y1) / 2) + + def anchor(node_id: str, other: tuple[float, float]) -> tuple[float, float]: + """Punkt auf dem Rand von node_id, der am naechsten zu 'other' liegt - + auch wenn 'other' (der Mittelpunkt des Partners) selbst innerhalb des + Rechtecks liegt (schmale Kreisel-Bahn ueber die ganze Anlage).""" + x0, y0, x1, y1 = rects[node_id] + ox, oy = other + cx, cy = min(max(ox, x0), x1), min(max(oy, y0), y1) + if (cx, cy) != (ox, oy): + return (cx, cy) + d_left, d_right = ox - x0, x1 - ox + d_bottom, d_top = oy - y0, y1 - oy + nearest = min(d_left, d_right, d_bottom, d_top) + if nearest == d_left: + return (x0, oy) + if nearest == d_right: + return (x1, oy) + if nearest == d_bottom: + return (ox, y0) + return (ox, y1) + + edges = [e for e in graph.edges if e.src in rects and e.dst in rects] + + # Mindestlaenge eines Pfeils in Bildschirm-Pixeln: zwei Kreisel-Bahnen + # grenzen direkt aneinander (siehe _kreisel_lane_rects) - ihr Umlauf-Pfeil + # laeuft dann exakt auf der gemeinsamen Grenze und wuerde ohne Nachschub + # unsichtbar lang Null sein. + MIN_ARROW_PX = 14.0 + + edge_svg: list[str] = [] + for edge in edges: + x1, y1 = anchor(edge.src, center(edge.dst)) + x2, y2 = anchor(edge.dst, center(edge.src)) + px1, py1, px2, py2 = sx(x1), sy(y1), sx(x2), sy(y2) + if (px2 - px1) ** 2 + (py2 - py1) ** 2 < (MIN_ARROW_PX / 2) ** 2: + src_cx, src_cy = center(edge.src) + dst_cx, dst_cy = center(edge.dst) + dx, dy = sx(dst_cx) - sx(src_cx), sy(dst_cy) - sy(src_cy) + length = max((dx ** 2 + dy ** 2) ** 0.5, 1e-6) + ux, uy = dx / length * (MIN_ARROW_PX / 2), dy / length * (MIN_ARROW_PX / 2) + mx, my = (px1 + px2) / 2, (py1 + py2) / 2 + px1, py1, px2, py2 = mx - ux, my - uy, mx + ux, my + uy + color = CHAIN_EDGE_COLOR.get(edge.kind, CHAIN_EDGE_COLOR[EDGE_FLOW]) + dash = ' stroke-dasharray="4 3"' if edge.kind == EDGE_UNRESOLVED or edge.note == "unbestimmt" else "" + title = f"{edge.src} -> {edge.dst}" + (f" ({_xml_escape(edge.note)})" if edge.note else "") + edge_svg.append( + f'{title}' + ) + + box_svg: list[str] = [] + for node_id, (x0, y0, x1, y1) in rects.items(): + if node_id in lane_info: + kreisel, lane = lane_info[node_id] + style = NODE_STYLES.get(kreisel.kind, DEFAULT_NODE_STYLE) + label = f"{kreisel.name or kreisel.teile_id} Bahn {lane}" + else: + strecke = strecke_labels[node_id] + style = NODE_STYLES.get(strecke.kind, DEFAULT_NODE_STYLE) + label = f"{strecke.name or node_id} [{node_id}]" + box_svg.append( + f'' + ) + box_svg.append( + f'{_xml_escape(label)}' + ) + + heading = f"{Path(source).stem} - Materialfluss (grobe Objekte)" + + out: list[str] = [] + add = out.append + add('') + add(f'') + add(f' ') + add(f'') + add(f' {_xml_escape(heading)}') + add(f' ') + add(' ') + for color in sorted(set(CHAIN_EDGE_COLOR.values())): + add(f' ' + f'') + add(' ') + add(f' {_xml_escape(heading)}') + add(f' ' + f'Massstabsgetreu • Quelle: {_xml_escape(source)}') + out.extend(box_svg) + out.extend(edge_svg) + add("") + return "\n".join(out) + "\n" + + # --------------------------------------------------------------------------- # SVG erzeugen # --------------------------------------------------------------------------- @@ -2318,6 +2806,7 @@ def report( bbs_file: Path | None = None, track_dot_file: Path | None = None, track_svg_file: Path | None = None, + chain_file: Path | None = None, ) -> None: kinds: dict[str, int] = {} for node in graph.nodes.values(): @@ -2350,6 +2839,7 @@ def report( 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(f"Chain-Graph = {chain_file if chain_file else '- (ohne --chain-graph)'}") print("================================================================") if stale_svg is not None: @@ -2400,6 +2890,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: " 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" + " material_flow.bat --file HundM05.csv --chain-graph\n" "\n" "Exit-Codes:\n" " 0 ok\n" @@ -2469,6 +2960,18 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "zu liefern. Siehe doc/track.md. Aendert den normalen Materialfluss-" "Output nicht.", ) + parser.add_argument( + "--chain-graph", + dest="chain_graph", + action="store_true", + help="Massstabsgetreuen Chain-/Lageplan der groben Objekte als " + "_chain.svg nach %%SKEL_RESULTS%% schreiben: jeder Kreisel " + "(je Bahn L/R) und jede Strecke/Gefaellestrecke (VF/GF) als " + "Rechteck an ihrer echten Position/Boundingbox, verbunden durch " + "die bereits aufgeloesten Materialfluss-Kanten. Schritt 1 - noch " + "ohne Anbauteile (Separator, Scanner, Weiche, ...) oder Ein-/" + "Ausgabestationen. Braucht kein Graphviz.", + ) return parser.parse_args(argv) @@ -2578,9 +3081,11 @@ def main(argv: list[str] | None = None) -> int: ) connect_file = connect_target + by_id = {element.teile_id: element for element in elements} + + track: TrackGraph | None = None 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") @@ -2593,6 +3098,17 @@ def main(argv: list[str] | None = None) -> int: except RuntimeError as exc: print(f"FEHLER: Bahnzusammenhang-SVG nicht erzeugt: {exc}", file=sys.stderr) + chain_file = None + if args.chain_graph: + try: + svg = render_chain_svg(elements, graph, csv_file.name) + except ValueError as exc: + print(f"WARNUNG: Chain-Graph: {exc} - nicht erzeugt.", file=sys.stderr) + else: + chain_target = results / f"{stem}_chain.svg" + chain_target.write_text(svg, encoding="utf-8") + chain_file = chain_target + report( graph, findings, @@ -2604,6 +3120,7 @@ def main(argv: list[str] | None = None) -> int: bbs_file=bbs_file, track_dot_file=track_dot_file, track_svg_file=track_svg_file, + chain_file=chain_file, ) if svg_error: diff --git a/lib/tro_flow.py b/lib/tro_flow.py index 0e3f87e..c4daa53 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -465,12 +465,13 @@ def derive_tros( # 3b) Separatoren an einem Aus-/Einschleuselement -> eigene 1Sep1Swi- # Uebergabestelle. Das Element uebernimmt die Rolle der (hier fehlenden) # Weiche; siehe _find_switch_separators. Host bewusst die Strecke (nicht - # die Kreisel-Bahn): beide Elementarten tragen "Strecke" im Merkmal, und - # die Strecke hat bereits reale, gerichtete Kanten zum Kreisel + # die Kreisel-Bahn): beide Elementarten tragen "Strecke" oder "Foerderer" + # im Merkmal (beide Schreibweisen kommen nebeneinander im selben Export + # vor), und die Strecke hat bereits reale, gerichtete Kanten zum Kreisel # (_apply_transfers) - darueber haengt sich die Verknuepfung (link_tros) # von selbst ein, ohne einen weiteren synthetischen Host zu erfinden. for sep_id, (transfer, distance) in sorted(switch_hits.items()): - strecke_id = str(_merkmal(transfer.merkmale, "Strecke") or "") + strecke_id = str(_merkmal(transfer.merkmale, "Strecke", "Förderer") or "") ende = str(_merkmal(transfer.merkmale, "Streckenende") or "") own_scanners = sorted(sep_scanners.get(sep_id, [])) art = "Ausschleuselement" if transfer.kind == "Ausschleus" else "Einschleuselement"