Materialfluss: VF/GF-Richtung, Stations-Bahn und Eingang/Ausgang-Merge

- _apply_transfers liest die andockende Strecke auch aus dem Merkmal
  "Foerderer"/"Förderer" (nicht nur "Strecke"), damit VF/GF gerichtet
  Kreisel->AS->..->ES->Kreisel gezeichnet werden (Ruecklauf = zwei Pfeile
  auf die beiden Kreisel-Bahnen) statt "unbestimmt".

- _attach_stations haengt BTMT/Eingang/Ausgang an die Kreisel-Bahn, in
  deren TrackIds-Umlauf sie liegen (Bahn aus der Drehrichtung); Nachbarn,
  die keine Flussobjekte sind, werden uebersprungen - keine
  Station<->Station-Kanten mehr.

- Eingang/Ausgang sind die generischen Symbole fuer Beladung/Entladung.
  Liegt ein Symbol deckungsgleich (<= STATION_MERGE_MM = 600 mm) ueber
  einer BTMT-Station gleicher Rolle, wird es verschluckt
  (_merge_station_symbols) - nur ein Knoten, in grobem Graph UND --track
  (dort auch aus den TrackIds gestrichen). Einzelne Symbole bleiben eigener
  gruener/roter Knoten.

- drive_uphill: die Hoehe bestimmt die Richtung NUR bei einer reinen
  Gefaellestrecke (Schwerkraft, bergab). Ein Foerderer/gemischte Strecke
  kann hoch oder runter foerdern - dort zaehlt nur eine eindeutige
  Antriebfahrtrichtung, sonst kommt die Richtung aus AS/ES.

