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/comparison/modes.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""Comparison mode strategies — pluggable algorithms for comparing variables.
|
|
2
|
+
|
|
3
|
+
Each mode implements a single `compare()` method that takes reference and actual
|
|
4
|
+
time series and returns a VariableComparison. Mode-specific configuration is
|
|
5
|
+
encapsulated in a typed dataclass rather than an untyped dict.
|
|
6
|
+
|
|
7
|
+
Modes:
|
|
8
|
+
NrmseMode — normalized RMSE with piecewise event handling (default)
|
|
9
|
+
TubeMode — tolerance tube envelope around reference trajectory
|
|
10
|
+
PointsMode — compare values at declared time points (or the final value
|
|
11
|
+
when no points are declared)
|
|
12
|
+
ScalarMode — score one number: a series reduction or a stored statistic
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from .algorithms import (
|
|
24
|
+
SCALAR_REDUCTIONS,
|
|
25
|
+
_event_tolerances,
|
|
26
|
+
_peak_tolerance,
|
|
27
|
+
_point_tolerances,
|
|
28
|
+
)
|
|
29
|
+
from .comparator import (
|
|
30
|
+
VariableComparison,
|
|
31
|
+
_compare_dominant_frequency,
|
|
32
|
+
_compare_event_timing,
|
|
33
|
+
_compare_points,
|
|
34
|
+
_compare_range,
|
|
35
|
+
_compare_scalar,
|
|
36
|
+
_compare_trajectories,
|
|
37
|
+
_compare_tube,
|
|
38
|
+
)
|
|
39
|
+
from .registry import DEFAULT_METRIC, DEFAULT_POINTS_METRIC
|
|
40
|
+
from .registry import metric as registry_metric
|
|
41
|
+
from .registry import metrics as registered_metrics
|
|
42
|
+
from .sides import Sides, parse_sides
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Abstract base
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ComparisonMode(ABC):
|
|
50
|
+
"""Strategy interface for variable comparison."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
"""Short identifier used in config and VariableComparison.mode."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def compare(
|
|
60
|
+
self,
|
|
61
|
+
ref_time: np.ndarray,
|
|
62
|
+
ref_values: np.ndarray,
|
|
63
|
+
act_time: np.ndarray,
|
|
64
|
+
act_values: np.ndarray,
|
|
65
|
+
) -> VariableComparison:
|
|
66
|
+
"""Compare actual vs reference and return metrics."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def is_baseline_free(self) -> bool:
|
|
70
|
+
"""Does this mode + its current config score without a reference?
|
|
71
|
+
|
|
72
|
+
Default ``False`` — most modes (NRMSE, tube, final-only) compare
|
|
73
|
+
against a saved baseline. Subclasses that score purely against
|
|
74
|
+
config-declared bounds or structures (range; event-timing with a
|
|
75
|
+
declared ``events`` list; dominant-frequency with declared
|
|
76
|
+
``peaks``) override this to return ``True``. Used by the
|
|
77
|
+
comparator to decide whether a test with no stored baseline can
|
|
78
|
+
still produce meaningful pass/fail — otherwise the whole test
|
|
79
|
+
collapses to the NO_REF state (idea #59 / D83).
|
|
80
|
+
"""
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Mode configs
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class NrmseConfig:
|
|
91
|
+
"""Configuration for NRMSE comparison."""
|
|
92
|
+
|
|
93
|
+
tolerance: float = field(
|
|
94
|
+
default=1e-4,
|
|
95
|
+
metadata={
|
|
96
|
+
"label": "Tolerance",
|
|
97
|
+
"help": (
|
|
98
|
+
"Pass iff NRMSE = RMSE / signal_range stays below this value. "
|
|
99
|
+
"Range-normalized so the same tolerance works across variables "
|
|
100
|
+
"with different magnitudes."
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class TubeConfig:
|
|
108
|
+
"""Configuration for tube comparison.
|
|
109
|
+
|
|
110
|
+
Mirrors the fields previously scattered as arbitrary keys in the
|
|
111
|
+
variable_overrides dict, but with types enforced. The tightened
|
|
112
|
+
`Literal[...]` choices feed the auto-derived UI schema
|
|
113
|
+
(comparison/mode_schema.py).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
tube_width_mode: Literal["band", "rel", "abs"] | None = field(
|
|
117
|
+
default=None,
|
|
118
|
+
metadata={
|
|
119
|
+
"label": "Width mode",
|
|
120
|
+
"help": (
|
|
121
|
+
"How the tube width is specified. "
|
|
122
|
+
"'band' = constant offset in signal units (uses tube_abs); "
|
|
123
|
+
"'rel' = fraction of |reference| (uses tube_rel); "
|
|
124
|
+
"'abs' = literal y-axis bounds — requires tube_points "
|
|
125
|
+
"with explicit upper/lower."
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
tube_abs: Sides = field(
|
|
130
|
+
default=Sides(0.0, 0.0),
|
|
131
|
+
metadata={
|
|
132
|
+
"label": "Tube abs",
|
|
133
|
+
"help": "Absolute offset from reference (used when width_mode='band').",
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
tube_rel: Sides = field(
|
|
137
|
+
default=Sides(0.0, 0.0),
|
|
138
|
+
metadata={
|
|
139
|
+
"label": "Tube rel",
|
|
140
|
+
"help": "Fractional offset from |reference| (e.g. 0.02 = 2%).",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
tube_min_width: float = field(
|
|
144
|
+
default=0.0,
|
|
145
|
+
metadata={
|
|
146
|
+
"label": "Min width",
|
|
147
|
+
"help": "Floor for the tube width — useful near zero-crossings.",
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
tube_points: list[dict] | None = field(
|
|
151
|
+
default=None,
|
|
152
|
+
metadata={
|
|
153
|
+
"label": "Control points",
|
|
154
|
+
"help": (
|
|
155
|
+
"Time-varying tube defined by control points. Edit on the "
|
|
156
|
+
"authoring tool's Edit on plot tab: drag a handle, or edit "
|
|
157
|
+
"the table."
|
|
158
|
+
),
|
|
159
|
+
# Row keys as ``_interpolate_tube_widths`` reads them: ``time``
|
|
160
|
+
# indexed, each width ``.get`` with a 0 default. A row with
|
|
161
|
+
# neither reads 0/0 and pinches the tube there; the reader
|
|
162
|
+
# refuses only a list in which NO row has a width.
|
|
163
|
+
"columns": (("time", "float"), ("upper", "float?"), ("lower", "float?")),
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
tube_interpolation: Literal["linear", "constant"] = field(
|
|
167
|
+
default="linear",
|
|
168
|
+
metadata={
|
|
169
|
+
"label": "Interpolation",
|
|
170
|
+
"help": "How tube bounds interpolate between control points.",
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def to_dict(self) -> dict:
|
|
175
|
+
"""Convert back to the flat dict format consumed by _compare_tube."""
|
|
176
|
+
d: dict = {}
|
|
177
|
+
if self.tube_width_mode is not None:
|
|
178
|
+
d["tube_width_mode"] = self.tube_width_mode
|
|
179
|
+
for key in ("tube_abs", "tube_rel"):
|
|
180
|
+
# A config built with a number holds the number: both sides. A
|
|
181
|
+
# dataclass is always truthy, so "no width" is said: zero on
|
|
182
|
+
# both sides — one zero side is a width.
|
|
183
|
+
sides = parse_sides(getattr(self, key), key)
|
|
184
|
+
if sides != Sides(0.0, 0.0):
|
|
185
|
+
d[key] = sides
|
|
186
|
+
if self.tube_min_width:
|
|
187
|
+
d["tube_min_width"] = self.tube_min_width
|
|
188
|
+
if self.tube_points is not None:
|
|
189
|
+
d["tube_points"] = self.tube_points
|
|
190
|
+
d["tube_interpolation"] = self.tube_interpolation
|
|
191
|
+
return d
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class PointsConfig:
|
|
196
|
+
"""Configuration for point-based comparison.
|
|
197
|
+
|
|
198
|
+
When ``points`` is None or [], the mode behaves exactly like the
|
|
199
|
+
former final-only: checks ``act[-1]`` vs ``ref[-1]`` with
|
|
200
|
+
``tolerance`` as absolute delta. When ``points`` is a non-empty
|
|
201
|
+
list, each entry is a declared checkpoint with optional explicit
|
|
202
|
+
target value, per-point tolerance, and per-point time-tolerance.
|
|
203
|
+
See docs/superpowers/specs/2026-04-24-points-mode-design.md.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
points: list[dict] | None = field(
|
|
207
|
+
default=None,
|
|
208
|
+
metadata={
|
|
209
|
+
"label": "Declared points",
|
|
210
|
+
"help": (
|
|
211
|
+
"Optional list of (time, value, tolerance, "
|
|
212
|
+
"tolerance_mode, time_tolerance) checkpoints. When None "
|
|
213
|
+
"or empty, the mode falls back to final-value comparison "
|
|
214
|
+
"with the global ``tolerance``. Edit on the authoring "
|
|
215
|
+
"tool's Edit on plot tab: drag a handle, or edit the table."
|
|
216
|
+
),
|
|
217
|
+
# As ``_compare_points`` reads them, all with ``.get``: a blank
|
|
218
|
+
# ``time`` is the documented trace-end sentinel (``time: null``),
|
|
219
|
+
# a blank ``value`` follows the baseline. A ``sides?`` cell is a
|
|
220
|
+
# number or a lower/upper pair; a time tolerance's two sides are
|
|
221
|
+
# how much earlier and how much later.
|
|
222
|
+
"columns": (
|
|
223
|
+
("time", "float?"),
|
|
224
|
+
("value", "float?"),
|
|
225
|
+
("tolerance", "sides?"),
|
|
226
|
+
("tolerance_mode", ("abs", "rel")),
|
|
227
|
+
("time_tolerance", "sides?", ("before", "after")),
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
tolerance: Sides = field(
|
|
232
|
+
default=Sides(1e-4, 1e-4),
|
|
233
|
+
metadata={
|
|
234
|
+
"label": "Default tolerance",
|
|
235
|
+
"help": (
|
|
236
|
+
"Default per-point y-tolerance when not specified inside "
|
|
237
|
+
"a point dict. Also the tolerance for the implicit final-"
|
|
238
|
+
"value check when ``points`` is empty."
|
|
239
|
+
),
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class RangeConfig:
|
|
246
|
+
"""Configuration for range (bounds-check) comparison.
|
|
247
|
+
|
|
248
|
+
At least one of ``min_value`` or ``max_value`` must be set. Bounds
|
|
249
|
+
are declared in the spec itself — reference data is not consulted.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
min_value: float | None = field(
|
|
253
|
+
default=None,
|
|
254
|
+
metadata={
|
|
255
|
+
"label": "Lower bound (optional)",
|
|
256
|
+
"help": (
|
|
257
|
+
"Signal must never drop below this value. Leave blank to "
|
|
258
|
+
"skip — only the upper bound will apply."
|
|
259
|
+
),
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
max_value: float | None = field(
|
|
263
|
+
default=None,
|
|
264
|
+
metadata={
|
|
265
|
+
"label": "Upper bound (optional)",
|
|
266
|
+
"help": (
|
|
267
|
+
"Signal must never exceed this value. Leave blank to skip — "
|
|
268
|
+
"only the lower bound will apply."
|
|
269
|
+
),
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class EventTimingConfig:
|
|
276
|
+
"""Configuration for event-timing comparison (4.C.1).
|
|
277
|
+
|
|
278
|
+
When ``events`` is None (default), both reference and actual event
|
|
279
|
+
instants are auto-detected from duplicate-time samples (Modelica
|
|
280
|
+
convention) and paired by index. When ``events`` is provided, the
|
|
281
|
+
declared list becomes the authoritative reference-side event set —
|
|
282
|
+
the actual signal is still auto-detected, and declared events claim
|
|
283
|
+
distinct actual events by optimal interval matching (windows sorted
|
|
284
|
+
by right endpoint, smallest unclaimed actual wins — see
|
|
285
|
+
``algorithms._compare_event_timing``); an unclaimed declared event
|
|
286
|
+
fails. Mirrors the dominant-frequency declared-peaks semantics (D75).
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
time_tolerance: Sides = field(
|
|
290
|
+
default=Sides(1e-3, 1e-3),
|
|
291
|
+
metadata={
|
|
292
|
+
"label": "Time tolerance (s)",
|
|
293
|
+
"help": (
|
|
294
|
+
"Default max time-shift between paired reference/actual "
|
|
295
|
+
"events. Per-event overrides in the declared ``events`` "
|
|
296
|
+
"list take precedence when present."
|
|
297
|
+
),
|
|
298
|
+
"side_labels": ("early", "late"),
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
count_must_match: bool = field(
|
|
302
|
+
default=True,
|
|
303
|
+
metadata={
|
|
304
|
+
"label": "Event counts must match",
|
|
305
|
+
"help": (
|
|
306
|
+
"If checked, reference and actual must fire the same number "
|
|
307
|
+
"of events. Unchecked allows pairs-that-exist comparisons "
|
|
308
|
+
"even when extra/missing events appear."
|
|
309
|
+
),
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
events: list[dict] | None = field(
|
|
313
|
+
default=None,
|
|
314
|
+
metadata={
|
|
315
|
+
"label": "Declared events",
|
|
316
|
+
"help": (
|
|
317
|
+
"Declared reference-side events. Each entry has a ``time`` "
|
|
318
|
+
"(seconds) and an optional ``tolerance`` (seconds; falls "
|
|
319
|
+
"back to the leaf's ``time_tolerance`` if omitted). When "
|
|
320
|
+
"None, events are auto-detected from duplicate-time samples "
|
|
321
|
+
"in ``ref_time``. Edit the table on the authoring tool's "
|
|
322
|
+
"Edit on plot tab."
|
|
323
|
+
),
|
|
324
|
+
"columns": (
|
|
325
|
+
("time", "float"),
|
|
326
|
+
("tolerance", "sides?", ("early", "late")),
|
|
327
|
+
),
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class DominantFrequencyConfig:
|
|
334
|
+
"""Configuration for dominant-frequency declared-peaks comparison (D75).
|
|
335
|
+
|
|
336
|
+
Each entry in ``peaks`` is a dict:
|
|
337
|
+
{"freq": float, "tolerance": float | {"lower": float, "upper": float},
|
|
338
|
+
"tolerance_mode": "rel"|"abs"}
|
|
339
|
+
|
|
340
|
+
The algorithm finds the strongest local maximum in the *actual*
|
|
341
|
+
spectrum within each declared peak's tolerance window; the leaf
|
|
342
|
+
passes iff every declared peak has such a match. A leaf with no
|
|
343
|
+
declared peaks fails and reports the reference spectrum's top-N
|
|
344
|
+
peaks as the ``detected_reference_peaks_hz`` diagnostic, which is
|
|
345
|
+
what an author declares the list from.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
peaks: list[dict] | None = field(
|
|
349
|
+
default=None,
|
|
350
|
+
metadata={
|
|
351
|
+
"label": "Peaks",
|
|
352
|
+
"help": (
|
|
353
|
+
"Declared peaks to track. Each entry has a frequency, a "
|
|
354
|
+
"tolerance, and a tolerance mode ('rel' = fractional, "
|
|
355
|
+
"'abs' = Hz). Edit on the authoring tool's Edit on plot "
|
|
356
|
+
"tab: drag a handle, or edit the table."
|
|
357
|
+
),
|
|
358
|
+
# ``freq`` stays required although ``_compare_dominant_frequency``
|
|
359
|
+
# reads it with ``.get``: a peak without a frequency is
|
|
360
|
+
# meaningless. The reader defaults ``tolerance_mode`` to "rel".
|
|
361
|
+
"columns": (
|
|
362
|
+
("freq", "float"),
|
|
363
|
+
("tolerance", "sides?"),
|
|
364
|
+
("tolerance_mode", ("rel", "abs")),
|
|
365
|
+
),
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
#: The keys a ``quantity`` OBJECT may hold — one of them — on a metric whose
|
|
371
|
+
#: config declares the field (``ScalarConfig``). ``expr`` is reserved: named
|
|
372
|
+
#: here so it is refused as "not implemented", not as a misspelling.
|
|
373
|
+
QUANTITY_KEYS = ("reduce", "expr")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass(frozen=True)
|
|
377
|
+
class ScalarConfig:
|
|
378
|
+
"""Configuration for scalar comparison — one number, not a trajectory.
|
|
379
|
+
|
|
380
|
+
``quantity`` says *which* number, in one of two v1 shapes:
|
|
381
|
+
|
|
382
|
+
* ``{"reduce": "max"|"min"|"mean"|"sample_mean"|"final"|"integral"}`` —
|
|
383
|
+
reduce the leaf's tracked ``variable`` (after the leaf window) on both
|
|
384
|
+
sides. ``integral`` is the trapezoid rule over the windowed series and
|
|
385
|
+
``mean`` is that integral over the time span — time-weighted, so a
|
|
386
|
+
finer output grid doesn't move the score; ``sample_mean`` is the plain
|
|
387
|
+
arithmetic mean of the samples, which does move with the grid.
|
|
388
|
+
* ``"<dotted.path>"`` — a stored scalar: a dotted lookup into the run's
|
|
389
|
+
``statistics``, falling back to a diagnostics summary field
|
|
390
|
+
(``"CPUtime.final"``). Resolved by
|
|
391
|
+
:func:`~.tree_eval.resolve_scalar_path`; the leaf window does not
|
|
392
|
+
apply because a stored scalar is not a time series.
|
|
393
|
+
|
|
394
|
+
The object shape is what keeps the field extensible: a later
|
|
395
|
+
``{"expr": ...}`` quantity slots in as a third shape without breaking
|
|
396
|
+
either of the above. It is deliberately NOT implemented in v1 — there
|
|
397
|
+
is no expression evaluator here.
|
|
398
|
+
|
|
399
|
+
Declaring ``min_value`` and/or ``max_value`` switches the leaf to the
|
|
400
|
+
baseline-free bounds form; otherwise the scalar is compared against the
|
|
401
|
+
same quantity taken from the baseline.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
quantity: str | dict | None = field(
|
|
405
|
+
default=None,
|
|
406
|
+
metadata={
|
|
407
|
+
"label": "Quantity",
|
|
408
|
+
"help": (
|
|
409
|
+
"Which number to score: a reduction of the leaf's variable "
|
|
410
|
+
'({"reduce": "max"|"min"|"mean"|"sample_mean"|"final"|'
|
|
411
|
+
"\"integral\"} — 'mean' is time-weighted, i.e. the integral "
|
|
412
|
+
"over the time span, so it doesn't move with the output grid, "
|
|
413
|
+
"while 'sample_mean' is the arithmetic mean of the samples), "
|
|
414
|
+
'or a dotted statistics / diagnostics path ("CPUtime.final", '
|
|
415
|
+
'"translation.scalar_unknowns"). Edited as raw JSON.'
|
|
416
|
+
),
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
tolerance: Sides = field(
|
|
420
|
+
default=Sides(1e-4, 1e-4),
|
|
421
|
+
metadata={
|
|
422
|
+
"label": "Tolerance",
|
|
423
|
+
"help": (
|
|
424
|
+
"Pass iff the deviation from the baseline scalar stays below "
|
|
425
|
+
"this value (absolute or relative — see tolerance mode). "
|
|
426
|
+
"Ignored when bounds are declared."
|
|
427
|
+
),
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
tolerance_mode: Literal["abs", "rel"] = field(
|
|
431
|
+
default="abs",
|
|
432
|
+
metadata={
|
|
433
|
+
"label": "Tolerance mode",
|
|
434
|
+
"help": (
|
|
435
|
+
"'abs' = |actual - reference| <= tolerance; 'rel' = "
|
|
436
|
+
"|actual - reference| <= tolerance * |reference|, falling "
|
|
437
|
+
"back to the absolute test when the reference is 0."
|
|
438
|
+
),
|
|
439
|
+
},
|
|
440
|
+
)
|
|
441
|
+
min_value: float | None = field(
|
|
442
|
+
default=None,
|
|
443
|
+
metadata={
|
|
444
|
+
"label": "Lower bound (optional)",
|
|
445
|
+
"help": (
|
|
446
|
+
"Declaring a bound switches the leaf to a baseline-free "
|
|
447
|
+
"check: the scalar must not fall below this value."
|
|
448
|
+
),
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
max_value: float | None = field(
|
|
452
|
+
default=None,
|
|
453
|
+
metadata={
|
|
454
|
+
"label": "Upper bound (optional)",
|
|
455
|
+
"help": (
|
|
456
|
+
"Declaring a bound switches the leaf to a baseline-free "
|
|
457
|
+
"check: the scalar must not exceed this value."
|
|
458
|
+
),
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# Mode implementations
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class NrmseMode(ComparisonMode):
|
|
469
|
+
"""Piecewise NRMSE comparison with event boundary handling."""
|
|
470
|
+
|
|
471
|
+
def __init__(self, config: NrmseConfig):
|
|
472
|
+
self.config = config
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def name(self) -> str:
|
|
476
|
+
return "nrmse"
|
|
477
|
+
|
|
478
|
+
def compare(self, ref_time, ref_values, act_time, act_values):
|
|
479
|
+
return _compare_trajectories(
|
|
480
|
+
ref_time,
|
|
481
|
+
ref_values,
|
|
482
|
+
act_time,
|
|
483
|
+
act_values,
|
|
484
|
+
self.config.tolerance,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class TubeMode(ComparisonMode):
|
|
489
|
+
"""Tolerance tube envelope comparison."""
|
|
490
|
+
|
|
491
|
+
def __init__(self, config: TubeConfig):
|
|
492
|
+
self.config = config
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def name(self) -> str:
|
|
496
|
+
return "tube"
|
|
497
|
+
|
|
498
|
+
def compare(self, ref_time, ref_values, act_time, act_values):
|
|
499
|
+
return _compare_tube(
|
|
500
|
+
ref_time,
|
|
501
|
+
ref_values,
|
|
502
|
+
act_time,
|
|
503
|
+
act_values,
|
|
504
|
+
self.config.to_dict(),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class PointsMode(ComparisonMode):
|
|
509
|
+
"""Compare actual vs reference at user-declared time points.
|
|
510
|
+
|
|
511
|
+
When ``config.points`` is None or empty, falls back to the legacy
|
|
512
|
+
final-value-only check (act[-1] vs ref[-1] with config.tolerance).
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
def __init__(self, config: PointsConfig):
|
|
516
|
+
self.config = config
|
|
517
|
+
# A config built with a number (a seed's, a test's) is both sides.
|
|
518
|
+
self.tolerance = parse_sides(config.tolerance, "tolerance")
|
|
519
|
+
# The rows' tolerances are read here as well as in ``compare``: a
|
|
520
|
+
# malformed one must be a ``ValueError`` where ``resolve_mode``'s
|
|
521
|
+
# callers catch it — out of ``compare`` the flat path catches nothing.
|
|
522
|
+
_point_tolerances(config.points, self.tolerance)
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def name(self) -> str:
|
|
526
|
+
return "points"
|
|
527
|
+
|
|
528
|
+
def compare(self, ref_time, ref_values, act_time, act_values):
|
|
529
|
+
return _compare_points(
|
|
530
|
+
ref_time,
|
|
531
|
+
ref_values,
|
|
532
|
+
act_time,
|
|
533
|
+
act_values,
|
|
534
|
+
points=self.config.points,
|
|
535
|
+
tolerance=self.tolerance,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def is_baseline_free(self) -> bool:
|
|
539
|
+
# Baseline-free iff non-empty points list AND every point
|
|
540
|
+
# has an explicit ``value``. Empty list → implicit final
|
|
541
|
+
# comparison reads ref → not baseline-free. Mixed (some with
|
|
542
|
+
# value, some without) is also not baseline-free; users
|
|
543
|
+
# commit to all-or-nothing.
|
|
544
|
+
pts = self.config.points
|
|
545
|
+
if not pts:
|
|
546
|
+
return False
|
|
547
|
+
return all(p.get("value") is not None for p in pts)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
class EventTimingMode(ComparisonMode):
|
|
551
|
+
"""Compare event instants (duplicate-time markers) between signals."""
|
|
552
|
+
|
|
553
|
+
def __init__(self, config: EventTimingConfig):
|
|
554
|
+
self.config = config
|
|
555
|
+
self.time_tolerance = parse_sides(config.time_tolerance, "time_tolerance")
|
|
556
|
+
# As ``PointsMode`` reads its rows: here, where a ``ValueError`` is
|
|
557
|
+
# the leaf's config error on both scoring paths.
|
|
558
|
+
_event_tolerances(config.events, self.time_tolerance)
|
|
559
|
+
|
|
560
|
+
@property
|
|
561
|
+
def name(self) -> str:
|
|
562
|
+
return "event-timing"
|
|
563
|
+
|
|
564
|
+
def compare(self, ref_time, ref_values, act_time, act_values):
|
|
565
|
+
return _compare_event_timing(
|
|
566
|
+
ref_time,
|
|
567
|
+
act_time,
|
|
568
|
+
time_tolerance=self.time_tolerance,
|
|
569
|
+
count_must_match=self.config.count_must_match,
|
|
570
|
+
declared_events=self.config.events,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def is_baseline_free(self) -> bool:
|
|
574
|
+
# Declared-events path (D82): the user-authored list replaces the
|
|
575
|
+
# auto-detected reference event set, so the leaf scores using only
|
|
576
|
+
# ``act_time``. Auto-detect path still needs the reference.
|
|
577
|
+
return self.config.events is not None
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class DominantFrequencyMode(ComparisonMode):
|
|
581
|
+
"""Compare the dominant frequency of two signals (FFT peak)."""
|
|
582
|
+
|
|
583
|
+
def __init__(self, config: DominantFrequencyConfig):
|
|
584
|
+
self.config = config
|
|
585
|
+
# As ``PointsMode`` reads its rows: here, where a ``ValueError`` is
|
|
586
|
+
# the leaf's config error on both scoring paths.
|
|
587
|
+
for i, declared in enumerate(config.peaks or []):
|
|
588
|
+
_peak_tolerance(declared, i)
|
|
589
|
+
|
|
590
|
+
@property
|
|
591
|
+
def name(self) -> str:
|
|
592
|
+
return "dominant-frequency"
|
|
593
|
+
|
|
594
|
+
def compare(self, ref_time, ref_values, act_time, act_values):
|
|
595
|
+
return _compare_dominant_frequency(
|
|
596
|
+
ref_time,
|
|
597
|
+
ref_values,
|
|
598
|
+
act_time,
|
|
599
|
+
act_values,
|
|
600
|
+
peaks=self.config.peaks,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def is_baseline_free(self) -> bool:
|
|
604
|
+
# Declared-peaks path (D75): pass/fail is decided by match-in-window
|
|
605
|
+
# against the actual spectrum; the reference spectrum is only used
|
|
606
|
+
# for the reporter's overlay. No declared peaks → the mode already
|
|
607
|
+
# fails loudly, and a reference would be needed anyway.
|
|
608
|
+
return self.config.peaks is not None
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class RangeMode(ComparisonMode):
|
|
612
|
+
"""Per-point bounds check (min <= actual <= max).
|
|
613
|
+
|
|
614
|
+
First leaf type that doesn't consume the reference trajectory — the
|
|
615
|
+
bounds come from the spec. Validates the MetricTree leaf contract
|
|
616
|
+
beyond NRMSE/tube/final-only.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
def __init__(self, config: RangeConfig):
|
|
620
|
+
if config.min_value is None and config.max_value is None:
|
|
621
|
+
raise ValueError("needs at least one of min_value / max_value")
|
|
622
|
+
self.config = config
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def name(self) -> str:
|
|
626
|
+
return "range"
|
|
627
|
+
|
|
628
|
+
def compare(self, ref_time, ref_values, act_time, act_values):
|
|
629
|
+
return _compare_range(
|
|
630
|
+
act_time,
|
|
631
|
+
act_values,
|
|
632
|
+
self.config.min_value,
|
|
633
|
+
self.config.max_value,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def is_baseline_free(self) -> bool:
|
|
637
|
+
# Bounds live in the leaf's own config; reference trajectories are
|
|
638
|
+
# never consulted.
|
|
639
|
+
return True
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class ScalarMode(ComparisonMode):
|
|
643
|
+
"""Score a single number against a baseline scalar or declared bounds.
|
|
644
|
+
|
|
645
|
+
The reduction applies to whatever arrays the evaluator supplies: a
|
|
646
|
+
windowed trajectory for the ``{"reduce": ...}`` quantity, or the
|
|
647
|
+
1-sample arrays it builds for a stats-path quantity (which is why the
|
|
648
|
+
reduction defaults to ``"final"``).
|
|
649
|
+
"""
|
|
650
|
+
|
|
651
|
+
def __init__(self, config: ScalarConfig):
|
|
652
|
+
_validate_scalar_config(config)
|
|
653
|
+
self.config = config
|
|
654
|
+
self.tolerance = parse_sides(config.tolerance, "tolerance")
|
|
655
|
+
quantity = config.quantity
|
|
656
|
+
self.reduction = quantity["reduce"] if isinstance(quantity, dict) else "final"
|
|
657
|
+
|
|
658
|
+
@property
|
|
659
|
+
def name(self) -> str:
|
|
660
|
+
return "scalar"
|
|
661
|
+
|
|
662
|
+
def compare(self, ref_time, ref_values, act_time, act_values):
|
|
663
|
+
return _compare_scalar(
|
|
664
|
+
ref_time,
|
|
665
|
+
ref_values,
|
|
666
|
+
act_time,
|
|
667
|
+
act_values,
|
|
668
|
+
reduction=self.reduction,
|
|
669
|
+
tolerance=self.tolerance,
|
|
670
|
+
tolerance_mode=self.config.tolerance_mode,
|
|
671
|
+
min_value=self.config.min_value,
|
|
672
|
+
max_value=self.config.max_value,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def is_baseline_free(self) -> bool:
|
|
676
|
+
# Declared bounds are scored against the actual scalar alone; the
|
|
677
|
+
# baseline form needs the same quantity from a reference.
|
|
678
|
+
return self.config.min_value is not None or self.config.max_value is not None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _validate_scalar_config(config: ScalarConfig) -> None:
|
|
682
|
+
"""Reject a scalar leaf that can't be scored, loudly and at build time.
|
|
683
|
+
|
|
684
|
+
Same error type the other modes raise (``ValueError`` from
|
|
685
|
+
``RangeMode.__init__``), so ``resolve_mode``'s callers keep one
|
|
686
|
+
failure path.
|
|
687
|
+
"""
|
|
688
|
+
quantity = config.quantity
|
|
689
|
+
if quantity is None or quantity == "":
|
|
690
|
+
raise ValueError(
|
|
691
|
+
"needs a 'quantity': a dotted statistics path or "
|
|
692
|
+
'{"reduce": "max"|"min"|"mean"|"sample_mean"|"final"|"integral"}'
|
|
693
|
+
)
|
|
694
|
+
if not isinstance(quantity, str):
|
|
695
|
+
if not isinstance(quantity, dict):
|
|
696
|
+
raise ValueError(
|
|
697
|
+
f"ScalarConfig quantity must be a string path or an object, "
|
|
698
|
+
f"got {type(quantity).__name__}"
|
|
699
|
+
)
|
|
700
|
+
shapes = [k for k in QUANTITY_KEYS if k in quantity]
|
|
701
|
+
if len(shapes) != 1:
|
|
702
|
+
raise ValueError(
|
|
703
|
+
f"ScalarConfig quantity object must declare exactly one of "
|
|
704
|
+
f"'reduce' / 'expr'; got {sorted(quantity)}"
|
|
705
|
+
)
|
|
706
|
+
if "expr" in quantity:
|
|
707
|
+
raise ValueError(
|
|
708
|
+
"ScalarConfig quantity {'expr': ...} is not implemented — v1 "
|
|
709
|
+
"takes a reduction or a statistics path"
|
|
710
|
+
)
|
|
711
|
+
if quantity["reduce"] not in SCALAR_REDUCTIONS:
|
|
712
|
+
raise ValueError(
|
|
713
|
+
f"ScalarConfig quantity reduce must be one of "
|
|
714
|
+
f"{list(SCALAR_REDUCTIONS)}; got {quantity['reduce']!r}"
|
|
715
|
+
)
|
|
716
|
+
if config.tolerance_mode not in ("abs", "rel"):
|
|
717
|
+
raise ValueError(
|
|
718
|
+
f"ScalarConfig tolerance_mode must be 'abs' or 'rel', "
|
|
719
|
+
f"got {config.tolerance_mode!r}"
|
|
720
|
+
)
|
|
721
|
+
if (
|
|
722
|
+
config.min_value is not None
|
|
723
|
+
and config.max_value is not None
|
|
724
|
+
and config.min_value >= config.max_value
|
|
725
|
+
):
|
|
726
|
+
raise ValueError(
|
|
727
|
+
f"ScalarConfig min_value ({config.min_value}) must be below "
|
|
728
|
+
f"max_value ({config.max_value})"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ---------------------------------------------------------------------------
|
|
733
|
+
# Factory: resolve override dict → ComparisonMode
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
|
|
736
|
+
#: Keys :func:`registry` builders lift out of a flat override dict into
|
|
737
|
+
#: :class:`TubeConfig`. Lives here next to the dataclass it mirrors.
|
|
738
|
+
_TUBE_KEYS = frozenset(
|
|
739
|
+
{
|
|
740
|
+
"tube_width_mode",
|
|
741
|
+
"tube_abs",
|
|
742
|
+
"tube_rel",
|
|
743
|
+
"tube_min_width",
|
|
744
|
+
"tube_points",
|
|
745
|
+
"tube_interpolation",
|
|
746
|
+
}
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def override_metric(var_override: dict, default_points: bool = False) -> str:
|
|
751
|
+
"""The name of the metric a flat override is scored by: its ``mode``,
|
|
752
|
+
else the default — ``points`` under ``default_points``, ``nrmse``
|
|
753
|
+
otherwise. Whatever was written, registered or not: :func:`resolve_mode`
|
|
754
|
+
refuses a name nothing is registered under."""
|
|
755
|
+
return var_override.get("mode", "") or (
|
|
756
|
+
DEFAULT_POINTS_METRIC if default_points else DEFAULT_METRIC
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def resolve_mode(
|
|
761
|
+
var_override: dict,
|
|
762
|
+
tolerance: Sides | float,
|
|
763
|
+
default_points: bool = False,
|
|
764
|
+
) -> ComparisonMode:
|
|
765
|
+
"""Build the appropriate ComparisonMode from a per-variable override dict.
|
|
766
|
+
|
|
767
|
+
Resolution order:
|
|
768
|
+
1. Explicit ``mode`` key in override → use that mode.
|
|
769
|
+
2. If no explicit mode and ``default_points`` is True → PointsMode
|
|
770
|
+
with points=None (implicit final-value check).
|
|
771
|
+
3. Otherwise → NrmseMode (legacy default).
|
|
772
|
+
|
|
773
|
+
The mode names and their construction come from
|
|
774
|
+
:mod:`dstf.comparison.registry`, so a third-party metric registered via
|
|
775
|
+
``register_metric`` resolves here without touching this function.
|
|
776
|
+
"""
|
|
777
|
+
mode_name = override_metric(var_override, default_points)
|
|
778
|
+
desc = registry_metric(mode_name)
|
|
779
|
+
if desc is None:
|
|
780
|
+
# An unrecognized mode must be loud. Silently defaulting to NRMSE
|
|
781
|
+
# changed the scoring algorithm on a typo ("Tube" vs "tube") with
|
|
782
|
+
# no diagnostic (review 2026-07-06).
|
|
783
|
+
valid = ", ".join(d.name for d in registered_metrics())
|
|
784
|
+
raise ValueError(f"unknown comparison mode {mode_name!r}; valid: {valid}")
|
|
785
|
+
|
|
786
|
+
# A number (a caller with the base in hand) is both sides.
|
|
787
|
+
tolerance = parse_sides(tolerance, "tolerance")
|
|
788
|
+
return desc.build(var_override, tolerance)
|