Files
dxfmakros/tests/test_export_kreisel_umlauf.py
T
m.stangl 47724fcfe2 TrackIds von VF/GF-Strecken um AS-/ES-Kettenenden ergaenzt
write_separatorliste_merkmale liess bisher nur "SEP"-Eintraege der
sepliste in die "TrackIds"-Spalte einfliessen; die AS-/ES-Ausschleus-
/Einschleuselemente an Anfang und Ende der Kette fehlten dadurch,
obwohl sie beim Kreisel (compute_kreisel_umlauf) bereits Teil von
dessen TrackIds sind. Jetzt werden alle sepliste-Eintraege (AS, SEP,
ES) in Baureihenfolge aufgeloest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:18:55 +02:00

425 lines
20 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
test_export_kreisel_umlauf.py - Kreisel-Kreisel-Weiche im Umlauf.
Laeuft komplett ohne BricsCAD (reine Python-Geometrie, siehe lib/export_neighbors.py).
Regressionstest fuer einen realen Mubea-Befund: zwei sich beruehrende Kreisel
erzeugen eine "ILS Weiche" am Beruehrpunkt (compute_kreisel_touch_switches).
Diese Weiche ist der Verzweigungspunkt zwischen beiden Kreiseln - ein
Separator direkt davor muss sie als Nachbarn bekommen, nicht (wie vor dem
Fix) einen Punkt vom jeweils ANDEREN Kreisel-Ende, weil die Weiche in
compute_kreisel_umlauf schlicht fehlte.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
from export_neighbors import ( # noqa: E402
compute_kreisel_touch_switches,
compute_kreisel_umlauf,
)
from export_csv import ( # noqa: E402
build_strecke_schleus_items,
map_separator_kette_items,
resolve_umlauf_und_sepliste_nachbarn,
write_kreisel_separatorliste_merkmale,
write_separatorliste_merkmale,
)
DURCHMESSER_MM = 800.0
RADIUS_MM = DURCHMESSER_MM / 2.0
def _kreisel(teileid, x0, y0, rotation, abstand=1000.0):
return {
"teileart": "ILS 2.0 Kreisel",
"teileid": teileid,
"x": x0, "y": y0, "z": 0.0,
"rotation": rotation,
"attribs": {"ABSTAND": str(abstand), "DREHRICHTUNG": "UZS"},
"_bbox": {"cx": x0, "cy": y0, "cz": 0.0,
"dx": abstand + DURCHMESSER_MM, "dy": DURCHMESSER_MM},
}
def _separator(teileid, x, y):
return {
"teileart": "ILS 2.0 Separator",
"teileid": teileid,
"_bbox": {"cx": x, "cy": y, "cz": 0.0, "dx": 150.0, "dy": 100.0},
}
def _btmt(teileart, teileid, x, y):
"""BTMT-Beladung/-Entladung-Stationsblock ("ILS 2.0 BTMT Beladung"/
"ILS 2.0 BTMT Entladung") - frei auf der Bahn platziert wie ein
Separator, siehe KREISEL_UMLAUF_TEILEARTEN."""
return {
"teileart": teileart,
"teileid": teileid,
"_bbox": {"cx": x, "cy": y, "cz": 0.0, "dx": 200.0, "dy": 150.0},
}
def _zwei_beruehrende_kreisel():
"""Zwei Kreisel, deren AN8-Enden sich exakt beruehren (End-an-End, wie im
Mubea-Fall: 'Kreis/Kreis'-Beruehrung an der jeweiligen AN8-Kappe).
Kreisel 1 liegt entlang +X ab x=0 (AN8-Mitte bei radius=400, SP8-Mitte bei
radius+abstand=1400). Kreisel 2 liegt entlang -X ab x=800 (AN8-Mitte bei
800-400=400) - beide AN8-Kappen beruehren sich bei x=400 (Kreis/Kreis).
"""
k1 = _kreisel("0001", 0.0, 0.0, 0.0)
k2 = _kreisel("0002", 800.0, 0.0, 180.0)
return [k1, k2]
class TestKreiselKreiselWeicheImUmlauf:
def test_touch_switch_erkannt(self):
items = _zwei_beruehrende_kreisel()
switches = compute_kreisel_touch_switches(items, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
assert len(switches) == 1
sw = switches[0]
assert sorted(sw["nachbarn"]) == ["0001", "0002"]
assert sw["key"] == "WEICHE#0001#0002"
def test_weiche_in_beiden_umlaeufen(self):
"""Die Weiche muss als Punkt in BEIDEN Kreisel-Umlaeufen auftauchen -
sie ist der gemeinsame Verzweigungspunkt (K1->K2 und K2->K1)."""
items = _zwei_beruehrende_kreisel()
switches = compute_kreisel_touch_switches(items, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM,
touch_switches=switches)
weiche_key = switches[0]["key"]
for tid in ("0001", "0002"):
keys = [p[0] for p in umlauf[tid]]
assert weiche_key in keys, f"Weiche fehlt im Umlauf von Kreisel {tid}"
def test_separator_bekommt_weiche_als_nachbar_nicht_anderen_kreisel(self):
"""Realer Mubea-Befund: ein Separator, der unmittelbar VOR der
Kreisel-Kreisel-Weiche liegt (auf der AN8-Seite von Kreisel 1), muss
die Weiche als Umlauf-Nachbarn bekommen - nicht einen Punkt, der
eigentlich zu Kreisel 2 gehoert (das war der Bug: die Weiche fehlte
in punkte_je_kreisel, wodurch beide Separatoren am Beruehrpunkt auf
denselben (falschen) Nachbarn verwiesen)."""
items = _zwei_beruehrende_kreisel()
# Separator dicht vor der Beruehrung (x=390), gehoert zu Kreisel 1.
sep = _separator("0036", 390.0, 0.0)
items.append(sep)
switches = compute_kreisel_touch_switches(items, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
assert len(switches) == 1
weiche_key = switches[0]["key"]
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=50.0,
durchmesser_mm=DURCHMESSER_MM,
touch_switches=switches)
punkte_k1 = {p[0]: (p[1], p[2]) for p in umlauf["0001"]}
assert "0036" in punkte_k1, "Separator 0036 fehlt im Umlauf von Kreisel 1"
vorgaenger, nachfolger = punkte_k1["0036"]
assert weiche_key in (vorgaenger, nachfolger), (
f"Separator 0036 sollte die Kreisel-Kreisel-Weiche ({weiche_key}) "
f"als Umlauf-Nachbarn haben, hat aber vorgaenger={vorgaenger!r}, "
f"nachfolger={nachfolger!r}")
class TestKreiselBoundsRotationsUnabhaengig:
"""Regressionstest fuer einen zweiten realen Mubea-Befund: ein um 90 Grad
gedrehter, sehr langer Kreisel (Achse entlang Welt-Y statt Welt-X) bekam
in compute_kreisel_umlauf eine falsch orientierte Matching-Box (schmal in
Y, ueberbreit in X statt umgekehrt) - kreisel_bounds baute die Box bisher
IMMER mit dx=durchmesser+achs_len/dy=durchmesser, unabhaengig von der
tatsaechlichen Drehung. Echte anliegende Separatoren (weit entfernt in X,
aber auf der Kreiselachse in Y) fielen dadurch durch, waehrend entfernte,
zufaellig in der ueberbreiten X-Box liegende Separatoren faelschlich als
Kreisel-Nachbarn erkannt wurden (Mubea Kreisel 0002, 20m lang, Drehung 90)."""
def _langer_kreisel_90grad(self):
# Kreisel 0002 aus dem Mubea-Datensatz: x=0, y=4529.5, Drehung 90,
# ABSTAND=20020mm -> Achse verlaeuft entlang Welt-Y von y~4930 bis
# y~24950, nicht entlang Welt-X.
return _kreisel("0002", 0.0, 4529.5, 90.0, abstand=20020.0)
def test_separator_auf_der_gedrehten_achse_wird_erkannt(self):
"""Ein Separator, der WIRKLICH auf der (gedrehten) Kreiselachse liegt
(x nahe 0, y zwischen AN8- und SP8-Mitte), muss erkannt werden."""
k = self._langer_kreisel_90grad()
sep = _separator("9001", 0.0, 10000.0) # liegt mitten auf der Achse
items = [k, sep]
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=200.0,
durchmesser_mm=DURCHMESSER_MM)
keys = [p[0] for p in umlauf["0002"]]
assert "9001" in keys, "Separator auf der gedrehten Kreiselachse wurde nicht erkannt"
def test_separator_weit_neben_der_achse_wird_nicht_erkannt(self):
"""Ein Separator, der (wie im Mubea-Fall Separator 0065/0066) 958mm
seitlich der schmalen Kreiselbreite (durchmesser=800mm, Radius 400mm)
liegt, gehoert NICHT zum Kreisel - er darf nicht ueber eine faelschlich
breite X-Box eingefangen werden."""
k = self._langer_kreisel_90grad()
sep = _separator("0065", 958.0, 5100.0)
items = [k, sep]
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=200.0,
durchmesser_mm=DURCHMESSER_MM)
keys = [p[0] for p in umlauf["0002"]]
assert "0065" not in keys, (
"Separator 958mm neben der Kreiselachse wurde faelschlich als "
"Kreisel-Nachbar erkannt (rotationsabhaengiger BBox-Bug)")
class TestSeplisteNachbarnAufgeloest:
"""Regressionstest fuer einen dritten realen Mubea-Befund: die meisten
Separatoren blieben trotz sepliste-XDATA komplett ohne "Nachbarn".
Architektur (nach dem Umbau "Proxy liefert Zeilen, sepliste nur
Reihenfolge"): die in einer Kette verpackten Separatoren liegen bereits als
echte (Proxy-)Separator_SP-Items vor (der LISP-Export fuegt je verpacktem
Symbol eine temporaere Kopie ein). map_separator_kette_items erzeugt daher
KEINE neuen Zeilen mehr, sondern ordnet jedem sepliste-SEP das vorhandene
Separator-Item zu (Match ueber Wrapper-ID + INSERT-Punkt) und traegt es in
sep_kette_by_key ein; die AS/ES-Zeilen kommen weiter aus
build_strecke_schleus_items. resolve_umlauf_und_sepliste_nachbarn setzt
dann die Nachbarn als ECHTE TeileIds (nicht die rohen sep_kette-Schluessel,
frueherer Bug)."""
def _sepliste_kette(self):
"""Ein Kreisel + eine Gefaellestrecke mit sepliste AS(1) -> SEP(2) ->
ES(3) PLUS das bereits vorhandene Proxy-Separator-Item fuer den SEP
(wie es der freie-Separator-Pfad in process_blocks erzeugt: TeileId,
Merkmal "Zuordnung" = Wrapper-ID, INSERT-Punkt _x/_y an der sepliste-
SEP-Koordinate). "nachbarn" auf der Strecke simuliert das Ergebnis von
compute_neighbor_ids (BBox-Ueberschneidung Strecke <-> Kreisel) - die
Vorbedingung, unter der compute_strecke_kreisel_schleus die AS/ES-
Schleuselemente erzeugt."""
k = _kreisel("0001", 0.0, 0.0, 0.0, abstand=1000.0)
strecke = {
"teileart": "ILS 2.0 Gefaellestrecke",
"teileid": "0010",
"x": 5000.0, "y": 0.0, "z": 0.0,
"nachbarn": "0001",
"_bbox": {"cx": 5000.0, "cy": 0.0, "cz": 0.0, "dx": 8000.0, "dy": 300.0},
"k1": "", "k2": "",
"sepliste": [
{"typ": "AS", "lfdnr": 1, "x": 1500.0, "y": 0.0},
{"typ": "SEP", "lfdnr": 2, "x": 5000.0, "y": 0.0},
{"typ": "ES", "lfdnr": 3, "x": 8500.0, "y": 0.0},
],
}
# Proxy-Separator-Item (bereits vorhanden), Zuordnung = Wrapper-ID 0010,
# INSERT-Punkt an der sepliste-SEP-Koordinate (5000, 0).
proxy_sep = {
"teileart": "ILS 2.0 Separator",
"teileid": "0055",
"merkmale": {"Zuordnung": "0010"},
"_x": 5000.0, "_y": 0.0,
"_bbox": {"cx": 5000.0, "cy": 0.0, "cz": 0.0, "dx": 150.0, "dy": 210.0},
"nachbarn": "",
}
return [k, strecke, proxy_sep]
def test_keine_rohen_sep_kette_schluessel_in_nachbarn(self):
from export_neighbors import compute_sep_kette
items = self._sepliste_kette()
sep_kette = compute_sep_kette(items)
assert set(sep_kette) == {"0010#1", "0010#2", "0010#3"}
touch_switches = []
weiche_by_key = {}
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette, tolerance_mm=50.0, durchmesser_mm=DURCHMESSER_MM,
touch_switches=touch_switches)
sep_kette_by_key = {}
build_strecke_schleus_items(
items, None, 100, sep_kette=sep_kette,
sep_kette_by_key=sep_kette_by_key)
map_separator_kette_items(
items, sep_kette, sep_kette_by_key=sep_kette_by_key)
resolve_umlauf_und_sepliste_nachbarn(
items, kreisel_umlauf, sep_kette, sep_kette_by_key, weiche_by_key)
# AS/ES (neu erzeugt) + der bereits vorhandene Proxy-Separator.
sepliste_items = [it for it in items
if it.get("teileart") in
("ILS 2.0 Separator", "ILS Ausschleuselement", "ILS Einschleuselement")]
assert len(sepliste_items) == 3, "AS/SEP/ES nicht alle vorhanden"
for it in sepliste_items:
for nachbar in it["nachbarn"].split(","):
nachbar = nachbar.strip()
if not nachbar:
continue
assert "#" not in nachbar, (
f'{it["teileart"]} {it["teileid"]}: "Nachbarn" enthaelt einen '
f'unaufgeloesten sep_kette-Schluessel ({nachbar!r}) statt '
f'einer TeileId')
def test_sep_zeile_hat_as_und_es_als_nachbarn(self):
"""Der Proxy-Separator (SEP-Position) muss nach der Aufloesung sowohl die
TeileId der AS- als auch der ES-Zeile als Nachbarn tragen."""
from export_neighbors import compute_sep_kette
items = self._sepliste_kette()
sep_kette = compute_sep_kette(items)
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette, tolerance_mm=50.0, durchmesser_mm=DURCHMESSER_MM,
touch_switches=[])
sep_kette_by_key = {}
build_strecke_schleus_items(
items, None, 100, sep_kette=sep_kette,
sep_kette_by_key=sep_kette_by_key)
map_separator_kette_items(
items, sep_kette, sep_kette_by_key=sep_kette_by_key)
resolve_umlauf_und_sepliste_nachbarn(
items, kreisel_umlauf, sep_kette, sep_kette_by_key, {})
as_item = sep_kette_by_key["0010#1"]
sep_item = sep_kette_by_key["0010#2"]
es_item = sep_kette_by_key["0010#3"]
# Der SEP-Schluessel muss auf das vorhandene Proxy-Separator-Item zeigen.
assert sep_item["teileid"] == "0055", "sepliste-SEP nicht auf Proxy-Separator gemappt"
sep_nachbarn = {n.strip() for n in sep_item["nachbarn"].split(",") if n.strip()}
assert sep_nachbarn == {as_item["teileid"], es_item["teileid"]}, (
f'SEP-Zeile hat Nachbarn {sep_nachbarn!r}, erwartet '
f'{{{as_item["teileid"]!r}, {es_item["teileid"]!r}}}')
def test_trackids_enthaelt_as_sep_und_es(self):
"""Die "TrackIds"-Spalte einer Strecke muss AS- UND ES-Ende mit
enthalten, nicht nur die dazwischenliegenden Separatoren - sonst
fehlen genau die Kettenenden, die beim Kreisel (compute_kreisel_
umlauf) bereits Teil von dessen TrackIds sind."""
from export_neighbors import compute_sep_kette
items = self._sepliste_kette()
sep_kette = compute_sep_kette(items)
sep_kette_by_key = {}
build_strecke_schleus_items(
items, None, 100, sep_kette=sep_kette,
sep_kette_by_key=sep_kette_by_key)
map_separator_kette_items(
items, sep_kette, sep_kette_by_key=sep_kette_by_key)
write_separatorliste_merkmale(items, sep_kette_by_key)
strecke = next(it for it in items if it.get("teileid") == "0010")
as_id = sep_kette_by_key["0010#1"]["teileid"]
sep_id = sep_kette_by_key["0010#2"]["teileid"]
es_id = sep_kette_by_key["0010#3"]["teileid"]
assert sep_id == "0055", "sepliste-SEP nicht auf Proxy-Separator gemappt"
assert strecke["trackids"] == f"{as_id}, {sep_id}, {es_id}"
class TestKreiselUmlaufStartetBeiNullGrad:
"""compute_kreisel_umlauf muss die Umlauf-Liste je Kreisel bei Winkel
0 Grad (Kapsel-Achsrichtung, siehe _kreisel_capsule) beginnen lassen -
Voraussetzung fuer eine reproduzierbare Separatorliste (Kundenwunsch:
Start immer bei 0 Grad, nicht bei einem beliebigen Listenanfang)."""
def _kreisel_mit_vier_separatoren(self, drehrichtung):
# Kreisel entlang +X (Achse von x=400 bis x=1400, siehe _kreisel).
# Vier Separatoren rundherum bei ca. 10/100/190/280 Grad um die
# Kreiselmitte (900, 0) - keiner exakt auf 0 Grad, damit der Test
# echte Rotation der sortierten Liste beweist statt eines Zufalls-
# treffers bei Index 0.
k = _kreisel("0001", 0.0, 0.0, 0.0, abstand=1000.0)
k["attribs"]["DREHRICHTUNG"] = drehrichtung
# r=350mm < Kreiselradius 400mm, damit alle vier Punkte innerhalb der
# Kreisel-AABB liegen (axis x in [400,1400] +- radius 400 -> y in
# [-400,400]); r=700 (ausserhalb) faellt bei ~90/270 Grad aus der Box.
cx, cy, r = 900.0, 0.0, 350.0
import math
seps = []
for name, grad in (("S010", 10.0), ("S100", 100.0),
("S190", 190.0), ("S280", 280.0)):
rad = math.radians(grad)
seps.append(_separator(name, cx + r * math.cos(rad), cy + r * math.sin(rad)))
return [k] + seps
def test_start_bei_0_grad_uzs(self):
# UZS negiert den Rohwinkel vor der Normierung (siehe compute_kreisel_
# umlauf - UZS entspricht dem alten "absteigend nach rohem Winkel
# sortieren", das ueber die Negation+aufsteigend-Sortierung erreicht
# wird): 10/100/190/280 Grad werden dadurch zu (normiert) 350/260/
# 170/80 Grad - der kleinste (und damit Listenanfang) ist S280 (80
# Grad), NICHT S010 (350 Grad, liegt kurz VOR dem vollen Umlauf).
items = self._kreisel_mit_vier_separatoren("UZS")
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
keys = [p[0] for p in umlauf["0001"]]
assert keys[0] == "S280", f"Umlauf startet nicht bei 0 Grad (UZS): {keys}"
assert keys == ["S280", "S190", "S100", "S010"]
def test_start_bei_0_grad_guz(self):
# GUZ (kein Vorzeichenwechsel): der Rohwinkel steigt hier bereits
# monoton mit 10/100/190/280 Grad - der Umlauf startet beim kleinsten
# (S010) und laeuft aufsteigend weiter.
items = self._kreisel_mit_vier_separatoren("GUZ")
umlauf = compute_kreisel_umlauf(items, sep_kette={}, tolerance_mm=0.0,
durchmesser_mm=DURCHMESSER_MM)
keys = [p[0] for p in umlauf["0001"]]
assert keys[0] == "S010", f"Umlauf startet nicht bei 0 Grad (GUZ): {keys}"
assert keys == ["S010", "S100", "S190", "S280"]
class TestKreiselTrackidsSpalte:
"""write_kreisel_separatorliste_merkmale schreibt die bei 0 Grad
beginnende Umlaufreihenfolge (Separatoren, AS/ES-Enden, Kreisel-Kreisel-
Weichen) in die eigene CSV-Spalte "TrackIds" (item["trackids"]) auf den
Kreisel selbst (analog zum gleichnamigen Feld bei VF_n/GF_n, siehe
write_separatorliste_merkmale)."""
def test_freie_separatoren_in_umlaufreihenfolge(self):
k = _kreisel("0001", 0.0, 0.0, 0.0, abstand=1000.0)
k["merkmale"] = {}
sep_a = _separator("0020", 300.0, 300.0) # ~63 Grad
sep_b = _separator("0021", 1400.0, -50.0) # nahe 0 Grad (Ende Achse)
items = [k, sep_a, sep_b]
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette={}, tolerance_mm=200.0, durchmesser_mm=DURCHMESSER_MM)
write_kreisel_separatorliste_merkmale(
items, kreisel_umlauf, sep_kette_by_key={}, weiche_by_key={})
kreisel_item = items[0]
assert kreisel_item["trackids"] == "0021, 0020"
def test_kreisel_ohne_umlauf_punkte_bekommt_keine_trackids(self):
k = _kreisel("0001", 0.0, 0.0, 0.0, abstand=1000.0)
k["merkmale"] = {}
items = [k]
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette={}, tolerance_mm=0.0, durchmesser_mm=DURCHMESSER_MM)
write_kreisel_separatorliste_merkmale(
items, kreisel_umlauf, sep_kette_by_key={}, weiche_by_key={})
assert "trackids" not in items[0]
def test_btmt_beladung_und_entladung_zaehlen_als_freie_punkte(self):
"""BTMT Beladung/Entladung sind Stationen AUF der Bahn/Track wie ein
Separator - sie muessen im Kreisel-Umlauf UND in der TrackIds-Spalte
des Kreisels auftauchen (Kundenwunsch), nicht nur echte Separatoren."""
k = _kreisel("0001", 0.0, 0.0, 0.0, abstand=1000.0)
k["merkmale"] = {}
sep = _separator("0020", 300.0, 300.0)
bel = _btmt("ILS 2.0 BTMT Beladung", "0030", 900.0, 400.0)
ent = _btmt("ILS 2.0 BTMT Entladung", "0031", 1400.0, -50.0)
items = [k, sep, bel, ent]
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette={}, tolerance_mm=200.0, durchmesser_mm=DURCHMESSER_MM)
keys = [p[0] for p in kreisel_umlauf["0001"]]
assert set(keys) == {"0020", "0030", "0031"}, (
f"BTMT Beladung/Entladung fehlen im Kreisel-Umlauf: {keys}")
write_kreisel_separatorliste_merkmale(
items, kreisel_umlauf, sep_kette_by_key={}, weiche_by_key={})
assert k["trackids"] == "0031, 0020, 0030"