diff --git a/lib/tro_flow.py b/lib/tro_flow.py index bd12b7f..2f672a9 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -166,9 +166,16 @@ GEFAELLE_DROP_MM = 30.0 GENERATED_MARKER = "" # Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in -# Millimeter. 0.25 pt/mm entspricht 1:4 - bei ~11 x 20 m Anlage ergibt das eine -# Zeichnung von etwa 2800 x 5000 pt, in der sich die TRO-Kaesten nicht ueberdecken. -COORD_SCALE = 0.25 +# 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 + +# Mindestabstand (mm) einer Systemgrenze zu einem TRO in der lagerichtigen +# Zeichnung - eine BTMT-Station sitzt oft fast auf ihrem Separator (Mubea SYS1 +# 186 mm neben TRO04); sie wird dann nur in der Darstellung beiseitegeschoben. +COORD_MIN_ABSTAND = 600 # Abstand der Legende zum Anlagenrand in mm LEGEND_MARGIN = 3000 @@ -374,13 +381,21 @@ def _find_switch_separators( for sep in separators: sep_kreisel_id = sep.assignment.split("-", 1)[0] if sep.assignment else "" kreisel = kreisel_by_id.get(sep_kreisel_id) - if kreisel is None: + # 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 transfer.teile_id not in kreisel.track_ids: + 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) @@ -393,6 +408,46 @@ def _find_switch_separators( 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]: @@ -543,11 +598,17 @@ def derive_tros( # als gewoehnlicher Separator ihres Zuordnung-Hosts (Kreisel-Bahn oder # Strecke - beides kommt vor) mitgezaehlt werden. switch_hits = _find_switch_separators(switch_candidates, elements) + # 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: + if sep.teile_id in switch_hits or sep.teile_id in weiche_hits: continue host = resolve_host(sep, findings) if host is not None: @@ -558,7 +619,7 @@ def derive_tros( sep_scanners: dict[str, list[str]] = {} for scanner in scanners: target = str(_merkmal(scanner.merkmale, "Naechster Separator") or "").strip() - if target in sep_host or target in switch_hits: + 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) @@ -804,12 +865,13 @@ def derive_tros( # 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()): 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" - tros.append( + as_switch_tro[transfer.teile_id] = tro = ( Tro( tro_id="", type_name="1Sep1Swi", @@ -826,6 +888,64 @@ def derive_tros( confidence="mittel", ) ) + tros.append(tro) + + # 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: @@ -1502,14 +1622,91 @@ def missing_positions(analysis: Analysis) -> list[str]: return [t.tro_id for t in analysis.tros + analysis.boundaries if not t.position] -def _legend_pos(analysis: Analysis) -> tuple[float, float]: - """Legende links unterhalb der Anlage platzieren (mm).""" +# 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 _anzeige_position( + boundary: Tro, tros: list[Tro] +) -> tuple[float, float, float] | None: + """Position einer Systemgrenze fuer die Zeichnung: liegt sie naeher als + COORD_MIN_ABSTAND an einem TRO, wird sie radial von ihm weggeschoben.""" + if not boundary.position: + return None + x, y, z = boundary.position + for tro in tros: + if not tro.position: + continue + dx, dy = x - tro.position[0], y - tro.position[1] + d = math.hypot(dx, dy) + if d >= COORD_MIN_ABSTAND: + continue + if d == 0: + dx, dy, d = 0.0, -1.0, 1.0 + x = tro.position[0] + dx / d * COORD_MIN_ABSTAND + y = tro.position[1] + dy / d * COORD_MIN_ABSTAND + return (x, y, z) + + +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(analysis: Analysis) -> tuple[float, float]: + """Legende rechts neben der Anlage, obere Kante etwa auf Anlagenoberkante + (mm; pos ist bei neato -n der Knotenmittelpunkt).""" all_nodes = analysis.tros + analysis.boundaries xs = [t.position[0] for t in all_nodes if t.position] ys = [t.position[1] for t in all_nodes if t.position] if not xs: return (0.0, 0.0) - return (min(xs) - LEGEND_MARGIN, min(ys) - LEGEND_MARGIN) + return (max(xs) + LEGEND_MARGIN + 1500, max(ys) - 1500) # Kantenstil je Art (an material_flow.py angelehnt: Umlauf hellblau gestrichelt, @@ -1546,17 +1743,23 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str add("digraph TROFluss {") if use_coords: # Bei -n bestimmen die pos-Attribute die Lage; rankdir/ranksep entfallen. - add(" graph [splines=true, outputorder=edgesfirst,") - add(' fontname="Segoe UI", fontsize=11, labelloc="t",') - add(f' label="TRO-Fluss lagerichtig ({COORD_SCALE} pt/mm) - ' - f'{_dot_escape(source)}"];') + 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)}"];') - 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];") + 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: @@ -1572,7 +1775,10 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str scale = COORD_SCALE if use_coords else 0.01 pos = (f', pos="{tro.position[0] * scale:.2f},' f'{tro.position[1] * scale:.2f}"') - add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];') + 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 @@ -1581,12 +1787,18 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str for boundary in analysis.boundaries: rendered = ", ".join(f'{k}="{v}"' for k, v in STYLE_EXT.dot_attrs().items()) pos = "" - if boundary.position: + position = (_anzeige_position(boundary, analysis.tros) if use_coords + else boundary.position) + if position: scale = COORD_SCALE if use_coords else 0.01 - pos = (f', pos="{boundary.position[0] * scale:.2f},' - f'{boundary.position[1] * scale:.2f}"') - add(f' "{boundary.tro_id}" [label="{_tro_label(boundary)}", ' - f'shape=ellipse, style=filled, {rendered}{pos}];') + pos = (f', pos="{position[0] * scale:.2f},' + f'{position[1] * scale:.2f}"') + if use_coords: + attrs = dict(STYLE_EXT.dot_attrs(), shape="ellipse") + add(f' "{boundary.tro_id}" [{_coord_node_attrs(boundary, attrs)}{pos}];') + else: + add(f' "{boundary.tro_id}" [label="{_tro_label(boundary)}", ' + f'shape=ellipse, style=filled, {rendered}{pos}];') add("") for src, dst in analysis.edges: @@ -1599,18 +1811,26 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str if key in seen: continue seen.add(key) + # Lagerichtig liegen gleiche Bahn/Strecke ohnehin sichtbar + # nebeneinander - dort nur noch als blasser Hinweis. + farbe = '"#c8c8c8", penwidth=0.7' if use_coords else '"#7f7f7f"' add(f' "{tro.tro_id}" -> "{other}" [dir=none, style=dotted, ' - f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];') + f'color={farbe}, 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'