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,708 @@
|
|
|
1
|
+
"""Load companion + soft_check overlays for interactive-HTML plotting.
|
|
2
|
+
|
|
3
|
+
D66/D67 split the baseline-role model into three:
|
|
4
|
+
|
|
5
|
+
* **primary** — the stored simulation result; regression anchor. Rendered
|
|
6
|
+
by the existing "Reference" trace.
|
|
7
|
+
* **soft_checks** — warn-wrapped scorable baselines (e.g. cross-backend
|
|
8
|
+
output, another system's primary). The reporter already consumes them
|
|
9
|
+
as :class:`Baseline` via :meth:`ReferenceStore.get_soft_checks`.
|
|
10
|
+
* **companions** — plot-only pointers to external files (experimental
|
|
11
|
+
data, analytical solutions, rig logs). Never scored. Reporter loads
|
|
12
|
+
them here. A companion's metadata records which of ITS variables draw
|
|
13
|
+
on which of the test's; :func:`attach_overlays_to_trajectories` is
|
|
14
|
+
where that record is read, and what it does without one.
|
|
15
|
+
|
|
16
|
+
Graceful degradation is the whole contract: a companion whose backing
|
|
17
|
+
file has been moved or renamed must not break the report. Missing /
|
|
18
|
+
unparseable files surface as an :class:`Overlay` with ``status="missing"``
|
|
19
|
+
or ``status="invalid"`` — the template can skip the trace (no false
|
|
20
|
+
visual overlay) but still show the overlay's presence in the picker as
|
|
21
|
+
a "not found" hint.
|
|
22
|
+
|
|
23
|
+
Two overlay formats supported today:
|
|
24
|
+
|
|
25
|
+
* **JSON** — same shape as a ref file baseline: ``{"time": [...],
|
|
26
|
+
"variables": [{"name": str, "values": [...]}, ...]}``.
|
|
27
|
+
* **CSV** — header row names the columns. First column is the time axis
|
|
28
|
+
(name is ignored). Remaining columns are variable values keyed by
|
|
29
|
+
their column headers.
|
|
30
|
+
|
|
31
|
+
Callers get a flat ``list[Overlay]`` — role-keyed dicts are the
|
|
32
|
+
template's concern, not the loader's.
|
|
33
|
+
|
|
34
|
+
**Cross-backend auto-companions.** In addition to user-registered
|
|
35
|
+
overlays, the loader can auto-discover peer-backend references (same
|
|
36
|
+
model_id, stored under a sibling ``<reference_root>/<backend>/<os>/``
|
|
37
|
+
partition) and surface them as read-only companions with
|
|
38
|
+
``kind="sibling-backend"``. Useful when a new simulator has simulated
|
|
39
|
+
but not yet been baselined — the user can eyeball "does this new sim's
|
|
40
|
+
trajectory match the old simulator's stored reference?" on the same
|
|
41
|
+
plot. No persistence — purely a visual overlay at report time. Gated
|
|
42
|
+
by passing a ``config`` (with ``reference_root`` + ``backend``
|
|
43
|
+
+ ``os_name``) into :func:`load_overlays`; omit the config and only
|
|
44
|
+
user-registered overlays are returned (existing behavior).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import csv
|
|
50
|
+
import json
|
|
51
|
+
import logging
|
|
52
|
+
import threading
|
|
53
|
+
from dataclasses import dataclass, field
|
|
54
|
+
from functools import lru_cache
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
from ..storage.reference_store import read_reference_file
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Overlay:
|
|
64
|
+
"""One visual overlay attached to a test report.
|
|
65
|
+
|
|
66
|
+
Populated for every registered soft_check + companion on the model.
|
|
67
|
+
``variables`` is keyed by the overlay's OWN variable names — the
|
|
68
|
+
reporter only renders overlays for variables the current test plots,
|
|
69
|
+
and ``mapping`` is what says which is which.
|
|
70
|
+
|
|
71
|
+
``mapping`` is ``{test variable name: this overlay's variable name}``,
|
|
72
|
+
read from a companion's metadata. Soft_checks and the auto-discovered
|
|
73
|
+
sibling-backend references carry none: they are DSTF's own data, keyed
|
|
74
|
+
by the same names the test uses. See
|
|
75
|
+
:func:`attach_overlays_to_trajectories` for what an absent mapping
|
|
76
|
+
means.
|
|
77
|
+
|
|
78
|
+
``mapping_malformed`` says the companion recorded a mapping that could
|
|
79
|
+
not be read as pairs (see
|
|
80
|
+
:func:`~dstf.storage.roles._read_variable_mapping`). The record is
|
|
81
|
+
still a record: such an overlay draws only what survived reading it,
|
|
82
|
+
and never falls back on spellings.
|
|
83
|
+
|
|
84
|
+
``warnings`` says what this overlay could not draw and why — a
|
|
85
|
+
malformed or stale mapping, filled in by :func:`_load_companion`, and
|
|
86
|
+
a spelling that could stand for two of its variables, filled in by
|
|
87
|
+
:func:`attach_overlays_to_trajectories`. :func:`overlay_summary`
|
|
88
|
+
surfaces them on the report's overlay table.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
role: str # "soft_check" | "companion"
|
|
93
|
+
kind: str | None = None # companion only: "external" | "frozen"
|
|
94
|
+
status: str = "loaded" # "loaded" | "missing" | "invalid"
|
|
95
|
+
note: str = ""
|
|
96
|
+
variables: dict[str, OverlayVariable] = field(default_factory=dict)
|
|
97
|
+
mapping: dict[str, str] = field(default_factory=dict)
|
|
98
|
+
mapping_malformed: bool = False
|
|
99
|
+
warnings: list[str] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class OverlayVariable:
|
|
104
|
+
time: list[float]
|
|
105
|
+
values: list[float]
|
|
106
|
+
#: What the overlay's own source calls this quantity, when it records
|
|
107
|
+
#: one: a companion that is itself a DSTF baseline of another library
|
|
108
|
+
#: stores an ``expression`` beside each variable's name (D132). It is
|
|
109
|
+
#: the last spelling the read rule tries for an unmapped companion —
|
|
110
|
+
#: never a key.
|
|
111
|
+
expression: str = ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_overlays(store, model_id: str, config=None) -> list[Overlay]:
|
|
115
|
+
"""Return every soft_check + companion available for this model.
|
|
116
|
+
|
|
117
|
+
Pure read path — never mutates the store. Missing / unparseable
|
|
118
|
+
companion files produce an :class:`Overlay` with ``status="missing"``
|
|
119
|
+
or ``"invalid"`` rather than raising. Soft_checks come from the
|
|
120
|
+
store's existing loader (which already tolerates bad files by
|
|
121
|
+
skipping them — same semantics apply here).
|
|
122
|
+
|
|
123
|
+
When ``config`` is provided, also auto-discovers sibling-backend
|
|
124
|
+
references (same ``model_id``, stored under peer
|
|
125
|
+
``<reference_root>/<backend>/<os>/`` partitions) and includes them
|
|
126
|
+
as read-only companions with ``kind="sibling-backend"`` — useful
|
|
127
|
+
for visually validating a new simulator against references from an
|
|
128
|
+
already-trusted one before accepting baselines.
|
|
129
|
+
"""
|
|
130
|
+
overlays: list[Overlay] = []
|
|
131
|
+
if store is None or not model_id:
|
|
132
|
+
return overlays
|
|
133
|
+
|
|
134
|
+
# Soft_checks arrive as Baseline objects — convert to the flat
|
|
135
|
+
# overlay shape keyed by variable name.
|
|
136
|
+
try:
|
|
137
|
+
soft_checks = store.get_soft_checks(model_id)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.warning("Failed to enumerate soft_checks for %s: %s", model_id, e)
|
|
140
|
+
soft_checks = {}
|
|
141
|
+
|
|
142
|
+
for name, baseline in soft_checks.items():
|
|
143
|
+
ov_vars = _baseline_to_overlay_vars(baseline)
|
|
144
|
+
overlays.append(
|
|
145
|
+
Overlay(
|
|
146
|
+
name=name,
|
|
147
|
+
role="soft_check",
|
|
148
|
+
status="loaded" if ov_vars else "invalid",
|
|
149
|
+
note="" if ov_vars else "soft_check has no variable data",
|
|
150
|
+
variables=ov_vars,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Companions: read metadata first (never opens the data file) then
|
|
155
|
+
# try to load the data, catching every failure mode.
|
|
156
|
+
try:
|
|
157
|
+
companions = store.get_companions(model_id)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.warning("Failed to enumerate companions for %s: %s", model_id, e)
|
|
160
|
+
companions = {}
|
|
161
|
+
|
|
162
|
+
for _name, companion in companions.items():
|
|
163
|
+
overlays.append(_load_companion(store, model_id, companion))
|
|
164
|
+
|
|
165
|
+
# Auto-discovered sibling-backend companions (opt-in via config).
|
|
166
|
+
if config is not None:
|
|
167
|
+
overlays.extend(load_sibling_backend_overlays(config, model_id))
|
|
168
|
+
|
|
169
|
+
return overlays
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_INDEX_LOCK = threading.Lock()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def index_args(config) -> tuple[str, str, str] | None:
|
|
176
|
+
"""The three values the sibling index is keyed by, or None when the
|
|
177
|
+
config names no reference root."""
|
|
178
|
+
reference_root = getattr(config, "reference_root", None)
|
|
179
|
+
if reference_root is None:
|
|
180
|
+
return None
|
|
181
|
+
return (
|
|
182
|
+
str(reference_root),
|
|
183
|
+
getattr(config, "backend", None) or "",
|
|
184
|
+
getattr(config, "os_name", None) or "",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def sibling_backend_index(reference_root: str, current_backend: str, current_os: str):
|
|
189
|
+
"""The cached index, built under a lock: ``lru_cache`` does not
|
|
190
|
+
serialise concurrent first calls, and the authoring Session warms the
|
|
191
|
+
index on a thread while a click may ask for it."""
|
|
192
|
+
with _INDEX_LOCK:
|
|
193
|
+
return _sibling_backend_index(reference_root, current_backend, current_os)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def warm_sibling_backend_index(config) -> None:
|
|
197
|
+
"""Build the index for ``config`` now (the tool calls this right after
|
|
198
|
+
open, off the UI thread), so no click pays for it."""
|
|
199
|
+
args = index_args(config)
|
|
200
|
+
if args is not None:
|
|
201
|
+
sibling_backend_index(*args)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def load_sibling_backend_overlays(config, model_id: str) -> list[Overlay]:
|
|
205
|
+
"""Find peer-backend references for ``model_id`` and render them as overlays.
|
|
206
|
+
|
|
207
|
+
Scans ``<reference_root>/<backend>/<os>/`` directories, excluding the
|
|
208
|
+
current partition (``config.backend`` + ``config.os_name``),
|
|
209
|
+
for ``ref_*.json`` files whose ``model_id`` matches. Each match becomes
|
|
210
|
+
an :class:`Overlay` with ``role="companion"`` + ``kind="sibling-backend"``
|
|
211
|
+
and ``name`` like ``"Dymola/windows"``. Every failure mode
|
|
212
|
+
(missing root, unreadable ref, obsolete status) is silently skipped —
|
|
213
|
+
this path must never break a report.
|
|
214
|
+
"""
|
|
215
|
+
args = index_args(config) if config is not None else None
|
|
216
|
+
if args is None:
|
|
217
|
+
return []
|
|
218
|
+
index = sibling_backend_index(*args)
|
|
219
|
+
matches = index.get(model_id, ())
|
|
220
|
+
overlays: list[Overlay] = []
|
|
221
|
+
for ref in matches:
|
|
222
|
+
ov_vars = _ref_data_to_overlay_vars(ref.data)
|
|
223
|
+
overlays.append(
|
|
224
|
+
Overlay(
|
|
225
|
+
name=f"{ref.backend}/{ref.os}",
|
|
226
|
+
role="companion",
|
|
227
|
+
kind="sibling-backend",
|
|
228
|
+
status="loaded" if ov_vars else "invalid",
|
|
229
|
+
note="" if ov_vars else "sibling ref has no variable trajectories",
|
|
230
|
+
variables=ov_vars,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
return overlays
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class _SiblingRef:
|
|
238
|
+
backend: str
|
|
239
|
+
os: str
|
|
240
|
+
path: Path
|
|
241
|
+
data: dict
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@lru_cache(maxsize=8)
|
|
245
|
+
def _sibling_backend_index(
|
|
246
|
+
reference_root: str,
|
|
247
|
+
current_backend: str,
|
|
248
|
+
current_os: str,
|
|
249
|
+
) -> dict[str, tuple[_SiblingRef, ...]]:
|
|
250
|
+
"""Build ``model_id -> (SiblingRef, ...)`` once per (root, current).
|
|
251
|
+
|
|
252
|
+
``lru_cache`` is safe here because the file tree is read-only during
|
|
253
|
+
a report run. Any later accept-and-rerender invalidates the cache by
|
|
254
|
+
using fresh args only on the partition the user *isn't* excluding —
|
|
255
|
+
the sibling partitions it scans don't change mid-run.
|
|
256
|
+
"""
|
|
257
|
+
index: dict[str, list[_SiblingRef]] = {}
|
|
258
|
+
root = Path(reference_root)
|
|
259
|
+
if not root.exists():
|
|
260
|
+
return {}
|
|
261
|
+
for backend_dir in sorted(root.iterdir()):
|
|
262
|
+
if not backend_dir.is_dir():
|
|
263
|
+
continue
|
|
264
|
+
for os_dir in sorted(backend_dir.iterdir()):
|
|
265
|
+
if not os_dir.is_dir():
|
|
266
|
+
continue
|
|
267
|
+
# Skip the current partition — its refs are the primary
|
|
268
|
+
# baselines, not overlays.
|
|
269
|
+
if backend_dir.name == current_backend and os_dir.name == current_os:
|
|
270
|
+
continue
|
|
271
|
+
for ref_file in sorted(os_dir.glob("ref_*.json")):
|
|
272
|
+
try:
|
|
273
|
+
data = read_reference_file(ref_file)
|
|
274
|
+
except (json.JSONDecodeError, OSError):
|
|
275
|
+
continue
|
|
276
|
+
if data.get("status") == "obsolete":
|
|
277
|
+
continue
|
|
278
|
+
mid = data.get("model_id")
|
|
279
|
+
if not mid:
|
|
280
|
+
continue
|
|
281
|
+
index.setdefault(mid, []).append(
|
|
282
|
+
_SiblingRef(
|
|
283
|
+
backend=backend_dir.name,
|
|
284
|
+
os=os_dir.name,
|
|
285
|
+
path=ref_file,
|
|
286
|
+
data=data,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return {k: tuple(v) for k, v in index.items()}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _ref_data_to_overlay_vars(ref_data: dict) -> dict[str, OverlayVariable]:
|
|
293
|
+
"""Flatten a raw ref-file dict (primary baseline shape) into overlay vars.
|
|
294
|
+
|
|
295
|
+
A ref file stores its primary flat: ``{"simulation": {...}, "time": [...],
|
|
296
|
+
"variables": [{"name":..., "values":[...]}, ...]}``. Anything else is a
|
|
297
|
+
safe no-op.
|
|
298
|
+
"""
|
|
299
|
+
time = ref_data.get("time")
|
|
300
|
+
if not isinstance(time, list):
|
|
301
|
+
return {}
|
|
302
|
+
out: dict[str, OverlayVariable] = {}
|
|
303
|
+
for entry in ref_data.get("variables") or []:
|
|
304
|
+
if not isinstance(entry, dict):
|
|
305
|
+
continue
|
|
306
|
+
name = entry.get("name")
|
|
307
|
+
values = entry.get("values")
|
|
308
|
+
# A name stored twice draws its FIRST entry, the one the
|
|
309
|
+
# comparator scores and the context builder draws.
|
|
310
|
+
if not name or not isinstance(values, list) or name in out:
|
|
311
|
+
continue
|
|
312
|
+
out[name] = OverlayVariable(
|
|
313
|
+
time=list(time),
|
|
314
|
+
values=list(values),
|
|
315
|
+
expression=_entry_expression(entry),
|
|
316
|
+
)
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Internals
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _entry_expression(entry: dict) -> str:
|
|
326
|
+
"""The ``expression`` a reference-shape variable entry records, as text.
|
|
327
|
+
|
|
328
|
+
Missing, null and non-text all read as "no expression" — the same
|
|
329
|
+
thing every other reader of the field takes ``""`` for (D132).
|
|
330
|
+
"""
|
|
331
|
+
value = entry.get("expression")
|
|
332
|
+
return value if isinstance(value, str) else ""
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _baseline_to_overlay_vars(baseline) -> dict[str, OverlayVariable]:
|
|
336
|
+
"""Flatten a :class:`Baseline` into per-variable overlay data."""
|
|
337
|
+
time = list(baseline.time or [])
|
|
338
|
+
out: dict[str, OverlayVariable] = {}
|
|
339
|
+
for var in baseline.variables or []:
|
|
340
|
+
name = var.get("name")
|
|
341
|
+
# A name stored twice draws its FIRST entry, the one the
|
|
342
|
+
# comparator scores and the context builder draws.
|
|
343
|
+
if not name or name in out:
|
|
344
|
+
continue
|
|
345
|
+
values = var.get("values")
|
|
346
|
+
if not isinstance(values, list):
|
|
347
|
+
continue
|
|
348
|
+
out[name] = OverlayVariable(
|
|
349
|
+
time=list(time),
|
|
350
|
+
values=list(values),
|
|
351
|
+
expression=_entry_expression(var),
|
|
352
|
+
)
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _load_companion(store, model_id: str, companion) -> Overlay:
|
|
357
|
+
"""Read a companion's data file, returning an Overlay with status.
|
|
358
|
+
|
|
359
|
+
Every failure mode maps to ``status != "loaded"`` with a ``note``
|
|
360
|
+
suitable for surfacing in the UI picker. The loader never raises.
|
|
361
|
+
"""
|
|
362
|
+
path = _resolve_companion_path(store, model_id, companion)
|
|
363
|
+
if path is None:
|
|
364
|
+
return Overlay(
|
|
365
|
+
name=companion.name,
|
|
366
|
+
role="companion",
|
|
367
|
+
kind=companion.kind,
|
|
368
|
+
status="missing",
|
|
369
|
+
note=f"no path resolved (kind={companion.kind!r})",
|
|
370
|
+
)
|
|
371
|
+
if not path.exists():
|
|
372
|
+
return Overlay(
|
|
373
|
+
name=companion.name,
|
|
374
|
+
role="companion",
|
|
375
|
+
kind=companion.kind,
|
|
376
|
+
status="missing",
|
|
377
|
+
note=f"file not found: {path}",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
fmt = (companion.format or "json").lower()
|
|
381
|
+
try:
|
|
382
|
+
vars_ = _load_csv(path) if fmt == "csv" else _load_json(path)
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.warning(
|
|
385
|
+
"Failed to load companion %r (%s): %s",
|
|
386
|
+
companion.name,
|
|
387
|
+
path,
|
|
388
|
+
e,
|
|
389
|
+
)
|
|
390
|
+
return Overlay(
|
|
391
|
+
name=companion.name,
|
|
392
|
+
role="companion",
|
|
393
|
+
kind=companion.kind,
|
|
394
|
+
status="invalid",
|
|
395
|
+
note=f"parse error: {e}",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if not vars_:
|
|
399
|
+
return Overlay(
|
|
400
|
+
name=companion.name,
|
|
401
|
+
role="companion",
|
|
402
|
+
kind=companion.kind,
|
|
403
|
+
status="invalid",
|
|
404
|
+
note="no variables parsed from file",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
mapping = dict(companion.variables)
|
|
408
|
+
# What the author wrote and what the file holds are read together
|
|
409
|
+
# exactly once, here: a mapping naming a variable the file lacks draws
|
|
410
|
+
# nothing (the file has been replaced since, and the mapping is now
|
|
411
|
+
# stale), and saying so is the only way the author finds out.
|
|
412
|
+
warnings = list(companion.mapping_warnings) + [
|
|
413
|
+
f"mapping for {test_var} names {companion_var}, which {path} does not hold"
|
|
414
|
+
for test_var, companion_var in mapping.items()
|
|
415
|
+
if companion_var not in vars_
|
|
416
|
+
]
|
|
417
|
+
return Overlay(
|
|
418
|
+
name=companion.name,
|
|
419
|
+
role="companion",
|
|
420
|
+
kind=companion.kind,
|
|
421
|
+
status="loaded",
|
|
422
|
+
variables=vars_,
|
|
423
|
+
mapping=mapping,
|
|
424
|
+
mapping_malformed=bool(companion.mapping_warnings),
|
|
425
|
+
warnings=warnings,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _resolve_companion_path(store, model_id: str, companion) -> Path | None:
|
|
430
|
+
"""Locate the data file on disk. Handles both external + frozen kinds."""
|
|
431
|
+
if companion.kind == "frozen":
|
|
432
|
+
# Frozen companions store their data beside the metadata in the
|
|
433
|
+
# ref dir — we reach it via the store's companion-dir lookup.
|
|
434
|
+
co_dir = store._companion_dir_for(model_id)
|
|
435
|
+
if co_dir is None or not companion.data_file:
|
|
436
|
+
return None
|
|
437
|
+
return co_dir / companion.data_file
|
|
438
|
+
# External: review 2026-07-06 (finding 34) — add_companion now resolves
|
|
439
|
+
# paths against the CWD at registration time and stores them absolute,
|
|
440
|
+
# so absolute paths are the normal case. A relative path here is a
|
|
441
|
+
# legacy entry (registered before the fix): keep the historical
|
|
442
|
+
# ref_dir-relative resolution for those, and say so.
|
|
443
|
+
if not companion.path:
|
|
444
|
+
return None
|
|
445
|
+
p = Path(companion.path)
|
|
446
|
+
if p.is_absolute():
|
|
447
|
+
return p
|
|
448
|
+
ref_dir = getattr(store, "ref_dir", None)
|
|
449
|
+
if ref_dir is None:
|
|
450
|
+
return p
|
|
451
|
+
logger.info(
|
|
452
|
+
"companion %r has a legacy relative path %r — resolving against "
|
|
453
|
+
"the reference dir %s (new registrations store absolute paths)",
|
|
454
|
+
companion.name,
|
|
455
|
+
companion.path,
|
|
456
|
+
ref_dir,
|
|
457
|
+
)
|
|
458
|
+
return (Path(ref_dir) / p).resolve()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _load_json(path: Path) -> dict[str, OverlayVariable]:
|
|
462
|
+
"""Parse a JSON companion file. Expects ``time: [...]`` + ``variables:
|
|
463
|
+
[...]`` — the shape of a reference file, which is what a companion that
|
|
464
|
+
points at another library's baseline IS: read by the one reader of
|
|
465
|
+
that shape, so a ``single`` file draws its run's own numbers."""
|
|
466
|
+
data = read_reference_file(path)
|
|
467
|
+
time = list(data.get("time") or [])
|
|
468
|
+
out: dict[str, OverlayVariable] = {}
|
|
469
|
+
for entry in data.get("variables", []) or []:
|
|
470
|
+
if not isinstance(entry, dict):
|
|
471
|
+
continue
|
|
472
|
+
name = entry.get("name")
|
|
473
|
+
values = entry.get("values")
|
|
474
|
+
# A name stored twice draws its FIRST entry, the one the
|
|
475
|
+
# comparator scores and the context builder draws.
|
|
476
|
+
if not name or not isinstance(values, list) or name in out:
|
|
477
|
+
continue
|
|
478
|
+
out[name] = OverlayVariable(
|
|
479
|
+
time=list(time),
|
|
480
|
+
values=list(values),
|
|
481
|
+
expression=_entry_expression(entry),
|
|
482
|
+
)
|
|
483
|
+
return out
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def companion_variable_names(path: Path, fmt: str) -> list[str]:
|
|
487
|
+
"""The names a companion file gives its variables, in file order.
|
|
488
|
+
|
|
489
|
+
What ``dstf companion add`` checks a ``--map``'s right-hand sides
|
|
490
|
+
against: a JSON companion's ``variables[].name``, a CSV companion's
|
|
491
|
+
column headers after the time column. Raises whatever the parse
|
|
492
|
+
raises — the caller is registering, not rendering, so a file it
|
|
493
|
+
cannot read is a registration error rather than a silent overlay.
|
|
494
|
+
"""
|
|
495
|
+
vars_ = _load_csv(path) if (fmt or "json").lower() == "csv" else _load_json(path)
|
|
496
|
+
return list(vars_)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _load_csv(path: Path) -> dict[str, OverlayVariable]:
|
|
500
|
+
"""Parse a wide CSV: first column is time, rest are value columns.
|
|
501
|
+
|
|
502
|
+
Header row names the variables. Rows with non-numeric values in any
|
|
503
|
+
cell are dropped silently — the reporter only plots whatever it can
|
|
504
|
+
parse rather than failing the whole companion on one bad row.
|
|
505
|
+
"""
|
|
506
|
+
with path.open(encoding="utf-8", newline="") as f:
|
|
507
|
+
reader = csv.reader(f)
|
|
508
|
+
try:
|
|
509
|
+
header = next(reader)
|
|
510
|
+
except StopIteration:
|
|
511
|
+
return {}
|
|
512
|
+
if len(header) < 2:
|
|
513
|
+
return {}
|
|
514
|
+
var_names = [h.strip() for h in header[1:]]
|
|
515
|
+
times: list[float] = []
|
|
516
|
+
cols: list[list[float]] = [[] for _ in var_names]
|
|
517
|
+
for row in reader:
|
|
518
|
+
if not row or len(row) < len(header):
|
|
519
|
+
continue
|
|
520
|
+
try:
|
|
521
|
+
t = float(row[0])
|
|
522
|
+
vals = [float(row[i + 1]) for i in range(len(var_names))]
|
|
523
|
+
except (TypeError, ValueError):
|
|
524
|
+
continue
|
|
525
|
+
times.append(t)
|
|
526
|
+
for i, v in enumerate(vals):
|
|
527
|
+
cols[i].append(v)
|
|
528
|
+
return {
|
|
529
|
+
var_names[i]: OverlayVariable(time=list(times), values=list(cols[i]))
|
|
530
|
+
for i in range(len(var_names))
|
|
531
|
+
if var_names[i]
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def attach_overlays_to_trajectories(
|
|
536
|
+
trajectories: list[dict],
|
|
537
|
+
overlays: list[Overlay],
|
|
538
|
+
) -> None:
|
|
539
|
+
"""Stamp each trajectory with its ``overlays`` list, in place.
|
|
540
|
+
|
|
541
|
+
Only overlays that carry the variable's data appear on that plot.
|
|
542
|
+
Missing/invalid overlays surface at the test level (future: summary
|
|
543
|
+
picker); they're dropped from per-trajectory lists since there's
|
|
544
|
+
nothing to render.
|
|
545
|
+
|
|
546
|
+
**The mapping is the record.** An overlay is another source's data,
|
|
547
|
+
and a companion names the quantity the way ITS result file does. Which
|
|
548
|
+
of its variables draws on which of the test's is a fact only whoever
|
|
549
|
+
registered it knows, so the companion records it in its own metadata
|
|
550
|
+
(``dstf companion add … --map TEST_VAR=COMPANION_VAR``). Where an
|
|
551
|
+
overlay HAS a mapping, that is the whole rule: a trajectory the
|
|
552
|
+
mapping does not name gets no trace from it, even when the overlay
|
|
553
|
+
holds a variable spelled exactly like it. Guessing behind a recorded
|
|
554
|
+
answer is how somebody else's curve ends up under the wrong plot.
|
|
555
|
+
|
|
556
|
+
**The fallbacks are for a companion nobody mapped** — the ones
|
|
557
|
+
registered before there was a mapping to write — and for NOTHING
|
|
558
|
+
else. A soft_check, the primary of a sibling partition and a
|
|
559
|
+
cross-backend import are DSTF's own data, keyed by the names this
|
|
560
|
+
test uses: they match BY NAME ONLY. A soft_check is also SCORED by
|
|
561
|
+
name (``against:``), so the curve drawn for it has to be the curve
|
|
562
|
+
scored for it; a spelling rule that found some other variable of it
|
|
563
|
+
would draw one thing and score another. In order, the companion
|
|
564
|
+
variable whose
|
|
565
|
+
|
|
566
|
+
1. NAME is the trajectory's name. The two sources agree on a spelling
|
|
567
|
+
the test already uses; nothing else needs to be true.
|
|
568
|
+
2. NAME is the trajectory's EXPRESSION. A variable of the Modelica
|
|
569
|
+
``UnitTests`` component is named positionally (``unitTests.x[2]``),
|
|
570
|
+
which no other tool can be expected to use; the expression the
|
|
571
|
+
model wrote is the only spelling the two have in common (D132).
|
|
572
|
+
3. EXPRESSION is the trajectory's name, or its expression. The mirror
|
|
573
|
+
of 2, for a companion that is itself a DSTF baseline of another
|
|
574
|
+
library: there the POSITIONAL name is on the companion's side and
|
|
575
|
+
the quantity's text is what it records beside it.
|
|
576
|
+
|
|
577
|
+
A fallback step that finds more than one candidate attaches nothing
|
|
578
|
+
and records a warning on the overlay (``x={a.Q, a.Q}`` is legal, so
|
|
579
|
+
one expression may belong to two of a companion's variables). Nothing
|
|
580
|
+
is drawn rather than the first of two, and the warning names the
|
|
581
|
+
trajectory, the candidates, and the mapping that would settle it.
|
|
582
|
+
"""
|
|
583
|
+
for traj in trajectories:
|
|
584
|
+
name = traj.get("name", "")
|
|
585
|
+
expression = traj.get("expression", "")
|
|
586
|
+
attached = []
|
|
587
|
+
for ov in overlays:
|
|
588
|
+
if ov.status != "loaded":
|
|
589
|
+
continue
|
|
590
|
+
var, ambiguous = _overlay_variable_for(ov, name, expression)
|
|
591
|
+
if ambiguous:
|
|
592
|
+
_record_ambiguity(ov, name, ambiguous)
|
|
593
|
+
if var is None:
|
|
594
|
+
continue
|
|
595
|
+
attached.append(
|
|
596
|
+
{
|
|
597
|
+
"name": ov.name,
|
|
598
|
+
"role": ov.role,
|
|
599
|
+
"kind": ov.kind,
|
|
600
|
+
"time": list(var.time),
|
|
601
|
+
"values": list(var.values),
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
traj["overlays"] = attached
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _overlay_variable_for(
|
|
608
|
+
ov: Overlay,
|
|
609
|
+
name: str,
|
|
610
|
+
expression: str,
|
|
611
|
+
) -> tuple[OverlayVariable | None, list[str]]:
|
|
612
|
+
"""This overlay's variable for one trajectory, else why not.
|
|
613
|
+
|
|
614
|
+
Returns ``(variable, [])`` for a hit, ``(None, [])`` for a clean miss,
|
|
615
|
+
and ``(None, candidates)`` when a fallback step could not choose.
|
|
616
|
+
"""
|
|
617
|
+
# A companion that recorded a mapping is read by that alone — even
|
|
618
|
+
# when nothing readable survived it (``mapping_malformed``): the
|
|
619
|
+
# author wrote an answer, and guessing behind a broken one is still
|
|
620
|
+
# guessing.
|
|
621
|
+
if ov.mapping or ov.mapping_malformed:
|
|
622
|
+
mapped = ov.mapping.get(name)
|
|
623
|
+
# A mapping naming a variable the file does not hold draws nothing:
|
|
624
|
+
# the file has been replaced since, and the mapping is now wrong.
|
|
625
|
+
# ``_load_companion`` has already warned about it.
|
|
626
|
+
return (ov.variables.get(mapped) if mapped else None), []
|
|
627
|
+
|
|
628
|
+
var = ov.variables.get(name)
|
|
629
|
+
if var is not None:
|
|
630
|
+
return var, []
|
|
631
|
+
if not _matches_by_more_than_name(ov):
|
|
632
|
+
return None, []
|
|
633
|
+
if expression:
|
|
634
|
+
var = ov.variables.get(expression)
|
|
635
|
+
if var is not None:
|
|
636
|
+
return var, []
|
|
637
|
+
|
|
638
|
+
wanted = {name, expression} - {""}
|
|
639
|
+
candidates = sorted(
|
|
640
|
+
key for key, value in ov.variables.items() if value.expression in wanted
|
|
641
|
+
)
|
|
642
|
+
if len(candidates) > 1:
|
|
643
|
+
return None, candidates
|
|
644
|
+
if candidates:
|
|
645
|
+
return ov.variables[candidates[0]], []
|
|
646
|
+
return None, []
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _matches_by_more_than_name(ov: Overlay) -> bool:
|
|
650
|
+
"""Whether this overlay's variables may be found by anything but name.
|
|
651
|
+
|
|
652
|
+
Only a REGISTERED companion may: it is an outside file, naming the
|
|
653
|
+
quantity the way its own source does. Everything else on the picker
|
|
654
|
+
is DSTF's own data under the test's own names — a soft_check
|
|
655
|
+
(imported whole, and SCORED by name through ``against:``, so the
|
|
656
|
+
curve drawn has to be the curve scored), a cross-backend import,
|
|
657
|
+
which is stored as one, and the auto-discovered primary of a sibling
|
|
658
|
+
partition. For those the name is the whole rule (D132).
|
|
659
|
+
"""
|
|
660
|
+
return ov.role == "companion" and ov.kind != "sibling-backend"
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _record_ambiguity(ov: Overlay, name: str, candidates: list[str]) -> None:
|
|
664
|
+
"""Say, once, that this overlay could not tell which curve is which.
|
|
665
|
+
|
|
666
|
+
Only a companion reaches here — nothing else falls back on spellings
|
|
667
|
+
(:func:`_matches_by_more_than_name`) — which is why the remedy is
|
|
668
|
+
worded as one: ``dstf companion add`` is the command that would
|
|
669
|
+
settle it, and there is no such command for a soft_check.
|
|
670
|
+
|
|
671
|
+
Once, because the finding outlives the pass that made it: the
|
|
672
|
+
authoring Session caches these overlay objects per model and
|
|
673
|
+
re-attaches them over the same trajectories on every ``view()``, so
|
|
674
|
+
an ambiguity would otherwise be appended again on every click.
|
|
675
|
+
"""
|
|
676
|
+
text = (
|
|
677
|
+
f"{name}: {len(candidates)} of this overlay's variables carry it as "
|
|
678
|
+
f"their expression ({', '.join(candidates)}), so nothing is drawn for "
|
|
679
|
+
f"it — add a mapping: dstf companion add <model> {ov.name} <path> "
|
|
680
|
+
f"--map {name}=<one of them>"
|
|
681
|
+
)
|
|
682
|
+
if text not in ov.warnings:
|
|
683
|
+
ov.warnings.append(text)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def overlay_summary(overlays: list[Overlay]) -> list[dict]:
|
|
687
|
+
"""Test-level summary of every overlay — includes missing/invalid ones
|
|
688
|
+
so the report can show a "not rendered" hint.
|
|
689
|
+
|
|
690
|
+
``warnings`` carries what :func:`attach_overlays_to_trajectories` found
|
|
691
|
+
it could not decide, so call this AFTER attaching — which is what the
|
|
692
|
+
report's ``context_builder`` does, its only caller. (The authoring tool
|
|
693
|
+
reads the overlays themselves, not this summary.)
|
|
694
|
+
"""
|
|
695
|
+
out = []
|
|
696
|
+
for ov in overlays:
|
|
697
|
+
out.append(
|
|
698
|
+
{
|
|
699
|
+
"name": ov.name,
|
|
700
|
+
"role": ov.role,
|
|
701
|
+
"kind": ov.kind,
|
|
702
|
+
"status": ov.status,
|
|
703
|
+
"note": ov.note,
|
|
704
|
+
"variables": sorted(ov.variables.keys()),
|
|
705
|
+
"warnings": list(ov.warnings),
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
return out
|