app 侧滤波接口改为驱动 SDK 并读回生效值 - What: shared/force/filters.py 从"自己滤一遍"改成 app 的滤波控制面,只做三件事:(1) FilterSettings 改用 SDK 的键与"按值开关"语义(flow_ema_alpha / median_window / lowpass_hz / lowpass_order / sample_rate_hz / show_unfiltered / preset;α≥1、窗口≤1、截止≤0 各自表示关),normalised() 把不可用值一律落成"关"而不是落成会滤波的默认值,并新增 problems(sample_rate_hz) 指出"截止 ≥ Nyquist、设备根本不会滤"这类请求;(2) apply_to(sensor) 就是一次 Sensor.configure(...),用 CONFIGURE_PARAMETERS 一处维护"状态键 → configure 形参"的映射(低通那一对在配置/状态里叫 force_lowpass_*、在调用里叫 lowpass_*,此前正是这里写错导致支持性判断把所有键都判成不支持),并返回该 SDK 支持不了的键而不是默默丢掉——老 SDK(0.5.2 之前)没有 flow_ema_alpha/中值,其光流 EMA 也关不掉,必须让人看见;(3) from_filters_state(state) 读回设备**实际**在跑的值(界面与记录都以此为准),metadata(sensor_state) 产出 session.json/CSV 头的 filters 块,measurement 组与 readout 组严格分开、带 describe 与 source(有设备时为 sensor.filters_state,只有请求时为 app.FilterSettings,两者不可混认)。新增 describe_state()/settings_from_mapping();ForceFilterChain 降级为"参考实现",只为 tests/test_force_filters.py 的等价性断言存在(app 不再调用它),并删掉随之无用的 _odd()。force/__init__.py 导出 describe_state。tests/test_force_filters.py 重写为 19 项:按值开关、不可用值=关、Nyquist 预警、一次 configure 调用且键名正确、老 SDK 缺键被上报而不是忽略、显示开关绝不发给设备、读回生效值与 sample_rate 往返、metadata 两组分离与来源标注、空的 preferences 映射等于"什么都不滤"、参考链仍与 SDK 滤波器逐位一致。 - Why: 用户要求"统一采集滤波和显示滤波、显示的和记录的必须一致、保留滤波前数据两个 buffer",并决定整条链留在 SDK、app 通过 Sensor.configure 驱动、filters.py 保留为 wrapper 与 CSV 元数据出口。上一版的 FilterSettings 用 median_enabled/lowpass_enabled 布尔 + app 自己实现滤波,既与 SDK 的"按值开关"两套词汇,也会让同一份数据在界面与 CSV 之间出现第二条滤波路径。 - Verification: pytest shared/tests/test_force_filters.py → 19 passed(含与 orisys.signal_processing.LowpassFilter 的逐位等价断言);shared 全量 153 passed;SDK 已升级到 0.5.2(editable),studio 85 项测试与 ui_check 554 项检查在其上全绿。 - Not in this change: sdk_worker/finger_worker 仍在硬编码 lowpass_cutoff_hz=10.0(下一步改为 apply_to)、曲线图仍在显示端做 filtfilt、齿轮对话框与 plot_* preferences、CSV 的 17 列与未滤波 buffer、session.json 写入 filters 块都还没接。

