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
dstf/config.py ADDED
@@ -0,0 +1,815 @@
1
+ """Configuration constants and path resolution for the testing system."""
2
+
3
+ import json
4
+ import logging
5
+ import platform
6
+ import re
7
+ import shutil
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Default simulation parameters (Dymola defaults)
14
+ DEFAULT_STOP_TIME = 1.0
15
+ DEFAULT_TOLERANCE = 1e-4
16
+ DEFAULT_METHOD = "Dassl"
17
+ DEFAULT_NUMBER_OF_INTERVALS = 500
18
+
19
+ # Comparison defaults
20
+ DEFAULT_COMPARISON_TOLERANCE = 1e-4
21
+
22
+ # `baseline_points` for every point the run wrote — the default (D153).
23
+ ALL_POINTS = "all"
24
+
25
+ # Config filename looked for near the package or in working directory
26
+ CONFIG_FILENAME = "testing.json"
27
+
28
+ # Maps a user-supplied backend name prefix to its canonical family:
29
+ # "Dymola 2026x" -> "Dymola". NOT the set of registered backends.
30
+ BACKEND_FAMILIES = {
31
+ "Dymola": "Dymola",
32
+ "OpenModelica": "OpenModelica",
33
+ "FMPy": "FMPy",
34
+ "Julia": "Julia",
35
+ # review 2026-07-06 finding 67: "Python" was missing, so the documented
36
+ # versioned-name convention ("Python 3.12") failed backend detection
37
+ # with "Unsupported backend".
38
+ "Python": "Python",
39
+ }
40
+
41
+ # Binary names by backend, used for PATH-lookup fallback when testing.json
42
+ # doesn't list an explicit path. Backend-name lowercase happens to match for
43
+ # Dymola; OpenModelica's binary is ``omc`` (not ``openmodelica``). An empty
44
+ # string marks backends that don't ship a standalone binary (FMPy is a
45
+ # Python library — backend_path is unused for it).
46
+ BACKEND_BINARY_NAMES = {
47
+ "Dymola": "dymola",
48
+ "OpenModelica": "omc",
49
+ "FMPy": "",
50
+ "Julia": "julia",
51
+ "Python": "python",
52
+ }
53
+
54
+
55
+ def _detect_backend(backend_name: str) -> str:
56
+ """Determine the canonical backend family from a named entry.
57
+
58
+ E.g., "Dymola 2025" -> "Dymola", "OpenModelica 1.23" -> "OpenModelica"
59
+ """
60
+ for prefix, backend in BACKEND_FAMILIES.items():
61
+ if backend_name.startswith(prefix):
62
+ return backend
63
+ return backend_name
64
+
65
+
66
+ def detect_os() -> str:
67
+ """Detect the current OS for reference result partitioning."""
68
+ system = platform.system().lower()
69
+ return {"linux": "linux", "darwin": "macos", "windows": "windows"}.get(
70
+ system, system
71
+ )
72
+
73
+
74
+ def find_package_dir(start: Path | None = None) -> Path:
75
+ """Find a Modelica package directory containing package.mo.
76
+
77
+ If start is a directory containing package.mo, returns it directly.
78
+ Otherwise walks up looking for a directory that contains a subdirectory
79
+ with package.mo.
80
+ """
81
+ if start is None:
82
+ start = Path.cwd()
83
+ path = start.resolve()
84
+
85
+ # Check if start itself is a package dir
86
+ if (path / "package.mo").exists():
87
+ return path
88
+
89
+ # Walk up looking for a child dir with package.mo
90
+ for _ in range(10):
91
+ for child in path.iterdir():
92
+ if child.is_dir() and (child / "package.mo").exists():
93
+ return child
94
+ path = path.parent
95
+
96
+ raise FileNotFoundError(
97
+ f"Could not find a Modelica package from {start}. "
98
+ "Expected a directory containing package.mo."
99
+ )
100
+
101
+
102
+ def read_package_name(package_dir: Path) -> str:
103
+ """Read the Modelica package name from package.mo."""
104
+ pkg_file = package_dir / "package.mo"
105
+ if not pkg_file.exists():
106
+ raise FileNotFoundError(f"No package.mo in {package_dir}")
107
+ text = pkg_file.read_text(encoding="utf-8", errors="replace")
108
+ m = re.search(r"package\s+(\w+)", text)
109
+ if m:
110
+ return m.group(1)
111
+ raise ValueError(f"Could not parse package name from {pkg_file}")
112
+
113
+
114
+ class ConfigError(ValueError):
115
+ """A testing.json problem the user must fix (malformed, unreadable, or a
116
+ missing explicit --config path)."""
117
+
118
+
119
+ def baseline_points_refused(named: str, value: object) -> str | None:
120
+ """Why ``value`` is no ``baseline_points`` — in ``testing.json`` or on a
121
+ ``test_spec.json`` entry, ``named`` saying which — or ``None``. It is
122
+ ``"all"`` or a whole number of 2 or more: a thinned baseline keeps both
123
+ ends of its run. ``true`` is no number, and neither is ``2000.0``."""
124
+ if isinstance(value, str) and value == ALL_POINTS:
125
+ return None
126
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 2:
127
+ return None
128
+ return (
129
+ f"{named} is {json.dumps(value, default=str)}: the points a baseline "
130
+ f'keeps are "{ALL_POINTS}" or a whole number of 2 or more'
131
+ )
132
+
133
+
134
+ #: testing.json keys renamed when the "simulator" vocabulary became Backend.
135
+ #: Reading one fails loudly: an untouched config still using these would
136
+ #: otherwise keep PARSING while every setting in it silently did nothing,
137
+ #: which a user experiences as "the tool ignores my config" with no
138
+ #: diagnostic. Naming the replacement is a diagnostic, not a deprecation
139
+ #: path — the old key never works again, it just says so.
140
+ _RENAMED_CONFIG_KEYS = {
141
+ "simulator": "backend",
142
+ "simulators": "backends",
143
+ "simulator_setup": "backend_setup",
144
+ }
145
+
146
+
147
+ def load_config_file(path: Path) -> dict:
148
+ """Load a testing.json config file.
149
+
150
+ A missing file returns ``{}`` (no config there). An EXISTING file that
151
+ cannot be read or parsed raises :class:`ConfigError` naming the file —
152
+ review 2026-07-06 finding 11: silently returning ``{}`` let the
153
+ auto-create branch overwrite the user's testing.json.
154
+ """
155
+ if path.is_dir():
156
+ path = path / CONFIG_FILENAME
157
+ if not path.exists():
158
+ return {}
159
+ try:
160
+ text = path.read_text(encoding="utf-8")
161
+ except OSError as e:
162
+ raise ConfigError(f"Cannot read config file {path}: {e}") from e
163
+ try:
164
+ data = json.loads(text)
165
+ except json.JSONDecodeError as e:
166
+ raise ConfigError(f"Malformed JSON in config file {path}: {e}") from e
167
+ if isinstance(data, dict):
168
+ stale = [k for k in _RENAMED_CONFIG_KEYS if k in data]
169
+ if stale:
170
+ renames = ", ".join(f"{k!r} -> {_RENAMED_CONFIG_KEYS[k]!r}" for k in stale)
171
+ raise ConfigError(
172
+ f"{path} uses renamed key(s): {renames}. The old names are no "
173
+ f"longer read; rename them so the settings take effect."
174
+ )
175
+ return data
176
+
177
+
178
+ def _create_default_config(config_dir: Path, library_name: str) -> dict:
179
+ """Create a default testing.json and return its contents."""
180
+ config = {
181
+ "backend": "Dymola",
182
+ "backends": {},
183
+ "dependencies": [],
184
+ }
185
+ config_path = config_dir / CONFIG_FILENAME
186
+ # review 2026-07-06 finding 19: the target dir may not exist yet (e.g.
187
+ # first run with a fresh --reference-root) — create it before writing.
188
+ config_dir.mkdir(parents=True, exist_ok=True)
189
+ # LF on every platform: testing.json is a surface
190
+ # users version-control, so it must not depend on the writing OS.
191
+ config_path.write_text(
192
+ json.dumps(config, indent=2) + "\n", encoding="utf-8", newline="\n"
193
+ )
194
+ logger.info("Created default %s at %s", CONFIG_FILENAME, config_path)
195
+ print(f"Created default {CONFIG_FILENAME} at {config_path}")
196
+ return config
197
+
198
+
199
+ def _resolve_backend_path(
200
+ backends_config: dict[str, list[str]],
201
+ backend_name: str,
202
+ ) -> str | None:
203
+ """Resolve a backend's executable path from the backends config.
204
+
205
+ Looks up the named entry and returns the first path that exists on disk.
206
+ Falls back to checking if the name is on PATH.
207
+ """
208
+ paths = backends_config.get(backend_name, [])
209
+ for p in paths:
210
+ path = Path(p)
211
+ if path.is_absolute() and path.exists():
212
+ return str(path)
213
+ found = shutil.which(p)
214
+ if found:
215
+ return found
216
+ return None
217
+
218
+
219
+ def _auto_detect_backend(
220
+ backends_config: dict[str, list[str]],
221
+ ) -> tuple[str, str] | None:
222
+ """Pick the first backend in ``backends_config`` whose binary resolves.
223
+
224
+ Resolution order per candidate entry:
225
+ 1. Any path in its ``backends`` list that exists on disk or resolves
226
+ on ``PATH`` (via :func:`_resolve_backend_path`).
227
+ 2. If the list contains no absolute paths (empty or bare-binary names
228
+ only), fall back to the backend's canonical binary
229
+ (``BACKEND_BINARY_NAMES``) on ``PATH``.
230
+
231
+ The fallback is gated on "no absolute paths given" because a list of
232
+ OS-specific absolute paths (e.g. ``C:\\Program Files\\...Dymola.exe``)
233
+ is a strong signal that the user wants *that* install — if none of the
234
+ absolute paths resolve, treating some same-named binary on PATH as
235
+ equivalent would silently run the wrong simulator on a mixed-OS setup
236
+ (Linux WSL often has a ``dymola`` symlink that points at a binary that
237
+ isn't usable the same way).
238
+
239
+ Iteration follows dict insertion order so users can express preference
240
+ (list Dymola first on Windows; list OpenModelica first on Linux) —
241
+ "first available wins" across both Windows and Linux machines with the
242
+ same ``testing.json``. Returns ``(backend_name, resolved_path)`` or
243
+ ``None`` if no entry resolves.
244
+ """
245
+ for name, paths in backends_config.items():
246
+ path = _resolve_backend_path(backends_config, name)
247
+ if path:
248
+ return name, path
249
+ # No explicit entry resolved. Only fall back to a generic PATH
250
+ # lookup when the user hasn't pinned this backend to platform-
251
+ # specific paths. An entirely-pinned list is a no-fallback
252
+ # instruction. We detect "looks like a path" via a Windows-drive
253
+ # regex OR POSIX-absolute — pathlib's ``is_absolute`` alone returns
254
+ # False for ``C:\...`` on Linux (PosixPath doesn't recognize
255
+ # Windows drive letters).
256
+ has_pinned_path = any(_looks_like_path(p) for p in paths)
257
+ if has_pinned_path:
258
+ continue
259
+ backend = _detect_backend(name)
260
+ binary = BACKEND_BINARY_NAMES.get(backend, backend.lower())
261
+ if binary:
262
+ found = shutil.which(binary)
263
+ if found:
264
+ return name, found
265
+ return None
266
+
267
+
268
+ _WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
269
+
270
+
271
+ def _looks_like_path(entry: str) -> bool:
272
+ """True if ``entry`` is an OS-specific path (not just a bare binary name).
273
+
274
+ POSIX-absolute (``/usr/bin/omc``) or Windows-drive-rooted
275
+ (``C:\\Program Files\\...``) count as pinned paths; bare names like
276
+ ``"omc"`` or ``"dymola.exe"`` do not.
277
+ """
278
+ if entry.startswith("/"):
279
+ return True
280
+ return bool(_WINDOWS_DRIVE_RE.match(entry))
281
+
282
+
283
+ def dependency_is_path(entry: str) -> bool:
284
+ """True if a ``dependencies`` entry is SPELLED as a filesystem path: a
285
+ separator, a ``.mo`` suffix, or a ``.``/``~`` prefix. Every entry of a
286
+ resolved ``Config.dependencies`` that names a path is (a relative one
287
+ was made absolute); a bare library name ("Modelica") is not — the
288
+ backend loads it by name."""
289
+ return (
290
+ "/" in entry
291
+ or "\\" in entry
292
+ or entry.endswith(".mo")
293
+ or entry.startswith((".", "~"))
294
+ )
295
+
296
+
297
+ def _dependency_looks_like_path(entry: str, base_dir: Path) -> bool:
298
+ """True if a ``dependencies`` entry denotes a filesystem path.
299
+
300
+ review 2026-07-06 finding 17: bare library names ("Modelica") must NOT
301
+ be absolutized — backends load them by name. Path signals: how it is
302
+ spelled (:func:`dependency_is_path`), or an existing file/dir relative
303
+ to the config file's directory.
304
+ """
305
+ if dependency_is_path(entry):
306
+ return True
307
+ try:
308
+ return (base_dir / entry).exists()
309
+ except OSError:
310
+ return False
311
+
312
+
313
+ @dataclass
314
+ class Config:
315
+ """Runtime configuration for the testing system.
316
+
317
+ The primary input is source_path — for source_type == "modelica" this is
318
+ the directory containing package.mo for the library being tested; for FMU
319
+ / Julia / data-file sources it generalizes to that source location.
320
+ """
321
+
322
+ # Source type: "modelica" (default, `source_path` points at a package.mo dir),
323
+ # "fmu" (Phase 2), "julia" / "simulink" / "data-file" (future). See docs/vision.md.
324
+ # Discovery + backend selection gate on this.
325
+ source_type: str = "modelica"
326
+
327
+ # Path to the source location for the library being tested.
328
+ # For source_type == "modelica": the directory containing package.mo.
329
+ # For other source types: the FMU directory / Julia script / CSV file / etc.
330
+ source_path: Path | None = None
331
+
332
+ # Reference results location (can be a separate repo/directory)
333
+ reference_root: Path | None = None
334
+
335
+ # Backend selection. ``None`` means "not explicitly chosen" — the
336
+ # post-init resolution then consults testing.json's ``backend`` key,
337
+ # then auto-detect from the ``backends`` map, then falls back to
338
+ # ``"Dymola"`` as the historical default. A non-None value (typically
339
+ # from a CLI ``--backend`` flag) is treated as authoritative and is
340
+ # NOT overridden by anything from testing.json.
341
+ backend_name: str | None = None
342
+ backend_path: str | None = None
343
+ show_ide: bool = False
344
+ backend_setup: list[str] = field(
345
+ default_factory=list
346
+ ) # Commands run after loading libraries
347
+
348
+ # OS override (auto-detected if not set)
349
+ os_name: str | None = None
350
+
351
+ # Paths to dependency library roots
352
+ dependencies: list[str] = field(default_factory=list)
353
+
354
+ # Simulation / comparison
355
+ parallel: int = 1
356
+ batch_size: int | None = (
357
+ None # tests per Dymola session; None = ceil(total/parallel) (one big batch per worker)
358
+ )
359
+ # Global comparison tolerance. None means "not explicitly set" — resolved
360
+ # in __post_init__ as CLI/constructor arg > testing.json `tolerance` key >
361
+ # DEFAULT_COMPARISON_TOLERANCE, with is-not-None semantics so an explicit
362
+ # 0.0 is honored (review 2026-07-06 finding 12). Always a float after
363
+ # construction.
364
+ tolerance: float | None = None
365
+ default_points: bool = False
366
+ timeout: int = 60
367
+ # Seconds a persistent worker's start may take (testing.json
368
+ # `worker_startup_timeout`). None means "not set": each backend's own
369
+ # default applies (`Worker.startup_timeout`: 600 s on every backend).
370
+ worker_startup_timeout: float | None = None
371
+
372
+ # The run's results will be read WHILE it is in progress (the authoring
373
+ # tool showing a running test). Set by that caller only: there is no
374
+ # testing.json key and no CLI flag, so a `dstf run` is never changed by
375
+ # it. A backend whose result file would otherwise reach the disk late
376
+ # pays to write it promptly — OpenModelica passes `-mat_sync=1`, measured
377
+ # at +3 % run time on a local disk and +59 % for an output-heavy run on a
378
+ # Windows mount. Dymola appends as it goes and needs nothing.
379
+ live_results: bool = False
380
+
381
+ # Output
382
+ work_dir: Path | None = None
383
+
384
+ # Optional override for Dymola's Python interface archive (dymola.egg or dymola-*.whl).
385
+ # If unset, auto-discovers under platform install roots.
386
+ dymola_interface_path: Path | None = None
387
+
388
+ # Test spec file (external test definitions)
389
+ test_spec_file: Path | None = None
390
+
391
+ # Diagnostic variables: auto-captured from simulation, shown in reports but not compared
392
+ diagnostic_variables: list[str] = field(
393
+ default_factory=lambda: ["CPUtime", "EventCounter"]
394
+ )
395
+
396
+ # Phase 6.0 — interactive.html payload budget. LTTB-decimates trajectories
397
+ # embedded for Plotly rendering; full-resolution arrays remain on disk in
398
+ # comparison_data.json. Only affects the HTML visual; pass/fail scoring,
399
+ # stored baselines, and the data sidecar are unaffected. Default 1000 keeps
400
+ # a 50-var × 5000-sample test under the ~5 MB budget given today's
401
+ # per-variable time-array duplication; the follow-up dedup (idea #47)
402
+ # will let this rise to 2000 at the same budget.
403
+ max_embedded_samples: int = 1000
404
+
405
+ # The points a stored baseline keeps of its run (D153, testing.json
406
+ # `baseline_points`): "all" — every row the simulator wrote — or a whole
407
+ # number of 2 or more, which thins the run to at most that many. The
408
+ # project's value: a test_spec.json entry's own `baseline_points` wins
409
+ # (`TestModel.baseline_points`).
410
+ # Scoring reads the run AT the baseline's time points, so this is also
411
+ # the resolution at which a regression can be seen.
412
+ baseline_points: int | str = ALL_POINTS
413
+
414
+ # The bundled theme (D148) the dashboard and the report pages are
415
+ # generated in and `dstf author` opens with: a NAME of
416
+ # reporting.theme.THEMES — no path, no inline colours, no CLI flag.
417
+ theme: str = "default"
418
+
419
+ # User-provided recognizers (PTA.3) — parsed from testing.json's
420
+ # "recognizers" list via discovery.json_recognizer.parse_recognizer_spec.
421
+ # Stored on Config so they're scoped to this run (no module-registry leak
422
+ # across multiple Config instances or between tests).
423
+ recognizers: list = field(default_factory=list)
424
+
425
+ # Names of bundled recognizers to disable (PTA.3). E.g.,
426
+ # ["modelica:bundled-unit-tests"] when the user has only custom-convention
427
+ # tests and wants the bundled UnitTests recognizer off.
428
+ disabled_bundled: list[str] = field(default_factory=list)
429
+
430
+ # testing.json's `backends` map, kept whole for the authoring shell's
431
+ # backend select (brief §3.6); the executable-path resolution below reads
432
+ # the same map.
433
+ backends: dict[str, list[str]] = field(default_factory=dict)
434
+
435
+ # Config file path
436
+ config_file: Path | None = None
437
+
438
+ # Resolved (set during __post_init__)
439
+ library_name: str | None = None
440
+
441
+ def __post_init__(self):
442
+ # Resolve reference root early — needed for config file search
443
+ if self.reference_root is not None:
444
+ self.reference_root = Path(self.reference_root).resolve()
445
+
446
+ # Load config file first — it may provide source_path
447
+ file_config = {}
448
+ config_found_dir = None
449
+ if self.config_file:
450
+ config_path = Path(self.config_file).resolve()
451
+ if config_path.is_dir():
452
+ config_path = config_path / CONFIG_FILENAME
453
+ if not config_path.is_file():
454
+ # review 2026-07-06 finding 14: an explicit --config that
455
+ # doesn't exist must fail loudly, not silently fall through
456
+ # to auto-detection (and a freshly written default config).
457
+ raise ConfigError(f"Config file not found: {config_path}")
458
+ config_found_dir = config_path.parent
459
+ file_config = load_config_file(config_path)
460
+ else:
461
+ # Build search dirs from what we know so far
462
+ search_dirs = [Path.cwd()]
463
+ if self.reference_root is not None:
464
+ search_dirs.insert(0, self.reference_root)
465
+ if self.source_path is not None:
466
+ pkg = Path(self.source_path).resolve()
467
+ search_dirs.insert(0, pkg) # package dir
468
+ search_dirs.insert(0, pkg.parent) # repo root
469
+ for search_dir in search_dirs:
470
+ # review 2026-07-06 findings 11/19: "found" means the FILE
471
+ # exists, not that it parsed to a non-empty dict — an existing
472
+ # (even empty) testing.json must stop the search and suppress
473
+ # the auto-create-defaults branch.
474
+ if (search_dir / CONFIG_FILENAME).is_file():
475
+ file_config = load_config_file(search_dir)
476
+ config_found_dir = search_dir.resolve()
477
+ break
478
+
479
+ # Source type peek — controls whether we look for a Modelica package.
480
+ # Done before source_path resolution so non-modelica backends (FMU,
481
+ # data-file, ...) can skip the package.mo lookup entirely.
482
+ source_type_hint = file_config.get("source_type", self.source_type)
483
+
484
+ if source_type_hint == "modelica":
485
+ # Resolve source path — CLI arg > config file > auto-detect.
486
+ # "no package.mo anywhere" is a user error (wrong --source-path,
487
+ # wrong cwd, or source_type should have been "fmu"/"julia"), so
488
+ # find_package_dir's FileNotFoundError becomes a ConfigError and
489
+ # main() prints one line instead of a traceback.
490
+ try:
491
+ if self.source_path is not None:
492
+ self.source_path = Path(self.source_path).resolve()
493
+ if not (self.source_path / "package.mo").exists():
494
+ self.source_path = find_package_dir(self.source_path)
495
+ elif "source_path" in file_config:
496
+ base_dir = config_found_dir or Path.cwd()
497
+ self.source_path = (base_dir / file_config["source_path"]).resolve()
498
+ if not (self.source_path / "package.mo").exists():
499
+ self.source_path = find_package_dir(self.source_path)
500
+ else:
501
+ self.source_path = find_package_dir()
502
+ except OSError as e:
503
+ raise ConfigError(
504
+ f"{e} Set 'source_path' in testing.json, pass --source-path, "
505
+ f"or set 'source_type' if this library isn't Modelica."
506
+ ) from e
507
+
508
+ # Read library name from package.mo
509
+ if self.library_name is None:
510
+ try:
511
+ self.library_name = read_package_name(self.source_path)
512
+ except (FileNotFoundError, ValueError) as e:
513
+ raise ConfigError(str(e)) from e
514
+
515
+ # The parent of the package dir is where testing.json typically lives
516
+ repo_root = self.source_path.parent
517
+ else:
518
+ # Non-modelica source: no package.mo to discover. The "library"
519
+ # is conceptually the FMU set / data set / Julia project / etc.
520
+ # described by the config. Resolve source_path here too so
521
+ # backends that need it (JuliaBackend for --project=) find the
522
+ # right directory. CLI arg > config file > config dir > cwd.
523
+ if self.source_path is not None:
524
+ self.source_path = Path(self.source_path).resolve()
525
+ elif "source_path" in file_config:
526
+ base_dir = config_found_dir or Path.cwd()
527
+ self.source_path = (base_dir / file_config["source_path"]).resolve()
528
+ elif config_found_dir is not None:
529
+ self.source_path = config_found_dir
530
+ repo_root = config_found_dir or (
531
+ self.source_path if self.source_path else Path.cwd()
532
+ )
533
+ if self.library_name is None:
534
+ self.library_name = file_config.get("library_name") or (
535
+ config_found_dir.name if config_found_dir else repo_root.name
536
+ )
537
+
538
+ # If no config was found yet (source_path wasn't available for search),
539
+ # try repo_root now — existence-based, same as the search loop above.
540
+ if config_found_dir is None and (repo_root / CONFIG_FILENAME).is_file():
541
+ file_config = load_config_file(repo_root)
542
+ config_found_dir = repo_root.resolve()
543
+
544
+ # Auto-create testing.json ONLY if no config file was found anywhere
545
+ # (review 2026-07-06 findings 11/19: a found config — even one that
546
+ # parses to {} — must never be overwritten with defaults).
547
+ if config_found_dir is None:
548
+ config_dir = self.reference_root if self.reference_root else repo_root
549
+ file_config = _create_default_config(config_dir, self.library_name)
550
+
551
+ # Source type (optional; default "modelica" from dataclass).
552
+ # testing.json may set: "source_type": "fmu" | "julia" | ... (future)
553
+ if "source_type" in file_config:
554
+ self.source_type = file_config["source_type"]
555
+
556
+ # OS detection
557
+ if self.os_name is None:
558
+ self.os_name = file_config.get("os", detect_os())
559
+
560
+ # Backend selection. Resolution order, first match wins:
561
+ # 1. Explicit constructor / CLI value (self.backend_name is not None)
562
+ # 2. testing.json explicit "backend" key
563
+ # 3. testing.json "backends" map auto-detect (first entry whose
564
+ # binary resolves on the current machine / PATH)
565
+ # 4. Default "Dymola" (historical fallback)
566
+ if self.backend_name is None:
567
+ if "backend" in file_config:
568
+ self.backend_name = file_config["backend"]
569
+ else:
570
+ backends_config = file_config.get("backends", {})
571
+ detected = _auto_detect_backend(backends_config)
572
+ if detected:
573
+ self.backend_name = detected[0]
574
+ # review 2026-07-06 finding 18: auto-detect may fill the
575
+ # simulator NAME, but must never clobber an explicitly
576
+ # supplied backend_path (CLI --backend-path).
577
+ if self.backend_path is None:
578
+ self.backend_path = detected[1]
579
+ else:
580
+ self.backend_name = "Dymola"
581
+
582
+ # Show IDE
583
+ if not self.show_ide and "show_ide" in file_config:
584
+ self.show_ide = file_config["show_ide"]
585
+
586
+ # Backend setup commands
587
+ if not self.backend_setup and "backend_setup" in file_config:
588
+ self.backend_setup = file_config["backend_setup"]
589
+
590
+ # Diagnostic variables
591
+ if "diagnostic_variables" in file_config:
592
+ self.diagnostic_variables = file_config["diagnostic_variables"]
593
+
594
+ # Global comparison tolerance (review 2026-07-06 finding 12: the
595
+ # documented testing.json `tolerance` key was never read). Precedence:
596
+ # explicit constructor/CLI value > file > default, is-not-None
597
+ # semantics so an explicit 0.0 anywhere is honored.
598
+ # The project's base tolerance, as a test's: ONE finite number
599
+ # (`is_base_tolerance`). Anything else is this config error, not a
600
+ # TypeError/OverflowError traceback — nor `"0.5"` or `true` read as
601
+ # a number by `float()`. `null` is unset.
602
+ from .comparison.sides import base_tolerance_refused, is_base_tolerance
603
+
604
+ if self.tolerance is None:
605
+ tolerance, named = file_config.get("tolerance"), "testing.json tolerance"
606
+ else:
607
+ tolerance, named = self.tolerance, "--tolerance"
608
+ if tolerance is None:
609
+ self.tolerance = DEFAULT_COMPARISON_TOLERANCE
610
+ elif is_base_tolerance(tolerance):
611
+ self.tolerance = float(tolerance)
612
+ else:
613
+ raise ConfigError(base_tolerance_refused(named, tolerance))
614
+
615
+ # A worker start's bound: one positive finite number of seconds.
616
+ startup = file_config.get("worker_startup_timeout")
617
+ if startup is not None:
618
+ if (
619
+ isinstance(startup, bool)
620
+ or not isinstance(startup, int | float)
621
+ or not 0 < startup < float("inf")
622
+ ):
623
+ raise ConfigError(
624
+ f"testing.json worker_startup_timeout must be a positive "
625
+ f"number of seconds, got {startup!r}"
626
+ )
627
+ self.worker_startup_timeout = float(startup)
628
+
629
+ # Reporter payload budget
630
+ if "max_embedded_samples" in file_config:
631
+ self.max_embedded_samples = int(file_config["max_embedded_samples"])
632
+
633
+ # A baseline's points: "all" or a whole number of 2 or more,
634
+ # wherever it was said. `null` is no value of it: absent is the
635
+ # only way to leave it unset.
636
+ if "baseline_points" in file_config:
637
+ self.baseline_points = file_config["baseline_points"]
638
+ named = "testing.json baseline_points"
639
+ else:
640
+ named = "baseline_points"
641
+ refused = baseline_points_refused(named, self.baseline_points)
642
+ if refused is not None:
643
+ raise ConfigError(refused)
644
+
645
+ # The theme: one of the bundled names, wherever it was said.
646
+ from .reporting.theme import THEMES
647
+
648
+ if "theme" in file_config:
649
+ self.theme, named = file_config["theme"], "testing.json theme"
650
+ else:
651
+ named = "theme"
652
+ if not isinstance(self.theme, str) or self.theme not in THEMES:
653
+ raise ConfigError(
654
+ f"{named} is {json.dumps(self.theme, default=str)}: the bundled themes are "
655
+ f"{', '.join(THEMES)}"
656
+ )
657
+
658
+ # User-provided recognizers (PTA.3) — declarative JSON specs become
659
+ # Recognizer instances stored on Config. CLI-provided recognizers
660
+ # take precedence; testing.json fills in if Config wasn't given any.
661
+ if not self.recognizers and "recognizers" in file_config:
662
+ from .discovery.json_recognizer import (
663
+ RecognizerSpecError,
664
+ parse_recognizer_spec,
665
+ )
666
+
667
+ where = (
668
+ config_found_dir / CONFIG_FILENAME if config_found_dir else "the config"
669
+ )
670
+ specs = file_config["recognizers"]
671
+ if not isinstance(specs, list):
672
+ raise ConfigError(
673
+ f"'recognizers' in {where} must be a list, "
674
+ f"got {type(specs).__name__}"
675
+ )
676
+ try:
677
+ self.recognizers = [parse_recognizer_spec(spec) for spec in specs]
678
+ except RecognizerSpecError as e:
679
+ # A malformed recognizer is a testing.json problem the user
680
+ # must fix — one clean line and exit 2, not a traceback.
681
+ raise ConfigError(f"Invalid recognizer in {where}: {e}") from e
682
+ if not self.disabled_bundled and "disable_bundled" in file_config:
683
+ self.disabled_bundled = list(file_config["disable_bundled"])
684
+
685
+ if not self.backends:
686
+ self.backends = {
687
+ name: list(paths)
688
+ for name, paths in (file_config.get("backends") or {}).items()
689
+ }
690
+
691
+ # Resolve backend executable path
692
+ if self.backend_path is None:
693
+ backends_config = file_config.get("backends", {})
694
+ if backends_config:
695
+ resolved = _resolve_backend_path(backends_config, self.backend_name)
696
+ if resolved:
697
+ self.backend_path = resolved
698
+ else:
699
+ logger.warning(
700
+ "No working path found for backend '%s' in testing.json's 'backends' map. "
701
+ "Falling back to PATH lookup.",
702
+ self.backend_name,
703
+ )
704
+ if self.backend_path is None:
705
+ backend = self.backend
706
+ binary = BACKEND_BINARY_NAMES.get(backend, backend.lower())
707
+ if binary:
708
+ self.backend_path = shutil.which(binary) or binary
709
+
710
+ # Reference root (may already be set from CLI arg)
711
+ if self.reference_root is None:
712
+ ref_path = file_config.get("reference_root")
713
+ if ref_path:
714
+ base_dir = config_found_dir or repo_root
715
+ self.reference_root = (base_dir / ref_path).resolve()
716
+ else:
717
+ # Default: if testing.json lives in a ReferenceResults dir, use that;
718
+ # otherwise fall back to <repo>/Resources/ReferenceResults
719
+ if config_found_dir and config_found_dir.name == "ReferenceResults":
720
+ self.reference_root = config_found_dir
721
+ else:
722
+ self.reference_root = repo_root / "Resources" / "ReferenceResults"
723
+
724
+ # Dependencies — resolve relative paths from config file location.
725
+ # review 2026-07-06 finding 17: only absolutize entries that LOOK
726
+ # like paths. Bare library names (the documented
727
+ # ``"dependencies": ["Modelica"]`` form) must pass through untouched
728
+ # so the OpenModelica backend's classify_dependency routes them to
729
+ # ``loadModel(Modelica)`` instead of ``loadFile(<bogus path>)`` —
730
+ # absolutizing them also suppressed the MSL auto-injection.
731
+ # Dymola note: its backends load dependencies via
732
+ # ``openModel(<dir>/package.mo)`` and cannot load by bare name, but
733
+ # Dymola auto-loads MSL natively, so "Modelica" should simply be
734
+ # omitted there; a bare name reaching the Dymola backend fails at
735
+ # startup with a clear message, exactly as the absolutized bogus
736
+ # path did before this fix (no regression).
737
+ if not self.dependencies and "dependencies" in file_config:
738
+ base_dir = config_found_dir or repo_root
739
+ resolved_deps = []
740
+ for dep in file_config["dependencies"]:
741
+ if not _dependency_looks_like_path(dep, base_dir):
742
+ resolved_deps.append(dep) # bare library name
743
+ continue
744
+ dep_path = Path(dep)
745
+ if not dep_path.is_absolute():
746
+ dep_path = (base_dir / dep_path).resolve()
747
+ resolved_deps.append(str(dep_path))
748
+ self.dependencies = resolved_deps
749
+
750
+ # Test spec file — resolve relative to where testing.json was found
751
+ if self.test_spec_file is None:
752
+ spec_path = file_config.get("test_spec")
753
+ if spec_path:
754
+ base_dir = config_found_dir or repo_root
755
+ self.test_spec_file = (base_dir / spec_path).resolve()
756
+ else:
757
+ self.test_spec_file = Path(self.test_spec_file).resolve()
758
+
759
+ # Work directory
760
+ if self.work_dir is None:
761
+ work = file_config.get("work_dir")
762
+ if work:
763
+ work_path = Path(work)
764
+ if not work_path.is_absolute():
765
+ # review 2026-07-06 finding 15: a relative work_dir
766
+ # resolves against the config file's directory (like
767
+ # test_spec / dependencies), not the CWD — `compare`
768
+ # from another directory must find the same results.
769
+ work_path = (config_found_dir or repo_root) / work_path
770
+ self.work_dir = work_path.resolve()
771
+ else:
772
+ backend_dir = self.backend_name.replace(" ", "_")
773
+ self.work_dir = (
774
+ Path.cwd()
775
+ / "testing_output"
776
+ / self.library_name
777
+ / backend_dir
778
+ / self.os_name
779
+ )
780
+ else:
781
+ self.work_dir = Path(self.work_dir).resolve()
782
+
783
+ @property
784
+ def backend(self) -> str:
785
+ """The canonical backend family (e.g., 'Dymola' from 'Dymola 2025')."""
786
+ assert self.backend_name is not None # resolved in __post_init__
787
+ return _detect_backend(self.backend_name)
788
+
789
+ @property
790
+ def library_dir(self) -> Path:
791
+ """Path to the library's top-level package directory (contains package.mo).
792
+
793
+ Modelica-source convenience accessor; for source_type == "modelica" this
794
+ is identical to ``source_path``.
795
+ """
796
+ assert self.source_path is not None # resolved in __post_init__
797
+ return self.source_path
798
+
799
+ @property
800
+ def reference_dir(self) -> Path:
801
+ """Reference results directory, partitioned by backend and OS."""
802
+ assert self.reference_root is not None # resolved in __post_init__
803
+ assert self.os_name is not None # resolved in __post_init__
804
+ return self.reference_root / self.backend / self.os_name
805
+
806
+ @property
807
+ def spec_path(self) -> Path:
808
+ """Where ``test_spec.json`` lives: the configured file, else
809
+ ``<reference_root>/test_spec.json``. The one spelling of a fallback
810
+ that ``add``, ``spec-update`` and the review loop each used to
811
+ carry; the authoring Session reads the same property."""
812
+ if self.test_spec_file is not None:
813
+ return self.test_spec_file
814
+ assert self.reference_root is not None # resolved in __post_init__
815
+ return self.reference_root / "test_spec.json"