Files
sps_skel/lib/tro_overrides.py
T
s.ayadi 1ac17b95e2 validate_anlage, stabiler TRO-Schluessel, Verbindungen aus BricsCAD, Zeichnung aus der Anlagen-JSON
- validate_anlage.py (+ bin-Wrapper): Pruefung vor der SCL fuer beide Wege
  (Struktur, Zuordnungen, Aktualitaet der JSON, Korrekturen ohne Ziel);
  scl_skeleton ruft sie auf (Warnung, --strict bricht ab)
- DerivedTro.key: stabiler Schluessel (ID der ID-Datei); hw_overrides.ini,
  <csv>_jam_names.ini und tro_overrides.ini greifen ueber ID oder Schluessel,
  eine Umbenennung in TRO_EDIT laesst sie bestehen; JAM_AREA traegt KEY
- --read-tros liest die CONNECTION_ARROW-Bloecke mit -> [<csv>.edit_connections];
  tro_flow.apply_connection_edits wendet sie nach link_tros an
- tro_annotate --anlage zeichnet Marker/Verbindungen aus der Anlagen-JSON
- INI-Korrekturen auch mit BOM lesbar (utf-8-sig)
- Ablauf CSV/DXF+TIA -> Anlagen-JSON -> (BricsCAD) -> SCL in doc/Python_Scripts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:32:10 +01:00

583 lines
23 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**.
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 `<csv>_tro_ids.ini`, die ueber die Lage des Separators stabil bleibt -
anders als die laufenden Nummern, an denen die alten Abschnitte veraltet sind.
[<csv>.edit.<ID>] Korrektur an einem abgeleiteten TRO
type = 1Sep1Swi neuer Katalogtyp (fehlt = wie abgeleitet)
name = Einschleus_1 neue Kennung (fehlt = <ID> 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)
[<csv>.edit_add.<ID>] neuer TRO (TRO_INSERT), Schluessel wie oben
[<csv>.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 '<TeileId> @ 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 ``<csv>_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 '+ <von> -> <nach> [| Art]' oder "
f"'- <von> -> <nach>'")
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 '<von> -> <nach>'")
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 <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.
# 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():
# [<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
# [<csv>.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(".")
# <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(),
)
)
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 [<csv>.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