From e5d1fce41f92d4bb08efe5b49dc4d586c13217e1 Mon Sep 17 00:00:00 2001 From: Samer Ayadi Date: Fri, 23 Oct 2026 12:47:11 +0200 Subject: [PATCH] tro_annotate --anlage: Staubereiche in der Zeichnung, --read-jams zurueck Mit --anlage _anlage.json zeichnet tro_annotate je Staubereich einen Block JAM_AREA (Layer TRO_JAM): Rechteck mit dem Namen Jam_ wie in der SCL, jam_versatz vor dem Separator Richtung Vorgaenger. Attribute CONVEYOR, NUMBER, TRO + ENTRY (stabiler Schluessel), AREA, AUTO. Doppelklick oeffnet den Dialog JAM_EDIT (dxfmakros). --read-jams uebernimmt im Dialog geaenderte Nummern aus der beschrifteten Zeichnung nach cfg/_jam_names.ini (nur Abweichungen von AUTO); scl_skeleton wendet die Datei mit der Anlagen-JSON an (scl_context: jam_numbers + Korrekturen, load/save_jam_overrides). Dematic: 25 Staubereiche gezeichnet; Rundreise TRO20 -> Jam0054_9 bis in die SCL geprueft. Gemeinsame Zeichnung mit --offset 0,0 beschriften (automatischer Lagebezug waehlte dy +360 mm). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- cfg/parameter.ini | 5 + doc/Python_Scripts.md | 26 ++++ lib/scl_context.py | 104 ++++++++++++- lib/scl_skeleton.py | 24 ++- lib/tro_annotate.py | 262 +++++++++++++++++++++++++++++++- tests/test_tro_annotate_jams.py | 131 ++++++++++++++++ 7 files changed, 540 insertions(+), 17 deletions(-) create mode 100644 tests/test_tro_annotate_jams.py diff --git a/CLAUDE.md b/CLAUDE.md index 031ff61..d1dc3a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,10 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha Switches `--json`, `--out`, `--start` (the last picks an entry point while the plant is a closed loop with no loading/unloading station). See `doc/Python_Scripts.md` §5b. 3. **`lib/tro_annotate.py`** — **viewer only**: draws the same TROs (same `analyse_tros` - call as `tro_flow.py`) into a copy of the DXF for a visual check in BricsCAD. + call as `tro_flow.py`) into a copy of the DXF for a visual check in BricsCAD. With + `--anlage _anlage.json` also the jam areas (block `JAM_AREA`, name `Jam_`, + double-click → dialog `JAM_EDIT` in dxfmakros); `--read-jams` takes numbers edited there + into `cfg/_jam_names.ini`, which `scl_skeleton` applies. 4. **`lib/tro_extract.py`** — **parked**: reads the annotated DXF back into the old `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 diff --git a/cfg/parameter.ini b/cfg/parameter.ini index c060d51..7d883d5 100644 --- a/cfg/parameter.ini +++ b/cfg/parameter.ini @@ -178,6 +178,11 @@ default_text = 220.0 ; Restfehler des Lagebezugs, ab dem er abgelehnt wird. default_max_residual = 600.0 +; Staubereich (--anlage): das Rechteck sitzt so weit vor seinem Separator, +; Richtung Vorgaenger-TRO (gegen den Fluss) - er ist die Strecke, auf der +; die Traeger vor dem Separator warten. +jam_versatz = 700.0 + [merge_hw_layout] ; Zulaessige Abweichung vom Sollabstand (soll_mm), wenn eine Regel in diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index 97e3b8d..e0c23a7 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -456,6 +456,26 @@ overridden. | `--re-register` | — | off | Re-derive the coordinate offset even if a verified one is already cached in `%SKEL_CFG%`. | | `--check` | — | off | Dry run: only analyze and report, write no output file. | | `--emit-lisp` | — | off | Regenerate `cad/tro_types.lsp` (the type list for the `TRO_INSERT`/`TRO_EDIT` dialogs) from `tro_catalog.py` and exit — no `--dxf` needed. Written under `%DXFM_LISP%` if set (next to the `dxfmakros` SSG_LIB modules that provide the dialogs), otherwise under `/cad`. | +| `--anlage` | `NAME` | — | Plant JSON (`_anlage.json` from `merge_hw_layout`, in `%SKEL_RESULTS%` or a path): also draw the **jam areas** (see below). Must belong to the same CSV. | +| `--read-jams` | `[DXF]` | `_annotated.dxf` | Read the jam-area numbers edited in BricsCAD back from the annotated drawing (saved as DXF) into `%SKEL_CFG%\_jam_names.ini`, then exit — no `--dxf` needed. | + +**Jam areas (`--anlage`).** One block `JAM_AREA` per jam area of the plant +JSON on layer `TRO_JAM`: a small rectangle with the name inside +(`Jam0054_3`, the same name the SCL uses — `scl_context.jam_numbers` plus the +corrections file). It sits `jam_versatz` mm (`cfg/parameter.ini +[tro_annotate]`) in front of its separator, towards the predecessor TRO, +where the carriers wait. Hidden attributes: `CONVEYOR` (NNNN), `NUMBER` (k), +`TRO` + `ENTRY` (the stable key, `scl_context.jam_key`), `AREA` (J0039) and +`AUTO` (the derived NNNN_k). A jam area without a conveyor shows +`Jam????_?`. Double-click opens the dialog **`JAM_EDIT`** (dxfmakros: +`Lisp/Jam_Edit.lsp`, `dcl/jam_edit.dcl`, dispatched by `SSG_BLOCKEDIT` on +`JAM_*`): conveyor and running number, a button to go back to the derived +value, and a warning if the name already exists. The dialog only changes the +drawing; `--read-jams` takes every block whose `CONVEYOR_NUMBER` differs from +`AUTO` into `_jam_names.ini` (`[jam] TRO17.1 = 0054_3`) and drops entries +that are back at `AUTO`. `scl_skeleton` with a plant JSON reads that file, and +the next `--anlage` run draws it, so drawing, file and SCL agree. Invalid +values (conveyor not four digits, number < 1) are reported and skipped. Layers always carry the prefix `TRO_`. Like `tro_flow.py`, it never reads `cfg/tro_overrides.ini`, so the markers always match the flow diagram. @@ -463,6 +483,12 @@ Layers always carry the prefix `TRO_`. Like `tro_flow.py`, it never reads **Output** (in `%SKEL_RESULTS%`, unless `--check`): `_annotated.dxf`, `_registration.json` (the coordinate transform used, with its proof). +**Merged drawing (Dematic).** The automatic registration only sees the +top-level 3D blocks and their insert points, which sit up to ~360 mm away +from the CSV positions; on `dematic_merge..dxf` it chose a wrong dy = +360 mm +(18/59 points). CSV and merged drawing share coordinates, so annotate with +`--offset 0,0`. + **Exit codes:** `0` ok · `1` input/CLI error · `4` coordinate registration could not be proven (or its residual error exceeds 600 mm) — resolve with `--offset`. diff --git a/lib/scl_context.py b/lib/scl_context.py index 7cf84cc..00fbabc 100644 --- a/lib/scl_context.py +++ b/lib/scl_context.py @@ -33,9 +33,11 @@ Wert ersetzen, Merker loeschen. from __future__ import annotations +import configparser import re from collections import deque from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field @@ -64,7 +66,13 @@ __all__ = [ "HwSource", "build_context", "flow_order", + "JAM_NAMES_SUFFIX", + "jam_key", + "jam_label", "jam_names", + "jam_numbers", + "load_jam_overrides", + "save_jam_overrides", "reachable_via", ] @@ -631,10 +639,88 @@ def _jam_db(area: JamArea | None, hw: HwSource, return (f'"DB_JamArea{nr}".stJam{nr}_____' if nr else _JAM_DB), False -def jam_names(document: TroDocument, geordnet: list[TroBase], - hw: HwSource) -> dict[str, str]: +JAM_NAMES_SUFFIX = "_jam_names.ini" +# Wert in der Korrekturdatei: Foerderer und laufende Nummer, "0054_3" +_JAM_WERT = re.compile(r"^(\d{4})_(\d+)$") + + +def jam_key(area: JamArea) -> str: + """Stabiler Schluessel eines Staubereichs: TRO-ID + Eingang ("TRO17.1"). + + Die Kennung J folgt der TeileId und aendert sich mit jedem + CSV-Export; die TRO-ID bleibt (cfg/_tro_ids.ini). + """ + return f"{area.tro}.{area.entry}" + + +def jam_label(nr: str, k: int) -> str: + """Kurzname fuer Zeichnung und Dialog: Jam0054_3.""" + return f"Jam{nr}_{k}" + + +def load_jam_overrides(path: Path) -> dict[str, tuple[str, int]]: + """Korrekturen der Staubereichs-Nummern aus ``_jam_names.ini``. + + Abschnitt [jam], je Zeile ``. = _`` + (``TRO17.1 = 0054_3``). Geschrieben von tro_annotate --read-jams aus den + im Dialog JAM_EDIT geaenderten Staubereichen; von Hand pflegbar. + Fehlt die Datei, gibt es keine Korrekturen. + """ + if not path.is_file(): + return {} + parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), + interpolation=None) + parser.optionxform = str + with path.open(encoding="utf-8") as fh: + parser.read_file(fh) + result: dict[str, tuple[str, int]] = {} + if parser.has_section("jam"): + for key, value in parser["jam"].items(): + m = _JAM_WERT.match(value.strip()) + if not m: + raise ValueError(f"{path.name} [jam] {key} = {value!r}: erwartet " + f"_, z. B. 0054_3") + result[key.strip()] = (m.group(1), int(m.group(2))) + return result + + +def save_jam_overrides(path: Path, overrides: dict[str, tuple[str, int]]) -> None: + """Korrekturdatei neu schreiben (sortiert nach TRO-ID); leer = Datei weg.""" + if not overrides: + if path.is_file(): + path.unlink() + return + zeilen = [ + "; Staubereichs-Nummern, die von der Ableitung abweichen", + "; (scl_context.jam_names). Geschrieben von tro_annotate --read-jams aus", + "; dem Dialog JAM_EDIT; von Hand pflegbar.", + "; . = _", + "", + "[jam]", + ] + zeilen += [f"{key} = {nr}_{k}" for key, (nr, k) in sorted(overrides.items())] + path.write_text("\n".join(zeilen) + "\n", encoding="utf-8") + + +def jam_names(document: TroDocument, geordnet: list[TroBase], hw: HwSource, + overrides: dict[str, tuple[str, int]] | None = None) -> dict[str, str]: """Staubereichs-Namen je Kennung (J0048 -> '"DB_JamArea0042".stJam0042_1'). + Die abgeleiteten Nummern (jam_numbers), ersetzt durch ``overrides`` + (load_jam_overrides) fuer die dort genannten Staubereiche. + """ + nummern = jam_numbers(document, geordnet, hw) + for area in document.jam_areas_or_derived(): + if overrides and jam_key(area) in overrides: + nummern[area.jam_id] = overrides[jam_key(area)] + return {jam_id: f'"DB_JamArea{nr}".stJam{nr}_{k}' + for jam_id, (nr, k) in nummern.items()} + + +def jam_numbers(document: TroDocument, geordnet: list[TroBase], + hw: HwSource) -> dict[str, tuple[str, int]]: + """Abgeleitete Staubereichs-Nummern je Kennung (J0048 -> ("0042", 1)). + Regel der SPS-Programmierer (Dematic, 2026-10-22): ist der Foerderer, auf dem der Staubereich liegt (Nummer des Kreisel- bzw. Vario-Motors), zaehlt auf diesem Foerderer in Fliessrichtung hoch. @@ -706,11 +792,11 @@ def jam_names(document: TroDocument, geordnet: list[TroBase], if nr: je_foerderer.setdefault(nr, []).append(area) nachfolger = {t.tro_id: list(t.successors) for t in geordnet} - namen: dict[str, str] = {} + nummern: dict[str, tuple[str, int]] = {} for nr, liste in je_foerderer.items(): for k, area in enumerate(_entlang(liste, nachfolger), start=1): - namen[area.jam_id] = f'"DB_JamArea{nr}".stJam{nr}_{k}' - return namen + nummern[area.jam_id] = (nr, k) + return nummern def _entlang(areas: list[JamArea], nachfolger: dict[str, list[str]]) -> list[JamArea]: @@ -908,13 +994,15 @@ def _tro_context(tro: TroBase, alle: dict[str, TroBase], def build_context(document: TroDocument, source: str, start_id: str = "", created: str | None = None, eplan: EplanDocument | None = None, - anlage: AnlageDocument | None = None) -> SclFile: + anlage: AnlageDocument | None = None, + jam_overrides: dict[str, tuple[str, int]] | None = None) -> SclFile: """TroDocument -> SclFile (TROs in Fliessrichtung). anlage: Anlagen-JSON (merge_hw_layout) - ihr Layout ist ``document``, ihre Zuordnungen liefern die Namen. eplan: alte Zuordnung Layout -> E-Planung (e_planning.py), nur ohne ``anlage``. Ohne beide bleiben alle - Namen offen und die Ausgabe ist die bisherige. + Namen offen und die Ausgabe ist die bisherige. jam_overrides: Korrekturen + der Staubereichs-Nummern (load_jam_overrides). """ if anlage is not None and eplan is not None: raise ValueError("anlage und eplan schliessen sich aus") @@ -924,7 +1012,7 @@ def build_context(document: TroDocument, source: str, start_id: str = "", for area in document.jam_areas_or_derived(): bereiche.setdefault(area.tro, []).append(area) geordnet, startgrund = flow_order(document.tros, start_id) - namen = jam_names(document, geordnet, hw) + namen = jam_names(document, geordnet, hw, jam_overrides) return SclFile( source=source, created=created or datetime.now().strftime("%Y-%m-%d %H:%M"), diff --git a/lib/scl_skeleton.py b/lib/scl_skeleton.py index 5157c3d..7c63f44 100644 --- a/lib/scl_skeleton.py +++ b/lib/scl_skeleton.py @@ -43,6 +43,7 @@ Aufruf ueber bin/scl_skeleton.bat bzw. bin/scl_skeleton.sh. from __future__ import annotations import argparse +import configparser import json import sys from pathlib import Path @@ -60,7 +61,15 @@ from e_planning import EPLAN_SCHEMA, EplanDocument from hw_import import HardwareDocument from material_flow import env_dir from model import ANLAGE_SCHEMA, SCHEMA, AnlageDocument, TroDocument -from scl_context import TODO, SclFile, SclName, SclTro, build_context +from scl_context import ( + JAM_NAMES_SUFFIX, + TODO, + SclFile, + SclName, + SclTro, + build_context, + load_jam_overrides, +) # Parametrisierte Aufrufstellen je FB (doc/SCL_Templates.md); Dateiname = fbType. TEMPLATE_DIR = Path(__file__).resolve().parent / "scl_templates" @@ -466,8 +475,17 @@ def main(argv: list[str] | None = None) -> int: f"TRO-JSON zu '{document.source}'.", file=sys.stderr) return 1 + # Staubereichs-Nummern, im Dialog JAM_EDIT geaendert und mit + # tro_annotate --read-jams uebernommen (nur mit Anlagen-JSON wirksam) + jam_file = env_dir("SKEL_CFG", "cfg") / f"{Path(document.source).stem}{JAM_NAMES_SUFFIX}" + try: + jam_overrides = load_jam_overrides(jam_file) if anlage is not None else {} + except (OSError, ValueError, configparser.Error) as exc: + print(f"FEHLER: Staubereichs-Korrekturen nicht lesbar: {exc}", file=sys.stderr) + return 1 + context = build_context(document, json_file.name, args.start, eplan=eplan, - anlage=anlage) + anlage=anlage, jam_overrides=jam_overrides) mit, ohne = template_usage(context, templates) if args.strict and ohne: print(f"FEHLER: kein Template fuer {', '.join(ohne)} (--strict) - " @@ -530,6 +548,8 @@ def main(argv: list[str] | None = None) -> int: print(f"Eingesetzt = {len(known_names(context))} Namen aus der Anlagen-JSON") if hw_file is not None: print(f"Gegenpruefung = {len(unbekannt)} unbekannte Namen ({hw_file.name})") + if jam_overrides: + print(f"Staubereiche = {len(jam_overrides)} Korrekturen aus {jam_file}") print(f"Offene Stellen = {offen} (Marker '{TODO}')") print(f"SCL = {out_file}") print("================================================================") diff --git a/lib/tro_annotate.py b/lib/tro_annotate.py index 7985ba8..c07252c 100644 --- a/lib/tro_annotate.py +++ b/lib/tro_annotate.py @@ -85,6 +85,7 @@ from __future__ import annotations import argparse import collections +import configparser import csv as csv_module import fnmatch import json @@ -120,6 +121,17 @@ from tro_flow import ( resolve_ids_file, ) from tro_ids import TroIds +from model import ANLAGE_SCHEMA, AnlageDocument, JamArea +from scl_context import ( + JAM_NAMES_SUFFIX, + HwSource, + flow_order, + jam_key, + jam_label, + jam_numbers, + load_jam_overrides, + save_jam_overrides, +) # --------------------------------------------------------------------------- # Konstanten @@ -131,12 +143,18 @@ BLOCK_PREFIX = "TRO_SYM_" # Verbindungsblock. Der Name beginnt mit CONNECTION_, damit der Dispatcher # SSG_BLOCKEDIT in dxfmakros ihn ueber "CONNECTION_*" erkennt. CONNECTION_BLOCK = "CONNECTION_ARROW" +# Staubereich (--anlage). Der Name beginnt mit JAM_, damit SSG_BLOCKEDIT ihn +# ueber "JAM_*" an den Dialog JAM_EDIT (dxfmakros/Lisp/Jam_Edit.lsp) gibt. +JAM_BLOCK = "JAM_AREA" +# Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar) +JAM_OFFEN = "Jam????_?" # Zeichnungsgroessen und Restfehler-Grenze des Lagebezugs aus cfg/parameter.ini, # Abschnitt [tro_annotate] (lib/parameter.py) DEFAULT_MARKER = get_float("tro_annotate", "default_marker") DEFAULT_TEXT = get_float("tro_annotate", "default_text") DEFAULT_MAX_RESIDUAL = get_float("tro_annotate", "default_max_residual") +JAM_VERSATZ = get_float("tro_annotate", "jam_versatz") def hex_to_rgb(value: str) -> tuple[int, int, int]: @@ -335,6 +353,7 @@ def build_layers(doc, prefix: str) -> dict[str, str]: ("weiche", "#cc8800"), ("eingang", STATION_FARBE[BOUNDARY_ENTRY]), ("ausgang", STATION_FARBE[BOUNDARY_EXIT]), + ("jam", "#7030a0"), ("legend", "#808080")): name = f"{prefix}{key.upper()}" names[key] = name @@ -702,6 +721,150 @@ def draw_connections( return drawn +def build_jam_symbol(doc, text: float) -> str: + """ + Blockdefinition fuer einen Staubereich: Rechteck, der Name mittig darin. + + Attribute: + NAME sichtbar - Jam0054_3 (Foerderer + laufende Nummer) + CONVEYOR unsichtbar - Foerderer NNNN (Kreisel-/Vario-Motor), im Dialog + NUMBER unsichtbar - laufende Nummer k auf dem Foerderer, im Dialog + TRO unsichtbar - TRO, in den der Staubereich fuehrt (TRO17) + ENTRY unsichtbar - Eingang des TRO (1, bei 2Sep-Typen auch 2) + AREA unsichtbar - Kennung der TRO-JSON (J0039) + AUTO unsichtbar - abgeleitete Nummer NNNN_k; --read-jams schreibt + nur, was davon abweicht + TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key). + """ + if JAM_BLOCK in doc.blocks: + return JAM_BLOCK + blk = doc.blocks.new(name=JAM_BLOCK) + w, h = text * 4.2, text * 1.2 + blk.add_lwpolyline([(-w, -h), (w, -h), (w, h), (-w, h)], close=True, + dxfattribs={"color": 256}) + name = blk.add_attdef("NAME", insert=(0, 0), height=text, + dxfattribs={"style": "Standard", "color": 256, + "text": JAM_OFFEN}) + name.set_placement((0, 0), align=TextEntityAlignment.MIDDLE_CENTER) + offset = -h - text + for tag_name in ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO"): + blk.add_attdef(tag_name, insert=(-w, offset), height=text * 0.7, + dxfattribs={"style": "Standard", "color": 256, "text": "", + "invisible": 1}) + offset -= text + return JAM_BLOCK + + +def _jam_position(area: JamArea, anlage: AnlageDocument) -> tuple[float, float] | None: + """Lage eines Staubereichs (CSV-Koordinaten): vor seinem Separator. + + Ausgangspunkt ist der Separator (sonst die TRO-Position); von dort + JAM_VERSATZ in Richtung des ersten Vorgaenger-TRO - gegen den Fluss, wo + die Traeger warten. Liegt der Vorgaenger naeher, nur die halbe Strecke. + """ + layout = anlage.layout + part = layout.part(area.separator) if area.separator else None + if part is not None and part.position is not None: + x, y = part.position[0], part.position[1] + elif area.position is not None: + x, y = area.position[0], area.position[1] + else: + return None + for vor in area.predecessors: + tro = layout.by_id(vor) + if tro is None or not tro.position: + continue + vx, vy = tro.position[0] - x, tro.position[1] - y + dist = math.hypot(vx, vy) + if dist > 1e-6: + schritt = min(JAM_VERSATZ, dist / 2) + return x + vx / dist * schritt, y + vy / dist * schritt + return x, y + + +def place_jams( + msp, doc, anlage: AnlageDocument, overrides: dict[str, tuple[str, int]], + transform: Transform, layers: dict[str, str], text: float, warnings: list[str], +) -> int: + """ + Je Staubereich der Anlagen-JSON einen JAM_AREA-Block einfuegen. + + Die Nummern sind dieselben wie in der SCL (scl_context.jam_numbers, dann + die Korrekturen aus _jam_names.ini). Ein Staubereich ohne Foerderer + zeigt Jam????_? - im Dialog nachtragen. + """ + layout = anlage.layout + geordnet, _grund = flow_order(layout.tros) + automatisch = jam_numbers(layout, geordnet, HwSource(anlage=anlage)) + block = build_jam_symbol(doc, text) + gezeichnet = 0 + for area in layout.jam_areas_or_derived(): + lage = _jam_position(area, anlage) + if lage is None: + warnings.append(f"Staubereich {area.jam_id} ({area.tro}): keine Lage - " + f"nicht gezeichnet") + continue + auto = automatisch.get(area.jam_id) + nr, k = overrides.get(jam_key(area), auto or ("", 0)) + ref = msp.add_blockref(block, transform.to_dxf(*lage), + dxfattribs={"layer": layers["jam"]}) + ref.add_auto_attribs({ + "NAME": jam_label(nr, k) if nr else JAM_OFFEN, + "CONVEYOR": nr, + "NUMBER": str(k) if nr else "", + "TRO": area.tro, + "ENTRY": str(area.entry), + "AREA": area.jam_id, + "AUTO": f"{auto[0]}_{auto[1]}" if auto else "", + }) + tag(ref, area.tro) + gezeichnet += 1 + return gezeichnet + + +def read_jams(dxf_file: Path, ini_file: Path) -> tuple[dict[str, tuple[str, int]], list[str]]: + """Im Dialog JAM_EDIT geaenderte Staubereichs-Nummern uebernehmen. + + Liest die JAM_AREA-Bloecke der beschrifteten Zeichnung. Weicht + CONVEYOR_NUMBER von AUTO ab, kommt der Staubereich in die Korrekturdatei + ``_jam_names.ini``; stimmt er (wieder) mit AUTO ueberein, faellt sein + Eintrag heraus. Eintraege zu Staubereichen, die nicht in der Zeichnung + stehen, bleiben. Rueckgabe: (Korrekturen danach, Meldungen). + """ + meldungen: list[str] = [] + overrides = load_jam_overrides(ini_file) + doc = ezdxf.readfile(str(dxf_file)) + namen: dict[str, str] = {} + for ref in doc.modelspace().query(f'INSERT[name=="{JAM_BLOCK}"]'): + a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs} + key = f"{a.get('TRO', '')}.{a.get('ENTRY', '')}" + nr, k = a.get("CONVEYOR", ""), a.get("NUMBER", "") + if not a.get("TRO") or not a.get("ENTRY"): + meldungen.append(f"Staubereich ohne TRO/ENTRY ({a.get('NAME', '?')}) - uebersprungen") + continue + if not nr and not k: + continue + if not (nr.isdigit() and len(nr) == 4 and k.isdigit() and int(k) > 0): + meldungen.append(f"{key} ({a.get('AREA', '')}): Foerderer '{nr}' / Nummer '{k}' " + f"ungueltig (erwartet NNNN und k >= 1) - uebersprungen") + continue + wert = f"{nr}_{int(k)}" + if wert in namen.values(): + andere = next(s for s, w in namen.items() if w == wert) + meldungen.append(f"{key}: Jam{wert} ist schon {andere} - doppelt vergeben") + namen[key] = wert + alt = overrides.get(key) + if wert == a.get("AUTO", ""): + if alt is not None: + del overrides[key] + meldungen.append(f"{key}: wieder abgeleitet (Jam{wert}) - Korrektur entfernt") + elif alt != (nr, int(k)): + overrides[key] = (nr, int(k)) + meldungen.append(f"{key}: Jam{wert} (abgeleitet: Jam{a.get('AUTO') or '-'})") + save_jam_overrides(ini_file, overrides) + return overrides, meldungen + + def draw_legend( msp, analysis: Analysis, placed: dict[str, tuple[float, float]], layers: dict[str, str], marker: float, text: float, source: str, @@ -790,6 +953,9 @@ def report( print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}") print(f"Verbindungen = {counts['flow']}" + (" (Block CONNECTION_ARROW mit FROM_UID/TO_UID)" if counts["flow"] else "")) + if counts.get("jams"): + print(f"Staubereiche = {counts['jams']} (Block {JAM_BLOCK}, Doppelklick -> " + f"JAM_EDIT; uebernehmen mit --read-jams)") if counts.get("first_id"): print(f"Interne IDs ab = {format_internal_id(counts['first_id'])} " f"(ID-Raum von SSG_LIB, siehe IDSCHECK)") @@ -820,6 +986,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: epilog="Beispiele:\n" " tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --check\n" " tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --flow --fb\n" + " tro_annotate.bat --file dematic_merge.csv --dxf dematic_merge..dxf --flow " + "--anlage dematic_merge_anlage.json\n" + " tro_annotate.bat --file dematic_merge.csv --read-jams\n" "\n" "Exit-Codes:\n" " 0 ok\n" @@ -855,6 +1024,17 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--re-register", action="store_true", help="Lagebezug neu ermitteln, auch wenn in %%SKEL_CFG%% " "einer abgelegt ist.") + parser.add_argument("--anlage", metavar="NAME", + help="Anlagen-JSON (_anlage.json aus merge_hw_layout, in " + "%%SKEL_RESULTS%% oder ein Pfad): zusaetzlich die " + "Staubereiche als JAM_AREA-Bloecke einzeichnen " + "(Jam_, Doppelklick -> Dialog JAM_EDIT).") + parser.add_argument("--read-jams", nargs="?", const="", default=None, metavar="DXF", + help="Im Dialog JAM_EDIT geaenderte Staubereichs-Nummern aus der " + "beschrifteten Zeichnung uebernehmen (Standard: " + "_annotated.dxf in %%SKEL_RESULTS%%) und nach " + "%%SKEL_CFG%%\\_jam_names.ini schreiben, dann beenden. " + "scl_skeleton liest die Datei.") parser.add_argument("--check", action="store_true", help="Nur pruefen und berichten, keine Datei schreiben.") parser.add_argument("--emit-lisp", action="store_true", @@ -863,6 +1043,38 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return parser.parse_args(argv) +def _main_read_jams(args, csv_file: Path) -> int: + """--read-jams: Nummern aus der beschrifteten Zeichnung -> _jam_names.ini.""" + results = env_dir("SKEL_RESULTS", "results") + name = args.read_jams or args.out or f"{csv_file.stem}_annotated.dxf" + dxf_file = Path(name) + if not dxf_file.is_absolute() and dxf_file.parent == Path("."): + dxf_file = results / name + if not dxf_file.is_file(): + print(f"FEHLER: beschriftete Zeichnung nicht gefunden: {dxf_file}", file=sys.stderr) + print(" In BricsCAD als DXF speichern (Dateiname wie beim Beschriften).", + file=sys.stderr) + return 1 + ini_file = env_dir("SKEL_CFG", "cfg") / f"{csv_file.stem}{JAM_NAMES_SUFFIX}" + try: + overrides, meldungen = read_jams(dxf_file, ini_file) + except (OSError, ValueError, configparser.Error, ezdxf.DXFError) as exc: + print(f"FEHLER: {exc}", file=sys.stderr) + return 1 + print("") + print("================================================================") + print("STAUBEREICHE UEBERNEHMEN") + print("================================================================") + print(f"Zeichnung = {dxf_file}") + print(f"Korrekturen = {len(overrides)} -> {ini_file if overrides else '- (keine, Datei entfernt)'}") + for m in meldungen: + print(f" {m}") + print("Danach: scl_skeleton mit der Anlagen-JSON neu laufen lassen.") + print("================================================================") + print("") + return 0 + + def main(argv: list[str] | None = None) -> int: args = parse_args(argv) warnings: list[str] = [] @@ -875,16 +1087,19 @@ def main(argv: list[str] | None = None) -> int: "SSG_LIB-Module (dxfmakros/Lisp), damit TRO_EDIT sie findet.") return 0 - if not args.dxf: - print("FEHLER: --dxf fehlt (nur --emit-lisp geht ohne Zeichnung).", - file=sys.stderr) - return 1 - csv_file = resolve_input(args.file) if csv_file.suffix.lower() != ".csv": print(f"FEHLER: --file erwartet eine CSV-Datei, keine '{csv_file.suffix}'-Datei: " f"{csv_file}", file=sys.stderr) return 1 + + if args.read_jams is not None: + return _main_read_jams(args, csv_file) + + if not args.dxf: + print("FEHLER: --dxf fehlt (nur --emit-lisp und --read-jams gehen ohne " + "Zeichnung).", file=sys.stderr) + return 1 if not csv_file.is_file(): print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr) return 1 @@ -904,6 +1119,29 @@ def main(argv: list[str] | None = None) -> int: cfg_dir = env_dir("SKEL_CFG", "cfg") results.mkdir(parents=True, exist_ok=True) + # Anlagen-JSON fuer die Staubereiche (optional) + anlage = None + jam_overrides: dict[str, tuple[str, int]] = {} + if args.anlage: + anlage_file = Path(args.anlage) + if not anlage_file.is_absolute() and anlage_file.parent == Path("."): + anlage_file = results / args.anlage + try: + text = anlage_file.read_text(encoding="utf-8") + if json.loads(text).get("schema") != ANLAGE_SCHEMA: + raise ValueError(f"kein Schema '{ANLAGE_SCHEMA}' - erwartet wird die " + f"Anlagen-JSON aus merge_hw_layout") + anlage = AnlageDocument.from_json(text) + jam_overrides = load_jam_overrides( + cfg_dir / f"{csv_file.stem}{JAM_NAMES_SUFFIX}") + except (OSError, ValueError, configparser.Error) as exc: + print(f"FEHLER: Anlagen-JSON {anlage_file} nicht lesbar: {exc}", file=sys.stderr) + return 1 + if Path(anlage.layout.source).name != csv_file.name: + print(f"FEHLER: {anlage_file.name} gehoert zu '{anlage.layout.source}', " + f"nicht zu '{csv_file.name}'.", file=sys.stderr) + return 1 + # TROs aus dem CSV read_warnings: list[str] = [] try: @@ -955,7 +1193,16 @@ def main(argv: list[str] | None = None) -> int: return 4 counts = {"marker": 0, "flow": 0, "removed": 0, "layers": 0, "blocks": 0, - "first_id": 0} + "first_id": 0, "jams": 0} + if anlage is not None: + aus_csv = {t.tro_id for t in analysis.tros} + aus_json = {t.tro_id for t in anlage.layout.tros} + if aus_csv != aus_json: + warnings.append( + f"TROs der Zeichnung und der Anlagen-JSON weichen ab (nur CSV: " + f"{', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: " + f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - tro_flow --json und " + f"merge_hw_layout neu laufen lassen") if args.check: counts["marker"] = sum(1 for t in analysis.tros if t.position) @@ -986,6 +1233,9 @@ def main(argv: list[str] | None = None) -> int: msp, doc, analysis, {**placed, **stationen}, internal, layers, args.marker_size, args.text_height, next_id=next_id + len(placed), ) + if anlage is not None: + counts["jams"] = place_jams(msp, doc, anlage, jam_overrides, transform, layers, + args.text_height, warnings) if args.legend: draw_legend(msp, analysis, placed, layers, args.marker_size, args.text_height, csv_file.name) diff --git a/tests/test_tro_annotate_jams.py b/tests/test_tro_annotate_jams.py new file mode 100644 index 0000000..21f3da2 --- /dev/null +++ b/tests/test_tro_annotate_jams.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +"""Tests fuer die Staubereiche in tro_annotate.py (--anlage, --read-jams). + +Kleine Anlage: ein Kreisel mit zwei TROs hintereinander (K0042). Die Bloecke +JAM_AREA tragen Name, Foerderer, Nummer und den stabilen Schluessel TRO + +Eingang; --read-jams uebernimmt im Dialog JAM_EDIT geaenderte Nummern nach +_jam_names.ini, und scl_context setzt sie in die SCL ein. +""" + +import ezdxf + +import tro_annotate +from dxf_registration import Transform +from model import ( + AnlageDocument, + Analysis, + ConveyorBinding, + DerivedTro, + LayoutPart, + TroDocument, +) +from scl_context import build_context, load_jam_overrides + +IDENT = Transform(dx=0.0, dy=0.0) + + +def _tro(tro_id, sep, x, vor, nach): + return DerivedTro(tro_id=tro_id, type_name="1Sep", label=tro_id, hosts=["0004-L"], + separators=[sep], scanners=[], reason="Test", + predecessors=vor, successors=nach, position=(x, 0.0, 0.0)) + + +def anlage() -> AnlageDocument: + tros = [_tro("TRO01", "0101", 0.0, ["SYS1"], ["TRO02"]), + _tro("TRO02", "0102", 5000.0, ["TRO01"], ["SYS2"])] + parts = [ + LayoutPart(layout_id="0004", kind="Kreisel", name="Kreisel4", + position=(2500.0, 0.0, 0.0), bbox=(8000.0, 800.0, 1.0)), + LayoutPart(layout_id="0101", kind="Separator", name="S1", position=(0.0, 0.0, 0.0)), + LayoutPart(layout_id="0102", kind="Separator", name="S2", position=(5000.0, 0.0, 0.0)), + ] + layout = TroDocument.from_analysis(Analysis(tros=tros), source="x.csv", parts=parts) + return AnlageDocument(layout=layout, conveyors=[ + ConveyorBinding(eplan_name="K0042", layout_id="0004", motor="MA0042")]) + + +def _zeichnen(overrides=None): + doc = ezdxf.new() + tro_annotate.ensure_appid(doc) + layers = tro_annotate.build_layers(doc, tro_annotate.LAYER_PREFIX) + warnings: list[str] = [] + n = tro_annotate.place_jams(doc.modelspace(), doc, anlage(), overrides or {}, IDENT, + layers, 200.0, warnings) + return doc, n, warnings + + +def _jams(doc) -> dict[str, dict]: + out = {} + for ref in doc.modelspace().query(f'INSERT[name=="{tro_annotate.JAM_BLOCK}"]'): + a = {t.dxf.tag: t.dxf.text for t in ref.attribs} + a["pos"] = (round(ref.dxf.insert.x), round(ref.dxf.insert.y)) + out[a["TRO"]] = a + return out + + +def test_staubereiche_als_block_mit_name_und_schluessel(): + doc, n, warnings = _zeichnen() + assert n == 2 and warnings == [] + jams = _jams(doc) + assert {k: v["NAME"] for k, v in jams.items()} == {"TRO01": "Jam0042_1", + "TRO02": "Jam0042_2"} + t2 = jams["TRO02"] + assert (t2["CONVEYOR"], t2["NUMBER"], t2["ENTRY"], t2["AREA"], t2["AUTO"]) == ( + "0042", "2", "1", "J0102", "0042_2") + # vor dem Separator, Richtung Vorgaenger TRO01 (gegen den Fluss) + assert t2["pos"] == (5000 - round(tro_annotate.JAM_VERSATZ), 0) + # TRO01 hat nur eine Station als Vorgaenger: am Separator + assert jams["TRO01"]["pos"] == (0, 0) + + +def test_korrektur_wird_gezeichnet(): + doc, _, _ = _zeichnen({"TRO02.1": ("0042", 7)}) + t2 = _jams(doc)["TRO02"] + assert (t2["NAME"], t2["NUMBER"], t2["AUTO"]) == ("Jam0042_7", "7", "0042_2") + + +def test_geaenderte_nummer_zurueck_in_die_korrekturdatei(tmp_path): + doc, _, _ = _zeichnen() + # wie der Dialog JAM_EDIT: NUMBER und NAME neu + for ref in doc.modelspace().query(f'INSERT[name=="{tro_annotate.JAM_BLOCK}"]'): + a = {t.dxf.tag: t for t in ref.attribs} + if a["TRO"].dxf.text == "TRO02": + a["NUMBER"].dxf.text = "7" + a["NAME"].dxf.text = "Jam0042_7" + dxf = tmp_path / "x_annotated.dxf" + doc.saveas(dxf) + ini = tmp_path / "x_jam_names.ini" + + overrides, meldungen = tro_annotate.read_jams(dxf, ini) + assert overrides == {"TRO02.1": ("0042", 7)} + assert load_jam_overrides(ini) == overrides + assert any("TRO02.1: Jam0042_7" in m for m in meldungen) + + # in der SCL: Ein- und Ausgang tragen die korrigierte Nummer + a = anlage() + ctx = build_context(a.layout, "x_anlage.json", anlage=a, jam_overrides=overrides) + tro1, tro2 = ctx.tros + assert tro2.jam_entries[0].db.value == '"DB_JamArea0042".stJam0042_7' + assert tro1.jam_exits[0].db.value == '"DB_JamArea0042".stJam0042_7' + + # zurueck auf die abgeleitete Nummer -> Eintrag (und Datei) weg + for ref in doc.modelspace().query(f'INSERT[name=="{tro_annotate.JAM_BLOCK}"]'): + a2 = {t.dxf.tag: t for t in ref.attribs} + if a2["TRO"].dxf.text == "TRO02": + a2["NUMBER"].dxf.text = "2" + doc.saveas(dxf) + overrides, _ = tro_annotate.read_jams(dxf, ini) + assert overrides == {} and not ini.exists() + + +def test_ungueltige_nummer_wird_nicht_uebernommen(tmp_path): + doc, _, _ = _zeichnen() + for ref in doc.modelspace().query(f'INSERT[name=="{tro_annotate.JAM_BLOCK}"]'): + a = {t.dxf.tag: t for t in ref.attribs} + if a["TRO"].dxf.text == "TRO01": + a["CONVEYOR"].dxf.text = "42" + dxf = tmp_path / "x_annotated.dxf" + doc.saveas(dxf) + overrides, meldungen = tro_annotate.read_jams(dxf, tmp_path / "x_jam_names.ini") + assert overrides == {} + assert any("TRO01.1" in m and "ungueltig" in m for m in meldungen)