# -*- coding: utf-8 -*- # Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" # ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. """ e_planning.py - Bruecke Layout <-> E-Planung ueber die Symbolpositionen. Das Layout (CSV) sagt, welche Separatoren, Weichen, Aus-/Einschleuselemente es gibt und wo; die E-Planung, wie ihre Sensoren und Ventile heissen (BG0642, MB0643 ...). Die EA-Liste allein kennt keine Topologie. Die E-Planung wird aber als Layer ueber der Hardwareplanung gezeichnet: jedes Sensor-/Aktorsymbol liegt neben dem Bauteil, zu dem es gehoert. Das Kabellaengen-Tool (kabellaengen/lib/getpositions.py) schreibt diese Symbole mit Position und Attributen nach _positionsdraw.json. Hier wird daraus die Zuordnung Layout-Bauteil -> BMK: 1. E-Bauteile einlesen (`sensors` und `schaltschrank_elemente`). 2. Lagebezug E-Zeichnung -> CSV mit lib/dxf_registration.py; Anker sind die Separator-Sensoren der E-Planung gegen die Separatoren des CSV. Der gepruefte Bezug wird in SKEL_CFG/dxf_registration.json abgelegt. 3. Regeln aus SKEL_CFG/e_planning.ini anwenden, je Rolle (z. B. "Separator.sensor"): welche Layout-Art, welche E-Verwendung (VERW), welcher Hoechstabstand - oder "Nummer = Nummer des Partners + Versatz" (Ventil MB0643 zum Sensor BG0642). Die Zuordnung ist eindeutig: jedes E-Bauteil und jede Rolle eines Layout-Bauteils wird hoechstens einmal vergeben, die kuerzesten Abstaende zuerst. 4. Ergebnis als _eplan.json (EplanDocument) und Bericht _eplan.md nach SKEL_RESULTS. scl_skeleton.py --eplan liest die JSON und setzt die gefundenen Namen in die SCL ein (SclName mit todo=False). Was keine Regel trifft, bleibt dort TODO(E-Planung) - geraten wird nicht. Seit 2026-10-15 liegen das E-Modell (EDevice, load_positionsdraw, pair_drives) in hw_import.py und die Zuordnung (Regeln, raeumlich, Nummernregel, Lagebezug) in merge_hw_layout.py - der neue Weg ueber die Anlagen-JSON (doc/Konzept_Layout_Hardware_SCL.md). e_planning bleibt fuer scl_skeleton --eplan, bis scl_skeleton die Anlagen-JSON liest, und re-exportiert die verschobenen Namen. Schichten: e_planning importiert material_flow (CSV), hw_import und merge_hw_layout; scl_context importiert nur das Modell EplanDocument. Aufruf ueber bin/e_planning.bat bzw. bin/e_planning.sh. """ from __future__ import annotations import argparse import sys from datetime import datetime from pathlib import Path from pydantic import BaseModel, ConfigDict, Field from dxf_registration import RegistrationError, Transform, file_fingerprint from merge_hw_layout import ( LAYOUT_KINDS, RULES_FILE, Assignment, LayoutPoint, Rule, _match, assign, load_rules, resolve_transform, ) from hw_import import Drive, EDevice, load_positionsdraw, pair_drives __all__ = [ "EPLAN_SCHEMA", "EDevice", "LayoutPoint", "Rule", "Assignment", "Drive", "EplanDocument", "load_positionsdraw", "load_rules", "assign", "pair_drives", "layout_points", "resolve_transform", "LAYOUT_KINDS", ] EPLAN_SCHEMA = "sps_skel-eplan-v1" # --------------------------------------------------------------------------- # Modell # --------------------------------------------------------------------------- class EplanDocument(BaseModel): """Die Zuordnungs-JSON _eplan.json.""" model_config = ConfigDict(extra="forbid", populate_by_name=True) schema_id: str = Field(default=EPLAN_SCHEMA, alias="schema") created: str = "" csv_file: str = "" eplan_file: str = "" transform: Transform | None = None assignments: list[Assignment] = Field(default_factory=list) drives: list[Drive] = Field(default_factory=list) # E-Bauteile im Feld ohne Zuordnung (BMK) unassigned: list[str] = Field(default_factory=list) warnings: list[str] = Field(default_factory=list) def for_layout(self, layout_id: str) -> dict[str, Assignment]: """Rollen eines Layout-Bauteils: {"sensor": Assignment, ...}.""" return { a.role.split(".", 1)[-1]: a for a in self.assignments if a.layout_id == layout_id } def to_json(self) -> str: return self.model_dump_json(by_alias=True, indent=2) + "\n" @classmethod def from_json(cls, text: str) -> "EplanDocument": return cls.model_validate_json(text) # --------------------------------------------------------------------------- # Layout # --------------------------------------------------------------------------- def layout_points(elements) -> list[LayoutPoint]: """Punktartige Bauteile aus den CSV-Elementen (material_flow.Element).""" points = [] for e in elements: if e.position is None: continue kind = next((k for k in LAYOUT_KINDS if k.lower() in f"{e.kind} {e.teile_art}".lower()), "") if kind: points.append(LayoutPoint(e.teile_id, kind, e.x, e.y, e.name)) return points # --------------------------------------------------------------------------- # Bericht # --------------------------------------------------------------------------- def render_report(doc: EplanDocument, rules: list[Rule], points: list[LayoutPoint], devices: list[EDevice]) -> str: out = [] add = out.append add(f"# E-Planung -> Layout: {doc.csv_file}") add("") add(f"- E-Planung: `{doc.eplan_file}`") add(f"- Erzeugt: {doc.created}") if doc.transform: add(f"- Lagebezug: {doc.transform.describe()}") add("") add("## Zuordnung je Rolle") add("") add("| Rolle | Layout-Bauteile | zugeordnet | davon Nummernregel |") add("|---|---|---|---|") for r in rules: n_pts = sum(1 for p in points if _match(p.kind, r.layout)) hits = [a for a in doc.assignments if a.role == r.role] n_num = sum(1 for a in hits if a.rule == "nummer") add(f"| {r.role} | {n_pts} | {len(hits)} | {n_num} |") add("") add("## Zuordnungen") add("") add("Abstand nach dem Lagebezug; \"2.\" = Abstand des naechstbesten Kandidaten " "(knapp beieinander = pruefen).") add("") add("| Layout | Art | Rolle | BMK | VERW | Bezeichnung | Abstand mm | 2. | Regel | Station |") add("|---|---|---|---|---|---|---|---|---|---|") for a in doc.assignments: second = "" if a.runner_up is None else f"{a.runner_up:.0f}" add(f"| {a.layout_id} | {a.layout_kind} | {a.role.split('.', 1)[-1]} | {a.bmk} " f"| {a.verw} | {a.bezeichnung} | {a.distance:.0f} | {second} | {a.rule} " f"| {a.controller}-{a.station} |") add("") roles_of: dict[str, set[str]] = {} for a in doc.assignments: roles_of.setdefault(a.layout_id, set()).add(a.role) add("## Layout-Bauteile ohne Zuordnung") add("") any_missing = False for r in rules: missing = [p for p in points if _match(p.kind, r.layout) and r.role not in roles_of.get(p.layout_id, set())] if missing: any_missing = True add(f"- **{r.role}**: {', '.join(p.layout_id for p in missing)}") if not any_missing: add("- keine") add("") by_bmk = {d.bmk: d for d in devices} add("## E-Bauteile im Feld ohne Zuordnung") add("") if doc.unassigned: add("| BMK | VERW | Bezeichnung | Station |") add("|---|---|---|---|") for bmk in doc.unassigned: d = by_bmk[bmk] add(f"| {bmk} | {d.verw} | {d.bezeichnung} | {d.controller}-{d.station} |") else: add("- keine") add("") add("## Antriebe (Motor <-> Motorschutz)") add("") if doc.drives: add("| Motor | Bezeichnung | Motorschutz | Bezeichnung |") add("|---|---|---|---|") for dr in doc.drives: add(f"| {dr.motor} | {dr.bezeichnung} | {dr.protection or '-'} " f"| {dr.protection_bezeichnung} |") else: add("- keine Motoren in der E-Planung") add("") if doc.warnings: add("## Hinweise") add("") for w in doc.warnings: add(f"- {w}") add("") return "\n".join(out) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def build_document(csv_path: Path, eplan_path: Path, rules_path: Path, cfg_dir: Path, re_register: bool = False ) -> tuple[EplanDocument, list[Rule], list[LayoutPoint], list[EDevice]]: """Kompletter Lauf ohne Ausgabe: CSV + positionsdraw -> EplanDocument.""" from material_flow import read_elements # erst hier: Tests ohne CSV brauchen es nicht warnings: list[str] = [] elements = read_elements(csv_path, warnings) points = layout_points(elements) devices = load_positionsdraw(eplan_path, warnings) rules, anchors = load_rules(rules_path) transform = resolve_transform(points, devices, anchors, cfg_dir, csv_path.name, eplan_path.name, re_register, warnings, file_fingerprint(eplan_path)) assignments = assign(points, devices, rules, transform) used = {a.bmk for a in assignments} unassigned = sorted(d.bmk for d in devices if not d.cabinet and d.pos is not None and d.bmk not in used) doc = EplanDocument( created=datetime.now().strftime("%Y-%m-%d %H:%M"), csv_file=csv_path.name, eplan_file=eplan_path.name, transform=transform, assignments=assignments, drives=pair_drives(devices), unassigned=unassigned, warnings=warnings, ) return doc, rules, points, devices def main(argv: list[str] | None = None) -> int: from material_flow import env_dir, resolve_input args = parse_args(argv) csv_path = resolve_input(args.file) eplan_path = resolve_input(args.eplan) cfg_dir = env_dir("SKEL_CFG", "cfg") rules_path = Path(args.rules) if args.rules else cfg_dir / RULES_FILE for label, path in (("CSV", csv_path), ("E-Planung", eplan_path), ("Regeln", rules_path)): if not path.is_file(): print(f"FEHLER: {label} nicht gefunden: {path}", file=sys.stderr) return 1 try: doc, rules, points, devices = build_document( csv_path, eplan_path, rules_path, cfg_dir, args.re_register) except RegistrationError as exc: print(f"FEHLER: Lagebezug E-Planung -> CSV nicht nachweisbar: {exc}", file=sys.stderr) print(" Passen CSV und E-Zeichnung zusammen? Anker-VERW in " f"{rules_path.name} [Registrierung] pruefen.", file=sys.stderr) return 1 except (OSError, ValueError) as exc: print(f"FEHLER: {exc}", file=sys.stderr) return 1 results = env_dir("SKEL_RESULTS", "results") results.mkdir(parents=True, exist_ok=True) json_file = results / f"{csv_path.stem}_eplan.json" md_file = results / f"{csv_path.stem}_eplan.md" json_file.write_text(doc.to_json(), encoding="utf-8") md_file.write_text(render_report(doc, rules, points, devices), encoding="utf-8") print("") print("================================================================") print("E-PLANUNG -> LAYOUT") print("================================================================") print(f"CSV = {csv_path}") print(f"E-Planung = {eplan_path}") print(f"Lagebezug = {doc.transform.describe() if doc.transform else '-'}") for r in rules: n_pts = sum(1 for p in points if _match(p.kind, r.layout)) hits = sum(1 for a in doc.assignments if a.role == r.role) print(f" {r.role:<26} {hits:4} von {n_pts}") print(f"Ohne Zuordnung = {len(doc.unassigned)} E-Bauteile im Feld") print(f"Antriebe = {len(doc.drives)} " f"({sum(1 for d in doc.drives if d.protection)} mit Motorschutz)") for w in doc.warnings: print(f" ! {w}") print(f"JSON = {json_file}") print(f"Bericht = {md_file}") print("================================================================") print("") return 0 def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="e_planning", description="Ordnet die Sensoren/Ventile der E-Planung " "(*_positionsdraw.json des Kabellaengen-Tools) den Bauteilen " "des Layouts (CSV) zu. Ergebnis: _eplan.json fuer " "scl_skeleton --eplan und ein Bericht _eplan.md.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Beispiele:\n" " e_planning.bat --file mubea.csv --eplan Mubea_positionsdraw.json\n" " e_planning.bat --file mubea.csv --eplan C:\\pfad\\Mubea_positionsdraw.json " "--re-register\n" "\n" "Exit-Codes:\n" " 0 ok (auch wenn nicht alles zugeordnet wurde - siehe Bericht)\n" " 1 Eingabe-, Aufruf- oder Lagebezugsfehler\n", ) parser.add_argument("--file", default="export.csv", metavar="NAME", help="CSV-Export in %%SKEL_DATA%% (oder ein Pfad). " "Standard: %(default)s") parser.add_argument("--eplan", required=True, metavar="NAME", help="*_positionsdraw.json der E-Planung in %%SKEL_DATA%% " "(oder ein Pfad)") parser.add_argument("--rules", metavar="DATEI", help=f"Regeldatei. Standard: %%SKEL_CFG%%\\{RULES_FILE}") parser.add_argument("--re-register", action="store_true", help="Lagebezug neu ermitteln, auch wenn in %%SKEL_CFG%% " "einer abgelegt ist") return parser.parse_args(argv) if __name__ == "__main__": sys.exit(main())