新增单链力滤波引擎(中值 + 因果低通),为"显示=记录"做准备 - What: shared/src/orisys_app_shared/force/filters.py 新增 FilterSettings(中值开关/窗口、低通开关/截止/阶数、采样率、show_unfiltered,窗口强制奇数、<3 自动关闭、空设置直通)与 ForceFilterChain:6 通道(3 原始计数 + 3 牛顿值)、先中值后低通、两级均因果且逐样本、每通道独立 IIR 状态,reset(first) 用稳态初值启动以避免会话开头的假瞬态;settings_from_mapping 支持从 prefs/metadata 构造,describe() 给出一行摘要写进录制元数据;force/__init__ 导出这三个名字。新增 shared/tests/test_force_filters.py(7 例:直通、中值去单点尖峰但不削持续变化、窗口奇数化与下限、阶跃无起始瞬态、通道状态独立、reset 清状态、prefs 往返),并加入 drive_shared 的模块表。 - Why: 用户问"滤波在哪里调",查清现状是两处固化 10 Hz 低通(SDK 侧影响记录、显示侧再用 filtfilt 二次滤波导致显示≠记录)且 median 完全不存在;用户要求把采集滤波与显示滤波统一成一条链、显示值必须等于记录值、并保留滤波前数据(另开一个 buffer,且写进 forces.csv)。本轮先落地这条链并覆盖测试。 - Verification: drive_shared.py 141 项 0 失败(含新增 7 项)、ui_check.py 554 项 0 失败、test_ui_smoke.py 71 项 0 失败、locale_studio.py 262 键齐全。 - Not in this change: 采集路径未改动(SDK 低通仍是 10 Hz、forces.csv 仍是 11 列)——把带状态的滤波器半途接进记录会让数据只滤一半,因此下一步一次性完成:关 SDK 侧滤波、worker 应用链并让 packet 同时携带滤波前后两套值、forces.csv 追加 6 列未滤波读数(11→17、RECORD_FORMAT_VERSION+1)、SessionStore 第二 buffer、去掉显示端 filtfilt、曲线表头新增齿轮按钮并扩展曲线设置对话框(中值/低通/显示未滤波,运行中禁用滤波控件)。完整清单见 .repro/filter_plan.md。

