TRO-Fluss: je Typ eigenes Symbol im Diagramm, Weichenerkennung ueber Scanner

lib/tro_catalog.py: dot_shape_attrs()/SYMBOL_LABELS geben render_dot() eine
Graphviz-Form je TroSymbol (bisher nur fuer den CAD-Marker in tro_annotate.py
genutzt) - Farbe gruppiert weiterhin verwandte Typen, Form unterscheidet den
einzelnen Typ. Legende und Doku-Tabellen zeigen jetzt jeden Katalogtyp mit
Farbe+Symbol statt nur die 6 Farbgruppen.

lib/tro_flow.py: _find_switch_separators() bekommt einen zweiten Durchlauf,
der einen Separator mit eigenem Scanner (CAD-Merkmal "Naechster Separator")
als Weiche erkennt, wenn die reine Abstandsregel (SWITCH_PROXIMITY_MM) ihn
verfehlt - Rolle (Aus-/Einschleusung) wird aus einem bereits bestaetigten
Treffer auf derselben Kreisel-Bahn gelesen, keine neue feste mm-Schwelle.
Grund: bei HundM05 (5 Abzweige an einem Kreisel) lagen 3 der 5 echten
Weichen-Separatoren 727-1139mm von ihrem Ausschleuselement entfernt - ausserhalb
der auf einer aelteren Revision kalibrierten 500mm-Grenze - und wurden bisher
faelschlich als einfacher 1Sep statt 1Sep1Swi eingestuft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-09-18 16:12:13 +02:00
co-authored by Claude Sonnet 5
parent 60f855fff5
commit 72d0a21f65
2 changed files with 140 additions and 24 deletions
+60 -2
View File
@@ -77,6 +77,9 @@ __all__ = [
"set_tro",
"catalog",
"mermaid_classdefs",
"dot_shape_attrs",
"SYMBOL_LABELS",
"symbol_label",
]
# Stand der Dokumente, aus denen die Typdefinition uebernommen wurde
@@ -165,6 +168,53 @@ class TroSymbol:
)
# Graphviz-Knotenform je Symbol, fuer render_dot() in tro_flow.py. Peripheries
# zeichnet die Doppel-Varianten als zwei konzentrische Formen - dasselbe Bild,
# das draw_shape() in tro_annotate.py fuer den CAD-Marker als zwei ineinander
# liegende Polygone zeichnet. Namen sind Graphviz-Bordformen (dot -Tsvg).
_DOT_SHAPE: dict[str, dict[str, str]] = {
TroSymbol.CIRCLE: {"shape": "circle"},
TroSymbol.DOUBLE_CIRCLE: {"shape": "circle", "peripheries": "2"},
TroSymbol.TRIANGLE: {"shape": "triangle"},
TroSymbol.TRIANGLE_DOWN: {"shape": "invtriangle"},
TroSymbol.DOUBLE_TRIANGLE_DOWN: {"shape": "invtriangle", "peripheries": "2"},
TroSymbol.SQUARE: {"shape": "square"},
TroSymbol.DIAMOND: {"shape": "diamond"},
TroSymbol.PENTAGON: {"shape": "pentagon"},
TroSymbol.HEXAGON: {"shape": "hexagon"},
TroSymbol.RECT: {"shape": "box"},
TroSymbol.ARROW: {"shape": "rarrow"},
}
def dot_shape_attrs(symbol: str) -> dict[str, str]:
"""Graphviz-Knotenform (shape[, peripheries]) fuer ein TroSymbol; unbekannte
Symbole fallen auf die normale Box zurueck."""
return dict(_DOT_SHAPE.get(symbol, {"shape": "box"}))
# Klartext je Symbol - fuer die Legende in render_dot()/render_doc() (tro_flow.py),
# damit dort nicht nur die Farbgruppe, sondern auch die Form je Typ lesbar ist.
SYMBOL_LABELS: dict[str, str] = {
TroSymbol.CIRCLE: "Kreis",
TroSymbol.DOUBLE_CIRCLE: "Doppelkreis",
TroSymbol.TRIANGLE: "Dreieck (Spitze oben)",
TroSymbol.TRIANGLE_DOWN: "Dreieck (Spitze unten)",
TroSymbol.DOUBLE_TRIANGLE_DOWN: "Doppel-Dreieck (Spitze unten)",
TroSymbol.SQUARE: "Quadrat",
TroSymbol.DIAMOND: "Raute",
TroSymbol.PENTAGON: "Fuenfeck",
TroSymbol.HEXAGON: "Sechseck",
TroSymbol.RECT: "liegendes Rechteck",
TroSymbol.ARROW: "Pfeil",
}
def symbol_label(symbol: str) -> str:
"""Klartext eines Symbols; der Symbolname selbst, wenn kein Klartext hinterlegt ist."""
return SYMBOL_LABELS.get(symbol, symbol)
class TroStyle(BaseModel):
"""
Darstellung einer TRO-Typgruppe.
@@ -371,8 +421,12 @@ class TroDefinition(BaseModel):
return self.style.group
def get_dot_attrs(self) -> dict[str, str]:
"""Graphviz-Knotenattribute dieses Typs."""
return self.style.dot_attrs()
"""Graphviz-Knotenattribute dieses Typs: Fuell-/Randfarbe der Farbgruppe
plus die typ-eigene Form (siehe TroSymbol/dot_shape_attrs) - Farbe
gruppiert verwandte Typen, Form unterscheidet den einzelnen Typ."""
attrs = self.style.dot_attrs()
attrs.update(dot_shape_attrs(self.symbol))
return attrs
# -- set ---------------------------------------------------------------
#
@@ -432,6 +486,10 @@ class TroDefinition(BaseModel):
return "-"
return ", ".join(f"{self.items[i]}x {i}" for i in self.get_item_names())
def describe_symbol(self) -> str:
"""Symbol als Klartext, z. B. 'Dreieck (Spitze oben)'."""
return symbol_label(self.symbol)
# ---------------------------------------------------------------------------
# Statische Typdefinition
+80 -22
View File
@@ -273,20 +273,38 @@ def _default_resolve_host(sep: Element, findings: list[str], graph: Graph) -> st
def _find_switch_separators(
separators: list[Element], elements: list[Element], tol_mm: float
) -> dict[str, tuple[Element, float]]:
"""Separator-Id -> (naechstgelegenes Aus-/Einschleuselement, Abstand mm),
nur wenn Abstand <= tol_mm.
"""Separator-Id -> (Aus-/Einschleuselement der Weiche, Abstand mm).
HundM05 hat an seinen Kreisel<->Strecke-Uebergaengen kein eigenes "ILS
Weiche"-Bauteil (anders als ein Kreisel<->Kreisel-Beruehrpunkt, siehe
Mubea) - das von dxfmakros synthetisierte Ausschleuselement (Kreisel ->
Strecke, am Streckenanfang) bzw. Einschleuselement (Strecke -> Kreisel,
am Streckenende) uebernimmt dort faktisch die Rolle der Weiche. Sitzt ein
Separator direkt daneben, gehoert er zu dieser Weiche - unabhaengig davon,
ob seine CSV-Spalte "Zuordnung" auf die Kreisel-Bahn oder auf die Strecke
zeigt (in den Daten kommt beides vor, siehe derive_tros).
Zwei Regeln, angewandt in dieser Reihenfolge:
1. Geometrische Naehe (Distanz <= tol_mm). HundM05 hat an seinen
Kreisel<->Strecke-Uebergaengen kein eigenes "ILS Weiche"-Bauteil
(anders als ein Kreisel<->Kreisel-Beruehrpunkt, siehe Mubea) - das von
dxfmakros synthetisierte Ausschleuselement (Kreisel -> Strecke, am
Streckenanfang) bzw. Einschleuselement (Strecke -> Kreisel, am
Streckenende) uebernimmt dort faktisch die Rolle der Weiche. Sitzt ein
Separator direkt daneben, gehoert er zu dieser Weiche - unabhaengig
davon, ob seine CSV-Spalte "Zuordnung" auf die Kreisel-Bahn oder auf
die Strecke zeigt (in den Daten kommt beides vor, siehe derive_tros).
2. Separator mit eigenem Scanner (Scanner-Merkmal "Naechster Separator"),
auf noch unbelegten Uebergabestellen der SELBEN Rolle (Aus- oder
Einschleusung) und dem SELBEN Kreisel-Anschluss wie ein bereits durch
Regel 1 bestaetigter Treffer auf dieser Bahn - keine feste mm-Grenze,
stattdessen die naechstgelegene freie Uebergabestelle dieser Rolle.
Grund (HundM05, Review 2026-09-18): an einem Kreisel mit 5 Abzweigen
liegen die tatsaechlichen Schalt-Separatoren pro Abzweig unterschiedlich
weit vom Uebergabeelement entfernt (418-1139mm in der aktuellen
Registrierung) - eine einzelne mm-Schwelle (Regel 1) trifft nicht alle.
Ein Scanner direkt am Separator ist hier aber der CAD-belegte
Lesepunkt vor der Weichenentscheidung (Barcode -> Richtung), also ein
verlaesslicherer Marker als der reine Abstand. Nach Rolle (Aus- vs.
Einschleusung) gefiltert, damit z. B. ein Separator auf der
Ausschleus-Bahn nicht versehentlich mit dem naeher liegenden
Einschleuselement einer anderen Strecke verkoppelt wird.
"""
transfers = [e for e in elements if e.kind in ("Ausschleus", "Einschleus")]
result: dict[str, tuple[Element, float]] = {}
for sep in separators:
if sep.x is None or sep.y is None:
continue
@@ -299,6 +317,41 @@ def _find_switch_separators(
best = (d, transfer)
if best is not None:
result[sep.teile_id] = (best[1], best[0])
# Rolle je Kreisel-Anschluss aus einem bereits bestaetigten Treffer lesen -
# keine Annahme, welche Bahn (-L/-R) Aus- bzw. Einschleusung uebernimmt.
bahn_role: dict[str, str] = {}
for sep in separators:
hit = result.get(sep.teile_id)
if hit and sep.assignment:
bahn_role.setdefault(sep.assignment, hit[0].kind)
scanner_targets = {
str(_merkmal(e.merkmale, "Naechster Separator") or "").strip()
for e in elements if e.kind == "Scanner"
}
used_transfers = {hit[0].teile_id for hit in result.values()}
for sep in separators:
if sep.teile_id in result or sep.teile_id not in scanner_targets:
continue
if sep.x is None or sep.y is None:
continue
role = bahn_role.get(sep.assignment or "")
if role is None:
continue
best: tuple[float, Element] | None = None
for transfer in transfers:
if (transfer.kind != role or transfer.teile_id in used_transfers
or transfer.x is None or transfer.y is None):
continue
d = math.hypot(transfer.x - sep.x, transfer.y - sep.y)
if best is None or d < best[0]:
best = (d, transfer)
if best is not None:
result[sep.teile_id] = (best[1], best[0])
used_transfers.add(best[1].teile_id)
return result
@@ -976,14 +1029,17 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str
f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];')
add("")
# Legende: eine Zeile je Farbgruppe, plus die Kantenarten.
# Bei -n braucht auch sie ein pos, sonst landet sie auf 0,0 mitten in der
# Anlage - daher unterhalb links vom Anlagenrand.
# 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.
rows = "".join(
f'<tr><td bgcolor="{s.fill}" align="left"> {s.group} </td>'
f'<td align="left">{s.label}</td></tr>'
for s in STYLES
f'<tr><td bgcolor="{d.get_style().fill}" align="left"> {d.get_name()} </td>'
f'<td align="left">{d.describe_symbol()}</td></tr>'
for d in TRO_CATALOG
)
rows += (f'<tr><td bgcolor="{STYLE_EXT.fill}" align="left"> Systemgrenze </td>'
f'<td align="left">Ellipse (Materialein-/ausgang, kein TRO)</td></tr>')
legend_label = ('<<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
+ rows
+ '<tr><td align="left"><font color="#2f5597">durchgezogen</font></td>'
@@ -1165,14 +1221,15 @@ def render_doc(
add(f"| Offene Punkte (noch kein TRO) | "
f"{len(analysis.overrides.open_points)} |")
add("")
add("| Typ | FB-Baustein | Anzahl hier | Bauteile je TRO | Farbgruppe |")
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.get_group() if definition else '-'} "
f"| {definition.describe_symbol() if definition else '-'} |")
add("")
add("## 2. TRO-Flussdiagramm")
@@ -1346,11 +1403,12 @@ def render_doc(
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 |")
add("|---:|---|---|---|---|")
add("| # | Typ | FB-Baustein | Bauteile je TRO | Farbgruppe | Symbol |")
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_items()} | {definition.get_group()} "
f"| {definition.describe_symbol()} |")
add("")
add("| Bauteil | Sub-FB |")
add("|---|---|")