Files
sps_skel/tests/test_hw_import.py
T
Samer AyadiandClaude Opus 5.5 df24cf5adb hw_import + merge_hw_layout: Hardwareplanung einlesen und mit dem Layout zusammenfuehren
Stufen 2 und 3 aus doc/Konzept_Layout_Hardware_SCL.md:

- lib/hw_import.py: TIA-Import (*_TIA.xlsx, ohne openpyxl gelesen) +
  Positionsdatei (*_positionsconv.json) -> <projekt>_hw.json: je BMK
  Adresse, Index-Konstante, VERW, Station, Lage, Foerderer (K0102, #106 ->
  K0106); Antriebe MA/FC/GF; Gegenpruefung beider Quellen.
- lib/merge_hw_layout.py: <csv>_tro.json + <projekt>_hw.json ->
  <csv>_anlage.json (model.AnlageDocument): bindings je TRO-ID + Slot +
  Rolle mit Konstante und Adresse, Foerderer per Mehrheit (Mubea: K0102 =
  Kreisel3, K0104 = Kreisel2, K0105 = Kreisel1), Handkorrekturen
  cfg/hw_overrides.ini. Zuordnungslogik aus e_planning.py hierher
  verschoben; e_planning re-exportiert sie, Ergebnis unveraendert.
- TRO-JSON traegt optional `parts` (Lage/Huellbox, tro_flow --json),
  PinStore bekommt Slots line<n>.
- Gespeicherter Lagebezug mit SHA-256 der Positionsdatei; Unterordner in
  Eingabenamen relativ zu SKEL_DATA.
- Doku: Python_Scripts.md 5d/5e, Konzept, E-Planung_Bruecke (Abgleich
  mubea_layout.dxf / CSV / E-Planung).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:16:10 +02:00

