simaticml-decoder 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ """simaticml-decoder — SimaticML LAD/FBD -> readable SCL + JSON metadata sidecar.
2
+
3
+ Pipeline (three independently testable phases):
4
+
5
+ parse.py XML -> model.* (faithful, dumb mirror of the XML syntax)
6
+ fold.py model.* -> ir.* (semantics: boolean tree + assignments)
7
+ emit.py ir.* -> SCL text + JSON sidecar
8
+
9
+ Supporting modules: instructions.py (part catalog, data not logic),
10
+ operand.py (Access -> display string), scl_reconstruct.py (SCL networks).
11
+ """
12
+
13
+ __version__ = "0.1.0"
@@ -0,0 +1,113 @@
1
+ """Command-line entry point: one exported SimaticML block in, SCL and/or JSON out.
2
+
3
+ v0 surface (one file at a time, by decision — batch is a deliberate later add):
4
+
5
+ simaticml-decode BLOCK.xml [-o OUTDIR] [--format {scl,json,both}] [-q]
6
+
7
+ The pipeline is the three phases in order:
8
+
9
+ parse.parse_file XML -> model.Document
10
+ fold.fold_block model.* -> ir.DecodedBlock
11
+ emit.emit_scl / emit.emit_sidecar -> .scl text + .json sidecar
12
+
13
+ Artifacts are written next to the input (or into ``--output``) as ``<stem>.scl``
14
+ and ``<stem>.json``. Warnings (deferred constructs, unknown instructions) are
15
+ reported on stderr but do not fail the run — the decoded output still surfaces
16
+ every unhandled part loudly, so a warning is informative, not fatal.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+ from xml.etree import ElementTree as ET
26
+
27
+ from . import __version__, emit, fold, parse
28
+
29
+ _EPILOG = """\
30
+ examples:
31
+ simaticml-decode Motor.xml # writes Motor.scl + Motor.json beside it
32
+ simaticml-decode Motor.xml -o out/ # writes into out/
33
+ simaticml-decode Motor.xml --format scl # SCL only
34
+ """
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ p = argparse.ArgumentParser(
39
+ prog="simaticml-decode",
40
+ description="Translate an exported SimaticML LAD/FBD block (TIA V21) into "
41
+ "readability-first SCL plus a JSON metadata sidecar.",
42
+ epilog=_EPILOG,
43
+ formatter_class=argparse.RawDescriptionHelpFormatter,
44
+ )
45
+ p.add_argument("input", metavar="BLOCK.xml",
46
+ help="path to a single exported SimaticML .xml block")
47
+ p.add_argument("-o", "--output", metavar="DIR",
48
+ help="output directory (default: alongside the input file)")
49
+ p.add_argument("--format", choices=("scl", "json", "both"), default="both",
50
+ help="which artifact(s) to write (default: both)")
51
+ p.add_argument("-q", "--quiet", action="store_true",
52
+ help="suppress the per-file progress/warning summary on stderr")
53
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
54
+ return p
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ args = build_parser().parse_args(argv)
59
+
60
+ input_path = Path(args.input)
61
+ if not input_path.is_file():
62
+ print(f"error: input file not found: {input_path}", file=sys.stderr)
63
+ return 2
64
+
65
+ try:
66
+ doc = parse.parse_file(str(input_path))
67
+ except ET.ParseError as exc:
68
+ print(f"error: {input_path} is not well-formed XML: {exc}", file=sys.stderr)
69
+ return 1
70
+ except (OSError, ValueError) as exc:
71
+ print(f"error: failed to read {input_path}: {exc}", file=sys.stderr)
72
+ return 1
73
+
74
+ decoded = fold.fold_block(doc)
75
+
76
+ out_dir = Path(args.output) if args.output else input_path.parent
77
+ try:
78
+ out_dir.mkdir(parents=True, exist_ok=True)
79
+ except OSError as exc:
80
+ print(f"error: cannot create output directory {out_dir}: {exc}", file=sys.stderr)
81
+ return 1
82
+
83
+ stem = input_path.stem
84
+ written: list[Path] = []
85
+ if args.format in ("scl", "both"):
86
+ written.append(_write(out_dir / f"{stem}.scl", emit.emit_scl(decoded)))
87
+ if args.format in ("json", "both"):
88
+ sidecar = json.dumps(emit.emit_sidecar(decoded), indent=2, ensure_ascii=False)
89
+ written.append(_write(out_dir / f"{stem}.json", sidecar + "\n"))
90
+
91
+ if not args.quiet:
92
+ _report(decoded, written)
93
+ return 0
94
+
95
+
96
+ def _write(path: Path, text: str) -> Path:
97
+ path.write_text(text, encoding="utf-8")
98
+ return path
99
+
100
+
101
+ def _report(decoded, written: list[Path]) -> None:
102
+ label = f"{decoded.name} ({decoded.kind})"
103
+ files = ", ".join(p.name for p in written)
104
+ print(f"decoded {label}: {len(decoded.networks)} network(s) -> {files}",
105
+ file=sys.stderr)
106
+ if decoded.warnings:
107
+ print(f" {len(decoded.warnings)} warning(s):", file=sys.stderr)
108
+ for warning in decoded.warnings:
109
+ print(f" - {warning}", file=sys.stderr)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ sys.exit(main())
@@ -0,0 +1,365 @@
1
+ """Phase 3: ir.* -> readable SCL text + JSON metadata sidecar.
2
+
3
+ Two artifacts (readability-first, NOT recompilable):
4
+
5
+ * SCL text — per network: a ``// Network N: <title>`` header, then the folded
6
+ statements rendered as SCL, with load-bearing constructs called out (latches,
7
+ edges, and any ir.Unhandled rendered as a visible ``// (!) UNHANDLED ...``
8
+ line). Edges render as ``R_TRIG(...)`` / ``F_TRIG(...)`` so the rising/falling
9
+ intent is explicit at the point of use.
10
+ * JSON sidecar — a single dict (schema in IMPLEMENTATION_PLAN.md §7):
11
+ { "block": {name, kind},
12
+ "interface": [...sections/members with ground-truth types...],
13
+ "networks": [{index, title, language, warnings}],
14
+ "xref": { tag: [{network, role, uid}, ...] }, # write/read map
15
+ "instruction_inventory": { "Contact": 20, ... },
16
+ "warnings": [...],
17
+ "trace": { uid: "claim/location", ... } } # UId -> claim map
18
+
19
+ This module is a faithful *renderer* of the IR: fold owns the semantics and the
20
+ readability rewrites (factoring, latch detection); emit owns text formatting and
21
+ never alters logic. Operator knowledge (``+``, ``<``, ``:=`` ...) is reused from
22
+ the instruction catalog rather than duplicated here.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from . import instructions, ir, model
28
+
29
+ _INDENT = " "
30
+
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # SCL text artifact #
34
+ # --------------------------------------------------------------------------- #
35
+ def emit_scl(decoded: ir.DecodedBlock) -> str:
36
+ """Render the readable SCL text artifact for a decoded block."""
37
+ lines: list[str] = [f"// Block: {decoded.name} ({decoded.kind})"]
38
+ if decoded.warnings:
39
+ lines.append(f"// {len(decoded.warnings)} warning(s) — see JSON sidecar")
40
+ lines.append("")
41
+
42
+ for net in decoded.networks:
43
+ lines.extend(_render_network(net))
44
+ lines.append("")
45
+
46
+ return "\n".join(lines).rstrip() + "\n"
47
+
48
+
49
+ def _render_network(net: ir.NetworkLogic) -> list[str]:
50
+ title = f": {net.title}" if net.title else ""
51
+ out = [f"// Network {net.index}{title} [{net.language}]"]
52
+ if net.comment:
53
+ out.append(f"// {net.comment}")
54
+ for warning in net.warnings:
55
+ out.append(f"// (!) {warning}")
56
+
57
+ if net.scl_text is not None:
58
+ # Reconstructed SCL network — already textual, emit verbatim.
59
+ out.append(net.scl_text)
60
+ return out
61
+
62
+ if not net.statements:
63
+ out.append("// (empty network)")
64
+ return out
65
+
66
+ for stmt in net.statements:
67
+ out.extend(_render_statement(stmt))
68
+ return out
69
+
70
+
71
+ # --------------------------------------------------------------------------- #
72
+ # Statements #
73
+ # --------------------------------------------------------------------------- #
74
+ def _render_statement(stmt: ir.Statement) -> list[str]:
75
+ if isinstance(stmt, ir.Assign):
76
+ return _render_assign(stmt)
77
+ if isinstance(stmt, ir.FlipFlop):
78
+ return _render_flipflop(stmt)
79
+ if isinstance(stmt, ir.BoxCall):
80
+ return _render_box(stmt)
81
+ if isinstance(stmt, ir.UserCall):
82
+ return _render_user_call(stmt)
83
+ if isinstance(stmt, ir.Unhandled):
84
+ return [_unhandled_line(stmt)]
85
+ return [f"// (!) UNRENDERED statement {type(stmt).__name__}"]
86
+
87
+
88
+ def _render_assign(stmt: ir.Assign) -> list[str]:
89
+ target = stmt.target.name
90
+ value = _expr(stmt.value)
91
+ trailer = ""
92
+ if stmt.is_latch:
93
+ trailer = f" // seal-in latch: {stmt.note}" if stmt.note else " // seal-in latch"
94
+ elif stmt.note:
95
+ trailer = f" // {stmt.note}"
96
+
97
+ if stmt.kind is ir.AssignKind.NORMAL:
98
+ return [f"{target} := {value};{trailer}"]
99
+ if stmt.kind is ir.AssignKind.NEGATED:
100
+ neg = _expr(ir.Not(operand=stmt.value))
101
+ return [f"{target} := {neg};{trailer}"]
102
+ if stmt.kind is ir.AssignKind.SET:
103
+ return _guard_block(stmt.value, [f"{target} := TRUE;"], trailer, "set coil ( S )")
104
+ # RESET
105
+ return _guard_block(stmt.value, [f"{target} := FALSE;"], trailer, "reset coil ( R )")
106
+
107
+
108
+ def _render_flipflop(stmt: ir.FlipFlop) -> list[str]:
109
+ target = stmt.target.name
110
+ set_s = _expr(stmt.set_expr)
111
+ reset_s = _expr(stmt.reset_expr)
112
+ if stmt.reset_priority:
113
+ head = f"// RS flip-flop (reset priority): {target}"
114
+ first, first_val = reset_s, "FALSE"
115
+ second, second_val = set_s, "TRUE"
116
+ else:
117
+ head = f"// SR flip-flop (set priority): {target}"
118
+ first, first_val = set_s, "TRUE"
119
+ second, second_val = reset_s, "FALSE"
120
+ return [
121
+ head,
122
+ f"IF {first} THEN",
123
+ f"{_INDENT}{target} := {first_val};",
124
+ f"ELSIF {second} THEN",
125
+ f"{_INDENT}{target} := {second_val};",
126
+ "END_IF;",
127
+ ]
128
+
129
+
130
+ def _render_box(stmt: ir.BoxCall) -> list[str]:
131
+ spec = instructions.lookup(stmt.instruction)
132
+ hint = spec.render if spec else None
133
+ body = _box_body(stmt, hint)
134
+ if not body:
135
+ # Nothing recognisable to render — surface loudly rather than drop it.
136
+ body = [f"// (!) UNHANDLED box {stmt.instruction} (UId {stmt.uid})"]
137
+ if stmt.enable is not None:
138
+ return _wrap_if(stmt.enable, body)
139
+ return body
140
+
141
+
142
+ def _box_body(stmt: ir.BoxCall, hint: str | None) -> list[str]:
143
+ if hint == ":=": # Move: dest := in;
144
+ src = _first(stmt.inputs, ("in", "in1"))
145
+ if src is not None and stmt.outputs:
146
+ return [f"{d.name} := {_expr(src)};" for d in stmt.outputs.values()]
147
+ elif hint in ("+", "-", "*", "/"): # Add/Sub/Mul/Div: dest := a <op> b;
148
+ operands = [stmt.inputs[k] for k in _sorted_pins(stmt.inputs)]
149
+ expr = f" {hint} ".join(_expr(o) for o in operands)
150
+ return [f"{d.name} := {expr};" for d in stmt.outputs.values()]
151
+ elif hint in ("+1", "-1"): # Inc/Dec: operand := operand +/- 1;
152
+ var = stmt.inputs.get("operand") or _first(stmt.inputs, ())
153
+ if var is not None:
154
+ name = _expr(var)
155
+ return [f"{name} := {name} {hint[0]} 1;"]
156
+ elif hint == "equation": # Calculate: dest := <equation>;
157
+ eq = stmt.inputs.get("__equation__")
158
+ if eq is not None and stmt.outputs:
159
+ return [f"{d.name} := {_expr(eq)};" for d in stmt.outputs.values()]
160
+
161
+ # Fallback / call-form boxes: timers (instance), system FCs, unknown boxes.
162
+ return _box_call_form(stmt)
163
+
164
+
165
+ def _box_call_form(stmt: ir.BoxCall) -> list[str]:
166
+ callee = stmt.instance or stmt.instruction
167
+ pairs = [f"{pin} := {_expr(e)}" for pin, e in stmt.inputs.items()
168
+ if pin != "__equation__"]
169
+ if stmt.instance is None:
170
+ # System FC: outputs are call parameters (OUT => dest).
171
+ pairs += [f"{pin} => {d.name}" for pin, d in stmt.outputs.items()]
172
+ lines = _format_call(callee, pairs)
173
+ if stmt.instance is not None:
174
+ # Instance box (timer): outputs read back off the instance member.
175
+ lines += [f"{d.name} := {stmt.instance}.{pin};"
176
+ for pin, d in stmt.outputs.items()]
177
+ return lines
178
+
179
+
180
+ def _render_user_call(stmt: ir.UserCall) -> list[str]:
181
+ callee = stmt.instance or stmt.name
182
+ pairs = [f"{pin} := {_expr(val)}" for pin, val in stmt.params.items()]
183
+ lines = _format_call(callee, pairs)
184
+ if stmt.enable is not None:
185
+ return _wrap_if(stmt.enable, lines)
186
+ return lines
187
+
188
+
189
+ # --------------------------------------------------------------------------- #
190
+ # Expression rendering (SCL precedence: NOT > AND > OR; compares parenthesised #
191
+ # inside boolean context for readability — purely cosmetic, logic unchanged) #
192
+ # --------------------------------------------------------------------------- #
193
+ _WRAP = {
194
+ "top": frozenset(),
195
+ "not": frozenset({"and", "or", "cmp"}),
196
+ "and": frozenset({"or", "cmp"}),
197
+ "or": frozenset({"and", "cmp"}),
198
+ "cmp": frozenset({"and", "or", "not"}),
199
+ }
200
+
201
+
202
+ def _expr(expr: ir.Expr, ctx: str = "top") -> str:
203
+ text, kind = _expr_core(expr)
204
+ if kind in _WRAP[ctx]:
205
+ return f"({text})"
206
+ return text
207
+
208
+
209
+ def _expr_core(expr: ir.Expr) -> tuple[str, str]:
210
+ if isinstance(expr, ir.VarRef):
211
+ return expr.name, "var"
212
+ if isinstance(expr, ir.Literal):
213
+ return expr.value, "lit"
214
+ if isinstance(expr, ir.Not):
215
+ return f"NOT {_expr(expr.operand, 'not')}", "not"
216
+ if isinstance(expr, ir.And):
217
+ return " AND ".join(_expr(o, "and") for o in expr.operands), "and"
218
+ if isinstance(expr, ir.Or):
219
+ return " OR ".join(_expr(o, "or") for o in expr.operands), "or"
220
+ if isinstance(expr, ir.Compare):
221
+ return f"{_expr(expr.left, 'cmp')} {expr.op} {_expr(expr.right, 'cmp')}", "cmp"
222
+ if isinstance(expr, ir.Edge):
223
+ fn = "R_TRIG" if expr.kind is ir.EdgeKind.RISING else "F_TRIG"
224
+ return f"{fn}({_expr(expr.signal)})", "edge"
225
+ if isinstance(expr, ir.RawExpr):
226
+ return expr.text, "raw"
227
+ if isinstance(expr, ir.Unhandled):
228
+ return f"(* (!) UNHANDLED {expr.part_name} (UId {expr.uid}) *)", "unhandled"
229
+ return repr(expr), "unhandled"
230
+
231
+
232
+ # --------------------------------------------------------------------------- #
233
+ # Small rendering helpers #
234
+ # --------------------------------------------------------------------------- #
235
+ def _wrap_if(cond: ir.Expr, body: list[str]) -> list[str]:
236
+ out = [f"IF {_expr(cond)} THEN"]
237
+ out += [f"{_INDENT}{line}" for line in body]
238
+ out.append("END_IF;")
239
+ return out
240
+
241
+
242
+ def _guard_block(cond: ir.Expr, body: list[str], trailer: str, label: str) -> list[str]:
243
+ lines = _wrap_if(cond, body)
244
+ lines[0] = f"{lines[0]}{trailer}" if trailer else f"{lines[0]} // {label}"
245
+ return lines
246
+
247
+
248
+ def _format_call(callee: str, pairs: list[str]) -> list[str]:
249
+ if not pairs:
250
+ return [f"{callee}();"]
251
+ if len(pairs) == 1:
252
+ return [f"{callee}({pairs[0]});"]
253
+ prefix = f"{callee}("
254
+ pad = " " * len(prefix)
255
+ lines = [f"{prefix}{pairs[0]},"]
256
+ lines += [f"{pad}{p}," for p in pairs[1:-1]]
257
+ lines.append(f"{pad}{pairs[-1]});")
258
+ return lines
259
+
260
+
261
+ def _first(inputs: dict[str, ir.Expr], preferred: tuple[str, ...]) -> ir.Expr | None:
262
+ for pin in preferred:
263
+ if pin in inputs:
264
+ return inputs[pin]
265
+ for pin, expr in inputs.items():
266
+ if pin != "__equation__":
267
+ return expr
268
+ return None
269
+
270
+
271
+ def _sorted_pins(inputs: dict[str, ir.Expr]) -> list[str]:
272
+ pins = [p for p in inputs if p != "__equation__"]
273
+ return sorted(pins, key=_pin_sort_key)
274
+
275
+
276
+ def _pin_sort_key(pin: str) -> tuple[str, int]:
277
+ i = len(pin)
278
+ while i > 0 and pin[i - 1].isdigit():
279
+ i -= 1
280
+ return (pin[:i], int(pin[i:]) if pin[i:] else -1)
281
+
282
+
283
+ def _unhandled_line(stmt: ir.Unhandled) -> str:
284
+ note = f" - {stmt.note}" if stmt.note else ""
285
+ return f"// (!) UNHANDLED {stmt.part_name} (UId {stmt.uid}){note}"
286
+
287
+
288
+ # --------------------------------------------------------------------------- #
289
+ # JSON sidecar artifact #
290
+ # --------------------------------------------------------------------------- #
291
+ def emit_sidecar(decoded: ir.DecodedBlock) -> dict:
292
+ """Build the JSON-serialisable sidecar dict (schema in plan §7)."""
293
+ return {
294
+ "block": {"name": decoded.name, "kind": decoded.kind},
295
+ "interface": _interface_json(decoded.interface),
296
+ "networks": [
297
+ {
298
+ "index": net.index,
299
+ "title": net.title,
300
+ "language": net.language,
301
+ "warnings": list(net.warnings),
302
+ }
303
+ for net in decoded.networks
304
+ ],
305
+ "xref": {
306
+ tag: [
307
+ {"network": ref.network_index, "role": ref.role, "uid": ref.uid}
308
+ for ref in refs
309
+ ]
310
+ for tag, refs in decoded.xref.items()
311
+ },
312
+ "instruction_inventory": dict(decoded.instruction_inventory),
313
+ "warnings": list(decoded.warnings),
314
+ "trace": _build_trace(decoded),
315
+ }
316
+
317
+
318
+ def _interface_json(interface: object) -> list[dict]:
319
+ if not isinstance(interface, model.Interface):
320
+ return []
321
+ return [
322
+ {"name": section.name, "members": [_member_json(m) for m in section.members]}
323
+ for section in interface.sections
324
+ ]
325
+
326
+
327
+ def _member_json(member: model.Member) -> dict:
328
+ out: dict = {"name": member.name, "datatype": member.datatype}
329
+ if member.is_udt:
330
+ out["is_udt"] = True
331
+ for attr in ("version", "start_value", "remanence", "comment"):
332
+ value = getattr(member, attr)
333
+ if value is not None:
334
+ out[attr] = value
335
+ if member.children:
336
+ out["children"] = [_member_json(c) for c in member.children]
337
+ return out
338
+
339
+
340
+ def _build_trace(decoded: ir.DecodedBlock) -> dict[str, str]:
341
+ """UId -> short claim, so any rendered statement is traceable to its net."""
342
+ trace: dict[str, str] = {}
343
+ for net in decoded.networks:
344
+ for stmt in net.statements:
345
+ uid = getattr(stmt, "uid", None)
346
+ if uid is None:
347
+ continue
348
+ trace[uid] = f"Network {net.index}: {_claim(stmt)}"
349
+ return trace
350
+
351
+
352
+ def _claim(stmt: ir.Statement) -> str:
353
+ if isinstance(stmt, ir.Assign):
354
+ return f"{stmt.kind.value} assign {stmt.target.name}"
355
+ if isinstance(stmt, ir.FlipFlop):
356
+ kind = "Rs" if stmt.reset_priority else "Sr"
357
+ return f"{kind} flip-flop {stmt.target.name}"
358
+ if isinstance(stmt, ir.BoxCall):
359
+ inst = f" {stmt.instance}" if stmt.instance else ""
360
+ return f"box {stmt.instruction}{inst}"
361
+ if isinstance(stmt, ir.UserCall):
362
+ return f"call {stmt.block_type} {stmt.name}"
363
+ if isinstance(stmt, ir.Unhandled):
364
+ return f"UNHANDLED {stmt.part_name}"
365
+ return type(stmt).__name__