TRO-Fluss: ILS Weiche als echte Weiche, 2Sep2Swi am Kreisel-Beruehrpunkt, lesbare lagerichtige Darstellung

- _find_weiche_separators: ILS Weiche (TeileArt) ist ein echtes Weichen-
  Bauteil -> 1Sep1Swi; steuernder Separator = CSV-Nachbar mit Scanner
  (Mubea 0125 -> 0036), mehrdeutig -> Finding statt Raten
- _find_switch_separators: Separator mit Zuordnung auf eine Strecke, der in
  der TrackIds-Kette eines Kreisels steht, gilt als Kreisel-Separator
  (Mubea 0005 vor Ausschleuselement 0057)
- derive_tros 3d: Ausschleuselement-Weiche und ILS Weiche direkt
  hintereinander in der TrackIds-Kette -> ein 2Sep2Swi (Mubea TRO10:
  Sep 0005/0036, Scan 0006/0041); FB_ILS_MTRO_2Sep2Swi fehlt noch in der ILSLib
- --use-cords: feste Symbolgroesse je Form, nur TRO-Nummer im Symbol,
  Typ/Separator als xlabel, Details als Tooltip, Massstab 0.1 statt 0.25 pt/mm,
  Legende rechts mit nur den vorkommenden Typen, blasse Linien fuer
  gleiche Bahn, Systemgrenzen dicht an einem TRO werden in der Zeichnung
  beiseitegeschoben
- HundM05 unveraendert (27 Verbindungen), Mubea 18 TROs / 23 Verbindungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-10-01 14:16:30 +02:00
co-authored by Claude Opus 5.5
parent 02db9a95b0
commit 43543915ca
+248 -28
View File
@@ -166,9 +166,16 @@ 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. 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 &#10;)."""
return _tro_label(tro).replace("\\n", "&#10;")
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'<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 TRO_CATALOG
for d in legend_types
)
rows += (f'<tr><td bgcolor="{STYLE_EXT.fill}" align="left"> Systemgrenze </td>'
f'<td align="left">Ellipse (Materialein-/ausgang, kein TRO)</td></tr>')
@@ -1626,8 +1846,8 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str
'<td align="left">gleiche Bahn / Strecke</td></tr>'
"</table>>")
if use_coords:
lx, ly = _legend_pos(analysis)
add(f' legende [shape=plaintext, style="", fillcolor="none", '
lx, ly = _legend_pos_rechts(analysis)
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: