#!/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**. Handkorrekturen aus BricsCAD (seit 2026-10-26) ----------------------------------------------- Die Abschnitte oben wendet tro_flow nicht mehr an (siehe unten). Die Arten `edit` und `edit_add` dagegen schon: sie schreibt `tro_annotate --read-tros` aus den im Dialog TRO_EDIT/TRO_INSERT (dxfmakros) geaenderten Markern, und `tro_flow.apply_edits` wendet sie nach der Ableitung an. Sie haengen an der ID aus `_tro_ids.ini`, die ueber die Lage des Separators stabil bleibt - anders als die laufenden Nummern, an denen die alten Abschnitte veraltet sind. [.edit.] Korrektur an einem abgeleiteten TRO type = 1Sep1Swi neuer Katalogtyp (fehlt = wie abgeleitet) name = Einschleus_1 neue Kennung (fehlt = bleibt) delete = true TRO entfaellt separator1 = 0010 @ 1234.5 -567.8 Bauteil je Slot: TeileId @ x y (mm, switch1 = - CSV); "-" = Slot bewusst leer lines = 0066 @ 958 12300, 0069 @ 958 12600 Lagerlinien in Reihenfolge reason = ... Begruendung (bleibt beim Neuschreiben erhalten) [.edit_add.] neuer TRO (TRO_INSERT), Schluessel wie oben [.edit_connections] Verbindungen, im Plan (CONNECTION_*) geaendert c01 = + TRO05 -> TRO07 | transfer dazu (Art optional, Standard transfer) c02 = - TRO05 -> TRO01 weg Enden sind die stabilen Schluessel der TROs Stehen Slots im Abschnitt, gelten sie vollstaendig (fehlende Slots des Typs bleiben leer); stehen keine, bleiben die Bauteile wie abgeleitet. Die Lage hinter der TeileId macht den Eintrag exportfest: passt die TeileId im neuen Export nicht mehr zur Lage, gilt das naechste Bauteil derselben Art dort. 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 import re from dataclasses import dataclass, field from datetime import datetime 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 PartRef: """Bauteil in einem Slot: TeileId plus Lage (mm, CSV) als Rueckfallebene. Die TeileId wechselt zwischen zwei Exporten derselben Anlage, die Lage nicht (wie bei der ID-Datei, lib/tro_ids.py). ``teile_id == ""`` ist ein bewusst leerer Slot. """ teile_id: str = "" x: float | None = None y: float | None = None def render(self) -> str: """'0010 @ 1234.5 -567.8', '0010' ohne Lage, '-' fuer leer.""" if not self.teile_id: return "-" if self.x is None or self.y is None: return self.teile_id return f"{self.teile_id} @ {self.x:.1f} {self.y:.1f}" @classmethod def parse(cls, raw: str) -> PartRef: """Gegenstueck zu :meth:`render` (ValueError bei falscher Form).""" text = raw.strip() if text in ("", "-"): return cls() teile_id, at, pos = (t.strip() for t in text.partition("@")) if not teile_id or " " in teile_id: raise ValueError(f"'{raw}' ist kein Bauteil ' @ x y'") if not at: return cls(teile_id=teile_id) werte = pos.replace(",", " ").split() if len(werte) != 2: raise ValueError(f"'{raw}': Lage hinter @ braucht x und y") return cls(teile_id=teile_id, x=float(werte[0]), y=float(werte[1])) # Slot-Schluessel in einem edit-Abschnitt (lines steht fuer alle Lagerlinien) _SLOT_KEY = re.compile(r"^(separator|switch|scanner)\d+$|^drive$") _EDIT_KEYS = ("type", "name", "delete", "reason", "lines") @dataclass class TroEdit: """Handkorrektur aus BricsCAD (Abschnitt edit bzw. edit_add). ``key`` ist die ID aus ``_tro_ids.ini`` (edit) bzw. die Kennung des neuen TRO (edit_add). ``slots``/``lines`` sind None, wenn die Bauteile wie abgeleitet bleiben. """ key: str add: bool = False delete: bool = False name: str = "" type_name: str = "" slots: dict[str, PartRef] | None = None lines: list[PartRef] | None = None reason: str = "" @property def section_kind(self) -> str: return "edit_add" if self.add else "edit" def is_empty(self) -> bool: """Nichts zu tun (ein edit-Abschnitt ohne Aenderung entfaellt).""" return not (self.add or self.delete or self.name or self.type_name or self.slots is not None or self.lines is not None) @dataclass class ConnectionEdit: """Eine im Plan hinzugefuegte (add) oder entfernte Verbindung zwischen zwei TROs, Enden als stabile Schluessel (DerivedTro.key).""" add: bool src: str dst: str kind: str = "" def render(self) -> str: text = f"{'+' if self.add else '-'} {self.src} -> {self.dst}" return f"{text} | {self.kind}" if self.add and self.kind else text @classmethod def parse(cls, raw: str) -> ConnectionEdit: text = raw.strip() if not text or text[0] not in "+-": raise ValueError(f"'{raw}': erwartet '+ -> [| Art]' oder " f"'- -> '") pfeil, _, kind = text[1:].partition("|") src, sep, dst = (t.strip() for t in pfeil.partition("->")) if not sep or not src or not dst: raise ValueError(f"'{raw}': erwartet ' -> '") return cls(add=text[0] == "+", src=src, dst=dst, kind=kind.strip()) @dataclass class Overrides: """Alle Korrekturen zu einer CSV-Datei. ``edits`` (Handkorrekturen aus BricsCAD) zaehlen fuer ``bool()`` nicht mit: material_flow --connect liest nur die uebrigen Arten. """ 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) # Handkorrekturen aus BricsCAD in Dateireihenfolge (tro_flow.apply_edits) edits: list[TroEdit] = field(default_factory=list) # Verbindungen aus BricsCAD (tro_flow.apply_connection_edits) connection_edits: list[ConnectionEdit] = 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. # utf-8-sig: von Hand (Editor, PowerShell) gespeichert oft mit BOM parser.read(path, encoding="utf-8-sig") 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 # [.edit_connections] - Verbindungen aus BricsCAD if section.lower() == f"{prefix}.edit_connections": for key, raw in parser[section].items(): try: result.connection_edits.append(ConnectionEdit.parse(raw)) except ValueError as exc: result.warnings.append(f"[{section}] {key}: {exc}") 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(), ) ) elif kind in ("edit", "edit_add"): edit = _parse_edit(section, name, kind == "edit_add", values, result.warnings) if edit is not None: result.edits.append(edit) else: result.warnings.append( f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro', 'split', " f"'merge', 'openpoint', 'edit' oder 'edit_add'" ) result.candidates.sort(key=lambda p: p.name) return result def _parse_edit(section: str, name: str, add: bool, values, warnings: list[str]) -> TroEdit | None: """Einen edit-/edit_add-Abschnitt lesen (None bei einem Fehler).""" type_name = values.get("type", "").strip() if type_name and get_tro(type_name) is None: warnings.append(f"[{section}]: Typ '{type_name}' steht nicht im TRO-Katalog " f"- Korrektur ignoriert") return None if add and not type_name: warnings.append(f"[{section}]: neuer TRO ohne 'type' - nicht angelegt") return None edit = TroEdit( key=name, add=add, delete=values.get("delete", "").strip().lower() in ("true", "ja", "1", "yes"), name=values.get("name", "").strip(), type_name=type_name, reason=values.get("reason", "").strip(), ) try: slots = {key: PartRef.parse(raw) for key, raw in values.items() if key not in _EDIT_KEYS} if "lines" in values: edit.lines = [PartRef.parse(t) for t in values["lines"].split(",") if t.strip()] except ValueError as exc: warnings.append(f"[{section}]: {exc} - Korrektur ignoriert") return None falsch = sorted(k for k in slots if not _SLOT_KEY.match(k)) if falsch: warnings.append(f"[{section}]: unbekannte Schluessel {', '.join(falsch)} - " f"erwartet type, name, delete, reason, lines oder Slots " f"(separator1, switch1, scanner1, drive)") return None if slots: edit.slots = slots return edit # Kopfzeile des von --read-tros geschriebenen Blocks; beim Neuschreiben # wird sie samt aller edit-/edit_add-Abschnitte der CSV ersetzt. _EDIT_HEAD = "; --- Handkorrekturen aus BricsCAD zu {csv}" _HEADER = re.compile(r"^\s*\[(.+)\]\s*$") def render_edit(csv_name: str, edit: TroEdit) -> str: """Einen Abschnitt als INI-Text.""" lines = [f"[{csv_name}.{edit.section_kind}.{edit.key}]"] if edit.delete: lines.append("delete = true") if edit.type_name: lines.append(f"type = {edit.type_name}") if edit.name: lines.append(f"name = {edit.name}") for slot, ref in (edit.slots or {}).items(): lines.append(f"{slot} = {ref.render()}") if edit.lines is not None: lines.append(f"lines = {', '.join(r.render() for r in edit.lines)}") if edit.reason: lines.append(f"reason = {edit.reason}") return "\n".join(lines) def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None, connections: list[ConnectionEdit] | None = None) -> Path: """Alle edit-/edit_add-Abschnitte und [.edit_connections] zu `csv_name` durch `edits` bzw. `connections` ersetzen. Der Rest der Datei (von Hand gepflegt, mit Kommentaren) bleibt Zeile fuer Zeile erhalten; der neue Block steht am Ende. Kommentarzeilen direkt vor dem naechsten Abschnitt gehoeren zu diesem und bleiben stehen. """ path = override_path(cfg_dir) text = path.read_text(encoding="utf-8") if path.is_file() else "" prefix = csv_name.lower() head = _EDIT_HEAD.format(csv=csv_name).lower() def ist_edit(section: str) -> bool: if section.lower() == f"{prefix}.edit_connections": return True parts = section.split(".") return (len(parts) >= 3 and parts[-2].lower() in ("edit", "edit_add") and ".".join(parts[:-2]).lower() == prefix) def ist_rand(line: str) -> bool: return not line.strip() or line.lstrip().startswith(";") keep: list[str] = [] skipped: list[str] = [] skipping = False for line in text.splitlines(): if line.strip().lower().startswith(head): continue if m := _HEADER.match(line): if skipping: # Kommentare/Leerzeilen am Ende gehoeren zum naechsten Abschnitt tail: list[str] = [] while skipped and ist_rand(skipped[-1]): tail.insert(0, skipped.pop()) keep.extend(tail) skipping = ist_edit(m.group(1).strip()) skipped = [] if skipping: continue if skipping: skipped.append(line) else: keep.append(line) while keep and not keep[-1].strip(): keep.pop() if edits or connections: if keep: keep += ["", ""] keep.append(_EDIT_HEAD.format(csv=csv_name) + f" - tro_annotate --read-tros, {datetime.now():%Y-%m-%d}") for edit in edits: keep += [render_edit(csv_name, edit), ""] if connections: keep.append(f"[{csv_name}.edit_connections]") keep += [f"c{n:02d} = {c.render()}" for n, c in enumerate(connections, start=1)] keep.append("") path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(keep).rstrip("\n") + "\n", encoding="utf-8") return path