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/cli/review.py ADDED
@@ -0,0 +1,398 @@
1
+ """The interactive review loop (`run -i`): accept / skip / detail / plot."""
2
+
3
+ from ..backends.types import label_with_name
4
+ from ..discovery.test_registry import TestModel
5
+ from ..reporting.status import (
6
+ FAIL,
7
+ NO_REF,
8
+ PASS,
9
+ SIM_FAIL,
10
+ STATUS_LABELS,
11
+ advisory_text,
12
+ classify,
13
+ failed_criteria,
14
+ nothing_ran,
15
+ refusal,
16
+ )
17
+ from ..storage.accept import accept_run_results
18
+ from .common import _exit_code, _parse_review_filter, _should_review
19
+
20
+
21
+ def _interactive_review(
22
+ tests: list[TestModel],
23
+ results: dict,
24
+ comparisons: list,
25
+ store,
26
+ config,
27
+ review_filter: str = "all",
28
+ browser: bool = True,
29
+ ) -> int:
30
+ """Interactively review each test and accept/reject results. A plot a
31
+ review asks for is opened in a browser tab when ``browser``."""
32
+ from ..backends import resolve_variable_patterns
33
+
34
+ # review 2026-07-06 (found while testing finding 62): the old import
35
+ # `from .backends.dymola.mat_reader import read_dymola_mat` pointed at
36
+ # a module removed in the backends/common refactor, so EVERY -i
37
+ # invocation crashed with ModuleNotFoundError before the loop started.
38
+ # The [v] path only needs variable names — use the names-only reader.
39
+ from ..backends.common.mat_reader import list_result_mat_variables
40
+ from ..discovery.spec_parser import update_test_variables
41
+
42
+ # Deferred: run_cmds imports this module at module scope, so the
43
+ # dependency back on its backend selection has to resolve at call time.
44
+ from .run_cmds import _get_backend
45
+
46
+ filters = _parse_review_filter(review_filter)
47
+
48
+ test_lookup = {t.model_id: t for t in tests}
49
+ n_accepted = 0
50
+ n_skipped = 0
51
+ n_auto_skipped = 0
52
+ # review 2026-07-06 finding 62: one consistent exit rule — after the
53
+ # review loop ends (completed OR quit), exit 1 iff any test the loop
54
+ # passed over (reviewed, user-skipped, or auto-skipped) is still
55
+ # failing/sim-failed and was not accepted during this session; else 0.
56
+ # Tests never reached before a quit were neither reviewed nor skipped,
57
+ # so they don't count. A review in which nothing ran exits 1, as `run`
58
+ # does (D145 §5); it can only complete, since only a test that ran is
59
+ # offered a prompt to quit at.
60
+ n_unresolved = 0
61
+
62
+ def _still_failing(c) -> bool:
63
+ # A test NOT RUN has no result: it fails nothing (D145).
64
+ return c.ran and ((not c.sim_success) or not c.passed)
65
+
66
+ n_total = len(comparisons)
67
+
68
+ spec_path = config.spec_path
69
+
70
+ # ANSI colors
71
+ GREEN = "\033[92m"
72
+ RED = "\033[91m"
73
+ YELLOW = "\033[93m"
74
+ BOLD = "\033[1m"
75
+ RESET = "\033[0m"
76
+ status_colors = {PASS: GREEN, FAIL: RED, SIM_FAIL: RED, NO_REF: YELLOW}
77
+
78
+ filter_desc = f" (filter: {review_filter})" if "all" not in filters else ""
79
+ print(f"\nInteractive review: {n_total} tests{filter_desc}\n")
80
+
81
+ for idx, comp in enumerate(comparisons, 1):
82
+ if not _should_review(comp, filters):
83
+ n_auto_skipped += 1
84
+ if _still_failing(comp):
85
+ n_unresolved += 1
86
+ continue
87
+ model_id = comp.model_id
88
+
89
+ # Status line: the one classifier's status (`reporting.status`), as
90
+ # every other surface shows it — keyed on `has_reference` instead, a
91
+ # refused test with no baseline read "NO BASELINE" above its error.
92
+ state = classify(comp)
93
+ status = "NO BASELINE" if state == NO_REF else STATUS_LABELS[state]
94
+ if state in status_colors:
95
+ status = f"{status_colors[state]}{status}{RESET}"
96
+
97
+ warn = ""
98
+ if comp.warnings:
99
+ warn = f" {YELLOW}[{len(comp.warnings)} warning(s)]{RESET}"
100
+
101
+ if comp.test_id:
102
+ id_tag = f"[{comp.test_id}] "
103
+ elif not comp.has_reference:
104
+ id_tag = "[new] "
105
+ else:
106
+ id_tag = ""
107
+ print(f"[{idx}/{n_total}] {BOLD}{id_tag}{model_id}{RESET}")
108
+ print(f" Status: {status}{warn}")
109
+
110
+ # A refused test's reason is its whole finding: there is no failing
111
+ # variable below to say it, and accepting it is refused.
112
+ refused = refusal(comp)
113
+ if comp.error_message and (not comp.sim_success or refused):
114
+ print(f" Error: {comp.error_message}")
115
+
116
+ # Its criteria in brief — `comp.variables` holds one comparison per
117
+ # CRITERION (two on one variable are two): the Criteria column's
118
+ # count, under its name.
119
+ if comp.variables:
120
+ n_pass = sum(1 for v in comp.variables if v.passed)
121
+ # Its own name: as `n_total` it replaced the number of TESTS,
122
+ # and every later header read `[2/<the last test's criteria>]`.
123
+ n_criteria = len(comp.variables)
124
+ # What did not pass is read where the console's blocks and
125
+ # JUnit's lines read it: a failure under `warn` fails no test,
126
+ # and counted with the ones that do, this line said `2 failed`
127
+ # of a test JUnit called `1 criterion failed, with 1 advisory
128
+ # failure`.
129
+ not_passed = failed_criteria(comp)
130
+ n_advisory = sum(1 for c in not_passed if c.advisory)
131
+ said = [f"{n_pass}/{n_criteria} passed"]
132
+ if not comp.passed:
133
+ said.append(f"{len(not_passed) - n_advisory} failed")
134
+ if n_advisory:
135
+ said.append(advisory_text(n_advisory))
136
+ print(f" Criteria: {', '.join(said)}")
137
+
138
+ # Can't accept if simulation failed, or never ran
139
+ if not comp.sim_success:
140
+ print(f" Skipping ({'simulation failed' if comp.ran else 'not run'})\n")
141
+ n_skipped += 1
142
+ n_unresolved += _still_failing(comp)
143
+ continue
144
+
145
+ # Prompt
146
+ result = results.get(model_id)
147
+ has_result = result is not None and result.success
148
+ can_accept = has_result and not refused
149
+ test = test_lookup.get(model_id)
150
+ added_patterns = [] # Track patterns added via [v] this session
151
+
152
+ while True:
153
+ if can_accept:
154
+ prompt = " [a]ccept [v]ariables [s]kip [d]etail [p]lot [q]uit > "
155
+ elif has_result:
156
+ prompt = " [v]ariables [s]kip [d]etail [p]lot [q]uit > "
157
+ else:
158
+ prompt = " [s]kip [d]etail [p]lot [q]uit > "
159
+ try:
160
+ choice = input(prompt).strip().lower()
161
+ except (EOFError, KeyboardInterrupt):
162
+ print("\nAborted.")
163
+ return 1
164
+
165
+ if choice == "a" and can_accept:
166
+ # If variables were added, re-read the .mat to resolve them
167
+ if added_patterns:
168
+ test.variable_patterns = list(
169
+ set(test.variable_patterns + added_patterns)
170
+ )
171
+ test_key = _find_test_key(model_id, config)
172
+ if test_key:
173
+ backend = _get_backend(config)
174
+ # The run's statistics and timing come back
175
+ # from the file it kept beside the result.
176
+ result = backend.read_run_result(test, test_key, None)
177
+
178
+ # The one accept path: `accept_run_results` refuses here
179
+ # exactly what `compare --accept` refuses — a poisoned
180
+ # trajectory (leftover B: it must not crash the loop) and a
181
+ # test the last run left pending — and stores no baseline for
182
+ # a simulate-only test, which it names in a note: neither
183
+ # stored nor refused.
184
+ # `test` is never missing on the `[a]` path: `cmd_run` hands the
185
+ # same `scope_tests` to `compare_all` (one comparison per test)
186
+ # and to this function, and `RunOutcome.results` is keyed by
187
+ # exactly those models — so a model with a comparison and a
188
+ # result has an entry in `test_lookup`.
189
+ stored, skipped, warnings, notes = accept_run_results(
190
+ store, [test], {test.model_id: result}, config.work_dir
191
+ )
192
+ if stored:
193
+ n_vars = len(result.variables) if result.variables else 0
194
+ print(f" {GREEN}Accepted ({n_vars} variables).{RESET}")
195
+ # A large baseline file: stored, and said.
196
+ for line in warnings:
197
+ print(f" {YELLOW}{line}{RESET}")
198
+ print()
199
+ n_accepted += 1
200
+ else:
201
+ if skipped:
202
+ print(f" {RED}Not accepted: {skipped[0][1]}{RESET}\n")
203
+ else:
204
+ # A simulate-only test: nothing was refused, and
205
+ # there is no baseline to store — the accept's own
206
+ # line, as `--accept` prints it.
207
+ for line in notes:
208
+ print(f" {line}")
209
+ print()
210
+ n_skipped += 1
211
+ if _still_failing(comp):
212
+ n_unresolved += 1
213
+ break
214
+
215
+ elif choice == "v" and has_result:
216
+ try:
217
+ raw = input(" Variable patterns (comma-separated): ").strip()
218
+ except (EOFError, KeyboardInterrupt):
219
+ print("\nAborted.")
220
+ return 1
221
+ if raw:
222
+ new_patterns = [p.strip() for p in raw.split(",") if p.strip()]
223
+ added_patterns.extend(new_patterns)
224
+ # Save to test_spec.json
225
+ if spec_path:
226
+ update_test_variables(spec_path, model_id, new_patterns)
227
+ print(
228
+ f" Added {len(new_patterns)} pattern(s) to {spec_path.name}"
229
+ )
230
+ else:
231
+ print(
232
+ f" Added {len(new_patterns)} pattern(s) (in-memory only, no spec file configured)"
233
+ )
234
+ # Show what would match from the .mat
235
+ test_key = _find_test_key(model_id, config)
236
+ if test_key:
237
+ test_dir = config.work_dir / test_key
238
+ mat_path = test_dir / "dsres.mat"
239
+ if mat_path.exists():
240
+ available = list_result_mat_variables(mat_path)
241
+ if available:
242
+ matched = resolve_variable_patterns(
243
+ new_patterns, available
244
+ )
245
+ print(
246
+ f" Matched {len(matched)} variables: {', '.join(matched[:10])}"
247
+ )
248
+ if len(matched) > 10:
249
+ print(f" ... and {len(matched) - 10} more")
250
+
251
+ elif choice == "s":
252
+ print(" Skipped.\n")
253
+ n_skipped += 1
254
+ if _still_failing(comp):
255
+ n_unresolved += 1
256
+ break
257
+
258
+ elif choice == "d":
259
+ _print_detail(comp)
260
+
261
+ elif choice == "p":
262
+ _generate_and_open_plots(
263
+ model_id, comp, result, store, config, test, browser=browser
264
+ )
265
+
266
+ elif choice == "q":
267
+ auto = f", {n_auto_skipped} auto-skipped" if n_auto_skipped else ""
268
+ print(f"\nStopped. Accepted {n_accepted}, skipped {n_skipped}{auto}.")
269
+ # review 2026-07-06 finding 62: quit uses the same rule as
270
+ # completion — unresolved failures seen so far decide.
271
+ return 1 if n_unresolved else 0
272
+
273
+ else:
274
+ options = (
275
+ "a/v/s/d/p/q"
276
+ if can_accept
277
+ else "v/s/d/p/q"
278
+ if has_result
279
+ else "s/d/p/q"
280
+ )
281
+ print(f" Invalid choice. Enter {options}.")
282
+
283
+ auto = f", {n_auto_skipped} auto-skipped" if n_auto_skipped else ""
284
+ print(f"\nDone. Accepted {n_accepted}, skipped {n_skipped}{auto}.")
285
+ # review 2026-07-06 finding 62: see n_unresolved above. A review in
286
+ # which nothing ran exits as `run` does, saying so (D145 §5).
287
+ if nothing_ran(comparisons):
288
+ return _exit_code(comparisons)
289
+ return 1 if n_unresolved else 0
290
+
291
+
292
+ def _generate_and_open_plots(
293
+ model_id, comp, result, store, config, test=None, *, browser: bool = True
294
+ ) -> None:
295
+ """Generate comparison plots, print their link and — when ``browser`` —
296
+ open them in a browser tab."""
297
+ from ..reporting.report_suite import (
298
+ generate_comparison_plots,
299
+ open_in_browser,
300
+ plot_inputs,
301
+ )
302
+
303
+ test_key = _find_test_key(model_id, config)
304
+ if test_key:
305
+ plot_dir = config.work_dir / test_key / "plots"
306
+ else:
307
+ plot_dir = config.work_dir / "plots" / model_id.replace(".", "_")
308
+
309
+ ref_data = store.get_reference(model_id)
310
+
311
+ test_dir = config.work_dir / test_key if test_key else None
312
+
313
+ # Resolve reference file path for clickable link in report
314
+ ref_file = None
315
+ test_id = store.index.get_id(model_id)
316
+ if test_id:
317
+ from ..storage.ref_index import RefIndex
318
+
319
+ ref_file = store.ref_dir / RefIndex.ref_filename(test_id)
320
+
321
+ from ..backends import get_backend_class
322
+
323
+ try:
324
+ backend_cls = get_backend_class(config)
325
+ artifact_files = tuple(backend_cls.artifact_files)
326
+ except ValueError:
327
+ backend_cls = None
328
+ artifact_files = ()
329
+
330
+ # The page's layout, through the one helper the suite path uses.
331
+ plots, extra_series = plot_inputs(
332
+ test, comp.variables, result, backend_cls, test_dir
333
+ )
334
+
335
+ html_path = generate_comparison_plots(
336
+ model_id=model_id,
337
+ ref_data=ref_data,
338
+ result=result,
339
+ comparisons=comp.variables,
340
+ plot_dir=plot_dir,
341
+ test_dir=test_dir,
342
+ test_model=test,
343
+ ref_file=ref_file,
344
+ warnings=comp.warnings,
345
+ comparison=comp,
346
+ metric_tree=comp.metric_tree,
347
+ artifact_files=artifact_files,
348
+ max_embedded_samples=config.max_embedded_samples,
349
+ theme=config.theme,
350
+ plots=plots,
351
+ extra_series=extra_series,
352
+ )
353
+
354
+ if html_path:
355
+ print(f" Plots saved to {plot_dir}")
356
+ open_in_browser(html_path, launch=browser)
357
+ else:
358
+ print(" Plot generation failed")
359
+
360
+
361
+ def _find_test_key(model_id: str, config) -> str | None:
362
+ """Find the test_NNNN key for a model from the batch manifest."""
363
+ from ..backends import BatchManifest
364
+
365
+ mp = config.work_dir / "batch_manifest.json"
366
+ if not mp.exists():
367
+ return None
368
+ bm = BatchManifest.load(mp)
369
+ for tk, entry in bm.manifest.items():
370
+ if entry["model_id"] == model_id:
371
+ return tk
372
+ return None
373
+
374
+
375
+ def _print_detail(comp) -> None:
376
+ """Print each leaf's mode-aware verdict for interactive review."""
377
+ from ..comparison.registry import summarize
378
+
379
+ if comp.error_message:
380
+ print(f" Error: {comp.error_message}")
381
+
382
+ for var in comp.variables:
383
+ name = label_with_name(var.name, var.expression)
384
+ status = "\033[92mPASS\033[0m" if var.passed else "\033[91mFAIL\033[0m"
385
+ summary = summarize(var)
386
+ # The flat-route unknown-mode sentinel carries mode="" on purpose
387
+ # (it must not be captioned "nrmse"); print no bracket for it.
388
+ mode = f"[{var.mode}] " if var.mode else ""
389
+ print(f" {status} {name} {mode}{summary.display}")
390
+ print(f" {summary.criterion}")
391
+ for key, value in summary.details:
392
+ print(f" {key}: {value}")
393
+
394
+ if comp.warnings:
395
+ print(" Structural warnings:")
396
+ for w in comp.warnings:
397
+ print(f" {w.text}")
398
+ print()