Files
sps_skel/lib/tro_overrides.py
T
s.ayadi 6c8c235287 tro_annotate --read-tros: TRO-Handkorrekturen aus BricsCAD, Staubereiche vor dem TRO
- Marker tragen KEY (ID der ID-Datei) und SLOTS (Bauteil je Slot), die
  Zeichnung die Bauteiltabelle SPS_SKEL_PARTS (XRECORD) fuer "Im Plan
  waehlen" im Dialog TRO_EDIT (dxfmakros)
- --read-tros schreibt geaenderte, neue und geloeschte Marker als
  Abschnitte edit/edit_add nach cfg/tro_overrides.ini; tro_flow.apply_edits
  wendet nur diese Arten an (Bauteile mit Lage, exportfest)
- --emit-lisp schreibt dazu *TRO-SLOTS* und *TRO-SLOT-KINDS*
- model: Lagerlinien zaehlen je TRO (Katalog nur Vorgabe), Mubea-Speicher
  jetzt 20 Linien (Golden-Datei)
- Staubereich als stehendes graues Rechteck direkt vor seinem TRO-Marker
  (jam_abstand statt jam_versatz); Marker 150 mm, Text 120 mm

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

532 lines
20 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
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 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)
# 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(),
)
)
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) -> Path:
"""Alle edit-/edit_add-Abschnitte zu `csv_name` durch `edits` 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:
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:
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), ""]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(keep).rstrip("\n") + "\n", encoding="utf-8")
return path