validate_anlage, stabiler TRO-Schluessel, Verbindungen aus BricsCAD, Zeichnung aus der Anlagen-JSON

- validate_anlage.py (+ bin-Wrapper): Pruefung vor der SCL fuer beide Wege
  (Struktur, Zuordnungen, Aktualitaet der JSON, Korrekturen ohne Ziel);
  scl_skeleton ruft sie auf (Warnung, --strict bricht ab)
- DerivedTro.key: stabiler Schluessel (ID der ID-Datei); hw_overrides.ini,
  <csv>_jam_names.ini und tro_overrides.ini greifen ueber ID oder Schluessel,
  eine Umbenennung in TRO_EDIT laesst sie bestehen; JAM_AREA traegt KEY
- --read-tros liest die CONNECTION_ARROW-Bloecke mit -> [<csv>.edit_connections];
  tro_flow.apply_connection_edits wendet sie nach link_tros an
- tro_annotate --anlage zeichnet Marker/Verbindungen aus der Anlagen-JSON
- INI-Korrekturen auch mit BOM lesbar (utf-8-sig)
- Ablauf CSV/DXF+TIA -> Anlagen-JSON -> (BricsCAD) -> SCL in doc/Python_Scripts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 14:32:10 +01:00
parent 6c8c235287
commit 1ac17b95e2
13 changed files with 1100 additions and 48 deletions
+13 -1
View File
@@ -212,7 +212,9 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha
types, rows from `*TRO-SLOTS*`, pick-in-drawing from the XRECORD `SPS_SKEL_PARTS`) edits 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 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 `cfg/tro_overrides.ini` — the only kinds of that file `tro_flow` applies
(`tro_flow.apply_edits`, keyed on the stable ID-file IDs). (`tro_flow.apply_edits`, keyed on the stable ID-file IDs); since 2026-10-27 also the
connection blocks (`edit_connections`, `tro_flow.apply_connection_edits`). With
`--anlage` the markers/connections are drawn from the plant JSON itself.
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
@@ -248,6 +250,16 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha
open/contradicting bindings stay `TODO(E-Planung)`; open/contradicting bindings stay `TODO(E-Planung)`;
inserted names are checked against `<projekt>_hw.json` (`--strict` aborts). Templates are inserted names are checked against `<projekt>_hw.json` (`--strict` aborts). Templates are
unchanged. See `doc/Python_Scripts.md` §5b. unchanged. See `doc/Python_Scripts.md` §5b.
9. **`lib/validate_anlage.py`** (implemented 2026-10-27) — the gate before SCL for both paths
(no changes / after BricsCAD edits): structure, bindings, freshness (plant JSON older than
TRO-JSON/hw.json/hw_overrides → rerun merge), corrections that match nothing. Exit 5 = not
ready; `scl_skeleton` calls it (warns, `--strict` aborts). The ordered command list for
both paths is in `doc/Python_Scripts.md` §1 "The workflow, step by step".
Since 2026-10-27 every TRO carries a stable `key` (ID-file ID) next to its display `id`;
all corrections (`hw_overrides.ini`, `<csv>_jam_names.ini`, `tro_overrides.ini`) resolve by
ID **or** key, so a rename in `TRO_EDIT` keeps them. The plant JSON is never hand-patched —
it is regenerated from inputs + these overlays.
What is **not** built out yet, relative to the original two-tool design: What is **not** built out yet, relative to the original two-tool design:
+30
View File
@@ -0,0 +1,30 @@
@echo off
REM ================================================================
REM SPS_SKEL - Anlagen-JSON pruefen, bevor daraus SCL wird
REM ================================================================
REM Aufruf:
REM bin\validate_anlage.bat --json mubea_anlage.json
REM bin\validate_anlage.bat --json dematic_merge_anlage.json --strict
REM
REM Die JSON wird in %SKEL_RESULTS% gesucht. Exit-Code 0 = bereit,
REM 5 = nicht bereit (Fehler; mit --strict auch Warnungen).
REM ================================================================
setlocal
REM Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_LIB, PYTHONPATH ...)
call "%~dp0setenv.bat" >nul
REM Virtuelle Umgebung nutzen, falls vorhanden (pydantic wird benoetigt)
if exist "%SPS_SKEL%\.venv\Scripts\activate.bat" (
call "%SPS_SKEL%\.venv\Scripts\activate.bat"
)
REM Python-Interpreter bestimmen (py-Launcher bevorzugt, sonst python)
set "PY=py"
py --version >nul 2>&1 || set "PY=python"
"%PY%" "%SKEL_LIB%\validate_anlage.py" %*
set "RC=%ERRORLEVEL%"
endlocal & exit /b %RC%
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# ================================================================
# SPS_SKEL - Anlagen-JSON pruefen, bevor daraus SCL wird
# ================================================================
# Aufruf:
# bash bin/validate_anlage.sh --json mubea_anlage.json
# bash bin/validate_anlage.sh --json dematic_merge_anlage.json --strict
#
# Die JSON wird in $SKEL_RESULTS gesucht. Exit-Code 0 = bereit,
# 5 = nicht bereit (Fehler; mit --strict auch Warnungen).
# ================================================================
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Umgebung setzen (SKEL_DATA, SKEL_RESULTS, SKEL_LIB, PYTHONPATH ...)
# shellcheck source=/dev/null
. "$SCRIPT_DIR/setenv.sh" >/dev/null
# Virtuelle Umgebung nutzen, falls vorhanden (pydantic wird benoetigt)
if [ -f "$SPS_SKEL/.venv/bin/activate" ]; then
# shellcheck source=/dev/null
. "$SPS_SKEL/.venv/bin/activate"
fi
# Python-Interpreter bestimmen
PY="python3"
command -v python3 >/dev/null 2>&1 || PY="python"
exec "$PY" "$SKEL_LIB/validate_anlage.py" "$@"
+90 -3
View File
@@ -63,7 +63,40 @@ parked: tro_extract.py drawing ──► JSON (not extended; see §5a)
The drawing is a **view** for checking the result, not a data source: the The drawing is a **view** for checking the result, not a data source: the
TRO-JSON is written by `tro_flow.py --json` straight from the CSV, and TRO-JSON is written by `tro_flow.py --json` straight from the CSV, and
`tro_annotate.py` derives exactly the same TROs to draw them. `tro_annotate.py` derives exactly the same TROs to draw them — with
`--anlage` it draws the plant JSON itself (since 2026-10-27).
### The workflow, step by step (since 2026-10-27)
The plant JSON `<name>_anlage.json` is the **unified JSON**: layout (TROs,
connections, jam areas) + hardware bindings. It is always **regenerated**;
corrections made by people live in INI overlays in `%SKEL_CFG%` that are
keyed on stable IDs and survive a new CSV export:
| Overlay | Written by | Applied by | Keyed on |
|---|---|---|---|
| `tro_overrides.ini` `edit`/`edit_add`/`edit_connections` | `tro_annotate --read-tros` | `tro_flow` (and `tro_annotate`) | TRO key (`DerivedTro.key`) |
| `<csv>_jam_names.ini` | `tro_annotate --read-jams` | `scl_skeleton`, `tro_annotate` | `<TRO key>.<entry>` |
| `hw_overrides.ini` | by hand | `merge_hw_layout` | TRO ID **or** key |
```
1 tro_flow --file <csv> --json CSV -> <csv>_tro.json
2 hw_import --tia <x>_TIA.xlsx --positions|--dxf E-plan + TIA -> <projekt>_hw.json
3 merge_hw_layout --tro <csv>_tro.json --hw <projekt>_hw.json -> <csv>_anlage.json
4 validate_anlage --json <csv>_anlage.json ready for SCL? (§5f)
Path A (no changes): scl_skeleton --json <csv>_anlage.json [--templates] [--strict]
Path B (changes): tro_annotate --file <csv> --dxf <dwg>.dxf --flow --anlage <csv>_anlage.json
... edit in BricsCAD (TRO_EDIT, TRO_INSERT, JAM_EDIT,
CONNECTION_INSERT/_EDIT, delete markers/arrow blocks), save as DXF
tro_annotate --file <csv> --read-tros -> tro_overrides.ini
tro_annotate --file <csv> --read-jams -> <csv>_jam_names.ini
then steps 1, 3, 4 again, then Path A
```
`scl_skeleton` runs the step-4 check itself (warns; `--strict` aborts on
errors), so an outdated plant JSON — e.g. step 3 forgotten after step 1 — is
reported, not silently turned into SCL.
- `material_flow.py` models the material flow of the **mechanical** objects only - `material_flow.py` models the material flow of the **mechanical** objects only
(Gefällestrecke, Strecke, Kreisel). (Gefällestrecke, Strecke, Kreisel).
@@ -467,7 +500,36 @@ 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 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 `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 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 (§4); a re-read of the new drawing gives the same sections.
**Connections (since 2026-10-27).** `--read-tros` also reads every
`CONNECTION_ARROW` block. Each end is matched to a marker of the same drawing
by its visible name (`FROM_TRO`/`TO_TRO`), else by the internal number
(`FROM_UID`/`TO_UID`; older dxfmakros blocks `FROM_ID`/`TO_ID`) — so arrows
still point right after a marker was renamed. The arrows are compared with the
derivation *including* the TRO corrections (edges of a new or edited TRO do not
count as hand-drawn); the differences go to `[<csv>.edit_connections]` as
`+ KEY_A -> KEY_B | kind` / `- KEY_A -> KEY_B`, ends as stable keys.
`tro_flow.apply_connection_edits` applies them after `link_tros` and resets
predecessors/successors, so the jam areas follow. Station arrows carry no
block and stay as derived; an arrow is deleted by deleting its block. Before
this, `CONNECTION_INSERT`/`_EDIT` in dxfmakros wrote attribute names the
blocks did not have (`ID`/`FROM_ID`/`TO_ID`, marker name from `TRO_ID`) — fixed
there on the same day.
**Stable key (since 2026-10-27).** Every TRO in the TRO-JSON carries `key`,
its ID in `<csv>_tro_ids.ini` (or, for a TRO added in BricsCAD, its own ID).
It differs from `id` only after a rename in `TRO_EDIT`. All corrections are
resolved by ID **or** key (`TroDocument.resolve`, `scl_context.jam_override`),
so a rename does not orphan `hw_overrides.ini` or `<csv>_jam_names.ini`
entries. Jam blocks carry a hidden `KEY` (`<TRO key>.<entry>`) that
`--read-jams` writes; an entry under the old display ID is moved to the key.
**With `--anlage` the drawing shows the plant JSON** (since 2026-10-27):
markers, connections and stations come from `anlage.layout`
(`analysis_from_document`), i.e. exactly what becomes SCL. The CSV is still
read for the registration and the parts table; if its derivation differs from
the JSON, a warning says the JSON is probably outdated. `--check` only
reports. If the drawing has no marker of the derived TROs (wrong file, or reports. If the drawing has no marker of the derived TROs (wrong file, or
annotated before the `KEY`/`SLOTS` attributes), nothing is written — annotated before the `KEY`/`SLOTS` attributes), nothing is written —
re-annotating an old drawing adds the missing attributes to the existing re-annotating an old drawing adds the missing attributes to the existing
@@ -496,7 +558,7 @@ verified on every run, and cached per CSV/DXF filename pair in
| `--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`. | | `--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. | | `--read-tros` | `[DXF]` | `<csv>_annotated.dxf` | Read the TRO markers edited in BricsCAD (`TRO_EDIT`/`TRO_INSERT`, deleted markers) and the connection blocks (`CONNECTION_INSERT`/`_EDIT`, deleted blocks) back from the annotated drawing (saved as DXF) into the `edit`/`edit_add`/`edit_connections` 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`: an upright grey rectangle with the name written JSON on layer `TRO_JAM`: an upright grey rectangle with the name written
@@ -1007,6 +1069,31 @@ fingerprint of the stored registration, CLI end to end with a real offset,
merged drawing without registration, two drive TROs on one conveyor, Dematic merged drawing without registration, two drive TROs on one conveyor, Dematic
rules file). rules file).
## 5f. `validate_anlage.py` — is the plant JSON ready for SCL?
One check for both paths of the workflow (§1): directly after
`merge_hw_layout` and after BricsCAD corrections have been read back and the
JSON rebuilt. `scl_skeleton` calls it before writing (findings as warnings;
with `--strict`, errors stop the run). Works on a plant JSON and, reduced, on
a TRO-JSON.
| Area | ERROR (no SCL like this) | WARNING (SCL with open spots) |
|---|---|---|
| Structure | duplicate TRO-ID, type not in catalog, connection or jam area to an unknown TRO, separator in two TROs | separator slot without layout part, TRO without connection, TRO not reachable from the material entry |
| Bindings | binding to an unknown TRO/slot, status `widerspruch`, BMK missing in `<projekt>_hw.json` | open mandatory roles (counted per role) |
| Freshness | plant JSON older than TRO-JSON, hardware planning or `hw_overrides.ini` (only if it has sections for this CSV) → run `merge_hw_layout` again | `tro_overrides.ini` with BricsCAD corrections newer than the TRO-JSON → run `tro_flow --json` again |
| Corrections | — | entries in `hw_overrides.ini` / `<csv>_jam_names.ini` that match no TRO/jam area, neither by ID nor by key |
| Switch | Argument | Default | Meaning |
|---|---|---|---|
| `--json` | `NAME` | — | Plant JSON (or TRO-JSON) in `%SKEL_RESULTS%`, or a path. |
| `--hw` | `NAME` | `hwFile` of the plant JSON | Hardware planning for the BMK check. |
| `--strict` | — | off | Warnings also make the JSON "not ready". |
**Exit codes:** `0` ready · `1` input error · `5` not ready.
Mubea on 2026-10-27: the plant JSON in `results/` was reported older than the
TRO-JSON — exactly the TRO18/TRO19 mismatch `tro_annotate --anlage` showed.
## 6. `tro_catalog.py` — TRO type catalog (library, no CLI) ## 6. `tro_catalog.py` — TRO type catalog (library, no CLI)
The single source of truth for everything needed to generate or draw a TRO The single source of truth for everything needed to generate or draw a TRO
+6 -2
View File
@@ -616,7 +616,8 @@ def load_overrides(path: Path, layout_source: str) -> Overrides:
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"),
interpolation=None) interpolation=None)
parser.optionxform = str parser.optionxform = str
with path.open(encoding="utf-8") as fh: # utf-8-sig: von Hand (Editor, PowerShell) gespeichert oft mit BOM
with path.open(encoding="utf-8-sig") as fh:
parser.read_file(fh) parser.read_file(fh)
section = f"{layout_source}.bindings" section = f"{layout_source}.bindings"
if parser.has_section(section): if parser.has_section(section):
@@ -639,7 +640,10 @@ def _apply_overrides(doc: TroDocument, bindings: list[Binding],
hw: HardwareDocument, warnings: list[str]) -> list[Binding]: hw: HardwareDocument, warnings: list[str]) -> list[Binding]:
devices = hw.by_bmk() devices = hw.by_bmk()
slots = {(t.tro_id, s.slot): s for t in doc.tros for s in t.slots} slots = {(t.tro_id, s.slot): s for t in doc.tros for s in t.slots}
for (tro, slot, role), bmk in overrides.bindings.items(): for (name, slot, role), bmk in overrides.bindings.items():
# ID oder - nach einer Umbenennung in BricsCAD - der stabile Schluessel
found = doc.resolve(name)
tro = found.tro_id if found is not None else name
s = slots.get((tro, slot)) s = slots.get((tro, slot))
if s is None: if s is None:
warnings.append(f"{OVERRIDES_FILE}: {tro}.{slot} gibt es in der TRO-JSON nicht " warnings.append(f"{OVERRIDES_FILE}: {tro}.{slot} gibt es in der TRO-JSON nicht "
+23 -1
View File
@@ -332,6 +332,12 @@ class DerivedTro(TroBase):
""" """
label: str label: str
# Stabiler Schluessel: die ID aus <csv>_tro_ids.ini (bzw. die Kennung eines
# in BricsCAD angelegten TRO). Weicht von ``tro_id`` ab, wenn der TRO im
# Dialog TRO_EDIT umbenannt wurde. Handkorrekturen (hw_overrides.ini,
# <csv>_jam_names.ini, tro_overrides.ini) gelten ueber ID *oder* Schluessel,
# damit sie eine Umbenennung ueberleben. "" in Dateien vor 2026-10-27.
key: str = ""
hosts: list[str] hosts: list[str]
scanners: list[str] scanners: list[str]
# Weichen-Bauteile (Ausschleuselement oder ILS Weiche), Reihenfolge wie die # Weichen-Bauteile (Ausschleuselement oder ILS Weiche), Reihenfolge wie die
@@ -351,6 +357,11 @@ class DerivedTro(TroBase):
def connected(self) -> bool: def connected(self) -> bool:
return bool(self.successors or self.predecessors or self.colocated) return bool(self.successors or self.predecessors or self.colocated)
@property
def stable_key(self) -> str:
"""Schluessel fuer Handkorrekturen (``key``, sonst die ID)."""
return self.key or self.tro_id
class Connection(BaseModel): class Connection(BaseModel):
"""Gerichtete Verbindung zwischen zwei TROs/Stationen (JSON: from/to/kind).""" """Gerichtete Verbindung zwischen zwei TROs/Stationen (JSON: from/to/kind)."""
@@ -451,7 +462,9 @@ class TroDocument(BaseModel):
traegt und nicht erst das Rendern sie kennt. ``parts`` (Lage der traegt und nicht erst das Rendern sie kennt. ``parts`` (Lage der
Layout-Bauteile) gibt tro_flow aus dem CSV mit. Layout-Bauteile) gibt tro_flow aus dem CSV mit.
""" """
tros = [t.model_copy(update={"slots": derive_slots(t)}) for t in analysis.tros] tros = [t.model_copy(update={"slots": derive_slots(t),
"key": analysis.edit_key(t.tro_id)})
for t in analysis.tros]
return cls( return cls(
source=source, source=source,
note=note, note=note,
@@ -490,6 +503,15 @@ class TroDocument(BaseModel):
return tro return tro
return None return None
def resolve(self, name: str) -> DerivedTro | None:
"""TRO zu einer ID oder - nach einer Umbenennung - zu seinem Schluessel.
Die ID geht vor: ein neuer TRO darf die alte Kennung eines umbenannten
tragen, dann meint ein Eintrag mit dieser Kennung den neuen.
"""
return self.by_id(name) or next(
(t for t in self.tros if t.key and t.key == name), None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Anlagen-JSON: Layout + Zuordnungen der Hardwareplanung (merge_hw_layout) # Anlagen-JSON: Layout + Zuordnungen der Hardwareplanung (merge_hw_layout)
+25 -3
View File
@@ -68,6 +68,8 @@ __all__ = [
"flow_order", "flow_order",
"JAM_NAMES_SUFFIX", "JAM_NAMES_SUFFIX",
"jam_key", "jam_key",
"jam_override",
"jam_stable_key",
"jam_label", "jam_label",
"jam_names", "jam_names",
"jam_numbers", "jam_numbers",
@@ -653,6 +655,25 @@ def jam_key(area: JamArea) -> str:
return f"{area.tro}.{area.entry}" return f"{area.tro}.{area.entry}"
def jam_stable_key(area: JamArea, document: TroDocument) -> str:
"""Wie jam_key, aber mit dem stabilen Schluessel des TRO (DerivedTro.key):
bleibt gleich, wenn der TRO in BricsCAD umbenannt wird."""
tro = document.by_id(area.tro)
return f"{tro.stable_key}.{area.entry}" if tro is not None else jam_key(area)
def jam_override(area: JamArea, document: TroDocument,
overrides: dict[str, tuple[str, int]] | None) -> tuple[str, int] | None:
"""Korrektur eines Staubereichs: ueber den stabilen Schluessel, sonst ueber
die aktuelle TRO-ID (Dateien vor 2026-10-27 bzw. ohne Umbenennung)."""
if not overrides:
return None
for key in (jam_stable_key(area, document), jam_key(area)):
if key in overrides:
return overrides[key]
return None
def jam_label(nr: str, k: int) -> str: def jam_label(nr: str, k: int) -> str:
"""Kurzname fuer Zeichnung und Dialog: Jam0054_3.""" """Kurzname fuer Zeichnung und Dialog: Jam0054_3."""
return f"Jam{nr}_{k}" return f"Jam{nr}_{k}"
@@ -671,7 +692,8 @@ def load_jam_overrides(path: Path) -> dict[str, tuple[str, int]]:
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"),
interpolation=None) interpolation=None)
parser.optionxform = str parser.optionxform = str
with path.open(encoding="utf-8") as fh: # utf-8-sig: von Hand (Editor, PowerShell) gespeichert oft mit BOM
with path.open(encoding="utf-8-sig") as fh:
parser.read_file(fh) parser.read_file(fh)
result: dict[str, tuple[str, int]] = {} result: dict[str, tuple[str, int]] = {}
if parser.has_section("jam"): if parser.has_section("jam"):
@@ -711,8 +733,8 @@ def jam_names(document: TroDocument, geordnet: list[TroBase], hw: HwSource,
""" """
nummern = jam_numbers(document, geordnet, hw) nummern = jam_numbers(document, geordnet, hw)
for area in document.jam_areas_or_derived(): for area in document.jam_areas_or_derived():
if overrides and jam_key(area) in overrides: if (wert := jam_override(area, document, overrides)) is not None:
nummern[area.jam_id] = overrides[jam_key(area)] nummern[area.jam_id] = wert
return {jam_id: f'"DB_JamArea{nr}".stJam{nr}_{k}' return {jam_id: f'"DB_JamArea{nr}".stJam{nr}_{k}'
for jam_id, (nr, k) in nummern.items()} for jam_id, (nr, k) in nummern.items()}
+17
View File
@@ -59,6 +59,7 @@ from pydantic import BaseModel, ValidationError
from e_planning import EPLAN_SCHEMA, EplanDocument from e_planning import EPLAN_SCHEMA, EplanDocument
from hw_import import HardwareDocument from hw_import import HardwareDocument
from validate_anlage import validate
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 ( from scl_context import (
@@ -441,6 +442,20 @@ def main(argv: list[str] | None = None) -> int:
print(f"FEHLER: {json_file} enthaelt keine TROs.", file=sys.stderr) print(f"FEHLER: {json_file} enthaelt keine TROs.", file=sys.stderr)
return 1 return 1
# Pruefung vor der SCL (validate_anlage): Fehler brechen nur mit --strict
# ab, sonst stehen sie als Warnung im Bericht.
pruefung = validate(json_file, hw_file=_in_results(args.hw, results) if args.hw else None,
results=json_file.parent)
for befund in pruefung.befunde:
if befund.stufe == "FEHLER" or args.strict:
print(f"{'FEHLER' if args.strict else 'WARNUNG'}: {befund.bereich}: {befund.text}",
file=sys.stderr)
if args.strict and not pruefung.bereit():
print(f" (--strict) {pruefung.anzahl_fehler} Fehler in der Pruefung - keine "
f"SCL geschrieben (Details: bin/validate_anlage --json {json_file.name}).",
file=sys.stderr)
return 1
templates = None templates = None
if args.templates is not None: if args.templates is not None:
ordner = Path(args.templates) ordner = Path(args.templates)
@@ -550,6 +565,8 @@ def main(argv: list[str] | None = None) -> int:
print(f"Gegenpruefung = {len(unbekannt)} unbekannte Namen ({hw_file.name})") print(f"Gegenpruefung = {len(unbekannt)} unbekannte Namen ({hw_file.name})")
if jam_overrides: if jam_overrides:
print(f"Staubereiche = {len(jam_overrides)} Korrekturen aus {jam_file}") print(f"Staubereiche = {len(jam_overrides)} Korrekturen aus {jam_file}")
print(f"Pruefung = {pruefung.anzahl_fehler} Fehler, {pruefung.anzahl_warnungen} "
f"Warnungen (validate_anlage)")
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("================================================================")
+165 -32
View File
@@ -143,20 +143,29 @@ from model import (
AnlageDocument, AnlageDocument,
DerivedTro, DerivedTro,
JamArea, JamArea,
TroDocument,
derive_slots, derive_slots,
has_lines, has_lines,
slot_kind, slot_kind,
slot_names, slot_names,
) )
from tro_ids import id_problem from tro_ids import id_problem
from tro_overrides import PartRef, TroEdit, load_overrides, override_path, save_edits from tro_overrides import (
ConnectionEdit,
PartRef,
TroEdit,
load_overrides,
override_path,
save_edits,
)
from scl_context import ( from scl_context import (
JAM_NAMES_SUFFIX, JAM_NAMES_SUFFIX,
HwSource, HwSource,
flow_order, flow_order,
jam_key,
jam_label, jam_label,
jam_numbers, jam_numbers,
jam_override,
jam_stable_key,
load_jam_overrides, load_jam_overrides,
save_jam_overrides, save_jam_overrides,
) )
@@ -180,6 +189,8 @@ JAM_BLOCK = "JAM_AREA"
PARTS_XRECORD = "SPS_SKEL_PARTS" PARTS_XRECORD = "SPS_SKEL_PARTS"
# Unsichtbare Attribute der Marker (build_symbol) # Unsichtbare Attribute der Marker (build_symbol)
HIDDEN_TAGS = ("UID", "KEY", "ITEMS", "CONFIDENCE", "SLOTS") HIDDEN_TAGS = ("UID", "KEY", "ITEMS", "CONFIDENCE", "SLOTS")
# Unsichtbare Attribute der Staubereiche (build_jam_symbol)
JAM_HIDDEN_TAGS = ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO", "KEY")
# Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar) # Anzeige eines Staubereichs ohne Foerderer (Nummer nicht ableitbar)
JAM_OFFEN = "Jam????_?" JAM_OFFEN = "Jam????_?"
@@ -854,35 +865,49 @@ def build_jam_symbol(doc, text: float) -> str:
AREA unsichtbar - Kennung der TRO-JSON (J0039) AREA unsichtbar - Kennung der TRO-JSON (J0039)
AUTO unsichtbar - abgeleitete Nummer NNNN_k; --read-jams schreibt AUTO unsichtbar - abgeleitete Nummer NNNN_k; --read-jams schreibt
nur, was davon abweicht nur, was davon abweicht
TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key). KEY unsichtbar - stabiler Schluessel <TRO-Schluessel>.<Eingang>
(scl_context.jam_stable_key), ueberlebt eine Umbenennung
KEY (sonst TRO + ENTRY) ist der Schluessel in <csv>_jam_names.ini.
""" """
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
if JAM_BLOCK in doc.blocks: if JAM_BLOCK in doc.blocks:
# Form aus einem frueheren Lauf (liegendes Rechteck) ersetzen; die # Form aus einem frueheren Lauf (liegendes Rechteck) ersetzen; die
# Attributdefinitionen bleiben, damit vorhandene Bloecke gueltig sind. # Attributdefinitionen bleiben, damit vorhandene Bloecke gueltig sind,
# fehlende (KEY) kommen dazu.
blk = doc.blocks.get(JAM_BLOCK) blk = doc.blocks.get(JAM_BLOCK)
for e in list(blk): for e in list(blk):
if e.dxftype() != "ATTDEF": if e.dxftype() != "ATTDEF":
blk.delete_entity(e) blk.delete_entity(e)
_jam_form(blk, text) _jam_form(blk, text)
vorhanden = set()
for a in blk.query("ATTDEF"): for a in blk.query("ATTDEF"):
vorhanden.add(a.dxf.tag.upper())
if a.dxf.tag.upper() == "NAME": if a.dxf.tag.upper() == "NAME":
_jam_name(a, text) _jam_name(a, text)
offset = min((a.dxf.insert.y for a in blk.query("ATTDEF")), default=-h)
for tag_name in JAM_HIDDEN_TAGS:
if tag_name not in vorhanden:
offset -= text
_jam_attdef(blk, tag_name, -w, offset, text)
return JAM_BLOCK return JAM_BLOCK
blk = doc.blocks.new(name=JAM_BLOCK) blk = doc.blocks.new(name=JAM_BLOCK)
_jam_form(blk, text) _jam_form(blk, text)
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
name = blk.add_attdef("NAME", insert=(0, 0), height=text * JAM_TEXT, name = blk.add_attdef("NAME", insert=(0, 0), height=text * JAM_TEXT,
dxfattribs={"style": "Standard", "text": JAM_OFFEN}) dxfattribs={"style": "Standard", "text": JAM_OFFEN})
_jam_name(name, text) _jam_name(name, text)
offset = -h - text offset = -h - text
for tag_name in ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO"): for tag_name in JAM_HIDDEN_TAGS:
blk.add_attdef(tag_name, insert=(-w, offset), height=text * 0.7, _jam_attdef(blk, tag_name, -w, offset, text)
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1})
offset -= text offset -= text
return JAM_BLOCK return JAM_BLOCK
def _jam_attdef(blk, tag_name: str, x: float, y: float, text: float) -> None:
blk.add_attdef(tag_name, insert=(x, y), height=text * 0.7,
dxfattribs={"style": "Standard", "color": 256, "text": "",
"invisible": 1})
def _jam_form(blk, text: float) -> None: def _jam_form(blk, text: float) -> None:
"""Stehendes Rechteck: graue Fuellung, grauer Rand.""" """Stehendes Rechteck: graue Fuellung, grauer Rand."""
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
@@ -967,7 +992,7 @@ def place_jams(
f"nicht gezeichnet") f"nicht gezeichnet")
continue continue
auto = automatisch.get(area.jam_id) auto = automatisch.get(area.jam_id)
nr, k = overrides.get(jam_key(area), auto or ("", 0)) nr, k = jam_override(area, layout, overrides) or auto or ("", 0)
ref = msp.add_blockref(block, transform.to_dxf(*lage), ref = msp.add_blockref(block, transform.to_dxf(*lage),
dxfattribs={"layer": layers["jam"]}) dxfattribs={"layer": layers["jam"]})
ref.add_auto_attribs({ ref.add_auto_attribs({
@@ -978,6 +1003,8 @@ def place_jams(
"ENTRY": str(area.entry), "ENTRY": str(area.entry),
"AREA": area.jam_id, "AREA": area.jam_id,
"AUTO": f"{auto[0]}_{auto[1]}" if auto else "", "AUTO": f"{auto[0]}_{auto[1]}" if auto else "",
# Schluessel fuer --read-jams: ueberlebt eine Umbenennung des TRO
"KEY": jam_stable_key(area, layout),
}) })
tag(ref, area.tro) tag(ref, area.tro)
gezeichnet += 1 gezeichnet += 1
@@ -999,11 +1026,16 @@ def read_jams(dxf_file: Path, ini_file: Path) -> tuple[dict[str, tuple[str, int]
namen: dict[str, str] = {} namen: dict[str, str] = {}
for ref in doc.modelspace().query(f'INSERT[name=="{JAM_BLOCK}"]'): 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} a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
key = f"{a.get('TRO', '')}.{a.get('ENTRY', '')}" # Stabiler Schluessel (KEY, seit 2026-10-27), sonst TRO-ID + Eingang
anzeige = f"{a.get('TRO', '')}.{a.get('ENTRY', '')}"
key = a.get("KEY") or anzeige
nr, k = a.get("CONVEYOR", ""), a.get("NUMBER", "") nr, k = a.get("CONVEYOR", ""), a.get("NUMBER", "")
if not a.get("TRO") or not a.get("ENTRY"): if not a.get("TRO") or not a.get("ENTRY"):
meldungen.append(f"Staubereich ohne TRO/ENTRY ({a.get('NAME', '?')}) - uebersprungen") meldungen.append(f"Staubereich ohne TRO/ENTRY ({a.get('NAME', '?')}) - uebersprungen")
continue continue
if key != anzeige and anzeige in overrides:
# Eintrag unter der alten Anzeige-ID auf den Schluessel umstellen
overrides.setdefault(key, overrides.pop(anzeige))
if not nr and not k: if not nr and not k:
continue continue
if not (nr.isdigit() and len(nr) == 4 and k.isdigit() and int(k) > 0): if not (nr.isdigit() and len(nr) == 4 and k.isdigit() and int(k) > 0):
@@ -1085,33 +1117,48 @@ def write_parts(doc, elements, transform: Transform) -> int:
return len(tags) // 5 return len(tags) // 5
def read_markers(dxf_file: Path) -> list[dict[str, str]]: def read_markers(dxf_file: Path) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Attribute aller TRO-Marker (Bloecke TRO_SYM_*) der Zeichnung.""" """Attribute aller TRO-Marker (Bloecke TRO_SYM_*) und aller
Verbindungsbloecke (CONNECTION_ARROW) der Zeichnung."""
doc = ezdxf.readfile(str(dxf_file)) doc = ezdxf.readfile(str(dxf_file))
markers = [] markers, arrows = [], []
for ref in doc.modelspace().query("INSERT"): for ref in doc.modelspace().query("INSERT"):
if not ref.dxf.name.upper().startswith(BLOCK_PREFIX): name = ref.dxf.name.upper()
if name.startswith(BLOCK_PREFIX):
ziel = markers
elif name == CONNECTION_BLOCK:
ziel = arrows
else:
continue continue
a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs} a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
a["_BLOCK"] = ref.dxf.name a["_BLOCK"] = ref.dxf.name
markers.append(a) ziel.append(a)
return markers return markers, arrows
def read_tros(markers: list[dict[str, str]], raw: Analysis, elements, def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
csv_name: str, cfg_dir: Path | None = None, csv_name: str, cfg_dir: Path | None = None,
write: bool = True) -> tuple[list[TroEdit], list[str]]: write: bool = True,
arrows: list[dict[str, str]] | None = None,
base: Analysis | None = None) -> tuple[list[TroEdit], list[str]]:
"""Marker der Zeichnung mit der Ableitung vergleichen -> Handkorrekturen. """Marker der Zeichnung mit der Ableitung vergleichen -> Handkorrekturen.
``raw`` ist die Ableitung OHNE Handkorrekturen (IDs aus der ID-Datei). Je ``raw`` ist die Ableitung OHNE Handkorrekturen (IDs aus der ID-Datei). Je
Marker mit KEY eines abgeleiteten TRO entsteht ein edit-Abschnitt, wenn Marker mit KEY eines abgeleiteten TRO entsteht ein edit-Abschnitt, wenn
Typ, Kennung oder Bauteile abweichen; ein Marker ohne passenden KEY ist Typ, Kennung oder Bauteile abweichen; ein Marker ohne passenden KEY ist
ein neuer TRO (edit_add); ein abgeleiteter TRO mit Lage, zu dem kein ein neuer TRO (edit_add); ein abgeleiteter TRO mit Lage, zu dem kein
Marker mehr da ist, wurde geloescht. Die Abschnitte ersetzen alle Marker mehr da ist, wurde geloescht. Mit ``arrows`` (Verbindungsbloecke)
bisherigen edit-/edit_add-Abschnitte der CSV in tro_overrides.ini kommen die geaenderten Verbindungen dazu (_read_connections, Abschnitt
(Begruendungen bleiben). Rueckgabe: (Korrekturen, Meldungen). edit_connections). Die Abschnitte ersetzen alle bisherigen
edit-/edit_add-/edit_connections-Abschnitte der CSV in tro_overrides.ini
(Begruendungen bleiben). Ohne ``arrows`` bleibt ein vorhandener
edit_connections-Abschnitt stehen. ``base`` ist die Ableitung MIT den
TRO-Korrekturen, aber ohne Verbindungskorrekturen - verglichen werden die
Pfeile mit ihr, damit Kanten eines geaenderten oder neuen TRO nicht als
Handkorrektur gelten (Standard: ``raw``). Rueckgabe: (Korrekturen, Meldungen).
""" """
meldungen: list[str] = [] meldungen: list[str] = []
marker_key: dict[int, str] = {}
by_id = {e.teile_id: e for e in elements} by_id = {e.teile_id: e for e in elements}
alt = {(e.section_kind, e.key): e alt = {(e.section_kind, e.key): e
for e in load_overrides(csv_name, cfg_dir).edits} for e in load_overrides(csv_name, cfg_dir).edits}
@@ -1176,6 +1223,8 @@ def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
raw_slots, raw_lines = tro_slots(derived) raw_slots, raw_lines = tro_slots(derived)
else: else:
raw_slots, raw_lines = {}, [] raw_slots, raw_lines = {}, []
# Schluessel des Markers fuer die Verbindungen: abgeleitet = KEY, neu = ID
marker_key[id(m)] = key if derived is not None else tro_id
if lines is None: if lines is None:
lines = raw_lines if has_lines(type_name) else [] lines = raw_lines if has_lines(type_name) else []
pruefen(where, type_name, slots, lines) pruefen(where, type_name, slots, lines)
@@ -1216,11 +1265,64 @@ def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
edits.append(edit) edits.append(edit)
meldungen.append(_describe_edit(edit)) meldungen.append(_describe_edit(edit))
if arrows is not None:
connections = _read_connections(arrows, markers, marker_key, base or raw, meldungen)
else:
# ohne Verbindungsbloecke: bisherige Verbindungskorrekturen behalten
connections = load_overrides(csv_name, cfg_dir).connection_edits
if write: if write:
save_edits(csv_name, edits, cfg_dir) save_edits(csv_name, edits, cfg_dir, connections)
return edits, meldungen return edits, meldungen
def _read_connections(arrows: list[dict[str, str]], markers: list[dict[str, str]],
marker_key: dict[int, str], base: Analysis,
meldungen: list[str]) -> list[ConnectionEdit]:
"""Verbindungsbloecke der Zeichnung gegen die abgeleiteten TRO-Kanten.
Die Enden eines CONNECTION_ARROW werden ueber die Marker derselben
Zeichnung zu stabilen Schluesseln: erst ueber den sichtbaren Namen
(FROM_TRO/TO_TRO, das sieht der Bearbeiter), sonst ueber die interne
Nummer (FROM_UID/TO_UID, bei dxfmakros-Bloecken frueher FROM_ID/TO_ID) -
so stimmt es auch nach einer Umbenennung des Markers. Kanten von/zu
Stationen tragen keinen Block und bleiben, wie abgeleitet; Kanten zu
geloeschten Markern fallen mit dem TRO weg.
"""
by_name = {m.get("ID", ""): m for m in markers if id(m) in marker_key}
by_uid = {m.get("UID", ""): m for m in markers if id(m) in marker_key and m.get("UID")}
def ende(a: dict[str, str], seite: str) -> str | None:
m = by_name.get(a.get(f"{seite}_TRO", ""))
if m is None:
m = by_uid.get(a.get(f"{seite}_UID", "") or a.get(f"{seite}_ID", ""))
return marker_key.get(id(m)) if m is not None else None
gewollt: dict[tuple[str, str], str] = {}
for a in arrows:
src, dst = ende(a, "FROM"), ende(a, "TO")
if src is None or dst is None:
meldungen.append(f"Verbindung {a.get('FROM_TRO') or '?'} -> "
f"{a.get('TO_TRO') or '?'}: Ende passt zu keinem Marker - "
f"uebersprungen")
continue
if src == dst:
meldungen.append(f"Verbindung {src} -> {dst}: Quelle = Ziel - uebersprungen")
continue
gewollt[(src, dst)] = "weiche" if a.get("KIND") == "weiche" else ""
vorhanden = set(marker_key.values())
tro_ids = {t.tro_id for t in base.tros}
# Kanten der Ableitung, Enden als stabile Schluessel wie die Marker
abgeleitet = {(base.edit_key(s), base.edit_key(d)) for s, d in base.edges
if s in tro_ids and d in tro_ids}
changes = [ConnectionEdit(add=False, src=s, dst=d) for s, d in sorted(abgeleitet)
if (s, d) not in gewollt and s in vorhanden and d in vorhanden]
changes += [ConnectionEdit(add=True, src=s, dst=d, kind=k)
for (s, d), k in sorted(gewollt.items()) if (s, d) not in abgeleitet]
meldungen += [f"Verbindung {c.render()}" for c in changes]
return changes
def _describe_edit(edit: TroEdit) -> str: def _describe_edit(edit: TroEdit) -> str:
if edit.delete: if edit.delete:
return f"{edit.key}: geloescht" return f"{edit.key}: geloescht"
@@ -1462,7 +1564,27 @@ def _main_read_jams(args, csv_file: Path) -> int:
return 0 return 0
def _derive(args, csv_file: Path, edits: list[TroEdit] | None): def analysis_from_document(doc: TroDocument) -> Analysis:
"""Analysis aus einer TRO-/Anlagen-JSON, damit die Zeichenfunktionen
(place_markers, draw_connections, place_stations) die JSON zeigen statt
einer neuen Ableitung. Umbenennungen kommen aus DerivedTro.key."""
tros = [t.model_copy(deep=True) for t in doc.tros]
analysis = Analysis(
tros=tros,
boundaries=[b.model_copy(deep=True) for b in doc.boundaries],
edges=[(c.src, c.dst) for c in doc.connections],
edge_kinds={(c.src, c.dst): c.kind for c in doc.connections},
findings=list(doc.findings),
renamed={t.tro_id: t.key for t in tros if t.key and t.key != t.tro_id},
method=f"Anlagen-/TRO-JSON {doc.source}",
)
analysis.unknown_types = sorted({t.type_name for t in tros if t.definition() is None})
analysis.isolated = [t for t in tros if not t.connected]
return analysis
def _derive(args, csv_file: Path, edits: list[TroEdit] | None,
connection_edits: list[ConnectionEdit] | None = None):
"""CSV lesen und die TROs ableiten - (Elemente, Analysis) oder None. """CSV lesen und die TROs ableiten - (Elemente, Analysis) oder None.
Derselbe Aufruf wie in tro_flow.main (mit der Separator-Gegenprobe aus Derselbe Aufruf wie in tro_flow.main (mit der Separator-Gegenprobe aus
@@ -1490,7 +1612,8 @@ def _derive(args, csv_file: Path, edits: list[TroEdit] | None):
print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr) print(f"FEHLER: ID-Datei nicht lesbar: {exc}", file=sys.stderr)
return None return None
analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts, analysis = analyse_tros(graph, elements, sepliste_counts=sepliste_counts,
tro_ids=tro_ids, edits=edits) tro_ids=tro_ids, edits=edits,
connection_edits=connection_edits)
if not analysis.tros: if not analysis.tros:
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr) print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
return None return None
@@ -1514,7 +1637,7 @@ def _main_read_tros(args, csv_file: Path) -> int:
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
try: try:
markers = read_markers(dxf_file) markers, arrows = read_markers(dxf_file)
except (OSError, ezdxf.DXFError) as exc: except (OSError, ezdxf.DXFError) as exc:
print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr) print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr)
return 1 return 1
@@ -1531,8 +1654,15 @@ def _main_read_tros(args, csv_file: Path) -> int:
f"mit dem aktuellen tro_annotate neu beschriften?", file=sys.stderr) f"mit dem aktuellen tro_annotate neu beschriften?", file=sys.stderr)
return 1 return 1
try: try:
# 1. TRO-Korrekturen aus den Markern; 2. die Pfeile gegen die Ableitung
# MIT diesen Korrekturen (sonst gaelten Kanten eines neuen TRO als
# von Hand gezogen)
edits, _ = read_tros(markers, raw, elements, csv_file.name, cfg_dir, write=False)
derived = _derive(args, csv_file, edits)
if derived is None:
return 1
edits, meldungen = read_tros(markers, raw, elements, csv_file.name, cfg_dir, edits, meldungen = read_tros(markers, raw, elements, csv_file.name, cfg_dir,
write=not args.check) write=not args.check, arrows=arrows, base=derived[1])
except (OSError, configparser.Error) as exc: except (OSError, configparser.Error) as exc:
print(f"FEHLER: {exc}", file=sys.stderr) print(f"FEHLER: {exc}", file=sys.stderr)
return 1 return 1
@@ -1540,7 +1670,7 @@ def _main_read_tros(args, csv_file: Path) -> int:
print("================================================================") print("================================================================")
print("TRO-HANDKORREKTUREN UEBERNEHMEN") print("TRO-HANDKORREKTUREN UEBERNEHMEN")
print("================================================================") print("================================================================")
print(f"Zeichnung = {dxf_file} ({len(markers)} Marker)") print(f"Zeichnung = {dxf_file} ({len(markers)} Marker, {len(arrows)} Verbindungen)")
ziel = override_path(cfg_dir) ziel = override_path(cfg_dir)
print(f"Korrekturen = {len(edits)} -> " print(f"Korrekturen = {len(edits)} -> "
f"{'- (--check, nichts geschrieben)' if args.check else ziel}") f"{'- (--check, nichts geschrieben)' if args.check else ziel}")
@@ -1624,7 +1754,7 @@ def main(argv: list[str] | None = None) -> int:
# TROs aus dem CSV, mit den Handkorrekturen aus tro_overrides.ini # TROs aus dem CSV, mit den Handkorrekturen aus tro_overrides.ini
overrides = load_overrides(csv_file.name, cfg_dir) overrides = load_overrides(csv_file.name, cfg_dir)
warnings += [w for w in overrides.warnings if ".edit" in w] warnings += [w for w in overrides.warnings if ".edit" in w]
derived = _derive(args, csv_file, overrides.edits) derived = _derive(args, csv_file, overrides.edits, overrides.connection_edits)
if derived is None: if derived is None:
return 1 return 1
elements, analysis = derived elements, analysis = derived
@@ -1654,14 +1784,17 @@ def main(argv: list[str] | None = None) -> int:
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, "jams": 0} "first_id": 0, "jams": 0}
if anlage is not None: if anlage is not None:
# Mit Anlagen-JSON zeigt die Zeichnung genau sie - das, woraus die SCL
# wird. Die Ableitung aus dem CSV dient nur noch als Gegenprobe.
aus_csv = {t.tro_id for t in analysis.tros} aus_csv = {t.tro_id for t in analysis.tros}
aus_json = {t.tro_id for t in anlage.layout.tros} aus_json = {t.tro_id for t in anlage.layout.tros}
if aus_csv != aus_json: if aus_csv != aus_json:
warnings.append( warnings.append(
f"TROs der Zeichnung und der Anlagen-JSON weichen ab (nur CSV: " f"Die Zeichnung zeigt die Anlagen-JSON; die Ableitung aus dem CSV weicht "
f"{', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: " f"ab (nur CSV: {', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: "
f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - tro_flow --json und " f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - JSON veraltet? "
f"merge_hw_layout neu laufen lassen") f"tro_flow --json und merge_hw_layout neu laufen lassen")
analysis = analysis_from_document(anlage.layout)
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)
+49 -2
View File
@@ -217,7 +217,7 @@ from tro_catalog import (
get_tro, get_tro,
mermaid_classdefs, mermaid_classdefs,
) )
from tro_overrides import PartRef, TroEdit, load_overrides from tro_overrides import ConnectionEdit, PartRef, TroEdit, load_overrides
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Konstanten # Konstanten
@@ -2428,6 +2428,47 @@ def apply_edits(tros: list[DerivedTro], edits: list[TroEdit], elements: list[Ele
return tros return tros
def apply_connection_edits(analysis: Analysis, changes: list[ConnectionEdit]) -> None:
"""Im Plan geaenderte Verbindungen (tro_overrides.ini, edit_connections).
Laeuft nach link_tros: '-' nimmt eine abgeleitete Kante weg, '+' fuegt eine
hinzu (Art aus dem Eintrag, sonst transfer). Die Enden sind stabile
Schluessel; ueber Analysis.renamed werden sie zur aktuellen ID. Danach
werden Vorgaenger/Nachfolger neu gesetzt - die Staubereiche folgen daraus.
"""
tros = analysis.tros + analysis.boundaries
id_of = {analysis.edit_key(t.tro_id): t.tro_id for t in tros}
id_of.update({t.tro_id: t.tro_id for t in tros})
edges = set(analysis.edges)
for change in changes:
src, dst = id_of.get(change.src), id_of.get(change.dst)
if src is None or dst is None:
fehlt = change.src if src is None else change.dst
analysis.findings.append(f"Handkorrektur Verbindung {change.render()}: {fehlt} "
f"gibt es nicht - ignoriert")
continue
if change.add:
if (src, dst) not in edges:
edges.add((src, dst))
analysis.findings.append(f"Handkorrektur Verbindung: {src} -> {dst} von Hand "
f"hinzugefuegt")
analysis.edge_kinds[(src, dst)] = change.kind or "transfer"
elif change.kind:
analysis.edge_kinds[(src, dst)] = change.kind
elif (src, dst) in edges:
edges.discard((src, dst))
analysis.edge_kinds.pop((src, dst), None)
analysis.findings.append(f"Handkorrektur Verbindung: {src} -> {dst} von Hand "
f"entfernt")
else:
analysis.findings.append(f"Handkorrektur Verbindung {change.render()}: wird "
f"nicht abgeleitet - nichts zu entfernen")
analysis.edges = sorted(edges)
for tro in tros:
tro.successors = sorted(d for s, d in analysis.edges if s == tro.tro_id)
tro.predecessors = sorted(s for s, d in analysis.edges if d == tro.tro_id)
def analyse_tros( def analyse_tros(
graph: Graph, graph: Graph,
elements: list[Element], elements: list[Element],
@@ -2435,6 +2476,7 @@ def analyse_tros(
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, edits: list[TroEdit] | None = None,
connection_edits: list[ConnectionEdit] | None = None,
) -> Analysis: ) -> Analysis:
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen. """TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
@@ -2452,6 +2494,8 @@ def analyse_tros(
nummerieren. nummerieren.
edits: Handkorrekturen aus BricsCAD (tro_overrides.load_overrides edits: Handkorrekturen aus BricsCAD (tro_overrides.load_overrides
(...).edits), angewandt direkt nach derive_tros (apply_edits). (...).edits), angewandt direkt nach derive_tros (apply_edits).
connection_edits: im Plan geaenderte Verbindungen
(load_overrides(...).connection_edits), angewandt nach link_tros.
Rueckgabe: Rueckgabe:
Analysis mit tros, boundaries, edges/edge_kinds, unknown_types Analysis mit tros, boundaries, edges/edge_kinds, unknown_types
@@ -2476,6 +2520,8 @@ def analyse_tros(
# Kreisel-Bahn wird ueber ihre Koordinate bestimmt. # Kreisel-Bahn wird ueber ihre Koordinate bestimmt.
locate_tros(linked, elements, graph) locate_tros(linked, elements, graph)
analysis.edges, analysis.edge_kinds = link_tros(graph, linked, elements) analysis.edges, analysis.edge_kinds = link_tros(graph, linked, elements)
if connection_edits:
apply_connection_edits(analysis, connection_edits)
analysis.unknown_types = sorted( analysis.unknown_types = sorted(
{tro.type_name for tro in analysis.tros if tro.definition() is None}) {tro.type_name for tro in analysis.tros if tro.definition() is None})
@@ -3598,7 +3644,8 @@ def main(argv: list[str] | None = None) -> int:
warnings.append(f"--renumber: {len(overrides.edits)} Handkorrektur(en) in " warnings.append(f"--renumber: {len(overrides.edits)} Handkorrektur(en) in "
f"{overrides.source} haengen an den alten IDs - pruefen") 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, edits=overrides.edits) tro_ids=tro_ids, edits=overrides.edits,
connection_edits=overrides.connection_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)
+55 -4
View File
@@ -56,6 +56,11 @@ anders als die laufenden Nummern, an denen die alten Abschnitte veraltet sind.
[<csv>.edit_add.<ID>] neuer TRO (TRO_INSERT), Schluessel wie oben [<csv>.edit_add.<ID>] neuer TRO (TRO_INSERT), Schluessel wie oben
[<csv>.edit_connections] Verbindungen, im Plan (CONNECTION_*) geaendert
c01 = + TRO05 -> TRO07 | transfer dazu (Art optional, Standard transfer)
c02 = - TRO05 -> TRO01 weg
Enden sind die stabilen Schluessel der TROs
Stehen Slots im Abschnitt, gelten sie vollstaendig (fehlende Slots des Typs Stehen Slots im Abschnitt, gelten sie vollstaendig (fehlende Slots des Typs
bleiben leer); stehen keine, bleiben die Bauteile wie abgeleitet. Die Lage bleiben leer); stehen keine, bleiben die Bauteile wie abgeleitet. Die Lage
hinter der TeileId macht den Eintrag exportfest: passt die TeileId im neuen hinter der TeileId macht den Eintrag exportfest: passt die TeileId im neuen
@@ -220,6 +225,33 @@ class TroEdit:
or self.slots is not None or self.lines is not None) or self.slots is not None or self.lines is not None)
@dataclass
class ConnectionEdit:
"""Eine im Plan hinzugefuegte (add) oder entfernte Verbindung zwischen
zwei TROs, Enden als stabile Schluessel (DerivedTro.key)."""
add: bool
src: str
dst: str
kind: str = ""
def render(self) -> str:
text = f"{'+' if self.add else '-'} {self.src} -> {self.dst}"
return f"{text} | {self.kind}" if self.add and self.kind else text
@classmethod
def parse(cls, raw: str) -> ConnectionEdit:
text = raw.strip()
if not text or text[0] not in "+-":
raise ValueError(f"'{raw}': erwartet '+ <von> -> <nach> [| Art]' oder "
f"'- <von> -> <nach>'")
pfeil, _, kind = text[1:].partition("|")
src, sep, dst = (t.strip() for t in pfeil.partition("->"))
if not sep or not src or not dst:
raise ValueError(f"'{raw}': erwartet '<von> -> <nach>'")
return cls(add=text[0] == "+", src=src, dst=dst, kind=kind.strip())
@dataclass @dataclass
class Overrides: class Overrides:
"""Alle Korrekturen zu einer CSV-Datei. """Alle Korrekturen zu einer CSV-Datei.
@@ -237,6 +269,8 @@ class Overrides:
candidates: list[TroCandidate] = field(default_factory=list) candidates: list[TroCandidate] = field(default_factory=list)
# Handkorrekturen aus BricsCAD in Dateireihenfolge (tro_flow.apply_edits) # Handkorrekturen aus BricsCAD in Dateireihenfolge (tro_flow.apply_edits)
edits: list[TroEdit] = field(default_factory=list) edits: list[TroEdit] = field(default_factory=list)
# Verbindungen aus BricsCAD (tro_flow.apply_connection_edits)
connection_edits: list[ConnectionEdit] = 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)
@@ -278,7 +312,8 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
parser = configparser.ConfigParser(inline_comment_prefixes=(";",)) parser = configparser.ConfigParser(inline_comment_prefixes=(";",))
# Schluessel sind Kleinschreibung, Sektionsnamen bleiben wie geschrieben. # Schluessel sind Kleinschreibung, Sektionsnamen bleiben wie geschrieben.
parser.read(path, encoding="utf-8") # utf-8-sig: von Hand (Editor, PowerShell) gespeichert oft mit BOM
parser.read(path, encoding="utf-8-sig")
prefix = csv_name.lower() prefix = csv_name.lower()
for section in parser.sections(): for section in parser.sections():
@@ -297,6 +332,14 @@ def load_overrides(csv_name: str, cfg_dir: Path | None = None) -> Overrides:
Connection(src=src, dst=dst, kind=rest.strip()) Connection(src=src, dst=dst, kind=rest.strip())
) )
continue continue
# [<csv>.edit_connections] - Verbindungen aus BricsCAD
if section.lower() == f"{prefix}.edit_connections":
for key, raw in parser[section].items():
try:
result.connection_edits.append(ConnectionEdit.parse(raw))
except ValueError as exc:
result.warnings.append(f"[{section}] {key}: {exc}")
continue
parts = section.split(".") parts = section.split(".")
# <csv>.<art>.<name> - der CSV-Name kann selbst einen Punkt enthalten # <csv>.<art>.<name> - der CSV-Name kann selbst einen Punkt enthalten
@@ -475,8 +518,10 @@ def render_edit(csv_name: str, edit: TroEdit) -> str:
return "\n".join(lines) return "\n".join(lines)
def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None) -> Path: def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None,
"""Alle edit-/edit_add-Abschnitte zu `csv_name` durch `edits` ersetzen. connections: list[ConnectionEdit] | None = None) -> Path:
"""Alle edit-/edit_add-Abschnitte und [<csv>.edit_connections] zu
`csv_name` durch `edits` bzw. `connections` ersetzen.
Der Rest der Datei (von Hand gepflegt, mit Kommentaren) bleibt Zeile fuer Der Rest der Datei (von Hand gepflegt, mit Kommentaren) bleibt Zeile fuer
Zeile erhalten; der neue Block steht am Ende. Kommentarzeilen direkt vor Zeile erhalten; der neue Block steht am Ende. Kommentarzeilen direkt vor
@@ -488,6 +533,8 @@ def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None)
head = _EDIT_HEAD.format(csv=csv_name).lower() head = _EDIT_HEAD.format(csv=csv_name).lower()
def ist_edit(section: str) -> bool: def ist_edit(section: str) -> bool:
if section.lower() == f"{prefix}.edit_connections":
return True
parts = section.split(".") parts = section.split(".")
return (len(parts) >= 3 and parts[-2].lower() in ("edit", "edit_add") return (len(parts) >= 3 and parts[-2].lower() in ("edit", "edit_add")
and ".".join(parts[:-2]).lower() == prefix) and ".".join(parts[:-2]).lower() == prefix)
@@ -519,13 +566,17 @@ def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None)
while keep and not keep[-1].strip(): while keep and not keep[-1].strip():
keep.pop() keep.pop()
if edits: if edits or connections:
if keep: if keep:
keep += ["", ""] keep += ["", ""]
keep.append(_EDIT_HEAD.format(csv=csv_name) keep.append(_EDIT_HEAD.format(csv=csv_name)
+ f" - tro_annotate --read-tros, {datetime.now():%Y-%m-%d}") + f" - tro_annotate --read-tros, {datetime.now():%Y-%m-%d}")
for edit in edits: for edit in edits:
keep += [render_edit(csv_name, edit), ""] keep += [render_edit(csv_name, edit), ""]
if connections:
keep.append(f"[{csv_name}.edit_connections]")
keep += [f"c{n:02d} = {c.render()}" for n, c in enumerate(connections, start=1)]
keep.append("")
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(keep).rstrip("\n") + "\n", encoding="utf-8") path.write_text("\n".join(keep).rstrip("\n") + "\n", encoding="utf-8")
return path return path
+364
View File
@@ -0,0 +1,364 @@
# -*- coding: utf-8 -*-
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
"""
validate_anlage.py - Ist die Anlagen-JSON bereit fuer die SCL?
Eine Pruefung fuer beide Wege des Ablaufs (doc/Konzept_Layout_Hardware_SCL.md):
direkt nach merge_hw_layout (keine Aenderungen) und nach den Handkorrekturen
in BricsCAD (tro_annotate --read-tros/--read-jams, dann tro_flow --json und
merge_hw_layout neu). scl_skeleton ruft sie vor dem Schreiben auf.
Geprueft wird (FEHLER = so keine SCL, WARNUNG = SCL mit offenen Stellen):
Struktur TRO-IDs eindeutig, jeder Typ im Katalog, Verbindungen und
Staubereiche zeigen auf vorhandene TROs/Stationen, kein
Separator in zwei TROs (FEHLER); leerer Separator-Slot, TRO
ohne Verbindung, TRO vom Materialeingang aus nicht erreichbar
(WARNUNG)
Zuordnungen Binding auf einen unbekannten TRO/Slot, Status widerspruch,
BMK fehlt in der Hardwareplanung (FEHLER); offene
Pflichtrollen (WARNUNG, je Rolle gezaehlt)
Aktualitaet Anlagen-JSON aelter als TRO-JSON, Hardwareplanung oder
hw_overrides.ini (FEHLER: merge_hw_layout neu);
TRO-JSON aelter als die BricsCAD-Korrekturen in
tro_overrides.ini (WARNUNG: tro_flow --json neu)
Korrekturen Eintraege in hw_overrides.ini / <csv>_jam_names.ini, die zu
keinem TRO bzw. Staubereich passen - weder ueber die ID noch
ueber den stabilen Schluessel (WARNUNG)
Aufruf ueber bin/validate_anlage.bat bzw. bin/validate_anlage.sh:
validate_anlage --json mubea_anlage.json [--hw mubea_hw.json] [--strict]
Exit-Codes: 0 bereit (mit --strict: auch ohne Warnungen), 1 Eingabefehler,
5 Fehler gefunden (mit --strict auch bei Warnungen).
"""
from __future__ import annotations
import argparse
import collections
import configparser
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from pydantic import ValidationError
from hw_import import HardwareDocument
from material_flow import env_dir
from model import ANLAGE_SCHEMA, AnlageDocument, TroDocument
from scl_context import JAM_NAMES_SUFFIX, jam_key, jam_stable_key, load_jam_overrides
from tro_overrides import load_overrides, override_path
FEHLER = "FEHLER"
WARNUNG = "WARNUNG"
# Name der Datei mit den Handkorrekturen der Hardware (merge_hw_layout)
HW_OVERRIDES_FILE = "hw_overrides.ini"
EXIT_FEHLER = 5
@dataclass
class Befund:
stufe: str
bereich: str
text: str
def __str__(self) -> str:
return f"{self.stufe:<7} {self.bereich:<11} {self.text}"
@dataclass
class Pruefung:
"""Ergebnis einer Pruefung, Befunde in der Reihenfolge der Bereiche."""
befunde: list[Befund] = field(default_factory=list)
def fehler(self, bereich: str, text: str) -> None:
self.befunde.append(Befund(FEHLER, bereich, text))
def warnung(self, bereich: str, text: str) -> None:
self.befunde.append(Befund(WARNUNG, bereich, text))
@property
def anzahl_fehler(self) -> int:
return sum(1 for b in self.befunde if b.stufe == FEHLER)
@property
def anzahl_warnungen(self) -> int:
return sum(1 for b in self.befunde if b.stufe == WARNUNG)
def bereit(self, strict: bool = False) -> bool:
return not self.anzahl_fehler and not (strict and self.anzahl_warnungen)
# ---------------------------------------------------------------------------
# Einzelpruefungen
# ---------------------------------------------------------------------------
def check_structure(doc: TroDocument, p: Pruefung) -> None:
"""IDs, Typen, Verbindungen, Staubereiche, Separatoren, Erreichbarkeit."""
ids = collections.Counter(t.tro_id for t in doc.tros + doc.boundaries)
for tro_id, n in sorted(ids.items()):
if n > 1:
p.fehler("Struktur", f"TRO-ID {tro_id} kommt {n}x vor")
for tro in doc.tros:
if tro.definition() is None:
p.fehler("Struktur", f"{tro.tro_id}: Typ '{tro.type_name}' steht nicht im Katalog")
bekannt = set(ids)
for c in doc.connections:
for ende in (c.src, c.dst):
if ende not in bekannt:
p.fehler("Struktur", f"Verbindung {c.src} -> {c.dst}: {ende} unbekannt")
for area in doc.jam_areas_or_derived():
if area.tro not in bekannt:
p.fehler("Struktur", f"Staubereich {area.jam_id}: TRO {area.tro} unbekannt")
besitzer: dict[str, str] = {}
for tro in doc.tros:
for sep in dict.fromkeys(tro.separators):
if not sep:
continue
if sep in besitzer:
p.fehler("Struktur", f"Separator {sep} steckt in {besitzer[sep]} und "
f"{tro.tro_id}")
besitzer.setdefault(sep, tro.tro_id)
for slot in tro.slots:
if slot.slot.startswith("separator") and not slot.layout_id:
p.warnung("Struktur", f"{tro.tro_id}: {slot.slot} ohne Layout-Bauteil")
# Erreichbarkeit vom Materialeingang (Systemgrenzen ohne Vorgaenger)
nach: dict[str, set[str]] = collections.defaultdict(set)
for c in doc.connections:
nach[c.src].add(c.dst)
for tro in doc.tros + doc.boundaries:
nach[tro.tro_id].update(tro.successors)
for tro in doc.tros:
if not (tro.successors or tro.predecessors or tro.colocated):
p.warnung("Struktur", f"{tro.tro_id}: ohne Verbindung")
starts = [b.tro_id for b in doc.boundaries if not b.predecessors]
if starts:
gesehen, stapel = set(starts), list(starts)
while stapel:
for nxt in nach.get(stapel.pop(), ()):
if nxt not in gesehen:
gesehen.add(nxt)
stapel.append(nxt)
weg = [t.tro_id for t in doc.tros if t.tro_id not in gesehen
and (t.successors or t.predecessors)]
if weg:
p.warnung("Struktur", f"vom Materialeingang nicht erreichbar: {', '.join(weg)}")
def check_bindings(anlage: AnlageDocument, hw: HardwareDocument | None,
p: Pruefung) -> None:
"""Zuordnungen der Hardwareplanung gegen Layout und TIA-Import."""
doc = anlage.layout
slots = {(t.tro_id, s.slot) for t in doc.tros for s in t.slots}
tros = {t.tro_id for t in doc.tros}
geraete = hw.by_bmk() if hw is not None else None
offen: collections.Counter[str] = collections.Counter()
for b in anlage.bindings:
wo = f"{b.tro}.{b.slot}.{b.role}" if b.tro else (b.layout_id or b.bmk)
if b.tro and b.tro not in tros:
p.fehler("Zuordnung", f"{wo}: TRO {b.tro} steht nicht im Layout")
continue
if b.tro and (b.tro, b.slot) not in slots:
p.fehler("Zuordnung", f"{wo}: Slot {b.slot} gibt es an {b.tro} nicht")
continue
if b.status == "widerspruch":
p.fehler("Zuordnung", f"{wo}: {b.bmk or '-'} - {b.note or 'Widerspruch'}")
elif b.status == "offen":
offen[b.role] += 1
if geraete is not None and b.bmk and b.status != "widerspruch" and b.bmk not in geraete:
p.fehler("Zuordnung", f"{wo}: {b.bmk} fehlt in der Hardwareplanung")
if offen:
p.warnung("Zuordnung", "offene Pflichtrollen (TODO(E-Planung)): "
+ ", ".join(f"{r} {n}x" for r, n in sorted(offen.items())))
def _mtime(path: Path | None) -> float | None:
return path.stat().st_mtime if path is not None and path.is_file() else None
def _has_sections(path: Path, csv_name: str) -> bool:
"""Hat die INI einen Abschnitt [<csv>.*]?"""
if not path.is_file():
return False
prefix = f"[{csv_name.lower()}."
try:
return any(line.strip().lower().startswith(prefix)
for line in path.read_text(encoding="utf-8-sig").splitlines())
except OSError:
return False
def check_freshness(anlage_file: Path, anlage: AnlageDocument | None, tro_file: Path | None,
hw_file: Path | None, cfg_dir: Path, csv_name: str,
p: Pruefung) -> None:
"""Ist die JSON juenger als alles, woraus sie gebaut wird?"""
stand = _mtime(anlage_file)
if stand is None:
return
if anlage is not None:
hw_ini = cfg_dir / HW_OVERRIDES_FILE
# hw_overrides.ini zaehlt nur mit Abschnitten zu dieser Anlage
if not _has_sections(hw_ini, csv_name):
hw_ini = None
for quelle, was in ((tro_file, "TRO-JSON"), (hw_file, "Hardwareplanung"),
(hw_ini, HW_OVERRIDES_FILE)):
t = _mtime(quelle)
if t is not None and t > stand:
p.fehler("Stand", f"{anlage_file.name} ist aelter als {was} ({quelle.name}) "
f"- merge_hw_layout neu laufen lassen")
# BricsCAD-Korrekturen wirken in tro_flow - die TRO-JSON muss juenger sein
layout_stand = _mtime(tro_file) if anlage is not None else stand
korrektur = override_path(cfg_dir)
t = _mtime(korrektur)
if layout_stand is not None and t is not None and t > layout_stand:
try:
if load_overrides(csv_name, cfg_dir).edits:
p.warnung("Stand", f"{korrektur.name} (Handkorrekturen aus BricsCAD) ist "
f"juenger als die TRO-JSON - tro_flow --json neu laufen lassen")
except (OSError, configparser.Error):
pass
def check_corrections(doc: TroDocument, cfg_dir: Path, csv_name: str, p: Pruefung) -> None:
"""Handkorrekturen, die ins Leere zeigen (z. B. nach einer Umbenennung)."""
hw_ini = cfg_dir / HW_OVERRIDES_FILE
if hw_ini.is_file():
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"),
interpolation=None)
parser.optionxform = str
try:
with hw_ini.open(encoding="utf-8-sig") as fh:
parser.read_file(fh)
except (OSError, configparser.Error) as exc:
p.fehler("Korrektur", f"{hw_ini.name} nicht lesbar: {exc}")
else:
section = f"{csv_name}.bindings"
for key in (parser[section] if parser.has_section(section) else {}):
teile = key.split(".")
tro = doc.resolve(teile[0]) if len(teile) == 3 else None
if tro is None or not any(s.slot == teile[1] for s in tro.slots):
p.warnung("Korrektur", f"{hw_ini.name} [{section}] {key}: kein TRO/Slot "
f"dazu (ID und Schluessel geprueft)")
jam_ini = cfg_dir / f"{Path(csv_name).stem}{JAM_NAMES_SUFFIX}"
try:
jam = load_jam_overrides(jam_ini)
except (OSError, ValueError, configparser.Error) as exc:
p.fehler("Korrektur", f"{jam_ini.name} nicht lesbar: {exc}")
return
if jam:
areas = doc.jam_areas_or_derived()
treffer = {k for a in areas for k in (jam_stable_key(a, doc), jam_key(a))}
for key in sorted(set(jam) - treffer):
p.warnung("Korrektur", f"{jam_ini.name} {key}: kein Staubereich dazu")
# ---------------------------------------------------------------------------
# Gesamt
# ---------------------------------------------------------------------------
def validate(json_file: Path, *, hw_file: Path | None = None,
results: Path | None = None, cfg_dir: Path | None = None) -> Pruefung:
"""TRO- oder Anlagen-JSON pruefen. Ladefehler werden zu einem FEHLER."""
results = results or json_file.parent
cfg_dir = cfg_dir or env_dir("SKEL_CFG", "cfg")
p = Pruefung()
try:
text = json_file.read_text(encoding="utf-8")
if json.loads(text).get("schema") == ANLAGE_SCHEMA:
anlage: AnlageDocument | None = AnlageDocument.from_json(text)
doc = anlage.layout
else:
anlage, doc = None, TroDocument.from_json(text)
except (OSError, ValueError, ValidationError) as exc:
p.fehler("Modell", f"{json_file.name} nicht lesbar oder passt nicht zum Modell: "
f"{str(exc).splitlines()[0]}")
return p
csv_name = Path(doc.source).name
check_structure(doc, p)
hw = None
tro_file = None
if anlage is not None:
tro_file = results / Path(anlage.layout_file).name if anlage.layout_file else None
if hw_file is None and anlage.hw_file:
hw_file = results / Path(anlage.hw_file).name
if hw_file is not None and hw_file.is_file():
try:
hw = HardwareDocument.from_json(hw_file.read_text(encoding="utf-8"))
except (OSError, ValidationError) as exc:
p.fehler("Zuordnung", f"{hw_file.name} nicht lesbar: {str(exc).splitlines()[0]}")
elif hw_file is not None:
p.warnung("Zuordnung", f"{hw_file.name} nicht gefunden - BMK nicht gegengeprueft")
check_bindings(anlage, hw, p)
check_freshness(json_file, anlage, tro_file, hw_file, cfg_dir, csv_name, p)
check_corrections(doc, cfg_dir, csv_name, p)
return p
def report(json_file: Path, p: Pruefung, strict: bool, out=sys.stdout) -> None:
print("", file=out)
print("================================================================", file=out)
print("PRUEFUNG ANLAGEN-JSON", file=out)
print("================================================================", file=out)
print(f"Datei = {json_file}", file=out)
print(f"Fehler = {p.anzahl_fehler}", file=out)
print(f"Warnungen = {p.anzahl_warnungen}", file=out)
print(f"Ergebnis = {'bereit fuer die SCL' if p.bereit(strict) else 'NICHT bereit'}"
+ (" (--strict: Warnungen zaehlen)" if strict else ""), file=out)
print("================================================================", file=out)
for b in p.befunde:
print(f" {b}", file=out)
print("", file=out)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="validate_anlage",
description="Prueft eine Anlagen-JSON (oder TRO-JSON), bevor daraus SCL wird.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n"
" validate_anlage.bat --json mubea_anlage.json\n"
" validate_anlage.bat --json dematic_merge_anlage.json --strict\n"
"\nExit-Codes: 0 bereit, 1 Aufruffehler, 5 nicht bereit\n",
)
parser.add_argument("--json", required=True, metavar="NAME",
help="Anlagen-JSON (<csv>_anlage.json) oder TRO-JSON, in "
"%%SKEL_RESULTS%% oder ein Pfad.")
parser.add_argument("--hw", metavar="NAME",
help="Hardwareplanung (<projekt>_hw.json). Standard: hwFile "
"der Anlagen-JSON.")
parser.add_argument("--strict", action="store_true",
help="Auch Warnungen machen die JSON 'nicht bereit'.")
return parser.parse_args(argv)
def _in_results(name: str, results: Path) -> Path:
path = Path(name)
return results / name if not path.is_absolute() and path.parent == Path(".") else path
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
results = env_dir("SKEL_RESULTS", "results")
json_file = _in_results(args.json, results)
if not json_file.is_file():
print(f"FEHLER: JSON nicht gefunden: {json_file}", file=sys.stderr)
return 1
hw_file = _in_results(args.hw, results) if args.hw else None
p = validate(json_file, hw_file=hw_file, results=json_file.parent)
report(json_file, p, args.strict)
return 0 if p.bereit(args.strict) else EXIT_FEHLER
if __name__ == "__main__":
sys.exit(main())
+232
View File
@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
"""Tests fuer den Ablauf Anlagen-JSON -> (BricsCAD) -> SCL.
- stabiler Schluessel (DerivedTro.key): Korrekturen ueberleben eine Umbenennung
- Verbindungen aus BricsCAD (edit_connections, tro_flow.apply_connection_edits,
tro_annotate._read_connections)
- Pruefung vor der SCL (validate_anlage)
- Zeichnung aus der JSON (tro_annotate.analysis_from_document)
Fixture: Testanlage test_system (wie test_tro_edits.py).
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from material_flow import build_graph, read_elements
from model import AnlageDocument, Binding, TroDocument
from scl_context import jam_override, jam_stable_key
from tro_annotate import analysis_from_document, format_slots, read_tros, tro_slots
from tro_flow import analyse_tros
from tro_overrides import ConnectionEdit, TroEdit, load_overrides, save_edits
from validate_anlage import FEHLER, validate
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, connection_edits=None):
elements, graph = layout
return analyse_tros(graph, elements, edits=edits, connection_edits=connection_edits)
def _tro_kante(analysis):
"""Eine abgeleitete Kante zwischen zwei TROs (keine Station)."""
ids = {t.tro_id for t in analysis.tros}
return next((s, d) for s, d in analysis.edges if s in ids and d in ids)
def _markers(analysis):
return [{"ID": t.tro_id, "KEY": analysis.edit_key(t.tro_id), "TYPE": t.type_name,
"UID": f"{n:04d}", "SLOTS": format_slots(*tro_slots(t)),
"_BLOCK": f"TRO_SYM_{t.type_name}"}
for n, t in enumerate(analysis.tros, start=100)]
def _arrows(analysis, markers):
uid = {m["ID"]: m["UID"] for m in markers}
ids = {t.tro_id for t in analysis.tros}
return [{"FROM_TRO": s, "TO_TRO": d, "FROM_UID": uid[s], "TO_UID": uid[d],
"KIND": "flow", "_BLOCK": "CONNECTION_ARROW"}
for s, d in analysis.edges if s in ids and d in ids]
# ---------------------------------------------------------------------------
# Stabiler Schluessel
# ---------------------------------------------------------------------------
def test_umbenannter_tro_traegt_seinen_schluessel(layout):
raw = _analyse(layout)
key = raw.tros[0].tro_id
doc = TroDocument.from_analysis(_analyse(layout, [TroEdit(key=key, name="Neu_1")]), CSV)
tro = doc.by_id("Neu_1")
assert tro.key == key and tro.stable_key == key
assert doc.resolve(key) is tro and doc.resolve("Neu_1") is tro
# Staubereichs-Korrektur unter dem alten Schluessel gilt weiter
area = next(a for a in doc.jam_areas_or_derived() if a.tro == "Neu_1")
assert jam_stable_key(area, doc) == f"{key}.{area.entry}"
assert jam_override(area, doc, {f"{key}.{area.entry}": ("0042", 3)}) == ("0042", 3)
# unveraenderte TROs: Schluessel = ID
assert all(t.key == t.tro_id for t in doc.tros if t.tro_id != "Neu_1")
# ---------------------------------------------------------------------------
# Verbindungen
# ---------------------------------------------------------------------------
def test_connection_edit_hin_und_zurueck(tmp_path):
c = ConnectionEdit.parse("+ TRO01 -> TRO05 | weiche")
assert (c.add, c.src, c.dst, c.kind) == (True, "TRO01", "TRO05", "weiche")
assert ConnectionEdit.parse(c.render()) == c
assert not ConnectionEdit.parse("- TRO01 -> TRO05").add
with pytest.raises(ValueError):
ConnectionEdit.parse("TRO01 -> TRO05")
save_edits(CSV, [TroEdit(key="TRO02", name="X")], tmp_path,
[c, ConnectionEdit(add=False, src="TRO03", dst="TRO04")])
o = load_overrides(CSV, tmp_path)
assert o.connection_edits == [c, ConnectionEdit(add=False, src="TRO03", dst="TRO04")]
assert [e.key for e in o.edits] == ["TRO02"]
save_edits(CSV, [], tmp_path, [])
assert "edit_connections" not in (tmp_path / "tro_overrides.ini").read_text(encoding="utf-8")
def test_verbindungen_aendern_vorgaenger_und_nachfolger(layout):
raw = _analyse(layout)
s, d = _tro_kante(raw)
andere = next(t.tro_id for t in raw.tros
if t.tro_id not in (s, d) and (t.tro_id, d) not in raw.edges)
analysis = _analyse(layout, connection_edits=[
ConnectionEdit(add=False, src=s, dst=d),
ConnectionEdit(add=True, src=andere, dst=d),
ConnectionEdit(add=True, src="GIBTSNICHT", dst=d)])
assert (s, d) not in analysis.edges and (andere, d) in analysis.edges
assert analysis.edge_kinds[(andere, d)] == "transfer"
ziel = analysis.by_id(d)
assert andere in ziel.predecessors and s not in ziel.predecessors
assert d not in analysis.by_id(s).successors
assert any("GIBTSNICHT gibt es nicht" in f for f in analysis.findings)
# die Staubereiche folgen den Vorgaengern
doc = TroDocument.from_analysis(analysis, CSV)
eingang = next(a for a in doc.jam_areas if a.tro == d and a.entry == 1)
assert andere in eingang.predecessors
def test_verbindung_ueber_schluessel_nach_umbenennung(layout):
raw = _analyse(layout)
s, d = _tro_kante(raw)
analysis = _analyse(layout, [TroEdit(key=s, name="Quelle_1")],
[ConnectionEdit(add=False, src=s, dst=d)])
assert ("Quelle_1", d) not in analysis.edges
def test_pfeile_der_zeichnung_werden_zu_verbindungskorrekturen(layout, tmp_path):
elements, _ = layout
raw = _analyse(layout)
markers = _markers(raw)
arrows = _arrows(raw, markers)
# unveraendert: keine Korrektur
_, meldungen = read_tros(markers, raw, elements, CSV, tmp_path, arrows=arrows)
assert load_overrides(CSV, tmp_path).connection_edits == []
s, d = _tro_kante(raw)
andere = next(t.tro_id for t in raw.tros
if t.tro_id not in (s, d) and (t.tro_id, d) not in raw.edges)
arrows = [a for a in arrows if (a["FROM_TRO"], a["TO_TRO"]) != (s, d)]
arrows.append({"FROM_TRO": andere, "TO_TRO": d, "FROM_UID": "", "TO_UID": "",
"KIND": "flow", "_BLOCK": "CONNECTION_ARROW"})
# ein Marker umbenannt, seine Pfeile tragen noch den alten Namen -> ueber UID
quelle = next(m for m in markers if m["ID"] == s)
quelle["ID"] = "Quelle_1"
read_tros(markers, raw, elements, CSV, tmp_path, arrows=arrows)
o = load_overrides(CSV, tmp_path)
assert o.connection_edits == [ConnectionEdit(add=False, src=s, dst=d),
ConnectionEdit(add=True, src=andere, dst=d)]
assert [(e.key, e.name) for e in o.edits] == [(s, "Quelle_1")]
# Rundlauf: angewandt, neu gezeichnet, wieder gelesen -> dasselbe
edited = _analyse(layout, o.edits, o.connection_edits)
m2 = _markers(edited)
again, _ = read_tros(m2, raw, elements, CSV, tmp_path, write=False,
arrows=_arrows(edited, m2), base=_analyse(layout, o.edits))
assert [(e.key, e.name) for e in again] == [(s, "Quelle_1")]
# ---------------------------------------------------------------------------
# Pruefung
# ---------------------------------------------------------------------------
def _anlage_dateien(layout, tmp_path, bindings=None):
doc = TroDocument.from_analysis(_analyse(layout), CSV)
tro_file = tmp_path / "x_tro.json"
tro_file.write_text(doc.to_json(), encoding="utf-8")
anlage = AnlageDocument(layout=doc, layout_file=tro_file.name, bindings=bindings or [])
anlage_file = tmp_path / "x_anlage.json"
anlage_file.write_text(anlage.model_dump_json(by_alias=True, indent=2), encoding="utf-8")
return tro_file, anlage_file, doc
def test_pruefung_frische_anlage_ist_bereit(layout, tmp_path):
tro_file, anlage_file, _ = _anlage_dateien(layout, tmp_path)
os.utime(tro_file, (1_000_000, 1_000_000))
p = validate(anlage_file, cfg_dir=tmp_path)
assert p.anzahl_fehler == 0, [str(b) for b in p.befunde]
assert p.bereit()
def test_pruefung_findet_fehler(layout, tmp_path):
tro = TroDocument.from_analysis(_analyse(layout), CSV).tros[0]
bindings = [Binding(tro="TRO99", slot="separator1", role="sensor", bmk="BG1"),
Binding(tro=tro.tro_id, slot="separator1", role="sensor", bmk="BG2",
status="widerspruch", note="BMK nicht im TIA-Import"),
Binding(tro=tro.tro_id, slot="separator1", role="stopper", status="offen")]
tro_file, anlage_file, _ = _anlage_dateien(layout, tmp_path, bindings)
# TRO-JSON juenger als die Anlagen-JSON -> veraltet
os.utime(anlage_file, (1_000_000, 1_000_000))
(tmp_path / "x_jam_names.ini").write_text("[jam]\nTRO99.1 = 0042_1\n", encoding="utf-8")
p = validate(anlage_file, cfg_dir=tmp_path)
texte = [b.text for b in p.befunde if b.stufe == FEHLER]
assert any("TRO99 steht nicht im Layout" in t for t in texte)
assert any("BMK nicht im TIA-Import" in t for t in texte)
assert any("aelter als TRO-JSON" in t for t in texte)
assert any("stopper 1x" in b.text for b in p.befunde)
assert not p.bereit()
# die Staubereichsdatei heisst nach der CSV, nicht nach der JSON
assert not any("TRO99.1" in b.text for b in p.befunde)
def test_pruefung_unbekannte_verbindung(layout, tmp_path):
doc = TroDocument.from_analysis(_analyse(layout), CSV)
doc.connections[0].dst = "TRO99"
f = tmp_path / "x_tro.json"
f.write_text(doc.to_json(), encoding="utf-8")
p = validate(f, cfg_dir=tmp_path)
assert any("TRO99 unbekannt" in b.text for b in p.befunde if b.stufe == FEHLER)
# ---------------------------------------------------------------------------
# Zeichnung aus der JSON
# ---------------------------------------------------------------------------
def test_zeichnung_zeigt_die_json(layout):
raw = _analyse(layout)
s = raw.tros[0].tro_id
doc = TroDocument.from_analysis(_analyse(layout, [TroEdit(key=s, name="Neu_1")]), CSV)
a = analysis_from_document(doc)
assert [t.tro_id for t in a.tros] == [t.tro_id for t in doc.tros]
assert set(a.edges) == {(c.src, c.dst) for c in doc.connections}
assert a.edit_key("Neu_1") == s
assert [b.tro_id for b in a.boundaries] == [b.tro_id for b in doc.boundaries]