280 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""Tests fuer lib/hw_import.py - TIA-Import + Positionsdatei -> HardwareDocument.
Die TIA-Datei wird hier als kleine xlsx gebaut (zipfile, wie sie der
TIA-Export schreibt: Blatt "PLC Tags" und "Constants"); Namen, Adressen und
Klartexte nach dem Mubea-Datensatz (data/Mubea/mubea-1_TIA.xlsx).
"""
import json
import zipfile
from pathlib import Path
import pytest
import hw_import
from hw_import import (
HardwareDocument,
build_hardware,
conveyor_kind_of,
conveyor_of,
load_tia,
project_name,
read_xlsx,
)
_HEAD = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<{tag} xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
def _col(i: int) -> str:
out = ""
i += 1
while i:
i, r = divmod(i - 1, 26)
out = chr(65 + r) + out
return out
def write_xlsx(path: Path, sheets: dict[str, list[list]], shared: bool = True) -> Path:
"""Minimale xlsx: Texte als Shared Strings (oder inline), Zahlen als Werte."""
strings: list[str] = []
files = {}
for n, (name, rows) in enumerate(sheets.items(), start=1):
xml_rows = []
for r, row in enumerate(rows, start=1):
cells = []
for c, value in enumerate(row):
ref = f"{_col(c)}{r}"
if value is None:
continue
if isinstance(value, (int, float)):
cells.append(f'<c r="{ref}"><v>{value}</v></c>')
elif shared:
if value not in strings:
strings.append(value)
cells.append(f'<c r="{ref}" t="s"><v>{strings.index(value)}</v></c>')
else:
cells.append(f'<c r="{ref}" t="inlineStr"><is><t>{value}</t></is></c>')
xml_rows.append(f'<row r="{r}">{"".join(cells)}</row>')
files[f"xl/worksheets/sheet{n}.xml"] = (
_HEAD.format(tag="worksheet") + f'<sheetData>{"".join(xml_rows)}</sheetData></worksheet>')
sheet_xml = "".join(f'<sheet name="{name}" sheetId="{n}" r:id="rId{n}"/>'
for n, name in enumerate(sheets, start=1))
files["xl/workbook.xml"] = _HEAD.format(tag="workbook") + f"<sheets>{sheet_xml}</sheets></workbook>"
rels = "".join(f'<Relationship Id="rId{n}" Type="worksheet" Target="worksheets/sheet{n}.xml"/>'
for n in range(1, len(sheets) + 1))
files["xl/_rels/workbook.xml.rels"] = (
'<?xml version="1.0" encoding="UTF-8"?><Relationships '
'xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
f"{rels}</Relationships>")
if shared and strings:
si = "".join(f"<si><t>{s}</t></si>" for s in strings)
files["xl/sharedStrings.xml"] = _HEAD.format(tag="sst") + f"{si}</sst>"
with zipfile.ZipFile(path, "w") as z:
for name, text in files.items():
z.writestr(name, text)
return path
TAG_HEADER = ["Name", "Path", "Data Type", "Logical Address", "Comment", "Hmi Visible"]
CONST_HEADER = ["Name", "Path", "Data Type", "Value", "Comment"]
def tia_file(tmp_path: Path, extra_tags=(), extra_consts=()) -> Path:
tags = [
["BG0532", "Import", "Bool", "%E053.2", "SENSOR IN SEP KR-K0102", "True"],
["MB0533", "Import", "Bool", "%A053.3", "STOPPER KR-K0102", "True"],
["MA0202", "Import", "Bool", "%A020.2", "ANTRIEB KREISEL-K0102", "True"],
["FC0202", "Import", "Bool", "%E020.2", "MOTORSCHUTZ KREISEL-K0102", "True"],
["GF20000", "Import", "Bool", "%A2000.0", "INKREMENTALGEBER KREISEL K0102 (M0205)", "True"],
["SF0001", "Import", "Bool", "%E000.1", "ANLAGE EIN", "True"],
["BG0999", "Import", "Bool", "%E099.0", "NUR IM TIA", "True"],
*extra_tags,
]
consts = [
["cInBG0532", "Import", "Int", 0],
["cOutMB0533", "Import", "Int", 0],
["cOutMA0202", "Import", "Int", 1],
["cInFC0202", "Import", "Int", 1],
["cOutGF20000", "Import", "Int", 2],
["cInSF0001", "Import", "Int", 2],
["cInBG0999", "Import", "Int", 3],
*extra_consts,
]
return write_xlsx(tmp_path / "x_TIA.xlsx",
{"PLC Tags": [TAG_HEADER, *tags], "Constants": [CONST_HEADER, *consts]})
def positions_file(tmp_path: Path) -> Path:
data = {
"sensors": {
"BG0532@1": {"IO": "BG0532", "VERW": "IN SEP", "BEZEICHNUNG": "SENSOR IN SEP KR-K0102",
"KENNZEICHNUNG": "=A1+UH01-KF102", "pos": [100.0, 200.0]},
"MB0533@1": {"IO": "MB0533", "VERW": "SEP", "BEZEICHNUNG": "STOPPER KR-K0102",
"KENNZEICHNUNG": "=A1+UH01-KF102", "pos": [100.0, 470.0]},
"BG0514@1": {"IO": "BG0514", "VERW": "KETTENBRUCH",
"BEZEICHNUNG": "SENSOR KETTENBRUCH CONVEYOR #101", "KENNZEICHNUNG": "",
"pos": [5.0, 5.0]},
},
"schaltschrank_elemente": {
"MA0202@1": {"IO": "MA0202", "VERW": "MOT", "BEZEICHNUNG": "ANTRIEB KREISEL-K0102",
"KENNZEICHNUNG": "=A1+UH01-KF1DQ3"},
"FC0202@1": {"IO": "FC0202", "BEZEICHNUNG": "MOTORSCHUTZ KREISEL-K0102",
"KENNZEICHNUNG": "=A1+UH01-KF1DI2"},
"QA7000@A/B": {"IO": "QA7000", "VERW": "IL-YE-LED", "BEZEICHNUNG": "HAUPTSCHUETZE",
"KENNZEICHNUNG": "=A1+UH01-KF1FDQ1"},
"SF0001@1": {"IO": "SF0001", "BEZEICHNUNG": "ANLAGE EIN"},
"GF20000@1": {"IO": "GF20000",
"BEZEICHNUNG": "INKREMENTALGEBER KREISEL K0102 (M0205)"},
},
"warnings": {"mapping_warnings": {"BG0514@1": "Ungueltiger Pfad in Kennzeichnung: "}},
}
path = tmp_path / "Xproj_positionsconv.json"
path.write_text(json.dumps(data), encoding="utf-8")
return path
# ---------------------------------------------------------------------------
# xlsx und TIA-Import
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("shared", [True, False])
def test_xlsx_lesen(tmp_path, shared):
path = write_xlsx(tmp_path / "t.xlsx", {"A": [["Name", "Wert"], ["x", 3], [None, "y"]]},
shared=shared)
assert read_xlsx(path) == {"A": [["Name", "Wert"], ["x", "3"], ["", "y"]]}
def test_tia_blaetter_an_der_kopfzeile_erkannt(tmp_path):
"""Blattnamen sind egal (deutscher Export), gezaehlt wird die Kopfzeile."""
path = write_xlsx(tmp_path / "de_TIA.xlsx", {
"PLC-Variablen": [["Name", "Pfad", "Datentyp", "Logische Adresse", "Kommentar"],
["BG0532", "Import", "Bool", "%I53.2", "SENSOR"]],
"Konstanten": [["Name", "Pfad", "Datentyp", "Wert", "Kommentar"],
["cInBG0532", "Import", "Int", "7", ""]],
"Leer": [["irgendwas"]],
})
warnings: list[str] = []
tags, consts = load_tia(path, warnings)
assert [(t.name, t.address, t.comment) for t in tags] == [("BG0532", "%I53.2", "SENSOR")]
assert [(c.name, c.value) for c in consts] == [("cInBG0532", "7")]
assert any("'Leer'" in w for w in warnings)
def test_tia_ohne_tags_ist_ein_fehler(tmp_path):
path = write_xlsx(tmp_path / "x.xlsx", {"Constants": [CONST_HEADER, ["cInBG1", "", "Int", 0]]})
with pytest.raises(ValueError, match="kein Blatt mit Tags"):
load_tia(path)
# ---------------------------------------------------------------------------
# Klartext -> Foerderer
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text, name, kind", [
("ANTRIEB KREISEL-K0102", "K0102", "Kreisel"),
("PRUEFUNG BT. POS.1 (KR-K0102 EINFAHRT PUFFER)", "K0102", "Kreisel"),
# dieselbe Strecke einmal als K0103, einmal als #103
("MOTORSCHUTZ CONVEYOR-K0103", "K0103", "Strecke"),
("MOTOR TEF LBT CONVEYOR-#103", "K0103", "Strecke"),
("PIN CONVEYOR #109(EXPRESS)", "K0109", "Strecke"),
# FC0204 im Text ist kein Foerderer
("MOTORSCHUTZ FC0204 CONVEYOR #110", "K0110", "Strecke"),
("SEPARATOR STR. 14", "", ""),
])
def test_foerderer_aus_klartext(text, name, kind):
assert conveyor_of(text) == name
assert conveyor_kind_of(text) == kind
# ---------------------------------------------------------------------------
# Zusammenfuehren
# ---------------------------------------------------------------------------
def test_beide_quellen_je_bmk_zusammengefuehrt(tmp_path):
doc = build_hardware(tia_file(tmp_path), positions_file(tmp_path), "Xproj")
dev = doc.by_bmk()
s = dev["BG0532"]
assert (s.direction, s.address, s.constant, s.constant_value) == ("in", "%E053.2", "cInBG0532", 0)
assert (s.verw, s.station, s.controller, s.pos) == ("IN SEP", "KF102", "UH01", (100.0, 200.0))
assert s.sources == ["tia", "positions"]
assert (s.conveyor, s.conveyor_kind) == ("K0102", "Kreisel")
assert dev["MB0533"].constant == "cOutMB0533" and dev["MB0533"].direction == "out"
# nur in einer Quelle
assert dev["QA7000"].sources == ["positions"] and dev["QA7000"].constant == ""
assert dev["BG0999"].sources == ["tia"] and dev["BG0999"].pos is None
assert [d.bmk for d in doc.field_devices()] == ["BG0514", "BG0532", "MB0533"]
assert doc.tia_sha256 and doc.positions_sha256
assert doc.project == "Xproj"
def test_antrieb_mit_motorschutz_und_geber(tmp_path):
doc = build_hardware(tia_file(tmp_path), positions_file(tmp_path))
[drive] = doc.drives
assert (drive.motor, drive.protection, drive.conveyor, drive.conveyor_kind, drive.encoder) == (
"MA0202", "FC0202", "K0102", "Kreisel", "GF20000")
def test_gegenpruefung(tmp_path):
doc = build_hardware(
tia_file(tmp_path,
extra_tags=[["BG0533", "Import", "Bool", "%E053.2", "GLEICHE ADRESSE", ""]],
extra_consts=[["cInBG4711", "Import", "Int", 9]]),
positions_file(tmp_path))
checks = "\n".join(doc.checks)
assert "nur in der Positionsdatei, nicht im TIA-Import (2): BG0514, QA7000" in checks
assert "Feldgeraet nur im TIA-Import, nicht in der Positionsdatei (2): BG0533, BG0999" in checks
assert "Signal ohne Index-Konstante cIn/cOut (1): BG0533" in checks
assert "Index-Konstante ohne Signal (1): cInBG4711" in checks
assert "Adresse mehrfach vergeben: %E053.2" in checks
assert "Feldgeraet ohne Steuerung in KENNZEICHNUNG (1): BG0514" in checks
assert any("BG0514" in w and "mapping_warnings" in w for w in doc.warnings)
assert [c.name for c in doc.constants] == ["cInBG4711"]
def test_nur_tia_ohne_positionsdatei(tmp_path):
doc = build_hardware(tia_file(tmp_path), None)
assert doc.positions_file == "" and doc.field_devices() == []
assert all(d.sources == ["tia"] for d in doc.devices)
def test_json_rundreise(tmp_path):
doc = build_hardware(tia_file(tmp_path), positions_file(tmp_path), "Xproj")
again = HardwareDocument.from_json(doc.to_json())
assert again == doc
assert json.loads(doc.to_json())["schema"] == "sps_skel-hw-v1"
def test_projektname():
assert project_name(None, Path("Mubea_positionsconv.json")) == "Mubea"
assert project_name(None, Path("Mubea_positionsdraw.json")) == "Mubea"
assert project_name(Path("mubea-1_TIA.xlsx"), None) == "mubea-1"
def test_cli_mit_unterordner_in_skel_data(tmp_path, monkeypatch, capsys):
"""--tia Mubea/x_TIA.xlsx wird ab SKEL_DATA gefunden (Befund der Nachpruefung)."""
data = tmp_path / "data" / "Xproj"
data.mkdir(parents=True)
tia_file(data)
positions_file(data)
monkeypatch.setenv("SKEL_DATA", str(tmp_path / "data"))
monkeypatch.setenv("SKEL_RESULTS", str(tmp_path / "results"))
monkeypatch.chdir(tmp_path)
rc = hw_import.main(["--tia", "Xproj/x_TIA.xlsx", "--positions",
"Xproj/Xproj_positionsconv.json"])
assert rc == 0
written = HardwareDocument.from_json(
(tmp_path / "results" / "Xproj_hw.json").read_text(encoding="utf-8"))
assert written.project == "Xproj" and len(written.devices) == 9
assert (tmp_path / "results" / "Xproj_hw.md").is_file()
assert "HARDWAREPLANUNG EINLESEN" in capsys.readouterr().out