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.
- simaticml_decoder/__init__.py +13 -0
- simaticml_decoder/cli.py +113 -0
- simaticml_decoder/emit.py +365 -0
- simaticml_decoder/fold.py +652 -0
- simaticml_decoder/instructions.py +105 -0
- simaticml_decoder/ir.py +182 -0
- simaticml_decoder/model.py +264 -0
- simaticml_decoder/operand.py +141 -0
- simaticml_decoder/parse.py +596 -0
- simaticml_decoder/scl_reconstruct.py +75 -0
- simaticml_decoder-0.1.0.dist-info/METADATA +118 -0
- simaticml_decoder-0.1.0.dist-info/RECORD +14 -0
- simaticml_decoder-0.1.0.dist-info/WHEEL +4 -0
- simaticml_decoder-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""Phase 2: model.* -> ir.* (the folding — the heart of the tool).
|
|
2
|
+
|
|
3
|
+
Intended algorithm per network (LAD/FBD FlgNet):
|
|
4
|
+
|
|
5
|
+
1. Build a directed pin-graph from the wires. Each Wire is one source endpoint
|
|
6
|
+
followed by N sink endpoints (fan-out); add a directed edge source -> sink.
|
|
7
|
+
2. Resolve every operand to a display string via operand.render, keyed by UId.
|
|
8
|
+
3. Classify each Part via instructions.lookup -> Category. Unknown name -> the
|
|
9
|
+
part folds to ir.Unhandled (loud, never dropped).
|
|
10
|
+
4. Walk forward from each Powerrail source. Compose power flow into expressions:
|
|
11
|
+
series (A.out -> B.in) -> And
|
|
12
|
+
parallel merged at an "O" node -> Or (n-ary, by cardinality)
|
|
13
|
+
Negated pin -> Not
|
|
14
|
+
comparison (pre/out) -> Compare
|
|
15
|
+
edge (PContact/PBox/...) -> Edge
|
|
16
|
+
The condition feeding a coil's `in` pin is that coil's rung expression.
|
|
17
|
+
5. Coils -> ir.Assign (NORMAL/NEGATED/SET/RESET by part name). Daisy-chained
|
|
18
|
+
coils (Coil_A.out -> Coil_B.in) share the upstream power flow — power leaving
|
|
19
|
+
a coil is the coil's *operand* value, so the second coil reads that variable.
|
|
20
|
+
6. Flip-flops (Rs/Sr) -> ir.FlipFlop. Boxes (TON/Move/...) -> ir.BoxCall with the
|
|
21
|
+
Instance rendered. Calls -> ir.UserCall (parameters from CallInfo).
|
|
22
|
+
7. Latch detection is *structural only*: a coil whose operand reappears inside
|
|
23
|
+
its own rung expression (a seal-in contact reading the coil's variable back
|
|
24
|
+
into its power path) -> mark the Assign is_latch + note. Never inferred from
|
|
25
|
+
block type — a block with no such feedback (e.g. Motor.xml) gets no latch.
|
|
26
|
+
|
|
27
|
+
A readability pass factors a common AND-prefix out of OR branches, so a fan-out
|
|
28
|
+
through a shared chain renders as ``A AND B AND (C OR D)`` rather than the
|
|
29
|
+
expanded ``(A AND B AND C) OR (A AND B AND D)`` (logically identical).
|
|
30
|
+
|
|
31
|
+
SCL networks are handed to scl_reconstruct (not folded). STL/GRAPH networks are
|
|
32
|
+
recorded as warnings in v0 (parsed, rendering deferred).
|
|
33
|
+
|
|
34
|
+
Also builds the cross-reference table and instruction inventory for the sidecar.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from . import instructions, ir, model, operand, scl_reconstruct
|
|
40
|
+
from .instructions import Category
|
|
41
|
+
|
|
42
|
+
# Sentinel for "pure power flow" (the left rail / unconditional TRUE). It is an
|
|
43
|
+
# identity element for AND and never appears as a rendered leaf — it is turned
|
|
44
|
+
# into ir.Literal("TRUE") by _materialize the moment it must become real.
|
|
45
|
+
_POWER = object()
|
|
46
|
+
|
|
47
|
+
_COIL_KIND = {
|
|
48
|
+
"Coil": ir.AssignKind.NORMAL,
|
|
49
|
+
"SCoil": ir.AssignKind.SET,
|
|
50
|
+
"RCoil": ir.AssignKind.RESET,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# set/reset input pin names per flip-flop type (SIMATICML_READING_GUIDE.md).
|
|
54
|
+
_FLIPFLOP_PINS = {
|
|
55
|
+
"Rs": ("s1", "r"),
|
|
56
|
+
"Sr": ("s", "r1"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
# Block + network entry points #
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fold_block(doc: model.Document) -> ir.DecodedBlock:
|
|
66
|
+
"""model.Document -> ir.DecodedBlock (folded networks + xref + inventory)."""
|
|
67
|
+
block = doc.block
|
|
68
|
+
decoded = ir.DecodedBlock(
|
|
69
|
+
name=block.name,
|
|
70
|
+
kind=block.kind.value,
|
|
71
|
+
interface=block.interface,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for network in block.networks:
|
|
75
|
+
logic, folder = _fold(network)
|
|
76
|
+
decoded.networks.append(logic)
|
|
77
|
+
decoded.warnings.extend(logic.warnings)
|
|
78
|
+
if folder is not None:
|
|
79
|
+
for tag, refs in folder.xref.items():
|
|
80
|
+
decoded.xref.setdefault(tag, []).extend(refs)
|
|
81
|
+
for name, count in folder.inventory.items():
|
|
82
|
+
decoded.instruction_inventory[name] = (
|
|
83
|
+
decoded.instruction_inventory.get(name, 0) + count
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return decoded
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fold_network(net: model.Network) -> ir.NetworkLogic:
|
|
90
|
+
"""Fold one network into statements (or reconstructed SCL text)."""
|
|
91
|
+
logic, _folder = _fold(net)
|
|
92
|
+
return logic
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fold(net: model.Network) -> tuple[ir.NetworkLogic, "_NetFolder | None"]:
|
|
96
|
+
"""Fold a network once, returning the logic and (for FlgNet) its folder so
|
|
97
|
+
fold_block can harvest xref/inventory without re-folding."""
|
|
98
|
+
logic = ir.NetworkLogic(
|
|
99
|
+
index=net.index,
|
|
100
|
+
language=net.language.value,
|
|
101
|
+
title=net.title,
|
|
102
|
+
comment=net.comment,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
source = net.source
|
|
106
|
+
if source is None:
|
|
107
|
+
return logic, None # empty network — nothing to render
|
|
108
|
+
|
|
109
|
+
if isinstance(source, model.StructuredText):
|
|
110
|
+
logic.scl_text = scl_reconstruct.reconstruct(source)
|
|
111
|
+
return logic, None
|
|
112
|
+
|
|
113
|
+
if isinstance(source, model.FlgNet):
|
|
114
|
+
folder = _NetFolder(net.index, source)
|
|
115
|
+
logic.statements = folder.statements
|
|
116
|
+
logic.warnings = folder.warnings
|
|
117
|
+
return logic, folder
|
|
118
|
+
|
|
119
|
+
# RawSource: STL / GRAPH / DB — parsed losslessly, rendering deferred in v0.
|
|
120
|
+
lang = getattr(getattr(source, "language", None), "value", net.language.value)
|
|
121
|
+
logic.warnings.append(
|
|
122
|
+
f"Network {net.index}: {lang} network parsed but rendering is deferred in v0"
|
|
123
|
+
)
|
|
124
|
+
return logic, None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# Per-network folder #
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _NetFolder:
|
|
133
|
+
"""Folds a single FlgNet. Holds the pin-graph maps and the memoised eval."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, index: int, net: model.FlgNet) -> None:
|
|
136
|
+
self.index = index
|
|
137
|
+
self.net = net
|
|
138
|
+
|
|
139
|
+
# pin-graph: who drives each sink, and what each source pin fans out to.
|
|
140
|
+
self.pin_driver: dict[tuple[str, str], model.Endpoint] = {}
|
|
141
|
+
self.access_driver: dict[str, model.Endpoint] = {}
|
|
142
|
+
self.pin_out_sinks: dict[tuple[str, str], list[model.Endpoint]] = {}
|
|
143
|
+
self._build_graph()
|
|
144
|
+
|
|
145
|
+
self._memo: dict[tuple[str, str], object] = {}
|
|
146
|
+
self._in_progress: set[tuple[str, str]] = set()
|
|
147
|
+
|
|
148
|
+
self.statements: list[ir.Statement] = []
|
|
149
|
+
self.warnings: list[str] = []
|
|
150
|
+
self.xref: dict[str, list[ir.TagRef]] = {}
|
|
151
|
+
self.inventory: dict[str, int] = {}
|
|
152
|
+
|
|
153
|
+
self._build_inventory()
|
|
154
|
+
self._build_xref()
|
|
155
|
+
self._build_statements()
|
|
156
|
+
|
|
157
|
+
# -- graph ------------------------------------------------------------- #
|
|
158
|
+
def _build_graph(self) -> None:
|
|
159
|
+
for wire in self.net.wires:
|
|
160
|
+
if not wire.endpoints:
|
|
161
|
+
continue
|
|
162
|
+
src, *sinks = wire.endpoints
|
|
163
|
+
for sink in sinks:
|
|
164
|
+
if sink.kind == model.EndpointKind.NAME_CON and sink.uid is not None:
|
|
165
|
+
self.pin_driver[(sink.uid, sink.pin or "")] = src
|
|
166
|
+
elif sink.kind == model.EndpointKind.IDENT_CON and sink.uid is not None:
|
|
167
|
+
self.access_driver[sink.uid] = src
|
|
168
|
+
if src.kind == model.EndpointKind.NAME_CON and src.uid is not None:
|
|
169
|
+
self.pin_out_sinks.setdefault((src.uid, src.pin or ""), []).extend(sinks)
|
|
170
|
+
|
|
171
|
+
def _build_inventory(self) -> None:
|
|
172
|
+
for part in self.net.parts.values():
|
|
173
|
+
self.inventory[part.name] = self.inventory.get(part.name, 0) + 1
|
|
174
|
+
|
|
175
|
+
# -- expression evaluation --------------------------------------------- #
|
|
176
|
+
def _eval_source(self, endpoint: model.Endpoint | None):
|
|
177
|
+
if endpoint is None:
|
|
178
|
+
return _POWER
|
|
179
|
+
kind = endpoint.kind
|
|
180
|
+
if kind == model.EndpointKind.POWERRAIL:
|
|
181
|
+
return _POWER
|
|
182
|
+
if kind == model.EndpointKind.IDENT_CON:
|
|
183
|
+
return self._value_of_access(endpoint.uid)
|
|
184
|
+
if kind == model.EndpointKind.NAME_CON and endpoint.uid is not None:
|
|
185
|
+
return self._eval_part_out(endpoint.uid, endpoint.pin or "")
|
|
186
|
+
# OpenCon / Openbranch as a source: treat as pure power (unconnected).
|
|
187
|
+
return _POWER
|
|
188
|
+
|
|
189
|
+
def _value_of_access(self, uid: str | None):
|
|
190
|
+
access = self.net.accesses.get(uid or "")
|
|
191
|
+
if access is None:
|
|
192
|
+
return ir.Unhandled("Access", uid, "unresolved access reference")
|
|
193
|
+
if isinstance(access.operand, model.Constant):
|
|
194
|
+
return ir.Literal(value=operand.render(access), uid=access.uid)
|
|
195
|
+
return ir.VarRef(name=operand.render(access), uid=access.uid)
|
|
196
|
+
|
|
197
|
+
def _eval_part_out(self, uid: str, pin: str):
|
|
198
|
+
key = (uid, pin)
|
|
199
|
+
if key in self._memo:
|
|
200
|
+
return self._memo[key]
|
|
201
|
+
if key in self._in_progress:
|
|
202
|
+
# Defensive: a wire cycle (not expected in LAD). Break loudly.
|
|
203
|
+
return ir.Unhandled(self.net.parts.get(uid, model.Part(uid, "?")).name,
|
|
204
|
+
uid, "cyclic power-flow reference")
|
|
205
|
+
self._in_progress.add(key)
|
|
206
|
+
result = self._eval_part_out_uncached(uid, pin)
|
|
207
|
+
self._in_progress.discard(key)
|
|
208
|
+
self._memo[key] = result
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
def _eval_part_out_uncached(self, uid: str, pin: str):
|
|
212
|
+
part = self.net.parts.get(uid)
|
|
213
|
+
if part is None:
|
|
214
|
+
return ir.Unhandled("?", uid, "unresolved part reference")
|
|
215
|
+
spec = instructions.lookup(part.name)
|
|
216
|
+
if spec is None:
|
|
217
|
+
return ir.Unhandled(part.name, uid, "no catalog entry")
|
|
218
|
+
|
|
219
|
+
cat = spec.category
|
|
220
|
+
if cat == Category.COIL:
|
|
221
|
+
# Power leaving a coil carries the coil's operand value.
|
|
222
|
+
return self._operand_varref(uid) or _POWER
|
|
223
|
+
if cat == Category.POWER_FLOW:
|
|
224
|
+
incoming = self._driver_expr(uid, spec.power_in)
|
|
225
|
+
cond = self._contact_condition(part, uid)
|
|
226
|
+
return _and([incoming, cond])
|
|
227
|
+
if cat == Category.COMPARISON:
|
|
228
|
+
incoming = self._driver_expr(uid, spec.power_in)
|
|
229
|
+
cmp = ir.Compare(
|
|
230
|
+
op=spec.render or "=",
|
|
231
|
+
left=_materialize(self._driver_expr(uid, "in1")),
|
|
232
|
+
right=_materialize(self._driver_expr(uid, "in2")),
|
|
233
|
+
uid=uid,
|
|
234
|
+
)
|
|
235
|
+
return _and([incoming, cmp])
|
|
236
|
+
if cat == Category.EDGE:
|
|
237
|
+
return self._eval_edge(part, uid, spec)
|
|
238
|
+
if cat == Category.OR_JUNCTION:
|
|
239
|
+
return self._eval_or(uid)
|
|
240
|
+
if cat == Category.FLIPFLOP:
|
|
241
|
+
return self._operand_varref(uid) or _POWER
|
|
242
|
+
if cat == Category.BOX:
|
|
243
|
+
# Reading a box output pin (Q/ET/OUT/RET_VAL): a member of its
|
|
244
|
+
# instance, or a synthetic name when there is no instance.
|
|
245
|
+
label = self._box_label(part)
|
|
246
|
+
return ir.VarRef(name=f"{label}.{pin}", uid=uid)
|
|
247
|
+
return ir.Unhandled(part.name, uid, "unclassified category")
|
|
248
|
+
|
|
249
|
+
def _eval_edge(self, part: model.Part, uid: str, spec) -> ir.Expr:
|
|
250
|
+
kind = ir.EdgeKind.FALLING if spec.render == "falling" else ir.EdgeKind.RISING
|
|
251
|
+
mem_bit = self._pin_varref(uid, "bit")
|
|
252
|
+
if spec.power_in == "in":
|
|
253
|
+
# PBox/NBox: the incoming power flow *is* the monitored signal.
|
|
254
|
+
signal = _materialize(self._driver_expr(uid, "in"))
|
|
255
|
+
return ir.Edge(kind=kind, signal=signal, mem_bit=mem_bit, uid=uid)
|
|
256
|
+
# PContact/NContact: monitored signal is `operand`, power flow is `pre`.
|
|
257
|
+
incoming = self._driver_expr(uid, spec.power_in or "pre")
|
|
258
|
+
signal = self._operand_varref(uid) or ir.Literal("TRUE")
|
|
259
|
+
edge = ir.Edge(kind=kind, signal=signal, mem_bit=mem_bit, uid=uid)
|
|
260
|
+
return _and([incoming, edge])
|
|
261
|
+
|
|
262
|
+
def _eval_or(self, uid: str) -> ir.Expr:
|
|
263
|
+
branches = []
|
|
264
|
+
for pin in self._sorted_input_pins(uid):
|
|
265
|
+
branches.append(_materialize(self._driver_expr(uid, pin)))
|
|
266
|
+
if not branches:
|
|
267
|
+
return _POWER
|
|
268
|
+
return _factor_or(branches)
|
|
269
|
+
|
|
270
|
+
# -- helpers used by eval ---------------------------------------------- #
|
|
271
|
+
def _driver_expr(self, uid: str, pin: str | None):
|
|
272
|
+
if pin is None:
|
|
273
|
+
return _POWER
|
|
274
|
+
return self._eval_source(self.pin_driver.get((uid, pin)))
|
|
275
|
+
|
|
276
|
+
def _contact_condition(self, part: model.Part, uid: str) -> ir.Expr:
|
|
277
|
+
var = self._operand_varref(uid) or ir.Literal("TRUE")
|
|
278
|
+
if "operand" in part.negated_pins:
|
|
279
|
+
return ir.Not(operand=var)
|
|
280
|
+
return var
|
|
281
|
+
|
|
282
|
+
def _operand_access(self, uid: str) -> model.Access | None:
|
|
283
|
+
endpoint = self.pin_driver.get((uid, "operand"))
|
|
284
|
+
if endpoint is not None and endpoint.kind == model.EndpointKind.IDENT_CON:
|
|
285
|
+
return self.net.accesses.get(endpoint.uid or "")
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def _operand_varref(self, uid: str) -> ir.VarRef | None:
|
|
289
|
+
access = self._operand_access(uid)
|
|
290
|
+
if access is None:
|
|
291
|
+
return None
|
|
292
|
+
return ir.VarRef(name=operand.render(access), uid=access.uid)
|
|
293
|
+
|
|
294
|
+
def _pin_varref(self, uid: str, pin: str) -> ir.VarRef | None:
|
|
295
|
+
endpoint = self.pin_driver.get((uid, pin))
|
|
296
|
+
if endpoint is not None and endpoint.kind == model.EndpointKind.IDENT_CON:
|
|
297
|
+
access = self.net.accesses.get(endpoint.uid or "")
|
|
298
|
+
if access is not None:
|
|
299
|
+
return ir.VarRef(name=operand.render(access), uid=access.uid)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
def _sorted_input_pins(self, uid: str) -> list[str]:
|
|
303
|
+
pins = [p for (u, p) in self.pin_driver if u == uid]
|
|
304
|
+
return sorted(pins, key=_pin_sort_key)
|
|
305
|
+
|
|
306
|
+
def _box_label(self, part: model.Part) -> str:
|
|
307
|
+
if part.instance is not None:
|
|
308
|
+
return _render_instance(part.instance)
|
|
309
|
+
return f"#{part.name}_{part.uid}"
|
|
310
|
+
|
|
311
|
+
# -- statement generation ---------------------------------------------- #
|
|
312
|
+
def _build_statements(self) -> None:
|
|
313
|
+
produced: list[tuple[int, ir.Statement]] = []
|
|
314
|
+
|
|
315
|
+
for uid, part in self.net.parts.items():
|
|
316
|
+
stmt = self._statement_for_part(uid, part)
|
|
317
|
+
if stmt is not None:
|
|
318
|
+
produced.append((_uid_key(uid), stmt))
|
|
319
|
+
|
|
320
|
+
for uid, call in self.net.calls.items():
|
|
321
|
+
produced.append((_uid_key(uid), self._statement_for_call(uid, call)))
|
|
322
|
+
|
|
323
|
+
produced.sort(key=lambda pair: pair[0])
|
|
324
|
+
self.statements = [stmt for _, stmt in produced]
|
|
325
|
+
|
|
326
|
+
def _statement_for_part(self, uid: str, part: model.Part) -> ir.Statement | None:
|
|
327
|
+
spec = instructions.lookup(part.name)
|
|
328
|
+
if spec is None:
|
|
329
|
+
note = f"unknown instruction '{part.name}'"
|
|
330
|
+
self.warnings.append(f"Network {self.index}: {note} (UId {uid})")
|
|
331
|
+
return ir.Unhandled(part_name=part.name, uid=uid, note=note)
|
|
332
|
+
|
|
333
|
+
cat = spec.category
|
|
334
|
+
if cat == Category.COIL:
|
|
335
|
+
return self._make_assign(uid, part, spec)
|
|
336
|
+
if cat == Category.FLIPFLOP:
|
|
337
|
+
return self._make_flipflop(uid, part)
|
|
338
|
+
if cat == Category.BOX:
|
|
339
|
+
return self._make_box(uid, part, spec)
|
|
340
|
+
# Contacts / comparisons / edges / OR are sub-expressions, not statements.
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def _make_assign(self, uid: str, part: model.Part, spec) -> ir.Statement:
|
|
344
|
+
target = self._operand_varref(uid)
|
|
345
|
+
if target is None:
|
|
346
|
+
note = f"coil '{part.name}' has no operand"
|
|
347
|
+
self.warnings.append(f"Network {self.index}: {note} (UId {uid})")
|
|
348
|
+
return ir.Unhandled(part_name=part.name, uid=uid, note=note)
|
|
349
|
+
|
|
350
|
+
kind = _COIL_KIND.get(part.name, ir.AssignKind.NORMAL)
|
|
351
|
+
if "operand" in part.negated_pins:
|
|
352
|
+
kind = ir.AssignKind.NEGATED
|
|
353
|
+
|
|
354
|
+
value = _materialize(self._driver_expr(uid, spec.power_in or "in"))
|
|
355
|
+
is_latch = _contains_var(value, target.name)
|
|
356
|
+
note = None
|
|
357
|
+
if is_latch:
|
|
358
|
+
note = f"seal-in latch: {target.name} feeds back into its own rung"
|
|
359
|
+
|
|
360
|
+
return ir.Assign(target=target, value=value, kind=kind,
|
|
361
|
+
is_latch=is_latch, note=note, uid=uid)
|
|
362
|
+
|
|
363
|
+
def _make_flipflop(self, uid: str, part: model.Part) -> ir.Statement:
|
|
364
|
+
target = self._operand_varref(uid)
|
|
365
|
+
if target is None:
|
|
366
|
+
note = f"flip-flop '{part.name}' has no operand"
|
|
367
|
+
self.warnings.append(f"Network {self.index}: {note} (UId {uid})")
|
|
368
|
+
return ir.Unhandled(part_name=part.name, uid=uid, note=note)
|
|
369
|
+
set_pin, reset_pin = _FLIPFLOP_PINS.get(part.name, ("s", "r"))
|
|
370
|
+
return ir.FlipFlop(
|
|
371
|
+
target=target,
|
|
372
|
+
set_expr=_materialize(self._driver_expr(uid, set_pin)),
|
|
373
|
+
reset_expr=_materialize(self._driver_expr(uid, reset_pin)),
|
|
374
|
+
reset_priority=(part.name == "Rs"),
|
|
375
|
+
uid=uid,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _make_box(self, uid: str, part: model.Part, spec) -> ir.Statement:
|
|
379
|
+
inputs: dict[str, ir.Expr] = {}
|
|
380
|
+
outputs: dict[str, ir.VarRef] = {}
|
|
381
|
+
enable: ir.Expr | None = None
|
|
382
|
+
|
|
383
|
+
for (u, pin), endpoint in self.pin_driver.items():
|
|
384
|
+
if u != uid:
|
|
385
|
+
continue
|
|
386
|
+
expr = self._eval_source(endpoint)
|
|
387
|
+
if pin == spec.power_in: # the en pin
|
|
388
|
+
if expr is not _POWER:
|
|
389
|
+
enable = _materialize(expr)
|
|
390
|
+
else:
|
|
391
|
+
inputs[pin] = _materialize(expr)
|
|
392
|
+
|
|
393
|
+
for (u, pin), sinks in self.pin_out_sinks.items():
|
|
394
|
+
if u != uid or pin == spec.power_out:
|
|
395
|
+
continue
|
|
396
|
+
for sink in sinks:
|
|
397
|
+
if sink.kind == model.EndpointKind.IDENT_CON:
|
|
398
|
+
access = self.net.accesses.get(sink.uid or "")
|
|
399
|
+
if access is not None:
|
|
400
|
+
outputs[pin] = ir.VarRef(name=operand.render(access),
|
|
401
|
+
uid=access.uid)
|
|
402
|
+
break
|
|
403
|
+
|
|
404
|
+
instance = _render_instance(part.instance) if part.instance else None
|
|
405
|
+
if spec.render == "equation" and part.equation:
|
|
406
|
+
inputs["__equation__"] = ir.RawExpr(text=part.equation, uid=uid)
|
|
407
|
+
|
|
408
|
+
return ir.BoxCall(
|
|
409
|
+
instruction=part.name,
|
|
410
|
+
inputs=inputs,
|
|
411
|
+
outputs=outputs,
|
|
412
|
+
instance=instance,
|
|
413
|
+
enable=enable,
|
|
414
|
+
uid=uid,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _statement_for_call(self, uid: str, call: model.Call) -> ir.Statement:
|
|
418
|
+
params: dict[str, ir.Expr | ir.VarRef] = {}
|
|
419
|
+
for param in call.parameters:
|
|
420
|
+
endpoint = self.pin_driver.get((uid, param.name))
|
|
421
|
+
if endpoint is not None:
|
|
422
|
+
params[param.name] = _materialize(self._eval_source(endpoint))
|
|
423
|
+
continue
|
|
424
|
+
sinks = self.pin_out_sinks.get((uid, param.name), [])
|
|
425
|
+
for sink in sinks:
|
|
426
|
+
if sink.kind == model.EndpointKind.IDENT_CON:
|
|
427
|
+
access = self.net.accesses.get(sink.uid or "")
|
|
428
|
+
if access is not None:
|
|
429
|
+
params[param.name] = ir.VarRef(name=operand.render(access),
|
|
430
|
+
uid=access.uid)
|
|
431
|
+
break
|
|
432
|
+
|
|
433
|
+
enable_endpoint = self.pin_driver.get((uid, "en"))
|
|
434
|
+
enable = None
|
|
435
|
+
if enable_endpoint is not None:
|
|
436
|
+
expr = self._eval_source(enable_endpoint)
|
|
437
|
+
if expr is not _POWER:
|
|
438
|
+
enable = _materialize(expr)
|
|
439
|
+
|
|
440
|
+
return ir.UserCall(
|
|
441
|
+
name=call.name,
|
|
442
|
+
block_type=call.block_type,
|
|
443
|
+
instance=_render_instance(call.instance) if call.instance else None,
|
|
444
|
+
params=params,
|
|
445
|
+
enable=enable,
|
|
446
|
+
uid=uid,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# -- cross-reference --------------------------------------------------- #
|
|
450
|
+
def _build_xref(self) -> None:
|
|
451
|
+
for wire in self.net.wires:
|
|
452
|
+
if not wire.endpoints:
|
|
453
|
+
continue
|
|
454
|
+
src, *sinks = wire.endpoints
|
|
455
|
+
if src.kind == model.EndpointKind.IDENT_CON:
|
|
456
|
+
for sink in sinks:
|
|
457
|
+
if sink.kind == model.EndpointKind.NAME_CON and sink.uid is not None:
|
|
458
|
+
role = self._role_for_input(sink.uid, sink.pin or "")
|
|
459
|
+
self._add_xref(src.uid, role)
|
|
460
|
+
elif src.kind == model.EndpointKind.NAME_CON:
|
|
461
|
+
for sink in sinks:
|
|
462
|
+
if sink.kind == model.EndpointKind.IDENT_CON:
|
|
463
|
+
self._add_xref(sink.uid, "write")
|
|
464
|
+
|
|
465
|
+
def _role_for_input(self, target_uid: str, pin: str) -> str:
|
|
466
|
+
call = self.net.calls.get(target_uid)
|
|
467
|
+
if call is not None:
|
|
468
|
+
section = next(
|
|
469
|
+
(p.section for p in call.parameters if p.name == pin), ""
|
|
470
|
+
)
|
|
471
|
+
return {
|
|
472
|
+
"Input": "read",
|
|
473
|
+
"Output": "write",
|
|
474
|
+
"InOut": "readwrite",
|
|
475
|
+
"Return": "write",
|
|
476
|
+
}.get(section, "read")
|
|
477
|
+
|
|
478
|
+
part = self.net.parts.get(target_uid)
|
|
479
|
+
if part is not None and pin == "operand":
|
|
480
|
+
spec = instructions.lookup(part.name)
|
|
481
|
+
if spec is not None and spec.category in (Category.COIL, Category.FLIPFLOP):
|
|
482
|
+
return "write"
|
|
483
|
+
if part.name in ("Inc", "Dec"):
|
|
484
|
+
return "readwrite"
|
|
485
|
+
return "read"
|
|
486
|
+
|
|
487
|
+
def _add_xref(self, access_uid: str | None, role: str) -> None:
|
|
488
|
+
access = self.net.accesses.get(access_uid or "")
|
|
489
|
+
if access is None or isinstance(access.operand, model.Constant):
|
|
490
|
+
return # constants are not tags
|
|
491
|
+
if access.operand is None:
|
|
492
|
+
return
|
|
493
|
+
tag = _tag_name(access)
|
|
494
|
+
self.xref.setdefault(tag, []).append(
|
|
495
|
+
ir.TagRef(network_index=self.index, uid=access.uid, role=role)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# --------------------------------------------------------------------------- #
|
|
500
|
+
# Expression construction helpers #
|
|
501
|
+
# --------------------------------------------------------------------------- #
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _materialize(expr):
|
|
505
|
+
"""Turn the _POWER sentinel into a concrete TRUE; pass anything else through."""
|
|
506
|
+
if expr is _POWER:
|
|
507
|
+
return ir.Literal(value="TRUE")
|
|
508
|
+
return expr
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _and(parts: list) -> object:
|
|
512
|
+
"""AND-combine, dropping pure-power operands and flattening nested ANDs."""
|
|
513
|
+
flat: list[ir.Expr] = []
|
|
514
|
+
for part in parts:
|
|
515
|
+
if part is _POWER:
|
|
516
|
+
continue
|
|
517
|
+
if isinstance(part, ir.And):
|
|
518
|
+
flat.extend(part.operands)
|
|
519
|
+
else:
|
|
520
|
+
flat.append(part)
|
|
521
|
+
if not flat:
|
|
522
|
+
return _POWER
|
|
523
|
+
if len(flat) == 1:
|
|
524
|
+
return flat[0]
|
|
525
|
+
return ir.And(operands=flat)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _factor_or(branches: list[ir.Expr]) -> ir.Expr:
|
|
529
|
+
"""OR the branches, factoring a shared leading AND-prefix out front.
|
|
530
|
+
|
|
531
|
+
``(A AND B AND C) OR (A AND B AND D)`` -> ``A AND B AND (C OR D)``. This makes
|
|
532
|
+
a fan-out through a shared contact chain read the way TIA draws it. Purely a
|
|
533
|
+
readability rewrite — logically identical.
|
|
534
|
+
"""
|
|
535
|
+
operands: list[ir.Expr] = []
|
|
536
|
+
for branch in branches:
|
|
537
|
+
if isinstance(branch, ir.Or):
|
|
538
|
+
operands.extend(branch.operands)
|
|
539
|
+
else:
|
|
540
|
+
operands.append(branch)
|
|
541
|
+
|
|
542
|
+
if len(operands) == 1:
|
|
543
|
+
return operands[0]
|
|
544
|
+
|
|
545
|
+
term_lists = [
|
|
546
|
+
list(op.operands) if isinstance(op, ir.And) else [op] for op in operands
|
|
547
|
+
]
|
|
548
|
+
|
|
549
|
+
prefix: list[ir.Expr] = []
|
|
550
|
+
i = 0
|
|
551
|
+
while all(len(terms) > i for terms in term_lists) and all(
|
|
552
|
+
_expr_key(terms[i]) == _expr_key(term_lists[0][i]) for terms in term_lists
|
|
553
|
+
):
|
|
554
|
+
prefix.append(term_lists[0][i])
|
|
555
|
+
i += 1
|
|
556
|
+
|
|
557
|
+
if not prefix:
|
|
558
|
+
return ir.Or(operands=operands)
|
|
559
|
+
|
|
560
|
+
remainders: list[ir.Expr] = []
|
|
561
|
+
for terms in term_lists:
|
|
562
|
+
tail = terms[i:]
|
|
563
|
+
if not tail:
|
|
564
|
+
remainders.append(ir.Literal(value="TRUE"))
|
|
565
|
+
elif len(tail) == 1:
|
|
566
|
+
remainders.append(tail[0])
|
|
567
|
+
else:
|
|
568
|
+
remainders.append(ir.And(operands=tail))
|
|
569
|
+
|
|
570
|
+
inner = ir.Or(operands=remainders)
|
|
571
|
+
return ir.And(operands=[*prefix, inner])
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _expr_key(expr) -> tuple:
|
|
575
|
+
"""Structural signature ignoring source UIds, for prefix comparison."""
|
|
576
|
+
if isinstance(expr, ir.VarRef):
|
|
577
|
+
return ("var", expr.name)
|
|
578
|
+
if isinstance(expr, ir.Literal):
|
|
579
|
+
return ("lit", expr.value)
|
|
580
|
+
if isinstance(expr, ir.Not):
|
|
581
|
+
return ("not", _expr_key(expr.operand))
|
|
582
|
+
if isinstance(expr, ir.And):
|
|
583
|
+
return ("and", tuple(_expr_key(o) for o in expr.operands))
|
|
584
|
+
if isinstance(expr, ir.Or):
|
|
585
|
+
return ("or", tuple(_expr_key(o) for o in expr.operands))
|
|
586
|
+
if isinstance(expr, ir.Compare):
|
|
587
|
+
return ("cmp", expr.op, _expr_key(expr.left), _expr_key(expr.right))
|
|
588
|
+
if isinstance(expr, ir.Edge):
|
|
589
|
+
bit = expr.mem_bit.name if expr.mem_bit else None
|
|
590
|
+
return ("edge", expr.kind.value, _expr_key(expr.signal), bit)
|
|
591
|
+
if isinstance(expr, ir.RawExpr):
|
|
592
|
+
return ("raw", expr.text)
|
|
593
|
+
if isinstance(expr, ir.Unhandled):
|
|
594
|
+
return ("unhandled", expr.part_name, expr.uid)
|
|
595
|
+
return ("?", repr(expr))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _contains_var(expr, name: str) -> bool:
|
|
599
|
+
"""True if a VarRef with this name appears anywhere in the expression tree."""
|
|
600
|
+
if isinstance(expr, ir.VarRef):
|
|
601
|
+
return expr.name == name
|
|
602
|
+
if isinstance(expr, ir.Literal):
|
|
603
|
+
return False
|
|
604
|
+
if isinstance(expr, ir.Not):
|
|
605
|
+
return _contains_var(expr.operand, name)
|
|
606
|
+
if isinstance(expr, (ir.And, ir.Or)):
|
|
607
|
+
return any(_contains_var(o, name) for o in expr.operands)
|
|
608
|
+
if isinstance(expr, ir.Compare):
|
|
609
|
+
return _contains_var(expr.left, name) or _contains_var(expr.right, name)
|
|
610
|
+
if isinstance(expr, ir.Edge):
|
|
611
|
+
return _contains_var(expr.signal, name) or (
|
|
612
|
+
expr.mem_bit is not None and expr.mem_bit.name == name
|
|
613
|
+
)
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# --------------------------------------------------------------------------- #
|
|
618
|
+
# Small leaf helpers #
|
|
619
|
+
# --------------------------------------------------------------------------- #
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _render_instance(instance: model.Instance) -> str:
|
|
623
|
+
"""Render an Instance (system FB backing member) as a display name."""
|
|
624
|
+
access = model.Access(
|
|
625
|
+
uid="",
|
|
626
|
+
scope=instance.scope or "LocalVariable",
|
|
627
|
+
operand=model.Symbol(components=instance.components),
|
|
628
|
+
)
|
|
629
|
+
return operand.render(access)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _tag_name(access: model.Access) -> str:
|
|
633
|
+
name = operand.render(access)
|
|
634
|
+
return name[1:] if name.startswith("#") else name
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _pin_sort_key(pin: str) -> tuple:
|
|
638
|
+
"""Order pins so in1 < in2 < ... < in10 (numeric suffix aware)."""
|
|
639
|
+
digits = ""
|
|
640
|
+
i = len(pin)
|
|
641
|
+
while i > 0 and pin[i - 1].isdigit():
|
|
642
|
+
i -= 1
|
|
643
|
+
digits = pin[i:]
|
|
644
|
+
base = pin[:i]
|
|
645
|
+
return (base, int(digits) if digits else -1)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _uid_key(uid: str) -> int:
|
|
649
|
+
try:
|
|
650
|
+
return int(uid)
|
|
651
|
+
except (ValueError, TypeError):
|
|
652
|
+
return 1 << 30
|