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,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)