fixed commetn from songxiao
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user