diff --git a/CLAUDE.md b/CLAUDE.md index d1dc3a5..c9097ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,12 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha 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. + into `cfg/_jam_names.ini`, which `scl_skeleton` applies. TRO markers carry `KEY` and + `SLOTS` (part per slot); the dxfmakros dialog `TRO_EDIT`/`TRO_INSERT` (one dialog for all + types, rows from `*TRO-SLOTS*`, pick-in-drawing from the XRECORD `SPS_SKEL_PARTS`) edits + them, and `--read-tros` writes the differences as `edit`/`edit_add` sections into + `cfg/tro_overrides.ini` — the only kinds of that file `tro_flow` applies + (`tro_flow.apply_edits`, keyed on the stable ID-file IDs). 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/cad/tro_types.lsp b/cad/tro_types.lsp index 9ad0faa..4938452 100644 --- a/cad/tro_types.lsp +++ b/cad/tro_types.lsp @@ -1,4 +1,28 @@ ;; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp -;; Quelle: TRO_CATALOG in lib/tro_catalog.py - nicht manuell aendern. +;; Quelle: TRO_CATALOG in lib/tro_catalog.py, Slots aus lib/model.py +;; (slot_names, SLOT_PART_KINDS) - nicht manuell aendern. (setq *TRO-TYPES* (list "1Sep" "1Sep1Swi" "1Sep2Swi" "1Sep_SSCC" "Vario" "PinStore_Auto" "Vario_workStation" "EmptyCarrBuffer" "LoadingBoom" "2Sep1Swi" "2Sep2Swi" "3Sep2Swi")) +;; (Typ Lagerlinien-Vorgabe Slot ...) - Lagerlinien heissen line1..lineN +(setq *TRO-SLOTS* '( + ("1Sep" 0 "separator1") + ("1Sep1Swi" 0 "separator1" "switch1") + ("1Sep2Swi" 0 "separator1" "switch1" "switch2" "scanner1") + ("1Sep_SSCC" 0 "separator1" "scanner1") + ("Vario" 0 "separator1" "drive") + ("PinStore_Auto" 19 "separator1" "scanner1") + ("Vario_workStation" 0 "separator1" "scanner1" "drive") + ("EmptyCarrBuffer" 5) + ("LoadingBoom" 0) + ("2Sep1Swi" 0 "separator1" "separator2" "switch1" "scanner1" "scanner2") + ("2Sep2Swi" 0 "separator1" "separator2" "switch1" "switch2" "scanner1" "scanner2") + ("3Sep2Swi" 0 "separator1" "separator2" "separator3" "switch1" "switch2" "scanner1" "scanner2" "scanner3") +)) +;; (Slot-Art Bauteilart ...) - Pruefung beim Waehlen im Plan +(setq *TRO-SLOT-KINDS* '( + ("separator" "Separator") + ("line" "Separator") + ("switch" "Ausschleus" "Weiche") + ("scanner" "Scanner") + ("drive" "Strecke" "Gefaellestrecke" "Kreisel") +)) (princ) diff --git a/cfg/parameter.ini b/cfg/parameter.ini index 014ce3e..6563b4e 100644 --- a/cfg/parameter.ini +++ b/cfg/parameter.ini @@ -179,16 +179,17 @@ id_min_share = 0.8 [tro_annotate] ; Zeichnungsgroessen, wenn nicht per --marker-size/--text-height angegeben. -default_marker = 300.0 -default_text = 220.0 +; So klein wie lesbar: ID = 1,35 x Text (~160 mm), Typ ~120 mm. +default_marker = 150.0 +default_text = 120.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 +; Staubereich (--anlage): stehendes graues Rechteck direkt vor seinem TRO, +; auf der Seite des Vorgaenger-TRO (gegen den Fluss) - dort warten die Traeger. +; Luft zwischen Markerrand und Rechteck (mm). +jam_abstand = 60.0 [merge_hw_layout] diff --git a/doc/Python_Scripts.md b/doc/Python_Scripts.md index cedee50..421a726 100644 --- a/doc/Python_Scripts.md +++ b/doc/Python_Scripts.md @@ -89,9 +89,10 @@ Supporting libraries (no CLI of their own, imported by the tools above): reads it, finds each TRO again by the position of its separators, assigns new IDs, writes/extends the file. Used by `tro_flow.py` (read and write) and `tro_annotate.py` (read only). See §4, "TRO-IDs". -- **`lib/tro_overrides.py`** — reads the hand-maintained - `%SKEL_CFG%\tro_overrides.ini`; only `material_flow.py --connect` still uses - it. See §6a. +- **`lib/tro_overrides.py`** — reads `%SKEL_CFG%\tro_overrides.ini`. The + hand-maintained kinds are only used by `material_flow.py --connect`; the + BricsCAD corrections (`edit`/`edit_add`, written by `tro_annotate.py + --read-tros`) are applied by `tro_flow.py` and `tro_annotate.py`. See §6a. Separate, documentation-only tool (not part of the generator pipeline): @@ -367,9 +368,14 @@ colors all come from `lib/tro_catalog.py` — nothing is hard-coded here. | `--renumber` | — | off | Number all TROs afresh along the material flow and rewrite the `[tro]` section of the ID file (early project phase). Renamed IDs are lost; other sections of the file are kept. | | `--id-tolerance` | `MM` (float) | `100.0` | Maximum distance between an ID-file entry and a separator of a TRO for the TRO to count as found again. | -TROs, types and connections always come from the layout alone; -`cfg/tro_overrides.ini` is not read. Errors in the derivation are fixed in -`lib/tro_flow.py`, not in a correction file. +TROs, types and connections come from the layout. Errors in the derivation +are fixed in `lib/tro_flow.py`, not in a correction file. The one exception +(since 2026-10-26) are the corrections the SPS programmers make in BricsCAD +(§5, "Editing TROs in BricsCAD"): `tro_flow` reads the `edit`/`edit_add` +sections of `cfg/tro_overrides.ini` and applies them right after the +derivation (`apply_edits`, before positions and links), so the flow diagram, +the TRO-JSON and the SCL show the corrected TROs. All other kinds in that file +are still ignored here. **TRO-IDs** (`lib/tro_ids.py`). The IDs belong to the SPS programmers and must survive a new layout export; the layout `TeileId` changes between exports, the @@ -419,7 +425,12 @@ with: triangle, square, diamond, …; see `TroSymbol` in `tro_catalog.py`) so types are distinguishable even without color; - visible attributes `ID`, `TYPE` (and `FB_BLOCK` with `--fb`); hidden - attributes `ITEMS`, `CONFIDENCE`, `SEPARATORS` for downstream tooling; + attributes `UID`, `KEY` (the TRO's key in `tro_overrides.ini`: its ID in + the ID file), `ITEMS`, `CONFIDENCE` and `SLOTS` (the part per slot, + `separator1=0010;switch1=0061;lines=0066,0069`); +- the parts of the export that fit a slot (TeileId, kind, drawing position, + half bounding box) as XRECORD `SPS_SKEL_PARTS` in the named object + dictionary, for the dialog's "pick in drawing" button; - optional directional flow arrows between TROs (`--flow`) and a legend (`--legend`). @@ -428,13 +439,39 @@ own `TRO_`-prefixed layers and carries XDATA under the app id `SPS_SKEL_TRO`; a re-run removes exactly those tagged objects (not by layer name) and rebuilds them, so hand-drawn content on the same layers survives. -Once at least one marker of a given type has been annotated, the `dxfmakros` -BricsCAD menu (`SSG_LIB > TRO`) can insert further markers of that type by -hand — `TRO_INSERT` reuses the `TRO_SYM_` block definition this tool -created and only asks for a point, an id and (optionally) a different type; -`TRO_EDIT` changes `ID`/`TYPE` on an existing marker. Neither command draws -new shapes — a type with no prior annotated instance in the drawing must be -run through this tool at least once first. +**Editing TROs in BricsCAD (since 2026-10-26).** The `dxfmakros` commands +`TRO_EDIT` (double-click on a marker) and `TRO_INSERT` (`SSG_LIB > TRO`) +share **one** dialog for all types (`dcl/tro_edit.dcl`): ID, type, and a list +with one row per slot of the type (`separator`, `switch`, `scanner`, +`drive`, storage lines `line1..N`) and its part (TeileId). The rows come from +the type (`*TRO-SLOTS*` in `tro_types.lsp`, written by `--emit-lisp` from +`model.slot_names`), so there is no mask per type; on a type change, slots +of the same name keep their part. Below the list: edit the selected slot's +TeileId, **"Im Plan waehlen <"** (closes the dialog, takes a point, sets the +nearest part of a kind that fits the slot — `model.SLOT_PART_KINDS` — from +the `SPS_SKEL_PARTS` table) and +/- for storage lines (the catalog count is +only the default). Empty separators, parts used twice or in another marker +show as a warning; saving is still allowed. `TRO_INSERT` reuses a +`TRO_SYM_` block definition this tool created and leaves `KEY` empty +(= new TRO); a type with no annotated instance in the drawing must be run +through this tool once first. Deleting a marker deletes the TRO. + +Back into the pipeline: save the drawing as DXF and run `--read-tros`. It +compares every marker with the derivation **without** corrections: a marker +whose `KEY` is a derived TRO becomes `[.edit.]` if type, ID (→ +`name =`) or parts differ; a marker without such a key becomes +`[.edit_add.]`; a derived TRO with a position but no marker becomes +`delete = true`. Each part is written as `TeileId @ x y` (CSV position) — the +TeileId changes between exports, the position does not, so `apply_edits` +falls back to the nearest part of the same kind at that spot. The sections +replace all earlier `edit`/`edit_add` sections of the CSV in +`tro_overrides.ini` (a `reason =` added by hand is kept); the rest of the file +stays line for line. The next `tro_flow` / `tro_annotate` run applies them +(§4); a re-read of the new drawing gives the same sections. `--check` only +reports. If the drawing has no marker of the derived TROs (wrong file, or +annotated before the `KEY`/`SLOTS` attributes), nothing is written — +re-annotating an old drawing adds the missing attributes to the existing +block definitions. CSV and DXF live in different coordinate systems; the offset between them is handled by `dxf_registration.py`: by TeileId when the drawing's blocks carry @@ -450,22 +487,26 @@ verified on every run, and cached per CSV/DXF filename pair in | `--flow` | — | off | Draw directional flow arrows between the TROs. | | `--fb` | — | off | Show the Siemens FB block name as a visible attribute on the marker. | | `--legend` | — | off | Draw a legend of the color groups. | -| `--marker-size` | `MM` (float) | `300.0` | Marker radius in mm. | -| `--text-height` | `MM` (float) | `220.0` | Text height in mm. | +| `--marker-size` | `MM` (float) | `150.0` | Marker radius in mm (`default_marker` in `cfg/parameter.ini`). | +| `--text-height` | `MM` (float) | `120.0` | Text height in mm (`default_text`; the ID is 1.35×). | | `--ids` | `DATEI` | `_tro_ids.ini` | ID file as for `tro_flow.py`, **read only**: the markers carry the same IDs as the TRO-JSON. Without the file the TROs are numbered along the material flow, as `tro_flow.py` would. | | `--offset` | `DX,DY` | — | Set the CSV↔DXF offset by hand instead of deriving/loading it. For a drawing built with dxfmakros (CSV exported from the same drawing) this is `0,0`. | | `--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`. | +| `--emit-lisp` | — | off | Regenerate `cad/tro_types.lsp` for the `TRO_INSERT`/`TRO_EDIT` dialog — type list (`*TRO-TYPES*`), slots per type with the storage-line default (`*TRO-SLOTS*`), part kinds per slot (`*TRO-SLOT-KINDS*`) — from `tro_catalog.py`/`model.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. | +| `--read-tros` | `[DXF]` | `_annotated.dxf` | Read the TRO markers edited in BricsCAD (`TRO_EDIT`/`TRO_INSERT`, deleted markers) back from the annotated drawing (saved as DXF) into the `edit`/`edit_add` sections of `%SKEL_CFG%\tro_overrides.ini`, then exit — no `--dxf` needed. With `--check` only reported. | **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), +JSON on layer `TRO_JAM`: an upright grey rectangle with the name written +vertically inside (`Jam0054_3`, the same name the SCL uses — +`scl_context.jam_numbers` plus the corrections file). It sits directly in +front of its TRO marker — marker radius + `jam_abstand` mm (`cfg/parameter.ini +[tro_annotate]`) from the marker — towards the predecessor TRO (against the +flow, where the carriers wait; with only a station before it, opposite to the +successor). A second entry (2Sep types) sits next to the first. Only the +position is derived this way; connections and flow direction are untouched. 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: @@ -478,8 +519,9 @@ 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. +Layers always carry the prefix `TRO_`. Like `tro_flow.py`, it reads only the +`edit`/`edit_add` sections of `cfg/tro_overrides.ini`, so the markers always +match the flow diagram and the TRO-JSON. **Output** (in `%SKEL_RESULTS%`, unless `--check`): `_annotated.dxf`, `_registration.json` (the coordinate transform used, with its proof). @@ -526,7 +568,9 @@ drawing is the current state, not the export. It finds the objects `tro_annotate.py` tagged with XDATA (AppID `SPS_SKEL_TRO`): the `TRO_SYM_` block references (attributes `ID`/`TYPE`/`ITEMS`/`CONFIDENCE`/`SEPARATORS`, plus `FB_BLOCK` when annotated -with `--fb`), the flow arrows (`"->"`), and the open-point markers +with `--fb`; since 2026-10-26 the markers carry `SLOTS` instead of +`SEPARATORS`, so the separators come back empty — the way back from the +drawing is now `tro_annotate --read-tros`), the flow arrows (`"->"`), and the open-point markers (`"OFFEN:"`). Each TRO also gets `predecessors`/`successors` computed from the arrows. @@ -562,7 +606,10 @@ renamed key, or `items`/`fbType` that do not match the TRO type in no SCL is written. A file with a different schema (e.g. the old `tro_extract.py` output) gets a short message asking for `tro_flow --json`. Part counts (`items`) and the FB type are taken from the catalog via the TRO -type. +type — except the storage lines (since 2026-10-26): the catalog count (19 for +`PinStore_Auto`) is only the default; a TRO with storage lines counts its own +(layout or BricsCAD correction), so the Mubea store shows 20. A TRO-JSON +written before still reads (the catalog count is accepted too). Produces a **skeleton, not a running program**: one `REGION` per TRO with the instance call and every parameter line in the right order, following the real @@ -1037,11 +1084,42 @@ wrong keys/items, SCL rendering from a document) — run with ## 6a. `tro_overrides.py` — manual corrections to the derived TROs (library, no CLI) -> **Since 2026-10-05 only `material_flow.py --connect` reads this file.** -> `tro_flow.py` and `tro_annotate.py` no longer apply corrections: the sections -> were keyed on running TRO numbers and went stale with every new export. -> Errors in the TRO derivation are fixed in `lib/tro_flow.py` instead. The -> format below is kept for `material_flow.py`. +> **Since 2026-10-05 only `material_flow.py --connect` reads the hand-written +> kinds below** (`tro`, `split`, `merge`, `openpoint`, `connections`). +> `tro_flow.py` and `tro_annotate.py` no longer apply them: the sections were +> keyed on running TRO numbers and went stale with every new export. Errors in +> the TRO derivation are fixed in `lib/tro_flow.py` instead. +> +> **Since 2026-10-26 the file also holds the BricsCAD corrections** (kinds +> `edit`, `edit_add`), and those *are* applied by `tro_flow.py` and +> `tro_annotate.py` (`tro_flow.apply_edits`). They are keyed on the ID from +> `_tro_ids.ini`, which stays stable across exports, and their parts carry +> a position. They are written by `tro_annotate.py --read-tros` (see §5), +> not by hand: +> +> ```ini +> [mubea.csv.edit.TRO05] ; derived TRO, key = ID in the ID file +> type = 1Sep1Swi ; omitted = as derived +> name = Einschleus_K2 ; new ID (the ID file keeps TRO05) +> separator1 = 0012 @ -400.0 -3554.9 ; part per slot: TeileId @ x y (CSV) +> switch1 = - ; slot deliberately empty +> lines = 0066 @ 958.0 12300.0, 0069 @ 958.0 11500.0 ; storage lines +> reason = ... ; may be added by hand, survives re-reads +> +> [mubea.csv.edit.TRO19] +> delete = true +> +> [mubea.csv.edit_add.TRO99] ; new TRO from TRO_INSERT +> type = 1Sep +> separator1 = 0036 @ 1475.0 -5078.0 +> ``` +> +> If slot keys are present they are the complete set (missing slots stay +> empty); without them the parts stay as derived. Order of application: +> deletes, edits, adds, renames last. A part given to an edited/added TRO is +> taken from the TRO that had it; a TRO left without its separators is dropped +> (finding). Renamed and added TROs are not written under their new ID into +> the ID file (`Analysis.renamed`/`added`). `tro_flow.py` derives TRO types from the mechanical layout alone. Some things are simply not in the CSV export: a switch that the export does not carry, a diff --git a/lib/model.py b/lib/model.py index bdbe6e7..4b1c3a2 100644 --- a/lib/model.py +++ b/lib/model.py @@ -82,6 +82,10 @@ __all__ = [ "derive_slots", "derive_jam_areas", "storage_lines", + "SLOT_PART_KINDS", + "slot_kind", + "slot_names", + "has_lines", ] # Kennung der TRO-JSON. v1 war die Ausgabe von tro_extract (aus der Zeichnung), @@ -204,9 +208,17 @@ class TroBase(_Positioned): @computed_field # type: ignore[prop-decorator] @property def items(self) -> dict[str, int]: - """Bauteile laut Katalog, z. B. {'Separator': 1} ({} wenn Typ unbekannt).""" + """Bauteile laut Katalog, z. B. {'Separator': 1} ({} wenn Typ unbekannt). + + Ausnahme Lagerlinien: der Katalog nennt nur die Vorgabe (PinStore_Auto + 19); hat der TRO Linien, gilt deren Zahl (Layout oder Handkorrektur im + Dialog TRO_EDIT). + """ definition = self.definition() - return definition.get_items() if definition else {} + if not definition: + return {} + return _with_line_count(definition.get_items(), self.separators, + getattr(self, "hosts", [])) @model_validator(mode="before") @classmethod @@ -219,9 +231,14 @@ class TroBase(_Positioned): fb_block = data.pop("fbType", data.pop("fb_block", None)) type_name = data.get("type", data.get("type_name")) definition = get_tro(type_name) if isinstance(type_name, str) else None - expected_items = definition.get_items() if definition else {} + expected_items = (_with_line_count(definition.get_items(), + data.get("separators", []), + data.get("hosts", [])) + if definition else {}) + # Dateien vor 2026-10-26 tragen bei Lagerlinien die Katalogvorgabe + catalog_items = definition.get_items() if definition else {} expected_fb = definition.get_fb_block() if definition else "" - if items is not None and dict(items) != expected_items: + if items is not None and dict(items) not in (expected_items, catalog_items): raise ValueError( f"items {items} passt nicht zum Typ '{type_name}' laut tro_catalog " f"({expected_items}) - den Typ aendern, nicht die Bauteile" @@ -381,6 +398,11 @@ class Analysis(BaseModel): # Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein # TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird. boundaries: list[DerivedTro] = Field(default_factory=list) + # Handkorrekturen aus BricsCAD (tro_flow.apply_edits): umbenannte TROs + # (neue ID -> ID aus _tro_ids.ini) und von Hand angelegte TROs - beide + # gehoeren nicht unter ihrer neuen Kennung in die ID-Datei. + renamed: dict[str, str] = Field(default_factory=dict) + added: list[str] = Field(default_factory=list) def by_id(self, tro_id: str) -> DerivedTro | None: for tro in self.tros + self.boundaries: @@ -388,6 +410,11 @@ class Analysis(BaseModel): return tro return None + def edit_key(self, tro_id: str) -> str: + """Schluessel des TRO in tro_overrides.ini (ID der ID-Datei bzw. Kennung + des von Hand angelegten TRO).""" + return self.renamed.get(tro_id, tro_id) + class TroDocument(BaseModel): """ @@ -613,6 +640,12 @@ def _at(values: list[str], index: int) -> str: return values[index] if index < len(values) else "" +def _lines_of(separators: list[str], hosts: list[str]) -> list[str]: + seps, hosts = list(separators or []), list(hosts or []) + count = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps) + return seps[len(seps) - count:] + + def storage_lines(tro: TroBase) -> list[str]: """Separatoren der Lagerlinien eines Speicher-TRO. @@ -620,10 +653,55 @@ def storage_lines(tro: TroBase) -> list[str]: letzten len(hosts) (eine je Speicherbahn). Fehlen Hosts, zaehlt jeder Separator als Linie - dieselbe Regel wie scl_context._store. """ - hosts = list(getattr(tro, "hosts", []) or []) - seps = list(tro.separators) - count = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps) - return seps[len(seps) - count:] + return _lines_of(tro.separators, getattr(tro, "hosts", [])) + + +def _with_line_count(items: dict[str, int], separators: list[str], + hosts: list[str]) -> dict[str, int]: + """Katalog-Bauteile, Lagerlinien auf die Zahl der Linien des TRO gesetzt.""" + if items.get(TroItem.STORAGE_LINE) and (lines := _lines_of(separators, hosts)): + items = {**items, TroItem.STORAGE_LINE: len(lines)} + return items + + +# Slot-Art (Name ohne Nummer) -> Bauteilarten (Element.kind im CSV), die in +# diesen Slot passen. Gebraucht vom Dialog TRO_EDIT (Pruefung beim Waehlen im +# Plan, tro_annotate --emit-lisp) und beim Anwenden der Handkorrekturen +# (tro_flow.apply_edits). "drive" ist der tragende Foerderer. +SLOT_PART_KINDS: dict[str, tuple[str, ...]] = { + "separator": ("Separator",), + "line": ("Separator",), + "switch": ("Ausschleus", "Weiche"), + "scanner": ("Scanner",), + "drive": ("Strecke", "Gefaellestrecke", "Kreisel"), +} + + +def slot_kind(slot: str) -> str: + """'separator2' -> 'separator', 'line17' -> 'line', 'drive' -> 'drive'.""" + return slot.rstrip("0123456789") + + +def slot_names(type_name: str) -> list[str]: + """Slots eines Typs in der Reihenfolge von derive_slots, ohne Lagerlinien. + + Die Lagerlinien (line1..lineN) haengen am TRO, nicht am Typ - ob ein Typ + sie hat, sagt :func:`has_lines`. + """ + definition = get_tro(type_name) + items = definition.get_items() if definition else {} + names = [f"separator{i}" for i in range(1, items.get(TroItem.SEPARATOR, 0) + 1)] + names += [f"switch{i}" for i in range(1, items.get(TroItem.SWITCH, 0) + 1)] + names += [f"scanner{i}" for i in range(1, items.get(TroItem.SCANNER, 0) + 1)] + if items.get(TroItem.VARIO_DRIVE): + names.append("drive") + return names + + +def has_lines(type_name: str) -> int: + """Vorgabe der Lagerlinien laut Katalog (0 = Typ hat keine).""" + definition = get_tro(type_name) + return definition.get_items().get(TroItem.STORAGE_LINE, 0) if definition else 0 def derive_slots(tro: DerivedTro) -> list[Slot]: diff --git a/lib/tro_annotate.py b/lib/tro_annotate.py index 86beffa..d06d7ac 100644 --- a/lib/tro_annotate.py +++ b/lib/tro_annotate.py @@ -35,9 +35,24 @@ Typen auch ohne Farbe unterscheiden. Die Daten stehen als Attribute am Block: TYPE 1Sep sichtbar, im Dialog bearbeitbar FB_BLOCK FB_ILS_... sichtbar nur mit --fb UID 0062 unsichtbar, dauerhafte interne Nummer + KEY TRO03 unsichtbar, Schluessel in tro_overrides.ini (ID der + ID-Datei; leer bei einem mit TRO_INSERT gesetzten) ITEMS 1x Separator unsichtbar, reine Daten CONFIDENCE mittel unsichtbar - SEPARATORS 0010 unsichtbar + SLOTS separator1=0010;switch1=0061 unsichtbar, Bauteil je Slot + (TeileId; Lagerlinien als lines=0066,0069,...), + im Dialog TRO_EDIT bearbeitbar + +Handkorrekturen (--read-tros) +----------------------------- +Im Dialog TRO_EDIT (dxfmakros) aendern die SPS-Programmierer Typ, Kennung und +die Bauteile je Slot; TRO_INSERT setzt neue Marker, geloeschte Marker fallen +weg. Fuer "Im Plan waehlen" legt die Beschriftung die Bauteile des Exports +(TeileId, Art, Lage in der Zeichnung) als XRECORD SPS_SKEL_PARTS in das +Named Object Dictionary. --read-tros vergleicht die Marker mit der Ableitung +und schreibt die Abweichungen als Abschnitte edit/edit_add nach +cfg/tro_overrides.ini; tro_flow und tro_annotate wenden sie beim naechsten +Lauf an (tro_flow.apply_edits). Zwei Kennungen, mit Absicht: ID die sprechende Kennung (TRO03). Darf sich aendern - der Dialog TRO_EDIT @@ -122,7 +137,19 @@ from tro_flow import ( resolve_ids_file, ) from tro_ids import TroIds -from model import ANLAGE_SCHEMA, AnlageDocument, JamArea +from model import ( + ANLAGE_SCHEMA, + SLOT_PART_KINDS, + AnlageDocument, + DerivedTro, + JamArea, + derive_slots, + has_lines, + slot_kind, + slot_names, +) +from tro_ids import id_problem +from tro_overrides import PartRef, TroEdit, load_overrides, override_path, save_edits from scl_context import ( JAM_NAMES_SUFFIX, HwSource, @@ -147,6 +174,12 @@ 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" +# Bauteile des Exports fuer "Im Plan waehlen" im Dialog TRO_EDIT: XRECORD im +# Named Object Dictionary, je Bauteil (1 . TeileId) (3 . Art) (10 . Lage) +# (40 . halbe Breite) (41 . halbe Hoehe) in Zeichnungskoordinaten. +PARTS_XRECORD = "SPS_SKEL_PARTS" +# Unsichtbare Attribute der Marker (build_symbol) +HIDDEN_TAGS = ("UID", "KEY", "ITEMS", "CONFIDENCE", "SLOTS") # Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar) JAM_OFFEN = "Jam????_?" @@ -155,7 +188,14 @@ JAM_OFFEN = "Jam????_?" 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") +JAM_ABSTAND = get_float("tro_annotate", "jam_abstand") +# Staubereich als stehendes Rechteck, Masse relativ zur Texthoehe: halbe +# Breite / halbe Hoehe, Name senkrecht darin (Jam0054_3 passt hinein). +JAM_HALB_BREITE = 0.5 +JAM_HALB_HOEHE = 2.6 +JAM_TEXT = 0.6 +# Fuellung und Rand (ACI 8 = grau) und Schrift (ACI 250 = fast schwarz) +JAM_FUELLUNG = "#d0d0d0" def hex_to_rgb(value: str) -> tuple[int, int, int]: @@ -189,10 +229,26 @@ def emit_lisp(cad_dir: Path) -> Path: cad_dir.mkdir(parents=True, exist_ok=True) path = cad_dir / "tro_types.lsp" types = " ".join(f'"{d.get_name()}"' for d in TRO_CATALOG) + + def lisp_list(values) -> str: + return " ".join(f'"{v}"' for v in values) + + # Je Typ die Slots in der Reihenfolge von model.derive_slots, dazu die + # Vorgabe der Lagerlinien (0 = keine): ("PinStore_Auto" 19 "separator1" ...) + slots = "\n".join( + " (" + " ".join([f'"{d.get_name()}"', str(has_lines(d.get_name()))] + + [f'"{s}"' for s in slot_names(d.get_name())]) + ")" + for d in TRO_CATALOG) + kinds = "\n".join(f' ("{k}" {lisp_list(v)})' for k, v in SLOT_PART_KINDS.items()) path.write_text( ";; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp\n" - ";; Quelle: TRO_CATALOG in lib/tro_catalog.py - nicht manuell aendern.\n" + ";; Quelle: TRO_CATALOG in lib/tro_catalog.py, Slots aus lib/model.py\n" + ";; (slot_names, SLOT_PART_KINDS) - nicht manuell aendern.\n" f"(setq *TRO-TYPES* (list {types}))\n" + ";; (Typ Lagerlinien-Vorgabe Slot ...) - Lagerlinien heissen line1..lineN\n" + f"(setq *TRO-SLOTS* '(\n{slots}\n))\n" + ";; (Slot-Art Bauteilart ...) - Pruefung beim Waehlen im Plan\n" + f"(setq *TRO-SLOT-KINDS* '(\n{kinds}\n))\n" "(princ)\n", encoding="utf-8", ) @@ -403,7 +459,7 @@ def build_layers(doc, prefix: str) -> dict[str, str]: ("weiche", "#cc8800"), ("eingang", STATION_FARBE[BOUNDARY_ENTRY]), ("ausgang", STATION_FARBE[BOUNDARY_EXIT]), - ("jam", "#7030a0"), + ("jam", "#808080"), ("legend", "#808080")): name = f"{prefix}{key.upper()}" names[key] = name @@ -518,6 +574,18 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) """ name = f"{BLOCK_PREFIX}{type_name}" if name in doc.blocks: + # Blockdefinition aus einem frueheren Lauf (bleibt, weil von Hand + # gesetzte Marker sie benutzen): fehlende Datenattribute nachtragen, + # sonst haetten die neuen Marker z. B. kein KEY/SLOTS. + blk = doc.blocks.get(name) + vorhanden = {a.dxf.tag.upper() for a in blk.query("ATTDEF")} + offset = min((a.dxf.insert.y for a in blk.query("ATTDEF")), default=0.0) + for tag_name in HIDDEN_TAGS: + if tag_name not in vorhanden: + offset -= text + blk.add_attdef(tag_name, insert=(marker * 1.5, offset), height=text * 0.7, + dxfattribs={"style": "Standard", "color": 256, "text": "", + "invisible": 1}) return name blk = doc.blocks.new(name=name) @@ -542,9 +610,9 @@ def build_symbol(doc, type_name: str, marker: float, text: float, show_fb: bool) ) offset -= height * 1.55 - # nicht sichtbare Attribute: reine Daten, nicht im Dialog. - # ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros. - for tag_name in ("UID", "ITEMS", "CONFIDENCE", "SEPARATORS"): + # nicht sichtbare Attribute: reine Daten; KEY und SLOTS liest der Dialog + # TRO_EDIT. ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros. + for tag_name in HIDDEN_TAGS: blk.add_attdef( tag_name, insert=(gap, offset), @@ -591,10 +659,11 @@ def place_markers( values = { "ID": tro.tro_id, "UID": internal[tro.tro_id], + "KEY": analysis.edit_key(tro.tro_id), "TYPE": tro.type_name, "ITEMS": ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())), "CONFIDENCE": tro.confidence, - "SEPARATORS": ", ".join(tro.separators), + "SLOTS": format_slots(*tro_slots(tro)), } if show_fb: values["FB_BLOCK"] = tro.fb_block or "-" @@ -773,7 +842,8 @@ def draw_connections( def build_jam_symbol(doc, text: float) -> str: """ - Blockdefinition fuer einen Staubereich: Rechteck, der Name mittig darin. + Blockdefinition fuer einen Staubereich: stehendes graues Rechteck, der + Name senkrecht mittig darin. Attribute: NAME sichtbar - Jam0054_3 (Foerderer + laufende Nummer) @@ -787,15 +857,23 @@ def build_jam_symbol(doc, text: float) -> str: TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key). """ if JAM_BLOCK in doc.blocks: + # Form aus einem frueheren Lauf (liegendes Rechteck) ersetzen; die + # Attributdefinitionen bleiben, damit vorhandene Bloecke gueltig sind. + blk = doc.blocks.get(JAM_BLOCK) + for e in list(blk): + if e.dxftype() != "ATTDEF": + blk.delete_entity(e) + _jam_form(blk, text) + for a in blk.query("ATTDEF"): + if a.dxf.tag.upper() == "NAME": + _jam_name(a, text) 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) + _jam_form(blk, text) + w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE + name = blk.add_attdef("NAME", insert=(0, 0), height=text * JAM_TEXT, + dxfattribs={"style": "Standard", "text": JAM_OFFEN}) + _jam_name(name, text) 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, @@ -805,36 +883,70 @@ def build_jam_symbol(doc, text: float) -> str: return JAM_BLOCK -def _jam_position(area: JamArea, anlage: AnlageDocument) -> tuple[float, float] | None: - """Lage eines Staubereichs (CSV-Koordinaten): vor seinem Separator. +def _jam_form(blk, text: float) -> None: + """Stehendes Rechteck: graue Fuellung, grauer Rand.""" + w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE + ecken = [(-w, -h), (w, -h), (w, h), (-w, h)] + hatch = blk.add_hatch(dxfattribs={"true_color": ezdxf.rgb2int(hex_to_rgb(JAM_FUELLUNG))}) + hatch.paths.add_polyline_path(ecken, is_closed=True) + blk.add_lwpolyline(ecken, close=True, dxfattribs={"color": 8}) - 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. + +def _jam_name(attdef, text: float) -> None: + """Name senkrecht (90 Grad) mittig ins Rechteck.""" + attdef.dxf.height = text * JAM_TEXT + attdef.dxf.rotation = 90.0 + attdef.dxf.color = 250 + attdef.set_placement((0, 0), align=TextEntityAlignment.MIDDLE_CENTER) + + +def _jam_position(area: JamArea, anlage: AnlageDocument, + marker: float, text: float) -> tuple[float, float] | None: + """Lage eines Staubereichs (CSV-Koordinaten): direkt vor seinem TRO. + + Ausgangspunkt ist der TRO-Marker (die TRO-Position, nicht der Separator: + bei Speichern und 2Sep-Typen liegt der weit vom Marker). Das Rechteck + sitzt gerade so weit davon, dass es den Marker nicht beruehrt + (Markerradius + JAM_ABSTAND + halbe Breite), ein zweiter Eingang direkt + daneben - in Richtung des ersten Vorgaenger-TRO, gegen den Fluss, wo die + Traeger warten. Ohne Vorgaenger mit Lage (Station) gegen die Richtung zum + Nachfolger. Liegt der Nachbar naeher, hoechstens die halbe Strecke. Nur + Lage - Verbindungen und Fliessrichtung bleiben unberuehrt. """ 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] + tro = layout.by_id(area.tro) + if tro is not None and tro.position: + x, y = tro.position[0], tro.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 + breite = 2 * text * JAM_HALB_BREITE + abstand = marker + JAM_ABSTAND + breite / 2 + (area.entry - 1) * (breite + JAM_ABSTAND) + + def richtung(ids: list[str], vorzeichen: float): + for other_id in ids: + other = layout.by_id(other_id) + if other is None or not other.position: + continue + vx, vy = other.position[0] - x, other.position[1] - y + dist = math.hypot(vx, vy) + if dist > 1e-6: + schritt = min(abstand, dist / 2) + return (x + vorzeichen * vx / dist * schritt, + y + vorzeichen * vy / dist * schritt) + return None + + lage = richtung(area.predecessors or (tro.predecessors if tro else []), 1.0) + if lage is None and tro is not None: + lage = richtung(tro.successors, -1.0) + return lage if lage is not None else (x - abstand, 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], + marker: float = DEFAULT_MARKER, ) -> int: """ Je Staubereich der Anlagen-JSON einen JAM_AREA-Block einfuegen. @@ -849,7 +961,7 @@ def place_jams( block = build_jam_symbol(doc, text) gezeichnet = 0 for area in layout.jam_areas_or_derived(): - lage = _jam_position(area, anlage) + lage = _jam_position(area, anlage, marker, text) if lage is None: warnings.append(f"Staubereich {area.jam_id} ({area.tro}): keine Lage - " f"nicht gezeichnet") @@ -915,6 +1027,218 @@ def read_jams(dxf_file: Path, ini_file: Path) -> tuple[dict[str, tuple[str, int] return overrides, meldungen +# --------------------------------------------------------------------------- +# Handkorrekturen an den TROs (Dialog TRO_EDIT, --read-tros) +# --------------------------------------------------------------------------- + + +def tro_slots(tro: DerivedTro) -> tuple[dict[str, str], list[str]]: + """Bauteile je Slot (ohne Lagerlinien) und die Lagerlinien in Reihenfolge.""" + slots: dict[str, str] = {} + lines: list[str] = [] + for slot in derive_slots(tro): + if slot_kind(slot.slot) == "line": + lines.append(slot.layout_id) + else: + slots[slot.slot] = slot.layout_id + return slots, lines + + +def format_slots(slots: dict[str, str], lines: list[str]) -> str: + """Attribut SLOTS: 'separator1=0010;switch1=;lines=0066,0069'.""" + text = ";".join(f"{k}={v}" for k, v in slots.items()) + if lines: + text += (";" if text else "") + "lines=" + ",".join(lines) + return text + + +def parse_slots(text: str) -> tuple[dict[str, str], list[str] | None]: + """Gegenstueck zu format_slots; lines ist None, wenn das Attribut keine nennt.""" + slots: dict[str, str] = {} + lines: list[str] | None = None + for part in text.split(";"): + key, sep, value = (t.strip() for t in part.partition("=")) + if not sep or not key: + continue + if key.lower() == "lines": + lines = [v.strip() for v in value.split(",") if v.strip()] + else: + slots[key.lower()] = value + return slots, lines + + +def write_parts(doc, elements, transform: Transform) -> int: + """Bauteile, die in einen Slot passen, als XRECORD in die Zeichnung legen.""" + kinds = {k for v in SLOT_PART_KINDS.values() for k in v} + tags: list[tuple[int, object]] = [] + for e in elements: + if e.kind not in kinds or e.position is None or not e.teile_id: + continue + x, y = transform.to_dxf(e.x, e.y) + w, h = (e.bbox[0] / 2, e.bbox[1] / 2) if e.bbox else (0.0, 0.0) + tags += [(1, e.teile_id), (3, e.kind), (10, (x, y, 0.0)), (40, w), (41, h)] + root = doc.rootdict + if PARTS_XRECORD in root: + root.remove(PARTS_XRECORD) + xrecord = root.add_xrecord(PARTS_XRECORD) + xrecord.reset(tags) + return len(tags) // 5 + + +def read_markers(dxf_file: Path) -> list[dict[str, str]]: + """Attribute aller TRO-Marker (Bloecke TRO_SYM_*) der Zeichnung.""" + doc = ezdxf.readfile(str(dxf_file)) + markers = [] + for ref in doc.modelspace().query("INSERT"): + if not ref.dxf.name.upper().startswith(BLOCK_PREFIX): + continue + a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs} + a["_BLOCK"] = ref.dxf.name + markers.append(a) + return markers + + +def read_tros(markers: list[dict[str, str]], raw: Analysis, elements, + csv_name: str, cfg_dir: Path | None = None, + write: bool = True) -> tuple[list[TroEdit], list[str]]: + """Marker der Zeichnung mit der Ableitung vergleichen -> Handkorrekturen. + + ``raw`` ist die Ableitung OHNE Handkorrekturen (IDs aus der ID-Datei). Je + Marker mit KEY eines abgeleiteten TRO entsteht ein edit-Abschnitt, wenn + Typ, Kennung oder Bauteile abweichen; ein Marker ohne passenden KEY ist + ein neuer TRO (edit_add); ein abgeleiteter TRO mit Lage, zu dem kein + Marker mehr da ist, wurde geloescht. Die Abschnitte ersetzen alle + bisherigen edit-/edit_add-Abschnitte der CSV in tro_overrides.ini + (Begruendungen bleiben). Rueckgabe: (Korrekturen, Meldungen). + """ + meldungen: list[str] = [] + by_id = {e.teile_id: e for e in elements} + alt = {(e.section_kind, e.key): e + for e in load_overrides(csv_name, cfg_dir).edits} + + def ref(teile_id: str) -> PartRef: + if not teile_id: + return PartRef() + element = by_id.get(teile_id.split("-", 1)[0]) + if element is None or element.position is None: + return PartRef(teile_id=teile_id) + return PartRef(teile_id=teile_id, x=element.x, y=element.y) + + def pruefen(where: str, type_name: str, slots: dict[str, str], + lines: list[str]) -> None: + for slot in slot_names(type_name): + teile_id = slots.get(slot, "") + if not teile_id: + if slot.startswith("separator"): + meldungen.append(f"{where}: {slot} ist leer") + continue + element = by_id.get(teile_id.split("-", 1)[0]) + kinds = SLOT_PART_KINDS[slot_kind(slot)] + if element is None: + meldungen.append(f"{where}: {slot} = {teile_id} steht nicht im Export") + elif element.kind not in kinds: + meldungen.append(f"{where}: {slot} = {teile_id} ist ein {element.kind}, " + f"erwartet {'/'.join(kinds)}") + if has_lines(type_name) and not lines: + meldungen.append(f"{where}: Speicher ohne Lagerlinie") + + raw_by_id = {t.tro_id: t for t in raw.tros} + edits: list[TroEdit] = [] + gesehen: set[str] = set() + vergeben: dict[str, str] = {} + ids: dict[str, str] = {} + for m in markers: + tro_id, key = m.get("ID", ""), m.get("KEY", "") + type_name = m.get("TYPE", "") or m["_BLOCK"][len(BLOCK_PREFIX):] + where = f"Marker {tro_id or '?'}" + if not tro_id: + meldungen.append(f"{where} ({m['_BLOCK']}): ohne ID - uebersprungen") + continue + if get_tro(type_name) is None: + meldungen.append(f"{where}: Typ '{type_name}' steht nicht im Katalog - " + f"uebersprungen") + continue + if problem := id_problem(tro_id): + meldungen.append(f"{where}: {problem} - uebersprungen") + continue + if tro_id in ids: + meldungen.append(f"{where}: Kennung doppelt (auch {ids[tro_id]})") + ids[tro_id] = key or tro_id + slots, lines = parse_slots(m.get("SLOTS", "")) + if key in gesehen: + # kopierter Marker: gleicher KEY wie ein schon gelesener + meldungen.append(f"{where}: KEY {key} doppelt (Marker kopiert?) - als " + f"neuer TRO gelesen") + key = "" + derived = raw_by_id.get(key) + if derived is not None: + gesehen.add(key) + raw_slots, raw_lines = tro_slots(derived) + else: + raw_slots, raw_lines = {}, [] + if lines is None: + lines = raw_lines if has_lines(type_name) else [] + pruefen(where, type_name, slots, lines) + for teile_id in [v for v in slots.values() if v] + lines: + if teile_id in vergeben and vergeben[teile_id] != tro_id: + meldungen.append(f"{where}: Bauteil {teile_id} steckt auch in " + f"{vergeben[teile_id]}") + vergeben[teile_id] = tro_id + + names = slot_names(type_name) + if derived is None: + edit = TroEdit(key=tro_id, add=True, type_name=type_name, + slots={s: ref(slots.get(s, "")) for s in names}, + lines=[ref(t) for t in lines] if has_lines(type_name) else None) + else: + edit = TroEdit(key=key) + if type_name != derived.type_name: + edit.type_name = type_name + if tro_id != key: + edit.name = tro_id + if any(slots.get(s, "") != raw_slots.get(s, "") for s in names): + edit.slots = {s: ref(slots.get(s, "")) for s in names} + if has_lines(type_name) and lines != raw_lines: + edit.lines = [ref(t) for t in lines] + if edit.is_empty(): + continue + if (vorher := alt.get((edit.section_kind, edit.key))) is not None: + edit.reason = vorher.reason + edits.append(edit) + meldungen.append(_describe_edit(edit)) + + for tro in raw.tros: + if tro.tro_id in gesehen or not tro.position: + continue + edit = TroEdit(key=tro.tro_id, delete=True) + if (vorher := alt.get(("edit", tro.tro_id))) is not None: + edit.reason = vorher.reason + edits.append(edit) + meldungen.append(_describe_edit(edit)) + + if write: + save_edits(csv_name, edits, cfg_dir) + return edits, meldungen + + +def _describe_edit(edit: TroEdit) -> str: + if edit.delete: + return f"{edit.key}: geloescht" + teile = [] + if edit.add: + teile.append(f"neu ({edit.type_name})") + elif edit.type_name: + teile.append(f"Typ -> {edit.type_name}") + if edit.name: + teile.append(f"Kennung -> {edit.name}") + if edit.slots is not None and not edit.add: + teile.append("Bauteile " + ", ".join( + f"{k}={r.teile_id or '-'}" for k, r in edit.slots.items())) + if edit.lines is not None: + teile.append(f"{len(edit.lines)} Lagerlinien") + return f"{edit.key}: {'; '.join(teile)}" + + def draw_legend( msp, analysis: Analysis, placed: dict[str, tuple[float, float]], layers: dict[str, str], marker: float, text: float, source: str, @@ -1000,7 +1324,12 @@ def report( print(f" Restfehler = max {transform.residual_max:.1f} mm, " f"mittel {transform.residual_mean:.1f} mm " f"({transform.matched}/{transform.candidates} Punkte)") - print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}") + print(f"TROs = {len(analysis.tros)}, beschriftet: {counts['marker']}" + + (f" (Handkorrekturen: {len(analysis.renamed)} umbenannt, " + f"{len(analysis.added)} neu)" if analysis.renamed or analysis.added else "")) + if counts.get("parts"): + print(f"Bauteile = {counts['parts']} (XRECORD {PARTS_XRECORD}, fuer " + f"TRO_EDIT 'Im Plan waehlen'; uebernehmen mit --read-tros)") print(f"Verbindungen = {counts['flow']}" + (" (Block CONNECTION_ARROW mit FROM_UID/TO_UID)" if counts["flow"] else "")) if counts.get("jams"): @@ -1039,6 +1368,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: " 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" + " tro_annotate.bat --file mubea.csv --read-tros --check\n" "\n" "Exit-Codes:\n" " 0 ok\n" @@ -1085,6 +1415,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "_annotated.dxf in %%SKEL_RESULTS%%) und nach " "%%SKEL_CFG%%\\_jam_names.ini schreiben, dann beenden. " "scl_skeleton liest die Datei.") + parser.add_argument("--read-tros", nargs="?", const="", default=None, metavar="DXF", + help="Im Dialog TRO_EDIT/TRO_INSERT geaenderte, neue und " + "geloeschte TRO-Marker aus der beschrifteten Zeichnung " + "uebernehmen (Standard: _annotated.dxf in " + "%%SKEL_RESULTS%%) und als Abschnitte edit/edit_add nach " + "%%SKEL_CFG%%\\tro_overrides.ini schreiben, dann beenden. " + "tro_flow und tro_annotate wenden sie an.") parser.add_argument("--check", action="store_true", help="Nur pruefen und berichten, keine Datei schreiben.") parser.add_argument("--emit-lisp", action="store_true", @@ -1125,6 +1462,96 @@ def _main_read_jams(args, csv_file: Path) -> int: return 0 +def _derive(args, csv_file: Path, edits: list[TroEdit] | None): + """CSV lesen und die TROs ableiten - (Elemente, Analysis) oder None. + + Derselbe Aufruf wie in tro_flow.main (mit der Separator-Gegenprobe aus + _raw.json, der ID-Datei und den Handkorrekturen), damit die + Zeichnung genau die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine + Datenquelle. --read-tros vergleicht mit der Ableitung ohne Korrekturen + (edits=None). + """ + read_warnings: list[str] = [] + try: + elements = read_elements(csv_file, read_warnings) + except (OSError, ValueError, csv_module.Error) as exc: + print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr) + return None + graph = build_graph(elements, csv_file.name, read_warnings) + raw_json_file = find_sibling_raw_json(csv_file) + sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings) + if raw_json_file is not None else {}) + # Dieselbe ID-Datei wie tro_flow, aber nur lesen: neue TROs bekommen + # dieselbe vorlaeufige ID, die tro_flow anhaengen wuerde. + ids_file = resolve_ids_file(args.ids, csv_file) + try: + tro_ids = TroIds.load(ids_file) if ids_file.is_file() else None + except (OSError, ValueError) as exc: + print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr) + return None + analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts, + tro_ids=tro_ids, edits=edits) + if not analysis.tros: + print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr) + return None + return elements, analysis + + +def _main_read_tros(args, csv_file: Path) -> int: + """--read-tros: Marker der beschrifteten Zeichnung -> tro_overrides.ini.""" + results = env_dir("SKEL_RESULTS", "results") + cfg_dir = env_dir("SKEL_CFG", "cfg") + name = args.read_tros 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 + if not csv_file.is_file(): + print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr) + return 1 + try: + markers = read_markers(dxf_file) + except (OSError, ezdxf.DXFError) as exc: + print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr) + return 1 + derived = _derive(args, csv_file, None) + if derived is None: + return 1 + elements, raw = derived + # Schutz: eine falsche Zeichnung (ohne oder mit fremden Markern) wuerde + # sonst alle TROs als geloescht eintragen. + keys = {m.get("KEY", "") for m in markers} + if not markers or not keys & {t.tro_id for t in raw.tros}: + print(f"FEHLER: {dxf_file.name} enthaelt keine Marker zu den TROs aus " + f"{csv_file.name} ({len(markers)} Marker) - nichts geschrieben. Zeichnung " + f"mit dem aktuellen tro_annotate neu beschriften?", file=sys.stderr) + return 1 + try: + edits, meldungen = read_tros(markers, raw, elements, csv_file.name, cfg_dir, + write=not args.check) + except (OSError, configparser.Error) as exc: + print(f"FEHLER: {exc}", file=sys.stderr) + return 1 + print("") + print("================================================================") + print("TRO-HANDKORREKTUREN UEBERNEHMEN") + print("================================================================") + print(f"Zeichnung = {dxf_file} ({len(markers)} Marker)") + ziel = override_path(cfg_dir) + print(f"Korrekturen = {len(edits)} -> " + f"{'- (--check, nichts geschrieben)' if args.check else ziel}") + for m in meldungen: + print(f" {m}") + print("Danach: tro_flow --json (und tro_annotate) neu laufen lassen.") + print("================================================================") + print("") + return 0 + + def main(argv: list[str] | None = None) -> int: args = parse_args(argv) warnings: list[str] = [] @@ -1145,10 +1572,12 @@ def main(argv: list[str] | None = None) -> int: if args.read_jams is not None: return _main_read_jams(args, csv_file) + if args.read_tros is not None: + return _main_read_tros(args, csv_file) if not args.dxf: - print("FEHLER: --dxf fehlt (nur --emit-lisp und --read-jams gehen ohne " - "Zeichnung).", file=sys.stderr) + print("FEHLER: --dxf fehlt (nur --emit-lisp, --read-jams und --read-tros 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) @@ -1192,33 +1621,13 @@ def main(argv: list[str] | None = None) -> int: f"nicht zu '{csv_file.name}'.", file=sys.stderr) return 1 - # TROs aus dem CSV - read_warnings: list[str] = [] - try: - elements = read_elements(csv_file, read_warnings) - except (OSError, ValueError, csv_module.Error) as exc: - print(f"FEHLER: {csv_file} nicht lesbar: {exc}", file=sys.stderr) - return 1 - graph = build_graph(elements, csv_file.name, read_warnings) - # Derselbe Aufruf wie in tro_flow.main (ohne cfg/tro_overrides.ini, mit - # der Separator-Gegenprobe aus _raw.json und der ID-Datei), damit die Zeichnung genau - # die TROs der TRO-JSON zeigt - sie ist nur Ansicht, keine Datenquelle. - raw_json_file = find_sibling_raw_json(csv_file) - sepliste_counts = (load_sepliste_counts(raw_json_file, read_warnings) - if raw_json_file is not None else {}) - # Dieselbe ID-Datei wie tro_flow, aber nur lesen: neue TROs bekommen - # dieselbe vorlaeufige ID, die tro_flow anhaengen wuerde. - ids_file = resolve_ids_file(args.ids, csv_file) - try: - tro_ids = TroIds.load(ids_file) if ids_file.is_file() else None - except (OSError, ValueError) as exc: - print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr) - return 1 - analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts, - tro_ids=tro_ids) - if not analysis.tros: - print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr) + # TROs aus dem CSV, mit den Handkorrekturen aus tro_overrides.ini + overrides = load_overrides(csv_file.name, cfg_dir) + warnings += [w for w in overrides.warnings if ".edit" in w] + derived = _derive(args, csv_file, overrides.edits) + if derived is None: return 1 + elements, analysis = derived # Zeichnung try: @@ -1274,6 +1683,7 @@ def main(argv: list[str] | None = None) -> int: counts["marker"] = len(placed) counts["blocks"] = sum(1 for b in doc.blocks if b.name.startswith(BLOCK_PREFIX)) counts["first_id"] = next_id + counts["parts"] = write_parts(doc, elements, transform) if args.flow: @@ -1285,7 +1695,7 @@ def main(argv: list[str] | None = None) -> int: ) if anlage is not None: counts["jams"] = place_jams(msp, doc, anlage, jam_overrides, transform, layers, - args.text_height, warnings) + args.text_height, warnings, marker=args.marker_size) if args.legend: draw_legend(msp, analysis, placed, layers, args.marker_size, args.text_height, csv_file.name) diff --git a/lib/tro_flow.py b/lib/tro_flow.py index 02fdd49..5915c39 100644 --- a/lib/tro_flow.py +++ b/lib/tro_flow.py @@ -115,7 +115,10 @@ ausschliesslich derive_tros(), Schritt fuer Schritt: Alle anderen Funktionen (link_tros, locate_tros, derive_boundaries, ...) aendern den Typ nicht mehr. cfg/tro_overrides.ini wird hier seit 2026-10-05 nicht mehr angewendet (siehe tro_overrides.py) - ein falsch -erkannter Typ wird in derive_tros() korrigiert, nicht per INI. +erkannter Typ wird in derive_tros() korrigiert, nicht per INI. Ausnahme seit +2026-10-26: die Handkorrekturen aus BricsCAD (Abschnitte edit/edit_add, von +tro_annotate --read-tros geschrieben) wendet apply_edits nach derive_tros an; +sie haengen an den stabilen IDs der ID-Datei. Nicht abgeleitet werden bisher: 1Sep_SSCC (nur Hinweis, Schritt 6), EmptyCarrBuffer und LoadingBoom. Schritt 3c braucht eine stimmige @@ -181,12 +184,23 @@ from material_flow import ( resolve_input, ) from kreisel_junctions import Junction, find_junctions, separators_before -from model import Analysis, DerivedTro, LayoutPart, TroDocument +from model import ( + SLOT_PART_KINDS, + Analysis, + DerivedTro, + LayoutPart, + TroDocument, + has_lines, + slot_kind, + slot_names, + storage_lines, +) from parameter import get_float, get_int from tro_ids import ( DEFAULT_TOLERANCE, TroIds, assign_ids, + id_problem, ids_path, insert_entries, other_sections, @@ -203,6 +217,7 @@ from tro_catalog import ( get_tro, mermaid_classdefs, ) +from tro_overrides import PartRef, TroEdit, load_overrides # --------------------------------------------------------------------------- # Konstanten @@ -2229,12 +2244,197 @@ def locate_tros(tros: list[DerivedTro], elements: list[Element], graph: Graph) - tro.position_from = source if tro.position else "keine Position" +def _resolve_part(ref: PartRef, slot: str, elements: list[Element], + by_id: dict[str, Element], where: str, tolerance: float, + findings: list[str]) -> str: + """TeileId fuer einen Slot der Handkorrektur im aktuellen Export. + + Erst die TeileId selbst (passende Art, Lage innerhalb der Toleranz), sonst + das naechste Bauteil derselben Art an der Lage - die TeileId wechselt + zwischen zwei Exporten, die Lage nicht. "" wenn nichts passt. + """ + if not ref.teile_id: + return "" + kinds = SLOT_PART_KINDS.get(slot_kind(slot), ()) + base = ref.teile_id.split("-", 1)[0] + element = by_id.get(base) + if element is not None and element.kind in kinds: + if ref.x is None or element.position is None or math.hypot( + element.x - ref.x, element.y - ref.y) <= tolerance: + return ref.teile_id + if ref.x is not None and ref.y is not None: + naechste = min( + ((math.hypot(e.x - ref.x, e.y - ref.y), e) for e in elements + if e.kind in kinds and e.position is not None), + default=None, key=lambda p: p[0]) + if naechste is not None and naechste[0] <= tolerance: + findings.append( + f"{where}: {slot} {ref.teile_id} steht im Export nicht mehr dort - " + f"{naechste[1].kind} {naechste[1].teile_id} an derselben Lage " + f"uebernommen ({naechste[0]:.0f} mm)") + return naechste[1].teile_id + findings.append(f"{where}: {slot} {ref.render()} ist kein " + f"{'/'.join(kinds) or 'Bauteil'} des Exports - Slot bleibt leer") + return "" + + +def _host_of(sep_id: str, by_id: dict[str, Element], graph: Graph) -> str: + """Flussknoten, auf dem ein Separator sitzt (CSV-Spalte 'Zuordnung').""" + element = by_id.get(sep_id) + host = element.assignment if element is not None else "" + return host if host and host != UNASSIGNED and host in graph.nodes else "" + + +def _set_parts(tro: DerivedTro, edit: TroEdit, elements: list[Element], + by_id: dict[str, Element], graph: Graph, tolerance: float, + findings: list[str]) -> None: + """Bauteile eines TRO aus den Slots der Handkorrektur setzen. + + Umkehrung von model.derive_slots: separator -> separators, line -> + die Lagerlinien am Ende von separators (je Linie ein Host), switch -> + switches, scanner -> scanners, drive -> hosts[0]. + """ + where = f"Handkorrektur {edit.key}" + slots = edit.slots or {} + names = slot_names(tro.type_name) + + def teil(slot: str) -> str: + ref = slots.get(slot) + return _resolve_part(ref, slot, elements, by_id, where, tolerance, + findings) if ref is not None else "" + + alte_seps = list(tro.separators) + if edit.slots is not None: + seps = [teil(n) for n in names if n.startswith("separator")] + while seps and not seps[-1]: + seps.pop() + tro.switches = [teil(n) for n in names if n.startswith("switch")] + tro.scanners = [s for s in (teil(n) for n in names if n.startswith("scanner")) if s] + drive = teil("drive") if "drive" in names else "" + else: + seps = alte_seps[:len(alte_seps) - len(storage_lines(tro))] \ + if has_lines(tro.type_name) else alte_seps + drive = "" + + if has_lines(tro.type_name): + if edit.lines is not None: + lines = [s for s in ( + _resolve_part(r, f"line{n}", elements, by_id, where, tolerance, findings) + for n, r in enumerate(edit.lines, start=1)) if s] + else: + lines = storage_lines(tro) + seps = [s for s in seps if s and s not in lines] + tro.separators = seps + lines + # Je Linie ein Host - danach zaehlt model.storage_lines die Linien + tro.hosts = [_host_of(s, by_id, graph) or s for s in lines] + else: + tro.separators = seps + if set(seps) != set(alte_seps): + hosts = list(dict.fromkeys(h for s in seps if (h := _host_of(s, by_id, graph)))) + if hosts: + tro.hosts = hosts + if drive: + tro.hosts = [drive] + [h for h in tro.hosts if h != drive] + + +def apply_edits(tros: list[DerivedTro], edits: list[TroEdit], elements: list[Element], + graph: Graph, findings: list[str], analysis: Analysis, + tolerance: float = DEFAULT_TOLERANCE) -> list[DerivedTro]: + """Handkorrekturen aus BricsCAD (tro_overrides.ini, edit/edit_add) anwenden. + + Laeuft nach derive_tros (die IDs aus der ID-Datei stehen fest) und vor + Verortung und Verknuepfung, damit Koordinaten und Kanten den korrigierten + TRO zeigen. Ein Bauteil, das ein korrigierter TRO bekommt, verliert sein + bisheriger TRO; bleibt der ohne Separator, entfaellt er (Befund). + Umbenennungen kommen zuletzt, die Schluessel bleiben bis dahin die IDs der + ID-Datei. Ergebnis in ``analysis.renamed``/``analysis.added``. + """ + by_id = {e.teile_id: e for e in elements} + tros = list(tros) + renames: list[tuple[DerivedTro, TroEdit]] = [] + # Erst loeschen, dann aendern, zuletzt neu anlegen: ein neuer TRO darf die + # Bauteile eines geloeschten uebernehmen, ohne dass der als "verdraengt" gilt. + for edit in sorted(edits, key=lambda e: 0 if e.delete else 2 if e.add else 1): + where = f"Handkorrektur {edit.key}" + tro = next((t for t in tros if t.tro_id == edit.key), None) + if edit.add: + if tro is not None: + findings.append(f"{where}: neuer TRO, aber {edit.key} gibt es schon - " + f"ignoriert") + continue + tro = DerivedTro(tro_id=edit.key, type_name=edit.type_name, + label=f"von Hand angelegt ({edit.key})", hosts=[], + scanners=[], reason="von Hand in BricsCAD angelegt", + confidence="bestaetigt") + tros.append(tro) + analysis.added.append(edit.key) + elif tro is None: + findings.append(f"{where}: TRO {edit.key} wird nicht (mehr) abgeleitet - " + f"Korrektur ignoriert") + continue + if edit.delete: + tros.remove(tro) + findings.append(f"{where}: TRO {edit.key} ({tro.type_name}) von Hand " + f"entfernt" + (f" - {edit.reason}" if edit.reason else "")) + continue + + if edit.type_name and edit.type_name != tro.type_name: + findings.append(f"{where}: Typ {tro.type_name} -> {edit.type_name}") + tro.type_name = edit.type_name + if edit.slots is not None or edit.lines is not None or edit.add: + _set_parts(tro, edit, elements, by_id, graph, tolerance, findings) + if not edit.add: + tro.reason = f"{tro.reason}; Handkorrektur BricsCAD" + if edit.reason: + tro.reason = f"{tro.reason} ({edit.reason})" + tro.confidence = "bestaetigt" + + # Bauteile, die dieser TRO jetzt hat, den anderen TROs nehmen + eigene = {p for p in tro.separators + tro.scanners + tro.switches if p} + for other in list(tros): + if other is tro: + continue + weg = eigene & set(other.separators + other.scanners + other.switches) + if not weg: + continue + other.separators = [s for s in other.separators if s not in weg] + other.scanners = [s for s in other.scanners if s not in weg] + other.switches = ["" if s in weg else s for s in other.switches] + if other.separators or not other.items.get(TroItem.SEPARATOR): + findings.append(f"{where}: {', '.join(sorted(weg))} von {other.tro_id} " + f"zu {tro.tro_id} verschoben") + else: + tros.remove(other) + findings.append(f"{where}: {other.tro_id} hat keinen Separator mehr " + f"({', '.join(sorted(weg))} an {tro.tro_id}) - entfaellt") + if edit.name and edit.name != tro.tro_id: + renames.append((tro, edit)) + + for tro, edit in renames: + if tro not in tros: + continue + problem = id_problem(edit.name) + if not problem and any(t.tro_id == edit.name for t in tros): + problem = f"'{edit.name}' ist schon vergeben" + if problem: + findings.append(f"Handkorrektur {edit.key}: Umbenennung nicht moeglich - " + f"{problem}") + continue + if tro.tro_id in analysis.added: + analysis.added[analysis.added.index(tro.tro_id)] = edit.name + else: + analysis.renamed[edit.name] = tro.tro_id + tro.tro_id = edit.name + return tros + + def analyse_tros( graph: Graph, elements: list[Element], *, sepliste_counts: dict[str, int] | None = None, tro_ids: TroIds | None = None, + edits: list[TroEdit] | None = None, ) -> Analysis: """TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen. @@ -2250,6 +2450,8 @@ def analyse_tros( Roh-JSON (load_sepliste_counts) - nur Gegenprobe. tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu nummerieren. + edits: Handkorrekturen aus BricsCAD (tro_overrides.load_overrides + (...).edits), angewandt direkt nach derive_tros (apply_edits). Rueckgabe: Analysis mit tros, boundaries, edges/edge_kinds, unknown_types @@ -2259,6 +2461,11 @@ def analyse_tros( analysis = Analysis() analysis.tros = derive_tros(graph, elements, analysis.findings, sepliste_counts=sepliste_counts, tro_ids=tro_ids) + if edits: + analysis.tros = apply_edits(analysis.tros, edits, elements, graph, + analysis.findings, analysis, + tolerance=tro_ids.tolerance if tro_ids + else DEFAULT_TOLERANCE) analysis.boundaries = derive_boundaries(elements, graph) # Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen: # link_tros() registriert eine Kante nur zu einem Knoten, der Host eines @@ -3281,16 +3488,20 @@ def write_ids_file(tro_ids: TroIds, analysis: Analysis, elements: list[Element], if result is None: return "unveraendert" anchors = separator_anchors(elements) + # Die ID-Datei kennt die TROs unter ihrer abgeleiteten ID: Umbenennungen + # und neue TROs aus BricsCAD stehen in tro_overrides.ini, nicht hier. + tros = [t.model_copy(update={"tro_id": analysis.edit_key(t.tro_id)}) + for t in analysis.tros if t.tro_id not in analysis.added] if result.renumbered: tro_ids.path.parent.mkdir(parents=True, exist_ok=True) rest = other_sections(tro_ids.path) - text = render_ids(analysis.tros, anchors, source) + text = render_ids(tros, anchors, source) tro_ids.path.write_text(text + (f"\n{rest}\n" if rest else ""), encoding="utf-8") return "neu geschrieben" if tro_ids.exists else "angelegt" if result.new: insert_entries(tro_ids.path, - render_new_entries(analysis.tros, anchors, result.new, source)) + render_new_entries(tros, anchors, result.new, source)) return f"{len(result.new)} neue(r) TRO(s) angehaengt" return "unveraendert" @@ -3379,8 +3590,15 @@ def main(argv: list[str] | None = None) -> int: print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr) return 1 + # Handkorrekturen aus BricsCAD (nur die Arten edit/edit_add, siehe + # tro_overrides.py); die uebrigen Abschnitte der Datei gelten hier nicht. + overrides = load_overrides(csv_file.name, env_dir("SKEL_CFG", "cfg")) + warnings += [w for w in overrides.warnings if ".edit" in w] + if overrides.edits and args.renumber: + warnings.append(f"--renumber: {len(overrides.edits)} Handkorrektur(en) in " + f"{overrides.source} haengen an den alten IDs - pruefen") analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts, - tro_ids=tro_ids) + tro_ids=tro_ids, edits=overrides.edits) if not analysis.tros: print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs " f"ableitbar.", file=sys.stderr) diff --git a/lib/tro_ids.py b/lib/tro_ids.py index 7e23f9f..4f96512 100644 --- a/lib/tro_ids.py +++ b/lib/tro_ids.py @@ -52,6 +52,7 @@ __all__ = [ "render_new_entries", "insert_entries", "other_sections", + "id_problem", ] SECTION = "tro" @@ -133,6 +134,15 @@ def _parser() -> configparser.ConfigParser: return parser +def id_problem(tro_id: str) -> str: + """Warum `tro_id` keine gueltige TRO-ID ist ("" = gueltig).""" + if not _ID_PATTERN.match(tro_id): + return f"'{tro_id}' ist kein gueltiger SCL-Bezeichner" + if _RESERVED.match(tro_id): + return f"'{tro_id}' ist fuer Systemgrenzen reserviert" + return "" + + def load_ids(path: Path) -> list[IdEntry]: """ID-Datei lesen. ValueError bei doppelter ID, falscher Lage oder ID.""" parser = _parser() diff --git a/lib/tro_overrides.py b/lib/tro_overrides.py index 094c7b9..d9233b9 100644 --- a/lib/tro_overrides.py +++ b/lib/tro_overrides.py @@ -36,6 +36,31 @@ mehrere Anlagen in einer Datei stehen koennen): Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**. +Handkorrekturen aus BricsCAD (seit 2026-10-26) +----------------------------------------------- +Die Abschnitte oben wendet tro_flow nicht mehr an (siehe unten). Die Arten +`edit` und `edit_add` dagegen schon: sie schreibt `tro_annotate --read-tros` +aus den im Dialog TRO_EDIT/TRO_INSERT (dxfmakros) geaenderten Markern, und +`tro_flow.apply_edits` wendet sie nach der Ableitung an. Sie haengen an der +ID aus `_tro_ids.ini`, die ueber die Lage des Separators stabil bleibt - +anders als die laufenden Nummern, an denen die alten Abschnitte veraltet sind. + + [.edit.] Korrektur an einem abgeleiteten TRO + type = 1Sep1Swi neuer Katalogtyp (fehlt = wie abgeleitet) + name = Einschleus_1 neue Kennung (fehlt = bleibt) + delete = true TRO entfaellt + separator1 = 0010 @ 1234.5 -567.8 Bauteil je Slot: TeileId @ x y (mm, + switch1 = - CSV); "-" = Slot bewusst leer + lines = 0066 @ 958 12300, 0069 @ 958 12600 Lagerlinien in Reihenfolge + reason = ... Begruendung (bleibt beim Neuschreiben erhalten) + + [.edit_add.] neuer TRO (TRO_INSERT), Schluessel wie oben + +Stehen Slots im Abschnitt, gelten sie vollstaendig (fehlende Slots des Typs +bleiben leer); stehen keine, bleiben die Bauteile wie abgeleitet. Die Lage +hinter der TeileId macht den Eintrag exportfest: passt die TeileId im neuen +Export nicht mehr zur Lage, gilt das naechste Bauteil derselben Art dort. + Die Felder heissen wie in lib/model.py (`type_name`, `separators`, `hosts`, `reason`, `confidence`); Verbindungen und offene Punkte sind direkt `model.Connection` bzw. `model.TroCandidate`. @@ -45,7 +70,9 @@ from __future__ import annotations import configparser import os +import re from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from model import Connection, TroCandidate @@ -122,9 +149,84 @@ class MergeSpec: confidence: str = DEFAULT_CONFIDENCE +@dataclass +class PartRef: + """Bauteil in einem Slot: TeileId plus Lage (mm, CSV) als Rueckfallebene. + + Die TeileId wechselt zwischen zwei Exporten derselben Anlage, die Lage + nicht (wie bei der ID-Datei, lib/tro_ids.py). ``teile_id == ""`` ist ein + bewusst leerer Slot. + """ + + teile_id: str = "" + x: float | None = None + y: float | None = None + + def render(self) -> str: + """'0010 @ 1234.5 -567.8', '0010' ohne Lage, '-' fuer leer.""" + if not self.teile_id: + return "-" + if self.x is None or self.y is None: + return self.teile_id + return f"{self.teile_id} @ {self.x:.1f} {self.y:.1f}" + + @classmethod + def parse(cls, raw: str) -> PartRef: + """Gegenstueck zu :meth:`render` (ValueError bei falscher Form).""" + text = raw.strip() + if text in ("", "-"): + return cls() + teile_id, at, pos = (t.strip() for t in text.partition("@")) + if not teile_id or " " in teile_id: + raise ValueError(f"'{raw}' ist kein Bauteil ' @ x y'") + if not at: + return cls(teile_id=teile_id) + werte = pos.replace(",", " ").split() + if len(werte) != 2: + raise ValueError(f"'{raw}': Lage hinter @ braucht x und y") + return cls(teile_id=teile_id, x=float(werte[0]), y=float(werte[1])) + + +# Slot-Schluessel in einem edit-Abschnitt (lines steht fuer alle Lagerlinien) +_SLOT_KEY = re.compile(r"^(separator|switch|scanner)\d+$|^drive$") +_EDIT_KEYS = ("type", "name", "delete", "reason", "lines") + + +@dataclass +class TroEdit: + """Handkorrektur aus BricsCAD (Abschnitt edit bzw. edit_add). + + ``key`` ist die ID aus ``_tro_ids.ini`` (edit) bzw. die Kennung des + neuen TRO (edit_add). ``slots``/``lines`` sind None, wenn die Bauteile wie + abgeleitet bleiben. + """ + + key: str + add: bool = False + delete: bool = False + name: str = "" + type_name: str = "" + slots: dict[str, PartRef] | None = None + lines: list[PartRef] | None = None + reason: str = "" + + @property + def section_kind(self) -> str: + return "edit_add" if self.add else "edit" + + def is_empty(self) -> bool: + """Nichts zu tun (ein edit-Abschnitt ohne Aenderung entfaellt).""" + return not (self.add or self.delete or self.name or self.type_name + or self.slots is not None or self.lines is not None) + + @dataclass class Overrides: - """Alle Korrekturen zu einer CSV-Datei.""" + """Alle Korrekturen zu einer CSV-Datei. + + ``edits`` (Handkorrekturen aus BricsCAD) zaehlen fuer ``bool()`` nicht mit: + material_flow --connect liest nur die uebrigen Arten. + """ source: Path | None = None tros: dict[str, TroOverride] = field(default_factory=dict) @@ -133,6 +235,8 @@ class Overrides: # Gesetzte Topologie: ersetzt die abgeleiteten Kanten vollstaendig connections: list[Connection] = field(default_factory=list) candidates: list[TroCandidate] = field(default_factory=list) + # Handkorrekturen aus BricsCAD in Dateireihenfolge (tro_flow.apply_edits) + edits: list[TroEdit] = field(default_factory=list) # Protokoll der tatsaechlich angewandten Korrekturen (fuer die Doku) applied: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) @@ -294,11 +398,134 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides: note=values.get("note", "").strip(), ) ) + elif kind in ("edit", "edit_add"): + edit = _parse_edit(section, name, kind == "edit_add", values, + result.warnings) + if edit is not None: + result.edits.append(edit) else: result.warnings.append( f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro', 'split', " - f"'merge' oder 'openpoint'" + f"'merge', 'openpoint', 'edit' oder 'edit_add'" ) result.candidates.sort(key=lambda p: p.name) return result + + +def _parse_edit(section: str, name: str, add: bool, values, + warnings: list[str]) -> TroEdit | None: + """Einen edit-/edit_add-Abschnitt lesen (None bei einem Fehler).""" + type_name = values.get("type", "").strip() + if type_name and get_tro(type_name) is None: + warnings.append(f"[{section}]: Typ '{type_name}' steht nicht im TRO-Katalog " + f"- Korrektur ignoriert") + return None + if add and not type_name: + warnings.append(f"[{section}]: neuer TRO ohne 'type' - nicht angelegt") + return None + edit = TroEdit( + key=name, + add=add, + delete=values.get("delete", "").strip().lower() in ("true", "ja", "1", "yes"), + name=values.get("name", "").strip(), + type_name=type_name, + reason=values.get("reason", "").strip(), + ) + try: + slots = {key: PartRef.parse(raw) for key, raw in values.items() + if key not in _EDIT_KEYS} + if "lines" in values: + edit.lines = [PartRef.parse(t) for t in values["lines"].split(",") if t.strip()] + except ValueError as exc: + warnings.append(f"[{section}]: {exc} - Korrektur ignoriert") + return None + falsch = sorted(k for k in slots if not _SLOT_KEY.match(k)) + if falsch: + warnings.append(f"[{section}]: unbekannte Schluessel {', '.join(falsch)} - " + f"erwartet type, name, delete, reason, lines oder Slots " + f"(separator1, switch1, scanner1, drive)") + return None + if slots: + edit.slots = slots + return edit + + +# Kopfzeile des von --read-tros geschriebenen Blocks; beim Neuschreiben +# wird sie samt aller edit-/edit_add-Abschnitte der CSV ersetzt. +_EDIT_HEAD = "; --- Handkorrekturen aus BricsCAD zu {csv}" +_HEADER = re.compile(r"^\s*\[(.+)\]\s*$") + + +def render_edit(csv_name: str, edit: TroEdit) -> str: + """Einen Abschnitt als INI-Text.""" + lines = [f"[{csv_name}.{edit.section_kind}.{edit.key}]"] + if edit.delete: + lines.append("delete = true") + if edit.type_name: + lines.append(f"type = {edit.type_name}") + if edit.name: + lines.append(f"name = {edit.name}") + for slot, ref in (edit.slots or {}).items(): + lines.append(f"{slot} = {ref.render()}") + if edit.lines is not None: + lines.append(f"lines = {', '.join(r.render() for r in edit.lines)}") + if edit.reason: + lines.append(f"reason = {edit.reason}") + return "\n".join(lines) + + +def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None) -> Path: + """Alle edit-/edit_add-Abschnitte zu `csv_name` durch `edits` ersetzen. + + Der Rest der Datei (von Hand gepflegt, mit Kommentaren) bleibt Zeile fuer + Zeile erhalten; der neue Block steht am Ende. Kommentarzeilen direkt vor + dem naechsten Abschnitt gehoeren zu diesem und bleiben stehen. + """ + path = override_path(cfg_dir) + text = path.read_text(encoding="utf-8") if path.is_file() else "" + prefix = csv_name.lower() + head = _EDIT_HEAD.format(csv=csv_name).lower() + + def ist_edit(section: str) -> bool: + parts = section.split(".") + return (len(parts) >= 3 and parts[-2].lower() in ("edit", "edit_add") + and ".".join(parts[:-2]).lower() == prefix) + + def ist_rand(line: str) -> bool: + return not line.strip() or line.lstrip().startswith(";") + + keep: list[str] = [] + skipped: list[str] = [] + skipping = False + for line in text.splitlines(): + if line.strip().lower().startswith(head): + continue + if m := _HEADER.match(line): + if skipping: + # Kommentare/Leerzeilen am Ende gehoeren zum naechsten Abschnitt + tail: list[str] = [] + while skipped and ist_rand(skipped[-1]): + tail.insert(0, skipped.pop()) + keep.extend(tail) + skipping = ist_edit(m.group(1).strip()) + skipped = [] + if skipping: + continue + if skipping: + skipped.append(line) + else: + keep.append(line) + + while keep and not keep[-1].strip(): + keep.pop() + if edits: + if keep: + keep += ["", ""] + keep.append(_EDIT_HEAD.format(csv=csv_name) + + f" - tro_annotate --read-tros, {datetime.now():%Y-%m-%d}") + for edit in edits: + keep += [render_edit(csv_name, edit), ""] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(keep).rstrip("\n") + "\n", encoding="utf-8") + return path diff --git a/tests/fixtures/scl/mubea_FB_Main.scl b/tests/fixtures/scl/mubea_FB_Main.scl index ee646f7..b0aa677 100644 --- a/tests/fixtures/scl/mubea_FB_Main.scl +++ b/tests/fixtures/scl/mubea_FB_Main.scl @@ -138,7 +138,7 @@ REGION TRO18 (PinStore_Auto) Sep 0066, 0069, 0072, 0075, 0078, 0081, 0084, 0087, #TRO18.stInBarcodeReader.Settings.tTimeout := T#3000ms; // TODO(E-Planung): Cognex-Interface verdrahten (#arCognexInterface["cScanner____"]) - // TODO(E-Planung): Linienspeicher mit 19 Linien - Parametrierung je Linie + // TODO(E-Planung): Linienspeicher mit 20 Linien - Parametrierung je Linie #TRO18.stInPriorityManager.xReleaseOk := TRUE; diff --git a/tests/test_tro_annotate_jams.py b/tests/test_tro_annotate_jams.py index 21f3da2..52cfb80 100644 --- a/tests/test_tro_annotate_jams.py +++ b/tests/test_tro_annotate_jams.py @@ -72,10 +72,25 @@ def test_staubereiche_als_block_mit_name_und_schluessel(): 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) + # direkt vor dem Marker, Richtung Vorgaenger TRO01 (gegen den Fluss): + # Markerradius + Luft + halbe Rechteckbreite + abstand = (tro_annotate.DEFAULT_MARKER + tro_annotate.JAM_ABSTAND + + 200.0 * tro_annotate.JAM_HALB_BREITE) + assert t2["pos"] == (5000 - round(abstand), 0) + # TRO01 hat nur eine Station als Vorgaenger: gegen die Richtung zum Nachfolger + assert jams["TRO01"]["pos"] == (-round(abstand), 0) + + +def test_staubereich_ist_stehendes_graues_rechteck(): + doc, _, _ = _zeichnen() + blk = doc.blocks.get(tro_annotate.JAM_BLOCK) + rahmen = next(iter(blk.query("LWPOLYLINE"))) + xs = [p[0] for p in rahmen.get_points()] + ys = [p[1] for p in rahmen.get_points()] + assert max(ys) - min(ys) > 3 * (max(xs) - min(xs)) + assert rahmen.dxf.color == 8 and len(blk.query("HATCH")) == 1 + name = next(a for a in blk.query("ATTDEF") if a.dxf.tag == "NAME") + assert name.dxf.rotation == 90.0 def test_korrektur_wird_gezeichnet(): diff --git a/tests/test_tro_edits.py b/tests/test_tro_edits.py new file mode 100644 index 0000000..abc8d72 --- /dev/null +++ b/tests/test_tro_edits.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +"""Tests fuer die Handkorrekturen aus BricsCAD (Dialog TRO_EDIT). + +Weg: Marker der Zeichnung (Attribute KEY/ID/TYPE/SLOTS) -> tro_annotate.read_tros +-> Abschnitte edit/edit_add in tro_overrides.ini (tro_overrides.save_edits) -> +tro_flow.apply_edits beim naechsten Lauf. + +Fixture: Testanlage test_system (TRO01 3Sep2Swi, TRO02 2Sep1Swi, TRO03 2Sep2Swi, +TRO04-06 1Sep1Swi an den Separatoren 0012/0011/0010). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from material_flow import build_graph, read_elements +from model import TroDocument, derive_slots +from tro_annotate import emit_lisp, format_slots, parse_slots, read_tros, tro_slots +from tro_flow import analyse_tros +from tro_overrides import PartRef, TroEdit, load_overrides, save_edits + +FIXTURE = Path(__file__).parent / "fixtures" / "test_system_export.csv" +CSV = FIXTURE.name + + +@pytest.fixture(scope="module") +def layout(): + elements = read_elements(FIXTURE, []) + return elements, build_graph(elements, CSV, []) + + +def _analyse(layout, edits=None): + elements, graph = layout + return analyse_tros(graph, elements, edits=edits) + + +def _by_sep(analysis, sep: str): + return next(t for t in analysis.tros if sep in t.separators) + + +def _markers(analysis) -> list[dict[str, str]]: + """Marker, wie tro_annotate.place_markers sie in die Zeichnung schreibt.""" + return [{"ID": t.tro_id, "KEY": analysis.edit_key(t.tro_id), "TYPE": t.type_name, + "SLOTS": format_slots(*tro_slots(t)), "_BLOCK": f"TRO_SYM_{t.type_name}"} + for t in analysis.tros] + + +# --------------------------------------------------------------------------- +# INI: lesen und schreiben +# --------------------------------------------------------------------------- + + +def test_partref_hin_und_zurueck(): + ref = PartRef.parse("0010 @ 1234.5 -567.8") + assert (ref.teile_id, ref.x, ref.y) == ("0010", 1234.5, -567.8) + assert PartRef.parse(ref.render()) == ref + assert PartRef.parse("-") == PartRef() + assert PartRef.parse("0010").x is None + with pytest.raises(ValueError): + PartRef.parse("0010 @ 1") + + +def test_load_overrides_liest_edit_abschnitte(tmp_path): + (tmp_path / "tro_overrides.ini").write_text( + f"[{CSV}.edit.TRO04]\ntype = 2Sep1Swi\nname = Weiche_K8\n" + f"separator1 = 0012 @ 1 2\nswitch1 = -\nreason = Begehung\n\n" + f"[{CSV}.edit_add.TRO99]\ntype = 1Sep\nseparator1 = 0011\n\n" + f"[{CSV}.edit.TRO05]\ndelete = true\n\n" + f"[{CSV}.edit.TRO06]\nweiche = 0023\n\n" + f"[{CSV}.edit_add.TRO98]\nseparator1 = 0011\n", + encoding="utf-8") + overrides = load_overrides(CSV, tmp_path) + edits = {e.key: e for e in overrides.edits} + assert set(edits) == {"TRO04", "TRO99", "TRO05"} + assert edits["TRO04"].name == "Weiche_K8" + assert edits["TRO04"].slots == {"separator1": PartRef("0012", 1.0, 2.0), + "switch1": PartRef()} + assert edits["TRO04"].reason == "Begehung" + assert edits["TRO99"].add and edits["TRO99"].lines is None + assert edits["TRO05"].delete + # unbekannter Schluessel und neuer TRO ohne Typ: gemeldet, nicht angewandt + assert len(overrides.warnings) == 2 + # material_flow --connect sieht die Handkorrekturen nicht + assert not overrides + + +def test_save_edits_ersetzt_nur_die_eigenen_abschnitte(tmp_path): + ini = tmp_path / "tro_overrides.ini" + ini.write_text( + "; Kopf\n\n" + f"[{CSV}.tro.TRO03]\ntype = 1Sep\n\n" + f"[{CSV}.edit.TRO01]\ntype = 1Sep\n\n" + "; Kommentar zum naechsten Abschnitt\n" + f"[andere.csv.edit.TRO01]\ntype = 1Sep\n", + encoding="utf-8") + save_edits(CSV, [TroEdit(key="TRO02", name="Neu"), + TroEdit(key="TRO07", add=True, type_name="1Sep", + slots={"separator1": PartRef("0010", 5, 6)})], tmp_path) + text = ini.read_text(encoding="utf-8") + assert f"[{CSV}.edit.TRO01]" not in text + assert f"[{CSV}.tro.TRO03]" in text and "[andere.csv.edit.TRO01]" in text + assert "; Kommentar zum naechsten Abschnitt\n[andere.csv.edit.TRO01]" in text + assert text.index("[andere.csv") < text.index(f"[{CSV}.edit.TRO02]") + assert "separator1 = 0010 @ 5.0 6.0" in text + assert {e.key for e in load_overrides(CSV, tmp_path).edits} == {"TRO02", "TRO07"} + + # erneut schreiben ersetzt den Block, leer entfernt ihn samt Kopfzeile + save_edits(CSV, [TroEdit(key="TRO02", name="Neu")], tmp_path) + assert ini.read_text(encoding="utf-8").count("Handkorrekturen aus BricsCAD") == 1 + save_edits(CSV, [], tmp_path) + text = ini.read_text(encoding="utf-8") + assert "Handkorrekturen" not in text and f"{CSV}.edit." not in text + assert text.startswith("; Kopf\n") + + +# --------------------------------------------------------------------------- +# tro_flow.apply_edits +# --------------------------------------------------------------------------- + + +def test_typ_und_bauteile_aendern_verdraengt_den_nachbarn(layout): + raw = _analyse(layout) + key = _by_sep(raw, "0012").tro_id + verdraengt = _by_sep(raw, "0011").tro_id + edit = TroEdit(key=key, type_name="2Sep1Swi", slots={ + "separator1": PartRef("0012"), "separator2": PartRef("0011"), + "switch1": PartRef("0024")}) + analysis = _analyse(layout, [edit]) + tro = analysis.by_id(key) + assert (tro.type_name, tro.separators, tro.switches) == ("2Sep1Swi", ["0012", "0011"], + ["0024"]) + assert tro.confidence == "bestaetigt" + assert analysis.by_id(verdraengt) is None + assert any(f"{verdraengt} hat keinen Separator mehr" in f for f in analysis.findings) + # 2Sep1Swi hat dazu zwei Scanner-Slots, hier leer + assert [s.layout_id for s in derive_slots(tro)] == ["0012", "0011", "0024", "", ""] + + +def test_umbenennen_loeschen_und_neu_anlegen(layout): + raw = _analyse(layout) + a, b = _by_sep(raw, "0012").tro_id, _by_sep(raw, "0010").tro_id + edits = [ + TroEdit(key=a, name="Weiche_K8"), + TroEdit(key="TRO99", add=True, type_name="1Sep", + slots={"separator1": PartRef("0010")}), + TroEdit(key=b, delete=True), + TroEdit(key=_by_sep(raw, "0011").tro_id, name="SYS1"), + ] + analysis = _analyse(layout, edits) + assert analysis.by_id("Weiche_K8") is not None and analysis.by_id(a) is None + assert analysis.renamed == {"Weiche_K8": a} + assert analysis.edit_key("Weiche_K8") == a + neu = analysis.by_id("TRO99") + assert neu.separators == ["0010"] and neu.position is not None + assert analysis.added == ["TRO99"] + assert analysis.by_id(b) is None + # das Loeschen laeuft vor dem Anlegen: 0010 wird nicht "verdraengt" + assert not any("verschoben" in f or "keinen Separator" in f for f in analysis.findings) + assert any("Umbenennung nicht moeglich" in f for f in analysis.findings) + # die korrigierten TROs laufen durch das Modell der TRO-JSON + TroDocument.from_json(TroDocument.from_analysis(analysis, CSV).to_json()) + + +def test_teileid_aus_altem_export_wird_ueber_die_lage_gefunden(layout): + elements, _ = layout + sep = next(e for e in elements if e.teile_id == "0012") + raw = _analyse(layout) + key = _by_sep(raw, "0012").tro_id + edit = TroEdit(key=key, slots={"separator1": PartRef("9999", sep.x, sep.y), + "switch1": PartRef("0024")}) + analysis = _analyse(layout, [edit]) + assert analysis.by_id(key).separators == ["0012"] + assert any("9999 steht im Export nicht mehr dort" in f for f in analysis.findings) + + +# --------------------------------------------------------------------------- +# tro_annotate.read_tros +# --------------------------------------------------------------------------- + + +def test_slots_attribut_hin_und_zurueck(): + slots, lines = {"separator1": "0010", "switch1": ""}, ["0066", "0069"] + assert format_slots(slots, lines) == "separator1=0010;switch1=;lines=0066,0069" + assert parse_slots(format_slots(slots, lines)) == (slots, lines) + assert parse_slots("separator1=0010") == ({"separator1": "0010"}, None) + + +def test_unveraenderte_zeichnung_ergibt_keine_korrektur(layout, tmp_path): + elements, _ = layout + raw = _analyse(layout) + edits, meldungen = read_tros(_markers(raw), raw, elements, CSV, tmp_path) + assert edits == [] + assert not (tmp_path / "tro_overrides.ini").read_text(encoding="utf-8").strip() + + +def test_dialogaenderungen_werden_zu_korrekturen(layout, tmp_path): + elements, _ = layout + raw = _analyse(layout) + markers = _markers(raw) + by_key = {m["KEY"]: m for m in markers} + a = _by_sep(raw, "0012").tro_id + b = _by_sep(raw, "0010").tro_id + by_key[a].update(ID="Weiche_K8", TYPE="2Sep1Swi", + SLOTS="separator1=0012;separator2=;switch1=;scanner1=0010") + markers.remove(by_key[b]) + markers.append({"ID": "TRO99", "KEY": "", "TYPE": "1Sep", + "SLOTS": "separator1=0010", "_BLOCK": "TRO_SYM_1Sep"}) + edits, meldungen = read_tros(markers, raw, elements, CSV, tmp_path) + by = {(e.section_kind, e.key): e for e in edits} + assert set(by) == {("edit", a), ("edit_add", "TRO99"), ("edit", b)} + assert by[("edit", a)].name == "Weiche_K8" + assert by[("edit", a)].type_name == "2Sep1Swi" + assert by[("edit", a)].slots["separator2"] == PartRef() + assert by[("edit", a)].slots["switch1"] == PartRef() + assert any("scanner1 = 0010 ist ein Separator" in m for m in meldungen) + assert by[("edit", b)].delete + # Lage aus dem CSV hinter der TeileId + assert by[("edit_add", "TRO99")].slots["separator1"].x is not None + assert any("separator2 ist leer" in m for m in meldungen) + + # Rundlauf: angewandt, neu beschriftet, wieder gelesen -> dieselben Korrekturen + edited = _analyse(layout, load_overrides(CSV, tmp_path).edits) + again, _ = read_tros(_markers(edited), raw, elements, CSV, tmp_path, write=False) + assert {(e.section_kind, e.key) for e in again} == set(by) + + +def test_kopierter_marker_wird_neuer_tro(layout, tmp_path): + elements, _ = layout + raw = _analyse(layout) + markers = _markers(raw) + kopie = dict(markers[0], ID="TRO77") + edits, meldungen = read_tros(markers + [kopie], raw, elements, CSV, tmp_path, + write=False) + assert [(e.section_kind, e.key) for e in edits] == [("edit_add", "TRO77")] + assert any("doppelt" in m for m in meldungen) + + +# --------------------------------------------------------------------------- +# Slot-Listen fuer den Dialog +# --------------------------------------------------------------------------- + + +def test_emit_lisp_schreibt_slots_je_typ(tmp_path): + text = emit_lisp(tmp_path).read_text(encoding="utf-8") + assert ('("2Sep1Swi" 0 "separator1" "separator2" "switch1" "scanner1" "scanner2")' + in text) + assert '("PinStore_Auto" 19 "separator1" "scanner1")' in text + assert '("switch" "Ausschleus" "Weiche")' in text