E-Planung -> Layout: e_planning.py, scl_skeleton --eplan, Kapitel Daten

- doc/Klassenhierarchie.md: Kapitel "Daten" - alle Daten der SCL-Referenzen
  nach Quelle (Layout, E-Planung, Namenskonvention, Parameter)
- doc/E-Planung_Bruecke.md: Bruecke ueber *_positionsdraw.json des
  Kabellaengen-Tools, Befunde an Mubea
- lib/e_planning.py + cfg/e_planning.ini + bin/e_planning.bat/.sh:
  Lagebezug E-Zeichnung -> CSV, Zuordnung BMK -> Layout-Bauteil nach Regeln
  (Abstand, Sollabstand, Nummernregel MB = BG + 1), <csv>_eplan.json + Bericht
- scl_skeleton --eplan: Separator-Sensor und Stopperventil eingesetzt,
  ohne Schalter unveraenderte Ausgabe (Golden-Files gleich)
- tests/test_e_planning.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-10-11 09:30:56 +02:00
co-authored by Claude Opus 5.5
parent 5764df1ac8
commit c736288476
11 changed files with 1683 additions and 26 deletions
+13 -3
View File
@@ -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` → `<csv>_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:
+33
View File
@@ -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 <csv>_eplan.json und Bericht <csv>_eplan.md in %SKEL_RESULTS%.
REM
REM Weiter: bin\scl_skeleton.bat --json <name>_tro.json --eplan <name>_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%
+34
View File
@@ -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 <csv>_eplan.json und Bericht <csv>_eplan.md in $SKEL_RESULTS.
#
# Weiter: bin/scl_skeleton.sh --json <name>_tro.json --eplan <name>_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" "$@"
+81
View File
@@ -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 "<Art>.<Rolle>" 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 <csv>_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<n>.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<n>
; 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
+161
View File
@@ -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 `<zeichnung>_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
`<csv>|<positionsdraw.json>` 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<n>.stSenInSep` | BG mit `VERW` `IN SEP` | räumlich am Separator (Anker) |
| `xOutStopper<n>` | MB mit `VERW` `SEP` | Nummernregel ab dem BG |
| `stInSenJam<n>`, `Settings.xPluggedJam` | BG `STAU_REF-LT` | räumlich, Versatz je Symbolart; vorhanden ⇒ `xPluggedJam := TRUE` |
| `xOutSw<n>ExTo3/4` | MB `AE ABZWEIG` / Weichen-MB | Nummernregel ab dem Stausensor bzw. räumlich an Weiche/Ausschleuselement |
| `Settings.xPluggedPart`, `stSenPart` | BG `BT BELADEN` | räumlich; vorhanden ⇒ `TRUE` |
| `xInMotorProtection`, `xOutVarioMotor` | MA im Feld + FC in `schaltschrank_elemente` | gleiche Nummer |
| `xInCarouselRun` (welcher Antrieb) | FC/MA-`BEZEICHNUNG` "KREISEL-K0102", "CONVEYOR #100" | Textabgleich mit dem Host-Kreisel des TRO |
| 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
`<csv>|<positionsdraw.json>`.
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** `<csv>_eplan.json` (`EplanDocument`) und Bericht `<csv>_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.
+198
View File
@@ -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 <fbType>.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) (= <csv>_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<n>`, `cSep<n>.<i>`, `"DB_Interface_HMI".stTRO.TRO<n>` …). 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<n>`, `xOutStopper<n>`)
| 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<n>` / `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<n>`, `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 `xOutSw<n>ExTo3/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<n>`, `stInPriorityManagerDir<n>` | `"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.
+106 -6
View File
@@ -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 ──► <name>_tro_flow.dot / .svg, _tro_doc.md, _tro_error
│ scl_skeleton.py ──► <name>_FB_Main.scl
│ (validates the JSON against model.py; one REGION per TRO;
│ E-planning values left as TODO(E-Planung) gaps)
│ ▲
│ │ --eplan <name>_eplan.json (optional)
│ e_planning.py ◄── <drawing>_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 ──► <name>_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/<name>.bat` (Windows) / `bin/<name>.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
`<source>_FB_Main.scl`), `--start TRO`. The last one matters while the plant has
`<source>_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
`<csv>_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<n> => "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 `<drawing>_positionsdraw.json`.
```
<name>.csv (layout) + <drawing>_positionsdraw.json (E-planning)
│
▼
e_planning.py ──► <name>_eplan.json (EplanDocument, schema sps_skel-eplan-v1)
──► <name>_eplan.md (report: assignments, gaps, drives)
│
▼
scl_skeleton.py --eplan <name>_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
`<csv>|<positionsdraw>` 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 `<kind>.<role>` 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
+715
View File
@@ -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 <zeichnung>_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 <csv>_eplan.json (EplanDocument) und Bericht
<csv>_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 "<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 = 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 <csv>_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 "<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", "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: <csv>_eplan.json fuer "
"scl_skeleton --eplan und ein Bericht <csv>_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())
+47 -13
View File
@@ -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<n>-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 ""),
)
+56 -4
View File
@@ -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: <csv>_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 <name>.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 <name>.csv --eplan "
"<zeichnung>_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 (<csv>_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",
+239
View File
@@ -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"}