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/figures/view.py ADDED
@@ -0,0 +1,233 @@
1
+ """The library-neutral picture of one variable.
2
+
3
+ Both tiers build a :class:`VariableView` the same way — from the report's
4
+ trajectory dicts and the evaluated leaves — and hand it to
5
+ :func:`~.plotly.variable_figure`. Decimation happens on the view
6
+ (:func:`decimate_view`), before any trace exists, so an envelope's two edges
7
+ stay on one time axis.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass, field, replace
14
+
15
+ import numpy as np
16
+
17
+ from ..comparison.algorithms import actual_on_reference_grid
18
+ from ..comparison.geometry import Band, Primitive, Series
19
+ from ..comparison.registry import summarize
20
+ from ..comparison.types import _EPS, ScoreSummary, VariableComparison
21
+ from .decimate import decimate_band, lttb
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class OverlayView:
26
+ name: str
27
+ role: str
28
+ kind: str | None
29
+ time: np.ndarray
30
+ values: np.ndarray
31
+ full_samples: int | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LeafView:
36
+ path: str
37
+ metric: str
38
+ passed: bool
39
+ geometry: tuple[Primitive, ...]
40
+ summary: ScoreSummary
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class VariableView:
45
+ #: The variable's result-file name — the key everywhere.
46
+ name: str
47
+ #: What a person reads for it on this test's page
48
+ #: (:func:`~dstf.backends.types.variable_texts`' ``text``): the
49
+ #: expression the model wrote, with the name beside it when another
50
+ #: variable of the test carries the same expression. Defaults to the
51
+ #: name, so a view built without one draws exactly what it always drew.
52
+ label: str = ""
53
+ act_time: np.ndarray | None = None
54
+ act_values: np.ndarray | None = None
55
+ ref_time: np.ndarray | None = None
56
+ ref_values: np.ndarray | None = None
57
+ overlays: tuple[OverlayView, ...] = ()
58
+ leaves: tuple[LeafView, ...] = ()
59
+ errors: tuple[Series, ...] = ()
60
+ #: Pre-decimation sample counts, stamped by :func:`decimate_view`.
61
+ full_samples: dict[str, int] = field(default_factory=dict)
62
+
63
+ def __post_init__(self) -> None:
64
+ # "Defaults to the name" as a real value, not a read-time fallback:
65
+ # every reader (the figure's hover, a family fold, a card title) sees
66
+ # a label, and `replace` carries it.
67
+ if not self.label:
68
+ object.__setattr__(self, "label", self.name)
69
+
70
+ @property
71
+ def has_spectrum(self) -> bool:
72
+ return any(p.panel == "spectrum" for leaf in self.leaves for p in leaf.geometry)
73
+
74
+
75
+ def _array(values) -> np.ndarray | None:
76
+ if values is None:
77
+ return None
78
+ arr = np.asarray(values, dtype=float)
79
+ return arr if arr.size else None
80
+
81
+
82
+ def _overlay_array(values) -> np.ndarray:
83
+ # Lists or arrays; `or []` would ask an array for its truth value.
84
+ return np.asarray([] if values is None else values, dtype=float)
85
+
86
+
87
+ def error_series(
88
+ ref_time: np.ndarray | None,
89
+ ref_values: np.ndarray | None,
90
+ act_time: np.ndarray | None,
91
+ act_values: np.ndarray | None,
92
+ ) -> tuple[Series, ...]:
93
+ """Signed, absolute and normalised error of the actual on the reference
94
+ grid, through the comparator's own event-aware interpolation
95
+ (:func:`~dstf.comparison.algorithms.actual_on_reference_grid`) and its
96
+ normalisation: the reference range, or its magnitude for a constant
97
+ reference. The error drawn is the error scored."""
98
+ if ref_time is None or ref_values is None or act_time is None or act_values is None:
99
+ return ()
100
+ times, ref_on, act_on = actual_on_reference_grid(
101
+ ref_time, ref_values, act_time, act_values
102
+ )
103
+ if len(times) == 0:
104
+ return ()
105
+ signed = act_on - ref_on
106
+ absolute = np.abs(signed)
107
+ span = float(np.max(ref_values) - np.min(ref_values))
108
+ if span < _EPS:
109
+ magnitude = float(np.max(np.abs(ref_values)))
110
+ scale = magnitude if magnitude > _EPS else 1.0
111
+ else:
112
+ scale = span
113
+ return (
114
+ Series(id="error/signed", kind="error", x=times, y=signed),
115
+ Series(id="error/abs", kind="error", x=times, y=absolute),
116
+ Series(id="error/nrmse", kind="error", x=times, y=absolute / scale),
117
+ )
118
+
119
+
120
+ def build_view(
121
+ name: str,
122
+ *,
123
+ label: str = "",
124
+ act_time=None,
125
+ act_values=None,
126
+ ref_time=None,
127
+ ref_values=None,
128
+ overlays: Sequence[dict] = (),
129
+ leaves: Sequence[tuple[str, VariableComparison]] = (),
130
+ ) -> VariableView:
131
+ """Assemble a view from array-likes (lists or arrays), overlay dicts of
132
+ the report's shape (``name`` / ``role`` / ``kind`` / ``time`` /
133
+ ``values``), and ``(leaf_path, VariableComparison)`` pairs. ``label``
134
+ is what a person reads for the variable on its test's page (the
135
+ ``text`` of :func:`~dstf.backends.types.variable_texts`); empty means
136
+ its name."""
137
+ at, av, rt, rv = (
138
+ _array(act_time),
139
+ _array(act_values),
140
+ _array(ref_time),
141
+ _array(ref_values),
142
+ )
143
+ return VariableView(
144
+ name=name,
145
+ label=label,
146
+ act_time=at,
147
+ act_values=av,
148
+ ref_time=rt,
149
+ ref_values=rv,
150
+ overlays=tuple(
151
+ OverlayView(
152
+ name=str(ov.get("name") or ""),
153
+ role=str(ov.get("role") or "companion"),
154
+ kind=ov.get("kind"),
155
+ time=_overlay_array(ov.get("time")),
156
+ values=_overlay_array(ov.get("values")),
157
+ )
158
+ for ov in overlays
159
+ ),
160
+ leaves=tuple(
161
+ LeafView(
162
+ path=path,
163
+ metric=vc.mode,
164
+ passed=bool(vc.passed),
165
+ geometry=tuple(vc.geometry),
166
+ summary=summarize(vc),
167
+ )
168
+ for path, vc in leaves
169
+ ),
170
+ errors=error_series(rt, rv, at, av),
171
+ )
172
+
173
+
174
+ def _pair(time, values, n):
175
+ if time is None or values is None:
176
+ return time, values
177
+ return lttb(time, values, n)
178
+
179
+
180
+ def _decimate_overlay(ov: OverlayView, max_samples: int) -> OverlayView:
181
+ t2, v2 = lttb(ov.time, ov.values, max_samples)
182
+ return OverlayView(
183
+ name=ov.name,
184
+ role=ov.role,
185
+ kind=ov.kind,
186
+ time=t2,
187
+ values=v2,
188
+ full_samples=len(ov.time) if ov.full_samples is None else ov.full_samples,
189
+ )
190
+
191
+
192
+ def decimate_view(view: VariableView, max_samples: int) -> VariableView:
193
+ """A copy under the sample budget: trajectories, overlays, error series
194
+ and bands are LTTB-decimated; ``full_samples`` records what they were."""
195
+ if max_samples is None or max_samples <= 0:
196
+ return view
197
+ at, av = _pair(view.act_time, view.act_values, max_samples)
198
+ rt, rv = _pair(view.ref_time, view.ref_values, max_samples)
199
+
200
+ # Pre-decimation counts, keyed by primitive id, so the figure can mark
201
+ # every decimated trace — bands, spectra and error series included. A
202
+ # count already stamped (a cached base view decimated again) is kept.
203
+ full: dict[str, int] = dict(view.full_samples)
204
+ full.setdefault("act", 0 if view.act_time is None else len(view.act_time))
205
+ full.setdefault("ref", 0 if view.ref_time is None else len(view.ref_time))
206
+
207
+ def _prim(p: Primitive) -> Primitive:
208
+ if isinstance(p, Band):
209
+ full.setdefault(p.id, len(p.time))
210
+ t2, l2, u2 = decimate_band(p.time, p.lower, p.upper, max_samples)
211
+ return replace(p, time=t2, lower=l2, upper=u2)
212
+ if isinstance(p, Series):
213
+ full.setdefault(p.id, len(p.x))
214
+ x2, y2 = lttb(p.x, p.y, max_samples)
215
+ return replace(p, x=x2, y=y2)
216
+ return p
217
+
218
+ leaves = tuple(
219
+ replace(leaf, geometry=tuple(_prim(p) for p in leaf.geometry))
220
+ for leaf in view.leaves
221
+ )
222
+ errors = tuple(_prim(s) for s in view.errors)
223
+ return replace(
224
+ view,
225
+ act_time=at,
226
+ act_values=av,
227
+ ref_time=rt,
228
+ ref_values=rv,
229
+ overlays=tuple(_decimate_overlay(ov, max_samples) for ov in view.overlays),
230
+ leaves=leaves,
231
+ errors=errors, # type: ignore[arg-type]
232
+ full_samples=full,
233
+ )
dstf/pipeline.py ADDED
@@ -0,0 +1,435 @@
1
+ """The run pipeline: the library form of ``dstf run``.
2
+
3
+ Configuration and tests in, results out. Everything ``cmd_run`` does between
4
+ "these are the tests" and "here is the report" lives here — the id mapping, the
5
+ stale-state wipe, the live dashboard's first paint, the simulation, the
6
+ cross-backend chains, the ``--merge`` scope widening and the result read —
7
+ with nothing a CLI owns: it never writes to the console, never exits the
8
+ process, never opens a browser and never prompts. Failures are exceptions; the
9
+ caller decides whether that is an exit code or a red job panel.
10
+
11
+ It lives beside :mod:`dstf.config` and :mod:`dstf.plugins` rather than in a
12
+ layer package because it orchestrates a backend, a store and a work dir and
13
+ belongs to none of them. It imports nothing from the CLI package — the CLI
14
+ imports this, never the reverse.
15
+
16
+ The run's real product is on disk, not in the returned
17
+ :class:`RunOutcome`: ``batch_manifest.json``, ``status.json``,
18
+ ``dashboard.html``, ``reference_manifest.json`` and the per-test sim dirs. The
19
+ authoring shell reads a finished run back through ``backend.read_last_results``
20
+ rather than holding the outcome, so a caller that drops the return value has
21
+ lost nothing the next read cannot recover.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import shutil
28
+ import threading
29
+ import time
30
+ from collections.abc import Callable, Iterable
31
+ from dataclasses import dataclass
32
+ from typing import TYPE_CHECKING
33
+
34
+ if TYPE_CHECKING:
35
+ from .backends.base import Backend
36
+ from .backends.pool import QueueItem, SimulationPool
37
+ from .backends.types import TestResult, TestRunResult
38
+ from .config import Config
39
+ from .discovery.test_registry import TestModel
40
+ from .storage.batch_manifest import BatchManifest
41
+ from .storage.reference_store import ReferenceStore
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RunOutcome:
46
+ """What one pipeline run produced.
47
+
48
+ ``results`` is keyed by exactly the models in ``scope_tests`` — with
49
+ ``merge`` that is wider than the tests just simulated, because the
50
+ out-of-scope entries are read back from the previous runs' files, and
51
+ never wider than the scope, whatever else the work dir's manifest holds
52
+ from earlier runs. Every result carries its run verdict
53
+ (``Backend.read_results``): a merged-in test whose last run failed or
54
+ timed out is ``success=False`` here exactly as ``compare`` reports it.
55
+ """
56
+
57
+ manifests: list[BatchManifest]
58
+ results: dict[str, TestResult]
59
+ scope_tests: list[TestModel]
60
+ notices: list[str]
61
+ #: How many per-test report pages the run removed as stale
62
+ #: (:func:`wipe_stale_state_for_scope`). The pipeline writes none back —
63
+ #: the caller says so unless it is about to.
64
+ report_pages_removed: int = 0
65
+
66
+
67
+ def write_id_mapping(store: ReferenceStore, config: Config) -> None:
68
+ """Write a ref ID → model ID mapping file to the work directory."""
69
+ active = store.index.active_tests()
70
+ if not active:
71
+ return
72
+
73
+ work_dir = config.work_dir
74
+ assert work_dir is not None # resolved in Config.__post_init__
75
+ mapping_path = work_dir / "reference_manifest.json"
76
+ work_dir.mkdir(parents=True, exist_ok=True)
77
+ mapping = {f"ref_{tid}": active[tid] for tid in sorted(active)}
78
+ # LF on every platform, like every other JSON we write.
79
+ mapping_path.write_text(
80
+ json.dumps(mapping, indent=2) + "\n", encoding="utf-8", newline="\n"
81
+ )
82
+
83
+
84
+ def wipe_stale_state_for_scope(
85
+ config: Config,
86
+ tests: list,
87
+ ref_id_map: dict,
88
+ *,
89
+ wipe_sim_dirs: bool,
90
+ ) -> int:
91
+ """Clear per-test report sidecars (and optionally sim work dirs) for
92
+ the tests this invocation is about to (re-)process. Returns how many
93
+ report PAGES (``interactive.html``) went with them: a caller that will
94
+ not write them again tells the user, who otherwise finds a `--report`'s
95
+ pages gone after a plain run.
96
+
97
+ A run cleans its own footprint so a prior run's stale state cannot
98
+ bleed through. Out-of-scope tests' directories are left untouched —
99
+ that's what makes ``--merge`` and ``--rerun`` work.
100
+
101
+ ``verdicts.json`` goes too, whole (:func:`remove_verdicts`).
102
+
103
+ Two layers of state to clear per test:
104
+
105
+ 1. Per-test report dir at ``<work_dir>/reports/<report_dir>/``. Holds
106
+ the ``comparison_data.json`` sidecar (read by the dashboard's
107
+ enricher) and ``interactive.html``. ``report_dir`` is ``ref_NNNN``
108
+ when a baseline exists for the model, else ``test_NNNN``.
109
+
110
+ 2. Per-test sim work dir at ``<work_dir>/<test_key>/``. Holds the
111
+ backend's outputs (.mat, log, dsfinal, etc.). ``wipe_sim_dirs=True``
112
+ only for a run — ``cmd_compare`` reads these dirs to re-evaluate
113
+ prior sims and must not erase them.
114
+
115
+ ``ref_id_map`` is the *backend's* map (a plain dict of model_id →
116
+ ``"ref_NNNN"``), pre-populated by the caller; not the store's index.
117
+
118
+ test_key for an in-scope test is read from the persistent
119
+ ``batch_manifest.json``. Tests not yet in the manifest (first-time
120
+ runs) have nothing to wipe — the backend will create a fresh dir.
121
+ """
122
+ work_dir = config.work_dir
123
+ assert work_dir is not None # resolved in Config.__post_init__
124
+ from .backends.base import last_test_keys
125
+ from .reporting.verdicts import remove_verdicts
126
+
127
+ # One file for every test: whatever this invocation re-processes, what
128
+ # it says of the run is about to be stale. A scoring run or compare
129
+ # writes it again.
130
+ remove_verdicts(work_dir)
131
+ model_to_key = last_test_keys(work_dir)
132
+
133
+ reports_root = work_dir / "reports"
134
+ pages_removed = 0
135
+ for test in tests:
136
+ test_key = model_to_key.get(test.model_id)
137
+ report_dir = ref_id_map.get(test.model_id) or test_key
138
+ if wipe_sim_dirs and test_key:
139
+ sim_dir = work_dir / test_key
140
+ if sim_dir.exists():
141
+ shutil.rmtree(sim_dir)
142
+ if report_dir:
143
+ rep_dir = reports_root / report_dir
144
+ if rep_dir.exists():
145
+ pages_removed += (rep_dir / "interactive.html").exists()
146
+ shutil.rmtree(rep_dir)
147
+ return pages_removed
148
+
149
+
150
+ def run_cross_backend_chains(tests, backend, config, store) -> list[str]:
151
+ """For each test with ``requested_baselines`` listing a known chain target,
152
+ invoke the chain and store the resulting named baseline (4.B.4).
153
+
154
+ Today only ``dymola-via-fmpy`` is recognized. Failures are logged + skipped
155
+ (don't abort the run); chains are best-effort enrichment of baselines.
156
+ Requires Windows + Dymola (with FMI export) for the export step — the chain
157
+ silently no-ops on platforms that don't support it (logs a warning).
158
+
159
+ Returns the lines describing what it did, for the caller to show however
160
+ it shows things; the chain itself says nothing to a console.
161
+ """
162
+ from .backends.cross_backend import (
163
+ CROSS_BACKEND_BASELINE_NAME,
164
+ produce_dymola_via_fmpy_baseline,
165
+ )
166
+
167
+ chain_tests = [
168
+ t for t in tests if CROSS_BACKEND_BASELINE_NAME in (t.requested_baselines or [])
169
+ ]
170
+ if not chain_tests:
171
+ return []
172
+
173
+ lines = [
174
+ f"[experimental] Cross-backend chain '{CROSS_BACKEND_BASELINE_NAME}' is "
175
+ f"scoped to autonomous FMU-exportable tests only (no external inputs, "
176
+ f"no python-driver tests); end-to-end validation on real Dymola pending. "
177
+ f"See D65.",
178
+ f"Running cross-backend chains for {len(chain_tests)} test(s)...",
179
+ ]
180
+ n_ok = 0
181
+ for t in chain_tests:
182
+ ok = produce_dymola_via_fmpy_baseline(t, backend, config, store)
183
+ if ok:
184
+ n_ok += 1
185
+ lines.append(
186
+ f" {t.model_id}: '{CROSS_BACKEND_BASELINE_NAME}' baseline written"
187
+ )
188
+ else:
189
+ lines.append(f" {t.model_id}: chain skipped (see logs)")
190
+ lines.append(f"Cross-backend chains: {n_ok}/{len(chain_tests)} succeeded")
191
+ return lines
192
+
193
+
194
+ def run_pipeline(
195
+ config: Config,
196
+ tests: list,
197
+ *,
198
+ backend: Backend,
199
+ store: ReferenceStore,
200
+ merge: bool = False,
201
+ candidates: list | None = None,
202
+ ) -> RunOutcome:
203
+ """Simulate *tests* and read their results back.
204
+
205
+ *backend* and *store* come from the caller rather than being built here:
206
+ the CLI already needs the backend before the run (``--rerun`` reads the
207
+ last results through it) and the authoring session holds both.
208
+
209
+ *merge* widens the read/compare scope to every model the returned batch
210
+ manifests know about, not just what ran this call — the incremental-rerun
211
+ story. *candidates* is the population that widening draws from, in its own
212
+ order (the CLI passes every discovered test); without it the scope can
213
+ only be *tests*.
214
+
215
+ Raises whatever the backend raises. An empty *tests* is not an error here:
216
+ whether "nothing to run" is a failure is the caller's policy.
217
+ """
218
+ if not tests:
219
+ return RunOutcome(manifests=[], results={}, scope_tests=[], notices=[])
220
+
221
+ work_dir = config.work_dir
222
+ assert work_dir is not None # resolved in Config.__post_init__
223
+
224
+ write_id_mapping(store, config)
225
+
226
+ # Pre-populate model_id → "ref_NNNN" map so the live dashboard can link
227
+ # to the correct per-test report directory (matches generate_report_suite
228
+ # naming), and so the wipe below finds each test's report dir.
229
+ for test in tests:
230
+ ref_id = store.index.get_id(test.model_id)
231
+ if ref_id:
232
+ backend.ref_id_map[test.model_id] = f"ref_{ref_id}"
233
+
234
+ # Clean each in-scope test's prior footprint before running. Without
235
+ # this, a prior run's stale `comparison_data.json` sidecar could bleed
236
+ # through `rows.enrich_row` and override a fresh sim's verdict in the
237
+ # dashboard. Out-of-scope tests are untouched so
238
+ # `merge` keeps working.
239
+ work_dir.mkdir(parents=True, exist_ok=True)
240
+ pages_removed = wipe_stale_state_for_scope(
241
+ config, tests, backend.ref_id_map, wipe_sim_dirs=True
242
+ )
243
+
244
+ # Paint the unified dashboard before the run starts, so a reader sees
245
+ # live progress as tests register. The first ProgressReporter.register
246
+ # call overwrites this initial empty page. Idempotent: the CLI renders
247
+ # it a moment earlier too, before opening a browser tab on it.
248
+ from .reporting.dashboard_render import build_rerun_prefix, render_live
249
+
250
+ render_live(work_dir, rerun_prefix=build_rerun_prefix(config), theme=config.theme)
251
+
252
+ manifests = backend.run_tests(tests)
253
+
254
+ # Enrich batch manifest with ref IDs now that store is available
255
+ for m in manifests:
256
+ m.enrich_ref_ids(store.index)
257
+
258
+ # 4.B.4 — cross-backend chains. Tests whose recognizer set
259
+ # requested_baselines to include a known chain target (today only
260
+ # "dymola-via-fmpy") get a second baseline produced by chaining backends.
261
+ # MetricTree leaves can then score against it via "against": "<chain-name>".
262
+ notices = run_cross_backend_chains(tests, backend, config, store)
263
+
264
+ if merge:
265
+ merged_model_ids = {
266
+ entry["model_id"] for m in manifests for entry in m.manifest.values()
267
+ }
268
+ population = candidates if candidates is not None else tests
269
+ scope_tests = [t for t in population if t.model_id in merged_model_ids]
270
+ else:
271
+ scope_tests = list(tests)
272
+
273
+ # `read_results` walks the whole accumulating batch manifest and emits a
274
+ # "not found in discovery" failure for every model outside `scope_tests`,
275
+ # so on a work dir with history the raw dict carries phantom failures for
276
+ # earlier runs' models. The outcome is the scope, nothing wider.
277
+ scope_ids = {t.model_id for t in scope_tests}
278
+ results = {
279
+ k: v
280
+ for k, v in backend.read_results(manifests, scope_tests).items()
281
+ if k in scope_ids
282
+ }
283
+
284
+ return RunOutcome(
285
+ manifests=list(manifests),
286
+ results=results,
287
+ scope_tests=scope_tests,
288
+ notices=notices,
289
+ report_pages_removed=pages_removed,
290
+ )
291
+
292
+
293
+ class ItemRun:
294
+ """The run pipeline for ONE test at a time — what :func:`run_pipeline`
295
+ does for a list, cut at the test: its key and "pending" stamp, the wipe
296
+ of that test's stale state only, and after the run its recorded verdict
297
+ and its one result read back.
298
+
299
+ It is the simulation pool's ``ItemPath``: the pool's threads call
300
+ :meth:`begin` and :meth:`end` around each run, so both serialize their
301
+ read-modify-write of ``batch_manifest.json`` on one lock.
302
+
303
+ What a list run writes once per run is NOT written per item:
304
+ ``status.json`` and ``dashboard.html`` are one run's snapshot, and a
305
+ queue's live view is the tool's own pages. :meth:`write_status` writes
306
+ them for the items given, when asked (a queue that drained).
307
+ """
308
+
309
+ def __init__(self, config: Config, *, backend: Backend, store: ReferenceStore):
310
+ self.config = config
311
+ self.backend = backend
312
+ self.store = store
313
+ self._manifest_lock = threading.Lock()
314
+
315
+ def begin(self, test: TestModel) -> str:
316
+ """Key *test*, stamp it pending and clear what its last run left;
317
+ returns its key."""
318
+ config, backend = self.config, self.backend
319
+ ref_id = self.store.index.get_id(test.model_id)
320
+ if ref_id:
321
+ backend.ref_id_map[test.model_id] = f"ref_{ref_id}"
322
+ with self._manifest_lock:
323
+ _, keyed = backend.key_tests([test])
324
+ wipe_stale_state_for_scope(
325
+ config, [test], backend.ref_id_map, wipe_sim_dirs=True
326
+ )
327
+ return keyed[0][1]
328
+
329
+ def end(
330
+ self, test: TestModel, test_key: str, run_result: TestRunResult
331
+ ) -> TestResult | None:
332
+ """Record *run_result* in the manifest (a cancelled run as
333
+ cancelled) and return the result read back — ``None`` for a
334
+ cancelled run, the failure for a failed one."""
335
+ from .storage.batch_manifest import BatchManifest
336
+
337
+ work_dir = self.config.work_dir
338
+ assert work_dir is not None # resolved in Config.__post_init__
339
+ with self._manifest_lock:
340
+ # Re-read: another item's thread has saved since this one's
341
+ # begin, and ref ids may have been assigned by an accept.
342
+ manifest = BatchManifest.load(work_dir / "batch_manifest.json")
343
+ manifest.enrich_ref_ids(self.store.index)
344
+ manifest.record_result(run_result)
345
+ return self.backend.result_of_run(test, test_key, run_result)
346
+
347
+ def write_status(self, items: Iterable[QueueItem]) -> None:
348
+ """Write ``status.json`` and the (live) ``dashboard.html`` for the
349
+ *items* that ran — one snapshot, as a list run leaves it at its end,
350
+ each row passed / failed / timed_out / cancelled. Items that never
351
+ ran (removed, or failed before a worker started) have no row. The
352
+ caller renders the final page (``render_final``) as after a run."""
353
+ from .backends.progress import DONE_PHASE, ProgressReporter, TestStatus
354
+ from .reporting.dashboard_render import build_rerun_prefix
355
+
356
+ work_dir = self.config.work_dir
357
+ assert work_dir is not None # resolved in Config.__post_init__
358
+ rows = []
359
+ started: list[float] = []
360
+ ended: list[float] = []
361
+ for item in items:
362
+ rr = item.run_result
363
+ if rr is None or item.test_key is None:
364
+ continue
365
+ if item.started_at is not None and item.ended_at is not None:
366
+ started.append(item.started_at)
367
+ ended.append(item.ended_at)
368
+ if rr.cancelled:
369
+ status = "cancelled"
370
+ elif rr.timed_out:
371
+ status = "timed_out"
372
+ else:
373
+ status = "passed" if rr.success else "failed"
374
+ rows.append(
375
+ TestStatus(
376
+ test_key=item.test_key,
377
+ model_id=item.model_id,
378
+ status=status,
379
+ phase=DONE_PHASE,
380
+ started_wall=item.started_at,
381
+ elapsed=rr.elapsed,
382
+ detail=None if rr.success else item.message,
383
+ worker_id=item.worker_id,
384
+ report_dir=self.backend.ref_id_map.get(item.model_id)
385
+ or item.test_key,
386
+ field_sources=item.test.field_sources,
387
+ )
388
+ )
389
+ work_dir.mkdir(parents=True, exist_ok=True)
390
+ progress = ProgressReporter(
391
+ work_dir,
392
+ len(rows),
393
+ rerun_prefix=build_rerun_prefix(self.config),
394
+ metadata=self.backend._build_run_metadata(**self.backend.run_versions()),
395
+ theme=self.config.theme,
396
+ )
397
+ # The wall time of the items reported: from the first one's start
398
+ # to the last one's end.
399
+ now = time.time()
400
+ progress.load(
401
+ rows,
402
+ started_wall=min(started, default=now),
403
+ ended_wall=max(ended, default=now),
404
+ )
405
+ progress.finalize()
406
+
407
+
408
+ def open_pool(
409
+ config: Config,
410
+ *,
411
+ backend: Backend,
412
+ store: ReferenceStore,
413
+ on_start: Callable[[QueueItem], None] | None = None,
414
+ on_end: Callable[[QueueItem], None] | None = None,
415
+ workers: int | None = None,
416
+ ) -> SimulationPool:
417
+ """A simulation pool over *backend* that runs each item through
418
+ :class:`ItemRun` — the pool's ``path``, so ``pool.path.write_status``
419
+ writes ``status.json`` and the dashboard for the items given.
420
+
421
+ *backend* runs the items, so it is the persistent-preferring one
422
+ (``select_backend(config, persistent=True)``). See
423
+ :class:`~dstf.backends.pool.SimulationPool` for the queue itself, the
424
+ callbacks' contract and ``workers``.
425
+ """
426
+ from .backends.pool import SimulationPool
427
+
428
+ write_id_mapping(store, config)
429
+ return SimulationPool(
430
+ backend,
431
+ ItemRun(config, backend=backend, store=store),
432
+ on_start=on_start,
433
+ on_end=on_end,
434
+ workers=workers,
435
+ )