# -*- 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 E-Planung sind alle Platzhalter ``todo=True``. Mit einer Zuordnung aus lib/e_planning.py (``eplan``) werden Separator-Sensor und Stopperventil der zugeordneten Separatoren eingesetzt und ``todo`` geloescht; 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 re from collections import deque from datetime import datetime from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field from model import JamArea, TroBase, TroDocument 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", "build_context", "flow_order", "reachable_via", ] TODO = "TODO(E-Planung)" _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) _SENSOR = "cIn{bmk}" _VENTIL = "{bmk}" 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 = "" # --------------------------------------------------------------------------- # 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 = "") -> SclJam: return SclJam( n=n, jam_id=jam_id, neighbour=neighbour, db=_offen(jam_db), sensor=sensor, carriers=_offen(f"{jam_db}.arCarrier"), data=_offen(f"{jam_db}.stData"), ) def _store(tro: TroBase) -> 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. """ 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:] 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=_offen("cInBG____"), pin_stopper=_offen("MB____"), lines=[ SclStoreLine(n=n, layout=sep, jam_length=_offen('"cStorage____ExitJam"'), sensor=_offen("cInBG____"), jam_sensor=_offen("cInBG____"), stopper=_offen("MB____")) for n, sep in enumerate(linien, start=1) ], ) def _separator(i: int, layout: str, eplan: EplanDocument | None) -> SclSeparator: rollen = eplan.for_layout(layout) if eplan is not None and layout != "?" else {} sensor = rollen.get("sensor") stopper = rollen.get("stopper") return SclSeparator( n=i, layout=layout, no=_offen("cSep_____"), sensor=_bekannt(_SENSOR.format(bmk=sensor.bmk)) if sensor else _offen("cInBG____"), stopper=_bekannt(_VENTIL.format(bmk=stopper.bmk)) if stopper else _offen("MB____"), # Teilesensor kennt die E-Planung-Bruecke noch nicht - offen part_sensor=_offen("cInBG____"), ) def _entries(tro: TroBase, eigene: list[JamArea], alle: dict[str, TroBase], jam_db: str) -> 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})" eingaenge.append(_jam(area.entry, von, jam_db, jam_id=area.jam_id)) return eingaenge def _exit_area(tro_id: str, nach: str, bereiche: dict[str, list[JamArea]]) -> str: """Kennung des Staubereichs, 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].jam_id if passend else "" def _tro_context(tro: TroBase, alle: dict[str, TroBase], eplan: EplanDocument | None = None, bereiche: dict[str, list[JamArea]] | 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 jam_db = '"DB_JamArea____".stJam____' bereiche = bereiche or {} separators = [ _separator(i, tro.separators[i - 1] if i <= len(tro.separators) else "?", eplan) for i in range(1, n_sep + 1) ] switches = [ SclSwitch( n=j, no=_offen("cSwi_____"), valve=_offen("MB____"), # 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: _offen("cInBG____") for name in ("stSenCarrInPos", "stSenFinger", "stSenLastPos", "stSenJam") } if has_vario else {} 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 braucht die Foerderernummer aus der # Hardwareplanung und bleibt offen. jam_entries = _entries(tro, bereiche.get(tro_id, []), alle, jam_db) jam_exits = [ _jam(n, _nachbar(nach, alle), jam_db, sensor=_offen("cInBG____"), jam_id=_exit_area(tro_id, nach, bereiche)) for n, nach in enumerate(tro.successors, start=2) ] 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=_offen('"FC____"'), vario_motor=_offen("MA____"), # 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=_offen("#fbCarousel____.xOutConveyorFullSpeed"), # 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) 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) -> SclFile: """TroDocument -> SclFile (TROs in Fliessrichtung). eplan: Zuordnung Layout -> E-Planung (e_planning.py); ohne sie bleiben alle Namen offen und die Ausgabe ist die bisherige. """ 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) return SclFile( source=source, created=created or datetime.now().strftime("%Y-%m-%d %H:%M"), start_reason=startgrund, tros=[_tro_context(t, alle, eplan, bereiche) for t in geordnet], eplan_source=(f"{eplan.eplan_file} (Zuordnung vom {eplan.created})" if eplan is not None else ""), )