Doku in doc/Python_Scripts.md und doc/track.md nachgezogen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-09-17 12:54:44 +02:00
co-authored by Claude Opus 4.8
parent 516f30278b
commit 117cd8914b
3 changed files with 222 additions and 63 deletions
+36 -6
View File
@@ -146,12 +146,42 @@ id ranges, e.g. `Gefaellestrecke 0017 - 0036 (20x)` / `Separator (20): 0037 -
in `lib/material_flow.py`) — the underlying graph, `--doc` report and in `lib/material_flow.py`) — the underlying graph, `--doc` report and
`--connect` output are unaffected and still list every line individually. `--connect` output are unaffected and still list every line individually.
Flow direction between conveyor segments is derived from height change Flow direction between a Kreisel and a Strecke/Gefällestrecke is set from the
(`Hoehe_Von_mm`/`Hoehe_Bis_mm`) or, failing that, from `Antriebfahrtrichtung` **Ausschleus-/Einschleuselement** at each end (`_apply_transfers`): the
("Auf"/"Ab"); connections whose direction can't be determined are drawn Ausschleuselement is the Kreisel outlet (Kreisel → Strecke, start), the
bidirectional and reported as a warning. `--doc` additionally cross-checks Einschleuselement the inlet (Strecke → Kreisel, end). A conveyor whose two ends
stated `Anzahl_Separator`/`Anzahl_Scanner` counts against what was actually dock on the *same* Kreisel (a return line — Eingang and Ausgang on one Kreisel)
attached. is drawn as **two** edges — one onto each lane (`0006-L → VF` on the way out,
`VF → 0006-R` on the way back) — so it is never flat/undetermined. The transfer
element names its conveyor via the `Strecke` **or** `Foerderer`/`Förderer`
attribute (both are read).
Where no transfer element resolves the direction, `drive_uphill` applies the
physics rule: **only a pure Gefällestrecke** takes its direction from the height
(`Hoehe_Von_mm`/`Hoehe_Bis_mm` → flows downhill; a GF with no height runs downhill
by definition). A Förderer (VF) can convey up *or* down and a `Strecke` is often
a mixed GF/VF chain, so **height is not a direction indicator for them** — they
use an unambiguous `Antriebfahrtrichtung` (exactly `"Auf"`/`"Ab"`, not a mixed
list like `"horizontal,horizontal,Auf"`) and otherwise stay undetermined
(direction is expected to come from the AS/ES ends). Only what nothing resolves
is drawn bidirectional and reported as a warning.
Entry/exit stations are hung on the Kreisel **lane** they belong to
(`_attach_stations`): first the Kreisel whose `TrackIds` umlauf lists the station,
then the entry-/exit-lane from its `Drehrichtung` (UZS → entry lane R / exit lane
L, GUZ reversed). Sources (`Beladung`/`Eingang`) get `Station → lane`, sinks
(`Entladung`/`Ausgang`) `lane → Station`. Neighbours that aren't flow objects
(another station, a separator) are skipped, so two BTMT points no longer get
wired to each other.
`Eingang`/`Ausgang` are the **generic symbols** for the concrete BTMT stations
`Beladung`/`Entladung` (same source/sink role). When a symbol lies almost on top
of a BTMT station of the same role (≤ `STATION_MERGE_MM` = 600 mm, `x`/`y` from
the `Position` column), only **one** node is drawn: the concrete BTMT station is
kept, the generic symbol is swallowed (`_merge_station_symbols`). A standalone
`Eingang`/`Ausgang` with no nearby BTMT partner is drawn as its own green/red
node. `--doc` additionally cross-checks stated `Anzahl_Separator`/`Anzahl_Scanner`
counts against what was actually attached.
`--track` builds a second, finer-grained graph in addition to the one above: `--track` builds a second, finer-grained graph in addition to the one above:
Separator, Ausschleus-/Einschleuselement, Weiche and the entry/exit points Separator, Ausschleus-/Einschleuselement, Weiche and the entry/exit points
+5 -1
View File
@@ -46,7 +46,11 @@ Kreisel 0001 trackIds = [0040(Sep@0001-R), 0079(Weiche), 0036(Sep@0014)]
4. **Systemgrenzen** — BTMT Be-/Entladung werden über den bereits im groben 4. **Systemgrenzen** — BTMT Be-/Entladung werden über den bereits im groben
Graphen aufgelösten Anschluss angebunden (`_attach_track_stations`); offene Graphen aufgelösten Anschluss angebunden (`_attach_track_stations`); offene
Bahnenden ohne Station bekommen ein generisches Symbol Eingang/Ausgang Bahnenden ohne Station bekommen ein generisches Symbol Eingang/Ausgang
(`_attach_track_symbols`). Wichtig: die Offenheit wird am **feinen** Graphen (`_attach_track_symbols`). Ein generisches Eingang/Ausgang-Symbol, das
deckungsgleich über einer konkreten BTMT-Station liegt (≤ `STATION_MERGE_MM`),
wird auch hier verschluckt (`_merge_station_symbols`) — sowohl aus der
Knotenliste als auch aus den `TrackIds` jedes Containers —, sonst liefe der
Kreisel-Umlauf durch zwei nahezu identische Knoten. Wichtig: die Offenheit wird am **feinen** Graphen
gemessen (ein reales Bahnelement ohne Vorgänger/Nachfolger in `track.edges`), gemessen (ein reales Bahnelement ohne Vorgänger/Nachfolger in `track.edges`),
**nicht** am groben. Der grobe Graph kann eine Bahn als offenes Ende führen, **nicht** am groben. Der grobe Graph kann eine Bahn als offenes Ende führen,
obwohl ihre AS/ES-Enden im Kreisel-Umlauf längst verkettet sind (die Richtung obwohl ihre AS/ES-Enden im Kreisel-Umlauf längst verkettet sind (die Richtung
+168 -43
View File
@@ -58,7 +58,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -86,22 +86,35 @@ CIRCLE_KINDS = ("Kreisel",)
# Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten) # Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten)
DEVICE_KINDS = ("Separator", "Scanner") DEVICE_KINDS = ("Separator", "Scanner")
# Ein-/Ausgabestationen (BTMT Be-/Entladung) = Materialquelle/-senke, d.h. die # Ein-/Ausgabestationen = Materialquelle/-senke, d.h. die Ein-/Ausgangspunkte
# Ein-/Ausgangspunkte des Materialflusses. Sie werden in sps_skel erkannt; # des Materialflusses. Es gibt sie in zwei Auspraegungen:
# dxfmakros exportiert sie derzeit NICHT (Blockmuster fehlt), die Erkennung # * konkrete BTMT-Station: "Beladung" (Quelle) / "Entladung" (Senke)
# greift also, sobald eine passende Zeile im CSV auftaucht - erkannt an # * generisches Symbol: "Eingang" (Quelle) / "Ausgang" (Senke) - der
# TeileArt/Bezeichnung: "Beladung"/"BTMT-Beladung" -> Eingang, "Entladung"/ # allgemeinere Bezeichner fuer dasselbe. Liegt ein Symbol nahezu deckungs-
# "SC_Entladung"/"Abwurf" -> Ausgang. # gleich ueber der konkreten BTMT-Station (haeufig, gleiche x/y), wird nur
ENTRY_KINDS = ("Beladung",) # BTMT-Beladung -> Materialeingang (Quelle) # ein Knoten gezeichnet: die BTMT-Station gewinnt, das Symbol wird
EXIT_KINDS = ("Entladung",) # SC_Entladung -> Materialausgang (Senke) # verschluckt (siehe _merge_station_symbols / STATION_MERGE_MM).
ENTRY_KINDS = ("Beladung", "Eingang") # -> Materialeingang (Quelle)
EXIT_KINDS = ("Entladung", "Ausgang") # -> Materialausgang (Senke)
STATION_KINDS = ENTRY_KINDS + EXIT_KINDS STATION_KINDS = ENTRY_KINDS + EXIT_KINDS
# Die generischen Symbole (verschluckbar zugunsten der konkreten BTMT-Station).
STATION_SYMBOL_KINDS = ("Eingang", "Ausgang")
# Schluesselwoerter zur Erkennung der Stationen (Kleinschreibung, Teilstring) # Schluesselwoerter zur Erkennung der Stationen (Kleinschreibung, Teilstring).
# "eingang"/"ausgang" MUESSEN nach den spezifischeren Mustern kommen, damit z.B.
# "BTMT-Beladung" als Beladung (nicht Eingang) klassifiziert wird.
STATION_PATTERNS = ( STATION_PATTERNS = (
("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")), ("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")),
("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf")), ("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf")),
("Eingang", ("eingang",)),
("Ausgang", ("ausgang",)),
) )
# Max. Abstand (mm), ab dem ein generisches Eingang/Ausgang-Symbol als
# "deckungsgleich" mit einer BTMT-Station gilt und verschluckt wird. Die
# beobachteten Paare liegen bei 256-431 mm, echte getrennte Stationen >1000 mm.
STATION_MERGE_MM = 600.0
# Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen # Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen
# Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten: # Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten:
# Ausschleuselement = Kreisel -> Strecke (am Streckenanfang, Material verlaesst Kreisel) # Ausschleuselement = Kreisel -> Strecke (am Streckenanfang, Material verlaesst Kreisel)
@@ -157,6 +170,10 @@ NODE_STYLES = {
"Strecke": dict(fillcolor="#e2f0d9", fontcolor="#375623", color="#548235"), "Strecke": dict(fillcolor="#e2f0d9", fontcolor="#375623", color="#548235"),
"Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"), "Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"),
"Entladung": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"), "Entladung": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"),
# Generische Symbole - gleiche Farbe wie die konkreten BTMT-Stationen
# (gruen = Eingang/Quelle, rot = Ausgang/Senke).
"Eingang": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"),
"Ausgang": dict(fillcolor="#f4d4d4", fontcolor="#7a1f1f", color="#cc3333"),
} }
DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080") DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080")
@@ -694,35 +711,43 @@ def drive_uphill(element: Element) -> tuple[bool | None, str]:
False = Fluss laeuft vom hoechsten Nachbarn weg (bergab) False = Fluss laeuft vom hoechsten Nachbarn weg (bergab)
None = nicht bestimmbar None = nicht bestimmbar
Bewertet wird in dieser Reihenfolge: Die Hoehe bestimmt die Richtung NUR bei einer reinen Gefaellestrecke - dort
1. Hoehe_Von_mm / Hoehe_Bis_mm - die Hoehenaenderung des Objekts selbst laeuft der Fluss per Definition bergab (Schwerkraft). Ein Foerderer (VF)
2. Antriebfahrtrichtung "Auf" (nach oben) bzw. "Ab" (nach unten) kann bauartbedingt nach oben ODER unten foerdern; eine Strecke ist oft eine
3. Gefaellestrecke ohne Angaben laeuft per Definition bergab gemischte GF/VF-Kette. Fuer beide ist die Hoehendifferenz KEIN Richtungs-
indikator - ihre Richtung kommt aus den Ein-/Ausschleuselementen
(_apply_transfers, AS -> ES) bzw. einer eindeutigen Antriebfahrtrichtung,
sonst bleibt sie unbestimmt.
Der zweite Rueckgabewert nennt die verwendete Quelle fuer Bericht und Bewertet wird daher:
Zeichnung. 1. Gefaellestrecke: Hoehe_Von/Bis (bergab), sonst per Definition bergab
2. Antriebfahrtrichtung "Auf"/"Ab" (nur wenn eindeutig, nicht bei
gemischten Listen wie "horizontal,horizontal,Auf")
3. sonst None (unbestimmt)
""" """
if element.kind == "Gefaellestrecke":
rise = element.rise rise = element.rise
if rise is not None and rise != 0: if rise is not None and rise != 0:
direction = "Auf" if rise > 0 else "Ab" direction = "Auf" if rise > 0 else "Ab"
return rise > 0, ( return rise > 0, (
f"Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis " f"Gefaellestrecke Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis "
f"{element.height_to:.0f} mm ({direction}, {rise:+.0f} mm)" f"{element.height_to:.0f} mm ({direction}, {rise:+.0f} mm)"
) )
drive = element.drive_dir.lower()
if drive.startswith("auf"):
return True, f"Antriebfahrtrichtung '{element.drive_dir}' (nach oben)"
if drive.startswith("ab"):
return False, f"Antriebfahrtrichtung '{element.drive_dir}' (nach unten)"
if element.kind == "Gefaellestrecke":
return False, "Gefaellestrecke ohne Hoehenangabe - laeuft bergab" return False, "Gefaellestrecke ohne Hoehenangabe - laeuft bergab"
# Foerderer/Strecke: Hoehe sagt NICHTS ueber die Richtung. Nur eine
# eindeutige Antriebfahrtrichtung (genau "Auf" bzw. "Ab", keine gemischte
# Liste) zaehlt.
drive = element.drive_dir.strip().lower()
if drive == "auf":
return True, f"Antriebfahrtrichtung '{element.drive_dir}' (nach oben)"
if drive == "ab":
return False, f"Antriebfahrtrichtung '{element.drive_dir}' (nach unten)"
return None, ( return None, (
f"weder Hoehe_Von/Bis noch Antriebfahrtrichtung verwertbar " f"Foerderer/Strecke - Hoehe ist kein Richtungsindikator und "
f"(Antrieb '{element.drive_dir}', Hoehen " f"Antriebfahrtrichtung '{element.drive_dir or '-'}' nicht eindeutig "
f"{element.height_from}/{element.height_to})" f"(Richtung kommt sonst aus AS/ES)"
) )
@@ -840,21 +865,34 @@ def _nearest_kreisel(station: Element, elements: list[Element]) -> Element | Non
return best return best
def _kreisel_containing(station_id: str, elements: list[Element]) -> Element | None:
"""Kreisel, in dessen TrackIds-Umlauf die Station liegt (oder None)."""
for el in elements:
if el.is_circle and station_id in el.track_ids:
return el
return None
def _attach_stations( def _attach_stations(
elements: list[Element], by_id: dict[str, Element], nodes: dict[str, Node], elements: list[Element], by_id: dict[str, Element], nodes: dict[str, Node],
warnings: list[str] warnings: list[str]
) -> list[Edge]: ) -> list[Edge]:
""" """
Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden. Ein-/Ausgabestationen (BTMT Be-/Entladung, Eingang/Ausgang) mit dem Fluss
verbinden.
Eingang (Beladung) ist Quelle: Kante Station -> Ziel. Eingang (Beladung) ist Quelle: Kante Station -> Kreisel-Bahn.
Ausgang (Entladung) ist Senke: Kante Ziel -> Station. Ausgang (Entladung) ist Senke: Kante Kreisel-Bahn -> Station.
Ziel kommt aus den 'Nachbarn' des Exports; fehlen sie (dxfmakros exportiert
fuer BTMT derzeit keine), haengen beide baulich direkt am naechstgelegenen Die Zielbahn kommt in dieser Reihenfolge:
Kreisel, auf dessen Eingangs- bzw. Ausgangsbahn (siehe _weiche_lane_roles): 1. Der Kreisel, in dessen TrackIds-Umlauf die Station liegt (verlaesslich,
UZS -> Eingang Bahn R / Ausgang Bahn L, GUZ -> Eingang Bahn L / Ausgang siehe Element.track_ids) - Eingangs-/Ausgangsbahn aus der Drehrichtung
Bahn R. Nur wenn kein Kreisel mit bekannter Drehrichtung gefunden wird, (_weiche_lane_roles: UZS -> Eingang R / Ausgang L, GUZ umgekehrt).
greift ersatzweise die naechstgelegene Bahn/Strecke per Abstand. 2. Ersatzweise: ein Flussobjekt aus den 'Nachbarn' (Separator/Station als
Nachbar werden bewusst uebersprungen - sonst haengen zwei BTMT-Punkte
aneinander statt an der Bahn).
3. Ersatzweise: der naechstgelegene Kreisel per Abstand, sonst der
naechste Flussknoten.
""" """
edges: list[Edge] = [] edges: list[Edge] = []
for element in elements: for element in elements:
@@ -863,8 +901,30 @@ def _attach_stations(
sid = element.teile_id sid = element.teile_id
if sid not in nodes: if sid not in nodes:
continue continue
partners: list[str] = [] partners: list[str] = []
# 1. Kreisel aus dem TrackIds-Umlauf + Bahn aus der Drehrichtung.
container = _kreisel_containing(sid, elements)
if container is not None:
roles = _weiche_lane_roles(container.teile_id, by_id, nodes)
if roles is not None:
eingang, ausgang = roles
lane, role = ((eingang, "Eingangsbahn") if element.is_entry
else (ausgang, "Ausgangsbahn"))
partners = [lane]
warnings.append(
f"{element.describe()}: im Umlauf von Kreisel "
f"{container.teile_id} - an Bahn {lane.split('-')[-1]} "
f"angebunden ({role}, Drehrichtung {container.rotation})"
)
# 2. Nachbarn, aber nur echte Flussobjekte (kein Separator/Station).
if not partners:
for base, lane in element.neighbours: for base, lane in element.neighbours:
partner_el = by_id.get(base)
if partner_el is None or not (partner_el.is_transport or partner_el.is_circle):
continue
if lane and f"{base}-{lane}" in nodes: if lane and f"{base}-{lane}" in nodes:
partners.append(f"{base}-{lane}") partners.append(f"{base}-{lane}")
elif base in nodes: elif base in nodes:
@@ -872,29 +932,35 @@ def _attach_stations(
else: else:
partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT) partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT)
if f"{base}-{L}" in nodes] if f"{base}-{L}" in nodes]
# 3. Naechster Kreisel per Abstand (Bahn aus Drehrichtung), sonst
# naechster Flussknoten.
if not partners and (element.is_entry or element.is_exit): if not partners and (element.is_entry or element.is_exit):
kreisel = _nearest_kreisel(element, elements) kreisel = _nearest_kreisel(element, elements)
roles = _weiche_lane_roles(kreisel.teile_id, by_id, nodes) if kreisel else None roles = _weiche_lane_roles(kreisel.teile_id, by_id, nodes) if kreisel else None
if roles: if roles:
eingang, ausgang = roles eingang, ausgang = roles
lane, role = (eingang, "Eingangsbahn") if element.is_entry else (ausgang, "Ausgangsbahn") lane, role = ((eingang, "Eingangsbahn") if element.is_entry
else (ausgang, "Ausgangsbahn"))
partners = [lane] partners = [lane]
warnings.append( warnings.append(
f"{element.describe()}: keine Nachbarn im Export - " f"{element.describe()}: nicht in einem TrackIds-Umlauf - "
f"an Kreisel {kreisel.teile_id} Bahn {lane.split('-')[-1]} " f"an naechsten Kreisel {kreisel.teile_id} Bahn "
f"angebunden ({role}, Drehrichtung {kreisel.rotation})" f"{lane.split('-')[-1]} angebunden ({role}, Drehrichtung "
f"{kreisel.rotation})"
) )
if not partners: if not partners:
nearest = _nearest_node(element, nodes) nearest = _nearest_node(element, nodes)
if nearest: if nearest:
partners = [nearest] partners = [nearest]
warnings.append( warnings.append(
f"{element.describe()}: keine Nachbarn im Export - " f"{element.describe()}: kein Umlauf/Flussnachbar - "
f"naechstes Objekt '{nearest}' per Abstand verbunden" f"naechstes Objekt '{nearest}' per Abstand verbunden"
) )
else: else:
warnings.append(f"{element.describe()}: kein Ziel gefunden - unverbunden") warnings.append(f"{element.describe()}: kein Ziel gefunden - unverbunden")
continue continue
for partner in partners: for partner in partners:
if element.is_entry: if element.is_entry:
edges.append(Edge(sid, partner, EDGE_FLOW)) edges.append(Edge(sid, partner, EDGE_FLOW))
@@ -963,7 +1029,9 @@ def _apply_transfers(
for el in elements: for el in elements:
if el.kind in ("Ausschleus", "Einschleus"): if el.kind in ("Ausschleus", "Einschleus"):
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip() k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
s = str(_merkmal(el.merkmale, "Strecke") or "").strip() # Der Export benennt die andockende Strecke je nach Bauart als
# "Strecke" (VF) oder "Foerderer"/"Förderer" (GF/Streckengruppe).
s = str(_merkmal(el.merkmale, "Strecke", "Foerderer", "Förderer") or "").strip()
if k and s: if k and s:
role = "aus" if el.kind == "Ausschleus" else "ein" role = "aus" if el.kind == "Ausschleus" else "ein"
per_strecke.setdefault(s, {})[role] = k per_strecke.setdefault(s, {})[role] = k
@@ -1067,7 +1135,52 @@ def _apply_transfers(
return result return result
def _merge_station_symbols(
elements: list[Element], warnings: list[str] | None = None
) -> set[str]:
"""
Generische Eingang/Ausgang-Symbole ermitteln, die deckungsgleich ueber einer
konkreten BTMT-Station liegen (gleiche Rolle Quelle/Senke, Abstand
<= STATION_MERGE_MM). Diese werden verschluckt: es bleibt nur die
BTMT-Station als Knoten. Rueckgabe = TeileIds der verschluckten Symbole.
Eingang gehoert zu Beladung (beide Quelle), Ausgang zu Entladung (beide
Senke). Ein Symbol ohne passende nahe BTMT-Station bleibt ein eigener Knoten.
warnings=None unterdrueckt die Meldungen (zweiter Aufruf im --track-Pfad,
der sonst dieselben Hinweise ein zweites Mal ausgibt).
"""
swallowed: set[str] = set()
btmt = [e for e in elements if e.is_station and e.kind not in STATION_SYMBOL_KINDS]
for sym in elements:
if sym.kind not in STATION_SYMBOL_KINDS or sym.x is None or sym.y is None:
continue
sym_is_entry = sym.kind in ENTRY_KINDS
best, best_d = None, None
for st in btmt:
if st.x is None or st.y is None:
continue
if st.is_entry != sym_is_entry: # nur gleiche Rolle (Quelle/Senke)
continue
d = ((st.x - sym.x) ** 2 + (st.y - sym.y) ** 2) ** 0.5
if d <= STATION_MERGE_MM and (best_d is None or d < best_d):
best, best_d = st, d
if best is not None:
swallowed.add(sym.teile_id)
if warnings is not None:
warnings.append(
f"{sym.describe()}: deckungsgleich mit {best.kind} {best.teile_id} "
f"({best_d:.0f} mm) - Symbol verschluckt, nur {best.kind} gezeichnet"
)
return swallowed
def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Graph: def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Graph:
# Generische Eingang/Ausgang-Symbole, die ueber einer BTMT-Station liegen,
# verschlucken (nur die konkrete Station bleibt Knoten).
swallowed = _merge_station_symbols(elements, warnings)
if swallowed:
elements = [e for e in elements if e.teile_id not in swallowed]
by_id = {element.teile_id: element for element in elements} by_id = {element.teile_id: element for element in elements}
nodes = _build_nodes(elements, warnings) nodes = _build_nodes(elements, warnings)
unassigned = _attach_devices(elements, nodes, warnings) unassigned = _attach_devices(elements, nodes, warnings)
@@ -1279,6 +1392,18 @@ def build_track_graph(
warnings wird mit build_graph() geteilt, damit Fein-Hinweise im selben warnings wird mit build_graph() geteilt, damit Fein-Hinweise im selben
Bericht erscheinen. Bericht erscheinen.
""" """
# Deckungsgleiche Eingang/Ausgang-Symbole (wie im groben Graph) verschlucken:
# aus der Element-Liste UND aus den TrackIds jedes Containers entfernen, sonst
# laeuft der Kreisel-Umlauf durch zwei nahezu identische Knoten (Symbol +
# BTMT-Station). warnings=None: die Meldung stand schon im groben Graph.
swallowed = _merge_station_symbols(elements, warnings=None)
if swallowed:
elements = [
replace(e, track_ids=[t for t in e.track_ids if t not in swallowed])
for e in elements if e.teile_id not in swallowed
]
by_id = {e.teile_id: e for e in elements}
track = TrackGraph(source=graph.source) track = TrackGraph(source=graph.source)
seen_edges: set[tuple[str, str]] = set() seen_edges: set[tuple[str, str]] = set()