diff --git a/CLAUDE.md b/CLAUDE.md index 0ff79aa..52f929b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,13 +27,16 @@ Template" below). What exists today is: tool in `lib/` (see "Environment scripts" below) - `lib/*.py` — CLI tools and libraries that turn a CSV export into a material-flow graph, a derived TRO list/diagram, a TRO-JSON (`lib/model.py`), an `FB_Main` SCL skeleton, and - (with the BricsCAD DXF) an annotated copy of the drawing for visual checking. + (with the BricsCAD DXF) an annotated copy of the drawing for visual checking; + `lib/e_planning.py` assigns the E-planning's sensors/valves (from the cable-length + tool's `*_positionsdraw.json`) to the layout parts so the skeleton can carry real names. See **`doc/Python_Scripts.md`** for what each script does and which switches it takes. - `data/` — input CSV exports (gitignored, not committed) - `cad/tro_types.lsp` — generated type list for the BricsCAD `TROEDIT` dialog (written by `tro_annotate.py --emit-lisp`) - `cfg/dxf_registration.json` — persisted, verified CSV↔DXF coordinate transform; - `cfg/tro_overrides.ini` — hand-maintained corrections to the derived TROs (see `tro_overrides.py`) + `cfg/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 - `doc/*.md` — analysis documents that reverse-engineer the SCL patterns and propose the fuller JSON layout schema for the parts of the pipeline still to be built out @@ -90,7 +93,7 @@ this order for onboarding: 0. **`doc/Python_Scripts.md`** — the existing CLI tooling (`lib/material_flow.py`, `lib/tro_flow.py`, `lib/tro_annotate.py`, `lib/tro_extract.py`, `lib/scl_skeleton.py`, - plus the `lib/tro_catalog.py`, `lib/model.py`, `lib/dxf_registration.py` and + `lib/e_planning.py`, plus the `lib/tro_catalog.py`, `lib/model.py`, `lib/dxf_registration.py` and `lib/tro_overrides.py` libraries they share): what each script does, its switches, inputs/outputs. This is working code, not a proposal — read it before touching `lib/`. 1. **`doc/HundM/Json_Layout-Konzept.md`** — the core proposal: a JSON file as single source @@ -182,6 +185,13 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha `sps_skel-tro-extract-v1` JSON, which `scl_skeleton.py` no longer accepts. Not deleted, not extended; if drawing edits (`TRO_INSERT`/`TRO_EDIT`) are ever needed again, it should write `model.TroDocument`. See `doc/Python_Scripts.md` §5a. +5. **`lib/e_planning.py`** (implemented, first version) — CSV + E-planning + `*_positionsdraw.json` → `_eplan.json` (`EplanDocument`): registers the E drawing to + the CSV (`dxf_registration.py`) and assigns BMKs to layout parts by rules in + `cfg/e_planning.ini` (distance, target distance, numbering rule `MB = BG + 1`). + `scl_skeleton.py --eplan` fills separator sensor and stopper valve from it; jam sensors + and outfeed/switch valves are assigned but not yet placed in the SCL. See + `doc/Python_Scripts.md` §5c and `doc/E-Planung_Bruecke.md`. What is **not** built out yet, relative to the original two-tool design: diff --git a/bin/e_planning.bat b/bin/e_planning.bat new file mode 100644 index 0000000..36d0afb --- /dev/null +++ b/bin/e_planning.bat @@ -0,0 +1,33 @@ +@echo off +REM ================================================================ +REM SPS_SKEL - E-Planung den Bauteilen des Layouts zuordnen +REM ================================================================ +REM Aufruf: +REM bin\e_planning.bat --file mubea.csv --eplan Mubea_positionsdraw.json +REM bin\e_planning.bat --file mubea.csv --eplan C:\pfad\Mubea_positionsdraw.json --re-register +REM +REM CSV und *_positionsdraw.json (Kabellaengen-Tool) werden in %SKEL_DATA% +REM gesucht (oder als Pfad angegeben). Regeln: %SKEL_CFG%\e_planning.ini. +REM Ergebnis _eplan.json und Bericht _eplan.md in %SKEL_RESULTS%. +REM +REM Weiter: bin\scl_skeleton.bat --json _tro.json --eplan _eplan.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%\e_planning.py" %* +set "RC=%ERRORLEVEL%" + +endlocal & exit /b %RC% diff --git a/bin/e_planning.sh b/bin/e_planning.sh new file mode 100644 index 0000000..0f4f3f2 --- /dev/null +++ b/bin/e_planning.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# ================================================================ +# SPS_SKEL - E-Planung den Bauteilen des Layouts zuordnen +# ================================================================ +# Aufruf: +# bash bin/e_planning.sh --file mubea.csv --eplan Mubea_positionsdraw.json +# bash bin/e_planning.sh --file mubea.csv --eplan /pfad/Mubea_positionsdraw.json --re-register +# +# CSV und *_positionsdraw.json (Kabellaengen-Tool) werden in $SKEL_DATA +# gesucht (oder als Pfad angegeben). Regeln: $SKEL_CFG/e_planning.ini. +# Ergebnis _eplan.json und Bericht _eplan.md in $SKEL_RESULTS. +# +# Weiter: bin/scl_skeleton.sh --json _tro.json --eplan _eplan.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/e_planning.py" "$@" diff --git a/cfg/e_planning.ini b/cfg/e_planning.ini new file mode 100644 index 0000000..2d5bf61 --- /dev/null +++ b/cfg/e_planning.ini @@ -0,0 +1,81 @@ +; ================================================================ +; e_planning.ini - Regeln fuer lib/e_planning.py +; ================================================================ +; Ordnet die Sensoren/Ventile der E-Planung (*_positionsdraw.json des +; Kabellaengen-Tools) den Bauteilen des Layouts (CSV) zu. Hintergrund und +; Befunde: doc/E-Planung_Bruecke.md. +; +; [Registrierung] +; anker VERW-Muster der E-Symbole, die auf einem Separator sitzen; +; daraus wird der Lagebezug E-Zeichnung -> CSV ermittelt. +; +; Jeder Abschnitt "." ist eine Regel; sie werden in dieser +; Reihenfolge angewendet, ein E-Bauteil wird hoechstens einmal vergeben. +; layout Muster fuer die Layout-Art (Separator, Ausschleus, +; Einschleus, Weiche, Scanner, Beladung, Entladung). +; Standard: der Teil vor dem Punkt im Abschnittsnamen. +; verw Muster fuer das E-Attribut VERW (fnmatch, Komma-Liste, +; Gross-/Kleinschreibung egal) +; praefix BMK-Praefix (BG, MB ...), leer = egal +; max_mm raeumlich: Hoechstabstand nach dem Lagebezug in mm +; soll_mm raeumlich: Sollabstand - das Symbol liegt immer gleich weit +; neben dem Bauteil; gewertet wird die Abweichung vom Soll +; (nicht der kleinste Abstand), zulaessig bis toleranz_mm +; toleranz_mm zulaessige Abweichung vom Sollabstand (Standard 150) +; nummer_von Nummernregel: BMK-Nummer = Nummer der genannten Rolle +; nummer_versatz ... plus dieser Versatz (Mubea: Ventil = Sensor + 1) +; Mit nummer_von wird zuerst die Nummer versucht, danach (wenn max_mm oder +; soll_mm gesetzt) der Abstand fuer die uebrigen Bauteile. +; +; Lieber eine Luecke als ein falscher Name: was keine Regel sicher trifft, +; bleibt in der SCL TODO(E-Planung) und steht im Bericht _eplan.md. +; +; Die VERW-Texte und die Nummernregel sind projektabhaengig. Die Werte hier +; stammen aus Mubea (500573); in den Referenz-SCL (UH01..UH05) haben Sensor +; und Stopper dieselbe Nummer (BG3242/MB3242) -> dort nummer_versatz = 0. +; ================================================================ + +[Registrierung] +anker = IN SEP + +; Sensor im Stopper -> stInSeparator.stSenInSep +; sitzt in der Zeichnung auf dem Separator (Mubea Speicher: 0-1 mm). +; An den Kreiseln liegen Sensoren 200-550 mm daneben, teils naeher an +; Aus-/Einschleuselementen - unsicher, deshalb bewusst eng. +[Separator.sensor] +verw = IN SEP +praefix = BG +max_mm = 300 + +; Stopperventil -> xOutStopper +; liegt ~270 mm neben dem Separator, im 800-mm-Raster der Speicherlinien +; naeher am Nachbarn als ueber die Nummer -> Nummernregel zuerst +[Separator.stopper] +verw = SEP +praefix = MB +nummer_von = Separator.sensor +nummer_versatz = 1 +max_mm = 600 +soll_mm = 270 + +; Stausensor hinter dem Ausschleuselement (Mubea: konstant ~1390 mm) +[Ausschleus.stau] +verw = STAU* +praefix = BG +max_mm = 1600 +soll_mm = 1390 + +; Ausschleusventil (Mubea: konstant ~1414 mm, Nummer = Stausensor + 1) +[Ausschleus.ventil] +verw = AE ABZW* +praefix = MB +nummer_von = Ausschleus.stau +nummer_versatz = 1 +max_mm = 1600 +soll_mm = 1414 + +; Weichenventil an einer Kreisel-Weiche +[Weiche.ventil] +verw = ABZWEIG-*, WEICHE* +praefix = MB +max_mm = 1000 diff --git a/doc/E-Planung_Bruecke.md b/doc/E-Planung_Bruecke.md new file mode 100644 index 0000000..828c607 --- /dev/null +++ b/doc/E-Planung_Bruecke.md @@ -0,0 +1,161 @@ +# Brücke Layout ↔ E-Planung über `*_positionsdraw.json` + +Stand 2026-10-10. Erst Machbarkeitsprüfung, inzwischen umgesetzt als +`lib/e_planning.py` (Regeln `cfg/e_planning.ini`, Aufruf `bin/e_planning.bat`, +`scl_skeleton --eplan`); Bedienung in `doc/Python_Scripts.md` §5c, Stand der +Umsetzung unten. + +## Problem + +Das Kapitel "Daten" in `doc/Klassenhierarchie.md` zeigt: das Hardwarelayout (CSV) +liefert die **Struktur** (welche TROs, welche Separatoren/Weichen, wer mit wem), die +E-Planung die **Namen und Adressen** (`BG…`, `MB…`, `MA…`, `FC…`). Die EA-Liste allein +kennt aber keine Topologie. Es fehlt die Zuordnung **Layout-Bauteil (CSV-`TeileId`) → +Betriebsmittelkennzeichen (BMK)**. + +## Idee + +Die E-Planung wird als Layer über der Hardwareplanung gezeichnet: Sensor- und +Aktorsymbole liegen in der Zeichnung neben dem Bauteil, zu dem sie gehören. Das +Kabellängen-Tool (`C:\10-Develop\gitrepos\kabellaengen`, `lib/getpositions.py`) +schreibt diese Symbole mit Position und Attributen nach `_positionsdraw.json`. +Damit ist die Brücke eine **räumliche Zuordnung**: E-Symbol → nächstes Layout-Bauteil +passender Art. + +## Datenquelle `*_positionsdraw.json` + +| Schlüssel | Inhalt | Nutzen für sps_skel | +|---|---|---| +| `sensors` | je BMK (`BG0642@UNKNOWN`): `pos` [x, y], `VERW` (Verwendung), `BEZEICHNUNG` (Klartext), `BESCHR`, `ARTINR`, `KENNZEICHNUNG` (`=A1+UH01-KF107`) | Hauptquelle: Sensoren/Ventile/Motoren im Feld | +| `schaltschrank_elemente` | Elemente im Schrank (Motorschutz `FC…`, Taster, Leuchten, `QA…`) mit `VERW`, `BEZEICHNUNG`, `KENNZEICHNUNG`, ohne Feldposition | Motorschutz ↔ Motor, Bedienelemente | +| `mappings` | Steuerung → Liste ihrer BMK (`UH01: [...]`) | Steuerungsaufteilung, Nummernkreis | +| `racks`, `distributors`, `tunnels` | Kabeltrassen, Verteiler | für sps_skel ohne Belang | +| `double_ids`, `warnings` | doppelte BMK, fehlende Attribute | Qualitätsprüfung der E-Daten | + +Die Erdungs-Beispiele (`PR500501_…_Erdung_…`, `ST500652_…`) enthalten nur +Potentialausgleich (`POT…`) und taugen nicht für die Brücke. Brauchbar sind +Dateien mit `BG`/`MB`/`MA`: `Mubea_positionsdraw.json` (157 Feld-BMK), +`ST500592_ILS_positionsdraw.json` (~3100), `ST500592_10_5-10_Omniflo_…` (~900), +`ST-Fortna_positions_.json` (mit 38 Scannern `BX`). + +## Test an Mubea + +Eingaben: `data/mubea.csv` (Layout, 38 Separatoren) und +`kabellaengen/work/Mubea_positionsdraw.json` (E-Planung). Die Zahlen in diesem +Abschnitt stammen aus der ersten Untersuchung (Umkreis 700 mm, ohne Regeln); das +Ergebnis der Umsetzung steht unten. + +### 1. Lagebezug + +Die Ermittlung aus `lib/dxf_registration.py` (`derive_transform`) funktioniert +unverändert, wenn man die E-Symbole mit `VERW` `SEP`/`IN SEP` als Rolle "Separator" +einsetzt: + +```text +mubea.csv <- Mubea_positionsdraw.json: dx +50613.6 mm, dy +8555.8 mm, Drehung 0 + 26/38 Separatoren im Toleranzband +``` + +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 +Koordinatensystem wie die Layoutzeichnung. Zum Prüfen genügt also der +bestehende Mechanismus; als Schlüssel in `cfg/dxf_registration.json` kann +`|` dienen. + +### 2. Was sich zuordnen lässt + +| E-Symbol (`VERW`) | Layout-Partner | Abstand | Ergebnis | +|---|---|---|---| +| `IN SEP` (BG, Sensor im Separator) | Separator | **0–1 mm** im Pin-Speicher (20/20), sonst 200–500 mm | Anker: das Symbol sitzt auf dem Separator | +| `SEP` (MB, Stopperventil) | Separator | konstant **~270 mm** daneben | eindeutig über Nummernregel (unten), nicht über Abstand | +| `AE ABZWEIG` (MB, Ausschleusventil) | Ausschleuselement | konstant **1414 mm** (20/20, Versatz (−1405, −159)) | eindeutig, wenn der Versatz je Symbolart bekannt ist | +| `STAU_REF-LT` (BG, Stausensor) | Ausschleuselement | konstant **1391 mm** (20/20) | wie oben | +| `ABZWEIG-K104` (MB, Weiche) | Weiche | 783 mm | eindeutig (nur eine Weiche) | +| `BANDBRUCH`, `KETTENBRUCH`, `ÜBERGABE`/`ÜBERNAHME`, `SYNC P1..4`, `ZAPPLER` | Kreisel/Strecke/Station | uneinheitlich, teils > 50 m (Symbol an Konvejoren außerhalb des CSV-Ausschnitts) | nicht über Abstand, nur über `BEZEICHNUNG` | + +Nebeneinander liegende Separatoren (Pin-Speicher, Raster 800 mm) machen den +Abstand allein mehrdeutig: das MB von Strecke n liegt 270 mm von Separator n und +530 mm von Separator n+1. Eindeutig wird es erst mit der Nummernregel. + +### 3. Nummernregel der E-Planung + +Ventil = Sensor + 1, gleiche Funktionsgruppe: + +| Paar | Treffer (von 58 MB) | +|---|---| +| `MB(n)` `SEP` ↔ `BG(n−1)` `IN SEP` (z. B. BG0642 / MB0643) | 31 | +| `MB(n)` `AE ABZWEIG` ↔ `BG(n−1)` `STAU_REF-LT` (z. B. BG0542 / MB0543) | 21 | +| sonstige Paare (`VEREINZ(CP)`, `STOPPER (CP)`, `AE ABZW.`) | 3 | +| ohne BG-Partner | 3 | + +Damit reicht es, den **Sensor** räumlich zuzuordnen (der `IN SEP` liegt auf dem +Separator); das Ventil folgt aus der Nummer. Die SCL-Referenzen zeigen dasselbe +Muster (`stSenInSep := …["cInBG3242"]`, `xOutStopper1 => "MB3242"`) — dort sogar +mit gleicher Nummer; die Regel ist also projektabhängig und gehört in eine +Konfiguration, nicht in den Code. + +Gleiches gilt für Motor ↔ Motorschutz: `MA0200` "PIN CONVEYOR#100" ↔ +`FC0200` "MOTORSCHUTZ CONVEYER #100" (gleiche Nummer, `schaltschrank_elemente`), +wie in der SCL `xOutVarioMotor => "MA0145"`, `xInMotorProtection := "FC0145"`. + +### 4. Was nicht passt + +12 von 38 Layout-Separatoren (vor allem an den Kreiseln K0102/K0104/K0105) haben im +Umkreis von 700 mm kein `IN SEP`/`SEP`; umgekehrt liegen mehrere `IN SEP` des +Kreisels K0102 neben Aus-/Einschleuselementen statt neben einem Separator. Vermutlich +ist die CSV (aus `500573_60_1.dxf`) ein anderer Planungsstand als die E-Zeichnung +`Mubea.dxf` des Kabellängen-Tools, oder Kreisel-Separatoren sind im CSV anders +modelliert. Das muss mit zueinander passenden Ständen geprüft werden, bevor man +eine Quote angibt. + +`Mubea_positionsdraw.json` enthält **keine Scanner** (`BX`). Für Scanner-Zuordnung +ist ein anderes Projekt nötig (`ST-Fortna_positions_.json`, `ILS-Baugruppe`). + +## Was die Brücke für das Kapitel "Daten" liefert + +| SCL-Datum | aus `positionsdraw.json` | Regel | +|---|---|---| +| `stInSeparator.stSenInSep` | BG mit `VERW` `IN SEP` | räumlich am Separator (Anker) | +| `xOutStopper` | MB mit `VERW` `SEP` | Nummernregel ab dem BG | +| `stInSenJam`, `Settings.xPluggedJam` | BG `STAU_REF-LT` | räumlich, Versatz je Symbolart; vorhanden ⇒ `xPluggedJam := TRUE` | +| `xOutSwExTo3/4` | MB `AE ABZWEIG` / Weichen-MB | Nummernregel ab dem Stausensor bzw. räumlich an Weiche/Ausschleuselement | +| `Settings.xPluggedPart`, `stSenPart` | BG `BT BELADEN` | räumlich; vorhanden ⇒ `TRUE` | +| `xInMotorProtection`, `xOutVarioMotor` | MA im Feld + FC in `schaltschrank_elemente` | gleiche Nummer | +| `xInCarouselRun` (welcher Antrieb) | FC/MA-`BEZEICHNUNG` "KREISEL-K0102", "CONVEYOR #100" | Textabgleich mit dem Host-Kreisel des TRO | +| Steuerung, TRO-Nummernkreis, `stInOutMachineState` | `KENNZEICHNUNG` `=A1+UH01-KF107`, `mappings` | `UH01` = Steuerung, `KF107` = E/A-Station | +| Plausibilisierung | `BEZEICHNUNG` "SEPARATOR STR. 14", "KR-K0102 VOR BT-BELADUNG" | Strecken-/Kreiselnummer gegen Layout prüfen | + +Nicht aus dieser Quelle: Not-Halt-Kreis (`xInSftyOk`), Taster für Automatik/Quittieren +(stehen nur als Schrankelemente ohne Bezug zum TRO), Scanner-Hardwarekennungen, +Weichenbauart (`xNewSwitch`). + +## Umsetzung (Stand 2026-10-10) + +1. **`lib/e_planning.py`**: liest `*_positionsdraw.json` in `EDevice` (BMK, VERW, + Bezeichnung, Kennzeichnung → Steuerung/E/A-Station, Position). +2. **Lagebezug** über `dxf_registration.derive_transform` mit den `IN SEP`-Sensoren + als Rolle "Separator"; abgelegt in `cfg/dxf_registration.json` unter + `|`. +3. **Regeln in `cfg/e_planning.ini`** (projektabhängig): je Rolle Layout-Art, VERW, + Höchstabstand, **Sollabstand** (für die konstanten Versätze von Stausensor und + Ausschleusventil — sonst gewinnt bei 0080/0084 ein näherer, fremder Stausensor + des Kreisels K0102) und die Nummernregel (`MB = BG + 1`). +4. **Ausgabe** `_eplan.json` (`EplanDocument`) und Bericht `_eplan.md`. + `scl_skeleton --eplan` setzt Separator-Sensor und Stopperventil ein + (`SclName.todo = False`); alles andere bleibt `TODO(E-Planung)`. + +Ergebnis Mubea mit den ausgelieferten Regeln: Separator-Sensor 21/38, +Stopper 22/38, Stausensor 20/23, Ausschleusventil 20/23, Weichenventil 1/1. Den +Separator-Sensor nimmt die Regel nur bis 300 mm; die Kreisel-Fälle mit 470–540 mm +(z. B. BG0532 an 0044) bleiben bewusst offen. + +Offen: + +- Stausensoren und Ausschleus-/Weichenventile stehen in JSON und Bericht, kommen + aber noch nicht in die SCL: die TRO-JSON sagt nicht, welches Ausschleuselement + bzw. welche Weiche zu welchem TRO-Ausgang gehört. +- Die Lagerlinien eines `PinStore`-TRO (`arInSep[n]`) sind zugeordnet, der + Generator erzeugt die Linienparameter aber noch nicht. +- Scanner (`BX`) fehlen in den Mubea-Daten; Regel und Test mit + `ST-Fortna_positions_.json` stehen aus. +- Ein Abgleich mit passenden Ständen von CSV und E-Zeichnung für die Kreisel. diff --git a/doc/Klassenhierarchie.md b/doc/Klassenhierarchie.md index 3175e21..b89e6ef 100644 --- a/doc/Klassenhierarchie.md +++ b/doc/Klassenhierarchie.md @@ -31,6 +31,8 @@ scl_skeleton.py (Ausgabe: SclFile -> FB_Main.scl, generisch oder per Jin material_flow.py dxf_registration.py (eigenständig, ohne Abhängigkeit auf obige) tro_overrides.py (importiert model.Connection / model.TroCandidate) +e_planning.py (Brücke zur E-Planung; importiert dxf_registration, liest das + CSV über material_flow; scl_context kennt nur EplanDocument) ``` `tro_flow.py` und `tro_annotate.py` definieren keine eigenen Klassen (nur @@ -132,6 +134,17 @@ lib/scl_context.py [Render-Kontext für die SCL, BaseM lib/scl_skeleton.py [SCL-Ausgabe] └── SclTemplates (Ordner mit .scl.j2, hält ein jinja2.Environment) + +lib/e_planning.py [Brücke Layout <-> E-Planung] +├── EDevice(BaseModel) (ein BMK aus *_positionsdraw.json: VERW, Bezeichnung, Position) +├── LayoutPoint (@dataclass: punktartiges Layout-Bauteil in CSV-Koordinaten) +├── Rule (@dataclass: eine Regel aus cfg/e_planning.ini) +├── Assignment(BaseModel) (BMK in einer Rolle an einem Layout-Bauteil, mit Abstand) +├── Drive(BaseModel) (Motor MA ↔ Motorschutz FC) +└── EplanDocument(BaseModel) (= _eplan.json) + ├─ transform: Transform (aus dxf_registration) + ├─ assignments: list[Assignment] + └─ drives: list[Drive] ``` ## Auffälligkeiten für ein mögliches Refactoring @@ -414,3 +427,188 @@ generischen Aufbau (`emit_region()`). **Nicht Produktion:** `doc/TRO_Katalog/scl_templates/*.scl` sind die realen Referenz-Aufrufstellen. Gegen sie prüft `tests/test_scl_templates.py` jedes Template im Round-Trip; in die erzeugte SCL fliessen sie nicht ein. + +## Daten + +Bestandsaufnahme aller Daten, die in den Referenz-Aufrufstellen und FB-Schnittstellen +unter `doc/TRO_Katalog/scl_templates/*.scl` vorkommen (Stand 2026-10-10), und woher +der Generator sie bekommen muss. Grundlage: die sieben Aufrufstellen +(`FB_ILS_MTRO_1Sep`, `_1Sep1Swi`, `_1Sep2Swi`, `_1Sep_SSCC`, `_PinStore_Auto`, +`_Vario`, `_Vario_workStation_CallSite`) und die `VAR_INPUT/OUTPUT/IN_OUT`-Abschnitte der +vier vollständig vorliegenden FBs (`FB_ILS_MTRO_2Sep1Swi`, `FB_ILS_MTRO_Vario_workStation`, +`FB_EmptyCarrBuffer`, `FB_LoadingBoom_INBOUND`). Die Beispielwerte stammen aus diesen +Dateien. + +**Quellen** (Spalte "Quelle"): + +| Kürzel | Quelle | Was sie liefert | +|---|---|---| +| **L** | Hardwarelayout (CSV-Export ILS 2.0, `data/*.csv`) | Bauteile (`TeileArt`: Separator, Scanner, Weiche, Kreisel, Strecke, Gefällestrecke, BTMT Be-/Entladung, Ein-/Ausschleuselement), `Nachbarn`/`TrackIds`, `Merkmale` (Drehrichtung, Antriebfahrtrichtung, Höhen, Längen, Anzahl Separatoren/Scanner, Zuordnung). Daraus abgeleitet: TRO-Typ, Topologie, Reihenfolge, Ziele. | +| **E** | Elektroplanung (EA-Liste/WSCAD/TIA-Variablentabelle, siehe `doc/HundM/EA-Listen-Analyse.md`) | Betriebsmittelkennzeichen und Variablennamen (`BG…` Sensoren, `MB…` Ventile, `MA…` Motoren, `FC…` Motorschutz, `SF…` Taster, `BX…` Scanner), Hardware-Kennungen, welche optionalen Sensoren verbaut sind, Zuordnung zu Schaltschrank und Not-Halt-Kreis. | +| **K** | Namenskonvention | Name folgt aus TRO-Nummer bzw. Staubereichs-/Weichennummer (`cMainTro`, `cSep.`, `"DB_Interface_HMI".stTRO.TRO` …). Keine externe Quelle nötig, sobald Nummernkreis und Regel feststehen. | +| **P** | Parameter / Projektstandard | Weder Layout noch E-Planung: Zeiten, Schwellwerte, Projektkonstanten, WCS-/Inbetriebnahme-Logik. Kandidaten für Typ-Defaults (`doc/TRO_Typen.md`). | + +`L + E` heisst: das Layout sagt, **dass/wie viele** es gibt und **wo**, die +E-Planung liefert den **Namen/die Adresse**. Das ist der häufigste Fall. + +### TRO-Kopf und Instanzaufruf + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Existenz, Anzahl, Reihenfolge der TROs | `REGION TRO 100 …` | L | `tro_flow` → `DerivedTro`; REGION-Reihenfolge `scl_context.flow_order()` | +| TRO-Typ / FB-Typ | `FB_ILS_MTRO_1Sep1Swi` | L (→ Katalog) | Typ aus Separator-Host (`doc/500573_Mubea/TRO_Identifikation_500573.md`), FB aus `tro_catalog` | +| TRO-Nummer, Instanzname | `#TRO107` | L + E | Anzahl aus L; Nummernkreis je Steuerung (100er = UH01, 200er = UH02 …) ist Projektvorgabe | +| REGION-Titel | `TRO 267 (1Sep2Switch)+Scanner(BX2100)` | L + E | Typ aus L, Scanner-BMK aus E | +| `nInMainTroNo` | `"cMainTro100"` | K | Wert der Konstante in der PLC-Konstantentabelle | +| `nOutStateLast` | `"DB_GLOB_TroState".arTroState["cMainTro100"]` | K | | +| `stInOutHMI` | `"DB_Interface_HMI".stTRO.TRO100` | K | PinStore/Puffer: eigener UDT `UDT_HMI_TRO_PinStore` | +| `xInRelease` | `"DB_TRO_TEST".TRO100`, `TRUE`, WCS-Verbindung aktiv | P | Freigabe-/Testlogik, Inbetriebnahme | +| `xInAllRdyToStart`, `xInCancel`, `xInReset` | `TRUE`, `"cTodoFalse"` | P | meist Konstante | +| `stInSettings.xIsBorner` (`xIsBorner1/2`) | `TRUE` | L | erster TRO nach Einfahrt in die Anlage (Beladestation / Systemgrenze `SYSn`) | +| `stInSettings.nBornerDest(1/2)` | – | P | Standardziel für neu angelegte Carrier (WCS) | +| `stInSettings.bSepType` (`bSep1Type/2Type`) | `.%X2 := TRUE` | E + P | X0 erstes Bein halten, X1 Beladekontrolle, X2 Beladeerfassung — hängt an verbauter Sensorik und Prozess | +| `stInSettings.nTroStopper1/2`, `nTroSwitchExit2/3` | – | L + K | Nummern der Nachbar-TROs | +| `stInSettings.sJamSrc` / `sJamDest` | – | L + K | Namen der benachbarten Staubereiche | +| `stInSettings.xCar2NecRight/Left` | – | L | zweiter Kreisel nötig für Richtung rechts/links (Topologie an einer Kreisel-Weiche) | +| `tPartSenHigh`, `tInSenHigh`, `tDummyTimeNormal`, `tDummyTimeBorner` | FB-Defaults 500 ms / 3 s / 0 ms | P | Typ-Defaults | + +### Separator (`stInSeparator`, `xOutStopper`) + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Anzahl Separatoren je TRO | `stInSeparator1`, `stInSeparator2` | L | `ILS 2.0 Separator` + `Zuordnung`, Typ-Katalog `items` | +| `Settings.nSeparatorNo` | `"cSep107.1"` | K | Layout-Separator-ID (`TeileId`) ist **nicht** diese Nummer | +| Sensor im Stopper `stSenInSep` | `"DB_Inputs".Sensors["cInBG3242"]` | E | | +| Sensor nach Stopper `stSenSepLeft`, Beladesensor `stSenPart`, Scan-Start `stSenStartScan` | `["cInBG4465"]` | E | nur wenn verbaut | +| Stopperventil `xOutStopper` / `xOutStopper` | `=> "MB3242"` | E | BG- und MB-Nummer sind in der E-Planung meist gleich (BG3242/MB3242) | +| `Settings.xPluggedJam` | `TRUE` | E | Stausensor verbaut? | +| `Settings.xPluggedPart` | `FALSE` | E | Beladesensor verbaut? | +| `Settings.xPluggedConfirm`, `xPluggedNullBT` | `FALSE` | E | | +| `Settings.xPluggedScanner` | `TRUE` | L | Scanner am Separator (`DerivedTro.scanners`) | +| `Settings.tDelayToNextItem` | `"TimeForSepWaiting_Short"` / `_Medium` | P (+ L) | Wahl Short/Medium hängt am Host (Kreisel/Strecke/Gefälle) | +| `Settings.tTrailingTime`, `tHandlingTime` | `t#600ms`, `t#200ms`, `"cTimeSepTrailing"` | P | Typ-Default, IBN | +| `Settings.tJam` | `"TimeForSepJam"` | P | Projektkonstante | +| `Settings.tSenFree`, `tSenWait` | `T#1500ms`, `T#4000ms` | P | | + +### Staubereiche (Jam) + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Anzahl Ein-/Ausgänge und Zuordnung Exit-Nr ↔ Nachbar | `arInOutJamEntr1`, `arInOutJamExit2..4` | L | je Vorgänger ein Eingang, je Nachfolger/Weichenabgang ein Ausgang (`SclJam`) | +| Gleicher Staubereich auf beiden Seiten einer Kante | Exit von A = Entr von B | L | Identität aus der Topologie | +| Staubereichs-DB und -Index | `"DB_JamArea0102".stJam0102_1.arCarrier` / `.stData` | K (+ E) | Nummerierung der Staubereiche ist Projektvorgabe | +| Stausensor je Ausgang | `stInSenJam2 := "DB_Inputs".Sensors["cInBG3243"]` | E | | +| Übergabe-Staubereich an Steuerungsgrenze | `#arCarrierHandoverJam5001`, `#stJamDataHandoverJam5001` | L + E | Steuerungsaufteilung aus der E-Planung | +| `xInJamAtExit2/3` | – | P | NIU | + +### Weiche (`stInSwitch`, `FC_Direction`) + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Anzahl Weichen je TRO | `stInSwitch1`, `stInSwitch2` | L | `ILS Weiche`, Ausschleuselement, Typ-Katalog | +| `Settings.nSwitchNo` | `"cSwi107.1"` | K | | +| `Settings.nTo1Destinations` | `3` | L | Erreichbarkeit im TRO-Graphen (`reachable_via()`); bei mehreren Weichen Planungsentscheidung | +| Zielliste je Abgang | `cDest…` | L + P | welche Ziele: L; Zielnummern: Projekt/WCS | +| `Settings.xPluggedExSen3/4`, `stSenExit3/4` | `FALSE` | E | Abgangssensoren verbaut? | +| `Settings.xNewSwitch` | `TRUE` | E | Weichenbauart (ggf. über `ARTINR` im CSV prüfbar) | +| Weichenventile `xOutSwExTo3/4`, `xOutSw1EntrTo1/2` | `=> "MB3304"` | E | | +| `FC_Direction`-Aufruf | `nInSwitchNo := "cSwi107.1"`, `InOutCarrier := …stJam0102_1.arCarrier[1]` | K + L | Carrier aus dem Eingangs-Staubereich | +| Vorfahrt `stInSettings.xPrioDir`, `stInPriorityManagerDir` | `"DB_Prozessdaten".stPriorityManager.udt_TRO107.TroIn` | L + K | Vorfahrt nötig, wo zwei Ströme zusammenlaufen (Kreuzung/Zusammenführung) | +| `stInPriorityManager.xReleaseOk` | `TRUE` ("braucht keine Vorfahrtsregelung") | L | TRUE, wenn keine Zusammenführung | + +### Scanner / Barcode / SSCC + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Scanner vorhanden, Anzahl | `stInBarcodeReader`, `stInBarcodeReader1/2` | L | `ILS 2.0 Scanner`, `Anzahl_Scanner` | +| Scanner-BMK und Index | `#arCognexInterface["cScannerBX2100"]` | E | | +| Cognex-Variablen | `"bx2100-TriggerReady"`, `-TriggerAck`, `-Results Available`, `-General Fault`, `-TriggerEnable`, `-Trigger`, `-ResultsAck` | E | aus dem BMK nach Regel bildbar, wenn die TIA-Variablentabelle dem Muster folgt | +| Hardware-Kennung Ergebnisse | `"=A01+UC021-BX2100~Ergebnisse_–_64_Bytes_1"` | E | | +| Kein Scanner | `#stCognexInterfaceDummy` | L | | +| `Settings.xActivateCheckASCI`, `tTimeout` | `TRUE`, `T#5s` | P | | +| `Settings.nStation`, `nScannerPurpose` | `111`, `0` | P | WCS | +| Zweck SSCC-Scanner, `xInPluggedSSCC` | `#stCognexIntfTro105` | E + P | Layout kennt den zweiten Scanner, nicht seinen Zweck | +| `stSenEndMeasurement` | `["cInBG3281"]` | E | | +| `FC_CARR_GLOB_CORRECTION` | `nInStation := 0`, Carrier aus Eingangs-Staubereich | P + L | | +| WCS-Telegramm, HMI-SSCC-Ausgabe | `FC_Send_GOHDReqM`, `"DB_Interface_HMI".stSSCC.SSCC1107` | P | WCS-Schnittstelle | + +### Vario (`stInVario`) und Arbeitsplatz + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Vario vorhanden | `FB_ILS_MTRO_Vario` | L | angetriebene Strecke / Variofoerderer | +| Sensoren `stSenCarrInPos`, `stSenFinger`, `stSenLastPos`, `stSenJam`, `stSenNextSep`, `stSenSepVario` | `["cInBG3026"]` … | E | | +| Motor `xOutVarioMotor`, Motorschutz `xInMotorProtection` | `"MA0145"`, `"FC0145"` | E | | +| Finger-Staubereich `arInOutJamFinger` | `"DB_JamArea4103".stJam4103_1` | L + K | | +| `Settings.tConvEmpty` | `T#45000ms` | L + P | aus Förderlänge (`L_VF_m`) und Geschwindigkeit ableitbar | +| `Settings.nCountCarrier`, `tWaitForJog`, `tWaitForStop`, `tCarrierInPosDelay`, `tCarrierInPosEdgeDelay`, `tRasterHigh/Low`, `xJamSenDelayActive`, `tJamSenDelay`, `xContinousEmptying`, `SettingsVarioFingerMonitoring` | `1`, `T#300ms` … | P | | +| Arbeitsplatz `stInStationSensor`, `xInReleaseStation`, `fbConvVario.xBeladenError` | `["cInBG3314"]`, `#"rTrig_Workstation 1".CLK` | E | Taster/Sensor am Arbeitsplatz | + +### Antrieb, Sicherheit, Betriebsart + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Fördertechnik, die den TRO trägt | `xInCarouselRun1 := #fbCarousel0102.xOutConveyorFullSpeed`; Gefälle: `TRUE` | L | Host-Kreisel aus `DerivedTro.hosts`; Instanzname nach K | +| `xInCarouselRun2` | zweiter Kreisel | L | TRO an der Berührung zweier Kreisel | +| `xInConveyorRunIn/Out` (PinStore) | `#fbSortConv1101`, `#fbExitConv1103` | L + K | | +| `xInSftyOk` | `"DB_InterfaceSafety".stFromSafety.EStopMainOF1.Q` | E | Not-Halt-Kreis | +| `stInOutMachineState` | `#stInOutControlUnitCabinetOF1` | E | Schaltschrank (OF1/OF2) | +| `xInAutomatic`, `xInErrorQuit`, `xInQuitLocal` | `#t_Automatik_OF1 AND "SF0120"`, `"SF0002"`, `"SF0121" OR "SF0117"` | E | Bedienelemente | +| `nInTestTarget` | `"IBN".nTestTarget111` | P | | + +### Pin-Lager (`FB_ILS_MTRO_PinStore_Auto`) und Leerträgerpuffer (`FB_EmptyCarrBuffer`) + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Anzahl Lagerlinien | `nNoOfLines := 19`, `arInSep[1..5]` | L | Separatoren auf Kreisel mit Pinband / `Separatorlist` | +| Positionen je Linie, Staulänge | `nNoOfPositions := 70`, `nJamLength := 4` / `"cStorage111ExitJam2"` | L + P | aus Linienlänge und Teilung | +| Liniensensoren `arInSep[n].stSenTrolley`, `arInJamSensor[n]`, `stInSenJamExit` | `["cInBG3270"]` | E | | +| Linienventile `arOutSeparatorStore[n]` | `"MB3270"` | E | | +| Pin-Separator `stInSeparatorPin`, `xOutSeparatorPin` | `["cInBG3000"]`, `"MB3000"` | E | | +| Referenzsensor "Zappler" `stInSensorReference` | `["cInBG3001"]` | E | | +| Encoder `stInEncoderParameter` | `#fbEncoderGF2200.nOutPosActDif` | E | | +| `nDestGeneral`, `nConvStations`, `nStationsMax`, `nStorageThreshold` | `"cDest11100"`, `"cHooksSorterMaxPin111"` | P | WCS/Projekt | +| `nInBufferNr`, `xInBufferReq/Release/ReleaseNext`, `xInPause` | `101`/`102` | P | Pufferlogik | +| Lager-/Stations-DBs | `"DB_Storage1.1.1".arLine`, `"DB_ConveyorStations"`, `"DB_HandshakeStorage"` | K + P | | +| Linien-Zeiten, Simulation | `"cTimeSepHandlingStorageLine"`, `stSimu.*` | P | | + +### Ladeschleife (`FB_LoadingBoom_INBOUND`) + +| Daten | SCL-Stelle (Beispiel) | Quelle | Bemerkung | +|---|---|---|---| +| Ladeschleife vorhanden, Lage | – | L | Beladestation (`ILS 2.0 BTMT Beladung`), prüfen | +| Bedienelemente `stInControl.*` | Taster, Knebel-/Schlüsselschalter | E | | +| Ausleger/Stützfuß `stInBoom.*`, `stInFoot.*` | Endlagen, Motorschutz, Bumper | E | | +| Analogwerte `stInTilt.wTilt`, `stInDistance.wDistance` | Neigung, Abstand | E | | +| Sensoren `stInSen2mThreshold`, `stInSenJamEntr/Exit1/Exit2`, `stInSenStopper1/2`, `stInSenGrid` | | E | | +| Ausgänge `stOutStopper.*`, `stOutBoom.*`, `stOutFoot.*`, `xOutLedMovement`, `xOutLight`, `xOutLedBumper`, `xOutCarousel` | | E | | +| Schwellwerte `nDiff`, `nZeroOk`, `nStartSlow/Fast`, `nDistanceZero`, `tDiff`, `tZero`, `nTiltComp` | | P | Inbetriebnahme | +| `stInSettings.*` Zeiten | `tBoomInput := T#3500ms` … | P | | +| Exit-Staubereich `arInOutCarrierExit`, `stInOutCarrierExit` | | L + K | | + +### Zusammenfassung + +- **Aus dem Hardwarelayout (L)** kommt die **Struktur**: welche TROs es gibt, ihr Typ + und damit FB und Anzahl der Parameterblöcke (Separatoren, Weichen, Scanner, Vario, + Lagerlinien), Vorgänger/Nachfolger und damit Ein-/Ausgangs-Staubereiche, + REGION-Reihenfolge, Ziele je Weichenabgang (`nTo1Destinations`), Vorfahrt ja/nein, + Systemgrenzen (`xIsBorner`), die tragende Fördertechnik (`xInCarouselRun`, Gefälle → + `TRUE`), ob ein Scanner am Separator sitzt (`xPluggedScanner`) und Längen als + Grundlage für Zeiten/Staulängen. Das meiste davon ist heute in `DerivedTro` → + `SclTro` abgebildet; Vorfahrt, `xIsBorner` und Längen noch nicht. +- **Aus der Elektroplanung (E)** kommen **alle Namen und Adressen**: jeder Sensor + (`BG`), jedes Ventil (`MB`), Motor (`MA`), Motorschutz (`FC`), Taster (`SF`), Scanner + (`BX`) samt Cognex-Variablen und Hardware-Kennung, die `xPlugged*`-Schalter (was ist + tatsächlich verbaut), Weichenbauart, Not-Halt-Kreis, Schaltschrank und + Steuerungsgrenzen. Im Render-Kontext sind das die `SclName` mit `todo=True` + (`TODO(E-Planung)`). Offen ist die Brücke: die EA-Liste kennt keine Topologie; es + braucht eine Zuordnung Layout-Bauteil (Separator-`TeileId`) ↔ BMK (`BG3242`/`MB3242`). + Diese Brücke baut `lib/e_planning.py` über die Symbolpositionen in + `*_positionsdraw.json` des Kabellängen-Tools (`doc/E-Planung_Bruecke.md`); + heute kommen damit Separator-Sensor und Stopperventil in die SCL + (`scl_skeleton --eplan`). +- **Namenskonvention (K)**: `cMainTro`, `cSep`, `cSwi`, Staubereichs-DBs, HMI- und + State-Pfade — sobald Nummernkreise feststehen, ohne weitere Quelle erzeugbar. +- **Parameter (P)**: Zeiten, Projektkonstanten, WCS-Ziele/Stationen, Test-/IBN-Signale — + weder L noch E; gehören als Typ-Defaults in den Katalog bzw. in eine + Projektkonfiguration. diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index 8e1e526..c5efc6a 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -1,14 +1,16 @@ # Python Scripts — CLI Tooling Overview -**As of:** 2026-10-09 +**As of:** 2026-10-10 -`lib/` currently holds ten Python modules (five of them runnable CLI tools, +`lib/` currently holds eleven Python modules (six of them runnable CLI tools, five supporting libraries), plus one documentation-generator script that lives under `doc/TRO_Katalog/tro_graphs/`. Together the CLI tools turn a CSV export of the mechanical layout (ILS 2.0) into a material-flow graph, a derived TRO list/diagram, a TRO-JSON (shared model `lib/model.py`), an `FB_Main` SCL skeleton generated from that JSON, and an annotated copy of the BricsCAD -drawing for visual checking. +drawing for visual checking. `e_planning.py` bridges to the electrical +planning: it assigns the sensors/valves of the E-planning drawing to the +layout parts, so the skeleton can carry their real names. 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 @@ -34,6 +36,11 @@ tro_flow.py ──► _tro_flow.dot / .svg, _tro_doc.md, _tro_error │ scl_skeleton.py ──► _FB_Main.scl │ (validates the JSON against model.py; one REGION per TRO; │ E-planning values left as TODO(E-Planung) gaps) + │ ▲ + │ │ --eplan _eplan.json (optional) + │ e_planning.py ◄── _positionsdraw.json (kabellaengen) + │ (assigns BG/MB of the E-planning drawing to the layout + │ parts by position + numbering rule; see §5c) │ └── same derivation ──► tro_annotate.py ──► _annotated.dxf (VIEWER ONLY) (also needs the BricsCAD DXF; uses dxf_registration.py for the CSV↔DXF @@ -91,6 +98,8 @@ Each CLI tool is invoked through its `bin/.bat` (Windows) / `bin/.sh bin\material_flow.bat --file mubea.csv --tosvg --use-cords --doc bin\tro_flow.bat --file mubea.csv --tosvg --use-cords --doc --json bin\scl_skeleton.bat --json mubea_tro.json +bin\e_planning.bat --file mubea.csv --eplan Mubea_positionsdraw.json +bin\scl_skeleton.bat --json mubea_tro.json --eplan mubea_eplan.json bin\tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --flow --fb --legend ``` @@ -102,8 +111,9 @@ Conventions shared by the CLI tools: - `--file` / `--dxf` accept either a bare filename — resolved against `%SKEL_DATA%` — or a full path. - All generated output is written to `%SKEL_RESULTS%`. -- `tro_annotate.py` additionally persists the verified CSV↔DXF coordinate - offset to `%SKEL_CFG%\dxf_registration.json`. +- `tro_annotate.py` and `e_planning.py` additionally persist the verified + coordinate offset (CSV↔DXF, CSV↔E-planning drawing) to + `%SKEL_CFG%\dxf_registration.json`. - Every run prints a `====` bordered report to stdout (counts, warnings, output file paths); non-fatal findings are printed as `?`/`!` lines and also do not stop the run. @@ -460,7 +470,7 @@ than a guess. Switches: `--json NAME` (default `export_tro.json`, looked up in `%SKEL_RESULTS%`; written by `tro_flow.py --json`), `--out NAME` (default -`_FB_Main.scl`), `--start TRO`. The last one matters while the plant has +`_FB_Main.scl`), `--eplan NAME` (see below), `--start TRO`. The last one matters while the plant has no loading/unloading station: the flow is then a closed loop with no entry, so the order has to be broken somewhere arbitrary. The chosen start and the reason are recorded in the file header. @@ -492,6 +502,16 @@ times 100/50 ms), and every value that is still open carries `TODO(E-Planung)`. `--strict` stops (exit `1`, no SCL) if any FB type has no template. Without `--templates` the output is unchanged. +**Names from the E-planning (opt-in, `--eplan NAME`).** Reads the assignment +`_eplan.json` written by `e_planning.py` (§5c; bare name looked up in +`%SKEL_RESULTS%`). For every separator it assigns, the separator sensor +(`stSenInSep := "DB_Inputs".Sensors["cInBG0642"]`) and the stopper valve +(`xOutStopper => "MB0643"`) are filled in and lose their `TODO(E-Planung)` +marker (`SclName.todo = False`); everything else stays open. The header names +the E-planning file. An assignment made for a different CSV (`csv_file` ≠ the +TRO-JSON's `source`) stops the run with exit `1` — separator numbers are only +unique within one export. Without `--eplan` the output is unchanged. + Tests: `tests/test_scl_context.py` (the derivations one by one), `tests/test_scl_templates.py` (each template rendered with the real values of its reference call site must equal that call site, comments and whitespace @@ -500,6 +520,86 @@ HundM05 byte for byte against `tests/fixtures/scl/*_FB_Main.scl` and `*_FB_Main_templates.scl`, timestamp masked; an intended change is written back with `SKEL_UPDATE_GOLDEN=1`). +## 5c. `e_planning.py` — assign the E-planning to the layout parts + +The bridge between layout and electrical planning (background and findings: +[`E-Planung_Bruecke.md`](E-Planung_Bruecke.md)). The layout knows which +separators, switches and outfeed elements exist and where; the E-planning knows +what their sensors and valves are called. The I/O list alone has no topology — +but the E-planning is drawn as a layer over the hardware layout, every sensor or +actuator symbol sits next to its part, and the cable-length tool +(`kabellaengen/lib/getpositions.py`) writes those symbols with position and +attributes to `_positionsdraw.json`. + +``` +.csv (layout) + _positionsdraw.json (E-planning) + │ + ▼ +e_planning.py ──► _eplan.json (EplanDocument, schema sps_skel-eplan-v1) + ──► _eplan.md (report: assignments, gaps, drives) + │ + ▼ +scl_skeleton.py --eplan _eplan.json +``` + +Steps: + +1. **Read** `sensors` (field devices) and `schaltschrank_elemente` (cabinet) of + the positionsdraw file into `EDevice` (BMK, `VERW`, `BEZEICHNUNG`, + `KENNZEICHNUNG` → controller `UH01` / I/O station `KF107`, position). A BMK + listed twice keeps the entry that has a `VERW`. +2. **Registration** E-planning drawing → CSV with `dxf_registration.py`: the + E symbols matching `[Registrierung] anker` (default `IN SEP`, the sensor that + sits on the separator) against the CSV separators. Stored under the key + `|` in `%SKEL_CFG%\dxf_registration.json` and re-verified + on every run; `--re-register` forces a new derivation. +3. **Rules** from `%SKEL_CFG%\e_planning.ini`, one section `.` per + rule, applied in file order; each E device is given out at most once, each + role of a layout part filled at most once: + + | key | meaning | + |---|---| + | `layout` | layout kind pattern (default: the part before the dot): `Separator`, `Ausschleus`, `Einschleus`, `Weiche`, `Scanner`, `Beladung`, `Entladung` | + | `verw`, `praefix` | pattern(s) for the E attribute `VERW`, BMK prefix (`BG`, `MB`) | + | `max_mm` | spatial: maximum distance after registration | + | `soll_mm`, `toleranz_mm` | spatial: the symbol always sits this far from the part; candidates are ranked by their deviation from it, not by the smallest distance | + | `nummer_von`, `nummer_versatz` | numbering rule: BMK number = number of the given role + offset (Mubea: valve = sensor + 1) — tried first, distance only for the rest | + + Shipped rules (tuned on Mubea): `Separator.sensor`, `Separator.stopper`, + `Ausschleus.stau`, `Ausschleus.ventil`, `Weiche.ventil`. `VERW` texts and the + numbering rule are project-specific — in the reference SCL (UH01..UH05) + sensor and stopper share the number (BG3242/MB3242), i.e. offset `0`. +4. **Drives**: motor `MAnnnn` ↔ motor protection `FCnnnn` (same number), for the + report. + +Uncertain cases stay open on purpose: a gap is better than a plausible wrong +name. The report lists, per role, the layout parts without a match, the field +devices without a match, and distance and runner-up distance of every match +(close values = check by hand). + +Switches: `--file NAME` (CSV, default `export.csv`), `--eplan NAME` +(positionsdraw JSON, required; bare name resolved against `%SKEL_DATA%`, or a +full path), `--rules FILE` (default `%SKEL_CFG%\e_planning.ini`), +`--re-register`. Exit `0` also when not everything was assigned; `1` on input +errors or when the registration cannot be proven. + +**Today only separator sensor and stopper reach the SCL.** Jam sensors and +outfeed/switch valves are assigned in the JSON/report, but the TRO-JSON does not +yet say which outfeed element or switch belongs to which TRO exit, so +`scl_skeleton` cannot place them. The same holds for the storage lines of a +`PinStore` TRO (`arInSep[n]`): their sensors are assigned but the generator does +not emit per-line parameters yet. + +Result on Mubea (`mubea.csv` + `Mubea_positionsdraw.json`): registration +dx +50613.6 / dy +8555.8 mm; separator sensor 21/38, stopper 22/38 (all 20 +storage lines exact, 0–1 mm), jam sensor 20/23, outfeed valve 20/23, switch +valve 1/1. The open separators are on the carousels K0102/K0104/K0105, where +the E-planning drawing and the CSV apparently differ. + +Tests: `tests/test_e_planning.py` (reading, rules, numbering vs. distance on a +storage-line grid, target distance, uniqueness, registration, drives, the path +into `scl_context` and `scl_skeleton --eplan`). + ## 6. `tro_catalog.py` — TRO type catalog (library, no CLI) The single source of truth for everything needed to generate or draw a TRO diff --git a/lib/e_planning.py b/lib/e_planning.py new file mode 100644 index 0000000..c2e78d3 --- /dev/null +++ b/lib/e_planning.py @@ -0,0 +1,715 @@ +# -*- coding: utf-8 -*- +# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" +# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. +""" +e_planning.py - Bruecke Layout <-> E-Planung ueber die Symbolpositionen. + +Das Layout (CSV) sagt, welche Separatoren, Weichen, Aus-/Einschleuselemente +es gibt und wo; die E-Planung, wie ihre Sensoren und Ventile heissen +(BG0642, MB0643 ...). Die EA-Liste allein kennt keine Topologie. Die +E-Planung wird aber als Layer ueber der Hardwareplanung gezeichnet: jedes +Sensor-/Aktorsymbol liegt neben dem Bauteil, zu dem es gehoert. Das +Kabellaengen-Tool (kabellaengen/lib/getpositions.py) schreibt diese Symbole +mit Position und Attributen nach _positionsdraw.json. + +Hier wird daraus die Zuordnung Layout-Bauteil -> BMK: + +1. E-Bauteile einlesen (`sensors` und `schaltschrank_elemente`). +2. Lagebezug E-Zeichnung -> CSV mit lib/dxf_registration.py; Anker sind die + Separator-Sensoren der E-Planung gegen die Separatoren des CSV. Der + gepruefte Bezug wird in SKEL_CFG/dxf_registration.json abgelegt. +3. Regeln aus SKEL_CFG/e_planning.ini anwenden, je Rolle (z. B. + "Separator.sensor"): welche Layout-Art, welche E-Verwendung (VERW), + welcher Hoechstabstand - oder "Nummer = Nummer des Partners + Versatz" + (Ventil MB0643 zum Sensor BG0642). Die Zuordnung ist eindeutig: jedes + E-Bauteil und jede Rolle eines Layout-Bauteils wird hoechstens einmal + vergeben, die kuerzesten Abstaende zuerst. +4. Ergebnis als _eplan.json (EplanDocument) und Bericht + _eplan.md nach SKEL_RESULTS. + +scl_skeleton.py --eplan liest die JSON und setzt die gefundenen Namen in die +SCL ein (SclName mit todo=False). Was keine Regel trifft, bleibt dort +TODO(E-Planung) - geraten wird nicht. + +Schichten: e_planning importiert material_flow (CSV) und dxf_registration +(Lagebezug); scl_context importiert nur das Modell EplanDocument. + +Aufruf ueber bin/e_planning.bat bzw. bin/e_planning.sh. +""" + +from __future__ import annotations + +import argparse +import configparser +import fnmatch +import json +import math +import re +import sys +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field + +from dxf_registration import ( + RegistrationError, + Transform, + derive_transform, + load_transform, + save_transform, + verify_transform, +) + +__all__ = [ + "EPLAN_SCHEMA", + "EDevice", + "LayoutPoint", + "Rule", + "Assignment", + "Drive", + "EplanDocument", + "load_positionsdraw", + "load_rules", + "assign", + "pair_drives", +] + +EPLAN_SCHEMA = "sps_skel-eplan-v1" +RULES_FILE = "e_planning.ini" + +_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+)$") + + +# --------------------------------------------------------------------------- +# Modell +# --------------------------------------------------------------------------- + + +class EDevice(BaseModel): + """Ein Sensor/Aktor/Schrankelement der E-Planung.""" + + model_config = _CONFIG + + bmk: str + verw: str = "" + bezeichnung: str = "" + kennzeichnung: str = "" + # Position in der E-Zeichnung (mm); None bei Schrankelementen ohne Lage + pos: tuple[float, float] | None = None + # True: aus "schaltschrank_elemente" (im Schrank, nicht im Feld) + cabinet: bool = False + + @property + def controller(self) -> str: + """Steuerung/Ort aus der Kennzeichnung ("UH01"), "" wenn unbekannt.""" + m = _KENNZEICHNUNG.search(self.kennzeichnung) + return m.group(1) if m else "" + + @property + def station(self) -> str: + """E/A-Station aus der Kennzeichnung ("KF107"), "" wenn unbekannt.""" + m = _KENNZEICHNUNG.search(self.kennzeichnung) + return m.group(2) if m else "" + + @property + def prefix(self) -> str: + m = _BMK.match(self.bmk) + return m.group(1) if m else "" + + @property + def number(self) -> int | None: + m = _BMK.match(self.bmk) + return int(m.group(2)) if m else None + + +@dataclass +class LayoutPoint: + """Ein punktartiges Bauteil des Layouts in CSV-Koordinaten.""" + + layout_id: str + kind: str + x: float + y: float + name: str = "" + + +@dataclass +class Rule: + """Eine Zuordnungsregel aus e_planning.ini (Abschnitt ".").""" + + role: str # "Separator.sensor" + layout: list[str] # Muster fuer die Layout-Art + verw: list[str] # Muster fuer VERW + max_mm: float | None = None # raeumlich: Hoechstabstand + number_from: str = "" # Nummernregel: Rolle des Partners ... + number_offset: int = 0 # ... und Versatz der Nummer + prefix: str = "" # BMK-Praefix, leer = egal + # Sollabstand: das Symbol liegt immer gleich weit neben dem Bauteil. + # Dann zaehlt die Abweichung vom Soll, nicht der kleinste Abstand. + target_mm: float | None = None + tolerance_mm: float = 150.0 + + def score(self, dist: float) -> float | None: + """Rangfolge eines Kandidaten (kleiner = besser), None = nicht zulaessig.""" + if self.max_mm is not None and dist > self.max_mm: + return None + if self.target_mm is None: + return dist + dev = abs(dist - self.target_mm) + return dev if dev <= self.tolerance_mm else None + + +class Assignment(BaseModel): + """Ein E-Bauteil in einer Rolle an einem Layout-Bauteil.""" + + model_config = _CONFIG + + layout_id: str + layout_kind: str + role: str + bmk: str + verw: str = "" + bezeichnung: str = "" + controller: str = "" + station: str = "" + # Abstand nach dem Lagebezug in mm (auch bei der Nummernregel zur Kontrolle) + distance: float + # "raeumlich" | "nummer" + rule: str + # Abstand des naechstbesten Kandidaten derselben Rolle (None: keiner) + runner_up: float | None = None + + +class Drive(BaseModel): + """Motor im Feld und sein Motorschutz im Schrank (gleiche Nummer).""" + + model_config = _CONFIG + + motor: str + protection: str = "" + bezeichnung: str = "" + protection_bezeichnung: str = "" + + +class EplanDocument(BaseModel): + """Die Zuordnungs-JSON _eplan.json.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + schema_id: str = Field(default=EPLAN_SCHEMA, alias="schema") + created: str = "" + csv_file: str = "" + eplan_file: str = "" + transform: Transform | None = None + assignments: list[Assignment] = Field(default_factory=list) + drives: list[Drive] = Field(default_factory=list) + # E-Bauteile im Feld ohne Zuordnung (BMK) + unassigned: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + def for_layout(self, layout_id: str) -> dict[str, Assignment]: + """Rollen eines Layout-Bauteils: {"sensor": Assignment, ...}.""" + return { + a.role.split(".", 1)[-1]: a + for a in self.assignments if a.layout_id == layout_id + } + + def to_json(self) -> str: + return self.model_dump_json(by_alias=True, indent=2) + "\n" + + @classmethod + def from_json(cls, text: str) -> "EplanDocument": + return cls.model_validate_json(text) + + +# --------------------------------------------------------------------------- +# Einlesen +# --------------------------------------------------------------------------- + + +def _device(key: str, raw: dict, cabinet: bool) -> EDevice: + # Schluessel "BG0642@UNKNOWN" / "QA7000@A/B": vor dem @ steht das BMK. + bmk = str(raw.get("IO") or raw.get("B") or key.split("@", 1)[0]).strip() + pos = raw.get("pos") + return EDevice( + bmk=bmk, + verw=str(raw.get("VERW") or "").strip(), + bezeichnung=str(raw.get("BEZEICHNUNG") or "").strip(), + kennzeichnung=str(raw.get("KENNZEICHNUNG") or "").strip(), + pos=(float(pos[0]), float(pos[1])) if pos and len(pos) >= 2 else None, + cabinet=cabinet, + ) + + +def load_positionsdraw(path: Path, warnings: list[str] | None = None) -> list[EDevice]: + """E-Bauteile aus einer *_positionsdraw.json lesen. + + Ein BMK kommt hoechstens einmal vor. Steht es doppelt da (im Schrank einmal + mit und einmal ohne Attribute), gewinnt der Eintrag mit VERW. + """ + warnings = warnings if warnings is not None else [] + data = json.loads(path.read_text(encoding="utf-8")) + devices: dict[str, EDevice] = {} + for section, cabinet in (("sensors", False), ("schaltschrank_elemente", True)): + for key, raw in (data.get(section) or {}).items(): + if not isinstance(raw, dict): + continue + dev = _device(key, raw, cabinet) + if not dev.bmk: + continue + old = devices.get(dev.bmk) + if old is None or (not old.verw and dev.verw): + devices[dev.bmk] = dev + for bmk, positions in (data.get("double_ids") or {}).items(): + warnings.append(f"E-Planung: {bmk} steht {len(positions)}x in der Zeichnung " + f"- verwendet wird ein Eintrag") + return list(devices.values()) + + +def _patterns(text: str) -> list[str]: + return [p.strip() for p in text.split(",") if p.strip()] + + +def load_rules(path: Path) -> tuple[list[Rule], list[str]]: + """Regeln und Anker-Verwendungen aus e_planning.ini lesen. + + Abschnitt [Registrierung] -> anker (VERW-Muster fuer den Lagebezug); + jeder Abschnitt "." ist eine Regel, in Dateireihenfolge. + """ + parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), + interpolation=None) + parser.optionxform = str # Schluessel nicht kleinschreiben + with path.open(encoding="utf-8") as fh: + parser.read_file(fh) + anchors = _patterns(parser.get("Registrierung", "anker", fallback="IN SEP")) + rules = [] + for section in parser.sections(): + if "." not in section: + continue + s = parser[section] + max_mm = s.get("max_mm") + soll = s.get("soll_mm") + rules.append(Rule( + role=section, + layout=_patterns(s.get("layout", section.split(".", 1)[0])), + verw=_patterns(s.get("verw", "*")), + max_mm=float(max_mm) if max_mm else None, + number_from=s.get("nummer_von", "").strip(), + number_offset=int(s.get("nummer_versatz", "0")), + prefix=s.get("praefix", "").strip(), + target_mm=float(soll) if soll else None, + tolerance_mm=float(s.get("toleranz_mm", "150")), + )) + known = {r.role for r in rules} + for r in rules: + if r.number_from and r.number_from not in known: + raise ValueError(f"{path.name} [{r.role}]: nummer_von = {r.number_from} " + f"ist keine Regel") + return rules, anchors + + +def _match(text: str, patterns: list[str]) -> bool: + return any(fnmatch.fnmatchcase(text.upper(), p.upper()) for p in patterns) + + +# --------------------------------------------------------------------------- +# Zuordnung +# --------------------------------------------------------------------------- + + +@dataclass +class _State: + positions: dict[str, tuple[float, float]] + used: set[str] = field(default_factory=set) + result: list[Assignment] = field(default_factory=list) + # (layout_id, role) -> Assignment + by_slot: dict[tuple[str, str], Assignment] = field(default_factory=dict) + + +def _make(point: LayoutPoint, rule: Rule, dev: EDevice, dist: float, how: str, + runner_up: float | None) -> Assignment: + return Assignment( + layout_id=point.layout_id, layout_kind=point.kind, role=rule.role, + bmk=dev.bmk, verw=dev.verw, bezeichnung=dev.bezeichnung, + controller=dev.controller, station=dev.station, + distance=round(dist, 1), rule=how, + runner_up=None if runner_up is None else round(runner_up, 1), + ) + + +def _candidates(rule: Rule, devices: list[EDevice], state: _State) -> list[EDevice]: + return [ + d for d in devices + if d.bmk not in state.used and d.bmk in state.positions + and _match(d.verw, rule.verw) + and (not rule.prefix or d.prefix.upper() == rule.prefix.upper()) + ] + + +def _assign_spatial(rule: Rule, points: list[LayoutPoint], devices: list[EDevice], + state: _State) -> None: + """Beste Bewertung zuerst (Rule.score); je Bauteil und E-Bauteil hoechstens einmal.""" + cands = _candidates(rule, devices, state) + pairs = [] + for p in points: + if (p.layout_id, rule.role) in state.by_slot: + continue + for d in cands: + dist = math.dist((p.x, p.y), state.positions[d.bmk]) + score = rule.score(dist) + if score is not None: + pairs.append((score, dist, p.layout_id, d.bmk)) + pairs.sort() + # je Bauteil die Kandidaten in Rangfolge - fuer "naechstbester Kandidat" + by_point: dict[str, list[tuple[str, float]]] = {} + for _, dist, pid, bmk in pairs: + by_point.setdefault(pid, []).append((bmk, dist)) + point_of = {p.layout_id: p for p in points} + dev_of = {d.bmk: d for d in cands} + for _, dist, pid, bmk in pairs: + if (pid, rule.role) in state.by_slot or bmk in state.used: + continue + others = [x for b, x in by_point[pid] if b != bmk] + a = _make(point_of[pid], rule, dev_of[bmk], dist, "raeumlich", + others[0] if others else None) + state.used.add(bmk) + state.by_slot[(pid, rule.role)] = a + state.result.append(a) + + +def _assign_by_number(rule: Rule, points: list[LayoutPoint], devices: list[EDevice], + state: _State) -> None: + """Nummer = Nummer des Partners (Rolle nummer_von) + Versatz.""" + cands = {(d.number): d for d in _candidates(rule, devices, state) + if d.number is not None} + for p in points: + if (p.layout_id, rule.role) in state.by_slot: + continue + partner = state.by_slot.get((p.layout_id, rule.number_from)) + if partner is None: + continue + m = _BMK.match(partner.bmk) + if not m: + continue + dev = cands.get(int(m.group(2)) + rule.number_offset) + if dev is None or dev.bmk in state.used: + continue + dist = math.dist((p.x, p.y), state.positions[dev.bmk]) + # Auch die Nummernregel darf nicht quer ueber die Anlage greifen + if rule.max_mm is not None and dist > rule.max_mm: + continue + a = _make(p, rule, dev, dist, "nummer", None) + state.used.add(dev.bmk) + state.by_slot[(p.layout_id, rule.role)] = a + state.result.append(a) + + +def assign(points: list[LayoutPoint], devices: list[EDevice], rules: list[Rule], + transform: Transform) -> list[Assignment]: + """Regeln der Reihe nach anwenden. + + Eine Regel mit nummer_von versucht zuerst die Nummernregel und nimmt fuer + die verbleibenden Bauteile den Abstand (wenn max_mm/soll_mm gesetzt ist). So wird + das Ventil eines Separators ueber die Nummer gefunden, auch wenn das + Ventil des Nachbarn naeher liegt (Speicherlinien im 800-mm-Raster). + """ + state = _State(positions={ + d.bmk: transform.to_csv(*d.pos) for d in devices + if d.pos is not None and not d.cabinet + }) + for rule in rules: + targets = [p for p in points if _match(p.kind, rule.layout)] + if rule.number_from: + _assign_by_number(rule, targets, devices, state) + if rule.max_mm is not None or rule.target_mm is not None: + _assign_spatial(rule, targets, devices, state) + return sorted(state.result, key=lambda a: (a.layout_id, a.role)) + + +def pair_drives(devices: list[EDevice]) -> list[Drive]: + """Motor MAnnnn <-> Motorschutz FCnnnn (gleiche Nummer).""" + fc = {d.number: d for d in devices if d.prefix.upper() == "FC" and d.number is not None} + drives = [] + for d in sorted(devices, key=lambda d: d.bmk): + if d.prefix.upper() != "MA" or d.number is None: + continue + schutz = fc.get(d.number) + drives.append(Drive( + motor=d.bmk, + protection=schutz.bmk if schutz else "", + bezeichnung=d.bezeichnung, + protection_bezeichnung=schutz.bezeichnung if schutz else "", + )) + return drives + + +# --------------------------------------------------------------------------- +# Layout und Lagebezug +# --------------------------------------------------------------------------- + +# Element.kind der punktartigen Layout-Bauteile +LAYOUT_KINDS = ("Separator", "Scanner", "Weiche", "Ausschleus", "Einschleus", + "Beladung", "Entladung") + + +def layout_points(elements) -> list[LayoutPoint]: + """Punktartige Bauteile aus den CSV-Elementen (material_flow.Element).""" + points = [] + for e in elements: + if e.position is None: + continue + kind = next((k for k in LAYOUT_KINDS + if k.lower() in f"{e.kind} {e.teile_art}".lower()), "") + if kind: + points.append(LayoutPoint(e.teile_id, kind, e.x, e.y, e.name)) + 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 +# --------------------------------------------------------------------------- + + +def render_report(doc: EplanDocument, rules: list[Rule], points: list[LayoutPoint], + devices: list[EDevice]) -> str: + out = [] + add = out.append + add(f"# E-Planung -> Layout: {doc.csv_file}") + add("") + add(f"- E-Planung: `{doc.eplan_file}`") + add(f"- Erzeugt: {doc.created}") + if doc.transform: + add(f"- Lagebezug: {doc.transform.describe()}") + add("") + + add("## Zuordnung je Rolle") + add("") + add("| Rolle | Layout-Bauteile | zugeordnet | davon Nummernregel |") + add("|---|---|---|---|") + for r in rules: + n_pts = sum(1 for p in points if _match(p.kind, r.layout)) + hits = [a for a in doc.assignments if a.role == r.role] + n_num = sum(1 for a in hits if a.rule == "nummer") + add(f"| {r.role} | {n_pts} | {len(hits)} | {n_num} |") + add("") + + add("## Zuordnungen") + add("") + add("Abstand nach dem Lagebezug; \"2.\" = Abstand des naechstbesten Kandidaten " + "(knapp beieinander = pruefen).") + add("") + add("| Layout | Art | Rolle | BMK | VERW | Bezeichnung | Abstand mm | 2. | Regel | Station |") + add("|---|---|---|---|---|---|---|---|---|---|") + for a in doc.assignments: + second = "" if a.runner_up is None else f"{a.runner_up:.0f}" + add(f"| {a.layout_id} | {a.layout_kind} | {a.role.split('.', 1)[-1]} | {a.bmk} " + f"| {a.verw} | {a.bezeichnung} | {a.distance:.0f} | {second} | {a.rule} " + f"| {a.controller}-{a.station} |") + add("") + + roles_of: dict[str, set[str]] = {} + for a in doc.assignments: + roles_of.setdefault(a.layout_id, set()).add(a.role) + add("## Layout-Bauteile ohne Zuordnung") + add("") + any_missing = False + for r in rules: + missing = [p for p in points + if _match(p.kind, r.layout) and r.role not in roles_of.get(p.layout_id, set())] + if missing: + any_missing = True + add(f"- **{r.role}**: {', '.join(p.layout_id for p in missing)}") + if not any_missing: + add("- keine") + add("") + + by_bmk = {d.bmk: d for d in devices} + add("## E-Bauteile im Feld ohne Zuordnung") + add("") + if doc.unassigned: + add("| BMK | VERW | Bezeichnung | Station |") + add("|---|---|---|---|") + for bmk in doc.unassigned: + d = by_bmk[bmk] + add(f"| {bmk} | {d.verw} | {d.bezeichnung} | {d.controller}-{d.station} |") + else: + add("- keine") + add("") + + add("## Antriebe (Motor <-> Motorschutz)") + add("") + if doc.drives: + add("| Motor | Bezeichnung | Motorschutz | Bezeichnung |") + add("|---|---|---|---|") + for dr in doc.drives: + add(f"| {dr.motor} | {dr.bezeichnung} | {dr.protection or '-'} " + f"| {dr.protection_bezeichnung} |") + else: + add("- keine Motoren in der E-Planung") + add("") + + if doc.warnings: + add("## Hinweise") + add("") + for w in doc.warnings: + add(f"- {w}") + add("") + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def build_document(csv_path: Path, eplan_path: Path, rules_path: Path, cfg_dir: Path, + re_register: bool = False + ) -> tuple[EplanDocument, list[Rule], list[LayoutPoint], list[EDevice]]: + """Kompletter Lauf ohne Ausgabe: CSV + positionsdraw -> EplanDocument.""" + from material_flow import read_elements # erst hier: Tests ohne CSV brauchen es nicht + + warnings: list[str] = [] + elements = read_elements(csv_path, warnings) + points = layout_points(elements) + devices = load_positionsdraw(eplan_path, warnings) + rules, anchors = load_rules(rules_path) + transform = resolve_transform(points, devices, anchors, cfg_dir, csv_path.name, + eplan_path.name, re_register, warnings) + assignments = assign(points, devices, rules, transform) + used = {a.bmk for a in assignments} + unassigned = sorted(d.bmk for d in devices + if not d.cabinet and d.pos is not None and d.bmk not in used) + doc = EplanDocument( + created=datetime.now().strftime("%Y-%m-%d %H:%M"), + csv_file=csv_path.name, + eplan_file=eplan_path.name, + transform=transform, + assignments=assignments, + drives=pair_drives(devices), + unassigned=unassigned, + warnings=warnings, + ) + return doc, rules, points, devices + + +def main(argv: list[str] | None = None) -> int: + from material_flow import env_dir, resolve_input + + args = parse_args(argv) + csv_path = resolve_input(args.file) + eplan_path = resolve_input(args.eplan) + cfg_dir = env_dir("SKEL_CFG", "cfg") + rules_path = Path(args.rules) if args.rules else cfg_dir / RULES_FILE + for label, path in (("CSV", csv_path), ("E-Planung", eplan_path), + ("Regeln", rules_path)): + if not path.is_file(): + print(f"FEHLER: {label} nicht gefunden: {path}", file=sys.stderr) + return 1 + + try: + doc, rules, points, devices = build_document( + csv_path, eplan_path, rules_path, cfg_dir, args.re_register) + except RegistrationError as exc: + print(f"FEHLER: Lagebezug E-Planung -> CSV nicht nachweisbar: {exc}", + file=sys.stderr) + print(" Passen CSV 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 + + results = env_dir("SKEL_RESULTS", "results") + results.mkdir(parents=True, exist_ok=True) + json_file = results / f"{csv_path.stem}_eplan.json" + md_file = results / f"{csv_path.stem}_eplan.md" + json_file.write_text(doc.to_json(), encoding="utf-8") + md_file.write_text(render_report(doc, rules, points, devices), encoding="utf-8") + + print("") + print("================================================================") + print("E-PLANUNG -> LAYOUT") + print("================================================================") + print(f"CSV = {csv_path}") + print(f"E-Planung = {eplan_path}") + print(f"Lagebezug = {doc.transform.describe() if doc.transform else '-'}") + for r in rules: + n_pts = sum(1 for p in points if _match(p.kind, r.layout)) + hits = sum(1 for a in doc.assignments if a.role == r.role) + print(f" {r.role:<26} {hits:4} von {n_pts}") + print(f"Ohne Zuordnung = {len(doc.unassigned)} E-Bauteile im Feld") + print(f"Antriebe = {len(doc.drives)} " + f"({sum(1 for d in doc.drives if d.protection)} mit Motorschutz)") + 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="e_planning", + description="Ordnet die Sensoren/Ventile der E-Planung " + "(*_positionsdraw.json des Kabellaengen-Tools) den Bauteilen " + "des Layouts (CSV) zu. Ergebnis: _eplan.json fuer " + "scl_skeleton --eplan und ein Bericht _eplan.md.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Beispiele:\n" + " e_planning.bat --file mubea.csv --eplan Mubea_positionsdraw.json\n" + " e_planning.bat --file mubea.csv --eplan C:\\pfad\\Mubea_positionsdraw.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("--file", default="export.csv", metavar="NAME", + help="CSV-Export in %%SKEL_DATA%% (oder ein Pfad). " + "Standard: %(default)s") + parser.add_argument("--eplan", required=True, metavar="NAME", + help="*_positionsdraw.json der E-Planung in %%SKEL_DATA%% " + "(oder ein Pfad)") + parser.add_argument("--rules", metavar="DATEI", + help=f"Regeldatei. Standard: %%SKEL_CFG%%\\{RULES_FILE}") + parser.add_argument("--re-register", action="store_true", + help="Lagebezug neu ermitteln, auch wenn in %%SKEL_CFG%% " + "einer abgelegt ist") + return parser.parse_args(argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/scl_context.py b/lib/scl_context.py index 997e1f4..fb0be7f 100644 --- a/lib/scl_context.py +++ b/lib/scl_context.py @@ -16,8 +16,11 @@ Schichten: Namen (Separator-, Weichen-, MainTro-Nummer, Adressen ...) sind ein :class:`SclName`: Wert plus Merker, ob er noch aus der E-Planung kommen muss. -Heute sind alle Platzhalter mit ``todo=True``; die Namensregeln aus Schritt 4 -(cfg/scl_naming.ini) ersetzen dann nur den Wert und loeschen den Merker. +Ohne E-Planung sind alle Platzhalter ``todo=True``. Mit einer Zuordnung aus +lib/e_planning.py (``eplan``) werden Separator-Sensor und Stopperventil der +zugeordneten Separatoren eingesetzt und ``todo`` geloescht; alles andere +bleibt offen. Die Namensregeln aus Schritt 4 (cfg/scl_naming.ini) arbeiten +spaeter genauso: Wert ersetzen, Merker loeschen. """ from __future__ import annotations @@ -25,12 +28,16 @@ from __future__ import annotations import re from collections import deque from datetime import datetime +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field from model import TroBase, TroDocument from tro_catalog import TroItem +if TYPE_CHECKING: + from e_planning import EplanDocument + __all__ = [ "TODO", "SclName", @@ -65,6 +72,16 @@ def _offen(platzhalter: str) -> SclName: return SclName(value=platzhalter, todo=True) +def _bekannt(wert: str) -> SclName: + return SclName(value=wert, todo=False) + + +# Rolle in e_planning.ini -> wie der Name in der SCL geschrieben wird +# (Sensor als Konstante der DB_Inputs-Sensorliste, Ventil als Variable) +_SENSOR = "cIn{bmk}" +_VENTIL = "{bmk}" + + class SclSeparator(BaseModel): """Ein stInSeparator-Block.""" @@ -183,6 +200,8 @@ class SclFile(BaseModel): created: str start_reason: str tros: list[SclTro] = Field(default_factory=list) + # Zuordnung zur E-Planung, aus der Namen eingesetzt wurden ("" = keine) + eplan_source: str = "" # --------------------------------------------------------------------------- @@ -282,7 +301,21 @@ def _nummer(tro_id: str) -> str: # Kontext bauen # --------------------------------------------------------------------------- -def _tro_context(tro: TroBase, alle: dict[str, TroBase]) -> SclTro: +def _separator(i: int, layout: str, eplan: EplanDocument | None) -> SclSeparator: + rollen = eplan.for_layout(layout) if eplan is not None and layout != "?" else {} + sensor = rollen.get("sensor") + stopper = rollen.get("stopper") + return SclSeparator( + n=i, + layout=layout, + no=_offen("cSep_____"), + sensor=_bekannt(_SENSOR.format(bmk=sensor.bmk)) if sensor else _offen("cInBG____"), + stopper=_bekannt(_VENTIL.format(bmk=stopper.bmk)) if stopper else _offen("MB____"), + ) + + +def _tro_context(tro: TroBase, alle: dict[str, TroBase], + eplan: EplanDocument | None = None) -> SclTro: items = tro.items n_sep = items.get(TroItem.SEPARATOR, 1) n_swi = items.get(TroItem.SWITCH, 0) @@ -291,13 +324,7 @@ def _tro_context(tro: TroBase, alle: dict[str, TroBase]) -> SclTro: jam_db = '"DB_JamArea____".stJam____' separators = [ - SclSeparator( - n=i, - layout=tro.separators[i - 1] if i <= len(tro.separators) else "?", - no=_offen("cSep_____"), - sensor=_offen("cInBG____"), - stopper=_offen("MB____"), - ) + _separator(i, tro.separators[i - 1] if i <= len(tro.separators) else "?", eplan) for i in range(1, n_sep + 1) ] switches = [ @@ -371,13 +398,20 @@ def _tro_context(tro: TroBase, alle: dict[str, TroBase]) -> SclTro: def build_context(document: TroDocument, source: str, start_id: str = "", - created: str | None = None) -> SclFile: - """TroDocument -> SclFile (TROs in Fliessrichtung).""" + created: str | None = None, + eplan: EplanDocument | None = None) -> SclFile: + """TroDocument -> SclFile (TROs in Fliessrichtung). + + eplan: Zuordnung Layout -> E-Planung (e_planning.py); ohne sie bleiben + alle Namen offen und die Ausgabe ist die bisherige. + """ alle = {t.tro_id: t for t in document.tros} geordnet, startgrund = flow_order(document.tros, start_id) return SclFile( source=source, created=created or datetime.now().strftime("%Y-%m-%d %H:%M"), start_reason=startgrund, - tros=[_tro_context(t, alle) for t in geordnet], + tros=[_tro_context(t, alle, eplan) for t in geordnet], + eplan_source=(f"{eplan.eplan_file} (Zuordnung vom {eplan.created})" + if eplan is not None else ""), ) diff --git a/lib/scl_skeleton.py b/lib/scl_skeleton.py index b24cd2e..e77be31 100644 --- a/lib/scl_skeleton.py +++ b/lib/scl_skeleton.py @@ -51,6 +51,7 @@ from jinja2 import ( ) from pydantic import ValidationError +from e_planning import EPLAN_SCHEMA, EplanDocument from material_flow import env_dir from model import SCHEMA, TroDocument from scl_context import TODO, SclFile, SclName, SclTro, build_context @@ -329,6 +330,12 @@ def render_context(context: SclFile, templates: SclTemplates | None = None) -> s add("//") add("// Reihenfolge der REGIONs = Materialfluss.") add(f"// Anfang bei {context.tros[0].id}: {context.start_reason}.") + if context.eplan_source: + add("//") + add(f"// E-Planung: {context.eplan_source}") + add("// Separator-Sensoren und Stopperventile aus der Zuordnung sind") + add("// eingesetzt (ohne Marker); was die Zuordnung nicht sicher fand,") + add(f"// bleibt '{TODO}'. Details: _eplan.md (lib/e_planning.py).") if templates is not None: mit, ohne = template_usage(context, templates) add("//") @@ -372,9 +379,7 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv) results = env_dir("SKEL_RESULTS", "results") - json_file = Path(args.json) - if not json_file.is_absolute() and json_file.parent == Path("."): - json_file = results / args.json + json_file = _in_results(args.json, results) if not json_file.is_file(): print(f"FEHLER: JSON nicht gefunden: {json_file}", file=sys.stderr) print(" Zuerst bin/tro_flow.bat --file .csv --json laufen lassen.", @@ -412,7 +417,28 @@ def main(argv: list[str] | None = None) -> int: return 1 templates = SclTemplates(ordner) - context = build_context(document, json_file.name, args.start) + eplan = None + if args.eplan: + eplan_file = _in_results(args.eplan, results) + try: + eplan = EplanDocument.from_json(eplan_file.read_text(encoding="utf-8")) + except OSError as exc: + print(f"FEHLER: E-Planungs-Zuordnung nicht lesbar: {exc}", file=sys.stderr) + print(" Zuerst bin/e_planning.bat --file .csv --eplan " + "_positionsdraw.json laufen lassen.", file=sys.stderr) + return 1 + except ValidationError as exc: + print(f"FEHLER: {eplan_file} passt nicht zum Schema '{EPLAN_SCHEMA}' " + f"({exc.error_count()} Abweichungen).", file=sys.stderr) + return 1 + # Eine Zuordnung zu einem anderen Layout setzt falsche Namen ein - + # Separatornummern sind nur innerhalb eines CSV-Exports eindeutig. + if Path(eplan.csv_file).name != Path(document.source).name: + print(f"FEHLER: {eplan_file.name} gehoert zu '{eplan.csv_file}', die " + f"TRO-JSON zu '{document.source}'.", file=sys.stderr) + return 1 + + context = build_context(document, json_file.name, args.start, eplan=eplan) mit, ohne = template_usage(context, templates) if args.strict and ohne: print(f"FEHLER: kein Template fuer {', '.join(ohne)} (--strict) - " @@ -440,6 +466,9 @@ def main(argv: list[str] | None = None) -> int: if templates is not None: print(f"Templates = {len(mit)} von {len(mit) + len(ohne)} Typen " f"(generisch: {', '.join(ohne) or '-'})") + if eplan is not None: + print(f"E-Planung = {eplan_file}") + print(f"Eingesetzt = {_eingesetzt(context)} Namen aus der E-Planung") print(f"Offene Stellen = {offen} (Marker '{TODO}')") print(f"SCL = {out_file}") print("================================================================") @@ -447,6 +476,21 @@ def main(argv: list[str] | None = None) -> int: return 0 +def _in_results(name: str, results: Path) -> Path: + """Blosser Dateiname -> in SKEL_RESULTS; Pfadangaben bleiben unveraendert.""" + path = Path(name) + if not path.is_absolute() and path.parent == Path("."): + return results / name + return path + + +def _eingesetzt(context: SclFile) -> int: + """Wie viele Separator-Namen aus der E-Planung kamen.""" + return sum(not name.todo + for tro in context.tros for s in tro.separators + for name in (s.sensor, s.stopper)) + + def _schema_of(path: Path) -> str: """Schema-Kennung einer JSON-Datei, nur fuer die Fehlermeldung ("" wenn unlesbar).""" try: @@ -465,6 +509,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: " scl_skeleton.bat\n" " scl_skeleton.bat --json export_tro.json --out FB_Main.scl\n" " scl_skeleton.bat --json mubea_tro.json --templates\n" + " scl_skeleton.bat --json mubea_tro.json --eplan mubea_eplan.json\n" "\n" "Exit-Codes:\n" " 0 ok (auch wenn TROs ohne Template generisch erzeugt wurden)\n" @@ -500,6 +545,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "Ohne DIR: lib/scl_templates. FB-Typen ohne Template bleiben " "generisch. Ohne diesen Schalter: bisherige Ausgabe.", ) + parser.add_argument( + "--eplan", + metavar="NAME", + help="Zuordnung zur E-Planung aus e_planning.py (_eplan.json in " + "%%SKEL_RESULTS%% oder ein Pfad): Separator-Sensoren und " + "Stopperventile werden eingesetzt statt als TODO markiert.", + ) parser.add_argument( "--strict", action="store_true", diff --git a/tests/test_e_planning.py b/tests/test_e_planning.py new file mode 100644 index 0000000..604615a --- /dev/null +++ b/tests/test_e_planning.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +"""Tests fuer lib/e_planning.py - Zuordnung Layout <-> E-Planung. + +Die Geometrie ist synthetisch und nachgebaut nach den Befunden an Mubea +(doc/E-Planung_Bruecke.md): Speicherlinien im 800-mm-Raster, Sensor auf dem +Separator, Ventil ~270 mm daneben, Stausensor konstant ~1390 mm vom +Ausschleuselement. Der Lagebezug ist hier die Identitaet. +""" + +import json +import shutil +from pathlib import Path + +import pytest + +import scl_skeleton +from dxf_registration import Transform +from e_planning import ( + EDevice, + EplanDocument, + LayoutPoint, + Rule, + assign, + load_positionsdraw, + load_rules, + pair_drives, +) +from scl_context import build_context +from scl_skeleton import load_document + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "scl" +RULES = Path(__file__).resolve().parent.parent / "cfg" / "e_planning.ini" +IDENT = Transform(dx=0.0, dy=0.0) + + +def _dev(bmk: str, verw: str, x: float, y: float, **extra) -> EDevice: + return EDevice(bmk=bmk, verw=verw, pos=(x, y), + kennzeichnung="=A1+UH01-KF107", **extra) + + +def _rolle(assignments, layout_id: str, role: str): + return next((a for a in assignments + if a.layout_id == layout_id and a.role == role), None) + + +# --------------------------------------------------------------------------- +# Einlesen +# --------------------------------------------------------------------------- + + +def test_positionsdraw_einlesen(tmp_path): + datei = tmp_path / "x_positionsdraw.json" + datei.write_text(json.dumps({ + "sensors": { + "BG0642@UNKNOWN": {"B": "BG0642", "VERW": "IN SEP", + "BEZEICHNUNG": "TEIL IN SEPARATOR STR. 20", + "KENNZEICHNUNG": "=A1+UH01-KF107", "pos": [1.0, 2.0]}, + }, + "schaltschrank_elemente": { + # Derselbe Motorschutz zweimal: einmal mit, einmal ohne Attribute + "FC0200@": {"IO": "FC0200", "VERW": "MOTORSCHUTZ", + "BEZEICHNUNG": "MOTORSCHUTZ CONVEYER #100", "pos": [5, 5]}, + "FC0200": {"B": "FC0200", "pos": [6, 6]}, + }, + "double_ids": {"BG0516": [[0, 0], [1, 1]]}, + }), encoding="utf-8") + warnungen: list[str] = [] + geraete = {d.bmk: d for d in load_positionsdraw(datei, warnungen)} + + assert set(geraete) == {"BG0642", "FC0200"} + bg = geraete["BG0642"] + assert (bg.verw, bg.pos, bg.cabinet) == ("IN SEP", (1.0, 2.0), False) + assert (bg.controller, bg.station, bg.prefix, bg.number) == ("UH01", "KF107", "BG", 642) + assert geraete["FC0200"].verw == "MOTORSCHUTZ" + assert geraete["FC0200"].cabinet + assert any("BG0516" in w for w in warnungen) + + +def test_regeln_aus_der_projektkonfiguration(): + regeln, anker = load_rules(RULES) + rollen = [r.role for r in regeln] + assert anker == ["IN SEP"] + # Reihenfolge = Dateireihenfolge; der Sensor muss vor dem Ventil kommen + assert rollen.index("Separator.sensor") < rollen.index("Separator.stopper") + stopper = next(r for r in regeln if r.role == "Separator.stopper") + assert (stopper.layout, stopper.number_from, stopper.number_offset) == ( + ["Separator"], "Separator.sensor", 1) + + +def test_nummer_von_muss_eine_regel_sein(tmp_path): + ini = tmp_path / "r.ini" + ini.write_text("[Separator.stopper]\nverw = SEP\nnummer_von = Separator.gibtsnicht\n", + encoding="utf-8") + with pytest.raises(ValueError, match="gibtsnicht"): + load_rules(ini) + + +# --------------------------------------------------------------------------- +# Zuordnung +# --------------------------------------------------------------------------- + +SENSOR = Rule(role="Separator.sensor", layout=["Separator"], verw=["IN SEP"], + prefix="BG", max_mm=300) +STOPPER = Rule(role="Separator.stopper", layout=["Separator"], verw=["SEP"], + prefix="MB", number_from="Separator.sensor", number_offset=1, + max_mm=600, target_mm=270) + + +def _speicher(): + """Drei Speicherlinien im 800-mm-Raster. + + Das Ventil jeder Linie liegt 530 mm von ihrem Separator - und nur 270 mm + vom Separator der Nachbarlinie. Raeumlich waere das falsch; die + Nummernregel (MB = BG + 1) muss gewinnen. + """ + punkte = [LayoutPoint(f"00{i}", "Separator", 0.0, 800.0 * i) for i in (1, 2, 3)] + geraete = [] + for i, bg in ((1, 642), (2, 644), (3, 646)): + geraete.append(_dev(f"BG0{bg}", "IN SEP", 0.0, 800.0 * i)) + geraete.append(_dev(f"MB0{bg + 1}", "SEP", 0.0, 800.0 * i + 530.0)) + return punkte, geraete + + +def test_ventil_ueber_die_nummer_nicht_ueber_den_abstand(): + punkte, geraete = _speicher() + ergebnis = assign(punkte, geraete, [SENSOR, STOPPER], IDENT) + + for sep, bg, mb in (("001", "BG0642", "MB0643"), ("002", "BG0644", "MB0645"), + ("003", "BG0646", "MB0647")): + assert _rolle(ergebnis, sep, "Separator.sensor").bmk == bg + stopper = _rolle(ergebnis, sep, "Separator.stopper") + assert (stopper.bmk, stopper.rule) == (mb, "nummer") + + +def test_jedes_e_bauteil_nur_einmal(): + """Zwei Separatoren, ein Sensor dazwischen: nur der naehere bekommt ihn.""" + punkte = [LayoutPoint("A", "Separator", 0, 0), LayoutPoint("B", "Separator", 0, 250)] + geraete = [_dev("BG0100", "IN SEP", 0, 100)] + ergebnis = assign(punkte, geraete, [SENSOR], IDENT) + assert [(a.layout_id, a.bmk) for a in ergebnis] == [("A", "BG0100")] + assert ergebnis[0].runner_up is None + + +def test_zu_weit_weg_bleibt_offen(): + punkte = [LayoutPoint("A", "Separator", 0, 0)] + geraete = [_dev("BG0100", "IN SEP", 0, 400)] + assert assign(punkte, geraete, [SENSOR], IDENT) == [] + + +def test_sollabstand_schlaegt_kleinsten_abstand(): + """Mubea 0080: ein fremder Stausensor liegt 269 mm nah, der eigene 1391 mm.""" + stau = Rule(role="Ausschleus.stau", layout=["Ausschleus"], verw=["STAU*"], + prefix="BG", max_mm=1600, target_mm=1390) + punkte = [LayoutPoint("0080", "Ausschleus", 0, 0)] + geraete = [_dev("BG0525", "STAU_REF-LT", 269, 0), + _dev("BG0550", "STAU_REF-LT", -1391, 0)] + ergebnis = assign(punkte, geraete, [stau], IDENT) + assert [a.bmk for a in ergebnis] == ["BG0550"] + + +def test_lagebezug_wird_angewendet(): + """E-Koordinaten werden mit dem Lagebezug ins CSV gebracht.""" + punkte = [LayoutPoint("A", "Separator", 1000, 2000)] + geraete = [_dev("BG0100", "IN SEP", 0, 0)] + ergebnis = assign(punkte, geraete, [SENSOR], Transform(dx=1000, dy=2000)) + assert ergebnis[0].bmk == "BG0100" and ergebnis[0].distance == 0 + + +def test_schrankelemente_werden_nicht_raeumlich_zugeordnet(): + punkte = [LayoutPoint("A", "Separator", 0, 0)] + geraete = [_dev("BG0100", "IN SEP", 0, 0, cabinet=True)] + assert assign(punkte, geraete, [SENSOR], IDENT) == [] + + +def test_motor_und_motorschutz_ueber_die_nummer(): + geraete = [_dev("MA0200", "MOT_0,37kW", 0, 0, bezeichnung="PIN CONVEYOR#100"), + EDevice(bmk="FC0200", verw="MOTORSCHUTZ", cabinet=True), + _dev("MA0300", "MOT", 0, 0)] + antriebe = {d.motor: d.protection for d in pair_drives(geraete)} + assert antriebe == {"MA0200": "FC0200", "MA0300": ""} + + +# --------------------------------------------------------------------------- +# Bis in die SCL +# --------------------------------------------------------------------------- + + +def _eplan_fuer_mubea(csv_file: str = "mubea.csv") -> EplanDocument: + """Zuordnung fuer Separator 0012 (TRO02 der Fixture) - nur Sensor und Ventil.""" + punkte = [LayoutPoint("0012", "Separator", 0, 0)] + geraete = [_dev("BG0712", "IN SEP", 0, 0), _dev("MB0713", "SEP", 0, 300)] + return EplanDocument(csv_file=csv_file, eplan_file="Mubea_positionsdraw.json", + created="2026-01-01 00:00", + assignments=assign(punkte, geraete, [SENSOR, STOPPER], IDENT)) + + +def test_kontext_setzt_namen_ein_und_loescht_den_merker(): + document = load_document(FIXTURES / "mubea_tro.json") + kontext = build_context(document, "mubea_tro.json", eplan=_eplan_fuer_mubea()) + tro02 = next(t for t in kontext.tros if t.id == "TRO02") + sep = tro02.separators[0] + assert (sep.layout, sep.sensor.value, sep.sensor.todo) == ("0012", "cInBG0712", False) + assert (sep.stopper.value, sep.stopper.todo) == ("MB0713", False) + # Was die E-Planung nicht liefert, bleibt offen + assert sep.no.todo + tro01 = next(t for t in kontext.tros if t.id == "TRO01") + assert tro01.separators[0].sensor.todo + assert kontext.eplan_source.startswith("Mubea_positionsdraw.json") + + +def _lauf(tmp_path, monkeypatch, eplan: EplanDocument) -> tuple[int, Path]: + shutil.copy(FIXTURES / "mubea_tro.json", tmp_path / "mubea_tro.json") + (tmp_path / "mubea_eplan.json").write_text(eplan.to_json(), encoding="utf-8") + monkeypatch.setenv("SKEL_RESULTS", str(tmp_path)) + rc = scl_skeleton.main(["--json", "mubea_tro.json", "--eplan", "mubea_eplan.json"]) + return rc, tmp_path / "mubea_FB_Main.scl" + + +def test_scl_skeleton_mit_eplan(tmp_path, monkeypatch): + rc, scl = _lauf(tmp_path, monkeypatch, _eplan_fuer_mubea()) + text = scl.read_text(encoding="utf-8") + assert rc == 0 + assert '#TRO02.stInSeparator1.stSenInSep := "DB_Inputs".Sensors["cInBG0712"];\n' in text + assert 'xOutStopper1 => "MB0713",\n' in text + assert "// E-Planung: Mubea_positionsdraw.json" in text + + +def test_scl_skeleton_lehnt_fremde_zuordnung_ab(tmp_path, monkeypatch): + rc, scl = _lauf(tmp_path, monkeypatch, _eplan_fuer_mubea("anderes.csv")) + assert rc == 1 + assert not scl.exists() + + +def test_eplan_json_rundlauf(): + doc = _eplan_fuer_mubea() + zurueck = EplanDocument.from_json(doc.to_json()) + assert zurueck == doc + assert json.loads(doc.to_json())["schema"] == "sps_skel-eplan-v1" + assert set(zurueck.for_layout("0012")) == {"sensor", "stopper"}