neuer Schalter --chain-graph, BTMT Eingang/Ausgang erkannt, Foerderer-Merkmal in tro_flow

--chain-graph zeichnet einen massstabsgetreuen Chain-/Lageplan der groben
Objekte (Kreisel-Bahnhaelften, Strecke/Gefaellestrecke) als <name>_chain.svg,
ohne Graphviz. BTMT-Stationen werden jetzt auch an "Eingang"/"Ausgang" statt
nur "Beladung"/"Entladung" erkannt und bevorzugt ueber TrackIds statt Nachbarn
verbunden. tro_flow.py erkennt bei Aus-/Einschleuselementen jetzt auch
"Foerderer" als Merkmal-Schreibweise fuer die Strecke, nicht nur "Strecke".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-09-17 13:15:50 +02:00
co-authored by Claude Sonnet 5
parent 516f30278b
commit 80e7acc8dd
5 changed files with 604 additions and 64 deletions
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+27 -9
View File
@@ -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 (`<name>_material_flow.md`) with object list, connections and plausibility findings. |
| `--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-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. 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 (`<name>_track.dot`, and `<name>_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 `<name>_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%`): `<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>_track.dot` / `<name>_track.svg` (with `--track`).
`<name>_bbs.svg` (with `--show-bbs`), `<name>_track.dot` / `<name>_track.svg` (with `--track`),
`<name>_chain.svg` (with `--chain-graph`).
**Exit codes:** `0` ok · `1` input/CLI error · `2` SVG rendering failed
(Graphviz).
+567 -50
View File
@@ -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"<TR><TD>{_xml_escape(line)}</TD></TR>" for line in lines if line)
dot_row = '<TR><TD><FONT COLOR="#cc0000" POINT-SIZE="16">&#9679;</FONT></TD></TR>'
return (
f'<<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="1">'
f'{rows}{dot_row}</TABLE>>'
)
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:
'<td align="left">Bahn-Platzhalter - Reihenfolge/Position offen (Teil B)</td></tr>')
add('<tr><td align="left"><font color="#bf8f00">gestrichelt gelb</font></td>'
'<td align="left">Richtung unbestimmt (Element nicht in einer TrackIds-Kette)</td></tr>')
add('<tr><td align="left"><font color="#cc0000">&#9679;</font></td>'
'<td align="left">Separator hat einen zugeordneten Scanner (kein eigener Knoten)</td></tr>')
add("</table>>];")
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" <!-- Quelle: {_xml_escape(source)} -->")
add(f' <rect x="0" y="0" width="{width:.2f}" height="{height:.2f}" fill="#ffffff"/>')
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' <rect x="{x:.2f}" y="{y:.2f}" width="{bw:.2f}" height="{bd:.2f}" '
f'fill="{style["fill"]}" fill-opacity="0.85" stroke="{style["stroke"]}" stroke-width="6"/>')
@@ -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} -&gt; {edge.dst}" + (f" ({_xml_escape(edge.note)})" if edge.note else "")
edge_svg.append(
f'<g><title>{title}</title><line x1="{px1:.1f}" y1="{py1:.1f}" '
f'x2="{px2:.1f}" y2="{py2:.1f}" stroke="{color}" stroke-width="1.8"{dash} '
f'marker-end="url(#{_chain_marker_id(color)})"/></g>'
)
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'<rect x="{sx(x0):.1f}" y="{sy(y1):.1f}" width="{(x1 - x0) * scale:.1f}" '
f'height="{(y1 - y0) * scale:.1f}" fill="{style["fillcolor"]}" fill-opacity="0.3" '
f'stroke="{style["color"]}" stroke-width="2.2"/>'
)
box_svg.append(
f'<text x="{sx(x0) + 4:.1f}" y="{sy(y1) + 12:.1f}" font-size="10" font-weight="700" '
f'fill="{style["color"]}">{_xml_escape(label)}</text>'
)
heading = f"{Path(source).stem} - Materialfluss (grobe Objekte)"
out: list[str] = []
add = out.append
add('<?xml version="1.0" encoding="UTF-8"?>')
add(f'<!-- Chain-/Lageplan (grobe Objekte): Kreisel-Bahnen und Strecke/Gefaellestrecke '
f'massstabsgetreu aus Position/Boundingbox, Kanten aus dem groben Materialfluss-Graphen '
f'(build_graph()). Automatisch erzeugt von lib/material_flow.py (Option "chain-graph"), '
f'nicht manuell aendern. -->')
add(f' <!-- Quelle: {_xml_escape(source)} -->')
add(f'<svg viewBox="0 0 {canvas_w:.0f} {canvas_h:.0f}" xmlns="http://www.w3.org/2000/svg" '
f'font-family="Segoe UI, Arial, sans-serif">')
add(f' <title>{_xml_escape(heading)}</title>')
add(f' <rect x="0" y="0" width="{canvas_w:.0f}" height="{canvas_h:.0f}" fill="#fbfaf6"/>')
add(' <defs>')
for color in sorted(set(CHAIN_EDGE_COLOR.values())):
add(f' <marker id="{_chain_marker_id(color)}" viewBox="0 0 10 10" refX="8" refY="5" '
f'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
f'<path d="M0,0 L10,5 L0,10 z" fill="{color}"/></marker>')
add(' </defs>')
add(f' <text x="{canvas_w / 2:.0f}" y="26" text-anchor="middle" font-size="17" font-weight="700" '
f'fill="#1f3864">{_xml_escape(heading)}</text>')
add(f' <text x="{canvas_w / 2:.0f}" y="44" text-anchor="middle" font-size="11" fill="#5b6270">'
f'Massstabsgetreu &#8226; Quelle: {_xml_escape(source)}</text>')
out.extend(box_svg)
out.extend(edge_svg)
add("</svg>")
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 "
"<name>_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:
+4 -3
View File
@@ -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"