diff --git a/CLAUDE.md b/CLAUDE.md index 33220f4..4e82ee4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,8 @@ Template" below). What exists today is: - `cfg/dxf_registration.json` — persisted, verified CSV↔DXF coordinate transform; `cfg/tro_overrides.ini` — hand-maintained corrections to the derived TROs (see `tro_overrides.py`); `cfg/e_planning.ini` — project-specific rules for assigning E-planning symbols to layout parts - (read by `merge_hw_layout.py`, also for `e_planning.py`); + (read by `merge_hw_layout.py`, also for `e_planning.py`); `cfg/e_planning_dematic.ini` — the + same for Dematic (`merge_hw_layout --rules e_planning_dematic.ini`); `cfg/hw_overrides.ini` — hand corrections of `merge_hw_layout.py` (bindings per TRO slot role, conveyors); `cfg/parameter.ini` — tolerances, thresholds, scales and drawing sizes of the `lib/` modules (read by `lib/parameter.py`; no numeric settings in the code, see `doc/Python_Scripts.md` §7a); @@ -221,7 +222,11 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha `*_positionsconv.json` → `_hw.json` (`HardwareDocument`): per BMK address, index constant `cIn…`/`cOut…`, VERW, station, position, conveyor (`K0102`; `#106` → `K0106`); drives MA↔FC (+ encoder GF); cross-check of both sources. EA list and WSCAD - are not needed. See `doc/Python_Scripts.md` §5d. + are not needed. Instead of the positions file `--dxf` reads a **merged drawing** (layout + + E planning in one DXF, Dematic): every block with an `IO` attribute is a device, already + in layout coordinates (`layoutCoordinates`, no registration in the merge); + `--conveyor-from-motor` takes the conveyor from the motor number (`MA0055` → `K0055`). + See `doc/Python_Scripts.md` §5d. 7. **`lib/merge_hw_layout.py`** (implemented, stage 3) — `_tro.json` + `_hw.json` → `_anlage.json` (`model.AnlageDocument`: layout unchanged + `bindings[]` keyed by TRO-ID + slot + role, `conveyors[]`). Reads no CSV: the TRO-JSON carries `parts` diff --git a/cfg/dematic_merge_tro_ids.ini b/cfg/dematic_merge_tro_ids.ini new file mode 100644 index 0000000..0b1c750 --- /dev/null +++ b/cfg/dematic_merge_tro_ids.ini @@ -0,0 +1,37 @@ +; ============================================================ +; TRO-IDs zu dematic_merge.csv - erzeugt von lib/tro_flow.py am 2026-10-21 +; +; Je TRO: ID = x, y (mm) seines Anker-Separators im CSV-Koordinatensystem. +; Die IDs duerfen umbenannt werden (eindeutig, gueltiger SCL-Bezeichner); +; tro_flow findet jeden TRO ueber die Lage wieder. Neue TROs werden +; unten angehaengt, weggefallene bleiben stehen und werden gemeldet. +; Alles neu nummerieren: tro_flow --renumber (ueberschreibt diese Datei). +; Siehe doc/Konzept_Layout_Hardware_SCL.md, Abschnitt TRO-IDs. +; ============================================================ + +[tro] +TRO01 = 34716.5, 3406.7 ; Vario | VF_4 | Sep 0074 +TRO02 = 20161.7, 3406.7 ; 1Sep | VF_4 | Sep 0075 +TRO03 = 42164.0, 4329.5 ; 1Sep2Swi | Separator 0048 an Ausschleuselementen 0073, 0081 (Strecken 0008, 0010) | Sep 0048 +TRO04 = 40437.8, 2448.3 ; Vario | VF_6 | Sep 0082 +TRO05 = 33048.6, 2448.3 ; 1Sep | VF_6 | Sep 0083 +TRO06 = 26860.8, 2448.3 ; Vario | VF_6 | Sep 0084 +TRO07 = 20134.0, 2448.3 ; 1Sep | VF_6 | Sep 0085 +TRO08 = 20161.9, 6635.8 ; Vario | VF_5 | Sep 0078 +TRO09 = 20161.9, 11560.3 ; 1Sep | VF_5 | Sep 0079 +TRO10 = 19604.0, 4982.3 ; 1Sep1Swi | Separator 0043 an Ausschleuselement 0077 (Strecke 0009, Anfang) | Sep 0043 +TRO11 = 3098.7, 12119.7 ; 1Sep | Separator 0038 auf Kreisel2 Bahn L | Sep 0038 +TRO12 = 34990.4, 12919.7 ; 1Sep | Separator 0046 auf Kreisel2 Bahn R | Sep 0046 +TRO13 = 5034.1, 12919.7 ; PinStore_Auto | Speicher (10 Schwerkraftlinien) | Sep 0040, 0088, 0091, 0094, 0097, 0100, 0103, 0106, 0109, 0112, 0115 +TRO14 = 35122.8, 25278.8 ; 1Sep | Separator 0047 auf Kreisel3 Bahn R | Sep 0047 +TRO15 = 2703.7, 21969.7 ; Vario | VF_1 | Sep 0062 +TRO16 = 2703.7, 13476.7 ; 1Sep | VF_1 | Sep 0063 +TRO17 = 3542.3, 24478.8 ; 1Sep1Swi | Separator 0039 an Ausschleuselement 0061 (Strecke 0005, Anfang) | Sep 0039 +TRO18 = 14674.7, 21969.7 ; Vario | VF_2 | Sep 0066 +TRO19 = 14674.7, 13476.7 ; 1Sep | VF_2 | Sep 0067 +TRO20 = 15472.4, 24478.8 ; 1Sep1Swi | Separator 0041 an Ausschleuselement 0065 (Strecke 0006, Anfang) | Sep 0041 +TRO21 = 23232.7, 21969.7 ; Vario | VF_3 | Sep 0070 +TRO22 = 23232.7, 13476.7 ; 1Sep | VF_3 | Sep 0071 +TRO23 = 24062.0, 24478.8 ; 1Sep1Swi | Separator 0044 an Ausschleuselement 0069 (Strecke 0007, Anfang) | Sep 0044 +TRO24 = 15934.1, 12919.8 ; PinStore_Auto | Speicher (7 Schwerkraftlinien) | Sep 0042, 0118, 0121, 0124, 0127, 0130, 0133, 0136 +TRO25 = 24734.2, 12919.8 ; PinStore_Auto | Speicher (10 Schwerkraftlinien) | Sep 0045, 0139, 0142, 0145, 0148, 0151, 0154, 0157, 0160, 0163, 0166 diff --git a/cfg/e_planning_dematic.ini b/cfg/e_planning_dematic.ini new file mode 100644 index 0000000..a4f16e2 --- /dev/null +++ b/cfg/e_planning_dematic.ini @@ -0,0 +1,56 @@ +; ================================================================ +; e_planning_dematic.ini - Zuordnungsregeln fuer Dematic +; ================================================================ +; Wie e_planning.ini (Aufbau und Schluessel siehe dort), aber fuer die +; gemeinsame Zeichnung Layout + E-Planung (hw_import --dxf). Aufruf: +; merge_hw_layout.bat --tro dematic_merge_tro.json --hw dematic_hw.json +; --rules e_planning_dematic.ini +; +; Die E-Symbole liegen schon in Layout-Koordinaten, aber neben dem Bauteil: +; die Abstaende unten sind Zeichenabstaende, gemessen 2026-10-21 an +; dematic_merge..dxf + dematic_merge_export.csv (Median je Rolle in Klammern). +; Unterschiede zu Mubea: +; - Sensor und Stopper haben dieselbe Nummer (BG3151 / MB3151) +; - Stausensor und Weichenventil einer Speicherlinie haben dieselbe Nummer +; (Stau 1 = BG3031 / Weiche Abzw. 1 = MB3031) +; - VERW-Texte "In Sep n", "Sep n", "Stau n", "Weiche Abzw. n" +; ================================================================ + +[Registrierung] +; wird bei der gemeinsamen Zeichnung nicht gebraucht (kein Lagebezug) +anker = IN SEP* + +; Sensor im Stopper -> stInSeparator.stSenInSep +; (Speicherlinien 568/609 mm, Kreisel/VF 220-1340 mm; VF_1 0062 1731 mm.) +; Zugeordnet wird eins zu eins, kleinster Abstand zuerst. +[Separator.sensor] +verw = IN SEP* +praefix = BG +max_mm = 1800 + +; Stopperventil -> xOutStopper: gleiche Nummer wie der Sensor; das +; Symbol liegt an derselben Stelle +[Separator.stopper] +verw = SEP* +praefix = MB +nummer_von = Separator.sensor +nummer_versatz = 0 +max_mm = 1800 + +; Stausensor am Anfang einer Speicherlinie (537-554 mm vom Ausschleuselement); +; haengt an keinem TRO-Slot, dient nur als Partner fuer die Nummernregel +[Ausschleus.stau] +verw = STAU* +praefix = BG +max_mm = 1000 +soll_mm = 540 + +; Weichenventil am Ausschleuselement -> Slot switch.valve (xOutSwitch). +; Speicherlinie: Nummer = Stausensor; Kreisel-Weichen ohne Stausensor: +; raeumlich (408-865 mm, Kreisel4 0081 1015 mm) +[Ausschleus.valve] +verw = WEICHE* +praefix = MB +nummer_von = Ausschleus.stau +nummer_versatz = 0 +max_mm = 1400 diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index c253664..3b07c58 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -746,9 +746,19 @@ positions file exists. ``` *_TIA.xlsx (PLC Tags + Constants) ─┐ ├─► hw_import.py ──► _hw.json (HardwareDocument, sps_skel-hw-v1) -*_positionsconv.json (optional) ────┘ ──► _hw.md (report: devices, drives, checks) +*_positionsconv.json (optional) ────┤ ──► _hw.md (report: devices, drives, checks) + or merged drawing *.dxf (--dxf) ───┘ ``` +- **Merged drawing** (`--dxf`, instead of `--positions`; Dematic): layout and + E planning in one DXF. Every model-space block with a BMK in the attribute + `IO` is an E device (`IO` → BMK, `VERW`, `BEZEICHNUNG`, `KENNZEICHNUNG`, + insert point → position). Blocks without `IO` (layout parts, bus modules, + the green motor labels `ILS_Motorkennzeichnung*`) are ignored — the motor + symbol is authoritative. The positions are already layout coordinates; + `layoutCoordinates: true` tells `merge_hw_layout` to skip the registration. + A BMK drawn twice is a warning (the entry with `VERW` wins). + - **TIA import**: sheets are recognised by their header row, not their name (English and German TIA exports): a sheet with `Logical Address` / `Logische Adresse` holds tags, one with `Value` / `Wert` constants. Read with @@ -762,6 +772,10 @@ positions file exists. `K0104` (kind `Kreisel`); `CONVEYOR-#103` and `CONVEYOR-K0103` are the same conveyor and both become `K0103` (kind `Strecke`) — the four-digit conveyor number of the SCL naming scheme (`DB_JamArea0103`, `fbCarousel0102`). + With `--conveyor-from-motor` (Dematic) a text without `K…`/`#…` that names a + motor gives the conveyor by motor number: `Motor KR-MA0055`, `Rad dreht + ILS-KR (MA0046)` → `K0055`, `K0046`; `ILS-VARIO` counts as kind `Strecke`. + Not for Mubea, where motor `MA0203` drives conveyor `K0103`. - **Drives**: motor `MA` ↔ motor protection `FC` (same number), conveyor and kind from their texts, encoder `GF…` of the same conveyor. - **Cross-check** (`checks`, also printed as `?` lines): BMK only in the @@ -772,7 +786,8 @@ positions file exists. through. Switches: `--tia NAME` (required), `--positions NAME` (optional; without it no -positions), `--project NAME` (default from the positions file name: +positions), `--dxf NAME` (merged drawing instead of `--positions`), +`--conveyor-from-motor`, `--project NAME` (default from the positions file name: `Mubea_positionsconv.json` → `Mubea`). Names resolve against `%SKEL_DATA%`, also with a sub-folder. Exit `0` (findings are in the report), `1` on input or read errors. @@ -782,10 +797,17 @@ Result on Mubea (`mubea-1_TIA.xlsx` + `Mubea_positionsconv.json`): 238 devices, 11 drives all with motor protection and conveyor (encoder `GF20000` at K0102); checks: `QA7000` only in the positions file, `BG0514` without controller. +Result on Dematic (`dematic_hw-2_TIA.xlsx` + `dematic_merge..dxf`, +`--conveyor-from-motor`): 1022 devices, 278 from the drawing, 267 in both +sources; 44 drives, all with motor protection and conveyor. Only in the drawing: +the network devices `BX2100–2108` (scanners), `GF2200` (encoder), `BC2050` +(RFID). 378 field devices only in TIA (areas not drawn). + Tests: `tests/test_hw_import.py` (xlsx reader with shared/inline strings, -header-based sheet detection, conveyor texts, merge per BMK, drives, every -cross-check, TIA-only run, JSON round trip, CLI with a sub-folder in -`%SKEL_DATA%`). The test builds its own small TIA xlsx. +header-based sheet detection, conveyor texts incl. motor numbers, merge per +BMK, drives, every cross-check, TIA-only run, JSON round trip, CLI with a +sub-folder in `%SKEL_DATA%`, merged drawing). The test builds its own small TIA +xlsx and DXF. ## 5e. `merge_hw_layout.py` — merge layout and hardware planning into the plant JSON @@ -809,8 +831,14 @@ Steps: layout separators), stored with the SHA-256 of the positions file. Warning when fewer than `registration_min_share` of the separators find a partner — a sign that layout and E drawing are different planning states. + A merged drawing (`layoutCoordinates`, `hw_import --dxf`) needs none: + identity, nothing stored. The symbols' offset beside their part (Dematic + ~600 mm) is drawing distance, covered by the rules' `max_mm`/`soll_mm`. 2. **Rules** from `e_planning.ini`, unchanged logic (spatial with maximum / - target distance, numbering rule `MB = BG + 1`). + target distance, numbering rule `MB = BG + 1`). Per project another file + via `--rules` — Dematic: `cfg/e_planning_dematic.ini` (`In Sep n` / `Sep n` + with the same number, `Weiche Abzw. n` = number of `Stau n`, distances + measured on the merged drawing). 3. **Slots**: each assignment goes to the slot role of the TRO that holds the part — key **TRO-ID + slot + role**. A `PinStore_Auto` now has a slot per storage line (`line1` … `lineN`, same split as `scl_context._store`); @@ -828,7 +856,11 @@ Steps: layout conveyor. This gives motor, motor protection and encoder per layout conveyor (`AnlageDocument.conveyor_for("0010-R")` → the TROs on that Kreisel, i.e. `xInCarouselRun`) and fills `drive.motor` / - `drive.motorProtection` of Vario TROs. + `drive.motorProtection` of Vario TROs. When several TROs have a drive slot + on the same layout conveyor (Dematic VF_6: two Vario TROs), the drive is a + `widerspruch` with the conveyor's motor as a suggestion — each TRO has its + own motor; set it in `hw_overrides.ini`. `scl_skeleton` then keeps + `MA____` instead of falling back to the conveyor's motor. 5. **Corrections** from `hw_overrides.ini` (last): `[.bindings] TRO01.separator1.sensor = BG0532` (or `= -` to clear) and `[.conveyors] K0102 = 0010`. @@ -838,7 +870,8 @@ Steps: Every binding carries `constant` and `address` from the TIA import. Switches: `--tro NAME`, `--hw NAME` (both required, resolved against -`%SKEL_RESULTS%`), `--rules FILE`, `--overrides FILE`, `--re-register`. Exit +`%SKEL_RESULTS%`), `--rules FILE` (a bare name resolves against `%SKEL_CFG%`), +`--overrides FILE`, `--re-register`. Exit `0` also when not everything was assigned; `1` on input errors, an old TRO-JSON without `parts`, or an unprovable registration. Parameters: `cfg/parameter.ini [merge_hw_layout]`. @@ -852,6 +885,14 @@ K0105 = Kreisel1 (2/3)**, each with motor and motor protection. The other conveyors (Aufgabestation #100/#101, Vario #106–#109) are outside the CSV or have a single vote and stay open. +Result on Dematic (`dematic_merge_tro.json` + `dematic_hw.json`, rules +`e_planning_dematic.ini`): 113 slot roles assigned (all 27 storage lines with +sensor and stopper, 23 of 25 separators, 5 of 6 switch valves, 6 Vario drives), +39 open (Vario sensors, scanners, VF_4/VF_6 where mechanical parts are still +missing), 4 contradictions (the two Vario TROs on VF_6). Then +`scl_skeleton --templates --strict`: 115 names inserted, all known in TIA, +open gaps 730 → 615. + **Stage 4:** `scl_skeleton --json _anlage.json` reads the plant JSON directly (§5b). `--eplan` still works for a TRO-JSON and is phased out together with `e_planning.py`. @@ -860,7 +901,9 @@ Tests: `tests/test_merge_hw_layout.py` (sensor spatial / stopper by number, contradiction, open roles, part without slot, conveyor majority with overlapping boxes, Vario drive, too few votes, corrections incl. moving a BMK and clearing a conveyor, storage-line slots, missing `parts`, JSON round trip, -fingerprint of the stored registration, CLI end to end with a real offset). +fingerprint of the stored registration, CLI end to end with a real offset, +merged drawing without registration, two drive TROs on one conveyor, Dematic +rules file). ## 6. `tro_catalog.py` — TRO type catalog (library, no CLI) diff --git a/lib/hw_import.py b/lib/hw_import.py index 755ab25..3dd0c4c 100644 --- a/lib/hw_import.py +++ b/lib/hw_import.py @@ -16,13 +16,19 @@ genuegen (Befund doc/E-Planung_Bruecke.md, "Nachpruefung 2026-10-15"): Kabellaengen-Tools (optional) - je BMK die Lage in der E-Zeichnung, die Verwendung (VERW, z. B. "IN SEP"), Klartext und E/A-Station. +* **Gemeinsame Zeichnung** ``*.dxf`` (statt der Positionsdatei) - Layout + und E-Planung in einer DXF (Dematic): jeder Block mit Attribut ``IO`` ist + ein E-Bauteil (IO = BMK, VERW, BEZEICHNUNG, KENNZEICHNUNG, Einfuegepunkt). + Die Lage steht schon in Layout-Koordinaten; merge_hw_layout ermittelt + dann keinen Lagebezug (``layoutCoordinates``). + EA-Liste und WSCAD-Import bringen bei vorhandener Positionsdatei nichts dazu und werden nicht gelesen. Ergebnis ``HardwareDocument`` (Schema sps_skel-hw-v1): je BMK ein Geraet mit beiden Seiten, die Antriebe (Motor MA <-> Motorschutz FC, Geber GF) je -Foerderer der E-Planung (K0102; "#106" wird K0106), und die Gegenpruefung -beider Quellen. +Foerderer der E-Planung (K0102; "#106" wird K0106; mit --conveyor-from-motor +auch "MA0055" -> K0055), und die Gegenpruefung beider Quellen. Die Zuordnung zum Layout macht merge_hw_layout.py. Die xlsx-Datei wird ohne Fremdbibliothek gelesen (zipfile + XML): der @@ -59,6 +65,7 @@ __all__ = [ "read_xlsx", "load_tia", "load_positionsdraw", + "load_dxf", "pair_drives", "conveyor_of", "conveyor_kind_of", @@ -79,10 +86,13 @@ _ADDRESS = re.compile(r"^%?([EIAQM])\s*(\d+)\.(\d)$", re.IGNORECASE) _CONVEYOR_K = re.compile(r"(? K0055 / K0046 / K0043 +_CONVEYOR_MA = re.compile(r"(? Kreisel; -# "CONVEYOR #106", "KLINKENFOERDERER" -> Strecke +# "CONVEYOR #106", "KLINKENFOERDERER", "ILS-VARIO" -> Strecke _KIND_KREISEL = re.compile(r"(? list[ED return list(devices.values()) +def load_dxf(path: Path, warnings: list[str] | None = None) -> list[EDevice]: + """E-Bauteile aus einer gemeinsamen Zeichnung (Layout + E-Planung) lesen. + + Jeder Block im Modellbereich mit einem BMK im Attribut ``IO`` ist ein + E-Bauteil; die Lage ist der Einfuegepunkt - schon in Layout-Koordinaten. + Bloecke ohne IO (Layout-Bauteile, Busverteiler, die Motorkennzeichnung + "ILS-Kreisel MA0053") zaehlen nicht: der Motor steht im Motorsymbol. + Steht ein BMK doppelt da, gewinnt der Eintrag mit VERW. + """ + import ezdxf + + warnings = warnings if warnings is not None else [] + doc = ezdxf.readfile(str(path)) + devices: dict[str, EDevice] = {} + count: Counter = Counter() + for insert in doc.modelspace().query("INSERT"): + attrs = {a.dxf.tag.upper(): a.dxf.text.strip() for a in insert.attribs} + bmk = attrs.get("IO", "") + if not _BMK.match(bmk): + continue + count[bmk] += 1 + dev = EDevice( + bmk=bmk, verw=attrs.get("VERW", ""), bezeichnung=attrs.get("BEZEICHNUNG", ""), + kennzeichnung=attrs.get("KENNZEICHNUNG", ""), + pos=(float(insert.dxf.insert.x), float(insert.dxf.insert.y))) + old = devices.get(bmk) + if old is None or (not old.verw and dev.verw): + devices[bmk] = dev + for bmk, n in sorted(count.items()): + if n > 1: + warnings.append(f"Zeichnung: {bmk} steht {n}x in der Zeichnung " + f"- verwendet wird ein Eintrag") + if not devices: + raise ValueError(f"{path.name}: kein Block mit BMK im Attribut 'IO' gefunden " + f"- ist das die Zeichnung mit der E-Planung?") + return list(devices.values()) + + def _positions_warnings(path: Path) -> list[str]: """Die Hinweise, die das Kabellaengen-Tool selbst in die Datei schreibt.""" data = json.loads(path.read_text(encoding="utf-8")) @@ -433,13 +484,18 @@ def _positions_warnings(path: Path) -> list[str]: # --------------------------------------------------------------------------- -def conveyor_of(*texts: str) -> str: +def conveyor_of(*texts: str, motor: bool = False) -> str: """Foerderer der E-Planung aus einem Klartext als "K", sonst "". Die E-Planung schreibt denselben Foerderer zweifach: "CONVEYOR-K0103" (Motorschutz FC0203) und "CONVEYOR-#103" (Motor MA0203). Beides ist die vierstellige Foerderernummer des SCL-Namensschemas (DB_JamArea0103, fbCarousel0102) und wird zu "K0103". + + ``motor=True`` (Dematic): ohne K/# nennt der Klartext nur den Motor + ("Motor KR-MA0055", "Rad dreht ILS-KR (MA0046)"); dann ist die + Motornummer die Foerderernummer -> "K0055". Bei Mubea waere das falsch + (Motor MA0203 treibt K0103), deshalb nur auf Wunsch. """ for text in texts: upper = (text or "").upper() @@ -449,6 +505,11 @@ def conveyor_of(*texts: str) -> str: m = _CONVEYOR_HASH.search(upper) if m: return f"K{int(m.group(1)):04d}" + if motor: + for text in texts: + m = _CONVEYOR_MA.search((text or "").upper()) + if m: + return f"K{m.group(1)}" return "" @@ -497,10 +558,18 @@ def _constant_name(bmk: str, direction: str) -> str: def build_hardware(tia_path: Path | None, positions_path: Path | None, - project: str = "") -> HardwareDocument: - """Beide Quellen einlesen, je BMK zusammenfuehren und gegeneinander pruefen.""" - if tia_path is None and positions_path is None: - raise ValueError("weder TIA-Import noch Positionsdatei angegeben") + project: str = "", dxf_path: Path | None = None, + conveyor_from_motor: bool = False) -> HardwareDocument: + """Beide Quellen einlesen, je BMK zusammenfuehren und gegeneinander pruefen. + + Die Lage kommt aus der Positionsdatei oder aus der gemeinsamen Zeichnung + ``dxf_path`` (load_dxf), nicht aus beiden. ``conveyor_from_motor``: siehe + conveyor_of(motor=True). + """ + if tia_path is None and positions_path is None and dxf_path is None: + raise ValueError("weder TIA-Import noch Positionsdatei/Zeichnung angegeben") + if positions_path is not None and dxf_path is not None: + raise ValueError("Positionsdatei und Zeichnung zugleich - nur eine Quelle fuer die Lage") warnings: list[str] = [] checks: list[str] = [] @@ -512,6 +581,9 @@ def build_hardware(tia_path: Path | None, positions_path: Path | None, if positions_path is not None: e_devices = load_positionsdraw(positions_path, warnings) warnings.extend(_positions_warnings(positions_path)) + elif dxf_path is not None: + e_devices = load_dxf(dxf_path, warnings) + positions_path = dxf_path tag_of = {} for t in tags: @@ -546,7 +618,8 @@ def build_hardware(tia_path: Path | None, positions_path: Path | None, comment=tag.comment if tag else "", constant=cname if const else "", constant_value=value, - conveyor=conveyor_of(e.bezeichnung if e else "", tag.comment if tag else ""), + conveyor=conveyor_of(e.bezeichnung if e else "", tag.comment if tag else "", + motor=conveyor_from_motor), conveyor_kind=conveyor_kind_of(e.bezeichnung if e else "", tag.comment if tag else ""), sources=[s for s, ok in (("tia", tag is not None), ("positions", e is not None)) @@ -561,6 +634,7 @@ def build_hardware(tia_path: Path | None, positions_path: Path | None, tia_sha256=file_fingerprint(tia_path) if tia_path else "", positions_file=positions_path.name if positions_path else "", positions_sha256=file_fingerprint(positions_path) if positions_path else "", + layout_coordinates=dxf_path is not None, devices=devices, drives=pair_drives(devices), constants=[c for c in constants if c.name not in used_constants], @@ -627,7 +701,10 @@ def render_report(doc: HardwareDocument) -> str: add(f"# Hardwareplanung: {doc.project}") add("") add(f"- TIA-Import: `{doc.tia_file or '-'}`") - add(f"- Positionsdatei: `{doc.positions_file or '-'}`") + if doc.layout_coordinates: + add(f"- Gemeinsame Zeichnung (Lage in Layout-Koordinaten): `{doc.positions_file}`") + else: + add(f"- Positionsdatei: `{doc.positions_file or '-'}`") add(f"- Erzeugt: {doc.created}") add("") add("## Geraete") @@ -699,20 +776,30 @@ def project_name(tia_path: Path | None, positions_path: Path | None) -> str: def main(argv: list[str] | None = None) -> int: + import ezdxf + from material_flow import env_dir, resolve_input args = parse_args(argv) tia_path = resolve_input(args.tia) if args.tia else None positions_path = resolve_input(args.positions) if args.positions else None - for label, path in (("TIA-Import", tia_path), ("Positionsdatei", positions_path)): + dxf_path = resolve_input(args.dxf) if args.dxf else None + if positions_path is not None and dxf_path is not None: + print("FEHLER: --positions und --dxf zugleich - nur eine Quelle fuer die Lage", + file=sys.stderr) + return 1 + for label, path in (("TIA-Import", tia_path), ("Positionsdatei", positions_path), + ("Zeichnung", dxf_path)): if path is not None and not path.is_file(): print(f"FEHLER: {label} nicht gefunden: {path}", file=sys.stderr) return 1 - project = args.project or project_name(tia_path, positions_path) + project = args.project or project_name(tia_path, positions_path or dxf_path) try: - doc = build_hardware(tia_path, positions_path, project) - except (OSError, ValueError, KeyError, zipfile.BadZipFile, ET.ParseError) as exc: + doc = build_hardware(tia_path, positions_path, project, dxf_path, + args.conveyor_from_motor) + except (OSError, ValueError, KeyError, zipfile.BadZipFile, ET.ParseError, + ezdxf.DXFError) as exc: print(f"FEHLER: {exc}", file=sys.stderr) return 1 @@ -730,7 +817,10 @@ def main(argv: list[str] | None = None) -> int: print("HARDWAREPLANUNG EINLESEN") print("================================================================") print(f"TIA-Import = {tia_path or '-'}") - print(f"Positionsdatei = {positions_path or '-'}") + if dxf_path is not None: + print(f"Zeichnung = {dxf_path} (Lage in Layout-Koordinaten)") + else: + print(f"Positionsdatei = {positions_path or '-'}") print(f"Projekt = {project}") print(f"Geraete = {len(doc.devices)} (TIA {n_tia}, Positionsdatei {n_pos}, " f"beide {sum(1 for d in doc.devices if len(d.sources) == 2)})") @@ -763,6 +853,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: " hw_import.bat --tia Mubea/mubea-1_TIA.xlsx " "--positions Mubea/Mubea_positionsconv.json\n" " hw_import.bat --tia mubea-1_TIA.xlsx --project Mubea\n" + " hw_import.bat --tia dematic_hw-2_TIA.xlsx --dxf dematic_merge..dxf " + "--project dematic --conveyor-from-motor\n" "\n" "Exit-Codes:\n" " 0 ok (Befunde der Gegenpruefung stehen im Bericht)\n" @@ -774,6 +866,17 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--positions", metavar="NAME", help="*_positionsconv.json bzw. *_positionsdraw.json in " "%%SKEL_DATA%% (oder ein Pfad); ohne sie keine Lage") + parser.add_argument("--dxf", metavar="NAME", + help="statt --positions: gemeinsame Zeichnung (Layout + " + "E-Planung, *.dxf) in %%SKEL_DATA%% (oder ein Pfad); " + "jeder Block mit Attribut IO ist ein E-Bauteil, die Lage " + "steht schon in Layout-Koordinaten") + parser.add_argument("--conveyor-from-motor", dest="conveyor_from_motor", + action="store_true", + help="Foerderer auch aus der Motornummer im Klartext " + "(\"Motor KR-MA0055\" -> K0055), wenn kein K0102/#106 " + "dasteht (Dematic). Nicht fuer Mubea: dort treibt MA0203 " + "den Foerderer K0103.") parser.add_argument("--project", metavar="NAME", help="Projektname fuer _hw.json. Standard: aus dem " "Namen der Positionsdatei (Mubea_positionsconv.json -> Mubea)") diff --git a/lib/merge_hw_layout.py b/lib/merge_hw_layout.py index 24c064a..04ca484 100644 --- a/lib/merge_hw_layout.py +++ b/lib/merge_hw_layout.py @@ -371,6 +371,19 @@ def resolve_transform(points, devices, anchors, cfg_dir: Path, csv_name: str, return transform +def _same_drawing(points: list[LayoutPoint], drawing: str) -> Transform: + """Lagebezug einer gemeinsamen Zeichnung (hw_import --dxf): keiner. + + Die E-Symbole stehen schon in Layout-Koordinaten. Ihr Versatz neben dem + Bauteil (Dematic ~600 mm) ist Zeichenabstand, keine Verschiebung - ein + eingepasster Bezug wuerde ihn nur verzerren. Er steckt in max_mm/soll_mm + der Regeln. + """ + n = sum(1 for p in points if p.kind == "Separator") + return Transform(dx=0.0, dy=0.0, source="zeichnung", anchor="gemeinsame Zeichnung", + matched=n, candidates=n, dxf_file=drawing) + + # --------------------------------------------------------------------------- # Slots # --------------------------------------------------------------------------- @@ -529,9 +542,20 @@ def _conveyor_kinds(hw: HardwareDocument) -> dict[str, str]: def bind_drives(doc: TroDocument, conveyors: list[ConveyorBinding], hw: HardwareDocument) -> list[Binding]: - """Antriebs-Slots (Vario): Motor und Motorschutz des zugeordneten Foerderers.""" + """Antriebs-Slots (Vario): Motor und Motorschutz des zugeordneten Foerderers. + + Tragen mehrere TROs einen Antriebs-Slot auf demselben Layout-Foerderer + (Dematic VF_6: zwei Vario-TROs, drei Vario-Motoren), hat jeder TRO seinen + eigenen Motor - welcher, sagt die Mehrheit je Foerderer nicht. Dann wird + der Antrieb ein Widerspruch (BMK als Vorschlag), Korrektur von Hand. + """ devices = hw.by_bmk() by_layout = {c.layout_id: c for c in conveyors if c.status == "zugeordnet"} + sharing: dict[str, list[str]] = {} + for tro in doc.tros: + for s in tro.slots: + if s.layout_id and any(r.rstrip("?") in _DRIVE_ROLES for r in s.roles): + sharing.setdefault(s.layout_id.split("-", 1)[0], []).append(tro.tro_id) bindings = [] for tro in doc.tros: for s in tro.slots: @@ -541,17 +565,24 @@ def bind_drives(doc: TroDocument, conveyors: list[ConveyorBinding], if conv is None: continue roles = [r.rstrip("?") for r in s.roles] + shared = sharing.get(s.layout_id.split("-", 1)[0], []) for role, attr in _DRIVE_ROLES.items(): bmk = getattr(conv, attr) if role not in roles or not bmk: continue dev = devices.get(bmk) + status = "zugeordnet" if dev and dev.in_tia else "widerspruch" + note = f"Antrieb von {conv.eplan_name}" + if len(shared) > 1: + status = "widerspruch" + note = (f"{conv.layout_name or conv.layout_id} traegt {len(shared)} " + f"Antriebs-TROs ({', '.join(shared)}) - Antrieb je TRO von Hand " + f"({OVERRIDES_FILE}); Vorschlag {conv.eplan_name}") bindings.append(Binding( tro=tro.tro_id, slot=s.slot, role=role, layout_id=s.layout_id, bmk=bmk, constant=dev.constant if dev else "", address=dev.address if dev else "", source="positionsdraw", - rule="mehrheit", status="zugeordnet" if dev and dev.in_tia else "widerspruch", - note=f"Antrieb von {conv.eplan_name}")) + rule="mehrheit", status=status, note=note)) return bindings @@ -690,7 +721,9 @@ def build_anlage(doc: TroDocument, hw: HardwareDocument, rules: list[Rule], assignments: list[Assignment] = [] conveyors: list[ConveyorBinding] = [] if hw.field_devices(): - if transform is None: + if transform is None and hw.layout_coordinates: + transform = _same_drawing(points, hw.positions_file) + elif transform is None: transform = resolve_transform(points, devices, anchors, cfg_dir, doc.source, hw.positions_file, re_register, warnings, hw.positions_sha256) @@ -856,6 +889,14 @@ def _in_results(name: str, results: Path) -> Path: return results / name +def _in_cfg(name: str, cfg_dir: Path) -> Path: + """Regeldatei: ein blosser Name, der hier nicht liegt, steht in %SKEL_CFG%.""" + path = Path(name) + if path.is_absolute() or path.parent != Path(".") or path.is_file(): + return path + return cfg_dir / name + + def main(argv: list[str] | None = None) -> int: from material_flow import env_dir @@ -864,7 +905,7 @@ def main(argv: list[str] | None = None) -> int: cfg_dir = env_dir("SKEL_CFG", "cfg") tro_path = _in_results(args.tro, results) hw_path = _in_results(args.hw, results) - rules_path = Path(args.rules) if args.rules else cfg_dir / RULES_FILE + rules_path = _in_cfg(args.rules, cfg_dir) if args.rules else cfg_dir / RULES_FILE overrides_path = Path(args.overrides) if args.overrides else cfg_dir / OVERRIDES_FILE for label, path in (("TRO-JSON", tro_path), ("Hardware-JSON", hw_path), ("Regeln", rules_path)): @@ -955,7 +996,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="Hardware-JSON _hw.json in %%SKEL_RESULTS%% " "(oder ein Pfad)") parser.add_argument("--rules", metavar="DATEI", - help=f"Regeldatei. Standard: %%SKEL_CFG%%\\{RULES_FILE}") + help=f"Regeldatei (Name in %%SKEL_CFG%% oder ein Pfad), z. B. " + f"e_planning_dematic.ini. Standard: %%SKEL_CFG%%\\{RULES_FILE}") parser.add_argument("--overrides", metavar="DATEI", help=f"Handkorrekturen. Standard: %%SKEL_CFG%%\\{OVERRIDES_FILE} " f"(fehlt sie, keine)") diff --git a/lib/scl_context.py b/lib/scl_context.py index c100597..a71153b 100644 --- a/lib/scl_context.py +++ b/lib/scl_context.py @@ -150,6 +150,13 @@ class HwSource: geraet = self.device(tro_id, slot, role, layout) return _bekannt(fmt.format(bmk=geraet[0])) if geraet else _offen(offen) + def conflict(self, tro_id: str, slot: str, role: str) -> bool: + """True, wenn die Anlagen-JSON fuer die Slot-Rolle einen Widerspruch fuehrt.""" + if self.anlage is None: + return False + b = self.anlage.binding(tro_id, slot, role) + return b is not None and b.status == "widerspruch" + def conveyor(self, host: str) -> ConveyorBinding | None: """Foerderer der Hardwareplanung (K0102) am Layout-Foerderer ("0010-R").""" if self.anlage is None or not host: @@ -643,7 +650,9 @@ def _drive(tro: TroBase, hw: HwSource) -> tuple[SclName, SclName]: Erst der Antriebs-Slot (Rollen motor/motorProtection); fehlt er, der Foerderer der Hardwareplanung am Host des TRO - der Vario-Antrieb ist - dessen Motor (MA/FC-Paar aus hw_import). + dessen Motor (MA/FC-Paar aus hw_import). Ein Widerspruch im Slot (z. B. + zwei Vario-TROs auf einem Foerderer) bleibt offen, statt den Motor des + Foerderers zu erben. """ motor = hw.output(tro.tro_id, "drive", "motor", offen="MA____") schutz = hw.output(tro.tro_id, "drive", "motorProtection", @@ -651,9 +660,11 @@ def _drive(tro: TroBase, hw: HwSource) -> tuple[SclName, SclName]: hosts = list(getattr(tro, "hosts", []) or []) conveyor = hw.conveyor(hosts[0]) if hosts else None if conveyor is not None: - if motor.todo and conveyor.motor: + if (motor.todo and conveyor.motor + and not hw.conflict(tro.tro_id, "drive", "motor")): motor = _bekannt(conveyor.motor) - if schutz.todo and conveyor.protection: + if (schutz.todo and conveyor.protection + and not hw.conflict(tro.tro_id, "drive", "motorProtection")): schutz = _bekannt(f'"{conveyor.protection}"') return motor, schutz diff --git a/tests/test_hw_import.py b/tests/test_hw_import.py index 5be1ed2..21df160 100644 --- a/tests/test_hw_import.py +++ b/tests/test_hw_import.py @@ -191,6 +191,91 @@ def test_foerderer_aus_klartext(text, name, kind): assert conveyor_kind_of(text) == kind +@pytest.mark.parametrize("text, name, kind", [ + # Dematic nennt nur den Motor + ("Motor KR-MA0055", "K0055", "Kreisel"), + ("Rad dreht ILS-KR (MA0046)", "K0046", "Kreisel"), + ("Motor ILS-VARIO-MA0043", "K0043", "Strecke"), + ("Abfrage Finger in Position ILS-VARIO (MA0050)", "K0050", "Strecke"), + # K/# gehen vor: der Motor MA0202 treibt hier K0102 + ("ANTRIEB KREISEL-K0102 MA0202", "K0102", "Kreisel"), + ("Separator Str. 1", "", ""), +]) +def test_foerderer_aus_motornummer(text, name, kind): + assert conveyor_of(text, motor=True) == name + assert conveyor_kind_of(text) == kind + # ohne motor=True (Mubea) zaehlt die Motornummer nicht + assert conveyor_of(text) == ("K0102" if "K0102" in text else "") + + +# --------------------------------------------------------------------------- +# Gemeinsame Zeichnung (Layout + E-Planung in einer DXF, Dematic) +# --------------------------------------------------------------------------- + + +def dxf_file(tmp_path: Path) -> Path: + """E-Symbole wie in dematic_merge..dxf: Bloecke mit IO/VERW/BEZEICHNUNG.""" + import ezdxf + + doc = ezdxf.new() + tags = ("SPS", "IO", "VERW", "BEZEICHNUNG", "KENNZEICHNUNG", "ID") + sym = doc.blocks.new("ILS_720002003-Separator") + for i, tag in enumerate(tags): + sym.add_attdef(tag, (0, -i * 10)) + label = doc.blocks.new("ILS_Motorkennzeichnung-Kreisel") + label.add_attdef("MOTORKENNZEICHNUNG", (0, 0)) + msp = doc.modelspace() + + def place(x, y, **attrs): + msp.add_blockref("ILS_720002003-Separator", (x, y)).add_auto_attribs(attrs) + + place(100.0, 200.0, SPS="2", IO="BG0532", VERW="In Sep 1", + BEZEICHNUNG="in Separator Str. 1", KENNZEICHNUNG="=A02+UC0103-KF1DI1") + place(100.0, 200.0, SPS="2", IO="MB0533", VERW="Sep 1", + BEZEICHNUNG="Separator Str. 1", KENNZEICHNUNG="=A02+UC0103-KF1DQ1") + place(900.0, 50.0, SPS="2", IO="MA0202", VERW="ILS-KR-MA0202", + BEZEICHNUNG="Motor KR-MA0202", KENNZEICHNUNG="=A02+UH01-KF1DQ2") + # doppelt: der Eintrag mit VERW gewinnt + place(5.0, 5.0, IO="BG0777") + place(7.0, 7.0, IO="BG0777", VERW="Stau 1") + # ohne BMK in IO: kein E-Bauteil + place(0.0, 0.0, IO="") + msp.add_blockref("ILS_Motorkennzeichnung-Kreisel", (0, 0)).add_auto_attribs( + {"MOTORKENNZEICHNUNG": "MA0999"}) + path = tmp_path / "anlage_merge.dxf" + doc.saveas(path) + return path + + +def test_zeichnung_liest_jeden_block_mit_bmk(tmp_path): + warnings: list[str] = [] + devices = {d.bmk: d for d in hw_import.load_dxf(dxf_file(tmp_path), warnings)} + assert sorted(devices) == ["BG0532", "BG0777", "MA0202", "MB0533"] + s = devices["BG0532"] + assert (s.verw, s.bezeichnung, s.pos, s.station) == ( + "In Sep 1", "in Separator Str. 1", (100.0, 200.0), "KF1DI1") + assert (devices["BG0777"].verw, devices["BG0777"].pos) == ("Stau 1", (7.0, 7.0)) + assert warnings == ["Zeichnung: BG0777 steht 2x in der Zeichnung - verwendet wird ein Eintrag"] + + +def test_zeichnung_mit_tia_lage_in_layout_koordinaten(tmp_path): + doc = build_hardware(tia_file(tmp_path), None, "X", dxf_path=dxf_file(tmp_path), + conveyor_from_motor=True) + assert doc.layout_coordinates and doc.positions_file == "anlage_merge.dxf" + s = doc.by_bmk()["BG0532"] + assert s.sources == ["tia", "positions"] and s.constant == "cInBG0532" + [drive] = doc.drives + # K0102 aus dem TIA-Kommentar geht der Motornummer vor + assert (drive.motor, drive.protection, drive.conveyor) == ("MA0202", "FC0202", "K0102") + assert HardwareDocument.from_json(doc.to_json()).layout_coordinates + + +def test_positionsdatei_und_zeichnung_schliessen_sich_aus(tmp_path): + with pytest.raises(ValueError, match="nur eine Quelle"): + build_hardware(tia_file(tmp_path), positions_file(tmp_path), "X", + dxf_path=dxf_file(tmp_path)) + + # --------------------------------------------------------------------------- # Zusammenfuehren # --------------------------------------------------------------------------- diff --git a/tests/test_merge_hw_layout.py b/tests/test_merge_hw_layout.py index f48ee8a..2a2a830 100644 --- a/tests/test_merge_hw_layout.py +++ b/tests/test_merge_hw_layout.py @@ -37,7 +37,8 @@ def _tro(tro_id, type_name, seps, hosts=()): separators=list(seps), scanners=[], reason="Test") -def layout() -> TroDocument: +def layout(second_vario: bool = False) -> TroDocument: + """``second_vario``: TRO06 ist ein zweiter Vario-TRO auf VF_1 (Dematic VF_6).""" parts = [_part(i, "Separator", x, y) for i, (x, y) in SEPS.items()] parts += [ _part("0030", "Ausschleus", 0.0, 2000.0), @@ -52,7 +53,8 @@ def layout() -> TroDocument: _tro("TRO03", "Vario", ["0003"], ["0020"]), _tro("TRO04", "1Sep", ["0004"]), _tro("TRO05", "1Sep", ["0005"]), - _tro("TRO06", "1Sep", ["0006"]), + _tro("TRO06", "Vario", ["0006"], ["0020"]) if second_vario + else _tro("TRO06", "1Sep", ["0006"]), ] return TroDocument.from_analysis(Analysis(tros=tros), source="x.csv", parts=parts) @@ -183,6 +185,37 @@ def test_vario_antrieb_aus_dem_foerderer(): assert anlage.binding("TRO03", "drive", "senJam").status == "offen" +def test_zwei_antriebs_tros_auf_einem_foerderer_sind_widerspruch(): + """Jeder Vario-TRO hat seinen eigenen Motor; die Mehrheit je Foerderer + kennt nur einen - nicht beiden denselben Motor geben.""" + anlage = run(doc=layout(second_vario=True)) + for tro in ("TRO03", "TRO06"): + motor = anlage.binding(tro, "drive", "motor") + assert (motor.bmk, motor.status) == ("MA0210", "widerspruch") + assert "TRO03, TRO06" in motor.note and "von Hand" in motor.note + assert anlage.binding(tro, "drive", "motorProtection").status == "widerspruch" + + +def test_gemeinsame_zeichnung_ohne_lagebezug(tmp_path): + """hw_import --dxf: E-Symbole stehen schon in Layout-Koordinaten - kein + Lagebezug wird ermittelt oder abgelegt.""" + hw = hardware() + hw.layout_coordinates = True + anlage = run(hw=hw, transform=None, tmp_path=tmp_path) + reg = anlage.registration + assert (reg.dx, reg.dy, reg.rotation, reg.source) == (0.0, 0.0, 0.0, "zeichnung") + assert not list(tmp_path.iterdir()) + assert anlage.binding("TRO01", "separator1", "sensor").bmk == "BG0101" + + +def test_dematic_regeln_gleiche_nummer(): + rules, _ = load_rules(RULES.parent / "e_planning_dematic.ini") + by_role = {r.role: r for r in rules} + assert by_role["Separator.stopper"].number_from == "Separator.sensor" + assert by_role["Separator.stopper"].number_offset == 0 + assert by_role["Ausschleus.valve"].number_offset == 0 + + def test_foerderer_mit_zu_wenig_stimmen_bleibt_offen(): hw = hardware() hw.devices = [d for d in hw.devices if d.bmk != "BG0802"] diff --git a/tests/test_scl_anlage.py b/tests/test_scl_anlage.py index ae9af06..c086ed2 100644 --- a/tests/test_scl_anlage.py +++ b/tests/test_scl_anlage.py @@ -120,6 +120,20 @@ def test_vario_antrieb_ueber_den_foerderer(): assert tro.vario_sensors["stSenFinger"].todo +def test_widerspruch_im_antrieb_erbt_nicht_den_foerderermotor(): + """Zwei Vario-TROs auf einem Foerderer: merge_hw_layout fuehrt den Antrieb + als Widerspruch - dann nicht doch den Motor des Foerderers einsetzen.""" + doc = anlage() + doc.bindings += [ + _bind("TRO03", "drive", "motor", "MA0210", "cOutMA0210", status="widerspruch"), + _bind("TRO03", "drive", "motorProtection", "FC0210", "cInFC0210", + status="widerspruch"), + ] + tro = _by_id(_kontext(doc), "TRO03") + assert (tro.vario_motor.value, tro.vario_motor.todo) == ("MA____", True) + assert tro.motor_protection.todo + + def test_ohne_hardwareplanung_alles_offen(): doc = anlage() ctx = build_context(doc.layout, "anlage_tro.json")