From 92e0b95ca226d82ae8a8d477edab89567cdf9dfc Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 11:08:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E5=9B=BE=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?=E5=8A=A0=E9=BD=BF=E8=BD=AE=EF=BC=8C=E7=BB=98=E5=9B=BE=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=AF=B9=E8=AF=9D=E6=A1=86=E9=87=8C=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=B0=83=E6=BB=A4=E6=B3=A2=20-=20What:=20ui/?= =?UTF-8?q?icons.py=20=E6=96=B0=E5=A2=9E=20settings=EF=BC=88=E9=BD=BF?= =?UTF-8?q?=E8=BD=AE=EF=BC=89=E5=9B=BE=E6=A0=87=EF=BC=88=E5=85=AB=E4=B8=AA?= =?UTF-8?q?=E9=BD=BF=20+=20=E5=9C=86=E7=8E=AF=EF=BC=8C=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E5=8F=96=E8=87=AA=E4=B8=BB=E9=A2=98=20token=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=E5=8F=A6=E5=A4=96=E4=B8=89=E4=B8=AA=E5=9B=BE=E6=A0=87=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E5=A5=97=E7=94=BB=E6=B3=95=EF=BC=89=EF=BC=9Bforce=5Fp?= =?UTF-8?q?lots.py=20=E8=A1=A8=E5=A4=B4=E6=96=B0=E5=A2=9E=20btn=5Fsettings?= =?UTF-8?q?=20=E4=B8=8E=20plot=5Fsettings=5Frequested=20=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?=EF=BC=88=E6=94=BE=E5=9C=A8=E7=BC=A9=E6=94=BE=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=E3=80=81=E5=8D=95=E4=BD=8D=E8=8A=AF=E7=89=87?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=EF=BC=89=EF=BC=8C=E6=8D=A2=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E6=97=B6=E4=B8=80=E5=B9=B6=E9=87=8D=E7=BB=98=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=EF=BC=8Cretranslate=20=E9=87=8C=E8=A1=A5=20tooltip/=E5=8F=AF?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=90=8D=EF=BC=9Bplot=5Fsettings=5Fdialog.py?= =?UTF-8?q?=20=E5=A2=9E=E5=8A=A0"=E6=BB=A4=E6=B3=A2"=E5=88=86=E5=8C=BA?= =?UTF-8?q?=EF=BC=9A=E9=A2=84=E8=AE=BE=E4=B8=8B=E6=8B=89=EF=BC=88off/low?= =?UTF-8?q?=5Flatency/balanced/low=5Fnoise=20+=20=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E6=98=BE=E7=A4=BA=E7=9A=84=E5=B0=B1=E6=98=AF?= =?UTF-8?q?=E5=86=99=E8=BF=9B=20sensor.json=20=E7=9A=84=E9=82=A3=E4=B8=AA?= =?UTF-8?q?=E5=90=8D=E5=AD=97=EF=BC=89=E3=80=81=E5=85=89=E6=B5=81=20EMA=20?= =?UTF-8?q?=CE=B1=EF=BC=880.05=E2=80=931.00=EF=BC=8C1.00=20=3D=20=E5=85=B3?= =?UTF-8?q?=EF=BC=89=E3=80=81=E4=B8=AD=E5=80=BC=E7=AA=97=E5=8F=A3=EF=BC=88?= =?UTF-8?q?1=E2=80=9331=EF=BC=8C1=20=3D=20=E5=85=B3=EF=BC=89=E3=80=81?= =?UTF-8?q?=E4=BD=8E=E9=80=9A=E6=88=AA=E6=AD=A2=EF=BC=880=E2=80=9360=20Hz?= =?UTF-8?q?=EF=BC=8C0=20=3D=20=E5=85=B3=EF=BC=89=E3=80=81=E4=BD=8E?= =?UTF-8?q?=E9=80=9A=E9=98=B6=E6=95=B0=EF=BC=881=E2=80=936=EF=BC=89?= =?UTF-8?q?=E3=80=81=E9=87=87=E6=A0=B7=E7=8E=87=EF=BC=88"=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=B7=9F=E9=9A=8F=E5=B8=A7=E7=8E=87"=E5=A4=8D?= =?UTF-8?q?=E9=80=89=E6=A1=86=20+=20=E5=9B=BA=E5=AE=9A=E5=80=BC=EF=BC=89?= =?UTF-8?q?=E3=80=81"=E5=90=8C=E6=97=B6=E6=98=BE=E7=A4=BA=E6=9C=AA?= =?UTF-8?q?=E6=BB=A4=E6=B3=A2=E6=9B=B2=E7=BA=BF"=EF=BC=9B=E9=80=89?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E5=8D=B3=E5=86=99=E5=85=A5=E8=AF=A5=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=E7=9A=84=E6=95=B0=E5=80=BC=EF=BC=8C=E6=89=8B=E6=94=B9?= =?UTF-8?q?=E4=BB=BB=E4=B8=80=E6=95=B0=E5=80=BC=E8=87=AA=E5=8A=A8=E5=88=87?= =?UTF-8?q?=E5=88=B0"=E8=87=AA=E5=AE=9A=E4=B9=89"=EF=BC=8C=E9=80=89?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E9=A2=84=E8=AE=BE=E5=90=8D=E4=BC=9A=E9=9A=8F?= =?UTF-8?q?=20filter=5Fvalues()=20=E4=B8=80=E8=B5=B7=E5=B8=A6=E5=87=BA?= =?UTF-8?q?=EF=BC=88preset=20=E5=AD=97=E6=AE=B5=EF=BC=89=EF=BC=8C"=5Freset?= =?UTF-8?q?=5Fdefaults"=20=E5=9B=9E=E5=88=B0"=E4=BB=80=E4=B9=88=E9=83=BD?= =?UTF-8?q?=E4=B8=8D=E6=BB=A4"=EF=BC=9B=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E6=8C=89=E5=BD=93=E5=89=8D=E6=95=B0=E5=80=BC?= =?UTF-8?q?=E7=BB=99=E6=8F=90=E7=A4=BA=EF=BC=9A=E5=85=89=E6=B5=81=20EMA=20?= =?UTF-8?q?=E6=89=93=E5=BC=80=E6=97=B6=E6=8F=90=E7=A4=BA"=E8=BF=99?= =?UTF-8?q?=E6=98=AF=E6=B5=8B=E9=87=8F=E8=AE=BE=E7=BD=AE=E3=80=81=E4=B8=A4?= =?UTF-8?q?=E6=AC=A1=E5=BD=95=E5=88=B6=E4=B8=8D=E5=8F=AF=E6=AF=94"?= =?UTF-8?q?=E3=80=81=E5=85=A8=E5=85=B3=E6=97=B6=E6=8F=90=E7=A4=BA"?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E4=B8=8D=E6=BB=A4=E6=B3=A2"=E3=80=81?= =?UTF-8?q?=E5=BD=95=E5=88=B6=E4=B8=AD=E6=98=BE=E7=A4=BA"=E5=BD=95?= =?UTF-8?q?=E5=88=B6=E4=B8=AD=E4=B8=8D=E8=83=BD=E6=9B=B4=E6=94=B9=E6=BB=A4?= =?UTF-8?q?=E6=B3=A2"=E5=B9=B6=E7=A6=81=E7=94=A8=E6=95=B4=E4=B8=AA?= =?UTF-8?q?=E5=88=86=E5=8C=BA=EF=BC=9B=E6=88=AA=E6=AD=A2=E9=A2=91=E7=8E=87?= =?UTF-8?q?=E8=BE=BE=E5=88=B0=E6=88=96=E8=B6=85=E8=BF=87=E5=A5=88=E5=A5=8E?= =?UTF-8?q?=E6=96=AF=E7=89=B9=EF=BC=88=E5=B8=A7=E7=8E=87=E4=B8=80=E5=8D=8A?= =?UTF-8?q?=EF=BC=89=E6=97=B6=E7=94=A8=20dialog.filter=5Fnyquist=20?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E8=AF=B4=E5=87=BA"=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E6=BB=A4"=EF=BC=8C=E5=85=B6=E4=B8=AD=20cutof?= =?UTF-8?q?f/nyquist/rate=20=E4=B8=89=E4=B8=AA=E6=95=B0=E5=AD=97=E7=94=B1?= =?UTF-8?q?=20FilterSettings.nyquist=5Fissue()=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=EF=BC=88problems()=20=E4=BB=8D=E8=BF=94=E5=9B=9E=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E4=B8=B2=E4=BE=9B=E6=97=A5=E5=BF=97=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=EF=BC=8C=E5=88=A4=E6=96=AD=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E4=B8=80=E5=A4=84=EF=BC=89=E3=80=82filters.p?= =?UTF-8?q?y=20=E6=96=B0=E5=A2=9E=20FALLBACK=5FPRESETS=20=E4=B8=8E=20avail?= =?UTF-8?q?able=5Fpresets()=EF=BC=88SDK=20=E8=83=BD=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=B0=B1=E7=94=A8=20SDK=20=E7=9A=84=20FILTER=5FPRESETS?= =?UTF-8?q?=EF=BC=8C=E5=AF=BC=E5=85=A5=E4=B8=8D=E4=BA=86=E6=89=8D=E7=94=A8?= =?UTF-8?q?=E8=BF=99=E4=BB=BD=E6=96=87=E6=A1=A3=E5=89=AF=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BF=9D=E8=AF=81=E4=B8=A4=E8=80=85=E9=80=90?= =?UTF-8?q?=E5=80=BC=E4=B8=80=E8=87=B4=EF=BC=89=E3=80=82app=5Fwindow.py?= =?UTF-8?q?=EF=BC=9A=E6=8A=8A=20plot=5Fsettings=5Frequested=20=E6=8E=A5?= =?UTF-8?q?=E5=88=B0=20=5Fon=5Fplot=5Fsettings=EF=BC=8C=E6=89=93=E5=BC=80?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=97=B6=E4=BC=A0=E5=85=A5=E5=BD=93?= =?UTF-8?q?=E5=89=8D=20=5Ffilter=5Fsettings()=E3=80=81filters=5Flocked?= =?UTF-8?q?=EF=BC=88=E6=AD=A3=E5=9C=A8=E8=B7=91=E6=88=96=E6=AD=A3=E5=9C=A8?= =?UTF-8?q?=E5=BD=95=EF=BC=89=E3=80=81=E4=BB=A5=E5=8F=8A=20=5Fdevice=5Fsam?= =?UTF-8?q?ple=5Frate()=EF=BC=88=E5=AE=9E=E6=B5=8B=E5=B8=A7=E7=8E=87?= =?UTF-8?q?=E4=BC=98=E5=85=88=EF=BC=8C=E5=90=A6=E5=88=99=2030=20fps?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E4=BA=8E=E5=A5=88=E5=A5=8E=E6=96=AF=E7=89=B9?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=EF=BC=89=EF=BC=9B=E6=8E=A5=E5=8F=97=E5=90=8E?= =?UTF-8?q?=E6=8A=8A=20filter=5Fvalues().as=5Fdict()=20=E9=80=90=E9=94=AE?= =?UTF-8?q?=E5=86=99=E6=88=90=20plot=5F*=20=E5=81=8F=E5=A5=BD=EF=BC=88?= =?UTF-8?q?=E4=B8=8E=20=5Ffilter=5Fsettings()=20=E8=AF=BB=E5=9B=9E?= =?UTF-8?q?=E7=9A=84=E6=98=AF=E5=90=8C=E4=B8=80=E7=BB=84=E9=94=AE=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E5=9C=A8=E9=87=87=E9=9B=86=E6=97=B6=E7=AB=8B?= =?UTF-8?q?=E5=88=BB=20set=5Ffilter=5Fsettings()=20=E4=BA=A4=E7=BB=99?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20worker=EF=BC=8C=E5=B9=B6=E5=9C=A8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=8F=E5=9B=9E=E6=8A=A5"=E5=B7=B2=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=EF=BC=9A{describe}=EF=BC=88=E4=B8=8B=E6=AC=A1?= =?UTF-8?q?=E9=87=87=E9=9B=86=E7=94=9F=E6=95=88=EF=BC=89"=E3=80=82?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20locale=20=E6=96=87=E4=BB=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=2021+3=20=E4=B8=AA=E6=96=B0=E9=94=AE=EF=BC=8C?= =?UTF-8?q?=E4=B8=AD=E8=8B=B1=E5=90=84=20343=20=E9=94=AE=E4=B8=80=E4=B8=80?= =?UTF-8?q?=E5=AF=B9=E5=BA=94=E3=80=82tests/test=5Fui=5Fsmoke.py=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=204=20=E9=A1=B9=EF=BC=9A=E9=BD=BF=E8=BD=AE?= =?UTF-8?q?=E7=A1=AE=E5=AE=9E=E8=A7=A6=E5=8F=91=E8=AE=BE=E7=BD=AE=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E3=80=81=E9=A2=84=E8=AE=BE/=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=88=87=E6=8D=A2=E4=B8=8E=E6=95=B0=E5=80=BC=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E3=80=81=E5=BD=95=E5=88=B6=E4=B8=AD=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E8=A2=AB=E7=A6=81=E7=94=A8=E4=B8=94=E7=BB=99=E5=87=BA=E7=90=86?= =?UTF-8?q?=E7=94=B1=E3=80=81=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=86=99=E7=9A=84?= =?UTF-8?q?=E9=94=AE=E4=B8=8E=20=5Ffilter=5Fsettings=20=E8=AF=BB=E5=9B=9E?= =?UTF-8?q?=E7=9A=84=E9=94=AE=E6=98=AF=E5=90=8C=E4=B8=80=E7=BB=84=E3=80=82?= =?UTF-8?q?=20-=20Why:=20=E7=94=A8=E6=88=B7=E8=A6=81=E6=B1=82"=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E6=9B=B2=E7=BA=BF=E5=9B=BE=E5=A2=9E=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E9=BD=BF=E8=BD=AE=E7=9A=84=E9=85=8D=E7=BD=AE=E6=8C=89?= =?UTF-8?q?=E9=94=AE=EF=BC=8C=E5=8F=AF=E4=BB=A5=E8=B0=83=E8=8A=82=E6=9B=B2?= =?UTF-8?q?=E7=BA=BF=E5=9B=BE=E7=9A=84=E4=B8=8A=E9=99=90=E5=92=8C=E6=BB=A4?= =?UTF-8?q?=E6=B3=A2=E7=AD=89"=E3=80=82=E6=AD=A4=E5=89=8D=E6=BB=A4?= =?UTF-8?q?=E6=B3=A2=E5=8F=AA=E8=83=BD=E9=9D=A0=E6=89=8B=E6=94=B9=20prefer?= =?UTF-8?q?ences.json=20=E7=9A=84=20plot=5F*=20=E9=94=AE=EF=BC=88=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E7=95=99=E4=B8=8B=E7=9A=84=E5=8F=A3=E5=AD=90?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E8=80=8C=20app=20=E9=BB=98=E8=AE=A4=E5=B7=B2?= =?UTF-8?q?=E6=94=B9=E6=88=90"=E4=BB=80=E4=B9=88=E9=83=BD=E4=B8=8D?= =?UTF-8?q?=E6=BB=A4"=E2=80=94=E2=80=94=E6=B2=A1=E6=9C=89=E8=BF=99?= =?UTF-8?q?=E4=B8=AA=E5=85=A5=E5=8F=A3=EF=BC=8C=E7=AD=89=E4=BA=8E=E6=8A=8A?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=8F=98=E6=88=90=E4=BA=86=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E3=80=82=20-=20Verification:=20studio=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=2089=20passed=EF=BC=88=E5=8E=9F=2085=20+=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=204=EF=BC=89=EF=BC=9Bshared=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=20158=20passed=EF=BC=9Bui=5Fcheck=20554=20=E9=A1=B9?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=200=20=E5=A4=B1=E8=B4=A5=EF=BC=9B.repro/loca?= =?UTF-8?q?le=5Fcheck.py=20=E6=89=AB=E6=8F=8F=2064=20=E4=B8=AA=E6=BA=90?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=87=8C=20287=20=E4=B8=AA=20t("=E2=80=A6")?= =?UTF-8?q?=20=E5=AD=97=E9=9D=A2=E9=94=AE=EF=BC=8C=E4=B8=A4=E4=B8=AA=20loc?= =?UTF-8?q?ale=20=E5=85=A8=E9=83=A8=E5=91=BD=E4=B8=AD=E4=B8=94=E9=94=AE?= =?UTF-8?q?=E9=9B=86=E5=90=88=E5=AE=8C=E5=85=A8=E7=9B=B8=E5=90=8C=EF=BC=88?= =?UTF-8?q?343/343=EF=BC=89=EF=BC=9B=E7=A6=BB=E5=B1=8F=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=EF=BC=9A=E9=A2=84=E8=AE=BE=20balanc?= =?UTF-8?q?ed=E2=86=92flowEMA(0.5)+median(3)=E3=80=81=E6=94=B9=E4=B8=AD?= =?UTF-8?q?=E5=80=BC=E2=86=92=E8=87=AA=E5=AE=9A=E4=B9=89=E3=80=81=E9=80=89?= =?UTF-8?q?=20low=5Fnoise=E2=86=92=E5=B8=A6=E4=BD=8E=E9=80=9A=E3=80=8130?= =?UTF-8?q?=20Hz=20=E9=87=87=E6=A0=B7=E7=8E=87=E4=B8=8B=E5=A1=AB=2030=20Hz?= =?UTF-8?q?=20=E6=88=AA=E6=AD=A2=E2=86=92=E5=BC=B9=E5=87=BA=E5=A5=88?= =?UTF-8?q?=E5=A5=8E=E6=96=AF=E7=89=B9=E8=AD=A6=E5=91=8A=E3=80=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=BB=98=E8=AE=A4=E2=86=92off/=E4=B8=8D=E6=BB=A4?= =?UTF-8?q?=E6=B3=A2=E3=80=82=20-=20Not=20in=20this=20change:=20CSV=20?= =?UTF-8?q?=E7=9A=84=2017=20=E5=88=97=E4=B8=8E=E6=9C=AA=E6=BB=A4=E6=B3=A2?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=20buffer=EF=BC=88SessionStore/=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=A0=BC=E5=BC=8F=E7=89=88=E6=9C=AC=EF=BC=89=E3=80=81?= =?UTF-8?q?=E5=9B=9E=E6=94=BE=E7=95=8C=E9=9D=A2=E6=98=BE=E7=A4=BA=E6=BB=A4?= =?UTF-8?q?=E6=B3=A2=E5=89=8D=E6=9B=B2=E7=BA=BF=E3=80=81=E6=A0=87=E5=AE=9A?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=98=BE=E7=A4=BA=20filters=20=E5=9D=97?= =?UTF-8?q?=E3=80=81=E5=8F=91=E5=B8=83=E8=AF=B4=E6=98=8E=E9=87=8C"?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=B8=8D=E5=86=8D=E6=BB=A4=E6=B3=A2"?= =?UTF-8?q?=E7=9A=84=E6=8E=AA=E8=BE=9E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/studio/config/locales/en_US.json | 25 ++ apps/studio/config/locales/zh_CN.json | 25 ++ apps/studio/src/orisys_studio/app_window.py | 37 +++ .../src/orisys_studio/ui/force_plots.py | 13 +- apps/studio/src/orisys_studio/ui/icons.py | 20 +- .../orisys_studio/ui/plot_settings_dialog.py | 248 +++++++++++++++++- apps/studio/tests/test_ui_smoke.py | 67 +++++ shared/src/orisys_app_shared/force/filters.py | 79 +++++- shared/tests/test_force_filters.py | 17 ++ 9 files changed, 515 insertions(+), 16 deletions(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 04f3a20..7a4025b 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -56,6 +56,28 @@ "plot_shear_raw_max": "Shear raw max", "plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range", "reset_defaults": "Reset to defaults", + "plot_filters": "Filters (applied while acquiring; screen and record agree)", + "filter_preset": "Preset", + "filter_preset_tip": "A ready-made combination; editing any value below switches to Custom", + "filter_preset_custom": "Custom", + "filter_flow_ema": "Flow EMA α", + "filter_flow_ema_tip": "Temporal smoothing of the optical flow. 1.00 = off. It changes what is *measured* (contact statistics and forces along with it), so two recordings with different α are not comparable — only use it when the flow jitter really has to go", + "filter_median": "Median window", + "filter_median_tip": "Sliding-median window over the force readings (samples). 1 = off. Removes single-sample spikes at the cost of about half a window of delay", + "filter_lowpass": "Low-pass cutoff", + "filter_lowpass_tip": "Low-pass cutoff for the force readings (Hz). 0 = off. Must stay below Nyquist (half the frame rate) or the device will not filter at all", + "filter_order": "Low-pass order", + "filter_order_tip": "Low-pass filter order (1–6). Steeper, with more phase distortion", + "filter_rate": "Sample rate", + "filter_rate_tip": "The rate the low-pass coefficients are designed for (Hz)", + "filter_rate_auto": "Follow the measured frame rate", + "filter_rate_auto_tip": "Design the low-pass for the device's measured rate; clear it to pin a number", + "filter_show_unfiltered": "Also show the unfiltered curves", + "filter_show_unfiltered_tip": "Draw the pre-filter readings beside the filtered ones, so what the filter did can be judged. Display only — the record is unaffected", + "filter_measurement_note": "The flow EMA is a *measurement* setting: changing it makes this recording incomparable with earlier ones.", + "filter_locked": "Filters cannot change while recording; this applies to the next session.", + "filter_nyquist": "Note: a {cutoff} Hz cutoff is at or above the Nyquist frequency of {nyquist} Hz (sample rate {rate} Hz) — the device would not filter. Lower the cutoff or pin a higher sample rate.", + "filter_off": "Not filtering (raw readings)", "ok": "OK", "cancel": "Cancel" }, @@ -161,6 +183,8 @@ "overview_tip": "Fit the entire session; drag the overview strip to move the window", "box_zoom": "Box zoom", "box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)", + "settings": "Chart settings", + "settings_tip": "Time window, axis ranges, and the acquisition filters (screen and record use the same data)", "selection": "Selection {duration}s / {samples} samples — {detail}", "selection_summary": "Selection {duration}s / {samples} samples", "selection_detail": "mean {mean} · σ {std}", @@ -333,6 +357,7 @@ "sample_invalid": "Invalid sample (#{count}): {reason} — marked invalid and excluded from the curves and from calibration", "samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)", "run_failed": "Run failed: {error}", + "filters_saved": "Filter settings saved: {describe} (applies to the next session)", "video_ended": "Video ended", "camera_disconnected": "Camera disconnected", "reset_ok": "Tracker reset", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 8c0495c..e9650f3 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -56,6 +56,28 @@ "plot_shear_raw_max": "原始剪切上限", "plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程", "reset_defaults": "恢复默认", + "plot_filters": "滤波(作用于采集,界面与记录同步)", + "filter_preset": "预设", + "filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”", + "filter_preset_custom": "自定义", + "filter_flow_ema": "光流 EMA α", + "filter_flow_ema_tip": "光流的时间平滑。1.00 = 关。它改变的是“测量本身”(接触统计与力值都会变),两个 α 不同的录制不可比 —— 只有确实需要压光流抖动时才调", + "filter_median": "中值窗口", + "filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟", + "filter_lowpass": "低通截止", + "filter_lowpass_tip": "力读数低通截止频率(Hz)。0 = 关。必须在奈奎斯特频率(帧率的一半)以下,否则设备不会滤", + "filter_order": "低通阶数", + "filter_order_tip": "低通滤波器阶数(1–6)。越高越陡,相位失真也越大", + "filter_rate": "采样率", + "filter_rate_tip": "低通系数按哪个采样率设计(Hz)", + "filter_rate_auto": "自动跟随帧率", + "filter_rate_auto_tip": "按设备实测帧率设计低通系数;取消可固定一个数值", + "filter_show_unfiltered": "同时显示未滤波曲线", + "filter_show_unfiltered_tip": "在力值卡片上把滤波前的读数一并显示,便于判断滤波到底做了什么。只影响显示,不影响记录", + "filter_measurement_note": "光流 EMA 属于“测量”设置:改了它,本次录制与之前的不可比。", + "filter_locked": "录制中不能更改滤波;下一次采集生效。", + "filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。", + "filter_off": "当前不滤波(原始读数)", "ok": "确定", "cancel": "取消" }, @@ -161,6 +183,8 @@ "overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗", "box_zoom": "框选", "box_zoom_tip": "切换为框选放大(拖动图表框选区域)", + "settings": "曲线图设置", + "settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)", "selection": "选区 {duration}s / {samples} 点 — {detail}", "selection_summary": "选区 {duration}s / {samples} 点", "selection_detail": "均 {mean} · σ {std}", @@ -333,6 +357,7 @@ "sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定", "samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)", "run_failed": "启动失败: {error}", + "filters_saved": "滤波设置已保存:{describe}(下次采集生效)", "video_ended": "视频已结束", "camera_disconnected": "摄像机已断开", "reset_ok": "追踪器已重置", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 125bd04..1af3ba4 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -658,6 +658,8 @@ class StudioMainWindow(QMainWindow): self.arrow_panel.view_mode_changed.connect(lambda _mode: self._render_replay_frame()) # The unit chip on the chart header switches the same unit everywhere. self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle) + # The gear on the chart header opens the same dialog as 视图 ▸ 曲线图设置. + self.force_plots.plot_settings_requested.connect(self._on_plot_settings) def _wire_replay(self) -> None: """The controller feeds the views; the chart is the only timeline.""" @@ -1595,6 +1597,11 @@ class StudioMainWindow(QMainWindow): self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX ), units=self.toolbar.units(), + filters=self._filter_settings(), + # Asked *now*: the answer is a property of this moment, and the + # dialog is the place that has to say "next session, not this one". + filters_locked=bool(self._running or self.recorder.is_recording), + sample_rate_hz=self._device_sample_rate(), ) if dialog.exec_() != PlotSettingsDialog.Accepted: return @@ -1604,10 +1611,40 @@ class StudioMainWindow(QMainWindow): self.prefs["plot_shear_max"] = shear_max self.prefs["plot_fn_raw_max"] = fn_raw_max self.prefs["plot_shear_raw_max"] = shear_raw_max + filters = dialog.filter_values() + # The same keys ``_filter_settings()`` reads back, so what the dialog + # shows next time is what the session will actually be given. + for key, value in filters.as_dict().items(): + self.prefs[f"plot_{key}"] = value self.force_plots.set_window_sec(window_sec) self.force_plots.set_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max) self._last_plot_packet_key = None self._persist_prefs() + if not self._running and not self.recorder.is_recording: + # Nothing is streaming: hand the choice over now, so the next session + # starts with it even if the operator never touches the dialog again. + self._ensure_workers() + for worker in (self._box_worker, self._finger_worker): + if worker is not None: + worker.set_filter_settings(filters) + self.statusBar().showMessage( + t("status.filters_saved", describe=filters.describe()) + ) + + def _device_sample_rate(self) -> float: + """The frame rate the low-pass coefficients would be designed for. + + The device's measured rate once it has one, else the configured 30 fps — + the dialog needs a number to check a cutoff against Nyquist. + """ + for source in (self._latest_fps, self.prefs.get("measured_fps")): + try: + rate = float(source or 0.0) + except (TypeError, ValueError): + rate = 0.0 + if rate > 0: + return rate + return float(FORCE_SAMPLE_RATE_HZ) def _apply_plot_prefs_to_panel(self) -> None: self.force_plots.set_window_sec( diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index c86409c..6a72b17 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -118,6 +118,8 @@ class ForcePlotsPanel(QFrame): mode_changed = pyqtSignal(str) #: The unit chip was clicked: switch between newton and raw. unit_toggle_requested = pyqtSignal() + #: The gear was clicked: open the plot settings (window, range, filters). + plot_settings_requested = pyqtSignal() #: The operator took the timeline over (dragged the cursor, box-zoomed, panned #: or moved the overview marker): playback stops where it is. timeline_taken_over = pyqtSignal() @@ -194,6 +196,12 @@ class ForcePlotsPanel(QFrame): self.btn_box_zoom.setCheckable(True) self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled) header.addWidget(self.btn_box_zoom) + # Window, range *and filters*. The filters belong here because they are + # what the curves are made of: the chain runs in the SDK, so this opens + # the one place the operator can say what the acquisition should do. + self.btn_settings = self._header_button("settings") + self.btn_settings.clicked.connect(self.plot_settings_requested.emit) + header.addWidget(self.btn_settings) # The unit chip is a button: switching between newton and raw is a chart # concern, so it lives on the chart. self.unit_badge = QPushButton() @@ -396,7 +404,8 @@ class ForcePlotsPanel(QFrame): text = color(theme, "text_primary") muted = color(theme, "text_muted") # Icons are painted in the theme's own colour, so they follow a switch. - for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom): + for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom, + self.btn_settings): name = getattr(button, "_icon_name", "") if name: button.setIcon(icons.icon(name, theme)) @@ -1220,6 +1229,8 @@ class ForcePlotsPanel(QFrame): self.btn_overview.setAccessibleName(t("chart.overview")) self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip")) self.btn_box_zoom.setAccessibleName(t("chart.box_zoom")) + self.btn_settings.setToolTip(t("chart.settings_tip")) + self.btn_settings.setAccessibleName(t("chart.settings")) self.unit_badge.setToolTip(t("chart.unit_toggle_tip")) self.unit_badge.setAccessibleName(t("chart.unit_toggle")) self.window_caption.setText(t("chart.overview")) diff --git a/apps/studio/src/orisys_studio/ui/icons.py b/apps/studio/src/orisys_studio/ui/icons.py index 44b2c53..9def105 100644 --- a/apps/studio/src/orisys_studio/ui/icons.py +++ b/apps/studio/src/orisys_studio/ui/icons.py @@ -20,7 +20,7 @@ from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap from orisys_app_shared.theme import color #: Names of the icons this module can paint. -NAMES = ("zoom_fit", "box_zoom", "latest") +NAMES = ("zoom_fit", "box_zoom", "latest", "settings") ICON_SIZE = 16 _PEN_WIDTH = 1.4 @@ -76,10 +76,28 @@ def _paint_latest(painter: QPainter, size: int) -> None: ) +def _paint_settings(painter: QPainter, size: int) -> None: + """A cog: eight teeth around a ring, with the hole left open.""" + import math + + centre = size / 2.0 + ring = size * 0.20 + tooth = size * 0.34 + painter.drawEllipse(QRectF(centre - ring, centre - ring, ring * 2, ring * 2)) + for index in range(8): + angle = index * math.pi / 4.0 + dx, dy = math.cos(angle), math.sin(angle) + painter.drawLine( + int(round(centre + dx * ring)), int(round(centre + dy * ring)), + int(round(centre + dx * tooth)), int(round(centre + dy * tooth)), + ) + + _PAINTERS = { "zoom_fit": _paint_zoom_fit, "box_zoom": _paint_box_zoom, "latest": _paint_latest, + "settings": _paint_settings, } diff --git a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py index f715ff0..a62d8e0 100644 --- a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py +++ b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py @@ -1,18 +1,35 @@ -"""Modal dialog for force-plot window and Y-axis limits.""" +"""Modal dialog for the force-plot window, Y-axis limits and the filters. + +Two things live here because they are answered together, at the chart: how much +time and range the curves show, and what the acquisition is allowed to filter. +The filter half is a *request*: it is handed to the SDK through +``FilterSettings.apply_to`` at the start of the next session, and what the device +reports as done is what the record will say (see ``force/filters.py``). +""" from __future__ import annotations from PyQt5.QtWidgets import ( + QCheckBox, + QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QPushButton, + QSpinBox, QVBoxLayout, ) +from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ +from orisys_app_shared.force.filters import ( + SUGGESTED_LOWPASS_HZ, + FilterSettings, + available_presets, +) from orisys_app_shared.i18n import t DEFAULT_PLOT_WINDOW_SEC = 4.0 @@ -35,10 +52,17 @@ class PlotSettingsDialog(QDialog): fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX, shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX, units: str = "newton", + filters: FilterSettings | None = None, + filters_locked: bool = False, + sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ, ) -> None: super().__init__(parent) self.setModal(True) - self.resize(380, 260) + self.resize(420, 520) + self._sample_rate_hz = float(sample_rate_hz) + #: True while the widgets are being filled in from a preset, so the + #: "edited -> custom" handler does not fire on our own writes. + self._syncing_filters = False root = QVBoxLayout(self) form = QFormLayout() @@ -102,6 +126,8 @@ class PlotSettingsDialog(QDialog): for widget in raw_widgets: widget.setEnabled(not newton) + self._build_filter_section(root, filters or FilterSettings(), filters_locked) + buttons_row = QHBoxLayout() self.btn_reset = QPushButton() self.btn_reset.clicked.connect(self._reset_defaults) @@ -115,6 +141,195 @@ class PlotSettingsDialog(QDialog): self.retranslate() + # ---------------------------------------------------------------- filters + def _build_filter_section( + self, root: QVBoxLayout, filters: FilterSettings, locked: bool + ) -> None: + """The filter half: a preset, its values, and what they cost.""" + self.filters_group = QGroupBox() + group_form = QFormLayout(self.filters_group) + + # The preset is shown by its own name — ``balanced`` is the string that + # goes into ``sensor.json``, so the dialog and the config use one word for + # one thing. + self.preset_combo = QComboBox() + self._preset_names = list(available_presets()) + for name in self._preset_names: + self.preset_combo.addItem(name) + self.preset_combo.addItem(t("dialog.filter_preset_custom")) + self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen) + + self.flow_ema_spin = QDoubleSpinBox() + self.flow_ema_spin.setRange(0.05, 1.0) + self.flow_ema_spin.setSingleStep(0.05) + self.flow_ema_spin.setDecimals(2) + self.flow_ema_spin.setValue(float(filters.flow_ema_alpha)) + + self.median_spin = QSpinBox() + self.median_spin.setRange(1, 31) + self.median_spin.setValue(int(filters.median_window)) + + self.lowpass_spin = QDoubleSpinBox() + self.lowpass_spin.setRange(0.0, 60.0) + self.lowpass_spin.setSingleStep(1.0) + self.lowpass_spin.setDecimals(1) + self.lowpass_spin.setSuffix(" Hz") + self.lowpass_spin.setValue(float(filters.lowpass_hz)) + + self.lowpass_order_spin = QSpinBox() + self.lowpass_order_spin.setRange(1, 6) + self.lowpass_order_spin.setValue(int(filters.lowpass_order)) + + self.rate_auto_check = QCheckBox() + self.rate_auto_check.setChecked(str(filters.sample_rate_hz) == "auto") + self.rate_spin = QDoubleSpinBox() + self.rate_spin.setRange(1.0, 240.0) + self.rate_spin.setSingleStep(1.0) + self.rate_spin.setDecimals(1) + self.rate_spin.setSuffix(" Hz") + rate = filters.sample_rate_hz + self.rate_spin.setValue( + float(rate) if str(rate) != "auto" else float(FORCE_SAMPLE_RATE_HZ) + ) + self.rate_spin.setEnabled(not self.rate_auto_check.isChecked()) + self.rate_auto_check.toggled.connect( + lambda checked: self.rate_spin.setEnabled(not checked) + ) + + self.show_unfiltered_check = QCheckBox() + self.show_unfiltered_check.setChecked(bool(filters.show_unfiltered)) + + self.filter_preset_label = QLabel() + self.flow_ema_label = QLabel() + self.median_label = QLabel() + self.lowpass_label = QLabel() + self.lowpass_order_label = QLabel() + self.rate_label = QLabel() + + group_form.addRow(self.filter_preset_label, self.preset_combo) + group_form.addRow(self.flow_ema_label, self.flow_ema_spin) + group_form.addRow(self.median_label, self.median_spin) + group_form.addRow(self.lowpass_label, self.lowpass_spin) + group_form.addRow(self.lowpass_order_label, self.lowpass_order_spin) + group_form.addRow(self.rate_auto_check, self.rate_spin) + group_form.addRow(self.show_unfiltered_check) + + for widget in (self.flow_ema_spin, self.median_spin, self.lowpass_spin, + self.rate_spin, self.rate_auto_check, + self.show_unfiltered_check): + widget.setToolTip("") # filled in by retranslate() + self.flow_ema_spin.valueChanged.connect(self._on_filter_value_edited) + self.median_spin.valueChanged.connect(self._on_filter_value_edited) + self.lowpass_spin.valueChanged.connect(self._on_filter_value_edited) + self.lowpass_order_spin.valueChanged.connect(self._on_filter_value_edited) + self.rate_spin.valueChanged.connect(self._on_filter_value_edited) + self.rate_auto_check.toggled.connect(self._on_filter_value_edited) + + # Start on the preset the settings came from, or on "custom" when they + # are a shape no preset has (a config edited by hand, a read-back state). + # A named preset also *means* its values: showing the name next to + # different numbers would be the same lie the config file used to tell. + if filters.preset in self._preset_names: + self._on_preset_chosen(self._preset_names.index(filters.preset)) + self._show_preset(filters.preset) + + self.filter_note = QLabel() + self.filter_note.setObjectName("hintText") + self.filter_note.setWordWrap(True) + self.filter_warning = QLabel() + self.filter_warning.setObjectName("warnText") + self.filter_warning.setWordWrap(True) + root.addWidget(self.filters_group) + root.addWidget(self.filter_note) + root.addWidget(self.filter_warning) + + self._filters_locked = bool(locked) + self.filters_group.setEnabled(not self._filters_locked) + self._refresh_filter_note() + + def _show_preset(self, name: str | None) -> None: + """Point the combo at a preset (or at "custom") without re-applying it.""" + index = self._preset_names.index(name) if name in self._preset_names else -1 + self._syncing_filters = True + try: + self.preset_combo.setCurrentIndex( + index if index >= 0 else self.preset_combo.count() - 1 + ) + finally: + self._syncing_filters = False + + def _on_preset_chosen(self, index: int) -> None: + if self._syncing_filters: + return + if not (0 <= index < len(self._preset_names)): + return # "custom": leave the numbers as the operator left them + values = available_presets()[self._preset_names[index]] + self._syncing_filters = True + try: + self.flow_ema_spin.setValue(float(values.get("flow_ema_alpha", 1.0))) + self.median_spin.setValue(int(values.get("force_median_window", 1))) + self.lowpass_spin.setValue(float(values.get("force_lowpass_cutoff_hz", 0.0))) + finally: + self._syncing_filters = False + self._refresh_filter_note() + + def _on_filter_value_edited(self, *_args) -> None: + if self._syncing_filters: + return + # Any hand edit is no longer the preset the combo used to name. + self._show_preset(None) + self._refresh_filter_note() + + def selected_preset(self) -> str | None: + """The preset the values still are, or ``None`` for a custom shape.""" + index = self.preset_combo.currentIndex() + if 0 <= index < len(self._preset_names): + return self._preset_names[index] + return None + + def filter_values(self) -> FilterSettings: + rate: float | str = "auto" + if not self.rate_auto_check.isChecked(): + rate = float(self.rate_spin.value()) + return FilterSettings( + flow_ema_alpha=float(self.flow_ema_spin.value()), + median_window=int(self.median_spin.value()), + lowpass_hz=float(self.lowpass_spin.value()), + lowpass_order=int(self.lowpass_order_spin.value()), + sample_rate_hz=rate, + show_unfiltered=self.show_unfiltered_check.isChecked(), + preset=self.selected_preset(), + ).normalised() + + def _refresh_filter_note(self) -> None: + """Say what these numbers amount to, and where they would not work.""" + if not hasattr(self, "filter_note"): + return # still being built + settings = self.filter_values() + rate = self._sample_rate_hz + if str(settings.sample_rate_hz) != "auto": + rate = float(settings.sample_rate_hz) + issue = settings.nyquist_issue(rate) + self.filter_warning.setText( + "" + if issue is None + else t( + "dialog.filter_nyquist", + cutoff=f"{issue['cutoff_hz']:g}", + nyquist=f"{issue['nyquist_hz']:g}", + rate=f"{issue['rate_hz']:g}", + ) + ) + self.filter_warning.setVisible(issue is not None) + if self._filters_locked: + self.filter_note.setText(t("dialog.filter_locked")) + elif settings.flow_ema_on: + self.filter_note.setText(t("dialog.filter_measurement_note")) + elif not settings.active(): + self.filter_note.setText(t("dialog.filter_off")) + else: + self.filter_note.setText("") + def values(self) -> tuple[float, float, float, float, float]: return ( float(self.window_spin.value()), @@ -130,6 +345,16 @@ class PlotSettingsDialog(QDialog): self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX) self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX) self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX) + # "Reset" means the shipped defaults, and those filter nothing — the same + # answer the dialog gives for a preference file that says nothing. + self.flow_ema_spin.setValue(1.0) + self.median_spin.setValue(1) + self.lowpass_spin.setValue(0.0) + self.lowpass_order_spin.setValue(2) + self.rate_auto_check.setChecked(True) + self.show_unfiltered_check.setChecked(False) + self._show_preset("off") + self._refresh_filter_note() def retranslate(self) -> None: self.setWindowTitle(t("dialog.plot_settings_title")) @@ -144,6 +369,25 @@ class PlotSettingsDialog(QDialog): self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max")) self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip")) self.btn_reset.setText(t("dialog.reset_defaults")) + self.filters_group.setTitle(t("dialog.plot_filters")) + self.filter_preset_label.setText(t("dialog.filter_preset")) + self.preset_combo.setToolTip(t("dialog.filter_preset_tip")) + self.preset_combo.setItemText(self.preset_combo.count() - 1, t("dialog.filter_preset_custom")) + self.flow_ema_label.setText(t("dialog.filter_flow_ema")) + self.flow_ema_spin.setToolTip(t("dialog.filter_flow_ema_tip")) + self.median_label.setText(t("dialog.filter_median")) + self.median_spin.setToolTip(t("dialog.filter_median_tip")) + self.lowpass_label.setText(t("dialog.filter_lowpass")) + self.lowpass_spin.setToolTip(t("dialog.filter_lowpass_tip")) + self.lowpass_order_label.setText(t("dialog.filter_order")) + self.lowpass_order_spin.setToolTip(t("dialog.filter_order_tip")) + self.rate_auto_check.setText(t("dialog.filter_rate_auto")) + self.rate_auto_check.setToolTip(t("dialog.filter_rate_auto_tip")) + self.rate_label.setText(t("dialog.filter_rate")) + self.rate_spin.setToolTip(t("dialog.filter_rate_tip")) + self.show_unfiltered_check.setText(t("dialog.filter_show_unfiltered")) + self.show_unfiltered_check.setToolTip(t("dialog.filter_show_unfiltered_tip")) + self._refresh_filter_note() ok_btn = self.button_box.button(QDialogButtonBox.Ok) cancel_btn = self.button_box.button(QDialogButtonBox.Cancel) if ok_btn is not None: diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 05032dd..dcd48df 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -2105,6 +2105,73 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window): _finish(window, stuck) +# ------------------------------------------------------- plot settings dialog +def test_the_chart_gear_opens_the_settings_dialog(qt_app, make_window): + """The gear is the one place the filters can be changed from the UI.""" + window = make_window() + captured = [] + window._on_plot_settings = lambda: captured.append(True) + window.force_plots.plot_settings_requested.disconnect() + window.force_plots.plot_settings_requested.connect(window._on_plot_settings) + window.force_plots.btn_settings.click() + assert captured == [True], "the gear did not ask for the settings dialog" + assert window.force_plots.btn_settings.toolTip() + window.close() + + +def test_the_filter_section_is_a_preset_and_a_custom_shape(qt_app): + """A preset means its values; editing one turns the choice into "custom".""" + from orisys_app_shared.force.filters import FilterSettings + from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog + + dialog = PlotSettingsDialog(filters=FilterSettings(preset="balanced")) + assert dialog.selected_preset() == "balanced" + assert dialog.filter_values().describe() == "flowEMA(0.5) + median(3)" + + dialog.median_spin.setValue(7) + assert dialog.selected_preset() is None, "an edited preset must stop claiming the name" + assert dialog.filter_values().describe() == "flowEMA(0.5) + median(7)" + + dialog.preset_combo.setCurrentIndex(3) # low_noise + assert dialog.filter_values().describe() == "flowEMA(0.3) + median(3) + lowpass(10Hz,order2)" + + # A cutoff the device would drop is stated before the session starts. + dialog.lowpass_spin.setValue(30.0) + assert "Nyquist" in dialog.filter_warning.text() or "奈奎斯特" in dialog.filter_warning.text() + + dialog._reset_defaults() + assert dialog.selected_preset() == "off" + assert dialog.filter_values().active() is False + assert dialog.filter_note.text(), "the dialog should say that nothing is filtered" + + +def test_the_filter_controls_are_locked_while_a_session_runs(qt_app): + from orisys_app_shared.force.filters import FilterSettings + from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog + + dialog = PlotSettingsDialog(filters=FilterSettings(), filters_locked=True) + assert not dialog.filters_group.isEnabled() + assert dialog.filter_note.text(), "a locked dialog has to say why" + # Reading the values still works: the operator can see what is running. + assert dialog.filter_values().active() is False + + +def test_the_dialog_writes_the_keys_the_session_reads_back(qt_app): + """The dialog's own vocabulary must be what ``_filter_settings`` reads.""" + from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping + + settings = FilterSettings( + flow_ema_alpha=0.5, median_window=3, lowpass_hz=10.0, show_unfiltered=True, + preset="low_noise", + ) + stored = {f"plot_{key}": value for key, value in settings.as_dict().items()} + read_back = settings_from_mapping( + {key.removeprefix("plot_"): value for key, value in stored.items()} + ) + assert read_back == settings.normalised() + assert read_back.describe() == "flowEMA(0.5) + median(3) + lowpass(10Hz,order2)" + + diff --git a/shared/src/orisys_app_shared/force/filters.py b/shared/src/orisys_app_shared/force/filters.py index 921b868..b1df8cd 100644 --- a/shared/src/orisys_app_shared/force/filters.py +++ b/shared/src/orisys_app_shared/force/filters.py @@ -66,6 +66,48 @@ ALL_KEYS: tuple[str, ...] = tuple(CONFIGURE_PARAMETERS) #: A sensible cutoff to offer when the operator switches the low-pass on. SUGGESTED_LOWPASS_HZ = 10.0 +#: The presets to offer when the SDK cannot be asked for its own. Kept in step +#: with ``orisys.sensor.FILTER_PRESETS`` by tests/test_force_filters.py; the SDK's +#: table wins whenever it can be imported, because that is the table the device +#: resolves against. +FALLBACK_PRESETS: dict[str, dict[str, Any]] = { + "off": { + "flow_ema_alpha": 1.0, + "force_median_window": 1, + "force_lowpass_cutoff_hz": 0.0, + }, + "low_latency": { + "flow_ema_alpha": 1.0, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + }, + "balanced": { + "flow_ema_alpha": 0.5, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + }, + "low_noise": { + "flow_ema_alpha": 0.3, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 10.0, + }, +} + + +def available_presets() -> dict[str, dict[str, Any]]: + """Named presets for the dialog: the SDK's own, or the documented copies.""" + fallback = {name: dict(values) for name, values in FALLBACK_PRESETS.items()} + try: + from orisys.sensor import available_filter_presets + except Exception: # noqa: BLE001 - the SDK may not be importable at all + return fallback + try: + presets = available_filter_presets() + except Exception: # noqa: BLE001 + return fallback + resolved = {str(name): dict(values) for name, values in dict(presets).items()} + return resolved or fallback + def _as_float(value: Any, default: float) -> float: try: @@ -199,24 +241,37 @@ class FilterSettings: """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. + def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]: + """The cutoff/Nyquist problem as numbers, for a UI to phrase itself. 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 + silently re-tuning it, so the dialog has to say so *before* a session starts — otherwise the file would claim a filter the device is not running. """ settings = self.normalised() - issues: list[str] = [] - if settings.lowpass_on and sample_rate_hz: - nyquist = 0.5 * _as_float(sample_rate_hz, 0.0) - if nyquist > 0 and settings.lowpass_hz >= nyquist: - issues.append( - f"lowpass {settings.lowpass_hz:g} Hz is at or above Nyquist " - f"({nyquist:g} Hz): the device would not filter" - ) - return tuple(issues) + if not settings.lowpass_on or not sample_rate_hz: + return None + rate = _as_float(sample_rate_hz, 0.0) + nyquist = 0.5 * rate + if nyquist <= 0 or settings.lowpass_hz < nyquist: + return None + return { + "cutoff_hz": float(settings.lowpass_hz), + "nyquist_hz": float(nyquist), + "rate_hz": float(rate), + } + + def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]: + """Why the device would not do what these settings ask (English, for logs).""" + issue = self.nyquist_issue(sample_rate_hz) + if issue is None: + return () + return ( + f"lowpass {issue['cutoff_hz']:g} Hz is at or above Nyquist " + f"({issue['nyquist_hz']:g} Hz at {issue['rate_hz']:g} fps): the device " + "would not filter", + ) # ---------------------------------------------------------- driving the SDK def unsupported_by(self, sensor: object) -> tuple[str, ...]: diff --git a/shared/tests/test_force_filters.py b/shared/tests/test_force_filters.py index b62b21b..6c4fe23 100644 --- a/shared/tests/test_force_filters.py +++ b/shared/tests/test_force_filters.py @@ -246,6 +246,23 @@ def test_settings_round_trip_through_a_preferences_mapping(): assert settings_from_mapping(None).active() is False +def test_the_documented_presets_match_the_sdk(): + """The dialog's fallback table must not drift away from the device's.""" + from orisys_app_shared.force.filters import FALLBACK_PRESETS, available_presets + + orisys = pytest.importorskip("orisys") + from orisys.sensor import available_filter_presets + + expected = {name: dict(values) for name, values in available_filter_presets().items()} + assert FALLBACK_PRESETS == expected, "the app's preset copy drifted from the SDK" + assert available_presets() == expected + # ...and each one is expressible as settings the app can ask for. + for name, values in expected.items(): + settings = settings_from_mapping(values) + assert settings.preset is None, name + assert settings.configure_kwargs()["force_median_window"] == values["force_median_window"] + + # ------------------------------------------------- the worker's filter contract def test_a_worker_reports_no_filter_state_until_a_session_has_one(): """``{}`` means "not asked yet", which is not the same as "nothing filters"."""