This commit is contained in:
ykcheungab
2026-09-14 10:56:04 +08:00
parent 65dafdc84d
commit 0d8b79b759
3 changed files with 583 additions and 141 deletions
+254 -66
View File
@@ -1,32 +1,254 @@
"""The force filter chain: what it removes, and what it must not do.
"""The app's filter control surface: what it asks the SDK for, and what it records.
The chain runs once, in the acquisition loop, so the numbers on the screen and
the numbers in ``forces.csv`` are the same filtered values; the unfiltered
readings are kept beside them. These tests pin the parts that are easy to get
wrong: odd windows, a disabled chain being a pass-through, per-channel state,
and a step not ringing at session start.
The chain itself lives in the SDK, so these tests are about the three things
this app is responsible for: handing the operator's choice to
``Sensor.configure`` in the sensor's own vocabulary, reading the *effective*
state back instead of trusting the request, and writing a record that keeps the
measurement stage apart from the readout stages.
``ForceFilterChain`` is checked here too, because it is what makes the SDK's
chain a known quantity rather than a black box: at the same settings it must
reproduce the SDK's filter exactly.
"""
from __future__ import annotations
import numpy as np
import pytest
from orisys_app_shared.force.filters import (
CHANNELS,
FilterSettings,
ForceFilterChain,
describe_state,
settings_from_mapping,
)
class FakeSensor:
"""A Sensor with the current SDK's ``configure`` surface."""
def __init__(self) -> None:
self.calls: list[dict] = []
def configure(
self,
*,
flow_ema_alpha=None,
force_median_window=None,
lowpass_cutoff_hz=None,
lowpass_order=None,
lowpass_sample_rate_hz=None,
) -> None:
self.calls.append(
{
"flow_ema_alpha": flow_ema_alpha,
"force_median_window": force_median_window,
"lowpass_cutoff_hz": lowpass_cutoff_hz,
"lowpass_order": lowpass_order,
"lowpass_sample_rate_hz": lowpass_sample_rate_hz,
}
)
def _zeros(value: float = 0.0) -> list[float]:
return [value] * CHANNELS
def test_a_disabled_chain_is_a_pass_through():
chain = ForceFilterChain(
FilterSettings(median_enabled=False, lowpass_enabled=False)
# ------------------------------------------------------------- value semantics
def test_every_stage_is_switched_by_its_own_value():
assert FilterSettings().active() is False
assert FilterSettings(flow_ema_alpha=1.0).flow_ema_on is False
assert FilterSettings(flow_ema_alpha=0.5).flow_ema_on is True
assert FilterSettings(median_window=1).median_on is False
assert FilterSettings(median_window=3).median_on is True
assert FilterSettings(lowpass_hz=0.0).lowpass_on is False
assert FilterSettings(lowpass_hz=10.0).lowpass_on is True
# ...and any one of them is enough to make the chain do something.
assert FilterSettings(median_window=3).active() is True
def test_an_unusable_value_means_off_not_a_default_that_filters():
assert FilterSettings(flow_ema_alpha="wide").normalised().flow_ema_alpha == 1.0
assert FilterSettings(flow_ema_alpha=0.0).normalised().flow_ema_alpha == 1.0
assert FilterSettings(flow_ema_alpha=2.0).normalised().flow_ema_alpha == 1.0
assert FilterSettings(median_window="many").normalised().median_window == 1
assert FilterSettings(lowpass_hz=-5.0).normalised().lowpass_hz == 0.0
assert FilterSettings(lowpass_hz="lots").normalised().lowpass_hz == 0.0
assert FilterSettings(sample_rate_hz=-1.0).normalised().sample_rate_hz == "auto"
assert FilterSettings(lowpass_order=99).normalised().lowpass_order == 6
def test_problems_reports_a_cutoff_the_device_would_drop():
"""The SDK drops a cutoff at or above Nyquist; the dialog must say so."""
assert FilterSettings(lowpass_hz=10.0).problems(30.0) == ()
above = FilterSettings(lowpass_hz=20.0).problems(30.0)
assert len(above) == 1 and "Nyquist" in above[0]
# A low-pass that is off cannot be above anything.
assert FilterSettings(lowpass_hz=0.0).problems(30.0) == ()
# ------------------------------------------------------------- driving the SDK
def test_applying_settings_is_one_configure_call_in_sdk_names():
sensor = FakeSensor()
unsupported = FilterSettings(
flow_ema_alpha=0.5, median_window=3, lowpass_hz=10.0, sample_rate_hz="auto"
).apply_to(sensor)
assert unsupported == ()
assert sensor.calls == [
{
"flow_ema_alpha": 0.5,
"force_median_window": 3,
"lowpass_cutoff_hz": 10.0,
"lowpass_order": 2,
"lowpass_sample_rate_hz": "auto",
}
]
def test_an_sdk_that_cannot_take_a_key_is_reported_not_ignored():
class OldSensor:
def configure(self, *, lowpass_cutoff_hz=None, lowpass_sample_rate_hz=None) -> None:
self.cutoff = lowpass_cutoff_hz
sensor = OldSensor()
unsupported = FilterSettings(flow_ema_alpha=0.5, median_window=3).apply_to(sensor)
assert unsupported == (
"flow_ema_alpha",
"force_median_window",
"force_lowpass_order",
)
# What the old SDK *can* take still gets there.
assert sensor.cutoff == 0.0
def test_the_display_toggle_is_never_sent_to_the_sensor():
kwargs = FilterSettings(show_unfiltered=True).configure_kwargs()
assert "show_unfiltered" not in kwargs
assert kwargs == {
"flow_ema_alpha": 1.0,
"force_median_window": 1,
"lowpass_cutoff_hz": 0.0,
"lowpass_order": 2,
"lowpass_sample_rate_hz": "auto",
}
# ------------------------------------------------------------ reading the truth
def test_the_effective_state_is_what_the_dialog_shows():
"""A request and a device can disagree; the device wins."""
state = {
"preset": "balanced",
"measurement": {"flow_ema_alpha": 0.5, "flow_ema_applied": True},
"readout": {
"force_median_window": 3,
"force_median_applied": True,
"force_lowpass_cutoff_hz": 0.0,
"force_lowpass_order": 2,
"force_lowpass_rate_auto": True,
"force_lowpass_applied": False,
},
"source": "sensor.filters_state",
}
settings = FilterSettings.from_filters_state(state, show_unfiltered=True)
assert settings.flow_ema_alpha == 0.5
assert settings.median_window == 3
assert settings.lowpass_hz == 0.0
assert settings.sample_rate_hz == "auto"
assert settings.show_unfiltered is True
assert settings.preset == "balanced"
assert settings.describe() == "flowEMA(0.5) + median(3)"
# Round trip: what came from the device configures the device.
assert settings.configure_kwargs()["flow_ema_alpha"] == 0.5
def test_a_pinned_sample_rate_survives_the_round_trip():
state = {
"measurement": {"flow_ema_alpha": 1.0},
"readout": {
"force_median_window": 1,
"force_lowpass_cutoff_hz": 10.0,
"force_lowpass_order": 4,
"force_lowpass_rate_auto": False,
"force_lowpass_sample_rate_hz": 60.0,
},
}
settings = FilterSettings.from_filters_state(state)
assert settings.sample_rate_hz == 60.0
assert settings.lowpass_order == 4
assert settings.as_state()["readout"]["force_lowpass_rate_auto"] is False
assert settings.as_state()["readout"]["force_lowpass_sample_rate_hz"] == 60.0
# ---------------------------------------------------------------- the record
def test_metadata_records_the_device_state_and_says_where_it_came_from():
state = {
"preset": "low_noise",
"measurement": {"flow_ema_alpha": 0.3},
"readout": {
"force_median_window": 3,
"force_lowpass_cutoff_hz": 10.0,
"force_lowpass_order": 2,
"force_lowpass_rate_auto": True,
},
"source": "sensor.filters_state",
}
block = FilterSettings.from_filters_state(state).metadata(state)
assert block["source"] == "sensor.filters_state"
assert block["preset"] == "low_noise"
# The two groups stay apart: this is what makes two records comparable or not.
assert block["measurement"] == {"flow_ema_alpha": 0.3}
assert block["readout"]["force_lowpass_cutoff_hz"] == 10.0
assert block["describe"] == "flowEMA(0.3) + median(3) + lowpass(10Hz,order2)"
def test_metadata_without_a_device_is_labelled_as_a_request():
block = FilterSettings(median_window=3).metadata()
assert block["source"] == "app.FilterSettings"
assert block["describe"] == "median(3)"
assert block["show_unfiltered"] is False
def test_describe_state_ignores_stages_that_are_off():
assert describe_state({}) == "none"
assert describe_state({"measurement": {"flow_ema_alpha": 1.0}}) == "none"
assert (
describe_state(
{
"measurement": {"flow_ema_alpha": 0.5},
"readout": {"force_median_window": 1, "force_lowpass_cutoff_hz": 0.0},
}
)
== "flowEMA(0.5)"
)
def test_settings_round_trip_through_a_preferences_mapping():
settings = settings_from_mapping(
{
"flow_ema_alpha": 0.3,
"force_median_window": 7,
"force_lowpass_cutoff_hz": 12.5,
"show_unfiltered": True,
"preset": "low_noise",
}
)
assert settings.flow_ema_alpha == 0.3
assert settings.median_window == 7
assert settings.lowpass_hz == 12.5
assert settings.show_unfiltered is True
assert settings.preset == "low_noise"
assert settings.describe() == "flowEMA(0.3) + median(7) + lowpass(12.5Hz,order2)"
# persisting and reloading must not change anything.
assert settings_from_mapping(settings.as_dict()) == settings
# An empty mapping is "nothing filters", not "our favourite filter".
assert settings_from_mapping(None) == FilterSettings()
assert settings_from_mapping(None).active() is False
# ------------------------------------------- the reference chain (SDK parity)
def test_a_disabled_chain_is_a_pass_through():
chain = ForceFilterChain(FilterSettings())
assert not chain.active
sample = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
assert chain.process(sample).tolist() == sample
@@ -34,9 +256,7 @@ def test_a_disabled_chain_is_a_pass_through():
def test_the_median_removes_a_single_sample_spike():
"""One bad readout must not reach the chart, the store or the CSV."""
chain = ForceFilterChain(
FilterSettings(median_enabled=True, median_window=3, lowpass_enabled=False)
)
chain = ForceFilterChain(FilterSettings(median_window=3))
outputs = [
float(chain.process(_zeros(9.0 if index == 2 else 0.0))[0])
for index in range(5)
@@ -48,18 +268,21 @@ def test_the_median_removes_a_single_sample_spike():
assert sustained == 9.0, sustained
def test_the_median_window_is_forced_odd_and_bounded():
assert FilterSettings(median_window=4).normalised().median_window == 5
assert FilterSettings(median_window=1).normalised().median_window == 5
# A window below three samples cannot remove anything: the stage turns off.
assert FilterSettings(median_enabled=True, median_window=2).normalised().median_enabled is False
def test_the_median_window_is_bounded():
# "Off" is a window of one; anything that cannot be a window at all becomes one.
assert FilterSettings(median_window=1).normalised().median_window == 1
assert FilterSettings(median_window=0).normalised().median_window == 1
assert FilterSettings(median_window=-3).normalised().median_window == 1
assert FilterSettings(median_window=1).median_on is False
# A window of two is a real (if mild) median — the SDK runs it, so we do too;
# only one sample means the stage is off.
assert FilterSettings(median_window=2).median_on is True
assert ForceFilterChain(FilterSettings(median_window=1)).active is False
def test_the_lowpass_settles_on_a_step_without_ringing_at_the_start():
"""A session must not open with a transient invented by the filter."""
chain = ForceFilterChain(
FilterSettings(lowpass_enabled=True, lowpass_hz=5.0, sample_rate_hz=30.0)
)
chain = ForceFilterChain(FilterSettings(lowpass_hz=5.0, sample_rate_hz=30.0))
first = float(chain.process(_zeros(3.0))[0])
assert abs(first - 3.0) < 1e-9, f"the filter invented a transient: {first}"
# A step is damped and then reached. A second-order digital Butterworth
@@ -74,19 +297,14 @@ def test_the_lowpass_settles_on_a_step_without_ringing_at_the_start():
def test_each_channel_keeps_its_own_filter_state():
chain = ForceFilterChain(
FilterSettings(median_enabled=True, median_window=3, lowpass_enabled=False)
)
chain.process([1.0, 100.0, 2.0, 200.0, 3.0, 300.0])
chain.process([1.0, 100.0, 2.0, 200.0, 3.0, 300.0])
out = chain.process([1.0, 100.0, 2.0, 200.0, 3.0, 300.0])
chain = ForceFilterChain(FilterSettings(median_window=3))
for _ in range(3):
out = chain.process([1.0, 100.0, 2.0, 200.0, 3.0, 300.0])
assert out.tolist() == [1.0, 100.0, 2.0, 200.0, 3.0, 300.0]
def test_reset_clears_the_memory_between_sessions():
chain = ForceFilterChain(
FilterSettings(lowpass_enabled=True, lowpass_hz=5.0, sample_rate_hz=30.0)
)
chain = ForceFilterChain(FilterSettings(lowpass_hz=5.0, sample_rate_hz=30.0))
for _ in range(30):
chain.process(_zeros(5.0))
chain.reset()
@@ -95,20 +313,19 @@ def test_reset_clears_the_memory_between_sessions():
)
def test_the_chain_reproduces_the_sdk_filter_exactly():
"""Replacing the SDK's low-pass with ours must not move a single number.
def test_the_reference_chain_reproduces_the_sdk_filter_exactly():
"""The SDK chain is not a black box: we can reproduce it number for number.
``Sensor._apply_force_filter`` filters ``_force_raw`` with
``orisys.signal_processing.LowpassFilter`` (Butterworth, ``sosfilt`` with
persistent state, steady-state reset) and then applies the device
calibration. Our chain uses the same recipe, so at the same settings the
recorded values stay identical — this test is what makes that a guarantee
rather than a hope.
persistent state, steady-state reset) and *then* applies the device
calibration. Running the same recipe here is what turns "the app does not
filter, the SDK does" from a claim into something a test can check.
"""
try:
from orisys.signal_processing import LowpassFilter
except Exception: # pragma: no cover - the SDK is not installed here
return
pytest.skip("orisys SDK is not importable")
rng = np.random.default_rng(3)
signal = np.cumsum(rng.normal(0.0, 1.0, (400, 3)), axis=0) * 10.0 + 500.0
@@ -118,9 +335,7 @@ def test_the_chain_reproduces_the_sdk_filter_exactly():
sdk.reset(signal[0])
sdk_out = np.array([sdk.process(row) for row in signal])
chain = ForceFilterChain(
FilterSettings(lowpass_enabled=True, lowpass_hz=10.0, sample_rate_hz=30.0)
)
chain = ForceFilterChain(FilterSettings(lowpass_hz=10.0, sample_rate_hz=30.0))
# The chain carries six channels (raw + newtons); the first three are the
# raw counts the SDK filters, so give all six the same steady state.
chain.reset(list(signal[0]) * 2)
@@ -129,30 +344,3 @@ def test_the_chain_reproduces_the_sdk_filter_exactly():
assert np.array_equal(sdk_out, chain_out), (
f"the chain differs from the SDK filter by {np.abs(sdk_out - chain_out).max()}"
)
settings = settings_from_mapping(
{
"median_enabled": True,
"median_window": 7,
"lowpass_enabled": True,
"lowpass_hz": 12.5,
"show_unfiltered": True,
}
)
assert settings.median_enabled and settings.median_window == 7
assert settings.lowpass_hz == 12.5 and settings.show_unfiltered
assert settings.describe() == "median(7) + lowpass(12.5Hz,order2)"
# An empty mapping is the documented default, not a disabled chain.
default = settings_from_mapping(None)
assert default.as_dict()["lowpass_hz"] == FilterSettings().lowpass_hz
def test_settings_round_trip_through_a_preferences_mapping():
settings = settings_from_mapping({
"median_enabled": True, "median_window": 7,
"lowpass_enabled": True, "lowpass_hz": 12.5, "show_unfiltered": True,
})
assert settings.median_enabled and settings.median_window == 7
assert settings.lowpass_hz == 12.5 and settings.show_unfiltered
assert settings.describe() == "median(7) + lowpass(12.5Hz,order2)"
assert settings_from_mapping(None).as_dict()["lowpass_hz"] == FilterSettings().lowpass_hz