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,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