Browse a long recording from disk instead of loading it into memory

What
- record/index.py: RecordingIndex streams forces.csv once and keeps only the
  small things — one time, one validity flag, one frame id per row plus a byte
  offset every 4096 rows (~6.5 MB per 500k rows). row_range() bisects the times,
  read_rows() seeks to the nearest checkpoint and scans forward, and overview()
  folds every row into its bucket during the same pass to build a per-axis
  min/max envelope.
- read_rows(max_points, unit) decimates **while streaming**: each bucket keeps
  the extreme row of every axis in the requested unit, so a long interval never
  has to exist in memory in full and a shear spike survives a flat normal channel.
- RecordingReader: info() decimates above max_rows (default 200k) and marks
  itself decimated, with total_rows and a duration taken from the frame index
  rather than from the view; window()/window_all()/overview()/statistics() read
  the interval being asked about. list/window semantics unchanged for small
  recordings.
- force/source.py: StoreCurveSource (live memory) and RecordingCurveSource (disk
  interval + cached envelope) implement one interface — extent, window,
  envelope, statistics, duration_s — so ForcePlotsPanel asks a *source* instead
  of knowing where the data lives.
- force_plots: reads through the source (window, envelope, statistics, extent),
  which also replaces its last direct store accesses.
- playback_dialog: draws the whole-session view through window_all() in the unit
  being shown (bounded by CHART_MAX_POINTS), and says so when the view is
  decimated ("显示 N / 共 M 行") instead of letting the operator count points.
- experiment._run: a failure that lands while the files are opening can no longer
  be overwritten by the writer's own transition to "recording" (found by a flaky
  test; the state check now covers STATE_FAILED).

Why
The store releases old blocks above ~4.6 h and the replay loaded every CSV row
into Python lists before drawing anything, so neither the live browser nor the
replay could show an earlier interval of a long recording. The index makes the
disk the data source and memory a cache of what is on screen.

Verification
- shared/tests/test_history_source.py (new, 8 cases) over a 12 000-row recording:
  the index reports its rows and reads a bounded interval; a large recording is
  marked decimated with its true duration and total rows; a small one is not; an
  interval read sees the exact rows including a single peak; a decimated window
  keeps the peak; the envelope fits the whole session in ≤200 points, keeps the
  spike and is cached; the disk and memory sources agree on extent, values and
  statistics; the whole session can be read without holding it all. Shared
  suites: 146 tests, 0 failures (three consecutive runs).
- apps/studio/tools/ui_check.py: the replay section now checks the index row
  count, that an interval read spans only the interval, the envelope size and
  total_rows. 324 checks, 0 failures.
- apps/studio/tests/test_ui_smoke.py: 43 tests, 36 pass; the 7 failures are the
  unchanged sandbox/harness artifacts.
- D:/orisys_ws/_review_phase12/reproduce.py: all ten lines unchanged;
  compileall, locale and prefs parity clean.
- apps/studio/docs/record-channel.md §8 documents the index, the reader API and
  the unified source, and lists what is still hardware work.

Not in this change
- Maximum RSS / backlog / loss measurements at real resolution on the target ARM
  board (needs the hardware), and range CSV export.
This commit is contained in:
ykcheungab
2026-09-13 16:54:26 +08:00
parent 22cc59c402
commit 73592f085f
40 changed files with 6398 additions and 1070 deletions
+19 -1
View File
@@ -115,6 +115,8 @@
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
"selection": "选区 {duration}s / {samples} 点 — {detail}",
"selection_empty": "选区无有效样本",
"value_unavailable": "不可用",
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
},
"layout": {
@@ -154,14 +156,21 @@
"depth_state_approximate": "本段缺少归零或预卷历史,算法参考状态为近似重建",
"depth_warnings": "重算提示: {detail}",
"warnings": "回放提示: {detail}",
"decimated": "该录制较长,曲线为降采样视图(显示 {shown} / 共 {total} 行;区间数据按需从磁盘读取)",
"warn_config_changed": "配置文件与录制时不一致,重算出的深度可能与当时不同",
"warn_sdk_changed": "SDK 版本与录制时不一致,重算结果可能不同",
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
"warn_depth_recomputed": "深度由离线重算得到,不是当时保存的数据",
"warn_rows_skipped": "有无法解析的 CSV 行被跳过",
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同",
"warn_state_across_segment": "重算不能跨分段延续算法状态",
"warn_state_approximate": "算法参考状态为近似重建"
"warn_state_approximate": "算法参考状态为近似重建",
"warn_state_approximate_after_rewind": "为回到更早的帧重新打开了视频,参考状态可能偏离当时",
"warn_state_too_far_back": "归零点过于久远,需要读取的帧数超出上限,已按近似状态重建",
"warn_reset_frame_not_indexed": "归零帧不在帧索引中,无法从该点重建状态",
"warn_reset_frame_not_in_video": "归零帧未写入视频,无法从该点重建状态",
"warn_reset_not_applied": "重放无法重新执行归零,参考状态可能偏离当时"
},
"record": {
"label": "记录",
@@ -177,6 +186,8 @@
"start_failed": "无法开始记录: {error}",
"finished": "记录完成 — {frames} 帧 / {segments} 段,已写入 {path}",
"finished_with_problems": "记录完成,但存在问题: {detail}",
"closing": "正在关闭记录文件…",
"failed_state": "记录已失败(详见摘要与日志)",
"alarm": "记录异常: {detail}"
},
"cal": {
@@ -204,8 +215,12 @@
"open_record_tip": "重新打开已保存的标定会话,检查每个点的完整采样与诊断状态",
"open_record_filter": "标定记录 (*.json)",
"open_record_ok": "已载入 {path}({points} 个点)",
"open_record_device": "记录设备: {device}",
"open_record_warnings": "注意: {detail}",
"open_record_failed": "打开标定记录失败: {error}",
"apply_mismatch_title": "标定与目标设备不匹配",
"apply_mismatch": "该标定文件属于: {device}\n不匹配项: {detail}\n\n当前目标: {sensor_type} / {config}\n\n继续应用会把不适用于本设备的系数写入运行中的设备。",
"apply_mismatch_skipped": "已取消应用(标定身份与目标设备不匹配)",
"sn": "序列号",
"sn_placeholder": "序列号(可选)",
"live_raw_label": "实时原始值",
@@ -255,11 +270,13 @@
"export_local": "同时写入 {path}",
"export_warnings": "跳过: {detail}",
"export_failed": "导出失败: {error}",
"export_no_model": "没有可导出的模型: {detail}",
"apply_title": "应用标定",
"apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。",
"apply_skipped": "已导出,未应用到设备",
"apply_pending": "已请求把标定应用到 {sensor_type},等待设备确认…",
"applied": "设备已确认标定生效(版本 {version})",
"applied_none": "设备已确认:当前没有加载标定(该项留空)",
"export_only": "已导出系数 {path}(未应用到设备)",
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
},
@@ -286,6 +303,7 @@
"video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"reset_pending": "已请求归零,等待采集线程确认…",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",