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
@@ -0,0 +1,661 @@
1
+ """JSON file-based storage for reference results with stable numeric test IDs.
2
+
3
+ Reference files (ref_NNNN.json) are the source of truth. Each file contains
4
+ model_id, test_id, status, date_added, and last_updated metadata. An in-memory
5
+ index is built by scanning ref files — no persistent manifest file needed
6
+ (:mod:`dstf.storage.ref_index`).
7
+
8
+ A :class:`~dstf.storage.baseline.Baseline` view is exposed alongside the raw-dict
9
+ API. The on-disk format is flat — one baseline per file — and the view
10
+ synthesizes a single baseline named ``"primary"`` from those fields. Additional
11
+ named baselines are separate files under ``soft_checks/ref_NNNN/<name>.json``
12
+ (D66); that CRUD lives in :mod:`dstf.storage.roles`.
13
+ """
14
+
15
+ import csv
16
+ import io
17
+ import json
18
+ import logging
19
+ import shutil
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ from ..backends import TestResult
27
+ from ..config import Config
28
+ from ..discovery.test_registry import TestModel
29
+ from .baseline import PRIMARY_BASELINE, Baseline
30
+ from .ref_index import RefIndex, _atomic_write_text
31
+ from .roles import _companion_dir, _RolesMixin, _soft_check_dir
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ #: Significant digits a value is written with, by the precision it has:
37
+ #: the fewest that identify EVERY number of that precision, so a stored
38
+ #: value is the run's own. IEEE 754 binary32 needs at most 9 decimal digits
39
+ #: to round-trip, binary64 at most 17. (A double was written with 15 — one
40
+ #: more often than not another double than the run's, and a baseline scored
41
+ #: 6.7e-10 against its own run on a signal of range 0.4 at 130450.) JSON
42
+ #: then writes each at its SHORTEST spelling that reads back the same:
43
+ #: `0.1` stays `0.1`.
44
+ SINGLE_DIGITS = 9
45
+ DOUBLE_DIGITS = 17
46
+
47
+ #: ``sampling.precision`` of a reference JSON whose time and value arrays
48
+ #: were ALL single precision in the run it was accepted from.
49
+ SINGLE = "single"
50
+ DOUBLE = "double"
51
+
52
+
53
+ def read_reference_file(path: Path) -> dict:
54
+ """A reference-shape JSON file as the dict it holds — THE reader of a
55
+ baseline's arrays (``time``, each variable's ``values``): a primary
56
+ reference of this partition (:meth:`ReferenceStore.get_reference`), of
57
+ a sibling one (the report's overlays), a soft check's file, a JSON
58
+ companion's (another library's reference file, or a frozen copy) and
59
+ the source of an ``import-baseline`` all come through here. ``OSError`` and
60
+ ``json.JSONDecodeError`` are the caller's: each says its own thing
61
+ about a file it could not read.
62
+
63
+ A file that says its run was single precision (``sampling.precision``)
64
+ is read back THROUGH single precision: each stored number becomes the
65
+ nearest float32, as a Python float. Those are the run's own values, bit
66
+ for bit — the 9 digits written identify a float32, not the double a
67
+ JSON reader would otherwise make of them, which is a different number
68
+ than the one the run holds (and scored a baseline against its own run
69
+ at up to 6e-2 where the signal's range was small beside its level).
70
+ The arrays stay lists of floats: nothing downstream sees another type.
71
+ A file without the field is read exactly as it always was."""
72
+ data = json.loads(path.read_text(encoding="utf-8"))
73
+ sampling = data.get("sampling") if isinstance(data, dict) else None
74
+ if isinstance(sampling, dict) and sampling.get("precision") == SINGLE:
75
+ if isinstance(data.get("time"), list):
76
+ data["time"] = _through_single(data["time"])
77
+ for entry in data.get("variables") or []:
78
+ if isinstance(entry, dict) and isinstance(entry.get("values"), list):
79
+ entry["values"] = _through_single(entry["values"])
80
+ return data
81
+
82
+
83
+ def _through_single(values: list) -> list:
84
+ """``values`` as the float32 nearest each, widened back: Python floats
85
+ that ARE single-precision numbers. Anything that is not all numbers is
86
+ returned as it is — what such a list means is its reader's to say."""
87
+ try:
88
+ return np.asarray(values, dtype=np.float32).astype(np.float64).tolist()
89
+ except (TypeError, ValueError):
90
+ return values
91
+
92
+
93
+ def _baseline_from_flat(data: dict) -> Baseline:
94
+ """Synthesize the ``"primary"`` baseline from a flat reference dict.
95
+
96
+ A ref file keeps its baseline fields (``time``, ``variables``,
97
+ ``comparison``, ...) at the top level. This adapter lifts them into a
98
+ Baseline view so readers target one API for primary and soft_checks alike.
99
+ """
100
+ provenance: dict[str, Any] = {"origin": "flat"}
101
+ if "date_added" in data:
102
+ provenance["captured_at"] = data["date_added"]
103
+ if "last_updated" in data and data.get("last_updated") != data.get("date_added"):
104
+ provenance["last_updated"] = data["last_updated"]
105
+
106
+ return Baseline(
107
+ name=PRIMARY_BASELINE,
108
+ time=data.get("time", []),
109
+ variables=data.get("variables", []),
110
+ diagnostics=data.get("diagnostics", []),
111
+ simulation=data.get("simulation", {}),
112
+ comparison=data.get("comparison", {}),
113
+ statistics=data.get("statistics", {}),
114
+ provenance=provenance,
115
+ )
116
+
117
+
118
+ def _extract_baselines(data: dict) -> dict[str, Baseline]:
119
+ """Return the ``"primary"`` baseline of a reference file, keyed by name.
120
+
121
+ A ref file carries exactly one baseline: the primary, stored as flat
122
+ top-level fields (``time``, ``variables``, ``simulation``,
123
+ ``comparison``, ``statistics``, ``diagnostics``). Named baselines live
124
+ in the store's ``soft_checks/ref_NNNN/<name>.json`` subdir (D66) and are
125
+ merged in by :meth:`ReferenceStore.get_baselines`, not read from here.
126
+ """
127
+ return {PRIMARY_BASELINE: _baseline_from_flat(data)}
128
+
129
+
130
+ class ReferenceStore(_RolesMixin):
131
+ """Manages per-test JSON reference files.
132
+
133
+ The soft_check / companion CRUD (``add_soft_check``, ``get_companions``,
134
+ ``freeze_companion``, ...) is inherited from
135
+ :class:`~dstf.storage.roles._RolesMixin`; it reaches back only through
136
+ ``ref_dir`` and :meth:`_ref_file_for_model`.
137
+ """
138
+
139
+ def __init__(self, config: Config):
140
+ self.config = config
141
+ self.ref_dir = config.reference_dir
142
+ self._index = RefIndex(self.ref_dir)
143
+
144
+ @property
145
+ def index(self) -> RefIndex:
146
+ return self._index
147
+
148
+ def _ensure_dir(self):
149
+ self.ref_dir.mkdir(parents=True, exist_ok=True)
150
+
151
+ def _ref_file_for_model(self, model_id: str) -> Path | None:
152
+ """Get the reference file path for a model. Returns None if not indexed."""
153
+ test_id = self._index.get_id(model_id)
154
+ if test_id is None:
155
+ return None
156
+ return self.ref_dir / RefIndex.ref_filename(test_id)
157
+
158
+ def reference_file(self, model_id: str) -> Path | None:
159
+ """The file a model's baseline is read from — what an error about
160
+ its contents names — or None for a model with none."""
161
+ return self._ref_file_for_model(model_id)
162
+
163
+ def get_reference(self, model_id: str) -> dict | None:
164
+ """Load reference data for a model. Returns None if not found.
165
+
166
+ Returns the raw dict as stored on disk. Most callers should prefer
167
+ :meth:`get_baseline` or :meth:`get_baselines`, which present a
168
+ schema-version-independent view.
169
+ """
170
+ ref_file = self._ref_file_for_model(model_id)
171
+ if ref_file is None or not ref_file.exists():
172
+ return None
173
+ try:
174
+ data = read_reference_file(ref_file)
175
+ except (json.JSONDecodeError, OSError) as e:
176
+ logger.error("Failed to read reference %s: %s", ref_file, e)
177
+ return None
178
+ if isinstance(data.get("baselines"), dict):
179
+ # Old data, not old code: named baselines are read only from
180
+ # soft_checks/ now. The key is ignored here and dropped the next
181
+ # time this file is written.
182
+ logger.warning(
183
+ "%s carries a top-level 'baselines' key — it is ignored. "
184
+ "Re-add those baselines as soft_checks: "
185
+ "`dstf import-baseline %s soft_check <name> <source>`.",
186
+ ref_file,
187
+ model_id,
188
+ )
189
+ return data
190
+
191
+ def get_baselines(self, model_id: str) -> dict[str, Baseline]:
192
+ """Return the primary baseline plus any registered soft_checks.
193
+
194
+ Companions are **not** returned here — they are plot-only
195
+ overlays and cannot be scored against (D66). Use
196
+ :meth:`get_companions` for those.
197
+
198
+ Empty dict if the model has no reference file.
199
+ """
200
+ data = self.get_reference(model_id)
201
+ if data is None:
202
+ return {}
203
+ out = _extract_baselines(data)
204
+ out.update(self.get_soft_checks(model_id))
205
+ return out
206
+
207
+ def get_baseline(
208
+ self,
209
+ model_id: str,
210
+ name: str = PRIMARY_BASELINE,
211
+ ) -> Baseline | None:
212
+ """Return one named baseline for a model, or None if not found.
213
+
214
+ Defaults to ``"primary"`` — which is what every existing caller
215
+ implicitly wants (there is only one baseline per file today).
216
+ """
217
+ return self.get_baselines(model_id).get(name)
218
+
219
+ def list_baseline_names(self, model_id: str) -> list[str]:
220
+ """Return the names of baselines available for a model."""
221
+ return list(self.get_baselines(model_id).keys())
222
+
223
+ def store_reference(
224
+ self,
225
+ test: TestModel,
226
+ result: TestResult,
227
+ ) -> bool:
228
+ """Store simulation results as a new reference baseline.
229
+
230
+ Raises :class:`ValueError` when the trajectory contains non-finite
231
+ values or no rows at all — callers that accept in bulk should catch
232
+ it per test.
233
+ """
234
+ if not result.success or not result.variables:
235
+ return False
236
+
237
+ # review 2026-07-06 leftover B: refuse to store a poisoned baseline.
238
+ # A trajectory containing NaN/Inf can never pass a later comparison
239
+ # (the metric math propagates NaN → guaranteed FAIL forever) and
240
+ # would serialize as non-strict JSON (`NaN`/`Infinity` literals)
241
+ # that strict parsers reject. Name the model + variable so accept
242
+ # flows can skip it and tell the user which signal is bad.
243
+ time_arr = np.asarray(result.variables[0].time, dtype=float)
244
+ if time_arr.size == 0:
245
+ # A baseline of no points is one nothing can be scored against:
246
+ # said here, where an accept skips the test with the reason.
247
+ raise ValueError(
248
+ f"refusing to store baseline for {test.model_id}: the result "
249
+ "has no rows to store"
250
+ )
251
+ if time_arr.size and not np.all(np.isfinite(time_arr)):
252
+ raise ValueError(
253
+ f"refusing to store baseline for {test.model_id}: the time "
254
+ "vector contains non-finite values (NaN/Inf)"
255
+ )
256
+ for var in result.variables:
257
+ vals = np.asarray(var.values, dtype=float)
258
+ if vals.size and not np.all(np.isfinite(vals)):
259
+ raise ValueError(
260
+ f"refusing to store baseline for {test.model_id}: variable "
261
+ f"'{var.name or var.index}' contains non-finite values (NaN/Inf)"
262
+ )
263
+
264
+ self._ensure_dir()
265
+
266
+ test_id = self._index.register(test.model_id)
267
+ filename = RefIndex.ref_filename(test_id)
268
+ ref_file = self.ref_dir / filename
269
+
270
+ # Check if this is a new file or an update
271
+ existing = None
272
+ if ref_file.exists():
273
+ try:
274
+ existing = json.loads(ref_file.read_text(encoding="utf-8"))
275
+ except (json.JSONDecodeError, OSError):
276
+ pass
277
+
278
+ # Build the reference JSON
279
+ # All variables share the same time vector (Modelica spec).
280
+ shared_time = result.variables[0].time
281
+ # The points kept (D153): every row the run wrote, unless
282
+ # `baseline_points` gives a number — the test's own, else the
283
+ # project's. "all" is the run's own row count — a budget
284
+ # `_downsample` returns every row within.
285
+ setting = (
286
+ test.baseline_points
287
+ if test.baseline_points is not None
288
+ else self.config.baseline_points
289
+ )
290
+ max_points = setting if isinstance(setting, int) else len(shared_time)
291
+ shared_time_list, _ = _downsample(shared_time, shared_time, max_points)
292
+
293
+ variables = []
294
+ for var in result.variables:
295
+ _, values_list = _downsample(shared_time, var.values, max_points)
296
+ variables.append(
297
+ {
298
+ "index": var.index,
299
+ "name": var.name,
300
+ # Always written, "" included: what the model's ``x={...}``
301
+ # said when this baseline was accepted, soft-checked on
302
+ # every compare. An entry WITHOUT the key is one no
303
+ # writer of this version produced.
304
+ "expression": var.expression,
305
+ "values": values_list,
306
+ }
307
+ )
308
+
309
+ now = datetime.now(timezone.utc).isoformat()
310
+ date_added = now
311
+ if existing and "date_added" in existing:
312
+ date_added = existing["date_added"]
313
+
314
+ # Derive numberOfIntervals from actual result if not explicitly set
315
+ n_intervals = test.number_of_intervals
316
+ out_interval = test.output_interval
317
+ if n_intervals is None and out_interval is None:
318
+ # Count unique time points (exclude event duplicates)
319
+ unique_times = len(np.unique(shared_time))
320
+ n_intervals = max(unique_times - 1, 1)
321
+
322
+ ref_data: dict[str, Any] = {
323
+ "model_id": test.model_id,
324
+ "test_id": test_id,
325
+ "status": "active",
326
+ "date_added": date_added,
327
+ "last_updated": now,
328
+ "simulation": {
329
+ "stop_time": test.stop_time,
330
+ "tolerance": test.tolerance,
331
+ "method": test.method,
332
+ "number_of_intervals": n_intervals,
333
+ "output_interval": out_interval,
334
+ },
335
+ # What was done to the run's rows, always written: a reference
336
+ # WITHOUT the key was stored before it existed, under a cap of
337
+ # 2000 points that no key set.
338
+ "sampling": {
339
+ "setting": setting,
340
+ "source_points": len(shared_time),
341
+ "stored_points": len(shared_time_list),
342
+ # Always written. "single" only when the time vector and
343
+ # EVERY value array were single precision: the reader then
344
+ # takes each stored number back through float32
345
+ # (`read_reference_file`), which would move a double.
346
+ "precision": SINGLE
347
+ if all(
348
+ np.asarray(a).dtype == np.float32
349
+ for a in (shared_time, *(v.values for v in result.variables))
350
+ )
351
+ else DOUBLE,
352
+ },
353
+ }
354
+
355
+ # Comparison settings (per-test and per-variable tolerances)
356
+ # A base tolerance is written only when it is ONE finite number —
357
+ # the test's, or else the old reference's: whatever calls this, a
358
+ # reference JSON never holds a base the comparison refuses.
359
+ # Imported here: at module level it closes a cycle through
360
+ # `dstf.comparison` -> comparator -> this module.
361
+ from ..comparison.sides import is_base_tolerance
362
+
363
+ comparison: dict[str, Any] = {}
364
+ if is_base_tolerance(test.comparison_tolerance):
365
+ comparison["tolerance"] = test.comparison_tolerance
366
+ if test.variable_overrides:
367
+ comparison["variable_overrides"] = test.variable_overrides
368
+ # Preserve existing comparison settings if not overridden
369
+ if existing and "comparison" in existing:
370
+ existing_comp = existing["comparison"]
371
+ if "tolerance" not in comparison and is_base_tolerance(
372
+ existing_comp.get("tolerance")
373
+ ):
374
+ comparison["tolerance"] = existing_comp["tolerance"]
375
+ if (
376
+ "variable_overrides" not in comparison
377
+ and "variable_overrides" in existing_comp
378
+ ):
379
+ comparison["variable_overrides"] = existing_comp["variable_overrides"]
380
+ if comparison:
381
+ ref_data["comparison"] = comparison
382
+
383
+ if result.statistics:
384
+ ref_data["statistics"] = result.statistics
385
+
386
+ # Diagnostic variables (CPUtime, EventCounter) — stored as a scalar
387
+ # summary, not a full trajectory. The trajectory value (especially
388
+ # CPUtime) is nondeterministic — storing it caused every re-accept
389
+ # to produce a spurious git diff and bloated the baseline files.
390
+ # Users who want a diagnostic's full trajectory can add its name to
391
+ # the test's variables/variable_patterns list — it'll be tracked
392
+ # like any other variable at that point.
393
+ if result.diagnostics:
394
+ diag_list = []
395
+ for diag in result.diagnostics:
396
+ values = np.asarray(diag.values)
397
+ entry: dict = {"name": diag.name}
398
+ if values.size > 0:
399
+ entry["final"] = float(values[-1])
400
+ entry["min"] = float(values.min())
401
+ entry["max"] = float(values.max())
402
+ diag_list.append(entry)
403
+ ref_data["diagnostics"] = diag_list
404
+
405
+ # Data fields last — keeps metadata readable at the top of the file
406
+ ref_data["n_vars"] = len(variables)
407
+ ref_data["time"] = shared_time_list
408
+ ref_data["variables"] = variables
409
+
410
+ # review 2026-07-06 (finding 31): atomic — a Ctrl-C mid-accept must
411
+ # not corrupt the baseline (a corrupt file is silently dropped by the
412
+ # next scan and its ID re-allocated to a different model).
413
+ _atomic_write_text(ref_file, json.dumps(ref_data, indent=2) + "\n")
414
+
415
+ return True
416
+
417
+ def accept_results(
418
+ self,
419
+ tests: list[TestModel],
420
+ results: dict[str, TestResult],
421
+ ) -> tuple[list[str], list[tuple[str, str]], list[str]]:
422
+ """Accept simulation results as new baselines.
423
+
424
+ Returns ``(stored, skipped, simulate_only)``: the model ids whose
425
+ baseline was written, in the order given; ``(model_id, reason)`` for
426
+ tests that should have produced a baseline but didn't — review
427
+ 2026-07-06 finding 30: the CLI exits nonzero (and says why) when an
428
+ --accept run stores fewer baselines than it attempted; and the model
429
+ ids of the simulate-only tests passed over, in the order given.
430
+ Simulate-only tests never store a baseline by design, so a
431
+ successful simulate-only run is neither stored nor skipped — and is
432
+ named, so that an accept can say why it stored nothing for it.
433
+ """
434
+ stored: list[str] = []
435
+ skipped: list[tuple[str, str]] = []
436
+ simulate_only: list[str] = []
437
+ for test in tests:
438
+ result = results.get(test.model_id)
439
+ if result is None:
440
+ skipped.append((test.model_id, "no simulation result"))
441
+ continue
442
+ if not result.success:
443
+ skipped.append((test.model_id, "simulation failed"))
444
+ continue
445
+ if test.is_simulate_only:
446
+ simulate_only.append(test.model_id)
447
+ continue
448
+ try:
449
+ ok = self.store_reference(test, result)
450
+ except ValueError as exc:
451
+ # review 2026-07-06 leftover B: one poisoned trajectory must
452
+ # not abort accepting the rest — skip it, warn, keep going.
453
+ logger.warning("Not accepting %s: %s", test.model_id, exc)
454
+ skipped.append((test.model_id, str(exc)))
455
+ continue
456
+ if ok:
457
+ stored.append(test.model_id)
458
+ else:
459
+ skipped.append((test.model_id, "no tracked variables in result"))
460
+ return stored, skipped, simulate_only
461
+
462
+ def set_status(self, model_id: str, status: str) -> bool:
463
+ """Update the status field of a reference file.
464
+
465
+ Valid statuses: 'active', 'skip', 'obsolete'.
466
+ """
467
+ ref_file = self._ref_file_for_model(model_id)
468
+ if ref_file is None or not ref_file.exists():
469
+ return False
470
+ try:
471
+ data = json.loads(ref_file.read_text(encoding="utf-8"))
472
+ data["status"] = status
473
+ # review 2026-07-06 (finding 31): atomic status flip.
474
+ _atomic_write_text(ref_file, json.dumps(data, indent=2) + "\n")
475
+ # Update in-memory index
476
+ test_id = self._index.get_id(model_id)
477
+ if test_id and test_id in self._index._by_id:
478
+ self._index._by_id[test_id]["status"] = status
479
+ if status == "obsolete":
480
+ self._index._by_model.pop(model_id, None)
481
+ return True
482
+ except (json.JSONDecodeError, OSError) as e:
483
+ logger.error("Failed to update status for %s: %s", ref_file, e)
484
+ return False
485
+
486
+ def list_models(self) -> list[str]:
487
+ """List all model IDs with stored references (excluding obsolete)."""
488
+ return sorted(self._index.active_tests().values())
489
+
490
+ def export_json(self, output_path: Path):
491
+ """Export all references as a single JSON file."""
492
+ models = self.list_models()
493
+ all_refs = {}
494
+ for model_id in models:
495
+ ref = self.get_reference(model_id)
496
+ if ref:
497
+ all_refs[model_id] = ref
498
+
499
+ output_path.parent.mkdir(parents=True, exist_ok=True)
500
+ # review 2026-07-06 (finding 31): atomic like every other JSON write.
501
+ _atomic_write_text(output_path, json.dumps(all_refs, indent=2) + "\n")
502
+
503
+ def export_csv(self, output_path: Path):
504
+ """Export a summary CSV of all references."""
505
+ models = self.list_models()
506
+ output_path.parent.mkdir(parents=True, exist_ok=True)
507
+
508
+ buf = io.StringIO(newline="")
509
+ writer = csv.writer(buf)
510
+ writer.writerow(
511
+ [
512
+ "test_id",
513
+ "model_id",
514
+ "status",
515
+ "n_vars",
516
+ "stop_time",
517
+ "tolerance",
518
+ "method",
519
+ "date_added",
520
+ "last_updated",
521
+ ]
522
+ )
523
+ for model_id in models:
524
+ ref = self.get_reference(model_id)
525
+ if ref:
526
+ sim = ref.get("simulation", {})
527
+ writer.writerow(
528
+ [
529
+ ref.get("test_id", ""),
530
+ model_id,
531
+ ref.get("status", "active"),
532
+ ref.get("n_vars", ""),
533
+ sim.get("stop_time", ""),
534
+ sim.get("tolerance", ""),
535
+ sim.get("method", ""),
536
+ ref.get("date_added", ""),
537
+ ref.get("last_updated", ""),
538
+ ]
539
+ )
540
+ # review 2026-07-06 (finding 31): atomic like every other export —
541
+ # an interrupted write must not leave a truncated summary behind.
542
+ _atomic_write_text(output_path, buf.getvalue(), newline="")
543
+
544
+ def list_obsolete(self) -> list[tuple[str, str, Path]]:
545
+ """``(test_id, model_id, ref_file)`` for every obsolete reference that
546
+ still has a file on disk.
547
+
548
+ The listing half of :meth:`cleanup_obsolete`, so ``manifest cleanup``
549
+ can show what it would delete before it deletes anything (dry-run is
550
+ the default for every destructive path).
551
+ """
552
+ out = []
553
+ for test_id, entry in self._index.all_tests().items():
554
+ if entry.get("status") != "obsolete":
555
+ continue
556
+ ref_file = self.ref_dir / RefIndex.ref_filename(test_id)
557
+ if ref_file.exists():
558
+ out.append((test_id, entry.get("model_id", ""), ref_file))
559
+ return sorted(out)
560
+
561
+ def cleanup_obsolete(self) -> int:
562
+ """Remove reference files with status 'obsolete'."""
563
+ all_tests = self._index.all_tests()
564
+ removed = 0
565
+ for test_id, entry in all_tests.items():
566
+ if entry.get("status") == "obsolete":
567
+ ref_file = self.ref_dir / RefIndex.ref_filename(test_id)
568
+ if ref_file.exists():
569
+ ref_file.unlink()
570
+ removed += 1
571
+ logger.info("Removed obsolete reference: %s", ref_file.name)
572
+ # review 2026-07-06 (finding 32): also remove the deleted
573
+ # ref's soft_checks/ and companions/ dirs — a later reuse of
574
+ # this ID must not inherit another model's role data.
575
+ for role_dir in (
576
+ _soft_check_dir(self.ref_dir, ref_file),
577
+ _companion_dir(self.ref_dir, ref_file),
578
+ ):
579
+ if role_dir.is_dir():
580
+ shutil.rmtree(role_dir, ignore_errors=True)
581
+ logger.info("Removed orphaned role dir: %s", role_dir)
582
+ return removed
583
+
584
+
585
+ def _downsample(
586
+ time: np.ndarray, values: np.ndarray, max_points: int
587
+ ) -> tuple[list[float], list[float]]:
588
+ """Downsample a time series to at most max_points; one of at most
589
+ max_points rows is returned whole, so no point is ever invented.
590
+
591
+ Always preserves the first and last samples. Event boundaries (duplicate
592
+ time points) are preserved whenever they fit within the budget; if event
593
+ indices alone exceed max_points they are evenly thinned (review
594
+ 2026-07-06, finding 3). Evenly samples remaining points to fill up to
595
+ max_points.
596
+ """
597
+ n = len(time)
598
+ if n <= max_points:
599
+ return _to_json_list(time), _to_json_list(values)
600
+
601
+ # Find event boundary indices (duplicate time values — keep both)
602
+ event_indices = set()
603
+ event_indices.add(0)
604
+ event_indices.add(n - 1)
605
+ for i in range(1, n):
606
+ if time[i] == time[i - 1]:
607
+ event_indices.add(i - 1) # pre-event
608
+ event_indices.add(i) # post-event
609
+
610
+ if len(event_indices) > max_points:
611
+ # review 2026-07-06 (finding 3): event indices alone exceed the
612
+ # budget. Keep first/last and evenly thin the *interior* event
613
+ # indices to fit — the old tail-trim (`all_indices[:max_points]`)
614
+ # silently dropped the final samples, making tail regressions
615
+ # invisible to every future comparison.
616
+ interior = sorted(event_indices - {0, n - 1})
617
+ budget = max(max_points - 2, 0)
618
+ if budget and interior:
619
+ pick = np.unique(np.linspace(0, len(interior) - 1, budget).astype(int))
620
+ kept = {interior[i] for i in pick}
621
+ else:
622
+ kept = set()
623
+ all_indices = sorted({0, n - 1} | kept)
624
+ else:
625
+ # Fill remaining budget with evenly spaced indices
626
+ remaining = max_points - len(event_indices)
627
+ candidates = np.linspace(0, n - 1, remaining + len(event_indices), dtype=int)
628
+ extras = sorted(set(candidates.tolist()) - event_indices)
629
+ # review 2026-07-06 (finding 3): when the union overshoots
630
+ # max_points, drop interior linspace candidates — never the
631
+ # endpoints or event boundaries — so the stored baseline always
632
+ # reaches the final time sample.
633
+ overshoot = len(event_indices) + len(extras) - max_points
634
+ if overshoot > 0:
635
+ keep_n = len(extras) - overshoot
636
+ if keep_n > 0:
637
+ pick = np.unique(np.linspace(0, len(extras) - 1, keep_n).astype(int))
638
+ extras = [extras[i] for i in pick]
639
+ else:
640
+ extras = []
641
+ all_indices = sorted(event_indices | set(extras))
642
+
643
+ idx = np.array(all_indices)
644
+ return _to_json_list(time[idx]), _to_json_list(values[idx])
645
+
646
+
647
+ def _to_json_list(arr: np.ndarray) -> list[float]:
648
+ """Convert numpy array to JSON-serializable list of Python floats.
649
+
650
+ Rounds to the significant digits that identify a value of the source's
651
+ precision (:data:`SINGLE_DIGITS`, :data:`DOUBLE_DIGITS`):
652
+ - float32 (Dymola's single-precision result files): 9 — the fewest
653
+ that round-trip every float32, so the reader can give the run's own
654
+ number back (`read_reference_file`). At 7, which this wrote before,
655
+ a stored value was a different float32 than the run's more often
656
+ than not.
657
+ - float64: 17, the fewest that round-trip every double — the stored
658
+ value IS the run's.
659
+ """
660
+ sig = SINGLE_DIGITS if arr.dtype == np.float32 else DOUBLE_DIGITS
661
+ return [float(f"%.{sig}g" % v) for v in arr]