TRO-Typen an Kreisel-Beruehrpunkten (lib/kreisel_junctions.py, derive_tros Schritt 3c): jeder Kreisel als Stadion-Form mit Laufrichtung; jeder gleichlaufende Beruehrpunkt ist eine Weiche (exportierte ILS Weiche oder abgeleitet W<a>_<b>), Beruehrpunkte derselben Umlenkung bilden einen Knoten. Ein Separator steuert ihn, wenn er in Laufrichtung unmittelbar davor liegt (keine andere Weiche dazwischen). Typ = Separatoren x Weichen. export.csv: 1Sep1Swi (9/1, 9/8, 8/7), 2Sep1Swi (7/2), 2Sep2Swi (2/3/4), 3Sep2Swi (4/5/6). Neuer Katalogtyp 3Sep2Swi (nicht aus SCL belegt). Gegenlaeufige Beruehrpunkte (Mubea, Dematic) bleiben bei der alten Regel; Mubea/HundM05/Dematic unveraendert, Showroom 0018+0019 jetzt 2Sep1Swi. material_flow --track: Mouseover je Knoten mit Zugehoerigkeit zu Kreisel/VF/GF (Zuordnung, TrackIds-Kette, Lage laut Geometrie), Weiche mit beiden Kreiseln, Be-/Entladung mit ihrem Kreisel; Widersprueche mit "!". Zahlenwerte (Toleranzen, Schwellen, Massstaebe, Groessen) aus allen lib-Modulen nach cfg/parameter.ini (lib/parameter.py, Abschnitt je Modul, Ueberschreiben je Anlage ueber %SKEL_CFG%/parameter.ini). Ausgaben aller Anlagen bytegleich. tro_flow.py: Docstrings mit Parametern/Rueckgabe, fehlende Typangaben. Geprueft: 90 Tests gruen; tro_flow/material_flow/scl_skeleton auf 5 CSVs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
16 KiB
Python
392 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
kreisel_junctions.py - Weichenknoten zwischen Kreiseln aus der Geometrie.
|
|
|
|
Zwei Kreisel, die sich beruehren, bilden eine Weichenstelle: am Beruehrpunkt
|
|
laufen beide Umlaeufe in dieselbe Richtung, ein Traeger kann dort von einem
|
|
Kreisel auf den anderen wechseln. dxfmakros exportiert solche Stellen als
|
|
"ILS Weiche" (Merkmal "Erzeugt aus: Kreisel-Beruehrpunkt") - aber nicht
|
|
immer: in data/export.csv fehlen die Weichen zwischen Kreisel 9/8, 2/3 und
|
|
3/4, obwohl sich die Kreisel dort beruehren. Darum wird hier jede Weiche aus
|
|
der Kreisel-Geometrie bestimmt und eine exportierte ILS Weiche nur noch ihrem
|
|
Beruehrpunkt zugeordnet (Id und Name).
|
|
|
|
Geometrie
|
|
---------
|
|
Ein Kreisel ist eine Stadion-Form: zwei Umlenkungen (Halbkreise vom Radius
|
|
r) im Abstand L, verbunden durch die beiden geraden Bahnen. Mittelpunkt ist
|
|
die CSV-Position, die Laengsachse zeigt in Richtung "Drehung". r und L kommen
|
|
aus der Boundingbox (kurze Seite = 2r, lange Seite = L + 2r), bei schraeger
|
|
Lage aus dem Merkmal "Abstand (Kreiselachse A - Kreiselachse) in Meter".
|
|
Jeder Punkt auf dem Umlauf hat eine Laufposition s (mm) in Drehrichtung
|
|
("Drehrichtung" UZS/GUZ) - damit ist "stromauf"/"stromab" definiert.
|
|
|
|
Weichenknoten und steuernde Separatoren
|
|
---------------------------------------
|
|
Beruehrpunkte, die auf derselben Umlenkung eines Kreisels liegen, bilden
|
|
EINEN Weichenknoten (Kreisel 5 beruehrt mit seiner Umlenkung Kreisel 6 und
|
|
Kreisel 4 - zwei Weichen, eine Uebergabestelle). Ein Separator steuert den
|
|
Knoten, wenn er auf einem der beteiligten Kreisel unmittelbar stromauf vor
|
|
dem Knoten liegt: vom Knoten gegen die Drehrichtung zurueckgegangen, kommt
|
|
zuerst dieser Separator und keine andere Weiche. Liegt dazwischen eine
|
|
fremde Weiche, steuert der Separator diese, nicht den Knoten.
|
|
|
|
Aus der Anzahl steuernder Separatoren und Weichen ergibt sich der TRO-Typ
|
|
(tro_flow.derive_tros, Schritt 3c): 1Sep1Swi, 2Sep1Swi, 2Sep2Swi, 3Sep2Swi.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
|
|
from material_flow import (
|
|
LANE_LEFT,
|
|
LANE_RIGHT,
|
|
ROTATION_CCW,
|
|
ROTATION_CW,
|
|
Element,
|
|
_as_float,
|
|
_merkmal,
|
|
)
|
|
from parameter import get_float, get_int
|
|
|
|
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [kreisel_junctions] - Bedeutung
|
|
# und Einheit stehen dort (lib/parameter.py).
|
|
_P = "kreisel_junctions"
|
|
CONTACT_TOLERANCE = get_float(_P, "contact_tolerance")
|
|
SEPARATOR_TOLERANCE = get_float(_P, "separator_tolerance")
|
|
WEICHE_TOLERANCE = get_float(_P, "weiche_tolerance")
|
|
SAMPLE_STEP = get_float(_P, "sample_step")
|
|
MIN_SAMPLES = get_int(_P, "min_samples")
|
|
CONTACT_TIE = get_float(_P, "contact_tie")
|
|
MAX_CONTACT_LENGTH = get_float(_P, "max_contact_length")
|
|
END_TOLERANCE = get_float(_P, "end_tolerance")
|
|
DEFAULT_RADIUS = get_float(_P, "default_radius")
|
|
DIRECTION_STEP = get_float(_P, "direction_step")
|
|
GLEICHLAUF_MIN_COS = get_float(_P, "gleichlauf_min_cos")
|
|
|
|
# Abschnitte eines Umlaufs: die beiden Umlenkungen und die beiden Geraden.
|
|
END_PLUS = "+" # Umlenkung in Achsrichtung
|
|
END_MINUS = "-" # Umlenkung gegen die Achsrichtung
|
|
STRAIGHT = "gerade"
|
|
|
|
|
|
@dataclass
|
|
class Loop:
|
|
"""Umlauf eines Kreisels als Stadion-Form mit Laufrichtung.
|
|
|
|
Lokale Koordinaten: a entlang der Achse ("Drehung"), b quer dazu (links
|
|
positiv). Die Umlenkungen liegen bei a = +half und a = -half.
|
|
"""
|
|
|
|
kreisel: Element
|
|
cx: float
|
|
cy: float
|
|
ux: float # Achsrichtung (Einheitsvektor)
|
|
uy: float
|
|
half: float # halber Umlenkungsabstand L/2 (mm)
|
|
radius: float # Bahnabstand / 2 (mm)
|
|
clockwise: bool # True = UZS
|
|
|
|
@property
|
|
def length(self) -> float:
|
|
"""Umfang des Umlaufs (mm)."""
|
|
return 4 * self.half + 2 * math.pi * self.radius
|
|
|
|
def _local(self, x: float, y: float) -> tuple[float, float]:
|
|
"""Weltpunkt -> lokale Koordinaten (a, b)."""
|
|
dx, dy = x - self.cx, y - self.cy
|
|
return dx * self.ux + dy * self.uy, -dx * self.uy + dy * self.ux
|
|
|
|
def lane(self, x: float, y: float) -> str:
|
|
"""Bahn eines Punkts: LANE_LEFT links der Achse, sonst LANE_RIGHT
|
|
(dieselbe Konvention wie tro_flow._kreisel_bahn)."""
|
|
_a, b = self._local(x, y)
|
|
return LANE_LEFT if b > 0 else LANE_RIGHT
|
|
|
|
def project(self, x: float, y: float) -> tuple[float, float, str]:
|
|
"""Naechster Punkt auf dem Umlauf.
|
|
|
|
Parameter:
|
|
x, y: Weltpunkt (mm).
|
|
|
|
Rueckgabe:
|
|
(s, Abstand, Abschnitt): s = Laufposition in Drehrichtung
|
|
(0 <= s < length), Abstand des Punkts vom Umlauf (mm), Abschnitt
|
|
END_PLUS, END_MINUS oder STRAIGHT.
|
|
"""
|
|
a, b = self._local(x, y)
|
|
h, r = self.half, self.radius
|
|
cands: list[tuple[float, float, str]] = []
|
|
# Gegen den Uhrzeigersinn (lokal): untere Gerade (b=-r) in +a, rechte
|
|
# Umlenkung, obere Gerade (b=+r) in -a, linke Umlenkung.
|
|
if -h <= a <= h:
|
|
cands.append((abs(b + r), a + h, STRAIGHT))
|
|
cands.append((abs(b - r), 2 * h + math.pi * r + (h - a), STRAIGHT))
|
|
if a >= h:
|
|
theta = math.atan2(b, a - h) # -pi/2 .. pi/2
|
|
cands.append((abs(math.hypot(a - h, b) - r),
|
|
2 * h + r * (theta + math.pi / 2), END_PLUS))
|
|
if a <= -h:
|
|
phi = math.atan2(b, a + h) % (2 * math.pi) # pi/2 .. 3pi/2
|
|
cands.append((abs(math.hypot(a + h, b) - r),
|
|
4 * h + math.pi * r + r * (phi - math.pi / 2), END_MINUS))
|
|
if not cands: # Innenpunkt nahe Achse
|
|
cands.append((abs(b + r), a + h, STRAIGHT))
|
|
dist, s_ccw, section = min(cands)
|
|
if section == STRAIGHT and a >= h - END_TOLERANCE:
|
|
section = END_PLUS
|
|
elif section == STRAIGHT and a <= -h + END_TOLERANCE:
|
|
section = END_MINUS
|
|
s = (self.length - s_ccw) % self.length if self.clockwise else s_ccw % self.length
|
|
return s, dist, section
|
|
|
|
def point(self, s: float) -> tuple[float, float]:
|
|
"""Weltpunkt zur Laufposition s (Umkehrung von project)."""
|
|
s_ccw = (self.length - s) % self.length if self.clockwise else s % self.length
|
|
h, r = self.half, self.radius
|
|
if s_ccw < 2 * h:
|
|
a, b = s_ccw - h, -r
|
|
elif s_ccw < 2 * h + math.pi * r:
|
|
theta = (s_ccw - 2 * h) / r - math.pi / 2
|
|
a, b = h + r * math.cos(theta), r * math.sin(theta)
|
|
elif s_ccw < 4 * h + math.pi * r:
|
|
a, b = h - (s_ccw - 2 * h - math.pi * r), r
|
|
else:
|
|
phi = (s_ccw - 4 * h - math.pi * r) / r + math.pi / 2
|
|
a, b = -h + r * math.cos(phi), r * math.sin(phi)
|
|
return (self.cx + a * self.ux - b * self.uy, self.cy + a * self.uy + b * self.ux)
|
|
|
|
def direction(self, s: float) -> tuple[float, float]:
|
|
"""Laufrichtung (Einheitsvektor) an der Laufposition s."""
|
|
x0, y0 = self.point(s - DIRECTION_STEP)
|
|
x1, y1 = self.point(s + DIRECTION_STEP)
|
|
n = math.hypot(x1 - x0, y1 - y0) or 1.0
|
|
return (x1 - x0) / n, (y1 - y0) / n
|
|
|
|
|
|
def kreisel_loop(kreisel: Element) -> Loop | None:
|
|
"""Umlauf eines Kreisels aus Position, Drehung, Boundingbox, Drehrichtung.
|
|
|
|
Parameter:
|
|
kreisel: Element mit kind "Kreisel".
|
|
|
|
Rueckgabe:
|
|
Loop oder None, wenn Position, Drehrichtung oder Abmessungen fehlen.
|
|
"""
|
|
if kreisel.x is None or kreisel.y is None:
|
|
return None
|
|
if kreisel.rotation not in (ROTATION_CW, ROTATION_CCW):
|
|
return None
|
|
drehung = _as_float(_merkmal(kreisel.merkmale, "Drehung")) or 0.0
|
|
rad = math.radians(drehung)
|
|
bw, bh = kreisel.bbox_x, kreisel.bbox_y
|
|
if bw and bh and abs(drehung) % 90 < 1e-6:
|
|
short, long_ = min(bw, bh), max(bw, bh)
|
|
radius, half = short / 2, (long_ - short) / 2
|
|
else:
|
|
abstand = _as_float(_merkmal(
|
|
kreisel.merkmale, "Abstand (Kreiselachse A - Kreiselachse) in Meter"))
|
|
if abstand is None:
|
|
return None
|
|
radius, half = DEFAULT_RADIUS, abstand * 1000 / 2
|
|
return Loop(kreisel, kreisel.x, kreisel.y, math.cos(rad), math.sin(rad),
|
|
half, radius, kreisel.rotation == ROTATION_CW)
|
|
|
|
|
|
@dataclass
|
|
class Contact:
|
|
"""Beruehrpunkt zweier Kreisel = eine Weiche."""
|
|
|
|
weiche_id: str # TeileId der ILS Weiche oder "W<a>_<b>"
|
|
kreisel: tuple[str, str] # beide Kreisel-Ids, sortiert
|
|
x: float
|
|
y: float
|
|
exported: bool # True = als ILS Weiche im CSV
|
|
s: dict[str, float] = field(default_factory=dict) # Kreisel -> s
|
|
section: dict[str, str] = field(default_factory=dict) # Kreisel -> Abschnitt
|
|
|
|
|
|
@dataclass
|
|
class Junction:
|
|
"""Weichenknoten: Beruehrpunkte auf derselben Umlenkung, plus Steuerung."""
|
|
|
|
contacts: list[Contact]
|
|
kreisel: list[str] # beteiligte Kreisel
|
|
separators: list[str] = field(default_factory=list) # steuernde Separatoren
|
|
sep_kreisel: dict[str, str] = field(default_factory=dict) # Separator -> Kreisel
|
|
sep_lane: dict[str, str] = field(default_factory=dict) # Separator -> "<Id>-L/R"
|
|
|
|
@property
|
|
def type_name(self) -> str:
|
|
"""TRO-Typ aus Separator- und Weichenzahl, z. B. "2Sep1Swi"."""
|
|
return f"{len(self.separators)}Sep{len(self.contacts)}Swi"
|
|
|
|
|
|
def _find_contact(a: Loop, b: Loop) -> tuple[float, float, float] | None:
|
|
"""Beruehrpunkt (x, y, Laenge des Beruehrbereichs) oder None."""
|
|
steps = max(int(a.length / SAMPLE_STEP), MIN_SAMPLES)
|
|
near: list[tuple[float, float, float]] = []
|
|
for i in range(steps):
|
|
x, y = a.point(i * a.length / steps)
|
|
_s, dist, _sec = b.project(x, y)
|
|
if dist <= CONTACT_TOLERANCE:
|
|
near.append((dist, x, y))
|
|
if not near:
|
|
return None
|
|
# Bei tangentialer Beruehrung sind viele Abtastpunkte fast gleich nah -
|
|
# der Mittelpunkt der naechsten liegt am eigentlichen Beruehrpunkt.
|
|
best = min(d for d, _x, _y in near)
|
|
mitte = [(x, y) for d, x, y in near if d <= best + CONTACT_TIE]
|
|
x = sum(p[0] for p in mitte) / len(mitte)
|
|
y = sum(p[1] for p in mitte) / len(mitte)
|
|
return x, y, len(near) * a.length / steps
|
|
|
|
|
|
def find_contacts(elements: list[Element], findings: list[str]) -> tuple[dict[str, Loop], list[Contact]]:
|
|
"""Alle Beruehrpunkte zwischen Kreiseln, mit Zuordnung der ILS Weichen.
|
|
|
|
Parameter:
|
|
elements: alle Elemente des CSV-Exports (Kreisel, ILS Weichen).
|
|
findings: Fundliste; Hinweise zu fehlenden/gegenlaeufigen Weichen.
|
|
|
|
Rueckgabe:
|
|
(Kreisel-Id -> Loop, Beruehrpunkte). Ein Beruehrpunkt ohne
|
|
exportierte ILS Weiche bekommt die Id "W<KreiselA>_<KreiselB>".
|
|
"""
|
|
loops = {e.teile_id: lp for e in elements if e.kind == "Kreisel"
|
|
and (lp := kreisel_loop(e)) is not None}
|
|
weichen = [e for e in elements if e.kind == "Weiche"]
|
|
ids = sorted(loops)
|
|
contacts: list[Contact] = []
|
|
for i, ka in enumerate(ids):
|
|
for kb in ids[i + 1:]:
|
|
hit = _find_contact(loops[ka], loops[kb])
|
|
if hit is None:
|
|
continue
|
|
x, y, laenge = hit
|
|
if laenge > MAX_CONTACT_LENGTH:
|
|
findings.append(
|
|
f"Kreisel {ka}/{kb}: beruehren sich auf {laenge:.0f}mm Laenge "
|
|
f"(parallele Bahnen?) - keine Weiche angenommen")
|
|
continue
|
|
c = Contact("", (ka, kb), x, y, False)
|
|
for k in (ka, kb):
|
|
c.s[k], _d, c.section[k] = loops[k].project(x, y)
|
|
da = loops[ka].direction(c.s[ka])
|
|
db = loops[kb].direction(c.s[kb])
|
|
if da[0] * db[0] + da[1] * db[1] < GLEICHLAUF_MIN_COS:
|
|
findings.append(
|
|
f"Kreisel {ka}/{kb}: Beruehrpunkt bei {x:.0f}, {y:.0f} laeuft "
|
|
f"gegenlaeufig (Drehrichtung pruefen) - kein Weichenknoten")
|
|
continue
|
|
contacts.append(c)
|
|
|
|
for c in contacts:
|
|
passend = [w for w in weichen if w.x is not None and w.y is not None
|
|
and math.hypot(w.x - c.x, w.y - c.y) <= WEICHE_TOLERANCE]
|
|
if passend:
|
|
w = min(passend, key=lambda w: math.hypot(w.x - c.x, w.y - c.y))
|
|
c.weiche_id, c.exported = w.teile_id, True
|
|
else:
|
|
c.weiche_id = f"W{c.kreisel[0]}_{c.kreisel[1]}"
|
|
findings.append(
|
|
f"Kreisel {c.kreisel[0]}/{c.kreisel[1]}: Beruehrpunkt bei "
|
|
f"{c.x:.0f}, {c.y:.0f} ohne ILS Weiche im Export - als Weiche "
|
|
f"{c.weiche_id} angenommen")
|
|
# ILS Weichen ohne passenden Beruehrpunkt (z. B. an gegenlaeufigen
|
|
# Kreiseln) bleiben unberuehrt - sie wertet tro_flow wie bisher ueber ihre
|
|
# CSV-Nachbarn aus (_find_weiche_separators).
|
|
return loops, contacts
|
|
|
|
|
|
def _group(contacts: list[Contact]) -> list[list[Contact]]:
|
|
"""Beruehrpunkte auf derselben Umlenkung eines Kreisels zusammenfassen."""
|
|
parent = list(range(len(contacts)))
|
|
|
|
def root(i: int) -> int:
|
|
while parent[i] != i:
|
|
parent[i] = parent[parent[i]]
|
|
i = parent[i]
|
|
return i
|
|
|
|
seen: dict[tuple[str, str], int] = {}
|
|
for i, c in enumerate(contacts):
|
|
for k, section in c.section.items():
|
|
if section == STRAIGHT:
|
|
continue
|
|
j = seen.setdefault((k, section), i)
|
|
parent[root(i)] = root(j)
|
|
groups: dict[int, list[Contact]] = {}
|
|
for i, c in enumerate(contacts):
|
|
groups.setdefault(root(i), []).append(c)
|
|
return list(groups.values())
|
|
|
|
|
|
def find_junctions(
|
|
elements: list[Element],
|
|
findings: list[str],
|
|
separators: list[Element] | None = None,
|
|
) -> list[Junction]:
|
|
"""Weichenknoten zwischen Kreiseln mit ihren steuernden Separatoren.
|
|
|
|
Parameter:
|
|
elements: alle Elemente des CSV-Exports.
|
|
findings: Fundliste (siehe find_contacts).
|
|
separators: Separatoren, die steuern duerfen; None = alle.
|
|
|
|
Rueckgabe:
|
|
Weichenknoten, sortiert nach erster Weichen-Id. Knoten ohne
|
|
steuernden Separator sind enthalten (separators leer) - der Aufrufer
|
|
entscheidet, was damit geschieht.
|
|
"""
|
|
loops, contacts = find_contacts(elements, findings)
|
|
if separators is None:
|
|
separators = [e for e in elements if e.kind == "Separator"]
|
|
|
|
# Separator -> (Kreisel, s): auf dem naechstgelegenen Umlauf
|
|
sep_pos: dict[str, tuple[str, float]] = {}
|
|
sep_lane: dict[str, str] = {}
|
|
for sep in separators:
|
|
if sep.x is None or sep.y is None:
|
|
continue
|
|
best: tuple[float, str, float] | None = None
|
|
for k, lp in loops.items():
|
|
s, dist, _sec = lp.project(sep.x, sep.y)
|
|
if dist <= SEPARATOR_TOLERANCE and (best is None or dist < best[0]):
|
|
best = (dist, k, s)
|
|
if best is not None:
|
|
sep_pos[sep.teile_id] = (best[1], best[2])
|
|
sep_lane[sep.teile_id] = f"{best[1]}-{loops[best[1]].lane(sep.x, sep.y)}"
|
|
|
|
junctions: list[Junction] = []
|
|
for group in _group(contacts):
|
|
kreisel = sorted({k for c in group for k in c.kreisel})
|
|
j = Junction(sorted(group, key=lambda c: c.weiche_id), kreisel)
|
|
in_group = {id(c) for c in group}
|
|
for k in kreisel:
|
|
# Ereignisse auf Kreisel k: (s, Art, Id); Art "sep" oder "swi"
|
|
events = [(s, "sep", sid) for sid, (kk, s) in sep_pos.items() if kk == k]
|
|
events += [(c.s[k], "own" if id(c) in in_group else "swi", c.weiche_id)
|
|
for c in contacts if k in c.s]
|
|
events.sort()
|
|
n = len(events)
|
|
for idx, (_s, art, _id) in enumerate(events):
|
|
if art != "own":
|
|
continue
|
|
# gegen die Laufrichtung zurueck bis zum ersten fremden Ereignis
|
|
for step in range(1, n):
|
|
_ps, part, pid = events[(idx - step) % n]
|
|
if part == "own":
|
|
continue
|
|
if part == "sep" and pid not in j.separators:
|
|
j.separators.append(pid)
|
|
j.sep_kreisel[pid] = k
|
|
j.sep_lane[pid] = sep_lane[pid]
|
|
break
|
|
j.separators.sort()
|
|
junctions.append(j)
|
|
junctions.sort(key=lambda j: j.contacts[0].weiche_id)
|
|
return junctions
|