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>
280 lines
12 KiB
Python
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
|