dynamic-systems-testing-framework 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|