TRO flow svg file can be created.
This commit is contained in:
+106
-5
@@ -769,8 +769,23 @@ def _nearest_node(station: Element, nodes: dict[str, Node]) -> str | None:
|
||||
return best
|
||||
|
||||
|
||||
def _nearest_kreisel(station: Element, elements: list[Element]) -> Element | None:
|
||||
"""Naechstgelegener Kreisel zur Station (2D-Abstand zur Kreisel-Achsposition)."""
|
||||
if station.x is None or station.y is None:
|
||||
return None
|
||||
best, best_d = None, None
|
||||
for el in elements:
|
||||
if not el.is_circle or el.x is None or el.y is None:
|
||||
continue
|
||||
d = (el.x - station.x) ** 2 + (el.y - station.y) ** 2
|
||||
if best_d is None or d < best_d:
|
||||
best, best_d = el, d
|
||||
return best
|
||||
|
||||
|
||||
def _attach_stations(
|
||||
elements: list[Element], nodes: dict[str, Node], warnings: list[str]
|
||||
elements: list[Element], by_id: dict[str, Element], nodes: dict[str, Node],
|
||||
warnings: list[str]
|
||||
) -> list[Edge]:
|
||||
"""
|
||||
Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden.
|
||||
@@ -778,7 +793,11 @@ def _attach_stations(
|
||||
Eingang (Beladung) ist Quelle: Kante Station -> Ziel.
|
||||
Ausgang (Entladung) ist Senke: Kante Ziel -> Station.
|
||||
Ziel kommt aus den 'Nachbarn' des Exports; fehlen sie (dxfmakros exportiert
|
||||
fuer BTMT derzeit keine), wird das naechstgelegene Objekt per Abstand genommen.
|
||||
fuer BTMT derzeit keine), haengen beide baulich direkt am naechstgelegenen
|
||||
Kreisel, auf dessen Eingangs- bzw. Ausgangsbahn (siehe _weiche_lane_roles):
|
||||
UZS -> Eingang Bahn R / Ausgang Bahn L, GUZ -> Eingang Bahn L / Ausgang
|
||||
Bahn R. Nur wenn kein Kreisel mit bekannter Drehrichtung gefunden wird,
|
||||
greift ersatzweise die naechstgelegene Bahn/Strecke per Abstand.
|
||||
"""
|
||||
edges: list[Edge] = []
|
||||
for element in elements:
|
||||
@@ -796,6 +815,18 @@ def _attach_stations(
|
||||
else:
|
||||
partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT)
|
||||
if f"{base}-{L}" in nodes]
|
||||
if not partners and (element.is_entry or element.is_exit):
|
||||
kreisel = _nearest_kreisel(element, elements)
|
||||
roles = _weiche_lane_roles(kreisel.teile_id, by_id, nodes) if kreisel else None
|
||||
if roles:
|
||||
eingang, ausgang = roles
|
||||
lane, role = (eingang, "Eingangsbahn") if element.is_entry else (ausgang, "Ausgangsbahn")
|
||||
partners = [lane]
|
||||
warnings.append(
|
||||
f"{element.describe()}: keine Nachbarn im Export - "
|
||||
f"an Kreisel {kreisel.teile_id} Bahn {lane.split('-')[-1]} "
|
||||
f"angebunden ({role}, Drehrichtung {kreisel.rotation})"
|
||||
)
|
||||
if not partners:
|
||||
nearest = _nearest_node(element, nodes)
|
||||
if nearest:
|
||||
@@ -827,6 +858,30 @@ def _kreisel_lane_node(kreisel_base: str, strecke: Element, nodes: dict[str, Nod
|
||||
return kreisel_base if kreisel_base in nodes else None
|
||||
|
||||
|
||||
def _weiche_lane_roles(kreisel_base: str, by_id: dict[str, Element], nodes: dict[str, Node]) -> tuple[str, str] | None:
|
||||
"""Eingangs-/Ausgangsbahn eines Kreisels an einer Kreisel<->Kreisel-Weiche.
|
||||
|
||||
Ein Kreisel ist intern ein Umlauf zwischen seinen zwei Bahnen (Merkmal
|
||||
"Drehrichtung"); am Beruehrpunkt mit dem anderen Kreisel liegt daher
|
||||
zwingend die eine Bahn als Eingang (freies Ende vor dem internen Umlauf)
|
||||
und die andere als Ausgang (freies Ende danach):
|
||||
UZS (Umlauf R -> L) -> Eingang R, Ausgang L
|
||||
GUZ (Umlauf L -> R) -> Eingang L, Ausgang R
|
||||
None, wenn die Drehrichtung fehlt/unbekannt ist oder eine Bahn im Graph fehlt.
|
||||
"""
|
||||
element = by_id.get(kreisel_base)
|
||||
if element is None or not element.is_circle:
|
||||
return None
|
||||
left, right = f"{kreisel_base}-{LANE_LEFT}", f"{kreisel_base}-{LANE_RIGHT}"
|
||||
if left not in nodes or right not in nodes:
|
||||
return None
|
||||
if element.rotation == ROTATION_CW:
|
||||
return right, left
|
||||
if element.rotation == ROTATION_CCW:
|
||||
return left, right
|
||||
return None
|
||||
|
||||
|
||||
def _apply_transfers(
|
||||
elements: list[Element], by_id: dict[str, Element],
|
||||
nodes: dict[str, Node], edges: list[Edge], warnings: list[str]
|
||||
@@ -836,7 +891,10 @@ def _apply_transfers(
|
||||
|
||||
Ausschleuselement: Kreisel -> Strecke (Streckenanfang).
|
||||
Einschleuselement: Strecke -> Kreisel (Streckenende).
|
||||
Weiche: Kreisel<->Kreisel als Weiche markieren.
|
||||
Weiche: Kreisel<->Kreisel, Bahn und Richtung aus der Drehrichtung
|
||||
beider Kreisel (siehe _weiche_lane_roles); ohne bekannte
|
||||
Drehrichtung bleibt die Verbindung unbestimmt, wird aber
|
||||
weiterhin als Weiche markiert.
|
||||
Die so definierten Kanten sind massgeblich und ersetzen die aus Hoehe/
|
||||
Nachbarn abgeleitete Richtung fuer genau diese Knotenpaare.
|
||||
"""
|
||||
@@ -844,6 +902,7 @@ def _apply_transfers(
|
||||
# ein = Auslauf (Strecke->Kreisel, Ende)
|
||||
per_strecke: dict[str, dict[str, str]] = {}
|
||||
weiche_pairs: list[set[str]] = []
|
||||
weiche_ab: list[tuple[str, str]] = []
|
||||
for el in elements:
|
||||
if el.kind in ("Ausschleus", "Einschleus"):
|
||||
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
|
||||
@@ -856,6 +915,28 @@ def _apply_transfers(
|
||||
b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip()
|
||||
if a and b:
|
||||
weiche_pairs.append({a, b})
|
||||
weiche_ab.append((a, b))
|
||||
|
||||
# Weiche zwischen zwei Kreiseln: Bahn und Richtung deterministisch aus der
|
||||
# Drehrichtung beider Kreisel ableiten (Ausgang der einen Bahn -> Eingang
|
||||
# der anderen, in beiden Richtungen) statt aus dem rohen Nachbarn-Kreuzprodukt,
|
||||
# das links/rechts nicht unterscheiden kann.
|
||||
weiche_edges: list[Edge] = []
|
||||
weiche_resolved: set[frozenset] = set()
|
||||
for a, b in weiche_ab:
|
||||
roles_a = _weiche_lane_roles(a, by_id, nodes)
|
||||
roles_b = _weiche_lane_roles(b, by_id, nodes)
|
||||
if roles_a is None or roles_b is None:
|
||||
warnings.append(
|
||||
f"Weiche {a}<->{b}: Drehrichtung mindestens eines Kreisels unbekannt - "
|
||||
f"Bahnzuordnung nicht bestimmbar, Verbindung bleibt unbestimmt"
|
||||
)
|
||||
continue
|
||||
in_a, out_a = roles_a
|
||||
in_b, out_b = roles_b
|
||||
weiche_edges.append(Edge(out_a, in_b, EDGE_FLOW, "Weiche"))
|
||||
weiche_edges.append(Edge(out_b, in_a, EDGE_FLOW, "Weiche"))
|
||||
weiche_resolved.add(frozenset({a, b}))
|
||||
|
||||
directed: dict[frozenset, tuple[str, str]] = {}
|
||||
for s, roles in per_strecke.items():
|
||||
@@ -885,9 +966,13 @@ def _apply_transfers(
|
||||
# einem dritten Kreisel liegt) ist ein Falschtreffer und wird verworfen.
|
||||
covered = set(per_strecke)
|
||||
result: list[Edge] = []
|
||||
superseded: set[tuple[str, str]] = set()
|
||||
for e in edges:
|
||||
if frozenset({e.src, e.dst}) in directed:
|
||||
continue # wird gerichtet neu gesetzt
|
||||
if frozenset({e.src.split("-")[0], e.dst.split("-")[0]}) in weiche_resolved:
|
||||
superseded.add((e.src, e.dst))
|
||||
continue # wird durch die Weiche-Kanten ersetzt
|
||||
ends = {e.src, e.dst}
|
||||
strecke_end = ends & covered
|
||||
others = ends - strecke_end
|
||||
@@ -902,7 +987,23 @@ def _apply_transfers(
|
||||
result.append(e)
|
||||
for (src, dst, note) in directed.values():
|
||||
result.append(Edge(src, dst, EDGE_FLOW, note))
|
||||
# Kreisel<->Kreisel-Kanten, die eine Weiche verbinden, markieren
|
||||
result.extend(weiche_edges)
|
||||
# Die anfaengliche Hoehen-basierte Ausrichtung (_orient) kann eine
|
||||
# Kreisel<->Kreisel-Verbindung nie bestimmen und meldet sie immer als
|
||||
# "beidseitig gezeichnet" - das stimmt nicht mehr, sobald die Weiche sie
|
||||
# gerade durch obenstehende Kanten ersetzt hat. Die dadurch ueberholte
|
||||
# Meldung wird entfernt.
|
||||
if superseded:
|
||||
stale = {
|
||||
f"Verbindung {a} <-> {b}: Richtung nicht bestimmbar "
|
||||
f"(kein Transportobjekt beteiligt) - beidseitig gezeichnet"
|
||||
for src, dst in superseded for a, b in ((src, dst), (dst, src))
|
||||
}
|
||||
warnings[:] = [w for w in warnings if w not in stale]
|
||||
# Kreisel<->Kreisel-Kanten, die eine Weiche verbinden, markieren (deckt vor
|
||||
# allem die Faelle ab, die mangels Drehrichtung nicht gerichtet aufgeloest
|
||||
# werden konnten - die aufgeloesten Weiche-Kanten tragen die Markierung
|
||||
# bereits).
|
||||
for e in result:
|
||||
if {e.src.split("-")[0], e.dst.split("-")[0]} in weiche_pairs:
|
||||
e.note = "Weiche"
|
||||
@@ -949,7 +1050,7 @@ def build_graph(elements: list[Element], source: str, warnings: list[str]) -> Gr
|
||||
edges.append(Edge(right, left, EDGE_CIRCLE, "unbestimmt"))
|
||||
|
||||
# Ein-/Ausgabestationen (BTMT Be-/Entladung) als Quelle/Senke anbinden
|
||||
edges.extend(_attach_stations(elements, nodes, warnings))
|
||||
edges.extend(_attach_stations(elements, by_id, nodes, warnings))
|
||||
|
||||
# Kreisel<->Strecke gerichtet aus den Transferelementen (massgeblich)
|
||||
edges = _apply_transfers(elements, by_id, nodes, edges, warnings)
|
||||
|
||||
+290
-25
@@ -60,6 +60,7 @@ import argparse
|
||||
import csv
|
||||
import math
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -88,6 +89,7 @@ from material_flow import (
|
||||
from tro_catalog import (
|
||||
CATALOG_AS_OF,
|
||||
ITEM_FB,
|
||||
STYLE_EXT,
|
||||
STYLES,
|
||||
TRO_CATALOG,
|
||||
TroDefinition,
|
||||
@@ -111,6 +113,14 @@ from tro_overrides import (
|
||||
# einem Separator) wird ein Speicherblock (PinStore_Auto) angenommen.
|
||||
MIN_STORE_LANES = 2
|
||||
|
||||
# Toleranz (mm), innerhalb derer ein Separator als "sitzt an einem Aus-/
|
||||
# Einschleuselement" gilt (siehe _find_switch_separators). Kalibriert an
|
||||
# hundmM05_1.csv: die tatsaechlich am Uebergang montierten Separatoren liegen
|
||||
# 250-350mm vom Element entfernt, die naechsten unbeteiligten (auf der
|
||||
# eigenen Streckenmitte montierten) Separatoren erst ab ca. 885mm - die
|
||||
# Grenze liegt bewusst in der Mitte dieser Luecke.
|
||||
SWITCH_PROXIMITY_MM = 500.0
|
||||
|
||||
GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->"
|
||||
|
||||
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
|
||||
@@ -187,9 +197,14 @@ class Analysis:
|
||||
findings: list[str] = field(default_factory=list)
|
||||
# Manuelle Korrekturen aus cfg/tro_overrides.ini (Typkorrekturen, offene Punkte)
|
||||
overrides: Overrides = field(default_factory=Overrides)
|
||||
# Wie Host/Fanout ermittelt wurden - siehe derive_tros(resolve_host, count_exits)
|
||||
method: str = "Materialfluss-Graph (Zuordnung/Nachbarn)"
|
||||
# Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein
|
||||
# TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird.
|
||||
boundaries: list[Tro] = field(default_factory=list)
|
||||
|
||||
def by_id(self, tro_id: str) -> Tro | None:
|
||||
for tro in self.tros:
|
||||
for tro in self.tros + self.boundaries:
|
||||
if tro.tro_id == tro_id:
|
||||
return tro
|
||||
return None
|
||||
@@ -243,13 +258,71 @@ def _switch_exits(hosts: list[str], graph: Graph) -> list[str]:
|
||||
)
|
||||
|
||||
|
||||
def _default_resolve_host(sep: Element, findings: list[str], graph: Graph) -> str | None:
|
||||
"""Host eines Separators aus der CSV-Spalte 'Zuordnung' (Default-Resolver)."""
|
||||
host = sep.assignment
|
||||
if host and host != UNASSIGNED and host in graph.nodes:
|
||||
return host
|
||||
findings.append(
|
||||
f"Separator {sep.teile_id} '{sep.name}': Zuordnung '{host or '-'}' "
|
||||
f"zeigt auf keinen Flussknoten - keinem TRO zugeordnet"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _find_switch_separators(
|
||||
separators: list[Element], elements: list[Element], tol_mm: float
|
||||
) -> dict[str, tuple[Element, float]]:
|
||||
"""Separator-Id -> (naechstgelegenes Aus-/Einschleuselement, Abstand mm),
|
||||
nur wenn Abstand <= tol_mm.
|
||||
|
||||
HundM05 hat an seinen Kreisel<->Strecke-Uebergaengen kein eigenes "ILS
|
||||
Weiche"-Bauteil (anders als ein Kreisel<->Kreisel-Beruehrpunkt, siehe
|
||||
Mubea) - das von dxfmakros synthetisierte Ausschleuselement (Kreisel ->
|
||||
Strecke, am Streckenanfang) bzw. Einschleuselement (Strecke -> Kreisel,
|
||||
am Streckenende) uebernimmt dort faktisch die Rolle der Weiche. Sitzt ein
|
||||
Separator direkt daneben, gehoert er zu dieser Weiche - unabhaengig davon,
|
||||
ob seine CSV-Spalte "Zuordnung" auf die Kreisel-Bahn oder auf die Strecke
|
||||
zeigt (in den Daten kommt beides vor, siehe derive_tros).
|
||||
"""
|
||||
transfers = [e for e in elements if e.kind in ("Ausschleus", "Einschleus")]
|
||||
result: dict[str, tuple[Element, float]] = {}
|
||||
for sep in separators:
|
||||
if sep.x is None or sep.y is None:
|
||||
continue
|
||||
best: tuple[float, Element] | None = None
|
||||
for transfer in transfers:
|
||||
if transfer.x is None or transfer.y is None:
|
||||
continue
|
||||
d = math.hypot(transfer.x - sep.x, transfer.y - sep.y)
|
||||
if d <= tol_mm and (best is None or d < best[0]):
|
||||
best = (d, transfer)
|
||||
if best is not None:
|
||||
result[sep.teile_id] = (best[1], best[0])
|
||||
return result
|
||||
|
||||
|
||||
def derive_tros(
|
||||
graph: Graph,
|
||||
elements: list[Element],
|
||||
findings: list[str],
|
||||
overrides: Overrides | None = None,
|
||||
*,
|
||||
resolve_host: Callable[[Element, list[str]], str | None] | None = None,
|
||||
count_exits: Callable[[list[str]], list[str]] | None = None,
|
||||
) -> list[Tro]:
|
||||
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden."""
|
||||
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden.
|
||||
|
||||
resolve_host(sep, findings) -> Host-Knoten-Id oder None; Default liest die
|
||||
CSV-Spalte "Zuordnung" (siehe _default_resolve_host). count_exits(hosts) ->
|
||||
Liste abgehender Nachbarn eines TRO-Hosts; Default liest graph.edges (siehe
|
||||
_switch_exits). tro_geometry.py::make_geometric_resolvers() liefert ein
|
||||
Paar, das beides stattdessen direkt aus den Bauteil-Weltkoordinaten sucht
|
||||
(STRtree) - siehe tro_flow.py --geometry.
|
||||
"""
|
||||
resolve_host = resolve_host or (lambda sep, findings: _default_resolve_host(sep, findings, graph))
|
||||
count_exits = count_exits or (lambda hosts: _switch_exits(hosts, graph))
|
||||
|
||||
separators = sorted(
|
||||
(e for e in elements if e.kind == "Separator"), key=lambda e: e.teile_id
|
||||
)
|
||||
@@ -257,30 +330,39 @@ def derive_tros(
|
||||
(e for e in elements if e.kind == "Scanner"), key=lambda e: e.teile_id
|
||||
)
|
||||
|
||||
# Separator -> Host-Knoten (Spalte "Zuordnung")
|
||||
# Separatoren an einem Aus-/Einschleuselement (siehe _find_switch_separators)
|
||||
# werden VOR der normalen Host-Aufloesung herausgenommen: sie bilden gleich
|
||||
# unten (3b) ihre eigene 1Sep1Swi-Uebergabestelle und duerfen nicht zugleich
|
||||
# als gewoehnlicher Separator ihres Zuordnung-Hosts (Kreisel-Bahn oder
|
||||
# Strecke - beides kommt vor) mitgezaehlt werden.
|
||||
switch_hits = _find_switch_separators(separators, elements, SWITCH_PROXIMITY_MM)
|
||||
|
||||
# Separator -> Host-Knoten
|
||||
sep_host: dict[str, str] = {}
|
||||
for sep in separators:
|
||||
host = sep.assignment
|
||||
if host and host != UNASSIGNED and host in graph.nodes:
|
||||
if sep.teile_id in switch_hits:
|
||||
continue
|
||||
host = resolve_host(sep, findings)
|
||||
if host is not None:
|
||||
sep_host[sep.teile_id] = host
|
||||
else:
|
||||
findings.append(
|
||||
f"Separator {sep.teile_id} '{sep.name}': Zuordnung '{host or '-'}' "
|
||||
f"zeigt auf keinen Flussknoten - keinem TRO zugeordnet"
|
||||
)
|
||||
|
||||
# Scanner -> Separator; "Naechster Separator" ist die funktionale Zuordnung,
|
||||
# "Zuordnung" nur der Montageort.
|
||||
sep_scanners: dict[str, list[str]] = {}
|
||||
for scanner in scanners:
|
||||
target = str(_merkmal(scanner.merkmale, "Naechster Separator") or "").strip()
|
||||
if target in sep_host:
|
||||
if target in sep_host or target in switch_hits:
|
||||
sep_scanners.setdefault(target, []).append(scanner.teile_id)
|
||||
mounted = scanner.assignment
|
||||
if mounted and mounted != UNASSIGNED and mounted != sep_host[target]:
|
||||
target_host = sep_host.get(target)
|
||||
# Fuer Switch-Separatoren (3b) gibt es keinen einzelnen Host zum
|
||||
# Vergleichen (sie haengen zwischen zwei Ketten) - der Abgleich
|
||||
# entfaellt dort, ohne den Scanner deshalb als "unbekannt" zu werten.
|
||||
if (mounted and mounted != UNASSIGNED and target_host is not None
|
||||
and mounted != target_host):
|
||||
findings.append(
|
||||
f"Scanner {scanner.teile_id} '{scanner.name}': montiert an {mounted}, "
|
||||
f"liest aber an Separator {target} auf {sep_host[target]}"
|
||||
f"liest aber an Separator {target} auf {target_host}"
|
||||
)
|
||||
else:
|
||||
findings.append(
|
||||
@@ -380,11 +462,41 @@ def derive_tros(
|
||||
)
|
||||
)
|
||||
|
||||
# 3b) Separatoren an einem Aus-/Einschleuselement -> eigene 1Sep1Swi-
|
||||
# Uebergabestelle. Das Element uebernimmt die Rolle der (hier fehlenden)
|
||||
# Weiche; siehe _find_switch_separators. Host bewusst die Strecke (nicht
|
||||
# die Kreisel-Bahn): beide Elementarten tragen "Strecke" im Merkmal, und
|
||||
# die Strecke hat bereits reale, gerichtete Kanten zum Kreisel
|
||||
# (_apply_transfers) - darueber haengt sich die Verknuepfung (link_tros)
|
||||
# von selbst ein, ohne einen weiteren synthetischen Host zu erfinden.
|
||||
for sep_id, (transfer, distance) in sorted(switch_hits.items()):
|
||||
strecke_id = str(_merkmal(transfer.merkmale, "Strecke") or "")
|
||||
ende = str(_merkmal(transfer.merkmale, "Streckenende") or "")
|
||||
own_scanners = sorted(sep_scanners.get(sep_id, []))
|
||||
art = "Ausschleuselement" if transfer.kind == "Ausschleus" else "Einschleuselement"
|
||||
tros.append(
|
||||
Tro(
|
||||
tro_id="",
|
||||
type_name="1Sep1Swi",
|
||||
label=f"Separator {sep_id} an {art} {transfer.teile_id} "
|
||||
f"(Strecke {strecke_id}, {ende})",
|
||||
hosts=[strecke_id] if strecke_id in graph.nodes else [transfer.teile_id],
|
||||
separators=[sep_id],
|
||||
scanners=own_scanners,
|
||||
reason=(
|
||||
f"{distance:.0f}mm von {art} {transfer.teile_id} entfernt - "
|
||||
f"kein eigenes Weichen-Bauteil im Layout, das {art} "
|
||||
f"uebernimmt diese Rolle"
|
||||
),
|
||||
confidence="mittel",
|
||||
)
|
||||
)
|
||||
|
||||
# 4) Weichen-Aufwertung: 2 bzw. 3 abgehende Flusskanten
|
||||
for tro in tros:
|
||||
if tro.type_name != "1Sep":
|
||||
continue
|
||||
exits = _switch_exits(tro.hosts, graph)
|
||||
exits = count_exits(tro.hosts)
|
||||
if not 2 <= len(exits) <= 3:
|
||||
continue
|
||||
upgraded = "1Sep1Swi" if len(exits) == 2 else "1Sep2Swi"
|
||||
@@ -443,6 +555,60 @@ def derive_tros(
|
||||
return tros
|
||||
|
||||
|
||||
# Typnamen fuer Systemgrenz-Knoten (siehe derive_boundaries) - bewusst NICHT
|
||||
# in TRO_CATALOG: eine BTMT Be-/Entladestation ist kein Steuerobjekt mit
|
||||
# eigenem FB-Baustein, nur der tatsaechliche Materialein-/ausgang.
|
||||
BOUNDARY_ENTRY = "Materialeingang"
|
||||
BOUNDARY_EXIT = "Materialausgang"
|
||||
BOUNDARY_TYPES = (BOUNDARY_ENTRY, BOUNDARY_EXIT)
|
||||
|
||||
|
||||
def derive_boundaries(elements: list[Element], graph: Graph) -> list[Tro]:
|
||||
"""BTMT Be-/Entladestationen als sichtbare Systemgrenz-Knoten.
|
||||
|
||||
Ohne diese Knoten "verschwindet" der Materialfluss stillschweigend an der
|
||||
Station: link_tros() registriert eine Kante nur, wenn die Traversierung
|
||||
einen Knoten erreicht, der ein TRO-Host ist. Eine BTMT-Station ist nie
|
||||
ein TRO-Host (sie hat keinen Separator), also wird sie beim Durchlaufen
|
||||
als passiver Zwischenknoten behandelt - und da eine Entladung (Senke)
|
||||
keine ausgehende Kante mehr hat, bricht die Suche dort einfach ab, ohne
|
||||
eine Kante einzutragen. Eine Beladung (Quelle) wird umgekehrt nie als
|
||||
Ausgangspunkt einer Traversierung betrachtet, weil kein TRO dort beginnt.
|
||||
Damit fehlte bislang genau der Teil des Flusses, den man am Anfang oder
|
||||
Ende eines Durchlaufs durch die Anlage sieht.
|
||||
|
||||
Kein eigener TRO-Typ (keine FB_ILS_MTRO_*-Zuordnung, kein Eintrag in
|
||||
TRO_CATALOG) - nur ein sichtbarer Marker fuer den echten Anfang/das echte
|
||||
Ende des Materialflusses. Eigene Nummerierung ("SYS1", "SYS2", ...)
|
||||
getrennt von den TRO-IDs, damit auf den ersten Blick klar bleibt: das
|
||||
sind keine Steuerobjekte.
|
||||
"""
|
||||
boundaries: list[Tro] = []
|
||||
for element in elements:
|
||||
if element.kind not in ("Beladung", "Entladung"):
|
||||
continue
|
||||
if element.teile_id not in graph.nodes:
|
||||
continue
|
||||
boundaries.append(
|
||||
Tro(
|
||||
tro_id="",
|
||||
type_name=BOUNDARY_ENTRY if element.is_entry else BOUNDARY_EXIT,
|
||||
label=element.name,
|
||||
hosts=[element.teile_id],
|
||||
separators=[],
|
||||
scanners=[],
|
||||
reason="Systemgrenze (BTMT Be-/Entladestation) - kein TRO, "
|
||||
"nur Materialein-/ausgang",
|
||||
confidence="hoch",
|
||||
)
|
||||
)
|
||||
boundaries.sort(key=lambda t: t.hosts[0])
|
||||
width = max(1, len(str(len(boundaries))))
|
||||
for index, boundary in enumerate(boundaries, start=1):
|
||||
boundary.tro_id = f"SYS{index:0{width}d}"
|
||||
return boundaries
|
||||
|
||||
|
||||
def _tro_edge_kind(edge: Edge) -> str:
|
||||
"""Materialkante -> Kantenart des TRO-Flusses (fuer Einfaerbung)."""
|
||||
if edge.note == "Weiche":
|
||||
@@ -620,23 +786,38 @@ def locate_tros(tros: list[Tro], elements: list[Element], graph: Graph) -> None:
|
||||
|
||||
|
||||
def analyse_tros(
|
||||
graph: Graph, elements: list[Element], overrides: Overrides | None = None
|
||||
graph: Graph,
|
||||
elements: list[Element],
|
||||
overrides: Overrides | None = None,
|
||||
*,
|
||||
resolve_host: Callable[[Element, list[str]], str | None] | None = None,
|
||||
count_exits: Callable[[list[str]], list[str]] | None = None,
|
||||
method: str | None = None,
|
||||
) -> Analysis:
|
||||
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen."""
|
||||
analysis = Analysis()
|
||||
analysis.overrides = overrides if overrides is not None else Overrides()
|
||||
analysis.tros = derive_tros(graph, elements, analysis.findings, analysis.overrides)
|
||||
if method is not None:
|
||||
analysis.method = method
|
||||
analysis.tros = derive_tros(graph, elements, analysis.findings, analysis.overrides,
|
||||
resolve_host=resolve_host, count_exits=count_exits)
|
||||
analysis.boundaries = derive_boundaries(elements, graph)
|
||||
# Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen:
|
||||
# link_tros() registriert eine Kante nur zu einem Knoten, der Host eines
|
||||
# Eintrags in der uebergebenen Liste ist - eine BTMT-Station muss also mit
|
||||
# in dieser Liste stehen, sonst bleibt sie unsichtbar (siehe derive_boundaries).
|
||||
linked = analysis.tros + analysis.boundaries
|
||||
# Verorten vor dem Verknuepfen: die Reihenfolge mehrerer TROs auf einer
|
||||
# Kreisel-Bahn wird ueber ihre Koordinate bestimmt.
|
||||
locate_tros(analysis.tros, elements, graph)
|
||||
analysis.edges, analysis.edge_kinds = link_tros(graph, analysis.tros)
|
||||
locate_tros(linked, elements, graph)
|
||||
analysis.edges, analysis.edge_kinds = link_tros(graph, linked)
|
||||
|
||||
# Vor Ort aufgenommene Topologie schlaegt die abgeleitete. Die Differenz
|
||||
# wird protokolliert, damit sichtbar bleibt, was die Ableitung falsch hatte.
|
||||
if analysis.overrides.connections:
|
||||
abgeleitet = set(analysis.edges)
|
||||
analysis.edges, analysis.edge_kinds = apply_connections(
|
||||
analysis.tros, analysis.overrides
|
||||
linked, analysis.overrides
|
||||
)
|
||||
gesetzt = set(analysis.edges)
|
||||
for src, dst in sorted(abgeleitet - gesetzt):
|
||||
@@ -688,14 +869,16 @@ def _tro_label(tro: Tro) -> str:
|
||||
|
||||
|
||||
def missing_positions(analysis: Analysis) -> list[str]:
|
||||
"""TROs ohne Koordinate - mit ihnen ist kein lagerichtiges Layout moeglich."""
|
||||
return [t.tro_id for t in analysis.tros if not t.position]
|
||||
"""TROs/Systemgrenzen ohne Koordinate - mit ihnen ist kein lagerichtiges
|
||||
Layout moeglich (auch eine Systemgrenze wird als Knoten gezeichnet)."""
|
||||
return [t.tro_id for t in analysis.tros + analysis.boundaries if not t.position]
|
||||
|
||||
|
||||
def _legend_pos(analysis: Analysis) -> tuple[float, float]:
|
||||
"""Legende links unterhalb der Anlage platzieren (mm)."""
|
||||
xs = [t.position[0] for t in analysis.tros if t.position]
|
||||
ys = [t.position[1] for t in analysis.tros if t.position]
|
||||
all_nodes = analysis.tros + analysis.boundaries
|
||||
xs = [t.position[0] for t in all_nodes if t.position]
|
||||
ys = [t.position[1] for t in all_nodes if t.position]
|
||||
if not xs:
|
||||
return (0.0, 0.0)
|
||||
return (min(xs) - LEGEND_MARGIN, min(ys) - LEGEND_MARGIN)
|
||||
@@ -764,6 +947,20 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str
|
||||
add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];')
|
||||
add("")
|
||||
|
||||
# Systemgrenzen (BTMT Be-/Entladestationen): kein Katalogtyp, eigene
|
||||
# Farbe/Form (ext-Stil, Ellipse statt Box) - deutlich als "kein TRO"
|
||||
# erkennbar, aber sichtbar als tatsaechlicher Anfang/Ende des Flusses.
|
||||
for boundary in analysis.boundaries:
|
||||
rendered = ", ".join(f'{k}="{v}"' for k, v in STYLE_EXT.dot_attrs().items())
|
||||
pos = ""
|
||||
if boundary.position:
|
||||
scale = COORD_SCALE if use_coords else 0.01
|
||||
pos = (f', pos="{boundary.position[0] * scale:.2f},'
|
||||
f'{boundary.position[1] * scale:.2f}"')
|
||||
add(f' "{boundary.tro_id}" [label="{_tro_label(boundary)}", '
|
||||
f'shape=ellipse, style=filled, {rendered}{pos}];')
|
||||
add("")
|
||||
|
||||
for src, dst in analysis.edges:
|
||||
add(f' "{src}" -> "{dst}" [{_tro_edge_attrs(analysis.edge_kinds.get((src, dst)))}];')
|
||||
|
||||
@@ -903,6 +1100,10 @@ def _tro_mermaid(analysis: Analysis) -> list[str]:
|
||||
label = f"{tro.tro_id}<br/>{tro.type_name}<br/>{_mermaid_text(tro.label)}"
|
||||
out.append(f' {tro.tro_id}["{label}"]:::{group}')
|
||||
|
||||
for boundary in analysis.boundaries:
|
||||
label = f"{boundary.tro_id}<br/>{boundary.type_name}<br/>{_mermaid_text(boundary.label)}"
|
||||
out.append(f' {boundary.tro_id}(["{label}"]):::ext')
|
||||
|
||||
for src, dst in analysis.edges:
|
||||
out.append(f" {src} --> {dst}")
|
||||
|
||||
@@ -935,6 +1136,7 @@ def render_doc(
|
||||
add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von "
|
||||
f"`lib/tro_flow.py` ")
|
||||
add(f"**Quelle:** `{csv_file}` ")
|
||||
add(f"**Methode:** {analysis.method} ")
|
||||
add(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})")
|
||||
add("")
|
||||
add("> Automatisch aus dem CSV-Export erzeugt. Die TRO-Typen sind aus dem "
|
||||
@@ -952,6 +1154,7 @@ def render_doc(
|
||||
add("| | |")
|
||||
add("|---|---|")
|
||||
add(f"| Ermittelte TROs | {len(analysis.tros)} |")
|
||||
add(f"| Systemgrenzen (Materialein-/ausgang) | {len(analysis.boundaries)} |")
|
||||
add(f"| TRO-Verbindungen | {len(analysis.edges)} |")
|
||||
add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |")
|
||||
add(f"| Hinweise | {len(analysis.findings)} |")
|
||||
@@ -1037,6 +1240,24 @@ def render_doc(
|
||||
add(f"Details: [`{error_file.name}`]({error_file.name})")
|
||||
add("")
|
||||
|
||||
if analysis.boundaries:
|
||||
add("### Systemgrenzen (Materialein-/ausgang)")
|
||||
add("")
|
||||
add("BTMT Be-/Entladestationen - kein TRO (kein Separator, kein "
|
||||
"FB-Baustein), aber der tatsaechliche Anfang bzw. das Ende des "
|
||||
"Materialflusses. Ohne diese Zeile wuerde die Verbindung an der "
|
||||
"Station stillschweigend enden (siehe `derive_boundaries` in "
|
||||
"`lib/tro_flow.py`).")
|
||||
add("")
|
||||
add("| Knoten | Typ | Bezeichnung | verbunden mit |")
|
||||
add("|---|---|---|---|")
|
||||
for boundary in analysis.boundaries:
|
||||
other = ", ".join(f"`{x}`" for x in
|
||||
sorted(set(boundary.successors) | set(boundary.predecessors))) or "-"
|
||||
add(f"| `{boundary.tro_id}` | `{boundary.type_name}` | "
|
||||
f"{boundary.label} | {other} |")
|
||||
add("")
|
||||
|
||||
add("## 5. Hinweise")
|
||||
add("")
|
||||
if analysis.findings:
|
||||
@@ -1107,6 +1328,8 @@ def render_doc(
|
||||
add("| Gefaellestrecke, einzelne Linie | `1Sep` |")
|
||||
add("| Strecke (angetrieben) | `Vario` (je Streckensegment) |")
|
||||
add("| Kreisel-Bahn | `1Sep` |")
|
||||
add(f"| Separator <= {SWITCH_PROXIMITY_MM:.0f}mm von einem Aus-/Einschleuselement "
|
||||
"| `1Sep1Swi` (Element uebernimmt die fehlende Weiche) |")
|
||||
add("| zusaetzlich: 2 bzw. 3 abgehende Wege | Aufwertung zu `1Sep1Swi` / `1Sep2Swi` |")
|
||||
add("")
|
||||
add("Ein **Scanner** am Separator aendert den Typ nicht. In der Referenzanlage "
|
||||
@@ -1167,6 +1390,7 @@ def report(
|
||||
print("TRO-FLUSS")
|
||||
print("================================================================")
|
||||
print(f"Eingabe = {csv_file}")
|
||||
print(f"Methode = {analysis.method}")
|
||||
print(f"TROs = {len(analysis.tros)}"
|
||||
+ (f" ({', '.join(f'{k}: {v}' for k, v in sorted(types_used.items()))})"
|
||||
if types_used else ""))
|
||||
@@ -1284,9 +1508,36 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
help="Dokumentation der TROs als Markdown in %%SKEL_RESULTS%% erzeugen "
|
||||
"(TRO-Liste, Verbindungen, Typdefinition).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geometry",
|
||||
action="store_true",
|
||||
help="Separator-Host und Weichen-Fanout per STRtree direkt aus den "
|
||||
"Bauteil-Weltkoordinaten bestimmen (lib/tro_geometry.py) statt "
|
||||
"aus den CSV-Spalten 'Zuordnung'/'Nachbarn' - unabhaengig davon, "
|
||||
"ob diese Spalten fuer die Eingabedatei luecken haben.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geo-tol-mm",
|
||||
type=float,
|
||||
default=tro_geometry_default_tolerance_mm(),
|
||||
metavar="MM",
|
||||
help="Nur mit --geometry: Toleranz (mm), innerhalb derer zwei "
|
||||
"Anschlusspunkte als derselbe Uebergabepunkt gelten. "
|
||||
"Standard: %(default)s",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def tro_geometry_default_tolerance_mm() -> float:
|
||||
"""Default fuer --geo-tol-mm; importiert tro_geometry nur bei Bedarf, damit
|
||||
--help/normale Laeufe ohne --geometry kein shapely brauchen."""
|
||||
try:
|
||||
from tro_geometry import DEFAULT_TOLERANCE_MM
|
||||
except ImportError:
|
||||
return 50.0
|
||||
return DEFAULT_TOLERANCE_MM
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
@@ -1320,14 +1571,28 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
resolve_host = count_exits = None
|
||||
method = None
|
||||
if args.geometry:
|
||||
try:
|
||||
from tro_geometry import make_geometric_resolvers
|
||||
except ImportError as exc:
|
||||
print(f"FEHLER: --geometry braucht shapely (pip install shapely): {exc}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
resolve_host, count_exits = make_geometric_resolvers(elements, tol_mm=args.geo_tol_mm)
|
||||
method = f"geometrisch (STRtree, Toleranz {args.geo_tol_mm:.0f}mm)"
|
||||
|
||||
overrides = load_overrides(csv_file.name, env_dir("SKEL_CFG", "cfg"))
|
||||
analysis = analyse_tros(graph, elements, overrides)
|
||||
analysis = analyse_tros(graph, elements, overrides,
|
||||
resolve_host=resolve_host, count_exits=count_exits,
|
||||
method=method)
|
||||
if not analysis.tros:
|
||||
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
|
||||
f"ableitbar.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
stem = csv_file.stem
|
||||
stem = csv_file.stem + ("_geo" if args.geometry else "")
|
||||
dot_file = results / f"{stem}_tro_flow.dot"
|
||||
svg_file = results / f"{stem}_tro_flow.svg"
|
||||
doc_target = results / f"{stem}_tro_doc.md"
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user