- _weichen_aus_umlauf: ein Separator, auf den in Laufrichtung ein Ausschleuselement folgt (ohne Separator dazwischen), ist dessen Weiche - auch ohne CSV-Nachbarschaft (Dematic 0043 -> VF_5, Nachbarn nur Einschleuselemente); Speicherlinien ausgenommen - Schritt 3b': weitere Ausschleuselemente bis zum naechsten Separator gehoeren zum selben Separator, zwei -> 1Sep2Swi (Dematic 0048 -> VF_4 + VF_6), Finding bei fehlendem Scanner bzw. mehr als zwei Weichen - Weichenkante je Host; Weichen-TRO ohne Bahn-Nachfolger (einziger Separator seines Kreisels) zaehlt als praezise Quelle - entfernt die Contraction-Walk-Kanten an VF_4/VF_5/VF_6 vorbei - keine gestrichelte "gleiche Bahn"-Notiz an Weichen-TROs (Host ist die Strecke, in die sie abzweigen, nicht ihre Bahn) - HundM05 (28 Kanten) und Mubea (26) inhaltlich unveraendert, --use-cords-Ausgabe byte-identisch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2888 lines
129 KiB
Python
2888 lines
129 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_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 dataclasses import dataclass, field
|
|
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 tro_catalog import (
|
|
CATALOG_AS_OF,
|
|
ITEM_FB,
|
|
STYLE_EXT,
|
|
STYLES,
|
|
TRO_CATALOG,
|
|
TroDefinition,
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Datenmodell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Tro:
|
|
"""Eine ermittelte Uebergabestelle."""
|
|
|
|
tro_id: str
|
|
type_name: str
|
|
label: str
|
|
hosts: list[str]
|
|
separators: list[str]
|
|
scanners: list[str]
|
|
reason: str
|
|
confidence: str
|
|
fb_block: str = ""
|
|
items: dict[str, int] = field(default_factory=dict)
|
|
successors: list[str] = field(default_factory=list)
|
|
predecessors: list[str] = field(default_factory=list)
|
|
colocated: list[str] = field(default_factory=list)
|
|
# Mittelpunkt der Bauteile, aus denen dieser TRO gebildet wurde
|
|
position: tuple[float, float, float] | None = None
|
|
position_from: str = ""
|
|
position_points: int = 0
|
|
|
|
@property
|
|
def x(self) -> float | None:
|
|
return self.position[0] if self.position else None
|
|
|
|
@property
|
|
def y(self) -> float | None:
|
|
return self.position[1] if self.position else None
|
|
|
|
@property
|
|
def z(self) -> float | None:
|
|
return self.position[2] if self.position else None
|
|
|
|
def describe_position(self) -> str:
|
|
"""Koordinate als Text, z. B. '1030, 4700'."""
|
|
if not self.position:
|
|
return "-"
|
|
return f"{self.position[0]:.0f}, {self.position[1]:.0f}"
|
|
|
|
@property
|
|
def connected(self) -> bool:
|
|
return bool(self.successors or self.predecessors or self.colocated)
|
|
|
|
def definition(self) -> TroDefinition | None:
|
|
"""Typdefinition aus tro_catalog (None wenn Typ unbekannt)."""
|
|
return get_tro(self.type_name)
|
|
|
|
|
|
@dataclass
|
|
class Analysis:
|
|
tros: list[Tro] = field(default_factory=list)
|
|
edges: list[tuple[str, str]] = field(default_factory=list)
|
|
# (von, nach) -> Kantenart (transfer / umlauf / weiche / unbestimmt / normal)
|
|
edge_kinds: dict[tuple[str, str], str] = field(default_factory=dict)
|
|
isolated: list[Tro] = field(default_factory=list)
|
|
unknown_types: list[str] = field(default_factory=list)
|
|
findings: list[str] = field(default_factory=list)
|
|
# Wie Host/Fanout ermittelt wurden (Doku/Bericht)
|
|
method: str = "Materialfluss-Graph (Zuordnung/Nachbarn)"
|
|
# Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein
|
|
# TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird.
|
|
boundaries: list[Tro] = field(default_factory=list)
|
|
|
|
def by_id(self, tro_id: str) -> Tro | None:
|
|
for tro in self.tros + self.boundaries:
|
|
if tro.tro_id == tro_id:
|
|
return tro
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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[Tro]:
|
|
"""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[Tro] = []
|
|
|
|
# 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(
|
|
Tro(
|
|
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(
|
|
Tro(
|
|
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(
|
|
Tro(
|
|
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(
|
|
Tro(
|
|
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(
|
|
Tro(
|
|
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, Tro] = {} # 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 = (
|
|
Tro(
|
|
tro_id="",
|
|
type_name="1Sep1Swi",
|
|
label=f"Separator {sep_id} an {art} {transfer.teile_id} "
|
|
f"(Strecke {strecke_id}, {ende})",
|
|
hosts=[strecke_id] if strecke_id in graph.nodes else [transfer.teile_id],
|
|
separators=[sep_id],
|
|
scanners=own_scanners,
|
|
reason=(
|
|
f"{distance:.0f}mm von {art} {transfer.teile_id} entfernt - "
|
|
f"kein eigenes Weichen-Bauteil im Layout, das {art} "
|
|
f"uebernimmt diese Rolle"
|
|
),
|
|
confidence="mittel",
|
|
)
|
|
)
|
|
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, Tro] = {} # 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 = Tro(
|
|
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(Tro(
|
|
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[Tro]:
|
|
"""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[Tro] = []
|
|
for element in elements:
|
|
if not element.is_station:
|
|
continue
|
|
if element.teile_id not in graph.nodes:
|
|
continue
|
|
boundaries.append(
|
|
Tro(
|
|
tro_id="",
|
|
type_name=BOUNDARY_ENTRY if element.is_entry else BOUNDARY_EXIT,
|
|
label=element.name,
|
|
hosts=[element.teile_id],
|
|
separators=[],
|
|
scanners=[],
|
|
reason="Systemgrenze (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: Tro, 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[Tro], 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[Tro]] = {}
|
|
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[Tro], 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)
|
|
|
|
for tro in analysis.tros:
|
|
definition = tro.definition()
|
|
if definition:
|
|
tro.fb_block = definition.get_fb_block()
|
|
tro.items = definition.get_items()
|
|
else:
|
|
analysis.unknown_types.append(tro.type_name)
|
|
|
|
analysis.unknown_types = sorted(set(analysis.unknown_types))
|
|
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: Tro) -> 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[Tro]] = {}
|
|
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: Tro) -> 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: Tro) -> str:
|
|
"""Vollstaendige Angaben als SVG-Tooltip (Zeilenumbruch per )."""
|
|
return _tro_label(tro).replace("\\n", " ")
|
|
|
|
|
|
def _coord_node_attrs(tro: Tro, 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 - Tro.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: Tro) -> 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"
|
|
"\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(
|
|
"--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"
|
|
|
|
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
|
|
|
|
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 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())
|