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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
dstf/reporting/rows.py ADDED
@@ -0,0 +1,458 @@
1
+ """A test's row: the one row model the dashboard and the tool's Suite table
2
+ render, its columns, and the builders that fill it.
3
+
4
+ ``TestRow`` holds a test's row-level fields; ``COLUMNS`` says, for each
5
+ column both surfaces show, its key, its label, its kind (what a filter on
6
+ it means) and how a row reads and sorts there. ``summary_fields`` is the
7
+ one computation of the numbers a row carries after the compare phase: the
8
+ per-test sidecar writes them, and the dashboard reads them back through
9
+ ``enrich_row``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from collections.abc import Callable, Mapping
16
+ from dataclasses import dataclass, field, replace
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ from ..comparison.types import TestComparison
21
+ from .context_builder import worst_nrmse
22
+ from .status import (
23
+ CANCELLED,
24
+ LIVE_STATES,
25
+ SCORING,
26
+ SCORING_CLASS,
27
+ STATUS_CLASSES,
28
+ STATUS_LABELS,
29
+ STATUS_ORDER,
30
+ classify_with_class,
31
+ live_status,
32
+ scored_status,
33
+ )
34
+
35
+ #: The word a live row reads when its simulation ran and the compare phase
36
+ #: has not scored it. Not a status: it is in no count and no pill.
37
+ SIMULATED = "SIMULATED"
38
+
39
+ #: The class that row is styled with: the theme's "no verdict yet" token.
40
+ PENDING_CLASS = "pending"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TestRow:
45
+ """One test's row, as both HTML surfaces show it."""
46
+
47
+ model_id: str
48
+ test_key: str | None = None
49
+ #: a ``reporting.status`` status, or None: simulated, not scored
50
+ status: str | None = None
51
+ ref_id: str | None = None
52
+ #: file:// URI of the reference JSON
53
+ ref_file: str | None = None
54
+ #: The name a run gives this test's directory under ``reports/`` before
55
+ #: it starts — where its page's sidecar is looked for, not a page.
56
+ report_dir: str | None = None
57
+ #: The directory under ``reports/`` of a report page the writer of this
58
+ #: row READ (its sidecar, fresh): what the Model cell links to. None
59
+ #: where it read none — a run wipes its tests' report dirs, and only
60
+ #: ``--report`` writes them again.
61
+ report_page: str | None = None
62
+ field_sources: Mapping[str, str] = field(default_factory=dict)
63
+ worker_id: int | None = None
64
+ elapsed: float | None = None
65
+ started_wall: float | None = None
66
+ worst_score: float | None = None
67
+ n_unscored: int | None = None
68
+ n_vars: int | None = None
69
+ n_vars_passed: int | None = None
70
+ n_warnings: int | None = None
71
+ translation_wall: float | None = None
72
+ sim_wall: float | None = None
73
+ #: the result file's final ``CPUtime`` (Dymola's diagnostic): what Sim
74
+ #: (s) shows, marked, where the run kept no wall time
75
+ sim_cpu: float | None = None
76
+ total_wall: float | None = None
77
+ message: str | None = None
78
+ #: the tool's staged edits on this test; 0 in a report
79
+ edits: int = 0
80
+ #: the tool's "not scored yet": no verdict, and no live run giving one —
81
+ #: the Session has not scored this test (yet)
82
+ waiting: bool = False
83
+
84
+ @property
85
+ def status_text(self) -> str:
86
+ if self.status:
87
+ return STATUS_LABELS[self.status]
88
+ return SCORING if self.waiting else SIMULATED
89
+
90
+ @property
91
+ def status_class(self) -> str:
92
+ if self.status:
93
+ return STATUS_CLASSES[self.status]
94
+ return SCORING_CLASS if self.waiting else PENDING_CLASS
95
+
96
+ @property
97
+ def sim_source(self) -> Literal["wall", "cpu"] | None:
98
+ """Where Sim (s) comes from: the wall the run kept, which always
99
+ wins; else the result file's CPU time; else nothing."""
100
+ if self.sim_wall is not None:
101
+ return "wall"
102
+ return "cpu" if self.sim_cpu is not None else None
103
+
104
+ @property
105
+ def sim_time(self) -> float | None:
106
+ """The number Sim (s) shows and sorts on, from ``sim_source``."""
107
+ return self.sim_wall if self.sim_wall is not None else self.sim_cpu
108
+
109
+ @property
110
+ def resolution(self) -> str | None:
111
+ """The one source every simulation field came from, "mixed", or
112
+ None when the row carries no provenance."""
113
+ sources = set(self.field_sources.values())
114
+ if not sources:
115
+ return None
116
+ return next(iter(sources)) if len(sources) == 1 else "mixed"
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Column:
121
+ """One column both surfaces show: what a filter on it means (``kind``)
122
+ and how a row reads (``text``) and sorts (``sort``) there."""
123
+
124
+ key: str
125
+ label: str
126
+ kind: Literal["text", "number", "status"]
127
+ #: the cell as a person reads it; "—" when absent
128
+ text: Callable[[TestRow], str]
129
+ #: None sorts last in both directions
130
+ sort: Callable[[TestRow], float | str | None]
131
+ #: the header's tooltip
132
+ title: str | None = None
133
+ #: a small word after a row's text ("cpu") and the cell's tooltip, for
134
+ #: a column whose number may be of more than one kind; None when the
135
+ #: row's needs neither
136
+ tag: Callable[[TestRow], str | None] | None = None
137
+ tip: Callable[[TestRow], str | None] | None = None
138
+
139
+ def filter_text(self, row: TestRow) -> str:
140
+ """What a column filter reads: the cell's text, or "" when the row
141
+ has no value here. The cell reads "—" then; a filter that read it
142
+ too would match every empty row on a regex ``.`` or on "—"."""
143
+ return "" if self.sort(row) is None else self.text(row)
144
+
145
+
146
+ #: A column filter's hint by the column's kind: its placeholder and its
147
+ #: tooltip. The dashboard's filter row and the tool's Suite table read
148
+ #: these, so the two say the same thing about the one filter language
149
+ #: (``templates/filters.js``).
150
+ FILTER_HINTS: dict[str, tuple[str, str]] = {
151
+ "text": (
152
+ "*Pipe*",
153
+ "Substring match by default; use * or ? for glob (e.g. *.Freq*);"
154
+ " .* above makes it a regex",
155
+ ),
156
+ "number": (
157
+ ">1e-4",
158
+ "A comparison (>1e-4, <=5, =0) or an inclusive range (1..10)",
159
+ ),
160
+ }
161
+
162
+
163
+ _DASH = "—"
164
+
165
+
166
+ def _text_col(
167
+ key: str, label: str, get: Callable[[TestRow], Any], title: str | None = None
168
+ ) -> Column:
169
+ """A text column: the value as is, sorting on its lower-cased text."""
170
+ return Column(
171
+ key=key,
172
+ label=label,
173
+ kind="text",
174
+ text=lambda r: _DASH if get(r) in (None, "") else str(get(r)),
175
+ sort=lambda r: None if get(r) in (None, "") else str(get(r)).lower(),
176
+ title=title,
177
+ )
178
+
179
+
180
+ def _num_col(key: str, label: str, get: Callable[[TestRow], Any], spec: str) -> Column:
181
+ """A number column: the value formatted by *spec*, sorting on the value."""
182
+ return Column(
183
+ key=key,
184
+ label=label,
185
+ kind="number",
186
+ text=lambda r: _DASH if get(r) is None else format(get(r), spec),
187
+ sort=get,
188
+ )
189
+
190
+
191
+ #: Sim (s)'s tooltip where the number is the result file's CPU time.
192
+ SIM_CPU_TIP = "CPU time from the result file (no wall time was kept)"
193
+
194
+
195
+ def _nrmse_text(row: TestRow) -> str:
196
+ if row.worst_score is not None:
197
+ return format(row.worst_score, ".4e")
198
+ if row.n_unscored:
199
+ return f"n/a ({row.n_unscored} unscored)"
200
+ return _DASH
201
+
202
+
203
+ COLUMNS: tuple[Column, ...] = (
204
+ _text_col("test", "Key", lambda r: r.test_key),
205
+ _text_col("ref", "Reference", lambda r: r.ref_id),
206
+ _text_col("model", "Test", lambda r: r.model_id),
207
+ Column(
208
+ key="status",
209
+ label="Status",
210
+ kind="status",
211
+ text=lambda r: r.status_text,
212
+ sort=lambda r: STATUS_ORDER.index(r.status) if r.status else None,
213
+ ),
214
+ _text_col(
215
+ "resolution",
216
+ "Resolution",
217
+ lambda r: r.resolution,
218
+ title=(
219
+ "Provenance of stop_time / tolerance / method / number_of_intervals"
220
+ " / output_interval — annotation, test_spec, or default"
221
+ ),
222
+ ),
223
+ _text_col(
224
+ "worker", "Worker", lambda r: None if r.worker_id is None else f"W{r.worker_id}"
225
+ ),
226
+ _num_col("elapsed", "Elapsed (s)", lambda r: r.elapsed, ".1f"),
227
+ Column(
228
+ key="nrmse",
229
+ label="Worst NRMSE",
230
+ kind="number",
231
+ text=_nrmse_text,
232
+ sort=lambda r: r.worst_score,
233
+ ),
234
+ Column(
235
+ # What is counted is CRITERIA — the leaves of the test's tree, one
236
+ # per tracked variable where it has no tree: two criteria on one
237
+ # variable are two, a family of twenty elements twenty, an advisory
238
+ # one (under a `warn`) one. The column read "Variables", and `3/3`
239
+ # under it for a test that scores one variable three ways. (The
240
+ # row's fields keep their names, `n_vars` / `n_vars_passed`: they
241
+ # are the sidecar summary's keys.)
242
+ key="criteria",
243
+ label="Criteria",
244
+ kind="number",
245
+ text=lambda r: _DASH if r.n_vars is None else f"{r.n_vars_passed}/{r.n_vars}",
246
+ # The number passed, as the column sorted before it had a filter:
247
+ # ">2" there means "more than 2 criteria passed".
248
+ sort=lambda r: None if r.n_vars is None else r.n_vars_passed,
249
+ title=(
250
+ "Criteria passed / criteria in all, advisory ones included: two "
251
+ "on one variable count as two. Sorts and filters on the number "
252
+ "passed: >2 means more than 2 passed"
253
+ ),
254
+ ),
255
+ _num_col("warnings", "Warnings", lambda r: r.n_warnings, "d"),
256
+ _num_col("translate", "Translate (s)", lambda r: r.translation_wall, ".2f"),
257
+ # The wall the run kept; else the result file's CPU time, marked: a
258
+ # CPU time is not a wall (it leaves out what the solver waited on), so
259
+ # a number of either kind says which it is.
260
+ replace(
261
+ _num_col("sim", "Sim (s)", lambda r: r.sim_time, ".2f"),
262
+ tag=lambda r: "cpu" if r.sim_source == "cpu" else None,
263
+ tip=lambda r: SIM_CPU_TIP if r.sim_source == "cpu" else None,
264
+ ),
265
+ _num_col("total", "Total (s)", lambda r: r.total_wall, ".2f"),
266
+ # Keyed "detail": the column-resize follow reads --col-w-detail.
267
+ _text_col("detail", "Message", lambda r: r.message),
268
+ )
269
+
270
+
271
+ def unsaved_text(edits: int) -> str:
272
+ """A test's staged edits as the Suite's Edits column and the Tests
273
+ drawer's row pill read them; "" for none."""
274
+ return f"{edits} unsaved" if edits else ""
275
+
276
+
277
+ #: The tool's staged edits: a Suite column, in no report (a report has no
278
+ #: staged edits).
279
+ EDITS = Column(
280
+ key="edits",
281
+ label="Edits",
282
+ kind="number",
283
+ text=lambda r: unsaved_text(r.edits),
284
+ sort=lambda r: r.edits or None,
285
+ title="Staged edits not yet saved to test_spec.json",
286
+ )
287
+
288
+
289
+ def cpu_time(stats: Mapping[str, Any] | None) -> float | None:
290
+ """The final value of the result's ``CPUtime`` diagnostic, as Dymola's
291
+ ``read_result`` folds it into the statistics (DSTF sets
292
+ ``OutputCPUtime := true``); None where the result has none — every
293
+ other backend."""
294
+ value = stats.get("CPUtime") if isinstance(stats, Mapping) else None
295
+ if isinstance(value, bool) or not isinstance(value, int | float):
296
+ return None
297
+ return float(value)
298
+
299
+
300
+ def summary_fields(
301
+ comp: TestComparison,
302
+ *,
303
+ warnings: list | None = None,
304
+ stats: Mapping[str, Any] | None = None,
305
+ ref_id: str | None = None,
306
+ ref_file: Path | None = None,
307
+ field_sources: Mapping[str, str] | None = None,
308
+ ) -> dict[str, Any]:
309
+ """The row-level numbers of *comp*, as the per-test sidecar's
310
+ ``summary`` carries them (bar its ``model_id`` / ``written_at``).
311
+
312
+ *warnings* defaults to the comparison's own; *stats* is the result's
313
+ statistics, whose ``timing`` block holds the phase walls and whose
314
+ ``CPUtime`` is the result file's CPU time.
315
+ """
316
+ status, status_class = classify_with_class(comp)
317
+ variables = comp.variables
318
+ warns = comp.warnings if warnings is None else warnings
319
+ # The worst NRMSE among the criteria scored by nrmse (`worst_nrmse`):
320
+ # None for a test with none. ``n_unscored`` counts the sentinel leaves,
321
+ # and is what the column says when there is no number to show.
322
+ worst, n_unscored = worst_nrmse(variables)
323
+ timing = stats.get("timing") if isinstance(stats, Mapping) else None
324
+ if not isinstance(timing, Mapping):
325
+ timing = {}
326
+ return {
327
+ "status": status,
328
+ "status_text": STATUS_LABELS[status],
329
+ "status_class": status_class,
330
+ "ref_id": ref_id,
331
+ # file:// URL so the dashboard's Reference column can hyperlink
332
+ # directly to the stored reference JSON.
333
+ "ref_file": ref_file.as_uri() if ref_file else None,
334
+ "worst_nrmse": worst,
335
+ "n_unscored": n_unscored,
336
+ "n_vars": len(variables),
337
+ "n_vars_passed": sum(1 for v in variables if v.passed),
338
+ "n_warnings": len(warns) if warns else 0,
339
+ "translation_wall": timing.get("translation_wall"),
340
+ "sim_wall": timing.get("sim_wall"),
341
+ "sim_cpu": cpu_time(stats),
342
+ "total_wall": timing.get("total_wall"),
343
+ "field_sources": dict(field_sources or {}),
344
+ }
345
+
346
+
347
+ def row_from_status(entry: Mapping[str, Any]) -> TestRow:
348
+ """A row from one ``status.json`` ``tests[]`` entry — a live row."""
349
+ report_dir = entry.get("report_dir") or ""
350
+ raw = entry.get("status", "queued")
351
+ message = entry.get("detail")
352
+ status = None
353
+ if isinstance(raw, str) and raw in LIVE_STATES:
354
+ status = live_status(raw)
355
+ elif raw is not None:
356
+ # status.json is data: a state no surface has a word for — or no
357
+ # word at all (a list would not even hash) — has no verdict, and
358
+ # says what it was rather than reading SIMULATED. A null says
359
+ # nothing, so the row has no verdict and nothing to add.
360
+ message = f"status {raw!r}" + (f": {message}" if message else "")
361
+ return TestRow(
362
+ model_id=entry.get("model_id") or "",
363
+ test_key=entry.get("test_key"),
364
+ status=status,
365
+ # A live ref_id follows the "ref_NNNN" naming of a report_dir the run
366
+ # pre-populated (backend.ref_id_map); a test with no baseline yet has
367
+ # its test_key there, and no ref_id.
368
+ ref_id=report_dir if report_dir.startswith("ref_") else None,
369
+ report_dir=entry.get("report_dir") or entry.get("test_key"),
370
+ field_sources=entry.get("field_sources") or {},
371
+ worker_id=entry.get("worker_id"),
372
+ elapsed=entry.get("elapsed"),
373
+ started_wall=entry.get("started_wall"),
374
+ message=message,
375
+ )
376
+
377
+
378
+ #: A sidecar's field → the row's.
379
+ _SUMMARY_TO_ROW = (
380
+ ("worst_nrmse", "worst_score"),
381
+ ("n_unscored", "n_unscored"),
382
+ ("n_vars", "n_vars"),
383
+ ("n_vars_passed", "n_vars_passed"),
384
+ ("n_warnings", "n_warnings"),
385
+ ("translation_wall", "translation_wall"),
386
+ ("sim_wall", "sim_wall"),
387
+ ("sim_cpu", "sim_cpu"),
388
+ ("total_wall", "total_wall"),
389
+ ("ref_id", "ref_id"),
390
+ ("ref_file", "ref_file"),
391
+ ("field_sources", "field_sources"),
392
+ )
393
+
394
+ _STATUS_BY_LABEL = {label: s for s, label in STATUS_LABELS.items()}
395
+
396
+
397
+ def enrich_row(
398
+ row: TestRow,
399
+ summary: Mapping[str, Any],
400
+ *,
401
+ start_wall: float | None,
402
+ anchor: str = "start_wall",
403
+ ) -> TestRow:
404
+ """*row* with a ``summary``'s fields, or *row* unchanged when the summary
405
+ is not this run's verdict on this test. Two guards (D91 — see
406
+ ``pipeline.wipe_stale_state_for_scope``):
407
+
408
+ 1. **model_id mismatch** — the sidecar belongs to another test
409
+ (bookkeeping drift between batch_manifest and reports/).
410
+ 2. **Stale timestamp** — its ``written_at`` is older than
411
+ ``start_wall``, when the run that produced this test's result
412
+ started: a prior run's sidecar the wipe did not clear, whose verdict
413
+ must not override this run's. ``anchor`` names that clock in the
414
+ warning — status.json's ``start_wall``, or the manifest entry's
415
+ ``last_run_at`` for a test the last run did not run.
416
+
417
+ Both guards print a one-line warning to stderr, so a skipped
418
+ enrichment is visible.
419
+ """
420
+ sidecar_model = summary.get("model_id")
421
+ if sidecar_model and row.model_id and sidecar_model != row.model_id:
422
+ print(
423
+ f"# WARN: sidecar model_id mismatch (expected {row.model_id!r}, "
424
+ f"sidecar has {sidecar_model!r}); ignoring enrichment.",
425
+ file=sys.stderr,
426
+ )
427
+ return row
428
+ written_at = summary.get("written_at")
429
+ if start_wall is not None and written_at is not None and written_at < start_wall:
430
+ print(
431
+ f"# WARN: stale sidecar for {row.model_id!r} "
432
+ f"(written_at={written_at} < {anchor}={start_wall}); "
433
+ f"ignoring enrichment.",
434
+ file=sys.stderr,
435
+ )
436
+ return row
437
+ # A null says its writer did not know, and replaces nothing: copied,
438
+ # the null `ref_id` of a failed simulation's sidecar took the baseline
439
+ # its run had named off the row.
440
+ changes: dict[str, Any] = {
441
+ dst: summary[src]
442
+ for src, dst in _SUMMARY_TO_ROW
443
+ if summary.get(src) is not None
444
+ }
445
+ # An empty provenance says nothing: the live row's stands.
446
+ if not changes.get("field_sources", True):
447
+ del changes["field_sources"]
448
+ # The compare phase's verdict replaces the live state. A sidecar written
449
+ # before ``status`` existed carries only the word a person reads. A
450
+ # status outside the vocabulary, or not a word at all, is ignored: the
451
+ # row keeps its own.
452
+ status = summary.get("status")
453
+ if not isinstance(status, str) or not status:
454
+ text = summary.get("status_text")
455
+ status = _STATUS_BY_LABEL.get(text) if isinstance(text, str) else None
456
+ if status in STATUS_LABELS:
457
+ changes["status"] = scored_status(status, cancelled=row.status == CANCELLED)
458
+ return replace(row, **changes)