# -*- coding: utf-8 -*- # Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" # ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. """ tro_geometry.py - Separator-Host und Weichen-Fanout direkt aus der Geometrie. Ergaenzt tro_flow.py::derive_tros() um einen zweiten Weg, zwei seiner Eingaben zu bestimmen: Zuordnung (Host des Separators) -> resolve_host_geometric Nachbarn/graph.edges (Fanout) -> count_geometric_exits Beide Werte kommen normalerweise aus den CSV-Spalten "Zuordnung"/"Nachbarn" (material_flow.py::build_graph). Dieses Modul sucht sie stattdessen direkt in den Weltkoordinaten der Bauteile: ein shapely-STRtree ueber die AS/ES-Enden (K1/K2) jeder Strecke/Gefaellestrecke sowie die beiden Kreisel-Bahnenden liefert per .nearest() den naechstgelegenen Host zu einem Separator und per .query(..., predicate="dwithin") alle anderen Ketten, die sich an einem gegebenen Punkt tatsaechlich treffen - unabhaengig davon, ob die betroffene Zeile im CSV eine vollstaendige "Nachbarn"-Liste hat. Hintergrund: hundmM05_1.csv zeigt fuer mehrere "ILS 2.0 Strecke"-Zeilen den Fehler "nur ein Partner" (Spalte Fehler) - die von dxfmakros berechnete Nachbarschaft ist dort unvollstaendig. Eine fruehere, gleichartige Datei (hundm_export.csv) zeigte den Ausfall dieser Abhaengigkeit in voller Wucht: tro_flow.py leitete 5 TROs ab, alle vom Typ Vario, 0 Verbindungen, alle isoliert (siehe results/hundm_export_tro_doc.md) - Zuordnung/Nachbarn liessen sich fuer diesen Export nicht ausreichend aufloesen. Dieses Modul macht die TRO-Ableitung fuer genau diesen Fall unabhaengig von der Qualitaet dieser beiden Spalten. Nutzung: `tro_flow.py --geometry` (siehe dort, `make_geometric_resolvers`). Dieses Modul kennt tro_flow.py nicht - nur material_flow.py::Element-Listen. """ from __future__ import annotations import math from dataclasses import dataclass from shapely import STRtree from shapely.geometry import LineString, Point from material_flow import ( LANE_LEFT, LANE_RIGHT, UNASSIGNED, Element, _as_float, _merkmal, ) # Kreisel-Durchmesser (mm), falls kein Merkmal mit dem Durchmesser vorliegt - # Default wie dxfmakros' cfg/export.cfg [Kreisel] durchmesser_mm. DEFAULT_KREISEL_DURCHMESSER_MM = 800.0 # Merkmal-Namen, unter denen der Achsabstand der beiden Kreiselstationen # (AN8/SP8) im CSV-Export auftaucht - in Metern, siehe kreisel_capsule(). ABSTAND_MERKMALE = ( "Abstand (Kreiselachse A - Kreiselachse) in Meter", "Abstand_m", "ABSTAND", ) # Default-Toleranz (mm), innerhalb derer zwei Anschlusspunkte als "derselbe # Uebergabepunkt" gelten. Bewusst zwischen dxfmakros' omniflo_ks_toleranz_mm # (10mm, enge KOS-Deckung von Omniflo-Steckverbindern) und vf_linienzug.lsp's # *vfl-kette-tol-weit* (100mm, "sieht verbunden aus, ist es aber vielleicht # nicht") - zwei Kettenenden an einem echten mechanischen Uebergang treffen # sich nicht auf den zehntel Millimeter, muessen aber auch nicht 10cm # auseinanderliegen duerfen, um noch als verbunden zu gelten. DEFAULT_TOLERANCE_MM = 50.0 @dataclass class _EndpointEntry: """Ein indizierter Anschlusspunkt: welches Bauteil, welches Ende.""" teile_id: str end: str # "K1"/"K2" (AS/ES) oder "a"/"b" (Kreisel-Bahnende) def chain_endpoints( element: Element, ) -> tuple[tuple[float, float] | None, tuple[float, float] | None]: """AS-(K1) und ES-(K2) Weltpunkt einer Strecke/Gefaellestrecke. Fehlen K1/K2 (aeltere Exporte ohne AS/ES-Punkte), Ersatz durch die Mitten der kurzen Stirnseiten der Bounding-Box entlang ihrer laengeren Ausdehnung - gleiche Idee wie dxfmakros' export_neighbors.py::_strecke_ends, nur ohne dessen "Anfang liegt naeher am Einfuegepunkt"-Zuordnung: fuer die reine Fanout-Zaehlung an einem Punkt (count_geometric_exits) ist unerheblich, welches Ende AS und welches ES ist. """ by_label = dict(element.kpoints) k1, k2 = by_label.get("K1"), by_label.get("K2") if k1 is not None and k2 is not None: return k1, k2 if element.x is None or element.y is None or element.bbox is None: return k1, k2 cx, cy = element.x, element.y dx, dy = element.bbox_x or 0.0, element.bbox_y or 0.0 if dx >= dy: bbox_ends = ((cx - dx / 2.0, cy), (cx + dx / 2.0, cy)) else: bbox_ends = ((cx, cy - dy / 2.0), (cx, cy + dy / 2.0)) return k1 or bbox_ends[0], k2 or bbox_ends[1] def kreisel_durchmesser_mm(element: Element) -> float: """Kreisel-Durchmesser (mm) aus einem Merkmal, sonst DEFAULT_*.""" for key in ("Durchmesser_mm", "Durchmesser (mm)", "Durchmesser"): value = _as_float(_merkmal(element.merkmale, key)) if value: return value return DEFAULT_KREISEL_DURCHMESSER_MM def kreisel_capsule(element: Element, durchmesser_mm: float | None = None) -> dict | None: """Achsen-Segment (Kapsel) eines Kreisels in Weltkoordinaten. dxfmakros' eigenes export_neighbors.py::_kreisel_capsule setzt die Antriebsstation (AN8) lokal bei (radius, 0) und die Spannstation (SP8) bei (radius+abstand, 0) - relativ zum BLOCK-Einfuegepunkt (lokaler Ursprung des KREISEL_n-Wrappers, siehe KreiselInsert.lsp::draw-module). Die CSV- Spalte "Position" fuehrt bei einem Kreisel jedoch nachweislich den BBOX-MITTELPUNKT, nicht diesen Einfuegepunkt: die exportierte Boundingbox ist exakt symmetrisch um Position (halbe Breite = (abstand+durchmesser)/2, siehe hundmM05_1.csv Zeile 6/7 - AN8-lokal-(radius,0) waere das nicht). AN8/SP8 werden daher symmetrisch um Position gelegt: AN8 bei (-abstand/2, 0), SP8 bei (+abstand/2, 0) - beide auf der lokalen X-Achse, gedreht um das Merkmal "Drehung" (Grad, CCW positiv). Rueckgabe {"a": (x,y), "b": (x,y), "r": radius} (a = AN8-, b = SP8-Mitte), oder None ohne Position. """ if element.x is None or element.y is None: return None abstand_m = None for key in ABSTAND_MERKMALE: abstand_m = _as_float(_merkmal(element.merkmale, key)) if abstand_m is not None: break abstand_mm = (abstand_m or 0.0) * 1000.0 durchmesser = durchmesser_mm or kreisel_durchmesser_mm(element) r = durchmesser / 2.0 drehung = _as_float(_merkmal(element.merkmale, "Drehung")) or 0.0 rad = math.radians(drehung) cos_r, sin_r = math.cos(rad), math.sin(rad) def world(lx: float, ly: float) -> tuple[float, float]: return (element.x + lx * cos_r - ly * sin_r, element.y + lx * sin_r + ly * cos_r) half = abstand_mm / 2.0 return {"a": world(-half, 0.0), "b": world(half, 0.0), "r": r} def kreisel_lane_for_point(capsule: dict, point: tuple[float, float]) -> str: """Welche Bahn (L/R) liegt ein Punkt bezogen auf die Kreiselachse a->b. Kreuzprodukt Achse x (Punkt - a): positiv = links der Achsrichtung (mathematisch positive/CCW-Drehung von der Achse aus), konsistent mit dxfmakros' "links" = lokal Y in [0, +radius] (siehe kreisel_half_bboxes). """ ax, ay = capsule["a"] bx, by = capsule["b"] px, py = point cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax) return LANE_LEFT if cross >= 0 else LANE_RIGHT class GeometryIndex: """STRtree-Index der Bauteil-Geometrie fuer die geometrische TRO-Ableitung. Zwei getrennte Baeume: _host_tree eine LineString je Gefaellestrecke/Strecke (K1->K2-Achse) bzw. Kreisel (AN8->SP8-Achse), abgefragt mit .nearest() - "welcher Bauteil-ACHSE liegt dieser Separator am naechsten" (resolve_host_geometric). Bewusst die Achse, nicht die lose Bounding-Box: ein Kreisel deckt mit seiner Box leicht 20+ Meter ab (Antriebs- bis Spannstation), ein Separator auf einer nahen VF-Kette waere nach reiner Box-Distanz faelschlich "im Kreisel", obwohl er auf der Kette sitzt (siehe Modul-Historie: genau dieser Fehler zeigte sich beim ersten Testlauf gegen hundmM05_1.csv). _endpoint_tree ein Punkt je AS/ES-Ende einer Strecke/Gefaellestrecke plus je Kreisel-Bahnende, abgefragt mit .query(..., predicate="dwithin") - "wie viele andere Ketten treffen sich an diesem Punkt" (count_geometric_exits). """ def __init__(self, elements: list[Element], durchmesser_mm: float | None = None): self.durchmesser_mm = durchmesser_mm self._host_ids: list[str] = [] host_axes = [] # Kreisel-Kapseln nach TeileId, um am Treffer die Bahn (-L/-R) zu # bestimmen - graph.nodes kennt Kreisel nur unter "-L"/"-R", # nie unter der blanken TeileId (siehe material_flow.py::_build_nodes). self._kreisel_capsules: dict[str, dict] = {} self._endpoints: list[_EndpointEntry] = [] endpoint_points = [] for element in elements: if element.kind in ("Gefaellestrecke", "Strecke"): k1, k2 = chain_endpoints(element) if k1 is None or k2 is None: continue self._host_ids.append(element.teile_id) host_axes.append(LineString([k1, k2])) for label, point in (("K1", k1), ("K2", k2)): self._endpoints.append(_EndpointEntry(element.teile_id, label)) endpoint_points.append(Point(point)) elif element.kind == "Kreisel": if element.x is None or element.y is None: continue capsule = kreisel_capsule(element, durchmesser_mm) if capsule is None: continue self._host_ids.append(element.teile_id) host_axes.append(LineString([capsule["a"], capsule["b"]])) self._kreisel_capsules[element.teile_id] = capsule for end in ("a", "b"): self._endpoints.append(_EndpointEntry(element.teile_id, end)) endpoint_points.append(Point(capsule[end])) self._host_tree = STRtree(host_axes) if host_axes else None self._endpoint_tree = STRtree(endpoint_points) if endpoint_points else None def nearest_host(self, x: float, y: float) -> str | None: """Naechster Host-Knoten nach Achsabstand (Gefaellestrecke/Strecke- TeileId, oder "-L"/"-R" - dieselbe Schreibweise wie graph.nodes).""" if self._host_tree is None: return None index = self._host_tree.nearest(Point(x, y)) if index is None: return None teile_id = self._host_ids[int(index)] capsule = self._kreisel_capsules.get(teile_id) if capsule is None: return teile_id lane = kreisel_lane_for_point(capsule, (x, y)) return f"{teile_id}-{lane}" def exits_near(self, x: float, y: float, tol_mm: float, exclude_ids: set[str]) -> set[str]: """Andere Bauteil-Ids, deren Anschlusspunkt innerhalb tol_mm von (x,y) liegt.""" if self._endpoint_tree is None: return set() hits = self._endpoint_tree.query(Point(x, y), predicate="dwithin", distance=tol_mm) return {self._endpoints[int(i)].teile_id for i in hits} - exclude_ids def resolve_host_geometric(separator: Element, index: GeometryIndex) -> str | None: """Naechster Host zum Separator rein nach Weltkoordinate.""" if separator.x is None or separator.y is None: return None return index.nearest_host(separator.x, separator.y) def _strip_lane(host_id: str) -> str: """"0006-L" -> "0006" (Kreisel-Bahnsuffix entfernen; alles andere unveraendert).""" if host_id.endswith(("-L", "-R")): return host_id[:-2] return host_id def count_geometric_exits( hosts: list[str], elements_by_id: dict[str, Element], index: GeometryIndex, tol_mm: float = DEFAULT_TOLERANCE_MM, ) -> list[str]: """Andere Ketten, die geometrisch an einem Ende von `hosts` zusammentreffen. Entspricht tro_flow.py::_switch_exits, aber unabhaengig von graph.edges: beide Enden JEDES Hosts (K1 und K2 bzw. beide Kreisel-Bahnenden) werden abgefragt, die Treffer aller Enden vereinigt - wie eine aus beiden Enden zusammengefuehrte "Nachbarn"-Spalte, nur direkt aus der Geometrie statt aus der (fuer diese Bauteile teils unvollstaendigen) CSV-Spalte. `hosts` kann Kreisel-Bahnen als "-L"/"-R" enthalten (siehe GeometryIndex.nearest_host) - die Endpunkt-Eintraege im Index fuehren dazu nur die blanke TeileId, daher wird der Suffix hier wie dort entfernt. """ exclude = {_strip_lane(h) for h in hosts} found: set[str] = set() for host_id in hosts: element = elements_by_id.get(_strip_lane(host_id)) if element is None: continue if element.kind == "Kreisel": capsule = kreisel_capsule(element, index.durchmesser_mm) points = (capsule["a"], capsule["b"]) if capsule else () else: points = tuple(p for p in chain_endpoints(element) if p is not None) for x, y in points: found |= index.exits_near(x, y, tol_mm, exclude) return sorted(found) def make_geometric_resolvers( elements: list[Element], tol_mm: float = DEFAULT_TOLERANCE_MM, durchmesser_mm: float | None = None, ): """resolve_host/count_exits-Paar fuer tro_flow.py::derive_tros(...). Ersetzt Zuordnung (Spalte) und Nachbarn/graph.edges durch eine direkte STRtree-Nachbarsuche ueber die Weltkoordinaten der Bauteile. Weicht der geometrisch gefundene Host von der CSV-Spalte "Zuordnung" ab, wird das als Hinweis protokolliert statt stillschweigend ignoriert - das deckt zugleich auf, wo die CSV-Spalte fehlerhaft ist. """ index = GeometryIndex(elements, durchmesser_mm) by_id = {e.teile_id: e for e in elements} def resolve_host(sep: Element, findings: list[str]) -> str | None: host = resolve_host_geometric(sep, index) if host is None: findings.append( f"Separator {sep.teile_id} '{sep.name}': geometrisch kein Host " f"gefunden (CSV-Zuordnung '{sep.assignment or '-'}') - keinem " f"TRO zugeordnet" ) return None if sep.assignment and sep.assignment != UNASSIGNED and sep.assignment != host: findings.append( f"Separator {sep.teile_id} '{sep.name}': geometrischer Host " f"'{host}' weicht von CSV-Zuordnung '{sep.assignment}' ab" ) return host def count_exits(hosts: list[str]) -> list[str]: return count_geometric_exits(hosts, by_id, index, tol_mm) return resolve_host, count_exits