# -*- 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 -> _anlage.json. Stufe 3 des Ablaufs in doc/Konzept_Layout_Hardware_SCL.md: _tro.json (tro_flow --json) + _hw.json (hw_import) \\ / merge_hw_layout -> _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 ".").""" 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 "." 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 ``[.bindings]`` und ``[.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"..") 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 _anlage.json plus einen " "Bericht _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 _tro.json in %%SKEL_RESULTS%% (oder ein Pfad)") parser.add_argument("--hw", required=True, metavar="NAME", help="Hardware-JSON _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())