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>
785 lines
31 KiB
Python
785 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
|
|
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
|
|
"""
|
|
hw_import.py - Hardwareplanung einlesen: TIA-Import + Positionsdatei -> <projekt>_hw.json.
|
|
|
|
Stufe 2 des Ablaufs in doc/Konzept_Layout_Hardware_SCL.md. Zwei Quellen
|
|
genuegen (Befund doc/E-Planung_Bruecke.md, "Nachpruefung 2026-10-15"):
|
|
|
|
* **TIA-Import** ``*_TIA.xlsx`` - Blatt "PLC Tags" (Name, Datentyp,
|
|
Absolutadresse, Kommentar) und Blatt "Constants" (Index-Konstante
|
|
``cIn<BMK>``/``cOut<BMK>`` je Signal). Das ist genau das, was im SCL steht
|
|
("DB_Inputs".Sensors["cInBG0532"], "MB0533") und im TIA-Projekt existieren
|
|
muss - deshalb die erste Quelle und die Pruefliste fuer das SCL.
|
|
* **Positionsdatei** ``*_positionsconv.json`` / ``*_positionsdraw.json`` des
|
|
Kabellaengen-Tools (optional) - je BMK die Lage in der E-Zeichnung, die
|
|
Verwendung (VERW, z. B. "IN SEP"), Klartext und E/A-Station.
|
|
|
|
EA-Liste und WSCAD-Import bringen bei vorhandener Positionsdatei nichts
|
|
dazu und werden nicht gelesen.
|
|
|
|
Ergebnis ``HardwareDocument`` (Schema sps_skel-hw-v1): je BMK ein Geraet mit
|
|
beiden Seiten, die Antriebe (Motor MA <-> Motorschutz FC, Geber GF) je
|
|
Foerderer der E-Planung (K0102; "#106" wird K0106), und die Gegenpruefung
|
|
beider Quellen.
|
|
Die Zuordnung zum Layout macht merge_hw_layout.py.
|
|
|
|
Die xlsx-Datei wird ohne Fremdbibliothek gelesen (zipfile + XML): der
|
|
TIA-Import ist eine einfache Tabelle, openpyxl waere eine Abhaengigkeit nur
|
|
dafuer.
|
|
|
|
Aufruf ueber bin/hw_import.bat bzw. bin/hw_import.sh.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import zipfile
|
|
import xml.etree.ElementTree as ET
|
|
from collections import Counter
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from dxf_registration import file_fingerprint
|
|
|
|
__all__ = [
|
|
"HW_SCHEMA",
|
|
"EDevice",
|
|
"Drive",
|
|
"TiaTag",
|
|
"TiaConstant",
|
|
"HwDevice",
|
|
"HardwareDocument",
|
|
"read_xlsx",
|
|
"load_tia",
|
|
"load_positionsdraw",
|
|
"pair_drives",
|
|
"conveyor_of",
|
|
"conveyor_kind_of",
|
|
"build_hardware",
|
|
]
|
|
|
|
HW_SCHEMA = "sps_skel-hw-v1"
|
|
|
|
_CONFIG = ConfigDict(extra="forbid", populate_by_name=True)
|
|
|
|
# "=A1+UH01-KF107" -> Ort/Steuerung "UH01", Geraet "KF107"
|
|
_KENNZEICHNUNG = re.compile(r"\+([A-Za-z0-9]+)-([A-Za-z0-9]+)")
|
|
# "MB0643" -> ("MB", "0643")
|
|
_BMK = re.compile(r"^([A-Za-z]+)(\d+)$")
|
|
# "%E053.2" / "%I53.2" / "%A020.0" / "%Q20.0"
|
|
_ADDRESS = re.compile(r"^%?([EIAQM])\s*(\d+)\.(\d)$", re.IGNORECASE)
|
|
# Foerderer im Klartext der E-Planung: "KREISEL-K0102", "KR-K0104", "K0105"
|
|
_CONVEYOR_K = re.compile(r"(?<![A-Z0-9])K(\d{4})(?!\d)")
|
|
# ... bzw. "CONVEYOR #100", "CONVEYOR-#106", "CONVEYOR#110"
|
|
_CONVEYOR_HASH = re.compile(r"#\s*(\d{3})(?!\d)")
|
|
# Art des Foerderers im Klartext: "KREISEL-K0102", "KR-K0104" -> Kreisel;
|
|
# "CONVEYOR #106", "KLINKENFOERDERER" -> Strecke
|
|
_KIND_KREISEL = re.compile(r"(?<![A-Z])(KREISEL|KR)(?![A-Z])")
|
|
_KIND_STRECKE = re.compile(r"CONVEYOR|CONVEYER|F(OE|Ö)RDERER")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Modell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class EDevice(BaseModel):
|
|
"""Ein Sensor/Aktor/Schrankelement der E-Planung (Positionsdatei)."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
bmk: str
|
|
verw: str = ""
|
|
bezeichnung: str = ""
|
|
kennzeichnung: str = ""
|
|
# Position in der E-Zeichnung (mm); None bei Schrankelementen ohne Lage
|
|
pos: tuple[float, float] | None = None
|
|
# True: aus "schaltschrank_elemente" (im Schrank, nicht im Feld)
|
|
cabinet: bool = False
|
|
|
|
@property
|
|
def controller(self) -> str:
|
|
"""Steuerung/Ort aus der Kennzeichnung ("UH01"), "" wenn unbekannt."""
|
|
m = _KENNZEICHNUNG.search(self.kennzeichnung)
|
|
return m.group(1) if m else ""
|
|
|
|
@property
|
|
def station(self) -> str:
|
|
"""E/A-Station aus der Kennzeichnung ("KF107"), "" wenn unbekannt."""
|
|
m = _KENNZEICHNUNG.search(self.kennzeichnung)
|
|
return m.group(2) if m else ""
|
|
|
|
@property
|
|
def prefix(self) -> str:
|
|
m = _BMK.match(self.bmk)
|
|
return m.group(1) if m else ""
|
|
|
|
@property
|
|
def number(self) -> int | None:
|
|
m = _BMK.match(self.bmk)
|
|
return int(m.group(2)) if m else None
|
|
|
|
|
|
class Drive(BaseModel):
|
|
"""Motor und sein Motorschutz (gleiche Nummer), dazu Foerderer und Geber."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
motor: str
|
|
protection: str = ""
|
|
bezeichnung: str = ""
|
|
protection_bezeichnung: str = ""
|
|
# Foerderer der E-Planung aus dem Klartext ("K0102", "#106" -> "K0106")
|
|
conveyor: str = ""
|
|
# "Kreisel" | "Strecke" | "" aus dem Klartext (conveyor_kind_of)
|
|
conveyor_kind: str = Field(default="", alias="conveyorKind")
|
|
# Inkrementalgeber (GF...) desselben Foerderers
|
|
encoder: str = ""
|
|
|
|
|
|
class TiaTag(BaseModel):
|
|
"""Eine Zeile aus dem Blatt "PLC Tags" des TIA-Imports."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
name: str
|
|
data_type: str = Field(default="", alias="dataType")
|
|
address: str = ""
|
|
comment: str = ""
|
|
|
|
|
|
class TiaConstant(BaseModel):
|
|
"""Eine Zeile aus dem Blatt "Constants" des TIA-Imports."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
name: str
|
|
data_type: str = Field(default="", alias="dataType")
|
|
value: str = ""
|
|
comment: str = ""
|
|
|
|
|
|
class HwDevice(EDevice):
|
|
"""
|
|
Ein Geraet der Hardwareplanung: Positionsdatei und TIA-Import zusammen.
|
|
|
|
Die Felder aus EDevice kommen aus der Positionsdatei (leer, wenn das BMK
|
|
dort fehlt), Adresse/Konstante aus dem TIA-Import (leer, wenn es dort
|
|
fehlt). ``sources`` sagt, wo das BMK steht.
|
|
"""
|
|
|
|
# "in" | "out" | "" (Merker, unbekannt)
|
|
direction: str = ""
|
|
address: str = ""
|
|
data_type: str = Field(default="", alias="dataType")
|
|
comment: str = ""
|
|
# Index-Konstante fuer "DB_Inputs".Sensors[...] bzw. Ausgaenge
|
|
constant: str = ""
|
|
constant_value: int | None = Field(default=None, alias="constantValue")
|
|
# Foerderer der E-Planung aus Bezeichnung/Kommentar ("K0102"; "#106" -> "K0106")
|
|
conveyor: str = ""
|
|
# "Kreisel" | "Strecke" | "" - nur aus dem eigenen Klartext
|
|
conveyor_kind: str = Field(default="", alias="conveyorKind")
|
|
# "tia", "positions"
|
|
sources: list[str] = Field(default_factory=list)
|
|
|
|
@property
|
|
def in_tia(self) -> bool:
|
|
return "tia" in self.sources
|
|
|
|
|
|
class HardwareDocument(BaseModel):
|
|
"""Die Hardware-JSON ``<projekt>_hw.json``."""
|
|
|
|
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
|
|
schema_id: str = Field(default=HW_SCHEMA, alias="schema")
|
|
created: str = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M"))
|
|
project: str = ""
|
|
tia_file: str = Field(default="", alias="tiaFile")
|
|
tia_sha256: str = Field(default="", alias="tiaSha256")
|
|
positions_file: str = Field(default="", alias="positionsFile")
|
|
positions_sha256: str = Field(default="", alias="positionsSha256")
|
|
devices: list[HwDevice] = Field(default_factory=list)
|
|
drives: list[Drive] = Field(default_factory=list)
|
|
# Konstanten ohne passendes Signal (z. B. Projektkonstanten)
|
|
constants: list[TiaConstant] = Field(default_factory=list)
|
|
# Gegenpruefung TIA <-> Positionsdatei (je Befund eine Zeile)
|
|
checks: list[str] = Field(default_factory=list)
|
|
# Hinweise beim Einlesen (auch die der Positionsdatei selbst)
|
|
warnings: list[str] = Field(default_factory=list)
|
|
|
|
def by_bmk(self) -> dict[str, HwDevice]:
|
|
return {d.bmk: d for d in self.devices}
|
|
|
|
def field_devices(self) -> list[HwDevice]:
|
|
"""Geraete im Feld mit Lage in der E-Zeichnung."""
|
|
return [d for d in self.devices if d.pos is not None and not d.cabinet]
|
|
|
|
def to_json(self) -> str:
|
|
return self.model_dump_json(by_alias=True, indent=2) + "\n"
|
|
|
|
@classmethod
|
|
def from_json(cls, text: str) -> HardwareDocument:
|
|
return cls.model_validate_json(text)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# xlsx lesen (ohne openpyxl)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_NS = {
|
|
"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
|
|
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
}
|
|
_T = "{%s}t" % _NS["m"]
|
|
|
|
|
|
def _column(ref: str) -> int:
|
|
"""'C12' -> 2 (Spalte, 0-basiert)."""
|
|
n = 0
|
|
for ch in re.match(r"[A-Z]+", ref).group():
|
|
n = n * 26 + ord(ch) - 64
|
|
return n - 1
|
|
|
|
|
|
def read_xlsx(path: Path) -> dict[str, list[list[str]]]:
|
|
"""Alle Blaetter einer xlsx-Datei als Zeilen von Texten ("" = leer).
|
|
|
|
Reicht fuer Tabellen-Exporte (TIA, EA-Liste): Werte, keine Formeln,
|
|
keine Formate. Leere Zeilen fallen weg.
|
|
"""
|
|
with zipfile.ZipFile(path) as z:
|
|
shared: list[str] = []
|
|
if "xl/sharedStrings.xml" in z.namelist():
|
|
root = ET.fromstring(z.read("xl/sharedStrings.xml"))
|
|
shared = ["".join(t.text or "" for t in si.iter(_T))
|
|
for si in root.findall("m:si", _NS)]
|
|
workbook = ET.fromstring(z.read("xl/workbook.xml"))
|
|
rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
|
|
targets = {r.get("Id"): r.get("Target") for r in rels}
|
|
sheets: dict[str, list[list[str]]] = {}
|
|
for sheet in workbook.find("m:sheets", _NS):
|
|
target = targets[sheet.get("{%s}id" % _NS["r"])].lstrip("/")
|
|
target = target if target.startswith("xl/") else "xl/" + target
|
|
rows = []
|
|
for row in ET.fromstring(z.read(target)).iter("{%s}row" % _NS["m"]):
|
|
cells: dict[int, str] = {}
|
|
for c in row.findall("m:c", _NS):
|
|
v = c.find("m:v", _NS)
|
|
kind = c.get("t")
|
|
if kind == "s" and v is not None:
|
|
text = shared[int(v.text)]
|
|
elif kind == "inlineStr":
|
|
text = "".join(t.text or "" for t in c.iter(_T))
|
|
else:
|
|
text = v.text if v is not None and v.text is not None else ""
|
|
if text != "":
|
|
cells[_column(c.get("r"))] = text
|
|
if cells:
|
|
rows.append([cells.get(i, "") for i in range(max(cells) + 1)])
|
|
sheets[sheet.get("name")] = rows
|
|
return sheets
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TIA-Import
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Spaltenkoepfe im englischen und deutschen TIA-Export
|
|
_TAG_HEADERS = {
|
|
"name": ("Name",),
|
|
"data_type": ("Data Type", "Datentyp"),
|
|
"address": ("Logical Address", "Logische Adresse", "Adresse"),
|
|
"comment": ("Comment", "Kommentar"),
|
|
}
|
|
_CONST_HEADERS = {
|
|
"name": ("Name",),
|
|
"data_type": ("Data Type", "Datentyp"),
|
|
"value": ("Value", "Wert"),
|
|
"comment": ("Comment", "Kommentar"),
|
|
}
|
|
|
|
|
|
def _columns(header: list[str], wanted: dict[str, tuple[str, ...]]) -> dict[str, int] | None:
|
|
"""Spaltennummern nach Kopfzeile; None, wenn "name" fehlt."""
|
|
lower = [h.strip().lower() for h in header]
|
|
found = {}
|
|
for key, names in wanted.items():
|
|
for n in names:
|
|
if n.lower() in lower:
|
|
found[key] = lower.index(n.lower())
|
|
break
|
|
return found if "name" in found else None
|
|
|
|
|
|
def _cell(row: list[str], index: int | None) -> str:
|
|
return row[index].strip() if index is not None and index < len(row) else ""
|
|
|
|
|
|
def load_tia(path: Path, warnings: list[str] | None = None
|
|
) -> tuple[list[TiaTag], list[TiaConstant]]:
|
|
"""Tags und Konstanten aus einem TIA-Import (xlsx).
|
|
|
|
Die Blaetter werden an der Kopfzeile erkannt, nicht am Namen: ein Blatt
|
|
mit Adressspalte sind Tags, eines mit Wertspalte Konstanten.
|
|
"""
|
|
warnings = warnings if warnings is not None else []
|
|
tags: list[TiaTag] = []
|
|
constants: list[TiaConstant] = []
|
|
for sheet, rows in read_xlsx(path).items():
|
|
if not rows:
|
|
continue
|
|
tag_cols = _columns(rows[0], _TAG_HEADERS)
|
|
const_cols = _columns(rows[0], _CONST_HEADERS)
|
|
if tag_cols and "address" in tag_cols:
|
|
for row in rows[1:]:
|
|
name = _cell(row, tag_cols["name"])
|
|
if name:
|
|
tags.append(TiaTag(
|
|
name=name, data_type=_cell(row, tag_cols.get("data_type")),
|
|
address=_cell(row, tag_cols.get("address")),
|
|
comment=_cell(row, tag_cols.get("comment"))))
|
|
elif const_cols and "value" in const_cols:
|
|
for row in rows[1:]:
|
|
name = _cell(row, const_cols["name"])
|
|
if name:
|
|
constants.append(TiaConstant(
|
|
name=name, data_type=_cell(row, const_cols.get("data_type")),
|
|
value=_cell(row, const_cols.get("value")),
|
|
comment=_cell(row, const_cols.get("comment"))))
|
|
else:
|
|
warnings.append(f"TIA-Import: Blatt '{sheet}' weder Tags noch Konstanten "
|
|
f"- uebersprungen")
|
|
if not tags:
|
|
raise ValueError(f"{path.name}: kein Blatt mit Tags (Spalte 'Logical Address' "
|
|
f"bzw. 'Logische Adresse') gefunden")
|
|
return tags, constants
|
|
|
|
|
|
def _direction(address: str) -> str:
|
|
m = _ADDRESS.match(address.strip())
|
|
if not m:
|
|
return ""
|
|
area = m.group(1).upper()
|
|
return "in" if area in "EI" else "out" if area in "AQ" else ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Positionsdatei (Kabellaengen-Tool)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _device(key: str, raw: dict, cabinet: bool) -> EDevice:
|
|
# Schluessel "BG0642@UNKNOWN" / "QA7000@A/B": vor dem @ steht das BMK.
|
|
bmk = str(raw.get("IO") or raw.get("B") or key.split("@", 1)[0]).strip()
|
|
pos = raw.get("pos")
|
|
return EDevice(
|
|
bmk=bmk,
|
|
verw=str(raw.get("VERW") or "").strip(),
|
|
bezeichnung=str(raw.get("BEZEICHNUNG") or "").strip(),
|
|
kennzeichnung=str(raw.get("KENNZEICHNUNG") or "").strip(),
|
|
pos=(float(pos[0]), float(pos[1])) if pos and len(pos) >= 2 else None,
|
|
cabinet=cabinet,
|
|
)
|
|
|
|
|
|
def load_positionsdraw(path: Path, warnings: list[str] | None = None) -> list[EDevice]:
|
|
"""E-Bauteile aus einer *_positionsdraw.json / *_positionsconv.json lesen.
|
|
|
|
Ein BMK kommt hoechstens einmal vor. Steht es doppelt da (im Schrank einmal
|
|
mit und einmal ohne Attribute), gewinnt der Eintrag mit VERW.
|
|
"""
|
|
warnings = warnings if warnings is not None else []
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
devices: dict[str, EDevice] = {}
|
|
for section, cabinet in (("sensors", False), ("schaltschrank_elemente", True)):
|
|
for key, raw in (data.get(section) or {}).items():
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
dev = _device(key, raw, cabinet)
|
|
if not dev.bmk:
|
|
continue
|
|
old = devices.get(dev.bmk)
|
|
if old is None or (not old.verw and dev.verw):
|
|
devices[dev.bmk] = dev
|
|
for bmk, positions in (data.get("double_ids") or {}).items():
|
|
warnings.append(f"E-Planung: {bmk} steht {len(positions)}x in der Zeichnung "
|
|
f"- verwendet wird ein Eintrag")
|
|
return list(devices.values())
|
|
|
|
|
|
def _positions_warnings(path: Path) -> list[str]:
|
|
"""Die Hinweise, die das Kabellaengen-Tool selbst in die Datei schreibt."""
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
out = []
|
|
for kind, entries in (data.get("warnings") or {}).items():
|
|
if isinstance(entries, dict):
|
|
for key, text in entries.items():
|
|
out.append(f"Positionsdatei ({kind}): {key.split('@', 1)[0]}: "
|
|
f"{str(text).split(':', 1)[0]}")
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Zusammenfuehren
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def conveyor_of(*texts: str) -> str:
|
|
"""Foerderer der E-Planung aus einem Klartext als "K<NNNN>", sonst "".
|
|
|
|
Die E-Planung schreibt denselben Foerderer zweifach: "CONVEYOR-K0103"
|
|
(Motorschutz FC0203) und "CONVEYOR-#103" (Motor MA0203). Beides ist die
|
|
vierstellige Foerderernummer des SCL-Namensschemas (DB_JamArea0103,
|
|
fbCarousel0102) und wird zu "K0103".
|
|
"""
|
|
for text in texts:
|
|
upper = (text or "").upper()
|
|
m = _CONVEYOR_K.search(upper)
|
|
if m:
|
|
return f"K{m.group(1)}"
|
|
m = _CONVEYOR_HASH.search(upper)
|
|
if m:
|
|
return f"K{int(m.group(1)):04d}"
|
|
return ""
|
|
|
|
|
|
def conveyor_kind_of(*texts: str) -> str:
|
|
"""Art des genannten Foerderers aus einem Klartext: "Kreisel", "Strecke" oder ""."""
|
|
for text in texts:
|
|
upper = (text or "").upper()
|
|
if _KIND_KREISEL.search(upper):
|
|
return "Kreisel"
|
|
if _KIND_STRECKE.search(upper):
|
|
return "Strecke"
|
|
return ""
|
|
|
|
|
|
def pair_drives(devices: list[EDevice]) -> list[Drive]:
|
|
"""Motor MAnnnn <-> Motorschutz FCnnnn (gleiche Nummer), Geber GF je Foerderer."""
|
|
fc = {d.number: d for d in devices if d.prefix.upper() == "FC" and d.number is not None}
|
|
encoders: dict[str, str] = {}
|
|
for d in sorted(devices, key=lambda d: d.bmk):
|
|
if d.prefix.upper() == "GF":
|
|
conv = getattr(d, "conveyor", "") or conveyor_of(d.bezeichnung)
|
|
if conv:
|
|
encoders.setdefault(conv, d.bmk)
|
|
drives = []
|
|
for d in sorted(devices, key=lambda d: d.bmk):
|
|
if d.prefix.upper() != "MA" or d.number is None:
|
|
continue
|
|
schutz = fc.get(d.number)
|
|
conv = (getattr(d, "conveyor", "") or conveyor_of(d.bezeichnung)
|
|
or (conveyor_of(schutz.bezeichnung) if schutz else ""))
|
|
drives.append(Drive(
|
|
motor=d.bmk,
|
|
protection=schutz.bmk if schutz else "",
|
|
bezeichnung=d.bezeichnung,
|
|
protection_bezeichnung=schutz.bezeichnung if schutz else "",
|
|
conveyor=conv,
|
|
conveyor_kind=conveyor_kind_of(d.bezeichnung,
|
|
schutz.bezeichnung if schutz else ""),
|
|
encoder=encoders.get(conv, "") if conv else "",
|
|
))
|
|
return drives
|
|
|
|
|
|
def _constant_name(bmk: str, direction: str) -> str:
|
|
return f"cIn{bmk}" if direction == "in" else f"cOut{bmk}" if direction == "out" else ""
|
|
|
|
|
|
def build_hardware(tia_path: Path | None, positions_path: Path | None,
|
|
project: str = "") -> HardwareDocument:
|
|
"""Beide Quellen einlesen, je BMK zusammenfuehren und gegeneinander pruefen."""
|
|
if tia_path is None and positions_path is None:
|
|
raise ValueError("weder TIA-Import noch Positionsdatei angegeben")
|
|
warnings: list[str] = []
|
|
checks: list[str] = []
|
|
|
|
tags: list[TiaTag] = []
|
|
constants: list[TiaConstant] = []
|
|
if tia_path is not None:
|
|
tags, constants = load_tia(tia_path, warnings)
|
|
e_devices: list[EDevice] = []
|
|
if positions_path is not None:
|
|
e_devices = load_positionsdraw(positions_path, warnings)
|
|
warnings.extend(_positions_warnings(positions_path))
|
|
|
|
tag_of = {}
|
|
for t in tags:
|
|
if t.name in tag_of:
|
|
checks.append(f"TIA: Tag {t.name} doppelt")
|
|
tag_of[t.name] = t
|
|
const_of = {c.name: c for c in constants}
|
|
e_of = {d.bmk: d for d in e_devices}
|
|
|
|
devices: list[HwDevice] = []
|
|
used_constants: set[str] = set()
|
|
for bmk in sorted(set(tag_of) | set(e_of)):
|
|
tag, e = tag_of.get(bmk), e_of.get(bmk)
|
|
direction = _direction(tag.address) if tag else ""
|
|
cname = _constant_name(bmk, direction)
|
|
const = const_of.get(cname)
|
|
if const:
|
|
used_constants.add(cname)
|
|
value = None
|
|
if const is not None:
|
|
try:
|
|
value = int(const.value)
|
|
except ValueError:
|
|
checks.append(f"TIA: Konstante {cname} hat keinen ganzzahligen Wert "
|
|
f"'{const.value}'")
|
|
base = e.model_dump() if e else {"bmk": bmk}
|
|
devices.append(HwDevice(
|
|
**base,
|
|
direction=direction,
|
|
address=tag.address if tag else "",
|
|
data_type=tag.data_type if tag else "",
|
|
comment=tag.comment if tag else "",
|
|
constant=cname if const else "",
|
|
constant_value=value,
|
|
conveyor=conveyor_of(e.bezeichnung if e else "", tag.comment if tag else ""),
|
|
conveyor_kind=conveyor_kind_of(e.bezeichnung if e else "",
|
|
tag.comment if tag else ""),
|
|
sources=[s for s, ok in (("tia", tag is not None), ("positions", e is not None))
|
|
if ok],
|
|
))
|
|
|
|
_check(devices, constants, used_constants, tags, e_devices, tia_path, positions_path,
|
|
checks)
|
|
return HardwareDocument(
|
|
project=project,
|
|
tia_file=tia_path.name if tia_path else "",
|
|
tia_sha256=file_fingerprint(tia_path) if tia_path else "",
|
|
positions_file=positions_path.name if positions_path else "",
|
|
positions_sha256=file_fingerprint(positions_path) if positions_path else "",
|
|
devices=devices,
|
|
drives=pair_drives(devices),
|
|
constants=[c for c in constants if c.name not in used_constants],
|
|
checks=checks,
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
def _check(devices: list[HwDevice], constants: list[TiaConstant], used_constants: set[str],
|
|
tags: list[TiaTag], e_devices: list[EDevice], tia_path: Path | None,
|
|
positions_path: Path | None, checks: list[str]) -> None:
|
|
"""Gegenpruefung TIA-Import <-> Positionsdatei (Ergebnis in checks)."""
|
|
if tia_path is not None and positions_path is not None:
|
|
only_e = [d.bmk for d in devices if d.sources == ["positions"]]
|
|
if only_e:
|
|
checks.append(f"nur in der Positionsdatei, nicht im TIA-Import "
|
|
f"({len(only_e)}): {', '.join(only_e)}")
|
|
# Feldgeraete (Sensoren/Ventile/Motoren) gehoeren in die Zeichnung;
|
|
# Taster, Leuchten, Not-Halt (SF/PF) stehen dort meist nicht.
|
|
only_tia = [d.bmk for d in devices if d.sources == ["tia"]
|
|
and d.prefix.upper() in ("BG", "MB", "MA", "BX")]
|
|
if only_tia:
|
|
checks.append(f"Feldgeraet nur im TIA-Import, nicht in der Positionsdatei "
|
|
f"({len(only_tia)}): {', '.join(only_tia)}")
|
|
differ = [d.bmk for d in devices if len(d.sources) == 2 and d.bezeichnung
|
|
and d.comment and d.bezeichnung.strip() != d.comment.strip()]
|
|
if differ:
|
|
checks.append(f"TIA-Kommentar weicht von der BEZEICHNUNG ab ({len(differ)}): "
|
|
f"{', '.join(differ)}")
|
|
if tia_path is not None:
|
|
no_const = [d.bmk for d in devices if d.in_tia and d.direction and not d.constant]
|
|
if no_const:
|
|
checks.append(f"Signal ohne Index-Konstante cIn/cOut ({len(no_const)}): "
|
|
f"{', '.join(no_const)}")
|
|
orphan = [c.name for c in constants
|
|
if c.name not in used_constants and re.match(r"^c(In|Out)[A-Z]+\d+$", c.name)]
|
|
if orphan:
|
|
checks.append(f"Index-Konstante ohne Signal ({len(orphan)}): {', '.join(orphan)}")
|
|
addresses = Counter(t.address for t in tags if t.address)
|
|
double = [a for a, n in addresses.items() if n > 1]
|
|
if double:
|
|
checks.append(f"Adresse mehrfach vergeben: {', '.join(sorted(double))}")
|
|
for direction in ("in", "out"):
|
|
values = Counter(d.constant_value for d in devices
|
|
if d.direction == direction and d.constant_value is not None)
|
|
double = sorted(v for v, n in values.items() if n > 1)
|
|
if double:
|
|
checks.append(f"Index-Konstanten ({direction}) mit gleichem Wert: "
|
|
f"{', '.join(map(str, double))}")
|
|
no_ctrl = [d.bmk for d in e_devices if not d.cabinet and not d.controller]
|
|
if no_ctrl:
|
|
checks.append(f"Feldgeraet ohne Steuerung in KENNZEICHNUNG ({len(no_ctrl)}): "
|
|
f"{', '.join(no_ctrl)}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bericht
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def render_report(doc: HardwareDocument) -> str:
|
|
out: list[str] = []
|
|
add = out.append
|
|
add(f"# Hardwareplanung: {doc.project}")
|
|
add("")
|
|
add(f"- TIA-Import: `{doc.tia_file or '-'}`")
|
|
add(f"- Positionsdatei: `{doc.positions_file or '-'}`")
|
|
add(f"- Erzeugt: {doc.created}")
|
|
add("")
|
|
add("## Geraete")
|
|
add("")
|
|
prefixes = Counter(d.prefix or "?" for d in doc.devices)
|
|
add("| Praefix | Anzahl | im Feld mit Lage | mit Index-Konstante |")
|
|
add("|---|---|---|---|")
|
|
for p, n in sorted(prefixes.items()):
|
|
devs = [d for d in doc.devices if (d.prefix or "?") == p]
|
|
add(f"| {p} | {n} | {sum(1 for d in devs if d.pos and not d.cabinet)} "
|
|
f"| {sum(1 for d in devs if d.constant)} |")
|
|
add("")
|
|
add("## Antriebe (Motor <-> Motorschutz, Geber)")
|
|
add("")
|
|
if doc.drives:
|
|
add("| Foerderer | Motor | Bezeichnung | Motorschutz | Geber |")
|
|
add("|---|---|---|---|---|")
|
|
for dr in doc.drives:
|
|
add(f"| {dr.conveyor or '-'} | {dr.motor} | {dr.bezeichnung} "
|
|
f"| {dr.protection or '-'} | {dr.encoder or '-'} |")
|
|
else:
|
|
add("- keine Motoren")
|
|
add("")
|
|
conveyors = Counter(d.conveyor for d in doc.field_devices() if d.conveyor)
|
|
add("## Foerderer der E-Planung (Feldgeraete, die ihn im Klartext nennen)")
|
|
add("")
|
|
if conveyors:
|
|
for name, n in sorted(conveyors.items()):
|
|
add(f"- {name}: {n}")
|
|
else:
|
|
add("- keine")
|
|
add("")
|
|
add("## Gegenpruefung")
|
|
add("")
|
|
for c in doc.checks or ["keine Befunde"]:
|
|
add(f"- {c}")
|
|
add("")
|
|
if doc.constants:
|
|
add("## Konstanten ohne Signal")
|
|
add("")
|
|
add(", ".join(c.name for c in doc.constants))
|
|
add("")
|
|
if doc.warnings:
|
|
add("## Hinweise")
|
|
add("")
|
|
for w in doc.warnings:
|
|
add(f"- {w}")
|
|
add("")
|
|
return "\n".join(out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_POSITION_SUFFIXES = ("_positionsconv", "_positionsdraw", "_positions")
|
|
|
|
|
|
def project_name(tia_path: Path | None, positions_path: Path | None) -> str:
|
|
"""Projektname aus den Dateinamen: Mubea_positionsconv.json -> Mubea."""
|
|
if positions_path is not None:
|
|
stem = positions_path.stem
|
|
for suffix in _POSITION_SUFFIXES:
|
|
if stem.lower().endswith(suffix):
|
|
return stem[: -len(suffix)]
|
|
return stem
|
|
stem = tia_path.stem if tia_path else "anlage"
|
|
return stem[:-4] if stem.lower().endswith("_tia") else stem
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
from material_flow import env_dir, resolve_input
|
|
|
|
args = parse_args(argv)
|
|
tia_path = resolve_input(args.tia) if args.tia else None
|
|
positions_path = resolve_input(args.positions) if args.positions else None
|
|
for label, path in (("TIA-Import", tia_path), ("Positionsdatei", positions_path)):
|
|
if path is not None and not path.is_file():
|
|
print(f"FEHLER: {label} nicht gefunden: {path}", file=sys.stderr)
|
|
return 1
|
|
project = args.project or project_name(tia_path, positions_path)
|
|
|
|
try:
|
|
doc = build_hardware(tia_path, positions_path, project)
|
|
except (OSError, ValueError, KeyError, zipfile.BadZipFile, ET.ParseError) as exc:
|
|
print(f"FEHLER: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
results = env_dir("SKEL_RESULTS", "results")
|
|
results.mkdir(parents=True, exist_ok=True)
|
|
json_file = results / f"{project}_hw.json"
|
|
md_file = results / f"{project}_hw.md"
|
|
json_file.write_text(doc.to_json(), encoding="utf-8")
|
|
md_file.write_text(render_report(doc), encoding="utf-8")
|
|
|
|
n_tia = sum(1 for d in doc.devices if d.in_tia)
|
|
n_pos = sum(1 for d in doc.devices if "positions" in d.sources)
|
|
print("")
|
|
print("================================================================")
|
|
print("HARDWAREPLANUNG EINLESEN")
|
|
print("================================================================")
|
|
print(f"TIA-Import = {tia_path or '-'}")
|
|
print(f"Positionsdatei = {positions_path or '-'}")
|
|
print(f"Projekt = {project}")
|
|
print(f"Geraete = {len(doc.devices)} (TIA {n_tia}, Positionsdatei {n_pos}, "
|
|
f"beide {sum(1 for d in doc.devices if len(d.sources) == 2)})")
|
|
print(f"Feld mit Lage = {len(doc.field_devices())}")
|
|
print(f"Index-Konstanten = {sum(1 for d in doc.devices if d.constant)} "
|
|
f"(+ {len(doc.constants)} ohne Signal)")
|
|
print(f"Antriebe = {len(doc.drives)} "
|
|
f"({sum(1 for d in doc.drives if d.protection)} mit Motorschutz, "
|
|
f"{sum(1 for d in doc.drives if d.conveyor)} mit Foerderer)")
|
|
for c in doc.checks:
|
|
print(f" ? {c}")
|
|
for w in doc.warnings:
|
|
print(f" ! {w}")
|
|
print(f"JSON = {json_file}")
|
|
print(f"Bericht = {md_file}")
|
|
print("================================================================")
|
|
print("")
|
|
return 0
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="hw_import",
|
|
description="Liest die Hardwareplanung ein - TIA-Import (PLC Tags + "
|
|
"Constants) und die Positionsdatei des Kabellaengen-Tools - "
|
|
"und schreibt <projekt>_hw.json fuer merge_hw_layout plus einen "
|
|
"Bericht <projekt>_hw.md.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="Beispiele:\n"
|
|
" hw_import.bat --tia Mubea/mubea-1_TIA.xlsx "
|
|
"--positions Mubea/Mubea_positionsconv.json\n"
|
|
" hw_import.bat --tia mubea-1_TIA.xlsx --project Mubea\n"
|
|
"\n"
|
|
"Exit-Codes:\n"
|
|
" 0 ok (Befunde der Gegenpruefung stehen im Bericht)\n"
|
|
" 1 Eingabe- oder Lesefehler\n",
|
|
)
|
|
parser.add_argument("--tia", required=True, metavar="NAME",
|
|
help="TIA-Import *_TIA.xlsx in %%SKEL_DATA%% (oder ein Pfad, "
|
|
"auch mit Unterordner)")
|
|
parser.add_argument("--positions", metavar="NAME",
|
|
help="*_positionsconv.json bzw. *_positionsdraw.json in "
|
|
"%%SKEL_DATA%% (oder ein Pfad); ohne sie keine Lage")
|
|
parser.add_argument("--project", metavar="NAME",
|
|
help="Projektname fuer <projekt>_hw.json. Standard: aus dem "
|
|
"Namen der Positionsdatei (Mubea_positionsconv.json -> Mubea)")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|