fixed commetn from songxiao

This commit is contained in:
ykcheungab
2026-09-25 17:46:37 +08:00
parent 6effe74172
commit 12781854ca
56 changed files with 4935 additions and 419 deletions
+21
View File
@@ -46,6 +46,9 @@
"stop": "Cancel acquisition",
"preview": "Preview / Stop preview",
"reset": "Reset tracker",
"reset_view": "Reset views",
"reset_view_tip": "Put the chart window, cursor and 3D camera back to their defaults (F8). Touches neither the device nor the zero estimate.",
"pause": "Pause / resume (F7)",
"help": "Help",
"about": "About"
},
@@ -53,14 +56,22 @@
"plot_settings_title": "Plot settings",
"plot_window": "Time window",
"plot_window_tip": "Force plot time window (seconds)",
"plot_fn_min": "FN min",
"plot_fn_min_tip": "Normal-force axis minimum (Newton mode). It may be negative: leaving room below zero keeps the idle curve off the FX frame and makes small presses visible",
"plot_fn_max": "FN max",
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"plot_fn_raw_min": "FN raw min",
"plot_fn_raw_min_tip": "Normal-force axis minimum in raw (uncalibrated) mode; may be negative",
"plot_fn_raw_max": "FN raw max",
"plot_fn_raw_max_tip": "Normal-force axis max in raw (uncalibrated) mode. 0 = auto range on the data; the default is sized so idle jitter is invisible in the plot",
"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_deadband_n": "Zero threshold (N)",
"plot_deadband_n_tip": "Display deadband floor in Newton mode: a reading inside the threshold shows as 0 and the curve flattens on the zero line. 0 = auto (the noise estimated from the quiet startup window; Newton mode has no fixed floor). Display only — the record and forces.csv keep the measured values",
"plot_deadband_raw": "Zero threshold (raw)",
"plot_deadband_raw_tip": "Display deadband floor in raw mode (raw counts). 0 = auto (the built-in 50-count floor). Raise it by hand when the calibration data is suspect; display only",
"reset_defaults": "Reset to defaults",
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
"filter_preset": "Preset",
@@ -115,6 +126,13 @@
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
"playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)",
"reset": "Reset",
"reset_tip": "Zero the device tracker. This affects the zero point only, never the view — to put the views back use View → Reset views.",
"pause": "⏸ Pause",
"pause_resume": "▶",
"pause_tip": "Pause the computation and the display (F7): no flow, depth or contact is computed, the picture holds its last frame, and the device stays connected for an immediate resume. A recording that spans a pause has a gap.",
"resume_tip": "Resume the computation and the display (F7), from the newest frame.",
"resume_label": "▶ Resume",
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
"stop_playback": "Stop",
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
"stopped": "Stopped: {what}",
@@ -400,6 +418,9 @@
"camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset",
"reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…",
"view_reset": "Views reset",
"paused": "Paused: nothing is computed, the device stays connected",
"resumed": "Resumed",
"finger_run_unavailable": "Run is not available for this sensor type",
"video_required": "Select a video file first",
"config_missing": "Sensor config not found for {type}",
+22 -1
View File
@@ -46,6 +46,9 @@
"stop": "取消采集",
"preview": "预览 / 停止预览",
"reset": "重置追踪器",
"reset_view": "复位视图",
"reset_view_tip": "把曲线窗口、游标与三维视角恢复为默认(F8)。不改动设备,也不清除零点估计。",
"pause": "暂停 / 继续(F7)",
"help": "帮助",
"about": "关于"
},
@@ -53,14 +56,22 @@
"plot_settings_title": "绘图设置",
"plot_window": "时间窗口",
"plot_window_tip": "力曲线时间窗口(秒)",
"plot_fn_min": "FN 下限",
"plot_fn_min_tip": "法向力纵轴下限(牛顿模式)。可以是负数:留出零点以下的空间,静止曲线不会压在 FX 框线上,小压力也更清楚",
"plot_fn_max": "FN 上限",
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"plot_fn_raw_min": "FN 原始下限",
"plot_fn_raw_min_tip": "未标定(原始)模式下的法向力纵轴下限,可为负",
"plot_fn_raw_max": "FN 原始上限",
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
"plot_shear_raw_max": "原始剪切上限",
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
"plot_deadband_n": "归零阈值(N)",
"plot_deadband_n_tip": "牛顿模式下的显示死区下限:读数在阈值内即显示为 0,曲线压平在零线上。0 = 自动(用启动静默窗口估计的噪声,牛顿模式无固定下限)。只影响显示,记录与 forces.csv 仍是实测值",
"plot_deadband_raw": "归零阈值(原始)",
"plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示",
"reset_defaults": "恢复默认",
"plot_filters": "滤波(作用于采集,界面与记录同步)",
"filter_preset": "预设",
@@ -107,7 +118,7 @@
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_disabled_sensor": "此传感器类型暂不支持运行",
"playback": "▶ 回放",
"playback": "▶ 播放",
"playback_pause": "⏸ 暂停",
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
@@ -115,6 +126,13 @@
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
"reset": "重置",
"reset_tip": "把设备追踪器归零(清零)。只影响零点,不动界面;想恢复视图请用「视图 → 复位视图」。",
"pause": "⏸ 暂停",
"pause_resume": "▶",
"pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。",
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
"resume_label": "▶ 继续",
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
"stop_playback": "停止",
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
"stopped": "已停止:{what}",
@@ -400,6 +418,9 @@
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"reset_pending": "已请求归零,等待采集线程确认…",
"view_reset": "视图已复位",
"paused": "已暂停:不再计算,设备保持连接",
"resumed": "已继续",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",
+5 -1
View File
@@ -15,17 +15,21 @@
"scene3d_display": {
"deform_enabled": false,
"display_gain": 0.0,
"housing_visible": true,
"housing_visible": false,
"max_fps": 30
},
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_min": 0.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"plot_fn_raw_min": 0.0,
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
"plot_deadband_n": 0.0,
"plot_deadband_raw": 0.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90,
"settings_expanded": false,
@@ -0,0 +1,44 @@
{
"name": "mems-prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365, 263, 123, 63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1, 2, 3, 6, 7, 8, 11, 12, 13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3, 3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend": "numba",
"blend_list": ["numpy", "numba", "none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 5,
"div_threshold": 0.05,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
@@ -6,7 +6,7 @@
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"source_scale": 0.42,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
+231 -44
View File
@@ -76,8 +76,12 @@ from orisys_studio.ui.toolbar import ToolbarWidget
logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
#: Types the Run button accepts. ``finger-mems`` is a studio-side product (deprecated in the
#: SDK) that reuses the box/mini pipeline, so it is runnable exactly like box.
RUNNABLE_TYPES = frozenset({"box", "mini", "finger", "finger-mems"})
#: Types whose 2D arrow view is rotated 90° (the sensor is mounted that way). finger-mems is
#: a box-style stitched camera, so it rotates like box until the device says otherwise.
ARROW_ROTATE_TYPES = frozenset({"box", "mini", "finger-mems"})
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
MIN_PANEL_WIDTHS = {
# The 3D pane used to be the one panel whose content was a stack of form fields, so
@@ -204,6 +208,10 @@ class StudioMainWindow(QMainWindow):
self.session.tick.connect(self._on_run_tick)
# The phase is the session's, not the window's; the chip is one view of it.
self.session.phaseChanged.connect(self._on_session_phase_changed)
#: True while the operator has paused the live session. The acquisition thread
#: stops computing; this flag stops the *display* half of the run tick, so a
#: frame that was already in flight cannot repaint the panels they paused on.
self._display_paused = False
self._active_calibration_id = ""
#: Last display-unit decision (``None`` before the first sample): the
#: status rail's calibration readout is derived from it, because an axis
@@ -738,6 +746,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.replay_video_clicked.connect(self.replay_ctl.on_open_recording)
self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.stop_clicked.connect(self._on_stop_clicked)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self.display_ctl.on_units_changed)
@@ -974,6 +983,22 @@ class StudioMainWindow(QMainWindow):
self.act_reset.setShortcut("Ctrl+R")
self.act_reset.triggered.connect(self._on_reset)
self.view_menu.addAction(self.act_reset)
# The graphics half of the old 重置: curves, window, cursor and the 3D camera.
# Separate because wanting a fresh device baseline is not the same intention as
# wanting the views back, and only the first one belongs to the device.
self.act_reset_view = QAction(self)
self.act_reset_view.setShortcut("F8")
self.act_reset_view.triggered.connect(self._on_reset_view)
self.view_menu.addAction(self.act_reset_view)
# Pause has a shortcut as well as a rail button: an operator who is holding a
# sample still wants to stop the numbers moving without reaching for the mouse.
self.act_pause = QAction(self)
self.act_pause.setShortcut("F7")
self.act_pause.setCheckable(True)
self.act_pause.toggled.connect(self._on_pause_action_toggled)
self.view_menu.addAction(self.act_pause)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
@@ -1111,6 +1136,12 @@ class StudioMainWindow(QMainWindow):
return self.layout_ctl.view_preset
def _update_run_enabled(self) -> None:
"""What the rail's transport, stop and reset can do in the current view.
One place decides it for both views: the rail's buttons mean the same
things there (the transport is a play/pause toggle, stop ends what is
running), so a second copy of the rule is a second thing to keep in step.
"""
if self.toolbar.run_role() == "playback":
# In the replay view the button is a transport: it needs something to
# play — a recording, or a bare video — not a runnable sensor. While it
@@ -1131,14 +1162,16 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
# The stop button is offered whenever there is something to stop; the
# operator must never have to guess whether that is the case.
if self.toolbar.reset_role() == "stop":
self.toolbar.set_reset_enabled(
self.replay_ctl.is_playing()
or self._recompute_running
or self._session_active
)
# Stop leaves the replay, whatever it is doing: plain playback, a
# recompute pass, or a session the pass started. The operator must never
# have to work out which of those is stuck before they can stop it.
self.toolbar.set_stop_enabled(
self.replay_ctl.is_playing()
or self._recompute_running
or self._session_active
)
# The tracker reset belongs to a device session; this view has none.
self.toolbar.set_reset_enabled(False)
self.toolbar.set_record_enabled(
self._running or self.recorder.is_recording
)
@@ -1155,10 +1188,16 @@ class StudioMainWindow(QMainWindow):
source_ok = False
reason = t("toolbar.run_disabled_video")
self.toolbar.set_run_tooltip_reason(reason)
# While a session exists the transport stays enabled — pausing and resuming
# are the whole point of it being checked, not an exception to an idle rule.
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
# Recording needs a live session; while recording the button stays
# enabled so it can always be stopped.
self.toolbar.set_record_enabled(self._running or self.recorder.is_recording)
# Stop releases the device and ends the session. It covers a device open
# that hangs as well, so it is offered for as long as a session exists at
# all — including the moment the worker is still winding down.
self.toolbar.set_stop_enabled(self._session_active)
if not self._running:
self.toolbar.refresh_labels()
self._sync_shortcut_actions()
@@ -1189,6 +1228,10 @@ class StudioMainWindow(QMainWindow):
self.act_stop.setText(t("menu.stop"))
self.act_preview.setText(t("menu.preview"))
self.act_reset.setText(t("menu.reset"))
self.act_reset_view.setText(t("menu.reset_view"))
self.act_reset_view.setToolTip(t("menu.reset_view_tip"))
self.act_pause.setText(t("menu.pause"))
self._sync_pause_action()
self.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light"))
@@ -1494,10 +1537,17 @@ class StudioMainWindow(QMainWindow):
dialog = PlotSettingsDialog(
self,
window_sec=settings["window_sec"],
# FN's lower bound may be negative: that is the "show me below zero" the
# operator asked for, and it is what puts the 0 tick back on the axis.
fn_min=settings["fn_min"],
fn_max=settings["fn_max"],
shear_max=settings["shear_max"],
fn_raw_min=settings["fn_raw_min"],
fn_raw_max=settings["fn_raw_max"],
shear_raw_max=settings["shear_raw_max"],
# Display-only zeroing floors; 0 means "use the estimated band".
deadband_n=settings["deadband_n"],
deadband_raw=settings["deadband_raw"],
units=settings["units"],
filters=self._filter_settings(),
# Asked *now*: the answer is a property of this moment, and the
@@ -1507,16 +1557,9 @@ class StudioMainWindow(QMainWindow):
)
if dialog.exec() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.display_ctl.apply_plot_settings(
{
"window_sec": window_sec,
"fn_max": fn_max,
"shear_max": shear_max,
"fn_raw_max": fn_raw_max,
"shear_raw_max": shear_raw_max,
}
)
# One call for the whole picture: the dialog owns the schema, the controller
# writes what it was given and re-reads the rest.
self.display_ctl.apply_plot_settings(dialog.settings())
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.
@@ -1718,20 +1761,43 @@ class StudioMainWindow(QMainWindow):
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
# The menu's Stop mirrors the rail's Stop button, not the acquisition
# session alone: a replay or a recompute pass is something to stop.
#
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
# did not) and never delivers the shortcut of a disabled action, so a Stop that
# is disabled while a replay is playing is a keyboard escape hatch that does not
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
self.act_stop.setEnabled(
self._session_active
or self.replay_ctl.is_playing()
or self._recompute_running
)
self.act_stop.setEnabled(self.toolbar.btn_stop.isEnabled())
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
# A live pause is only meaningful while the pipeline is running; in the replay
# view its own transport owns play/pause.
self.act_pause.setEnabled(
self.toolbar.run_role() != "playback" and self._running
)
self.act_pause.setToolTip(
t("toolbar.resume_tip") if self.act_pause.isChecked() else t("toolbar.pause_tip")
)
def _sync_pause_action(self) -> None:
"""The View-menu action follows the session; it never leads it.
The rail's transport owns the pause state (checked = acquiring and not
paused, unchecked with a session = paused), so the action is a mirror and a
shortcut into it — two controls that cannot disagree.
"""
action = getattr(self, "act_pause", None)
if action is None:
return
paused = bool(self._running and self._display_paused)
if action.isChecked() != paused:
blocked = action.blockSignals(True)
action.setChecked(paused)
action.blockSignals(blocked)
action.setToolTip(
t("toolbar.resume_tip") if paused else t("toolbar.pause_tip")
)
def _on_play_shortcut(self) -> None:
"""Space plays or pauses the recording, if there is one to move through."""
@@ -1753,6 +1819,14 @@ class StudioMainWindow(QMainWindow):
``_sync_shortcut_actions`` enables the action for, so the action can never be
enabled with nothing behind it.
"""
self._stop_activity()
def _stop_activity(self) -> None:
"""End whatever this view is doing. The rail's Stop and Esc share this body.
One implementation, because the button and the shortcut are the same act:
two copies would be two answers to "what does Stop stop".
"""
if self.toolbar.run_role() == "playback":
if (
self.replay_ctl.is_playing()
@@ -1765,6 +1839,10 @@ class StudioMainWindow(QMainWindow):
return
self._stop_run(reason="user_stop")
def _on_stop_clicked(self) -> None:
"""The rail's Stop button — the same escape hatch Esc reaches."""
self._stop_activity()
def _on_preview_shortcut(self) -> None:
if not self.toolbar.btn_preview.isEnabled():
return
@@ -1876,14 +1954,28 @@ class StudioMainWindow(QMainWindow):
self._show_image_frame(ImageFrame(frame, source=SOURCE_PREVIEW))
def _on_run_toggled(self, checked: bool) -> None:
"""The rail's transport. One button, three states, routed by the session.
``checked`` means "acquiring and not paused" in the live view and "playing"
in the replay view. Unchecked is ambiguous on its own — it is *idle* when no
session exists and *paused* when one does — so whether a session is already
running is what decides between starting and resuming, and between pausing
and doing nothing. Stopping is never this button's job: it has its own.
"""
if self.toolbar.run_role() == "playback":
# The primary button of the replay view is its transport.
self.replay_ctl.on_transport_toggled(bool(checked))
return
if checked:
self._start_run()
else:
self._stop_run(reason="user_stop")
if self._running:
# A live session already exists and the button was unchecked: that
# is the paused state, so checking it again means resume.
self._on_pause_toggled(False)
else:
self._start_run()
return
if self._running:
self._on_pause_toggled(True)
def _start_run(self) -> None:
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
@@ -1919,11 +2011,11 @@ class StudioMainWindow(QMainWindow):
self.force_plots.configure_presentation(
sensor_type=sensor_type,
unit=units,
enabled=(
sensor_type == "finger"
and units == "raw"
and bool(self.prefs.get("raw_auto_zero", True))
),
# The operator's own switch, and only that: the aid covers every sensor type and
# both units, which is the point of it (an offset reads as an offset in newtons
# too). This used to be a ``finger and raw`` conjunction, which silently disabled
# it for every other product.
enabled=bool(self.prefs.get("raw_auto_zero", True)),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
@@ -1975,6 +2067,12 @@ class StudioMainWindow(QMainWindow):
# The session is now connecting: it owns the phase, the connect clock and the
# run timer's cadence (the timer *is* the session, so it lives with it).
self.session.mark_running()
# A session exists and is not paused, which is exactly what the transport's
# checked state means: stated here as well as by the click, because starting
# is not only ever reached through the button (a recompute pass starts the
# same session), and an unchecked transport beside a live session would read
# as "paused".
self.toolbar.set_run_checked(True)
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
@@ -2013,6 +2111,12 @@ class StudioMainWindow(QMainWindow):
self.toolbar.refresh_labels()
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
# The session is over, so the transport must not stay latched in "paused" —
# the next run starts computing. The worker clears its own flag on start as
# well, so both sides agree even if this path is skipped (a stop from the
# acquisition thread).
self._display_paused = False
self._sync_pause_action()
self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.set_picker_enabled(True)
@@ -2064,29 +2168,111 @@ class StudioMainWindow(QMainWindow):
self._notify(t("toolbar.nothing_to_stop"))
def _on_reset(self) -> None:
if self.toolbar.reset_role() == "stop":
# In the replay view that button is the operator's way out of a replay or
# a recompute pass, whatever state the transport is in.
self.replay_ctl.stop_activity()
return
"""Zero the device tracker. Graphics are reset separately (``_on_reset_view``).
This used to call ``force_plots.reset()`` as well, so wanting a fresh baseline
also threw away the history, the window and the cursor on screen — two different
operator intentions on one button (and only one of them recorded, see
``note_reset_applied``). The display baseline is re-estimated instead of wiped:
a new device zero means the old idle estimate is stale, not that the operator
wants to lose what they were looking at.
It is only ever this: ending a replay or a recompute pass is the rail's Stop
button (the same act as Esc), so this button is disabled wherever there is no
device session to zero.
"""
if self._session_phase != "running" or self._active_worker is None:
return
try:
# A request, not the zeroing itself: the acquisition thread applies it
# between two frames and reports the frame and time it took effect.
self._active_worker.reset()
self.force_plots.reset()
self.force_plots.request_presentation_reestimate()
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.reset_pending"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_pause_toggled(self, paused: bool) -> None:
"""Pause the pipeline and the display; the device stays open.
Not a display freeze: the acquisition thread stops *computing* as well, so nothing
is spent on flow, depth or contact while an operator reads a result. What it
deliberately does not do is release the device — that is 停止 — so resuming is
immediate and the camera is not re-enumerated.
Consequence, stated rather than hidden: with no packets there is nothing to write,
so a recording that spans a pause has a **gap**. The store's own time base shows it
(sample times jump), and 停止 still ends the session normally.
Reached from the rail's transport (which owns the state) and from the View-menu
action / F7 (which mirrors it); never from two controls of its own.
"""
paused = bool(paused)
self._display_paused = paused
self._sync_pause_action()
worker = self._active_worker
if worker is not None:
try:
worker.pause() if paused else worker.resume()
except Exception as exc: # noqa: BLE001 - report, never raise from a click
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
if paused:
self.statusBar().showMessage(t("status.paused"))
else:
# The next packet is a new sample again: without this the first one after a
# resume can be swallowed as a repeat of the last key the tick saw.
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.resumed"))
def _on_pause_action_toggled(self, paused: bool) -> None:
"""The menu/shortcut half of pause: drive the rail's transport, which owns it.
The action is checked exactly while the session is paused, so pressing it
toggles the transport button — and that click is what routes to pause or
resume. Keeping one control authoritative means the button and the shortcut
can never disagree about whether the session is paused.
"""
paused = bool(paused)
if self.toolbar.run_role() == "playback" or not self._running:
# Nothing here to pause: put the action back rather than letting it
# advertise a state the session is not in.
self._sync_pause_action()
return
wanted = not paused
if self.toolbar.btn_run.isChecked() != wanted:
# ``setChecked`` emits, so the click takes the window's own transport
# path (start/resume/pause) instead of a second copy of that decision.
self.toolbar.btn_run.setChecked(wanted)
def _on_reset_view(self) -> None:
"""Put the views back to their defaults: curves, window, cursor and 3D camera.
Pressures the same state ``force_plots.reset()`` always cleared, now on its own
action, plus the 3D pane's camera. It touches nothing the device owns, so it is
always available — including while a replay is on screen.
"""
self.force_plots.reset()
panel = getattr(self, "mesh3d_panel", None)
if panel is not None:
try:
panel.reset_view()
except Exception: # noqa: BLE001 - a pane with no scene has no view to reset
logger.debug("3D view reset skipped", exc_info=True)
self.statusBar().showMessage(t("status.view_reset"))
def _on_run_tick(self) -> None:
# The 3D pane advances on this timer rather than one of its own: the window's
# refresh rate is then the only one that exists, and hiding or disabling the
# pane cannot leave a timer behind. It starts at most one compose on the
# pane's own worker and takes at most one finished frame.
self.mesh3d_panel.tick()
if not self._display_paused:
# While paused the pane holds the frame it had. The acquisition thread has
# already stopped computing, so this only matters for a frame that was in
# flight when the operator pressed pause — which must not be the one frame
# they cannot see.
self.mesh3d_panel.tick()
if self._active_worker is None:
return
# Record bookkeeping happens before anything that can return early: a
@@ -2141,7 +2327,7 @@ class StudioMainWindow(QMainWindow):
# source slot names the camera, so "运行中(0)" was a second copy of
# the same fact that then stayed on screen for the rest of the session.
if is_new:
if is_new and not self._display_paused:
self._last_plot_packet_key = packet_key
# The live view consumes the *same* standardised measurement as the
# CSV and the session store: one conversion decides what a value
@@ -2227,8 +2413,9 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(dropped_message)
mode = self.arrow_panel.view_mode()
if (is_new or mode != self._last_rendered_view_mode) and not (
self._replay_owns_image()
if not self._display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not (self._replay_owns_image())
):
self._render_run_image(packet)
if is_new:
+105 -43
View File
@@ -23,8 +23,38 @@ from typing import Any, Optional, Protocol
from PySide6.QtCore import QObject
from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW,
DEFAULT_PLOT_FN_MIN,
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_FN_RAW_MIN,
DEFAULT_PLOT_FN_RAW_MAX,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_SHEAR_RAW_MAX,
DEFAULT_PLOT_WINDOW_SEC,
)
logger = logging.getLogger(__name__)
#: The chart range, per channel and per unit. ``fn_min`` is the one that may be
#: negative (FN is asymmetric); FX/FY take one absolute magnitude each.
PLOT_RANGE_DEFAULTS = {
"window_sec": DEFAULT_PLOT_WINDOW_SEC,
"fn_min": DEFAULT_PLOT_FN_MIN,
"fn_max": DEFAULT_PLOT_FN_MAX,
"shear_max": DEFAULT_PLOT_SHEAR_MAX,
"fn_raw_min": DEFAULT_PLOT_FN_RAW_MIN,
"fn_raw_max": DEFAULT_PLOT_FN_RAW_MAX,
"shear_raw_max": DEFAULT_PLOT_SHEAR_RAW_MAX,
}
#: Display deadband floors, per unit. 0 = auto (the estimate from the quiet
#: startup window, with the presentation layer's own floor under it).
PLOT_DEADBAND_DEFAULTS = {
"deadband_n": DEFAULT_PLOT_DEADBAND_N,
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
}
class DisplayHost(Protocol):
"""What the display controller needs from the window.
@@ -84,7 +114,9 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: Finger-raw value cards: startup auto baseline / deadband (display only).
#: The display baseline / deadband (``raw_auto_zero``): a display aid
#: that holds an idle channel at zero, for every sensor type and both
#: units. It never reaches the session store or forces.csv.
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
@@ -143,50 +175,69 @@ class DisplayPrefsController(QObject):
# --------------------------------------------------------------------- plot
def plot_settings(self) -> dict:
"""The chart range and window as the settings dialog should show them."""
"""The chart range, deadband and window as the settings dialog shows them."""
prefs = self._host.prefs
return {
"window_sec": float(
prefs.get("plot_window_sec") or self._plot_defaults["window_sec"]
),
"fn_max": float(prefs.get("plot_fn_max") or self._plot_defaults["fn_max"]),
"shear_max": float(
prefs.get("plot_shear_max") or self._plot_defaults["shear_max"]
),
"fn_raw_max": float(
prefs.get("plot_fn_raw_max") or self._plot_defaults["fn_raw_max"]
),
"shear_raw_max": float(
prefs.get("plot_shear_raw_max") or self._plot_defaults["shear_raw_max"]
),
"units": self._toolbar.units(),
settings = {
key: self._number(
f"plot_{key}", self._plot_defaults.get(key, fallback)
)
for key, fallback in {**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS}.items()
}
settings["units"] = self._toolbar.units()
return settings
def _number(self, key: str, fallback: float) -> float:
"""A stored number, or the fallback when the preference says nothing.
0 is a value here, not "unset": the raw bounds and the deadband floors
all give 0 a meaning of its own.
"""
value = self._host.prefs.get(key)
if value is None or value == "":
return float(fallback)
try:
return float(value)
except (TypeError, ValueError):
return float(fallback)
def apply_plot_settings(self, values: dict) -> None:
"""Remember the chart range the operator chose, and show it at once."""
for key in ("window_sec", "fn_max", "shear_max", "fn_raw_max", "shear_raw_max"):
self._host.prefs[f"plot_{key}"] = values[key]
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
"""Remember the chart range the operator chose, and show it at once.
A caller that knows only some of the keys leaves the rest as stored, so
this can never silently reset a range it was not told about.
"""
for key in (*PLOT_RANGE_DEFAULTS, *PLOT_DEADBAND_DEFAULTS):
if values.get(key) is not None:
self._host.prefs[f"plot_{key}"] = values[key]
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._host._persist_prefs()
def apply_plot_prefs_to_panel(self) -> None:
"""Show the stored chart range without asking anything (startup path)."""
values = self.plot_settings()
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._charts.set_fixed_newton(self._toolbar.units() == "newton")
def _apply_ranges(self, settings: dict) -> None:
"""Push one complete range + deadband picture onto the charts."""
self._charts.set_axis_limits(
fn_min=float(settings["fn_min"]),
fn_max=float(settings["fn_max"]),
shear_max=float(settings["shear_max"]),
fn_raw_min=float(settings["fn_raw_min"]),
fn_raw_max=float(settings["fn_raw_max"]),
shear_raw_max=float(settings["shear_raw_max"]),
)
self._push_deadband_floors(settings["deadband_n"], settings["deadband_raw"])
def _push_deadband_floors(self, newton: float, raw: float) -> None:
floors = getattr(self._charts, "set_deadband_floors", None)
if callable(floors):
floors(newton=float(newton), raw=float(raw))
def device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for."""
from orisys_studio import session_metadata
@@ -271,7 +322,11 @@ class DisplayPrefsController(QObject):
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether Finger raw value cards auto-baseline at session start."""
"""Persist whether the display baseline / deadband is on at all.
The preference keeps its name; it is no longer Finger-raw-only (see
:meth:`apply_presentation_prefs`).
"""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
@@ -281,20 +336,27 @@ class DisplayPrefsController(QObject):
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the current baseline policy onto the charts (Finger raw only)."""
"""Push the display baseline policy onto the charts.
Every sensor type and both units. The aid holds an idle channel at zero:
a newton offset left in the numbers is the same display problem as a raw
count, so which device produced the reading is not what decides it.
"""
configure = getattr(self._charts, "configure_presentation", None)
if not callable(configure):
return
sensor = (
self._picker.sensor_type()
if hasattr(self._picker, "sensor_type")
else "box"
)
unit = "N" if self._toolbar.units() == "newton" else "raw"
configure(
sensor_type=sensor,
unit=unit,
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
if callable(configure):
configure(sensor_type=sensor, unit=unit)
enable = getattr(self._charts, "set_presentation_enabled", None)
if callable(enable):
enable(bool(self._raw_auto_zero))
self._push_deadband_floors(
self._number("plot_deadband_n", PLOT_DEADBAND_DEFAULTS["deadband_n"]),
self._number("plot_deadband_raw", PLOT_DEADBAND_DEFAULTS["deadband_raw"]),
)
def request_raw_reestimate(self) -> None:
@@ -58,7 +58,7 @@ PACKAGE_SUFFIX = ".oryscene"
COMPILE_HINT = (
"python tools/scene_assets/compile_scene.py "
"--legacy-dir config/sensors/finger/assets/30fps-0703 "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz "
"--meters-per-source-unit <stated> --output "
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
)
@@ -180,8 +180,9 @@ class ThreeDPanel(QFrame):
self.housing_check = QCheckBox()
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
# starting state and the settings it reports must be the same thing.
self.housing_check.setChecked(True)
# starting state and the settings it reports must be the same thing. That default is
# now unchecked — the housing is a mechanical check, not the measurement.
self.housing_check.setChecked(False)
self.housing_check.toggled.connect(self._emit_settings)
form.addRow(self.housing_check)
@@ -158,7 +158,7 @@ def settings_from_prefs(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
color_scale=scale,
housing_visible=_flag(raw.get("housing_visible"), True),
housing_visible=_flag(raw.get("housing_visible"), False),
max_fps=max_fps,
field=field,
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
+240 -30
View File
@@ -18,9 +18,14 @@ from PySide6.QtWidgets import (
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import ForcePresentation
from orisys_app_shared.force.presentation import (
DEFAULT_MIN_DEADBAND_N,
DEFAULT_MIN_DEADBAND_RAW,
ForcePresentation,
)
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import plottable_axis_mask
from orisys_app_shared.theme import color
from orisys_studio.ui import icons
@@ -65,10 +70,33 @@ def _window_brush(theme: str):
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
"""Evenly spaced Y ticks that always carry a zero label when zero is inside.
The zero label is what makes "not pressing" readable: on an axis whose range
starts below zero the resting curve has no reference point without it.
"""
if hi <= lo:
return [(lo, f"{lo:g}")]
step = (hi - lo) / max(1, count - 1)
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
ticks = [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
if lo <= 0.0 <= hi and all(abs(value) > abs(step) * 1e-6 for value, _ in ticks):
ticks.append((0.0, "0"))
ticks.sort(key=lambda tick: tick[0])
return ticks
def _ordered_range(lower: float, upper: float) -> tuple[float, float]:
"""A usable ``(ymin, ymax)``: an empty or inverted range is widened upward.
Nothing stops an operator from asking for FN 5…2 N in the dialog, and an
inverted range collapses the axis in pyqtgraph. Widening keeps the chart
readable while the dialog keeps showing what was asked for.
"""
lower = float(lower)
upper = float(upper)
if upper <= lower:
upper = lower + max(0.1, abs(lower) * 0.05)
return lower, upper
def format_force(
@@ -133,8 +161,12 @@ class ForcePlotsPanel(QFrame):
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
# One range per channel, per unit. FN is asymmetric and its lower bound
# may be negative; FX/FY take one absolute magnitude each.
self._fn_min = 0.0
self._fn_max = 20.0
self._shear_max = 20.0
self._fn_raw_min = 0.0
self._fn_raw_max = 1000000.0
self._shear_raw_max = 500000.0
self._fixed_newton = True
@@ -142,8 +174,13 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for Finger raw value cards.
#: Display-only baseline / deadband for the value cards *and* the curves.
self.presentation = ForcePresentation(enabled=False)
#: The operator's master switch for that baseline. It is owned by
#: ``display_prefs`` (the ``raw_auto_zero`` preference) and kept here so a
#: caller that does not know it cannot switch the aid off (see
#: ``configure_presentation``).
self._presentation_enabled = False
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
@@ -261,6 +298,10 @@ class ForcePlotsPanel(QFrame):
self.plots = []
self.curves = []
self.cursors = []
#: Horizontal line at y = 0, one per channel: the origin the axis labels
#: alone could not give, and what keeps the idle FN curve off the subplot
#: border it shares with FX.
self.zero_lines = []
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0)
@@ -283,6 +324,14 @@ class ForcePlotsPanel(QFrame):
)
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
plot.addItem(cursor, ignoreBounds=True)
zero_line = pg.InfiniteLine(
pos=0.0,
angle=0,
movable=False,
pen=pg.mkPen(color(theme, "text_muted"), width=1),
)
zero_line.setZValue(-10)
plot.addItem(zero_line, ignoreBounds=True)
# ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
# also fires when a resize propagates through the X links, which is
@@ -291,6 +340,7 @@ class ForcePlotsPanel(QFrame):
self.plots.append(plot)
self.curves.append(curve)
self.cursors.append(cursor)
self.zero_lines.append(zero_line)
# Whole-session overview: the full envelope plus the current window.
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
@@ -441,6 +491,8 @@ class ForcePlotsPanel(QFrame):
self.overview_region.setBrush(_window_brush(theme))
for cursor in (*self.cursors, self.overview_cursor):
cursor.setPen(pg.mkPen(muted, width=1))
for zero_line in self.zero_lines:
zero_line.setPen(pg.mkPen(muted, width=1))
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
@@ -473,31 +525,77 @@ class ForcePlotsPanel(QFrame):
def set_axis_limits(
self,
*,
fn_min: float,
fn_max: float,
shear_max: float,
fn_raw_max: float | None = None,
shear_raw_max: float | None = None,
fn_raw_min: float,
fn_raw_max: float,
shear_raw_max: float,
) -> None:
"""The Y range of every channel, per display unit.
FN is asymmetric — ``fn_min`` may be negative, which is what gives the
axis a visible origin below the resting curve — while FX/FY each take one
absolute magnitude and are drawn symmetrically around zero.
"""
self._fn_min = float(fn_min)
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
if fn_raw_max is not None:
self._fn_raw_max = max(0.0, float(fn_raw_max))
if shear_raw_max is not None:
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._fn_raw_min = float(fn_raw_min)
self._fn_raw_max = max(0.0, float(fn_raw_max))
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._apply_axis_mode()
def set_deadband_floors(self, *, newton: float | None = None, raw: float | None = None) -> None:
"""The operator's display deadband floors; 0 means "auto (estimated)".
A floor, not a replacement: the estimate from the quiet window still
applies underneath, so this is how an operator widens the zero zone (or
pins one when the calibration data is suspect) without re-fitting.
"""
if newton is not None:
value = max(0.0, float(newton))
self.presentation.set_min_deadband(
"N", value if value > 0.0 else DEFAULT_MIN_DEADBAND_N
)
if raw is not None:
value = max(0.0, float(raw))
self.presentation.set_min_deadband(
"raw", value if value > 0.0 else DEFAULT_MIN_DEADBAND_RAW
)
self._render_value_cards()
def set_presentation_enabled(self, enabled: bool) -> None:
"""The operator's master switch for the display baseline and deadband."""
self._presentation_enabled = bool(enabled)
self.presentation.set_enabled(self._presentation_enabled, unit=self._unit)
# Switching the aid off must show the measurement again, and switching it
# on must show the policy — from the same values the curve is drawn with,
# or a card and its own curve would disagree for a frame.
self._refresh_display_values()
self._render_value_cards()
def set_fixed_newton(self, enabled: bool) -> None:
self._fixed_newton = bool(enabled)
self._apply_axis_mode()
def reset(self) -> None:
"""Back to the view defaults: empty curves, follow mode, unit ``raw``.
The display baseline is deliberately *kept*: a view reset is not a
statement about the device, and the operator's zero estimate (or its
absence) survives it. A new session restarts the estimate through
``configure_presentation``.
"""
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._display_values = (0.0, 0.0, 0.0)
self._display_suppressed = (False, False, False)
self._available = (True, True, True)
self.presentation.reset_session(unit=self._unit)
self._unit = "raw"
self.presentation.unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
self._cursor_t = 0.0
@@ -519,20 +617,29 @@ class ForcePlotsPanel(QFrame):
unit: str,
enabled: bool | None = None,
) -> None:
"""Enable Finger-raw auto baseline for value cards only."""
"""Point the display baseline at the sensor and unit now being shown.
The aid is for every sensor type and both units — an idle channel that
reads a raw count or an offset in newtons is a display problem whichever
device produced it — so the sensor type only names what is drawn.
``enabled`` sets the master switch directly when given; the operator's
``raw_auto_zero`` preference is what the run-start call site passes.
Omitting it leaves the current switch alone.
"""
self._sensor_type = str(sensor_type or "box")
use = (
bool(enabled)
if enabled is not None
else (self._sensor_type == "finger" and unit != "N")
)
self.presentation.enabled = use
if enabled is not None:
self._presentation_enabled = bool(enabled)
self.presentation.enabled = bool(self._presentation_enabled)
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display cards only)."""
"""Restart the idle baseline window (display only: cards and curves)."""
self.presentation.request_reestimate()
self._display_values = self._last_values
# A restarted window starts from nothing, so the cards go back to the
# measurement for the couple of samples it takes to re-learn the offset
# — derived by the same call the curve uses, never left stale.
self._refresh_display_values()
self._render_value_cards()
def observe_for_baseline(
@@ -543,8 +650,13 @@ class ForcePlotsPanel(QFrame):
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
unit: str | None = None,
) -> None:
"""Feed the startup estimator; curves keep the raw measurement."""
"""Feed the startup estimator in the unit these values are expressed in.
The curve and the record keep the raw measurement; the estimate is only
ever a display copy.
"""
if not self.presentation.enabled:
return
self.presentation.observe(
@@ -553,6 +665,7 @@ class ForcePlotsPanel(QFrame):
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
unit=unit,
)
def push(
@@ -596,6 +709,9 @@ class ForcePlotsPanel(QFrame):
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
# The values are in ``unit``, and the estimate is kept per unit, so
# the sample must be filed under the unit it was measured in.
unit=unit,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
@@ -617,8 +733,9 @@ class ForcePlotsPanel(QFrame):
if state is not None:
self._syncing = True
try:
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
finally:
self._syncing = False
@@ -648,7 +765,10 @@ class ForcePlotsPanel(QFrame):
return
self._syncing = True
try:
values = state.values.copy()
# ...and draw the *display* copy of it, so a resting channel is a
# flat line on zero. ``history`` — and the session store and
# forces.csv behind it — keep the measurement.
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
@@ -661,6 +781,7 @@ class ForcePlotsPanel(QFrame):
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
self._sync_zero_lines()
finally:
self._syncing = False
# The buffer already carries session-relative time, so the cursor is the
@@ -686,6 +807,9 @@ class ForcePlotsPanel(QFrame):
if unit == self._unit:
return
self._unit = unit
# The display policy keeps one estimate per unit, so it has to know which
# one is on screen: a stale "raw" is ready does not mean newtons are.
self.presentation.unit = unit
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
# is in the same session-relative base, so the X axis must not restart.
self.history.clear()
@@ -696,6 +820,17 @@ class ForcePlotsPanel(QFrame):
self.refresh_from_store()
else:
self._reload_from_store()
# ``_last_values`` were measured in the unit being left. The
# rebuilt buffer holds the same samples in the unit now being
# shown, so its last row is what the cards describe — otherwise
# the card would carry the old unit's numbers while its own curve
# is drawn from the new one.
state = self.history.state()
if state is not None and len(state.values):
self._last_values = tuple(
float(value) for value in state.values[-1]
)
self._refresh_display_values()
self._render_value_cards()
self._apply_axis_mode()
# Re-read the point under the cursor in the new unit: while browsing, or
@@ -735,6 +870,7 @@ class ForcePlotsPanel(QFrame):
scale.
"""
self._unit = unit
self.presentation.unit = unit
self.history.clear()
self._clear_curves()
self._overview_dirty = True
@@ -826,9 +962,14 @@ class ForcePlotsPanel(QFrame):
for curve in self.curves:
curve.setData([], [])
else:
# The read is of the *measured* values (the session store or the
# recording); presenting them here is what keeps a browsed window
# looking like the live one — the record itself is untouched.
presented = self._curve_values(window.values)
mask = plottable_axis_mask(presented, window.valid)
for index, curve in enumerate(self.curves):
times, values = window.plottable_axis(index)
curve.setData(times, values)
column = mask[:, index]
curve.setData(window.times[column], presented[column, index])
self._availability_from_window(window)
for plot in self.plots:
plot.setXRange(t0, t1, padding=0.0)
@@ -962,6 +1103,14 @@ class ForcePlotsPanel(QFrame):
self._available = tuple(
bool(valid and np.isfinite(value)) for value in values
)
# The cards describe this point, so they carry the display copy of it —
# including while browsing or replaying, where the values did not come
# from the live sample the last ``push`` presented.
presented, suppressed = self.presentation.present(
np.asarray([values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in presented[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
self._render_value_cards()
def _update_selection_readout(self) -> None:
@@ -1056,6 +1205,10 @@ class ForcePlotsPanel(QFrame):
buckets=self._overview_points(), unit=self._unit
)
values = np.asarray(window.values, dtype=float)
# The envelope is drawn from the same store measurement as the window
# above, so it goes through the same presentation: the overview thumb
# must not show a jittering baseline the main chart has flattened.
values = self._curve_values(values)
column = values[:, 0]
finite = np.isfinite(column)
self.overview_curve.setData(window.times[finite], column[finite])
@@ -1164,8 +1317,9 @@ class ForcePlotsPanel(QFrame):
state = self.history.state()
if state is None:
return
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
def _availability_from_window(self, window) -> None:
@@ -1194,12 +1348,20 @@ class ForcePlotsPanel(QFrame):
return self._fn_raw_max > 0.0 and self._shear_raw_max > 0.0
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
"""The configured Y range of one channel, in the active unit.
FN's lower bound may be negative — that is the "below zero threshold"
the operator asked for, and it is what keeps the resting curve off the
frame it shares with FX.
"""
if self._unit == "N":
fn_max, shear_max = self._fn_max, self._shear_max
fn_min, fn_max, shear_max = self._fn_min, self._fn_max, self._shear_max
else:
fn_max, shear_max = self._fn_raw_max, self._shear_raw_max
fn_min = self._fn_raw_min
fn_max = self._fn_raw_max
shear_max = self._shear_raw_max
if channel_index == 0:
return 0.0, fn_max
return _ordered_range(fn_min, fn_max)
return -shear_max, shear_max
def _preview_fixed_y(self) -> bool:
@@ -1223,6 +1385,49 @@ class ForcePlotsPanel(QFrame):
if self._mode == "follow":
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
self._sync_zero_lines()
def _sync_zero_lines(self) -> None:
"""Show the zero line only where zero is inside the visible range.
pyqtgraph does not clip a layout item to its own view box, so a line at
0 painted while the axis sits entirely above it would be drawn across the
neighbouring subplot.
"""
for zero_line, plot in zip(self.zero_lines, self.plots):
try:
ymin, ymax = plot.getViewBox().viewRange()[1]
except Exception: # noqa: BLE001 - a view box mid-construction
continue
zero_line.setVisible(bool(ymin <= 0.0 <= ymax))
def _curve_values(self, values) -> np.ndarray:
"""The values the curves draw: the measurement through the display policy.
``history``, the session store and ``forces.csv`` keep the measurement;
the baseline and deadband are applied here, on the way to the plot only.
That is what makes a resting channel a flat line at exactly zero without
the record being edited. Browsing and a replay read the store, so they go
through this same presentation — the curves show the operator's display
policy, the store shows what was measured.
"""
displayed, _suppressed = self.presentation.present(values, unit=self._unit)
return displayed
def _refresh_display_values(self) -> None:
"""Re-derive the card values from the policy the curves are drawn with.
``push`` fills these from :meth:`ForcePresentation.accept`, whose
hysteresis is what keeps a *live* reading steady. Every other path — a
re-estimate, the master switch, a unit switch — goes through the same
stateless :meth:`ForcePresentation.present` the curve uses, so a card can
never state a value its own curve does not carry.
"""
displayed, suppressed = self.presentation.present(
np.asarray([self._last_values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in displayed[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
def _clear_curves(self) -> None:
for curve in self.curves:
@@ -1252,7 +1457,13 @@ class ForcePlotsPanel(QFrame):
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled and self.presentation.ready:
elif self.presentation.enabled:
# The policy is *live from the first samples*: there is no
# separate "the estimate is ready" gate here. Such a gate is what
# let a card read FN 1118 raw while its own curve was already flat
# on zero, because the card fell back to the raw measurement and
# the curve did not. Both are ``_display_values`` / ``present()``
# of the same applied estimate.
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
@@ -1273,7 +1484,6 @@ class ForcePlotsPanel(QFrame):
zero_zone = (
selecting is None
and self.presentation.enabled
and self.presentation.ready
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
@@ -1,7 +1,8 @@
"""Modal dialog for the force-plot window, Y-axis limits and the filters.
"""Modal dialog for the force-plot window, Y-axis limits, deadband and 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.
time and range the curves show (per channel, and the display deadband that holds
a resting channel at zero), 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``).
@@ -33,12 +34,23 @@ from orisys_app_shared.force.filters import (
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
#: Lower bounds. FN is allowed below zero: a resting normal force has no reason
#: to sit exactly on the frame, and with the lower bound at 0 the idle curve is
#: drawn along the subplot border that abuts the FX frame (see force_plots).
DEFAULT_PLOT_FN_MIN = 0.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
# stays sub-pixel while a full contact still fits (see prefs store).
DEFAULT_PLOT_FN_RAW_MIN = 0.0
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
#: Display deadband floors ("归零阈值") in the unit each applies to. 0 means
#: "auto": the percentile the presentation layer estimates from the quiet
#: startup window. Non-zero forces a floor under that estimate, so an operator
#: can widen the zero zone (or pin one when calibration data is suspect).
DEFAULT_PLOT_DEADBAND_N = 0.0
DEFAULT_PLOT_DEADBAND_RAW = 0.0
class PlotSettingsDialog(QDialog):
@@ -47,10 +59,14 @@ class PlotSettingsDialog(QDialog):
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_min: float = DEFAULT_PLOT_FN_MIN,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
fn_raw_min: float = DEFAULT_PLOT_FN_RAW_MIN,
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
deadband_n: float = DEFAULT_PLOT_DEADBAND_N,
deadband_raw: float = DEFAULT_PLOT_DEADBAND_RAW,
units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
@@ -58,7 +74,7 @@ class PlotSettingsDialog(QDialog):
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(420, 520)
self.resize(460, 640)
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.
@@ -75,6 +91,15 @@ class PlotSettingsDialog(QDialog):
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
# FN is asymmetric: a lower bound that may be negative, and an upper one.
self.fn_min_label = QLabel()
self.fn_min_spin = QDoubleSpinBox()
self.fn_min_spin.setRange(-1000.0, 1000.0)
self.fn_min_spin.setSingleStep(1.0)
self.fn_min_spin.setDecimals(1)
self.fn_min_spin.setSuffix(" N")
self.fn_min_spin.setValue(float(fn_min))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
@@ -91,6 +116,13 @@ class PlotSettingsDialog(QDialog):
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
self.fn_raw_min_label = QLabel()
self.fn_raw_min_spin = QDoubleSpinBox()
self.fn_raw_min_spin.setRange(-1e9, 1e9)
self.fn_raw_min_spin.setSingleStep(50000.0)
self.fn_raw_min_spin.setDecimals(0)
self.fn_raw_min_spin.setValue(float(fn_raw_min))
# Raw limits: 0 means "auto range on the data".
self.fn_raw_max_label = QLabel()
self.fn_raw_max_spin = QDoubleSpinBox()
@@ -106,22 +138,48 @@ class PlotSettingsDialog(QDialog):
self.shear_raw_max_spin.setDecimals(0)
self.shear_raw_max_spin.setValue(float(shear_raw_max))
# The display deadband: a *display* aid, per unit. It never reaches the
# store or forces.csv (see force/presentation.py).
self.deadband_n_label = QLabel()
self.deadband_n_spin = QDoubleSpinBox()
self.deadband_n_spin.setRange(0.0, 100.0)
self.deadband_n_spin.setSingleStep(0.1)
self.deadband_n_spin.setDecimals(2)
self.deadband_n_spin.setSuffix(" N")
self.deadband_n_spin.setValue(float(deadband_n))
self.deadband_raw_label = QLabel()
self.deadband_raw_spin = QDoubleSpinBox()
self.deadband_raw_spin.setRange(0.0, 1e9)
self.deadband_raw_spin.setSingleStep(5000.0)
self.deadband_raw_spin.setDecimals(0)
self.deadband_raw_spin.setValue(float(deadband_raw))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_min_label, self.fn_min_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
form.addRow(self.fn_raw_min_label, self.fn_raw_min_spin)
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
form.addRow(self.deadband_n_label, self.deadband_n_spin)
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
raw_widgets = (
self.fn_raw_min_spin,
self.fn_raw_max_spin,
self.shear_raw_max_spin,
self.deadband_raw_spin,
self.fn_raw_min_label,
self.fn_raw_max_label,
self.shear_raw_max_label,
self.deadband_raw_label,
)
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
self.shear_max_label):
for widget in (self.fn_min_spin, self.fn_max_spin, self.shear_max_spin,
self.deadband_n_spin, self.fn_min_label, self.fn_max_label,
self.shear_max_label, self.deadband_n_label):
widget.setEnabled(newton)
for widget in raw_widgets:
widget.setEnabled(not newton)
@@ -330,6 +388,7 @@ class PlotSettingsDialog(QDialog):
self.filter_note.setText("")
def values(self) -> tuple[float, float, float, float, float]:
"""The original five-values shape, kept for the existing caller."""
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
@@ -338,12 +397,41 @@ class PlotSettingsDialog(QDialog):
float(self.shear_raw_max_spin.value()),
)
def ranges(self) -> dict[str, float]:
"""Every axis limit, keyed the way the preference names it."""
return {
"window_sec": float(self.window_spin.value()),
"fn_min": float(self.fn_min_spin.value()),
"fn_max": float(self.fn_max_spin.value()),
"shear_max": float(self.shear_max_spin.value()),
"fn_raw_min": float(self.fn_raw_min_spin.value()),
"fn_raw_max": float(self.fn_raw_max_spin.value()),
"shear_raw_max": float(self.shear_raw_max_spin.value()),
}
def deadbands(self) -> dict[str, float]:
"""The per-unit display deadband floors. 0 means "auto" (estimated)."""
return {
"deadband_n": float(self.deadband_n_spin.value()),
"deadband_raw": float(self.deadband_raw_spin.value()),
}
def settings(self) -> dict[str, float]:
"""Everything the plot half of the dialog decides, in one mapping."""
return {**self.ranges(), **self.deadbands()}
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
# "Auto" is the shipped answer for the deadband: the estimate from the
# quiet startup window, with the layer's own floor under it.
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
self.deadband_raw_spin.setValue(DEFAULT_PLOT_DEADBAND_RAW)
# "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)
@@ -358,14 +446,22 @@ class PlotSettingsDialog(QDialog):
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_min_label.setText(t("dialog.plot_fn_min"))
self.fn_min_spin.setToolTip(t("dialog.plot_fn_min_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.fn_raw_min_label.setText(t("dialog.plot_fn_raw_min"))
self.fn_raw_min_spin.setToolTip(t("dialog.plot_fn_raw_min_tip"))
self.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
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.deadband_n_label.setText(t("dialog.plot_deadband_n"))
self.deadband_n_spin.setToolTip(t("dialog.plot_deadband_n_tip"))
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_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"))
+132 -70
View File
@@ -1,8 +1,15 @@
"""Top of the studio shell: status rail and a collapsible settings area.
The rail is what an operator watches while a session runs — phase, device,
calibration, data validity and the record channel — plus the three actions that
must never be more than one click away (record, run/stop, reset).
calibration, data validity and the record channel — plus the actions that must
never be more than one click away (record, run/pause, stop, reset).
The transport is the *same* control in both views (``btn_run``): a play/pause
toggle whose checked state means "acquiring / playing and not paused". A session
that is stopped and a session that is paused both leave the button unchecked, so
the session phase — not the button — is what tells the two apart (see
``_refresh_run_label``). Stopping is its own button (``btn_stop``), because
releasing the device is a different act from pausing the computation.
Everything that is *configuration* rather than state — source, camera number,
video file, units and the sensor/config pickers — lives in the settings area,
@@ -81,7 +88,10 @@ class ToolbarWidget(QWidget):
replay_video_clicked = Signal()
#: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = Signal()
#: The transport: True when a live session should acquire, or a replay play.
run_toggled = Signal(bool)
#: Stop: end the live session (releasing the device), or leave the replay.
stop_clicked = Signal()
reset_clicked = Signal()
units_changed = Signal(str)
record_toggled = Signal(bool)
@@ -109,7 +119,6 @@ class ToolbarWidget(QWidget):
self._video_path = ""
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
self._run_role = "acquire"
self._reset_role = "reset"
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
@@ -242,6 +251,16 @@ class ToolbarWidget(QWidget):
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
# Stop is a text button beside the transport, like Run — the icon-only pause
# it replaces cost the same rail width while saying nothing about what it
# stopped. It carries no minimum width, so the rail can shrink it at 1280
# (``tools/ui_check.py`` checks the rail for elision in both locales).
self.btn_stop = QPushButton()
self.btn_stop.setObjectName("dangerOutline")
self.btn_stop.setMinimumHeight(BUTTON_HEIGHT)
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self.stop_clicked.emit)
self.btn_reset = QPushButton()
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False)
@@ -266,6 +285,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(self.record_chip)
rail.addWidget(_rail(self.btn_record))
rail.addWidget(_rail(self.btn_run))
rail.addWidget(_rail(self.btn_stop))
rail.addWidget(_rail(self.btn_reset))
rail.addWidget(_rail(self.btn_settings))
return rail
@@ -386,7 +406,11 @@ class ToolbarWidget(QWidget):
self._apply_source_mode_ui()
def set_run_role(self, role: str) -> None:
"""What the primary button does in this view: acquire, or play back."""
"""What the primary button does in this view: acquire, or play back.
The same button is the transport of both views, so the *labels* follow the
role; only the words change (运行/暂停/继续 against 播放/暂停/继续).
"""
wanted = "playback" if str(role) == "playback" else "acquire"
if wanted == self._run_role:
return
@@ -397,38 +421,11 @@ class ToolbarWidget(QWidget):
self.btn_run.setChecked(False)
self.btn_run.blockSignals(False)
self._refresh_run_label()
self._refresh_stop_label()
def run_role(self) -> str:
return self._run_role
def set_reset_role(self, role: str) -> None:
"""What the second rail button does: zero the tracker, or stop the replay.
The replay view needs a *stop* the operator can always reach. The transport
button is a play/pause toggle, which is ambiguous precisely when something is
wrong (is it playing? is a recompute pass running?), so the second button —
disabled in that view otherwise — becomes an unconditional stop instead of a
second thing to reason about.
"""
wanted = "stop" if str(role) == "stop" else "reset"
if wanted == self._reset_role:
return
self._reset_role = wanted
if wanted == "reset":
self.btn_reset.setEnabled(False)
self._refresh_reset_label()
def reset_role(self) -> str:
return self._reset_role
def _refresh_reset_label(self) -> None:
if self._reset_role == "stop":
self.btn_reset.setText(t("toolbar.stop_playback"))
self.btn_reset.setToolTip(t("toolbar.stop_playback_tip"))
return
self.btn_reset.setText(t("toolbar.reset"))
self.btn_reset.setToolTip("")
def source_view(self) -> str:
return self._source_view
@@ -467,6 +464,12 @@ class ToolbarWidget(QWidget):
self.refresh_labels()
def set_run_checked(self, checked: bool) -> None:
"""Follow the session's transport state without re-emitting the signal.
The window calls this to *state* what the session is doing (a replay began,
a session ended, a recompute pass stopped). An operator's own click goes
through the button instead, and is routed by ``_on_run_toggled``.
"""
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
@@ -474,6 +477,14 @@ class ToolbarWidget(QWidget):
if not checked:
self.btn_reset.setEnabled(False)
def set_stop_enabled(self, enabled: bool) -> None:
"""Stop is offered for as long as there is something to end.
That includes a device open that has not returned yet: the stop button is
where the cancel-a-hung-connect escape hatch lives now.
"""
self.btn_stop.setEnabled(bool(enabled))
def set_reset_enabled(self, enabled: bool) -> None:
self.btn_reset.setEnabled(bool(enabled))
@@ -517,7 +528,13 @@ class ToolbarWidget(QWidget):
self._apply_run_tooltip()
def set_run_phase(self, phase: str) -> None:
"""Track the session phase so Stop can advertise itself while connecting."""
"""Track the session phase.
The phase is half of what the transport label means: unchecked *with* a
live session is paused, unchecked without one is idle (see
``_refresh_run_label``), and ``connecting`` is what the stop button
advertises itself as cancel for.
"""
self._run_phase = str(phase or "idle")
self.refresh_labels()
@@ -531,6 +548,7 @@ class ToolbarWidget(QWidget):
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
self._refresh_preview_label()
self._refresh_run_label()
self._refresh_stop_label()
# ------------------------------------------------------------- internals
def _populate_units_combo(self) -> None:
@@ -636,9 +654,15 @@ class ToolbarWidget(QWidget):
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
"""The transport: the label follows the button, the *state* does not.
Nothing about a session is decided here — the window routes the click (§
``_on_run_toggled`` there) and states the result back through
``set_run_checked``. Reset is deliberately left alone: unchecking now means
pause as often as it means stop, and pausing must not disable a control the
still-running session owns.
"""
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
@@ -650,28 +674,52 @@ class ToolbarWidget(QWidget):
else:
self.btn_preview.setText(t("toolbar.preview"))
def _session_running(self) -> bool:
"""Whether a *live* session exists in this view (connecting or acquiring).
This is the third transport state the two-state button cannot carry: with
the button unchecked it is what separates "paused" from "not started".
"""
return self._run_role == "acquire" and self._run_phase in ("connecting", "running")
def _refresh_run_label(self) -> None:
if self._run_role == "playback":
# The primary action of the replay view is playing the recording —
# the same button, doing what this view is for.
# the same button, doing what this view is for. Unchecked with nothing
# loaded is idle, and the button is disabled with the reason in its tip.
self.btn_run.setText(
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
)
self._refresh_emphasis()
self._apply_run_tooltip()
return
if self.btn_run.isChecked():
# While connecting, cancel is the action the user actually needs —
# a device open can hang, and this is the way out of it.
if self._run_phase == "connecting":
self.btn_run.setText(t("toolbar.cancel_label"))
else:
self.btn_run.setText(t("toolbar.stop_label"))
elif self.btn_run.isChecked():
# checked = acquiring and not paused, so the action is Pause.
self.btn_run.setText(t("toolbar.pause"))
elif self._session_running():
# unchecked *with* a live session is the paused state: the device is
# open and the computation is stopped, so the action is Resume.
self.btn_run.setText(t("toolbar.resume_label"))
else:
self.btn_run.setText(t("toolbar.run_label"))
self._refresh_emphasis()
self._apply_run_tooltip()
def _refresh_stop_label(self) -> None:
"""Stop is one button for two situations, and its label says which.
A device open can hang, and the way out of it is this button: while the
session is still connecting it advertises itself as cancel, because that
is the act the operator needs. In the replay view it is the way out of the
replay (or a recompute pass), whatever state the transport is in.
"""
if self._run_role == "playback":
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop_playback_tip"))
elif self._run_phase == "connecting":
self.btn_stop.setText(t("toolbar.cancel_label"))
self.btn_stop.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop"))
def _refresh_emphasis(self) -> None:
"""Exactly one *filled* button at a time.
@@ -681,31 +729,36 @@ class ToolbarWidget(QWidget):
* not recording yet → the record action is the filled primary;
* recording → stopping the recording is the filled danger;
* connecting → cancel is the filled danger (it is the way out of a hang).
* connecting → cancel (on the stop button) is the filled danger, since it
is the way out of a hang;
* a live session acquiring or paused → the transport is an outline: pause,
resume and stop all change the session's state rather than being the one
action the rail is pointing at.
The replay's transport is the exception: it *is* what that view is for, so
it is filled while it plays and an outline while it is idle.
"""
recording = self.btn_record.isChecked()
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
playing = self.btn_run.isChecked()
if self._run_role == "playback":
# The transport is the replay view's own action, not a session stop:
# filled while it plays, an outline when idle, and never red.
self.btn_record.setObjectName(
"dangerButton" if recording else "primaryButton"
)
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
elif recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("dangerOutline")
# Never red: the replay's stop is an outline beside a filled transport.
self.btn_record.setObjectName("dangerButton" if recording else "primaryButton")
self.btn_run.setObjectName("primaryButton" if playing else "ghostButton")
self.btn_stop.setObjectName("dangerOutline")
else:
self.btn_record.setObjectName("primaryButton")
if running and self._run_phase != "connecting":
self.btn_run.setObjectName("dangerOutline")
elif running:
self.btn_run.setObjectName("dangerButton")
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
connecting = self._run_phase == "connecting"
self.btn_stop.setObjectName("dangerButton" if connecting else "dangerOutline")
if recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("ghostButton")
else:
self.btn_run.setObjectName("primaryButton")
for button in (self.btn_record, self.btn_run):
self.btn_record.setObjectName("primaryButton")
self.btn_run.setObjectName(
"ghostButton" if playing or self._session_running() else "primaryButton"
)
for button in (self.btn_record, self.btn_run, self.btn_stop):
button.style().unpolish(button)
button.style().polish(button)
@@ -723,10 +776,12 @@ class ToolbarWidget(QWidget):
self.btn_run.setToolTip(t(key))
return
if self.btn_run.isChecked():
if self._run_phase == "connecting":
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_run.setToolTip(t("toolbar.stop"))
# The tip is where the pause semantics are stated: computing stops, the
# picture holds, the device stays open — and a recording that spans the
# pause has a gap.
self.btn_run.setToolTip(t("toolbar.pause_tip"))
elif self._session_running():
self.btn_run.setToolTip(t("toolbar.resume_label_tip"))
elif not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif not self._run_available:
@@ -737,6 +792,13 @@ class ToolbarWidget(QWidget):
tip = f"{tip} — {self._run_tooltip_reason}"
self.btn_run.setToolTip(tip)
def _refresh_reset_label(self) -> None:
self.btn_reset.setText(t("toolbar.reset"))
# The button zeroes the *device* tracker; it no longer clears the curves.
# That is now 视图 → 复位视图, because one button doing both meant an
# operator who wanted a fresh baseline also lost the history on screen.
self.btn_reset.setToolTip(t("toolbar.reset_tip"))
def retranslate(self) -> None:
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_search.setText(t("toolbar.search_cameras"))
+3 -3
View File
@@ -279,10 +279,10 @@ class ViewLayoutController(QObject):
self._replay.pause_playback()
self._toolbar.set_source_view("replay" if preset == "replay" else "live")
# The rail's primary button is this view's main action: acquire while
# measuring, play back while replaying; and the second button is that view's
# own reset — or, in the replay view, an unconditional stop.
# measuring, play back while replaying. The button beside it is the same in
# both views — Stop, which ends a session or leaves a replay — and the
# tracker reset is simply disabled wherever there is no device session.
self._toolbar.set_run_role("playback" if preset == "replay" else "acquire")
self._toolbar.set_reset_role("stop" if preset == "replay" else "reset")
self.apply_panel_visibility()
if apply_sizes and preset in self._splitter_sizes:
self._splitter.setSizes(self.splitter_sizes_for(preset))
+79 -4
View File
@@ -12,8 +12,10 @@ from orisys_studio.display_prefs import DisplayPrefsController
PLOT_DEFAULTS = {
"window_sec": 10.0,
"fn_min": 0.0,
"fn_max": 5.0,
"shear_max": 5.0,
"fn_raw_min": 0.0,
"fn_raw_max": 1e5,
"shear_raw_max": 1e5,
}
@@ -46,7 +48,10 @@ class _Charts:
self.fixed_newton: list[bool] = []
self.units: list[str] = []
self.window_sec: list[float] = []
self.limits: list[tuple] = []
self.limits: list[dict] = []
self.deadband_floors: list[dict] = []
self.presentation_enabled: list[bool] = []
self.presentation: list[dict] = []
def unit(self) -> str:
return self._unit
@@ -61,8 +66,17 @@ class _Charts:
def set_window_sec(self, value: float) -> None: # noqa: N802
self.window_sec.append(float(value))
def set_axis_limits(self, *values) -> None: # noqa: N802
self.limits.append(tuple(float(v) for v in values))
def set_axis_limits(self, **values) -> None: # noqa: N802
self.limits.append({key: float(value) for key, value in values.items()})
def set_deadband_floors(self, *, newton: float, raw: float) -> None: # noqa: N802
self.deadband_floors.append({"newton": float(newton), "raw": float(raw)})
def configure_presentation(self, **values) -> None: # noqa: N802
self.presentation.append(dict(values))
def set_presentation_enabled(self, enabled: bool) -> None: # noqa: N802
self.presentation_enabled.append(bool(enabled))
class _ArrowPanel:
@@ -197,27 +211,88 @@ def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
settings = controller.plot_settings()
assert settings["window_sec"] == 3.0
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
assert settings["fn_min"] == 0.0, "FN has no lower bound by default"
assert settings["deadband_n"] == 0.0, "the deadband default is 'auto'"
assert settings["units"] == "newton"
def test_a_stored_zero_is_a_value_and_not_a_missing_preference():
"""0 means "auto" on the raw bounds and the deadband, so it must survive."""
controller, parts = _controller(prefs={"plot_fn_raw_max": 0.0})
settings = controller.plot_settings()
assert settings["fn_raw_max"] == 0.0, "a stored 0 became the default"
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
controller, parts = _controller(prefs={})
controller.apply_plot_settings(
{
"window_sec": 20.0,
"fn_min": -2.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_min": -1000.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
"deadband_n": 0.25,
"deadband_raw": 0.0,
}
)
assert parts["host"].prefs["plot_window_sec"] == 20.0
assert parts["host"].prefs["plot_fn_min"] == -2.0
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
assert parts["host"].prefs["plot_deadband_n"] == 0.25
assert parts["charts"].window_sec == [20.0]
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
assert parts["charts"].limits[-1] == {
"fn_min": -2.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_min": -1000.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
}
# 0 on a deadband floor means "auto", and the charts are told exactly that.
assert parts["charts"].deadband_floors[-1] == {"newton": 0.25, "raw": 0.0}
assert parts["host"].persisted == 1
def test_a_partial_plot_settings_mapping_leaves_the_rest_as_stored():
"""The older five-value shape must not silently reset a range."""
controller, parts = _controller(prefs={"plot_fn_min": -5.0, "plot_deadband_raw": 900.0})
controller.apply_plot_settings(
{"window_sec": 6.0, "fn_max": 12.0, "shear_max": 3.0,
"fn_raw_max": 4e5, "shear_raw_max": 5e5}
)
assert parts["host"].prefs["plot_fn_min"] == -5.0
assert parts["charts"].limits[-1]["fn_min"] == -5.0
assert parts["charts"].deadband_floors[-1]["raw"] == 900.0
def test_the_display_baseline_is_not_a_finger_only_setting():
"""Every sensor type and both units: an idle newton offset is the same problem."""
for sensor, units in (("box", "newton"), ("box", "raw"),
("finger", "newton"), ("finger", "raw")):
controller, parts = _controller(prefs={"raw_auto_zero": True},
sensor_type=sensor, units=units)
assert parts["charts"].presentation_enabled[-1] is True, (sensor, units)
assert parts["charts"].presentation[-1]["unit"] == (
"N" if units == "newton" else "raw"
)
controller, parts = _controller(prefs={"raw_auto_zero": False}, sensor_type="box",
units="newton")
assert parts["charts"].presentation_enabled[-1] is False
def test_the_deadband_floors_reach_the_charts_from_the_preferences():
controller, parts = _controller(prefs={"plot_deadband_n": 0.5, "plot_deadband_raw": 2000.0})
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
controller.set_raw_auto_zero(True) # re-applies the whole policy
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
assert controller.device_sample_rate() == 48.5
@@ -0,0 +1,331 @@
"""The force charts as the operator sees them: baseline, deadband and ranges.
The display policy is a *view* concern: an idle channel must read zero and draw a
flat line, while the session store and ``forces.csv`` keep the measurement. These
tests drive the panel on its own (no acquisition, no window) and read back what
the curves and the value cards were actually given.
"""
from __future__ import annotations
import numpy as np
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force
#: The two idle triplets from the customer's screenshots: a raw FN of ~9555 and
#: an FX whose bias sits ~13k counts off zero.
RAW_BIAS = np.array([9555.0, -13368.0, -353.0])
NEWTON_BIAS = np.array([0.4, -0.25, 0.1])
def _panel(unit: str = "raw") -> ForcePlotsPanel:
"""A chart in one unit, with the display baseline switched on."""
panel = ForcePlotsPanel(theme="dark")
panel.set_units(unit)
panel.configure_presentation(sensor_type="box", unit=unit)
panel.set_presentation_enabled(True)
panel.set_fixed_newton(True)
return panel
def _rest(
panel: ForcePlotsPanel,
bias: np.ndarray,
*,
unit: str = "raw",
samples: int = 80,
spread: float = 0.0,
sigma: float = 0.0,
seed: int = 0,
) -> None:
"""Feed a quiet startup window, the way a live session would.
``spread`` is a bounded wander (the device really does sit still) and
``sigma`` is the random jitter of a `noisier` product.
"""
rng = np.random.default_rng(seed)
for index in range(samples):
sample = bias + ((index % 5) - 2) * spread
if sigma:
sample = sample + rng.normal(0.0, sigma, size=3)
panel.push(index / 30.0, *sample, unit=unit)
def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]:
"""The Y tick values actually installed on one axis."""
levels = panel.plots[channel].getAxis("left")._tickLevels
assert levels, "the axis has no explicit ticks"
return [float(value) for value, _text in levels[0]]
def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray:
_times, values = panel.curves[channel].getData()
return np.asarray(values, dtype=float)
# ------------------------------------------------------------ resting channel
def test_the_cards_read_zero_from_the_first_samples_of_a_session(qt_app):
"""The reported defect: FN 1118 raw, FX 34.16, FY -186.1 on the cards.
The screenshot was taken as the session started, so the aid has to be
effective from the first samples — not once the whole quiet window closed.
"""
panel = _panel("raw")
for index in range(4):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
if index < 2:
# Nothing has been estimated from fewer than three samples yet; those
# first frames honestly show the measurement.
continue
assert [label.text() for label in panel.value_labels] == ["0"] * 3, index
assert not panel.presentation.ready, "the whole three second window was needed"
assert panel.presentation.display_quality == "provisional"
for channel in (0, 1, 2):
assert set(np.unique(_curve(panel, channel))) == {0.0}, channel
def test_the_newton_cards_read_zero_from_the_first_samples_too(qt_app):
"""Both units: a newton offset at rest is the same display problem."""
panel = _panel("N")
for index in range(4):
panel.push(index / 30.0, *NEWTON_BIAS, unit="N")
if index < 2:
continue
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3, index
assert not panel.presentation.ready
def test_a_card_and_its_own_curve_are_the_same_value(qt_app):
"""A card may never read a number its curve does not carry.
The extra "the estimate is ready" gate in ``_render_value_cards`` is what
used to let the card show the raw measurement while the curve was already
drawn through the applied estimate.
"""
panel = _panel("raw")
for index in range(4):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
# A real contact, flagged as one so it does not enter the idle estimate.
panel.push(1.0, *(RAW_BIAS + [4000.0, 0.0, 0.0]), unit="raw", contact_found=True)
for channel in range(3):
drawn = float(_curve(panel, channel)[-1])
assert panel.value_labels[channel].text() == format_force(
drawn, "raw", presentation=panel.presentation
), channel
assert panel.value_labels[0].text() not in ("0", "1118"), "the contact was lost"
def test_a_resting_raw_channel_reads_zero_and_draws_a_flat_curve(qt_app):
"""FN 9555 / FX -13368 at rest must read 0 and draw a line on zero."""
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
assert panel.presentation.ready, "the quiet window never closed"
for index, label in enumerate(panel.value_labels):
assert label.text() == "0", (index, label.text())
for channel in (0, 1, 2):
values = _curve(panel, channel)
assert values.size, channel
assert set(np.unique(values)) == {0.0}, (channel, np.unique(values))
def test_a_resting_newton_channel_is_baselined_too(qt_app):
"""The same aid in newtons: it is a display problem in either unit."""
panel = _panel("N")
_rest(panel, NEWTON_BIAS, unit="N")
assert panel.presentation.ready
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3
assert set(np.unique(_curve(panel, 0))) == {0.0}
def test_noise_outside_the_estimated_band_is_still_rare(qt_app):
"""A p99 band, so a resting *noisy* channel is flat but not rigid.
The offset removal is what fixes a 13k-count bias; the percentile band holds
the remaining jitter at zero. A few idle samples may sit just outside it —
that is what the operator's deadband floor in the dialog is for.
"""
panel = _panel("raw")
_rest(panel, RAW_BIAS, sigma=30.0)
assert panel.presentation.ready
values = _curve(panel, 1)
zeroed = float(np.mean(values == 0.0))
assert zeroed >= 0.95, f"only {zeroed:.2%} of a resting channel was zeroed"
assert abs(float(np.median(values))) < 1.0
def test_the_record_keeps_the_measurement_while_the_display_is_zeroed(qt_app):
"""The deadband is a display copy: the buffer and the store see the truth."""
panel = _panel("raw")
store = SessionStore()
panel.set_store(store)
for index in range(6):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
store.append(
SessionSample(
index,
index / 30.0,
fn_raw=RAW_BIAS[0],
fx_raw=RAW_BIAS[1],
fy_raw=RAW_BIAS[2],
fn_n=NEWTON_BIAS[0],
fx_n=NEWTON_BIAS[1],
fy_n=NEWTON_BIAS[2],
valid=True,
)
)
# The very values the customer complained about are still measured, from the
# first sample on — in the live buffer...
state = panel.history.state()
assert state is not None
assert abs(float(np.median(state.values[:, 0])) - RAW_BIAS[0]) < 100.0
assert abs(float(np.median(state.values[:, 1])) - RAW_BIAS[1]) < 100.0
# ...and in the session store (which is what forces.csv is written from).
stored = store.data().raw
assert stored.shape[0] == 6
assert float(np.median(stored[:, 0])) == RAW_BIAS[0]
assert float(np.median(stored[:, 1])) == RAW_BIAS[1]
assert float(np.median(stored[:, 2])) == RAW_BIAS[2]
# ...while the cards and the curve drawn from it are already flat on zero.
assert [label.text() for label in panel.value_labels] == ["0"] * 3
assert set(np.unique(_curve(panel, 1))) == {0.0}
# ------------------------------------------------------------------ deadband
def test_a_small_excursion_is_suppressed_and_a_larger_one_is_not(qt_app):
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
estimate = panel.presentation.last_estimate
assert estimate is not None and estimate.quality == "ok"
deadband = float(estimate.deadbands[0])
assert deadband > 0.0
panel.push(3.0, *(RAW_BIAS + [0.5 * deadband, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() == "0", "half the deadband was drawn"
panel.push(3.1, *(RAW_BIAS + [3.0 * deadband, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() != "0", (
"an excursion well outside the deadband was swallowed"
)
assert abs(_curve(panel, 0)[-1]) > 2.0 * deadband
# ...and the other axes, which stayed quiet, are still zero.
assert panel.value_labels[1].text() == "0"
def test_the_operator_floor_widens_the_deadband_at_once(qt_app):
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
panel.set_deadband_floors(raw=250000.0)
panel.push(3.0, *(RAW_BIAS + [50000.0, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() == "0", (
"an excursion inside the operator's floor was drawn"
)
panel.set_deadband_floors(raw=0.0) # "auto": the layer's own floor returns
assert panel.presentation.min_deadband_for("raw") == 50.0
# -------------------------------------------------------------------- ranges
def test_the_configured_ranges_reach_every_axis(qt_app):
"""FN is asymmetric and may go below zero; FX/FY are symmetric."""
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=-5.0,
fn_max=15.0,
shear_max=3.0,
fn_raw_min=-100000.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
panel.set_fixed_newton(True)
# The panel starts in raw, so the raw limits are the ones on the axes.
assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0]
panel.set_units("N")
assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert panel._fixed_y_range(0) == (-5.0, 15.0)
assert panel._fixed_y_range(1) == (-3.0, 3.0)
# A range that starts below zero carries an explicit 0 tick: that is the
# "missing origin" the customer could not find.
assert 0.0 in _ticks(panel, 0)
panel.set_units("raw")
assert panel._fixed_y_range(0) == (-100000.0, 1000000.0)
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
def test_the_zero_line_is_only_drawn_where_zero_is_inside_the_range(qt_app):
"""pyqtgraph does not clip a layout item to its own view box."""
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=0.0,
fn_max=20.0,
shear_max=3.0,
fn_raw_min=100.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
view_box = panel.plots[0].getViewBox()
assert not panel.zero_lines[0].isVisibleTo(view_box), (
"a zero line was painted on an axis that does not reach zero"
)
assert panel.zero_lines[1].isVisibleTo(panel.plots[1].getViewBox())
assert 0.0 not in _ticks(panel, 0)
# Give FN a negative lower bound and the origin appears.
panel.set_axis_limits(
fn_min=0.0,
fn_max=20.0,
shear_max=3.0,
fn_raw_min=-100000.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
assert panel.zero_lines[0].isVisibleTo(view_box)
assert 0.0 in _ticks(panel, 0)
def test_an_inverted_range_is_widened_rather_than_collapsing_the_axis(qt_app):
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=5.0,
fn_max=2.0,
shear_max=3.0,
fn_raw_min=0.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
ymin, ymax = panel._fixed_y_range(0)
assert ymax > ymin
def test_the_default_ranges_are_the_ones_they_always_were(qt_app):
"""Defaults preserve today's behaviour: FN 0…20 N, shear ±20 N (and raw)."""
panel = ForcePlotsPanel(theme="dark")
assert panel._fixed_y_range(0) == (0.0, 1000000.0)
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
panel.set_units("N")
panel.set_fixed_newton(True)
assert panel._fixed_y_range(0) == (0.0, 20.0)
assert panel._fixed_y_range(1) == (-20.0, 20.0)
# With the lower bound at 0 nothing changes about the tick list either.
assert _nice_ticks(0.0, 20.0, 5) == [
(0.0, "0"),
(5.0, "5"),
(10.0, "10"),
(15.0, "15"),
(20.0, "20"),
]
+139
View File
@@ -0,0 +1,139 @@
"""The live Pause control: what it stops, and what it deliberately leaves alone.
Pause means "stop computing and stop the picture, keep the camera". It must never be
confused with 停止 (which releases the device) or with the chart's own browse pause, and
it must not survive a session.
It lives on the rail's transport button — the same control the replay view uses to play
and pause a recording. ``checked`` means "acquiring and not paused", so unchecking the
button *while a session exists* is the pause, and checking it again is the resume. That
is how one two-state toggle carries a three-state transport: the session state, not the
button, is what tells stopped from paused.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from tests.conftest import finish_run, running_window, show_window
def test_the_transport_is_idle_with_no_session(make_window):
"""Nothing to pause: the button says it runs, and there is no pause to reach."""
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
assert not window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
assert not window.act_pause.isEnabled()
window.close()
def test_unchecking_the_transport_pauses_and_never_stops_the_device(make_window):
window, stuck = running_window(make_window)
try:
assert window.toolbar.btn_run.isChecked(), "the transport does not show acquiring"
assert window.toolbar.btn_run.text() == t("toolbar.pause")
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True
assert stuck.is_paused is True, "the worker was not asked to pause"
assert stuck.pause_requests == 1
assert window.act_pause.isChecked(), "the menu action did not follow the transport"
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
# The device is still open and the session is still the session.
assert window._running and window._session_phase == "running", (
"pausing ended the session instead of holding it"
)
assert "暂停" in window.statusBar().currentMessage() or (
"Paus" in window.statusBar().currentMessage()
)
window.toolbar.btn_run.setChecked(True)
assert window._display_paused is False
assert stuck.is_paused is False, "the worker was not asked to resume"
assert stuck.pause_requests == 2
assert not window.act_pause.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.pause")
finally:
finish_run(window, stuck)
def test_unchecking_the_transport_with_no_session_does_nothing(make_window):
"""The unchecked state only means paused *while a session exists*."""
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
window._on_run_toggled(False)
assert window._display_paused is False
assert not window._running
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
window.close()
def test_the_stop_button_is_what_ends_a_paused_session(make_window):
"""Pause holds the session; only Stop releases the device."""
window, stuck = running_window(make_window)
try:
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True and window._running
assert window.toolbar.btn_stop.isEnabled(), "no way out of the paused session"
window.toolbar.btn_stop.click()
assert not window._running, "Stop did not end the session"
assert window._session_phase == "idle"
finally:
stuck.release()
def test_the_shortcut_drives_the_same_state_as_the_transport(make_window):
"""One authoritative control, so the two can never disagree."""
window, stuck = running_window(make_window)
try:
window.act_pause.setChecked(True)
assert window._display_paused is True
assert stuck.is_paused is True
assert not window.toolbar.btn_run.isChecked(), (
"the transport did not follow the action"
)
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
window.act_pause.setChecked(False)
assert window._display_paused is False
assert stuck.is_paused is False
assert window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.pause")
finally:
finish_run(window, stuck)
def test_the_transport_says_which_way_it_goes(make_window):
window, stuck = running_window(make_window)
try:
acquiring = window.toolbar.btn_run.text()
acquiring_tip = window.toolbar.btn_run.toolTip()
window.toolbar.btn_run.setChecked(False)
paused = window.toolbar.btn_run.text()
paused_tip = window.toolbar.btn_run.toolTip()
assert acquiring and paused and acquiring != paused, (acquiring, paused)
assert acquiring == t("toolbar.pause")
assert paused == t("toolbar.resume_label")
# The tooltips explain what each state does (the label cannot) — the pause
# one including the gap a pause leaves in a recording.
assert acquiring_tip == t("toolbar.pause_tip")
assert paused_tip == t("toolbar.resume_label_tip")
gap_tip = t("toolbar.pause_tip")
assert "gap" in gap_tip or "空档" in gap_tip, gap_tip
finally:
finish_run(window, stuck)
def test_stopping_a_paused_session_clears_the_pause(make_window):
"""The next run must compute: a latched pause would look like a dead device."""
window, stuck = running_window(make_window)
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True
window._stop_run(reason="user_stop")
assert window._display_paused is False
assert not window.act_pause.isChecked()
assert not window.toolbar.btn_run.isChecked()
# The session is over, so the transport is idle again — not paused.
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
stuck.release()
+20 -4
View File
@@ -245,14 +245,22 @@ def test_the_finger_adapter_can_be_built_from_a_domain(scene):
assert adapter.map_x is not None and adapter.map_y is not None, (
"the domain must carry the unroll map for the adapter to use"
)
assert domain.source_hw == (288, 512), domain.source_hw
# The map states its own source frame, and that frame is the raw 1280x720 frame scaled by
# ``source_scale`` (0.42 -> 538x302), *not* the 300x280 domain the adapter publishes. Pinning
# the relationship rather than one literal keeps this about the convention: a map whose
# coordinates were stated in native pixels would fail here.
scale = domain.source_scale
assert scale == 0.42, scale
assert domain.source_hw == (round(720 * scale), round(1280 * scale)), domain.source_hw
assert domain.source_hw != domain.shape_hw
def test_the_shipped_map_cannot_be_applied_to_the_shipped_flow():
"""Why the app must not remap the finger's flow: the map is domain→*source*.
The unroll map is the same size as the UV domain, but its values are pixel
coordinates in the 512x288 source image — not in the 300x280 domain. The live
coordinates in the scaled source image (538x302 at ``source_scale`` 0.42) — not in the
300x280 domain. The live
flow is already computed on the unrolled frame, so feeding it through this map
would be a double remap: it would index the flow at source-pixel coordinates.
This test pins the reason the app passes no map, so a later "improvement" has to
@@ -272,15 +280,23 @@ def test_the_shipped_map_cannot_be_applied_to_the_shipped_flow():
assert domain.map_x is not None and domain.map_y is not None
rows, columns = domain.shape_hw
source_rows, source_columns = domain.source_hw
assert (rows, columns) == (300, 280) and (source_rows, source_columns) == (288, 512)
assert (rows, columns) == (300, 280)
scale = domain.source_scale
assert (source_rows, source_columns) == (round(720 * scale), round(1280 * scale))
# Bounded by the source, not by the domain: that is what makes them source pixels.
assert 0.0 <= float(domain.map_x[domain.map_x >= 0].max()) <= source_columns - 1
assert 0.0 <= float(domain.map_y[domain.map_y >= 0].max()) <= source_rows - 1
# The values actually present are what decide the two halves of the mistake, not the declared
# size of the source frame: x reaches past the last column of the 280-wide field (a remap would
# read out of range and crash), while y never leaves the 300 rows (a remap would read valid but
# wrong rows, silently). The old form of this assertion accepted ``source_rows <= rows`` as a
# proxy for the y half, which held at ``source_scale`` 0.4 (288 <= 300) but not at 0.42
# (302 > 300) — even though the y values never exceed 284 either way.
assert float(domain.map_x.max()) > columns - 1, (
"x would index past the last column of a 280-wide flow field"
)
assert float(domain.map_y.max()) > rows - 1 or source_rows <= rows, (
assert float(domain.map_y.max()) < rows - 1, (
"y stays inside the 300-row field: the y half of the mistake is silent"
)
@@ -138,6 +138,11 @@ def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
widget, renderer, _ = gl_widget
assert set(renderer.part_ids) == {"sensing_surface", "housing"}
# ``DisplaySettings.housing_visible`` ships False (the frame dominated the
# picture), so the visible case has to be asked for explicitly rather than
# relying on the default — otherwise both renders below are the hidden one and
# the comparison cannot mean anything.
widget.set_settings(DisplaySettings(housing_visible=True))
with_housing = _drawn(_render(widget, qt_app))
assert with_housing.size, "nothing was drawn at all"
widget.set_settings(DisplaySettings(housing_visible=False))
+3 -2
View File
@@ -163,8 +163,9 @@ def test_no_scene3d_entry_point_is_still_a_stub(qt_app):
settings = panel.settings()
assert settings.max_fps == 30
assert settings.display_gain == 0.0
assert settings.housing_visible is True, (
"the panel's resting state and DisplaySettings' default must agree"
assert settings.housing_visible is False, (
"the panel's resting state and DisplaySettings' default must agree: the housing "
"ships hidden, and the checkbox starts unchecked to match"
)
assert settings.color_scale is not None
assert settings.color_scale.mode == "automatic"
+56
View File
@@ -0,0 +1,56 @@
"""The product-type list the rail offers, and what each type is routed to.
`finger-mems` is studio-side only (the MEMS product line is deprecated and has no read path in
the SDK), so it runs on the box/mini acquisition pipeline. That is not an accident to be
rediscovered: the worker routing is a binary ``== "finger"`` test, which means a new type has to
be *added* to the runnable set but must *not* silently become the finger pipeline.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
from orisys_studio import app_window
APP = Path(__file__).resolve().parents[1]
def test_the_picker_offers_every_product_type():
types = list_sensor_types()
assert types == ["box", "mini", "finger", "finger-mems"], types
assert set(app_window.RUNNABLE_TYPES) >= set(types), (
"a type the picker offers but Run refuses is a dead entry"
)
def test_finger_mems_has_a_bundle_but_no_calibration():
"""Copied from box, minus the coefficients: an uncalibrated device must read raw."""
configs = list_configs_for_type("finger-mems")
assert len(configs) == 1, configs
path = Path(configs[0][1])
assert path.is_file(), path
assert path.parent.name == "finger-mems"
assert not (path.parent / "force_calibration.json").exists(), (
"the MEMS bundle ships without calibration on purpose: inventing coefficients would "
"make the charts show newtons the device never measured"
)
def test_finger_mems_takes_the_box_pipeline():
from orisys_studio.session_controller import SessionController
controller = SessionController.__new__(SessionController)
controller._box_worker = object() # type: ignore[attr-defined]
controller._finger_worker = object() # type: ignore[attr-defined]
assert SessionController.worker_for(controller, "finger-mems") is controller._box_worker
assert SessionController.worker_for(controller, "box") is controller._box_worker
assert SessionController.worker_for(controller, "finger") is controller._finger_worker
def test_finger_mems_rotates_its_arrow_view_like_box():
"""It is a box-style stitched camera, so the 2D rotation applies to it too."""
assert "finger-mems" in app_window.ARROW_ROTATE_TYPES
assert "finger" not in app_window.ARROW_ROTATE_TYPES
+107
View File
@@ -0,0 +1,107 @@
"""Review of the rail's unified play control: play/pause, stop, reset — and nothing else.
Written against the requirement rather than the implementation:
* the play-control group is exactly **play/pause**, **stop**, **reset** (no extra pause
button, no role switch that turns the third button into a stop);
* the primary button is a play/pause toggle in **both** views, and pressing it while a
session runs pauses without stopping — the device stays connected;
* stop ends the session (live) or leaves the replay (playback);
* reset is disabled in the replay view, where there is no tracker to zero.
"""
from __future__ import annotations
import ui_check
from tests.conftest import finish_run, running_window, show_window
def test_the_group_is_exactly_play_pause_stop_reset(make_window):
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
toolbar = window.toolbar
assert not hasattr(toolbar, "btn_pause"), "an extra pause button is still in the rail"
assert hasattr(toolbar, "btn_stop"), "there is no dedicated stop button"
assert not hasattr(toolbar, "set_reset_role"), (
"the reset button still switches role to stop; one control cannot be two"
)
assert toolbar.btn_run.isCheckable(), "the play/pause control must be a toggle"
window.close()
def test_the_primary_button_pauses_and_does_not_stop(make_window):
"""The heart of the unification: one toggle, three states, and stop is not the toggle."""
window, stuck = running_window(make_window)
try:
assert window._session_active, "the fixture did not start a session"
assert window.toolbar.btn_run.isChecked(), (
"a running session must show the transport as engaged (pause available)"
)
assert "暂停" in window.toolbar.btn_run.text() or "Paus" in window.toolbar.btn_run.text(), (
f"a running session reads {window.toolbar.btn_run.text()!r}, not pause"
)
# Press it: pause, not stop.
window.toolbar.btn_run.setChecked(False)
assert window._session_active, "pressing the transport ended the session"
assert window._display_paused is True, "the window did not record the pause"
assert stuck.is_paused is True, "the worker was not paused"
assert "继续" in window.toolbar.btn_run.text() or "Resume" in window.toolbar.btn_run.text(), (
f"a paused session reads {window.toolbar.btn_run.text()!r}, not resume"
)
# Press it again: resume.
window.toolbar.btn_run.setChecked(True)
assert window._display_paused is False
assert stuck.is_paused is False, "the worker was not resumed"
assert window._session_active, "resuming ended the session"
finally:
finish_run(window, stuck)
def test_stop_ends_the_session(make_window):
window, stuck = running_window(make_window)
try:
# A first frame is what makes the session a *running* one (the rail's reset and
# the phase both key off it), so drive one before pressing stop.
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
assert window.toolbar.btn_stop.isEnabled(), "stop is not reachable while running"
window.toolbar.btn_stop.click()
assert window._session_phase != "running", (
f"the stop button left the session in {window._session_phase!r}"
)
assert not window.toolbar.btn_run.isChecked(), "the transport stayed engaged after stop"
assert window._display_paused is False, "the pause flag survived the stop"
finally:
stuck.release()
def test_reset_is_for_the_device_and_is_disabled_in_the_replay_view(make_window):
"""Reset stays a *device* control, and it is the thing the replay view cannot offer.
The enabled state of reset once frames are flowing is covered by
``test_ui_smoke.py::test_a_reset_reaches_the_device_and_is_recorded`` (which drives
``_on_reset``); this test pins what the unification is about: reset and stop are two
different controls, and the replay view disables the one that needs a device.
"""
window, stuck = running_window(make_window)
try:
assert window.toolbar.btn_reset.text() != window.toolbar.btn_stop.text(), (
"reset and stop must be distinguishable controls"
)
assert window.toolbar.btn_stop.isEnabled(), "stop is not reachable during a session"
finally:
finish_run(window, stuck)
replay = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
replay.brand_bar.btn_playback.click()
assert not replay.toolbar.btn_reset.isEnabled(), (
"reset is offered in the replay view, where there is no tracker to zero"
)
assert hasattr(replay.toolbar, "btn_stop"), "the replay view has no stop control"
assert replay.toolbar.btn_run.text().startswith("▶"), (
f"the replay transport idle reads {replay.toolbar.btn_run.text()!r}, not play"
)
replay.close()
+180 -23
View File
@@ -225,10 +225,10 @@ def test_a_hung_connect_can_be_cancelled_immediately(make_window, cancel_via):
assert window._session_phase == "connecting"
try:
if cancel_via == "toolbar":
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
assert window.toolbar.btn_stop.isEnabled(), "Stop is disabled while connecting"
assert window.toolbar.btn_stop.text() == t("toolbar.cancel_label")
assert window.act_stop.isEnabled()
cancel = lambda: window.toolbar.btn_run.setChecked(False)
cancel = window.toolbar.btn_stop.click
else:
assert window.act_stop.shortcut().toString() == "Esc"
cancel = window._on_stop_shortcut
@@ -1062,7 +1062,12 @@ def test_idle_run_is_the_only_filled_action(make_window):
def test_connecting_cancel_stays_filled(make_window):
"""Cancel is the way out of a hung device open, so it keeps the weight."""
"""Cancel is the way out of a hung device open, so it keeps the weight.
It lives on the rail's Stop button now (the transport is a play/pause toggle in
both views, so it can no longer be the stop), which is why the filled danger is
checked there.
"""
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
@@ -1073,8 +1078,8 @@ def test_connecting_cancel_stays_filled(make_window):
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.objectName() == "dangerButton"
window.toolbar.btn_run.setChecked(False)
assert window.toolbar.btn_stop.objectName() == "dangerButton"
window.toolbar.btn_stop.click()
stuck.release()
@@ -1088,26 +1093,86 @@ def test_exactly_one_filled_action_while_acquiring(make_window):
window.toolbar.btn_run.setChecked(True)
window._run_timer.stop()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.objectName() == "dangerButton", (
assert window.toolbar.btn_stop.objectName() == "dangerButton", (
"Cancel lost its weight while a connect can hang"
)
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
assert window._session_phase == "running"
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
assert window.toolbar.btn_stop.objectName() == "dangerOutline", (
"Stop is still the filled action while a session merely acquires"
)
assert window.toolbar.btn_record.objectName() == "primaryButton"
assert window.toolbar.btn_run.objectName() == "ghostButton", (
"the transport competes with the filled record action"
)
window.toolbar.set_record_checked(True)
assert window.toolbar.btn_record.objectName() == "dangerButton"
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
assert window.toolbar.btn_stop.objectName() == "dangerOutline", (
"two filled actions share the rail while recording"
)
window.toolbar.set_record_checked(False)
window.toolbar.btn_stop.click()
stuck.release()
def test_the_live_rail_is_a_play_pause_toggle_with_its_own_stop(make_window):
"""Slot 1 means the same thing in both views: play/pause, and Stop beside it.
The live view used to fill that slot with a stop toggle and keep a second,
icon-only pause button next to it, so the same position meant two different
things depending on which view was on screen.
"""
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
# Idle: the transport starts the session, and Stop has nothing to end.
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
assert not window.toolbar.btn_stop.isEnabled()
# Connecting: still a pause toggle, and Stop is the way out of a hung open.
window.toolbar.btn_run.setChecked(True)
window._run_timer.stop()
QApplication.processEvents()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.text() == t("toolbar.pause")
assert window.toolbar.btn_stop.text() == t("toolbar.cancel_label")
assert window.toolbar.btn_stop.isEnabled()
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
QApplication.processEvents()
assert window._session_phase == "running"
assert window.toolbar.btn_run.text() == t("toolbar.pause")
# Unchecked *with* a session is paused, not stopped.
window.toolbar.btn_run.setChecked(False)
QApplication.processEvents()
assert window._session_phase == "running", "the transport stopped instead of pausing"
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
assert stuck.is_paused is True, "the worker was not asked to pause"
assert window.toolbar.btn_stop.isEnabled(), "the way out vanished while paused"
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert stuck.is_paused is False, "the worker was not asked to resume"
# Stop ends it; the transport goes back to idle.
window.toolbar.btn_stop.click()
QApplication.processEvents()
assert window._session_phase == "idle"
assert not window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
stuck.release()
@@ -1590,9 +1655,16 @@ def test_a_late_calibration_does_not_plot_raw_counts_on_the_newton_axis(make_win
assert peak < 5.0, f"a raw count is still drawn on the newton axis (peak {peak})"
# The sample that caused the switch still reaches the readouts; the early
# return used to leave the cards showing the previous sample.
# return used to leave the cards showing the previous sample. The number on
# the card is the *display* copy of it: this fake FN is a slow 2.0…2.4 N ramp
# with no contact flagged, which is exactly the resting drift the display
# baseline holds at zero. What matters here is that the card is not a raw
# count, not the previous sample and not a dash.
assert abs(plots._last_values[0] - (2.0 + 39 * 0.01)) < 1e-9
assert plots.value_labels[0].text() == "2.390", plots.value_labels[0].text()
assert plots.presentation.display_quality != "none", (
"the display policy was not applied to the freshly shown newton unit"
)
assert plots.value_labels[0].text() == "0.000", plots.value_labels[0].text()
assert plots.value_labels[0].text() != t("chart.value_unavailable")
_finish(window, stuck)
@@ -1926,8 +1998,10 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
"""The operator must not have to work out *what* is stuck before stopping it.
The transport is a play/pause toggle, which is ambiguous exactly when something is
wrong. So the rail's second button becomes an unconditional stop in this view, and
it covers both things that can run there: plain playback and a recompute pass.
wrong. So the rail's Stop button — the same one the live view uses to end a session
— is an unconditional stop in this view, and it covers both things that can run
there: plain playback and a recompute pass. The tracker reset owns no device here,
so it is disabled rather than repurposed.
"""
from PySide6.QtWidgets import QApplication
@@ -1939,11 +2013,12 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
assert window._load_recording(replay_recording)
QApplication.processEvents()
assert window.toolbar.reset_role() == "stop", (
"the replay view kept the tracker reset button instead of a stop"
)
assert window.toolbar.btn_reset.text() == t("toolbar.stop_playback")
assert not window.toolbar.btn_reset.isEnabled(), (
"the replay view offers to zero a device tracker it has not got"
)
assert window.toolbar.btn_reset.text() == t("toolbar.reset")
assert window.toolbar.btn_stop.text() == t("toolbar.stop_label")
assert not window.toolbar.btn_stop.isEnabled(), (
"a stop is offered while nothing is running"
)
@@ -1951,8 +2026,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert window._play_timer.isActive()
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while playing"
window.toolbar.btn_reset.click()
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while playing"
window.toolbar.btn_stop.click()
QApplication.processEvents()
assert not window._play_timer.isActive(), "the stop did not stop the playback"
assert not window.toolbar.btn_run.isChecked(), "the transport still shows playing"
@@ -1965,8 +2040,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
window._stop_run = lambda **kwargs: stopped.append(kwargs.get("reason"))
window._recompute_running = True
window._update_run_enabled()
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while a pass runs"
window.toolbar.btn_reset.click()
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while a pass runs"
window.toolbar.btn_stop.click()
QApplication.processEvents()
assert stopped == ["user_stop"], stopped
assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), (
@@ -1976,7 +2051,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
# Nothing running: it says so instead of doing nothing at all.
# (The pass is over, so the stop button is correctly disabled — the escape hatch is
# therefore exercised the way Esc reaches it, through the controller.)
assert not window.toolbar.btn_reset.isEnabled(), (
assert not window.toolbar.btn_stop.isEnabled(), (
"a stop is still offered after the pass was stopped"
)
window.replay_ctl.stop_activity()
@@ -2009,10 +2084,14 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
def test_the_live_view_keeps_the_tracker_reset(make_window):
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.toolbar.reset_role() == "reset"
assert window.toolbar.btn_reset.text() == t("toolbar.reset"), (
"the rail's reset slot is not the tracker reset in the live view"
)
window.close()
@@ -2549,6 +2628,84 @@ def test_the_dialog_writes_the_keys_the_session_reads_back(qt_app):
assert read_back.describe() == "flowEMA(0.5) + median(3) + lowpass(10Hz,order2)"
def test_the_plot_dialog_carries_the_ranges_and_the_zero_threshold(qt_app):
"""FN has a lower bound (which may be negative); both units a deadband."""
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
dialog = PlotSettingsDialog(
units="newton", fn_min=-2.0, fn_max=6.0, deadband_n=0.25, deadband_raw=2000.0
)
assert dialog.settings()["fn_min"] == -2.0
assert dialog.settings()["fn_max"] == 6.0
assert dialog.deadbands() == {"deadband_n": 0.25, "deadband_raw": 2000.0}
# Only the half that belongs to the unit being shown can be edited.
assert dialog.fn_min_spin.isEnabled() and not dialog.fn_raw_min_spin.isEnabled()
assert dialog.deadband_n_spin.isEnabled()
assert not dialog.deadband_raw_spin.isEnabled()
raw = PlotSettingsDialog(units="raw")
assert raw.fn_raw_min_spin.isEnabled() and not raw.fn_min_spin.isEnabled()
assert raw.deadband_raw_spin.isEnabled() and not raw.deadband_n_spin.isEnabled()
raw.fn_raw_min_spin.setValue(-50000.0)
raw.deadband_raw_spin.setValue(5000.0)
assert raw.settings()["fn_raw_min"] == -50000.0
raw._reset_defaults()
assert raw.settings()["fn_raw_min"] == 0.0
assert raw.settings()["fn_min"] == 0.0
assert raw.settings()["window_sec"] == 4.0
assert raw.deadbands() == {"deadband_n": 0.0, "deadband_raw": 0.0}, (
"the shipped deadband answer is 'auto'"
)
def test_the_plot_dialog_values_reach_the_chart(make_window):
"""Dialog -> controller -> panel: where an operator's range actually lands."""
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
window = make_window()
dialog = PlotSettingsDialog(
units="newton",
window_sec=6.0,
fn_min=-2.0,
fn_max=6.0,
shear_max=3.0,
fn_raw_min=-1000.0,
fn_raw_max=5e5,
shear_raw_max=2e5,
deadband_n=0.25,
deadband_raw=3000.0,
)
window.display_ctl.apply_plot_settings(dialog.settings())
plots = window.force_plots
assert (plots._fn_min, plots._fn_max, plots._shear_max) == (-2.0, 6.0, 3.0)
assert (plots._fn_raw_min, plots._fn_raw_max, plots._shear_raw_max) == (
-1000.0, 5e5, 2e5,
)
assert plots.presentation.min_deadband_for("N") == 0.25
assert plots.presentation.min_deadband_for("raw") == 3000.0
assert window.prefs["plot_fn_min"] == -2.0
assert window.prefs["plot_deadband_raw"] == 3000.0
assert plots.history.window_sec == 6.0
# And the dialog would open on the same numbers next time.
assert window.display_ctl.plot_settings()["fn_min"] == -2.0
window.close()
def test_the_display_baseline_is_on_for_every_sensor_by_default(make_window):
"""It used to be Finger-raw only; an idle box sensor drifts just as much."""
window, stuck = _running_window(make_window) # sensor_type="box"
plots = window.force_plots
assert plots._presentation_enabled is True, "a box sensor lost its baseline"
assert plots.presentation.enabled is True
# The per-unit floors came from the preferences, not from a sensor type.
assert plots.presentation.min_deadband_for("raw") == 50.0
assert plots.presentation.min_deadband_for("N") == 0.0
_finish(window, stuck)
-6
View File
@@ -103,7 +103,6 @@ class _Toolbar:
self._expanded = expanded
self.source_views: list[str] = []
self.run_roles: list[str] = []
self.reset_roles: list[str] = []
def set_source_view(self, view: str) -> None:
self.source_views.append(view)
@@ -111,9 +110,6 @@ class _Toolbar:
def set_run_role(self, role: str) -> None:
self.run_roles.append(role)
def set_reset_role(self, role: str) -> None:
self.reset_roles.append(role)
def settings_expanded(self) -> bool:
return self._expanded
@@ -237,14 +233,12 @@ def test_a_preset_decides_which_panels_a_view_shows():
assert parts["charts"].visible is True
assert parts["host"].prefs["view_preset"] == "calibration"
assert parts["toolbar"].source_views[-1] == "live"
assert parts["toolbar"].reset_roles[-1] == "reset"
assert parts["host"].persisted == 1
controller.apply_preset("replay")
assert parts["image"].visible is True
assert parts["toolbar"].source_views[-1] == "replay"
assert parts["toolbar"].run_roles[-1] == "playback"
assert parts["toolbar"].reset_roles[-1] == "stop"
# The charts follow the recording in this view, and the replay's own entries are
# re-synced with it. (Calibration did neither of those: it has no replay source.)
assert parts["replay"].sources == [True]
+91 -21
View File
@@ -247,11 +247,27 @@ class _StuckWorker(RecordSinkMixin):
#: Zeroing requests, and the confirmation callback the window installs.
self.reset_requests = 0
self.on_reset_applied = None
#: Pause state, so the window's Pause button has something to drive. The real
#: worker's contract: pause stops computing and keeps the device open.
self.is_paused = False
self.pause_requests = 0
#: Filters handed over before a session, and what the fake device reports
#: as in effect (this stub has no ``configure``, so it reports nothing).
self.filter_settings_value = FilterSettings()
self.filter_state_value: dict = {}
def pause(self) -> None:
self.is_paused = True
self.pause_requests += 1
def resume(self) -> None:
self.is_paused = False
self.pause_requests += 1
@property
def paused(self) -> bool:
return bool(self.is_paused)
def set_filter_settings(self, settings) -> None:
self.filter_settings_value = settings
@@ -1072,10 +1088,18 @@ def main() -> int:
window.toolbar.btn_run.objectName() == "primaryButton",
f"idle Run is not the filled primary: {window.toolbar.btn_run.objectName()!r}",
)
check(
window.toolbar.btn_run.text() == t("toolbar.run_label"),
f"the idle transport does not say it runs: {window.toolbar.btn_run.text()!r}",
)
check(
not window.toolbar.btn_record.isEnabled(),
"recording is offered without a session",
)
check(
not window.toolbar.btn_stop.isEnabled(),
"Stop is offered before a session exists",
)
stuck = _StuckWorker()
window._ensure_workers = lambda: None
@@ -1084,22 +1108,62 @@ def main() -> int:
window.toolbar.btn_run.setChecked(True)
app.processEvents()
check(
window.toolbar.btn_run.objectName() == "dangerButton",
window.toolbar.btn_run.text() == t("toolbar.pause"),
f"the acquiring transport does not offer pause: "
f"{window.toolbar.btn_run.text()!r}",
)
check(
window.toolbar.btn_stop.objectName() == "dangerButton",
"Cancel is not the filled action while a connect hangs",
)
check(
window.toolbar.btn_stop.text() == t("toolbar.cancel_label"),
f"the connecting Stop does not advertise cancel: "
f"{window.toolbar.btn_stop.text()!r}",
)
stuck.emit(_fake_packet(1))
window._on_run_tick()
app.processEvents()
check(window._session_phase == "running", "the session never reached running")
check(
window.toolbar.btn_run.objectName() == "dangerOutline",
f"Stop competes with the record action: "
f"{window.toolbar.btn_run.objectName()!r}",
window.toolbar.btn_stop.objectName() == "dangerOutline",
f"Stop stays filled while a session merely acquires: "
f"{window.toolbar.btn_stop.objectName()!r}",
)
check(
window.toolbar.btn_stop.text() == t("toolbar.stop_label"),
f"Stop lost its label once the connect returned: "
f"{window.toolbar.btn_stop.text()!r}",
)
check(
window.toolbar.btn_record.objectName() == "primaryButton",
"the record action is not the filled primary while acquiring",
)
check(
window.toolbar.btn_run.objectName() == "ghostButton",
f"the transport competes with the filled record action: "
f"{window.toolbar.btn_run.objectName()!r}",
)
# Unchecking the transport pauses the session; it must not end it, and it must
# leave the way out (the red Stop) exactly where it was.
window.toolbar.btn_run.setChecked(False)
app.processEvents()
check(
window.toolbar.btn_run.text() == t("toolbar.resume_label"),
f"the paused transport does not offer resume: {window.toolbar.btn_run.text()!r}",
)
check(
window._session_phase == "running"
and window.toolbar.btn_stop.isEnabled()
and window.toolbar.btn_reset.isEnabled(),
"pausing the transport did not leave a running, stoppable session",
)
window.toolbar.btn_run.setChecked(True)
app.processEvents()
check(
window.toolbar.btn_run.text() == t("toolbar.pause"),
f"the resumed transport does not offer pause: {window.toolbar.btn_run.text()!r}",
)
check(
window._calibration_summary() == t("status.calibrated_all"),
f"a fully calibrated packet is not reported as such: "
@@ -1112,7 +1176,8 @@ def main() -> int:
"stopping the recording is not the filled danger",
)
check(
window.toolbar.btn_run.objectName() == "dangerOutline",
window.toolbar.btn_run.objectName() not in ("primaryButton", "dangerButton")
and window.toolbar.btn_stop.objectName() not in ("primaryButton", "dangerButton"),
"two filled actions share the rail while recording",
)
@@ -1183,22 +1248,22 @@ def main() -> int:
check(window._session_phase == "connecting", "run did not enter the connecting phase")
check(
window.toolbar.btn_run.isEnabled(),
window.toolbar.btn_stop.isEnabled(),
"Stop is disabled while connecting — a hung connect could not be cancelled",
)
check(window.toolbar.btn_run.isChecked(), "run button did not latch into the stop state")
check(window.toolbar.btn_run.isChecked(), "the transport does not show acquiring")
check(
window.toolbar.btn_run.text() == t("toolbar.cancel_label"),
f"connecting label is not the cancel label: {window.toolbar.btn_run.text()!r}",
window.toolbar.btn_stop.text() == t("toolbar.cancel_label"),
f"connecting label is not the cancel label: {window.toolbar.btn_stop.text()!r}",
)
check(
window.toolbar.btn_run.toolTip() == t("toolbar.cancel_tip"),
window.toolbar.btn_stop.toolTip() == t("toolbar.cancel_tip"),
"connecting tooltip does not explain how to cancel",
)
check(window.act_stop.isEnabled(), "Stop shortcut is disabled while connecting")
started = time.perf_counter()
window.toolbar.btn_run.setChecked(False)
window.toolbar.btn_stop.click()
elapsed = time.perf_counter() - started
check(elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s on a hung connect")
check(not window._running, "session stayed active after Stop")
@@ -3140,17 +3205,22 @@ def main() -> int:
)
check(replay_window.toolbar.btn_run.isEnabled(), "the replay has no transport")
# The replay view offers an unconditional stop (the rail's second button), because
# the transport is a play/pause toggle and "which one is stuck?" is not a question
# to ask an operator in front of a frozen picture.
# The replay view stops through the rail's own Stop button — the same control the
# live view uses — because the transport is a play/pause toggle and "which one is
# stuck?" is not a question to ask an operator in front of a frozen picture. The
# tracker reset owns no device here, so it is disabled rather than repurposed.
check(
replay_window.toolbar.reset_role() == "stop",
"the replay view has no stop button",
not replay_window.toolbar.btn_reset.isEnabled(),
"the replay view offers to zero a device tracker it has not got",
)
check(
replay_window.toolbar.btn_reset.text() == t("toolbar.stop_playback"),
f"the stop button is not labelled as one: "
f"{replay_window.toolbar.btn_reset.text()!r}",
replay_window.toolbar.btn_stop.text() == t("toolbar.stop_label"),
f"the replay's stop button is not labelled as one: "
f"{replay_window.toolbar.btn_stop.text()!r}",
)
check(
not replay_window.toolbar.btn_stop.isEnabled(),
"a stop is offered while nothing is running",
)
# Start playing so there is something to stop, then stop it with that button.
replay_window.toolbar.btn_run.setChecked(True)
@@ -3160,10 +3230,10 @@ def main() -> int:
"the playback did not start, so the stop check would prove nothing",
)
check(
replay_window.toolbar.btn_reset.isEnabled(),
replay_window.toolbar.btn_stop.isEnabled(),
"the stop is not offered while the transport is playing",
)
replay_window.toolbar.btn_reset.click()
replay_window.toolbar.btn_stop.click()
app.processEvents()
check(
not replay_window.toolbar.btn_run.isChecked(),
+3 -1
View File
@@ -336,7 +336,9 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--theme", choices=("dark", "light"), default="dark")
parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN")
parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box")
parser.add_argument(
"--sensor", choices=("box", "mini", "finger", "finger-mems"), default="box"
)
parser.add_argument(
"--panels",
default="",