Files
sps_skel/lib/validate_anlage.py
T
s.ayadi 1ac17b95e2 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>
2026-10-27 14:32:10 +01:00

365 lines
16 KiB
Python

# -*- 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())