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:
@@ -14,8 +14,9 @@ from orisys_app_shared.force.calibration_recorder import (
|
|||||||
CalibrationRecorder,
|
CalibrationRecorder,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.force.filters import (
|
from orisys_app_shared.force.filters import (
|
||||||
ForceFilterChain,
|
|
||||||
FilterSettings,
|
FilterSettings,
|
||||||
|
ForceFilterChain,
|
||||||
|
describe_state,
|
||||||
settings_from_mapping,
|
settings_from_mapping,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
|
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
|
||||||
@@ -47,6 +48,7 @@ __all__ = [
|
|||||||
"SessionSlice",
|
"SessionSlice",
|
||||||
"SessionStore",
|
"SessionStore",
|
||||||
"build_force_calibration_v2",
|
"build_force_calibration_v2",
|
||||||
|
"describe_state",
|
||||||
"export_force_calibration_v2",
|
"export_force_calibration_v2",
|
||||||
"lowpass_filter",
|
"lowpass_filter",
|
||||||
"meta_from_sensor_json",
|
"meta_from_sensor_json",
|
||||||
|
|||||||
@@ -1,119 +1,370 @@
|
|||||||
"""One causal filter chain for the force readings, and the values it replaced.
|
"""The app's filter control surface: what was asked for, and what is running.
|
||||||
|
|
||||||
The chain runs **once, in the acquisition loop**, so the numbers on the screen
|
The filter *chain* lives in the SDK — ``compute_flow`` applies the flow EMA
|
||||||
and the numbers in ``forces.csv`` are the same filtered values — there is no
|
before the HHD, and ``_apply_force_filter`` applies the median and the low-pass
|
||||||
second, display-only smoothing that would make the chart disagree with the
|
*before* the device calibration. Filtering anywhere else would produce numbers no
|
||||||
record. The unfiltered readings are kept beside the filtered ones, so a filter
|
recording could reproduce, and would leave the unfiltered newtons unrecoverable
|
||||||
can be judged instead of trusted.
|
(the calibration has already been applied by then). So this module does three
|
||||||
|
things, and nothing else:
|
||||||
|
|
||||||
Both stages are causal and per-sample, because acquisition cannot look ahead:
|
* turn the operator's choice into one ``Sensor.configure(...)`` call
|
||||||
|
(:meth:`FilterSettings.apply_to`);
|
||||||
|
* read the **effective** values back from ``Sensor.filters_state()``
|
||||||
|
(:meth:`FilterSettings.from_filters_state`), so the dialog and the record show
|
||||||
|
what the device is doing rather than what was typed into it;
|
||||||
|
* write the settings into the recording metadata (:meth:`FilterSettings.metadata`),
|
||||||
|
keeping the **measurement** group (``flow_ema_alpha``: it changes the flow, so
|
||||||
|
two recordings with different values are not comparable) apart from the
|
||||||
|
**readout** group (median, low-pass: they only smooth the force on its way out).
|
||||||
|
|
||||||
* **median** — a window of ``median_window`` samples (odd, ≥3), applied per
|
``ForceFilterChain`` at the bottom is a reference implementation kept for
|
||||||
channel; it removes single-sample spikes without smearing a step;
|
``tests/test_force_filters.py``, which pins that this app understands the SDK's
|
||||||
* **low-pass** — a Butterworth IIR with persistent state (``sosfilt`` with
|
chain exactly. The app itself never filters.
|
||||||
``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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import inspect
|
||||||
from dataclasses import dataclass
|
import logging
|
||||||
from pathlib import Path
|
from dataclasses import dataclass, replace
|
||||||
from typing import Any, Iterable, Optional
|
from typing import Any, Iterable, Mapping, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
#: Channels of the chain: three raw counts followed by the three newton values.
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Channels of the reference chain: three raw counts then the three newtons.
|
||||||
CHANNELS = 6
|
CHANNELS = 6
|
||||||
|
|
||||||
|
#: The SDK settings this app drives, in chain order (measurement first). These
|
||||||
|
#: are the sensor's own *state* keys: the same words appear in ``sensor.json``,
|
||||||
|
#: ``filters_state()``, ``session.json`` and the preferences, so a reader never
|
||||||
|
#: has to translate between two vocabularies.
|
||||||
|
MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",)
|
||||||
|
READOUT_KEYS: tuple[str, ...] = (
|
||||||
|
"force_median_window",
|
||||||
|
"force_lowpass_cutoff_hz",
|
||||||
|
"force_lowpass_order",
|
||||||
|
"force_lowpass_sample_rate_hz",
|
||||||
|
)
|
||||||
|
|
||||||
def _odd(value: Any, *, minimum: int = 3, maximum: int = 31) -> int:
|
#: State key -> ``Sensor.configure`` parameter. Not the same on both sides: the
|
||||||
|
#: low-pass pair is ``force_lowpass_*`` in the config/state and ``lowpass_*`` in
|
||||||
|
#: the call. One table, so "can the SDK take this?" and "what do we pass?" can
|
||||||
|
#: never disagree.
|
||||||
|
CONFIGURE_PARAMETERS: dict[str, str] = {
|
||||||
|
"flow_ema_alpha": "flow_ema_alpha",
|
||||||
|
"force_median_window": "force_median_window",
|
||||||
|
"force_lowpass_cutoff_hz": "lowpass_cutoff_hz",
|
||||||
|
"force_lowpass_order": "lowpass_order",
|
||||||
|
"force_lowpass_sample_rate_hz": "lowpass_sample_rate_hz",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: A sensible cutoff to offer when the operator switches the low-pass on.
|
||||||
|
SUGGESTED_LOWPASS_HZ = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: Any, default: float) -> float:
|
||||||
try:
|
try:
|
||||||
number = int(value)
|
return float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0
|
return default
|
||||||
if number < minimum:
|
|
||||||
return 0
|
|
||||||
if number % 2 == 0:
|
def _as_int(value: Any, default: int) -> int:
|
||||||
number += 1
|
try:
|
||||||
return min(number, maximum)
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_rate(value: Any) -> float | str:
|
||||||
|
"""``"auto"``, or a positive rate in Hz. Anything else is ``"auto"``."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip().lower()
|
||||||
|
return "auto" if text in ("", "auto", "measured") else "auto"
|
||||||
|
rate = _as_float(value, 0.0)
|
||||||
|
return rate if rate > 0 else "auto"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FilterSettings:
|
class FilterSettings:
|
||||||
"""What the operator chose. ``enabled`` flags decide what actually runs."""
|
"""The filter settings the operator chose, in the SDK's own vocabulary.
|
||||||
|
|
||||||
median_enabled: bool = False
|
Every stage is switched by its **own value** (``flow_ema_alpha >= 1``,
|
||||||
median_window: int = 5
|
``median_window <= 1``, ``lowpass_hz <= 0`` all mean "off"), so there is no
|
||||||
lowpass_enabled: bool = True
|
second boolean to disagree with the number. Values that cannot be used are
|
||||||
lowpass_hz: float = 10.0
|
resolved to "off" by :meth:`normalised`, never to a default that filters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Flow-EMA exponent. **Measurement**: it changes the flow, the contact
|
||||||
|
#: statistics and ``_force_raw``, so two recordings with different values are
|
||||||
|
#: not comparable.
|
||||||
|
flow_ema_alpha: float = 1.0
|
||||||
|
#: Sliding-median window in samples. **Readout**.
|
||||||
|
median_window: int = 1
|
||||||
|
#: Low-pass cutoff in Hz. **Readout**.
|
||||||
|
lowpass_hz: float = 0.0
|
||||||
lowpass_order: int = 2
|
lowpass_order: int = 2
|
||||||
sample_rate_hz: float = 30.0
|
#: ``"auto"`` follows the measured frame rate (30 fps until measured); a
|
||||||
#: Show the unfiltered readings next to the filtered ones on the cards.
|
#: number pins the rate the coefficients were designed for.
|
||||||
|
sample_rate_hz: float | str = "auto"
|
||||||
|
#: Display only — draw the unfiltered curves beside the filtered ones. Never
|
||||||
|
#: sent to the sensor, and never part of what makes two records comparable.
|
||||||
show_unfiltered: bool = False
|
show_unfiltered: bool = False
|
||||||
|
#: The preset this came from, when it came from one (kept so a record can say
|
||||||
|
#: "balanced" instead of only the numbers it expanded to).
|
||||||
|
preset: Optional[str] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- resolving
|
||||||
def normalised(self) -> "FilterSettings":
|
def normalised(self) -> "FilterSettings":
|
||||||
window = _odd(self.median_window)
|
"""Clamp to what the SDK can run, with unusable values meaning "off"."""
|
||||||
return FilterSettings(
|
alpha = _as_float(self.flow_ema_alpha, 1.0)
|
||||||
median_enabled=bool(self.median_enabled) and window >= 3,
|
if not 0.0 < alpha < 1.0:
|
||||||
median_window=window or 5,
|
alpha = 1.0
|
||||||
lowpass_enabled=bool(self.lowpass_enabled) and float(self.lowpass_hz) > 0,
|
window = max(1, _as_int(self.median_window, 1))
|
||||||
lowpass_hz=max(0.0, float(self.lowpass_hz)),
|
cutoff = max(0.0, _as_float(self.lowpass_hz, 0.0))
|
||||||
lowpass_order=max(1, min(6, int(self.lowpass_order))),
|
order = min(6, max(1, _as_int(self.lowpass_order, 2)))
|
||||||
sample_rate_hz=max(1.0, float(self.sample_rate_hz)),
|
return replace(
|
||||||
|
self,
|
||||||
|
flow_ema_alpha=alpha,
|
||||||
|
median_window=window,
|
||||||
|
lowpass_hz=cutoff,
|
||||||
|
lowpass_order=order,
|
||||||
|
sample_rate_hz=_sample_rate(self.sample_rate_hz),
|
||||||
show_unfiltered=bool(self.show_unfiltered),
|
show_unfiltered=bool(self.show_unfiltered),
|
||||||
)
|
)
|
||||||
|
|
||||||
def active(self) -> bool:
|
@property
|
||||||
"""Whether any stage actually filters (a disabled chain is a pass-through)."""
|
def flow_ema_on(self) -> bool:
|
||||||
settings = self.normalised()
|
return 0.0 < _as_float(self.flow_ema_alpha, 1.0) < 1.0
|
||||||
return bool(settings.median_enabled or settings.lowpass_enabled)
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
@property
|
||||||
|
def median_on(self) -> bool:
|
||||||
|
return _as_int(self.median_window, 1) > 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lowpass_on(self) -> bool:
|
||||||
|
return _as_float(self.lowpass_hz, 0.0) > 0.0
|
||||||
|
|
||||||
|
def active(self) -> bool:
|
||||||
|
"""Whether any stage filters at all (an off chain is a pass-through)."""
|
||||||
|
return bool(self.flow_ema_on or self.median_on or self.lowpass_on)
|
||||||
|
|
||||||
|
def as_state(self) -> dict[str, Any]:
|
||||||
|
"""The settings in the shape ``Sensor.filters_state()`` reports."""
|
||||||
|
settings = self.normalised()
|
||||||
|
rate = settings.sample_rate_hz
|
||||||
|
readout: dict[str, Any] = {
|
||||||
|
"force_median_window": settings.median_window,
|
||||||
|
"force_lowpass_cutoff_hz": settings.lowpass_hz,
|
||||||
|
"force_lowpass_order": settings.lowpass_order,
|
||||||
|
"force_lowpass_rate_auto": rate == "auto",
|
||||||
|
}
|
||||||
|
if rate != "auto":
|
||||||
|
readout["force_lowpass_sample_rate_hz"] = float(rate)
|
||||||
|
return {
|
||||||
|
"measurement": {"flow_ema_alpha": settings.flow_ema_alpha},
|
||||||
|
"readout": readout,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _values_by_state_key(self) -> dict[str, Any]:
|
||||||
|
"""This request, keyed the way the SDK's state names things."""
|
||||||
settings = self.normalised()
|
settings = self.normalised()
|
||||||
return {
|
return {
|
||||||
"median_enabled": settings.median_enabled,
|
"flow_ema_alpha": settings.flow_ema_alpha,
|
||||||
"median_window": settings.median_window,
|
"force_median_window": settings.median_window,
|
||||||
"lowpass_enabled": settings.lowpass_enabled,
|
"force_lowpass_cutoff_hz": settings.lowpass_hz,
|
||||||
"lowpass_hz": settings.lowpass_hz,
|
"force_lowpass_order": settings.lowpass_order,
|
||||||
"lowpass_order": settings.lowpass_order,
|
"force_lowpass_sample_rate_hz": settings.sample_rate_hz,
|
||||||
"sample_rate_hz": settings.sample_rate_hz,
|
}
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
"""A flat mapping for the preferences file (SDK keys, plus display)."""
|
||||||
|
settings = self.normalised()
|
||||||
|
return {
|
||||||
|
"flow_ema_alpha": settings.flow_ema_alpha,
|
||||||
|
"force_median_window": settings.median_window,
|
||||||
|
"force_lowpass_cutoff_hz": settings.lowpass_hz,
|
||||||
|
"force_lowpass_order": settings.lowpass_order,
|
||||||
|
"force_lowpass_sample_rate_hz": settings.sample_rate_hz,
|
||||||
"show_unfiltered": settings.show_unfiltered,
|
"show_unfiltered": settings.show_unfiltered,
|
||||||
|
"preset": settings.preset,
|
||||||
}
|
}
|
||||||
|
|
||||||
def describe(self) -> str:
|
def describe(self) -> str:
|
||||||
"""A short human line for the recording metadata."""
|
"""A short line for the record, upstream stage first."""
|
||||||
|
return describe_state(self.as_state())
|
||||||
|
|
||||||
|
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]:
|
||||||
|
"""Why the device would not do what these settings ask.
|
||||||
|
|
||||||
|
The SDK drops a low-pass whose cutoff is at or above Nyquist instead of
|
||||||
|
silently re-tuning it, so the dialog has to say so *before* the session
|
||||||
|
starts — otherwise the file would claim a filter the device is not
|
||||||
|
running.
|
||||||
|
"""
|
||||||
settings = self.normalised()
|
settings = self.normalised()
|
||||||
parts = []
|
issues: list[str] = []
|
||||||
if settings.median_enabled:
|
if settings.lowpass_on and sample_rate_hz:
|
||||||
parts.append(f"median({settings.median_window})")
|
nyquist = 0.5 * _as_float(sample_rate_hz, 0.0)
|
||||||
if settings.lowpass_enabled:
|
if nyquist > 0 and settings.lowpass_hz >= nyquist:
|
||||||
parts.append(
|
issues.append(
|
||||||
f"lowpass({settings.lowpass_hz:g}Hz,order{settings.lowpass_order})"
|
f"lowpass {settings.lowpass_hz:g} Hz is at or above Nyquist "
|
||||||
|
f"({nyquist:g} Hz): the device would not filter"
|
||||||
|
)
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- driving the SDK
|
||||||
|
def unsupported_by(self, sensor: object) -> tuple[str, ...]:
|
||||||
|
"""The SDK keys this sensor's ``configure()`` does not accept.
|
||||||
|
|
||||||
|
Reported rather than dropped: a stage the operator switched on but the
|
||||||
|
installed SDK cannot run is exactly the disagreement that ends up in a
|
||||||
|
recorded file. Older SDKs (before 0.5.2) have no ``flow_ema_alpha`` and
|
||||||
|
no median, and their flow EMA is not switchable at all.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parameters = inspect.signature(sensor.configure).parameters
|
||||||
|
except (AttributeError, TypeError, ValueError): # pragma: no cover - exotic fake
|
||||||
|
return tuple(CONFIGURE_PARAMETERS)
|
||||||
|
return tuple(
|
||||||
|
key
|
||||||
|
for key, parameter in CONFIGURE_PARAMETERS.items()
|
||||||
|
if parameter not in parameters
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure_kwargs(self, skip: Iterable[str] = ()) -> dict[str, Any]:
|
||||||
|
"""The ``Sensor.configure(...)`` call these settings amount to."""
|
||||||
|
skipped = set(skip)
|
||||||
|
return {
|
||||||
|
CONFIGURE_PARAMETERS[key]: value
|
||||||
|
for key, value in self._values_by_state_key().items()
|
||||||
|
if key not in skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_to(self, sensor: object) -> tuple[str, ...]:
|
||||||
|
"""Push these settings into the device; return what it could not take.
|
||||||
|
|
||||||
|
Called from the acquisition thread right after the sensor opens, so a
|
||||||
|
session is filtered the way the operator asked for from its first frame.
|
||||||
|
"""
|
||||||
|
unsupported = self.unsupported_by(sensor)
|
||||||
|
if unsupported:
|
||||||
|
logger.warning(
|
||||||
|
"The installed orisys SDK cannot apply %s; the effective filter "
|
||||||
|
"state is in Sensor.filters_state()",
|
||||||
|
", ".join(unsupported),
|
||||||
)
|
)
|
||||||
return " + ".join(parts) if parts else "none"
|
sensor.configure(**self.configure_kwargs(unsupported))
|
||||||
|
return unsupported
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ reading back
|
||||||
|
@classmethod
|
||||||
|
def from_filters_state(
|
||||||
|
cls, state: Mapping[str, Any], *, show_unfiltered: bool = False
|
||||||
|
) -> "FilterSettings":
|
||||||
|
"""The settings a device reports as in effect.
|
||||||
|
|
||||||
|
This is what the dialog displays and what a record stores: a config file
|
||||||
|
or a request can say one thing while the running device does another.
|
||||||
|
"""
|
||||||
|
measurement = dict(state.get("measurement") or {})
|
||||||
|
readout = dict(state.get("readout") or {})
|
||||||
|
rate: Any = "auto"
|
||||||
|
if not readout.get("force_lowpass_rate_auto", True):
|
||||||
|
rate = readout.get("force_lowpass_sample_rate_hz", "auto")
|
||||||
|
return cls(
|
||||||
|
flow_ema_alpha=_as_float(measurement.get("flow_ema_alpha"), 1.0),
|
||||||
|
median_window=_as_int(readout.get("force_median_window"), 1),
|
||||||
|
lowpass_hz=_as_float(readout.get("force_lowpass_cutoff_hz"), 0.0),
|
||||||
|
lowpass_order=_as_int(readout.get("force_lowpass_order"), 2),
|
||||||
|
sample_rate_hz=rate,
|
||||||
|
show_unfiltered=bool(show_unfiltered),
|
||||||
|
preset=state.get("preset"),
|
||||||
|
).normalised()
|
||||||
|
|
||||||
|
def metadata(self, sensor_state: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
|
||||||
|
"""The ``filters`` block for ``session.json`` / a recording header.
|
||||||
|
|
||||||
|
With a sensor state this records what the device was doing
|
||||||
|
(``source: sensor.filters_state``); without one it records what the app
|
||||||
|
asked for and says so, so the two are never mistaken for each other.
|
||||||
|
"""
|
||||||
|
if sensor_state:
|
||||||
|
measurement = dict(sensor_state.get("measurement") or {})
|
||||||
|
readout = dict(sensor_state.get("readout") or {})
|
||||||
|
preset = sensor_state.get("preset")
|
||||||
|
source = str(sensor_state.get("source") or "sensor.filters_state")
|
||||||
|
describe = describe_state(sensor_state)
|
||||||
|
else:
|
||||||
|
state = self.as_state()
|
||||||
|
measurement = state["measurement"]
|
||||||
|
readout = state["readout"]
|
||||||
|
preset = self.normalised().preset
|
||||||
|
source = "app.FilterSettings"
|
||||||
|
describe = describe_state(state)
|
||||||
|
block: dict[str, Any] = {
|
||||||
|
"measurement": measurement,
|
||||||
|
"readout": readout,
|
||||||
|
"describe": describe,
|
||||||
|
"source": source,
|
||||||
|
"show_unfiltered": bool(self.show_unfiltered),
|
||||||
|
}
|
||||||
|
if preset:
|
||||||
|
block["preset"] = str(preset)
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
def settings_from_mapping(data: Optional[dict]) -> FilterSettings:
|
def describe_state(state: Mapping[str, Any]) -> str:
|
||||||
|
"""``none`` / ``flowEMA(0.5) + median(3) + lowpass(10Hz,order2)``.
|
||||||
|
|
||||||
|
Written upstream-first, which is also the order the stages run and the order
|
||||||
|
the two groups are listed in: the measurement stage first.
|
||||||
|
"""
|
||||||
|
measurement = dict(state.get("measurement") or {})
|
||||||
|
readout = dict(state.get("readout") or {})
|
||||||
|
parts: list[str] = []
|
||||||
|
alpha = _as_float(measurement.get("flow_ema_alpha"), 1.0)
|
||||||
|
if 0.0 < alpha < 1.0:
|
||||||
|
parts.append(f"flowEMA({alpha:g})")
|
||||||
|
window = _as_int(readout.get("force_median_window"), 1)
|
||||||
|
if window > 1:
|
||||||
|
parts.append(f"median({window})")
|
||||||
|
cutoff = _as_float(readout.get("force_lowpass_cutoff_hz"), 0.0)
|
||||||
|
if cutoff > 0:
|
||||||
|
order = _as_int(readout.get("force_lowpass_order"), 2)
|
||||||
|
parts.append(f"lowpass({cutoff:g}Hz,order{order})")
|
||||||
|
return " + ".join(parts) if parts else "none"
|
||||||
|
|
||||||
|
|
||||||
|
def settings_from_mapping(data: Optional[Mapping[str, Any]]) -> FilterSettings:
|
||||||
"""Build settings from a preferences/metadata mapping, with defaults."""
|
"""Build settings from a preferences/metadata mapping, with defaults."""
|
||||||
source = dict(data or {})
|
source = dict(data or {})
|
||||||
defaults = FilterSettings()
|
base = FilterSettings()
|
||||||
return FilterSettings(
|
return FilterSettings(
|
||||||
median_enabled=source.get("median_enabled", defaults.median_enabled),
|
flow_ema_alpha=source.get("flow_ema_alpha", base.flow_ema_alpha),
|
||||||
median_window=source.get("median_window", defaults.median_window),
|
median_window=source.get("force_median_window", base.median_window),
|
||||||
lowpass_enabled=source.get("lowpass_enabled", defaults.lowpass_enabled),
|
lowpass_hz=source.get("force_lowpass_cutoff_hz", base.lowpass_hz),
|
||||||
lowpass_hz=source.get("lowpass_hz", defaults.lowpass_hz),
|
lowpass_order=source.get("force_lowpass_order", base.lowpass_order),
|
||||||
lowpass_order=source.get("lowpass_order", defaults.lowpass_order),
|
sample_rate_hz=source.get("force_lowpass_sample_rate_hz", base.sample_rate_hz),
|
||||||
sample_rate_hz=source.get("sample_rate_hz", defaults.sample_rate_hz),
|
show_unfiltered=source.get("show_unfiltered", base.show_unfiltered),
|
||||||
show_unfiltered=source.get("show_unfiltered", defaults.show_unfiltered),
|
preset=source.get("preset", base.preset),
|
||||||
).normalised()
|
).normalised()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reference implementation: kept so the tests can prove this app understands the
|
||||||
|
# SDK's chain exactly. Nothing in the app calls it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
class ForceFilterChain:
|
class ForceFilterChain:
|
||||||
"""The median + low-pass chain, one instance per acquisition session."""
|
"""The median + low-pass chain the SDK runs, reimplemented for comparison.
|
||||||
|
|
||||||
|
Both stages are causal and per-sample, because acquisition cannot look
|
||||||
|
ahead: the median removes single-sample spikes without smearing a step, and
|
||||||
|
the low-pass is a Butterworth IIR with persistent state (``sosfilt`` with
|
||||||
|
``zi``). Order is median then low-pass, so a spike cannot ring the IIR.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, settings: Optional[FilterSettings] = None) -> None:
|
def __init__(self, settings: Optional[FilterSettings] = None) -> None:
|
||||||
self._settings = (settings or FilterSettings()).normalised()
|
self._settings = (settings or FilterSettings()).normalised()
|
||||||
@@ -128,11 +379,12 @@ class ForceFilterChain:
|
|||||||
self._sos = None
|
self._sos = None
|
||||||
self._zi = None
|
self._zi = None
|
||||||
settings = self._settings
|
settings = self._settings
|
||||||
if not settings.lowpass_enabled:
|
if not settings.lowpass_on:
|
||||||
return
|
return
|
||||||
from scipy.signal import butter
|
from scipy.signal import butter
|
||||||
|
|
||||||
nyquist = 0.5 * settings.sample_rate_hz
|
rate = _as_float(settings.sample_rate_hz, 30.0)
|
||||||
|
nyquist = 0.5 * rate
|
||||||
cutoff = min(float(settings.lowpass_hz), nyquist * 0.95)
|
cutoff = min(float(settings.lowpass_hz), nyquist * 0.95)
|
||||||
if cutoff <= 0:
|
if cutoff <= 0:
|
||||||
return
|
return
|
||||||
@@ -182,7 +434,7 @@ class ForceFilterChain:
|
|||||||
return sample.copy()
|
return sample.copy()
|
||||||
settings = self._settings
|
settings = self._settings
|
||||||
out = sample.copy()
|
out = sample.copy()
|
||||||
if settings.median_enabled:
|
if settings.median_on:
|
||||||
window = int(settings.median_window)
|
window = int(settings.median_window)
|
||||||
for index in range(CHANNELS):
|
for index in range(CHANNELS):
|
||||||
series = self._median[index]
|
series = self._median[index]
|
||||||
|
|||||||
@@ -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 chain itself lives in the SDK, so these tests are about the three things
|
||||||
the numbers in ``forces.csv`` are the same filtered values; the unfiltered
|
this app is responsible for: handing the operator's choice to
|
||||||
readings are kept beside them. These tests pin the parts that are easy to get
|
``Sensor.configure`` in the sensor's own vocabulary, reading the *effective*
|
||||||
wrong: odd windows, a disabled chain being a pass-through, per-channel state,
|
state back instead of trusting the request, and writing a record that keeps the
|
||||||
and a step not ringing at session start.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
from orisys_app_shared.force.filters import (
|
from orisys_app_shared.force.filters import (
|
||||||
CHANNELS,
|
CHANNELS,
|
||||||
FilterSettings,
|
FilterSettings,
|
||||||
ForceFilterChain,
|
ForceFilterChain,
|
||||||
|
describe_state,
|
||||||
settings_from_mapping,
|
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]:
|
def _zeros(value: float = 0.0) -> list[float]:
|
||||||
return [value] * CHANNELS
|
return [value] * CHANNELS
|
||||||
|
|
||||||
|
|
||||||
def test_a_disabled_chain_is_a_pass_through():
|
# ------------------------------------------------------------- value semantics
|
||||||
chain = ForceFilterChain(
|
def test_every_stage_is_switched_by_its_own_value():
|
||||||
FilterSettings(median_enabled=False, lowpass_enabled=False)
|
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
|
assert not chain.active
|
||||||
sample = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
sample = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
|
||||||
assert chain.process(sample).tolist() == sample
|
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():
|
def test_the_median_removes_a_single_sample_spike():
|
||||||
"""One bad readout must not reach the chart, the store or the CSV."""
|
"""One bad readout must not reach the chart, the store or the CSV."""
|
||||||
chain = ForceFilterChain(
|
chain = ForceFilterChain(FilterSettings(median_window=3))
|
||||||
FilterSettings(median_enabled=True, median_window=3, lowpass_enabled=False)
|
|
||||||
)
|
|
||||||
outputs = [
|
outputs = [
|
||||||
float(chain.process(_zeros(9.0 if index == 2 else 0.0))[0])
|
float(chain.process(_zeros(9.0 if index == 2 else 0.0))[0])
|
||||||
for index in range(5)
|
for index in range(5)
|
||||||
@@ -48,18 +268,21 @@ def test_the_median_removes_a_single_sample_spike():
|
|||||||
assert sustained == 9.0, sustained
|
assert sustained == 9.0, sustained
|
||||||
|
|
||||||
|
|
||||||
def test_the_median_window_is_forced_odd_and_bounded():
|
def test_the_median_window_is_bounded():
|
||||||
assert FilterSettings(median_window=4).normalised().median_window == 5
|
# "Off" is a window of one; anything that cannot be a window at all becomes one.
|
||||||
assert FilterSettings(median_window=1).normalised().median_window == 5
|
assert FilterSettings(median_window=1).normalised().median_window == 1
|
||||||
# A window below three samples cannot remove anything: the stage turns off.
|
assert FilterSettings(median_window=0).normalised().median_window == 1
|
||||||
assert FilterSettings(median_enabled=True, median_window=2).normalised().median_enabled is False
|
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():
|
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."""
|
"""A session must not open with a transient invented by the filter."""
|
||||||
chain = ForceFilterChain(
|
chain = ForceFilterChain(FilterSettings(lowpass_hz=5.0, sample_rate_hz=30.0))
|
||||||
FilterSettings(lowpass_enabled=True, lowpass_hz=5.0, sample_rate_hz=30.0)
|
|
||||||
)
|
|
||||||
first = float(chain.process(_zeros(3.0))[0])
|
first = float(chain.process(_zeros(3.0))[0])
|
||||||
assert abs(first - 3.0) < 1e-9, f"the filter invented a transient: {first}"
|
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
|
# 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():
|
def test_each_channel_keeps_its_own_filter_state():
|
||||||
chain = ForceFilterChain(
|
chain = ForceFilterChain(FilterSettings(median_window=3))
|
||||||
FilterSettings(median_enabled=True, median_window=3, lowpass_enabled=False)
|
for _ in range(3):
|
||||||
)
|
out = 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])
|
|
||||||
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])
|
|
||||||
assert out.tolist() == [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():
|
def test_reset_clears_the_memory_between_sessions():
|
||||||
chain = ForceFilterChain(
|
chain = ForceFilterChain(FilterSettings(lowpass_hz=5.0, sample_rate_hz=30.0))
|
||||||
FilterSettings(lowpass_enabled=True, lowpass_hz=5.0, sample_rate_hz=30.0)
|
|
||||||
)
|
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
chain.process(_zeros(5.0))
|
chain.process(_zeros(5.0))
|
||||||
chain.reset()
|
chain.reset()
|
||||||
@@ -95,20 +313,19 @@ def test_reset_clears_the_memory_between_sessions():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_chain_reproduces_the_sdk_filter_exactly():
|
def test_the_reference_chain_reproduces_the_sdk_filter_exactly():
|
||||||
"""Replacing the SDK's low-pass with ours must not move a single number.
|
"""The SDK chain is not a black box: we can reproduce it number for number.
|
||||||
|
|
||||||
``Sensor._apply_force_filter`` filters ``_force_raw`` with
|
``Sensor._apply_force_filter`` filters ``_force_raw`` with
|
||||||
``orisys.signal_processing.LowpassFilter`` (Butterworth, ``sosfilt`` with
|
``orisys.signal_processing.LowpassFilter`` (Butterworth, ``sosfilt`` with
|
||||||
persistent state, steady-state reset) and then applies the device
|
persistent state, steady-state reset) and *then* applies the device
|
||||||
calibration. Our chain uses the same recipe, so at the same settings the
|
calibration. Running the same recipe here is what turns "the app does not
|
||||||
recorded values stay identical — this test is what makes that a guarantee
|
filter, the SDK does" from a claim into something a test can check.
|
||||||
rather than a hope.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from orisys.signal_processing import LowpassFilter
|
from orisys.signal_processing import LowpassFilter
|
||||||
except Exception: # pragma: no cover - the SDK is not installed here
|
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)
|
rng = np.random.default_rng(3)
|
||||||
signal = np.cumsum(rng.normal(0.0, 1.0, (400, 3)), axis=0) * 10.0 + 500.0
|
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.reset(signal[0])
|
||||||
sdk_out = np.array([sdk.process(row) for row in signal])
|
sdk_out = np.array([sdk.process(row) for row in signal])
|
||||||
|
|
||||||
chain = ForceFilterChain(
|
chain = ForceFilterChain(FilterSettings(lowpass_hz=10.0, sample_rate_hz=30.0))
|
||||||
FilterSettings(lowpass_enabled=True, lowpass_hz=10.0, sample_rate_hz=30.0)
|
|
||||||
)
|
|
||||||
# The chain carries six channels (raw + newtons); the first three are the
|
# 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.
|
# raw counts the SDK filters, so give all six the same steady state.
|
||||||
chain.reset(list(signal[0]) * 2)
|
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), (
|
assert np.array_equal(sdk_out, chain_out), (
|
||||||
f"the chain differs from the SDK filter by {np.abs(sdk_out - chain_out).max()}"
|
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