采集端统一由 SDK 滤波、显示端不再二次滤波,session.json 记录生效值 - What: sdk_worker/finger_worker 删除硬编码的 sensor.configure(lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, lowpass_sample_rate_hz=...) —— 它会让界面上的曲线与 CSV 走两条不同的滤波路径;改为在会话打开设备后调用 FilterSettings.apply_to(sensor),并把两件事分别记下来:self._filter_state = effective_filters(sensor)(设备报的**实际**生效值,供界面与记录使用)与 self._filters_unsupported(该 SDK 应用不了的键,老 SDK 必须被看见而不是被吞掉);新增 worker 的 set_filter_settings()/filter_settings()/filter_state()/filters_unsupported() 访问器,start() 时清空上一会话的 filter_state(与 _calibration_version 同样的理由:新会话不能继承旧会话的说法)。finger_worker 保留原有的"runtime 可能拒绝 configure"的容错,但改成记日志并把全部键标为未应用。app_window 新增 _filter_settings()(从 preferences 的 plot_* 键读,空/坏值即"什么都不滤")与 _filter_metadata()(向**正在跑的** worker 要设备实际状态,写成 session.json 的 filters 块:measurement/readout/describe/source,SDK 缺键时附 unsupported_by_sdk);_start_session 在 start() 之前把设置交给 worker。ui/force_plots.py 把 MetricHistory 的 lowpass_cutoff_hz 从 LOWPASS_CUTOFF_HZ 改成 0.0:SDK 已经滤过一遍,且这个 buffer 就是进记录的那个,显示端再滤会让曲线画出 CSV 里根本没有的数据(原来用的还是零相位 filtfilt,非因果)。acquisition/constants.py 删掉 LOWPASS_CUTOFF_HZ("app 在 10 Hz 滤波"这件事不存在了),FORCE_SAMPLE_RATE_HZ 保留。tests/test_force_filters.py 增 3 项:worker 未开设备时 filter_state() 为 {}("没问过"不等于"没在滤")、effective_filters() 对没有 filters_state() 的 SDK 返回 {}、以及对着真实 SDK 的端到端断言(external-frame 构造 box Sensor → apply_to → filters_state 回报 flowEMA(0.5)+median(3)+lowpass(10Hz,order2) → force_triplets 两个 buffer 都在 → 全关也可表达)。apps/studio/tools/ui_check.py 的 _StuckWorker 补上新合约方法(set_filter_settings/filter_settings/filter_state/filters_unsupported),否则窗口一启动就 AttributeError。 - Why: 用户的核心要求是"显示的和记录的必须一致,包括所有滤波",并决定整条链留在 SDK、app 只通过 Sensor.configure 驱动。此前 app 硬编码 10 Hz 低通(SDK 侧)**加上**显示端 filtfilt(app 侧),同一次采集的两份数据经过两条不同的滤波路径;session.json 里的 filter 块也只是照抄常量,说的不是设备在做的事。 - Verification: shared 全量 156 passed(含新增的真实 SDK 端到端断言);studio 85 项测试通过(修好 _StuckWorker 合约后);ui_check 554 项检查、4 个场景 0 失败;编译通过。 - Not in this change: 曲线表头的齿轮按钮与滤波设置对话框(因此现在还只能改 preferences.json 的 plot_* 键来换滤波)、CSV 的 17 列与未滤波第二 buffer、回放界面显示滤波前曲线、发布说明里"默认不再滤波"的措辞。

