dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
dstf/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)