# -*- coding: utf-8 -*- """ scl_context.py - Render-Kontext fuer das FB_Main-Geruest. Schritt 2 aus doc/SCL_Templates.md: alles, was scl_skeleton aus der TRO-JSON *ableitet*, steht hier - Reihenfolge, Bauteilbloecke, Staubereiche, Weichenziele, Namen. Die Ausgabe (heute die emit_*-Funktionen, spaeter die Templates in lib/scl_templates/) liest nur noch diesen Kontext und rechnet nichts mehr selbst. Schichten: model.py TroDocument (die TRO-JSON) scl_context.py TroDocument -> SclFile <- hier scl_skeleton.py SclFile -> SCL-Text Namen (Separator-, Weichen-, MainTro-Nummer, Adressen ...) sind ein :class:`SclName`: Wert plus Merker, ob er noch aus der E-Planung kommen muss. Ohne Hardwareplanung sind alle Platzhalter ``todo=True``. Quelle der echten Namen ist :class:`HwSource`: - die Anlagen-JSON aus merge_hw_layout (``anlage``, Stufe 4 in doc/Konzept_Layout_Hardware_SCL.md): Zuordnungen je TRO-ID + Slot + Rolle (Separator, Lagerlinie, Weiche, Antrieb) und die Foerderernummer je Layout-Foerderer (Kreisel-Freigabe, Staubereichs-DB); - oder, auslaufend, die Zuordnung aus lib/e_planning.py (``eplan``): nur Separator-Sensor und Stopperventil, je Layout-ID. Ein eingesetzter Name loescht ``todo``; alles andere bleibt offen. Die Namensregeln aus Schritt 4 (cfg/scl_naming.ini) arbeiten spaeter genauso: Wert ersetzen, Merker loeschen. """ from __future__ import annotations import configparser import re from collections import deque from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field from model import AnlageDocument, ConveyorBinding, JamArea, TroBase, TroDocument from parameter import get_float from tro_catalog import TroItem if TYPE_CHECKING: from e_planning import EplanDocument __all__ = [ "TODO", "SclName", "SclSeparator", "SclSwitch", "SclSwitchExit", "SclScanner", "SclJam", "SclSscc", "SclWorkStation", "SclStoreLine", "SclStore", "SclTro", "SclFile", "HwSource", "build_context", "flow_order", "JAM_NAMES_SUFFIX", "jam_key", "jam_label", "jam_names", "jam_numbers", "load_jam_overrides", "save_jam_overrides", "reachable_via", ] TODO = "TODO(E-Planung)" # Zahlenwerte aus cfg/parameter.ini [scl_context] - Bedeutung steht dort. STAUBEREICH_KREISEL_MAX_MM = get_float("scl_context", "staubereich_kreisel_max_mm") _CONFIG = ConfigDict(extra="forbid") class SclName(BaseModel): """Ein Name/eine Adresse in der SCL und ob er noch offen ist.""" model_config = _CONFIG value: str # True: Platzhalter, muss aus der E-Planung kommen -> TODO-Kommentar todo: bool = True def _offen(platzhalter: str) -> SclName: return SclName(value=platzhalter, todo=True) def _bekannt(wert: str) -> SclName: return SclName(value=wert, todo=False) # Rolle in e_planning.ini -> wie der Name in der SCL geschrieben wird # (Sensor als Konstante der DB_Inputs-Sensorliste, Ventil als Variable). # Die Anlagen-JSON bringt die Konstante aus dem TIA-Import mit; die Vorlage # gilt nur, wenn sie fehlt (alte eplan-Zuordnung). _SENSOR = "cIn{bmk}" _VENTIL = "{bmk}" # Antriebs-Slot (model._DRIVE_ROLES) -> Parameter des Vario-Aufrufs _VARIO_SENSOR_ROLES = { "stSenCarrInPos": "senCarrInPos", "stSenFinger": "senFinger", "stSenLastPos": "senLastPos", "stSenJam": "senJam", } class HwSource: """Namen aus der Hardwareplanung - eine Abfrage fuer beide Quellen. ``anlage`` (merge_hw_layout): Schluessel TRO-ID + Slot + Rolle, nur Zuordnungen mit Status ``zugeordnet`` zaehlen; ``offen``/``widerspruch`` bleiben ``TODO(E-Planung)``. ``eplan`` (e_planning.py, auslaufend): Schluessel Layout-ID, kennt nur Separator-Sensor und -Stopper. Ohne beide liefert jede Abfrage None. """ def __init__(self, anlage: AnlageDocument | None = None, eplan: EplanDocument | None = None) -> None: self.anlage = anlage self.eplan = eplan self._kinds = ({p.layout_id: p.kind for p in anlage.layout.parts} if anlage is not None else {}) def device(self, tro_id: str, slot: str, role: str, layout: str = "") -> tuple[str, str] | None: """(BMK, Konstante) einer Slot-Rolle; Konstante "" wenn unbekannt.""" if self.anlage is not None: b = self.anlage.binding(tro_id, slot, role) if b is None or b.status != "zugeordnet" or not b.bmk: return None return b.bmk, b.constant if self.eplan is not None and layout and layout != "?": a = self.eplan.for_layout(layout).get(role) return (a.bmk, "") if a is not None else None return None def sensor(self, tro_id: str, slot: str, role: str, layout: str = "", offen: str = "cInBG____") -> SclName: """Sensor als Konstante der DB_Inputs-Sensorliste (cInBG0532).""" geraet = self.device(tro_id, slot, role, layout) if geraet is None: return _offen(offen) bmk, konstante = geraet return _bekannt(konstante or _SENSOR.format(bmk=bmk)) def output(self, tro_id: str, slot: str, role: str, layout: str = "", offen: str = "MB____", fmt: str = _VENTIL) -> SclName: """Ausgang/Variable ueber den BMK (MB0533, "FC0202" ...).""" geraet = self.device(tro_id, slot, role, layout) return _bekannt(fmt.format(bmk=geraet[0])) if geraet else _offen(offen) def conflict(self, tro_id: str, slot: str, role: str) -> bool: """True, wenn die Anlagen-JSON fuer die Slot-Rolle einen Widerspruch fuehrt.""" if self.anlage is None: return False b = self.anlage.binding(tro_id, slot, role) return b is not None and b.status == "widerspruch" def conveyor(self, host: str) -> ConveyorBinding | None: """Foerderer der Hardwareplanung (K0102) am Layout-Foerderer ("0010-R").""" if self.anlage is None or not host: return None return self.anlage.conveyor_for(host) def is_carousel(self, host: str) -> bool: return self._kinds.get(host.split("-", 1)[0]) == "Kreisel" @property def source(self) -> str: """Fuer den Dateikopf; "" ohne Hardwareplanung.""" if self.anlage is not None: return (f"{self.anlage.hw_file or '-'} + {self.anlage.layout_file or '-'} " f"(Anlagen-JSON vom {self.anlage.created})") if self.eplan is not None: return f"{self.eplan.eplan_file} (Zuordnung vom {self.eplan.created})" return "" def _foerderer_nr(conveyor: ConveyorBinding) -> str: """K0102 / #106 -> "0102" bzw. "0106" (Schreibweise der Referenz-DBs).""" ziffern = re.sub(r"\D", "", conveyor.eplan_name) return ziffern.zfill(4) if ziffern else "" class SclSeparator(BaseModel): """Ein stInSeparator-Block.""" model_config = _CONFIG n: int # Separatornummer aus dem Layout ("?" wenn der Typ mehr Separatoren hat # als im Layout gefunden wurden) layout: str no: SclName sensor: SclName stopper: SclName # Teilesensor (stSenPart) - nur in Templates, deren Referenz ihn hat (1Sep2Swi) part_sensor: SclName class SclSwitchExit(BaseModel): """Ein Abgang eines Weichen-TRO und die darueber erreichbaren TROs.""" model_config = _CONFIG via: str targets: list[str] class SclSwitch(BaseModel): """Ein stInSwitch-Block plus sein FC_Direction-Aufruf.""" model_config = _CONFIG n: int no: SclName valve: SclName # None: mehrere Weichen teilen sich die Abgaenge - Aufteilung ist eine # Planungsentscheidung und wird nicht geraten to1_destinations: int | None # Ziel von nOutDirection, z. B. "#TRO07.nInDirection" bzw. "...nInDirection2" direction_target: str # Staubereich, dessen erster Traeger in FC_Direction ausgewertet wird jam_db: SclName class SclScanner(BaseModel): """Ein stInBarcodeReader-Block ("" als Suffix, wenn es nur einen gibt). Die Cognex-Verdrahtung (nur in den Templates): ``interface`` ist der Schluessel in #arCognexInterface (z. B. "cScannerBX2100"), ``tag`` der Praefix der Hardware-Symbole ("bx2100-TriggerReady" ...), ``hw_results`` das Ergebnis-Symbol der Kamera. """ model_config = _CONFIG n: int suffix: str interface: SclName tag: SclName hw_results: SclName class SclJam(BaseModel): """Ein Staubereich am Ein- oder Ausgang (JamEntr / JamExit). Ein Eingang ist ein Staubereich des TRO selbst (TroDocument.jamAreas), ein Ausgang der Eingangs-Staubereich des Nachfolgers. """ model_config = _CONFIG n: int # Kennung aus der TRO-JSON (J); "" am Ausgang zu einer Station jam_id: str = "" # Eingang: die Vorgaenger, Ausgang: der Nachfolger - als Text fuer den # Kommentar, SYSn als Station markiert neighbour: str db: SclName # Stausensor des Ausgangs (stInSenJam); None an Eingaengen sensor: SclName | None = None # Traeger-Array und Staudaten des Bereichs - im Normalfall .arCarrier # bzw. .stData; an Uebergaben auch eigene Variablen (#arCarrierHandover...) carriers: SclName data: SclName @property def title(self) -> str: """Fuer den Kommentar: 'Staubereich J0003' (ohne Kennung nur 'Staubereich').""" return f"Staubereich {self.jam_id}" if self.jam_id else "Staubereich" class SclSscc(BaseModel): """SSCC-Scanner eines 1Sep_SSCC: zweite Kamera, Endmessung, HMI-Anzeige.""" model_config = _CONFIG # interface ist hier die Struktur selbst (z. B. "#stCognexIntfTro105") scanner: SclScanner end_sensor: SclName # Schluessel der HMI-Anzeige, z. B. "SSCC1107" hmi: SclName class SclWorkStation(BaseModel): """Arbeitsplatz eines Vario_workStation.""" model_config = _CONFIG station_sensor: SclName station_release: SclName loaded_error_sensor: SclName class SclStoreLine(BaseModel): """Eine Speicherlinie eines PinStore_Auto (arInSep[n]).""" model_config = _CONFIG n: int # Separatornummer der Linie aus dem Layout layout: str jam_length: SclName sensor: SclName jam_sensor: SclName stopper: SclName class SclStore(BaseModel): """Alles fuer ein automatisches Pin-Lager (PinStore_Auto).""" model_config = _CONFIG encoder: SclName dest: SclName conv_stations: SclName stations_max: SclName threshold: SclName reference_sensor: SclName station: SclName quit_local: SclName test_target: SclName run_in: SclName run_out: SclName db: SclName removal: SclName conv_db: SclName # Einschleus-Separator (Pin) - aus dem Layout, wenn tro_flow einen # steuernden Separator vor den Linien gefunden hat, sonst "?" pin_layout: str pin_sensor: SclName pin_stopper: SclName # Eine Linie je Speicherbahn im Layout (nicht die 19 aus dem Katalog) lines: list[SclStoreLine] @property def title(self) -> str: """Fuer den Kommentar: 'Staubereich J0003' (ohne Kennung nur 'Staubereich').""" return f"Staubereich {self.jam_id}" if self.jam_id else "Staubereich" class SclTro(BaseModel): """Alles, was eine REGION fuer einen TRO braucht.""" model_config = _CONFIG id: str # Nummernteil der ID, wie geschrieben ("TRO01" -> "01"); "" ohne Ziffern nr: str type: str fb: str region_title: str layout_separators: list[str] position: tuple[float, float] | None predecessors: list[str] successors: list[str] confidence: str separators: list[SclSeparator] switches: list[SclSwitch] switch_exits: list[SclSwitchExit] has_vario: bool # Vario-Sensoren in Aufrufreihenfolge (Parametername -> Sensor); leer ohne Vario vario_sensors: dict[str, SclName] scanners: list[SclScanner] storage_lines: int jam_entries: list[SclJam] jam_exits: list[SclJam] main_tro: SclName safety: SclName release: str motor_protection: SclName vario_motor: SclName machine_state: SclName # Nur in den Templates (Referenz-Aufrufstellen), nicht im generischen Aufbau: # UDT des Prioritaetsmanagers ("udt_TRO107") und ob Richtung 2 Vorrang hat priority_udt: SclName prio_dir2: SclName # Kreisel-Freigabe (xInCarouselRun1). Ob ein TRO am Kreisel liegt, steht # nicht in der TRO-JSON - deshalb immer offen (doc/SCL_Templates.md §6). carousel_run: SclName # -- nur fuer die Templates (Referenz-Aufrufstellen, doc/SCL_Templates.md §6) # Einziger Staubereich am Ein-/Ausgang fuer FBs ohne Nummerierung # (Vario: JamEntr/JamFinger, PinStore: JamEntr/JamExit): der erste # Vorgaenger bzw. Nachfolger, sonst ein offener Platzhalter jam_in: SclJam jam_out: SclJam error_quit: SclName automatic: SclName # Vario: stInSettings.xIsBorner (Bedeutung mit SPS klaeren) is_border: SclName # Station fuer FC_CARR_GLOB_CORRECTION (1Sep_SSCC, PinStore_Auto) correction_station: SclName sscc: SclSscc | None = None work_station: SclWorkStation | None = None store: SclStore | None = None @property def has_scanner(self) -> bool: return bool(self.scanners) class SclFile(BaseModel): """Kontext der ganzen Datei: Kopf und die TROs in Fliessrichtung.""" model_config = _CONFIG source: str created: str start_reason: str tros: list[SclTro] = Field(default_factory=list) # Zuordnung zur E-Planung, aus der Namen eingesetzt wurden ("" = keine) eplan_source: str = "" # Anlagen-JSON (merge_hw_layout), aus der Namen eingesetzt wurden ("" = keine) hw_source: str = "" # --------------------------------------------------------------------------- # Graph # --------------------------------------------------------------------------- def flow_order(tros: list[TroBase], start_id: str = "") -> tuple[list[TroBase], str]: """TROs in Fliessrichtung sortieren. Startpunkt ist ein TRO ohne Vorgaenger - also die Stelle, an der Material in die Anlage kommt. Solange Be-/Entladestation fehlen, ist der Fluss ein geschlossener Kreis und es gibt keinen solchen TRO; dann muss der Kreis irgendwo aufgetrennt werden. Das ist eine willkuerliche Wahl, deshalb wird sie zurueckgegeben und im Kopf der SCL vermerkt (und laesst sich mit --start ueberschreiben). Nicht erreichte TROs haengt die Funktion hinten an, damit nie einer verlorengeht. """ nach_id = {t.tro_id: t for t in tros} ohne_vorgaenger = sorted(t.tro_id for t in tros if not t.predecessors) # Materialeingang ueber eine Station (SYSn von tro_flow.py): der TRO, den # sie speist, ist der natuerliche Anfang - auch wenn er zusaetzlich einen # TRO-Vorgaenger hat (Kreisel-Umlauf). eingaenge = sorted({ t.tro_id for t in tros for vor in t.predecessors if vor not in nach_id }) if start_id and start_id in nach_id: start, grund = [start_id], "per --start gesetzt" elif ohne_vorgaenger: start, grund = ohne_vorgaenger, "TRO ohne Vorgaenger (Materialeingang)" elif eingaenge: stationen = sorted({ vor for i in eingaenge for vor in nach_id[i].predecessors if vor not in nach_id }) start = eingaenge grund = f"Materialeingang ueber {', '.join(stationen)}" else: start = [min(nach_id)] grund = ("Kreislauf ohne Eingang - willkuerlich aufgetrennt; sobald " "Be-/Entladestation im Export stehen, ergibt sich der Anfang") gesehen: list[str] = [] queue = deque(start) while queue: tro_id = queue.popleft() if tro_id in gesehen: continue gesehen.append(tro_id) for nxt in nach_id[tro_id].successors if tro_id in nach_id else []: # SYSn (Materialausgang) ist kein TRO - keine REGION, keine Instanz. if nxt in nach_id and nxt not in gesehen: queue.append(nxt) for tro_id in sorted(nach_id): if tro_id not in gesehen: gesehen.append(tro_id) return [nach_id[i] for i in gesehen], grund def reachable_via(start: str, first: str, tros: dict[str, TroBase]) -> list[str]: """Welche TROs sind ueber diesen Abgang erreichbar? Das ist die Zielliste eines Weichenabgangs: von `first` aus weiterlaufen, ohne ueber die Weiche selbst zurueckzugehen. Genau die Information, die FC_Direction braucht, um zu entscheiden - und sie steht im Graphen. """ gesehen: set[str] = set() queue = deque([first]) while queue: node = queue.popleft() if node in gesehen or node == start: continue gesehen.add(node) if node in tros: queue.extend(tros[node].successors) return sorted(gesehen) def _nachbar(node: str, alle: dict[str, TroBase]) -> str: """Nachbar fuer Kommentare; SYSn als Station kenntlich machen.""" return node if node in alle else f"{node} (Station, kein TRO)" def _region_title(tro: TroBase) -> str: seps = ", ".join(tro.separators) teile = f" Sep {seps}" if seps else "" return f"{tro.tro_id} ({tro.type_name}){teile}" def _nummer(tro_id: str) -> str: treffer = re.search(r"(\d+)$", tro_id) return treffer.group(1) if treffer else "" # --------------------------------------------------------------------------- # Kontext bauen # --------------------------------------------------------------------------- def _scanner(n: int, suffix: str, interface: str = "cScanner____") -> SclScanner: return SclScanner( n=n, suffix=suffix, interface=_offen(interface), tag=_offen("bx____"), hw_results=_offen("BX____~Ergebnisse_\u2013_64_Bytes_1"), ) def _jam(n: int, neighbour: str, jam_db: str, sensor: SclName | None = None, jam_id: str = "", known: bool = False) -> SclJam: name = _bekannt if known else _offen return SclJam( n=n, jam_id=jam_id, neighbour=neighbour, db=name(jam_db), sensor=sensor, carriers=name(f"{jam_db}.arCarrier"), data=name(f"{jam_db}.stData"), ) def _store(tro: TroBase, hw: HwSource) -> SclStore: """PinStore_Auto: eine Linie je Speicherbahn (Host) des Layouts. tro_flow legt die Separatoren als [steuernde ..., Linien ...] ab; die Linien sind die letzten len(hosts). Fehlen Hosts (fremde JSON), zaehlt jeder Separator als Linie. Sensor und Stopper jeder Linie (Slot ``line``) und des Pin-Separators (Slot ``separator1``, falls tro_flow einen steuernden Separator gefunden hat) kommen aus der Hardwareplanung wie bei den Separatorbloecken. """ hosts = list(getattr(tro, "hosts", []) or []) seps = list(tro.separators) anzahl = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps) steuer, linien = seps[:len(seps) - anzahl], seps[len(seps) - anzahl:] if steuer: pin_sensor, pin_stopper = _sep_namen(tro.tro_id, "separator1", steuer[0], hw) else: pin_sensor, pin_stopper = _offen("cInBG____"), _offen("MB____") zeilen = [] for n, sep in enumerate(linien, start=1): slot = f"line{n}" sensor, stopper = _sep_namen(tro.tro_id, slot, sep, hw) zeilen.append(SclStoreLine(n=n, layout=sep, jam_length=_offen('"cStorage____ExitJam"'), sensor=sensor, jam_sensor=hw.sensor(tro.tro_id, slot, "jamSensor"), stopper=stopper)) return SclStore( encoder=_offen("#fbEncoder____"), dest=_offen("cDest____"), conv_stations=_offen("cHooksSorterMaxPin____"), stations_max=_offen("cStationsMax____"), threshold=_offen("cNoPositionsBlock____"), reference_sensor=_offen("cInBG____"), station=_offen("____"), quit_local=_offen('"SF____"'), test_target=_offen('"IBN".nTestTarget____'), run_in=_offen("#fbSortConv____.xOutConveyorFullSpeed"), run_out=_offen("#fbExitConv____.xOutConveyorFullSpeed"), db=_offen('"DB_Storage____"'), removal=_offen('"DB_HandshakeStorage".Str_StorageRemoval.' 'arrudt_StorageRemovalHandshakeTROsLvl1[____]'), conv_db=_offen('"DB_ConveyorStations".stConveyorStations____'), pin_layout=", ".join(steuer) or "?", pin_sensor=pin_sensor, pin_stopper=pin_stopper, lines=zeilen, ) def _sep_namen(tro_id: str, slot: str, layout: str, hw: HwSource) -> tuple[SclName, SclName]: """Sensor und Stopperventil eines Separators aus der Hardwareplanung. Gilt fuer die Separatorbloecke (stInSeparator, Slot ``separator``) und genauso fuer die Linien eines PinStore_Auto (Slot ``line``) - beide sind Separatoren im Layout. Ohne Zuordnung bleiben beide offen. """ return (hw.sensor(tro_id, slot, "sensor", layout), hw.output(tro_id, slot, "stopper", layout)) def _separator(i: int, tro_id: str, layout: str, hw: HwSource) -> SclSeparator: slot = f"separator{i}" sensor, stopper = _sep_namen(tro_id, slot, layout, hw) return SclSeparator( n=i, layout=layout, no=_offen("cSep_____"), sensor=sensor, stopper=stopper, # Teilesensor: nur die Anlagen-JSON kennt ihn (Rolle partSensor) part_sensor=hw.sensor(tro_id, slot, "partSensor"), ) _JAM_DB = '"DB_JamArea____".stJam____' def _jam_db(area: JamArea | None, hw: HwSource, namen: dict[str, str] | None = None) -> tuple[str, bool]: """Name des Staubereichs-DB und ob er feststeht. Schema der Referenzen: "DB_JamArea".stJam_ (jam_names). Steht der Name nicht fest, bleibt ein Platzhalter - mit Foerderernummer, soweit der Layout-Foerderer sie kennt. """ if area is not None and namen and area.jam_id in namen: return namen[area.jam_id], True conveyor = hw.conveyor(area.host) if area is not None else None nr = _foerderer_nr(conveyor) if conveyor is not None else "" return (f'"DB_JamArea{nr}".stJam{nr}_____' if nr else _JAM_DB), False JAM_NAMES_SUFFIX = "_jam_names.ini" # Wert in der Korrekturdatei: Foerderer und laufende Nummer, "0054_3" _JAM_WERT = re.compile(r"^(\d{4})_(\d+)$") def jam_key(area: JamArea) -> str: """Stabiler Schluessel eines Staubereichs: TRO-ID + Eingang ("TRO17.1"). Die Kennung J folgt der TeileId und aendert sich mit jedem CSV-Export; die TRO-ID bleibt (cfg/_tro_ids.ini). """ return f"{area.tro}.{area.entry}" def jam_label(nr: str, k: int) -> str: """Kurzname fuer Zeichnung und Dialog: Jam0054_3.""" return f"Jam{nr}_{k}" def load_jam_overrides(path: Path) -> dict[str, tuple[str, int]]: """Korrekturen der Staubereichs-Nummern aus ``_jam_names.ini``. Abschnitt [jam], je Zeile ``. = _`` (``TRO17.1 = 0054_3``). Geschrieben von tro_annotate --read-jams aus den im Dialog JAM_EDIT geaenderten Staubereichen; von Hand pflegbar. Fehlt die Datei, gibt es keine Korrekturen. """ if not path.is_file(): return {} parser = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), interpolation=None) parser.optionxform = str with path.open(encoding="utf-8") as fh: parser.read_file(fh) result: dict[str, tuple[str, int]] = {} if parser.has_section("jam"): for key, value in parser["jam"].items(): m = _JAM_WERT.match(value.strip()) if not m: raise ValueError(f"{path.name} [jam] {key} = {value!r}: erwartet " f"_, z. B. 0054_3") result[key.strip()] = (m.group(1), int(m.group(2))) return result def save_jam_overrides(path: Path, overrides: dict[str, tuple[str, int]]) -> None: """Korrekturdatei neu schreiben (sortiert nach TRO-ID); leer = Datei weg.""" if not overrides: if path.is_file(): path.unlink() return zeilen = [ "; Staubereichs-Nummern, die von der Ableitung abweichen", "; (scl_context.jam_names). Geschrieben von tro_annotate --read-jams aus", "; dem Dialog JAM_EDIT; von Hand pflegbar.", "; . = _", "", "[jam]", ] zeilen += [f"{key} = {nr}_{k}" for key, (nr, k) in sorted(overrides.items())] path.write_text("\n".join(zeilen) + "\n", encoding="utf-8") def jam_names(document: TroDocument, geordnet: list[TroBase], hw: HwSource, overrides: dict[str, tuple[str, int]] | None = None) -> dict[str, str]: """Staubereichs-Namen je Kennung (J0048 -> '"DB_JamArea0042".stJam0042_1'). Die abgeleiteten Nummern (jam_numbers), ersetzt durch ``overrides`` (load_jam_overrides) fuer die dort genannten Staubereiche. """ nummern = jam_numbers(document, geordnet, hw) for area in document.jam_areas_or_derived(): if overrides and jam_key(area) in overrides: nummern[area.jam_id] = overrides[jam_key(area)] return {jam_id: f'"DB_JamArea{nr}".stJam{nr}_{k}' for jam_id, (nr, k) in nummern.items()} def jam_numbers(document: TroDocument, geordnet: list[TroBase], hw: HwSource) -> dict[str, tuple[str, int]]: """Abgeleitete Staubereichs-Nummern je Kennung (J0048 -> ("0042", 1)). Regel der SPS-Programmierer (Dematic, 2026-10-22): ist der Foerderer, auf dem der Staubereich liegt (Nummer des Kreisel- bzw. Vario-Motors), zaehlt auf diesem Foerderer in Fliessrichtung hoch. = 1 ist der Staubereich, den der Fluss (flow_order) auf diesem Foerderer zuerst erreicht - auf einem Kreisel die Stelle, an der das Material auf den Kreisel kommt; von dort geht es die Nachfolger entlang (_entlang), nicht nach REGION-Reihenfolge: die ist Breitensuche ueber die ganze Anlage und erreicht einen Kreisel-TRO oft zuerst ueber einen anderen Foerderer (Dematic Kreisel3: TRO17 -> TRO14 -> TRO23 -> TRO20). Foerderer eines Staubereichs, in dieser Reihenfolge: 1. der TRO hat einen eigenen Antrieb (Vario) -> dessen Foerderer; 2. der Separator liegt auf einem Kreisel (hoechstens STAUBEREICH_KREISEL_MAX_MM von dessen Huellbox) -> der Kreisel; 3. der Host ist ein Kreisel -> der Kreisel; 4. der Host ist eine Strecke mit Antriebs-TROs -> der Antrieb des letzten Antriebs-TRO davor auf dieser Strecke (ein 1Sep hinter einem Vario gehoert zu dessen Foerderer; VF_6 traegt zwei Varios). Sonst bleibt der Staubereich offen und zaehlt nicht mit. """ anlage = hw.anlage if anlage is None: return {} rang = {t.tro_id: i for i, t in enumerate(geordnet)} nr_of_motor = {c.motor: _foerderer_nr(c) for c in anlage.conveyors if c.motor} def antrieb(tro_id: str) -> str: b = anlage.binding(tro_id, "drive", "motor") if b is None or b.status != "zugeordnet" or not b.bmk: return "" return nr_of_motor.get(b.bmk, "") antriebe: dict[str, list[tuple[int, str]]] = {} for t in geordnet: nr = antrieb(t.tro_id) hosts = list(getattr(t, "hosts", []) or []) if nr and hosts: antriebe.setdefault(hosts[0].split("-", 1)[0], []).append((rang[t.tro_id], nr)) kreisel = [(p, anlage.conveyor_for(p.layout_id)) for p in document.parts if p.kind == "Kreisel"] kreisel = [(p, c) for p, c in kreisel if c is not None] def auf_kreisel(separator: str) -> str: part = document.part(separator) if separator else None if part is None or part.position is None: return "" nah = [(d, c) for p, c in kreisel if (d := p.distance_to_box(part.position[0], part.position[1])) is not None and d <= STAUBEREICH_KREISEL_MAX_MM] return _foerderer_nr(min(nah, key=lambda x: x[0])[1]) if nah else "" def foerderer(area: JamArea) -> str: nr = antrieb(area.tro) or auf_kreisel(area.separator) if not nr and hw.is_carousel(area.host): conv = hw.conveyor(area.host) nr = _foerderer_nr(conv) if conv is not None else "" if not nr: davor = [n for r, n in antriebe.get(area.host.split("-", 1)[0], []) if r < rang.get(area.tro, -1)] nr = davor[-1] if davor else "" return nr areas = sorted(document.jam_areas_or_derived(), key=lambda a: (rang.get(a.tro, len(rang)), a.entry)) je_foerderer: dict[str, list[JamArea]] = {} for area in areas: nr = foerderer(area) if nr: je_foerderer.setdefault(nr, []).append(area) nachfolger = {t.tro_id: list(t.successors) for t in geordnet} nummern: dict[str, tuple[str, int]] = {} for nr, liste in je_foerderer.items(): for k, area in enumerate(_entlang(liste, nachfolger), start=1): nummern[area.jam_id] = (nr, k) return nummern def _entlang(areas: list[JamArea], nachfolger: dict[str, list[str]]) -> list[JamArea]: """Staubereiche eines Foerderers in Fliessrichtung. ``areas`` kommt nach flow_order sortiert; der erste ist der Anfang. Danach jeweils der naechste Eingang desselben TRO, sonst ein Staubereich eines Nachfolger-TRO auf diesem Foerderer, sonst (Kette unterbrochen) der naechste nach flow_order. """ rest = list(areas) folge = [rest.pop(0)] while rest: jetzt = folge[-1].tro weiter = ([a for a in rest if a.tro == jetzt] or [a for a in rest if a.tro in nachfolger.get(jetzt, [])] or rest) folge.append(weiter[0]) rest.remove(weiter[0]) return folge def _entries(tro: TroBase, eigene: list[JamArea], alle: dict[str, TroBase], hw: HwSource, namen: dict[str, str] | None = None) -> list[SclJam]: """Ein JamEntr je eigenem Staubereich - nicht je Vorgaenger: mehrere Vorgaenger fahren in denselben Staubereich ein (Referenzen UH01-UH05).""" eingaenge = [] for area in eigene: von = ", ".join(_nachbar(v, alle) for v in area.predecessors) or "-" if area.note: von = f"{von} ({area.note})" db, known = _jam_db(area, hw, namen) eingaenge.append(_jam(area.entry, von, db, jam_id=area.jam_id, known=known)) return eingaenge def _exit_area(tro_id: str, nach: str, bereiche: dict[str, list[JamArea]]) -> JamArea | None: """Staubereich, in den tro_id beim Nachfolger einfaehrt.""" ziel = bereiche.get(nach, []) passend = [a for a in ziel if tro_id in a.predecessors] or ziel return passend[0] if passend else None def _drive(tro: TroBase, hw: HwSource) -> tuple[SclName, SclName]: """Motor und Motorschutz eines Vario-Antriebs. Erst der Antriebs-Slot (Rollen motor/motorProtection); fehlt er, der Foerderer der Hardwareplanung am Host des TRO - der Vario-Antrieb ist dessen Motor (MA/FC-Paar aus hw_import). Ein Widerspruch im Slot (z. B. zwei Vario-TROs auf einem Foerderer) bleibt offen, statt den Motor des Foerderers zu erben. """ motor = hw.output(tro.tro_id, "drive", "motor", offen="MA____") schutz = hw.output(tro.tro_id, "drive", "motorProtection", offen='"FC____"', fmt='"{bmk}"') hosts = list(getattr(tro, "hosts", []) or []) conveyor = hw.conveyor(hosts[0]) if hosts else None if conveyor is not None: if (motor.todo and conveyor.motor and not hw.conflict(tro.tro_id, "drive", "motor")): motor = _bekannt(conveyor.motor) if (schutz.todo and conveyor.protection and not hw.conflict(tro.tro_id, "drive", "motorProtection")): schutz = _bekannt(f'"{conveyor.protection}"') return motor, schutz def _carousel_run(tro: TroBase, hw: HwSource) -> SclName: """xInCarouselRun1: Freigabe des Kreisels, auf dem der TRO sitzt. Bekannt nur, wenn der Host ein Kreisel ist und die Anlagen-JSON dessen Foerderernummer kennt (Referenz: #fbCarousel0102.xOutConveyorFullSpeed). """ hosts = list(getattr(tro, "hosts", []) or []) host = hosts[0] if hosts else "" conveyor = hw.conveyor(host) if hw.is_carousel(host) else None nr = _foerderer_nr(conveyor) if conveyor is not None else "" if not nr: return _offen("#fbCarousel____.xOutConveyorFullSpeed") return _bekannt(f"#fbCarousel{nr}.xOutConveyorFullSpeed") def _tro_context(tro: TroBase, alle: dict[str, TroBase], hw: HwSource | None = None, bereiche: dict[str, list[JamArea]] | None = None, namen: dict[str, str] | None = None) -> SclTro: items = tro.items n_sep = items.get(TroItem.SEPARATOR, 1) n_swi = items.get(TroItem.SWITCH, 0) n_scan = items.get(TroItem.SCANNER, 0) tro_id = tro.tro_id hw = hw or HwSource() bereiche = bereiche or {} separators = [ _separator(i, tro_id, tro.separators[i - 1] if i <= len(tro.separators) else "?", hw) for i in range(1, n_sep + 1) ] switches = [ SclSwitch( n=j, no=_offen("cSwi_____"), valve=hw.output(tro_id, f"switch{j}", "valve"), # Eine Weiche bedient alle Abgaenge - direkt ableitbar. to1_destinations=len(tro.successors) if n_swi == 1 else None, direction_target=(f"#{tro_id}.nInDirection" if n_swi == 1 else f"#{tro_id}.nInDirection{j}"), jam_db=_offen(_JAM_DB), ) for j in range(1, n_swi + 1) ] has_vario = bool(items.get(TroItem.VARIO_DRIVE)) vario_sensors = { name: hw.sensor(tro_id, "drive", rolle) for name, rolle in _VARIO_SENSOR_ROLES.items() } if has_vario else {} # Nur ein Vario hat einen eigenen Antrieb; sonst bleiben die (ungenutzten) # Platzhalter offen, statt den Motor des Kreisels zu tragen. vario_motor, motor_protection = _drive(tro, hw) if has_vario else ( _offen("MA____"), _offen('"FC____"')) switch_exits = [ SclSwitchExit(via=abgang, targets=reachable_via(tro_id, abgang, alle)) for abgang in tro.successors ] if n_swi else [] # Staubereiche aus der TRO-JSON (jamAreas): Eingaenge sind die eigenen # (ein JamEntr je eigenem Staubereich, nicht je Vorgaenger), Ausgaenge die # Eingaenge der Nachfolger. Der DB-Name (jam_names) braucht Foerderer- # nummer und laufende Nummer; ohne beide bleibt er offen. jam_entries = _entries(tro, bereiche.get(tro_id, []), alle, hw, namen) jam_exits = [] for n, nach in enumerate(tro.successors, start=2): area = _exit_area(tro_id, nach, bereiche) db, known = _jam_db(area, hw, namen) jam_exits.append(_jam(n, _nachbar(nach, alle), db, sensor=_offen("cInBG____"), jam_id=area.jam_id if area is not None else "", known=known)) return SclTro( id=tro_id, nr=_nummer(tro_id), type=tro.type_name, fb=tro.fb_block or "FB_UNBEKANNT", region_title=_region_title(tro), layout_separators=list(tro.separators), position=(tro.x, tro.y) if tro.position else None, predecessors=list(tro.predecessors), successors=list(tro.successors), confidence=tro.confidence, separators=separators, switches=switches, switch_exits=switch_exits, has_vario=has_vario, vario_sensors=vario_sensors, scanners=[ _scanner(i, "" if n_scan == 1 else str(i)) for i in range(1, n_scan + 1) ], storage_lines=items.get(TroItem.STORAGE_LINE, 0), jam_entries=jam_entries, jam_exits=jam_exits, main_tro=_offen("cMainTro____"), safety=_offen('"DB_InterfaceSafety".stFromSafety.EStopMain.Q'), release=f'"DB_TRO_TEST".{tro_id}', motor_protection=motor_protection, vario_motor=vario_motor, # Schaltschrank ist anlagenspezifisch ("...OF1" in der Referenz) machine_state=_offen("#stInOutControlUnitCabinet"), priority_udt=_offen("udt_____"), # Vorschlag aus der Referenz TRO107, Prioritaet bleibt Planungsentscheidung prio_dir2=_offen("TRUE"), carousel_run=_carousel_run(tro, hw), # dasselbe Objekt wie der erste Ein-/Ausgang: ein Name, den die # Namensregeln dort setzen, gilt damit auch hier jam_in=jam_entries[0] if jam_entries else _jam(1, "-", _JAM_DB), jam_out=jam_exits[0] if jam_exits else _jam(2, "-", _JAM_DB), error_quit=_offen('"SF____"'), automatic=_offen("#t_automatik"), is_border=_offen("TRUE"), correction_station=_offen("0"), sscc=(SclSscc(scanner=_scanner(1, "SSCC", "#stCognexIntf____"), end_sensor=_offen("cInBG____"), hmi=_offen("SSCC____")) if items.get(TroItem.SSCC) else None), work_station=(SclWorkStation(station_sensor=_offen("cInBG____"), station_release=_offen('#"rTrig_Workstation ____".CLK'), loaded_error_sensor=_offen("cInBG____")) if items.get(TroItem.WORK_STATION) else None), store=_store(tro, hw) if items.get(TroItem.STORAGE_LINE) else None, ) def build_context(document: TroDocument, source: str, start_id: str = "", created: str | None = None, eplan: EplanDocument | None = None, anlage: AnlageDocument | None = None, jam_overrides: dict[str, tuple[str, int]] | None = None) -> SclFile: """TroDocument -> SclFile (TROs in Fliessrichtung). anlage: Anlagen-JSON (merge_hw_layout) - ihr Layout ist ``document``, ihre Zuordnungen liefern die Namen. eplan: alte Zuordnung Layout -> E-Planung (e_planning.py), nur ohne ``anlage``. Ohne beide bleiben alle Namen offen und die Ausgabe ist die bisherige. jam_overrides: Korrekturen der Staubereichs-Nummern (load_jam_overrides). """ if anlage is not None and eplan is not None: raise ValueError("anlage und eplan schliessen sich aus") hw = HwSource(anlage=anlage, eplan=eplan) alle = {t.tro_id: t for t in document.tros} bereiche: dict[str, list[JamArea]] = {} for area in document.jam_areas_or_derived(): bereiche.setdefault(area.tro, []).append(area) geordnet, startgrund = flow_order(document.tros, start_id) namen = jam_names(document, geordnet, hw, jam_overrides) return SclFile( source=source, created=created or datetime.now().strftime("%Y-%m-%d %H:%M"), start_reason=startgrund, tros=[_tro_context(t, alle, hw, bereiche, namen) for t in geordnet], eplan_source=hw.source if eplan is not None else "", hw_source=hw.source if anlage is not None else "", )