新增单链力滤波引擎(中值 + 因果低通),为"显示=记录"做准备 - 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:
@@ -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
|
||||
Reference in New Issue
Block a user