Files
sps_skel/lib/merge_hw_layout.py
T
Samer AyadiandClaude Opus 5.5 df24cf5adb hw_import + merge_hw_layout: Hardwareplanung einlesen und mit dem Layout zusammenfuehren
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>
2026-10-15 16:16:10 +02:00

970 lines
41 KiB
Python

# -*- coding: utf-8 -*-
# Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3"
# ableiten und scheitert, wenn keine PythonCore-Installation registriert ist.
"""
merge_hw_layout.py - Layout und Hardwareplanung zusammenfuehren -> <csv>_anlage.json.
Stufe 3 des Ablaufs in doc/Konzept_Layout_Hardware_SCL.md:
<csv>_tro.json (tro_flow --json) + <projekt>_hw.json (hw_import)
\\ /
merge_hw_layout -> <csv>_anlage.json + Bericht .md
Das CSV wird nicht gelesen: die TRO-JSON traegt die Slots der TROs und die
Lage der Layout-Bauteile (``parts``). Ablauf:
1. Lagebezug E-Zeichnung -> Layout (lib/dxf_registration.py) ueber die
Separator-Sensoren (VERW "IN SEP") gegen die Separatoren des Layouts;
abgelegt in SKEL_CFG/dxf_registration.json mit Pruefsumme der
Positionsdatei - ein neuer Stand gleichen Namens wird neu ermittelt.
2. Regeln aus SKEL_CFG/e_planning.ini (raeumlich mit Hoechst-/Sollabstand,
Nummernregel MB = BG + 1) ordnen E-Bauteile Layout-Bauteilen zu.
3. Jede Zuordnung geht an die Slot-Rolle des TRO, der das Bauteil traegt
(Schluessel TRO-ID + Slot + Rolle). Bauteile ohne Slot (Ausschleus-
elemente, Weichen) bleiben als ``ohne_slot`` sichtbar.
4. Foerderer: die Namen der E-Planung (K0102) und des Layouts (Kreisel3)
passen nicht zusammen. Alle Feldgeraete, deren Klartext einen Foerderer
nennt, stimmen fuer den naechsten Layout-Foerderer ab; die Mehrheit
entscheidet (cfg/parameter.ini [merge_hw_layout]). Daraus Antrieb, Motorschutz
und Geber je Layout-Foerderer und die Antriebs-Slots der Vario-TROs.
5. Handkorrekturen aus SKEL_CFG/hw_overrides.ini, zuletzt.
6. Pflichtrollen ohne BMK werden ``offen`` (spaeter TODO(E-Planung)), ein
BMK, das nicht im TIA-Import steht, ``widerspruch``.
Lieber eine Luecke als ein falscher Name: was keine Regel sicher trifft,
bleibt offen und steht im Bericht.
Die Zuordnungslogik (Regeln, raeumlich, Nummernregel) stand bis 2026-10-15
in e_planning.py; e_planning importiert sie jetzt von hier, damit
``scl_skeleton --eplan`` bis zur Umstellung auf die Anlagen-JSON weiterlaeuft.
Aufruf ueber bin/merge_hw_layout.bat bzw. bin/merge_hw_layout.sh.
"""
from __future__ import annotations
import argparse
import configparser
import fnmatch
import math
import sys
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from pydantic import BaseModel, ConfigDict, ValidationError
from dxf_registration import (
RegistrationError,
Transform,
derive_transform,
load_transform,
save_transform,
verify_transform,
)
from hw_import import HW_SCHEMA, EDevice, HardwareDocument, HwDevice
from model import (
AnlageDocument,
Binding,
ConveyorBinding,
LayoutPart,
Registration,
TroDocument,
)
from parameter import get_float, get_int
__all__ = [
"RULES_FILE",
"OVERRIDES_FILE",
"LAYOUT_KINDS",
"CONVEYOR_KINDS",
"LayoutPoint",
"Rule",
"Assignment",
"load_rules",
"assign",
"resolve_transform",
"points_from_parts",
"bind_slots",
"assign_conveyors",
"load_overrides",
"build_anlage",
]
RULES_FILE = "e_planning.ini"
OVERRIDES_FILE = "hw_overrides.ini"
# Zahlenwerte aus cfg/parameter.ini [merge_hw_layout] - Bedeutung und Einheit stehen dort.
_P = "merge_hw_layout"
DEFAULT_TOLERANCE_MM = get_float(_P, "toleranz_mm")
CONVEYOR_MAX_MM = get_float(_P, "conveyor_max_mm")
CONVEYOR_MIN_VOTES = get_int(_P, "conveyor_min_votes")
CONVEYOR_MIN_SHARE = get_float(_P, "conveyor_min_share")
REGISTRATION_MIN_SHARE = get_float(_P, "registration_min_share")
_CONFIG = ConfigDict(extra="forbid")
# Punktartige Layout-Bauteile (Element.kind bzw. Teil der TeileArt)
LAYOUT_KINDS = ("Separator", "Scanner", "Weiche", "Ausschleus", "Einschleus",
"Beladung", "Entladung")
# Foerderer, die fuer einen Foerderer der E-Planung in Frage kommen
CONVEYOR_KINDS = ("Kreisel", "Strecke", "Gefaellestrecke")
# Antriebs-Rollen eines Slots, die aus dem zugeordneten Foerderer folgen
_DRIVE_ROLES = {"motor": "motor", "motorProtection": "protection"}
# ---------------------------------------------------------------------------
# Regeln und Zuordnung (bis 2026-10-15 in e_planning.py)
# ---------------------------------------------------------------------------
@dataclass
class LayoutPoint:
"""Ein punktartiges Bauteil des Layouts in Layout-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
@property
def slot_role(self) -> str:
"""Rolle im Slot: "Separator.sensor" -> "sensor"."""
return self.role.split(".", 1)[-1]
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
def _patterns(text: str) -> list[str]:
return [p.strip() for p in text.split(",") if p.strip()]
def _match(text: str, patterns: list[str]) -> bool:
return any(fnmatch.fnmatchcase(text.upper(), p.upper()) for p in patterns)
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
@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 or partner.bmk == "":
continue
number = next((d.number for d in devices if d.bmk == partner.bmk), None)
if number is None:
continue
dev = cands.get(number + 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))
# ---------------------------------------------------------------------------
# Lagebezug
# ---------------------------------------------------------------------------
def _anchor_points(points: list[LayoutPoint], devices: list[EDevice],
anchors: list[str]):
layout_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 layout_pts, e_pts
def resolve_transform(points, devices, anchors, cfg_dir: Path, csv_name: str,
eplan_name: str, re_register: bool,
warnings: list[str], fingerprint: str = "") -> Transform:
"""Abgelegten Lagebezug nachrechnen oder neu ermitteln und ablegen.
Mit ``fingerprint`` (Pruefsumme der Positionsdatei) gilt ein abgelegter
Bezug nur fuer genau diesen Dateistand.
"""
layout_pts, e_pts = _anchor_points(points, devices, anchors)
if not re_register:
stored = load_transform(cfg_dir, csv_name, eplan_name, fingerprint)
if stored:
verify_transform(stored, layout_pts, e_pts)
return stored
transform = derive_transform(layout_pts, e_pts, csv_name, eplan_name)
transform.fingerprint = fingerprint
saved = save_transform(cfg_dir, transform)
warnings.append(f"Lagebezug neu ermittelt und abgelegt: {saved}")
return transform
# ---------------------------------------------------------------------------
# Slots
# ---------------------------------------------------------------------------
def points_from_parts(parts: list[LayoutPart]) -> list[LayoutPoint]:
"""Punktartige Bauteile aus TroDocument.parts."""
points = []
for p in parts:
if p.position is None:
continue
kind = next((k for k in LAYOUT_KINDS if k.lower() in p.kind.lower()), "")
if kind:
points.append(LayoutPoint(p.layout_id, kind, p.position[0], p.position[1],
p.name))
return points
def _binding_from(a: Assignment, device: HwDevice | None, tro: str = "",
slot: str = "", role: str = "") -> Binding:
status, note = "zugeordnet", ""
if device is None or not device.in_tia:
status, note = "widerspruch", "BMK nicht im TIA-Import"
if not tro:
status = "ohne_slot" if status == "zugeordnet" else status
note = note or f"{a.layout_kind} {a.layout_id} haengt an keinem TRO-Slot"
return Binding(
tro=tro, slot=slot, role=role or a.role, layout_id=a.layout_id, bmk=a.bmk,
constant=device.constant if device else "",
address=device.address if device else "",
source="positionsdraw", rule=a.rule, distance=a.distance,
runner_up=a.runner_up, status=status, note=note,
)
def bind_slots(doc: TroDocument, assignments: list[Assignment],
rules: list[Rule], hw: HardwareDocument) -> list[Binding]:
"""Zuordnungen an die Slot-Rollen der TROs haengen.
Ein Bauteil kann in zwei Slots desselben TRO stehen (Speicher:
``separator1`` und ``line1``) - dann bekommen beide die Zuordnung.
"""
devices = hw.by_bmk()
slots_of: dict[str, list[tuple[str, str, list[str]]]] = {}
for tro in doc.tros:
for s in tro.slots:
if s.layout_id:
roles = [r.rstrip("?") for r in s.roles]
slots_of.setdefault(s.layout_id, []).append((tro.tro_id, s.slot, roles))
rule_of = {r.role: r for r in rules}
bindings: list[Binding] = []
for a in assignments:
role = rule_of[a.role].slot_role if a.role in rule_of else a.role.split(".")[-1]
targets = [(t, s) for t, s, roles in slots_of.get(a.layout_id, []) if role in roles]
if not targets:
bindings.append(_binding_from(a, devices.get(a.bmk)))
continue
for tro_id, slot in targets:
bindings.append(_binding_from(a, devices.get(a.bmk), tro_id, slot, role))
return bindings
# ---------------------------------------------------------------------------
# Foerderer
# ---------------------------------------------------------------------------
def assign_conveyors(doc: TroDocument, hw: HardwareDocument, transform: Transform
) -> list[ConveyorBinding]:
"""Foerderer der E-Planung (K0102) per Mehrheit einem Layout-Foerderer zuordnen.
Jedes Feldgeraet, dessen Klartext einen Foerderer nennt, stimmt fuer den
Layout-Foerderer, dessen Huellbox ihm am naechsten liegt (hoechstens
CONVEYOR_MAX_MM). Zugeordnet wird bei mindestens CONVEYOR_MIN_VOTES
Stimmen und CONVEYOR_MIN_SHARE Anteil; je Layout-Foerderer hoechstens ein
Foerderer der E-Planung (der mit den meisten Stimmen).
"""
conveyors = [p for p in doc.parts if p.kind in CONVEYOR_KINDS and p.bbox and p.position]
drives = {dr.conveyor: dr for dr in hw.drives if dr.conveyor}
kind_of = _conveyor_kinds(hw)
votes: dict[str, Counter] = {}
totals: Counter = Counter()
for d in hw.field_devices():
if not d.conveyor:
continue
totals[d.conveyor] += 1
# Die Huellboxen ueberlappen (Speicherlinien reichen bis an den
# Kreisel); nennt der Klartext die Art, stimmen nur Foerderer dieser Art.
kinds = _LAYOUT_KINDS_FOR.get(kind_of.get(d.conveyor, ""), CONVEYOR_KINDS)
x, y = transform.to_csv(*d.pos)
best = min(((c.distance_to_box(x, y), c.layout_id) for c in conveyors
if c.kind in kinds), default=(None, ""))
if best[0] is not None and best[0] <= CONVEYOR_MAX_MM:
votes.setdefault(d.conveyor, Counter())[best[1]] += 1
names = sorted(set(totals) | set(drives))
name_of = {p.layout_id: p.name for p in doc.parts}
candidates = []
for name in names:
counter = votes.get(name, Counter())
layout_id, n = counter.most_common(1)[0] if counter else ("", 0)
candidates.append((n, name, layout_id))
taken: dict[str, str] = {}
result = []
for n, name, layout_id in sorted(candidates, key=lambda c: (-c[0], c[1])):
total = totals.get(name, 0)
dr = drives.get(name)
cb = ConveyorBinding(eplan_name=name, votes=n, total=total, rule="mehrheit",
motor=dr.motor if dr else "",
protection=dr.protection if dr else "",
encoder=dr.encoder if dr else "")
share = n / total if total else 0.0
if not layout_id:
cb.status = "offen"
cb.note = ("kein Feldgeraet nennt diesen Foerderer" if not total else
f"keine Stimme innerhalb {CONVEYOR_MAX_MM:.0f} mm eines Layout-Foerderers")
elif n < CONVEYOR_MIN_VOTES or share < CONVEYOR_MIN_SHARE:
cb.status = "offen"
cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "")
cb.note = (f"unsicher: {n} von {total} Stimmen fuer {cb.layout_name or layout_id} "
f"(noetig {CONVEYOR_MIN_VOTES} und {CONVEYOR_MIN_SHARE:.0%})")
elif layout_id in taken:
cb.status = "widerspruch"
cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "")
cb.note = f"{layout_id} ist schon {taken[layout_id]} zugeordnet (mehr Stimmen)"
else:
taken[layout_id] = name
cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "")
others = votes.get(name, Counter())
rest = [f"{name_of.get(k, k)} {v}" for k, v in others.most_common() if k != layout_id]
if rest:
cb.note = "Gegenstimmen: " + ", ".join(rest)
result.append(cb)
return sorted(result, key=lambda c: c.eplan_name)
# Art des Foerderers im Klartext der E-Planung -> Arten im Layout
_LAYOUT_KINDS_FOR = {"Kreisel": ("Kreisel",), "Strecke": ("Strecke", "Gefaellestrecke")}
def _conveyor_kinds(hw: HardwareDocument) -> dict[str, str]:
"""Art je Foerderer der E-Planung: Mehrheit aller Klartexte, die ihn nennen.
"PRUEFUNG BT. POS.1 (KR-K0102 EINFAHRT PUFFER)" und "ANTRIEB
KREISEL-K0102" sagen Kreisel; ein Geraet ohne Artwort erbt die Art.
"""
counts: dict[str, Counter] = {}
for d in hw.devices:
if d.conveyor and d.conveyor_kind:
counts.setdefault(d.conveyor, Counter())[d.conveyor_kind] += 1
for dr in hw.drives:
if dr.conveyor and dr.conveyor_kind:
counts.setdefault(dr.conveyor, Counter())[dr.conveyor_kind] += 1
return {name: c.most_common(1)[0][0] for name, c in counts.items()}
def bind_drives(doc: TroDocument, conveyors: list[ConveyorBinding],
hw: HardwareDocument) -> list[Binding]:
"""Antriebs-Slots (Vario): Motor und Motorschutz des zugeordneten Foerderers."""
devices = hw.by_bmk()
by_layout = {c.layout_id: c for c in conveyors if c.status == "zugeordnet"}
bindings = []
for tro in doc.tros:
for s in tro.slots:
if not s.layout_id:
continue
conv = by_layout.get(s.layout_id.split("-", 1)[0])
if conv is None:
continue
roles = [r.rstrip("?") for r in s.roles]
for role, attr in _DRIVE_ROLES.items():
bmk = getattr(conv, attr)
if role not in roles or not bmk:
continue
dev = devices.get(bmk)
bindings.append(Binding(
tro=tro.tro_id, slot=s.slot, role=role, layout_id=s.layout_id,
bmk=bmk, constant=dev.constant if dev else "",
address=dev.address if dev else "", source="positionsdraw",
rule="mehrheit", status="zugeordnet" if dev and dev.in_tia else "widerspruch",
note=f"Antrieb von {conv.eplan_name}"))
return bindings
# ---------------------------------------------------------------------------
# Handkorrekturen
# ---------------------------------------------------------------------------
@dataclass
class Overrides:
"""Handkorrekturen aus hw_overrides.ini fuer eine Anlage."""
# (tro, slot, role) -> BMK ("" = Zuordnung loeschen, Rolle offen)
bindings: dict[tuple[str, str, str], str] = field(default_factory=dict)
# Foerderer der E-Planung -> Layout-ID ("" = Zuordnung loeschen)
conveyors: dict[str, str] = field(default_factory=dict)
def load_overrides(path: Path, layout_source: str) -> Overrides:
"""Abschnitte ``[<csv>.bindings]`` und ``[<csv>.conveyors]`` lesen.
``TRO01.separator1.sensor = BG0532`` setzt ein BMK, ``= -`` loescht die
Zuordnung (Rolle bleibt offen); ``K0102 = 0010`` setzt einen Foerderer.
"""
result = Overrides()
if not path.is_file():
return result
parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"),
interpolation=None)
parser.optionxform = str
with path.open(encoding="utf-8") as fh:
parser.read_file(fh)
section = f"{layout_source}.bindings"
if parser.has_section(section):
for key, value in parser[section].items():
parts = key.split(".")
if len(parts) != 3:
raise ValueError(f"{path.name} [{section}] '{key}': erwartet "
f"<TRO-ID>.<Slot>.<Rolle>")
result.bindings[(parts[0], parts[1], parts[2].rstrip("?"))] = \
"" if value.strip() == "-" else value.strip()
section = f"{layout_source}.conveyors"
if parser.has_section(section):
for key, value in parser[section].items():
result.conveyors[key.strip()] = "" if value.strip() == "-" else value.strip()
return result
def _apply_overrides(doc: TroDocument, bindings: list[Binding],
conveyors: list[ConveyorBinding], overrides: Overrides,
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():
s = slots.get((tro, slot))
if s is None:
warnings.append(f"{OVERRIDES_FILE}: {tro}.{slot} gibt es in der TRO-JSON nicht "
f"- Korrektur nicht angewendet")
continue
bindings = [b for b in bindings
if not (b.tro == tro and b.slot == slot and b.role == role)]
if not bmk:
continue
# dasselbe BMK nicht zusaetzlich automatisch an anderer Stelle
bindings = [b for b in bindings if b.bmk != bmk or b.rule == "hand"]
dev = devices.get(bmk)
bindings.append(Binding(
tro=tro, slot=slot, role=role, layout_id=s.layout_id, bmk=bmk,
constant=dev.constant if dev else "", address=dev.address if dev else "",
source=OVERRIDES_FILE, rule="hand",
status="zugeordnet" if dev and dev.in_tia else "widerspruch",
note="" if dev and dev.in_tia else
("BMK nicht im TIA-Import" if dev else "BMK unbekannt in der Hardwareplanung")))
name_of = {p.layout_id: p.name for p in doc.parts}
for name, layout_id in overrides.conveyors.items():
cb = next((c for c in conveyors if c.eplan_name == name), None)
if cb is None:
cb = ConveyorBinding(eplan_name=name)
conveyors.append(cb)
if layout_id and doc.part(layout_id) is None:
warnings.append(f"{OVERRIDES_FILE}: Foerderer {name} -> {layout_id}: Layout-ID "
f"unbekannt - Korrektur nicht angewendet")
continue
cb.layout_id, cb.layout_name = layout_id, name_of.get(layout_id, "")
cb.rule = "hand"
cb.status = "zugeordnet" if layout_id else "offen"
cb.note = "von Hand"
return bindings
# ---------------------------------------------------------------------------
# Zusammenbau
# ---------------------------------------------------------------------------
def _open_and_conflicts(doc: TroDocument, bindings: list[Binding]) -> list[Binding]:
"""Pflichtrollen ohne BMK als ``offen``; doppelt vergebene BMK als Widerspruch."""
have = {(b.tro, b.slot, b.role) for b in bindings if b.tro}
for tro in doc.tros:
for s in tro.slots:
for role in s.roles:
if role.endswith("?") or (tro.tro_id, s.slot, role) in have:
continue
bindings.append(Binding(tro=tro.tro_id, slot=s.slot, role=role,
layout_id=s.layout_id, status="offen"))
# Ein BMK an zwei verschiedenen Bauteilen ist ein Widerspruch. Derselbe
# Separator in zwei Slots eines Speichers (separator1/line1) ist keiner.
places: dict[str, set[tuple[str, str]]] = {}
for b in bindings:
if b.bmk and b.status != "offen":
places.setdefault(b.bmk, set()).add((b.layout_id, b.role))
for b in bindings:
if b.bmk and len(places.get(b.bmk, ())) > 1 and b.status == "zugeordnet":
b.status = "widerspruch"
b.note = "BMK mehrfach vergeben"
order = {t.tro_id: i for i, t in enumerate(doc.tros)}
return sorted(bindings, key=lambda b: (b.tro == "", order.get(b.tro, 0), b.slot,
b.role, b.layout_id))
def build_anlage(doc: TroDocument, hw: HardwareDocument, rules: list[Rule],
anchors: list[str], cfg_dir: Path, layout_file: str, hw_file: str,
overrides: Overrides | None = None, re_register: bool = False,
transform: Transform | None = None) -> AnlageDocument:
"""Kompletter Lauf ohne Ausgabe: TRO-JSON + Hardware-JSON -> AnlageDocument.
``transform`` vorgeben (Tests) ueberspringt die Ermittlung des Lagebezugs.
"""
warnings: list[str] = []
if not doc.parts:
raise ValueError(f"{layout_file} enthaelt keine 'parts' (Lage der Bauteile) - "
f"mit tro_flow --json neu erzeugen")
points = points_from_parts(doc.parts)
devices: list[EDevice] = list(hw.devices)
assignments: list[Assignment] = []
conveyors: list[ConveyorBinding] = []
if hw.field_devices():
if transform is None:
transform = resolve_transform(points, devices, anchors, cfg_dir, doc.source,
hw.positions_file, re_register, warnings,
hw.positions_sha256)
share = transform.matched / transform.candidates if transform.candidates else 1.0
if share < REGISTRATION_MIN_SHARE:
warnings.append(
f"Lagebezug passt nur fuer {transform.matched} von {transform.candidates} "
f"Separatoren ({share:.0%}) - Layout und E-Zeichnung sind vermutlich "
f"verschiedene Planungsstaende; raeumliche Zuordnungen pruefen")
assignments = assign(points, devices, rules, transform)
conveyors = assign_conveyors(doc, hw, transform)
else:
warnings.append("Hardwareplanung ohne Lage (keine Positionsdatei) - keine "
"raeumliche Zuordnung, alle Rollen offen")
bindings = bind_slots(doc, assignments, rules, hw)
if overrides is not None:
bindings = _apply_overrides(doc, bindings, conveyors, overrides, hw, warnings)
bindings += bind_drives(doc, conveyors, hw)
bindings = _open_and_conflicts(doc, bindings)
used = {b.bmk for b in bindings if b.bmk}
unassigned = sorted(d.bmk for d in hw.field_devices() if d.bmk not in used)
registration = None
if transform is not None:
registration = Registration(
dx=round(transform.dx, 1), dy=round(transform.dy, 1),
rotation=round(transform.rotation, 4), source=transform.source,
matched=transform.matched, candidates=transform.candidates,
residual_max=round(transform.residual_max, 1),
description=transform.describe())
return AnlageDocument(
layout_file=layout_file, hw_file=hw_file, project=hw.project,
registration=registration, layout=doc, bindings=bindings,
conveyors=sorted(conveyors, key=lambda c: c.eplan_name),
unassigned=unassigned, warnings=warnings + hw.checks,
)
# ---------------------------------------------------------------------------
# Bericht
# ---------------------------------------------------------------------------
def render_report(anlage: AnlageDocument, hw: HardwareDocument) -> str:
out: list[str] = []
add = out.append
devices = hw.by_bmk()
add(f"# Anlage: {anlage.layout.source} + {anlage.project}")
add("")
add(f"- Layout: `{anlage.layout_file}`")
add(f"- Hardwareplanung: `{anlage.hw_file}` (TIA `{hw.tia_file or '-'}`, "
f"Positionen `{hw.positions_file or '-'}`)")
add(f"- Erzeugt: {anlage.created}")
if anlage.registration:
add(f"- Lagebezug: {anlage.registration.description}")
add("")
add("## Slot-Rollen")
add("")
add("| Slot-Art | Rolle | zugeordnet | offen | Widerspruch |")
add("|---|---|---|---|---|")
stats: dict[tuple[str, str], Counter] = {}
for b in anlage.bindings:
if not b.tro:
continue
kind = b.slot.rstrip("0123456789")
stats.setdefault((kind, b.role), Counter())[b.status] += 1
for (kind, role), c in sorted(stats.items()):
add(f"| {kind} | {role} | {c['zugeordnet']} | {c['offen']} | {c['widerspruch']} |")
add("")
add("## Foerderer (E-Planung -> Layout)")
add("")
if anlage.conveyors:
add("| E-Planung | Layout | Stimmen | Motor | Motorschutz | Geber | Status | Hinweis |")
add("|---|---|---|---|---|---|---|---|")
for c in anlage.conveyors:
layout = f"{c.layout_name} ({c.layout_id})" if c.layout_id else "-"
add(f"| {c.eplan_name} | {layout} | {c.votes}/{c.total} | {c.motor or '-'} "
f"| {c.protection or '-'} | {c.encoder or '-'} | {c.status} | {c.note} |")
else:
add("- keine")
add("")
add("## Zuordnungen je TRO")
add("")
add("Abstand nach dem Lagebezug; \"2.\" = naechstbester Kandidat (knapp = pruefen).")
add("")
add("| TRO | Slot | Rolle | Layout | BMK | Konstante | Adresse | VERW | Abstand mm "
"| 2. | Regel | Status |")
add("|---|---|---|---|---|---|---|---|---|---|---|---|")
for b in anlage.bindings:
if not b.tro or b.status == "offen":
continue
d = devices.get(b.bmk)
dist = "" if b.distance is None else f"{b.distance:.0f}"
second = "" if b.runner_up is None else f"{b.runner_up:.0f}"
status = b.status + (f" ({b.note})" if b.note and b.status != "zugeordnet" else "")
add(f"| {b.tro} | {b.slot} | {b.role} | {b.layout_id} | {b.bmk} | {b.constant} "
f"| {b.address} | {d.verw if d else ''} | {dist} | {second} | {b.rule} | {status} |")
add("")
add("## Offene Pflichtrollen")
add("")
open_by: dict[str, list[str]] = {}
for b in anlage.bindings:
if b.status == "offen":
open_by.setdefault(b.tro, []).append(f"{b.slot}.{b.role}")
if open_by:
for tro, roles in open_by.items():
add(f"- **{tro}**: {', '.join(roles)}")
else:
add("- keine")
add("")
loose = [b for b in anlage.bindings if not b.tro]
add("## Zugeordnet, aber ohne TRO-Slot")
add("")
if loose:
add("Diese Bauteile haengen (noch) an keinem Slot der TRO-JSON, z. B. "
"Ausschleuselemente: welcher TRO-Ausgang zu welchem Element gehoert, "
"weiss tro_flow noch nicht.")
add("")
add("| Regel | Layout | BMK | Abstand mm | Status |")
add("|---|---|---|---|---|")
for b in loose:
dist = "" if b.distance is None else f"{b.distance:.0f}"
add(f"| {b.role} | {b.layout_id} | {b.bmk} | {dist} | {b.status} |")
else:
add("- keine")
add("")
add("## Feldgeraete ohne Zuordnung")
add("")
if anlage.unassigned:
add("| BMK | VERW | Bezeichnung | Station |")
add("|---|---|---|---|")
for bmk in anlage.unassigned:
d = devices[bmk]
add(f"| {bmk} | {d.verw} | {d.bezeichnung} | {d.controller}-{d.station} |")
else:
add("- keine")
add("")
if anlage.warnings:
add("## Hinweise")
add("")
for w in anlage.warnings:
add(f"- {w}")
add("")
return "\n".join(out)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _in_results(name: str, results: Path) -> Path:
path = Path(name)
if path.is_absolute() or path.parent != Path(".") or path.is_file():
return path
return results / name
def main(argv: list[str] | None = None) -> int:
from material_flow import env_dir
args = parse_args(argv)
results = env_dir("SKEL_RESULTS", "results")
cfg_dir = env_dir("SKEL_CFG", "cfg")
tro_path = _in_results(args.tro, results)
hw_path = _in_results(args.hw, results)
rules_path = Path(args.rules) if args.rules else cfg_dir / RULES_FILE
overrides_path = Path(args.overrides) if args.overrides else cfg_dir / OVERRIDES_FILE
for label, path in (("TRO-JSON", tro_path), ("Hardware-JSON", hw_path),
("Regeln", rules_path)):
if not path.is_file():
print(f"FEHLER: {label} nicht gefunden: {path}", file=sys.stderr)
return 1
try:
doc = TroDocument.from_json(tro_path.read_text(encoding="utf-8"))
except ValidationError as exc:
print(f"FEHLER: {tro_path} ist keine gueltige TRO-JSON ({exc.error_count()} "
f"Abweichungen) - mit tro_flow --json neu erzeugen.", file=sys.stderr)
return 1
try:
hw = HardwareDocument.from_json(hw_path.read_text(encoding="utf-8"))
except ValidationError as exc:
print(f"FEHLER: {hw_path} passt nicht zum Schema '{HW_SCHEMA}' "
f"({exc.error_count()} Abweichungen) - mit hw_import neu erzeugen.",
file=sys.stderr)
return 1
try:
rules, anchors = load_rules(rules_path)
overrides = load_overrides(overrides_path, doc.source)
anlage = build_anlage(doc, hw, rules, anchors, cfg_dir, tro_path.name, hw_path.name,
overrides, args.re_register)
except RegistrationError as exc:
print(f"FEHLER: Lagebezug E-Zeichnung -> Layout nicht nachweisbar: {exc}",
file=sys.stderr)
print(f" Passen Layout 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
stem = Path(doc.source).stem or tro_path.stem.removesuffix("_tro")
results.mkdir(parents=True, exist_ok=True)
json_file = results / f"{stem}_anlage.json"
md_file = results / f"{stem}_anlage.md"
json_file.write_text(anlage.to_json(), encoding="utf-8")
md_file.write_text(render_report(anlage, hw), encoding="utf-8")
status = Counter(b.status for b in anlage.bindings)
print("")
print("================================================================")
print("LAYOUT + HARDWAREPLANUNG -> ANLAGE")
print("================================================================")
print(f"TRO-JSON = {tro_path}")
print(f"Hardware-JSON = {hw_path}")
print(f"Lagebezug = {anlage.registration.description if anlage.registration else '-'}")
print(f"Slot-Rollen = {status['zugeordnet']} zugeordnet, {status['offen']} offen, "
f"{status['widerspruch']} Widerspruch")
print(f"Ohne Slot = {status['ohne_slot']} Zuordnungen an Bauteilen ohne TRO-Slot")
conv_ok = [c for c in anlage.conveyors if c.status == "zugeordnet"]
print(f"Foerderer = {len(conv_ok)} von {len(anlage.conveyors)} zugeordnet"
+ (": " + ", ".join(f"{c.eplan_name}={c.layout_name or c.layout_id}"
for c in conv_ok) if conv_ok else ""))
print(f"Ohne Zuordnung = {len(anlage.unassigned)} Feldgeraete")
for w in anlage.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="merge_hw_layout",
description="Fuehrt die TRO-JSON (tro_flow --json) und die Hardware-JSON "
"(hw_import) zusammen: ordnet Sensoren, Ventile und Antriebe den "
"Slots der TROs zu und schreibt <csv>_anlage.json plus einen "
"Bericht <csv>_anlage.md.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n"
" merge_hw_layout.bat --tro mubea_tro.json --hw Mubea_hw.json\n"
" merge_hw_layout.bat --tro mubea_tro.json --hw Mubea_hw.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("--tro", required=True, metavar="NAME",
help="TRO-JSON <csv>_tro.json in %%SKEL_RESULTS%% (oder ein Pfad)")
parser.add_argument("--hw", required=True, metavar="NAME",
help="Hardware-JSON <projekt>_hw.json in %%SKEL_RESULTS%% "
"(oder ein Pfad)")
parser.add_argument("--rules", metavar="DATEI",
help=f"Regeldatei. Standard: %%SKEL_CFG%%\\{RULES_FILE}")
parser.add_argument("--overrides", metavar="DATEI",
help=f"Handkorrekturen. Standard: %%SKEL_CFG%%\\{OVERRIDES_FILE} "
f"(fehlt sie, keine)")
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())