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
+306
View File
@@ -0,0 +1,306 @@
# -*- coding: utf-8 -*-
"""
model.py - Gemeinsames Datenmodell der TRO-Instanzen und der TRO-JSON.
Schichten (wer darf wen importieren):
tro_catalog.py Typ-Ebene: was ein TRO-*Typ* ist (Bauteile, FB, Farbe, Symbol)
model.py Instanz-Ebene: ein konkreter TRO, die ganze TRO-JSON <- hier
importiert nur tro_catalog
tro_flow.py Ableitung: CSV-Graph -> Analysis -> TroDocument
scl_skeleton.py Ausgabe: TroDocument -> FB_Main.scl
tro_annotate.py Ausgabe: Analysis -> annotierte Zeichnung (nur Ansicht)
Klassen:
TroBase was jeder TRO hat, egal woher er stammt: ID, Typ, Separatoren,
Vorgaenger/Nachfolger, Position. FB-Baustein und Bauteile
werden aus dem Katalog gelesen, nie gespeichert.
DerivedTro TroBase + was tro_flow bei der Ableitung zusaetzlich weiss
(Hosts, Scanner, Begruendung, Positionsquelle ...)
Connection eine gerichtete Verbindung zwischen zwei TROs/Stationen
OpenPoint eine gemeldete Stelle, die noch kein TRO ist
Analysis Arbeitsergebnis von tro_flow.analyse_tros
TroDocument die TRO-JSON (<csv>_tro.json): von tro_flow --json
geschrieben, von scl_skeleton gelesen und geprueft
In der JSON stehen die Schluessel wie bisher in der TRO-JSON (`id`, `type`,
`fbType`, `openPoints`, ...); im Python-Code heissen die Felder weiter
`tro_id`/`type_name` (Aliase, `populate_by_name`).
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import (
BaseModel,
ConfigDict,
Field,
computed_field,
field_serializer,
field_validator,
model_validator,
)
from tro_catalog import TroDefinition, get_tro
__all__ = [
"SCHEMA",
"TroBase",
"DerivedTro",
"Connection",
"OpenPoint",
"Analysis",
"TroDocument",
]
# Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung),
# v2 die von tro_flow --json (aus dem CSV, ueber dieses Modell).
SCHEMA = "sps_skel-tro-v2"
# Gemeinsame Einstellungen: Pruefung bei Erzeugung und Zuweisung, unbekannte
# Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt
# still auf einen Standardwert zurueckzufallen), Aliase und Feldnamen gelten
# beide.
_CONFIG = ConfigDict(
validate_assignment=True,
extra="forbid",
populate_by_name=True,
)
class TroBase(BaseModel):
"""
Kern eines TRO (Instanz in einer Anlage), unabhaengig von seiner Quelle.
Gegenstueck zu :class:`tro_catalog.TroDefinition`: die Definition beschreibt
den *Typ* (FB-Baustein, Bauteile), ein TRO die konkrete Stelle im Layout.
Verknuepft ueber ``type_name``, siehe :meth:`definition`.
FB-Baustein und Bauteile haengen nur am Typ. Sie werden nicht gespeichert,
sondern bei jedem Zugriff aus dem Katalog gelesen (``fb_block``, ``items``)
und so auch in die JSON geschrieben. Steht beim Einlesen einer JSON ein
Wert drin, der nicht zum Katalog passt, ist das ein Fehler - den Typ
aendern, nicht die Bauteile.
Listenfelder werden beim Pruefen kopiert; ``tro.hosts.append(...)`` aendert
also nur diesen TRO.
"""
model_config = _CONFIG
tro_id: str = Field(alias="id")
type_name: str = Field(alias="type")
confidence: str = ""
separators: list[str] = Field(default_factory=list)
predecessors: list[str] = Field(default_factory=list)
successors: list[str] = Field(default_factory=list)
# Anlagenkoordinate (mm); in der JSON als {"x": .., "y": .., "z": ..}
position: tuple[float, float, float] | None = None
# -- aus dem Katalog -----------------------------------------------------
def definition(self) -> TroDefinition | None:
"""Typdefinition aus tro_catalog (None wenn Typ unbekannt)."""
return get_tro(self.type_name)
@computed_field(alias="fbType") # type: ignore[prop-decorator]
@property
def fb_block(self) -> str:
"""FB-Baustein laut Katalog, z. B. 'FB_ILS_MTRO_1Sep' ("" wenn Typ unbekannt)."""
definition = self.definition()
return definition.get_fb_block() if definition else ""
@computed_field # type: ignore[prop-decorator]
@property
def items(self) -> dict[str, int]:
"""Bauteile laut Katalog, z. B. {'Separator': 1} ({} wenn Typ unbekannt)."""
definition = self.definition()
return definition.get_items() if definition else {}
@model_validator(mode="before")
@classmethod
def _check_catalog_fields(cls, data: Any) -> Any:
"""`items`/`fbType` aus einer JSON gegen den Katalog pruefen und entfernen."""
if not isinstance(data, dict) or not ({"items", "fbType", "fb_block"} & data.keys()):
return data
data = dict(data)
items = data.pop("items", None)
fb_block = data.pop("fbType", data.pop("fb_block", None))
type_name = data.get("type", data.get("type_name"))
definition = get_tro(type_name) if isinstance(type_name, str) else None
expected_items = definition.get_items() if definition else {}
expected_fb = definition.get_fb_block() if definition else ""
if items is not None and dict(items) != expected_items:
raise ValueError(
f"items {items} passt nicht zum Typ '{type_name}' laut tro_catalog "
f"({expected_items}) - den Typ aendern, nicht die Bauteile"
)
if fb_block is not None and (fb_block or "") != expected_fb:
raise ValueError(
f"fbType '{fb_block}' passt nicht zum Typ '{type_name}' laut "
f"tro_catalog ('{expected_fb}')"
)
return data
# -- Position ------------------------------------------------------------
@field_validator("position", mode="before")
@classmethod
def _position_from_json(cls, value: Any) -> Any:
if isinstance(value, dict):
return (value["x"], value["y"], value.get("z", 0.0))
return value
@field_serializer("position", when_used="json-unless-none")
def _position_to_json(self, value: tuple[float, float, float]) -> dict[str, float]:
return {"x": round(value[0], 2), "y": round(value[1], 2), "z": round(value[2], 2)}
@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}"
class DerivedTro(TroBase):
"""
Ein von tro_flow aus dem CSV abgeleiteter TRO (oder eine Systemgrenze).
Zusaetzlich zum Kern: aus welchen Bauteilen er gebildet wurde, warum er
diesen Typ hat, und woher seine Position stammt.
"""
label: str
hosts: list[str]
scanners: list[str]
reason: str
colocated: list[str] = Field(default_factory=list)
# Mittelpunkt der Bauteile, aus denen dieser TRO gebildet wurde
position_from: str = Field(default="", alias="positionFrom")
position_points: int = Field(default=0, alias="positionPoints")
@property
def connected(self) -> bool:
return bool(self.successors or self.predecessors or self.colocated)
class Connection(BaseModel):
"""Gerichtete Verbindung zwischen zwei TROs/Stationen (JSON: from/to/kind)."""
model_config = _CONFIG
src: str = Field(alias="from")
dst: str = Field(alias="to")
# transfer / umlauf / weiche / unbestimmt / normal (siehe tro_flow.link_tros)
kind: str = ""
class OpenPoint(BaseModel):
"""Eine im Review gemeldete Stelle, die noch kein eigener TRO ist."""
model_config = _CONFIG
name: str
x: float
y: float
z: float | None = None
expect: str = ""
belongs_to: str = Field(default="", alias="belongsTo")
note: str = ""
def describe_position(self) -> str:
return f"{self.x:.0f}, {self.y:.0f}"
class Analysis(BaseModel):
"""Ergebnis von tro_flow.analyse_tros: TROs, Kanten, Befunde."""
model_config = _CONFIG
tros: list[DerivedTro] = 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[DerivedTro] = 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[DerivedTro] = Field(default_factory=list)
def by_id(self, tro_id: str) -> DerivedTro | None:
for tro in self.tros + self.boundaries:
if tro.tro_id == tro_id:
return tro
return None
class TroDocument(BaseModel):
"""
Die TRO-JSON einer Anlage (``<csv>_tro.json``).
Geschrieben von ``tro_flow --json`` (:meth:`from_analysis`, :meth:`to_json`),
gelesen von scl_skeleton (:meth:`from_json`). Weil das Modell beide Seiten
definiert, faellt ein umbenannter oder falscher Schluessel beim Einlesen
als Fehler auf.
"""
model_config = _CONFIG
schema_id: str = Field(default=SCHEMA, alias="schema")
source: str
created: str = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M"))
note: str = ""
tros: list[DerivedTro] = Field(default_factory=list)
# Systemgrenzen (SYSn): Materialein-/ausgang, kein TRO - keine SCL
boundaries: list[DerivedTro] = Field(default_factory=list)
connections: list[Connection] = Field(default_factory=list)
open_points: list[OpenPoint] = Field(default_factory=list, alias="openPoints")
findings: list[str] = Field(default_factory=list)
@classmethod
def from_analysis(cls, analysis: Analysis, source: str, note: str = "") -> TroDocument:
"""Dokument aus dem Ergebnis von tro_flow.analyse_tros bilden."""
return cls(
source=source,
note=note,
tros=analysis.tros,
boundaries=analysis.boundaries,
connections=[
Connection(src=src, dst=dst, kind=analysis.edge_kinds.get((src, dst), ""))
for src, dst in analysis.edges
],
findings=analysis.findings,
)
@classmethod
def from_json(cls, text: str) -> TroDocument:
"""JSON-Text einlesen und pruefen (pydantic.ValidationError bei Fehlern)."""
return cls.model_validate_json(text)
def to_json(self) -> str:
"""Als JSON-Text mit den Schluesseln der TRO-JSON (id, type, fbType ...)."""
return self.model_dump_json(by_alias=True, indent=2) + "\n"
def by_id(self, tro_id: str) -> DerivedTro | None:
for tro in self.tros + self.boundaries:
if tro.tro_id == tro_id:
return tro
return None
+106 -75
View File
@@ -7,11 +7,14 @@ scl_skeleton.py - FB_Main-Geruest als SCL aus der TRO-Liste erzeugen.
Letzter Schritt der Kette:
material_flow.py Objekte -> Materialfluss
tro_flow.py Fluss -> TROs
tro_annotate.py TROs -> Marker in der Zeichnung
tro_extract.py Zeichnung -> JSON
tro_flow.py Fluss -> TROs -> <name>_tro.json (--json)
scl_skeleton.py JSON -> FB_Main.scl <- hier
Die TRO-JSON wird ueber das gemeinsame Modell lib/model.py (TroDocument)
eingelesen und dabei geprueft: ein falscher oder umbenannter Schluessel bricht
mit einer Fehlermeldung ab, statt still einen Standardwert zu nehmen. Bauteile
(items) und FB-Typ kommen aus tro_catalog, ueber den TRO-Typ.
Erzeugt wird ein **Geruest**, kein fertiges Programm: je TRO eine REGION mit
Instanzaufruf und allen Parameterzeilen in der richtigen Reihenfolge, so wie es
die realen Aufrufstellen in doc/TRO_Katalog/scl_templates/*.scl zeigen.
@@ -41,7 +44,11 @@ from collections import deque
from datetime import datetime
from pathlib import Path
from pydantic import ValidationError
from material_flow import env_dir
from model import SCHEMA, TroBase, TroDocument
from tro_catalog import TroItem
TODO = "TODO(E-Planung)"
@@ -55,11 +62,12 @@ DEFAULT_SEP_TIMES = (
)
def load_tros(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def load_document(path: Path) -> TroDocument:
"""TRO-JSON einlesen und gegen das Modell pruefen."""
return TroDocument.from_json(path.read_text(encoding="utf-8"))
def flow_order(tros: list[dict], start_id: str = "") -> tuple[list[dict], str]:
def flow_order(tros: list[TroBase], start_id: str = "") -> tuple[list[TroBase], str]:
"""TROs in Fliessrichtung sortieren.
Startpunkt ist ein TRO ohne Vorgaenger - also die Stelle, an der Material
@@ -70,14 +78,14 @@ def flow_order(tros: list[dict], start_id: str = "") -> tuple[list[dict], str]:
--start ueberschreiben). Nicht erreichte TROs haengt die Funktion hinten
an, damit nie einer verlorengeht.
"""
nach_id = {t["id"]: t for t in tros}
ohne_vorgaenger = sorted(t["id"] for t in tros if not t.get("predecessors"))
nach_id = {t.tro_id: t for t in tros}
ohne_vorgaenger = sorted(t.tro_id for t in tros if not t.predecessors)
# Materialeingang ueber eine Station (SYSn von tro_flow.py): der TRO, den
# sie speist, ist der natuerliche Anfang - auch wenn er zusaetzlich einen
# TRO-Vorgaenger hat (Kreisel-Umlauf).
eingaenge = sorted({
t["id"] for t in tros
for vor in t.get("predecessors", []) if vor not in nach_id
t.tro_id for t in tros
for vor in t.predecessors if vor not in nach_id
})
if start_id and start_id in nach_id:
@@ -86,7 +94,7 @@ def flow_order(tros: list[dict], start_id: str = "") -> tuple[list[dict], str]:
start, grund = ohne_vorgaenger, "TRO ohne Vorgaenger (Materialeingang)"
elif eingaenge:
stationen = sorted({
vor for i in eingaenge for vor in nach_id[i]["predecessors"]
vor for i in eingaenge for vor in nach_id[i].predecessors
if vor not in nach_id
})
start = eingaenge
@@ -103,7 +111,7 @@ def flow_order(tros: list[dict], start_id: str = "") -> tuple[list[dict], str]:
if tro_id in gesehen:
continue
gesehen.append(tro_id)
for nxt in nach_id.get(tro_id, {}).get("successors", []):
for nxt in nach_id[tro_id].successors if tro_id in nach_id else []:
# SYSn (Materialausgang) ist kein TRO - keine REGION, keine Instanz.
if nxt in nach_id and nxt not in gesehen:
queue.append(nxt)
@@ -113,7 +121,7 @@ def flow_order(tros: list[dict], start_id: str = "") -> tuple[list[dict], str]:
return [nach_id[i] for i in gesehen], grund
def reachable_via(start: str, first: str, tros: dict[str, dict]) -> list[str]:
def reachable_via(start: str, first: str, tros: dict[str, TroBase]) -> list[str]:
"""Welche TROs sind ueber diesen Abgang erreichbar?
Das ist die Zielliste eines Weichenabgangs: von `first` aus weiterlaufen,
@@ -127,25 +135,26 @@ def reachable_via(start: str, first: str, tros: dict[str, dict]) -> list[str]:
if node in gesehen or node == start:
continue
gesehen.add(node)
queue.extend(tros.get(node, {}).get("successors", []))
if node in tros:
queue.extend(tros[node].successors)
return sorted(gesehen)
def nachbar(node: str, alle: dict[str, dict]) -> str:
def nachbar(node: str, alle: dict[str, TroBase]) -> str:
"""Nachbar fuer Kommentare; SYSn als Station kenntlich machen."""
return node if node in alle else f"{node} (Station, kein TRO)"
def region_title(tro: dict) -> str:
seps = ", ".join(tro.get("separators", []))
def region_title(tro: TroBase) -> str:
seps = ", ".join(tro.separators)
teile = f" Sep {seps}" if seps else ""
return f"{tro['id']} ({tro['type']}){teile}"
return f"{tro.tro_id} ({tro.type_name}){teile}"
def emit_separators(add, tro: dict, anzahl: int, hat_scanner: bool) -> None:
seps = tro.get("separators", [])
def emit_separators(add, tro: TroBase, anzahl: int, hat_scanner: bool) -> None:
seps = tro.separators
for i in range(1, anzahl + 1):
pfad = f"#{tro['id']}.stInSeparator{i}"
pfad = f"#{tro.tro_id}.stInSeparator{i}"
layout = seps[i - 1] if i <= len(seps) else "?"
add(f" // Separator {i} = Bauteil {layout} aus dem Layout")
add(f' {pfad}.Settings.nSeparatorNo := "cSep_____"; // {TODO}')
@@ -162,10 +171,10 @@ def emit_separators(add, tro: dict, anzahl: int, hat_scanner: bool) -> None:
add("")
def emit_switches(add, tro: dict, anzahl: int, alle: dict[str, dict]) -> None:
abgaenge = tro.get("successors", [])
def emit_switches(add, tro: TroBase, anzahl: int, alle: dict[str, TroBase]) -> None:
abgaenge = tro.successors
for j in range(1, anzahl + 1):
pfad = f"#{tro['id']}.stInSwitch{j}"
pfad = f"#{tro.tro_id}.stInSwitch{j}"
add(f' {pfad}.Settings.nSwitchNo := "cSwi_____"; // {TODO}')
add(f" {pfad}.Settings.xPluggedExSen3 := FALSE;")
add(f" {pfad}.Settings.xPluggedExSen4 := FALSE;")
@@ -185,13 +194,13 @@ def emit_switches(add, tro: dict, anzahl: int, alle: dict[str, dict]) -> None:
add(" // Zielliste je Abgang (aus dem TRO-Graphen berechnet) -")
add(" // Grundlage fuer die Zuordnung Ziel -> Richtung in FC_Direction:")
for abgang in abgaenge:
ziele = reachable_via(tro["id"], abgang, alle)
ziele = reachable_via(tro.tro_id, abgang, alle)
add(f" // ueber {abgang}: {', '.join(ziele) if ziele else '-'}")
add("")
def emit_vario(add, tro: dict) -> None:
pfad = f"#{tro['id']}.stInVario"
def emit_vario(add, tro: TroBase) -> None:
pfad = f"#{tro.tro_id}.stInVario"
add(f" {pfad}.Settings.xContinousEmptying := FALSE;")
add(f" {pfad}.Settings.tWaitForJog := T#300ms; // {TODO}: pruefen")
add(f" {pfad}.Settings.tConvEmpty := T#45000ms; // {TODO}: pruefen")
@@ -208,10 +217,10 @@ def emit_vario(add, tro: dict) -> None:
add("")
def emit_barcode(add, tro: dict, anzahl: int) -> None:
def emit_barcode(add, tro: TroBase, anzahl: int) -> None:
for i in range(1, anzahl + 1):
suffix = "" if anzahl == 1 else str(i)
pfad = f"#{tro['id']}.stInBarcodeReader{suffix}"
pfad = f"#{tro.tro_id}.stInBarcodeReader{suffix}"
add(f" {pfad}.Settings.xActivateCheckASCI := TRUE;")
add(f" {pfad}.Settings.tTimeout := T#3000ms;")
add(f' // {TODO}: Cognex-Interface verdrahten '
@@ -219,21 +228,21 @@ def emit_barcode(add, tro: dict, anzahl: int) -> None:
add("")
def emit_call(add, tro: dict, items: dict[str, int], alle: dict[str, dict]) -> None:
def emit_call(add, tro: TroBase, items: dict[str, int], alle: dict[str, TroBase]) -> None:
"""Aufrufstelle schreiben.
Die Zeilen werden als (Code, Kommentar) gefuehrt und das Komma erst beim
Zusammensetzen gesetzt - sonst landet es hinter dem "//" und ist Teil des
Kommentars, womit die Parameterliste nicht mehr uebersetzt.
"""
tro_id = tro["id"]
tro_id = tro.tro_id
zeilen: list[tuple[str, str]] = [
('nInMainTroNo := "cMainTro____"', TODO),
('xInSftyOk := "DB_InterfaceSafety".stFromSafety.EStopMain.Q', TODO),
("xInAllRdyToStart := TRUE", ""),
(f'xInRelease := "DB_TRO_TEST".{tro_id}', ""),
]
if items.get("VarioDrive"):
if items.get(TroItem.VARIO_DRIVE):
zeilen.append(('xInMotorProtection := "FC____"', TODO))
zeilen.append(("xInAutomatic := #t_automatik", ""))
zeilen.append(("stInOutMachineState := #stInOutControlUnitCabinet", ""))
@@ -242,20 +251,20 @@ def emit_call(add, tro: dict, items: dict[str, int], alle: dict[str, dict]) -> N
# Staubereiche: je ankommender/abgehender Verbindung einer. Welche
# DB-Struktur dahintersteht, ist eine Planungsentscheidung - hier steht
# nur, WIE VIELE es sein muessen und zu welchem Nachbarn sie gehoeren.
for n, vor in enumerate(tro.get("predecessors", []), start=1):
for n, vor in enumerate(tro.predecessors, start=1):
zeilen.append((f'arInOutJamEntr{n} := "DB_JamArea____".stJam____.arCarrier',
f"{TODO} - Staubereich von {nachbar(vor, alle)}"))
zeilen.append((f'stInOutJamEntr{n} := "DB_JamArea____".stJam____.stData', ""))
for n, nach in enumerate(tro.get("successors", []), start=2):
for n, nach in enumerate(tro.successors, start=2):
zeilen.append((f'arInOutJamExit{n} := "DB_JamArea____".stJam____.arCarrier',
f"{TODO} - Staubereich nach {nachbar(nach, alle)}"))
zeilen.append((f'stInOutJamExit{n} := "DB_JamArea____".stJam____.stData', ""))
for i in range(1, items.get("Separator", 1) + 1):
for i in range(1, items.get(TroItem.SEPARATOR, 1) + 1):
zeilen.append((f'xOutStopper{i} => "MB____"', TODO))
if items.get("VarioDrive"):
if items.get(TroItem.VARIO_DRIVE):
zeilen.append(('xOutVarioMotor => "MA____"', TODO))
for j in range(1, items.get("Switch", 0) + 1):
for j in range(1, items.get(TroItem.SWITCH, 0) + 1):
zeilen.append((f'xOutSw{j}ExTo3 => "MB____"', TODO))
zeilen.append((f'stInOutHMI := "DB_Interface_HMI".stTRO.{tro_id}', ""))
zeilen.append(('nOutStateLast => "DB_GLOB_TroState".arTroState["cMainTro____"]',
@@ -270,13 +279,13 @@ def emit_call(add, tro: dict, items: dict[str, int], alle: dict[str, dict]) -> N
add(f"{text} // {kommentar}" if kommentar else text)
def emit_direction(add, tro: dict, anzahl: int) -> None:
def emit_direction(add, tro: TroBase, anzahl: int) -> None:
"""Weichenlogik - je Weiche ein FC_Direction-Aufruf."""
add("")
add(" REGION Direction")
for j in range(1, anzahl + 1):
ziel = (f"#{tro['id']}.nInDirection" if anzahl == 1
else f"#{tro['id']}.nInDirection{j}")
ziel = (f"#{tro.tro_id}.nInDirection" if anzahl == 1
else f"#{tro.tro_id}.nInDirection{j}")
add(f' "FC_Direction"(nInSwitchNo := "cSwi_____", // {TODO}')
add(f" nOutDirection => {ziel},")
add(f' InOutCarrier := "DB_JamArea____".stJam____'
@@ -286,9 +295,9 @@ def emit_direction(add, tro: dict, anzahl: int) -> None:
add(" END_REGION")
def render(daten: dict, quelle: str, start_id: str = "") -> str:
tros = daten["tros"]
alle = {t["id"]: t for t in tros}
def render(document: TroDocument, quelle: str, start_id: str = "") -> str:
tros = document.tros
alle = {t.tro_id: t for t in tros}
geordnet, startgrund = flow_order(tros, start_id)
out: list[str] = []
@@ -310,7 +319,7 @@ def render(daten: dict, quelle: str, start_id: str = "") -> str:
add("// (DB_JamArea...) und die Zeiten, soweit als 'pruefen' vermerkt.")
add("//")
add("// Reihenfolge der REGIONs = Materialfluss.")
add(f"// Anfang bei {geordnet[0]['id']}: {startgrund}.")
add(f"// Anfang bei {geordnet[0].tro_id}: {startgrund}.")
add("// ============================================================")
add("")
add('FUNCTION_BLOCK "FB_Main"')
@@ -318,45 +327,44 @@ def render(daten: dict, quelle: str, start_id: str = "") -> str:
add("VERSION : 0.1")
add(" VAR ")
for tro in geordnet:
fb = tro.get("fbType") or "FB_UNBEKANNT"
kommentar = f" // {tro['type']}"
if tro.get("separators"):
kommentar += f", Sep {', '.join(tro['separators'])}"
add(f' {tro["id"]} : "{fb}";{kommentar}')
fb = tro.fb_block or "FB_UNBEKANNT"
kommentar = f" // {tro.type_name}"
if tro.separators:
kommentar += f", Sep {', '.join(tro.separators)}"
add(f' {tro.tro_id} : "{fb}";{kommentar}')
add(" END_VAR")
add("")
add("BEGIN")
for tro in geordnet:
items = tro.get("items", {})
n_sep = items.get("Separator", 1)
n_swi = items.get("Switch", 0)
n_scan = items.get("Scanner", 0)
items = tro.items
n_sep = items.get(TroItem.SEPARATOR, 1)
n_swi = items.get(TroItem.SWITCH, 0)
n_scan = items.get(TroItem.SCANNER, 0)
add("")
add(f"REGION {region_title(tro)}")
pos = tro.get("position")
if pos:
add(f" // Anlagenkoordinate x/y: {pos['x']:.0f} / {pos['y']:.0f} mm")
add(f" // Vorgaenger: {', '.join(tro.get('predecessors', [])) or '-'}")
add(f" // Nachfolger: {', '.join(tro.get('successors', [])) or '-'}")
if tro.get("confidence"):
add(f" // Einstufung: {tro['confidence']}")
if tro.position:
add(f" // Anlagenkoordinate x/y: {tro.x:.0f} / {tro.y:.0f} mm")
add(f" // Vorgaenger: {', '.join(tro.predecessors) or '-'}")
add(f" // Nachfolger: {', '.join(tro.successors) or '-'}")
if tro.confidence:
add(f" // Einstufung: {tro.confidence}")
add("")
emit_separators(add, tro, n_sep, hat_scanner=bool(n_scan))
if n_swi:
emit_switches(add, tro, n_swi, alle)
if items.get("VarioDrive"):
if items.get(TroItem.VARIO_DRIVE):
emit_vario(add, tro)
if n_scan:
emit_barcode(add, tro, n_scan)
if items.get("StorageLine"):
add(f" // {TODO}: Linienspeicher mit {items['StorageLine']} Linien - "
if items.get(TroItem.STORAGE_LINE):
add(f" // {TODO}: Linienspeicher mit {items[TroItem.STORAGE_LINE]} Linien - "
f"Parametrierung je Linie")
add("")
add(" #%s.stInPriorityManager.xReleaseOk := TRUE;" % tro["id"])
add(" #%s.stInPriorityManager.xReleaseOk := TRUE;" % tro.tro_id)
add("")
emit_call(add, tro, items, alle)
if n_swi:
@@ -378,32 +386,47 @@ def main(argv: list[str] | None = None) -> int:
json_file = results / args.json
if not json_file.is_file():
print(f"FEHLER: JSON nicht gefunden: {json_file}", file=sys.stderr)
print(" Zuerst bin/tro_extract.bat laufen lassen.", file=sys.stderr)
print(" Zuerst bin/tro_flow.bat --file <name>.csv --json laufen lassen.",
file=sys.stderr)
return 1
try:
daten = load_tros(json_file)
except (OSError, ValueError) as exc:
document = load_document(json_file)
except OSError as exc:
print(f"FEHLER: {json_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
if not daten.get("tros"):
except ValidationError as exc:
schema = _schema_of(json_file)
if schema and schema != SCHEMA:
# Anderes Format (z. B. die alte tro_extract-JSON) - die einzelnen
# Feldfehler helfen dann nicht weiter.
print(f"FEHLER: {json_file} hat das Schema '{schema}', erwartet wird "
f"'{SCHEMA}' ({exc.error_count()} Abweichungen).", file=sys.stderr)
print(" Mit bin/tro_flow.bat --file <name>.csv --json neu erzeugen.",
file=sys.stderr)
return 1
print(f"FEHLER: {json_file} passt nicht zum TRO-Modell (lib/model.py):",
file=sys.stderr)
print(exc, file=sys.stderr)
return 1
if not document.tros:
print(f"FEHLER: {json_file} enthaelt keine TROs.", file=sys.stderr)
return 1
text = render(daten, json_file.name, args.start)
text = render(document, json_file.name, args.start)
out_file = results / (args.out or f"{json_file.stem.replace('_tro', '')}_FB_Main.scl")
out_file.write_text(text, encoding="utf-8")
offen = text.count(TODO)
geordnet, startgrund = flow_order(daten["tros"], args.start)
geordnet, startgrund = flow_order(document.tros, args.start)
print("")
print("================================================================")
print("FB_MAIN-GERUEST")
print("================================================================")
print(f"Quelle = {json_file}")
print(f"TROs = {len(daten['tros'])}")
print(f"Reihenfolge = {' -> '.join(t['id'] for t in geordnet)}")
print(f"Anfang = {geordnet[0]['id']} ({startgrund})")
print(f"TROs = {len(document.tros)}")
print(f"Reihenfolge = {' -> '.join(t.tro_id for t in geordnet)}")
print(f"Anfang = {geordnet[0].tro_id} ({startgrund})")
print(f"Offene Stellen = {offen} (Marker '{TODO}')")
print(f"SCL = {out_file}")
print("================================================================")
@@ -411,10 +434,18 @@ def main(argv: list[str] | None = None) -> int:
return 0
def _schema_of(path: Path) -> str:
"""Schema-Kennung einer JSON-Datei, nur fuer die Fehlermeldung ("" wenn unlesbar)."""
try:
return str(json.loads(path.read_text(encoding="utf-8")).get("schema", ""))
except (OSError, ValueError, AttributeError):
return ""
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="scl_skeleton",
description="Erzeugt aus der extrahierten TRO-Liste ein FB_Main-Geruest "
description="Erzeugt aus der TRO-JSON (tro_flow --json) ein FB_Main-Geruest "
"als SCL zum Import in TIA Portal.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n"
@@ -429,7 +460,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"--json",
default="export_tro.json",
metavar="NAME",
help="TRO-JSON aus tro_extract.py in %%SKEL_RESULTS%% "
help="TRO-JSON aus tro_flow.py --json in %%SKEL_RESULTS%% "
"(oder ein vollstaendiger Pfad). Standard: %(default)s",
)
parser.add_argument(
+16 -4
View File
@@ -12,6 +12,12 @@ TROs und schreibt eine **Kopie** der Zeichnung mit zusaetzlicher Beschriftung:
* optional Pfeile fuer den gerichteten Materialfluss zwischen den TROs
* optional eine Legende
Die Zeichnung ist eine **Ansicht** zur Sichtkontrolle, keine Datenquelle: die
TRO-JSON fuer scl_skeleton.py schreibt tro_flow.py --json direkt aus dem CSV.
tro_annotate leitet die TROs mit genau demselben Aufruf ab wie tro_flow, damit
Zeichnung und JSON uebereinstimmen. (tro_extract.py, der Rueckweg Zeichnung ->
JSON, ist geparkt.)
Die Originalzeichnung wird nie veraendert. Alle Ergaenzungen liegen auf eigenen
Layern (Vorgabe: Praefix "TRO_") und tragen XDATA der Anwendungskennung
SPS_SKEL_TRO. Ein erneuter Lauf entfernt genau diese Objekte und baut sie neu -
@@ -101,13 +107,15 @@ from dxf_registration import (
)
from material_flow import build_graph, env_dir, read_elements, resolve_input
from tro_catalog import STYLES, TRO_CATALOG, TroSymbol, get_tro
from model import Analysis
from tro_flow import (
BOUNDARY_ENTRY,
BOUNDARY_EXIT,
STATION_FARBE,
Analysis,
_station_positionen,
analyse_tros,
find_sibling_raw_json,
load_sepliste_counts,
)
# ---------------------------------------------------------------------------
@@ -898,9 +906,13 @@ def main(argv: list[str] | None = None) -> int:
print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
graph = build_graph(elements, csv_file.name, read_warnings)
# Ohne cfg/tro_overrides.ini - wie tro_flow, damit Marker und
# Flussdiagramm zusammenpassen.
analysis = analyse_tros(graph, elements)
# Derselbe Aufruf wie in tro_flow.main (ohne cfg/tro_overrides.ini, mit
# der Separator-Gegenprobe aus <name>_raw.json), damit die Zeichnung genau
# die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine Datenquelle.
raw_json_file = find_sibling_raw_json(csv_file)
sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings)
if raw_json_file is not None else {})
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts)
if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
return 1
+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)