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
|
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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user