merge des aktuelllen Standes

This commit is contained in:
Samer Ayadi
2026-09-17 13:23:37 +02:00
3 changed files with 246 additions and 83 deletions
+205 -76
View File
@@ -74,7 +74,7 @@ import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
@@ -102,24 +102,37 @@ CIRCLE_KINDS = ("Kreisel",)
# Objektarten, die per "Zuordnung" an einem Knoten haengen (kein eigener Knoten)
DEVICE_KINDS = ("Separator", "Scanner")
# Ein-/Ausgabestationen (BTMT Be-/Entladung) = Materialquelle/-senke, d.h. die
# 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" -> 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)
# Ein-/Ausgabestationen = Materialquelle/-senke, d.h. die Ein-/Ausgangspunkte
# des Materialflusses. Es gibt sie in zwei Auspraegungen:
# * konkrete BTMT-Station: "Beladung" (Quelle) / "Entladung" (Senke)
# * generisches Symbol: "Eingang" (Quelle) / "Ausgang" (Senke) - der
# allgemeinere Bezeichner fuer dasselbe. Liegt ein Symbol nahezu deckungs-
# gleich ueber der konkreten BTMT-Station (haeufig, gleiche x/y), wird nur
# ein Knoten gezeichnet: die BTMT-Station gewinnt, das Symbol wird
# 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
# Die generischen Symbole (verschluckbar zugunsten der konkreten BTMT-Station).
STATION_SYMBOL_KINDS = ("Eingang", "Ausgang")
# 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 = (
("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung", "eingang")),
("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf", "ausgang")),
("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")),
("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
# Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten:
# Ausschleuselement = Kreisel -> Strecke (am Streckenanfang, Material verlaesst Kreisel)
@@ -178,6 +191,10 @@ NODE_STYLES = {
"Strecke": dict(fillcolor="#e2f0d9", fontcolor="#375623", color="#548235"),
"Beladung": dict(fillcolor="#d4f0d4", fontcolor="#1e5b1e", color="#338833"),
"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")
@@ -848,35 +865,43 @@ def drive_uphill(element: Element) -> tuple[bool | None, str]:
False = Fluss laeuft vom hoechsten Nachbarn weg (bergab)
None = nicht bestimmbar
Bewertet wird in dieser Reihenfolge:
1. Hoehe_Von_mm / Hoehe_Bis_mm - die Hoehenaenderung des Objekts selbst
2. Antriebfahrtrichtung "Auf" (nach oben) bzw. "Ab" (nach unten)
3. Gefaellestrecke ohne Angaben laeuft per Definition bergab
Die Hoehe bestimmt die Richtung NUR bei einer reinen Gefaellestrecke - dort
laeuft der Fluss per Definition bergab (Schwerkraft). Ein Foerderer (VF)
kann bauartbedingt nach oben ODER unten foerdern; eine Strecke ist oft eine
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
Zeichnung.
Bewertet wird daher:
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)
"""
rise = element.rise
if rise is not None and rise != 0:
direction = "Auf" if rise > 0 else "Ab"
return rise > 0, (
f"Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis "
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":
rise = element.rise
if rise is not None and rise != 0:
direction = "Auf" if rise > 0 else "Ab"
return rise > 0, (
f"Gefaellestrecke Hoehe_Von {element.height_from:.0f} mm -> Hoehe_Bis "
f"{element.height_to:.0f} mm ({direction}, {rise:+.0f} mm)"
)
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, (
f"weder Hoehe_Von/Bis noch Antriebfahrtrichtung verwertbar "
f"(Antrieb '{element.drive_dir}', Hoehen "
f"{element.height_from}/{element.height_to})"
f"Foerderer/Strecke - Hoehe ist kein Richtungsindikator und "
f"Antriebfahrtrichtung '{element.drive_dir or '-'}' nicht eindeutig "
f"(Richtung kommt sonst aus AS/ES)"
)
@@ -994,24 +1019,35 @@ def _nearest_kreisel(station: Element, elements: list[Element]) -> Element | Non
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(
elements: list[Element], by_id: dict[str, Element], nodes: dict[str, Node],
owners_of: dict[str, list[str]], warnings: list[str]
) -> 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 -> Kreisel-Bahn.
Ausgang (Entladung) ist Senke: Kante Kreisel-Bahn -> Station.
Die Zielbahn kommt in dieser Reihenfolge:
1. Der Kreisel, in dessen TrackIds-Umlauf die Station liegt (verlaesslich,
siehe Element.track_ids) - Eingangs-/Ausgangsbahn aus der Drehrichtung
(_weiche_lane_roles: UZS -> Eingang R / Ausgang L, GUZ umgekehrt).
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.
Eingang (Beladung) ist Quelle: Kante Station -> Ziel.
Ausgang (Entladung) ist Senke: Kante Ziel -> Station.
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:
@@ -1020,62 +1056,94 @@ def _attach_stations(
sid = element.teile_id
if sid not in nodes:
continue
partners: list[str] = []
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:
# 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:
# 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:
# 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")
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})"
f"{element.describe()}: im Umlauf von Kreisel "
f"{container.teile_id} - an Bahn {lane.split('-')[-1]} "
f"angebunden ({role}, Drehrichtung {container.rotation})"
)
else:
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")
# 2. Nachbarn, aber nur echte Flussobjekte (kein Separator/Station).
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:
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:
partners.append(f"{base}-{lane}")
elif base in nodes and not nodes[base].element.is_station:
elif base in nodes:
partners.append(base)
else:
partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT)
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):
kreisel = _nearest_kreisel(element, elements)
roles = _weiche_lane_roles(kreisel.teile_id, by_id, nodes) if kreisel else None
if 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]
warnings.append(
f"{element.describe()}: keine Nachbarn im Export - "
f"an Kreisel {kreisel.teile_id} Bahn {lane.split('-')[-1]} "
f"angebunden ({role}, Drehrichtung {kreisel.rotation})"
f"{element.describe()}: nicht in einem TrackIds-Umlauf - "
f"an naechsten Kreisel {kreisel.teile_id} Bahn "
f"{lane.split('-')[-1]} angebunden ({role}, Drehrichtung "
f"{kreisel.rotation})"
)
if not partners:
nearest = _nearest_node(element, nodes)
if nearest:
partners = [nearest]
warnings.append(
f"{element.describe()}: keine Nachbarn im Export - "
f"{element.describe()}: kein Umlauf/Flussnachbar - "
f"naechstes Objekt '{nearest}' per Abstand verbunden"
)
else:
warnings.append(f"{element.describe()}: kein Ziel gefunden - unverbunden")
continue
for partner in partners:
if element.is_entry:
edges.append(Edge(sid, partner, EDGE_FLOW))
@@ -1144,7 +1212,11 @@ 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", "Förderer") 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:
role = "aus" if el.kind == "Ausschleus" else "ein"
per_strecke.setdefault(s, {})[role] = k
@@ -1248,7 +1320,52 @@ def _apply_transfers(
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:
# 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}
nodes = _build_nodes(elements, warnings)
owners_of = _build_track_owners(elements, warnings)
@@ -1470,6 +1587,18 @@ def build_track_graph(
warnings wird mit build_graph() geteilt, damit Fein-Hinweise im selben
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)
seen_edges: set[tuple[str, str]] = set()