曲线图表头加齿轮,绘图设置对话框里可以直接调滤波 - 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": "Shear raw max",
|
||||||
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
|
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
|
||||||
"reset_defaults": "Reset to defaults",
|
"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",
|
"ok": "OK",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
@@ -161,6 +183,8 @@
|
|||||||
"overview_tip": "Fit the entire session; drag the overview strip to move the window",
|
"overview_tip": "Fit the entire session; drag the overview strip to move the window",
|
||||||
"box_zoom": "Box zoom",
|
"box_zoom": "Box zoom",
|
||||||
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
|
"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": "Selection {duration}s / {samples} samples — {detail}",
|
||||||
"selection_summary": "Selection {duration}s / {samples} samples",
|
"selection_summary": "Selection {duration}s / {samples} samples",
|
||||||
"selection_detail": "mean {mean} · σ {std}",
|
"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",
|
"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)",
|
"samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)",
|
||||||
"run_failed": "Run failed: {error}",
|
"run_failed": "Run failed: {error}",
|
||||||
|
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
|
||||||
"video_ended": "Video ended",
|
"video_ended": "Video ended",
|
||||||
"camera_disconnected": "Camera disconnected",
|
"camera_disconnected": "Camera disconnected",
|
||||||
"reset_ok": "Tracker reset",
|
"reset_ok": "Tracker reset",
|
||||||
|
|||||||
@@ -56,6 +56,28 @@
|
|||||||
"plot_shear_raw_max": "原始剪切上限",
|
"plot_shear_raw_max": "原始剪切上限",
|
||||||
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
|
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
|
||||||
"reset_defaults": "恢复默认",
|
"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": "确定",
|
"ok": "确定",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
@@ -161,6 +183,8 @@
|
|||||||
"overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗",
|
"overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗",
|
||||||
"box_zoom": "框选",
|
"box_zoom": "框选",
|
||||||
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
||||||
|
"settings": "曲线图设置",
|
||||||
|
"settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)",
|
||||||
"selection": "选区 {duration}s / {samples} 点 — {detail}",
|
"selection": "选区 {duration}s / {samples} 点 — {detail}",
|
||||||
"selection_summary": "选区 {duration}s / {samples} 点",
|
"selection_summary": "选区 {duration}s / {samples} 点",
|
||||||
"selection_detail": "均 {mean} · σ {std}",
|
"selection_detail": "均 {mean} · σ {std}",
|
||||||
@@ -333,6 +357,7 @@
|
|||||||
"sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定",
|
"sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定",
|
||||||
"samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)",
|
"samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)",
|
||||||
"run_failed": "启动失败: {error}",
|
"run_failed": "启动失败: {error}",
|
||||||
|
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
|
||||||
"video_ended": "视频已结束",
|
"video_ended": "视频已结束",
|
||||||
"camera_disconnected": "摄像机已断开",
|
"camera_disconnected": "摄像机已断开",
|
||||||
"reset_ok": "追踪器已重置",
|
"reset_ok": "追踪器已重置",
|
||||||
|
|||||||
@@ -658,6 +658,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.arrow_panel.view_mode_changed.connect(lambda _mode: self._render_replay_frame())
|
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.
|
# The unit chip on the chart header switches the same unit everywhere.
|
||||||
self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle)
|
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:
|
def _wire_replay(self) -> None:
|
||||||
"""The controller feeds the views; the chart is the only timeline."""
|
"""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
|
self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX
|
||||||
),
|
),
|
||||||
units=self.toolbar.units(),
|
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:
|
if dialog.exec_() != PlotSettingsDialog.Accepted:
|
||||||
return
|
return
|
||||||
@@ -1604,10 +1611,40 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.prefs["plot_shear_max"] = shear_max
|
self.prefs["plot_shear_max"] = shear_max
|
||||||
self.prefs["plot_fn_raw_max"] = fn_raw_max
|
self.prefs["plot_fn_raw_max"] = fn_raw_max
|
||||||
self.prefs["plot_shear_raw_max"] = shear_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_window_sec(window_sec)
|
||||||
self.force_plots.set_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max)
|
self.force_plots.set_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max)
|
||||||
self._last_plot_packet_key = None
|
self._last_plot_packet_key = None
|
||||||
self._persist_prefs()
|
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:
|
def _apply_plot_prefs_to_panel(self) -> None:
|
||||||
self.force_plots.set_window_sec(
|
self.force_plots.set_window_sec(
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ class ForcePlotsPanel(QFrame):
|
|||||||
mode_changed = pyqtSignal(str)
|
mode_changed = pyqtSignal(str)
|
||||||
#: The unit chip was clicked: switch between newton and raw.
|
#: The unit chip was clicked: switch between newton and raw.
|
||||||
unit_toggle_requested = pyqtSignal()
|
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
|
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
|
||||||
#: or moved the overview marker): playback stops where it is.
|
#: or moved the overview marker): playback stops where it is.
|
||||||
timeline_taken_over = pyqtSignal()
|
timeline_taken_over = pyqtSignal()
|
||||||
@@ -194,6 +196,12 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.btn_box_zoom.setCheckable(True)
|
self.btn_box_zoom.setCheckable(True)
|
||||||
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
|
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
|
||||||
header.addWidget(self.btn_box_zoom)
|
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
|
# The unit chip is a button: switching between newton and raw is a chart
|
||||||
# concern, so it lives on the chart.
|
# concern, so it lives on the chart.
|
||||||
self.unit_badge = QPushButton()
|
self.unit_badge = QPushButton()
|
||||||
@@ -396,7 +404,8 @@ class ForcePlotsPanel(QFrame):
|
|||||||
text = color(theme, "text_primary")
|
text = color(theme, "text_primary")
|
||||||
muted = color(theme, "text_muted")
|
muted = color(theme, "text_muted")
|
||||||
# Icons are painted in the theme's own colour, so they follow a switch.
|
# 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", "")
|
name = getattr(button, "_icon_name", "")
|
||||||
if name:
|
if name:
|
||||||
button.setIcon(icons.icon(name, theme))
|
button.setIcon(icons.icon(name, theme))
|
||||||
@@ -1220,6 +1229,8 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.btn_overview.setAccessibleName(t("chart.overview"))
|
self.btn_overview.setAccessibleName(t("chart.overview"))
|
||||||
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
|
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
|
||||||
self.btn_box_zoom.setAccessibleName(t("chart.box_zoom"))
|
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.setToolTip(t("chart.unit_toggle_tip"))
|
||||||
self.unit_badge.setAccessibleName(t("chart.unit_toggle"))
|
self.unit_badge.setAccessibleName(t("chart.unit_toggle"))
|
||||||
self.window_caption.setText(t("chart.overview"))
|
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
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
#: Names of the icons this module can paint.
|
#: Names of the icons this module can paint.
|
||||||
NAMES = ("zoom_fit", "box_zoom", "latest")
|
NAMES = ("zoom_fit", "box_zoom", "latest", "settings")
|
||||||
|
|
||||||
ICON_SIZE = 16
|
ICON_SIZE = 16
|
||||||
_PEN_WIDTH = 1.4
|
_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 = {
|
_PAINTERS = {
|
||||||
"zoom_fit": _paint_zoom_fit,
|
"zoom_fit": _paint_zoom_fit,
|
||||||
"box_zoom": _paint_box_zoom,
|
"box_zoom": _paint_box_zoom,
|
||||||
"latest": _paint_latest,
|
"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 __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
QVBoxLayout,
|
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
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
DEFAULT_PLOT_WINDOW_SEC = 4.0
|
DEFAULT_PLOT_WINDOW_SEC = 4.0
|
||||||
@@ -35,10 +52,17 @@ class PlotSettingsDialog(QDialog):
|
|||||||
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
|
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
|
||||||
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
|
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
|
||||||
units: str = "newton",
|
units: str = "newton",
|
||||||
|
filters: FilterSettings | None = None,
|
||||||
|
filters_locked: bool = False,
|
||||||
|
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setModal(True)
|
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)
|
root = QVBoxLayout(self)
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
@@ -102,6 +126,8 @@ class PlotSettingsDialog(QDialog):
|
|||||||
for widget in raw_widgets:
|
for widget in raw_widgets:
|
||||||
widget.setEnabled(not newton)
|
widget.setEnabled(not newton)
|
||||||
|
|
||||||
|
self._build_filter_section(root, filters or FilterSettings(), filters_locked)
|
||||||
|
|
||||||
buttons_row = QHBoxLayout()
|
buttons_row = QHBoxLayout()
|
||||||
self.btn_reset = QPushButton()
|
self.btn_reset = QPushButton()
|
||||||
self.btn_reset.clicked.connect(self._reset_defaults)
|
self.btn_reset.clicked.connect(self._reset_defaults)
|
||||||
@@ -115,6 +141,195 @@ class PlotSettingsDialog(QDialog):
|
|||||||
|
|
||||||
self.retranslate()
|
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]:
|
def values(self) -> tuple[float, float, float, float, float]:
|
||||||
return (
|
return (
|
||||||
float(self.window_spin.value()),
|
float(self.window_spin.value()),
|
||||||
@@ -130,6 +345,16 @@ class PlotSettingsDialog(QDialog):
|
|||||||
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
|
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
|
||||||
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
|
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
|
||||||
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_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:
|
def retranslate(self) -> None:
|
||||||
self.setWindowTitle(t("dialog.plot_settings_title"))
|
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_label.setText(t("dialog.plot_shear_raw_max"))
|
||||||
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
|
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
|
||||||
self.btn_reset.setText(t("dialog.reset_defaults"))
|
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)
|
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
|
||||||
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
||||||
if ok_btn is not None:
|
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)
|
_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)"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,48 @@ ALL_KEYS: tuple[str, ...] = tuple(CONFIGURE_PARAMETERS)
|
|||||||
#: A sensible cutoff to offer when the operator switches the low-pass on.
|
#: A sensible cutoff to offer when the operator switches the low-pass on.
|
||||||
SUGGESTED_LOWPASS_HZ = 10.0
|
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:
|
def _as_float(value: Any, default: float) -> float:
|
||||||
try:
|
try:
|
||||||
@@ -199,24 +241,37 @@ class FilterSettings:
|
|||||||
"""A short line for the record, upstream stage first."""
|
"""A short line for the record, upstream stage first."""
|
||||||
return describe_state(self.as_state())
|
return describe_state(self.as_state())
|
||||||
|
|
||||||
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]:
|
def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]:
|
||||||
"""Why the device would not do what these settings ask.
|
"""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
|
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
|
starts — otherwise the file would claim a filter the device is not
|
||||||
running.
|
running.
|
||||||
"""
|
"""
|
||||||
settings = self.normalised()
|
settings = self.normalised()
|
||||||
issues: list[str] = []
|
if not settings.lowpass_on or not sample_rate_hz:
|
||||||
if settings.lowpass_on and sample_rate_hz:
|
return None
|
||||||
nyquist = 0.5 * _as_float(sample_rate_hz, 0.0)
|
rate = _as_float(sample_rate_hz, 0.0)
|
||||||
if nyquist > 0 and settings.lowpass_hz >= nyquist:
|
nyquist = 0.5 * rate
|
||||||
issues.append(
|
if nyquist <= 0 or settings.lowpass_hz < nyquist:
|
||||||
f"lowpass {settings.lowpass_hz:g} Hz is at or above Nyquist "
|
return None
|
||||||
f"({nyquist:g} Hz): the device would not filter"
|
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",
|
||||||
)
|
)
|
||||||
return tuple(issues)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------- driving the SDK
|
# ---------------------------------------------------------- driving the SDK
|
||||||
def unsupported_by(self, sensor: object) -> tuple[str, ...]:
|
def unsupported_by(self, sensor: object) -> tuple[str, ...]:
|
||||||
|
|||||||
@@ -246,6 +246,23 @@ def test_settings_round_trip_through_a_preferences_mapping():
|
|||||||
assert settings_from_mapping(None).active() is False
|
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
|
# ------------------------------------------------- the worker's filter contract
|
||||||
def test_a_worker_reports_no_filter_state_until_a_session_has_one():
|
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"."""
|
"""``{}`` means "not asked yet", which is not the same as "nothing filters"."""
|
||||||
|
|||||||
Reference in New Issue
Block a user