Stabile TRO-IDs: ID-Datei je Anlage, Wiederfinden ueber die Lage

- lib/tro_ids.py: cfg/<csv>_tro_ids.ini lesen/schreiben, TROs ueber die
  Lage ihrer Separatoren wiederfinden, neue IDs vergeben, Weggefallene melden
- tro_flow: Nummerierung entlang des Materialflusses, Schalter --ids,
  --renumber, --id-tolerance; Bericht der IDs
- tro_annotate: liest dieselbe ID-Datei (nur lesend)
- Tests tests/test_tro_ids.py, Doku Python_Scripts.md, Konzept (Stufe 1 erledigt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-10-11 20:31:18 +02:00
co-authored by Claude Opus 5.5
parent ef57a2218e
commit dd2d98f2ee
7 changed files with 712 additions and 29 deletions
+186 -8
View File
@@ -90,6 +90,14 @@ Ausgabe (alles in SKEL_RESULTS)
fuer scl_skeleton.py
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
TRO-IDs (ID-Datei in SKEL_CFG)
------------------------------
Die IDs kommen aus %SKEL_CFG%/<name>_tro_ids.ini (lib/tro_ids.py): jeder TRO
wird ueber die Lage seiner Separatoren wiedergefunden und behaelt seine ID,
auch wenn die SPS-Programmierer sie umbenannt haben. Fehlt die Datei, werden
die TROs entlang des Materialflusses durchnummeriert und die Datei angelegt;
neue TROs werden angehaengt. --renumber nummeriert alles neu.
Aufruf ueber bin/tro_flow.bat bzw. bin/tro_flow.sh.
"""
@@ -126,6 +134,16 @@ from material_flow import (
resolve_input,
)
from model import Analysis, DerivedTro, TroDocument
from tro_ids import (
DEFAULT_TOLERANCE,
TroIds,
assign_ids,
ids_path,
insert_entries,
other_sections,
render_ids,
render_new_entries,
)
from tro_catalog import (
CATALOG_AS_OF,
ITEM_FB,
@@ -655,6 +673,7 @@ def derive_tros(
findings: list[str],
*,
sepliste_counts: dict[str, int] | None = None,
tro_ids: TroIds | None = None,
) -> list[DerivedTro]:
"""TRO-Liste aus den Separatoren und ihren angeschlossenen Objekten bilden.
@@ -666,6 +685,10 @@ def derive_tros(
der CSV abgeleitete Separatorenzahl eines Strecke/Gefaellestrecke-Hosts
nicht mit dieser Zahl ueberein, wird ein Finding angehaengt; die TRO-Daten
selbst bleiben unangetastet (CSV bleibt die massgebliche Quelle).
tro_ids (optional): ID-Datei der Anlage. Ohne sie werden die TROs entlang
des Materialflusses neu durchnummeriert; mit ihr behalten wiedergefundene
TROs ihre ID (Ergebnis in tro_ids.result).
"""
def resolve_host(sep: Element, findings: list[str]) -> str | None:
return _default_resolve_host(sep, findings, graph)
@@ -1156,11 +1179,16 @@ def derive_tros(
tro.reason = f"{tro.reason}; {len(exits)} abgehende Wege -> Weiche"
tro.confidence = "niedrig"
# 5) Stabile, laufende Nummern in Reihenfolge der Host-Knoten
tros.sort(key=lambda t: (t.hosts[0], t.separators[0] if t.separators else ""))
width = max(2, len(str(len(tros))))
for index, tro in enumerate(tros, start=1):
tro.tro_id = f"TRO{index:0{width}d}"
# 5) IDs: Reihenfolge entlang des Materialflusses, wiedergefundene TROs
# behalten ihre ID aus der ID-Datei (siehe lib/tro_ids.py)
tros.sort(key=_flow_key(graph, elements_by_id))
anchors = separator_anchors(elements)
if tro_ids is None:
assign_ids(tros, anchors, [], renumber=True)
else:
tro_ids.result = assign_ids(
tros, anchors, tro_ids.entries,
tolerance=tro_ids.tolerance, renumber=tro_ids.renumber)
# 6) Scanner am Separator: 1Sep_SSCC laesst sich mechanisch nicht erkennen
for tro in tros:
@@ -1174,6 +1202,56 @@ def derive_tros(
return tros
def separator_anchors(elements: list[Element]) -> dict[str, tuple[float, float]]:
"""Separator-TeileId -> (x, y) in mm, Bezugslage fuer die ID-Datei."""
return {e.teile_id: (e.position[0], e.position[1])
for e in elements if e.kind == "Separator" and e.position}
def _flow_rank(graph: Graph) -> dict[str, int]:
"""Knoten -> Rang entlang des Materialflusses (Tiefensuche).
Start an den Materialeingaengen (Beladung/Eingang), danach an jedem noch
nicht erreichten Knoten - eine geschlossene Anlage ohne Station wird so
trotzdem vollstaendig erfasst. Gleichrangige Nachfolger nach Knotennamen.
"""
succ: dict[str, list[str]] = {}
for edge in graph.edges:
succ.setdefault(edge.src, []).append(edge.dst)
starts = sorted(n for n, node in graph.nodes.items() if node.element.is_entry)
rank: dict[str, int] = {}
for start in starts + sorted(graph.nodes):
stack = [start]
while stack:
node = stack.pop()
if node in rank:
continue
rank[node] = len(rank)
stack.extend(n for n in sorted(succ.get(node, []), reverse=True)
if n not in rank)
return rank
def _flow_key(graph: Graph, elements_by_id: dict[str, Element]):
"""Sortierschluessel fuer TROs entlang des Materialflusses.
Erst der Rang des Host-Knotens, dann die Lage des Separators in der
TrackIds-Kette des Hosts (Kreisel: Umlaufrichtung, Strecke: Baureihenfolge).
"""
rank = _flow_rank(graph)
def key(tro: DerivedTro) -> tuple:
host_rank = min((rank.get(h, len(rank)) for h in tro.hosts),
default=len(rank))
sep = tro.separators[0] if tro.separators else ""
host = elements_by_id.get(tro.hosts[0]) if tro.hosts else None
track = host.track_ids if host else []
in_track = track.index(sep) if sep in track else len(track)
return (host_rank, in_track, sep)
return key
# Typnamen fuer Systemgrenz-Knoten (siehe derive_boundaries) - bewusst NICHT
# in TRO_CATALOG: eine Be-/Entladestation (BTMT oder generisches Eingang/
# Ausgang-Symbol) ist kein Steuerobjekt mit eigenem FB-Baustein, nur der
@@ -1781,11 +1859,15 @@ def analyse_tros(
elements: list[Element],
*,
sepliste_counts: dict[str, int] | None = None,
tro_ids: TroIds | None = None,
) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen."""
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu nummerieren.
"""
analysis = Analysis()
analysis.tros = derive_tros(graph, elements, analysis.findings,
sepliste_counts=sepliste_counts)
sepliste_counts=sepliste_counts, tro_ids=tro_ids)
analysis.boundaries = derive_boundaries(elements, graph)
# Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen:
# link_tros() registriert eine Kante nur zu einem Knoten, der Host eines
@@ -2638,6 +2720,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" 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"
" tro_flow.bat --file mubea_export.csv --ids mubea_tro_ids.ini --json\n"
" tro_flow.bat --file mubea.csv --renumber\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
@@ -2694,9 +2778,93 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
f"Leerstrecken ueber {COORD_MAX_LUECKE} mm zwischen den Knoten "
"(Reihenfolge und Nachbarschaft bleiben erhalten).",
)
parser.add_argument(
"--ids",
metavar="DATEI",
help="ID-Datei der Anlage (Name in %%SKEL_CFG%% oder vollstaendiger "
"Pfad). Standard: <name>_tro_ids.ini in %%SKEL_CFG%%. Fehlt sie, "
"wird sie angelegt.",
)
parser.add_argument(
"--renumber",
action="store_true",
help="Alle TRO-IDs neu entlang des Materialflusses vergeben und die "
"Abschnitt [tro] der ID-Datei neu schreiben (fruehe Projektphase - "
"umbenannte IDs gehen verloren, andere Abschnitte bleiben).",
)
parser.add_argument(
"--id-tolerance",
dest="id_tolerance",
type=float,
default=DEFAULT_TOLERANCE,
metavar="MM",
help="Hoechster Abstand (mm) zwischen Eintrag der ID-Datei und "
"Separator, bis zu dem ein TRO als wiedergefunden gilt. "
"Standard: %(default)s",
)
return parser.parse_args(argv)
def resolve_ids_file(arg: str | None, csv_file: Path) -> Path:
"""Pfad der ID-Datei: --ids (Name in SKEL_CFG oder Pfad), sonst Standard."""
cfg_dir = env_dir("SKEL_CFG", "cfg")
if not arg:
return ids_path(csv_file.stem, cfg_dir)
path = Path(arg)
if path.is_absolute() or path.parent != Path("."):
return path
return cfg_dir / path
def write_ids_file(tro_ids: TroIds, analysis: Analysis, elements: list[Element],
source: str) -> str:
"""ID-Datei anlegen, neu schreiben oder neue TROs anhaengen.
Rueckgabe: kurze Beschreibung fuer den Bericht.
"""
result = tro_ids.result
if result is None:
return "unveraendert"
anchors = separator_anchors(elements)
if result.renumbered:
tro_ids.path.parent.mkdir(parents=True, exist_ok=True)
rest = other_sections(tro_ids.path)
text = render_ids(analysis.tros, anchors, source)
tro_ids.path.write_text(text + (f"\n{rest}\n" if rest else ""),
encoding="utf-8")
return "neu geschrieben" if tro_ids.exists else "angelegt"
if result.new:
insert_entries(tro_ids.path,
render_new_entries(analysis.tros, anchors, result.new, source))
return f"{len(result.new)} neue(r) TRO(s) angehaengt"
return "unveraendert"
def report_ids(tro_ids: TroIds, status: str) -> None:
"""Abschnitt TRO-IDs im Konsolenbericht."""
result = tro_ids.result
if result is None:
return
print(f"TRO-IDs = {tro_ids.path} ({status})")
if result.renumbered:
grund = "--renumber" if tro_ids.renumber else "keine ID-Datei"
print(f" neu entlang des Materialflusses nummeriert "
f"({grund})")
return
print(f" wiedergefunden: {len(result.kept)}, "
f"neu: {len(result.new)}, weggefallen: {len(result.missing)} "
f"(Toleranz {tro_ids.tolerance:g} mm)")
if result.new:
print(f" + neu (vorlaeufige ID, in der ID-Datei umbenennbar): "
f"{', '.join(result.new)}")
for entry in result.missing:
merged = result.merged_into.get(entry.tro_id)
grund = (f"Separator gehoert jetzt zu {merged}" if merged else
f"kein Separator innerhalb {tro_ids.tolerance:g} mm")
print(f" - weggefallen: {entry.tro_id} bei {entry.x:.1f}, {entry.y:.1f} "
f"({grund}; Eintrag bleibt stehen, ID wird nicht wiederverwendet)")
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
@@ -2739,11 +2907,20 @@ def main(argv: list[str] | None = None) -> int:
connect_target = results / f"{stem}_tro_connect.ini"
json_target = results / f"{stem}_tro.json"
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts)
try:
tro_ids = TroIds.load(resolve_ids_file(args.ids, csv_file),
tolerance=args.id_tolerance, renumber=args.renumber)
except (OSError, ValueError) as exc:
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return 1
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids)
if not analysis.tros:
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
f"ableitbar.", file=sys.stderr)
return 1
ids_status = write_ids_file(tro_ids, analysis, elements, csv_file.name)
dot_file = results / f"{stem}_tro_flow.dot"
svg_file = results / f"{stem}_tro_flow.svg"
@@ -2806,6 +2983,7 @@ def main(argv: list[str] | None = None) -> int:
report(analysis, csv_file, dot_file, created_svg, error_file, doc_file,
warnings, engine=engine)
report_ids(tro_ids, ids_status)
if connect_file:
print(f"connect.ini = {connect_file}")
if json_file: