Files
sps_skel/lib/tro_flow.py
T
Samer AyadiandClaude Opus 5.5 97a0db6707 model.py: gemeinsames TRO-Modell; Hauptweg CSV -> tro_flow --json -> scl_skeleton
- lib/model.py (neu, pydantic): TroBase -> DerivedTro, Connection, OpenPoint,
  Analysis (aus tro_flow verschoben), TroDocument = TRO-JSON (Schema
  sps_skel-tro-v2). items/fbType werden immer ueber den Typ aus tro_catalog
  gelesen, nie gespeichert; abweichende Werte in einer JSON sind ein Fehler.
  JSON-Schluessel wie bisher (id, type, fbType ...), extra="forbid".
- tro_flow: Tro/Analysis aus model.py; neuer Schalter --json schreibt
  <name>_tro.json (TroDocument)
- scl_skeleton: liest die JSON ueber TroDocument statt per Dict-Zugriff,
  Bauteile ueber TroItem-Konstanten; ungueltige JSON -> Exit 1 ohne SCL,
  alte tro_extract-JSON (v1) mit Hinweis auf tro_flow --json
- tro_annotate: nur noch Ansicht; derselbe analyse_tros-Aufruf wie tro_flow
  (inkl. sepliste-Gegenprobe), damit Zeichnung und JSON uebereinstimmen
- tro_extract: geparkt (unveraendert)
- tests/test_model.py + conftest.py, pytest in requirements.txt aktiviert
- Doku: Python_Scripts.md (§6b model.py), Commandlines.md, CLAUDE.md,
  Aufrufkommentare in bin/ (scl_skeleton.bat zeigte tro_flow-Aufrufe)

Geprueft: alle tro_flow/tro_annotate-Ausgaben (8 CSVs) bis auf Zeitstempel
identisch zum vorherigen Stand; scl_skeleton alt/neu auf derselben JSON
byte-identisch (export, mubea, HundM05); 10 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:42:46 +02:00

2826 lines
127 KiB
Python

