TRO flow svg file can be created.

This commit is contained in:
2026-09-10 11:29:22 +02:00
parent 6fda4951e1
commit 4bab45705d
4 changed files with 738 additions and 30 deletions
+106 -5
View File
@@ -769,8 +769,23 @@ def _nearest_node(station: Element, nodes: dict[str, Node]) -> str | None:
return best 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( 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]: ) -> list[Edge]:
""" """
Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden. Ein-/Ausgabestationen (BTMT Be-/Entladung) mit dem Fluss verbinden.
@@ -778,7 +793,11 @@ def _attach_stations(
Eingang (Beladung) ist Quelle: Kante Station -> Ziel. Eingang (Beladung) ist Quelle: Kante Station -> Ziel.
Ausgang (Entladung) ist Senke: Kante Ziel -> Station. Ausgang (Entladung) ist Senke: Kante Ziel -> Station.
Ziel kommt aus den 'Nachbarn' des Exports; fehlen sie (dxfmakros exportiert 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] = [] edges: list[Edge] = []
for element in elements: for element in elements:
@@ -796,6 +815,18 @@ def _attach_stations(
else: else:
partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT) partners += [f"{base}-{L}" for L in (LANE_LEFT, LANE_RIGHT)
if f"{base}-{L}" in nodes] 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: if not partners:
nearest = _nearest_node(element, nodes) nearest = _nearest_node(element, nodes)
if nearest: 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 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( def _apply_transfers(
elements: list[Element], by_id: dict[str, Element], elements: list[Element], by_id: dict[str, Element],
nodes: dict[str, Node], edges: list[Edge], warnings: list[str] nodes: dict[str, Node], edges: list[Edge], warnings: list[str]
@@ -836,7 +891,10 @@ def _apply_transfers(
Ausschleuselement: Kreisel -> Strecke (Streckenanfang). Ausschleuselement: Kreisel -> Strecke (Streckenanfang).
Einschleuselement: Strecke -> Kreisel (Streckenende). 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/ Die so definierten Kanten sind massgeblich und ersetzen die aus Hoehe/
Nachbarn abgeleitete Richtung fuer genau diese Knotenpaare. Nachbarn abgeleitete Richtung fuer genau diese Knotenpaare.
""" """
@@ -844,6 +902,7 @@ def _apply_transfers(
# ein = Auslauf (Strecke->Kreisel, Ende) # ein = Auslauf (Strecke->Kreisel, Ende)
per_strecke: dict[str, dict[str, str]] = {} per_strecke: dict[str, dict[str, str]] = {}
weiche_pairs: list[set[str]] = [] weiche_pairs: list[set[str]] = []
weiche_ab: list[tuple[str, str]] = []
for el in elements: for el in elements:
if el.kind in ("Ausschleus", "Einschleus"): if el.kind in ("Ausschleus", "Einschleus"):
k = str(_merkmal(el.merkmale, "Kreisel") or "").strip() k = str(_merkmal(el.merkmale, "Kreisel") or "").strip()
@@ -856,6 +915,28 @@ def _apply_transfers(
b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip() b = str(_merkmal(el.merkmale, "Kreisel B") or "").strip()
if a and b: if a and b:
weiche_pairs.append({a, 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]] = {} directed: dict[frozenset, tuple[str, str]] = {}
for s, roles in per_strecke.items(): for s, roles in per_strecke.items():
@@ -885,9 +966,13 @@ def _apply_transfers(
# einem dritten Kreisel liegt) ist ein Falschtreffer und wird verworfen. # einem dritten Kreisel liegt) ist ein Falschtreffer und wird verworfen.
covered = set(per_strecke) covered = set(per_strecke)
result: list[Edge] = [] result: list[Edge] = []
superseded: set[tuple[str, str]] = set()
for e in edges: for e in edges:
if frozenset({e.src, e.dst}) in directed: if frozenset({e.src, e.dst}) in directed:
continue # wird gerichtet neu gesetzt 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} ends = {e.src, e.dst}
strecke_end = ends & covered strecke_end = ends & covered
others = ends - strecke_end others = ends - strecke_end
@@ -902,7 +987,23 @@ def _apply_transfers(
result.append(e) result.append(e)
for (src, dst, note) in directed.values(): for (src, dst, note) in directed.values():
result.append(Edge(src, dst, EDGE_FLOW, note)) 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: for e in result:
if {e.src.split("-")[0], e.dst.split("-")[0]} in weiche_pairs: if {e.src.split("-")[0], e.dst.split("-")[0]} in weiche_pairs:
e.note = "Weiche" 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")) edges.append(Edge(right, left, EDGE_CIRCLE, "unbestimmt"))
# Ein-/Ausgabestationen (BTMT Be-/Entladung) als Quelle/Senke anbinden # 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) # Kreisel<->Strecke gerichtet aus den Transferelementen (massgeblich)
edges = _apply_transfers(elements, by_id, nodes, edges, warnings) edges = _apply_transfers(elements, by_id, nodes, edges, warnings)
+290 -25
View File
@@ -60,6 +60,7 @@ import argparse
import csv import csv
import math import math
import sys import sys
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -88,6 +89,7 @@ from material_flow import (
from tro_catalog import ( from tro_catalog import (
CATALOG_AS_OF, CATALOG_AS_OF,
ITEM_FB, ITEM_FB,
STYLE_EXT,
STYLES, STYLES,
TRO_CATALOG, TRO_CATALOG,
TroDefinition, TroDefinition,
@@ -111,6 +113,14 @@ from tro_overrides import (
# einem Separator) wird ein Speicherblock (PinStore_Auto) angenommen. # einem Separator) wird ein Speicherblock (PinStore_Auto) angenommen.
MIN_STORE_LANES = 2 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 -->" GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->"
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in # Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
@@ -187,9 +197,14 @@ class Analysis:
findings: list[str] = field(default_factory=list) findings: list[str] = field(default_factory=list)
# Manuelle Korrekturen aus cfg/tro_overrides.ini (Typkorrekturen, offene Punkte) # Manuelle Korrekturen aus cfg/tro_overrides.ini (Typkorrekturen, offene Punkte)
overrides: Overrides = field(default_factory=Overrides) 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: 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: if tro.tro_id == tro_id:
return tro return tro
return None 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( def derive_tros(
graph: Graph, graph: Graph,
elements: list[Element], elements: list[Element],
findings: list[str], findings: list[str],
overrides: Overrides | None = None, 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]: ) -> 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( separators = sorted(
(e for e in elements if e.kind == "Separator"), key=lambda e: e.teile_id (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 (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] = {} sep_host: dict[str, str] = {}
for sep in separators: for sep in separators:
host = sep.assignment if sep.teile_id in switch_hits:
if host and host != UNASSIGNED and host in graph.nodes: continue
host = resolve_host(sep, findings)
if host is not None:
sep_host[sep.teile_id] = host 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, # Scanner -> Separator; "Naechster Separator" ist die funktionale Zuordnung,
# "Zuordnung" nur der Montageort. # "Zuordnung" nur der Montageort.
sep_scanners: dict[str, list[str]] = {} sep_scanners: dict[str, list[str]] = {}
for scanner in scanners: for scanner in scanners:
target = str(_merkmal(scanner.merkmale, "Naechster Separator") or "").strip() 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) sep_scanners.setdefault(target, []).append(scanner.teile_id)
mounted = scanner.assignment 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( findings.append(
f"Scanner {scanner.teile_id} '{scanner.name}': montiert an {mounted}, " 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: else:
findings.append( 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 # 4) Weichen-Aufwertung: 2 bzw. 3 abgehende Flusskanten
for tro in tros: for tro in tros:
if tro.type_name != "1Sep": if tro.type_name != "1Sep":
continue continue
exits = _switch_exits(tro.hosts, graph) exits = count_exits(tro.hosts)
if not 2 <= len(exits) <= 3: if not 2 <= len(exits) <= 3:
continue continue
upgraded = "1Sep1Swi" if len(exits) == 2 else "1Sep2Swi" upgraded = "1Sep1Swi" if len(exits) == 2 else "1Sep2Swi"
@@ -443,6 +555,60 @@ def derive_tros(
return 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: def _tro_edge_kind(edge: Edge) -> str:
"""Materialkante -> Kantenart des TRO-Flusses (fuer Einfaerbung).""" """Materialkante -> Kantenart des TRO-Flusses (fuer Einfaerbung)."""
if edge.note == "Weiche": if edge.note == "Weiche":
@@ -620,23 +786,38 @@ def locate_tros(tros: list[Tro], elements: list[Element], graph: Graph) -> None:
def analyse_tros( 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: ) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.""" """TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen."""
analysis = Analysis() analysis = Analysis()
analysis.overrides = overrides if overrides is not None else Overrides() 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 # Verorten vor dem Verknuepfen: die Reihenfolge mehrerer TROs auf einer
# Kreisel-Bahn wird ueber ihre Koordinate bestimmt. # Kreisel-Bahn wird ueber ihre Koordinate bestimmt.
locate_tros(analysis.tros, elements, graph) locate_tros(linked, elements, graph)
analysis.edges, analysis.edge_kinds = link_tros(graph, analysis.tros) analysis.edges, analysis.edge_kinds = link_tros(graph, linked)
# Vor Ort aufgenommene Topologie schlaegt die abgeleitete. Die Differenz # Vor Ort aufgenommene Topologie schlaegt die abgeleitete. Die Differenz
# wird protokolliert, damit sichtbar bleibt, was die Ableitung falsch hatte. # wird protokolliert, damit sichtbar bleibt, was die Ableitung falsch hatte.
if analysis.overrides.connections: if analysis.overrides.connections:
abgeleitet = set(analysis.edges) abgeleitet = set(analysis.edges)
analysis.edges, analysis.edge_kinds = apply_connections( analysis.edges, analysis.edge_kinds = apply_connections(
analysis.tros, analysis.overrides linked, analysis.overrides
) )
gesetzt = set(analysis.edges) gesetzt = set(analysis.edges)
for src, dst in sorted(abgeleitet - gesetzt): for src, dst in sorted(abgeleitet - gesetzt):
@@ -688,14 +869,16 @@ def _tro_label(tro: Tro) -> str:
def missing_positions(analysis: Analysis) -> list[str]: def missing_positions(analysis: Analysis) -> list[str]:
"""TROs ohne Koordinate - mit ihnen ist kein lagerichtiges Layout moeglich.""" """TROs/Systemgrenzen ohne Koordinate - mit ihnen ist kein lagerichtiges
return [t.tro_id for t in analysis.tros if not t.position] 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]: def _legend_pos(analysis: Analysis) -> tuple[float, float]:
"""Legende links unterhalb der Anlage platzieren (mm).""" """Legende links unterhalb der Anlage platzieren (mm)."""
xs = [t.position[0] for t in analysis.tros if t.position] all_nodes = analysis.tros + analysis.boundaries
ys = [t.position[1] for t in analysis.tros if t.position] 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: if not xs:
return (0.0, 0.0) return (0.0, 0.0)
return (min(xs) - LEGEND_MARGIN, min(ys) - LEGEND_MARGIN) 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(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];')
add("") 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: for src, dst in analysis.edges:
add(f' "{src}" -> "{dst}" [{_tro_edge_attrs(analysis.edge_kinds.get((src, dst)))}];') 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)}" label = f"{tro.tro_id}<br/>{tro.type_name}<br/>{_mermaid_text(tro.label)}"
out.append(f' {tro.tro_id}["{label}"]:::{group}') 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: for src, dst in analysis.edges:
out.append(f" {src} --> {dst}") out.append(f" {src} --> {dst}")
@@ -935,6 +1136,7 @@ def render_doc(
add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von " add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von "
f"`lib/tro_flow.py` ") f"`lib/tro_flow.py` ")
add(f"**Quelle:** `{csv_file}` ") add(f"**Quelle:** `{csv_file}` ")
add(f"**Methode:** {analysis.method} ")
add(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})") add(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})")
add("") add("")
add("> Automatisch aus dem CSV-Export erzeugt. Die TRO-Typen sind aus dem " add("> Automatisch aus dem CSV-Export erzeugt. Die TRO-Typen sind aus dem "
@@ -952,6 +1154,7 @@ def render_doc(
add("| | |") add("| | |")
add("|---|---|") add("|---|---|")
add(f"| Ermittelte TROs | {len(analysis.tros)} |") add(f"| Ermittelte TROs | {len(analysis.tros)} |")
add(f"| Systemgrenzen (Materialein-/ausgang) | {len(analysis.boundaries)} |")
add(f"| TRO-Verbindungen | {len(analysis.edges)} |") add(f"| TRO-Verbindungen | {len(analysis.edges)} |")
add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |") add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |")
add(f"| Hinweise | {len(analysis.findings)} |") add(f"| Hinweise | {len(analysis.findings)} |")
@@ -1037,6 +1240,24 @@ def render_doc(
add(f"Details: [`{error_file.name}`]({error_file.name})") add(f"Details: [`{error_file.name}`]({error_file.name})")
add("") 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("## 5. Hinweise")
add("") add("")
if analysis.findings: if analysis.findings:
@@ -1107,6 +1328,8 @@ def render_doc(
add("| Gefaellestrecke, einzelne Linie | `1Sep` |") add("| Gefaellestrecke, einzelne Linie | `1Sep` |")
add("| Strecke (angetrieben) | `Vario` (je Streckensegment) |") add("| Strecke (angetrieben) | `Vario` (je Streckensegment) |")
add("| Kreisel-Bahn | `1Sep` |") 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("| zusaetzlich: 2 bzw. 3 abgehende Wege | Aufwertung zu `1Sep1Swi` / `1Sep2Swi` |")
add("") add("")
add("Ein **Scanner** am Separator aendert den Typ nicht. In der Referenzanlage " add("Ein **Scanner** am Separator aendert den Typ nicht. In der Referenzanlage "
@@ -1167,6 +1390,7 @@ def report(
print("TRO-FLUSS") print("TRO-FLUSS")
print("================================================================") print("================================================================")
print(f"Eingabe = {csv_file}") print(f"Eingabe = {csv_file}")
print(f"Methode = {analysis.method}")
print(f"TROs = {len(analysis.tros)}" print(f"TROs = {len(analysis.tros)}"
+ (f" ({', '.join(f'{k}: {v}' for k, v in sorted(types_used.items()))})" + (f" ({', '.join(f'{k}: {v}' for k, v in sorted(types_used.items()))})"
if types_used else "")) 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 " help="Dokumentation der TROs als Markdown in %%SKEL_RESULTS%% erzeugen "
"(TRO-Liste, Verbindungen, Typdefinition).", "(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) 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: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) 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) print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte.", file=sys.stderr)
return 1 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")) 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: if not analysis.tros:
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs " print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
f"ableitbar.", file=sys.stderr) f"ableitbar.", file=sys.stderr)
return 1 return 1
stem = csv_file.stem stem = csv_file.stem + ("_geo" if args.geometry else "")
dot_file = results / f"{stem}_tro_flow.dot" dot_file = results / f"{stem}_tro_flow.dot"
svg_file = results / f"{stem}_tro_flow.svg" svg_file = results / f"{stem}_tro_flow.svg"
doc_target = results / f"{stem}_tro_doc.md" doc_target = results / f"{stem}_tro_doc.md"
+338
View File
@@ -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
+4
View File
@@ -6,6 +6,10 @@ pydantic >= 2.0.0
# DXF lesen und schreiben (tro_annotate). DWG wird nicht unterstuetzt. # DXF lesen und schreiben (tro_annotate). DWG wird nicht unterstuetzt.
ezdxf >= 1.4.0 ezdxf >= 1.4.0
# STRtree fuer die geometrische TRO-Ableitung (tro_flow.py --geometry, siehe
# lib/tro_geometry.py) - nur zur Laufzeit von --geometry benoetigt.
shapely >= 2.1
# Nur fuer Vorschaubilder eines DXF (ezdxf.addons.drawing) - optional: # Nur fuer Vorschaubilder eines DXF (ezdxf.addons.drawing) - optional:
# matplotlib >= 3.8 # matplotlib >= 3.8