subject: fix calibration cancel, unit mixing, FN clamping, export/apply and depth scale
This commit is contained in:
@@ -74,6 +74,11 @@ ramp monotonicity) that guard the brand tokens; see
|
||||
colour system, its relationship to the UI design spec, and every deliberate
|
||||
deviation from it.
|
||||
|
||||
`ui_check.py` includes a `record channel` section as well; see
|
||||
[`apps/studio/docs/record-channel.md`](apps/studio/docs/record-channel.md) for
|
||||
why recording and calibration sampling no longer hang off the UI refresh timer,
|
||||
and for the data-reliability fixes that came with it.
|
||||
|
||||
## Branding
|
||||
|
||||
Window / EXE icon: `shared/resources/branding/logo.png`
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
"view_mode_tip": "Image display mode while running",
|
||||
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
|
||||
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
|
||||
"depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)",
|
||||
"depth_scale": "Depth scale {low} – {high}",
|
||||
"depth_scale_tip": "Fixed for this session: the range is latched to the first frame that carried depth so frames stay comparable. Grey marks samples with no depth.",
|
||||
"plots": "Force Charts",
|
||||
"cal": "Calibration",
|
||||
"mesh3d": "3D mesh",
|
||||
@@ -122,12 +125,18 @@
|
||||
"col_std": "σ",
|
||||
"col_actions": "",
|
||||
"recording": "Recording calibration samples…",
|
||||
"recording_stop_tip": "Click to cancel this calibration recording (always available)",
|
||||
"record_ok": "Recorded weight={weight} raw={raw}",
|
||||
"record_failed_few": "Recording failed — too few samples",
|
||||
"export_ok": "Exported {path}",
|
||||
"export_local": "Also wrote {path}",
|
||||
"export_warnings": "Skipped: {detail}",
|
||||
"export_failed": "Export failed: {error}"
|
||||
"export_failed": "Export failed: {error}",
|
||||
"apply_title": "Apply calibration",
|
||||
"apply_confirm": "Apply the calibration just exported to the current device?\n\nSensor type: {sensor_type}\nConfig: {config}\n\nChoosing No keeps the exported file and leaves the running device untouched.",
|
||||
"apply_skipped": "Exported; not applied to the device",
|
||||
"apply_done": "Calibration applied to {sensor_type}",
|
||||
"apply_no_target": "No device to apply to (run an acquisition first)"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Ready",
|
||||
@@ -146,6 +155,8 @@
|
||||
"run_stopped": "Stopped",
|
||||
"run_stopping": "Stopping… (releasing the device)",
|
||||
"run_stopping_cancelled": "Connection cancelled (releasing the device)",
|
||||
"sample_invalid": "Invalid sample (#{count}): {reason} — marked invalid and excluded from the curves and from calibration",
|
||||
"samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)",
|
||||
"run_failed": "Run failed: {error}",
|
||||
"video_ended": "Video ended",
|
||||
"camera_disconnected": "Camera disconnected",
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
"view_mode_tip": "运行时的图像显示模式",
|
||||
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
|
||||
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
||||
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
|
||||
"depth_scale": "深度色标 {low} – {high}",
|
||||
"depth_scale_tip": "本会话固定色标:色标范围锁定在首个有效深度帧,便于跨帧比较;灰色区域表示无深度数据",
|
||||
"plots": "力曲线图",
|
||||
"cal": "标定",
|
||||
"mesh3d": "三维网格",
|
||||
@@ -123,12 +126,18 @@
|
||||
"col_std": "σ",
|
||||
"col_actions": "",
|
||||
"recording": "正在采集标定样本…",
|
||||
"recording_stop_tip": "点击可取消本次标定采集(随时可用)",
|
||||
"record_ok": "已记录 weight={weight} raw={raw}",
|
||||
"record_failed_few": "采集失败 — 样本过少",
|
||||
"export_ok": "已导出 {path}",
|
||||
"export_local": "同时写入 {path}",
|
||||
"export_warnings": "跳过: {detail}",
|
||||
"export_failed": "导出失败: {error}"
|
||||
"export_failed": "导出失败: {error}",
|
||||
"apply_title": "应用标定",
|
||||
"apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。",
|
||||
"apply_skipped": "已导出,未应用到设备",
|
||||
"apply_done": "标定已应用到 {sensor_type}",
|
||||
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
|
||||
},
|
||||
"status": {
|
||||
"ready": "就绪",
|
||||
@@ -147,6 +156,8 @@
|
||||
"run_stopped": "已停止",
|
||||
"run_stopping": "正在停止…(正在释放设备)",
|
||||
"run_stopping_cancelled": "已取消连接(正在释放设备)",
|
||||
"sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定",
|
||||
"samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)",
|
||||
"run_failed": "启动失败: {error}",
|
||||
"video_ended": "视频已结束",
|
||||
"camera_disconnected": "摄像机已断开",
|
||||
|
||||
@@ -43,6 +43,11 @@ class ArrowPanel(QFrame):
|
||||
self.fps_label = QLabel()
|
||||
self.fps_label.setObjectName("panelMeta")
|
||||
self.fps_label.setVisible(False)
|
||||
# Shows the fixed depth colour-scale range, so two frames can be
|
||||
# compared instead of the scale silently re-fitting every frame.
|
||||
self.depth_scale_label = QLabel()
|
||||
self.depth_scale_label.setObjectName("panelMeta")
|
||||
self.depth_scale_label.setVisible(False)
|
||||
self._fps_visible = False
|
||||
self._fps_value: float | None = None
|
||||
self._rotation_deg = 90
|
||||
@@ -53,6 +58,7 @@ class ArrowPanel(QFrame):
|
||||
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
|
||||
header.addWidget(self.title)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self.depth_scale_label)
|
||||
header.addWidget(self.fps_label)
|
||||
header.addWidget(self.view_combo)
|
||||
layout.addLayout(header)
|
||||
@@ -111,6 +117,16 @@ class ArrowPanel(QFrame):
|
||||
self._fps_value = None if fps is None else float(fps)
|
||||
self._refresh_fps_label()
|
||||
|
||||
def set_depth_scale(self, text: str | None) -> None:
|
||||
"""Show the fixed depth colour-scale range (``None`` hides it)."""
|
||||
if not text:
|
||||
self.depth_scale_label.clear()
|
||||
self.depth_scale_label.setVisible(False)
|
||||
return
|
||||
self.depth_scale_label.setText(str(text))
|
||||
self.depth_scale_label.setToolTip(t("panels.depth_scale_tip"))
|
||||
self.depth_scale_label.setVisible(True)
|
||||
|
||||
def _refresh_fps_label(self) -> None:
|
||||
if not self._fps_visible or self._fps_value is None:
|
||||
self.fps_label.clear()
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
@@ -35,7 +32,7 @@ from orisys_app_shared.force.calibration_io import (
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir, sensors_dir
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
CHANNEL_LABELS = ("fn", "fx", "fy")
|
||||
@@ -51,7 +48,8 @@ def _mono_font() -> str:
|
||||
|
||||
class CalPanel(QFrame):
|
||||
status_message = pyqtSignal(str)
|
||||
calibration_exported = pyqtSignal(str) # path applied to running sensor
|
||||
#: Coefficients were written; the window asks the user before applying them.
|
||||
calibration_exported = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
@@ -60,16 +58,14 @@ class CalPanel(QFrame):
|
||||
self._store = CalSessionStore()
|
||||
self._channel: ChannelKey = "fn"
|
||||
self._running = False
|
||||
self._recording = False
|
||||
self._record_deadline = 0.0
|
||||
self._record_started = 0.0
|
||||
self._record_samples: list[float] = []
|
||||
#: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the
|
||||
#: lifecycle no longer depends on this panel being visible.
|
||||
self._recorder = None
|
||||
self._live_fn = 0.0
|
||||
self._live_fx = 0.0
|
||||
self._live_fy = 0.0
|
||||
self._sensor_type = "box"
|
||||
self._config_ref: str | None = None
|
||||
self._record_duration_s = 7.0
|
||||
self._mono = _mono_font()
|
||||
self._mono_font = QFont(self._mono, 10)
|
||||
|
||||
@@ -201,32 +197,58 @@ class CalPanel(QFrame):
|
||||
self._theme = theme
|
||||
self._render_live_label()
|
||||
|
||||
# --------------------------------------------------------- recorder link
|
||||
def bind_recorder(self, recorder) -> None:
|
||||
"""Attach the sampling state machine that owns the recording lifecycle."""
|
||||
self._recorder = recorder
|
||||
|
||||
@property
|
||||
def _recording(self) -> bool:
|
||||
return bool(self._recorder is not None and self._recorder.is_recording)
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._running = bool(running)
|
||||
if not self._running and self._recording:
|
||||
self._cancel_recording()
|
||||
self._recorder.cancel()
|
||||
self._refresh_enabled()
|
||||
|
||||
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
|
||||
self._sensor_type = str(sensor_type or "box")
|
||||
self._config_ref = config_ref
|
||||
|
||||
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
|
||||
def set_live(self, fn: float, fx: float, fy: float) -> None:
|
||||
"""Update the live readout. Sampling is handled by the recorder."""
|
||||
self._live_fn = float(fn)
|
||||
self._live_fx = float(fx)
|
||||
self._live_fy = float(fy)
|
||||
self._render_live_label()
|
||||
if not self._recording:
|
||||
|
||||
def set_progress(self, fraction: float, elapsed_s: float, total_s: float) -> None:
|
||||
self.progress.setVisible(True)
|
||||
self.progress.setValue(max(0, min(1000, int(fraction * 1000))))
|
||||
self.progress.setFormat(f"{elapsed_s:.1f} / {total_s:.1f} s")
|
||||
|
||||
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._refresh_table()
|
||||
if point.status == "few_samples":
|
||||
self.status_message.emit(t("cal.record_failed_few"))
|
||||
return
|
||||
now = time.perf_counter()
|
||||
value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel]
|
||||
self._record_samples.append(float(value))
|
||||
span = max(1e-6, self._record_deadline - self._record_started)
|
||||
frac = min(1.0, max(0.0, (now - self._record_started) / span))
|
||||
self.progress.setValue(int(frac * 1000))
|
||||
self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s")
|
||||
if now >= self._record_deadline:
|
||||
self._finish_recording()
|
||||
self.status_message.emit(
|
||||
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
|
||||
)
|
||||
|
||||
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
|
||||
"""Back-compat shim: live readout only, sampling lives in the recorder."""
|
||||
self.set_live(fn, fx, fy)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.cal"))
|
||||
@@ -277,14 +299,19 @@ class CalPanel(QFrame):
|
||||
if key is None:
|
||||
return
|
||||
if self._recording:
|
||||
self._cancel_recording()
|
||||
self._recorder.cancel()
|
||||
self._channel = key # type: ignore[assignment]
|
||||
self._refresh_channel_badge()
|
||||
self._refresh_table()
|
||||
|
||||
def _refresh_enabled(self) -> None:
|
||||
can_record = self._running and not self._recording
|
||||
# While recording the button must stay **enabled**: it is the cancel
|
||||
# affordance. Disabling it here was why a recording could not be stopped.
|
||||
can_record = self._running
|
||||
self.btn_record.setEnabled(can_record)
|
||||
self.btn_record.setText(
|
||||
t("cal.recording_stop") if self._recording else t("cal.record")
|
||||
)
|
||||
self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton")
|
||||
self.btn_record.style().unpolish(self.btn_record)
|
||||
self.btn_record.style().polish(self.btn_record)
|
||||
@@ -298,6 +325,8 @@ class CalPanel(QFrame):
|
||||
record_hint = t("cal.record_disabled") if not self._running else ""
|
||||
if not self._recording:
|
||||
self.btn_record.setToolTip(record_hint)
|
||||
else:
|
||||
self.btn_record.setToolTip(t("cal.recording_stop_tip"))
|
||||
|
||||
def _render_live_label(self) -> None:
|
||||
colors = {
|
||||
@@ -321,53 +350,47 @@ class CalPanel(QFrame):
|
||||
)
|
||||
|
||||
def _on_record_clicked(self) -> None:
|
||||
if self._recorder is None:
|
||||
return
|
||||
if self._recording:
|
||||
self._cancel_recording()
|
||||
self.status_message.emit(t("cal.record_cancelled"))
|
||||
self._recorder.cancel()
|
||||
return
|
||||
if not self._running:
|
||||
return
|
||||
self._recording = True
|
||||
self._record_samples = []
|
||||
self._record_duration_s = float(self.duration_spin.value())
|
||||
self._record_started = time.perf_counter()
|
||||
self._record_deadline = self._record_started + self._record_duration_s
|
||||
duration = float(self.duration_spin.value())
|
||||
self._recorder.start(
|
||||
channel=self._channel,
|
||||
reference_load_n=float(self.weight_spin.value()),
|
||||
duration_s=duration,
|
||||
direction="load",
|
||||
)
|
||||
self.progress.setVisible(True)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
|
||||
self.progress.setFormat(f"0.0 / {duration:.1f} s")
|
||||
self._refresh_enabled()
|
||||
self.status_message.emit(t("cal.recording"))
|
||||
|
||||
def _cancel_recording(self) -> None:
|
||||
self._recording = False
|
||||
self._record_samples = []
|
||||
def on_record_cancelled(self) -> None:
|
||||
"""Called by the window when the recorder was cancelled."""
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat("")
|
||||
self._refresh_enabled()
|
||||
self.status_message.emit(t("cal.record_cancelled"))
|
||||
|
||||
def _finish_recording(self) -> None:
|
||||
samples = list(self._record_samples)
|
||||
self._recording = False
|
||||
self._record_samples = []
|
||||
def on_record_finished(self, point) -> None:
|
||||
self.progress.setValue(1000)
|
||||
self.progress.setFormat("")
|
||||
self._refresh_enabled()
|
||||
if len(samples) < 2:
|
||||
self.status_message.emit(t("cal.record_failed_few"))
|
||||
self.add_point(point)
|
||||
|
||||
def poll_recorder(self) -> None:
|
||||
"""Mirror the recorder's progress; the recorder owns the deadline."""
|
||||
if not self._recording:
|
||||
return
|
||||
mean = sum(samples) / len(samples)
|
||||
var = sum((x - mean) ** 2 for x in samples) / len(samples)
|
||||
std = math.sqrt(var)
|
||||
sample = CalSample(
|
||||
weight_n=float(self.weight_spin.value()),
|
||||
raw_mean=mean,
|
||||
n=len(samples),
|
||||
std=std,
|
||||
)
|
||||
self._store.upsert(self._channel, sample)
|
||||
self._refresh_table()
|
||||
self.status_message.emit(
|
||||
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
|
||||
self.set_progress(
|
||||
self._recorder.progress(),
|
||||
self._recorder.elapsed_s(),
|
||||
self._recorder.requested_duration_s,
|
||||
)
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
@@ -410,16 +433,17 @@ class CalPanel(QFrame):
|
||||
meta["sensor_type"] = self._sensor_type
|
||||
meta["sn"] = self.sn_edit.text().strip()
|
||||
meta["record_duration_s"] = float(self.duration_spin.value())
|
||||
local_bundle = sensors_dir() / self._sensor_type
|
||||
if not local_bundle.is_dir():
|
||||
local_bundle = None
|
||||
try:
|
||||
primary, local_path, warnings = export_force_calibration_v2(
|
||||
# Write into the user's calibration directory only. Overwriting the
|
||||
# device bundle under config/sensors/<type>/ is now an explicit,
|
||||
# separate step so that exporting cannot silently change what the
|
||||
# next Sensor() will load.
|
||||
primary, _local_path, warnings = export_force_calibration_v2(
|
||||
self._store,
|
||||
export_dir=calibration_export_dir(),
|
||||
sensor_type=self._sensor_type,
|
||||
meta=meta,
|
||||
local_bundle_dir=local_bundle,
|
||||
local_bundle_dir=None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
QMessageBox.warning(self, t("panels.cal"), str(exc))
|
||||
@@ -429,12 +453,9 @@ class CalPanel(QFrame):
|
||||
return
|
||||
|
||||
msg = t("cal.export_ok", path=str(primary))
|
||||
if local_path is not None:
|
||||
msg += "\n" + t("cal.export_local", path=str(local_path))
|
||||
if warnings:
|
||||
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
|
||||
self.status_message.emit(msg)
|
||||
# Prefer the bundle file for live apply so the next Sensor() also sees it.
|
||||
apply_path = local_path if local_path is not None else primary
|
||||
self.calibration_exported.emit(str(apply_path))
|
||||
# Ask the window to confirm the target device before applying.
|
||||
self.calibration_exported.emit(str(primary))
|
||||
QMessageBox.information(self, t("panels.cal"), msg)
|
||||
|
||||
@@ -235,18 +235,22 @@ class ForcePlotsPanel(QFrame):
|
||||
*,
|
||||
unit: str = "raw",
|
||||
) -> None:
|
||||
self._unit = unit
|
||||
if unit != self._unit:
|
||||
# Never mix units in one buffer. Callers switch units through
|
||||
# ``set_units``/``set_fixed_newton`` which rebuild from the session
|
||||
# store; a bare change here would silently reinterpret old samples.
|
||||
self._unit = unit
|
||||
self._last_values = (float(fn), float(fx), float(fy))
|
||||
use_fixed = self._uses_fixed_y()
|
||||
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
|
||||
self.history.push(timestamp, plot_fn, float(fx), float(fy))
|
||||
# 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()
|
||||
if use_fixed:
|
||||
values[:, 0] = np.maximum(values[:, 0], 0.0)
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
@@ -258,6 +262,50 @@ class ForcePlotsPanel(QFrame):
|
||||
self.plots[i].getAxis("left").setTicks(None)
|
||||
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||
|
||||
def set_units(self, unit: str, store=None) -> None:
|
||||
"""Switch display units without mixing units in the curve buffer.
|
||||
|
||||
When a session store is supplied the visible window is rebuilt from it,
|
||||
because it keeps both the raw and the calibrated triplet for every
|
||||
sample. Otherwise the buffer is cleared rather than reinterpreted.
|
||||
"""
|
||||
unit = "N" if str(unit).upper().startswith("N") else "raw"
|
||||
if unit == self._unit and store is None:
|
||||
return
|
||||
self._unit = unit
|
||||
self.history.clear()
|
||||
self._clear_curves()
|
||||
if store is not None:
|
||||
self._reload_from_store(store)
|
||||
self._render_value_cards()
|
||||
self._apply_axis_mode()
|
||||
|
||||
def _reload_from_store(self, store) -> None:
|
||||
"""Re-populate the visible buffer from the whole-session store."""
|
||||
window = self.history.window_sec
|
||||
slice_ = store.tail(window) if window > 0 else store.data()
|
||||
if len(slice_) == 0:
|
||||
return
|
||||
values = slice_.value_for_unit(self._unit)
|
||||
for index in range(len(slice_)):
|
||||
if not bool(slice_.valid[index]):
|
||||
continue
|
||||
row = values[index]
|
||||
if not all(np.isfinite(v) for v in row):
|
||||
continue
|
||||
self.history.push(
|
||||
float(slice_.times[index]),
|
||||
float(row[0]),
|
||||
float(row[1]),
|
||||
float(row[2]),
|
||||
)
|
||||
state = self.history.state()
|
||||
if state is None:
|
||||
return
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, state.values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
|
||||
def _uses_fixed_y(self) -> bool:
|
||||
"""Fixed Y whenever we have limits for the active unit.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import ui_check
|
||||
from tests.conftest import visible_descendants
|
||||
|
||||
RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080))
|
||||
@@ -196,7 +197,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
|
||||
|
||||
@@ -279,3 +280,152 @@ def test_force_plots_render_demo_data(make_window):
|
||||
QApplication.processEvents()
|
||||
assert window.force_plots.history.sample_count > 0
|
||||
assert all(label.text() for label in window.force_plots.value_labels)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- record channel
|
||||
|
||||
|
||||
def _running_window(make_window):
|
||||
"""A window in the ``running`` phase whose worker never opens a device.
|
||||
|
||||
The Qt timer is stopped deliberately: these tests drive the display path by
|
||||
calling ``_on_run_tick`` themselves, and a concurrent 16 ms timer would race
|
||||
the assertions (and outlive the window on teardown).
|
||||
"""
|
||||
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window._start_run()
|
||||
window._run_timer.stop()
|
||||
window._set_phase("running")
|
||||
return window, stuck
|
||||
|
||||
|
||||
def _finish(window, stuck):
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
|
||||
|
||||
def test_record_channel_does_not_depend_on_the_ui_timer(make_window):
|
||||
window, stuck = _running_window(make_window)
|
||||
for index in range(90):
|
||||
stuck.emit(ui_check._fake_packet(index))
|
||||
|
||||
assert window.session_store.count == 90, "samples were lost without a UI tick"
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_invalid_samples_are_excluded_not_zeroed(make_window):
|
||||
import math
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
for index in range(9):
|
||||
stuck.emit(ui_check._fake_packet(index))
|
||||
stuck.emit(ui_check._fake_packet(99, valid=False, reason="read failed"))
|
||||
|
||||
slice_ = window.session_store.data()
|
||||
assert slice_.valid.tolist()[-1] is False
|
||||
assert math.isnan(slice_.raw[-1][0]), "a failed readout became a number"
|
||||
assert sum(1 for _ in window.session_store.iter_valid("raw")) == 9
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_calibration_sampling_survives_a_hidden_panel(make_window):
|
||||
window, stuck = _running_window(make_window)
|
||||
window._show_cal = False
|
||||
window._apply_panel_visibility()
|
||||
assert not window.cal_panel.isVisible()
|
||||
|
||||
window.cal_recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0)
|
||||
for index in range(40):
|
||||
stuck.emit(ui_check._fake_packet(index))
|
||||
point = window.cal_recorder.finish_now()
|
||||
|
||||
assert point is not None
|
||||
assert point.n_valid == 40, "hiding the panel stopped the sampling"
|
||||
assert point.status == "ok"
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_record_button_stays_a_cancel_affordance(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
window.cal_panel.set_running(True)
|
||||
window.cal_panel._on_record_clicked()
|
||||
|
||||
assert window.cal_panel._recording
|
||||
assert window.cal_panel.btn_record.isEnabled(), "the recording cannot be stopped"
|
||||
assert window.cal_panel.btn_record.text() == t("cal.recording_stop")
|
||||
|
||||
window.cal_panel._on_record_clicked()
|
||||
assert not window.cal_panel._recording
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_unit_switch_rebuilds_from_the_session_store(make_window):
|
||||
import math
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
window.toolbar.units_combo.setCurrentIndex(1) # raw
|
||||
window.force_plots.reset()
|
||||
for index in range(60):
|
||||
stuck.emit(ui_check._fake_packet(index))
|
||||
window._on_run_tick()
|
||||
|
||||
state = window.force_plots.history.state()
|
||||
assert float(state.values[:, 0].max()) > 5.0, "raw values were not plotted"
|
||||
|
||||
window.toolbar.units_combo.setCurrentIndex(0) # newton
|
||||
assert window.force_plots._unit == "N"
|
||||
newtons = window.force_plots.history.state().values[:, 0]
|
||||
finite = [v for v in newtons if math.isfinite(v)]
|
||||
assert finite and max(finite) < 5.0, "the buffer still held raw values in N mode"
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_history_keeps_negative_fn(make_window):
|
||||
window, stuck = _running_window(make_window)
|
||||
window.force_plots.set_fixed_newton(True)
|
||||
for index in range(20):
|
||||
window.force_plots.push(index / 30.0, -2.5, 0.0, 0.0, unit="N")
|
||||
window.force_plots.push(1.0, -3.0, 0.0, 0.0, unit="N")
|
||||
|
||||
state = window.force_plots.history.state()
|
||||
assert float(state.values[:, 0].min()) < 0.0, (
|
||||
"negative FN was clamped before entering the buffer"
|
||||
)
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
|
||||
import numpy as np
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
window.arrow_panel.set_view_mode("depth")
|
||||
|
||||
window._last_rendered_view_mode = None
|
||||
stuck.emit(ui_check._fake_packet(0, depth=None))
|
||||
window._on_run_tick()
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
assert window.arrow_panel.image_label.text() == t("panels.depth_unavailable"), (
|
||||
"a missing depth map silently fell back to the processed frame"
|
||||
)
|
||||
|
||||
window._last_rendered_view_mode = None
|
||||
stuck.emit(ui_check._fake_packet(1, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8)))
|
||||
window._on_run_tick()
|
||||
latched = window.arrow_panel.depth_scale_label.text()
|
||||
assert latched
|
||||
|
||||
window._last_rendered_view_mode = None
|
||||
stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8)))
|
||||
window._on_run_tick()
|
||||
assert window.arrow_panel.depth_scale_label.text() == latched, (
|
||||
"the depth colour scale re-fitted on every frame"
|
||||
)
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO_ROOT = APP_ROOT.parents[1]
|
||||
for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"):
|
||||
@@ -32,6 +31,8 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
||||
os.chdir(APP_ROOT)
|
||||
|
||||
from orisys_app_shared.acquisition.sinks import RecordSinkMixin # noqa: E402
|
||||
|
||||
FAILURES: list[str] = []
|
||||
CHECKS = 0
|
||||
|
||||
@@ -47,25 +48,79 @@ def section(name: str) -> None:
|
||||
print(f"\n== {name} ==")
|
||||
|
||||
|
||||
class _StuckWorker:
|
||||
def _fake_packet(
|
||||
frame_id: int,
|
||||
*,
|
||||
valid: bool = True,
|
||||
depth=None,
|
||||
reason: str = "",
|
||||
):
|
||||
"""A packet shaped like the real worker's, for the record-channel checks."""
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
|
||||
|
||||
nan = float("nan")
|
||||
blank = np.zeros((8, 8, 3), np.uint8)
|
||||
return SdkPacket(
|
||||
raw_frame=blank,
|
||||
processed_frame=blank,
|
||||
timestamp=1000.0 + frame_id / 30.0,
|
||||
fps=30.0,
|
||||
process_ms=1.0,
|
||||
normal_force=(2.0 + frame_id * 0.01) if valid else nan,
|
||||
shear_x=0.5 if valid else nan,
|
||||
shear_y=-0.5 if valid else nan,
|
||||
normal_force_raw=(9.0 + frame_id * 0.1) if valid else nan,
|
||||
shear_x_raw=0.4 if valid else nan,
|
||||
shear_y_raw=-0.4 if valid else nan,
|
||||
force_unit="N",
|
||||
force_calibrated=True,
|
||||
session_id=1,
|
||||
frame_id=frame_id,
|
||||
valid=valid,
|
||||
invalid_reason=reason,
|
||||
raw_available=True,
|
||||
depth=depth,
|
||||
)
|
||||
|
||||
|
||||
class _StuckWorker(RecordSinkMixin):
|
||||
"""A worker whose device open never returns.
|
||||
|
||||
Mirrors the real worker's non-blocking ``stop()`` contract so the window can
|
||||
be exercised against the failure mode the Stop button exists to survive.
|
||||
Mirrors the real worker contract — including the record-channel sink API —
|
||||
so the window can be exercised against the failure mode the Stop button
|
||||
exists to survive.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._init_sinks()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._end_reason: str | None = None
|
||||
self._latest = None
|
||||
self.thread_still_owned = False
|
||||
self.stop_blocked_for = 0.0
|
||||
#: Released by ``release()`` so daemon threads do not outlive the run.
|
||||
self._release = threading.Event()
|
||||
|
||||
def start(self, _source, *, config_ref=None) -> None:
|
||||
self._release.clear()
|
||||
self._thread = threading.Thread(target=self._hang, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _hang(self) -> None:
|
||||
time.sleep(600.0)
|
||||
self._release.wait(timeout=600.0)
|
||||
|
||||
def release(self) -> None:
|
||||
"""Let the stuck thread finish so the process can exit cleanly."""
|
||||
self._release.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5.0)
|
||||
|
||||
def emit(self, packet) -> None:
|
||||
"""Publish a packet exactly like the acquisition loop would."""
|
||||
self._latest = packet
|
||||
self._emit(packet)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -76,7 +131,7 @@ class _StuckWorker:
|
||||
return "connecting"
|
||||
|
||||
def latest(self):
|
||||
return None
|
||||
return self._latest
|
||||
|
||||
def consume_end_reason(self):
|
||||
reason, self._end_reason = self._end_reason, None
|
||||
@@ -559,6 +614,110 @@ def main() -> int:
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
# Let the deliberately-stuck thread finish before the interpreter tears
|
||||
# down Qt, otherwise it would outlive the widgets it can reach.
|
||||
stuck.release()
|
||||
|
||||
section("record channel")
|
||||
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.toolbar.btn_run.setChecked(True)
|
||||
app.processEvents()
|
||||
|
||||
check(
|
||||
window._on_packet_recorded in stuck.sinks(),
|
||||
"the record channel is not attached to the running worker",
|
||||
)
|
||||
|
||||
# The record channel must not depend on the UI refresh timer at all.
|
||||
for index in range(90):
|
||||
stuck.emit(_fake_packet(index))
|
||||
check(
|
||||
window.session_store.count == 90,
|
||||
f"record channel dropped samples without a UI tick: {window.session_store.count}",
|
||||
)
|
||||
|
||||
# Invalid readouts must never be averaged in as zeros.
|
||||
for index in range(10):
|
||||
stuck.emit(_fake_packet(200 + index, valid=False, reason="read failed"))
|
||||
slice_ = window.session_store.data()
|
||||
check(int(slice_.valid.sum()) == 90, "invalid samples were not marked invalid")
|
||||
check(
|
||||
not bool(slice_.valid[-1]) and slice_.raw[-1][0] != slice_.raw[-1][0],
|
||||
"a failed readout was stored as a number instead of NaN",
|
||||
)
|
||||
|
||||
# Calibration sampling must survive the panel being hidden.
|
||||
window._show_cal = False
|
||||
window._apply_panel_visibility()
|
||||
check(not window.cal_panel.isVisible(), "the calibration panel is still visible")
|
||||
window.cal_recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0)
|
||||
for index in range(40):
|
||||
stuck.emit(_fake_packet(300 + index))
|
||||
point = window.cal_recorder.finish_now()
|
||||
check(point is not None and point.n_valid == 40, "hidden panel stopped the sampling")
|
||||
check(
|
||||
point is not None and point.status == "ok",
|
||||
f"calibration point was not usable: {getattr(point, 'status', None)}",
|
||||
)
|
||||
|
||||
# While recording, the record button must stay enabled — it is the cancel.
|
||||
window.cal_panel.set_running(True)
|
||||
window.cal_panel._on_record_clicked()
|
||||
app.processEvents()
|
||||
check(window.cal_panel._recording, "recording did not start")
|
||||
check(
|
||||
window.cal_panel.btn_record.isEnabled(),
|
||||
"the record button is disabled while recording, so it cannot cancel",
|
||||
)
|
||||
check(
|
||||
window.cal_panel.btn_record.text() == t("cal.recording_stop"),
|
||||
f"record button does not show the stop label: {window.cal_panel.btn_record.text()!r}",
|
||||
)
|
||||
window.cal_panel._on_record_clicked()
|
||||
app.processEvents()
|
||||
check(not window.cal_panel._recording, "the recording could not be cancelled")
|
||||
|
||||
# The depth view must not fall back to the processed frame.
|
||||
window.arrow_panel.set_view_mode("depth")
|
||||
window._last_rendered_view_mode = None
|
||||
stuck.emit(_fake_packet(400, depth=None))
|
||||
window._on_run_tick()
|
||||
app.processEvents()
|
||||
check(
|
||||
window.arrow_panel.image_label.text() == t("panels.depth_unavailable"),
|
||||
f"missing depth was not reported: {window.arrow_panel.image_label.text()!r}",
|
||||
)
|
||||
window._last_rendered_view_mode = None
|
||||
stuck.emit(_fake_packet(401, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8)))
|
||||
window._on_run_tick()
|
||||
first_scale = window.arrow_panel.depth_scale_label.text()
|
||||
window._last_rendered_view_mode = None
|
||||
stuck.emit(_fake_packet(402, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8)))
|
||||
window._on_run_tick()
|
||||
check(
|
||||
window.arrow_panel.depth_scale_label.text() == first_scale and first_scale != "",
|
||||
"the depth colour scale re-fitted per frame instead of staying fixed",
|
||||
)
|
||||
|
||||
window.close()
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
|
||||
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
|
||||
for failure in FAILURES:
|
||||
print(f" FAIL: {failure}")
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Record-channel behaviour: full-session storage and calibration sampling.
|
||||
|
||||
These cover the guarantees the architect's review asked for:
|
||||
|
||||
* a failed readout is never averaged in as a zero,
|
||||
* the stored session is not limited to what the plots happened to show,
|
||||
* both raw and calibrated values are kept, so switching units cannot
|
||||
reinterpret old data,
|
||||
* calibration sampling is driven by the acquisition stream rather than by any
|
||||
widget being visible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.force.calibration_recorder import (
|
||||
STATUS_FEW_SAMPLES,
|
||||
STATUS_OK,
|
||||
CalibrationRecorder,
|
||||
)
|
||||
from orisys_app_shared.force.session import NA, SessionSample, SessionStore
|
||||
|
||||
|
||||
def _sample(index: int, *, valid: bool = True, rate: float = 30.0) -> SessionSample:
|
||||
if not valid:
|
||||
return SessionSample(frame_id=index, t_rel_s=index / rate, valid=False)
|
||||
return SessionSample(
|
||||
frame_id=index,
|
||||
t_rel_s=index / rate,
|
||||
fn_raw=float(index),
|
||||
fx_raw=1.0,
|
||||
fy_raw=-1.0,
|
||||
fn_n=index / 10.0,
|
||||
fx_n=0.1,
|
||||
fy_n=-0.1,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- SessionStore
|
||||
|
||||
|
||||
def test_store_keeps_whole_session_and_reports_evictions():
|
||||
store = SessionStore(max_samples=100, block_size=16)
|
||||
store.reset(session_id=3)
|
||||
for index in range(250):
|
||||
store.append(_sample(index))
|
||||
|
||||
assert store.session_id == 3
|
||||
assert store.count <= 100
|
||||
assert store.dropped > 0, "evictions must be visible instead of silent"
|
||||
assert store.data().truncated is True
|
||||
|
||||
|
||||
def test_store_keeps_raw_and_newton_side_by_side():
|
||||
store = SessionStore()
|
||||
store.reset()
|
||||
store.append(_sample(0))
|
||||
store.append(_sample(1))
|
||||
|
||||
slice_ = store.data()
|
||||
assert not (slice_.value_for_unit("N") == slice_.value_for_unit("raw")).all()
|
||||
assert slice_.value_for_unit("raw")[1][0] == 1.0
|
||||
assert slice_.value_for_unit("N")[1][0] == 0.1
|
||||
|
||||
|
||||
def test_invalid_samples_are_stored_as_invalid_not_zero():
|
||||
store = SessionStore()
|
||||
store.reset()
|
||||
store.append(_sample(0))
|
||||
store.append(_sample(1, valid=False))
|
||||
|
||||
slice_ = store.data()
|
||||
assert slice_.valid.tolist() == [True, False]
|
||||
assert math.isnan(slice_.raw[1][0]), "a failed readout must not become 0.0"
|
||||
assert store.invalid == 1
|
||||
# And it must not leak into anything that computes statistics.
|
||||
assert [t for t, *_ in store.iter_valid("raw")] == [0.0]
|
||||
|
||||
|
||||
def test_store_window_queries_are_relative_to_the_first_sample():
|
||||
store = SessionStore()
|
||||
store.reset()
|
||||
for index in range(120):
|
||||
store.append(_sample(index))
|
||||
|
||||
tail = store.tail(1.0)
|
||||
assert int(tail.frame_ids[-1]) == 119, "tail must end at the newest sample"
|
||||
assert float(tail.times[-1]) - float(tail.times[0]) <= 1.0 + 1e-9
|
||||
assert len(tail) == pytest.approx(30, abs=1) # inclusive boundary
|
||||
|
||||
first_second = store.window(0.0, 1.0)
|
||||
assert int(first_second.frame_ids[0]) == 0
|
||||
assert float(first_second.times[-1]) <= 1.0 + 1e-9
|
||||
assert float(first_second.times[0]) == 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------- CalibrationRecorder
|
||||
|
||||
|
||||
def _recorder(**kwargs) -> CalibrationRecorder:
|
||||
return CalibrationRecorder(**kwargs)
|
||||
|
||||
|
||||
def test_recorder_excludes_failed_samples_from_the_statistics():
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0)
|
||||
for index in range(50):
|
||||
recorder.feed(_sample(index))
|
||||
for index in range(3):
|
||||
recorder.feed(_sample(100 + index, valid=False))
|
||||
|
||||
point = recorder.finish_now()
|
||||
assert point is not None
|
||||
assert point.n_total == 53
|
||||
assert point.n_valid == 50, "failed readouts must not be counted as samples"
|
||||
assert point.status == STATUS_OK
|
||||
# 9.7-style values are absent here; the mean must come only from 0..49.
|
||||
assert point.loaded_stats().mean == pytest.approx(24.5, abs=0.01)
|
||||
|
||||
|
||||
def test_recorder_captures_all_three_axes_and_keeps_diagnostics():
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0, direction="unload")
|
||||
for index in range(10):
|
||||
recorder.feed(_sample(index))
|
||||
point = recorder.finish_now()
|
||||
|
||||
assert set(point.raw) == {"fn", "fx", "fy"}
|
||||
assert point.raw["fx"].mean == pytest.approx(1.0)
|
||||
assert point.raw["fy"].mean == pytest.approx(-1.0)
|
||||
assert point.newtons["fn"].mean == pytest.approx(0.45, abs=0.01)
|
||||
assert point.direction == "unload"
|
||||
# Only the loaded axis has a known reference load.
|
||||
assert point.reference_load_n == 1.0
|
||||
|
||||
|
||||
def test_recorder_marks_too_few_samples_instead_of_guessing():
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fx", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
point = recorder.finish_now()
|
||||
assert point.status == STATUS_FEW_SAMPLES
|
||||
|
||||
|
||||
def test_recorder_finishes_on_the_deadline_from_the_ui_thread():
|
||||
clock = {"now": 0.0}
|
||||
recorder = _recorder(clock=lambda: clock["now"])
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.feed(_sample(1))
|
||||
|
||||
assert recorder.tick() is None, "must not finish before the deadline"
|
||||
clock["now"] = 2.0
|
||||
point = recorder.tick()
|
||||
assert point is not None
|
||||
assert recorder.is_recording is False
|
||||
assert recorder.tick() is None
|
||||
|
||||
|
||||
def test_recorder_is_fed_from_a_foreign_thread():
|
||||
"""The acquisition thread feeds it; nothing here touches Qt."""
|
||||
recorder = _recorder()
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=0.05)
|
||||
|
||||
def producer():
|
||||
for index in range(5000):
|
||||
recorder.feed(_sample(index))
|
||||
|
||||
thread = threading.Thread(target=producer)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
time.sleep(0.06)
|
||||
point = recorder.tick()
|
||||
assert point is not None
|
||||
assert point.n_valid == 5000
|
||||
|
||||
|
||||
def test_recorder_cancel_discards_the_point():
|
||||
cancelled = []
|
||||
recorder = _recorder(on_cancelled=lambda: cancelled.append(True))
|
||||
recorder.start(channel="fy", reference_load_n=3.0, duration_s=5.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.cancel()
|
||||
|
||||
assert recorder.is_recording is False
|
||||
assert cancelled == [True]
|
||||
assert recorder.tick() is None
|
||||
assert recorder.points == []
|
||||
|
||||
|
||||
def test_recorder_round_index_advances_per_channel():
|
||||
recorder = _recorder()
|
||||
for _ in range(2):
|
||||
recorder.start(channel="fn", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.feed(_sample(1))
|
||||
recorder.finish_now()
|
||||
recorder.start(channel="fx", reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.feed(_sample(1))
|
||||
point = recorder.finish_now()
|
||||
|
||||
assert [p.round_index for p in recorder.points] == [0, 1, 0]
|
||||
assert point.channel == "fx"
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual runner
|
||||
failures = 0
|
||||
for name, function in sorted(globals().items()):
|
||||
if not name.startswith("test_") or not callable(function):
|
||||
continue
|
||||
try:
|
||||
function()
|
||||
print(f" ok {name}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
|
||||
print(f"failures: {failures}")
|
||||
raise SystemExit(1 if failures else 0)
|
||||
Reference in New Issue
Block a user