Kreisel-Separatorliste (list_trackids) im CSV-Export ergaenzt

Kreisel bekommen wie VF_n/GF_n ein Merkmal mit der Reihenfolge aller
Separator-/BTMT-Beladung-/BTMT-Entladung-TeileIds entlang der Bahn,
sortiert nach Winkel um die Kreiselachse (Start bei 0 Grad in
Drehrichtung DREHRICHTUNG) - nutzt dieselbe Umlauf-Logik, die bisher
nur die "Nachbarn"-Spalte gespeist hat. Merkmal-Schluessel einheitlich
auf "list_trackids" umbenannt (vorher "Separatorlist" bei VF/GF).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 14:41:25 +02:00
parent 061a878346
commit bfd95ea725
4 changed files with 278 additions and 32 deletions
+70 -14
View File
@@ -130,6 +130,7 @@ from export_neighbors import (
SENSOR_TEILEARTEN,
WEICHE_TEILEART,
GF_VF_TEILEARTEN,
KREISEL_UMLAUF_TEILEARTEN,
)
BLOCKPATTERNS = load_patterns()
@@ -234,7 +235,7 @@ def build_variofoerderer_merkmale(block):
export_raw.json-Dateien (z.B. tests/reference/export_raw.json) und noch
nicht neu aufgebaute Bloecke in der Zeichnung weiterhin Werte liefern.
Das Merkmal "Separatorlist" (Reihenfolge der Separator-TeileIds entlang
Das Merkmal "list_trackids" (Reihenfolge der Separator-TeileIds entlang
der Strecke) steht hier NICHT drin - es kommt erst nach der sepliste-
Aufloesung dazu (siehe write_separatorliste_merkmale in process_blocks),
weil an dieser Stelle die x/y-Koordinaten aus der sepliste-XDATA noch
@@ -778,15 +779,17 @@ def resolve_umlauf_und_sepliste_nachbarn(items, kreisel_umlauf, sep_kette,
"""
# Gemeinsamer Lookup fuer BEIDE Schluesselarten, die in kreisel_umlauf und
# sep_kette als punkt_key/vorgaenger/nachfolger auftreten koennen: freie
# Separatoren (TeileId), Kreisel-Kreisel-Weichen (weiche_by_key) UND
# sepliste-Zeilen (sep_kette_by_key, Schluessel "<strecke_id>#<lfdnr>").
# Ein Umlauf-Punkt kann naemlich ein AS/ES-Ende sein (sep_kette-Schluessel)
# und ein sep_kette-Vorgaenger/Nachfolger kann das freie Kreiselende gar
# nicht sein (das loest ausschliesslich compute_kreisel_umlauf auf) - erst
# der gemeinsame Lookup deckt alle Kombinationen ab.
separator_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") == "ILS 2.0 Separator"}
alle_schluessel = {**sep_kette_by_key, **weiche_by_key, **separator_by_id}
# Punkte (Separator/BTMT Beladung/BTMT Entladung, TeileId - siehe
# KREISEL_UMLAUF_TEILEARTEN), Kreisel-Kreisel-Weichen
# (weiche_by_key) UND sepliste-Zeilen (sep_kette_by_key, Schluessel
# "<strecke_id>#<lfdnr>"). Ein Umlauf-Punkt kann naemlich ein AS/ES-Ende
# sein (sep_kette-Schluessel) und ein sep_kette-Vorgaenger/Nachfolger kann
# das freie Kreiselende gar nicht sein (das loest ausschliesslich
# compute_kreisel_umlauf auf) - erst der gemeinsame Lookup deckt alle
# Kombinationen ab.
freipunkt_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") in KREISEL_UMLAUF_TEILEARTEN}
alle_schluessel = {**sep_kette_by_key, **weiche_by_key, **freipunkt_by_id}
# Freie Separatoren, Weichen UND die AS/ES-Enden andockender Ketten
# bekommen ihre "Nachbarn"-Spalte aus dem Kreisel-Umlauf.
@@ -827,7 +830,7 @@ def resolve_umlauf_und_sepliste_nachbarn(items, kreisel_umlauf, sep_kette,
def write_separatorliste_merkmale(items, sep_kette_by_key, dbg=None):
"""Traegt in jede Strecke (VF_n/GF_n mit "sepliste") das Merkmal
"Separatorlist" ein: die TeileIds ihrer Separatoren in Baureihenfolge,
"list_trackids" ein: die TeileIds ihrer Separatoren in Baureihenfolge,
kommagetrennt (analog zu "Zuordnung"/"Nachbarn"), z.B. "0052, 0053, 0054".
Quelle ist die rohe "sepliste" des Items (AS/SEP/ES mit x/y, bereits in
@@ -857,12 +860,57 @@ def write_separatorliste_merkmale(items, sep_kette_by_key, dbg=None):
sep_item = sep_kette_by_key.get(key)
if sep_item is None:
if dbg:
dbg(f" Separatorlist {key}: kein aufgeloestes Separator-Item "
dbg(f" list_trackids {key}: kein aufgeloestes Separator-Item "
f"- uebersprungen")
continue
separator_ids.append(sep_item.get("teileid", ""))
if separator_ids:
item["merkmale"]["Separatorlist"] = ", ".join(separator_ids)
item["merkmale"]["list_trackids"] = ", ".join(separator_ids)
def write_kreisel_separatorliste_merkmale(items, kreisel_umlauf, sep_kette_by_key,
weiche_by_key, dbg=None):
"""Traegt in jeden Kreisel ("ILS 2.0 Kreisel") das Merkmal "list_trackids"
ein (gleicher Schluessel wie bei VF_n/GF_n, siehe write_separatorliste_
merkmale): die TeileIds aller auf ihm liegenden freien Punkte (Separator,
BTMT Beladung/Entladung - siehe KREISEL_UMLAUF_TEILEARTEN),
andockenden AS/ES-Streckenenden und Kreisel-Kreisel-Weichen, in
Umlaufreihenfolge ab Winkel 0 Grad (siehe compute_kreisel_umlauf - die
Liste beginnt dort bereits bei 0 Grad in Drehrichtung DREHRICHTUNG),
kommagetrennt.
Quelle ist kreisel_umlauf (export_neighbors.compute_kreisel_umlauf), das
fuer jeden Kreisel bereits alle Punkte (freie Punkte, AS/ES-Enden,
Kreisel-Kreisel-Weichen) nach Winkel um die Kreiselachse sortiert hat -
hier werden nur noch deren rohe Schluessel (TeileId direkt, sep_kette-
Schluessel oder Weichen-"key") auf die TeileId des jeweils zugehoerigen
Items aufgeloest. Derselbe Lookup wie in
resolve_umlauf_und_sepliste_nachbarn, daher muss diese Funktion NACH
map_separator_kette_items/build_kreisel_weiche_items/
build_strecke_schleus_items laufen (deren TeileIds stehen erst dann fest).
"""
freipunkt_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") in KREISEL_UMLAUF_TEILEARTEN}
alle_schluessel = {**sep_kette_by_key, **weiche_by_key, **freipunkt_by_id}
kreisel_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") == "ILS 2.0 Kreisel"}
for tid, punkte in (kreisel_umlauf or {}).items():
kreisel_item = kreisel_by_id.get(tid)
if kreisel_item is None or not punkte:
continue
teileids = []
for punkt_key, _vorgaenger, _nachfolger in punkte:
item = alle_schluessel.get(punkt_key)
if item is None:
if dbg:
dbg(f" list_trackids Kreisel {tid}: kein Item fuer "
f"Umlauf-Punkt {punkt_key!r} - uebersprungen")
continue
teileids.append(item.get("teileid", ""))
if teileids:
kreisel_item["merkmale"]["list_trackids"] = ", ".join(teileids)
def process_blocks(blocks, lookup, dbg=None):
@@ -1262,13 +1310,21 @@ def process_blocks(blocks, lookup, dbg=None):
map_separator_kette_items(
items, sep_kette, sep_kette_by_key=sep_kette_by_key, dbg=dbg)
# Merkmal "Separatorlist" je Strecke (Reihenfolge der SEP-TeileIds aus der
# Merkmal "list_trackids" je Strecke (Reihenfolge der SEP-TeileIds aus der
# sepliste, x/y bereits oben auf das reale Separator-Item aufgeloest).
write_separatorliste_merkmale(items, sep_kette_by_key, dbg=dbg)
resolve_umlauf_und_sepliste_nachbarn(
items, kreisel_umlauf, sep_kette, sep_kette_by_key, weiche_by_key)
# Merkmal "list_trackids" je Kreisel: dieselben Umlauf-Punkte (freie
# Separatoren, AS/ES-Enden, Kreisel-Kreisel-Weichen), die oben schon die
# "Nachbarn"-Spalte gespeist haben, jetzt als eine einzige, bei 0 Grad
# beginnende Reihenfolge auf den Kreisel selbst geschrieben (analog zu
# write_separatorliste_merkmale bei VF_n/GF_n).
write_kreisel_separatorliste_merkmale(
items, kreisel_umlauf, sep_kette_by_key, weiche_by_key, dbg=dbg)
# Nach dem Sammelheimen aller Zeilen: sortiert nach TeileId ausgeben, damit
# Elementnummer (erste Spalte) und TeileId nach Moeglichkeit uebereinstimmen
# (siehe format_csv_line/Elementnummer-Neuvergabe unten). Numerische IDs
+49 -14
View File
@@ -74,6 +74,19 @@ STRECKEN_TEILEARTEN = {"ILS 2.0 Gefaellestrecke", "ILS 2.0 Strecke", "ILS 2.0 St
OMNIFLO_TEILEARTEN = {"Omniflo Kurve", "Omniflo Weiche", "Omniflo Gerade"}
SENSOR_TEILEARTEN = {"ILS 2.0 Separator", "ILS 2.0 Scanner"}
# Frei auf der Bahn platzierte Elemente, die im Kreisel-Umlauf
# (compute_kreisel_umlauf) und im Kreisel-Merkmal "list_trackids" (export_csv.py
# write_kreisel_separatorliste_merkmale) als eigene Punkte auftreten koennen -
# wie ein Separator per Bounding-Box-Ueberschneidung einer Kreiselhaelfte/
# einem Kreisel zugeordnet (siehe compute_sensor_zuordnung/compute_kreisel_
# umlauf). BTMT-Beladung/-Entladung sind Stationen AUF der Strecke, keine
# Sensoren - eigene Konstante statt Erweiterung von SENSOR_TEILEARTEN, damit
# deren Sensor-spezifische Weiterverarbeitung (z.B. compute_scanner_nearest_
# separator) unberuehrt bleibt.
KREISEL_UMLAUF_TEILEARTEN = {
"ILS 2.0 Separator", "ILS 2.0 BTMT Beladung", "ILS 2.0 BTMT Entladung", "ILS Weiche",
}
# Nur "ILS 2.0 Kreisel" (echte Kreisel-Bloecke mit AN8/Antrieb + SP8/Spann-
# station + ABSTAND-Attribut) werden links/rechts gesplittet - "ILS 2.0
# Eckrad" hat keine dieser zwei Stationen und bleibt eine einzelne BBox.
@@ -1380,10 +1393,11 @@ def compute_sep_kette(items, dbg=None):
def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
touch_switches=None, dbg=None):
"""Bringt fuer jeden echten Kreisel (KREISEL_SPLIT_TEILEART) alle dort
liegenden Punkte - frei platzierte Separatoren (per compute_sensor_
zuordnung bereits einer Kreiselhaelfte zugeordnet), die AS/ES-Enden
aller andockenden VF_n/GF_n-Ketten (aus sep_kette, per BBox-Naehe wie
compute_strecke_kreisel_schleus zugeordnet) UND die Kreisel-Kreisel-
liegenden Punkte - frei platzierte Separatoren UND BTMT-Beladung/
-Entladung (KREISEL_UMLAUF_TEILEARTEN, per BBox-Ueberschneidung
wie compute_sensor_zuordnung einer Kreiselhaelfte zugeordnet), die AS/ES-
Enden aller andockenden VF_n/GF_n-Ketten (aus sep_kette, per BBox-Naehe
wie compute_strecke_kreisel_schleus zugeordnet) UND die Kreisel-Kreisel-
Weichen (touch_switches, siehe compute_kreisel_touch_switches) - in eine
geschlossene Umlauf-Reihenfolge (zirkulaer, siehe DREHRICHTUNG-Attribut).
@@ -1399,7 +1413,11 @@ def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
_kreisel_capsule) gemessen, 0 Grad = Kapsel-Achsrichtung. UZS
(Uhrzeigersinn, Default) sortiert nach fallendem Winkel, GUZ nach
steigendem - beides ergibt einen konsistenten Umlauf, die Achsrichtung
selbst ist nur der Nullpunkt.
selbst ist nur der Nullpunkt. Die zurueckgegebene Liste beginnt IMMER bei
diesem Nullpunkt (kleinster Winkel >= 0 Grad in Umlaufrichtung) - das ist
fuer die reine Vorgaenger-/Nachfolger-Ermittlung (zirkulaer) irrelevant,
aber Voraussetzung fuer eine reproduzierbare, bei 0 Grad beginnende
"list_trackids" (siehe export_csv.py write_kreisel_separatorliste_merkmale).
items = dieselbe Liste wie bei compute_neighbor_ids/compute_sensor_
zuordnung. sep_kette = Rueckgabe von compute_sep_kette (fuer die AS/ES-
@@ -1408,10 +1426,11 @@ def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
Umlauf).
Rueckgabe: dict {kreisel_teileid: [(punkt_key, vorgaenger_key,
nachfolger_key), ...]} in Umlaufreihenfolge - punkt_key ist eine
Separator-TeileId (freie Separatoren), ein sep_kette-Schluessel (AS/ES-
Enden) oder ein touch_switches-"key" (Kreisel-Kreisel-Weiche). Kreisel
ohne zugeordnete Punkte liefern eine leere Liste.
nachfolger_key), ...]} in Umlaufreihenfolge, beginnend bei Winkel 0 Grad -
punkt_key ist die TeileId eines freien Punkts (Separator oder BTMT-
Beladung/-Entladung, siehe KREISEL_UMLAUF_TEILEARTEN), ein
sep_kette-Schluessel (AS/ES-Enden) oder ein touch_switches-"key" (Kreisel-
Kreisel-Weiche). Kreisel ohne zugeordnete Punkte liefern eine leere Liste.
"""
half_tol = tolerance_mm / 2.0
kreisel = {}
@@ -1451,7 +1470,7 @@ def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
punkte_je_kreisel = {tid: [] for tid in kreisel}
for item in items:
if item.get("teileart", "") != "ILS 2.0 Separator":
if item.get("teileart", "") not in KREISEL_UMLAUF_TEILEARTEN:
continue
bbox = item.get("_bbox")
if not bbox:
@@ -1495,11 +1514,27 @@ def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0,
acy = (cap["a"][1] + cap["b"][1]) / 2.0
achs_winkel = math.atan2(cap["b"][1] - cap["a"][1], cap["b"][0] - cap["a"][0])
def _winkel(pxy):
return math.atan2(pxy[2] - acy, pxy[1] - acx) - achs_winkel
uzs = (drehrichtung or "UZS").strip().upper() != "GUZ"
reverse = (drehrichtung or "UZS").strip().upper() != "GUZ"
sortiert = sorted(punkte, key=lambda p: _winkel(p), reverse=reverse)
def _winkel(pxy):
# Rohwinkel relativ zur Achsrichtung, auf [0, 2*pi) normiert IN
# UMLAUFRICHTUNG. Vorher (reine Vorgaenger-/Nachfolger-Ermittlung
# ohne Startpunkt-Anspruch) wurde UZS durch reverse=True auf dem
# rohen mathematischen Winkel erreicht (= "absteigend sortieren");
# absteigend sortieren auf theta ist aequivalent zu aufsteigend
# sortieren auf -theta - darum wird HIER fuer UZS negiert (NICHT
# fuer GUZ, das dem unveraenderten mathematischen Winkel bereits
# aufsteigend entspricht). Ohne dieses Vorzeichen liefe der UZS-
# Umlauf in die falsche (zu GUZ spiegelverkehrte) Richtung - siehe
# tests/test_export_kreisel_umlauf.py::TestKreiselUmlaufStartetBeiNullGrad.
# Die Normierung auf [0, 2*pi) sorgt zusaetzlich dafuer, dass
# Index 0 nach dem Sortieren der kleinste Winkel >= 0 Grad ist
# (der gewuenschte Start bei 0 Grad).
roh = math.atan2(pxy[2] - acy, pxy[1] - acx) - achs_winkel
roh = -roh if uzs else roh
return roh % (2.0 * math.pi)
sortiert = sorted(punkte, key=lambda p: _winkel(p))
n = len(sortiert)
umlauf = []
for i, p in enumerate(sortiert):