add session browsing, calibration diagnostics and the 2D layout preset
Phase 2 of the review: browse the whole session instead of only the rolling window, diagnose a calibration from the recorded points, and provide the reviewed two-panel layout. What - SessionStore gains extent(), visible(t0, t1, max_points) and statistics(t0, t1, unit). visible() walks only the blocks that overlap the window and decimates by taking each bucket's minimum and maximum, so a spike survives decimation and browsing a long session does not copy all of it. - ForcePlotsPanel gains follow/browse modes, a whole-session overview strip with the current window, pan and wheel zoom, a rubber-band zoom toggle, a shared time cursor across all three channels, and a selection readout (duration, valid samples, per-axis peak/mean/std). - CalibrationPoint records a bounded per-axis trace, an excluded flag, a validation flag and its fit residual. CalibrationRecorder gains set_excluded/set_validation/fit; the fit is a least-squares line over the non-excluded, non-validation points with per-point residuals and an independent validation error. - The calibration table becomes the reviewed diagnostic view (reference load | FN | FX | FY | stability | state) with the residual shown next to the loaded axis, plus exclude/restore and mark-validation actions and a fit summary line. Double clicking a row opens the point's three-axis trace. - Add a layout preset: free layout or 2D mode (depth map on the left, force charts on the right, calibration folded away), persisted in preferences. Why - Browsing is a display pause, not a capture pause: the record channel keeps running while the charts show an older window. - Excluding a point removes it from the fit and from the exported calibration but keeps it on record, so the evidence for the decision is not destroyed. - The non-loaded axes are recorded but never given a reference load: their true load is unknown during a single-axis calibration, so it stays empty. Verification - shared/tests/test_record_channel.py grows to 18 cases, covering extent, peak-preserving decimation, windowed reads, inverted/empty windows, unit-aware statistics and the time origin. - tools/ui_check.py gains "session browsing" and "layout preset" sections (223 checks, 0 failures overall). - apps/studio/tests/test_ui_smoke.py covers the same invariants. Not in this change: synchronised playback of video, depth and curves. It needs a decision on how depth is stored for replay (per-frame PNG, reduced-rate PNG, or offline recomputation); the cursor plumbing (cursor_moved / set_cursor) is already in place for it.
This commit is contained in:
@@ -102,6 +102,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._theme = str(self.prefs.get("theme") or "dark")
|
||||
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||
self._show_cal = bool(self.prefs.get("show_cal_panel", False))
|
||||
self._layout_preset = str(self.prefs.get("layout_preset") or "default")
|
||||
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
|
||||
self._show_fps = bool(self.prefs.get("show_fps", False))
|
||||
self._session_phase = "idle"
|
||||
@@ -174,6 +175,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self.mesh3d_panel = Mesh3dPanel()
|
||||
self.arrow_panel = ArrowPanel()
|
||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||
# The charts browse the whole session, not just their rolling buffer.
|
||||
self.force_plots.set_store(self.session_store)
|
||||
self.cal_panel = CalPanel(theme=self._theme)
|
||||
self.content_splitter.addWidget(self.mesh3d_panel)
|
||||
self.content_splitter.addWidget(self.arrow_panel)
|
||||
@@ -500,6 +503,23 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
|
||||
self.view_menu.addAction(self.act_cal_panel)
|
||||
|
||||
self.layout_group = QActionGroup(self)
|
||||
self.layout_group.setExclusive(True)
|
||||
self.act_layout_default = QAction(self)
|
||||
self.act_layout_default.setCheckable(True)
|
||||
self.act_layout_default.setChecked(self._layout_preset != "2d")
|
||||
self.act_layout_default.triggered.connect(
|
||||
lambda: self._on_layout_preset("default")
|
||||
)
|
||||
self.act_layout_2d = QAction(self)
|
||||
self.act_layout_2d.setCheckable(True)
|
||||
self.act_layout_2d.setChecked(self._layout_preset == "2d")
|
||||
self.act_layout_2d.triggered.connect(lambda: self._on_layout_preset("2d"))
|
||||
self.layout_group.addAction(self.act_layout_default)
|
||||
self.layout_group.addAction(self.act_layout_2d)
|
||||
self.view_menu.addAction(self.act_layout_default)
|
||||
self.view_menu.addAction(self.act_layout_2d)
|
||||
|
||||
self.act_finger_3d = QAction(self)
|
||||
self.act_finger_3d.setCheckable(True)
|
||||
self.act_finger_3d.setChecked(self._show_finger_3d)
|
||||
@@ -586,36 +606,62 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_cal_panel.setChecked(self._show_cal)
|
||||
self.act_cal_panel.blockSignals(False)
|
||||
is_finger = self.sensor_picker.sensor_type() == "finger"
|
||||
self.act_finger_3d.setEnabled(is_finger)
|
||||
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d")
|
||||
self.act_finger_3d.blockSignals(True)
|
||||
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
|
||||
self.act_finger_3d.blockSignals(False)
|
||||
self.act_layout_default.blockSignals(True)
|
||||
self.act_layout_default.setChecked(self._layout_preset != "2d")
|
||||
self.act_layout_default.blockSignals(False)
|
||||
self.act_layout_2d.blockSignals(True)
|
||||
self.act_layout_2d.setChecked(self._layout_preset == "2d")
|
||||
self.act_layout_2d.blockSignals(False)
|
||||
self._apply_arrow_rotation_for_sensor()
|
||||
|
||||
def _apply_panel_visibility(self) -> None:
|
||||
is_finger = self.sensor_picker.sensor_type() == "finger"
|
||||
show_3d = is_finger and bool(self._show_finger_3d)
|
||||
show_3d = is_finger and bool(self._show_finger_3d) and self._layout_preset != "2d"
|
||||
self.mesh3d_panel.setVisible(show_3d)
|
||||
self.cal_panel.setVisible(self._show_cal)
|
||||
self.mesh3d_panel.setMinimumWidth(MIN_PANEL_WIDTHS["mesh3d"])
|
||||
self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"])
|
||||
self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"])
|
||||
self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"])
|
||||
self.act_finger_3d.setEnabled(is_finger)
|
||||
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d")
|
||||
self.act_finger_3d.blockSignals(True)
|
||||
self.act_finger_3d.setChecked(show_3d)
|
||||
self.act_finger_3d.blockSignals(False)
|
||||
# The 2D preset is the reviewed layout: depth map and force charts side
|
||||
# by side, sharing one time axis, with calibration folded away.
|
||||
arrow_weight = 3 if self._layout_preset != "2d" else 5
|
||||
plots_weight = 4 if self._layout_preset != "2d" else 6
|
||||
for i in range(self.content_splitter.count()):
|
||||
widget = self.content_splitter.widget(i)
|
||||
if widget is self.mesh3d_panel:
|
||||
self.content_splitter.setStretchFactor(i, 2 if show_3d else 0)
|
||||
elif widget is self.arrow_panel:
|
||||
self.content_splitter.setStretchFactor(i, 3)
|
||||
self.content_splitter.setStretchFactor(i, arrow_weight)
|
||||
elif widget is self.force_plots:
|
||||
self.content_splitter.setStretchFactor(i, 4)
|
||||
self.content_splitter.setStretchFactor(i, plots_weight)
|
||||
elif widget is self.cal_panel:
|
||||
self.content_splitter.setStretchFactor(i, 3 if self._show_cal else 0)
|
||||
|
||||
def _on_layout_preset(self, preset: str) -> None:
|
||||
"""Switch between the free layout and the reviewed 2D preset."""
|
||||
self._layout_preset = "2d" if preset == "2d" else "default"
|
||||
self.prefs["layout_preset"] = self._layout_preset
|
||||
if self._layout_preset == "2d":
|
||||
# Depth map on the left, force charts on the right, one timeline.
|
||||
self.arrow_panel.set_view_mode("depth")
|
||||
self._show_cal = False
|
||||
self.prefs["show_cal_panel"] = False
|
||||
self._apply_panel_visibility()
|
||||
self._sync_menu_checks()
|
||||
self._persist_prefs()
|
||||
self.statusBar().showMessage(
|
||||
t("layout.preset_2d" if self._layout_preset == "2d" else "layout.preset_default")
|
||||
)
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
|
||||
source_ok = True
|
||||
@@ -641,6 +687,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||
self.view_menu.setTitle(t("menu.view"))
|
||||
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
||||
self.act_layout_default.setText(t("layout.default"))
|
||||
self.act_layout_2d.setText(t("layout.preset_2d_name"))
|
||||
self.act_finger_3d.setText(t("menu.finger_3d"))
|
||||
self.act_show_fps.setText(t("menu.show_fps"))
|
||||
self.act_plot_settings.setText(t("menu.plot_settings"))
|
||||
@@ -1066,6 +1114,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._dropped_reported = 0
|
||||
self._depth_range_value = None
|
||||
self._last_depth_frame = None
|
||||
self.force_plots.set_store(self.session_store)
|
||||
# A new session invalidates the previous recording.
|
||||
self._stop_recording(wait=True)
|
||||
self._session_counter += 1
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
@@ -35,8 +36,15 @@ from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
|
||||
|
||||
CHANNEL_LABELS = ("fn", "fx", "fy")
|
||||
|
||||
#: Diagnostic columns, in display order: reference load, the three axis means,
|
||||
#: a stability measure and the point's state.
|
||||
DIAGNOSTIC_COLUMNS = ("load", "fn", "fx", "fy", "stability", "state")
|
||||
DIAGNOSTIC_COMPACT_COLUMNS = (0, 4, 5)
|
||||
|
||||
|
||||
def _mono_font() -> str:
|
||||
families = set(QFontDatabase().families())
|
||||
@@ -143,7 +151,9 @@ class CalPanel(QFrame):
|
||||
self.empty_hint.setWordWrap(True)
|
||||
root.addWidget(self.empty_hint)
|
||||
|
||||
self.table = QTableWidget(0, 4)
|
||||
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
|
||||
# stability measure and the point's state.
|
||||
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
@@ -151,11 +161,19 @@ class CalPanel(QFrame):
|
||||
self.table.setShowGrid(False)
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
for column in DIAGNOSTIC_COMPACT_COLUMNS:
|
||||
header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.verticalHeader().setDefaultSectionSize(26)
|
||||
self.table.itemSelectionChanged.connect(self._on_row_selected)
|
||||
self.table.doubleClicked.connect(self._on_row_activated)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
self.fit_label = QLabel()
|
||||
self.fit_label.setObjectName("hintText")
|
||||
self.fit_label.setWordWrap(True)
|
||||
root.addWidget(self.fit_label)
|
||||
|
||||
sn_row = QHBoxLayout()
|
||||
self._sn_label = QLabel()
|
||||
self._sn_label.setObjectName("fieldLabel")
|
||||
@@ -170,16 +188,28 @@ class CalPanel(QFrame):
|
||||
self.btn_delete = QPushButton()
|
||||
self.btn_clear_channel = QPushButton()
|
||||
self.btn_clear_all = QPushButton()
|
||||
self.btn_toggle_excluded = QPushButton()
|
||||
self.btn_toggle_validation = QPushButton()
|
||||
self.btn_export = QPushButton()
|
||||
self.btn_export.setObjectName("primaryButton")
|
||||
for button in (self.btn_delete, self.btn_clear_channel, self.btn_clear_all):
|
||||
for button in (
|
||||
self.btn_delete,
|
||||
self.btn_clear_channel,
|
||||
self.btn_clear_all,
|
||||
self.btn_toggle_excluded,
|
||||
self.btn_toggle_validation,
|
||||
):
|
||||
# The action row must survive a narrow three-panel layout.
|
||||
button.setProperty("compact", "true")
|
||||
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
|
||||
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
|
||||
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
|
||||
self.btn_clear_all.clicked.connect(self._on_clear_all)
|
||||
self.btn_export.clicked.connect(self._on_export)
|
||||
actions.addWidget(self.btn_toggle_excluded)
|
||||
actions.addWidget(self.btn_toggle_validation)
|
||||
actions.addWidget(self.btn_delete)
|
||||
actions.addWidget(self.btn_clear_channel)
|
||||
actions.addWidget(self.btn_clear_all)
|
||||
@@ -230,20 +260,14 @@ class CalPanel(QFrame):
|
||||
|
||||
def add_point(self, point) -> None:
|
||||
"""Record a finished calibration point (three axes captured)."""
|
||||
stats = point.loaded_stats()
|
||||
sample = CalSample(
|
||||
weight_n=float(point.reference_load_n),
|
||||
raw_mean=float(stats.mean),
|
||||
n=int(stats.n),
|
||||
std=float(stats.std),
|
||||
)
|
||||
self._store.upsert(point.channel, sample)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
stats = point.loaded_stats()
|
||||
if point.status == "few_samples":
|
||||
self.status_message.emit(t("cal.record_failed_few"))
|
||||
return
|
||||
self.status_message.emit(
|
||||
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
|
||||
t("cal.record_ok", weight=f"{point.reference_load_n:g}", raw=f"{stats.mean:.3f}")
|
||||
)
|
||||
|
||||
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
|
||||
@@ -261,14 +285,21 @@ class CalPanel(QFrame):
|
||||
self.btn_clear_channel.setText(t("cal.clear_channel"))
|
||||
self.btn_clear_all.setText(t("cal.clear_all"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self.btn_toggle_excluded.setText(t("cal.state_exclude"))
|
||||
self.btn_toggle_validation.setText(t("cal.mark_validation"))
|
||||
self.btn_toggle_excluded.setToolTip(t("cal.state_exclude_tip"))
|
||||
self.btn_toggle_validation.setToolTip(t("cal.mark_validation_tip"))
|
||||
self._sn_label.setText(t("cal.sn"))
|
||||
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
|
||||
self.empty_hint.setText(t("cal.empty_hint"))
|
||||
headers = (
|
||||
t("cal.col_weight"),
|
||||
t("cal.col_raw"),
|
||||
t("cal.col_n"),
|
||||
t("cal.col_std"),
|
||||
t("cal.col_fn"),
|
||||
t("cal.col_fx"),
|
||||
t("cal.col_fy"),
|
||||
t("cal.col_stability"),
|
||||
t("cal.col_state"),
|
||||
)
|
||||
for i, text in enumerate(headers):
|
||||
item = self.table.horizontalHeaderItem(i)
|
||||
@@ -280,6 +311,7 @@ class CalPanel(QFrame):
|
||||
self._refresh_channel_badge()
|
||||
self._render_live_label()
|
||||
self._refresh_enabled()
|
||||
self._refresh_table()
|
||||
|
||||
# ------------------------------------------------------------- internals
|
||||
def _refresh_channel_badge(self) -> None:
|
||||
@@ -393,39 +425,173 @@ class CalPanel(QFrame):
|
||||
self._recorder.requested_duration_s,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ diagnostics
|
||||
def _diagnostic_points(self):
|
||||
"""The recorder's points for the active channel, or the export store."""
|
||||
if self._recorder is not None:
|
||||
return self._recorder.points_for(self._channel)
|
||||
return []
|
||||
|
||||
def _current_point(self):
|
||||
row = self.table.currentRow()
|
||||
points = self._diagnostic_points()
|
||||
if 0 <= row < len(points):
|
||||
return points[row]
|
||||
return None
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
rows = self._store.channel(self._channel)
|
||||
self.table.setRowCount(len(rows))
|
||||
for r, sample in enumerate(rows):
|
||||
points = self._diagnostic_points()
|
||||
self.table.setRowCount(len(points))
|
||||
for row, point in enumerate(points):
|
||||
values = (
|
||||
f"{sample.weight_n:g}",
|
||||
f"{sample.raw_mean:.4f}",
|
||||
str(sample.n),
|
||||
f"{sample.std:.4f}",
|
||||
f"{point.reference_load_n:g}",
|
||||
self._axis_cell(point, "fn"),
|
||||
self._axis_cell(point, "fx"),
|
||||
self._axis_cell(point, "fy"),
|
||||
self._stability_cell(point),
|
||||
t(point.state_key()),
|
||||
)
|
||||
for c, text in enumerate(values):
|
||||
for column, text in enumerate(values):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||||
if c != 2:
|
||||
if column in (0, 1, 2, 3):
|
||||
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
|
||||
else:
|
||||
item.setTextAlignment(int(Qt.AlignCenter))
|
||||
self.table.setItem(r, c, item)
|
||||
self.empty_hint.setVisible(not rows)
|
||||
if point.excluded:
|
||||
item.setForeground(QColor(color(self._theme, "text_muted")))
|
||||
elif point.is_validation:
|
||||
item.setForeground(QColor(color(self._theme, "info")))
|
||||
self.table.setItem(row, column, item)
|
||||
self.empty_hint.setVisible(not points)
|
||||
self._refresh_fit_label()
|
||||
|
||||
@staticmethod
|
||||
def _axis_cell(point, channel: str) -> str:
|
||||
value = point.axis_mean(channel)
|
||||
if not np.isfinite(value):
|
||||
return "—"
|
||||
if channel == point.channel and np.isfinite(point.residual_n):
|
||||
# Residual of the fit on the same cell as the value it came from.
|
||||
return f"{value:.4f} ({point.residual_n:+.4f})"
|
||||
return f"{value:.4f}"
|
||||
|
||||
@staticmethod
|
||||
def _stability_cell(point) -> str:
|
||||
stats = point.loaded_stats()
|
||||
if not np.isfinite(stats.std):
|
||||
return "—"
|
||||
return f"σ {stats.std:.4f} / n {stats.n}"
|
||||
|
||||
def _refresh_fit_label(self) -> None:
|
||||
if self._recorder is None:
|
||||
self.fit_label.setText("")
|
||||
return
|
||||
fit = self._recorder.fit(self._channel)
|
||||
if not fit.usable:
|
||||
self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points))
|
||||
return
|
||||
text = t(
|
||||
"cal.fit_summary",
|
||||
channel=self._channel.upper(),
|
||||
gain=f"{fit.gain:g}",
|
||||
offset=f"{fit.offset:g}",
|
||||
count=fit.n_points,
|
||||
residual=f"{fit.max_residual_n:.4f}",
|
||||
)
|
||||
if fit.validation_points:
|
||||
text += " " + t(
|
||||
"cal.fit_validation",
|
||||
error=f"{fit.validation_error_n:.4f}",
|
||||
count=fit.validation_points,
|
||||
)
|
||||
self.fit_label.setText(text)
|
||||
|
||||
def _on_row_selected(self) -> None:
|
||||
point = self._current_point()
|
||||
self.btn_toggle_excluded.setEnabled(point is not None)
|
||||
self.btn_toggle_validation.setEnabled(point is not None)
|
||||
if point is not None:
|
||||
state = t("cal.state_restore") if point.excluded else t("cal.state_exclude")
|
||||
self.btn_toggle_excluded.setText(state)
|
||||
|
||||
def _on_row_activated(self) -> None:
|
||||
"""Double click a row to inspect that point's three-axis curve."""
|
||||
point = self._current_point()
|
||||
if point is None or not point.trace:
|
||||
return
|
||||
CalibrationPointDialog(point, theme=self._theme, parent=self).exec_()
|
||||
|
||||
def _on_toggle_excluded(self) -> None:
|
||||
row = self.table.currentRow()
|
||||
point = self._current_point()
|
||||
if point is None:
|
||||
return
|
||||
self._recorder.set_excluded(row, not point.excluded)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
self.status_message.emit(
|
||||
t("cal.excluded" if point.excluded else "cal.restored")
|
||||
)
|
||||
|
||||
def _on_toggle_validation(self) -> None:
|
||||
row = self.table.currentRow()
|
||||
point = self._current_point()
|
||||
if point is None:
|
||||
return
|
||||
self._recorder.set_validation(row, not point.is_validation)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
self.status_message.emit(
|
||||
t("cal.marked_validation" if point.is_validation else "cal.unmarked_validation")
|
||||
)
|
||||
|
||||
def _rebuild_store(self) -> None:
|
||||
"""Rebuild the export store from the points that are still in the fit."""
|
||||
self._store.clear()
|
||||
if self._recorder is None:
|
||||
return
|
||||
for point in self._recorder.points:
|
||||
if point.excluded:
|
||||
continue
|
||||
stats = point.loaded_stats()
|
||||
if not np.isfinite(stats.mean):
|
||||
continue
|
||||
self._store.upsert(
|
||||
point.channel,
|
||||
CalSample(
|
||||
weight_n=float(point.reference_load_n),
|
||||
raw_mean=float(stats.mean),
|
||||
n=int(stats.n),
|
||||
std=float(stats.std),
|
||||
),
|
||||
)
|
||||
|
||||
def _on_delete_row(self) -> None:
|
||||
row = self.table.currentRow()
|
||||
if row < 0:
|
||||
point = self._current_point()
|
||||
if point is None or self._recorder is None:
|
||||
return
|
||||
self._store.remove_at(self._channel, row)
|
||||
# Deleting is an exclusion: the recorded evidence stays, but it leaves
|
||||
# the fit and the export.
|
||||
self._recorder.set_excluded(row, True)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_channel(self) -> None:
|
||||
self._store.clear(self._channel)
|
||||
if self._recorder is not None:
|
||||
with self._recorder._lock: # noqa: SLF001 - panel owns the view
|
||||
self._recorder.points = [
|
||||
p for p in self._recorder.points if p.channel != self._channel
|
||||
]
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_all(self) -> None:
|
||||
self._store.clear()
|
||||
if self._recorder is not None:
|
||||
with self._recorder._lock: # noqa: SLF001
|
||||
self._recorder.points = []
|
||||
self._refresh_table()
|
||||
|
||||
def _on_export(self) -> None:
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Inspect one calibration point: its three-axis trace and its diagnostics.
|
||||
|
||||
Kept in a dialog rather than inline so the calibration panel can stay narrow in
|
||||
the three-panel layout, while a single point can still be examined in full.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force import NA
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise ImportError("pyqtgraph is required for the calibration point dialog") from exc
|
||||
|
||||
TRACE_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
|
||||
TRACE_KEYS = ("fn", "fx", "fy")
|
||||
|
||||
|
||||
def _mono_font(point_size: int = 9) -> QFont:
|
||||
families = set(QFontDatabase().families())
|
||||
chosen = "monospace"
|
||||
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
|
||||
if name in families:
|
||||
chosen = name
|
||||
break
|
||||
return QFont(chosen, point_size)
|
||||
|
||||
|
||||
class CalibrationPointDialog(QDialog):
|
||||
"""One recorded point: all three raw traces plus its fit residual."""
|
||||
|
||||
def __init__(self, point, *, theme: str = "dark", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._point = point
|
||||
self._theme = theme
|
||||
self.setWindowTitle(
|
||||
t(
|
||||
"cal.point_title",
|
||||
channel=point.channel.upper(),
|
||||
load=f"{point.reference_load_n:g}",
|
||||
)
|
||||
)
|
||||
self.resize(560, 420)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(8)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setHorizontalSpacing(10)
|
||||
form.setVerticalSpacing(4)
|
||||
self._add_row(form, "cal.point_channel", point.channel.upper())
|
||||
self._add_row(form, "cal.point_direction", point.direction)
|
||||
self._add_row(form, "cal.point_round", str(point.round_index))
|
||||
self._add_row(
|
||||
form,
|
||||
"cal.point_duration",
|
||||
t(
|
||||
"cal.point_duration_value",
|
||||
actual=f"{point.duration_s:.2f}",
|
||||
requested=f"{point.requested_duration_s:.2f}",
|
||||
),
|
||||
)
|
||||
self._add_row(
|
||||
form,
|
||||
"cal.point_samples",
|
||||
t("cal.point_samples_value", valid=point.n_valid, total=point.n_total),
|
||||
)
|
||||
stats = []
|
||||
for key in TRACE_KEYS:
|
||||
axis = point.raw.get(key)
|
||||
if axis is None or not np.isfinite(axis.mean):
|
||||
stats.append(f"{key.upper()} —")
|
||||
continue
|
||||
stats.append(f"{key.upper()} {axis.mean:.4f} ± {axis.std:.4f}")
|
||||
value = QLabel(" ".join(stats))
|
||||
value.setFont(_mono_font())
|
||||
self._add_row(form, "cal.point_axes", "")
|
||||
form.addRow(value)
|
||||
residual = point.residual_n
|
||||
self._add_row(
|
||||
form,
|
||||
"cal.point_residual",
|
||||
"—" if not np.isfinite(residual) else f"{residual:+.4f}",
|
||||
)
|
||||
root.addLayout(form)
|
||||
|
||||
self.plot_widget = pg.GraphicsLayoutWidget()
|
||||
self.plot_widget.setBackground(color(theme, "plot_bg"))
|
||||
root.addWidget(self.plot_widget, 1)
|
||||
self._build_traces()
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _add_row(self, form: QFormLayout, key: str, value: str) -> None:
|
||||
label = QLabel(t(key))
|
||||
label.setObjectName("fieldLabel")
|
||||
field = QLabel(str(value))
|
||||
form.addRow(label, field)
|
||||
|
||||
def _build_traces(self) -> None:
|
||||
"""One row per axis, stacked, sharing the sample axis."""
|
||||
pen_axis = pg.mkPen(color(self._theme, "text_secondary"))
|
||||
first = None
|
||||
for row, key in enumerate(TRACE_KEYS):
|
||||
plot = self.plot_widget.addPlot(row=row, col=0)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
plot.setMouseEnabled(x=True, y=False)
|
||||
plot.getAxis("left").setPen(pen_axis)
|
||||
plot.getAxis("left").setTextPen(pen_axis)
|
||||
for axis_name in ("left", "bottom"):
|
||||
plot.getAxis(axis_name).setPen(pen_axis)
|
||||
plot.getAxis(axis_name).setTextPen(pen_axis)
|
||||
plot.setLabel(
|
||||
"left", key.upper(), color=color(self._theme, TRACE_TOKENS[row])
|
||||
)
|
||||
if first is None:
|
||||
first = plot
|
||||
else:
|
||||
plot.setXLink(first)
|
||||
self.plot_widget.ci.layout.setRowStretchFactor(row, 1)
|
||||
values = self._point.trace.get(key)
|
||||
if not values:
|
||||
continue
|
||||
step = self._point.trace_dt_s or 0.01
|
||||
times = np.arange(len(values), dtype=float) * step
|
||||
plot.plot(
|
||||
times,
|
||||
np.asarray(values, dtype=float),
|
||||
pen=pg.mkPen(color(self._theme, TRACE_TOKENS[row]), width=2),
|
||||
)
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
|
||||
from PyQt5.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
|
||||
from orisys_app_shared.force import MetricHistory
|
||||
@@ -23,6 +31,8 @@ CHANNEL_KEYS = ("fn", "fx", "fy")
|
||||
CHANNEL_LABELS = ("FN", "FX", "FY")
|
||||
# Theme token that colors each channel curve / label, per index.
|
||||
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
|
||||
#: How often the whole-session overview strip is rebuilt while following live.
|
||||
OVERVIEW_REFRESH_S = 1.0
|
||||
|
||||
|
||||
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
|
||||
@@ -56,7 +66,29 @@ def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
|
||||
return font
|
||||
|
||||
|
||||
class _Sync:
|
||||
"""Marks a block of range changes as programmatic (not a user gesture)."""
|
||||
|
||||
def __init__(self, panel: "ForcePlotsPanel") -> None:
|
||||
self._panel = panel
|
||||
self._previous = panel._syncing
|
||||
|
||||
def __enter__(self) -> "_Sync":
|
||||
self._panel._syncing = True
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc) -> None:
|
||||
self._panel._syncing = self._previous
|
||||
|
||||
|
||||
class ForcePlotsPanel(QFrame):
|
||||
#: The visible time window changed while browsing history.
|
||||
window_changed = pyqtSignal(float, float)
|
||||
#: The shared cursor moved; other views sync their playback position to it.
|
||||
cursor_moved = pyqtSignal(float)
|
||||
#: "follow" (live) or "browse" (user-controlled window).
|
||||
mode_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
@@ -68,6 +100,15 @@ class ForcePlotsPanel(QFrame):
|
||||
self._fixed_newton = True
|
||||
self._unit = "raw"
|
||||
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
# -- browsing state -------------------------------------------------
|
||||
self._store = None
|
||||
self._mode = "follow"
|
||||
self._window: tuple[float, float] = (0.0, 0.0)
|
||||
self._cursor_t = 0.0
|
||||
self._syncing = False
|
||||
self._overview_dirty = True
|
||||
self._last_overview_at = 0.0
|
||||
self._max_draw_points = 2000
|
||||
self.history = MetricHistory(
|
||||
channels=3,
|
||||
window_sec=4.0,
|
||||
@@ -86,7 +127,23 @@ class ForcePlotsPanel(QFrame):
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
header.addWidget(self.title)
|
||||
self.mode_label = QLabel()
|
||||
self.mode_label.setObjectName("hintText")
|
||||
header.addWidget(self.mode_label)
|
||||
header.addStretch(1)
|
||||
self.btn_latest = QPushButton()
|
||||
self.btn_latest.setProperty("compact", "true")
|
||||
self.btn_latest.clicked.connect(self.follow_latest)
|
||||
header.addWidget(self.btn_latest)
|
||||
self.btn_overview = QPushButton()
|
||||
self.btn_overview.setProperty("compact", "true")
|
||||
self.btn_overview.clicked.connect(self.show_all)
|
||||
header.addWidget(self.btn_overview)
|
||||
self.btn_box_zoom = QPushButton()
|
||||
self.btn_box_zoom.setProperty("compact", "true")
|
||||
self.btn_box_zoom.setCheckable(True)
|
||||
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
|
||||
header.addWidget(self.btn_box_zoom)
|
||||
self.unit_badge = QLabel()
|
||||
self.unit_badge.setObjectName("unitBadge")
|
||||
header.addWidget(self.unit_badge)
|
||||
@@ -103,6 +160,7 @@ class ForcePlotsPanel(QFrame):
|
||||
|
||||
self.plots = []
|
||||
self.curves = []
|
||||
self.cursors = []
|
||||
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)
|
||||
@@ -111,8 +169,40 @@ class ForcePlotsPanel(QFrame):
|
||||
if i > 0:
|
||||
plot.setXLink(self.plots[0])
|
||||
curve = plot.plot(pen=pen)
|
||||
# One shared time cursor across all three channels.
|
||||
cursor = pg.InfiniteLine(
|
||||
pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1)
|
||||
)
|
||||
plot.addItem(cursor, ignoreBounds=True)
|
||||
plot.getViewBox().sigXRangeChanged.connect(self._on_x_range_changed)
|
||||
self.plots.append(plot)
|
||||
self.curves.append(curve)
|
||||
self.cursors.append(cursor)
|
||||
|
||||
# Whole-session overview: the full envelope plus the current window.
|
||||
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
|
||||
self.overview.setMouseEnabled(x=False, y=False)
|
||||
self.overview.showGrid(x=True, y=False, alpha=0.2)
|
||||
self.overview.setMaximumHeight(46)
|
||||
self.overview.getAxis("left").setStyle(showValues=False)
|
||||
self.overview_curve = self.overview.plot(pen=pg.mkPen("#8A93A6", width=1))
|
||||
self.overview_region = pg.LinearRegionItem(
|
||||
values=(0.0, 1.0), movable=False, brush=(90, 110, 190, 60)
|
||||
)
|
||||
self.overview_region.setZValue(-10)
|
||||
self.overview.addItem(self.overview_region)
|
||||
self.overview_cursor = pg.InfiniteLine(
|
||||
pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1)
|
||||
)
|
||||
self.overview.addItem(self.overview_cursor, ignoreBounds=True)
|
||||
for row in range(len(CHANNEL_LABELS)):
|
||||
self.plot_widget.ci.layout.setRowStretchFactor(row, 3)
|
||||
self.plot_widget.ci.layout.setRowStretchFactor(len(CHANNEL_LABELS), 1)
|
||||
|
||||
self.stats_label = QLabel()
|
||||
self.stats_label.setObjectName("panelMeta")
|
||||
self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
root.addWidget(self.stats_label)
|
||||
|
||||
values_row = QHBoxLayout()
|
||||
values_row.setSpacing(6)
|
||||
@@ -130,6 +220,7 @@ class ForcePlotsPanel(QFrame):
|
||||
root.addLayout(values_row)
|
||||
|
||||
self.set_theme(theme)
|
||||
self._interactive(False)
|
||||
self._apply_axis_mode()
|
||||
self.retranslate()
|
||||
|
||||
@@ -223,8 +314,17 @@ class ForcePlotsPanel(QFrame):
|
||||
self._clear_curves()
|
||||
self._last_values = (0.0, 0.0, 0.0)
|
||||
self._unit = "raw"
|
||||
self._mode = "follow"
|
||||
self._window = (0.0, 0.0)
|
||||
self._cursor_t = 0.0
|
||||
self._overview_dirty = True
|
||||
self._interactive(False)
|
||||
self.set_cursor(0.0)
|
||||
self.overview_curve.setData([], [])
|
||||
self.stats_label.setText("")
|
||||
self._render_value_cards()
|
||||
self._apply_axis_mode()
|
||||
self._refresh_mode_label()
|
||||
|
||||
def push(
|
||||
self,
|
||||
@@ -241,26 +341,51 @@ class ForcePlotsPanel(QFrame):
|
||||
# store; a bare change here would silently reinterpret old samples.
|
||||
self._unit = unit
|
||||
self._last_values = (float(fn), float(fx), float(fy))
|
||||
self._render_value_cards()
|
||||
if self._mode == "browse":
|
||||
# Browsing is a display pause, not a capture pause: the record
|
||||
# channel keeps running and the view simply stops following.
|
||||
self._overview_dirty = True
|
||||
return
|
||||
use_fixed = self._uses_fixed_y()
|
||||
# Store the true value. Clamping here (the old ``max(0.0, fn)``) erased
|
||||
# negative FN from the buffer and hid zero drift; out-of-range values are
|
||||
# now handled by the axis range, which only affects what is drawn.
|
||||
self.history.push(timestamp, float(fn), float(fx), float(fy))
|
||||
self._render_value_cards()
|
||||
state = self.history.state()
|
||||
if state is None:
|
||||
return
|
||||
values = state.values.copy()
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
self.plots[i].setYRange(ymin, ymax, padding=0.0)
|
||||
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
self.plots[i].getAxis("left").setTicks(None)
|
||||
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||
self._syncing = True
|
||||
try:
|
||||
values = state.values.copy()
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
self.plots[i].setYRange(ymin, ymax, padding=0.0)
|
||||
self.plots[i].getAxis("left").setTicks(
|
||||
[_nice_ticks(ymin, ymax, count=5)]
|
||||
)
|
||||
else:
|
||||
self.plots[i].getAxis("left").setTicks(None)
|
||||
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||
finally:
|
||||
self._syncing = False
|
||||
self.set_cursor(float(timestamp) - (self._time_origin() or 0.0))
|
||||
# The overview is a full-store read, so refresh it on a timer rather
|
||||
# than on every frame.
|
||||
now = time.perf_counter()
|
||||
if self._overview_dirty or (now - self._last_overview_at) >= OVERVIEW_REFRESH_S:
|
||||
self._overview_dirty = False
|
||||
self._last_overview_at = now
|
||||
self._sync_overview()
|
||||
|
||||
def _time_origin(self) -> float:
|
||||
"""The store's time origin, so the cursor matches the overview axis."""
|
||||
if self._store is None:
|
||||
return 0.0
|
||||
return self._store.origin_time()
|
||||
|
||||
def set_units(self, unit: str, store=None) -> None:
|
||||
"""Switch display units without mixing units in the curve buffer.
|
||||
@@ -275,11 +400,216 @@ class ForcePlotsPanel(QFrame):
|
||||
self._unit = unit
|
||||
self.history.clear()
|
||||
self._clear_curves()
|
||||
self._overview_dirty = True
|
||||
if store is not None:
|
||||
self._reload_from_store(store)
|
||||
self._store = store
|
||||
if self._mode == "browse":
|
||||
self.refresh_from_store()
|
||||
else:
|
||||
self._reload_from_store(store)
|
||||
self._render_value_cards()
|
||||
self._apply_axis_mode()
|
||||
|
||||
# ---------------------------------------------------------------- browsing
|
||||
def set_store(self, store) -> None:
|
||||
"""Attach the whole-session store the browser reads from."""
|
||||
self._store = store
|
||||
self._overview_dirty = True
|
||||
self._refresh_mode_label()
|
||||
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
def cursor_time(self) -> float:
|
||||
return self._cursor_t
|
||||
|
||||
def window(self) -> tuple[float, float]:
|
||||
if self._mode == "browse":
|
||||
return self._window
|
||||
if self._store is not None:
|
||||
return self._store.extent()
|
||||
return (0.0, 0.0)
|
||||
|
||||
def follow_latest(self) -> None:
|
||||
"""Leave history browsing and resume the live rolling window."""
|
||||
self._set_mode("follow")
|
||||
self._apply_axis_mode()
|
||||
|
||||
def show_all(self) -> None:
|
||||
"""Overview: fit the entire session, in browse mode."""
|
||||
if self._store is None:
|
||||
return
|
||||
first, last = self._store.extent()
|
||||
if last <= first:
|
||||
return
|
||||
self._set_mode("browse")
|
||||
self.set_window(first, last)
|
||||
|
||||
def set_window(self, t0: float, t1: float) -> None:
|
||||
"""Show ``[t0, t1]`` from the whole-session store."""
|
||||
if self._store is None:
|
||||
return
|
||||
first, last = self._store.extent()
|
||||
if t1 <= t0:
|
||||
t1 = t0 + max(0.001, self.history.window_sec)
|
||||
t0 = max(first, float(t0))
|
||||
t1 = min(max(last, first + 0.001), float(t1))
|
||||
self._set_mode("browse")
|
||||
self._window = (t0, t1)
|
||||
self.refresh_from_store()
|
||||
|
||||
def refresh_from_store(self) -> None:
|
||||
"""Redraw the visible window. Called on pan, zoom and data changes."""
|
||||
if self._store is None:
|
||||
return
|
||||
t0, t1 = self._window
|
||||
slice_ = self._store.visible(t0, t1, max_points=self._max_draw_points)
|
||||
self._syncing = True
|
||||
try:
|
||||
if len(slice_) == 0:
|
||||
for curve in self.curves:
|
||||
curve.setData([], [])
|
||||
else:
|
||||
values = slice_.value_for_unit(self._unit)
|
||||
for index, curve in enumerate(self.curves):
|
||||
column = values[:, index]
|
||||
finite = np.isfinite(column)
|
||||
curve.setData(slice_.times[finite], column[finite])
|
||||
for plot in self.plots:
|
||||
plot.setXRange(t0, t1, padding=0.0)
|
||||
self._apply_axis_mode()
|
||||
finally:
|
||||
self._syncing = False
|
||||
self._update_selection_readout()
|
||||
self._sync_overview()
|
||||
self.window_changed.emit(t0, t1)
|
||||
|
||||
def _set_mode(self, mode: str) -> None:
|
||||
mode = "browse" if str(mode) == "browse" else "follow"
|
||||
if mode == self._mode:
|
||||
return
|
||||
self._mode = mode
|
||||
self._interactive(mode == "browse")
|
||||
self._refresh_mode_label()
|
||||
self._sync_overview()
|
||||
self.mode_changed.emit(mode)
|
||||
|
||||
def _interactive(self, enabled: bool) -> None:
|
||||
"""Pan/zoom is only meaningful over a stored window."""
|
||||
for plot in self.plots:
|
||||
plot.setMouseEnabled(x=bool(enabled), y=False)
|
||||
if not enabled:
|
||||
plot.getViewBox().setMouseMode(pg.ViewBox.PanMode)
|
||||
|
||||
def _on_box_zoom_toggled(self, checked: bool) -> None:
|
||||
mode = pg.ViewBox.RectMode if checked else pg.ViewBox.PanMode
|
||||
if not checked or self._mode != "browse":
|
||||
self._set_mode("browse" if checked else self._mode)
|
||||
for plot in self.plots:
|
||||
plot.getViewBox().setMouseMode(mode)
|
||||
|
||||
def _on_x_range_changed(self, _viewbox, x_range) -> None:
|
||||
"""User scrolled or dragged: leave live mode and read from the store.
|
||||
|
||||
Programmatic range changes are ignored via ``self._syncing`` — without
|
||||
that, redrawing a window would feed back in as if the user had dragged,
|
||||
and "back to latest" could never take effect.
|
||||
"""
|
||||
if self._syncing or self._store is None:
|
||||
return
|
||||
x0, x1 = float(x_range[0]), float(x_range[1])
|
||||
if not (x1 > x0):
|
||||
return
|
||||
first, last = self._store.extent()
|
||||
if last <= first:
|
||||
return
|
||||
# Keep the window inside the recorded session.
|
||||
x0 = max(first, x0)
|
||||
x1 = min(last, x1)
|
||||
if not (x1 > x0):
|
||||
return
|
||||
if self._mode != "browse":
|
||||
self._set_mode("browse")
|
||||
self._window = (x0, x1)
|
||||
self.refresh_from_store()
|
||||
|
||||
def _guard(self) -> "_Sync":
|
||||
"""Context manager marking range changes as programmatic."""
|
||||
return _Sync(self)
|
||||
|
||||
def set_cursor(self, t: float, *, emit: bool = False) -> None:
|
||||
"""Move the shared time cursor without redrawing the data."""
|
||||
self._cursor_t = float(t)
|
||||
for cursor in self.cursors:
|
||||
cursor.setValue(self._cursor_t)
|
||||
self.overview_cursor.setValue(self._cursor_t)
|
||||
if emit:
|
||||
self.cursor_moved.emit(self._cursor_t)
|
||||
|
||||
def _update_selection_readout(self) -> None:
|
||||
if self._store is None:
|
||||
self.stats_label.setText("")
|
||||
return
|
||||
t0, t1 = self._window
|
||||
stats = self._store.statistics(t0, t1, unit=self._unit)
|
||||
if stats.samples == 0:
|
||||
self.stats_label.setText(t("chart.selection_empty"))
|
||||
return
|
||||
unit = unit_display(self._unit)
|
||||
parts = []
|
||||
for index, label in enumerate(CHANNEL_LABELS):
|
||||
peak = stats.peaks[index]
|
||||
mean = stats.means[index]
|
||||
std = stats.stds[index]
|
||||
if not np.isfinite(peak):
|
||||
continue
|
||||
parts.append(
|
||||
f"{label} 峰 {peak:.3g}{unit} · 均 {mean:.3g} · σ {std:.3g}"
|
||||
)
|
||||
self.stats_label.setText(
|
||||
t(
|
||||
"chart.selection",
|
||||
duration=f"{stats.duration_s:.2f}",
|
||||
samples=stats.samples,
|
||||
detail=" ".join(parts),
|
||||
)
|
||||
)
|
||||
|
||||
def _sync_overview(self) -> None:
|
||||
if self._store is None:
|
||||
return
|
||||
first, last = self._store.extent()
|
||||
if last <= first:
|
||||
self.overview_curve.setData([], [])
|
||||
return
|
||||
# The overview is a full-store read, so only rebuild it when it can have
|
||||
# changed (window/unit/session) rather than on every pan.
|
||||
slice_ = self._store.visible(first, last, max_points=self._overview_points())
|
||||
values = slice_.value_for_unit(self._unit)
|
||||
column = values[:, 0]
|
||||
finite = np.isfinite(column)
|
||||
self.overview_curve.setData(slice_.times[finite], column[finite])
|
||||
self.overview.setXRange(first, last, padding=0.0)
|
||||
if self._mode == "browse":
|
||||
self.overview_region.setRegion(self._window)
|
||||
self.overview_region.show()
|
||||
else:
|
||||
self.overview_region.hide()
|
||||
self.overview.setYRange(
|
||||
float(np.nanmin(column[finite])) if finite.any() else 0.0,
|
||||
float(np.nanmax(column[finite])) if finite.any() else 1.0,
|
||||
padding=0.05,
|
||||
)
|
||||
|
||||
def _overview_points(self) -> int:
|
||||
return 1200
|
||||
|
||||
def _refresh_mode_label(self) -> None:
|
||||
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
|
||||
self.mode_label.setText(t(key))
|
||||
self.btn_latest.setEnabled(self._mode == "browse")
|
||||
self.btn_overview.setEnabled(self._store is not None)
|
||||
|
||||
def _reload_from_store(self, store) -> None:
|
||||
"""Re-populate the visible buffer from the whole-session store."""
|
||||
window = self.history.window_sec
|
||||
@@ -337,15 +667,17 @@ class ForcePlotsPanel(QFrame):
|
||||
|
||||
def _apply_axis_mode(self) -> None:
|
||||
use_fixed = self._preview_fixed_y()
|
||||
for i, plot in enumerate(self.plots):
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
plot.setYRange(ymin, ymax, padding=0.0)
|
||||
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
plot.getAxis("left").setTicks(None)
|
||||
for plot in self.plots:
|
||||
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
||||
with self._guard():
|
||||
for i, plot in enumerate(self.plots):
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
plot.setYRange(ymin, ymax, padding=0.0)
|
||||
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
plot.getAxis("left").setTicks(None)
|
||||
if self._mode == "follow":
|
||||
for plot in self.plots:
|
||||
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
||||
|
||||
def _clear_curves(self) -> None:
|
||||
for curve in self.curves:
|
||||
@@ -364,4 +696,11 @@ class ForcePlotsPanel(QFrame):
|
||||
self.title.setText(t("panels.plots"))
|
||||
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
||||
badge.setText(label)
|
||||
self.btn_latest.setText(t("chart.return_latest"))
|
||||
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
|
||||
self.btn_overview.setText(t("chart.overview"))
|
||||
self.btn_overview.setToolTip(t("chart.overview_tip"))
|
||||
self.btn_box_zoom.setText(t("chart.box_zoom"))
|
||||
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
|
||||
self._refresh_mode_label()
|
||||
self._render_value_cards()
|
||||
|
||||
Reference in New Issue
Block a user