tro_annotate --anlage: Staubereiche in der Zeichnung, --read-jams zurueck

Mit --anlage <csv>_anlage.json zeichnet tro_annotate je Staubereich einen
Block JAM_AREA (Layer TRO_JAM): Rechteck mit dem Namen Jam<NNNN>_<k> wie in
der SCL, jam_versatz vor dem Separator Richtung Vorgaenger. Attribute
CONVEYOR, NUMBER, TRO + ENTRY (stabiler Schluessel), AREA, AUTO. Doppelklick
oeffnet den Dialog JAM_EDIT (dxfmakros).

--read-jams uebernimmt im Dialog geaenderte Nummern aus der beschrifteten
Zeichnung nach cfg/<csv>_jam_names.ini (nur Abweichungen von AUTO);
scl_skeleton wendet die Datei mit der Anlagen-JSON an (scl_context:
jam_numbers + Korrekturen, load/save_jam_overrides).

Dematic: 25 Staubereiche gezeichnet; Rundreise TRO20 -> Jam0054_9 bis in die
SCL geprueft. Gemeinsame Zeichnung mit --offset 0,0 beschriften (automatischer
Lagebezug waehlte dy +360 mm).

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