Sepliste-XDATA fuer Separator/AS/ES-Reihenfolge + Unit-Tests, makunbound-Fix

Sammelt beim Bau von VF_n/GF_n-Ketten die Baureihenfolge der Separator-/
AS-/ES-Sub-Bloecke vor dem Block-Sweep und schreibt sie als XDATA auf den
fertigen Wrapper (ssg_ks_insert.lsp); export.lsp liest sie fuer das neue
"sepliste"-Feld im CSV-Export, export_neighbors.py nutzt es als Fallback
zur BBox-Kollisionspruefung. Dazu Unit-Tests fuer die reinen Serialisierungs-
funktionen (Chunking, Roundtrip) in test_unit.lsp.

Ausserdem: makunbound (existiert nicht in BricsCAD-AutoLISP) aus
test_run_all.lsp entfernt - verursachte Laufzeitfehler bei
SSG_RUN_ALL_TESTS_EXPORT/_OFFEN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 20:36:42 +02:00
parent f3500f7d2a
commit 3698c8982c
9 changed files with 529 additions and 22 deletions
+139 -17
View File
@@ -122,6 +122,8 @@ from export_neighbors import (
compute_scanner_nearest_separator,
compute_kreisel_touch_switches,
compute_strecke_kreisel_schleus,
compute_sep_kette,
compute_kreisel_umlauf,
SENSOR_TEILEARTEN,
WEICHE_TEILEART,
GF_VF_TEILEARTEN,
@@ -529,28 +531,50 @@ def build_kreisel_weiche_items(items, planquadrat_cfg, start_elem_nr, dbg=None):
return elem_nr
def build_strecke_schleus_items(items, planquadrat_cfg, start_elem_nr, dbg=None):
def _strecke_typ_feld(teileart):
"""Merkmals-Feldname fuer die Ketten-Zugehoerigkeit einer AS/ES-/Separator-
Zeile, je Kettentyp unterschiedlich (User-Vorgabe): VF_n ("ILS 2.0
Strecke") -> "Förderer", GF_n ("ILS 2.0 Gefaellestrecke") -> "Strecke"
(wie bisher)."""
return "Strecke" if teileart == "ILS 2.0 Gefaellestrecke" else "Förderer"
def build_strecke_schleus_items(items, planquadrat_cfg, start_elem_nr, sep_kette=None, dbg=None):
"""Haengt fuer jedes Strecken-Ende mit anschliessendem Kreisel eine
"ILS Ausschleuselement"- (Anfang) bzw. "ILS Einschleuselement"-Zeile
(Ende) an items an (siehe export_neighbors.compute_strecke_kreisel_schleus).
(Ende) an items an (siehe export_neighbors.compute_strecke_kreisel_schleus
fuer Position/Boundingbox - reine Geometrie, unveraendert).
Muss NACH build_kreisel_weiche_items laufen, damit die TeileId-Zaehlung
(max_numeric_teileid) auch die bereits erzeugten Weichen einschliesst.
Position = Kollisionsposition Kreisel<->Strecke; Nachbarn = Kreisel-ID +
Strecken-ID; Anzahl 1; K1-K4/Insertpoint leer; Boundingbox = die statische,
an der Kreiselachse ausgerichtete Default-Box.
Anzahl 1; K1-K4/Insertpoint leer; Boundingbox = die statische, an der
Kreiselachse ausgerichtete Default-Box.
sep_kette = Rueckgabe von export_neighbors.compute_sep_kette (keys
"<strecke_id>#<lfdnr>" -> Eintrag mit typ/vorgaenger/nachfolger). Traegt
die zugehoerige Strecke eine "sepliste" (neue LISP-XDATA, siehe
ssg-sepliste-xdata-schreiben), enthaelt "Nachbarn" NUR noch die IDs des
ketteninternen Vorgaengers/Nachfolgers (Separator oder je anderem
Kreisel-Ende) statt Kreisel-ID + Strecken-ID; die Kreisel-/Foerderer-
Zugehoerigkeit steht dann stattdessen in den Merkmalen ("Kreisel" +
"Förderer"/"Strecke" je Typ, analog zum "Zuordnung"-Feld der Separatoren).
Fehlt die sepliste (Altbestand-Wrapper ohne die neue XDATA), bleibt der
bisherige Nachbarn-Inhalt [kreisel_id, strecke_id] unveraendert erhalten.
Rueckgabe: fortgeschriebene Elementnummer.
"""
if sep_kette is None:
sep_kette = {}
schleus, warnings = compute_strecke_kreisel_schleus(
items, durchmesser_mm=KREISEL_DURCHMESSER_MM,
element_box_mm=ELEMENT_BOX_MM, dbg=dbg)
strecke_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") in GF_VF_TEILEARTEN}
# Warnungen (>2 Kreisel-BBox-Beruehrungen je Strecke) auf die Strecken-Zeile
# schreiben (Spalte "Warnungen") und in die Konsole ausgeben.
if warnings:
strecke_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") in GF_VF_TEILEARTEN}
for sid, text in warnings.items():
if sid in strecke_by_id:
strecke_by_id[sid]["warnung"] = text
@@ -567,6 +591,26 @@ def build_strecke_schleus_items(items, planquadrat_cfg, start_elem_nr, dbg=None)
zaehler[teileart] = zaehler.get(teileart, 0) + 1
planquadrat = (compute_planquadrat(sp["x"], sp["y"], planquadrat_cfg)
if planquadrat_cfg is not None else "")
strecke = strecke_by_id.get(sp["strecke_id"])
sepliste = strecke.get("sepliste") if strecke else None
merkmale = {"Erzeugt aus": "Kreisel-Strecke-Kollision"}
if sepliste:
# Neue XDATA vorhanden: Nachbarn nur noch aus der Ketten-internen
# Vorgaenger/Nachfolger-Beziehung (AS/ES ist immer das erste bzw.
# letzte Sepliste-Element seiner Strecke).
lfdnr = sepliste[0]["lfdnr"] if sp["ende_label"] == "Anfang" else sepliste[-1]["lfdnr"]
key = f"{sp['strecke_id']}#{lfdnr}"
eintrag = sep_kette.get(key, {})
nachbarn = [n for n in (eintrag.get("vorgaenger"), eintrag.get("nachfolger")) if n]
merkmale["Kreisel"] = sp["kreisel_id"]
merkmale[_strecke_typ_feld(strecke.get("teileart", ""))] = sp["strecke_id"]
else:
nachbarn = [sp["kreisel_id"], sp["strecke_id"]]
merkmale["Kreisel"] = sp["kreisel_id"]
merkmale["Strecke"] = sp["strecke_id"]
merkmale["Streckenende"] = sp["ende_label"]
items.append({
"nr": elem_nr,
"teileart": teileart,
@@ -577,14 +621,64 @@ def build_strecke_schleus_items(items, planquadrat_cfg, start_elem_nr, dbg=None)
"boundingbox": f'{sp["dx"]:.2f}, {sp["dy"]:.2f}, {sp["dz"]:.2f}',
"insertpoint": "",
"k1": "", "k2": "", "k3": "", "k4": "",
"nachbarn": ", ".join([sp["kreisel_id"], sp["strecke_id"]]),
"nachbarn": ", ".join(nachbarn),
"fehler": "",
"merkmale": {
"Erzeugt aus": "Kreisel-Strecke-Kollision",
"Kreisel": sp["kreisel_id"],
"Strecke": sp["strecke_id"],
"Streckenende": sp["ende_label"],
},
"merkmale": merkmale,
})
return elem_nr
def build_separator_kette_items(items, planquadrat_cfg, start_elem_nr, sep_kette, dbg=None):
"""Haengt fuer jeden in einer VF_n/GF_n-Kette eingebetteten Separator
("Staustrecke_Separator_SP_300_mm", ueber die neue "sepliste"-XDATA
bekannt - siehe ssg-sepliste-xdata-schreiben/compute_sep_kette) eine
eigene "ILS 2.0 Separator"-Zeile an. Bisher wurden diese internen
Ketten-Separatoren nur gezaehlt (ANZAHL_SEPARATOR), nicht einzeln
exportiert - anders als frei platzierte Separator_SP-Symbole.
Nachbarn = ketteninterner Vorgaenger/Nachfolger (Separator oder AS/ES),
analog zur AS/ES-Nachbarn-Spalte in build_strecke_schleus_items.
Merkmale bekommen "Zuordnung" = Strecken-TeileId (wie bei frei
platzierten Separatoren ueber compute_sensor_zuordnung).
Rueckgabe: fortgeschriebene Elementnummer.
"""
strecke_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") in GF_VF_TEILEARTEN}
naechste_id = max_numeric_teileid(items)
elem_nr = start_elem_nr
zaehler = 0
for key, eintrag in sep_kette.items():
if eintrag.get("typ") != "SEP":
continue
elem_nr += 1
naechste_id += 1
zaehler += 1
teileid = f"{naechste_id:04d}"
planquadrat = (compute_planquadrat(eintrag["x"], eintrag["y"], planquadrat_cfg)
if planquadrat_cfg is not None else "")
nachbarn = [n for n in (eintrag.get("vorgaenger"), eintrag.get("nachfolger")) if n]
strecke = strecke_by_id.get(eintrag["strecke_id"])
merkmale = {
"Erzeugt aus": "VF/GF-Sepliste",
"Zuordnung": eintrag["strecke_id"],
}
if strecke:
merkmale[_strecke_typ_feld(strecke.get("teileart", ""))] = eintrag["strecke_id"]
items.append({
"nr": elem_nr,
"teileart": "ILS 2.0 Separator",
"teileid": teileid,
"bezeichnung": f"Separator :{zaehler}",
"planquadrat": planquadrat,
"position": f'{eintrag["x"]:.2f}, {eintrag["y"]:.2f}, 0.00',
"boundingbox": "",
"insertpoint": "",
"k1": "", "k2": "", "k3": "", "k4": "",
"nachbarn": ", ".join(nachbarn),
"fehler": "",
"merkmale": merkmale,
})
return elem_nr
@@ -921,14 +1015,42 @@ def process_blocks(blocks, lookup, dbg=None):
if item.get("teileart", "") == "ILS 2.0 Scanner" and separator_id:
item["merkmale"]["Naechster Separator"] = separator_id
# Ketteninterne Separator-/AS-/ES-Reihenfolge aus der neuen "sepliste"-
# XDATA (siehe ssg-sepliste-xdata-schreiben) - leeres dict, wenn keine
# Strecke eine sepliste traegt (reiner Altbestand ohne neue XDATA).
sep_kette = compute_sep_kette(items, dbg=dbg)
# Umlauf-Reihenfolge an jedem Kreisel: freie Separatoren (per Zuordnung
# oben bereits einem Kreisel zugeordnet) UND die AS/ES-Enden andockender
# Ketten (aus sep_kette), sortiert nach Winkel um die Kreiselachse (siehe
# compute_kreisel_umlauf - DREHRICHTUNG-Attribut bestimmt den Umlaufsinn).
# Freie Separator-Zeilen bekommen ihre "Nachbarn"-Spalte hier aus dem
# Umlauf statt leer zu bleiben; die AS/ES-Nachbarn selbst setzt weiterhin
# build_strecke_schleus_items (ketteninterner Vorgaenger/Nachfolger).
kreisel_umlauf = compute_kreisel_umlauf(
items, sep_kette, NEIGHBOR_TOLERANCE_MM,
durchmesser_mm=KREISEL_DURCHMESSER_MM, dbg=dbg)
if kreisel_umlauf:
separator_by_id = {it.get("teileid", ""): it for it in items
if it.get("teileart", "") == "ILS 2.0 Separator"}
for punkte in kreisel_umlauf.values():
for punkt_key, vorgaenger, nachfolger in punkte:
item = separator_by_id.get(punkt_key)
if item is None:
continue
nachbarn = [n for n in (vorgaenger, nachfolger) if n]
item["nachbarn"] = ", ".join(nachbarn)
# Nach der Kollisions-/Nachbarschaftserkennung: an jedem Kreisel-
# Beruehrpunkt eine ILS-Weiche anhaengen (eigene Zeilen am Listenende -
# sie nehmen an der Nachbarschaftserkennung selbst nicht teil, ihre
# Nachbarn sind fest die beiden beruehrenden Kreisel). Danach an jedem
# Strecken-Ende mit anschliessendem Kreisel ein Aus-/Einschleuselement -
# die TeileId-Zaehlung laeuft ueber beide hinweg weiter (Weichen zuerst).
# Strecken-Ende mit anschliessendem Kreisel ein Aus-/Einschleuselement,
# gefolgt von je einer Zeile pro in einer Kette eingebettetem Separator -
# die TeileId-Zaehlung laeuft ueber alle drei hinweg weiter.
elem_nr = build_kreisel_weiche_items(items, planquadrat_cfg, elem_nr, dbg=dbg)
build_strecke_schleus_items(items, planquadrat_cfg, elem_nr, dbg=dbg)
elem_nr = build_strecke_schleus_items(items, planquadrat_cfg, elem_nr, sep_kette=sep_kette, dbg=dbg)
build_separator_kette_items(items, planquadrat_cfg, elem_nr, sep_kette, dbg=dbg)
# Nach dem Sammelheimen aller Zeilen: sortiert nach TeileId ausgeben, damit
# Elementnummer (erste Spalte) und TeileId nach Moeglichkeit uebereinstimmen
+153
View File
@@ -1266,3 +1266,156 @@ def compute_strecke_kreisel_schleus(items, durchmesser_mm=800.0,
f"Strecken mit Warnung (>2 Kreisel): {len(warnings)}")
return result, warnings
# ---------------------------------------------------------------------------
# Sepliste (LISP-XDATA, siehe ssg-sepliste-xdata-schreiben) -> Nachbarschaft
# ---------------------------------------------------------------------------
#
# "sepliste" (item["sepliste"], von csv:block-to-json aus SSG_VF_SEP/
# SSG_GF_SEP-XDATA gelesen) ist die beim Bau in Baureihenfolge gesammelte
# Liste der Separator-/AS-/ES-Sub-Bloecke EINER VF_n/GF_n-Kette:
# [{"typ": "AS"|"SEP"|"ES", "lfdnr": int, "x": float, "y": float}, ...].
# Ersetzt das bisherige Raten der Ketten-internen Reihenfolge (die Linie
# selbst wird nicht exportiert) durch die tatsaechliche Bau-Reihenfolge.
def compute_sep_kette(items, dbg=None):
"""Baut je VF_n/GF_n-Item MIT "sepliste" die ketteninterne Vorgaenger-/
Nachfolger-Beziehung AS -> Sep1 -> ... -> SepN -> ES.
Jeder Sepliste-Eintrag bekommt eine (innerhalb dieser Kette eindeutige)
Schluessel-ID "<strecke_teileid>#<lfdnr>" - das ist die ID, mit der
build_strecke_schleus_items/build_separator_kette_items die spaeteren
globalen TeileIds verknuepft (siehe export_csv.py). Vorgaenger/Nachfolger
sind schlicht der vorherige/naechste Eintrag in der Sepliste (die bereits
in Baureihenfolge vorliegt) - kein Rateverfahren noetig.
Rueckgabe: dict {key: {"typ", "x", "y", "strecke_id", "vorgaenger",
"nachfolger"}} - vorgaenger/nachfolger sind wieder solche keys, oder None
am jeweiligen Kettenende (Verkettung mit dem Kreisel-Umlauf uebernimmt
compute_kreisel_umlauf).
"""
result = {}
for item in items:
if item.get("teileart", "") not in GF_VF_TEILEARTEN:
continue
sepliste = item.get("sepliste")
if not sepliste:
continue
sid = item.get("teileid", "")
keys = [f"{sid}#{e.get('lfdnr')}" for e in sepliste]
for i, entry in enumerate(sepliste):
key = keys[i]
result[key] = {
"typ": entry.get("typ", ""),
"x": entry.get("x", 0.0),
"y": entry.get("y", 0.0),
"strecke_id": sid,
"vorgaenger": keys[i - 1] if i > 0 else None,
"nachfolger": keys[i + 1] if i < len(keys) - 1 else None,
}
if dbg:
kette_txt = " -> ".join(f"{e.get('typ')}:{e.get('lfdnr')}" for e in sepliste)
dbg(f" Strecke {sid}: Sepliste-Kette {kette_txt}")
return result
def compute_kreisel_umlauf(items, sep_kette, tolerance_mm, durchmesser_mm=800.0, 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) UND die AS/ES-Enden
aller andockenden VF_n/GF_n-Ketten (aus sep_kette, per BBox-Naehe wie
compute_strecke_kreisel_schleus zugeordnet) - in eine geschlossene
Umlauf-Reihenfolge (zirkulaer, siehe DREHRICHTUNG-Attribut).
Winkel wird um den Kapsel-Mittelpunkt (Mitte AN8-/SP8-Achse, siehe
_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.
items = dieselbe Liste wie bei compute_neighbor_ids/compute_sensor_
zuordnung. sep_kette = Rueckgabe von compute_sep_kette (fuer die AS/ES-
Punkte: x/y + strecke_id).
Rueckgabe: dict {kreisel_teileid: [(punkt_key, vorgaenger_key,
nachfolger_key), ...]} in Umlaufreihenfolge - punkt_key ist entweder eine
Separator-TeileId (freie Separatoren) oder ein sep_kette-Schluessel
(AS/ES-Enden). Kreisel ohne zugeordnete Punkte liefern eine leere Liste.
"""
half_tol = tolerance_mm / 2.0
kreisel = {}
for item in items:
if item.get("teileart", "") == KREISEL_SPLIT_TEILEART:
kreisel[item.get("teileid", "")] = (
_kreisel_capsule(item, durchmesser_mm),
(item.get("attribs", {}) or {}).get("DREHRICHTUNG", "UZS"))
# Freie Separatoren -> ihre Kreiselhaelfte (bereits per BBox-Ueberschneidung
# bestimmbar, wie compute_sensor_zuordnung es fuer die "Zuordnung"-Spalte
# tut - hier direkt gegen die volle Kreisel-BBox statt der Haelften, weil
# nur "gehoert zu diesem Kreisel" gebraucht wird, nicht links/rechts).
kreisel_bounds = {}
for tid, (cap, _dr) in kreisel.items():
acx = (cap["a"][0] + cap["b"][0]) / 2.0
acy = (cap["a"][1] + cap["b"][1]) / 2.0
achs_len = math.hypot(cap["b"][0] - cap["a"][0], cap["b"][1] - cap["a"][1])
kreisel_bounds[tid] = _bounds(
{"cx": acx, "cy": acy, "dx": durchmesser_mm + achs_len, "dy": durchmesser_mm},
half_tol)
punkte_je_kreisel = {tid: [] for tid in kreisel}
for item in items:
if item.get("teileart", "") != "ILS 2.0 Separator":
continue
bbox = item.get("_bbox")
if not bbox:
continue
bounds = _bounds(bbox, half_tol)
for tid, kb in kreisel_bounds.items():
if _overlaps(bounds, kb):
punkte_je_kreisel[tid].append(
(item.get("teileid", ""), bbox.get("cx", 0.0), bbox.get("cy", 0.0)))
break
# AS/ES-Enden jeder Kette mit sepliste: naechstgelegener Kreisel (wie
# compute_strecke_kreisel_schleus._nearest), aber direkt aus sep_kette
# statt ueber die grobe BBox-Nachbarschaft der Strecke.
for key, eintrag in sep_kette.items():
if eintrag["typ"] not in ("AS", "ES"):
continue
p = (eintrag["x"], eintrag["y"])
best_tid, best_d = None, None
for tid, (cap, _dr) in kreisel.items():
q = _closest_pt_on_segment(cap["a"], cap["b"], p)
d = math.hypot(q[0] - p[0], q[1] - p[1])
if best_d is None or d < best_d:
best_tid, best_d = tid, d
if best_tid is not None:
punkte_je_kreisel[best_tid].append((key, eintrag["x"], eintrag["y"]))
result = {}
for tid, punkte in punkte_je_kreisel.items():
cap, drehrichtung = kreisel[tid]
acx = (cap["a"][0] + cap["b"][0]) / 2.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
reverse = (drehrichtung or "UZS").strip().upper() != "GUZ"
sortiert = sorted(punkte, key=lambda p: _winkel(p), reverse=reverse)
n = len(sortiert)
umlauf = []
for i, p in enumerate(sortiert):
vorgaenger = sortiert[(i - 1) % n][0] if n > 1 else None
nachfolger = sortiert[(i + 1) % n][0] if n > 1 else None
umlauf.append((p[0], vorgaenger, nachfolger))
result[tid] = umlauf
if dbg:
dbg(f" Kreisel {tid} ({drehrichtung}): Umlauf "
f"{' -> '.join(p[0] for p in sortiert)}")
return result