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