From 2bb00bb48f478c375dc2f9cb5dcf0c25d023b42a Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Wed, 28 Oct 2026 17:22:36 +0100 Subject: [PATCH] fix(export): freie Separatoren nach Lage in die Kette ihrer Strecke einsortieren Eigenstaendige Separatoren, deren Zuordnung eine VF/GF nennt, die aber nicht beim Bau der Strecke entstanden, fehlten in deren sepliste und damit in TrackIds und Nachbarn (Mubea VF_3: 0037/0038). Sie werden jetzt beim Export nach Lage zwischen AS und ES aufgenommen - nur im Speicher des Laufs, die sepliste-XDATA bleibt Bauprotokoll (Lisp paart sie per Anzahl je Typ mit den Bausteinen und fuehrt reservierte AS/ES-IDs unter der lfdnr). Co-Authored-By: Claude Opus 5.5 --- lib/export_csv.py | 7 + lib/export_neighbors.py | 189 +++++++++++++++++++++++++ tests/test_export_freie_separatoren.py | 168 ++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 tests/test_export_freie_separatoren.py diff --git a/lib/export_csv.py b/lib/export_csv.py index 1abee2e..9f5aad7 100644 --- a/lib/export_csv.py +++ b/lib/export_csv.py @@ -42,6 +42,7 @@ from export_neighbors import ( compute_kreisel_touch_switches, lisp_touch_switches, load_lisp_kreisel_weichen, + einsortieren_freie_separatoren, compute_sep_kette, compute_kreisel_umlauf, SENSOR_TEILEARTEN, @@ -1543,6 +1544,12 @@ def process_blocks(blocks, lookup, dbg=None, lisp_weichen=None): if item.get("teileart", "") == "ILS 2.0 Scanner" and separator_id: item["merkmale"]["Naechster Separator"] = separator_id + # Freie Separatoren, deren Zuordnung eine VF_n/GF_n nennt, die aber nicht + # beim Bau der Strecke entstanden sind (fehlen darum in ihrer sepliste), + # nach Lage in die Kette aufnehmen - nur fuer diesen Lauf, die XDATA am + # Block bleibt unveraendert (siehe einsortieren_freie_separatoren). + einsortieren_freie_separatoren(items, NEIGHBOR_TOLERANCE_MM, dbg=dbg) + # 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). diff --git a/lib/export_neighbors.py b/lib/export_neighbors.py index aa78ad1..03ae7bb 100644 --- a/lib/export_neighbors.py +++ b/lib/export_neighbors.py @@ -1255,6 +1255,190 @@ def _asel_collision_boxes(item, half_tol, box_dims): # Ersetzt das bisherige Raten der Ketten-internen Reihenfolge (die Linie # selbst wird nicht exportiert) durch die tatsaechliche Bau-Reihenfolge. +def sepliste_einsortier_positionen(kette_xy, kandidaten): + """Reiner Rechenkern fuer einsortieren_freie_separatoren: wohin gehoert + jeder Kandidat in eine vorhandene Kette? + + kette_xy = [(x, y), ...] Lage der Kettenglieder in Kettenfolge (AS ... + ES), mindestens zwei Punkte. Sie bilden einen Polygonzug - so + folgt die Zuordnung auch einer Kette, die um eine Kurve laeuft, + statt nur einer Geraden AS->ES. + kandidaten = [(key, x, y), ...] + + Jeder Kandidat wird dem naechstgelegenen Abschnitt (Glied i -> i+1) + zugeordnet. Liegt er dabei VOR dem ersten oder HINTER dem letzten Glied, + wird er verworfen - eingefuegt wird nur zwischen AS und ES. + + Rueckgabe (einfuegen, verworfen): einfuegen = {i: [key, ...]} - nach + Glied i einzufuegen, untereinander in Kettenrichtung sortiert; + verworfen = [key, ...]. + """ + abschnitte = [] + for i in range(len(kette_xy) - 1): + (ax, ay), (bx, by) = kette_xy[i], kette_xy[i + 1] + dx, dy = bx - ax, by - ay + l2 = dx * dx + dy * dy + if l2 > 1e-12: + abschnitte.append((i, ax, ay, dx, dy, l2)) + letzter = len(kette_xy) - 2 + + einfuegen = {} + verworfen = [] + for key, x, y in kandidaten: + best = None + for i, ax, ay, dx, dy, l2 in abschnitte: + t = ((x - ax) * dx + (y - ay) * dy) / l2 + tc = min(max(t, 0.0), 1.0) + d = math.hypot(x - (ax + tc * dx), y - (ay + tc * dy)) + if best is None or d < best[0] - 1e-9: + best = (d, i, t) + if best is None: + verworfen.append(key) + continue + _d, i, t = best + if (i == 0 and t <= 0.0) or (i == letzter and t >= 1.0): + verworfen.append(key) + continue + einfuegen.setdefault(i, []).append((min(max(t, 0.0), 1.0), key)) + + return ({i: [key for _t, key in sorted(liste)] for i, liste in einfuegen.items()}, + verworfen) + + +def _kettenglied_xy(entry, item, strecke): + """Lage eines sepliste-Glieds fuer den Polygonzug: AS/ES ueber K1/K2 der + Strecke (Ein-/Auslauf - der ZZ_ASES_MARKER sitzt auf dem Einfuegepunkt + des Elements und kann bis ~600 mm seitlich davon liegen), sonst bzw. + ohne K1/K2 der Bounding-Box-Mittelpunkt des vertretenden Items.""" + typ = entry.get("typ", "") + if typ in ("AS", "ES"): + k1_xy, k2_xy = _asel_kos_xy(strecke) + pt = k1_xy if typ == "AS" else k2_xy + if pt is not None: + return pt + bbox = (item or {}).get("_bbox") + if not bbox: + return None + return (bbox.get("cx", 0.0), bbox.get("cy", 0.0)) + + +def einsortieren_freie_separatoren(items, tolerance_mm, dbg=None): + """Nimmt freie Separatoren in die sepliste ihrer VF_n/GF_n-Strecke auf. + + Die sepliste entsteht beim BAU der Strecke (ssg-sepliste-sammeln) und + kennt nur die Separatoren, die dabei erzeugt wurden. Ein eigenstaendiger + Separator_SP-Block, der spaeter (oder aus einer frueheren Fassung der + Strecke) auf ihr liegt, fehlt darin - obwohl seine ZUORDNUNG + (cs-zuordnung-lauf) die Strecke nennt. Ohne diesen Schritt fehlt er in + TrackIds und Nachbarn (realer Fall Mubea VF_3: 0037/0038 zwischen 0062 + und 0063). + + Aufgenommen wird ein Separator nur, wenn + - seine Zuordnung die Strecke nennt, + - er in keiner sepliste steht, + - sein Mittelpunkt in der Bounding-Box der Strecke liegt (+ tolerance_mm/2), + - er laengs der Kette zwischen AS und ES liegt + (sepliste_einsortier_positionen). + + Bewusst NUR im Speicher dieses Exportlaufs: die sepliste-XDATA am Block + bleibt das Bauprotokoll. Lisp ordnet ihre Eintraege den eingebauten + Bausteinen ueber die Anzahl je Typ zu (csv:sepliste-kette-paare) und + fuehrt reservierte AS/ES-IDs unter der lfdnr - ein in die XDATA + eingefuegter fremder Separator verschoebe beides. Ausserdem schreibt jeder + Neuaufbau der Strecke die XDATA neu; hier wird bei jedem Export frisch + aus dem aktuellen Zeichnungsstand gerechnet. + + Aendert items in-place: item["sepliste"] der Strecke bekommt neue + SEP-Eintraege (lfdnr "frei", "id" = TeileId des Separators, + "frei": True); der Separator bekommt "_in_strecke_kette" (Strecken- + TeileId, damit compute_kreisel_umlauf ihn nicht zusaetzlich einem + Kreisel zuschlaegt) und einen Hinweis in "warnung". + Muss NACH der Separator-Zuordnung und VOR compute_sep_kette laufen. + """ + half_tol = tolerance_mm / 2.0 + by_teileid = {it.get("teileid", ""): it for it in items if it.get("teileid", "")} + in_ketten = set() + for it in items: + if it.get("teileart", "") in GF_VF_TEILEARTEN: + for e in it.get("sepliste") or []: + eid = str(e.get("id", "")).strip() + if eid: + in_ketten.add(eid) + + if dbg: + dbg("=== einsortieren_freie_separatoren ===") + + for strecke in items: + if strecke.get("teileart", "") not in GF_VF_TEILEARTEN: + continue + sepliste = strecke.get("sepliste") + sbbox = strecke.get("_bbox") + if not sepliste or not sbbox: + continue + sid = strecke.get("teileid", "") + sbounds = _bounds(sbbox, half_tol) + + kandidaten = [] + for it in items: + if it.get("teileart", "") != "ILS 2.0 Separator": + continue + tid = it.get("teileid", "") + if not tid or tid in in_ketten: + continue + zuordnung = ((it.get("merkmale") or {}).get("Zuordnung") + or it.get("_zuordnung_attr") or "").strip() + if zuordnung != sid: + continue + bbox = it.get("_bbox") + if not bbox: + continue + cx, cy = bbox.get("cx", 0.0), bbox.get("cy", 0.0) + if not (sbounds[0] <= cx <= sbounds[1] and sbounds[2] <= cy <= sbounds[3]): + if dbg: + dbg(f" Strecke {sid}: Separator {tid} ausserhalb der Strecken-BBox " + f"- nicht einsortiert") + continue + kandidaten.append((tid, cx, cy)) + if not kandidaten: + continue + + kette_xy = [] + for e in sepliste: + pt = _kettenglied_xy(e, by_teileid.get(str(e.get("id", "")).strip()), strecke) + if pt is None: + break + kette_xy.append(pt) + if len(kette_xy) != len(sepliste) or len(kette_xy) < 2: + if dbg: + dbg(f" Strecke {sid}: Kettenglieder nicht vollstaendig lokalisierbar " + f"- freie Separatoren {[k[0] for k in kandidaten]} nicht einsortiert") + continue + + einfuegen, verworfen = sepliste_einsortier_positionen(kette_xy, kandidaten) + if dbg and verworfen: + dbg(f" Strecke {sid}: {verworfen} liegen nicht zwischen AS und ES " + f"- nicht einsortiert") + if not einfuegen: + continue + + neu = [] + for i, entry in enumerate(sepliste): + neu.append(entry) + for tid in einfuegen.get(i, []): + neu.append({"typ": "SEP", "lfdnr": f"frei{tid}", "id": tid, "frei": True}) + sep = by_teileid[tid] + sep["_in_strecke_kette"] = sid + hinweis = (f"nicht im Bau von {sid} erzeugt - per Lage in dessen " + f"Kette einsortiert") + bisherige = sep.get("warnung", "") + sep["warnung"] = f"{bisherige}; {hinweis}" if bisherige else hinweis + in_ketten.add(tid) + strecke["sepliste"] = neu + if dbg: + dbg(f" Strecke {sid}: Kette jetzt " + f"{' -> '.join(str(e.get('id', '')) for e in neu)}") + + 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. @@ -1392,6 +1576,11 @@ def compute_kreisel_umlauf(items, tolerance_mm, durchmesser_mm=800.0, for item in items: if item.get("teileart", "") not in KREISEL_UMLAUF_TEILEARTEN: continue + # Freier Separator, den einsortieren_freie_separatoren in die Kette + # einer Strecke aufgenommen hat - gehoert nicht zusaetzlich auf einen + # Kreisel, auch wenn seine Box einen beruehrt. + if item.get("_in_strecke_kette"): + continue teileid = item.get("teileid", "") bbox = item.get("_bbox") if not bbox: diff --git a/tests/test_export_freie_separatoren.py b/tests/test_export_freie_separatoren.py new file mode 100644 index 0000000..3396fa1 --- /dev/null +++ b/tests/test_export_freie_separatoren.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_export_freie_separatoren.py - freie Separatoren in der Kette einer Strecke. +Laeuft komplett ohne BricsCAD (reine Python-Logik, siehe lib/export_neighbors.py). + +Szenario nach einem realen Mubea-Befund (VF_3 = 0015): die sepliste aus dem +Bau kennt nur AS 0061, SEP 0062, SEP 0063, ES 0064. Die eigenstaendigen +Separatoren 0037/0038 liegen auf der Strecke, ihre Zuordnung nennt 0015 - sie +fehlten aber in TrackIds und Nachbarn. einsortieren_freie_separatoren nimmt +sie nach Lage auf. + +Alle Daten sind hier fest eingetragen (Stand des Mubea-Exports 2026-10-28) und +werden aus keiner Datei gelesen - aendert sich die Mubea-Zeichnung, bleibt +dieser Test unberuehrt. Er prueft den Algorithmus, nicht die Szene. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) + +from export_neighbors import ( # noqa: E402 + compute_sep_kette, + einsortieren_freie_separatoren, + sepliste_einsortier_positionen, +) +from export_csv import ( # noqa: E402 + map_sepliste_kette_items, + resolve_umlauf_und_sepliste_nachbarn, + write_separatorliste_merkmale, +) + +TOL_MM = 100.0 + + +def _sep(teileid, x, y, zuordnung): + return { + "teileart": "ILS 2.0 Separator", + "teileid": teileid, + "merkmale": {"Zuordnung": zuordnung}, + "nachbarn": "", + "_bbox": {"cx": x, "cy": y, "cz": 0.0, "dx": 150.0, "dy": 210.0}, + } + + +def _ases(teileart, teileid, x, y, zuordnung): + return { + "teileart": teileart, + "teileid": teileid, + "_zuordnung": zuordnung, + "merkmale": {}, + "nachbarn": "", + "_bbox": {"cx": x, "cy": y, "cz": 0.0, "dx": 200.0, "dy": 200.0}, + } + + +def _mubea_vf3(): + strecke = { + "teileart": "ILS 2.0 Strecke", + "teileid": "0015", + "nachbarn": "0002-L", + "merkmale": {}, + "k1": "", "k2": "", + "_bbox": {"cx": -631.28, "cy": 6543.11, "cz": 1624.70, + "dx": 1018.09, "dy": 12791.0}, + "sepliste": [ + {"typ": "AS", "lfdnr": 1, "id": "0061"}, + {"typ": "SEP", "lfdnr": 2, "id": "0062"}, + {"typ": "SEP", "lfdnr": 3, "id": "0063"}, + {"typ": "ES", "lfdnr": 4, "id": "0064"}, + ], + } + return [ + strecke, + _ases("ILS Ausschleuselement", "0061", -400.00, 270.54, "0015"), + _sep("0062", -898.87, 1134.60, "0015"), + _sep("0063", -702.98, 12307.81, "0015"), + _ases("ILS Einschleuselement", "0064", -400.00, 12832.58, "0015"), + _sep("0037", -1026.00, 10723.05, "0015"), + _sep("0038", -1026.00, 5250.05, "0015"), + ] + + +def _by_id(items, teileid): + return next(it for it in items if it.get("teileid") == teileid) + + +def _pipeline(items): + einsortieren_freie_separatoren(items, TOL_MM) + sep_kette = compute_sep_kette(items) + sep_kette_by_key = map_sepliste_kette_items(items, sep_kette) + write_separatorliste_merkmale(items, sep_kette_by_key) + resolve_umlauf_und_sepliste_nachbarn(items, {}, sep_kette, sep_kette_by_key, {}) + return items + + +class TestEinsortierPositionen: + """Reiner Rechenkern, ohne Items.""" + + KETTE = [(0.0, 0.0), (0.0, 1000.0), (0.0, 5000.0), (0.0, 6000.0)] + + def test_zwei_kandidaten_im_selben_abschnitt_sortiert(self): + einfuegen, verworfen = sepliste_einsortier_positionen( + self.KETTE, [("B", 50.0, 4000.0), ("A", -50.0, 2000.0)]) + assert einfuegen == {1: ["A", "B"]} + assert verworfen == [] + + def test_vor_as_und_hinter_es_verworfen(self): + einfuegen, verworfen = sepliste_einsortier_positionen( + self.KETTE, [("vor", 0.0, -500.0), ("hinter", 0.0, 6500.0)]) + assert einfuegen == {} + assert sorted(verworfen) == ["hinter", "vor"] + + def test_kette_um_eine_kurve(self): + """Polygonzug statt Gerade AS->ES: ein Separator auf dem zweiten + Schenkel einer L-Kette gehoert in DEN Abschnitt, nicht nach + seiner Projektion auf die Diagonale.""" + kette = [(0.0, 0.0), (0.0, 3000.0), (3000.0, 3000.0)] + einfuegen, _ = sepliste_einsortier_positionen(kette, [("S", 1500.0, 3050.0)]) + assert einfuegen == {1: ["S"]} + + def test_entartete_kette(self): + einfuegen, verworfen = sepliste_einsortier_positionen( + [(0.0, 0.0), (0.0, 0.0)], [("S", 10.0, 10.0)]) + assert einfuegen == {} + assert verworfen == ["S"] + + +class TestMubeaVf3: + + def test_trackids_mit_freien_separatoren(self): + items = _pipeline(_mubea_vf3()) + assert _by_id(items, "0015").get("trackids") == \ + "0061, 0062, 0038, 0037, 0063, 0064" + + def test_nachbarn_der_kette(self): + items = _pipeline(_mubea_vf3()) + assert _by_id(items, "0062")["nachbarn"] == "0061, 0038" + assert _by_id(items, "0038")["nachbarn"] == "0062, 0037" + assert _by_id(items, "0037")["nachbarn"] == "0038, 0063" + assert _by_id(items, "0063")["nachbarn"] == "0037, 0064" + + def test_hinweis_am_freien_separator(self): + items = _pipeline(_mubea_vf3()) + assert "0015" in _by_id(items, "0037").get("warnung", "") + assert _by_id(items, "0062").get("warnung", "") == "" + + def test_fremde_zuordnung_bleibt_draussen(self): + items = _mubea_vf3() + _by_id(items, "0037")["merkmale"]["Zuordnung"] = "0002-L" + _pipeline(items) + assert _by_id(items, "0015").get("trackids") == "0061, 0062, 0038, 0063, 0064" + + def test_ausserhalb_der_strecken_bbox_bleibt_draussen(self): + items = _mubea_vf3() + _by_id(items, "0038")["_bbox"]["cx"] = -3000.0 + _pipeline(items) + assert _by_id(items, "0015").get("trackids") == "0061, 0062, 0037, 0063, 0064" + + def test_ohne_ids_kein_einsortieren(self): + """Altbestand ohne aufgeloeste IDs: Glieder nicht lokalisierbar -> + sepliste bleibt wie gebaut.""" + items = _mubea_vf3() + for e in _by_id(items, "0015")["sepliste"]: + e["id"] = "" + einsortieren_freie_separatoren(items, TOL_MM) + assert len(_by_id(items, "0015")["sepliste"]) == 4