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
+36 -6
View File
@@ -162,12 +162,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
+205 -76
View File
@@ -74,7 +74,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
@@ -102,24 +102,37 @@ 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-/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 = ( STATION_PATTERNS = (
("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung", "eingang")), ("Beladung", ("beladung", "beladestation", "btmt-beladung", "btmt beladung")),
("Entladung", ("entladung", "entladestation", "sc_entladung", "abwurf", "ausgang")), ("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)
@@ -178,6 +191,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")
@@ -848,35 +865,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)
""" """
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": 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" 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)"
) )
@@ -994,24 +1019,35 @@ 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],
owners_of: dict[str, list[str]], warnings: list[str] owners_of: dict[str, 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 -> 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] = [] edges: list[Edge] = []
for element in elements: for element in elements:
@@ -1020,62 +1056,94 @@ 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] = []
owner = _track_owner(element, owners_of, by_id, warnings) # owner = _track_owner(element, owners_of, by_id, warnings)
if owner is not None and owner.is_circle: # if owner is not None and owner.is_circle:
roles = _weiche_lane_roles(owner.teile_id, by_id, nodes) # roles = _weiche_lane_roles(owner.teile_id, by_id, nodes)
if roles: # 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 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()}: aus TrackIds an Kreisel {owner.teile_id} " f"{element.describe()}: im Umlauf von Kreisel "
f"Bahn {lane.split('-')[-1]} angebunden ({role}, Drehrichtung {owner.rotation})" f"{container.teile_id} - an Bahn {lane.split('-')[-1]} "
f"angebunden ({role}, Drehrichtung {container.rotation})"
) )
else:
warnings.append( # 2. Nachbarn, aber nur echte Flussobjekte (kein Separator/Station).
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: 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: 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 and not nodes[base].element.is_station: elif base in nodes:
partners.append(base) partners.append(base)
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))
@@ -1144,7 +1212,11 @@ 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", "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: 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
@@ -1248,7 +1320,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)
owners_of = _build_track_owners(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 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()