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,488 @@
1
+ """Orchestrate test discovery: find test models and merge parameters."""
2
+
3
+ import copy
4
+ import logging
5
+ from collections.abc import Callable, Iterable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Optional
9
+
10
+ from ..config import (
11
+ DEFAULT_METHOD,
12
+ DEFAULT_STOP_TIME,
13
+ DEFAULT_TOLERANCE,
14
+ Config,
15
+ )
16
+ from .recognizer import RecognizerResult, get_recognizers
17
+
18
+ if TYPE_CHECKING:
19
+ from ..comparison.tree_spec import SpecNode
20
+ from .mo_parser import LocatedClass
21
+ from .plots import PlotExtra
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class TestModel:
28
+ """A fully resolved test model with all metadata needed for simulation."""
29
+
30
+ model_id: str
31
+ source_file: Path
32
+ source_package: str
33
+ short_name: str
34
+ n_vars: int
35
+ # The UnitTests component's instance name: its variables are
36
+ # ``<instance>.x[i]`` in the result file. A test with no such component
37
+ # (``source == "spec"``) keeps the conventional default, and
38
+ # ``unit_test_variables`` returns nothing for it, so that default never
39
+ # ends up naming anything.
40
+ unit_tests_instance: str = "unitTests"
41
+ x_expressions: list[str] = field(default_factory=list)
42
+ x_raw: str = ""
43
+ x_reference: list[float] | None = None
44
+ error_expected: float = 1e-6
45
+ # Simulation fields default to None = "not set by any source" so the
46
+ # spec-over-annotation merge can use `is not None` as the explicitly-set
47
+ # sentinel, exactly like number_of_intervals / output_interval — review
48
+ # 2026-07-06 finding 13: equality-with-default sentinels silently dropped
49
+ # a spec value that happened to equal the framework default. During
50
+ # discovery they may be None; ``finalize_defaults()`` (called at the end
51
+ # of discover_tests) resolves them to the framework defaults so
52
+ # downstream consumers (backends, reporters, storage) only ever see
53
+ # concrete values.
54
+ stop_time: float | None = None
55
+ tolerance: float | None = None
56
+ method: str | None = None
57
+ number_of_intervals: int | None = None
58
+ output_interval: float | None = None
59
+ result_file: str = ""
60
+ timeout: int | None = None # Per-test timeout override (seconds)
61
+
62
+ # External spec: variable patterns (may include globs like "medium.T*" or "*")
63
+ # These are resolved against actual .mat variable names after simulation.
64
+ variable_patterns: list[str] = field(default_factory=list)
65
+
66
+ # Comparison settings (per-test and per-variable overrides)
67
+ #: Overrides config.tolerance. As the spec wrote it: a value that is no
68
+ #: number is carried to ``compare_test``, which refuses the test.
69
+ comparison_tolerance: Any = None
70
+ variable_overrides: dict[str, dict] = field(default_factory=dict)
71
+ # variable_overrides format: {"var_name": {"tolerance": 0.1}, ...}
72
+
73
+ # Phase 3.2: optional user-authored MetricTree spec from test_spec.json
74
+ # under the "metrics" key. When set, Phase 3.3+ will use this in place
75
+ # of the implicit flat-AND. Parse-only today — no behavior gated on it.
76
+ metric_tree_spec: Optional["SpecNode"] = None
77
+
78
+ # The entry's optional ``plots`` extras (`discovery.plots`): context
79
+ # series and watch plots beside the plots derived from the tree. None
80
+ # when the key is absent, ``()`` for ``[]`` — both mean no extras.
81
+ plots: tuple["PlotExtra", ...] | None = None
82
+
83
+ # The entry's own ``baseline_points`` (D153): ``"all"`` or a whole number
84
+ # of 2 or more — the points this test's stored baseline keeps of its
85
+ # run. None when the entry has no such key: the project's applies
86
+ # (``Config.baseline_points``).
87
+ baseline_points: int | str | None = None
88
+
89
+ # PTA.4 — richer-contract fields settable via recognizers. Additive;
90
+ # defaults preserve pre-PTA behavior.
91
+ #
92
+ # simulate_only: a recognizer's MARK — the test passes iff the simulation
93
+ # completes without error, whatever it declares, and no criterion staged
94
+ # on it is ever scored. What a reader asks is `is_simulate_only`, which
95
+ # is also true of a test that declares nothing to score.
96
+ simulate_only: bool = False
97
+ # requested_baselines: names of additional baselines to produce for this
98
+ # test (e.g., ["dymola-via-fmpy"]). Placeholder for 4.B.
99
+ requested_baselines: list[str] = field(default_factory=list)
100
+
101
+ # Per-field provenance: where the value came from.
102
+ # Keys: "stop_time", "tolerance", "method", "number_of_intervals",
103
+ # "output_interval". Values: "annotation", "test_spec", "default".
104
+ # Populated during recognizer + spec merge so the dashboard's
105
+ # resolution-explainer column can show "stop_time: 10 (annotation)".
106
+ field_sources: dict[str, str] = field(default_factory=dict)
107
+
108
+ # Where this test was defined: "unit_tests", "spec", "both"
109
+ source: str = "unit_tests"
110
+
111
+ @property
112
+ def is_simulate_only(self) -> bool:
113
+ """Whether the test passes iff its simulation completes — THE one
114
+ rule: a recognizer marked it (``simulate_only``), or it DECLARES
115
+ nothing to score: no UnitTests variable, no ``variables`` pattern,
116
+ no ``metrics`` tree and no ``comparison.variable_overrides`` entry.
117
+ That is the entry ``dstf add <model>`` writes (``"variables": []``),
118
+ which has no key of its own for it.
119
+
120
+ An override is a declaration: whoever wrote ``{"x": {"mode":
121
+ "range", ...}}`` asked for ``x`` to be checked. On an entry that
122
+ tracks nothing it scores nothing — an override changes how a TRACKED
123
+ variable is scored — and such a test passed, simulate-only, with no
124
+ word of the bound it never read.
125
+
126
+ Not the test that declares variables and whose result holds none of
127
+ them, nor the one whose overrides are all it declares: each scored
128
+ nothing it asked about, and fails
129
+ (``comparison.comparator.nothing_scored``)."""
130
+ from .unit_tests import unit_test_variables
131
+
132
+ if self.simulate_only:
133
+ return True
134
+ return (
135
+ not unit_test_variables(self)
136
+ and not self.variable_patterns
137
+ and self.metric_tree_spec is None
138
+ and not self.variable_overrides
139
+ )
140
+
141
+ def finalize_defaults(self) -> "TestModel":
142
+ """Resolve unset (None) simulation fields to framework defaults.
143
+
144
+ Called once, at the END of discover_tests — review 2026-07-06
145
+ finding 13: only the merge logic distinguishes "unset" (None) from
146
+ "explicitly the default value"; everything downstream sees concrete
147
+ values. Also stamps provenance "default" for the five sim fields no
148
+ source set (finding 57: spec-only tests used to claim "test_spec"
149
+ provenance for pure framework defaults).
150
+ """
151
+ if self.stop_time is None:
152
+ self.stop_time = DEFAULT_STOP_TIME
153
+ if self.tolerance is None:
154
+ self.tolerance = DEFAULT_TOLERANCE
155
+ if self.method is None:
156
+ self.method = DEFAULT_METHOD
157
+ for fname in (
158
+ "stop_time",
159
+ "tolerance",
160
+ "method",
161
+ "number_of_intervals",
162
+ "output_interval",
163
+ ):
164
+ self.field_sources.setdefault(fname, "default")
165
+ return self
166
+
167
+
168
+ def _build_test_model_from_recognizer_results(
169
+ model_id: str,
170
+ results: list[RecognizerResult],
171
+ ) -> TestModel:
172
+ """Merge per-recognizer results for one model into a TestModel.
173
+
174
+ Later results override earlier ones per-field. The bundled recognizer
175
+ registers first; user-provided recognizers (PTA.3) append, so user values
176
+ win on conflicts. Fields no recognizer set fall back to TestModel's
177
+ dataclass defaults.
178
+
179
+ The UnitTests variables are the ones a recognizer DECLARED: ``n_vars``
180
+ when one gave it, else as many as the expressions one gave, else none.
181
+ A test recognized by something other than the component (``extends
182
+ *Icons.Example``) has no ``<instance>.x[i]``, and a default of one made
183
+ every run look for ``unitTests.x[1]`` in its result and warn.
184
+ """
185
+ parts = model_id.rsplit(".", 1)
186
+ source_package = parts[0] if len(parts) > 1 else ""
187
+ short_name = parts[-1]
188
+
189
+ model = TestModel(
190
+ model_id=model_id,
191
+ source_file=Path(""),
192
+ source_package=source_package,
193
+ short_name=short_name,
194
+ n_vars=0,
195
+ result_file=short_name,
196
+ )
197
+
198
+ counted = False
199
+ for r in results:
200
+ if r.source_file is not None:
201
+ model.source_file = r.source_file
202
+ if r.n_vars is not None:
203
+ model.n_vars = r.n_vars
204
+ counted = True
205
+ if r.unit_tests_instance:
206
+ model.unit_tests_instance = r.unit_tests_instance
207
+ if r.x_expressions:
208
+ model.x_expressions = list(r.x_expressions)
209
+ if r.x_raw:
210
+ model.x_raw = r.x_raw
211
+ if r.x_reference is not None:
212
+ model.x_reference = list(r.x_reference)
213
+ if r.error_expected is not None:
214
+ model.error_expected = r.error_expected
215
+ if r.stop_time is not None:
216
+ model.stop_time = r.stop_time
217
+ model.field_sources["stop_time"] = "annotation"
218
+ if r.tolerance is not None:
219
+ model.tolerance = r.tolerance
220
+ model.field_sources["tolerance"] = "annotation"
221
+ if r.method is not None:
222
+ model.method = r.method
223
+ model.field_sources["method"] = "annotation"
224
+ if r.number_of_intervals is not None:
225
+ model.number_of_intervals = r.number_of_intervals
226
+ model.field_sources["number_of_intervals"] = "annotation"
227
+ if r.output_interval is not None:
228
+ model.output_interval = r.output_interval
229
+ model.field_sources["output_interval"] = "annotation"
230
+ if r.simulate_only is not None:
231
+ model.simulate_only = r.simulate_only
232
+ if r.requested_baselines is not None:
233
+ model.requested_baselines = list(r.requested_baselines)
234
+
235
+ if not counted:
236
+ model.n_vars = len(model.x_expressions)
237
+ return model
238
+
239
+
240
+ def scan_source_tests(
241
+ config: Config, progress: Callable[[int, int], None] | None = None
242
+ ) -> dict[str, TestModel]:
243
+ """Step 1 of discovery: recognizer results over the source tree, keyed
244
+ by model_id. Empty unless ``source_type == "modelica"``. ``progress(done,
245
+ total)`` is called after each ``.mo`` file is scanned."""
246
+ if config.source_type != "modelica":
247
+ return {}
248
+ return scan_files(config, sorted(config.library_dir.rglob("*.mo")), progress)
249
+
250
+
251
+ def scan_files(
252
+ config: Config,
253
+ files: Iterable[Path],
254
+ progress: Callable[[int, int], None] | None = None,
255
+ ) -> dict[str, TestModel]:
256
+ """The tests the recognizers find in ``files`` of a Modelica library,
257
+ keyed by model_id: :func:`scan_source_tests` over every ``.mo`` file of
258
+ it. ``progress(done, total)`` is called after each file."""
259
+ # Trigger bundled-recognizer registration on first call.
260
+ from . import mo_parser # noqa: F401
261
+
262
+ # Bundled recognizers come from the module-level registry (filtered by
263
+ # config.disabled_bundled); user-provided recognizers from config.recognizers
264
+ # append, so user values win on per-field merge in
265
+ # _build_test_model_from_recognizer_results.
266
+ library_dir = config.library_dir
267
+ bundled = [
268
+ r for r in get_recognizers("modelica") if r.name not in config.disabled_bundled
269
+ ]
270
+ user = [r for r in config.recognizers if "modelica" in r.applies_to]
271
+ recognizers = bundled + user
272
+
273
+ files = list(files)
274
+ per_model: dict[str, list[RecognizerResult]] = {}
275
+ for done, mo_file in enumerate(files, start=1):
276
+ for recognizer in recognizers:
277
+ if not recognizer.applies_to_path(mo_file, library_dir):
278
+ continue
279
+ result = recognizer.recognize(mo_file)
280
+ if result is not None:
281
+ per_model.setdefault(result.model_id, []).append(result)
282
+ if progress is not None:
283
+ progress(done, len(files))
284
+
285
+ return {
286
+ model_id: _build_test_model_from_recognizer_results(model_id, results)
287
+ for model_id, results in per_model.items()
288
+ }
289
+
290
+
291
+ def spec_tests_by_model(models: list[TestModel]) -> dict[str, TestModel]:
292
+ """Step 2: the spec's models keyed by model_id, first entry wins on a
293
+ duplicate (matching patch_apply / the spec edit helpers), with a warning."""
294
+ spec_tests: dict[str, TestModel] = {}
295
+ for test in models:
296
+ # review 2026-07-06 finding 51: duplicate model entries — FIRST
297
+ # wins (matching patch_apply / the spec edit helpers), with a
298
+ # warning. Last-wins here made applied patches silently
299
+ # ineffective (they edit the first entry).
300
+ if test.model_id in spec_tests:
301
+ logger.warning(
302
+ "test_spec has duplicate entries for model '%s'; "
303
+ "using the first — remove the duplicates",
304
+ test.model_id,
305
+ )
306
+ continue
307
+ spec_tests[test.model_id] = test
308
+ return spec_tests
309
+
310
+
311
+ #: The simulation fields an ``experiment(...)`` annotation can give.
312
+ _SIMULATION_FIELDS = (
313
+ "stop_time",
314
+ "tolerance",
315
+ "method",
316
+ "number_of_intervals",
317
+ "output_interval",
318
+ )
319
+
320
+
321
+ class SpecModelLocator:
322
+ """Finds the model of a test that only ``test_spec.json`` declares: its
323
+ file and its ``experiment(...)`` annotation
324
+ (:func:`~.mo_parser.find_class`), which :func:`merge_tests` fills the
325
+ entry's unset simulation fields from.
326
+
327
+ Modelica sources only — any other source type has no annotation to
328
+ read, and every ask answers None.
329
+
330
+ Each model is looked for ONCE per locator, a miss included: the
331
+ authoring Session re-merges on every edit of the spec, and on a slow
332
+ mount the probes for a few spec-only models are not something to repeat
333
+ at every keystroke. A locator therefore shows a model file as it was
334
+ when first asked; whoever holds one replaces it when the library is
335
+ scanned again, or takes one that knows better (:meth:`having`) when it
336
+ read a model's file again itself.
337
+ """
338
+
339
+ def __init__(self, config: Config):
340
+ self._config = config
341
+ self._found: dict[str, LocatedClass | None] = {}
342
+
343
+ def having(self, found: Mapping[str, "LocatedClass"]) -> "SpecModelLocator":
344
+ """A locator that answers *found* for those models and, for every
345
+ other, what this one has found so far (and looks for the rest)."""
346
+ other = SpecModelLocator(self._config)
347
+ other._found = {**self._found, **found}
348
+ return other
349
+
350
+ def __call__(self, model_id: str) -> "LocatedClass | None":
351
+ if self._config.source_type != "modelica":
352
+ return None
353
+ if model_id not in self._found:
354
+ from .mo_parser import find_class
355
+
356
+ config = self._config
357
+ assert config.library_name is not None # resolved in __post_init__
358
+ self._found[model_id] = find_class(
359
+ config.library_dir, config.library_name, model_id
360
+ )
361
+ return self._found[model_id]
362
+
363
+
364
+ def _fill_from_model(test: TestModel, located: "LocatedClass") -> None:
365
+ """Give a spec-only ``test`` what its model says: the file, and every
366
+ simulation field the entry left unset that the model's ``experiment``
367
+ annotation sets — the two-layer contract a scanned test already gets
368
+ (docs/usage.md). The entry's own ``simulation.*`` and ``source`` stay."""
369
+ if test.source_file == Path(""):
370
+ test.source_file = located.mo_file
371
+ experiment = located.experiment
372
+ if experiment is None:
373
+ return
374
+ for name in _SIMULATION_FIELDS:
375
+ value = getattr(experiment, name)
376
+ if getattr(test, name) is None and value is not None:
377
+ setattr(test, name, value)
378
+ test.field_sources[name] = "annotation"
379
+
380
+
381
+ def merge_tests(
382
+ scanned: dict[str, TestModel],
383
+ spec_tests: dict[str, TestModel],
384
+ *,
385
+ locate: Callable[[str], "LocatedClass | None"] | None = None,
386
+ ) -> list[TestModel]:
387
+ """Step 3: union by model_id; the spec wins per field. Copies both
388
+ inputs, so a caller (the authoring Session) can keep one scan and
389
+ re-merge it against every edit of the spec.
390
+
391
+ ``locate`` finds the model of an entry the scan did not recognize
392
+ (:class:`SpecModelLocator`); such a test takes its model's file and
393
+ annotation. Without it a spec-only entry is taken as written."""
394
+ merged: dict[str, TestModel] = {
395
+ model_id: copy.deepcopy(test) for model_id, test in scanned.items()
396
+ }
397
+
398
+ # Merge spec tests. Two-layer contract (docs/usage.md): the model's
399
+ # experiment(...) annotation provides defaults; any field set in
400
+ # simulation.* overrides it. DECISION (review 2026-07-06 finding 13):
401
+ # "explicitly set in the spec" is `is not None` for ALL five sim fields —
402
+ # never equality with a framework default, which silently dropped a spec
403
+ # value like stop_time=1.0 against experiment(StopTime=100). Framework
404
+ # defaults are applied only afterwards, by finalize_defaults() below.
405
+ for model_id, spec_test in spec_tests.items():
406
+ spec_test = copy.deepcopy(spec_test)
407
+ if model_id in merged:
408
+ existing = merged[model_id]
409
+ existing.variable_patterns = spec_test.variable_patterns
410
+ existing.source = "both"
411
+ if spec_test.stop_time is not None:
412
+ existing.stop_time = spec_test.stop_time
413
+ existing.field_sources["stop_time"] = "test_spec"
414
+ if spec_test.tolerance is not None:
415
+ existing.tolerance = spec_test.tolerance
416
+ existing.field_sources["tolerance"] = "test_spec"
417
+ if spec_test.method is not None:
418
+ existing.method = spec_test.method
419
+ existing.field_sources["method"] = "test_spec"
420
+ if spec_test.number_of_intervals is not None:
421
+ existing.number_of_intervals = spec_test.number_of_intervals
422
+ existing.field_sources["number_of_intervals"] = "test_spec"
423
+ if spec_test.output_interval is not None:
424
+ existing.output_interval = spec_test.output_interval
425
+ existing.field_sources["output_interval"] = "test_spec"
426
+ if spec_test.comparison_tolerance is not None:
427
+ existing.comparison_tolerance = spec_test.comparison_tolerance
428
+ if spec_test.variable_overrides:
429
+ existing.variable_overrides.update(spec_test.variable_overrides)
430
+ if spec_test.timeout is not None:
431
+ existing.timeout = spec_test.timeout
432
+ if spec_test.metric_tree_spec is not None:
433
+ existing.metric_tree_spec = spec_test.metric_tree_spec
434
+ if spec_test.plots is not None:
435
+ existing.plots = spec_test.plots
436
+ if spec_test.baseline_points is not None:
437
+ existing.baseline_points = spec_test.baseline_points
438
+ else:
439
+ # Provenance for spec-set fields was stamped by parse_test_spec;
440
+ # unset fields get "default" in finalize_defaults — review
441
+ # 2026-07-06 finding 57 (the old blanket "test_spec" stamp lied).
442
+ located = locate(model_id) if locate is not None else None
443
+ if located is not None:
444
+ _fill_from_model(spec_test, located)
445
+ merged[model_id] = spec_test
446
+
447
+ # Sort by model_id for consistent ordering; resolve remaining None sim
448
+ # fields to framework defaults so downstream code never sees None
449
+ # (finding 13 decision: apply-defaults-at-end-of-merge).
450
+ tests = sorted(merged.values(), key=lambda t: t.model_id)
451
+ for test in tests:
452
+ test.finalize_defaults()
453
+ return tests
454
+
455
+
456
+ def discover_tests(config: Config) -> list[TestModel]:
457
+ """Discover all test models from registered recognizers and/or external spec.
458
+
459
+ Sources (merged by model_id):
460
+ 1. Source-file recognizers (PTA.1+) — for ``source_type == "modelica"``,
461
+ walks ``*.mo`` and runs every registered Modelica recognizer. Bundled
462
+ default is the ``UnitTests`` + ``experiment(...)`` recognizer; user-
463
+ provided recognizers (PTA.3) layer on top.
464
+ 2. External test_spec.json (the configured file, else
465
+ ``<reference_root>/test_spec.json`` when it exists; overrides
466
+ recognizer values). A model only the spec names is looked up in the
467
+ library (:class:`SpecModelLocator`) for its file and its
468
+ ``experiment`` annotation.
469
+
470
+ When a model appears in both recognizers and spec, variable patterns
471
+ from the spec are added alongside the recognizer-derived variables, and
472
+ the source is marked as "both". Spec simulation parameters override
473
+ recognizer values when explicitly set.
474
+
475
+ The three steps are separable: the authoring Session keeps one
476
+ :func:`scan_source_tests` result and re-runs :func:`merge_tests` against
477
+ its in-memory spec document on every edit.
478
+ """
479
+ scanned = scan_source_tests(config)
480
+ if config.spec_path.exists():
481
+ from .spec_parser import parse_test_spec
482
+
483
+ models = parse_test_spec(config.spec_path)
484
+ else:
485
+ models = []
486
+ return merge_tests(
487
+ scanned, spec_tests_by_model(models), locate=SpecModelLocator(config)
488
+ )
@@ -0,0 +1,95 @@
1
+ """The one place a UnitTests component's variables are named.
2
+
3
+ A variable's NAME is the one it has in the result file —
4
+ ``<instance>.x[i]``. The text the model wrote in ``x={...}`` is its
5
+ EXPRESSION: a label that rides beside the name, never a key. The
6
+ backends that extract a result, and the variable filter OpenModelica
7
+ simulates under, ask here rather than spelling ``unitTests.x[i]``
8
+ themselves.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from .test_registry import TestModel
19
+
20
+
21
+ #: The array the component tracks, ``x={...}`` (see :func:`unit_test_variables`).
22
+ _ARRAY = "x"
23
+
24
+
25
+ def unit_test_name(instance: str, index: int) -> str:
26
+ """``<instance>.x[i]`` — the result-file name of element ``index``."""
27
+ return f"{instance}.{_ARRAY}[{index}]"
28
+
29
+
30
+ def is_unit_test_variable(test: TestModel, name: str) -> bool:
31
+ """Whether ``name`` is an element of the test's UnitTests component,
32
+ ``<instance>.x[i]`` — declared through the component or tracked by that
33
+ name. Each is its own test quantity, whatever its expression reads as,
34
+ so no plot folds it into a family with another (the owner's ruling O3,
35
+ 2026-09-28)."""
36
+ head = f"{test.unit_tests_instance}.{_ARRAY}["
37
+ # ASCII digits: `str.isdigit` takes `²` and `١` for one too
38
+ return (
39
+ name.startswith(head)
40
+ and name.endswith("]")
41
+ and re.fullmatch(r"[0-9]+", name[len(head) : -1]) is not None
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class UnitTestVariable:
47
+ """One tracked variable of the component: its result-file name and the
48
+ expression the model wrote for it (``""`` when nothing said which
49
+ expression belongs to which index)."""
50
+
51
+ name: str
52
+ expression: str
53
+
54
+
55
+ def unit_test_variables(test: TestModel) -> list[UnitTestVariable]:
56
+ """The component's variables in index order, empty for a test with no
57
+ such component (``source == "spec"``, or no variables declared).
58
+
59
+ The component is assumed to name its tracked array ``x`` — the bundled
60
+ one does, and a custom component reached through a JSON recognizer is
61
+ taken to follow it.
62
+
63
+ The expression is ``x_expressions[i - 1]`` when the model wrote one per
64
+ variable, and ``f"{x_expressions[0]}[{i}]"`` when it wrote a SINGLE
65
+ expression covering all of them — an array (``x=y``) or a concatenation
66
+ (``x=cat(1, a, b)``), whose element ``i`` reads as ``y[i]`` /
67
+ ``cat(1, a, b)[i]``.
68
+
69
+ Any other shape leaves the expression ``""``: two expressions for three
70
+ variables says nothing about which piece is which, and a wrong label is
71
+ worse than none.
72
+ """
73
+ if test.source not in ("unit_tests", "both") or test.n_vars <= 0:
74
+ return []
75
+ exprs = test.x_expressions
76
+ out: list[UnitTestVariable] = []
77
+ for i in range(1, test.n_vars + 1):
78
+ if len(exprs) == test.n_vars:
79
+ expression = exprs[i - 1]
80
+ elif len(exprs) == 1:
81
+ expression = f"{exprs[0]}[{i}]"
82
+ else:
83
+ expression = ""
84
+ out.append(
85
+ UnitTestVariable(unit_test_name(test.unit_tests_instance, i), expression)
86
+ )
87
+ return out
88
+
89
+
90
+ __all__ = [
91
+ "UnitTestVariable",
92
+ "is_unit_test_variable",
93
+ "unit_test_name",
94
+ "unit_test_variables",
95
+ ]
@@ -0,0 +1,14 @@
1
+ """One figure layer, two consumers.
2
+
3
+ A :class:`~.view.VariableView` is everything a plot of one variable needs —
4
+ trajectories, overlays, the leaves that target it with their geometry and
5
+ summaries, the error series — assembled the same way by the HTML report
6
+ and by the authoring tool. :func:`~.plotly.variable_figure` renders it to a
7
+ plain Plotly figure dict (``data`` / ``layout`` / ``config``), with no
8
+ ``plotly.py`` dependency; the report embeds the dict, the tool hands it to
9
+ its plotly element. :func:`~.plotly.plot_figure` renders a plot of SEVERAL
10
+ views the same way — array families in one hue ramp (:mod:`.families`) —
11
+ and is ``variable_figure`` itself when a plot holds one. Nothing here knows
12
+ a mode name: drawing is keyed on primitive type
13
+ (:mod:`dstf.comparison.geometry`).
14
+ """
@@ -0,0 +1,48 @@
1
+ """From a comparison and a trajectory to a VariableView — the assembly the
2
+ report's figure build and the authoring Session's ``view`` share."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Sequence
7
+
8
+ from ..comparison.types import VariableComparison
9
+ from .view import VariableView, build_view
10
+
11
+ LeafPair = tuple[str, VariableComparison]
12
+
13
+
14
+ def leaves_by_variable(metric_tree) -> dict[str, list[LeafPair]]:
15
+ """Every scored leaf, keyed by the variable it scores, as the
16
+ ``(leaf_path, comparison)`` pairs ``build_view`` takes. Empty for
17
+ ``None``."""
18
+ from ..comparison.tree_eval import leaf_comparisons_by_path
19
+
20
+ out: dict[str, list[LeafPair]] = {}
21
+ if metric_tree is None:
22
+ return out
23
+ for path, vc in leaf_comparisons_by_path(metric_tree).items():
24
+ out.setdefault(vc.name, []).append((path, vc))
25
+ return out
26
+
27
+
28
+ def view_from_trajectory(
29
+ name: str, trajectory: dict, leaves: Sequence[LeafPair]
30
+ ) -> VariableView:
31
+ """The view for one variable from a report-shaped trajectory entry
32
+ (``act_time`` / ``act_values`` / ``ref_time`` / ``ref_values`` /
33
+ ``text`` / ``overlays``, any of them absent).
34
+
35
+ The view's label is the trajectory's ``text`` — what the test's page
36
+ reads for the variable — not its ``label``: the plain label belongs to
37
+ the surfaces that show the muted name beside it, and a figure's legend
38
+ and hover show no name."""
39
+ return build_view(
40
+ name,
41
+ label=trajectory.get("text") or trajectory.get("label") or name,
42
+ act_time=trajectory.get("act_time"),
43
+ act_values=trajectory.get("act_values"),
44
+ ref_time=trajectory.get("ref_time"),
45
+ ref_values=trajectory.get("ref_values"),
46
+ overlays=trajectory.get("overlays") or [],
47
+ leaves=leaves,
48
+ )