TRO-Typen an Kreisel-Beruehrpunkten (lib/kreisel_junctions.py, derive_tros Schritt 3c): jeder Kreisel als Stadion-Form mit Laufrichtung; jeder gleichlaufende Beruehrpunkt ist eine Weiche (exportierte ILS Weiche oder abgeleitet W<a>_<b>), Beruehrpunkte derselben Umlenkung bilden einen Knoten. Ein Separator steuert ihn, wenn er in Laufrichtung unmittelbar davor liegt (keine andere Weiche dazwischen). Typ = Separatoren x Weichen. export.csv: 1Sep1Swi (9/1, 9/8, 8/7), 2Sep1Swi (7/2), 2Sep2Swi (2/3/4), 3Sep2Swi (4/5/6). Neuer Katalogtyp 3Sep2Swi (nicht aus SCL belegt). Gegenlaeufige Beruehrpunkte (Mubea, Dematic) bleiben bei der alten Regel; Mubea/HundM05/Dematic unveraendert, Showroom 0018+0019 jetzt 2Sep1Swi. material_flow --track: Mouseover je Knoten mit Zugehoerigkeit zu Kreisel/VF/GF (Zuordnung, TrackIds-Kette, Lage laut Geometrie), Weiche mit beiden Kreiseln, Be-/Entladung mit ihrem Kreisel; Widersprueche mit "!". Zahlenwerte (Toleranzen, Schwellen, Massstaebe, Groessen) aus allen lib-Modulen nach cfg/parameter.ini (lib/parameter.py, Abschnitt je Modul, Ueberschreiben je Anlage ueber %SKEL_CFG%/parameter.ini). Ausgaben aller Anlagen bytegleich. tro_flow.py: Docstrings mit Parametern/Rueckgabe, fehlende Typangaben. Geprueft: 90 Tests gruen; tro_flow/material_flow/scl_skeleton auf 5 CSVs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
721 lines
27 KiB
Python
721 lines
27 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.
|
|
|
|
Schichten: e_planning importiert material_flow (CSV) und dxf_registration
|
|
(Lagebezug); 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 configparser
|
|
import fnmatch
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from dxf_registration import (
|
|
RegistrationError,
|
|
Transform,
|
|
derive_transform,
|
|
load_transform,
|
|
save_transform,
|
|
verify_transform,
|
|
)
|
|
from parameter import get_float
|
|
|
|
__all__ = [
|
|
"EPLAN_SCHEMA",
|
|
"EDevice",
|
|
"LayoutPoint",
|
|
"Rule",
|
|
"Assignment",
|
|
"Drive",
|
|
"EplanDocument",
|
|
"load_positionsdraw",
|
|
"load_rules",
|
|
"assign",
|
|
"pair_drives",
|
|
]
|
|
|
|
EPLAN_SCHEMA = "sps_skel-eplan-v1"
|
|
RULES_FILE = "e_planning.ini"
|
|
|
|
# Zulaessige Abweichung vom Sollabstand, wenn eine Regel kein toleranz_mm
|
|
# angibt - aus cfg/parameter.ini [e_planning] (lib/parameter.py).
|
|
DEFAULT_TOLERANCE_MM = get_float("e_planning", "toleranz_mm")
|
|
|
|
_CONFIG = ConfigDict(extra="forbid")
|
|
|
|
# "=A1+UH01-KF107" -> Ort/Steuerung "UH01", Geraet "KF107"
|
|
_KENNZEICHNUNG = re.compile(r"\+([A-Za-z0-9]+)-([A-Za-z0-9]+)")
|
|
# "MB0643" -> ("MB", "0643")
|
|
_BMK = re.compile(r"^([A-Za-z]+)(\d+)$")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Modell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class EDevice(BaseModel):
|
|
"""Ein Sensor/Aktor/Schrankelement der E-Planung."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
bmk: str
|
|
verw: str = ""
|
|
bezeichnung: str = ""
|
|
kennzeichnung: str = ""
|
|
# Position in der E-Zeichnung (mm); None bei Schrankelementen ohne Lage
|
|
pos: tuple[float, float] | None = None
|
|
# True: aus "schaltschrank_elemente" (im Schrank, nicht im Feld)
|
|
cabinet: bool = False
|
|
|
|
@property
|
|
def controller(self) -> str:
|
|
"""Steuerung/Ort aus der Kennzeichnung ("UH01"), "" wenn unbekannt."""
|
|
m = _KENNZEICHNUNG.search(self.kennzeichnung)
|
|
return m.group(1) if m else ""
|
|
|
|
@property
|
|
def station(self) -> str:
|
|
"""E/A-Station aus der Kennzeichnung ("KF107"), "" wenn unbekannt."""
|
|
m = _KENNZEICHNUNG.search(self.kennzeichnung)
|
|
return m.group(2) if m else ""
|
|
|
|
@property
|
|
def prefix(self) -> str:
|
|
m = _BMK.match(self.bmk)
|
|
return m.group(1) if m else ""
|
|
|
|
@property
|
|
def number(self) -> int | None:
|
|
m = _BMK.match(self.bmk)
|
|
return int(m.group(2)) if m else None
|
|
|
|
|
|
@dataclass
|
|
class LayoutPoint:
|
|
"""Ein punktartiges Bauteil des Layouts in CSV-Koordinaten."""
|
|
|
|
layout_id: str
|
|
kind: str
|
|
x: float
|
|
y: float
|
|
name: str = ""
|
|
|
|
|
|
@dataclass
|
|
class Rule:
|
|
"""Eine Zuordnungsregel aus e_planning.ini (Abschnitt "<Art>.<Rolle>")."""
|
|
|
|
role: str # "Separator.sensor"
|
|
layout: list[str] # Muster fuer die Layout-Art
|
|
verw: list[str] # Muster fuer VERW
|
|
max_mm: float | None = None # raeumlich: Hoechstabstand
|
|
number_from: str = "" # Nummernregel: Rolle des Partners ...
|
|
number_offset: int = 0 # ... und Versatz der Nummer
|
|
prefix: str = "" # BMK-Praefix, leer = egal
|
|
# Sollabstand: das Symbol liegt immer gleich weit neben dem Bauteil.
|
|
# Dann zaehlt die Abweichung vom Soll, nicht der kleinste Abstand.
|
|
target_mm: float | None = None
|
|
tolerance_mm: float = DEFAULT_TOLERANCE_MM
|
|
|
|
def score(self, dist: float) -> float | None:
|
|
"""Rangfolge eines Kandidaten (kleiner = besser), None = nicht zulaessig."""
|
|
if self.max_mm is not None and dist > self.max_mm:
|
|
return None
|
|
if self.target_mm is None:
|
|
return dist
|
|
dev = abs(dist - self.target_mm)
|
|
return dev if dev <= self.tolerance_mm else None
|
|
|
|
|
|
class Assignment(BaseModel):
|
|
"""Ein E-Bauteil in einer Rolle an einem Layout-Bauteil."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
layout_id: str
|
|
layout_kind: str
|
|
role: str
|
|
bmk: str
|
|
verw: str = ""
|
|
bezeichnung: str = ""
|
|
controller: str = ""
|
|
station: str = ""
|
|
# Abstand nach dem Lagebezug in mm (auch bei der Nummernregel zur Kontrolle)
|
|
distance: float
|
|
# "raeumlich" | "nummer"
|
|
rule: str
|
|
# Abstand des naechstbesten Kandidaten derselben Rolle (None: keiner)
|
|
runner_up: float | None = None
|
|
|
|
|
|
class Drive(BaseModel):
|
|
"""Motor im Feld und sein Motorschutz im Schrank (gleiche Nummer)."""
|
|
|
|
model_config = _CONFIG
|
|
|
|
motor: str
|
|
protection: str = ""
|
|
bezeichnung: str = ""
|
|
protection_bezeichnung: str = ""
|
|
|
|
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Einlesen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _device(key: str, raw: dict, cabinet: bool) -> EDevice:
|
|
# Schluessel "BG0642@UNKNOWN" / "QA7000@A/B": vor dem @ steht das BMK.
|
|
bmk = str(raw.get("IO") or raw.get("B") or key.split("@", 1)[0]).strip()
|
|
pos = raw.get("pos")
|
|
return EDevice(
|
|
bmk=bmk,
|
|
verw=str(raw.get("VERW") or "").strip(),
|
|
bezeichnung=str(raw.get("BEZEICHNUNG") or "").strip(),
|
|
kennzeichnung=str(raw.get("KENNZEICHNUNG") or "").strip(),
|
|
pos=(float(pos[0]), float(pos[1])) if pos and len(pos) >= 2 else None,
|
|
cabinet=cabinet,
|
|
)
|
|
|
|
|
|
def load_positionsdraw(path: Path, warnings: list[str] | None = None) -> list[EDevice]:
|
|
"""E-Bauteile aus einer *_positionsdraw.json lesen.
|
|
|
|
Ein BMK kommt hoechstens einmal vor. Steht es doppelt da (im Schrank einmal
|
|
mit und einmal ohne Attribute), gewinnt der Eintrag mit VERW.
|
|
"""
|
|
warnings = warnings if warnings is not None else []
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
devices: dict[str, EDevice] = {}
|
|
for section, cabinet in (("sensors", False), ("schaltschrank_elemente", True)):
|
|
for key, raw in (data.get(section) or {}).items():
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
dev = _device(key, raw, cabinet)
|
|
if not dev.bmk:
|
|
continue
|
|
old = devices.get(dev.bmk)
|
|
if old is None or (not old.verw and dev.verw):
|
|
devices[dev.bmk] = dev
|
|
for bmk, positions in (data.get("double_ids") or {}).items():
|
|
warnings.append(f"E-Planung: {bmk} steht {len(positions)}x in der Zeichnung "
|
|
f"- verwendet wird ein Eintrag")
|
|
return list(devices.values())
|
|
|
|
|
|
def _patterns(text: str) -> list[str]:
|
|
return [p.strip() for p in text.split(",") if p.strip()]
|
|
|
|
|
|
def load_rules(path: Path) -> tuple[list[Rule], list[str]]:
|
|
"""Regeln und Anker-Verwendungen aus e_planning.ini lesen.
|
|
|
|
Abschnitt [Registrierung] -> anker (VERW-Muster fuer den Lagebezug);
|
|
jeder Abschnitt "<Art>.<Rolle>" ist eine Regel, in Dateireihenfolge.
|
|
"""
|
|
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"),
|
|
interpolation=None)
|
|
parser.optionxform = str # Schluessel nicht kleinschreiben
|
|
with path.open(encoding="utf-8") as fh:
|
|
parser.read_file(fh)
|
|
anchors = _patterns(parser.get("Registrierung", "anker", fallback="IN SEP"))
|
|
rules = []
|
|
for section in parser.sections():
|
|
if "." not in section:
|
|
continue
|
|
s = parser[section]
|
|
max_mm = s.get("max_mm")
|
|
soll = s.get("soll_mm")
|
|
rules.append(Rule(
|
|
role=section,
|
|
layout=_patterns(s.get("layout", section.split(".", 1)[0])),
|
|
verw=_patterns(s.get("verw", "*")),
|
|
max_mm=float(max_mm) if max_mm else None,
|
|
number_from=s.get("nummer_von", "").strip(),
|
|
number_offset=int(s.get("nummer_versatz", "0")),
|
|
prefix=s.get("praefix", "").strip(),
|
|
target_mm=float(soll) if soll else None,
|
|
tolerance_mm=float(s.get("toleranz_mm", str(DEFAULT_TOLERANCE_MM))),
|
|
))
|
|
known = {r.role for r in rules}
|
|
for r in rules:
|
|
if r.number_from and r.number_from not in known:
|
|
raise ValueError(f"{path.name} [{r.role}]: nummer_von = {r.number_from} "
|
|
f"ist keine Regel")
|
|
return rules, anchors
|
|
|
|
|
|
def _match(text: str, patterns: list[str]) -> bool:
|
|
return any(fnmatch.fnmatchcase(text.upper(), p.upper()) for p in patterns)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Zuordnung
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _State:
|
|
positions: dict[str, tuple[float, float]]
|
|
used: set[str] = field(default_factory=set)
|
|
result: list[Assignment] = field(default_factory=list)
|
|
# (layout_id, role) -> Assignment
|
|
by_slot: dict[tuple[str, str], Assignment] = field(default_factory=dict)
|
|
|
|
|
|
def _make(point: LayoutPoint, rule: Rule, dev: EDevice, dist: float, how: str,
|
|
runner_up: float | None) -> Assignment:
|
|
return Assignment(
|
|
layout_id=point.layout_id, layout_kind=point.kind, role=rule.role,
|
|
bmk=dev.bmk, verw=dev.verw, bezeichnung=dev.bezeichnung,
|
|
controller=dev.controller, station=dev.station,
|
|
distance=round(dist, 1), rule=how,
|
|
runner_up=None if runner_up is None else round(runner_up, 1),
|
|
)
|
|
|
|
|
|
def _candidates(rule: Rule, devices: list[EDevice], state: _State) -> list[EDevice]:
|
|
return [
|
|
d for d in devices
|
|
if d.bmk not in state.used and d.bmk in state.positions
|
|
and _match(d.verw, rule.verw)
|
|
and (not rule.prefix or d.prefix.upper() == rule.prefix.upper())
|
|
]
|
|
|
|
|
|
def _assign_spatial(rule: Rule, points: list[LayoutPoint], devices: list[EDevice],
|
|
state: _State) -> None:
|
|
"""Beste Bewertung zuerst (Rule.score); je Bauteil und E-Bauteil hoechstens einmal."""
|
|
cands = _candidates(rule, devices, state)
|
|
pairs = []
|
|
for p in points:
|
|
if (p.layout_id, rule.role) in state.by_slot:
|
|
continue
|
|
for d in cands:
|
|
dist = math.dist((p.x, p.y), state.positions[d.bmk])
|
|
score = rule.score(dist)
|
|
if score is not None:
|
|
pairs.append((score, dist, p.layout_id, d.bmk))
|
|
pairs.sort()
|
|
# je Bauteil die Kandidaten in Rangfolge - fuer "naechstbester Kandidat"
|
|
by_point: dict[str, list[tuple[str, float]]] = {}
|
|
for _, dist, pid, bmk in pairs:
|
|
by_point.setdefault(pid, []).append((bmk, dist))
|
|
point_of = {p.layout_id: p for p in points}
|
|
dev_of = {d.bmk: d for d in cands}
|
|
for _, dist, pid, bmk in pairs:
|
|
if (pid, rule.role) in state.by_slot or bmk in state.used:
|
|
continue
|
|
others = [x for b, x in by_point[pid] if b != bmk]
|
|
a = _make(point_of[pid], rule, dev_of[bmk], dist, "raeumlich",
|
|
others[0] if others else None)
|
|
state.used.add(bmk)
|
|
state.by_slot[(pid, rule.role)] = a
|
|
state.result.append(a)
|
|
|
|
|
|
def _assign_by_number(rule: Rule, points: list[LayoutPoint], devices: list[EDevice],
|
|
state: _State) -> None:
|
|
"""Nummer = Nummer des Partners (Rolle nummer_von) + Versatz."""
|
|
cands = {(d.number): d for d in _candidates(rule, devices, state)
|
|
if d.number is not None}
|
|
for p in points:
|
|
if (p.layout_id, rule.role) in state.by_slot:
|
|
continue
|
|
partner = state.by_slot.get((p.layout_id, rule.number_from))
|
|
if partner is None:
|
|
continue
|
|
m = _BMK.match(partner.bmk)
|
|
if not m:
|
|
continue
|
|
dev = cands.get(int(m.group(2)) + rule.number_offset)
|
|
if dev is None or dev.bmk in state.used:
|
|
continue
|
|
dist = math.dist((p.x, p.y), state.positions[dev.bmk])
|
|
# Auch die Nummernregel darf nicht quer ueber die Anlage greifen
|
|
if rule.max_mm is not None and dist > rule.max_mm:
|
|
continue
|
|
a = _make(p, rule, dev, dist, "nummer", None)
|
|
state.used.add(dev.bmk)
|
|
state.by_slot[(p.layout_id, rule.role)] = a
|
|
state.result.append(a)
|
|
|
|
|
|
def assign(points: list[LayoutPoint], devices: list[EDevice], rules: list[Rule],
|
|
transform: Transform) -> list[Assignment]:
|
|
"""Regeln der Reihe nach anwenden.
|
|
|
|
Eine Regel mit nummer_von versucht zuerst die Nummernregel und nimmt fuer
|
|
die verbleibenden Bauteile den Abstand (wenn max_mm/soll_mm gesetzt ist). So wird
|
|
das Ventil eines Separators ueber die Nummer gefunden, auch wenn das
|
|
Ventil des Nachbarn naeher liegt (Speicherlinien im 800-mm-Raster).
|
|
"""
|
|
state = _State(positions={
|
|
d.bmk: transform.to_csv(*d.pos) for d in devices
|
|
if d.pos is not None and not d.cabinet
|
|
})
|
|
for rule in rules:
|
|
targets = [p for p in points if _match(p.kind, rule.layout)]
|
|
if rule.number_from:
|
|
_assign_by_number(rule, targets, devices, state)
|
|
if rule.max_mm is not None or rule.target_mm is not None:
|
|
_assign_spatial(rule, targets, devices, state)
|
|
return sorted(state.result, key=lambda a: (a.layout_id, a.role))
|
|
|
|
|
|
def pair_drives(devices: list[EDevice]) -> list[Drive]:
|
|
"""Motor MAnnnn <-> Motorschutz FCnnnn (gleiche Nummer)."""
|
|
fc = {d.number: d for d in devices if d.prefix.upper() == "FC" and d.number is not None}
|
|
drives = []
|
|
for d in sorted(devices, key=lambda d: d.bmk):
|
|
if d.prefix.upper() != "MA" or d.number is None:
|
|
continue
|
|
schutz = fc.get(d.number)
|
|
drives.append(Drive(
|
|
motor=d.bmk,
|
|
protection=schutz.bmk if schutz else "",
|
|
bezeichnung=d.bezeichnung,
|
|
protection_bezeichnung=schutz.bezeichnung if schutz else "",
|
|
))
|
|
return drives
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layout und Lagebezug
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Element.kind der punktartigen Layout-Bauteile
|
|
LAYOUT_KINDS = ("Separator", "Scanner", "Weiche", "Ausschleus", "Einschleus",
|
|
"Beladung", "Entladung")
|
|
|
|
|
|
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
|
|
|
|
|
|
def _anchor_points(points: list[LayoutPoint], devices: list[EDevice],
|
|
anchors: list[str]):
|
|
csv_pts = {"Separator": [(p.x, p.y) for p in points if p.kind == "Separator"]}
|
|
e_pts = {"Separator": [d.pos for d in devices
|
|
if d.pos is not None and not d.cabinet
|
|
and _match(d.verw, anchors)]}
|
|
return csv_pts, e_pts
|
|
|
|
|
|
def resolve_transform(points, devices, anchors, cfg_dir: Path, csv_name: str,
|
|
eplan_name: str, re_register: bool,
|
|
warnings: list[str]) -> Transform:
|
|
"""Abgelegten Lagebezug nachrechnen oder neu ermitteln und ablegen."""
|
|
csv_pts, e_pts = _anchor_points(points, devices, anchors)
|
|
if not re_register:
|
|
stored = load_transform(cfg_dir, csv_name, eplan_name)
|
|
if stored:
|
|
verify_transform(stored, csv_pts, e_pts)
|
|
return stored
|
|
transform = derive_transform(csv_pts, e_pts, csv_name, eplan_name)
|
|
saved = save_transform(cfg_dir, transform)
|
|
warnings.append(f"Lagebezug neu ermittelt und abgelegt: {saved}")
|
|
return transform
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
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())
|