This commit is contained in:
ykcheungab
2026-09-14 11:01:55 +08:00
parent 0d8b79b759
commit 148573934f
10 changed files with 273 additions and 35 deletions
+62 -8
View File
@@ -28,12 +28,10 @@ from PyQt5.QtWidgets import (
QWidget,
)
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping
from orisys_app_shared.force.session import SessionStore
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import RecordConfig
@@ -547,10 +545,7 @@ class StudioMainWindow(QMainWindow):
),
"frame_id": "monotonic per acquisition session",
},
"filter": {
"lowpass_cutoff_hz": float(LOWPASS_CUTOFF_HZ),
"sample_rate_hz": float(FORCE_SAMPLE_RATE_HZ),
},
"filter": self._filter_metadata(),
"recording_mode": self.toolbar.record_mode(),
"video_config": config.as_dict(),
}
@@ -577,6 +572,61 @@ class StudioMainWindow(QMainWindow):
except Exception: # noqa: BLE001
return "unavailable"
def _filter_settings(self) -> FilterSettings:
"""The filters the operator asked for, from the preferences file.
One place reads them, so the dialog that writes them and the session that
runs them cannot drift apart. An empty or unreadable preference is
"nothing filters" — the same default the shipped sensor configs state.
"""
keys = (
"flow_ema_alpha",
"force_median_window",
"force_lowpass_cutoff_hz",
"force_lowpass_order",
"force_lowpass_sample_rate_hz",
"show_unfiltered",
"preset",
)
stored = {}
for key in keys:
try:
value = self.prefs.get(f"plot_{key}")
except Exception: # noqa: BLE001 - a broken preference is not fatal
value = None
if value is not None:
stored[key] = value
return settings_from_mapping(stored)
def _filter_metadata(self) -> dict:
"""The filters this session actually ran, for ``session.json``.
Read from the worker that is running the device, not from the settings
the operator chose: those are two different things whenever the SDK is
older than the request, and the record has to describe the data it holds.
Never raises — a record that cannot name its filters is still a record.
"""
try:
worker = self._active_worker
if worker is None:
worker = self._worker_for(self.sensor_picker.sensor_type())
except Exception: # noqa: BLE001
logger.exception("Could not find the worker to ask about the filters")
return FilterSettings().metadata()
try:
state = worker.filter_state() if worker is not None else {}
settings = worker.filter_settings() if worker is not None else None
unsupported = tuple(worker.filters_unsupported()) if worker is not None else ()
except Exception: # noqa: BLE001
logger.exception("Could not read the filter state for the record")
state, settings, unsupported = {}, None, ()
block = (settings or FilterSettings()).metadata(state or None)
if unsupported:
# Named, not hidden: the record says which settings the installed SDK
# could not apply, so the numbers can be read for what they are.
block["unsupported_by_sdk"] = list(unsupported)
return block
# ------------------------------------------------------- record channel
def _on_packet_recorded(self, packet) -> None:
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
@@ -2047,6 +2097,10 @@ class StudioMainWindow(QMainWindow):
# it is wired even if the worker was installed by something other than
# ``_ensure_workers`` (``add_sink`` is idempotent).
self._active_worker.add_sink(self._on_packet_recorded)
# The filters for this session, before it opens the device: the chain runs
# in the SDK, so this is the one moment the choice can be handed over.
# The acquisition thread applies it and records what the device reports.
self._active_worker.set_filter_settings(self._filter_settings())
try:
self._active_worker.start(source, config_ref=config_ref)
except Exception as exc:
@@ -15,7 +15,7 @@ from PyQt5.QtWidgets import (
QVBoxLayout,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.source import StoreCurveSource
@@ -158,7 +158,12 @@ class ForcePlotsPanel(QFrame):
channels=3,
window_sec=4.0,
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
# No filtering here: the SDK already filtered these readings (median,
# low-pass, flow EMA — whatever the session was configured with) and
# this is the same buffer that goes into record/forces.csv. Filtering
# again would draw a curve the file does not contain, and the
# zero-phase filtfilt this used to do is not even causal.
lowpass_cutoff_hz=0.0,
y_axis_min_span=0.5,
max_fps=DEFAULT_MAX_FPS,
)
+17
View File
@@ -32,6 +32,7 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
os.chdir(APP_ROOT)
from orisys_app_shared.acquisition.sinks import RecordSinkMixin # noqa: E402
from orisys_app_shared.force.filters import FilterSettings # noqa: E402
from orisys_app_shared.record import FORCES_HEADER # noqa: E402
FAILURES: list[str] = []
@@ -237,6 +238,22 @@ class _StuckWorker(RecordSinkMixin):
#: Zeroing requests, and the confirmation callback the window installs.
self.reset_requests = 0
self.on_reset_applied = None
#: Filters handed over before a session, and what the fake device reports
#: as in effect (this stub has no ``configure``, so it reports nothing).
self.filter_settings_value = FilterSettings()
self.filter_state_value: dict = {}
def set_filter_settings(self, settings) -> None:
self.filter_settings_value = settings
def filter_settings(self):
return self.filter_settings_value
def filter_state(self) -> dict:
return dict(self.filter_state_value)
def filters_unsupported(self) -> tuple[str, ...]:
return ()
def set_pending_calibration(self, path) -> None:
self.calibration_requests.append(str(path))