dynamic-systems-testing-framework 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
}
|