Files
sps_skel/lib/tro_overrides.py
T
Michael StanglandClaude Opus 5.5 29077caa81 model: TroCandidate statt OpenPoint, tro_overrides nutzt model-Klassen (Schema v3)
- OpenPoint -> model.TroCandidate (type, position, belongsTo, note);
  TroDocument.candidates ersetzt openPoints, Schema sps_skel-tro-v3
- Positionsbehandlung von TroBase und TroCandidate im Mixin _Positioned
- tro_overrides.py: eigene ConnectionSpec/OpenPoint entfallen, stattdessen
  model.Connection / model.TroCandidate; einheitliche Feldnamen
  (tro_id/separator, hosts als Liste)
- Fixtures, Beispiel-JSON und Doku auf v3 nachgezogen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:31:05 +02:00

305 lines
12 KiB
Python

#!/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
`[<Anlage>.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):
[<csv>.tro.<TRO-ID>] Korrektur an einem abgeleiteten TRO
type = 1Sep1Swi neuer Katalogtyp
reason = ... Begruendung (kommt in die Doku)
confidence = bestaetigt Vertrauensangabe (Default: bestaetigt)
[<csv>.openpoint.<Name>] 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 <projekt>/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():
# [<csv>.connections] - gesetzte Topologie, eigene Form ohne <name>
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"'<von> -> <nach>'"
)
continue
result.connections.append(
Connection(src=src, dst=dst, kind=rest.strip())
)
continue
parts = section.split(".")
# <csv>.<art>.<name> - 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