Files
dxfmakros/tests/test_hw_symbole.py
T
Michael StanglandClaude Opus 5.5 8abb6b3c65 feat: HW Planung - Symbole erzeugen, Nummerierung, Ausgabeziel-Dialog
- Symbole erzeugen (HW_SYMBOLE, Einzelbefehle HW_SYM_*): Bauteile nach
  cfg/io_regeln.json (reduzierte Kopie aus kabellaengen), Symbole aus
  cfg/hw_symbole.json katalog-identisch nachgebaut (lib/hw_symbole.py),
  REALE_POSITION mit x auf dem Bauteil, Paare uebereinander, Ausweichen
  in y ohne Ueberlappung
- Symbole nummerieren (HW_NUMMERIEREN): lib/create_numbers.py samt
  error_collector.py und cfg/enumerate.cfg aus kabellaengen uebernommen
- Ausgabeziel per Dialog dcl/hw_ausgabe.dcl: aktuelle Zeichnung,
  bestehende Datei ergaenzen oder neue Datei
- Materialfluss mit XDATA SSG_HW_MF, ergaenzt vorhandene Datei
- Doku doc/hw_planung.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:39:37 +01:00

279 lines
13 KiB
Python

"""Tests fuer lib/hw_symbole.py, cfg/hw_symbole.json und die Kette
Symbole -> Renamer -> create_numbers."""
import json
import os
import sys
import ezdxf
import pytest
ROOT = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, os.path.join(ROOT, "lib"))
import create_numbers # noqa: E402
import hw_symbole as hs # noqa: E402
KONFIG = {
"textstile": {"standard": "ARIAL", "schriften": {"ARIAL": "arial.ttf"}},
"vorlagen": {"klein": {"rahmen": [750.0, 270.0], "attribute": [
{"tag": "IO", "x": 100.0, "y": 130.0, "hoehe": 125.0, "farbe": "symbol"},
{"tag": "VERW", "x": 20.0, "y": 20.0, "hoehe": 70.0},
{"tag": "BEZEICHNUNG", "x": 0.0, "y": -80.0, "hoehe": 60.0, "unsichtbar": True},
{"tag": "REALE_POSITION", "x": 715.0, "y": 110.0, "hoehe": 50.0,
"ausrichtung": "MC", "wert": "x"}]}},
"symbole": {
"SYM_VENTIL": {"vorlage": "klein", "layer": "ILS_Ausgang", "farbe": 3,
"werte": {"IO": "MB0", "VERW": "Sep", "BEZEICHNUNG": "Separator (MB0)"}},
"SYM_SENSOR": {"vorlage": "klein", "layer": "ILS_Eingang",
"werte": {"IO": "BG0", "VERW": "In Sep"}}},
}
def _plan(praefixe, symbole):
return {"praefixe": praefixe, "symbole": symbole}
def _sym(praefix, block, x, y, rolle="r"):
return {"praefix": praefix, "io": f"{praefix}1@@@", "block": block,
"x": x, "y": y, "rolle": rolle, "key": f"{x}", "bauteil": "separator"}
def test_attribut_werte_ersetzt_platzhalter():
vorgaben = {"IO": "MA0", "VERW": "ILS-KR-MA0", "BEZEICHNUNG": "Motor MA0", "ARTINR": "1"}
assert hs.attribut_werte(vorgaben, "MA1@@@") == {
"IO": "MA1@@@", "VERW": "ILS-KR-MA1@@@", "BEZEICHNUNG": "Motor MA1@@@"}
assert hs.attribut_werte({"IO": ""}, "BX1@@@") == {"IO": "BX1@@@"}
def test_symbol_vorgaben_vorlage_und_symbol():
werte = hs.symbol_vorgaben(KONFIG["symbole"]["SYM_SENSOR"], KONFIG["vorlagen"]["klein"])
assert werte == {"IO": "BG0", "VERW": "In Sep", "BEZEICHNUNG": "", "REALE_POSITION": "x"}
def test_block_aufbauen_aus_konfiguration():
doc = ezdxf.new("R2018")
hs.block_aufbauen(doc, "SYM_VENTIL", KONFIG)
blk = doc.blocks.get("SYM_VENTIL")
rahmen = blk.query("LWPOLYLINE")[0]
assert rahmen.dxf.layer == "ILS_Ausgang" and rahmen.closed
attdefs = {a.dxf.tag: a for a in blk.query("ATTDEF")}
assert list(attdefs) == ["IO", "VERW", "BEZEICHNUNG", "REALE_POSITION"]
assert attdefs["IO"].dxf.text == "MB0" and attdefs["IO"].dxf.color == 3
assert attdefs["BEZEICHNUNG"].is_invisible
assert attdefs["REALE_POSITION"].dxf.text == "x"
assert "ARIAL" in doc.styles
# Erneuter Aufbau ersetzt den Inhalt statt ihn zu verdoppeln
hs.block_aufbauen(doc, "SYM_VENTIL", KONFIG)
assert len(doc.blocks.get("SYM_VENTIL").query("ATTDEF")) == 4
def test_symbole_setzen_und_nur_eigene_praefixe_ersetzen():
doc = ezdxf.new("R2018")
doc.modelspace().add_line((0, 0), (1, 1), dxfattribs={"layer": "HW_Materialfluss"})
plan = _plan(["MB", "BG"], [_sym("MB", "SYM_VENTIL", 0, 0),
_sym("BG", "SYM_SENSOR", 0, 350),
_sym("MB", "FEHLT", 9, 9)])
gesetzt, entfernt, fehlend, ueberlappt = hs.symbole_setzen(doc, plan, KONFIG)
assert gesetzt == {"MB": 1, "BG": 1} and entfernt == 0 and fehlend == ["FEHLT"]
assert ueberlappt == 0
ventil = doc.modelspace().query('INSERT[name=="SYM_VENTIL"]')[0]
attr = {a.dxf.tag: a.dxf.text for a in ventil.attribs}
assert ventil.dxf.layer == "ILS_Ausgang"
assert attr["IO"] == "MB1@@@" and attr["BEZEICHNUNG"] == "Separator (MB1@@@)"
assert attr["REALE_POSITION"] == "x"
assert hs.symbol_xdata(ventil)[:2] == ("MB", "r")
# Nur Ventile neu: Sensor und fremde Geometrie bleiben stehen
gesetzt, entfernt, _, _ = hs.symbole_setzen(
doc, _plan(["MB"], [_sym("MB", "SYM_VENTIL", 100, 0),
_sym("BG", "SYM_SENSOR", 100, 0)]), KONFIG)
msp = doc.modelspace()
assert entfernt == 1 and gesetzt == {"MB": 1}
assert len(msp.query('INSERT[name=="SYM_VENTIL"]')) == 1
assert len(msp.query('INSERT[name=="SYM_SENSOR"]')) == 1
assert len(msp.query("LINE")) == 1
def test_echte_konfiguration_deckt_regelwerk_ab():
"""Jedes Symbol aus cfg/io_regeln.json ist in cfg/hw_symbole.json definiert,
jede Vorlage existiert, und jedes Symbol baut sich fehlerfrei auf."""
with open(os.path.join(ROOT, "cfg", "io_regeln.json"), encoding="utf-8") as fh:
regeln = json.load(fh)
with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh:
konfig = json.load(fh)
bloecke = set()
for r in regeln["rollen"]:
bloecke.add(r["block"])
bloecke.update(v["block"] for v in r.get("block_nach_name", []))
assert bloecke - set(konfig["symbole"]) == set()
doc = ezdxf.new("R2018")
for name, sdef in konfig["symbole"].items():
assert sdef["vorlage"] in konfig["vorlagen"]
assert "IO" in sdef["werte"]
hs.block_aufbauen(doc, name, konfig)
def _renamer_pfad(doc, punkte):
"""Renamer-Block mit Polylinie (POLYLINE_PATH) fuer BG und MB."""
blk = doc.blocks.new("RENAMER_TEST")
blk.add_lwpolyline(punkte)
werte = {"NAME1": "BG-1@@@", "LAYER_NAME1": "ILS_Eingang",
"NAME2": "MB-1@@@", "LAYER_NAME2": "ILS_Ausgang",
"KENNZEICHNUNG": "=A01+UC0100", "DIRECTION": "POLYLINE_PATH"}
for i, tag in enumerate(werte):
blk.add_attdef(tag, (0, -100 * i))
if "ILS_RENAMER" not in doc.layers:
doc.layers.add("ILS_RENAMER")
ref = doc.modelspace().add_blockref("RENAMER_TEST", (0, 0), dxfattribs={"layer": "ILS_RENAMER"})
ref.add_auto_attribs(werte)
def test_kette_symbole_renamer_nummerierung(tmp_path):
symbole = tmp_path / "hw_symbole.json"
symbole.write_text(json.dumps(KONFIG), encoding="utf-8")
hw = str(tmp_path / "Anlage_HW.dxf")
plan_pfad = tmp_path / "plan.json"
# Zwei Separatoren (Ziel auf y=0), der rechte zuerst im Plan - nummeriert
# wird entlang der Polylinie
plan_pfad.write_text(json.dumps(_plan(["MB", "BG"], [
_sym("MB", "SYM_VENTIL", 5000, 0),
_sym("BG", "SYM_SENSOR", 5000, 0),
_sym("MB", "SYM_VENTIL", 1000, 0),
_sym("BG", "SYM_SENSOR", 1000, 0)])), encoding="utf-8")
assert hs.main([str(plan_pfad), hw, "--symbole", str(symbole)]) == 0
doc = ezdxf.readfile(hw)
# create_numbers verlangt fuer POLYLINE_PATH mind. 4 Punkte und kein
# Rechteck (offen mit genau 4 Punkten gilt als Rechteck) - also 5 Punkte
_renamer_pfad(doc, [(-1000, 0), (1000, 0), (3000, 0), (5000, 0), (7000, 0)])
doc.saveas(hw)
cfg = tmp_path / "enumerate.cfg"
cfg.write_text("[Layers]\nILS_RENAMER\n[Parameters]\npolyline_max_distance = 500.0\n",
encoding="utf-8")
info = tmp_path / "info.json"
rc = create_numbers.main(["--filename", hw, "--config", str(cfg), "--dryrun",
"--info", str(info), "--errorfile", str(tmp_path / "err.json")])
assert rc == 0
umbenannt = json.loads(info.read_text(encoding="utf-8"))["renamed_symbols"]
# Einfuegepunkt = Ziel - Marke (715): links das Paar am Ziel x=1000
links = {r["new_value"] for r in umbenannt if round(r["position"]["x"]) == 1000 - 715}
rechts = {r["new_value"] for r in umbenannt if round(r["position"]["x"]) == 5000 - 715}
assert {"MB1001", "BG1002"} <= links or {"BG1001", "MB1002"} <= links
assert {"MB1003", "BG1004"} <= rechts or {"BG1003", "MB1004"} <= rechts
assert any(r["new_value"] in ("Separator (MB1001)", "Separator (MB1002)") for r in umbenannt)
# Katalog der ILS-Symbole im Nachbar-Repo kabellaengen (Quelle von hw_symbole.json)
KATALOG = os.environ.get("ILS_SYMBOLE_KATALOG", os.path.join(
ROOT, "..", "kabellaengen", "doc", "Symbole", "ILS-Symbole.dxf"))
def _signatur(blk):
"""Vergleichbare Beschreibung einer Blockdefinition: Rahmen, Schraffur, ATTDEFs."""
attdefs = {}
for a in blk.query("ATTDEF"):
h, v = a.dxf.get("halign", 0), a.dxf.get("valign", 0)
p = a.dxf.align_point if (h or v) else a.dxf.insert
attdefs[a.dxf.tag] = {
"x": round(p.x, 1), "y": round(p.y, 1), "hoehe": a.dxf.height,
"unsichtbar": a.is_invisible, "layer": a.dxf.layer, "ausrichtung": (h, v),
"stil": a.dxf.get("style", "ARIAL"), "breite": round(a.dxf.get("width", 1.0), 2),
"farbe": a.dxf.get("color", 256) or 256, "text": a.dxf.text}
rahmen = sorted({(round(p[0], 1), round(p[1], 1))
for e in blk.query("LWPOLYLINE") for p in e.get_points()})
schraffur = [(h.dxf.pattern_name, h.dxf.pattern_scale, h.dxf.layer, h.dxf.get("color", 256))
for h in blk.query("HATCH")]
return rahmen, schraffur, attdefs
@pytest.mark.skipif(not os.path.isfile(KATALOG), reason="kabellaengen-Katalog nicht gefunden")
def test_symbole_gleichen_dem_katalog():
"""Die aus cfg/hw_symbole.json gebauten Symbole tragen dieselben Blocknamen
wie die Katalogsymbole in kabellaengen - sie muessen darum auch gleich
aussehen (Einfuegepunkt, Rahmen, Lage/Hoehe/Stil aller Attribute, Werte),
sonst gilt beim Mischen beider Fassungen still die falsche."""
with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh:
konfig = json.load(fh)
katalog = ezdxf.readfile(KATALOG)
doc = ezdxf.new("R2018")
abweichungen = []
for name in konfig["symbole"]:
hs.block_aufbauen(doc, name, konfig)
soll_r, soll_s, soll_a = _signatur(katalog.blocks.get(name))
ist_r, ist_s, ist_a = _signatur(doc.blocks.get(name))
if soll_r != ist_r:
abweichungen.append(f"{name}: Rahmen {soll_r} != {ist_r}")
if soll_s != ist_s:
abweichungen.append(f"{name}: Schraffur {soll_s} != {ist_s}")
for tag in sorted(set(soll_a) | set(ist_a)):
s, i = soll_a.get(tag), ist_a.get(tag)
if s is None or i is None:
abweichungen.append(f"{name}.{tag}: nur im {'Katalog' if i is None else 'eigenen Symbol'}")
continue
for k in s:
gleich = abs(s[k] - i[k]) <= 0.11 if k in ("x", "y") else s[k] == i[k]
if not gleich:
abweichungen.append(f"{name}.{tag}.{k}: Katalog {s[k]!r}, eigen {i[k]!r}")
assert abweichungen == []
# ------------------------------------------------------------------
# Platzierung
# ------------------------------------------------------------------
def _geo(x0, y0, x1, y1, mx, my):
return {"box": (x0, y0, x1, y1), "marke": (mx, my)}
OBEN = _geo(0, 0, 750, 270, 750, 135) # wie Katalog-Ausgang
UNTEN = _geo(0, -270, 750, 0, 750, -135) # wie Katalog-Eingang (haengt nach unten)
def test_symbol_geometrie_aus_konfiguration():
with open(os.path.join(ROOT, "cfg", "hw_symbole.json"), encoding="utf-8") as fh:
konfig = json.load(fh)
assert hs.symbol_geometrie(konfig, "ILS_Separator-Ausgang") == {
"box": (0.0, 0.0, 750.0, 270.0), "marke": (750.0, 135.0)}
assert hs.symbol_geometrie(konfig, "ILS_720002003-Separator") == {
"box": (0.0, -270.0, 750.0, 0.0), "marke": (750.0, -135.0)}
def test_platzieren_einzeln_marke_auf_ziel():
(punkte,), ueber = hs.platzieren([((1000.0, 500.0), [OBEN])])
assert ueber == 0
ex, ey = punkte[0]
assert (ex + 750, ey + 135) == (1000.0, 500.0) # Marke genau auf dem Ziel
(punkte,), _ = hs.platzieren([((1000.0, 500.0), [UNTEN])])
assert (punkte[0][0] + 750, punkte[0][1] - 135) == (1000.0, 500.0)
def test_platzieren_paar_uebereinander():
(punkte,), ueber = hs.platzieren([((1000.0, 0.0), [OBEN, UNTEN])])
assert ueber == 0
(x1, y1), (x2, y2) = punkte
# Marken beide mit x auf dem Ziel, Kaesten links daneben, luekenlos
# uebereinander und zusammen um das Ziel zentriert
assert x1 + 750 == 1000.0 and x2 + 750 == 1000.0
assert (y1, y1 + 270) == (0.0, 270.0) # oberer Kasten 0..270
assert (y2 - 270, y2) == (-270.0, 0.0) # unterer Kasten -270..0
def test_platzieren_weicht_in_y_aus():
gruppen = [((1000.0, 0.0), [OBEN, UNTEN]), ((1200.0, 100.0), [OBEN])]
(paar, (einzel,)), ueber = hs.platzieren(gruppen, abstand=20.0, schritt=50.0)
assert ueber == 0
assert einzel[0] + 750 == 1200.0 # x bleibt auf dem Ziel
unten, oben = einzel[1], einzel[1] + 270
assert oben <= -270 - 20 or unten >= 270 + 20 # frei von Paar-Kasten -270..270
def test_platzieren_meldet_ueberlappung_ohne_platz():
gruppen = [((0.0, 0.0), [OBEN]), ((0.0, 0.0), [OBEN])]
_punkte, ueber = hs.platzieren(gruppen, schritt=50.0, max_verschiebung=100.0)
assert ueber == 1