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>
This commit is contained in:
Samer Ayadi
2026-10-09 18:06:18 +02:00
co-authored by Claude Opus 5.5
parent 97a0db6707
commit 4895ebf5ed
18 changed files with 7276 additions and 212 deletions
+242 -211
View File
@@ -40,17 +40,60 @@ from __future__ import annotations
import argparse
import json
import sys
from collections import deque
from datetime import datetime
from pathlib import Path
from jinja2 import (
Environment,
FileSystemLoader,
StrictUndefined,
TemplateError,
TemplateNotFound,
)
from pydantic import ValidationError
from material_flow import env_dir
from model import SCHEMA, TroBase, TroDocument
from tro_catalog import TroItem
from model import SCHEMA, TroDocument
from scl_context import TODO, SclFile, SclName, SclTro, build_context
TODO = "TODO(E-Planung)"
# Parametrisierte Aufrufstellen je FB (doc/SCL_Templates.md); Dateiname = fbType.
TEMPLATE_DIR = Path(__file__).resolve().parent / "scl_templates"
TEMPLATE_SUFFIX = ".scl.j2"
class SclTemplates:
"""Die FB-Templates eines Ordners; ohne Template -> generischer Aufbau."""
def __init__(self, folder: Path) -> None:
self.folder = folder
self.env = Environment(
loader=FileSystemLoader(str(folder)),
# Tippfehler im Template -> Fehler statt leerer SCL-Stelle
undefined=StrictUndefined,
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
autoescape=False,
)
self.env.globals["TODO"] = TODO
def has(self, fb: str) -> bool:
return (self.folder / f"{fb}{TEMPLATE_SUFFIX}").is_file()
def render(self, tro: SclTro) -> str:
"""REGION eines TRO aus seinem Template (ohne abschliessenden Umbruch)."""
try:
template = self.env.get_template(f"{tro.fb}{TEMPLATE_SUFFIX}")
except TemplateNotFound as exc:
raise TemplateError(f"Template fuer {tro.fb} fehlt: {exc}") from exc
# Leerzeilen hier zusammenfassen statt im Template mit Whitespace-
# Steuerung ({%- ... -%}) - die macht Templates schwer lesbar und
# bricht leicht die Einrueckung.
zeilen: list[str] = []
for zeile in template.render(t=tro).split("\n"):
zeile = zeile.rstrip()
if zeile or (zeilen and zeilen[-1]):
zeilen.append(zeile)
return "\n".join(zeilen).strip("\n")
# Zeitwerte aus den Referenz-Aufrufstellen. Sie sind plausible Startwerte, KEINE
# fuer diese Anlage ermittelten - deshalb ueberall mit Pruefhinweis.
@@ -67,140 +110,58 @@ def load_document(path: Path) -> TroDocument:
return TroDocument.from_json(path.read_text(encoding="utf-8"))
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 _todo(name: SclName, abstand: str) -> str:
"""TODO-Kommentar hinter einer Zeile, wenn der Name noch offen ist."""
return f"{abstand}// {TODO}" if name.todo else ""
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 emit_separators(add, tro: TroBase, anzahl: int, hat_scanner: bool) -> None:
seps = tro.separators
for i in range(1, anzahl + 1):
pfad = f"#{tro.tro_id}.stInSeparator{i}"
layout = seps[i - 1] if i <= len(seps) else "?"
add(f" // Separator {i} = Bauteil {layout} aus dem Layout")
add(f' {pfad}.Settings.nSeparatorNo := "cSep_____"; // {TODO}')
def emit_separators(add, tro: SclTro) -> None:
for s in tro.separators:
pfad = f"#{tro.id}.stInSeparator{s.n}"
add(f" // Separator {s.n} = Bauteil {s.layout} aus dem Layout")
add(f' {pfad}.Settings.nSeparatorNo := "{s.no.value}";{_todo(s.no, " ")}')
for name, wert, hinweis in DEFAULT_SEP_TIMES:
kommentar = f" // {TODO}: {hinweis}" if hinweis else ""
add(f" {pfad}.Settings.{name} := {wert};{kommentar}")
add(f" {pfad}.Settings.xPluggedJam := TRUE;")
add(f" {pfad}.Settings.xPluggedPart := FALSE;")
add(f" {pfad}.Settings.xPluggedScanner := "
f"{'TRUE' if hat_scanner else 'FALSE'};")
add(f' {pfad}.stSenInSep := "DB_Inputs".Sensors["cInBG____"]; // {TODO}')
f"{'TRUE' if tro.has_scanner else 'FALSE'};")
add(f' {pfad}.stSenInSep := "DB_Inputs".Sensors["{s.sensor.value}"];'
f'{_todo(s.sensor, " ")}')
add(f" {pfad}.Settings.tSenFree := T#1500ms; // {TODO}: pruefen")
add(f" {pfad}.Settings.tSenWait := T#4000ms; // {TODO}: pruefen")
add("")
def emit_switches(add, tro: TroBase, anzahl: int, alle: dict[str, TroBase]) -> None:
abgaenge = tro.successors
for j in range(1, anzahl + 1):
pfad = f"#{tro.tro_id}.stInSwitch{j}"
add(f' {pfad}.Settings.nSwitchNo := "cSwi_____"; // {TODO}')
def emit_switches(add, tro: SclTro) -> None:
for w in tro.switches:
pfad = f"#{tro.id}.stInSwitch{w.n}"
add(f' {pfad}.Settings.nSwitchNo := "{w.no.value}";{_todo(w.no, " ")}')
add(f" {pfad}.Settings.xPluggedExSen3 := FALSE;")
add(f" {pfad}.Settings.xPluggedExSen4 := FALSE;")
if anzahl == 1:
# Eine Weiche bedient alle Abgaenge - direkt ableitbar.
add(f" {pfad}.Settings.nTo1Destinations := {len(abgaenge)};"
if w.to1_destinations is not None:
add(f" {pfad}.Settings.nTo1Destinations := {w.to1_destinations};"
f" // Abgaenge laut Topologie")
else:
# Bei mehreren Weichen ist es eine Planungsentscheidung, welche
# Weiche welche Abgaenge bedient - die Summe ist bekannt, die
# Aufteilung nicht. Deshalb hier kein geratener Wert.
add(f" {pfad}.Settings.nTo1Destinations := _;"
f" // {TODO}: dieser TRO hat {len(abgaenge)} "
f"Abgaenge auf {anzahl} Weichen - Aufteilung festlegen")
f" // {TODO}: dieser TRO hat {len(tro.successors)} "
f"Abgaenge auf {len(tro.switches)} Weichen - Aufteilung festlegen")
add("")
if abgaenge:
if tro.switch_exits:
add(" // Zielliste je Abgang (aus dem TRO-Graphen berechnet) -")
add(" // Grundlage fuer die Zuordnung Ziel -> Richtung in FC_Direction:")
for abgang in abgaenge:
ziele = reachable_via(tro.tro_id, abgang, alle)
add(f" // ueber {abgang}: {', '.join(ziele) if ziele else '-'}")
for abgang in tro.switch_exits:
ziele = ", ".join(abgang.targets) if abgang.targets else "-"
add(f" // ueber {abgang.via}: {ziele}")
add("")
def emit_vario(add, tro: TroBase) -> None:
pfad = f"#{tro.tro_id}.stInVario"
def emit_vario(add, tro: SclTro) -> None:
pfad = f"#{tro.id}.stInVario"
add(f" {pfad}.Settings.xContinousEmptying := FALSE;")
add(f" {pfad}.Settings.tWaitForJog := T#300ms; // {TODO}: pruefen")
add(f" {pfad}.Settings.tConvEmpty := T#45000ms; // {TODO}: pruefen")
@@ -210,17 +171,18 @@ def emit_vario(add, tro: TroBase) -> None:
add(f" {pfad}.Settings.tCarrierInPosEdgeDelay := t#100ms;")
add(f" {pfad}.Settings.xJamSenDelayActive := TRUE;")
add(f" {pfad}.Settings.tJamSenDelay := T#2s;")
add(f' {pfad}.stSenCarrInPos := "DB_Inputs".Sensors["cInBG____"]; // {TODO}')
add(f' {pfad}.stSenFinger := "DB_Inputs".Sensors["cInBG____"]; // {TODO}')
add(f' {pfad}.stSenLastPos := "DB_Inputs".Sensors["cInBG____"]; // {TODO}')
add(f' {pfad}.stSenJam := "DB_Inputs".Sensors["cInBG____"]; // {TODO}')
# Der Kommentar steht bei allen Sensoren in derselben Spalte
# (Parametername + Abstand = 16 Zeichen).
for name, sensor in tro.vario_sensors.items():
abstand = " " * (16 - len(name))
add(f' {pfad}.{name} := "DB_Inputs".Sensors["{sensor.value}"];'
f"{_todo(sensor, abstand)}")
add("")
def emit_barcode(add, tro: TroBase, anzahl: int) -> None:
for i in range(1, anzahl + 1):
suffix = "" if anzahl == 1 else str(i)
pfad = f"#{tro.tro_id}.stInBarcodeReader{suffix}"
def emit_barcode(add, tro: SclTro) -> None:
for scanner in tro.scanners:
pfad = f"#{tro.id}.stInBarcodeReader{scanner.suffix}"
add(f" {pfad}.Settings.xActivateCheckASCI := TRUE;")
add(f" {pfad}.Settings.tTimeout := T#3000ms;")
add(f' // {TODO}: Cognex-Interface verdrahten '
@@ -228,49 +190,55 @@ def emit_barcode(add, tro: TroBase, anzahl: int) -> None:
add("")
def emit_call(add, tro: TroBase, items: dict[str, int], alle: dict[str, TroBase]) -> None:
def emit_call(add, tro: SclTro) -> None:
"""Aufrufstelle schreiben.
Die Zeilen werden als (Code, Kommentar) gefuehrt und das Komma erst beim
Zusammensetzen gesetzt - sonst landet es hinter dem "//" und ist Teil des
Kommentars, womit die Parameterliste nicht mehr uebersetzt.
"""
tro_id = tro.tro_id
def offen(name: SclName, text: str = "") -> str:
if not name.todo:
return text
return f"{TODO} - {text}" if text else TODO
zeilen: list[tuple[str, str]] = [
('nInMainTroNo := "cMainTro____"', TODO),
('xInSftyOk := "DB_InterfaceSafety".stFromSafety.EStopMain.Q', TODO),
(f'nInMainTroNo := "{tro.main_tro.value}"', offen(tro.main_tro)),
(f"xInSftyOk := {tro.safety.value}", offen(tro.safety)),
("xInAllRdyToStart := TRUE", ""),
(f'xInRelease := "DB_TRO_TEST".{tro_id}', ""),
(f"xInRelease := {tro.release}", ""),
]
if items.get(TroItem.VARIO_DRIVE):
zeilen.append(('xInMotorProtection := "FC____"', TODO))
if tro.has_vario:
zeilen.append((f"xInMotorProtection := {tro.motor_protection.value}",
offen(tro.motor_protection)))
zeilen.append(("xInAutomatic := #t_automatik", ""))
zeilen.append(("stInOutMachineState := #stInOutControlUnitCabinet", ""))
# Der generische Aufbau hat den Schaltschrank nie als offen markiert;
# das bleibt so, damit seine Ausgabe stabil ist (Golden-Files). Die
# Templates markieren ihn.
zeilen.append((f"stInOutMachineState := {tro.machine_state.value}", ""))
zeilen.append(("stInOutCognexInterface := #stCognexInterfaceDummy", ""))
# 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.
for n, vor in enumerate(tro.predecessors, start=1):
zeilen.append((f'arInOutJamEntr{n} := "DB_JamArea____".stJam____.arCarrier',
f"{TODO} - Staubereich von {nachbar(vor, alle)}"))
zeilen.append((f'stInOutJamEntr{n} := "DB_JamArea____".stJam____.stData', ""))
for n, nach in enumerate(tro.successors, start=2):
zeilen.append((f'arInOutJamExit{n} := "DB_JamArea____".stJam____.arCarrier',
f"{TODO} - Staubereich nach {nachbar(nach, alle)}"))
zeilen.append((f'stInOutJamExit{n} := "DB_JamArea____".stJam____.stData', ""))
for jam in tro.jam_entries:
zeilen.append((f"arInOutJamEntr{jam.n} := {jam.db.value}.arCarrier",
offen(jam.db, f"Staubereich von {jam.neighbour}")))
zeilen.append((f"stInOutJamEntr{jam.n} := {jam.db.value}.stData", ""))
for jam in tro.jam_exits:
zeilen.append((f"arInOutJamExit{jam.n} := {jam.db.value}.arCarrier",
offen(jam.db, f"Staubereich nach {jam.neighbour}")))
zeilen.append((f"stInOutJamExit{jam.n} := {jam.db.value}.stData", ""))
for i in range(1, items.get(TroItem.SEPARATOR, 1) + 1):
zeilen.append((f'xOutStopper{i} => "MB____"', TODO))
if items.get(TroItem.VARIO_DRIVE):
zeilen.append(('xOutVarioMotor => "MA____"', TODO))
for j in range(1, items.get(TroItem.SWITCH, 0) + 1):
zeilen.append((f'xOutSw{j}ExTo3 => "MB____"', TODO))
zeilen.append((f'stInOutHMI := "DB_Interface_HMI".stTRO.{tro_id}', ""))
zeilen.append(('nOutStateLast => "DB_GLOB_TroState".arTroState["cMainTro____"]',
TODO))
for s in tro.separators:
zeilen.append((f'xOutStopper{s.n} => "{s.stopper.value}"', offen(s.stopper)))
if tro.has_vario:
zeilen.append((f'xOutVarioMotor => "{tro.vario_motor.value}"',
offen(tro.vario_motor)))
for w in tro.switches:
zeilen.append((f'xOutSw{w.n}ExTo3 => "{w.valve.value}"', offen(w.valve)))
zeilen.append((f'stInOutHMI := "DB_Interface_HMI".stTRO.{tro.id}', ""))
zeilen.append((f'nOutStateLast => "DB_GLOB_TroState".arTroState["{tro.main_tro.value}"]',
offen(tro.main_tro)))
kopf = f" #{tro_id}("
kopf = f" #{tro.id}("
einzug = " " * len(kopf)
for index, (code, kommentar) in enumerate(zeilen):
trenner = ");" if index == len(zeilen) - 1 else ","
@@ -279,34 +247,75 @@ def emit_call(add, tro: TroBase, items: dict[str, int], alle: dict[str, TroBase]
add(f"{text} // {kommentar}" if kommentar else text)
def emit_direction(add, tro: TroBase, anzahl: int) -> None:
def emit_direction(add, tro: SclTro) -> None:
"""Weichenlogik - je Weiche ein FC_Direction-Aufruf."""
add("")
add(" REGION Direction")
for j in range(1, anzahl + 1):
ziel = (f"#{tro.tro_id}.nInDirection" if anzahl == 1
else f"#{tro.tro_id}.nInDirection{j}")
add(f' "FC_Direction"(nInSwitchNo := "cSwi_____", // {TODO}')
add(f" nOutDirection => {ziel},")
add(f' InOutCarrier := "DB_JamArea____".stJam____'
f".arCarrier[1]);")
if j < anzahl:
for w in tro.switches:
add(f' "FC_Direction"(nInSwitchNo := "{w.no.value}",{_todo(w.no, " ")}')
add(f" nOutDirection => {w.direction_target},")
add(f" InOutCarrier := {w.jam_db.value}.arCarrier[1]);")
if w.n < len(tro.switches):
add("")
add(" END_REGION")
def render(document: TroDocument, quelle: str, start_id: str = "") -> str:
tros = document.tros
alle = {t.tro_id: t for t in tros}
geordnet, startgrund = flow_order(tros, start_id)
def emit_region(add, tro: SclTro) -> None:
"""Eine REGION je TRO (heutiger, generischer Aufbau aus den Bauteilen)."""
add("")
add(f"REGION {tro.region_title}")
if tro.position:
add(f" // Anlagenkoordinate x/y: {tro.position[0]:.0f} / {tro.position[1]:.0f} mm")
add(f" // Vorgaenger: {', '.join(tro.predecessors) or '-'}")
add(f" // Nachfolger: {', '.join(tro.successors) or '-'}")
if tro.confidence:
add(f" // Einstufung: {tro.confidence}")
add("")
emit_separators(add, tro)
if tro.switches:
emit_switches(add, tro)
if tro.has_vario:
emit_vario(add, tro)
if tro.scanners:
emit_barcode(add, tro)
if tro.storage_lines:
add(f" // {TODO}: Linienspeicher mit {tro.storage_lines} Linien - "
f"Parametrierung je Linie")
add("")
add(" #%s.stInPriorityManager.xReleaseOk := TRUE;" % tro.id)
add("")
emit_call(add, tro)
if tro.switches:
emit_direction(add, tro)
add("END_REGION")
def template_usage(context: SclFile, templates: SclTemplates | None
) -> tuple[list[str], list[str]]:
"""FB-Typen mit bzw. ohne Template (je sortiert, ohne Doppelte)."""
fbs = sorted({t.fb for t in context.tros})
if templates is None:
return [], fbs
return ([fb for fb in fbs if templates.has(fb)],
[fb for fb in fbs if not templates.has(fb)])
def render_context(context: SclFile, templates: SclTemplates | None = None) -> str:
"""SCL-Text aus dem fertigen Kontext.
Mit ``templates`` wird jeder TRO, fuer dessen FB ein Template existiert, aus
dem Template erzeugt, alle anderen generisch. Ohne ``templates`` ist die
Ausgabe die bisherige (Golden-Files in tests/fixtures/scl/).
"""
out: list[str] = []
add = out.append
add("// ============================================================")
add("// FB_Main - GERUEST, erzeugt von lib/scl_skeleton.py")
add(f"// Quelle : {quelle}")
add(f"// Erzeugt: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
add(f"// Quelle : {context.source}")
add(f"// Erzeugt: {context.created}")
add("//")
add("// Das ist KEIN lauffaehiges Programm, sondern ein Geruest zum")
add("// Ausfuellen. Struktur, Reihenfolge und Bauteilzahl stammen aus dem")
@@ -319,57 +328,33 @@ def render(document: TroDocument, quelle: str, start_id: str = "") -> str:
add("// (DB_JamArea...) und die Zeiten, soweit als 'pruefen' vermerkt.")
add("//")
add("// Reihenfolge der REGIONs = Materialfluss.")
add(f"// Anfang bei {geordnet[0].tro_id}: {startgrund}.")
add(f"// Anfang bei {context.tros[0].id}: {context.start_reason}.")
if templates is not None:
mit, ohne = template_usage(context, templates)
add("//")
add(f"// Aus Template : {', '.join(mit) or '-'}")
add(f"// Generisch : {', '.join(ohne) or '-'} (kein Template - von Hand pruefen)")
add("// ============================================================")
add("")
add('FUNCTION_BLOCK "FB_Main"')
add("{ S7_Optimized_Access := 'TRUE' }")
add("VERSION : 0.1")
add(" VAR ")
for tro in geordnet:
fb = tro.fb_block or "FB_UNBEKANNT"
kommentar = f" // {tro.type_name}"
if tro.separators:
kommentar += f", Sep {', '.join(tro.separators)}"
add(f' {tro.tro_id} : "{fb}";{kommentar}')
for tro in context.tros:
kommentar = f" // {tro.type}"
if tro.layout_separators:
kommentar += f", Sep {', '.join(tro.layout_separators)}"
add(f' {tro.id} : "{tro.fb}";{kommentar}')
add(" END_VAR")
add("")
add("BEGIN")
for tro in geordnet:
items = tro.items
n_sep = items.get(TroItem.SEPARATOR, 1)
n_swi = items.get(TroItem.SWITCH, 0)
n_scan = items.get(TroItem.SCANNER, 0)
add("")
add(f"REGION {region_title(tro)}")
if tro.position:
add(f" // Anlagenkoordinate x/y: {tro.x:.0f} / {tro.y:.0f} mm")
add(f" // Vorgaenger: {', '.join(tro.predecessors) or '-'}")
add(f" // Nachfolger: {', '.join(tro.successors) or '-'}")
if tro.confidence:
add(f" // Einstufung: {tro.confidence}")
add("")
emit_separators(add, tro, n_sep, hat_scanner=bool(n_scan))
if n_swi:
emit_switches(add, tro, n_swi, alle)
if items.get(TroItem.VARIO_DRIVE):
emit_vario(add, tro)
if n_scan:
emit_barcode(add, tro, n_scan)
if items.get(TroItem.STORAGE_LINE):
add(f" // {TODO}: Linienspeicher mit {items[TroItem.STORAGE_LINE]} Linien - "
f"Parametrierung je Linie")
for tro in context.tros:
if templates is not None and templates.has(tro.fb):
add("")
add(" #%s.stInPriorityManager.xReleaseOk := TRUE;" % tro.tro_id)
add("")
emit_call(add, tro, items, alle)
if n_swi:
emit_direction(add, tro, n_swi)
add("END_REGION")
out.extend(templates.render(tro).split("\n"))
else:
emit_region(add, tro)
add("")
add("END_FUNCTION_BLOCK")
@@ -377,6 +362,12 @@ def render(document: TroDocument, quelle: str, start_id: str = "") -> str:
return "\n".join(out)
def render(document: TroDocument, quelle: str, start_id: str = "",
templates: SclTemplates | None = None) -> str:
"""TRO-JSON -> SCL-Text (Kontext bauen, dann ausgeben)."""
return render_context(build_context(document, quelle, start_id), templates)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
results = env_dir("SKEL_RESULTS", "results")
@@ -413,20 +404,42 @@ def main(argv: list[str] | None = None) -> int:
print(f"FEHLER: {json_file} enthaelt keine TROs.", file=sys.stderr)
return 1
text = render(document, json_file.name, args.start)
templates = None
if args.templates is not None:
ordner = Path(args.templates)
if not ordner.is_dir():
print(f"FEHLER: Template-Ordner nicht gefunden: {ordner}", file=sys.stderr)
return 1
templates = SclTemplates(ordner)
context = build_context(document, json_file.name, args.start)
mit, ohne = template_usage(context, templates)
if args.strict and ohne:
print(f"FEHLER: kein Template fuer {', '.join(ohne)} (--strict) - "
f"keine SCL geschrieben.", file=sys.stderr)
return 1
try:
text = render_context(context, templates)
except TemplateError as exc:
# StrictUndefined, Syntaxfehler, fehlendes Makro ... - lieber keine SCL
# als eine mit stillen Luecken.
print(f"FEHLER im Template: {exc}", file=sys.stderr)
return 1
out_file = results / (args.out or f"{json_file.stem.replace('_tro', '')}_FB_Main.scl")
out_file.write_text(text, encoding="utf-8")
offen = text.count(TODO)
geordnet, startgrund = flow_order(document.tros, args.start)
print("")
print("================================================================")
print("FB_MAIN-GERUEST")
print("================================================================")
print(f"Quelle = {json_file}")
print(f"TROs = {len(document.tros)}")
print(f"Reihenfolge = {' -> '.join(t.tro_id for t in geordnet)}")
print(f"Anfang = {geordnet[0].tro_id} ({startgrund})")
print(f"TROs = {len(context.tros)}")
print(f"Reihenfolge = {' -> '.join(t.id for t in context.tros)}")
print(f"Anfang = {context.tros[0].id} ({context.start_reason})")
if templates is not None:
print(f"Templates = {len(mit)} von {len(mit) + len(ohne)} Typen "
f"(generisch: {', '.join(ohne) or '-'})")
print(f"Offene Stellen = {offen} (Marker '{TODO}')")
print(f"SCL = {out_file}")
print("================================================================")
@@ -451,10 +464,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
epilog="Beispiele:\n"
" scl_skeleton.bat\n"
" scl_skeleton.bat --json export_tro.json --out FB_Main.scl\n"
" scl_skeleton.bat --json mubea_tro.json --templates\n"
"\n"
"Exit-Codes:\n"
" 0 ok\n"
" 1 Eingabe- oder Aufruffehler\n",
" 0 ok (auch wenn TROs ohne Template generisch erzeugt wurden)\n"
" 1 Eingabe-, Aufruf- oder Templatefehler; mit --strict auch\n"
" ein FB-Typ ohne Template\n",
)
parser.add_argument(
"--json",
@@ -475,6 +490,22 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="TRO, bei dem die Reihenfolge beginnt. Nur noetig, solange der "
"Fluss ein geschlossener Kreis ohne Be-/Entladestation ist.",
)
parser.add_argument(
"--templates",
nargs="?",
const=str(TEMPLATE_DIR),
default=None,
metavar="DIR",
help="REGIONs aus den FB-Templates erzeugen (doc/SCL_Templates.md). "
"Ohne DIR: lib/scl_templates. FB-Typen ohne Template bleiben "
"generisch. Ohne diesen Schalter: bisherige Ausgabe.",
)
parser.add_argument(
"--strict",
action="store_true",
help="Nur mit --templates: abbrechen (Exit 1), wenn ein FB-Typ der "
"Anlage kein Template hat.",
)
return parser.parse_args(argv)