Weichenknoten zwischen Kreiseln, Track-Mouseover, Parameter in cfg/parameter.ini

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>
This commit is contained in:
Samer Ayadi
2026-10-14 17:20:30 +02:00
co-authored by Claude Opus 5.5
parent 0c69faf3db
commit 550bdeb464
17 changed files with 1634 additions and 125 deletions
+11 -12
View File
@@ -58,6 +58,8 @@ from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from parameter import get_float, get_int
__all__ = [
"Transform",
"RegistrationError",
@@ -84,18 +86,15 @@ ANCHOR_BLOCKS: dict[str, tuple[str, ...]] = {
"Scanner": ("SCAN", "Scanner_*"),
}
# Rastergroesse der Abstimmung in mm
VOTE_GRID = 100.0
# So viele Kandidaten der Abstimmung werden ausgeglichen und bewertet
VOTE_CANDIDATES = 6
# Nur Paare bis zu diesem Abstand gehen in den Ausgleich ein (exakte Treffer)
FIT_TOL = 50.0
# Punkte gelten als zugeordnet, wenn sie nach der Verschiebung so nah liegen
DEFAULT_MATCH_TOL = 600.0
# So viele exakte Treffer muessen es mindestens sein
MIN_INLIERS = 5
# Ab dieser Drehung wird gewarnt (Grad)
ROTATION_WARN_DEG = 0.05
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [dxf_registration] - Bedeutung
# und Einheit stehen dort (lib/parameter.py).
_P = "dxf_registration"
VOTE_GRID = get_float(_P, "vote_grid")
VOTE_CANDIDATES = get_int(_P, "vote_candidates")
FIT_TOL = get_float(_P, "fit_tol")
DEFAULT_MATCH_TOL = get_float(_P, "default_match_tol")
MIN_INLIERS = get_int(_P, "min_inliers")
ROTATION_WARN_DEG = get_float(_P, "rotation_warn_deg")
class RegistrationError(RuntimeError):
+7 -2
View File
@@ -60,6 +60,7 @@ from dxf_registration import (
save_transform,
verify_transform,
)
from parameter import get_float
__all__ = [
"EPLAN_SCHEMA",
@@ -78,6 +79,10 @@ __all__ = [
EPLAN_SCHEMA = "sps_skel-eplan-v1"
RULES_FILE = "e_planning.ini"
# Zulaessige Abweichung vom Sollabstand, wenn eine Regel kein toleranz_mm
# angibt - aus cfg/parameter.ini [e_planning] (lib/parameter.py).
DEFAULT_TOLERANCE_MM = get_float("e_planning", "toleranz_mm")
_CONFIG = ConfigDict(extra="forbid")
# "=A1+UH01-KF107" -> Ort/Steuerung "UH01", Geraet "KF107"
@@ -153,7 +158,7 @@ class Rule:
# Sollabstand: das Symbol liegt immer gleich weit neben dem Bauteil.
# Dann zaehlt die Abweichung vom Soll, nicht der kleinste Abstand.
target_mm: float | None = None
tolerance_mm: float = 150.0
tolerance_mm: float = DEFAULT_TOLERANCE_MM
def score(self, dist: float) -> float | None:
"""Rangfolge eines Kandidaten (kleiner = besser), None = nicht zulaessig."""
@@ -304,7 +309,7 @@ def load_rules(path: Path) -> tuple[list[Rule], list[str]]:
number_offset=int(s.get("nummer_versatz", "0")),
prefix=s.get("praefix", "").strip(),
target_mm=float(soll) if soll else None,
tolerance_mm=float(s.get("toleranz_mm", "150")),
tolerance_mm=float(s.get("toleranz_mm", str(DEFAULT_TOLERANCE_MM))),
))
known = {r.role for r in rules}
for r in rules:
+391
View File
@@ -0,0 +1,391 @@
# -*- 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
+178 -37
View File
@@ -79,6 +79,8 @@ from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
from parameter import get_float, get_int
# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------
@@ -129,10 +131,10 @@ STATION_PATTERNS = (
("Ausgang", ("ausgang",)),
)
# Max. Abstand (mm), ab dem ein generisches Eingang/Ausgang-Symbol als
# "deckungsgleich" mit einer BTMT-Station gilt und verschluckt wird. Die
# beobachteten Paare liegen bei 256-431 mm, echte getrennte Stationen >1000 mm.
STATION_MERGE_MM = 600.0
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [material_flow] - Bedeutung
# und Einheit stehen dort (lib/parameter.py).
_P = "material_flow"
STATION_MERGE_MM = get_float(_P, "station_merge_mm")
# Transfer-/Weichenelemente aus dem erweiterten Export. Sie bilden KEINE eigenen
# Knoten, liefern aber die *gerichtete* Verbindung zwischen den Transportobjekten:
@@ -199,11 +201,8 @@ NODE_STYLES = {
}
DEFAULT_NODE_STYLE = dict(fillcolor="#f2f2f2", fontcolor="#404040", color="#808080")
# Ab dieser Anzahl paralleler Gefaellestrecken zwischen denselben zwei
# Kreisel-Bahnen (gleicher Zulauf, gleicher Ablauf, gleiche Steigung) werden
# sie im Materialfluss-Diagramm zu einem Block zusammengefasst - sonst wird
# eine grosse Linienbank (Speicher) unlesbar breit.
MIN_GEFAELLE_GROUP = 2
# Parallele Gefaellestrecken je Block im Materialfluss-Diagramm (parameter.ini)
MIN_GEFAELLE_GROUP = get_int(_P, "min_gefaelle_group")
# ---------------------------------------------------------------------------
@@ -1893,13 +1892,11 @@ def _node_label(node: Node) -> str:
return _dot_label(lines)
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
# Millimeter. 0.25 pt/mm (1:4) - wie in tro_flow.py, damit Material- und TRO-Graph
# im selben Massstab lagerichtig sind.
COORD_SCALE = 0.25
# Die beiden Bahnen (L/R) eines Kreisels teilen sich eine Position; im
# lagerichtigen Layout leicht versetzen, damit sie nicht uebereinander liegen.
LANE_NUDGE_MM = 350.0
# Lagerichtiges Layout (--use-cords): Massstab, Bahnversatz, Legendenabstand
# (parameter.ini [material_flow])
COORD_SCALE = get_float(_P, "coord_scale")
LANE_NUDGE_MM = get_float(_P, "lane_nudge_mm")
LEGEND_OFFSET_MM = get_float(_P, "legend_offset_mm")
def _node_xy(node: "Node") -> tuple[float, float] | None:
@@ -2110,7 +2107,7 @@ def render_dot(graph: Graph, use_coords: bool = False) -> str:
xs = [xy[0] for n in graph.nodes.values() if (xy := _node_xy(n))]
ys = [xy[1] for n in graph.nodes.values() if (xy := _node_xy(n))]
if xs and ys:
lx, ly = min(xs), min(ys) - 3000.0 # etwas unterhalb der Anlage
lx, ly = min(xs), min(ys) - LEGEND_OFFSET_MM # unterhalb der Anlage
legend_pos = (f' pos="{lx * COORD_SCALE:.2f},'
f'{ly * COORD_SCALE:.2f}",')
@@ -2198,10 +2195,157 @@ def _track_node_html_label(node: TrackNode) -> str:
)
def render_track_dot(track: TrackGraph) -> str:
# Container-Arten fuer die Zugehoerigkeit im Mouseover (--track)
CONTAINER_TEXT = {"Kreisel": "Kreisel", "Strecke": "VF", "Gefaellestrecke": "GF"}
@dataclass
class _TooltipContext:
"""Nachschlagedaten fuer die Mouseover-Texte des --track-Graphen."""
by_id: dict[str, Element]
containers: dict[str, list[Element]] # Element-Id -> Container mit ihm in TrackIds
loops: dict[str, object] # Kreisel-Id -> kreisel_junctions.Loop
contacts: dict[str, tuple[str, str]] # Weichen-Id -> beruehrende Kreisel (Geometrie)
def _tooltip_context(elements: list[Element]) -> _TooltipContext:
"""Container-Mitgliedschaft und Kreisel-Geometrie fuer die Mouseover-Texte.
kreisel_junctions baut auf diesem Modul auf; der Import steht deshalb hier
in der Funktion (beim Aufruf ist material_flow laengst geladen).
"""
from kreisel_junctions import find_contacts
by_id = {e.teile_id: e for e in elements}
containers: dict[str, list[Element]] = {}
for c in elements:
if c.kind in CONTAINER_TEXT:
for tid in c.track_ids:
containers.setdefault(tid, []).append(c)
loops, contacts = find_contacts(elements, [])
return _TooltipContext(by_id, containers, loops,
{c.weiche_id: c.kreisel for c in contacts})
def _container_text(ctx: _TooltipContext, ref: str) -> str:
"""'0009-L' -> 'Kreisel Kreisel9 (0009) Bahn L', '0014' -> 'VF VF_2 (0014)'."""
base, _sep, lane = ref.partition("-")
el = ctx.by_id.get(base)
if el is None:
return f"{ref} (unbekannt)"
art = CONTAINER_TEXT.get(el.kind, el.kind)
name = el.name if el.name.lower().startswith(art.lower()) else f"{art} {el.name}"
text = f"{name} ({base})"
return f"{text} Bahn {lane}" if lane else text
def _geometry_text(ctx: _TooltipContext, el: Element, station: bool = False
) -> tuple[str, str | None]:
"""Lage auf einem Kreisel aus der Geometrie.
Parameter:
ctx: Nachschlagedaten (_tooltip_context).
el: das Bauteil.
station: True fuer Be-/Entladung - sie steht neben dem Umlauf, darum
zaehlt dort der naechste Kreisel unabhaengig vom Abstand.
Rueckgabe:
(Text, Kreisel-Id oder None, wenn das Bauteil auf keinem Kreisel liegt).
"""
if el.x is None or el.y is None or not ctx.loops:
return "Geometrie: keine Position", None
dist, k = min((lp.project(el.x, el.y)[1], k) for k, lp in ctx.loops.items())
kreisel = _container_text(ctx, k)
if station:
return f"Geometrie: naechster Kreisel {kreisel}, {dist:.0f}mm entfernt", k
from kreisel_junctions import SEPARATOR_TOLERANCE
if dist <= SEPARATOR_TOLERANCE:
return f"Geometrie: liegt auf {kreisel} Bahn {ctx.loops[k].lane(el.x, el.y)}", k
return f"Geometrie: auf keinem Kreisel, naechster {kreisel} {dist:.0f}mm", None
def _track_node_tooltip(node: TrackNode, ctx: _TooltipContext | None) -> str:
"""Mouseover-Text eines Knotens: Zugehoerigkeit zu Kreisel/VF/GF.
Separator: Zuordnung (CSV), TrackIds-Kette(n), Lage laut Geometrie
Weiche: die beiden erzeugenden Kreisel (Merkmale Kreisel A/B),
Beruehrart, TrackIds-Kette(n), Beruehrpunkt laut Geometrie
Be-/Entladung: Kreisel, auf dessen Umlauf sie liegt (TrackIds), Geometrie
Widersprechen sich die Quellen, kommt eine Zeile mit "!" dazu.
"""
el = node.element
if el is None:
return _track_node_label(node)
lines = [f"{el.teile_art} {el.teile_id} '{el.name}' (Zeile {el.row})"]
if ctx is None:
return _dot_label(lines)
ketten = ctx.containers.get(el.teile_id, [])
ketten_ids = {c.teile_id for c in ketten}
ketten_text = ", ".join(_container_text(ctx, c.teile_id) for c in ketten) or "-"
warn: list[str] = []
if node.kind == "Weiche":
a = str(_merkmal(el.merkmale, "Kreisel A") or "")
b = str(_merkmal(el.merkmale, "Kreisel B") or "")
lines.append(f"Kreisel A: {_container_text(ctx, a) if a else '-'}")
lines.append(f"Kreisel B: {_container_text(ctx, b) if b else '-'}")
for key in ("Beruehrung", "Achse von Kreisel", "Erzeugt aus"):
if (v := _merkmal(el.merkmale, key)) is not None:
lines.append(f"{key}: {v}")
lines.append(f"TrackIds-Kette: {ketten_text}")
geo = ctx.contacts.get(el.teile_id)
if geo is None:
lines.append("Geometrie: an keinem gleichlaufenden Kreisel-Beruehrpunkt")
warn.append("Weiche ohne stimmigen Beruehrpunkt (Drehrichtung pruefen)")
else:
lines.append(f"Geometrie: Beruehrpunkt Kreisel {geo[0]} / {geo[1]}")
if a and b and set(geo) != {a, b}:
warn.append(f"Kreisel A/B ({a}/{b}) passen nicht zur Geometrie")
else:
zuordnung = el.assignment
if zuordnung and zuordnung != UNASSIGNED:
lines.append(f"Zuordnung: {_container_text(ctx, zuordnung)}")
elif node.kind == "Separator":
lines.append("Zuordnung: -")
lines.append(f"TrackIds-Kette: {ketten_text}")
station = node.kind in ("Beladung", "Entladung")
geo_text, geo_k = _geometry_text(ctx, el, station=station)
lines.append(geo_text)
zu_base = zuordnung.partition("-")[0] if zuordnung else ""
zu_el = ctx.by_id.get(zu_base)
if zu_el is not None and ketten_ids and zu_base not in ketten_ids:
warn.append("Zuordnung und TrackIds-Kette widersprechen sich")
if zu_el is not None and zu_el.kind == "Kreisel" and geo_k and geo_k != zu_base:
warn.append(f"Zuordnung Kreisel {zu_base}, liegt aber auf Kreisel {geo_k}")
kreisel_ketten = {c.teile_id for c in ketten if c.kind == "Kreisel"}
if station and geo_k and kreisel_ketten and geo_k not in kreisel_ketten:
warn.append(f"TrackIds-Kette nennt {', '.join(sorted(kreisel_ketten))}, "
f"naechster Kreisel ist aber {geo_k}")
if not ketten and el.kind not in CONTAINER_TEXT:
warn.append("in keiner TrackIds-Kette")
if (geo_k is None and not station
and not (zu_el is not None and zu_el.kind == "Kreisel")):
# Bauteil einer VF/GF: der naechste Kreisel ist dort ohne Belang
lines[-1] = "Geometrie: liegt auf keinem Kreisel"
lines.extend(f"! {w}" for w in warn)
return _dot_label(lines)
def render_track_dot(track: TrackGraph, elements: list[Element] | None = None) -> str:
"""DOT des Bahnzusammenhang-Graphen (--track); siehe build_track_graph()
und doc/track.md. Gerendert ueber dieselben graph_to_svg/dot_to_svg wie
render_dot()."""
render_dot().
Parameter:
track: der Bahnzusammenhang-Graph.
elements: alle Elemente des CSV-Exports; damit bekommt jeder Knoten
einen Mouseover-Text (tooltip, im SVG als Hover-Titel) mit seiner
Zugehoerigkeit zu Kreisel/VF/GF (siehe _track_node_tooltip).
None = nur die Bezeichnung.
"""
ctx = _tooltip_context(elements) if elements else None
out: list[str] = []
add = out.append
@@ -2228,7 +2372,8 @@ def render_track_dot(track: TrackGraph) -> str:
label_attr = f"label={_track_node_html_label(node)}"
else:
label_attr = f'label="{_track_node_label(node)}"'
add(f' "{node_id}" [{label_attr}, {attrs}{extra}];')
tooltip = f', tooltip="{_track_node_tooltip(node, ctx)}"'
add(f' "{node_id}" [{label_attr}, {attrs}{extra}{tooltip}];')
add("")
for edge in track.edges:
@@ -2411,18 +2556,14 @@ BBS_DEFAULT_STYLE = dict(fill="#f2f2f2", stroke="#808080", text="#404040")
BBS_DETAIL_KINDS = ("Bogen", "Streckenelement", "Motorstation", "Umlenkstation")
BBS_MODES = ("flow", "all")
# Legende (--show-bbs): Kaestchen- und Schriftgroesse in mm.
BBS_LEGEND_BOX_MM = 250.0
BBS_LEGEND_FONT_MM = 180.0
# Rand um die Anlage herum und minimale Schriftgroesse, jeweils in mm.
BBS_MARGIN_MM = 500.0
BBS_MIN_FONT_MM = 40.0
# Darstellung der Anschlusspunkte K1-K4 (--show-kx): Groesse des Kreuzes und
# der Beschriftung in mm, Verbindungslinie K1->K2->K3->K4 in Blau.
KX_CROSS_MM = 120.0
KX_FONT_MM = 90.0
# Groessen fuer --show-bbs/--show-kx (parameter.ini [material_flow]); die
# Verbindungslinie K1->K2->K3->K4 ist blau.
BBS_LEGEND_BOX_MM = get_float(_P, "bbs_legend_box_mm")
BBS_LEGEND_FONT_MM = get_float(_P, "bbs_legend_font_mm")
BBS_MARGIN_MM = get_float(_P, "bbs_margin_mm")
BBS_MIN_FONT_MM = get_float(_P, "bbs_min_font_mm")
KX_CROSS_MM = get_float(_P, "kx_cross_mm")
KX_FONT_MM = get_float(_P, "kx_font_mm")
KX_CROSS_COLOR = "#c00000"
KX_LINK_COLOR = "#1f5fd0"
@@ -2708,10 +2849,10 @@ def _container_children(element: Element, by_id: dict[str, Element]) -> list[Ele
# Keine Anbauteile (Separator, Scanner, Weiche, ...) und keine Ein-/
# Ausgabestationen - die kommen in einem spaeteren Schritt dazu.
CHAIN_MAX_W = 1400.0
CHAIN_MAX_H = 850.0
CHAIN_MARGIN = 70.0
CHAIN_TITLE_H = 60.0
CHAIN_MAX_W = get_float(_P, "chain_max_w") # Groessen: parameter.ini
CHAIN_MAX_H = get_float(_P, "chain_max_h")
CHAIN_MARGIN = get_float(_P, "chain_margin")
CHAIN_TITLE_H = get_float(_P, "chain_title_h")
CHAIN_EDGE_COLOR = {
EDGE_FLOW: "#2f5597",
@@ -3793,7 +3934,7 @@ def main(argv: list[str] | None = None) -> int:
track = build_track_graph(elements, by_id, graph, warnings)
if args.track:
track_dot_target = results / f"{stem}_track.dot"
track_dot_target.write_text(render_track_dot(track), encoding="utf-8")
track_dot_target.write_text(render_track_dot(track, elements), encoding="utf-8")
track_dot_file = track_dot_target
if args.tosvg:
track_svg_target = results / f"{stem}_track.svg"
+99
View File
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
parameter.py - Zahlenwerte der Bibliotheken aus cfg/parameter.ini.
Toleranzen, Schwellen, Massstaebe und Zeichnungsgroessen stehen nicht im Code,
sondern in einer INI-Datei: ein Abschnitt je Modul, der Schluessel ist der
Name der Konstanten in Kleinbuchstaben ([kreisel_junctions]
contact_tolerance -> kreisel_junctions.CONTACT_TOLERANCE).
Gelesen wird zuerst die Grunddatei des Repositorys (<Projekt>/cfg/
parameter.ini, versioniert, muss vollstaendig sein). Liegt in %SKEL_CFG% eine
weitere parameter.ini, ueberschreibt sie einzelne Werte - so kann eine Anlage
abweichende Toleranzen bekommen, ohne die Grunddatei zu aendern.
Fehlt die Grunddatei oder ein Schluessel, bricht der Import mit
ParameterError ab: einen stillen Ersatzwert im Code gibt es bewusst nicht,
sonst stuende die Zahl doch wieder im Code.
Unterste Schicht: importiert nichts aus lib/, damit jedes Modul es nutzen kann.
"""
from __future__ import annotations
import configparser
import os
from functools import lru_cache
from pathlib import Path
PARAMETER_FILE = "parameter.ini"
__all__ = ["PARAMETER_FILE", "ParameterError", "files", "get_float", "get_int"]
class ParameterError(RuntimeError):
"""Grunddatei fehlt oder ein Wert fehlt bzw. ist keine Zahl."""
def _base_file() -> Path:
"""Grunddatei im Repository: <Projekt>/cfg/parameter.ini."""
return Path(__file__).resolve().parent.parent / "cfg" / PARAMETER_FILE
def files() -> list[Path]:
"""Gelesene Dateien in Lesereihenfolge (Grunddatei, ggf. %SKEL_CFG%)."""
result = [_base_file()]
cfg = os.environ.get("SKEL_CFG")
if cfg:
override = Path(cfg) / PARAMETER_FILE
if override.is_file() and override.resolve() != result[0].resolve():
result.append(override)
return result
@lru_cache(maxsize=1)
def _parser() -> configparser.ConfigParser:
"""Alle Parameterdateien einlesen (einmal je Prozess)."""
base = _base_file()
if not base.is_file():
raise ParameterError(f"Parameterdatei fehlt: {base}")
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"))
for path in files():
with path.open(encoding="utf-8") as handle:
parser.read_file(handle, source=str(path))
return parser
def _raw(section: str, key: str) -> str:
"""Rohwert; ParameterError, wenn Abschnitt oder Schluessel fehlt."""
parser = _parser()
if not parser.has_option(section, key):
raise ParameterError(
f"[{section}] {key} fehlt in {', '.join(str(p) for p in files())}")
return parser.get(section, key)
def get_float(section: str, key: str) -> float:
"""Zahl aus [section] key.
Parameter:
section: Abschnitt, in der Regel der Modulname (z. B. "tro_flow").
key: Schluessel, der Konstantenname in Kleinbuchstaben.
Rueckgabe:
Der Wert als float.
"""
raw = _raw(section, key)
try:
return float(raw)
except ValueError:
raise ParameterError(f"[{section}] {key} = {raw!r} ist keine Zahl") from None
def get_int(section: str, key: str) -> int:
"""Ganze Zahl aus [section] key (Anzahlen, Schrittweiten)."""
raw = _raw(section, key)
try:
return int(raw)
except ValueError:
raise ParameterError(f"[{section}] {key} = {raw!r} ist keine ganze Zahl") from None
+6 -5
View File
@@ -108,6 +108,7 @@ from dxf_registration import (
from material_flow import build_graph, env_dir, read_elements, resolve_input
from tro_catalog import STYLES, TRO_CATALOG, TroSymbol, get_tro
from model import Analysis
from parameter import get_float
from tro_flow import (
BOUNDARY_ENTRY,
BOUNDARY_EXIT,
@@ -131,11 +132,11 @@ BLOCK_PREFIX = "TRO_SYM_"
# SSG_BLOCKEDIT in dxfmakros ihn ueber "CONNECTION_*" erkennt.
CONNECTION_BLOCK = "CONNECTION_ARROW"
# Zeichnungsgroessen in mm, wenn nicht anders angegeben
DEFAULT_MARKER = 300.0
DEFAULT_TEXT = 220.0
# Restfehler, ab dem der Lagebezug abgelehnt wird
DEFAULT_MAX_RESIDUAL = 600.0
# Zeichnungsgroessen und Restfehler-Grenze des Lagebezugs aus cfg/parameter.ini,
# Abschnitt [tro_annotate] (lib/parameter.py)
DEFAULT_MARKER = get_float("tro_annotate", "default_marker")
DEFAULT_TEXT = get_float("tro_annotate", "default_text")
DEFAULT_MAX_RESIDUAL = get_float("tro_annotate", "default_max_residual")
def hex_to_rgb(value: str) -> tuple[int, int, int]:
+11
View File
@@ -619,6 +619,17 @@ TRO_CATALOG: tuple[TroDefinition, ...] = (
TroItem.SWITCH: 2,
TroItem.SCANNER: 2,
}, STYLE_SWI, TroSymbol.DOUBLE_TRIANGLE_DOWN, "#f2a65a"),
# ACHTUNG - NICHT aus SCL belegt, wie 2Sep2Swi. Aufgenommen am 2026-10-14
# fuer die Testanlage data/export.csv: Weichenknoten, an dem die Umlenkung
# eines Kreisels zwei andere beruehrt (Kreisel 5 an 4 und 6) - drei
# Separatoren laufen in Laufrichtung auf die zwei Weichen zu (siehe
# lib/kreisel_junctions.py). Scanner je Separator analog zu 2Sep2Swi.
# FB_ILS_MTRO_3Sep2Swi existiert noch nicht in der ILSLib.
TroDefinition("3Sep2Swi", "FB_ILS_MTRO_3Sep2Swi", {
TroItem.SEPARATOR: 3,
TroItem.SWITCH: 2,
TroItem.SCANNER: 3,
}, STYLE_SWI, TroSymbol.DOUBLE_TRIANGLE_DOWN, "#d9822b"),
)
_BY_NAME: dict[str, TroDefinition] = {t.get_name(): t for t in TRO_CATALOG}
+415 -59
View File
@@ -78,6 +78,49 @@ Hinweis ausgegeben.
Zusaetzlich: hat ein Host 2 bzw. 3 abgehende Flusskanten, wird 1Sep zu
1Sep1Swi bzw. 1Sep2Swi aufgewertet (Weiche).
Wo im Code der Typ entsteht
---------------------------
Einstieg ist analyse_tros(); den TRO-Typ (DerivedTro.type_name) setzt aber
ausschliesslich derive_tros(), Schritt fuer Schritt:
Schritt Bauteil-Muster im CSV -> TRO-Typ
-----------------------------------------------------------------------
1 >= 2 Gefaellestrecken mit Separator, PinStore_Auto
gemeinsamer Zu-/Ablauf (_group_lanes,
_speicher_bloecke)
1 einzelne Gefaellestrecke mit Separator 1Sep
2 Separator in der TrackIds-Kette einer Vario / 1Sep
Strecke (alternierendes Muster, (abwechselnd)
_incoming_drop)
2 Separator mit Zuordnung auf eine Strecke, 1Sep
aber nicht in deren TrackIds-Kette
3 Separator auf einer Kreisel-Bahn 1Sep
3b Separator vor einem Ausschleuselement 1Sep1Swi
(_find_switch_separators, _weichen_*)
3b' derselbe Separator vor einem zweiten 1Sep2Swi
Ausschleuselement ohne Separator dazwischen
3c Weichenknoten zwischen Kreiseln: n Separa- 1Sep1Swi, 2Sep1Swi,
toren in Laufrichtung unmittelbar vor m 2Sep2Swi, 3Sep2Swi
Beruehrpunkten derselben Umlenkung
(_kreisel_knoten, lib/kreisel_junctions.py)
3c' Separator an einer ILS Weiche, die kein 1Sep1Swi
Weichenknoten aus 3c ist
(_find_weiche_separators)
3d 3b-TRO und 3c'-TRO direkt hintereinander 2Sep2Swi
in der TrackIds-Kette eines Kreisels
4 1Sep mit 2 bzw. 3 abgehenden Flusskanten 1Sep1Swi / 1Sep2Swi
Alle anderen Funktionen (link_tros, locate_tros, derive_boundaries, ...)
aendern den Typ nicht mehr. cfg/tro_overrides.ini wird hier seit
2026-10-05 nicht mehr angewendet (siehe tro_overrides.py) - ein falsch
erkannter Typ wird in derive_tros() korrigiert, nicht per INI.
Nicht abgeleitet werden bisher: 1Sep_SSCC (nur Hinweis, Schritt 6),
EmptyCarrBuffer und LoadingBoom. Schritt 3c braucht eine stimmige
Drehrichtung beider Kreisel: laufen sie am Beruehrpunkt gegeneinander
(Mubea, Dematic - vermutlich Exportfehler), bleibt es fuer diese ILS Weiche
bei 3c' und 3d.
Typen, FB-Bausteine, Bauteile und Farben kommen ausschliesslich aus
lib/tro_catalog.py.
@@ -108,6 +151,7 @@ import csv
import json
import math
import sys
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
@@ -123,6 +167,7 @@ from material_flow import (
UNASSIGNED,
Element,
Graph,
Node,
_as_float,
_dot_escape,
_dot_label,
@@ -133,7 +178,9 @@ from material_flow import (
read_elements,
resolve_input,
)
from kreisel_junctions import Junction, find_junctions
from model import Analysis, DerivedTro, TroDocument
from parameter import get_float, get_int
from tro_ids import (
DEFAULT_TOLERANCE,
TroIds,
@@ -159,42 +206,19 @@ from tro_catalog import (
# Konstanten
# ---------------------------------------------------------------------------
# Ab dieser Anzahl zusammenhaengender Schwerkraftlinien (Gefaellestrecke mit je
# einem Separator) wird ein Speicherblock (PinStore_Auto) angenommen.
MIN_STORE_LANES = 2
# Mindest-Hoehenverlust (mm) auf dem der Reihe nach vorangehenden TrackIds-
# Segment, damit ein Separator auf einer "Strecke" trotzdem als 1Sep statt
# Vario gilt (siehe _incoming_drop). Muss nur "horizontal" gefuehrte, aber
# tatsaechlich Gefaelle-Segmente erwischen (HundM05 Separator 0035: -104mm auf
# 3,8m), nicht das Rauschen eines echten flachen VF-Laufs (HundM05 Separator
# 0036: +26mm auf 0,5m). 30mm liegt bewusst weit unter dem echten Befund und
# weit ueber dem Rauschen.
GEFAELLE_DROP_MM = 30.0
# Zahlenwerte aus cfg/parameter.ini, Abschnitt [tro_flow] - Bedeutung und
# Einheit stehen dort (lib/parameter.py).
_P = "tro_flow"
MIN_STORE_LANES = get_int(_P, "min_store_lanes")
GEFAELLE_DROP_MM = get_float(_P, "gefaelle_drop_mm")
COORD_SCALE = get_float(_P, "coord_scale")
COORD_PFEIL_LAENGE = get_int(_P, "coord_pfeil_laenge")
COORD_MAX_LUECKE = get_int(_P, "coord_max_luecke")
LEGEND_MARGIN = get_int(_P, "legend_margin")
LEGEND_DROP = get_int(_P, "legend_drop")
GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->"
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
# Millimeter. Seit die Symbole eine feste Groesse haben (COORD_SYMBOL_SIZE)
# statt mit ihrem Label zu wachsen, reichen 0.1 pt/mm: ~11 x 20 m Anlage ergeben
# etwa 1100 x 2000 pt - bildschirmfuellend lesbar. Vorher 0.25 (riesige
# Zeichnung, in der Symbole und Schrift verschwanden; Review 2026-10-01).
COORD_SCALE = 0.1
# Zu-/Ablaufpfeil einer Station in der lagerichtigen Zeichnung: feste Laenge
# (mm) ab dem angehaengten TRO (siehe _station_positionen).
COORD_PFEIL_LAENGE = 1300
# Groesste Luecke (mm) zwischen zwei benachbarten Knotenkoordinaten je Achse in
# der lagerichtigen Zeichnung. Groessere Leerstrecken (lange Foerderer ohne
# TRO) werden auf diesen Wert gekuerzt - Reihenfolge und Nachbarschaft der
# Knoten bleiben erhalten, nur der Leerraum verschwindet (Review 2026-10-01:
# "zu verstreut, zu viel Weissraum"). --true-scale schaltet das ab.
COORD_MAX_LUECKE = 1500
# Abstand der Legende zum Anlagenrand in mm
LEGEND_MARGIN = 3000
# ---------------------------------------------------------------------------
# TRO-Ermittlung
@@ -207,6 +231,17 @@ def _group_lanes(lanes: list[str], graph: Graph) -> list[list[str]]:
Linien, die sich einen Zu- oder Ablaufknoten teilen, gehoeren zum selben
Speicher. So entstehen bei zwei getrennten Speichern auch zwei TROs.
Parameter:
lanes: Knoten-Ids der Gefaellestrecken, die einen Separator tragen
(derive_tros Schritt 1).
graph: Materialflussgraph aus material_flow.build_graph; nur
graph.edges wird gelesen (Nachbarschaft der Linien).
Rueckgabe:
Liste von Gruppen, jede eine sortierte Liste von Linien-Ids; die
Gruppen sind nach ihrer ersten Linie sortiert. Eine Gruppe mit
>= MIN_STORE_LANES Linien wird in derive_tros zu PinStore_Auto.
"""
adjacency: dict[str, set[str]] = {}
for lane in lanes:
@@ -246,6 +281,18 @@ def _kreisel_bahn(sep: Element, elements: list[Element], graph: Graph) -> str |
der Achsrichtung L, rechts R - dieselbe Konvention wie die Zuordnungen,
die der Export richtig setzt (0038 unterhalb von Kreisel2 mit Drehung 180
-> L, 0047 oberhalb von Kreisel3 -> R).
Parameter:
sep: der Separator (Element.kind "Separator").
elements: alle Elemente des CSV-Exports; durchsucht werden die
Kreisel und ihre track_ids.
graph: Materialflussgraph; die gefundene Bahn muss dort ein Knoten
sein (z. B. "0002-L").
Rueckgabe:
Knoten-Id der Bahn ("<Kreisel-Id>-L" bzw. "-R") oder None, wenn der
Separator in keiner Kreiselkette steht, Position bzw. "Drehung"
fehlen oder die Bahn kein Graphknoten ist.
"""
for k in elements:
if k.kind != "Kreisel" or sep.teile_id not in k.track_ids:
@@ -278,6 +325,16 @@ def _weichen_nach_umlauf(
Schluss mit dem Ausschleuselement 0057 von VF_1 "benachbart" (TRO03 als
Weiche in VF_1), 0046 liegt hinter dem letzten Ausschleuselement von
Speicherblock C. Beide sind gewoehnliche Kreisel-Separatoren (Schritt 3).
Parameter:
switch_hits: Ergebnis von _find_switch_separators
(Separator-Id -> (Ausschleuselement, Abstand mm)).
elements: alle Elemente des CSV-Exports (Kreisel mit track_ids).
findings: Fundliste; je verworfenem Paar wird ein Eintrag angehaengt.
Rueckgabe:
Gefiltertes switch_hits. Paare ausserhalb einer Kreiselkette bleiben
ungeprueft erhalten.
"""
by_id = {e.teile_id: e for e in elements}
kreisel = [e for e in elements if e.kind == "Kreisel" and e.track_ids]
@@ -324,6 +381,18 @@ def _weichen_aus_umlauf(
Ausschleuselemente einer Gefaellestrecke bleiben aussen vor: das sind
Speicherlinien, ihr Steuer-Separator entsteht in _speicher_bloecke nur
aus der CSV-Nachbarschaft (Mubea 0044 bleibt eigener 1Sep vor dem Block).
Parameter:
switch_hits: bisherige Paare (Separator-Id -> (Ausschleuselement,
Abstand mm)), meist aus _weichen_nach_umlauf.
kandidaten: Separatoren, die ueberhaupt Weiche sein duerfen (in
derive_tros: ohne die eigenen VF-Kettenseparatoren).
elements: alle Elemente des CSV-Exports.
findings: Fundliste; je ergaenztem Paar wird ein Eintrag angehaengt.
Rueckgabe:
Neues dict: switch_hits plus die ergaenzten Paare. Jedes
Ausschleuselement gehoert hoechstens einem Separator.
"""
by_id = {e.teile_id: e for e in elements}
erlaubt = {s.teile_id for s in kandidaten}
@@ -381,8 +450,16 @@ def _speicher_bloecke(
Mubea 0044 (TRO05) ist durch ein Einschleuselement vom Block getrennt,
kein Nachbar eines Ausschleuselements und bleibt deshalb eigener 1Sep.
Rueckgabe: (Linie -> Block, Steuer-Separator -> Block). Linien ohne
Ausschleuselement in einer Kreisel-Kette fehlen in der ersten Zuordnung.
Parameter:
lanes: Knoten-Ids der Gefaellestrecken mit Separator.
elements: alle Elemente des CSV-Exports (Ausschleuselemente mit
Merkmal "Strecke"/"Foerderer", Kreisel mit track_ids).
switch_hits: Weichen-Separatoren (Separator-Id -> (Ausschleuselement,
Abstand mm)), siehe _find_switch_separators.
Rueckgabe: (Linie -> Block, Steuer-Separator -> Block). Ein Block ist
(Kreisel-Id, laufende Nummer ab 1). Linien ohne Ausschleuselement in
einer Kreisel-Kette fehlen in der ersten Zuordnung.
"""
lane_set = set(lanes)
by_id = {e.teile_id: e for e in elements}
@@ -427,7 +504,17 @@ def _speicher_bloecke(
def _switch_exits(hosts: list[str], graph: Graph) -> list[str]:
"""Abgehende Flusskanten eines TRO-Hosts (ohne Kreisel-Umlauf)."""
"""Abgehende Flusskanten eines TRO-Hosts (ohne Kreisel-Umlauf).
Parameter:
hosts: Knoten-Ids, die zusammen den TRO tragen (DerivedTro.hosts).
graph: Materialflussgraph; gezaehlt werden nur Kanten der Art
EDGE_FLOW, die aus hosts hinaus fuehren.
Rueckgabe:
Sortierte, eindeutige Ziel-Knoten-Ids. 2 bzw. 3 Ziele werten einen
1Sep in derive_tros Schritt 4 zu 1Sep1Swi bzw. 1Sep2Swi auf.
"""
return sorted(
{
edge.dst
@@ -438,7 +525,20 @@ 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 eines Separators aus der CSV-Spalte 'Zuordnung' (Default-Resolver).
Parameter:
sep: der Separator; gelesen wird sep.assignment (Merkmal
"Zuordnung", z. B. "0002-L" oder die Id einer Strecke).
findings: Fundliste; ist die Zuordnung leer oder kein Graphknoten,
wird ein Eintrag angehaengt.
graph: Materialflussgraph, gegen dessen Knoten geprueft wird.
Rueckgabe:
Knoten-Id des Hosts oder None (Separator bekommt dann keinen TRO).
Die Art dieses Knotens (Kreisel/Strecke/Gefaellestrecke) entscheidet
in derive_tros, in welchem Schritt der Separator landet.
"""
host = sep.assignment
if host and host != UNASSIGNED and host in graph.nodes:
return host
@@ -503,6 +603,18 @@ def _find_switch_separators(
Die Distanz im Rueckgabewert ist nur noch Beiwerk fuer die Doku ("XXXmm
von Ausschleuselement YYYY entfernt"), kein Kriterium mehr.
Parameter:
separators: zu pruefende Separatoren (in derive_tros ohne die
eigenen VF-Kettenseparatoren).
elements: alle Elemente des CSV-Exports (Ausschleuselemente und
Kreisel mit track_ids).
Rueckgabe:
Separator-Id -> (Ausschleuselement, Abstand mm); je Separator das
erste passende Element aus seinen Nachbarn. Wird in derive_tros
Schritt 3b zu 1Sep1Swi (oder zum Steuer-Separator eines
PinStore_Auto, siehe _speicher_bloecke).
"""
transfers = {e.teile_id: e for e in elements if e.kind == "Ausschleus"}
kreisel_by_id = {e.teile_id: e for e in elements if e.kind == "Kreisel"}
@@ -555,6 +667,22 @@ def _find_weiche_separators(
bestaetigt (Review 2026-08-12 und 2026-10-01). Ist nur ein Separator
benachbart, gilt dieser. Bleibt es mehrdeutig, wird nichts geraten,
sondern ein Finding geschrieben.
Grenze: einer Weiche wird hoechstens EIN Separator zugeordnet. Weichen
mit mehreren Separatoren (2Sep1Swi, 3Sep2Swi, ...) ermittelt vorher
_kreisel_knoten aus Geometrie und Laufrichtung; hier kommen nur die
ILS Weichen an, die dort keinen Knoten bilden.
Parameter:
separators: zu pruefende Separatoren (in derive_tros ohne die
Ausschleus-Weichen-Separatoren aus switch_hits).
elements: alle Elemente des CSV-Exports (ILS Weichen und Scanner).
findings: Fundliste; je mehrdeutiger Weiche wird ein Eintrag
angehaengt.
Rueckgabe:
Separator-Id -> ILS Weiche (Element.kind "Weiche"). Wird in
derive_tros Schritt 3c' zu 1Sep1Swi.
"""
by_id = {s.teile_id: s for s in separators}
mit_scanner = {
@@ -578,6 +706,48 @@ def _find_weiche_separators(
return result
def _kreisel_knoten(
elements: list[Element], findings: list[str], frei: set[str]
) -> list[Junction]:
"""Weichenknoten zwischen Kreiseln, die einen eigenen TRO bilden.
Geometrie und Steuerregel siehe lib/kreisel_junctions.py: ein Separator
steuert einen Knoten, wenn er auf einem beteiligten Kreisel in
Laufrichtung unmittelbar davor liegt (keine andere Weiche dazwischen).
Parameter:
elements: alle Elemente des CSV-Exports.
findings: Fundliste; je verworfenem Knoten ein Eintrag.
frei: Separator-Ids, die einen Knoten steuern duerfen. Steuert ein
anderer Separator (Ausschleus-Weiche aus 3b, eigener VF-
Kettenseparator) mit, bleibt der Knoten den bisherigen Regeln
(3b, 3c', 3d) ueberlassen.
Rueckgabe:
Die Knoten, deren Typ (j.type_name) im Katalog steht und die nur von
freien Separatoren gesteuert werden.
"""
result: list[Junction] = []
for j in find_junctions(elements, findings):
weichen = ", ".join(c.weiche_id for c in j.contacts)
if not j.separators:
if any(not c.exported for c in j.contacts):
findings.append(
f"Weichenknoten Kreisel {'/'.join(j.kreisel)} ({weichen}): kein "
f"Separator in Laufrichtung davor - kein TRO")
continue
if not set(j.separators) <= frei:
continue
if get_tro(j.type_name) is None:
findings.append(
f"Weichenknoten Kreisel {'/'.join(j.kreisel)} ({weichen}): "
f"{len(j.separators)} Separatoren und {len(j.contacts)} Weichen - "
f"Typ {j.type_name} fehlt im Katalog, kein TRO")
continue
result.append(j)
return result
def _incoming_drop(
host: Element, sep_id: str, elements_by_id: dict[str, Element]
) -> tuple[str | None, float | None]:
@@ -591,6 +761,18 @@ def _incoming_drop(
oder fehlt einem der beiden Enden das Merkmal "Hoehe" (z. B. Aus-/
Einschleuselemente tragen keins), liefert die Funktion (None, None) - der
Aufrufer behandelt das als "kein Gefaelle-Nachweis", bleibt also bei Vario.
Parameter:
host: die Streckengruppe (Element.kind "Strecke") mit track_ids und
drive_dir.
sep_id: TeileId des Separators in host.track_ids.
elements_by_id: TeileId -> Element, fuer die Hoehen der beiden
Segmentenden.
Rueckgabe:
(Antriebfahrtrichtung des Segments, Hoehendifferenz in mm; negativ =
Gefaelle). "horizontal" mit <= -GEFAELLE_DROP_MM stuft in
derive_tros Schritt 2 einen Vario auf 1Sep herab.
"""
chain = host.track_ids
if sep_id not in chain:
@@ -677,23 +859,67 @@ def derive_tros(
) -> list[DerivedTro]:
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden.
Host eines Separators aus der CSV-Spalte "Zuordnung" (_default_resolve_host),
abgehende Wege eines TRO-Hosts aus graph.edges (_switch_exits).
**Hier - und nur hier - wird der TRO-Typ bestimmt** (DerivedTro.type_name,
Uebersicht im Modul-Docstring "Wo im Code der Typ entsteht").
sepliste_counts (optional, siehe load_sepliste_counts): Container-Id ->
Anzahl 'SEP'-Eintraege aus dem Roh-JSON. Reine Gegenprobe - stimmt die aus
der CSV abgeleitete Separatorenzahl eines Strecke/Gefaellestrecke-Hosts
nicht mit dieser Zahl ueberein, wird ein Finding angehaengt; die TRO-Daten
selbst bleiben unangetastet (CSV bleibt die massgebliche Quelle).
Vorbereitung (vor den Schritten):
a) Weichen-Separatoren: _find_switch_separators ->
_weichen_nach_umlauf -> _weichen_aus_umlauf (Ausschleuselemente),
_kreisel_knoten (Weichenknoten zwischen Kreiseln) und
_find_weiche_separators (uebrige ILS Weichen). Diese Separatoren
bekommen keinen Host aus der Zuordnung.
b) Uebrige Separatoren -> Host-Knoten (_default_resolve_host, bei
Gefaellestrecken ggf. korrigiert durch _kreisel_bahn).
c) Scanner -> Separator ueber das Merkmal "Naechster Separator".
tro_ids (optional): ID-Datei der Anlage. Ohne sie werden die TROs entlang
des Materialflusses neu durchnummeriert; mit ihr behalten wiedergefundene
TROs ihre ID (Ergebnis in tro_ids.result).
Schritte (setzen type_name):
1 Gefaellestrecken: PinStore_Auto (>= MIN_STORE_LANES Linien) bzw.
1Sep (einzelne Linie).
2 Strecken: je Separator Vario oder 1Sep (alternierendes Muster,
Gefaelle-Pruefung ueber _incoming_drop).
3 Kreisel-Bahnen: je Separator 1Sep.
3b Separator vor Ausschleuselement: 1Sep1Swi.
3b' zweites Ausschleuselement desselben Separators: 1Sep2Swi.
3c Weichenknoten zwischen Kreiseln (_kreisel_knoten): Typ aus der
Zahl der Separatoren unmittelbar davor und der Weichen, z. B.
2Sep1Swi, 3Sep2Swi.
3c' Separator an einer uebrigen ILS Weiche: 1Sep1Swi.
3d 3b- und 3c'-TRO direkt hintereinander am Kreisel: 2Sep2Swi.
4 1Sep mit 2/3 abgehenden Flusskanten: 1Sep1Swi/1Sep2Swi.
5 Sortieren entlang des Materialflusses, IDs vergeben.
6 Hinweis bei 1Sep mit Scanner (evtl. 1Sep_SSCC, Typ bleibt).
Parameter:
graph: Materialflussgraph aus material_flow.build_graph (Knoten =
Kreisel-Bahnen "<Id>-L/-R", Strecken, Gefaellestrecken,
Stationen; Kanten = Materialfluss).
elements: alle Elemente des CSV-Exports (material_flow.read_elements);
Separatoren, Scanner, Aus-/Einschleuselemente, ILS Weichen und
Kreisel werden daraus gelesen.
findings: Fundliste, an die jeder Schritt Hinweise anhaengt (wird
veraendert, nicht ersetzt).
sepliste_counts: optional, siehe load_sepliste_counts: Container-Id
-> Anzahl 'SEP'-Eintraege aus dem Roh-JSON. Reine Gegenprobe -
stimmt die aus der CSV abgeleitete Separatorenzahl eines
Strecke/Gefaellestrecke-Hosts nicht mit dieser Zahl ueberein,
wird ein Finding angehaengt; die TRO-Daten selbst bleiben
unangetastet (CSV bleibt die massgebliche Quelle).
tro_ids: optional, ID-Datei der Anlage. Ohne sie werden die TROs
entlang des Materialflusses neu durchnummeriert; mit ihr behalten
wiedergefundene TROs ihre ID (Ergebnis in tro_ids.result).
Rueckgabe:
Die TROs in Materialfluss-Reihenfolge, mit tro_id, type_name, label,
hosts, separators, scanners, reason und confidence. Position,
Vorgaenger/Nachfolger fuellt erst analyse_tros (locate_tros,
link_tros).
"""
def resolve_host(sep: Element, findings: list[str]) -> str | None:
"""Host-Knoten eines Separators (siehe _default_resolve_host)."""
return _default_resolve_host(sep, findings, graph)
def count_exits(hosts: list[str]) -> list[str]:
"""Abgehende Flussziele der Hosts (siehe _switch_exits)."""
return _switch_exits(hosts, graph)
separators = sorted(
@@ -732,17 +958,26 @@ def derive_tros(
_weichen_nach_umlauf(
_find_switch_separators(switch_candidates, elements), elements, findings),
switch_candidates, elements, findings)
# Weichenknoten zwischen Kreiseln aus Geometrie und Laufrichtung (siehe
# _kreisel_knoten, Schritt 3c) - ihre Separatoren und Weichen sind damit
# vergeben und laufen nicht mehr durch _find_weiche_separators.
knoten = _kreisel_knoten(
elements, findings, frei={s.teile_id for s in switch_candidates} - set(switch_hits))
knoten_seps = {s for j in knoten for s in j.separators}
knoten_weichen = {c.weiche_id for j in knoten for c in j.contacts}
# Separatoren an einer echten ILS Weiche (siehe _find_weiche_separators) -
# wie switch_hits vor der Host-Aufloesung herausgenommen, Schritt 3c.
# wie switch_hits vor der Host-Aufloesung herausgenommen, Schritt 3c'.
weiche_hits = _find_weiche_separators(
[s for s in switch_candidates if s.teile_id not in switch_hits],
elements, findings,
[s for s in switch_candidates
if s.teile_id not in switch_hits and s.teile_id not in knoten_seps],
[e for e in elements if e.teile_id not in knoten_weichen], findings,
)
# Separator -> Host-Knoten
sep_host: dict[str, str] = {}
for sep in separators:
if sep.teile_id in switch_hits or sep.teile_id in weiche_hits:
if (sep.teile_id in switch_hits or sep.teile_id in weiche_hits
or sep.teile_id in knoten_seps):
continue
host = resolve_host(sep, findings)
# Steht er in der Umlaufkette eines Kreisels, zeigt die Zuordnung aber
@@ -764,7 +999,8 @@ def derive_tros(
sep_scanners: dict[str, list[str]] = {}
for scanner in scanners:
target = str(_merkmal(scanner.merkmale, "Naechster Separator") or "").strip()
if target in sep_host or target in switch_hits or target in weiche_hits:
if (target in sep_host or target in switch_hits or target in weiche_hits
or target in knoten_seps):
sep_scanners.setdefault(target, []).append(scanner.teile_id)
mounted = scanner.assignment
target_host = sep_host.get(target)
@@ -784,9 +1020,11 @@ def derive_tros(
)
def seps_on(hosts: list[str]) -> list[str]:
"""Sortierte Separator-Ids, deren Host in hosts liegt."""
return sorted(s for s, h in sep_host.items() if h in hosts)
def scanners_for(seps: list[str]) -> list[str]:
"""Sortierte Scanner-Ids, die an einem der Separatoren lesen."""
found: list[str] = []
for sep in seps:
found.extend(sep_scanners.get(sep, []))
@@ -1106,7 +1344,35 @@ def derive_tros(
f"Barcode-Scanner am Eingang, im Layout fehlt einer vor Separator "
f"{tro.separators[0]}")
# 3c) Separatoren an einer echten ILS Weiche -> 1Sep1Swi (siehe
# 3c) Weichenknoten zwischen Kreiseln -> nSepmSwi (siehe _kreisel_knoten):
# n = Separatoren, die in Laufrichtung unmittelbar vor dem Knoten liegen,
# m = Weichen (Beruehrpunkte) des Knotens. Host je Separator ist die Bahn,
# auf der er physisch liegt - nicht die Zuordnung (export.csv 0015:
# Zuordnung 0003-L, liegt aber auf Kreisel 0002 Bahn R).
for j in knoten:
weichen = [c.weiche_id for c in j.contacts]
vor = ", ".join(f"{s} ({j.sep_lane[s]})" for s in j.separators)
hosts = list(dict.fromkeys(j.sep_lane[s] for s in j.separators
if j.sep_lane[s] in graph.nodes))
tros.append(DerivedTro(
tro_id="",
type_name=j.type_name,
label=(f"Weichenknoten Kreisel {'/'.join(j.kreisel)}: "
f"Weiche{'n' if len(weichen) > 1 else ''} {', '.join(weichen)}"),
hosts=hosts or [j.sep_kreisel[s] for s in j.separators],
separators=list(j.separators),
scanners=scanners_for(j.separators),
reason=(f"Separator{'en' if len(j.separators) > 1 else ''} {vor} "
f"in Laufrichtung unmittelbar vor "
f"{'den Weichen' if len(weichen) > 1 else 'der Weiche'} "
f"{', '.join(weichen)} (Beruehrpunkt"
f"{'e' if len(weichen) > 1 else ''} der Kreisel "
f"{'/'.join(j.kreisel)})"),
confidence="hoch" if all(c.exported for c in j.contacts) else "mittel",
))
# 3c') Separatoren an einer ILS Weiche, die kein Weichenknoten aus 3c ist
# (z. B. gegenlaeufige Drehrichtung im Export) -> 1Sep1Swi (siehe
# _find_weiche_separators). Host wie bei 3b: der aufgeloeste Zuordnung-Host
# des Separators, sonst die Weiche selbst.
weiche_tro: dict[str, DerivedTro] = {} # Weichen-Id -> 1Sep1Swi
@@ -1232,15 +1498,26 @@ def _flow_rank(graph: Graph) -> dict[str, int]:
return rank
def _flow_key(graph: Graph, elements_by_id: dict[str, Element]):
def _flow_key(
graph: Graph, elements_by_id: dict[str, Element]
) -> Callable[[DerivedTro], tuple[int, int, str]]:
"""Sortierschluessel fuer TROs entlang des Materialflusses.
Erst der Rang des Host-Knotens, dann die Lage des Separators in der
TrackIds-Kette des Hosts (Kreisel: Umlaufrichtung, Strecke: Baureihenfolge).
Parameter:
graph: Materialflussgraph (fuer _flow_rank).
elements_by_id: TeileId -> Element, fuer die track_ids des Hosts.
Rueckgabe:
Funktion fuer sorted()/list.sort(key=...), die je TRO
(Host-Rang, Index in der TrackIds-Kette, erster Separator) liefert.
"""
rank = _flow_rank(graph)
def key(tro: DerivedTro) -> tuple:
def key(tro: DerivedTro) -> tuple[int, int, str]:
"""(Host-Rang, Index in der TrackIds-Kette, erster Separator)."""
host_rank = min((rank.get(h, len(rank)) for h in tro.hosts),
default=len(rank))
sep = tro.separators[0] if tro.separators else ""
@@ -1328,7 +1605,7 @@ def _tro_edge_kind(edge: Edge) -> str:
return "normal"
def _bahn_richtung(node) -> tuple[float, float] | None:
def _bahn_richtung(node: Node) -> tuple[float, float] | None:
"""Foerderrichtung einer Kreisel-Bahn als Einheitsvektor.
Der Kreisel ist eine geschlossene Schleife: auf der einen Bahn laeuft das
@@ -1414,7 +1691,8 @@ def _chain_mit_ungelisteten_separatoren(
if dx == 0 and dy == 0:
return chain
def _t(pos) -> float:
def _t(pos: tuple[float, float, float]) -> float:
"""Projektion von pos auf die Richtung anfang -> ende (unskaliert)."""
return (pos[0] - anfang.position[0]) * dx + (pos[1] - anfang.position[1]) * dy
# Kettenelemente ohne Position behalten ihren Platz: sie erben den Wert des
@@ -1507,6 +1785,7 @@ def link_tros(
elem_owner.setdefault(sep_id, tro.tro_id)
def _add_chain_pair(a: str, b: str) -> None:
"""Gerichtete Kante TRO a -> TRO b aus der TrackIds-Kette eintragen."""
if a == b:
return
# Keine Sperre fuer Systemgrenzen noetig: sie sind kein Kettenglied
@@ -1863,7 +2142,23 @@ def analyse_tros(
) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu nummerieren.
Gemeinsamer Einstieg fuer tro_flow.py und tro_annotate.py. Der TRO-Typ
wird NICHT hier bestimmt, sondern in derive_tros (erster Aufruf unten);
danach kommen nur noch Systemgrenzen (derive_boundaries), Koordinaten
(locate_tros) und Verbindungen (link_tros) dazu.
Parameter:
graph: Materialflussgraph aus material_flow.build_graph.
elements: alle Elemente des CSV-Exports (material_flow.read_elements).
sepliste_counts: optional, Separatorenzahl je Container aus dem
Roh-JSON (load_sepliste_counts) - nur Gegenprobe.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu
nummerieren.
Rueckgabe:
Analysis mit tros, boundaries, edges/edge_kinds, unknown_types
(Typnamen ohne Katalogeintrag), isolated (TROs ohne Verbindung) und
den sortierten findings.
"""
analysis = Analysis()
analysis.tros = derive_tros(graph, elements, analysis.findings,
@@ -1892,6 +2187,12 @@ def analyse_tros(
def _tro_label(tro: DerivedTro) -> str:
"""Mehrzeiliges DOT-Label eines TRO-Knotens.
Zeilen: ID und Typ, Bezeichnung, FB-Baustein, Bauteile laut Katalog,
Separatoren (ab 7 gekuerzt), Scanner, Koordinate - jeweils nur, wenn
vorhanden.
"""
lines = [f"{tro.tro_id} ({tro.type_name})", tro.label]
if tro.fb_block:
lines.append(tro.fb_block)
@@ -1981,6 +2282,7 @@ def _station_positionen(
je_tro.setdefault(anker, []).append(station)
def _abstand(a: float, b: float) -> float:
"""Kleinster Winkelabstand (rad) zweier Richtungen."""
d = abs(a - b) % (2 * math.pi)
return min(d, 2 * math.pi - d)
@@ -2060,7 +2362,7 @@ def _legend_pos_rechts(zeichen: dict[str, tuple[float, float]]) -> tuple[float,
return (0.0, 0.0)
xs = [p[0] for p in zeichen.values()]
ys = [p[1] for p in zeichen.values()]
return (max(xs) + LEGEND_MARGIN, max(ys) - 1000)
return (max(xs) + LEGEND_MARGIN, max(ys) - LEGEND_DROP)
def _zeichen_positionen(
@@ -2099,6 +2401,7 @@ STATION_FARBE = {
def _tro_edge_attrs(kind: str | None) -> str:
"""DOT-Kantenattribute zur Kantenart (TRO_EDGE_STYLE); None = "normal"."""
return TRO_EDGE_STYLE.get(kind or "normal", "")
@@ -2118,6 +2421,7 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False,
zeichen = _zeichen_positionen(analysis, compact) if use_coords else {}
def _pos(node: DerivedTro) -> str:
"""DOT-Attribut ', pos="x,y"' des Knotens oder ''."""
if use_coords:
p = zeichen.get(node.tro_id)
return (f', pos="{p[0] * COORD_SCALE:.2f},{p[1] * COORD_SCALE:.2f}"'
@@ -2336,6 +2640,16 @@ def render_connect_ini(analysis: Analysis, section: str, source: str) -> str:
def render_errors(analysis: Analysis, csv_file: Path) -> str:
"""Fehlerbericht <name>_tro_errors.md als Markdown-Text.
Parameter:
analysis: Ergebnis von analyse_tros (isolated, unknown_types).
csv_file: Eingabe-CSV, nur fuer Titel und Quellenangabe.
Rueckgabe:
Der Markdown-Text. Ob er geschrieben wird (nur bei isolierten TROs
oder unbekannten Typen), entscheidet write_error_file.
"""
out = [GENERATED_MARKER, f"# TRO-Fehler - {csv_file.name}", ""]
out.append(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} ")
out.append(f"**Quelle:** `{csv_file}` ")
@@ -2406,10 +2720,16 @@ def write_error_file(analysis: Analysis, csv_file: Path, path: Path) -> bool:
def _mermaid_text(text: str) -> str:
"""Text fuer ein Mermaid-Knotenlabel entschaerfen (\" und [] ersetzen)."""
return text.replace('"', "'").replace("[", "(").replace("]", ")")
def _tro_mermaid(analysis: Analysis) -> list[str]:
"""TRO-Fluss als Mermaid-Flowchart (Zeilen inklusive ```-Zaun).
Knoten je TRO (Farbe nach Katalogtyp) und Systemgrenze, Kanten aus
analysis.edges, gestrichelt TROs auf derselben Bahn (colocated).
"""
out = ["```mermaid", "flowchart LR"]
out.extend(f" {line}" for line in mermaid_classdefs())
@@ -2447,6 +2767,20 @@ def render_doc(
svg_file: Path | None,
error_file: Path | None,
) -> str:
"""TRO-Dokumentation <name>_tro_doc.md (--doc) als Markdown-Text.
Parameter:
analysis: Ergebnis von analyse_tros.
graph: Materialflussgraph (Host-Namen und -Arten fuer die Tabellen).
csv_file: Eingabe-CSV (Titel, Quellenangabe).
dot_file: erzeugte DOT-Datei, wird verlinkt.
svg_file: erzeugtes SVG oder None (ohne --tosvg).
error_file: Fehlerdatei oder None (fehlerfreier Lauf).
Rueckgabe:
Der Markdown-Text: TRO-Liste mit Typ und Begruendung, Mermaid-Fluss,
Verbindungen, Typdefinition aus dem Katalog, Findings.
"""
out: list[str] = [GENERATED_MARKER]
add = out.append
@@ -2650,6 +2984,17 @@ def report(
warnings: list[str],
engine: str = "dot",
) -> None:
"""Konsolenbericht eines Laufs: TRO-Zahl je Typ, Findings, Warnungen,
erzeugte Dateien.
Parameter:
analysis: Ergebnis von analyse_tros.
csv_file: Eingabe-CSV.
dot_file: erzeugte DOT-Datei.
svg_file / error_file / doc_file: erzeugte Dateien oder None.
warnings: Warnungen aus material_flow (Einlesen, Graphaufbau).
engine: Graphviz-Programm des SVG ("dot" oder "neato", mit -n).
"""
types_used: dict[str, int] = {}
for tro in analysis.tros:
types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1
@@ -2709,6 +3054,7 @@ def report(
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Kommandozeile auswerten; argv=None liest sys.argv[1:]."""
parser = argparse.ArgumentParser(
prog="tro_flow",
description="Ermittelt aus einem CSV-Export der Anlagenobjekte die "
@@ -2866,6 +3212,16 @@ def report_ids(tro_ids: TroIds, status: str) -> None:
def main(argv: list[str] | None = None) -> int:
"""CLI-Einstieg: CSV lesen, Graph bauen, analyse_tros, Ausgaben schreiben.
Parameter:
argv: Argumente ohne Programmnamen; None = sys.argv[1:].
Rueckgabe:
Exitcode: 0 = Erfolg, 1 = Eingabe- oder Ausgabefehler, 2 = SVG nicht
erzeugt, 3 = TRO-Pruefung fehlgeschlagen (isolierte TROs oder
unbekannte Typen, siehe Fehlerdatei).
"""
args = parse_args(argv)
csv_file = resolve_input(args.file)
+4 -3
View File
@@ -37,6 +37,8 @@ from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from parameter import get_float
__all__ = [
"SECTION",
"DEFAULT_TOLERANCE",
@@ -57,9 +59,8 @@ ID_FILE_SUFFIX = "_tro_ids.ini"
PREFIX = "TRO"
# Abstand (mm) zwischen Eintrag und Separator, bis zu dem ein TRO als
# wiedergefunden gilt. Separatoren derselben Bahn liegen deutlich weiter
# auseinander; ein verschobenes Bauteil faellt so als "neu" auf.
DEFAULT_TOLERANCE = 100.0
# wiedergefunden gilt - aus cfg/parameter.ini [tro_ids] (lib/parameter.py).
DEFAULT_TOLERANCE = get_float("tro_ids", "default_tolerance")
# Eine ID wird zum SCL-Bezeichner (Instanz #<ID>, "DB_TRO_TEST".<ID>).
_ID_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")