#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ tro_overrides.py - manuelle Korrekturen an der abgeleiteten TRO-Liste. `tro_flow.py` leitet die TROs allein aus dem mechanischen Layout (CSV-Export) ab. Manches steht dort aber nicht drin: eine Weiche, die im Export fehlt, ein Scanner, der den Typ auf `1Sep_SSCC` hebt, oder eine Stelle, die vor Ort als eigener TRO erkannt wurde. Solche Erkenntnisse kommen aus der Begehung bzw. aus dem Review der erzeugten Diagramme. Damit sie den naechsten Lauf ueberleben, stehen sie in einer INI-Datei (`%SKEL_CFG%/tro_overrides.ini`) statt in den erzeugten Dateien. Seit 2026-10-05 nur noch von `material_flow.py --connect` gelesen (Abschnitt `[.manual]` der connect.ini). `tro_flow.py` und `tro_annotate.py` wenden keine Korrekturen mehr an: die Abschnitte hingen an laufenden TRO-Nummern und veralteten mit jedem Export - Fehler der Ableitung werden seitdem im Code behoben (lib/tro_flow.py), nicht in der INI. Aufbau der INI (Sektionsnamen mit dem CSV-Dateinamen als Praefix, damit mehrere Anlagen in einer Datei stehen koennen): [.tro.] Korrektur an einem abgeleiteten TRO type = 1Sep1Swi neuer Katalogtyp reason = ... Begruendung (kommt in die Doku) confidence = bestaetigt Vertrauensangabe (Default: bestaetigt) [.openpoint.] offener Punkt - noch KEIN TRO (model.TroCandidate) x = 529.95 Anlagenkoordinate in mm y = -3895.27 z = 1468 expect = 1Sep1Swi hier erwarteter TRO-Typ (-> type_name) belongs_to = TRO10 TRO, der das Bauteil heute enthaelt note = ... Erlaeuterung Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**. Die Felder heissen wie in lib/model.py (`type_name`, `separators`, `hosts`, `reason`, `confidence`); Verbindungen und offene Punkte sind direkt `model.Connection` bzw. `model.TroCandidate`. """ from __future__ import annotations import configparser import os from dataclasses import dataclass, field from pathlib import Path from model import Connection, TroCandidate from tro_catalog import get_tro OVERRIDE_FILE = "tro_overrides.ini" # Vertrauensangabe fuer manuell gesetzte Typen. Bewusst ein eigener Wert - so # ist in der Doku sichtbar, dass der Typ nicht aus dem Layout stammt. DEFAULT_CONFIDENCE = "bestaetigt" @dataclass class TroOverride: """Korrektur an einem abgeleiteten TRO. Adressiert entweder ueber die TRO-ID (`TRO03`) oder - stabiler - ueber die Nummer eines seiner Separatoren (`SEP0011`). Die TRO-IDs sind laufende Nummern eines Laufs und verschieben sich, sobald TROs dazukommen oder wegfallen; die Separator-Nummer aus dem CSV-Export tut das nicht. """ # genau eins von beiden ist gesetzt tro_id: str = "" separator: str = "" type_name: str = "" reason: str = "" confidence: str = "" def matches(self, tro) -> bool: if self.separator: return self.separator in tro.separators return self.tro_id == tro.tro_id @dataclass class SplitSpec: """Ein neuer TRO, herausgeloest aus einem bereits abgeleiteten. Fuer Uebergabestellen, die die Ableitung nicht als eigenstaendig erkennt, weil die "Zuordnung" des Separators auf ein Transportobjekt zeigt (Strecke, Gefaellestrecke) statt auf die Kreisel-Bahn, an der er tatsaechlich schaltet. Die genannten Separatoren/Scanner werden ihrem bisherigen TRO entnommen und bilden einen neuen. """ name: str type_name: str separators: list[str] = field(default_factory=list) scanners: list[str] = field(default_factory=list) hosts: list[str] = field(default_factory=list) label: str = "" reason: str = "" confidence: str = DEFAULT_CONFIDENCE @dataclass class MergeSpec: """Mehrere abgeleitete TROs zu einem Steuerobjekt zusammenfassen. Fuer Stellen, die mechanisch aus mehreren Bauteilgruppen bestehen, steuerungstechnisch aber eine einzige Uebergabestelle sind - etwa zwei Sperren beiderseits eines Kreisel-Beruehrpunkts. Adressiert wird ueber die Separatoren, weil deren Nummern ueber Exporte hinweg stabiler sind als die laufenden TRO-Nummern. """ name: str type_name: str separators: list[str] = field(default_factory=list) hosts: list[str] = field(default_factory=list) label: str = "" reason: str = "" confidence: str = DEFAULT_CONFIDENCE @dataclass class Overrides: """Alle Korrekturen zu einer CSV-Datei.""" source: Path | None = None tros: dict[str, TroOverride] = field(default_factory=dict) splits: list[SplitSpec] = field(default_factory=list) merges: list[MergeSpec] = field(default_factory=list) # Gesetzte Topologie: ersetzt die abgeleiteten Kanten vollstaendig connections: list[Connection] = field(default_factory=list) candidates: list[TroCandidate] = field(default_factory=list) # Protokoll der tatsaechlich angewandten Korrekturen (fuer die Doku) applied: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) def __bool__(self) -> bool: return bool(self.tros or self.splits or self.merges or self.connections or self.candidates) def override_path(cfg_dir: Path | None = None) -> Path: """Pfad zur Override-Datei: %SKEL_CFG%, sonst /cfg.""" if cfg_dir is not None: return cfg_dir / OVERRIDE_FILE env = os.environ.get("SKEL_CFG") if env: return Path(env) / OVERRIDE_FILE return Path(__file__).resolve().parent.parent / "cfg" / OVERRIDE_FILE def _as_float(raw: str, where: str, warnings: list[str]) -> float | None: try: return float(str(raw).strip()) except (TypeError, ValueError): warnings.append(f"{where}: '{raw}' ist keine Zahl - Eintrag ignoriert") return None def _as_list(raw: str) -> list[str]: return [s.strip() for s in raw.split(",") if s.strip()] def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides: """Korrekturen zu `csv_name` laden (leeres Ergebnis, wenn es keine gibt).""" path = override_path(cfg_dir) result = Overrides() if not path.is_file(): return result result.source = path parser = configparser.ConfigParser(inline_comment_prefixes=(";",)) # Schluessel sind Kleinschreibung, Sektionsnamen bleiben wie geschrieben. parser.read(path, encoding="utf-8") prefix = csv_name.lower() for section in parser.sections(): # [.connections] - gesetzte Topologie, eigene Form ohne if section.lower() == f"{prefix}.connections": for key, raw in parser[section].items(): pfeil, _, rest = str(raw).partition("|") src, sep, dst = (t.strip() for t in pfeil.partition("->")) if not sep or not src or not dst: result.warnings.append( f"[{section}] {key}: '{raw}' ist keine Verbindung " f"' -> '" ) continue result.connections.append( Connection(src=src, dst=dst, kind=rest.strip()) ) continue parts = section.split(".") # .. - der CSV-Name kann selbst einen Punkt enthalten if len(parts) < 3: continue kind, name = parts[-2].lower(), parts[-1] owner = ".".join(parts[:-2]).lower() if owner != prefix: continue values = parser[section] if kind == "tro": type_name = values.get("type", "").strip() if type_name and get_tro(type_name) is None: result.warnings.append( f"[{section}]: Typ '{type_name}' steht nicht im TRO-Katalog " f"- Korrektur ignoriert" ) continue key = name.upper() by_sep = key.startswith("SEP") result.tros[key] = TroOverride( tro_id="" if by_sep else key, separator=key[3:] if by_sep else "", type_name=type_name, reason=values.get("reason", "").strip(), confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE, ) elif kind == "split": type_name = values.get("type", "").strip() if get_tro(type_name) is None: result.warnings.append( f"[{section}]: Typ '{type_name}' steht nicht im TRO-Katalog " f"- neuer TRO nicht angelegt" ) continue seps = _as_list(values.get("separators", "")) if not seps: result.warnings.append( f"[{section}]: 'separators' fehlt - ohne Separator kein TRO" ) continue result.splits.append( SplitSpec( name=name, type_name=type_name, separators=seps, scanners=_as_list(values.get("scanners", "")), hosts=_as_list(values.get("hosts", values.get("host", ""))), label=values.get("label", "").strip(), reason=values.get("reason", "").strip(), confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE, ) ) elif kind == "merge": type_name = values.get("type", "").strip() if get_tro(type_name) is None: result.warnings.append( f"[{section}]: Typ '{type_name}' steht nicht im TRO-Katalog " f"- Zusammenfassung nicht durchgefuehrt" ) continue seps = _as_list(values.get("separators", "")) if len(seps) < 2: result.warnings.append( f"[{section}]: 'separators' braucht mindestens zwei Nummern" ) continue result.merges.append( MergeSpec( name=name, type_name=type_name, separators=seps, hosts=_as_list(values.get("hosts", values.get("host", ""))), label=values.get("label", "").strip(), reason=values.get("reason", "").strip(), confidence=values.get("confidence", "").strip() or DEFAULT_CONFIDENCE, ) ) elif kind == "openpoint": x = _as_float(values.get("x", ""), f"[{section}] x", result.warnings) y = _as_float(values.get("y", ""), f"[{section}] y", result.warnings) if x is None or y is None: continue z_raw = values.get("z", "").strip() z = _as_float(z_raw, f"[{section}] z", result.warnings) if z_raw else None type_name = values.get("expect", "").strip() if type_name and get_tro(type_name) is None: result.warnings.append( f"[{section}]: erwarteter Typ '{type_name}' steht nicht im " f"TRO-Katalog" ) result.candidates.append( TroCandidate( name=name, type_name=type_name, position=(x, y, z if z is not None else 0.0), belongs_to=values.get("belongs_to", "").strip(), note=values.get("note", "").strip(), ) ) else: result.warnings.append( f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro', 'split', " f"'merge' oder 'openpoint'" ) result.candidates.sort(key=lambda p: p.name) return result