From fea601450c2fed8906f7c6dc5b86383f06301b08 Mon Sep 17 00:00:00 2001 From: Samer Ayadi Date: Wed, 14 Oct 2026 17:22:23 +0200 Subject: [PATCH] [FIX] Export: ZUORDNUNG-Attribut vor BBox-Suche bei Separator/Scanner Separatoren an einer Kreisel-Kreisel-Weiche ragen mit Toleranz in die Box beider Kreisel; compute_kreisel_umlauf/compute_sensor_zuordnung nahmen den ersten Treffer (test_system: Separator 0018 an Kreisel 0006 statt 0005, 0015 an 0003 statt 0002). Das ZUORDNUNG-Attribut aus count_sep_scan hat jetzt Vorrang, sofern es einen vorhandenen Carrier nennt; die Haelfte L/R wird weiter per BBox bestimmt, ohne Attribut bleibt die BBox-Suche. Co-Authored-By: Claude Opus 5.5 --- lib/export_csv.py | 14 +++++++++ lib/export_neighbors.py | 37 +++++++++++++++++++--- tests/test_export_kreisel_umlauf.py | 48 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/lib/export_csv.py b/lib/export_csv.py index c2b539d..1abee2e 100644 --- a/lib/export_csv.py +++ b/lib/export_csv.py @@ -255,6 +255,14 @@ def build_sensor_merkmale(block): } +def _sensor_zuordnung_attr(block): + """Carrier-ID (Kreisel/GF/VF-TeileId) eines Separator-/Scanner-Blocks aus + der Zeichnung: zuordnung_fix (verpackte Kopie, siehe build_sensor_ + merkmale) vor dem ZUORDNUNG-Attribut. "" wenn nichts gesetzt ist.""" + return (block.get("zuordnung_fix") + or block.get("attribs", {}).get("ZUORDNUNG", "") or "").strip() + + def build_element_merkmale(block): """Merkmale-Dict fuer eine ausgepackte VF_n/GF_n-Sub-Komponente (Bogen/ Streckenelement/Umlenkstation/Motorstation) - siehe csv:element-proxies- @@ -1341,6 +1349,11 @@ def process_blocks(blocks, lookup, dbg=None, lisp_weichen=None): # Zuordnung, die compute_sensor_zuordnung unten nicht # ueberschreiben darf - siehe build_sensor_merkmale. "_zuordnung_fix": bool(block.get("zuordnung_fix")), + # Privates Feld: Carrier-ID aus der Zeichnung (zuordnung_fix + # bzw. ZUORDNUNG-Attribut, von cs-zuordnung-lauf in + # Lisp/count_sep_scan.lsp gepflegt) - hat in compute_sensor_ + # zuordnung/compute_kreisel_umlauf Vorrang vor der BBox-Suche. + "_zuordnung_attr": _sensor_zuordnung_attr(block), # Roher INSERT-Punkt (nicht der bbox-Mittelpunkt) - dient # map_sepliste_kette_items als Koordinaten-FALLBACK, falls der # sepliste-Eintrag (noch) kein "id"-Feld traegt (Altbestand; @@ -1368,6 +1381,7 @@ def process_blocks(blocks, lookup, dbg=None, lisp_weichen=None): # sammelt - csv:block-to-json (export.lsp) haengt den Text als # "warnung"-Feld an den betroffenen Scanner-Block im JSON an. "warnung": block.get("warnung", ""), + "_zuordnung_attr": _sensor_zuordnung_attr(block), **bbox_columns(block), }) continue diff --git a/lib/export_neighbors.py b/lib/export_neighbors.py index 23de13d..aa78ad1 100644 --- a/lib/export_neighbors.py +++ b/lib/export_neighbors.py @@ -784,6 +784,9 @@ def compute_sensor_zuordnung(items, tolerance_mm, kreisel_durchmesser_mm=800.0, gf_entries = [] vf_entries = [] kreisel_entries = [] + # TeileIds aller Carrier (GF/VF/Kreisel) - fuer den Vorrang des + # ZUORDNUNG-Attributs ("_zuordnung_attr") vor der BBox-Suche. + carrier_ids = set() for item in items: bbox = item.get("_bbox") @@ -793,9 +796,12 @@ def compute_sensor_zuordnung(items, tolerance_mm, kreisel_durchmesser_mm=800.0, teileid = item.get("teileid", "") if teileart == "ILS 2.0 Gefaellestrecke": gf_entries.append((teileid, _bounds(bbox, half_tol))) + carrier_ids.add(teileid) elif teileart in ("ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"): vf_entries.append((teileid, _bounds(bbox, half_tol))) + carrier_ids.add(teileid) elif teileart == KREISEL_SPLIT_TEILEART: + carrier_ids.add(teileid) bbox_links, bbox_rechts = kreisel_half_bboxes(item, kreisel_durchmesser_mm) kreisel_entries.append((f"{teileid}-L", _bounds(bbox_links, half_tol))) kreisel_entries.append((f"{teileid}-R", _bounds(bbox_rechts, half_tol))) @@ -824,10 +830,22 @@ def compute_sensor_zuordnung(items, tolerance_mm, kreisel_durchmesser_mm=800.0, result.append("") continue bounds = _bounds(bbox, half_tol) - treffer = (_find(bounds, gf_entries) - or _find(bounds, vf_entries) - or _find(bounds, kreisel_entries) - or "nicht zugeordnet") + # Vorrang: ZUORDNUNG aus der Zeichnung (cs-zuordnung-lauf), sofern sie + # einen vorhandenen Carrier nennt. Die BBox-Suche nimmt sonst den + # ERSTEN Treffer - an einer Kreisel-Kreisel-Weiche ragt ein Separator + # mit Toleranz in beide Kreisel und landete beim falschen (realer + # Fall test_system, Separator 0018: Attribut 0005, BBox -> 0006). + # Bei einem Kreisel wird nur noch die Haelfte (L/R) per BBox bestimmt. + attr = (item.get("_zuordnung_attr") or "").strip() + if attr in carrier_ids: + haelften = [e for e in kreisel_entries + if e[0] in (f"{attr}-L", f"{attr}-R")] + treffer = (_find(bounds, haelften) if haelften else None) or attr + else: + treffer = (_find(bounds, gf_entries) + or _find(bounds, vf_entries) + or _find(bounds, kreisel_entries) + or "nicht zugeordnet") if dbg: dbg(f" {item.get('teileart', '')} {item.get('teileid', '')!r} " f"bounds={_fmt_bounds(bounds)} -> {treffer!r}") @@ -1383,7 +1401,18 @@ def compute_kreisel_umlauf(items, tolerance_mm, durchmesser_mm=800.0, continue bounds = _bounds(bbox, half_tol) matched_tid = None + # Vorrang: ZUORDNUNG-Attribut des Separators/Scanners, wenn es einen + # Kreisel nennt (siehe compute_sensor_zuordnung) - die BBox-Suche + # unten nimmt den ERSTEN ueberlappenden Kreisel und verwechselt an + # einer Kreisel-Kreisel-Weiche die beiden Kreisel. + attr = (item.get("_zuordnung_attr") or "").strip() + if attr in kreisel_bounds: + punkte_je_kreisel[attr].append( + (teileid, bbox.get("cx", 0.0), bbox.get("cy", 0.0))) + matched_tid = attr for tid, kb in kreisel_bounds.items(): + if matched_tid is not None: + break if _overlaps(bounds, kb): punkte_je_kreisel[tid].append( (teileid, bbox.get("cx", 0.0), bbox.get("cy", 0.0))) diff --git a/tests/test_export_kreisel_umlauf.py b/tests/test_export_kreisel_umlauf.py index b2bce05..cdece05 100644 --- a/tests/test_export_kreisel_umlauf.py +++ b/tests/test_export_kreisel_umlauf.py @@ -538,3 +538,51 @@ class TestAsEsUeberEinlaufpunkt: umlauf = compute_kreisel_umlauf( items, tolerance_mm=200.0, durchmesser_mm=DURCHMESSER_MM) assert "0071" not in [p[0] for p in umlauf["0001"]] + + +class TestZuordnungAttributVorBBox: + """Realer Fall test_system, Separator 0018: er sitzt an der Kreisel- + Kreisel-Weiche zweier Kreisel und ragt mit Toleranz in die Box BEIDER. + Die BBox-Suche nahm den ersten Kreisel in der Item-Liste (den falschen); + das ZUORDNUNG-Attribut aus der Zeichnung (cs-zuordnung-lauf) nennt den + richtigen und hat Vorrang.""" + + def _anlage(self, attr): + # Kreisel 0006 senkrecht (Achse +Y), AN8-Mitte bei (0, 400); + # Kreisel 0005 waagerecht (Achse +X), AN8-Mitte bei (0, -400): + # AN8/AN8 beruehren sich bei (0, 0). Separator auf der Oberkante von + # Kreisel 0005, 615 mm rechts der Weiche. + k6 = _kreisel("0006", 0.0, 0.0, 90.0, abstand=2000.0) + k5 = _kreisel("0005", -400.0, -400.0, 0.0, abstand=8000.0) + sep = _separator("0018", 615.0, 0.0) + sep["_zuordnung_attr"] = attr + sep["merkmale"] = {} + return [sep, k6, k5] + + def test_umlauf_folgt_attribut(self): + items = self._anlage("0005") + umlauf = compute_kreisel_umlauf(items, tolerance_mm=200.0, + durchmesser_mm=DURCHMESSER_MM) + assert "0018" in [p[0] for p in umlauf["0005"]] + assert "0018" not in [p[0] for p in umlauf["0006"]] + + def test_ohne_attribut_bbox_rueckfall(self): + items = self._anlage("") + umlauf = compute_kreisel_umlauf(items, tolerance_mm=200.0, + durchmesser_mm=DURCHMESSER_MM) + alle = [p[0] for pts in umlauf.values() for p in pts] + assert alle.count("0018") == 1 + + def test_sensor_zuordnung_folgt_attribut(self): + from export_neighbors import compute_sensor_zuordnung + items = self._anlage("0005") + zuo = compute_sensor_zuordnung(items, 200.0, + kreisel_durchmesser_mm=DURCHMESSER_MM) + assert zuo[0].startswith("0005") + + def test_unbekanntes_attribut_bbox_rueckfall(self): + from export_neighbors import compute_sensor_zuordnung + items = self._anlage("9999") + zuo = compute_sensor_zuordnung(items, 200.0, + kreisel_durchmesser_mm=DURCHMESSER_MM) + assert zuo[0] != "9999" and zuo[0] != "nicht zugeordnet"