From 7f8e5d09757a88e084edb2d30c6b7f2d501d8c65 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 10:44:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9B=E4=B8=AA=E6=BB=A4=E6=B3=A2=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=E8=90=BD=E6=88=90=E6=9C=BA=E5=99=A8=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E4=B8=8A=E9=98=B2=E6=BC=82=E7=A7=BB=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20-=20What:=20=E6=96=B0=E5=A2=9E=20examples/filters/filters=5F?= =?UTF-8?q?{off,low=5Flatency,balanced,low=5Fnoise}.json=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=AF=8F=E4=B8=AA=E9=83=BD=E6=98=AF=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=8F=AF=E6=95=B4=E5=9D=97=E6=9B=BF=E6=8D=A2=E7=9A=84?= =?UTF-8?q?=20filters=20=E6=AE=B5=EF=BC=88=E4=BA=94=E9=94=AE=E9=BD=90?= =?UTF-8?q?=E5=85=A8=EF=BC=9Aflow=5Fema=5Falpha=20/=20force=5Fmedian=5Fwin?= =?UTF-8?q?dow=20/=20force=5Flowpass=5Fcutoff=5Fhz=20/=20force=5Flowpass?= =?UTF-8?q?=5Forder=20/=20force=5Flowpass=5Fsample=5Frate=5Fhz=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=95=B0=E5=80=BC=E5=8D=B3=20FILTER=5FPRESETS=20?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E5=90=8E=E7=9A=84=E7=BB=93=E6=9E=9C=EF=BC=9B?= =?UTF-8?q?examples/filters/README.md=20=E8=AF=B4=E6=98=8E=E8=BF=99?= =?UTF-8?q?=E5=9B=9B=E4=BB=BD=E5=89=AF=E6=9C=AC=E7=9A=84=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E4=B8=8E=E7=94=A8=E9=80=94=EF=BC=9B=E6=96=B0=E5=A2=9E=20tests/?= =?UTF-8?q?test=5Ffilter=5Fpresets.py=EF=BC=8812=20=E9=A1=B9=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E9=A2=84=E8=AE=BE=E5=90=8D=E6=81=B0=E4=B8=BA=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=E3=80=81=E6=AF=8F=E4=B8=AA=E7=A4=BA=E4=BE=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20=3D=3D=20=E9=BB=98=E8=AE=A4=E5=9D=97=E5=8F=A0?= =?UTF-8?q?=E5=8A=A0=E8=AF=A5=E9=A2=84=E8=AE=BE=EF=BC=88=E6=94=B9=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=E6=88=96=E6=94=B9=E6=96=87=E4=BB=B6=E9=83=BD=E4=BC=9A?= =?UTF-8?q?=E7=BA=A2=EF=BC=89=E3=80=81=E6=AF=8F=E4=BB=BD=E9=83=BD=E6=98=AF?= =?UTF-8?q?=E5=90=AB=E4=B8=89=E4=B8=AA=E5=BC=80=E5=85=B3=E9=94=AE=E7=9A=84?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20filters=20=E6=AE=B5=E3=80=81=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=E5=87=BA=E5=8E=82=20bundle=EF=BC=88box/mini/finger?= =?UTF-8?q?=EF=BC=89=E7=9A=84=20filters=20=E6=AE=B5=E4=BB=8D=E4=B8=BA?= =?UTF-8?q?=E5=85=A8=E5=85=B3=E3=80=81=E4=B8=94=E7=94=A8=20external-frame?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F=E6=9E=84=E9=80=A0=E7=9C=9F=E5=AE=9E=20Sen?= =?UTF-8?q?sor=20=E6=A0=B8=E5=AF=B9=20filters=5Fstate()=20=E6=8A=A5?= =?UTF-8?q?=E7=9A=84=E4=B9=9F=E6=98=AF=E5=85=A8=E5=85=B3=EF=BC=88=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E8=AE=BE=E5=A4=87=E5=BF=85=E9=A1=BB=E4=B8=80?= =?UTF-8?q?=E8=87=B4=EF=BC=89=E3=80=82=20-=20Why:=20=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=E9=87=8C=E5=86=99?= =?UTF-8?q?=E7=9A=84=E6=98=AF"=E5=9B=9B=E4=B8=AA=20sensor=5F.json?= =?UTF-8?q?=20=E6=95=B4=E6=9C=BA=E9=85=8D=E7=BD=AE"=EF=BC=8C=E4=BD=86?= =?UTF-8?q?=E6=95=B4=E6=9C=BA=E9=85=8D=E7=BD=AE=E8=A6=81=E6=8C=89=E4=BC=A0?= =?UTF-8?q?=E6=84=9F=E5=99=A8=E5=90=84=E5=A4=8D=E5=88=B6=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=E3=80=81=E4=B8=94=E5=BF=85=E7=84=B6=E9=9A=8F=20bundle=20?= =?UTF-8?q?=E6=BC=82=E7=A7=BB=EF=BC=9BREADME=20=E9=87=8C=E5=86=85=E5=B5=8C?= =?UTF-8?q?=E7=9A=84=20JSON=20=E5=8F=88=E6=97=A0=E6=B3=95=E8=A2=AB?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=EF=BC=8C=E7=AD=89=E4=BA=8E?= =?UTF-8?q?=E6=8A=8A"=E5=BB=BA=E8=AE=AE=E7=94=A8=E5=93=AA=E6=A1=A3?= =?UTF-8?q?=E6=BB=A4=E6=B3=A2"=E8=BF=99=E5=8F=A5=E8=AF=9D=E7=95=99?= =?UTF-8?q?=E5=9C=A8=E4=BA=86=E4=B8=80=E5=A4=84=E4=BC=9A=E8=BF=87=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E6=95=A3=E6=96=87=E9=87=8C=E3=80=82=E6=94=B9=E6=88=90?= =?UTF-8?q?"filters=20=E7=89=87=E6=AE=B5=20+=20=E6=96=AD=E8=A8=80=E4=B8=8E?= =?UTF-8?q?=20FILTER=5FPRESETS=20=E9=80=90=E5=80=BC=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95"=EF=BC=8C=E6=97=A2=E4=B8=8D?= =?UTF-8?q?=E8=B0=8E=E7=A7=B0=E6=98=AF=E5=AE=8C=E6=95=B4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=8C=E4=B9=9F=E4=B8=8D=E4=BC=9A=E9=9D=99=E9=BB=98=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E3=80=82=20-=20Verification:=20pytest=20tests/test=5F?= =?UTF-8?q?filter=5Fpresets.py=20=E2=86=92=2012=20passed=EF=BC=9BSDK=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=20335=20passed=20/=2015=20skipped=EF=BC=88?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E5=A4=B1=E8=B4=A5=E4=BB=8D=E6=98=AF=E9=9C=80?= =?UTF-8?q?=E8=A6=81=201200=20=E5=B8=A7=E8=80=8C=E6=A0=B7=E4=BE=8B?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=20577=20=E5=B8=A7=E7=9A=84=20longrun=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=89=E3=80=82=20-=20Not=20in=20this=20ch?= =?UTF-8?q?ange:=20=E6=9C=AA=E7=94=9F=E6=88=90=E6=8C=89=E4=BC=A0=E6=84=9F?= =?UTF-8?q?=E5=99=A8=E5=A4=8D=E5=88=B6=E7=9A=84=E6=95=B4=E6=9C=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=9Bdocs/filters.md=20=E7=9A=84=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E8=A1=A8=E6=9C=AA=E7=BA=B3=E5=85=A5=E6=B5=8B=E8=AF=95=EF=BC=88?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E8=BF=99=E5=9B=9B=E4=B8=AA=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=B8=B8=E9=87=8F=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=89=EF=BC=9Bapp=20=E4=BE=A7=E6=9C=AA=E5=8A=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/filters/README.md | 14 ++++ examples/filters/filters_balanced.json | 7 ++ examples/filters/filters_low_latency.json | 7 ++ examples/filters/filters_low_noise.json | 7 ++ examples/filters/filters_off.json | 7 ++ tests/test_filter_presets.py | 90 +++++++++++++++++++++++ 6 files changed, 132 insertions(+) create mode 100644 examples/filters/filters_balanced.json create mode 100644 examples/filters/filters_low_latency.json create mode 100644 examples/filters/filters_low_noise.json create mode 100644 examples/filters/filters_off.json create mode 100644 tests/test_filter_presets.py diff --git a/examples/filters/README.md b/examples/filters/README.md index 4d49244..64f2c96 100644 --- a/examples/filters/README.md +++ b/examples/filters/README.md @@ -7,6 +7,10 @@ 背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见 `_perf/` 与 `orisys-sdk-perf-plan.md`。 +下面每一段都有一个同目录的机器可读副本 `filters_<预设名>.json`(四个文件), +`tests/test_filter_presets.py` 会核对它们与 `FILTER_PRESETS` 完全一致 —— 所以改预设时 +这些文件会一起红,不会悄悄过时。 + --- ## `off` —— 原始读数(默认) @@ -86,3 +90,13 @@ print(s.filters_state()["preset"], s.filters_state()["measurement"], s.filters_s `flow_ema_applied` / `force_median_applied` / `force_lowpass_applied` 三个布尔就是 "这一级到底有没有在滤"的答案;`flow_ema_alpha` 属于 **measurement**(改变测量, 不同值的两次录制不可比),中值与低通属于 **readout**(只影响读数呈现)。 + +导出标定/写记录时把同一个状态交给指纹函数,文件里就会留下可核对的设置: + +```python +from orisys.calibration import pipeline_from_sensor_json +pipeline = pipeline_from_sensor_json("sensors/mini/sensor.json", filters_state=s.filters_state()) +print(pipeline["filters"]) # measurement / readout / preset / source +``` + +详见 `docs/filters.md` 第 7 节。 diff --git a/examples/filters/filters_balanced.json b/examples/filters/filters_balanced.json new file mode 100644 index 0000000..26e834a --- /dev/null +++ b/examples/filters/filters_balanced.json @@ -0,0 +1,7 @@ +{ + "flow_ema_alpha": 0.5, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} diff --git a/examples/filters/filters_low_latency.json b/examples/filters/filters_low_latency.json new file mode 100644 index 0000000..b29795f --- /dev/null +++ b/examples/filters/filters_low_latency.json @@ -0,0 +1,7 @@ +{ + "flow_ema_alpha": 1.0, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} diff --git a/examples/filters/filters_low_noise.json b/examples/filters/filters_low_noise.json new file mode 100644 index 0000000..ab4d842 --- /dev/null +++ b/examples/filters/filters_low_noise.json @@ -0,0 +1,7 @@ +{ + "flow_ema_alpha": 0.3, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 10.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} diff --git a/examples/filters/filters_off.json b/examples/filters/filters_off.json new file mode 100644 index 0000000..08fcdda --- /dev/null +++ b/examples/filters/filters_off.json @@ -0,0 +1,7 @@ +{ + "flow_ema_alpha": 1.0, + "force_median_window": 1, + "force_lowpass_cutoff_hz": 0.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} diff --git a/tests/test_filter_presets.py b/tests/test_filter_presets.py new file mode 100644 index 0000000..3120c44 --- /dev/null +++ b/tests/test_filter_presets.py @@ -0,0 +1,90 @@ +"""The shipped filter presets and the example configs that document them. + +``json.load`` cannot carry comments, so the answer to "which filter should I +use?" lives in three places that must not drift apart: ``FILTER_PRESETS`` in the +sensor, the table in ``docs/filters.md``, and the copy-paste files in +``examples/filters/``. The presets are the source of truth; these tests fail +when a copy stops matching them. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from orisys import Sensor +from orisys.calibration import filters_fingerprint +from orisys.sensor import FILTER_PRESETS, available_filter_presets +from orisys import configs as orisys_configs + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = REPO_ROOT / "examples" / "filters" +BUNDLES = ("box", "mini", "finger") + +#: What every shipped bundle says: filtering is opt-in, so a fresh install +#: records the raw reading and the operator turns a stage on deliberately. +ALL_OFF = { + "flow_ema_alpha": 1.0, + "force_median_window": 1, + "force_lowpass_cutoff_hz": 0.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto", +} + + +def test_the_preset_names_are_the_documented_ones(): + assert sorted(available_filter_presets()) == [ + "balanced", + "low_latency", + "low_noise", + "off", + ] + # ``available_filter_presets`` hands out copies: a UI cannot edit the truth. + snapshot = available_filter_presets() + snapshot["off"]["flow_ema_alpha"] = 0.0 + assert FILTER_PRESETS["off"]["flow_ema_alpha"] == 1.0 + + +@pytest.mark.parametrize("preset", sorted(FILTER_PRESETS)) +def test_every_example_file_still_matches_its_preset(preset: str): + path = EXAMPLES / f"filters_{preset}.json" + assert path.is_file(), f"missing example for preset {preset!r}: {path}" + block = json.loads(path.read_text(encoding="utf-8")) + # A whole ``filters`` block: the preset's own keys on top of the defaults + # every config needs, and nothing else. + expected = dict(ALL_OFF) + expected.update(FILTER_PRESETS[preset]) + assert block == expected, f"{path.name} drifted from preset {preset!r}" + + +def test_every_example_file_is_a_real_filters_block(): + """A file that cannot be dropped into a config is documentation that lies.""" + for preset in FILTER_PRESETS: + block = json.loads((EXAMPLES / f"filters_{preset}.json").read_text(encoding="utf-8")) + assert isinstance(block, dict) and block, preset + for key in ("flow_ema_alpha", "force_median_window", "force_lowpass_cutoff_hz"): + assert key in block, f"{preset}: {key} missing" + + +@pytest.mark.parametrize("bundle", BUNDLES) +def test_shipped_bundles_ship_with_filtering_off(bundle: str): + """Nothing filters unless it was asked for.""" + path = orisys_configs.resolve_sensor_config_path(bundle) + assert path is not None and path.is_file(), f"missing bundle: {bundle}" + cfg = json.loads(path.read_text(encoding="utf-8")) + assert cfg.get("filters") == ALL_OFF, f"{bundle} no longer ships with filtering off" + + +@pytest.mark.parametrize("bundle", BUNDLES) +def test_shipped_bundles_report_all_off(bundle: str): + """The file and the running device have to agree about the shipped default.""" + sensor = Sensor("external", config_name=bundle, verbose=False, backend="cpu") + try: + fp = filters_fingerprint(sensor.filters_state()) + finally: + sensor.disconnect() + assert fp["measurement"] == {"flow_ema_alpha": 1.0} + assert fp["readout"]["force_median_window"] == 1 + assert fp["readout"]["force_lowpass_cutoff_hz"] == 0.0