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>
This commit is contained in:
2026-10-26 14:35:04 +01:00
parent 9ec1022fb1
commit 6c8c235287
12 changed files with 1441 additions and 125 deletions
+86 -8
View File
@@ -82,6 +82,10 @@ __all__ = [
"derive_slots",
"derive_jam_areas",
"storage_lines",
"SLOT_PART_KINDS",
"slot_kind",
"slot_names",
"has_lines",
]
# Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung),
@@ -204,9 +208,17 @@ class TroBase(_Positioned):
@computed_field # type: ignore[prop-decorator]
@property
def items(self) -> dict[str, int]:
"""Bauteile laut Katalog, z. B. {'Separator': 1} ({} wenn Typ unbekannt)."""
"""Bauteile laut Katalog, z. B. {'Separator': 1} ({} wenn Typ unbekannt).
Ausnahme Lagerlinien: der Katalog nennt nur die Vorgabe (PinStore_Auto
19); hat der TRO Linien, gilt deren Zahl (Layout oder Handkorrektur im
Dialog TRO_EDIT).
"""
definition = self.definition()
return definition.get_items() if definition else {}
if not definition:
return {}
return _with_line_count(definition.get_items(), self.separators,
getattr(self, "hosts", []))
@model_validator(mode="before")
@classmethod
@@ -219,9 +231,14 @@ class TroBase(_Positioned):
fb_block = data.pop("fbType", data.pop("fb_block", None))
type_name = data.get("type", data.get("type_name"))
definition = get_tro(type_name) if isinstance(type_name, str) else None
expected_items = definition.get_items() if definition else {}
expected_items = (_with_line_count(definition.get_items(),
data.get("separators", []),
data.get("hosts", []))
if definition else {})
# Dateien vor 2026-10-26 tragen bei Lagerlinien die Katalogvorgabe
catalog_items = definition.get_items() if definition else {}
expected_fb = definition.get_fb_block() if definition else ""
if items is not None and dict(items) != expected_items:
if items is not None and dict(items) not in (expected_items, catalog_items):
raise ValueError(
f"items {items} passt nicht zum Typ '{type_name}' laut tro_catalog "
f"({expected_items}) - den Typ aendern, nicht die Bauteile"
@@ -381,6 +398,11 @@ class Analysis(BaseModel):
# Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein
# TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird.
boundaries: list[DerivedTro] = Field(default_factory=list)
# Handkorrekturen aus BricsCAD (tro_flow.apply_edits): umbenannte TROs
# (neue ID -> ID aus <csv>_tro_ids.ini) und von Hand angelegte TROs - beide
# gehoeren nicht unter ihrer neuen Kennung in die ID-Datei.
renamed: dict[str, str] = Field(default_factory=dict)
added: list[str] = Field(default_factory=list)
def by_id(self, tro_id: str) -> DerivedTro | None:
for tro in self.tros + self.boundaries:
@@ -388,6 +410,11 @@ class Analysis(BaseModel):
return tro
return None
def edit_key(self, tro_id: str) -> str:
"""Schluessel des TRO in tro_overrides.ini (ID der ID-Datei bzw. Kennung
des von Hand angelegten TRO)."""
return self.renamed.get(tro_id, tro_id)
class TroDocument(BaseModel):
"""
@@ -613,6 +640,12 @@ def _at(values: list[str], index: int) -> str:
return values[index] if index < len(values) else ""
def _lines_of(separators: list[str], hosts: list[str]) -> list[str]:
seps, hosts = list(separators or []), list(hosts or [])
count = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps)
return seps[len(seps) - count:]
def storage_lines(tro: TroBase) -> list[str]:
"""Separatoren der Lagerlinien eines Speicher-TRO.
@@ -620,10 +653,55 @@ def storage_lines(tro: TroBase) -> list[str]:
letzten len(hosts) (eine je Speicherbahn). Fehlen Hosts, zaehlt jeder
Separator als Linie - dieselbe Regel wie scl_context._store.
"""
hosts = list(getattr(tro, "hosts", []) or [])
seps = list(tro.separators)
count = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps)
return seps[len(seps) - count:]
return _lines_of(tro.separators, getattr(tro, "hosts", []))
def _with_line_count(items: dict[str, int], separators: list[str],
hosts: list[str]) -> dict[str, int]:
"""Katalog-Bauteile, Lagerlinien auf die Zahl der Linien des TRO gesetzt."""
if items.get(TroItem.STORAGE_LINE) and (lines := _lines_of(separators, hosts)):
items = {**items, TroItem.STORAGE_LINE: len(lines)}
return items
# Slot-Art (Name ohne Nummer) -> Bauteilarten (Element.kind im CSV), die in
# diesen Slot passen. Gebraucht vom Dialog TRO_EDIT (Pruefung beim Waehlen im
# Plan, tro_annotate --emit-lisp) und beim Anwenden der Handkorrekturen
# (tro_flow.apply_edits). "drive" ist der tragende Foerderer.
SLOT_PART_KINDS: dict[str, tuple[str, ...]] = {
"separator": ("Separator",),
"line": ("Separator",),
"switch": ("Ausschleus", "Weiche"),
"scanner": ("Scanner",),
"drive": ("Strecke", "Gefaellestrecke", "Kreisel"),
}
def slot_kind(slot: str) -> str:
"""'separator2' -> 'separator', 'line17' -> 'line', 'drive' -> 'drive'."""
return slot.rstrip("0123456789")
def slot_names(type_name: str) -> list[str]:
"""Slots eines Typs in der Reihenfolge von derive_slots, ohne Lagerlinien.
Die Lagerlinien (line1..lineN) haengen am TRO, nicht am Typ - ob ein Typ
sie hat, sagt :func:`has_lines`.
"""
definition = get_tro(type_name)
items = definition.get_items() if definition else {}
names = [f"separator{i}" for i in range(1, items.get(TroItem.SEPARATOR, 0) + 1)]
names += [f"switch{i}" for i in range(1, items.get(TroItem.SWITCH, 0) + 1)]
names += [f"scanner{i}" for i in range(1, items.get(TroItem.SCANNER, 0) + 1)]
if items.get(TroItem.VARIO_DRIVE):
names.append("drive")
return names
def has_lines(type_name: str) -> int:
"""Vorgabe der Lagerlinien laut Katalog (0 = Typ hat keine)."""
definition = get_tro(type_name)
return definition.get_items().get(TroItem.STORAGE_LINE, 0) if definition else 0
def derive_slots(tro: DerivedTro) -> list[Slot]:
+476 -66
View File
@@ -35,9 +35,24 @@ Typen auch ohne Farbe unterscheiden. Die Daten stehen als Attribute am Block:
TYPE 1Sep sichtbar, im Dialog bearbeitbar
FB_BLOCK FB_ILS_... sichtbar nur mit --fb
UID 0062 unsichtbar, dauerhafte interne Nummer
KEY TRO03 unsichtbar, Schluessel in tro_overrides.ini (ID der
ID-Datei; leer bei einem mit TRO_INSERT gesetzten)
ITEMS 1x Separator unsichtbar, reine Daten
CONFIDENCE mittel unsichtbar
SEPARATORS 0010 unsichtbar
SLOTS separator1=0010;switch1=0061 unsichtbar, Bauteil je Slot
(TeileId; Lagerlinien als lines=0066,0069,...),
im Dialog TRO_EDIT bearbeitbar
Handkorrekturen (--read-tros)
-----------------------------
Im Dialog TRO_EDIT (dxfmakros) aendern die SPS-Programmierer Typ, Kennung und
die Bauteile je Slot; TRO_INSERT setzt neue Marker, geloeschte Marker fallen
weg. Fuer "Im Plan waehlen" legt die Beschriftung die Bauteile des Exports
(TeileId, Art, Lage in der Zeichnung) als XRECORD SPS_SKEL_PARTS in das
Named Object Dictionary. --read-tros vergleicht die Marker mit der Ableitung
und schreibt die Abweichungen als Abschnitte edit/edit_add nach
cfg/tro_overrides.ini; tro_flow und tro_annotate wenden sie beim naechsten
Lauf an (tro_flow.apply_edits).
Zwei Kennungen, mit Absicht:
ID die sprechende Kennung (TRO03). Darf sich aendern - der Dialog TRO_EDIT
@@ -122,7 +137,19 @@ from tro_flow import (
resolve_ids_file,
)
from tro_ids import TroIds
from model import ANLAGE_SCHEMA, AnlageDocument, JamArea
from model import (
ANLAGE_SCHEMA,
SLOT_PART_KINDS,
AnlageDocument,
DerivedTro,
JamArea,
derive_slots,
has_lines,
slot_kind,
slot_names,
)
from tro_ids import id_problem
from tro_overrides import PartRef, TroEdit, load_overrides, override_path, save_edits
from scl_context import (
JAM_NAMES_SUFFIX,
HwSource,
@@ -147,6 +174,12 @@ CONNECTION_BLOCK = "CONNECTION_ARROW"
# Staubereich (--anlage). Der Name beginnt mit JAM_, damit SSG_BLOCKEDIT ihn
# ueber "JAM_*" an den Dialog JAM_EDIT (dxfmakros/Lisp/Jam_Edit.lsp) gibt.
JAM_BLOCK = "JAM_AREA"
# Bauteile des Exports fuer "Im Plan waehlen" im Dialog TRO_EDIT: XRECORD im
# Named Object Dictionary, je Bauteil (1 . TeileId) (3 . Art) (10 . Lage)
# (40 . halbe Breite) (41 . halbe Hoehe) in Zeichnungskoordinaten.
PARTS_XRECORD = "SPS_SKEL_PARTS"
# Unsichtbare Attribute der Marker (build_symbol)
HIDDEN_TAGS = ("UID", "KEY", "ITEMS", "CONFIDENCE", "SLOTS")
# Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar)
JAM_OFFEN = "Jam????_?"
@@ -155,7 +188,14 @@ JAM_OFFEN = "Jam????_?"
DEFAULT_MARKER = get_float("tro_annotate", "default_marker")
DEFAULT_TEXT = get_float("tro_annotate", "default_text")
DEFAULT_MAX_RESIDUAL = get_float("tro_annotate", "default_max_residual")
JAM_VERSATZ = get_float("tro_annotate", "jam_versatz")
JAM_ABSTAND = get_float("tro_annotate", "jam_abstand")
# Staubereich als stehendes Rechteck, Masse relativ zur Texthoehe: halbe
# Breite / halbe Hoehe, Name senkrecht darin (Jam0054_3 passt hinein).
JAM_HALB_BREITE = 0.5
JAM_HALB_HOEHE = 2.6
JAM_TEXT = 0.6
# Fuellung und Rand (ACI 8 = grau) und Schrift (ACI 250 = fast schwarz)
JAM_FUELLUNG = "#d0d0d0"
def hex_to_rgb(value: str) -> tuple[int, int, int]:
@@ -189,10 +229,26 @@ def emit_lisp(cad_dir: Path) -> Path:
cad_dir.mkdir(parents=True, exist_ok=True)
path = cad_dir / "tro_types.lsp"
types = " ".join(f'"{d.get_name()}"' for d in TRO_CATALOG)
def lisp_list(values) -> str:
return " ".join(f'"{v}"' for v in values)
# Je Typ die Slots in der Reihenfolge von model.derive_slots, dazu die
# Vorgabe der Lagerlinien (0 = keine): ("PinStore_Auto" 19 "separator1" ...)
slots = "\n".join(
" (" + " ".join([f'"{d.get_name()}"', str(has_lines(d.get_name()))]
+ [f'"{s}"' for s in slot_names(d.get_name())]) + ")"
for d in TRO_CATALOG)
kinds = "\n".join(f' ("{k}" {lisp_list(v)})' for k, v in SLOT_PART_KINDS.items())
path.write_text(
";; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp\n"
";; Quelle: TRO_CATALOG in lib/tro_catalog.py - nicht manuell aendern.\n"
";; Quelle: TRO_CATALOG in lib/tro_catalog.py, Slots aus lib/model.py\n"
";; (slot_names, SLOT_PART_KINDS) - nicht manuell aendern.\n"
f"(setq *TRO-TYPES* (list {types}))\n"
";; (Typ Lagerlinien-Vorgabe Slot ...) - Lagerlinien heissen line1..lineN\n"
f"(setq *TRO-SLOTS* '(\n{slots}\n))\n"
";; (Slot-Art Bauteilart ...) - Pruefung beim Waehlen im Plan\n"
f"(setq *TRO-SLOT-KINDS* '(\n{kinds}\n))\n"
"(princ)\n",
encoding="utf-8",
)
@@ -403,7 +459,7 @@ def build_layers(doc, prefix: str) -> dict[str, str]:
("weiche", "#cc8800"),
("eingang", STATION_FARBE[BOUNDARY_ENTRY]),
("ausgang", STATION_FARBE[BOUNDARY_EXIT]),
("jam", "#7030a0"),
("jam", "#808080"),
("legend", "#808080")):
name = f"{prefix}{key.upper()}"
names[key] = name
@@ -518,6 +574,18 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool)
"""
name = f"{BLOCK_PREFIX}{type_name}"
if name in doc.blocks:
# Blockdefinition aus einem frueheren Lauf (bleibt, weil von Hand
# gesetzte Marker sie benutzen): fehlende Datenattribute nachtragen,
# sonst haetten die neuen Marker z. B. kein KEY/SLOTS.
blk = doc.blocks.get(name)
vorhanden = {a.dxf.tag.upper() for a in blk.query("ATTDEF")}
offset = min((a.dxf.insert.y for a in blk.query("ATTDEF")), default=0.0)
for tag_name in HIDDEN_TAGS:
if tag_name not in vorhanden:
offset -= text
blk.add_attdef(tag_name, insert=(marker * 1.5, offset), height=text * 0.7,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1})
return name
blk = doc.blocks.new(name=name)
@@ -542,9 +610,9 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool)
)
offset -= height * 1.55
# nicht sichtbare Attribute: reine Daten, nicht im Dialog.
# ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros.
for tag_name in ("UID", "ITEMS", "CONFIDENCE", "SEPARATORS"):
# nicht sichtbare Attribute: reine Daten; KEY und SLOTS liest der Dialog
# TRO_EDIT. ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros.
for tag_name in HIDDEN_TAGS:
blk.add_attdef(
tag_name,
insert=(gap, offset),
@@ -591,10 +659,11 @@ def place_markers(
values = {
"ID": tro.tro_id,
"UID": internal[tro.tro_id],
"KEY": analysis.edit_key(tro.tro_id),
"TYPE": tro.type_name,
"ITEMS": ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())),
"CONFIDENCE": tro.confidence,
"SEPARATORS": ", ".join(tro.separators),
"SLOTS": format_slots(*tro_slots(tro)),
}
if show_fb:
values["FB_BLOCK"] = tro.fb_block or "-"
@@ -773,7 +842,8 @@ def draw_connections(
def build_jam_symbol(doc, text: float) -> str:
"""
Blockdefinition fuer einen Staubereich: Rechteck, der Name mittig darin.
Blockdefinition fuer einen Staubereich: stehendes graues Rechteck, der
Name senkrecht mittig darin.
Attribute:
NAME sichtbar - Jam0054_3 (Foerderer + laufende Nummer)
@@ -787,15 +857,23 @@ def build_jam_symbol(doc, text: float) -> str:
TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key).
"""
if JAM_BLOCK in doc.blocks:
# Form aus einem frueheren Lauf (liegendes Rechteck) ersetzen; die
# Attributdefinitionen bleiben, damit vorhandene Bloecke gueltig sind.
blk = doc.blocks.get(JAM_BLOCK)
for e in list(blk):
if e.dxftype() != "ATTDEF":
blk.delete_entity(e)
_jam_form(blk, text)
for a in blk.query("ATTDEF"):
if a.dxf.tag.upper() == "NAME":
_jam_name(a, text)
return JAM_BLOCK
blk = doc.blocks.new(name=JAM_BLOCK)
w, h = text * 4.2, text * 1.2
blk.add_lwpolyline([(-w, -h), (w, -h), (w, h), (-w, h)], close=True,
dxfattribs={"color": 256})
name = blk.add_attdef("NAME", insert=(0, 0), height=text,
dxfattribs={"style": "Standard", "color": 256,
"text": JAM_OFFEN})
name.set_placement((0, 0), align=TextEntityAlignment.MIDDLE_CENTER)
_jam_form(blk, text)
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
name = blk.add_attdef("NAME", insert=(0, 0), height=text * JAM_TEXT,
dxfattribs={"style": "Standard", "text": JAM_OFFEN})
_jam_name(name, text)
offset = -h - text
for tag_name in ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO"):
blk.add_attdef(tag_name, insert=(-w, offset), height=text * 0.7,
@@ -805,36 +883,70 @@ def build_jam_symbol(doc, text: float) -> str:
return JAM_BLOCK
def _jam_position(area: JamArea, anlage: AnlageDocument) -> tuple[float, float] | None:
"""Lage eines Staubereichs (CSV-Koordinaten): vor seinem Separator.
def _jam_form(blk, text: float) -> None:
"""Stehendes Rechteck: graue Fuellung, grauer Rand."""
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
ecken = [(-w, -h), (w, -h), (w, h), (-w, h)]
hatch = blk.add_hatch(dxfattribs={"true_color": ezdxf.rgb2int(hex_to_rgb(JAM_FUELLUNG))})
hatch.paths.add_polyline_path(ecken, is_closed=True)
blk.add_lwpolyline(ecken, close=True, dxfattribs={"color": 8})
Ausgangspunkt ist der Separator (sonst die TRO-Position); von dort
JAM_VERSATZ in Richtung des ersten Vorgaenger-TRO - gegen den Fluss, wo
die Traeger warten. Liegt der Vorgaenger naeher, nur die halbe Strecke.
def _jam_name(attdef, text: float) -> None:
"""Name senkrecht (90 Grad) mittig ins Rechteck."""
attdef.dxf.height = text * JAM_TEXT
attdef.dxf.rotation = 90.0
attdef.dxf.color = 250
attdef.set_placement((0, 0), align=TextEntityAlignment.MIDDLE_CENTER)
def _jam_position(area: JamArea, anlage: AnlageDocument,
marker: float, text: float) -> tuple[float, float] | None:
"""Lage eines Staubereichs (CSV-Koordinaten): direkt vor seinem TRO.
Ausgangspunkt ist der TRO-Marker (die TRO-Position, nicht der Separator:
bei Speichern und 2Sep-Typen liegt der weit vom Marker). Das Rechteck
sitzt gerade so weit davon, dass es den Marker nicht beruehrt
(Markerradius + JAM_ABSTAND + halbe Breite), ein zweiter Eingang direkt
daneben - in Richtung des ersten Vorgaenger-TRO, gegen den Fluss, wo die
Traeger warten. Ohne Vorgaenger mit Lage (Station) gegen die Richtung zum
Nachfolger. Liegt der Nachbar naeher, hoechstens die halbe Strecke. Nur
Lage - Verbindungen und Fliessrichtung bleiben unberuehrt.
"""
layout = anlage.layout
part = layout.part(area.separator) if area.separator else None
if part is not None and part.position is not None:
x, y = part.position[0], part.position[1]
tro = layout.by_id(area.tro)
if tro is not None and tro.position:
x, y = tro.position[0], tro.position[1]
elif area.position is not None:
x, y = area.position[0], area.position[1]
else:
return None
for vor in area.predecessors:
tro = layout.by_id(vor)
if tro is None or not tro.position:
continue
vx, vy = tro.position[0] - x, tro.position[1] - y
dist = math.hypot(vx, vy)
if dist > 1e-6:
schritt = min(JAM_VERSATZ, dist / 2)
return x + vx / dist * schritt, y + vy / dist * schritt
return x, y
breite = 2 * text * JAM_HALB_BREITE
abstand = marker + JAM_ABSTAND + breite / 2 + (area.entry - 1) * (breite + JAM_ABSTAND)
def richtung(ids: list[str], vorzeichen: float):
for other_id in ids:
other = layout.by_id(other_id)
if other is None or not other.position:
continue
vx, vy = other.position[0] - x, other.position[1] - y
dist = math.hypot(vx, vy)
if dist > 1e-6:
schritt = min(abstand, dist / 2)
return (x + vorzeichen * vx / dist * schritt,
y + vorzeichen * vy / dist * schritt)
return None
lage = richtung(area.predecessors or (tro.predecessors if tro else []), 1.0)
if lage is None and tro is not None:
lage = richtung(tro.successors, -1.0)
return lage if lage is not None else (x - abstand, y)
def place_jams(
msp, doc, anlage: AnlageDocument, overrides: dict[str, tuple[str, int]],
transform: Transform, layers: dict[str, str], text: float, warnings: list[str],
marker: float = DEFAULT_MARKER,
) -> int:
"""
Je Staubereich der Anlagen-JSON einen JAM_AREA-Block einfuegen.
@@ -849,7 +961,7 @@ def place_jams(
block = build_jam_symbol(doc, text)
gezeichnet = 0
for area in layout.jam_areas_or_derived():
lage = _jam_position(area, anlage)
lage = _jam_position(area, anlage, marker, text)
if lage is None:
warnings.append(f"Staubereich {area.jam_id} ({area.tro}): keine Lage - "
f"nicht gezeichnet")
@@ -915,6 +1027,218 @@ def read_jams(dxf_file: Path, ini_file: Path) -> tuple[dict[str, tuple[str, int]
return overrides, meldungen
# ---------------------------------------------------------------------------
# Handkorrekturen an den TROs (Dialog TRO_EDIT, --read-tros)
# ---------------------------------------------------------------------------
def tro_slots(tro: DerivedTro) -> tuple[dict[str, str], list[str]]:
"""Bauteile je Slot (ohne Lagerlinien) und die Lagerlinien in Reihenfolge."""
slots: dict[str, str] = {}
lines: list[str] = []
for slot in derive_slots(tro):
if slot_kind(slot.slot) == "line":
lines.append(slot.layout_id)
else:
slots[slot.slot] = slot.layout_id
return slots, lines
def format_slots(slots: dict[str, str], lines: list[str]) -> str:
"""Attribut SLOTS: 'separator1=0010;switch1=;lines=0066,0069'."""
text = ";".join(f"{k}={v}" for k, v in slots.items())
if lines:
text += (";" if text else "") + "lines=" + ",".join(lines)
return text
def parse_slots(text: str) -> tuple[dict[str, str], list[str] | None]:
"""Gegenstueck zu format_slots; lines ist None, wenn das Attribut keine nennt."""
slots: dict[str, str] = {}
lines: list[str] | None = None
for part in text.split(";"):
key, sep, value = (t.strip() for t in part.partition("="))
if not sep or not key:
continue
if key.lower() == "lines":
lines = [v.strip() for v in value.split(",") if v.strip()]
else:
slots[key.lower()] = value
return slots, lines
def write_parts(doc, elements, transform: Transform) -> int:
"""Bauteile, die in einen Slot passen, als XRECORD in die Zeichnung legen."""
kinds = {k for v in SLOT_PART_KINDS.values() for k in v}
tags: list[tuple[int, object]] = []
for e in elements:
if e.kind not in kinds or e.position is None or not e.teile_id:
continue
x, y = transform.to_dxf(e.x, e.y)
w, h = (e.bbox[0] / 2, e.bbox[1] / 2) if e.bbox else (0.0, 0.0)
tags += [(1, e.teile_id), (3, e.kind), (10, (x, y, 0.0)), (40, w), (41, h)]
root = doc.rootdict
if PARTS_XRECORD in root:
root.remove(PARTS_XRECORD)
xrecord = root.add_xrecord(PARTS_XRECORD)
xrecord.reset(tags)
return len(tags) // 5
def read_markers(dxf_file: Path) -> list[dict[str, str]]:
"""Attribute aller TRO-Marker (Bloecke TRO_SYM_*) der Zeichnung."""
doc = ezdxf.readfile(str(dxf_file))
markers = []
for ref in doc.modelspace().query("INSERT"):
if not ref.dxf.name.upper().startswith(BLOCK_PREFIX):
continue
a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
a["_BLOCK"] = ref.dxf.name
markers.append(a)
return markers
def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
csv_name: str, cfg_dir: Path | None = None,
write: bool = True) -> tuple[list[TroEdit], list[str]]:
"""Marker der Zeichnung mit der Ableitung vergleichen -> Handkorrekturen.
``raw`` ist die Ableitung OHNE Handkorrekturen (IDs aus der ID-Datei). Je
Marker mit KEY eines abgeleiteten TRO entsteht ein edit-Abschnitt, wenn
Typ, Kennung oder Bauteile abweichen; ein Marker ohne passenden KEY ist
ein neuer TRO (edit_add); ein abgeleiteter TRO mit Lage, zu dem kein
Marker mehr da ist, wurde geloescht. Die Abschnitte ersetzen alle
bisherigen edit-/edit_add-Abschnitte der CSV in tro_overrides.ini
(Begruendungen bleiben). Rueckgabe: (Korrekturen, Meldungen).
"""
meldungen: list[str] = []
by_id = {e.teile_id: e for e in elements}
alt = {(e.section_kind, e.key): e
for e in load_overrides(csv_name, cfg_dir).edits}
def ref(teile_id: str) -> PartRef:
if not teile_id:
return PartRef()
element = by_id.get(teile_id.split("-", 1)[0])
if element is None or element.position is None:
return PartRef(teile_id=teile_id)
return PartRef(teile_id=teile_id, x=element.x, y=element.y)
def pruefen(where: str, type_name: str, slots: dict[str, str],
lines: list[str]) -> None:
for slot in slot_names(type_name):
teile_id = slots.get(slot, "")
if not teile_id:
if slot.startswith("separator"):
meldungen.append(f"{where}: {slot} ist leer")
continue
element = by_id.get(teile_id.split("-", 1)[0])
kinds = SLOT_PART_KINDS[slot_kind(slot)]
if element is None:
meldungen.append(f"{where}: {slot} = {teile_id} steht nicht im Export")
elif element.kind not in kinds:
meldungen.append(f"{where}: {slot} = {teile_id} ist ein {element.kind}, "
f"erwartet {'/'.join(kinds)}")
if has_lines(type_name) and not lines:
meldungen.append(f"{where}: Speicher ohne Lagerlinie")
raw_by_id = {t.tro_id: t for t in raw.tros}
edits: list[TroEdit] = []
gesehen: set[str] = set()
vergeben: dict[str, str] = {}
ids: dict[str, str] = {}
for m in markers:
tro_id, key = m.get("ID", ""), m.get("KEY", "")
type_name = m.get("TYPE", "") or m["_BLOCK"][len(BLOCK_PREFIX):]
where = f"Marker {tro_id or '?'}"
if not tro_id:
meldungen.append(f"{where} ({m['_BLOCK']}): ohne ID - uebersprungen")
continue
if get_tro(type_name) is None:
meldungen.append(f"{where}: Typ '{type_name}' steht nicht im Katalog - "
f"uebersprungen")
continue
if problem := id_problem(tro_id):
meldungen.append(f"{where}: {problem} - uebersprungen")
continue
if tro_id in ids:
meldungen.append(f"{where}: Kennung doppelt (auch {ids[tro_id]})")
ids[tro_id] = key or tro_id
slots, lines = parse_slots(m.get("SLOTS", ""))
if key in gesehen:
# kopierter Marker: gleicher KEY wie ein schon gelesener
meldungen.append(f"{where}: KEY {key} doppelt (Marker kopiert?) - als "
f"neuer TRO gelesen")
key = ""
derived = raw_by_id.get(key)
if derived is not None:
gesehen.add(key)
raw_slots, raw_lines = tro_slots(derived)
else:
raw_slots, raw_lines = {}, []
if lines is None:
lines = raw_lines if has_lines(type_name) else []
pruefen(where, type_name, slots, lines)
for teile_id in [v for v in slots.values() if v] + lines:
if teile_id in vergeben and vergeben[teile_id] != tro_id:
meldungen.append(f"{where}: Bauteil {teile_id} steckt auch in "
f"{vergeben[teile_id]}")
vergeben[teile_id] = tro_id
names = slot_names(type_name)
if derived is None:
edit = TroEdit(key=tro_id, add=True, type_name=type_name,
slots={s: ref(slots.get(s, "")) for s in names},
lines=[ref(t) for t in lines] if has_lines(type_name) else None)
else:
edit = TroEdit(key=key)
if type_name != derived.type_name:
edit.type_name = type_name
if tro_id != key:
edit.name = tro_id
if any(slots.get(s, "") != raw_slots.get(s, "") for s in names):
edit.slots = {s: ref(slots.get(s, "")) for s in names}
if has_lines(type_name) and lines != raw_lines:
edit.lines = [ref(t) for t in lines]
if edit.is_empty():
continue
if (vorher := alt.get((edit.section_kind, edit.key))) is not None:
edit.reason = vorher.reason
edits.append(edit)
meldungen.append(_describe_edit(edit))
for tro in raw.tros:
if tro.tro_id in gesehen or not tro.position:
continue
edit = TroEdit(key=tro.tro_id, delete=True)
if (vorher := alt.get(("edit", tro.tro_id))) is not None:
edit.reason = vorher.reason
edits.append(edit)
meldungen.append(_describe_edit(edit))
if write:
save_edits(csv_name, edits, cfg_dir)
return edits, meldungen
def _describe_edit(edit: TroEdit) -> str:
if edit.delete:
return f"{edit.key}: geloescht"
teile = []
if edit.add:
teile.append(f"neu ({edit.type_name})")
elif edit.type_name:
teile.append(f"Typ -> {edit.type_name}")
if edit.name:
teile.append(f"Kennung -> {edit.name}")
if edit.slots is not None and not edit.add:
teile.append("Bauteile " + ", ".join(
f"{k}={r.teile_id or '-'}" for k, r in edit.slots.items()))
if edit.lines is not None:
teile.append(f"{len(edit.lines)} Lagerlinien")
return f"{edit.key}: {'; '.join(teile)}"
def draw_legend(
msp, analysis: Analysis, placed: dict[str, tuple[float, float]],
layers: dict[str, str], marker: float, text: float, source: str,
@@ -1000,7 +1324,12 @@ def report(
print(f" Restfehler = max {transform.residual_max:.1f} mm, "
f"mittel {transform.residual_mean:.1f} mm "
f"({transform.matched}/{transform.candidates} Punkte)")
print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}")
print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}"
+ (f" (Handkorrekturen: {len(analysis.renamed)} umbenannt, "
f"{len(analysis.added)} neu)" if analysis.renamed or analysis.added else ""))
if counts.get("parts"):
print(f"Bauteile = {counts['parts']} (XRECORD {PARTS_XRECORD}, fuer "
f"TRO_EDIT 'Im Plan waehlen'; uebernehmen mit --read-tros)")
print(f"Verbindungen = {counts['flow']}"
+ (" (Block CONNECTION_ARROW mit FROM_UID/TO_UID)" if counts["flow"] else ""))
if counts.get("jams"):
@@ -1039,6 +1368,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" tro_annotate.bat --file dematic_merge.csv --dxf dematic_merge..dxf --flow "
"--anlage dematic_merge_anlage.json\n"
" tro_annotate.bat --file dematic_merge.csv --read-jams\n"
" tro_annotate.bat --file mubea.csv --read-tros --check\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
@@ -1085,6 +1415,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"<csv>_annotated.dxf in %%SKEL_RESULTS%%) und nach "
"%%SKEL_CFG%%\\<csv>_jam_names.ini schreiben, dann beenden. "
"scl_skeleton liest die Datei.")
parser.add_argument("--read-tros", nargs="?", const="", default=None, metavar="DXF",
help="Im Dialog TRO_EDIT/TRO_INSERT geaenderte, neue und "
"geloeschte TRO-Marker aus der beschrifteten Zeichnung "
"uebernehmen (Standard: <csv>_annotated.dxf in "
"%%SKEL_RESULTS%%) und als Abschnitte edit/edit_add nach "
"%%SKEL_CFG%%\\tro_overrides.ini schreiben, dann beenden. "
"tro_flow und tro_annotate wenden sie an.")
parser.add_argument("--check", action="store_true",
help="Nur pruefen und berichten, keine Datei schreiben.")
parser.add_argument("--emit-lisp", action="store_true",
@@ -1125,6 +1462,96 @@ def _main_read_jams(args, csv_file: Path) -> int:
return 0
def _derive(args, csv_file: Path, edits: list[TroEdit] | None):
"""CSV lesen und die TROs ableiten - (Elemente, Analysis) oder None.
Derselbe Aufruf wie in tro_flow.main (mit der Separator-Gegenprobe aus
<name>_raw.json, der ID-Datei und den Handkorrekturen), damit die
Zeichnung genau die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine
Datenquelle. --read-tros vergleicht mit der Ableitung ohne Korrekturen
(edits=None).
"""
read_warnings: list[str] = []
try:
elements = read_elements(csv_file, read_warnings)
except (OSError, ValueError, csv_module.Error) as exc:
print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr)
return None
graph = build_graph(elements, csv_file.name, read_warnings)
raw_json_file = find_sibling_raw_json(csv_file)
sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings)
if raw_json_file is not None else {})
# Dieselbe ID-Datei wie tro_flow, aber nur lesen: neue TROs bekommen
# dieselbe vorlaeufige ID, die tro_flow anhaengen wuerde.
ids_file = resolve_ids_file(args.ids, csv_file)
try:
tro_ids = TroIds.load(ids_file) if ids_file.is_file() else None
except (OSError, ValueError) as exc:
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return None
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids, edits=edits)
if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
return None
return elements, analysis
def _main_read_tros(args, csv_file: Path) -> int:
"""--read-tros: Marker der beschrifteten Zeichnung -> tro_overrides.ini."""
results = env_dir("SKEL_RESULTS", "results")
cfg_dir = env_dir("SKEL_CFG", "cfg")
name = args.read_tros or args.out or f"{csv_file.stem}_annotated.dxf"
dxf_file = Path(name)
if not dxf_file.is_absolute() and dxf_file.parent == Path("."):
dxf_file = results / name
if not dxf_file.is_file():
print(f"FEHLER: beschriftete Zeichnung nicht gefunden: {dxf_file}", file=sys.stderr)
print(" In BricsCAD als DXF speichern (Dateiname wie beim Beschriften).",
file=sys.stderr)
return 1
if not csv_file.is_file():
print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr)
return 1
try:
markers = read_markers(dxf_file)
except (OSError, ezdxf.DXFError) as exc:
print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
derived = _derive(args, csv_file, None)
if derived is None:
return 1
elements, raw = derived
# Schutz: eine falsche Zeichnung (ohne oder mit fremden Markern) wuerde
# sonst alle TROs als geloescht eintragen.
keys = {m.get("KEY", "") for m in markers}
if not markers or not keys & {t.tro_id for t in raw.tros}:
print(f"FEHLER: {dxf_file.name} enthaelt keine Marker zu den TROs aus "
f"{csv_file.name} ({len(markers)} Marker) - nichts geschrieben. Zeichnung "
f"mit dem aktuellen tro_annotate neu beschriften?", file=sys.stderr)
return 1
try:
edits, meldungen = read_tros(markers, raw, elements, csv_file.name, cfg_dir,
write=not args.check)
except (OSError, configparser.Error) as exc:
print(f"FEHLER: {exc}", file=sys.stderr)
return 1
print("")
print("================================================================")
print("TRO-HANDKORREKTUREN UEBERNEHMEN")
print("================================================================")
print(f"Zeichnung = {dxf_file} ({len(markers)} Marker)")
ziel = override_path(cfg_dir)
print(f"Korrekturen = {len(edits)} -> "
f"{'- (--check, nichts geschrieben)' if args.check else ziel}")
for m in meldungen:
print(f" {m}")
print("Danach: tro_flow --json (und tro_annotate) neu laufen lassen.")
print("================================================================")
print("")
return 0
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
warnings: list[str] = []
@@ -1145,10 +1572,12 @@ def main(argv: list[str] | None = None) -> int:
if args.read_jams is not None:
return _main_read_jams(args, csv_file)
if args.read_tros is not None:
return _main_read_tros(args, csv_file)
if not args.dxf:
print("FEHLER: --dxf fehlt (nur --emit-lisp und --read-jams gehen ohne "
"Zeichnung).", file=sys.stderr)
print("FEHLER: --dxf fehlt (nur --emit-lisp, --read-jams und --read-tros gehen "
"ohne Zeichnung).", file=sys.stderr)
return 1
if not csv_file.is_file():
print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr)
@@ -1192,33 +1621,13 @@ def main(argv: list[str] | None = None) -> int:
f"nicht zu '{csv_file.name}'.", file=sys.stderr)
return 1
# TROs aus dem CSV
read_warnings: list[str] = []
try:
elements = read_elements(csv_file, read_warnings)
except (OSError, ValueError, csv_module.Error) as exc:
print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
graph = build_graph(elements, csv_file.name, read_warnings)
# Derselbe Aufruf wie in tro_flow.main (ohne cfg/tro_overrides.ini, mit
# der Separator-Gegenprobe aus <name>_raw.json und der ID-Datei), damit die Zeichnung genau
# die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine Datenquelle.
raw_json_file = find_sibling_raw_json(csv_file)
sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings)
if raw_json_file is not None else {})
# Dieselbe ID-Datei wie tro_flow, aber nur lesen: neue TROs bekommen
# dieselbe vorlaeufige ID, die tro_flow anhaengen wuerde.
ids_file = resolve_ids_file(args.ids, csv_file)
try:
tro_ids = TroIds.load(ids_file) if ids_file.is_file() else None
except (OSError, ValueError) as exc:
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return 1
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids)
if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
# TROs aus dem CSV, mit den Handkorrekturen aus tro_overrides.ini
overrides = load_overrides(csv_file.name, cfg_dir)
warnings += [w for w in overrides.warnings if ".edit" in w]
derived = _derive(args, csv_file, overrides.edits)
if derived is None:
return 1
elements, analysis = derived
# Zeichnung
try:
@@ -1274,6 +1683,7 @@ def main(argv: list[str] | None = None) -> int:
counts["marker"] = len(placed)
counts["blocks"] = sum(1 for b in doc.blocks if b.name.startswith(BLOCK_PREFIX))
counts["first_id"] = next_id
counts["parts"] = write_parts(doc, elements, transform)
if args.flow:
@@ -1285,7 +1695,7 @@ def main(argv: list[str] | None = None) -> int:
)
if anlage is not None:
counts["jams"] = place_jams(msp, doc, anlage, jam_overrides, transform, layers,
args.text_height, warnings)
args.text_height, warnings, marker=args.marker_size)
if args.legend:
draw_legend(msp, analysis, placed, layers, args.marker_size,
args.text_height, csv_file.name)
+223 -5
View File
@@ -115,7 +115,10 @@ ausschliesslich derive_tros(), Schritt fuer Schritt:
Alle anderen Funktionen (link_tros, locate_tros, derive_boundaries, ...)
aendern den Typ nicht mehr. cfg/tro_overrides.ini wird hier seit
2026-10-05 nicht mehr angewendet (siehe tro_overrides.py) - ein falsch
erkannter Typ wird in derive_tros() korrigiert, nicht per INI.
erkannter Typ wird in derive_tros() korrigiert, nicht per INI. Ausnahme seit
2026-10-26: die Handkorrekturen aus BricsCAD (Abschnitte edit/edit_add, von
tro_annotate --read-tros geschrieben) wendet apply_edits nach derive_tros an;
sie haengen an den stabilen IDs der ID-Datei.
Nicht abgeleitet werden bisher: 1Sep_SSCC (nur Hinweis, Schritt 6),
EmptyCarrBuffer und LoadingBoom. Schritt 3c braucht eine stimmige
@@ -181,12 +184,23 @@ from material_flow import (
resolve_input,
)
from kreisel_junctions import Junction, find_junctions, separators_before
from model import Analysis, DerivedTro, LayoutPart, TroDocument
from model import (
SLOT_PART_KINDS,
Analysis,
DerivedTro,
LayoutPart,
TroDocument,
has_lines,
slot_kind,
slot_names,
storage_lines,
)
from parameter import get_float, get_int
from tro_ids import (
DEFAULT_TOLERANCE,
TroIds,
assign_ids,
id_problem,
ids_path,
insert_entries,
other_sections,
@@ -203,6 +217,7 @@ from tro_catalog import (
get_tro,
mermaid_classdefs,
)
from tro_overrides import PartRef, TroEdit, load_overrides
# ---------------------------------------------------------------------------
# Konstanten
@@ -2229,12 +2244,197 @@ def locate_tros(tros: list[DerivedTro], elements: list[Element], graph: Graph) -
tro.position_from = source if tro.position else "keine Position"
def _resolve_part(ref: PartRef, slot: str, elements: list[Element],
by_id: dict[str, Element], where: str, tolerance: float,
findings: list[str]) -> str:
"""TeileId fuer einen Slot der Handkorrektur im aktuellen Export.
Erst die TeileId selbst (passende Art, Lage innerhalb der Toleranz), sonst
das naechste Bauteil derselben Art an der Lage - die TeileId wechselt
zwischen zwei Exporten, die Lage nicht. "" wenn nichts passt.
"""
if not ref.teile_id:
return ""
kinds = SLOT_PART_KINDS.get(slot_kind(slot), ())
base = ref.teile_id.split("-", 1)[0]
element = by_id.get(base)
if element is not None and element.kind in kinds:
if ref.x is None or element.position is None or math.hypot(
element.x - ref.x, element.y - ref.y) <= tolerance:
return ref.teile_id
if ref.x is not None and ref.y is not None:
naechste = min(
((math.hypot(e.x - ref.x, e.y - ref.y), e) for e in elements
if e.kind in kinds and e.position is not None),
default=None, key=lambda p: p[0])
if naechste is not None and naechste[0] <= tolerance:
findings.append(
f"{where}: {slot} {ref.teile_id} steht im Export nicht mehr dort - "
f"{naechste[1].kind} {naechste[1].teile_id} an derselben Lage "
f"uebernommen ({naechste[0]:.0f} mm)")
return naechste[1].teile_id
findings.append(f"{where}: {slot} {ref.render()} ist kein "
f"{'/'.join(kinds) or 'Bauteil'} des Exports - Slot bleibt leer")
return ""
def _host_of(sep_id: str, by_id: dict[str, Element], graph: Graph) -> str:
"""Flussknoten, auf dem ein Separator sitzt (CSV-Spalte 'Zuordnung')."""
element = by_id.get(sep_id)
host = element.assignment if element is not None else ""
return host if host and host != UNASSIGNED and host in graph.nodes else ""
def _set_parts(tro: DerivedTro, edit: TroEdit, elements: list[Element],
by_id: dict[str, Element], graph: Graph, tolerance: float,
findings: list[str]) -> None:
"""Bauteile eines TRO aus den Slots der Handkorrektur setzen.
Umkehrung von model.derive_slots: separator<i> -> separators, line<n> ->
die Lagerlinien am Ende von separators (je Linie ein Host), switch<i> ->
switches, scanner<i> -> scanners, drive -> hosts[0].
"""
where = f"Handkorrektur {edit.key}"
slots = edit.slots or {}
names = slot_names(tro.type_name)
def teil(slot: str) -> str:
ref = slots.get(slot)
return _resolve_part(ref, slot, elements, by_id, where, tolerance,
findings) if ref is not None else ""
alte_seps = list(tro.separators)
if edit.slots is not None:
seps = [teil(n) for n in names if n.startswith("separator")]
while seps and not seps[-1]:
seps.pop()
tro.switches = [teil(n) for n in names if n.startswith("switch")]
tro.scanners = [s for s in (teil(n) for n in names if n.startswith("scanner")) if s]
drive = teil("drive") if "drive" in names else ""
else:
seps = alte_seps[:len(alte_seps) - len(storage_lines(tro))] \
if has_lines(tro.type_name) else alte_seps
drive = ""
if has_lines(tro.type_name):
if edit.lines is not None:
lines = [s for s in (
_resolve_part(r, f"line{n}", elements, by_id, where, tolerance, findings)
for n, r in enumerate(edit.lines, start=1)) if s]
else:
lines = storage_lines(tro)
seps = [s for s in seps if s and s not in lines]
tro.separators = seps + lines
# Je Linie ein Host - danach zaehlt model.storage_lines die Linien
tro.hosts = [_host_of(s, by_id, graph) or s for s in lines]
else:
tro.separators = seps
if set(seps) != set(alte_seps):
hosts = list(dict.fromkeys(h for s in seps if (h := _host_of(s, by_id, graph))))
if hosts:
tro.hosts = hosts
if drive:
tro.hosts = [drive] + [h for h in tro.hosts if h != drive]
def apply_edits(tros: list[DerivedTro], edits: list[TroEdit], elements: list[Element],
graph: Graph, findings: list[str], analysis: Analysis,
tolerance: float = DEFAULT_TOLERANCE) -> list[DerivedTro]:
"""Handkorrekturen aus BricsCAD (tro_overrides.ini, edit/edit_add) anwenden.
Laeuft nach derive_tros (die IDs aus der ID-Datei stehen fest) und vor
Verortung und Verknuepfung, damit Koordinaten und Kanten den korrigierten
TRO zeigen. Ein Bauteil, das ein korrigierter TRO bekommt, verliert sein
bisheriger TRO; bleibt der ohne Separator, entfaellt er (Befund).
Umbenennungen kommen zuletzt, die Schluessel bleiben bis dahin die IDs der
ID-Datei. Ergebnis in ``analysis.renamed``/``analysis.added``.
"""
by_id = {e.teile_id: e for e in elements}
tros = list(tros)
renames: list[tuple[DerivedTro, TroEdit]] = []
# Erst loeschen, dann aendern, zuletzt neu anlegen: ein neuer TRO darf die
# Bauteile eines geloeschten uebernehmen, ohne dass der als "verdraengt" gilt.
for edit in sorted(edits, key=lambda e: 0 if e.delete else 2 if e.add else 1):
where = f"Handkorrektur {edit.key}"
tro = next((t for t in tros if t.tro_id == edit.key), None)
if edit.add:
if tro is not None:
findings.append(f"{where}: neuer TRO, aber {edit.key} gibt es schon - "
f"ignoriert")
continue
tro = DerivedTro(tro_id=edit.key, type_name=edit.type_name,
label=f"von Hand angelegt ({edit.key})", hosts=[],
scanners=[], reason="von Hand in BricsCAD angelegt",
confidence="bestaetigt")
tros.append(tro)
analysis.added.append(edit.key)
elif tro is None:
findings.append(f"{where}: TRO {edit.key} wird nicht (mehr) abgeleitet - "
f"Korrektur ignoriert")
continue
if edit.delete:
tros.remove(tro)
findings.append(f"{where}: TRO {edit.key} ({tro.type_name}) von Hand "
f"entfernt" + (f" - {edit.reason}" if edit.reason else ""))
continue
if edit.type_name and edit.type_name != tro.type_name:
findings.append(f"{where}: Typ {tro.type_name} -> {edit.type_name}")
tro.type_name = edit.type_name
if edit.slots is not None or edit.lines is not None or edit.add:
_set_parts(tro, edit, elements, by_id, graph, tolerance, findings)
if not edit.add:
tro.reason = f"{tro.reason}; Handkorrektur BricsCAD"
if edit.reason:
tro.reason = f"{tro.reason} ({edit.reason})"
tro.confidence = "bestaetigt"
# Bauteile, die dieser TRO jetzt hat, den anderen TROs nehmen
eigene = {p for p in tro.separators + tro.scanners + tro.switches if p}
for other in list(tros):
if other is tro:
continue
weg = eigene & set(other.separators + other.scanners + other.switches)
if not weg:
continue
other.separators = [s for s in other.separators if s not in weg]
other.scanners = [s for s in other.scanners if s not in weg]
other.switches = ["" if s in weg else s for s in other.switches]
if other.separators or not other.items.get(TroItem.SEPARATOR):
findings.append(f"{where}: {', '.join(sorted(weg))} von {other.tro_id} "
f"zu {tro.tro_id} verschoben")
else:
tros.remove(other)
findings.append(f"{where}: {other.tro_id} hat keinen Separator mehr "
f"({', '.join(sorted(weg))} an {tro.tro_id}) - entfaellt")
if edit.name and edit.name != tro.tro_id:
renames.append((tro, edit))
for tro, edit in renames:
if tro not in tros:
continue
problem = id_problem(edit.name)
if not problem and any(t.tro_id == edit.name for t in tros):
problem = f"'{edit.name}' ist schon vergeben"
if problem:
findings.append(f"Handkorrektur {edit.key}: Umbenennung nicht moeglich - "
f"{problem}")
continue
if tro.tro_id in analysis.added:
analysis.added[analysis.added.index(tro.tro_id)] = edit.name
else:
analysis.renamed[edit.name] = tro.tro_id
tro.tro_id = edit.name
return tros
def analyse_tros(
graph: Graph,
elements: list[Element],
*,
sepliste_counts: dict[str, int] | None = None,
tro_ids: TroIds | None = None,
edits: list[TroEdit] | None = None,
) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
@@ -2250,6 +2450,8 @@ def analyse_tros(
Roh-JSON (load_sepliste_counts) - nur Gegenprobe.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu
nummerieren.
edits: Handkorrekturen aus BricsCAD (tro_overrides.load_overrides
(...).edits), angewandt direkt nach derive_tros (apply_edits).
Rueckgabe:
Analysis mit tros, boundaries, edges/edge_kinds, unknown_types
@@ -2259,6 +2461,11 @@ def analyse_tros(
analysis = Analysis()
analysis.tros = derive_tros(graph, elements, analysis.findings,
sepliste_counts=sepliste_counts, tro_ids=tro_ids)
if edits:
analysis.tros = apply_edits(analysis.tros, edits, elements, graph,
analysis.findings, analysis,
tolerance=tro_ids.tolerance if tro_ids
else DEFAULT_TOLERANCE)
analysis.boundaries = derive_boundaries(elements, graph)
# Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen:
# link_tros() registriert eine Kante nur zu einem Knoten, der Host eines
@@ -3281,16 +3488,20 @@ def write_ids_file(tro_ids: TroIds, analysis: Analysis, elements: list[Element],
if result is None:
return "unveraendert"
anchors = separator_anchors(elements)
# Die ID-Datei kennt die TROs unter ihrer abgeleiteten ID: Umbenennungen
# und neue TROs aus BricsCAD stehen in tro_overrides.ini, nicht hier.
tros = [t.model_copy(update={"tro_id": analysis.edit_key(t.tro_id)})
for t in analysis.tros if t.tro_id not in analysis.added]
if result.renumbered:
tro_ids.path.parent.mkdir(parents=True, exist_ok=True)
rest = other_sections(tro_ids.path)
text = render_ids(analysis.tros, anchors, source)
text = render_ids(tros, anchors, source)
tro_ids.path.write_text(text + (f"\n{rest}\n" if rest else ""),
encoding="utf-8")
return "neu geschrieben" if tro_ids.exists else "angelegt"
if result.new:
insert_entries(tro_ids.path,
render_new_entries(analysis.tros, anchors, result.new, source))
render_new_entries(tros, anchors, result.new, source))
return f"{len(result.new)} neue(r) TRO(s) angehaengt"
return "unveraendert"
@@ -3379,8 +3590,15 @@ def main(argv: list[str] | None = None) -> int:
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return 1
# Handkorrekturen aus BricsCAD (nur die Arten edit/edit_add, siehe
# tro_overrides.py); die uebrigen Abschnitte der Datei gelten hier nicht.
overrides = load_overrides(csv_file.name, env_dir("SKEL_CFG", "cfg"))
warnings += [w for w in overrides.warnings if ".edit" in w]
if overrides.edits and args.renumber:
warnings.append(f"--renumber: {len(overrides.edits)} Handkorrektur(en) in "
f"{overrides.source} haengen an den alten IDs - pruefen")
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids)
tro_ids=tro_ids, edits=overrides.edits)
if not analysis.tros:
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
f"ableitbar.", file=sys.stderr)
+10
View File
@@ -52,6 +52,7 @@ __all__ = [
"render_new_entries",
"insert_entries",
"other_sections",
"id_problem",
]
SECTION = "tro"
@@ -133,6 +134,15 @@ def _parser() -> configparser.ConfigParser:
return parser
def id_problem(tro_id: str) -> str:
"""Warum `tro_id` keine gueltige TRO-ID ist ("" = gueltig)."""
if not _ID_PATTERN.match(tro_id):
return f"'{tro_id}' ist kein gueltiger SCL-Bezeichner"
if _RESERVED.match(tro_id):
return f"'{tro_id}' ist fuer Systemgrenzen reserviert"
return ""
def load_ids(path: Path) -> list[IdEntry]:
"""ID-Datei lesen. ValueError bei doppelter ID, falscher Lage oder ID."""
parser = _parser()
+229 -2
View File
@@ -36,6 +36,31 @@ mehrere Anlagen in einer Datei stehen koennen):
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`.
@@ -45,7 +70,9 @@ 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
@@ -122,9 +149,84 @@ class MergeSpec:
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."""
"""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)
@@ -133,6 +235,8 @@ class Overrides:
# 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)
@@ -294,11 +398,134 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
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' oder 'openpoint'"
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