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>
This commit is contained in:
2026-10-27 14:32:10 +01:00
parent 6c8c235287
commit 1ac17b95e2
13 changed files with 1100 additions and 48 deletions
+165 -32
View File
@@ -143,20 +143,29 @@ from model import (
AnlageDocument,
DerivedTro,
JamArea,
TroDocument,
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 tro_overrides import (
ConnectionEdit,
PartRef,
TroEdit,
load_overrides,
override_path,
save_edits,
)
from scl_context import (
JAM_NAMES_SUFFIX,
HwSource,
flow_order,
jam_key,
jam_label,
jam_numbers,
jam_override,
jam_stable_key,
load_jam_overrides,
save_jam_overrides,
)
@@ -180,6 +189,8 @@ JAM_BLOCK = "JAM_AREA"
PARTS_XRECORD = "SPS_SKEL_PARTS"
# Unsichtbare Attribute der Marker (build_symbol)
HIDDEN_TAGS = ("UID", "KEY", "ITEMS", "CONFIDENCE", "SLOTS")
# Unsichtbare Attribute der Staubereiche (build_jam_symbol)
JAM_HIDDEN_TAGS = ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO", "KEY")
# Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar)
JAM_OFFEN = "Jam????_?"
@@ -854,35 +865,49 @@ def build_jam_symbol(doc, text: float) -> str:
AREA unsichtbar - Kennung der TRO-JSON (J0039)
AUTO unsichtbar - abgeleitete Nummer NNNN_k; --read-jams schreibt
nur, was davon abweicht
TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key).
KEY unsichtbar - stabiler Schluessel <TRO-Schluessel>.<Eingang>
(scl_context.jam_stable_key), ueberlebt eine Umbenennung
KEY (sonst TRO + ENTRY) ist der Schluessel in <csv>_jam_names.ini.
"""
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
if JAM_BLOCK in doc.blocks:
# Form aus einem frueheren Lauf (liegendes Rechteck) ersetzen; die
# Attributdefinitionen bleiben, damit vorhandene Bloecke gueltig sind.
# Attributdefinitionen bleiben, damit vorhandene Bloecke gueltig sind,
# fehlende (KEY) kommen dazu.
blk = doc.blocks.get(JAM_BLOCK)
for e in list(blk):
if e.dxftype() != "ATTDEF":
blk.delete_entity(e)
_jam_form(blk, text)
vorhanden = set()
for a in blk.query("ATTDEF"):
vorhanden.add(a.dxf.tag.upper())
if a.dxf.tag.upper() == "NAME":
_jam_name(a, text)
offset = min((a.dxf.insert.y for a in blk.query("ATTDEF")), default=-h)
for tag_name in JAM_HIDDEN_TAGS:
if tag_name not in vorhanden:
offset -= text
_jam_attdef(blk, tag_name, -w, offset, text)
return JAM_BLOCK
blk = doc.blocks.new(name=JAM_BLOCK)
_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,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1})
for tag_name in JAM_HIDDEN_TAGS:
_jam_attdef(blk, tag_name, -w, offset, text)
offset -= text
return JAM_BLOCK
def _jam_attdef(blk, tag_name: str, x: float, y: float, text: float) -> None:
blk.add_attdef(tag_name, insert=(x, y), height=text * 0.7,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1})
def _jam_form(blk, text: float) -> None:
"""Stehendes Rechteck: graue Fuellung, grauer Rand."""
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
@@ -967,7 +992,7 @@ def place_jams(
f"nicht gezeichnet")
continue
auto = automatisch.get(area.jam_id)
nr, k = overrides.get(jam_key(area), auto or ("", 0))
nr, k = jam_override(area, layout, overrides) or auto or ("", 0)
ref = msp.add_blockref(block, transform.to_dxf(*lage),
dxfattribs={"layer": layers["jam"]})
ref.add_auto_attribs({
@@ -978,6 +1003,8 @@ def place_jams(
"ENTRY": str(area.entry),
"AREA": area.jam_id,
"AUTO": f"{auto[0]}_{auto[1]}" if auto else "",
# Schluessel fuer --read-jams: ueberlebt eine Umbenennung des TRO
"KEY": jam_stable_key(area, layout),
})
tag(ref, area.tro)
gezeichnet += 1
@@ -999,11 +1026,16 @@ def read_jams(dxf_file: Path, ini_file: Path) -> tuple[dict[str, tuple[str, int]
namen: dict[str, str] = {}
for ref in doc.modelspace().query(f'INSERT[name=="{JAM_BLOCK}"]'):
a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
key = f"{a.get('TRO', '')}.{a.get('ENTRY', '')}"
# Stabiler Schluessel (KEY, seit 2026-10-27), sonst TRO-ID + Eingang
anzeige = f"{a.get('TRO', '')}.{a.get('ENTRY', '')}"
key = a.get("KEY") or anzeige
nr, k = a.get("CONVEYOR", ""), a.get("NUMBER", "")
if not a.get("TRO") or not a.get("ENTRY"):
meldungen.append(f"Staubereich ohne TRO/ENTRY ({a.get('NAME', '?')}) - uebersprungen")
continue
if key != anzeige and anzeige in overrides:
# Eintrag unter der alten Anzeige-ID auf den Schluessel umstellen
overrides.setdefault(key, overrides.pop(anzeige))
if not nr and not k:
continue
if not (nr.isdigit() and len(nr) == 4 and k.isdigit() and int(k) > 0):
@@ -1085,33 +1117,48 @@ def write_parts(doc, elements, transform: Transform) -> int:
return len(tags) // 5
def read_markers(dxf_file: Path) -> list[dict[str, str]]:
"""Attribute aller TRO-Marker (Bloecke TRO_SYM_*) der Zeichnung."""
def read_markers(dxf_file: Path) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Attribute aller TRO-Marker (Bloecke TRO_SYM_*) und aller
Verbindungsbloecke (CONNECTION_ARROW) der Zeichnung."""
doc = ezdxf.readfile(str(dxf_file))
markers = []
markers, arrows = [], []
for ref in doc.modelspace().query("INSERT"):
if not ref.dxf.name.upper().startswith(BLOCK_PREFIX):
name = ref.dxf.name.upper()
if name.startswith(BLOCK_PREFIX):
ziel = markers
elif name == CONNECTION_BLOCK:
ziel = arrows
else:
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
ziel.append(a)
return markers, arrows
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]]:
write: bool = True,
arrows: list[dict[str, str]] | None = None,
base: Analysis | None = None) -> 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).
Marker mehr da ist, wurde geloescht. Mit ``arrows`` (Verbindungsbloecke)
kommen die geaenderten Verbindungen dazu (_read_connections, Abschnitt
edit_connections). Die Abschnitte ersetzen alle bisherigen
edit-/edit_add-/edit_connections-Abschnitte der CSV in tro_overrides.ini
(Begruendungen bleiben). Ohne ``arrows`` bleibt ein vorhandener
edit_connections-Abschnitt stehen. ``base`` ist die Ableitung MIT den
TRO-Korrekturen, aber ohne Verbindungskorrekturen - verglichen werden die
Pfeile mit ihr, damit Kanten eines geaenderten oder neuen TRO nicht als
Handkorrektur gelten (Standard: ``raw``). Rueckgabe: (Korrekturen, Meldungen).
"""
meldungen: list[str] = []
marker_key: dict[int, 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}
@@ -1176,6 +1223,8 @@ def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
raw_slots, raw_lines = tro_slots(derived)
else:
raw_slots, raw_lines = {}, []
# Schluessel des Markers fuer die Verbindungen: abgeleitet = KEY, neu = ID
marker_key[id(m)] = key if derived is not None else tro_id
if lines is None:
lines = raw_lines if has_lines(type_name) else []
pruefen(where, type_name, slots, lines)
@@ -1216,11 +1265,64 @@ def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
edits.append(edit)
meldungen.append(_describe_edit(edit))
if arrows is not None:
connections = _read_connections(arrows, markers, marker_key, base or raw, meldungen)
else:
# ohne Verbindungsbloecke: bisherige Verbindungskorrekturen behalten
connections = load_overrides(csv_name, cfg_dir).connection_edits
if write:
save_edits(csv_name, edits, cfg_dir)
save_edits(csv_name, edits, cfg_dir, connections)
return edits, meldungen
def _read_connections(arrows: list[dict[str, str]], markers: list[dict[str, str]],
marker_key: dict[int, str], base: Analysis,
meldungen: list[str]) -> list[ConnectionEdit]:
"""Verbindungsbloecke der Zeichnung gegen die abgeleiteten TRO-Kanten.
Die Enden eines CONNECTION_ARROW werden ueber die Marker derselben
Zeichnung zu stabilen Schluesseln: erst ueber den sichtbaren Namen
(FROM_TRO/TO_TRO, das sieht der Bearbeiter), sonst ueber die interne
Nummer (FROM_UID/TO_UID, bei dxfmakros-Bloecken frueher FROM_ID/TO_ID) -
so stimmt es auch nach einer Umbenennung des Markers. Kanten von/zu
Stationen tragen keinen Block und bleiben, wie abgeleitet; Kanten zu
geloeschten Markern fallen mit dem TRO weg.
"""
by_name = {m.get("ID", ""): m for m in markers if id(m) in marker_key}
by_uid = {m.get("UID", ""): m for m in markers if id(m) in marker_key and m.get("UID")}
def ende(a: dict[str, str], seite: str) -> str | None:
m = by_name.get(a.get(f"{seite}_TRO", ""))
if m is None:
m = by_uid.get(a.get(f"{seite}_UID", "") or a.get(f"{seite}_ID", ""))
return marker_key.get(id(m)) if m is not None else None
gewollt: dict[tuple[str, str], str] = {}
for a in arrows:
src, dst = ende(a, "FROM"), ende(a, "TO")
if src is None or dst is None:
meldungen.append(f"Verbindung {a.get('FROM_TRO') or '?'} -> "
f"{a.get('TO_TRO') or '?'}: Ende passt zu keinem Marker - "
f"uebersprungen")
continue
if src == dst:
meldungen.append(f"Verbindung {src} -> {dst}: Quelle = Ziel - uebersprungen")
continue
gewollt[(src, dst)] = "weiche" if a.get("KIND") == "weiche" else ""
vorhanden = set(marker_key.values())
tro_ids = {t.tro_id for t in base.tros}
# Kanten der Ableitung, Enden als stabile Schluessel wie die Marker
abgeleitet = {(base.edit_key(s), base.edit_key(d)) for s, d in base.edges
if s in tro_ids and d in tro_ids}
changes = [ConnectionEdit(add=False, src=s, dst=d) for s, d in sorted(abgeleitet)
if (s, d) not in gewollt and s in vorhanden and d in vorhanden]
changes += [ConnectionEdit(add=True, src=s, dst=d, kind=k)
for (s, d), k in sorted(gewollt.items()) if (s, d) not in abgeleitet]
meldungen += [f"Verbindung {c.render()}" for c in changes]
return changes
def _describe_edit(edit: TroEdit) -> str:
if edit.delete:
return f"{edit.key}: geloescht"
@@ -1462,7 +1564,27 @@ def _main_read_jams(args, csv_file: Path) -> int:
return 0
def _derive(args, csv_file: Path, edits: list[TroEdit] | None):
def analysis_from_document(doc: TroDocument) -> Analysis:
"""Analysis aus einer TRO-/Anlagen-JSON, damit die Zeichenfunktionen
(place_markers, draw_connections, place_stations) die JSON zeigen statt
einer neuen Ableitung. Umbenennungen kommen aus DerivedTro.key."""
tros = [t.model_copy(deep=True) for t in doc.tros]
analysis = Analysis(
tros=tros,
boundaries=[b.model_copy(deep=True) for b in doc.boundaries],
edges=[(c.src, c.dst) for c in doc.connections],
edge_kinds={(c.src, c.dst): c.kind for c in doc.connections},
findings=list(doc.findings),
renamed={t.tro_id: t.key for t in tros if t.key and t.key != t.tro_id},
method=f"Anlagen-/TRO-JSON {doc.source}",
)
analysis.unknown_types = sorted({t.type_name for t in tros if t.definition() is None})
analysis.isolated = [t for t in tros if not t.connected]
return analysis
def _derive(args, csv_file: Path, edits: list[TroEdit] | None,
connection_edits: list[ConnectionEdit] | None = None):
"""CSV lesen und die TROs ableiten - (Elemente, Analysis) oder None.
Derselbe Aufruf wie in tro_flow.main (mit der Separator-Gegenprobe aus
@@ -1490,7 +1612,8 @@ def _derive(args, csv_file: Path, edits: list[TroEdit] | None):
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)
tro_ids=tro_ids, edits=edits,
connection_edits=connection_edits)
if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
return None
@@ -1514,7 +1637,7 @@ def _main_read_tros(args, csv_file: Path) -> int:
print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr)
return 1
try:
markers = read_markers(dxf_file)
markers, arrows = read_markers(dxf_file)
except (OSError, ezdxf.DXFError) as exc:
print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr)
return 1
@@ -1531,8 +1654,15 @@ def _main_read_tros(args, csv_file: Path) -> int:
f"mit dem aktuellen tro_annotate neu beschriften?", file=sys.stderr)
return 1
try:
# 1. TRO-Korrekturen aus den Markern; 2. die Pfeile gegen die Ableitung
# MIT diesen Korrekturen (sonst gaelten Kanten eines neuen TRO als
# von Hand gezogen)
edits, _ = read_tros(markers, raw, elements, csv_file.name, cfg_dir, write=False)
derived = _derive(args, csv_file, edits)
if derived is None:
return 1
edits, meldungen = read_tros(markers, raw, elements, csv_file.name, cfg_dir,
write=not args.check)
write=not args.check, arrows=arrows, base=derived[1])
except (OSError, configparser.Error) as exc:
print(f"FEHLER: {exc}", file=sys.stderr)
return 1
@@ -1540,7 +1670,7 @@ def _main_read_tros(args, csv_file: Path) -> int:
print("================================================================")
print("TRO-HANDKORREKTUREN UEBERNEHMEN")
print("================================================================")
print(f"Zeichnung = {dxf_file} ({len(markers)} Marker)")
print(f"Zeichnung = {dxf_file} ({len(markers)} Marker, {len(arrows)} Verbindungen)")
ziel = override_path(cfg_dir)
print(f"Korrekturen = {len(edits)} -> "
f"{'- (--check, nichts geschrieben)' if args.check else ziel}")
@@ -1624,7 +1754,7 @@ def main(argv: list[str] | None = None) -> int:
# 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)
derived = _derive(args, csv_file, overrides.edits, overrides.connection_edits)
if derived is None:
return 1
elements, analysis = derived
@@ -1654,14 +1784,17 @@ def main(argv: list[str] | None = None) -> int:
counts = {"marker": 0, "flow": 0, "removed": 0, "layers": 0, "blocks": 0,
"first_id": 0, "jams": 0}
if anlage is not None:
# Mit Anlagen-JSON zeigt die Zeichnung genau sie - das, woraus die SCL
# wird. Die Ableitung aus dem CSV dient nur noch als Gegenprobe.
aus_csv = {t.tro_id for t in analysis.tros}
aus_json = {t.tro_id for t in anlage.layout.tros}
if aus_csv != aus_json:
warnings.append(
f"TROs der Zeichnung und der Anlagen-JSON weichen ab (nur CSV: "
f"{', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: "
f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - tro_flow --json und "
f"merge_hw_layout neu laufen lassen")
f"Die Zeichnung zeigt die Anlagen-JSON; die Ableitung aus dem CSV weicht "
f"ab (nur CSV: {', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: "
f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - JSON veraltet? "
f"tro_flow --json und merge_hw_layout neu laufen lassen")
analysis = analysis_from_document(anlage.layout)
if args.check:
counts["marker"] = sum(1 for t in analysis.tros if t.position)