曲线图表头加齿轮,绘图设置对话框里可以直接调滤波 - What: ui/icons.py 新增 settings(齿轮)图标(八个齿 + 圆环,颜色取自主题 token,与另外三个图标同一套画法);force_plots.py 表头新增 btn_settings 与 plot_settings_requested 信号(放在缩放按钮之后、单位芯片之前),换主题时一并重绘图标,retranslate 里补 tooltip/可访问名;plot_settings_dialog.py 增加"滤波"分区:预设下拉(off/low_latency/balanced/low_noise + 自定义,显示的就是写进 sensor.json 的那个名字)、光流 EMA α(0.05–1.00,1.00 = 关)、中值窗口(1–31,1 = 关)、低通截止(0–60 Hz,0 = 关)、低通阶数(1–6)、采样率("自动跟随帧率"复选框 + 固定值)、"同时显示未滤波曲线";选预设即写入该预设的数值,手改任一数值自动切到"自定义",选中的预设名会随 filter_values() 一起带出(preset 字段),"_reset_defaults" 回到"什么都不滤";对话框底部按当前数值给提示:光流 EMA 打开时提示"这是测量设置、两次录制不可比"、全关时提示"当前不滤波"、录制中显示"录制中不能更改滤波"并禁用整个分区;截止频率达到或超过奈奎斯特(帧率一半)时用 dialog.filter_nyquist 明确说出"设备不会滤",其中 cutoff/nyquist/rate 三个数字由 FilterSettings.nyquist_issue() 提供(problems() 仍返回英文串供日志与测试用,判断条件只有一处)。filters.py 新增 FALLBACK_PRESETS 与 available_presets()(SDK 能导入就用 SDK 的 FILTER_PRESETS,导入不了才用这份文档副本,测试保证两者逐值一致)。app_window.py:把 plot_settings_requested 接到 _on_plot_settings,打开对话框时传入当前 _filter_settings()、filters_locked(正在跑或正在录)、以及 _device_sample_rate()(实测帧率优先,否则 30 fps,用于奈奎斯特检查);接受后把 filter_values().as_dict() 逐键写成 plot_* 偏好(与 _filter_settings() 读回的是同一组键),未在采集时立刻 set_filter_settings() 交给两个 worker,并在状态栏回报"已保存:{describe}(下次采集生效)"。两个 locale 文件补齐 21+3 个新键,中英各 343 键一一对应。tests/test_ui_smoke.py 新增 4 项:齿轮确实触发设置信号、预设/自定义切换与数值一致、录制中分区被禁用且给出理由、对话框写的键与 _filter_settings 读回的键是同一组。 - Why: 用户要求"建议曲线图增加一个齿轮的配置按键,可以调节曲线图的上限和滤波等"。此前滤波只能靠手改 preferences.json 的 plot_* 键(上一轮留下的口子),而 app 默认已改成"什么都不滤"——没有这个入口,等于把默认变成了唯一选择。 - Verification: studio 全量 89 passed(原 85 + 新增 4);shared 全量 158 passed;ui_check 554 项检查 0 失败;.repro/locale_check.py 扫描 64 个源文件里 287 个 t("…") 字面键,两个 locale 全部命中且键集合完全相同(343/343);离屏实测对话框:预设 balanced→flowEMA(0.5)+median(3)、改中值→自定义、选 low_noise→带低通、30 Hz 采样率下填 30 Hz 截止→弹出奈奎斯特警告、恢复默认→off/不滤波。 - Not in this change: CSV 的 17 列与未滤波第二 buffer(SessionStore/记录格式版本)、回放界面显示滤波前曲线、标定面板显示 filters 块、发布说明里"默认不再滤波"的措辞。
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "追踪器已重置",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user