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
+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"