Stufen 2 und 3 aus doc/Konzept_Layout_Hardware_SCL.md: - lib/hw_import.py: TIA-Import (*_TIA.xlsx, ohne openpyxl gelesen) + Positionsdatei (*_positionsconv.json) -> <projekt>_hw.json: je BMK Adresse, Index-Konstante, VERW, Station, Lage, Foerderer (K0102, #106 -> K0106); Antriebe MA/FC/GF; Gegenpruefung beider Quellen. - lib/merge_hw_layout.py: <csv>_tro.json + <projekt>_hw.json -> <csv>_anlage.json (model.AnlageDocument): bindings je TRO-ID + Slot + Rolle mit Konstante und Adresse, Foerderer per Mehrheit (Mubea: K0102 = Kreisel3, K0104 = Kreisel2, K0105 = Kreisel1), Handkorrekturen cfg/hw_overrides.ini. Zuordnungslogik aus e_planning.py hierher verschoben; e_planning re-exportiert sie, Ergebnis unveraendert. - TRO-JSON traegt optional `parts` (Lage/Huellbox, tro_flow --json), PinStore bekommt Slots line<n>. - Gespeicherter Lagebezug mit SHA-256 der Positionsdatei; Unterordner in Eingabenamen relativ zu SKEL_DATA. - Doku: Python_Scripts.md 5d/5e, Konzept, E-Planung_Bruecke (Abgleich mubea_layout.dxf / CSV / E-Planung). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
14 KiB
Python
362 lines
14 KiB
Python
# -*- 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 <zeichnung>_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 <csv>_eplan.json (EplanDocument) und Bericht
|
|
<csv>_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 <csv>_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: <csv>_eplan.json fuer "
|
|
"scl_skeleton --eplan und ein Bericht <csv>_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())
|