# -*- coding: utf-8 -*- # Absichtlich ohne Shebang: der Windows-py-Launcher wuerde daraus "python3" # ableiten und scheitert, wenn keine PythonCore-Installation registriert ist. """ scl_skeleton.py - FB_Main-Geruest als SCL aus der TRO-Liste erzeugen. Letzter Schritt der Kette: material_flow.py Objekte -> Materialfluss tro_flow.py Fluss -> TROs -> _tro.json (--json) scl_skeleton.py JSON -> FB_Main.scl <- hier Die TRO-JSON wird ueber das gemeinsame Modell lib/model.py (TroDocument) eingelesen und dabei geprueft: ein falscher oder umbenannter Schluessel bricht mit einer Fehlermeldung ab, statt still einen Standardwert zu nehmen. Bauteile (items) und FB-Typ kommen aus tro_catalog, ueber den TRO-Typ. Erzeugt wird ein **Geruest**, kein fertiges Programm: je TRO eine REGION mit Instanzaufruf und allen Parameterzeilen in der richtigen Reihenfolge, so wie es die realen Aufrufstellen in doc/TRO_Katalog/scl_templates/*.scl zeigen. Was aus dem Layout kommt, steht drin. Was aus der E-Planung kommen muss, steht als Platzhalter mit `TODO(E-Planung)` da - bewusst als offensichtliche Luecke und nicht als erfundener Wert, denn eine plausibel aussehende falsche Sensoradresse ist schlimmer als eine leere. Alle Platzhalter sind ueber genau diesen Marker auffindbar. Ableitbar und daher gefuellt: - Reihenfolge der REGIONs (Materialfluss), Instanzliste, FB-Typ je TRO - Anzahl der Separator-/Weichen-/Scanner-Parameterbloecke je TRO - nTo1Destinations einer Weiche = Anzahl ihrer Abgaenge - die Zielliste je Weichenabgang (Erreichbarkeit im TRO-Graphen) - Querverweis auf die Bauteile im Layout (Separatornummern, Koordinate) Aufruf ueber bin/scl_skeleton.bat bzw. bin/scl_skeleton.sh. """ from __future__ import annotations import argparse import json import sys from pathlib import Path from jinja2 import ( Environment, FileSystemLoader, StrictUndefined, TemplateError, TemplateNotFound, ) from pydantic import ValidationError from e_planning import EPLAN_SCHEMA, EplanDocument from material_flow import env_dir from model import SCHEMA, TroDocument from scl_context import TODO, SclFile, SclName, SclTro, build_context # 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. DEFAULT_SEP_TIMES = ( ("tDelayToNextItem", '"TimeForSepWaiting_Short"', None), ("tTrailingTime", "t#600ms", "pruefen"), ("tHandlingTime", "t#200ms", "pruefen"), ("tJam", '"TimeForSepJam"', None), ) def load_document(path: Path) -> TroDocument: """TRO-JSON einlesen und gegen das Modell pruefen.""" return TroDocument.from_json(path.read_text(encoding="utf-8")) 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 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 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: 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 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(tro.successors)} " f"Abgaenge auf {len(tro.switches)} Weichen - Aufteilung festlegen") add("") 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 tro.switch_exits: ziele = ", ".join(abgang.targets) if abgang.targets else "-" add(f" // ueber {abgang.via}: {ziele}") add("") 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") add(f" {pfad}.Settings.nCountCarrier := 1; // {TODO}: pruefen") add(f" {pfad}.Settings.tWaitForStop := t#3s;") add(f" {pfad}.Settings.tCarrierInPosDelay := t#1200ms;") add(f" {pfad}.Settings.tCarrierInPosEdgeDelay := t#100ms;") add(f" {pfad}.Settings.xJamSenDelayActive := TRUE;") add(f" {pfad}.Settings.tJamSenDelay := T#2s;") # 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: 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 ' f'(#arCognexInterface["cScanner____"])') add("") 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. """ 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]] = [ (f'nInMainTroNo := "{tro.main_tro.value}"', offen(tro.main_tro)), (f"xInSftyOk := {tro.safety.value}", offen(tro.safety)), ("xInAllRdyToStart := TRUE", ""), (f"xInRelease := {tro.release}", ""), ] if tro.has_vario: zeilen.append((f"xInMotorProtection := {tro.motor_protection.value}", offen(tro.motor_protection))) zeilen.append(("xInAutomatic := #t_automatik", "")) # 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", "")) 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 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}(" einzug = " " * len(kopf) for index, (code, kommentar) in enumerate(zeilen): trenner = ");" if index == len(zeilen) - 1 else "," prefix = kopf if index == 0 else einzug text = f"{prefix}{code}{trenner}" add(f"{text} // {kommentar}" if kommentar else text) def emit_direction(add, tro: SclTro) -> None: """Weichenlogik - je Weiche ein FC_Direction-Aufruf.""" add("") add(" REGION Direction") 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 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 : {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") add("// Anlagenlayout und der vor Ort aufgenommenen Topologie.") add("//") add(f"// Jede Stelle, die aus der E-Planung kommen muss, ist mit") add(f"// '{TODO}' markiert - danach suchen, dann ist nichts vergessen.") add("// Offen sind durchgaengig: Sensor-/Aktoradressen (cInBG/MB/MA),") add("// Separator- und Weichennummern, MainTro-Nummern, die Staubereiche") add("// (DB_JamArea...) und die Zeiten, soweit als 'pruefen' vermerkt.") add("//") add("// Reihenfolge der REGIONs = Materialfluss.") add(f"// Anfang bei {context.tros[0].id}: {context.start_reason}.") if context.eplan_source: add("//") add(f"// E-Planung: {context.eplan_source}") add("// Separator-Sensoren und Stopperventile aus der Zuordnung sind") add("// eingesetzt (ohne Marker); was die Zuordnung nicht sicher fand,") add(f"// bleibt '{TODO}'. Details: _eplan.md (lib/e_planning.py).") 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 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 context.tros: if templates is not None and templates.has(tro.fb): add("") out.extend(templates.render(tro).split("\n")) else: emit_region(add, tro) add("") add("END_FUNCTION_BLOCK") add("") 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") json_file = _in_results(args.json, results) if not json_file.is_file(): print(f"FEHLER: JSON nicht gefunden: {json_file}", file=sys.stderr) print(" Zuerst bin/tro_flow.bat --file .csv --json laufen lassen.", file=sys.stderr) return 1 try: document = load_document(json_file) except OSError as exc: print(f"FEHLER: {json_file} nicht lesbar: {exc}", file=sys.stderr) return 1 except ValidationError as exc: schema = _schema_of(json_file) if schema and schema != SCHEMA: # Anderes Format (z. B. die alte tro_extract-JSON) - die einzelnen # Feldfehler helfen dann nicht weiter. print(f"FEHLER: {json_file} hat das Schema '{schema}', erwartet wird " f"'{SCHEMA}' ({exc.error_count()} Abweichungen).", file=sys.stderr) print(" Mit bin/tro_flow.bat --file .csv --json neu erzeugen.", file=sys.stderr) return 1 print(f"FEHLER: {json_file} passt nicht zum TRO-Modell (lib/model.py):", file=sys.stderr) print(exc, file=sys.stderr) return 1 if not document.tros: print(f"FEHLER: {json_file} enthaelt keine TROs.", file=sys.stderr) return 1 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) eplan = None if args.eplan: eplan_file = _in_results(args.eplan, results) try: eplan = EplanDocument.from_json(eplan_file.read_text(encoding="utf-8")) except OSError as exc: print(f"FEHLER: E-Planungs-Zuordnung nicht lesbar: {exc}", file=sys.stderr) print(" Zuerst bin/e_planning.bat --file .csv --eplan " "_positionsdraw.json laufen lassen.", file=sys.stderr) return 1 except ValidationError as exc: print(f"FEHLER: {eplan_file} passt nicht zum Schema '{EPLAN_SCHEMA}' " f"({exc.error_count()} Abweichungen).", file=sys.stderr) return 1 # Eine Zuordnung zu einem anderen Layout setzt falsche Namen ein - # Separatornummern sind nur innerhalb eines CSV-Exports eindeutig. if Path(eplan.csv_file).name != Path(document.source).name: print(f"FEHLER: {eplan_file.name} gehoert zu '{eplan.csv_file}', die " f"TRO-JSON zu '{document.source}'.", file=sys.stderr) return 1 context = build_context(document, json_file.name, args.start, eplan=eplan) 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) print("") print("================================================================") print("FB_MAIN-GERUEST") print("================================================================") print(f"Quelle = {json_file}") 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 '-'})") if eplan is not None: print(f"E-Planung = {eplan_file}") print(f"Eingesetzt = {_eingesetzt(context)} Namen aus der E-Planung") print(f"Offene Stellen = {offen} (Marker '{TODO}')") print(f"SCL = {out_file}") print("================================================================") print("") return 0 def _in_results(name: str, results: Path) -> Path: """Blosser Dateiname -> in SKEL_RESULTS; Pfadangaben bleiben unveraendert.""" path = Path(name) if not path.is_absolute() and path.parent == Path("."): return results / name return path def _eingesetzt(context: SclFile) -> int: """Wie viele Separator-Namen aus der E-Planung kamen.""" return sum(not name.todo for tro in context.tros for s in tro.separators for name in (s.sensor, s.stopper)) def _schema_of(path: Path) -> str: """Schema-Kennung einer JSON-Datei, nur fuer die Fehlermeldung ("" wenn unlesbar).""" try: return str(json.loads(path.read_text(encoding="utf-8")).get("schema", "")) except (OSError, ValueError, AttributeError): return "" def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="scl_skeleton", description="Erzeugt aus der TRO-JSON (tro_flow --json) ein FB_Main-Geruest " "als SCL zum Import in TIA Portal.", formatter_class=argparse.RawDescriptionHelpFormatter, 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" " scl_skeleton.bat --json mubea_tro.json --eplan mubea_eplan.json\n" "\n" "Exit-Codes:\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", default="export_tro.json", metavar="NAME", help="TRO-JSON aus tro_flow.py --json in %%SKEL_RESULTS%% " "(oder ein vollstaendiger Pfad). Standard: %(default)s", ) parser.add_argument( "--out", metavar="NAME", help="Name der SCL-Datei in %%SKEL_RESULTS%%. Standard: _FB_Main.scl", ) parser.add_argument( "--start", default="", metavar="TRO", 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( "--eplan", metavar="NAME", help="Zuordnung zur E-Planung aus e_planning.py (_eplan.json in " "%%SKEL_RESULTS%% oder ein Pfad): Separator-Sensoren und " "Stopperventile werden eingesetzt statt als TODO markiert.", ) 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) if __name__ == "__main__": sys.exit(main())