Files
sps_skel/lib/scl_context.py
T
Samer AyadiandClaude Opus 5.5 4895ebf5ed SCL aus parametrisierten Templates: Golden-Files, Render-Kontext, Pilot 1Sep1Swi
Schritte 1-3 aus doc/SCL_Templates.md (neu: Konzept, Wertklassen je Zeile,
Namensregeln, Tests, Migrationsplan, Vertrag fuer den CSV->SCL-Workflow).

- Golden-Files (tests/test_scl_skeleton_golden.py, tests/fixtures/scl/):
  komplette FB_Main-Ausgabe von mubea und HundM05 byte-genau festgeschrieben,
  Zeitstempel maskiert; gewollte Aenderung per SKEL_UPDATE_GOLDEN=1
- lib/scl_context.py (neu): alles, was scl_skeleton aus der TRO-JSON ableitet
  (Reihenfolge, Separator-/Weichen-/Scanner-Bloecke, Staubereiche, Ziellisten,
  Namen) als pydantic-Kontext SclFile/SclTro; Namen als SclName (Wert +
  todo-Merker) als Andockstelle fuer die Namensregeln (Schritt 4).
  scl_skeleton schreibt nur noch Text; Ausgabe byte-identisch (Golden-Files,
  zusaetzlich dematic/showroom mit und ohne --start alt/neu verglichen)
- lib/scl_templates/ (neu, Jinja2): _macros.scl.j2 + Pilot
  FB_ILS_MTRO_1Sep1Swi.scl.j2 nach der realen Aufrufstelle TRO107
  (PriorityManagerDir2, stInSenJam<n>, xInCarouselRun1, xInRelease TRUE,
  Separatorzeiten 100/50 ms). Ortsgebundene Zeilen als Vorschlag umgesetzt,
  jede offene Stelle mit TODO(E-Planung) - Bestaetigung durch SPS steht aus
- scl_skeleton: --templates [DIR] (opt-in, ohne Schalter Ausgabe unveraendert),
  --strict (Exit 1 ohne SCL, wenn ein FB-Typ kein Template hat); Kopf und
  Zusammenfassung nennen Template-/generische Typen; Templatefehler
  (StrictUndefined) -> Exit 1 ohne SCL
- tests/test_scl_templates.py: Rundlauf Template mit Referenzwerten ==
  Referenz-REGION (normalisiert); jede Platzhalterzeile traegt den TODO-Marker
- requirements.txt: jinja2 >= 3.1 (bestehende .venv: pip install -r requirements.txt)
- Doku: SCL_Templates.md, Python_Scripts.md §5b, Commandlines.md §2.5, CLAUDE.md

Befunde: xInCarouselRun1 laesst sich nicht aus der TRO-JSON ableiten (Kanten-
art "umlauf" kommt praktisch nicht vor) -> immer als TODO; nTo1Destinations
ist in der Referenz 3 bei 2 Abgaengen (zaehlt evtl. Ziele) -> "pruefen".
Mit --templates nur die 1Sep1Swi-REGIONs (+ Kopf) anders als generisch;
32 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:06:18 +02:00

384 lines
12 KiB
Python

# -*- 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<n>-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<n>-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<n> / JamExit<n>)."""
model_config = _CONFIG
n: int
# Nachbar als Text fuer den Kommentar, SYSn als Station markiert
neighbour: str
db: SclName
# Stausensor des Ausgangs (stInSenJam<n>); 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],
)