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,105 @@
1
+ """The instruction catalog — deliberately *data, not logic*.
2
+
3
+ fold.py reasons about pin *categories* (power-flow in/out, comparison pre/out,
4
+ box en/eno, flip-flop, OR-junction), never about specific instruction names.
5
+ This table maps Part Name -> category + pin vocabulary + a render hint. Adding a
6
+ new instruction (TOF, CTU, NContact, ...) is a row here, not a change to the
7
+ traversal. That is what lets v0's "parse-but-flag everything else" degrade
8
+ gracefully: a name absent from this table folds to an ir.Unhandled node.
9
+
10
+ Pin names per the Part Pin Vocabulary in SIMATICML_READING_GUIDE.md. Entries are
11
+ the confirmed instructions from the six sample blocks plus their clearly-symmetric
12
+ siblings (TOF/TP, Sr, NBox/NContact, the comparison family).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+
20
+
21
+ class Category(str, Enum):
22
+ POWER_FLOW = "power_flow" # in/out, contact-like: passes or blocks power
23
+ COIL = "coil" # in/out, writes its operand
24
+ OR_JUNCTION = "or_junction" # merges parallel branches (Part Name="O")
25
+ COMPARISON = "comparison" # pre/out, contact-like compare
26
+ EDGE = "edge" # pre/out or in/out, rising/falling detection
27
+ FLIPFLOP = "flipflop" # named set/reset inputs -> q
28
+ BOX = "box" # en/eno operation box (Move/Add/Inc/timers/system FC)
29
+
30
+
31
+ @dataclass
32
+ class Spec:
33
+ name: str
34
+ category: Category
35
+ power_in: str | None = None # name of the power-in pin, if any
36
+ power_out: str | None = None # name of the power-out pin, if any
37
+ render: str | None = None # operator/keyword hint for emit (":=", "<", ...)
38
+ pins: tuple[str, ...] = field(default_factory=tuple) # informative pin list
39
+ note: str = ""
40
+
41
+
42
+ # Helper constructors keep the table compact and readable.
43
+ def _pf(name, render=None):
44
+ return Spec(name, Category.POWER_FLOW, "in", "out", render, ("in", "operand", "out"))
45
+
46
+
47
+ def _coil(name, render):
48
+ return Spec(name, Category.COIL, "in", "out", render, ("in", "operand", "out"))
49
+
50
+
51
+ def _cmp(name, op):
52
+ return Spec(name, Category.COMPARISON, "pre", "out", op, ("pre", "in1", "in2", "out"))
53
+
54
+
55
+ def _box(name, pins, render=None):
56
+ return Spec(name, Category.BOX, "en", "eno", render, pins)
57
+
58
+
59
+ CATALOG: dict[str, Spec] = {
60
+ # --- power flow -------------------------------------------------------- #
61
+ "Contact": _pf("Contact"),
62
+ "Coil": _coil("Coil", ":="),
63
+ "SCoil": _coil("SCoil", "S"), # set coil ( S )
64
+ "RCoil": _coil("RCoil", "R"), # reset coil ( R )
65
+ "O": Spec("O", Category.OR_JUNCTION, None, "out", "OR", ("in1", "in2", "out")),
66
+ # --- comparisons (pre/out, contact-like) ------------------------------ #
67
+ "Lt": _cmp("Lt", "<"),
68
+ "Le": _cmp("Le", "<="),
69
+ "Eq": _cmp("Eq", "="),
70
+ "Ne": _cmp("Ne", "<>"),
71
+ "Ge": _cmp("Ge", ">="),
72
+ "Gt": _cmp("Gt", ">"),
73
+ # --- edge detection ---------------------------------------------------- #
74
+ "PContact": Spec("PContact", Category.EDGE, "pre", "out", "rising",
75
+ ("pre", "operand", "bit", "out")),
76
+ "NContact": Spec("NContact", Category.EDGE, "pre", "out", "falling",
77
+ ("pre", "operand", "bit", "out")),
78
+ "PBox": Spec("PBox", Category.EDGE, "in", "out", "rising", ("in", "bit", "out")),
79
+ "NBox": Spec("NBox", Category.EDGE, "in", "out", "falling", ("in", "bit", "out")),
80
+ # --- flip-flops -------------------------------------------------------- #
81
+ "Rs": Spec("Rs", Category.FLIPFLOP, None, "q", "reset_priority",
82
+ ("s1", "r", "operand", "q")),
83
+ "Sr": Spec("Sr", Category.FLIPFLOP, None, "q", "set_priority",
84
+ ("s", "r1", "operand", "q")),
85
+ # --- boxes (en/eno) ---------------------------------------------------- #
86
+ "Move": _box("Move", ("en", "in", "out1", "eno"), ":="),
87
+ "Add": _box("Add", ("en", "in1", "in2", "out", "eno"), "+"),
88
+ "Sub": _box("Sub", ("en", "in1", "in2", "out", "eno"), "-"),
89
+ "Mul": _box("Mul", ("en", "in1", "in2", "out", "eno"), "*"),
90
+ "Div": _box("Div", ("en", "in1", "in2", "out", "eno"), "/"),
91
+ "Inc": _box("Inc", ("en", "operand", "eno"), "+1"),
92
+ "Dec": _box("Dec", ("en", "operand", "eno"), "-1"),
93
+ "Calculate": _box("Calculate", ("en", "eno"), "equation"),
94
+ # --- IEC timers (system FBs; UPPERCASE pins; need Instance) ------------ #
95
+ "TON": _box("TON", ("IN", "PT", "Q", "ET")),
96
+ "TOF": _box("TOF", ("IN", "PT", "Q", "ET")),
97
+ "TP": _box("TP", ("IN", "PT", "Q", "ET")),
98
+ # --- system FC example ------------------------------------------------- #
99
+ "RD_LOC_T": _box("RD_LOC_T", ("en", "RET_VAL", "OUT", "eno")),
100
+ }
101
+
102
+
103
+ def lookup(name: str) -> Spec | None:
104
+ """Return the Spec for a Part name, or None (caller folds to ir.Unhandled)."""
105
+ return CATALOG.get(name)
@@ -0,0 +1,182 @@
1
+ """Phase 2 output: the folded *semantics*, with no memory of the XML.
2
+
3
+ A network becomes an ordered list of statements. Rung conditions become a boolean
4
+ expression tree. Two cross-cutting commitments live here from the start:
5
+
6
+ * Traceability — every node carries the source ``uid``(s) it came from, so emit
7
+ can map any rendered claim back to the net it was derived from.
8
+ * Loud failure — anything the folder could not interpret becomes an ``Unhandled``
9
+ node carrying its part name + uid, so emit can surface it visibly instead of a
10
+ silent omission (the worst possible failure in authoritative-looking SCL).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
17
+
18
+ # --------------------------------------------------------------------------- #
19
+ # Boolean / value expressions (rung conditions, comparison operands) #
20
+ # --------------------------------------------------------------------------- #
21
+ @dataclass
22
+ class VarRef:
23
+ """A resolved operand, already rendered to its display form by operand.py."""
24
+
25
+ name: str # e.g. "#FI_Forward", '"DB".field', "%MW100"
26
+ uid: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class Literal:
31
+ value: str # rendered constant, e.g. "0", "TRUE", "T#3s"
32
+ uid: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class Not:
37
+ operand: Expr
38
+
39
+
40
+ @dataclass
41
+ class And:
42
+ operands: list[Expr] = field(default_factory=list)
43
+
44
+
45
+ @dataclass
46
+ class Or:
47
+ operands: list[Expr] = field(default_factory=list) # n-ary, matches O cardinality
48
+
49
+
50
+ @dataclass
51
+ class Compare:
52
+ op: str # "<" | "<=" | "=" | ">=" | ">" | "<>"
53
+ left: Expr
54
+ right: Expr
55
+ uid: str | None = None
56
+
57
+
58
+ class EdgeKind(str, Enum):
59
+ RISING = "rising" # P_TRIG / PContact / PBox
60
+ FALLING = "falling" # N_TRIG / NContact / NBox
61
+
62
+
63
+ @dataclass
64
+ class Edge:
65
+ kind: EdgeKind
66
+ signal: Expr # monitored signal / incoming power flow
67
+ mem_bit: VarRef | None = None # edge-memory operand
68
+ uid: str | None = None
69
+
70
+
71
+ @dataclass
72
+ class RawExpr:
73
+ """Free-form expression text taken verbatim (e.g. a Calculate-box Equation)."""
74
+
75
+ text: str
76
+ uid: str | None = None
77
+
78
+
79
+ @dataclass
80
+ class Unhandled:
81
+ """A construct parsed but not folded. Rendered loudly by emit, never dropped."""
82
+
83
+ part_name: str
84
+ uid: str | None = None
85
+ note: str = ""
86
+
87
+
88
+ Expr = (
89
+ VarRef | Literal | Not | And | Or | Compare | Edge | RawExpr | Unhandled
90
+ )
91
+
92
+
93
+ # --------------------------------------------------------------------------- #
94
+ # Statements (one rung's effect) #
95
+ # --------------------------------------------------------------------------- #
96
+ class AssignKind(str, Enum):
97
+ NORMAL = "normal" # Coil -> target := <cond>;
98
+ NEGATED = "negated" # negated Coil -> target := NOT <cond>;
99
+ SET = "set" # SCoil -> IF <cond> THEN target := TRUE;
100
+ RESET = "reset" # RCoil -> IF <cond> THEN target := FALSE;
101
+
102
+
103
+ @dataclass
104
+ class Assign:
105
+ target: VarRef
106
+ value: Expr
107
+ kind: AssignKind = AssignKind.NORMAL
108
+ is_latch: bool = False # set when a structural seal-in was detected
109
+ note: str | None = None # surfaced by emit when load-bearing
110
+ uid: str | None = None
111
+
112
+
113
+ @dataclass
114
+ class FlipFlop:
115
+ """Rs / Sr — reset- or set-priority bistable on a stored operand."""
116
+
117
+ target: VarRef
118
+ set_expr: Expr
119
+ reset_expr: Expr
120
+ reset_priority: bool = True # Rs == reset-dominant; Sr == set-dominant
121
+ uid: str | None = None
122
+
123
+
124
+ @dataclass
125
+ class BoxCall:
126
+ """A system FB/FC box: TON/TOF/TP, Move/Add/Inc, RD_LOC_T, ..."""
127
+
128
+ instruction: str # "TON", "Move", ...
129
+ inputs: dict[str, Expr] = field(default_factory=dict) # pin -> expression
130
+ outputs: dict[str, VarRef] = field(default_factory=dict) # pin -> destination
131
+ instance: str | None = None # rendered instance name, for system FBs
132
+ enable: Expr | None = None # the en / IN power-flow condition
133
+ uid: str | None = None
134
+
135
+
136
+ @dataclass
137
+ class UserCall:
138
+ """A Call/CallInfo to a user FC/FB — parameters self-documented in the XML."""
139
+
140
+ name: str
141
+ block_type: str # FC | FB
142
+ instance: str | None = None
143
+ params: dict[str, Expr | VarRef] = field(default_factory=dict)
144
+ enable: Expr | None = None
145
+ uid: str | None = None
146
+
147
+
148
+ Statement = Assign | FlipFlop | BoxCall | UserCall | Unhandled
149
+
150
+
151
+ # --------------------------------------------------------------------------- #
152
+ # Network + block level #
153
+ # --------------------------------------------------------------------------- #
154
+ @dataclass
155
+ class NetworkLogic:
156
+ index: int
157
+ language: str
158
+ title: str | None = None
159
+ comment: str | None = None
160
+ statements: list[Statement] = field(default_factory=list) # folded LAD/FBD
161
+ scl_text: str | None = None # set for reconstructed SCL networks
162
+ warnings: list[str] = field(default_factory=list)
163
+
164
+
165
+ @dataclass
166
+ class TagRef:
167
+ """One side of the cross-reference table: where a tag is written / read."""
168
+
169
+ network_index: int
170
+ uid: str | None = None
171
+ role: str = "" # "write" | "read" + context (pin/operand)
172
+
173
+
174
+ @dataclass
175
+ class DecodedBlock:
176
+ name: str
177
+ kind: str
178
+ interface: object # model.Interface, carried through for the sidecar
179
+ networks: list[NetworkLogic] = field(default_factory=list)
180
+ xref: dict[str, list[TagRef]] = field(default_factory=dict) # tag -> uses
181
+ instruction_inventory: dict[str, int] = field(default_factory=dict)
182
+ warnings: list[str] = field(default_factory=list)
@@ -0,0 +1,264 @@
1
+ """Phase 1 output: a faithful, behaviour-free mirror of the SimaticML XML.
2
+
3
+ This layer mirrors the *syntax* of the export (blocks, sections, parts, wires)
4
+ exactly as it appears — it does not interpret logic. Interpretation happens in
5
+ fold.py against ir.py. Keeping the two apart is the front-end/middle-end split:
6
+ adding FBD (same FlgNet) or a new output target touches one phase, not all three.
7
+
8
+ Field coverage follows the "Stable Parser Model" in SIMATICML_READING_GUIDE.md.
9
+ Large open enumerations (the 20 Access scopes, 13 address areas) are kept as
10
+ plain ``str`` plus a ``raw`` escape hatch rather than exhaustive enums, so an
11
+ unfamiliar value round-trips instead of raising.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+
19
+
20
+ class BlockKind(str, Enum):
21
+ FC = "FC"
22
+ FB = "FB"
23
+ OB = "OB"
24
+ DB = "DB"
25
+ UNKNOWN = "UNKNOWN"
26
+
27
+
28
+ class Language(str, Enum):
29
+ """Per-compile-unit programming language. Subset we render in v0 + passthrough."""
30
+
31
+ LAD = "LAD"
32
+ FBD = "FBD"
33
+ SCL = "SCL"
34
+ STL = "STL"
35
+ GRAPH = "GRAPH"
36
+ OTHER = "OTHER" # DB / SDB / IEC variants etc. — parsed, not rendered in v0
37
+
38
+
39
+ # ----------------------------------------------------------------------------- #
40
+ # Interface (block signature + local declarations) #
41
+ # ----------------------------------------------------------------------------- #
42
+ @dataclass
43
+ class Member:
44
+ name: str
45
+ datatype: str # may be a quoted UDT ref, e.g. '"PLC_System"'
46
+ is_udt: bool = False # True when datatype was quoted in the XML
47
+ version: str | None = None # system/complex types (e.g. TON_TIME "1.0")
48
+ start_value: str | None = None # TIA literal, e.g. "T#3s", "16#0"
49
+ remanence: str | None = None # e.g. "Retain"
50
+ comment: str | None = None
51
+ children: list[Member] = field(default_factory=list) # nested struct members
52
+ raw: dict = field(default_factory=dict)
53
+
54
+
55
+ @dataclass
56
+ class Section:
57
+ name: str # Input | Output | InOut | Static | Temp | Constant | Return
58
+ members: list[Member] = field(default_factory=list)
59
+
60
+
61
+ @dataclass
62
+ class Interface:
63
+ sections: list[Section] = field(default_factory=list)
64
+
65
+
66
+ # ----------------------------------------------------------------------------- #
67
+ # Operands referenced by Access nodes #
68
+ # ----------------------------------------------------------------------------- #
69
+ @dataclass
70
+ class Component:
71
+ """One level of a (possibly dotted) symbol path, e.g. System.CLK100ms."""
72
+
73
+ name: str
74
+ slice_access: str | None = None # SliceAccessModifier, e.g. "x0" / "b1"
75
+ access_modifier: str = "None" # None | Array | Reference | ReferenceToArray
76
+ simple_access_modifier: str = "None" # None | Periphery | QualityInformation | combos
77
+ indices: list[Access] = field(default_factory=list) # array subscripts (Access children)
78
+
79
+
80
+ @dataclass
81
+ class Symbol:
82
+ components: list[Component] = field(default_factory=list)
83
+
84
+
85
+ @dataclass
86
+ class Constant:
87
+ type: str | None = None # ConstantType (may be Informative)
88
+ value: str | None = None # ConstantValue (may be Informative)
89
+ name: str | None = None
90
+ raw: dict = field(default_factory=dict)
91
+
92
+
93
+ @dataclass
94
+ class Address:
95
+ area: str # Input | Output | Memory | DB | Timer | Counter | ...
96
+ type: str | None = None
97
+ bit_offset: int | None = None # Byte*8 + Bit
98
+ block_number: int | None = None # DB number, for DB access
99
+
100
+
101
+ # Operand = Symbol | Constant | Address (other scope children parsed into ``raw``)
102
+ Operand = Symbol | Constant | Address | None
103
+
104
+
105
+ @dataclass
106
+ class Access:
107
+ """A data reference. Per-use (never deduplicated): each occurrence is its own node."""
108
+
109
+ uid: str
110
+ scope: str # 20 possible values — kept as str (see guide)
111
+ operand: Operand = None
112
+ raw: dict = field(default_factory=dict)
113
+
114
+
115
+ # ----------------------------------------------------------------------------- #
116
+ # Instructions (Part) and user/system calls (Call) #
117
+ # ----------------------------------------------------------------------------- #
118
+ @dataclass
119
+ class TemplateValue:
120
+ name: str # e.g. "Card", "SrcType", "time_type"
121
+ kind: str # Cardinality | Type | Operation
122
+ value: str | None = None
123
+
124
+
125
+ @dataclass
126
+ class Instance:
127
+ """Backing static member for a system FB (TON/CTU/...) — same shape as Symbol."""
128
+
129
+ scope: str
130
+ components: list[Component] = field(default_factory=list)
131
+
132
+
133
+ @dataclass
134
+ class Part:
135
+ uid: str
136
+ name: str # Contact | Coil | O | Move | TON | Lt | Rs | ...
137
+ disabled_eno: bool = False
138
+ version: str | None = None # system FB/FC type version
139
+ template_values: list[TemplateValue] = field(default_factory=list)
140
+ negated_pins: list[str] = field(default_factory=list) # from <Negated Name="..."/>
141
+ invisible_pins: list[str] = field(default_factory=list)
142
+ instance: Instance | None = None # system FB instance ref
143
+ equation: str | None = None # Calculate box only
144
+ comment: str | None = None
145
+ raw: dict = field(default_factory=dict)
146
+
147
+
148
+ @dataclass
149
+ class Parameter:
150
+ name: str
151
+ section: str # Input | Output | InOut | Return
152
+ type: str | None = None
153
+ informative: bool = False
154
+
155
+
156
+ @dataclass
157
+ class Call:
158
+ uid: str
159
+ name: str # called block name
160
+ block_type: str # FC | FB | OB | DB | UDT | FBT | FCT
161
+ instance: Instance | None = None # for FB calls
162
+ parameters: list[Parameter] = field(default_factory=list)
163
+ comment: str | None = None
164
+
165
+
166
+ # ----------------------------------------------------------------------------- #
167
+ # Wires (the graph edges) #
168
+ # ----------------------------------------------------------------------------- #
169
+ class EndpointKind(str, Enum):
170
+ POWERRAIL = "Powerrail"
171
+ IDENT_CON = "IdentCon" # -> Access node (data), by uid
172
+ NAME_CON = "NameCon" # -> Part/Call pin, by (uid, name)
173
+ OPEN_CON = "OpenCon" # explicitly unused output
174
+ OPEN_BRANCH = "Openbranch" # unterminated branch
175
+
176
+
177
+ @dataclass
178
+ class Endpoint:
179
+ kind: EndpointKind
180
+ uid: str | None = None # for IdentCon / NameCon / OpenCon
181
+ pin: str | None = None # for NameCon
182
+
183
+
184
+ @dataclass
185
+ class Wire:
186
+ """First endpoint is the source; the rest are sinks (fan-out)."""
187
+
188
+ uid: str
189
+ endpoints: list[Endpoint] = field(default_factory=list)
190
+
191
+
192
+ @dataclass
193
+ class Label:
194
+ uid: str
195
+ name: str
196
+ comment: str | None = None
197
+
198
+
199
+ # ----------------------------------------------------------------------------- #
200
+ # Network sources (one per programming language) #
201
+ # ----------------------------------------------------------------------------- #
202
+ @dataclass
203
+ class FlgNet:
204
+ """LAD/FBD network: indexed parts/calls/accesses + the wire list. UIds are
205
+ scoped to *this* compile unit only (reused across networks)."""
206
+
207
+ accesses: dict[str, Access] = field(default_factory=dict)
208
+ parts: dict[str, Part] = field(default_factory=dict)
209
+ calls: dict[str, Call] = field(default_factory=dict)
210
+ wires: list[Wire] = field(default_factory=list)
211
+ labels: list[Label] = field(default_factory=list)
212
+
213
+
214
+ @dataclass
215
+ class StructuredText:
216
+ """SCL network: an ordered, interleaved token/access stream (a tokenised AST).
217
+ Reconstructed to text by scl_reconstruct.py — not folded."""
218
+
219
+ items: list[object] = field(default_factory=list) # Access | Token | comment nodes (raw)
220
+
221
+
222
+ # StatementList (STL) and Graph (SFC) are parsed-but-not-rendered in v0; kept as
223
+ # raw element trees so nothing is lost.
224
+ @dataclass
225
+ class RawSource:
226
+ language: Language
227
+ element: object = None # retained xml.etree element for later phases
228
+
229
+
230
+ NetworkSource = FlgNet | StructuredText | RawSource | None
231
+
232
+
233
+ @dataclass
234
+ class Network:
235
+ index: int # 1-based, in document order
236
+ language: Language
237
+ title: str | None = None
238
+ comment: str | None = None
239
+ source: NetworkSource = None # None == empty network
240
+
241
+
242
+ # ----------------------------------------------------------------------------- #
243
+ # Top level #
244
+ # ----------------------------------------------------------------------------- #
245
+ @dataclass
246
+ class Block:
247
+ kind: BlockKind
248
+ id: str
249
+ name: str
250
+ number: int | None = None
251
+ language: Language = Language.LAD # block-level default; networks may differ
252
+ memory_layout: str | None = None
253
+ memory_reserve: int | None = None # FB only
254
+ set_eno_automatically: bool = False
255
+ title: str | None = None
256
+ comment: str | None = None
257
+ interface: Interface = field(default_factory=Interface)
258
+ networks: list[Network] = field(default_factory=list)
259
+
260
+
261
+ @dataclass
262
+ class Document:
263
+ engineering_version: str | None # e.g. "V21"
264
+ block: Block
@@ -0,0 +1,141 @@
1
+ """Render a model.Access into its TIA display string.
2
+
3
+ Split out because it is needed in two phases (fold labels its nodes; emit prints
4
+ them) and is a self-contained pile of display conventions. Conventions applied
5
+ (SIMATICML_READING_GUIDE.md "Display Conventions"):
6
+
7
+ LocalVariable scope -> "#name"
8
+ GlobalVariable scope -> '"DB".field'
9
+ Address scope -> "%MW100" (area letter + offset)
10
+ SliceAccessModifier "x0" -> ".%X0"
11
+ multi-component symbol -> dotted path "System.CLK100ms"
12
+ array AccessModifier -> "name[<index>]"
13
+ LiteralConstant -> the value verbatim ("0", "TRUE", "T#3s")
14
+
15
+ Phase 1 (parse) is implemented first; this is filled alongside it so folded
16
+ networks have readable names.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from . import model
22
+
23
+ # Address Area -> TIA display prefix (SIMATICML_READING_GUIDE.md "Area values").
24
+ _AREA_PREFIX: dict[str, str] = {
25
+ "Input": "%I",
26
+ "Output": "%Q",
27
+ "Memory": "%M",
28
+ "PeripheryInput": "%PI",
29
+ "PeripheryOutput": "%PQ",
30
+ "Timer": "%T",
31
+ "Counter": "%C",
32
+ "Local": "%L",
33
+ "DB": "%DB",
34
+ "DI": "%DI",
35
+ }
36
+
37
+ # Address Type -> width letter used in the absolute display (%MW, %MB, ...).
38
+ # Bool is special-cased to the byte.bit form and is not in this table.
39
+ _TYPE_WIDTH: dict[str, str] = {
40
+ "byte": "B",
41
+ "sint": "B",
42
+ "usint": "B",
43
+ "char": "B",
44
+ "word": "W",
45
+ "int": "W",
46
+ "uint": "W",
47
+ "s5time": "W",
48
+ "dword": "D",
49
+ "dint": "D",
50
+ "udint": "D",
51
+ "real": "D",
52
+ "time": "D",
53
+ "lword": "L",
54
+ "lint": "L",
55
+ "ulint": "L",
56
+ "lreal": "L",
57
+ }
58
+
59
+
60
+ def render(access: model.Access) -> str:
61
+ """model.Access -> display string (TIA conventions; see module docstring)."""
62
+ operand = access.operand
63
+ if isinstance(operand, model.Symbol):
64
+ return _render_symbol(operand, access.scope)
65
+ if isinstance(operand, model.Constant):
66
+ return _render_constant(operand)
67
+ if isinstance(operand, model.Address):
68
+ return _render_address(operand)
69
+ # Undef / Unnamed / scopes whose child landed in raw (Expression, CallInfo,
70
+ # ...): no readable operand. Fail visibly rather than silently — a wrong name
71
+ # is worse than an obvious placeholder.
72
+ return f"<{access.scope or 'Undef'}#{access.uid}>"
73
+
74
+
75
+ # --------------------------------------------------------------------------- #
76
+ # Symbols #
77
+ # --------------------------------------------------------------------------- #
78
+ def _render_symbol(symbol: model.Symbol, scope: str | None) -> str:
79
+ if not symbol.components:
80
+ return f"<{scope or 'Symbol'}>"
81
+ is_global = scope == "GlobalVariable"
82
+ parts = [
83
+ _render_component(comp, quote=(is_global and i == 0))
84
+ for i, comp in enumerate(symbol.components)
85
+ ]
86
+ path = ".".join(parts)
87
+ # Local/interface variables get the '#' prefix; global access is already
88
+ # quoted at its root ('"DB".field') and takes no prefix.
89
+ if scope == "LocalVariable":
90
+ return "#" + path
91
+ return path
92
+
93
+
94
+ def _render_component(comp: model.Component, quote: bool = False) -> str:
95
+ text = f'"{comp.name}"' if quote else comp.name
96
+ # Array subscripts: one or more Access children rendered recursively.
97
+ if comp.access_modifier in ("Array", "ReferenceToArray") and comp.indices:
98
+ subs = ", ".join(render(idx) for idx in comp.indices)
99
+ text += f"[{subs}]"
100
+ # Bit/byte/word/dword slice: "x0" -> ".%X0", "b1" -> ".%B1".
101
+ if comp.slice_access:
102
+ letter = comp.slice_access[0].upper()
103
+ number = comp.slice_access[1:]
104
+ text += f".%{letter}{number}"
105
+ return text
106
+
107
+
108
+ # --------------------------------------------------------------------------- #
109
+ # Constants #
110
+ # --------------------------------------------------------------------------- #
111
+ def _render_constant(const: model.Constant) -> str:
112
+ if const.value is not None:
113
+ return const.value
114
+ if const.name is not None:
115
+ return const.name
116
+ return "<const>"
117
+
118
+
119
+ # --------------------------------------------------------------------------- #
120
+ # Absolute addresses #
121
+ # --------------------------------------------------------------------------- #
122
+ def _render_address(addr: model.Address) -> str:
123
+ prefix = _AREA_PREFIX.get(addr.area, "%" + (addr.area or "?"))
124
+ # DB-qualified access carries a block number: DB10.DBW0-style.
125
+ db_qualifier = ""
126
+ if addr.block_number is not None and addr.area in ("DB", "DI"):
127
+ prefix = f"%DB{addr.block_number}" if addr.area == "DB" else f"%DI{addr.block_number}"
128
+ db_qualifier = ".DB" if addr.area == "DB" else ".DI"
129
+
130
+ if addr.bit_offset is None:
131
+ return prefix + db_qualifier
132
+
133
+ byte, bit = divmod(addr.bit_offset, 8)
134
+ type_lc = (addr.type or "").lower()
135
+ if type_lc in ("bool", "bit", ""):
136
+ return f"{prefix}{db_qualifier}{'X' if db_qualifier else ''}{byte}.{bit}"
137
+ width = _TYPE_WIDTH.get(type_lc)
138
+ if width:
139
+ return f"{prefix}{db_qualifier}{width}{byte}"
140
+ # Unknown width: fall back to the bit-addressed form so nothing is lost.
141
+ return f"{prefix}{db_qualifier}{byte}.{bit}"