# -*- 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. Heute sind alle Platzhalter mit ``todo=True``; die Namensregeln aus Schritt 4 (cfg/scl_naming.ini) ersetzen dann nur den Wert und loeschen den Merker. """ from __future__ import annotations import re from collections import deque from datetime import datetime from pydantic import BaseModel, ConfigDict, Field from model import TroBase, TroDocument from tro_catalog import TroItem __all__ = [ "TODO", "SclName", "SclSeparator", "SclSwitch", "SclSwitchExit", "SclScanner", "SclJam", "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) 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 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).""" model_config = _CONFIG n: int suffix: str class SclJam(BaseModel): """Ein Staubereich am Ein- oder Ausgang (JamEntr / JamExit).""" model_config = _CONFIG n: int # Nachbar als Text fuer den Kommentar, SYSn als Station markiert neighbour: str db: SclName # Stausensor des Ausgangs (stInSenJam); None an Eingaengen sensor: SclName | None = None 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 @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) # --------------------------------------------------------------------------- # 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 _tro_context(tro: TroBase, alle: dict[str, TroBase]) -> 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____' separators = [ SclSeparator( n=i, layout=tro.separators[i - 1] if i <= len(tro.separators) else "?", no=_offen("cSep_____"), sensor=_offen("cInBG____"), stopper=_offen("MB____"), ) 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 [] 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=[ SclScanner(n=i, suffix="" if n_scan == 1 else str(i)) for i in range(1, n_scan + 1) ], storage_lines=items.get(TroItem.STORAGE_LINE, 0), # Staubereiche: je ankommender/abgehender Verbindung einer. Welche # DB-Struktur dahintersteht, ist eine Planungsentscheidung - hier steht # nur, WIE VIELE es sein muessen und zu welchem Nachbarn sie gehoeren. jam_entries=[ SclJam(n=n, neighbour=_nachbar(vor, alle), db=_offen(jam_db)) for n, vor in enumerate(tro.predecessors, start=1) ], jam_exits=[ SclJam(n=n, neighbour=_nachbar(nach, alle), db=_offen(jam_db), sensor=_offen("cInBG____")) for n, nach in enumerate(tro.successors, start=2) ], 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"), ) def build_context(document: TroDocument, source: str, start_id: str = "", created: str | None = None) -> SclFile: """TroDocument -> SclFile (TROs in Fliessrichtung).""" alle = {t.tro_id: t for t in document.tros} 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) for t in geordnet], )