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:
ykcheungab
2026-09-13 13:34:26 +08:00
parent 817f830565
commit 92da51217c
13 changed files with 1576 additions and 75 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+49 -2
View File
@@ -102,7 +102,24 @@
"fn": "FN",
"fx": "FX",
"fy": "FY",
"no_data": "—"
"no_data": "—",
"mode_follow": "Following live",
"mode_browse": "Browsing history (display paused; capture and recording continue)",
"return_latest": "Back to latest",
"return_latest_tip": "Leave history browsing and follow the newest data again",
"overview": "Whole session",
"overview_tip": "Fit the entire session; drag the overview strip to move the window",
"box_zoom": "Box zoom",
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
"selection": "Selection {duration}s / {samples} samples — {detail}",
"selection_empty": "No valid samples in the selection",
"browse_only_tip": "Drag or scroll on the chart to browse history"
},
"layout": {
"default": "Free layout",
"preset_2d_name": "2D mode (depth + charts)",
"preset_2d": "2D mode: depth map on the left, force charts on the right, one shared time axis",
"preset_default": "Free layout restored"
},
"record": {
"label": "Record",
@@ -135,10 +152,40 @@
"sn_placeholder": "Serial number (optional)",
"live_raw_label": "Live raw",
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
"col_weight": "Weight (N)",
"col_weight": "Reference load",
"col_fn": "FN mean",
"col_fx": "FX mean",
"col_fy": "FY mean",
"col_stability": "Stability",
"col_state": "State",
"col_raw": "Raw mean",
"col_n": "n",
"col_std": "σ",
"state_ok": "OK",
"state_excluded": "Excluded",
"state_validation": "Validation",
"state_exclude": "Exclude",
"state_exclude_tip": "Drop the selected point from the fit and the export (kept on record, reversible)",
"state_restore": "Restore",
"mark_validation": "Mark validation",
"mark_validation_tip": "Hold the selected point out of the fit and use it to test the fit",
"excluded": "Point excluded from the fit",
"restored": "Point restored",
"marked_validation": "Point marked as a validation point",
"unmarked_validation": "Validation mark removed",
"fit_insufficient": "A fit needs at least 2 usable points (currently {count})",
"fit_summary": "Fit {channel}: N = {gain} × raw + {offset} ({count} points, max residual {residual} N)",
"fit_validation": "Independent validation on {count} point(s), max deviation {error} N",
"point_title": "Calibration point {channel} @ {load} N",
"point_channel": "Loaded axis",
"point_direction": "Direction",
"point_round": "Round",
"point_duration": "Actual sampling time",
"point_duration_value": "{actual} s (requested {requested} s)",
"point_samples": "Samples",
"point_samples_value": "{valid} valid / {total} total",
"point_axes": "Three-axis mean ± std",
"point_residual": "Fit residual",
"col_actions": "",
"recording": "Recording calibration samples…",
"recording_stop_tip": "Click to cancel this calibration recording (always available)",
+50 -4
View File
@@ -102,10 +102,26 @@
"fn": "FN",
"fx": "FX",
"fy": "FY",
"no_data": "—"
"no_data": "—",
"mode_follow": "跟随实时",
"mode_browse": "浏览历史(显示暂停,采集与记录继续)",
"return_latest": "返回最新",
"return_latest_tip": "退出历史浏览,恢复跟随最新数据",
"overview": "全程总览",
"overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗",
"box_zoom": "框选",
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
"selection": "选区 {duration}s / {samples} 点 — {detail}",
"selection_empty": "选区无有效样本",
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
},
"record": {
"label": "记录",
"layout": {
"default": "自由布局",
"preset_2d_name": "二维模式(深度图 + 曲线)",
"preset_2d": "已切换到二维模式:左侧深度图、右侧三轴曲线,共用时间轴",
"preset_default": "已恢复自由布局"
},
"record": { "label": "记录",
"mode_video_data": "视频 + 数据",
"mode_data_only": "仅数据",
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
@@ -136,10 +152,40 @@
"sn_placeholder": "序列号(可选)",
"live_raw_label": "实时原始值",
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
"col_weight": "重量 (N)",
"col_weight": "参考载荷",
"col_fn": "FN 均值",
"col_fx": "FX 均值",
"col_fy": "FY 均值",
"col_stability": "稳定性",
"col_state": "状态",
"col_raw": "原始均值",
"col_n": "n",
"col_std": "σ",
"state_ok": "正常",
"state_excluded": "已排除",
"state_validation": "验证点",
"state_exclude": "排除",
"state_exclude_tip": "把选中点排除出拟合与导出(记录保留,可恢复)",
"state_restore": "恢复",
"mark_validation": "设为验证点",
"mark_validation_tip": "把选中点留作独立验证点:不参与拟合,用于检验拟合",
"excluded": "该点已排除出拟合",
"restored": "该点已恢复",
"marked_validation": "该点已标记为验证点",
"unmarked_validation": "该点已取消验证点标记",
"fit_insufficient": "拟合需要至少 2 个有效点(当前 {count} 个)",
"fit_summary": "拟合 {channel}: N = {gain} × raw + {offset}({count} 点,最大残差 {residual} N)",
"fit_validation": "独立验证 {count} 点,最大偏差 {error} N",
"point_title": "标定点 {channel} @ {load} N",
"point_channel": "加载轴",
"point_direction": "方向",
"point_round": "轮次",
"point_duration": "实际采样时长",
"point_duration_value": "{actual} s(请求 {requested} s)",
"point_samples": "样本数",
"point_samples_value": "有效 {valid} / 共 {total}",
"point_axes": "三轴均值 ± 标准差",
"point_residual": "拟合残差",
"col_actions": "",
"recording": "正在采集标定样本…",
"recording_stop_tip": "点击可取消本次标定采集(随时可用)",
+54 -5
View File
@@ -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
+195 -29
View File
@@ -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),
)
+363 -24
View File
@@ -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()
+88 -7
View File
@@ -1,9 +1,11 @@
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
import ui_check
@@ -199,7 +201,7 @@ def test_toolbar_run_button_is_labelled(make_window):
window = _show(make_window(), (1400, 880))
label = window.toolbar.btn_run.text()
assert label.strip()
assert label != "â–¶"
assert label != "â–¶"
window.toolbar.set_run_checked(True)
assert window.toolbar.btn_run.text() != label
@@ -264,14 +266,92 @@ def test_cal_panel_records_and_lists_samples(make_window):
cal.set_running(True)
assert cal.btn_record.isEnabled()
assert cal.btn_record.text()
assert cal.table.columnCount() == 6, "the diagnostic columns changed"
from orisys_app_shared.force.calibration_io import CalSample
cal._store.upsert("fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02))
cal._refresh_table()
assert cal.table.rowCount() == 1
_add_calibration_point(window, load=1.0, raw=100.2)
_add_calibration_point(window, load=2.0, raw=400.0) # off-trend
_add_calibration_point(window, load=3.0, raw=300.6)
assert cal.table.rowCount() == 3
assert not cal.empty_hint.isVisible()
headers = [
cal.table.horizontalHeaderItem(i).text() for i in range(cal.table.columnCount())
]
from orisys_app_shared.i18n import t
assert headers == [
t("cal.col_weight"),
t("cal.col_fn"),
t("cal.col_fx"),
t("cal.col_fy"),
t("cal.col_stability"),
t("cal.col_state"),
]
def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40) -> None:
"""Drive one point through the recorder and into the panel's table."""
from orisys_app_shared.force.session import SessionSample
window.cal_recorder.start(channel="fn", reference_load_n=load, duration_s=1.0)
for index in range(samples):
window.cal_recorder.feed(
SessionSample(
frame_id=index,
t_rel_s=index / 100.0,
fn_raw=raw,
fx_raw=0.4,
fy_raw=-0.2,
valid=True,
)
)
window.cal_panel.on_record_finished(window.cal_recorder.finish_now())
def test_calibration_diagnostics_drive_the_fit(make_window):
window = _show(make_window(), (1400, 880))
window.cal_panel.set_running(True)
_add_calibration_point(window, load=1.0, raw=100.2)
_add_calibration_point(window, load=2.0, raw=400.0)
_add_calibration_point(window, load=3.0, raw=300.6)
_add_calibration_point(window, load=5.0, raw=500.9)
recorder = window.cal_recorder
fit = recorder.fit("fn")
assert fit.usable and fit.n_points == 4
worst = fit.max_residual_n
assert worst > 0.05, "the off-trend point produced no residual"
assert window.cal_panel.fit_label.text()
window.cal_panel.table.setCurrentCell(1, 0)
window.cal_panel._on_toggle_excluded()
assert recorder.fit("fn").max_residual_n < worst, "exclusion did not tighten the fit"
assert recorder.points[1].excluded
# An excluded point leaves the export store but stays on record.
assert len(window.cal_panel._store.channel("fn")) == 3
window.cal_panel.table.setCurrentCell(2, 0)
window.cal_panel._on_toggle_validation()
held_out = recorder.fit("fn")
assert held_out.validation_points == 1
assert recorder.points[2].is_validation
assert np.isnan(recorder.points[2].residual_n), "validation point entered the fit"
def test_calibration_point_dialog_shows_three_axes(make_window):
window = _show(make_window(), (1400, 880))
_add_calibration_point(window, load=1.0, raw=100.2)
point = window.cal_recorder.points[0]
assert set(point.trace) == {"fn", "fx", "fy"}
assert point.trace_dt_s > 0
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
dialog = CalibrationPointDialog(point, theme="dark")
assert dialog.windowTitle()
assert len(dialog.plot_widget.ci.items) == 3, "not one trace per axis"
dialog.close()
def test_force_plots_render_demo_data(make_window):
window = _show(make_window(), (1440, 880))
@@ -516,3 +596,4 @@ def test_recording_leaves_unavailable_values_empty(make_window, record_root):
_finish(window, stuck)
+207 -3
View File
@@ -61,6 +61,23 @@ def section(name: str) -> None:
print(f"\n== {name} ==")
def _calibration_sample(index: int, *, fn_raw: float, fx_raw: float = 0.4, fy_raw: float = -0.2):
"""A sample shaped for the calibration recorder."""
from orisys_app_shared.force.session import SessionSample
return SessionSample(
frame_id=index,
t_rel_s=index / 100.0,
fn_raw=fn_raw,
fx_raw=fx_raw,
fy_raw=fy_raw,
fn_n=fn_raw / 100.0,
fx_n=fx_raw / 100.0,
fy_n=fy_raw / 100.0,
valid=True,
)
def _fake_packet(
frame_id: int,
*,
@@ -75,6 +92,8 @@ def _fake_packet(
nan = float("nan")
blank = np.zeros((8, 8, 3), np.uint8)
# A single tall sample makes it easy to prove decimation keeps peaks.
fn_raw = 99.0 if frame_id == 700 else (9.0 + frame_id * 0.01)
return SdkPacket(
raw_frame=blank,
processed_frame=blank,
@@ -495,15 +514,71 @@ def main() -> int:
window.cal_panel.set_running(True)
check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled")
window.cal_panel._store.upsert(
"fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02)
check(
window.cal_panel.table.columnCount() == 6,
"the calibration table is no longer the six-column diagnostic view",
)
window.cal_panel._refresh_table()
# Drive a real point through the recorder: the table is a view over
# the recorded points, not over a separate sample list.
window.cal_recorder.start(
channel="fn", reference_load_n=1.0, duration_s=1.0
)
for index in range(40):
window.cal_recorder.feed(
_calibration_sample(index, fn_raw=100.0 + index * 0.01)
)
point = window.cal_recorder.finish_now()
window.cal_panel.on_record_finished(point)
app.processEvents()
check(window.cal_panel.table.rowCount() == 1, "calibration row missing")
check(
not window.cal_panel.empty_hint.isVisible(),
"empty hint still visible with rows",
)
check(
window.cal_panel.fit_label.text() != "",
"the fit summary is empty",
)
# Three more points, one of them off-trend, so the fit has a residual
# that excluding the outlier must reduce and enough left over to hold
# one out as a validation point.
for load, raw in ((2.0, 400.0), (3.0, 300.6), (5.0, 500.9)):
window.cal_recorder.start(
channel="fn", reference_load_n=load, duration_s=1.0
)
for index in range(40):
window.cal_recorder.feed(_calibration_sample(index, fn_raw=raw))
window.cal_panel.on_record_finished(window.cal_recorder.finish_now())
app.processEvents()
check(window.cal_panel.table.rowCount() == 4, "calibration rows missing")
worst = window.cal_recorder.fit("fn").max_residual_n
check(worst > 0.05, f"the off-trend point produced no residual: {worst}")
window.cal_panel.table.setCurrentCell(1, 0)
window.cal_panel._on_toggle_excluded()
app.processEvents()
check(
window.cal_recorder.fit("fn").max_residual_n < worst,
"excluding an outlier did not tighten the fit",
)
check(
window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"),
"the excluded row does not show its state",
)
# A validation point is held out of the fit and reported separately.
window.cal_panel.table.setCurrentCell(2, 0)
window.cal_panel._on_toggle_validation()
app.processEvents()
check(
t("cal.fit_validation").split("{")[0].strip()
in window.cal_panel.fit_label.text(),
"no validation summary after marking a validation point",
)
window.cal_recorder.reset_session()
window.cal_panel._refresh_table()
window._set_phase("running")
check("status_running" not in window.status_chip.text(), "chip not localized")
@@ -843,6 +918,135 @@ def main() -> int:
stuck.release()
_shutil.rmtree(scratch, ignore_errors=True)
section("session browsing")
import numpy as np
stuck = _StuckWorker()
window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
)
window.resize(1440, 880)
window.show()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
for index in range(1500):
stuck.emit(_fake_packet(index))
app.processEvents()
plots = window.force_plots
check(plots.mode() == "follow", "the charts do not start in live mode")
check(
plots.stats_label.text() == "",
"the selection readout is not blank while following live",
)
first, last = window.session_store.extent()
check(last > first, "the session store has no extent after recording")
plots.show_all()
check(plots.mode() == "browse", "the overview did not enter browse mode")
check(
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
"browsing does not enable panning",
)
window_texts = plots.stats_label.text()
check(window_texts != "", "no selection statistics in browse mode")
check("峰" in window_texts, f"the peak is missing from the readout: {window_texts!r}")
# Decimation must keep the spike while bounding the point count.
drawn = max(int(curve.getData()[0].size) for curve in plots.curves)
check(drawn <= 2 * plots._max_draw_points, f"overview drew {drawn} points")
spike = float(np.nanmax(plots.curves[0].getData()[1]))
check(spike > 90.0, f"the overview lost the spike: {spike}")
plots.set_window(10.0, 20.0)
check(plots.window() == (10.0, 20.0), f"set_window did not apply: {plots.window()}")
check(
plots.overview_curve.getData()[0].size > 0,
"the whole-session overview strip is empty",
)
# A user gesture enters browse; "back to latest" must actually leave it.
plots._syncing = False
plots.plots[0].setXRange(5.0, 8.0, padding=0.0)
app.processEvents()
check(plots.mode() == "browse", "a user pan did not enter browse mode")
plots.follow_latest()
check(plots.mode() == "follow", "back-to-latest did not restore live mode")
plots.set_cursor(3.5, emit=True)
check(
[round(cursor.value(), 3) for cursor in plots.cursors] == [3.5, 3.5, 3.5],
"the time cursor is not shared across the three channels",
)
# History browsing is a display pause: capture and recording keep going.
plots.show_all()
before = window.session_store.count
for index in range(1500, 1560):
stuck.emit(_fake_packet(index))
check(
window.session_store.count > before,
"browsing history stopped the record channel",
)
window.close()
app.processEvents()
stuck.release()
section("layout preset")
stuck = _StuckWorker()
window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "finger",
"source_mode": "camera",
"show_finger_3d": True,
}
)
window.resize(1600, 900)
window.show()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
app.processEvents()
check(window._layout_preset == "default", "the layout preset did not load")
check(window.mesh3d_panel.isVisible(), "the 3D panel is hidden in the free layout")
window._on_layout_preset("2d")
app.processEvents()
check(window._layout_preset == "2d", "the 2D preset did not apply")
check(not window.mesh3d_panel.isVisible(), "the 2D preset keeps the 3D panel")
check(
window.arrow_panel.view_mode() == "depth",
"the 2D preset does not show the depth map",
)
check(not window.cal_panel.isVisible(), "the 2D preset keeps calibration expanded")
check(not window.act_finger_3d.isEnabled(), "the 3D toggle is live in the 2D preset")
check(window.act_layout_2d.isChecked(), "the 2D menu entry is not checked")
window._on_layout_preset("default")
app.processEvents()
check(window.mesh3d_panel.isVisible(), "the free layout did not restore the 3D panel")
check(window.act_finger_3d.isEnabled(), "the 3D toggle stayed disabled")
window._persist_prefs()
check(window.prefs.get("layout_preset") == "default", "the preset was not persisted")
window.close()
app.processEvents()
stuck.release()
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
for failure in FAILURES:
print(f" FAIL: {failure}")