model.py: gemeinsames TRO-Modell; Hauptweg CSV -> tro_flow --json -> scl_skeleton

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Samer Ayadi
2026-10-09 12:42:46 +02:00
co-authored by Claude Opus 5.5
parent 90cafec99c
commit 97a0db6707
14 changed files with 790 additions and 275 deletions
+51 -113
View File
@@ -86,6 +86,8 @@ Ausgabe (alles in SKEL_RESULTS)
<name>_tro_flow.dot immer (mit --use-cords lagerichtige pos-Attribute)
<name>_tro_flow.svg mit --tosvg (dot, mit --use-cords neato -n)
<name>_tro_doc.md mit --doc
<name>_tro.json mit --json: TRO-JSON (model.TroDocument), Eingabe
fuer scl_skeleton.py
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
Aufruf ueber bin/tro_flow.bat bzw. bin/tro_flow.sh.
@@ -98,7 +100,6 @@ import csv
import json
import math
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
@@ -124,13 +125,13 @@ from material_flow import (
read_elements,
resolve_input,
)
from model import Analysis, DerivedTro, TroDocument
from tro_catalog import (
CATALOG_AS_OF,
ITEM_FB,
STYLE_EXT,
STYLES,
TRO_CATALOG,
TroDefinition,
TroItem,
get_tro,
mermaid_classdefs,
@@ -177,82 +178,6 @@ COORD_MAX_LUECKE = 1500
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
# ---------------------------------------------------------------------------
@@ -730,7 +655,7 @@ def derive_tros(
findings: list[str],
*,
sepliste_counts: dict[str, int] | None = None,
) -> list[Tro]:
) -> list[DerivedTro]:
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden.
Host eines Separators aus der CSV-Spalte "Zuordnung" (_default_resolve_host),
@@ -862,7 +787,7 @@ def derive_tros(
f"sepliste (Roh-JSON) - Gegenprobe weicht ab, bitte pruefen"
)
tros: list[Tro] = []
tros: list[DerivedTro] = []
# 1) Schwerkraftlinien (Gefaellestrecke mit Separator) -> Speicherblock
lanes = sorted(
@@ -887,7 +812,7 @@ def derive_tros(
speicher_steuer.update(steuer)
alle_seps = steuer + seps
tros.append(
Tro(
DerivedTro(
tro_id="",
type_name="PinStore_Auto",
label=f"Speicher ({len(group)} Schwerkraftlinien)",
@@ -907,7 +832,7 @@ def derive_tros(
for lane in group:
lane_seps = seps_on([lane])
tros.append(
Tro(
DerivedTro(
tro_id="",
type_name="1Sep",
label=graph.nodes[lane].element.name,
@@ -973,7 +898,7 @@ def derive_tros(
# Streckengruppe genau 1 echter Kettenseparator, alle
# uebrigen Zuordnung-Treffer sind eigenstaendige Vereinzeler).
tros.append(
Tro(
DerivedTro(
tro_id="",
type_name="1Sep",
label=element.name,
@@ -1034,7 +959,7 @@ def derive_tros(
f"Vario eingestuft (widerspricht alternierendem Muster)"
)
tros.append(
Tro(
DerivedTro(
tro_id="",
type_name=type_name,
label=element.name,
@@ -1055,7 +980,7 @@ def derive_tros(
node = graph.nodes[host]
lane_name = _merkmal(node.element.merkmale, "Name") or node.element.name
tros.append(
Tro(
DerivedTro(
tro_id="",
type_name="1Sep",
label=f"Separator {sep_id} auf {lane_name} Bahn {node.lane}",
@@ -1079,7 +1004,7 @@ 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
as_switch_tro: dict[str, DerivedTro] = {} # Ausschleuselement-Id -> 1Sep1Swi
for sep_id, (transfer, distance) in sorted(switch_hits.items()):
if sep_id in speicher_steuer:
continue # Teil seines Speichers (Schritt 1)
@@ -1088,7 +1013,7 @@ def derive_tros(
own_scanners = sorted(sep_scanners.get(sep_id, []))
art = "Ausschleuselement"
as_switch_tro[transfer.teile_id] = tro = (
Tro(
DerivedTro(
tro_id="",
type_name="1Sep1Swi",
label=f"Separator {sep_id} an {art} {transfer.teile_id} "
@@ -1161,10 +1086,10 @@ def derive_tros(
# 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
weiche_tro: dict[str, DerivedTro] = {} # Weichen-Id -> 1Sep1Swi
for sep_id, weiche in sorted(weiche_hits.items()):
host = resolve_host(elements_by_id[sep_id], findings)
weiche_tro[weiche.teile_id] = tro = Tro(
weiche_tro[weiche.teile_id] = tro = DerivedTro(
tro_id="",
type_name="1Sep1Swi",
label=f"Separator {sep_id} an ILS Weiche {weiche.teile_id}",
@@ -1194,7 +1119,7 @@ def derive_tros(
continue
tros.remove(as_tro)
tros.remove(w_tro)
tros.append(Tro(
tros.append(DerivedTro(
tro_id="",
type_name="2Sep2Swi",
label=(f"Uebergabestelle {kreisel.name}: Ausschleuselement "
@@ -1258,7 +1183,7 @@ BOUNDARY_EXIT = "Materialausgang"
BOUNDARY_TYPES = (BOUNDARY_ENTRY, BOUNDARY_EXIT)
def derive_boundaries(elements: list[Element], graph: Graph) -> list[Tro]:
def derive_boundaries(elements: list[Element], graph: Graph) -> list[DerivedTro]:
"""Be-/Entladestationen als sichtbare Systemgrenz-Knoten.
Ohne diese Knoten "verschwindet" der Materialfluss stillschweigend an der
@@ -1286,14 +1211,14 @@ def derive_boundaries(elements: list[Element], graph: Graph) -> list[Tro]:
getrennt von den TRO-IDs, damit auf den ersten Blick klar bleibt: das
sind keine Steuerobjekte.
"""
boundaries: list[Tro] = []
boundaries: list[DerivedTro] = []
for element in elements:
if not element.is_station:
continue
if element.teile_id not in graph.nodes:
continue
boundaries.append(
Tro(
DerivedTro(
tro_id="",
type_name=BOUNDARY_ENTRY if element.is_entry else BOUNDARY_EXIT,
label=element.name,
@@ -1349,7 +1274,7 @@ def _bahn_richtung(node) -> tuple[float, float] | None:
return achse if mit_achse else (-achse[0], -achse[1])
def _tro_bahn(tro: Tro, graph: Graph, sep_assignment: dict[str, str]) -> str | None:
def _tro_bahn(tro: DerivedTro, graph: Graph, sep_assignment: dict[str, str]) -> str | None:
"""Kreisel-Bahn, auf der ein TRO physisch sitzt - fuer die Positions-
Verkettung in link_tros(). Meist einfach der TRO-Host (wenn er direkt auf
der Bahn liegt, z. B. ein gewoehnlicher Kreisel-Separator). Bei einem
@@ -1430,7 +1355,7 @@ def _chain_mit_ungelisteten_separatoren(
def link_tros(
graph: Graph, tros: list[Tro], elements: list[Element]
graph: Graph, tros: list[DerivedTro], elements: list[Element]
) -> tuple[list[tuple[str, str]], dict[tuple[str, str], str]]:
"""
TROs verknuepfen.
@@ -1692,7 +1617,7 @@ def link_tros(
# 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]] = {}
bahn_gruppen: dict[str, list[DerivedTro]] = {}
for tro in tros:
bahn = _tro_bahn(tro, graph, sep_assignment)
if bahn and bahn.split("-")[0] not in covered_kreisel_bases:
@@ -1814,7 +1739,7 @@ def _centroid(
)
def locate_tros(tros: list[Tro], elements: list[Element], graph: Graph) -> None:
def locate_tros(tros: list[DerivedTro], elements: list[Element], graph: Graph) -> None:
"""
Jedem TRO eine Koordinate geben.
@@ -1872,15 +1797,8 @@ def analyse_tros(
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.unknown_types = sorted(
{tro.type_name for tro in analysis.tros if tro.definition() is None})
analysis.isolated = [tro for tro in analysis.tros if not tro.connected]
analysis.findings.sort()
return analysis
@@ -1891,7 +1809,7 @@ def analyse_tros(
# ---------------------------------------------------------------------------
def _tro_label(tro: Tro) -> str:
def _tro_label(tro: DerivedTro) -> str:
lines = [f"{tro.tro_id} ({tro.type_name})", tro.label]
if tro.fb_block:
lines.append(tro.fb_block)
@@ -1968,7 +1886,7 @@ def _station_positionen(
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]] = {}
je_tro: dict[str, list[DerivedTro]] = {}
ergebnis: dict[str, tuple[float, float]] = {}
for station in analysis.boundaries:
nachbarn = (station.successors if station.type_name == BOUNDARY_ENTRY
@@ -2017,7 +1935,7 @@ def _station_positionen(
return ergebnis
def _tro_kurzinfo(tro: Tro) -> str:
def _tro_kurzinfo(tro: DerivedTro) -> str:
"""Zweizeilige Beschriftung neben dem Symbol: Typ und Bauteile."""
if tro.type_name in BOUNDARY_TYPES:
return _dot_label([tro.type_name, tro.label])
@@ -2030,12 +1948,12 @@ def _tro_kurzinfo(tro: Tro) -> str:
return _dot_label([tro.type_name, teile])
def _tro_tooltip(tro: Tro) -> str:
def _tro_tooltip(tro: DerivedTro) -> str:
"""Vollstaendige Angaben als SVG-Tooltip (Zeilenumbruch per &#10;)."""
return _tro_label(tro).replace("\\n", "&#10;")
def _coord_node_attrs(tro: Tro, base: dict[str, str]) -> str:
def _coord_node_attrs(tro: DerivedTro, base: dict[str, str]) -> str:
"""Knotenattribute fuer --use-cords: feste Groesse, kurzes Label im Symbol,
Typ/Bauteile als xlabel daneben, alle Details als Tooltip."""
attrs = dict(base)
@@ -2069,7 +1987,7 @@ def _zeichen_positionen(
"""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
angehaengten TRO (_station_positionen). Reine Darstellung - DerivedTro.position
und die Tooltip-Koordinaten bleiben echt."""
roh: dict[str, tuple[float, float]] = {}
for tro in analysis.tros:
@@ -2117,7 +2035,7 @@ def render_dot(analysis: Analysis, source: str, use_coords: bool = False,
add = out.append
zeichen = _zeichen_positionen(analysis, compact) if use_coords else {}
def _pos(node: Tro) -> str:
def _pos(node: DerivedTro) -> str:
if use_coords:
p = zeichen.get(node.tro_id)
return (f', pos="{p[0] * COORD_SCALE:.2f},{p[1] * COORD_SCALE:.2f}"'
@@ -2719,6 +2637,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" tro_flow.bat --file mubea.csv --tosvg\n"
" tro_flow.bat --file mubea.csv --tosvg --doc\n"
" tro_flow.bat --file mubea.csv --tosvg --use-cords\n"
" tro_flow.bat --file mubea.csv --json\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
@@ -2761,6 +2680,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"schreiben: Knoten (TRO = Typ), Verbindungen, Systemgrenzen und je "
"TRO Vorgaenger/Nachfolger.",
)
parser.add_argument(
"--json",
action="store_true",
help="TRO-JSON als <name>_tro.json nach %%SKEL_RESULTS%% schreiben "
"(Modell lib/model.py, TroDocument) - Eingabe fuer scl_skeleton.py.",
)
parser.add_argument(
"--true-scale",
dest="true_scale",
@@ -2812,6 +2737,7 @@ def main(argv: list[str] | None = None) -> int:
stem = csv_file.stem
connect_target = results / f"{stem}_tro_connect.ini"
json_target = results / f"{stem}_tro.json"
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts)
if not analysis.tros:
@@ -2847,6 +2773,16 @@ def main(argv: list[str] | None = None) -> int:
)
connect_file = connect_target
json_file = None
if args.json:
document = TroDocument.from_analysis(
analysis, source=csv_file.name,
note="Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die "
"Zeichnung ist nur Ansicht, keine Datenquelle.",
)
json_target.write_text(document.to_json(), encoding="utf-8")
json_file = json_target
has_errors = write_error_file(analysis, csv_file, error_target)
error_file = error_target if has_errors else None
@@ -2872,6 +2808,8 @@ def main(argv: list[str] | None = None) -> int:
warnings, engine=engine)
if connect_file:
print(f"connect.ini = {connect_file}")
if json_file:
print(f"TRO-JSON = {json_file}")
if svg_error:
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)