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/roles.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Soft_check and companion storage — the two non-primary baseline roles (D66).
|
|
2
|
+
|
|
3
|
+
Both roles hang off a model's primary ref file: soft_checks in
|
|
4
|
+
``<ref_dir>/soft_checks/ref_NNNN/<name>.json`` (scored via ``against:``, but
|
|
5
|
+
the validator forces ``warn`` wrapping so they never hard-fail) and companions
|
|
6
|
+
in ``<ref_dir>/companions/ref_NNNN/<name>.{json,csv}`` (plot-only overlays,
|
|
7
|
+
never scored against; the metadata records which of the companion's own
|
|
8
|
+
variables draw on which of the test's).
|
|
9
|
+
|
|
10
|
+
The CRUD is packaged as :class:`_RolesMixin` so the public call sites stay
|
|
11
|
+
``store.add_soft_check(...)`` / ``store.get_companions(...)``. The mixin's
|
|
12
|
+
whole dependency on the store is the seam declared at the top of the class:
|
|
13
|
+
``ref_dir`` and ``_ref_file_for_model``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .baseline import PRIMARY_BASELINE, Baseline
|
|
26
|
+
from .ref_index import _atomic_write_text
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# review 2026-07-06 (finding 35): soft_check/companion names are interpolated
|
|
32
|
+
# into file paths (``soft_checks/ref_NNNN/<name>.json``) — an unvalidated name
|
|
33
|
+
# containing a separator or ".." writes outside the role dir and becomes
|
|
34
|
+
# invisible to list/remove/``against``. Windows-illegal characters are also
|
|
35
|
+
# rejected because reference partitions are shared across OSes.
|
|
36
|
+
_ROLE_NAME_FORBIDDEN = ("/", "\\", "..", ":", "*", "?", '"', "<", ">", "|", "\0")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_role_name(name: str, role: str) -> None:
|
|
40
|
+
"""Reject soft_check/companion names that differ from their filename-safe
|
|
41
|
+
form, with a clear error naming the offending character."""
|
|
42
|
+
if not name or not isinstance(name, str):
|
|
43
|
+
raise ValueError(f"{role} name must be a non-empty string")
|
|
44
|
+
for token in _ROLE_NAME_FORBIDDEN:
|
|
45
|
+
if token in name:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"{role} name {name!r} contains {token!r} — names must be "
|
|
48
|
+
f"plain filenames (no path separators, '..', or characters "
|
|
49
|
+
f"illegal on Windows)"
|
|
50
|
+
)
|
|
51
|
+
if name == "." or name != name.strip():
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"{role} name {name!r} is not filename-safe (leading/trailing "
|
|
54
|
+
f"whitespace or '.' are not allowed)"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ref_stem(ref_file: Path) -> str:
|
|
59
|
+
"""Extract ``ref_NNNN`` stem from ``<ref_dir>/ref_NNNN.json``."""
|
|
60
|
+
return ref_file.stem
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _soft_check_dir(ref_dir: Path, ref_file: Path) -> Path:
|
|
64
|
+
return ref_dir / "soft_checks" / _ref_stem(ref_file)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _companion_dir(ref_dir: Path, ref_file: Path) -> Path:
|
|
68
|
+
return ref_dir / "companions" / _ref_stem(ref_file)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_COMPANION_FORMAT_EXT = {"csv": ".csv", "json": ".json"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def infer_companion_format(path: str) -> str:
|
|
75
|
+
"""Infer companion format from file extension. Defaults to JSON."""
|
|
76
|
+
lower = path.lower()
|
|
77
|
+
if lower.endswith(".csv"):
|
|
78
|
+
return "csv"
|
|
79
|
+
if lower.endswith(".json"):
|
|
80
|
+
return "json"
|
|
81
|
+
return "json"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: Suffix separating a frozen JSON companion's data file from its metadata.
|
|
85
|
+
#: Both live in the same directory and the metadata is always ``<name>.json``,
|
|
86
|
+
#: so the data file MUST NOT be ``<name>.json`` — the metadata write would
|
|
87
|
+
#: overwrite it.
|
|
88
|
+
_FROZEN_JSON_DATA_SUFFIX = ".data.json"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _frozen_data_basename(name: str, fmt: str) -> str:
|
|
92
|
+
"""Filename for a frozen companion's data file, beside its metadata."""
|
|
93
|
+
if _COMPANION_FORMAT_EXT.get(fmt, ".json") == ".json":
|
|
94
|
+
return f"{name}{_FROZEN_JSON_DATA_SUFFIX}"
|
|
95
|
+
return f"{name}{_COMPANION_FORMAT_EXT[fmt]}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
#: ``accept``'s key order for a variable entry
|
|
99
|
+
#: (:meth:`ReferenceStore.accept`). Any other key an entry carries follows,
|
|
100
|
+
#: in its own order.
|
|
101
|
+
_ENTRY_KEY_ORDER = ("index", "name", "expression", "values")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _with_expression(variable: dict) -> dict:
|
|
105
|
+
"""A COPY of a variable entry as this store writes one: ``expression``
|
|
106
|
+
present, and ``accept``'s key order — index, name, expression, values.
|
|
107
|
+
|
|
108
|
+
``add_soft_check`` is handed entries someone else produced —
|
|
109
|
+
``import-baseline`` reads a file that may predate the key, and a
|
|
110
|
+
cross-backend chain builds its own. A writer never leaves the field out
|
|
111
|
+
(an entry without it is one no writer of this version made), so the
|
|
112
|
+
missing key becomes the empty text.
|
|
113
|
+
|
|
114
|
+
The order is imposed rather than patched in beside whatever ``name``
|
|
115
|
+
it found: an entry that already carried ``expression``, or one whose
|
|
116
|
+
``name`` came after its ``values``, kept its own order and two
|
|
117
|
+
soft_check files written by the same command read differently. The
|
|
118
|
+
input is never mutated — the caller's entry may be a live result's.
|
|
119
|
+
|
|
120
|
+
A null ``expression`` becomes the empty text, like a missing one: JSON
|
|
121
|
+
someone else wrote may hold it, and every reader of this field takes
|
|
122
|
+
``""`` for "no expression" (``display_label``, the soft check, the
|
|
123
|
+
migrate hint) — ``None`` would be a third value none of them tests for.
|
|
124
|
+
"""
|
|
125
|
+
out: dict = {
|
|
126
|
+
key: (variable.get(key) or "") if key == "expression" else variable[key]
|
|
127
|
+
for key in _ENTRY_KEY_ORDER
|
|
128
|
+
if key in variable or key == "expression"
|
|
129
|
+
}
|
|
130
|
+
for key, value in variable.items():
|
|
131
|
+
if key not in out:
|
|
132
|
+
out[key] = value
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Companion:
|
|
138
|
+
"""External or frozen reference data used as a plot-only overlay.
|
|
139
|
+
|
|
140
|
+
Companions are never scored against (D66) — they exist purely as
|
|
141
|
+
visual context (experimental data, analytical solutions, rig logs).
|
|
142
|
+
``kind="external"`` stores only a path pointer; the file may move
|
|
143
|
+
or disappear and the reporter degrades gracefully.
|
|
144
|
+
``kind="frozen"`` has been copied into ref storage (data_file sits
|
|
145
|
+
next to this metadata) and is stable across repo moves.
|
|
146
|
+
|
|
147
|
+
``variables`` is the companion's own record of which of ITS variables
|
|
148
|
+
draw on which of the test's — ``{test variable name: companion
|
|
149
|
+
variable name}``, the ``variables`` key of the metadata JSON. It is
|
|
150
|
+
what the reporter reads; without it the overlay loader has to guess
|
|
151
|
+
from spellings. Empty for a registration that declared none, and for
|
|
152
|
+
one written before the key existed.
|
|
153
|
+
|
|
154
|
+
``mapping_warnings`` is what reading that record found wrong, one
|
|
155
|
+
line per finding. A record with any of these is still a record: the
|
|
156
|
+
overlay says so and draws by what survived, rather than falling back
|
|
157
|
+
on spellings behind the author's back (D132).
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
name: str
|
|
161
|
+
kind: str # "external" | "frozen"
|
|
162
|
+
format: str # "csv" | "json"
|
|
163
|
+
path: str | None = None # absolute path for external
|
|
164
|
+
data_file: str | None = None # basename for frozen (sibling of metadata)
|
|
165
|
+
provenance: dict = field(default_factory=dict)
|
|
166
|
+
variables: dict[str, str] = field(default_factory=dict)
|
|
167
|
+
mapping_warnings: tuple[str, ...] = ()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _read_variable_mapping(entry: dict) -> tuple[dict[str, str], tuple[str, ...]]:
|
|
171
|
+
"""The ``variables`` mapping of a companion's metadata, and what is
|
|
172
|
+
wrong with it: ``(pairs, warnings)``.
|
|
173
|
+
|
|
174
|
+
Old data is tolerated — a registration written before the key existed
|
|
175
|
+
has no mapping, and reads as none, as does the empty object a
|
|
176
|
+
registration with no ``--map`` writes. A hand-edited file is a
|
|
177
|
+
different thing: a ``variables`` that is not an object, or an entry
|
|
178
|
+
whose value is not a variable name, is a MALFORMED record, not an
|
|
179
|
+
absent one. The entry is dropped rather than raised (a companion is
|
|
180
|
+
plot-only and must never break a report) and the finding is said —
|
|
181
|
+
the warnings reach the report's overlay table through
|
|
182
|
+
:class:`~dstf.reporting.overlay_loader.Overlay`, and the companion
|
|
183
|
+
keeps its record's authority rather than falling back on spellings.
|
|
184
|
+
"""
|
|
185
|
+
if "variables" not in entry:
|
|
186
|
+
return {}, ()
|
|
187
|
+
raw = entry["variables"]
|
|
188
|
+
if not isinstance(raw, dict):
|
|
189
|
+
return {}, (
|
|
190
|
+
"the 'variables' record is not an object of <test variable>: "
|
|
191
|
+
"<companion variable> pairs, so nothing is drawn from it",
|
|
192
|
+
)
|
|
193
|
+
mapping: dict[str, str] = {}
|
|
194
|
+
warnings: list[str] = []
|
|
195
|
+
for key, value in raw.items():
|
|
196
|
+
if isinstance(key, str) and isinstance(value, str) and value:
|
|
197
|
+
mapping[key] = value
|
|
198
|
+
else:
|
|
199
|
+
warnings.append(f"mapping for {key} is not a variable name")
|
|
200
|
+
return mapping, tuple(warnings)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class _RolesMixin:
|
|
204
|
+
"""Soft_check + companion CRUD, mixed into :class:`ReferenceStore`.
|
|
205
|
+
|
|
206
|
+
Not usable on its own: the two members below are the seam the store
|
|
207
|
+
supplies, and they are the mixin's only coupling to it.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
#: The partition directory holding ``ref_NNNN.json`` — set by the store.
|
|
211
|
+
ref_dir: Path
|
|
212
|
+
|
|
213
|
+
def _ref_file_for_model(self, model_id: str) -> Path | None:
|
|
214
|
+
"""Resolve a model's primary ref file. Provided by the store."""
|
|
215
|
+
raise NotImplementedError
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Soft_checks (D66) — another regression system's primary imported
|
|
219
|
+
# here, or the output of a cross-backend chain (e.g. dymola-via-fmpy).
|
|
220
|
+
# Scored via ``against: "<name>"`` but validator enforces ``warn``
|
|
221
|
+
# wrapping — soft_checks never hard-fail.
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def _soft_check_dir_for(self, model_id: str) -> Path | None:
|
|
225
|
+
ref_file = self._ref_file_for_model(model_id)
|
|
226
|
+
if ref_file is None:
|
|
227
|
+
return None
|
|
228
|
+
return _soft_check_dir(self.ref_dir, ref_file)
|
|
229
|
+
|
|
230
|
+
def get_soft_checks(self, model_id: str) -> dict[str, Baseline]:
|
|
231
|
+
"""Return all soft_checks registered for a model.
|
|
232
|
+
|
|
233
|
+
Empty dict if the model has no primary baseline or no
|
|
234
|
+
soft_checks are registered.
|
|
235
|
+
"""
|
|
236
|
+
# Late: `reference_store` builds its class on this mixin.
|
|
237
|
+
from .reference_store import read_reference_file
|
|
238
|
+
|
|
239
|
+
sc_dir = self._soft_check_dir_for(model_id)
|
|
240
|
+
if sc_dir is None or not sc_dir.is_dir():
|
|
241
|
+
return {}
|
|
242
|
+
out: dict[str, Baseline] = {}
|
|
243
|
+
for sc_file in sorted(sc_dir.glob("*.json")):
|
|
244
|
+
name = sc_file.stem
|
|
245
|
+
try:
|
|
246
|
+
# The one reader of a reference-shape file: a soft check is
|
|
247
|
+
# one, and a `single` file's numbers are its run's only
|
|
248
|
+
# through it.
|
|
249
|
+
entry = read_reference_file(sc_file)
|
|
250
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
251
|
+
logger.warning("Skipping unreadable soft_check %s: %s", sc_file, e)
|
|
252
|
+
continue
|
|
253
|
+
out[name] = Baseline(
|
|
254
|
+
name=name,
|
|
255
|
+
time=entry.get("time", []),
|
|
256
|
+
variables=entry.get("variables", []),
|
|
257
|
+
diagnostics=entry.get("diagnostics", []),
|
|
258
|
+
simulation=entry.get("simulation", {}),
|
|
259
|
+
comparison=entry.get("comparison", {}),
|
|
260
|
+
statistics=entry.get("statistics", {}),
|
|
261
|
+
provenance=entry.get("provenance", {}),
|
|
262
|
+
)
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
def add_soft_check(
|
|
266
|
+
self,
|
|
267
|
+
model_id: str,
|
|
268
|
+
name: str,
|
|
269
|
+
time: list[float],
|
|
270
|
+
variables: list[dict],
|
|
271
|
+
*,
|
|
272
|
+
diagnostics: list[dict] | None = None,
|
|
273
|
+
provenance: dict | None = None,
|
|
274
|
+
simulation: dict | None = None,
|
|
275
|
+
statistics: dict | None = None,
|
|
276
|
+
overwrite: bool = True,
|
|
277
|
+
) -> bool:
|
|
278
|
+
"""Register a soft_check baseline for a model.
|
|
279
|
+
|
|
280
|
+
The model must already have a primary baseline on disk.
|
|
281
|
+
Name collision with an existing companion is rejected.
|
|
282
|
+
|
|
283
|
+
Returns True on write, False if overwrite=False and the name exists.
|
|
284
|
+
"""
|
|
285
|
+
if name == PRIMARY_BASELINE:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
"'primary' is reserved for the stored simulation result; "
|
|
288
|
+
"soft_checks live alongside, not in place of, primary."
|
|
289
|
+
)
|
|
290
|
+
# review 2026-07-06 (finding 35): names become filenames — validate.
|
|
291
|
+
_validate_role_name(name, "soft_check")
|
|
292
|
+
|
|
293
|
+
ref_file = self._ref_file_for_model(model_id)
|
|
294
|
+
if ref_file is None or not ref_file.exists():
|
|
295
|
+
raise FileNotFoundError(
|
|
296
|
+
f"No primary baseline for {model_id!r}. Run the test with "
|
|
297
|
+
f"--accept first, then add soft_checks."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
companions = self.get_companions(model_id)
|
|
301
|
+
if name in companions:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"name {name!r} is already registered as a companion for "
|
|
304
|
+
f"{model_id!r}; companions and soft_checks share a namespace."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
sc_dir = _soft_check_dir(self.ref_dir, ref_file)
|
|
308
|
+
sc_file = sc_dir / f"{name}.json"
|
|
309
|
+
if not overwrite and sc_file.exists():
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
entry: dict[str, Any] = {
|
|
313
|
+
"time": list(time),
|
|
314
|
+
"variables": [_with_expression(v) for v in variables],
|
|
315
|
+
}
|
|
316
|
+
if diagnostics:
|
|
317
|
+
entry["diagnostics"] = list(diagnostics)
|
|
318
|
+
if simulation:
|
|
319
|
+
entry["simulation"] = dict(simulation)
|
|
320
|
+
if statistics:
|
|
321
|
+
entry["statistics"] = dict(statistics)
|
|
322
|
+
if provenance:
|
|
323
|
+
entry["provenance"] = dict(provenance)
|
|
324
|
+
|
|
325
|
+
sc_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
# review 2026-07-06 (finding 31): atomic — no partial soft_check files.
|
|
327
|
+
_atomic_write_text(sc_file, json.dumps(entry, indent=2) + "\n")
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
def remove_soft_check(self, model_id: str, name: str) -> bool:
|
|
331
|
+
"""Delete a soft_check. Returns True if removed, False if not found."""
|
|
332
|
+
# review 2026-07-06 (finding 35): reject path-escaping names here too.
|
|
333
|
+
_validate_role_name(name, "soft_check")
|
|
334
|
+
sc_dir = self._soft_check_dir_for(model_id)
|
|
335
|
+
if sc_dir is None:
|
|
336
|
+
return False
|
|
337
|
+
sc_file = sc_dir / f"{name}.json"
|
|
338
|
+
if not sc_file.exists():
|
|
339
|
+
return False
|
|
340
|
+
sc_file.unlink()
|
|
341
|
+
# Remove empty parent directory.
|
|
342
|
+
try:
|
|
343
|
+
sc_dir.rmdir()
|
|
344
|
+
except OSError:
|
|
345
|
+
pass
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------------
|
|
349
|
+
# Companions (D66) — external CSV / JSON / analytical data pointed
|
|
350
|
+
# to by file path; plot-only overlays; never scored against. Stored
|
|
351
|
+
# as ``kind="external"`` (path pointer) or ``kind="frozen"``
|
|
352
|
+
# (copied into ref storage).
|
|
353
|
+
# ------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
def _companion_dir_for(self, model_id: str) -> Path | None:
|
|
356
|
+
ref_file = self._ref_file_for_model(model_id)
|
|
357
|
+
if ref_file is None:
|
|
358
|
+
return None
|
|
359
|
+
return _companion_dir(self.ref_dir, ref_file)
|
|
360
|
+
|
|
361
|
+
def get_companions(self, model_id: str) -> dict[str, Companion]:
|
|
362
|
+
"""Return all registered companions for a model (metadata only).
|
|
363
|
+
|
|
364
|
+
Does NOT read or validate the underlying data file — reporter
|
|
365
|
+
consumers are responsible for loading with graceful degradation.
|
|
366
|
+
"""
|
|
367
|
+
co_dir = self._companion_dir_for(model_id)
|
|
368
|
+
if co_dir is None or not co_dir.is_dir():
|
|
369
|
+
return {}
|
|
370
|
+
out: dict[str, Companion] = {}
|
|
371
|
+
for meta_file in sorted(co_dir.glob("*.json")):
|
|
372
|
+
# A frozen JSON companion keeps its data in `<name>.data.json`
|
|
373
|
+
# beside the `<name>.json` metadata (see freeze_companion) —
|
|
374
|
+
# never mistake that data file for a registration.
|
|
375
|
+
if meta_file.name.endswith(_FROZEN_JSON_DATA_SUFFIX):
|
|
376
|
+
continue
|
|
377
|
+
name = meta_file.stem
|
|
378
|
+
try:
|
|
379
|
+
entry = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
380
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
381
|
+
logger.warning(
|
|
382
|
+
"Skipping unreadable companion meta %s: %s", meta_file, e
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
# Companion metadata always declares its `kind`; anything else
|
|
386
|
+
# in this directory is not a registration.
|
|
387
|
+
if entry.get("kind") not in ("external", "frozen"):
|
|
388
|
+
continue
|
|
389
|
+
mapping, mapping_warnings = _read_variable_mapping(entry)
|
|
390
|
+
out[name] = Companion(
|
|
391
|
+
name=name,
|
|
392
|
+
kind=entry["kind"],
|
|
393
|
+
format=entry.get("format", "json"),
|
|
394
|
+
path=entry.get("path"),
|
|
395
|
+
data_file=entry.get("data_file"),
|
|
396
|
+
provenance=entry.get("provenance", {}),
|
|
397
|
+
variables=mapping,
|
|
398
|
+
mapping_warnings=mapping_warnings,
|
|
399
|
+
)
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
def add_companion(
|
|
403
|
+
self,
|
|
404
|
+
model_id: str,
|
|
405
|
+
name: str,
|
|
406
|
+
path: Path,
|
|
407
|
+
*,
|
|
408
|
+
format: str | None = None,
|
|
409
|
+
provenance: dict | None = None,
|
|
410
|
+
variables: dict[str, str] | None = None,
|
|
411
|
+
) -> bool:
|
|
412
|
+
"""Register an external file as a companion (pointer only, no copy).
|
|
413
|
+
|
|
414
|
+
The file must exist at registration (fail fast on a typo'd path);
|
|
415
|
+
if it disappears *later* the reporter degrades gracefully.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
path: absolute or relative path to the external file. Relative
|
|
419
|
+
paths are resolved against the current working directory at
|
|
420
|
+
registration time; the resolved absolute path is stored.
|
|
421
|
+
format: ``"csv"`` or ``"json"``. Auto-detected from the file
|
|
422
|
+
extension if not given.
|
|
423
|
+
variables: which of the companion's variables draw on which of
|
|
424
|
+
the test's — ``{test variable name: companion variable
|
|
425
|
+
name}``. Two test variables MAY name one companion
|
|
426
|
+
variable: that draws the one curve on both plots, which is
|
|
427
|
+
a legitimate thing to ask of a rig channel that stands in
|
|
428
|
+
for several modelled quantities. Whether the names on
|
|
429
|
+
either side exist is not checked here — the caller that
|
|
430
|
+
has both files open does that (``dstf companion add``).
|
|
431
|
+
"""
|
|
432
|
+
if name == PRIMARY_BASELINE:
|
|
433
|
+
raise ValueError("'primary' is reserved for the stored simulation result")
|
|
434
|
+
# review 2026-07-06 (finding 35): names become filenames — validate.
|
|
435
|
+
_validate_role_name(name, "companion")
|
|
436
|
+
# A `<x>.data` companion's metadata file would be `<x>.data.json`,
|
|
437
|
+
# which is where a frozen `<x>` keeps its data (see freeze_companion).
|
|
438
|
+
if name.endswith(".data"):
|
|
439
|
+
raise ValueError(
|
|
440
|
+
f"companion name {name!r} may not end in '.data' — that "
|
|
441
|
+
f"suffix is reserved for frozen JSON data files"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
ref_file = self._ref_file_for_model(model_id)
|
|
445
|
+
if ref_file is None or not ref_file.exists():
|
|
446
|
+
raise FileNotFoundError(
|
|
447
|
+
f"No primary baseline for {model_id!r}. Run the test with "
|
|
448
|
+
f"--accept first, then add companions."
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
soft_checks = self.get_soft_checks(model_id)
|
|
452
|
+
if name in soft_checks:
|
|
453
|
+
raise ValueError(
|
|
454
|
+
f"name {name!r} is already registered as a soft_check for "
|
|
455
|
+
f"{model_id!r}; companions and soft_checks share a namespace."
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# review 2026-07-06 (finding 34): resolve against the CWD (what the
|
|
459
|
+
# user typed) and store the absolute path — the overlay loader used
|
|
460
|
+
# to resolve relative paths against ref_dir, so relative
|
|
461
|
+
# registrations always loaded as "missing". Companions are
|
|
462
|
+
# user-registered files, so a missing file is a registration error:
|
|
463
|
+
# fail fast here instead of surfacing a silent missing overlay later.
|
|
464
|
+
resolved = Path(path).resolve()
|
|
465
|
+
if not resolved.exists():
|
|
466
|
+
raise FileNotFoundError(
|
|
467
|
+
f"companion file does not exist: {path} "
|
|
468
|
+
f"(resolved to {resolved}) — companions must point at an "
|
|
469
|
+
f"existing file at registration time"
|
|
470
|
+
)
|
|
471
|
+
path_str = str(resolved)
|
|
472
|
+
fmt = format or infer_companion_format(path_str)
|
|
473
|
+
|
|
474
|
+
meta: dict[str, Any] = {
|
|
475
|
+
"kind": "external",
|
|
476
|
+
"format": fmt,
|
|
477
|
+
"path": path_str,
|
|
478
|
+
# A writer never leaves the mapping out, so metadata WITHOUT
|
|
479
|
+
# the key is a registration no writer of this version made.
|
|
480
|
+
"variables": dict(variables or {}),
|
|
481
|
+
}
|
|
482
|
+
if provenance:
|
|
483
|
+
meta["provenance"] = dict(provenance)
|
|
484
|
+
|
|
485
|
+
co_dir = _companion_dir(self.ref_dir, ref_file)
|
|
486
|
+
co_dir.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
meta_file = co_dir / f"{name}.json"
|
|
488
|
+
# review 2026-07-06 (finding 31): atomic — no partial metadata files.
|
|
489
|
+
_atomic_write_text(meta_file, json.dumps(meta, indent=2) + "\n")
|
|
490
|
+
return True
|
|
491
|
+
|
|
492
|
+
def freeze_companion(self, model_id: str, name: str) -> bool:
|
|
493
|
+
"""Copy an external companion's data into ref storage.
|
|
494
|
+
|
|
495
|
+
Converts ``kind="external"`` to ``kind="frozen"``. The data file
|
|
496
|
+
is copied beside the metadata as ``<name>.csv`` for CSV and
|
|
497
|
+
``<name>.data.json`` for JSON — the metadata itself occupies
|
|
498
|
+
``<name>.json``, so a JSON data file under that name would be
|
|
499
|
+
destroyed by the metadata write below. Already-frozen companions
|
|
500
|
+
are a no-op.
|
|
501
|
+
|
|
502
|
+
Returns True on freeze, False if the companion is missing or
|
|
503
|
+
already frozen.
|
|
504
|
+
"""
|
|
505
|
+
# review 2026-07-06 (finding 35): reject path-escaping names here too.
|
|
506
|
+
_validate_role_name(name, "companion")
|
|
507
|
+
companions = self.get_companions(model_id)
|
|
508
|
+
co = companions.get(name)
|
|
509
|
+
if co is None:
|
|
510
|
+
return False
|
|
511
|
+
if co.kind == "frozen":
|
|
512
|
+
logger.info("companion %r is already frozen; no-op", name)
|
|
513
|
+
return False
|
|
514
|
+
|
|
515
|
+
src = Path(co.path) if co.path else None
|
|
516
|
+
if src is None or not src.exists():
|
|
517
|
+
raise FileNotFoundError(
|
|
518
|
+
f"Cannot freeze companion {name!r}: source path {co.path!r} "
|
|
519
|
+
f"does not exist"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
co_dir = self._companion_dir_for(model_id)
|
|
523
|
+
assert co_dir is not None
|
|
524
|
+
data_basename = _frozen_data_basename(name, co.format)
|
|
525
|
+
dst = co_dir / data_basename
|
|
526
|
+
# review 2026-07-06 (finding 31): copy via tmp + replace so an
|
|
527
|
+
# interrupted freeze can't leave a truncated data file behind.
|
|
528
|
+
tmp_dst = dst.with_name(f"{dst.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
529
|
+
try:
|
|
530
|
+
shutil.copyfile(src, tmp_dst)
|
|
531
|
+
tmp_dst.replace(dst)
|
|
532
|
+
finally:
|
|
533
|
+
try:
|
|
534
|
+
if tmp_dst.exists():
|
|
535
|
+
tmp_dst.unlink()
|
|
536
|
+
except OSError:
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
meta_file = co_dir / f"{name}.json"
|
|
540
|
+
meta: dict[str, Any] = {
|
|
541
|
+
"kind": "frozen",
|
|
542
|
+
"format": co.format,
|
|
543
|
+
"data_file": data_basename,
|
|
544
|
+
# Freezing copies the data; it does not rename anything, so
|
|
545
|
+
# the mapping rides across unchanged.
|
|
546
|
+
"variables": dict(co.variables),
|
|
547
|
+
}
|
|
548
|
+
if co.provenance:
|
|
549
|
+
meta["provenance"] = dict(co.provenance)
|
|
550
|
+
# review 2026-07-06 (finding 31): atomic — no partial metadata files.
|
|
551
|
+
_atomic_write_text(meta_file, json.dumps(meta, indent=2) + "\n")
|
|
552
|
+
return True
|
|
553
|
+
|
|
554
|
+
def remove_companion(self, model_id: str, name: str) -> bool:
|
|
555
|
+
"""Delete a companion registration. For frozen companions the
|
|
556
|
+
associated data file is also removed. Returns True if removed."""
|
|
557
|
+
# review 2026-07-06 (finding 35): reject path-escaping names here too.
|
|
558
|
+
_validate_role_name(name, "companion")
|
|
559
|
+
co_dir = self._companion_dir_for(model_id)
|
|
560
|
+
if co_dir is None:
|
|
561
|
+
return False
|
|
562
|
+
meta_file = co_dir / f"{name}.json"
|
|
563
|
+
if not meta_file.exists():
|
|
564
|
+
return False
|
|
565
|
+
companions = self.get_companions(model_id)
|
|
566
|
+
co = companions.get(name)
|
|
567
|
+
meta_file.unlink()
|
|
568
|
+
if co and co.kind == "frozen" and co.data_file:
|
|
569
|
+
data_file = co_dir / co.data_file
|
|
570
|
+
if data_file.exists():
|
|
571
|
+
data_file.unlink()
|
|
572
|
+
try:
|
|
573
|
+
co_dir.rmdir()
|
|
574
|
+
except OSError:
|
|
575
|
+
pass
|
|
576
|
+
return True
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""A test's run statistics, kept beside its result: ``<test_dir>/run_stats.json``.
|
|
2
|
+
|
|
3
|
+
The walls a run measured (translation, simulation, elapsed) and the
|
|
4
|
+
statistics its backend parsed live only on the in-process ``TestRunResult``.
|
|
5
|
+
A later ``compare``, a ``--merge`` over tests this run did not re-run, or a
|
|
6
|
+
reload in the tool holds none, and read the result back with no timing —
|
|
7
|
+
and, for Dymola, without the dslog statistics too. The file is written as
|
|
8
|
+
the run records the test's verdict (:meth:`BatchManifest.record_result`) and
|
|
9
|
+
lives in the test's own directory, which a run wipes exactly when it re-runs
|
|
10
|
+
the test: it is as fresh as the result file beside it.
|
|
11
|
+
|
|
12
|
+
Plain dicts only: the run result is rebuilt by the backends
|
|
13
|
+
(``backends.base.kept_run_result``), which keeps this module out of the
|
|
14
|
+
backends' import graph, as ``batch_manifest`` is.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from .ref_index import _atomic_write_text
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ..backends.types import TestRunResult
|
|
28
|
+
|
|
29
|
+
RUN_STATS_FILENAME = "run_stats.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_run_stats(test_dir: Path, result: TestRunResult) -> None:
|
|
33
|
+
"""Write *result*'s walls, elapsed and statistics into *test_dir*.
|
|
34
|
+
|
|
35
|
+
Never raises: it is called while the run records the test's verdict,
|
|
36
|
+
which a failed write must not cost — one ``# WARN:`` line instead. A
|
|
37
|
+
statistics value JSON cannot hold is written as its ``str``.
|
|
38
|
+
"""
|
|
39
|
+
path = test_dir / RUN_STATS_FILENAME
|
|
40
|
+
payload = {
|
|
41
|
+
"model_id": result.model_id,
|
|
42
|
+
"translation_wall": result.translation_wall,
|
|
43
|
+
"sim_wall": result.sim_wall,
|
|
44
|
+
"elapsed": result.elapsed,
|
|
45
|
+
"statistics": result.statistics,
|
|
46
|
+
}
|
|
47
|
+
try:
|
|
48
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
_atomic_write_text(path, json.dumps(payload, default=str))
|
|
50
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
51
|
+
print(f"# WARN: could not write {path}: {exc}", file=sys.stderr)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_run_stats(test_dir: Path, model_id: str) -> dict[str, Any] | None:
|
|
55
|
+
"""The run statistics kept for *model_id* in *test_dir*, or ``None``.
|
|
56
|
+
|
|
57
|
+
``None`` without a word when there is no file (a run from before the file
|
|
58
|
+
existed); ``None`` with one warning line on stderr when the file cannot
|
|
59
|
+
be read or was written for another model.
|
|
60
|
+
"""
|
|
61
|
+
path = test_dir / RUN_STATS_FILENAME
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
raise ValueError("not a JSON object")
|
|
68
|
+
if data.get("model_id") != model_id:
|
|
69
|
+
raise ValueError(f"written for {data.get('model_id')!r}, not {model_id!r}")
|
|
70
|
+
stats = data.get("statistics")
|
|
71
|
+
return {
|
|
72
|
+
"translation_wall": _number(data.get("translation_wall")),
|
|
73
|
+
"sim_wall": _number(data.get("sim_wall")),
|
|
74
|
+
"elapsed": _number(data.get("elapsed")) or 0.0,
|
|
75
|
+
"statistics": stats if isinstance(stats, dict) else None,
|
|
76
|
+
}
|
|
77
|
+
except (OSError, ValueError) as exc:
|
|
78
|
+
print(f"# WARN: ignoring {path}: {exc}", file=sys.stderr)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _number(value: Any) -> float | None:
|
|
83
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
84
|
+
return None
|
|
85
|
+
return float(value)
|