# -*- 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 TroBase, TroDocument from tro_catalog import TroItem if TYPE_CHECKING: from e_planning import EplanDocument __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) 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 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) # 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 _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____"), ) def _tro_context(tro: TroBase, alle: dict[str, TroBase], eplan: EplanDocument | 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____' 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 [] 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, 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} 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) for t in geordnet], eplan_source=(f"{eplan.eplan_file} (Zuordnung vom {eplan.created})" if eplan is not None else ""), )