diff --git a/CLAUDE.md b/CLAUDE.md index af34dd3..8864834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,9 @@ Template" below). What exists today is: `tro_annotate.py --emit-lisp`) - `cfg/dxf_registration.json` — persisted, verified CSV↔DXF coordinate transform; `cfg/tro_overrides.ini` — hand-maintained corrections to the derived TROs (see `tro_overrides.py`); - `cfg/e_planning.ini` — project-specific rules for assigning E-planning symbols to layout parts; + `cfg/e_planning.ini` — project-specific rules for assigning E-planning symbols to layout parts + (read by `merge_hw_layout.py`, also for `e_planning.py`); + `cfg/hw_overrides.ini` — hand corrections of `merge_hw_layout.py` (bindings per TRO slot role, conveyors); `cfg/parameter.ini` — tolerances, thresholds, scales and drawing sizes of the `lib/` modules (read by `lib/parameter.py`; no numeric settings in the code, see `doc/Python_Scripts.md` §7a); `cfg/_tro_ids.ini` — per-plant TRO-ID file (versioned, IDs renamed by the SPS @@ -150,7 +152,7 @@ this order for onboarding: `doc/Klassenhierarchie.md`, chapter "Daten". 10. **`doc/Konzept_Layout_Hardware_SCL.md`** — the target workflow (planned, not built): layout → `_tro.json` (with slots, `jamAreas`), hardware planning → - `hw_import.py` → `_hw.json`, `hw_assign.py` → `_anlage.json`, + `hw_import.py` → `_hw.json`, `merge_hw_layout.py` → `_anlage.json`, then `scl_skeleton.py`; what happens to `e_planning.py` (split), `tro_annotate.py` (viewer reading the JSON) and `tro_extract.py` (dropped). @@ -212,7 +214,21 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha `cfg/e_planning.ini` (distance, target distance, numbering rule `MB = BG + 1`). `scl_skeleton.py --eplan` fills separator sensor and stopper valve from it; jam sensors and outfeed/switch valves are assigned but not yet placed in the SCL. See - `doc/Python_Scripts.md` §5c and `doc/E-Planung_Bruecke.md`. + `doc/Python_Scripts.md` §5c and `doc/E-Planung_Bruecke.md`. Since 2026-10-15 its model + and assignment logic live in `hw_import.py`/`merge_hw_layout.py` (re-exported here). +6. **`lib/hw_import.py`** (implemented, stage 2 of `doc/Konzept_Layout_Hardware_SCL.md`) — + TIA import `*_TIA.xlsx` (PLC Tags + Constants, read without openpyxl) + positions file + `*_positionsconv.json` → `_hw.json` (`HardwareDocument`): per BMK address, + index constant `cIn…`/`cOut…`, VERW, station, position, conveyor (`K0102`; `#106` → + `K0106`); drives MA↔FC (+ encoder GF); cross-check of both sources. EA list and WSCAD + are not needed. See `doc/Python_Scripts.md` §5d. +7. **`lib/merge_hw_layout.py`** (implemented, stage 3) — `_tro.json` + `_hw.json` + → `_anlage.json` (`model.AnlageDocument`: layout unchanged + `bindings[]` keyed by + TRO-ID + slot + role, `conveyors[]`). Reads no CSV: the TRO-JSON carries `parts` + (positions/bounding boxes, written by `tro_flow --json`). Conveyors are matched by + majority vote of the devices naming them; corrections in `cfg/hw_overrides.ini`, + numbers in `cfg/parameter.ini [merge_hw_layout]`. `scl_skeleton` does not read the plant JSON + yet (stage 4). See `doc/Python_Scripts.md` §5e. What is **not** built out yet, relative to the original two-tool design: diff --git a/bin/hw_import.bat b/bin/hw_import.bat new file mode 100644 index 0000000..72a882b --- /dev/null +++ b/bin/hw_import.bat @@ -0,0 +1,33 @@ +@echo off +REM ================================================================ +REM SPS_SKEL - Hardwareplanung einlesen (TIA-Import + Positionsdatei) +REM ================================================================ +REM Aufruf: +REM bin\hw_import.bat --tia Mubea\mubea-1_TIA.xlsx --positions Mubea\Mubea_positionsconv.json +REM bin\hw_import.bat --tia mubea-1_TIA.xlsx --project Mubea +REM +REM TIA-Import und *_positionsconv.json bzw. *_positionsdraw.json werden in +REM %SKEL_DATA% gesucht (auch mit Unterordner, oder als Pfad angegeben). +REM Ergebnis _hw.json und Bericht _hw.md in %SKEL_RESULTS%. +REM +REM Weiter: bin\merge_hw_layout.bat --tro _tro.json --hw _hw.json +REM ================================================================ + +setlocal + +REM Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_LIB, PYTHONPATH ...) +call "%~dp0setenv.bat" >nul + +REM Virtuelle Umgebung nutzen, falls vorhanden (pydantic wird benoetigt) +if exist "%SPS_SKEL%\.venv\Scripts\activate.bat" ( + call "%SPS_SKEL%\.venv\Scripts\activate.bat" +) + +REM Python-Interpreter bestimmen (py-Launcher bevorzugt, sonst python) +set "PY=py" +py --version >nul 2>&1 || set "PY=python" + +"%PY%" "%SKEL_LIB%\hw_import.py" %* +set "RC=%ERRORLEVEL%" + +endlocal & exit /b %RC% diff --git a/bin/hw_import.sh b/bin/hw_import.sh new file mode 100644 index 0000000..448162d --- /dev/null +++ b/bin/hw_import.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# ================================================================ +# SPS_SKEL - Hardwareplanung einlesen (TIA-Import + Positionsdatei) +# ================================================================ +# Aufruf: +# bash bin/hw_import.sh --tia Mubea/mubea-1_TIA.xlsx --positions Mubea/Mubea_positionsconv.json +# bash bin/hw_import.sh --tia mubea-1_TIA.xlsx --project Mubea +# +# TIA-Import und *_positionsconv.json bzw. *_positionsdraw.json werden in +# $SKEL_DATA gesucht (auch mit Unterordner, oder als Pfad angegeben). +# Ergebnis _hw.json und Bericht _hw.md in $SKEL_RESULTS. +# +# Weiter: bin/merge_hw_layout.sh --tro _tro.json --hw _hw.json +# ================================================================ + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_LIB, PYTHONPATH ...) +# shellcheck source=/dev/null +. "$SCRIPT_DIR/setenv.sh" >/dev/null + +# Virtuelle Umgebung nutzen, falls vorhanden (pydantic wird benoetigt) +if [ -f "$SPS_SKEL/.venv/bin/activate" ]; then + # shellcheck source=/dev/null + . "$SPS_SKEL/.venv/bin/activate" +fi + +# Python-Interpreter bestimmen +PY="python3" +command -v python3 >/dev/null 2>&1 || PY="python" + +exec "$PY" "$SKEL_LIB/hw_import.py" "$@" diff --git a/bin/merge_hw_layout.bat b/bin/merge_hw_layout.bat new file mode 100644 index 0000000..c9fcdbe --- /dev/null +++ b/bin/merge_hw_layout.bat @@ -0,0 +1,34 @@ +@echo off +REM ================================================================ +REM SPS_SKEL - Layout und Hardwareplanung zusammenfuehren +REM ================================================================ +REM Aufruf: +REM bin\merge_hw_layout.bat --tro mubea_tro.json --hw Mubea_hw.json +REM bin\merge_hw_layout.bat --tro mubea_tro.json --hw Mubea_hw.json --re-register +REM +REM TRO-JSON (tro_flow --json) und Hardware-JSON (hw_import) werden in +REM %SKEL_RESULTS% gesucht. Regeln: %SKEL_CFG%\e_planning.ini, +REM Handkorrekturen: %SKEL_CFG%\hw_overrides.ini. +REM Ergebnis _anlage.json und Bericht _anlage.md in %SKEL_RESULTS%. +REM +REM Vorher: bin\tro_flow.bat --file --json und bin\hw_import.bat +REM ================================================================ + +setlocal + +REM Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_LIB, PYTHONPATH ...) +call "%~dp0setenv.bat" >nul + +REM Virtuelle Umgebung nutzen, falls vorhanden (pydantic wird benoetigt) +if exist "%SPS_SKEL%\.venv\Scripts\activate.bat" ( + call "%SPS_SKEL%\.venv\Scripts\activate.bat" +) + +REM Python-Interpreter bestimmen (py-Launcher bevorzugt, sonst python) +set "PY=py" +py --version >nul 2>&1 || set "PY=python" + +"%PY%" "%SKEL_LIB%\merge_hw_layout.py" %* +set "RC=%ERRORLEVEL%" + +endlocal & exit /b %RC% diff --git a/bin/merge_hw_layout.sh b/bin/merge_hw_layout.sh new file mode 100644 index 0000000..2bc16b4 --- /dev/null +++ b/bin/merge_hw_layout.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# ================================================================ +# SPS_SKEL - Layout und Hardwareplanung zusammenfuehren +# ================================================================ +# Aufruf: +# bash bin/merge_hw_layout.sh --tro mubea_tro.json --hw Mubea_hw.json +# bash bin/merge_hw_layout.sh --tro mubea_tro.json --hw Mubea_hw.json --re-register +# +# TRO-JSON (tro_flow --json) und Hardware-JSON (hw_import) werden in +# $SKEL_RESULTS gesucht. Regeln: $SKEL_CFG/e_planning.ini, +# Handkorrekturen: $SKEL_CFG/hw_overrides.ini. +# Ergebnis _anlage.json und Bericht _anlage.md in $SKEL_RESULTS. +# +# Vorher: bin/tro_flow.sh --file --json und bin/hw_import.sh +# ================================================================ + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_LIB, PYTHONPATH ...) +# shellcheck source=/dev/null +. "$SCRIPT_DIR/setenv.sh" >/dev/null + +# Virtuelle Umgebung nutzen, falls vorhanden (pydantic wird benoetigt) +if [ -f "$SPS_SKEL/.venv/bin/activate" ]; then + # shellcheck source=/dev/null + . "$SPS_SKEL/.venv/bin/activate" +fi + +# Python-Interpreter bestimmen +PY="python3" +command -v python3 >/dev/null 2>&1 || PY="python" + +exec "$PY" "$SKEL_LIB/merge_hw_layout.py" "$@" diff --git a/cfg/dxf_registration.json b/cfg/dxf_registration.json index 323b8e4..1b31684 100644 --- a/cfg/dxf_registration.json +++ b/cfg/dxf_registration.json @@ -68,5 +68,20 @@ "csv_file": "dematic.csv", "dxf_file": "dematic_TST.dxf", "created": "2026-10-06 14:19" + }, + "mubea.csv|Mubea_positionsconv.json": { + "dx": -151.4000000000001, + "dy": -696.5699999999997, + "rotation": 0.0, + "source": "auto", + "anchor": "20 exakte Treffer (<= 50 mm) ueber die Rollen Separator:38; 20 Stimmen im 100-mm-Raster, 6 Kandidaten geprueft", + "matched": 23, + "candidates": 38, + "residual_max": 541.020430390572, + "residual_mean": 52.985147546665765, + "csv_file": "mubea.csv", + "dxf_file": "Mubea_positionsconv.json", + "created": "2026-10-15 13:15", + "fingerprint": "085df1af63e4b71d8a51301ea0c6e7812d415b5efd0c6b409c58cf2cb77ba4e2" } } diff --git a/cfg/e_planning.ini b/cfg/e_planning.ini index 6f4856a..144dd6a 100644 --- a/cfg/e_planning.ini +++ b/cfg/e_planning.ini @@ -22,7 +22,7 @@ ; neben dem Bauteil; gewertet wird die Abweichung vom Soll ; (nicht der kleinste Abstand), zulaessig bis toleranz_mm ; toleranz_mm zulaessige Abweichung vom Sollabstand (Standard: cfg/parameter.ini -; [e_planning] toleranz_mm) +; [merge_hw_layout] toleranz_mm) ; nummer_von Nummernregel: BMK-Nummer = Nummer der genannten Rolle ; nummer_versatz ... plus dieser Versatz (Mubea: Ventil = Sensor + 1) ; Mit nummer_von wird zuerst die Nummer versucht, danach (wenn max_mm oder diff --git a/cfg/hw_overrides.ini b/cfg/hw_overrides.ini new file mode 100644 index 0000000..ae58c2f --- /dev/null +++ b/cfg/hw_overrides.ini @@ -0,0 +1,30 @@ +; ================================================================ +; hw_overrides.ini - Handkorrekturen fuer lib/merge_hw_layout.py +; ================================================================ +; Was die Regeln (cfg/e_planning.ini) nicht oder falsch zuordnen, wird hier +; von Hand gesetzt. Angewendet nach der automatischen Zuordnung; im Bericht +; _anlage.md mit Regel "hand". +; +; Je Anlage (Name der CSV, aus der die TRO-JSON stammt) zwei Abschnitte: +; +; [.bindings] +; .. = BMK setzen (muss im TIA-Import stehen) +; .. = - Zuordnung loeschen, Rolle bleibt offen +; TRO-IDs aus cfg/_tro_ids.ini (stabil), Slots/Rollen aus der +; TRO-JSON (separator1.sensor, separator1.stopper, line3.sensor, +; drive.motor ...). +; +; [.conveyors] +; = z. B. K0102 = 0010 +; = - Zuordnung loeschen +; Achtung: die Layout-ID (TeileId) aendert sich mit jedem CSV-Export. +; +; Beispiel (auskommentiert): +; +; [mubea.csv.bindings] +; TRO03.separator1.sensor = BG0532 +; TRO03.separator1.stopper = MB0533 +; +; [mubea.csv.conveyors] +; K0105 = 0001 +; ================================================================ diff --git a/cfg/parameter.ini b/cfg/parameter.ini index f5400f3..372c8f7 100644 --- a/cfg/parameter.ini +++ b/cfg/parameter.ini @@ -172,7 +172,21 @@ default_text = 220.0 default_max_residual = 600.0 -[e_planning] +[merge_hw_layout] ; Zulaessige Abweichung vom Sollabstand (soll_mm), wenn eine Regel in -; e_planning.ini kein toleranz_mm angibt. +; e_planning.ini kein toleranz_mm angibt (die Regeln liest merge_hw_layout, auch +; fuer e_planning). toleranz_mm = 150.0 +; Foerderer der E-Planung (K0102) -> Layout-Foerderer (Kreisel3) per Mehrheit: +; ein Feldgeraet, dessen Klartext den Foerderer nennt, stimmt fuer den +; Layout-Foerderer, dessen Huellbox ihm am naechsten liegt - hoechstens so +; weit entfernt (mm, nach dem Lagebezug). +conveyor_max_mm = 1500.0 +; Zugeordnet wird erst ab so vielen Stimmen ... +conveyor_min_votes = 2 +; ... und diesem Anteil an allen Geraeten, die den Foerderer nennen (0..1). +conveyor_min_share = 0.6 +; Warnung, wenn der Lagebezug fuer weniger als diesen Anteil der +; Layout-Separatoren einen Partner findet (0..1) - Hinweis auf verschiedene +; Planungsstaende von Layout und E-Zeichnung. +registration_min_share = 0.8 diff --git a/doc/E-Planung_Bruecke.md b/doc/E-Planung_Bruecke.md index 828c607..6479628 100644 --- a/doc/E-Planung_Bruecke.md +++ b/doc/E-Planung_Bruecke.md @@ -1,9 +1,11 @@ # Brücke Layout ↔ E-Planung über `*_positionsdraw.json` -Stand 2026-10-10. Erst Machbarkeitsprüfung, inzwischen umgesetzt als +Stand 2026-10-15. Erst Machbarkeitsprüfung, inzwischen umgesetzt als `lib/e_planning.py` (Regeln `cfg/e_planning.ini`, Aufruf `bin/e_planning.bat`, `scl_skeleton --eplan`); Bedienung in `doc/Python_Scripts.md` §5c, Stand der -Umsetzung unten. +Umsetzung unten. Am 2026-10-15 mit dem neuen Mubea-Datensatz in `data/Mubea/` +(TIA-Import + Positionsdatei) nachgeprüft; Ergebnisse unter "Nachprüfung +2026-10-15". ## Problem @@ -34,16 +36,47 @@ passender Art. Die Erdungs-Beispiele (`PR500501_…_Erdung_…`, `ST500652_…`) enthalten nur Potentialausgleich (`POT…`) und taugen nicht für die Brücke. Brauchbar sind -Dateien mit `BG`/`MB`/`MA`: `Mubea_positionsdraw.json` (157 Feld-BMK), +Dateien mit `BG`/`MB`/`MA`: `Mubea_positionsdraw.json` (158 Feld-BMK, davon 157 +in `mappings` unter UH01 — `BG0514` hat eine leere `KENNZEICHNUNG`), `ST500592_ILS_positionsdraw.json` (~3100), `ST500592_10_5-10_Omniflo_…` (~900), `ST-Fortna_positions_.json` (mit 38 Scannern `BX`). +`*_positionsconv.json` und `*_positionsdraw.json` sind bei Mubea byte-gleich; +eine von beiden genügt. + +## Datenquelle TIA-Import `*_TIA.xlsx` + +Die zweite nötige Quelle. Blatt **PLC Tags**: je Signal Name (`BG0542`), +Absolutadresse (`%E…`/`%A…`), Kommentar. Blatt **Constants**: je Signal eine +Index-Konstante `cIn`/`cOut` (laufender Index, **nicht** aus der +Adresse berechenbar). + +| Nutzen | Warum | +|---|---| +| SCL übersetzbar | Sensoren stehen im SCL als `"DB_Inputs".Sensors["cInBG…"]`, Ventile als `"MB…"`; beides muss im TIA-Projekt existieren — der TIA-Import liefert genau diese Tags und Konstanten | +| Prüfliste | jeder Name im erzeugten SCL muss im TIA-Import stehen | +| `FB_CallSensors`, Größe `DB_Inputs` | alle `cIn…`-Konstanten mit Wert (noch nicht generiert) | +| Schrank- und Antriebssignale | `SF…` (Taster, Not-Halt), `PF…` (Leuchten), `MA…`/`FC…` (Motor/Motorschutz), `GF…` (Geber), `BP…`, `DI…` | + +Mubea: 237 Tags (105 BG, 64 MB, 26 SF, 17 PF, 11 MA, 11 FC, je 1 BP/DI/GF) und +237 Konstanten (144 `cIn`, 93 `cOut`). Alle 158 Feld-BMK und 80 von 81 +Schrankelementen (fehlt: `QA7000`) der Positionsdatei stehen im TIA-Import, die +Kommentare sind 1:1 gleich der `BEZEICHNUNG`. + +**Nicht nötig** (Mubea): `*_EA.xlsx` (dieselben 237 Signale, nur Adresse und +Kommentar, keine Namen), `*_WSCAD.xlsx` (zusätzlich nur die E/A-Baugruppe je +Kanal, die `KENNZEICHNUNG` der Positionsdatei schon trägt, und 259 freie Kanäle), +`*_BOM.xlsx`, `*_cables.*`, `*_todraw.json`, `*_errors.json` (Kabel, Stückliste, +Trassen; die Qualitätswarnungen stehen auch in der Positionsdatei). Eingaben für +die Brücke also: **CSV + TIA-Import + eine Positionsdatei**. + ## Test an Mubea Eingaben: `data/mubea.csv` (Layout, 38 Separatoren) und -`kabellaengen/work/Mubea_positionsdraw.json` (E-Planung). Die Zahlen in diesem -Abschnitt stammen aus der ersten Untersuchung (Umkreis 700 mm, ohne Regeln); das -Ergebnis der Umsetzung steht unten. +`kabellaengen/work/Mubea_positionsdraw.json` (E-Planung, alter Stand). Die Zahlen +in diesem Abschnitt stammen aus der ersten Untersuchung (Umkreis 700 mm, ohne +Regeln); das Ergebnis der Umsetzung und die Nachprüfung mit dem neuen Stand +`data/Mubea/Mubea_positionsdraw.json` stehen unten. ### 1. Lagebezug @@ -57,10 +90,15 @@ mubea.csv <- Mubea_positionsdraw.json: dx +50613.6 mm, dy +8555.8 mm, Drehung 0 ``` Das entspricht dem schon gespeicherten Bezug `mubea.csv|500573_60_1.dxf` -(dx +50833.7, dy +8414.5) bis auf ~250 mm: die E-Zeichnung liegt im selben +(dx +50833.7, dy +8414.5) bis auf ~250 mm: diese (alte) E-Zeichnung lag im selben Koordinatensystem wie die Layoutzeichnung. Zum Prüfen genügt also der -bestehende Mechanismus; als Schlüssel in `cfg/dxf_registration.json` kann -`|` dienen. +bestehende Mechanismus; als Schlüssel in `cfg/dxf_registration.json` dient +`|`. + +Das gilt **nicht allgemein**: der neue Stand in `data/Mubea/` liegt in einem +anderen Koordinatensystem (dx −151.4, dy −696.6, siehe "Nachprüfung +2026-10-15"). Der Lagebezug ist je Datei neu zu ermitteln, nicht von der +Layoutzeichnung zu übernehmen. ### 2. Was sich zuordnen lässt @@ -121,13 +159,20 @@ ist ein anderes Projekt nötig (`ST-Fortna_positions_.json`, `ILS-Baugruppe`). | `xOutSwExTo3/4` | MB `AE ABZWEIG` / Weichen-MB | Nummernregel ab dem Stausensor bzw. räumlich an Weiche/Ausschleuselement | | `Settings.xPluggedPart`, `stSenPart` | BG `BT BELADEN` | räumlich; vorhanden ⇒ `TRUE` | | `xInMotorProtection`, `xOutVarioMotor` | MA im Feld + FC in `schaltschrank_elemente` | gleiche Nummer | -| `xInCarouselRun` (welcher Antrieb) | FC/MA-`BEZEICHNUNG` "KREISEL-K0102", "CONVEYOR #100" | Textabgleich mit dem Host-Kreisel des TRO | +| `xInCarouselRun` (welcher Antrieb) | FC/MA-`BEZEICHNUNG` "KREISEL-K0102", "CONVEYOR #100" | **räumlich**, nicht über Text: das CSV nennt die Kreisel `Kreisel1/2/3`, die E-Planung `K0102/K0104/K0105`. Alle Symbole, deren `BEZEICHNUNG` einen Kreisel nennt, per Mehrheit dem nächsten CSV-Kreisel zuordnen (siehe Nachprüfung) | | Steuerung, TRO-Nummernkreis, `stInOutMachineState` | `KENNZEICHNUNG` `=A1+UH01-KF107`, `mappings` | `UH01` = Steuerung, `KF107` = E/A-Station | | Plausibilisierung | `BEZEICHNUNG` "SEPARATOR STR. 14", "KR-K0102 VOR BT-BELADUNG" | Strecken-/Kreiselnummer gegen Layout prüfen | -Nicht aus dieser Quelle: Not-Halt-Kreis (`xInSftyOk`), Taster für Automatik/Quittieren -(stehen nur als Schrankelemente ohne Bezug zum TRO), Scanner-Hardwarekennungen, -Weichenbauart (`xNewSwitch`). +Nicht aus dieser Quelle: Scanner-Hardwarekennungen, Weichenbauart (`xNewSwitch`). + +Auch nicht aus den E/A-Daten, aber **je Projekt nur ein Wert**: `xInSftyOk`, +`stInOutMachineState`, `xInAutomatic`, `xInErrorQuit`. Der TIA-Import enthält +zwar die Not-Halt-Kanäle (`SF5000…SF6006`) und `SF0001` "ANLAGE EIN" / `SF0002` +"START NACH STÖRUNG", die Referenz-SCL verdrahtet diese Parameter aber nicht mit +E/A-Signalen, sondern mit dem Sicherheitsprogramm +(`"DB_InterfaceSafety".stFromSafety.EStopMain….Q`) bzw. der Schrank-Struktur +(`#stInOutControlUnitCabinet…`). Sie gehören als Projektkonstanten in eine +Konfiguration (bei Mubea ~39 der offenen Stellen), nicht in die Brücke. ## Umsetzung (Stand 2026-10-10) @@ -144,11 +189,59 @@ Weichenbauart (`xNewSwitch`). `scl_skeleton --eplan` setzt Separator-Sensor und Stopperventil ein (`SclName.todo = False`); alles andere bleibt `TODO(E-Planung)`. -Ergebnis Mubea mit den ausgelieferten Regeln: Separator-Sensor 21/38, +Ergebnis Mubea (alter Stand) mit den ausgelieferten Regeln: Separator-Sensor 21/38, Stopper 22/38, Stausensor 20/23, Ausschleusventil 20/23, Weichenventil 1/1. Den Separator-Sensor nimmt die Regel nur bis 300 mm; die Kreisel-Fälle mit 470–540 mm (z. B. BG0532 an 0044) bleiben bewusst offen. +## Nachprüfung 2026-10-15 (neuer Stand `data/Mubea/`) + +Lauf: `e_planning --file mubea.csv --eplan \data\Mubea\Mubea_positionsdraw.json`, +dann `tro_flow --json` und `scl_skeleton --eplan --templates`. + +| | alter Stand | neuer Stand | +|---|---|---| +| Lagebezug | dx +50613.6, dy +8555.8 | **dx −151.4, dy −696.6**, Drehung 0, 23/38 Punkte, Restfehler max 541 mm | +| Separator-Sensor | 21/38 | 21/38 | +| Stopper | 22/38 | 22/38 | +| Stausensor | 20/23 | **21/23** | +| Ausschleusventil | 20/23 | **19/23** | +| Weichenventil | 1/1 | 1/1 | +| Antriebe | — | 11, alle mit Motorschutz | + +Im erzeugten `FB_Main` (18 TROs): 43 Namen aus der E-Planung, 440 Stellen +`TODO(E-Planung)`. Alle 21 Konstanten und 22 Tags im SCL stehen im TIA-Import. + +Befunde: + +- **Pfad:** `--eplan Mubea/Mubea_positionsdraw.json` (Unterordner relativ zu + `%SKEL_DATA%`) wird nicht gefunden; nur ein Dateiname direkt in `%SKEL_DATA%` + oder ein voller Pfad geht. +- **Gespeicherter Lagebezug:** der Schlüssel `|` in + `cfg/dxf_registration.json` enthält nur den Dateinamen. Ein neuer Stand mit + gleichem Namen übernimmt ohne `--re-register` still den alten Bezug — beim + Mubea-Wechsel wären das ~51 m Versatz gewesen. +- **Kreisel-Zuordnung räumlich möglich:** die Symbole, deren `BEZEICHNUNG` einen + Kreisel nennt (nach dem Lagebezug, Abstand zur Boundingbox), liegen eindeutig + auf einem CSV-Kreisel — **K0102 = Kreisel3 (0010)**, **K0104 = Kreisel2 (0002)**, + **K0105 = Kreisel1 (0001)**; ein Ausreißer (`BG0725` "BANDBRUCHÜBERWACHUNG + KR-K0105" an Kreisel2). Damit erhält jeder Kreisel seinen Antrieb + (`MA0202`/`FC0202`, `MA0205`/`FC0205`, `MA0206`/`FC0206`) und K0102 seinen Geber + `GF20000`. + +Was sich mit TIA-Import + Positionsdatei noch schließen lässt: + +| Offene Stellen (Mubea) | Anzahl | Weg | +|---|---|---| +| `stInSenJam` | ~17 | schon zugeordnet; fehlt nur TRO-Ausgang → Ausschleuselement (siehe "Offen") | +| `xInCarouselRun` | ~13 | Kreisel-Zuordnung oben + Motorpaar | +| Vario-Sensoren (`stSenLastPos`, `stSenJam`, `stSenFinger`, `stSenCarrInPos`), `xOutVarioMotor`, `xInMotorProtection` | ~18 | wahrscheinlich, neue Regeln über `VERW` (`SYNC P1..4`, `ZAPPLER`, `ABFR.FINGER`, `BT BELADEN`) und die Motorpaare; zu prüfen | +| `xInSftyOk`, `stInOutMachineState`, `xInAutomatic`, `xInErrorQuit` | ~39 | Projektkonstante (oben), nicht aus diesen Dateien | +| `nSeparatorNo`/`nSwitchNo`, Zeiten, Staubereichs-Verdrahtung | Rest | weder TIA noch Positionsdatei | + +Die 17 nicht zugeordneten Separatoren an den Kreiseln sind ein Standunterschied +Layout ↔ E-Zeichnung, keine fehlenden Daten. + Offen: - Stausensoren und Ausschleus-/Weichenventile stehen in JSON und Bericht, kommen @@ -159,3 +252,39 @@ Offen: - Scanner (`BX`) fehlen in den Mubea-Daten; Regel und Test mit `ST-Fortna_positions_.json` stehen aus. - Ein Abgleich mit passenden Ständen von CSV und E-Zeichnung für die Kreisel. +- ~~TIA-Import einlesen~~ — **erledigt** 2026-10-15: `lib/hw_import.py` + (`_hw.json`); `FB_CallSensors` daraus steht noch aus. +- ~~Kreisel ↔ E-Kreisel (`K01xx`) räumlich zuordnen~~ — **erledigt** in + `lib/merge_hw_layout.py` (Mehrheit, nur Förderer der genannten Art); in die SCL + (`xInCarouselRun`) kommt es mit der Umstellung von `scl_skeleton` auf die + Anlagen-JSON. +- ~~Gespeicherten Lagebezug absichern~~ — **erledigt**: SHA-256 der + Positionsdatei im Eintrag von `cfg/dxf_registration.json`. +- ~~Unterordner in `--eplan`~~ — **erledigt** (`material_flow.resolve_input`). + +## Abgleich Layoutzeichnung ↔ CSV ↔ E-Planung (2026-10-15) + +Zeichnung `data/mubea_layout.dxf` (aus `mubea_layout.dwg`), gelesen über alle +Blockebenen mit vollständiger Blocktransformation (die `Staustrecke…`-Blöcke +der Gefällestrecken sind mit gekippter Extrusionsrichtung eingefügt; ohne +OCS liegen ihre Separatoren falsch). + +| Vergleich | Ergebnis | +|---|---| +| DXF ↔ `mubea.csv` | **gleiches Koordinatensystem, kein Versatz.** 38/38 Separatoren je 60–70 mm (Einfügepunkt `Separator_SP_3D` gegen CSV-Punkt, in Blockrichtung), 8/8 Scanner je 360 mm, Aus-/Einschleuselemente 22/23 bzw. 22/23 auf 0 mm (`0061`, `0064` und die Weiche `0125` sind keine eigenen Blöcke) | +| E-Planung ↔ DXF | (−211, −697) mm; Speicherlinien 20/20 auf **1 mm** | +| E-Planung ↔ CSV | (−151, −697) mm; 23/38 im Toleranzband | + +Die 20 Separatoren der Gefällestrecken passen mit dem Versatz exakt (ohne ihn +liegt jeder Sensor eine Speicherlinie daneben, 235 mm). Die 12 Separatoren an +Kreiseln/Strecken und die 6 Vario-Separatoren passen in **keiner** Lage +(155–3438 mm, mit und ohne Versatz). Es gibt also keine starre Transformation +für die ganze Anlage: der Speicher ist in der E-Zeichnung als Ganzes +verschoben, der Bereich um die Kreisel ist ein anderer Planungsstand. +Folge: die Kreisel-Separatoren bleiben offen, bis E-Zeichnung und Layout zum +selben Stand gehören; `merge_hw_layout` warnt deshalb (23/38 = 61 %). + +Nebenbefund für `tro_annotate` (Kontrollansicht): es liest nur die +Modellbereichs-Blöcke (12 der 38 Separatoren, die übrigen stecken in +`GF_n`/`VF_n`), und der Lagebezug übernimmt den 60-mm-Einfügeversatz als +scheinbare Verschiebung (dx +59.8). diff --git a/doc/Konzept_Layout_Hardware_SCL.md b/doc/Konzept_Layout_Hardware_SCL.md index 3b4e530..f006f8f 100644 --- a/doc/Konzept_Layout_Hardware_SCL.md +++ b/doc/Konzept_Layout_Hardware_SCL.md @@ -23,11 +23,11 @@ Hardware später nachtragen können, ohne Layout-Arbeit zu wiederholen. ```text Abteilung 1: Anlagenlayout Abteilung 2: Hardwareplanung (später) -CSV ──tro_flow──> _tro.json EA/TIA/WSCAD.xlsx, positionsdraw.json +CSV ──tro_flow──> _tro.json TIA.xlsx, positionsdraw.json (TROs, Abfolge, ──hw_import──> _hw.json Staubereiche, Slots) (globale Variablen, Geräte) │ │ - └──────────── hw_assign ─────────────────┘ + └──────────── merge_hw_layout ─────────────────┘ │ _anlage.json (Layout + Zuordnungen) │ @@ -37,8 +37,8 @@ CSV ──tro_flow──> _tro.json EA/TIA/WSCAD.xlsx, positionsd | Stufe | Skript | Eingabe | Ausgabe | Status | |---|---|---|---|---| | 1 Layout | `tro_flow.py --json` | CSV, `cfg/tro_overrides.ini` | `_tro.json` | vorhanden, wird erweitert | -| 2 Hardware | `hw_import.py` | `*_TIA.xlsx`, `*_EA.xlsx`, `*_WSCAD.xlsx`, `*_positionsdraw.json` | `_hw.json` | neu | -| 3 Zuordnung | `hw_assign.py` | `_tro.json`, `_hw.json`, Regeln, `cfg/hw_overrides.ini` | `_anlage.json`, Bericht `.md` | neu (Logik aus `e_planning.py`) | +| 2 Hardware | `hw_import.py` | `*_TIA.xlsx`, `*_positionsdraw.json` (optional `*_EA.xlsx`/`*_WSCAD.xlsx` ohne Positionsdatei) | `_hw.json` | vorhanden (2026-10-15), ohne EA/WSCAD | +| 3 Zuordnung | `merge_hw_layout.py` | `_tro.json`, `_hw.json`, Regeln, `cfg/hw_overrides.ini` | `_anlage.json`, Bericht `.md` | vorhanden (2026-10-15, Logik aus `e_planning.py`) | | 4 SCL | `scl_skeleton.py` | `_anlage.json` (oder nur `_tro.json`) | `.scl` | vorhanden, wird umgestellt | Grundsätze: @@ -109,8 +109,12 @@ Weichenventile (`doc/E-Planung_Bruecke.md`, "Offen"). ### 2. Neues Skript `hw_import.py` (Stufe 2) -- Liest TIA-Import (PLC Tags + Constants), EA-Liste, WSCAD-Import und optional - `positionsdraw.json`. +- Liest TIA-Import (PLC Tags + Constants) und `positionsdraw.json`. Das genügt: + bei Mubea stehen alle BMK der Positionsdatei im TIA-Import, die Kommentare sind + gleich; EA-Liste und WSCAD-Import bringen nichts dazu (EA ohne Namen, WSCAD nur + die E/A-Baugruppe, die `KENNZEICHNUNG` schon trägt). EA/WSCAD nur als Ersatz, + wenn keine Positionsdatei vorliegt (Weg "nur EA-Liste" in Stufe 3). Befund in + `doc/E-Planung_Bruecke.md`, "Nachprüfung 2026-10-15". - Schreibt ein neues Modell `HardwareDocument`: - **Geräte** je BMK: Adresse, Index-Konstante (`cInBG3306`), Kommentar, VERW, Steuerung, Station/Schrank, Position (falls vorhanden); @@ -122,7 +126,7 @@ Weichenventile (`doc/E-Planung_Bruecke.md`, "Offen"). - Erste Quelle: der TIA-Import, weil er Namen und Konstanten 1:1 liefert und die spätere Prüfung des SCL ermöglicht. -### 3. Neues Skript `hw_assign.py` (Stufe 3) +### 3. Neues Skript `merge_hw_layout.py` (Stufe 3) - Eingabe: `_tro.json` + `_hw.json`; das CSV wird nicht mehr gelesen. - Zuordnungswege, als Regeln in einer INI (heute `cfg/e_planning.ini`): @@ -152,6 +156,36 @@ Weichenventile (`doc/E-Planung_Bruecke.md`, "Offen"). - **Gegenprüfung** beider Quellen (Anzahl Separatoren vs. `IN SEP`, Weichen vs. `AE ABZWEIG`, angetriebene Strecken vs. `MA`) in den Bericht. +**Stand Umbau 2/3 (2026-10-15), umgesetzt:** + +- `lib/hw_import.py` (`bin/hw_import.bat`): TIA-Import (Blätter an der + Kopfzeile erkannt, xlsx ohne openpyxl gelesen) + Positionsdatei → + `_hw.json` (`HardwareDocument`, `sps_skel-hw-v1`): je BMK Adresse, + Richtung, Index-Konstante, VERW, Station, Lage, Förderer; Antriebe mit + Motorschutz und Geber; Gegenprüfung beider Quellen. EA-Liste/WSCAD nicht + gelesen (Weg "nur EA-Liste" bleibt offen). +- `lib/merge_hw_layout.py` (`bin/merge_hw_layout.bat`): `_tro.json` + + `_hw.json` → `_anlage.json` (`model.AnlageDocument`, + `sps_skel-anlage-v1`): Layout unverändert unter `layout`, daneben + `bindings[]` (Schlüssel TRO-ID + Slot + Rolle, mit Konstante und Adresse aus + dem TIA-Import, Status `zugeordnet`/`offen`/`widerspruch`/`ohne_slot`) und + `conveyors[]`. Handkorrekturen `cfg/hw_overrides.ini`, Zahlenwerte + `cfg/parameter.ini [merge_hw_layout]`. +- Damit `merge_hw_layout` kein CSV liest, trägt die TRO-JSON jetzt `parts` (Lage und + Hüllbox der Layout-Bauteile, von `tro_flow --json`; Schema bleibt v4, eine + Datei ohne `parts` liest sich weiter). Ein `PinStore_Auto` hat je Lagerlinie + einen Slot `line`. +- **Förderernummer:** die E-Planung nennt Förderer im Klartext als `K0102` bzw. + `#106` (beides dieselbe Nummer, `CONVEYOR-#103` = `CONVEYOR-K0103`). + `merge_hw_layout` ordnet sie per Mehrheit der Feldgeräte einem Layout-Förderer zu + (Mubea: K0102 = Kreisel3, K0104 = Kreisel2, K0105 = Kreisel1). Das ist die in + "Staubereiche" gesuchte Quelle für `` und für `xInCarouselRun`. +- Gespeicherter Lagebezug mit SHA-256 der Positionsdatei; ein neuer Stand + gleichen Namens wird neu ermittelt. +- Noch offen: Weichen-/Ausschleus-Slots mit Layout-ID (sonst bleiben + Stausensoren und Ausschleusventile `ohne_slot`), Scanner (`BX`), Weg "nur + EA-Liste", Umstellung von `scl_skeleton` (Umbau 4). + ### 4. `scl_skeleton.py` / `scl_context.py` (Stufe 4) - Liest **eine** Eingabe: `_anlage.json`; ohne Hardwareteil wie heute @@ -180,16 +214,16 @@ Projektdatei `cfg/defaults.ini`. | `tro_extract.py` | entfällt bzw. wird umgebaut (unten) | | `scl_skeleton.py`, `scl_context.py` | Stufe 4, Umstellung auf `_anlage.json` | -### `e_planning.py` → `hw_import` + `hw_assign` +### `e_planning.py` → `hw_import` + `merge_hw_layout` | Teil heute | Wohin | |---|---| | `load_positionsdraw`, `EDevice`, `pair_drives` | `hw_import.py` | -| `resolve_transform`, `load_rules`, `_assign_spatial`, `_assign_by_number`, Bericht | `hw_assign.py`, Ziele = Slots statt CSV-Bauteile | -| `cfg/e_planning.ini` | Regeldatei von `hw_assign` | +| `resolve_transform`, `load_rules`, `_assign_spatial`, `_assign_by_number`, Bericht | `merge_hw_layout.py`, Ziele = Slots statt CSV-Bauteile | +| `cfg/e_planning.ini` | Regeldatei von `merge_hw_layout` | | `EplanDocument` / `_eplan.json` | entfällt, ersetzt durch `bindings[]` | -Übergang: `e_planning.py` bleibt kurz als Hülle, die `hw_import` und `hw_assign` +Übergang: `e_planning.py` bleibt kurz als Hülle, die `hw_import` und `merge_hw_layout` nacheinander aufruft, damit `bin/e_planning.bat` weiter funktioniert; danach löschen. @@ -276,7 +310,7 @@ Einzelheiten in `doc/Python_Scripts.md` §4 "TRO-IDs"):** Sie werden auf die IDs der ID-Datei umgestellt, sobald `tro_flow` die Overrides wieder liest. - Dadurch werden auch die Abschnitte `[.tro.]` in - `cfg/tro_overrides.ini` und die Zuordnungen von `hw_assign` stabil. + `cfg/tro_overrides.ini` und die Zuordnungen von `merge_hw_layout` stabil. ### Staubereiche @@ -356,9 +390,12 @@ unverändert. 2. Slots und `jamAreas` in die TRO-JSON (Umbau 1), ein Eingangs-Staubereich je TRO — ohne Slots hat die Zuordnung kein Ziel. **Erster Teil erledigt** (2026-10-11, siehe Umbau 1 "Stand"). -3. `hw_import` mit dem TIA-Import als erster Quelle. -4. `hw_assign` mit der räumlichen Logik aus `e_planning.py`; `e_planning.py` als - Übergangshülle. +3. `hw_import` mit dem TIA-Import als erster Quelle. **Erledigt** (2026-10-15, + siehe unten "Stand Umbau 2/3"). +4. `merge_hw_layout` mit der räumlichen Logik aus `e_planning.py`; `e_planning.py` als + Übergangshülle. **Erledigt bis auf die Hülle** (2026-10-15): die Logik liegt + in `merge_hw_layout.py`, `e_planning.py` importiert sie und bleibt vollständig, + solange `scl_skeleton --eplan` die `_eplan.json` liest (Schritt 5). 5. `scl_skeleton` auf `_anlage.json` umstellen, `--eplan` entfernen; `tro_annotate` auf JSON umstellen. 6. Defaults (`cfg/defaults.ini`), `FB_CallSensors`, EA-Liste ohne Positionen. diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index 50c9c84..e73c0ac 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -1,16 +1,20 @@ # Python Scripts — CLI Tooling Overview -**As of:** 2026-10-10 +**As of:** 2026-10-15 -`lib/` currently holds eleven Python modules (six of them runnable CLI tools, -five supporting libraries), plus one documentation-generator script that lives -under `doc/TRO_Katalog/tro_graphs/`. Together the CLI tools turn a CSV export -of the mechanical layout (ILS 2.0) into a material-flow graph, a derived TRO -list/diagram, a TRO-JSON (shared model `lib/model.py`), an `FB_Main` SCL -skeleton generated from that JSON, and an annotated copy of the BricsCAD -drawing for visual checking. `e_planning.py` bridges to the electrical -planning: it assigns the sensors/valves of the E-planning drawing to the -layout parts, so the skeleton can carry their real names. +`lib/` currently holds sixteen Python modules (eight of them runnable CLI +tools, eight supporting libraries), plus one documentation-generator script +that lives under `doc/TRO_Katalog/tro_graphs/`. Together the CLI tools turn a +CSV export of the mechanical layout (ILS 2.0) into a material-flow graph, a +derived TRO list/diagram, a TRO-JSON (shared model `lib/model.py`), an +`FB_Main` SCL skeleton generated from that JSON, and an annotated copy of the +BricsCAD drawing for visual checking. `e_planning.py` bridges to the +electrical planning: it assigns the sensors/valves of the E-planning drawing +to the layout parts, so the skeleton can carry their real names. +`hw_import.py` and `merge_hw_layout.py` are the new path for that bridge +(`doc/Konzept_Layout_Hardware_SCL.md`, stages 2 and 3): TIA import + +positions file → `_hw.json`, merged with the TRO-JSON into +`_anlage.json` (§5d, §5e). This document is the practical "what does each script do and which switches does it take" reference. For the underlying domain model (TRO types, JSON @@ -42,6 +46,12 @@ tro_flow.py ──► _tro_flow.dot / .svg, _tro_doc.md, _tro_error │ (assigns BG/MB of the E-planning drawing to the layout │ parts by position + numbering rule; see §5c) │ + │ NEW PATH (stages 2/3, scl_skeleton does not read it yet): + │ _tro.json ──┐ + │ ├─► merge_hw_layout.py ──► _anlage.json + .md (§5e) + │ hw_import.py ─────┘ (TRO slots <- BMK, conveyors K0102 -> Kreisel3) + │ ▲ *_TIA.xlsx + *_positionsconv.json (§5d) + │ └── same derivation ──► tro_annotate.py ──► _annotated.dxf (VIEWER ONLY) (also needs the BricsCAD DXF; uses dxf_registration.py for the CSV↔DXF coordinate offset and tro_catalog.py for marker shape/color per TRO type) @@ -655,6 +665,141 @@ Tests: `tests/test_e_planning.py` (reading, rules, numbering vs. distance on a storage-line grid, target distance, uniqueness, registration, drives, the path into `scl_context` and `scl_skeleton --eplan`). +> **Since 2026-10-15** the E model (`EDevice`, `load_positionsdraw`, +> `pair_drives`) lives in `hw_import.py` and the assignment logic (rules, +> spatial, numbering rule, registration) in `merge_hw_layout.py`; `e_planning.py` +> imports and re-exports them and keeps only `EplanDocument`, the CSV path, +> report and CLI. Results are unchanged (Mubea: 43 names in the SCL). It stays +> until `scl_skeleton` reads `_anlage.json`. The stored registration now +> carries a SHA-256 of the positions file: a new file with the same name is +> registered anew instead of silently reusing the old offset. `--eplan` (and +> every `--file`-style input) also resolves a relative sub-folder against +> `%SKEL_DATA%` (`Mubea/Mubea_positionsdraw.json`). + +## 5d. `hw_import.py` — read the hardware planning (TIA import + positions file) + +Stage 2 of [`Konzept_Layout_Hardware_SCL.md`](Konzept_Layout_Hardware_SCL.md). +Two sources are enough (finding in [`E-Planung_Bruecke.md`](E-Planung_Bruecke.md), +"Nachprüfung 2026-10-15"); the EA list and the WSCAD import add nothing once a +positions file exists. + +``` +*_TIA.xlsx (PLC Tags + Constants) ─┐ + ├─► hw_import.py ──► _hw.json (HardwareDocument, sps_skel-hw-v1) +*_positionsconv.json (optional) ────┘ ──► _hw.md (report: devices, drives, checks) +``` + +- **TIA import**: sheets are recognised by their header row, not their name + (English and German TIA exports): a sheet with `Logical Address` / + `Logische Adresse` holds tags, one with `Value` / `Wert` constants. Read with + `zipfile` + XML — no openpyxl dependency. +- **Per BMK one `HwDevice`** (an `EDevice` plus): `direction` (`in`/`out` from + `%E`/`%I` vs. `%A`/`%Q`), `address`, `dataType`, `comment`, the index constant + `constant` (`cInBG0532` / `cOutMB0533` — what the SCL references) with its + `constantValue`, `conveyor` + `conveyorKind`, and `sources` (`tia`, + `positions`). +- **Conveyor from the plain text**: `KREISEL-K0102`, `KR-K0104` → `K0102`, + `K0104` (kind `Kreisel`); `CONVEYOR-#103` and `CONVEYOR-K0103` are the same + conveyor and both become `K0103` (kind `Strecke`) — the four-digit conveyor + number of the SCL naming scheme (`DB_JamArea0103`, `fbCarousel0102`). +- **Drives**: motor `MA` ↔ motor protection `FC` (same number), conveyor and + kind from their texts, encoder `GF…` of the same conveyor. +- **Cross-check** (`checks`, also printed as `?` lines): BMK only in the + positions file / field device (`BG`/`MB`/`MA`/`BX`) only in TIA, TIA comment ≠ + `BEZEICHNUNG`, signal without index constant, index constant without signal, + duplicate addresses, duplicate constant values, field device without + controller in `KENNZEICHNUNG`. The positions file's own `warnings` are passed + through. + +Switches: `--tia NAME` (required), `--positions NAME` (optional; without it no +positions), `--project NAME` (default from the positions file name: +`Mubea_positionsconv.json` → `Mubea`). Names resolve against `%SKEL_DATA%`, +also with a sub-folder. Exit `0` (findings are in the report), `1` on input or +read errors. + +Result on Mubea (`mubea-1_TIA.xlsx` + `Mubea_positionsconv.json`): 238 devices, +237 in both sources, 237 index constants, 158 field devices with position, +11 drives all with motor protection and conveyor (encoder `GF20000` at K0102); +checks: `QA7000` only in the positions file, `BG0514` without controller. + +Tests: `tests/test_hw_import.py` (xlsx reader with shared/inline strings, +header-based sheet detection, conveyor texts, merge per BMK, drives, every +cross-check, TIA-only run, JSON round trip, CLI with a sub-folder in +`%SKEL_DATA%`). The test builds its own small TIA xlsx. + +## 5e. `merge_hw_layout.py` — merge layout and hardware planning into the plant JSON + +Stage 3 of the concept. Reads **no CSV**: the TRO-JSON carries the TRO slots +and, since 2026-10-15, the positions and bounding boxes of the layout parts +(`parts`, written by `tro_flow --json`). + +``` +_tro.json + _hw.json + └──────┬──────┘ + merge_hw_layout.py rules %SKEL_CFG%\e_planning.ini, corrections %SKEL_CFG%\hw_overrides.ini + │ + ├─► _anlage.json (AnlageDocument, sps_skel-anlage-v1: + │ layout unchanged + bindings[] + conveyors[]) + └─► _anlage.md (report) +``` + +Steps: + +1. **Registration** E drawing → layout as in §5c (`IN SEP` sensors against the + layout separators), stored with the SHA-256 of the positions file. Warning + when fewer than `registration_min_share` of the separators find a partner — + a sign that layout and E drawing are different planning states. +2. **Rules** from `e_planning.ini`, unchanged logic (spatial with maximum / + target distance, numbering rule `MB = BG + 1`). +3. **Slots**: each assignment goes to the slot role of the TRO that holds the + part — key **TRO-ID + slot + role**. A `PinStore_Auto` now has a slot per + storage line (`line1` … `lineN`, same split as `scl_context._store`); + `separator1` and `line1` can be the same part and both get the binding. + Parts without a slot (outfeed elements, switches — `tro_flow` does not keep + them on the TRO yet) stay visible as `ohne_slot`. +4. **Conveyors**: the E-planning names (`K0102`) and the layout names + (`Kreisel3`) do not match, so every field device whose text names a conveyor + votes for the nearest layout conveyor (distance to its bounding box, at most + `conveyor_max_mm`). Only conveyors of the named kind vote — the bounding + boxes overlap (storage lines reach the Kreisel). Assigned at + `conveyor_min_votes` votes and `conveyor_min_share` share; one E conveyor per + layout conveyor. This gives motor, motor protection and encoder per layout + conveyor (`AnlageDocument.conveyor_for("0010-R")` → the TROs on that + Kreisel, i.e. `xInCarouselRun`) and fills `drive.motor` / + `drive.motorProtection` of Vario TROs. +5. **Corrections** from `hw_overrides.ini` (last): + `[.bindings] TRO01.separator1.sensor = BG0532` (or `= -` to clear) and + `[.conveyors] K0102 = 0010`. +6. **Status** per binding: `zugeordnet`; `offen` (required role without BMK → + later `TODO(E-Planung)`; optional `?` roles are not listed); `widerspruch` + (BMK not in the TIA import, unknown, or given to two parts); `ohne_slot`. + Every binding carries `constant` and `address` from the TIA import. + +Switches: `--tro NAME`, `--hw NAME` (both required, resolved against +`%SKEL_RESULTS%`), `--rules FILE`, `--overrides FILE`, `--re-register`. Exit +`0` also when not everything was assigned; `1` on input errors, an old +TRO-JSON without `parts`, or an unprovable registration. Parameters: +`cfg/parameter.ini [merge_hw_layout]`. + +Result on Mubea (`mubea.csv` → `mubea_tro.json`, `Mubea_hw.json`): registration +dx −151.4 / dy −696.6 mm (23/38, warning); 45 slot roles assigned (all 20 +storage-line sensors, 19 stoppers, separator sensor/stopper at TRO03/04/05/18), +58 open, 0 contradictions; 41 assignments at outfeed elements/switch without a +slot; conveyors **K0102 = Kreisel3 (21/23 votes), K0104 = Kreisel2 (8/8), +K0105 = Kreisel1 (2/3)**, each with motor and motor protection. The other +conveyors (Aufgabestation #100/#101, Vario #106–#109) are outside the CSV or +have a single vote and stay open. + +**Not yet:** `scl_skeleton` still reads `_eplan.json` (`--eplan`); +switching it to `_anlage.json` is stage 4 of the concept, and only then +`e_planning.py` becomes the thin wrapper and is removed. + +Tests: `tests/test_merge_hw_layout.py` (sensor spatial / stopper by number, +contradiction, open roles, part without slot, conveyor majority with +overlapping boxes, Vario drive, too few votes, corrections incl. moving a BMK +and clearing a conveyor, storage-line slots, missing `parts`, JSON round trip, +fingerprint of the stored registration, CLI end to end with a real offset). + ## 6. `tro_catalog.py` — TRO type catalog (library, no CLI) The single source of truth for everything needed to generate or draw a TRO @@ -701,12 +846,16 @@ tro_flow.py (writes) · scl_skeleton.py (reads) · tro_annotate.py (draws) |---|---| | `TroBase` | Core of every TRO: `id`, `type`, `confidence`, `separators`, `predecessors`, `successors`, `position` (`{x,y,z}` in JSON). `definition()`, `fb_block` (JSON `fbType`) and `items` are **read from the catalog** on every access, never stored — so they follow a type change and cannot go stale. | | `DerivedTro` | `TroBase` + what `tro_flow.py` knows from the derivation: `label`, `hosts`, `scanners`, `reason`, `colocated`, `positionFrom`, `positionPoints`, `slots`. Also used for the system boundaries `SYSn` (no slots). | -| `Slot` | A place on a TRO where hardware belongs: `slot` (`separator1`, `switch1`, `scanner1`, `drive` — the stable key together with the TRO-ID), `layoutId` (TeileId in the current export, for tracing only; empty for switches, whose element `tro_flow` does not keep yet), `roles` the hardware planning must fill (`sensor`, `stopper`, `jamSensor?` …; `?` = optional). Built by `derive_slots()` from the catalog type. | +| `Slot` | A place on a TRO where hardware belongs: `slot` (`separator1`, `switch1`, `scanner1`, `drive`, `line` for the storage lines of a store — the stable key together with the TRO-ID), `layoutId` (TeileId in the current export, for tracing only; empty for switches, whose element `tro_flow` does not keep yet), `roles` the hardware planning must fill (`sensor`, `stopper`, `jamSensor?` …; `?` = optional). Built by `derive_slots()` from the catalog type. | | `JamArea` | A jam area = the queue in front of a separator, the entry of exactly one TRO: `id` (`J`, e.g. `J0042`), `tro`, `entry` (1, 2 for 2Sep types), `separator`, `host`, `predecessors` (all TROs/stations feeding it — merges have several), `note`, `position`. Built by `derive_jam_areas()`; the SCL name `"DB_JamArea".stJam_` needs the conveyor number from the hardware planning and is not stored. | | `Connection` | Directed edge `from` → `to` with its `kind` (transfer / umlauf / weiche / unbestimmt / normal / bahn). | | `TroCandidate` | A spot reported in a review where a TRO is **expected** but not derived (formerly `OpenPoint`): `name`, `type` (the expected catalog type), `position`, `belongsTo` (the TRO that holds the part today), `note`. Not a TRO — no separators, no connections, no SCL. Built by `tro_overrides.py` from `[.openpoint.]`; empty in the TRO-JSON for now. | | `Analysis` | Working result of `tro_flow.analyse_tros` (moved here from `tro_flow.py`). | -| `TroDocument` | The TRO-JSON: `schema` (`sps_skel-tro-v4`), `source`, `created`, `note`, `tros`, `boundaries`, `connections`, `candidates`, `jamAreas`, `findings`. `from_analysis()` (derives slots and jam areas), `to_json()`, `from_json()`, `jam_areas_or_derived()` (a v3 file without `jamAreas` still renders). | +| `TroDocument` | The TRO-JSON: `schema` (`sps_skel-tro-v4`), `source`, `created`, `note`, `tros`, `boundaries`, `connections`, `candidates`, `jamAreas`, `parts`, `findings`. `from_analysis()` (derives slots and jam areas, takes `parts`), `to_json()`, `from_json()`, `jam_areas_or_derived()` (a v3 file without `jamAreas` still renders), `part()` (layout part by TeileId, lane suffix ignored). | +| `LayoutPart` | A layout part with position (`layoutId`, `kind`, `name`, `position`, `bbox`) — the target of the spatial assignment in `merge_hw_layout.py`, so that it never reads the CSV. `distance_to_box()`. Optional in v4; a file without `parts` still reads. | +| `Binding` | One assignment hardware → slot role: `tro`, `slot`, `role`, `layoutId`, `bmk`, `constant`, `address`, `source`, `rule` (`raeumlich`/`nummer`/`mehrheit`/`hand`), `distance`, `runnerUp`, `status` (`zugeordnet`/`offen`/`widerspruch`/`ohne_slot`), `note`. | +| `ConveyorBinding` | A conveyor of the E-planning (`K0102`) at a layout conveyor: `eplanName`, `layoutId`, `layoutName`, `votes`/`total`, `motor`, `protection`, `encoder`, `rule`, `status`, `note`. | +| `AnlageDocument` | The plant JSON `_anlage.json` (`sps_skel-anlage-v1`): `layout` (the TRO-JSON unchanged), `registration`, `bindings`, `conveyors`, `unassigned`, `warnings`. `binding(tro, slot, role)`, `conveyor_for(layout_id)`. Written by `merge_hw_layout.py`; the future single input of `scl_skeleton`. | `TroBase` and `TroCandidate` share the `position` handling (JSON `{x,y,z}`, `x`/`y`/`z`, `describe_position()`) through the private mixin `_Positioned`. diff --git a/lib/dxf_registration.py b/lib/dxf_registration.py index 157afcc..11a4112 100644 --- a/lib/dxf_registration.py +++ b/lib/dxf_registration.py @@ -50,6 +50,7 @@ neue Ermittlung. from __future__ import annotations +import hashlib import json import math from collections import Counter @@ -69,6 +70,7 @@ __all__ = [ "load_transform", "save_transform", "registration_path", + "file_fingerprint", ] # Rollen fuer den Lagebezug: CSV-Objektart -> Blocknamen im DXF, als Muster @@ -123,6 +125,10 @@ class Transform(BaseModel): csv_file: str = Field(default="") dxf_file: str = Field(default="") created: str = Field(default="") + # Pruefsumme der Zeichnung/E-Datei beim Ermitteln ("" = unbekannt). Der + # Schluessel enthaelt nur Dateinamen; ein neuer Stand gleichen Namens wird + # daran erkannt (siehe load_transform). + fingerprint: str = Field(default="") # -- Anwenden ---------------------------------------------------------- @@ -329,8 +335,25 @@ def _key(csv_file: str, dxf_file: str) -> str: return f"{Path(csv_file).name}|{Path(dxf_file).name}" -def load_transform(cfg_dir: Path, csv_file: str, dxf_file: str) -> Transform | None: - """Geprueften Lagebezug aus der Konfiguration lesen.""" +def file_fingerprint(path: Path) -> str: + """SHA-256 einer Datei (Hex) - Kennung des Planungsstands.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_transform(cfg_dir: Path, csv_file: str, dxf_file: str, + fingerprint: str = "") -> Transform | None: + """Geprueften Lagebezug aus der Konfiguration lesen. + + Mit ``fingerprint``: nur verwenden, wenn der abgelegte Bezug fuer genau + diesen Dateistand ermittelt wurde. Ein Eintrag ohne Pruefsumme (aeltere + Ablage) oder mit anderer gilt als veraltet -> None, der Aufrufer ermittelt + neu. Ohne ``fingerprint`` (z. B. tro_annotate) wie bisher nur ueber den + Dateinamen. + """ path = registration_path(cfg_dir) if not path.is_file(): return None @@ -341,6 +364,8 @@ def load_transform(cfg_dir: Path, csv_file: str, dxf_file: str) -> Transform | N entry = data.get(_key(csv_file, dxf_file)) if not entry: return None + if fingerprint and entry.get("fingerprint", "") != fingerprint: + return None transform = Transform.model_validate(entry) transform.source = "cfg" return transform diff --git a/lib/e_planning.py b/lib/e_planning.py index ad001bf..8a3bcc4 100644 --- a/lib/e_planning.py +++ b/lib/e_planning.py @@ -31,8 +31,15 @@ scl_skeleton.py --eplan liest die JSON und setzt die gefundenen Namen in die SCL ein (SclName mit todo=False). Was keine Regel trifft, bleibt dort TODO(E-Planung) - geraten wird nicht. -Schichten: e_planning importiert material_flow (CSV) und dxf_registration -(Lagebezug); scl_context importiert nur das Modell EplanDocument. +Seit 2026-10-15 liegen das E-Modell (EDevice, load_positionsdraw, +pair_drives) in hw_import.py und die Zuordnung (Regeln, raeumlich, +Nummernregel, Lagebezug) in merge_hw_layout.py - der neue Weg ueber die +Anlagen-JSON (doc/Konzept_Layout_Hardware_SCL.md). e_planning bleibt fuer +scl_skeleton --eplan, bis scl_skeleton die Anlagen-JSON liest, und +re-exportiert die verschobenen Namen. + +Schichten: e_planning importiert material_flow (CSV), hw_import und +merge_hw_layout; scl_context importiert nur das Modell EplanDocument. Aufruf ueber bin/e_planning.bat bzw. bin/e_planning.sh. """ @@ -40,27 +47,25 @@ Aufruf ueber bin/e_planning.bat bzw. bin/e_planning.sh. from __future__ import annotations import argparse -import configparser -import fnmatch -import json -import math -import re import sys -from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from pydantic import BaseModel, ConfigDict, Field -from dxf_registration import ( - RegistrationError, - Transform, - derive_transform, - load_transform, - save_transform, - verify_transform, +from dxf_registration import RegistrationError, Transform, file_fingerprint +from merge_hw_layout import ( + LAYOUT_KINDS, + RULES_FILE, + Assignment, + LayoutPoint, + Rule, + _match, + assign, + load_rules, + resolve_transform, ) -from parameter import get_float +from hw_import import Drive, EDevice, load_positionsdraw, pair_drives __all__ = [ "EPLAN_SCHEMA", @@ -74,21 +79,12 @@ __all__ = [ "load_rules", "assign", "pair_drives", + "layout_points", + "resolve_transform", + "LAYOUT_KINDS", ] EPLAN_SCHEMA = "sps_skel-eplan-v1" -RULES_FILE = "e_planning.ini" - -# Zulaessige Abweichung vom Sollabstand, wenn eine Regel kein toleranz_mm -# angibt - aus cfg/parameter.ini [e_planning] (lib/parameter.py). -DEFAULT_TOLERANCE_MM = get_float("e_planning", "toleranz_mm") - -_CONFIG = ConfigDict(extra="forbid") - -# "=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+)$") # --------------------------------------------------------------------------- @@ -96,112 +92,6 @@ _BMK = re.compile(r"^([A-Za-z]+)(\d+)$") # --------------------------------------------------------------------------- -class EDevice(BaseModel): - """Ein Sensor/Aktor/Schrankelement der E-Planung.""" - - 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 - - -@dataclass -class LayoutPoint: - """Ein punktartiges Bauteil des Layouts in CSV-Koordinaten.""" - - layout_id: str - kind: str - x: float - y: float - name: str = "" - - -@dataclass -class Rule: - """Eine Zuordnungsregel aus e_planning.ini (Abschnitt ".").""" - - role: str # "Separator.sensor" - layout: list[str] # Muster fuer die Layout-Art - verw: list[str] # Muster fuer VERW - max_mm: float | None = None # raeumlich: Hoechstabstand - number_from: str = "" # Nummernregel: Rolle des Partners ... - number_offset: int = 0 # ... und Versatz der Nummer - prefix: str = "" # BMK-Praefix, leer = egal - # Sollabstand: das Symbol liegt immer gleich weit neben dem Bauteil. - # Dann zaehlt die Abweichung vom Soll, nicht der kleinste Abstand. - target_mm: float | None = None - tolerance_mm: float = DEFAULT_TOLERANCE_MM - - def score(self, dist: float) -> float | None: - """Rangfolge eines Kandidaten (kleiner = besser), None = nicht zulaessig.""" - if self.max_mm is not None and dist > self.max_mm: - return None - if self.target_mm is None: - return dist - dev = abs(dist - self.target_mm) - return dev if dev <= self.tolerance_mm else None - - -class Assignment(BaseModel): - """Ein E-Bauteil in einer Rolle an einem Layout-Bauteil.""" - - model_config = _CONFIG - - layout_id: str - layout_kind: str - role: str - bmk: str - verw: str = "" - bezeichnung: str = "" - controller: str = "" - station: str = "" - # Abstand nach dem Lagebezug in mm (auch bei der Nummernregel zur Kontrolle) - distance: float - # "raeumlich" | "nummer" - rule: str - # Abstand des naechstbesten Kandidaten derselben Rolle (None: keiner) - runner_up: float | None = None - - -class Drive(BaseModel): - """Motor im Feld und sein Motorschutz im Schrank (gleiche Nummer).""" - - model_config = _CONFIG - - motor: str - protection: str = "" - bezeichnung: str = "" - protection_bezeichnung: str = "" - - class EplanDocument(BaseModel): """Die Zuordnungs-JSON _eplan.json.""" @@ -234,235 +124,10 @@ class EplanDocument(BaseModel): # --------------------------------------------------------------------------- -# Einlesen +# Layout # --------------------------------------------------------------------------- -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 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 _patterns(text: str) -> list[str]: - return [p.strip() for p in text.split(",") if p.strip()] - - -def load_rules(path: Path) -> tuple[list[Rule], list[str]]: - """Regeln und Anker-Verwendungen aus e_planning.ini lesen. - - Abschnitt [Registrierung] -> anker (VERW-Muster fuer den Lagebezug); - jeder Abschnitt "." ist eine Regel, in Dateireihenfolge. - """ - parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), - interpolation=None) - parser.optionxform = str # Schluessel nicht kleinschreiben - with path.open(encoding="utf-8") as fh: - parser.read_file(fh) - anchors = _patterns(parser.get("Registrierung", "anker", fallback="IN SEP")) - rules = [] - for section in parser.sections(): - if "." not in section: - continue - s = parser[section] - max_mm = s.get("max_mm") - soll = s.get("soll_mm") - rules.append(Rule( - role=section, - layout=_patterns(s.get("layout", section.split(".", 1)[0])), - verw=_patterns(s.get("verw", "*")), - max_mm=float(max_mm) if max_mm else None, - number_from=s.get("nummer_von", "").strip(), - number_offset=int(s.get("nummer_versatz", "0")), - prefix=s.get("praefix", "").strip(), - target_mm=float(soll) if soll else None, - tolerance_mm=float(s.get("toleranz_mm", str(DEFAULT_TOLERANCE_MM))), - )) - known = {r.role for r in rules} - for r in rules: - if r.number_from and r.number_from not in known: - raise ValueError(f"{path.name} [{r.role}]: nummer_von = {r.number_from} " - f"ist keine Regel") - return rules, anchors - - -def _match(text: str, patterns: list[str]) -> bool: - return any(fnmatch.fnmatchcase(text.upper(), p.upper()) for p in patterns) - - -# --------------------------------------------------------------------------- -# Zuordnung -# --------------------------------------------------------------------------- - - -@dataclass -class _State: - positions: dict[str, tuple[float, float]] - used: set[str] = field(default_factory=set) - result: list[Assignment] = field(default_factory=list) - # (layout_id, role) -> Assignment - by_slot: dict[tuple[str, str], Assignment] = field(default_factory=dict) - - -def _make(point: LayoutPoint, rule: Rule, dev: EDevice, dist: float, how: str, - runner_up: float | None) -> Assignment: - return Assignment( - layout_id=point.layout_id, layout_kind=point.kind, role=rule.role, - bmk=dev.bmk, verw=dev.verw, bezeichnung=dev.bezeichnung, - controller=dev.controller, station=dev.station, - distance=round(dist, 1), rule=how, - runner_up=None if runner_up is None else round(runner_up, 1), - ) - - -def _candidates(rule: Rule, devices: list[EDevice], state: _State) -> list[EDevice]: - return [ - d for d in devices - if d.bmk not in state.used and d.bmk in state.positions - and _match(d.verw, rule.verw) - and (not rule.prefix or d.prefix.upper() == rule.prefix.upper()) - ] - - -def _assign_spatial(rule: Rule, points: list[LayoutPoint], devices: list[EDevice], - state: _State) -> None: - """Beste Bewertung zuerst (Rule.score); je Bauteil und E-Bauteil hoechstens einmal.""" - cands = _candidates(rule, devices, state) - pairs = [] - for p in points: - if (p.layout_id, rule.role) in state.by_slot: - continue - for d in cands: - dist = math.dist((p.x, p.y), state.positions[d.bmk]) - score = rule.score(dist) - if score is not None: - pairs.append((score, dist, p.layout_id, d.bmk)) - pairs.sort() - # je Bauteil die Kandidaten in Rangfolge - fuer "naechstbester Kandidat" - by_point: dict[str, list[tuple[str, float]]] = {} - for _, dist, pid, bmk in pairs: - by_point.setdefault(pid, []).append((bmk, dist)) - point_of = {p.layout_id: p for p in points} - dev_of = {d.bmk: d for d in cands} - for _, dist, pid, bmk in pairs: - if (pid, rule.role) in state.by_slot or bmk in state.used: - continue - others = [x for b, x in by_point[pid] if b != bmk] - a = _make(point_of[pid], rule, dev_of[bmk], dist, "raeumlich", - others[0] if others else None) - state.used.add(bmk) - state.by_slot[(pid, rule.role)] = a - state.result.append(a) - - -def _assign_by_number(rule: Rule, points: list[LayoutPoint], devices: list[EDevice], - state: _State) -> None: - """Nummer = Nummer des Partners (Rolle nummer_von) + Versatz.""" - cands = {(d.number): d for d in _candidates(rule, devices, state) - if d.number is not None} - for p in points: - if (p.layout_id, rule.role) in state.by_slot: - continue - partner = state.by_slot.get((p.layout_id, rule.number_from)) - if partner is None: - continue - m = _BMK.match(partner.bmk) - if not m: - continue - dev = cands.get(int(m.group(2)) + rule.number_offset) - if dev is None or dev.bmk in state.used: - continue - dist = math.dist((p.x, p.y), state.positions[dev.bmk]) - # Auch die Nummernregel darf nicht quer ueber die Anlage greifen - if rule.max_mm is not None and dist > rule.max_mm: - continue - a = _make(p, rule, dev, dist, "nummer", None) - state.used.add(dev.bmk) - state.by_slot[(p.layout_id, rule.role)] = a - state.result.append(a) - - -def assign(points: list[LayoutPoint], devices: list[EDevice], rules: list[Rule], - transform: Transform) -> list[Assignment]: - """Regeln der Reihe nach anwenden. - - Eine Regel mit nummer_von versucht zuerst die Nummernregel und nimmt fuer - die verbleibenden Bauteile den Abstand (wenn max_mm/soll_mm gesetzt ist). So wird - das Ventil eines Separators ueber die Nummer gefunden, auch wenn das - Ventil des Nachbarn naeher liegt (Speicherlinien im 800-mm-Raster). - """ - state = _State(positions={ - d.bmk: transform.to_csv(*d.pos) for d in devices - if d.pos is not None and not d.cabinet - }) - for rule in rules: - targets = [p for p in points if _match(p.kind, rule.layout)] - if rule.number_from: - _assign_by_number(rule, targets, devices, state) - if rule.max_mm is not None or rule.target_mm is not None: - _assign_spatial(rule, targets, devices, state) - return sorted(state.result, key=lambda a: (a.layout_id, a.role)) - - -def pair_drives(devices: list[EDevice]) -> list[Drive]: - """Motor MAnnnn <-> Motorschutz FCnnnn (gleiche Nummer).""" - fc = {d.number: d for d in devices if d.prefix.upper() == "FC" and d.number is not None} - 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) - drives.append(Drive( - motor=d.bmk, - protection=schutz.bmk if schutz else "", - bezeichnung=d.bezeichnung, - protection_bezeichnung=schutz.bezeichnung if schutz else "", - )) - return drives - - -# --------------------------------------------------------------------------- -# Layout und Lagebezug -# --------------------------------------------------------------------------- - -# Element.kind der punktartigen Layout-Bauteile -LAYOUT_KINDS = ("Separator", "Scanner", "Weiche", "Ausschleus", "Einschleus", - "Beladung", "Entladung") - - def layout_points(elements) -> list[LayoutPoint]: """Punktartige Bauteile aus den CSV-Elementen (material_flow.Element).""" points = [] @@ -476,31 +141,6 @@ def layout_points(elements) -> list[LayoutPoint]: return points -def _anchor_points(points: list[LayoutPoint], devices: list[EDevice], - anchors: list[str]): - csv_pts = {"Separator": [(p.x, p.y) for p in points if p.kind == "Separator"]} - e_pts = {"Separator": [d.pos for d in devices - if d.pos is not None and not d.cabinet - and _match(d.verw, anchors)]} - return csv_pts, e_pts - - -def resolve_transform(points, devices, anchors, cfg_dir: Path, csv_name: str, - eplan_name: str, re_register: bool, - warnings: list[str]) -> Transform: - """Abgelegten Lagebezug nachrechnen oder neu ermitteln und ablegen.""" - csv_pts, e_pts = _anchor_points(points, devices, anchors) - if not re_register: - stored = load_transform(cfg_dir, csv_name, eplan_name) - if stored: - verify_transform(stored, csv_pts, e_pts) - return stored - transform = derive_transform(csv_pts, e_pts, csv_name, eplan_name) - saved = save_transform(cfg_dir, transform) - warnings.append(f"Lagebezug neu ermittelt und abgelegt: {saved}") - return transform - - # --------------------------------------------------------------------------- # Bericht # --------------------------------------------------------------------------- @@ -610,7 +250,8 @@ def build_document(csv_path: Path, eplan_path: Path, rules_path: Path, cfg_dir: devices = load_positionsdraw(eplan_path, warnings) rules, anchors = load_rules(rules_path) transform = resolve_transform(points, devices, anchors, cfg_dir, csv_path.name, - eplan_path.name, re_register, warnings) + eplan_path.name, re_register, warnings, + file_fingerprint(eplan_path)) assignments = assign(points, devices, rules, transform) used = {a.bmk for a in assignments} unassigned = sorted(d.bmk for d in devices diff --git a/lib/hw_import.py b/lib/hw_import.py new file mode 100644 index 0000000..755ab25 --- /dev/null +++ b/lib/hw_import.py @@ -0,0 +1,784 @@ +# -*- 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 -> _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``/``cOut`` 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"(? Kreisel; +# "CONVEYOR #106", "KLINKENFOERDERER" -> Strecke +_KIND_KREISEL = re.compile(r"(? 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 ``_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", 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 _hw.json fuer merge_hw_layout plus einen " + "Bericht _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 _hw.json. Standard: aus dem " + "Namen der Positionsdatei (Mubea_positionsconv.json -> Mubea)") + return parser.parse_args(argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/material_flow.py b/lib/material_flow.py index 7db9b4c..1f1d4aa 100644 --- a/lib/material_flow.py +++ b/lib/material_flow.py @@ -463,10 +463,18 @@ def env_dir(var: str, fallback: str) -> Path: def resolve_input(name: str) -> Path: - """Dateinamen gegen SKEL_DATA aufloesen; Pfadangaben bleiben unveraendert.""" + """Dateinamen gegen SKEL_DATA aufloesen. + + Ein blosser Name liegt in SKEL_DATA, ein absoluter Pfad bleibt. Ein + relativer Pfad mit Ordner ("Mubea/Mubea_positionsconv.json") gilt ab dem + aktuellen Verzeichnis, wenn er dort existiert, sonst ab SKEL_DATA. + """ candidate = Path(name) - if candidate.is_absolute() or candidate.parent != Path("."): + if candidate.is_absolute(): return candidate + if candidate.parent != Path("."): + if candidate.exists(): + return candidate return env_dir("SKEL_DATA", "data") / name diff --git a/lib/merge_hw_layout.py b/lib/merge_hw_layout.py new file mode 100644 index 0000000..24c064a --- /dev/null +++ b/lib/merge_hw_layout.py @@ -0,0 +1,969 @@ +# -*- coding: utf-8 -*- +# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" +# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. +""" +merge_hw_layout.py - Layout und Hardwareplanung zusammenfuehren -> _anlage.json. + +Stufe 3 des Ablaufs in doc/Konzept_Layout_Hardware_SCL.md: + + _tro.json (tro_flow --json) + _hw.json (hw_import) + \\ / + merge_hw_layout -> _anlage.json + Bericht .md + +Das CSV wird nicht gelesen: die TRO-JSON traegt die Slots der TROs und die +Lage der Layout-Bauteile (``parts``). Ablauf: + +1. Lagebezug E-Zeichnung -> Layout (lib/dxf_registration.py) ueber die + Separator-Sensoren (VERW "IN SEP") gegen die Separatoren des Layouts; + abgelegt in SKEL_CFG/dxf_registration.json mit Pruefsumme der + Positionsdatei - ein neuer Stand gleichen Namens wird neu ermittelt. +2. Regeln aus SKEL_CFG/e_planning.ini (raeumlich mit Hoechst-/Sollabstand, + Nummernregel MB = BG + 1) ordnen E-Bauteile Layout-Bauteilen zu. +3. Jede Zuordnung geht an die Slot-Rolle des TRO, der das Bauteil traegt + (Schluessel TRO-ID + Slot + Rolle). Bauteile ohne Slot (Ausschleus- + elemente, Weichen) bleiben als ``ohne_slot`` sichtbar. +4. Foerderer: die Namen der E-Planung (K0102) und des Layouts (Kreisel3) + passen nicht zusammen. Alle Feldgeraete, deren Klartext einen Foerderer + nennt, stimmen fuer den naechsten Layout-Foerderer ab; die Mehrheit + entscheidet (cfg/parameter.ini [merge_hw_layout]). Daraus Antrieb, Motorschutz + und Geber je Layout-Foerderer und die Antriebs-Slots der Vario-TROs. +5. Handkorrekturen aus SKEL_CFG/hw_overrides.ini, zuletzt. +6. Pflichtrollen ohne BMK werden ``offen`` (spaeter TODO(E-Planung)), ein + BMK, das nicht im TIA-Import steht, ``widerspruch``. + +Lieber eine Luecke als ein falscher Name: was keine Regel sicher trifft, +bleibt offen und steht im Bericht. + +Die Zuordnungslogik (Regeln, raeumlich, Nummernregel) stand bis 2026-10-15 +in e_planning.py; e_planning importiert sie jetzt von hier, damit +``scl_skeleton --eplan`` bis zur Umstellung auf die Anlagen-JSON weiterlaeuft. + +Aufruf ueber bin/merge_hw_layout.bat bzw. bin/merge_hw_layout.sh. +""" + +from __future__ import annotations + +import argparse +import configparser +import fnmatch +import math +import sys +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, ValidationError + +from dxf_registration import ( + RegistrationError, + Transform, + derive_transform, + load_transform, + save_transform, + verify_transform, +) +from hw_import import HW_SCHEMA, EDevice, HardwareDocument, HwDevice +from model import ( + AnlageDocument, + Binding, + ConveyorBinding, + LayoutPart, + Registration, + TroDocument, +) +from parameter import get_float, get_int + +__all__ = [ + "RULES_FILE", + "OVERRIDES_FILE", + "LAYOUT_KINDS", + "CONVEYOR_KINDS", + "LayoutPoint", + "Rule", + "Assignment", + "load_rules", + "assign", + "resolve_transform", + "points_from_parts", + "bind_slots", + "assign_conveyors", + "load_overrides", + "build_anlage", +] + +RULES_FILE = "e_planning.ini" +OVERRIDES_FILE = "hw_overrides.ini" + +# Zahlenwerte aus cfg/parameter.ini [merge_hw_layout] - Bedeutung und Einheit stehen dort. +_P = "merge_hw_layout" +DEFAULT_TOLERANCE_MM = get_float(_P, "toleranz_mm") +CONVEYOR_MAX_MM = get_float(_P, "conveyor_max_mm") +CONVEYOR_MIN_VOTES = get_int(_P, "conveyor_min_votes") +CONVEYOR_MIN_SHARE = get_float(_P, "conveyor_min_share") +REGISTRATION_MIN_SHARE = get_float(_P, "registration_min_share") + +_CONFIG = ConfigDict(extra="forbid") + +# Punktartige Layout-Bauteile (Element.kind bzw. Teil der TeileArt) +LAYOUT_KINDS = ("Separator", "Scanner", "Weiche", "Ausschleus", "Einschleus", + "Beladung", "Entladung") +# Foerderer, die fuer einen Foerderer der E-Planung in Frage kommen +CONVEYOR_KINDS = ("Kreisel", "Strecke", "Gefaellestrecke") +# Antriebs-Rollen eines Slots, die aus dem zugeordneten Foerderer folgen +_DRIVE_ROLES = {"motor": "motor", "motorProtection": "protection"} + + +# --------------------------------------------------------------------------- +# Regeln und Zuordnung (bis 2026-10-15 in e_planning.py) +# --------------------------------------------------------------------------- + + +@dataclass +class LayoutPoint: + """Ein punktartiges Bauteil des Layouts in Layout-Koordinaten.""" + + layout_id: str + kind: str + x: float + y: float + name: str = "" + + +@dataclass +class Rule: + """Eine Zuordnungsregel aus e_planning.ini (Abschnitt ".").""" + + role: str # "Separator.sensor" + layout: list[str] # Muster fuer die Layout-Art + verw: list[str] # Muster fuer VERW + max_mm: float | None = None # raeumlich: Hoechstabstand + number_from: str = "" # Nummernregel: Rolle des Partners ... + number_offset: int = 0 # ... und Versatz der Nummer + prefix: str = "" # BMK-Praefix, leer = egal + # Sollabstand: das Symbol liegt immer gleich weit neben dem Bauteil. + # Dann zaehlt die Abweichung vom Soll, nicht der kleinste Abstand. + target_mm: float | None = None + tolerance_mm: float = DEFAULT_TOLERANCE_MM + + @property + def slot_role(self) -> str: + """Rolle im Slot: "Separator.sensor" -> "sensor".""" + return self.role.split(".", 1)[-1] + + def score(self, dist: float) -> float | None: + """Rangfolge eines Kandidaten (kleiner = besser), None = nicht zulaessig.""" + if self.max_mm is not None and dist > self.max_mm: + return None + if self.target_mm is None: + return dist + dev = abs(dist - self.target_mm) + return dev if dev <= self.tolerance_mm else None + + +class Assignment(BaseModel): + """Ein E-Bauteil in einer Rolle an einem Layout-Bauteil.""" + + model_config = _CONFIG + + layout_id: str + layout_kind: str + role: str + bmk: str + verw: str = "" + bezeichnung: str = "" + controller: str = "" + station: str = "" + # Abstand nach dem Lagebezug in mm (auch bei der Nummernregel zur Kontrolle) + distance: float + # "raeumlich" | "nummer" + rule: str + # Abstand des naechstbesten Kandidaten derselben Rolle (None: keiner) + runner_up: float | None = None + + +def _patterns(text: str) -> list[str]: + return [p.strip() for p in text.split(",") if p.strip()] + + +def _match(text: str, patterns: list[str]) -> bool: + return any(fnmatch.fnmatchcase(text.upper(), p.upper()) for p in patterns) + + +def load_rules(path: Path) -> tuple[list[Rule], list[str]]: + """Regeln und Anker-Verwendungen aus e_planning.ini lesen. + + Abschnitt [Registrierung] -> anker (VERW-Muster fuer den Lagebezug); + jeder Abschnitt "." ist eine Regel, in Dateireihenfolge. + """ + parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), + interpolation=None) + parser.optionxform = str # Schluessel nicht kleinschreiben + with path.open(encoding="utf-8") as fh: + parser.read_file(fh) + anchors = _patterns(parser.get("Registrierung", "anker", fallback="IN SEP")) + rules = [] + for section in parser.sections(): + if "." not in section: + continue + s = parser[section] + max_mm = s.get("max_mm") + soll = s.get("soll_mm") + rules.append(Rule( + role=section, + layout=_patterns(s.get("layout", section.split(".", 1)[0])), + verw=_patterns(s.get("verw", "*")), + max_mm=float(max_mm) if max_mm else None, + number_from=s.get("nummer_von", "").strip(), + number_offset=int(s.get("nummer_versatz", "0")), + prefix=s.get("praefix", "").strip(), + target_mm=float(soll) if soll else None, + tolerance_mm=float(s.get("toleranz_mm", str(DEFAULT_TOLERANCE_MM))), + )) + known = {r.role for r in rules} + for r in rules: + if r.number_from and r.number_from not in known: + raise ValueError(f"{path.name} [{r.role}]: nummer_von = {r.number_from} " + f"ist keine Regel") + return rules, anchors + + +@dataclass +class _State: + positions: dict[str, tuple[float, float]] + used: set[str] = field(default_factory=set) + result: list[Assignment] = field(default_factory=list) + # (layout_id, role) -> Assignment + by_slot: dict[tuple[str, str], Assignment] = field(default_factory=dict) + + +def _make(point: LayoutPoint, rule: Rule, dev: EDevice, dist: float, how: str, + runner_up: float | None) -> Assignment: + return Assignment( + layout_id=point.layout_id, layout_kind=point.kind, role=rule.role, + bmk=dev.bmk, verw=dev.verw, bezeichnung=dev.bezeichnung, + controller=dev.controller, station=dev.station, + distance=round(dist, 1), rule=how, + runner_up=None if runner_up is None else round(runner_up, 1), + ) + + +def _candidates(rule: Rule, devices: list[EDevice], state: _State) -> list[EDevice]: + return [ + d for d in devices + if d.bmk not in state.used and d.bmk in state.positions + and _match(d.verw, rule.verw) + and (not rule.prefix or d.prefix.upper() == rule.prefix.upper()) + ] + + +def _assign_spatial(rule: Rule, points: list[LayoutPoint], devices: list[EDevice], + state: _State) -> None: + """Beste Bewertung zuerst (Rule.score); je Bauteil und E-Bauteil hoechstens einmal.""" + cands = _candidates(rule, devices, state) + pairs = [] + for p in points: + if (p.layout_id, rule.role) in state.by_slot: + continue + for d in cands: + dist = math.dist((p.x, p.y), state.positions[d.bmk]) + score = rule.score(dist) + if score is not None: + pairs.append((score, dist, p.layout_id, d.bmk)) + pairs.sort() + # je Bauteil die Kandidaten in Rangfolge - fuer "naechstbester Kandidat" + by_point: dict[str, list[tuple[str, float]]] = {} + for _, dist, pid, bmk in pairs: + by_point.setdefault(pid, []).append((bmk, dist)) + point_of = {p.layout_id: p for p in points} + dev_of = {d.bmk: d for d in cands} + for _, dist, pid, bmk in pairs: + if (pid, rule.role) in state.by_slot or bmk in state.used: + continue + others = [x for b, x in by_point[pid] if b != bmk] + a = _make(point_of[pid], rule, dev_of[bmk], dist, "raeumlich", + others[0] if others else None) + state.used.add(bmk) + state.by_slot[(pid, rule.role)] = a + state.result.append(a) + + +def _assign_by_number(rule: Rule, points: list[LayoutPoint], devices: list[EDevice], + state: _State) -> None: + """Nummer = Nummer des Partners (Rolle nummer_von) + Versatz.""" + cands = {(d.number): d for d in _candidates(rule, devices, state) + if d.number is not None} + for p in points: + if (p.layout_id, rule.role) in state.by_slot: + continue + partner = state.by_slot.get((p.layout_id, rule.number_from)) + if partner is None or partner.bmk == "": + continue + number = next((d.number for d in devices if d.bmk == partner.bmk), None) + if number is None: + continue + dev = cands.get(number + rule.number_offset) + if dev is None or dev.bmk in state.used: + continue + dist = math.dist((p.x, p.y), state.positions[dev.bmk]) + # Auch die Nummernregel darf nicht quer ueber die Anlage greifen + if rule.max_mm is not None and dist > rule.max_mm: + continue + a = _make(p, rule, dev, dist, "nummer", None) + state.used.add(dev.bmk) + state.by_slot[(p.layout_id, rule.role)] = a + state.result.append(a) + + +def assign(points: list[LayoutPoint], devices: list[EDevice], rules: list[Rule], + transform: Transform) -> list[Assignment]: + """Regeln der Reihe nach anwenden. + + Eine Regel mit nummer_von versucht zuerst die Nummernregel und nimmt fuer + die verbleibenden Bauteile den Abstand (wenn max_mm/soll_mm gesetzt ist). So wird + das Ventil eines Separators ueber die Nummer gefunden, auch wenn das + Ventil des Nachbarn naeher liegt (Speicherlinien im 800-mm-Raster). + """ + state = _State(positions={ + d.bmk: transform.to_csv(*d.pos) for d in devices + if d.pos is not None and not d.cabinet + }) + for rule in rules: + targets = [p for p in points if _match(p.kind, rule.layout)] + if rule.number_from: + _assign_by_number(rule, targets, devices, state) + if rule.max_mm is not None or rule.target_mm is not None: + _assign_spatial(rule, targets, devices, state) + return sorted(state.result, key=lambda a: (a.layout_id, a.role)) + + +# --------------------------------------------------------------------------- +# Lagebezug +# --------------------------------------------------------------------------- + + +def _anchor_points(points: list[LayoutPoint], devices: list[EDevice], + anchors: list[str]): + layout_pts = {"Separator": [(p.x, p.y) for p in points if p.kind == "Separator"]} + e_pts = {"Separator": [d.pos for d in devices + if d.pos is not None and not d.cabinet + and _match(d.verw, anchors)]} + return layout_pts, e_pts + + +def resolve_transform(points, devices, anchors, cfg_dir: Path, csv_name: str, + eplan_name: str, re_register: bool, + warnings: list[str], fingerprint: str = "") -> Transform: + """Abgelegten Lagebezug nachrechnen oder neu ermitteln und ablegen. + + Mit ``fingerprint`` (Pruefsumme der Positionsdatei) gilt ein abgelegter + Bezug nur fuer genau diesen Dateistand. + """ + layout_pts, e_pts = _anchor_points(points, devices, anchors) + if not re_register: + stored = load_transform(cfg_dir, csv_name, eplan_name, fingerprint) + if stored: + verify_transform(stored, layout_pts, e_pts) + return stored + transform = derive_transform(layout_pts, e_pts, csv_name, eplan_name) + transform.fingerprint = fingerprint + saved = save_transform(cfg_dir, transform) + warnings.append(f"Lagebezug neu ermittelt und abgelegt: {saved}") + return transform + + +# --------------------------------------------------------------------------- +# Slots +# --------------------------------------------------------------------------- + + +def points_from_parts(parts: list[LayoutPart]) -> list[LayoutPoint]: + """Punktartige Bauteile aus TroDocument.parts.""" + points = [] + for p in parts: + if p.position is None: + continue + kind = next((k for k in LAYOUT_KINDS if k.lower() in p.kind.lower()), "") + if kind: + points.append(LayoutPoint(p.layout_id, kind, p.position[0], p.position[1], + p.name)) + return points + + +def _binding_from(a: Assignment, device: HwDevice | None, tro: str = "", + slot: str = "", role: str = "") -> Binding: + status, note = "zugeordnet", "" + if device is None or not device.in_tia: + status, note = "widerspruch", "BMK nicht im TIA-Import" + if not tro: + status = "ohne_slot" if status == "zugeordnet" else status + note = note or f"{a.layout_kind} {a.layout_id} haengt an keinem TRO-Slot" + return Binding( + tro=tro, slot=slot, role=role or a.role, layout_id=a.layout_id, bmk=a.bmk, + constant=device.constant if device else "", + address=device.address if device else "", + source="positionsdraw", rule=a.rule, distance=a.distance, + runner_up=a.runner_up, status=status, note=note, + ) + + +def bind_slots(doc: TroDocument, assignments: list[Assignment], + rules: list[Rule], hw: HardwareDocument) -> list[Binding]: + """Zuordnungen an die Slot-Rollen der TROs haengen. + + Ein Bauteil kann in zwei Slots desselben TRO stehen (Speicher: + ``separator1`` und ``line1``) - dann bekommen beide die Zuordnung. + """ + devices = hw.by_bmk() + slots_of: dict[str, list[tuple[str, str, list[str]]]] = {} + for tro in doc.tros: + for s in tro.slots: + if s.layout_id: + roles = [r.rstrip("?") for r in s.roles] + slots_of.setdefault(s.layout_id, []).append((tro.tro_id, s.slot, roles)) + rule_of = {r.role: r for r in rules} + bindings: list[Binding] = [] + for a in assignments: + role = rule_of[a.role].slot_role if a.role in rule_of else a.role.split(".")[-1] + targets = [(t, s) for t, s, roles in slots_of.get(a.layout_id, []) if role in roles] + if not targets: + bindings.append(_binding_from(a, devices.get(a.bmk))) + continue + for tro_id, slot in targets: + bindings.append(_binding_from(a, devices.get(a.bmk), tro_id, slot, role)) + return bindings + + +# --------------------------------------------------------------------------- +# Foerderer +# --------------------------------------------------------------------------- + + +def assign_conveyors(doc: TroDocument, hw: HardwareDocument, transform: Transform + ) -> list[ConveyorBinding]: + """Foerderer der E-Planung (K0102) per Mehrheit einem Layout-Foerderer zuordnen. + + Jedes Feldgeraet, dessen Klartext einen Foerderer nennt, stimmt fuer den + Layout-Foerderer, dessen Huellbox ihm am naechsten liegt (hoechstens + CONVEYOR_MAX_MM). Zugeordnet wird bei mindestens CONVEYOR_MIN_VOTES + Stimmen und CONVEYOR_MIN_SHARE Anteil; je Layout-Foerderer hoechstens ein + Foerderer der E-Planung (der mit den meisten Stimmen). + """ + conveyors = [p for p in doc.parts if p.kind in CONVEYOR_KINDS and p.bbox and p.position] + drives = {dr.conveyor: dr for dr in hw.drives if dr.conveyor} + kind_of = _conveyor_kinds(hw) + votes: dict[str, Counter] = {} + totals: Counter = Counter() + for d in hw.field_devices(): + if not d.conveyor: + continue + totals[d.conveyor] += 1 + # Die Huellboxen ueberlappen (Speicherlinien reichen bis an den + # Kreisel); nennt der Klartext die Art, stimmen nur Foerderer dieser Art. + kinds = _LAYOUT_KINDS_FOR.get(kind_of.get(d.conveyor, ""), CONVEYOR_KINDS) + x, y = transform.to_csv(*d.pos) + best = min(((c.distance_to_box(x, y), c.layout_id) for c in conveyors + if c.kind in kinds), default=(None, "")) + if best[0] is not None and best[0] <= CONVEYOR_MAX_MM: + votes.setdefault(d.conveyor, Counter())[best[1]] += 1 + names = sorted(set(totals) | set(drives)) + name_of = {p.layout_id: p.name for p in doc.parts} + + candidates = [] + for name in names: + counter = votes.get(name, Counter()) + layout_id, n = counter.most_common(1)[0] if counter else ("", 0) + candidates.append((n, name, layout_id)) + taken: dict[str, str] = {} + result = [] + for n, name, layout_id in sorted(candidates, key=lambda c: (-c[0], c[1])): + total = totals.get(name, 0) + dr = drives.get(name) + cb = ConveyorBinding(eplan_name=name, votes=n, total=total, rule="mehrheit", + motor=dr.motor if dr else "", + protection=dr.protection if dr else "", + encoder=dr.encoder if dr else "") + share = n / total if total else 0.0 + if not layout_id: + cb.status = "offen" + cb.note = ("kein Feldgeraet nennt diesen Foerderer" if not total else + f"keine Stimme innerhalb {CONVEYOR_MAX_MM:.0f} mm eines Layout-Foerderers") + elif n < CONVEYOR_MIN_VOTES or share < CONVEYOR_MIN_SHARE: + cb.status = "offen" + cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "") + cb.note = (f"unsicher: {n} von {total} Stimmen fuer {cb.layout_name or layout_id} " + f"(noetig {CONVEYOR_MIN_VOTES} und {CONVEYOR_MIN_SHARE:.0%})") + elif layout_id in taken: + cb.status = "widerspruch" + cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "") + cb.note = f"{layout_id} ist schon {taken[layout_id]} zugeordnet (mehr Stimmen)" + else: + taken[layout_id] = name + cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "") + others = votes.get(name, Counter()) + rest = [f"{name_of.get(k, k)} {v}" for k, v in others.most_common() if k != layout_id] + if rest: + cb.note = "Gegenstimmen: " + ", ".join(rest) + result.append(cb) + return sorted(result, key=lambda c: c.eplan_name) + + +# Art des Foerderers im Klartext der E-Planung -> Arten im Layout +_LAYOUT_KINDS_FOR = {"Kreisel": ("Kreisel",), "Strecke": ("Strecke", "Gefaellestrecke")} + + +def _conveyor_kinds(hw: HardwareDocument) -> dict[str, str]: + """Art je Foerderer der E-Planung: Mehrheit aller Klartexte, die ihn nennen. + + "PRUEFUNG BT. POS.1 (KR-K0102 EINFAHRT PUFFER)" und "ANTRIEB + KREISEL-K0102" sagen Kreisel; ein Geraet ohne Artwort erbt die Art. + """ + counts: dict[str, Counter] = {} + for d in hw.devices: + if d.conveyor and d.conveyor_kind: + counts.setdefault(d.conveyor, Counter())[d.conveyor_kind] += 1 + for dr in hw.drives: + if dr.conveyor and dr.conveyor_kind: + counts.setdefault(dr.conveyor, Counter())[dr.conveyor_kind] += 1 + return {name: c.most_common(1)[0][0] for name, c in counts.items()} + + +def bind_drives(doc: TroDocument, conveyors: list[ConveyorBinding], + hw: HardwareDocument) -> list[Binding]: + """Antriebs-Slots (Vario): Motor und Motorschutz des zugeordneten Foerderers.""" + devices = hw.by_bmk() + by_layout = {c.layout_id: c for c in conveyors if c.status == "zugeordnet"} + bindings = [] + for tro in doc.tros: + for s in tro.slots: + if not s.layout_id: + continue + conv = by_layout.get(s.layout_id.split("-", 1)[0]) + if conv is None: + continue + roles = [r.rstrip("?") for r in s.roles] + for role, attr in _DRIVE_ROLES.items(): + bmk = getattr(conv, attr) + if role not in roles or not bmk: + continue + dev = devices.get(bmk) + bindings.append(Binding( + tro=tro.tro_id, slot=s.slot, role=role, layout_id=s.layout_id, + bmk=bmk, constant=dev.constant if dev else "", + address=dev.address if dev else "", source="positionsdraw", + rule="mehrheit", status="zugeordnet" if dev and dev.in_tia else "widerspruch", + note=f"Antrieb von {conv.eplan_name}")) + return bindings + + +# --------------------------------------------------------------------------- +# Handkorrekturen +# --------------------------------------------------------------------------- + + +@dataclass +class Overrides: + """Handkorrekturen aus hw_overrides.ini fuer eine Anlage.""" + + # (tro, slot, role) -> BMK ("" = Zuordnung loeschen, Rolle offen) + bindings: dict[tuple[str, str, str], str] = field(default_factory=dict) + # Foerderer der E-Planung -> Layout-ID ("" = Zuordnung loeschen) + conveyors: dict[str, str] = field(default_factory=dict) + + +def load_overrides(path: Path, layout_source: str) -> Overrides: + """Abschnitte ``[.bindings]`` und ``[.conveyors]`` lesen. + + ``TRO01.separator1.sensor = BG0532`` setzt ein BMK, ``= -`` loescht die + Zuordnung (Rolle bleibt offen); ``K0102 = 0010`` setzt einen Foerderer. + """ + result = Overrides() + if not path.is_file(): + return result + parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), + interpolation=None) + parser.optionxform = str + with path.open(encoding="utf-8") as fh: + parser.read_file(fh) + section = f"{layout_source}.bindings" + if parser.has_section(section): + for key, value in parser[section].items(): + parts = key.split(".") + if len(parts) != 3: + raise ValueError(f"{path.name} [{section}] '{key}': erwartet " + f"..") + result.bindings[(parts[0], parts[1], parts[2].rstrip("?"))] = \ + "" if value.strip() == "-" else value.strip() + section = f"{layout_source}.conveyors" + if parser.has_section(section): + for key, value in parser[section].items(): + result.conveyors[key.strip()] = "" if value.strip() == "-" else value.strip() + return result + + +def _apply_overrides(doc: TroDocument, bindings: list[Binding], + conveyors: list[ConveyorBinding], overrides: Overrides, + hw: HardwareDocument, warnings: list[str]) -> list[Binding]: + devices = hw.by_bmk() + slots = {(t.tro_id, s.slot): s for t in doc.tros for s in t.slots} + for (tro, slot, role), bmk in overrides.bindings.items(): + s = slots.get((tro, slot)) + if s is None: + warnings.append(f"{OVERRIDES_FILE}: {tro}.{slot} gibt es in der TRO-JSON nicht " + f"- Korrektur nicht angewendet") + continue + bindings = [b for b in bindings + if not (b.tro == tro and b.slot == slot and b.role == role)] + if not bmk: + continue + # dasselbe BMK nicht zusaetzlich automatisch an anderer Stelle + bindings = [b for b in bindings if b.bmk != bmk or b.rule == "hand"] + dev = devices.get(bmk) + bindings.append(Binding( + tro=tro, slot=slot, role=role, layout_id=s.layout_id, bmk=bmk, + constant=dev.constant if dev else "", address=dev.address if dev else "", + source=OVERRIDES_FILE, rule="hand", + status="zugeordnet" if dev and dev.in_tia else "widerspruch", + note="" if dev and dev.in_tia else + ("BMK nicht im TIA-Import" if dev else "BMK unbekannt in der Hardwareplanung"))) + name_of = {p.layout_id: p.name for p in doc.parts} + for name, layout_id in overrides.conveyors.items(): + cb = next((c for c in conveyors if c.eplan_name == name), None) + if cb is None: + cb = ConveyorBinding(eplan_name=name) + conveyors.append(cb) + if layout_id and doc.part(layout_id) is None: + warnings.append(f"{OVERRIDES_FILE}: Foerderer {name} -> {layout_id}: Layout-ID " + f"unbekannt - Korrektur nicht angewendet") + continue + cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "") + cb.rule = "hand" + cb.status = "zugeordnet" if layout_id else "offen" + cb.note = "von Hand" + return bindings + + +# --------------------------------------------------------------------------- +# Zusammenbau +# --------------------------------------------------------------------------- + + +def _open_and_conflicts(doc: TroDocument, bindings: list[Binding]) -> list[Binding]: + """Pflichtrollen ohne BMK als ``offen``; doppelt vergebene BMK als Widerspruch.""" + have = {(b.tro, b.slot, b.role) for b in bindings if b.tro} + for tro in doc.tros: + for s in tro.slots: + for role in s.roles: + if role.endswith("?") or (tro.tro_id, s.slot, role) in have: + continue + bindings.append(Binding(tro=tro.tro_id, slot=s.slot, role=role, + layout_id=s.layout_id, status="offen")) + # Ein BMK an zwei verschiedenen Bauteilen ist ein Widerspruch. Derselbe + # Separator in zwei Slots eines Speichers (separator1/line1) ist keiner. + places: dict[str, set[tuple[str, str]]] = {} + for b in bindings: + if b.bmk and b.status != "offen": + places.setdefault(b.bmk, set()).add((b.layout_id, b.role)) + for b in bindings: + if b.bmk and len(places.get(b.bmk, ())) > 1 and b.status == "zugeordnet": + b.status = "widerspruch" + b.note = "BMK mehrfach vergeben" + order = {t.tro_id: i for i, t in enumerate(doc.tros)} + return sorted(bindings, key=lambda b: (b.tro == "", order.get(b.tro, 0), b.slot, + b.role, b.layout_id)) + + +def build_anlage(doc: TroDocument, hw: HardwareDocument, rules: list[Rule], + anchors: list[str], cfg_dir: Path, layout_file: str, hw_file: str, + overrides: Overrides | None = None, re_register: bool = False, + transform: Transform | None = None) -> AnlageDocument: + """Kompletter Lauf ohne Ausgabe: TRO-JSON + Hardware-JSON -> AnlageDocument. + + ``transform`` vorgeben (Tests) ueberspringt die Ermittlung des Lagebezugs. + """ + warnings: list[str] = [] + if not doc.parts: + raise ValueError(f"{layout_file} enthaelt keine 'parts' (Lage der Bauteile) - " + f"mit tro_flow --json neu erzeugen") + points = points_from_parts(doc.parts) + devices: list[EDevice] = list(hw.devices) + + assignments: list[Assignment] = [] + conveyors: list[ConveyorBinding] = [] + if hw.field_devices(): + if transform is None: + transform = resolve_transform(points, devices, anchors, cfg_dir, doc.source, + hw.positions_file, re_register, warnings, + hw.positions_sha256) + share = transform.matched / transform.candidates if transform.candidates else 1.0 + if share < REGISTRATION_MIN_SHARE: + warnings.append( + f"Lagebezug passt nur fuer {transform.matched} von {transform.candidates} " + f"Separatoren ({share:.0%}) - Layout und E-Zeichnung sind vermutlich " + f"verschiedene Planungsstaende; raeumliche Zuordnungen pruefen") + assignments = assign(points, devices, rules, transform) + conveyors = assign_conveyors(doc, hw, transform) + else: + warnings.append("Hardwareplanung ohne Lage (keine Positionsdatei) - keine " + "raeumliche Zuordnung, alle Rollen offen") + + bindings = bind_slots(doc, assignments, rules, hw) + if overrides is not None: + bindings = _apply_overrides(doc, bindings, conveyors, overrides, hw, warnings) + bindings += bind_drives(doc, conveyors, hw) + bindings = _open_and_conflicts(doc, bindings) + + used = {b.bmk for b in bindings if b.bmk} + unassigned = sorted(d.bmk for d in hw.field_devices() if d.bmk not in used) + registration = None + if transform is not None: + registration = Registration( + dx=round(transform.dx, 1), dy=round(transform.dy, 1), + rotation=round(transform.rotation, 4), source=transform.source, + matched=transform.matched, candidates=transform.candidates, + residual_max=round(transform.residual_max, 1), + description=transform.describe()) + return AnlageDocument( + layout_file=layout_file, hw_file=hw_file, project=hw.project, + registration=registration, layout=doc, bindings=bindings, + conveyors=sorted(conveyors, key=lambda c: c.eplan_name), + unassigned=unassigned, warnings=warnings + hw.checks, + ) + + +# --------------------------------------------------------------------------- +# Bericht +# --------------------------------------------------------------------------- + + +def render_report(anlage: AnlageDocument, hw: HardwareDocument) -> str: + out: list[str] = [] + add = out.append + devices = hw.by_bmk() + add(f"# Anlage: {anlage.layout.source} + {anlage.project}") + add("") + add(f"- Layout: `{anlage.layout_file}`") + add(f"- Hardwareplanung: `{anlage.hw_file}` (TIA `{hw.tia_file or '-'}`, " + f"Positionen `{hw.positions_file or '-'}`)") + add(f"- Erzeugt: {anlage.created}") + if anlage.registration: + add(f"- Lagebezug: {anlage.registration.description}") + add("") + + add("## Slot-Rollen") + add("") + add("| Slot-Art | Rolle | zugeordnet | offen | Widerspruch |") + add("|---|---|---|---|---|") + stats: dict[tuple[str, str], Counter] = {} + for b in anlage.bindings: + if not b.tro: + continue + kind = b.slot.rstrip("0123456789") + stats.setdefault((kind, b.role), Counter())[b.status] += 1 + for (kind, role), c in sorted(stats.items()): + add(f"| {kind} | {role} | {c['zugeordnet']} | {c['offen']} | {c['widerspruch']} |") + add("") + + add("## Foerderer (E-Planung -> Layout)") + add("") + if anlage.conveyors: + add("| E-Planung | Layout | Stimmen | Motor | Motorschutz | Geber | Status | Hinweis |") + add("|---|---|---|---|---|---|---|---|") + for c in anlage.conveyors: + layout = f"{c.layout_name} ({c.layout_id})" if c.layout_id else "-" + add(f"| {c.eplan_name} | {layout} | {c.votes}/{c.total} | {c.motor or '-'} " + f"| {c.protection or '-'} | {c.encoder or '-'} | {c.status} | {c.note} |") + else: + add("- keine") + add("") + + add("## Zuordnungen je TRO") + add("") + add("Abstand nach dem Lagebezug; \"2.\" = naechstbester Kandidat (knapp = pruefen).") + add("") + add("| TRO | Slot | Rolle | Layout | BMK | Konstante | Adresse | VERW | Abstand mm " + "| 2. | Regel | Status |") + add("|---|---|---|---|---|---|---|---|---|---|---|---|") + for b in anlage.bindings: + if not b.tro or b.status == "offen": + continue + d = devices.get(b.bmk) + dist = "" if b.distance is None else f"{b.distance:.0f}" + second = "" if b.runner_up is None else f"{b.runner_up:.0f}" + status = b.status + (f" ({b.note})" if b.note and b.status != "zugeordnet" else "") + add(f"| {b.tro} | {b.slot} | {b.role} | {b.layout_id} | {b.bmk} | {b.constant} " + f"| {b.address} | {d.verw if d else ''} | {dist} | {second} | {b.rule} | {status} |") + add("") + + add("## Offene Pflichtrollen") + add("") + open_by: dict[str, list[str]] = {} + for b in anlage.bindings: + if b.status == "offen": + open_by.setdefault(b.tro, []).append(f"{b.slot}.{b.role}") + if open_by: + for tro, roles in open_by.items(): + add(f"- **{tro}**: {', '.join(roles)}") + else: + add("- keine") + add("") + + loose = [b for b in anlage.bindings if not b.tro] + add("## Zugeordnet, aber ohne TRO-Slot") + add("") + if loose: + add("Diese Bauteile haengen (noch) an keinem Slot der TRO-JSON, z. B. " + "Ausschleuselemente: welcher TRO-Ausgang zu welchem Element gehoert, " + "weiss tro_flow noch nicht.") + add("") + add("| Regel | Layout | BMK | Abstand mm | Status |") + add("|---|---|---|---|---|") + for b in loose: + dist = "" if b.distance is None else f"{b.distance:.0f}" + add(f"| {b.role} | {b.layout_id} | {b.bmk} | {dist} | {b.status} |") + else: + add("- keine") + add("") + + add("## Feldgeraete ohne Zuordnung") + add("") + if anlage.unassigned: + add("| BMK | VERW | Bezeichnung | Station |") + add("|---|---|---|---|") + for bmk in anlage.unassigned: + d = devices[bmk] + add(f"| {bmk} | {d.verw} | {d.bezeichnung} | {d.controller}-{d.station} |") + else: + add("- keine") + add("") + if anlage.warnings: + add("## Hinweise") + add("") + for w in anlage.warnings: + add(f"- {w}") + add("") + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _in_results(name: str, results: Path) -> Path: + path = Path(name) + if path.is_absolute() or path.parent != Path(".") or path.is_file(): + return path + return results / name + + +def main(argv: list[str] | None = None) -> int: + from material_flow import env_dir + + args = parse_args(argv) + results = env_dir("SKEL_RESULTS", "results") + cfg_dir = env_dir("SKEL_CFG", "cfg") + tro_path = _in_results(args.tro, results) + hw_path = _in_results(args.hw, results) + rules_path = Path(args.rules) if args.rules else cfg_dir / RULES_FILE + overrides_path = Path(args.overrides) if args.overrides else cfg_dir / OVERRIDES_FILE + for label, path in (("TRO-JSON", tro_path), ("Hardware-JSON", hw_path), + ("Regeln", rules_path)): + if not path.is_file(): + print(f"FEHLER: {label} nicht gefunden: {path}", file=sys.stderr) + return 1 + + try: + doc = TroDocument.from_json(tro_path.read_text(encoding="utf-8")) + except ValidationError as exc: + print(f"FEHLER: {tro_path} ist keine gueltige TRO-JSON ({exc.error_count()} " + f"Abweichungen) - mit tro_flow --json neu erzeugen.", file=sys.stderr) + return 1 + try: + hw = HardwareDocument.from_json(hw_path.read_text(encoding="utf-8")) + except ValidationError as exc: + print(f"FEHLER: {hw_path} passt nicht zum Schema '{HW_SCHEMA}' " + f"({exc.error_count()} Abweichungen) - mit hw_import neu erzeugen.", + file=sys.stderr) + return 1 + + try: + rules, anchors = load_rules(rules_path) + overrides = load_overrides(overrides_path, doc.source) + anlage = build_anlage(doc, hw, rules, anchors, cfg_dir, tro_path.name, hw_path.name, + overrides, args.re_register) + except RegistrationError as exc: + print(f"FEHLER: Lagebezug E-Zeichnung -> Layout nicht nachweisbar: {exc}", + file=sys.stderr) + print(f" Passen Layout und E-Zeichnung zusammen? Anker-VERW in " + f"{rules_path.name} [Registrierung] pruefen.", file=sys.stderr) + return 1 + except (OSError, ValueError) as exc: + print(f"FEHLER: {exc}", file=sys.stderr) + return 1 + + stem = Path(doc.source).stem or tro_path.stem.removesuffix("_tro") + results.mkdir(parents=True, exist_ok=True) + json_file = results / f"{stem}_anlage.json" + md_file = results / f"{stem}_anlage.md" + json_file.write_text(anlage.to_json(), encoding="utf-8") + md_file.write_text(render_report(anlage, hw), encoding="utf-8") + + status = Counter(b.status for b in anlage.bindings) + print("") + print("================================================================") + print("LAYOUT + HARDWAREPLANUNG -> ANLAGE") + print("================================================================") + print(f"TRO-JSON = {tro_path}") + print(f"Hardware-JSON = {hw_path}") + print(f"Lagebezug = {anlage.registration.description if anlage.registration else '-'}") + print(f"Slot-Rollen = {status['zugeordnet']} zugeordnet, {status['offen']} offen, " + f"{status['widerspruch']} Widerspruch") + print(f"Ohne Slot = {status['ohne_slot']} Zuordnungen an Bauteilen ohne TRO-Slot") + conv_ok = [c for c in anlage.conveyors if c.status == "zugeordnet"] + print(f"Foerderer = {len(conv_ok)} von {len(anlage.conveyors)} zugeordnet" + + (": " + ", ".join(f"{c.eplan_name}={c.layout_name or c.layout_id}" + for c in conv_ok) if conv_ok else "")) + print(f"Ohne Zuordnung = {len(anlage.unassigned)} Feldgeraete") + for w in anlage.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="merge_hw_layout", + description="Fuehrt die TRO-JSON (tro_flow --json) und die Hardware-JSON " + "(hw_import) zusammen: ordnet Sensoren, Ventile und Antriebe den " + "Slots der TROs zu und schreibt _anlage.json plus einen " + "Bericht _anlage.md.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Beispiele:\n" + " merge_hw_layout.bat --tro mubea_tro.json --hw Mubea_hw.json\n" + " merge_hw_layout.bat --tro mubea_tro.json --hw Mubea_hw.json --re-register\n" + "\n" + "Exit-Codes:\n" + " 0 ok (auch wenn nicht alles zugeordnet wurde - siehe Bericht)\n" + " 1 Eingabe-, Aufruf- oder Lagebezugsfehler\n", + ) + parser.add_argument("--tro", required=True, metavar="NAME", + help="TRO-JSON _tro.json in %%SKEL_RESULTS%% (oder ein Pfad)") + parser.add_argument("--hw", required=True, metavar="NAME", + help="Hardware-JSON _hw.json in %%SKEL_RESULTS%% " + "(oder ein Pfad)") + parser.add_argument("--rules", metavar="DATEI", + help=f"Regeldatei. Standard: %%SKEL_CFG%%\\{RULES_FILE}") + parser.add_argument("--overrides", metavar="DATEI", + help=f"Handkorrekturen. Standard: %%SKEL_CFG%%\\{OVERRIDES_FILE} " + f"(fehlt sie, keine)") + parser.add_argument("--re-register", action="store_true", + help="Lagebezug neu ermitteln, auch wenn in %%SKEL_CFG%% " + "einer abgelegt ist") + return parser.parse_args(argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/model.py b/lib/model.py index 94080bd..264ce75 100644 --- a/lib/model.py +++ b/lib/model.py @@ -26,9 +26,17 @@ Klassen: Hardwareplanung dort fuellen muss JamArea ein Staubereich: Eingang genau eines TRO (je Separator laut Katalog), mit allen Vorgaengern, die dort einfahren + LayoutPart ein Bauteil des Layouts mit Lage und Huellbox - das Ziel + der raeumlichen Zuordnung in merge_hw_layout (die TRO-JSON traegt + es, damit merge_hw_layout das CSV nicht lesen muss) Analysis Arbeitsergebnis von tro_flow.analyse_tros TroDocument die TRO-JSON (_tro.json): von tro_flow --json geschrieben, von scl_skeleton gelesen und geprueft + Binding eine Zuordnung Hardware -> TRO-Slot-Rolle (merge_hw_layout) + ConveyorBinding ein Foerderer der Hardwareplanung (K0102) an einem + Layout-Foerderer, mit Antrieb, Motorschutz, Geber + AnlageDocument die Anlagen-JSON (_anlage.json): Layout unveraendert + plus bindings[] - von merge_hw_layout geschrieben In der JSON stehen die Schluessel wie bisher in der TRO-JSON (`id`, `type`, `fbType`, `candidates`, ...); im Python-Code heissen die Felder weiter @@ -59,22 +67,44 @@ __all__ = [ "TroBase", "Slot", "JamArea", + "LayoutPart", "DerivedTro", "Connection", "TroCandidate", "Analysis", "TroDocument", + "Registration", + "Binding", + "ConveyorBinding", + "AnlageDocument", + "ANLAGE_SCHEMA", + "BINDING_STATUS", "derive_slots", "derive_jam_areas", + "storage_lines", ] # Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung), # v2 die von tro_flow --json (aus dem CSV, ueber dieses Modell), v3 wie v2 mit # `candidates` statt `openPoints`, v4 wie v3 mit `slots` je TRO und `jamAreas`. # Eine v3-Datei liest sich weiter (die neuen Felder fehlen dann und werden beim -# Rendern abgeleitet, siehe TroDocument.jam_areas_or_derived). +# Rendern abgeleitet, siehe TroDocument.jam_areas_or_derived). Seit 2026-10-15 +# traegt v4 optional `parts` (Lage der Layout-Bauteile fuer merge_hw_layout); eine +# v4-Datei ohne `parts` liest sich weiter, nur merge_hw_layout braucht sie. SCHEMA = "sps_skel-tro-v4" +# Kennung der Anlagen-JSON (_anlage.json, merge_hw_layout) +ANLAGE_SCHEMA = "sps_skel-anlage-v1" + +# Zustand einer Zuordnung (doc/Konzept_Layout_Hardware_SCL.md, Umbau 3): +# zugeordnet ein BMK steht fest +# offen Pflichtrolle eines Slots ohne BMK -> TODO(E-Planung) +# widerspruch BMK steht fest, passt aber nicht (fehlt im TIA-Import, +# doppelt vergeben, Handkorrektur auf unbekanntes BMK) +# ohne_slot E-Bauteil einem Layout-Bauteil zugeordnet, das (noch) an +# keinem TRO-Slot haengt (z. B. Ausschleuselemente) +BINDING_STATUS = ("zugeordnet", "offen", "widerspruch", "ohne_slot") + # Gemeinsame Einstellungen: Pruefung bei Erzeugung und Zuweisung, unbekannte # Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt # still auf einen Standardwert zurueckzufallen), Aliase und Feldnamen gelten @@ -246,6 +276,34 @@ class JamArea(_Positioned): position: tuple[float, float, float] | None = None +class LayoutPart(_Positioned): + """ + Ein Bauteil des Layouts (eine CSV-Zeile mit Lage), z. B. Separator, + Ausschleuselement, Kreisel. + + Ziel der raeumlichen Zuordnung in merge_hw_layout: die Slots nennen nur die + ``layout_id``, die Lage steht hier. ``bbox`` ist die achsparallele + Ausdehnung (mm) um ``position`` aus der CSV-Spalte "Boundingbox" - + gebraucht, um Geraete einem langen Foerderer (Kreisel) zuzuordnen. + """ + + model_config = _CONFIG + + layout_id: str = Field(alias="layoutId") + kind: str + name: str = "" + position: tuple[float, float, float] | None = None + bbox: tuple[float, float, float] | None = None + + def distance_to_box(self, x: float, y: float) -> float | None: + """Abstand eines Punkts zur Huellbox (0 innerhalb); None ohne Lage/Box.""" + if not self.position or not self.bbox: + return None + dx = max(abs(x - self.position[0]) - self.bbox[0] / 2, 0.0) + dy = max(abs(y - self.position[1]) - self.bbox[1] / 2, 0.0) + return (dx * dx + dy * dy) ** 0.5 + + class DerivedTro(TroBase): """ Ein von tro_flow aus dem CSV abgeleiteter TRO (oder eine Systemgrenze). @@ -346,14 +404,18 @@ class TroDocument(BaseModel): connections: list[Connection] = Field(default_factory=list) candidates: list[TroCandidate] = Field(default_factory=list) jam_areas: list[JamArea] = Field(default_factory=list, alias="jamAreas") + # Layout-Bauteile mit Lage (fuer merge_hw_layout); leer in aelteren Dateien + parts: list[LayoutPart] = Field(default_factory=list) findings: list[str] = Field(default_factory=list) @classmethod - def from_analysis(cls, analysis: Analysis, source: str, note: str = "") -> TroDocument: + def from_analysis(cls, analysis: Analysis, source: str, note: str = "", + parts: list[LayoutPart] | None = None) -> TroDocument: """Dokument aus dem Ergebnis von tro_flow.analyse_tros bilden. Slots und Staubereiche werden hier abgeleitet, damit die JSON sie - traegt und nicht erst das Rendern sie kennt. + traegt und nicht erst das Rendern sie kennt. ``parts`` (Lage der + Layout-Bauteile) gibt tro_flow aus dem CSV mit. """ tros = [t.model_copy(update={"slots": derive_slots(t)}) for t in analysis.tros] return cls( @@ -362,6 +424,7 @@ class TroDocument(BaseModel): tros=tros, boundaries=analysis.boundaries, jam_areas=derive_jam_areas(tros), + parts=list(parts or []), connections=[ Connection(src=src, dst=dst, kind=analysis.edge_kinds.get((src, dst), "")) for src, dst in analysis.edges @@ -369,6 +432,11 @@ class TroDocument(BaseModel): findings=analysis.findings, ) + def part(self, layout_id: str) -> LayoutPart | None: + """Layout-Bauteil zur TeileId; ein Bahn-Suffix ("0010-R") zaehlt nicht.""" + base = layout_id.split("-", 1)[0] + return next((p for p in self.parts if p.layout_id == base), None) + @classmethod def from_json(cls, text: str) -> TroDocument: """JSON-Text einlesen und pruefen (pydantic.ValidationError bei Fehlern).""" @@ -389,6 +457,138 @@ class TroDocument(BaseModel): return None +# --------------------------------------------------------------------------- +# Anlagen-JSON: Layout + Zuordnungen der Hardwareplanung (merge_hw_layout) +# --------------------------------------------------------------------------- + + +class Registration(BaseModel): + """Lagebezug E-Zeichnung -> Layout, mit dem die Zuordnung lief (Nachweis). + + Kopie der Kennwerte aus dxf_registration.Transform - model.py importiert + dxf_registration bewusst nicht (Schichten, siehe Kopf). + """ + + model_config = _CONFIG + + dx: float + dy: float + rotation: float = 0.0 + source: str = "" + matched: int = 0 + candidates: int = 0 + residual_max: float = Field(default=0.0, alias="residualMax") + description: str = "" + + +class Binding(BaseModel): + """ + Eine Zuordnung: Rolle ``role`` im Slot ``slot`` des TRO ``tro`` <- BMK. + + Stabiler Schluessel ist TRO-ID + Slot + Rolle (TRO-IDs aus der ID-Datei); + ``layout_id`` dient nur zur Nachverfolgung im aktuellen Layoutstand. Bei + ``status == "ohne_slot"`` sind ``tro``/``slot`` leer und ``role`` ist die + Regel (``Ausschleus.stau``). ``constant``/``address`` kommen aus dem + TIA-Import und sind das, was im SCL steht (``cInBG0532``, ``%E053.2``). + """ + + model_config = _CONFIG + + tro: str = "" + slot: str = "" + role: str + layout_id: str = Field(default="", alias="layoutId") + bmk: str = "" + constant: str = "" + address: str = "" + # positionsdraw | hw_overrides.ini + source: str = "" + # raeumlich | nummer | hand + rule: str = "" + distance: float | None = None + runner_up: float | None = Field(default=None, alias="runnerUp") + status: str = "zugeordnet" + note: str = "" + + @field_validator("status") + @classmethod + def _known_status(cls, value: str) -> str: + if value not in BINDING_STATUS: + raise ValueError(f"status '{value}' unbekannt, erlaubt: {', '.join(BINDING_STATUS)}") + return value + + +class ConveyorBinding(BaseModel): + """ + Ein Foerderer der Hardwareplanung (``K0102``, ``K0106``) an einem + Layout-Foerderer (``layout_id`` "0010" = Kreisel3). + + Die Namenswelten passen nicht zusammen (Layout: Kreisel1..3, E-Planung: + K0102/K0104/K0105), deshalb raeumlich: alle Feldgeraete, deren + Bezeichnung den Foerderer nennt, stimmen fuer den naechsten + Layout-Foerderer ab (``votes`` von ``total``). + """ + + model_config = _CONFIG + + eplan_name: str = Field(alias="eplanName") + layout_id: str = Field(default="", alias="layoutId") + layout_name: str = Field(default="", alias="layoutName") + votes: int = 0 + total: int = 0 + motor: str = "" + protection: str = "" + encoder: str = "" + # mehrheit | hand + rule: str = "" + status: str = "zugeordnet" + note: str = "" + + +class AnlageDocument(BaseModel): + """ + Die Anlagen-JSON ``_anlage.json``: das Layout (TRO-JSON) unveraendert + unter ``layout``, daneben die Zuordnungen der Hardwareplanung. + + Geschrieben von merge_hw_layout, kuenftig die eine Eingabe von scl_skeleton + (doc/Konzept_Layout_Hardware_SCL.md, Umbau 4). + """ + + model_config = _CONFIG + + schema_id: str = Field(default=ANLAGE_SCHEMA, alias="schema") + created: str = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M")) + layout_file: str = Field(default="", alias="layoutFile") + hw_file: str = Field(default="", alias="hwFile") + project: str = "" + registration: Registration | None = None + layout: TroDocument + bindings: list[Binding] = Field(default_factory=list) + conveyors: list[ConveyorBinding] = Field(default_factory=list) + # Feldgeraete mit Lage, die keine Zuordnung bekommen haben (BMK) + unassigned: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + def binding(self, tro_id: str, slot: str, role: str) -> Binding | None: + """Zuordnung einer Slot-Rolle (Rolle ohne "?"), None wenn keine.""" + role = role.rstrip("?") + return next((b for b in self.bindings + if b.tro == tro_id and b.slot == slot and b.role == role), None) + + def conveyor_for(self, layout_id: str) -> ConveyorBinding | None: + """Foerderer der Hardwareplanung an einem Layout-Foerderer ("0010-R" geht).""" + base = layout_id.split("-", 1)[0] + return next((c for c in self.conveyors + if c.layout_id == base and c.status == "zugeordnet"), None) + + def to_json(self) -> str: + return self.model_dump_json(by_alias=True, indent=2) + "\n" + + @classmethod + def from_json(cls, text: str) -> AnlageDocument: + return cls.model_validate_json(text) + + # --------------------------------------------------------------------------- # Ableitungen aus Katalogtyp und Topologie # --------------------------------------------------------------------------- @@ -399,23 +599,43 @@ _SWITCH_ROLES = ("valve", "exitSensor?") _SCANNER_ROLES = ("reader",) _DRIVE_ROLES = ("motor", "motorProtection", "senCarrInPos", "senFinger", "senLastPos", "senJam") +_STORAGE_LINE_ROLES = ("sensor", "stopper", "jamSensor?") def _at(values: list[str], index: int) -> str: return values[index] if index < len(values) else "" +def storage_lines(tro: TroBase) -> list[str]: + """Separatoren der Lagerlinien eines Speicher-TRO. + + tro_flow legt sie als [steuernde ..., Linien ...] ab; die Linien sind die + letzten len(hosts) (eine je Speicherbahn). Fehlen Hosts, zaehlt jeder + Separator als Linie - dieselbe Regel wie scl_context._store. + """ + hosts = list(getattr(tro, "hosts", []) or []) + seps = list(tro.separators) + count = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps) + return seps[len(seps) - count:] + + def derive_slots(tro: DerivedTro) -> list[Slot]: """Slots eines TRO: Anzahl aus dem Katalog, Layout-ID aus dem TRO. Weichen tragen noch keine Layout-ID - tro_flow speichert das Weichen- - bzw. Ausschleuselement heute nicht am TRO. + bzw. Ausschleuselement heute nicht am TRO. Ein Speicher bekommt je + Lagerlinie einen Slot ``line``; der erste Separator-Slot und + ``line1`` koennen dasselbe Bauteil sein (wie im PinStore-Template). """ items = tro.items slots: list[Slot] = [] for i in range(items.get(TroItem.SEPARATOR, 0)): slots.append(Slot(slot=f"separator{i + 1}", layout_id=_at(tro.separators, i), roles=list(_SEPARATOR_ROLES))) + if items.get(TroItem.STORAGE_LINE): + for n, sep in enumerate(storage_lines(tro), start=1): + slots.append(Slot(slot=f"line{n}", layout_id=sep, + roles=list(_STORAGE_LINE_ROLES))) for i in range(items.get(TroItem.SWITCH, 0)): slots.append(Slot(slot=f"switch{i + 1}", roles=list(_SWITCH_ROLES))) for i in range(items.get(TroItem.SCANNER, 0)): diff --git a/lib/tro_flow.py b/lib/tro_flow.py index 8f52acf..c47bd95 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -179,7 +179,7 @@ from material_flow import ( resolve_input, ) from kreisel_junctions import Junction, find_junctions -from model import Analysis, DerivedTro, TroDocument +from model import Analysis, DerivedTro, LayoutPart, TroDocument from parameter import get_float, get_int from tro_ids import ( DEFAULT_TOLERANCE, @@ -2210,6 +2210,19 @@ def _tro_label(tro: DerivedTro) -> str: return _dot_label(lines) +def layout_parts(elements: list[Element]) -> list[LayoutPart]: + """Alle Bauteile mit Lage fuer die TRO-JSON (``parts``). + + Ziel der raeumlichen Zuordnung in merge_hw_layout: Separatoren, Aus-/ + Einschleuselemente, Weichen, Scanner, Foerderer (mit Huellbox). + """ + return [ + LayoutPart(layout_id=e.teile_id, kind=e.kind, name=e.name, + position=e.position, bbox=e.bbox) + for e in elements if e.position is not None + ] + + def missing_positions(analysis: Analysis) -> list[str]: """TROs/Systemgrenzen ohne Koordinate - mit ihnen ist kein lagerichtiges Layout moeglich (auch eine Systemgrenze wird als Knoten gezeichnet).""" @@ -3312,6 +3325,7 @@ def main(argv: list[str] | None = None) -> int: analysis, source=csv_file.name, note="Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die " "Zeichnung ist nur Ansicht, keine Datenquelle.", + parts=layout_parts(elements), ) json_target.write_text(document.to_json(), encoding="utf-8") json_file = json_target diff --git a/tests/test_hw_import.py b/tests/test_hw_import.py new file mode 100644 index 0000000..5be1ed2 --- /dev/null +++ b/tests/test_hw_import.py @@ -0,0 +1,279 @@ +# -*- 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 = ('' + '<{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'{value}') + elif shared: + if value not in strings: + strings.append(value) + cells.append(f'{strings.index(value)}') + else: + cells.append(f'{value}') + xml_rows.append(f'{"".join(cells)}') + files[f"xl/worksheets/sheet{n}.xml"] = ( + _HEAD.format(tag="worksheet") + f'{"".join(xml_rows)}') + sheet_xml = "".join(f'' + for n, name in enumerate(sheets, start=1)) + files["xl/workbook.xml"] = _HEAD.format(tag="workbook") + f"{sheet_xml}" + rels = "".join(f'' + for n in range(1, len(sheets) + 1)) + files["xl/_rels/workbook.xml.rels"] = ( + '' + f"{rels}") + if shared and strings: + si = "".join(f"{s}" for s in strings) + files["xl/sharedStrings.xml"] = _HEAD.format(tag="sst") + f"{si}" + 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 diff --git a/tests/test_merge_hw_layout.py b/tests/test_merge_hw_layout.py new file mode 100644 index 0000000..090e93b --- /dev/null +++ b/tests/test_merge_hw_layout.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +"""Tests fuer lib/merge_hw_layout.py - TRO-JSON + Hardware-JSON -> Anlagen-JSON. + +Synthetische Anlage nach den Mubea-Befunden (doc/E-Planung_Bruecke.md): +Separator-Sensor auf dem Separator, Stopperventil ~270 mm daneben mit +Nummer = Sensor + 1, Stausensor ~1390 mm vom Ausschleuselement, Kreisel mit +ueberlappender Speicherlinie (Gefaellestrecke), Vario-Strecke mit Antrieb. +""" + +import json +from pathlib import Path + +import pytest + +import merge_hw_layout +from dxf_registration import Transform, load_transform, save_transform +from merge_hw_layout import ( + build_anlage, + load_overrides, + load_rules, +) +from hw_import import HardwareDocument, HwDevice, conveyor_kind_of, conveyor_of, pair_drives +from model import AnlageDocument, Analysis, DerivedTro, LayoutPart, TroDocument, derive_slots + +RULES = Path(__file__).resolve().parent.parent / "cfg" / "e_planning.ini" +IDENT = Transform(dx=0.0, dy=0.0) +SEPS = {f"000{n}": ((n - 1) * 800.0, 0.0) for n in range(1, 7)} + + +def _part(layout_id, kind, x, y, bbox=None, name=""): + return LayoutPart(layout_id=layout_id, kind=kind, name=name or f"{kind}{layout_id}", + position=(x, y, 0.0), bbox=bbox) + + +def _tro(tro_id, type_name, seps, hosts=()): + return DerivedTro(tro_id=tro_id, type_name=type_name, label=tro_id, hosts=list(hosts), + separators=list(seps), scanners=[], reason="Test") + + +def layout() -> TroDocument: + parts = [_part(i, "Separator", x, y) for i, (x, y) in SEPS.items()] + parts += [ + _part("0030", "Ausschleus", 0.0, 2000.0), + _part("0010", "Kreisel", 2000.0, 5000.0, (6000.0, 800.0, 1.0), "Kreisel3"), + # Speicherlinie, deren Huellbox den Kreisel ueberdeckt (wie bei Mubea) + _part("0040", "Gefaellestrecke", 2000.0, 5000.0, (6000.0, 1200.0, 1.0), "GF_1"), + _part("0020", "Strecke", 2000.0, -3000.0, (6000.0, 800.0, 1.0), "VF_1"), + ] + tros = [ + _tro("TRO01", "1Sep", ["0001"], ["0010-R"]), + _tro("TRO02", "1Sep1Swi", ["0002"], ["0010-R"]), + _tro("TRO03", "Vario", ["0003"], ["0020"]), + _tro("TRO04", "1Sep", ["0004"]), + _tro("TRO05", "1Sep", ["0005"]), + _tro("TRO06", "1Sep", ["0006"]), + ] + return TroDocument.from_analysis(Analysis(tros=tros), source="x.csv", parts=parts) + + +def _dev(bmk, verw, pos=None, text="", tia=True, cabinet=False, direction="in"): + return HwDevice(bmk=bmk, verw=verw, bezeichnung=text, kennzeichnung="=A1+UH01-KF102", + pos=pos, cabinet=cabinet, direction=direction, + address="%E000.0" if direction == "in" else "%A000.0", + constant=(f"cIn{bmk}" if direction == "in" else f"cOut{bmk}") if tia else "", + conveyor=conveyor_of(text), conveyor_kind=conveyor_kind_of(text), + sources=["tia", "positions"] if tia else ["positions"]) + + +def hardware(shift=(0.0, 0.0)) -> HardwareDocument: + """E-Zeichnung = Layout - shift (shift = Lagebezug E -> Layout).""" + def at(x, y): + return (x - shift[0], y - shift[1]) + + devices = [] + for n in range(1, 6): + x, y = SEPS[f"000{n}"] + devices.append(_dev(f"BG0{n}01", "IN SEP", at(x, y))) + if n != 3: + # MB0502 fehlt im TIA-Import -> Widerspruch + devices.append(_dev(f"MB0{n}02", "SEP", at(x, y + 270.0), tia=(n != 5), + direction="out")) + devices += [ + _dev("BG0601", "STAU_REF-LT", at(0.0, 2000.0 + 1390.0)), + _dev("BG0701", "SYNC P1", at(1000.0, 5100.0), "PRUEFUNG BT (KR-K0102 PUFFER)"), + _dev("BG0702", "BT BELADEN", at(3000.0, 4900.0), "BT BELADEN KR-K0102"), + _dev("BG0801", "KETTENBRUCH", at(1000.0, -3000.0), "KETTENBRUCH CONVEYOR #106"), + _dev("BG0802", "UEBERGABE", at(3000.0, -3000.0), "UEBERGABE CONVEYOR #106"), + _dev("BG0900", "UEBERNAHME", at(50000.0, 0.0), "SENSOR UEBERNAHME"), + _dev("MA0202", "MOT", None, "ANTRIEB KREISEL-K0102", cabinet=True, direction="out"), + _dev("FC0202", "", None, "MOTORSCHUTZ KREISEL-K0102", cabinet=True), + _dev("MA0210", "MOT", None, "PIN CONVEYOR-#106", cabinet=True, direction="out"), + _dev("FC0210", "", None, "MOTORSCHUTZ CONVEYOR #106", cabinet=True), + ] + return HardwareDocument(project="X", positions_file="X_positionsconv.json", + positions_sha256="abc", devices=devices, + drives=pair_drives(devices)) + + +def run(doc=None, hw=None, overrides=None, transform=IDENT, tmp_path=None): + rules, anchors = load_rules(RULES) + return build_anlage(doc or layout(), hw or hardware(), rules, anchors, + tmp_path or Path("."), "x_tro.json", "X_hw.json", + overrides, transform=transform) + + +# --------------------------------------------------------------------------- +# Slots +# --------------------------------------------------------------------------- + + +def test_separator_sensor_raeumlich_stopper_ueber_nummer(): + anlage = run() + sensor = anlage.binding("TRO01", "separator1", "sensor") + stopper = anlage.binding("TRO01", "separator1", "stopper") + assert (sensor.bmk, sensor.constant, sensor.rule, sensor.status) == ( + "BG0101", "cInBG0101", "raeumlich", "zugeordnet") + assert (stopper.bmk, stopper.constant, stopper.rule) == ("MB0102", "cOutMB0102", "nummer") + assert stopper.layout_id == "0001" + + +def test_bmk_nicht_im_tia_ist_widerspruch(): + b = run().binding("TRO05", "separator1", "stopper") + assert (b.bmk, b.status, b.note) == ("MB0502", "widerspruch", "BMK nicht im TIA-Import") + + +def test_pflichtrollen_ohne_bmk_sind_offen_optionale_nicht(): + anlage = run() + roles = {(b.slot, b.role): b.status for b in anlage.bindings if b.tro == "TRO06"} + assert roles == {("separator1", "sensor"): "offen", ("separator1", "stopper"): "offen"} + # die Weiche hat noch keine Layout-ID -> Ventil offen + assert anlage.binding("TRO02", "switch1", "valve").status == "offen" + + +def test_bauteil_ohne_slot_bleibt_sichtbar(): + loose = [b for b in run().bindings if not b.tro] + assert [(b.role, b.layout_id, b.bmk, b.status) for b in loose] == [ + ("Ausschleus.stau", "0030", "BG0601", "ohne_slot")] + + +def test_unzugeordnete_feldgeraete(): + assert "BG0900" in run().unassigned + assert "BG0101" not in run().unassigned + + +# --------------------------------------------------------------------------- +# Foerderer +# --------------------------------------------------------------------------- + + +def test_foerderer_per_mehrheit_und_art(): + """K0102 nennt "KR" -> nur Kreisel stimmen, obwohl die Speicherlinie den + Kreisel ueberdeckt; #106 nennt "CONVEYOR" -> Strecke.""" + anlage = run() + by_name = {c.eplan_name: c for c in anlage.conveyors} + k = by_name["K0102"] + assert (k.layout_id, k.layout_name, k.votes, k.total, k.status) == ( + "0010", "Kreisel3", 2, 2, "zugeordnet") + assert (k.motor, k.protection) == ("MA0202", "FC0202") + assert by_name["K0106"].layout_id == "0020" + assert anlage.conveyor_for("0010-R").eplan_name == "K0102" + + +def test_vario_antrieb_aus_dem_foerderer(): + anlage = run() + motor = anlage.binding("TRO03", "drive", "motor") + schutz = anlage.binding("TRO03", "drive", "motorProtection") + assert (motor.bmk, motor.rule, motor.constant) == ("MA0210", "mehrheit", "cOutMA0210") + assert schutz.bmk == "FC0210" + assert anlage.binding("TRO03", "drive", "senJam").status == "offen" + + +def test_foerderer_mit_zu_wenig_stimmen_bleibt_offen(): + hw = hardware() + hw.devices = [d for d in hw.devices if d.bmk != "BG0802"] + k = next(c for c in run(hw=hw).conveyors if c.eplan_name == "K0106") + assert k.status == "offen" and "unsicher: 1 von 1" in k.note + + +# --------------------------------------------------------------------------- +# Handkorrekturen +# --------------------------------------------------------------------------- + + +def test_handkorrekturen(tmp_path): + ini = tmp_path / "hw_overrides.ini" + ini.write_text( + "[x.csv.bindings]\n" + "TRO06.separator1.sensor = BG0401 ; vom Nachbarn umhaengen\n" + "TRO06.separator1.stopper = MB4711\n" + "TRO02.separator1.stopper = -\n" + "TRO99.separator1.sensor = BG0101\n" + "[x.csv.conveyors]\n" + "K0102 = -\n" + "[andere.csv.bindings]\n" + "TRO01.separator1.sensor = BG0999\n", encoding="utf-8") + anlage = run(overrides=load_overrides(ini, "x.csv")) + + moved = anlage.binding("TRO06", "separator1", "sensor") + assert (moved.bmk, moved.rule, moved.source, moved.status) == ( + "BG0401", "hand", "hw_overrides.ini", "zugeordnet") + # automatisch vergeben war BG0401 an TRO04 - dort jetzt offen + assert anlage.binding("TRO04", "separator1", "sensor").status == "offen" + unknown = anlage.binding("TRO06", "separator1", "stopper") + assert (unknown.status, unknown.note) == ("widerspruch", "BMK unbekannt in der Hardwareplanung") + assert anlage.binding("TRO02", "separator1", "stopper").status == "offen" + assert any("TRO99.separator1" in w for w in anlage.warnings) + # andere Anlage nicht angewendet + assert anlage.binding("TRO01", "separator1", "sensor").bmk == "BG0101" + k = next(c for c in anlage.conveyors if c.eplan_name == "K0102") + assert (k.status, k.rule, k.layout_id) == ("offen", "hand", "") + # ohne Foerderer kein Antrieb ueber K0102, der Vario-Antrieb bleibt + assert anlage.binding("TRO03", "drive", "motor").bmk == "MA0210" + + +def test_falscher_schluessel_in_handkorrektur(tmp_path): + ini = tmp_path / "hw_overrides.ini" + ini.write_text("[x.csv.bindings]\nTRO01.sensor = BG0101\n", encoding="utf-8") + with pytest.raises(ValueError, match=".."): + load_overrides(ini, "x.csv") + + +# --------------------------------------------------------------------------- +# Speicher: Linien-Slots +# --------------------------------------------------------------------------- + + +def test_speicher_bekommt_linien_slots_und_beide_slots_die_zuordnung(): + store = _tro("TRO01", "PinStore_Auto", ["0001", "0002", "0003"], ["0016", "0017", "0018"]) + assert [(s.slot, s.layout_id) for s in derive_slots(store)] == [ + ("separator1", "0001"), ("line1", "0001"), ("line2", "0002"), ("line3", "0003"), + ("scanner1", "")] + doc = TroDocument.from_analysis(Analysis(tros=[store]), source="x.csv", + parts=[_part(i, "Separator", *SEPS[i]) for i in ("0001", "0002", "0003")]) + anlage = run(doc=doc) + a = anlage.binding("TRO01", "separator1", "sensor") + b = anlage.binding("TRO01", "line1", "sensor") + assert a.bmk == b.bmk == "BG0101" and a.status == b.status == "zugeordnet" + assert anlage.binding("TRO01", "line2", "stopper").bmk == "MB0202" + + +# --------------------------------------------------------------------------- +# JSON und Lauf +# --------------------------------------------------------------------------- + + +def test_ohne_parts_kein_lauf(): + doc = layout() + doc.parts = [] + with pytest.raises(ValueError, match="parts"): + run(doc=doc) + + +def test_anlage_json_rundreise(): + anlage = run() + again = AnlageDocument.from_json(anlage.to_json()) + assert again == anlage + data = json.loads(anlage.to_json()) + assert data["schema"] == "sps_skel-anlage-v1" + assert data["layout"]["schema"] == "sps_skel-tro-v4" + assert data["layout"]["parts"][0]["layoutId"] == "0001" + + +def test_gespeicherter_lagebezug_gilt_nur_fuer_denselben_dateistand(tmp_path): + t = Transform(dx=5.0, dy=6.0, csv_file="x.csv", dxf_file="E.json", fingerprint="aaa") + save_transform(tmp_path, t) + assert load_transform(tmp_path, "x.csv", "E.json", "aaa").dx == 5.0 + assert load_transform(tmp_path, "x.csv", "E.json", "bbb") is None + # ohne Pruefsumme (tro_annotate) wie bisher ueber den Dateinamen + assert load_transform(tmp_path, "x.csv", "E.json").dx == 5.0 + # alte Ablage ohne Pruefsumme gilt fuer einen Aufrufer mit Pruefsumme als veraltet + save_transform(tmp_path, Transform(dx=1.0, dy=1.0, csv_file="x.csv", dxf_file="Alt.json")) + assert load_transform(tmp_path, "x.csv", "Alt.json", "aaa") is None + + +def test_cli_mit_lagebezug(tmp_path, monkeypatch, capsys): + """Ende zu Ende: E-Zeichnung um (100, 50) verschoben; Lagebezug wird + ermittelt, mit Pruefsumme abgelegt und beim zweiten Lauf wiederverwendet.""" + results, cfg = tmp_path / "results", tmp_path / "cfg" + results.mkdir() + cfg.mkdir() + (cfg / "e_planning.ini").write_text(RULES.read_text(encoding="utf-8"), encoding="utf-8") + (results / "x_tro.json").write_text(layout().to_json(), encoding="utf-8") + hw = hardware(shift=(100.0, 50.0)) + (results / "X_hw.json").write_text(hw.to_json(), encoding="utf-8") + monkeypatch.setenv("SKEL_RESULTS", str(results)) + monkeypatch.setenv("SKEL_CFG", str(cfg)) + + assert merge_hw_layout.main(["--tro", "x_tro.json", "--hw", "X_hw.json"]) == 0 + first = capsys.readouterr().out + assert "Lagebezug neu ermittelt" in first + anlage = AnlageDocument.from_json((results / "x_anlage.json").read_text(encoding="utf-8")) + assert (anlage.registration.dx, anlage.registration.dy) == (100.0, 50.0) + assert anlage.binding("TRO01", "separator1", "sensor").bmk == "BG0101" + assert (results / "x_anlage.md").is_file() + stored = json.loads((cfg / "dxf_registration.json").read_text(encoding="utf-8")) + assert stored["x.csv|X_positionsconv.json"]["fingerprint"] == "abc" + + assert merge_hw_layout.main(["--tro", "x_tro.json", "--hw", "X_hw.json"]) == 0 + assert "Lagebezug neu ermittelt" not in capsys.readouterr().out + + # neuer Stand der Positionsdatei, gleicher Name -> neu ermitteln + hw.positions_sha256 = "neu" + (results / "X_hw.json").write_text(hw.to_json(), encoding="utf-8") + assert merge_hw_layout.main(["--tro", "x_tro.json", "--hw", "X_hw.json"]) == 0 + assert "Lagebezug neu ermittelt" in capsys.readouterr().out + + +def test_cli_alte_tro_json_ohne_parts(tmp_path, monkeypatch, capsys): + results = tmp_path / "results" + results.mkdir() + doc = layout() + doc.parts = [] + (results / "x_tro.json").write_text(doc.to_json(), encoding="utf-8") + (results / "X_hw.json").write_text(hardware().to_json(), encoding="utf-8") + monkeypatch.setenv("SKEL_RESULTS", str(results)) + monkeypatch.setenv("SKEL_CFG", str(Path(RULES).parent)) + assert merge_hw_layout.main(["--tro", "x_tro.json", "--hw", "X_hw.json"]) == 1 + assert "tro_flow --json neu erzeugen" in capsys.readouterr().err