tro_annotate --read-tros: TRO-Handkorrekturen aus BricsCAD, Staubereiche vor dem TRO

- Marker tragen KEY (ID der ID-Datei) und SLOTS (Bauteil je Slot), die
  Zeichnung die Bauteiltabelle SPS_SKEL_PARTS (XRECORD) fuer "Im Plan
  waehlen" im Dialog TRO_EDIT (dxfmakros)
- --read-tros schreibt geaenderte, neue und geloeschte Marker als
  Abschnitte edit/edit_add nach cfg/tro_overrides.ini; tro_flow.apply_edits
  wendet nur diese Arten an (Bauteile mit Lage, exportfest)
- --emit-lisp schreibt dazu *TRO-SLOTS* und *TRO-SLOT-KINDS*
- model: Lagerlinien zaehlen je TRO (Katalog nur Vorgabe), Mubea-Speicher
  jetzt 20 Linien (Golden-Datei)
- Staubereich als stehendes graues Rechteck direkt vor seinem TRO-Marker
  (jam_abstand statt jam_versatz); Marker 150 mm, Text 120 mm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 14:35:04 +01:00
parent 9ec1022fb1
commit 6c8c235287
12 changed files with 1441 additions and 125 deletions
+6 -1
View File
@@ -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 call as `tro_flow.py`) into a copy of the DXF for a visual check in BricsCAD. With
`--anlage <csv>_anlage.json` also the jam areas (block `JAM_AREA`, name `Jam<NNNN>_<k>`, `--anlage <csv>_anlage.json` also the jam areas (block `JAM_AREA`, name `Jam<NNNN>_<k>`,
double-click → dialog `JAM_EDIT` in dxfmakros); `--read-jams` takes numbers edited there double-click → dialog `JAM_EDIT` in dxfmakros); `--read-jams` takes numbers edited there
into `cfg/<csv>_jam_names.ini`, which `scl_skeleton` applies. into `cfg/<csv>_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 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 `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 extended; if drawing edits (`TRO_INSERT`/`TRO_EDIT`) are ever needed again, it should
+25 -1
View File
@@ -1,4 +1,28 @@
;; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp ;; 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")) (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) (princ)
+7 -6
View File
@@ -179,16 +179,17 @@ id_min_share = 0.8
[tro_annotate] [tro_annotate]
; Zeichnungsgroessen, wenn nicht per --marker-size/--text-height angegeben. ; Zeichnungsgroessen, wenn nicht per --marker-size/--text-height angegeben.
default_marker = 300.0 ; So klein wie lesbar: ID = 1,35 x Text (~160 mm), Typ ~120 mm.
default_text = 220.0 default_marker = 150.0
default_text = 120.0
; Restfehler des Lagebezugs, ab dem er abgelehnt wird. ; Restfehler des Lagebezugs, ab dem er abgelehnt wird.
default_max_residual = 600.0 default_max_residual = 600.0
; Staubereich (--anlage): das Rechteck sitzt so weit vor seinem Separator, ; Staubereich (--anlage): stehendes graues Rechteck direkt vor seinem TRO,
; Richtung Vorgaenger-TRO (gegen den Fluss) - er ist die Strecke, auf der ; auf der Seite des Vorgaenger-TRO (gegen den Fluss) - dort warten die Traeger.
; die Traeger vor dem Separator warten. ; Luft zwischen Markerrand und Rechteck (mm).
jam_versatz = 700.0 jam_abstand = 60.0
[merge_hw_layout] [merge_hw_layout]
+109 -31
View File
@@ -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 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 new IDs, writes/extends the file. Used by `tro_flow.py` (read and write) and
`tro_annotate.py` (read only). See §4, "TRO-IDs". `tro_annotate.py` (read only). See §4, "TRO-IDs".
- **`lib/tro_overrides.py`** — reads the hand-maintained - **`lib/tro_overrides.py`** — reads `%SKEL_CFG%\tro_overrides.ini`. The
`%SKEL_CFG%\tro_overrides.ini`; only `material_flow.py --connect` still uses hand-maintained kinds are only used by `material_flow.py --connect`; the
it. See §6a. 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): 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. | | `--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. | | `--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; TROs, types and connections come from the layout. Errors in the derivation
`cfg/tro_overrides.ini` is not read. Errors in the derivation are fixed in are fixed in `lib/tro_flow.py`, not in a correction file. The one exception
`lib/tro_flow.py`, not in a correction file. (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 **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 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 triangle, square, diamond, …; see `TroSymbol` in `tro_catalog.py`) so types
are distinguishable even without color; are distinguishable even without color;
- visible attributes `ID`, `TYPE` (and `FB_BLOCK` with `--fb`); hidden - 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 - optional directional flow arrows between TROs (`--flow`) and a legend
(`--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 `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. 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` **Editing TROs in BricsCAD (since 2026-10-26).** The `dxfmakros` commands
BricsCAD menu (`SSG_LIB > TRO`) can insert further markers of that type by `TRO_EDIT` (double-click on a marker) and `TRO_INSERT` (`SSG_LIB > TRO`)
hand — `TRO_INSERT` reuses the `TRO_SYM_<Typ>` block definition this tool share **one** dialog for all types (`dcl/tro_edit.dcl`): ID, type, and a list
created and only asks for a point, an id and (optionally) a different type; with one row per slot of the type (`separator<n>`, `switch<n>`, `scanner<n>`,
`TRO_EDIT` changes `ID`/`TYPE` on an existing marker. Neither command draws `drive`, storage lines `line1..N`) and its part (TeileId). The rows come from
new shapes — a type with no prior annotated instance in the drawing must be the type (`*TRO-SLOTS*` in `tro_types.lsp`, written by `--emit-lisp` from
run through this tool at least once first. `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_<Typ>` 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 `[<csv>.edit.<KEY>]` if type, ID (→
`name =`) or parts differ; a marker without such a key becomes
`[<csv>.edit_add.<ID>]`; 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 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 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. | | `--flow` | — | off | Draw directional flow arrows between the TROs. |
| `--fb` | — | off | Show the Siemens FB block name as a visible attribute on the marker. | | `--fb` | — | off | Show the Siemens FB block name as a visible attribute on the marker. |
| `--legend` | — | off | Draw a legend of the color groups. | | `--legend` | — | off | Draw a legend of the color groups. |
| `--marker-size` | `MM` (float) | `300.0` | Marker radius in mm. | | `--marker-size` | `MM` (float) | `150.0` | Marker radius in mm (`default_marker` in `cfg/parameter.ini`). |
| `--text-height` | `MM` (float) | `220.0` | Text height in mm. | | `--text-height` | `MM` (float) | `120.0` | Text height in mm (`default_text`; the ID is 1.35×). |
| `--ids` | `DATEI` | `<csv-stem>_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. | | `--ids` | `DATEI` | `<csv-stem>_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`. | | `--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%`. | | `--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. | | `--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 `<SPS_SKEL>/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 `<SPS_SKEL>/cad`. |
| `--anlage` | `NAME` | — | Plant JSON (`<csv>_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. | | `--anlage` | `NAME` | — | Plant JSON (`<csv>_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]` | `<csv>_annotated.dxf` | Read the jam-area numbers edited in BricsCAD back from the annotated drawing (saved as DXF) into `%SKEL_CFG%\<csv>_jam_names.ini`, then exit — no `--dxf` needed. | | `--read-jams` | `[DXF]` | `<csv>_annotated.dxf` | Read the jam-area numbers edited in BricsCAD back from the annotated drawing (saved as DXF) into `%SKEL_CFG%\<csv>_jam_names.ini`, then exit — no `--dxf` needed. |
| `--read-tros` | `[DXF]` | `<csv>_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 **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 JSON on layer `TRO_JAM`: an upright grey rectangle with the name written
(`Jam0054_3`, the same name the SCL uses — `scl_context.jam_numbers` plus the vertically inside (`Jam0054_3`, the same name the SCL uses —
corrections file). It sits `jam_versatz` mm (`cfg/parameter.ini `scl_context.jam_numbers` plus the corrections file). It sits directly in
[tro_annotate]`) in front of its separator, towards the predecessor TRO, front of its TRO marker — marker radius + `jam_abstand` mm (`cfg/parameter.ini
where the carriers wait. Hidden attributes: `CONVEYOR` (NNNN), `NUMBER` (k), [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 `TRO` + `ENTRY` (the stable key, `scl_context.jam_key`), `AREA` (J0039) and
`AUTO` (the derived NNNN_k). A jam area without a conveyor shows `AUTO` (the derived NNNN_k). A jam area without a conveyor shows
`Jam????_?`. Double-click opens the dialog **`JAM_EDIT`** (dxfmakros: `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 the next `--anlage` run draws it, so drawing, file and SCL agree. Invalid
values (conveyor not four digits, number < 1) are reported and skipped. values (conveyor not four digits, number < 1) are reported and skipped.
Layers always carry the prefix `TRO_`. Like `tro_flow.py`, it never reads Layers always carry the prefix `TRO_`. Like `tro_flow.py`, it reads only the
`cfg/tro_overrides.ini`, so the markers always match the flow diagram. `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`): `<name>_annotated.dxf`, **Output** (in `%SKEL_RESULTS%`, unless `--check`): `<name>_annotated.dxf`,
`<name>_registration.json` (the coordinate transform used, with its proof). `<name>_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 It finds the objects `tro_annotate.py` tagged with XDATA (AppID
`SPS_SKEL_TRO`): the `TRO_SYM_<type>` block references (attributes `SPS_SKEL_TRO`): the `TRO_SYM_<type>` block references (attributes
`ID`/`TYPE`/`ITEMS`/`CONFIDENCE`/`SEPARATORS`, plus `FB_BLOCK` when annotated `ID`/`TYPE`/`ITEMS`/`CONFIDENCE`/`SEPARATORS`, plus `FB_BLOCK` when annotated
with `--fb`), the flow arrows (`"<from>-><to>"`), 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 (`"<from>-><to>"`), and the open-point markers
(`"OFFEN:<name>"`). Each TRO also gets `predecessors`/`successors` computed (`"OFFEN:<name>"`). Each TRO also gets `predecessors`/`successors` computed
from the arrows. 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 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`. `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 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 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 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) ## 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.** > **Since 2026-10-05 only `material_flow.py --connect` reads the hand-written
> `tro_flow.py` and `tro_annotate.py` no longer apply corrections: the sections > kinds below** (`tro`, `split`, `merge`, `openpoint`, `connections`).
> were keyed on running TRO numbers and went stale with every new export. > `tro_flow.py` and `tro_annotate.py` no longer apply them: the sections were
> Errors in the TRO derivation are fixed in `lib/tro_flow.py` instead. The > keyed on running TRO numbers and went stale with every new export. Errors in
> format below is kept for `material_flow.py`. > 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
> `<csv>_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 `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 are simply not in the CSV export: a switch that the export does not carry, a
+86 -8
View File
@@ -82,6 +82,10 @@ __all__ = [
"derive_slots", "derive_slots",
"derive_jam_areas", "derive_jam_areas",
"storage_lines", "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), # 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] @computed_field # type: ignore[prop-decorator]
@property @property
def items(self) -> dict[str, int]: 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() 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") @model_validator(mode="before")
@classmethod @classmethod
@@ -219,9 +231,14 @@ class TroBase(_Positioned):
fb_block = data.pop("fbType", data.pop("fb_block", None)) fb_block = data.pop("fbType", data.pop("fb_block", None))
type_name = data.get("type", data.get("type_name")) type_name = data.get("type", data.get("type_name"))
definition = get_tro(type_name) if isinstance(type_name, str) else None 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 "" 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( raise ValueError(
f"items {items} passt nicht zum Typ '{type_name}' laut tro_catalog " f"items {items} passt nicht zum Typ '{type_name}' laut tro_catalog "
f"({expected_items}) - den Typ aendern, nicht die Bauteile" f"({expected_items}) - den Typ aendern, nicht die Bauteile"
@@ -381,6 +398,11 @@ class Analysis(BaseModel):
# Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein # Systemgrenzen (BTMT Be-/Entladestationen) - siehe derive_boundaries; kein
# TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird. # TRO, nur damit der tatsaechliche Materialein-/ausgang sichtbar wird.
boundaries: list[DerivedTro] = Field(default_factory=list) boundaries: list[DerivedTro] = Field(default_factory=list)
# Handkorrekturen aus BricsCAD (tro_flow.apply_edits): umbenannte TROs
# (neue ID -> ID aus <csv>_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: def by_id(self, tro_id: str) -> DerivedTro | None:
for tro in self.tros + self.boundaries: for tro in self.tros + self.boundaries:
@@ -388,6 +410,11 @@ class Analysis(BaseModel):
return tro return tro
return None 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): class TroDocument(BaseModel):
""" """
@@ -613,6 +640,12 @@ def _at(values: list[str], index: int) -> str:
return values[index] if index < len(values) else "" 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]: def storage_lines(tro: TroBase) -> list[str]:
"""Separatoren der Lagerlinien eines Speicher-TRO. """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 letzten len(hosts) (eine je Speicherbahn). Fehlen Hosts, zaehlt jeder
Separator als Linie - dieselbe Regel wie scl_context._store. Separator als Linie - dieselbe Regel wie scl_context._store.
""" """
hosts = list(getattr(tro, "hosts", []) or []) return _lines_of(tro.separators, getattr(tro, "hosts", []))
seps = list(tro.separators)
count = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps)
return seps[len(seps) - count:] 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]: def derive_slots(tro: DerivedTro) -> list[Slot]:
+476 -66
View File
@@ -35,9 +35,24 @@ Typen auch ohne Farbe unterscheiden. Die Daten stehen als Attribute am Block:
TYPE 1Sep sichtbar, im Dialog bearbeitbar TYPE 1Sep sichtbar, im Dialog bearbeitbar
FB_BLOCK FB_ILS_... sichtbar nur mit --fb FB_BLOCK FB_ILS_... sichtbar nur mit --fb
UID 0062 unsichtbar, dauerhafte interne Nummer 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 ITEMS 1x Separator unsichtbar, reine Daten
CONFIDENCE mittel unsichtbar 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: Zwei Kennungen, mit Absicht:
ID die sprechende Kennung (TRO03). Darf sich aendern - der Dialog TRO_EDIT ID die sprechende Kennung (TRO03). Darf sich aendern - der Dialog TRO_EDIT
@@ -122,7 +137,19 @@ from tro_flow import (
resolve_ids_file, resolve_ids_file,
) )
from tro_ids import TroIds 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 ( from scl_context import (
JAM_NAMES_SUFFIX, JAM_NAMES_SUFFIX,
HwSource, HwSource,
@@ -147,6 +174,12 @@ CONNECTION_BLOCK = "CONNECTION_ARROW"
# Staubereich (--anlage). Der Name beginnt mit JAM_, damit SSG_BLOCKEDIT ihn # Staubereich (--anlage). Der Name beginnt mit JAM_, damit SSG_BLOCKEDIT ihn
# ueber "JAM_*" an den Dialog JAM_EDIT (dxfmakros/Lisp/Jam_Edit.lsp) gibt. # ueber "JAM_*" an den Dialog JAM_EDIT (dxfmakros/Lisp/Jam_Edit.lsp) gibt.
JAM_BLOCK = "JAM_AREA" 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) # Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar)
JAM_OFFEN = "Jam????_?" JAM_OFFEN = "Jam????_?"
@@ -155,7 +188,14 @@ JAM_OFFEN = "Jam????_?"
DEFAULT_MARKER = get_float("tro_annotate", "default_marker") DEFAULT_MARKER = get_float("tro_annotate", "default_marker")
DEFAULT_TEXT = get_float("tro_annotate", "default_text") DEFAULT_TEXT = get_float("tro_annotate", "default_text")
DEFAULT_MAX_RESIDUAL = get_float("tro_annotate", "default_max_residual") 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]: 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) cad_dir.mkdir(parents=True, exist_ok=True)
path = cad_dir / "tro_types.lsp" path = cad_dir / "tro_types.lsp"
types = " ".join(f'"{d.get_name()}"' for d in TRO_CATALOG) 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( path.write_text(
";; Automatisch erzeugt von lib/tro_annotate.py --emit-lisp\n" ";; 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" 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", "(princ)\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -403,7 +459,7 @@ def build_layers(doc, prefix: str) -> dict[str, str]:
("weiche", "#cc8800"), ("weiche", "#cc8800"),
("eingang", STATION_FARBE[BOUNDARY_ENTRY]), ("eingang", STATION_FARBE[BOUNDARY_ENTRY]),
("ausgang", STATION_FARBE[BOUNDARY_EXIT]), ("ausgang", STATION_FARBE[BOUNDARY_EXIT]),
("jam", "#7030a0"), ("jam", "#808080"),
("legend", "#808080")): ("legend", "#808080")):
name = f"{prefix}{key.upper()}" name = f"{prefix}{key.upper()}"
names[key] = name 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}" name = f"{BLOCK_PREFIX}{type_name}"
if name in doc.blocks: 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 return name
blk = doc.blocks.new(name=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 offset -= height * 1.55
# nicht sichtbare Attribute: reine Daten, nicht im Dialog. # nicht sichtbare Attribute: reine Daten; KEY und SLOTS liest der Dialog
# ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros. # TRO_EDIT. ID zuerst - gleiche Reihenfolge wie *strecke-attr-front* in dxfmakros.
for tag_name in ("UID", "ITEMS", "CONFIDENCE", "SEPARATORS"): for tag_name in HIDDEN_TAGS:
blk.add_attdef( blk.add_attdef(
tag_name, tag_name,
insert=(gap, offset), insert=(gap, offset),
@@ -591,10 +659,11 @@ def place_markers(
values = { values = {
"ID": tro.tro_id, "ID": tro.tro_id,
"UID": internal[tro.tro_id], "UID": internal[tro.tro_id],
"KEY": analysis.edit_key(tro.tro_id),
"TYPE": tro.type_name, "TYPE": tro.type_name,
"ITEMS": ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())), "ITEMS": ", ".join(f"{c}x {i}" for i, c in sorted(tro.items.items())),
"CONFIDENCE": tro.confidence, "CONFIDENCE": tro.confidence,
"SEPARATORS": ", ".join(tro.separators), "SLOTS": format_slots(*tro_slots(tro)),
} }
if show_fb: if show_fb:
values["FB_BLOCK"] = tro.fb_block or "-" values["FB_BLOCK"] = tro.fb_block or "-"
@@ -773,7 +842,8 @@ def draw_connections(
def build_jam_symbol(doc, text: float) -> str: 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: Attribute:
NAME sichtbar - Jam0054_3 (Foerderer + laufende Nummer) 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). TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key).
""" """
if JAM_BLOCK in doc.blocks: 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 return JAM_BLOCK
blk = doc.blocks.new(name=JAM_BLOCK) blk = doc.blocks.new(name=JAM_BLOCK)
w, h = text * 4.2, text * 1.2 _jam_form(blk, text)
blk.add_lwpolyline([(-w, -h), (w, -h), (w, h), (-w, h)], close=True, w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
dxfattribs={"color": 256}) name = blk.add_attdef("NAME", insert=(0, 0), height=text * JAM_TEXT,
name = blk.add_attdef("NAME", insert=(0, 0), height=text, dxfattribs={"style": "Standard", "text": JAM_OFFEN})
dxfattribs={"style": "Standard", "color": 256, _jam_name(name, text)
"text": JAM_OFFEN})
name.set_placement((0, 0), align=TextEntityAlignment.MIDDLE_CENTER)
offset = -h - text offset = -h - text
for tag_name in ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO"): for tag_name in ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO"):
blk.add_attdef(tag_name, insert=(-w, offset), height=text * 0.7, 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 return JAM_BLOCK
def _jam_position(area: JamArea, anlage: AnlageDocument) -> tuple[float, float] | None: def _jam_form(blk, text: float) -> None:
"""Lage eines Staubereichs (CSV-Koordinaten): vor seinem Separator. """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 def _jam_name(attdef, text: float) -> None:
die Traeger warten. Liegt der Vorgaenger naeher, nur die halbe Strecke. """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 layout = anlage.layout
part = layout.part(area.separator) if area.separator else None tro = layout.by_id(area.tro)
if part is not None and part.position is not None: if tro is not None and tro.position:
x, y = part.position[0], part.position[1] x, y = tro.position[0], tro.position[1]
elif area.position is not None: elif area.position is not None:
x, y = area.position[0], area.position[1] x, y = area.position[0], area.position[1]
else: else:
return None return None
for vor in area.predecessors: breite = 2 * text * JAM_HALB_BREITE
tro = layout.by_id(vor) abstand = marker + JAM_ABSTAND + breite / 2 + (area.entry - 1) * (breite + JAM_ABSTAND)
if tro is None or not tro.position:
continue def richtung(ids: list[str], vorzeichen: float):
vx, vy = tro.position[0] - x, tro.position[1] - y for other_id in ids:
dist = math.hypot(vx, vy) other = layout.by_id(other_id)
if dist > 1e-6: if other is None or not other.position:
schritt = min(JAM_VERSATZ, dist / 2) continue
return x + vx / dist * schritt, y + vy / dist * schritt vx, vy = other.position[0] - x, other.position[1] - y
return x, 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( def place_jams(
msp, doc, anlage: AnlageDocument, overrides: dict[str, tuple[str, int]], msp, doc, anlage: AnlageDocument, overrides: dict[str, tuple[str, int]],
transform: Transform, layers: dict[str, str], text: float, warnings: list[str], transform: Transform, layers: dict[str, str], text: float, warnings: list[str],
marker: float = DEFAULT_MARKER,
) -> int: ) -> int:
""" """
Je Staubereich der Anlagen-JSON einen JAM_AREA-Block einfuegen. Je Staubereich der Anlagen-JSON einen JAM_AREA-Block einfuegen.
@@ -849,7 +961,7 @@ def place_jams(
block = build_jam_symbol(doc, text) block = build_jam_symbol(doc, text)
gezeichnet = 0 gezeichnet = 0
for area in layout.jam_areas_or_derived(): for area in layout.jam_areas_or_derived():
lage = _jam_position(area, anlage) lage = _jam_position(area, anlage, marker, text)
if lage is None: if lage is None:
warnings.append(f"Staubereich {area.jam_id} ({area.tro}): keine Lage - " warnings.append(f"Staubereich {area.jam_id} ({area.tro}): keine Lage - "
f"nicht gezeichnet") 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 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( def draw_legend(
msp, analysis: Analysis, placed: dict[str, tuple[float, float]], msp, analysis: Analysis, placed: dict[str, tuple[float, float]],
layers: dict[str, str], marker: float, text: float, source: str, 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, " print(f" Restfehler = max {transform.residual_max:.1f} mm, "
f"mittel {transform.residual_mean:.1f} mm " f"mittel {transform.residual_mean:.1f} mm "
f"({transform.matched}/{transform.candidates} Punkte)") 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']}" print(f"Verbindungen = {counts['flow']}"
+ (" (Block CONNECTION_ARROW mit FROM_UID/TO_UID)" if counts["flow"] else "")) + (" (Block CONNECTION_ARROW mit FROM_UID/TO_UID)" if counts["flow"] else ""))
if counts.get("jams"): 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 " " tro_annotate.bat --file dematic_merge.csv --dxf dematic_merge..dxf --flow "
"--anlage dematic_merge_anlage.json\n" "--anlage dematic_merge_anlage.json\n"
" tro_annotate.bat --file dematic_merge.csv --read-jams\n" " tro_annotate.bat --file dematic_merge.csv --read-jams\n"
" tro_annotate.bat --file mubea.csv --read-tros --check\n"
"\n" "\n"
"Exit-Codes:\n" "Exit-Codes:\n"
" 0 ok\n" " 0 ok\n"
@@ -1085,6 +1415,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"<csv>_annotated.dxf in %%SKEL_RESULTS%%) und nach " "<csv>_annotated.dxf in %%SKEL_RESULTS%%) und nach "
"%%SKEL_CFG%%\\<csv>_jam_names.ini schreiben, dann beenden. " "%%SKEL_CFG%%\\<csv>_jam_names.ini schreiben, dann beenden. "
"scl_skeleton liest die Datei.") "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: <csv>_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", parser.add_argument("--check", action="store_true",
help="Nur pruefen und berichten, keine Datei schreiben.") help="Nur pruefen und berichten, keine Datei schreiben.")
parser.add_argument("--emit-lisp", action="store_true", parser.add_argument("--emit-lisp", action="store_true",
@@ -1125,6 +1462,96 @@ def _main_read_jams(args, csv_file: Path) -> int:
return 0 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
<name>_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: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
warnings: list[str] = [] warnings: list[str] = []
@@ -1145,10 +1572,12 @@ def main(argv: list[str] | None = None) -> int:
if args.read_jams is not None: if args.read_jams is not None:
return _main_read_jams(args, csv_file) 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: if not args.dxf:
print("FEHLER: --dxf fehlt (nur --emit-lisp und --read-jams gehen ohne " print("FEHLER: --dxf fehlt (nur --emit-lisp, --read-jams und --read-tros gehen "
"Zeichnung).", file=sys.stderr) "ohne Zeichnung).", file=sys.stderr)
return 1 return 1
if not csv_file.is_file(): if not csv_file.is_file():
print(f"FEHLER: CSV nicht gefunden: {csv_file}", file=sys.stderr) 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) f"nicht zu '{csv_file.name}'.", file=sys.stderr)
return 1 return 1
# TROs aus dem CSV # TROs aus dem CSV, mit den Handkorrekturen aus tro_overrides.ini
read_warnings: list[str] = [] overrides = load_overrides(csv_file.name, cfg_dir)
try: warnings += [w for w in overrides.warnings if ".edit" in w]
elements = read_elements(csv_file, read_warnings) derived = _derive(args, csv_file, overrides.edits)
except (OSError, ValueError, csv_module.Error) as exc: if derived is None:
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 <name>_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)
return 1 return 1
elements, analysis = derived
# Zeichnung # Zeichnung
try: try:
@@ -1274,6 +1683,7 @@ def main(argv: list[str] | None = None) -> int:
counts["marker"] = len(placed) counts["marker"] = len(placed)
counts["blocks"] = sum(1 for b in doc.blocks if b.name.startswith(BLOCK_PREFIX)) counts["blocks"] = sum(1 for b in doc.blocks if b.name.startswith(BLOCK_PREFIX))
counts["first_id"] = next_id counts["first_id"] = next_id
counts["parts"] = write_parts(doc, elements, transform)
if args.flow: if args.flow:
@@ -1285,7 +1695,7 @@ def main(argv: list[str] | None = None) -> int:
) )
if anlage is not None: if anlage is not None:
counts["jams"] = place_jams(msp, doc, anlage, jam_overrides, transform, layers, 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: if args.legend:
draw_legend(msp, analysis, placed, layers, args.marker_size, draw_legend(msp, analysis, placed, layers, args.marker_size,
args.text_height, csv_file.name) args.text_height, csv_file.name)
+223 -5
View File
@@ -115,7 +115,10 @@ ausschliesslich derive_tros(), Schritt fuer Schritt:
Alle anderen Funktionen (link_tros, locate_tros, derive_boundaries, ...) Alle anderen Funktionen (link_tros, locate_tros, derive_boundaries, ...)
aendern den Typ nicht mehr. cfg/tro_overrides.ini wird hier seit aendern den Typ nicht mehr. cfg/tro_overrides.ini wird hier seit
2026-10-05 nicht mehr angewendet (siehe tro_overrides.py) - ein falsch 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), Nicht abgeleitet werden bisher: 1Sep_SSCC (nur Hinweis, Schritt 6),
EmptyCarrBuffer und LoadingBoom. Schritt 3c braucht eine stimmige EmptyCarrBuffer und LoadingBoom. Schritt 3c braucht eine stimmige
@@ -181,12 +184,23 @@ from material_flow import (
resolve_input, resolve_input,
) )
from kreisel_junctions import Junction, find_junctions, separators_before 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 parameter import get_float, get_int
from tro_ids import ( from tro_ids import (
DEFAULT_TOLERANCE, DEFAULT_TOLERANCE,
TroIds, TroIds,
assign_ids, assign_ids,
id_problem,
ids_path, ids_path,
insert_entries, insert_entries,
other_sections, other_sections,
@@ -203,6 +217,7 @@ from tro_catalog import (
get_tro, get_tro,
mermaid_classdefs, mermaid_classdefs,
) )
from tro_overrides import PartRef, TroEdit, load_overrides
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Konstanten # 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" 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<i> -> separators, line<n> ->
die Lagerlinien am Ende von separators (je Linie ein Host), switch<i> ->
switches, scanner<i> -> 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( def analyse_tros(
graph: Graph, graph: Graph,
elements: list[Element], elements: list[Element],
*, *,
sepliste_counts: dict[str, int] | None = None, sepliste_counts: dict[str, int] | None = None,
tro_ids: TroIds | None = None, tro_ids: TroIds | None = None,
edits: list[TroEdit] | None = None,
) -> Analysis: ) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen. """TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
@@ -2250,6 +2450,8 @@ def analyse_tros(
Roh-JSON (load_sepliste_counts) - nur Gegenprobe. Roh-JSON (load_sepliste_counts) - nur Gegenprobe.
tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu tro_ids: ID-Datei der Anlage (siehe derive_tros); None = neu
nummerieren. nummerieren.
edits: Handkorrekturen aus BricsCAD (tro_overrides.load_overrides
(...).edits), angewandt direkt nach derive_tros (apply_edits).
Rueckgabe: Rueckgabe:
Analysis mit tros, boundaries, edges/edge_kinds, unknown_types Analysis mit tros, boundaries, edges/edge_kinds, unknown_types
@@ -2259,6 +2461,11 @@ def analyse_tros(
analysis = Analysis() analysis = Analysis()
analysis.tros = derive_tros(graph, elements, analysis.findings, analysis.tros = derive_tros(graph, elements, analysis.findings,
sepliste_counts=sepliste_counts, tro_ids=tro_ids) 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) analysis.boundaries = derive_boundaries(elements, graph)
# Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen: # Fuer Verortung und Verknuepfung zaehlen TROs und Systemgrenzen zusammen:
# link_tros() registriert eine Kante nur zu einem Knoten, der Host eines # 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: if result is None:
return "unveraendert" return "unveraendert"
anchors = separator_anchors(elements) 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: if result.renumbered:
tro_ids.path.parent.mkdir(parents=True, exist_ok=True) tro_ids.path.parent.mkdir(parents=True, exist_ok=True)
rest = other_sections(tro_ids.path) 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 ""), tro_ids.path.write_text(text + (f"\n{rest}\n" if rest else ""),
encoding="utf-8") encoding="utf-8")
return "neu geschrieben" if tro_ids.exists else "angelegt" return "neu geschrieben" if tro_ids.exists else "angelegt"
if result.new: if result.new:
insert_entries(tro_ids.path, 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 f"{len(result.new)} neue(r) TRO(s) angehaengt"
return "unveraendert" 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) print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return 1 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, analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids) tro_ids=tro_ids, edits=overrides.edits)
if not analysis.tros: if not analysis.tros:
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs " print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
f"ableitbar.", file=sys.stderr) f"ableitbar.", file=sys.stderr)
+10
View File
@@ -52,6 +52,7 @@ __all__ = [
"render_new_entries", "render_new_entries",
"insert_entries", "insert_entries",
"other_sections", "other_sections",
"id_problem",
] ]
SECTION = "tro" SECTION = "tro"
@@ -133,6 +134,15 @@ def _parser() -> configparser.ConfigParser:
return parser 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]: def load_ids(path: Path) -> list[IdEntry]:
"""ID-Datei lesen. ValueError bei doppelter ID, falscher Lage oder ID.""" """ID-Datei lesen. ValueError bei doppelter ID, falscher Lage oder ID."""
parser = _parser() parser = _parser()
+229 -2
View File
@@ -36,6 +36,31 @@ mehrere Anlagen in einer Datei stehen koennen):
Offene Punkte aendern die TRO-Struktur ausdruecklich **nicht**. 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 `<csv>_tro_ids.ini`, die ueber die Lage des Separators stabil bleibt -
anders als die laufenden Nummern, an denen die alten Abschnitte veraltet sind.
[<csv>.edit.<ID>] Korrektur an einem abgeleiteten TRO
type = 1Sep1Swi neuer Katalogtyp (fehlt = wie abgeleitet)
name = Einschleus_1 neue Kennung (fehlt = <ID> 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)
[<csv>.edit_add.<ID>] 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`, Die Felder heissen wie in lib/model.py (`type_name`, `separators`, `hosts`,
`reason`, `confidence`); Verbindungen und offene Punkte sind direkt `reason`, `confidence`); Verbindungen und offene Punkte sind direkt
`model.Connection` bzw. `model.TroCandidate`. `model.Connection` bzw. `model.TroCandidate`.
@@ -45,7 +70,9 @@ from __future__ import annotations
import configparser import configparser
import os import os
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path from pathlib import Path
from model import Connection, TroCandidate from model import Connection, TroCandidate
@@ -122,9 +149,84 @@ class MergeSpec:
confidence: str = DEFAULT_CONFIDENCE 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 '<TeileId> @ 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 ``<csv>_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 @dataclass
class Overrides: 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 source: Path | None = None
tros: dict[str, TroOverride] = field(default_factory=dict) tros: dict[str, TroOverride] = field(default_factory=dict)
@@ -133,6 +235,8 @@ class Overrides:
# Gesetzte Topologie: ersetzt die abgeleiteten Kanten vollstaendig # Gesetzte Topologie: ersetzt die abgeleiteten Kanten vollstaendig
connections: list[Connection] = field(default_factory=list) connections: list[Connection] = field(default_factory=list)
candidates: list[TroCandidate] = 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) # Protokoll der tatsaechlich angewandten Korrekturen (fuer die Doku)
applied: list[str] = field(default_factory=list) applied: list[str] = field(default_factory=list)
warnings: 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(), 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: else:
result.warnings.append( result.warnings.append(
f"[{section}]: unbekannte Art '{kind}' - erwartet 'tro', 'split', " 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) result.candidates.sort(key=lambda p: p.name)
return result 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
+1 -1
View File
@@ -138,7 +138,7 @@ REGION TRO18 (PinStore_Auto) Sep 0066, 0069, 0072, 0075, 0078, 0081, 0084, 0087,
#TRO18.stInBarcodeReader.Settings.tTimeout := T#3000ms; #TRO18.stInBarcodeReader.Settings.tTimeout := T#3000ms;
// TODO(E-Planung): Cognex-Interface verdrahten (#arCognexInterface["cScanner____"]) // 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; #TRO18.stInPriorityManager.xReleaseOk := TRUE;
+19 -4
View File
@@ -72,10 +72,25 @@ def test_staubereiche_als_block_mit_name_und_schluessel():
t2 = jams["TRO02"] t2 = jams["TRO02"]
assert (t2["CONVEYOR"], t2["NUMBER"], t2["ENTRY"], t2["AREA"], t2["AUTO"]) == ( assert (t2["CONVEYOR"], t2["NUMBER"], t2["ENTRY"], t2["AREA"], t2["AUTO"]) == (
"0042", "2", "1", "J0102", "0042_2") "0042", "2", "1", "J0102", "0042_2")
# vor dem Separator, Richtung Vorgaenger TRO01 (gegen den Fluss) # direkt vor dem Marker, Richtung Vorgaenger TRO01 (gegen den Fluss):
assert t2["pos"] == (5000 - round(tro_annotate.JAM_VERSATZ), 0) # Markerradius + Luft + halbe Rechteckbreite
# TRO01 hat nur eine Station als Vorgaenger: am Separator abstand = (tro_annotate.DEFAULT_MARKER + tro_annotate.JAM_ABSTAND
assert jams["TRO01"]["pos"] == (0, 0) + 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(): def test_korrektur_wird_gezeichnet():
+250
View File
@@ -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