hw_import + merge_hw_layout: Hardwareplanung einlesen und mit dem Layout zusammenfuehren
Stufen 2 und 3 aus doc/Konzept_Layout_Hardware_SCL.md: - lib/hw_import.py: TIA-Import (*_TIA.xlsx, ohne openpyxl gelesen) + Positionsdatei (*_positionsconv.json) -> <projekt>_hw.json: je BMK Adresse, Index-Konstante, VERW, Station, Lage, Foerderer (K0102, #106 -> K0106); Antriebe MA/FC/GF; Gegenpruefung beider Quellen. - lib/merge_hw_layout.py: <csv>_tro.json + <projekt>_hw.json -> <csv>_anlage.json (model.AnlageDocument): bindings je TRO-ID + Slot + Rolle mit Konstante und Adresse, Foerderer per Mehrheit (Mubea: K0102 = Kreisel3, K0104 = Kreisel2, K0105 = Kreisel1), Handkorrekturen cfg/hw_overrides.ini. Zuordnungslogik aus e_planning.py hierher verschoben; e_planning re-exportiert sie, Ergebnis unveraendert. - TRO-JSON traegt optional `parts` (Lage/Huellbox, tro_flow --json), PinStore bekommt Slots line<n>. - Gespeicherter Lagebezug mit SHA-256 der Positionsdatei; Unterordner in Eingabenamen relativ zu SKEL_DATA. - Doku: Python_Scripts.md 5d/5e, Konzept, E-Planung_Bruecke (Abgleich mubea_layout.dxf / CSV / E-Planung). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c4122b4497
commit
df24cf5adb
@@ -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/<csv>_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 → `<csv>_tro.json` (with slots, `jamAreas`), hardware planning →
|
||||
`hw_import.py` → `<projekt>_hw.json`, `hw_assign.py` → `<csv>_anlage.json`,
|
||||
`hw_import.py` → `<projekt>_hw.json`, `merge_hw_layout.py` → `<csv>_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` → `<projekt>_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) — `<csv>_tro.json` + `<projekt>_hw.json`
|
||||
→ `<csv>_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:
|
||||
|
||||
|
||||
@@ -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 <projekt>_hw.json und Bericht <projekt>_hw.md in %SKEL_RESULTS%.
|
||||
REM
|
||||
REM Weiter: bin\merge_hw_layout.bat --tro <csv>_tro.json --hw <projekt>_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%
|
||||
@@ -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 <projekt>_hw.json und Bericht <projekt>_hw.md in $SKEL_RESULTS.
|
||||
#
|
||||
# Weiter: bin/merge_hw_layout.sh --tro <csv>_tro.json --hw <projekt>_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" "$@"
|
||||
@@ -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 <csv>_anlage.json und Bericht <csv>_anlage.md in %SKEL_RESULTS%.
|
||||
REM
|
||||
REM Vorher: bin\tro_flow.bat --file <csv> --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%
|
||||
@@ -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 <csv>_anlage.json und Bericht <csv>_anlage.md in $SKEL_RESULTS.
|
||||
#
|
||||
# Vorher: bin/tro_flow.sh --file <csv> --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" "$@"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
; <csv>_anlage.md mit Regel "hand".
|
||||
;
|
||||
; Je Anlage (Name der CSV, aus der die TRO-JSON stammt) zwei Abschnitte:
|
||||
;
|
||||
; [<csv>.bindings]
|
||||
; <TRO-ID>.<Slot>.<Rolle> = <BMK> BMK setzen (muss im TIA-Import stehen)
|
||||
; <TRO-ID>.<Slot>.<Rolle> = - Zuordnung loeschen, Rolle bleibt offen
|
||||
; TRO-IDs aus cfg/<csv>_tro_ids.ini (stabil), Slots/Rollen aus der
|
||||
; TRO-JSON (separator1.sensor, separator1.stopper, line3.sensor,
|
||||
; drive.motor ...).
|
||||
;
|
||||
; [<csv>.conveyors]
|
||||
; <Foerderer E-Planung> = <Layout-ID> z. B. K0102 = 0010
|
||||
; <Foerderer E-Planung> = - 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
|
||||
; ================================================================
|
||||
+16
-2
@@ -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
|
||||
|
||||
+143
-14
@@ -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<BMK>`/`cOut<BMK>` (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
|
||||
`<csv>|<positionsdraw.json>` dienen.
|
||||
bestehende Mechanismus; als Schlüssel in `cfg/dxf_registration.json` dient
|
||||
`<csv>|<positionsdraw.json>`.
|
||||
|
||||
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`).
|
||||
| `xOutSw<n>ExTo3/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 <voller Pfad>\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 `<csv>|<positionsdraw.json>` 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<n>` | ~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`
|
||||
(`<projekt>_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).
|
||||
|
||||
@@ -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──> <csv>_tro.json EA/TIA/WSCAD.xlsx, positionsdraw.json
|
||||
CSV ──tro_flow──> <csv>_tro.json TIA.xlsx, positionsdraw.json
|
||||
(TROs, Abfolge, ──hw_import──> <projekt>_hw.json
|
||||
Staubereiche, Slots) (globale Variablen, Geräte)
|
||||
│ │
|
||||
└──────────── hw_assign ─────────────────┘
|
||||
└──────────── merge_hw_layout ─────────────────┘
|
||||
│
|
||||
<csv>_anlage.json (Layout + Zuordnungen)
|
||||
│
|
||||
@@ -37,8 +37,8 @@ CSV ──tro_flow──> <csv>_tro.json EA/TIA/WSCAD.xlsx, positionsd
|
||||
| Stufe | Skript | Eingabe | Ausgabe | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 Layout | `tro_flow.py --json` | CSV, `cfg/tro_overrides.ini` | `<csv>_tro.json` | vorhanden, wird erweitert |
|
||||
| 2 Hardware | `hw_import.py` | `*_TIA.xlsx`, `*_EA.xlsx`, `*_WSCAD.xlsx`, `*_positionsdraw.json` | `<projekt>_hw.json` | neu |
|
||||
| 3 Zuordnung | `hw_assign.py` | `<csv>_tro.json`, `<projekt>_hw.json`, Regeln, `cfg/hw_overrides.ini` | `<csv>_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) | `<projekt>_hw.json` | vorhanden (2026-10-15), ohne EA/WSCAD |
|
||||
| 3 Zuordnung | `merge_hw_layout.py` | `<csv>_tro.json`, `<projekt>_hw.json`, Regeln, `cfg/hw_overrides.ini` | `<csv>_anlage.json`, Bericht `.md` | vorhanden (2026-10-15, Logik aus `e_planning.py`) |
|
||||
| 4 SCL | `scl_skeleton.py` | `<csv>_anlage.json` (oder nur `<csv>_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: `<csv>_tro.json` + `<projekt>_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 →
|
||||
`<projekt>_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`): `<csv>_tro.json` +
|
||||
`<projekt>_hw.json` → `<csv>_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<n>`.
|
||||
- **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 `<NNNN>` 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: `<csv>_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 `<csv>_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` / `<csv>_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 `[<csv>.tro.<TRO-ID>]` 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 `<csv>_eplan.json` liest (Schritt 5).
|
||||
5. `scl_skeleton` auf `<csv>_anlage.json` umstellen, `--eplan` entfernen;
|
||||
`tro_annotate` auf JSON umstellen.
|
||||
6. Defaults (`cfg/defaults.ini`), `FB_CallSensors`, EA-Liste ohne Positionen.
|
||||
|
||||
+161
-12
@@ -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 → `<project>_hw.json`, merged with the TRO-JSON into
|
||||
`<csv>_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 ──► <name>_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):
|
||||
│ <name>_tro.json ──┐
|
||||
│ ├─► merge_hw_layout.py ──► <name>_anlage.json + .md (§5e)
|
||||
│ hw_import.py ─────┘ (TRO slots <- BMK, conveyors K0102 -> Kreisel3)
|
||||
│ ▲ *_TIA.xlsx + *_positionsconv.json (§5d)
|
||||
│
|
||||
└── same derivation ──► tro_annotate.py ──► <name>_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 `<csv>_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 ──► <project>_hw.json (HardwareDocument, sps_skel-hw-v1)
|
||||
*_positionsconv.json (optional) ────┘ ──► <project>_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`).
|
||||
|
||||
```
|
||||
<name>_tro.json + <project>_hw.json
|
||||
└──────┬──────┘
|
||||
merge_hw_layout.py rules %SKEL_CFG%\e_planning.ini, corrections %SKEL_CFG%\hw_overrides.ini
|
||||
│
|
||||
├─► <name>_anlage.json (AnlageDocument, sps_skel-anlage-v1:
|
||||
│ layout unchanged + bindings[] + conveyors[])
|
||||
└─► <name>_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):
|
||||
`[<csv>.bindings] TRO01.separator1.sensor = BG0532` (or `= -` to clear) and
|
||||
`[<csv>.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 `<csv>_eplan.json` (`--eplan`);
|
||||
switching it to `<csv>_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<n>` 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<Separator>`, 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<NNNN>".stJam<NNNN>_<k>` 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 `[<csv>.openpoint.<Name>]`; 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 `<csv>_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`.
|
||||
|
||||
+27
-2
@@ -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
|
||||
|
||||
+27
-386
@@ -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 "<Art>.<Rolle>")."""
|
||||
|
||||
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 <csv>_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 "<Art>.<Rolle>" 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
|
||||
|
||||
@@ -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 -> <projekt>_hw.json.
|
||||
|
||||
Stufe 2 des Ablaufs in doc/Konzept_Layout_Hardware_SCL.md. Zwei Quellen
|
||||
genuegen (Befund doc/E-Planung_Bruecke.md, "Nachpruefung 2026-10-15"):
|
||||
|
||||
* **TIA-Import** ``*_TIA.xlsx`` - Blatt "PLC Tags" (Name, Datentyp,
|
||||
Absolutadresse, Kommentar) und Blatt "Constants" (Index-Konstante
|
||||
``cIn<BMK>``/``cOut<BMK>`` je Signal). Das ist genau das, was im SCL steht
|
||||
("DB_Inputs".Sensors["cInBG0532"], "MB0533") und im TIA-Projekt existieren
|
||||
muss - deshalb die erste Quelle und die Pruefliste fuer das SCL.
|
||||
* **Positionsdatei** ``*_positionsconv.json`` / ``*_positionsdraw.json`` des
|
||||
Kabellaengen-Tools (optional) - je BMK die Lage in der E-Zeichnung, die
|
||||
Verwendung (VERW, z. B. "IN SEP"), Klartext und E/A-Station.
|
||||
|
||||
EA-Liste und WSCAD-Import bringen bei vorhandener Positionsdatei nichts
|
||||
dazu und werden nicht gelesen.
|
||||
|
||||
Ergebnis ``HardwareDocument`` (Schema sps_skel-hw-v1): je BMK ein Geraet mit
|
||||
beiden Seiten, die Antriebe (Motor MA <-> Motorschutz FC, Geber GF) je
|
||||
Foerderer der E-Planung (K0102; "#106" wird K0106), und die Gegenpruefung
|
||||
beider Quellen.
|
||||
Die Zuordnung zum Layout macht merge_hw_layout.py.
|
||||
|
||||
Die xlsx-Datei wird ohne Fremdbibliothek gelesen (zipfile + XML): der
|
||||
TIA-Import ist eine einfache Tabelle, openpyxl waere eine Abhaengigkeit nur
|
||||
dafuer.
|
||||
|
||||
Aufruf ueber bin/hw_import.bat bzw. bin/hw_import.sh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from dxf_registration import file_fingerprint
|
||||
|
||||
__all__ = [
|
||||
"HW_SCHEMA",
|
||||
"EDevice",
|
||||
"Drive",
|
||||
"TiaTag",
|
||||
"TiaConstant",
|
||||
"HwDevice",
|
||||
"HardwareDocument",
|
||||
"read_xlsx",
|
||||
"load_tia",
|
||||
"load_positionsdraw",
|
||||
"pair_drives",
|
||||
"conveyor_of",
|
||||
"conveyor_kind_of",
|
||||
"build_hardware",
|
||||
]
|
||||
|
||||
HW_SCHEMA = "sps_skel-hw-v1"
|
||||
|
||||
_CONFIG = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
# "=A1+UH01-KF107" -> Ort/Steuerung "UH01", Geraet "KF107"
|
||||
_KENNZEICHNUNG = re.compile(r"\+([A-Za-z0-9]+)-([A-Za-z0-9]+)")
|
||||
# "MB0643" -> ("MB", "0643")
|
||||
_BMK = re.compile(r"^([A-Za-z]+)(\d+)$")
|
||||
# "%E053.2" / "%I53.2" / "%A020.0" / "%Q20.0"
|
||||
_ADDRESS = re.compile(r"^%?([EIAQM])\s*(\d+)\.(\d)$", re.IGNORECASE)
|
||||
# Foerderer im Klartext der E-Planung: "KREISEL-K0102", "KR-K0104", "K0105"
|
||||
_CONVEYOR_K = re.compile(r"(?<![A-Z0-9])K(\d{4})(?!\d)")
|
||||
# ... bzw. "CONVEYOR #100", "CONVEYOR-#106", "CONVEYOR#110"
|
||||
_CONVEYOR_HASH = re.compile(r"#\s*(\d{3})(?!\d)")
|
||||
# Art des Foerderers im Klartext: "KREISEL-K0102", "KR-K0104" -> Kreisel;
|
||||
# "CONVEYOR #106", "KLINKENFOERDERER" -> Strecke
|
||||
_KIND_KREISEL = re.compile(r"(?<![A-Z])(KREISEL|KR)(?![A-Z])")
|
||||
_KIND_STRECKE = re.compile(r"CONVEYOR|CONVEYER|F(OE|Ö)RDERER")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EDevice(BaseModel):
|
||||
"""Ein Sensor/Aktor/Schrankelement der E-Planung (Positionsdatei)."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
bmk: str
|
||||
verw: str = ""
|
||||
bezeichnung: str = ""
|
||||
kennzeichnung: str = ""
|
||||
# Position in der E-Zeichnung (mm); None bei Schrankelementen ohne Lage
|
||||
pos: tuple[float, float] | None = None
|
||||
# True: aus "schaltschrank_elemente" (im Schrank, nicht im Feld)
|
||||
cabinet: bool = False
|
||||
|
||||
@property
|
||||
def controller(self) -> str:
|
||||
"""Steuerung/Ort aus der Kennzeichnung ("UH01"), "" wenn unbekannt."""
|
||||
m = _KENNZEICHNUNG.search(self.kennzeichnung)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
@property
|
||||
def station(self) -> str:
|
||||
"""E/A-Station aus der Kennzeichnung ("KF107"), "" wenn unbekannt."""
|
||||
m = _KENNZEICHNUNG.search(self.kennzeichnung)
|
||||
return m.group(2) if m else ""
|
||||
|
||||
@property
|
||||
def prefix(self) -> str:
|
||||
m = _BMK.match(self.bmk)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
@property
|
||||
def number(self) -> int | None:
|
||||
m = _BMK.match(self.bmk)
|
||||
return int(m.group(2)) if m else None
|
||||
|
||||
|
||||
class Drive(BaseModel):
|
||||
"""Motor und sein Motorschutz (gleiche Nummer), dazu Foerderer und Geber."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
motor: str
|
||||
protection: str = ""
|
||||
bezeichnung: str = ""
|
||||
protection_bezeichnung: str = ""
|
||||
# Foerderer der E-Planung aus dem Klartext ("K0102", "#106" -> "K0106")
|
||||
conveyor: str = ""
|
||||
# "Kreisel" | "Strecke" | "" aus dem Klartext (conveyor_kind_of)
|
||||
conveyor_kind: str = Field(default="", alias="conveyorKind")
|
||||
# Inkrementalgeber (GF...) desselben Foerderers
|
||||
encoder: str = ""
|
||||
|
||||
|
||||
class TiaTag(BaseModel):
|
||||
"""Eine Zeile aus dem Blatt "PLC Tags" des TIA-Imports."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
name: str
|
||||
data_type: str = Field(default="", alias="dataType")
|
||||
address: str = ""
|
||||
comment: str = ""
|
||||
|
||||
|
||||
class TiaConstant(BaseModel):
|
||||
"""Eine Zeile aus dem Blatt "Constants" des TIA-Imports."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
name: str
|
||||
data_type: str = Field(default="", alias="dataType")
|
||||
value: str = ""
|
||||
comment: str = ""
|
||||
|
||||
|
||||
class HwDevice(EDevice):
|
||||
"""
|
||||
Ein Geraet der Hardwareplanung: Positionsdatei und TIA-Import zusammen.
|
||||
|
||||
Die Felder aus EDevice kommen aus der Positionsdatei (leer, wenn das BMK
|
||||
dort fehlt), Adresse/Konstante aus dem TIA-Import (leer, wenn es dort
|
||||
fehlt). ``sources`` sagt, wo das BMK steht.
|
||||
"""
|
||||
|
||||
# "in" | "out" | "" (Merker, unbekannt)
|
||||
direction: str = ""
|
||||
address: str = ""
|
||||
data_type: str = Field(default="", alias="dataType")
|
||||
comment: str = ""
|
||||
# Index-Konstante fuer "DB_Inputs".Sensors[...] bzw. Ausgaenge
|
||||
constant: str = ""
|
||||
constant_value: int | None = Field(default=None, alias="constantValue")
|
||||
# Foerderer der E-Planung aus Bezeichnung/Kommentar ("K0102"; "#106" -> "K0106")
|
||||
conveyor: str = ""
|
||||
# "Kreisel" | "Strecke" | "" - nur aus dem eigenen Klartext
|
||||
conveyor_kind: str = Field(default="", alias="conveyorKind")
|
||||
# "tia", "positions"
|
||||
sources: list[str] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def in_tia(self) -> bool:
|
||||
return "tia" in self.sources
|
||||
|
||||
|
||||
class HardwareDocument(BaseModel):
|
||||
"""Die Hardware-JSON ``<projekt>_hw.json``."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
schema_id: str = Field(default=HW_SCHEMA, alias="schema")
|
||||
created: str = Field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M"))
|
||||
project: str = ""
|
||||
tia_file: str = Field(default="", alias="tiaFile")
|
||||
tia_sha256: str = Field(default="", alias="tiaSha256")
|
||||
positions_file: str = Field(default="", alias="positionsFile")
|
||||
positions_sha256: str = Field(default="", alias="positionsSha256")
|
||||
devices: list[HwDevice] = Field(default_factory=list)
|
||||
drives: list[Drive] = Field(default_factory=list)
|
||||
# Konstanten ohne passendes Signal (z. B. Projektkonstanten)
|
||||
constants: list[TiaConstant] = Field(default_factory=list)
|
||||
# Gegenpruefung TIA <-> Positionsdatei (je Befund eine Zeile)
|
||||
checks: list[str] = Field(default_factory=list)
|
||||
# Hinweise beim Einlesen (auch die der Positionsdatei selbst)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
def by_bmk(self) -> dict[str, HwDevice]:
|
||||
return {d.bmk: d for d in self.devices}
|
||||
|
||||
def field_devices(self) -> list[HwDevice]:
|
||||
"""Geraete im Feld mit Lage in der E-Zeichnung."""
|
||||
return [d for d in self.devices if d.pos is not None and not d.cabinet]
|
||||
|
||||
def to_json(self) -> str:
|
||||
return self.model_dump_json(by_alias=True, indent=2) + "\n"
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, text: str) -> HardwareDocument:
|
||||
return cls.model_validate_json(text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# xlsx lesen (ohne openpyxl)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NS = {
|
||||
"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
_T = "{%s}t" % _NS["m"]
|
||||
|
||||
|
||||
def _column(ref: str) -> int:
|
||||
"""'C12' -> 2 (Spalte, 0-basiert)."""
|
||||
n = 0
|
||||
for ch in re.match(r"[A-Z]+", ref).group():
|
||||
n = n * 26 + ord(ch) - 64
|
||||
return n - 1
|
||||
|
||||
|
||||
def read_xlsx(path: Path) -> dict[str, list[list[str]]]:
|
||||
"""Alle Blaetter einer xlsx-Datei als Zeilen von Texten ("" = leer).
|
||||
|
||||
Reicht fuer Tabellen-Exporte (TIA, EA-Liste): Werte, keine Formeln,
|
||||
keine Formate. Leere Zeilen fallen weg.
|
||||
"""
|
||||
with zipfile.ZipFile(path) as z:
|
||||
shared: list[str] = []
|
||||
if "xl/sharedStrings.xml" in z.namelist():
|
||||
root = ET.fromstring(z.read("xl/sharedStrings.xml"))
|
||||
shared = ["".join(t.text or "" for t in si.iter(_T))
|
||||
for si in root.findall("m:si", _NS)]
|
||||
workbook = ET.fromstring(z.read("xl/workbook.xml"))
|
||||
rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
|
||||
targets = {r.get("Id"): r.get("Target") for r in rels}
|
||||
sheets: dict[str, list[list[str]]] = {}
|
||||
for sheet in workbook.find("m:sheets", _NS):
|
||||
target = targets[sheet.get("{%s}id" % _NS["r"])].lstrip("/")
|
||||
target = target if target.startswith("xl/") else "xl/" + target
|
||||
rows = []
|
||||
for row in ET.fromstring(z.read(target)).iter("{%s}row" % _NS["m"]):
|
||||
cells: dict[int, str] = {}
|
||||
for c in row.findall("m:c", _NS):
|
||||
v = c.find("m:v", _NS)
|
||||
kind = c.get("t")
|
||||
if kind == "s" and v is not None:
|
||||
text = shared[int(v.text)]
|
||||
elif kind == "inlineStr":
|
||||
text = "".join(t.text or "" for t in c.iter(_T))
|
||||
else:
|
||||
text = v.text if v is not None and v.text is not None else ""
|
||||
if text != "":
|
||||
cells[_column(c.get("r"))] = text
|
||||
if cells:
|
||||
rows.append([cells.get(i, "") for i in range(max(cells) + 1)])
|
||||
sheets[sheet.get("name")] = rows
|
||||
return sheets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TIA-Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Spaltenkoepfe im englischen und deutschen TIA-Export
|
||||
_TAG_HEADERS = {
|
||||
"name": ("Name",),
|
||||
"data_type": ("Data Type", "Datentyp"),
|
||||
"address": ("Logical Address", "Logische Adresse", "Adresse"),
|
||||
"comment": ("Comment", "Kommentar"),
|
||||
}
|
||||
_CONST_HEADERS = {
|
||||
"name": ("Name",),
|
||||
"data_type": ("Data Type", "Datentyp"),
|
||||
"value": ("Value", "Wert"),
|
||||
"comment": ("Comment", "Kommentar"),
|
||||
}
|
||||
|
||||
|
||||
def _columns(header: list[str], wanted: dict[str, tuple[str, ...]]) -> dict[str, int] | None:
|
||||
"""Spaltennummern nach Kopfzeile; None, wenn "name" fehlt."""
|
||||
lower = [h.strip().lower() for h in header]
|
||||
found = {}
|
||||
for key, names in wanted.items():
|
||||
for n in names:
|
||||
if n.lower() in lower:
|
||||
found[key] = lower.index(n.lower())
|
||||
break
|
||||
return found if "name" in found else None
|
||||
|
||||
|
||||
def _cell(row: list[str], index: int | None) -> str:
|
||||
return row[index].strip() if index is not None and index < len(row) else ""
|
||||
|
||||
|
||||
def load_tia(path: Path, warnings: list[str] | None = None
|
||||
) -> tuple[list[TiaTag], list[TiaConstant]]:
|
||||
"""Tags und Konstanten aus einem TIA-Import (xlsx).
|
||||
|
||||
Die Blaetter werden an der Kopfzeile erkannt, nicht am Namen: ein Blatt
|
||||
mit Adressspalte sind Tags, eines mit Wertspalte Konstanten.
|
||||
"""
|
||||
warnings = warnings if warnings is not None else []
|
||||
tags: list[TiaTag] = []
|
||||
constants: list[TiaConstant] = []
|
||||
for sheet, rows in read_xlsx(path).items():
|
||||
if not rows:
|
||||
continue
|
||||
tag_cols = _columns(rows[0], _TAG_HEADERS)
|
||||
const_cols = _columns(rows[0], _CONST_HEADERS)
|
||||
if tag_cols and "address" in tag_cols:
|
||||
for row in rows[1:]:
|
||||
name = _cell(row, tag_cols["name"])
|
||||
if name:
|
||||
tags.append(TiaTag(
|
||||
name=name, data_type=_cell(row, tag_cols.get("data_type")),
|
||||
address=_cell(row, tag_cols.get("address")),
|
||||
comment=_cell(row, tag_cols.get("comment"))))
|
||||
elif const_cols and "value" in const_cols:
|
||||
for row in rows[1:]:
|
||||
name = _cell(row, const_cols["name"])
|
||||
if name:
|
||||
constants.append(TiaConstant(
|
||||
name=name, data_type=_cell(row, const_cols.get("data_type")),
|
||||
value=_cell(row, const_cols.get("value")),
|
||||
comment=_cell(row, const_cols.get("comment"))))
|
||||
else:
|
||||
warnings.append(f"TIA-Import: Blatt '{sheet}' weder Tags noch Konstanten "
|
||||
f"- uebersprungen")
|
||||
if not tags:
|
||||
raise ValueError(f"{path.name}: kein Blatt mit Tags (Spalte 'Logical Address' "
|
||||
f"bzw. 'Logische Adresse') gefunden")
|
||||
return tags, constants
|
||||
|
||||
|
||||
def _direction(address: str) -> str:
|
||||
m = _ADDRESS.match(address.strip())
|
||||
if not m:
|
||||
return ""
|
||||
area = m.group(1).upper()
|
||||
return "in" if area in "EI" else "out" if area in "AQ" else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Positionsdatei (Kabellaengen-Tool)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _device(key: str, raw: dict, cabinet: bool) -> EDevice:
|
||||
# Schluessel "BG0642@UNKNOWN" / "QA7000@A/B": vor dem @ steht das BMK.
|
||||
bmk = str(raw.get("IO") or raw.get("B") or key.split("@", 1)[0]).strip()
|
||||
pos = raw.get("pos")
|
||||
return EDevice(
|
||||
bmk=bmk,
|
||||
verw=str(raw.get("VERW") or "").strip(),
|
||||
bezeichnung=str(raw.get("BEZEICHNUNG") or "").strip(),
|
||||
kennzeichnung=str(raw.get("KENNZEICHNUNG") or "").strip(),
|
||||
pos=(float(pos[0]), float(pos[1])) if pos and len(pos) >= 2 else None,
|
||||
cabinet=cabinet,
|
||||
)
|
||||
|
||||
|
||||
def load_positionsdraw(path: Path, warnings: list[str] | None = None) -> list[EDevice]:
|
||||
"""E-Bauteile aus einer *_positionsdraw.json / *_positionsconv.json lesen.
|
||||
|
||||
Ein BMK kommt hoechstens einmal vor. Steht es doppelt da (im Schrank einmal
|
||||
mit und einmal ohne Attribute), gewinnt der Eintrag mit VERW.
|
||||
"""
|
||||
warnings = warnings if warnings is not None else []
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
devices: dict[str, EDevice] = {}
|
||||
for section, cabinet in (("sensors", False), ("schaltschrank_elemente", True)):
|
||||
for key, raw in (data.get(section) or {}).items():
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
dev = _device(key, raw, cabinet)
|
||||
if not dev.bmk:
|
||||
continue
|
||||
old = devices.get(dev.bmk)
|
||||
if old is None or (not old.verw and dev.verw):
|
||||
devices[dev.bmk] = dev
|
||||
for bmk, positions in (data.get("double_ids") or {}).items():
|
||||
warnings.append(f"E-Planung: {bmk} steht {len(positions)}x in der Zeichnung "
|
||||
f"- verwendet wird ein Eintrag")
|
||||
return list(devices.values())
|
||||
|
||||
|
||||
def _positions_warnings(path: Path) -> list[str]:
|
||||
"""Die Hinweise, die das Kabellaengen-Tool selbst in die Datei schreibt."""
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
out = []
|
||||
for kind, entries in (data.get("warnings") or {}).items():
|
||||
if isinstance(entries, dict):
|
||||
for key, text in entries.items():
|
||||
out.append(f"Positionsdatei ({kind}): {key.split('@', 1)[0]}: "
|
||||
f"{str(text).split(':', 1)[0]}")
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zusammenfuehren
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def conveyor_of(*texts: str) -> str:
|
||||
"""Foerderer der E-Planung aus einem Klartext als "K<NNNN>", sonst "".
|
||||
|
||||
Die E-Planung schreibt denselben Foerderer zweifach: "CONVEYOR-K0103"
|
||||
(Motorschutz FC0203) und "CONVEYOR-#103" (Motor MA0203). Beides ist die
|
||||
vierstellige Foerderernummer des SCL-Namensschemas (DB_JamArea0103,
|
||||
fbCarousel0102) und wird zu "K0103".
|
||||
"""
|
||||
for text in texts:
|
||||
upper = (text or "").upper()
|
||||
m = _CONVEYOR_K.search(upper)
|
||||
if m:
|
||||
return f"K{m.group(1)}"
|
||||
m = _CONVEYOR_HASH.search(upper)
|
||||
if m:
|
||||
return f"K{int(m.group(1)):04d}"
|
||||
return ""
|
||||
|
||||
|
||||
def conveyor_kind_of(*texts: str) -> str:
|
||||
"""Art des genannten Foerderers aus einem Klartext: "Kreisel", "Strecke" oder ""."""
|
||||
for text in texts:
|
||||
upper = (text or "").upper()
|
||||
if _KIND_KREISEL.search(upper):
|
||||
return "Kreisel"
|
||||
if _KIND_STRECKE.search(upper):
|
||||
return "Strecke"
|
||||
return ""
|
||||
|
||||
|
||||
def pair_drives(devices: list[EDevice]) -> list[Drive]:
|
||||
"""Motor MAnnnn <-> Motorschutz FCnnnn (gleiche Nummer), Geber GF je Foerderer."""
|
||||
fc = {d.number: d for d in devices if d.prefix.upper() == "FC" and d.number is not None}
|
||||
encoders: dict[str, str] = {}
|
||||
for d in sorted(devices, key=lambda d: d.bmk):
|
||||
if d.prefix.upper() == "GF":
|
||||
conv = getattr(d, "conveyor", "") or conveyor_of(d.bezeichnung)
|
||||
if conv:
|
||||
encoders.setdefault(conv, d.bmk)
|
||||
drives = []
|
||||
for d in sorted(devices, key=lambda d: d.bmk):
|
||||
if d.prefix.upper() != "MA" or d.number is None:
|
||||
continue
|
||||
schutz = fc.get(d.number)
|
||||
conv = (getattr(d, "conveyor", "") or conveyor_of(d.bezeichnung)
|
||||
or (conveyor_of(schutz.bezeichnung) if schutz else ""))
|
||||
drives.append(Drive(
|
||||
motor=d.bmk,
|
||||
protection=schutz.bmk if schutz else "",
|
||||
bezeichnung=d.bezeichnung,
|
||||
protection_bezeichnung=schutz.bezeichnung if schutz else "",
|
||||
conveyor=conv,
|
||||
conveyor_kind=conveyor_kind_of(d.bezeichnung,
|
||||
schutz.bezeichnung if schutz else ""),
|
||||
encoder=encoders.get(conv, "") if conv else "",
|
||||
))
|
||||
return drives
|
||||
|
||||
|
||||
def _constant_name(bmk: str, direction: str) -> str:
|
||||
return f"cIn{bmk}" if direction == "in" else f"cOut{bmk}" if direction == "out" else ""
|
||||
|
||||
|
||||
def build_hardware(tia_path: Path | None, positions_path: Path | None,
|
||||
project: str = "") -> HardwareDocument:
|
||||
"""Beide Quellen einlesen, je BMK zusammenfuehren und gegeneinander pruefen."""
|
||||
if tia_path is None and positions_path is None:
|
||||
raise ValueError("weder TIA-Import noch Positionsdatei angegeben")
|
||||
warnings: list[str] = []
|
||||
checks: list[str] = []
|
||||
|
||||
tags: list[TiaTag] = []
|
||||
constants: list[TiaConstant] = []
|
||||
if tia_path is not None:
|
||||
tags, constants = load_tia(tia_path, warnings)
|
||||
e_devices: list[EDevice] = []
|
||||
if positions_path is not None:
|
||||
e_devices = load_positionsdraw(positions_path, warnings)
|
||||
warnings.extend(_positions_warnings(positions_path))
|
||||
|
||||
tag_of = {}
|
||||
for t in tags:
|
||||
if t.name in tag_of:
|
||||
checks.append(f"TIA: Tag {t.name} doppelt")
|
||||
tag_of[t.name] = t
|
||||
const_of = {c.name: c for c in constants}
|
||||
e_of = {d.bmk: d for d in e_devices}
|
||||
|
||||
devices: list[HwDevice] = []
|
||||
used_constants: set[str] = set()
|
||||
for bmk in sorted(set(tag_of) | set(e_of)):
|
||||
tag, e = tag_of.get(bmk), e_of.get(bmk)
|
||||
direction = _direction(tag.address) if tag else ""
|
||||
cname = _constant_name(bmk, direction)
|
||||
const = const_of.get(cname)
|
||||
if const:
|
||||
used_constants.add(cname)
|
||||
value = None
|
||||
if const is not None:
|
||||
try:
|
||||
value = int(const.value)
|
||||
except ValueError:
|
||||
checks.append(f"TIA: Konstante {cname} hat keinen ganzzahligen Wert "
|
||||
f"'{const.value}'")
|
||||
base = e.model_dump() if e else {"bmk": bmk}
|
||||
devices.append(HwDevice(
|
||||
**base,
|
||||
direction=direction,
|
||||
address=tag.address if tag else "",
|
||||
data_type=tag.data_type if tag else "",
|
||||
comment=tag.comment if tag else "",
|
||||
constant=cname if const else "",
|
||||
constant_value=value,
|
||||
conveyor=conveyor_of(e.bezeichnung if e else "", tag.comment if tag else ""),
|
||||
conveyor_kind=conveyor_kind_of(e.bezeichnung if e else "",
|
||||
tag.comment if tag else ""),
|
||||
sources=[s for s, ok in (("tia", tag is not None), ("positions", e is not None))
|
||||
if ok],
|
||||
))
|
||||
|
||||
_check(devices, constants, used_constants, tags, e_devices, tia_path, positions_path,
|
||||
checks)
|
||||
return HardwareDocument(
|
||||
project=project,
|
||||
tia_file=tia_path.name if tia_path else "",
|
||||
tia_sha256=file_fingerprint(tia_path) if tia_path else "",
|
||||
positions_file=positions_path.name if positions_path else "",
|
||||
positions_sha256=file_fingerprint(positions_path) if positions_path else "",
|
||||
devices=devices,
|
||||
drives=pair_drives(devices),
|
||||
constants=[c for c in constants if c.name not in used_constants],
|
||||
checks=checks,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _check(devices: list[HwDevice], constants: list[TiaConstant], used_constants: set[str],
|
||||
tags: list[TiaTag], e_devices: list[EDevice], tia_path: Path | None,
|
||||
positions_path: Path | None, checks: list[str]) -> None:
|
||||
"""Gegenpruefung TIA-Import <-> Positionsdatei (Ergebnis in checks)."""
|
||||
if tia_path is not None and positions_path is not None:
|
||||
only_e = [d.bmk for d in devices if d.sources == ["positions"]]
|
||||
if only_e:
|
||||
checks.append(f"nur in der Positionsdatei, nicht im TIA-Import "
|
||||
f"({len(only_e)}): {', '.join(only_e)}")
|
||||
# Feldgeraete (Sensoren/Ventile/Motoren) gehoeren in die Zeichnung;
|
||||
# Taster, Leuchten, Not-Halt (SF/PF) stehen dort meist nicht.
|
||||
only_tia = [d.bmk for d in devices if d.sources == ["tia"]
|
||||
and d.prefix.upper() in ("BG", "MB", "MA", "BX")]
|
||||
if only_tia:
|
||||
checks.append(f"Feldgeraet nur im TIA-Import, nicht in der Positionsdatei "
|
||||
f"({len(only_tia)}): {', '.join(only_tia)}")
|
||||
differ = [d.bmk for d in devices if len(d.sources) == 2 and d.bezeichnung
|
||||
and d.comment and d.bezeichnung.strip() != d.comment.strip()]
|
||||
if differ:
|
||||
checks.append(f"TIA-Kommentar weicht von der BEZEICHNUNG ab ({len(differ)}): "
|
||||
f"{', '.join(differ)}")
|
||||
if tia_path is not None:
|
||||
no_const = [d.bmk for d in devices if d.in_tia and d.direction and not d.constant]
|
||||
if no_const:
|
||||
checks.append(f"Signal ohne Index-Konstante cIn/cOut ({len(no_const)}): "
|
||||
f"{', '.join(no_const)}")
|
||||
orphan = [c.name for c in constants
|
||||
if c.name not in used_constants and re.match(r"^c(In|Out)[A-Z]+\d+$", c.name)]
|
||||
if orphan:
|
||||
checks.append(f"Index-Konstante ohne Signal ({len(orphan)}): {', '.join(orphan)}")
|
||||
addresses = Counter(t.address for t in tags if t.address)
|
||||
double = [a for a, n in addresses.items() if n > 1]
|
||||
if double:
|
||||
checks.append(f"Adresse mehrfach vergeben: {', '.join(sorted(double))}")
|
||||
for direction in ("in", "out"):
|
||||
values = Counter(d.constant_value for d in devices
|
||||
if d.direction == direction and d.constant_value is not None)
|
||||
double = sorted(v for v, n in values.items() if n > 1)
|
||||
if double:
|
||||
checks.append(f"Index-Konstanten ({direction}) mit gleichem Wert: "
|
||||
f"{', '.join(map(str, double))}")
|
||||
no_ctrl = [d.bmk for d in e_devices if not d.cabinet and not d.controller]
|
||||
if no_ctrl:
|
||||
checks.append(f"Feldgeraet ohne Steuerung in KENNZEICHNUNG ({len(no_ctrl)}): "
|
||||
f"{', '.join(no_ctrl)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bericht
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_report(doc: HardwareDocument) -> str:
|
||||
out: list[str] = []
|
||||
add = out.append
|
||||
add(f"# Hardwareplanung: {doc.project}")
|
||||
add("")
|
||||
add(f"- TIA-Import: `{doc.tia_file or '-'}`")
|
||||
add(f"- Positionsdatei: `{doc.positions_file or '-'}`")
|
||||
add(f"- Erzeugt: {doc.created}")
|
||||
add("")
|
||||
add("## Geraete")
|
||||
add("")
|
||||
prefixes = Counter(d.prefix or "?" for d in doc.devices)
|
||||
add("| Praefix | Anzahl | im Feld mit Lage | mit Index-Konstante |")
|
||||
add("|---|---|---|---|")
|
||||
for p, n in sorted(prefixes.items()):
|
||||
devs = [d for d in doc.devices if (d.prefix or "?") == p]
|
||||
add(f"| {p} | {n} | {sum(1 for d in devs if d.pos and not d.cabinet)} "
|
||||
f"| {sum(1 for d in devs if d.constant)} |")
|
||||
add("")
|
||||
add("## Antriebe (Motor <-> Motorschutz, Geber)")
|
||||
add("")
|
||||
if doc.drives:
|
||||
add("| Foerderer | Motor | Bezeichnung | Motorschutz | Geber |")
|
||||
add("|---|---|---|---|---|")
|
||||
for dr in doc.drives:
|
||||
add(f"| {dr.conveyor or '-'} | {dr.motor} | {dr.bezeichnung} "
|
||||
f"| {dr.protection or '-'} | {dr.encoder or '-'} |")
|
||||
else:
|
||||
add("- keine Motoren")
|
||||
add("")
|
||||
conveyors = Counter(d.conveyor for d in doc.field_devices() if d.conveyor)
|
||||
add("## Foerderer der E-Planung (Feldgeraete, die ihn im Klartext nennen)")
|
||||
add("")
|
||||
if conveyors:
|
||||
for name, n in sorted(conveyors.items()):
|
||||
add(f"- {name}: {n}")
|
||||
else:
|
||||
add("- keine")
|
||||
add("")
|
||||
add("## Gegenpruefung")
|
||||
add("")
|
||||
for c in doc.checks or ["keine Befunde"]:
|
||||
add(f"- {c}")
|
||||
add("")
|
||||
if doc.constants:
|
||||
add("## Konstanten ohne Signal")
|
||||
add("")
|
||||
add(", ".join(c.name for c in doc.constants))
|
||||
add("")
|
||||
if doc.warnings:
|
||||
add("## Hinweise")
|
||||
add("")
|
||||
for w in doc.warnings:
|
||||
add(f"- {w}")
|
||||
add("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_POSITION_SUFFIXES = ("_positionsconv", "_positionsdraw", "_positions")
|
||||
|
||||
|
||||
def project_name(tia_path: Path | None, positions_path: Path | None) -> str:
|
||||
"""Projektname aus den Dateinamen: Mubea_positionsconv.json -> Mubea."""
|
||||
if positions_path is not None:
|
||||
stem = positions_path.stem
|
||||
for suffix in _POSITION_SUFFIXES:
|
||||
if stem.lower().endswith(suffix):
|
||||
return stem[: -len(suffix)]
|
||||
return stem
|
||||
stem = tia_path.stem if tia_path else "anlage"
|
||||
return stem[:-4] if stem.lower().endswith("_tia") else stem
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
from material_flow import env_dir, resolve_input
|
||||
|
||||
args = parse_args(argv)
|
||||
tia_path = resolve_input(args.tia) if args.tia else None
|
||||
positions_path = resolve_input(args.positions) if args.positions else None
|
||||
for label, path in (("TIA-Import", tia_path), ("Positionsdatei", positions_path)):
|
||||
if path is not None and not path.is_file():
|
||||
print(f"FEHLER: {label} nicht gefunden: {path}", file=sys.stderr)
|
||||
return 1
|
||||
project = args.project or project_name(tia_path, positions_path)
|
||||
|
||||
try:
|
||||
doc = build_hardware(tia_path, positions_path, project)
|
||||
except (OSError, ValueError, KeyError, zipfile.BadZipFile, ET.ParseError) as exc:
|
||||
print(f"FEHLER: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
results = env_dir("SKEL_RESULTS", "results")
|
||||
results.mkdir(parents=True, exist_ok=True)
|
||||
json_file = results / f"{project}_hw.json"
|
||||
md_file = results / f"{project}_hw.md"
|
||||
json_file.write_text(doc.to_json(), encoding="utf-8")
|
||||
md_file.write_text(render_report(doc), encoding="utf-8")
|
||||
|
||||
n_tia = sum(1 for d in doc.devices if d.in_tia)
|
||||
n_pos = sum(1 for d in doc.devices if "positions" in d.sources)
|
||||
print("")
|
||||
print("================================================================")
|
||||
print("HARDWAREPLANUNG EINLESEN")
|
||||
print("================================================================")
|
||||
print(f"TIA-Import = {tia_path or '-'}")
|
||||
print(f"Positionsdatei = {positions_path or '-'}")
|
||||
print(f"Projekt = {project}")
|
||||
print(f"Geraete = {len(doc.devices)} (TIA {n_tia}, Positionsdatei {n_pos}, "
|
||||
f"beide {sum(1 for d in doc.devices if len(d.sources) == 2)})")
|
||||
print(f"Feld mit Lage = {len(doc.field_devices())}")
|
||||
print(f"Index-Konstanten = {sum(1 for d in doc.devices if d.constant)} "
|
||||
f"(+ {len(doc.constants)} ohne Signal)")
|
||||
print(f"Antriebe = {len(doc.drives)} "
|
||||
f"({sum(1 for d in doc.drives if d.protection)} mit Motorschutz, "
|
||||
f"{sum(1 for d in doc.drives if d.conveyor)} mit Foerderer)")
|
||||
for c in doc.checks:
|
||||
print(f" ? {c}")
|
||||
for w in doc.warnings:
|
||||
print(f" ! {w}")
|
||||
print(f"JSON = {json_file}")
|
||||
print(f"Bericht = {md_file}")
|
||||
print("================================================================")
|
||||
print("")
|
||||
return 0
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hw_import",
|
||||
description="Liest die Hardwareplanung ein - TIA-Import (PLC Tags + "
|
||||
"Constants) und die Positionsdatei des Kabellaengen-Tools - "
|
||||
"und schreibt <projekt>_hw.json fuer merge_hw_layout plus einen "
|
||||
"Bericht <projekt>_hw.md.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="Beispiele:\n"
|
||||
" hw_import.bat --tia Mubea/mubea-1_TIA.xlsx "
|
||||
"--positions Mubea/Mubea_positionsconv.json\n"
|
||||
" hw_import.bat --tia mubea-1_TIA.xlsx --project Mubea\n"
|
||||
"\n"
|
||||
"Exit-Codes:\n"
|
||||
" 0 ok (Befunde der Gegenpruefung stehen im Bericht)\n"
|
||||
" 1 Eingabe- oder Lesefehler\n",
|
||||
)
|
||||
parser.add_argument("--tia", required=True, metavar="NAME",
|
||||
help="TIA-Import *_TIA.xlsx in %%SKEL_DATA%% (oder ein Pfad, "
|
||||
"auch mit Unterordner)")
|
||||
parser.add_argument("--positions", metavar="NAME",
|
||||
help="*_positionsconv.json bzw. *_positionsdraw.json in "
|
||||
"%%SKEL_DATA%% (oder ein Pfad); ohne sie keine Lage")
|
||||
parser.add_argument("--project", metavar="NAME",
|
||||
help="Projektname fuer <projekt>_hw.json. Standard: aus dem "
|
||||
"Namen der Positionsdatei (Mubea_positionsconv.json -> Mubea)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+10
-2
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 -> <csv>_anlage.json.
|
||||
|
||||
Stufe 3 des Ablaufs in doc/Konzept_Layout_Hardware_SCL.md:
|
||||
|
||||
<csv>_tro.json (tro_flow --json) + <projekt>_hw.json (hw_import)
|
||||
\\ /
|
||||
merge_hw_layout -> <csv>_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 "<Art>.<Rolle>")."""
|
||||
|
||||
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 "<Art>.<Rolle>" 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 ``[<csv>.bindings]`` und ``[<csv>.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"<TRO-ID>.<Slot>.<Rolle>")
|
||||
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 <csv>_anlage.json plus einen "
|
||||
"Bericht <csv>_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 <csv>_tro.json in %%SKEL_RESULTS%% (oder ein Pfad)")
|
||||
parser.add_argument("--hw", required=True, metavar="NAME",
|
||||
help="Hardware-JSON <projekt>_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())
|
||||
+224
-4
@@ -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 (<csv>_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 (<csv>_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 (<csv>_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 ``<csv>_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<n>``; 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)):
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -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 = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<{tag} xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
|
||||
|
||||
|
||||
def _col(i: int) -> str:
|
||||
out = ""
|
||||
i += 1
|
||||
while i:
|
||||
i, r = divmod(i - 1, 26)
|
||||
out = chr(65 + r) + out
|
||||
return out
|
||||
|
||||
|
||||
def write_xlsx(path: Path, sheets: dict[str, list[list]], shared: bool = True) -> Path:
|
||||
"""Minimale xlsx: Texte als Shared Strings (oder inline), Zahlen als Werte."""
|
||||
strings: list[str] = []
|
||||
files = {}
|
||||
for n, (name, rows) in enumerate(sheets.items(), start=1):
|
||||
xml_rows = []
|
||||
for r, row in enumerate(rows, start=1):
|
||||
cells = []
|
||||
for c, value in enumerate(row):
|
||||
ref = f"{_col(c)}{r}"
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
cells.append(f'<c r="{ref}"><v>{value}</v></c>')
|
||||
elif shared:
|
||||
if value not in strings:
|
||||
strings.append(value)
|
||||
cells.append(f'<c r="{ref}" t="s"><v>{strings.index(value)}</v></c>')
|
||||
else:
|
||||
cells.append(f'<c r="{ref}" t="inlineStr"><is><t>{value}</t></is></c>')
|
||||
xml_rows.append(f'<row r="{r}">{"".join(cells)}</row>')
|
||||
files[f"xl/worksheets/sheet{n}.xml"] = (
|
||||
_HEAD.format(tag="worksheet") + f'<sheetData>{"".join(xml_rows)}</sheetData></worksheet>')
|
||||
sheet_xml = "".join(f'<sheet name="{name}" sheetId="{n}" r:id="rId{n}"/>'
|
||||
for n, name in enumerate(sheets, start=1))
|
||||
files["xl/workbook.xml"] = _HEAD.format(tag="workbook") + f"<sheets>{sheet_xml}</sheets></workbook>"
|
||||
rels = "".join(f'<Relationship Id="rId{n}" Type="worksheet" Target="worksheets/sheet{n}.xml"/>'
|
||||
for n in range(1, len(sheets) + 1))
|
||||
files["xl/_rels/workbook.xml.rels"] = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?><Relationships '
|
||||
'xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
f"{rels}</Relationships>")
|
||||
if shared and strings:
|
||||
si = "".join(f"<si><t>{s}</t></si>" for s in strings)
|
||||
files["xl/sharedStrings.xml"] = _HEAD.format(tag="sst") + f"{si}</sst>"
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
for name, text in files.items():
|
||||
z.writestr(name, text)
|
||||
return path
|
||||
|
||||
|
||||
TAG_HEADER = ["Name", "Path", "Data Type", "Logical Address", "Comment", "Hmi Visible"]
|
||||
CONST_HEADER = ["Name", "Path", "Data Type", "Value", "Comment"]
|
||||
|
||||
|
||||
def tia_file(tmp_path: Path, extra_tags=(), extra_consts=()) -> Path:
|
||||
tags = [
|
||||
["BG0532", "Import", "Bool", "%E053.2", "SENSOR IN SEP KR-K0102", "True"],
|
||||
["MB0533", "Import", "Bool", "%A053.3", "STOPPER KR-K0102", "True"],
|
||||
["MA0202", "Import", "Bool", "%A020.2", "ANTRIEB KREISEL-K0102", "True"],
|
||||
["FC0202", "Import", "Bool", "%E020.2", "MOTORSCHUTZ KREISEL-K0102", "True"],
|
||||
["GF20000", "Import", "Bool", "%A2000.0", "INKREMENTALGEBER KREISEL K0102 (M0205)", "True"],
|
||||
["SF0001", "Import", "Bool", "%E000.1", "ANLAGE EIN", "True"],
|
||||
["BG0999", "Import", "Bool", "%E099.0", "NUR IM TIA", "True"],
|
||||
*extra_tags,
|
||||
]
|
||||
consts = [
|
||||
["cInBG0532", "Import", "Int", 0],
|
||||
["cOutMB0533", "Import", "Int", 0],
|
||||
["cOutMA0202", "Import", "Int", 1],
|
||||
["cInFC0202", "Import", "Int", 1],
|
||||
["cOutGF20000", "Import", "Int", 2],
|
||||
["cInSF0001", "Import", "Int", 2],
|
||||
["cInBG0999", "Import", "Int", 3],
|
||||
*extra_consts,
|
||||
]
|
||||
return write_xlsx(tmp_path / "x_TIA.xlsx",
|
||||
{"PLC Tags": [TAG_HEADER, *tags], "Constants": [CONST_HEADER, *consts]})
|
||||
|
||||
|
||||
def positions_file(tmp_path: Path) -> Path:
|
||||
data = {
|
||||
"sensors": {
|
||||
"BG0532@1": {"IO": "BG0532", "VERW": "IN SEP", "BEZEICHNUNG": "SENSOR IN SEP KR-K0102",
|
||||
"KENNZEICHNUNG": "=A1+UH01-KF102", "pos": [100.0, 200.0]},
|
||||
"MB0533@1": {"IO": "MB0533", "VERW": "SEP", "BEZEICHNUNG": "STOPPER KR-K0102",
|
||||
"KENNZEICHNUNG": "=A1+UH01-KF102", "pos": [100.0, 470.0]},
|
||||
"BG0514@1": {"IO": "BG0514", "VERW": "KETTENBRUCH",
|
||||
"BEZEICHNUNG": "SENSOR KETTENBRUCH CONVEYOR #101", "KENNZEICHNUNG": "",
|
||||
"pos": [5.0, 5.0]},
|
||||
},
|
||||
"schaltschrank_elemente": {
|
||||
"MA0202@1": {"IO": "MA0202", "VERW": "MOT", "BEZEICHNUNG": "ANTRIEB KREISEL-K0102",
|
||||
"KENNZEICHNUNG": "=A1+UH01-KF1DQ3"},
|
||||
"FC0202@1": {"IO": "FC0202", "BEZEICHNUNG": "MOTORSCHUTZ KREISEL-K0102",
|
||||
"KENNZEICHNUNG": "=A1+UH01-KF1DI2"},
|
||||
"QA7000@A/B": {"IO": "QA7000", "VERW": "IL-YE-LED", "BEZEICHNUNG": "HAUPTSCHUETZE",
|
||||
"KENNZEICHNUNG": "=A1+UH01-KF1FDQ1"},
|
||||
"SF0001@1": {"IO": "SF0001", "BEZEICHNUNG": "ANLAGE EIN"},
|
||||
"GF20000@1": {"IO": "GF20000",
|
||||
"BEZEICHNUNG": "INKREMENTALGEBER KREISEL K0102 (M0205)"},
|
||||
},
|
||||
"warnings": {"mapping_warnings": {"BG0514@1": "Ungueltiger Pfad in Kennzeichnung: "}},
|
||||
}
|
||||
path = tmp_path / "Xproj_positionsconv.json"
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# xlsx und TIA-Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared", [True, False])
|
||||
def test_xlsx_lesen(tmp_path, shared):
|
||||
path = write_xlsx(tmp_path / "t.xlsx", {"A": [["Name", "Wert"], ["x", 3], [None, "y"]]},
|
||||
shared=shared)
|
||||
assert read_xlsx(path) == {"A": [["Name", "Wert"], ["x", "3"], ["", "y"]]}
|
||||
|
||||
|
||||
def test_tia_blaetter_an_der_kopfzeile_erkannt(tmp_path):
|
||||
"""Blattnamen sind egal (deutscher Export), gezaehlt wird die Kopfzeile."""
|
||||
path = write_xlsx(tmp_path / "de_TIA.xlsx", {
|
||||
"PLC-Variablen": [["Name", "Pfad", "Datentyp", "Logische Adresse", "Kommentar"],
|
||||
["BG0532", "Import", "Bool", "%I53.2", "SENSOR"]],
|
||||
"Konstanten": [["Name", "Pfad", "Datentyp", "Wert", "Kommentar"],
|
||||
["cInBG0532", "Import", "Int", "7", ""]],
|
||||
"Leer": [["irgendwas"]],
|
||||
})
|
||||
warnings: list[str] = []
|
||||
tags, consts = load_tia(path, warnings)
|
||||
assert [(t.name, t.address, t.comment) for t in tags] == [("BG0532", "%I53.2", "SENSOR")]
|
||||
assert [(c.name, c.value) for c in consts] == [("cInBG0532", "7")]
|
||||
assert any("'Leer'" in w for w in warnings)
|
||||
|
||||
|
||||
def test_tia_ohne_tags_ist_ein_fehler(tmp_path):
|
||||
path = write_xlsx(tmp_path / "x.xlsx", {"Constants": [CONST_HEADER, ["cInBG1", "", "Int", 0]]})
|
||||
with pytest.raises(ValueError, match="kein Blatt mit Tags"):
|
||||
load_tia(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Klartext -> Foerderer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, name, kind", [
|
||||
("ANTRIEB KREISEL-K0102", "K0102", "Kreisel"),
|
||||
("PRUEFUNG BT. POS.1 (KR-K0102 EINFAHRT PUFFER)", "K0102", "Kreisel"),
|
||||
# dieselbe Strecke einmal als K0103, einmal als #103
|
||||
("MOTORSCHUTZ CONVEYOR-K0103", "K0103", "Strecke"),
|
||||
("MOTOR TEF LBT CONVEYOR-#103", "K0103", "Strecke"),
|
||||
("PIN CONVEYOR #109(EXPRESS)", "K0109", "Strecke"),
|
||||
# FC0204 im Text ist kein Foerderer
|
||||
("MOTORSCHUTZ FC0204 CONVEYOR #110", "K0110", "Strecke"),
|
||||
("SEPARATOR STR. 14", "", ""),
|
||||
])
|
||||
def test_foerderer_aus_klartext(text, name, kind):
|
||||
assert conveyor_of(text) == name
|
||||
assert conveyor_kind_of(text) == kind
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zusammenfuehren
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_beide_quellen_je_bmk_zusammengefuehrt(tmp_path):
|
||||
doc = build_hardware(tia_file(tmp_path), positions_file(tmp_path), "Xproj")
|
||||
dev = doc.by_bmk()
|
||||
|
||||
s = dev["BG0532"]
|
||||
assert (s.direction, s.address, s.constant, s.constant_value) == ("in", "%E053.2", "cInBG0532", 0)
|
||||
assert (s.verw, s.station, s.controller, s.pos) == ("IN SEP", "KF102", "UH01", (100.0, 200.0))
|
||||
assert s.sources == ["tia", "positions"]
|
||||
assert (s.conveyor, s.conveyor_kind) == ("K0102", "Kreisel")
|
||||
assert dev["MB0533"].constant == "cOutMB0533" and dev["MB0533"].direction == "out"
|
||||
|
||||
# nur in einer Quelle
|
||||
assert dev["QA7000"].sources == ["positions"] and dev["QA7000"].constant == ""
|
||||
assert dev["BG0999"].sources == ["tia"] and dev["BG0999"].pos is None
|
||||
|
||||
assert [d.bmk for d in doc.field_devices()] == ["BG0514", "BG0532", "MB0533"]
|
||||
assert doc.tia_sha256 and doc.positions_sha256
|
||||
assert doc.project == "Xproj"
|
||||
|
||||
|
||||
def test_antrieb_mit_motorschutz_und_geber(tmp_path):
|
||||
doc = build_hardware(tia_file(tmp_path), positions_file(tmp_path))
|
||||
[drive] = doc.drives
|
||||
assert (drive.motor, drive.protection, drive.conveyor, drive.conveyor_kind, drive.encoder) == (
|
||||
"MA0202", "FC0202", "K0102", "Kreisel", "GF20000")
|
||||
|
||||
|
||||
def test_gegenpruefung(tmp_path):
|
||||
doc = build_hardware(
|
||||
tia_file(tmp_path,
|
||||
extra_tags=[["BG0533", "Import", "Bool", "%E053.2", "GLEICHE ADRESSE", ""]],
|
||||
extra_consts=[["cInBG4711", "Import", "Int", 9]]),
|
||||
positions_file(tmp_path))
|
||||
checks = "\n".join(doc.checks)
|
||||
assert "nur in der Positionsdatei, nicht im TIA-Import (2): BG0514, QA7000" in checks
|
||||
assert "Feldgeraet nur im TIA-Import, nicht in der Positionsdatei (2): BG0533, BG0999" in checks
|
||||
assert "Signal ohne Index-Konstante cIn/cOut (1): BG0533" in checks
|
||||
assert "Index-Konstante ohne Signal (1): cInBG4711" in checks
|
||||
assert "Adresse mehrfach vergeben: %E053.2" in checks
|
||||
assert "Feldgeraet ohne Steuerung in KENNZEICHNUNG (1): BG0514" in checks
|
||||
assert any("BG0514" in w and "mapping_warnings" in w for w in doc.warnings)
|
||||
assert [c.name for c in doc.constants] == ["cInBG4711"]
|
||||
|
||||
|
||||
def test_nur_tia_ohne_positionsdatei(tmp_path):
|
||||
doc = build_hardware(tia_file(tmp_path), None)
|
||||
assert doc.positions_file == "" and doc.field_devices() == []
|
||||
assert all(d.sources == ["tia"] for d in doc.devices)
|
||||
|
||||
|
||||
def test_json_rundreise(tmp_path):
|
||||
doc = build_hardware(tia_file(tmp_path), positions_file(tmp_path), "Xproj")
|
||||
again = HardwareDocument.from_json(doc.to_json())
|
||||
assert again == doc
|
||||
assert json.loads(doc.to_json())["schema"] == "sps_skel-hw-v1"
|
||||
|
||||
|
||||
def test_projektname():
|
||||
assert project_name(None, Path("Mubea_positionsconv.json")) == "Mubea"
|
||||
assert project_name(None, Path("Mubea_positionsdraw.json")) == "Mubea"
|
||||
assert project_name(Path("mubea-1_TIA.xlsx"), None) == "mubea-1"
|
||||
|
||||
|
||||
def test_cli_mit_unterordner_in_skel_data(tmp_path, monkeypatch, capsys):
|
||||
"""--tia Mubea/x_TIA.xlsx wird ab SKEL_DATA gefunden (Befund der Nachpruefung)."""
|
||||
data = tmp_path / "data" / "Xproj"
|
||||
data.mkdir(parents=True)
|
||||
tia_file(data)
|
||||
positions_file(data)
|
||||
monkeypatch.setenv("SKEL_DATA", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("SKEL_RESULTS", str(tmp_path / "results"))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
rc = hw_import.main(["--tia", "Xproj/x_TIA.xlsx", "--positions",
|
||||
"Xproj/Xproj_positionsconv.json"])
|
||||
assert rc == 0
|
||||
written = HardwareDocument.from_json(
|
||||
(tmp_path / "results" / "Xproj_hw.json").read_text(encoding="utf-8"))
|
||||
assert written.project == "Xproj" and len(written.devices) == 9
|
||||
assert (tmp_path / "results" / "Xproj_hw.md").is_file()
|
||||
assert "HARDWAREPLANUNG EINLESEN" in capsys.readouterr().out
|
||||
@@ -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="<TRO-ID>.<Slot>.<Rolle>"):
|
||||
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
|
||||
Reference in New Issue
Block a user