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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|