From 65dafdc84d955742d2ae17aee53f4fa574d10c09 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 10:20:33 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E4=B8=A4=E6=9D=A1=E6=BB=A4=E6=B3=A2?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E6=B5=8B=E8=AF=95=EF=BC=9A=E4=B8=8E=20SDK=20?= =?UTF-8?q?=E6=BB=A4=E6=B3=A2=E9=80=90=E7=82=B9=E7=AD=89=E4=BB=B7=E3=80=81?= =?UTF-8?q?prefs=20=E5=BE=80=E8=BF=94=20-=20What:=20test=5Fforce=5Ffilters?= =?UTF-8?q?.py=20=E6=96=B0=E5=A2=9E=20test=5Fthe=5Fchain=5Freproduces=5Fth?= =?UTF-8?q?e=5Fsdk=5Ffilter=5Fexactly=EF=BC=88=E5=90=8C=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=20400=20=E7=82=B9=E8=BE=93=E5=85=A5=E5=88=86=E5=88=AB=E5=96=82?= =?UTF-8?q?=20orisys.signal=5Fprocessing.LowpassFilter=20=E4=B8=8E?= =?UTF-8?q?=E6=88=91=E4=BB=AC=206=20=E9=80=9A=E9=81=93=E9=93=BE=E7=9A=84?= =?UTF-8?q?=E5=89=8D=E4=B8=89=E9=80=9A=E9=81=93=EF=BC=8C=E6=96=AD=E8=A8=80?= =?UTF-8?q?=20np.array=5Fequal=EF=BC=8C=E5=AE=9E=E6=B5=8B=20max|=E5=B7=AE|?= =?UTF-8?q?=3D0.0=EF=BC=8CSDK=20=E4=B8=8D=E5=9C=A8=E6=97=B6=E8=A3=85?= =?UTF-8?q?=E4=BD=9C=E9=80=9A=E8=BF=87=EF=BC=89=E4=B8=8E=20test=5Fsettings?= =?UTF-8?q?=5Fround=5Ftrip=5Fthrough=5Fa=5Fpreferences=5Fmapping=EF=BC=88m?= =?UTF-8?q?edian=5Fwindow=207=E3=80=81lowpass=2012.5=20Hz=E3=80=81show=5Fu?= =?UTF-8?q?nfiltered=20=E5=BE=80=E8=BF=94=EF=BC=8Cdescribe()=20=E7=AD=89?= =?UTF-8?q?=E4=BA=8E=20"median(7)=20+=20lowpass(12.5Hz,order2)"=EF=BC=8C?= =?UTF-8?q?=E7=A9=BA=E6=98=A0=E5=B0=84=E5=8F=96=E9=BB=98=E8=AE=A4=E5=BC=B1?= =?UTF-8?q?=E9=80=9A=EF=BC=89=E3=80=82=20-=20Why:=20=E5=86=B3=E5=AE=9A"?= =?UTF-8?q?=E5=8F=AA=E7=95=99=E4=B8=80=E6=9D=A1=E4=BD=8E=E9=80=9A=E3=80=81?= =?UTF-8?q?=E6=94=BE=20SDK=20=E9=87=8C=E3=80=81app=20=E9=80=9A=E8=BF=87=20?= =?UTF-8?q?configure=20=E9=A9=B1=E5=8A=A8"=E4=B9=8B=E5=90=8E=EF=BC=8C?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E8=AF=81=E6=98=8E"=E6=8D=A2=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E4=B8=8D=E7=A7=BB=E5=8A=A8=E4=BB=BB=E4=BD=95=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=95=B0"=E6=98=AF=E4=BF=9D=E8=AF=81=E8=80=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E5=B8=8C=E6=9C=9B=EF=BC=9B=E8=BF=99=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E6=8A=8A=E8=AF=A5=E4=BF=9D=E8=AF=81=E9=92=89=E8=BF=9B?= =?UTF-8?q?=E5=A5=97=E4=BB=B6=EF=BC=8C=E5=B9=B6=E8=AE=A9=20FilterSettings?= =?UTF-8?q?=20=E6=88=90=E4=B8=BA=20prefs/session.json=20=E7=9A=84=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E7=9C=9F=E5=80=BC=E5=AF=B9=E8=B1=A1=E3=80=82=20-=20Ve?= =?UTF-8?q?rification:=20drive=5Fshared.py=20142=20=E9=A1=B9=200=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=EF=BC=88=E6=96=B0=E5=A2=9E=201=20=E9=A1=B9?= =?UTF-8?q?=EF=BC=8Cmodify=201=20=E9=A1=B9=EF=BC=89=E3=80=81ui=5Fcheck.py?= =?UTF-8?q?=20554=20=E9=A1=B9=E3=80=81test=5Fui=5Fsmoke.py=2071=20?= =?UTF-8?q?=E9=A1=B9=E3=80=81locale=5Fstudio.py=20262=20=E9=94=AE=E9=BD=90?= =?UTF-8?q?=E5=85=A8=EF=BC=8C=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87=E3=80=82?= =?UTF-8?q?=20-=20Not=20in=20this=20change:=20=E9=87=87=E9=9B=86=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=9C=AA=E5=8A=A8=EF=BC=88SDK=20=E4=BD=8E=E9=80=9A?= =?UTF-8?q?=E4=BB=8D=E7=94=B1=20app=20=E7=A1=AC=E7=BC=96=E7=A0=81=2010=20H?= =?UTF-8?q?z=20=E6=89=93=E5=BC=80=EF=BC=89=EF=BC=8CCSV=20=E4=BB=8D=2011=20?= =?UTF-8?q?=E5=88=97=EF=BC=9B=E4=B8=8B=E4=B8=80=E6=AD=A5=E8=A7=81=20.repro?= =?UTF-8?q?/filter=5Fplan.md=EF=BC=9ASDK=20=E4=BE=A7=20A=EF=BC=88=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=87=AA=E6=8F=8F=E8=BF=B0=E3=80=81=E5=8E=BB=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=BC=8F=E6=80=BB=E5=BC=80=E5=85=B3=EF=BC=89=E2=86=92?= =?UTF-8?q?=20SDK=20=E4=BE=A7=20B=EF=BC=88filters=5Fstate()=20+=20force=5F?= =?UTF-8?q?triplets()=20=E6=9C=AA=E6=BB=A4=E6=B3=A2=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E3=80=81=E6=8C=87=E7=BA=B9=E6=8C=89=20measurement/readout=20?= =?UTF-8?q?=E5=88=86=E7=BB=84=EF=BC=89=E2=86=92=20=E5=8D=87=E7=BA=A7=20SDK?= =?UTF-8?q?=20=E8=BF=9B=20venv=20=E2=86=92=20app=20=E4=BE=A7=20wrapper+?= =?UTF-8?q?=E9=BD=BF=E8=BD=AE=20=E2=86=92=20=E4=B8=A4=E4=B8=AA=20buffer=20?= =?UTF-8?q?=E4=B8=8E=20CSV=2017=20=E5=88=97=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/tests/test_force_filters.py | 47 +++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/shared/tests/test_force_filters.py b/shared/tests/test_force_filters.py index 2538225..98ff3c7 100644 --- a/shared/tests/test_force_filters.py +++ b/shared/tests/test_force_filters.py @@ -95,7 +95,41 @@ def test_reset_clears_the_memory_between_sessions(): ) -def test_settings_round_trip_through_a_preferences_mapping(): +def test_the_chain_reproduces_the_sdk_filter_exactly(): + """Replacing the SDK's low-pass with ours must not move a single number. + + ``Sensor._apply_force_filter`` filters ``_force_raw`` with + ``orisys.signal_processing.LowpassFilter`` (Butterworth, ``sosfilt`` with + persistent state, steady-state reset) and then applies the device + calibration. Our chain uses the same recipe, so at the same settings the + recorded values stay identical — this test is what makes that a guarantee + rather than a hope. + """ + try: + from orisys.signal_processing import LowpassFilter + except Exception: # pragma: no cover - the SDK is not installed here + return + + rng = np.random.default_rng(3) + signal = np.cumsum(rng.normal(0.0, 1.0, (400, 3)), axis=0) * 10.0 + 500.0 + + sdk = LowpassFilter(cutoff_hz=10.0, sample_rate_hz=30.0, order=2, n_channels=3) + sdk.configure(cutoff_hz=10.0, sample_rate_hz=30.0, order=2) + sdk.reset(signal[0]) + sdk_out = np.array([sdk.process(row) for row in signal]) + + chain = ForceFilterChain( + 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 + # raw counts the SDK filters, so give all six the same steady state. + chain.reset(list(signal[0]) * 2) + chain_out = np.array([chain.process(list(row) * 2)[:3] for row in signal]) + + assert np.array_equal(sdk_out, chain_out), ( + f"the chain differs from the SDK filter by {np.abs(sdk_out - chain_out).max()}" + ) + settings = settings_from_mapping( { "median_enabled": True, @@ -111,3 +145,14 @@ def test_settings_round_trip_through_a_preferences_mapping(): # 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