# -*- coding: utf-8 -*-
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
"""
tro_flow.py - TROs aus einem CSV-Export ermitteln und ihren Fluss zeichnen.
Schwesterprogramm zu material_flow.py:
material_flow.py Materialfluss der *mechanischen Objekte*
(Kreisel, Gefaellestrecke, Strecke)
tro_flow.py Fluss der *Steuerungsobjekte* (TROs)
Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das
Flussdiagramm der TROs, nicht das der Einzelobjekte.
Zusaetzlich --use-cords: jeder TRO traegt die Koordinate seiner Bauteile, und
das SVG wird mit "neato -n" lagerichtig gezeichnet statt von "dot" angeordnet.
Der Graph sieht dann aus wie das Anlagenlayout.
Was ein TRO ist
---------------
Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer
Uebergabestelle**. Ausgangspunkt ist der Separator (Vereinzeler/Sperre) und das,
was an ihm haengt (Host-Strecke, Scanner). Ableitungsregeln nach
doc/500573_Mubea/TRO_Identifikation_500573.md:
Host des Separators -> TRO-Typ
-----------------------------------------------------------------------
Gefaellestrecke, >= 2 Linien -> PinStore_Auto (ein Block je Gruppe)
Gefaellestrecke, einzelne Linie -> 1Sep
Strecke (angetrieben) -> Vario (je Separator, siehe unten)
Kreisel-Bahn -> 1Sep
Zwei Ausnahmen stufen einen Separator auf einer "Strecke" trotzdem als 1Sep
statt Vario ein (Review 2026-09-23/25, HundM05):
1. Alternierendes Muster vom Wiedereinschleusen auf die Kreisel-Bahn
rueckwaerts (in TrackIds-Reihenfolge, nur echte Kettenmitglieder - siehe
"eigenstaendige Vereinzeler" unten): der letzte Separator vor dem
Wiedereinschleusen ist ein reiner Vereinzeler ohne eigene VarioDrive-
Strecke (die Kreisel-Bahn treibt ab dort selbst an), der vorletzte wieder
ein eigener Vario-Antriebspunkt, der drittletzte wieder 1Sep usw. - eine
Streckengruppe ist baulich meist eine alternierende VF/GF-Kette
(angetriebener Abschnitt, Vereinzeler, unbetriebener Abschnitt,
Vereinzeler, ...), nicht nur am Ende. Gilt fuer jede Separatoranzahl >= 2
(z. B. VF_3: erster Separator 0041 = Vario, letzter 0040 = 1Sep); bei
genau einem Separator bleibt dieser Vario (einziger Antriebspunkt der
Strecke).
2. Eine "Strecke" (Streckengruppe) ist selbst oft eine gemischte VF/GF-Kette:
sie enthaelt sowohl angetriebene (VF, jede Antriebfahrtrichtung -
horizontal, Auf oder Ab zaehlt gleichermassen als VF) als auch unbetriebene
Gefaelle-Teilstuecke (GF) - siehe Merkmal "Antriebfahrtrichtung"/"VF_Winkel"
je TrackIds-Segment. Ein laut Regel 1 als Vario erwarteter Separator,
dessen unmittelbar vorangehendes Segment als "horizontal" (=keine aktive
Antriebsrichtung) gefuehrt wird, dabei aber tatsaechlich an Hoehe verliert
(Merkmal "Hoehe" der Anbauteile, > 30mm Gefaelle), sitzt auf einem
GF-Teilstueck und wird ebenfalls 1Sep (siehe _incoming_drop) - eine
unabhaengige Zusatzpruefung neben der Positions-Regel, kein Ersatz dafuer;
ein laut Regel 1 bereits 1Sep bleibt 1Sep. Achtung: diese Hoehen-/
Antriebsangaben sind bei mehreren Streckengruppen desselben Typs (z. B.
VF_2/VF_3/VF_4 bei HundM05) identisch und damit vermutlich unkorrigierte
CAD-Blockvorgaben, nicht echte Einzelmessungen - Regel 1 (Position in der
Kette) ist deshalb der robustere Nachweis, Regel 2 nur ein Zusatzfund fuer
Faelle mit echten, individuellen Messwerten (wie VF_1).
Separatoren, deren Zuordnung zwar auf eine Streckengruppe zeigt, die aber
nicht Teil von deren eigener TrackIds-Kette sind, zaehlen bei beiden Regeln
nicht mit - sie sind eigenstaendige Vereinzeler (plain 1Sep, siehe
derive_tros Schritt 2) und treiben das alternierende Muster nicht mit.
Ein Scanner am Separator aendert den Typ NICHT: in connect.ini der
Referenzanlage sind Separatoren mit Scanner ueberwiegend 1Sep (z. B.
"TRO104 = 1Sep | TRO 104 (1Sep)+Scanner"). 1Sep_SSCC ist dem Sonderfall mit
SSCC-Scanner, Endmessung und WCS-Telegramm vorbehalten (nur TRO105/TRO110) und
laesst sich aus dem mechanischen Layout nicht erkennen - es wird nur ein
Hinweis ausgegeben.
Zusaetzlich: hat ein Host 2 bzw. 3 abgehende Flusskanten, wird 1Sep zu
1Sep1Swi bzw. 1Sep2Swi aufgewertet (Weiche).
Typen, FB-Bausteine, Bauteile und Farben kommen ausschliesslich aus
lib/tro_catalog.py.
Ausgabe (alles in SKEL_RESULTS)
-------------------------------
<name>_tro_flow.dot immer (mit --use-cords lagerichtige pos-Attribute)
<name>_tro_flow.svg mit --tosvg (dot, mit --use-cords neato -n)
<name>_tro_doc.md mit --doc
<name>_tro.json mit --json: TRO-JSON (model.TroDocument), Eingabe
fuer scl_skeleton.py
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
Aufruf ueber bin/tro_flow.bat bzw. bin/tro_flow.sh.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import sys
from datetime import datetime
from pathlib import Path
from material_flow import (
EDGE_CIRCLE,
EDGE_FLOW,
EDGE_UNRESOLVED,
LANE_LEFT,
LANE_RIGHT,
ROTATION_CCW,
ROTATION_CW,
TRANSPORT_KINDS,
UNASSIGNED,
Element,
Graph,
_as_float,
_dot_escape,
_dot_label,
_merkmal,
build_graph,
env_dir,
graph_to_svg,
read_elements,
resolve_input,
)
from model import Analysis, DerivedTro, TroDocument
from tro_catalog import (
CATALOG_AS_OF,
ITEM_FB,
STYLE_EXT,
STYLES,
TRO_CATALOG,
TroItem,
get_tro,
mermaid_classdefs,
)
# ---------------------------------------------------------------------------
# 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
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
# ---------------------------------------------------------------------------
def _group_lanes(lanes: list[str], graph: Graph) -> list[list[str]]:
"""
Schwerkraftlinien zu Speicherbloecken gruppieren.
Linien, die sich einen Zu- oder Ablaufknoten teilen, gehoeren zum selben
Speicher. So entstehen bei zwei getrennten Speichern auch zwei TROs.
"""
adjacency: dict[str, set[str]] = {}
for lane in lanes:
neighbours: set[str] = set()
for edge in graph.edges:
if edge.src == lane:
neighbours.add(edge.dst)
elif edge.dst == lane:
neighbours.add(edge.src)
adjacency[lane] = neighbours
groups: list[dict] = []
for lane in sorted(lanes):
current = {"lanes": [lane], "nodes": set(adjacency[lane]) | {lane}}
for group in [g for g in groups if current["nodes"] & g["nodes"]]:
current["lanes"].extend(group["lanes"])
current["nodes"] |= group["nodes"]
groups.remove(group)
groups.append(current)
for group in groups:
group["lanes"].sort()
groups.sort(key=lambda g: g["lanes"][0])
return [group["lanes"] for group in groups]
def _kreisel_bahn(sep: Element, elements: list[Element], graph: Graph) -> str | None:
"""Kreisel-Bahn-Knoten eines Separators, der in der Umlaufkette
(Element.track_ids) eines Kreisels steht - sonst None.
dxfmakros traegt bei Kreisel-Separatoren als "Zuordnung" oft die naechste
Strecke statt der Bahn ein (Dematic/Arvato: 0043 -> VF_5, 0048 -> VF_4,
0046 -> Gefaellestrecke 0037; nur 0038/0047 stehen auf "0002-L"/"0003-R").
Bei einer Gefaellestrecke zaehlt er dann faelschlich als Speicherlinien-
Separator (Aufrufer in derive_tros wendet es nur dort an).
Die Bahn ergibt sich aus der Seite der Kreisel-Achse ("Drehung"): links
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).
"""
for k in elements:
if k.kind != "Kreisel" or sep.teile_id not in k.track_ids:
continue
drehung = _as_float(_merkmal(k.merkmale, "Drehung"))
if drehung is None or None in (sep.x, sep.y, k.x, k.y):
return None
rad = math.radians(drehung)
kreuz = math.cos(rad) * (sep.y - k.y) - math.sin(rad) * (sep.x - k.x)
bahn = f"{k.teile_id}-{LANE_LEFT if kreuz > 0 else LANE_RIGHT}"
return bahn if bahn in graph.nodes else None
return None
def _weichen_nach_umlauf(
switch_hits: dict[str, tuple[Element, float]],
elements: list[Element],
findings: list[str],
) -> dict[str, tuple[Element, float]]:
"""Weichen-Separatoren verwerfen, die in Laufrichtung NACH ihrem
Ausschleuselement liegen.
_find_switch_separators paart ueber die CSV-Nachbarn - die kennen aber
keine Laufrichtung, und am Ring ist auch das erste mit dem letzten
Element der TrackIds-Liste benachbart. Ein Separator steuert ein
Ausschleuselement nur, wenn er der LETZTE Separator davor ist (Element.
track_ids des Kreisels ist in Laufrichtung sortiert, bei gesetzter
Drehrichtung ein geschlossener Ring). Dematic/Arvato (Review 2026-10-06):
0047 sitzt auf Kreisel3 am Kopf der Gegenbahn und war ueber den Ring-
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).
"""
by_id = {e.teile_id: e for e in elements}
kreisel = [e for e in elements if e.kind == "Kreisel" and e.track_ids]
result: dict[str, tuple[Element, float]] = {}
for sep_id, (transfer, distance) in switch_hits.items():
k = next((k for k in kreisel
if transfer.teile_id in k.track_ids and sep_id in k.track_ids), None)
if k is None:
result[sep_id] = (transfer, distance)
continue
ids = list(k.track_ids)
i = ids.index(transfer.teile_id)
ring = k.rotation in (ROTATION_CW, ROTATION_CCW)
davor = ids[:i][::-1] + (ids[i + 1:][::-1] if ring else [])
vorgaenger = next((t for t in davor
if (el := by_id.get(t)) and el.kind == "Separator"), None)
if vorgaenger == sep_id:
result[sep_id] = (transfer, distance)
else:
findings.append(
f"Separator {sep_id}: CSV-Nachbar von Ausschleuselement "
f"{transfer.teile_id}, liegt in Laufrichtung aber nicht davor "
f"(letzter Separator davor: {vorgaenger or '-'}) - keine Weiche, "
f"gewoehnlicher Kreisel-Separator")
return result
def _weichen_aus_umlauf(
switch_hits: dict[str, tuple[Element, float]],
kandidaten: list[Element],
elements: list[Element],
findings: list[str],
) -> dict[str, tuple[Element, float]]:
"""Weichen-Separatoren ergaenzen, die die CSV-Nachbarn nicht zeigen.
Ein Separator, auf den in Laufrichtung ein Ausschleuselement folgt, ohne
Separator dazwischen, steuert diese Weiche - auch wenn das Element nicht
in seinen "Nachbarn" steht (dort stehen nur die unmittelbaren Ring-
Nachbarn, oft ein Einschleuselement). Dematic/Arvato Kreisel1 (Review
2026-10-08): 0043 -> Ausschleuselement 0071 (VF_5), Nachbarn von 0043
sind nur die Einschleuselemente von VF_4/VF_6. Weitere Elemente bis zum
naechsten Separator ergaenzt derive_tros Schritt 3b' (1Sep2Swi).
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).
"""
by_id = {e.teile_id: e for e in elements}
erlaubt = {s.teile_id for s in kandidaten}
belegt = {t.teile_id for t, _d in switch_hits.values()}
result = dict(switch_hits)
for kreisel in (e for e in elements if e.kind == "Kreisel" and e.track_ids):
ids = list(kreisel.track_ids)
ring = kreisel.rotation in (ROTATION_CW, ROTATION_CCW)
for i, as_id in enumerate(ids):
transfer = by_id.get(as_id)
if transfer is None or transfer.kind != "Ausschleus" or as_id in belegt:
continue
strecke = by_id.get(str(_merkmal(transfer.merkmale, "Strecke", "Förderer") or ""))
if strecke is not None and strecke.kind == "Gefaellestrecke":
continue
davor = ids[:i][::-1] + (ids[i + 1:][::-1] if ring else [])
sep_id = next((t for t in davor
if (el := by_id.get(t)) and el.kind == "Separator"), None)
if sep_id is None or sep_id in result or sep_id not in erlaubt:
continue
sep = by_id[sep_id]
d = (math.hypot(transfer.x - sep.x, transfer.y - sep.y)
if None not in (sep.x, sep.y, transfer.x, transfer.y) else 0.0)
result[sep_id] = (transfer, d)
belegt.add(as_id)
findings.append(
f"Separator {sep_id}: steuert Ausschleuselement {as_id} (folgt in "
f"Laufrichtung ohne Separator dazwischen, kein CSV-Nachbar) - Weiche")
return result
def _speicher_bloecke(
lanes: list[str],
elements: list[Element],
switch_hits: dict[str, tuple[Element, float]],
) -> tuple[dict[str, tuple[str, int]], dict[str, tuple[str, int]]]:
"""Schwerkraftlinien je Kreisel in Speicherbloecke schneiden.
_group_lanes fasst alle Linien mit gemeinsamem Zu-/Ablauf zusammen - an
Dematic/Arvato haengen aber alle 27 Linien an denselben zwei Kreiseln und
bilden doch DREI Bloecke (10/7/10), je von einem eigenen Separator auf der
Kreisel-Bahn gesteuert, dazwischen kehren VF_1..VF_3 zurueck (Review
2026-10-06). Grenze ist darum die Umlaufreihenfolge (Element.track_ids):
die Ausschleuselemente der Linien folgen dort lueckenlos aufeinander, ein
Separator dazwischen beginnt einen neuen Block. Mubea Kreisel3 (20
Ausschleuselemente ohne Separator dazwischen) bleibt ein Block.
Steuer-Separator eines Blocks ist der Weichen-Separator (switch_hits,
_find_switch_separators), dessen Ausschleuselement zu einer Linie des
Blocks gehoert: er steht auf der Kreisel-Bahn direkt davor und hat das
erste Ausschleuselement des Blocks als CSV-Nachbarn (Dematic 0040 ->
0083). Er gehoert in den PinStore_Auto-TRO (Katalog: 1x Separator +
Scanner + Encoder + Speicherlinien), nicht in einen eigenen 1Sep1Swi -
das Ausschleuselement einer Speicherlinie ist keine Weiche einer Strecke.
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.
"""
lane_set = set(lanes)
by_id = {e.teile_id: e for e in elements}
as_lane: dict[str, str] = {}
for e in elements:
if e.kind != "Ausschleus":
continue
strecke = str(_merkmal(e.merkmale, "Strecke", "Förderer") or "")
if strecke in lane_set:
as_lane[e.teile_id] = strecke
lane_block: dict[str, tuple[str, int]] = {}
as_block: dict[str, tuple[str, int]] = {}
for kreisel in (e for e in elements if e.kind == "Kreisel"):
ids = list(kreisel.track_ids)
if not any(t in as_lane for t in ids):
continue
# Am ersten Separator beginnen, damit ein Block nicht ueber das
# Listenende hinweg in zwei Teile zerfaellt (die Kette ist ein Ring).
start = next((i for i, t in enumerate(ids)
if (el := by_id.get(t)) and el.kind == "Separator"), 0)
ids = ids[start:] + ids[:start]
nummer, offen = 0, False
for t in ids:
el = by_id.get(t)
if el is not None and el.kind == "Separator":
offen = False
elif t in as_lane:
if not offen:
nummer += 1
offen = True
block = (kreisel.teile_id, nummer)
as_block[t] = block
lane_block.setdefault(as_lane[t], block)
steuer: dict[str, tuple[str, int]] = {}
for sep_id, (transfer, _d) in switch_hits.items():
block = as_block.get(transfer.teile_id)
if block is not None:
steuer[sep_id] = block
return lane_block, steuer
def _switch_exits(hosts: list[str], graph: Graph) -> list[str]:
"""Abgehende Flusskanten eines TRO-Hosts (ohne Kreisel-Umlauf)."""
return sorted(
{
edge.dst
for edge in graph.edges
if edge.kind == EDGE_FLOW and edge.src in hosts and edge.dst not in hosts
}
)
def _default_resolve_host(sep: Element, findings: list[str], graph: Graph) -> str | None:
"""Host eines Separators aus der CSV-Spalte 'Zuordnung' (Default-Resolver)."""
host = sep.assignment
if host and host != UNASSIGNED and host in graph.nodes:
return host
findings.append(
f"Separator {sep.teile_id} '{sep.name}': Zuordnung '{host or '-'}' "
f"zeigt auf keinen Flussknoten - keinem TRO zugeordnet"
)
return None
def _find_switch_separators(
separators: list[Element], elements: list[Element]
) -> dict[str, tuple[Element, float]]:
"""Separator-Id -> (Ausschleuselement der Weiche, Abstand mm).
Ein Separator ist eine Weiche, wenn ihm ein ILS Ausschleuselement
zugeordnet werden kann - das Element ist die Weiche (siehe unten), der
Separator nur der Steuerpunkt davor. NICHT das Einschleuselement: das
Wiedereinschleusen ist ein passiver Merge ohne Richtungsentscheidung, nur
das Ausschleusen (Verlassen der Kreisel-Bahn) ist eine Weichenstelle - vor
Ort bestaetigt (HundM05, Review 2026-09-21): ein Separator an einem
Einschleuselement ist keine eigene Uebergabestelle. Ein eventueller
Scanner am selben Separator ist ebenfalls kein Kriterium, sondern nur ein
zusaetzliches Bauteil des TROs (siehe derive_tros/scanners_for) - viele
Separatoren tragen einen Scanner, ohne eine Weiche zu sein.
Frueher ueber eine reine Distanzschwelle ermittelt (<=500mm, mit einer
grosszuegigeren Fallback-Schwelle bis 1500mm fuer weiter entfernte
Faelle). Das erwies sich als nicht plausibel genug in beide Richtungen
(Review 2026-09-25): die Fallback-Schwelle paarte an Mubea wiederholt
voellig unbeteiligte Separatoren mit dem naechstgelegenen noch freien
Ausschleuselement, ohne echten Bezug zwischen beiden - vor Ort geprueft,
keine Weiche an der Stelle. Eine reine Nahdistanz ohne Fallback haette
umgekehrt an HundM05 alle 5 echten Weichen verfehlt, die dort
gleichmaessig bei 1013-1016mm liegen (Review 2026-09-18) - Distanz allein
ist also weder an Mubea noch an HundM05 ein verlaessliches Kriterium.
Stattdessen wird die im CSV-Export bereits vorhandene topologische
Nachbarschaft verwendet (Spalte "Nachbarn", Element.neighbours): dxfmakros
traegt dort den tatsaechlich anliegenden Nachbarn ein (CAD-Topologie),
nicht nur den geometrisch naechsten - an HundM05 steht das echte
Ausschleuselement trotz 1013-1016mm Abstand direkt in den Nachbarn des
Schalt-Separators, an Mubea NICHT in den Nachbarn der zu Unrecht per
Fallback getroffenen Separatoren. Ein Separator ist eine Weiche, wenn:
1. ein Ausschleuselement unter seinen Nachbarn steht, UND
2. dieses Ausschleuselement zur selben Kreisel-Bahn gehoert wie der
Separator (gleiche "Zuordnung", z. B. beide auf Kreisel "0002" -
Element.track_ids des betreffenden Kreisels enthaelt das
Ausschleuselement). Das ist nicht nur eine Absicherung gegen einen
ungenau exportierten "Nachbarn"-Eintrag, sondern noetig: ein
Ausschleuselement markiert nicht nur den Kreisel->Strecke-Uebergang,
sondern an einem PinStore_Auto-Speicherblock auch den Ablauf jeder
einzelnen Schwerkraftlinie zum gemeinsamen Sammler - jeder
Linien-Separator hat dort ein Ausschleuselement als CSV-Nachbarn, ohne
damit eine echte Weiche zu sein (Review 2026-09-25, Mubea: 20
Speicherlinien faelschlich als 1Sep1Swi einsortiert, weil ihre
Kreisel-Zuordnung fehlt und Kriterium 2 dafuer faelschlich nicht griff).
Ist die Kreisel-Zuordnung des Separators nicht aufloesbar (er sitzt gar
nicht auf einer Kreisel-Bahn), ist er deshalb KEIN Weichenkandidat -
Kriterium 1 allein reicht nicht.
Die Distanz im Rueckgabewert ist nur noch Beiwerk fuer die Doku ("XXXmm
von Ausschleuselement YYYY entfernt"), kein Kriterium mehr.
"""
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"}
result: dict[str, tuple[Element, float]] = {}
for sep in separators:
sep_kreisel_id = sep.assignment.split("-", 1)[0] if sep.assignment else ""
kreisel = kreisel_by_id.get(sep_kreisel_id)
# Zuordnung zeigt auf die Strecke statt auf den Kreisel, der Separator
# steht aber in der TrackIds-Kette eines Kreisels (Mubea 0005: Zuordnung
# VF_2, liegt auf Kreisel2 Bahn R direkt vor Ausschleuselement 0057) -
# dann gilt dieser Kreisel. Die Gegenprobe "Ausschleuselement in
# derselben Kette" (Kriterium 2) bleibt unveraendert.
bahn_kreisel = [k for k in kreisel_by_id.values() if sep.teile_id in k.track_ids]
if kreisel is not None:
bahn_kreisel = [kreisel]
if not bahn_kreisel:
continue
for base, _lane in sep.neighbours:
transfer = transfers.get(base)
if transfer is None:
continue
if not any(transfer.teile_id in k.track_ids for k in bahn_kreisel):
continue
d = (
math.hypot(transfer.x - sep.x, transfer.y - sep.y)
if None not in (sep.x, sep.y, transfer.x, transfer.y)
else 0.0
)
result[sep.teile_id] = (transfer, d)
break
return result
def _find_weiche_separators(
separators: list[Element], elements: list[Element], findings: list[str]
) -> dict[str, Element]:
"""Separator-Id -> ILS Weiche, die er steuert.
Eine "ILS Weiche" (TeileArt, Element.kind "Weiche") ist ein ECHTES
Weichen-Bauteil - anders als das Ausschleuselement in
_find_switch_separators, das die Weichenrolle nur uebernimmt. dxfmakros
erzeugt sie z. B. am Beruehrpunkt zweier Kreisel (Merkmal "Erzeugt aus":
"Kreisel-Beruehrpunkt"; Mubea 0125 zwischen Kreisel2 und Kreisel1).
Steuernder Separator ist der Separator unter den CSV-Nachbarn der Weiche,
der einen Scanner traegt (Scanner-Merkmal "Naechster Separator"). Mubea
0125: Nachbarn 0040 und 0036, nur 0036 hat einen Scanner (0041) - vor Ort
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.
"""
by_id = {s.teile_id: s for s in separators}
mit_scanner = {
str(_merkmal(e.merkmale, "Naechster Separator") or "").strip()
for e in elements if e.kind == "Scanner"
}
result: dict[str, Element] = {}
for weiche in (e for e in elements if e.kind == "Weiche"):
kandidaten = sorted({base for base, _lane in weiche.neighbours if base in by_id})
gewaehlt = [s for s in kandidaten if s in mit_scanner]
if len(gewaehlt) != 1:
gewaehlt = kandidaten if len(kandidaten) == 1 else []
if not gewaehlt:
findings.append(
f"ILS Weiche {weiche.teile_id}: steuernder Separator nicht "
f"eindeutig (benachbart: {', '.join(kandidaten) or '-'}) - "
f"keinem TRO zugeordnet"
)
continue
result[gewaehlt[0]] = weiche
return result
def _incoming_drop(
host: Element, sep_id: str, elements_by_id: dict[str, Element]
) -> tuple[str | None, float | None]:
"""Antriebfahrtrichtung und Hoehendifferenz (mm) des TrackIds-Segments,
das unmittelbar vor sep_id in host.track_ids liegt.
host ist eine "Strecke" (Streckengruppe); ihr Merkmal "Antriebfahrtrichtung"
ist eine Kommaliste, ein Eintrag je Segment zwischen zwei aufeinander-
folgenden TrackIds-Elementen (siehe derive_tros/Docstring-Tabelle). Fehlt
sep_id in der Kette, ist es das erste Element (kein vorangehendes Segment)
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.
"""
chain = host.track_ids
if sep_id not in chain:
return None, None
idx = chain.index(sep_id)
if idx == 0:
return None, None
prev = elements_by_id.get(chain[idx - 1])
cur = elements_by_id.get(sep_id)
if prev is None or cur is None:
return None, None
prev_h = _as_float(_merkmal(prev.merkmale, "Höhe", "Hoehe"))
cur_h = _as_float(_merkmal(cur.merkmale, "Höhe", "Hoehe"))
if prev_h is None or cur_h is None:
return None, None
drive_list = [d.strip() for d in (host.drive_dir or "").split(",")]
drive = drive_list[idx - 1] if idx - 1 < len(drive_list) else None
return drive, cur_h - prev_h
def find_sibling_raw_json(csv_path: Path) -> Path | None:
"""<CSV-Stamm, klein geschrieben>_raw.json neben der CSV, falls vorhanden.
Optionale Roh-Attributsammlung des DXF-Exports (dxfmakros): eine flache
Liste von Bloecken mit 'attribs' (u. a. 'ID') und bei Strecke/
Gefaellestrecke zusaetzlich einer 'sepliste' (siehe load_sepliste_counts).
Kein Pflichtbestandteil des Exports - existiert die Datei nicht, liefert
diese Funktion None und die Gegenprobe entfaellt einfach.
"""
candidate = csv_path.with_name(f"{csv_path.stem.lower()}_raw.json")
return candidate if candidate.is_file() else None
def load_sepliste_counts(raw_json_path: Path, warnings: list[str]) -> dict[str, int]:
"""Container-Id (Roh-JSON 'attribs.ID') -> Anzahl 'SEP'-Eintraege in dessen 'sepliste'.
Reine Gegenprobe gegen die aus der CSV abgeleitete Separatorenzahl je
Host (siehe derive_tros) - NICHT zur Rekonstruktion fehlender Separatoren
gedacht. Zwei Merkmale des Roh-JSON erwiesen sich als unzuverlaessig und
werden deshalb bewusst NICHT verwendet (Review 2026-09-24, HundM05):
- Der jeweils ERSTE 'SEP'-Eintrag jedes VF-Blocks traegt durchgehend eine
kaputte Position (x = -Block-x, y ein Fantasiewert) - ein Bug im
DXF-Export, keine echte Platzierung. Die sepliste-IDs stammen ausserdem
aus einem anderen Nummerierungslauf als die CSV und lassen sich nicht
darauf abbilden - nur die ANZAHL der 'SEP'-Eintraege ist verwertbar,
nicht ihre Position oder ihre Id.
- Das Attribut "ANZAHL_SEPARATOR" auf dem Block selbst weicht in beiden
bisher geprueften Anlagen sowohl von der TrackIds-Zaehlung als auch von
der sepliste-Zaehlung ab (z. B. HundM05 VF_1: TrackIds/sepliste sagen 4,
ANZAHL_SEPARATOR sagt 9) - vermutlich ein veraltetes/doppelt gezaehltes
Katalog-Attribut, keine verlaessliche Quelle.
Bei fehlender oder kaputter Datei (z. B. ein noch unvollstaendig
geschriebenes *_raw.json) wird ein Warnhinweis angehaengt und ein leeres
dict geliefert - die Gegenprobe entfaellt dann einfach, ohne den Lauf
abzubrechen.
"""
try:
raw = json.loads(raw_json_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
warnings.append(
f"{raw_json_path.name}: nicht lesbar/kein gueltiges JSON ({exc}) - "
f"sepliste-Gegenprobe uebersprungen"
)
return {}
counts: dict[str, int] = {}
for item in raw if isinstance(raw, list) else []:
container_id = str((item.get("attribs") or {}).get("ID") or "")
sepliste = item.get("sepliste")
if not container_id or not sepliste:
continue
counts[container_id] = sum(1 for s in sepliste if s.get("typ") == "SEP")
return counts
def derive_tros(
graph: Graph,
elements: list[Element],
findings: list[str],
*,
sepliste_counts: dict[str, int] | None = None,
) -> 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).
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).
"""
def resolve_host(sep: Element, findings: list[str]) -> str | None:
return _default_resolve_host(sep, findings, graph)
def count_exits(hosts: list[str]) -> list[str]:
return _switch_exits(hosts, graph)
separators = sorted(
(e for e in elements if e.kind == "Separator"), key=lambda e: e.teile_id
)
scanners = sorted(
(e for e in elements if e.kind == "Scanner"), key=lambda e: e.teile_id
)
elements_by_id = {e.teile_id: e for e in elements}
# Separatoren, die bereits ein echtes VF-Kettenmitglied ihrer EIGENEN
# Zuordnung-Streckengruppe sind (siehe Schritt 2/_incoming_drop unten),
# werden von der Weichenerkennung ausgenommen: Naehe zum eigenen Aus-/
# Einschleuselement ist bei ihnen der NORMALE, erwartete Aufbau jeder
# angetriebenen Streckengruppe (TrackIds = [Aus-, 1x Separator, Ein-
# schleuselement], siehe Mubea VF_1-VF_3), keine Weichenevidenz. Ohne
# diesen Ausschluss faengt Regel 1 faelschlich genau diesen Separator ein
# (Mubea 0005, 163mm - der engste, staerkste Treffer ueberhaupt, aber kein
# Weichen-Indiz - Review 2026-09-24).
own_vf_separators = {
sep.teile_id
for sep in separators
if sep.assignment
and (host := elements_by_id.get(sep.assignment))
and host.kind == "Strecke"
and sep.teile_id in host.track_ids
}
switch_candidates = [s for s in separators if s.teile_id not in own_vf_separators]
# Separatoren an einem Ausschleuselement (siehe _find_switch_separators)
# werden VOR der normalen Host-Aufloesung herausgenommen: sie bilden gleich
# unten (3b) ihre eigene 1Sep1Swi-Uebergabestelle und duerfen nicht zugleich
# als gewoehnlicher Separator ihres Zuordnung-Hosts (Kreisel-Bahn oder
# Strecke - beides kommt vor) mitgezaehlt werden.
switch_hits = _weichen_aus_umlauf(
_weichen_nach_umlauf(
_find_switch_separators(switch_candidates, elements), elements, findings),
switch_candidates, elements, findings)
# Separatoren an einer echten ILS Weiche (siehe _find_weiche_separators) -
# 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,
)
# 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:
continue
host = resolve_host(sep, findings)
# Steht er in der Umlaufkette eines Kreisels, zeigt die Zuordnung aber
# auf eine Gefaellestrecke, ist das nur die naechstgelegene Linie
# (siehe _kreisel_bahn) - sonst zaehlte er als Speicherlinien-
# Separator (Dematic 0046 in Speicherblock C). Bewusst NUR fuer
# Gefaellestrecken: Kreisel-Separatoren mit einer VF-Strecke als
# Zuordnung bleiben wie bisher (HundM05 0018 laut Review 2026-09-21
# Teil seiner Strecke; ein Umhaengen nummerierte HundM05/Mubea um).
if host is not None and graph.nodes[host].kind == "Gefaellestrecke":
bahn = _kreisel_bahn(sep, elements, graph)
if bahn is not None:
host = bahn
if host is not None:
sep_host[sep.teile_id] = host
# Scanner -> Separator; "Naechster Separator" ist die funktionale Zuordnung,
# "Zuordnung" nur der Montageort.
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:
sep_scanners.setdefault(target, []).append(scanner.teile_id)
mounted = scanner.assignment
target_host = sep_host.get(target)
# Fuer Switch-Separatoren (3b) gibt es keinen einzelnen Host zum
# Vergleichen (sie haengen zwischen zwei Ketten) - der Abgleich
# entfaellt dort, ohne den Scanner deshalb als "unbekannt" zu werten.
if (mounted and mounted != UNASSIGNED and target_host is not None
and mounted != target_host):
findings.append(
f"Scanner {scanner.teile_id} '{scanner.name}': montiert an {mounted}, "
f"liest aber an Separator {target} auf {target_host}"
)
else:
findings.append(
f"Scanner {scanner.teile_id} '{scanner.name}': 'Naechster Separator' "
f"'{target or '-'}' unbekannt - keinem TRO zugeordnet"
)
def seps_on(hosts: list[str]) -> list[str]:
return sorted(s for s, h in sep_host.items() if h in hosts)
def scanners_for(seps: list[str]) -> list[str]:
found: list[str] = []
for sep in seps:
found.extend(sep_scanners.get(sep, []))
return sorted(found)
# Sepliste-Gegenprobe (siehe load_sepliste_counts/Docstring oben): reine
# Beobachtung, veraendert weder sep_host noch die spaeter abgeleiteten
# TROs - die CSV bleibt die massgebliche Quelle.
if sepliste_counts:
for node_id, node in sorted(graph.nodes.items()):
if node.kind not in ("Strecke", "Gefaellestrecke"):
continue
expected = sepliste_counts.get(node_id)
if expected is None:
continue
actual = len(seps_on([node_id]))
if actual != expected:
findings.append(
f"{node.element.name} ({node_id}): {actual} Separator(en) aus "
f"der CSV zugeordnet, aber {expected} 'SEP'-Eintraege in "
f"sepliste (Roh-JSON) - Gegenprobe weicht ab, bitte pruefen"
)
tros: list[DerivedTro] = []
# 1) Schwerkraftlinien (Gefaellestrecke mit Separator) -> Speicherblock
lanes = sorted(
{h for h in sep_host.values() if graph.nodes[h].kind == "Gefaellestrecke"}
)
# Speicherbloecke nach Umlaufreihenfolge und Steuer-Separator schneiden
# (siehe _speicher_bloecke). Steuer-Separatoren eines gebildeten Speichers
# bekommen in 3b keinen eigenen 1Sep1Swi mehr.
lane_block, steuer_block = _speicher_bloecke(lanes, elements, switch_hits)
speicher_steuer: set[str] = set()
teilgruppen: list[list[str]] = []
for group in _group_lanes(lanes, graph):
nach_block: dict = {}
for lane in group:
nach_block.setdefault(lane_block.get(lane), []).append(lane)
teilgruppen.extend(sorted(nach_block.values(), key=lambda g: g[0]))
for group in teilgruppen:
seps = seps_on(group)
if len(group) >= MIN_STORE_LANES:
bloecke = {lane_block.get(lane) for lane in group} - {None}
steuer = sorted(s for s, b in steuer_block.items() if b in bloecke)
speicher_steuer.update(steuer)
alle_seps = steuer + seps
tros.append(
DerivedTro(
tro_id="",
type_name="PinStore_Auto",
label=f"Speicher ({len(group)} Schwerkraftlinien)",
hosts=group,
separators=alle_seps,
scanners=scanners_for(alle_seps),
reason=(
f"{len(group)} Gefaellestrecken mit je eigenem Separator, "
f"gemeinsamer Zu-/Ablauf - Muster eines Linienspeichers"
+ (f"; Steuer-Separator {', '.join(steuer)} auf der "
f"Kreisel-Bahn davor" if steuer else "")
),
confidence="hoch",
)
)
else:
for lane in group:
lane_seps = seps_on([lane])
tros.append(
DerivedTro(
tro_id="",
type_name="1Sep",
label=graph.nodes[lane].element.name,
hosts=[lane],
separators=lane_seps,
scanners=scanners_for(lane_seps),
reason="einzelne Gefaellestrecke mit Separator (kein Linienspeicher)",
confidence="mittel",
)
)
# 2) Angetriebene Strecken -> je SEPARATOR ein eigener Vario-TRO. Katalog
# (TRO_CATALOG in tro_catalog.py) definiert Vario als genau 1x Separator +
# 1x VarioDrive - ein Vario-TRO ist die Paarung eines einzelnen VF-
# Teilstuecks mit dem einen Separator, den es antreibt, kein Sammel-TRO
# fuer die ganze Streckengruppe (Review 2026-09-21/24). Mehrere
# Separatoren auf demselben Streckensegment sind deshalb mehrere
# eigenstaendige Uebergabestellen. Ein Segment ganz ohne Separator bildet
# folgerichtig KEIN eigenes TRO (kein Separator zum Paaren) - anders als
# bisher wird es nicht mehr als Platzhalter-Vario mit leerer
# Separatorliste gefuehrt.
for node_id in sorted(n for n, node in graph.nodes.items() if node.kind == "Strecke"):
seps = seps_on([node_id])
if not seps:
continue
element = graph.nodes[node_id].element
drive = element.drive_dir or "unbekannt"
reason = f"angetriebenes Streckensegment (Antriebfahrtrichtung: {drive})"
# Alternierendes Muster vom Wiedereinschleusen auf die Kreisel-Bahn
# rueckwaerts (in echter TrackIds-Reihenfolge, nicht sortierte
# Teile-Id): eine Streckengruppe ist baulich meist eine alternierende
# VF/GF-Kette, deshalb wechselt auch der TRO-Typ ab dem letzten
# Separator (naechster Nachbar der Kreisel-Bahn, reiner Vereinzeler
# ohne eigene VarioDrive-Strecke - die Kreisel-Bahn treibt ab dort
# selbst an) rueckwaerts durch: letzter = 1Sep, davor Vario, davor
# wieder 1Sep usw. Bestaetigt am Beispiel VF_3 (Review 2026-09-23):
# TRO11 (erster Separator 0041, direkt nach dem Ausschleusen) ist
# Vario, TRO10 (zweiter/letzter Separator 0040, vor dem
# Wiedereinschleusen) ist 1Sep; siehe auch VF_1 (0034, letzter von
# vieren). Fuer genau 2 Separatoren identisch zur frueheren "nur der
# letzte wird umgestuft"-Regel; ab 3 weicht es ab (Review 2026-09-25)
# - vorher blieben alle bis auf den letzten pauschal Vario, obwohl die
# Kette baulich mehrfach zwischen VF und GF wechselt. Bei genau einem
# Separator gibt es kein "erster vs. letzter", der bleibt Vario
# (einziger Antriebspunkt der Strecke).
seps_set = set(seps)
track_seps = [tid for tid in element.track_ids if tid in seps_set]
track_seps_set = set(track_seps)
alt_type: dict[str, str] = {}
alt_offset: dict[str, int] = {}
if len(track_seps) > 1:
for offset, tid in enumerate(reversed(track_seps)):
alt_offset[tid] = offset
alt_type[tid] = "1Sep" if offset % 2 == 0 else "Vario"
for sep_id in seps:
if sep_id not in track_seps_set:
# Zuordnung zeigt zwar auf diese Streckengruppe, der Separator
# ist aber nicht Teil von deren eigener TrackIds-Kette - kein
# VF-Teilstueck treibt ihn an, also plain 1Sep statt Vario
# (Mubea VF_1-VF_3 vor Ort bestaetigt, Review 2026-09-24: pro
# Streckengruppe genau 1 echter Kettenseparator, alle
# uebrigen Zuordnung-Treffer sind eigenstaendige Vereinzeler).
tros.append(
DerivedTro(
tro_id="",
type_name="1Sep",
label=element.name,
hosts=[node_id],
separators=[sep_id],
scanners=scanners_for([sep_id]),
reason=(
f"Separator {sep_id}: Zuordnung zeigt auf "
f"{element.name}, ist aber nicht Teil von dessen "
f"TrackIds-Kette - kein eigenes VF treibt ihn an"
),
confidence="mittel",
)
)
findings.append(
f"{element.name} Separator {sep_id}: Zuordnung ohne "
f"TrackIds-Mitgliedschaft - als 1Sep statt Vario eingestuft"
)
continue
type_name = alt_type.get(sep_id, "Vario")
sep_reason = reason
confidence = "hoch"
if type_name == "1Sep":
confidence = "mittel"
sep_reason = (
f"alternierendes VF/GF-Muster vom Wiedereinschleusen von "
f"{element.name} rueckwaerts ({alt_offset[sep_id] + 1}. "
f"Separator von hinten) - unbetriebener Abschnitt ohne "
f"eigenes VarioDrive (Review 2026-09-23/25)"
)
findings.append(
f"{element.name} Separator {sep_id}: alternierendes Muster "
f"({alt_offset[sep_id] + 1}. von hinten) - als 1Sep statt "
f"Vario eingestuft"
)
# Hoehen-/Antriebsdaten des vorangehenden Segments (Strecke/
# Gefaellestrecke) bleiben eine unabhaengige Zusatzpruefung (siehe
# Docstring oben, Regel 2): sie kann eine laut Muster als Vario
# erwartete Position auf 1Sep umstufen, wenn dort tatsaechlich ein
# unbetriebenes Gefaelle gemessen wurde - nicht umgekehrt, ein
# bereits per Muster bestimmtes 1Sep bleibt 1Sep.
if type_name == "Vario":
seg_drive, seg_drop = _incoming_drop(element, sep_id, elements_by_id)
if seg_drive == "horizontal" and seg_drop is not None and seg_drop <= -GEFAELLE_DROP_MM:
type_name = "1Sep"
confidence = "mittel"
sep_reason = (
f"Separator {sep_id} liegt auf einem unbetriebenen "
f"Gefaelle-Teilstueck der Streckengruppe {element.name} "
f"(Hoehe faellt um {-seg_drop:.0f}mm, keine aktive "
f"Antriebfahrtrichtung) - kein Vario trotz Host 'Strecke' "
f"und trotz alternierendem Muster"
)
findings.append(
f"{element.name} Separator {sep_id}: {-seg_drop:.0f}mm Gefaelle "
f"ohne Antrieb auf dem vorangehenden Segment - als 1Sep statt "
f"Vario eingestuft (widerspricht alternierendem Muster)"
)
tros.append(
DerivedTro(
tro_id="",
type_name=type_name,
label=element.name,
hosts=[node_id],
separators=[sep_id],
scanners=scanners_for([sep_id]),
reason=sep_reason,
confidence=confidence,
)
)
# 3) Separatoren auf Kreisel-Bahnen -> je Separator eine Uebergabestelle
for sep_id in sorted(
s for s, h in sep_host.items() if graph.nodes[h].kind == "Kreisel"
):
host = sep_host[sep_id]
own_scanners = sorted(sep_scanners.get(sep_id, []))
node = graph.nodes[host]
lane_name = _merkmal(node.element.merkmale, "Name") or node.element.name
tros.append(
DerivedTro(
tro_id="",
type_name="1Sep",
label=f"Separator {sep_id} auf {lane_name} Bahn {node.lane}",
hosts=[host],
separators=[sep_id],
scanners=own_scanners,
reason=(
"Separator mit Scanner auf angetriebener Kreisel-Bahn"
if own_scanners
else "einzelner Separator auf angetriebener Kreisel-Bahn"
),
confidence="mittel",
)
)
# 3b) Separatoren an einem Ausschleuselement -> eigene 1Sep1Swi-
# Uebergabestelle. Das Element uebernimmt die Rolle der (hier fehlenden)
# Weiche; siehe _find_switch_separators. Host bewusst die Strecke (nicht
# die Kreisel-Bahn): beide Elementarten tragen "Strecke" oder "Foerderer"
# im Merkmal (beide Schreibweisen kommen nebeneinander im selben Export
# vor), und die Strecke hat bereits reale, gerichtete Kanten zum Kreisel
# (_apply_transfers) - darueber haengt sich die Verknuepfung (link_tros)
# von selbst ein, ohne einen weiteren synthetischen Host zu erfinden.
as_switch_tro: dict[str, DerivedTro] = {} # Ausschleuselement-Id -> 1Sep1Swi
for sep_id, (transfer, distance) in sorted(switch_hits.items()):
if sep_id in speicher_steuer:
continue # Teil seines Speichers (Schritt 1)
strecke_id = str(_merkmal(transfer.merkmale, "Strecke", "Förderer") or "")
ende = str(_merkmal(transfer.merkmale, "Streckenende") or "")
own_scanners = sorted(sep_scanners.get(sep_id, []))
art = "Ausschleuselement"
as_switch_tro[transfer.teile_id] = tro = (
DerivedTro(
tro_id="",
type_name="1Sep1Swi",
label=f"Separator {sep_id} an {art} {transfer.teile_id} "
f"(Strecke {strecke_id}, {ende})",
hosts=[strecke_id] if strecke_id in graph.nodes else [transfer.teile_id],
separators=[sep_id],
scanners=own_scanners,
reason=(
f"{distance:.0f}mm von {art} {transfer.teile_id} entfernt - "
f"kein eigenes Weichen-Bauteil im Layout, das {art} "
f"uebernimmt diese Rolle"
),
confidence="mittel",
)
)
tros.append(tro)
# 3b') Weitere Weichen desselben Separators. Ein Separator steuert ALLE
# Ausschleuselemente, die in Laufrichtung bis zum naechsten Separator auf
# ihn folgen - nicht nur das eine, das in seinen CSV-Nachbarn steht
# (_find_switch_separators). Dematic/Arvato Kreisel4 (GUZ): 0048 ->
# Ausschleuselement 0069 (VF_4) -> Ausschleuselement 0077 (VF_6); 0077 ist
# kein CSV-Nachbar von 0048, wird aber von ihm gesteuert (Review
# 2026-10-07). Mit zwei Weichen wird der TRO zum 1Sep2Swi (Katalog: 1x
# Separator, 2x Switch, 1x Scanner). Ausschleuselemente von Speicherlinien
# gehoeren dem PinStore_Auto (Schritt 1) und zaehlen hier nicht.
speicher_hosts = {h for t in tros if t.type_name == "PinStore_Auto" for h in t.hosts}
sep_switch_tro = {t.separators[0]: t for t in as_switch_tro.values() if t.separators}
weichen_je_tro: dict[int, list[str]] = {
id(t): [a for a, tt in as_switch_tro.items() if tt is t] for t in as_switch_tro.values()}
for kreisel in (e for e in elements if e.kind == "Kreisel" and e.track_ids):
ids = list(kreisel.track_ids)
ring = kreisel.rotation in (ROTATION_CW, ROTATION_CCW)
for i, as_id in enumerate(ids):
transfer = elements_by_id.get(as_id)
if transfer is None or transfer.kind != "Ausschleus" or as_id in as_switch_tro:
continue
strecke_id = str(_merkmal(transfer.merkmale, "Strecke", "Förderer") or "")
if strecke_id in speicher_hosts:
continue
davor = ids[:i][::-1] + (ids[i + 1:][::-1] if ring else [])
steuer = next((t for t in davor if (el := elements_by_id.get(t))
and el.kind == "Separator"), None)
tro = sep_switch_tro.get(steuer or "")
if tro is None:
continue
as_switch_tro[as_id] = tro
weichen = weichen_je_tro[id(tro)]
weichen.append(as_id)
if strecke_id in graph.nodes and strecke_id not in tro.hosts:
tro.hosts.append(strecke_id)
tro.label = (f"Separator {steuer} an Ausschleuselementen "
f"{', '.join(weichen)} (Strecken {', '.join(tro.hosts)})")
tro.reason += (f"; steuert auch Ausschleuselement {as_id} - folgt in "
f"Laufrichtung ohne Separator dazwischen")
if len(weichen) == 2:
tro.type_name = "1Sep2Swi"
else:
findings.append(
f"Separator {steuer}: {len(weichen)} Ausschleuselemente "
f"({', '.join(weichen)}) ohne Separator dazwischen - der "
f"Katalog kennt hoechstens 1Sep2Swi, bitte pruefen")
for tro in sep_switch_tro.values():
if tro.type_name == "1Sep2Swi" and not tro.scanners:
findings.append(
f"{tro.label}: 1Sep2Swi ohne Scanner - der Katalog verlangt einen "
f"Barcode-Scanner am Eingang, im Layout fehlt einer vor Separator "
f"{tro.separators[0]}")
# 3c) Separatoren an einer echten ILS Weiche -> 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
for sep_id, weiche in sorted(weiche_hits.items()):
host = resolve_host(elements_by_id[sep_id], findings)
weiche_tro[weiche.teile_id] = tro = DerivedTro(
tro_id="",
type_name="1Sep1Swi",
label=f"Separator {sep_id} an ILS Weiche {weiche.teile_id}",
hosts=[host] if host is not None else [weiche.teile_id],
separators=[sep_id],
scanners=sorted(sep_scanners.get(sep_id, [])),
reason=f"steuert die ILS Weiche {weiche.teile_id} (echtes Weichen-Bauteil)",
confidence="hoch",
)
tros.append(tro)
# 3d) Ausschleuselement-Weiche und ILS Weiche direkt hintereinander in der
# TrackIds-Kette eines Kreisels -> EIN 2Sep2Swi (2 Separatoren, 2 Weichen,
# 2 Scanner). Mubea: Kreisel2 ... 0005 -> 0057 (AS VF_2) -> 0125 (ILS
# Weiche zu Kreisel1) -> 0012 ...; Separator 0005 steuert das Ausschleusen,
# 0036 die Weiche - steuerungstechnisch eine Uebergabestelle (Review
# 2026-08-12, im Code seit 2026-10-01). Host ist der des AS-Teils (die
# Strecke), damit link_tros die Weichenkante in deren ersten Vario-TRO zieht.
for kreisel in (e for e in elements if e.kind == "Kreisel"):
chain = list(kreisel.track_ids)
if len(chain) < 2:
continue
for a, b in zip(chain, chain[1:] + chain[:1]):
for as_id, w_id in ((a, b), (b, a)):
as_tro, w_tro = as_switch_tro.get(as_id), weiche_tro.get(w_id)
if not (as_tro and w_tro and as_tro in tros and w_tro in tros):
continue
tros.remove(as_tro)
tros.remove(w_tro)
tros.append(DerivedTro(
tro_id="",
type_name="2Sep2Swi",
label=(f"Uebergabestelle {kreisel.name}: Ausschleuselement "
f"{as_id} + ILS Weiche {w_id}"),
hosts=list(as_tro.hosts),
separators=as_tro.separators + w_tro.separators,
scanners=sorted(as_tro.scanners + w_tro.scanners),
reason=(f"Ausschleuselement {as_id} (Separator "
f"{', '.join(as_tro.separators)}) und ILS Weiche {w_id} "
f"(Separator {', '.join(w_tro.separators)}) liegen in "
f"der TrackIds-Kette von {kreisel.name} direkt "
f"hintereinander - eine Uebergabestelle"),
confidence="hoch",
))
findings.append(
f"{kreisel.name}: Ausschleuselement {as_id} und ILS Weiche "
f"{w_id} benachbart - als 2Sep2Swi zusammengefasst "
f"(FB_ILS_MTRO_2Sep2Swi fehlt noch in der ILSLib)"
)
# 4) Weichen-Aufwertung: 2 bzw. 3 abgehende Flusskanten
for tro in tros:
if tro.type_name != "1Sep":
continue
exits = count_exits(tro.hosts)
if not 2 <= len(exits) <= 3:
continue
upgraded = "1Sep1Swi" if len(exits) == 2 else "1Sep2Swi"
findings.append(
f"{tro.label}: {len(exits)} abgehende Wege ({', '.join(exits)}) - "
f"als Weiche {upgraded} statt {tro.type_name} eingestuft"
)
tro.type_name = upgraded
tro.reason = f"{tro.reason}; {len(exits)} abgehende Wege -> Weiche"
tro.confidence = "niedrig"
# 5) Stabile, laufende Nummern in Reihenfolge der Host-Knoten
tros.sort(key=lambda t: (t.hosts[0], t.separators[0] if t.separators else ""))
width = max(2, len(str(len(tros))))
for index, tro in enumerate(tros, start=1):
tro.tro_id = f"TRO{index:0{width}d}"
# 6) Scanner am Separator: 1Sep_SSCC laesst sich mechanisch nicht erkennen
for tro in tros:
if tro.type_name == "1Sep" and tro.scanners:
findings.append(
f"{tro.tro_id} ({tro.label}): Scanner {', '.join(tro.scanners)} am "
f"Separator - als 1Sep eingestuft. Pruefen, ob 1Sep_SSCC "
f"(SSCC-Scanner, Endmessung, WCS-Telegramm) erforderlich ist; das "
f"ist aus dem mechanischen Layout nicht erkennbar."
)
return tros
# Typnamen fuer Systemgrenz-Knoten (siehe derive_boundaries) - bewusst NICHT
# in TRO_CATALOG: eine Be-/Entladestation (BTMT oder generisches Eingang/
# Ausgang-Symbol) ist kein Steuerobjekt mit eigenem FB-Baustein, nur der
# tatsaechliche Materialein-/ausgang.
BOUNDARY_ENTRY = "Materialeingang"
BOUNDARY_EXIT = "Materialausgang"
BOUNDARY_TYPES = (BOUNDARY_ENTRY, BOUNDARY_EXIT)
def derive_boundaries(elements: list[Element], graph: Graph) -> list[DerivedTro]:
"""Be-/Entladestationen als sichtbare Systemgrenz-Knoten.
Ohne diese Knoten "verschwindet" der Materialfluss stillschweigend an der
Station: link_tros() registriert eine Kante nur, wenn die Traversierung
einen Knoten erreicht, der ein TRO-Host ist. Eine Station ist nie ein
TRO-Host (sie hat keinen Separator), also wird sie beim Durchlaufen als
passiver Zwischenknoten behandelt - und da eine Entladung/ein Ausgang
(Senke) keine ausgehende Kante mehr hat, bricht die Suche dort einfach
ab, ohne eine Kante einzutragen. Eine Beladung/ein Eingang (Quelle) wird
umgekehrt nie als Ausgangspunkt einer Traversierung betrachtet, weil kein
TRO dort beginnt. Damit fehlte bislang genau der Teil des Flusses, den
man am Anfang oder Ende eines Durchlaufs durch die Anlage sieht - die
Anlage laeuft als Kreislauf, diese Stationen sind ihre einzigen
Materialquellen/-senken (siehe auch material_flow.py --chain-graph).
Erfasst sowohl die konkrete BTMT-Station (Beladung/Entladung) als auch das
generische Eingang/Ausgang-Symbol (element.is_station, STATION_KINDS in
material_flow.py) - ein durch eine deckungsgleiche BTMT-Station
verschlucktes Symbol taucht dank des graph.nodes-Filters unten trotzdem
nicht doppelt auf (_merge_station_symbols(), siehe build_graph()).
Kein eigener TRO-Typ (keine FB_ILS_MTRO_*-Zuordnung, kein Eintrag in
TRO_CATALOG) - nur ein sichtbarer Marker fuer den echten Anfang/das echte
Ende des Materialflusses. Eigene Nummerierung ("SYS1", "SYS2", ...)
getrennt von den TRO-IDs, damit auf den ersten Blick klar bleibt: das
sind keine Steuerobjekte.
"""
boundaries: list[DerivedTro] = []
for element in elements:
if not element.is_station:
continue
if element.teile_id not in graph.nodes:
continue
boundaries.append(
DerivedTro(
tro_id="",
type_name=BOUNDARY_ENTRY if element.is_entry else BOUNDARY_EXIT,
label=element.name,
hosts=[element.teile_id],
separators=[],
scanners=[],
reason="Systemgrenze (Be-/Entladestation) - kein TRO, "
"nur Materialein-/ausgang",
confidence="hoch",
)
)
boundaries.sort(key=lambda t: t.hosts[0])
width = max(1, len(str(len(boundaries))))
for index, boundary in enumerate(boundaries, start=1):
boundary.tro_id = f"SYS{index:0{width}d}"
return boundaries
def _tro_edge_kind(edge: Edge) -> str:
"""Materialkante -> Kantenart des TRO-Flusses (fuer Einfaerbung)."""
if edge.note == "Weiche":
return "weiche"
if edge.kind == EDGE_CIRCLE:
return "umlauf"
if edge.kind == EDGE_UNRESOLVED:
return "unbestimmt"
if edge.note in ("Einschleusung", "Ausschleusung"):
return "transfer"
return "normal"
def _bahn_richtung(node) -> tuple[float, float] | None:
"""Foerderrichtung einer Kreisel-Bahn als Einheitsvektor.
Der Kreisel ist eine geschlossene Schleife: auf der einen Bahn laeuft das
Material hin, auf der anderen zurueck. Welche Richtung das ist, ergibt sich
aus der Laengsachse ("Drehung") und dem Umlaufsinn ("Drehrichtung"):
UZS (im Uhrzeigersinn) Bahn R entgegen der Achse, Bahn L mit ihr
GUZ (gegen den UZS) umgekehrt
Rueckgabe None, wenn eines der beiden Merkmale fehlt - dann bleibt es bei
der ungerichteten "gleiche Bahn"-Notiz.
"""
element = node.element
drehung = _as_float(_merkmal(element.merkmale, "Drehung"))
umlauf = element.rotation
if drehung is None or umlauf not in (ROTATION_CW, ROTATION_CCW):
return None
rad = math.radians(drehung)
achse = (math.cos(rad), math.sin(rad))
mit_achse = (node.lane == LANE_LEFT) == (umlauf == ROTATION_CW)
return achse if mit_achse else (-achse[0], -achse[1])
def _tro_bahn(tro: DerivedTro, graph: Graph, sep_assignment: dict[str, str]) -> str | None:
"""Kreisel-Bahn, auf der ein TRO physisch sitzt - fuer die Positions-
Verkettung in link_tros(). Meist einfach der TRO-Host (wenn er direkt auf
der Bahn liegt, z. B. ein gewoehnlicher Kreisel-Separator). Bei einem
Weichen-TRO zeigt der Host dagegen auf die gefuetterte Strecke, nicht auf
die Bahn (siehe derive_tros Schritt 3b) - dort zaehlt stattdessen die
Zuordnung des eigenen Separators, falls die auf eine Kreisel-Bahn zeigt.
Zeigt sie stattdessen auf eine Strecke (z. B. HundM05 Separator 0016/0018,
die auf ihrer eigenen Strecke montiert sind, nicht auf der Kreisel-Bahn),
gibt es keine Bahn-Zugehoerigkeit - der TRO gehoert dann nicht in die
Verkettung.
"""
for host in tro.hosts:
node = graph.nodes.get(host)
if node and node.kind == "Kreisel":
return host
for sep_id in tro.separators:
bahn = sep_assignment.get(sep_id)
node = graph.nodes.get(bahn or "")
if node and node.kind == "Kreisel":
return bahn
return None
def _chain_mit_ungelisteten_separatoren(
strecke: Element,
chain: list[str],
elements: list[Element],
elements_by_id: dict[str, Element],
) -> list[str]:
"""Separatoren der Strecke, die in KEINER TrackIds-Liste stehen, lagerichtig
in die (bereits AS -> ES orientierte) Kette einsortieren.
Hintergrund (Mubea, VF_3): Separatoren 0037/0038 sind per Zuordnung der
Strecke 0015 zugeordnet und liegen physisch zwischen 0062 und 0063, fehlen
aber in deren TrackIds. Ohne Platz in der Kette bekommen ihre TROs keinen
praezisen Nachfolger, behalten die groben Contraction-Walk-Kanten und
zeigen auf jeden TRO hinter Kreisel2 Bahn R (je 9 Fehlkanten).
Einsortiert wird ueber die Projektion auf die Gerade erstes -> letztes
Kettenelement. Steht ein Separator in irgendeiner TrackIds-Liste (z. B.
eines Kreisels), gehoert er dorthin und bleibt hier draussen.
"""
if len(chain) < 2:
return chain
gelistet = {tid for e in elements for tid in e.track_ids}
fehlend = [
e for e in elements
if e.kind == "Separator" and e.assignment == strecke.teile_id
and e.teile_id not in gelistet and e.position
]
if not fehlend:
return chain
anfang = elements_by_id.get(chain[0])
ende = elements_by_id.get(chain[-1])
if not (anfang and ende and anfang.position and ende.position):
return chain
dx = ende.position[0] - anfang.position[0]
dy = ende.position[1] - anfang.position[1]
if dx == 0 and dy == 0:
return chain
def _t(pos) -> float:
return (pos[0] - anfang.position[0]) * dx + (pos[1] - anfang.position[1]) * dy
# Kettenelemente ohne Position behalten ihren Platz: sie erben den Wert des
# Vorgaengers, damit die Sortierung stabil bleibt.
werte: list[tuple[float, int, str]] = []
letzter = float("-inf")
for i, tid in enumerate(chain):
el = elements_by_id.get(tid)
if el is not None and el.position:
letzter = _t(el.position)
werte.append((letzter, i, tid))
for e in fehlend:
werte.append((_t(e.position), len(chain), e.teile_id))
werte.sort(key=lambda w: (w[0], w[1]))
return [tid for _, _, tid in werte]
def link_tros(
graph: Graph, tros: list[DerivedTro], elements: list[Element]
) -> tuple[list[tuple[str, str]], dict[tuple[str, str], str]]:
"""
TROs verknuepfen.
Der Materialfluss laeuft von Uebergabestelle zu Uebergabestelle, ggf. durch
passive Zwischenknoten (Strecken/Bahnen ohne eigenen TRO). Solche Knoten
werden daher durchlaufen ("kontrahiert"), belegte Knoten beenden den Pfad.
Jede TRO-Kante bekommt zusaetzlich die Art der *ankommenden* Materialkante
(transfer / umlauf / weiche / unbestimmt / normal).
"""
owners: dict[str, list[str]] = {}
for tro in tros:
for host in tro.hosts:
owners.setdefault(host, []).append(tro.tro_id)
by_id = {t.tro_id: t for t in tros}
forward: dict[str, list[tuple[str, str]]] = {}
for edge in graph.edges:
kind = _tro_edge_kind(edge)
forward.setdefault(edge.src, []).append((edge.dst, kind))
if edge.kind == EDGE_UNRESOLVED:
# unbestimmte Richtung: in beide Richtungen begehbar
forward.setdefault(edge.dst, []).append((edge.src, kind))
edges: set[tuple[str, str]] = set()
edge_kinds: dict[tuple[str, str], str] = {}
for tro in tros:
seen = set(tro.hosts)
queue = [(nxt, k) for host in tro.hosts for (nxt, k) in forward.get(host, [])]
while queue:
node, arriving = queue.pop()
if node in seen:
continue
seen.add(node)
if node in owners:
for other in owners[node]:
if other != tro.tro_id:
pair = (tro.tro_id, other)
edges.add(pair)
edge_kinds.setdefault(pair, arriving)
continue
queue.extend(forward.get(node, []))
# verkettete_paare (ungerichtet) unterdrueckt die "gleiche Bahn"-Punktlinie
# weiter unten - dafuer reicht Ortsgleichheit unabhaengig von der Richtung.
# directed_paare (gerichtet) markiert dagegen genau die Kanten, die durch
# eine dieser Regeln bewusst gesetzt wurden - fuer den Weichen-
# Redundanzfilter weiter unten muss das richtungsscharf sein: TRO12->TRO01
# (echte Umlaufkette) darf die entgegengesetzte TRO01->TRO12 (grober,
# redundanter Contraction-Walk-Fund) NICHT mitdecken, nur weil es dieselben
# zwei TROs sind (Review 2026-09-21, Bug beim ersten Versuch dieses Filters).
verkettete_paare: set[frozenset[str]] = set()
directed_paare: set[tuple[str, str]] = set()
# 1. Ground-Truth-Umlaufreihenfolge je Kreisel aus Element.track_ids
# (dieselbe nach Drehrichtung-Winkel sortierte Liste, die auch --track/
# build_track_graph verwendet - siehe doc/track.md). Laeuft ZUERST, damit
# der Positions-Fallback weiter unten weiss, welche Kreisel damit schon
# praezise abgedeckt sind. Ersetzt fuer diese Kreisel die aus dem groben
# Materialfluss-Graph kontrahierten "Nabe -> jeder erreichbare Zweig"-
# Kanten weiter unten: die kennen keine Reihenfolge und zeigen daher u. U.
# rueckwaerts (Review 2026-09-21 - TRO11 zeigte auf alle 5 Abzweige
# gleichrangig, obwohl es in der echten Umlaufreihenfolge der LETZTE
# Separator vor der Entladung ist).
elem_owner: dict[str, str] = {}
for tro in tros:
if tro.type_name in BOUNDARY_TYPES:
elem_owner.setdefault(tro.hosts[0], tro.tro_id)
else:
for sep_id in tro.separators:
elem_owner.setdefault(sep_id, tro.tro_id)
def _add_chain_pair(a: str, b: str) -> None:
if a == b:
return
# Keine Sperre fuer Systemgrenzen noetig: sie sind kein Kettenglied
# (siehe station_hosts unten) und kommen hier nur als Zu-/Ablaufpfeil
# in der richtigen Richtung an.
pair = (a, b)
edges.add(pair)
edge_kinds.setdefault(pair, "bahn")
verkettete_paare.add(frozenset(pair))
directed_paare.add(pair)
trackids_pairs.add(pair)
trackids_pairs: set[tuple[str, str]] = set()
covered_kreisel_bases: set[str] = set()
# 0. Reihenfolge der Vario-TROs INNERHALB einer Strecke, aus derselben
# Element.track_ids-Quelle wie der Kreisel-Umlauf unten, hier aber linear
# (AS -> SEP.. -> ES, keine geschlossene Schleife) - siehe build_track_graph()
# in material_flow.py, dessen Richtungskorrektur hier repliziert wird. Seit
# derive_tros Schritt 2 einen eigenen Vario-TRO je Separator anlegt (Review
# 2026-09-21: "jedes Vario-TRO soll genau 1 Separator haben"), muessen
# mehrere Varios auf derselben Strecke in echter Reihenfolge verkettet
# werden - strecke_first_owned/strecke_last_owned merken sich den jeweils
# ersten/letzten fuer die Anschluss-Kanten an Weiche bzw. Wiedereinschleusung.
elements_by_id = {e.teile_id: e for e in elements}
strecke_first_owned: dict[str, str] = {}
strecke_last_owned: dict[str, str] = {}
for el in elements:
if el.kind not in TRANSPORT_KINDS:
continue
chain = list(el.track_ids)
if len(chain) >= 2:
first_el, last_el = elements_by_id.get(chain[0]), elements_by_id.get(chain[-1])
if ((first_el and first_el.kind == "Einschleus")
or (last_el and last_el.kind == "Ausschleus")):
chain = list(reversed(chain))
# Ein Separator, der (Zuordnung) zwar auf dieser Strecke steht, aber
# selbst eine Weiche ist (z. B. HundM05 Separator 0016 auf VF_5 - siehe
# derive_tros Schritt 3b), gehoert NICHT in die Vario-Kette der Strecke:
# er ist der Kreisel-Bahn zugehoerig und dort schon (Schritt 1 unten)
# verkettet. Ohne diesen Ausschluss wuerde er faelschlich als erster
# oder letzter "Vario" gelten (Review 2026-09-21).
chain = _chain_mit_ungelisteten_separatoren(el, chain, elements, elements_by_id)
owned = [
elem_owner[tid] for tid in chain if tid in elem_owner
and not ((d := by_id[elem_owner[tid]].definition()) and d.has_item(TroItem.SWITCH))
]
if not owned:
# Segment ganz ohne Separator (derive_tros Schritt 2: leere
# Separatorliste) - der einzelne Vario-TRO dieses Hosts bleibt
# trotzdem Anschlusspunkt fuer Weiche/Wiedereinschleusung.
solo = [tid for tid in owners.get(el.teile_id, [])
if by_id[tid].type_name == "Vario"]
if len(solo) == 1:
owned = solo
for a, b in zip(owned, owned[1:]):
_add_chain_pair(a, b)
if owned:
strecke_first_owned[el.teile_id] = owned[0]
strecke_last_owned[el.teile_id] = owned[-1]
# Einschleuselement-Id -> das Vario-TRO, das dort real wieder einmuendet
# (der LETZTE Vario-TRO der Strecke in echter Reihenfolge, nicht irgendein
# beliebiger - Review 2026-09-21).
einschleus_vario: dict[str, str] = {}
for el in elements:
if el.kind != "Einschleus":
continue
strecke_id = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "")
vario_id = strecke_last_owned.get(strecke_id)
if vario_id:
einschleus_vario[el.teile_id] = vario_id
# Linienspeicher in der Umlaufkette. Seine Schwerkraftlinien beginnen als
# Folge von Ausschleuselementen auf dem einen Kreisel (Mubea Kreisel3:
# TRO05 -> 0122, 0119, ... 0065 -> TRO07) und enden als Folge von
# Einschleuselementen auf dem anderen (Kreisel2: 0067 ... 0124 -> TRO10).
# Die Ausschleuselemente zaehlen in der Kette als Glied des PinStore-TROs:
# der Speicher liegt IM Umlauf, TRO05 -> TRO18 -> TRO07 (TRO05 bleibt 1Sep,
# kein ILS Ausschleuselement einer Strecke - Review 2026-10-01). Die
# Einschleuselemente liefern - wie bei einer Vario-Strecke - den Ablauf
# zum naechsten Halt (TRO18 -> TRO10).
store_by_host = {h: t.tro_id for t in tros
if t.type_name == "PinStore_Auto" for h in t.hosts}
speicher_ausschleus: dict[str, str] = {}
for el in elements:
if el.kind not in ("Ausschleus", "Einschleus"):
continue
strecke_id = str(_merkmal(el.merkmale, "Strecke", "Förderer") or "")
if strecke_id not in store_by_host:
continue
if el.kind == "Ausschleus":
speicher_ausschleus[el.teile_id] = store_by_host[strecke_id]
else:
einschleus_vario[el.teile_id] = store_by_host[strecke_id]
# Systemgrenzen (Be-/Entladung, Eingang/Ausgang) sind KEIN Kettenglied:
# die Umlaufkette verbindet die TROs direkt an ihnen vorbei, die Station
# haengt nur als Zu- bzw. Ablaufpfeil am benachbarten TRO (Review
# 2026-10-02). Vorher liefen sie als Glied mit (TRO19 -> SYS3 -> SYS1 ->
# SYS2 -> TRO20), jetzt TRO19 -> TRO20 plus TRO19 -> SYS3, SYS1/SYS2 -> TRO20.
station_hosts = {t.hosts[0]: t for t in tros if t.type_name in BOUNDARY_TYPES}
ketten_owner = {
**{k: v for k, v in elem_owner.items() if k not in station_hosts},
**speicher_ausschleus,
}
for el in elements:
if not el.is_circle or len(el.track_ids) < 2:
continue
chain = list(el.track_ids)
laenge = len(chain)
geschlossen = el.rotation in (ROTATION_CW, ROTATION_CCW)
if geschlossen:
# Umlauf schliessen: den Listenanfang hinten anhaengen, und zwar
# bis einschliesslich des ersten Kettenglieds (Element mit TRO).
# Alles davor - Stationen, aber auch Ein-/Ausschleuselemente ohne
# eigenen TRO - ist kein Glied; endete die Verlaengerung dort, fehlte
# der Schluss (HundM05 Kreisel1 beginnt mit den Beladungen 0021/
# 0022: ohne das fehlte TRO19 -> TRO20; Dematic Kreisel2 beginnt
# mit dem Einschleuselement 0060: es fehlte TRO01 -> Speicher,
# Review 2026-10-06).
k = 0
while k < laenge - 1 and chain[k] not in ketten_owner:
k += 1
chain.extend(chain[:k + 1])
# Hauptkette: Separatoren in Reihenfolge, Aus-/Einschleuselemente und
# Stationen bewusst uebersprungen (unowned) - das beschreibt den BYPASS
# fuer Verkehr, der NICHT abzweigt (z. B. TRO01 -> TRO03, wenn
# dazwischen niemand abbiegt).
owned = [(i, ketten_owner[tid]) for i, tid in enumerate(chain) if tid in ketten_owner]
for (_, a), (_, b) in zip(owned, owned[1:]):
_add_chain_pair(a, b)
if owned:
covered_kreisel_bases.add(el.teile_id)
# Stationen als Pfeile: Entladung/Ausgang verlaesst den TRO direkt
# DAVOR, Beladung/Eingang muendet in den TRO direkt DANACH.
for i, tid in enumerate(chain[:laenge]):
station = station_hosts.get(tid)
if station is None or not owned:
continue
if station.type_name == BOUNDARY_EXIT:
tro_id = next((o for j, o in reversed(owned) if j < i),
owned[-1][1] if geschlossen else None)
if tro_id is not None:
_add_chain_pair(tro_id, station.tro_id)
else:
tro_id = next((o for j, o in owned if j > i),
owned[0][1] if geschlossen else None)
if tro_id is not None:
_add_chain_pair(station.tro_id, tro_id)
# Zusaetzlich der reale Wiedereintritt eines abgezweigten Vario-
# Segments: vom Einschleuselement zum naechsten Halt DANACH in
# derselben Kette - ersetzt fuer dieses Vario-TRO den groben "Strecke
# -> Bahn"-Kontraktionsfund (der pauschal auf DEN EINEN TRO zeigt, der
# zufaellig noch die Kreisel-Bahn als Host hat, unabhaengig davon,
# welche Strecke tatsaechlich zurueckkehrt - Review 2026-09-21: "warum
# zeigen alle Varios auf TRO12"). Unabhaengig von der Hauptkette oben,
# die an dieser Stelle bewusst durchlaeuft (Bypass, siehe dort).
for i, tid in enumerate(chain):
vario_id = einschleus_vario.get(tid)
if vario_id is None:
continue
nxt = next((o for j, o in owned if j > i), None)
if nxt is None and owned:
nxt = owned[0][1] # Umlauf geschlossen - wickelt um
if nxt is not None and nxt != vario_id:
_add_chain_pair(vario_id, nxt)
# Nur TROs, fuer die die Umlaufkette tatsaechlich einen praezisen
# Nachfolger gefunden hat (precise_sources) - ein TRO ohne brauchbare
# TrackIds-Abdeckung behaelt seine bisherigen (ungenaueren, aber nicht
# leeren) Kanten aus dem Contraction-Walk als Rueckfall. Angewendet wird
# der Filter erst ganz unten, nachdem auch die Weichen-Kanten feststehen.
precise_sources = {a for a, _ in trackids_pairs}
# 2. Positions-Fallback: mehrere TROs auf derselben Kreisel-Bahn liegen
# HINTEREINANDER, nicht nebeneinander - der Reihe nach verkettet statt nur
# als "gleiche Bahn" markiert. NUR fuer Kreisel, die Schritt 1 NICHT schon
# praezise abgedeckt hat (covered_kreisel_bases) - sonst gruppiert dieser
# Fallback rein nach CSV-Spalte "Zuordnung" und uebersieht z. B. bei
# HundM05 die Separatoren 0016/0018 (auf "0005" zugeordnet, nicht auf der
# Bahn) und zieht faelschlich eine direkte Kante 0014->0020 an ihnen
# vorbei (Review 2026-09-21).
sep_assignment = {e.teile_id: e.assignment for e in elements
if e.kind == "Separator" and e.assignment}
bahn_gruppen: dict[str, list[DerivedTro]] = {}
for tro in tros:
bahn = _tro_bahn(tro, graph, sep_assignment)
if bahn and bahn.split("-")[0] not in covered_kreisel_bases:
bahn_gruppen.setdefault(bahn, []).append(tro)
for bahn, gruppe in bahn_gruppen.items():
if len(gruppe) < 2:
continue
node = graph.nodes[bahn]
if not node.lane:
continue
richtung = _bahn_richtung(node)
if richtung is None:
continue
auf_bahn = [t for t in gruppe if t.position]
if len(auf_bahn) < 2:
continue
# Projektion auf die Foerderrichtung: kleinster Wert kommt zuerst
auf_bahn.sort(key=lambda t: t.position[0] * richtung[0]
+ t.position[1] * richtung[1])
for vorher, nachher in zip(auf_bahn, auf_bahn[1:]):
pair = (vorher.tro_id, nachher.tro_id)
edges.add(pair)
edge_kinds.setdefault(pair, "bahn")
verkettete_paare.add(frozenset(pair))
directed_paare.add(pair)
# Ein Weichen-TRO auf derselben Strecke wie ein Vario-TRO ist keine blosse
# Ortsgleichheit, sondern der zweite der beiden moeglichen Wege an der
# Weiche: entweder weiter auf der Kreisel-Bahn zum naechsten Separator
# (verkettete_paare oben) oder durch die Weiche hindurch in dieses Segment
# - konkret in dessen ERSTEN Vario-TRO (strecke_first_owned oben; bei
# mehreren Separatoren auf derselben Strecke sind die weiteren bereits
# ueber die Strecken-Kette oben an den ersten angeschlossen). Gerichtete
# Kante statt "gleiche Bahn"-Punktlinie, mit dem bereits vorhandenen
# "weiche"-Kantenstil (orange, Label "Weiche"). Review 2026-09-21.
# Je Host eine Weichenkante: ein 1Sep2Swi hat zwei Strecken (Schritt 3b'
# in derive_tros). Hat ein Weichen-TRO keinen Nachfolger auf der Bahn
# (einziger Separator seines Kreisels - Dematic Kreisel1/Kreisel4: ohne
# Abzweig laeuft das Material auf dem Kreisel zu ihm selbst zurueck),
# beschreiben die Weichenkanten seine Weiterfuehrung trotzdem vollstaendig;
# er zaehlt dann wie ein Bahn-TRO als praezise Quelle, die groben
# Contraction-Walk-Kanten entfallen (sie fuehrten an VF_5 vorbei direkt
# auf Kreisel2, Review 2026-10-08).
for tro in tros:
definition = tro.definition()
if not (definition and definition.has_item(TroItem.SWITCH)):
continue
weichen_kanten = 0
for host in tro.hosts:
ziel_id = strecke_first_owned.get(host)
if ziel_id is None or ziel_id == tro.tro_id:
continue
pair = (tro.tro_id, ziel_id)
edges.add(pair)
edge_kinds.setdefault(pair, "weiche")
verkettete_paare.add(frozenset(pair))
directed_paare.add(pair)
weichen_kanten += 1
if tro.hosts and weichen_kanten == len(tro.hosts):
precise_sources.add(tro.tro_id)
# Jedes TRO, fuer das oben ein praeziser Nachfolger in der Umlaufkette
# gefunden wurde (precise_sources), hat damit seine gesamte reale
# Weiterfuehrung bereits vollstaendig beschrieben (Bahn-Fortsetzung und/
# oder Weiche). Jede WEITERE von ihm ausgehende Kante stammt aus dem
# groben Contraction-Walk ganz oben (ueber denselben Host wie ein anderes
# TRO - z. B. TRO01 -> TRO12 zusaetzlich zu TRO01 -> TRO02, oder TRO02 ->
# TRO12 zusaetzlich zu TRO02 -> TRO03) und ist redundant bzw. schlicht
# falsch: das jeweilige Ziel-TRO berichtet dieselbe Weiterfuehrung bereits
# selbst, praeziser. Review 2026-09-21: erst "jede Weiche hat 3 statt 2
# ausgehende Kanten", dann "warum zeigen alle Varios auf TRO12" - beides
# derselbe Fehler, hier fuer jedes TRO vereinheitlicht statt nur fuer
# Weichen. directed_paare (NICHT das ungerichtete verkettete_paare!) -
# sonst deckt eine TRO12->TRO01-Umlaufkante faelschlich auch die
# Gegenrichtung TRO01->TRO12 ab, nur weil dasselbe TRO-Paar beteiligt ist.
edges = {e for e in edges
if not (e[0] in precise_sources and e not in directed_paare)}
# gleicher Host = physisch dieselbe Strecke/Bahn. Ein bereits verkettetes
# PAAR (Bahn-Reihenfolge oben oder Weiche gerade eben) braucht die
# ungerichtete "gleiche Bahn"-Notiz nicht zusaetzlich - ein Weichen-TRO
# kann durchaus auf der Bahn verkettet UND mit seinem Vario-TRO ueber die
# Weiche verbunden sein, das sind zwei unabhaengige Beziehungen.
# Weichen-TROs bekommen keine "gleiche Bahn"-Notiz: ihr Host ist die
# Strecke, IN die sie abzweigen - sie selbst sitzen auf der Kreisel-Bahn,
# nicht auf dieser Strecke. Die Notiz verband sonst einen 1Sep2Swi mit
# jedem TRO beider Strecken (Dematic TRO13 mit VF_4 und VF_6, Review
# 2026-10-08); die Verbindung zeigt bereits die Weichenkante.
weichen_tros = {t.tro_id for t in tros
if (d := t.definition()) and d.has_item(TroItem.SWITCH)}
for host, ids in owners.items():
for tro_id in ids:
tro = next(t for t in tros if t.tro_id == tro_id)
others = {o for o in ids if o != tro_id
and frozenset((tro_id, o)) not in verkettete_paare
and not {tro_id, o} & weichen_tros}
if others:
tro.colocated = sorted(set(tro.colocated) | others)
for tro in tros:
tro.successors = sorted(dst for src, dst in edges if src == tro.tro_id)
tro.predecessors = sorted(src for src, dst in edges if dst == tro.tro_id)
return sorted(edges), edge_kinds
def _centroid(
points: list[tuple[float, float, float]]
) -> tuple[float, float, float] | None:
"""Mittelpunkt mehrerer Positionen."""
if not points:
return None
count = len(points)
return (
sum(p[0] for p in points) / count,
sum(p[1] for p in points) / count,
sum(p[2] for p in points) / count,
)
def locate_tros(tros: list[DerivedTro], elements: list[Element], graph: Graph) -> None:
"""
Jedem TRO eine Koordinate geben.
Die Koordinate ist der **Mittelpunkt der Bauteile**, aus denen der TRO
gebildet wurde - bei einem 1Sep1Swi also der Mittelwert aus Separator und
Weiche, beim Linienspeicher der Mittelwert aller Linien-Separatoren.
Hat ein TRO keine Bauteile mit Position (z. B. ein Vario ohne Separator),
wird auf die Position seiner Host-Objekte zurueckgegriffen; das steht dann
in `position_from`.
"""
by_id = {element.teile_id: element for element in elements}
for tro in tros:
# 1) Bauteile des TRO (Separatoren, Scanner)
parts = [
by_id[i].position
for i in list(tro.separators) + list(tro.scanners)
if i in by_id and by_id[i].position
]
source = "Bauteile"
# 2) Ersatzweise die Host-Objekte
if not parts:
parts = [
graph.nodes[h].element.position
for h in tro.hosts
if h in graph.nodes and graph.nodes[h].element.position
]
source = "Host-Objekte"
tro.position = _centroid(parts)
tro.position_points = len(parts)
tro.position_from = source if tro.position else "keine Position"
def analyse_tros(
graph: Graph,
elements: list[Element],
*,
sepliste_counts: dict[str, int] | None = None,
) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen."""
analysis = Analysis()
analysis.tros = derive_tros(graph, elements, analysis.findings,
sepliste_counts=sepliste_counts)
analysis.boundaries = derive_boundaries(elements, graph)
# Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen:
# link_tros() registriert eine Kante nur zu einem Knoten, der Host eines
# Eintrags in der uebergebenen Liste ist - eine BTMT-Station muss also mit
# in dieser Liste stehen, sonst bleibt sie unsichtbar (siehe derive_boundaries).
linked = analysis.tros + analysis.boundaries
# Verorten vor dem Verknuepfen: die Reihenfolge mehrerer TROs auf einer
# Kreisel-Bahn wird ueber ihre Koordinate bestimmt.
locate_tros(linked, elements, graph)
analysis.edges, analysis.edge_kinds = link_tros(graph, linked, elements)
analysis.unknown_types = sorted(
{tro.type_name for tro in analysis.tros if tro.definition() is None})
analysis.isolated = [tro for tro in analysis.tros if not tro.connected]
analysis.findings.sort()
return analysis
# ---------------------------------------------------------------------------
# DOT erzeugen - Flussdiagramm der TROs
# ---------------------------------------------------------------------------
def _tro_label(tro: DerivedTro) -> str:
lines = [f"{tro.tro_id} ({tro.type_name})", tro.label]
if tro.fb_block:
lines.append(tro.fb_block)
if tro.items:
lines.append(", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())))
if tro.separators:
seps = ", ".join(tro.separators)
if len(tro.separators) > 6:
seps = f"{tro.separators[0]} … {tro.separators[-1]} ({len(tro.separators)})"
lines.append(f"Sep: {seps}")
if tro.scanners:
lines.append(f"Scan: {', '.join(tro.scanners)}")
if tro.position:
lines.append(f"x/y: {tro.describe_position()}")
return _dot_label(lines)
def missing_positions(analysis: Analysis) -> list[str]:
"""TROs/Systemgrenzen ohne Koordinate - mit ihnen ist kein lagerichtiges
Layout moeglich (auch eine Systemgrenze wird als Knoten gezeichnet)."""
return [t.tro_id for t in analysis.tros + analysis.boundaries if not t.position]
# Lagerichtige Darstellung (--use-cords): feste Symbolgroesse je Form in Zoll
# (Breite, Hoehe). Ohne fixedsize waechst jede Form mit ihrem Label - Dreiecke
# und Doppel-Dreiecke wurden dadurch um ein Vielfaches groesser als die Kreise
# (Review 2026-10-01: "manche Symbole sind riesig").
COORD_SYMBOL_SIZE: dict[str, tuple[float, float]] = {
"circle": (0.45, 0.45),
"triangle": (0.62, 0.52),
"invtriangle": (0.62, 0.52),
"box": (0.7, 0.34),
"square": (0.55, 0.55),
"diamond": (0.62, 0.5),
"pentagon": (0.55, 0.52),
"hexagon": (0.65, 0.45),
"rarrow": (0.7, 0.36),
"ellipse": (0.7, 0.34),
}
def _achse_kompakt(werte: list[float], max_luecke: float) -> dict[float, float]:
"""Koordinaten einer Achse -> gekuerzte Koordinaten: Abstaende bis
max_luecke bleiben, groessere werden auf max_luecke gekuerzt. Monoton,
die Reihenfolge der Knoten bleibt also unveraendert."""
sortiert = sorted(set(werte))
if not sortiert:
return {}
abbildung = {sortiert[0]: sortiert[0]}
for vorher, wert in zip(sortiert, sortiert[1:]):
abbildung[wert] = abbildung[vorher] + min(wert - vorher, max_luecke)
return abbildung
def _kompakte_positionen(
positionen: dict[str, tuple[float, float]], max_luecke: float
) -> dict[str, tuple[float, float]]:
"""Knoten-Id -> Zeichenposition (mm) mit gekuerzten Leerstrecken."""
ax = _achse_kompakt([p[0] for p in positionen.values()], max_luecke)
ay = _achse_kompakt([p[1] for p in positionen.values()], max_luecke)
return {k: (ax[x], ay[y]) for k, (x, y) in positionen.items()}
def _station_positionen(
analysis: Analysis, tro_zeichen: dict[str, tuple[float, float]]
) -> dict[str, tuple[float, float]]:
"""Zeichenposition der Stationsbeschriftung (Ende des Zu-/Ablaufpfeils).
Feste Pfeillaenge COORD_PFEIL_LAENGE ab dem angehaengten TRO (Beladung:
der TRO danach, Entladung: der TRO davor) - so bleibt der Pfeil sichtbar,
auch wenn die Station praktisch auf dem TRO sitzt (Mubea SYS1 186 mm neben
TRO04). Die Richtung haelt den groessten Winkelabstand zu allen Kanten des
Anker-TROs und zu bereits gesetzten Stationspfeilen; die Richtung zur
echten Station entscheidet nur bei Gleichstand. Station ohne angehaengten
TRO: echte Koordinate."""
tro_by_id = {t.tro_id: t for t in analysis.tros}
je_tro: dict[str, list[DerivedTro]] = {}
ergebnis: dict[str, tuple[float, float]] = {}
for station in analysis.boundaries:
nachbarn = (station.successors if station.type_name == BOUNDARY_ENTRY
else station.predecessors)
anker = next((n for n in nachbarn if n in tro_zeichen), None)
if anker is None:
if station.position:
ergebnis[station.tro_id] = (station.position[0], station.position[1])
continue
je_tro.setdefault(anker, []).append(station)
def _abstand(a: float, b: float) -> float:
d = abs(a - b) % (2 * math.pi)
return min(d, 2 * math.pi - d)
for anker, stationen in je_tro.items():
ax, ay = tro_zeichen[anker]
echt = tro_by_id[anker].position
# Richtungen der Kettenkanten am Anker-TRO - der Pfeil soll von der
# Kette WEG zeigen, nicht auf ihr liegen (Review 2026-10-02: Mubea
# TRO02 -> SYS2 lief fast deckungsgleich mit TRO02 -> TRO13).
belegt: list[float] = []
for src, dst in analysis.edges:
anderer = dst if src == anker else src if dst == anker else None
if anderer is None or anderer not in tro_zeichen:
continue
ox, oy = tro_zeichen[anderer]
if (ox, oy) != (ax, ay):
belegt.append(math.atan2(oy - ay, ox - ax))
for station in sorted(stationen, key=lambda s: s.tro_id):
wunsch = -math.pi / 2
if echt and station.position:
dx, dy = station.position[0] - echt[0], station.position[1] - echt[1]
if math.hypot(dx, dy) >= 1.0:
wunsch = math.atan2(dy, dx)
# Kandidaten im 15-Grad-Raster: groesster Mindestabstand zu allen
# belegten Richtungen gewinnt, bei Gleichstand die zur echten
# Station naechste Richtung.
kandidaten = [math.radians(g) for g in range(0, 360, 15)]
winkel = max(kandidaten, key=lambda w: (
round(min((_abstand(w, b) for b in belegt), default=math.pi), 3),
-_abstand(w, wunsch)))
belegt.append(winkel)
ergebnis[station.tro_id] = (ax + math.cos(winkel) * COORD_PFEIL_LAENGE,
ay + math.sin(winkel) * COORD_PFEIL_LAENGE)
return ergebnis
def _tro_kurzinfo(tro: DerivedTro) -> str:
"""Zweizeilige Beschriftung neben dem Symbol: Typ und Bauteile."""
if tro.type_name in BOUNDARY_TYPES:
return _dot_label([tro.type_name, tro.label])
if len(tro.separators) > 3:
teile = f"{len(tro.separators)} Separatoren"
elif tro.separators:
teile = "Sep " + ", ".join(tro.separators)
else:
teile = tro.label
return _dot_label([tro.type_name, teile])
def _tro_tooltip(tro: DerivedTro) -> str:
"""Vollstaendige Angaben als SVG-Tooltip (Zeilenumbruch per &#10;)."""
return _tro_label(tro).replace("\\n", "&#10;")
def _coord_node_attrs(tro: DerivedTro, base: dict[str, str]) -> str:
"""Knotenattribute fuer --use-cords: feste Groesse, kurzes Label im Symbol,
Typ/Bauteile als xlabel daneben, alle Details als Tooltip."""
attrs = dict(base)
shape = attrs.get("shape", "box")
width, height = COORD_SYMBOL_SIZE.get(shape, (0.7, 0.5))
attrs.update({
"fixedsize": "true",
"width": f"{width}",
"height": f"{height}",
"fontsize": "8",
"label": tro.tro_id,
"xlabel": _tro_kurzinfo(tro),
"tooltip": _tro_tooltip(tro),
})
return ", ".join(f'{k}="{v}"' for k, v in attrs.items())
def _legend_pos_rechts(zeichen: dict[str, tuple[float, float]]) -> tuple[float, float]:
"""Legende rechts neben der Anlage, obere Kante etwa auf Anlagenoberkante
(mm, Zeichenpositionen; pos ist bei neato -n der Knotenmittelpunkt)."""
if not zeichen:
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)
def _zeichen_positionen(
analysis: Analysis, compact: bool
) -> dict[str, tuple[float, float]]:
"""Zeichenposition (mm) je TRO/Systemgrenze fuer --use-cords: TROs auf
ihrer Anlagenkoordinate, mit compact die Leerstrecken gekuerzt
(_kompakte_positionen); die Stationsbeschriftungen danach relativ zum
angehaengten TRO (_station_positionen). Reine Darstellung - DerivedTro.position
und die Tooltip-Koordinaten bleiben echt."""
roh: dict[str, tuple[float, float]] = {}
for tro in analysis.tros:
if tro.position:
roh[tro.tro_id] = (tro.position[0], tro.position[1])
zeichen = _kompakte_positionen(roh, COORD_MAX_LUECKE) if compact else roh
zeichen.update(_station_positionen(analysis, zeichen))
return zeichen
# Kantenstil je Art (an material_flow.py angelehnt: Umlauf hellblau gestrichelt,
# Weiche orange). "transfer"/"normal" bleiben der durchgezogene Standardfluss.
TRO_EDGE_STYLE = {
"umlauf": 'color="#8ea9db", style=dashed, tooltip="Kreisel-Umlauf"',
"weiche": 'color="#cc8800", penwidth=1.8, xlabel="Weiche", fontcolor="#cc8800", '
'tooltip="Weiche (Kreisel-Uebergang)"',
"unbestimmt": 'color="#bf8f00", style=dashed, dir=both, tooltip="Richtung unbestimmt"',
"transfer": 'tooltip="Uebergabe (Ein-/Ausschleusung)"',
}
# Farbe der Zu-/Ablaufpfeile und ihrer Stationsbeschriftung
STATION_FARBE = {
BOUNDARY_ENTRY: "#2e8b57", # gruen: Material kommt herein
BOUNDARY_EXIT: "#c0392b", # rot: Material geht hinaus
}
def _tro_edge_attrs(kind: str | None) -> str:
return TRO_EDGE_STYLE.get(kind or "normal", "")
def render_dot(analysis: Analysis, source: str, use_coords: bool = False,
compact: bool = True) -> str:
"""
Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle.
use_coords=False Anordnung berechnet 'dot' (Fluss von links nach rechts)
use_coords=True jeder Knoten wird auf seine Anlagenkoordinate gesetzt und
mit "neato -n" lagerichtig gezeichnet
compact nur mit use_coords: Leerstrecken ueber COORD_MAX_LUECKE
kuerzen (Reihenfolge der Knoten bleibt); False = masstaeblich
"""
out: list[str] = []
add = out.append
zeichen = _zeichen_positionen(analysis, compact) if use_coords else {}
def _pos(node: DerivedTro) -> str:
if use_coords:
p = zeichen.get(node.tro_id)
return (f', pos="{p[0] * COORD_SCALE:.2f},{p[1] * COORD_SCALE:.2f}"'
if p else "")
# Ohne --use-cords ignoriert dot das pos, die Angabe bleibt aber als
# Information im DOT stehen.
if node.position:
return f', pos="{node.position[0] * 0.01:.2f},{node.position[1] * 0.01:.2f}"'
return ""
add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.")
add(f"// Quelle: {source}")
if use_coords:
add(f"// Lagerichtig: Knoten auf Anlagenkoordinate, {COORD_SCALE} pt/mm.")
add("// Rendern mit: neato -n -Tsvg <datei>.dot -o <datei>.svg")
add("digraph TROFluss {")
if use_coords:
# Bei -n bestimmen die pos-Attribute die Lage; rankdir/ranksep entfallen.
add(" graph [splines=true, outputorder=edgesfirst, forcelabels=true,")
add(' bgcolor="white", pad=0.6,')
add(' fontname="Segoe UI", fontsize=16, labelloc="t",')
add(f' label="TRO-Fluss - {_dot_escape(source)}"];')
else:
add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,")
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
add(f' label="TRO-Fluss - {_dot_escape(source)}"];')
if use_coords:
add(' node [shape=box, style="filled", penwidth=1.6, fontname="Segoe UI Semibold",'
' fontsize=9];')
add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.3,'
" arrowsize=0.7];")
else:
add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];')
add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,'
" arrowsize=0.8];")
add("")
for tro in analysis.tros:
definition = tro.definition()
attrs = definition.get_dot_attrs() if definition else {
"fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000"
}
rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items())
pos = _pos(tro)
if use_coords:
add(f' "{tro.tro_id}" [{_coord_node_attrs(tro, attrs)}{pos}];')
else:
add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];')
add("")
# Systemgrenzen (BTMT Be-/Entladestationen): kein Katalogtyp, eigene
# Farbe/Form (ext-Stil, Ellipse statt Box) - deutlich als "kein TRO"
# erkennbar, aber sichtbar als tatsaechlicher Anfang/Ende des Flusses.
# Systemgrenzen (Be-/Entladung, Eingang/Ausgang) sind kein TRO und kein
# Kettenglied: sie erscheinen nur als Beschriftung am Ende eines farbigen
# Zu- bzw. Ablaufpfeils (Review 2026-10-02).
boundary_ids = {b.tro_id for b in analysis.boundaries}
for boundary in analysis.boundaries:
farbe = STATION_FARBE[boundary.type_name]
add(f' "{boundary.tro_id}" [shape=plaintext, style="", fontsize=9, '
f'fontcolor="{farbe}", label="{_dot_escape(boundary.label)}", '
f'tooltip="{_tro_tooltip(boundary)}"{_pos(boundary)}];')
add("")
for src, dst in analysis.edges:
if src in boundary_ids or dst in boundary_ids:
farbe = STATION_FARBE[BOUNDARY_ENTRY if src in boundary_ids else BOUNDARY_EXIT]
add(f' "{src}" -> "{dst}" [color="{farbe}", penwidth=2.0, arrowsize=0.9, '
f'tooltip="{"Materialeingang" if src in boundary_ids else "Materialausgang"}"];')
continue
add(f' "{src}" -> "{dst}" [{_tro_edge_attrs(analysis.edge_kinds.get((src, dst)))}];')
# "gleiche Bahn"-Punktlinien nur in der berechneten Anordnung: lagerichtig
# liegen TROs derselben Bahn/Strecke ohnehin sichtbar nebeneinander, die
# Linien waren dort nur Unruhe (Review 2026-10-01).
seen: set[frozenset] = set()
for tro in analysis.tros if not use_coords else []:
for other in tro.colocated:
key = frozenset((tro.tro_id, other))
if key in seen:
continue
seen.add(key)
add(f' "{tro.tro_id}" -> "{other}" [dir=none, style=dotted, '
f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];')
add("")
# Legende: eine Zeile je TRO-Typ (Farbe der Gruppe + eigene Form, siehe
# tro_catalog.TRO_CATALOG), plus eine Zeile fuer die Systemgrenzen und die
# Kantenarten. Bei -n braucht auch sie ein pos, sonst landet sie auf 0,0
# mitten in der Anlage - daher unterhalb links vom Anlagenrand.
# Lagerichtig nur die tatsaechlich vorkommenden Typen - die volle
# Katalogliste macht die Legende dort laenger als die Anlage breit ist.
used_types = {t.type_name for t in analysis.tros}
legend_types = [d for d in TRO_CATALOG
if not use_coords or d.get_name() in used_types]
rows = "".join(
f'<tr><td bgcolor="{d.get_fill()}" color="{d.get_style().stroke}" align="left"> {d.get_name()} </td>'
f'<td align="left">{d.describe_symbol()}</td></tr>'
for d in legend_types
)
rows += (f'<tr><td align="left"><font color="{STATION_FARBE[BOUNDARY_ENTRY]}">'
f'gruener Pfeil</font></td><td align="left">Materialeingang (Beladung / Eingang)</td></tr>'
f'<tr><td align="left"><font color="{STATION_FARBE[BOUNDARY_EXIT]}">'
f'roter Pfeil</font></td><td align="left">Materialausgang (Entladung / Ausgang)</td></tr>')
legend_label = ('<<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
+ rows
+ '<tr><td align="left"><font color="#2f5597">durchgezogen</font></td>'
'<td align="left">Uebergabe / Ein-Ausschleusung</td></tr>'
'<tr><td align="left"><font color="#cc8800">orange</font></td>'
'<td align="left">Weiche (Kreisel-Uebergang)</td></tr>'
+ ('' if use_coords else
'<tr><td align="left">grau gepunktet</td>'
'<td align="left">gleiche Bahn / Strecke</td></tr>')
+ "</table>>")
if use_coords:
lx, ly = _legend_pos_rechts(zeichen)
add(f' legende [shape=plaintext, style="", fillcolor="none", fontsize=11, '
f'label={legend_label}, pos="{lx * COORD_SCALE:.2f},'
f'{ly * COORD_SCALE:.2f}"];')
else:
add(" subgraph cluster_legende {")
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
add(f' legende [shape=plaintext, style="", fillcolor="none", '
f'label={legend_label}];')
add(" }")
add("}")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# connect.ini (TRO-Ebene) - --connect schreibt
# ---------------------------------------------------------------------------
def render_connect_ini(analysis: Analysis, section: str, source: str) -> str:
"""
TRO-Topologie als connect.ini, angelehnt an doc/TRO_Katalog/connect.ini
(hmf-connect-v1). Reine Ausgabe - wird von keinem Lauf zurueckgelesen.
Abweichung vom Referenzformat: Systemgrenzen stehen nicht als EXTERN,
sondern mit ihrer ID (SYSn). [<Anlage>.tros] ist eine reine Uebersicht
(Vorgaenger/Nachfolger je TRO).
"""
out: list[str] = []
add = out.append
add("; ============================================================")
add("; connect.ini - Routing / Zusammenhalt der TROs (TRO-Ebene)")
add(f"; Automatisch erzeugt von lib/tro_flow.py aus '{source}'.")
add("; Wird bei jedem Lauf mit --connect neu geschrieben - nicht von Hand")
add("; aendern, Korrekturen gehoeren in die Ableitung (lib/tro_flow.py).")
add(";")
add("; [<Anlage>.nodes] TRO = <Typ> | <Bezeichnung> | <Bauteile>")
add("; SYSn = Materialeingang/-ausgang (kein TRO)")
add("; [<Anlage>.connections] cNNN = <von> -> <nach> | <JamArea> | <kind>")
add("; kind: normal | transfer | bahn | weiche | umlauf | unbestimmt | gesetzt")
add("; [<Anlage>.externals] eNNN = SYSn -> <TRO> | <JamArea> | <kind> (Zulauf)")
add("; eNNN = <TRO> -> SYSn | <JamArea> | <kind> (Ablauf)")
add("; [<Anlage>.tros] TRO = <Typ> | prev: ... | next: ...")
add("; nur Uebersicht - beim Einlesen ignoriert")
add("; ============================================================")
add("")
add(f"[{section}.nodes]")
for tro in analysis.tros:
teile = []
if tro.separators:
teile.append(f"Sep {', '.join(tro.separators)}")
if tro.scanners:
teile.append(f"Scan {', '.join(tro.scanners)}")
add(f"{tro.tro_id} = {tro.type_name} | {tro.label}"
+ (f" | {'; '.join(teile)}" if teile else ""))
for boundary in analysis.boundaries:
add(f"{boundary.tro_id} = {boundary.type_name} | {boundary.label}")
add("")
boundary_ids = {b.tro_id for b in analysis.boundaries}
intern = [e for e in analysis.edges
if e[0] not in boundary_ids and e[1] not in boundary_ids]
extern = [e for e in analysis.edges
if e[0] in boundary_ids or e[1] in boundary_ids]
add(f"[{section}.connections]")
for i, (src, dst) in enumerate(intern, start=1):
kind = analysis.edge_kinds.get((src, dst)) or "normal"
add(f"c{i:03d} = {src} -> {dst} | | {kind}")
add("")
add(f"[{section}.externals]")
if not extern and boundary_ids:
add(f"; Systemgrenzen ohne Verbindung: {', '.join(sorted(boundary_ids))}")
elif not extern:
add("; keine Be-/Entladestation im Layout - Materialein-/ausgang offen")
for i, (src, dst) in enumerate(extern, start=1):
kind = analysis.edge_kinds.get((src, dst)) or "normal"
add(f"e{i:03d} = {src} -> {dst} | | {kind}")
add("")
add(f"[{section}.tros]")
for tro in analysis.tros:
prev = ", ".join(tro.predecessors) or "-"
nxt = ", ".join(tro.successors) or "-"
line = f"{tro.tro_id} = {tro.type_name} | prev: {prev} | next: {nxt}"
if tro.colocated:
line += f" | gleiche Bahn: {', '.join(tro.colocated)}"
add(line)
add("")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# Fehlerdatei
# ---------------------------------------------------------------------------
def render_errors(analysis: Analysis, csv_file: Path) -> str:
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}` ")
out.append(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})")
out.append("")
if analysis.isolated:
out.append("## Nicht verbundene TROs")
out.append("")
out.append(
"Jeder TRO muss mit mindestens einem anderen TRO verbunden sein. "
"Fuer die folgenden Uebergabestellen liess sich im Materialfluss kein "
"Nachbar-TRO finden - weder ueber die Spalte `Nachbarn` noch ueber einen "
"gemeinsamen Host."
)
out.append("")
out.append("| TRO | Typ | Host | Separatoren | Bezeichnung |")
out.append("|---|---|---|---|---|")
for tro in analysis.isolated:
out.append(
f"| `{tro.tro_id}` | `{tro.type_name}` | "
f"{', '.join(f'`{h}`' for h in tro.hosts)} | "
f"{', '.join(tro.separators) or '-'} | {tro.label} |"
)
out.append("")
out.append("**Moegliche Ursachen**")
out.append("")
out.append("- Spalte `Nachbarn` des Host-Objekts ist leer oder unvollstaendig")
out.append("- `Nachbarn` verweist auf eine unbekannte `TeileId`")
out.append("- Die Bahnangabe (`-L` / `-R`) eines Kreisels fehlt auf beiden Seiten")
out.append("")
if analysis.unknown_types:
out.append("## Typen ohne Katalogeintrag")
out.append("")
out.append(
"Die folgenden Typen wurden ermittelt, stehen aber nicht in "
"`TRO_CATALOG` in `lib/tro_catalog.py`:"
)
out.append("")
for name in analysis.unknown_types:
holders = [t.tro_id for t in analysis.tros if t.type_name == name]
out.append(f"- `{name}` ({', '.join(holders)})")
out.append("")
return "\n".join(out) + "\n"
def write_error_file(analysis: Analysis, csv_file: Path, path: Path) -> bool:
"""Fehlerdatei schreiben; veraltete eigene Datei bei fehlerfreiem Lauf entfernen."""
if analysis.isolated or analysis.unknown_types:
path.write_text(render_errors(analysis, csv_file), encoding="utf-8")
return True
if path.is_file():
try:
first = path.read_text(encoding="utf-8").splitlines()[:1]
except OSError:
first = []
if first and first[0].strip() == GENERATED_MARKER:
path.unlink()
return False
# ---------------------------------------------------------------------------
# Dokumentation (Markdown)
# ---------------------------------------------------------------------------
def _mermaid_text(text: str) -> str:
return text.replace('"', "'").replace("[", "(").replace("]", ")")
def _tro_mermaid(analysis: Analysis) -> list[str]:
out = ["```mermaid", "flowchart LR"]
out.extend(f" {line}" for line in mermaid_classdefs())
for tro in analysis.tros:
definition = tro.definition()
css_class = definition.mermaid_class() if definition else "ext"
label = f"{tro.tro_id}<br/>{tro.type_name}<br/>{_mermaid_text(tro.label)}"
out.append(f' {tro.tro_id}["{label}"]:::{css_class}')
for boundary in analysis.boundaries:
label = f"{boundary.tro_id}<br/>{boundary.type_name}<br/>{_mermaid_text(boundary.label)}"
out.append(f' {boundary.tro_id}(["{label}"]):::ext')
for src, dst in analysis.edges:
out.append(f" {src} --> {dst}")
seen: set[frozenset] = set()
for tro in analysis.tros:
for other in tro.colocated:
key = frozenset((tro.tro_id, other))
if key in seen:
continue
seen.add(key)
out.append(f" {tro.tro_id} -. gleiche Bahn .- {other}")
out.append("```")
return out
def render_doc(
analysis: Analysis,
graph: Graph,
csv_file: Path,
dot_file: Path,
svg_file: Path | None,
error_file: Path | None,
) -> str:
out: list[str] = [GENERATED_MARKER]
add = out.append
add(f"# TRO-Liste und TRO-Fluss - {csv_file.name}")
add("")
add(f"**Erzeugt:** {datetime.now().strftime('%Y-%m-%d %H:%M')} von "
f"`lib/tro_flow.py` ")
add(f"**Quelle:** `{csv_file}` ")
add(f"**Methode:** {analysis.method} ")
add(f"**Typdefinition:** `lib/tro_catalog.py` (Stand {CATALOG_AS_OF})")
add("")
add("> Automatisch aus dem CSV-Export erzeugt. Die TRO-Typen sind aus dem "
"mechanischen Layout **abgeleitet** - ohne E-Planung (I/O-Liste) und ohne "
"`FB_Main`-Aufrufstellen gibt es noch keine verbindliche TRO-Nummerierung. "
"Die IDs unten sind laufende Nummern dieses Laufs.")
add("")
types_used: dict[str, int] = {}
for tro in analysis.tros:
types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1
add("## 1. Kurzfassung")
add("")
add("| | |")
add("|---|---|")
add(f"| Ermittelte TROs | {len(analysis.tros)} |")
add(f"| Systemgrenzen (Materialein-/ausgang) | {len(analysis.boundaries)} |")
add(f"| TRO-Verbindungen | {len(analysis.edges)} |")
add(f"| Nicht verbundene TROs | {len(analysis.isolated)} |")
add(f"| Hinweise | {len(analysis.findings)} |")
add("")
add("| Typ | FB-Baustein | Anzahl hier | Bauteile je TRO | Farbgruppe | Symbol |")
add("|---|---|---:|---|---|---|")
for name, count in sorted(types_used.items()):
definition = get_tro(name)
add(f"| `{name}` | "
f"{f'`{definition.get_fb_block()}`' if definition else '_kein Katalogeintrag_'} "
f"| {count} | {definition.describe_items() if definition else '-'} "
f"| {definition.get_group() if definition else '-'} "
f"| {definition.describe_symbol() if definition else '-'} |")
add("")
add("## 2. TRO-Flussdiagramm")
add("")
out.extend(_tro_mermaid(analysis))
add("")
add(f"Gerendert: [`{dot_file.name}`]({dot_file.name})"
+ (f" - [`{svg_file.name}`]({svg_file.name})" if svg_file else ""))
add("")
add("## 3. TRO-Liste")
add("")
add("| TRO | Typ | FB-Baustein | Bauteile laut Typ | X [mm] | Y [mm] | Z [mm] "
"| Host-Knoten | Separatoren | Scanner | Begruendung | Vertrauen |")
add("|---|---|---|---|---:|---:|---:|---|---|---|---|---|")
for tro in analysis.tros:
hosts = ", ".join(f"`{h}`" for h in tro.hosts)
if len(tro.hosts) > 4:
hosts = f"`{tro.hosts[0]}` … `{tro.hosts[-1]}` ({len(tro.hosts)} Knoten)"
seps = ", ".join(tro.separators) or "-"
if len(tro.separators) > 6:
seps = f"{tro.separators[0]} … {tro.separators[-1]} ({len(tro.separators)})"
items = ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())) or "-"
cx = f"{tro.x:.0f}" if tro.x is not None else "-"
cy = f"{tro.y:.0f}" if tro.y is not None else "-"
cz = f"{tro.z:.0f}" if tro.z is not None else "-"
add(f"| `{tro.tro_id}` | `{tro.type_name}` | "
f"{f'`{tro.fb_block}`' if tro.fb_block else '-'} | {items} "
f"| {cx} | {cy} | {cz} | {hosts} | {seps} | "
f"{', '.join(tro.scanners) or '-'} | {tro.reason} | {tro.confidence} |")
add("")
add("**X/Y/Z** ist der Mittelpunkt der Bauteile, aus denen der TRO gebildet "
"wurde (Separatoren und Scanner) - beim Linienspeicher also der "
"Mittelwert aller Linien-Separatoren. Koordinaten in mm im "
"Anlagen-Koordinatensystem des CSV-Exports.")
add("")
add("| TRO | Koordinate aus | Anzahl Punkte |")
add("|---|---|---:|")
for tro in analysis.tros:
add(f"| `{tro.tro_id}` | {tro.position_from} | {tro.position_points} |")
add("")
add("## 4. TRO-Verbindungen")
add("")
if analysis.edges:
add("Richtung des Materialflusses zwischen den Uebergabestellen. "
"Passive Zwischenknoten ohne eigenen TRO sind zusammengefasst.")
add("")
add("| von | nach | Typ von -> Typ nach |")
add("|---|---|---|")
for src, dst in analysis.edges:
a, b = analysis.by_id(src), analysis.by_id(dst)
add(f"| `{src}` | `{dst}` | "
f"`{a.type_name if a else '?'}` -> `{b.type_name if b else '?'}` |")
else:
add("_Keine TRO-Verbindungen ermittelt._")
add("")
if analysis.isolated:
add("### Nicht verbundene TROs")
add("")
for tro in analysis.isolated:
add(f"- `{tro.tro_id}` (`{tro.type_name}`) - {tro.label}")
add("")
if error_file:
add(f"Details: [`{error_file.name}`]({error_file.name})")
add("")
if analysis.boundaries:
add("### Systemgrenzen (Materialein-/ausgang)")
add("")
add("BTMT Be-/Entladestationen - kein TRO (kein Separator, kein "
"FB-Baustein), aber der tatsaechliche Anfang bzw. das Ende des "
"Materialflusses. Ohne diese Zeile wuerde die Verbindung an der "
"Station stillschweigend enden (siehe `derive_boundaries` in "
"`lib/tro_flow.py`).")
add("")
add("| Knoten | Typ | Bezeichnung | verbunden mit |")
add("|---|---|---|---|")
for boundary in analysis.boundaries:
other = ", ".join(f"`{x}`" for x in
sorted(set(boundary.successors) | set(boundary.predecessors))) or "-"
add(f"| `{boundary.tro_id}` | `{boundary.type_name}` | "
f"{boundary.label} | {other} |")
add("")
add("## 5. Hinweise")
add("")
if analysis.findings:
for finding in analysis.findings:
add(f"- {finding}")
else:
add("_Keine._")
add("")
add("## 6. Ableitungsregeln und Typdefinition")
add("")
add("Ein TRO ist die Steuerungslogik **einer Uebergabestelle**. Ausgangspunkt "
"ist der Separator und das, was an ihm haengt (Host-Objekt, Scanner).")
add("")
add("| Host des Separators | TRO-Typ |")
add("|---|---|")
add(f"| Gefaellestrecke, >= {MIN_STORE_LANES} Linien mit gemeinsamem Zu-/Ablauf "
"| `PinStore_Auto` (ein Block je Liniengruppe) |")
add("| Gefaellestrecke, einzelne Linie | `1Sep` |")
add("| Strecke (angetrieben) | `Vario` (je Streckensegment) |")
add("| Kreisel-Bahn | `1Sep` |")
add("| Separator mit Ausschleuselement unter seinen CSV-Nachbarn (gleiche "
"Kreisel-Bahn) | `1Sep1Swi` (Element uebernimmt die fehlende Weiche; "
"ein Einschleuselement zaehlt nicht - passiver Merge ohne "
"Richtungsentscheidung) |")
add("| zusaetzlich: 2 bzw. 3 abgehende Wege | Aufwertung zu `1Sep1Swi` / `1Sep2Swi` |")
add("")
add("Ein **Scanner** am Separator aendert den Typ nicht. In der Referenzanlage "
"sind Separatoren mit Scanner ueberwiegend `1Sep`. `1Sep_SSCC` ist dem "
"Sonderfall mit SSCC-Scanner, Endmessung und WCS-Telegramm vorbehalten und "
"aus einem mechanischen Layout nicht erkennbar - betroffene Stellen stehen "
"als Hinweis in Abschnitt 5.")
add("")
add("Was aus dem CSV **nicht** ableitbar ist: TRO-Nummerierung, Sensor- und "
"Aktor-Tags, JamAreas, Zielsteuerung und Timing. Das kommt aus der "
"E-Planung (I/O-Liste) bzw. aus `FB_Main`.")
add("")
add(f"Typen, FB-Bausteine, Bauteile und Farben stammen vollstaendig aus "
f"`TRO_CATALOG` in `lib/tro_catalog.py` (Stand {CATALOG_AS_OF}):")
add("")
add("| # | Typ | FB-Baustein | Bauteile je TRO | Farbgruppe | Symbol | Fuellfarbe |")
add("|---:|---|---|---|---|---|---|")
for index, definition in enumerate(TRO_CATALOG, start=1):
add(f"| {index} | `{definition.get_name()}` | `{definition.get_fb_block()}` "
f"| {definition.describe_items()} | {definition.get_group()} "
f"| {definition.describe_symbol()} | `{definition.get_fill()}` |")
add("")
add("| Bauteil | Sub-FB |")
add("|---|---|")
for item in sorted({i for d in TRO_CATALOG for i in d.get_item_names()}):
fb = ITEM_FB.get(item, "")
add(f"| {item} | {f'`{fb}`' if fb else '_kein eigener FB_'} |")
add("")
add("| Farbgruppe | Bedeutung | Fuellfarbe |")
add("|---|---|---|")
for style in STYLES:
add(f"| {style.group} | {style.label} | `{style.fill}` |")
add("")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# Bericht
# ---------------------------------------------------------------------------
def report(
analysis: Analysis,
csv_file: Path,
dot_file: Path,
svg_file: Path | None,
error_file: Path | None,
doc_file: Path | None,
warnings: list[str],
engine: str = "dot",
) -> None:
types_used: dict[str, int] = {}
for tro in analysis.tros:
types_used[tro.type_name] = types_used.get(tro.type_name, 0) + 1
print("")
print("================================================================")
print("TRO-FLUSS")
print("================================================================")
print(f"Eingabe = {csv_file}")
print(f"Methode = {analysis.method}")
print(f"TROs = {len(analysis.tros)}"
+ (f" ({', '.join(f'{k}: {v}' for k, v in sorted(types_used.items()))})"
if types_used else ""))
print(f"TRO-Verbindungen = {len(analysis.edges)}, "
f"nicht verbunden: {len(analysis.isolated)}")
print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), "
f"{len(TRO_CATALOG)} Typen")
print(f"Layout = {engine}"
+ (" (lagerichtig, neato -n auf Anlagenkoordinaten, "
f"{COORD_SCALE} pt/mm)" if engine == "neato"
else " (berechnete Anordnung; lagerichtig mit --use-cords)"))
print(f"DOT = {dot_file}")
print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}")
print(f"Fehlerdatei = {error_file if error_file else '- (keine Fehler)'}")
print("================================================================")
if analysis.isolated:
print("")
print(f"FEHLER: {len(analysis.isolated)} TRO(s) ohne Verbindung zu einem "
f"anderen TRO:")
for tro in analysis.isolated:
print(f" x {tro.tro_id} ({tro.type_name}) - {tro.label}")
if analysis.unknown_types:
print("")
print("FEHLER: TRO-Typen ohne Eintrag in TRO_CATALOG: "
+ ", ".join(analysis.unknown_types))
if analysis.findings:
print("")
print(f"Hinweise ({len(analysis.findings)}):")
for finding in analysis.findings:
print(f" ? {finding}")
if warnings:
print("")
print(f"Warnungen aus dem Materialfluss ({len(warnings)}):")
for warning in warnings:
print(f" ! {warning}")
print("")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="tro_flow",
description="Ermittelt aus einem CSV-Export der Anlagenobjekte die "
"benoetigten TROs und zeichnet ihren Fluss (DOT, optional SVG).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n"
" tro_flow.bat --file mubea.csv\n"
" tro_flow.bat --file mubea.csv --tosvg\n"
" tro_flow.bat --file mubea.csv --tosvg --doc\n"
" tro_flow.bat --file mubea.csv --tosvg --use-cords\n"
" tro_flow.bat --file mubea.csv --json\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
" 1 Eingabe- oder Aufruffehler\n"
" 2 SVG konnte nicht erzeugt werden (Graphviz)\n"
" 3 TRO-Fehler gefunden, Fehlerdatei geschrieben\n",
)
parser.add_argument(
"--file",
default="export.csv",
metavar="NAME",
help="Name der CSV-Eingabedatei in %%SKEL_DATA%% "
"(oder ein vollstaendiger Pfad). Standard: %(default)s",
)
parser.add_argument(
"--tosvg",
action="store_true",
help="Aus der erzeugten DOT-Datei zusaetzlich das TRO-Flussdiagramm als "
"SVG erzeugen (benoetigt Graphviz 'dot').",
)
parser.add_argument(
"--use-cords",
"--use-coords",
dest="use_cords",
action="store_true",
help="Lagerichtig zeichnen: jeder TRO wird auf seine Anlagenkoordinate "
"gesetzt und das SVG von Graphviz 'neato -n' erzeugt statt von "
f"'dot'. Massstab {COORD_SCALE} pt/mm. Nur mit --tosvg wirksam.",
)
parser.add_argument(
"--doc",
action="store_true",
help="Dokumentation der TROs als Markdown in %%SKEL_RESULTS%% erzeugen "
"(TRO-Liste, Verbindungen, Typdefinition).",
)
parser.add_argument(
"--connect",
action="store_true",
help="TRO-Topologie als <name>_tro_connect.ini nach %%SKEL_RESULTS%% "
"schreiben: Knoten (TRO = Typ), Verbindungen, Systemgrenzen und je "
"TRO Vorgaenger/Nachfolger.",
)
parser.add_argument(
"--json",
action="store_true",
help="TRO-JSON als <name>_tro.json nach %%SKEL_RESULTS%% schreiben "
"(Modell lib/model.py, TroDocument) - Eingabe fuer scl_skeleton.py.",
)
parser.add_argument(
"--true-scale",
dest="true_scale",
action="store_true",
help="Nur mit --use-cords: masstaeblich zeichnen. Standard kuerzt "
f"Leerstrecken ueber {COORD_MAX_LUECKE} mm zwischen den Knoten "
"(Reihenfolge und Nachbarschaft bleiben erhalten).",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
csv_file = resolve_input(args.file)
if csv_file.suffix.lower() != ".csv":
print(f"FEHLER: --file erwartet eine CSV-Datei, keine '{csv_file.suffix}'-Datei: "
f"{csv_file}", file=sys.stderr)
return 1
if not csv_file.is_file():
print(f"FEHLER: Eingabedatei nicht gefunden: {csv_file}", file=sys.stderr)
print(f" Suchpfad fuer Dateinamen: {env_dir('SKEL_DATA', 'data')}",
file=sys.stderr)
return 1
results = env_dir("SKEL_RESULTS", "results")
results.mkdir(parents=True, exist_ok=True)
warnings: list[str] = []
try:
elements = read_elements(csv_file, warnings)
except (OSError, ValueError, csv.Error) as exc:
print(f"FEHLER: {csv_file} konnte nicht gelesen werden: {exc}", file=sys.stderr)
return 1
if not elements:
print(f"FEHLER: {csv_file} enthaelt keine Objekte.", file=sys.stderr)
return 1
graph = build_graph(elements, csv_file.name, warnings)
if not graph.nodes:
print(f"FEHLER: {csv_file} enthaelt keine Flussobjekte.", file=sys.stderr)
return 1
sepliste_counts: dict[str, int] = {}
raw_json_file = find_sibling_raw_json(csv_file)
if raw_json_file is not None:
sepliste_counts = load_sepliste_counts(raw_json_file, warnings)
stem = csv_file.stem
connect_target = results / f"{stem}_tro_connect.ini"
json_target = results / f"{stem}_tro.json"
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts)
if not analysis.tros:
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
f"ableitbar.", file=sys.stderr)
return 1
dot_file = results / f"{stem}_tro_flow.dot"
svg_file = results / f"{stem}_tro_flow.svg"
doc_target = results / f"{stem}_tro_doc.md"
error_target = results / f"{stem}_tro_errors.md"
# Ohne Koordinaten ist kein lagerichtiges Layout moeglich
without = missing_positions(analysis)
if args.use_cords and without:
print(f"FEHLER: --use-cords nicht moeglich, {len(without)} TRO(s) ohne "
f"Koordinate: {', '.join(without)}", file=sys.stderr)
print(" Ohne pos an jedem Knoten kann 'neato -n' nicht zeichnen.",
file=sys.stderr)
return 1
dot_file.write_text(
render_dot(analysis, csv_file.name, use_coords=args.use_cords,
compact=not args.true_scale),
encoding="utf-8",
)
connect_file = None
if args.connect:
connect_target.write_text(
render_connect_ini(analysis, section=stem, source=csv_file.name),
encoding="utf-8",
)
connect_file = connect_target
json_file = None
if args.json:
document = TroDocument.from_analysis(
analysis, source=csv_file.name,
note="Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die "
"Zeichnung ist nur Ansicht, keine Datenquelle.",
)
json_target.write_text(document.to_json(), encoding="utf-8")
json_file = json_target
has_errors = write_error_file(analysis, csv_file, error_target)
error_file = error_target if has_errors else None
engine = "neato" if args.use_cords else "dot"
svg_error: str | None = None
if args.tosvg:
try:
graph_to_svg(dot_file, svg_file, engine=engine,
no_op=1 if args.use_cords else 0)
except RuntimeError as exc:
svg_error = str(exc)
created_svg = svg_file if args.tosvg and svg_error is None else None
doc_file = None
if args.doc:
doc_target.write_text(
render_doc(analysis, graph, csv_file, dot_file, created_svg, error_file),
encoding="utf-8",
)
doc_file = doc_target
report(analysis, csv_file, dot_file, created_svg, error_file, doc_file,
warnings, engine=engine)
if connect_file:
print(f"connect.ini = {connect_file}")
if json_file:
print(f"TRO-JSON = {json_file}")
if svg_error:
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)
return 2
if has_errors:
print(f"FEHLER: TRO-Pruefung fehlgeschlagen - siehe {error_target}",
file=sys.stderr)
return 3
return 0
if __name__ == "__main__":
sys.exit(main())