opencrbasic 1.0.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.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
opencrbasic/tables.py ADDED
@@ -0,0 +1,2173 @@
1
+ """Derive `-z` (TDF) and `-l` (table list) output from a parsed `Program`.
2
+
3
+ Rules are reverse-engineered black-box against `CR6Comp.exe`; see `docs/tdf-rules.md` for the
4
+ evidence and confidence tags. Section references below (`§N`) are to that document.
5
+
6
+ Entry points:
7
+ build_tables(program, profile, consts) -> (tables, table_list_text, diagnostics_or_None)
8
+ render_table_list(tables) -> str # the `-l`/`-c` text, given tables in TDF order
9
+ """
10
+
11
+ import dataclasses
12
+ import json
13
+ import math
14
+ import struct
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import TypedDict
18
+
19
+ from . import consteval
20
+ from . import syntax as S
21
+ from .tdf import TdfField, TdfTable
22
+
23
+ # `TableName.<property>` members of a data (or Status) table that are not fields
24
+ # (extracted_chm/Info/datatableaccess.htm "Data Table Access").
25
+ TABLE_PROPERTIES = (
26
+ "BytesPerRecord",
27
+ "EventCount",
28
+ "EventEnd",
29
+ "Output",
30
+ "Record",
31
+ "TableBytes",
32
+ "TableFull",
33
+ "TableSize",
34
+ "TimeStamp",
35
+ )
36
+
37
+ DATA = Path(__file__).resolve().parent / "data"
38
+
39
+ # ---------------------------------------------------------------- type codes (§1, §8)
40
+
41
+ # TDF field type byte. Confirmed by oracle: 2 UINT2, 6 LONG, 7 FP2, 9 IEEE4, 11 ASCII, 14 NSEC,
42
+ # 17 BOOL8, 18 IEEE8, 28 BOOLEAN. UINT4=3 oracle-confirmed, UINT1 a guess (never produced; see
43
+ # docs/tdf-rules.md open question 12).
44
+ TYPE_CODES = {
45
+ "FLOAT": 9,
46
+ "IEEE4": 9,
47
+ "FP2": 7,
48
+ "LONG": 6,
49
+ "BOOLEAN": 28,
50
+ "BOOL8": 17,
51
+ "STRING": 11,
52
+ "DOUBLE": 18,
53
+ "IEEE8": 18,
54
+ "UINT2": 2,
55
+ "NSEC": 14,
56
+ "UINT1": 1,
57
+ "UINT4": 3,
58
+ }
59
+
60
+
61
+ def _dtype_code(expr: S.Expr | None, env: consteval.Env) -> int:
62
+ """TDF type code of an output instruction's DataType argument. A constant expression or named
63
+ `Const` resolves through its value (the predefined data-type symbols equal the TDF codes)."""
64
+ if isinstance(expr, S.Name) and expr.name.upper() in TYPE_CODES:
65
+ return TYPE_CODES[expr.name.upper()]
66
+ v = _fold(expr, env, None)
67
+ return int(v) if isinstance(v, (int, float)) and v in TYPE_CODES.values() else 9
68
+
69
+
70
+ _UNIT_SECONDS = {"USEC": 1e-6, "MSEC": 1e-3, "SEC": 1, "MIN": 60, "HR": 3600, "DAY": 86400}
71
+ _UNIT_NS = {
72
+ "USEC": 1_000,
73
+ "MSEC": 1_000_000,
74
+ "SEC": 1_000_000_000,
75
+ "MIN": 60_000_000_000,
76
+ "HR": 3_600_000_000_000,
77
+ "DAY": 86_400_000_000_000,
78
+ }
79
+ # Numeric Scan units (`checker/scan_time.UNIT_NAMES`), keyed by the predefined symbol value.
80
+ _UNIT_NAME_BY_VALUE = {0: "USEC", 1: "MSEC", 2: "SEC", 3: "MIN", 4: "HR", 5: "DAY"}
81
+
82
+ _STATUS_JSON = json.loads((DATA / "status_table.json").read_text())
83
+
84
+ # Auto-size (§10): one finalisation algorithm for every table mix (`docs/tdf-rules.md` §10, rewritten
85
+ # 2026-10-10 from a re-read of the finalisation loop). Free memory is the compiler's own malloc probe,
86
+ # a binary search that returns 2**22 - 1 whenever every allocation succeeds. All table arithmetic is
87
+ # 32-bit float (`_f32`), as in the real compiler.
88
+ _FREE_MEMORY = 4_194_303
89
+ _DEFAULT_PROBE_BUFFER_BYTES = 1024
90
+ _DEFAULT_PROBE_OVERHEAD = 16
91
+ _TABLE_INIT_RESERVED = 4096
92
+
93
+
94
+ def _f32(x: float) -> float:
95
+ return struct.unpack("f", struct.pack("f", x))[0]
96
+
97
+
98
+ def _f32_safe(x) -> float:
99
+ """float32 rounding that overflows to +-inf (CPython 3.13 raises on some builds)."""
100
+ try:
101
+ return _f32(float(x))
102
+ except OverflowError:
103
+ return float("inf") if x > 0 else float("-inf")
104
+
105
+
106
+ _INT32_MAX = 2147483647
107
+ _TWO31 = 2147483648.0
108
+
109
+
110
+ def size_class(raw):
111
+ """How the real compiler reads a `DataTable` size argument (oracle, all four loggers,
112
+ `docs/tdf-rules.md` "DataTable size argument"): the constant is rounded to float32 and
113
+ truncated toward zero (an integer constant that fits in 32 bits is kept exactly). Returns `("auto",)` for -1, `("fixed", N, flagged)` for any other
114
+ integer (`flagged`: it was negative, `N = -size`; zero is 1), `("edge", sign)` when the
115
+ float32 is exactly +-2^31, `("inf",)` for `+inf`, `("toxic",)` beyond it (and `-inf`), `None`
116
+ when the value is not a number."""
117
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
118
+ return None
119
+ if isinstance(raw, int) and -_TWO31 <= raw < _TWO31:
120
+ v = raw # an integer literal that fits is kept exactly
121
+ else:
122
+ f = _f32_safe(raw)
123
+ if f != f:
124
+ return None
125
+ if f == float("inf"):
126
+ return ("inf",)
127
+ if f == _TWO31:
128
+ return ("edge", 1)
129
+ if f == -_TWO31:
130
+ return ("edge", -1)
131
+ if abs(f) > _TWO31:
132
+ return ("toxic",)
133
+ v = int(f)
134
+ if v == -1:
135
+ return ("auto",)
136
+ if v == 0:
137
+ return ("fixed", 1, False)
138
+ return ("fixed", min(-v, _INT32_MAX), True) if v < -1 else ("fixed", v, False)
139
+
140
+
141
+ def declared_size(raw, cr3xx: bool, bytes_per_record: int) -> int | None:
142
+ """The TDF size of a called table with this size argument, or `None` when it is auto-sized
143
+ (the caller computes it). CR6/CR1000X auto-size every negative or out-of-range size and keep
144
+ only `+inf` as `INT32_MAX`; CR300/CR350 keep `-N` as a fixed `N`, saturate +-2^31, and give
145
+ a beyond-range size ("toxic", `checker/storage.py`) the minimum a table can get, three pages
146
+ less the first block: `(12288 // R) - (4096 // R + 1)` records, `R = bytes + 16`."""
147
+ c = size_class(raw)
148
+ if c is None or c[0] == "auto":
149
+ return None
150
+ if c[0] == "inf":
151
+ return _INT32_MAX
152
+ if cr3xx:
153
+ if c[0] == "edge":
154
+ return _INT32_MAX
155
+ if c[0] == "toxic":
156
+ rec = bytes_per_record + 16
157
+ return max(12288 // rec - (4096 // rec + 1), 1)
158
+ return c[1]
159
+ if c[0] in ("edge", "toxic") or c[2]:
160
+ return None
161
+ return c[1]
162
+
163
+
164
+ def _fold(expr: S.Expr | None, env: consteval.Env, default=None):
165
+ if expr is None:
166
+ return default
167
+ try:
168
+ return consteval.value_of(expr, env)
169
+ except consteval.NotConstant:
170
+ return default
171
+
172
+
173
+ def _unit_value(expr: S.Expr | None, env: consteval.Env) -> int | None:
174
+ """Units argument of `Scan` or `DataInterval` as an integer: the predefined name (`Min`), its
175
+ number (`3`) and a `Const` agree, and a fraction is truncated toward zero (`1.9` is mSec, `-0.5`
176
+ is uSec; oracle, round 20 W5, `docs/tdf-rules.md` "Numeric and Const units"). `None` when the
177
+ argument does not fold to a finite number."""
178
+ v = _fold(expr, env, None)
179
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
180
+ return None
181
+ if isinstance(v, float) and not math.isfinite(v):
182
+ return None
183
+ return int(v)
184
+
185
+
186
+ def _scan_unit_name(expr: S.Expr | None, env: consteval.Env) -> str:
187
+ """`_UNIT_NS` key of a Scan units argument (`_unit_value`). A non-constant reads as seconds. A
188
+ value outside 0..5 reads as uSec, the unit the real compiler leaves no table charge for."""
189
+ v = _unit_value(expr, env)
190
+ if v is None:
191
+ return "SEC"
192
+ return _UNIT_NAME_BY_VALUE.get(v, "USEC")
193
+
194
+
195
+ def _as_int(v, default=0) -> int:
196
+ if v is None:
197
+ return default
198
+ try:
199
+ return int(round(float(v)))
200
+ except TypeError, ValueError, OverflowError:
201
+ return default
202
+
203
+
204
+ # ---------------------------------------------------------------- const int/float inference (§7)
205
+
206
+
207
+ def _const_is_int(expr: S.Expr | None, env: consteval.Env, int_names: dict) -> bool:
208
+ """Whether a ConstTable `Const` expression folds to CRBasic-integer (-> LONG) rather than
209
+ float (-> IEEE4). Oracle-confirmed (docs/tdf-rules.md §7, exp `consttypes`/`negconst`):
210
+ integer literals, hex/binary literals, unary +/- of an int, True/False and predefined symbols,
211
+ and +/-/* of two ints stay LONG; `/` is always IEEE4 even when exact; other operators are
212
+ treated as float unless both operands are known int (best-effort, not exhaustively tested).
213
+ """
214
+ if isinstance(expr, S.Number):
215
+ return isinstance(expr.value, int)
216
+ if isinstance(expr, S.Name):
217
+ upper = expr.name.upper()
218
+ if upper in int_names:
219
+ return int_names[upper]
220
+ sym = env.profile.symbols.get(upper)
221
+ if sym is not None:
222
+ return isinstance(sym.get("value"), int)
223
+ return True # unknown name (already resolved as constant elsewhere): assume int
224
+ if isinstance(expr, S.Unary):
225
+ if expr.op == "NOT":
226
+ return True
227
+ return _const_is_int(expr.operand, env, int_names)
228
+ if isinstance(expr, S.Binary):
229
+ if expr.op == "/":
230
+ return False
231
+ if expr.op in ("+", "-", "*"):
232
+ return _const_is_int(expr.left, env, int_names) and _const_is_int(expr.right, env, int_names)
233
+ if expr.op in ("\\", "MOD", "AND", "OR", "XOR", "=", "<>", "<", ">", "<=", ">=", "EQV", "IMP", "<<", ">>"):
234
+ return True
235
+ return False # "^" and anything else: unverified, assume float
236
+ return True
237
+
238
+
239
+ # ---------------------------------------------------------------- variable registry
240
+
241
+
242
+ @dataclass
243
+ class VarInfo:
244
+ """A declared Public/Dim variable: its TDF-relevant shape, for Public-table fields and for
245
+ resolving DataTable output instructions' `Source` arrays (§3, §8, §9)."""
246
+
247
+ type_code: int
248
+ shape: list[int] = field(default_factory=list) # declared array dims, e.g. [10] or [2,3]
249
+ stored_len: int | None = None # STRING only: rounded storage length
250
+ units: str = ""
251
+ struct: dict | None = None # StructureType body when typed as one
252
+ name: str = "" # declared spelling (CRBasic is
253
+ # case-insensitive; a `Source` reference may use different casing than the declaration, but
254
+ # the default output field name follows the declaration -- oracle-confirmed, ftpclient_4.CR6:
255
+ # `Public Count As Long` + `Sample(1,count,LONG)` -> field name `Count`, not `count`).
256
+
257
+ def full_shape(self) -> list[int]:
258
+ s = list(self.shape)
259
+ if self.stored_len is not None:
260
+ s.append(self.stored_len)
261
+ return s
262
+
263
+ def collapsed(self) -> tuple[int, list[int]]:
264
+ """(dim, subdims) per the §3 collapse rule."""
265
+ shape = self.full_shape()
266
+ if not shape:
267
+ return 1, []
268
+ if len(shape) == 1 and shape[0] == 1:
269
+ return 1, []
270
+ dim = 1
271
+ for n in shape:
272
+ dim *= n
273
+ return dim, shape
274
+
275
+
276
+ def _string_stored_len(n: int) -> int:
277
+ """§8: smallest multiple of 4 strictly greater than n."""
278
+ return (n // 4 + 1) * 4
279
+
280
+
281
+ def _var_shape(v: S.VarDecl, env: consteval.Env) -> list[int]:
282
+ dims = []
283
+ for d in v.dims:
284
+ n = _as_int(_fold(d, env), default=1)
285
+ dims.append(max(n, 1))
286
+ return dims
287
+
288
+
289
+ def _var_type_code(v: S.VarDecl) -> int:
290
+ t = (v.type or "FLOAT").upper()
291
+ return TYPE_CODES.get(t, 9)
292
+
293
+
294
+ def _string_size_expr(v: S.VarDecl) -> S.Expr | None:
295
+ """`v.string_size`, working around a parser quirk: `As String * 60 = "x"` currently parses
296
+ the size and the initializer together as one `Binary(op='=', left=60, right="x")` node
297
+ instead of splitting them; the real size is the left side."""
298
+ e = v.string_size
299
+ if isinstance(e, S.Binary) and e.op == "=":
300
+ return e.left
301
+ return e
302
+
303
+
304
+ def _make_var_info(v: S.VarDecl, env: consteval.Env) -> VarInfo:
305
+ type_code = _var_type_code(v)
306
+ shape = _var_shape(v, env)
307
+ stored_len = None
308
+ if (v.type or "").upper() == "STRING":
309
+ # A bare `As String` with no `* n` defaults to n=20 (oracle-confirmed: formatlong_1.CR6's
310
+ # `FormatString As String` and ctype_1.CR6's `Type(6) As String` both round to dim=24 per
311
+ # element, matching n=20 -- `(20 div 4 + 1) * 4`).
312
+ n = _as_int(_fold(_string_size_expr(v), env), default=20)
313
+ stored_len = _string_stored_len(max(n, 1))
314
+ return VarInfo(type_code, shape, stored_len, name=v.name)
315
+
316
+
317
+ # ---------------------------------------------------------------- statement block scanning
318
+
319
+
320
+ def _kw(s: S.Stmt) -> str:
321
+ return getattr(s, "keyword", "").upper() if isinstance(s, S.Instruction) else ""
322
+
323
+
324
+ @dataclass
325
+ class ConstTableDecl:
326
+ name: str | None
327
+ hidden: int
328
+ consts: list[S.ConstDecl]
329
+ line: int
330
+
331
+
332
+ @dataclass
333
+ class DataTableDecl:
334
+ name: str
335
+ trig: S.Expr | None
336
+ size_expr: S.Expr | None
337
+ body: list[S.Stmt]
338
+ line: int
339
+
340
+
341
+ class _Scanned(TypedDict):
342
+ uses_ch400: bool
343
+ struct_types: dict[str, dict]
344
+ uses_cpi: bool
345
+ publics: list[S.VarDecl]
346
+ all_vars: list[S.VarDecl]
347
+ const_tables: list[ConstTableDecl]
348
+ data_tables: list[DataTableDecl]
349
+ alias_stmts: list[S.Instruction]
350
+ units_stmts: list[S.Instruction]
351
+ readonly_names: set[str]
352
+ slow_seq_count: int
353
+ called: set[str]
354
+
355
+
356
+ def _scan_program(program: S.Program) -> _Scanned:
357
+ """One pass over the flat statement list: collects Public/Dim declarations (in order),
358
+ ConstTable blocks, DataTable blocks, Alias/Units statements and the SlowSequence count.
359
+ DataTable and ConstTable bodies do not nest (CRBasic), so simple non-nested pairing suffices.
360
+ """
361
+ publics: list[S.VarDecl] = []
362
+ all_vars: list[S.VarDecl] = [] # Public + Dim (+ Static), for Source lookups
363
+ const_tables: list[ConstTableDecl] = []
364
+ data_tables: list[DataTableDecl] = []
365
+ alias_stmts: list[S.Instruction] = []
366
+ units_stmts: list[S.Instruction] = []
367
+ readonly_names: set[str] = set()
368
+ slow_seq_count = 0
369
+ struct_types: dict[str, dict] = {} # NAME -> {"members": [VarDecl], "units": {}, "ro": set}
370
+
371
+ stmts = program.statements
372
+ n = len(stmts)
373
+ i = 0
374
+ while i < n:
375
+ s = stmts[i]
376
+ kw = _kw(s)
377
+ if isinstance(s, S.Instruction) and (kw == "STRUCTURETYPE"):
378
+ st = {"members": [], "units": {}, "ro": set()}
379
+ if s.args and isinstance(s.args[0], S.Name):
380
+ struct_types[s.args[0].name.upper()] = st
381
+ i += 1
382
+ while i < n and _kw(stmts[i]) != "ENDSTRUCTURETYPE":
383
+ m = stmts[i]
384
+ if isinstance(m, S.Declaration):
385
+ st["members"].extend(m.vars)
386
+ elif isinstance(m, S.Instruction) and (
387
+ _kw(m) == "UNITS" and len(m.args) > 1 and isinstance(m.args[1], S.String)
388
+ ):
389
+ st["units"][getattr(m.args[0], "name", "").upper()] = m.args[1].value
390
+ elif isinstance(m, S.Instruction) and (_kw(m) == "READONLY"):
391
+ st["ro"].update(a.name.upper() for a in m.args if isinstance(a, S.Name))
392
+ i += 1
393
+ i += 1
394
+ continue
395
+ if isinstance(s, S.Declaration) and s.kind in ("PUBLIC", "DIM", "STATIC"):
396
+ all_vars.extend(s.vars)
397
+ if s.kind == "PUBLIC":
398
+ publics.extend(s.vars)
399
+ i += 1
400
+ continue
401
+ if isinstance(s, S.Instruction) and (kw == "CONSTTABLE"):
402
+ args = s.args
403
+ name = args[0].name if args and isinstance(args[0], S.Name) else None
404
+ hidden = _as_int(args[1].value, 0) if len(args) > 1 and isinstance(args[1], S.Number) else 0
405
+ j = i + 1
406
+ const_body: list[S.ConstDecl] = []
407
+ while j < n and _kw(stmts[j]) != "ENDCONSTTABLE":
408
+ candidate = stmts[j]
409
+ if isinstance(candidate, S.ConstDecl):
410
+ const_body.append(candidate)
411
+ j += 1
412
+ const_tables.append(ConstTableDecl(name, hidden, const_body, s.line))
413
+ i = j + 1
414
+ continue
415
+ if isinstance(s, S.Instruction) and (kw == "DATATABLE"):
416
+ args = s.args
417
+ name = args[0].name if args and isinstance(args[0], S.Name) else ""
418
+ value = (program.consts or {}).get(name.upper()) if name else None
419
+ if isinstance(value, str) and value:
420
+ name = value # a String `Const` names the table its value spells (oracle, round 67)
421
+ trig = args[1] if len(args) > 1 else None
422
+ size_expr = args[2] if len(args) > 2 else None
423
+ j = i + 1
424
+ body: list[S.Stmt] = []
425
+ while j < n and _kw(stmts[j]) != "ENDTABLE":
426
+ body.append(stmts[j])
427
+ j += 1
428
+ data_tables.append(DataTableDecl(name, trig, size_expr, body, s.line))
429
+ i = j + 1
430
+ continue
431
+ if kw == "SLOWSEQUENCE":
432
+ slow_seq_count += 1
433
+ i += 1
434
+ continue
435
+ if isinstance(s, S.Instruction) and (kw == "ALIAS"):
436
+ alias_stmts.append(s)
437
+ i += 1
438
+ continue
439
+ if isinstance(s, S.Instruction) and (kw == "UNITS"):
440
+ units_stmts.append(s)
441
+ i += 1
442
+ continue
443
+ if isinstance(s, S.Instruction) and (kw == "READONLY"):
444
+ for arg in s.args:
445
+ if isinstance(arg, S.Name):
446
+ readonly_names.add(arg.name.upper())
447
+ i += 1
448
+ continue
449
+ i += 1
450
+
451
+ called: set[str] = set()
452
+
453
+ for s in _flat(stmts):
454
+ kw2 = _kw(s)
455
+ if kw2 == "CALLTABLE" and s.args and isinstance(s.args[0], S.Name):
456
+ called.add(s.args[0].name.upper())
457
+ elif kw2 == "ACCEPTDATARECORDS" and len(s.args) > 2 and isinstance(s.args[2], S.Name):
458
+ # AcceptDataRecords(PakBusAddr, TableNo, DestTableName): populates DestTableName
459
+ # without a `CallTable` (oracle-confirmed, acceptdatarecords_1.CR6: a fixed-size
460
+ # table named only here still gets its declared size, not the uncalled `size=1`).
461
+ called.add(s.args[2].name.upper())
462
+
463
+ publics = [v for v in publics if (v.type or "").upper() not in struct_types]
464
+ uses_ch400 = any(_kw(s) == "CH400STATUS" for s in stmts)
465
+ uses_cpi = any(_kw(s).startswith("CDM_") or _kw(s) == "CPIFILESEND" for s in stmts)
466
+
467
+ return _Scanned(
468
+ uses_ch400=uses_ch400,
469
+ struct_types=struct_types,
470
+ uses_cpi=uses_cpi,
471
+ publics=publics,
472
+ all_vars=all_vars,
473
+ const_tables=const_tables,
474
+ data_tables=data_tables,
475
+ alias_stmts=alias_stmts,
476
+ units_stmts=units_stmts,
477
+ readonly_names=readonly_names,
478
+ slow_seq_count=slow_seq_count,
479
+ called=called,
480
+ )
481
+
482
+
483
+ def _flat(ss):
484
+ """`ss` with the statements of every inline `If c Then ... Else ...` spliced in after it (an
485
+ inline `If c Then CallTable T` calls T too)."""
486
+ for x in ss:
487
+ yield x
488
+ yield from _flat((getattr(x, "inline", None) or []) + (getattr(x, "inline_else", None) or []))
489
+
490
+
491
+ def _scan_weights(stmts: list[S.Stmt], env: consteval.Env) -> dict[str, float]:
492
+ """Maps each `CallTable`d table name (upper) to the interval, in seconds, of the nearest
493
+ enclosing `Scan` block (a top-level `Scan` or a `SlowSequence`'s own `Scan`) that contains its
494
+ `CallTable` statement. Used as the auto-size weight for a table with no `DataInterval` (see
495
+ `_build_data_table`/`_resolve_auto_sizes` and docs/tdf-rules.md §10 "scan-interval weighting",
496
+ oracle-confirmed against `FinApp_tester.CR6`: real weight is the enclosing scan's interval, not
497
+ a flat 1 second). `SubScan` is transparent (does not change the enclosing weight) -- untested,
498
+ matches §4's "SubScan has no effect" finding for the Status table.
499
+ """
500
+ stack: list[float] = []
501
+ weights: dict[str, float] = {}
502
+ for s in stmts:
503
+ kw = _kw(s)
504
+ if isinstance(s, S.Instruction) and (kw == "SCAN"):
505
+ secs = None
506
+ if s.args:
507
+ val = _fold(s.args[0], env, None)
508
+ # `_fold` legitimately returns a non-numeric Python value (e.g. a string literal's
509
+ # own text) for a `Scan` interval the real compiler itself rejects elsewhere with
510
+ # its own specific message (`Scan Interval parameter must be a non-negative
511
+ # integer`, an instruction-specific value check, not this module's concern, see
512
+ # `docs/fuzz-findings.md` "Crash"). This function unconditionally runs for every
513
+ # `Scan` block regardless of whether that check ran or found anything, so it must
514
+ # tolerate the value being un-usable here rather than assume `float()` succeeds.
515
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
516
+ unit_name = _scan_unit_name(s.args[1], env) if len(s.args) > 1 else "SEC"
517
+ secs = float(val) * _UNIT_SECONDS.get(unit_name, 1)
518
+ stack.append(secs if secs else 1.0)
519
+ elif kw == "NEXTSCAN":
520
+ if stack:
521
+ stack.pop()
522
+ elif isinstance(s, S.Instruction) and (
523
+ kw == "CALLTABLE" and s.args and isinstance(s.args[0], S.Name) and stack
524
+ ):
525
+ weights[s.args[0].name.upper()] = stack[-1]
526
+ return weights
527
+
528
+
529
+ def _scan_calls(stmts: list[S.Stmt], env: consteval.Env) -> dict[str, list[float | None]]:
530
+ """Maps each table name (upper) to the interval (seconds, None when not a plain constant) of
531
+ every enclosing `Scan` of each of its `CallTable` statements that is lexically inside one;
532
+ a call in a `Sub` or outside any `Scan` adds nothing (`checker/storage.py`)."""
533
+ stack: list[float | None] = []
534
+ calls: dict[str, list[float | None]] = {}
535
+ for s in _flat(stmts):
536
+ kw = _kw(s)
537
+ if kw == "SCAN":
538
+ secs = None
539
+ if s.args:
540
+ val = _fold(s.args[0], env, None)
541
+ if isinstance(val, (int, float)) and not isinstance(val, bool) and val > 0:
542
+ unit_name = _scan_unit_name(s.args[1], env) if len(s.args) > 1 else "SEC"
543
+ secs = float(val) * _UNIT_SECONDS.get(unit_name, 1)
544
+ stack.append(secs)
545
+ elif kw == "NEXTSCAN":
546
+ if stack:
547
+ stack.pop()
548
+ elif kw == "CALLTABLE" and s.args and isinstance(s.args[0], S.Name) and stack:
549
+ calls.setdefault(s.args[0].name.upper(), []).append(stack[-1])
550
+ return calls
551
+
552
+
553
+ def _last_call_interval(stmts: list[S.Stmt], env: consteval.Env) -> dict[str, float | None]:
554
+ """The call interval (seconds) the real compiler leaves on each table: that of the last
555
+ `CallTable` in source order (the enclosing `Scan`'s interval; `None` for a call outside every
556
+ `Scan`, e.g. in a `Sub`, or one whose interval is not a plain constant). `checker/storage.py`."""
557
+ stack: list[float | None] = []
558
+ last: dict[str, float | None] = {}
559
+ for s in _flat(stmts):
560
+ kw = _kw(s)
561
+ if kw == "SCAN":
562
+ secs = None
563
+ if s.args:
564
+ val = _fold(s.args[0], env, None)
565
+ if isinstance(val, (int, float)) and not isinstance(val, bool) and val > 0:
566
+ unit_name = _scan_unit_name(s.args[1], env) if len(s.args) > 1 else "SEC"
567
+ secs = float(val) * _UNIT_SECONDS.get(unit_name, 1)
568
+ stack.append(secs)
569
+ elif kw == "NEXTSCAN":
570
+ if stack:
571
+ stack.pop()
572
+ elif kw == "CALLTABLE" and s.args and isinstance(s.args[0], S.Name):
573
+ last[s.args[0].name.upper()] = stack[-1] if stack else None
574
+ return last
575
+
576
+
577
+ # ---------------------------------------------------------------- Status table (§4)
578
+
579
+
580
+ _STATUS_INSERTIONS = [
581
+ ("SkippedSystemScan", "SkippedSlowScan", 6, False, ""),
582
+ ("LastSystemScan", "LastSlowScan", 14, True, ""),
583
+ ("SystemProcTime", "SlowProcTime", 6, True, "usec"),
584
+ ("MaxSystemProcTime", "MaxSlowProcTime", 6, False, "usec"),
585
+ ]
586
+
587
+
588
+ def _status_table(slow_seq_count: int) -> TdfTable:
589
+ fields = []
590
+ for f in _STATUS_JSON["fields"]:
591
+ fields.append(
592
+ TdfField(
593
+ f["type"],
594
+ f["read_only"],
595
+ f["name"],
596
+ list(f["aliases"]),
597
+ f["processing"],
598
+ f["units"],
599
+ f["description"],
600
+ f["begin"],
601
+ f["dim"],
602
+ list(f["subdims"]),
603
+ )
604
+ )
605
+ if slow_seq_count >= 1:
606
+ if slow_seq_count == 1:
607
+ dim, subdims = 1, []
608
+ else:
609
+ dim, subdims = slow_seq_count, [slow_seq_count]
610
+ for after, name, type_code, ro, units in _STATUS_INSERTIONS:
611
+ new_field = TdfField(type_code, ro, name, [], "", units, "", 1, dim, list(subdims))
612
+ idx = next(k for k, existing in enumerate(fields) if existing.name == after)
613
+ fields.insert(idx + 1, new_field)
614
+ return TdfTable(
615
+ "Status",
616
+ 1,
617
+ _STATUS_JSON["time_type"],
618
+ tuple(_STATUS_JSON["time_into"]),
619
+ tuple(_STATUS_JSON["interval"]),
620
+ fields,
621
+ )
622
+
623
+
624
+ def _build_struct_tables(all_vars: list[S.VarDecl], struct_types: dict, env: consteval.Env):
625
+ """docs/tdf-rules.md §2: every Public/Dim variable of a StructureType is a table named after
626
+ the variable (size = product of its dims, one field per member), in reverse declaration order."""
627
+ out = []
628
+ for v in reversed(all_vars):
629
+ st = struct_types.get((v.type or "").upper())
630
+ if st is None:
631
+ continue
632
+ size = 1
633
+ for d in _var_shape(v, env):
634
+ size *= d
635
+ fields = []
636
+ for m in st["members"]:
637
+ info = _make_var_info(m, env)
638
+ dim, subdims = info.collapsed()
639
+ fields.append(
640
+ TdfField(
641
+ info.type_code,
642
+ m.name.upper() in st["ro"],
643
+ m.name,
644
+ [],
645
+ "",
646
+ st["units"].get(m.name.upper(), ""),
647
+ "",
648
+ 1,
649
+ dim,
650
+ subdims,
651
+ )
652
+ )
653
+ out.append(TdfTable(v.name, size, 14, (0, 0), (0, 0), fields))
654
+ return out
655
+
656
+
657
+ _DEG_C = "\xef\xbf\xbdC"
658
+ # CH400Status instruction table (docs/tdf-rules.md §2): (name, units); None = 28-byte string. The
659
+ # temperature units are the bytes EF BF BD 'C' (a UTF-8 replacement character, then C) -- reproduced
660
+ # as latin-1 text.
661
+ _CH400_FIELDS = (
662
+ ("ChargeAVoltage", "Volts"),
663
+ ("ChargeACurrent", "Amps"),
664
+ ("ChargeBVoltage", "Volts"),
665
+ ("ChargeBCurrent", "Amps"),
666
+ ("InputChargeStatus", None),
667
+ ("InputCurrentLimit", "Amps"),
668
+ ("BattAVoltage", "Volts"),
669
+ ("BattACurrent", "Amps"),
670
+ ("BattAChargeStatus", None),
671
+ ("BattASetVoltage", "Volts"),
672
+ ("BattACurrentLimit", "Amps"),
673
+ ("BattADepthofDischarge", "%"),
674
+ ("BattBVoltage", "Volts"),
675
+ ("BattBCurrent", "Amps"),
676
+ ("BattBChargeStatus", None),
677
+ ("BattBSetVoltage", "Volts"),
678
+ ("BattBCurrentLimit", "Amps"),
679
+ ("BattBDepthofDischarge", "%"),
680
+ ("SystemVoltage", "Volts"),
681
+ ("SystemCurrent", "Amps"),
682
+ ("AlwaysOn12Voltage", "Volts"),
683
+ ("AlwaysOn12Current", "Amps"),
684
+ ("Switched12Voltage", "Volts"),
685
+ ("Switched12Current", "Amps"),
686
+ ("Switched24Voltage", "Volts"),
687
+ ("Switched24Current", "Amps"),
688
+ ("PowerSwitchState", None),
689
+ ("MaxBoardTemp", _DEG_C),
690
+ ("MinBoardTemp", _DEG_C),
691
+ ("PanelTemp", _DEG_C),
692
+ ("BattAProbeTemp", _DEG_C),
693
+ ("BattBProbeTemp", _DEG_C),
694
+ )
695
+
696
+
697
+ def _ch400status_table() -> TdfTable:
698
+ fields = [
699
+ TdfField(
700
+ 11 if u is None else 9, True, n, [], "", u or "", "", 1, 28 if u is None else 1, [28] if u is None else []
701
+ )
702
+ for n, u in _CH400_FIELDS
703
+ ]
704
+ return TdfTable("CH400Status", 1, 14, (0, 0), (0, 0), fields)
705
+
706
+
707
+ def _cpistatus_table() -> TdfTable:
708
+ """docs/tdf-rules.md §2: present iff a `CDM_*` instruction or `CPIFileSend` is used."""
709
+
710
+ def f(t: int, ro: bool, name: str, dim: int = 1, sub: tuple[int, ...] = ()) -> TdfField:
711
+ return TdfField(t, ro, name, [], "", "", "", 1, dim, list(sub))
712
+
713
+ fields = [f(9, True, "BusLoad")] + [
714
+ f(6, False, n) for n in ("ModuleReportCount", "ActiveModules", "BuffErr", "RxErrMax", "TxErrMax", "FrameErr")
715
+ ]
716
+ fields.append(f(11, False, "ModuleInfo", 6400, (50, 128)))
717
+ return TdfTable("CPIStatus", 1, 14, (0, 0), (0, 0), fields)
718
+
719
+
720
+ # ---------------------------------------------------------------- Public table (§8)
721
+
722
+
723
+ def _resolve_alias_target(expr: S.Expr | None, var_info: dict | None = None) -> tuple[str, int | None]:
724
+ """`Var` -> (Var, None); `Var(i)` -> (Var, i) for a single constant index; `Var(i,j)` of a
725
+ declared multi-dimensional array -> its row-major flat 1-based index."""
726
+ if isinstance(expr, S.Name):
727
+ return expr.name.upper(), None
728
+ if isinstance(expr, S.Index) and len(expr.args) > 1 and var_info is not None:
729
+ info = var_info.get(expr.name.upper())
730
+ numbers = [a for a in expr.args if isinstance(a, S.Number)]
731
+ if info is not None and len(info.shape) == len(expr.args) == len(numbers):
732
+ flat = 0
733
+ for k, a in enumerate(numbers):
734
+ stride = 1
735
+ for d in info.shape[k + 1 :]:
736
+ stride *= d
737
+ flat += (_as_int(a.value) - 1) * stride
738
+ return expr.name.upper(), flat + 1
739
+ if isinstance(expr, S.Index) and expr.args:
740
+ idx = expr.args[0]
741
+ if isinstance(idx, S.Number):
742
+ return expr.name.upper(), _as_int(idx.value)
743
+ if isinstance(expr, S.Index):
744
+ return expr.name.upper(), None
745
+ return "", None
746
+
747
+
748
+ def _build_public_table(
749
+ publics: list[S.VarDecl],
750
+ var_info: dict[str, VarInfo],
751
+ alias_stmts: list[S.Instruction],
752
+ units_stmts: list[S.Instruction],
753
+ readonly_names: set[str],
754
+ ):
755
+ """Returns (TdfTable('Public') or None, display_name -> TdfField, base_name -> [TdfField])."""
756
+ if not publics:
757
+ return None, {}, {}
758
+
759
+ # base_name(upper) -> list of aliased element indices (1-based) -> alias display name
760
+ aliased_elements: dict[str, dict[int, str]] = {}
761
+ whole_alias: dict[str, str] = {}
762
+ for a in alias_stmts:
763
+ if len(a.args) < 2 or not isinstance(a.args[1], S.Name):
764
+ continue
765
+ base, idx = _resolve_alias_target(a.args[0], var_info)
766
+ alias_name = a.args[1].name
767
+ if idx is None:
768
+ whole_alias[base] = alias_name
769
+ else:
770
+ aliased_elements.setdefault(base, {})[idx] = alias_name
771
+
772
+ fields: list[TdfField] = []
773
+ by_display: dict[str, TdfField] = {}
774
+ by_base: dict[str, list[TdfField]] = {}
775
+
776
+ for v in publics:
777
+ base = v.name.upper()
778
+ info = var_info[base]
779
+ display_name = whole_alias.get(base, v.name)
780
+ ro = base in readonly_names
781
+
782
+ elements = aliased_elements.get(base)
783
+ if elements and info.shape:
784
+ array_size = 1
785
+ for d in info.shape:
786
+ array_size *= d
787
+ full_shape = info.full_shape()
788
+ group: list[TdfField] = []
789
+ pos = 1
790
+ es = info.stored_len or 1 # String elements are stored_len bytes (docs/tdf-rules.md §9a)
791
+ while pos <= array_size:
792
+ if pos in elements:
793
+ f = TdfField(
794
+ info.type_code, ro, elements[pos], [], "", "", "", 1, es, [es] if info.stored_len else []
795
+ )
796
+ f.alias_elem = True # type: ignore[attr-defined]
797
+ f.alias_of = (v.name, pos) # type: ignore[attr-defined]
798
+ group.append(f)
799
+ pos += 1
800
+ else:
801
+ run_start = pos
802
+ while pos <= array_size and pos not in elements:
803
+ pos += 1
804
+ run_len = pos - run_start
805
+ f = TdfField(
806
+ info.type_code,
807
+ ro,
808
+ v.name,
809
+ [],
810
+ "",
811
+ "",
812
+ "",
813
+ (run_start - 1) * es + 1,
814
+ run_len * es,
815
+ list(full_shape),
816
+ )
817
+ group.append(f)
818
+ fields.extend(group)
819
+ for f in group:
820
+ by_display[f.name.upper()] = f
821
+ by_base[base] = group
822
+ continue
823
+
824
+ dim, subdims = info.collapsed()
825
+ f = TdfField(info.type_code, ro, display_name, [], "", "", "", 1, dim, subdims)
826
+ fields.append(f)
827
+ by_display[display_name.upper()] = f
828
+ by_base[base] = [f]
829
+
830
+ for u in units_stmts:
831
+ if len(u.args) < 2 or not isinstance(u.args[1], S.String):
832
+ continue
833
+ target, label = u.args[0], u.args[1].value
834
+ targets: list[TdfField] = []
835
+ if isinstance(target, S.Index) and not target.args:
836
+ targets = by_base.get(target.name.upper(), [])
837
+ elif isinstance(target, (S.Name, S.Index)):
838
+ nm = target.name.upper()
839
+ if nm in by_base:
840
+ targets = by_base[nm] # Units on the declared name covers every alias split too
841
+ elif nm in by_display:
842
+ targets = [by_display[nm]]
843
+ for f in targets:
844
+ f.units = label
845
+
846
+ return TdfTable("Public", 1, 14, (0, 0), (0, 0), fields), by_display, by_base
847
+
848
+
849
+ # ---------------------------------------------------------------- ConstTable (§7)
850
+
851
+
852
+ def _build_const_tables(const_tables: list[ConstTableDecl], env: consteval.Env) -> list[TdfTable]:
853
+ tables = []
854
+ int_names: dict[str, bool] = {}
855
+ for ct in const_tables:
856
+ fields = []
857
+ for c in ct.consts:
858
+ is_str = isinstance(c.value, S.String)
859
+ if c.type:
860
+ # An explicit `Const X As <Type> = ...` (oracle-confirmed,
861
+ # mqttpublishconsttable_1.CR6: `Const myDouble As Double = 987.654321` -> IEEE8,
862
+ # not the IEEE4 the literal alone would infer) overrides literal-based inference.
863
+ type_code = TYPE_CODES.get(c.type.upper(), TYPE_CODES["IEEE4"])
864
+ is_ascii = type_code == TYPE_CODES["STRING"]
865
+ dim, subdims = (80, [80]) if is_ascii else (1, [])
866
+ fields.append(TdfField(type_code, False, c.name, [], "", "", "", 1, dim, subdims))
867
+ int_names[c.name.upper()] = type_code == TYPE_CODES["LONG"]
868
+ elif is_str:
869
+ fields.append(TdfField(TYPE_CODES["STRING"], False, c.name, [], "", "", "", 1, 80, [80]))
870
+ int_names[c.name.upper()] = True
871
+ else:
872
+ is_int = _const_is_int(c.value, env, int_names)
873
+ int_names[c.name.upper()] = is_int
874
+ type_code = TYPE_CODES["LONG"] if is_int else TYPE_CODES["IEEE4"]
875
+ fields.append(TdfField(type_code, False, c.name, [], "", "", "", 1, 1, []))
876
+ if ct.name is None:
877
+ continue # bare ConstTable: no TDF table (§7)
878
+ fields.append(TdfField(TYPE_CODES["BOOLEAN"], False, "ApplyAndRestart", [], "", "", "", 1, 1, []))
879
+ tables.append(TdfTable(ct.name, 1, 14, (0, 0), (0, 0), fields))
880
+ return tables
881
+
882
+
883
+ # ---------------------------------------------------------------- DataTableInfo (§6)
884
+
885
+
886
+ def _datatableinfo_table(n: int) -> TdfTable:
887
+ def dims(n):
888
+ return [] if n == 1 else [n]
889
+
890
+ fields = (
891
+ []
892
+ if n == 0
893
+ else [
894
+ TdfField(11, True, "DataTableName", [], "", "", "", 1, 24 * n, [n, 24]),
895
+ TdfField(6, False, "SkippedRecord", [], "", "", "", 1, n, dims(n)),
896
+ TdfField(6, True, "DataRecordSize", [], "", "records", "", 1, 2 * n, [n, 2]),
897
+ TdfField(9, True, "SecsPerRecord", [], "", "", "", 1, n, dims(n)),
898
+ TdfField(9, True, "DataFillDays", [], "", "days", "", 1, 2 * n, [n, 2]),
899
+ TdfField(11, True, "DataFilled", [], "", "", "", 1, 64 * n, [n, 2, 32]),
900
+ TdfField(14, True, "LastPublish", [], "", "", "", 1, n, dims(n)),
901
+ ]
902
+ )
903
+ return TdfTable("DataTableInfo", 1, 14, (0, 0), (0, 0), fields)
904
+
905
+
906
+ # ---------------------------------------------------------------- DataTable output fields (§9)
907
+
908
+
909
+ def _source_info(expr: S.Expr | None, var_info: dict[str, VarInfo]) -> VarInfo | None:
910
+ if isinstance(expr, (S.Name, S.Index)):
911
+ return var_info.get(expr.name.upper())
912
+ if isinstance(expr, S.Member) and isinstance(expr.base, (S.Name, S.Index)):
913
+ return var_info.get(f"{expr.base.name}.{expr.member}".upper())
914
+ return None
915
+
916
+
917
+ def _source_name(expr: S.Expr | None, var_info: dict[str, VarInfo] | None = None) -> str:
918
+ if var_info is not None:
919
+ info = _source_info(expr, var_info)
920
+ if info is not None and info.name:
921
+ return info.name # declared spelling; CRBasic is case-insensitive at the use site
922
+ if isinstance(expr, (S.Name, S.Index)):
923
+ return expr.name
924
+ return expr.member if isinstance(expr, S.Member) else "Anonymous"
925
+
926
+
927
+ def _is_anonymous_source(expr: S.Expr | None, var_info: dict, env: consteval.Env) -> bool:
928
+ """docs/tdf-rules.md §9c: a Source that is not a variable (literal, `Const`, `NAN`/`True`, any
929
+ expression, a function call) is named `Anonymous<n>`; a `Table.Field` is not (it takes the
930
+ referenced field's name)."""
931
+ if isinstance(expr, S.Index):
932
+ return expr.name.upper() not in var_info
933
+ if isinstance(expr, S.Name):
934
+ return expr.name.upper() not in var_info and (_fold(expr, env, None) is not None or expr.name.upper() == "NAN")
935
+ return expr is not None and not isinstance(expr, S.Member)
936
+
937
+
938
+ def _anonymous_string_len(expr: S.Expr | None, var_info: dict, env: consteval.Env) -> int:
939
+ """Character count that sizes a non-variable String source (§9c): a literal is its own length,
940
+ a String variable its stored length, `+`/`&` of those the sum. String functions propagate a
941
+ temporary buffer length, not the length of their runtime result. Unknown/numeric sources
942
+ retain the 20-character conversion default (stored 24)."""
943
+
944
+ def known(e):
945
+ v = _fold(e, env, None)
946
+ if isinstance(v, str):
947
+ return len(v)
948
+ info = _source_info(e, var_info)
949
+ if info is not None:
950
+ return info.stored_len
951
+ if isinstance(e, (S.Name, S.Index)):
952
+ fn = env.profile.functions.get(e.name.upper())
953
+ if isinstance(e, S.Index) and fn and fn["kind"] == 312:
954
+ code = fn["code"]
955
+ if code == 229: # Chr / Chr$
956
+ return 2
957
+ if code == 243: # Hex
958
+ return 0
959
+ if code == 254: # IPInfo
960
+ return 104
961
+ if code in (239, 251) and e.args:
962
+ n = known(e.args[-1])
963
+ return (24 if n is None else n) + 60
964
+ if code == 252 and e.args: # FormatLongLong's single variable/string argument
965
+ return known(e.args[0])
966
+ if code == 250 and len(e.args) == 3: # Replace
967
+ first, last = known(e.args[0]), known(e.args[2])
968
+ return (24 if first is None else first) - 1 + (24 if last is None else last)
969
+ if code in (230, 237, 238, 245, 246, 247, 248, 249) and e.args:
970
+ n = known(e.args[0])
971
+ return 24 if n is None else n
972
+ return None
973
+ if isinstance(e, S.Binary) and e.op in ("+", "&"):
974
+ l, r = known(e.left), known(e.right)
975
+ return None if l is None or r is None else l + r
976
+ return None
977
+
978
+ n = known(expr)
979
+ return 20 if n is None else n
980
+
981
+
982
+ def _number_anonymous(
983
+ fields: list, src: S.Expr | None, var_info: dict, env: consteval.Env, anon: list[int] | None
984
+ ) -> None:
985
+ """Give the fields derived from a non-variable Source its `Anonymous<n>` name; `anon[0]` is the
986
+ program-wide count so far (one number per such Source, FieldNames or not)."""
987
+ if anon is None or not _is_anonymous_source(src, var_info, env):
988
+ return
989
+ anon[0] += 1
990
+ raw = _source_name(src, var_info)
991
+ for f in fields:
992
+ if f.name.startswith(raw):
993
+ f.name = f"Anonymous{anon[0]}" + f.name[len(raw) :]
994
+
995
+
996
+ def _source_shape(
997
+ expr: S.Expr | None, var_info: dict[str, VarInfo], env: consteval.Env, reps: int
998
+ ) -> tuple[int, list[int]]:
999
+ """(begin, subdims) for an output field's Source, per §3: subdims are the *declared* shape of the
1000
+ source array whatever `reps` is; begin is the 1-based row-major flat index of the first element."""
1001
+ if isinstance(expr, S.Member) and isinstance(expr.base, (S.Name, S.Index)):
1002
+ info = var_info.get(f"{expr.base.name}.{expr.member}".upper())
1003
+ if info is not None:
1004
+ return 1, list(info.shape)
1005
+ if isinstance(expr, (S.Index, S.Name)):
1006
+ info = var_info.get(expr.name.upper())
1007
+ if isinstance(expr, S.Index) and info is None and (len(expr.args) != 1 or isinstance(expr.args[0], S.String)):
1008
+ # The source-name parser accepts a single integer subscript, even on a function,
1009
+ # but not a comma-separated argument list or a quoted string (§9c).
1010
+ return 1, []
1011
+ array_shape = list(info.shape) if info and info.shape else ([reps] if isinstance(expr, S.Index) else [])
1012
+ if array_shape == [1]:
1013
+ array_shape = [] # §3 collapse rule: a one-element array has no subdims
1014
+ begin = 1
1015
+ if isinstance(expr, S.Index) and expr.args:
1016
+ idx = [_as_int(_fold(a, env, 1), 1) for a in expr.args]
1017
+ if info and info.shape and len(idx) == len(info.shape) > 1:
1018
+ if not all(isinstance(a, (S.Number, S.Name)) for a in expr.args):
1019
+ return 1, array_shape # docs/tdf-rules.md §9b: expression subscripts give begin=1
1020
+ begin = 0
1021
+ for k, v in enumerate(idx):
1022
+ stride = 1
1023
+ for d in info.shape[k + 1 :]:
1024
+ stride *= d
1025
+ begin += (v - 1) * stride
1026
+ begin += 1
1027
+ else:
1028
+ begin = idx[0]
1029
+ return begin, array_shape
1030
+ return 1, []
1031
+
1032
+
1033
+ def _units_for_source(expr: S.Expr | None, var_info: dict[str, VarInfo], by_display: dict[str, TdfField]) -> str:
1034
+ if isinstance(expr, S.Member) and isinstance(expr.base, (S.Name, S.Index)):
1035
+ info = var_info.get(expr.base.name.upper())
1036
+ if info is not None and info.struct is not None:
1037
+ return info.struct["units"].get(expr.member.upper(), "")
1038
+ name = _source_name(expr, var_info).upper()
1039
+ f = by_display.get(name)
1040
+ if f is not None:
1041
+ return f.units
1042
+ info = var_info.get(name)
1043
+ return info.units if info else ""
1044
+
1045
+
1046
+ @dataclass
1047
+ class _OutField:
1048
+ name: str
1049
+ type_code: int
1050
+ read_only: bool
1051
+ processing: str
1052
+ begin: int
1053
+ dim: int
1054
+ subdims: list[int]
1055
+ units: str = ""
1056
+ bytes: int = 0
1057
+ # Duplicate-field-name bookkeeping only (`checker/instr_data.check_duplicate_fields`):
1058
+ # `renamed` marks a name set by `FieldNames`; `name_only` fields (FFT, CH400Status source)
1059
+ # take part in that check but are never written to the TDF or counted in the record size;
1060
+ # `placeholder` distinguishes a generated Anonymous label from a user field of that name;
1061
+ # `rng` is the element range `(lo, hi)` the field covers (`None`: never conflicts by range).
1062
+ renamed: bool = False
1063
+ name_only: bool = False
1064
+ ch400: bool = False
1065
+ placeholder: bool = False
1066
+ rng: tuple[int, int] | None = None
1067
+ unit_list: list | None = None # per-element units when a FieldNames split must differ
1068
+ elem: int = 1 # bytes per element when splittable (a stored String element is stored_len)
1069
+ splittable: bool = False # dim == Reps * element size: follows the Source's alias decomposition
1070
+ struct_members: int = 0 # >0: field of a StructureType source (members per element)
1071
+ member: str = ""
1072
+
1073
+
1074
+ _FFT_UNBOUNDED = 1 << 30
1075
+
1076
+
1077
+ def _element_range(src: S.Expr | None, begin: int, dim: int, env: consteval.Env):
1078
+ if isinstance(src, S.Index) and len(src.args) > 1:
1079
+ if dim != 1:
1080
+ return None
1081
+ flat = 0
1082
+ for k, a in enumerate(src.args):
1083
+ v = _fold(a, env, None)
1084
+ if not isinstance(v, (int, float)):
1085
+ return None
1086
+ flat += int(v) << (20 * k)
1087
+ return flat, flat
1088
+ return begin, begin + max(dim, 1) - 1
1089
+
1090
+
1091
+ _TYPE_BYTES = {2: 2, 6: 4, 7: 2, 9: 4, 11: 0, 14: 8, 17: 1, 18: 8, 28: 4, 1: 1, 4: 4}
1092
+
1093
+
1094
+ def _type_bytes(type_code: int, dim_for_string: int = 0) -> int:
1095
+ if type_code == 11:
1096
+ return dim_for_string
1097
+ return _TYPE_BYTES.get(type_code, 4)
1098
+
1099
+
1100
+ def _elem_size(f: _OutField, info: VarInfo) -> int:
1101
+ return info.stored_len if (info.stored_len and f.type_code == 11) else 1
1102
+
1103
+
1104
+ def _split_by_alias(fields: list[_OutField], src, var_info: dict, by_base: dict, env: consteval.Env) -> list[_OutField]:
1105
+ """docs/tdf-rules.md §9a: an output field over an array that has element `Alias`es follows the
1106
+ Public table's field decomposition: one scalar field per aliased element (alias name + the
1107
+ instruction suffix, the alias' units), one field per un-aliased run (source name, `begin` =
1108
+ run start in the array). Only for fields with `dim == Reps * element size` (a `Covariance(6,A,...)`
1109
+ output of 21 values is one field named after the alias at its first element, unsplit); a
1110
+ `FieldNames`-named field is never split."""
1111
+ if not isinstance(src, (S.Name, S.Index)):
1112
+ return fields
1113
+ base = src.name.upper()
1114
+ groups = by_base.get(base)
1115
+ if not groups or not any(getattr(g, "alias_elem", False) for g in groups):
1116
+ return fields
1117
+ out = []
1118
+ declared = var_info[base].name
1119
+ for f in fields:
1120
+ if f.name_only or f.renamed or not f.name.startswith(declared):
1121
+ out.append(f)
1122
+ continue
1123
+ suffix = f.name[len(declared) :].lstrip("0123456789") # drop the flat-index digits (_idx_suffix)
1124
+ es = _elem_size(f, var_info[base])
1125
+ lo, hi = f.begin, f.begin + f.dim - 1
1126
+ if not f.splittable:
1127
+ # unsplit: named after the alias of the Source's first element, if it has one
1128
+ first = (_source_shape(src, var_info, env, 1)[0] - 1) * es + 1
1129
+ pos = 1
1130
+ for g in groups:
1131
+ n = es if getattr(g, "alias_elem", False) else g.dimension
1132
+ if pos <= first < pos + n:
1133
+ if getattr(g, "alias_elem", False):
1134
+ f.name = g.name + suffix
1135
+ break
1136
+ pos += n
1137
+ out.append(f)
1138
+ continue
1139
+ pos = 1
1140
+ for g in groups:
1141
+ if getattr(g, "alias_elem", False):
1142
+ glo, ghi = pos, pos + es - 1
1143
+ else:
1144
+ glo, ghi = g.begin_index, g.begin_index + g.dimension - 1
1145
+ pos = ghi + 1
1146
+ if ghi < lo or glo > hi:
1147
+ continue
1148
+ if getattr(g, "alias_elem", False):
1149
+ out.append(
1150
+ _OutField(
1151
+ g.name + suffix,
1152
+ f.type_code,
1153
+ f.read_only,
1154
+ f.processing,
1155
+ 1,
1156
+ es,
1157
+ list(g.subdims),
1158
+ g.units,
1159
+ rng=(glo, ghi),
1160
+ )
1161
+ )
1162
+ else:
1163
+ a, b = max(lo, glo), min(hi, ghi)
1164
+ out.append(
1165
+ _OutField(
1166
+ f.name,
1167
+ f.type_code,
1168
+ f.read_only,
1169
+ f.processing,
1170
+ a,
1171
+ b - a + 1,
1172
+ list(g.subdims),
1173
+ f.units,
1174
+ rng=(a, b),
1175
+ )
1176
+ )
1177
+ return out
1178
+
1179
+
1180
+ def _fft_field(
1181
+ s: S.Instruction, var_info: dict, by_display: dict, env: consteval.Env, anon: list[int] | None = None
1182
+ ) -> _OutField:
1183
+ """`FFT(Source, DataType, N, Tint, Units, Option)` (docs/tdf-rules.md §9b): one read-only field
1184
+ `<Source>[<flat element index if > 1>]_FFT`; layout by `Option mod 10`, description `FFT,N,<Tint seconds %.4e>,<Option mod 100>`.
1185
+ A `Table.Field` Source this module cannot resolve stays a name-only placeholder (no TDF field);
1186
+ any other non-variable Source is a field named `Anonymous<n>_FFT` (§9c)."""
1187
+ a = s.args
1188
+ src = a[0] if a else None
1189
+ placeholder = src is None or (
1190
+ isinstance(src, S.Member) and not var_info.get(f"{getattr(src.base, 'name', '')}.{src.member}".upper())
1191
+ )
1192
+ if placeholder:
1193
+ return _OutField(
1194
+ "Anonymous",
1195
+ 9,
1196
+ True,
1197
+ "FFT",
1198
+ 1,
1199
+ _FFT_UNBOUNDED,
1200
+ [],
1201
+ name_only=True,
1202
+ placeholder=True,
1203
+ rng=(1, _FFT_UNBOUNDED),
1204
+ )
1205
+ n = _as_int(_fold(a[2], env, 1), 1) if len(a) > 2 else 1
1206
+ tint = _fold(a[3], env, 0) if len(a) > 3 else 0
1207
+ unit = str(getattr(a[4], "name", "")).upper() if len(a) > 4 else "SEC"
1208
+ opt = _as_int(_fold(a[5], env, 0), 0) if len(a) > 5 else 0
1209
+ half = n // 2 + 1
1210
+ k = opt % 10
1211
+ if k == 5:
1212
+ dim, subdims = n, [n]
1213
+ elif k in (1, 3, 4):
1214
+ dim, subdims = half, [half]
1215
+ elif k in (0, 2):
1216
+ dim, subdims = 2 * half, [half, 2]
1217
+ else:
1218
+ dim, subdims = 2 * half, [half]
1219
+ first, shape = _source_shape(src, var_info, env, 1)
1220
+ if dim == 1:
1221
+ subdims = shape
1222
+ proc = "FFT,%d,%.4e,%d" % (n, float(tint) * _UNIT_SECONDS.get(unit, 1), opt % 100)
1223
+ out = _OutField(
1224
+ _source_name(src, var_info) + (str(first) if first > 1 else "") + "_FFT",
1225
+ (_dtype_code(a[1], env) if len(a) > 1 else 9),
1226
+ True,
1227
+ proc,
1228
+ 1,
1229
+ dim,
1230
+ subdims,
1231
+ _units_for_source(src, var_info, by_display),
1232
+ rng=(1, _FFT_UNBOUNDED),
1233
+ )
1234
+ _number_anonymous([out], src, var_info, env, anon)
1235
+ return out
1236
+
1237
+
1238
+ def _idx_suffix(src: S.Expr | None, var_info: dict, env: consteval.Env) -> str:
1239
+ """Flat first-element index appended to the source name when the Source is an array element
1240
+ other than the first (FFT, Covariance, Histogram, WindVector; docs/tdf-rules.md §9b)."""
1241
+ first, _ = _source_shape(src, var_info, env, 1)
1242
+ return str(first) if first > 1 else ""
1243
+
1244
+
1245
+ def _alias_source(instr: S.Instruction, var_info: dict, by_display: dict, by_base: dict | None):
1246
+ if (
1247
+ by_base
1248
+ and len(instr.args) > 1
1249
+ and isinstance(instr.args[1], S.Name)
1250
+ and instr.args[1].name.upper() not in var_info
1251
+ ):
1252
+ # a Source that names an element Alias is that element of its array (docs/tdf-rules.md §9a)
1253
+ target = by_display.get(instr.args[1].name.upper())
1254
+ of = getattr(target, "alias_of", None)
1255
+ if of is not None:
1256
+ a = instr.args[1]
1257
+ idx = S.Number(a.line, a.file, value=of[1], text=str(of[1]))
1258
+ args = list(instr.args)
1259
+ args[1] = S.Index(a.line, a.file, name=of[0], args=[idx])
1260
+ instr = dataclasses.replace(instr, args=args)
1261
+ return instr
1262
+
1263
+
1264
+ def _emit_output_fields(
1265
+ instr: S.Instruction,
1266
+ var_info: dict,
1267
+ by_display: dict,
1268
+ env: consteval.Env,
1269
+ by_base: dict | None = None,
1270
+ anon: list[int] | None = None,
1271
+ ) -> list[_OutField]:
1272
+ instr = _alias_source(instr, var_info, by_display, by_base)
1273
+ fields = _emit_struct_fields(instr, var_info, env)
1274
+ if fields is None:
1275
+ fields = _emit_output_fields_raw(instr, var_info, by_display, env)
1276
+ if len(instr.args) > 1 and isinstance(instr.args[1], (S.Name, S.Index)):
1277
+ info = var_info.get(instr.args[1].name.upper())
1278
+ reps = _fold(instr.args[0], env, None)
1279
+ if info is not None and consteval.is_finite_number(reps):
1280
+ for f in fields:
1281
+ f.elem = _elem_size(f, info)
1282
+ f.splittable = f.dim == int(reps) * f.elem
1283
+ src = instr.args[1] if len(instr.args) > 1 else None
1284
+ if (
1285
+ instr.keyword.upper() == "SAMPLE"
1286
+ and isinstance(src, S.Name)
1287
+ and src.name.upper() == "CH400STATUS"
1288
+ and "CH400STATUS" not in var_info
1289
+ ):
1290
+ for f in fields:
1291
+ f.ch400 = True
1292
+ for f in fields:
1293
+ if f.rng is None and not f.ch400:
1294
+ f.rng = _element_range(src, f.begin, f.dim, env)
1295
+ if instr.keyword.upper() != "HISTOGRAM":
1296
+ _number_anonymous(fields, src, var_info, env, anon)
1297
+ return fields
1298
+
1299
+
1300
+ _STRUCT_SUFFIX = {
1301
+ "SAMPLE": ("", "Smp"),
1302
+ "AVERAGE": ("_Avg", "Avg"),
1303
+ "TOTALIZE": ("_Tot", "Tot"),
1304
+ "MAXIMUM": ("_Max", "Max"),
1305
+ "MINIMUM": ("_Min", "Min"),
1306
+ }
1307
+
1308
+
1309
+ def _emit_struct_fields(instr: S.Instruction, var_info: dict, env: consteval.Env):
1310
+ """docs/tdf-rules.md §9a: an output instruction whose Source is a StructureType variable emits
1311
+ one field per member per element (reps elements); None when not applicable."""
1312
+ kw = instr.keyword.upper()
1313
+ a = instr.args
1314
+ if kw not in _STRUCT_SUFFIX or len(a) < 2 or not isinstance(a[1], (S.Name, S.Index)):
1315
+ return None
1316
+ info = var_info.get(a[1].name.upper())
1317
+ if info is None or info.struct is None:
1318
+ return None
1319
+ if kw in ("MAXIMUM", "MINIMUM") and len(a) > 4 and str(getattr(a[4], "name", "")).upper() == "TRUE":
1320
+ return None # time-of-extreme fields of a structure source: not probed
1321
+ reps = _as_int(_fold(a[0], env, 1), 1)
1322
+ if env.profile.name in ("cr300", "cr350"):
1323
+ reps = 1 # CR300/CR350 expand one element whatever Reps says (oracle, 2026-10-12)
1324
+ suffix, tag = _STRUCT_SUFFIX[kw]
1325
+ st = info.struct
1326
+ out = []
1327
+ for _ in range(reps):
1328
+ for m in st["members"]:
1329
+ mi = _make_var_info(m, env)
1330
+ dim, subdims = mi.collapsed()
1331
+ out.append(
1332
+ _OutField(
1333
+ m.name + suffix,
1334
+ mi.type_code,
1335
+ m.name.upper() in st["ro"],
1336
+ tag,
1337
+ 1,
1338
+ dim,
1339
+ subdims,
1340
+ st["units"].get(m.name.upper(), ""),
1341
+ struct_members=len(st["members"]),
1342
+ member=m.name,
1343
+ )
1344
+ )
1345
+ return out
1346
+
1347
+
1348
+ def _emit_output_fields_raw(
1349
+ instr: S.Instruction, var_info: dict, by_display: dict, env: consteval.Env
1350
+ ) -> list[_OutField]:
1351
+ kw = instr.keyword.upper()
1352
+ a = instr.args
1353
+
1354
+ def type_of(idx):
1355
+ return _dtype_code(a[idx], env) if idx < len(a) else 9
1356
+
1357
+ def reps_of():
1358
+ return _as_int(_fold(a[0], env, 1), 1) if a else 1
1359
+
1360
+ def shaped(src, reps):
1361
+ begin, shape = _source_shape(src, var_info, env, reps)
1362
+ subdims = shape if shape else ([] if reps == 1 else [reps])
1363
+ units = _units_for_source(src, var_info, by_display)
1364
+ return begin, subdims, units
1365
+
1366
+ if kw == "SAMPLE":
1367
+ reps = reps_of()
1368
+ src = a[1] if len(a) > 1 else None
1369
+ tcode = type_of(2)
1370
+ begin, subdims, units = shaped(src, reps)
1371
+ info = _source_info(src, var_info)
1372
+ dim = reps
1373
+ if info is not None and info.stored_len is not None and tcode == 11:
1374
+ # String source (docs/tdf-rules.md §9a): element = stored_len bytes
1375
+ ln = info.stored_len
1376
+ dim = reps * ln
1377
+ subdims = info.full_shape()
1378
+ if isinstance(src, S.Index) and src.args:
1379
+ begin = (begin - 1) * ln + 1
1380
+ elif tcode == 11 and _is_anonymous_source(src, var_info, env):
1381
+ # a non-variable String source is as wide as its value (docs/tdf-rules.md §9c)
1382
+ dim = _string_stored_len(_anonymous_string_len(src, var_info, env))
1383
+ subdims = [dim]
1384
+ begin = ((begin - 1) * dim + 1) & 0xFFFFFFFF
1385
+ return [
1386
+ _OutField(
1387
+ _source_name(src, var_info),
1388
+ tcode,
1389
+ True,
1390
+ "Smp",
1391
+ begin,
1392
+ dim,
1393
+ subdims,
1394
+ units,
1395
+ placeholder=True,
1396
+ elem=dim,
1397
+ splittable=True,
1398
+ )
1399
+ ]
1400
+ return [
1401
+ _OutField(
1402
+ _source_name(src, var_info),
1403
+ tcode,
1404
+ True,
1405
+ "Smp",
1406
+ begin,
1407
+ dim,
1408
+ subdims,
1409
+ units,
1410
+ placeholder=not isinstance(src, (S.Name, S.Index)) and info is None,
1411
+ )
1412
+ ]
1413
+
1414
+ if kw in ("AVERAGE", "TOTALIZE", "STDDEV"):
1415
+ reps = reps_of()
1416
+ src = a[1] if len(a) > 1 else None
1417
+ tcode = type_of(2)
1418
+ begin, subdims, units = shaped(src, reps)
1419
+ suffix, tag = {"AVERAGE": ("_Avg", "Avg"), "TOTALIZE": ("_Tot", "Tot"), "STDDEV": ("_Std", "Std")}[kw]
1420
+ name = _source_name(src, var_info) + suffix
1421
+ return [_OutField(name, tcode, True, tag, begin, reps, subdims, units)]
1422
+
1423
+ if kw in ("MAXIMUM", "MINIMUM"):
1424
+ reps = reps_of()
1425
+ src = a[1] if len(a) > 1 else None
1426
+ tcode = type_of(2)
1427
+ begin, subdims, units = shaped(src, reps)
1428
+ time_flag = False
1429
+ if len(a) > 4:
1430
+ time_flag = str(getattr(a[4], "name", "")).upper() == "TRUE"
1431
+ base = _source_name(src, var_info)
1432
+ vsuf, vtag, tsuf, ttag = ("_Max", "Max", "_TMx", "TMx") if kw == "MAXIMUM" else ("_Min", "Min", "_TMn", "TMn")
1433
+ out = [_OutField(base + vsuf, tcode, True, vtag, begin, reps, subdims, units)]
1434
+ if time_flag:
1435
+ out.append(_OutField(base + tsuf, 14, True, ttag, 1, reps, [] if reps == 1 else [reps], units))
1436
+ return out
1437
+
1438
+ if kw == "MEDIAN":
1439
+ reps = reps_of()
1440
+ src = a[1] if len(a) > 1 else None
1441
+ tcode = type_of(3)
1442
+ begin, subdims, units = shaped(src, reps)
1443
+ return [_OutField(_source_name(src, var_info) + "_Med", tcode, True, "Med", begin, reps, subdims, units)]
1444
+
1445
+ if kw == "MOMENT":
1446
+ reps = reps_of()
1447
+ src = a[1] if len(a) > 1 else None
1448
+ tcode = type_of(3)
1449
+ begin, subdims, units = shaped(src, reps)
1450
+ return [_OutField(_source_name(src, var_info) + "_MMT", tcode, True, "MMT", begin, reps, subdims, units)]
1451
+
1452
+ if kw == "SAMPLEMAXMIN":
1453
+ reps = reps_of()
1454
+ src = a[1] if len(a) > 1 else None
1455
+ tcode = type_of(2)
1456
+ begin, subdims, units = shaped(src, reps)
1457
+ return [_OutField(_source_name(src, var_info) + "_SMM", tcode, True, "SMM", begin, reps, subdims, units)]
1458
+
1459
+ if kw == "COVARIANCE":
1460
+ src = a[1] if len(a) > 1 else None
1461
+ tcode = type_of(2)
1462
+ n_cov = _as_int(_fold(a[4], env, 1), 1) if len(a) > 4 else 1
1463
+ units = _units_for_source(src, var_info, by_display)
1464
+ return [
1465
+ _OutField(
1466
+ _source_name(src, var_info) + _idx_suffix(src, var_info, env) + "_Cov",
1467
+ tcode,
1468
+ True,
1469
+ "Cov",
1470
+ 1,
1471
+ n_cov,
1472
+ [] if n_cov == 1 else [n_cov],
1473
+ units,
1474
+ )
1475
+ ]
1476
+
1477
+ if kw == "WINDVECTOR":
1478
+ speed = a[1] if len(a) > 1 else None
1479
+ tcode = type_of(3)
1480
+ output_opt = _as_int(_fold(a[7], env, 0), 0) if len(a) > 7 else 0
1481
+ dim = {0: 3, 1: 2, 2: 4, 3: 1, 4: 2}.get(output_opt, 3)
1482
+ units = _units_for_source(speed, var_info, by_display)
1483
+ # per-element units by Output option (docs/tdf-rules.md §9a): speed units, then "Deg"
1484
+ ulist = {
1485
+ 0: [units, "Deg", "Deg"],
1486
+ 1: [units, "Deg"],
1487
+ 2: [units, units, "Deg", "Deg"],
1488
+ 3: ["Deg"],
1489
+ 4: ["Deg", "Deg"],
1490
+ }.get(output_opt, [units] * dim)
1491
+ f = _OutField(
1492
+ _source_name(speed, var_info) + _idx_suffix(speed, var_info, env) + "_WVc",
1493
+ tcode,
1494
+ True,
1495
+ "WVc",
1496
+ 1,
1497
+ dim,
1498
+ [] if dim == 1 else [dim],
1499
+ ulist[0],
1500
+ )
1501
+ f.unit_list = ulist
1502
+ return [f]
1503
+
1504
+ if kw == "HISTOGRAM":
1505
+ bin_select = a[0] if a else None
1506
+ tcode = type_of(1)
1507
+ bins = _as_int(_fold(a[3], env, 1), 1) if len(a) > 3 else 1
1508
+ wtval = _fold(a[5], env, 0.0) if len(a) > 5 else 0.0
1509
+ lolim = _fold(a[6], env, 0.0) if len(a) > 6 else 0.0
1510
+ uplim = _fold(a[7], env, 0.0) if len(a) > 7 else 0.0
1511
+ tag = f"Hst,{bins},{float(wtval):.4e},{float(lolim):.4e},{float(uplim):.4e}"
1512
+ units = _units_for_source(bin_select, var_info, by_display)
1513
+ return [
1514
+ _OutField(
1515
+ _source_name(bin_select, var_info) + _idx_suffix(bin_select, var_info, env) + "_Hst",
1516
+ tcode,
1517
+ True,
1518
+ tag,
1519
+ 1,
1520
+ bins,
1521
+ [] if bins == 1 else [bins],
1522
+ units,
1523
+ )
1524
+ ]
1525
+
1526
+ return []
1527
+
1528
+
1529
+ def _apply_fieldnames(fields: list[_OutField], arg: str, profile_name: str = "") -> list[_OutField]:
1530
+ parts = arg.split(",")
1531
+ n_members = fields[0].struct_members if fields else 0
1532
+ if n_members and profile_name in ("cr300", "cr350"):
1533
+ return fields # `Fieldnames` after a structure source renames nothing there (oracle, 2026-10-12)
1534
+ if n_members:
1535
+ # structure source: name k prefixes every member of element k (`<name>_<member>`), the
1536
+ # instruction's own suffix (`_Avg`...) is dropped; unnamed elements keep default names
1537
+ raw = arg.split(",")
1538
+ for k, f in enumerate(fields):
1539
+ if k // n_members < len(raw):
1540
+ nm, _, desc = raw[k // n_members].partition(":")
1541
+ f.name = nm + "_" + f.member
1542
+ if desc:
1543
+ f.processing += ":" + desc
1544
+ f.renamed = True
1545
+ return fields
1546
+ if len(fields) == 1 and len(parts) == 1 and fields[0].dim == 1:
1547
+ name, _, desc = parts[0].partition(":")
1548
+ fields[0].name = name.strip()
1549
+ fields[0].renamed = True
1550
+ fields[0].placeholder = False
1551
+ fields[0].begin, fields[0].subdims = 1, []
1552
+ if desc:
1553
+ fields[0].processing += ":" + desc
1554
+ return fields
1555
+ if any(f.dim > 1 and f.splittable for f in fields) or (len(fields) == 1 and fields[0].dim > 1):
1556
+ # One name per repetition across the instruction's output fields in order (§9, exp #33/#37;
1557
+ # `Minimum(2,A,...,True)` + "M1,M2,T1,T2" names the Min then the TMn elements): each named
1558
+ # element is its own field and a field the names run out in keeps the remainder under the
1559
+ # original default name with the source array's full declared subdims (oracle-confirmed,
1560
+ # SbdPayload_tester.CR6: `Average(2,FinApp_Neutrons(),...) FieldNames("Neutrons")`).
1561
+ out = []
1562
+ names = list(parts)
1563
+ for base in fields:
1564
+ if not names:
1565
+ out.append(base)
1566
+ continue
1567
+ if not (base.dim > 1 and (base.splittable or len(fields) == 1)):
1568
+ name, _, desc = names.pop(0).partition(":")
1569
+ base.name = name.strip()
1570
+ base.renamed = True
1571
+ base.placeholder = False
1572
+ if desc:
1573
+ base.processing += ":" + desc
1574
+ out.append(base)
1575
+ continue
1576
+ es = base.elem if base.splittable else 1
1577
+ n_named = min(len(names), base.dim // es)
1578
+ for i in range(n_named):
1579
+ name, _, desc = names.pop(0).partition(":")
1580
+ proc = base.processing + (":" + desc if desc else "")
1581
+ rng = None if base.rng is None else (base.rng[0] + i * es, base.rng[0] + (i + 1) * es - 1)
1582
+ u = base.unit_list[i] if base.unit_list and i < len(base.unit_list) else base.units
1583
+ out.append(
1584
+ _OutField(
1585
+ name.strip(),
1586
+ base.type_code,
1587
+ base.read_only,
1588
+ proc,
1589
+ 1,
1590
+ es,
1591
+ [es] if es > 1 else [],
1592
+ u,
1593
+ renamed=True,
1594
+ name_only=base.name_only,
1595
+ placeholder=False,
1596
+ rng=rng,
1597
+ )
1598
+ )
1599
+ remaining = base.dim - n_named * es
1600
+ if remaining > 0:
1601
+ rng = None if base.rng is None else (base.rng[0] + n_named * es, base.rng[1])
1602
+ out.append(
1603
+ _OutField(
1604
+ base.name,
1605
+ base.type_code,
1606
+ base.read_only,
1607
+ base.processing,
1608
+ base.begin + n_named * es,
1609
+ remaining,
1610
+ base.subdims,
1611
+ base.units,
1612
+ name_only=base.name_only,
1613
+ placeholder=base.placeholder,
1614
+ rng=rng,
1615
+ splittable=base.splittable,
1616
+ elem=base.elem,
1617
+ )
1618
+ )
1619
+ return out
1620
+ for f, part in zip(fields, parts):
1621
+ name, _, desc = part.partition(":")
1622
+ f.name = name.strip()
1623
+ f.renamed = True
1624
+ f.placeholder = False
1625
+ if desc:
1626
+ f.processing += ":" + desc
1627
+ return fields
1628
+
1629
+
1630
+ # ---------------------------------------------------------------- user DataTable header (§5)
1631
+
1632
+ _TIME_KEYWORDS = {"DATAINTERVAL", "DATAEVENT"}
1633
+ _OUTPUT_KEYWORDS = {
1634
+ "SAMPLE",
1635
+ "AVERAGE",
1636
+ "MAXIMUM",
1637
+ "MINIMUM",
1638
+ "TOTALIZE",
1639
+ "STDDEV",
1640
+ "MEDIAN",
1641
+ "MOMENT",
1642
+ "SAMPLEMAXMIN",
1643
+ "HISTOGRAM",
1644
+ "WINDVECTOR",
1645
+ "COVARIANCE",
1646
+ }
1647
+
1648
+
1649
+ _UNIT_MON = 6 # predefined `MON`; a DataInterval unit only (`Scan` has no month)
1650
+
1651
+
1652
+ def _interval_from_args(args: list, env: consteval.Env, into_idx: int, val_idx: int, unit_idx: int):
1653
+ """(TintoInt, Interval) as (sec, nsec) TDF pairs, the Interval value, and its unit name for the
1654
+ `-l` line. The units argument reads by value (`_unit_value`): 0..5 are the named units, 6 is MON
1655
+ (both TDF pairs (0,0) and TintoInt dropped, oracle round 20 W4), and any other value is invalid
1656
+ (Interval 1 Sec, TintoInt dropped; oracle round 20 W4). A non-constant units argument reads as
1657
+ seconds."""
1658
+ unit_val = _unit_value(args[unit_idx], env) if len(args) > unit_idx else None
1659
+ into_val = _fold(args[into_idx], env, 0) if len(args) > into_idx else 0
1660
+ interval_val = _fold(args[val_idx], env, 0) if len(args) > val_idx else 0
1661
+ # A String `TintoInt`/`Interval` (`DataInterval("x",15,Sec,10)`) compiles on CR300/CR350
1662
+ # (oracle, 2026-10-12): it is taken as 0 here rather than crashing in `float()`.
1663
+ into_val = into_val if consteval.is_finite_number(into_val) else 0
1664
+ interval_val = interval_val if consteval.is_finite_number(interval_val) else 0
1665
+ if unit_val == _UNIT_MON:
1666
+ return (0, 0), (0, 0), float(interval_val or 0), "MON"
1667
+ if unit_val is not None and unit_val not in _UNIT_NAME_BY_VALUE:
1668
+ into_val, interval_val, unit_val = 0, 1, 2
1669
+ unit_name = "SEC" if unit_val is None else _UNIT_NAME_BY_VALUE.get(unit_val, "SEC")
1670
+ ns_per_unit = _UNIT_NS[unit_name]
1671
+ into_ns = int(round(float(into_val or 0) * ns_per_unit))
1672
+ interval_ns = int(round(float(interval_val or 0) * ns_per_unit))
1673
+ into = (into_ns // 1_000_000_000, into_ns % 1_000_000_000)
1674
+ interval = (interval_ns // 1_000_000_000, interval_ns % 1_000_000_000)
1675
+ return into, interval, float(interval_val or 0), unit_name
1676
+
1677
+
1678
+ def _build_data_table(
1679
+ dt: DataTableDecl,
1680
+ env: consteval.Env,
1681
+ var_info: dict,
1682
+ by_display: dict,
1683
+ called: set[str],
1684
+ by_base: dict | None = None,
1685
+ anon: list[int] | None = None,
1686
+ ) -> tuple[TdfTable, dict]:
1687
+ """Returns (TdfTable, meta) where meta carries auto-sizing inputs (bytes_per_record,
1688
+ interval_seconds, has_interval, has_card) for tables whose declared Size is -1.
1689
+ """
1690
+ into = (0, 0)
1691
+ interval = (0, 0)
1692
+ interval_seconds = None
1693
+ display_interval = None # (value, unit_text) for the -l "Interval:" line
1694
+ zero_interval = False
1695
+ has_card = False
1696
+ lapses = 10
1697
+ own_weight = None # the DataInterval's own interval in seconds, kept even when the header is cleared
1698
+
1699
+ out_fields: list[_OutField] = []
1700
+ pending: list[_OutField] = []
1701
+ pending_src = None # Source of `pending` when it is eligible for alias splitting (after FieldNames)
1702
+
1703
+ def split_pending():
1704
+ if pending_src is not None and by_base:
1705
+ return _split_by_alias(pending, pending_src, var_info, by_base, env)
1706
+ return pending
1707
+
1708
+ for s in dt.body:
1709
+ kw = _kw(s)
1710
+ if isinstance(s, S.Instruction) and (kw == "DATAINTERVAL"):
1711
+ into, interval, ival, unit = _interval_from_args(s.args, env, 0, 1, 2)
1712
+ interval_seconds = ival * (_UNIT_SECONDS.get(unit, 1))
1713
+ display_interval = (ival, unit)
1714
+ zero_interval = interval_seconds == 0
1715
+ if unit == "MON":
1716
+ # no `-l` line for a zero MON interval, no enclosing-Scan weight, and no interval
1717
+ # for the auto-size arithmetic (oracle round 20 W5: Size -1 gives 232674 records for
1718
+ # 1, 2, 15 and 100 MON, the size of a table with no interval)
1719
+ zero_interval = False
1720
+ display_interval = (ival, unit) if ival else None
1721
+ interval_seconds = None
1722
+ if zero_interval:
1723
+ interval_seconds = None
1724
+ own_weight = interval_seconds
1725
+ if len(s.args) > 3:
1726
+ lapses = abs(_as_int(_fold(s.args[3], env, 10), 10))
1727
+ if len(s.args) > 3 and _fold(s.args[3], env, None) == 0:
1728
+ # Lapses = 0: TDF header and sizing as for a table with no DataInterval; only the
1729
+ # `-l` "Interval:" line remains (docs/tdf-rules.md §5)
1730
+ into, interval, interval_seconds = (0, 0), (0, 0), None
1731
+ elif kw == "DATAEVENT":
1732
+ pass # interval/into stay (0,0) (§5, oracle-confirmed)
1733
+ elif kw in ("CARDOUT", "TABLEFILE"):
1734
+ has_card = True
1735
+ elif isinstance(s, S.Instruction) and (kw == "FIELDNAMES"):
1736
+ if s.args and isinstance(s.args[0], S.String) and pending:
1737
+ pending = _apply_fieldnames(pending, s.args[0].value, env.profile.name)
1738
+ out_fields.extend(split_pending())
1739
+ pending, pending_src = [], None
1740
+ elif kw in _OUTPUT_KEYWORDS and isinstance(s, S.Instruction):
1741
+ if pending:
1742
+ out_fields.extend(split_pending())
1743
+ s = _alias_source(s, var_info, by_display, by_base)
1744
+ pending = _emit_output_fields(s, var_info, by_display, env, by_base, anon)
1745
+ pending_src = s.args[1] if len(s.args) > 1 and not any(f.struct_members for f in pending) else None
1746
+ elif isinstance(s, S.Instruction) and (kw == "FFT"):
1747
+ if pending:
1748
+ out_fields.extend(split_pending())
1749
+ pending, pending_src = [_fft_field(s, var_info, by_display, env, anon)], None
1750
+ elif kw in ("ETSZ", "ETCLEARSKY"):
1751
+ if pending:
1752
+ out_fields.extend(split_pending())
1753
+ pending, pending_src = (
1754
+ [_OutField(n, 7, True, n, 1, 1, [], name_only=True, rng=(1, _FFT_UNBOUNDED)) for n in ("ETrs", "Rso")],
1755
+ None,
1756
+ ) # name-only: the TDF fields are not derived
1757
+ if pending:
1758
+ out_fields.extend(split_pending())
1759
+
1760
+ fields = []
1761
+ for of in out_fields:
1762
+ if of.name_only:
1763
+ continue
1764
+ fields.append(
1765
+ TdfField(
1766
+ of.type_code,
1767
+ of.read_only,
1768
+ of.name,
1769
+ [],
1770
+ of.processing,
1771
+ of.units,
1772
+ of.processing,
1773
+ of.begin,
1774
+ of.dim,
1775
+ of.subdims,
1776
+ )
1777
+ )
1778
+
1779
+ size_raw = _fold(dt.size_expr, env, -1)
1780
+
1781
+ bytes_per_record = sum(_field_record_bytes(of) for of in out_fields if not of.name_only)
1782
+ size_val = declared_size(size_raw, env.profile.name in ("cr300", "cr350"), bytes_per_record)
1783
+ size_val = -1 if size_val is None else size_val
1784
+
1785
+ if interval_seconds is not None and 2 * bytes_per_record > _DEFAULT_PROBE_BUFFER_BYTES - _DEFAULT_PROBE_OVERHEAD:
1786
+ # fewer than 2 records fit the 1008-byte probe buffer: sized and stamped like a no-interval table
1787
+ into, interval, interval_seconds = (0, 0), (0, 0), None
1788
+
1789
+ meta = dict(
1790
+ own_weight=own_weight,
1791
+ lapses=lapses,
1792
+ zero_interval=zero_interval,
1793
+ size_val=size_val,
1794
+ size_raw=size_raw,
1795
+ bytes_per_record=bytes_per_record,
1796
+ has_event=any(_kw(s) == "DATAEVENT" for s in dt.body),
1797
+ fillstop=any(_kw(s) == "FILLSTOP" for s in dt.body),
1798
+ interval_seconds=interval_seconds,
1799
+ has_card=has_card,
1800
+ display_interval=display_interval,
1801
+ called=dt.name.upper() in called,
1802
+ )
1803
+
1804
+ table = TdfTable(dt.name, 1, 14, into, interval, fields)
1805
+ table._dup_fields = out_fields # type: ignore[attr-defined]
1806
+ if display_interval is not None:
1807
+ table._interval_display = display_interval # type: ignore[attr-defined]
1808
+ return table, meta
1809
+
1810
+
1811
+ def _field_record_bytes(of: _OutField) -> int:
1812
+ """Per-record byte cost of one output field: for ASCII (type 11) `dim` already IS the byte
1813
+ length; for numeric types it is `dim` (element count) times the type's own byte width."""
1814
+ if of.type_code == 11:
1815
+ return of.dim
1816
+ return of.dim * _TYPE_BYTES.get(of.type_code, 4)
1817
+
1818
+
1819
+ # ---------------------------------------------------------------- auto-size (§10)
1820
+
1821
+
1822
+ def _table_default_min(bytes_per_record: int) -> int:
1823
+ """`floor((1024-16)/bytes)`: a table's own default record count (probe buffer)."""
1824
+ if bytes_per_record <= 0:
1825
+ return 1
1826
+ return max((_DEFAULT_PROBE_BUFFER_BYTES - _DEFAULT_PROBE_OVERHEAD) // bytes_per_record, 1)
1827
+
1828
+
1829
+ def _resolve_auto_sizes(tables_meta: list[tuple[TdfTable, dict]], scan_weights: dict[str, float]):
1830
+ autos = [(t, m) for t, m in tables_meta if m["size_val"] == -1 and m["called"]]
1831
+ if not autos:
1832
+ return
1833
+ reserved = _TABLE_INIT_RESERVED
1834
+ rate = 0.0
1835
+ for t, m in autos:
1836
+ # weight: a DataInterval table's own interval; a DataEvent/no-interval table's enclosing
1837
+ # Scan/SlowSequence interval (§10 "scan-interval weighting"), 1 s if none was found.
1838
+ w = _f32(m["own_weight"] or scan_weights.get(t.name.upper(), 1.0))
1839
+ b = m["bytes_per_record"]
1840
+ lapses = m["lapses"] if m["interval_seconds"] else 0
1841
+ dflt = _table_default_min(b) if lapses else 1
1842
+ if dflt < 2:
1843
+ dflt, lapses = 1, 0
1844
+ t8 = dflt * b + _DEFAULT_PROBE_OVERHEAD
1845
+ m.update(_w=w, _dflt=dflt, _t8=t8, _lapses=lapses)
1846
+ m["_reserve"] = (
1847
+ (2 * _DEFAULT_PROBE_BUFFER_BYTES) // t8
1848
+ + (1 if dflt == 1 else 0)
1849
+ + (1 if t8 < _DEFAULT_PROBE_BUFFER_BYTES else 3)
1850
+ ) * t8
1851
+ m["_rate"] = _f32(_f32(t8) / _f32(_f32(dflt) * w))
1852
+ # a CardOut/TableFile table (nonzero card size) skips the Lapses-buffer term
1853
+ reserved += m["_reserve"]
1854
+ if lapses and b and not m["has_card"]:
1855
+ if (
1856
+ 0 < lapses < 1 << 31
1857
+ ): # `Lapses * 16` wraps to 32 bits before the division (CR6/CR1000X `Out of memory.`)
1858
+ w16 = (lapses * 16 + (1 << 31)) % (1 << 32) - (1 << 31)
1859
+ reserved += (int(w16 / b) + 1) * b
1860
+ else:
1861
+ reserved += ((lapses * 16) // b + 1) * b
1862
+ rate = _f32(m["_rate"] + rate)
1863
+ duration = _f32(_f32(_FREE_MEMORY - reserved) / rate) if rate else float("inf")
1864
+ if rate:
1865
+ # tables whose interval is not shorter than the duration get 2 records and leave the pool
1866
+ for _t, m in autos:
1867
+ if duration > 0 and duration <= m["_w"]:
1868
+ m["_forced"] = True
1869
+ rate = _f32(rate - m["_rate"])
1870
+ reserved -= m["_reserve"]
1871
+ duration = _f32(_f32(_FREE_MEMORY - reserved) / rate) if rate else float("inf")
1872
+ for t, m in autos:
1873
+ if m.get("_forced"):
1874
+ t.size = 2
1875
+ continue
1876
+ records = int(_f32(duration / m["_w"])) if m["bytes_per_record"] else 0
1877
+ m["_n_raw"] = records # before the minimum: the count `checker/storage.cr6_table_oom` charges
1878
+ t.size = max(records, 2, m["_dflt"])
1879
+
1880
+
1881
+ def _apply_sizes(tables_meta: list[tuple[TdfTable, dict]], scan_weights: dict[str, float], resolve_auto: bool = True):
1882
+ for t, m in tables_meta:
1883
+ w = scan_weights.get(t.name.upper())
1884
+ if (
1885
+ m["zero_interval"]
1886
+ and m["called"]
1887
+ and w
1888
+ and 2 * m["bytes_per_record"] <= _DEFAULT_PROBE_BUFFER_BYTES - _DEFAULT_PROBE_OVERHEAD
1889
+ ):
1890
+ # DataInterval(0,0,...): the interval is the enclosing Scan's (docs/tdf-rules.md §5)
1891
+ ns = round(w * 1_000_000_000)
1892
+ t.interval = (ns // 1_000_000_000, ns % 1_000_000_000)
1893
+ m["interval_seconds"] = w
1894
+ unit = m["display_interval"][1]
1895
+ t._interval_display = (w / _UNIT_SECONDS.get(unit, 1), unit) # type: ignore[attr-defined]
1896
+ if not m["called"]:
1897
+ # A table with no `CallTable` anywhere in the compiled program never gets its
1898
+ # DataInterval/DataEvent finalised either (oracle-confirmed, docs/tdf-rules.md §13):
1899
+ # `size=1` *and* `interval`/`into` reset to (0,0), as if declared with neither.
1900
+ t.size = 1
1901
+ t.interval = (0, 0)
1902
+ t.time_into = (0, 0)
1903
+ elif m["size_val"] != -1:
1904
+ t.size = m["size_val"]
1905
+ if resolve_auto:
1906
+ _resolve_auto_sizes(tables_meta, scan_weights)
1907
+
1908
+
1909
+ # ---------------------------------------------------------------- top-level
1910
+
1911
+
1912
+ def _register_table_sources(table: TdfTable, var_info: dict[str, VarInfo]) -> None:
1913
+ """Descriptors for `Table.Field` sources (§9b/§9c), without inheriting table-field units."""
1914
+ for f in table.fields:
1915
+ shape = list(f.subdims)
1916
+ stored_len = None
1917
+ if f.type == 11:
1918
+ stored_len = shape.pop() if shape else f.dimension
1919
+ elif not shape and f.dimension > 1:
1920
+ shape = [f.dimension]
1921
+ var_info[f"{table.name}.{f.name}".upper()] = VarInfo(f.type, shape, stored_len, name=f.name)
1922
+
1923
+
1924
+ def build_tables(program: S.Program, profile, consts: dict):
1925
+ env = consteval.Env(profile, dict(consts))
1926
+ scanned = _scan_program(program)
1927
+ scan_weights = _scan_weights(program.statements, env)
1928
+ scan_calls = _scan_calls(program.statements, env)
1929
+
1930
+ var_info: dict[str, VarInfo] = {}
1931
+ for v in scanned["all_vars"]:
1932
+ var_info[v.name.upper()] = _make_var_info(v, env)
1933
+ var_info[v.name.upper()].struct = scanned["struct_types"].get((v.type or "").upper())
1934
+
1935
+ # Build the Public table first (source-variable units lookups for output fields need its
1936
+ # display-name map), but keep its position in the TDF (after DataTableInfo/ConstTables, §2).
1937
+ public_table, by_display, by_base = _build_public_table(
1938
+ scanned["publics"], var_info, scanned["alias_stmts"], scanned["units_stmts"], scanned["readonly_names"]
1939
+ )
1940
+
1941
+ tables: list[TdfTable] = [_status_table(scanned["slow_seq_count"])]
1942
+ struct_tables = _build_struct_tables(scanned["all_vars"], scanned["struct_types"], env)
1943
+ tables.extend(struct_tables)
1944
+ if scanned["uses_ch400"]:
1945
+ struct_tables = struct_tables + [_ch400status_table()]
1946
+ tables.append(struct_tables[-1])
1947
+ for table in tables:
1948
+ _register_table_sources(table, var_info)
1949
+
1950
+ data_table_metas = []
1951
+ anon = [0] # program-wide `Anonymous<n>` counter (docs/tdf-rules.md §9c)
1952
+ for dt in scanned["data_tables"]:
1953
+ table, meta = _build_data_table(dt, env, var_info, by_display, scanned["called"], by_base, anon)
1954
+ tables.append(table)
1955
+ data_table_metas.append((table, meta))
1956
+ _register_table_sources(table, var_info)
1957
+
1958
+ if scanned["data_tables"] or struct_tables:
1959
+ tables.append(_datatableinfo_table(len(scanned["data_tables"])))
1960
+
1961
+ if scanned["uses_cpi"]:
1962
+ tables.append(_cpistatus_table())
1963
+
1964
+ tables.extend(_build_const_tables(scanned["const_tables"], env))
1965
+
1966
+ if public_table is not None:
1967
+ tables.append(public_table)
1968
+
1969
+ cr3xx = profile.name in ("cr300", "cr350")
1970
+ if cr3xx:
1971
+ last_call = _last_call_interval(program.statements, env)
1972
+ for table, meta in data_table_metas:
1973
+ meta["call_w"] = meta["own_weight"] or last_call.get(table.name.upper())
1974
+ _apply_sizes(data_table_metas, scan_weights, resolve_auto=not cr3xx)
1975
+ alloc = None
1976
+ if cr3xx and data_table_metas:
1977
+ from .checker import storage
1978
+
1979
+ pool = storage.pool_bytes(
1980
+ profile.name,
1981
+ len(data_table_metas),
1982
+ bool(scanned["publics"]),
1983
+ sum(1 for c in scanned["const_tables"] if c.name),
1984
+ ) - _preserve_deduction(program, env, scanned, var_info)
1985
+ metas = [m for _, m in data_table_metas]
1986
+ alloc = storage.run(metas, pool)
1987
+ if alloc:
1988
+ for (table, m), n in zip(data_table_metas, alloc["sizes"]):
1989
+ if m["called"] and (m["size_val"] == -1 or n > table.size):
1990
+ table.size = n # a fixed size is raised to the record block `k` (Sub-called)
1991
+ else:
1992
+ for table, m in data_table_metas:
1993
+ if m["called"] and m["size_val"] == -1:
1994
+ table.size = 1
1995
+ for table, meta in data_table_metas:
1996
+ if alloc:
1997
+ meta["alloc"] = alloc
1998
+ table._storage = dict(meta, scans=scan_calls.get(table.name.upper(), [])) # type: ignore[attr-defined]
1999
+
2000
+ table_list_text = render_table_list(tables)
2001
+ return tables, table_list_text, None
2002
+
2003
+
2004
+ def _preserve_deduction(program: S.Program, env, scanned, var_info) -> int:
2005
+ """CR300/CR350 pool bytes taken before the tables by `PreserveVariables` and
2006
+ `PreserveOneVariable` (docs/tdf-rules.md "Preserved variables"; `checker/storage.py`)."""
2007
+ from .checker import storage
2008
+
2009
+ stmts = program.statements
2010
+ kws = [_kw(s) for s in stmts]
2011
+ if "PRESERVEVARIABLES" not in kws and "PRESERVEONEVARIABLE" not in kws:
2012
+ return 0
2013
+ ticks = None # smallest main-sequence Scan; SlowSequence scans only lift the 60 s cap
2014
+ slow = False
2015
+ for s in stmts:
2016
+ if _kw(s) == "SLOWSEQUENCE":
2017
+ slow = True
2018
+ elif isinstance(s, S.Instruction) and (_kw(s) == "SCAN"):
2019
+ if slow:
2020
+ ticks = 600000 if ticks is None else ticks
2021
+ elif s.args:
2022
+ val = _fold(s.args[0], env, None)
2023
+ if isinstance(val, (int, float)) and not isinstance(val, bool) and val > 0:
2024
+ unit = _scan_unit_name(s.args[1], env) if len(s.args) > 1 else "SEC"
2025
+ t = int(val * _UNIT_NS.get(unit, _UNIT_NS["SEC"]) // 100_000)
2026
+ ticks = t if ticks is None else min(ticks, t)
2027
+ ticks = 100000 if ticks is None else min(ticks, 600000)
2028
+
2029
+ def size(v: S.VarDecl) -> tuple[int, int]:
2030
+ """(element width, element count) of a declaration; a `Double` is 8 bytes."""
2031
+ info = _make_var_info(v, env)
2032
+ return info.stored_len or (8 if (v.type or "").upper() == "DOUBLE" else 4), math.prod(info.shape)
2033
+
2034
+ total = 0
2035
+ if "PRESERVEVARIABLES" in kws:
2036
+ pub = 16 # every Public, Dim and Sub-level Dim, plus each StructureType member once
2037
+ decls = [v for v in scanned["all_vars"] if (v.type or "").upper() not in scanned["struct_types"]]
2038
+ decls += [m for st in scanned["struct_types"].values() for m in st["members"]]
2039
+ pub += sum(w * n for w, n in map(size, decls))
2040
+ total += storage.preserve_charge(64, 60, 0, 100000, ticks, pub)
2041
+ by_name = {v.name.upper(): v for v in scanned["all_vars"]}
2042
+ payloads = []
2043
+ for s in stmts:
2044
+ if _kw(s) != "PRESERVEONEVARIABLE" or not isinstance(s, S.Instruction) or not s.args:
2045
+ continue
2046
+ a = s.args[0]
2047
+ v = by_name.get(getattr(a, "name", "").upper())
2048
+ if v is None:
2049
+ continue
2050
+ w, n = size(v)
2051
+ whole = not isinstance(a, S.Index) or not any(x is not None for x in a.args) # `V()` is the whole array
2052
+ payloads.append(w * n if whole else w)
2053
+ if payloads:
2054
+ top = max(payloads)
2055
+ total += storage.preserve_charge(top + 78, top, len(payloads), 20000, ticks, 0)
2056
+ return total
2057
+
2058
+
2059
+ # ---------------------------------------------------------------- `-l` table list (docs/cli.md)
2060
+
2061
+ # Defensive cap on how many index tuples a single field's `-l` listing will ever enumerate.
2062
+ # `checker/structure.py`'s array-dimension check (`docs/checker.md` "Array dimension limits")
2063
+ # rejects the specific fuzz-found case that made this hang outright (a `Public`/`Dim` dimension
2064
+ # above `INT32_MAX`, `docs/fuzz-findings.md` "Hang"), so `build_tables`/`tables_hook.attach` is
2065
+ # only ever reached for a declaration this checker considers legal -- but that still allows a
2066
+ # dimension anywhere up to `INT32_MAX` through to here, and the real compiler itself rejects most
2067
+ # of that range with `Out of memory.` (`checker/storage.py`, not this module). This is therefore pure robustness, not a claim about a
2068
+ # verified real-compiler limit: `_MAX_EXPLODE_COUNT` is comfortably above the largest single-field
2069
+ # element count this session could get the real compiler to actually accept (2,000,000, oracle-
2070
+ # confirmed), so no known-exact `-l` output is affected, and it keeps this generator from ever
2071
+ # building tens or hundreds of millions of index tuples for a declaration this checker did not
2072
+ # (or, for some other field this module derives a huge `dimension` for, cannot) reject.
2073
+ _MAX_EXPLODE_COUNT = 2_000_000
2074
+
2075
+
2076
+ def _explode_indices(subdims: list[int], drop_last: bool, begin: int = 1, dim: int = 1):
2077
+ """Row-major (last dim fastest) index tuples for the `dim` stored elements starting at the
2078
+ 1-based linear position `begin` within `subdims` (after dropping the trailing string-storage
2079
+ dim for ASCII fields, per §3/§8's alias-prefix/suffix fields and §3's Reps<array-size case:
2080
+ both need the *slice*, not every valid coordinate of the full declared shape). Bounded by
2081
+ `_MAX_EXPLODE_COUNT` regardless of how large `dim`/`subdims` claim to be; see that constant."""
2082
+ dims = subdims[:-1] if drop_last and subdims else subdims
2083
+ if drop_last and subdims and dim % subdims[-1] == 0 and begin % subdims[-1] == 1 % subdims[-1]:
2084
+ # string field: begin/dim are in bytes (§9a), convert to elements
2085
+ begin, dim = (begin - 1) // subdims[-1] + 1, dim // subdims[-1]
2086
+ if not dims:
2087
+ yield ()
2088
+ return
2089
+ total = 1
2090
+ for d in dims:
2091
+ total *= d
2092
+ strides = [1] * len(dims)
2093
+ acc = 1
2094
+ for i in range(len(dims) - 1, -1, -1):
2095
+ strides[i] = acc
2096
+ acc *= dims[i]
2097
+ start = max(min(begin - 1, total - 1), 0)
2098
+ if len(dims) == 1 and begin + dim - 1 > total and not drop_last:
2099
+ # a rank-1 field longer than its subdim (FFT Option mod 10 = 6..9: dim 2*(N/2+1) over
2100
+ # subdims [N/2+1]) lists its index running on past the subdim (docs/tdf-rules.md §9b)
2101
+ start, count = max(begin - 1, 0), min(dim, _MAX_EXPLODE_COUNT)
2102
+ for lin in range(start, start + count):
2103
+ yield (lin + 1,)
2104
+ return
2105
+ count = max(min(dim, total - start), 0)
2106
+ count = min(count, _MAX_EXPLODE_COUNT)
2107
+ for lin in range(start, start + count):
2108
+ coord = []
2109
+ rem = lin
2110
+ for _d, stride in zip(dims, strides):
2111
+ coord.append(rem // stride + 1)
2112
+ rem %= stride
2113
+ yield tuple(coord)
2114
+
2115
+
2116
+ def render_table_list(tables: list[TdfTable]) -> str:
2117
+ lines: list[str] = []
2118
+ for t in tables:
2119
+ if t.name == "Status":
2120
+ continue
2121
+ lines.append("-" * 52)
2122
+ lines.append(f"Table: {t.name}")
2123
+ disp = getattr(t, "_interval_display", None)
2124
+ if disp is not None:
2125
+ value, unit = disp
2126
+ value = int(value) if float(value).is_integer() else value
2127
+ lines.append(f"Interval: {value} {unit}")
2128
+ elif t.interval != (0, 0):
2129
+ # Fallback when the original declared unit wasn't carried on the table (shouldn't
2130
+ # happen for a DataInterval-bearing table built via _build_data_table).
2131
+ secs = t.interval[0]
2132
+ value, unit = _seconds_to_display(secs)
2133
+ lines.append(f"Interval: {value} {unit}")
2134
+ lines.append("Fields:")
2135
+ for f in t.fields:
2136
+ drop_last = f.type == 11 and bool(f.subdims)
2137
+ dims_after_drop = f.subdims[:-1] if drop_last else f.subdims
2138
+ if f.type == 11 and len(dims_after_drop) >= 2:
2139
+ # Oracle-confirmed quirk (DataTableInfo's DataFilled at n=1,2,7; a 2D Public
2140
+ # String array, filelist_1.CR6's `Files(3,10) As String * 25`): an ASCII field
2141
+ # with 2+ non-string dims does NOT get the plain Cartesian product every other
2142
+ # multi-dim field gets (contrast DataRecordSize/DataFillDays, same [n,2] shape but
2143
+ # type LONG/IEEE4, which *do* show the full product): the `-l` lister instead
2144
+ # computes both displayed coordinates via `divmod` by the *string storage length*
2145
+ # (`stored_len` = `subdims[-1]`) rather than by the true adjacent declared
2146
+ # dimension -- confirmed exactly for `Files(3,10)` (`stored_len`=28, wrong stride
2147
+ # vs. the true 10): entries 1..28 show as `(1,1)..(1,28)`, then `(2,1)`, `(2,2)`
2148
+ # for the last 2 of its 30 true elements, not `(1,1)..(1,10)` then `(2,1)..`.
2149
+ # Total entry count is still correct (`dim / stored_len`). A field with 3+
2150
+ # non-string dims is unverified; this always yields exactly 2 coordinates like
2151
+ # the confirmed cases, which may not generalise. Mechanism not understood (a
2152
+ # legacy quirk in the exe's own lister); reproduced, not derived.
2153
+ stored_len = f.subdims[-1]
2154
+ total = f.dimension // stored_len if stored_len else 0
2155
+ total = min(total, _MAX_EXPLODE_COUNT)
2156
+ explode_iter = ((lin // stored_len + 1, lin % stored_len + 1) for lin in range(total))
2157
+ else:
2158
+ explode_iter = _explode_indices(f.subdims, drop_last, f.begin_index, f.dimension)
2159
+ for idx in explode_iter:
2160
+ suffix = "(" + ",".join(str(i) for i in idx) + ")" if idx else ""
2161
+ line = f" {f.name}{suffix}"
2162
+ if f.units:
2163
+ line += f"\tUnits: {f.units}"
2164
+ lines.append(line)
2165
+ lines.append("")
2166
+ return "\r\n".join(lines) + ("\r\n" if lines else "")
2167
+
2168
+
2169
+ def _seconds_to_display(secs: int) -> tuple[int, str]:
2170
+ for unit, mult in (("DAY", 86400), ("HR", 3600), ("MIN", 60)):
2171
+ if secs % mult == 0 and secs // mult > 0:
2172
+ return secs // mult, unit
2173
+ return secs, "SEC"