Files
orisys-app/apps/studio/docs/record-channel.md
T

7.3 KiB
Raw Blame History

记录通道与数据可靠性

本文记录 Studio 在"完整记录、数据可靠优先"这轮改动中的架构决定,以及针对架构评审 第 8 节所列缺陷的修法与验证方式。

核心结论是:标定采样、全程历史、无效样本隔离之所以会出问题,根因是同一处架构缺口 —— 采集数据只有一个出口,就是 16 ms 的 UI 定时器。


1. 改动前的问题链

worker 线程 ──> self._latest(单槽,后写覆盖)
                        │
        UI 定时器 _on_run_tick(16 ms)──┬──> 曲线历史(只保留窗口内样本)
                                        ├──> 标定采样(且被 if self._show_cal 守卫)
                                        └──> 箭头/深度渲染

由此派生出评审指出的多个缺陷,它们不是彼此独立的问题:

现象 机制
标定依赖面板可见 app_window.py 中 push_raw 被 if self._show_cal: 守卫,而它是唯一检查录制截止时间的地方
标定录制中无法停止 push_raw 永不执行 → _finish_recording 永不调用 → _recording 恒为真 → 而 _recording 又让录制按钮禁用。两次点击都无法脱困
历史被窗口裁掉 MetricHistory._prune() 主动丢弃 t < t_end - window_sec 的样本
面板未刷新时丢数据 单槽 _latest 后写覆盖,UI 降帧即丢样本

2. 现在的结构

worker 线程 ──> self._latest(显示,可有损)
     │
     └──> _emit(packet) ──> 记录 sink(记录,不可丢)
                              │
        app_window._on_packet_recorded(运行在采集线程,不碰 Qt)
                              ├──> SessionStore    全程存储
                              └──> CalibrationRecorder.feed()  标定采样缓冲

UI 线程 _on_run_tick ──> 曲线显示(仍可降帧)
                    └──> CalibrationRecorder.tick()  到点后结算(Qt 回调留在 UI 线程)

分工原则:

  • feed() 在采集线程调用,只往线程安全的缓冲里追加,绝不触碰 Qt 控件。因此 隐藏标定面板、曲线重绘变慢、窗口后台化都不会中断记录。
  • tick() 在 UI 线程调用,负责判定截止时间、计算统计量并触发回调 —— Qt 信号与 控件更新必须留在 UI 线程。
  • sink 是普通 callable 列表,某个 sink 抛异常会被记录并只影响该 packet,不会杀掉采集。

关键文件

文件 职责
shared/.../acquisition/sinks.py RecordSinkMixin:sink 注册与扇出,两个 worker 共用
shared/.../force/session.py SessionStore:全程存储,内存有界,双单位 + 有效性 + 帧号
shared/.../force/calibration_recorder.py 标定采样状态机,与 Qt 无关

3. SessionStore 的三条保证

  1. 不是"把窗口调大":按定长 numpy 块追加,O(1) 写入;达到 max_samples 时释放最旧 的整块并累加 dropped。dropped 由界面显式提示(status.samples_dropped), 不允许静默丢数据。
  2. 同时保存 raw 与标定后数值:每个样本存 (fn,fx,fy)_raw 与 (fn,fx,fy)_N 两套。 切换显示单位时曲线从 store 重建,而不是把新单位的值追加到旧缓冲上。
  3. 无效样本标记而不是归零:读取失败时存 NaN 且 valid=False;iter_valid() 只产出 有效样本,因此标定统计不可能把一个失败读数当作 0 平均进去。

4. 针对评审第 8 节的修法

# 问题 修法
1 标定中无法点击停止 _refresh_enabled 改为 can_record = self._running,录制中按钮保持可用并显示「停止」
2 切换 raw/N 后历史混用单位 新增 ForcePlotsPanel.set_units(unit, store):清空缓冲并从 SessionStore 按目标单位重建;时间原点保留,X 轴不会跳回 0
3 异常被当成正常数据 read_forces_from_sensor 不再吞异常:返回 valid=False + invalid_reason + NaN;SdkPacket 新增 valid / invalid_reason / raw_available;无效样本不进曲线、不进标定
4 标定依赖面板可见 采样改由 worker sink 驱动;_show_cal 只影响可见性,不再作为采样入口
5 历史 FN 被截断 去掉写入前的 max(0.0, fn) 夹值与显示前的 np.maximum;负值原样保留,越界由坐标轴范围裁剪(只影响绘制,不影响数据)
6 导出覆盖并自动应用标定 导出只写用户标定目录(local_bundle_dir=None),不再改写 config/sensors/<type>/;应用前弹窗显示目标设备类型与配置,且只下发给匹配的 worker
7 深度显示误导 depth_map_to_bgr 支持 value_range 固定色标与 invalid_mask;色标在会话内锁定到首个有效深度帧并在面板头部显示数值范围;无深度时显示明确提示,不再回退成普通形变图
8 历史只保留窗口内样本 见 §3;SessionStore 与显示用的 MetricHistory 分离

另外修正了评审未列出的一处:RAW 通道不可用时,原代码把标定后的 N 值复制进 raw 字段 (fn_raw = fn)。现改为 raw_available=False 且 raw 保持 NaN,避免"原始值"实际上是 标定值。


5. 验证

$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe"
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src"
cd D:\orisys_ws\orisys-app\apps\studio
& $py tools/ui_check.py     # 含 "record channel" 段落
  • shared/tests/test_record_channel.py:11 项,覆盖内存上界、双单位、无效隔离、 跨线程 feed、截止时间结算、取消语义。
  • shared/tests/test_worker_cancellation.py:4 项,覆盖非阻塞 stop、线程所有权、 会话交接。
  • tools/ui_check.py:新增 record channel 段落,断言记录通道在完全没有 UI 定时器 触发的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用取消入口、 深度缺失不回退、色标锁帧。
  • apps/studio/tests/test_ui_smoke.py:上述不变量各有对应 pytest 用例,复用 ui_check._StuckWorker 与 ui_check._fake_packet,保持单一来源。

已知环境限制

本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,conftest.py 的 tmp_path 因此 写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);ui_check.py 覆盖同样的不变量且可完整运行。


6. 尚未完成(第二阶段及以后)

  • MP4 分段录制、frames.csv 帧索引、session.json 元数据(评审第一阶段剩余部分)。
  • CSV 导出(session_id/frame_id/t_rel_s/FN_raw/…/valid/calibration_id)——数据模型 SessionSample 已按这些字段设计,SessionStore.iter_valid() 已可直接供导出使用。
  • 全程曲线浏览(跟随/历史、框选缩放、降采样保峰值)。
  • 标定诊断扩展:稳定后采样、异常点排除/恢复、拟合残差、独立验证点。当前 CalibrationPoint 已记录三轴均值/标准差/有效样本数、方向、轮次与实际采样时长。
  • PyOpenGL 三维显示(mesh3d_panel.py 目前仍是占位符,不是已有实现)。