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.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -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
+ )