This commit is contained in:
ykcheungab
2026-09-14 09:57:03 +08:00
parent da8165cf03
commit 2fd2891a47
3 changed files with 326 additions and 0 deletions
@@ -13,6 +13,11 @@ from orisys_app_shared.force.calibration_recorder import (
CalibrationPoint,
CalibrationRecorder,
)
from orisys_app_shared.force.filters import (
ForceFilterChain,
FilterSettings,
settings_from_mapping,
)
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
from orisys_app_shared.force.session import (
NA,
@@ -33,6 +38,8 @@ __all__ = [
"CalibrationPoint",
"CalibrationRecorder",
"DisplayForces",
"FilterSettings",
"ForceFilterChain",
"MetricHistory",
"PlotState",
"SelectionStats",
@@ -44,5 +51,6 @@ __all__ = [
"lowpass_filter",
"meta_from_sensor_json",
"select_display_forces",
"settings_from_mapping",
"summarise",
]
@@ -0,0 +1,205 @@
"""One causal filter chain for the force readings, and the values it replaced.
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 — there is no
second, display-only smoothing that would make the chart disagree with the
record. The unfiltered readings are kept beside the filtered ones, so a filter
can be judged instead of trusted.
Both stages are causal and per-sample, because acquisition cannot look ahead:
* **median** — a window of ``median_window`` samples (odd, ≥3), applied per
channel; it removes single-sample spikes without smearing a step;
* **low-pass** — a Butterworth IIR with persistent state (``sosfilt`` with
``zi``), the same shape as the SDK's own ``LowpassFilter``.
Order is median then low-pass: the median protects the IIR from a spike that
would otherwise ring for several samples.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Optional
import numpy as np
#: Channels of the chain: three raw counts followed by the three newton values.
CHANNELS = 6
def _odd(value: Any, *, minimum: int = 3, maximum: int = 31) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return 0
if number < minimum:
return 0
if number % 2 == 0:
number += 1
return min(number, maximum)
@dataclass(frozen=True)
class FilterSettings:
"""What the operator chose. ``enabled`` flags decide what actually runs."""
median_enabled: bool = False
median_window: int = 5
lowpass_enabled: bool = True
lowpass_hz: float = 10.0
lowpass_order: int = 2
sample_rate_hz: float = 30.0
#: Show the unfiltered readings next to the filtered ones on the cards.
show_unfiltered: bool = False
def normalised(self) -> "FilterSettings":
window = _odd(self.median_window)
return FilterSettings(
median_enabled=bool(self.median_enabled) and window >= 3,
median_window=window or 5,
lowpass_enabled=bool(self.lowpass_enabled) and float(self.lowpass_hz) > 0,
lowpass_hz=max(0.0, float(self.lowpass_hz)),
lowpass_order=max(1, min(6, int(self.lowpass_order))),
sample_rate_hz=max(1.0, float(self.sample_rate_hz)),
show_unfiltered=bool(self.show_unfiltered),
)
def active(self) -> bool:
"""Whether any stage actually filters (a disabled chain is a pass-through)."""
settings = self.normalised()
return bool(settings.median_enabled or settings.lowpass_enabled)
def as_dict(self) -> dict:
settings = self.normalised()
return {
"median_enabled": settings.median_enabled,
"median_window": settings.median_window,
"lowpass_enabled": settings.lowpass_enabled,
"lowpass_hz": settings.lowpass_hz,
"lowpass_order": settings.lowpass_order,
"sample_rate_hz": settings.sample_rate_hz,
"show_unfiltered": settings.show_unfiltered,
}
def describe(self) -> str:
"""A short human line for the recording metadata."""
settings = self.normalised()
parts = []
if settings.median_enabled:
parts.append(f"median({settings.median_window})")
if settings.lowpass_enabled:
parts.append(
f"lowpass({settings.lowpass_hz:g}Hz,order{settings.lowpass_order})"
)
return " + ".join(parts) if parts else "none"
def settings_from_mapping(data: Optional[dict]) -> FilterSettings:
"""Build settings from a preferences/metadata mapping, with defaults."""
source = dict(data or {})
defaults = FilterSettings()
return FilterSettings(
median_enabled=source.get("median_enabled", defaults.median_enabled),
median_window=source.get("median_window", defaults.median_window),
lowpass_enabled=source.get("lowpass_enabled", defaults.lowpass_enabled),
lowpass_hz=source.get("lowpass_hz", defaults.lowpass_hz),
lowpass_order=source.get("lowpass_order", defaults.lowpass_order),
sample_rate_hz=source.get("sample_rate_hz", defaults.sample_rate_hz),
show_unfiltered=source.get("show_unfiltered", defaults.show_unfiltered),
).normalised()
class ForceFilterChain:
"""The median + low-pass chain, one instance per acquisition session."""
def __init__(self, settings: Optional[FilterSettings] = None) -> None:
self._settings = (settings or FilterSettings()).normalised()
self._median: list[list[float]] = [[] for _ in range(CHANNELS)]
self._sos = None
#: One IIR state per channel: the six readings are independent signals.
self._zi: Optional[list[np.ndarray]] = None
self._build()
# ------------------------------------------------------------------ setup
def _build(self) -> None:
self._sos = None
self._zi = None
settings = self._settings
if not settings.lowpass_enabled:
return
from scipy.signal import butter
nyquist = 0.5 * settings.sample_rate_hz
cutoff = min(float(settings.lowpass_hz), nyquist * 0.95)
if cutoff <= 0:
return
self._sos = butter(
settings.lowpass_order,
cutoff / nyquist,
btype="low",
output="sos",
)
self._zi = None
def configure(self, settings: FilterSettings) -> None:
"""Re-tune the chain; the caller resets the stream when it does."""
self._settings = settings.normalised()
self._median = [[] for _ in range(CHANNELS)]
self._build()
def settings(self) -> FilterSettings:
return self._settings
@property
def active(self) -> bool:
return self._settings.active()
def reset(self, first: Optional[Iterable[float]] = None) -> None:
"""Drop the filter memory (a new session must not inherit the old one)."""
self._median = [[] for _ in range(CHANNELS)]
self._zi = None
if first is not None and self._sos is not None:
# Start each IIR at its first sample's steady state instead of at
# zero: a step from 0 to a real reading otherwise rings for a few
# samples, which would show up as a transient at session start.
from scipy.signal import sosfilt_zi
values = np.asarray(list(first), dtype=np.float64).reshape(-1)
if values.size == CHANNELS:
steady = sosfilt_zi(self._sos)
self._zi = [steady * float(value) for value in values]
# ----------------------------------------------------------------- filter
def process(self, values) -> np.ndarray:
"""Filter one 6-channel sample: three raw counts then three newtons."""
sample = np.asarray(list(values), dtype=np.float64).reshape(-1)
if sample.size != CHANNELS:
raise ValueError(f"expected {CHANNELS} channels, got {sample.size}")
if not self.active:
return sample.copy()
settings = self._settings
out = sample.copy()
if settings.median_enabled:
window = int(settings.median_window)
for index in range(CHANNELS):
series = self._median[index]
series.append(float(sample[index]))
if len(series) > window:
del series[0]
out[index] = float(np.median(series))
if self._sos is not None:
from scipy.signal import sosfilt
if self._zi is None:
self.reset(out)
states = self._zi if self._zi is not None else [None] * CHANNELS
for index in range(CHANNELS):
filtered, states[index] = sosfilt(
self._sos, np.array([out[index]]), zi=states[index]
)
out[index] = float(filtered[0])
self._zi = states
return out