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,868 @@
|
|
|
1
|
+
"""Evaluate a parsed MetricTree spec against simulation + reference data.
|
|
2
|
+
|
|
3
|
+
Takes a :class:`SpecNode` from :mod:`tree_spec` plus the per-variable
|
|
4
|
+
simulation result and reference dicts, and walks the tree to produce an
|
|
5
|
+
evaluated :class:`MetricResult` root.
|
|
6
|
+
|
|
7
|
+
Leaves evaluate via the existing :func:`resolve_mode` factory — the spec
|
|
8
|
+
is just a declarative envelope around the same per-variable comparison
|
|
9
|
+
that :func:`compare_test` already does in the implicit-AND path. Internal
|
|
10
|
+
nodes look ``spec.combinator`` up in :mod:`registry` to get the concrete
|
|
11
|
+
``Combinator`` from :mod:`metric_tree`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ..backends import VariableResult
|
|
22
|
+
from ..backends.common.statistics import read_summary
|
|
23
|
+
from ..backends.types import DatasetType
|
|
24
|
+
from .comparator import VariableComparison, migration_hint
|
|
25
|
+
from .geometry import Window
|
|
26
|
+
from .metric_tree import (
|
|
27
|
+
Combinator,
|
|
28
|
+
MetricResult,
|
|
29
|
+
leaf_from_variable,
|
|
30
|
+
stamp_leaf_path,
|
|
31
|
+
)
|
|
32
|
+
from .modes import ComparisonMode, resolve_mode
|
|
33
|
+
from .registry import DEFAULT_METRIC, MetricDescriptor
|
|
34
|
+
from .registry import combinator as registry_combinator
|
|
35
|
+
from .registry import metric as registry_metric
|
|
36
|
+
from .sides import Sides, parse_sides
|
|
37
|
+
from .tree_spec import CombinatorSpec, LeafSpec, SpecNode, resolves_a_variable
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
PRIMARY_BASELINE = "primary"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class BaselineView:
|
|
46
|
+
"""Pre-processed view of one baseline for leaf evaluation.
|
|
47
|
+
|
|
48
|
+
Packages the per-variable reference dicts and the shared time vector
|
|
49
|
+
(or ``None`` when each variable carries its own time). Multiple named
|
|
50
|
+
baselines coexist in the evaluator's input (``{"primary": ...,
|
|
51
|
+
"experiment": ...}``); a leaf's ``against`` field picks which one it
|
|
52
|
+
scores against.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
ref_vars_by_name: dict[str, dict]
|
|
57
|
+
shared_ref_time: np.ndarray | None = None
|
|
58
|
+
#: Statistics captured with the baseline (dotted keys like
|
|
59
|
+
#: ``translation.scalar_unknowns``) and the stored diagnostics summaries
|
|
60
|
+
#: (``{"name", "final", "min", "max"}``) — the baseline side of a
|
|
61
|
+
#: ``scalar`` leaf's stats-path quantity.
|
|
62
|
+
statistics: dict = field(default_factory=dict)
|
|
63
|
+
diagnostics: list[dict] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ScalarSources:
|
|
68
|
+
"""The current run's stored scalars, for stats-path ``scalar`` leaves.
|
|
69
|
+
|
|
70
|
+
Mirrors :class:`BaselineView`'s two scalar fields for the actual side.
|
|
71
|
+
The difference is the diagnostics shape: a run's diagnostics are
|
|
72
|
+
:class:`~dstf.backends.VariableResult` trajectories, so ``final`` /
|
|
73
|
+
``min`` / ``max`` are computed live rather than read from a summary.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
statistics: dict = field(default_factory=dict)
|
|
77
|
+
diagnostics: list[VariableResult] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def evaluate_spec(
|
|
81
|
+
spec: SpecNode,
|
|
82
|
+
var_results_by_name: dict[str, VariableResult],
|
|
83
|
+
baselines: dict[str, BaselineView],
|
|
84
|
+
base_tolerance: float,
|
|
85
|
+
dataset_kind: DatasetType = DatasetType.TIME_SERIES,
|
|
86
|
+
scalars: ScalarSources | None = None,
|
|
87
|
+
path: str = "/metrics",
|
|
88
|
+
model_id: str = "",
|
|
89
|
+
_warned: set[tuple[str, str]] | None = None,
|
|
90
|
+
) -> MetricResult:
|
|
91
|
+
"""Walk a spec tree and produce an evaluated :class:`MetricResult` root.
|
|
92
|
+
|
|
93
|
+
``base_tolerance`` is used when a leaf's params omit ``tolerance`` (the
|
|
94
|
+
per-test / config / default chain resolved by the caller).
|
|
95
|
+
|
|
96
|
+
``baselines`` maps baseline name (``"primary"``, ``"experiment"``, ...)
|
|
97
|
+
to a :class:`BaselineView`; each leaf's ``against`` field picks which
|
|
98
|
+
one it scores against, defaulting to ``"primary"``.
|
|
99
|
+
|
|
100
|
+
``dataset_kind`` is the :class:`~dstf.backends.types.DatasetType` the
|
|
101
|
+
supplied results carry. Every backend produces ``TIME_SERIES`` today,
|
|
102
|
+
so it is a parameter rather than a field on ``VariableResult`` — a leaf
|
|
103
|
+
whose metric doesn't accept the kind hard-fails with a diagnostic.
|
|
104
|
+
|
|
105
|
+
``scalars`` carries the run's statistics + diagnostics for ``scalar``
|
|
106
|
+
leaves whose quantity is a stats path; leaves that reduce a tracked
|
|
107
|
+
series never touch it.
|
|
108
|
+
|
|
109
|
+
``path`` is this node's RFC 6902 path; leaves are stamped with it
|
|
110
|
+
(``stamp_leaf_path``) so results address the spec, not a position in a
|
|
111
|
+
flat list.
|
|
112
|
+
|
|
113
|
+
``model_id`` only names the test in the migrate-hint log line: a suite
|
|
114
|
+
of tests printing the same nameless sentence says how many baselines
|
|
115
|
+
are un-migrated and not one of the models whose baseline to open.
|
|
116
|
+
"""
|
|
117
|
+
sources = scalars if scalars is not None else ScalarSources()
|
|
118
|
+
# The migrate hint is a fact about a VARIABLE's baseline entry, not
|
|
119
|
+
# about a leaf: a TRANSFORM test with five criteria on one un-migrated
|
|
120
|
+
# variable printed the same sentence five times. The set is created by
|
|
121
|
+
# the outermost call and threaded down, so it spans one test. Keyed by
|
|
122
|
+
# the baseline too — the same variable missing from the primary AND
|
|
123
|
+
# from a soft_check is two things to fix, not one repeat.
|
|
124
|
+
seen = _warned if _warned is not None else set()
|
|
125
|
+
if isinstance(spec, LeafSpec):
|
|
126
|
+
result = _evaluate_leaf(
|
|
127
|
+
spec,
|
|
128
|
+
var_results_by_name,
|
|
129
|
+
baselines,
|
|
130
|
+
base_tolerance,
|
|
131
|
+
dataset_kind,
|
|
132
|
+
sources,
|
|
133
|
+
seen,
|
|
134
|
+
model_id,
|
|
135
|
+
)
|
|
136
|
+
stamp_leaf_path(result, path)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
children = [
|
|
140
|
+
evaluate_spec(
|
|
141
|
+
c,
|
|
142
|
+
var_results_by_name,
|
|
143
|
+
baselines,
|
|
144
|
+
base_tolerance,
|
|
145
|
+
dataset_kind,
|
|
146
|
+
sources,
|
|
147
|
+
path=f"{path}/children/{i}",
|
|
148
|
+
model_id=model_id,
|
|
149
|
+
_warned=seen,
|
|
150
|
+
)
|
|
151
|
+
for i, c in enumerate(spec.children)
|
|
152
|
+
]
|
|
153
|
+
return _build_combinator(spec).combine(children)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def collect_leaf_variables(tree: MetricResult) -> list[VariableComparison]:
|
|
157
|
+
"""Return the ``VariableComparison`` stashed on each leaf, in tree order.
|
|
158
|
+
|
|
159
|
+
``leaf_from_variable`` puts the originating ``VariableComparison`` in
|
|
160
|
+
``diagnostics['variable']``. This helper walks the tree and collects
|
|
161
|
+
them so the reporter (which still takes a flat list) sees the
|
|
162
|
+
variables referenced by the user's tree.
|
|
163
|
+
"""
|
|
164
|
+
out: list[VariableComparison] = []
|
|
165
|
+
_walk_leaves(tree, out)
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def leaf_comparisons_by_path(tree: MetricResult) -> dict[str, VariableComparison]:
|
|
170
|
+
"""``{leaf_path: VariableComparison}`` for every stamped leaf, in tree order.
|
|
171
|
+
|
|
172
|
+
The report and the figure layer look leaves up here, by the path a patch
|
|
173
|
+
op would use — never by position in a flat list.
|
|
174
|
+
"""
|
|
175
|
+
out: dict[str, VariableComparison] = {}
|
|
176
|
+
|
|
177
|
+
def _walk(node: MetricResult) -> None:
|
|
178
|
+
if not node.children:
|
|
179
|
+
vc = node.diagnostics.get("variable")
|
|
180
|
+
path = node.diagnostics.get("leaf_path")
|
|
181
|
+
if vc is not None and path is not None:
|
|
182
|
+
out[path] = vc
|
|
183
|
+
return
|
|
184
|
+
for child in node.children:
|
|
185
|
+
_walk(child)
|
|
186
|
+
|
|
187
|
+
_walk(tree)
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def advisory_comparisons(tree: MetricResult | None) -> list[VariableComparison]:
|
|
192
|
+
"""The leaf comparisons under a ``warn``, in tree order — the same
|
|
193
|
+
objects :func:`collect_leaf_variables` returns. ``warn`` never fails its
|
|
194
|
+
parent, so a failure among them informs and cannot fail the test: a
|
|
195
|
+
surface that counts failures leaves these out, or calls them advisory.
|
|
196
|
+
``[]`` for no tree.
|
|
197
|
+
|
|
198
|
+
A ``warn`` node is the one whose diagnostics carry ``warned``
|
|
199
|
+
(``WarnCombinator.combine`` always sets it, to either value)."""
|
|
200
|
+
out: list[VariableComparison] = []
|
|
201
|
+
|
|
202
|
+
def _walk(node: MetricResult, under_warn: bool) -> None:
|
|
203
|
+
if not node.children:
|
|
204
|
+
if under_warn:
|
|
205
|
+
_walk_leaves(node, out)
|
|
206
|
+
return
|
|
207
|
+
for child in node.children:
|
|
208
|
+
_walk(child, under_warn or "warned" in (node.diagnostics or {}))
|
|
209
|
+
|
|
210
|
+
if tree is not None:
|
|
211
|
+
_walk(tree, False)
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def failed_groups(tree: MetricResult | None) -> list[tuple[str, MetricResult]]:
|
|
216
|
+
"""The groups that fail ON THEIR OWN: a combinator that did not pass
|
|
217
|
+
though every child of it did — a ``weighted`` whose sum is on the wrong
|
|
218
|
+
side of its threshold, or undefined — as ``(place, node)`` in tree
|
|
219
|
+
order, the place as a parse error writes it (``metrics.children[1]``).
|
|
220
|
+
None from under a ``warn``: nothing there can fail the test. ``[]`` for
|
|
221
|
+
no tree.
|
|
222
|
+
|
|
223
|
+
A test can FAIL with no failing criterion; it then fails at one of
|
|
224
|
+
these, and a surface that lists what failed has only them to name."""
|
|
225
|
+
out: list[tuple[str, MetricResult]] = []
|
|
226
|
+
|
|
227
|
+
def _walk(node: MetricResult, place: str) -> None:
|
|
228
|
+
if not node.children or "warned" in (node.diagnostics or {}):
|
|
229
|
+
return
|
|
230
|
+
if not node.passed and all(child.passed for child in node.children):
|
|
231
|
+
out.append((place, node))
|
|
232
|
+
for i, child in enumerate(node.children):
|
|
233
|
+
_walk(child, f"{place}.children[{i}]")
|
|
234
|
+
|
|
235
|
+
if tree is not None:
|
|
236
|
+
_walk(tree, "metrics")
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def flatten_evaluation(
|
|
241
|
+
tree: MetricResult,
|
|
242
|
+
*,
|
|
243
|
+
root: str = "/metrics",
|
|
244
|
+
) -> dict[str, dict]:
|
|
245
|
+
"""Flatten an evaluated MetricResult tree into a ``{path: {...}}`` dict.
|
|
246
|
+
|
|
247
|
+
The returned mapping is keyed by the same JSON-Pointer paths that
|
|
248
|
+
:func:`tree_spec.collect_leaf_paths` / :func:`tree_spec.spec_to_view`
|
|
249
|
+
produce, so the reporter can merge evaluation results into a spec
|
|
250
|
+
view by path lookup.
|
|
251
|
+
|
|
252
|
+
Each entry carries ``passed``, ``score``, ``label``, plus any diagnostics
|
|
253
|
+
the evaluator stashed on that node (``mode``, ``tolerance``,
|
|
254
|
+
``max_abs_error``, ``against``, ``window``, ``n_passed``, ``warned``, ...).
|
|
255
|
+
"""
|
|
256
|
+
out: dict[str, dict] = {}
|
|
257
|
+
_flatten_evaluation(tree, root, out)
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _flatten_evaluation(
|
|
262
|
+
node: MetricResult,
|
|
263
|
+
path: str,
|
|
264
|
+
out: dict[str, dict],
|
|
265
|
+
) -> None:
|
|
266
|
+
entry = {
|
|
267
|
+
"passed": bool(node.passed),
|
|
268
|
+
"score": node.score,
|
|
269
|
+
"label": node.label,
|
|
270
|
+
}
|
|
271
|
+
diag = node.diagnostics or {}
|
|
272
|
+
# Copy JSON-safe diagnostic keys (skip the stashed VariableComparison).
|
|
273
|
+
for key, val in diag.items():
|
|
274
|
+
if key == "variable":
|
|
275
|
+
continue
|
|
276
|
+
entry[key] = val
|
|
277
|
+
out[path] = entry
|
|
278
|
+
for i, child in enumerate(node.children):
|
|
279
|
+
_flatten_evaluation(child, f"{path}/children/{i}", out)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def to_view(tree: MetricResult) -> dict:
|
|
283
|
+
"""Serialize a ``MetricResult`` tree into a JSON-safe nested dict
|
|
284
|
+
for Jinja consumption. Shape is render-oriented — no simulation data,
|
|
285
|
+
no numpy arrays.
|
|
286
|
+
|
|
287
|
+
Fields per node:
|
|
288
|
+
* ``kind`` — "leaf" | "combinator"
|
|
289
|
+
* ``passed`` — bool
|
|
290
|
+
* ``label`` — human-readable (variable name for leaves,
|
|
291
|
+
"and[N]" / "or[N]" / "warn" / "k-of-n[K/N]" for combinators)
|
|
292
|
+
* ``score`` — float or None
|
|
293
|
+
* ``mode`` — metric mode on leaves (nrmse / tube / points)
|
|
294
|
+
* ``tolerance`` — on leaves, the tolerance applied
|
|
295
|
+
* ``warned`` — True on ``warn`` nodes whose child failed
|
|
296
|
+
* ``children`` — recursive list (empty for leaves)
|
|
297
|
+
"""
|
|
298
|
+
node: dict = {
|
|
299
|
+
"kind": "leaf" if not tree.children else "combinator",
|
|
300
|
+
"passed": bool(tree.passed),
|
|
301
|
+
"label": tree.label,
|
|
302
|
+
"score": tree.score,
|
|
303
|
+
"children": [to_view(c) for c in tree.children],
|
|
304
|
+
}
|
|
305
|
+
diag = tree.diagnostics or {}
|
|
306
|
+
if not tree.children:
|
|
307
|
+
# Leaf diagnostics come from leaf_from_variable + _evaluate_leaf
|
|
308
|
+
if "mode" in diag:
|
|
309
|
+
node["mode"] = diag["mode"]
|
|
310
|
+
if "tolerance" in diag:
|
|
311
|
+
node["tolerance"] = diag["tolerance"]
|
|
312
|
+
if "max_abs_error" in diag:
|
|
313
|
+
node["max_abs_error"] = diag["max_abs_error"]
|
|
314
|
+
if "against" in diag:
|
|
315
|
+
node["against"] = diag["against"]
|
|
316
|
+
else:
|
|
317
|
+
# Combinator diagnostics: n_failed / n_passed / k / n / warned
|
|
318
|
+
for key in ("n_failed", "n_passed", "k", "n", "warned"):
|
|
319
|
+
if key in diag:
|
|
320
|
+
node[key] = diag[key]
|
|
321
|
+
return node
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
# Internals
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _walk_leaves(node: MetricResult, out: list[VariableComparison]) -> None:
|
|
330
|
+
if not node.children:
|
|
331
|
+
vc = node.diagnostics.get("variable")
|
|
332
|
+
if vc is not None:
|
|
333
|
+
out.append(vc)
|
|
334
|
+
return
|
|
335
|
+
for child in node.children:
|
|
336
|
+
_walk_leaves(child, out)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _build_combinator(spec: CombinatorSpec) -> Combinator:
|
|
340
|
+
desc = registry_combinator(spec.combinator)
|
|
341
|
+
if desc is None:
|
|
342
|
+
# tree_spec validates on parse; this is just a defensive fallback.
|
|
343
|
+
raise ValueError(f"Unknown combinator: {spec.combinator!r}")
|
|
344
|
+
return desc.build(spec)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _evaluate_leaf(
|
|
348
|
+
leaf: LeafSpec,
|
|
349
|
+
var_results_by_name: dict[str, VariableResult],
|
|
350
|
+
baselines: dict[str, BaselineView],
|
|
351
|
+
base_tolerance: float,
|
|
352
|
+
dataset_kind: DatasetType = DatasetType.TIME_SERIES,
|
|
353
|
+
scalars: ScalarSources | None = None,
|
|
354
|
+
warned: set[tuple[str, str]] | None = None,
|
|
355
|
+
model_id: str = "",
|
|
356
|
+
) -> MetricResult:
|
|
357
|
+
# Own set when called outside a walk: one leaf, one line, never silence.
|
|
358
|
+
warned = set() if warned is None else warned
|
|
359
|
+
desc = registry_metric(leaf.metric)
|
|
360
|
+
quantity = leaf.params.get("quantity")
|
|
361
|
+
# A stored-scalar leaf reads its numbers from the run's statistics or
|
|
362
|
+
# diagnostics, so it resolves no variable of the result and has no
|
|
363
|
+
# expression to carry. Every other leaf names one, and carries its
|
|
364
|
+
# expression onto whatever comparison it returns — a leaf that fails
|
|
365
|
+
# before it ever scores (a lost baseline, a misconfigured metric) is
|
|
366
|
+
# exactly the one a person reads, so it must not fall back to the bare
|
|
367
|
+
# name (the Task 3 review). ONE definition of "resolves no variable",
|
|
368
|
+
# shared with the spec walk the expression soft-check reads.
|
|
369
|
+
scores_stored_scalar = not resolves_a_variable(leaf)
|
|
370
|
+
var_result = (
|
|
371
|
+
None if scores_stored_scalar else var_results_by_name.get(leaf.variable)
|
|
372
|
+
)
|
|
373
|
+
expression = var_result.expression if var_result is not None else ""
|
|
374
|
+
|
|
375
|
+
#: The baseline this leaf reads: its `against`, once its mode is built
|
|
376
|
+
#: and is one that reads a baseline. None until then, and for a mode
|
|
377
|
+
#: that scores from its own config.
|
|
378
|
+
reads: str | None = None
|
|
379
|
+
|
|
380
|
+
def _leaf(vc: VariableComparison) -> MetricResult:
|
|
381
|
+
vc.expression = expression
|
|
382
|
+
# The tolerance this leaf applies, on every return: one that fails
|
|
383
|
+
# before it scores (a variable the result lacks, a baseline the test
|
|
384
|
+
# does not have) reports the one it would have applied, never the
|
|
385
|
+
# comparison's 0.0 default.
|
|
386
|
+
vc.tolerance_used = tolerance
|
|
387
|
+
# A criterion whose config is an error read nothing, whenever the
|
|
388
|
+
# error was found: the flat path's sentinel says the same.
|
|
389
|
+
config_error = (vc.diagnostics or {}).get("config_error")
|
|
390
|
+
vc.against = None if config_error else reads
|
|
391
|
+
return leaf_from_variable(vc)
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
tolerance = _leaf_tolerance(leaf, base_tolerance)
|
|
395
|
+
except ValueError as exc:
|
|
396
|
+
# A tolerance that is neither a number nor a lower/upper pair fails
|
|
397
|
+
# its leaf, not the run (D107); the base is the one it reports as
|
|
398
|
+
# applied.
|
|
399
|
+
tolerance = Sides(float(base_tolerance), float(base_tolerance))
|
|
400
|
+
return _leaf(_leaf_config_error_comparison(leaf, exc))
|
|
401
|
+
|
|
402
|
+
# A metric declares which dataset kinds it can score. Unscorable input
|
|
403
|
+
# is a hard fail with a diagnostic naming both sides, not a crash inside
|
|
404
|
+
# the mode. Unreachable for the builtins (all accept TIME_SERIES, which
|
|
405
|
+
# is all any backend produces) — it guards registered metrics. After the
|
|
406
|
+
# expression is resolved, like every other early return: this sentinel
|
|
407
|
+
# is one a person reads, and it named the variable twice as differently
|
|
408
|
+
# as the bare name and the label (the Task 4 review).
|
|
409
|
+
if desc is not None and dataset_kind not in desc.accepted_datasets:
|
|
410
|
+
return _leaf(_incompatible_dataset_comparison(leaf, desc, dataset_kind))
|
|
411
|
+
|
|
412
|
+
override = _leaf_override_dict(leaf)
|
|
413
|
+
try:
|
|
414
|
+
mode = resolve_mode(override, tolerance, default_points=False)
|
|
415
|
+
except ValueError as exc:
|
|
416
|
+
# A misconfigured leaf fails that leaf, like the flat
|
|
417
|
+
# variable_overrides path — one bad spec entry must not abort the
|
|
418
|
+
# whole run with an anonymous traceback (D107).
|
|
419
|
+
return _leaf(_leaf_config_error_comparison(leaf, exc))
|
|
420
|
+
|
|
421
|
+
# leaf.against picks the named baseline. Missing name →
|
|
422
|
+
# hard fail with a clear label (loud on user-facing reports). Unless
|
|
423
|
+
# the leaf is baseline-free (range / declared-events / declared-peaks)
|
|
424
|
+
# — then we don't need any baseline at all (idea #59 / D83).
|
|
425
|
+
baseline = baselines.get(leaf.against)
|
|
426
|
+
reads = None if mode.is_baseline_free() else leaf.against
|
|
427
|
+
if baseline is None and not mode.is_baseline_free():
|
|
428
|
+
return _leaf(_missing_baseline_comparison(leaf))
|
|
429
|
+
|
|
430
|
+
windowed = False
|
|
431
|
+
if scores_stored_scalar and isinstance(quantity, str):
|
|
432
|
+
# A string ``quantity`` reads a stored scalar instead of a tracked
|
|
433
|
+
# series — where the data comes from is the evaluator's call, the
|
|
434
|
+
# same seam the window below occupies (a future ``{"expr": ...}``
|
|
435
|
+
# quantity extends this spot). The window is skipped deliberately:
|
|
436
|
+
# a stored scalar is not a time series.
|
|
437
|
+
#
|
|
438
|
+
# The switch is gated on the metric's ``accepts_quantity`` flag, not
|
|
439
|
+
# on the param alone: a stray ``quantity`` on an nrmse/tube/points
|
|
440
|
+
# leaf must leave that leaf scoring its trajectory. Ungated, both
|
|
441
|
+
# sides collapsed to one identical sample and a 2x-off signal passed.
|
|
442
|
+
resolved = _scalar_path_arrays(
|
|
443
|
+
leaf, quantity, mode, baseline, scalars or ScalarSources()
|
|
444
|
+
)
|
|
445
|
+
if isinstance(resolved, VariableComparison):
|
|
446
|
+
return _leaf(resolved)
|
|
447
|
+
ref_time, ref_values, act_time, act_values = resolved
|
|
448
|
+
index = 0
|
|
449
|
+
else:
|
|
450
|
+
ref_var = baseline.ref_vars_by_name.get(leaf.variable) if baseline else None
|
|
451
|
+
|
|
452
|
+
if var_result is None:
|
|
453
|
+
# Missing actual result is always a hard fail — no mode can score
|
|
454
|
+
# without the actual trajectory.
|
|
455
|
+
return _leaf(_missing_variable_comparison(leaf.variable, var_result))
|
|
456
|
+
|
|
457
|
+
if ref_var is None:
|
|
458
|
+
if not mode.is_baseline_free():
|
|
459
|
+
# A baseline that stores this variable under its EXPRESSION
|
|
460
|
+
# is un-migrated, not missing: the same clause the flat path
|
|
461
|
+
# logs, from the same helper, on the leaf's own reason as
|
|
462
|
+
# well — the tree path shows no log line to the person
|
|
463
|
+
# reading the report. Logged only when it applies; a
|
|
464
|
+
# variable simply new to the baseline is re-accepted. The
|
|
465
|
+
# REASON rides on every leaf; the LOG LINE is said once per
|
|
466
|
+
# (variable, baseline) — `warned` is the test's set.
|
|
467
|
+
hint = migration_hint(
|
|
468
|
+
baseline.ref_vars_by_name if baseline else {},
|
|
469
|
+
leaf.variable,
|
|
470
|
+
expression,
|
|
471
|
+
)
|
|
472
|
+
key = (leaf.against, leaf.variable)
|
|
473
|
+
if hint and key not in warned:
|
|
474
|
+
warned.add(key)
|
|
475
|
+
logger.warning(
|
|
476
|
+
"%s: baseline %r holds no variable named %r%s",
|
|
477
|
+
model_id or "?",
|
|
478
|
+
leaf.against,
|
|
479
|
+
leaf.variable,
|
|
480
|
+
hint,
|
|
481
|
+
)
|
|
482
|
+
return _leaf(
|
|
483
|
+
_missing_variable_comparison(leaf.variable, var_result, hint=hint)
|
|
484
|
+
)
|
|
485
|
+
ref_time = np.array([])
|
|
486
|
+
ref_values = np.array([])
|
|
487
|
+
else:
|
|
488
|
+
if baseline is not None and baseline.shared_ref_time is not None:
|
|
489
|
+
ref_time = baseline.shared_ref_time
|
|
490
|
+
else:
|
|
491
|
+
ref_time = np.array(ref_var["time"])
|
|
492
|
+
ref_values = np.array(ref_var["values"])
|
|
493
|
+
|
|
494
|
+
act_time = var_result.time
|
|
495
|
+
act_values = var_result.values
|
|
496
|
+
index = var_result.index
|
|
497
|
+
|
|
498
|
+
# Apply the leaf window (idea #46) — slice both sides uniformly before
|
|
499
|
+
# handing to mode.compare. Mode configs stay untouched; slicing is a
|
|
500
|
+
# cross-cutting leaf property. Skip the ref slice when there's no
|
|
501
|
+
# reference at all (baseline-free path).
|
|
502
|
+
if leaf.window_start is not None or leaf.window_end is not None:
|
|
503
|
+
windowed = True
|
|
504
|
+
if len(ref_time) > 0:
|
|
505
|
+
ref_time, ref_values = _slice_window(
|
|
506
|
+
ref_time,
|
|
507
|
+
ref_values,
|
|
508
|
+
leaf.window_start,
|
|
509
|
+
leaf.window_end,
|
|
510
|
+
)
|
|
511
|
+
act_time, act_values = _slice_window(
|
|
512
|
+
act_time,
|
|
513
|
+
act_values,
|
|
514
|
+
leaf.window_start,
|
|
515
|
+
leaf.window_end,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
vc = mode.compare(ref_time, ref_values, act_time, act_values)
|
|
520
|
+
except ValueError as exc:
|
|
521
|
+
# Config errors a mode can only detect at compare time (a tube
|
|
522
|
+
# control point with no widths, an unknown width mode) — same
|
|
523
|
+
# leaf-scoped failure as the resolve_mode guard above (D107).
|
|
524
|
+
return _leaf(_leaf_config_error_comparison(leaf, exc))
|
|
525
|
+
vc.index = index
|
|
526
|
+
vc.name = leaf.variable
|
|
527
|
+
if windowed:
|
|
528
|
+
# The window is a leaf property, not a mode's: the evaluator owns it.
|
|
529
|
+
vc.geometry = vc.geometry + (
|
|
530
|
+
Window(id="window", start=leaf.window_start, end=leaf.window_end),
|
|
531
|
+
)
|
|
532
|
+
leaf_result = _leaf(vc)
|
|
533
|
+
if quantity is not None:
|
|
534
|
+
leaf_result.diagnostics["quantity"] = quantity
|
|
535
|
+
if windowed:
|
|
536
|
+
leaf_result.diagnostics["window"] = {
|
|
537
|
+
"start": leaf.window_start,
|
|
538
|
+
"end": leaf.window_end,
|
|
539
|
+
}
|
|
540
|
+
return leaf_result
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _scalar_path_arrays(
|
|
544
|
+
leaf: LeafSpec,
|
|
545
|
+
path: str,
|
|
546
|
+
mode,
|
|
547
|
+
baseline: BaselineView | None,
|
|
548
|
+
scalars: ScalarSources,
|
|
549
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | VariableComparison:
|
|
550
|
+
"""Resolve a stats-path quantity on both sides into 1-sample arrays.
|
|
551
|
+
|
|
552
|
+
Returns ``(ref_time, ref_values, act_time, act_values)``, or a hard-fail
|
|
553
|
+
:class:`VariableComparison` naming the path and the side it is missing
|
|
554
|
+
from. The reference side is left empty when the leaf scores against
|
|
555
|
+
declared bounds (baseline-free) — there may be no baseline at all.
|
|
556
|
+
"""
|
|
557
|
+
act = resolve_scalar_path(path, scalars.statistics, scalars.diagnostics)
|
|
558
|
+
if act is None:
|
|
559
|
+
return _missing_scalar_path_comparison(leaf, path, "current run")
|
|
560
|
+
|
|
561
|
+
empty = np.array([])
|
|
562
|
+
ref_time, ref_values = empty, empty
|
|
563
|
+
if baseline is not None:
|
|
564
|
+
ref = resolve_scalar_path(path, baseline.statistics, baseline.diagnostics)
|
|
565
|
+
if ref is None:
|
|
566
|
+
if not mode.is_baseline_free():
|
|
567
|
+
return _missing_scalar_path_comparison(
|
|
568
|
+
leaf, path, f"baseline {baseline.name!r}"
|
|
569
|
+
)
|
|
570
|
+
else:
|
|
571
|
+
ref_time, ref_values = np.array([0.0]), np.array([ref])
|
|
572
|
+
return ref_time, ref_values, np.array([0.0]), np.array([act])
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def resolve_scalar_path(
|
|
576
|
+
path: str,
|
|
577
|
+
statistics: dict,
|
|
578
|
+
diagnostics: list,
|
|
579
|
+
) -> float | None:
|
|
580
|
+
"""Resolve a dotted quantity path to a number, or ``None`` if absent.
|
|
581
|
+
|
|
582
|
+
Two lookups, in order, applied identically to both sides:
|
|
583
|
+
|
|
584
|
+
1. a dotted walk into ``statistics`` (``translation.scalar_unknowns``);
|
|
585
|
+
2. the last segment read as a diagnostics-summary field —
|
|
586
|
+
``"CPUtime.final"`` means diagnostic ``CPUtime``, field ``final``
|
|
587
|
+
(one of final / min / max).
|
|
588
|
+
|
|
589
|
+
Statistics win on a collision: ``EventCounter`` lives in both maps, and
|
|
590
|
+
the statistics entry is the one the structural-warning checks already
|
|
591
|
+
treat as authoritative.
|
|
592
|
+
|
|
593
|
+
Diagnostics come in three shapes: the baseline's stored summaries
|
|
594
|
+
(``{"name", "final", "min", "max"}``), pre-summary baselines carrying
|
|
595
|
+
the full ``{"name", "values"}`` array (old data — the reporter's
|
|
596
|
+
``build_trajectories`` still back-reads it), and the current run's
|
|
597
|
+
``VariableResult`` trajectories. The last two are summarized here; the
|
|
598
|
+
run's trajectories are deliberately not stored (reference_store:
|
|
599
|
+
CPUtime is nondeterministic).
|
|
600
|
+
"""
|
|
601
|
+
value = _dotted_lookup(statistics, path)
|
|
602
|
+
if value is not None:
|
|
603
|
+
return value
|
|
604
|
+
|
|
605
|
+
name, _, field_name = path.rpartition(".")
|
|
606
|
+
if not name or field_name not in ("final", "min", "max"):
|
|
607
|
+
return None
|
|
608
|
+
for entry in diagnostics or []:
|
|
609
|
+
if isinstance(entry, VariableResult):
|
|
610
|
+
if entry.name != name:
|
|
611
|
+
continue
|
|
612
|
+
return _summarize_values(entry.values, field_name)
|
|
613
|
+
if isinstance(entry, dict) and entry.get("name") == name:
|
|
614
|
+
if field_name in entry:
|
|
615
|
+
return _as_float(entry.get(field_name))
|
|
616
|
+
# Pre-summary baseline: derive from the stored full array.
|
|
617
|
+
return _summarize_values(entry.get("values"), field_name)
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _summarize_values(values, field_name: str) -> float | None:
|
|
622
|
+
"""Return the ``final`` / ``min`` / ``max`` of a diagnostic's array."""
|
|
623
|
+
if values is None:
|
|
624
|
+
return None
|
|
625
|
+
array = np.asarray(values, dtype=float)
|
|
626
|
+
if array.size == 0:
|
|
627
|
+
return None
|
|
628
|
+
if field_name == "final":
|
|
629
|
+
return float(array[-1])
|
|
630
|
+
return float(array.min() if field_name == "min" else array.max())
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _dotted_lookup(statistics: dict, path: str) -> float | None:
|
|
634
|
+
"""Walk ``path`` through nested dicts; ``None`` unless it lands on a number."""
|
|
635
|
+
node: object = statistics or {}
|
|
636
|
+
parts = path.split(".")
|
|
637
|
+
for i, part in enumerate(parts):
|
|
638
|
+
if not isinstance(node, dict):
|
|
639
|
+
return None
|
|
640
|
+
if part not in node:
|
|
641
|
+
# A summary a baseline written before the parser always wrote
|
|
642
|
+
# it: 0 where its list is present and empty (the zero rule).
|
|
643
|
+
return _as_float(read_summary(node, part)) if i == len(parts) - 1 else None
|
|
644
|
+
node = node[part]
|
|
645
|
+
return _as_float(node)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _as_float(value: object) -> float | None:
|
|
649
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
650
|
+
return None
|
|
651
|
+
try:
|
|
652
|
+
return float(value)
|
|
653
|
+
except (TypeError, ValueError):
|
|
654
|
+
return None
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _slice_window(
|
|
658
|
+
t: np.ndarray,
|
|
659
|
+
v: np.ndarray,
|
|
660
|
+
start: float | None,
|
|
661
|
+
end: float | None,
|
|
662
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
663
|
+
"""Return ``(t, v)`` restricted to ``[start, end]`` (inclusive).
|
|
664
|
+
|
|
665
|
+
Open-ended on either side supported. A degenerate window returns empty
|
|
666
|
+
arrays — modes that can't handle empty data (NRMSE / tube) then fail
|
|
667
|
+
loudly downstream with a clear error, rather than being silently
|
|
668
|
+
widened back to the full trace here.
|
|
669
|
+
"""
|
|
670
|
+
mask = np.ones(len(t), dtype=bool)
|
|
671
|
+
if start is not None:
|
|
672
|
+
mask &= t >= start
|
|
673
|
+
if end is not None:
|
|
674
|
+
mask &= t <= end
|
|
675
|
+
return t[mask], v[mask]
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def build_leaf_mode(leaf: LeafSpec, base_tolerance: float) -> ComparisonMode:
|
|
679
|
+
"""The mode ``leaf`` is scored with, built as :func:`_evaluate_leaf`
|
|
680
|
+
builds it. ``ValueError`` — the leaf's config error at compare time —
|
|
681
|
+
when the leaf can never be scored: a tolerance that is no number or
|
|
682
|
+
pair, a ``range`` with neither bound, a ``scalar`` with no quantity.
|
|
683
|
+
An update asks before it writes a file
|
|
684
|
+
(``discovery.spec_update.refuse_newly_unscorable``): a write that makes
|
|
685
|
+
such a leaf is refused."""
|
|
686
|
+
return resolve_mode(
|
|
687
|
+
_leaf_override_dict(leaf),
|
|
688
|
+
_leaf_tolerance(leaf, base_tolerance),
|
|
689
|
+
default_points=False,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _leaf_tolerance(leaf: LeafSpec, base: float) -> Sides:
|
|
694
|
+
"""The tolerance ``leaf`` applies: its own, else the test's ``base``.
|
|
695
|
+
One that is neither a number nor a lower/upper pair (``"abc"``,
|
|
696
|
+
``null``, a pair missing a side) is a ``ValueError`` naming it, which
|
|
697
|
+
the caller turns into the leaf's config error (D107)."""
|
|
698
|
+
return parse_sides(leaf.params.get("tolerance", base), "tolerance")
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _leaf_override_dict(leaf: LeafSpec) -> dict:
|
|
702
|
+
"""Translate a LeafSpec to the flat override dict resolve_mode expects.
|
|
703
|
+
|
|
704
|
+
Spec metric names and override ``mode`` strings are the same identifier,
|
|
705
|
+
so this is the identity mapping plus one rule: the default metric has no
|
|
706
|
+
``mode`` key (its absence is what selects it).
|
|
707
|
+
"""
|
|
708
|
+
override = dict(leaf.params)
|
|
709
|
+
if leaf.metric != DEFAULT_METRIC:
|
|
710
|
+
override["mode"] = leaf.metric
|
|
711
|
+
return override
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _incompatible_dataset_comparison(
|
|
715
|
+
leaf: LeafSpec,
|
|
716
|
+
desc: MetricDescriptor,
|
|
717
|
+
dataset_kind: DatasetType,
|
|
718
|
+
) -> VariableComparison:
|
|
719
|
+
"""Build a hard-fail sentinel for a metric that can't score this data.
|
|
720
|
+
|
|
721
|
+
Takes the already-resolved descriptor — the caller just checked its
|
|
722
|
+
``accepted_datasets``, and a re-lookup here would need a dead None
|
|
723
|
+
branch (mypy union-attr).
|
|
724
|
+
|
|
725
|
+
The name stays the variable — the figure layer pairs leaves with plots
|
|
726
|
+
by it — and the reason rides in ``diagnostics["error"]``, where
|
|
727
|
+
``registry.summarize``, the console and JUnit already read it.
|
|
728
|
+
"""
|
|
729
|
+
accepted = ", ".join(sorted(d.value for d in desc.accepted_datasets))
|
|
730
|
+
return VariableComparison(
|
|
731
|
+
index=0,
|
|
732
|
+
name=leaf.variable,
|
|
733
|
+
passed=False,
|
|
734
|
+
nrmse=float("inf"),
|
|
735
|
+
rmse=float("inf"),
|
|
736
|
+
signal_range=0.0,
|
|
737
|
+
max_abs_error=float("inf"),
|
|
738
|
+
max_abs_error_time=0.0,
|
|
739
|
+
reference_final=float("nan"),
|
|
740
|
+
actual_final=float("nan"),
|
|
741
|
+
mode=leaf.metric,
|
|
742
|
+
diagnostics={
|
|
743
|
+
"error": (
|
|
744
|
+
f"metric {leaf.metric!r} accepts {accepted}; got {dataset_kind.value}"
|
|
745
|
+
)
|
|
746
|
+
},
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _leaf_config_error_comparison(
|
|
751
|
+
leaf: LeafSpec,
|
|
752
|
+
exc: ValueError,
|
|
753
|
+
) -> VariableComparison:
|
|
754
|
+
"""Hard-fail sentinel for a leaf whose metric config is invalid.
|
|
755
|
+
|
|
756
|
+
The name is the variable — the figure layer pairs leaves with plots by
|
|
757
|
+
it — and the reason (the metric that could not build, plus the raised
|
|
758
|
+
message, which names the correct key) rides in ``diagnostics["error"]``,
|
|
759
|
+
matching the flat-override path's shape.
|
|
760
|
+
|
|
761
|
+
``config_error`` marks the sentinel as config-shaped, distinguishing
|
|
762
|
+
it from a data-shaped one like ``_no_data_failure``: an empty window
|
|
763
|
+
is data — the same spec scores once the samples are there — while a
|
|
764
|
+
metric config that never built is a defect in the spec itself.
|
|
765
|
+
"""
|
|
766
|
+
return VariableComparison(
|
|
767
|
+
index=0,
|
|
768
|
+
name=leaf.variable,
|
|
769
|
+
passed=False,
|
|
770
|
+
nrmse=float("inf"),
|
|
771
|
+
rmse=float("inf"),
|
|
772
|
+
signal_range=0.0,
|
|
773
|
+
max_abs_error=float("inf"),
|
|
774
|
+
max_abs_error_time=0.0,
|
|
775
|
+
reference_final=float("nan"),
|
|
776
|
+
actual_final=float("nan"),
|
|
777
|
+
mode=leaf.metric,
|
|
778
|
+
diagnostics={
|
|
779
|
+
"error": f"metric {leaf.metric!r} misconfigured: {exc}",
|
|
780
|
+
"config_error": True,
|
|
781
|
+
},
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _missing_baseline_comparison(leaf: LeafSpec) -> VariableComparison:
|
|
786
|
+
"""Build a hard-fail sentinel for a leaf referencing an unknown baseline.
|
|
787
|
+
|
|
788
|
+
The name stays the variable so the leaf still finds its plot; the
|
|
789
|
+
missing baseline name rides in ``diagnostics["error"]``.
|
|
790
|
+
"""
|
|
791
|
+
return VariableComparison(
|
|
792
|
+
index=0,
|
|
793
|
+
name=leaf.variable,
|
|
794
|
+
passed=False,
|
|
795
|
+
nrmse=float("inf"),
|
|
796
|
+
rmse=float("inf"),
|
|
797
|
+
signal_range=0.0,
|
|
798
|
+
max_abs_error=float("inf"),
|
|
799
|
+
max_abs_error_time=0.0,
|
|
800
|
+
reference_final=float("nan"),
|
|
801
|
+
actual_final=float("nan"),
|
|
802
|
+
mode=leaf.metric,
|
|
803
|
+
diagnostics={"error": f"against={leaf.against!r}: baseline not found"},
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _missing_scalar_path_comparison(
|
|
808
|
+
leaf: LeafSpec,
|
|
809
|
+
path: str,
|
|
810
|
+
side: str,
|
|
811
|
+
) -> VariableComparison:
|
|
812
|
+
"""Hard-fail sentinel for a quantity path absent from one side.
|
|
813
|
+
|
|
814
|
+
The name stays the leaf's variable (the path itself when none was
|
|
815
|
+
authored) so the leaf keeps its place in the report; the diagnostic
|
|
816
|
+
names the path and the side (``"current run"`` / ``"baseline 'x'"``) —
|
|
817
|
+
a stats path that silently scored as 0 would be a green regression
|
|
818
|
+
test measuring nothing.
|
|
819
|
+
"""
|
|
820
|
+
return VariableComparison(
|
|
821
|
+
index=0,
|
|
822
|
+
name=leaf.variable,
|
|
823
|
+
passed=False,
|
|
824
|
+
nrmse=float("inf"),
|
|
825
|
+
rmse=float("inf"),
|
|
826
|
+
signal_range=0.0,
|
|
827
|
+
max_abs_error=float("inf"),
|
|
828
|
+
max_abs_error_time=0.0,
|
|
829
|
+
reference_final=float("nan"),
|
|
830
|
+
actual_final=float("nan"),
|
|
831
|
+
mode=leaf.metric,
|
|
832
|
+
diagnostics={"error": f"quantity {path!r} not found in {side}"},
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _missing_variable_comparison(
|
|
837
|
+
name: str,
|
|
838
|
+
var_result: VariableResult | None,
|
|
839
|
+
hint: str = "",
|
|
840
|
+
) -> VariableComparison:
|
|
841
|
+
actual_final = float("nan")
|
|
842
|
+
index = 0
|
|
843
|
+
if var_result is not None and len(var_result.values) > 0:
|
|
844
|
+
actual_final = float(var_result.values[-1])
|
|
845
|
+
index = var_result.index
|
|
846
|
+
return VariableComparison(
|
|
847
|
+
index=index,
|
|
848
|
+
name=name,
|
|
849
|
+
passed=False,
|
|
850
|
+
nrmse=float("inf"),
|
|
851
|
+
rmse=float("inf"),
|
|
852
|
+
signal_range=0.0,
|
|
853
|
+
max_abs_error=float("inf"),
|
|
854
|
+
max_abs_error_time=0.0,
|
|
855
|
+
reference_final=float("nan"),
|
|
856
|
+
actual_final=actual_final,
|
|
857
|
+
# The two call sites are told apart by what they had: no result for
|
|
858
|
+
# the variable at all, or a result the baseline does not carry. The
|
|
859
|
+
# second one carries the migration hint when the baseline holds the
|
|
860
|
+
# variable under its expression (``hint`` is "" otherwise).
|
|
861
|
+
diagnostics={
|
|
862
|
+
"error": (
|
|
863
|
+
f"variable {name!r} not found in the result"
|
|
864
|
+
if var_result is None
|
|
865
|
+
else f"variable {name!r} not found in the baseline{hint}"
|
|
866
|
+
)
|
|
867
|
+
},
|
|
868
|
+
)
|