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,103 @@
1
+ """Run-level provenance metadata: which backend/version produced
2
+ a set of results.
3
+
4
+ Motivation (2026-07-07): a report showed *what* the verdicts were but never
5
+ *what produced them*. When results drift, the first question is "did the tool
6
+ change?" — e.g. Dymola 2025x vs 2026x, or an MSL bump. Without the backend +
7
+ version stamped on the report, that question needs a full re-investigation.
8
+
9
+ Design: the identity that's always available comes from ``Config`` for free
10
+ (``backend``, the user-configured ``backend_name`` label, ``os_name``).
11
+ The *actual* tool-reported version (``DymolaVersion()``, ``getVersion()``, …)
12
+ is best-effort — captured from a live backend when obtainable, ``None`` when
13
+ not, and never allowed to fail a run. ``tool_version`` is therefore the gold
14
+ standard when present and the configured ``backend_name`` label is the reliable
15
+ floor.
16
+
17
+ This dataclass is constructed once per run by the backend, serialized into
18
+ ``status.json`` under a ``"metadata"`` key, and read back by the reporters to
19
+ render a provenance banner.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import time
25
+ from dataclasses import asdict, dataclass
26
+ from typing import TYPE_CHECKING
27
+
28
+ if TYPE_CHECKING: # pragma: no cover — typing only
29
+ from ..config import Config
30
+
31
+
32
+ def _dstf_version() -> str:
33
+ """Installed DSTF version, or ``"unknown"`` when it can't be resolved.
34
+
35
+ Never raises — provenance must not be able to break a run.
36
+ """
37
+ try:
38
+ from importlib.metadata import PackageNotFoundError, version
39
+
40
+ from .. import _DIST_NAME
41
+
42
+ try:
43
+ return version(_DIST_NAME)
44
+ except PackageNotFoundError:
45
+ return "unknown"
46
+ except Exception: # pragma: no cover — defensive
47
+ return "unknown"
48
+
49
+
50
+ @dataclass
51
+ class RunMetadata:
52
+ """Provenance for one test run.
53
+
54
+ ``backend`` resolved backend family (e.g. ``"Dymola"``).
55
+ ``backend_name`` the label the user configured (e.g. ``"Dymola 2026x"``);
56
+ equals ``backend`` when no version suffix was given.
57
+ ``os`` ``"linux"`` / ``"macos"`` / ``"windows"``.
58
+ ``tool_version`` actual tool-reported version, or ``None`` when the
59
+ backend couldn't (or wasn't asked to) report one.
60
+ ``dstf_version`` the DSTF package version that ran the tests.
61
+ ``generated_at`` epoch seconds when the run started; ``None`` only in
62
+ synthetic contexts (tests) that don't stamp a time.
63
+ ``library_versions`` loaded-library versions the backend could report,
64
+ e.g. ``{"Modelica": "4.1.0"}`` — the Modelica Standard
65
+ Library version is the second drift suspect (a solver's
66
+ MSL can change between tool releases). ``None`` when the
67
+ backend reports none.
68
+ """
69
+
70
+ backend: str
71
+ backend_name: str
72
+ os: str
73
+ tool_version: str | None = None
74
+ dstf_version: str = ""
75
+ generated_at: float | None = None
76
+ library_versions: dict | None = None
77
+
78
+ @classmethod
79
+ def from_config(
80
+ cls,
81
+ config: Config,
82
+ tool_version: str | None = None,
83
+ now: float | None = None,
84
+ library_versions: dict | None = None,
85
+ ) -> RunMetadata:
86
+ """Build from a resolved ``Config``.
87
+
88
+ ``now`` is injectable so tests can assert a deterministic timestamp;
89
+ production passes ``None`` and gets wall-clock ``time.time()``.
90
+ """
91
+ return cls(
92
+ backend=config.backend,
93
+ backend_name=config.backend_name or config.backend,
94
+ os=config.os_name or "unknown",
95
+ tool_version=tool_version,
96
+ dstf_version=_dstf_version(),
97
+ generated_at=time.time() if now is None else now,
98
+ library_versions=library_versions or None,
99
+ )
100
+
101
+ def as_dict(self) -> dict:
102
+ """Plain-dict form for JSON serialization into ``status.json``."""
103
+ return asdict(self)
dstf/backends/types.py ADDED
@@ -0,0 +1,310 @@
1
+ """Value types every backend speaks, and the variable-pattern resolver.
2
+
3
+ Stdlib + numpy only: this module imports nothing from ``dstf``. That is what
4
+ keeps the package's import graph acyclic — :mod:`dstf.storage` and
5
+ :mod:`dstf.comparison` can name these types without a cycle back through the
6
+ backends, and a backend module can import them before the registry in
7
+ :mod:`dstf.backends` has finished loading.
8
+ """
9
+
10
+ import logging
11
+ from collections import Counter
12
+ from collections.abc import Iterable, Mapping
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import NamedTuple
16
+
17
+ import numpy as np
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Backend capabilities + dataset types (Phase 1 abstraction)
24
+ # ---------------------------------------------------------------------------
25
+ # See docs/vision.md and docs/extensibility.md. Each backend declares what it
26
+ # supports; framework features gate on these rather than on backend class.
27
+
28
+
29
+ class Capability(str, Enum):
30
+ """Declared abilities of a backend."""
31
+
32
+ #: Backend can hold a loaded model in memory across multiple tests.
33
+ PERSISTENT_WORKERS = "persistent-workers"
34
+ #: Backend exposes a non-interactive script-driven fallback.
35
+ BATCH_FALLBACK = "batch-fallback"
36
+ #: Backend can export a test artefact as an FMU (enables cross-backend verification).
37
+ FMU_EXPORT = "fmu-export"
38
+ #: The result file grows while the run is in progress and can be read
39
+ #: then: ``Backend.live_result_path`` says which file.
40
+ LIVE_RESULTS = "live-results"
41
+ #: Backend can stop a test that is running, from another thread (the
42
+ #: simulation queue's cancel).
43
+ CANCEL = "cancel"
44
+
45
+
46
+ class DatasetType(str, Enum):
47
+ """Typed categories of result data a backend may produce.
48
+
49
+ Every backend currently emits ``TIME_SERIES``. ``SCALARS`` is scorable
50
+ already — the ``scalar`` metric accepts it — so a scalars-only backend
51
+ would work today; the remaining types (events, spectra, distributions)
52
+ are reserved for metrics that don't exist yet.
53
+ """
54
+
55
+ TIME_SERIES = "time-series"
56
+ SCALARS = "scalars"
57
+ EVENTS = "events"
58
+ SPECTRUM = "spectrum"
59
+ DISTRIBUTION = "distribution"
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Common result types (shared across all backends)
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ @dataclass
68
+ class VariableResult:
69
+ """Time series for a single tracked variable."""
70
+
71
+ index: int # 1-based
72
+ time: np.ndarray
73
+ values: np.ndarray
74
+ # The variable's name in the result file ("pipe.T[1]", "unitTests.x[1]")
75
+ # — the only key: overrides, tree leaves, plot series and baselines all
76
+ # name a variable this way.
77
+ name: str = ""
78
+ # What the model wrote for a UnitTests variable (``x={pipe.T[1]}``): a
79
+ # label for people, recorded beside the name in the baseline. Never a key,
80
+ # and empty for a variable whose name is all there is.
81
+ expression: str = ""
82
+
83
+
84
+ def display_label(name: str, expression: str) -> str:
85
+ """What a person reads for a variable: the expression the model wrote
86
+ for it, or its name when it wrote none.
87
+
88
+ The plain label. A surface that shows the muted NAME beside its title
89
+ — the report's variable heading, the tool's card title — reads this;
90
+ every other human-facing surface reads :func:`variable_texts`' ``text``
91
+ instead, which adds the name where the label is ambiguous. The name
92
+ remains the key everywhere — element ids, markers, ``meta.id``,
93
+ ``data-variable``, overrides, leaves, baselines — so nothing is ever
94
+ looked up by either.
95
+ """
96
+ return expression or name
97
+
98
+
99
+ def label_with_name(name: str, expression: str) -> str:
100
+ """``label (name)`` for the LOG surfaces — the console, JUnit, the
101
+ interactive review — and the bare name when the two agree.
102
+
103
+ These three do NOT take the page's rule (:func:`variable_texts`): a
104
+ line of CI output is read without a page around it, so there is no
105
+ scope to disambiguate within. The expression says WHICH quantity and
106
+ the name says what to write a criterion, an override or a `--filter`
107
+ against, every time the two differ — whether or not some other
108
+ variable of the same test happens to share the expression.
109
+ """
110
+ label = display_label(name, expression)
111
+ return f"{label} ({name})" if label != name else name
112
+
113
+
114
+ class VariableText(NamedTuple):
115
+ """What one variable reads as on a page, both ways.
116
+
117
+ ``label`` is :func:`display_label` — the expression the model wrote,
118
+ else the name. ``text`` is that label with the name beside it when the
119
+ page's scope makes the label ambiguous. A surface that already shows
120
+ the muted name beside its title reads ``label``; every other
121
+ human-facing surface reads ``text``. See :func:`variable_texts`.
122
+ """
123
+
124
+ label: str
125
+ text: str
126
+
127
+
128
+ def variable_texts(
129
+ labels: Mapping[str, str],
130
+ plot_series: Iterable[str] = (),
131
+ ) -> dict[str, VariableText]:
132
+ """``{name: VariableText}`` — the ONE rule for what a person reads for
133
+ each variable of a TEST, on both tiers.
134
+
135
+ The scope is the whole test: every name its page can show. ``labels``
136
+ maps name → :func:`display_label` for the result's tracked variables
137
+ and the scored comparisons; ``plot_series`` adds the names of every
138
+ series of the test's resolved plots, a plot-only one included — such a
139
+ name has no expression, so its label is itself.
140
+
141
+ ``text`` is the label, unless ANOTHER name in that scope carries the
142
+ same label and the label is not already the name — then the name rides
143
+ with it, the same ``label (name)`` pair :func:`label_with_name`
144
+ prints. ``x={a.Q, a.Q}`` is legal Modelica and happens: two criteria
145
+ rows, two legend entries, two pills or two select options reading
146
+ ``a.Q`` name two different variables and give no way to tell which is
147
+ which. The ``label != name`` half is what keeps a plot-only variable
148
+ genuinely NAMED ``z`` reading ``z`` beside a tracked variable whose
149
+ EXPRESSION is ``z`` — the one carrying the expression is the ambiguous
150
+ one, and it reads ``z (unitTests.x[1])``.
151
+
152
+ One scope for the test, not one per plot or one per scored list: five
153
+ different scopes gave the same variable four different texts on one
154
+ page (the b147918 review).
155
+ """
156
+ scope: dict[str, str] = {name: name for name in plot_series if name}
157
+ scope.update({name: label for name, label in labels.items() if name})
158
+ counts = Counter(scope.values())
159
+ return {
160
+ name: VariableText(
161
+ label,
162
+ f"{label} ({name})" if label != name and counts[label] > 1 else label,
163
+ )
164
+ for name, label in scope.items()
165
+ }
166
+
167
+
168
+ @dataclass
169
+ class TestResult:
170
+ """Results from a single test simulation."""
171
+
172
+ model_id: str
173
+ success: bool
174
+ variables: list[VariableResult] = field(default_factory=list)
175
+ diagnostics: list[VariableResult] = field(default_factory=list)
176
+ error_message: str | None = None
177
+ statistics: dict | None = None
178
+ #: Whether ``error_message`` is the RUN's own reason — its run result's,
179
+ #: or a failure the run wrote into its result file — and not a reader's
180
+ #: account of what the file holds. A reader's account is led by the
181
+ #: verdict the run recorded (``backends.base._force_failed``); the run's
182
+ #: own reason already says what happened. In memory only.
183
+ run_reason: bool = False
184
+
185
+
186
+ #: Appended to every timed-out result's ``error_message``, so the escape hatch
187
+ #: travels with the failure to wherever it is read — the dispatcher's progress
188
+ #: line, the console report, the dashboard detail. ``--timeout`` only moves the
189
+ #: global default; one genuinely slow model wants the per-test override, and a
190
+ #: user who has just hit a timeout is exactly who needs to be told that.
191
+ TIMEOUT_OVERRIDE_HINT = (
192
+ "raise it per test with simulation.timeout in test_spec.json, "
193
+ "or globally with --timeout"
194
+ )
195
+
196
+
197
+ def timeout_message(what: str) -> str:
198
+ """``"<what> (<hint>)"`` — the one phrasing for a timeout failure."""
199
+ return f"{what} ({TIMEOUT_OVERRIDE_HINT})"
200
+
201
+
202
+ @dataclass
203
+ class TestRunResult:
204
+ """Result of running a single test."""
205
+
206
+ model_id: str
207
+ test_key: str
208
+ success: bool
209
+ elapsed: float = 0.0
210
+ error_message: str | None = None
211
+ timed_out: bool = False
212
+ statistics: dict | None = None
213
+ # Phase breakdown (captured by the persistent backend; None in batch mode)
214
+ translation_wall: float | None = None
215
+ sim_wall: float | None = None
216
+ # Set by persistent workers when the test run left the worker process
217
+ # dead (we called close()). The dispatch loop uses this to decide
218
+ # whether the next restart should count against the budget — workers
219
+ # killed for benign reasons (timeout, post-timeout disk-rescue) get a
220
+ # fresh start without ticking the bound. Independent of `timed_out`
221
+ # and `success`: a disk-rescued test can have success=True AND
222
+ # worker_killed=True (the .mat was written just before the watchdog
223
+ # killed Dymola).
224
+ worker_killed: bool = False
225
+ # The run was stopped on request (the simulation queue's cancel), so it
226
+ # is neither a failure nor a result: recorded as cancelled
227
+ # (BatchManifest.record_result) and read back as no result at all.
228
+ # ``success`` is False beside it.
229
+ cancelled: bool = False
230
+
231
+
232
+ # Variables to exclude when resolving "*" or glob patterns (Dymola internals, etc.)
233
+ _EXCLUDE_PREFIXES = ("der(", "$", "_")
234
+ _EXCLUDE_NAMES = {"time"}
235
+
236
+
237
+ def _pattern_to_regex(pattern: str):
238
+ """Convert a simple glob pattern to a compiled regex.
239
+
240
+ Only * (match any) and ? (match one) are wildcards.
241
+ All other characters (including Modelica's [] for array indices,
242
+ parentheses, dots) are treated as literals.
243
+ """
244
+ import re as _re
245
+
246
+ parts = []
247
+ for ch in pattern:
248
+ if ch == "*":
249
+ parts.append(".*")
250
+ elif ch == "?":
251
+ parts.append(".")
252
+ else:
253
+ parts.append(_re.escape(ch))
254
+ return _re.compile("".join(parts))
255
+
256
+
257
+ def is_glob_pattern(pattern: str) -> bool:
258
+ """Only ``*`` and ``?`` are wildcards; everything else — including the
259
+ ``[]`` of a Modelica array element such as ``pipe.T[1]`` — is literal.
260
+ The one rule :func:`resolve_variable_patterns` and the spec-update
261
+ materialization share."""
262
+ return "*" in pattern or "?" in pattern
263
+
264
+
265
+ def resolve_variable_patterns(
266
+ patterns: list[str],
267
+ available_vars: list[str],
268
+ ) -> list[str]:
269
+ """Resolve variable patterns against available variable names from simulation.
270
+
271
+ Pattern types:
272
+ - Explicit name: "pipe.T[1]" — must exist exactly
273
+ - Glob: "medium.T*" — fnmatch against available names
274
+ - Wildcard: "*" — all non-internal variables
275
+ - Empty list: [] — no variables (simulate-only mode)
276
+
277
+ Returns the resolved names in PATTERN order: explicit names as written,
278
+ each glob's matches in ``available_vars``' (the result file's) order, a
279
+ name matched twice where it was first. Sorted, ``x[10]`` came before
280
+ ``x[2]``: a test with no tree drew its plots alphabetically, and an
281
+ accept wrote its ``variables`` that way.
282
+ """
283
+ if not patterns:
284
+ return []
285
+
286
+ resolved: dict[str, None] = {}
287
+ for pattern in patterns:
288
+ if pattern == "*":
289
+ # All non-internal variables
290
+ for var in available_vars:
291
+ if var in _EXCLUDE_NAMES:
292
+ continue
293
+ if any(var.startswith(p) for p in _EXCLUDE_PREFIXES):
294
+ continue
295
+ resolved.setdefault(var)
296
+ elif is_glob_pattern(pattern):
297
+ regex = _pattern_to_regex(pattern)
298
+ matched = [v for v in available_vars if regex.fullmatch(v)]
299
+ if not matched:
300
+ logger.warning("Pattern '%s' matched no variables", pattern)
301
+ for var in matched:
302
+ resolved.setdefault(var)
303
+ else:
304
+ # Explicit name
305
+ if pattern in available_vars:
306
+ resolved.setdefault(pattern)
307
+ else:
308
+ logger.warning("Variable '%s' not found in results", pattern)
309
+
310
+ return list(resolved)
dstf/cli/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Command-line interface for the Dynamic Systems Testing Framework.
2
+
3
+ The package's public face is the two entry points: ``main`` (used by the
4
+ docs and by tests that drive the whole CLI) and ``main_entry`` (the
5
+ ``[project.scripts]`` console-script target and what ``python -m dstf``
6
+ calls). Everything else lives in the command modules and is imported from
7
+ its own home — there is no compatibility re-export layer here.
8
+ """
9
+
10
+ from .main import main, main_entry
11
+
12
+ __all__ = ["main", "main_entry"]
dstf/cli/author_cmd.py ADDED
@@ -0,0 +1,61 @@
1
+ """`dstf author`: the authoring tool (§4.7 "Launch")."""
2
+
3
+ import argparse
4
+ import importlib
5
+ from pathlib import Path
6
+
7
+ from ..config import ConfigError
8
+
9
+ #: Every module the ``[author]`` extra installs, checked before serving:
10
+ #: the Variables drawer's `regex` is reached only when a Test page is (per
11
+ #: request), so a venv synced before it joined the extra served the Suite
12
+ #: page and failed every Test page. The extra's distributions are these
13
+ #: names (`test_author_checks_every_package_of_the_extra`).
14
+ _AUTHOR_MODULES = ("nicegui", "regex")
15
+
16
+
17
+ def cmd_author(args: argparse.Namespace) -> int:
18
+ """Open the authoring shell on this project and serve until Quit."""
19
+ # The Session builds the Config itself, from the file it will save
20
+ # beside. Nothing is resolved before these checks: a full Config
21
+ # resolution can WRITE a default testing.json next to a package.mo.
22
+ if not getattr(args, "config", None):
23
+ raise ConfigError("dstf author needs --config <testing.json>")
24
+ dropped = [
25
+ flag
26
+ for attr, flag in (
27
+ ("source_path", "--source-path"),
28
+ ("reference_root", "--reference-root"),
29
+ ("test_spec", "--test-spec"),
30
+ ("dymola_interface", "--dymola-interface"),
31
+ )
32
+ if getattr(args, attr, None)
33
+ ]
34
+ if dropped:
35
+ raise ConfigError(
36
+ f"dstf author reads {', '.join(dropped)} from testing.json; the tool "
37
+ "edits and saves the spec that file names, so an override here "
38
+ "would save to a different file than it shows"
39
+ )
40
+ try:
41
+ for module in _AUTHOR_MODULES:
42
+ importlib.import_module(module)
43
+ from ..authoring.shell import run_app
44
+ except ImportError as e:
45
+ # The tool needs NiceGUI and regex, the optional extra — say how to
46
+ # get it as one clean "Config error: ..." line, never a traceback.
47
+ raise ConfigError(
48
+ "the authoring tool needs the [author] extra: "
49
+ "uv sync --extra dev --extra author (or pip install "
50
+ f"'dynamic-systems-testing-framework[author]') — {e}"
51
+ ) from e
52
+ run_app(
53
+ Path(args.config),
54
+ filter=args.filter,
55
+ package=args.package,
56
+ port=args.port,
57
+ work_dir=Path(args.work_dir) if getattr(args, "work_dir", None) else None,
58
+ backend=args.backend if getattr(args, "backend", None) else None,
59
+ show=not args.no_browser,
60
+ )
61
+ return 0