dynamic-systems-testing-framework 1.0.1__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 (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,794 @@
1
+ """Parse Modelica .mo files to extract UnitTests block info and experiment annotations."""
2
+
3
+ import logging
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass
12
+ class UnitTestInfo:
13
+ """Information extracted from a UnitTests block in a .mo file."""
14
+
15
+ n: int = 1
16
+ #: The component's instance name in the model — its variables are
17
+ #: ``<instance>.x[i]`` in the result file.
18
+ instance: str = "unitTests"
19
+ x_expressions: list[str] = field(default_factory=list)
20
+ x_raw: str = "" # Raw text of x={...} for complex cases (cat, etc.)
21
+ x_reference: list[float] | None = None
22
+ error_expected: float = 1e-6
23
+
24
+
25
+ @dataclass
26
+ class ExperimentInfo:
27
+ """Simulation parameters from experiment() annotation."""
28
+
29
+ stop_time: float | None = None
30
+ tolerance: float | None = None
31
+ method: str | None = None
32
+ number_of_intervals: int | None = None
33
+ output_interval: float | None = None
34
+
35
+
36
+ @dataclass
37
+ class MoParseResult:
38
+ """Full parse result from a .mo file."""
39
+
40
+ model_id: str # Fully qualified Modelica path
41
+ mo_file: Path
42
+ unit_test: UnitTestInfo | None = None
43
+ experiment: ExperimentInfo | None = None
44
+
45
+
46
+ _CLASS_DECL_RE = re.compile(r"\b(?:model|block|class|package)\s+(\w+)")
47
+
48
+
49
+ def _extract_within(text: str) -> str:
50
+ """Extract the 'within' clause to get the parent package path.
51
+
52
+ Operates on comment/string-stripped text — review 2026-07-06 finding 48:
53
+ a ``within`` mentioned in a comment must not shadow the real clause.
54
+ """
55
+ stripped = _strip_modelica_literals(text)
56
+ m = re.search(r"\bwithin\s+([\w.]+)\s*;", stripped)
57
+ return m.group(1) if m else ""
58
+
59
+
60
+ def _extract_model_name(text: str, source: object = "") -> str:
61
+ """Extract the first class-like declaration's name.
62
+
63
+ Operates on comment/string-stripped text with a word-boundary anchor —
64
+ review 2026-07-06 finding 48: the word "model" in a comment above the
65
+ class must not shadow the real declaration.
66
+
67
+ TODO(finding 55): single-file multi-class storage is NOT supported —
68
+ only the FIRST top-level class is parsed. When a second top-level class
69
+ is detected (a class declaration after the first class's ``end Name;``),
70
+ a warning names what was skipped. ``source`` is optional context (file
71
+ path) for that warning.
72
+ """
73
+ stripped = _strip_modelica_literals(text)
74
+ m = _CLASS_DECL_RE.search(stripped)
75
+ if not m:
76
+ return ""
77
+ name = m.group(1)
78
+ # review 2026-07-06 finding 55: warn about sibling top-level classes the
79
+ # parser skips instead of silently dropping their tests.
80
+ end_m = re.search(rf"\bend\s+{re.escape(name)}\s*;", stripped[m.end() :])
81
+ if end_m:
82
+ extra = _CLASS_DECL_RE.search(stripped, m.end() + end_m.end())
83
+ if extra:
84
+ logger.warning(
85
+ "%s: multiple top-level classes in one file are not supported; "
86
+ "parsed %r, skipped %r (and any further classes)",
87
+ source or "<unknown file>",
88
+ name,
89
+ extra.group(1),
90
+ )
91
+ return name
92
+
93
+
94
+ def _extract_balanced_braces(text: str, start: int) -> str:
95
+ """Extract text within balanced braces starting from position of '{'."""
96
+ depth = 0
97
+ i = start
98
+ while i < len(text):
99
+ if text[i] == "{":
100
+ depth += 1
101
+ elif text[i] == "}":
102
+ depth -= 1
103
+ if depth == 0:
104
+ return text[start + 1 : i]
105
+ i += 1
106
+ return text[start + 1 :]
107
+
108
+
109
+ _WS_RUN_RE = re.compile(r"\s+")
110
+ _WS_AFTER_OPEN_RE = re.compile(r"([\[({])\s+")
111
+ _WS_BEFORE_CLOSE_RE = re.compile(r"\s+([\])},])")
112
+ #: A comma that is not the expression's last character: exactly one space
113
+ #: follows it in the normal form. A comma directly before a closing bracket
114
+ #: needs no exception here — the space this puts after it is taken back by
115
+ #: ``_WS_BEFORE_CLOSE_RE``, which runs after.
116
+ _COMMA_RE = re.compile(r",\s*(?!$)")
117
+
118
+
119
+ def normalize_expression(text: str) -> str:
120
+ """One expression in its normal form, without its source layout.
121
+
122
+ A ``.mo`` file wraps a long expression over two lines, or spells an
123
+ index ``[1,2]`` where the result file spells it ``[1, 2]``; that is the
124
+ file's formatting, not part of what the expression names. The normal
125
+ form: whitespace runs become one space; none is kept directly after an
126
+ opening bracket or directly before a closing bracket or a comma; and
127
+ every comma is followed by exactly one space — which is how Dymola's own
128
+ result names read (``a[1, 2]``). So ``a.b[\\n 1, 1]``,
129
+ ``a.b[ 1 ,1 ]`` and ``a.b[1,1]`` are one expression, and a source
130
+ reformat does not read as a changed expression on compare.
131
+
132
+ Normalizing an expression already in normal form changes nothing.
133
+
134
+ OUT OF SCOPE: a comma inside a string literal within an expression —
135
+ the rule is textual and would put a space inside the literal. On the
136
+ ``.mo`` UnitTests path no literal can reach here: ``_parse_unit_tests``
137
+ blanks comments and string literals before it looks for ``x=`` at all.
138
+ A JSON recognizer's ``parameter`` source slices the ORIGINAL text, so
139
+ one could reach here that way; no ``x={...}`` in the libraries this
140
+ parses contains one.
141
+ """
142
+ text = _WS_RUN_RE.sub(" ", text.strip())
143
+ text = _COMMA_RE.sub(", ", text)
144
+ text = _WS_AFTER_OPEN_RE.sub(r"\1", text)
145
+ return _WS_BEFORE_CLOSE_RE.sub(r"\1", text)
146
+
147
+
148
+ def _parse_x_expressions(raw: str) -> list[str]:
149
+ """Parse x={expr1, expr2, ...} into individual expressions.
150
+
151
+ Handles simple comma-separated variables but returns raw text for
152
+ complex cases like cat() or array comprehensions. Every expression
153
+ returned is normalized (:func:`normalize_expression`).
154
+ """
155
+ # If it contains cat( or 'for', it's complex — return as single raw entry
156
+ stripped = raw.strip()
157
+ if "cat(" in stripped or " for " in stripped:
158
+ return [normalize_expression(stripped)]
159
+
160
+ # Split by commas, respecting nested brackets/parens
161
+ expressions = []
162
+ depth = 0
163
+ current = []
164
+ for ch in stripped:
165
+ if ch in "({[":
166
+ depth += 1
167
+ current.append(ch)
168
+ elif ch in ")}]":
169
+ depth -= 1
170
+ current.append(ch)
171
+ elif ch == "," and depth == 0:
172
+ expr = normalize_expression("".join(current))
173
+ if expr:
174
+ expressions.append(expr)
175
+ current = []
176
+ else:
177
+ current.append(ch)
178
+ last = normalize_expression("".join(current))
179
+ if last:
180
+ expressions.append(last)
181
+
182
+ return expressions
183
+
184
+
185
+ def _take_argument(text: str, start: int) -> str:
186
+ """The rest of one argument of a parameter list, from ``start``.
187
+
188
+ Ends at the next top-level comma or at the end of the list (the closing
189
+ bracket that was never opened inside it), nesting respected — so
190
+ ``x=a.b[1].c, x_reference={1}`` yields ``a.b[1].c`` and ``x=a.b[1, :]``
191
+ yields the whole index, where a regex over bracket contents stopped at
192
+ ``a.b[1]`` and ``a.b``.
193
+ """
194
+ depth = 0
195
+ for i in range(start, len(text)):
196
+ c = text[i]
197
+ if c in "([{":
198
+ depth += 1
199
+ elif c in ")]}":
200
+ if depth == 0:
201
+ return text[start:i]
202
+ depth -= 1
203
+ elif c == "," and depth == 0:
204
+ return text[start:i]
205
+ return text[start:]
206
+
207
+
208
+ def _parse_float_list(raw: str) -> list[float] | None:
209
+ """Parse a simple {val1, val2, ...} list of floats."""
210
+ try:
211
+ parts = raw.strip().split(",")
212
+ return [float(p.strip()) for p in parts if p.strip()]
213
+ except ValueError:
214
+ return None
215
+
216
+
217
+ def _strip_modelica_literals(text: str) -> str:
218
+ """Blank comments + string literals from Modelica source (1:1, newlines
219
+ kept, so offsets into the result index the original — findings 48/50 slice
220
+ the original for string-valued params the blanking removed).
221
+
222
+ Single-pass lexer, NOT a sequence of independent regexes. The old 3-pass
223
+ approach (block-comment, then line-comment, then string) was unsound
224
+ because the three languages interleave: a ``//`` inside a string literal
225
+ (``"modelica://..."`` — ubiquitous in Modelica annotation URIs) was eaten
226
+ by the line-comment pass, blanking the string's closing quote and
227
+ desyncing every following string. On TRANSFORM's InverseParameterization
228
+ that swallowed the real ``experiment(StopTime=10)`` — StopTime silently
229
+ defaulted to 1.0 and the sim ran a tenth of its length (regression found
230
+ 2026-07-07). A stateful scan is the only correct model: at each position we
231
+ are in exactly one of code / string / line-comment / block-comment /
232
+ quoted name, and the delimiters that matter depend on which.
233
+
234
+ A quoted name (``'a"b'``, ``'in ner'``) is CODE and is kept as it is,
235
+ but what it holds delimits nothing: a ``"``, ``//`` or ``/*`` inside one
236
+ starts no string and no comment. ``model 'a"b'`` was read as a string
237
+ running to the ``"`` of its own ``end 'a"b';``. It ends at its closing
238
+ ``'`` — or at the end of its line, which no quoted name crosses, so an
239
+ apostrophe astray in code costs one line and not the rest of the file.
240
+ """
241
+ out: list[str] = []
242
+ i, n = 0, len(text)
243
+ NORMAL, STRING, LINE, BLOCK, QUOTED = 0, 1, 2, 3, 4
244
+ state = NORMAL
245
+ while i < n:
246
+ c = text[i]
247
+ two = text[i : i + 2]
248
+ if state == NORMAL:
249
+ if two == "//":
250
+ out.append(" ")
251
+ i += 2
252
+ state = LINE
253
+ elif two == "/*":
254
+ out.append(" ")
255
+ i += 2
256
+ state = BLOCK
257
+ elif c == '"':
258
+ out.append(" ")
259
+ i += 1
260
+ state = STRING
261
+ else:
262
+ out.append(c)
263
+ i += 1
264
+ if c == "'":
265
+ state = QUOTED
266
+ elif state == QUOTED:
267
+ if c == "\\" and i + 1 < n and text[i + 1] != "\n":
268
+ out.append(text[i : i + 2]) # an escape: `\'` closes nothing
269
+ i += 2
270
+ else:
271
+ out.append(c)
272
+ i += 1
273
+ if c in "'\n":
274
+ state = NORMAL
275
+ elif state == STRING:
276
+ if c == "\\" and i + 1 < n:
277
+ # escape: blank the backslash + the escaped char together, so a
278
+ # ``\"`` inside the string is NOT read as the closing quote.
279
+ out.append(" " if text[i + 1] != "\n" else " \n")
280
+ i += 2
281
+ elif c == '"':
282
+ out.append(" ")
283
+ i += 1
284
+ state = NORMAL
285
+ else:
286
+ out.append("\n" if c == "\n" else " ")
287
+ i += 1
288
+ elif state == LINE:
289
+ if c == "\n":
290
+ out.append("\n")
291
+ i += 1
292
+ state = NORMAL
293
+ else:
294
+ out.append(" ")
295
+ i += 1
296
+ else: # BLOCK (Modelica block comments do not nest)
297
+ if two == "*/":
298
+ out.append(" ")
299
+ i += 2
300
+ state = NORMAL
301
+ else:
302
+ out.append("\n" if c == "\n" else " ")
303
+ i += 1
304
+ return "".join(out)
305
+
306
+
307
+ def _parse_unit_tests(text: str, source: object = "") -> UnitTestInfo | None:
308
+ """Extract UnitTests block parameters from model text."""
309
+ # Match the UnitTests declaration — may span multiple lines
310
+ # Patterns: "UnitTests unitTests(" or "ErrorAnalysis.UnitTests unitTests("
311
+ # Strip comments + strings first so documentation prose that mentions
312
+ # ``UnitTests(...)`` for example purposes doesn't misidentify the
313
+ # surrounding model as a test.
314
+ text = _strip_modelica_literals(text)
315
+ # The leading ``\b`` is what makes this the UnitTests component and not
316
+ # any class whose name merely ends in it (``MyUnitTests bar(`` was read
317
+ # as an instance named ``bar``). A dotted prefix still matches: the
318
+ # optional ``Utilities.ErrorAnalysis.`` group, and any longer prefix
319
+ # (``TRANSFORM.Utilities.ErrorAnalysis.``) because the ``.`` before
320
+ # ``UnitTests`` is itself a word boundary.
321
+ pattern = re.compile(
322
+ r"\b(?:Utilities\.ErrorAnalysis\.)?UnitTests\s+(\w+)\s*\(", re.DOTALL
323
+ )
324
+ match = pattern.search(text)
325
+ if not match:
326
+ return None
327
+
328
+ # Find the end of the parameter list — match balanced parentheses
329
+ start = match.end() - 1 # position of the '('
330
+ depth = 0
331
+ end = start
332
+ for i in range(start, len(text)):
333
+ if text[i] == "(":
334
+ depth += 1
335
+ elif text[i] == ")":
336
+ depth -= 1
337
+ if depth == 0:
338
+ end = i
339
+ break
340
+
341
+ param_text = text[start + 1 : end]
342
+
343
+ # group(1) is the component's INSTANCE name (the word after the class),
344
+ # not the class: its variables are ``<instance>.x[i]``.
345
+ info = UnitTestInfo(instance=match.group(1))
346
+
347
+ # Extract n=
348
+ m = re.search(r"\bn\s*=\s*(\d+)", param_text)
349
+ if m:
350
+ info.n = int(m.group(1))
351
+
352
+ # Extract x={...}
353
+ m = re.search(r"\bx\s*=\s*\{", param_text)
354
+ if m:
355
+ brace_start = m.end() - 1
356
+ raw = _extract_balanced_braces(param_text, brace_start)
357
+ info.x_raw = raw
358
+ info.x_expressions = _parse_x_expressions(raw)
359
+ elif re.search(r"\bx\s*=\s*(?:cat|fill|zeros|ones|linspace)\s*\(", param_text):
360
+ # x=cat(1, ...) or x=fill(...) etc. without outer braces
361
+ m2 = re.search(r"\bx\s*=\s*((?:cat|fill|zeros|ones|linspace)\s*\()", param_text)
362
+ if m2:
363
+ paren_start = param_text.index("(", m2.start())
364
+ depth2 = 0
365
+ end2 = paren_start
366
+ for i in range(paren_start, len(param_text)):
367
+ if param_text[i] == "(":
368
+ depth2 += 1
369
+ elif param_text[i] == ")":
370
+ depth2 -= 1
371
+ if depth2 == 0:
372
+ end2 = i
373
+ break
374
+ raw = param_text[m2.start() + 2 : end2 + 1] # include "func(...)"
375
+ info.x_raw = raw.strip()
376
+ info.x_expressions = [normalize_expression(raw)]
377
+ else:
378
+ # x=varName, x=some.qualified.name, x=a.b[1].c, x=a.b[1, :] — a bare
379
+ # expression. It runs to the end of ITS argument; a bracket regex
380
+ # truncated it (``a.b[1].c`` became ``a.b[1]``, ``a.b[1, :]`` became
381
+ # ``a.b``), which then rode into the baseline as the expression.
382
+ m2 = re.search(r"\bx\s*=\s*", param_text)
383
+ if m2:
384
+ raw = _take_argument(param_text, m2.end()).strip()
385
+ if raw:
386
+ info.x_raw = raw
387
+ info.x_expressions = [normalize_expression(raw)]
388
+
389
+ # Extract x_reference={...}
390
+ m = re.search(r"\bx_reference\s*=\s*\{", param_text)
391
+ if m:
392
+ brace_start = m.end() - 1
393
+ raw = _extract_balanced_braces(param_text, brace_start)
394
+ info.x_reference = _parse_float_list(raw)
395
+
396
+ # Extract errorExpected= (finding 56 applies here too: an expression like
397
+ # ``1e-3*2`` must warn and keep the default, not silently truncate)
398
+ error_expected = _parse_number(
399
+ param_text, r"\berrorExpected", source, "errorExpected"
400
+ )
401
+ if error_expected is not None:
402
+ info.error_expected = error_expected
403
+
404
+ return info
405
+
406
+
407
+ def _find_experiment_span(stripped: str) -> tuple[int, int] | None:
408
+ """Locate the ``experiment(...)`` parameter text in stripped source.
409
+
410
+ Returns (start, end) offsets of the text BETWEEN the balanced outer
411
+ parentheses, or None when no experiment annotation exists. Balanced-paren
412
+ scan instead of ``[^)]*`` — review 2026-07-06 finding 48: a nested group
413
+ like ``__Dymola_Tuning(...)`` must not truncate the parameter list.
414
+ Offsets are valid in the original text too (1:1 stripping).
415
+ """
416
+ m = re.search(r"\bexperiment\s*\(", stripped)
417
+ if not m:
418
+ return None
419
+ open_pos = m.end() - 1
420
+ depth = 0
421
+ for i in range(open_pos, len(stripped)):
422
+ if stripped[i] == "(":
423
+ depth += 1
424
+ elif stripped[i] == ")":
425
+ depth -= 1
426
+ if depth == 0:
427
+ return (open_pos + 1, i)
428
+ return (open_pos + 1, len(stripped)) # unbalanced — take the rest
429
+
430
+
431
+ def _parse_number(
432
+ param_text: str,
433
+ key_pattern: str,
434
+ source: object,
435
+ field_name: str,
436
+ ) -> float | None:
437
+ """Extract ``<key> = <number>`` from an annotation parameter list.
438
+
439
+ review 2026-07-06 finding 56: accept plain numbers incl. scientific
440
+ notation only; an expression (``0.5*3600``) or garbage capture logs a
441
+ warning naming the file + field and is skipped — it must neither
442
+ silently truncate (old ``[0-9eE.+-]+`` regex kept just ``0.5``) nor
443
+ crash discovery with an uncaught ValueError.
444
+ """
445
+ m = re.search(rf"{key_pattern}\s*=\s*([^,]+)", param_text)
446
+ if not m:
447
+ return None
448
+ raw = m.group(1).strip()
449
+ try:
450
+ return float(raw)
451
+ except ValueError:
452
+ logger.warning(
453
+ "%s: ignoring non-numeric experiment %s=%r",
454
+ source or "<unknown file>",
455
+ field_name,
456
+ raw,
457
+ )
458
+ return None
459
+
460
+
461
+ def _parse_experiment(text: str, source: object = "") -> ExperimentInfo | None:
462
+ """Extract experiment() annotation parameters.
463
+
464
+ Scans comment/string-stripped text (review 2026-07-06 finding 48: a
465
+ ``// experiment(StopTime=5)`` comment must not shadow the real
466
+ annotation) but recovers string values (``__Dymola_Algorithm="..."``)
467
+ from the ORIGINAL text via the offset-preserving stripper.
468
+ """
469
+ stripped = _strip_modelica_literals(text)
470
+ span = _find_experiment_span(stripped)
471
+ if span is None:
472
+ return None
473
+
474
+ start, end = span
475
+ param_text = stripped[start:end] # numeric fields: comments stripped
476
+ param_orig = text[start:end] # string fields: literals intact
477
+ info = ExperimentInfo()
478
+
479
+ # StopTime (Modelica standard) or stopTime
480
+ info.stop_time = _parse_number(param_text, r"\b[Ss]topTime", source, "StopTime")
481
+
482
+ # Tolerance
483
+ info.tolerance = _parse_number(param_text, r"\b[Tt]olerance", source, "Tolerance")
484
+
485
+ # __Dymola_Algorithm or method — quoted string, read from original text
486
+ m2 = re.search(r'__Dymola_Algorithm\s*=\s*"([^"]+)"', param_orig)
487
+ if m2:
488
+ info.method = m2.group(1)
489
+
490
+ # __Dymola_NumberOfIntervals or numberOfIntervals
491
+ m2 = re.search(r"(?:__Dymola_)?[Nn]umberOfIntervals\s*=\s*(\d+)", param_text)
492
+ if m2:
493
+ info.number_of_intervals = int(m2.group(1))
494
+
495
+ # Interval (output interval length) — standard Modelica
496
+ interval = _parse_number(param_text, r"(?<!\w)[Ii]nterval", source, "Interval")
497
+ if interval is not None:
498
+ info.output_interval = interval
499
+
500
+ return info
501
+
502
+
503
+ def parse_mo_file(path: Path) -> MoParseResult | None:
504
+ """Parse a .mo file and extract UnitTests and experiment info.
505
+
506
+ Returns None if the file cannot be parsed or has no UnitTests block.
507
+ """
508
+ try:
509
+ text = path.read_text(encoding="utf-8", errors="replace")
510
+ except OSError:
511
+ return None
512
+
513
+ unit_test = _parse_unit_tests(text, source=path)
514
+ if unit_test is None:
515
+ return None
516
+
517
+ within = _extract_within(text)
518
+ model_name = _extract_model_name(text, source=path)
519
+ if not model_name:
520
+ return None
521
+
522
+ # Skip the UnitTests component definition itself — it contains example
523
+ # usage in documentation that the parser would pick up as a false positive
524
+ if model_name == "UnitTests":
525
+ return None
526
+
527
+ model_id = f"{within}.{model_name}" if within else model_name
528
+ experiment = _parse_experiment(text, source=path)
529
+
530
+ return MoParseResult(
531
+ model_id=model_id,
532
+ mo_file=path,
533
+ unit_test=unit_test,
534
+ experiment=experiment,
535
+ )
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # A class by its name: where it is stored, and its own experiment annotation
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ @dataclass
544
+ class LocatedClass:
545
+ """A class of the library found by its qualified name: the file that
546
+ holds it and its own ``experiment(...)`` annotation (None when it has
547
+ none)."""
548
+
549
+ mo_file: Path
550
+ experiment: ExperimentInfo | None
551
+
552
+
553
+ @dataclass(frozen=True)
554
+ class _ClassSpan:
555
+ """One class declaration in stripped source: ``start`` at its keyword,
556
+ ``body`` just past its name, ``end`` past its ``end Name;`` — or, for a
557
+ ``short`` class (``model A = B(...) annotation(...);``), past the ``;``
558
+ that ends its one statement."""
559
+
560
+ name: str
561
+ start: int
562
+ body: int
563
+ end: int
564
+ short: bool = False
565
+ #: How many brackets of the scanned text are open around a short class.
566
+ #: 0 is a declaration of that text; one written inside a modification
567
+ #: (``Pipe p(redeclare model HT = Ideal);``) is no class of the body the
568
+ #: statement stands in (:func:`_named`).
569
+ depth: int = 0
570
+
571
+
572
+ #: A Modelica name: an identifier, or a quoted one (``'in ner'``).
573
+ _NAME = r"(\w+|'(?:[^'\\]|\\.)*')"
574
+
575
+ #: A class declaration or an ``end Name;``, in comment/string-stripped text.
576
+ #: The kinds are :data:`_CLASS_DECL_RE`'s — the ones that can hold a model.
577
+ #: Group 2 marks a SHORT class (``model A = B(...)``), which has no body and
578
+ #: no ``end``; ``model extends A`` is named by what it extends.
579
+ _CLASS_TOKEN_RE = re.compile(
580
+ rf"\b(?:model|block|class|package)\s+(?:extends\s+)?{_NAME}(\s*=)?"
581
+ rf"|\bend\s+{_NAME}\s*;"
582
+ )
583
+
584
+ _OPENERS, _CLOSERS = "([{", ")]}"
585
+ #: A bracket of stripped source (group 1) — or a quoted name, which is code
586
+ #: there and whose brackets open and close nothing (``Real 'a(b' = 1;``). It
587
+ #: ends as the lexer ends it: at its closing ``'`` or at the end of its line.
588
+ _BRACKET_RE = re.compile(r"'(?:\\[^\n]|[^'\n])*'?|([()\[\]{}])")
589
+
590
+
591
+ def _statement_end(stripped: str, start: int, end: int) -> int:
592
+ """Just past the ``;`` that ends the statement at ``start`` — the first
593
+ one outside every bracket — or ``end`` when there is none. A short
594
+ class written INSIDE a modification (``A(redeclare model M = B
595
+ annotation(...))``) ends before the bracket that closes the list it
596
+ sits in: what follows that bracket is the enclosing statement's."""
597
+ depth = 0
598
+ for i in range(start, end):
599
+ c = stripped[i]
600
+ if c in _OPENERS:
601
+ depth += 1
602
+ elif c in _CLOSERS:
603
+ if depth == 0:
604
+ return i
605
+ depth -= 1
606
+ elif depth == 0 and c == ";":
607
+ return i + 1
608
+ return end
609
+
610
+
611
+ def _direct_classes(stripped: str, start: int, end: int) -> list[_ClassSpan]:
612
+ """The classes declared DIRECTLY in ``stripped[start:end]`` — a file, or
613
+ one class's body — in source order; a class nested deeper belongs to
614
+ the one that holds it.
615
+
616
+ A stack of open declarations, closed by the ``end`` that names the top
617
+ one: ``end if;`` / ``end for;`` and the ``end`` of a kind not tracked
618
+ here (a function, a record) name nothing on it and close nothing. A
619
+ short class opens nothing: it is one statement, and its span runs to
620
+ that statement's ``;`` — it carries an ``annotation(...)`` of its own,
621
+ which is not the enclosing class's.
622
+
623
+ A short class written inside a bracket — a modification's ``redeclare
624
+ model HT = Ideal`` — is in the list with the ``depth`` it stands at: its
625
+ text is not the enclosing class's own (its annotation neither), and it
626
+ is no class of this text by name. Taken for one, it hid the class ``HT``
627
+ declared after it, which was then found with no annotation — so did a
628
+ class redeclared in a sibling short class's modification.
629
+ """
630
+ out: list[_ClassSpan] = []
631
+ stack: list[tuple[str, int, int]] = []
632
+ depth, at = 0, start
633
+ for m in _CLASS_TOKEN_RE.finditer(stripped, start, end):
634
+ for found in _BRACKET_RE.finditer(stripped, at, m.start()):
635
+ if found.group(1):
636
+ depth += 1 if found.group(1) in _OPENERS else -1
637
+ at = m.end()
638
+ closed = m.group(3)
639
+ if closed is not None:
640
+ if stack and stack[-1][0] == closed:
641
+ name, opened, body = stack.pop()
642
+ if not stack:
643
+ out.append(_ClassSpan(name, opened, body, m.end()))
644
+ elif m.group(2) is None:
645
+ stack.append((m.group(1), m.start(), m.end()))
646
+ elif not stack:
647
+ after = _statement_end(stripped, m.end(), end)
648
+ out.append(_ClassSpan(m.group(1), m.start(), m.end(), after, True, depth))
649
+ return out
650
+
651
+
652
+ def find_class(
653
+ library_dir: Path, library_name: str, model_id: str
654
+ ) -> LocatedClass | None:
655
+ """Locate the class ``model_id`` in the library's files and read its
656
+ own ``experiment(...)`` annotation — for a test that names a model no
657
+ recognizer found (declared in ``test_spec.json`` only).
658
+
659
+ A Modelica class is stored as its own file (``A/B/C.mo``), as a
660
+ directory (``A/B/C/package.mo``), or inside the file of a class that
661
+ holds it (``A/B/package.mo``, ``A/B.mo``, ... up to the library's
662
+ ``package.mo``). Those are tried from the most specific: a handful of
663
+ path probes per name, never a walk of the library. A file is trusted
664
+ only when its ``within`` clause and its class say it is the one looked
665
+ for. None when the name is not a class of this library (another
666
+ library's model, a component). A short class (``model S = L.Rod
667
+ annotation(...);``) is one, read like any other: its own annotation,
668
+ not the one of the class it is made from.
669
+
670
+ The annotation is the class's OWN: the text of the classes nested in
671
+ it — a short class's one statement and a class of a quoted name
672
+ included — is blanked first, so a nested model's ``experiment`` — which
673
+ the source spells before the enclosing class's annotation — is not read
674
+ as the parent's. It is then read by :func:`_parse_experiment`, the reader
675
+ a scanned test's goes through.
676
+ """
677
+ names = model_id.split(".")
678
+ if len(names) < 2 or names[0] != library_name:
679
+ return None
680
+ inner = names[1:]
681
+ for k in range(len(inner), -1, -1):
682
+ owner, nested = inner[:k], inner[k:]
683
+ folder = library_dir.joinpath(*owner)
684
+ candidates = [folder / "package.mo"]
685
+ if owner:
686
+ candidates.insert(0, folder.parent / f"{owner[-1]}.mo")
687
+ for path in candidates:
688
+ if not path.is_file():
689
+ continue
690
+ found = _class_in_file(path, [library_name, *owner], nested)
691
+ if found is not None:
692
+ return found
693
+ return None
694
+
695
+
696
+ def _class_in_file(
697
+ path: Path, top: list[str], nested: list[str]
698
+ ) -> LocatedClass | None:
699
+ """The class ``nested`` deep inside the file's top class ``top`` (the
700
+ top class itself for an empty ``nested``), or None when the file does
701
+ not declare it."""
702
+ try:
703
+ text = path.read_text(encoding="utf-8", errors="replace")
704
+ except OSError:
705
+ return None
706
+ if _extract_within(text) != ".".join(top[:-1]):
707
+ return None
708
+ stripped = _strip_modelica_literals(text)
709
+ span = _named(_direct_classes(stripped, 0, len(stripped)), top[-1])
710
+ for name in nested:
711
+ if span is None:
712
+ return None
713
+ # A short class has no body: the classes of its own are the ones
714
+ # its modification redeclares, one bracket into its statement.
715
+ inside = _direct_classes(stripped, span.body, span.end)
716
+ span = _named(inside, name, depth=1 if span.short else 0)
717
+ if span is None:
718
+ return None
719
+ # Blanked 1:1, so `_parse_experiment`'s offsets into the original text
720
+ # (its string-valued fields) still hold.
721
+ own = list(text[span.start : span.end])
722
+ for child in _direct_classes(stripped, span.body, span.end):
723
+ for i in range(child.start - span.start, child.end - span.start):
724
+ if own[i] != "\n":
725
+ own[i] = " "
726
+ return LocatedClass(
727
+ mo_file=path, experiment=_parse_experiment("".join(own), source=path)
728
+ )
729
+
730
+
731
+ def _named(spans: list[_ClassSpan], name: str, depth: int = 0) -> _ClassSpan | None:
732
+ """The class of that name declared at ``depth`` — a short class too:
733
+ ``model S = L.Rod annotation(experiment(StopTime=13));`` is a class of
734
+ the library with an annotation of its own, in its one statement. (It
735
+ was passed over, and a run said of ``Examples/ShortOne.mo`` that the
736
+ model was "not found under source_path".) It has no body: nothing is
737
+ found inside it but the classes its own modification redeclares.
738
+
739
+ ``depth`` is 0 in a file and in a class's body: a ``redeclare model X
740
+ = …`` inside a component's or an ``extends`` clause's modification
741
+ there is no class ``X`` of that body."""
742
+ return next((s for s in spans if s.name == name and s.depth == depth), None)
743
+
744
+
745
+ # ---------------------------------------------------------------------------
746
+ # Bundled recognizer (PTA.1)
747
+ # ---------------------------------------------------------------------------
748
+
749
+ from .recognizer import ( # noqa: E402 (late import: registers the bundled recognizer after its deps are defined)
750
+ Recognizer,
751
+ RecognizerResult,
752
+ register,
753
+ )
754
+
755
+
756
+ class BundledModelicaUnitTestsRecognizer(Recognizer):
757
+ """The default Modelica recognizer.
758
+
759
+ Matches any class that instantiates the ``UnitTests`` component (with
760
+ parameters ``n``, ``x={...}``, optional ``error_expected``) plus the
761
+ standard ``experiment(...)`` annotation. Wraps :func:`parse_mo_file` and
762
+ translates its ``MoParseResult`` into the framework-neutral
763
+ :class:`RecognizerResult` shape.
764
+ """
765
+
766
+ name = "modelica:bundled-unit-tests"
767
+ applies_to = frozenset({"modelica"})
768
+
769
+ def recognize(self, source_file: Path) -> RecognizerResult | None:
770
+ parsed = parse_mo_file(source_file)
771
+ if parsed is None:
772
+ return None
773
+ ut = parsed.unit_test
774
+ exp = parsed.experiment
775
+ return RecognizerResult(
776
+ model_id=parsed.model_id,
777
+ source_file=parsed.mo_file,
778
+ n_vars=ut.n if ut else None,
779
+ unit_tests_instance=ut.instance if ut else None,
780
+ x_expressions=list(ut.x_expressions) if ut else [],
781
+ x_raw=ut.x_raw if ut else "",
782
+ x_reference=list(ut.x_reference)
783
+ if ut and ut.x_reference is not None
784
+ else None,
785
+ error_expected=ut.error_expected if ut else None,
786
+ stop_time=exp.stop_time if exp else None,
787
+ tolerance=exp.tolerance if exp else None,
788
+ method=exp.method if exp else None,
789
+ number_of_intervals=exp.number_of_intervals if exp else None,
790
+ output_interval=exp.output_interval if exp else None,
791
+ )
792
+
793
+
794
+ register(BundledModelicaUnitTestsRecognizer())