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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user