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`)
|
`tro_annotate.py --emit-lisp`)
|
||||||
- `cfg/dxf_registration.json` — persisted, verified CSV↔DXF coordinate transform;
|
- `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/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
|
`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);
|
(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
|
`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".
|
`doc/Klassenhierarchie.md`, chapter "Daten".
|
||||||
10. **`doc/Konzept_Layout_Hardware_SCL.md`** — the target workflow (planned, not built):
|
10. **`doc/Konzept_Layout_Hardware_SCL.md`** — the target workflow (planned, not built):
|
||||||
layout → `<csv>_tro.json` (with slots, `jamAreas`), hardware planning →
|
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),
|
then `scl_skeleton.py`; what happens to `e_planning.py` (split),
|
||||||
`tro_annotate.py` (viewer reading the JSON) and `tro_extract.py` (dropped).
|
`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`).
|
`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
|
`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
|
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:
|
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",
|
"csv_file": "dematic.csv",
|
||||||
"dxf_file": "dematic_TST.dxf",
|
"dxf_file": "dematic_TST.dxf",
|
||||||
"created": "2026-10-06 14:19"
|
"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
|
; neben dem Bauteil; gewertet wird die Abweichung vom Soll
|
||||||
; (nicht der kleinste Abstand), zulaessig bis toleranz_mm
|
; (nicht der kleinste Abstand), zulaessig bis toleranz_mm
|
||||||
; toleranz_mm zulaessige Abweichung vom Sollabstand (Standard: cfg/parameter.ini
|
; 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_von Nummernregel: BMK-Nummer = Nummer der genannten Rolle
|
||||||
; nummer_versatz ... plus dieser Versatz (Mubea: Ventil = Sensor + 1)
|
; nummer_versatz ... plus dieser Versatz (Mubea: Ventil = Sensor + 1)
|
||||||
; Mit nummer_von wird zuerst die Nummer versucht, danach (wenn max_mm oder
|
; 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
|
default_max_residual = 600.0
|
||||||
|
|
||||||
|
|
||||||
[e_planning]
|
[merge_hw_layout]
|
||||||
; Zulaessige Abweichung vom Sollabstand (soll_mm), wenn eine Regel in
|
; 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
|
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`
|
# 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`,
|
`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
|
`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
|
## Problem
|
||||||
|
|
||||||
@@ -34,16 +36,47 @@ passender Art.
|
|||||||
|
|
||||||
Die Erdungs-Beispiele (`PR500501_…_Erdung_…`, `ST500652_…`) enthalten nur
|
Die Erdungs-Beispiele (`PR500501_…_Erdung_…`, `ST500652_…`) enthalten nur
|
||||||
Potentialausgleich (`POT…`) und taugen nicht für die Brücke. Brauchbar sind
|
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),
|
`ST500592_ILS_positionsdraw.json` (~3100), `ST500592_10_5-10_Omniflo_…` (~900),
|
||||||
`ST-Fortna_positions_.json` (mit 38 Scannern `BX`).
|
`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
|
## Test an Mubea
|
||||||
|
|
||||||
Eingaben: `data/mubea.csv` (Layout, 38 Separatoren) und
|
Eingaben: `data/mubea.csv` (Layout, 38 Separatoren) und
|
||||||
`kabellaengen/work/Mubea_positionsdraw.json` (E-Planung). Die Zahlen in diesem
|
`kabellaengen/work/Mubea_positionsdraw.json` (E-Planung, alter Stand). Die Zahlen
|
||||||
Abschnitt stammen aus der ersten Untersuchung (Umkreis 700 mm, ohne Regeln); das
|
in diesem Abschnitt stammen aus der ersten Untersuchung (Umkreis 700 mm, ohne
|
||||||
Ergebnis der Umsetzung steht unten.
|
Regeln); das Ergebnis der Umsetzung und die Nachprüfung mit dem neuen Stand
|
||||||
|
`data/Mubea/Mubea_positionsdraw.json` stehen unten.
|
||||||
|
|
||||||
### 1. Lagebezug
|
### 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`
|
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
|
Koordinatensystem wie die Layoutzeichnung. Zum Prüfen genügt also der
|
||||||
bestehende Mechanismus; als Schlüssel in `cfg/dxf_registration.json` kann
|
bestehende Mechanismus; als Schlüssel in `cfg/dxf_registration.json` dient
|
||||||
`<csv>|<positionsdraw.json>` dienen.
|
`<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
|
### 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 |
|
| `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` |
|
| `Settings.xPluggedPart`, `stSenPart` | BG `BT BELADEN` | räumlich; vorhanden ⇒ `TRUE` |
|
||||||
| `xInMotorProtection`, `xOutVarioMotor` | MA im Feld + FC in `schaltschrank_elemente` | gleiche Nummer |
|
| `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 |
|
| 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 |
|
| 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
|
Nicht aus dieser Quelle: Scanner-Hardwarekennungen, Weichenbauart (`xNewSwitch`).
|
||||||
(stehen nur als Schrankelemente ohne Bezug zum TRO), 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)
|
## Umsetzung (Stand 2026-10-10)
|
||||||
|
|
||||||
@@ -144,11 +189,59 @@ Weichenbauart (`xNewSwitch`).
|
|||||||
`scl_skeleton --eplan` setzt Separator-Sensor und Stopperventil ein
|
`scl_skeleton --eplan` setzt Separator-Sensor und Stopperventil ein
|
||||||
(`SclName.todo = False`); alles andere bleibt `TODO(E-Planung)`.
|
(`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
|
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
|
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.
|
(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:
|
Offen:
|
||||||
|
|
||||||
- Stausensoren und Ausschleus-/Weichenventile stehen in JSON und Bericht, kommen
|
- 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
|
- Scanner (`BX`) fehlen in den Mubea-Daten; Regel und Test mit
|
||||||
`ST-Fortna_positions_.json` stehen aus.
|
`ST-Fortna_positions_.json` stehen aus.
|
||||||
- Ein Abgleich mit passenden Ständen von CSV und E-Zeichnung für die Kreisel.
|
- 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
|
```text
|
||||||
Abteilung 1: Anlagenlayout Abteilung 2: Hardwareplanung (später)
|
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
|
(TROs, Abfolge, ──hw_import──> <projekt>_hw.json
|
||||||
Staubereiche, Slots) (globale Variablen, Geräte)
|
Staubereiche, Slots) (globale Variablen, Geräte)
|
||||||
│ │
|
│ │
|
||||||
└──────────── hw_assign ─────────────────┘
|
└──────────── merge_hw_layout ─────────────────┘
|
||||||
│
|
│
|
||||||
<csv>_anlage.json (Layout + Zuordnungen)
|
<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 |
|
| Stufe | Skript | Eingabe | Ausgabe | Status |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 Layout | `tro_flow.py --json` | CSV, `cfg/tro_overrides.ini` | `<csv>_tro.json` | vorhanden, wird erweitert |
|
| 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 |
|
| 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 | `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`) |
|
| 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 |
|
| 4 SCL | `scl_skeleton.py` | `<csv>_anlage.json` (oder nur `<csv>_tro.json`) | `.scl` | vorhanden, wird umgestellt |
|
||||||
|
|
||||||
Grundsätze:
|
Grundsätze:
|
||||||
@@ -109,8 +109,12 @@ Weichenventile (`doc/E-Planung_Bruecke.md`, "Offen").
|
|||||||
|
|
||||||
### 2. Neues Skript `hw_import.py` (Stufe 2)
|
### 2. Neues Skript `hw_import.py` (Stufe 2)
|
||||||
|
|
||||||
- Liest TIA-Import (PLC Tags + Constants), EA-Liste, WSCAD-Import und optional
|
- Liest TIA-Import (PLC Tags + Constants) und `positionsdraw.json`. Das genügt:
|
||||||
`positionsdraw.json`.
|
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`:
|
- Schreibt ein neues Modell `HardwareDocument`:
|
||||||
- **Geräte** je BMK: Adresse, Index-Konstante (`cInBG3306`), Kommentar, VERW,
|
- **Geräte** je BMK: Adresse, Index-Konstante (`cInBG3306`), Kommentar, VERW,
|
||||||
Steuerung, Station/Schrank, Position (falls vorhanden);
|
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
|
- Erste Quelle: der TIA-Import, weil er Namen und Konstanten 1:1 liefert und die
|
||||||
spätere Prüfung des SCL ermöglicht.
|
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.
|
- Eingabe: `<csv>_tro.json` + `<projekt>_hw.json`; das CSV wird nicht mehr gelesen.
|
||||||
- Zuordnungswege, als Regeln in einer INI (heute `cfg/e_planning.ini`):
|
- 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.
|
- **Gegenprüfung** beider Quellen (Anzahl Separatoren vs. `IN SEP`, Weichen vs.
|
||||||
`AE ABZWEIG`, angetriebene Strecken vs. `MA`) in den Bericht.
|
`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)
|
### 4. `scl_skeleton.py` / `scl_context.py` (Stufe 4)
|
||||||
|
|
||||||
- Liest **eine** Eingabe: `<csv>_anlage.json`; ohne Hardwareteil wie heute
|
- 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) |
|
| `tro_extract.py` | entfällt bzw. wird umgebaut (unten) |
|
||||||
| `scl_skeleton.py`, `scl_context.py` | Stufe 4, Umstellung auf `<csv>_anlage.json` |
|
| `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 |
|
| Teil heute | Wohin |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `load_positionsdraw`, `EDevice`, `pair_drives` | `hw_import.py` |
|
| `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 |
|
| `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 `hw_assign` |
|
| `cfg/e_planning.ini` | Regeldatei von `merge_hw_layout` |
|
||||||
| `EplanDocument` / `<csv>_eplan.json` | entfällt, ersetzt durch `bindings[]` |
|
| `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
|
nacheinander aufruft, damit `bin/e_planning.bat` weiter funktioniert; danach
|
||||||
löschen.
|
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
|
Sie werden auf die IDs der ID-Datei umgestellt, sobald `tro_flow` die
|
||||||
Overrides wieder liest.
|
Overrides wieder liest.
|
||||||
- Dadurch werden auch die Abschnitte `[<csv>.tro.<TRO-ID>]` in
|
- 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
|
### Staubereiche
|
||||||
|
|
||||||
@@ -356,9 +390,12 @@ unverändert.
|
|||||||
2. Slots und `jamAreas` in die TRO-JSON (Umbau 1), ein Eingangs-Staubereich je
|
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**
|
TRO — ohne Slots hat die Zuordnung kein Ziel. **Erster Teil erledigt**
|
||||||
(2026-10-11, siehe Umbau 1 "Stand").
|
(2026-10-11, siehe Umbau 1 "Stand").
|
||||||
3. `hw_import` mit dem TIA-Import als erster Quelle.
|
3. `hw_import` mit dem TIA-Import als erster Quelle. **Erledigt** (2026-10-15,
|
||||||
4. `hw_assign` mit der räumlichen Logik aus `e_planning.py`; `e_planning.py` als
|
siehe unten "Stand Umbau 2/3").
|
||||||
Übergangshülle.
|
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;
|
5. `scl_skeleton` auf `<csv>_anlage.json` umstellen, `--eplan` entfernen;
|
||||||
`tro_annotate` auf JSON umstellen.
|
`tro_annotate` auf JSON umstellen.
|
||||||
6. Defaults (`cfg/defaults.ini`), `FB_CallSensors`, EA-Liste ohne Positionen.
|
6. Defaults (`cfg/defaults.ini`), `FB_CallSensors`, EA-Liste ohne Positionen.
|
||||||
|
|||||||
+161
-12
@@ -1,16 +1,20 @@
|
|||||||
# Python Scripts — CLI Tooling Overview
|
# 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,
|
`lib/` currently holds sixteen Python modules (eight of them runnable CLI
|
||||||
five supporting libraries), plus one documentation-generator script that lives
|
tools, eight supporting libraries), plus one documentation-generator script
|
||||||
under `doc/TRO_Katalog/tro_graphs/`. Together the CLI tools turn a CSV export
|
that lives under `doc/TRO_Katalog/tro_graphs/`. Together the CLI tools turn a
|
||||||
of the mechanical layout (ILS 2.0) into a material-flow graph, a derived TRO
|
CSV export of the mechanical layout (ILS 2.0) into a material-flow graph, a
|
||||||
list/diagram, a TRO-JSON (shared model `lib/model.py`), an `FB_Main` SCL
|
derived TRO list/diagram, a TRO-JSON (shared model `lib/model.py`), an
|
||||||
skeleton generated from that JSON, and an annotated copy of the BricsCAD
|
`FB_Main` SCL skeleton generated from that JSON, and an annotated copy of the
|
||||||
drawing for visual checking. `e_planning.py` bridges to the electrical
|
BricsCAD drawing for visual checking. `e_planning.py` bridges to the
|
||||||
planning: it assigns the sensors/valves of the E-planning drawing to the
|
electrical planning: it assigns the sensors/valves of the E-planning drawing
|
||||||
layout parts, so the skeleton can carry their real names.
|
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
|
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
|
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
|
│ (assigns BG/MB of the E-planning drawing to the layout
|
||||||
│ parts by position + numbering rule; see §5c)
|
│ 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)
|
└── same derivation ──► tro_annotate.py ──► <name>_annotated.dxf (VIEWER ONLY)
|
||||||
(also needs the BricsCAD DXF; uses dxf_registration.py for the CSV↔DXF
|
(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)
|
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
|
storage-line grid, target distance, uniqueness, registration, drives, the path
|
||||||
into `scl_context` and `scl_skeleton --eplan`).
|
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)
|
## 6. `tro_catalog.py` — TRO type catalog (library, no CLI)
|
||||||
|
|
||||||
The single source of truth for everything needed to generate or draw a TRO
|
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. |
|
| `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). |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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`). |
|
| `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}`,
|
`TroBase` and `TroCandidate` share the `position` handling (JSON `{x,y,z}`,
|
||||||
`x`/`y`/`z`, `describe_position()`) through the private mixin `_Positioned`.
|
`x`/`y`/`z`, `describe_position()`) through the private mixin `_Positioned`.
|
||||||
|
|||||||
+27
-2
@@ -50,6 +50,7 @@ neue Ermittlung.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
@@ -69,6 +70,7 @@ __all__ = [
|
|||||||
"load_transform",
|
"load_transform",
|
||||||
"save_transform",
|
"save_transform",
|
||||||
"registration_path",
|
"registration_path",
|
||||||
|
"file_fingerprint",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Rollen fuer den Lagebezug: CSV-Objektart -> Blocknamen im DXF, als Muster
|
# Rollen fuer den Lagebezug: CSV-Objektart -> Blocknamen im DXF, als Muster
|
||||||
@@ -123,6 +125,10 @@ class Transform(BaseModel):
|
|||||||
csv_file: str = Field(default="")
|
csv_file: str = Field(default="")
|
||||||
dxf_file: str = Field(default="")
|
dxf_file: str = Field(default="")
|
||||||
created: 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 ----------------------------------------------------------
|
# -- Anwenden ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -329,8 +335,25 @@ def _key(csv_file: str, dxf_file: str) -> str:
|
|||||||
return f"{Path(csv_file).name}|{Path(dxf_file).name}"
|
return f"{Path(csv_file).name}|{Path(dxf_file).name}"
|
||||||
|
|
||||||
|
|
||||||
def load_transform(cfg_dir: Path, csv_file: str, dxf_file: str) -> Transform | None:
|
def file_fingerprint(path: Path) -> str:
|
||||||
"""Geprueften Lagebezug aus der Konfiguration lesen."""
|
"""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)
|
path = registration_path(cfg_dir)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return None
|
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))
|
entry = data.get(_key(csv_file, dxf_file))
|
||||||
if not entry:
|
if not entry:
|
||||||
return None
|
return None
|
||||||
|
if fingerprint and entry.get("fingerprint", "") != fingerprint:
|
||||||
|
return None
|
||||||
transform = Transform.model_validate(entry)
|
transform = Transform.model_validate(entry)
|
||||||
transform.source = "cfg"
|
transform.source = "cfg"
|
||||||
return transform
|
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
|
SCL ein (SclName mit todo=False). Was keine Regel trifft, bleibt dort
|
||||||
TODO(E-Planung) - geraten wird nicht.
|
TODO(E-Planung) - geraten wird nicht.
|
||||||
|
|
||||||
Schichten: e_planning importiert material_flow (CSV) und dxf_registration
|
Seit 2026-10-15 liegen das E-Modell (EDevice, load_positionsdraw,
|
||||||
(Lagebezug); scl_context importiert nur das Modell EplanDocument.
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
|
||||||
import fnmatch
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from dxf_registration import (
|
from dxf_registration import RegistrationError, Transform, file_fingerprint
|
||||||
RegistrationError,
|
from merge_hw_layout import (
|
||||||
Transform,
|
LAYOUT_KINDS,
|
||||||
derive_transform,
|
RULES_FILE,
|
||||||
load_transform,
|
Assignment,
|
||||||
save_transform,
|
LayoutPoint,
|
||||||
verify_transform,
|
Rule,
|
||||||
|
_match,
|
||||||
|
assign,
|
||||||
|
load_rules,
|
||||||
|
resolve_transform,
|
||||||
)
|
)
|
||||||
from parameter import get_float
|
from hw_import import Drive, EDevice, load_positionsdraw, pair_drives
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EPLAN_SCHEMA",
|
"EPLAN_SCHEMA",
|
||||||
@@ -74,21 +79,12 @@ __all__ = [
|
|||||||
"load_rules",
|
"load_rules",
|
||||||
"assign",
|
"assign",
|
||||||
"pair_drives",
|
"pair_drives",
|
||||||
|
"layout_points",
|
||||||
|
"resolve_transform",
|
||||||
|
"LAYOUT_KINDS",
|
||||||
]
|
]
|
||||||
|
|
||||||
EPLAN_SCHEMA = "sps_skel-eplan-v1"
|
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):
|
class EplanDocument(BaseModel):
|
||||||
"""Die Zuordnungs-JSON <csv>_eplan.json."""
|
"""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]:
|
def layout_points(elements) -> list[LayoutPoint]:
|
||||||
"""Punktartige Bauteile aus den CSV-Elementen (material_flow.Element)."""
|
"""Punktartige Bauteile aus den CSV-Elementen (material_flow.Element)."""
|
||||||
points = []
|
points = []
|
||||||
@@ -476,31 +141,6 @@ def layout_points(elements) -> list[LayoutPoint]:
|
|||||||
return points
|
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
|
# 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)
|
devices = load_positionsdraw(eplan_path, warnings)
|
||||||
rules, anchors = load_rules(rules_path)
|
rules, anchors = load_rules(rules_path)
|
||||||
transform = resolve_transform(points, devices, anchors, cfg_dir, csv_path.name,
|
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)
|
assignments = assign(points, devices, rules, transform)
|
||||||
used = {a.bmk for a in assignments}
|
used = {a.bmk for a in assignments}
|
||||||
unassigned = sorted(d.bmk for d in devices
|
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:
|
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)
|
candidate = Path(name)
|
||||||
if candidate.is_absolute() or candidate.parent != Path("."):
|
if candidate.is_absolute():
|
||||||
return candidate
|
return candidate
|
||||||
|
if candidate.parent != Path("."):
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
return env_dir("SKEL_DATA", "data") / name
|
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
|
Hardwareplanung dort fuellen muss
|
||||||
JamArea ein Staubereich: Eingang genau eines TRO (je Separator
|
JamArea ein Staubereich: Eingang genau eines TRO (je Separator
|
||||||
laut Katalog), mit allen Vorgaengern, die dort einfahren
|
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
|
Analysis Arbeitsergebnis von tro_flow.analyse_tros
|
||||||
TroDocument die TRO-JSON (<csv>_tro.json): von tro_flow --json
|
TroDocument die TRO-JSON (<csv>_tro.json): von tro_flow --json
|
||||||
geschrieben, von scl_skeleton gelesen und geprueft
|
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`,
|
In der JSON stehen die Schluessel wie bisher in der TRO-JSON (`id`, `type`,
|
||||||
`fbType`, `candidates`, ...); im Python-Code heissen die Felder weiter
|
`fbType`, `candidates`, ...); im Python-Code heissen die Felder weiter
|
||||||
@@ -59,22 +67,44 @@ __all__ = [
|
|||||||
"TroBase",
|
"TroBase",
|
||||||
"Slot",
|
"Slot",
|
||||||
"JamArea",
|
"JamArea",
|
||||||
|
"LayoutPart",
|
||||||
"DerivedTro",
|
"DerivedTro",
|
||||||
"Connection",
|
"Connection",
|
||||||
"TroCandidate",
|
"TroCandidate",
|
||||||
"Analysis",
|
"Analysis",
|
||||||
"TroDocument",
|
"TroDocument",
|
||||||
|
"Registration",
|
||||||
|
"Binding",
|
||||||
|
"ConveyorBinding",
|
||||||
|
"AnlageDocument",
|
||||||
|
"ANLAGE_SCHEMA",
|
||||||
|
"BINDING_STATUS",
|
||||||
"derive_slots",
|
"derive_slots",
|
||||||
"derive_jam_areas",
|
"derive_jam_areas",
|
||||||
|
"storage_lines",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung),
|
# 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
|
# 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`.
|
# `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
|
# 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"
|
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
|
# Gemeinsame Einstellungen: Pruefung bei Erzeugung und Zuweisung, unbekannte
|
||||||
# Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt
|
# Schluessel sind ein Fehler (ein umbenannter JSON-Schluessel faellt auf statt
|
||||||
# still auf einen Standardwert zurueckzufallen), Aliase und Feldnamen gelten
|
# still auf einen Standardwert zurueckzufallen), Aliase und Feldnamen gelten
|
||||||
@@ -246,6 +276,34 @@ class JamArea(_Positioned):
|
|||||||
position: tuple[float, float, float] | None = None
|
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):
|
class DerivedTro(TroBase):
|
||||||
"""
|
"""
|
||||||
Ein von tro_flow aus dem CSV abgeleiteter TRO (oder eine Systemgrenze).
|
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)
|
connections: list[Connection] = Field(default_factory=list)
|
||||||
candidates: list[TroCandidate] = Field(default_factory=list)
|
candidates: list[TroCandidate] = Field(default_factory=list)
|
||||||
jam_areas: list[JamArea] = Field(default_factory=list, alias="jamAreas")
|
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)
|
findings: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
@classmethod
|
@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.
|
"""Dokument aus dem Ergebnis von tro_flow.analyse_tros bilden.
|
||||||
|
|
||||||
Slots und Staubereiche werden hier abgeleitet, damit die JSON sie
|
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]
|
tros = [t.model_copy(update={"slots": derive_slots(t)}) for t in analysis.tros]
|
||||||
return cls(
|
return cls(
|
||||||
@@ -362,6 +424,7 @@ class TroDocument(BaseModel):
|
|||||||
tros=tros,
|
tros=tros,
|
||||||
boundaries=analysis.boundaries,
|
boundaries=analysis.boundaries,
|
||||||
jam_areas=derive_jam_areas(tros),
|
jam_areas=derive_jam_areas(tros),
|
||||||
|
parts=list(parts or []),
|
||||||
connections=[
|
connections=[
|
||||||
Connection(src=src, dst=dst, kind=analysis.edge_kinds.get((src, dst), ""))
|
Connection(src=src, dst=dst, kind=analysis.edge_kinds.get((src, dst), ""))
|
||||||
for src, dst in analysis.edges
|
for src, dst in analysis.edges
|
||||||
@@ -369,6 +432,11 @@ class TroDocument(BaseModel):
|
|||||||
findings=analysis.findings,
|
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
|
@classmethod
|
||||||
def from_json(cls, text: str) -> TroDocument:
|
def from_json(cls, text: str) -> TroDocument:
|
||||||
"""JSON-Text einlesen und pruefen (pydantic.ValidationError bei Fehlern)."""
|
"""JSON-Text einlesen und pruefen (pydantic.ValidationError bei Fehlern)."""
|
||||||
@@ -389,6 +457,138 @@ class TroDocument(BaseModel):
|
|||||||
return None
|
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
|
# Ableitungen aus Katalogtyp und Topologie
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -399,23 +599,43 @@ _SWITCH_ROLES = ("valve", "exitSensor?")
|
|||||||
_SCANNER_ROLES = ("reader",)
|
_SCANNER_ROLES = ("reader",)
|
||||||
_DRIVE_ROLES = ("motor", "motorProtection", "senCarrInPos", "senFinger",
|
_DRIVE_ROLES = ("motor", "motorProtection", "senCarrInPos", "senFinger",
|
||||||
"senLastPos", "senJam")
|
"senLastPos", "senJam")
|
||||||
|
_STORAGE_LINE_ROLES = ("sensor", "stopper", "jamSensor?")
|
||||||
|
|
||||||
|
|
||||||
def _at(values: list[str], index: int) -> str:
|
def _at(values: list[str], index: int) -> str:
|
||||||
return values[index] if index < len(values) else ""
|
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]:
|
def derive_slots(tro: DerivedTro) -> list[Slot]:
|
||||||
"""Slots eines TRO: Anzahl aus dem Katalog, Layout-ID aus dem TRO.
|
"""Slots eines TRO: Anzahl aus dem Katalog, Layout-ID aus dem TRO.
|
||||||
|
|
||||||
Weichen tragen noch keine Layout-ID - tro_flow speichert das Weichen-
|
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
|
items = tro.items
|
||||||
slots: list[Slot] = []
|
slots: list[Slot] = []
|
||||||
for i in range(items.get(TroItem.SEPARATOR, 0)):
|
for i in range(items.get(TroItem.SEPARATOR, 0)):
|
||||||
slots.append(Slot(slot=f"separator{i + 1}", layout_id=_at(tro.separators, i),
|
slots.append(Slot(slot=f"separator{i + 1}", layout_id=_at(tro.separators, i),
|
||||||
roles=list(_SEPARATOR_ROLES)))
|
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)):
|
for i in range(items.get(TroItem.SWITCH, 0)):
|
||||||
slots.append(Slot(slot=f"switch{i + 1}", roles=list(_SWITCH_ROLES)))
|
slots.append(Slot(slot=f"switch{i + 1}", roles=list(_SWITCH_ROLES)))
|
||||||
for i in range(items.get(TroItem.SCANNER, 0)):
|
for i in range(items.get(TroItem.SCANNER, 0)):
|
||||||
|
|||||||
+15
-1
@@ -179,7 +179,7 @@ from material_flow import (
|
|||||||
resolve_input,
|
resolve_input,
|
||||||
)
|
)
|
||||||
from kreisel_junctions import Junction, find_junctions
|
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 parameter import get_float, get_int
|
||||||
from tro_ids import (
|
from tro_ids import (
|
||||||
DEFAULT_TOLERANCE,
|
DEFAULT_TOLERANCE,
|
||||||
@@ -2210,6 +2210,19 @@ def _tro_label(tro: DerivedTro) -> str:
|
|||||||
return _dot_label(lines)
|
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]:
|
def missing_positions(analysis: Analysis) -> list[str]:
|
||||||
"""TROs/Systemgrenzen ohne Koordinate - mit ihnen ist kein lagerichtiges
|
"""TROs/Systemgrenzen ohne Koordinate - mit ihnen ist kein lagerichtiges
|
||||||
Layout moeglich (auch eine Systemgrenze wird als Knoten gezeichnet)."""
|
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,
|
analysis, source=csv_file.name,
|
||||||
note="Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die "
|
note="Aus dem CSV-Export abgeleitet (lib/tro_flow.py) - die "
|
||||||
"Zeichnung ist nur Ansicht, keine Datenquelle.",
|
"Zeichnung ist nur Ansicht, keine Datenquelle.",
|
||||||
|
parts=layout_parts(elements),
|
||||||
)
|
)
|
||||||
json_target.write_text(document.to_json(), encoding="utf-8")
|
json_target.write_text(document.to_json(), encoding="utf-8")
|
||||||
json_file = json_target
|
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