339 lines
15 KiB
Python
339 lines
15 KiB
Python
# -*- 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 "<id>-L"/"<id>-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 "<Kreisel-TeileId>-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 "<TeileId>-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
|