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:
@@ -616,7 +616,8 @@ def load_overrides(path: Path, layout_source: str) -> Overrides:
|
||||
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"),
|
||||
interpolation=None)
|
||||
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)
|
||||
section = f"{layout_source}.bindings"
|
||||
if parser.has_section(section):
|
||||
@@ -639,7 +640,10 @@ def _apply_overrides(doc: TroDocument, bindings: list[Binding],
|
||||
hw: HardwareDocument, warnings: list[str]) -> list[Binding]:
|
||||
devices = hw.by_bmk()
|
||||
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))
|
||||
if s is None:
|
||||
warnings.append(f"{OVERRIDES_FILE}: {tro}.{slot} gibt es in der TRO-JSON nicht "
|
||||
|
||||
+23
-1
@@ -332,6 +332,12 @@ class DerivedTro(TroBase):
|
||||
"""
|
||||
|
||||
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]
|
||||
scanners: list[str]
|
||||
# Weichen-Bauteile (Ausschleuselement oder ILS Weiche), Reihenfolge wie die
|
||||
@@ -351,6 +357,11 @@ class DerivedTro(TroBase):
|
||||
def connected(self) -> bool:
|
||||
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):
|
||||
"""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
|
||||
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(
|
||||
source=source,
|
||||
note=note,
|
||||
@@ -490,6 +503,15 @@ class TroDocument(BaseModel):
|
||||
return tro
|
||||
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)
|
||||
|
||||
+25
-3
@@ -68,6 +68,8 @@ __all__ = [
|
||||
"flow_order",
|
||||
"JAM_NAMES_SUFFIX",
|
||||
"jam_key",
|
||||
"jam_override",
|
||||
"jam_stable_key",
|
||||
"jam_label",
|
||||
"jam_names",
|
||||
"jam_numbers",
|
||||
@@ -653,6 +655,25 @@ def jam_key(area: JamArea) -> str:
|
||||
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:
|
||||
"""Kurzname fuer Zeichnung und Dialog: Jam0054_3."""
|
||||
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=(";", "#"),
|
||||
interpolation=None)
|
||||
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)
|
||||
result: dict[str, tuple[str, int]] = {}
|
||||
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)
|
||||
for area in document.jam_areas_or_derived():
|
||||
if overrides and jam_key(area) in overrides:
|
||||
nummern[area.jam_id] = overrides[jam_key(area)]
|
||||
if (wert := jam_override(area, document, overrides)) is not None:
|
||||
nummern[area.jam_id] = wert
|
||||
return {jam_id: f'"DB_JamArea{nr}".stJam{nr}_{k}'
|
||||
for jam_id, (nr, k) in nummern.items()}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ from pydantic import BaseModel, ValidationError
|
||||
|
||||
from e_planning import EPLAN_SCHEMA, EplanDocument
|
||||
from hw_import import HardwareDocument
|
||||
from validate_anlage import validate
|
||||
from material_flow import env_dir
|
||||
from model import ANLAGE_SCHEMA, SCHEMA, AnlageDocument, TroDocument
|
||||
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)
|
||||
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
|
||||
if args.templates is not None:
|
||||
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})")
|
||||
if jam_overrides:
|
||||
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"SCL = {out_file}")
|
||||
print("================================================================")
|
||||
|
||||
+165
-32
@@ -143,20 +143,29 @@ from model import (
|
||||
AnlageDocument,
|
||||
DerivedTro,
|
||||
JamArea,
|
||||
TroDocument,
|
||||
derive_slots,
|
||||
has_lines,
|
||||
slot_kind,
|
||||
slot_names,
|
||||
)
|
||||
from tro_ids import id_problem
|
||||
from tro_overrides import PartRef, TroEdit, load_overrides, override_path, save_edits
|
||||
from tro_overrides import (
|
||||
ConnectionEdit,
|
||||
PartRef,
|
||||
TroEdit,
|
||||
load_overrides,
|
||||
override_path,
|
||||
save_edits,
|
||||
)
|
||||
from scl_context import (
|
||||
JAM_NAMES_SUFFIX,
|
||||
HwSource,
|
||||
flow_order,
|
||||
jam_key,
|
||||
jam_label,
|
||||
jam_numbers,
|
||||
jam_override,
|
||||
jam_stable_key,
|
||||
load_jam_overrides,
|
||||
save_jam_overrides,
|
||||
)
|
||||
@@ -180,6 +189,8 @@ JAM_BLOCK = "JAM_AREA"
|
||||
PARTS_XRECORD = "SPS_SKEL_PARTS"
|
||||
# Unsichtbare Attribute der Marker (build_symbol)
|
||||
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)
|
||||
JAM_OFFEN = "Jam????_?"
|
||||
|
||||
@@ -854,35 +865,49 @@ def build_jam_symbol(doc, text: float) -> str:
|
||||
AREA unsichtbar - Kennung der TRO-JSON (J0039)
|
||||
AUTO unsichtbar - abgeleitete Nummer NNNN_k; --read-jams schreibt
|
||||
nur, was davon abweicht
|
||||
TRO + ENTRY ist der stabile Schluessel (scl_context.jam_key).
|
||||
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:
|
||||
# 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)
|
||||
for e in list(blk):
|
||||
if e.dxftype() != "ATTDEF":
|
||||
blk.delete_entity(e)
|
||||
_jam_form(blk, text)
|
||||
vorhanden = set()
|
||||
for a in blk.query("ATTDEF"):
|
||||
vorhanden.add(a.dxf.tag.upper())
|
||||
if a.dxf.tag.upper() == "NAME":
|
||||
_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
|
||||
blk = doc.blocks.new(name=JAM_BLOCK)
|
||||
_jam_form(blk, text)
|
||||
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
|
||||
name = blk.add_attdef("NAME", insert=(0, 0), height=text * JAM_TEXT,
|
||||
dxfattribs={"style": "Standard", "text": JAM_OFFEN})
|
||||
_jam_name(name, text)
|
||||
offset = -h - text
|
||||
for tag_name in ("CONVEYOR", "NUMBER", "TRO", "ENTRY", "AREA", "AUTO"):
|
||||
blk.add_attdef(tag_name, insert=(-w, offset), height=text * 0.7,
|
||||
dxfattribs={"style": "Standard", "color": 256, "text": "",
|
||||
"invisible": 1})
|
||||
for tag_name in JAM_HIDDEN_TAGS:
|
||||
_jam_attdef(blk, tag_name, -w, offset, text)
|
||||
offset -= text
|
||||
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:
|
||||
"""Stehendes Rechteck: graue Fuellung, grauer Rand."""
|
||||
w, h = text * JAM_HALB_BREITE, text * JAM_HALB_HOEHE
|
||||
@@ -967,7 +992,7 @@ def place_jams(
|
||||
f"nicht gezeichnet")
|
||||
continue
|
||||
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),
|
||||
dxfattribs={"layer": layers["jam"]})
|
||||
ref.add_auto_attribs({
|
||||
@@ -978,6 +1003,8 @@ def place_jams(
|
||||
"ENTRY": str(area.entry),
|
||||
"AREA": area.jam_id,
|
||||
"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)
|
||||
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] = {}
|
||||
for ref in doc.modelspace().query(f'INSERT[name=="{JAM_BLOCK}"]'):
|
||||
a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
|
||||
key = f"{a.get('TRO', '')}.{a.get('ENTRY', '')}"
|
||||
# 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", "")
|
||||
if not a.get("TRO") or not a.get("ENTRY"):
|
||||
meldungen.append(f"Staubereich ohne TRO/ENTRY ({a.get('NAME', '?')}) - uebersprungen")
|
||||
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:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
def read_markers(dxf_file: Path) -> list[dict[str, str]]:
|
||||
"""Attribute aller TRO-Marker (Bloecke TRO_SYM_*) der Zeichnung."""
|
||||
def read_markers(dxf_file: Path) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
"""Attribute aller TRO-Marker (Bloecke TRO_SYM_*) und aller
|
||||
Verbindungsbloecke (CONNECTION_ARROW) der Zeichnung."""
|
||||
doc = ezdxf.readfile(str(dxf_file))
|
||||
markers = []
|
||||
markers, arrows = [], []
|
||||
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
|
||||
a = {att.dxf.tag.upper(): (att.dxf.text or "").strip() for att in ref.attribs}
|
||||
a["_BLOCK"] = ref.dxf.name
|
||||
markers.append(a)
|
||||
return markers
|
||||
ziel.append(a)
|
||||
return markers, arrows
|
||||
|
||||
|
||||
def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
|
||||
csv_name: str, cfg_dir: Path | None = None,
|
||||
write: bool = True) -> tuple[list[TroEdit], list[str]]:
|
||||
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.
|
||||
|
||||
``raw`` ist die Ableitung OHNE Handkorrekturen (IDs aus der ID-Datei). Je
|
||||
Marker mit KEY eines abgeleiteten TRO entsteht ein edit-Abschnitt, wenn
|
||||
Typ, Kennung oder Bauteile abweichen; ein Marker ohne passenden KEY ist
|
||||
ein neuer TRO (edit_add); ein abgeleiteter TRO mit Lage, zu dem kein
|
||||
Marker mehr da ist, wurde geloescht. Die Abschnitte ersetzen alle
|
||||
bisherigen edit-/edit_add-Abschnitte der CSV in tro_overrides.ini
|
||||
(Begruendungen bleiben). Rueckgabe: (Korrekturen, Meldungen).
|
||||
Marker mehr da ist, wurde geloescht. Mit ``arrows`` (Verbindungsbloecke)
|
||||
kommen die geaenderten Verbindungen dazu (_read_connections, Abschnitt
|
||||
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] = []
|
||||
marker_key: dict[int, str] = {}
|
||||
by_id = {e.teile_id: e for e in elements}
|
||||
alt = {(e.section_kind, e.key): e
|
||||
for e in load_overrides(csv_name, cfg_dir).edits}
|
||||
@@ -1176,6 +1223,8 @@ def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
|
||||
raw_slots, raw_lines = tro_slots(derived)
|
||||
else:
|
||||
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:
|
||||
lines = raw_lines if has_lines(type_name) else []
|
||||
pruefen(where, type_name, slots, lines)
|
||||
@@ -1216,11 +1265,64 @@ def read_tros(markers: list[dict[str, str]], raw: Analysis, elements,
|
||||
edits.append(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:
|
||||
save_edits(csv_name, edits, cfg_dir)
|
||||
save_edits(csv_name, edits, cfg_dir, connections)
|
||||
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:
|
||||
if edit.delete:
|
||||
return f"{edit.key}: geloescht"
|
||||
@@ -1462,7 +1564,27 @@ def _main_read_jams(args, csv_file: Path) -> int:
|
||||
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.
|
||||
|
||||
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)
|
||||
return None
|
||||
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:
|
||||
print(f"FEHLER: keine TROs aus {csv_file} ableitbar.", file=sys.stderr)
|
||||
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)
|
||||
return 1
|
||||
try:
|
||||
markers = read_markers(dxf_file)
|
||||
markers, arrows = read_markers(dxf_file)
|
||||
except (OSError, ezdxf.DXFError) as exc:
|
||||
print(f"FEHLER: {dxf_file} nicht lesbar: {exc}", file=sys.stderr)
|
||||
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)
|
||||
return 1
|
||||
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,
|
||||
write=not args.check)
|
||||
write=not args.check, arrows=arrows, base=derived[1])
|
||||
except (OSError, configparser.Error) as exc:
|
||||
print(f"FEHLER: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -1540,7 +1670,7 @@ def _main_read_tros(args, csv_file: Path) -> int:
|
||||
print("================================================================")
|
||||
print("TRO-HANDKORREKTUREN UEBERNEHMEN")
|
||||
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)
|
||||
print(f"Korrekturen = {len(edits)} -> "
|
||||
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
|
||||
overrides = load_overrides(csv_file.name, cfg_dir)
|
||||
warnings += [w for w in overrides.warnings if ".edit" in w]
|
||||
derived = _derive(args, csv_file, overrides.edits)
|
||||
derived = _derive(args, csv_file, overrides.edits, overrides.connection_edits)
|
||||
if derived is None:
|
||||
return 1
|
||||
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,
|
||||
"first_id": 0, "jams": 0}
|
||||
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_json = {t.tro_id for t in anlage.layout.tros}
|
||||
if aus_csv != aus_json:
|
||||
warnings.append(
|
||||
f"TROs der Zeichnung und der Anlagen-JSON weichen ab (nur CSV: "
|
||||
f"{', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: "
|
||||
f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - tro_flow --json und "
|
||||
f"merge_hw_layout neu laufen lassen")
|
||||
f"Die Zeichnung zeigt die Anlagen-JSON; die Ableitung aus dem CSV weicht "
|
||||
f"ab (nur CSV: {', '.join(sorted(aus_csv - aus_json)) or '-'}; nur JSON: "
|
||||
f"{', '.join(sorted(aus_json - aus_csv)) or '-'}) - JSON veraltet? "
|
||||
f"tro_flow --json und merge_hw_layout neu laufen lassen")
|
||||
analysis = analysis_from_document(anlage.layout)
|
||||
|
||||
if args.check:
|
||||
counts["marker"] = sum(1 for t in analysis.tros if t.position)
|
||||
|
||||
+49
-2
@@ -217,7 +217,7 @@ from tro_catalog import (
|
||||
get_tro,
|
||||
mermaid_classdefs,
|
||||
)
|
||||
from tro_overrides import PartRef, TroEdit, load_overrides
|
||||
from tro_overrides import ConnectionEdit, PartRef, TroEdit, load_overrides
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konstanten
|
||||
@@ -2428,6 +2428,47 @@ def apply_edits(tros: list[DerivedTro], edits: list[TroEdit], elements: list[Ele
|
||||
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(
|
||||
graph: Graph,
|
||||
elements: list[Element],
|
||||
@@ -2435,6 +2476,7 @@ def analyse_tros(
|
||||
sepliste_counts: dict[str, int] | None = None,
|
||||
tro_ids: TroIds | None = None,
|
||||
edits: list[TroEdit] | None = None,
|
||||
connection_edits: list[ConnectionEdit] | None = None,
|
||||
) -> Analysis:
|
||||
"""TROs ermitteln, verknuepfen, verorten und gegen die Typdefinition pruefen.
|
||||
|
||||
@@ -2452,6 +2494,8 @@ def analyse_tros(
|
||||
nummerieren.
|
||||
edits: Handkorrekturen aus BricsCAD (tro_overrides.load_overrides
|
||||
(...).edits), angewandt direkt nach derive_tros (apply_edits).
|
||||
connection_edits: im Plan geaenderte Verbindungen
|
||||
(load_overrides(...).connection_edits), angewandt nach link_tros.
|
||||
|
||||
Rueckgabe:
|
||||
Analysis mit tros, boundaries, edges/edge_kinds, unknown_types
|
||||
@@ -2476,6 +2520,8 @@ def analyse_tros(
|
||||
# Kreisel-Bahn wird ueber ihre Koordinate bestimmt.
|
||||
locate_tros(linked, elements, graph)
|
||||
analysis.edges, analysis.edge_kinds = link_tros(graph, linked, elements)
|
||||
if connection_edits:
|
||||
apply_connection_edits(analysis, connection_edits)
|
||||
|
||||
analysis.unknown_types = sorted(
|
||||
{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 "
|
||||
f"{overrides.source} haengen an den alten IDs - pruefen")
|
||||
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:
|
||||
print(f"FEHLER: {csv_file} enthaelt keine Separatoren - keine TROs "
|
||||
f"ableitbar.", file=sys.stderr)
|
||||
|
||||
+55
-4
@@ -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_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
|
||||
bleiben leer); stehen keine, bleiben die Bauteile wie abgeleitet. Die Lage
|
||||
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)
|
||||
|
||||
|
||||
@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
|
||||
class Overrides:
|
||||
"""Alle Korrekturen zu einer CSV-Datei.
|
||||
@@ -237,6 +269,8 @@ class Overrides:
|
||||
candidates: list[TroCandidate] = field(default_factory=list)
|
||||
# Handkorrekturen aus BricsCAD in Dateireihenfolge (tro_flow.apply_edits)
|
||||
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)
|
||||
applied: 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=(";",))
|
||||
# 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()
|
||||
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())
|
||||
)
|
||||
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(".")
|
||||
# <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)
|
||||
|
||||
|
||||
def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None) -> Path:
|
||||
"""Alle edit-/edit_add-Abschnitte zu `csv_name` durch `edits` ersetzen.
|
||||
def save_edits(csv_name: str, edits: list[TroEdit], cfg_dir: Path | None = None,
|
||||
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
|
||||
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()
|
||||
|
||||
def ist_edit(section: str) -> bool:
|
||||
if section.lower() == f"{prefix}.edit_connections":
|
||||
return True
|
||||
parts = section.split(".")
|
||||
return (len(parts) >= 3 and parts[-2].lower() in ("edit", "edit_add")
|
||||
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():
|
||||
keep.pop()
|
||||
if edits:
|
||||
if edits or connections:
|
||||
if keep:
|
||||
keep += ["", ""]
|
||||
keep.append(_EDIT_HEAD.format(csv=csv_name)
|
||||
+ f" - tro_annotate --read-tros, {datetime.now():%Y-%m-%d}")
|
||||
for edit in edits:
|
||||
keep += [render_edit(csv_name, edit), ""]
|
||||
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.write_text("\n".join(keep).rstrip("\n") + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user