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,2127 @@
1
+ """Per-mode comparison algorithms — pure compute, no orchestration.
2
+
3
+ Lives separately from :mod:`.comparator` (orchestrates compare_test /
4
+ compare_all) and :mod:`.types` (result dataclasses) to make per-mode
5
+ edits easier to review and reason about without scrolling through the
6
+ test-suite-level orchestration.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+
13
+ import numpy as np
14
+
15
+ from .geometry import Band, Box, HLine, Marker, Primitive, Series, VLine
16
+ from .sides import Sides, as_written, parse_number, parse_sides
17
+ from .types import _EPS, VariableComparison
18
+
19
+
20
+ def _find_event_boundaries(time: np.ndarray) -> list[tuple[int, int]]:
21
+ """Find event boundaries where duplicate time values occur.
22
+
23
+ Dymola may produce 2 or 3 duplicate time points per event. This function
24
+ groups consecutive duplicates and returns (first_dup, last_dup) pairs.
25
+ The segment before the event ends at first_dup (inclusive, pre-event value).
26
+ The segment after starts at last_dup (inclusive, post-event value).
27
+ """
28
+ boundaries = []
29
+ i = 1
30
+ while i < len(time):
31
+ if time[i] == time[i - 1]:
32
+ first_dup = i # first duplicate index
33
+ while i < len(time) and time[i] == time[first_dup - 1]:
34
+ i += 1
35
+ last_dup = i - 1 # last duplicate index
36
+ boundaries.append((first_dup, last_dup))
37
+ else:
38
+ i += 1
39
+ return boundaries
40
+
41
+
42
+ def _split_segments(
43
+ time: np.ndarray,
44
+ values: np.ndarray,
45
+ boundaries: list[tuple[int, int]],
46
+ ) -> list[tuple[np.ndarray, np.ndarray]]:
47
+ """Split time/values at event boundaries into piecewise segments.
48
+
49
+ Each segment has strictly monotonic time. At a boundary, the pre-event
50
+ value (first duplicate) ends the previous segment, and the post-event
51
+ value (last duplicate) starts the next segment. Any intermediate
52
+ duplicates are skipped.
53
+ """
54
+ if not boundaries:
55
+ return [(time, values)]
56
+
57
+ segments = []
58
+ prev = 0
59
+ for first_dup, last_dup in boundaries:
60
+ # Segment up to and including the pre-event value
61
+ segments.append((time[prev:first_dup], values[prev:first_dup]))
62
+ prev = last_dup # Next segment starts at the post-event value
63
+
64
+ # Final segment
65
+ if prev < len(time):
66
+ segments.append((time[prev:], values[prev:]))
67
+
68
+ return segments
69
+
70
+
71
+ def _dedup_time_series(
72
+ time: np.ndarray, values: np.ndarray, keep: str = "last"
73
+ ) -> tuple[np.ndarray, np.ndarray]:
74
+ """Remove duplicate time entries, keeping either 'first' or 'last' value."""
75
+ if keep == "first":
76
+ # Keep first occurrence: mask where time differs from previous
77
+ mask = np.concatenate(([True], np.diff(time) != 0))
78
+ else:
79
+ # Keep last occurrence: mask where time differs from next
80
+ mask = np.concatenate((np.diff(time) != 0, [True]))
81
+ return time[mask], values[mask]
82
+
83
+
84
+ def _no_data_failure(mode: str, reason: str) -> VariableComparison:
85
+ """Hard-fail sentinel for comparisons with nothing to compare.
86
+
87
+ Review 2026-07-06 (Theme 1): an empty reference slice, empty actual
88
+ trajectory, or a window that excludes every sample must FAIL with a
89
+ diagnostic — a vacuous pass here would let a dead simulation or a
90
+ mis-specified window report green in a regression framework.
91
+ """
92
+ return VariableComparison(
93
+ index=0,
94
+ name="",
95
+ passed=False,
96
+ nrmse=float("inf"),
97
+ rmse=float("inf"),
98
+ signal_range=0.0,
99
+ max_abs_error=float("inf"),
100
+ max_abs_error_time=0.0,
101
+ reference_final=float("nan"),
102
+ actual_final=float("nan"),
103
+ mode=mode,
104
+ diagnostics={"error": reason},
105
+ )
106
+
107
+
108
+ def actual_on_reference_grid(
109
+ ref_time: np.ndarray,
110
+ ref_values: np.ndarray,
111
+ act_time: np.ndarray,
112
+ act_values: np.ndarray,
113
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
114
+ """The actual trajectory read on the reference grid, event-aware.
115
+
116
+ Returns ``(times, ref_on_times, act_on_times)``. Modelica writes an
117
+ event as two samples at one instant; the reference is split there and
118
+ the actual is deduplicated per segment — first value at a duplicate
119
+ time for a segment ending at an event, last value for one starting at
120
+ an event — so the pre-event actual is never paired with the post-event
121
+ reference. This is THE interpolation the nrmse score uses; the figure
122
+ layer's error series reads the same function, so the error drawn is
123
+ the error scored (an event never shows as a spike the verdict lacks).
124
+ """
125
+ boundaries = _find_event_boundaries(ref_time)
126
+ segments = _split_segments(ref_time, ref_values, boundaries)
127
+
128
+ act_time_pre, act_values_pre = _dedup_time_series(
129
+ act_time, act_values, keep="first"
130
+ )
131
+ act_time_post, act_values_post = _dedup_time_series(
132
+ act_time, act_values, keep="last"
133
+ )
134
+
135
+ all_times = []
136
+ all_ref = []
137
+ all_act = []
138
+ n_segments = len(segments)
139
+ for seg_idx, (seg_time, seg_values) in enumerate(segments):
140
+ if len(seg_time) == 0:
141
+ continue
142
+ is_last_segment = seg_idx == n_segments - 1
143
+ if is_last_segment or n_segments == 1:
144
+ # Last (or only) segment: no event at end, use post-event for start
145
+ seg_actual = np.interp(seg_time, act_time_post, act_values_post)
146
+ elif seg_idx == 0:
147
+ # First segment ending at an event: use pre-event values
148
+ seg_actual = np.interp(seg_time, act_time_pre, act_values_pre)
149
+ else:
150
+ # Interior segment: starts post-event, ends pre-event.
151
+ # Use pre-event for bulk (correct at end boundary), then fix
152
+ # the first point with the post-event value (correct at start).
153
+ seg_actual = np.interp(seg_time, act_time_pre, act_values_pre)
154
+ seg_actual[0] = np.interp(seg_time[0], act_time_post, act_values_post)
155
+ all_times.append(seg_time)
156
+ all_ref.append(seg_values)
157
+ all_act.append(seg_actual)
158
+
159
+ if not all_times:
160
+ empty = np.array([], dtype=float)
161
+ return empty, empty, empty
162
+ return np.concatenate(all_times), np.concatenate(all_ref), np.concatenate(all_act)
163
+
164
+
165
+ def _compare_trajectories(
166
+ ref_time: np.ndarray,
167
+ ref_values: np.ndarray,
168
+ act_time: np.ndarray,
169
+ act_values: np.ndarray,
170
+ tolerance: float,
171
+ ) -> VariableComparison:
172
+ """Compare two time series using piecewise NRMSE.
173
+
174
+ If the reference has duplicate time values (events), the comparison
175
+ is done piecewise between event boundaries. This preserves event
176
+ discontinuities and avoids interpolation across jumps.
177
+
178
+ For each segment:
179
+ - Pre-event segment: interpolate actual using pre-event values (first at duplicate times)
180
+ - Post-event segment: interpolate actual using post-event values (last at duplicate times)
181
+
182
+ Pass/fail metric:
183
+ - NRMSE = RMSE / (max(ref) - min(ref)) for varying signals
184
+ - RMSE directly for constant signals (range ~ 0)
185
+ - Pass if NRMSE < tolerance
186
+ """
187
+ if len(ref_time) == 0:
188
+ return _no_data_failure(
189
+ "nrmse", "no reference samples to compare (empty baseline or window)"
190
+ )
191
+ if len(act_time) == 0:
192
+ return _no_data_failure(
193
+ "nrmse", "no actual samples to compare (empty trajectory or window)"
194
+ )
195
+
196
+ error_times, ref_on_grid, act_on_grid = actual_on_reference_grid(
197
+ ref_time, ref_values, act_time, act_values
198
+ )
199
+ if len(error_times) == 0:
200
+ # All segments empty — can happen with pathological duplicate-only
201
+ # time vectors. Same rule as the guards above: nothing compared
202
+ # means FAIL, not a vacuous pass (review 2026-07-06, finding 1).
203
+ return _no_data_failure(
204
+ "nrmse", "no comparable samples after event segmentation"
205
+ )
206
+ abs_error = np.abs(act_on_grid - ref_on_grid)
207
+
208
+ rmse = float(np.sqrt(np.mean(abs_error**2)))
209
+
210
+ # Max absolute error and its location
211
+ max_abs_idx = int(np.argmax(abs_error))
212
+ max_abs_error = float(abs_error[max_abs_idx])
213
+ max_abs_error_time = float(error_times[max_abs_idx])
214
+
215
+ # Signal range for normalization (across entire reference, including jumps)
216
+ ref_min = float(np.min(ref_values))
217
+ ref_max = float(np.max(ref_values))
218
+ signal_range = ref_max - ref_min
219
+
220
+ # NRMSE: normalize by range, or by magnitude for constant signals.
221
+ # For constants with large magnitude (e.g., 37e9), raw RMSE is misleading
222
+ # because float32 quantization alone can produce errors of hundreds.
223
+ # Normalizing by magnitude gives a meaningful relative metric.
224
+ is_constant = signal_range < _EPS
225
+ if is_constant:
226
+ ref_magnitude = float(np.max(np.abs(ref_values)))
227
+ nrmse = rmse / ref_magnitude if ref_magnitude > _EPS else rmse
228
+ else:
229
+ nrmse = rmse / signal_range
230
+
231
+ passed = nrmse < tolerance
232
+
233
+ ref_final = float(ref_values[-1]) if len(ref_values) > 0 else 0.0
234
+ # Use actual value at final reference time
235
+ act_final = float(np.interp(ref_time[-1], act_time, act_values))
236
+
237
+ return VariableComparison(
238
+ index=0, # Set by caller
239
+ name="", # Set by caller
240
+ passed=passed,
241
+ nrmse=nrmse,
242
+ rmse=rmse,
243
+ signal_range=signal_range,
244
+ max_abs_error=max_abs_error,
245
+ max_abs_error_time=max_abs_error_time,
246
+ reference_final=ref_final,
247
+ actual_final=act_final,
248
+ is_constant=bool(is_constant),
249
+ )
250
+
251
+
252
+ def _box_candidates(
253
+ act_time: np.ndarray,
254
+ act_values: np.ndarray,
255
+ t_lo: float,
256
+ t_hi: float,
257
+ ) -> list[tuple[float, float]]:
258
+ """The time-ordered ``(t, v)`` points a box check evaluates: the
259
+ interpolated ends ``t_lo`` / ``t_hi`` (where inside the trajectory) and
260
+ every sample between them. Empty for an empty box."""
261
+ if len(act_time) == 0 or t_hi < t_lo:
262
+ return []
263
+ candidates: list[tuple[float, float]] = []
264
+ if act_time[0] <= t_lo <= act_time[-1]:
265
+ candidates.append((t_lo, float(np.interp(t_lo, act_time, act_values))))
266
+ for i, t in enumerate(act_time):
267
+ if t_lo <= t <= t_hi:
268
+ candidates.append((float(t), float(act_values[i])))
269
+ if act_time[0] <= t_hi <= act_time[-1]:
270
+ candidates.append((t_hi, float(np.interp(t_hi, act_time, act_values))))
271
+ # A NaN sample is no evidence of anything: it cannot be the closest
272
+ # approach, and treating it as one side of a sign change made a curve
273
+ # 3 units off everywhere "cross" the target and pass. Drop it; a window
274
+ # holding nothing but NaN then reports no comparable sample (inf).
275
+ candidates = [(t, v) for t, v in candidates if not np.isnan(v)]
276
+ # Sort by time so adjacent-pair crossing detection is well-defined.
277
+ candidates.sort(key=lambda tv: tv[0])
278
+ return candidates
279
+
280
+
281
+ def _violation_in_box(
282
+ act_time: np.ndarray,
283
+ act_values: np.ndarray,
284
+ t_lo: float,
285
+ t_hi: float,
286
+ target: float,
287
+ lower: float,
288
+ upper: float,
289
+ ) -> tuple[float, float]:
290
+ """How far the piecewise-linear actual stays outside the band
291
+ ``-lower <= act - target <= upper`` over ``[t_lo, t_hi]``, and the value
292
+ that comes closest to it.
293
+
294
+ The violation is ``0.0`` when a candidate (:func:`_box_candidates`) lies
295
+ in the band, or two adjacent candidates lie on opposite sides of it —
296
+ the curve crossed it between them; else the smallest distance to the
297
+ band; ``inf`` for a box with no candidate.
298
+
299
+ Judged on ``d = v - target``, never on the edges ``target ± limit``: the
300
+ two differ by a rounding step at the edge (``1.0 + 0.1`` is ``1.1``
301
+ while ``1.1 - 1.0`` exceeds ``0.1``), and with equal sides this must be
302
+ ``|d| <= limit`` to the bit — the verdict a number always gave. A band
303
+ inverted by a negative side (``-lower > upper``) holds nothing and is
304
+ crossed by nothing, as a negative tolerance never passed.
305
+ """
306
+ candidates = _box_candidates(act_time, act_values, t_lo, t_hi)
307
+ best, closest = float("inf"), float("nan")
308
+ for _, v in candidates:
309
+ d = v - target
310
+ if -lower <= d <= upper:
311
+ return 0.0, v
312
+ gap = d - upper if d > upper else -lower - d
313
+ if gap < best:
314
+ best, closest = gap, v
315
+ if -lower <= upper:
316
+ for (_, v1), (_, v2) in zip(candidates, candidates[1:]):
317
+ d1, d2 = v1 - target, v2 - target
318
+ if (d1 < -lower and d2 > upper) or (d1 > upper and d2 < -lower):
319
+ return 0.0, target
320
+ return best, closest
321
+
322
+
323
+ def _point_tolerances(
324
+ points: list[dict] | None, tolerance: Sides
325
+ ) -> list[tuple[Sides, Sides]]:
326
+ """Each declared point's ``(tolerance, time_tolerance)``: its own, else
327
+ the leaf's ``tolerance`` (value) or none at all (time); a ``null`` is an
328
+ absent key. A malformed one is a ``ValueError`` naming the row —
329
+ ``PointsMode.__init__`` calls this too, because ``resolve_mode``'s
330
+ callers are where a ``ValueError`` becomes the leaf's config error on
331
+ both scoring paths."""
332
+ out = []
333
+ for i, point in enumerate(points or []):
334
+ value, time = point.get("tolerance"), point.get("time_tolerance")
335
+ out.append(
336
+ (
337
+ tolerance
338
+ if value is None
339
+ else parse_sides(value, f"points[{i}].tolerance"),
340
+ Sides(0.0, 0.0)
341
+ if time is None
342
+ else parse_sides(time, f"points[{i}].time_tolerance"),
343
+ )
344
+ )
345
+ return out
346
+
347
+
348
+ def _min_delta_in_box(
349
+ act_time: np.ndarray,
350
+ act_values: np.ndarray,
351
+ t_lo: float,
352
+ t_hi: float,
353
+ target: float,
354
+ ) -> tuple[float, float]:
355
+ """Find the smallest |act(t) - target| for t in [t_lo, t_hi].
356
+
357
+ Evaluates at every act_time sample inside the window plus the
358
+ interpolated endpoints t_lo and t_hi — so a curve that enters the
359
+ box between samples is still detected. Because the act trace is
360
+ piecewise-linear between samples, the minimum on any segment is
361
+ either at an endpoint or at a zero-crossing of ``act - target``;
362
+ if any segment crosses the target value, min delta is 0.
363
+
364
+ Returns (min_delta, t_at_min). When [t_lo, t_hi] is empty / outside
365
+ the trajectory, returns (inf, t_lo) — caller decides whether that
366
+ counts as a fail or a skip.
367
+ """
368
+ candidates = _box_candidates(act_time, act_values, t_lo, t_hi)
369
+ if not candidates:
370
+ return float("inf"), t_lo
371
+ best_delta = float("inf")
372
+ best_t = t_lo
373
+ for t, v in candidates:
374
+ d = abs(v - target)
375
+ if d < best_delta:
376
+ best_delta = d
377
+ best_t = t
378
+ # Zero-crossing check on each linear segment (between adjacent eval
379
+ # points). If sign of (v - target) flips, the curve passes through
380
+ # the target value somewhere inside the segment → delta = 0 there.
381
+ for (t1, v1), (t2, v2) in zip(candidates, candidates[1:]):
382
+ d1 = v1 - target
383
+ d2 = v2 - target
384
+ if d1 == 0 or d2 == 0:
385
+ continue # Already captured by endpoint scan.
386
+ if (d1 > 0) != (d2 > 0):
387
+ # Linear interp solve: t* = t1 + d1 / (d1 - d2) * (t2 - t1).
388
+ denom = d1 - d2
389
+ if denom != 0:
390
+ t_star = t1 + d1 / denom * (t2 - t1)
391
+ best_delta = 0.0
392
+ best_t = t_star
393
+ break
394
+ return best_delta, best_t
395
+
396
+
397
+ def _signed_delta_in_box(
398
+ act_time: np.ndarray,
399
+ act_values: np.ndarray,
400
+ t_lo: float,
401
+ t_hi: float,
402
+ target: float,
403
+ delta: float,
404
+ ) -> float:
405
+ """``actual − target`` at the value :func:`_min_delta_in_box` found
406
+ ``delta`` (its magnitude) at: the first candidate that far from the
407
+ target, as that scan keeps the first. ``0.0`` where the curve crosses
408
+ the target; ``delta`` itself where no candidate is that far (a box with
409
+ no comparable sample)."""
410
+ if delta == 0.0:
411
+ return 0.0
412
+ for _, v in _box_candidates(act_time, act_values, t_lo, t_hi):
413
+ if abs(v - target) == delta:
414
+ return v - target
415
+ return delta
416
+
417
+
418
+ def _reported_point(
419
+ tolerance: Sides,
420
+ reported: tuple[float, Sides, bool, float] | None,
421
+ worst_delta: float,
422
+ ) -> dict:
423
+ """What `summarize_points` says of the REPORTED point — the one furthest
424
+ outside its own band, the largest |delta|'s when none is — as
425
+ ``(actual − target, the sides it was judged on, whether they are
426
+ relative, its time)``; ``None`` when no point was scored.
427
+
428
+ Recorded wherever the leaf's own sentence — ``worst_delta``, the largest
429
+ |delta| of any point, beside the leaf's sides — would not be that
430
+ point's: its sides are not the leaf's (a pair or a number of its own),
431
+ or they are RELATIVE (the band its delta was held to is those sides
432
+ times |target|, the leaf's number or not), or another point's delta is
433
+ the larger (a passing one, inside wider sides of its own); and where
434
+ the leaf's are a pair, when its signed delta says which side applies.
435
+ A leaf of one number whose reported point defers to it, absolute and
436
+ of the largest delta, carries what it always carried."""
437
+ if reported is None:
438
+ return {} if tolerance.equal else {"worst_signed_delta": 0.0}
439
+ signed_delta, sides, relative, time = reported
440
+ its_own = sides != tolerance or relative or abs(signed_delta) != worst_delta
441
+ if tolerance.equal and not its_own:
442
+ return {}
443
+ return {
444
+ "worst_signed_delta": signed_delta,
445
+ "worst_sides": as_written(sides),
446
+ "worst_sides_relative": relative,
447
+ "worst_point_time": time,
448
+ }
449
+
450
+
451
+ def _compare_points(
452
+ ref_time: np.ndarray,
453
+ ref_values: np.ndarray,
454
+ act_time: np.ndarray,
455
+ act_values: np.ndarray,
456
+ points: list[dict] | None = None,
457
+ tolerance: Sides | float = 1e-4,
458
+ ) -> VariableComparison:
459
+ """Compare actual vs reference at declared time points.
460
+
461
+ When ``points`` is None or empty, falls back to the legacy final-
462
+ value check (act[-1] vs ref[-1] with ``tolerance``). When ``points``
463
+ is a non-empty list, each entry is a checkpoint:
464
+
465
+ ``time`` — absolute time, or None for "trace's final time".
466
+ ``value`` — absolute target. If absent, target = ref(time).
467
+ ``tolerance`` — per-point y-tolerance (defaults to ``tolerance``).
468
+ ``tolerance_mode`` — "abs" (default) | "rel" (scale by |target|).
469
+ ``time_tolerance`` — x-tolerance for the box check (Task 4 of
470
+ the points-mode plan; defaults to 0 = strict-time).
471
+
472
+ Every tolerance is a number (both sides equal) or a lower/upper pair.
473
+ With ``d = actual − target``, a declared point passes iff the actual
474
+ curve meets ``−lower <= d <= upper`` somewhere in its time box
475
+ ``[time − lower, time + upper]`` (``lower``: how much earlier); the
476
+ final-value check passes iff ``−lower < d < upper`` (strict).
477
+ """
478
+ tolerance = parse_sides(tolerance, "tolerance")
479
+ if not points:
480
+ # Implicit final-only — legacy behavior.
481
+ if len(ref_values) == 0 or len(act_values) == 0:
482
+ return VariableComparison(
483
+ index=0,
484
+ name="",
485
+ passed=False,
486
+ nrmse=float("inf"),
487
+ rmse=float("inf"),
488
+ signal_range=0.0,
489
+ max_abs_error=float("inf"),
490
+ max_abs_error_time=0.0,
491
+ reference_final=float("nan"),
492
+ actual_final=float("nan"),
493
+ mode="points",
494
+ diagnostics={"error": "empty trajectory"},
495
+ )
496
+ ref_final = float(ref_values[-1])
497
+ act_final = float(act_values[-1])
498
+ d = act_final - ref_final
499
+ delta = abs(d)
500
+ passed = -tolerance.lower < d < tolerance.upper
501
+ t_final = float(ref_time[-1]) if len(ref_time) else 0.0
502
+ return VariableComparison(
503
+ index=0,
504
+ name="",
505
+ passed=passed,
506
+ nrmse=delta,
507
+ rmse=delta,
508
+ signal_range=0.0,
509
+ max_abs_error=delta,
510
+ max_abs_error_time=t_final,
511
+ reference_final=ref_final,
512
+ actual_final=act_final,
513
+ mode="points",
514
+ diagnostics={"tolerance": as_written(tolerance), "delta": delta},
515
+ geometry=(
516
+ Marker(
517
+ id="points/target/final",
518
+ kind="handle",
519
+ x=t_final,
520
+ y=ref_final,
521
+ passed=passed,
522
+ ),
523
+ Box(
524
+ id="points/box/final",
525
+ kind="handle",
526
+ t0=t_final,
527
+ t1=t_final,
528
+ y0=ref_final - tolerance.lower,
529
+ y1=ref_final + tolerance.upper,
530
+ passed=passed,
531
+ ),
532
+ Marker(
533
+ id="points/upper/final",
534
+ kind="grip",
535
+ x=t_final,
536
+ y=ref_final + tolerance.upper,
537
+ ),
538
+ Marker(
539
+ id="points/lower/final",
540
+ kind="grip",
541
+ x=t_final,
542
+ y=ref_final - tolerance.lower,
543
+ ),
544
+ ),
545
+ )
546
+
547
+ # Declared-points path.
548
+ trace_end = (
549
+ float(ref_time[-1])
550
+ if len(ref_time)
551
+ else float(act_time[-1])
552
+ if len(act_time)
553
+ else 0.0
554
+ )
555
+ scored = 0
556
+ failed = 0
557
+ worst_delta = 0.0
558
+ worst_t = 0.0
559
+ # With uneven sides the largest |delta| need not be the point that
560
+ # breaks its band: +0.04 under an upper side of 0.05 passes while −0.02
561
+ # under a lower side of 0.01 fails. The point the sentence reports is
562
+ # the one that lies FURTHEST OUTSIDE its own band — `violation`, in the
563
+ # band's units, so a `rel` point's is on the scaled band; the largest
564
+ # |delta|'s when every point is inside. Of equals, the first. Each is
565
+ # kept as `_reported_point` takes it.
566
+ worst_outside = 0.0
567
+ outside: tuple[float, Sides, bool, float] | None = None
568
+ largest: tuple[float, Sides, bool, float] | None = None
569
+ # Review 2026-07-06 (finding 2): a checkpoint the simulation never
570
+ # reached — or whose ref-relative target lies beyond the stored
571
+ # baseline — FAILS instead of being skipped. Skipping let truncated
572
+ # simulations pass their tail checkpoints. Reasons are collected for
573
+ # the report.
574
+ geometry: list[Primitive] = []
575
+ point_failures: list[dict] = []
576
+ # The `point_failures` of checkpoints that were never SCORED (each
577
+ # `continue` below). The first is the failure the sentence reports,
578
+ # before any scored point: no delta of another row explains it.
579
+ unscored: list[dict] = []
580
+ row_tolerances = _point_tolerances(points, tolerance)
581
+ for i, point in enumerate(points):
582
+ t = point.get("time")
583
+ t = trace_end if t is None else float(t)
584
+ # Resolve target.
585
+ explicit_value = point.get("value")
586
+ if explicit_value is not None:
587
+ target = float(explicit_value)
588
+ else:
589
+ if len(ref_time) == 0:
590
+ # Ref-relative point with no reference data cannot score.
591
+ # The ``is_baseline_free`` invariant should prevent us
592
+ # from getting here, but if it happens it is a failure,
593
+ # not a skip.
594
+ failed += 1
595
+ unscored.append(
596
+ {"time": t, "reason": "no reference data for ref-relative point"}
597
+ )
598
+ point_failures.append(unscored[-1])
599
+ continue
600
+ if t < float(ref_time[0]) or t > float(ref_time[-1]):
601
+ # np.interp would silently clamp to the nearest endpoint,
602
+ # scoring against a target the baseline never defined.
603
+ failed += 1
604
+ unscored.append(
605
+ {"time": t, "reason": "checkpoint time outside reference range"}
606
+ )
607
+ point_failures.append(unscored[-1])
608
+ continue
609
+ target = float(np.interp(t, ref_time, ref_values))
610
+ # Resolve tolerance + mode.
611
+ per_tol, x_tol = row_tolerances[i]
612
+ mode = point.get("tolerance_mode", "abs")
613
+ # ``rel``: each side is that fraction of |target|.
614
+ lower_limit = per_tol.lower * abs(target) if mode == "rel" else per_tol.lower
615
+ upper_limit = per_tol.upper * abs(target) if mode == "rel" else per_tol.upper
616
+ if len(act_time) == 0:
617
+ failed += 1
618
+ unscored.append(
619
+ {"time": t, "reason": "no actual samples (empty trajectory)"}
620
+ )
621
+ point_failures.append(unscored[-1])
622
+ continue
623
+ t_lo = max(t - x_tol.lower, float(act_time[0]))
624
+ t_hi = min(t + x_tol.upper, float(act_time[-1]))
625
+ if t_hi < t_lo:
626
+ # Time box entirely outside the actual trajectory — the
627
+ # simulation never reached this checkpoint.
628
+ failed += 1
629
+ unscored.append(
630
+ {"time": t, "reason": "checkpoint time outside actual trajectory"}
631
+ )
632
+ point_failures.append(unscored[-1])
633
+ continue
634
+ delta, t_at_min = _min_delta_in_box(
635
+ act_time,
636
+ act_values,
637
+ t_lo,
638
+ t_hi,
639
+ target,
640
+ )
641
+ scored += 1
642
+ if largest is None or delta > worst_delta:
643
+ largest = (
644
+ _signed_delta_in_box(act_time, act_values, t_lo, t_hi, target, delta),
645
+ per_tol,
646
+ mode == "rel",
647
+ t,
648
+ )
649
+ if delta > worst_delta:
650
+ worst_delta = delta
651
+ worst_t = t_at_min
652
+ # ``delta`` (the closest approach to the target itself) stays the
653
+ # reported score; the verdict is the band's.
654
+ violation, closest = _violation_in_box(
655
+ act_time, act_values, t_lo, t_hi, target, lower_limit, upper_limit
656
+ )
657
+ point_passed = violation == 0.0
658
+ if violation > worst_outside:
659
+ worst_outside = violation
660
+ outside = (
661
+ _signed_delta_in_box(act_time, act_values, t_lo, t_hi, target, delta),
662
+ per_tol,
663
+ mode == "rel",
664
+ t,
665
+ )
666
+ lo, hi = target - lower_limit, target + upper_limit
667
+ geometry.append(
668
+ Marker(
669
+ id=f"points/target/{i}",
670
+ kind="handle",
671
+ x=t,
672
+ y=target,
673
+ passed=point_passed,
674
+ )
675
+ )
676
+ geometry.append(
677
+ Box(
678
+ id=f"points/box/{i}",
679
+ kind="handle",
680
+ t0=t - x_tol.lower,
681
+ t1=t + x_tol.upper,
682
+ y0=lo,
683
+ y1=hi,
684
+ passed=point_passed,
685
+ )
686
+ )
687
+ # One grip per side of the box, on its edge. The time grips only
688
+ # where the row HOLDS a time tolerance: a row without one has no
689
+ # width in time to drag.
690
+ geometry.append(Marker(id=f"points/upper/{i}", kind="grip", x=t, y=hi))
691
+ geometry.append(Marker(id=f"points/lower/{i}", kind="grip", x=t, y=lo))
692
+ if point.get("time_tolerance") is not None:
693
+ geometry.append(
694
+ Marker(
695
+ id=f"points/before/{i}",
696
+ kind="grip",
697
+ x=t - x_tol.lower,
698
+ y=target,
699
+ )
700
+ )
701
+ geometry.append(
702
+ Marker(
703
+ id=f"points/after/{i}",
704
+ kind="grip",
705
+ x=t + x_tol.upper,
706
+ y=target,
707
+ )
708
+ )
709
+ if not point_passed:
710
+ failed += 1
711
+ if per_tol.equal:
712
+ reason = f"delta {delta:.6g} exceeds limit {upper_limit:.6g}"
713
+ elif np.isnan(target):
714
+ reason = "the target is not a number"
715
+ elif np.isnan(closest):
716
+ # No sample compares (every one is NaN): there is no
717
+ # closest value to name. A row with no time tolerance has
718
+ # no box to name either.
719
+ in_box = x_tol.lower != 0 or x_tol.upper != 0
720
+ reason = "no comparable sample" + (" in the time box" if in_box else "")
721
+ else:
722
+ reason = f"closest value {closest:.6g} is outside [{lo:.6g}, {hi:.6g}]"
723
+ point_failures.append({"time": t, "reason": reason})
724
+
725
+ passed = scored > 0 and failed == 0
726
+ return VariableComparison(
727
+ index=0,
728
+ name="",
729
+ passed=passed,
730
+ nrmse=worst_delta,
731
+ rmse=worst_delta,
732
+ signal_range=0.0,
733
+ max_abs_error=worst_delta,
734
+ max_abs_error_time=worst_t,
735
+ reference_final=float(ref_values[-1]) if len(ref_values) else float("nan"),
736
+ actual_final=float(act_values[-1]) if len(act_values) else float("nan"),
737
+ mode="points",
738
+ diagnostics={
739
+ "scored_points": scored,
740
+ "failed_points": failed,
741
+ "worst_delta": worst_delta,
742
+ "worst_time": worst_t,
743
+ "point_failures": point_failures,
744
+ # The first, and how many: `failed_points` counts them beside
745
+ # the scored rows that fail.
746
+ **(
747
+ {"unscored_point": unscored[0], "unscored_points": len(unscored)}
748
+ if unscored
749
+ else {}
750
+ ),
751
+ **_reported_point(
752
+ tolerance, outside if outside is not None else largest, worst_delta
753
+ ),
754
+ },
755
+ geometry=tuple(geometry),
756
+ )
757
+
758
+
759
+ def _interpolate_tube_widths(
760
+ eval_time: np.ndarray,
761
+ tube_points: list[dict],
762
+ interpolation: str = "linear",
763
+ ) -> tuple[np.ndarray, np.ndarray]:
764
+ """Interpolate tube upper/lower widths at evaluation times.
765
+
766
+ Control-point format: ``{"time": t, "upper": u, "lower": l}``. A
767
+ symmetric tube sets ``upper == lower``; either side may be omitted and
768
+ defaults to 0.
769
+
770
+ Returns (upper_widths, lower_widths) arrays matching eval_time.
771
+ Before first point: hold. After last: hold.
772
+ """
773
+ if not tube_points:
774
+ return np.zeros_like(eval_time), np.zeros_like(eval_time)
775
+
776
+ pts = sorted(tube_points, key=lambda p: p["time"])
777
+ ctrl_times = np.array([p["time"] for p in pts])
778
+
779
+ if not any("upper" in p or "lower" in p for p in pts):
780
+ raise ValueError(
781
+ "tube_points entries need 'upper' and/or 'lower' widths; "
782
+ f"got keys {sorted(pts[0])}"
783
+ )
784
+ ctrl_upper = np.array([p.get("upper", 0.0) for p in pts])
785
+ ctrl_lower = np.array([p.get("lower", 0.0) for p in pts])
786
+
787
+ if len(pts) == 1 or interpolation == "constant":
788
+ indices = np.searchsorted(ctrl_times, eval_time, side="right") - 1
789
+ indices = np.clip(indices, 0, len(pts) - 1)
790
+ return ctrl_upper[indices], ctrl_lower[indices]
791
+
792
+ upper_widths = np.interp(eval_time, ctrl_times, ctrl_upper)
793
+ lower_widths = np.interp(eval_time, ctrl_times, ctrl_lower)
794
+ return upper_widths, lower_widths
795
+
796
+
797
+ def _tube_bounds(
798
+ eval_time: np.ndarray,
799
+ ref_at_eval: np.ndarray,
800
+ tube_config: dict,
801
+ ) -> tuple[np.ndarray, np.ndarray]:
802
+ """Upper and lower band edges at ``eval_time``.
803
+
804
+ Split out so the envelope is built by ONE piece of code whichever points it
805
+ is asked about — the union grid, or the reference grid. ``_compare_tube``
806
+ scores both and takes the stricter verdict, and that argument only holds if
807
+ the two bands are constructed identically.
808
+ """
809
+ tube_width_mode = tube_config.get("tube_width_mode")
810
+ if tube_width_mode not in ("band", "rel", "abs"):
811
+ raise ValueError(
812
+ f"tube_width_mode must be 'band', 'rel', or 'abs'; got {tube_width_mode!r}"
813
+ )
814
+ tube_points = tube_config.get("tube_points")
815
+ if tube_points:
816
+ raw_upper, raw_lower = _interpolate_tube_widths(
817
+ eval_time,
818
+ tube_points,
819
+ tube_config.get("tube_interpolation", "linear"),
820
+ )
821
+ else:
822
+ # Constant-tube shorthand — only 'band' and 'rel' supported. 'abs'
823
+ # (literal y-bounds) needs both bounds explicitly, so it must use
824
+ # tube_points. A number is both sides; a pair is the width above
825
+ # (``upper``) and below (``lower``) the reference.
826
+ const_abs = parse_sides(tube_config.get("tube_abs", 0.0), "tube_abs")
827
+ const_rel = parse_sides(tube_config.get("tube_rel", 0.0), "tube_rel")
828
+ if tube_width_mode == "band":
829
+ raw_upper = np.full_like(eval_time, const_abs.upper)
830
+ raw_lower = np.full_like(eval_time, const_abs.lower)
831
+ elif tube_width_mode == "rel":
832
+ raw_upper = np.full_like(eval_time, const_rel.upper)
833
+ raw_lower = np.full_like(eval_time, const_rel.lower)
834
+ else: # abs
835
+ raise ValueError(
836
+ "tube_width_mode='abs' requires tube_points; "
837
+ "constant-tube shorthand supports only 'band' and 'rel'."
838
+ )
839
+
840
+ if tube_width_mode == "abs":
841
+ return raw_upper, raw_lower # literal y-axis bounds
842
+
843
+ if tube_width_mode == "rel":
844
+ upper_width = raw_upper * np.abs(ref_at_eval)
845
+ lower_width = raw_lower * np.abs(ref_at_eval)
846
+ else: # band
847
+ upper_width = raw_upper
848
+ lower_width = raw_lower
849
+
850
+ # One number, whoever built the config: a negative one is no floor.
851
+ min_width = parse_number(tube_config.get("tube_min_width", 0.0), "tube_min_width")
852
+ if min_width > 0:
853
+ upper_width = np.maximum(upper_width, min_width)
854
+ lower_width = np.maximum(lower_width, min_width)
855
+
856
+ return ref_at_eval + upper_width, ref_at_eval - lower_width
857
+
858
+
859
+ def _tube_eval_grid(
860
+ ref_time: np.ndarray,
861
+ act_time: np.ndarray,
862
+ ctrl_times: np.ndarray | None,
863
+ ) -> np.ndarray:
864
+ """Breakpoints of both curves, so the band is enforced between samples.
865
+
866
+ A tube is a continuous envelope; the reference grid is only the coarse
867
+ basis on which it is declared. Evaluating on ``ref_time`` alone cannot see
868
+ an excursion that lives between two baseline samples, and rounds away the
869
+ band's own corners when a ``tube_points`` control point falls off that grid
870
+ (declare an upper bound of 0.10 at t=1.5 against a baseline sampling
871
+ 0,1,2,3 and the reconstructed bound there is 0.25).
872
+
873
+ EXACTNESS, AND ITS LIMITS. For ``band`` and ``abs`` width modes with linear
874
+ width interpolation, checking every breakpoint is exactly as strict as
875
+ checking the envelope continuously: between two consecutive breakpoints
876
+ neither curve has a knot, so both are linear, their difference is linear,
877
+ and a linear function attains its extremum at an endpoint. Verified over
878
+ 20,000 random reference/actual/band combinations against a dense scan.
879
+
880
+ That argument does NOT cover three configurations, because each puts a kink
881
+ in the violation function at a time this grid does not carry, leaving it
882
+ concave on a segment so the endpoints understate the true maximum:
883
+
884
+ * ``rel`` width mode -- ``|ref|`` kinks at every reference zero crossing,
885
+ which is where a relative band pinches to zero width.
886
+ * ``tube_min_width`` -- ``max(width, min_width)`` kinks where the clamp
887
+ starts binding.
888
+ * ``tube_interpolation="constant"`` -- a step width means the segment left
889
+ of a widening step is checked with the new, wider width.
890
+
891
+ Each of those kinks is computable in closed form and adding them here makes
892
+ the guarantee unconditional; that is a separate change. Until then this
893
+ grid is strictly stricter than evaluating on ``ref_time``, but exhaustive
894
+ only for the two modes named above.
895
+
896
+ DUPLICATE TIMESTAMPS, AND A KNOWN GAP. A discontinuity is stored as two
897
+ samples at one instant. ``np.unique`` collapses it and ``np.interp`` then
898
+ yields the post-event value, so the pre-event band centre is not checked.
899
+ Evaluating ``ref_values`` by index instead does check both, but pairs the
900
+ pre-event CENTRE against the post-event ACTUAL, which false-FAILS a perfect
901
+ reproduction of the baseline -- so that is not a fix, and keeping it
902
+ alongside this grid would preserve the false failure. Away from a duplicated
903
+ instant the two approaches agree exactly, because interpolating at a node
904
+ returns the node.
905
+
906
+ The residual gap is therefore narrow: an actual whose value is wrong ONLY at
907
+ a duplicated timestamp and correct at every surrounding sample. Handling a
908
+ jump properly -- pairing pre with pre and post with post, and widening to the
909
+ union of both bands where only one series carries the duplicate -- is a
910
+ separate change with its own design question. ``TestKnownGapAtDiscontinuities``
911
+ pins the current behaviour so that change has a target.
912
+
913
+ The grid is clipped to the reference's span because that is where the band
914
+ is declared -- an actual sample past the end of the baseline has no
915
+ envelope to be judged against, which also keeps the evaluated span
916
+ identical to what this function used before.
917
+ """
918
+ parts = [np.asarray(ref_time, dtype=float), np.asarray(act_time, dtype=float)]
919
+ if ctrl_times is not None and len(ctrl_times) > 0:
920
+ parts.append(np.asarray(ctrl_times, dtype=float))
921
+ grid = np.unique(np.concatenate(parts))
922
+ return grid[(grid >= ref_time[0]) & (grid <= ref_time[-1])]
923
+
924
+
925
+ def _tube_geometry(
926
+ grid: np.ndarray,
927
+ tube_lower: np.ndarray,
928
+ tube_upper: np.ndarray,
929
+ tube_points: list[dict] | None,
930
+ ) -> tuple[Primitive, ...]:
931
+ """The band as scored, plus one handle pair per authored control point.
932
+
933
+ Handles sit at the RESOLVED band edge at the control point's time — for
934
+ a ``rel`` tube that is ``ref ± rel·|ref|`` there, not the authored
935
+ fraction — so an editor drags what the user sees. Control-point times
936
+ are on the grid by construction (``_tube_eval_grid``), so the lookup is
937
+ exact. Ids follow the spec's array order: ``tube/upper/3`` addresses
938
+ ``tube_points/3`` in a patch.
939
+
940
+ A tube of constant width (``tube_abs`` / ``tube_rel``, no control
941
+ points) has one grip per side instead, ``tube/upper/const`` and
942
+ ``tube/lower/const``, on the band's edges at the grid's middle sample.
943
+ """
944
+ out: list[Primitive] = [
945
+ Band(id="tube/band", time=grid, lower=tube_lower, upper=tube_upper)
946
+ ]
947
+ if not tube_points:
948
+ mid = len(grid) // 2
949
+ for side, edge in (("upper", tube_upper), ("lower", tube_lower)):
950
+ out.append(
951
+ Marker(
952
+ id=f"tube/{side}/const",
953
+ kind="grip",
954
+ x=float(grid[mid]),
955
+ y=float(edge[mid]),
956
+ )
957
+ )
958
+ for i, pt in enumerate(tube_points or []):
959
+ t = float(pt["time"])
960
+ if t < grid[0] or t > grid[-1]:
961
+ continue # off the span: no band edge exists there to anchor a handle on
962
+ out.append(
963
+ Marker(
964
+ id=f"tube/upper/{i}",
965
+ kind="handle",
966
+ x=t,
967
+ y=float(np.interp(t, grid, tube_upper)),
968
+ )
969
+ )
970
+ out.append(
971
+ Marker(
972
+ id=f"tube/lower/{i}",
973
+ kind="handle",
974
+ x=t,
975
+ y=float(np.interp(t, grid, tube_lower)),
976
+ )
977
+ )
978
+ return tuple(out)
979
+
980
+
981
+ def _compare_tube(
982
+ ref_time: np.ndarray,
983
+ ref_values: np.ndarray,
984
+ act_time: np.ndarray,
985
+ act_values: np.ndarray,
986
+ tube_config: dict,
987
+ ) -> VariableComparison:
988
+ """Compare using a tolerance tube around the reference trajectory.
989
+
990
+ Tube width modes:
991
+ - "band": widths are constant offsets in signal units (ref ± width)
992
+ - "rel": widths are fractions of |reference| (ref ± frac * |ref|)
993
+ - "abs": upper/lower are literal y-axis bounds (not offsets) —
994
+ requires tube_points with explicit upper/lower
995
+
996
+ Supports symmetric (upper == lower) and asymmetric tubes.
997
+ Constant-tube shorthand (tube_abs / tube_rel) supports only
998
+ 'band' and 'rel'; 'abs' must use tube_points.
999
+
1000
+ A point passes if: tube_lower <= actual <= tube_upper.
1001
+ The test passes if ALL points are inside the tube (strict).
1002
+ """
1003
+ if len(ref_time) == 0:
1004
+ return _no_data_failure(
1005
+ "tube", "no reference samples to compare (empty baseline or window)"
1006
+ )
1007
+ if len(act_time) == 0:
1008
+ return _no_data_failure(
1009
+ "tube", "no actual samples to compare (empty trajectory or window)"
1010
+ )
1011
+ # Width-mode validation and the min-width floor live in _tube_bounds, which
1012
+ # is the single place the envelope is constructed.
1013
+ tube_points = tube_config.get("tube_points")
1014
+ ctrl_times = (
1015
+ np.array([p["time"] for p in tube_points], dtype=float) if tube_points else None
1016
+ )
1017
+
1018
+ # The envelope is continuous: evaluate every breakpoint of either curve.
1019
+ grid = _tube_eval_grid(ref_time, act_time, ctrl_times)
1020
+ ref_on_grid = np.interp(grid, ref_time, ref_values)
1021
+ act_on_grid = np.interp(grid, act_time, act_values)
1022
+ tube_upper, tube_lower = _tube_bounds(grid, ref_on_grid, tube_config)
1023
+
1024
+ # Check: tube_lower <= actual <= tube_upper
1025
+ violations = np.maximum(act_on_grid - tube_upper, tube_lower - act_on_grid)
1026
+
1027
+ n_total = len(violations)
1028
+ if n_total == 0:
1029
+ return _no_data_failure(
1030
+ "tube", "no evaluable samples (non-finite or disjoint time span)"
1031
+ )
1032
+ n_inside = int(np.sum(violations <= 0))
1033
+ fraction_inside = n_inside / n_total
1034
+
1035
+ worst_idx = int(np.argmax(violations))
1036
+ worst_violation = float(max(violations[worst_idx], 0.0))
1037
+ worst_violation_time = float(grid[worst_idx])
1038
+
1039
+ passed = n_inside == n_total
1040
+
1041
+ # The NRMSE a tube leaf carries stays on the REFERENCE grid. The verdict
1042
+ # moved to the union grid above; this figure decides nothing — no surface
1043
+ # shows it as the tube's number, and "Worst NRMSE" reads nrmse-scored
1044
+ # criteria only — but it is what `_compare_trajectories`, the authority on
1045
+ # trajectory error, would say of the same pair, and a mean taken over a
1046
+ # denser grid is a different number.
1047
+ act_interp = np.interp(ref_time, act_time, act_values)
1048
+ diff = act_interp - ref_values
1049
+ abs_error = np.abs(diff)
1050
+ rmse = float(np.sqrt(np.mean(abs_error**2)))
1051
+ signal_range = float(np.max(ref_values) - np.min(ref_values))
1052
+ is_constant = signal_range < _EPS
1053
+ if is_constant:
1054
+ ref_magnitude = float(np.max(np.abs(ref_values)))
1055
+ nrmse = rmse / ref_magnitude if ref_magnitude > _EPS else rmse
1056
+ else:
1057
+ nrmse = rmse / signal_range
1058
+
1059
+ max_abs_idx = int(np.argmax(abs_error))
1060
+ max_abs_error = float(abs_error[max_abs_idx])
1061
+ max_abs_error_time = float(ref_time[max_abs_idx])
1062
+
1063
+ ref_final = float(ref_values[-1]) if len(ref_values) > 0 else 0.0
1064
+ act_final = float(act_interp[-1]) if len(act_interp) > 0 else 0.0
1065
+
1066
+ return VariableComparison(
1067
+ index=0,
1068
+ name="",
1069
+ passed=passed,
1070
+ nrmse=nrmse,
1071
+ rmse=rmse,
1072
+ signal_range=signal_range,
1073
+ max_abs_error=max_abs_error,
1074
+ max_abs_error_time=max_abs_error_time,
1075
+ reference_final=ref_final,
1076
+ actual_final=act_final,
1077
+ is_constant=bool(is_constant),
1078
+ mode="tube",
1079
+ tube_points_inside=fraction_inside,
1080
+ tube_worst_violation=worst_violation,
1081
+ tube_worst_violation_time=worst_violation_time,
1082
+ geometry=_tube_geometry(grid, tube_lower, tube_upper, tube_points),
1083
+ )
1084
+
1085
+
1086
+ def _compare_range(
1087
+ act_time: np.ndarray,
1088
+ act_values: np.ndarray,
1089
+ min_value: float | None,
1090
+ max_value: float | None,
1091
+ ) -> VariableComparison:
1092
+ """Check that every sample of the actual signal lies within [min, max].
1093
+
1094
+ Reference data is not used — the bounds come from the spec itself.
1095
+ This gives the MetricTree a leaf type that works without a stored
1096
+ baseline (the "is this variable always within safe limits" pattern).
1097
+ """
1098
+ if len(act_values) == 0:
1099
+ return _no_data_failure(
1100
+ "range", "no actual samples to check (empty trajectory or window)"
1101
+ )
1102
+ above = (
1103
+ np.maximum(act_values - max_value, 0.0)
1104
+ if max_value is not None
1105
+ else np.zeros_like(act_values)
1106
+ )
1107
+ below = (
1108
+ np.maximum(min_value - act_values, 0.0)
1109
+ if min_value is not None
1110
+ else np.zeros_like(act_values)
1111
+ )
1112
+ violations = np.maximum(above, below)
1113
+
1114
+ n_total = len(act_values)
1115
+ n_inside = int(np.sum(violations <= 0))
1116
+ fraction_inside = n_inside / n_total if n_total > 0 else 1.0
1117
+
1118
+ worst_idx = int(np.argmax(violations)) if n_total > 0 else 0
1119
+ max_violation = float(violations[worst_idx]) if n_total > 0 else 0.0
1120
+ worst_time = float(act_time[worst_idx]) if n_total > 0 else 0.0
1121
+
1122
+ passed = max_violation <= 0.0
1123
+ act_range = float(np.max(act_values) - np.min(act_values)) if n_total > 0 else 0.0
1124
+ act_final = float(act_values[-1]) if n_total > 0 else 0.0
1125
+
1126
+ return VariableComparison(
1127
+ index=0,
1128
+ name="",
1129
+ passed=passed,
1130
+ # Signal-free metric: use max_violation as the score ("0 = all in bounds,
1131
+ # larger = farther out of bounds"). nrmse/rmse are overloaded here for
1132
+ # reporting uniformity.
1133
+ nrmse=max_violation,
1134
+ rmse=float(np.sqrt(np.mean(violations**2))) if n_total > 0 else 0.0,
1135
+ signal_range=act_range,
1136
+ max_abs_error=max_violation,
1137
+ max_abs_error_time=worst_time,
1138
+ reference_final=float("nan"),
1139
+ actual_final=act_final,
1140
+ is_constant=bool(act_range < _EPS),
1141
+ mode="range",
1142
+ # Repurpose the tube fields for consistent reporting — "inside" reads
1143
+ # as "inside bounds" here.
1144
+ tube_points_inside=fraction_inside,
1145
+ tube_worst_violation=max_violation,
1146
+ tube_worst_violation_time=worst_time,
1147
+ # Preserve the declared bounds so the reporter can report them.
1148
+ diagnostics={"min_value": min_value, "max_value": max_value},
1149
+ geometry=tuple(
1150
+ HLine(
1151
+ id=f"range/{label}",
1152
+ kind="handle",
1153
+ value=float(bound),
1154
+ passed=bool(np.all(viol <= 0)),
1155
+ )
1156
+ for label, bound, viol in (
1157
+ ("min", min_value, below),
1158
+ ("max", max_value, above),
1159
+ )
1160
+ if bound is not None
1161
+ ),
1162
+ )
1163
+
1164
+
1165
+ #: Series reductions a ``scalar`` leaf may declare. Closed set in v1 — the
1166
+ #: quantity field takes either one of these or a stats path, and grows an
1167
+ #: ``{"expr": ...}`` shape later without breaking either.
1168
+ SCALAR_REDUCTIONS = ("max", "min", "mean", "sample_mean", "final", "integral")
1169
+
1170
+
1171
+ def _reduce_series(time: np.ndarray, values: np.ndarray, reduction: str) -> float:
1172
+ """Reduce a (already windowed) series to the number a scalar leaf scores.
1173
+
1174
+ ``mean`` is the TIME-WEIGHTED mean (integral / time span), not the
1175
+ sample mean: a solver that emits a finer output grid, or extra samples
1176
+ around events, must not move the score of an identical trajectory. It
1177
+ degrades to the sample mean when the span is zero (a single sample, or
1178
+ the 1-sample arrays a stats-path leaf is handed).
1179
+
1180
+ ``sample_mean`` is the plain arithmetic mean of the samples — it DOES
1181
+ move with the output grid, and exists for parity with tools that report
1182
+ that number.
1183
+
1184
+ ``integral`` is the trapezoid rule spelled out rather than
1185
+ ``np.trapezoid`` — that spelling only exists from numpy 2.0 and the
1186
+ package supports 1.20+.
1187
+ """
1188
+ if reduction == "final":
1189
+ return float(values[-1])
1190
+ if reduction == "max":
1191
+ return float(np.max(values))
1192
+ if reduction == "min":
1193
+ return float(np.min(values))
1194
+ if reduction == "mean":
1195
+ span = float(time[-1] - time[0]) if len(time) > 1 else 0.0
1196
+ if span <= 0.0:
1197
+ return float(np.mean(values))
1198
+ return _trapezoid(time, values) / span
1199
+ if reduction == "sample_mean":
1200
+ return float(np.mean(values))
1201
+ if reduction == "integral":
1202
+ return _trapezoid(time, values)
1203
+ # Unreachable — ScalarMode validates the reduction at construction time.
1204
+ raise ValueError(f"unknown scalar reduction {reduction!r}")
1205
+
1206
+
1207
+ def _trapezoid(time: np.ndarray, values: np.ndarray) -> float:
1208
+ """Trapezoid-rule integral of ``values`` over ``time``."""
1209
+ if len(values) < 2:
1210
+ return 0.0
1211
+ return float(np.sum(np.diff(time) * (values[:-1] + values[1:]) / 2.0))
1212
+
1213
+
1214
+ def _scalar_geometry(
1215
+ act_scalar: float,
1216
+ min_value: float | None,
1217
+ max_value: float | None,
1218
+ *,
1219
+ ref_scalar: float | None,
1220
+ passed: bool,
1221
+ allowed: tuple[float, float] | None = None,
1222
+ ) -> tuple[Primitive, ...]:
1223
+ """The scalar and what it was judged against, as horizontal lines.
1224
+
1225
+ A scalar leaf on a statistics path has no trajectory at all; these
1226
+ lines are then the whole figure. ``allowed`` is what the baseline form
1227
+ lets the scalar be, ``(lowest, highest)``: drawn, and dragged, as the
1228
+ bounds form's bounds are.
1229
+ """
1230
+ out: list[Primitive] = [
1231
+ HLine(id="scalar/actual", kind="act", value=float(act_scalar), passed=passed)
1232
+ ]
1233
+ if ref_scalar is not None:
1234
+ out.append(HLine(id="scalar/reference", kind="ref", value=float(ref_scalar)))
1235
+ if min_value is not None:
1236
+ out.append(HLine(id="scalar/min", kind="handle", value=float(min_value)))
1237
+ if max_value is not None:
1238
+ out.append(HLine(id="scalar/max", kind="handle", value=float(max_value)))
1239
+ if allowed is not None:
1240
+ lowest, highest = allowed
1241
+ out.append(HLine(id="scalar/upper", kind="handle", value=float(highest)))
1242
+ out.append(HLine(id="scalar/lower", kind="handle", value=float(lowest)))
1243
+ return tuple(out)
1244
+
1245
+
1246
+ def _compare_scalar(
1247
+ ref_time: np.ndarray,
1248
+ ref_values: np.ndarray,
1249
+ act_time: np.ndarray,
1250
+ act_values: np.ndarray,
1251
+ *,
1252
+ reduction: str,
1253
+ tolerance: Sides | float,
1254
+ tolerance_mode: str,
1255
+ min_value: float | None,
1256
+ max_value: float | None,
1257
+ ) -> VariableComparison:
1258
+ """Score one number: reduced series (or resolved stats path) vs baseline.
1259
+
1260
+ Two forms, selected by whether bounds are declared:
1261
+
1262
+ * **bounds** (``min_value`` / ``max_value`` set) — baseline-free; pass
1263
+ iff the actual scalar lies within the inclusive bounds. The score is
1264
+ the distance outside them (0.0 inside), mirroring ``_compare_range``.
1265
+ * **baseline** — ``d = act - ref``; ``abs`` passes when
1266
+ ``-lower <= d <= upper``, ``rel`` when ``d / |ref|`` lies in that
1267
+ range, ``tolerance`` being a number (both sides) or a lower/upper
1268
+ pair. A zero-magnitude reference makes the relative criterion
1269
+ degenerate, so ``rel`` falls back to the absolute comparison when
1270
+ ``|ref| == 0``. The score stays the magnitude of the deviation.
1271
+
1272
+ The evaluator hands stats-path leaves 1-sample arrays, so the same
1273
+ reduction path serves both quantity shapes.
1274
+ """
1275
+ if len(act_values) == 0:
1276
+ return _no_data_failure(
1277
+ "scalar", "no actual samples to reduce (empty trajectory or window)"
1278
+ )
1279
+ act_scalar = _reduce_series(act_time, act_values, reduction)
1280
+ # A non-finite reduction is nothing to score: a NaN among the samples, or
1281
+ # a window that left the reduction undefined. Fail with the reason rather
1282
+ # than carrying nan into the verdict — every surface reads
1283
+ # ``diagnostics["error"]`` when the numbers are not there.
1284
+ if not np.isfinite(act_scalar):
1285
+ return _no_data_failure(
1286
+ "scalar",
1287
+ f"{reduction} of the actual samples is not finite",
1288
+ )
1289
+
1290
+ diagnostics: dict = {
1291
+ "reduction": reduction,
1292
+ "tolerance_mode": tolerance_mode,
1293
+ "min_value": min_value,
1294
+ "max_value": max_value,
1295
+ "actual_scalar": act_scalar,
1296
+ }
1297
+
1298
+ if min_value is not None or max_value is not None:
1299
+ above = max(act_scalar - max_value, 0.0) if max_value is not None else 0.0
1300
+ below = max(min_value - act_scalar, 0.0) if min_value is not None else 0.0
1301
+ violation = max(above, below)
1302
+ return VariableComparison(
1303
+ index=0,
1304
+ name="",
1305
+ passed=violation <= 0.0,
1306
+ # Signal-free metric: nrmse/rmse carry the score ("0 = in bounds,
1307
+ # larger = farther out"), as in _compare_range.
1308
+ nrmse=violation,
1309
+ rmse=violation,
1310
+ signal_range=0.0,
1311
+ max_abs_error=violation,
1312
+ max_abs_error_time=0.0,
1313
+ reference_final=float("nan"),
1314
+ actual_final=act_scalar,
1315
+ mode="scalar",
1316
+ tube_worst_violation=violation,
1317
+ diagnostics=diagnostics,
1318
+ geometry=_scalar_geometry(
1319
+ act_scalar,
1320
+ min_value,
1321
+ max_value,
1322
+ ref_scalar=None,
1323
+ passed=violation <= 0.0,
1324
+ ),
1325
+ )
1326
+
1327
+ if len(ref_values) == 0:
1328
+ return _no_data_failure(
1329
+ "scalar", "no reference samples to reduce (empty baseline or window)"
1330
+ )
1331
+ ref_scalar = _reduce_series(ref_time, ref_values, reduction)
1332
+ if not np.isfinite(ref_scalar):
1333
+ return _no_data_failure(
1334
+ "scalar",
1335
+ f"{reduction} of the reference samples is not finite",
1336
+ )
1337
+ tolerance = parse_sides(tolerance, "tolerance")
1338
+ d = act_scalar - ref_scalar
1339
+ err = abs(d)
1340
+ # A zero-magnitude reference makes the relative criterion degenerate —
1341
+ # fall back to the absolute test and record that it fired, so the report
1342
+ # can say which criterion actually decided the verdict.
1343
+ abs_fallback = tolerance_mode == "rel" and abs(ref_scalar) == 0.0
1344
+ # ``rel``: each side is that fraction of |ref| — the plain side under
1345
+ # the fallback.
1346
+ scale = abs(ref_scalar) if tolerance_mode == "rel" and not abs_fallback else 1.0
1347
+ signed = d / abs(ref_scalar) if tolerance_mode == "rel" and not abs_fallback else d
1348
+ # What a ``weighted`` combinator reads stays a magnitude; the verdict is
1349
+ # each side's.
1350
+ score = abs(signed)
1351
+ passed = bool(-tolerance.lower <= signed <= tolerance.upper)
1352
+ diagnostics["reference_scalar"] = ref_scalar
1353
+ diagnostics["abs_fallback"] = abs_fallback
1354
+
1355
+ return VariableComparison(
1356
+ index=0,
1357
+ name="",
1358
+ passed=passed,
1359
+ nrmse=score,
1360
+ rmse=err,
1361
+ signal_range=abs(ref_scalar),
1362
+ max_abs_error=err,
1363
+ max_abs_error_time=0.0,
1364
+ reference_final=ref_scalar,
1365
+ actual_final=act_scalar,
1366
+ # NOT is_constant: that flag means "the reference SIGNAL has zero
1367
+ # range", which the console renders as "(constant signal)". A scalar
1368
+ # leaf never compares signals, so the flag would be a false claim.
1369
+ mode="scalar",
1370
+ diagnostics=diagnostics,
1371
+ geometry=_scalar_geometry(
1372
+ act_scalar,
1373
+ None,
1374
+ None,
1375
+ ref_scalar=ref_scalar,
1376
+ passed=passed,
1377
+ allowed=(
1378
+ ref_scalar - tolerance.lower * scale,
1379
+ ref_scalar + tolerance.upper * scale,
1380
+ ),
1381
+ ),
1382
+ )
1383
+
1384
+
1385
+ def _event_geometry(
1386
+ ref_events: list[float],
1387
+ act_events: list[float],
1388
+ tolerances: list[Sides],
1389
+ matched: Sequence[bool | None],
1390
+ ) -> tuple[Primitive, ...]:
1391
+ """Reference (or declared) events with their tolerance windows, and the
1392
+ actual events. Windows span the panel height (``y0``/``y1`` ``None``)
1393
+ and run from ``lower`` before the event to ``upper`` after it.
1394
+
1395
+ ``matched[i]`` is ``None`` for a window that was never scored — an
1396
+ auto-detected reference event beyond the paired count contributes
1397
+ nothing to the verdict (only ``count_must_match`` can fail the leaf on
1398
+ it), so drawing it failed would contradict a passing leaf."""
1399
+ out: list[Primitive] = []
1400
+ for i, (t, tol, ok) in enumerate(zip(ref_events, tolerances, matched)):
1401
+ out.append(VLine(id=f"events/ref/{i}", kind="ref", time=float(t)))
1402
+ out.append(
1403
+ Box(
1404
+ id=f"events/window/{i}",
1405
+ kind="handle",
1406
+ t0=float(t - tol.lower),
1407
+ t1=float(t + tol.upper),
1408
+ y0=None,
1409
+ y1=None,
1410
+ passed=None if ok is None else bool(ok),
1411
+ )
1412
+ )
1413
+ out.append(
1414
+ VLine(id=f"events/early/{i}", kind="grip", time=float(t - tol.lower))
1415
+ )
1416
+ out.append(VLine(id=f"events/late/{i}", kind="grip", time=float(t + tol.upper)))
1417
+ for j, t in enumerate(act_events):
1418
+ out.append(VLine(id=f"events/act/{j}", kind="act", time=float(t)))
1419
+ return tuple(out)
1420
+
1421
+
1422
+ def _event_tolerances(events: list[dict] | None, time_tolerance: Sides) -> list[Sides]:
1423
+ """Each declared event's window sides: its own ``tolerance``, else the
1424
+ leaf's ``time_tolerance`` (a ``null`` is an absent key). ``lower`` is how
1425
+ much EARLIER the actual event may come, ``upper`` how much LATER. A
1426
+ malformed one is a ``ValueError`` naming the row;
1427
+ ``EventTimingMode.__init__`` calls this too, so it is the leaf's config
1428
+ error on both scoring paths."""
1429
+ return [
1430
+ time_tolerance
1431
+ if event.get("tolerance") is None
1432
+ else parse_sides(event["tolerance"], f"events[{k}].tolerance")
1433
+ for k, event in enumerate(events or [])
1434
+ ]
1435
+
1436
+
1437
+ def _signed_time_delta(time_tolerance: Sides, max_signed: float) -> dict:
1438
+ """``actual − reference`` (late positive) of the pair that lies furthest
1439
+ outside its window — of the largest |Δt| when none does — under
1440
+ ``max_time_delta_signed``, for a sentence whose sides differ
1441
+ (`summarize_event_timing`): recorded only there, so a leaf with a
1442
+ number carries what it always carried."""
1443
+ return {} if time_tolerance.equal else {"max_time_delta_signed": max_signed}
1444
+
1445
+
1446
+ def _reported_event(
1447
+ time_tolerance: Sides, reported: tuple[float, Sides, float] | None
1448
+ ) -> dict:
1449
+ """What `summarize_event_timing` says of the REPORTED declared event —
1450
+ the matched pair of the largest |Δt| — as ``(actual − declared, the
1451
+ sides of its window, its declared time)``; ``None`` when no declared
1452
+ event was found.
1453
+
1454
+ As `_reported_point`: recorded where that event's window is not the
1455
+ leaf's — a pair or a number of its own — or the leaf's is a pair, so
1456
+ the sentence names the window the Δt was held to. A leaf of one number
1457
+ whose reported event defers to it carries what it always carried."""
1458
+ if reported is None:
1459
+ return _signed_time_delta(time_tolerance, 0.0)
1460
+ signed_delta, sides, time = reported
1461
+ if sides == time_tolerance and time_tolerance.equal:
1462
+ return {}
1463
+ return {
1464
+ "max_time_delta_signed": signed_delta,
1465
+ "worst_sides": as_written(sides),
1466
+ "worst_event_time": time,
1467
+ }
1468
+
1469
+
1470
+ def _compare_event_timing(
1471
+ ref_time: np.ndarray,
1472
+ act_time: np.ndarray,
1473
+ time_tolerance: Sides | float = 1e-3,
1474
+ count_must_match: bool = True,
1475
+ declared_events: list[dict] | None = None,
1476
+ ) -> VariableComparison:
1477
+ """Compare event instants between reference and actual signals (4.C.1).
1478
+
1479
+ Two paths:
1480
+
1481
+ * ``declared_events is None`` (default): events are auto-detected
1482
+ from duplicate-time samples in BOTH arrays, then paired by index.
1483
+ Pass when ``count_must_match`` is satisfied and every pair's
1484
+ ``d = actual − reference`` lies in ``[−lower, upper]`` of
1485
+ ``time_tolerance`` (a number is both sides; ``lower`` is how much
1486
+ earlier, ``upper`` how much later).
1487
+
1488
+ * ``declared_events is not None``: the declared list IS the reference
1489
+ event set. The actual signal is still auto-detected; declared
1490
+ windows (``event["tolerance"]`` if set, else ``time_tolerance``) are
1491
+ then matched optimally — sorted by right endpoint, each claiming the
1492
+ smallest unclaimed actual event inside it. An
1493
+ unclaimed declared event fails. Unclaimed actual events only fail
1494
+ if ``count_must_match`` is set (and declared count != actual count).
1495
+
1496
+ The score (``nrmse`` field — repurposed for reporting uniformity) is
1497
+ the max event-time delta across matched pairs.
1498
+ """
1499
+ time_tolerance = parse_sides(time_tolerance, "time_tolerance")
1500
+ act_boundaries = _find_event_boundaries(act_time)
1501
+ act_events = [float(act_time[b[0]]) for b in act_boundaries]
1502
+
1503
+ if declared_events is None:
1504
+ # Legacy auto-detect path — unchanged behavior.
1505
+ ref_boundaries = _find_event_boundaries(ref_time)
1506
+ ref_events = [float(ref_time[b[0]]) for b in ref_boundaries]
1507
+
1508
+ n = min(len(ref_events), len(act_events))
1509
+ max_delta = 0.0
1510
+ max_signed = 0.0
1511
+ delta_at = 0.0
1512
+ # With uneven sides the largest |Δt| need not be the pair that
1513
+ # breaks the window (0.04 late under an upper side of 0.05 passes,
1514
+ # 0.03 early under a lower side of 0.01 fails): the signed Δt the
1515
+ # sentence prints is the pair's FURTHEST OUTSIDE it, and
1516
+ # `max_signed` (the largest |Δt|) when every pair is inside.
1517
+ max_outside = 0.0
1518
+ outside_signed = 0.0
1519
+ # ``d > 0`` is LATE: the actual event came after the reference's.
1520
+ in_window = []
1521
+ for i in range(n):
1522
+ d = act_events[i] - ref_events[i]
1523
+ in_window.append(-time_tolerance.lower <= d <= time_tolerance.upper)
1524
+ if abs(d) > max_delta:
1525
+ max_delta = abs(d)
1526
+ max_signed = d
1527
+ delta_at = ref_events[i]
1528
+ if not in_window[-1]:
1529
+ outside = (
1530
+ d - time_tolerance.upper
1531
+ if d > time_tolerance.upper
1532
+ else -time_tolerance.lower - d
1533
+ )
1534
+ if outside > max_outside:
1535
+ max_outside = outside
1536
+ outside_signed = d
1537
+
1538
+ counts_match = len(ref_events) == len(act_events)
1539
+ # A window inverted by a negative side holds nothing — with no pair
1540
+ # to score as well, as a negative number never passed
1541
+ # (``max_delta <= time_tolerance`` with ``max_delta`` 0).
1542
+ holds = -time_tolerance.lower <= time_tolerance.upper
1543
+ passed = holds and all(in_window) and (counts_match or not count_must_match)
1544
+
1545
+ return VariableComparison(
1546
+ index=0,
1547
+ name="",
1548
+ passed=passed,
1549
+ nrmse=max_delta,
1550
+ rmse=max_delta,
1551
+ signal_range=0.0,
1552
+ max_abs_error=max_delta,
1553
+ max_abs_error_time=delta_at,
1554
+ reference_final=float("nan"),
1555
+ actual_final=float("nan"),
1556
+ mode="event-timing",
1557
+ diagnostics={
1558
+ "ref_event_count": len(ref_events),
1559
+ "act_event_count": len(act_events),
1560
+ "max_time_delta": max_delta,
1561
+ "time_tolerance": as_written(time_tolerance),
1562
+ "counts_match": counts_match,
1563
+ **_signed_time_delta(
1564
+ time_tolerance, outside_signed if max_outside > 0 else max_signed
1565
+ ),
1566
+ },
1567
+ geometry=_event_geometry(
1568
+ ref_events,
1569
+ act_events,
1570
+ [time_tolerance] * len(ref_events),
1571
+ [in_window[i] if i < n else None for i in range(len(ref_events))],
1572
+ ),
1573
+ )
1574
+
1575
+ # Declared-events path.
1576
+ declared_count = len(declared_events)
1577
+ actual_count = len(act_events)
1578
+ counts_match = declared_count == actual_count
1579
+ max_delta = 0.0
1580
+ delta_at = 0.0
1581
+ # The pair the sentence reports: the largest |Δt|'s, of equals the
1582
+ # first claimed — kept as `_reported_event` takes it.
1583
+ largest: tuple[float, Sides, float] | None = None
1584
+ # Optimal 1-D interval matching (review 2026-07-06, greedy-stealing
1585
+ # finding): each declared event defines a window [time-lower, time+upper].
1586
+ # Sorting windows by right endpoint and assigning each the SMALLEST
1587
+ # unclaimed actual event inside it is a maximum matching for interval
1588
+ # graphs — a feasible assignment is never missed because an earlier
1589
+ # declared event claimed the wrong actual event.
1590
+ claimed = [False] * actual_count
1591
+ matched_flags = [False] * declared_count
1592
+ windows = []
1593
+ declared_times: list[float] = []
1594
+ declared_tols = _event_tolerances(declared_events, time_tolerance)
1595
+ for k, e in enumerate(declared_events):
1596
+ target = float(e["time"])
1597
+ tol = declared_tols[k]
1598
+ declared_times.append(target)
1599
+ windows.append((target - tol.lower, target + tol.upper, target, k))
1600
+ windows.sort(key=lambda w: w[1])
1601
+ for lo, hi, target, k in windows:
1602
+ best_idx = -1
1603
+ for j, at in enumerate(act_events):
1604
+ if not claimed[j] and lo <= at <= hi:
1605
+ best_idx = j
1606
+ break # act_events is time-sorted → first hit is smallest
1607
+ if best_idx < 0:
1608
+ continue
1609
+ claimed[best_idx] = True
1610
+ matched_flags[k] = True
1611
+ # A pair exists only INSIDE its window (the claim above), so none
1612
+ # lies outside its band: the signed Δt is the largest |Δt|'s.
1613
+ signed_d = act_events[best_idx] - target
1614
+ d = abs(signed_d)
1615
+ if largest is None or d > max_delta:
1616
+ largest = (signed_d, declared_tols[k], target)
1617
+ if d > max_delta:
1618
+ max_delta = d
1619
+ delta_at = target
1620
+
1621
+ # A declared event that claimed nothing fails the leaf, and the score
1622
+ # above says nothing of it (it is the matched pairs'): the count is what
1623
+ # the sentence then says (`summarize_event_timing`); WHICH ones is the
1624
+ # geometry's — a window that is not ``passed``.
1625
+ unmatched = matched_flags.count(False)
1626
+ passed = unmatched == 0 and (counts_match or not count_must_match)
1627
+
1628
+ return VariableComparison(
1629
+ index=0,
1630
+ name="",
1631
+ passed=passed,
1632
+ nrmse=max_delta,
1633
+ rmse=max_delta,
1634
+ signal_range=0.0,
1635
+ max_abs_error=max_delta,
1636
+ max_abs_error_time=delta_at,
1637
+ reference_final=float("nan"),
1638
+ actual_final=float("nan"),
1639
+ mode="event-timing",
1640
+ diagnostics={
1641
+ "ref_event_count": declared_count,
1642
+ "act_event_count": actual_count,
1643
+ "max_time_delta": max_delta,
1644
+ "time_tolerance": as_written(time_tolerance),
1645
+ "counts_match": counts_match,
1646
+ **_reported_event(time_tolerance, largest),
1647
+ "unmatched_events": unmatched,
1648
+ "declared_events": declared_count,
1649
+ },
1650
+ geometry=_event_geometry(
1651
+ declared_times, act_events, declared_tols, matched_flags
1652
+ ),
1653
+ )
1654
+
1655
+
1656
+ def _compute_fft_spectrum(
1657
+ t: np.ndarray,
1658
+ v: np.ndarray,
1659
+ ) -> tuple[np.ndarray, np.ndarray]:
1660
+ """Return (freqs, magnitude) for a time-series via uniform-grid FFT.
1661
+
1662
+ Resamples to a uniform grid sized at the **next power of 2** above
1663
+ ``max(len(t), 64)``, strips DC, takes the real-FFT. Bin frequencies
1664
+ therefore depend on the sample COUNT: the same signal at a different
1665
+ resolution lands its peaks on a different grid.
1666
+ """
1667
+ import math
1668
+
1669
+ if len(t) == 0:
1670
+ return np.array([]), np.array([])
1671
+ n0 = max(len(t), 64)
1672
+ n = 1 << int(math.ceil(math.log2(n0))) if n0 > 1 else 64
1673
+ t_uniform = np.linspace(t[0], t[-1], n)
1674
+ unique_t, unique_idx = np.unique(t, return_index=True)
1675
+ v_uniform = np.interp(t_uniform, unique_t, v[unique_idx])
1676
+ v_uniform = v_uniform - np.mean(v_uniform)
1677
+ dt = (t_uniform[-1] - t_uniform[0]) / (n - 1)
1678
+ if dt <= 0:
1679
+ return np.array([]), np.array([])
1680
+ spectrum = np.abs(np.fft.rfft(v_uniform))
1681
+ freqs = np.fft.rfftfreq(n, d=dt)
1682
+ return freqs, spectrum
1683
+
1684
+
1685
+ def _find_top_n_peaks(
1686
+ freqs: np.ndarray,
1687
+ spectrum: np.ndarray,
1688
+ n_peaks: int,
1689
+ min_frequency: float = 0.0,
1690
+ ) -> list[tuple[float, float]]:
1691
+ """Return the ``n_peaks`` largest local maxima as ``[(freq, amplitude), ...]``
1692
+ sorted by **frequency** (ascending).
1693
+
1694
+ Local maxima are indices strictly greater than both immediate neighbors
1695
+ above ``min_frequency``. The amplitude filter (pick top-N by magnitude)
1696
+ runs first so spectral noise doesn't get counted; the frequency-sort
1697
+ runs second so pairing between reference and actual is predictable for
1698
+ tests with known frequencies (e.g., PRBS).
1699
+
1700
+ Reported as the ``detected_reference_peaks_hz`` diagnostic so a spec
1701
+ author can read the reference's own peaks off a failing run and
1702
+ declare them; not consulted during pass/fail evaluation (that path
1703
+ uses declared peaks).
1704
+ """
1705
+ if len(freqs) < 3 or n_peaks < 1:
1706
+ return []
1707
+ interior = np.arange(1, len(spectrum) - 1)
1708
+ is_peak = (spectrum[interior] > spectrum[interior - 1]) & (
1709
+ spectrum[interior] > spectrum[interior + 1]
1710
+ )
1711
+ peak_idx = interior[is_peak]
1712
+ floor = max(min_frequency, 0.0)
1713
+ peak_idx = peak_idx[freqs[peak_idx] > floor]
1714
+ if len(peak_idx) == 0:
1715
+ return []
1716
+ sorted_by_amp = peak_idx[np.argsort(-spectrum[peak_idx])]
1717
+ top_n = sorted_by_amp[:n_peaks]
1718
+ top_n_sorted = top_n[np.argsort(freqs[top_n])]
1719
+ return [(float(freqs[i]), float(spectrum[i])) for i in top_n_sorted]
1720
+
1721
+
1722
+ def _find_strongest_peak_in_window(
1723
+ freqs: np.ndarray,
1724
+ spectrum: np.ndarray,
1725
+ lo: float,
1726
+ hi: float,
1727
+ *,
1728
+ exclude: set[float] | None = None,
1729
+ min_amplitude: float = 0.0,
1730
+ ) -> tuple[float, float] | None:
1731
+ """Return the ``(freq, amplitude)`` of the strongest local maximum in
1732
+ ``[lo, hi]``, or ``None`` if no local maximum exists in that window.
1733
+
1734
+ Used by the declared-peaks pass/fail path. A peak qualifies if it's a
1735
+ local max (greater than both neighbors) AND its frequency sits in
1736
+ ``[lo, hi]``. Returning ``None`` means the leaf fails that peak.
1737
+
1738
+ ``exclude`` holds bin frequencies already claimed by other declared
1739
+ peaks; ``min_amplitude`` filters spectral noise (review 2026-07-06:
1740
+ without both, two declared peaks could match the same actual peak,
1741
+ or a claimed-peak fix would just re-match machine-noise sidelobes).
1742
+ """
1743
+ if len(freqs) < 3:
1744
+ return None
1745
+ interior = np.arange(1, len(spectrum) - 1)
1746
+ in_window = (freqs[interior] >= lo) & (freqs[interior] <= hi)
1747
+ is_local_max = (spectrum[interior] > spectrum[interior - 1]) & (
1748
+ spectrum[interior] > spectrum[interior + 1]
1749
+ )
1750
+ candidate_idx = interior[in_window & is_local_max]
1751
+ if min_amplitude > 0.0:
1752
+ candidate_idx = candidate_idx[spectrum[candidate_idx] >= min_amplitude]
1753
+ if exclude:
1754
+ candidate_idx = np.array(
1755
+ [i for i in candidate_idx if float(freqs[i]) not in exclude], dtype=int
1756
+ )
1757
+ if len(candidate_idx) == 0:
1758
+ return None
1759
+ best = candidate_idx[int(np.argmax(spectrum[candidate_idx]))]
1760
+ return (float(freqs[best]), float(spectrum[best]))
1761
+
1762
+
1763
+ # Cap spectrum samples embedded into the HTML report — fine enough to see
1764
+ # peaks, small enough to not bloat the payload.
1765
+ _SPECTRUM_EMBED_CAP = 512
1766
+
1767
+ # A local maximum below this fraction of the spectrum's global max is
1768
+ # treated as noise, not a matchable mode. Declared peaks describe
1769
+ # significant resonances; letting near-zero FFT sidelobes satisfy a
1770
+ # declared peak defeats the check (see _compare_dominant_frequency).
1771
+ _PEAK_MIN_REL_AMPLITUDE = 0.01
1772
+
1773
+
1774
+ def _frequency_geometry(
1775
+ ref_freqs: np.ndarray,
1776
+ ref_spectrum: np.ndarray,
1777
+ act_freqs: np.ndarray,
1778
+ act_spectrum: np.ndarray,
1779
+ detected_ref_peaks: list[tuple[float, float]],
1780
+ peaks: list[dict],
1781
+ paired: list[dict],
1782
+ ) -> tuple[Primitive, ...]:
1783
+ """Both spectra, the reference's detected peaks, and per declared peak
1784
+ its handle, its acceptance window and (when found) its match. All on
1785
+ the spectrum panel.
1786
+
1787
+ The spectra are the FULL-resolution arrays the peaks were found in, not
1788
+ the index-strided ``_embed`` lists the diagnostics carry: a stride skips
1789
+ the peak bin for any signal over a few hundred samples, and a marker
1790
+ placed at full-resolution amplitude then floats above a curve with no
1791
+ peak in it. The figure layer decimates with LTTB, which keeps peaks."""
1792
+ out: list[Primitive] = []
1793
+ if len(ref_freqs):
1794
+ out.append(
1795
+ Series(
1796
+ id="spectrum/ref",
1797
+ kind="ref",
1798
+ panel="spectrum",
1799
+ x=ref_freqs,
1800
+ y=ref_spectrum,
1801
+ )
1802
+ )
1803
+ out.append(
1804
+ Series(
1805
+ id="spectrum/act",
1806
+ kind="act",
1807
+ panel="spectrum",
1808
+ x=act_freqs,
1809
+ y=act_spectrum,
1810
+ )
1811
+ )
1812
+ for k, (f, amp) in enumerate(detected_ref_peaks):
1813
+ out.append(
1814
+ Marker(
1815
+ id=f"peaks/detected/{k}",
1816
+ kind="detected",
1817
+ panel="spectrum",
1818
+ x=float(f),
1819
+ y=float(amp),
1820
+ )
1821
+ )
1822
+
1823
+ def _amp_at(freq: float) -> float:
1824
+ return (
1825
+ float(np.interp(freq, act_freqs, act_spectrum)) if len(act_freqs) else 0.0
1826
+ )
1827
+
1828
+ for i, (declared, pair) in enumerate(zip(peaks, paired)):
1829
+ f_decl = float(declared.get("freq", 0.0))
1830
+ lo, hi, _, _ = _peak_window(declared, i)
1831
+ ok = bool(pair["passed"])
1832
+ out.append(
1833
+ Marker(
1834
+ id=f"peaks/declared/{i}",
1835
+ kind="handle",
1836
+ panel="spectrum",
1837
+ x=f_decl,
1838
+ y=_amp_at(f_decl),
1839
+ passed=ok,
1840
+ )
1841
+ )
1842
+ out.append(
1843
+ Box(
1844
+ id=f"peaks/window/{i}",
1845
+ kind="handle",
1846
+ panel="spectrum",
1847
+ t0=lo,
1848
+ t1=hi,
1849
+ y0=None,
1850
+ y1=None,
1851
+ passed=ok,
1852
+ )
1853
+ )
1854
+ for side, edge in (("lower", lo), ("upper", hi)):
1855
+ out.append(
1856
+ VLine(id=f"peaks/{side}/{i}", kind="grip", panel="spectrum", time=edge)
1857
+ )
1858
+ if pair.get("matched_hz") is not None:
1859
+ mh = float(pair["matched_hz"])
1860
+ out.append(
1861
+ Marker(
1862
+ id=f"peaks/matched/{i}",
1863
+ kind="matched",
1864
+ panel="spectrum",
1865
+ x=mh,
1866
+ y=_amp_at(mh),
1867
+ passed=True,
1868
+ )
1869
+ )
1870
+ return tuple(out)
1871
+
1872
+
1873
+ def _peak_tolerance(declared: dict, i: int) -> Sides:
1874
+ """Declared peak ``i``'s tolerance: a number (both sides) or a
1875
+ lower/upper pair; none (or ``null``) is 1 % each side. A malformed one
1876
+ is a ``ValueError`` naming the row — ``DominantFrequencyMode.__init__``
1877
+ calls this too, so it is the leaf's config error on both scoring paths."""
1878
+ raw = declared.get("tolerance")
1879
+ if raw is None:
1880
+ return Sides(0.01, 0.01)
1881
+ return parse_sides(raw, f"peaks[{i}].tolerance")
1882
+
1883
+
1884
+ def _peak_window(declared: dict, i: int) -> tuple[float, float, Sides, str]:
1885
+ """Declared peak ``i``'s acceptance window ``(lo, hi)``, its sides and
1886
+ its tolerance mode — the ONE place the window is computed: the scorer
1887
+ matches in it and the figure draws it. ``rel``: ``f·(1 − lower)`` to
1888
+ ``f·(1 + upper)``; ``abs``: ``f − lower`` to ``f + upper``."""
1889
+ f = float(declared.get("freq", 0.0))
1890
+ tol = _peak_tolerance(declared, i)
1891
+ mode = declared.get("tolerance_mode", "rel")
1892
+ if mode == "rel":
1893
+ return f * (1.0 - tol.lower), f * (1.0 + tol.upper), tol, mode
1894
+ return f - tol.lower, f + tol.upper, tol, mode
1895
+
1896
+
1897
+ def _compare_dominant_frequency(
1898
+ ref_time: np.ndarray,
1899
+ ref_values: np.ndarray,
1900
+ act_time: np.ndarray,
1901
+ act_values: np.ndarray,
1902
+ peaks: list[dict] | None = None,
1903
+ ) -> VariableComparison:
1904
+ """Compare declared frequency peaks between reference and actual (4.C.2).
1905
+
1906
+ Takes a list of user-declared peaks of the form::
1907
+
1908
+ [{"freq": 1.0, "tolerance": 0.02, "tolerance_mode": "rel"},
1909
+ {"freq": 7.0, "tolerance": 0.5, "tolerance_mode": "abs"}, ...]
1910
+
1911
+ For each declared peak the algorithm looks for the strongest local
1912
+ maximum in ``act_spectrum`` within the peak's tolerance window
1913
+ (rel: ``[f*(1-lower), f*(1+upper)]``; abs: ``[f-lower, f+upper]``, a
1914
+ number being both sides — :func:`_peak_window`). The
1915
+ leaf passes iff every declared peak has a match in its window.
1916
+ Unmatched declared peaks fail with ``matched_hz=None`` and a reason
1917
+ of "no peak in tolerance window".
1918
+
1919
+ When ``peaks`` is empty (or omitted), the leaf fails with a reason
1920
+ pointing the author at the leaf's ``peaks`` list and at the
1921
+ ``detected_reference_peaks_hz`` diagnostic — the declared-peaks
1922
+ contract is explicit by design.
1923
+
1924
+ The top-N-by-amplitude algorithm of the pre-D75 implementation is
1925
+ retained as ``_find_top_n_peaks`` — it fills that diagnostic, so an
1926
+ author who doesn't yet know their peak frequencies can take them
1927
+ from the reference spectrum. Pass/fail logic no longer calls it.
1928
+ """
1929
+ # The actual-side FFT is always required — without it we can't match
1930
+ # any peaks. The reference-side FFT is only required when no peaks
1931
+ # are declared (the failure then reports the ref spectrum's top-N as
1932
+ # a diagnostic). With declared peaks, an empty reference is OK
1933
+ # (baseline-free path, idea #59 / D83).
1934
+ if len(act_values) < 4:
1935
+ return VariableComparison(
1936
+ index=0,
1937
+ name="",
1938
+ passed=False,
1939
+ nrmse=float("inf"),
1940
+ rmse=0.0,
1941
+ signal_range=0.0,
1942
+ max_abs_error=0.0,
1943
+ max_abs_error_time=0.0,
1944
+ reference_final=float("nan"),
1945
+ actual_final=float("nan"),
1946
+ mode="dominant-frequency",
1947
+ diagnostics={"reason": "signal too short for FFT (need >=4 samples)"},
1948
+ )
1949
+
1950
+ has_ref = len(ref_values) >= 4
1951
+ if not has_ref and not peaks:
1952
+ return VariableComparison(
1953
+ index=0,
1954
+ name="",
1955
+ passed=False,
1956
+ nrmse=float("inf"),
1957
+ rmse=0.0,
1958
+ signal_range=0.0,
1959
+ max_abs_error=0.0,
1960
+ max_abs_error_time=0.0,
1961
+ reference_final=float("nan"),
1962
+ actual_final=float("nan"),
1963
+ mode="dominant-frequency",
1964
+ diagnostics={"reason": "signal too short for FFT (need >=4 samples)"},
1965
+ )
1966
+
1967
+ if has_ref:
1968
+ ref_freqs, ref_spectrum = _compute_fft_spectrum(ref_time, ref_values)
1969
+ # Reference peak detection — embedded so the reporter's "Detect"
1970
+ # button can seed a fresh declared-peaks table without recomputing.
1971
+ # N=10 gives room for up to 10 tracked modes without bloat.
1972
+ detected_ref_peaks = _find_top_n_peaks(ref_freqs, ref_spectrum, 10)
1973
+ else:
1974
+ ref_freqs = np.array([])
1975
+ ref_spectrum = np.array([])
1976
+ detected_ref_peaks = []
1977
+
1978
+ act_freqs, act_spectrum = _compute_fft_spectrum(act_time, act_values)
1979
+
1980
+ # Downsample spectra for embedding. 512 bins is ample for visual
1981
+ # inspection; full-resolution would inflate every dominant-frequency
1982
+ # leaf's payload.
1983
+ def _embed(freqs: np.ndarray, mag: np.ndarray) -> tuple[list[float], list[float]]:
1984
+ if len(freqs) <= _SPECTRUM_EMBED_CAP:
1985
+ return freqs.tolist(), mag.tolist()
1986
+ idx = np.linspace(0, len(freqs) - 1, _SPECTRUM_EMBED_CAP).astype(int)
1987
+ return freqs[idx].tolist(), mag[idx].tolist()
1988
+
1989
+ ref_f_embed, ref_m_embed = _embed(ref_freqs, ref_spectrum)
1990
+ act_f_embed, act_m_embed = _embed(act_freqs, act_spectrum)
1991
+
1992
+ peaks = peaks or []
1993
+ if not peaks:
1994
+ return VariableComparison(
1995
+ index=0,
1996
+ name="",
1997
+ passed=False,
1998
+ nrmse=float("inf"),
1999
+ rmse=0.0,
2000
+ signal_range=0.0,
2001
+ max_abs_error=0.0,
2002
+ max_abs_error_time=0.0,
2003
+ reference_final=float("nan"),
2004
+ actual_final=float("nan"),
2005
+ mode="dominant-frequency",
2006
+ diagnostics={
2007
+ "reason": (
2008
+ "no peaks declared — declare them in the leaf's "
2009
+ "`peaks` list; the reference's detected peaks are in "
2010
+ "the diagnostics as `detected_reference_peaks_hz`."
2011
+ ),
2012
+ "peaks_declared": [],
2013
+ "paired_peaks": [],
2014
+ "detected_reference_peaks_hz": [f for f, _ in detected_ref_peaks],
2015
+ "ref_spectrum_freq": ref_f_embed,
2016
+ "ref_spectrum_mag": ref_m_embed,
2017
+ "act_spectrum_freq": act_f_embed,
2018
+ "act_spectrum_mag": act_m_embed,
2019
+ },
2020
+ geometry=_frequency_geometry(
2021
+ ref_freqs,
2022
+ ref_spectrum,
2023
+ act_freqs,
2024
+ act_spectrum,
2025
+ detected_ref_peaks,
2026
+ [],
2027
+ [],
2028
+ ),
2029
+ )
2030
+
2031
+ paired: list[dict] = []
2032
+ all_passed = True
2033
+ max_rel_err = 0.0
2034
+ max_delta = 0.0
2035
+ # Each declared peak must claim a DISTINCT actual peak (review
2036
+ # 2026-07-06: without claiming, a two-resonance system regressing to
2037
+ # one resonance passed both declared peaks against the single
2038
+ # surviving peak). The amplitude floor keeps the claiming from
2039
+ # falling through to machine-noise sidelobes: a "peak" below
2040
+ # _PEAK_MIN_REL_AMPLITUDE of the spectrum maximum is not a mode.
2041
+ claimed_hz: set[float] = set()
2042
+ amp_floor = (
2043
+ _PEAK_MIN_REL_AMPLITUDE * float(np.max(act_spectrum))
2044
+ if len(act_spectrum)
2045
+ else 0.0
2046
+ )
2047
+ for i, declared in enumerate(peaks):
2048
+ f_decl = float(declared.get("freq", 0.0))
2049
+ lo, hi, tol, mode = _peak_window(declared, i)
2050
+
2051
+ match = _find_strongest_peak_in_window(
2052
+ act_freqs,
2053
+ act_spectrum,
2054
+ lo,
2055
+ hi,
2056
+ exclude=claimed_hz,
2057
+ min_amplitude=amp_floor,
2058
+ )
2059
+ if match is not None:
2060
+ claimed_hz.add(match[0])
2061
+ if match is None:
2062
+ paired.append(
2063
+ {
2064
+ "declared_hz": f_decl,
2065
+ "matched_hz": None,
2066
+ "delta": None,
2067
+ "passed": False,
2068
+ "tolerance": as_written(tol),
2069
+ "tolerance_mode": mode,
2070
+ "reason": "no peak in tolerance window",
2071
+ }
2072
+ )
2073
+ all_passed = False
2074
+ max_rel_err = float("inf")
2075
+ else:
2076
+ matched_hz, _amp = match
2077
+ delta = abs(matched_hz - f_decl)
2078
+ rel_err = delta / f_decl if f_decl > 0 else delta
2079
+ if rel_err > max_rel_err:
2080
+ max_rel_err = rel_err
2081
+ if delta > max_delta:
2082
+ max_delta = delta
2083
+ paired.append(
2084
+ {
2085
+ "declared_hz": f_decl,
2086
+ "matched_hz": matched_hz,
2087
+ "delta": delta,
2088
+ "passed": True, # match-in-window guarantees pass for this peak
2089
+ "tolerance": as_written(tol),
2090
+ "tolerance_mode": mode,
2091
+ }
2092
+ )
2093
+
2094
+ act_range = float(np.max(act_values) - np.min(act_values))
2095
+ return VariableComparison(
2096
+ index=0,
2097
+ name="",
2098
+ passed=all_passed,
2099
+ nrmse=max_rel_err, # repurposed as "max relative error across declared peaks"
2100
+ rmse=max_rel_err,
2101
+ signal_range=act_range,
2102
+ max_abs_error=max_delta,
2103
+ max_abs_error_time=0.0,
2104
+ reference_final=float("nan"),
2105
+ actual_final=float("nan"),
2106
+ mode="dominant-frequency",
2107
+ diagnostics={
2108
+ "peaks_declared": list(peaks),
2109
+ "paired_peaks": paired,
2110
+ "detected_reference_peaks_hz": [f for f, _ in detected_ref_peaks],
2111
+ "max_rel_error": max_rel_err,
2112
+ # Spectrum arrays for the reporter's subplot.
2113
+ "ref_spectrum_freq": ref_f_embed,
2114
+ "ref_spectrum_mag": ref_m_embed,
2115
+ "act_spectrum_freq": act_f_embed,
2116
+ "act_spectrum_mag": act_m_embed,
2117
+ },
2118
+ geometry=_frequency_geometry(
2119
+ ref_freqs,
2120
+ ref_spectrum,
2121
+ act_freqs,
2122
+ act_spectrum,
2123
+ detected_ref_peaks,
2124
+ peaks,
2125
+ paired,
2126
+ ),
2127
+ )