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,979 @@
1
+ """Build the per-test template context from comparison results.
2
+
3
+ The pure ``results -> context dict`` side of the per-test report: every
4
+ helper here takes already-extracted inputs and returns one named slice of
5
+ the context that :mod:`dstf.reporting.report_suite` renders. Nothing in
6
+ this module touches Jinja, the filesystem, or the decimation pass.
7
+ """
8
+
9
+ import logging
10
+ import math
11
+ from collections.abc import Iterable, Sequence
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+ from ..backends.types import VariableText, display_label, variable_texts
17
+ from ..comparison.comparator import VariableComparison
18
+ from ..comparison.registry import summarize
19
+ from .stats_rows import SECTION_TITLES, report_text, statistics_rows
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def test_texts(
25
+ comparisons: Iterable,
26
+ result,
27
+ plot_series: Iterable[str] = (),
28
+ ) -> dict[str, VariableText]:
29
+ """The TEST's label scope (:func:`~dstf.backends.types.variable_texts`)
30
+ for one page: the result's variables, the scored comparisons' and every
31
+ series of the test's resolved plots.
32
+
33
+ The one builder both tiers call — the report through
34
+ :func:`_build_template_context`, the tool through
35
+ ``Session.variable_texts`` — so a variable reads the same in its card
36
+ title, its criteria row, its pill and its legend entry.
37
+ """
38
+ labels: dict[str, str] = {}
39
+ for var in getattr(result, "variables", None) or ():
40
+ if var.name:
41
+ labels[var.name] = display_label(var.name, var.expression)
42
+ for vc in comparisons:
43
+ if vc.name:
44
+ labels.setdefault(vc.name, display_label(vc.name, vc.expression))
45
+ return variable_texts(labels, plot_series)
46
+
47
+
48
+ def _format_value(val) -> str:
49
+ """Format a value for display in HTML tables."""
50
+ if isinstance(val, list):
51
+ return ", ".join(str(v) for v in val)
52
+ if val is None or val == "":
53
+ return ""
54
+ return str(val)
55
+
56
+
57
+ def worst_nrmse(comparisons: list) -> tuple[float | None, int]:
58
+ """``(the worst NRMSE among the criteria scored BY nrmse, the count of
59
+ criteria with no score)`` — what the "Worst NRMSE" column, the report
60
+ header and the tool's Suite page show.
61
+
62
+ ``VariableComparison.nrmse`` is overloaded: it carries the mode's own
63
+ number (a range's worst violation, an event's Δt), and for a tube the
64
+ NRMSE against the reference — which the tube does not score by. A max
65
+ over every leaf showed ``2.8213e-01`` for a test whose tube passed at
66
+ 100 % inside while the criterion that failed, an nrmse, was at
67
+ ``4.264e-02``. Only a criterion whose mode is ``nrmse`` has an NRMSE
68
+ that decided anything; a test with none has no worst one (``None``:
69
+ the column reads "—").
70
+
71
+ A sentinel leaf of any mode (a metric config that would not build, no
72
+ data to compare) carries ``inf``/``nan``: it is left out and counted,
73
+ so one of them cannot swallow the real numbers, and the surface can
74
+ say that criteria went unscored.
75
+ """
76
+ scored = [
77
+ vc.nrmse for vc in comparisons if vc.mode == "nrmse" and math.isfinite(vc.nrmse)
78
+ ]
79
+ n_unscored = sum(1 for vc in comparisons if not math.isfinite(vc.nrmse))
80
+ return (max(scored) if scored else None, n_unscored)
81
+
82
+
83
+ def _text_of(texts: dict[str, VariableText] | None, name: str, expression: str) -> str:
84
+ """What the page reads for ``name``, from the test's scope — or its
85
+ plain label when the caller built no scope (a variable the scope never
86
+ saw cannot collide with anything in it)."""
87
+ entry = (texts or {}).get(name)
88
+ return entry.text if entry is not None else display_label(name, expression)
89
+
90
+
91
+ def _in_effect(view: dict, base: tuple[float, str] | None) -> dict:
92
+ """``{field: {"value", "source"}}`` for what a leaf is scored with while
93
+ its field is EMPTY (K3): today the test's base tolerance and its source
94
+ (``base``, `TestComparison.base_tolerance` / ``tolerance_source``), on
95
+ every leaf whose metric's form has the field and does not ignore it — a
96
+ scalar with bounds does. A leaf with a tolerance of its own carries it
97
+ too: its editor shows no hint until that value is cleared, and cleared,
98
+ the base is what applies. None — a caller that has none — shows
99
+ nothing. Known gap: on the flat path a baseline JSON's
100
+ ``variable_overrides`` tolerance scores its variable, while the hint
101
+ names the base."""
102
+ from ..comparison.mode_schema import derive_schema
103
+ from ..comparison.registry import metric
104
+
105
+ params = view.get("params") or {}
106
+ desc = metric(view.get("metric", ""))
107
+ if base is None or desc is None:
108
+ return {}
109
+ fields = {f.name for f in derive_schema(desc.config_cls, mode=desc.name).fields}
110
+ if "tolerance" not in fields or {"min_value", "max_value"} & set(params):
111
+ return {}
112
+ value, source = base
113
+ return {"tolerance": {"value": float(value), "source": source}}
114
+
115
+
116
+ def _leaf_tolerance(view: dict, vc: VariableComparison) -> float | dict | None:
117
+ """The tolerance a leaf was scored with, as written (`sides.as_written`),
118
+ for a metric that takes one — its config has the field — and scored by
119
+ it: a scalar with bounds does not. None otherwise: a tube's width, a
120
+ range's bounds and a table's rows are the leaf's own ``params``, and
121
+ the base tolerance the evaluator handed such a mode decided nothing."""
122
+ from ..comparison.mode_schema import derive_schema
123
+ from ..comparison.registry import metric
124
+ from ..comparison.sides import Sides, as_written
125
+
126
+ desc = metric(vc.mode or view.get("metric", ""))
127
+ if desc is None or {"min_value", "max_value"} & set(view.get("params") or {}):
128
+ return None
129
+ fields = {f.name for f in derive_schema(desc.config_cls, mode=desc.name).fields}
130
+ if "tolerance" not in fields:
131
+ return None
132
+ used = vc.tolerance_used
133
+ return as_written(used) if isinstance(used, Sides) else float(used)
134
+
135
+
136
+ def _augment_tree_view(
137
+ view: dict,
138
+ comparisons_by_path: dict[str, VariableComparison],
139
+ texts: dict[str, VariableText] | None = None,
140
+ base_tolerance: tuple[float, str] | None = None,
141
+ ) -> None:
142
+ """Walk ``view`` in place, enriching leaf nodes with what the page shows.
143
+
144
+ The template renders the tree server-side and read-only, so a leaf
145
+ carries the CLI's verdict and the CLI's words for it: the effective
146
+ mode, the variable's name, ``summarize``'s display / criterion /
147
+ detail pairs, and the reason a sentinel could not be scored. Nothing
148
+ here is an input to a computation — the page performs none.
149
+
150
+ ``texts`` is the TEST's label scope (:func:`test_texts`): a criteria
151
+ row shows no muted name, so it reads the ``text``. Two variables
152
+ sharing one expression — ``x={a.Q, a.Q}`` — gave two rows the
153
+ identical ``nrmse · a.Q``, with nothing on either saying which
154
+ variable it scored.
155
+ """
156
+ if view.get("kind") == "leaf":
157
+ path = view.get("path", "")
158
+ vc = comparisons_by_path.get(path)
159
+ if vc is None:
160
+ return
161
+ var_label = vc.name or view.get("variable", "")
162
+ summary = summarize(vc)
163
+ view.update(
164
+ {
165
+ # The metric the criterion was scored by — the comparison's
166
+ # own `mode`, the ONE source every surface names it from
167
+ # ("" for the one with none: a flat override naming a mode
168
+ # nothing is registered under). `metric` beside it is the
169
+ # spec's: a flat test's default is written `nrmse` there
170
+ # while `default_points` scores it by `points`.
171
+ "mode_effective": vc.mode,
172
+ # The baseline the criterion READ (`VariableComparison.
173
+ # against`), null where it reads none — a `range`, a
174
+ # criterion scored from its own declared values. The spec's
175
+ # `against` said "primary" of each of those.
176
+ "against": vc.against,
177
+ "name": var_label,
178
+ # What the leaf's row READS (`metric · pipe.T[1]`), on both
179
+ # tiers: the report's node macro and the tool's `leaf_name`
180
+ # take it from here, so the two cannot drift. `variable`
181
+ # stays the key a patch op targets.
182
+ "label": _text_of(texts, var_label, vc.expression),
183
+ "score_display": summary.display,
184
+ "criterion": summary.criterion,
185
+ # The mode's own key/value pairs behind the verdict, as the
186
+ # server-rendered ``<dl class="node-details">`` under the leaf.
187
+ # Tuples become lists so the view stays JSON-shaped.
188
+ "details": [list(pair) for pair in summary.details],
189
+ # Reason a sentinel leaf could not be scored. Rendered as the
190
+ # score pill's tooltip plus a muted line beneath it; None on
191
+ # every normally-scored leaf.
192
+ "error": (vc.diagnostics or {}).get("error"),
193
+ }
194
+ )
195
+ tolerance = _leaf_tolerance(view, vc)
196
+ if tolerance is not None:
197
+ # The tolerance the criterion was scored with — its own, else
198
+ # the test's — as data (a number, or a lower/upper pair): what
199
+ # a script reads off `verdicts.json` and the sidecar without
200
+ # parsing the criterion sentence.
201
+ view["tolerance"] = tolerance
202
+ in_effect = _in_effect(view, base_tolerance)
203
+ if in_effect:
204
+ view["in_effect"] = in_effect
205
+ return
206
+ for child in view.get("children", []):
207
+ _augment_tree_view(child, comparisons_by_path, texts, base_tolerance)
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Per-section context builders
212
+ # ---------------------------------------------------------------------------
213
+ # _build_template_context (below) is an orchestrator over the helpers in this
214
+ # section. Each helper produces one named slice of the output context dict
215
+ # (ref_info / sim_params / statistics_sections / etc.) so per-section edits
216
+ # don't muddy git blame on unrelated sections, and each piece is independently
217
+ # testable by feeding it a small input dict + asserting on the returned slice.
218
+ # Shared invariants:
219
+ # * Every helper accepts already-extracted inputs (ref_stats, ref_sim) so
220
+ # the orchestrator owns the "if ref_data else {}" sentinel-handling.
221
+ # * Helpers return values; mutation of caller-owned state happens only at
222
+ # the orchestrator level (overlay attach is the one in-place step).
223
+
224
+
225
+ def _build_ref_info(
226
+ comparisons: list,
227
+ ref_data: dict | None,
228
+ test_dir: Path | None,
229
+ ref_file: Path | None,
230
+ ) -> list:
231
+ """Build the test-metadata rows shown in the report's "Reference Info"
232
+ section: test_id / status / dates / reference-file link / test-dir link
233
+ / how many criteria scored the run / how many variables the baseline
234
+ stores.
235
+ """
236
+ ref_info = []
237
+ meta_fields = [
238
+ ("test_id", "Test ID"),
239
+ ("status", "Status"),
240
+ ("date_added", "Date Added"),
241
+ ("last_updated", "Last Updated"),
242
+ ]
243
+ for key, label in meta_fields:
244
+ ref_val = ref_data.get(key, "") if ref_data else ""
245
+ if ref_val:
246
+ ref_info.append({"label": label, "value": _format_value(ref_val)})
247
+
248
+ if ref_data and ref_data.get("test_id"):
249
+ from ..storage.ref_index import RefIndex
250
+
251
+ ref_filename = RefIndex.ref_filename(ref_data["test_id"])
252
+ row = {"label": "Reference File", "value": ref_filename}
253
+ if ref_file and ref_file.exists():
254
+ row["link"] = ref_file.resolve().as_uri()
255
+ ref_info.append(row)
256
+
257
+ if test_dir and test_dir.exists():
258
+ ref_info.append(
259
+ {
260
+ "label": "Test Directory",
261
+ "value": test_dir.name,
262
+ "link": test_dir.resolve().as_uri(),
263
+ }
264
+ )
265
+
266
+ # Two counts of two things, each named for what it is: the criteria
267
+ # this run was scored by (one per leaf — two on one variable are two),
268
+ # and the variables the baseline stores. They read `Tracked Variables |
269
+ # 23 current / 21 reference`, as one quantity then and now.
270
+ if comparisons:
271
+ ref_info.append({"label": "Criteria", "value": str(len(comparisons))})
272
+ if ref_data and ref_data.get("n_vars") is not None:
273
+ ref_info.append(
274
+ {"label": "Reference Variables", "value": str(ref_data["n_vars"])}
275
+ )
276
+ return ref_info
277
+
278
+
279
+ def _build_sim_params(test_model, ref_sim: dict) -> list:
280
+ """Build the current-vs-reference simulation-parameter table (stop_time,
281
+ tolerance, method, ...). Each row carries a ``changed`` flag the
282
+ template uses to highlight drift.
283
+ """
284
+ sim_params = []
285
+ sim_fields = [
286
+ ("stop_time", "Stop Time"),
287
+ ("tolerance", "Tolerance"),
288
+ ("method", "Method"),
289
+ ("number_of_intervals", "Number of Intervals"),
290
+ ("output_interval", "Output Interval"),
291
+ ]
292
+ for key, label in sim_fields:
293
+ ref_val = ref_sim.get(key)
294
+ cur_val = getattr(test_model, key, None) if test_model else None
295
+ ref_str = _format_value(ref_val) if ref_val is not None else ""
296
+ cur_str = _format_value(cur_val) if cur_val is not None else ""
297
+ if ref_str or cur_str:
298
+ sim_params.append(
299
+ {
300
+ "label": label,
301
+ "current": cur_str,
302
+ "reference": ref_str,
303
+ "changed": cur_str != "" and ref_str != "" and cur_str != ref_str,
304
+ }
305
+ )
306
+ return sim_params
307
+
308
+
309
+ def _build_statistics_sections(
310
+ ref_stats: dict,
311
+ cur_stats: dict,
312
+ *,
313
+ run_diagnostics: Sequence = (),
314
+ ) -> list:
315
+ """The Statistics table: :func:`~.stats_rows.statistics_rows` rendered
316
+ into the template's ``{title, rows: [{label, current, reference,
317
+ changed}]}`` sections — the rows the tool's Statistics card shows, in
318
+ the report's order, under one "changed" rule for both; printed in full
319
+ (:func:`~.stats_rows.report_text`), where the card rounds. A section with
320
+ no key is no section. ``run_diagnostics`` are the run's samples: a
321
+ folded diagnostic's row takes its kind from them, as the card's does."""
322
+ sections: dict[str, dict] = {}
323
+ for row in statistics_rows(cur_stats, ref_stats, run_diagnostics=run_diagnostics):
324
+ if row.section not in sections:
325
+ title = (
326
+ SECTION_TITLES.get(row.section) or row.section.replace("_", " ").title()
327
+ )
328
+ if row.section in ("translation", "simulation"):
329
+ title += " Statistics"
330
+ sections[row.section] = {"title": title, "rows": []}
331
+ sections[row.section]["rows"].append(
332
+ {
333
+ "label": row.label,
334
+ "current": report_text(row.kind, row.run),
335
+ "reference": report_text(row.kind, row.baseline),
336
+ "changed": row.changed,
337
+ }
338
+ )
339
+ return list(sections.values())
340
+
341
+
342
+ def build_trajectories(
343
+ comparisons: list,
344
+ result,
345
+ ref_data: dict | None,
346
+ texts: dict[str, VariableText] | None = None,
347
+ ) -> tuple[list, list, list]:
348
+ """Build the trajectory triple — primary tracked variables, diagnostic
349
+ variables (CPUtime / EventCounter), and the no-baseline pass-through
350
+ used when a test runs without a reference. Diagnostic refs may carry
351
+ a scalar summary instead of a full trajectory; the legacy
352
+ full-array shape is still accepted for back-read.
353
+
354
+ ``texts`` is the test's label scope (:func:`test_texts`). Every entry
355
+ carries BOTH readings: ``label`` for the surfaces that show the muted
356
+ name beside it (the section heading, the per-variable status badge),
357
+ ``text`` for the ones that do not (the figure's legend and hover, the
358
+ plot notes).
359
+
360
+ Returns ``(trajectories, diag_trajectories, nobaseline_trajectories)``.
361
+ """
362
+ trajectories = []
363
+ ref_time_list = ref_data.get("time", []) if ref_data else []
364
+
365
+ if result and result.variables:
366
+ # One entry per variable NAME, not per comparison: several leaves may
367
+ # score the same variable, and a sentinel among them carries
368
+ # ``index=0`` (the evaluator never resolved a variable to pair with).
369
+ # Pairing that by index finds nothing and emits an EMPTY entry under
370
+ # the real name — a second, blank copy beside the healthy one, or the
371
+ # variable's only entry when every leaf on it is a sentinel. So: the
372
+ # actual curve by index when any leaf on the name resolved one, else
373
+ # by name.
374
+ by_name: dict[str, list] = {}
375
+ for vc in comparisons:
376
+ by_name.setdefault(vc.name, []).append(vc)
377
+ # The REFERENCE always by name, as scoring pairs it: a baseline's
378
+ # indices are the order it was accepted in, and the pattern order
379
+ # (D140 item 9) moved them — by index, a baseline accepted under the
380
+ # old sorted order drew another variable's reference, and its error
381
+ # overlays, under this one's name. The first entry of a name wins,
382
+ # as it does for the comparator.
383
+ ref_vars_by_name: dict[str, dict] = {}
384
+ if ref_data:
385
+ for rv in ref_data.get("variables", []):
386
+ if rv.get("name"):
387
+ ref_vars_by_name.setdefault(rv["name"], rv)
388
+ for name, name_comparisons in by_name.items():
389
+ index = next((vc.index for vc in name_comparisons if vc.index > 0), 0)
390
+ if index > 0:
391
+ act_var = next((v for v in result.variables if v.index == index), None)
392
+ else:
393
+ act_var = next((v for v in result.variables if v.name == name), None)
394
+ index = act_var.index if act_var else 0
395
+ ref_var = ref_vars_by_name.get(name)
396
+ trajectories.append(
397
+ {
398
+ "index": index,
399
+ "name": name,
400
+ # The label beside the name — what the page's titles,
401
+ # legends and hovers read, and what an overlay whose own
402
+ # source names the quantity that way is matched by (see
403
+ # ``attach_overlays_to_trajectories``).
404
+ "expression": act_var.expression if act_var else "",
405
+ "label": display_label(name, act_var.expression if act_var else ""),
406
+ "text": _text_of(
407
+ texts, name, act_var.expression if act_var else ""
408
+ ),
409
+ "act_time": act_var.time.tolist() if act_var else [],
410
+ "act_values": act_var.values.tolist() if act_var else [],
411
+ "ref_time": ref_time_list,
412
+ "ref_values": ref_var["values"] if ref_var else [],
413
+ }
414
+ )
415
+
416
+ diag_trajectories = []
417
+ if result and result.diagnostics:
418
+ ref_diags_by_name = {}
419
+ if ref_data:
420
+ for rd in ref_data.get("diagnostics", []):
421
+ ref_diags_by_name[rd["name"]] = rd
422
+ for diag in result.diagnostics:
423
+ ref_diag = ref_diags_by_name.get(diag.name)
424
+ ref_values = ref_diag.get("values", []) if ref_diag else []
425
+ diag_trajectories.append(
426
+ {
427
+ "name": diag.name,
428
+ "act_time": diag.time.tolist(),
429
+ "act_values": diag.values.tolist(),
430
+ "ref_time": ref_time_list if ref_values else [],
431
+ "ref_values": ref_values,
432
+ }
433
+ )
434
+
435
+ nobaseline_trajectories = []
436
+ if not comparisons and result and result.variables:
437
+ for var in result.variables:
438
+ nobaseline_trajectories.append(
439
+ {
440
+ "index": var.index,
441
+ "name": var.name,
442
+ "expression": var.expression,
443
+ "label": display_label(var.name, var.expression),
444
+ "text": _text_of(texts, var.name, var.expression),
445
+ "time": var.time.tolist(),
446
+ "values": var.values.tolist(),
447
+ }
448
+ )
449
+ return trajectories, diag_trajectories, nobaseline_trajectories
450
+
451
+
452
+ def _build_diag_summaries(result, ref_data: dict | None) -> list:
453
+ """Diagnostic-variable summary rows (current final/min/max vs reference
454
+ summary). Replaces the pre-D78 full-trajectory comparison for
455
+ diagnostics — the trajectory is informational; the summary values
456
+ are the actual regression signal (final CPUtime, total event count).
457
+ """
458
+ if not (result and result.diagnostics):
459
+ return []
460
+ ref_diags_by_name = {}
461
+ if ref_data:
462
+ for rd in ref_data.get("diagnostics", []):
463
+ ref_diags_by_name[rd["name"]] = rd
464
+ diag_summaries = []
465
+ for diag in result.diagnostics:
466
+ values = np.asarray(diag.values)
467
+ cur_final = float(values[-1]) if values.size else None
468
+ ref_entry = ref_diags_by_name.get(diag.name, {})
469
+ # Accept either new summary shape (final/min/max) or legacy full-
470
+ # trajectory shape (values) for back-read during transition.
471
+ if "final" in ref_entry:
472
+ ref_final = ref_entry.get("final")
473
+ elif "values" in ref_entry and ref_entry["values"]:
474
+ ref_final = float(ref_entry["values"][-1])
475
+ else:
476
+ ref_final = None
477
+ diag_summaries.append(
478
+ {
479
+ "name": diag.name,
480
+ "current": _format_value(cur_final) if cur_final is not None else "",
481
+ "reference": _format_value(ref_final) if ref_final is not None else "",
482
+ "changed": (
483
+ cur_final is not None
484
+ and ref_final is not None
485
+ and cur_final != ref_final
486
+ ),
487
+ }
488
+ )
489
+ return diag_summaries
490
+
491
+
492
+ def _build_artifacts(
493
+ test_dir: Path | None,
494
+ artifact_files: tuple[tuple[str, str], ...],
495
+ ) -> list:
496
+ """Walk the backend-declared :attr:`Backend.artifact_files`
497
+ list, return entries for files that exist on disk. Backend-agnostic
498
+ — no hardcoded Dymola/OM/etc. names here.
499
+ """
500
+ if not (test_dir and test_dir.exists()):
501
+ return []
502
+ artifacts = []
503
+ for fname, label in artifact_files or ():
504
+ fpath = test_dir / fname
505
+ if fpath.exists():
506
+ artifacts.append({"uri": fpath.resolve().as_uri(), "label": label})
507
+ return artifacts
508
+
509
+
510
+ def _compute_summary_flags(
511
+ comparisons: list,
512
+ result,
513
+ test_model,
514
+ ) -> tuple[int, int, bool, bool]:
515
+ """Top-level pass/fail summary flags for the report header.
516
+
517
+ Returns ``(n_passed, n_nobaseline, is_simulate_only, sim_failed)``.
518
+ ``simulate_only`` tests legitimately have no comparisons and no
519
+ variables — the pass signal is "the simulation ran", so this guards
520
+ against rendering a misleading "Simulation failed" banner for them.
521
+ """
522
+ n_passed = sum(1 for vc in comparisons if vc.passed)
523
+ n_nobaseline = (
524
+ len(result.variables)
525
+ if (not comparisons and result and result.variables)
526
+ else 0
527
+ )
528
+ is_simulate_only = bool(test_model and test_model.is_simulate_only)
529
+ sim_failed = len(comparisons) == 0 and n_nobaseline == 0 and not is_simulate_only
530
+ return n_passed, n_nobaseline, is_simulate_only, sim_failed
531
+
532
+
533
+ def _build_key_stats(
534
+ comparisons: list,
535
+ ref_stats: dict,
536
+ cur_stats: dict,
537
+ *,
538
+ run_diagnostics: Sequence = (),
539
+ ) -> list:
540
+ """Top-level summary row: the worst NRMSE (`worst_nrmse`), plus a small
541
+ fixed pick of structural stats (continuous states, nonlinear
542
+ counts) and CPUtime / EventCounter. Format mirrors statistics_sections
543
+ rows so the template renderer is uniform.
544
+ """
545
+
546
+ # A statistics pick reads its row: its values (a baseline's empty list
547
+ # reads its count as 0), printed as the table prints them, and the one
548
+ # "changed" rule.
549
+ rows = {
550
+ row.path: row
551
+ for row in statistics_rows(
552
+ cur_stats, ref_stats, run_diagnostics=run_diagnostics
553
+ )
554
+ }
555
+
556
+ def _pick(path: str, label: str) -> None:
557
+ row = rows.get(path)
558
+ if row is None or (row.run is None and row.baseline is None):
559
+ return
560
+ key_stats.append(
561
+ {
562
+ "label": label,
563
+ "current": report_text(row.kind, row.run),
564
+ "reference": report_text(row.kind, row.baseline),
565
+ "changed": row.changed,
566
+ }
567
+ )
568
+
569
+ key_stats = []
570
+ # "Worst NRMSE": the worst among the criteria scored by nrmse
571
+ # (`worst_nrmse`), the dashboard column's number. A test whose
572
+ # criteria are all of other metrics reads "—": it has none. Sentinel
573
+ # leaves are left out and counted.
574
+ worst_score, n_unscored = worst_nrmse(comparisons)
575
+ # The unscored count rides in ``current`` (the template prefixes
576
+ # ``reference`` with "ref:", which this is not).
577
+ unscored_note = (
578
+ ""
579
+ if not n_unscored
580
+ else (
581
+ " (1 leaf unscored)"
582
+ if n_unscored == 1
583
+ else f" ({n_unscored} leaves unscored)"
584
+ )
585
+ )
586
+ if worst_score is not None:
587
+ key_stats.append(
588
+ {
589
+ "label": "Worst NRMSE",
590
+ "current": f"{worst_score:.4e}{unscored_note}",
591
+ "reference": "",
592
+ }
593
+ )
594
+ elif n_unscored:
595
+ key_stats.append(
596
+ {
597
+ "label": "Worst NRMSE",
598
+ "current": (
599
+ "n/a (1 unscored leaf)"
600
+ if n_unscored == 1
601
+ else f"n/a ({n_unscored} unscored leaves)"
602
+ ),
603
+ "reference": "",
604
+ }
605
+ )
606
+ elif comparisons:
607
+ # Criteria, and none of them scored by nrmse.
608
+ key_stats.append({"label": "Worst NRMSE", "current": "—", "reference": ""})
609
+
610
+ for path, label in (
611
+ ("translation.continuous_time_states", "Continuous States"),
612
+ ("translation.nonlinear_count", "Nonlinear Systems"),
613
+ ("translation.nonlinear_max", "Largest Nonlinear"),
614
+ ("CPUtime", "CPU Time"),
615
+ ("EventCounter", "Events"),
616
+ ):
617
+ _pick(path, label)
618
+ return key_stats
619
+
620
+
621
+ def _build_warning_rows(warnings: list | None) -> list:
622
+ """Translate :class:`StructuralWarning` instances into the row dicts
623
+ the template iterates over: a change as its three cells, a statement
624
+ (``is_change`` false) as its one sentence, ``text``.
625
+ """
626
+ return [
627
+ {
628
+ "field": w.field,
629
+ "reference": str(w.reference_value),
630
+ "current": str(w.current_value),
631
+ "is_change": w.is_change,
632
+ "text": w.text,
633
+ }
634
+ for w in (warnings or [])
635
+ ]
636
+
637
+
638
+ def _view_leaf_paths(node: dict) -> list[str]:
639
+ """Leaf paths of a spec view, in tree order."""
640
+ if node.get("kind") == "leaf":
641
+ return [node["path"]]
642
+ out: list[str] = []
643
+ for child in node.get("children") or []:
644
+ out.extend(_view_leaf_paths(child))
645
+ return out
646
+
647
+
648
+ def _view_leaves(node: dict) -> list[dict]:
649
+ """Every leaf view under ``node``, in tree order."""
650
+ if node.get("kind") == "leaf":
651
+ return [node]
652
+ out: list[dict] = []
653
+ for child in node.get("children") or []:
654
+ out.extend(_view_leaves(child))
655
+ return out
656
+
657
+
658
+ def _mark_advisory(node: dict, *, under_warn: bool = False) -> None:
659
+ """Stamp ``advisory: True`` on every leaf sitting under a ``warn``.
660
+
661
+ ``warn`` never fails its parent (``comparison/metric_tree.py``), so such
662
+ a leaf's failure cannot change the test's verdict — it informs, it does
663
+ not gate. The summary line and the per-variable pills read this flag to
664
+ keep an advisory failure out of the red verdict and report it as a chip
665
+ beside it instead.
666
+ """
667
+ if node.get("kind") == "leaf":
668
+ node["advisory"] = under_warn
669
+ return
670
+ warn = under_warn or node.get("combinator") == "warn"
671
+ for child in node.get("children") or []:
672
+ _mark_advisory(child, under_warn=warn)
673
+
674
+
675
+ def _tree_summary(view: dict | None) -> dict | None:
676
+ """The pieces the summary line is built from: the leaf tally, the ROOT
677
+ verdict, and how many advisory leaves failed.
678
+
679
+ The headline colour follows the ROOT verdict, not the leaf tally — a
680
+ warn-wrapped failure painting the summary red reads as "this test
681
+ failed" for a finding that is advisory by construction. ``root_passed``
682
+ is tri-state: ``None`` means there is no verdict to show.
683
+ """
684
+ if not view:
685
+ return None
686
+ leaves = _view_leaves(view)
687
+ return {
688
+ "n_pass": sum(1 for leaf in leaves if leaf.get("passed")),
689
+ "n_total": len(leaves),
690
+ "root_passed": view.get("passed"),
691
+ "n_advisory_failed": sum(
692
+ 1 for leaf in leaves if leaf.get("advisory") and not leaf.get("passed")
693
+ ),
694
+ }
695
+
696
+
697
+ def _spec_for(comparisons: list, test_model):
698
+ """The tree the view walks: the authored one, else the implicit tree
699
+ synthesized from what was scored. Both the tree view and the
700
+ per-variable entries read it, so they agree by construction."""
701
+ from ..comparison.tree_spec import (
702
+ synthesize_implicit_tree as _synthesize_implicit_tree,
703
+ )
704
+
705
+ if (
706
+ test_model is not None
707
+ and getattr(test_model, "metric_tree_spec", None) is not None
708
+ ):
709
+ return test_model.metric_tree_spec
710
+ comparison_var_names = [vc.name for vc in comparisons]
711
+ return _synthesize_implicit_tree(
712
+ comparison_var_names,
713
+ variable_overrides=(
714
+ getattr(test_model, "variable_overrides", None) if test_model else None
715
+ ),
716
+ )
717
+
718
+
719
+ def build_tree_view(
720
+ comparisons: list,
721
+ test_model,
722
+ metric_tree,
723
+ texts: dict[str, VariableText] | None = None,
724
+ base_tolerance: tuple[float, str] | None = None,
725
+ ) -> dict | None:
726
+ """The read-only metric tree as the report renders it — the spec's shape
727
+ (the authored tree, else the implicit one synthesized from what was
728
+ scored) with each leaf carrying the CLI's verdict, ``summarize``'s
729
+ display / criterion / details, the sentinel reason and the advisory
730
+ flag. ``None`` when nothing was compared. The authoring shell shows the
731
+ same dict, built by this function.
732
+
733
+ ``texts`` is the TEST's label scope (:func:`test_texts`) — the scored
734
+ variables are only part of it, and a caller that passes none gets each
735
+ leaf's plain label. Both tiers pass one: a criteria row read ``a.Q``
736
+ under a card titled ``a.Q (x)`` when the scope was the scored list
737
+ alone (the b147918 review)."""
738
+ if not comparisons:
739
+ return None
740
+
741
+ from ..comparison.tree_eval import flatten_evaluation as _flatten_evaluation
742
+ from ..comparison.tree_spec import (
743
+ spec_to_view as _spec_to_view,
744
+ )
745
+
746
+ spec = _spec_for(comparisons, test_model)
747
+ eval_by_path = _flatten_evaluation(metric_tree) if metric_tree is not None else {}
748
+
749
+ # Leaves are addressed by the path a patch op would use — stamped by the
750
+ # evaluator on both evaluation routes — never by position in the flat
751
+ # comparison list.
752
+ from ..comparison.tree_eval import leaf_comparisons_by_path
753
+
754
+ comparisons_by_path = (
755
+ leaf_comparisons_by_path(metric_tree) if metric_tree is not None else {}
756
+ )
757
+
758
+ tree_view = _spec_to_view(spec, evaluation_by_path=eval_by_path)
759
+ # The view walks the spec; the lookup holds what the evaluator stamped.
760
+ # Both routes stamp every leaf the view will show, so a miss is drift
761
+ # between the two walks — a leaf that would render with no pill and no
762
+ # criterion, and nothing else would say so.
763
+ missing = [
764
+ path for path in _view_leaf_paths(tree_view) if path not in comparisons_by_path
765
+ ]
766
+ if missing:
767
+ logger.warning(
768
+ "%s: %d spec leaf path(s) have no evaluated comparison and render "
769
+ "without scores: %s",
770
+ getattr(test_model, "model_id", "?"),
771
+ len(missing),
772
+ ", ".join(missing),
773
+ )
774
+ _augment_tree_view(tree_view, comparisons_by_path, texts, base_tolerance)
775
+ # Before the per-variable pills read it (`_build_tree_view_and_variables`)
776
+ # and before a plot's criteria rows share these leaf dicts.
777
+ _mark_advisory(tree_view)
778
+ return tree_view
779
+
780
+
781
+ def _build_tree_view_and_variables(
782
+ comparisons: list,
783
+ test_model,
784
+ metric_tree,
785
+ trajectories: list,
786
+ texts: dict[str, VariableText] | None = None,
787
+ ) -> tuple[dict | None, dict]:
788
+ """Stage-2 single-source-of-truth: returns ``(tree_view,
789
+ variables_by_name)``.
790
+
791
+ The reporter's recursive-component UI walks ``tree_view`` directly
792
+ (a serialized SpecNode tree with per-node paths + evaluation results,
793
+ leaves augmented with render artifacts). ``variables_by_name`` carries
794
+ one entry per unique variable — its trajectory + overlays + the leaf
795
+ paths targeting it + its verdict — and a plot's section lists its
796
+ subject's leaves of the same tree
797
+ (:func:`~dstf.reporting.tree_rows.criteria_rows`).
798
+
799
+ When there are no comparisons the function returns ``(None, {})`` —
800
+ sim-failed and no-baseline tests render without a tree view at all.
801
+ """
802
+ if not comparisons:
803
+ return None, {}
804
+
805
+ from ..comparison.tree_spec import (
806
+ collect_variables as _collect_variables,
807
+ )
808
+ from ..comparison.tree_spec import (
809
+ leaves_for_variable as _leaves_for_variable,
810
+ )
811
+
812
+ tree_view = build_tree_view(comparisons, test_model, metric_tree, texts)
813
+ if tree_view is None:
814
+ return None, {}
815
+ spec = _spec_for(comparisons, test_model)
816
+ all_leaves = _view_leaves(tree_view)
817
+
818
+ # Look the trajectory up by variable name — ``build_trajectories``
819
+ # emits exactly one entry per name, sentinel leaves included.
820
+ variables_by_name: dict[str, dict] = {}
821
+ for vn in _collect_variables(spec):
822
+ match_traj = next((t for t in trajectories if t["name"] == vn), None)
823
+ var_leaves = [leaf for leaf in all_leaves if leaf.get("variable") == vn]
824
+ # Only the leaves that can actually gate this variable decide its
825
+ # colour; advisory ones are reported beside it as a chip.
826
+ gating = [leaf for leaf in var_leaves if not leaf.get("advisory")]
827
+ variables_by_name[vn] = {
828
+ "name": vn,
829
+ # The variable's own label, from its trajectory (the result is
830
+ # what knows the expression); a variable with no trajectory —
831
+ # every leaf on it a sentinel — reads as its name. ``label``
832
+ # is for the status badge, which shows the muted name beside
833
+ # it; ``text`` for the plot note, which does not.
834
+ "label": (match_traj or {}).get("label") or vn,
835
+ "text": (match_traj or {}).get("text") or _text_of(texts, vn, ""),
836
+ "trajectory": match_traj or {},
837
+ "overlays": (match_traj or {}).get("overlays", []),
838
+ "leaf_paths": [p for _, p in _leaves_for_variable(spec, vn)],
839
+ "passed": bool(var_leaves)
840
+ and all(bool(leaf.get("passed")) for leaf in gating),
841
+ "n_advisory_failed": sum(
842
+ 1
843
+ for leaf in var_leaves
844
+ if leaf.get("advisory") and not leaf.get("passed")
845
+ ),
846
+ }
847
+ return tree_view, variables_by_name
848
+
849
+
850
+ # ---------------------------------------------------------------------------
851
+ # Orchestrator
852
+ # ---------------------------------------------------------------------------
853
+
854
+
855
+ def _build_template_context(
856
+ model_id: str,
857
+ comparisons: list[VariableComparison],
858
+ ref_data: dict | None,
859
+ cur_stats: dict | None,
860
+ test_dir: Path | None,
861
+ test_model=None,
862
+ result=None,
863
+ ref_file: Path | None = None,
864
+ warnings: list | None = None,
865
+ last_run_at: float | None = None,
866
+ metric_tree=None,
867
+ artifact_files: tuple[tuple[str, str], ...] = (),
868
+ overlays: list | None = None,
869
+ run_metadata: dict | None = None,
870
+ plot_series: Iterable[str] = (),
871
+ ) -> dict:
872
+ """Build the full template context dict from comparison data.
873
+
874
+ Orchestrator over the per-section ``_build_*`` helpers above. Each
875
+ output key in the returned dict is produced by exactly one helper.
876
+
877
+ ``plot_series`` is every series name of the test's resolved plots,
878
+ plot-only ones included: they are part of the page's label scope
879
+ (:func:`test_texts`), so a plot-only variable NAMED like another's
880
+ expression is what makes that other one read its name too.
881
+ """
882
+ if cur_stats is None:
883
+ cur_stats = {}
884
+ ref_stats = ref_data.get("statistics", {}) if ref_data else {}
885
+ ref_sim = ref_data.get("simulation", {}) if ref_data else {}
886
+
887
+ ref_info = _build_ref_info(comparisons, ref_data, test_dir, ref_file)
888
+ sim_params = _build_sim_params(test_model, ref_sim)
889
+ run_diagnostics = (result.diagnostics or []) if result is not None else []
890
+ statistics_sections = _build_statistics_sections(
891
+ ref_stats, cur_stats, run_diagnostics=run_diagnostics
892
+ )
893
+ texts = test_texts(comparisons, result, plot_series)
894
+ trajectories, diag_trajectories, nobaseline_trajectories = build_trajectories(
895
+ comparisons,
896
+ result,
897
+ ref_data,
898
+ texts,
899
+ )
900
+ diag_summaries = _build_diag_summaries(result, ref_data)
901
+ artifacts = _build_artifacts(test_dir, artifact_files)
902
+ n_passed, _n_nobaseline, is_simulate_only, sim_failed = _compute_summary_flags(
903
+ comparisons,
904
+ result,
905
+ test_model,
906
+ )
907
+ key_stats = _build_key_stats(
908
+ comparisons, ref_stats, cur_stats, run_diagnostics=run_diagnostics
909
+ )
910
+ warning_rows = _build_warning_rows(warnings)
911
+
912
+ last_run_str = ""
913
+ if last_run_at:
914
+ from datetime import datetime
915
+
916
+ last_run_str = datetime.fromtimestamp(last_run_at).isoformat(timespec="seconds")
917
+
918
+ # Stamp overlays onto each trajectory by variable name. The nobaseline
919
+ # list gets the same pass so NO_REF tests can still show
920
+ # sibling-backend / companion / soft_check overlays — useful for the
921
+ # pre-accept cross-check story on a brand-new backend / OS. Missing
922
+ # or invalid overlays drop off the per-plot path but stay in
923
+ # ``overlay_rows`` so the report surfaces them as "not rendered".
924
+ from .overlay_loader import attach_overlays_to_trajectories, overlay_summary
925
+
926
+ overlays = overlays or []
927
+ attach_overlays_to_trajectories(trajectories, overlays)
928
+ attach_overlays_to_trajectories(nobaseline_trajectories, overlays)
929
+ overlay_rows = overlay_summary(overlays)
930
+
931
+ tree_view, variables_by_name = _build_tree_view_and_variables(
932
+ comparisons,
933
+ test_model,
934
+ metric_tree,
935
+ trajectories,
936
+ texts,
937
+ )
938
+
939
+ return {
940
+ "model_id": model_id,
941
+ "n_passed": n_passed,
942
+ "sim_failed": sim_failed,
943
+ "is_simulate_only": is_simulate_only,
944
+ "last_run_at": last_run_at,
945
+ "last_run_str": last_run_str,
946
+ "warnings": warning_rows,
947
+ "key_stats": key_stats,
948
+ "ref_info": ref_info,
949
+ "sim_params": sim_params,
950
+ "statistics_sections": statistics_sections,
951
+ "diagnostic_summaries": diag_summaries,
952
+ "artifacts": artifacts,
953
+ "trajectories": trajectories,
954
+ "diag_trajectories": diag_trajectories,
955
+ "nobaseline_trajectories": nobaseline_trajectories,
956
+ # The TEST's label scope, stamped once and read by every surface
957
+ # that says a variable's name (`report_suite._context_labels`).
958
+ # Read back off the entries that carry a label of their own — the
959
+ # scored variables and the no-baseline trajectories — it was the
960
+ # scope of those entries, not of the test: a TRACKED variable no
961
+ # criterion scores is in neither list, so its heading, its "is plot
962
+ # only" note, its legend entry and the decimation banner all read
963
+ # its bare `unitTests.x[2]` while the tool read `a.Q (unitTests.x[2])`.
964
+ "variable_texts": {
965
+ name: {"label": entry.label, "text": entry.text}
966
+ for name, entry in texts.items()
967
+ },
968
+ # A2 / idea #50 — companion + soft_check overlays (default off).
969
+ "overlay_rows": overlay_rows,
970
+ # Stage 2 — recursive tree view + per-variable plot grouping.
971
+ "tree_view": tree_view,
972
+ "variables_by_name": variables_by_name,
973
+ # Pieces of the server-rendered summary line — the template builds
974
+ # the markup, so nothing here has to be trusted as HTML.
975
+ "tree_summary": _tree_summary(tree_view),
976
+ # Run provenance banner (backend/simulator/version/os). Same shape as
977
+ # the dashboard's, formatted by dashboard_render.format_run_metadata.
978
+ "run_metadata": run_metadata,
979
+ }