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
|
@@ -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]
|