#!/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. `tro_flow.py` und `tro_annotate.py` lesen sie und wenden sie nach der Ableitung an; die Doku weist jede angewandte Korrektur getrennt aus, damit abgeleitet und behauptet unterscheidbar bleiben. 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 x = 529.95 Anlagenkoordinate in mm y = -3895.27 z = 1468 expect = 1Sep1Swi hier erwarteter TRO-Typ belongs_to = TRO10 TRO, der das Bauteil heute enthaelt note = ... Erlaeuterung Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**. Sie werden nur in der Doku und in der annotierten Zeichnung markiert, damit sie in BricsCAD geprueft werden koennen, bevor daraus ein TRO wird. """ from __future__ import annotations import configparser import os from dataclasses import dataclass, field from pathlib import Path 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. """ key: str by_separator: bool = False type_name: str = "" reason: str = "" confidence: str = "" def matches(self, tro) -> bool: if self.by_separator: return self.key in tro.separators return self.key == 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) host: str = "" label: str = "" reason: str = "" confidence: str = DEFAULT_CONFIDENCE @dataclass class ConnectionSpec: """Eine vor Ort bestaetigte Verbindung zwischen zwei TROs.""" src: str dst: str kind: str = "" @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) host: str = "" label: str = "" reason: str = "" confidence: str = DEFAULT_CONFIDENCE @dataclass class OpenPoint: """Eine im Review gemeldete Stelle, die noch kein eigener TRO ist.""" name: str x: float y: float z: float | None = None expect: str = "" belongs_to: str = "" note: str = "" def describe_position(self) -> str: return f"{self.x:.0f}, {self.y:.0f}" @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[ConnectionSpec] = field(default_factory=list) open_points: list[OpenPoint] = 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.open_points) 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 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( ConnectionSpec(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( key=key[3:] if by_sep else key, by_separator=by_sep, 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 = [s.strip() for s in values.get("separators", "").split(",") if s.strip()] 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=[s.strip() for s in values.get("scanners", "").split(",") if s.strip()], host=values.get("host", "").strip(), 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 = [s.strip() for s in values.get("separators", "").split(",") if s.strip()] 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, host=values.get("host", "").strip(), 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() expect = values.get("expect", "").strip() if expect and get_tro(expect) is None: result.warnings.append( f"[{section}]: erwarteter Typ '{expect}' steht nicht im " f"TRO-Katalog" ) result.open_points.append( OpenPoint( name=name, x=x, y=y, z=_as_float(z_raw, f"[{section}] z", result.warnings) if z_raw else None, expect=expect, belongs_to=values.get("belongs_to", "").strip(), note=values.get("note", "").strip(), ) ) else: result.warnings.append( f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro' oder " f"'openpoint'" ) result.open_points.sort(key=lambda p: p.name) return result def apply_splits(tros, overrides: Overrides, make_tro) -> list: """Neue TROs aus bestehenden herausloesen (vor der Nummernvergabe). `make_tro(spec, hosts, label)` erzeugt das projektspezifische Tro-Objekt - so bleibt dieses Modul frei von einem Import aus tro_flow (das umgekehrt schon von hier importiert). """ for spec in overrides.splits: quellen = [t for t in tros if set(spec.separators) & set(t.separators)] if not quellen: overrides.warnings.append( f"Split '{spec.name}': keiner der Separatoren " f"{', '.join(spec.separators)} kommt in diesem Lauf vor - " f"nicht angelegt" ) continue hosts: list[str] = [] for quelle in quellen: quelle.separators = [s for s in quelle.separators if s not in spec.separators] quelle.scanners = [s for s in quelle.scanners if s not in spec.scanners] hosts.extend(quelle.hosts) if not quelle.separators and quelle.type_name not in ("Vario",): overrides.warnings.append( f"Split '{spec.name}': {quelle.label} hat danach keinen " f"Separator mehr - bitte pruefen" ) ziel_hosts = [spec.host] if spec.host else sorted(set(hosts))[:1] label = spec.label or ( f"Separator {', '.join(spec.separators)} " f"(manuell aus {quellen[0].label} herausgeloest)" ) tros.append(make_tro(spec, ziel_hosts, label)) overrides.applied.append( f"neu {spec.name}: {spec.type_name} auf {', '.join(ziel_hosts)} " f"mit Separator {', '.join(spec.separators)}, herausgeloest aus " f"{', '.join(sorted({q.label for q in quellen}))} " f"({spec.reason or 'manuelle Ergaenzung'})" ) return tros def apply_merges(tros, overrides: Overrides) -> list: """Mehrere TROs zu einem zusammenfassen (nach den Splits, vor der Nummer). Der erste betroffene TRO bleibt bestehen und uebernimmt Bauteile, Hosts und Typ; die uebrigen werden aus der Liste entfernt. """ for spec in overrides.merges: betroffen = [t for t in tros if set(spec.separators) & set(t.separators)] if len(betroffen) < 2: gefunden = ", ".join(t.tro_id or t.label for t in betroffen) or "keiner" overrides.warnings.append( f"Merge '{spec.name}': nur {len(betroffen)} der Separatoren " f"{', '.join(spec.separators)} gefunden ({gefunden}) - " f"nicht zusammengefasst" ) continue ziel = betroffen[0] quellen = betroffen[1:] for quelle in quellen: ziel.separators = sorted(set(ziel.separators) | set(quelle.separators)) ziel.scanners = sorted(set(ziel.scanners) | set(quelle.scanners)) ziel.hosts = sorted(set(ziel.hosts) | set(quelle.hosts)) tros.remove(quelle) if spec.host: # Ein Host bestimmt, wo der zusammengefasste TRO im Fluss haengt; # die uebrigen Bahnen bleiben als weitere Hosts erhalten, damit die # Verknuepfung beide Seiten des Uebergangs sieht. ziel.hosts = [spec.host] + [h for h in ziel.hosts if h != spec.host] ziel.type_name = spec.type_name ziel.confidence = spec.confidence ziel.label = spec.label or ziel.label grund = spec.reason or "manuell zusammengefasst" ziel.reason = f"{ziel.reason}; manuell zusammengefasst ({grund})" overrides.applied.append( f"merge {spec.name}: {len(betroffen)} TROs -> ein {spec.type_name} " f"mit Separatoren {', '.join(ziel.separators)} ({grund})" ) return tros def apply_connections(tros, overrides: Overrides): """Gesetzte Topologie an die Stelle der abgeleiteten Kanten setzen. Ersetzt vollstaendig, nicht ergaenzend: wenn die Verbindungen vor Ort aufgenommen wurden, sind die abgeleiteten damit erledigt - sonst blieben genau die Kanten stehen, die korrigiert werden sollten. Was dabei wegfaellt bzw. hinzukommt, steht anschliessend in overrides.applied. Rueckgabe: (edges, edge_kinds) wie von link_tros. """ bekannt = {t.tro_id for t in tros} edges: list[tuple[str, str]] = [] kinds: dict[tuple[str, str], str] = {} for spec in overrides.connections: for seite, wert in (("von", spec.src), ("nach", spec.dst)): if wert not in bekannt: overrides.warnings.append( f"Verbindung {spec.src} -> {spec.dst}: {seite} '{wert}' ist " f"kein TRO dieses Laufs - Verbindung ignoriert" ) break else: paar = (spec.src, spec.dst) if paar in kinds: continue edges.append(paar) kinds[paar] = spec.kind or "gesetzt" for tro in tros: tro.successors = sorted(d for s, d in edges if s == tro.tro_id) tro.predecessors = sorted(s for s, d in edges if d == tro.tro_id) tro.colocated = [] ohne = [t.tro_id for t in tros if not t.successors and not t.predecessors] if ohne: overrides.warnings.append( "ohne Verbindung in der gesetzten Topologie: " + ", ".join(sorted(ohne)) ) overrides.applied.append( f"Topologie gesetzt: {len(edges)} Verbindungen aus der Override-Datei " f"ersetzen die abgeleiteten" ) return sorted(edges), kinds def apply_overrides(tros, overrides: Overrides) -> None: """Typkorrekturen auf die abgeleitete TRO-Liste anwenden. Wird aufgerufen, nachdem die TRO-IDs vergeben sind (die Korrekturen sind ueber die ID adressiert) und bevor FB-Baustein und Bauteilliste aus dem Katalog gezogen werden - der Typwechsel zieht beides mit. """ if not overrides.tros: return for section_key, override in sorted(overrides.tros.items()): treffer = [t for t in tros if override.matches(t)] if not treffer: wie = "Separator" if override.by_separator else "TRO-ID" overrides.warnings.append( f"[{section_key}]: kein TRO mit {wie} '{override.key}' in diesem " f"Lauf - Korrektur nicht angewandt" + ("" if override.by_separator else " (Nummerierung geaendert? Besser ueber SEP adressieren)") ) continue if len(treffer) > 1: overrides.warnings.append( f"[{section_key}]: trifft {len(treffer)} TROs " f"({', '.join(t.tro_id for t in treffer)}) - auf alle angewandt" ) for tro in treffer: if not override.type_name or override.type_name == tro.type_name: continue vorher = tro.type_name tro.type_name = override.type_name tro.confidence = override.confidence grund = override.reason or "manuelle Korrektur" tro.reason = (f"{tro.reason}; manuell auf {override.type_name} " f"gesetzt ({grund})") overrides.applied.append( f"{tro.tro_id}: {vorher} -> {override.type_name} ({grund})" )