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/snap.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The one snap rule (D149): a value the POINTER placed is written at a
|
|
2
|
+
readable step.
|
|
3
|
+
|
|
4
|
+
A drag released on the plot wrote where the pointer happened to stop, to 12
|
|
5
|
+
significant digits — ``0.373778382112`` for a tolerance nobody would type.
|
|
6
|
+
A value placed by the pointer (a drag's release, a click, and the preview
|
|
7
|
+
of either) is rounded to a step of the axis it was placed on: the power of
|
|
8
|
+
ten nearest to a thousandth of what that axis SHOWED, so a plot drawn about
|
|
9
|
+
a thousand pixels wide snaps to about a pixel, and a zoom makes the step
|
|
10
|
+
finer. A typed value is never snapped: this is called for a position, never
|
|
11
|
+
for a field.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
|
|
18
|
+
#: The smallest step, as its power of ten: what an axis showing nothing a
|
|
19
|
+
#: thousandth of which is a number snaps to — so nothing is rounded away.
|
|
20
|
+
_SMALLEST = -300
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _exponent(span: object) -> int | None:
|
|
24
|
+
"""The step's power of ten for an axis showing ``span``; None where
|
|
25
|
+
``span`` is no number at all — a caller that is no pointer — and then
|
|
26
|
+
nothing is snapped. A span that is zero, negative, not finite or under
|
|
27
|
+
1e-300 has the smallest step: `log10` of a thousandth of it is no
|
|
28
|
+
number, or is one of an underflowed zero, which raised."""
|
|
29
|
+
if isinstance(span, bool) or not isinstance(span, (int, float)):
|
|
30
|
+
return None
|
|
31
|
+
if not math.isfinite(span) or span < 1e-300:
|
|
32
|
+
return _SMALLEST
|
|
33
|
+
return max(_SMALLEST, round(math.log10(span / 1000)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def snap_step(span: object) -> float | None:
|
|
37
|
+
"""The step a value placed on an axis showing ``span`` is rounded to:
|
|
38
|
+
``10 ** round(log10(span / 1000))`` — nearest in the exponent, so a span
|
|
39
|
+
of 3.1 snaps to 0.001 and one of 3.2 to 0.01. None for a span that is
|
|
40
|
+
no number; the smallest step, 1e-300, for one that shows nothing."""
|
|
41
|
+
exponent = _exponent(span)
|
|
42
|
+
return None if exponent is None else 10.0**exponent
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def snapped(value: float, span: object, *, fraction: bool = False) -> float:
|
|
46
|
+
"""``value`` as a pointer on an axis showing ``span`` writes it.
|
|
47
|
+
|
|
48
|
+
A coordinate, and a tolerance side in the axis' own units, is rounded
|
|
49
|
+
to a multiple of :func:`snap_step` — as a decimal, so the result is the
|
|
50
|
+
float a person would type (``0.374``, never ``0.37400000000000005``);
|
|
51
|
+
a value exactly half way between two multiples goes to the even one.
|
|
52
|
+
A RELATIVE side (``fraction``: a fraction of its target, on no axis) is
|
|
53
|
+
rounded to 3 significant digits. A value that snaps to zero is ``0.0``,
|
|
54
|
+
never ``-0.0``. With no span — a caller that is no pointer — ``value``
|
|
55
|
+
comes back as it is.
|
|
56
|
+
"""
|
|
57
|
+
exponent = _exponent(span)
|
|
58
|
+
if exponent is None or not math.isfinite(value):
|
|
59
|
+
return value
|
|
60
|
+
if fraction:
|
|
61
|
+
return float(f"{value:.3g}") + 0.0
|
|
62
|
+
return round(float(value), -exponent) + 0.0
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""A key nobody reads is an error that names it.
|
|
2
|
+
|
|
3
|
+
``test_spec.json`` is written by hand as well as by the tool, and a key
|
|
4
|
+
the scorer does not read used to be kept and ignored: a ``range`` leaf
|
|
5
|
+
spelled ``"max_val": 0.5`` scored as if it had no upper bound and passed;
|
|
6
|
+
an ``"agianst"`` scored against the primary baseline. This module is the
|
|
7
|
+
ONE check every part of a spec entry goes through — a leaf, a flat
|
|
8
|
+
override, the rows of a metric's tables, the keys inside a ``quantity``
|
|
9
|
+
object and a tolerance pair, and (through :func:`unknown_field`) a window,
|
|
10
|
+
a combinator, the entry's own blocks and the spec's root.
|
|
11
|
+
|
|
12
|
+
What is known is never listed here. It is read from what already says it:
|
|
13
|
+
a metric's config dataclass (its fields, and the ``columns`` metadata of
|
|
14
|
+
its row tables — what the authoring tool's forms are built from) and the
|
|
15
|
+
keys the parser and the evaluator read off every leaf. A metric a plug-in
|
|
16
|
+
registers is therefore checked against ITS config with no change here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import dataclasses
|
|
22
|
+
from collections.abc import Iterable, Mapping
|
|
23
|
+
|
|
24
|
+
from .registry import metric, unnamed_metrics
|
|
25
|
+
|
|
26
|
+
#: Params read off EVERY leaf and override, whatever its metric.
|
|
27
|
+
#:
|
|
28
|
+
#: ``tolerance`` — the evaluator resolves it for each leaf before it builds
|
|
29
|
+
#: the mode (a leaf's own, else the test's), so it is a key of every metric
|
|
30
|
+
#: even where the mode then has no use for the number.
|
|
31
|
+
#:
|
|
32
|
+
#: ``quantity`` — the evaluator looks at it on each leaf, and acts on it
|
|
33
|
+
#: only for a metric that declares ``accepts_quantity``. On any other
|
|
34
|
+
#: metric it is the one documented inert param (docs/extensibility.md,
|
|
35
|
+
#: the Metric contract, item 6; ``MetricDescriptor``): "an unrecognized
|
|
36
|
+
#: ``quantity`` stays an inert param — a trajectory metric keeps scoring
|
|
37
|
+
#: trajectories". That promise is kept here, by name, and not by leniency.
|
|
38
|
+
EVERY_METRIC = ("tolerance", "quantity")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _edits(a: str, b: str) -> int:
|
|
42
|
+
"""How many single edits turn ``a`` into ``b``: a letter added, dropped
|
|
43
|
+
or replaced, or two neighbours swapped (the optimal-string-alignment
|
|
44
|
+
distance — ``agianst`` is ONE slip from ``against``)."""
|
|
45
|
+
previous2: list[int] = []
|
|
46
|
+
previous = list(range(len(b) + 1))
|
|
47
|
+
for i, ca in enumerate(a, start=1):
|
|
48
|
+
row = [i]
|
|
49
|
+
for j, cb in enumerate(b, start=1):
|
|
50
|
+
cost = 0 if ca == cb else 1
|
|
51
|
+
best = min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + cost)
|
|
52
|
+
if i > 1 and j > 1 and ca == b[j - 2] and a[i - 2] == cb:
|
|
53
|
+
best = min(best, previous2[j - 2] + 1)
|
|
54
|
+
row.append(best)
|
|
55
|
+
previous2, previous = previous, row
|
|
56
|
+
return previous[-1]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def near_miss(key: str, names: Iterable[str]) -> str | None:
|
|
60
|
+
"""The ONE of ``names`` that ``key`` is a slip of the hand from, or None.
|
|
61
|
+
|
|
62
|
+
A suggestion that is wrong sends the reader to the wrong field, so one
|
|
63
|
+
is made only for a near miss, and only when it is the only one:
|
|
64
|
+
|
|
65
|
+
- the same name in another case, always (``K`` for ``k``);
|
|
66
|
+
- else at most two edits (:func:`_edits`, case ignored) and at most one
|
|
67
|
+
per three letters typed — ``max_val`` → ``max_value``, ``tolerence``
|
|
68
|
+
→ ``tolerance``, ``frq`` → ``freq``, ``agianst`` → ``against`` — with
|
|
69
|
+
no other name as near: ``m_value`` is as far from ``min_value`` as
|
|
70
|
+
from ``max_value``, and gets neither;
|
|
71
|
+
- else the start, three letters or more, of exactly one name that is
|
|
72
|
+
ONE word — itself (``tol`` for ``tolerance``) or less its last letter
|
|
73
|
+
(``childs`` for ``children``). Never the start of a name of several
|
|
74
|
+
words: ``time`` was offered ``time_tolerance``, ``tube_width``
|
|
75
|
+
``tube_width_mode`` and ``baseline`` ``baseline_points``, each to a
|
|
76
|
+
person who had typed a whole word for another thing (a row's time, a
|
|
77
|
+
tube's width, ``against``). ``min`` for ``min_value`` goes with them:
|
|
78
|
+
nothing tells it from ``time``.
|
|
79
|
+
|
|
80
|
+
``difflib``'s ratio, which this replaces, called these close:
|
|
81
|
+
``start_time`` → ``stop_time`` (a different field), ``notes`` →
|
|
82
|
+
``plots``, ``simulate_only`` → ``simulation``, ``tolerance_mode`` on a
|
|
83
|
+
leaf without one → ``tolerance``; the prefix rule offered ``tube_abs``
|
|
84
|
+
for ``tube`` with four names starting so. Each now lists the fields."""
|
|
85
|
+
names = sorted(set(names))
|
|
86
|
+
typed = key.casefold()
|
|
87
|
+
same = [name for name in names if name.casefold() == typed]
|
|
88
|
+
if same:
|
|
89
|
+
return same[0]
|
|
90
|
+
allowed = min(2, len(typed) // 3)
|
|
91
|
+
scored = sorted((_edits(typed, name.casefold()), name) for name in names)
|
|
92
|
+
if scored and 0 < scored[0][0] <= allowed:
|
|
93
|
+
if len(scored) == 1 or scored[1][0] > scored[0][0]:
|
|
94
|
+
return scored[0][1]
|
|
95
|
+
return None
|
|
96
|
+
for stem in (key, key[:-1]):
|
|
97
|
+
starts = [name for name in names if len(stem) >= 3 and name.startswith(stem)]
|
|
98
|
+
if starts:
|
|
99
|
+
return starts[0] if len(starts) == 1 and "_" not in starts[0] else None
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def unknown_field(keys: Iterable[str], known: Iterable[str]) -> str | None:
|
|
104
|
+
"""What to say about the first of ``keys`` that is not in ``known``, or
|
|
105
|
+
None when all are: ``unknown field "max_val" — did you mean
|
|
106
|
+
"max_value"?`` when ONE known key is a near miss (:func:`near_miss`),
|
|
107
|
+
else the keys there are to choose from."""
|
|
108
|
+
names = sorted(set(known))
|
|
109
|
+
for key in keys:
|
|
110
|
+
if key in names:
|
|
111
|
+
continue
|
|
112
|
+
close = near_miss(str(key), names)
|
|
113
|
+
if close is not None:
|
|
114
|
+
return f'unknown field "{key}" — did you mean "{close}"?'
|
|
115
|
+
return f'unknown field "{key}"; the fields here are: {", ".join(names)}'
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def metric_fields(metric_name: str) -> frozenset[str] | None:
|
|
120
|
+
"""The param keys a metric reads: its config dataclass's fields and
|
|
121
|
+
:data:`EVERY_METRIC`. None — NOT CHECKED — for a name no metric is
|
|
122
|
+
registered under (the parser and the flat path each say that
|
|
123
|
+
themselves) and for a metric whose ``config_cls`` is not a dataclass:
|
|
124
|
+
nothing says what such a metric reads, so nothing is refused for it.
|
|
125
|
+
"""
|
|
126
|
+
desc = metric(metric_name)
|
|
127
|
+
if desc is None or not dataclasses.is_dataclass(desc.config_cls):
|
|
128
|
+
return None
|
|
129
|
+
return frozenset(f.name for f in dataclasses.fields(desc.config_cls)) | frozenset(
|
|
130
|
+
EVERY_METRIC
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def row_fields(metric_name: str) -> dict[str, frozenset[str]]:
|
|
135
|
+
"""``{table field: the keys of one of its rows}`` for a metric's row
|
|
136
|
+
tables — ``points``, ``peaks``, ``events``, ``tube_points`` — from the
|
|
137
|
+
``columns`` metadata on the config field, which names each row key as
|
|
138
|
+
the scorer reads it. A list field with no ``columns`` has no entry and
|
|
139
|
+
its rows are not checked."""
|
|
140
|
+
desc = metric(metric_name)
|
|
141
|
+
if desc is None or not dataclasses.is_dataclass(desc.config_cls):
|
|
142
|
+
return {}
|
|
143
|
+
return {
|
|
144
|
+
f.name: frozenset(column[0] for column in f.metadata["columns"])
|
|
145
|
+
for f in dataclasses.fields(desc.config_cls)
|
|
146
|
+
if f.metadata.get("columns")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def unread_param(
|
|
151
|
+
metric_name: str, params: Mapping[str, object], *, beside: Iterable[str] = ()
|
|
152
|
+
) -> str | None:
|
|
153
|
+
"""What to say about the first key of ``params`` — a leaf's keys bar
|
|
154
|
+
its structural ones, or a flat override's bar ``mode`` — that
|
|
155
|
+
``metric_name`` does not read, or None. A row of one of its tables is
|
|
156
|
+
checked too: ``points row 2: unknown field "tolerence" — did you mean
|
|
157
|
+
"tolerance"?`` (rows count from 1, as the tool's tables do).
|
|
158
|
+
|
|
159
|
+
``beside`` names the keys written beside the params — a leaf's
|
|
160
|
+
``metric`` / ``variable`` / ``against`` / ``window``, an override's
|
|
161
|
+
``mode`` — which the caller reads itself: a misspelling of one of
|
|
162
|
+
THOSE lands among the params, and is offered its spelling (``agianst``
|
|
163
|
+
→ ``against``)."""
|
|
164
|
+
known = metric_fields(metric_name)
|
|
165
|
+
if known is None:
|
|
166
|
+
return None
|
|
167
|
+
problem = unknown_field(params, (*known, *beside))
|
|
168
|
+
if problem is not None:
|
|
169
|
+
return problem
|
|
170
|
+
problem = _unread_inside(params, declares_quantity(metric_name))
|
|
171
|
+
if problem is not None:
|
|
172
|
+
return problem
|
|
173
|
+
for name, columns in row_fields(metric_name).items():
|
|
174
|
+
rows = params.get(name)
|
|
175
|
+
if not isinstance(rows, list):
|
|
176
|
+
continue
|
|
177
|
+
for i, row in enumerate(rows, start=1):
|
|
178
|
+
if not isinstance(row, dict):
|
|
179
|
+
continue # the mode's own reader says what a row must be
|
|
180
|
+
problem = unknown_field(row, columns) or _unread_inside(row, False)
|
|
181
|
+
if problem is not None:
|
|
182
|
+
return f"{name} row {i}: {problem}"
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def declares_quantity(metric_name: str) -> bool:
|
|
187
|
+
"""Whether the metric's config has a ``quantity`` field of its own —
|
|
188
|
+
``scalar`` — and so reads the object's keys. On any other metric
|
|
189
|
+
``quantity`` is the inert param (:data:`EVERY_METRIC`): of any shape,
|
|
190
|
+
read by nothing, and not looked into."""
|
|
191
|
+
desc = metric(metric_name)
|
|
192
|
+
return (
|
|
193
|
+
desc is not None
|
|
194
|
+
and dataclasses.is_dataclass(desc.config_cls)
|
|
195
|
+
and any(f.name == "quantity" for f in dataclasses.fields(desc.config_cls))
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _unread_inside(values: Mapping[str, object], quantity_is_read: bool) -> str | None:
|
|
200
|
+
"""What to say about the first key INSIDE one of ``values`` — a leaf's
|
|
201
|
+
params, or the cells of a table row — that nothing reads, or None:
|
|
202
|
+
``tolerance: unknown field "uper" — did you mean "upper"?``.
|
|
203
|
+
|
|
204
|
+
Two kinds of value are objects. A ``quantity`` holds ``reduce``
|
|
205
|
+
(``modes.QUANTITY_KEYS``): ``{"reduce": "max", "over": "x"}`` scored the
|
|
206
|
+
max and dropped ``over``. Every other object is a tolerance's
|
|
207
|
+
lower/upper pair, and ``sides.parse_sides`` — the one place a pair is
|
|
208
|
+
read — takes exactly those two keys: ``{"lower": 0.1, "uper": 0.2}`` was
|
|
209
|
+
found at compare, as a criterion that could not be configured. Both are
|
|
210
|
+
said here, where the spec is read."""
|
|
211
|
+
from .modes import QUANTITY_KEYS
|
|
212
|
+
from .sides import PAIR_KEYS
|
|
213
|
+
|
|
214
|
+
for name, value in values.items():
|
|
215
|
+
if not isinstance(value, dict):
|
|
216
|
+
continue
|
|
217
|
+
if name == "quantity":
|
|
218
|
+
if not quantity_is_read:
|
|
219
|
+
continue
|
|
220
|
+
problem = unknown_field(value, QUANTITY_KEYS)
|
|
221
|
+
else:
|
|
222
|
+
problem = unknown_field(value, PAIR_KEYS)
|
|
223
|
+
if problem is not None:
|
|
224
|
+
return f"{name}: {problem}"
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def unread_override_key(override: Mapping[str, object]) -> tuple[str, str] | None:
|
|
229
|
+
"""For a flat ``comparison.variable_overrides`` entry holding a key its
|
|
230
|
+
mode does not read: ``(that mode, what to say)``; else None.
|
|
231
|
+
|
|
232
|
+
The mode is the one ``modes.resolve_mode`` gives it. With no ``mode``
|
|
233
|
+
that is the default metric, or ``points`` under ``--default-points``
|
|
234
|
+
(:func:`~.registry.unnamed_metrics`) — the flag is not known where a
|
|
235
|
+
spec is read, so the entry may carry what EITHER reads, and is refused
|
|
236
|
+
only when neither reads all of it. A ``mode`` no metric is registered
|
|
237
|
+
under is not judged here: the comparison fails that variable loudly.
|
|
238
|
+
"""
|
|
239
|
+
params = {k: v for k, v in override.items() if k != "mode"}
|
|
240
|
+
named = override.get("mode") or ""
|
|
241
|
+
modes = (str(named),) if named else unnamed_metrics()
|
|
242
|
+
problems = [unread_param(mode, params, beside=("mode",)) for mode in modes]
|
|
243
|
+
if any(problem is None for problem in problems):
|
|
244
|
+
return None
|
|
245
|
+
assert problems[0] is not None
|
|
246
|
+
return modes[0], problems[0]
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Per-mode :class:`~.types.ScoreSummary` builders.
|
|
2
|
+
|
|
3
|
+
Each function turns one mode's ``VariableComparison`` into the pill text and
|
|
4
|
+
criterion sentence the report and the terminal review loop show. They are
|
|
5
|
+
attached to the mode's :class:`~.registry.MetricDescriptor` and dispatched
|
|
6
|
+
by :func:`~.registry.summarize`; a third-party metric supplies its own or
|
|
7
|
+
inherits :func:`summarize_default`. No mode name appears outside this file
|
|
8
|
+
and the registrations in :mod:`.builtins`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
from .geometry import Box, VLine
|
|
16
|
+
from .sides import Sides, parse_sides, sides_text, signed
|
|
17
|
+
from .types import ScoreSummary, VariableComparison
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _verdict(vc: VariableComparison) -> str:
|
|
21
|
+
return "PASS" if vc.passed else "FAIL"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _at(value: float, time: float) -> str:
|
|
25
|
+
return f"{value:.6e} at t={time:g}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _deviation(value: float, sides: Sides) -> str:
|
|
29
|
+
"""A reported row's deviation beside ITS sides: the magnitude under
|
|
30
|
+
equal ones, as a number always printed; signed under uneven ones, so
|
|
31
|
+
the reader sees which side applies."""
|
|
32
|
+
return f"{abs(value):.3e}" if sides.equal else signed(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _finals(vc: VariableComparison) -> tuple[str, str]:
|
|
36
|
+
return ("final", f"ref={vc.reference_final:.6e} act={vc.actual_final:.6e}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _trajectory_details(vc: VariableComparison) -> tuple[tuple[str, str], ...]:
|
|
40
|
+
"""The pairs behind an NRMSE score: what the review loop printed for
|
|
41
|
+
every variable before summaries became mode-aware."""
|
|
42
|
+
if vc.is_constant:
|
|
43
|
+
spread: tuple[str, str] = ("RMSE", f"{vc.rmse:.6e} (constant signal)")
|
|
44
|
+
else:
|
|
45
|
+
spread = ("RMSE", f"{vc.rmse:.6e} (range {vc.signal_range:.4e})")
|
|
46
|
+
return (
|
|
47
|
+
spread,
|
|
48
|
+
("max |err|", _at(vc.max_abs_error, vc.max_abs_error_time)),
|
|
49
|
+
_finals(vc),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def summarize_default(vc: VariableComparison) -> ScoreSummary:
|
|
54
|
+
"""NRMSE-shaped: the score is an error against a tolerance."""
|
|
55
|
+
return ScoreSummary(
|
|
56
|
+
display=f"NRMSE {vc.nrmse:.3e}",
|
|
57
|
+
criterion=(
|
|
58
|
+
f"NRMSE {vc.nrmse:.3e} vs tolerance {sides_text(vc.tolerance_used)} → "
|
|
59
|
+
f"{_verdict(vc)}"
|
|
60
|
+
),
|
|
61
|
+
score=float(vc.nrmse),
|
|
62
|
+
score_label="NRMSE",
|
|
63
|
+
details=_trajectory_details(vc),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
summarize_nrmse = summarize_default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def summarize_tube(vc: VariableComparison) -> ScoreSummary:
|
|
71
|
+
if vc.tube_points_inside is None:
|
|
72
|
+
return summarize_default(vc)
|
|
73
|
+
pct = vc.tube_points_inside * 100
|
|
74
|
+
details: list[tuple[str, str]] = []
|
|
75
|
+
if vc.tube_worst_violation is not None and vc.tube_worst_violation > 0:
|
|
76
|
+
details.append(
|
|
77
|
+
(
|
|
78
|
+
"worst violation",
|
|
79
|
+
_at(vc.tube_worst_violation, vc.tube_worst_violation_time or 0.0),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
# The tube's own measures only. Its comparator also computes an NRMSE
|
|
83
|
+
# against the reference, which nothing scores: printed here, beside
|
|
84
|
+
# PASS, it read as the criterion's number (`2.82e-01` on a tube 100 %
|
|
85
|
+
# inside) — and the largest deviation says what it could.
|
|
86
|
+
details.append(("max |err|", _at(vc.max_abs_error, vc.max_abs_error_time)))
|
|
87
|
+
details.append(_finals(vc))
|
|
88
|
+
return ScoreSummary(
|
|
89
|
+
display=f"{pct:.1f}% in tube",
|
|
90
|
+
criterion=f"{pct:.1f}% inside tube → {_verdict(vc)} (requires 100%)",
|
|
91
|
+
score=float(vc.tube_points_inside),
|
|
92
|
+
score_label="in tube",
|
|
93
|
+
details=tuple(details),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def summarize_range(vc: VariableComparison) -> ScoreSummary:
|
|
98
|
+
if vc.tube_worst_violation is None:
|
|
99
|
+
return summarize_default(vc)
|
|
100
|
+
diag = vc.diagnostics or {}
|
|
101
|
+
lo, hi = diag.get("min_value"), diag.get("max_value")
|
|
102
|
+
lo_s = "-inf" if lo is None else f"{lo:g}"
|
|
103
|
+
hi_s = "inf" if hi is None else f"{hi:g}"
|
|
104
|
+
details: list[tuple[str, str]] = [("bounds", f"[{lo_s}, {hi_s}]")]
|
|
105
|
+
if vc.tube_worst_violation > 0:
|
|
106
|
+
details.append(
|
|
107
|
+
(
|
|
108
|
+
"worst violation",
|
|
109
|
+
_at(vc.tube_worst_violation, vc.tube_worst_violation_time or 0.0),
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
if vc.tube_points_inside is not None:
|
|
113
|
+
details.append(("inside", f"{vc.tube_points_inside * 100:.1f}% of samples"))
|
|
114
|
+
return ScoreSummary(
|
|
115
|
+
display=f"max_viol {vc.tube_worst_violation:.3e}",
|
|
116
|
+
criterion=(
|
|
117
|
+
f"max violation {vc.tube_worst_violation:.3e} → {_verdict(vc)} (requires 0)"
|
|
118
|
+
),
|
|
119
|
+
score=float(vc.tube_worst_violation),
|
|
120
|
+
score_label="max violation",
|
|
121
|
+
details=tuple(details),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def summarize_points(vc: VariableComparison) -> ScoreSummary:
|
|
126
|
+
diag = vc.diagnostics or {}
|
|
127
|
+
details: list[tuple[str, str]] = []
|
|
128
|
+
if "scored_points" in diag:
|
|
129
|
+
# Of the DECLARED rows: `failed_points` counts a checkpoint that was
|
|
130
|
+
# never scored and `scored_points` does not — "3 of 2 failed".
|
|
131
|
+
n_unscored = diag.get("unscored_points", 0)
|
|
132
|
+
declared = diag["scored_points"] + n_unscored
|
|
133
|
+
said = f"{diag.get('failed_points', 0)} of {declared} failed"
|
|
134
|
+
if n_unscored:
|
|
135
|
+
said += f", {n_unscored} not scored"
|
|
136
|
+
details.append(("checkpoints", said))
|
|
137
|
+
if diag.get("worst_delta", 0.0) > 0:
|
|
138
|
+
details.append(
|
|
139
|
+
("worst delta", _at(diag["worst_delta"], diag.get("worst_time", 0.0)))
|
|
140
|
+
)
|
|
141
|
+
for failure in diag.get("point_failures") or []:
|
|
142
|
+
details.append(
|
|
143
|
+
(f"t={failure.get('time'):g}", str(failure.get("reason", "")))
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
details.append(_finals(vc))
|
|
147
|
+
# Equal sides: the magnitude, as a number always printed. Uneven ones:
|
|
148
|
+
# actual − target, signed, so the reader sees which side applies.
|
|
149
|
+
error = f"{vc.max_abs_error:.3e}"
|
|
150
|
+
if not vc.tolerance_used.equal:
|
|
151
|
+
if "scored_points" in diag:
|
|
152
|
+
error = signed(diag.get("worst_signed_delta", vc.max_abs_error))
|
|
153
|
+
else:
|
|
154
|
+
error = signed(vc.actual_final - vc.reference_final)
|
|
155
|
+
# What is scored: the final value where no row is declared, else the
|
|
156
|
+
# largest |Δ| of the declared ones — which was said of the final value
|
|
157
|
+
# too, though no row need be it. Where it is, is in the details (`worst
|
|
158
|
+
# delta`): its time is a sample's, inside a row's time box.
|
|
159
|
+
what = "Largest point" if "scored_points" in diag else "Final value"
|
|
160
|
+
criterion = (
|
|
161
|
+
f"{what} error {error} vs tolerance "
|
|
162
|
+
f"{sides_text(vc.tolerance_used)} → {_verdict(vc)}"
|
|
163
|
+
)
|
|
164
|
+
# Among declared points, the reported one's sides are not the leaf's
|
|
165
|
+
# (its own pair or number), or are relative, or its delta is not the
|
|
166
|
+
# largest, or the leaf's are a pair: the scorer recorded that point —
|
|
167
|
+
# the one furthest outside its band, or of the largest |Δ| when none
|
|
168
|
+
# is — with the sides IT was judged on.
|
|
169
|
+
if "worst_sides" in diag:
|
|
170
|
+
own = parse_sides(diag["worst_sides"], "worst_sides")
|
|
171
|
+
error = _deviation(diag["worst_signed_delta"], own)
|
|
172
|
+
relative = " (relative)" if diag["worst_sides_relative"] else ""
|
|
173
|
+
criterion = (
|
|
174
|
+
f"Point at t={diag['worst_point_time']:g}: error {error} vs tolerance "
|
|
175
|
+
f"{sides_text(own)}{relative} → {_verdict(vc)}"
|
|
176
|
+
)
|
|
177
|
+
# A checkpoint that was never scored (outside the reference, or the run
|
|
178
|
+
# never reached it) is the failure, whatever the scored rows did: the
|
|
179
|
+
# sentence named a row that passes beside FAIL. A scored row that fails
|
|
180
|
+
# as well is counted beside it: the one sentence hid that any did.
|
|
181
|
+
if "unscored_point" in diag:
|
|
182
|
+
unscored = diag["unscored_point"]
|
|
183
|
+
scored = diag["failed_points"] - diag["unscored_points"]
|
|
184
|
+
also = ""
|
|
185
|
+
if scored > 0:
|
|
186
|
+
also = (
|
|
187
|
+
f" (and {scored} scored point{'' if scored == 1 else 's'} "
|
|
188
|
+
f"fail{'s' if scored == 1 else ''})"
|
|
189
|
+
)
|
|
190
|
+
criterion = (
|
|
191
|
+
f"Point at t={unscored['time']:g}: not scored — {unscored['reason']}"
|
|
192
|
+
f"{also} → {_verdict(vc)}"
|
|
193
|
+
)
|
|
194
|
+
return ScoreSummary(
|
|
195
|
+
display=f"|err| {vc.max_abs_error:.3e}",
|
|
196
|
+
criterion=criterion,
|
|
197
|
+
score=float(vc.max_abs_error),
|
|
198
|
+
score_label="|err|",
|
|
199
|
+
details=tuple(details),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def summarize_scalar(vc: VariableComparison) -> ScoreSummary:
|
|
204
|
+
diag = vc.diagnostics or {}
|
|
205
|
+
verdict = _verdict(vc)
|
|
206
|
+
if not math.isfinite(vc.actual_final):
|
|
207
|
+
# Hard-fail sentinel (unresolvable quantity, no samples, a reduction
|
|
208
|
+
# that came out non-finite): there is no scalar to show, so surface
|
|
209
|
+
# the reason instead of "nan vs nan". The sentinel keeps the
|
|
210
|
+
# variable's NAME, so the name is never the reason — every route that
|
|
211
|
+
# builds one stamps ``diagnostics["error"]``.
|
|
212
|
+
reason = diag.get("error") or "quantity could not be resolved"
|
|
213
|
+
return ScoreSummary(
|
|
214
|
+
display="not scored", criterion=f"Scalar leaf not scored — {reason}"
|
|
215
|
+
)
|
|
216
|
+
lo, hi = diag.get("min_value"), diag.get("max_value")
|
|
217
|
+
if lo is not None or hi is not None:
|
|
218
|
+
lo_s = "-inf" if lo is None else f"{lo:g}"
|
|
219
|
+
hi_s = "inf" if hi is None else f"{hi:g}"
|
|
220
|
+
return ScoreSummary(
|
|
221
|
+
display=f"value {vc.actual_final:.6g}",
|
|
222
|
+
criterion=f"Scalar {vc.actual_final:.6g} within [{lo_s}, {hi_s}] → {verdict}",
|
|
223
|
+
score=float(vc.actual_final),
|
|
224
|
+
score_label="value",
|
|
225
|
+
details=(("reduction", str(diag.get("reduction"))),),
|
|
226
|
+
)
|
|
227
|
+
fallback = bool(diag.get("abs_fallback"))
|
|
228
|
+
rel = diag.get("tolerance_mode") == "rel" and not fallback
|
|
229
|
+
criterion = "relative" if rel else "absolute"
|
|
230
|
+
note = " (|ref|=0 → absolute)" if fallback else ""
|
|
231
|
+
# Uneven sides: the signed score, as the scorer judged it (`d / |ref|`
|
|
232
|
+
# in rel) — the same two operations on the same two numbers.
|
|
233
|
+
deviation = f"{vc.nrmse:.3e}"
|
|
234
|
+
if not vc.tolerance_used.equal:
|
|
235
|
+
d = vc.actual_final - vc.reference_final
|
|
236
|
+
deviation = signed(d / abs(vc.reference_final) if rel else d)
|
|
237
|
+
return ScoreSummary(
|
|
238
|
+
display=(f"rel Δ {vc.nrmse:.3e}" if rel else f"|Δ| {vc.nrmse:.3e}"),
|
|
239
|
+
criterion=(
|
|
240
|
+
f"Scalar {vc.actual_final:.6g} vs baseline {vc.reference_final:.6g}: "
|
|
241
|
+
f"{criterion} deviation {deviation} vs "
|
|
242
|
+
f"tolerance {sides_text(vc.tolerance_used)}{note} → {verdict}"
|
|
243
|
+
),
|
|
244
|
+
score=float(vc.nrmse),
|
|
245
|
+
score_label="rel Δ" if rel else "|Δ|",
|
|
246
|
+
details=(
|
|
247
|
+
("reduction", str(diag.get("reduction"))),
|
|
248
|
+
("scalar", f"ref={vc.reference_final:.6g} act={vc.actual_final:.6g}"),
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _events_not_found(vc: VariableComparison) -> list[float]:
|
|
254
|
+
"""The declared events that claimed no actual event, in declared order:
|
|
255
|
+
the scorer draws each declared event as a line and its window, and a
|
|
256
|
+
window nothing was found in is the one not ``passed``. The ids are read
|
|
257
|
+
by their tail — the evaluator stamps a leaf's path before them."""
|
|
258
|
+
times = {p.id: p.time for p in vc.geometry if isinstance(p, VLine)}
|
|
259
|
+
missing = []
|
|
260
|
+
for p in vc.geometry:
|
|
261
|
+
head, window, k = p.id.rpartition("events/window/")
|
|
262
|
+
if isinstance(p, Box) and window and p.passed is False:
|
|
263
|
+
missing.append(times[f"{head}events/ref/{k}"])
|
|
264
|
+
return missing
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def summarize_event_timing(vc: VariableComparison) -> ScoreSummary:
|
|
268
|
+
diag = vc.diagnostics or {}
|
|
269
|
+
ref_n = diag.get("ref_event_count", "?")
|
|
270
|
+
act_n = diag.get("act_event_count", "?")
|
|
271
|
+
sides = parse_sides(diag.get("time_tolerance", 0.0), "time_tolerance")
|
|
272
|
+
tol = sides_text(sides)
|
|
273
|
+
# Uneven sides: Δt signed (late positive) — of the pair furthest
|
|
274
|
+
# outside its window, or of the largest |Δt| when none is — recorded by
|
|
275
|
+
# the scorer.
|
|
276
|
+
delta = f"{vc.nrmse:.3e}"
|
|
277
|
+
if not sides.equal:
|
|
278
|
+
delta = signed(diag.get("max_time_delta_signed", vc.nrmse))
|
|
279
|
+
details: list[tuple[str, str]] = [
|
|
280
|
+
("events", f"{act_n} actual / {ref_n} reference"),
|
|
281
|
+
("max Δt", _at(vc.nrmse, vc.max_abs_error_time)),
|
|
282
|
+
]
|
|
283
|
+
criterion = (
|
|
284
|
+
f"Max event Δt {delta} vs {tol} "
|
|
285
|
+
f"({act_n} actual events / {ref_n} reference) → {_verdict(vc)}"
|
|
286
|
+
)
|
|
287
|
+
# Among declared events, the reported one's window is not the leaf's
|
|
288
|
+
# (its own pair or number), or the leaf's is a pair: the window that Δt
|
|
289
|
+
# was held to is that event's (as `summarize_points`).
|
|
290
|
+
if "worst_sides" in diag:
|
|
291
|
+
own = parse_sides(diag["worst_sides"], "worst_sides")
|
|
292
|
+
criterion = (
|
|
293
|
+
f"Event at t={diag['worst_event_time']:g}: "
|
|
294
|
+
f"Δt {_deviation(diag['max_time_delta_signed'], own)} vs "
|
|
295
|
+
f"{sides_text(own)} "
|
|
296
|
+
f"({act_n} actual events / {ref_n} reference) → {_verdict(vc)}"
|
|
297
|
+
)
|
|
298
|
+
# A declared event that was never found is what failed, and the Δt
|
|
299
|
+
# above is not it: that is the matched events', inside the tolerance.
|
|
300
|
+
unmatched = diag.get("unmatched_events", 0)
|
|
301
|
+
if unmatched:
|
|
302
|
+
criterion = (
|
|
303
|
+
f"{unmatched} of {diag['declared_events']} declared events not found "
|
|
304
|
+
f"within tolerance {tol} ({act_n} actual events) → {_verdict(vc)}"
|
|
305
|
+
)
|
|
306
|
+
details.append(
|
|
307
|
+
("not found", ", ".join(f"t={t:g}" for t in _events_not_found(vc)))
|
|
308
|
+
)
|
|
309
|
+
if "counts_match" in diag and not diag["counts_match"]:
|
|
310
|
+
details.append(("event count", "mismatch"))
|
|
311
|
+
return ScoreSummary(
|
|
312
|
+
display=f"Δt {vc.nrmse:.3e} ({act_n}/{ref_n} events)",
|
|
313
|
+
criterion=criterion,
|
|
314
|
+
score=float(vc.nrmse),
|
|
315
|
+
score_label="Δt",
|
|
316
|
+
details=tuple(details),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def summarize_dominant_frequency(vc: VariableComparison) -> ScoreSummary:
|
|
321
|
+
diag = vc.diagnostics or {}
|
|
322
|
+
paired = diag.get("paired_peaks") or []
|
|
323
|
+
n_matched = sum(1 for p in paired if p.get("matched_hz") is not None)
|
|
324
|
+
n_declared = len(paired)
|
|
325
|
+
if n_declared == 0:
|
|
326
|
+
return ScoreSummary(
|
|
327
|
+
display="no peaks declared",
|
|
328
|
+
criterion=(
|
|
329
|
+
"No peaks declared — declare them in the leaf's `peaks` "
|
|
330
|
+
"list; the reference's detected peaks are in the "
|
|
331
|
+
"diagnostics as `detected_reference_peaks_hz`"
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
details = tuple(
|
|
335
|
+
(
|
|
336
|
+
f"peak {p.get('declared_hz', '?')} Hz",
|
|
337
|
+
(
|
|
338
|
+
f"matched at {p['matched_hz']:.6g} Hz"
|
|
339
|
+
if p.get("matched_hz") is not None
|
|
340
|
+
else "not found"
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
for p in paired
|
|
344
|
+
)
|
|
345
|
+
return ScoreSummary(
|
|
346
|
+
display=f"{n_matched}/{n_declared} peaks matched",
|
|
347
|
+
criterion=(
|
|
348
|
+
f"{n_matched} / {n_declared} declared peaks found in actual "
|
|
349
|
+
f"spectrum within tolerance → {_verdict(vc)}"
|
|
350
|
+
),
|
|
351
|
+
score=float(n_matched),
|
|
352
|
+
score_label="peaks matched",
|
|
353
|
+
details=details,
|
|
354
|
+
)
|