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/storage/accept.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Accepting a run's results as baselines — the one function the CLI's two
|
|
2
|
+
``--accept`` paths, its interactive review and the authoring tool's Accept
|
|
3
|
+
call."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..backends.common.result_variables import criterion_variables, tracks
|
|
10
|
+
from ..backends.types import TestResult
|
|
11
|
+
from ..comparison.comparator import nothing_scored, refused_base_tolerance
|
|
12
|
+
from ..discovery.test_registry import TestModel
|
|
13
|
+
from .batch_manifest import unfinished_run_models
|
|
14
|
+
from .ref_index import RefIndex
|
|
15
|
+
from .reference_store import ReferenceStore
|
|
16
|
+
|
|
17
|
+
#: A baseline file larger than this many bytes — 5 MB — is said by the accept
|
|
18
|
+
#: that wrote it (D153). A constant, not a key: the remedy it names is the key.
|
|
19
|
+
LARGE_BASELINE_BYTES = 5_000_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def large_baseline_warning(store: ReferenceStore, model_id: str) -> str | None:
|
|
23
|
+
"""The sentence for ``model_id``'s reference file when it is larger than
|
|
24
|
+
`LARGE_BASELINE_BYTES`, else ``None``. A warning: the baseline IS stored.
|
|
25
|
+
The size is the file's, and its counts are read back from it — only for
|
|
26
|
+
a file that is over, so an accept of small baselines reads nothing."""
|
|
27
|
+
test_id = store.index.get_id(model_id)
|
|
28
|
+
if test_id is None:
|
|
29
|
+
return None
|
|
30
|
+
ref_file = store.ref_dir / RefIndex.ref_filename(test_id)
|
|
31
|
+
try:
|
|
32
|
+
size = ref_file.stat().st_size
|
|
33
|
+
except OSError:
|
|
34
|
+
return None
|
|
35
|
+
if size <= LARGE_BASELINE_BYTES:
|
|
36
|
+
return None
|
|
37
|
+
reference = store.get_reference(model_id) or {}
|
|
38
|
+
points = len(reference.get("time", []))
|
|
39
|
+
n_vars = len(reference.get("variables", []))
|
|
40
|
+
return (
|
|
41
|
+
f"{ref_file.name} ({model_id}) is {size / 1e6:.1f} MB ({points:,} points "
|
|
42
|
+
f"× {n_vars:,} variable{'' if n_vars == 1 else 's'}) — set "
|
|
43
|
+
'"baseline_points" for this test to store fewer.'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
#: How many names a sentence that lists them spells out
|
|
48
|
+
#: (:func:`lost_variables_warnings`, :func:`simulate_only_note`).
|
|
49
|
+
_NAMED = 6
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _named(names: list[str]) -> str:
|
|
53
|
+
"""Up to `_NAMED` of ``names``, then ``and N more``."""
|
|
54
|
+
named = ", ".join(names[:_NAMED])
|
|
55
|
+
if len(names) > _NAMED:
|
|
56
|
+
named += f" and {len(names) - _NAMED} more"
|
|
57
|
+
return named
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def simulate_only_note(model_ids: list[str]) -> str | None:
|
|
61
|
+
"""``Simulate-only, nothing to store: MyLib.Tank`` for the tests an
|
|
62
|
+
accept passed over because they declare nothing to score
|
|
63
|
+
(``TestModel.is_simulate_only``), else ``None``. Neither a warning nor a
|
|
64
|
+
skip: such a test has no baseline to store, and its accept did not fail
|
|
65
|
+
— but `Accepted 0 test baselines` with no word of the test read as an
|
|
66
|
+
accept that did nothing, for no reason."""
|
|
67
|
+
if not model_ids:
|
|
68
|
+
return None
|
|
69
|
+
return f"Simulate-only, nothing to store: {_named(model_ids)}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _variable_names(reference: dict | None) -> list[str]:
|
|
73
|
+
return [v.get("name") or "" for v in (reference or {}).get("variables", [])]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def lost_variables_warnings(
|
|
77
|
+
store: ReferenceStore, test: TestModel, held_before: list[str]
|
|
78
|
+
) -> list[str]:
|
|
79
|
+
"""The sentences for a baseline just written that no longer holds a
|
|
80
|
+
variable the one it replaced held (``held_before``, its names); none
|
|
81
|
+
when it lost nothing. A warning: the baseline IS stored — accepting is
|
|
82
|
+
how a variable is dropped on purpose — but a run that LOST one rewrote
|
|
83
|
+
the baseline without it, and nothing said so.
|
|
84
|
+
|
|
85
|
+
Two sentences, since a variable leaves a baseline for two reasons, and
|
|
86
|
+
each is said of its own names. One the test still tracks — a UnitTests
|
|
87
|
+
variable, a ``variables`` pattern, a criterion's variable — the run did
|
|
88
|
+
not deliver: ``the accepted result does not hold it``. One it no longer
|
|
89
|
+
tracks was taken out on purpose, and the run may well still write it:
|
|
90
|
+
``the test no longer tracks it`` — that half read ``the accepted result
|
|
91
|
+
does not hold it`` too, of a result file that held it."""
|
|
92
|
+
model_id = test.model_id
|
|
93
|
+
test_id = store.index.get_id(model_id)
|
|
94
|
+
if test_id is None:
|
|
95
|
+
return []
|
|
96
|
+
holds = set(_variable_names(store.get_reference(model_id)))
|
|
97
|
+
lost = [name for name in held_before if name and name not in holds]
|
|
98
|
+
tracked = tracks(test)
|
|
99
|
+
by_criterion = set(criterion_variables(test, store.config.diagnostic_variables))
|
|
100
|
+
asked = [name for name in lost if tracked(name) or name in by_criterion]
|
|
101
|
+
dropped = [name for name in lost if name not in asked]
|
|
102
|
+
|
|
103
|
+
def said(names: list[str], why: str) -> str:
|
|
104
|
+
return (
|
|
105
|
+
f"{RefIndex.ref_filename(test_id)} ({model_id}) no longer holds "
|
|
106
|
+
f"{_named(names)} — {why} {'it' if len(names) == 1 else 'them'}."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
*([said(asked, "the accepted result does not hold")] if asked else []),
|
|
111
|
+
*([said(dropped, "the test no longer tracks")] if dropped else []),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def accept_refusal(
|
|
116
|
+
store: ReferenceStore, test: TestModel, result: TestResult | None
|
|
117
|
+
) -> str | None:
|
|
118
|
+
"""Why the comparison REFUSES ``test`` — its base tolerance is no
|
|
119
|
+
number, or its result holds nothing it tracks — or ``None``: the
|
|
120
|
+
comparison's own decisions (`refused_base_tolerance`, `nothing_scored`),
|
|
121
|
+
asked of the test and its stored reference as ``compare_one`` asks
|
|
122
|
+
them. A test with no successful result is not refused: it has its own
|
|
123
|
+
reason."""
|
|
124
|
+
if result is None or not result.success:
|
|
125
|
+
return None
|
|
126
|
+
refused = refused_base_tolerance(
|
|
127
|
+
test, store.get_reference(test.model_id), store.config.tolerance
|
|
128
|
+
)
|
|
129
|
+
if refused is not None:
|
|
130
|
+
return refused.error_message
|
|
131
|
+
return nothing_scored(test, result)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def accept_run_results(
|
|
135
|
+
store: ReferenceStore,
|
|
136
|
+
tests: list[TestModel],
|
|
137
|
+
results: dict[str, TestResult],
|
|
138
|
+
work_dir: Path,
|
|
139
|
+
) -> tuple[int, list[tuple[str, str]], list[str], list[str]]:
|
|
140
|
+
""":meth:`ReferenceStore.accept_results` plus two refusals, two warnings
|
|
141
|
+
and a note.
|
|
142
|
+
|
|
143
|
+
A test the batch manifest in ``work_dir`` still records as pending was
|
|
144
|
+
in flight when a run was aborted: it has no recorded verdict, and its
|
|
145
|
+
truncated result file may still parse. Acceptance requires an
|
|
146
|
+
authoritative successful run, so it is skipped with the reason, never
|
|
147
|
+
stored. Deliberately NOT keyed on the comparison verdict — a test that
|
|
148
|
+
ran fine and now compares FAILED is exactly the drift case accepting
|
|
149
|
+
exists to re-baseline.
|
|
150
|
+
|
|
151
|
+
A test the comparison REFUSES (`accept_refusal`) is skipped with the
|
|
152
|
+
refusal's sentence: its result was scored against nothing, and the
|
|
153
|
+
baseline written would carry — or keep — a base no comparison reads.
|
|
154
|
+
Its reference file is not touched.
|
|
155
|
+
|
|
156
|
+
A baseline file just written that is larger than `LARGE_BASELINE_BYTES`
|
|
157
|
+
is said once (`large_baseline_warning`): it IS stored, and counts as
|
|
158
|
+
stored. Every accept path prints what this returns, so the CLI and the
|
|
159
|
+
tool say the same sentence. So is a baseline that no longer holds a
|
|
160
|
+
variable the one it replaced held (`lost_variables_warnings`).
|
|
161
|
+
|
|
162
|
+
A simulate-only test whose run completed is neither stored nor skipped:
|
|
163
|
+
it has no baseline to store. The tests passed over for that are named
|
|
164
|
+
in one line (`simulate_only_note`), which every accept path prints too.
|
|
165
|
+
|
|
166
|
+
Returns ``(stored, skipped, warnings, notes)``: how many baselines were
|
|
167
|
+
written; ``accept_results``'s skips with the refusals appended — the
|
|
168
|
+
refused tests in the order given, then the unfinished ones in model
|
|
169
|
+
order; the warnings in the order written: per test, what it lost, then
|
|
170
|
+
its size; and the lines that are neither — the simulate-only tests'.
|
|
171
|
+
"""
|
|
172
|
+
unfinished = unfinished_run_models(work_dir) & {t.model_id for t in tests}
|
|
173
|
+
refused = {
|
|
174
|
+
t.model_id: reason
|
|
175
|
+
for t in tests
|
|
176
|
+
if t.model_id not in unfinished
|
|
177
|
+
and (reason := accept_refusal(store, t, results.get(t.model_id))) is not None
|
|
178
|
+
}
|
|
179
|
+
accepted = [
|
|
180
|
+
t for t in tests if t.model_id not in unfinished and t.model_id not in refused
|
|
181
|
+
]
|
|
182
|
+
# What each baseline holds now: read before the accept replaces it.
|
|
183
|
+
held_before = {
|
|
184
|
+
t.model_id: _variable_names(store.get_reference(t.model_id)) for t in accepted
|
|
185
|
+
}
|
|
186
|
+
stored, skipped, simulate_only = store.accept_results(accepted, results)
|
|
187
|
+
by_model = {t.model_id: t for t in accepted}
|
|
188
|
+
note = simulate_only_note(simulate_only)
|
|
189
|
+
return (
|
|
190
|
+
len(stored),
|
|
191
|
+
[
|
|
192
|
+
*skipped,
|
|
193
|
+
*refused.items(),
|
|
194
|
+
*(
|
|
195
|
+
(
|
|
196
|
+
model_id,
|
|
197
|
+
f"last run did not complete for this test (aborted or "
|
|
198
|
+
f"cancelled while it was in flight; {work_dir} records no "
|
|
199
|
+
f"verdict for it) — re-run before accepting",
|
|
200
|
+
)
|
|
201
|
+
for model_id in sorted(unfinished)
|
|
202
|
+
),
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
warning
|
|
206
|
+
for model_id in stored
|
|
207
|
+
for warning in (
|
|
208
|
+
*lost_variables_warnings(
|
|
209
|
+
store, by_model[model_id], held_before[model_id]
|
|
210
|
+
),
|
|
211
|
+
large_baseline_warning(store, model_id),
|
|
212
|
+
)
|
|
213
|
+
if warning is not None
|
|
214
|
+
],
|
|
215
|
+
[note] if note is not None else [],
|
|
216
|
+
)
|
dstf/storage/baseline.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The `Baseline` value type shared by the store and its role modules.
|
|
2
|
+
|
|
3
|
+
A baseline is one named set of reference trajectories: a ref file's
|
|
4
|
+
``"primary"`` (synthesized from the flat on-disk fields by
|
|
5
|
+
:func:`~dstf.storage.reference_store._extract_baselines`) or one soft_check
|
|
6
|
+
read from ``soft_checks/ref_NNNN/<name>.json`` (D66). It lives in its own
|
|
7
|
+
module so both :mod:`dstf.storage.reference_store` and
|
|
8
|
+
:mod:`dstf.storage.roles` can depend on it without depending on each other.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
#: Name of a reference file's one built-in baseline.
|
|
14
|
+
PRIMARY_BASELINE = "primary"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Baseline:
|
|
19
|
+
"""A single named baseline: a ref file's primary, or one soft_check.
|
|
20
|
+
|
|
21
|
+
See docs/architecture.md "Multiple named baselines (D66)".
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
#: Baseline name (e.g. ``"primary"``, ``"experiment"``, ``"analytical"``).
|
|
25
|
+
name: str
|
|
26
|
+
#: Shared time vector (all variables in this baseline share one time grid).
|
|
27
|
+
time: list[float]
|
|
28
|
+
#: Variable entries: ``[{"index", "name", "expression", "values"}, ...]``.
|
|
29
|
+
#: ``name`` is the variable's name in the result file and the only key;
|
|
30
|
+
#: ``expression`` is the label beside it (see ``VariableResult``).
|
|
31
|
+
variables: list[dict]
|
|
32
|
+
#: Diagnostic variable entries (same shape as ``variables``).
|
|
33
|
+
diagnostics: list[dict] = field(default_factory=list)
|
|
34
|
+
#: Simulation parameters that produced this baseline.
|
|
35
|
+
simulation: dict = field(default_factory=dict)
|
|
36
|
+
#: Comparison configuration (tolerance, variable_overrides).
|
|
37
|
+
comparison: dict = field(default_factory=dict)
|
|
38
|
+
#: Statistics captured at baseline-acceptance time.
|
|
39
|
+
statistics: dict = field(default_factory=dict)
|
|
40
|
+
#: Provenance metadata: origin, captured_at, backend, os, notes,
|
|
41
|
+
#: citation, plus arbitrary user metadata. For a ref file's primary this
|
|
42
|
+
#: is synthesized from ``date_added`` / ``last_updated``.
|
|
43
|
+
provenance: dict = field(default_factory=dict)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The per-run batch manifest: test_key ↔ model_id, and the run's results.
|
|
2
|
+
|
|
3
|
+
Lives in :mod:`dstf.storage` with the other on-disk record keepers — it is a
|
|
4
|
+
JSON file in the work dir, written through the same atomic primitive as the
|
|
5
|
+
reference files. The backends package re-exports it (``from dstf.backends
|
|
6
|
+
import BatchManifest``) because every backend's ``run_tests`` returns one.
|
|
7
|
+
|
|
8
|
+
``TestRunResult`` is imported only for typing: keeping it out of the runtime
|
|
9
|
+
import graph is what lets ``dstf.storage.batch_manifest`` be imported first,
|
|
10
|
+
before ``dstf.backends`` has run its package ``__init__``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from .ref_index import _atomic_write_text
|
|
23
|
+
from .run_stats import write_run_stats
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ..backends.types import TestRunResult
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
#: A run that was stopped on request (the simulation queue's cancel). It is
|
|
31
|
+
#: recorded, not left pending: a pending entry falls through to the
|
|
32
|
+
#: status.json cross-check, where an earlier run's "failed" row for the same
|
|
33
|
+
#: model would be applied to a test nobody failed.
|
|
34
|
+
CANCELLED_RUN_STATUS = "cancelled"
|
|
35
|
+
|
|
36
|
+
#: ``last_run_status`` values that mean "this run recorded what became of
|
|
37
|
+
#: this test". Only these make a manifest entry authoritative for a reader.
|
|
38
|
+
RECORDED_RUN_STATUSES = ("passed", "failed", "timed_out", CANCELLED_RUN_STATUS)
|
|
39
|
+
|
|
40
|
+
#: Stamped on every in-scope entry when a run starts, and overwritten the
|
|
41
|
+
#: instant that test's result lands. An entry still saying ``pending`` when a
|
|
42
|
+
#: reader arrives means a run began and never reached a verdict for it — an
|
|
43
|
+
#: abort, a crash, a Ctrl-C — so the reader must NOT treat it as authoritative
|
|
44
|
+
#: and must fall back to the status.json cross-check. Without this stamp a
|
|
45
|
+
#: PREVIOUS run's ``passed`` survived ``assign_test_keys``' carry-forward into
|
|
46
|
+
#: an aborted run's manifest and reported green over a partial ``.mat``.
|
|
47
|
+
PENDING_RUN_STATUS = "pending"
|
|
48
|
+
|
|
49
|
+
#: The two statuses of a last run that left NO result — it never finished the
|
|
50
|
+
#: test, or was told to stop. Whatever file such a run left is partial, and
|
|
51
|
+
#: is neither read nor accepted.
|
|
52
|
+
NO_RESULT_RUN_STATUSES = (PENDING_RUN_STATUS, CANCELLED_RUN_STATUS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class BatchManifest:
|
|
57
|
+
"""Maps test keys to model IDs (and optionally ref IDs) and tracks run results."""
|
|
58
|
+
|
|
59
|
+
work_dir: Path
|
|
60
|
+
manifest: dict[str, dict] # test_NNNN -> {"model_id": ..., "ref_id": ...}
|
|
61
|
+
results: list[TestRunResult] = field(default_factory=list)
|
|
62
|
+
#: Serializes the read-modify-save in :meth:`record_result`, which every
|
|
63
|
+
#: dispatch thread calls as its test finishes.
|
|
64
|
+
_write_lock: threading.Lock = field(
|
|
65
|
+
default_factory=threading.Lock, repr=False, compare=False
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def test_dir(self, test_key: str) -> Path:
|
|
69
|
+
"""Per-test subdirectory for all simulation artifacts."""
|
|
70
|
+
return self.work_dir / test_key
|
|
71
|
+
|
|
72
|
+
def model_id(self, test_key: str) -> str:
|
|
73
|
+
"""Get the model ID for a test key."""
|
|
74
|
+
return self.manifest[test_key]["model_id"]
|
|
75
|
+
|
|
76
|
+
def save(self) -> Path:
|
|
77
|
+
path = self.work_dir / "batch_manifest.json"
|
|
78
|
+
# review 2026-07-06 finding 66: atomic replace — a torn write made
|
|
79
|
+
# every subsequent run/compare crash at startup until the file was
|
|
80
|
+
# hand-deleted.
|
|
81
|
+
_atomic_write_text(path, json.dumps(self.manifest, indent=2))
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
def record_result(self, result: TestRunResult) -> None:
|
|
85
|
+
"""Stamp one test's verdict into its manifest entry and persist NOW.
|
|
86
|
+
|
|
87
|
+
The run's per-test outcome has to outlive the process that produced
|
|
88
|
+
it: a later ``compare`` holds no run results, so without this it can
|
|
89
|
+
only read the ``.mat`` on disk — and a partial/stale one from a failed
|
|
90
|
+
run reads back as a success (D93 follow-on). ``last_run_status`` is
|
|
91
|
+
that record, in status.json's vocabulary
|
|
92
|
+
(``passed`` / ``failed`` / ``timed_out``), plus
|
|
93
|
+
:data:`CANCELLED_RUN_STATUS` for a run that was stopped on request.
|
|
94
|
+
|
|
95
|
+
Written per result rather than in a run epilogue **because the
|
|
96
|
+
epilogue is exactly what an abort skips**: Ctrl-C or an exception in
|
|
97
|
+
the dispatch loop would leave every entry at
|
|
98
|
+
:data:`PENDING_RUN_STATUS`, discarding verdicts the run had already
|
|
99
|
+
reached. Each save is a small atomic replace, on the same order of
|
|
100
|
+
cost as the status.json write the progress reporter already does per
|
|
101
|
+
state change.
|
|
102
|
+
|
|
103
|
+
Entries this run produced no result for are untouched, which is what
|
|
104
|
+
makes ``--merge`` and ``--rerun`` reports honest about the tests they
|
|
105
|
+
did not re-run.
|
|
106
|
+
"""
|
|
107
|
+
if result.cancelled:
|
|
108
|
+
status = CANCELLED_RUN_STATUS
|
|
109
|
+
elif result.timed_out:
|
|
110
|
+
status = "timed_out"
|
|
111
|
+
else:
|
|
112
|
+
status = "passed" if result.success else "failed"
|
|
113
|
+
with self._write_lock:
|
|
114
|
+
for entry in self.manifest.values():
|
|
115
|
+
if entry["model_id"] == result.model_id:
|
|
116
|
+
entry["last_run_status"] = status
|
|
117
|
+
self.save()
|
|
118
|
+
# The run's walls and statistics, beside the result they describe
|
|
119
|
+
# (storage.run_stats). After the verdict is saved and outside its
|
|
120
|
+
# lock: the file is the test's own, and its write never raises.
|
|
121
|
+
write_run_stats(self.test_dir(result.test_key), result)
|
|
122
|
+
|
|
123
|
+
def mark_pending(self, model_ids: set[str]) -> None:
|
|
124
|
+
"""Stamp :data:`PENDING_RUN_STATUS` on the entries a run is about to
|
|
125
|
+
(re-)run, so a stale verdict cannot survive into it.
|
|
126
|
+
|
|
127
|
+
``assign_test_keys`` carries every prior entry forward, so without
|
|
128
|
+
this a previous run's ``passed`` would still be sitting on a test that
|
|
129
|
+
this run fails and then aborts before recording — and the reader would
|
|
130
|
+
trust it. Entries outside *model_ids* keep their own last verdict.
|
|
131
|
+
|
|
132
|
+
Does not save: the caller (``_prepare_run``) saves the manifest once
|
|
133
|
+
the keys are assigned.
|
|
134
|
+
"""
|
|
135
|
+
for entry in self.manifest.values():
|
|
136
|
+
if entry["model_id"] in model_ids:
|
|
137
|
+
entry["last_run_status"] = PENDING_RUN_STATUS
|
|
138
|
+
|
|
139
|
+
def enrich_ref_ids(self, ref_index) -> None:
|
|
140
|
+
"""Add ref_id to each entry using the reference index."""
|
|
141
|
+
for _test_key, entry in self.manifest.items():
|
|
142
|
+
ref_id = ref_index.get_id(entry["model_id"])
|
|
143
|
+
entry["ref_id"] = f"ref_{ref_id}" if ref_id else None
|
|
144
|
+
self.save()
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def load(cls, path: Path) -> BatchManifest:
|
|
148
|
+
try:
|
|
149
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
150
|
+
if not isinstance(data, dict):
|
|
151
|
+
raise ValueError(f"expected a JSON object, got {type(data).__name__}")
|
|
152
|
+
except (ValueError, OSError) as exc:
|
|
153
|
+
# review 2026-07-06 finding 66: a corrupt manifest must not crash
|
|
154
|
+
# startup. Quarantine it (keeping the bytes for forensics) and
|
|
155
|
+
# start from an empty manifest — every caller already treats a
|
|
156
|
+
# missing manifest as empty, so test keys are simply re-assigned
|
|
157
|
+
# on the next run. (JSONDecodeError is a ValueError subclass.)
|
|
158
|
+
corrupt = path.with_name(f"{path.name}.corrupt")
|
|
159
|
+
where = ""
|
|
160
|
+
try:
|
|
161
|
+
path.replace(corrupt)
|
|
162
|
+
where = f"; moved to {corrupt.name}"
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
logger.warning(
|
|
166
|
+
"Corrupt batch manifest %s (%s)%s — starting with an empty manifest",
|
|
167
|
+
path,
|
|
168
|
+
exc,
|
|
169
|
+
where,
|
|
170
|
+
)
|
|
171
|
+
return cls(work_dir=path.parent, manifest={})
|
|
172
|
+
# Handle legacy format: test_key -> model_id string
|
|
173
|
+
normalized = {}
|
|
174
|
+
for key, val in data.items():
|
|
175
|
+
if isinstance(val, str):
|
|
176
|
+
normalized[key] = {"model_id": val, "ref_id": None}
|
|
177
|
+
else:
|
|
178
|
+
normalized[key] = val
|
|
179
|
+
return cls(work_dir=path.parent, manifest=normalized)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def unfinished_run_models(work_dir: Path) -> set[str]:
|
|
183
|
+
"""Model IDs whose last run started them and never reached a verdict.
|
|
184
|
+
|
|
185
|
+
A manifest entry still reading :data:`PENDING_RUN_STATUS` means a run
|
|
186
|
+
stamped it at startup and was aborted (Ctrl-C, crash, killed worker pool)
|
|
187
|
+
before that test finished; one reading :data:`CANCELLED_RUN_STATUS` was
|
|
188
|
+
stopped on request. The accept path uses this: blessing such a
|
|
189
|
+
test's on-disk ``.mat`` would bake in whatever the simulator had flushed
|
|
190
|
+
at the moment it died, and a truncated result file can still parse.
|
|
191
|
+
|
|
192
|
+
Entries with no field at all are NOT included — a manifest written before
|
|
193
|
+
the field existed says nothing about completeness, so those keep the
|
|
194
|
+
pre-existing status.json-guard behaviour (old data, not old code).
|
|
195
|
+
Returns an empty set when the work dir has no manifest.
|
|
196
|
+
"""
|
|
197
|
+
manifest_path = work_dir / "batch_manifest.json"
|
|
198
|
+
if not manifest_path.exists():
|
|
199
|
+
return set()
|
|
200
|
+
return {
|
|
201
|
+
entry["model_id"]
|
|
202
|
+
for entry in BatchManifest.load(manifest_path).manifest.values()
|
|
203
|
+
if entry.get("last_run_status") in NO_RESULT_RUN_STATUSES
|
|
204
|
+
}
|