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