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,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