新增统一力读数会话 ForceSession

把同帧测量、空载归零、模型推断与共用后处理收进一个 SDK 会话,Studio 显示与记录使用同一样本;旧 Sensor 读数接口保持兼容。以 force session 与受影响 SDK 套件、lint 与文档门禁验证,硬件与真实载荷精度未验证。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CpCfJF6SvonMKmPZhzKZHb
This commit is contained in:
ykcheungab
2026-10-20 07:07:22 +00:00
parent dc8a630df3
commit ee830d8505
17 changed files with 4175 additions and 29 deletions
+74
View File
@@ -14,6 +14,7 @@ from orisys.signal_processing import (
NORMAL_FORCE_CAL_OFFSET,
NORMAL_FORCE_CAL_SLOPE,
LowpassFilter,
MultiAxisCausalFilter,
calibrate_normal_force,
calibrate_shear_components,
calibrate_shear_force,
@@ -494,3 +495,76 @@ def test_the_calibration_note_names_the_bundle_that_was_found(tmp_path, monkeypa
# A dict always keys ``found_root``, so guard its value: an unguarded copy of
# this branch prints "bundle found at None".
assert "None" not in note
# --------------------------------------------- multi-axis causal force chain
class _ReferenceMedian:
"""Independent median window, so the operator test has its own expectation."""
def __init__(self, window):
self.window = window
self.history = []
def process(self, value):
self.history.append(float(value))
self.history = self.history[-self.window :]
return float(np.median(self.history))
def test_multi_axis_median_matches_a_plain_window_per_axis():
chain = MultiAxisCausalFilter(median_window=3)
reference = [_ReferenceMedian(3) for _ in range(3)]
samples = [(10.0, 20.0, 30.0), (90.0, 20.0, 30.0), (10.0, 20.0, 30.0),
(10.0, 20.0, 30.0)]
for sample in samples:
actual = chain.process(sample)
expected = [reference[index].process(value) for index, value in enumerate(sample)]
np.testing.assert_allclose(actual, expected)
def test_multi_axis_nan_axis_does_not_enter_history_or_pull_the_others():
chain = MultiAxisCausalFilter(median_window=3)
chain.process((10.0, 20.0, 30.0))
skipped = chain.process(
(float("nan"), 20.0, 30.0), valid=(False, True, True)
)
assert np.isnan(skipped[0])
assert skipped[1] == pytest.approx(20.0)
# Valid axes keep advancing while the missing axis is skipped.
resumed = chain.process((10.0, 20.0, 30.0))
assert resumed[0] == pytest.approx(10.0)
# A NaN arriving without an explicit validity flag is treated as unavailable.
assert np.isnan(chain.process((float("nan"), 20.0, 30.0))[0])
def test_multi_axis_all_stages_off_is_exact_passthrough_including_nan():
chain = MultiAxisCausalFilter(median_window=1, lowpass_cutoff_hz=0.0)
assert chain.enabled is False
sample = (1.5, -2.0, float("nan"))
first = chain.process(sample)
second = chain.process(sample)
assert first[0] == 1.5 and first[1] == -2.0 and np.isnan(first[2])
assert second[0] == 1.5 and second[1] == -2.0 and np.isnan(second[2])
def test_multi_axis_lowpass_ignores_missing_axes_and_resets():
chain = MultiAxisCausalFilter(
median_window=1, lowpass_cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3
)
values = []
for index in range(20):
values.append(
chain.process(
(float(index), float(index), float(index)),
valid=(True, True, index % 2 == 0),
)
)
for index, value in enumerate(values):
assert np.isfinite(value[0])
assert np.isfinite(value[1])
# The third axis is only fed on even frames, so it is missing on odd ones.
assert np.isnan(value[2]) == bool(index % 2)
chain.reset()
after = chain.process((1.0, 2.0, 3.0))
# A reset IIR starts from its first sample's steady state.
np.testing.assert_allclose(after, (1.0, 2.0, 3.0))